Files
plezy/test/utils/codec_utils_test.dart
edde746 f4ce60611b fix(subtitles): let the server deliver subtitles on a transcode
Two regressions since 2.9.1 broke subtitles on transcoded playback. Since
a1b6a8971 sidecars load with the media behind a 10s open guard, so a
subtitle URL the server is slow to serve — Jellyfin extracting an
embedded stream while its transcoder spins up — tripped the guard: stop,
reopen without subtitles, "Selected subtitles could not be loaded"
snackbar, and an emptied subtitle menu. Since 2b3853a88 every embedded
Plex subtitle was handed to the player as a sidecar whose URL is the
original container, so a transcode also range-read and demuxed the
source over HTTP — for a 40 GB remux, purely to find a subtitle track —
which is also why PGS never appeared: the client was handed a container
to demux rather than a rendition to play.

Delivery is the server's job again, backported from the AVPlayer branch
(42ba01440, the subtitle subset of 6852ac274, and a3da81e83) and adapted
to main's mpv backend:

Plex burns every embedded track (subtitles=burn); only a real external
file with a /library/streams key stays a client-fetched sidecar. A burn
is a re-encode, so directPlay is withdrawn — a real PMS answers HTTP 400
to directPlay=1 with burn — and the burn is aimed by selecting the
stream on the part first via the selectStreams PUT, because the decision
endpoint ignores subtitleStreamID alongside subtitles=burn. An
unaimable or undeliverable burn (dvb_teletext) refuses the transcode and
falls back to warned direct play rather than welding the wrong language
in or silently dropping the caption. Main's per-preset
directPlay/directStream pinning is kept; verified against a live PMS
that burn works under directStream=0.

Jellyfin never offers image formats as External, so bitmaps fall through
to Encode and are burned; text External is withheld per request when the
effective selection — including the server's DefaultSubtitleStreamIndex —
is embedded, and offered when it is a real file, so a file is delivered
as a file and never fetched twice. The burned row is excluded from the
sidecars; remaining text rows stay extractable, which is how a secondary
track still renders over a transcode. Sidecar URLs now use the format
extension the endpoint expects instead of the reported codec name.

The controls and selection layers learn what burning means: burn
eligibility is the codec's property, so burned rows stay selectable in
the menu; any change away from a burned selection renegotiates with the
server instead of pretending a local switch worked; the visibility
shortcut explains itself instead of doing nothing; and the track manager
is told when the primary is server-rendered so it stops waiting out a
thirty-second deadline for a native track that is already pixels.

Verified: analyzer parity, clean_translations --check --strict, full
flutter test (5749), and decision-level runs against live Plex and
Jellyfin servers — text and PGS burn decisions, the directPlay=1+burn
400, External file delivery, an unchanged no-burn baseline, and a real
burn session serving its playlist. The pre-commit aggregate was bypassed
for pre-existing main-state findings outside this diff: 21 format-drifted
files and three unused test seams in lib/main.dart.

close #1738

Refs #1815, #1622.
2026-08-09 07:30:47 +02:00

205 lines
7.6 KiB
Dart

