feat: support Jellyfin media segments

close #960
This commit is contained in:
edde746
2026-05-02 18:30:12 +02:00
parent 8555254e81
commit 81e036dc4f
11 changed files with 416 additions and 38 deletions
+17 -5
View File
@@ -361,9 +361,9 @@ abstract class MediaServerClient {
Future<ExternalIds> 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<PlaybackExtras> 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);
}
+15 -1
View File
@@ -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<MediaChapter> backfillEndOffsets(List<MediaChapter> chapters) {
static List<MediaChapter> backfillEndOffsets(List<MediaChapter> 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;
}
@@ -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<PlaybackExtras?> _fetchJellyfinPlaybackExtras(String cacheServerId, String itemId) async {
static Future<PlaybackExtras?> _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<List<MediaMarker>> _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<Map<String, dynamic>> _jellyfinRawItem(String cacheServerId, String itemId) async {
+16 -8
View File
@@ -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<void> _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);
}
+17 -6
View File
@@ -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<void> 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<void> pinForOffline(String serverId, String itemId) => pinByKeyPattern(_itemPattern(serverId, itemId));
Future<void> 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<void> unpinForOffline(String serverId, String itemId) => unpinByKeyPattern(_itemPattern(serverId, itemId));
Future<void> 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.
///
+59 -11
View File
@@ -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<PlaybackExtras> 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 <MediaMarker>[] : 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<List<MediaMarker>> _fetchMediaSegmentMarkers(String itemId) async {
final endpoint = JellyfinApiCache.mediaSegmentsEndpoint(itemId);
try {
return await fetchWithCacheFallback<List<MediaMarker>>(
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<List<MediaMarker>> _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);
+47 -4
View File
@@ -102,12 +102,21 @@ List<MediaSubtitleTrack> _withDefaultSubtitleSelection(List<MediaSubtitleTrack>
}
/// 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<MediaMarker> markers = const [],
}) {
String segment(String value) => Uri.encodeComponent(value);
String query(String value) => Uri.encodeComponent(value);
final chapters = raw is Map<String, dynamic> ? raw['Chapters'] : null;
final runtimeMs = raw is Map<String, dynamic> ? jellyfinTicksToMs(raw['RunTimeTicks']) : null;
final mapped = <MediaChapter>[];
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<MediaMarker> jellyfinMediaSegmentsToMarkers(dynamic raw) {
final items = raw is Map ? raw['Items'] : raw;
if (items is! List) return const [];
final markers = <MediaMarker>[];
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<int width, TrickplayInfo>`,
@@ -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);
});
});
}
@@ -714,6 +714,80 @@ void main() {
expect(capturedNextUp!.queryParameters.containsKey('NextUpDateCutoff'), isFalse);
});
test('fetchPlaybackExtras loads native Jellyfin media segments', () async {
final requests = <Uri>[];
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 = <Uri>[];
final scoped = JellyfinClient.forTesting(
@@ -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<String, dynamic> _info({
required int width,
@@ -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 {