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
@@ -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 {