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 '../mpv/mpv.dart'; import '../utils/app_logger.dart'; import '../i18n/strings.g.dart'; import '../database/app_database.dart'; import 'download_storage_service.dart'; import 'dart:io'; import 'package:drift/drift.dart'; /// 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 getOfflineVideoPath(String serverId, String ratingKey, {int mediaIndex = 0}) async { if (database == null) { return null; } try { // Query database for downloaded media with matching serverId and ratingKey final query = database!.select(database!.downloadedMedia) ..where((tbl) => tbl.serverId.equals(serverId) & tbl.ratingKey.equals(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. Future getPlaybackData({ required PlexMetadata metadata, required int selectedMediaIndex, bool preferOffline = false, PlexVideoPlaybackData? playbackData, }) 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); } // 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())); } } /// Build list of external subtitle tracks from media info List _buildExternalSubtitles(PlexMediaInfo? mediaInfo) { final externalSubtitles = []; 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; } } /// Result of playback initialization class PlaybackInitializationResult { final List availableVersions; final String? videoUrl; final PlexMediaInfo? mediaInfo; final List externalSubtitles; final bool isOffline; PlaybackInitializationResult({ required this.availableVersions, this.videoUrl, this.mediaInfo, this.externalSubtitles = const [], this.isOffline = false, }); } /// Exception thrown when playback initialization fails class PlaybackException implements Exception { final String message; PlaybackException(this.message); @override String toString() => message; }