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