fix(player): seek Plex transcodes in-band instead of pre-warming at the resume offset
A quality switch or resumed open at a nonzero position sent offset=T on the HLS start URL, waited for the readiness probe to touch the segment at T, and then had mpv seek to T anyway. mpv's stream probing always reads segment zero first, and a Plex segment request is a seek, so the transcoder was dragged through seek(T) -> seek(0) -> seek(T) within seconds of the open. Measured against PMS 1.43, a segment response that races such a restart can be left open with headers sent and no data or error, and ffmpeg's HLS segment reads have no default timeout, so playback buffered forever after the first frame (issue #1859). Starting the session plain and letting the player's start=T request the resume segment performs the one unavoidable transcoder seek. The offset request parameter, the readiness probe, and the probe-only getStatus HTTP helper are removed; live TV time-shift keeps its own offset path. Transcode opens now also set an explicit network-timeout with demuxer-level reconnect options: mpv's stream-layer reconnect settings never reach ffmpeg's HLS segment fetches, so a silently hung segment response now times out after 20s and is re-requested on a fresh connection instead of buffering indefinitely. Verified against a live PMS (resume plays from the requested position) and a stall harness (hung segment re-requested at 20s with no content skip).
This commit is contained in:
@@ -695,7 +695,6 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
|
|||||||
preferredSubtitleTrack: initializationSubtitleTrack,
|
preferredSubtitleTrack: initializationSubtitleTrack,
|
||||||
sessionIdentifier: _playbackSessionIdentifier,
|
sessionIdentifier: _playbackSessionIdentifier,
|
||||||
transcodeSessionId: _playbackTranscodeSessionId,
|
transcodeSessionId: _playbackTranscodeSessionId,
|
||||||
transcodeOffset: openResumePosition,
|
|
||||||
),
|
),
|
||||||
offlineLibraryMode: _offlineLibraryMode,
|
offlineLibraryMode: _offlineLibraryMode,
|
||||||
);
|
);
|
||||||
@@ -801,12 +800,6 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
|
|||||||
externalSubtitles: subtitleSelection.sidecarsAtOpen,
|
externalSubtitles: subtitleSelection.sidecarsAtOpen,
|
||||||
);
|
);
|
||||||
var effectiveExternalSubtitlePlan = externalSubtitlePlan;
|
var effectiveExternalSubtitlePlan = externalSubtitlePlan;
|
||||||
await _awaitTranscodeReadiness(
|
|
||||||
client: mediaClient,
|
|
||||||
isTranscoding: result.isTranscoding,
|
|
||||||
videoUrl: result.videoUrl!,
|
|
||||||
);
|
|
||||||
if (!isCurrentReload()) return _MediaReloadOutcome.superseded;
|
|
||||||
final openResult = await _openMediaOnPlayer(
|
final openResult = await _openMediaOnPlayer(
|
||||||
player: currentPlayer,
|
player: currentPlayer,
|
||||||
settingsService: settingsService,
|
settingsService: settingsService,
|
||||||
|
|||||||
@@ -548,7 +548,7 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState {
|
|||||||
/// mpv stream ring buffer for poorly interleaved MP4/MOV direct play (the
|
/// mpv stream ring buffer for poorly interleaved MP4/MOV direct play (the
|
||||||
/// ring absorbs the demuxer's audio↔video byte ping-pong so HTTP reads stay
|
/// ring absorbs the demuxer's audio↔video byte ping-pong so HTTP reads stay
|
||||||
/// linear instead of dropping the connection on every byte seek — see
|
/// linear instead of dropping the connection on every byte seek — see
|
||||||
/// [networkStreamRingBytes]). Both properties are always written, set or
|
/// [networkStreamRingBytes]). Every property is always written, set or
|
||||||
/// reset, so a reused player never carries one item's tuning into the next
|
/// reset, so a reused player never carries one item's tuning into the next
|
||||||
/// open. On Android with ExoPlayer active they are stashed natively and
|
/// open. On Android with ExoPlayer active they are stashed natively and
|
||||||
/// replayed on the exo→mpv fallback, so keep them unconditional.
|
/// replayed on the exo→mpv fallback, so keep them unconditional.
|
||||||
@@ -579,6 +579,25 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState {
|
|||||||
await player.setProperty('stream-lavf-o', '');
|
await player.setProperty('stream-lavf-o', '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Transcode (HLS) segment fetches happen inside ffmpeg's hls demuxer, not
|
||||||
|
// mpv's stream layer, so the reconnect options above never reach them and
|
||||||
|
// mpv's default network-timeout is inert there: a segment response PMS
|
||||||
|
// leaves open without data or error — observed when the request races a
|
||||||
|
// transcoder seek/restart — buffers forever (#1859). An explicit
|
||||||
|
// network-timeout bounds each stalled read and the demuxer-level
|
||||||
|
// reconnect options re-request the same segment instead of skipping its
|
||||||
|
// content. 20s sits above the segment-serve latency of a struggling
|
||||||
|
// transcode (reads that deliver any bytes reset the clock) and a false
|
||||||
|
// trip is a Range-resumed reconnect, not an error.
|
||||||
|
if (isNetworkVod && isTranscoding) {
|
||||||
|
await player.setProperty('network-timeout', '20');
|
||||||
|
await player.setProperty('demuxer-lavf-o', 'reconnect=1,reconnect_streamed=1,reconnect_on_network_error=1');
|
||||||
|
} else {
|
||||||
|
// mpv's documented default network-timeout.
|
||||||
|
await player.setProperty('network-timeout', '60');
|
||||||
|
await player.setProperty('demuxer-lavf-o', '');
|
||||||
|
}
|
||||||
|
|
||||||
int? ringBytes;
|
int? ringBytes;
|
||||||
if (isNetworkVod && !isTranscoding) {
|
if (isNetworkVod && !isTranscoding) {
|
||||||
// Transcode (HLS) playback only uses the mpv stream layer for the
|
// Transcode (HLS) playback only uses the mpv stream layer for the
|
||||||
@@ -606,24 +625,6 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState {
|
|||||||
await player.setProperty('stream-buffer-size', '${ringBytes ?? mpvDefaultStreamBufferBytes}');
|
await player.setProperty('stream-buffer-size', '${ringBytes ?? mpvDefaultStreamBufferBytes}');
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Best-effort wait for an offset transcode session's segment at the
|
|
||||||
/// resume point, run immediately before the player opens the URL so the
|
|
||||||
/// wait hides behind the other pre-open work and the guarantee is fresh
|
|
||||||
/// when the player attaches. A not-ready session still opens — mpv
|
|
||||||
/// classifies whatever the server actually returns — and no-offset URLs
|
|
||||||
/// return immediately. Starting a new probe aborts the previous one so a
|
|
||||||
/// superseded open never leaves it polling out its window.
|
|
||||||
Future<void> _awaitTranscodeReadiness({
|
|
||||||
required MediaServerClient? client,
|
|
||||||
required bool isTranscoding,
|
|
||||||
required String videoUrl,
|
|
||||||
}) async {
|
|
||||||
if (!isTranscoding || client is! PlexClient) return;
|
|
||||||
_transcodeReadinessAbort?.abort();
|
|
||||||
final abort = _transcodeReadinessAbort = AbortController();
|
|
||||||
await client.waitForTranscodeReady(videoUrl, abort: abort);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Open [videoUrl] on [player]: stream tuning → open → native subtitle style.
|
/// Open [videoUrl] on [player]: stream tuning → open → native subtitle style.
|
||||||
///
|
///
|
||||||
/// [shouldContinue] is re-checked between the awaits so stale generations
|
/// [shouldContinue] is re-checked between the awaits so stale generations
|
||||||
|
|||||||
@@ -261,11 +261,6 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
|
|||||||
resumePosition: resumePosition,
|
resumePosition: resumePosition,
|
||||||
durationMs: _currentMetadata.durationMs,
|
durationMs: _currentMetadata.durationMs,
|
||||||
);
|
);
|
||||||
await _awaitTranscodeReadiness(
|
|
||||||
client: playbackContext.reportingClient,
|
|
||||||
isTranscoding: result.isTranscoding,
|
|
||||||
videoUrl: result.videoUrl!,
|
|
||||||
);
|
|
||||||
if (!attempt.isCurrent) return;
|
if (!attempt.isCurrent) return;
|
||||||
final openResult = await _openMediaOnPlayer(
|
final openResult = await _openMediaOnPlayer(
|
||||||
player: currentPlayer,
|
player: currentPlayer,
|
||||||
|
|||||||
@@ -75,7 +75,6 @@ import '../providers/shader_provider.dart';
|
|||||||
import '../providers/user_profile_provider.dart';
|
import '../providers/user_profile_provider.dart';
|
||||||
import '../utils/app_logger.dart';
|
import '../utils/app_logger.dart';
|
||||||
import '../utils/dialogs.dart';
|
import '../utils/dialogs.dart';
|
||||||
import '../utils/media_server_http_client.dart' show AbortController;
|
|
||||||
import '../utils/log_redaction_manager.dart';
|
import '../utils/log_redaction_manager.dart';
|
||||||
import '../utils/live_tv_player_navigation.dart';
|
import '../utils/live_tv_player_navigation.dart';
|
||||||
import '../utils/player_utils.dart';
|
import '../utils/player_utils.dart';
|
||||||
@@ -473,9 +472,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
Completer<void>? _playbackTransitionIdleCompleter;
|
Completer<void>? _playbackTransitionIdleCompleter;
|
||||||
bool _playbackIntentShouldPlay = true;
|
bool _playbackIntentShouldPlay = true;
|
||||||
|
|
||||||
/// In-flight transcode readiness probe, aborted by the next probe or by
|
|
||||||
/// dispose so a superseded open never leaves it polling out its window.
|
|
||||||
AbortController? _transcodeReadinessAbort;
|
|
||||||
int _pendingSubtitleCycleCount = 0;
|
int _pendingSubtitleCycleCount = 0;
|
||||||
bool _subtitleCycleDrainActive = false;
|
bool _subtitleCycleDrainActive = false;
|
||||||
|
|
||||||
@@ -1307,13 +1303,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
preferredSubtitleTrack: _preferredSubtitleTrack,
|
preferredSubtitleTrack: _preferredSubtitleTrack,
|
||||||
sessionIdentifier: _playbackSessionIdentifier,
|
sessionIdentifier: _playbackSessionIdentifier,
|
||||||
transcodeSessionId: _playbackTranscodeSessionId,
|
transcodeSessionId: _playbackTranscodeSessionId,
|
||||||
// The initial resume position is the server view offset (the
|
|
||||||
// online open resolves the same value later), so a resumed
|
|
||||||
// transcode starts producing at the resume point instead of
|
|
||||||
// seeking a stream that begins at zero.
|
|
||||||
transcodeOffset: _currentMetadata.viewOffsetMs != null
|
|
||||||
? Duration(milliseconds: _currentMetadata.viewOffsetMs!)
|
|
||||||
: null,
|
|
||||||
),
|
),
|
||||||
offlineLibraryMode: false,
|
offlineLibraryMode: false,
|
||||||
);
|
);
|
||||||
@@ -1828,7 +1817,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
unawaited(AndroidExitDiagnostics.markUiState(AndroidUiState.mainScreen));
|
unawaited(AndroidExitDiagnostics.markUiState(AndroidUiState.mainScreen));
|
||||||
_transcodeReadinessAbort?.abort();
|
|
||||||
_playerInitializationGeneration++;
|
_playerInitializationGeneration++;
|
||||||
_frameRate.dispose();
|
_frameRate.dispose();
|
||||||
WidgetsBinding.instance.removeObserver(this);
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
|
|||||||
@@ -61,13 +61,6 @@ class PlaybackInitializationOptions {
|
|||||||
/// for Plex transcode.
|
/// for Plex transcode.
|
||||||
final String? transcodeSessionId;
|
final String? transcodeSessionId;
|
||||||
|
|
||||||
/// Absolute VOD position at which a new Plex transcode must begin. Sent
|
|
||||||
/// with both Plex's decision and HLS start request so the server and the
|
|
||||||
/// player agree on the first available segment. Only the Plex client
|
|
||||||
/// consumes this today; Jellyfin's StartTimeTicks equivalent is
|
|
||||||
/// intentionally unwired.
|
|
||||||
final Duration? transcodeOffset;
|
|
||||||
|
|
||||||
const PlaybackInitializationOptions({
|
const PlaybackInitializationOptions({
|
||||||
required this.metadata,
|
required this.metadata,
|
||||||
required this.selectedMediaIndex,
|
required this.selectedMediaIndex,
|
||||||
@@ -80,7 +73,6 @@ class PlaybackInitializationOptions {
|
|||||||
this.preferredSubtitleTrack,
|
this.preferredSubtitleTrack,
|
||||||
this.sessionIdentifier,
|
this.sessionIdentifier,
|
||||||
this.transcodeSessionId,
|
this.transcodeSessionId,
|
||||||
this.transcodeOffset,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+10
-207
@@ -1,7 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import '../utils/isolate_helper.dart';
|
import '../utils/isolate_helper.dart';
|
||||||
import '../utils/json_utils.dart';
|
import '../utils/json_utils.dart';
|
||||||
import 'package:clock/clock.dart';
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
@@ -2648,6 +2647,16 @@ class PlexClient
|
|||||||
/// [transcodeSessionId] and [sessionIdentifier] should be reused across
|
/// [transcodeSessionId] and [sessionIdentifier] should be reused across
|
||||||
/// seeks + quality/version/audio switches within one playback so the
|
/// seeks + quality/version/audio switches within one playback so the
|
||||||
/// server-side transcode session is preserved.
|
/// server-side transcode session is preserved.
|
||||||
|
///
|
||||||
|
/// Deliberately no `offset` request parameter: the start URL always
|
||||||
|
/// describes the full title and the player seeks in-band by requesting the
|
||||||
|
/// segment at the resume position (`Media(start:)`). Pre-warming the
|
||||||
|
/// transcoder at the resume point looked cheaper but never was — mpv's
|
||||||
|
/// stream probing reads segment zero first, which is itself a Plex seek, so
|
||||||
|
/// an offset start forced the transcoder through seek→0→seek within a
|
||||||
|
/// couple of seconds. PMS can leave the segment response that races such a
|
||||||
|
/// restart open without data or error, which the player waits out as
|
||||||
|
/// endless buffering (#1859).
|
||||||
Future<({String? startPath, TranscodeDecisionOutcome outcome})> buildTranscodeStartPath({
|
Future<({String? startPath, TranscodeDecisionOutcome outcome})> buildTranscodeStartPath({
|
||||||
required String ratingKey,
|
required String ratingKey,
|
||||||
required int mediaIndex,
|
required int mediaIndex,
|
||||||
@@ -2656,7 +2665,6 @@ class PlexClient
|
|||||||
required String sessionIdentifier,
|
required String sessionIdentifier,
|
||||||
required String transcodeSessionId,
|
required String transcodeSessionId,
|
||||||
int? audioStreamId,
|
int? audioStreamId,
|
||||||
Duration? offset,
|
|
||||||
MediaSubtitleTrack? selectedSubtitleTrack,
|
MediaSubtitleTrack? selectedSubtitleTrack,
|
||||||
int? partId,
|
int? partId,
|
||||||
}) async {
|
}) async {
|
||||||
@@ -2670,7 +2678,6 @@ class PlexClient
|
|||||||
sessionIdentifier: sessionIdentifier,
|
sessionIdentifier: sessionIdentifier,
|
||||||
transcodeSessionId: transcodeSessionId,
|
transcodeSessionId: transcodeSessionId,
|
||||||
audioStreamId: audioStreamId,
|
audioStreamId: audioStreamId,
|
||||||
offset: offset,
|
|
||||||
selectedSubtitleTrack: selectedSubtitleTrack,
|
selectedSubtitleTrack: selectedSubtitleTrack,
|
||||||
useTsFallbackTarget: useTsFallbackTarget,
|
useTsFallbackTarget: useTsFallbackTarget,
|
||||||
);
|
);
|
||||||
@@ -2707,205 +2714,6 @@ class PlexClient
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Absolute media position a transcode start URL was requested at, or null
|
|
||||||
/// when the URL is not an offset HLS start request. Matched on decoded
|
|
||||||
/// path segments so a percent-encoded spelling of the same URL cannot
|
|
||||||
/// silently switch the readiness probe off.
|
|
||||||
static Duration? transcodeStreamOffsetFromUrl(String videoUrl) {
|
|
||||||
final uri = Uri.tryParse(videoUrl);
|
|
||||||
if (uri == null || !'/${uri.pathSegments.join('/')}'.endsWith('/video/:/transcode/universal/start.m3u8')) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
final offsetSeconds = double.tryParse(uri.queryParameters['offset'] ?? '');
|
|
||||||
if (offsetSeconds == null || offsetSeconds <= 0) return null;
|
|
||||||
return Duration(microseconds: (offsetSeconds * Duration.microsecondsPerSecond).round());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Picks the playlist entry the readiness probe should touch: the segment
|
|
||||||
/// whose duration window contains [offset].
|
|
||||||
///
|
|
||||||
/// Plex media playlists always cover the full title from segment zero, so
|
|
||||||
/// probing the first entry would steer the transcoder back to the start —
|
|
||||||
/// requesting a segment is how a client seeks a Plex HLS session. A master
|
|
||||||
/// playlist (no `#EXTINF` durations) descends into its first variant. A
|
|
||||||
/// media playlist whose durations never cross [offset] returns null: it
|
|
||||||
/// cannot say where the offset lives, and a probe aimed at the wrong
|
|
||||||
/// segment would seek the session, so the caller skips probing instead.
|
|
||||||
@visibleForTesting
|
|
||||||
static String? selectReadinessProbeTarget(String body, Duration offset) {
|
|
||||||
String? firstEntry;
|
|
||||||
var sawSegmentDurations = false;
|
|
||||||
var cumulative = Duration.zero;
|
|
||||||
var pending = Duration.zero;
|
|
||||||
for (final raw in body.split(RegExp(r'\r?\n'))) {
|
|
||||||
final line = raw.trim();
|
|
||||||
if (line.isEmpty) continue;
|
|
||||||
if (line.startsWith('#')) {
|
|
||||||
if (line.startsWith('#EXTINF:')) {
|
|
||||||
sawSegmentDurations = true;
|
|
||||||
final seconds = double.tryParse(line.substring('#EXTINF:'.length).split(',').first);
|
|
||||||
if (seconds != null) pending = Duration(microseconds: (seconds * Duration.microsecondsPerSecond).round());
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
firstEntry ??= line;
|
|
||||||
cumulative += pending;
|
|
||||||
pending = Duration.zero;
|
|
||||||
if (sawSegmentDurations && cumulative > offset) return line;
|
|
||||||
}
|
|
||||||
return sawSegmentDurations ? null : (firstEntry ?? '');
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Waits for a just-started Plex offset HLS session to serve the segment at
|
|
||||||
/// the requested offset before a native player opens its playlist. Plex can
|
|
||||||
/// return a manifest before the segment is ready; mpv treats that 404 as an
|
|
||||||
/// HLS error and races through the rest of the manifest.
|
|
||||||
///
|
|
||||||
/// Best-effort by design: the probe never fails an open, it only stops
|
|
||||||
/// waiting, and callers ignore the returned bool — it exists for tests. The
|
|
||||||
/// player then sees whatever the server is actually doing and the existing
|
|
||||||
/// log-stream classification applies unchanged. To that end a 500 stops the
|
|
||||||
/// wait immediately — a persistent 500 must keep failing fast so the
|
|
||||||
/// server-limit dialog appears promptly — whether it arrives as a response
|
|
||||||
/// or inside a decode exception, and a cancellation ([abort] fired or the
|
|
||||||
/// owning client closing) stops it too rather than sleeping out the window.
|
|
||||||
/// URLs without an offset return immediately: probing a no-offset playlist
|
|
||||||
/// would touch segment zero, and requesting a segment is how a client seeks
|
|
||||||
/// a Plex HLS session.
|
|
||||||
///
|
|
||||||
/// Other non-2xx responses are the expected not-ready signal. `_http.get`
|
|
||||||
/// does not throw on the status, though its body decode can throw carrying
|
|
||||||
/// one — both paths share [handOffStatus] so they cannot drift. Every
|
|
||||||
/// not-ready round waits [pollInterval], doubling up to 4x after three
|
|
||||||
/// consecutive failed round-trips so a stalled transcode is not hammered;
|
|
||||||
/// the accepted trade is that a session whose segments 404 for real
|
|
||||||
/// reaches the player, and its media-unreadable dialog, one probe window
|
|
||||||
/// later than an unprobed open would. The probe carries this retry budget
|
|
||||||
/// itself, so its requests bypass endpoint failover, and each request has a
|
|
||||||
/// hard timeout (5s, shrinking as the overall deadline approaches) so a
|
|
||||||
/// single hung request cannot consume the entire window.
|
|
||||||
Future<bool> waitForTranscodeReady(
|
|
||||||
String videoUrl, {
|
|
||||||
Duration timeout = const Duration(seconds: 15),
|
|
||||||
Duration pollInterval = const Duration(milliseconds: 500),
|
|
||||||
AbortController? abort,
|
|
||||||
}) async {
|
|
||||||
final startUri = Uri.tryParse(videoUrl);
|
|
||||||
final probeOffset = transcodeStreamOffsetFromUrl(videoUrl);
|
|
||||||
if (startUri == null || probeOffset == null) return true;
|
|
||||||
|
|
||||||
// One rule for terminal statuses, applied to responses and to
|
|
||||||
// status-bearing exceptions alike.
|
|
||||||
bool handOffStatus(int? statusCode) {
|
|
||||||
if (statusCode != 500) return false;
|
|
||||||
// Hand off without classifying: mpv opens the URL, hits the same 500,
|
|
||||||
// and the log-stream path raises the server-limit dialog.
|
|
||||||
appLogger.i('Plex transcode readiness probe handing off on HTTP 500');
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
final deadline = clock.now().add(timeout);
|
|
||||||
var candidate = startUri;
|
|
||||||
var playlistDepth = 0;
|
|
||||||
var consecutiveFailures = 0;
|
|
||||||
int? lastStatus;
|
|
||||||
while (true) {
|
|
||||||
final remaining = deadline.difference(clock.now());
|
|
||||||
if (remaining <= Duration.zero) break;
|
|
||||||
if (abort?.isAborted ?? false) return false;
|
|
||||||
try {
|
|
||||||
final requestTimeout = remaining < const Duration(seconds: 5) ? remaining : const Duration(seconds: 5);
|
|
||||||
final isPlaylist = candidate.path.toLowerCase().endsWith('.m3u8');
|
|
||||||
// The default Accept is application/json (PlexConfig.headers); the
|
|
||||||
// probe mirrors the player's request shape instead. Segments go
|
|
||||||
// through getStatus so a server that ignores Range never routes a
|
|
||||||
// full media segment through text decoding.
|
|
||||||
final int statusCode;
|
|
||||||
var body = '';
|
|
||||||
Uri? effectiveUri;
|
|
||||||
if (isPlaylist) {
|
|
||||||
final response = await _http.get(
|
|
||||||
candidate.toString(),
|
|
||||||
headers: const {'Accept': '*/*'},
|
|
||||||
timeout: requestTimeout,
|
|
||||||
abort: abort,
|
|
||||||
allowEndpointFailover: false,
|
|
||||||
);
|
|
||||||
statusCode = response.statusCode;
|
|
||||||
body = response.data?.toString() ?? '';
|
|
||||||
effectiveUri = response.effectiveUri;
|
|
||||||
} else {
|
|
||||||
final response = await _http.getStatus(
|
|
||||||
candidate.toString(),
|
|
||||||
headers: const {'Range': 'bytes=0-0', 'Accept': '*/*'},
|
|
||||||
timeout: requestTimeout,
|
|
||||||
abort: abort,
|
|
||||||
);
|
|
||||||
statusCode = response.statusCode;
|
|
||||||
}
|
|
||||||
lastStatus = statusCode;
|
|
||||||
if (statusCode >= 200 && statusCode < 300) {
|
|
||||||
consecutiveFailures = 0;
|
|
||||||
if (body.trimLeft().startsWith('#EXTM3U')) {
|
|
||||||
final child = selectReadinessProbeTarget(body, probeOffset);
|
|
||||||
if (child == null) {
|
|
||||||
// The playlist has segments but its durations never reach the
|
|
||||||
// offset — a playlist shape this client has never observed
|
|
||||||
// against a real PMS. It cannot say where the offset lives,
|
|
||||||
// and a probe aimed at the wrong segment would seek the
|
|
||||||
// session, so skip probing and let the player negotiate.
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (child.isNotEmpty) {
|
|
||||||
candidate = (effectiveUri ?? candidate).resolve(child);
|
|
||||||
playlistDepth++;
|
|
||||||
if (playlistDepth > 4) {
|
|
||||||
appLogger.w('Plex transcode readiness exceeded the HLS playlist depth limit');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// Descending into a child playlist is progress, not a poll.
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// A manifest with no media entries yet: not ready, poll again.
|
|
||||||
} else if (!isPlaylist) {
|
|
||||||
// The segment at the offset answered: the session is ready.
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
} else if (handOffStatus(statusCode)) {
|
|
||||||
return false;
|
|
||||||
} else {
|
|
||||||
consecutiveFailures++;
|
|
||||||
}
|
|
||||||
} on MediaServerHttpException catch (e) {
|
|
||||||
if (e.isCancellation) {
|
|
||||||
// Cancellation is not a not-ready signal, so stop instead of
|
|
||||||
// sleeping out the window.
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
lastStatus = e.statusCode ?? lastStatus;
|
|
||||||
if (handOffStatus(e.statusCode)) return false;
|
|
||||||
// Transport failure — same treatment as a not-ready response.
|
|
||||||
consecutiveFailures++;
|
|
||||||
appLogger.d('Plex transcode readiness probe transport failure', error: e);
|
|
||||||
} catch (e) {
|
|
||||||
consecutiveFailures++;
|
|
||||||
appLogger.d('Plex transcode readiness probe transport failure', error: e);
|
|
||||||
}
|
|
||||||
var delay = pollInterval;
|
|
||||||
if (consecutiveFailures > 3) {
|
|
||||||
delay = pollInterval * (1 << (consecutiveFailures - 3).clamp(0, 2));
|
|
||||||
}
|
|
||||||
final timeLeft = deadline.difference(clock.now());
|
|
||||||
if (timeLeft <= Duration.zero) break;
|
|
||||||
await Future<void>.delayed(delay < timeLeft ? delay : timeLeft);
|
|
||||||
}
|
|
||||||
appLogger.w(
|
|
||||||
'Plex transcode did not become ready within ${timeout.inMilliseconds}ms '
|
|
||||||
'(playlistDepth=$playlistDepth, lastStatus=${lastStatus ?? 'none'}, consecutiveFailures=$consecutiveFailures)',
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Point the part's server-side subtitle selection at [track] so an imminent
|
/// Point the part's server-side subtitle selection at [track] so an imminent
|
||||||
/// `subtitles=burn` transcode burns *that* stream.
|
/// `subtitles=burn` transcode burns *that* stream.
|
||||||
///
|
///
|
||||||
@@ -3067,7 +2875,6 @@ class PlexClient
|
|||||||
required String sessionIdentifier,
|
required String sessionIdentifier,
|
||||||
required String transcodeSessionId,
|
required String transcodeSessionId,
|
||||||
int? audioStreamId,
|
int? audioStreamId,
|
||||||
Duration? offset,
|
|
||||||
MediaSubtitleTrack? selectedSubtitleTrack,
|
MediaSubtitleTrack? selectedSubtitleTrack,
|
||||||
bool useTsFallbackTarget = false,
|
bool useTsFallbackTarget = false,
|
||||||
}) {
|
}) {
|
||||||
@@ -3108,7 +2915,6 @@ class PlexClient
|
|||||||
'directStreamAudio': '1',
|
'directStreamAudio': '1',
|
||||||
'mediaBufferSize': '102400',
|
'mediaBufferSize': '102400',
|
||||||
'session': transcodeSessionId,
|
'session': transcodeSessionId,
|
||||||
if (offset != null && offset > Duration.zero) 'offset': (offset.inMilliseconds / 1000).toStringAsFixed(6),
|
|
||||||
// `subtitles` is the only subtitle knob this endpoint honours. Which
|
// `subtitles` is the only subtitle knob this endpoint honours. Which
|
||||||
// stream gets burned comes from the part's server-side selection, not
|
// stream gets burned comes from the part's server-side selection, not
|
||||||
// from here: measured against a real PMS, passing `subtitleStreamID` for
|
// from here: measured against a real PMS, passing `subtitleStreamID` for
|
||||||
@@ -3145,7 +2951,6 @@ class PlexClient
|
|||||||
required String sessionIdentifier,
|
required String sessionIdentifier,
|
||||||
required String transcodeSessionId,
|
required String transcodeSessionId,
|
||||||
int? audioStreamId,
|
int? audioStreamId,
|
||||||
Duration? offset,
|
|
||||||
MediaSubtitleTrack? selectedSubtitleTrack,
|
MediaSubtitleTrack? selectedSubtitleTrack,
|
||||||
bool useTsFallbackTarget = false,
|
bool useTsFallbackTarget = false,
|
||||||
}) {
|
}) {
|
||||||
@@ -3157,7 +2962,6 @@ class PlexClient
|
|||||||
sessionIdentifier: sessionIdentifier,
|
sessionIdentifier: sessionIdentifier,
|
||||||
transcodeSessionId: transcodeSessionId,
|
transcodeSessionId: transcodeSessionId,
|
||||||
audioStreamId: audioStreamId,
|
audioStreamId: audioStreamId,
|
||||||
offset: offset,
|
|
||||||
selectedSubtitleTrack: selectedSubtitleTrack,
|
selectedSubtitleTrack: selectedSubtitleTrack,
|
||||||
useTsFallbackTarget: useTsFallbackTarget,
|
useTsFallbackTarget: useTsFallbackTarget,
|
||||||
);
|
);
|
||||||
@@ -3696,7 +3500,6 @@ class PlexClient
|
|||||||
sessionIdentifier: options.sessionIdentifier!,
|
sessionIdentifier: options.sessionIdentifier!,
|
||||||
transcodeSessionId: options.transcodeSessionId!,
|
transcodeSessionId: options.transcodeSessionId!,
|
||||||
audioStreamId: resolvedAudioId,
|
audioStreamId: resolvedAudioId,
|
||||||
offset: options.transcodeOffset,
|
|
||||||
selectedSubtitleTrack: requestedSubtitleTrack,
|
selectedSubtitleTrack: requestedSubtitleTrack,
|
||||||
partId: data.mediaInfo?.getPartId(),
|
partId: data.mediaInfo?.getPartId(),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -177,45 +177,6 @@ class MediaServerHttpClient {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Issue a GET and return only status and headers, draining the body
|
|
||||||
/// unread — the shape for probes that ask "does this answer?" rather than
|
|
||||||
/// "what does it say?".
|
|
||||||
///
|
|
||||||
/// Unlike [getBytes] the status code is surfaced instead of only logged.
|
|
||||||
/// Unlike [get] nothing is ever decoded, so a body that fails decoding
|
|
||||||
/// cannot convert a status into an exception, and — because
|
|
||||||
/// [FailoverHttpClient] overrides [get] alone — this method structurally
|
|
||||||
/// never enters the endpoint-failover cascade. Non-2xx is returned, not
|
|
||||||
/// thrown, matching [get].
|
|
||||||
Future<MediaServerResponse> getStatus(
|
|
||||||
String url, {
|
|
||||||
Map<String, String>? headers,
|
|
||||||
Duration? timeout,
|
|
||||||
AbortController? abort,
|
|
||||||
}) {
|
|
||||||
return _perform<MediaServerResponse>(
|
|
||||||
'GET',
|
|
||||||
url,
|
|
||||||
headers: headers,
|
|
||||||
timeout: timeout,
|
|
||||||
abort: abort,
|
|
||||||
consume: (streamed, scope) async {
|
|
||||||
final effectiveUri = switch (streamed) {
|
|
||||||
http.BaseResponseWithUrl(:final url) => url,
|
|
||||||
_ => scope.uri,
|
|
||||||
};
|
|
||||||
await scope.receive(streamed.stream.drain<void>());
|
|
||||||
scope.logResponse(streamed.statusCode);
|
|
||||||
return MediaServerResponse(
|
|
||||||
statusCode: streamed.statusCode,
|
|
||||||
headers: streamed.headers,
|
|
||||||
requestUri: scope.uri,
|
|
||||||
effectiveUri: effectiveUri,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stream-download a URL directly into a file.
|
/// Stream-download a URL directly into a file.
|
||||||
Future<void> downloadFile(
|
Future<void> downloadFile(
|
||||||
String url,
|
String url,
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:fake_async/fake_async.dart';
|
|
||||||
import 'package:plezy/media/ids.dart';
|
import 'package:plezy/media/ids.dart';
|
||||||
|
|
||||||
import 'package:drift/native.dart';
|
import 'package:drift/native.dart';
|
||||||
@@ -7,7 +6,6 @@ import 'package:flutter_test/flutter_test.dart';
|
|||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:plezy/database/app_database.dart';
|
import 'package:plezy/database/app_database.dart';
|
||||||
import 'package:plezy/exceptions/media_server_exceptions.dart';
|
import 'package:plezy/exceptions/media_server_exceptions.dart';
|
||||||
import 'package:plezy/utils/media_server_http_client.dart' show AbortController;
|
|
||||||
import 'package:plezy/media/media_backend.dart';
|
import 'package:plezy/media/media_backend.dart';
|
||||||
|
|
||||||
import 'package:plezy/media/media_kind.dart';
|
import 'package:plezy/media/media_kind.dart';
|
||||||
@@ -344,7 +342,6 @@ void main() {
|
|||||||
qualityPreset: TranscodeQualityPreset.p720_3mbps,
|
qualityPreset: TranscodeQualityPreset.p720_3mbps,
|
||||||
sessionIdentifier: 'session-id',
|
sessionIdentifier: 'session-id',
|
||||||
transcodeSessionId: 'transcode-id',
|
transcodeSessionId: 'transcode-id',
|
||||||
transcodeOffset: const Duration(minutes: 8, seconds: 15),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -352,7 +349,11 @@ void main() {
|
|||||||
(request) => request.url.path == '/video/:/transcode/universal/decision',
|
(request) => request.url.path == '/video/:/transcode/universal/decision',
|
||||||
);
|
);
|
||||||
expect(decisionRequest.url.queryParameters['subtitles'], 'burn');
|
expect(decisionRequest.url.queryParameters['subtitles'], 'burn');
|
||||||
expect(decisionRequest.url.queryParameters['offset'], '495.000000');
|
expect(
|
||||||
|
decisionRequest.url.queryParameters.containsKey('offset'),
|
||||||
|
isFalse,
|
||||||
|
reason: 'the player seeks in-band; an offset start forces transcoder restarts PMS can wedge on (#1859)',
|
||||||
|
);
|
||||||
expect(
|
expect(
|
||||||
decisionRequest.url.queryParameters.containsKey('subtitleStreamID'),
|
decisionRequest.url.queryParameters.containsKey('subtitleStreamID'),
|
||||||
isFalse,
|
isFalse,
|
||||||
@@ -370,8 +371,7 @@ void main() {
|
|||||||
expect(selection.url.queryParameters.containsKey('audioStreamID'), isFalse);
|
expect(selection.url.queryParameters.containsKey('audioStreamID'), isFalse);
|
||||||
expect(result.isTranscoding, isTrue);
|
expect(result.isTranscoding, isTrue);
|
||||||
expect(result.videoUrl, contains('/video/:/transcode/universal/start.m3u8?'));
|
expect(result.videoUrl, contains('/video/:/transcode/universal/start.m3u8?'));
|
||||||
expect(Uri.parse(result.videoUrl!).queryParameters['offset'], '495.000000');
|
expect(Uri.parse(result.videoUrl!).queryParameters.containsKey('offset'), isFalse);
|
||||||
expect(PlexClient.transcodeStreamOffsetFromUrl(result.videoUrl!), const Duration(minutes: 8, seconds: 15));
|
|
||||||
expect(result.subtitleSidecars.map((sidecar) => sidecar.sourceStreamId), [402]);
|
expect(result.subtitleSidecars.map((sidecar) => sidecar.sourceStreamId), [402]);
|
||||||
expect(result.subtitleSidecars.single.preload, isTrue);
|
expect(result.subtitleSidecars.single.preload, isTrue);
|
||||||
expect(result.subtitleSidecars.single.track.uri, contains('/library/streams/402.srt'));
|
expect(result.subtitleSidecars.single.track.uri, contains('/library/streams/402.srt'));
|
||||||
@@ -1159,110 +1159,6 @@ void main() {
|
|||||||
expect(startPath, isNot(contains('X-Plex-Token')));
|
expect(startPath, isNot(contains('X-Plex-Token')));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('transcode start path carries the requested decision offset', () {
|
|
||||||
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',
|
|
||||||
offset: const Duration(minutes: 8, seconds: 15),
|
|
||||||
);
|
|
||||||
|
|
||||||
final startPath = client.buildTranscodeStartPathFromParamsForTesting(params);
|
|
||||||
|
|
||||||
expect(startPath, startsWith('/video/:/transcode/universal/start.m3u8?'));
|
|
||||||
expect(Uri.parse(startPath).queryParameters['offset'], '495.000000');
|
|
||||||
expect(startPath, isNot(contains('X-Plex-Token')));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('transcode stream offset is parsed from an offset start URL only', () {
|
|
||||||
expect(
|
|
||||||
PlexClient.transcodeStreamOffsetFromUrl(
|
|
||||||
'https://plex.example.com/video/:/transcode/universal/start.m3u8?offset=495.000000',
|
|
||||||
),
|
|
||||||
const Duration(minutes: 8, seconds: 15),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
PlexClient.transcodeStreamOffsetFromUrl(
|
|
||||||
'https://plex.example.com/video/:/transcode/universal/start.m3u8?session=abc',
|
|
||||||
),
|
|
||||||
isNull,
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
PlexClient.transcodeStreamOffsetFromUrl(
|
|
||||||
'https://plex.example.com/video/:/transcode/universal/start.m3u8?offset=0.000000',
|
|
||||||
),
|
|
||||||
isNull,
|
|
||||||
);
|
|
||||||
expect(PlexClient.transcodeStreamOffsetFromUrl('https://plex.example.com/library/parts/1/file.mkv'), isNull);
|
|
||||||
// Percent-encoded spelling of the same path must not silently switch the
|
|
||||||
// probe off.
|
|
||||||
expect(
|
|
||||||
PlexClient.transcodeStreamOffsetFromUrl(
|
|
||||||
'https://plex.example.com/video/%3A/transcode/universal/start.m3u8?offset=495.000000',
|
|
||||||
),
|
|
||||||
const Duration(minutes: 8, seconds: 15),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('transcode readiness probes the segment at the offset, not the first one', () async {
|
|
||||||
final requests = <Uri>[];
|
|
||||||
final rangeHeaders = <String?>[];
|
|
||||||
final acceptHeaders = <String?>[];
|
|
||||||
var segmentAttempts = 0;
|
|
||||||
// Plex media playlists cover the full title from segment zero; the probe
|
|
||||||
// must touch the offset's segment because requesting a segment is how a
|
|
||||||
// client seeks a Plex HLS session — probing 00000.ts would relocate the
|
|
||||||
// transcoder back to the start.
|
|
||||||
final mediaPlaylist = StringBuffer('#EXTM3U\n#EXT-X-TARGETDURATION:1\n');
|
|
||||||
for (var i = 0; i < 600; i++) {
|
|
||||||
mediaPlaylist.write('#EXTINF:1.000000,\n${i.toString().padLeft(5, '0')}.ts\n');
|
|
||||||
}
|
|
||||||
final client = makeClient((request) async {
|
|
||||||
requests.add(request.url);
|
|
||||||
rangeHeaders.add(request.headers['Range']);
|
|
||||||
acceptHeaders.add(request.headers['Accept']);
|
|
||||||
return switch (request.url.path) {
|
|
||||||
'/video/:/transcode/universal/start.m3u8' => http.Response(
|
|
||||||
'#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=1500000\nindex.m3u8?session=transcode-id\n',
|
|
||||||
200,
|
|
||||||
),
|
|
||||||
'/video/:/transcode/universal/index.m3u8' => http.Response(mediaPlaylist.toString(), 200),
|
|
||||||
'/video/:/transcode/universal/00495.ts' =>
|
|
||||||
++segmentAttempts == 1
|
|
||||||
? http.Response('not ready', 404)
|
|
||||||
: http.Response('segment bytes', 206, headers: {'content-type': 'video/mp2t'}),
|
|
||||||
_ => http.Response('unexpected request', 500),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
addTearDown(client.close);
|
|
||||||
|
|
||||||
final ready = await client.waitForTranscodeReady(
|
|
||||||
'https://plex.example.com/video/:/transcode/universal/start.m3u8?offset=495.500000',
|
|
||||||
timeout: const Duration(seconds: 1),
|
|
||||||
pollInterval: Duration.zero,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(ready, isTrue);
|
|
||||||
expect(segmentAttempts, 2);
|
|
||||||
expect(requests.map((uri) => uri.path), [
|
|
||||||
'/video/:/transcode/universal/start.m3u8',
|
|
||||||
'/video/:/transcode/universal/index.m3u8',
|
|
||||||
'/video/:/transcode/universal/00495.ts',
|
|
||||||
'/video/:/transcode/universal/00495.ts',
|
|
||||||
]);
|
|
||||||
expect(requests[1].queryParameters['session'], 'transcode-id');
|
|
||||||
expect(rangeHeaders, [null, null, 'bytes=0-0', 'bytes=0-0']);
|
|
||||||
// The client's default Accept is application/json; the probe must mirror
|
|
||||||
// the player's request shape instead.
|
|
||||||
expect(acceptHeaders, everyElement('*/*'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('an embedded subtitle this server cannot burn falls back as a failure', () async {
|
test('an embedded subtitle this server cannot burn falls back as a failure', () async {
|
||||||
// The row has no `key`, so it is embedded and a transcode would have to burn it; `dvb_teletext`
|
// The row has no `key`, so it is embedded and a transcode would have to burn it; `dvb_teletext`
|
||||||
// is not a codec the burn path accepts. Treating that as "no burn requested" sent
|
// is not a codec the burn path accepts. Treating that as "no burn requested" sent
|
||||||
@@ -1394,264 +1290,6 @@ void main() {
|
|||||||
expect(result.playMethod, 'DirectPlay', reason: 'direct play lets the native player read it');
|
expect(result.playMethod, 'DirectPlay', reason: 'direct play lets the native player read it');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('readiness probe target selection walks segment durations to the offset', () {
|
|
||||||
const mediaPlaylist =
|
|
||||||
'#EXTM3U\n'
|
|
||||||
'#EXT-X-TARGETDURATION:2\n'
|
|
||||||
'#EXTINF:2.000000,\n00000.ts\n'
|
|
||||||
'#EXTINF:2.000000,\n00001.ts\n'
|
|
||||||
'#EXTINF:2.000000,\n00002.ts\n';
|
|
||||||
expect(PlexClient.selectReadinessProbeTarget(mediaPlaylist, Duration.zero), '00000.ts');
|
|
||||||
expect(PlexClient.selectReadinessProbeTarget(mediaPlaylist, const Duration(seconds: 3)), '00001.ts');
|
|
||||||
// Durations never cross the offset: the playlist cannot say where the
|
|
||||||
// offset lives, and probing a guessed segment would seek the session.
|
|
||||||
expect(PlexClient.selectReadinessProbeTarget(mediaPlaylist, const Duration(seconds: 30)), isNull);
|
|
||||||
// A master playlist has no segment durations: descend into the first variant.
|
|
||||||
const masterPlaylist = '#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=1500000\nindex.m3u8\nfallback.m3u8\n';
|
|
||||||
expect(PlexClient.selectReadinessProbeTarget(masterPlaylist, const Duration(minutes: 10)), 'index.m3u8');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('transcode readiness skips probing when the playlist cannot locate the offset', () {
|
|
||||||
fakeAsync((async) {
|
|
||||||
final requestPaths = <String>[];
|
|
||||||
// The offset-relative playlist shape this client has never observed
|
|
||||||
// against a real PMS: segments named from the resume point, durations
|
|
||||||
// summing to a minute. Probing its last segment would ask the session
|
|
||||||
// to produce a minute past the resume point.
|
|
||||||
final mediaPlaylist = StringBuffer('#EXTM3U\n#EXT-X-TARGETDURATION:1\n');
|
|
||||||
for (var i = 8062; i <= 8121; i++) {
|
|
||||||
mediaPlaylist.write('#EXTINF:1.000000,\n${i.toString().padLeft(5, '0')}.ts\n');
|
|
||||||
}
|
|
||||||
final client = makeClient((request) async {
|
|
||||||
requestPaths.add(request.url.path);
|
|
||||||
if (request.url.path.endsWith('/start.m3u8')) {
|
|
||||||
return http.Response(mediaPlaylist.toString(), 200);
|
|
||||||
}
|
|
||||||
return http.Response('segment bytes', 206, headers: {'content-type': 'video/mp2t'});
|
|
||||||
});
|
|
||||||
addTearDown(client.close);
|
|
||||||
|
|
||||||
bool? ready;
|
|
||||||
client
|
|
||||||
.waitForTranscodeReady(
|
|
||||||
'https://plex.example.com/video/:/transcode/universal/start.m3u8?offset=8062.000000',
|
|
||||||
timeout: const Duration(seconds: 15),
|
|
||||||
pollInterval: const Duration(milliseconds: 500),
|
|
||||||
)
|
|
||||||
.then((value) => ready = value);
|
|
||||||
async.flushMicrotasks();
|
|
||||||
|
|
||||||
expect(ready, isTrue);
|
|
||||||
expect(requestPaths.where((path) => path.endsWith('.ts')), isEmpty);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('transcode readiness hands off on a 500 that arrives inside a decode exception', () {
|
|
||||||
fakeAsync((async) {
|
|
||||||
var requestCount = 0;
|
|
||||||
// A proxy or gateway in front of PMS answering 500 with a JSON
|
|
||||||
// content-type and a non-JSON body: the client's body decode throws a
|
|
||||||
// status-bearing exception instead of returning the response, and the
|
|
||||||
// exception path must apply the same hand-off rule as the response
|
|
||||||
// path.
|
|
||||||
final client = makeClient((request) async {
|
|
||||||
requestCount++;
|
|
||||||
return http.Response('<html>gateway error</html>', 500, headers: {'content-type': 'application/json'});
|
|
||||||
});
|
|
||||||
addTearDown(client.close);
|
|
||||||
|
|
||||||
bool? ready;
|
|
||||||
client
|
|
||||||
.waitForTranscodeReady(
|
|
||||||
'https://plex.example.com/video/:/transcode/universal/start.m3u8?offset=495.000000',
|
|
||||||
timeout: const Duration(seconds: 15),
|
|
||||||
pollInterval: const Duration(milliseconds: 500),
|
|
||||||
)
|
|
||||||
.then((value) => ready = value);
|
|
||||||
// No elapse: like the response-path 500, the exception-path 500 must
|
|
||||||
// complete the probe without a single poll wait.
|
|
||||||
async.flushMicrotasks();
|
|
||||||
|
|
||||||
expect(ready, isFalse);
|
|
||||||
expect(requestCount, 1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('transcode readiness returns immediately when the probe is already aborted', () async {
|
|
||||||
var requestCount = 0;
|
|
||||||
final client = makeClient((request) async {
|
|
||||||
requestCount++;
|
|
||||||
return http.Response('#EXTM3U\n#EXTINF:1.0,\nmedia-00000.ts\n', 200);
|
|
||||||
});
|
|
||||||
addTearDown(client.close);
|
|
||||||
|
|
||||||
final abort = AbortController()..abort();
|
|
||||||
final ready = await client.waitForTranscodeReady(
|
|
||||||
'https://plex.example.com/video/:/transcode/universal/start.m3u8?offset=0.500000',
|
|
||||||
abort: abort,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(ready, isFalse);
|
|
||||||
expect(requestCount, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('transcode readiness polls a bounded number of times while the segment stays unavailable', () {
|
|
||||||
fakeAsync((async) {
|
|
||||||
var requestCount = 0;
|
|
||||||
final client = makeClient((request) async {
|
|
||||||
requestCount++;
|
|
||||||
if (request.url.path.endsWith('/start.m3u8')) {
|
|
||||||
return http.Response('#EXTM3U\n#EXTINF:1.0,\nmedia-00000.ts\n', 200);
|
|
||||||
}
|
|
||||||
return http.Response('not ready', 404);
|
|
||||||
});
|
|
||||||
addTearDown(client.close);
|
|
||||||
|
|
||||||
bool? ready;
|
|
||||||
client
|
|
||||||
.waitForTranscodeReady(
|
|
||||||
'https://plex.example.com/video/:/transcode/universal/start.m3u8?offset=0.500000',
|
|
||||||
timeout: const Duration(milliseconds: 600),
|
|
||||||
pollInterval: const Duration(milliseconds: 50),
|
|
||||||
)
|
|
||||||
.then((value) => ready = value);
|
|
||||||
async.elapse(const Duration(milliseconds: 700));
|
|
||||||
|
|
||||||
expect(ready, isFalse);
|
|
||||||
// The not-ready signal is a non-2xx *response*, not an exception: every
|
|
||||||
// failed probe must still wait out the poll interval, and after three
|
|
||||||
// consecutive failures the interval doubles to a 4x cap. Under a fake
|
|
||||||
// clock the cadence is exact: the playlist hop, then segment polls at
|
|
||||||
// 0/50/100/150/250/450ms. Drafts of this probe that skipped the delay
|
|
||||||
// on non-2xx or discarded the backoff measured 3,651 and 12 requests
|
|
||||||
// respectively in this same window; neither shape ever shipped.
|
|
||||||
expect(requestCount, 7);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('transcode readiness requests bypass endpoint failover', () {
|
|
||||||
fakeAsync((async) {
|
|
||||||
var exhaustedSignals = 0;
|
|
||||||
var requestCount = 0;
|
|
||||||
final client = testPlexClient(
|
|
||||||
serverId: ServerId('server-id'),
|
|
||||||
prioritizedEndpoints: const ['https://plex.example.com'],
|
|
||||||
onAllEndpointsExhausted: () => exhaustedSignals++,
|
|
||||||
// 503 on the playlist itself: the segment leg goes through getRaw,
|
|
||||||
// which never enters the failover path, so the playlist request is
|
|
||||||
// the one that could drive the cascade.
|
|
||||||
handler: (request) async {
|
|
||||||
requestCount++;
|
|
||||||
return http.Response('busy', 503);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
addTearDown(client.close);
|
|
||||||
|
|
||||||
bool? ready;
|
|
||||||
client
|
|
||||||
.waitForTranscodeReady(
|
|
||||||
'https://plex.example.com/video/:/transcode/universal/start.m3u8?offset=495.000000',
|
|
||||||
timeout: const Duration(milliseconds: 200),
|
|
||||||
pollInterval: const Duration(milliseconds: 50),
|
|
||||||
)
|
|
||||||
.then((value) => ready = value);
|
|
||||||
async.elapse(const Duration(milliseconds: 300));
|
|
||||||
|
|
||||||
expect(ready, isFalse);
|
|
||||||
expect(requestCount, greaterThanOrEqualTo(3));
|
|
||||||
// The probe carries its own retry budget, so it must not drive the
|
|
||||||
// endpoint-failover cascade: its URL is absolute (the retry re-hits the
|
|
||||||
// same host), each switch rewrites config.baseUrl underneath the
|
|
||||||
// videoUrl already handed to the open path, and on a single-endpoint
|
|
||||||
// server every failed poll fires the all-endpoints-exhausted signal —
|
|
||||||
// the manager's cue to flip server status and reconnect.
|
|
||||||
expect(exhaustedSignals, 0);
|
|
||||||
expect(client.config.baseUrl, 'https://plex.example.com');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('transcode readiness stops on cancellation instead of sleeping out the window', () {
|
|
||||||
fakeAsync((async) {
|
|
||||||
var requestCount = 0;
|
|
||||||
late final PlexClient client;
|
|
||||||
client = testPlexClient(
|
|
||||||
serverId: ServerId('server-id'),
|
|
||||||
handler: (request) async {
|
|
||||||
requestCount++;
|
|
||||||
if (request.url.path.endsWith('/start.m3u8')) {
|
|
||||||
return http.Response('#EXTM3U\n#EXTINF:1.0,\nmedia-00000.ts\n', 200);
|
|
||||||
}
|
|
||||||
// The owner closes mid-probe; the next request must surface as a
|
|
||||||
// cancellation, not as one more not-ready round.
|
|
||||||
client.close();
|
|
||||||
return http.Response('not ready', 404);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
bool? ready;
|
|
||||||
client
|
|
||||||
.waitForTranscodeReady(
|
|
||||||
'https://plex.example.com/video/:/transcode/universal/start.m3u8?offset=0.500000',
|
|
||||||
timeout: const Duration(milliseconds: 600),
|
|
||||||
pollInterval: const Duration(milliseconds: 50),
|
|
||||||
)
|
|
||||||
.then((value) => ready = value);
|
|
||||||
// One poll interval is all it may consume after the close; a probe
|
|
||||||
// that counts cancellation as not-ready sleeps out the full 600ms.
|
|
||||||
async.elapse(const Duration(milliseconds: 100));
|
|
||||||
|
|
||||||
expect(ready, isFalse);
|
|
||||||
expect(requestCount, 2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('transcode readiness hands off immediately on HTTP 500', () {
|
|
||||||
fakeAsync((async) {
|
|
||||||
var requestCount = 0;
|
|
||||||
final client = makeClient((request) async {
|
|
||||||
requestCount++;
|
|
||||||
if (request.url.path.endsWith('/start.m3u8')) {
|
|
||||||
return http.Response('#EXTM3U\n#EXTINF:1.0,\nmedia-00000.ts\n', 200);
|
|
||||||
}
|
|
||||||
return http.Response('limit rejected', 500);
|
|
||||||
});
|
|
||||||
addTearDown(client.close);
|
|
||||||
|
|
||||||
bool? ready;
|
|
||||||
client
|
|
||||||
.waitForTranscodeReady(
|
|
||||||
'https://plex.example.com/video/:/transcode/universal/start.m3u8?offset=0.500000',
|
|
||||||
timeout: const Duration(seconds: 15),
|
|
||||||
pollInterval: const Duration(milliseconds: 500),
|
|
||||||
)
|
|
||||||
.then((value) => ready = value);
|
|
||||||
// No elapse: a 500 must complete the probe without a single poll wait,
|
|
||||||
// so the player opens promptly and the server-limit dialog path runs.
|
|
||||||
async.flushMicrotasks();
|
|
||||||
|
|
||||||
expect(ready, isFalse);
|
|
||||||
expect(requestCount, 2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('transcode readiness returns immediately for URLs without an offset', () async {
|
|
||||||
var requestCount = 0;
|
|
||||||
final client = makeClient((request) async {
|
|
||||||
requestCount++;
|
|
||||||
return http.Response('should not be called', 500);
|
|
||||||
});
|
|
||||||
addTearDown(client.close);
|
|
||||||
|
|
||||||
// Probing a no-offset playlist would touch segment zero, and requesting
|
|
||||||
// a segment is how a client seeks a Plex HLS session.
|
|
||||||
expect(
|
|
||||||
await client.waitForTranscodeReady('https://plex.example.com/video/:/transcode/universal/start.m3u8?session=x'),
|
|
||||||
isTrue,
|
|
||||||
);
|
|
||||||
expect(await client.waitForTranscodeReady('https://plex.example.com/library/parts/1/file.mkv'), isTrue);
|
|
||||||
expect(requestCount, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('transcode params preserve resolved media and part indices', () {
|
test('transcode params preserve resolved media and part indices', () {
|
||||||
final client = makeClient((_) async => http.Response('not used', 500));
|
final client = makeClient((_) async => http.Response('not used', 500));
|
||||||
addTearDown(client.close);
|
addTearDown(client.close);
|
||||||
|
|||||||
Reference in New Issue
Block a user