diff --git a/lib/services/cached_playback_metadata_service.dart b/lib/services/cached_playback_metadata_service.dart index 47a9e7af..f3c48696 100644 --- a/lib/services/cached_playback_metadata_service.dart +++ b/lib/services/cached_playback_metadata_service.dart @@ -1,14 +1,13 @@ import 'dart:convert'; import '../media/ids.dart'; -import 'package:drift/drift.dart'; - import '../media/media_backend.dart'; import '../media/media_source_info.dart'; import '../utils/app_logger.dart'; import '../utils/plex_cache_parser.dart'; import 'api_cache.dart'; import 'jellyfin_api_cache.dart'; +import 'jellyfin_cache_resolver.dart'; import 'jellyfin_media_info_mapper.dart'; import 'plex_mappers.dart'; @@ -99,7 +98,8 @@ class CachedPlaybackMetadataService { String itemId, { required int mediaIndex, }) async { - final raw = await _jellyfinRawItem(cacheServerId, itemId); + final resolved = await _jellyfinRawItem(cacheServerId, itemId); + final raw = resolved.raw; final sources = raw['MediaSources']; if (sources is! List || sources.isEmpty) return null; final selected = mediaIndex >= 0 && mediaIndex < sources.length ? sources[mediaIndex] : sources.first; @@ -114,8 +114,9 @@ class CachedPlaybackMetadataService { String? creditsPattern, bool forceChapterFallback = false, }) async { - final raw = await _jellyfinRawItem(cacheServerId, itemId); - final markers = await _jellyfinMediaSegmentMarkers(cacheServerId, itemId); + final resolved = await _jellyfinRawItem(cacheServerId, itemId); + final raw = resolved.raw; + final markers = await _jellyfinMediaSegmentMarkers(resolved.scopeId, itemId); return jellyfinPlaybackExtrasFromRaw( raw, itemId, @@ -138,17 +139,13 @@ class CachedPlaybackMetadataService { } } - static Future> _jellyfinRawItem(String cacheServerId, String itemId) async { + static Future<({Map raw, String scopeId})> _jellyfinRawItem( + String cacheServerId, + String itemId, + ) async { final cache = ApiCache.forBackend(MediaBackend.jellyfin); - final scopedPrefix = cacheServerId.contains('/') ? null : '$cacheServerId/%:/Users/%/Items/$itemId'; - final rows = - await (cache.database.select(cache.database.apiCache)..where( - (t) => - t.cacheKey.like('$cacheServerId:/Users/%/Items/$itemId') | - (scopedPrefix == null ? const Constant(false) : t.cacheKey.like(scopedPrefix)), - )) - .get(); - if (rows.isEmpty) throw StateError('No Jellyfin cache row for $cacheServerId:$itemId'); - return jsonDecode(rows.first.data) as Map; + final resolved = await JellyfinCacheResolver(cache.database).findItem(cacheServerId, itemId); + if (resolved == null) throw StateError('No Jellyfin cache row for $cacheServerId:$itemId'); + return (raw: jsonDecode(resolved.cacheRow.data) as Map, scopeId: resolved.key.scopeId); } } diff --git a/lib/services/download_manager_service.dart b/lib/services/download_manager_service.dart index 9a17607a..db661f0a 100644 --- a/lib/services/download_manager_service.dart +++ b/lib/services/download_manager_service.dart @@ -5,7 +5,6 @@ import '../media/ids.dart'; import 'dart:io'; import 'package:background_downloader/background_downloader.dart'; import 'package:connectivity_plus/connectivity_plus.dart'; -import 'package:drift/drift.dart'; import 'package:flutter/foundation.dart' show visibleForTesting; import 'package:path/path.dart' as path; import 'package:plezy/utils/media_server_http_client.dart'; @@ -21,6 +20,7 @@ import '../media/media_server_client.dart'; import 'api_cache.dart'; import 'download_artwork_helpers.dart'; import 'download_artwork_service.dart'; +import 'jellyfin_cache_resolver.dart'; import 'settings_service.dart'; import 'saf_storage_service.dart'; import 'package:saf_util/saf_util_platform_interface.dart' show SafDocumentFile; @@ -29,6 +29,7 @@ import '../services/offline_mode_source.dart'; import '../services/download_storage_service.dart'; import '../i18n/strings.g.dart'; import '../utils/app_logger.dart'; +import '../utils/active_client_scope.dart'; import '../utils/codec_utils.dart'; import '../utils/global_key_utils.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; @@ -36,6 +37,7 @@ import 'package:sentry_flutter/sentry_flutter.dart'; typedef MediaClientResolver = MediaServerClient? Function(ServerId serverId, {String? clientScopeId}); typedef _NativeTaskForId = Future Function(String taskId); typedef _NativeResumeTask = Future Function(DownloadTask task); +typedef _EpisodeStorageDeletion = ({String? seasonDirUri, String? showDirUri}); const bool _tvosBuild = bool.fromEnvironment('TVOS_BUILD'); @@ -66,6 +68,7 @@ class DownloadManagerService { final DownloadStorageService _storageService; final MediaServerHttpClient _http; final DownloadArtworkService _artworkService; + final SafStorageOperations _safStorage; final bool? _downloadsSupportedOverride; final _progressController = StreamController.broadcast(); @@ -162,11 +165,13 @@ class DownloadManagerService { required DownloadStorageService storageService, required MediaClientResolver clientResolver, MediaServerHttpClient? http, + @visibleForTesting SafStorageOperations? safStorage, @visibleForTesting this._downloadsSupportedOverride, }) : _database = database, _storageService = storageService, _clientResolver = clientResolver, _http = http ?? httpClient, + _safStorage = safStorage ?? SafStorageService.instance, _artworkService = DownloadArtworkService(storageService: storageService, http: http ?? httpClient); bool get downloadsSupported => _downloadsSupportedOverride ?? platformDownloadsSupported; @@ -208,9 +213,7 @@ class DownloadManagerService { String? activeClientScopeIdForServer(ServerId serverId) { final client = _getClient(serverId); - final scopeId = client?.cacheServerId; - if (scopeId == null || scopeId == serverId || scopeId.isEmpty) return null; - return scopeId; + return resolveActiveClientScopeId(serverId: serverId, cacheServerId: client?.cacheServerId); } /// Bulk-load every backend's pinned metadata into one map keyed by @@ -304,22 +307,14 @@ class DownloadManagerService { /// `Connections` table directly so the lookup works even when the server /// is currently offline (the connection persists across launches). /// - /// Jellyfin's connection row is keyed by `${serverMachineId}/$userId` - /// while clients/cache rows use the bare machineId — match by prefix so - /// the lookup resolves either form. Uses [substr]-based prefix matching - /// (mirrors [JellyfinApiCache._serverContext]) so any `_` / `%` chars in - /// [serverId] are treated literally; `LIKE '$serverId/%'` would interpret - /// them as wildcards. + /// [JellyfinCacheResolver] reconciles bare machine ids with compound + /// `${serverMachineId}/$userId` connection ids without treating `_` or `%` + /// as wildcards. Future _backendForServer(ServerId serverId) async { // Prefer a live client — `MediaServerClient.backend` is in memory. final live = _getClient(serverId); if (live != null) return live.backend; - final prefix = '$serverId/'; - final row = - await (_database.select(_database.connections) - ..where((t) => t.id.equals(serverId) | t.id.substr(1, prefix.length).equals(prefix)) - ..limit(1)) - .getSingleOrNull(); + final row = await JellyfinCacheResolver(_database).findConnection(serverId); if (row == null) return null; return switch (row.kind) { 'jellyfin' => MediaBackend.jellyfin, @@ -895,7 +890,7 @@ class DownloadManagerService { /// Delete a SAF file or directory. Missing targets are a silent no-op. Future _tryDeleteSaf(String uri, {required bool isDir, required String description}) async { - final ok = await SafStorageService.instance.delete(uri, isDir: isDir); + final ok = await _safStorage.delete(uri, isDir: isDir); if (ok) appLogger.i('Deleted $description: $uri'); } @@ -904,7 +899,7 @@ class DownloadManagerService { /// Manual recursion because DocumentsProvider-level recursion isn't guaranteed /// across providers. Future _deleteSafDirRecursive(String dirUri, {required String description}) async { - final saf = SafStorageService.instance; + final saf = _safStorage; final children = await saf.list(dirUri); if (children != null && children.isNotEmpty) { await Future.wait( @@ -921,7 +916,7 @@ class DownloadManagerService { /// Walk a chain of SAF directory URIs (deepest-first) and delete each that is empty. /// Stops at the first non-empty directory. Skips missing/null entries. Future _deleteEmptySafDirsInOrder(List dirUris) async { - final saf = SafStorageService.instance; + final saf = _safStorage; for (final uri in dirUris) { if (uri == null) break; if (!await saf.exists(uri, isDir: true)) continue; @@ -934,7 +929,7 @@ class DownloadManagerService { /// Find a SAF file in [dirUri] whose name (minus extension) matches [baseName]. Future _findSafFileByBaseName(String dirUri, String baseName) async { - final children = await SafStorageService.instance.list(dirUri); + final children = await _safStorage.list(dirUri); if (children == null) return null; for (final child in children) { if (!child.isDir && path.basenameWithoutExtension(child.name) == baseName) return child; @@ -948,7 +943,7 @@ class DownloadManagerService { /// otherwise produce "name (1).ext" / "name.ext (1)" corrupt duplicates on /// every app-level retry. Future _cleanupSafTargetFile(String safDirUri, String safFileName) async { - final children = await SafStorageService.instance.list(safDirUri); + final children = await _safStorage.list(safDirUri); if (children == null) return; // Match BOTH numbering schemes a DocumentsProvider may use: @@ -2252,27 +2247,18 @@ class DownloadManagerService { return; } - final isSaf = _storageService.isUsingSaf; switch (metadata.kind) { case MediaKind.episode: - isSaf - ? await _deleteEpisodeFilesSaf(metadata, serverId, clientScopeId: scopeId) - : await _deleteEpisodeFiles(metadata, serverId, clientScopeId: scopeId); + await _deleteEpisodeFiles(metadata, serverId, clientScopeId: scopeId); break; case MediaKind.season: - isSaf - ? await _deleteSeasonFilesSaf(metadata, serverId, clientScopeId: scopeId) - : await _deleteSeasonFiles(metadata, serverId, clientScopeId: scopeId); + await _deleteSeasonFiles(metadata, serverId, clientScopeId: scopeId); break; case MediaKind.show: - isSaf - ? await _deleteShowFilesSaf(metadata, serverId, clientScopeId: scopeId) - : await _deleteShowFiles(metadata, serverId, clientScopeId: scopeId); + await _deleteShowFiles(metadata, serverId, clientScopeId: scopeId); break; case MediaKind.movie: - isSaf - ? await _deleteMovieFilesSaf(metadata, serverId, clientScopeId: scopeId) - : await _deleteMovieFiles(metadata, serverId, clientScopeId: scopeId); + await _deleteMovieFiles(metadata, serverId, clientScopeId: scopeId); break; // Tracks live in the generic downloads/{serverId}/{ratingKey}/ layout // (both file and SAF mode), so deletion is DB-record-driven rather @@ -2387,21 +2373,23 @@ class DownloadManagerService { } } - Future _deleteEpisodeFiles(MediaItem episode, ServerId serverId, {String? clientScopeId}) async { + Future _deleteEpisodeFiles( + MediaItem episode, + ServerId serverId, { + String? clientScopeId, + bool skipStorageVideoAndParents = false, + }) async { try { final parentMetadata = episode.grandparentId != null ? await _lookupMetadata(serverId, episode.grandparentId!, clientScopeId: clientScopeId) : null; final showYear = parentMetadata?.year; - final videoPathTemplate = await _storageService.getEpisodeVideoPath(episode, 'tmp', showYear: showYear); - final videoPathWithoutExt = videoPathTemplate.substring(0, videoPathTemplate.lastIndexOf('.')); - final actualVideoFile = await _findFileWithAnyExtension(videoPathWithoutExt); - if (actualVideoFile != null) { - await _deleteFileIfExists(actualVideoFile, 'episode video'); - // Also clean up any .part file from interrupted downloads - await _deleteFileIfExists(File('${actualVideoFile.path}.part'), 'partial download'); - } + final storageDeletion = await _deleteEpisodeStorageVideo( + episode, + showYear: showYear, + skipVideo: skipStorageVideoAndParents, + ); final thumbPath = await _storageService.getEpisodeThumbnailPath(episode, showYear: showYear); await _deleteFileIfExists(File(thumbPath), 'episode thumbnail'); @@ -2414,12 +2402,14 @@ class DownloadManagerService { await _deleteChapterThumbnails(serverId, episode.id, clientScopeId: clientScopeId); - await _cleanupEmptyDirectories(episode, showYear); - - // Safety net: verify the actual DB-recorded file is gone - await _ensureDbFileDeleted(serverId, episode.id); + if (!skipStorageVideoAndParents) { + await _cleanupEpisodeStorageParents(episode, showYear, storageDeletion); + // Safety net: verify the actual DB-recorded file is gone. + await _ensureDbFileDeleted(serverId, episode.id); + } } catch (e, stack) { - appLogger.e('Error deleting episode files', error: e, stackTrace: stack); + final storageLabel = _storageService.isUsingSaf ? 'SAF ' : ''; + appLogger.e('Error deleting ${storageLabel}episode files', error: e, stackTrace: stack); } } @@ -2432,7 +2422,8 @@ class DownloadManagerService { final episodesInSeason = await _database.getEpisodesBySeason(season.id, serverId: serverId); - appLogger.d('Deleting ${episodesInSeason.length} episodes in season ${season.id}'); + final storageLabel = _storageService.isUsingSaf ? ' (SAF)' : ''; + appLogger.d('Deleting ${episodesInSeason.length} episodes in season ${season.id}$storageLabel'); await _deleteEpisodesInCollection( episodes: episodesInSeason, serverId: serverId, @@ -2441,15 +2432,10 @@ class DownloadManagerService { parentTitle: season.displayTitle, ); - final seasonDir = await _storageService.getSeasonDirectory(season, showYear: showYear); - if (await seasonDir.exists()) { - await seasonDir.delete(recursive: true); - appLogger.i('Deleted season directory: ${seasonDir.path}'); - } - - await _cleanupShowDirectory(season, showYear); + await _deleteSeasonStorageDirectory(season, showYear); } catch (e, stack) { - appLogger.e('Error deleting season files', error: e, stackTrace: stack); + final storageLabel = _storageService.isUsingSaf ? 'SAF ' : ''; + appLogger.e('Error deleting ${storageLabel}season files', error: e, stackTrace: stack); } } @@ -2486,11 +2472,11 @@ class DownloadManagerService { clientScopeId: episodeScopeId, ); if (episodeMetadata != null) { - await _deleteEpisodeFilesSaf( + await _deleteEpisodeFiles( episodeMetadata, serverId, clientScopeId: episodeScopeId, - skipSafVideoAndParents: true, + skipStorageVideoAndParents: true, ); } else { await _deleteChapterThumbnails(ServerId(serverId), episode.ratingKey, clientScopeId: episodeScopeId); @@ -2578,7 +2564,8 @@ class DownloadManagerService { try { final episodesInShow = await _database.getEpisodesByShow(show.id, serverId: serverId); - appLogger.d('Deleting ${episodesInShow.length} episodes in show ${show.id}'); + final storageLabel = _storageService.isUsingSaf ? ' (SAF)' : ''; + appLogger.d('Deleting ${episodesInShow.length} episodes in show ${show.id}$storageLabel'); await _deleteEpisodesInCollection( episodes: episodesInShow, serverId: serverId, @@ -2587,175 +2574,134 @@ class DownloadManagerService { parentTitle: show.displayTitle, ); - final showDir = await _storageService.getShowDirectory(show); - if (await showDir.exists()) { - await showDir.delete(recursive: true); - appLogger.i('Deleted show directory: ${showDir.path}'); - } + await _deleteShowStorageDirectory(show); } catch (e, stack) { - appLogger.e('Error deleting show files', error: e, stackTrace: stack); + final storageLabel = _storageService.isUsingSaf ? 'SAF ' : ''; + appLogger.e('Error deleting ${storageLabel}show files', error: e, stackTrace: stack); } } Future _deleteMovieFiles(MediaItem movie, ServerId serverId, {String? clientScopeId}) async { try { - final movieDir = await _storageService.getMovieDirectory(movie); - if (await movieDir.exists()) { - await movieDir.delete(recursive: true); - appLogger.i('Deleted movie directory: ${movieDir.path}'); - } + await _deleteMovieStorageDirectory(movie); await _deleteChapterThumbnails(serverId, movie.id, clientScopeId: clientScopeId); // Safety net: verify the actual DB-recorded file is gone await _ensureDbFileDeleted(serverId, movie.id); } catch (e, stack) { - appLogger.e('Error deleting movie files', error: e, stackTrace: stack); + final storageLabel = _storageService.isUsingSaf ? 'SAF ' : ''; + appLogger.e('Error deleting ${storageLabel}movie files', error: e, stackTrace: stack); } } - Future _deleteMovieFilesSaf(MediaItem movie, ServerId serverId, {String? clientScopeId}) async { - try { + Future _deleteMovieStorageDirectory(MediaItem movie) async { + if (_storageService.isUsingSaf) { final safBaseUri = _storageService.safBaseUri; - if (safBaseUri != null) { - final movieDir = await SafStorageService.instance.getChild( - safBaseUri, - _storageService.getMovieSafPathComponents(movie), - ); - if (movieDir != null) { - await _deleteSafDirRecursive(movieDir.uri, description: 'movie directory'); - } + if (safBaseUri == null) return; + final movieDir = await _safStorage.getChild(safBaseUri, _storageService.getMovieSafPathComponents(movie)); + if (movieDir != null) { + await _deleteSafDirRecursive(movieDir.uri, description: 'movie directory'); } - await _deleteChapterThumbnails(serverId, movie.id, clientScopeId: clientScopeId); - await _ensureDbFileDeleted(serverId, movie.id); - } catch (e, stack) { - appLogger.e('Error deleting SAF movie files', error: e, stackTrace: stack); + return; + } + + final movieDir = await _storageService.getMovieDirectory(movie); + if (await movieDir.exists()) { + await movieDir.delete(recursive: true); + appLogger.i('Deleted movie directory: ${movieDir.path}'); } } - /// When called inside a bulk season/show delete, the caller wipes the parent - /// dir — so we skip the SAF video delete and parent walk-up here. - Future _deleteEpisodeFilesSaf( - MediaItem episode, - ServerId serverId, { - String? clientScopeId, - bool skipSafVideoAndParents = false, + Future<_EpisodeStorageDeletion> _deleteEpisodeStorageVideo( + MediaItem episode, { + required int? showYear, + required bool skipVideo, }) async { - try { - final parentMetadata = episode.grandparentId != null - ? await _lookupMetadata(ServerId(serverId), episode.grandparentId!, clientScopeId: clientScopeId) - : null; - final showYear = parentMetadata?.year; - + if (_storageService.isUsingSaf) { + if (skipVideo) return (seasonDirUri: null, showDirUri: null); final safBaseUri = _storageService.safBaseUri; - String? seasonDirUri; - String? showDirUri; + if (safBaseUri == null) return (seasonDirUri: null, showDirUri: null); - if (safBaseUri != null && !skipSafVideoAndParents) { - final saf = SafStorageService.instance; - final resolved = await Future.wait([ - saf.getChild(safBaseUri, _storageService.getEpisodeSafPathComponents(episode, showYear: showYear)), - saf.getChild(safBaseUri, _storageService.getShowSafPathComponents(episode, showYear: showYear)), - ]); - seasonDirUri = resolved.first?.uri; - showDirUri = resolved[1]?.uri; - - if (seasonDirUri != null) { - final baseName = _storageService.getEpisodeSafBaseName(episode); - final file = await _findSafFileByBaseName(seasonDirUri, baseName); - if (file != null) { - await _tryDeleteSaf(file.uri, isDir: false, description: 'SAF episode video'); - } + final resolved = await Future.wait([ + _safStorage.getChild(safBaseUri, _storageService.getEpisodeSafPathComponents(episode, showYear: showYear)), + _safStorage.getChild(safBaseUri, _storageService.getShowSafPathComponents(episode, showYear: showYear)), + ]); + final seasonDirUri = resolved.first?.uri; + final showDirUri = resolved[1]?.uri; + if (seasonDirUri != null) { + final file = await _findSafFileByBaseName(seasonDirUri, _storageService.getEpisodeSafBaseName(episode)); + if (file != null) { + await _tryDeleteSaf(file.uri, isDir: false, description: 'SAF episode video'); } } - - // Subtitles and the episode thumbnail are written into app-private storage - // even in SAF mode (getDownloadsDirectory() falls through to default when - // _customPathType == 'saf'). Deletion follows suit until writing is migrated. - final thumbPath = await _storageService.getEpisodeThumbnailPath(episode, showYear: showYear); - await _deleteFileIfExists(File(thumbPath), 'episode thumbnail'); - - final subsDir = await _storageService.getEpisodeSubtitlesDirectory(episode, showYear: showYear); - if (await subsDir.exists()) { - await subsDir.delete(recursive: true); - appLogger.i('Deleted episode subtitles: ${subsDir.path}'); - } - - await _deleteChapterThumbnails(ServerId(serverId), episode.id, clientScopeId: clientScopeId); - - if (!skipSafVideoAndParents) { - await _deleteEmptySafDirsInOrder([seasonDirUri, showDirUri]); - await _ensureDbFileDeleted(ServerId(serverId), episode.id); - } - } catch (e, stack) { - appLogger.e('Error deleting SAF episode files', error: e, stackTrace: stack); + return (seasonDirUri: seasonDirUri, showDirUri: showDirUri); } + + if (!skipVideo) { + final videoPathTemplate = await _storageService.getEpisodeVideoPath(episode, 'tmp', showYear: showYear); + final videoPathWithoutExt = videoPathTemplate.substring(0, videoPathTemplate.lastIndexOf('.')); + final actualVideoFile = await _findFileWithAnyExtension(videoPathWithoutExt); + if (actualVideoFile != null) { + await _deleteFileIfExists(actualVideoFile, 'episode video'); + await _deleteFileIfExists(File('${actualVideoFile.path}.part'), 'partial download'); + } + } + return (seasonDirUri: null, showDirUri: null); } - Future _deleteSeasonFilesSaf(MediaItem season, ServerId serverId, {String? clientScopeId}) async { - try { - final parentMetadata = season.parentId != null - ? await _lookupMetadata(serverId, season.parentId!, clientScopeId: clientScopeId) - : null; - final showYear = parentMetadata?.year; - - final episodesInSeason = await _database.getEpisodesBySeason(season.id, serverId: serverId); - appLogger.d('Deleting ${episodesInSeason.length} episodes in season ${season.id} (SAF)'); - await _deleteEpisodesInCollection( - episodes: episodesInSeason, - serverId: serverId, - clientScopeId: clientScopeId, - parentKey: season.id, - parentTitle: season.displayTitle, - ); - - final safBaseUri = _storageService.safBaseUri; - if (safBaseUri != null) { - final saf = SafStorageService.instance; - final seasonDir = await saf.getChild( - safBaseUri, - _storageService.getSeasonSafPathComponents(season, showYear: showYear), - ); - if (seasonDir != null) { - await _deleteSafDirRecursive(seasonDir.uri, description: 'season directory'); - } - final showDir = await saf.getChild( - safBaseUri, - _storageService.getShowSafPathComponents(season, showYear: showYear), - ); - if (showDir != null) { - await _deleteEmptySafDirsInOrder([showDir.uri]); - } - } - } catch (e, stack) { - appLogger.e('Error deleting SAF season files', error: e, stackTrace: stack); + Future _cleanupEpisodeStorageParents(MediaItem episode, int? showYear, _EpisodeStorageDeletion deletion) async { + if (_storageService.isUsingSaf) { + await _deleteEmptySafDirsInOrder([deletion.seasonDirUri, deletion.showDirUri]); + return; } + await _cleanupEmptyDirectories(episode, showYear); } - Future _deleteShowFilesSaf(MediaItem show, ServerId serverId, {String? clientScopeId}) async { - try { - final episodesInShow = await _database.getEpisodesByShow(show.id, serverId: serverId); - appLogger.d('Deleting ${episodesInShow.length} episodes in show ${show.id} (SAF)'); - await _deleteEpisodesInCollection( - episodes: episodesInShow, - serverId: serverId, - clientScopeId: clientScopeId, - parentKey: show.id, - parentTitle: show.displayTitle, - ); - + Future _deleteSeasonStorageDirectory(MediaItem season, int? showYear) async { + if (_storageService.isUsingSaf) { final safBaseUri = _storageService.safBaseUri; - if (safBaseUri != null) { - final showDir = await SafStorageService.instance.getChild( - safBaseUri, - _storageService.getShowSafPathComponents(show), - ); - if (showDir != null) { - await _deleteSafDirRecursive(showDir.uri, description: 'show directory'); - } + if (safBaseUri == null) return; + final seasonDir = await _safStorage.getChild( + safBaseUri, + _storageService.getSeasonSafPathComponents(season, showYear: showYear), + ); + if (seasonDir != null) { + await _deleteSafDirRecursive(seasonDir.uri, description: 'season directory'); } - } catch (e, stack) { - appLogger.e('Error deleting SAF show files', error: e, stackTrace: stack); + final showDir = await _safStorage.getChild( + safBaseUri, + _storageService.getShowSafPathComponents(season, showYear: showYear), + ); + if (showDir != null) { + await _deleteEmptySafDirsInOrder([showDir.uri]); + } + return; + } + + final seasonDir = await _storageService.getSeasonDirectory(season, showYear: showYear); + if (await seasonDir.exists()) { + await seasonDir.delete(recursive: true); + appLogger.i('Deleted season directory: ${seasonDir.path}'); + } + await _cleanupShowDirectory(season, showYear); + } + + Future _deleteShowStorageDirectory(MediaItem show) async { + if (_storageService.isUsingSaf) { + final safBaseUri = _storageService.safBaseUri; + if (safBaseUri == null) return; + final showDir = await _safStorage.getChild(safBaseUri, _storageService.getShowSafPathComponents(show)); + if (showDir != null) { + await _deleteSafDirRecursive(showDir.uri, description: 'show directory'); + } + return; + } + + final showDir = await _storageService.getShowDirectory(show); + if (await showDir.exists()) { + await showDir.delete(recursive: true); + appLogger.i('Deleted show directory: ${showDir.path}'); } } @@ -2771,9 +2717,9 @@ class DownloadManagerService { if (_storageService.isSafUri(storedPath)) { // SAF mode: parent cleanup is handled by the type-specific SAF helpers — // here we only verify the video URI itself is gone. - if (await SafStorageService.instance.exists(storedPath, isDir: false)) { + if (await _safStorage.exists(storedPath, isDir: false)) { appLogger.w('Safety net: SAF video still exists after metadata deletion, deleting: $storedPath'); - await SafStorageService.instance.delete(storedPath, isDir: false); + await _safStorage.delete(storedPath, isDir: false); } return; } @@ -2880,7 +2826,12 @@ class DownloadManagerService { try { final files = await dir .list() - .where((e) => e is File && path.basenameWithoutExtension(e.path) == baseName) + .where( + (e) => + e is File && + path.basenameWithoutExtension(e.path) == baseName && + _videoExtensions.contains(path.extension(e.path).toLowerCase()), + ) .toList(); return files.isNotEmpty ? files.first as File : null; diff --git a/lib/services/download_storage_service.dart b/lib/services/download_storage_service.dart index 59ebf6da..a2deca0a 100644 --- a/lib/services/download_storage_service.dart +++ b/lib/services/download_storage_service.dart @@ -27,7 +27,15 @@ class DownloadStorageException implements Exception { class DownloadStorageService { static DownloadStorageService? _instance; static DownloadStorageService get instance => _instance ??= DownloadStorageService._(); - DownloadStorageService._(); + DownloadStorageService._() : _safAvailableOverride = false; + + @visibleForTesting + DownloadStorageService.forTestingSaf(String baseUri) : _safAvailableOverride = true { + _customDownloadPath = baseUri; + _customPathType = 'saf'; + } + + final bool _safAvailableOverride; /// Drop the cached singleton so the next [instance] call returns a fresh /// service. Test-only. @@ -43,7 +51,8 @@ class DownloadStorageService { String? _customDownloadPath; String _customPathType = 'file'; - bool get isUsingSaf => Platform.isAndroid && _customPathType == 'saf' && _customDownloadPath != null; + bool get isUsingSaf => + (Platform.isAndroid || _safAvailableOverride) && _customPathType == 'saf' && _customDownloadPath != null; String? get safBaseUri => isUsingSaf ? _customDownloadPath : null; diff --git a/lib/services/jellyfin_api_cache.dart b/lib/services/jellyfin_api_cache.dart index 999bd611..661bfcf6 100644 --- a/lib/services/jellyfin_api_cache.dart +++ b/lib/services/jellyfin_api_cache.dart @@ -10,6 +10,7 @@ import '../utils/global_key_utils.dart'; import '../utils/isolate_helper.dart'; import 'api_cache.dart'; import 'credential_vault.dart'; +import 'jellyfin_cache_resolver.dart'; import 'jellyfin_mappers.dart'; /// Jellyfin-shape helpers on top of the shared [ApiCache] substrate. @@ -38,9 +39,7 @@ class JellyfinApiCache extends ApiCache { ApiCache.registerInstance(MediaBackend.jellyfin, _instance!); } - static final RegExp _itemKeyPattern = RegExp(r'/Users/[^/]+/Items/([^/?]+)$'); - - String _itemPattern(ServerId serverId, String itemId) => '$serverId:/Users/%/Items/$itemId'; + JellyfinCacheResolver get _resolver => JellyfinCacheResolver(database); static String mediaSegmentsEndpoint(String itemId) => '/MediaSegments/${Uri.encodeComponent(itemId)}'; @@ -50,28 +49,44 @@ class JellyfinApiCache extends ApiCache { @override Future deleteForItem(ServerId serverId, String itemId) async { final endpoint = mediaSegmentsEndpoint(itemId); - await (database.delete( - database.apiCache, - )..where((t) => t.cacheKey.like(_itemPattern(serverId, itemId)) | t.cacheKey.equals('$serverId:$endpoint'))).go(); + await (database.delete(database.apiCache)..where( + (t) => _resolver.itemKeyPredicate(t.cacheKey, serverId, itemId) | t.cacheKey.equals('$serverId:$endpoint'), + )) + .go(); } /// Pin the metadata row(s) for [itemId] so they survive cache eviction. @override Future pinForOffline(ServerId serverId, String itemId) async { final endpoint = mediaSegmentsEndpoint(itemId); - await Future.wait([pinByKeyPattern(_itemPattern(serverId, itemId)), pin(serverId, endpoint)]); + await Future.wait([ + (database.update(database.apiCache)..where((t) => _resolver.itemKeyPredicate(t.cacheKey, serverId, itemId))) + .write(const ApiCacheCompanion(pinned: Value(true))), + pin(serverId, endpoint), + ]); } Future unpinForOffline(ServerId serverId, String itemId) async { final endpoint = mediaSegmentsEndpoint(itemId); - await Future.wait([unpinByKeyPattern(_itemPattern(serverId, itemId)), unpin(serverId, endpoint)]); + await Future.wait([ + (database.update(database.apiCache)..where((t) => _resolver.itemKeyPredicate(t.cacheKey, serverId, itemId))) + .write(const ApiCacheCompanion(pinned: Value(false))), + unpin(serverId, endpoint), + ]); } /// Whether the metadata for [itemId] is pinned for offline. /// /// Named `isPinnedItemId` to avoid colliding with the inherited /// [ApiCache.isPinned]'s identical Dart signature. - Future isPinnedItemId(ServerId serverId, String itemId) => hasPinnedMatching(_itemPattern(serverId, itemId)); + Future isPinnedItemId(ServerId serverId, String itemId) async { + final row = + await (database.select(database.apiCache) + ..where((t) => _resolver.itemKeyPredicate(t.cacheKey, serverId, itemId) & t.pinned.equals(true)) + ..limit(1)) + .getSingleOrNull(); + return row != null; + } /// Fetch and parse a [MediaItem] from cache. /// @@ -90,16 +105,13 @@ class JellyfinApiCache extends ApiCache { /// callers go through [getAllPinnedMetadata] which still parallelises. @override Future getMetadata(ServerId serverId, String itemId) async { - final row = await (database.select( - database.apiCache, - )..where((t) => t.cacheKey.like(_itemPattern(serverId, itemId)))).get(); - if (row.isEmpty) return null; - - final ctx = await _serverContext(serverId); + final resolved = await _resolver.findResolvedItem(serverId, itemId); + if (resolved == null) return null; + final ctx = await _serverContext(resolved.connection, machineId: resolved.key.machineId); if (ctx == null) return null; try { - final data = jsonDecode(row.first.data) as Map; + final data = jsonDecode(resolved.cacheRow.data) as Map; final absolutizer = JellyfinImageAbsolutizer(baseUrl: ctx.baseUrl, accessToken: ctx.accessToken); return JellyfinMappers.mediaItem( data, @@ -134,7 +146,7 @@ class JellyfinApiCache extends ApiCache { int? viewedLeafCount, }) async { final query = database.select(database.apiCache) - ..where((t) => t.cacheKey.like(_itemPattern(ServerId(serverId), itemId))); + ..where((t) => _resolver.itemKeyPredicate(t.cacheKey, serverId, itemId)); final rows = await query.get(); if (rows.isEmpty) return; for (final row in rows) { @@ -180,7 +192,7 @@ class JellyfinApiCache extends ApiCache { /// spread-merge the two results. @override Future> getAllPinnedMetadata() async { - final entries = await listPinnedRowsByPattern(_itemKeyPattern); + final entries = await _resolver.findPinnedItems(); if (entries.isEmpty) return {}; // Resolve the connection context per serverId once on the main thread @@ -189,8 +201,10 @@ class JellyfinApiCache extends ApiCache { // required to absolutize image paths. final contexts = {}; final absolutizers = {}; - for (final id in entries.map((e) => e.serverId).toSet()) { - final ctx = await _serverContext(id); + for (final entry in entries) { + final id = entry.connection.id; + if (contexts.containsKey(id)) continue; + final ctx = await _serverContext(entry.connection, machineId: entry.key.machineId); if (ctx != null) { contexts[id] = ctx; absolutizers[id] = JellyfinImageAbsolutizer(baseUrl: ctx.baseUrl, accessToken: ctx.accessToken); @@ -200,11 +214,11 @@ class JellyfinApiCache extends ApiCache { return await tryIsolateRun(() { final result = {}; for (final entry in entries) { - final ctx = contexts[entry.serverId]; - final absolutizer = absolutizers[entry.serverId]; + final ctx = contexts[entry.connection.id]; + final absolutizer = absolutizers[entry.connection.id]; if (ctx == null || absolutizer == null) continue; try { - final data = jsonDecode(entry.data) as Map; + final data = jsonDecode(entry.cacheRow.data) as Map; final mapped = JellyfinMappers.mediaItem( data, serverId: ServerId(ctx.machineId), @@ -212,7 +226,7 @@ class JellyfinApiCache extends ApiCache { absolutizer: absolutizer, ); if (mapped != null) { - result[buildGlobalKey(ServerId(entry.serverId), entry.id)] = mapped; + result[buildGlobalKey(ServerId(entry.key.scopeId), entry.key.itemId)] = mapped; } } catch (_) { // Skip malformed entries @@ -236,37 +250,26 @@ class JellyfinApiCache extends ApiCache { /// Returns `null` when no row matches or the row carries an empty /// `baseUrl` (no honest URL we can build). Future<({String machineId, String name, String baseUrl, String accessToken})?> _serverContext( - ServerId serverId, - ) async { - // Match either the bare machineId (Plex) or the compound - // `{machineId}/{userId}` (Jellyfin). The compound match uses a - // [substr]-based prefix check so any `_` / `%` in the runtime - // [serverId] is treated literally — `LIKE '$serverId/%'` would - // interpret those chars as wildcards. - final prefix = '$serverId/'; - final row = - await (database.select(database.connections) - ..where((t) => t.id.equals(serverId) | t.id.substr(1, prefix.length).equals(prefix)) - ..limit(1)) - .getSingleOrNull(); - if (row == null) return null; + ConnectionRow row, { + required String machineId, + }) async { String? configName; - String? machineId; + String? configMachineId; String baseUrl = ''; String accessToken = ''; try { final rawConfig = jsonDecode(row.configJson) as Map; final config = (await CredentialVault.revealConnectionConfig(row.kind, rawConfig)).config; configName = config['serverName'] as String?; - machineId = config['serverMachineId'] as String?; + configMachineId = config['serverMachineId'] as String?; baseUrl = config['baseUrl'] as String? ?? ''; accessToken = config['accessToken'] as String? ?? ''; } catch (_) { // Fall through with the values defaulted above. } if (baseUrl.isEmpty) return null; - machineId ??= row.id.contains('/') ? row.id.substring(0, row.id.indexOf('/')) : row.id; + configMachineId ??= machineId; final name = (configName != null && configName.isNotEmpty) ? configName : row.displayName; - return (machineId: machineId, name: name, baseUrl: baseUrl, accessToken: accessToken); + return (machineId: configMachineId, name: name, baseUrl: baseUrl, accessToken: accessToken); } } diff --git a/lib/services/jellyfin_cache_resolver.dart b/lib/services/jellyfin_cache_resolver.dart new file mode 100644 index 00000000..44ea7712 --- /dev/null +++ b/lib/services/jellyfin_cache_resolver.dart @@ -0,0 +1,157 @@ +import 'package:drift/drift.dart'; + +import '../database/app_database.dart'; + +typedef JellyfinItemCacheKey = ({String scopeId, String machineId, String userId, String itemId}); +typedef JellyfinCacheItem = ({ApiCacheData cacheRow, JellyfinItemCacheKey key}); +typedef ResolvedJellyfinCacheItem = ({ApiCacheData cacheRow, ConnectionRow connection, JellyfinItemCacheKey key}); + +/// Canonical Jellyfin connection and item-cache key resolution. +class JellyfinCacheResolver { + JellyfinCacheResolver(this.database); + + final AppDatabase database; + + static const _likeEscape = r'\'; + static const _usersMarker = ':/Users/'; + static const _itemsMarker = '/Items/'; + + Expression itemKeyPredicate(GeneratedColumn column, String serverOrScopeId, String itemId) { + final scope = _splitScope(serverOrScopeId); + final escapedItemId = _escapeLike(itemId); + final scopedUser = scope.userId == null ? '%' : _escapeLike(scope.userId!); + final scopedPattern = '${_escapeLike(serverOrScopeId)}:/Users/$scopedUser/Items/$escapedItemId'; + var predicate = column.like(scopedPattern, escapeChar: _likeEscape); + + if (scope.userId == null) { + final compoundPattern = '${_escapeLike(scope.machineId)}/%:/Users/%/Items/$escapedItemId'; + predicate = predicate | column.like(compoundPattern, escapeChar: _likeEscape); + } else { + final legacyPattern = '${_escapeLike(scope.machineId)}:/Users/${_escapeLike(scope.userId!)}/Items/$escapedItemId'; + predicate = predicate | column.like(legacyPattern, escapeChar: _likeEscape); + } + return predicate; + } + + Future findItem(String serverOrScopeId, String itemId) async { + final matches = await _findItems(serverOrScopeId, itemId); + return matches.isEmpty ? null : matches.first; + } + + Future findResolvedItem(String serverOrScopeId, String itemId) async { + final matches = await _findItems(serverOrScopeId, itemId); + for (final match in matches) { + final connection = await findConnection(match.key.scopeId, userId: match.key.userId); + if (connection != null) return (cacheRow: match.cacheRow, connection: connection, key: match.key); + } + return null; + } + + Future> _findItems(String serverOrScopeId, String itemId) async { + final rows = + await (database.select(database.apiCache) + ..where((t) => itemKeyPredicate(t.cacheKey, serverOrScopeId, itemId)) + ..orderBy([(t) => OrderingTerm.asc(t.cacheKey)])) + .get(); + final requested = _splitScope(serverOrScopeId); + final matches = []; + for (final row in rows) { + final key = parseItemKey(row.cacheKey); + if (key == null || key.itemId != itemId) continue; + if (key.machineId != requested.machineId) continue; + if (requested.userId != null && key.userId != requested.userId) continue; + matches.add((cacheRow: row, key: key)); + } + if (requested.userId != null) { + matches.sort((a, b) => a.key.scopeId == serverOrScopeId ? -1 : (b.key.scopeId == serverOrScopeId ? 1 : 0)); + } + return matches; + } + + Future> findPinnedItems() async { + final rows = + await (database.select(database.apiCache) + ..where((t) => t.pinned.equals(true)) + ..orderBy([(t) => OrderingTerm.asc(t.cacheKey)])) + .get(); + final matches = []; + for (final row in rows) { + final resolved = await _resolveRow(row); + if (resolved != null) matches.add(resolved); + } + return matches; + } + + Future _resolveRow(ApiCacheData row) async { + final key = parseItemKey(row.cacheKey); + if (key == null) return null; + final connection = await findConnection(key.scopeId, userId: key.userId); + if (connection == null) return null; + return (cacheRow: row, connection: connection, key: key); + } + + Future findConnection(String serverOrScopeId, {String? userId}) async { + final scope = _splitScope(serverOrScopeId); + if (scope.userId != null && userId != null && scope.userId != userId) return null; + final expectedUserId = userId ?? scope.userId; + + if (expectedUserId != null) { + final compoundId = '${scope.machineId}/$expectedUserId'; + final compound = await (database.select( + database.connections, + )..where((t) => t.id.equals(compoundId) & t.kind.equals('jellyfin'))).getSingleOrNull(); + if (compound != null && await _matchesProfileBinding(compound.id, expectedUserId)) return compound; + + final legacy = await (database.select( + database.connections, + )..where((t) => t.id.equals(scope.machineId) & t.kind.equals('jellyfin'))).getSingleOrNull(); + if (legacy != null && await _matchesProfileBinding(legacy.id, expectedUserId)) return legacy; + return null; + } + + final exact = await (database.select( + database.connections, + )..where((t) => t.id.equals(scope.machineId))).getSingleOrNull(); + if (exact != null) return exact; + + final prefix = '${scope.machineId}/'; + return (database.select(database.connections) + ..where((t) => t.id.substr(1, prefix.length).equals(prefix) & t.kind.equals('jellyfin')) + ..orderBy([(t) => OrderingTerm.asc(t.id)]) + ..limit(1)) + .getSingleOrNull(); + } + + Future _matchesProfileBinding(String connectionId, String userId) async { + final bindings = await (database.select( + database.profileConnections, + )..where((t) => t.connectionId.equals(connectionId))).get(); + return bindings.isEmpty || bindings.any((binding) => binding.userIdentifier == userId); + } + + static JellyfinItemCacheKey? parseItemKey(String cacheKey) { + final usersMarker = cacheKey.indexOf(_usersMarker); + if (usersMarker <= 0) return null; + final scopeId = cacheKey.substring(0, usersMarker); + final userStart = usersMarker + _usersMarker.length; + final itemsMarker = cacheKey.indexOf(_itemsMarker, userStart); + if (itemsMarker <= userStart) return null; + final userId = cacheKey.substring(userStart, itemsMarker); + final itemId = cacheKey.substring(itemsMarker + _itemsMarker.length); + if (itemId.isEmpty || itemId.contains('/') || itemId.contains('?')) return null; + + final scope = _splitScope(scopeId); + if (scope.userId != null && scope.userId != userId) return null; + return (scopeId: scopeId, machineId: scope.machineId, userId: userId, itemId: itemId); + } + + static ({String machineId, String? userId}) _splitScope(String scopeId) { + final slash = scopeId.indexOf('/'); + if (slash <= 0 || slash == scopeId.length - 1) return (machineId: scopeId, userId: null); + return (machineId: scopeId.substring(0, slash), userId: scopeId.substring(slash + 1)); + } + + static String _escapeLike(String value) { + return value.replaceAll(_likeEscape, '$_likeEscape$_likeEscape').replaceAll('%', r'\%').replaceAll('_', r'\_'); + } +} diff --git a/lib/services/offline_watch_sync_service.dart b/lib/services/offline_watch_sync_service.dart index d326bdcc..aca4b6c2 100644 --- a/lib/services/offline_watch_sync_service.dart +++ b/lib/services/offline_watch_sync_service.dart @@ -13,6 +13,7 @@ import '../media/media_server_client.dart'; import '../media/playback_report_metadata.dart'; import '../media/watch_progress.dart'; import '../utils/app_logger.dart'; +import '../utils/active_client_scope.dart'; import '../utils/global_key_utils.dart'; import 'offline_mode_source.dart'; import '../utils/watch_state_notifier.dart'; @@ -432,19 +433,18 @@ class OfflineWatchSyncService extends ChangeNotifier { // currently active scoped Jellyfin client. Once queued, _clientForAction // replays that exact scope even if the active user changes later. final client = _serverManager.getClient(serverId); - if (client != null) { - final scopeId = client.cacheServerId; - if (scopeId != serverId) return scopeId; - } + final activeScopeId = resolveActiveClientScopeId(serverId: serverId, cacheServerId: client?.cacheServerId); + if (activeScopeId != null) return activeScopeId; final download = await _database.getDownloadedMedia(buildGlobalKey(ServerId(serverId), itemId)); - final downloadedScopeId = download?.clientScopeId; - if (downloadedScopeId != null && downloadedScopeId.isNotEmpty) return downloadedScopeId; - return null; + return resolveActiveClientScopeId(serverId: serverId, cacheServerId: download?.clientScopeId); } Future<({MediaServerClient client, String? clientScopeId})?> _clientForAction(OfflineWatchProgressItem action) async { - final scopeId = action.clientScopeId; - if (scopeId != null && scopeId.isNotEmpty) { + final scopeId = resolveActiveClientScopeId( + serverId: ServerId(action.serverId), + cacheServerId: action.clientScopeId, + ); + if (scopeId != null) { final scoped = _serverManager.getJellyfinClientByCompoundId(scopeId); if (scoped != null) return (client: scoped, clientScopeId: scopeId); } @@ -496,9 +496,7 @@ class OfflineWatchSyncService extends ChangeNotifier { String? _activeClientScopeIdForServer(ServerId serverId) { final client = _serverManager.getClient(serverId); - if (client == null) return null; - final scopeId = client.cacheServerId; - return scopeId == serverId ? null : scopeId; + return resolveActiveClientScopeId(serverId: serverId, cacheServerId: client?.cacheServerId); } Future _clientForDownloadScope(ServerId serverId, String? clientScopeId) async { diff --git a/lib/services/saf_storage_service.dart b/lib/services/saf_storage_service.dart index 1dc74fa3..af671ca7 100644 --- a/lib/services/saf_storage_service.dart +++ b/lib/services/saf_storage_service.dart @@ -5,8 +5,18 @@ import '../utils/app_logger.dart'; import '../utils/platform_detector.dart'; import 'package:saf_util/saf_util_platform_interface.dart'; +abstract interface class SafStorageOperations { + Future getChild(String parentUri, List names); + + Future delete(String uri, {required bool isDir}); + + Future exists(String uri, {required bool isDir}); + + Future?> list(String uri); +} + /// Handles Storage Access Framework (SAF) operations for Android -class SafStorageService { +class SafStorageService implements SafStorageOperations { static SafStorageService? _instance; static SafStorageService get instance => _instance ??= SafStorageService._(); SafStorageService._(); @@ -48,6 +58,7 @@ class SafStorageService { /// Traverse to a child file/directory under a SAF directory. /// [names] is the path-component list from [parentUri] to the target; /// pass a single element for an immediate child. + @override Future getChild(String parentUri, List names) async { if (!isAvailable) return null; try { @@ -72,6 +83,7 @@ class SafStorageService { } /// Delete a SAF file or directory. Returns true on success, false on error. + @override Future delete(String uri, {required bool isDir}) async { if (!isAvailable) return false; try { @@ -84,6 +96,7 @@ class SafStorageService { } /// Check whether a SAF file or directory exists. Returns false on error. + @override Future exists(String uri, {required bool isDir}) async { if (!isAvailable) return false; try { @@ -96,6 +109,7 @@ class SafStorageService { /// List children of a SAF directory. Returns null on error so callers can /// distinguish "error" from "empty dir". + @override Future?> list(String uri) async { if (!isAvailable) return null; try { diff --git a/lib/utils/active_client_scope.dart b/lib/utils/active_client_scope.dart new file mode 100644 index 00000000..4f39fa63 --- /dev/null +++ b/lib/utils/active_client_scope.dart @@ -0,0 +1,10 @@ +import '../media/ids.dart'; + +/// Returns the user-specific active client scope, or `null` when the client is +/// absent or only exposes the public server namespace. +String? resolveActiveClientScopeId({required ServerId serverId, required String? cacheServerId}) { + if (cacheServerId == null) return null; + final userPrefix = '$serverId/'; + if (!cacheServerId.startsWith(userPrefix) || cacheServerId.length == userPrefix.length) return null; + return cacheServerId; +} diff --git a/test/services/download_manager_service_test.dart b/test/services/download_manager_service_test.dart index 3dbb1ae1..a02f4cd6 100644 --- a/test/services/download_manager_service_test.dart +++ b/test/services/download_manager_service_test.dart @@ -23,9 +23,11 @@ import 'package:plezy/services/download_manager_service.dart'; import 'package:plezy/services/download_storage_service.dart'; import 'package:plezy/services/jellyfin_api_cache.dart'; import 'package:plezy/services/plex_api_cache.dart'; +import 'package:plezy/services/saf_storage_service.dart'; import 'package:plezy/services/settings_service.dart'; import 'package:plezy/utils/media_server_http_client.dart'; import 'package:plugin_platform_interface/plugin_platform_interface.dart'; +import 'package:saf_util/saf_util_platform_interface.dart'; import '../test_helpers/prefs.dart'; @@ -326,6 +328,53 @@ void main() { expect(await subtitles.exists(), isFalse); expect(await db.getDownloadedMedia('srv:item-1'), isNull); }); + + test('filesystem and SAF episode deletion apply the same cleanup policy', () async { + final filesystem = await _runEpisodeDeletion(saf: false); + final saf = await _runEpisodeDeletion(saf: true); + + expect(filesystem, saf); + expect( + filesystem, + const _DeletionResult( + rowDeleted: true, + cacheDeleted: true, + videoDeleted: true, + thumbnailDeleted: true, + subtitlesDeleted: true, + progressItems: [0, 1], + ), + ); + }); + + test('SAF deletion failure still cleans sidecars, cache, and database state', () async { + final result = await _runEpisodeDeletion(saf: true, failVideoDeletion: true); + + expect( + result, + const _DeletionResult( + rowDeleted: true, + cacheDeleted: true, + videoDeleted: false, + thumbnailDeleted: true, + subtitlesDeleted: true, + progressItems: [0, 1], + ), + ); + }); + + test('movie, season, and show deletion agree across filesystem and SAF', () async { + for (final kind in [MediaKind.movie, MediaKind.season, MediaKind.show]) { + final filesystem = await _runContainerDeletion(kind: kind, saf: false); + final saf = await _runContainerDeletion(kind: kind, saf: true); + + expect(filesystem, saf, reason: '${kind.id} deletion differs by storage backend'); + expect( + filesystem, + const _ContainerDeletionResult(rowDeleted: true, cacheDeleted: true, directoryDeleted: true), + ); + } + }); }); group('task session validation', () { @@ -497,6 +546,363 @@ void main() { }); } +Future<_DeletionResult> _runEpisodeDeletion({required bool saf, bool failVideoDeletion = false}) async { + resetSharedPreferencesForTest(); + SettingsService.resetForTesting(); + DownloadStorageService.resetForTesting(); + final tmpRoot = await Directory.systemTemp.createTemp('download_manager_backend_delete_test_'); + PathProviderPlatform.instance = _FakePathProvider(tmpRoot); + + final storage = saf ? DownloadStorageService.forTestingSaf('content://downloads') : DownloadStorageService.instance; + if (!saf) { + await storage.initialize(await SettingsService.getInstance()); + } + + final db = AppDatabase.forTesting(NativeDatabase.memory()); + PlexApiCache.initialize(db); + JellyfinApiCache.initialize(db); + final serverId = ServerId('srv'); + const globalKey = 'srv:episode-1'; + final episode = MediaItem( + id: 'episode-1', + backend: MediaBackend.plex, + kind: MediaKind.episode, + serverId: serverId, + title: 'Pilot', + parentId: 'season-1', + parentIndex: 1, + grandparentId: 'show-1', + grandparentTitle: 'Show', + index: 1, + ); + + await PlexApiCache.instance.put(serverId, '/library/metadata/episode-1', { + 'MediaContainer': { + 'Metadata': [ + { + 'ratingKey': 'episode-1', + 'type': 'episode', + 'title': 'Pilot', + 'parentRatingKey': 'season-1', + 'parentIndex': 1, + 'grandparentRatingKey': 'show-1', + 'grandparentTitle': 'Show', + 'index': 1, + }, + ], + }, + }); + await PlexApiCache.instance.put(serverId, '/library/metadata/show-1', { + 'MediaContainer': { + 'Metadata': [ + {'ratingKey': 'show-1', 'type': 'show', 'title': 'Show', 'year': 2000}, + ], + }, + }); + + await db.insertDownload( + serverId: serverId, + ratingKey: 'episode-1', + globalKey: globalKey, + type: 'episode', + parentRatingKey: 'season-1', + grandparentRatingKey: 'show-1', + status: DownloadStatus.completed.index, + ); + + const safVideoUri = 'content://episode-1.mkv'; + final filesystemVideoPath = await storage.getEpisodeVideoPath(episode, 'mkv', showYear: 2000); + final storedVideoPath = saf ? safVideoUri : filesystemVideoPath; + if (!saf) { + await File(filesystemVideoPath).writeAsString('video'); + } + await db.updateVideoFilePath(globalKey, storedVideoPath); + + final thumbnail = File(await storage.getEpisodeThumbnailPath(episode, showYear: 2000)); + await thumbnail.writeAsString('thumbnail'); + final subtitles = await storage.getEpisodeSubtitlesDirectory(episode, showYear: 2000); + await File(p.join(subtitles.path, '1.srt')).writeAsString('subtitle'); + + final safStorage = _FakeSafStorage(failDeletes: failVideoDeletion ? {safVideoUri} : const {}); + if (saf) { + safStorage.addEpisode(storage, episode, videoUri: safVideoUri, showYear: 2000); + } + + final manager = DownloadManagerService( + database: db, + storageService: storage, + clientResolver: (serverId, {clientScopeId}) => null, + safStorage: safStorage, + downloadsSupportedOverride: false, + )..recoveryFuture = Future.value(); + final progress = []; + final subscription = manager.deletionProgressStream.listen(progress.add); + + try { + await manager.deleteDownload(globalKey); + await Future.delayed(Duration.zero); + return _DeletionResult( + rowDeleted: await db.getDownloadedMedia(globalKey) == null, + cacheDeleted: await PlexApiCache.instance.getMetadata(serverId, 'episode-1') == null, + videoDeleted: saf ? !safStorage.existsSync(safVideoUri) : !await File(filesystemVideoPath).exists(), + thumbnailDeleted: !await thumbnail.exists(), + subtitlesDeleted: !await subtitles.exists(), + progressItems: progress.map((event) => event.currentItem).toList(), + ); + } finally { + await subscription.cancel(); + manager.dispose(); + await db.close(); + DownloadStorageService.resetForTesting(); + SettingsService.resetForTesting(); + if (await tmpRoot.exists()) await tmpRoot.delete(recursive: true); + } +} + +Future<_ContainerDeletionResult> _runContainerDeletion({required MediaKind kind, required bool saf}) async { + resetSharedPreferencesForTest(); + SettingsService.resetForTesting(); + DownloadStorageService.resetForTesting(); + final tmpRoot = await Directory.systemTemp.createTemp('download_manager_container_delete_test_'); + PathProviderPlatform.instance = _FakePathProvider(tmpRoot); + + final storage = saf ? DownloadStorageService.forTestingSaf('content://downloads') : DownloadStorageService.instance; + if (!saf) await storage.initialize(await SettingsService.getInstance()); + + final db = AppDatabase.forTesting(NativeDatabase.memory()); + PlexApiCache.initialize(db); + JellyfinApiCache.initialize(db); + final serverId = ServerId('srv'); + final id = '${kind.id}-1'; + final globalKey = 'srv:$id'; + final metadata = MediaItem( + id: id, + backend: MediaBackend.plex, + kind: kind, + serverId: serverId, + title: kind == MediaKind.movie ? 'Movie' : (kind == MediaKind.show ? 'Show' : 'Season 1'), + year: 2000, + parentId: kind == MediaKind.season ? 'show-1' : null, + grandparentTitle: kind == MediaKind.season ? 'Show' : null, + index: kind == MediaKind.season ? 1 : null, + ); + await PlexApiCache.instance.put(serverId, '/library/metadata/$id', { + 'MediaContainer': { + 'Metadata': [ + { + 'ratingKey': id, + 'type': kind.id, + 'title': metadata.title, + 'year': 2000, + if (kind == MediaKind.season) ...{'parentRatingKey': 'show-1', 'grandparentTitle': 'Show', 'index': 1}, + }, + ], + }, + }); + await db.insertDownload( + serverId: serverId, + ratingKey: id, + globalKey: globalKey, + type: kind.id, + status: DownloadStatus.completed.index, + ); + + final safStorage = _FakeSafStorage(); + Directory? filesystemDirectory; + if (saf) { + safStorage.addContainer(storage, metadata); + } else { + filesystemDirectory = switch (kind) { + MediaKind.movie => await storage.getMovieDirectory(metadata), + MediaKind.season => await storage.getSeasonDirectory(metadata), + MediaKind.show => await storage.getShowDirectory(metadata), + _ => throw StateError('Unsupported test kind: $kind'), + }; + await File(p.join(filesystemDirectory.path, 'asset.bin')).writeAsString('asset'); + } + + final manager = DownloadManagerService( + database: db, + storageService: storage, + clientResolver: (serverId, {clientScopeId}) => null, + safStorage: safStorage, + downloadsSupportedOverride: false, + )..recoveryFuture = Future.value(); + + try { + await manager.deleteDownload(globalKey); + return _ContainerDeletionResult( + rowDeleted: await db.getDownloadedMedia(globalKey) == null, + cacheDeleted: await PlexApiCache.instance.getMetadata(serverId, id) == null, + directoryDeleted: saf ? !safStorage.existsSync('content://target') : !await filesystemDirectory!.exists(), + ); + } finally { + manager.dispose(); + await db.close(); + DownloadStorageService.resetForTesting(); + SettingsService.resetForTesting(); + if (await tmpRoot.exists()) await tmpRoot.delete(recursive: true); + } +} + +class _DeletionResult { + const _DeletionResult({ + required this.rowDeleted, + required this.cacheDeleted, + required this.videoDeleted, + required this.thumbnailDeleted, + required this.subtitlesDeleted, + required this.progressItems, + }); + + final bool rowDeleted; + final bool cacheDeleted; + final bool videoDeleted; + final bool thumbnailDeleted; + final bool subtitlesDeleted; + final List progressItems; + + @override + bool operator ==(Object other) => + other is _DeletionResult && + rowDeleted == other.rowDeleted && + cacheDeleted == other.cacheDeleted && + videoDeleted == other.videoDeleted && + thumbnailDeleted == other.thumbnailDeleted && + subtitlesDeleted == other.subtitlesDeleted && + _listEquals(progressItems, other.progressItems); + + @override + int get hashCode => Object.hash( + rowDeleted, + cacheDeleted, + videoDeleted, + thumbnailDeleted, + subtitlesDeleted, + Object.hashAll(progressItems), + ); +} + +class _ContainerDeletionResult { + const _ContainerDeletionResult({ + required this.rowDeleted, + required this.cacheDeleted, + required this.directoryDeleted, + }); + + final bool rowDeleted; + final bool cacheDeleted; + final bool directoryDeleted; + + @override + bool operator ==(Object other) => + other is _ContainerDeletionResult && + rowDeleted == other.rowDeleted && + cacheDeleted == other.cacheDeleted && + directoryDeleted == other.directoryDeleted; + + @override + int get hashCode => Object.hash(rowDeleted, cacheDeleted, directoryDeleted); +} + +bool _listEquals(List left, List right) { + if (left.length != right.length) return false; + for (var i = 0; i < left.length; i++) { + if (left[i] != right[i]) return false; + } + return true; +} + +class _FakeSafStorage implements SafStorageOperations { + _FakeSafStorage({this.failDeletes = const {}}); + + final Set failDeletes; + final Map _childrenByPath = {}; + final Map> _childrenByUri = {}; + final Set _existing = {}; + + void addEpisode( + DownloadStorageService storage, + MediaItem episode, { + required String videoUri, + required int showYear, + }) { + const rootUri = 'content://downloads'; + const showUri = 'content://show'; + const seasonUri = 'content://season'; + final show = _document(showUri, 'Show (2000)', isDir: true); + final season = _document(seasonUri, 'Season 01', isDir: true); + final video = _document(videoUri, storage.getEpisodeSafFileName(episode, 'mkv'), isDir: false); + + _childrenByPath[_pathKey(rootUri, storage.getShowSafPathComponents(episode, showYear: showYear))] = show; + _childrenByPath[_pathKey(rootUri, storage.getEpisodeSafPathComponents(episode, showYear: showYear))] = season; + _childrenByUri[rootUri] = [show]; + _childrenByUri[showUri] = [season]; + _childrenByUri[seasonUri] = [video]; + _childrenByUri[videoUri] = []; + _existing.addAll([rootUri, showUri, seasonUri, videoUri]); + } + + void addContainer(DownloadStorageService storage, MediaItem metadata) { + const rootUri = 'content://downloads'; + const targetUri = 'content://target'; + const assetUri = 'content://asset'; + final target = _document(targetUri, metadata.displayTitle, isDir: true); + final asset = _document(assetUri, 'asset.bin', isDir: false); + final components = switch (metadata.kind) { + MediaKind.movie => storage.getMovieSafPathComponents(metadata), + MediaKind.season => storage.getSeasonSafPathComponents(metadata), + MediaKind.show => storage.getShowSafPathComponents(metadata), + _ => throw StateError('Unsupported test kind: ${metadata.kind}'), + }; + _childrenByPath[_pathKey(rootUri, components)] = target; + _childrenByUri[targetUri] = [asset]; + _childrenByUri[assetUri] = []; + _existing.addAll([rootUri, targetUri, assetUri]); + + if (metadata.kind == MediaKind.season) { + const showUri = 'content://show'; + final show = _document(showUri, 'Show (2000)', isDir: true); + _childrenByPath[_pathKey(rootUri, storage.getShowSafPathComponents(metadata))] = show; + _childrenByUri[showUri] = [target]; + _existing.add(showUri); + } else { + _childrenByUri[rootUri] = [target]; + } + } + + bool existsSync(String uri) => _existing.contains(uri); + + @override + Future getChild(String parentUri, List names) async { + return _childrenByPath[_pathKey(parentUri, names)]; + } + + @override + Future?> list(String uri) async { + return List.from(_childrenByUri[uri] ?? const []); + } + + @override + Future exists(String uri, {required bool isDir}) async => _existing.contains(uri); + + @override + Future delete(String uri, {required bool isDir}) async { + if (failDeletes.contains(uri)) return false; + _existing.remove(uri); + for (final children in _childrenByUri.values) { + children.removeWhere((child) => child.uri == uri); + } + return true; + } + + String _pathKey(String parentUri, List names) => '$parentUri/${names.join('/')}'; + + SafDocumentFile _document(String uri, String name, {required bool isDir}) { + return SafDocumentFile(uri: uri, name: name, isDir: isDir, length: 0, lastModified: 0); + } +} + DownloadTask _downloadTask(String taskId, String globalKey) { return DownloadTask( taskId: taskId, diff --git a/test/services/jellyfin_cache_resolver_test.dart b/test/services/jellyfin_cache_resolver_test.dart new file mode 100644 index 00000000..e7afbcd5 --- /dev/null +++ b/test/services/jellyfin_cache_resolver_test.dart @@ -0,0 +1,133 @@ +import 'dart:convert'; + +import 'package:drift/drift.dart' show Value; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/database/app_database.dart'; +import 'package:plezy/services/jellyfin_cache_resolver.dart'; + +void main() { + late AppDatabase db; + late JellyfinCacheResolver resolver; + + setUp(() { + db = AppDatabase.forTesting(NativeDatabase.memory()); + resolver = JellyfinCacheResolver(db); + }); + + tearDown(() => db.close()); + + Future insertConnection(String machineId, String userId, {String? profileId}) async { + final connectionId = '$machineId/$userId'; + await db + .into(db.connections) + .insert( + ConnectionsCompanion.insert( + id: connectionId, + kind: 'jellyfin', + displayName: userId, + configJson: jsonEncode({'serverMachineId': machineId, 'userId': userId}), + createdAt: DateTime.now().millisecondsSinceEpoch, + ), + ); + if (profileId != null) { + await db + .into(db.profileConnections) + .insert( + ProfileConnectionsCompanion.insert( + profileId: profileId, + connectionId: connectionId, + userIdentifier: userId, + ), + ); + } + } + + Future insertItem(String scopeId, String userId, String itemId, {bool pinned = false}) { + return db + .into(db.apiCache) + .insert( + ApiCacheCompanion.insert( + cacheKey: '$scopeId:/Users/$userId/Items/$itemId', + data: jsonEncode({'Id': itemId, 'Name': userId}), + pinned: Value(pinned), + ), + ); + } + + test('bare server id resolves its compound cache row and connection', () async { + await insertConnection('server', 'user-a'); + await insertItem('server/user-a', 'user-a', 'item-1'); + + final match = await resolver.findResolvedItem('server', 'item-1'); + + expect(match?.key.scopeId, 'server/user-a'); + expect(match?.connection.id, 'server/user-a'); + }); + + test('compound scope selects the same user cache row, connection, and profile binding', () async { + await insertConnection('server', 'user-a', profileId: 'profile-a'); + await insertConnection('server', 'user-b', profileId: 'profile-b'); + await insertItem('server/user-a', 'user-a', 'item-1'); + await insertItem('server/user-b', 'user-b', 'item-1'); + + final match = await resolver.findResolvedItem('server/user-b', 'item-1'); + + expect(match?.key.userId, 'user-b'); + expect(match?.connection.id, 'server/user-b'); + }); + + test('rejects a cache row whose compound scope and user segment disagree', () async { + await insertConnection('server', 'user-a'); + await insertConnection('server', 'user-b'); + await insertItem('server/user-a', 'user-b', 'item-1'); + + expect(await resolver.findItem('server/user-a', 'item-1'), isNull); + }); + + test('returns no match when the exact user connection is absent', () async { + await insertConnection('server', 'user-a'); + await insertItem('server/user-b', 'user-b', 'item-1'); + + expect(await resolver.findResolvedItem('server/user-b', 'item-1'), isNull); + }); + + test('rejects a connection bound to a different Jellyfin user', () async { + await insertConnection('server', 'user-a'); + await db + .into(db.profileConnections) + .insert( + ProfileConnectionsCompanion.insert( + profileId: 'profile-b', + connectionId: 'server/user-a', + userIdentifier: 'user-b', + ), + ); + await insertItem('server/user-a', 'user-a', 'item-1'); + + expect(await resolver.findResolvedItem('server/user-a', 'item-1'), isNull); + }); + + test('treats wildcard characters in server and item ids literally', () async { + await insertConnection('server_%', 'user-a'); + await insertConnection('server-xx', 'user-a'); + await insertItem('server_%/user-a', 'user-a', 'item_%'); + await insertItem('server-xx/user-a', 'user-a', 'item-zz'); + + final match = await resolver.findResolvedItem('server_%', 'item_%'); + + expect(match?.key.scopeId, 'server_%/user-a'); + expect(match?.key.itemId, 'item_%'); + }); + + test('resolves pinned rows against each exact same-server user connection', () async { + await insertConnection('server', 'user-a', profileId: 'profile-a'); + await insertConnection('server', 'user-b', profileId: 'profile-b'); + await insertItem('server/user-a', 'user-a', 'item-a', pinned: true); + await insertItem('server/user-b', 'user-b', 'item-b', pinned: true); + + final matches = await resolver.findPinnedItems(); + + expect(matches.map((match) => match.connection.id).toSet(), {'server/user-a', 'server/user-b'}); + }); +} diff --git a/test/services/offline_watch_sync_service_test.dart b/test/services/offline_watch_sync_service_test.dart index 285d4a3e..e92eb23d 100644 --- a/test/services/offline_watch_sync_service_test.dart +++ b/test/services/offline_watch_sync_service_test.dart @@ -137,6 +137,13 @@ class _RecordingMediaClient implements MediaServerClient { dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } +class _ScopedRecordingMediaClient extends _RecordingMediaClient implements ScopedMediaServerClient { + _ScopedRecordingMediaClient({required super.serverId, required super.backend, required this.scopedServerId}); + + @override + final String scopedServerId; +} + /// Build a service against an in-memory database and a bare-metal /// [MultiServerManager] (no servers added). ({OfflineWatchSyncService svc, AppDatabase db, MultiServerManager mgr}) _makeService() { @@ -842,6 +849,37 @@ void main() { }); group('Jellyfin scoped sync', () { + test('empty active scope falls back to the downloaded scope during client pre-bind', () async { + final (svc: svc, db: db, mgr: mgr) = _makeService(); + addTearDown(() async { + svc.dispose(); + mgr.dispose(); + await db.close(); + }); + + await db.insertDownload( + serverId: ServerId('jf-machine'), + clientScopeId: 'jf-machine/user-a', + ratingKey: 'item-1', + globalKey: 'jf-machine:item-1', + type: 'movie', + status: 3, + ); + mgr.debugRegisterClientForTesting( + _ScopedRecordingMediaClient( + serverId: ServerId('jf-machine'), + backend: MediaBackend.jellyfin, + scopedServerId: '', + ), + ); + + final returnedScope = await svc.queueMarkWatched(serverId: ServerId('jf-machine'), itemId: 'item-1'); + + final queued = await db.getPendingWatchActions(); + expect(returnedScope, 'jf-machine/user-a'); + expect(queued.single.clientScopeId, 'jf-machine/user-a'); + }); + test('queues with downloaded Jellyfin source scope when no active client is registered', () async { final (svc: svc, db: db, mgr: mgr) = _makeService(); addTearDown(() async { diff --git a/test/utils/active_client_scope_test.dart b/test/utils/active_client_scope_test.dart new file mode 100644 index 00000000..076a9cb0 --- /dev/null +++ b/test/utils/active_client_scope_test.dart @@ -0,0 +1,35 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/media/ids.dart'; +import 'package:plezy/utils/active_client_scope.dart'; + +void main() { + final serverId = ServerId('jf-machine'); + + group('resolveActiveClientScopeId', () { + test('returns null before a client is bound', () { + expect(resolveActiveClientScopeId(serverId: serverId, cacheServerId: null), isNull); + }); + + test('rejects an empty cache scope', () { + expect(resolveActiveClientScopeId(serverId: serverId, cacheServerId: ''), isNull); + }); + + test('rejects the bare server scope', () { + expect(resolveActiveClientScopeId(serverId: serverId, cacheServerId: 'jf-machine'), isNull); + }); + + test('rejects empty and foreign compound scopes', () { + expect(resolveActiveClientScopeId(serverId: serverId, cacheServerId: 'jf-machine/'), isNull); + expect(resolveActiveClientScopeId(serverId: serverId, cacheServerId: 'other-machine/user-a'), isNull); + }); + + test('resolves a compound user scope', () { + expect(resolveActiveClientScopeId(serverId: serverId, cacheServerId: 'jf-machine/user-a'), 'jf-machine/user-a'); + }); + + test('keeps users on the same server in distinct active scopes', () { + expect(resolveActiveClientScopeId(serverId: serverId, cacheServerId: 'jf-machine/user-a'), 'jf-machine/user-a'); + expect(resolveActiveClientScopeId(serverId: serverId, cacheServerId: 'jf-machine/user-b'), 'jf-machine/user-b'); + }); + }); +}