Files
plezy/lib/services/download_storage_service.dart
T
edde746 4f736c555b fix: make PlexMetadata type/key/title nullable for folder items
Plex API returns Directory entries without type field, causing
TypeError crash in fromJson. Made fields nullable and migrated
callers to use mediaType enum and displayTitle getter instead
of raw string access.
2026-03-14 05:40:14 +01:00

616 lines
24 KiB
Dart

import 'dart:convert';
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as path;
import '../models/plex_metadata.dart';
import '../utils/app_logger.dart';
import '../utils/formatters.dart';
import 'settings_service.dart';
import 'saf_storage_service.dart';
class DownloadStorageService {
static DownloadStorageService? _instance;
static DownloadStorageService get instance => _instance ??= DownloadStorageService._();
DownloadStorageService._();
Directory? _baseDownloadsDir;
String? _artworkDirectoryPath;
// Custom path configuration
SettingsService? _settingsService;
String? _customDownloadPath;
String _customPathType = 'file';
/// Check if currently using SAF mode (Android only)
bool get isUsingSaf => Platform.isAndroid && _customPathType == 'saf' && _customDownloadPath != null;
/// Get the SAF base URI (only valid when isUsingSaf is true)
String? get safBaseUri => isUsingSaf ? _customDownloadPath : null;
/// Get artwork directory path (cached, synchronous after first call)
String? get artworkDirectoryPath => _artworkDirectoryPath;
/// Initialize with settings service (call during app startup)
Future<void> initialize(SettingsService settingsService) async {
_settingsService = settingsService;
_customDownloadPath = settingsService.getCustomDownloadPath();
_customPathType = settingsService.getCustomDownloadPathType();
// Reset cached directories to force recalculation
_baseDownloadsDir = null;
_artworkDirectoryPath = null;
}
/// Refresh custom path from settings (call when settings change)
Future<void> refreshCustomPath() async {
if (_settingsService != null) {
_customDownloadPath = _settingsService!.getCustomDownloadPath();
_customPathType = _settingsService!.getCustomDownloadPathType();
_baseDownloadsDir = null;
_artworkDirectoryPath = null;
}
}
/// Get the base app directory for storing data.
/// Uses ApplicationDocumentsDirectory on mobile, ApplicationSupportDirectory on desktop.
Future<Directory> _getBaseAppDir() {
if (Platform.isAndroid || Platform.isIOS) {
return getApplicationDocumentsDirectory();
}
return getApplicationSupportDirectory();
}
/// Format episode filename base: S{XX}E{XX} - {Title}
String _formatEpisodeFileName(PlexMetadata 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';
}
/// Check if using custom download path
bool isUsingCustomPath() => _customDownloadPath != null;
/// Get current download path for display in settings
Future<String> getCurrentDownloadPathDisplay() async {
if (_customDownloadPath != null) {
return _customDownloadPath!;
}
final dir = await getDownloadsDirectory();
return dir.path;
}
/// Get default download path (for "Reset to Default" functionality)
Future<String> getDefaultDownloadPath() async {
final baseDir = await _getBaseAppDir();
return path.join(baseDir.path, 'downloads');
}
/// Check if a directory is writable
Future<bool> 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;
}
}
/// Initialize and get base downloads directory
Future<Directory> getDownloadsDirectory() async {
if (_baseDownloadsDir != null) return _baseDownloadsDir!;
// Check for custom path first (file type only - SAF handled differently)
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
}
// Default path logic
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<Directory> 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 Plex thumb path (synchronous, requires initialization)
/// Returns path to cached artwork file using hash of the thumb URL
/// Example: artwork/a1b2c3d4e5f6.jpg
String getArtworkPathSync(String serverId, String thumbPath) {
if (_artworkDirectoryPath == null) {
throw StateError('Artwork directory not initialized. Call getArtworkDirectory() first.');
}
// Create hash from serverId:thumbPath for deduplication
final hash = _hashArtworkPath(serverId, thumbPath);
return path.join(_artworkDirectoryPath!, '$hash.jpg');
}
/// Get artwork file path from Plex thumb path (async version)
Future<String> getArtworkPathFromThumb(String serverId, String thumbPath) async {
final artworkDir = await getArtworkDirectory();
final hash = _hashArtworkPath(serverId, thumbPath);
return path.join(artworkDir.path, '$hash.jpg');
}
/// Check if artwork already exists (for deduplication)
Future<bool> 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();
}
/// Get directory for a specific media item
Future<Directory> getMediaDirectory(String serverId, String ratingKey) async {
final baseDir = await getDownloadsDirectory();
return _ensureDirectoryExists(Directory(path.join(baseDir.path, serverId, ratingKey)));
}
/// Get video file path
Future<String> getVideoFilePath(String serverId, String ratingKey, String extension) async {
final mediaDir = await getMediaDirectory(serverId, ratingKey);
return path.join(mediaDir.path, 'video.$extension');
}
/// Get artwork file path (poster, art, thumb)
Future<String> getArtworkPath(String serverId, String ratingKey, String artworkType) async {
final mediaDir = await getMediaDirectory(serverId, ratingKey);
return path.join(mediaDir.path, '$artworkType.jpg');
}
/// Get subtitles directory
Future<Directory> 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;
}
/// Get subtitle file path
Future<String> getSubtitlePath(String serverId, String ratingKey, int trackId, String extension) async {
final subtitlesDir = await getSubtitlesDirectory(serverId, ratingKey);
return path.join(subtitlesDir.path, '$trackId.$extension');
}
// ============================================================
// USER-FRIENDLY PATH METHODS (for Files app visibility)
// ============================================================
/// 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
Future<Directory> _ensureDirectoryExists(Directory dir) async {
if (!await dir.exists()) {
await dir.create(recursive: true);
}
return dir;
}
/// 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;
}
/// Get the folder name for a movie: "Movie Name (YYYY)"
String _getMovieFolderName(PlexMetadata 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(PlexMetadata metadata, {int? showYear}) {
final title = metadata.grandparentTitle ?? metadata.title!;
final year = showYear ?? metadata.year;
return _formatTitleWithYear(title, year);
}
/// Get movie directory: downloads/Movies/{Movie Name} ({Year})/
Future<Directory> getMovieDirectory(PlexMetadata 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<String> getMovieVideoPath(PlexMetadata movie, String extension) async {
final movieDir = await getMovieDirectory(movie);
final fileName = _getMovieFolderName(movie);
return path.join(movieDir.path, '$fileName.$extension');
}
/// Get movie artwork path: .../Movie Name (YYYY)/{artworkType}.jpg
Future<String> getMovieArtworkPath(PlexMetadata movie, String artworkType) async {
final movieDir = await getMovieDirectory(movie);
return path.join(movieDir.path, '$artworkType.jpg');
}
/// 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<Directory> getShowDirectory(PlexMetadata 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 show artwork path: downloads/TV Shows/{Show}/poster.jpg
Future<String> getShowArtworkPath(PlexMetadata metadata, String artworkType, {int? showYear}) async {
final showDir = await getShowDirectory(metadata, showYear: showYear);
return path.join(showDir.path, '$artworkType.jpg');
}
/// Get season directory: .../TV Shows/{Show}/Season {XX}/
/// [showYear]: Pass the show's premiere year (not episode or season year)
Future<Directory> getSeasonDirectory(PlexMetadata 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 season artwork path: .../Season XX/poster.jpg
Future<String> getSeasonArtworkPath(PlexMetadata metadata, String artworkType, {int? showYear}) async {
final seasonDir = await getSeasonDirectory(metadata, showYear: showYear);
return path.join(seasonDir.path, '$artworkType.jpg');
}
/// 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(PlexMetadata 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<String> getEpisodeVideoPath(PlexMetadata 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<String> getEpisodeThumbnailPath(PlexMetadata 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<Directory> getEpisodeSubtitlesDirectory(PlexMetadata episode, {int? showYear}) async {
final base = await _getEpisodeBasePath(episode, showYear: showYear);
return _ensureDirectoryExists(Directory(path.join(base.seasonDirPath, '${base.fileName}_subs')));
}
/// Get episode subtitle path
/// [showYear]: Pass the show's premiere year (not episode year)
Future<String> getEpisodeSubtitlePath(PlexMetadata episode, int trackId, String extension, {int? showYear}) async {
final subsDir = await getEpisodeSubtitlesDirectory(episode, showYear: showYear);
return path.join(subsDir.path, '$trackId.$extension');
}
/// Get subtitles directory for movie
Future<Directory> getMovieSubtitlesDirectory(PlexMetadata movie) async {
final movieDir = await getMovieDirectory(movie);
final baseName = _getMovieFolderName(movie);
return _ensureDirectoryExists(Directory(path.join(movieDir.path, '${baseName}_subs')));
}
/// Get movie subtitle path
Future<String> getMovieSubtitlePath(PlexMetadata movie, int trackId, String extension) async {
final subsDir = await getMovieSubtitlesDirectory(movie);
return path.join(subsDir.path, '$trackId.$extension');
}
/// Delete all files for a media item
Future<void> deleteMediaFiles(String serverId, String ratingKey) async {
final mediaDir = await getMediaDirectory(serverId, ratingKey);
if (await mediaDir.exists()) {
await mediaDir.delete(recursive: true);
}
}
/// 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<String> 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;
// Already relative or from a different base - return as-is
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<String> toAbsolutePath(String relativePath) async {
// If it's already an absolute path, return as-is
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<String> ensureAbsolutePath(String storedPath) async {
appLogger.d('ensureAbsolutePath: input="$storedPath", isAbsolute=${path.isAbsolute(storedPath)}');
final baseDir = await _getBaseAppDir();
final normalizedCandidates = <String>[];
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);
final secondBaseIndex = storedPath.indexOf(baseDir.path, firstBaseIndex + baseDir.path.length);
if (firstBaseIndex != -1 && 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;
}
/// Calculate total storage used by downloads
Future<int> getTotalStorageUsed() async {
final baseDir = await getDownloadsDirectory();
return _calculateDirectorySize(baseDir);
}
Future<int> _calculateDirectorySize(Directory dir) async {
int size = 0;
if (!await dir.exists()) return size;
await for (var entity in dir.list(recursive: true, followLinks: false)) {
if (entity is File) {
try {
size += await entity.length();
} catch (_) {
// Ignore errors reading file size
}
}
}
return size;
}
/// Format bytes to human readable string
static String formatBytes(int bytes) => ByteFormatter.formatBytes(bytes);
// ============================================================
// SAF (Storage Access Framework) SUPPORT FOR ANDROID
// ============================================================
/// Get temporary cache directory for initial downloads
/// Files are downloaded here first, then copied to SAF if using SAF mode
Future<Directory> getCacheDownloadDirectory() async {
final cacheDir = await getApplicationDocumentsDirectory();
return _ensureDirectoryExists(Directory(path.join(cacheDir.path, '.download_cache')));
}
/// Get temporary file path for downloading (before copying to SAF)
Future<String> getTempDownloadPath(String fileName) async {
final cacheDir = await getCacheDownloadDirectory();
return path.join(cacheDir.path, fileName);
}
/// Copy a file from temp cache to SAF and return the SAF URI
/// Returns null if SAF is not available or copy fails
/// Always cleans up temp file regardless of success/failure
Future<String?> copyToSaf(String tempFilePath, List<String> pathComponents, String fileName, String mimeType) async {
if (!isUsingSaf || _customDownloadPath == null) return null;
final safService = SafStorageService.instance;
try {
// Create nested directory structure in SAF
final targetDirUri = await safService.createNestedDirectories(_customDownloadPath!, pathComponents);
if (targetDirUri == null) {
return null;
}
// Copy the file to SAF using native copy
return await safService.copyFileToSaf(tempFilePath, targetDirUri, fileName, mimeType);
} finally {
// Always clean up temp file regardless of success/failure
try {
final tempFile = File(tempFilePath);
if (await tempFile.exists()) {
await tempFile.delete();
}
} catch (_) {
// Ignore cleanup errors
}
}
}
/// Get the MIME type for a file extension
String getMimeType(String extension) {
switch (extension.toLowerCase()) {
case 'mp4':
return 'video/mp4';
case 'mkv':
return 'video/x-matroska';
case 'm4v':
return 'video/x-m4v';
case 'avi':
return 'video/x-msvideo';
case 'ogv':
return 'video/ogg';
case 'webm':
return 'video/webm';
case 'srt':
return 'application/x-subrip';
case 'vtt':
return 'text/vtt';
case 'ass':
case 'ssa':
return 'text/x-ssa';
case 'jpg':
case 'jpeg':
return 'image/jpeg';
case 'png':
return 'image/png';
default:
return 'application/octet-stream';
}
}
/// Get path components for SAF based on media type
/// Returns list of directory names to create under the SAF base
List<String> getMovieSafPathComponents(PlexMetadata movie) {
return ['Movies', _getMovieFolderName(movie)];
}
/// Get path components for episode SAF storage
List<String> getEpisodeSafPathComponents(PlexMetadata episode, {int? showYear}) {
final showFolder = _getShowFolderName(episode, showYear: showYear);
final seasonNum = padNumber(episode.parentIndex ?? 0, 2);
return ['TV Shows', showFolder, 'Season $seasonNum'];
}
/// Get SAF file name for a movie
String getMovieSafFileName(PlexMetadata movie, String extension) {
return '${_getMovieFolderName(movie)}.$extension';
}
/// Get SAF file name for an episode
String getEpisodeSafFileName(PlexMetadata episode, String extension) {
final fileName = _formatEpisodeFileName(episode);
return '$fileName.$extension';
}
/// Check if a path is a SAF content URI
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<String> getReadablePath(String storedPath) async {
if (isSafUri(storedPath)) {
// SAF content:// URIs are already readable by media players
return storedPath;
}
return await ensureAbsolutePath(storedPath);
}
}