Files
plezy/test/services/playback_initialization_offline_cache_test.dart
edde746 c2bd1d28fd fix(subtitles): load external subtitle files with the media whether or not selected
Since a1b6a8971 only the selected sidecar attached at open, so mpv's
track-list carried one external subtitle and the track sheet could only
offer the rest as primary source switches - tap-and-hold on a
non-selected external track selected it as primary instead of secondary.

Real external files are cheap static fetches, so Jellyfin, Plex direct
play, and offline discovery now mark them preload and they ride along in
sub-files at open, keeping every external track selectable as a
secondary subtitle without a reopen. Embedded rows extracted on a
transcode stay lazy: extraction can stall behind the transcoder, which
is exactly what used to trip the sidecar open guard.

close #1860
2026-08-10 21:23:55 +02:00

602 lines
20 KiB
Dart

import 'dart:convert';
import 'package:plezy/media/ids.dart';
import 'dart:io';
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
import 'package:plezy/database/app_database.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/media/media_server_client.dart';
import 'package:plezy/models/download_models.dart';
import 'package:plezy/services/cached_playback_metadata_service.dart';
import 'package:plezy/services/download_storage_service.dart';
import 'package:plezy/services/jellyfin_api_cache.dart';
import 'package:plezy/services/jellyfin_media_info_mapper.dart';
import 'package:plezy/services/playback_initialization_service.dart';
import 'package:plezy/services/plex_api_cache.dart';
import 'package:plezy/services/plex_mappers.dart';
import 'package:plezy/services/settings_service.dart';
import '../test_helpers/io_fakes.dart';
import '../test_helpers/prefs.dart';
import '../test_helpers/media_items.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late AppDatabase db;
late Directory tmpRoot;
late PathProviderPlatform previousPathProvider;
setUp(() async {
resetSharedPreferencesForTest();
SettingsService.resetForTesting();
DownloadStorageService.resetForTesting();
tmpRoot = await Directory.systemTemp.createTemp('playback_init_test_');
previousPathProvider = PathProviderPlatform.instance;
PathProviderPlatform.instance = FakePathProvider(tmpRoot);
db = AppDatabase.forTesting(NativeDatabase.memory());
PlexApiCache.initialize(db);
JellyfinApiCache.initialize(db);
});
tearDown(() async {
await db.close();
DownloadStorageService.resetForTesting();
SettingsService.resetForTesting();
PathProviderPlatform.instance = previousPathProvider;
expect(PathProviderPlatform.instance, same(previousPathProvider));
if (await tmpRoot.exists()) {
await tmpRoot.delete(recursive: true);
}
});
test('pure-offline playback loads cached Plex media source info without a client', () async {
await _insertDownloaded(
db,
serverId: ServerId('srv-1'),
ratingKey: 'movie-1',
videoFilePath: 'content://offline/movie-1',
);
await PlexApiCache.instance.put(ServerId('srv-1'), '/library/metadata/movie-1', _plexMetadataEnvelope());
final result = await PlaybackInitializationService(database: db).getPlaybackData(
PlaybackInitializationOptions(
metadata: testMediaItem(
id: 'movie-1',
backend: MediaBackend.plex,
kind: MediaKind.movie,
serverId: ServerId('srv-1'),
),
selectedMediaIndex: 0,
),
preferOffline: true,
);
expect(result.isOffline, isTrue);
expect(result.videoUrl, 'content://offline/movie-1');
expect(result.mediaInfo?.audioTracks.single.languageCode, 'eng');
});
test('downloaded track resolves to its local file through the offline path', () async {
// Same globalKey shape queueDownload writes (`serverId:ratingKey`) —
// the music resolver reaches this via preferOffline=true (original
// audio preset), so a downloaded track must play from disk.
await _insertDownloaded(
db,
serverId: ServerId('srv-1'),
ratingKey: 'track-1',
type: 'track',
videoFilePath: 'content://offline/track-1',
);
final client = _FailingPlaybackClient(serverId: ServerId('srv-1'));
final result = await PlaybackInitializationService(client: client, database: db).getPlaybackData(
PlaybackInitializationOptions(
metadata: testMediaItem(
id: 'track-1',
backend: MediaBackend.plex,
kind: MediaKind.track,
serverId: ServerId('srv-1'),
),
selectedMediaIndex: 0,
),
preferOffline: true,
);
expect(client.playbackInitializationCalls, 0);
expect(result.isOffline, isTrue);
expect(result.videoUrl, 'content://offline/track-1');
expect(result.playMethod, 'DirectPlay');
});
test('preferOffline uses cache without calling live client when local file exists', () async {
await _insertDownloaded(
db,
serverId: ServerId('srv-1'),
ratingKey: 'movie-1',
videoFilePath: 'content://offline/movie-1',
);
await PlexApiCache.instance.put(ServerId('srv-1'), '/library/metadata/movie-1', _plexMetadataEnvelope());
final client = _FailingPlaybackClient(serverId: ServerId('srv-1'));
final result = await PlaybackInitializationService(client: client, database: db).getPlaybackData(
PlaybackInitializationOptions(
metadata: testMediaItem(
id: 'movie-1',
backend: MediaBackend.plex,
kind: MediaKind.movie,
serverId: ServerId('srv-1'),
),
selectedMediaIndex: 0,
),
preferOffline: true,
);
expect(client.playbackInitializationCalls, 0);
expect(result.isOffline, isTrue);
expect(result.videoUrl, 'content://offline/movie-1');
expect(result.availableVersions, isEmpty);
expect(result.mediaInfo?.audioTracks.single.languageCode, 'eng');
});
test('pure-offline playback uses cached Plex media source for selected version', () async {
await _insertDownloaded(
db,
serverId: ServerId('srv-1'),
ratingKey: 'movie-1',
videoFilePath: 'content://offline/movie-1-v2',
mediaIndex: 1,
);
await PlexApiCache.instance.put(
ServerId('srv-1'),
'/library/metadata/movie-1',
_plexMetadataEnvelope(includeSecondVersion: true),
);
final result = await PlaybackInitializationService(database: db).getPlaybackData(
PlaybackInitializationOptions(
metadata: testMediaItem(
id: 'movie-1',
backend: MediaBackend.plex,
kind: MediaKind.movie,
serverId: ServerId('srv-1'),
),
selectedMediaIndex: 1,
),
preferOffline: true,
);
expect(result.videoUrl, 'content://offline/movie-1-v2');
expect(result.mediaInfo?.audioTracks.single.languageCode, 'fre');
});
test('no-client playback falls back to the downloaded version on a default request', () async {
// Issue #1440: only the non-default version (index 1) is downloaded, but
// plain Play requests the default (index 0). With no client to stream
// from, the downloaded copy must play — with its own version metadata.
await _insertDownloaded(
db,
serverId: ServerId('srv-1'),
ratingKey: 'movie-1',
videoFilePath: 'content://offline/movie-1-v2',
mediaIndex: 1,
mediaSourceId: 'source-b',
);
await PlexApiCache.instance.put(
ServerId('srv-1'),
'/library/metadata/movie-1',
_plexMetadataEnvelope(includeSecondVersion: true),
);
final result = await PlaybackInitializationService(database: db).getPlaybackData(
PlaybackInitializationOptions(
metadata: testMediaItem(
id: 'movie-1',
backend: MediaBackend.plex,
kind: MediaKind.movie,
serverId: ServerId('srv-1'),
),
selectedMediaIndex: 0,
),
);
expect(result.isOffline, isTrue);
expect(result.videoUrl, 'content://offline/movie-1-v2');
expect(result.selectedMediaIndex, 1);
expect(result.selectedMediaSourceId, 'source-b');
expect(result.mediaInfo?.audioTracks.single.languageCode, 'fre');
});
test('no-client playback plays the local copy even on an explicit version mismatch', () async {
await _insertDownloaded(
db,
serverId: ServerId('srv-1'),
ratingKey: 'movie-1',
videoFilePath: 'content://offline/movie-1-v2',
mediaIndex: 1,
mediaSourceId: 'source-b',
);
final result = await PlaybackInitializationService(database: db).getPlaybackData(
PlaybackInitializationOptions(
metadata: testMediaItem(
id: 'movie-1',
backend: MediaBackend.plex,
kind: MediaKind.movie,
serverId: ServerId('srv-1'),
),
selectedMediaIndex: 0,
selectedMediaSourceId: 'source-a',
),
);
expect(result.isOffline, isTrue);
expect(result.videoUrl, 'content://offline/movie-1-v2');
expect(result.selectedMediaIndex, 1);
});
test('online explicit version mismatch keeps streaming from the server', () async {
// Online pinning guard: Play Version + Original on a NON-downloaded
// version runs the offline check first (preferOffline), but the strict
// mismatch must send it to the server, not the downloaded file.
await _insertDownloaded(
db,
serverId: ServerId('srv-1'),
ratingKey: 'movie-1',
videoFilePath: 'content://offline/movie-1-v2',
mediaIndex: 1,
mediaSourceId: 'source-b',
);
final client = _StreamingPlaybackClient(serverId: ServerId('srv-1'));
final result = await PlaybackInitializationService(client: client, database: db).getPlaybackData(
PlaybackInitializationOptions(
metadata: testMediaItem(
id: 'movie-1',
backend: MediaBackend.plex,
kind: MediaKind.movie,
serverId: ServerId('srv-1'),
),
selectedMediaIndex: 0,
selectedMediaSourceId: 'source-a',
),
preferOffline: true,
);
expect(client.playbackInitializationCalls, 1);
expect(result.isOffline, isFalse);
expect(result.videoUrl, 'https://server/stream/0');
});
test('offline path falls back to media index when caller has no source id', () async {
await _insertDownloaded(
db,
serverId: ServerId('srv-1'),
ratingKey: 'movie-1',
videoFilePath: 'content://offline/movie-1-v1',
mediaIndex: 0,
mediaSourceId: 'source-a',
);
final service = PlaybackInitializationService(database: db);
expect(await service.getOfflineVideoPath(ServerId('srv-1'), 'movie-1', mediaIndex: 1), null);
expect(
await service.getOfflineVideoPath(ServerId('srv-1'), 'movie-1', mediaIndex: 0),
'content://offline/movie-1-v1',
);
});
test('pure-offline Jellyfin cache works without a connection row', () async {
await _insertDownloaded(
db,
serverId: ServerId('jf-machine'),
clientScopeId: 'jf-machine/user-a',
ratingKey: 'item-1',
videoFilePath: 'content://offline/jf-item-1',
);
await db
.into(db.apiCache)
.insert(
ApiCacheCompanion.insert(
cacheKey: 'jf-machine/user-a:/Users/user-a/Items/item-1',
data: jsonEncode(_jellyfinItemRaw()),
pinned: const Value(true),
),
);
final result = await PlaybackInitializationService(database: db).getPlaybackData(
PlaybackInitializationOptions(
metadata: testMediaItem(
id: 'item-1',
backend: MediaBackend.jellyfin,
kind: MediaKind.movie,
serverId: ServerId('jf-machine'),
),
selectedMediaIndex: 0,
),
preferOffline: true,
);
expect(result.videoUrl, 'content://offline/jf-item-1');
expect(result.mediaInfo?.audioTracks.single.languageCode, 'eng');
expect(result.mediaInfo?.chapters.single.title, 'Chapter 1');
});
test('SAF offline playback discovers app-managed sidecar subtitles', () async {
await _insertDownloaded(
db,
serverId: ServerId('srv-1'),
ratingKey: 'movie-1',
videoFilePath: 'content://offline/movie-1',
);
final subtitlePath = await DownloadStorageService.instance.getSubtitlePath(ServerId('srv-1'), 'movie-1', 2, 'srt');
final subtitleFile = File(subtitlePath);
await subtitleFile.parent.create(recursive: true);
await subtitleFile.writeAsString('1\n00:00:00,000 --> 00:00:01,000\nHello');
final result = await PlaybackInitializationService(database: db).getPlaybackData(
PlaybackInitializationOptions(
metadata: testMediaItem(
id: 'movie-1',
backend: MediaBackend.plex,
kind: MediaKind.movie,
serverId: ServerId('srv-1'),
),
selectedMediaIndex: 0,
),
preferOffline: true,
);
expect(result.videoUrl, 'content://offline/movie-1');
expect(result.externalSubtitles, hasLength(1));
expect(result.externalSubtitles.single.uri, Uri.file(subtitlePath).toString());
expect(
result.subtitleSidecars.single.preload,
isTrue,
reason: 'local sidecars load with the media so they stay selectable as secondary subtitles (#1860)',
);
});
test('cache-only playback extras fills missing Plex marker types from chapters', () async {
await PlexApiCache.instance.put(ServerId('srv-1'), '/library/metadata/movie-1', _plexMetadataEnvelope());
final extras = await CachedPlaybackMetadataService.fetchPlaybackExtras(
backend: MediaBackend.plex,
cacheServerId: 'srv-1',
itemId: 'movie-1',
);
expect(extras?.chapters.single.title, 'Intro');
expect(extras?.markers.map((m) => m.type), ['intro', 'credits']);
});
test('Plex extras parser skips malformed entries and keeps valid ones', () async {
await PlexApiCache.instance.put(
ServerId('srv-1'),
'/library/metadata/movie-1',
_plexMetadataEnvelope(malformedExtras: true),
);
final extras = await CachedPlaybackMetadataService.fetchPlaybackExtras(
backend: MediaBackend.plex,
cacheServerId: 'srv-1',
itemId: 'movie-1',
);
expect(extras?.chapters.map((c) => c.title), ['Intro']);
expect(extras?.markers.map((m) => m.type), ['intro', 'credits']);
});
test('Plex extras parser can force chapter fallback over native marker timings', () {
final extras = plexPlaybackExtrasFromCacheJson({
'Chapter': [
{'id': 1, 'index': 0, 'startTimeOffset': 5000, 'endTimeOffset': 45000, 'tag': 'Intro'},
{'id': 2, 'index': 1, 'startTimeOffset': 90000, 'endTimeOffset': 100000, 'tag': 'Credits'},
],
'Marker': [
{'id': 10, 'type': 'intro', 'startTimeOffset': 12000, 'endTimeOffset': 30000},
{'id': 11, 'type': 'credits', 'startTimeOffset': 93000, 'endTimeOffset': 98000},
],
}, forceChapterFallback: true);
final intro = extras.markers.firstWhere((m) => m.type == 'intro');
final credits = extras.markers.firstWhere((m) => m.type == 'credits');
expect(intro.startTimeOffset, 5000);
expect(intro.endTimeOffset, 45000);
expect(credits.startTimeOffset, 90000);
expect(credits.endTimeOffset, 100000);
expect(extras.markers.any((m) => m.id == 10 || m.id == 11), isFalse);
});
test('Jellyfin extras parser tolerates non-string chapter names', () {
final extras = jellyfinPlaybackExtrasFromRaw({
'Chapters': [
{'Name': 123, 'StartPositionTicks': 10000000},
],
}, 'item-1');
expect(extras.chapters.single.title, '123');
});
test('cache-only Jellyfin playback extras uses chapter fallback patterns', () async {
await JellyfinApiCache.instance.put(ServerId('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(ServerId('srv-1/user-1'), '/Users/user-1/Items/item-1', {
'Id': 'item-1',
'Type': 'Episode',
'Name': 'Episode',
'Chapters': [],
});
await JellyfinApiCache.instance.put(ServerId('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 _StreamingPlaybackClient implements MediaServerClient {
_StreamingPlaybackClient({required this.serverId});
@override
final ServerId serverId;
int playbackInitializationCalls = 0;
@override
Future<PlaybackInitializationResult> getPlaybackInitialization(PlaybackInitializationOptions options) async {
playbackInitializationCalls++;
return PlaybackInitializationResult(
availableVersions: const [],
videoUrl: 'https://server/stream/${options.selectedMediaIndex}',
selectedMediaIndex: options.selectedMediaIndex,
);
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _FailingPlaybackClient implements MediaServerClient {
_FailingPlaybackClient({required this.serverId});
@override
final ServerId serverId;
int playbackInitializationCalls = 0;
@override
Future<PlaybackInitializationResult> getPlaybackInitialization(PlaybackInitializationOptions options) async {
playbackInitializationCalls++;
throw StateError('live playback initialization should not be called for downloaded playback');
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
Future<void> _insertDownloaded(
AppDatabase db, {
required ServerId serverId,
String? clientScopeId,
required String ratingKey,
required String videoFilePath,
String type = 'movie',
int mediaIndex = 0,
String? mediaSourceId,
}) async {
await db
.into(db.downloadedMedia)
.insert(
DownloadedMediaCompanion.insert(
serverId: serverId,
clientScopeId: Value(clientScopeId),
ratingKey: ratingKey,
globalKey: '$serverId:$ratingKey',
type: type,
status: DownloadStatus.completed.index,
videoFilePath: Value(videoFilePath),
mediaIndex: Value(mediaIndex),
mediaSourceId: Value(mediaSourceId),
),
);
}
Map<String, dynamic> _plexMetadataEnvelope({bool includeSecondVersion = false, bool malformedExtras = false}) {
final chapters = <Map<String, dynamic>>[
if (malformedExtras) {'id': 'bad'},
{'id': 1, 'index': 0, 'startTimeOffset': 0, 'endTimeOffset': 10000, 'tag': 'Intro'},
];
final markers = <Map<String, dynamic>>[
if (malformedExtras) {'id': 99, 'type': 'broken'},
{'id': 2, 'type': 'credits', 'startTimeOffset': 90000, 'endTimeOffset': 100000},
];
final media = <Map<String, dynamic>>[
_plexMediaWithAudio('English', 'eng'),
if (includeSecondVersion) _plexMediaWithAudio('French', 'fre'),
];
return {
'MediaContainer': {
'Metadata': [
{
'ratingKey': 'movie-1',
'type': 'movie',
'title': 'Movie',
'Chapter': chapters,
'Marker': markers,
'Media': media,
},
],
},
};
}
Map<String, dynamic> _plexMediaWithAudio(String language, String languageCode) {
return {
'Part': [
{
'Stream': [
{'id': 10, 'streamType': 2, 'index': 1, 'language': language, 'languageCode': languageCode},
],
},
],
};
}
Map<String, dynamic> _jellyfinItemRaw() {
return {
'Id': 'item-1',
'Type': 'Movie',
'Name': 'Jellyfin Movie',
'Chapters': [
{'Name': 'Chapter 1', 'StartPositionTicks': 0},
],
'MediaSources': [
{
'Id': 'src-1',
'MediaStreams': [
{'Type': 'Audio', 'Index': 1, 'Language': 'eng', 'DisplayLanguage': 'English', 'IsDefault': true},
],
},
],
};
}