import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/utils/codec_utils.dart';
void main() {
group('CodecUtils.getSubtitleExtension', () {
test('returns srt for null', () {
expect(CodecUtils.getSubtitleExtension(null), 'srt');
});
test('maps subrip/srt -> srt', () {
expect(CodecUtils.getSubtitleExtension('subrip'), 'srt');
expect(CodecUtils.getSubtitleExtension('SRT'), 'srt');
});
test('maps ass/ssa -> ass', () {
expect(CodecUtils.getSubtitleExtension('ass'), 'ass');
expect(CodecUtils.getSubtitleExtension('SSA'), 'ass');
});
test('maps webvtt/vtt -> vtt', () {
expect(CodecUtils.getSubtitleExtension('webvtt'), 'vtt');
expect(CodecUtils.getSubtitleExtension('VTT'), 'vtt');
});
test('maps mov_text -> srt', () {
expect(CodecUtils.getSubtitleExtension('mov_text'), 'srt');
});
test('maps pgs codecs -> sup', () {
expect(CodecUtils.getSubtitleExtension('pgs'), 'sup');
expect(CodecUtils.getSubtitleExtension('pgssub'), 'sup');
expect(CodecUtils.getSubtitleExtension('HDMV_PGS_SUBTITLE'), 'sup');
});
test('maps dvd/vobsub/dvb bitmap codecs -> sub', () {
expect(CodecUtils.getSubtitleExtension('dvd_subtitle'), 'sub');
expect(CodecUtils.getSubtitleExtension('dvdsub'), 'sub');
expect(CodecUtils.getSubtitleExtension('vobsub'), 'sub');
expect(CodecUtils.getSubtitleExtension('dvb_sub'), 'sub');
expect(CodecUtils.getSubtitleExtension('dvb_subtitle'), 'sub');
});
test('defaults to srt for unknown codec', () {
expect(CodecUtils.getSubtitleExtension('weirdcodec'), 'srt');
expect(CodecUtils.getSubtitleExtension(''), 'srt');
});
});
group('CodecUtils subtitle classification', () {
test('isTextSubtitleCodec recognizes text codecs only', () {
for (final codec in ['srt', 'subrip', 'ass', 'ssa', 'webvtt', 'vtt', 'mov_text', 'ASS']) {
expect(CodecUtils.isTextSubtitleCodec(codec), isTrue, reason: codec);
}
for (final codec in ['pgs', 'dvd_subtitle', 'vobsub', 'weird', null]) {
expect(CodecUtils.isTextSubtitleCodec(codec), isFalse, reason: '$codec');
}
});
test('isImageSubtitleCodec recognizes bitmap codecs only', () {
for (final codec in [
'pgs',
'pgssub',
'hdmv_pgs_subtitle',
'dvd_subtitle',
'dvdsub',
'vobsub',
'dvb_sub',
// Jellyfin's own spelling. The transcode profile asks it to burn `dvbsub`, so a picker that
// does not recognise the name never offers the track it just negotiated.
'dvbsub',
'dvb_subtitle',
'PGS',
]) {
expect(CodecUtils.isImageSubtitleCodec(codec), isTrue, reason: codec);
}
for (final codec in ['srt', 'ass', 'mov_text', 'weird', null]) {
expect(CodecUtils.isImageSubtitleCodec(codec), isFalse, reason: '$codec');
}
});
test('isTranscodableSubtitleCodec covers text and image, not unknown', () {
for (final codec in ['srt', 'ass', 'pgs', 'vobsub', 'dvd_subtitle']) {
expect(CodecUtils.isTranscodableSubtitleCodec(codec), isTrue, reason: codec);
}
expect(CodecUtils.isTranscodableSubtitleCodec('weird'), isFalse);
expect(CodecUtils.isTranscodableSubtitleCodec(null), isFalse);
});
});
group('CodecUtils.formatSubtitleCodec', () {
test('maps known codecs to friendly labels', () {
expect(CodecUtils.formatSubtitleCodec('subrip'), 'SRT');
expect(CodecUtils.formatSubtitleCodec('SUBRIP'), 'SRT');
expect(CodecUtils.formatSubtitleCodec('dvd_subtitle'), 'DVD');
expect(CodecUtils.formatSubtitleCodec('webvtt'), 'VTT');
expect(CodecUtils.formatSubtitleCodec('hdmv_pgs_subtitle'), 'PGS');
expect(CodecUtils.formatSubtitleCodec('mov_text'), 'MOV');
});
test('uppercases unknown codecs', () {
expect(CodecUtils.formatSubtitleCodec('foo'), 'FOO');
expect(CodecUtils.formatSubtitleCodec('ass'), 'ASS');
});
});
group('CodecUtils.formatVideoCodec', () {
test('h264 aliases -> H.264', () {
expect(CodecUtils.formatVideoCodec('h264'), 'H.264');
expect(CodecUtils.formatVideoCodec('avc1'), 'H.264');
expect(CodecUtils.formatVideoCodec('avc'), 'H.264');
expect(CodecUtils.formatVideoCodec('H264'), 'H.264');
});
test('hevc aliases -> HEVC', () {
expect(CodecUtils.formatVideoCodec('hevc'), 'HEVC');
expect(CodecUtils.formatVideoCodec('h265'), 'HEVC');
expect(CodecUtils.formatVideoCodec('hev1'), 'HEVC');
});
test('av1/vp8/vp9', () {
expect(CodecUtils.formatVideoCodec('av1'), 'AV1');
expect(CodecUtils.formatVideoCodec('vp8'), 'VP8');
expect(CodecUtils.formatVideoCodec('vp9'), 'VP9');
});
test('mpeg aliases', () {
expect(CodecUtils.formatVideoCodec('mpeg2video'), 'MPEG-2');
expect(CodecUtils.formatVideoCodec('mpeg2'), 'MPEG-2');
expect(CodecUtils.formatVideoCodec('mpeg4'), 'MPEG-4');
});
test('vc1', () {
expect(CodecUtils.formatVideoCodec('vc1'), 'VC-1');
});
test('unknown codec uppercases original input', () {
expect(CodecUtils.formatVideoCodec('foo'), 'FOO');
expect(CodecUtils.formatVideoCodec('Prores'), 'PRORES');
});
});
group('CodecUtils.formatAudioCodec', () {
test('common codecs', () {
expect(CodecUtils.formatAudioCodec('aac'), 'AAC');
expect(CodecUtils.formatAudioCodec('AAC'), 'AAC');
expect(CodecUtils.formatAudioCodec('ac3'), 'AC3');
expect(CodecUtils.formatAudioCodec('truehd'), 'TrueHD');
expect(CodecUtils.formatAudioCodec('flac'), 'FLAC');
expect(CodecUtils.formatAudioCodec('opus'), 'Opus');
expect(CodecUtils.formatAudioCodec('vorbis'), 'Vorbis');
});
test('eac3/ec3 -> E-AC3', () {
expect(CodecUtils.formatAudioCodec('eac3'), 'E-AC3');
expect(CodecUtils.formatAudioCodec('ec3'), 'E-AC3');
});
test('dts family', () {
expect(CodecUtils.formatAudioCodec('dts'), 'DTS');
expect(CodecUtils.formatAudioCodec('dca'), 'DTS');
expect(CodecUtils.formatAudioCodec('dtshd'), 'DTS-HD');
expect(CodecUtils.formatAudioCodec('dts-hd'), 'DTS-HD');
});
test('mp3 aliases', () {
expect(CodecUtils.formatAudioCodec('mp3'), 'MP3');
expect(CodecUtils.formatAudioCodec('mp3float'), 'MP3');
});
test('pcm aliases', () {
expect(CodecUtils.formatAudioCodec('pcm'), 'PCM');
expect(CodecUtils.formatAudioCodec('pcm_s16le'), 'PCM');
expect(CodecUtils.formatAudioCodec('pcm_s24le'), 'PCM');
});
test('unknown codec uppercases original input', () {
expect(CodecUtils.formatAudioCodec('alac'), 'ALAC');
expect(CodecUtils.formatAudioCodec('weird'), 'WEIRD');
});
});
group('CodecUtils.formatAudioChannels', () {
test('maps counts to layout names', () {
expect(CodecUtils.formatAudioChannels(1), 'Mono');
expect(CodecUtils.formatAudioChannels(2), 'Stereo');
expect(CodecUtils.formatAudioChannels(3), '3.0');
expect(CodecUtils.formatAudioChannels(4), '4.0');
expect(CodecUtils.formatAudioChannels(5), '4.1');
expect(CodecUtils.formatAudioChannels(6), '5.1');
expect(CodecUtils.formatAudioChannels(7), '6.1');
expect(CodecUtils.formatAudioChannels(8), '7.1');
});
test('falls back to Nch above 8 channels', () {
expect(CodecUtils.formatAudioChannels(10), '10ch');
});
test('returns null for null and non-positive counts', () {
expect(CodecUtils.formatAudioChannels(null), null);
expect(CodecUtils.formatAudioChannels(0), null);
expect(CodecUtils.formatAudioChannels(-1), null);
});
});
}