jellyfin_controller.dart 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541
  1. import 'dart:async';
  2. import 'package:flutter/foundation.dart';
  3. import '../../data/models/jellyfin_models.dart';
  4. import '../../data/repositories/settings_repository.dart';
  5. import '../../data/services/jellyfin_service.dart';
  6. import '../../data/services/secure_store.dart';
  7. enum JellyfinStatus {
  8. disabled,
  9. needsSetup,
  10. connecting,
  11. pairing,
  12. connected,
  13. offline,
  14. error,
  15. }
  16. /// Owns the optional Jellyfin session. Quick Connect is the only sign-in path:
  17. /// the user approves a code in Jellyfin and CarDash keeps the returned token in
  18. /// secure storage. A rejected token disables the integration and clears it so
  19. /// the user has to pair again; an unreachable server is treated as a temporary
  20. /// outage and retried with backoff.
  21. class JellyfinController extends ChangeNotifier {
  22. JellyfinController(
  23. this._settings,
  24. this._gateway,
  25. this._secureStore, {
  26. this.pollInterval = const Duration(seconds: 3),
  27. this.pairingLifetime = const Duration(minutes: 10),
  28. this.healthInterval = const Duration(minutes: 1),
  29. List<Duration>? retryDelays,
  30. }) : retryDelays =
  31. retryDelays ??
  32. const [
  33. Duration(seconds: 5),
  34. Duration(seconds: 10),
  35. Duration(seconds: 20),
  36. Duration(seconds: 30),
  37. Duration(seconds: 60),
  38. ];
  39. static const tokenKey = 'jellyfin.accessToken';
  40. final SettingsRepository _settings;
  41. final JellyfinGateway _gateway;
  42. final SecureStore _secureStore;
  43. final Duration pollInterval;
  44. final Duration pairingLifetime;
  45. final Duration healthInterval;
  46. final List<Duration> retryDelays;
  47. bool _enabled = false;
  48. bool _needsRepair = false;
  49. bool _busy = false;
  50. bool _polling = false;
  51. bool _started = false;
  52. bool _disposed = false;
  53. JellyfinStatus _status = JellyfinStatus.disabled;
  54. String _serverUrl = '';
  55. String? _serverName;
  56. String? _userName;
  57. String? _error;
  58. String? _code;
  59. String? _secret;
  60. String? _token;
  61. String _deviceId = '';
  62. int _generation = 0;
  63. int _retryAttempt = 0;
  64. Timer? _pollTimer;
  65. Timer? _pairingTimer;
  66. Timer? _healthTimer;
  67. Timer? _retryTimer;
  68. bool get enabled => _enabled;
  69. bool get connected => _status == JellyfinStatus.connected;
  70. bool get busy => _busy;
  71. bool get pairing => _status == JellyfinStatus.pairing;
  72. bool get needsRepair => _needsRepair;
  73. bool get hasServerUrl => _serverUrl.isNotEmpty;
  74. JellyfinStatus get status => _status;
  75. String get serverUrl => _serverUrl;
  76. String? get serverName => _serverName;
  77. String? get userName => _userName;
  78. String? get error => _error;
  79. String? get quickConnectCode => _code;
  80. String get statusLabel {
  81. if (!_enabled) {
  82. return _needsRepair ? 'Pairing needed' : 'Off';
  83. }
  84. switch (_status) {
  85. case JellyfinStatus.disabled:
  86. return 'Off';
  87. case JellyfinStatus.needsSetup:
  88. return 'Not connected';
  89. case JellyfinStatus.connecting:
  90. return 'Connecting…';
  91. case JellyfinStatus.pairing:
  92. return 'Waiting for approval';
  93. case JellyfinStatus.connected:
  94. return _serverName == null ? 'Connected' : 'Connected to $_serverName';
  95. case JellyfinStatus.offline:
  96. return 'No internet — retrying';
  97. case JellyfinStatus.error:
  98. return 'Not connected';
  99. }
  100. }
  101. /// Loads persisted state. Does not touch the network; call [start] once the
  102. /// app is running so a cold start is never delayed by Jellyfin.
  103. Future<void> initialize() async {
  104. _enabled = _settings.jellyfinEnabled;
  105. _serverUrl = _settings.jellyfinServerUrl;
  106. _serverName = _settings.jellyfinServerName;
  107. _userName = _settings.jellyfinUserName;
  108. _deviceId = await _settings.ensureJellyfinDeviceId();
  109. _token = await _secureStore.read(tokenKey);
  110. if (!_enabled) {
  111. _status = JellyfinStatus.disabled;
  112. return;
  113. }
  114. if (_serverUrl.isEmpty || _token == null) {
  115. _status = JellyfinStatus.needsSetup;
  116. return;
  117. }
  118. _status = JellyfinStatus.connecting;
  119. }
  120. /// Starts the first health check. Safe to call more than once.
  121. void start() {
  122. if (_started || _disposed) return;
  123. _started = true;
  124. if (_enabled && _token != null && _serverUrl.isNotEmpty) {
  125. unawaited(_validate());
  126. }
  127. }
  128. Future<void> setEnabled(bool value) async {
  129. if (_disposed || value == _enabled) return;
  130. if (!value) {
  131. _generation++;
  132. _cancelTimers();
  133. _enabled = false;
  134. _needsRepair = false;
  135. _busy = false;
  136. _code = null;
  137. _secret = null;
  138. _error = null;
  139. _status = JellyfinStatus.disabled;
  140. await _settings.setJellyfinEnabled(false);
  141. _notify();
  142. return;
  143. }
  144. _enabled = true;
  145. _needsRepair = false;
  146. _error = null;
  147. await _settings.setJellyfinEnabled(true);
  148. if (_serverUrl.isEmpty) {
  149. _status = JellyfinStatus.needsSetup;
  150. _notify();
  151. return;
  152. }
  153. if (_token != null) {
  154. await _validate();
  155. } else {
  156. _status = JellyfinStatus.needsSetup;
  157. _notify();
  158. }
  159. }
  160. /// Saves the URL and connects. With a saved token the session is reused;
  161. /// otherwise a Quick Connect code is requested.
  162. Future<void> connect(String rawUrl) async {
  163. final normalized = displayJellyfinUrl(rawUrl);
  164. if (normalized == null) {
  165. _error = 'Enter a valid http or https server URL.';
  166. _status = _enabled ? JellyfinStatus.needsSetup : JellyfinStatus.disabled;
  167. _notify();
  168. return;
  169. }
  170. final changed = _serverUrl.isNotEmpty && _serverUrl != normalized;
  171. _serverUrl = normalized;
  172. _error = null;
  173. await _settings.setJellyfinServerUrl(normalized);
  174. if (!_enabled) {
  175. _enabled = true;
  176. await _settings.setJellyfinEnabled(true);
  177. }
  178. if (changed) {
  179. await _clearSavedSession();
  180. _serverName = null;
  181. _userName = null;
  182. }
  183. if (_token != null) {
  184. await _validate();
  185. } else {
  186. await _beginQuickConnect();
  187. }
  188. }
  189. /// Re-enables Jellyfin and starts pairing again after the token was rejected.
  190. Future<void> pairAgain() async {
  191. _needsRepair = false;
  192. _error = null;
  193. if (!_enabled) {
  194. _enabled = true;
  195. await _settings.setJellyfinEnabled(true);
  196. }
  197. if (_serverUrl.isEmpty) {
  198. _status = JellyfinStatus.needsSetup;
  199. _notify();
  200. return;
  201. }
  202. await _beginQuickConnect();
  203. }
  204. /// Forgets the server entirely. The URL and device id stay for convenience.
  205. Future<void> removePairing() async {
  206. _generation++;
  207. _cancelTimers();
  208. await _clearSavedSession();
  209. _enabled = false;
  210. _busy = false;
  211. _needsRepair = false;
  212. _error = null;
  213. _code = null;
  214. _secret = null;
  215. _serverName = null;
  216. _userName = null;
  217. _status = JellyfinStatus.disabled;
  218. await _settings.setJellyfinEnabled(false);
  219. _notify();
  220. }
  221. /// Stops the current Quick Connect attempt without disabling the section.
  222. void cancelPairing() {
  223. if (_status != JellyfinStatus.pairing) return;
  224. _generation++;
  225. _cancelTimers();
  226. _code = null;
  227. _secret = null;
  228. _error = null;
  229. _busy = false;
  230. _status = JellyfinStatus.needsSetup;
  231. _notify();
  232. }
  233. /// Manual retry used by the card and when the app returns to the foreground.
  234. Future<void> checkNow() async {
  235. if (_disposed || !_enabled) return;
  236. if (_status == JellyfinStatus.pairing) {
  237. await _poll(_generation);
  238. return;
  239. }
  240. if (_token != null) {
  241. await _validate();
  242. } else if (_serverUrl.isNotEmpty) {
  243. await _beginQuickConnect();
  244. }
  245. }
  246. void onAppResumed() {
  247. if (_disposed || !_enabled) return;
  248. if (_status == JellyfinStatus.connected ||
  249. _status == JellyfinStatus.connecting) {
  250. return;
  251. }
  252. unawaited(checkNow());
  253. }
  254. Future<void> _beginQuickConnect() async {
  255. final base = normalizeJellyfinUrl(_serverUrl);
  256. if (base == null) {
  257. _error = 'Enter a valid http or https server URL.';
  258. _status = JellyfinStatus.needsSetup;
  259. _notify();
  260. return;
  261. }
  262. final generation = ++_generation;
  263. _cancelTimers();
  264. _busy = true;
  265. _code = null;
  266. _secret = null;
  267. _error = null;
  268. _status = JellyfinStatus.connecting;
  269. _notify();
  270. try {
  271. final info = await _gateway.serverInfo(base);
  272. if (_stale(generation)) return;
  273. _serverName = info.name;
  274. await _settings.setJellyfinServerName(info.name);
  275. final ticket = await _gateway.startQuickConnect(
  276. base,
  277. deviceId: _deviceId,
  278. );
  279. if (_stale(generation)) return;
  280. _code = ticket.code;
  281. _secret = ticket.secret;
  282. _busy = false;
  283. _status = JellyfinStatus.pairing;
  284. _notify();
  285. _startPairingTimers(generation);
  286. } on JellyfinUnreachable catch (exception) {
  287. if (_stale(generation)) return;
  288. _goOffline(exception.message, _beginQuickConnect);
  289. } on JellyfinException catch (exception) {
  290. if (_stale(generation)) return;
  291. _failPairing(exception.message);
  292. }
  293. }
  294. void _startPairingTimers(int generation) {
  295. _pollTimer = Timer.periodic(
  296. pollInterval,
  297. (_) => unawaited(_poll(generation)),
  298. );
  299. _pairingTimer = Timer(pairingLifetime, () {
  300. if (_stale(generation)) return;
  301. _failPairing('The Quick Connect code expired. Try again.');
  302. });
  303. }
  304. Future<void> _poll(int generation) async {
  305. if (_disposed || _polling || _stale(generation)) return;
  306. final base = normalizeJellyfinUrl(_serverUrl);
  307. final secret = _secret;
  308. if (base == null || secret == null) return;
  309. _polling = true;
  310. try {
  311. final approved = await _gateway.isQuickConnectApproved(base, secret);
  312. if (_stale(generation)) return;
  313. if (!approved) {
  314. if (_error != null) {
  315. _error = null;
  316. _notify();
  317. }
  318. return;
  319. }
  320. _pollTimer?.cancel();
  321. _pairingTimer?.cancel();
  322. _busy = true;
  323. _status = JellyfinStatus.connecting;
  324. _notify();
  325. final session = await _gateway.authenticateQuickConnect(
  326. base,
  327. secret,
  328. deviceId: _deviceId,
  329. );
  330. if (_stale(generation)) return;
  331. _token = session.accessToken;
  332. _userName = session.userName;
  333. await _secureStore.write(tokenKey, session.accessToken);
  334. await _settings.setJellyfinSession(
  335. userId: session.userId,
  336. userName: session.userName,
  337. );
  338. _code = null;
  339. _secret = null;
  340. _busy = false;
  341. _error = null;
  342. _status = JellyfinStatus.connected;
  343. _retryAttempt = 0;
  344. _notify();
  345. _startHealthTimer();
  346. } on JellyfinUnreachable {
  347. // Keep the code alive; the next poll retries once the network is back.
  348. if (_stale(generation)) return;
  349. _error = 'No connection — waiting to retry.';
  350. _notify();
  351. } on JellyfinAuthException catch (exception) {
  352. if (_stale(generation)) return;
  353. _failPairing(exception.message);
  354. } on JellyfinException catch (exception) {
  355. if (_stale(generation)) return;
  356. _failPairing(exception.message);
  357. } finally {
  358. _polling = false;
  359. }
  360. }
  361. Future<void> _validate({bool silent = false}) async {
  362. final base = normalizeJellyfinUrl(_serverUrl);
  363. final token = _token;
  364. if (base == null) {
  365. _status = JellyfinStatus.needsSetup;
  366. _notify();
  367. return;
  368. }
  369. if (token == null) {
  370. _status = JellyfinStatus.needsSetup;
  371. _notify();
  372. return;
  373. }
  374. final generation = ++_generation;
  375. _cancelTimers();
  376. if (!silent) {
  377. _busy = true;
  378. _status = JellyfinStatus.connecting;
  379. }
  380. _error = null;
  381. if (!silent) _notify();
  382. try {
  383. final info = await _gateway.serverInfo(base);
  384. if (_stale(generation)) return;
  385. final user = await _gateway.currentUser(base, token);
  386. if (_stale(generation)) return;
  387. final changed =
  388. _serverName != info.name ||
  389. _userName != user.name ||
  390. _status != JellyfinStatus.connected;
  391. _serverName = info.name;
  392. _userName = user.name;
  393. _busy = false;
  394. _status = JellyfinStatus.connected;
  395. _error = null;
  396. _retryAttempt = 0;
  397. unawaited(_settings.setJellyfinServerName(info.name));
  398. unawaited(_settings.setJellyfinUserName(user.name));
  399. if (changed || !silent) _notify();
  400. _startHealthTimer();
  401. } on JellyfinUnreachable catch (exception) {
  402. if (_stale(generation)) return;
  403. _goOffline(exception.message, _validate);
  404. } on JellyfinAuthException {
  405. if (_stale(generation)) return;
  406. await _invalidateSession('The saved pairing is no longer valid.');
  407. } on JellyfinException catch (exception) {
  408. if (_stale(generation)) return;
  409. await _invalidateSession(exception.message);
  410. }
  411. }
  412. void _goOffline(String message, Future<void> Function() resume) {
  413. _cancelTimers();
  414. _busy = false;
  415. _status = JellyfinStatus.offline;
  416. _error = message;
  417. _notify();
  418. final index = _retryAttempt < retryDelays.length
  419. ? _retryAttempt
  420. : retryDelays.length - 1;
  421. _retryAttempt++;
  422. _retryTimer = Timer(retryDelays[index], () {
  423. _retryTimer = null;
  424. if (_disposed || !_enabled) return;
  425. unawaited(resume());
  426. });
  427. }
  428. void _failPairing(String message) {
  429. _cancelTimers();
  430. _code = null;
  431. _secret = null;
  432. _busy = false;
  433. _status = JellyfinStatus.error;
  434. _error = message;
  435. _notify();
  436. }
  437. /// The token was rejected: turn Jellyfin off, clear the token and profile,
  438. /// and leave the card asking for a fresh Quick Connect.
  439. Future<void> _invalidateSession(String message) async {
  440. _generation++;
  441. _cancelTimers();
  442. await _clearSavedSession();
  443. _enabled = false;
  444. _needsRepair = true;
  445. _busy = false;
  446. _code = null;
  447. _secret = null;
  448. _serverName = null;
  449. _userName = null;
  450. _status = JellyfinStatus.error;
  451. _error = message;
  452. await _settings.setJellyfinEnabled(false);
  453. _notify();
  454. }
  455. Future<void> _clearSavedSession() async {
  456. _token = null;
  457. await _secureStore.delete(tokenKey);
  458. await _settings.clearJellyfinSession();
  459. }
  460. void _startHealthTimer() {
  461. _healthTimer?.cancel();
  462. _healthTimer = Timer.periodic(healthInterval, (_) {
  463. if (_disposed || !_enabled || _token == null) return;
  464. unawaited(_validate(silent: true));
  465. });
  466. }
  467. void _cancelTimers() {
  468. _pollTimer?.cancel();
  469. _pollTimer = null;
  470. _pairingTimer?.cancel();
  471. _pairingTimer = null;
  472. _healthTimer?.cancel();
  473. _healthTimer = null;
  474. _retryTimer?.cancel();
  475. _retryTimer = null;
  476. }
  477. bool _stale(int generation) {
  478. return _disposed || generation != _generation || !_enabled;
  479. }
  480. void _notify() {
  481. if (!_disposed) notifyListeners();
  482. }
  483. @override
  484. void dispose() {
  485. _disposed = true;
  486. _cancelTimers();
  487. _gateway.close();
  488. super.dispose();
  489. }
  490. }