jellyfin_controller.dart 15 KB

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