361 lines
13 KiB
Dart
361 lines
13 KiB
Dart
import 'plex_client.dart';
|
|
import '../models/plex_media_info.dart';
|
|
import '../models/plex_metadata.dart';
|
|
import '../models/plex_video_playback_data.dart';
|
|
import '../models/download_models.dart';
|
|
import '../models/transcode_quality_preset.dart';
|
|
import '../mpv/mpv.dart';
|
|
import '../utils/app_logger.dart';
|
|
import '../utils/global_key_utils.dart';
|
|
import '../utils/plex_url_helper.dart';
|
|
import '../i18n/strings.g.dart';
|
|
import '../database/app_database.dart';
|
|
import 'download_storage_service.dart';
|
|
import 'dart:io';
|
|
|
|
/// Service responsible for fetching video playback data from the Plex server
|
|
class PlaybackInitializationService {
|
|
final PlexClient client;
|
|
final AppDatabase? database;
|
|
|
|
PlaybackInitializationService({required this.client, this.database});
|
|
|
|
/// Format a video path as a URL (adds file:// prefix for file paths)
|
|
String _formatVideoUrl(String path) {
|
|
return path.contains('://') ? path : 'file://$path';
|
|
}
|
|
|
|
/// Check if content is available offline and return local path
|
|
///
|
|
/// Returns the local file path if the video is downloaded and completed.
|
|
/// Returns null if not available offline or database is not provided.
|
|
Future<String?> getOfflineVideoPath(String serverId, String ratingKey, {int mediaIndex = 0}) async {
|
|
if (database == null) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
// Query by globalKey — the column is UNIQUE so SQLite's auto-index on it
|
|
// makes this an O(log n) lookup. Filtering by (serverId, ratingKey)
|
|
// would only use the serverId index and then linear-scan matching rows.
|
|
final query = database!.select(database!.downloadedMedia)
|
|
..where((tbl) => tbl.globalKey.equals(buildGlobalKey(serverId, ratingKey)));
|
|
|
|
final downloadedItem = await query.getSingleOrNull();
|
|
|
|
// Return null if not found or not completed
|
|
if (downloadedItem == null || downloadedItem.status != DownloadStatus.completed.index) {
|
|
return null;
|
|
}
|
|
|
|
// Skip offline file if a different version was requested
|
|
if (downloadedItem.mediaIndex != mediaIndex) {
|
|
appLogger.d(
|
|
'[VersionTrace] Offline video is version ${downloadedItem.mediaIndex}, '
|
|
'but requested version $mediaIndex — skipping offline',
|
|
);
|
|
return null;
|
|
}
|
|
|
|
// Return null if no video file path
|
|
if (downloadedItem.videoFilePath == null) {
|
|
return null;
|
|
}
|
|
|
|
final storageService = DownloadStorageService.instance;
|
|
final storedPath = downloadedItem.videoFilePath!;
|
|
|
|
// Get readable path (handles both SAF URIs and file paths)
|
|
final readablePath = await storageService.getReadablePath(storedPath);
|
|
|
|
// For file paths (not SAF), verify the file exists
|
|
if (!storageService.isSafUri(storedPath)) {
|
|
final file = File(readablePath);
|
|
if (!await file.exists()) {
|
|
appLogger.w('Offline video file not found: $readablePath (stored as: $storedPath)');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
appLogger.d('Found offline video: $readablePath');
|
|
return readablePath;
|
|
} catch (e) {
|
|
appLogger.w('Error checking offline video path', error: e);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// Fetch playback data for the given metadata
|
|
///
|
|
/// Returns a PlaybackInitializationResult with video URL and available versions
|
|
/// If [preferOffline] is true and offline content is available, uses local file
|
|
/// If [playbackData] is provided, skips the network call to fetch it again.
|
|
/// When [qualityPreset] is non-original and online, the video URL is built
|
|
/// against Plex's transcode start endpoint and all subtitle tracks are
|
|
/// sidecar-attached since the transcoded stream carries none.
|
|
Future<PlaybackInitializationResult> getPlaybackData({
|
|
required PlexMetadata metadata,
|
|
required int selectedMediaIndex,
|
|
bool preferOffline = false,
|
|
PlexVideoPlaybackData? playbackData,
|
|
TranscodeQualityPreset qualityPreset = TranscodeQualityPreset.original,
|
|
int? selectedAudioStreamId,
|
|
String? sessionIdentifier,
|
|
String? transcodeSessionId,
|
|
}) async {
|
|
try {
|
|
// Check for offline content first if preferOffline is enabled
|
|
String? offlineVideoPath;
|
|
if (preferOffline && database != null) {
|
|
offlineVideoPath = await getOfflineVideoPath(
|
|
client.serverId,
|
|
metadata.ratingKey,
|
|
mediaIndex: selectedMediaIndex,
|
|
);
|
|
}
|
|
|
|
// If offline video is available, use it
|
|
if (offlineVideoPath != null) {
|
|
appLogger.d('Using offline playback for ${metadata.ratingKey}');
|
|
|
|
// For offline playback, we still need to fetch media info for subtitles
|
|
// but use the local file path for video
|
|
try {
|
|
final data =
|
|
playbackData ?? await client.getVideoPlaybackData(metadata.ratingKey, mediaIndex: selectedMediaIndex);
|
|
|
|
// Build list of external subtitle tracks
|
|
final externalSubtitles = _buildExternalSubtitles(data.mediaInfo);
|
|
|
|
// Return result with local file path
|
|
return PlaybackInitializationResult(
|
|
availableVersions: data.availableVersions,
|
|
videoUrl: _formatVideoUrl(offlineVideoPath),
|
|
mediaInfo: data.mediaInfo,
|
|
externalSubtitles: externalSubtitles,
|
|
isOffline: true,
|
|
);
|
|
} catch (e) {
|
|
// If we can't fetch media info (e.g., no network), use offline-only mode
|
|
appLogger.w('Failed to fetch media info for offline video, using offline-only mode', error: e);
|
|
return PlaybackInitializationResult(
|
|
availableVersions: [],
|
|
videoUrl: _formatVideoUrl(offlineVideoPath),
|
|
mediaInfo: null,
|
|
externalSubtitles: const [],
|
|
isOffline: true,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Use pre-parsed data or fall back to network streaming
|
|
final data =
|
|
playbackData ?? await client.getVideoPlaybackData(metadata.ratingKey, mediaIndex: selectedMediaIndex);
|
|
|
|
if (!data.hasValidVideoUrl) {
|
|
throw PlaybackException(t.messages.fileInfoNotAvailable);
|
|
}
|
|
|
|
final wantTranscode = !qualityPreset.isOriginal;
|
|
if (wantTranscode && sessionIdentifier != null && transcodeSessionId != null) {
|
|
final resolvedAudioId = _resolveAudioStreamId(selectedAudioStreamId, data.mediaInfo);
|
|
// Note: no `offsetMs` — seeking is handled by the player via the HLS
|
|
// manifest, matching Plex Web's behavior. Baking `offset=` into the URL
|
|
// makes the server pre-position the transcoder, but the resulting
|
|
// segments and mpv's native HLS positioning fight each other, leaving
|
|
// the player clock at 0 and desyncing sidecar subtitles.
|
|
final result = await client.buildTranscodeStartPath(
|
|
ratingKey: metadata.ratingKey,
|
|
mediaIndex: selectedMediaIndex,
|
|
preset: qualityPreset,
|
|
sessionIdentifier: sessionIdentifier,
|
|
transcodeSessionId: transcodeSessionId,
|
|
audioStreamId: resolvedAudioId,
|
|
);
|
|
|
|
if (result.outcome == TranscodeDecisionOutcome.transcodeOk && result.startPath != null) {
|
|
final transcodeUrl = '${client.config.baseUrl}${result.startPath}'.withPlexToken(client.config.token);
|
|
final sidecarSubs = _buildTranscodeSidecarSubtitles(data.mediaInfo);
|
|
return PlaybackInitializationResult(
|
|
availableVersions: data.availableVersions,
|
|
videoUrl: transcodeUrl,
|
|
mediaInfo: data.mediaInfo,
|
|
externalSubtitles: sidecarSubs,
|
|
isOffline: false,
|
|
isTranscoding: true,
|
|
activeAudioStreamId: resolvedAudioId,
|
|
);
|
|
}
|
|
|
|
// Decision failed or said direct-play only — fall through to direct-play path
|
|
// and surface the fallback reason so the UI can notify the user.
|
|
final fallbackReason = result.outcome == TranscodeDecisionOutcome.directPlayOnly
|
|
? TranscodeFallbackReason.directPlayOnly
|
|
: TranscodeFallbackReason.decisionFailed;
|
|
appLogger.w('Transcode decision fell back to direct play: ${fallbackReason.name}');
|
|
return PlaybackInitializationResult(
|
|
availableVersions: data.availableVersions,
|
|
videoUrl: data.videoUrl,
|
|
mediaInfo: data.mediaInfo,
|
|
externalSubtitles: _buildExternalSubtitles(data.mediaInfo),
|
|
isOffline: false,
|
|
isTranscoding: false,
|
|
fallbackReason: fallbackReason,
|
|
);
|
|
}
|
|
|
|
// Build list of external subtitle tracks
|
|
final externalSubtitles = _buildExternalSubtitles(data.mediaInfo);
|
|
|
|
// Return result with available versions and video URL
|
|
return PlaybackInitializationResult(
|
|
availableVersions: data.availableVersions,
|
|
videoUrl: data.videoUrl,
|
|
mediaInfo: data.mediaInfo,
|
|
externalSubtitles: externalSubtitles,
|
|
isOffline: false,
|
|
);
|
|
} catch (e) {
|
|
if (e is PlaybackException) {
|
|
rethrow;
|
|
}
|
|
throw PlaybackException(t.messages.errorLoading(error: e.toString()));
|
|
}
|
|
}
|
|
|
|
/// Pick the audio stream ID to send to the transcoder. Preference order:
|
|
/// explicit [explicit] → audio track with `selected == true` → first → null.
|
|
int? _resolveAudioStreamId(int? explicit, PlexMediaInfo? info) {
|
|
if (explicit != null) return explicit;
|
|
if (info == null) return null;
|
|
final tracks = info.audioTracks;
|
|
if (tracks.isEmpty) return null;
|
|
for (final track in tracks) {
|
|
if (track.selected) return track.id;
|
|
}
|
|
return tracks.first.id;
|
|
}
|
|
|
|
/// Build sidecar SubtitleTracks for ALL source subtitle streams (internal +
|
|
/// external) so the player can hot-swap between them when the main stream
|
|
/// is transcoded and has no embedded subs.
|
|
List<SubtitleTrack> _buildTranscodeSidecarSubtitles(PlexMediaInfo? mediaInfo) {
|
|
if (mediaInfo == null) return const [];
|
|
final token = client.config.token;
|
|
if (token == null) {
|
|
appLogger.w('No auth token available for transcode sidecar subtitles');
|
|
return const [];
|
|
}
|
|
|
|
final tracks = <SubtitleTrack>[];
|
|
for (final sub in mediaInfo.subtitleTracks) {
|
|
try {
|
|
final url = sub.getTranscodeSidecarUrl(client.config.baseUrl, token);
|
|
tracks.add(
|
|
SubtitleTrack.uri(
|
|
url,
|
|
title: sub.displayTitle ?? sub.language ?? 'Track ${sub.id}',
|
|
language: sub.languageCode,
|
|
),
|
|
);
|
|
} catch (e) {
|
|
appLogger.w('Failed to build sidecar subtitle for stream ${sub.id}', error: e);
|
|
}
|
|
}
|
|
return tracks;
|
|
}
|
|
|
|
/// Build list of external subtitle tracks from media info
|
|
List<SubtitleTrack> _buildExternalSubtitles(PlexMediaInfo? mediaInfo) {
|
|
final externalSubtitles = <SubtitleTrack>[];
|
|
|
|
if (mediaInfo == null) {
|
|
return externalSubtitles;
|
|
}
|
|
|
|
final externalTracks = mediaInfo.subtitleTracks.where((PlexSubtitleTrack track) => track.isExternal).toList();
|
|
|
|
if (externalTracks.isNotEmpty) {
|
|
appLogger.d('Found ${externalTracks.length} external subtitle track(s)');
|
|
}
|
|
|
|
for (final plexTrack in externalTracks) {
|
|
try {
|
|
// Skip if no auth token is available
|
|
final token = client.config.token;
|
|
if (token == null) {
|
|
appLogger.w('No auth token available for external subtitles');
|
|
continue;
|
|
}
|
|
|
|
final url = plexTrack.getSubtitleUrl(client.config.baseUrl, token);
|
|
|
|
// Skip if URL couldn't be constructed
|
|
if (url == null) continue;
|
|
|
|
externalSubtitles.add(
|
|
SubtitleTrack.uri(
|
|
url,
|
|
title: plexTrack.displayTitle ?? plexTrack.language ?? 'Track ${plexTrack.id}',
|
|
language: plexTrack.languageCode,
|
|
),
|
|
);
|
|
} catch (e) {
|
|
// Silent fallback - log error but continue with other subtitles
|
|
appLogger.w('Failed to add external subtitle track ${plexTrack.id}', error: e);
|
|
}
|
|
}
|
|
|
|
return externalSubtitles;
|
|
}
|
|
}
|
|
|
|
/// Reason the transcode branch fell back to direct play.
|
|
enum TranscodeFallbackReason {
|
|
/// Plex decision said only direct-play is available.
|
|
directPlayOnly,
|
|
|
|
/// The decision endpoint errored (HTTP error, code >= 2000, parse failure).
|
|
decisionFailed,
|
|
}
|
|
|
|
/// Result of playback initialization
|
|
class PlaybackInitializationResult {
|
|
final List<dynamic> availableVersions;
|
|
final String? videoUrl;
|
|
final PlexMediaInfo? mediaInfo;
|
|
final List<SubtitleTrack> externalSubtitles;
|
|
final bool isOffline;
|
|
|
|
/// `true` when [videoUrl] is a Plex transcode start URL.
|
|
final bool isTranscoding;
|
|
|
|
/// Non-null when a non-original preset was requested but fallback kicked in.
|
|
final TranscodeFallbackReason? fallbackReason;
|
|
|
|
/// The Plex audio stream ID actually passed to the transcoder (`null` when
|
|
/// not transcoding or when no audio stream was selectable).
|
|
final int? activeAudioStreamId;
|
|
|
|
PlaybackInitializationResult({
|
|
required this.availableVersions,
|
|
this.videoUrl,
|
|
this.mediaInfo,
|
|
this.externalSubtitles = const [],
|
|
this.isOffline = false,
|
|
this.isTranscoding = false,
|
|
this.fallbackReason,
|
|
this.activeAudioStreamId,
|
|
});
|
|
}
|
|
|
|
/// Exception thrown when playback initialization fails
|
|
class PlaybackException implements Exception {
|
|
final String message;
|
|
|
|
PlaybackException(this.message);
|
|
|
|
@override
|
|
String toString() => message;
|
|
}
|