import 'dart:convert'; import 'dart:io'; import 'package:crypto/crypto.dart'; import 'package:flutter/foundation.dart'; import 'package:path_provider/path_provider.dart'; import 'package:path/path.dart' as path; import '../media/media_item.dart'; import '../utils/app_logger.dart'; import '../utils/formatters.dart'; import 'settings_service.dart'; /// Thrown when the downloads storage layer cannot create or access a directory /// (permission denied, quota exceeded, SAF permission revoked, etc.). class DownloadStorageException implements Exception { final String message; final String path; final Object cause; DownloadStorageException(this.message, this.path, this.cause); @override String toString() => 'DownloadStorageException: $message (path: $path, cause: $cause)'; } class DownloadStorageService { static DownloadStorageService? _instance; static DownloadStorageService get instance => _instance ??= DownloadStorageService._(); DownloadStorageService._(); /// Drop the cached singleton so the next [instance] call returns a fresh /// service. Test-only. @visibleForTesting static void resetForTesting() { _instance = null; } Directory? _baseDownloadsDir; String? _artworkDirectoryPath; SettingsService? _settingsService; String? _customDownloadPath; String _customPathType = 'file'; bool get isUsingSaf => Platform.isAndroid && _customPathType == 'saf' && _customDownloadPath != null; String? get safBaseUri => isUsingSaf ? _customDownloadPath : null; String? get artworkDirectoryPath => _artworkDirectoryPath; Future initialize(SettingsService settingsService) async { _settingsService = settingsService; _customDownloadPath = settingsService.read(SettingsService.customDownloadPath); _customPathType = settingsService.read(SettingsService.customDownloadPathType) ?? 'file'; _baseDownloadsDir = null; _artworkDirectoryPath = null; await getArtworkDirectory(); } Future refreshCustomPath() async { if (_settingsService != null) { _customDownloadPath = _settingsService!.read(SettingsService.customDownloadPath); _customPathType = _settingsService!.read(SettingsService.customDownloadPathType) ?? 'file'; _baseDownloadsDir = null; _artworkDirectoryPath = null; await getArtworkDirectory(); } } /// Get the base app directory for storing data. /// Uses ApplicationDocumentsDirectory on mobile, ApplicationSupportDirectory on desktop. Future _getBaseAppDir() { if (Platform.isAndroid || Platform.isIOS) { return getApplicationDocumentsDirectory(); } return getApplicationSupportDirectory(); } /// Format episode filename base: S{XX}E{XX} - {Title} String _formatEpisodeFileName(MediaItem episode) { final season = padNumber(episode.parentIndex ?? 0, 2); final ep = padNumber(episode.index ?? 0, 2); final episodeName = _sanitizeFileName(episode.title!); return 'S${season}E$ep - $episodeName'; } bool isUsingCustomPath() => _customDownloadPath != null; Future getCurrentDownloadPathDisplay() async { if (_customDownloadPath != null) { return _customDownloadPath!; } final dir = await getDownloadsDirectory(); return dir.path; } Future isDirectoryWritable(Directory dir) async { try { if (!await dir.exists()) { await dir.create(recursive: true); } // Test write access with a temp file final testFile = File(path.join(dir.path, '.write_test_${DateTime.now().millisecondsSinceEpoch}')); await testFile.writeAsString('test'); await testFile.delete(); return true; } catch (e) { return false; } } Future getDownloadsDirectory() async { if (_baseDownloadsDir != null) return _baseDownloadsDir!; if (_customDownloadPath != null && _customPathType == 'file') { final customDir = Directory(_customDownloadPath!); if (await isDirectoryWritable(customDir)) { _baseDownloadsDir = customDir; return _baseDownloadsDir!; } // Fall through to default if custom path is not writable } final baseDir = await _getBaseAppDir(); _baseDownloadsDir = await _ensureDirectoryExists(Directory(path.join(baseDir.path, 'downloads'))); return _baseDownloadsDir!; } /// Get centralized artwork directory for offline artwork caching /// This directory stores artwork files with hashed filenames for deduplication Future getArtworkDirectory() async { // If custom download path is set, put artwork alongside downloads if (_customDownloadPath != null && _customPathType == 'file') { final customDir = Directory(_customDownloadPath!); final parent = customDir.parent; final artworkDir = Directory(path.join(parent.path, 'artwork')); try { // Validate writeability for custom artwork path if (await isDirectoryWritable(artworkDir)) { _artworkDirectoryPath = artworkDir.path; return artworkDir; } } catch (e) { // Fall through to default if we can't create artwork dir } } // Default: Get the app base directory directly (not downloads directory) final baseDir = await _getBaseAppDir(); final artworkDir = await _ensureDirectoryExists(Directory(path.join(baseDir.path, 'artwork'))); // Cache the path for synchronous access _artworkDirectoryPath = artworkDir.path; return artworkDir; } /// Get artwork file path from a server-side thumb path (synchronous, requires initialization). /// Works for any backend — the thumb path is hashed alongside the serverId, /// so Plex `/library/metadata/.../thumb` and Jellyfin /// `/Items/.../Images/Primary` paths both round-trip cleanly. /// Returns path to cached artwork file using hash of the thumb URL, or null if not initialized. /// Example: artwork/a1b2c3d4e5f6.jpg String? getArtworkPathSync(String serverId, String thumbPath) { if (_artworkDirectoryPath == null) return null; final hash = _hashArtworkPath(serverId, thumbPath); return path.join(_artworkDirectoryPath!, '$hash.jpg'); } /// Get artwork file path from a server-side thumb path (async version). /// Backend-neutral — see [getArtworkPathSync] for details. Future getArtworkPathFromThumb(String serverId, String thumbPath) async { final artworkDir = await getArtworkDirectory(); final hash = _hashArtworkPath(serverId, thumbPath); return path.join(artworkDir.path, '$hash.jpg'); } Future artworkExists(String serverId, String thumbPath) async { final artworkPath = await getArtworkPathFromThumb(serverId, thumbPath); return File(artworkPath).exists(); } /// Hash artwork path for filename using MD5 for stability across app restarts String _hashArtworkPath(String serverId, String thumbPath) { final combined = '$serverId:$thumbPath'; return md5.convert(utf8.encode(combined)).toString(); } Future getMediaDirectory(String serverId, String ratingKey) async { final baseDir = await getDownloadsDirectory(); return _ensureDirectoryExists(Directory(path.join(baseDir.path, serverId, ratingKey))); } Future getVideoFilePath(String serverId, String ratingKey, String extension) async { final mediaDir = await getMediaDirectory(serverId, ratingKey); return path.join(mediaDir.path, 'video.$extension'); } Future getSubtitlesDirectory(String serverId, String ratingKey) async { final mediaDir = await getMediaDirectory(serverId, ratingKey); final subtitlesDir = Directory(path.join(mediaDir.path, 'subtitles')); if (!await subtitlesDir.exists()) { await subtitlesDir.create(recursive: true); } return subtitlesDir; } Future getSubtitlePath(String serverId, String ratingKey, int trackId, String extension) async { final subtitlesDir = await getSubtitlesDirectory(serverId, ratingKey); return path.join(subtitlesDir.path, '$trackId.$extension'); } /// Sanitize a filename by removing invalid filesystem characters String _sanitizeFileName(String name) { // Remove invalid filesystem characters: < > : " / \ | ? * // Also remove leading/trailing whitespace and dots return name .replaceAll(RegExp(r'[<>:"/\\|?*]'), '') .replaceAll(RegExp(r'^\.+|\.+$'), '') .replaceAll('.', '_') .trim(); } /// Ensure a directory exists, creating it if necessary. /// `Directory.create(recursive: true)` is idempotent — it no-ops if the /// directory already exists. Future _ensureDirectoryExists(Directory dir) async { try { await dir.create(recursive: true); return dir; } catch (e, st) { appLogger.e('Failed to ensure directory exists: ${dir.path}', error: e, stackTrace: st); throw DownloadStorageException('Cannot create directory', dir.path, e); } } /// Format a media title with optional year: "Title (YYYY)" or "Title" String _formatTitleWithYear(String title, int? year) { final sanitized = _sanitizeFileName(title); return year != null ? '$sanitized ($year)' : sanitized; } String _getMovieFolderName(MediaItem movie) { return _formatTitleWithYear(movie.title!, movie.year); } /// Get the folder name for a TV show: "Show Name (YYYY)" /// [showYear]: Pass explicitly for episodes (episode.year may differ from show's year) String _getShowFolderName(MediaItem metadata, {int? showYear}) { final title = metadata.grandparentTitle ?? metadata.title!; final year = showYear ?? metadata.year; return _formatTitleWithYear(title, year); } Future getMovieDirectory(MediaItem movie) async { final baseDir = await getDownloadsDirectory(); final movieFolder = _getMovieFolderName(movie); return _ensureDirectoryExists(Directory(path.join(baseDir.path, 'Movies', movieFolder))); } /// Get movie video file path: .../Movie Name (YYYY)/Movie Name (YYYY).{ext} Future getMovieVideoPath(MediaItem movie, String extension) async { final movieDir = await getMovieDirectory(movie); final fileName = _getMovieFolderName(movie); return path.join(movieDir.path, '$fileName.$extension'); } /// Get show directory: downloads/TV Shows/{Show Name} ({Year})/ /// [showYear]: Pass the show's premiere year explicitly (for episodes, the episode's /// year may differ from the show's year). If not provided, uses metadata.year. Future getShowDirectory(MediaItem metadata, {int? showYear}) async { final baseDir = await getDownloadsDirectory(); final showFolder = _getShowFolderName(metadata, showYear: showYear); return _ensureDirectoryExists(Directory(path.join(baseDir.path, 'TV Shows', showFolder))); } /// Get season directory: .../TV Shows/{Show}/Season {XX}/ /// [showYear]: Pass the show's premiere year (not episode or season year) Future getSeasonDirectory(MediaItem metadata, {int? showYear}) async { final showDir = await getShowDirectory(metadata, showYear: showYear); final seasonNum = padNumber(metadata.parentIndex ?? 0, 2); return _ensureDirectoryExists(Directory(path.join(showDir.path, 'Season $seasonNum'))); } /// Get base path info for episode files (season directory path and formatted filename). /// [showYear]: Pass the show's premiere year (not episode year) Future<({String seasonDirPath, String fileName})> _getEpisodeBasePath(MediaItem episode, {int? showYear}) async { final seasonDir = await getSeasonDirectory(episode, showYear: showYear); final fileName = _formatEpisodeFileName(episode); return (seasonDirPath: seasonDir.path, fileName: fileName); } /// Get episode video file path: .../Season XX/S{XX}E{XX} - {Title}.{ext} /// [showYear]: Pass the show's premiere year (not episode year) Future getEpisodeVideoPath(MediaItem episode, String extension, {int? showYear}) async { final base = await _getEpisodeBasePath(episode, showYear: showYear); return path.join(base.seasonDirPath, '${base.fileName}.$extension'); } /// Get episode thumbnail path: .../Season XX/S{XX}E{XX} - {Title}.jpg /// [showYear]: Pass the show's premiere year (not episode year) Future getEpisodeThumbnailPath(MediaItem episode, {int? showYear}) async { final base = await _getEpisodeBasePath(episode, showYear: showYear); return path.join(base.seasonDirPath, '${base.fileName}.jpg'); } /// Get subtitles directory for episode: .../Season XX/S{XX}E{XX} - {Title}_subs/ /// [showYear]: Pass the show's premiere year (not episode year) Future getEpisodeSubtitlesDirectory(MediaItem episode, {int? showYear}) async { final base = await _getEpisodeBasePath(episode, showYear: showYear); return _ensureDirectoryExists(Directory(path.join(base.seasonDirPath, '${base.fileName}_subs'))); } /// [showYear]: Pass the show's premiere year (not episode year) Future getEpisodeSubtitlePath(MediaItem episode, int trackId, String extension, {int? showYear}) async { final subsDir = await getEpisodeSubtitlesDirectory(episode, showYear: showYear); return path.join(subsDir.path, '$trackId.$extension'); } Future getMovieSubtitlesDirectory(MediaItem movie) async { final movieDir = await getMovieDirectory(movie); final baseName = _getMovieFolderName(movie); return _ensureDirectoryExists(Directory(path.join(movieDir.path, '${baseName}_subs'))); } Future getMovieSubtitlePath(MediaItem movie, int trackId, String extension) async { final subsDir = await getMovieSubtitlesDirectory(movie); return path.join(subsDir.path, '$trackId.$extension'); } /// Convert an absolute file path to a relative path (for database storage) /// This ensures paths remain valid across app reinstalls on iOS where /// the container UUID can change. /// Returns a path relative to the app's documents directory. Future toRelativePath(String absolutePath) async { final baseDir = await _getBaseAppDir(); // Strip the base directory prefix iteratively — background_downloader // recovery paths can contain the base dir doubled (e.g. // /data/.../app_flutter/data/.../app_flutter/downloads/...). var result = absolutePath; while (result.startsWith(baseDir.path)) { result = result.substring(baseDir.path.length); if (result.startsWith('/') || result.startsWith('\\')) { result = result.substring(1); } } if (result != absolutePath) return result; return absolutePath; } /// Convert a relative file path to an absolute path (for file operations) /// Reconstructs the full path using the current app documents directory. Future toAbsolutePath(String relativePath) async { if (path.isAbsolute(relativePath)) { return relativePath; } final baseDir = await _getBaseAppDir(); return path.join(baseDir.path, relativePath); } /// Convert a potentially absolute path (from old database entries) to absolute /// This handles both old absolute paths and new relative paths, including /// corrupted paths that contain nested base-dir fragments without a leading slash /// (e.g. "data/user/0/.../app_flutter/downloads/..."). Future ensureAbsolutePath(String storedPath) async { appLogger.d('ensureAbsolutePath: input="$storedPath", isAbsolute=${path.isAbsolute(storedPath)}'); final baseDir = await _getBaseAppDir(); final normalizedCandidates = []; void addCandidate(String candidate) { if (candidate.isEmpty) return; final normalized = path.normalize(candidate); if (!normalizedCandidates.contains(normalized)) { normalizedCandidates.add(normalized); } } String trimLeadingSeparators(String value) => value.replaceFirst(RegExp(r'^[\\/]+'), ''); if (path.isAbsolute(storedPath)) { // Keep the original absolute path first (covers valid custom download paths). addCandidate(storedPath); // Recover from doubled app base path corruption: // /data/.../app_flutter/data/.../app_flutter/downloads/... final firstBaseIndex = storedPath.indexOf(baseDir.path); if (firstBaseIndex != -1) { final secondBaseIndex = storedPath.indexOf(baseDir.path, firstBaseIndex + baseDir.path.length); if (secondBaseIndex != -1) { final tail = trimLeadingSeparators(storedPath.substring(secondBaseIndex + baseDir.path.length)); addCandidate(path.join(baseDir.path, tail)); } } // Recover from paths that contain downloads/ but wrong prefix. final downloadsIndex = storedPath.lastIndexOf('downloads/'); if (downloadsIndex != -1) { final relativePart = storedPath.substring(downloadsIndex); addCandidate(await toAbsolutePath(relativePart)); } } else { // Normal relative path. addCandidate(await toAbsolutePath(storedPath)); // Recover from nested base-dir fragment without leading slash. final baseIndex = storedPath.indexOf(baseDir.path); if (baseIndex > 0) { final tail = trimLeadingSeparators(storedPath.substring(baseIndex + baseDir.path.length)); addCandidate(path.join(baseDir.path, tail)); } // Recover from nested fragment containing downloads/. final downloadsIndex = storedPath.lastIndexOf('downloads/'); if (downloadsIndex >= 0) { addCandidate(await toAbsolutePath(storedPath.substring(downloadsIndex))); } } // Prefer the first candidate that exists on disk. for (final candidate in normalizedCandidates) { if (await File(candidate).exists()) { appLogger.d('ensureAbsolutePath: resolved="$candidate"'); return candidate; } } // Fall back to the most conservative candidate if none currently exist. final fallback = normalizedCandidates.isNotEmpty ? normalizedCandidates.first : await toAbsolutePath(storedPath); appLogger.d('ensureAbsolutePath: resolved="$fallback" (fallback)'); return fallback; } List getMovieSafPathComponents(MediaItem movie) { return ['Movies', _getMovieFolderName(movie)]; } List getEpisodeSafPathComponents(MediaItem episode, {int? showYear}) { final showFolder = _getShowFolderName(episode, showYear: showYear); final seasonNum = padNumber(episode.parentIndex ?? 0, 2); return ['TV Shows', showFolder, 'Season $seasonNum']; } /// Get SAF path components for a show directory: ['TV Shows', {showFolder}] List getShowSafPathComponents(MediaItem metadata, {int? showYear}) { return ['TV Shows', _getShowFolderName(metadata, showYear: showYear)]; } /// Get SAF path components for a season directory when called with season metadata: /// ['TV Shows', {showFolder}, 'Season XX']. Uses season.index for the season number. List getSeasonSafPathComponents(MediaItem season, {int? showYear}) { final showFolder = _getShowFolderName(season, showYear: showYear); final seasonNum = padNumber(season.index ?? 0, 2); return ['TV Shows', showFolder, 'Season $seasonNum']; } String getMovieSafFileName(MediaItem movie, String extension) { return '${_getMovieFolderName(movie)}.$extension'; } String getEpisodeSafFileName(MediaItem episode, String extension) { final fileName = _formatEpisodeFileName(episode); return '$fileName.$extension'; } /// Get the extension-less episode filename used for SAF lookups. String getEpisodeSafBaseName(MediaItem episode) => _formatEpisodeFileName(episode); bool isSafUri(String storedPath) { return storedPath.startsWith('content://'); } /// Get a readable path for a stored path (handles both SAF URIs and file paths) /// For SAF URIs, returns the URI as-is (content:// URIs work with media players) /// For file paths, ensures the path is absolute Future getReadablePath(String storedPath) async { if (isSafUri(storedPath)) { return storedPath; } return await ensureAbsolutePath(storedPath); } }