Files
plezy/lib/services/download_storage_service.dart
T
2025-12-12 02:59:27 +01:00

711 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 '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;
}
}
/// 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 Directory baseDir;
if (Platform.isAndroid || Platform.isIOS) {
baseDir = await getApplicationDocumentsDirectory();
} else {
baseDir = await getApplicationSupportDirectory();
}
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 Directory baseDir;
if (Platform.isAndroid || Platform.isIOS) {
baseDir = await getApplicationDocumentsDirectory();
} else {
// Desktop: macOS, Windows, Linux
baseDir = await getApplicationSupportDirectory();
}
_baseDownloadsDir = Directory(path.join(baseDir.path, 'downloads'));
if (!await _baseDownloadsDir!.exists()) {
await _baseDownloadsDir!.create(recursive: true);
}
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 {
if (!await artworkDir.exists()) {
await artworkDir.create(recursive: true);
}
_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 Directory baseDir;
if (Platform.isAndroid || Platform.isIOS) {
baseDir = await getApplicationDocumentsDirectory();
} else {
// Desktop: macOS, Windows, Linux
baseDir = await getApplicationSupportDirectory();
}
final artworkDir = Directory(path.join(baseDir.path, 'artwork'));
if (!await artworkDir.exists()) {
await artworkDir.create(recursive: true);
}
// 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();
final mediaDir = Directory(path.join(baseDir.path, serverId, ratingKey));
if (!await mediaDir.exists()) {
await mediaDir.create(recursive: true);
}
return mediaDir;
}
/// 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'^\.+|\.+$'), '')
.trim();
}
/// Get movie directory: downloads/Movies/{Movie Name} ({Year})/
Future<Directory> getMovieDirectory(PlexMetadata movie) async {
final baseDir = await getDownloadsDirectory();
final movieName = _sanitizeFileName(movie.title);
final year = movie.year;
final movieFolder = year != null ? '$movieName ($year)' : movieName;
final dir = Directory(path.join(baseDir.path, 'Movies', movieFolder));
if (!await dir.exists()) {
await dir.create(recursive: true);
}
return dir;
}
/// 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 movieName = _sanitizeFileName(movie.title);
final year = movie.year;
final fileName = year != null ? '$movieName ($year)' : movieName;
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();
// For episodes, use grandparentTitle; for shows, use title
final showName = _sanitizeFileName(
metadata.grandparentTitle ?? metadata.title,
);
// Use explicit showYear if provided, otherwise fall back to metadata.year
final year = showYear ?? metadata.year;
final showFolder = year != null ? '$showName ($year)' : showName;
final dir = Directory(path.join(baseDir.path, 'TV Shows', showFolder));
if (!await dir.exists()) {
await dir.create(recursive: true);
}
return dir;
}
/// 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 = (metadata.parentIndex ?? 0).toString().padLeft(2, '0');
final dir = Directory(path.join(showDir.path, 'Season $seasonNum'));
if (!await dir.exists()) {
await dir.create(recursive: true);
}
return dir;
}
/// 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 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 seasonDir = await getSeasonDirectory(episode, showYear: showYear);
final seasonNum = (episode.parentIndex ?? 0).toString().padLeft(2, '0');
final episodeNum = (episode.index ?? 0).toString().padLeft(2, '0');
final episodeName = _sanitizeFileName(episode.title);
return path.join(
seasonDir.path,
'S${seasonNum}E$episodeNum - $episodeName.$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 seasonDir = await getSeasonDirectory(episode, showYear: showYear);
final seasonNum = (episode.parentIndex ?? 0).toString().padLeft(2, '0');
final episodeNum = (episode.index ?? 0).toString().padLeft(2, '0');
final episodeName = _sanitizeFileName(episode.title);
return path.join(
seasonDir.path,
'S${seasonNum}E$episodeNum - $episodeName.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 seasonDir = await getSeasonDirectory(episode, showYear: showYear);
final seasonNum = (episode.parentIndex ?? 0).toString().padLeft(2, '0');
final episodeNum = (episode.index ?? 0).toString().padLeft(2, '0');
final episodeName = _sanitizeFileName(episode.title);
final subsDir = Directory(
path.join(
seasonDir.path,
'S${seasonNum}E$episodeNum - ${episodeName}_subs',
),
);
if (!await subsDir.exists()) {
await subsDir.create(recursive: true);
}
return subsDir;
}
/// 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 movieName = _sanitizeFileName(movie.title);
final year = movie.year;
final baseName = year != null ? '$movieName ($year)' : movieName;
final subsDir = Directory(path.join(movieDir.path, '${baseName}_subs'));
if (!await subsDir.exists()) {
await subsDir.create(recursive: true);
}
return subsDir;
}
/// 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 Directory baseDir;
if (Platform.isAndroid || Platform.isIOS) {
baseDir = await getApplicationDocumentsDirectory();
} else {
baseDir = await getApplicationSupportDirectory();
}
// If the path starts with the base directory, strip it
if (absolutePath.startsWith(baseDir.path)) {
// Remove the base path and any leading separator
var relative = absolutePath.substring(baseDir.path.length);
if (relative.startsWith('/') || relative.startsWith('\\')) {
relative = relative.substring(1);
}
return relative;
}
// 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 Directory baseDir;
if (Platform.isAndroid || Platform.isIOS) {
baseDir = await getApplicationDocumentsDirectory();
} else {
baseDir = await getApplicationSupportDirectory();
}
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
Future<String> ensureAbsolutePath(String storedPath) async {
if (path.isAbsolute(storedPath)) {
// Already absolute - check if file exists at this path
if (await File(storedPath).exists()) {
return storedPath;
}
// File doesn't exist at absolute path - try to reconstruct
// Extract the relative portion (everything after 'downloads/')
final downloadsIndex = storedPath.indexOf('downloads/');
if (downloadsIndex != -1) {
final relativePart = storedPath.substring(downloadsIndex);
return await toAbsolutePath(relativePart);
}
// Can't reconstruct, return original
return storedPath;
}
// Relative path - convert to absolute
return await toAbsolutePath(storedPath);
}
/// 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) {
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
if (bytes < 1024 * 1024 * 1024) {
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
}
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB';
}
// ============================================================
// 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();
final downloadCache = Directory(
path.join(cacheDir.path, '.download_cache'),
);
if (!await downloadCache.exists()) {
await downloadCache.create(recursive: true);
}
return downloadCache;
}
/// 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
Future<String?> copyToSaf(
String tempFilePath,
List<String> pathComponents,
String fileName,
String mimeType,
) async {
if (!isUsingSaf || _customDownloadPath == null) return null;
final safService = SafStorageService.instance;
// Create nested directory structure in SAF
final targetDirUri = await safService.createNestedDirectories(
_customDownloadPath!,
pathComponents,
);
if (targetDirUri == null) {
return null;
}
// Copy the file to SAF
final safUri = await safService.copyFileToSaf(
tempFilePath,
targetDirUri,
fileName,
mimeType,
);
// Delete temp file after successful copy
if (safUri != null) {
try {
await File(tempFilePath).delete();
} catch (_) {
// Ignore cleanup errors
}
}
return safUri;
}
/// 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) {
final movieName = _sanitizeFileName(movie.title);
final year = movie.year;
final movieFolder = year != null ? '$movieName ($year)' : movieName;
return ['Movies', movieFolder];
}
/// Get path components for episode SAF storage
List<String> getEpisodeSafPathComponents(
PlexMetadata episode, {
int? showYear,
}) {
final showName = _sanitizeFileName(
episode.grandparentTitle ?? episode.title,
);
final year = showYear ?? episode.year;
final showFolder = year != null ? '$showName ($year)' : showName;
final seasonNum = (episode.parentIndex ?? 0).toString().padLeft(2, '0');
return ['TV Shows', showFolder, 'Season $seasonNum'];
}
/// Get SAF file name for a movie
String getMovieSafFileName(PlexMetadata movie, String extension) {
final movieName = _sanitizeFileName(movie.title);
final year = movie.year;
final fileName = year != null ? '$movieName ($year)' : movieName;
return '$fileName.$extension';
}
/// Get SAF file name for an episode
String getEpisodeSafFileName(PlexMetadata episode, String extension) {
final seasonNum = (episode.parentIndex ?? 0).toString().padLeft(2, '0');
final episodeNum = (episode.index ?? 0).toString().padLeft(2, '0');
final episodeName = _sanitizeFileName(episode.title);
return 'S${seasonNum}E$episodeNum - $episodeName.$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);
}
}