diff --git a/lib/media/media_server_client.dart b/lib/media/media_server_client.dart index 0507625d..52d29833 100644 --- a/lib/media/media_server_client.dart +++ b/lib/media/media_server_client.dart @@ -361,9 +361,9 @@ abstract class MediaServerClient { Future fetchExternalIds(String itemId); // ── Hubs: extras ───────────────────────────────────────────────── - /// Chapters and intro/credits markers for [itemId]. Plex returns both - /// in one round trip; Jellyfin only has chapters (markers list is - /// empty). Implementations may cache. + /// Chapters and intro/credits markers for [itemId]. Plex returns both in one + /// round trip; Jellyfin combines item-level chapters with best-effort native + /// media segments. Implementations may cache. Future fetchPlaybackExtras( String itemId, { String? introPattern, @@ -535,7 +535,13 @@ mixin MediaServerCacheMixin implements MediaServerClient { } try { final response = await networkCall(); - if (cacheResponse) await _putCacheResponse(cacheKey, response.data); + if (cacheResponse) { + try { + await _putCacheResponse(cacheKey, response.data); + } catch (e, st) { + appLogger.w('Cache write failed for $cacheKey', error: e, stackTrace: st); + } + } return parseResponse(response); } catch (e) { appLogger.w('Network request failed for $cacheKey, trying cache', error: e); @@ -560,7 +566,13 @@ mixin MediaServerCacheMixin implements MediaServerClient { if (cached != null) return parseCache(cached); if (isOfflineMode) return null; final response = await networkCall(); - if (cacheResponse) await _putCacheResponse(cacheKey, response.data); + if (cacheResponse) { + try { + await _putCacheResponse(cacheKey, response.data); + } catch (e, st) { + appLogger.w('Cache write failed for $cacheKey', error: e, stackTrace: st); + } + } return parseResponse(response); } diff --git a/lib/media/media_source_info.dart b/lib/media/media_source_info.dart index 44916e53..e8cade9c 100644 --- a/lib/media/media_source_info.dart +++ b/lib/media/media_source_info.dart @@ -173,7 +173,7 @@ class MediaChapter { /// Backfill missing `endTimeOffset` on each chapter from the next chapter's /// `startTimeOffset`. Jellyfin sends only starts; the seek-bar tick UI needs /// duration ranges. Mutates [chapters] in place and returns it. - static List backfillEndOffsets(List chapters) { + static List backfillEndOffsets(List chapters, {int? runtimeMs}) { for (var i = 0; i < chapters.length - 1; i++) { final c = chapters[i]; if (c.endTimeOffset != null) continue; @@ -186,6 +186,20 @@ class MediaChapter { thumb: c.thumb, ); } + if (runtimeMs != null && chapters.isNotEmpty) { + final last = chapters.last; + final start = last.startTimeOffset; + if (last.endTimeOffset == null && start != null && runtimeMs > start) { + chapters[chapters.length - 1] = MediaChapter( + id: last.id, + index: last.index, + startTimeOffset: start, + endTimeOffset: runtimeMs, + title: last.title, + thumb: last.thumb, + ); + } + } return chapters; } diff --git a/lib/services/cached_playback_metadata_service.dart b/lib/services/cached_playback_metadata_service.dart index d18abddc..6715d803 100644 --- a/lib/services/cached_playback_metadata_service.dart +++ b/lib/services/cached_playback_metadata_service.dart @@ -7,6 +7,7 @@ import '../media/media_source_info.dart'; import '../utils/app_logger.dart'; import '../utils/plex_cache_parser.dart'; import 'api_cache.dart'; +import 'jellyfin_api_cache.dart'; import 'jellyfin_media_info_mapper.dart'; import 'plex_mappers.dart'; @@ -47,7 +48,13 @@ class CachedPlaybackMetadataService { creditsPattern: creditsPattern, forceChapterFallback: forceChapterFallback, ), - MediaBackend.jellyfin => _fetchJellyfinPlaybackExtras(cacheServerId, itemId), + MediaBackend.jellyfin => _fetchJellyfinPlaybackExtras( + cacheServerId, + itemId, + introPattern: introPattern, + creditsPattern: creditsPattern, + forceChapterFallback: forceChapterFallback, + ), }; } catch (e) { appLogger.d('Cached playback extras unavailable for $cacheServerId:$itemId', error: e); @@ -99,9 +106,35 @@ class CachedPlaybackMetadataService { return jellyfinMediaSourceToMediaSourceInfo(selected, chapters: raw['Chapters'], trickplay: raw['Trickplay']); } - static Future _fetchJellyfinPlaybackExtras(String cacheServerId, String itemId) async { + static Future _fetchJellyfinPlaybackExtras( + String cacheServerId, + String itemId, { + String? introPattern, + String? creditsPattern, + bool forceChapterFallback = false, + }) async { final raw = await _jellyfinRawItem(cacheServerId, itemId); - return jellyfinPlaybackExtrasFromRaw(raw, itemId); + final markers = await _jellyfinMediaSegmentMarkers(cacheServerId, itemId); + return jellyfinPlaybackExtrasFromRaw( + raw, + itemId, + introPattern: introPattern, + creditsPattern: creditsPattern, + forceChapterFallback: forceChapterFallback, + markers: markers, + ); + } + + static Future> _jellyfinMediaSegmentMarkers(String cacheServerId, String itemId) async { + try { + final raw = await ApiCache.forBackend( + MediaBackend.jellyfin, + ).get(cacheServerId, JellyfinApiCache.mediaSegmentsEndpoint(itemId)); + return jellyfinMediaSegmentsToMarkers(raw); + } catch (e) { + appLogger.d('Cached Jellyfin media segments unavailable for $cacheServerId:$itemId', error: e); + return const []; + } } static Future> _jellyfinRawItem(String cacheServerId, String itemId) async { diff --git a/lib/services/download_manager_service.dart b/lib/services/download_manager_service.dart index 0500c1ca..5fc4a13f 100644 --- a/lib/services/download_manager_service.dart +++ b/lib/services/download_manager_service.dart @@ -318,20 +318,28 @@ class DownloadManagerService { return null; } - /// Backend-aware "ensure cached & pin" — populates the read-path cache via - /// `client.fetchItem(...)` (idempotent: no-op if warm, falls back to - /// existing cache on network error) and then pins the row so it survives - /// general cache eviction. + /// Backend-aware "ensure cached & pin". Jellyfin loads playback extras so + /// both item metadata and native media segments are available offline; other + /// backends only need the item metadata row. Then pin cached rows so they + /// survive general cache eviction. Future _pinMetadataForOffline(MediaServerClient client, MediaItem metadata) async { final serverId = metadata.serverId; if (serverId == null) { appLogger.w('Cannot pin metadata without serverId'); return; } - try { - await client.fetchItem(metadata.id); - } catch (e) { - appLogger.w('fetchItem failed during offline-pin for ${metadata.globalKey}', error: e); + if (client.backend == MediaBackend.jellyfin) { + try { + await client.fetchPlaybackExtras(metadata.id); + } catch (e) { + appLogger.w('fetchPlaybackExtras failed during offline-pin for ${metadata.globalKey}', error: e); + } + } else { + try { + await client.fetchItem(metadata.id); + } catch (e) { + appLogger.w('fetchItem failed during offline-pin for ${metadata.globalKey}', error: e); + } } await ApiCache.forBackend(client.backend).pinForOffline(client.cacheServerId, metadata.id); } diff --git a/lib/services/jellyfin_api_cache.dart b/lib/services/jellyfin_api_cache.dart index 9dfb8103..6312f06e 100644 --- a/lib/services/jellyfin_api_cache.dart +++ b/lib/services/jellyfin_api_cache.dart @@ -41,20 +41,31 @@ class JellyfinApiCache extends ApiCache { String _itemPattern(String serverId, String itemId) => '$serverId:/Users/%/Items/$itemId'; - /// Delete cached item metadata for [itemId] (single-item endpoint only; - /// children-list endpoints are out of scope for v1 — they'll get cleaned - /// up via [deleteForServer] or [clearAll]). + static String mediaSegmentsEndpoint(String itemId) => '/MediaSegments/${Uri.encodeComponent(itemId)}'; + + /// Delete cached item metadata and playback segment rows for [itemId]. + /// Children-list endpoints are out of scope for v1 — they'll get cleaned up + /// via [deleteForServer] or [clearAll]. @override Future deleteForItem(String serverId, String itemId) async { - await (database.delete(database.apiCache)..where((t) => t.cacheKey.like(_itemPattern(serverId, itemId)))).go(); + final endpoint = mediaSegmentsEndpoint(itemId); + await (database.delete( + database.apiCache, + )..where((t) => t.cacheKey.like(_itemPattern(serverId, itemId)) | t.cacheKey.equals('$serverId:$endpoint'))).go(); } /// Pin the metadata row(s) for [itemId] so they survive cache eviction. @override - Future pinForOffline(String serverId, String itemId) => pinByKeyPattern(_itemPattern(serverId, itemId)); + Future pinForOffline(String serverId, String itemId) async { + final endpoint = mediaSegmentsEndpoint(itemId); + await Future.wait([pinByKeyPattern(_itemPattern(serverId, itemId)), pin(serverId, endpoint)]); + } /// Unpin a previously pinned item. - Future unpinForOffline(String serverId, String itemId) => unpinByKeyPattern(_itemPattern(serverId, itemId)); + Future unpinForOffline(String serverId, String itemId) async { + final endpoint = mediaSegmentsEndpoint(itemId); + await Future.wait([unpinByKeyPattern(_itemPattern(serverId, itemId)), unpin(serverId, endpoint)]); + } /// Whether the metadata for [itemId] is pinned for offline. /// diff --git a/lib/services/jellyfin_client.dart b/lib/services/jellyfin_client.dart index 6d7b02c0..06f5e05c 100644 --- a/lib/services/jellyfin_client.dart +++ b/lib/services/jellyfin_client.dart @@ -469,11 +469,10 @@ class JellyfinClient with MediaServerCacheMixin implements MediaServerClient, Sc return fetchLibraryContent(libraryId, effective, abort: abort); } - /// Backend-neutral [PlaybackExtras] for [itemId]. Jellyfin only exposes - /// chapters at the item level (`raw['Chapters']`); markers don't exist - /// in the API so [PlaybackExtras.markers] is always empty. Chapter end - /// offsets are backfilled from the next chapter's start so the UI can - /// render duration ranges (Plex serves explicit ends). + /// Backend-neutral [PlaybackExtras] for [itemId]. Jellyfin exposes chapters + /// at the item level (`raw['Chapters']`) and native skip segments through a + /// separate `/MediaSegments/{itemId}` endpoint. Segment loading is best-effort + /// so older servers still use chapter title fallback. @override Future fetchPlaybackExtras( String itemId, { @@ -483,7 +482,15 @@ class JellyfinClient with MediaServerCacheMixin implements MediaServerClient, Sc bool forceRefresh = false, }) async { final item = await fetchItem(itemId); - return _playbackExtrasFromRaw(item?.raw, itemId); + final markers = item == null ? const [] : await _fetchMediaSegmentMarkers(itemId); + return jellyfinPlaybackExtrasFromRaw( + item?.raw, + itemId, + introPattern: introPattern, + creditsPattern: creditsPattern, + forceChapterFallback: forceChapterFallback, + markers: markers, + ); } @override @@ -495,7 +502,15 @@ class JellyfinClient with MediaServerCacheMixin implements MediaServerClient, Sc }) async { final item = await cache.getMetadata(cacheServerId, itemId); if (item == null) return null; - return _playbackExtrasFromRaw(item.raw, itemId); + final markers = await _fetchCachedMediaSegmentMarkers(itemId); + return jellyfinPlaybackExtrasFromRaw( + item.raw, + itemId, + introPattern: introPattern, + creditsPattern: creditsPattern, + forceChapterFallback: forceChapterFallback, + markers: markers, + ); } @override @@ -526,10 +541,43 @@ class JellyfinClient with MediaServerCacheMixin implements MediaServerClient, Sc ); } - /// Parse Jellyfin chapter list from the `raw` payload of a [MediaItem] - /// or a fresh `BaseItemDto` map. End offsets are backfilled from the - /// next chapter's start so the seek-bar tick UI has duration ranges. - PlaybackExtras _playbackExtrasFromRaw(dynamic raw, String itemId) => jellyfinPlaybackExtrasFromRaw(raw, itemId); + Future> _fetchMediaSegmentMarkers(String itemId) async { + final endpoint = JellyfinApiCache.mediaSegmentsEndpoint(itemId); + try { + return await fetchWithCacheFallback>( + cacheKey: endpoint, + networkCall: () async { + final response = await _http.get(endpoint); + if (response.statusCode == 404) { + return MediaServerResponse(statusCode: 200, headers: response.headers, requestUri: response.requestUri); + } + throwIfHttpError(response); + return response; + }, + parseCache: jellyfinMediaSegmentsToMarkers, + parseResponse: (response) => jellyfinMediaSegmentsToMarkers(response.data), + ) ?? + const []; + } on MediaServerHttpException catch (e) { + if (e.statusCode != 404) { + appLogger.d('JellyfinClient.fetchPlaybackExtras media segments unavailable', error: e); + } + return const []; + } catch (e) { + appLogger.d('JellyfinClient.fetchPlaybackExtras media segments unavailable', error: e); + return const []; + } + } + + Future> _fetchCachedMediaSegmentMarkers(String itemId) async { + try { + final data = await cache.get(cacheServerId, JellyfinApiCache.mediaSegmentsEndpoint(itemId)); + return jellyfinMediaSegmentsToMarkers(data); + } catch (e) { + appLogger.d('JellyfinClient.fetchPlaybackExtras cached media segments unavailable', error: e); + return const []; + } + } static String _segment(String value) => Uri.encodeComponent(value); diff --git a/lib/services/jellyfin_media_info_mapper.dart b/lib/services/jellyfin_media_info_mapper.dart index 04c59f8b..d0d314bf 100644 --- a/lib/services/jellyfin_media_info_mapper.dart +++ b/lib/services/jellyfin_media_info_mapper.dart @@ -102,12 +102,21 @@ List _withDefaultSubtitleSelection(List } /// Parse Jellyfin chapters from the raw `BaseItemDto` payload into neutral -/// playback extras. Markers are not exposed by Jellyfin, so the list is empty. -PlaybackExtras jellyfinPlaybackExtrasFromRaw(dynamic raw, String itemId) { +/// playback extras. Native Jellyfin media segments are passed in separately; +/// chapter title fallback uses the same intro/credits patterns as Plex. +PlaybackExtras jellyfinPlaybackExtrasFromRaw( + dynamic raw, + String itemId, { + String? introPattern, + String? creditsPattern, + bool forceChapterFallback = false, + List markers = const [], +}) { String segment(String value) => Uri.encodeComponent(value); String query(String value) => Uri.encodeComponent(value); final chapters = raw is Map ? raw['Chapters'] : null; + final runtimeMs = raw is Map ? jellyfinTicksToMs(raw['RunTimeTicks']) : null; final mapped = []; if (chapters is List) { for (var i = 0; i < chapters.length; i++) { @@ -123,9 +132,43 @@ PlaybackExtras jellyfinPlaybackExtrasFromRaw(dynamic raw, String itemId) { MediaChapter(id: i, index: i, startTimeOffset: startMs, title: entry['Name']?.toString(), thumb: thumb), ); } - MediaChapter.backfillEndOffsets(mapped); + MediaChapter.backfillEndOffsets(mapped, runtimeMs: runtimeMs); } - return PlaybackExtras(chapters: mapped, markers: const []); + return PlaybackExtras.withChapterFallback( + chapters: mapped, + markers: markers, + introPatternStr: introPattern, + creditsPatternStr: creditsPattern, + forceChapterFallback: forceChapterFallback, + ); +} + +List jellyfinMediaSegmentsToMarkers(dynamic raw) { + final items = raw is Map ? raw['Items'] : raw; + if (items is! List) return const []; + + final markers = []; + for (var i = 0; i < items.length; i++) { + final entry = items[i]; + if (entry is! Map) continue; + final type = _jellyfinSegmentMarkerType(entry['Type']?.toString()); + final start = jellyfinTicksToMs(entry['StartTicks']); + final end = jellyfinTicksToMs(entry['EndTicks']); + if (type == null || start == null || end == null || end <= start) continue; + markers.add(MediaMarker(id: i, type: type, startTimeOffset: start, endTimeOffset: end)); + } + return markers; +} + +String? _jellyfinSegmentMarkerType(String? value) { + switch (value?.toLowerCase()) { + case 'intro': + return 'intro'; + case 'outro': + case 'credits': + return 'credits'; + } + return null; } /// Coerce a Jellyfin trickplay manifest to `Map`, diff --git a/test/services/download_manager_service_test.dart b/test/services/download_manager_service_test.dart index 88eee734..4ef245d1 100644 --- a/test/services/download_manager_service_test.dart +++ b/test/services/download_manager_service_test.dart @@ -131,6 +131,33 @@ void main() { expect(year, 2008); }); + + test('Jellyfin offline pinning keeps media segment cache rows with metadata', () async { + final db = AppDatabase.forTesting(NativeDatabase.memory()); + PlexApiCache.initialize(db); + JellyfinApiCache.initialize(db); + addTearDown(db.close); + + await JellyfinApiCache.instance.put('jf-machine/user-a', '/Users/user-a/Items/item-1', { + 'Id': 'item-1', + 'Type': 'Episode', + 'Name': 'Episode', + }); + await JellyfinApiCache.instance.put('jf-machine/user-a', '/MediaSegments/item-1', { + 'Items': [ + {'Type': 'Intro', 'StartTicks': 10000000, 'EndTicks': 20000000}, + ], + }); + + await JellyfinApiCache.instance.pinForOffline('jf-machine/user-a', 'item-1'); + + expect(await JellyfinApiCache.instance.isPinned('jf-machine/user-a', '/MediaSegments/item-1'), isTrue); + + await JellyfinApiCache.instance.deleteForItem('jf-machine/user-a', 'item-1'); + + expect(await JellyfinApiCache.instance.get('jf-machine/user-a', '/Users/user-a/Items/item-1'), isNull); + expect(await JellyfinApiCache.instance.get('jf-machine/user-a', '/MediaSegments/item-1'), isNull); + }); }); } diff --git a/test/services/jellyfin_client_urls_test.dart b/test/services/jellyfin_client_urls_test.dart index 32c03e7e..bf5baec0 100644 --- a/test/services/jellyfin_client_urls_test.dart +++ b/test/services/jellyfin_client_urls_test.dart @@ -714,6 +714,80 @@ void main() { expect(capturedNextUp!.queryParameters.containsKey('NextUpDateCutoff'), isFalse); }); + test('fetchPlaybackExtras loads native Jellyfin media segments', () async { + final requests = []; + final scoped = JellyfinClient.forTesting( + connection: _conn(), + httpClient: MockClient((req) async { + requests.add(req.url); + if (req.url.path == '/Users/user-1/Items/item-1') { + return http.Response( + jsonEncode({'Id': 'item-1', 'Type': 'Episode', 'Name': 'Episode', 'Chapters': []}), + 200, + headers: {'content-type': 'application/json'}, + ); + } + if (req.url.path == '/MediaSegments/item-1') { + return http.Response( + jsonEncode({ + 'Items': [ + {'Type': 'Intro', 'StartTicks': 50000000, 'EndTicks': 450000000}, + {'Type': 'Outro', 'StartTicks': 900000000, 'EndTicks': 1000000000}, + ], + }), + 200, + headers: {'content-type': 'application/json'}, + ); + } + return http.Response('not found', 404); + }), + ); + addTearDown(scoped.close); + + final extras = await scoped.fetchPlaybackExtras('item-1'); + + expect(requests.map((uri) => uri.path), contains('/MediaSegments/item-1')); + expect(extras.markers.map((m) => m.type), ['intro', 'credits']); + expect(extras.markers.first.startTimeOffset, 5000); + expect(extras.markers.first.endTimeOffset, 45000); + }); + + test('fetchPlaybackExtras falls back to OP/ED chapters when media segments are unavailable', () async { + final scoped = JellyfinClient.forTesting( + connection: _conn(), + httpClient: MockClient((req) async { + if (req.url.path == '/Users/user-1/Items/item-1') { + return http.Response( + jsonEncode({ + 'Id': 'item-1', + 'Type': 'Episode', + 'Name': 'Episode', + 'RunTimeTicks': 1200000000, + 'Chapters': [ + {'Name': 'OP', 'StartPositionTicks': 100000000}, + {'Name': 'Episode', 'StartPositionTicks': 450000000}, + {'Name': 'ED', 'StartPositionTicks': 900000000}, + ], + }), + 200, + headers: {'content-type': 'application/json'}, + ); + } + if (req.url.path == '/MediaSegments/item-1') { + return http.Response('not found', 404); + } + return http.Response('not found', 404); + }), + ); + addTearDown(scoped.close); + + final extras = await scoped.fetchPlaybackExtras('item-1'); + + expect(extras.markers.map((m) => m.type), ['intro', 'credits']); + expect(extras.markers.first.endTimeOffset, 45000); + expect(extras.markers.last.endTimeOffset, 120000); + }); + test('fetchContinueWatching merges resume with non-resumable Next Up', () async { final requests = []; final scoped = JellyfinClient.forTesting( diff --git a/test/services/jellyfin_media_info_test.dart b/test/services/jellyfin_media_info_test.dart index b833211a..26fdb381 100644 --- a/test/services/jellyfin_media_info_test.dart +++ b/test/services/jellyfin_media_info_test.dart @@ -354,9 +354,69 @@ void main() { expect(extras.chapters.single.thumb, '/Items/folder%2Fitem%20%231%3Fx/Images/Chapter/0?tag=chapter%2Ftag%20%3Fx'); }); + + test('uses default OP and ED chapter patterns for skip markers', () { + final extras = jellyfinPlaybackExtrasFromRaw({ + 'RunTimeTicks': _ticks(150000), + 'Chapters': [ + {'Name': 'Prologue', 'StartPositionTicks': _ticks(0)}, + {'Name': 'OP', 'StartPositionTicks': _ticks(10000)}, + {'Name': 'Part A', 'StartPositionTicks': _ticks(90000)}, + {'Name': 'ED', 'StartPositionTicks': _ticks(120000)}, + ], + }, 'item-1'); + + expect(extras.markers.map((m) => m.type), ['intro', 'credits']); + expect(extras.markers[0].startTimeOffset, 10000); + expect(extras.markers[0].endTimeOffset, 90000); + expect(extras.markers[1].startTimeOffset, 120000); + expect(extras.markers[1].endTimeOffset, 150000); + }); + + test('parses native Jellyfin media segments into skip markers', () { + final markers = jellyfinMediaSegmentsToMarkers({ + 'Items': [ + {'Type': 'Intro', 'StartTicks': _ticks(5000), 'EndTicks': _ticks(45000)}, + {'Type': 'Outro', 'StartTicks': _ticks(90000), 'EndTicks': _ticks(100000)}, + {'Type': 'Recap', 'StartTicks': _ticks(0), 'EndTicks': _ticks(4000)}, + ], + }); + + expect(markers.map((m) => m.type), ['intro', 'credits']); + expect(markers[0].startTimeOffset, 5000); + expect(markers[0].endTimeOffset, 45000); + expect(markers[1].startTimeOffset, 90000); + expect(markers[1].endTimeOffset, 100000); + }); + + test('keeps native segments and fills missing marker types from chapters', () { + final extras = jellyfinPlaybackExtrasFromRaw( + { + 'RunTimeTicks': _ticks(120000), + 'Chapters': [ + {'Name': 'Episode', 'StartPositionTicks': _ticks(0)}, + {'Name': 'ED', 'StartPositionTicks': _ticks(90000)}, + ], + }, + 'item-1', + markers: jellyfinMediaSegmentsToMarkers({ + 'Items': [ + {'Type': 'Intro', 'StartTicks': _ticks(10000), 'EndTicks': _ticks(30000)}, + ], + }), + ); + + expect(extras.markers.map((m) => m.type), ['intro', 'credits']); + expect(extras.markers[0].startTimeOffset, 10000); + expect(extras.markers[0].endTimeOffset, 30000); + expect(extras.markers[1].startTimeOffset, 90000); + expect(extras.markers[1].endTimeOffset, 120000); + }); }); } +int _ticks(int ms) => ms * 10000; + /// Build a Jellyfin TrickplayInfoDto-shaped JSON map for a fixture. Map _info({ required int width, diff --git a/test/services/playback_initialization_offline_cache_test.dart b/test/services/playback_initialization_offline_cache_test.dart index 72383a4a..4cc762fe 100644 --- a/test/services/playback_initialization_offline_cache_test.dart +++ b/test/services/playback_initialization_offline_cache_test.dart @@ -237,6 +237,54 @@ void main() { expect(extras.chapters.single.title, '123'); }); + + test('cache-only Jellyfin playback extras uses chapter fallback patterns', () async { + await JellyfinApiCache.instance.put('srv-1/user-1', '/Users/user-1/Items/item-1', { + 'Id': 'item-1', + 'Type': 'Episode', + 'Name': 'Episode', + 'RunTimeTicks': 1200000000, + 'Chapters': [ + {'Name': 'OP', 'StartPositionTicks': 100000000}, + {'Name': 'Episode', 'StartPositionTicks': 450000000}, + {'Name': 'ED', 'StartPositionTicks': 900000000}, + ], + }); + + final extras = await CachedPlaybackMetadataService.fetchPlaybackExtras( + backend: MediaBackend.jellyfin, + cacheServerId: 'srv-1/user-1', + itemId: 'item-1', + ); + + expect(extras?.markers.map((m) => m.type), ['intro', 'credits']); + expect(extras?.markers.last.endTimeOffset, 120000); + }); + + test('cache-only Jellyfin playback extras uses cached native media segments', () async { + await JellyfinApiCache.instance.put('srv-1/user-1', '/Users/user-1/Items/item-1', { + 'Id': 'item-1', + 'Type': 'Episode', + 'Name': 'Episode', + 'Chapters': [], + }); + await JellyfinApiCache.instance.put('srv-1/user-1', '/MediaSegments/item-1', { + 'Items': [ + {'Type': 'Intro', 'StartTicks': 50000000, 'EndTicks': 450000000}, + {'Type': 'Outro', 'StartTicks': 900000000, 'EndTicks': 1000000000}, + ], + }); + + final extras = await CachedPlaybackMetadataService.fetchPlaybackExtras( + backend: MediaBackend.jellyfin, + cacheServerId: 'srv-1/user-1', + itemId: 'item-1', + ); + + expect(extras?.markers.map((m) => m.type), ['intro', 'credits']); + expect(extras?.markers.first.startTimeOffset, 5000); + expect(extras?.markers.last.endTimeOffset, 100000); + }); } class _FailingPlaybackClient implements MediaServerClient {