@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user