* fix(plex): show PGS subtitles when transcoding Bitmap subtitles (PGS/VOBSUB) disappeared from the menu while transcoding on Plex, leaving only "Off". The MKV transcode already supports them, so embed them in the stream like text subs. Now they show up and can be turned on. * fix(plex): give bitmap subtitle codecs the right sidecar extension getSubtitleExtension fell back to .srt for vobsub, pgssub and dvb subs. Map them to .sub/.sup so it stays consistent with isImageSubtitleCodec and any sidecar URL or download uses the correct extension.
505 lines
17 KiB
Dart
505 lines
17 KiB
Dart
import 'dart:convert';
|
|
import 'package:plezy/media/ids.dart';
|
|
|
|
import 'package:drift/native.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:http/testing.dart';
|
|
import 'package:plezy/database/app_database.dart';
|
|
import 'package:plezy/media/media_backend.dart';
|
|
import 'package:plezy/media/media_item.dart';
|
|
import 'package:plezy/media/media_kind.dart';
|
|
import 'package:plezy/media/media_source_info.dart';
|
|
import 'package:plezy/mpv/mpv.dart';
|
|
import 'package:plezy/models/plex/plex_config.dart';
|
|
import 'package:plezy/models/transcode_quality_preset.dart';
|
|
import 'package:plezy/services/playback_initialization_types.dart';
|
|
import 'package:plezy/services/plex_api_cache.dart';
|
|
import 'package:plezy/services/plex_client.dart';
|
|
|
|
void main() {
|
|
late AppDatabase db;
|
|
|
|
setUp(() {
|
|
db = AppDatabase.forTesting(NativeDatabase.memory());
|
|
PlexApiCache.initialize(db);
|
|
});
|
|
|
|
tearDown(() async {
|
|
await db.close();
|
|
});
|
|
|
|
PlexClient makeClient(Future<http.Response> Function(http.Request request) handler) {
|
|
return PlexClient.forTesting(
|
|
config: PlexConfig(
|
|
baseUrl: 'https://plex.example.com',
|
|
token: 'token',
|
|
clientIdentifier: 'client-id',
|
|
product: 'Plezy',
|
|
version: '1',
|
|
),
|
|
serverId: ServerId('server-id'),
|
|
httpClient: MockClient(handler),
|
|
);
|
|
}
|
|
|
|
MediaSourceInfo mediaInfoWithSubtitles(List<MediaSubtitleTrack> subtitleTracks) {
|
|
return MediaSourceInfo(
|
|
videoUrl: 'https://plex.example.com/video.mkv',
|
|
audioTracks: const [],
|
|
subtitleTracks: subtitleTracks,
|
|
chapters: const [],
|
|
);
|
|
}
|
|
|
|
List<SubtitleTrack> buildTranscodeSubtitles(PlexClient client, List<MediaSubtitleTrack> subtitleTracks) {
|
|
return client.buildTranscodeSidecarSubtitlesForTesting(mediaInfoWithSubtitles(subtitleTracks));
|
|
}
|
|
|
|
test('selectStreams sends audio stream selection with allParts', () async {
|
|
final requests = <http.Request>[];
|
|
final client = makeClient((request) async {
|
|
requests.add(request);
|
|
return http.Response('', 200);
|
|
});
|
|
addTearDown(client.close);
|
|
|
|
final saved = await client.selectStreams(99, audioStreamID: 301, allParts: true);
|
|
|
|
expect(saved, isTrue);
|
|
expect(requests, hasLength(1));
|
|
expect(requests.single.method, 'PUT');
|
|
expect(requests.single.url.path, '/library/parts/99');
|
|
expect(requests.single.url.queryParameters['audioStreamID'], '301');
|
|
expect(requests.single.url.queryParameters['allParts'], '1');
|
|
});
|
|
|
|
test('playback metadata request includes streams for transcode sidecar subtitles', () async {
|
|
final requests = <Uri>[];
|
|
final client = makeClient((request) async {
|
|
requests.add(request.url);
|
|
if (request.url.path != '/library/metadata/42') {
|
|
return http.Response('not found', 404);
|
|
}
|
|
|
|
return http.Response(
|
|
jsonEncode({
|
|
'MediaContainer': {
|
|
'Metadata': [
|
|
{
|
|
'ratingKey': '42',
|
|
'type': 'movie',
|
|
'title': 'Movie',
|
|
'Media': [
|
|
{
|
|
'id': 7,
|
|
'container': 'mkv',
|
|
'Part': [
|
|
{
|
|
'id': 99,
|
|
'key': '/library/parts/99/file.mkv',
|
|
'Stream': [
|
|
{'streamType': 1, 'id': 300, 'codec': 'h264'},
|
|
{'streamType': 2, 'id': 301, 'index': 0, 'languageCode': 'jpn', 'selected': true},
|
|
{
|
|
'streamType': 3,
|
|
'id': 401,
|
|
'index': 1,
|
|
'codec': 'ass',
|
|
'language': 'English',
|
|
'languageCode': 'eng',
|
|
'title': 'Signs/Songs',
|
|
'selected': true,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
200,
|
|
headers: {'content-type': 'application/json'},
|
|
);
|
|
});
|
|
addTearDown(client.close);
|
|
|
|
final data = await client.getVideoPlaybackData('42');
|
|
|
|
expect(requests, hasLength(1));
|
|
expect(requests.single.queryParameters['includeStreams'], '1');
|
|
expect(requests.single.queryParameters['checkFiles'], '1');
|
|
expect(requests.single.queryParameters.containsKey('checkFileAvailability'), isFalse);
|
|
expect(data.mediaInfo?.subtitleTracks, hasLength(1));
|
|
expect(data.mediaInfo?.subtitleTracks.single.id, 401);
|
|
expect(data.mediaInfo?.subtitleTracks.single.selected, isTrue);
|
|
});
|
|
|
|
test('playback uses metadata availability flags without probing part URLs', () async {
|
|
final requests = <http.Request>[];
|
|
final client = makeClient((request) async {
|
|
requests.add(request);
|
|
if (request.url.path != '/library/metadata/42') {
|
|
return http.Response('unexpected request', 500);
|
|
}
|
|
|
|
return http.Response(
|
|
jsonEncode({
|
|
'MediaContainer': {
|
|
'Metadata': [
|
|
{
|
|
'ratingKey': '42',
|
|
'type': 'movie',
|
|
'title': 'Movie',
|
|
'Media': [
|
|
{
|
|
'id': 7,
|
|
'container': 'mkv',
|
|
'Part': [
|
|
{'id': 10, 'key': '/library/parts/10/file.mkv', 'exists': 0, 'accessible': 1},
|
|
{'id': 20, 'key': '/library/parts/20/file.mkv', 'exists': 1, 'accessible': 1},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
200,
|
|
headers: {'content-type': 'application/json'},
|
|
);
|
|
});
|
|
addTearDown(client.close);
|
|
|
|
final data = await client.getVideoPlaybackData('42');
|
|
|
|
expect(requests, hasLength(1));
|
|
expect(requests.single.url.queryParameters['checkFiles'], '1');
|
|
expect(requests.single.url.queryParameters.containsKey('checkFileAvailability'), isFalse);
|
|
expect(data.videoUrl, 'https://plex.example.com/library/parts/20/file.mkv?X-Plex-Token=token');
|
|
expect(data.selectedMediaIndex, 0);
|
|
expect(data.selectedPartIndex, 1);
|
|
});
|
|
|
|
test('latest server metadata overwrites cached playback media fields', () async {
|
|
final cache = PlexApiCache.instance;
|
|
await cache.put(ServerId('server-id'), '/library/metadata/42', {
|
|
'MediaContainer': {
|
|
'Metadata': [
|
|
{
|
|
'ratingKey': '42',
|
|
'type': 'movie',
|
|
'title': 'Playback title',
|
|
'Media': [
|
|
{
|
|
'id': 7,
|
|
'Part': [
|
|
{
|
|
'id': 99,
|
|
'key': '/library/parts/99/file.mkv',
|
|
'exists': true,
|
|
'accessible': true,
|
|
'Stream': [
|
|
{'streamType': 1, 'id': 300, 'codec': 'h264'},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
await cache.put(ServerId('server-id'), '/library/metadata/42', {
|
|
'MediaContainer': {
|
|
'Metadata': [
|
|
{
|
|
'ratingKey': '42',
|
|
'type': 'movie',
|
|
'title': 'Detail title',
|
|
'Media': [
|
|
{
|
|
'id': 7,
|
|
'Part': [
|
|
{'id': 99, 'key': '/library/parts/99/weak.mkv'},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
final cached = await cache.get(ServerId('server-id'), '/library/metadata/42');
|
|
final metadata = (cached!['MediaContainer'] as Map<String, dynamic>)['Metadata'] as List<dynamic>;
|
|
final item = metadata.single as Map<String, dynamic>;
|
|
final media = item['Media'] as List<dynamic>;
|
|
final part = ((media.single as Map<String, dynamic>)['Part'] as List<dynamic>).single as Map<String, dynamic>;
|
|
|
|
expect(item['title'], 'Detail title');
|
|
expect(part['key'], '/library/parts/99/weak.mkv');
|
|
expect(part.containsKey('exists'), isFalse);
|
|
expect(part.containsKey('accessible'), isFalse);
|
|
expect(part.containsKey('Stream'), isFalse);
|
|
});
|
|
|
|
test('network failure falls back to lean cached playback metadata', () async {
|
|
await PlexApiCache.instance.put(ServerId('server-id'), '/library/metadata/42', {
|
|
'MediaContainer': {
|
|
'Metadata': [
|
|
{
|
|
'ratingKey': '42',
|
|
'type': 'movie',
|
|
'title': 'Movie',
|
|
'Media': [
|
|
{
|
|
'id': 7,
|
|
'Part': [
|
|
{'id': 10, 'key': '/library/parts/10/stale.mkv'},
|
|
],
|
|
},
|
|
{
|
|
'id': 8,
|
|
'Part': [
|
|
{'id': 20, 'key': '/library/parts/20/current.mkv'},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
});
|
|
final requests = <http.Request>[];
|
|
final client = makeClient((request) async {
|
|
requests.add(request);
|
|
throw Exception('offline');
|
|
});
|
|
addTearDown(client.close);
|
|
|
|
final data = await client.getVideoPlaybackData('42');
|
|
|
|
expect(requests, hasLength(1));
|
|
expect(data.videoUrl, 'https://plex.example.com/library/parts/10/stale.mkv?X-Plex-Token=token');
|
|
expect(data.availableVersions, hasLength(2));
|
|
});
|
|
|
|
test('playback initialization exposes effective selected media index', () async {
|
|
final client = makeClient((request) async {
|
|
if (request.url.path != '/library/metadata/42') {
|
|
return http.Response('unexpected request', 500);
|
|
}
|
|
|
|
return http.Response(
|
|
jsonEncode({
|
|
'MediaContainer': {
|
|
'Metadata': [
|
|
{
|
|
'ratingKey': '42',
|
|
'type': 'movie',
|
|
'title': 'Movie',
|
|
'Media': [
|
|
{
|
|
'id': 7,
|
|
'Part': [
|
|
{'id': 10, 'key': '/library/parts/10/stale.mkv', 'exists': false, 'accessible': false},
|
|
],
|
|
},
|
|
{
|
|
'id': 8,
|
|
'Part': [
|
|
{'id': 20, 'key': '/library/parts/20/current.mkv', 'exists': true, 'accessible': true},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
200,
|
|
headers: {'content-type': 'application/json'},
|
|
);
|
|
});
|
|
addTearDown(client.close);
|
|
|
|
final result = await client.getPlaybackInitialization(
|
|
PlaybackInitializationOptions(
|
|
metadata: MediaItem(id: '42', backend: MediaBackend.plex, kind: MediaKind.movie, serverId: 'server-id'),
|
|
selectedMediaIndex: 0,
|
|
),
|
|
);
|
|
|
|
expect(result.videoUrl, 'https://plex.example.com/library/parts/20/current.mkv?X-Plex-Token=token');
|
|
expect(result.selectedMediaIndex, 1);
|
|
});
|
|
|
|
test('transcode subtitle sidecars only use real Plex stream keys', () {
|
|
final client = makeClient((_) async => http.Response('not used', 500));
|
|
addTearDown(client.close);
|
|
|
|
final subtitles = buildTranscodeSubtitles(client, [
|
|
MediaSubtitleTrack(id: 401, codec: 'srt', languageCode: 'eng', selected: false, forced: false),
|
|
MediaSubtitleTrack(
|
|
id: 402,
|
|
codec: 'srt',
|
|
languageCode: 'eng',
|
|
selected: false,
|
|
forced: false,
|
|
key: '/library/streams/402',
|
|
external: true,
|
|
),
|
|
]);
|
|
|
|
expect(subtitles, hasLength(1));
|
|
expect(subtitles.single.uri, 'https://plex.example.com/library/streams/402.srt?encoding=utf-8&X-Plex-Token=token');
|
|
});
|
|
|
|
test('selected internal text subtitles are not attached as external sidecars', () {
|
|
final client = makeClient((_) async => http.Response('not used', 500));
|
|
addTearDown(client.close);
|
|
|
|
final subtitles = buildTranscodeSubtitles(client, [
|
|
MediaSubtitleTrack(
|
|
id: 401,
|
|
codec: 'ass',
|
|
language: 'English',
|
|
languageCode: 'eng',
|
|
title: 'Signs/Songs',
|
|
selected: true,
|
|
forced: false,
|
|
),
|
|
]);
|
|
|
|
expect(subtitles, isEmpty);
|
|
});
|
|
|
|
test('selected internal text subtitles are embedded in HTTP MKV transcode', () {
|
|
final client = makeClient((_) async => http.Response('not used', 500));
|
|
addTearDown(client.close);
|
|
|
|
final params = client.buildTranscodeParamsForTesting(
|
|
ratingKey: '42',
|
|
mediaIndex: 0,
|
|
preset: TranscodeQualityPreset.p720_3mbps,
|
|
sessionIdentifier: 'session-id',
|
|
transcodeSessionId: 'transcode-id',
|
|
selectedSubtitleTrack: MediaSubtitleTrack(
|
|
id: 401,
|
|
codec: 'ass',
|
|
languageCode: 'eng',
|
|
selected: true,
|
|
forced: false,
|
|
),
|
|
);
|
|
|
|
expect(params['protocol'], 'http');
|
|
expect(params['subtitles'], 'embedded');
|
|
expect(params['subtitleStreamID'], '401');
|
|
expect(params['advancedSubtitles'], 'text');
|
|
expect(params['X-Plex-Chunked'], '1');
|
|
expect(params.containsKey('X-Plex-Incomplete-Segments'), isFalse);
|
|
expect(params['X-Plex-Client-Profile-Extra'], contains('add-settings(DirectPlayStreamSelection=true)'));
|
|
expect(
|
|
params['X-Plex-Client-Profile-Extra'],
|
|
contains(
|
|
'add-transcode-target(type=videoProfile&context=streaming'
|
|
'&protocol=http&container=mkv&videoCodec=h264%2Chevc%2C*'
|
|
'&audioCodec=opus%2Cvorbis%2Cflac%2C*&subtitleCodec=ass%2Cpgs%2Cvobsub%2C*)',
|
|
),
|
|
);
|
|
expect(
|
|
params['X-Plex-Client-Profile-Extra'],
|
|
contains(
|
|
'add-transcode-target-settings(type=videoProfile&context=streaming'
|
|
'&protocol=http&CopyMatroskaAttachments=true)',
|
|
),
|
|
);
|
|
expect(params['X-Plex-Client-Profile-Extra'], isNot(contains('protocol=hls')));
|
|
expect(params['X-Plex-Client-Profile-Extra'], isNot(contains('type=subtitleProfile')));
|
|
});
|
|
|
|
test('transcode start path uses HTTP start endpoint without token', () {
|
|
final client = makeClient((_) async => http.Response('not used', 500));
|
|
addTearDown(client.close);
|
|
|
|
final params = client.buildTranscodeParamsForTesting(
|
|
ratingKey: '42',
|
|
mediaIndex: 0,
|
|
preset: TranscodeQualityPreset.p720_3mbps,
|
|
sessionIdentifier: 'session-id',
|
|
transcodeSessionId: 'transcode-id',
|
|
offsetMs: 90500,
|
|
);
|
|
|
|
final startPath = client.buildTranscodeStartPathFromParamsForTesting(params);
|
|
|
|
expect(params['offset'], '90');
|
|
expect(startPath, startsWith('/video/:/transcode/universal/start?'));
|
|
expect(startPath, isNot(contains('start.m3u8')));
|
|
expect(startPath, contains('protocol=http'));
|
|
expect(startPath, contains('offset=90'));
|
|
expect(startPath, isNot(contains('X-Plex-Token')));
|
|
});
|
|
|
|
test('transcode params preserve resolved media and part indices', () {
|
|
final client = makeClient((_) async => http.Response('not used', 500));
|
|
addTearDown(client.close);
|
|
|
|
final params = client.buildTranscodeParamsForTesting(
|
|
ratingKey: '42',
|
|
mediaIndex: 1,
|
|
partIndex: 2,
|
|
preset: TranscodeQualityPreset.p720_3mbps,
|
|
sessionIdentifier: 'session-id',
|
|
transcodeSessionId: 'transcode-id',
|
|
);
|
|
|
|
expect(params['mediaIndex'], '1');
|
|
expect(params['partIndex'], '2');
|
|
});
|
|
|
|
test('selected image-based subtitles are embedded in HTTP MKV transcode without advancedSubtitles', () {
|
|
final client = makeClient((_) async => http.Response('not used', 500));
|
|
addTearDown(client.close);
|
|
|
|
final params = client.buildTranscodeParamsForTesting(
|
|
ratingKey: '42',
|
|
mediaIndex: 0,
|
|
preset: TranscodeQualityPreset.p720_3mbps,
|
|
sessionIdentifier: 'session-id',
|
|
transcodeSessionId: 'transcode-id',
|
|
selectedSubtitleTrack: MediaSubtitleTrack(
|
|
id: 401,
|
|
codec: 'pgs',
|
|
languageCode: 'eng',
|
|
selected: true,
|
|
forced: false,
|
|
),
|
|
);
|
|
|
|
// PGS is copied into the MKV as a stream; `advancedSubtitles=text` is
|
|
// text-only and must be absent so the server doesn't try to convert it.
|
|
expect(params['subtitles'], 'embedded');
|
|
expect(params['subtitleStreamID'], '401');
|
|
expect(params['protocol'], 'http');
|
|
expect(params.containsKey('advancedSubtitles'), isFalse);
|
|
expect(params['X-Plex-Client-Profile-Extra'], isNot(contains('type=subtitleProfile')));
|
|
});
|
|
|
|
test('image-based embedded subtitles are carried in the MKV, not as sidecars', () {
|
|
final client = makeClient((_) async => http.Response('not used', 500));
|
|
addTearDown(client.close);
|
|
|
|
// Embedded bitmap streams have no Plex `key`, so there is no sidecar URL to
|
|
// build — they ride the main HTTP/MKV stream via `subtitles=embedded`.
|
|
final subtitles = buildTranscodeSubtitles(client, [
|
|
MediaSubtitleTrack(id: 401, codec: 'pgs', languageCode: 'eng', selected: true, forced: false),
|
|
MediaSubtitleTrack(id: 402, codec: 'dvd_subtitle', languageCode: 'eng', selected: true, forced: false),
|
|
]);
|
|
|
|
expect(subtitles, isEmpty);
|
|
});
|
|
}
|