feat(downloads): remove playlist sync downloads together

close #1656
This commit is contained in:
edde746
2026-07-26 14:58:38 +02:00
parent 1fea9ef6e3
commit 60cc983471
32 changed files with 2025 additions and 136 deletions
+101 -1
View File
@@ -62,6 +62,7 @@ final class AppDatabaseBootstrap {
ApiCache, ApiCache,
OfflineWatchProgress, OfflineWatchProgress,
SyncRules, SyncRules,
SyncRuleDownloads,
Connections, Connections,
Profiles, Profiles,
ProfileConnections, ProfileConnections,
@@ -495,7 +496,7 @@ class AppDatabase extends _$AppDatabase {
} }
@override @override
int get schemaVersion => 19; int get schemaVersion => 20;
@override @override
MigrationStrategy get migration { MigrationStrategy get migration {
@@ -911,6 +912,18 @@ class AppDatabase extends _$AppDatabase {
WHERE backend IS NULL WHERE backend IS NULL
'''); ''');
} }
if (from < 20) {
appLogger.i('Adding sync rule download associations (v20 migration)');
await _ignoreAlreadyExists(
'SyncRules.downloadLinksInitialized column',
() => m.addColumn(syncRules, syncRules.downloadLinksInitialized),
);
await _ignoreAlreadyExists('SyncRuleDownloads table', () => m.createTable(syncRuleDownloads));
await _ignoreAlreadyExists(
'Index idx_sync_rule_downloads_profile_key',
() => m.create(idxSyncRuleDownloadsProfileKey),
);
}
}, },
); );
} }
@@ -1230,6 +1243,81 @@ class AppDatabase extends _$AppDatabase {
return (select(syncRules)..where((t) => t.globalKey.equals(globalKey))).getSingleOrNull(); return (select(syncRules)..where((t) => t.globalKey.equals(globalKey))).getSingleOrNull();
} }
Future<void> associateSyncRuleDownload(SyncRuleItem rule, String downloadGlobalKey) {
return into(syncRuleDownloads).insertOnConflictUpdate(
SyncRuleDownloadsCompanion.insert(
syncRuleId: rule.id,
profileId: rule.profileId,
downloadGlobalKey: downloadGlobalKey,
),
);
}
Future<void> markSyncRuleDownloadLinksInitialized(String globalKey) {
return (update(syncRules)..where((t) => t.globalKey.equals(globalKey))).write(
const SyncRulesCompanion(downloadLinksInitialized: Value(true)),
);
}
Future<List<SyncRuleItem>> getUninitializedSyncRulesForServer({
required String profileId,
required ServerId serverId,
}) {
return (select(syncRules)..where(
(t) => t.profileId.equals(profileId) & t.serverId.equals(serverId) & t.downloadLinksInitialized.equals(false),
))
.get();
}
Future<List<SyncRuleDownloadItem>> getSyncRuleDownloadLinks(int syncRuleId) {
return (select(syncRuleDownloads)..where((t) => t.syncRuleId.equals(syncRuleId))).get();
}
Future<List<String>> getOwnedDownloadKeysForAncestorRule({
required String profileId,
required ServerId serverId,
required String ratingKey,
required bool matchGrandparent,
}) async {
final query = select(
downloadedMedia,
).join([innerJoin(downloadOwners, downloadOwners.globalKey.equalsExp(downloadedMedia.globalKey))]);
final ancestorMatches = matchGrandparent
? downloadedMedia.grandparentRatingKey.equals(ratingKey) | downloadedMedia.parentRatingKey.equals(ratingKey)
: downloadedMedia.parentRatingKey.equals(ratingKey);
query.where(
downloadOwners.profileId.equals(profileId) &
downloadedMedia.serverId.equals(serverId) &
downloadedMedia.status.isIn([
DownloadStatus.queued.index,
DownloadStatus.downloading.index,
DownloadStatus.completed.index,
DownloadStatus.paused.index,
]) &
ancestorMatches,
);
final rows = await query.get();
return rows.map((row) => row.readTable(downloadedMedia).globalKey).toList(growable: false);
}
Future<List<String>> getExclusiveSyncRuleDownloadKeys(SyncRuleItem rule) async {
final links = await getSyncRuleDownloadLinks(rule.id);
if (links.isEmpty) return const [];
final keys = links.map((link) => link.downloadGlobalKey).toSet();
final allLinks = await (select(
syncRuleDownloads,
)..where((t) => t.profileId.equals(rule.profileId) & t.downloadGlobalKey.isIn(keys))).get();
final linkedRuleCounts = <String, int>{};
for (final link in allLinks) {
linkedRuleCounts.update(link.downloadGlobalKey, (count) => count + 1, ifAbsent: () => 1);
}
return [
for (final key in keys)
if (linkedRuleCounts[key] == 1) key,
];
}
Future<void> insertSyncRule({ Future<void> insertSyncRule({
String profileId = '', String profileId = '',
required ServerId serverId, required ServerId serverId,
@@ -1290,6 +1378,9 @@ class AppDatabase extends _$AppDatabase {
await (update(syncRules)..where((t) => t.id.equals(rule.id))).write( await (update(syncRules)..where((t) => t.id.equals(rule.id))).write(
SyncRulesCompanion(profileId: Value(profileId), globalKey: Value(scopedKey)), SyncRulesCompanion(profileId: Value(profileId), globalKey: Value(scopedKey)),
); );
await (update(
syncRuleDownloads,
)..where((t) => t.syncRuleId.equals(rule.id))).write(SyncRuleDownloadsCompanion(profileId: Value(profileId)));
} }
} }
@@ -1317,6 +1408,15 @@ class AppDatabase extends _$AppDatabase {
); );
} }
Future<void> completeSyncRuleExecution(String globalKey) {
return (update(syncRules)..where((t) => t.globalKey.equals(globalKey))).write(
SyncRulesCompanion(
lastExecutedAt: Value(DateTime.now().millisecondsSinceEpoch),
downloadLinksInitialized: const Value(true),
),
);
}
Future<void> deleteSyncRule(String globalKey) async { Future<void> deleteSyncRule(String globalKey) async {
await (delete(syncRules)..where((t) => t.globalKey.equals(globalKey))).go(); await (delete(syncRules)..where((t) => t.globalKey.equals(globalKey))).go();
} }
File diff suppressed because it is too large Load Diff
+14 -4
View File
@@ -51,8 +51,13 @@ extension DownloadDatabaseOperations on AppDatabase {
); );
} }
Future<void> removeDownloadOwner({required String profileId, required String globalKey}) async { Future<void> removeDownloadOwner({required String profileId, required String globalKey}) {
await (delete(downloadOwners)..where((t) => t.profileId.equals(profileId) & t.globalKey.equals(globalKey))).go(); return transaction(() async {
await (delete(
syncRuleDownloads,
)..where((t) => t.profileId.equals(profileId) & t.downloadGlobalKey.equals(globalKey))).go();
await (delete(downloadOwners)..where((t) => t.profileId.equals(profileId) & t.globalKey.equals(globalKey))).go();
});
} }
/// Removes one owner from a shared download while keeping an incomplete /// Removes one owner from a shared download while keeping an incomplete
@@ -102,8 +107,12 @@ extension DownloadDatabaseOperations on AppDatabase {
)..where((t) => t.profileId.equals(profileId) & t.globalKey.equals(globalKey))).getSingleOrNull(); )..where((t) => t.profileId.equals(profileId) & t.globalKey.equals(globalKey))).getSingleOrNull();
} }
Future<void> clearAllDownloadOwners() async { Future<void> clearAllDownloadOwners() {
await delete(downloadOwners).go(); return transaction(() async {
await delete(syncRuleDownloads).go();
await update(syncRules).write(const SyncRulesCompanion(downloadLinksInitialized: Value(false)));
await delete(downloadOwners).go();
});
} }
Future<Set<String>> getDownloadOwnerKeysForProfile(String profileId) async { Future<Set<String>> getDownloadOwnerKeysForProfile(String profileId) async {
@@ -620,6 +629,7 @@ extension DownloadDatabaseOperations on AppDatabase {
late String? safRootUri; late String? safRootUri;
await transaction(() async { await transaction(() async {
safRootUri = (await getDownloadedMedia(globalKey))?.safRootUri; safRootUri = (await getDownloadedMedia(globalKey))?.safRootUri;
await (delete(syncRuleDownloads)..where((t) => t.downloadGlobalKey.equals(globalKey))).go();
await (delete(downloadOwners)..where((t) => t.globalKey.equals(globalKey))).go(); await (delete(downloadOwners)..where((t) => t.globalKey.equals(globalKey))).go();
await (delete(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).go(); await (delete(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).go();
await (delete(downloadQueue)..where((t) => t.mediaGlobalKey.equals(globalKey))).go(); await (delete(downloadQueue)..where((t) => t.mediaGlobalKey.equals(globalKey))).go();
+21
View File
@@ -105,6 +105,27 @@ class SyncRules extends Table {
IntColumn get mediaIndex => integer().withDefault(const Constant(0))(); IntColumn get mediaIndex => integer().withDefault(const Constant(0))();
TextColumn get downloadFilter => text().withDefault(const Constant('unwatched'))(); TextColumn get downloadFilter => text().withDefault(const Constant('unwatched'))();
BoolColumn get includeSpecials => boolean().withDefault(const Constant(true))(); BoolColumn get includeSpecials => boolean().withDefault(const Constant(true))();
/// Whether every currently-owned candidate has been associated in
/// [SyncRuleDownloads]. Existing rules start false and are backfilled before
/// destructive cleanup.
BoolColumn get downloadLinksInitialized => boolean().withDefault(const Constant(false))();
}
/// Downloads covered by a sync rule for one profile.
///
/// Links are retained when list membership changes so removing a rule can
/// clean up items it previously synced without re-fetching the list. A
/// download may be linked to multiple rules.
@DataClassName('SyncRuleDownloadItem')
@TableIndex(name: 'idx_sync_rule_downloads_profile_key', columns: {#profileId, #downloadGlobalKey})
class SyncRuleDownloads extends Table {
IntColumn get syncRuleId => integer().references(SyncRules, #id, onDelete: KeyAction.cascade)();
TextColumn get profileId => text()();
TextColumn get downloadGlobalKey => text()();
@override
Set<Column> get primaryKey => {syncRuleId, downloadGlobalKey};
} }
/// Persisted media-server connections. /// Persisted media-server connections.
+6
View File
@@ -1191,9 +1191,15 @@
"editSyncRule": "Редактирай правило за синхронизация", "editSyncRule": "Редактирай правило за синхронизация",
"removeSyncRule": "Премахни правило за синхронизация", "removeSyncRule": "Премахни правило за синхронизация",
"removeSyncRuleConfirm": "Да се спре ли синхронизацията за \"${title}\"? Изтеглените епизоди ще останат.", "removeSyncRuleConfirm": "Да се спре ли синхронизацията за \"${title}\"? Изтеглените епизоди ще останат.",
"removeListSyncRuleConfirm": "",
"deleteSyncRuleDownloads": "",
"deleteSyncRuleDownloadsDescription": "",
"syncRuleCreated": "Правилото за синхронизация е създадено — запазват се ${count} негледани епизода", "syncRuleCreated": "Правилото за синхронизация е създадено — запазват се ${count} негледани епизода",
"syncRuleUpdated": "Правилото за синхронизация е обновено", "syncRuleUpdated": "Правилото за синхронизация е обновено",
"syncRuleRemoved": "Правилото за синхронизация е премахнато", "syncRuleRemoved": "Правилото за синхронизация е премахнато",
"syncRuleAndDownloadsRemoved": "",
"syncRuleCleanupBusy": "",
"syncRuleCleanupUnavailable": "",
"syncedNewEpisodes": "Синхронизирани са ${count} нови епизода за ${title}", "syncedNewEpisodes": "Синхронизирани са ${count} нови епизода за ${title}",
"activeSyncRules": "Правила за синхронизация", "activeSyncRules": "Правила за синхронизация",
"noSyncRules": "Няма правила за синхронизация", "noSyncRules": "Няма правила за синхронизация",
+6
View File
@@ -1191,9 +1191,15 @@
"editSyncRule": "Rediger synkroniseringsregel", "editSyncRule": "Rediger synkroniseringsregel",
"removeSyncRule": "Fjern synkroniseringsregel", "removeSyncRule": "Fjern synkroniseringsregel",
"removeSyncRuleConfirm": "Stop synkronisering af \"${title}\"? Downloadede episoder beholdes.", "removeSyncRuleConfirm": "Stop synkronisering af \"${title}\"? Downloadede episoder beholdes.",
"removeListSyncRuleConfirm": "",
"deleteSyncRuleDownloads": "",
"deleteSyncRuleDownloadsDescription": "",
"syncRuleCreated": "Synkroniseringsregel oprettet — beholder ${count} usete episoder", "syncRuleCreated": "Synkroniseringsregel oprettet — beholder ${count} usete episoder",
"syncRuleUpdated": "Synkroniseringsregel opdateret", "syncRuleUpdated": "Synkroniseringsregel opdateret",
"syncRuleRemoved": "Synkroniseringsregel fjernet", "syncRuleRemoved": "Synkroniseringsregel fjernet",
"syncRuleAndDownloadsRemoved": "",
"syncRuleCleanupBusy": "",
"syncRuleCleanupUnavailable": "",
"syncedNewEpisodes": "Synkroniserede ${count} nye episoder for ${title}", "syncedNewEpisodes": "Synkroniserede ${count} nye episoder for ${title}",
"activeSyncRules": "Synkroniseringsregler", "activeSyncRules": "Synkroniseringsregler",
"noSyncRules": "Ingen synkroniseringsregler", "noSyncRules": "Ingen synkroniseringsregler",
+6
View File
@@ -1191,9 +1191,15 @@
"editSyncRule": "Synchronisierungsregel bearbeiten", "editSyncRule": "Synchronisierungsregel bearbeiten",
"removeSyncRule": "Synchronisierungsregel entfernen", "removeSyncRule": "Synchronisierungsregel entfernen",
"removeSyncRuleConfirm": "Synchronisierung von „${title}“ beenden? Heruntergeladene Episoden werden behalten.", "removeSyncRuleConfirm": "Synchronisierung von „${title}“ beenden? Heruntergeladene Episoden werden behalten.",
"removeListSyncRuleConfirm": "",
"deleteSyncRuleDownloads": "",
"deleteSyncRuleDownloadsDescription": "",
"syncRuleCreated": "Synchronisierungsregel erstellt ${count} ungesehene Episoden werden behalten", "syncRuleCreated": "Synchronisierungsregel erstellt ${count} ungesehene Episoden werden behalten",
"syncRuleUpdated": "Synchronisierungsregel aktualisiert", "syncRuleUpdated": "Synchronisierungsregel aktualisiert",
"syncRuleRemoved": "Synchronisierungsregel entfernt", "syncRuleRemoved": "Synchronisierungsregel entfernt",
"syncRuleAndDownloadsRemoved": "",
"syncRuleCleanupBusy": "",
"syncRuleCleanupUnavailable": "",
"syncedNewEpisodes": "${count} neue Episoden für ${title} synchronisiert", "syncedNewEpisodes": "${count} neue Episoden für ${title} synchronisiert",
"activeSyncRules": "Synchronisierungsregeln", "activeSyncRules": "Synchronisierungsregeln",
"noSyncRules": "Keine Synchronisierungsregeln", "noSyncRules": "Keine Synchronisierungsregeln",
+6
View File
@@ -1191,9 +1191,15 @@
"editSyncRule": "Edit sync rule", "editSyncRule": "Edit sync rule",
"removeSyncRule": "Remove sync rule", "removeSyncRule": "Remove sync rule",
"removeSyncRuleConfirm": "Stop syncing \"${title}\"? Downloaded episodes will be kept.", "removeSyncRuleConfirm": "Stop syncing \"${title}\"? Downloaded episodes will be kept.",
"removeListSyncRuleConfirm": "Stop syncing \"${title}\"?",
"deleteSyncRuleDownloads": "Also delete associated downloads",
"deleteSyncRuleDownloadsDescription": "Downloads used by another sync rule or profile will be kept.",
"syncRuleCreated": "Sync rule created — keeping ${count} unwatched episodes", "syncRuleCreated": "Sync rule created — keeping ${count} unwatched episodes",
"syncRuleUpdated": "Sync rule updated", "syncRuleUpdated": "Sync rule updated",
"syncRuleRemoved": "Sync rule removed", "syncRuleRemoved": "Sync rule removed",
"syncRuleAndDownloadsRemoved": "Sync rule and associated downloads removed",
"syncRuleCleanupBusy": "Sync rules are currently updating. Try again in a moment.",
"syncRuleCleanupUnavailable": "Associated downloads could not be identified safely. Reconnect the server and try again, or remove the rule without deleting downloads.",
"syncedNewEpisodes": "Synced ${count} new episodes for ${title}", "syncedNewEpisodes": "Synced ${count} new episodes for ${title}",
"activeSyncRules": "Sync rules", "activeSyncRules": "Sync rules",
"noSyncRules": "No sync rules", "noSyncRules": "No sync rules",
+6
View File
@@ -1191,9 +1191,15 @@
"editSyncRule": "Editar regla de sincronización", "editSyncRule": "Editar regla de sincronización",
"removeSyncRule": "Eliminar regla de sincronización", "removeSyncRule": "Eliminar regla de sincronización",
"removeSyncRuleConfirm": "¿Dejar de sincronizar \"${title}\"? Los episodios descargados se conservarán.", "removeSyncRuleConfirm": "¿Dejar de sincronizar \"${title}\"? Los episodios descargados se conservarán.",
"removeListSyncRuleConfirm": "",
"deleteSyncRuleDownloads": "",
"deleteSyncRuleDownloadsDescription": "",
"syncRuleCreated": "Regla de sincronización creada — se conservarán ${count} episodios no vistos", "syncRuleCreated": "Regla de sincronización creada — se conservarán ${count} episodios no vistos",
"syncRuleUpdated": "Regla de sincronización actualizada", "syncRuleUpdated": "Regla de sincronización actualizada",
"syncRuleRemoved": "Regla de sincronización eliminada", "syncRuleRemoved": "Regla de sincronización eliminada",
"syncRuleAndDownloadsRemoved": "",
"syncRuleCleanupBusy": "",
"syncRuleCleanupUnavailable": "",
"syncedNewEpisodes": "${count} nuevos episodios sincronizados para ${title}", "syncedNewEpisodes": "${count} nuevos episodios sincronizados para ${title}",
"activeSyncRules": "Reglas de sincronización", "activeSyncRules": "Reglas de sincronización",
"noSyncRules": "Sin reglas de sincronización", "noSyncRules": "Sin reglas de sincronización",
+6
View File
@@ -1191,9 +1191,15 @@
"editSyncRule": "Modifier la règle de synchronisation", "editSyncRule": "Modifier la règle de synchronisation",
"removeSyncRule": "Supprimer la règle de synchronisation", "removeSyncRule": "Supprimer la règle de synchronisation",
"removeSyncRuleConfirm": "Arrêter la synchronisation de « ${title} » ? Les épisodes téléchargés seront conservés.", "removeSyncRuleConfirm": "Arrêter la synchronisation de « ${title} » ? Les épisodes téléchargés seront conservés.",
"removeListSyncRuleConfirm": "",
"deleteSyncRuleDownloads": "",
"deleteSyncRuleDownloadsDescription": "",
"syncRuleCreated": "Règle de synchronisation créée — ${count} épisodes non vus conservés", "syncRuleCreated": "Règle de synchronisation créée — ${count} épisodes non vus conservés",
"syncRuleUpdated": "Règle de synchronisation mise à jour", "syncRuleUpdated": "Règle de synchronisation mise à jour",
"syncRuleRemoved": "Règle de synchronisation supprimée", "syncRuleRemoved": "Règle de synchronisation supprimée",
"syncRuleAndDownloadsRemoved": "",
"syncRuleCleanupBusy": "",
"syncRuleCleanupUnavailable": "",
"syncedNewEpisodes": "${count} nouveaux épisodes synchronisés pour ${title}", "syncedNewEpisodes": "${count} nouveaux épisodes synchronisés pour ${title}",
"activeSyncRules": "Règles de synchronisation", "activeSyncRules": "Règles de synchronisation",
"noSyncRules": "Aucune règle de synchronisation", "noSyncRules": "Aucune règle de synchronisation",
+6
View File
@@ -1191,9 +1191,15 @@
"editSyncRule": "Szinkronizálási szabály szerkesztése", "editSyncRule": "Szinkronizálási szabály szerkesztése",
"removeSyncRule": "Szinkronizálási szabály eltávolítása", "removeSyncRule": "Szinkronizálási szabály eltávolítása",
"removeSyncRuleConfirm": "Leállítod a(z) \"${title}\" szinkronizálását? A letöltött epizódok megmaradnak.", "removeSyncRuleConfirm": "Leállítod a(z) \"${title}\" szinkronizálását? A letöltött epizódok megmaradnak.",
"removeListSyncRuleConfirm": "",
"deleteSyncRuleDownloads": "",
"deleteSyncRuleDownloadsDescription": "",
"syncRuleCreated": "Szinkronizálási szabály létrehozva — ${count} nem látott epizód megtartása", "syncRuleCreated": "Szinkronizálási szabály létrehozva — ${count} nem látott epizód megtartása",
"syncRuleUpdated": "Szinkronizálási szabály frissítve", "syncRuleUpdated": "Szinkronizálási szabály frissítve",
"syncRuleRemoved": "Szinkronizálási szabály eltávolítva", "syncRuleRemoved": "Szinkronizálási szabály eltávolítva",
"syncRuleAndDownloadsRemoved": "",
"syncRuleCleanupBusy": "",
"syncRuleCleanupUnavailable": "",
"syncedNewEpisodes": "${count} új epizód szinkronizálva a következőhöz: ${title}", "syncedNewEpisodes": "${count} új epizód szinkronizálva a következőhöz: ${title}",
"activeSyncRules": "Szinkronizálási szabályok", "activeSyncRules": "Szinkronizálási szabályok",
"noSyncRules": "Nincsenek szinkronizálási szabályok", "noSyncRules": "Nincsenek szinkronizálási szabályok",
+6
View File
@@ -1191,9 +1191,15 @@
"editSyncRule": "Modifica regola di sincronizzazione", "editSyncRule": "Modifica regola di sincronizzazione",
"removeSyncRule": "Rimuovi regola di sincronizzazione", "removeSyncRule": "Rimuovi regola di sincronizzazione",
"removeSyncRuleConfirm": "Interrompere la sincronizzazione di \"${title}\"? Gli episodi scaricati verranno mantenuti.", "removeSyncRuleConfirm": "Interrompere la sincronizzazione di \"${title}\"? Gli episodi scaricati verranno mantenuti.",
"removeListSyncRuleConfirm": "",
"deleteSyncRuleDownloads": "",
"deleteSyncRuleDownloadsDescription": "",
"syncRuleCreated": "Regola di sincronizzazione creata — ${count} episodi non visti mantenuti", "syncRuleCreated": "Regola di sincronizzazione creata — ${count} episodi non visti mantenuti",
"syncRuleUpdated": "Regola di sincronizzazione aggiornata", "syncRuleUpdated": "Regola di sincronizzazione aggiornata",
"syncRuleRemoved": "Regola di sincronizzazione rimossa", "syncRuleRemoved": "Regola di sincronizzazione rimossa",
"syncRuleAndDownloadsRemoved": "",
"syncRuleCleanupBusy": "",
"syncRuleCleanupUnavailable": "",
"syncedNewEpisodes": "${count} nuovi episodi sincronizzati per ${title}", "syncedNewEpisodes": "${count} nuovi episodi sincronizzati per ${title}",
"activeSyncRules": "Regole di sincronizzazione", "activeSyncRules": "Regole di sincronizzazione",
"noSyncRules": "Nessuna regola di sincronizzazione", "noSyncRules": "Nessuna regola di sincronizzazione",
+6
View File
@@ -1188,9 +1188,15 @@
"editSyncRule": "同期ルールを編集", "editSyncRule": "同期ルールを編集",
"removeSyncRule": "同期ルールを削除", "removeSyncRule": "同期ルールを削除",
"removeSyncRuleConfirm": "「${title}」の同期を停止しますか?ダウンロード済みのエピソードは保持されます。", "removeSyncRuleConfirm": "「${title}」の同期を停止しますか?ダウンロード済みのエピソードは保持されます。",
"removeListSyncRuleConfirm": "",
"deleteSyncRuleDownloads": "",
"deleteSyncRuleDownloadsDescription": "",
"syncRuleCreated": "同期ルールを作成しました — 未視聴のエピソードを${count}件保持", "syncRuleCreated": "同期ルールを作成しました — 未視聴のエピソードを${count}件保持",
"syncRuleUpdated": "同期ルールを更新しました", "syncRuleUpdated": "同期ルールを更新しました",
"syncRuleRemoved": "同期ルールを削除しました", "syncRuleRemoved": "同期ルールを削除しました",
"syncRuleAndDownloadsRemoved": "",
"syncRuleCleanupBusy": "",
"syncRuleCleanupUnavailable": "",
"syncedNewEpisodes": "${title}の新しいエピソードを${count}件同期しました", "syncedNewEpisodes": "${title}の新しいエピソードを${count}件同期しました",
"activeSyncRules": "同期ルール", "activeSyncRules": "同期ルール",
"noSyncRules": "同期ルールなし", "noSyncRules": "同期ルールなし",
+6
View File
@@ -1188,9 +1188,15 @@
"editSyncRule": "동기화 규칙 편집", "editSyncRule": "동기화 규칙 편집",
"removeSyncRule": "동기화 규칙 제거", "removeSyncRule": "동기화 규칙 제거",
"removeSyncRuleConfirm": "\"${title}\" 동기화를 중단하시겠습니까? 다운로드된 에피소드는 유지됩니다.", "removeSyncRuleConfirm": "\"${title}\" 동기화를 중단하시겠습니까? 다운로드된 에피소드는 유지됩니다.",
"removeListSyncRuleConfirm": "",
"deleteSyncRuleDownloads": "",
"deleteSyncRuleDownloadsDescription": "",
"syncRuleCreated": "동기화 규칙 생성됨 — 미시청 에피소드 ${count}개 유지", "syncRuleCreated": "동기화 규칙 생성됨 — 미시청 에피소드 ${count}개 유지",
"syncRuleUpdated": "동기화 규칙 업데이트됨", "syncRuleUpdated": "동기화 규칙 업데이트됨",
"syncRuleRemoved": "동기화 규칙 제거됨", "syncRuleRemoved": "동기화 규칙 제거됨",
"syncRuleAndDownloadsRemoved": "",
"syncRuleCleanupBusy": "",
"syncRuleCleanupUnavailable": "",
"syncedNewEpisodes": "${title}의 새 에피소드 ${count}개 동기화됨", "syncedNewEpisodes": "${title}의 새 에피소드 ${count}개 동기화됨",
"activeSyncRules": "동기화 규칙", "activeSyncRules": "동기화 규칙",
"noSyncRules": "동기화 규칙 없음", "noSyncRules": "동기화 규칙 없음",
+6
View File
@@ -1191,9 +1191,15 @@
"editSyncRule": "Rediger synkroniseringsregel", "editSyncRule": "Rediger synkroniseringsregel",
"removeSyncRule": "Fjern synkroniseringsregel", "removeSyncRule": "Fjern synkroniseringsregel",
"removeSyncRuleConfirm": "Slutte å synkronisere \"${title}\"? Nedlastede episoder beholdes.", "removeSyncRuleConfirm": "Slutte å synkronisere \"${title}\"? Nedlastede episoder beholdes.",
"removeListSyncRuleConfirm": "",
"deleteSyncRuleDownloads": "",
"deleteSyncRuleDownloadsDescription": "",
"syncRuleCreated": "Synkroniseringsregel opprettet — beholder ${count} usette episoder", "syncRuleCreated": "Synkroniseringsregel opprettet — beholder ${count} usette episoder",
"syncRuleUpdated": "Synkroniseringsregel oppdatert", "syncRuleUpdated": "Synkroniseringsregel oppdatert",
"syncRuleRemoved": "Synkroniseringsregel fjernet", "syncRuleRemoved": "Synkroniseringsregel fjernet",
"syncRuleAndDownloadsRemoved": "",
"syncRuleCleanupBusy": "",
"syncRuleCleanupUnavailable": "",
"syncedNewEpisodes": "Synkroniserte ${count} nye episoder for ${title}", "syncedNewEpisodes": "Synkroniserte ${count} nye episoder for ${title}",
"activeSyncRules": "Synkroniseringsregler", "activeSyncRules": "Synkroniseringsregler",
"noSyncRules": "Ingen synkroniseringsregler", "noSyncRules": "Ingen synkroniseringsregler",
+6
View File
@@ -1191,9 +1191,15 @@
"editSyncRule": "Synchronisatieregel bewerken", "editSyncRule": "Synchronisatieregel bewerken",
"removeSyncRule": "Synchronisatieregel verwijderen", "removeSyncRule": "Synchronisatieregel verwijderen",
"removeSyncRuleConfirm": "Synchronisatie van \"${title}\" stoppen? Gedownloade afleveringen worden behouden.", "removeSyncRuleConfirm": "Synchronisatie van \"${title}\" stoppen? Gedownloade afleveringen worden behouden.",
"removeListSyncRuleConfirm": "",
"deleteSyncRuleDownloads": "",
"deleteSyncRuleDownloadsDescription": "",
"syncRuleCreated": "Synchronisatieregel aangemaakt — ${count} onbekeken afleveringen behouden", "syncRuleCreated": "Synchronisatieregel aangemaakt — ${count} onbekeken afleveringen behouden",
"syncRuleUpdated": "Synchronisatieregel bijgewerkt", "syncRuleUpdated": "Synchronisatieregel bijgewerkt",
"syncRuleRemoved": "Synchronisatieregel verwijderd", "syncRuleRemoved": "Synchronisatieregel verwijderd",
"syncRuleAndDownloadsRemoved": "",
"syncRuleCleanupBusy": "",
"syncRuleCleanupUnavailable": "",
"syncedNewEpisodes": "${count} nieuwe afleveringen gesynchroniseerd voor ${title}", "syncedNewEpisodes": "${count} nieuwe afleveringen gesynchroniseerd voor ${title}",
"activeSyncRules": "Synchronisatieregels", "activeSyncRules": "Synchronisatieregels",
"noSyncRules": "Geen synchronisatieregels", "noSyncRules": "Geen synchronisatieregels",
+6
View File
@@ -1197,9 +1197,15 @@
"editSyncRule": "Edytuj regułę synchronizacji", "editSyncRule": "Edytuj regułę synchronizacji",
"removeSyncRule": "Usuń regułę synchronizacji", "removeSyncRule": "Usuń regułę synchronizacji",
"removeSyncRuleConfirm": "Zatrzymać synchronizację \"${title}\"? Pobrane odcinki zostaną zachowane.", "removeSyncRuleConfirm": "Zatrzymać synchronizację \"${title}\"? Pobrane odcinki zostaną zachowane.",
"removeListSyncRuleConfirm": "",
"deleteSyncRuleDownloads": "",
"deleteSyncRuleDownloadsDescription": "",
"syncRuleCreated": "Reguła synchronizacji utworzona — zachowywanie ${count} nieobejrzanych odcinków", "syncRuleCreated": "Reguła synchronizacji utworzona — zachowywanie ${count} nieobejrzanych odcinków",
"syncRuleUpdated": "Reguła synchronizacji zaktualizowana", "syncRuleUpdated": "Reguła synchronizacji zaktualizowana",
"syncRuleRemoved": "Reguła synchronizacji usunięta", "syncRuleRemoved": "Reguła synchronizacji usunięta",
"syncRuleAndDownloadsRemoved": "",
"syncRuleCleanupBusy": "",
"syncRuleCleanupUnavailable": "",
"syncedNewEpisodes": "Zsynchronizowano ${count} nowych odcinków dla ${title}", "syncedNewEpisodes": "Zsynchronizowano ${count} nowych odcinków dla ${title}",
"activeSyncRules": "Reguły synchronizacji", "activeSyncRules": "Reguły synchronizacji",
"noSyncRules": "Brak reguł synchronizacji", "noSyncRules": "Brak reguł synchronizacji",
+6
View File
@@ -1191,9 +1191,15 @@
"editSyncRule": "Editar regra de sincronização", "editSyncRule": "Editar regra de sincronização",
"removeSyncRule": "Remover regra de sincronização", "removeSyncRule": "Remover regra de sincronização",
"removeSyncRuleConfirm": "Parar de sincronizar \"${title}\"? Os episódios baixados serão mantidos.", "removeSyncRuleConfirm": "Parar de sincronizar \"${title}\"? Os episódios baixados serão mantidos.",
"removeListSyncRuleConfirm": "",
"deleteSyncRuleDownloads": "",
"deleteSyncRuleDownloadsDescription": "",
"syncRuleCreated": "Regra de sincronização criada — mantendo ${count} episódios não assistidos", "syncRuleCreated": "Regra de sincronização criada — mantendo ${count} episódios não assistidos",
"syncRuleUpdated": "Regra de sincronização atualizada", "syncRuleUpdated": "Regra de sincronização atualizada",
"syncRuleRemoved": "Regra de sincronização removida", "syncRuleRemoved": "Regra de sincronização removida",
"syncRuleAndDownloadsRemoved": "",
"syncRuleCleanupBusy": "",
"syncRuleCleanupUnavailable": "",
"syncedNewEpisodes": "${count} novos episódios sincronizados para ${title}", "syncedNewEpisodes": "${count} novos episódios sincronizados para ${title}",
"activeSyncRules": "Regras de sincronização", "activeSyncRules": "Regras de sincronização",
"noSyncRules": "Nenhuma regra de sincronização", "noSyncRules": "Nenhuma regra de sincronização",
+6
View File
@@ -1197,9 +1197,15 @@
"editSyncRule": "Редактировать правило синхронизации", "editSyncRule": "Редактировать правило синхронизации",
"removeSyncRule": "Удалить правило синхронизации", "removeSyncRule": "Удалить правило синхронизации",
"removeSyncRuleConfirm": "Прекратить синхронизацию «${title}»? Скачанные эпизоды будут сохранены.", "removeSyncRuleConfirm": "Прекратить синхронизацию «${title}»? Скачанные эпизоды будут сохранены.",
"removeListSyncRuleConfirm": "",
"deleteSyncRuleDownloads": "",
"deleteSyncRuleDownloadsDescription": "",
"syncRuleCreated": "Правило синхронизации создано — хранится ${count} непросмотренных эпизодов", "syncRuleCreated": "Правило синхронизации создано — хранится ${count} непросмотренных эпизодов",
"syncRuleUpdated": "Правило синхронизации обновлено", "syncRuleUpdated": "Правило синхронизации обновлено",
"syncRuleRemoved": "Правило синхронизации удалено", "syncRuleRemoved": "Правило синхронизации удалено",
"syncRuleAndDownloadsRemoved": "",
"syncRuleCleanupBusy": "",
"syncRuleCleanupUnavailable": "",
"syncedNewEpisodes": "Синхронизировано ${count} новых эпизодов для ${title}", "syncedNewEpisodes": "Синхронизировано ${count} новых эпизодов для ${title}",
"activeSyncRules": "Правила синхронизации", "activeSyncRules": "Правила синхронизации",
"noSyncRules": "Нет правил синхронизации", "noSyncRules": "Нет правил синхронизации",
+1 -1
View File
@@ -4,7 +4,7 @@
/// To regenerate, run: `dart run slang` /// To regenerate, run: `dart run slang`
/// ///
/// Locales: 18 /// Locales: 18
/// Strings: 26501 (1472 per locale) /// Strings: 26507 (1472 per locale)
// coverage:ignore-file // coverage:ignore-file
// ignore_for_file: type=lint, unused_import // ignore_for_file: type=lint, unused_import
+24
View File
@@ -3450,6 +3450,15 @@ class Translations$downloads$en {
/// en: 'Stop syncing "${title}"? Downloaded episodes will be kept.' /// en: 'Stop syncing "${title}"? Downloaded episodes will be kept.'
String removeSyncRuleConfirm({required Object title}) => 'Stop syncing "${title}"? Downloaded episodes will be kept.'; String removeSyncRuleConfirm({required Object title}) => 'Stop syncing "${title}"? Downloaded episodes will be kept.';
/// en: 'Stop syncing "${title}"?'
String removeListSyncRuleConfirm({required Object title}) => 'Stop syncing "${title}"?';
/// en: 'Also delete associated downloads'
String get deleteSyncRuleDownloads => 'Also delete associated downloads';
/// en: 'Downloads used by another sync rule or profile will be kept.'
String get deleteSyncRuleDownloadsDescription => 'Downloads used by another sync rule or profile will be kept.';
/// en: 'Sync rule created — keeping ${count} unwatched episodes' /// en: 'Sync rule created — keeping ${count} unwatched episodes'
String syncRuleCreated({required Object count}) => 'Sync rule created — keeping ${count} unwatched episodes'; String syncRuleCreated({required Object count}) => 'Sync rule created — keeping ${count} unwatched episodes';
@@ -3459,6 +3468,15 @@ class Translations$downloads$en {
/// en: 'Sync rule removed' /// en: 'Sync rule removed'
String get syncRuleRemoved => 'Sync rule removed'; String get syncRuleRemoved => 'Sync rule removed';
/// en: 'Sync rule and associated downloads removed'
String get syncRuleAndDownloadsRemoved => 'Sync rule and associated downloads removed';
/// en: 'Sync rules are currently updating. Try again in a moment.'
String get syncRuleCleanupBusy => 'Sync rules are currently updating. Try again in a moment.';
/// en: 'Associated downloads could not be identified safely. Reconnect the server and try again, or remove the rule without deleting downloads.'
String get syncRuleCleanupUnavailable => 'Associated downloads could not be identified safely. Reconnect the server and try again, or remove the rule without deleting downloads.';
/// en: 'Synced ${count} new episodes for ${title}' /// en: 'Synced ${count} new episodes for ${title}'
String syncedNewEpisodes({required Object count, required Object title}) => 'Synced ${count} new episodes for ${title}'; String syncedNewEpisodes({required Object count, required Object title}) => 'Synced ${count} new episodes for ${title}';
@@ -6245,9 +6263,15 @@ extension on Translations {
'downloads.editSyncRule' => 'Edit sync rule', 'downloads.editSyncRule' => 'Edit sync rule',
'downloads.removeSyncRule' => 'Remove sync rule', 'downloads.removeSyncRule' => 'Remove sync rule',
'downloads.removeSyncRuleConfirm' => ({required Object title}) => 'Stop syncing "${title}"? Downloaded episodes will be kept.', 'downloads.removeSyncRuleConfirm' => ({required Object title}) => 'Stop syncing "${title}"? Downloaded episodes will be kept.',
'downloads.removeListSyncRuleConfirm' => ({required Object title}) => 'Stop syncing "${title}"?',
'downloads.deleteSyncRuleDownloads' => 'Also delete associated downloads',
'downloads.deleteSyncRuleDownloadsDescription' => 'Downloads used by another sync rule or profile will be kept.',
'downloads.syncRuleCreated' => ({required Object count}) => 'Sync rule created — keeping ${count} unwatched episodes', 'downloads.syncRuleCreated' => ({required Object count}) => 'Sync rule created — keeping ${count} unwatched episodes',
'downloads.syncRuleUpdated' => 'Sync rule updated', 'downloads.syncRuleUpdated' => 'Sync rule updated',
'downloads.syncRuleRemoved' => 'Sync rule removed', 'downloads.syncRuleRemoved' => 'Sync rule removed',
'downloads.syncRuleAndDownloadsRemoved' => 'Sync rule and associated downloads removed',
'downloads.syncRuleCleanupBusy' => 'Sync rules are currently updating. Try again in a moment.',
'downloads.syncRuleCleanupUnavailable' => 'Associated downloads could not be identified safely. Reconnect the server and try again, or remove the rule without deleting downloads.',
'downloads.syncedNewEpisodes' => ({required Object count, required Object title}) => 'Synced ${count} new episodes for ${title}', 'downloads.syncedNewEpisodes' => ({required Object count, required Object title}) => 'Synced ${count} new episodes for ${title}',
'downloads.activeSyncRules' => 'Sync rules', 'downloads.activeSyncRules' => 'Sync rules',
'downloads.noSyncRules' => 'No sync rules', 'downloads.noSyncRules' => 'No sync rules',
+6
View File
@@ -1191,9 +1191,15 @@
"editSyncRule": "Redigera synkregel", "editSyncRule": "Redigera synkregel",
"removeSyncRule": "Ta bort synkregel", "removeSyncRule": "Ta bort synkregel",
"removeSyncRuleConfirm": "Sluta synkronisera \"${title}\"? Nedladdade avsnitt behålls.", "removeSyncRuleConfirm": "Sluta synkronisera \"${title}\"? Nedladdade avsnitt behålls.",
"removeListSyncRuleConfirm": "",
"deleteSyncRuleDownloads": "",
"deleteSyncRuleDownloadsDescription": "",
"syncRuleCreated": "Synkregel skapad — behåller ${count} osedda avsnitt", "syncRuleCreated": "Synkregel skapad — behåller ${count} osedda avsnitt",
"syncRuleUpdated": "Synkregel uppdaterad", "syncRuleUpdated": "Synkregel uppdaterad",
"syncRuleRemoved": "Synkregel borttagen", "syncRuleRemoved": "Synkregel borttagen",
"syncRuleAndDownloadsRemoved": "",
"syncRuleCleanupBusy": "",
"syncRuleCleanupUnavailable": "",
"syncedNewEpisodes": "Synkroniserade ${count} nya avsnitt för ${title}", "syncedNewEpisodes": "Synkroniserade ${count} nya avsnitt för ${title}",
"activeSyncRules": "Synkregler", "activeSyncRules": "Synkregler",
"noSyncRules": "Inga synkregler", "noSyncRules": "Inga synkregler",
+6
View File
@@ -1188,9 +1188,15 @@
"editSyncRule": "編輯同步規則", "editSyncRule": "編輯同步規則",
"removeSyncRule": "刪除同步規則", "removeSyncRule": "刪除同步規則",
"removeSyncRuleConfirm": "停止同步「${title}」?已下載的單集將會保留。", "removeSyncRuleConfirm": "停止同步「${title}」?已下載的單集將會保留。",
"removeListSyncRuleConfirm": "",
"deleteSyncRuleDownloads": "",
"deleteSyncRuleDownloadsDescription": "",
"syncRuleCreated": "同步規則已建立 — 將保留 ${count} 個未觀看單集", "syncRuleCreated": "同步規則已建立 — 將保留 ${count} 個未觀看單集",
"syncRuleUpdated": "同步規則已更新", "syncRuleUpdated": "同步規則已更新",
"syncRuleRemoved": "同步規則已刪除", "syncRuleRemoved": "同步規則已刪除",
"syncRuleAndDownloadsRemoved": "",
"syncRuleCleanupBusy": "",
"syncRuleCleanupUnavailable": "",
"syncedNewEpisodes": "已為 ${title} 同步 ${count} 個新單集", "syncedNewEpisodes": "已為 ${title} 同步 ${count} 個新單集",
"activeSyncRules": "同步規則", "activeSyncRules": "同步規則",
"noSyncRules": "沒有同步規則", "noSyncRules": "沒有同步規則",
+6
View File
@@ -1188,9 +1188,15 @@
"editSyncRule": "编辑同步规则", "editSyncRule": "编辑同步规则",
"removeSyncRule": "删除同步规则", "removeSyncRule": "删除同步规则",
"removeSyncRuleConfirm": "停止同步“${title}”?已下载的剧集将被保留。", "removeSyncRuleConfirm": "停止同步“${title}”?已下载的剧集将被保留。",
"removeListSyncRuleConfirm": "",
"deleteSyncRuleDownloads": "",
"deleteSyncRuleDownloadsDescription": "",
"syncRuleCreated": "同步规则已创建 — 保留 ${count} 集未观看内容", "syncRuleCreated": "同步规则已创建 — 保留 ${count} 集未观看内容",
"syncRuleUpdated": "同步规则已更新", "syncRuleUpdated": "同步规则已更新",
"syncRuleRemoved": "同步规则已删除", "syncRuleRemoved": "同步规则已删除",
"syncRuleAndDownloadsRemoved": "",
"syncRuleCleanupBusy": "",
"syncRuleCleanupUnavailable": "",
"syncedNewEpisodes": "已为 ${title} 同步 ${count} 个新剧集", "syncedNewEpisodes": "已为 ${title} 同步 ${count} 个新剧集",
"activeSyncRules": "同步规则", "activeSyncRules": "同步规则",
"noSyncRules": "没有同步规则", "noSyncRules": "没有同步规则",
+254 -52
View File
@@ -29,6 +29,7 @@ import '../utils/deletion_notifier.dart';
import '../utils/downloaded_version_match.dart'; import '../utils/downloaded_version_match.dart';
import '../media/episode_collection.dart'; import '../media/episode_collection.dart';
import '../utils/global_key_utils.dart'; import '../utils/global_key_utils.dart';
import '../utils/content_utils.dart';
import '../utils/notification_permission.dart'; import '../utils/notification_permission.dart';
import '../utils/watch_state_notifier.dart'; import '../utils/watch_state_notifier.dart';
import '../mixins/disposable_change_notifier_mixin.dart'; import '../mixins/disposable_change_notifier_mixin.dart';
@@ -96,6 +97,8 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
// Persistent sync rules keyed by profile-scoped globalKey // Persistent sync rules keyed by profile-scoped globalKey
// (profileId|serverId:ratingKey). Downloads remain public/shared. // (profileId|serverId:ratingKey). Downloads remain public/shared.
final Map<String, SyncRuleItem> _syncRules = {}; final Map<String, SyncRuleItem> _syncRules = {};
final Set<String> _removingSyncRuleKeys = {};
bool _syncRuleCleanupInProgress = false;
String? _activeProfileId; String? _activeProfileId;
int _profileGeneration = 0; int _profileGeneration = 0;
@@ -1150,12 +1153,12 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
/// Movies, episodes, and tracks are queued directly. Shows and seasons are /// Movies, episodes, and tracks are queued directly. Shows and seasons are
/// expanded into their episodes and albums/artists into their tracks (when /// expanded into their episodes and albums/artists into their tracks (when
/// [expandShows] is true). Nested collections/playlists and unknown types /// [expandShows] is true). Nested collections/playlists and unknown types
/// are skipped.
Future<int> queueListDownload( Future<int> queueListDownload(
List<MediaItem> items, List<MediaItem> items,
MediaServerClient client, { MediaServerClient client, {
DownloadFilter filter = DownloadFilter.all, DownloadFilter filter = DownloadFilter.all,
bool expandShows = true, bool expandShows = true,
SyncRuleItem? syncRule,
}) async { }) async {
if (!_downloadManager.downloadsSupported) return 0; if (!_downloadManager.downloadsSupported) return 0;
@@ -1165,44 +1168,61 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
} }
if (!_isQueueOwnershipCurrent(ownership)) return 0; if (!_isQueueOwnershipCurrent(ownership)) return 0;
final unwatchedOnly = filter == DownloadFilter.unwatched; final membership = <MediaItem>[];
final relatedContext = _RelatedMetadataDownloadContext(); final candidates = <MediaItem>[];
int count = 0; if (expandShows) {
if (syncRule != null) {
await _syncRuleExecutor.collectItemsForList(client, items, unwatchedOnly: false, out: membership);
}
if (filter == DownloadFilter.all && syncRule != null) {
candidates.addAll(membership);
} else {
await _syncRuleExecutor.collectItemsForList(
client,
items,
unwatchedOnly: filter == DownloadFilter.unwatched,
out: candidates,
);
}
} else {
final playableItems = items.where((item) => item.isMovie || item.isEpisode || item.kind == MediaKind.track);
if (syncRule != null) membership.addAll(playableItems);
candidates.addAll(
filter == DownloadFilter.unwatched
? playableItems.where((item) => item.isUnwatchedOrInProgress)
: playableItems,
);
}
if (!_isQueueOwnershipCurrent(ownership)) return 0;
Future<void> queueItem(MediaItem item) async { if (syncRule != null) {
if (unwatchedOnly && !item.isUnwatchedOrInProgress) return; for (final item in membership) {
final queued = await _queueSingleDownload(item, client, ownership: ownership, relatedContext: relatedContext); final withServer = _ensureServerId(item, client.serverId);
if (queued) count++; if (_hasActiveOwnedDownload(withServer.globalKey)) {
await _associateSyncRuleDownload(syncRule, withServer.globalKey, ownership);
}
}
} }
for (final item in items) { final relatedContext = _RelatedMetadataDownloadContext();
var count = 0;
for (final item in candidates) {
if (!_isQueueOwnershipCurrent(ownership)) return count; if (!_isQueueOwnershipCurrent(ownership)) return count;
if (item.isMovie || item.isEpisode || item.kind == MediaKind.track) { final withServer = _ensureServerId(item, client.serverId);
await queueItem(item); if (_hasActiveOwnedDownload(withServer.globalKey)) continue;
} else if (item.isShow || item.isSeason) { final queued = await _queueSingleDownload(
if (!expandShows) continue; withServer,
// One-shot recursive expansion for both shows and seasons. client,
final episodes = <MediaItem>[]; ownership: ownership,
await collectEpisodes(client, item.id, unwatchedOnly: unwatchedOnly, out: episodes, fallback: item); relatedContext: relatedContext,
if (!_isQueueOwnershipCurrent(ownership)) return count; );
for (final ep in episodes) { if (syncRule != null) {
await queueItem(ep); await _associateSyncRuleDownload(syncRule, withServer.globalKey, ownership);
if (!_isQueueOwnershipCurrent(ownership)) return count;
}
} else if (item.kind == MediaKind.album || item.kind == MediaKind.artist) {
if (!expandShows) continue;
// Same one-shot expansion for music containers (album/artist →
// tracks) via the shared recursive-leaves call.
final tracks = await client.fetchPlayableDescendants(item.id);
if (!_isQueueOwnershipCurrent(ownership)) return count;
for (final track in tracks) {
await queueItem(_ensureServerId(track, item.serverId));
if (!_isQueueOwnershipCurrent(ownership)) return count;
}
} else {
// Skip clips, nested collections/playlists, unknown types.
continue;
} }
if (queued) count++;
}
if (syncRule != null && _isQueueOwnershipCurrent(ownership)) {
await markSyncRuleDownloadLinksInitialized(syncRule.globalKey);
} }
return count; return count;
} }
@@ -1793,6 +1813,56 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
/// Get a sync rule for the given item /// Get a sync rule for the given item
SyncRuleItem? getSyncRule(String globalKey) => _syncRules[globalKey]; SyncRuleItem? getSyncRule(String globalKey) => _syncRules[globalKey];
bool _hasActiveOwnedDownload(String globalKey) {
if (!_ownsDownloadKey(globalKey)) return false;
final progress = _downloads[globalKey];
return progress != null &&
(progress.status == DownloadStatus.downloading ||
progress.status == DownloadStatus.completed ||
progress.status == DownloadStatus.queued ||
progress.status == DownloadStatus.paused);
}
Future<void> _associateSyncRuleDownload(
SyncRuleItem rule,
String downloadGlobalKey,
_QueueOwnership ownership,
) async {
if (!_isQueueOwnershipCurrent(ownership) ||
_removingSyncRuleKeys.contains(rule.globalKey) ||
!_hasActiveOwnedDownload(downloadGlobalKey)) {
return;
}
final currentRule = _syncRules[rule.globalKey];
if (currentRule == null) return;
await _database.associateSyncRuleDownload(currentRule, downloadGlobalKey);
}
Future<bool> _queueSyncRuleDownload(
MediaItem item,
MediaServerClient client, {
required _QueueOwnership ownership,
required _RelatedMetadataDownloadContext relatedContext,
int mediaIndex = 0,
}) async {
if (!_isQueueOwnershipCurrent(ownership)) return false;
return _queueSingleDownload(
item,
client,
ownership: ownership,
mediaIndex: mediaIndex,
relatedContext: relatedContext,
);
}
Future<void> markSyncRuleDownloadLinksInitialized(String globalKey) async {
await _database.markSyncRuleDownloadLinksInitialized(globalKey);
final existing = _syncRules[globalKey];
if (existing != null) {
_syncRules[globalKey] = existing.copyWith(downloadLinksInitialized: true);
}
}
/// Create (or upsert) a sync rule for a show, season, collection, or playlist. /// Create (or upsert) a sync rule for a show, season, collection, or playlist.
/// ///
/// [targetMetadata], when provided, is stored in the in-memory metadata map so /// [targetMetadata], when provided, is stored in the in-memory metadata map so
@@ -1881,6 +1951,129 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
Future<void> deleteSyncRule(String globalKey) async { Future<void> deleteSyncRule(String globalKey) async {
_requireActiveProfileId(); _requireActiveProfileId();
final existing = _syncRules[globalKey] ?? await _database.getSyncRule(globalKey); final existing = _syncRules[globalKey] ?? await _database.getSyncRule(globalKey);
await _deleteSyncRuleRecord(globalKey, existing);
safeNotifyListeners();
}
/// Delete a list sync rule and every active-profile download associated only
/// with that rule. Other rules and profile owners keep their copies.
Future<void> deleteSyncRuleAndDownloads(String globalKey, MultiServerManager serverManager) async {
final profileId = _requireActiveProfileId();
if (_syncRuleCleanupInProgress || _syncRuleExecutor.isExecuting) {
throw const SyncRuleCleanupBusyException();
}
final ownership = _captureQueueOwnership();
final existing = await _database.getSyncRule(globalKey);
if (existing == null || existing.profileId != profileId) return;
if (existing.targetType != ContentTypes.collection && existing.targetType != ContentTypes.playlist) {
throw ArgumentError.value(existing.targetType, 'targetType', 'Only collection/playlist rules support cleanup');
}
var stateChanged = false;
_syncRuleCleanupInProgress = true;
try {
await _backfillUninitializedRuleLinksForServer(existing, serverManager, ownership);
if (!_isQueueOwnershipCurrent(ownership)) {
throw const SyncRuleCleanupBusyException();
}
final trackedRule = await _database.getSyncRule(globalKey);
if (trackedRule == null || !trackedRule.downloadLinksInitialized) {
throw SyncRuleCleanupUnavailableException(globalKey);
}
_removingSyncRuleKeys.add(globalKey);
await _database.updateSyncRuleEnabled(globalKey, false);
final cachedRule = _syncRules[globalKey];
if (cachedRule != null) {
_syncRules[globalKey] = cachedRule.copyWith(enabled: false, downloadLinksInitialized: true);
}
stateChanged = true;
final downloadKeys = await _database.getExclusiveSyncRuleDownloadKeys(trackedRule);
_batchDeletionDepth++;
try {
for (final downloadKey in downloadKeys) {
if (!_isQueueOwnershipCurrent(ownership)) {
throw const SyncRuleCleanupBusyException();
}
final wasOwned = _ownsDownloadKey(downloadKey);
final metadata = _metadata[downloadKey];
await _deleteDownload(downloadKey, notify: false);
if (wasOwned && metadata != null) {
DeletionNotifier().notifyDeletedItem(item: metadata, isDownloadOnly: true);
}
}
} finally {
_batchDeletionDepth--;
}
await _deleteSyncRuleRecord(globalKey, trackedRule);
appLogger.i('Deleted sync rule and ${downloadKeys.length} associated downloads: $globalKey');
} finally {
_removingSyncRuleKeys.remove(globalKey);
_syncRuleCleanupInProgress = false;
if (stateChanged) safeNotifyListeners();
}
}
Future<void> _backfillUninitializedRuleLinksForServer(
SyncRuleItem target,
MultiServerManager serverManager,
_QueueOwnership ownership,
) async {
final rules = await _database.getUninitializedSyncRulesForServer(
profileId: target.profileId,
serverId: ServerId(target.serverId),
);
final requiredRules = rules.where((rule) => rule.enabled || rule.globalKey == target.globalKey);
for (final rule in requiredRules) {
if (!_isQueueOwnershipCurrent(ownership)) {
throw const SyncRuleCleanupBusyException();
}
switch (rule.targetType) {
case ContentTypes.show:
case ContentTypes.season:
final downloadKeys = await _database.getOwnedDownloadKeysForAncestorRule(
profileId: rule.profileId,
serverId: ServerId(rule.serverId),
ratingKey: rule.ratingKey,
matchGrandparent: rule.targetType == ContentTypes.show,
);
for (final downloadKey in downloadKeys) {
await _database.associateSyncRuleDownload(rule, downloadKey);
}
await _database.markSyncRuleDownloadLinksInitialized(rule.globalKey);
break;
case ContentTypes.collection:
case ContentTypes.playlist:
final backfilled = await _syncRuleExecutor.backfillListRuleDownloadLinks(
rule: rule,
serverManager: serverManager,
downloads: downloads,
metadata: Map.unmodifiable(_metadata),
associateDownload: (resolvedRule, downloadKey) async {
if (_isQueueOwnershipCurrent(ownership) && _hasActiveOwnedDownload(downloadKey)) {
await _database.associateSyncRuleDownload(resolvedRule, downloadKey);
}
},
);
if (!backfilled) {
throw SyncRuleCleanupUnavailableException(rule.globalKey);
}
break;
default:
throw SyncRuleCleanupUnavailableException(rule.globalKey);
}
final cachedRule = _syncRules[rule.globalKey];
if (cachedRule != null) {
_syncRules[rule.globalKey] = cachedRule.copyWith(downloadLinksInitialized: true);
}
}
}
Future<void> _deleteSyncRuleRecord(String globalKey, SyncRuleItem? existing) async {
final publicGlobalKey = existing == null final publicGlobalKey = existing == null
? globalKey ? globalKey
: buildGlobalKey(ServerId(existing.serverId), existing.ratingKey); : buildGlobalKey(ServerId(existing.serverId), existing.ratingKey);
@@ -1891,7 +2084,6 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
if (!_downloads.containsKey(publicGlobalKey)) { if (!_downloads.containsKey(publicGlobalKey)) {
_metadata.remove(publicGlobalKey); _metadata.remove(publicGlobalKey);
} }
safeNotifyListeners();
appLogger.i('Deleted sync rule: $globalKey'); appLogger.i('Deleted sync rule: $globalKey');
} }
@@ -1904,6 +2096,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
/// Returns titles of newly queued items (for snackbar display). /// Returns titles of newly queued items (for snackbar display).
Future<List<String>> executeSyncRules(MultiServerManager serverManager, {bool force = false}) async { Future<List<String>> executeSyncRules(MultiServerManager serverManager, {bool force = false}) async {
if (!_downloadManager.downloadsSupported) return []; if (!_downloadManager.downloadsSupported) return [];
if (_syncRuleCleanupInProgress) return [];
final profileId = _activeProfileId; final profileId = _activeProfileId;
if (profileId == null || profileId.isEmpty) return []; if (profileId == null || profileId.isEmpty) return [];
@@ -1916,17 +2109,17 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
serverManager: serverManager, serverManager: serverManager,
downloads: downloads, downloads: downloads,
metadata: Map.unmodifiable(_metadata), metadata: Map.unmodifiable(_metadata),
queueSingleDownload: (episode, client, {int mediaIndex = 0}) async { associateDownload: (rule, downloadGlobalKey) => _associateSyncRuleDownload(rule, downloadGlobalKey, ownership),
// A profile switch mid-pass must not keep queueing the old queueSingleDownload: (episode, client, {int mediaIndex = 0}) {
// profile's rules; whatever does get queued is claimed for the // A profile switch mid-pass must not keep queueing the old profile's
// rule's owner, never the new active profile. // rules; whatever does get queued is claimed for the rule's owner,
if (!_isQueueOwnershipCurrent(ownership)) return false; // never the new active profile.
return _queueSingleDownload( return _queueSyncRuleDownload(
episode, episode,
client, client,
ownership: ownership, ownership: ownership,
mediaIndex: mediaIndex,
relatedContext: relatedContext, relatedContext: relatedContext,
mediaIndex: mediaIndex,
); );
}, },
isOffline: _offlineSource?.isOffline ?? false, isOffline: _offlineSource?.isOffline ?? false,
@@ -1943,6 +2136,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
/// `addToCollection`). Bypasses the cooldown. /// `addToCollection`). Bypasses the cooldown.
Future<SyncRuleResult?> executeSyncRuleFor(String globalKey, MultiServerManager serverManager) async { Future<SyncRuleResult?> executeSyncRuleFor(String globalKey, MultiServerManager serverManager) async {
if (!_downloadManager.downloadsSupported) return null; if (!_downloadManager.downloadsSupported) return null;
if (_syncRuleCleanupInProgress) return null;
final profileId = _activeProfileId; final profileId = _activeProfileId;
if (profileId == null || profileId.isEmpty) return null; if (profileId == null || profileId.isEmpty) return null;
@@ -1956,16 +2150,14 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
serverManager: serverManager, serverManager: serverManager,
downloads: downloads, downloads: downloads,
metadata: Map.unmodifiable(_metadata), metadata: Map.unmodifiable(_metadata),
queueSingleDownload: (episode, client, {int mediaIndex = 0}) async { associateDownload: (rule, downloadGlobalKey) => _associateSyncRuleDownload(rule, downloadGlobalKey, ownership),
if (!_isQueueOwnershipCurrent(ownership)) return false; queueSingleDownload: (episode, client, {int mediaIndex = 0}) => _queueSyncRuleDownload(
return _queueSingleDownload( episode,
episode, client,
client, ownership: ownership,
ownership: ownership, relatedContext: relatedContext,
mediaIndex: mediaIndex, mediaIndex: mediaIndex,
relatedContext: relatedContext, ),
);
},
isOffline: _offlineSource?.isOffline ?? false, isOffline: _offlineSource?.isOffline ?? false,
); );
} }
@@ -2012,6 +2204,16 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
} }
} }
class SyncRuleCleanupBusyException implements Exception {
const SyncRuleCleanupBusyException();
}
class SyncRuleCleanupUnavailableException implements Exception {
final String ruleGlobalKey;
const SyncRuleCleanupUnavailableException(this.ruleGlobalKey);
}
/// Exception thrown when download is blocked due to cellular-only setting /// Exception thrown when download is blocked due to cellular-only setting
class CellularDownloadBlockedException implements Exception { class CellularDownloadBlockedException implements Exception {
String get message => t.settings.cellularDownloadBlocked; String get message => t.settings.cellularDownloadBlocked;
+2 -2
View File
@@ -1304,8 +1304,8 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
globalKey: ruleKey, globalKey: ruleKey,
displayTitle: metadata.displayTitle, displayTitle: metadata.displayTitle,
); );
if (removed && context.mounted) { if (removed != null && context.mounted) {
showSuccessSnackBar(context, t.downloads.syncRuleRemoved); showSuccessSnackBar(context, syncRuleRemovalMessage(removed));
} }
case _SyncRuleAction.delete: case _SyncRuleAction.delete:
+129 -45
View File
@@ -1,5 +1,4 @@
import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/foundation.dart';
import '../media/ids.dart'; import '../media/ids.dart';
import '../database/app_database.dart'; import '../database/app_database.dart';
@@ -22,6 +21,11 @@ class SyncRuleFilter {
static const String unwatched = 'unwatched'; static const String unwatched = 'unwatched';
} }
typedef AssociateSyncRuleDownload = Future<void> Function(SyncRuleItem rule, String downloadGlobalKey);
typedef QueueSyncRuleDownload = Future<bool> Function(MediaItem item, MediaServerClient client, {int mediaIndex});
typedef _ResolvedListRuleItems = ({List<MediaItem> membership, List<MediaItem> candidates});
/// Result of executing a single sync rule. /// Result of executing a single sync rule.
class SyncRuleResult { class SyncRuleResult {
final String globalKey; final String globalKey;
@@ -61,14 +65,16 @@ class SyncRuleExecutor {
/// to bypass it: we already know state changed and the UX expectation is /// to bypass it: we already know state changed and the UX expectation is
/// immediate feedback. /// immediate feedback.
/// ///
/// [queueSingleDownload] queues a single movie/episode and returns `true` if it /// [associateDownload] records coverage for an already-present download.
/// was actually queued (false when the item was already present). /// [queueSingleDownload] queues and records a missing download, returning
/// whether a queue entry was created.
Future<List<SyncRuleResult>> executeSyncRules({ Future<List<SyncRuleResult>> executeSyncRules({
required String profileId, required String profileId,
required MultiServerManager serverManager, required MultiServerManager serverManager,
required Map<String, DownloadProgress> downloads, required Map<String, DownloadProgress> downloads,
required Map<String, MediaItem> metadata, required Map<String, MediaItem> metadata,
required Future<bool> Function(MediaItem episode, MediaServerClient client, {int mediaIndex}) queueSingleDownload, required AssociateSyncRuleDownload associateDownload,
required QueueSyncRuleDownload queueSingleDownload,
required bool isOffline, required bool isOffline,
bool force = false, bool force = false,
}) async { }) async {
@@ -120,6 +126,7 @@ class SyncRuleExecutor {
downloads: downloads, downloads: downloads,
metadata: metadata, metadata: metadata,
queueSingleDownload: queueSingleDownload, queueSingleDownload: queueSingleDownload,
associateDownload: associateDownload,
); );
if (result != null && result.queuedCount > 0) { if (result != null && result.queuedCount > 0) {
results.add(result); results.add(result);
@@ -144,7 +151,8 @@ class SyncRuleExecutor {
required MultiServerManager serverManager, required MultiServerManager serverManager,
required Map<String, DownloadProgress> downloads, required Map<String, DownloadProgress> downloads,
required Map<String, MediaItem> metadata, required Map<String, MediaItem> metadata,
required Future<bool> Function(MediaItem episode, MediaServerClient client, {int mediaIndex}) queueSingleDownload, required AssociateSyncRuleDownload associateDownload,
required QueueSyncRuleDownload queueSingleDownload,
required bool isOffline, required bool isOffline,
}) async { }) async {
if (_isExecuting) { if (_isExecuting) {
@@ -175,6 +183,7 @@ class SyncRuleExecutor {
downloads: downloads, downloads: downloads,
metadata: metadata, metadata: metadata,
queueSingleDownload: queueSingleDownload, queueSingleDownload: queueSingleDownload,
associateDownload: associateDownload,
); );
} catch (e) { } catch (e) {
appLogger.w('Failed to execute single sync rule $globalKey: $e'); appLogger.w('Failed to execute single sync rule $globalKey: $e');
@@ -184,12 +193,56 @@ class SyncRuleExecutor {
} }
} }
/// Populate persistent coverage for a legacy list rule without queueing
/// missing items. Returns false when the rule cannot be resolved safely.
Future<bool> backfillListRuleDownloadLinks({
required SyncRuleItem rule,
required MultiServerManager serverManager,
required Map<String, DownloadProgress> downloads,
required Map<String, MediaItem> metadata,
required AssociateSyncRuleDownload associateDownload,
}) async {
if (_isExecuting || (rule.targetType != ContentTypes.collection && rule.targetType != ContentTypes.playlist)) {
return false;
}
final client = serverManager.getClient(ServerId(rule.serverId));
if (client == null || !serverManager.isServerOnline(ServerId(rule.serverId))) {
return false;
}
_isExecuting = true;
try {
final resolved = await _resolveListRuleItems(
rule: rule,
client: client,
clientScopeId: _clientScopeIdFor(client, ServerId(rule.serverId)),
profileId: rule.profileId,
metadata: metadata,
);
for (final item in resolved.membership) {
final globalKey = buildGlobalKey(ServerId(rule.serverId), item.id);
if (_isActiveDownload(downloads[globalKey])) {
await associateDownload(rule, globalKey);
}
}
await _completeRuleExecution(rule.globalKey);
return true;
} catch (error, stackTrace) {
appLogger.w('Failed to backfill sync rule ${rule.globalKey}', error: error, stackTrace: stackTrace);
return false;
} finally {
_isExecuting = false;
}
}
Future<SyncRuleResult?> _executeRule({ Future<SyncRuleResult?> _executeRule({
required SyncRuleItem rule, required SyncRuleItem rule,
required MultiServerManager serverManager, required MultiServerManager serverManager,
required Map<String, DownloadProgress> downloads, required Map<String, DownloadProgress> downloads,
required Map<String, MediaItem> metadata, required Map<String, MediaItem> metadata,
required Future<bool> Function(MediaItem episode, MediaServerClient client, {int mediaIndex}) queueSingleDownload, required AssociateSyncRuleDownload associateDownload,
required QueueSyncRuleDownload queueSingleDownload,
}) async { }) async {
final client = serverManager.getClient(ServerId(rule.serverId)); final client = serverManager.getClient(ServerId(rule.serverId));
if (client == null || !serverManager.isServerOnline(ServerId(rule.serverId))) { if (client == null || !serverManager.isServerOnline(ServerId(rule.serverId))) {
@@ -222,6 +275,7 @@ class SyncRuleExecutor {
profileId: rule.profileId, profileId: rule.profileId,
downloads: downloads, downloads: downloads,
metadata: resolvedMetadata, metadata: resolvedMetadata,
associateDownload: associateDownload,
queueSingleDownload: queueSingleDownload, queueSingleDownload: queueSingleDownload,
); );
case ContentTypes.collection: case ContentTypes.collection:
@@ -233,6 +287,7 @@ class SyncRuleExecutor {
profileId: rule.profileId, profileId: rule.profileId,
downloads: downloads, downloads: downloads,
metadata: resolvedMetadata, metadata: resolvedMetadata,
associateDownload: associateDownload,
queueSingleDownload: queueSingleDownload, queueSingleDownload: queueSingleDownload,
); );
default: default:
@@ -246,6 +301,10 @@ class SyncRuleExecutor {
return cacheServerId == serverId || cacheServerId.isEmpty ? null : cacheServerId; return cacheServerId == serverId || cacheServerId.isEmpty ? null : cacheServerId;
} }
Future<void> _completeRuleExecution(String globalKey) {
return _database.completeSyncRuleExecution(globalKey);
}
/// Keep [rule.episodeCount] unwatched episodes queued for a show/season /// Keep [rule.episodeCount] unwatched episodes queued for a show/season
/// (0 = all). Always "unwatched" — watched/all filtering doesn't apply here. /// (0 = all). Always "unwatched" — watched/all filtering doesn't apply here.
Future<SyncRuleResult?> _executeEpisodeRule({ Future<SyncRuleResult?> _executeEpisodeRule({
@@ -255,7 +314,8 @@ class SyncRuleExecutor {
required String profileId, required String profileId,
required Map<String, DownloadProgress> downloads, required Map<String, DownloadProgress> downloads,
required Map<String, MediaItem> metadata, required Map<String, MediaItem> metadata,
required Future<bool> Function(MediaItem episode, MediaServerClient client, {int mediaIndex}) queueSingleDownload, required AssociateSyncRuleDownload associateDownload,
required QueueSyncRuleDownload queueSingleDownload,
}) async { }) async {
final fromServer = <MediaItem>[]; final fromServer = <MediaItem>[];
final sourceMetadata = metadata[rule.globalKey]; final sourceMetadata = metadata[rule.globalKey];
@@ -277,14 +337,17 @@ class SyncRuleExecutor {
if (unwatchedEpisodes.isEmpty) { if (unwatchedEpisodes.isEmpty) {
appLogger.d('Sync rule ${rule.globalKey}: no unwatched episodes available'); appLogger.d('Sync rule ${rule.globalKey}: no unwatched episodes available');
await _database.updateSyncRuleLastExecuted(rule.globalKey); await _completeRuleExecution(rule.globalKey);
return null; return null;
} }
int alreadyHave = 0; int alreadyHave = 0;
for (final ep in unwatchedEpisodes) { for (final ep in unwatchedEpisodes) {
final gk = buildGlobalKey(ServerId(rule.serverId), ep.id); final gk = buildGlobalKey(ServerId(rule.serverId), ep.id);
if (_isActiveDownload(downloads[gk])) alreadyHave++; if (_isActiveDownload(downloads[gk])) {
alreadyHave++;
await associateDownload(rule, gk);
}
} }
// episodeCount == 0 means "all unwatched" — target is total unwatched count // episodeCount == 0 means "all unwatched" — target is total unwatched count
@@ -292,7 +355,7 @@ class SyncRuleExecutor {
final deficit = targetCount - alreadyHave; final deficit = targetCount - alreadyHave;
if (deficit <= 0) { if (deficit <= 0) {
appLogger.d('Sync rule ${rule.globalKey}: no deficit ($alreadyHave/$targetCount already have)'); appLogger.d('Sync rule ${rule.globalKey}: no deficit ($alreadyHave/$targetCount already have)');
await _database.updateSyncRuleLastExecuted(rule.globalKey); await _completeRuleExecution(rule.globalKey);
return null; return null;
} }
@@ -305,13 +368,14 @@ class SyncRuleExecutor {
final episodeWithServer = ep.serverId != null ? ep : ep.copyWith(serverId: rule.serverId); final episodeWithServer = ep.serverId != null ? ep : ep.copyWith(serverId: rule.serverId);
final ok = await queueSingleDownload(episodeWithServer, client, mediaIndex: rule.mediaIndex); final ok = await queueSingleDownload(episodeWithServer, client, mediaIndex: rule.mediaIndex);
await associateDownload(rule, gk);
if (ok) { if (ok) {
queued++; queued++;
appLogger.i('Sync rule ${rule.globalKey}: queued ${ep.title ?? ep.id}'); appLogger.i('Sync rule ${rule.globalKey}: queued ${ep.title ?? ep.id}');
} }
} }
await _database.updateSyncRuleLastExecuted(rule.globalKey); await _completeRuleExecution(rule.globalKey);
final displayTitle = metadata[rule.globalKey]?.title; final displayTitle = metadata[rule.globalKey]?.title;
appLogger.i('Sync rule ${rule.globalKey}: queued $queued episodes (had $alreadyHave/$targetCount)'); appLogger.i('Sync rule ${rule.globalKey}: queued $queued episodes (had $alreadyHave/$targetCount)');
@@ -329,47 +393,31 @@ class SyncRuleExecutor {
required String profileId, required String profileId,
required Map<String, DownloadProgress> downloads, required Map<String, DownloadProgress> downloads,
required Map<String, MediaItem> metadata, required Map<String, MediaItem> metadata,
required Future<bool> Function(MediaItem episode, MediaServerClient client, {int mediaIndex}) queueSingleDownload, required AssociateSyncRuleDownload associateDownload,
required QueueSyncRuleDownload queueSingleDownload,
}) async { }) async {
final List<MediaItem> rootItems; final _ResolvedListRuleItems resolved;
try { try {
// Page list calls so long collections/playlists don't truncate at the resolved = await _resolveListRuleItems(
// default limit. Plex collections use a distinct collections endpoint; rule: rule,
// Jellyfin's collection page implementation maps to its children API. client: client,
if (rule.targetType == ContentTypes.collection) { clientScopeId: clientScopeId,
rootItems = await _fetchAllCollectionItems(client, rule.ratingKey, source: metadata[rule.globalKey]); profileId: profileId,
} else { metadata: metadata,
rootItems = await _fetchAllPlaylistItems(client, rule.ratingKey); );
}
} catch (e) { } catch (e) {
appLogger.w('Sync rule ${rule.globalKey}: failed to fetch list items: $e'); appLogger.w('Sync rule ${rule.globalKey}: failed to fetch list items: $e');
return null; return null;
} }
if (rootItems.isEmpty) { for (final item in resolved.membership) {
appLogger.d('Sync rule ${rule.globalKey}: list is empty'); final globalKey = buildGlobalKey(ServerId(rule.serverId), item.id);
await _database.updateSyncRuleLastExecuted(rule.globalKey); if (_isActiveDownload(downloads[globalKey])) {
return null; await associateDownload(rule, globalKey);
}
} }
final unwatchedOnly = rule.downloadFilter == SyncRuleFilter.unwatched; final candidates = resolved.candidates;
final collected = <MediaItem>[];
await collectItemsForList(client, rootItems, unwatchedOnly: unwatchedOnly, out: collected);
final candidates = unwatchedOnly
? await _excludeLocallyWatched(
episodes: collected,
serverId: ServerId(rule.serverId),
profileId: profileId,
clientScopeId: clientScopeId,
)
: collected;
if (candidates.isEmpty) {
appLogger.d('Sync rule ${rule.globalKey}: no candidates after filtering');
await _database.updateSyncRuleLastExecuted(rule.globalKey);
return null;
}
int queued = 0; int queued = 0;
for (final item in candidates) { for (final item in candidates) {
@@ -378,13 +426,14 @@ class SyncRuleExecutor {
final itemWithServer = item.serverId != null ? item : item.copyWith(serverId: rule.serverId); final itemWithServer = item.serverId != null ? item : item.copyWith(serverId: rule.serverId);
final ok = await queueSingleDownload(itemWithServer, client, mediaIndex: 0); final ok = await queueSingleDownload(itemWithServer, client, mediaIndex: 0);
await associateDownload(rule, gk);
if (ok) { if (ok) {
queued++; queued++;
appLogger.i('Sync rule ${rule.globalKey}: queued ${item.title ?? item.id}'); appLogger.i('Sync rule ${rule.globalKey}: queued ${item.title ?? item.id}');
} }
} }
await _database.updateSyncRuleLastExecuted(rule.globalKey); await _completeRuleExecution(rule.globalKey);
final displayTitle = metadata[rule.globalKey]?.title; final displayTitle = metadata[rule.globalKey]?.title;
appLogger.i('Sync rule ${rule.globalKey}: queued $queued items from ${candidates.length} candidates'); appLogger.i('Sync rule ${rule.globalKey}: queued $queued items from ${candidates.length} candidates');
@@ -392,6 +441,42 @@ class SyncRuleExecutor {
return SyncRuleResult(globalKey: rule.globalKey, title: displayTitle, queuedCount: queued); return SyncRuleResult(globalKey: rule.globalKey, title: displayTitle, queuedCount: queued);
} }
Future<_ResolvedListRuleItems> _resolveListRuleItems({
required SyncRuleItem rule,
required MediaServerClient client,
required String? clientScopeId,
required String profileId,
required Map<String, MediaItem> metadata,
}) async {
// Page list calls so long collections/playlists don't truncate at the
// default limit. Plex collections use a distinct collections endpoint;
// Jellyfin's collection page implementation maps to its children API.
final rootItems = rule.targetType == ContentTypes.collection
? await _fetchAllCollectionItems(client, rule.ratingKey, source: metadata[rule.globalKey])
: await _fetchAllPlaylistItems(client, rule.ratingKey);
if (rootItems.isEmpty) {
return (membership: const <MediaItem>[], candidates: const <MediaItem>[]);
}
// Resolve the complete membership for cleanup provenance. The rule's
// unwatched filter applies only to queueing; watched downloads still
// belong to the list and must be removable with it.
final membership = <MediaItem>[];
await collectItemsForList(client, rootItems, unwatchedOnly: false, out: membership);
if (rule.downloadFilter != SyncRuleFilter.unwatched) {
return (membership: membership, candidates: membership);
}
final serverUnwatched = <MediaItem>[];
await collectItemsForList(client, rootItems, unwatchedOnly: true, out: serverUnwatched);
final candidates = await _excludeLocallyWatched(
episodes: serverUnwatched,
serverId: ServerId(rule.serverId),
profileId: profileId,
clientScopeId: clientScopeId,
);
return (membership: membership, candidates: candidates);
}
/// Page through every item in a playlist using the shared playlist page size. /// Page through every item in a playlist using the shared playlist page size.
Future<List<MediaItem>> _fetchAllPlaylistItems(MediaServerClient client, String playlistId) async { Future<List<MediaItem>> _fetchAllPlaylistItems(MediaServerClient client, String playlistId) async {
return fetchAllPlaylistItems(client, playlistId); return fetchAllPlaylistItems(client, playlistId);
@@ -417,7 +502,6 @@ class SyncRuleExecutor {
/// sync rules). Clips, nested collections/playlists, and unknown types are /// sync rules). Clips, nested collections/playlists, and unknown types are
/// skipped. [unwatchedOnly] applies the same played-state filter to every /// skipped. [unwatchedOnly] applies the same played-state filter to every
/// kind — for tracks that means Plex/Jellyfin play counts. /// kind — for tracks that means Plex/Jellyfin play counts.
@visibleForTesting
Future<void> collectItemsForList( Future<void> collectItemsForList(
MediaServerClient client, MediaServerClient client,
List<MediaItem> items, { List<MediaItem> items, {
+107 -16
View File
@@ -2,15 +2,20 @@ import 'package:flutter/material.dart';
import '../media/ids.dart'; import '../media/ids.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import '../i18n/strings.g.dart'; import '../i18n/strings.g.dart';
import '../media/media_item.dart'; import '../media/media_item.dart';
import '../media/media_kind.dart'; import '../media/media_kind.dart';
import '../media/media_server_client.dart'; import '../media/media_server_client.dart';
import '../database/app_database.dart'; import '../database/app_database.dart';
import '../providers/download_provider.dart'; import '../providers/download_provider.dart';
import '../providers/multi_server_provider.dart';
import '../services/settings_service.dart'; import '../services/settings_service.dart';
import '../services/sync_rule_executor.dart'; import '../services/sync_rule_executor.dart';
import '../widgets/background_download_warning_banner.dart'; import '../widgets/background_download_warning_banner.dart';
import '../widgets/dialog_action_button.dart';
import '../widgets/focusable_list_tile.dart';
import 'app_logger.dart';
import 'content_utils.dart'; import 'content_utils.dart';
import 'dialogs.dart'; import 'dialogs.dart';
import 'download_version_utils.dart'; import 'download_version_utils.dart';
@@ -31,6 +36,12 @@ enum _DownloadChoice { all, unwatched, next5, next10, custom, delete }
/// Whether the user chose a one-time download or a persistent sync rule. /// Whether the user chose a one-time download or a persistent sync rule.
enum _SyncChoice { downloadOnce, keepSynced } enum _SyncChoice { downloadOnce, keepSynced }
enum SyncRuleRemovalResult { ruleOnly, ruleAndDownloads }
String syncRuleRemovalMessage(SyncRuleRemovalResult result) => result == SyncRuleRemovalResult.ruleAndDownloads
? t.downloads.syncRuleAndDownloadsRemoved
: t.downloads.syncRuleRemoved;
/// Result of the download dialog + queue operation. /// Result of the download dialog + queue operation.
class DownloadResult { class DownloadResult {
final int count; final int count;
@@ -251,6 +262,7 @@ Future<DownloadResult?> showListDownloadOptionsAndQueue(
bool syncRuleCreated = false; bool syncRuleCreated = false;
bool syncRuleUpdated = false; bool syncRuleUpdated = false;
SyncRuleItem? syncRule;
if (syncChoice == _SyncChoice.keepSynced) { if (syncChoice == _SyncChoice.keepSynced) {
final ruleKey = downloadProvider.syncRuleKeyFor(ServerId(serverId), rootMetadata.id); final ruleKey = downloadProvider.syncRuleKeyFor(ServerId(serverId), rootMetadata.id);
@@ -269,9 +281,10 @@ Future<DownloadResult?> showListDownloadOptionsAndQueue(
); );
syncRuleCreated = true; syncRuleCreated = true;
} }
syncRule = downloadProvider.getSyncRule(ruleKey);
} }
final count = await downloadProvider.queueListDownload(items, client, filter: selectedFilter); final count = await downloadProvider.queueListDownload(items, client, filter: selectedFilter, syncRule: syncRule);
return DownloadResult( return DownloadResult(
count: count, count: count,
@@ -356,8 +369,8 @@ Future<bool> editSyncRuleCount(
globalKey: globalKey, globalKey: globalKey,
displayTitle: displayTitle ?? globalKey, displayTitle: displayTitle ?? globalKey,
); );
if (removed && context.mounted) { if (removed != null && context.mounted) {
showSuccessSnackBar(context, t.downloads.syncRuleRemoved); showSuccessSnackBar(context, syncRuleRemovalMessage(removed));
} }
return false; return false;
} }
@@ -388,23 +401,101 @@ Future<bool> editSyncRuleFilter(
return true; return true;
} }
/// Shows a confirmation dialog to remove a sync rule. Returns true if removed. /// Shows a confirmation dialog to remove a sync rule.
Future<bool> confirmAndRemoveSyncRule( Future<SyncRuleRemovalResult?> confirmAndRemoveSyncRule(
BuildContext context, { BuildContext context, {
required DownloadProvider downloadProvider, required DownloadProvider downloadProvider,
required String globalKey, required String globalKey,
required String displayTitle, required String displayTitle,
}) async { }) async {
final confirmed = await showConfirmDialog( final rule = downloadProvider.getSyncRule(globalKey);
context, if (rule == null) return null;
title: t.downloads.removeSyncRule,
message: t.downloads.removeSyncRuleConfirm(title: displayTitle),
confirmText: t.downloads.removeSyncRule,
);
if (!confirmed || !context.mounted) return false;
await downloadProvider.deleteSyncRule(globalKey); final bool deleteDownloads;
return true; if (rule.isListRule) {
final choice = await _showListSyncRuleRemovalDialog(context, displayTitle);
if (choice == null || !context.mounted) return null;
deleteDownloads = choice;
} else {
final confirmed = await showConfirmDialog(
context,
title: t.downloads.removeSyncRule,
message: t.downloads.removeSyncRuleConfirm(title: displayTitle),
confirmText: t.downloads.removeSyncRule,
);
if (!confirmed || !context.mounted) return null;
deleteDownloads = false;
}
try {
if (deleteDownloads) {
final serverManager = context.read<MultiServerProvider>().serverManager;
await downloadProvider.deleteSyncRuleAndDownloads(globalKey, serverManager);
return SyncRuleRemovalResult.ruleAndDownloads;
}
await downloadProvider.deleteSyncRule(globalKey);
return SyncRuleRemovalResult.ruleOnly;
} on SyncRuleCleanupBusyException {
if (context.mounted) showErrorSnackBar(context, t.downloads.syncRuleCleanupBusy);
return null;
} on SyncRuleCleanupUnavailableException {
if (context.mounted) showErrorSnackBar(context, t.downloads.syncRuleCleanupUnavailable);
return null;
} catch (error, stackTrace) {
appLogger.e('Failed to remove sync rule', error: error, stackTrace: stackTrace);
if (context.mounted) {
showErrorSnackBar(context, t.messages.errorLoading(error: error.toString()));
}
return null;
}
}
Future<bool?> _showListSyncRuleRemovalDialog(BuildContext context, String displayTitle) {
var deleteDownloads = false;
return showScopedDialog<bool>(
context: context,
builder: (dialogContext) {
return StatefulBuilder(
builder: (context, setState) {
final colorScheme = Theme.of(context).colorScheme;
return AlertDialog(
title: Text(t.downloads.removeSyncRule),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(t.downloads.removeListSyncRuleConfirm(title: displayTitle)),
const SizedBox(height: 12),
FocusableSwitchListTile(
key: const ValueKey('delete_sync_rule_downloads'),
value: deleteDownloads,
onChanged: (value) => setState(() => deleteDownloads = value),
title: Text(t.downloads.deleteSyncRuleDownloads),
subtitle: Text(t.downloads.deleteSyncRuleDownloadsDescription),
contentPadding: EdgeInsets.zero,
),
],
),
actions: [
DialogActionButton(
autofocus: true,
onPressed: () => Navigator.pop(dialogContext),
label: t.common.cancel,
),
DialogActionButton(
onPressed: () => Navigator.pop(dialogContext, deleteDownloads),
label: t.downloads.removeSyncRule,
isPrimary: true,
style: deleteDownloads
? FilledButton.styleFrom(backgroundColor: colorScheme.error, foregroundColor: colorScheme.onError)
: null,
),
],
);
},
);
},
);
} }
/// Whether this rule targets a collection or playlist (as opposed to a /// Whether this rule targets a collection or playlist (as opposed to a
@@ -462,7 +553,7 @@ Future<void> removeSyncRuleAndSnack(
globalKey: globalKey, globalKey: globalKey,
displayTitle: displayTitle, displayTitle: displayTitle,
); );
if (removed && context.mounted) { if (removed != null && context.mounted) {
showSuccessSnackBar(context, t.downloads.syncRuleRemoved); showSuccessSnackBar(context, syncRuleRemovalMessage(removed));
} }
} }
+98
View File
@@ -781,6 +781,55 @@ class _AppDatabaseTestSuite {
db = AppDatabase.forTesting(NativeDatabase.memory()); db = AppDatabase.forTesting(NativeDatabase.memory());
} }
}); });
test('v20 migration creates sync download associations without claiming legacy rules', () async {
await db.close();
final tempDir = await Directory.systemTemp.createTemp('plezy_db_v20_migration_test_');
final file = File('${tempDir.path}/plezy_downloads.db');
AppDatabase? seeded;
AppDatabase? reopened;
try {
seeded = AppDatabase.forTesting(NativeDatabase(file));
await seeded.select(seeded.syncRuleDownloads).get();
await seeded.insertSyncRule(
profileId: 'profile-a',
serverId: ServerId('server'),
ratingKey: 'playlist',
globalKey: 'profile-a|server:playlist',
targetType: 'playlist',
episodeCount: 0,
);
await seeded.customStatement('DROP TABLE sync_rule_downloads');
await seeded.customStatement('ALTER TABLE sync_rules DROP COLUMN download_links_initialized');
await seeded.customStatement('PRAGMA user_version = 19');
await seeded.close();
seeded = null;
reopened = AppDatabase.forTesting(NativeDatabase(file));
final legacyRule = await reopened.getSyncRule('profile-a|server:playlist');
expect(legacyRule, isNotNull);
expect(legacyRule!.downloadLinksInitialized, isFalse);
expect(await reopened.select(reopened.syncRuleDownloads).get(), isEmpty);
await reopened.insertDownload(
serverId: ServerId('server'),
ratingKey: 'episode',
globalKey: 'server:episode',
type: 'episode',
status: DownloadStatus.completed.index,
);
await reopened.associateSyncRuleDownload(legacyRule, 'server:episode');
expect(await reopened.getSyncRuleDownloadLinks(legacyRule.id), hasLength(1));
await reopened.deleteSyncRule(legacyRule.globalKey);
expect(await reopened.getSyncRuleDownloadLinks(legacyRule.id), isEmpty);
} finally {
await reopened?.close();
await seeded?.close();
await tempDir.delete(recursive: true);
db = AppDatabase.forTesting(NativeDatabase.memory());
}
});
}); });
_registerLegacyDesktopMigrationTests(); _registerLegacyDesktopMigrationTests();
@@ -2133,6 +2182,55 @@ class _AppDatabaseTestSuite {
expect(remaining, hasLength(1)); expect(remaining, hasLength(1));
expect(remaining.first.globalKey, 'srv:11'); expect(remaining.first.globalKey, 'srv:11');
}); });
test('exclusive sync download keys preserve downloads covered by another rule', () async {
Future<SyncRuleItem> insertRule(String profileId, String id) async {
final globalKey = '$profileId|srv:$id';
await db.insertSyncRule(
profileId: profileId,
serverId: ServerId('srv'),
ratingKey: id,
globalKey: globalKey,
targetType: 'playlist',
episodeCount: 0,
);
return (await db.getSyncRule(globalKey))!;
}
Future<void> insertOwnedDownload(String id, List<String> profileIds) async {
final globalKey = 'srv:$id';
await db.insertDownload(
serverId: ServerId('srv'),
ratingKey: id,
globalKey: globalKey,
type: 'episode',
status: DownloadStatus.completed.index,
);
for (final profileId in profileIds) {
await db.addDownloadOwner(profileId: profileId, globalKey: globalKey);
}
}
final target = await insertRule('profile-a', 'playlist-a');
final sibling = await insertRule('profile-a', 'playlist-b');
final otherProfile = await insertRule('profile-b', 'playlist-c');
await insertOwnedDownload('exclusive', ['profile-a']);
await insertOwnedDownload('sibling-shared', ['profile-a']);
await insertOwnedDownload('profile-shared', ['profile-a', 'profile-b']);
await db.associateSyncRuleDownload(target, 'srv:exclusive');
await db.associateSyncRuleDownload(target, 'srv:sibling-shared');
await db.associateSyncRuleDownload(target, 'srv:profile-shared');
await db.associateSyncRuleDownload(sibling, 'srv:sibling-shared');
await db.associateSyncRuleDownload(otherProfile, 'srv:profile-shared');
expect(
await db.getExclusiveSyncRuleDownloadKeys(target),
unorderedEquals(['srv:exclusive', 'srv:profile-shared']),
);
await db.removeDownloadOwner(profileId: 'profile-a', globalKey: 'srv:profile-shared');
expect(await db.getSyncRuleDownloadLinks(otherProfile.id), hasLength(1));
});
}); });
} }
} }
+135
View File
@@ -16,6 +16,7 @@ import 'package:plezy/models/download_models.dart';
import 'package:plezy/providers/download_provider.dart'; import 'package:plezy/providers/download_provider.dart';
import 'package:plezy/services/download_manager_service.dart'; import 'package:plezy/services/download_manager_service.dart';
import 'package:plezy/services/api_cache.dart'; import 'package:plezy/services/api_cache.dart';
import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/services/download_storage_service.dart'; import 'package:plezy/services/download_storage_service.dart';
import 'package:plezy/services/jellyfin_api_cache.dart'; import 'package:plezy/services/jellyfin_api_cache.dart';
import 'package:plezy/services/plex_api_cache.dart'; import 'package:plezy/services/plex_api_cache.dart';
@@ -587,6 +588,30 @@ void main() {
p.dispose(); p.dispose();
}); });
test('deleteSyncRule keeps downloads previously associated with the rule', () async {
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
addTearDown(p.dispose);
await p.ensureInitialized();
await p.createSyncRule(serverId: ServerId('srv'), ratingKey: 'playlist', targetType: 'playlist', episodeCount: 0);
final ruleKey = p.syncRuleKeyFor(ServerId('srv'), 'playlist');
final rule = (await db.getSyncRule(ruleKey))!;
await db.insertDownload(
serverId: ServerId('srv'),
ratingKey: 'episode',
globalKey: 'srv:episode',
type: 'episode',
status: DownloadStatus.completed.index,
);
await db.addDownloadOwner(profileId: 'test-profile', globalKey: 'srv:episode');
await db.associateSyncRuleDownload(rule, 'srv:episode');
await p.deleteSyncRule(ruleKey);
expect(await db.getSyncRule(ruleKey), isNull);
expect(await db.getDownloadedMedia('srv:episode'), isNotNull);
expect(await db.getDownloadOwner(profileId: 'test-profile', globalKey: 'srv:episode'), isNotNull);
});
test('deleteSyncRule releases targetMetadata when no download holds it', () async { test('deleteSyncRule releases targetMetadata when no download holds it', () async {
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db); final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
await p.ensureInitialized(); await p.ensureInitialized();
@@ -646,6 +671,116 @@ void main() {
p.dispose(); p.dispose();
}); });
test('deleteSyncRuleAndDownloads removes only downloads exclusive to the rule and active profile', () async {
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
addTearDown(p.dispose);
await p.ensureInitialized();
final serverManager = MultiServerManager();
addTearDown(serverManager.dispose);
await p.createSyncRule(
serverId: ServerId('srv'),
ratingKey: 'playlist-a',
targetType: 'playlist',
episodeCount: 0,
);
await p.createSyncRule(
serverId: ServerId('srv'),
ratingKey: 'playlist-b',
targetType: 'playlist',
episodeCount: 0,
);
final targetKey = p.syncRuleKeyFor(ServerId('srv'), 'playlist-a');
final siblingKey = p.syncRuleKeyFor(ServerId('srv'), 'playlist-b');
final targetRule = (await db.getSyncRule(targetKey))!;
final siblingRule = (await db.getSyncRule(siblingKey))!;
await db.markSyncRuleDownloadLinksInitialized(targetKey);
await db.markSyncRuleDownloadLinksInitialized(siblingKey);
final items = <String, MediaItem>{
'srv:exclusive': testMediaItem(
id: 'exclusive',
backend: MediaBackend.plex,
kind: MediaKind.episode,
title: 'Exclusive',
serverId: ServerId('srv'),
),
'srv:rule-shared': testMediaItem(
id: 'rule-shared',
backend: MediaBackend.plex,
kind: MediaKind.episode,
title: 'Rule shared',
serverId: ServerId('srv'),
),
'srv:profile-shared': testMediaItem(
id: 'profile-shared',
backend: MediaBackend.plex,
kind: MediaKind.episode,
title: 'Profile shared',
serverId: ServerId('srv'),
),
};
for (final entry in items.entries) {
await db.insertDownload(
serverId: ServerId('srv'),
ratingKey: entry.value.id,
globalKey: entry.key,
type: 'episode',
status: DownloadStatus.completed.index,
);
await db.addDownloadOwner(profileId: 'test-profile', globalKey: entry.key);
await db.associateSyncRuleDownload(targetRule, entry.key);
}
await db.associateSyncRuleDownload(siblingRule, 'srv:rule-shared');
await db.addDownloadOwner(profileId: 'profile-b', globalKey: 'srv:profile-shared');
p.debugSeedState(
downloads: {
for (final key in items.keys) key: DownloadProgress(globalKey: key, status: DownloadStatus.completed),
},
metadata: items,
ownedDownloadKeys: items.keys.toSet(),
);
await p.deleteSyncRuleAndDownloads(targetKey, serverManager);
expect(await db.getSyncRule(targetKey), isNull);
expect(await db.getSyncRule(siblingKey), isNotNull);
expect(await db.getDownloadedMedia('srv:exclusive'), isNull);
expect(await db.getDownloadedMedia('srv:rule-shared'), isNotNull);
expect(await db.getDownloadOwner(profileId: 'test-profile', globalKey: 'srv:rule-shared'), isNotNull);
expect(await db.getDownloadedMedia('srv:profile-shared'), isNotNull);
expect(await db.getDownloadOwner(profileId: 'test-profile', globalKey: 'srv:profile-shared'), isNull);
expect(await db.getDownloadOwner(profileId: 'profile-b', globalKey: 'srv:profile-shared'), isNotNull);
});
test('deleteSyncRuleAndDownloads keeps an unresolvable legacy list rule intact', () async {
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
addTearDown(p.dispose);
await p.ensureInitialized();
final serverManager = MultiServerManager();
addTearDown(serverManager.dispose);
await p.createSyncRule(
serverId: ServerId('missing-server'),
ratingKey: 'deleted-playlist',
targetType: 'playlist',
episodeCount: 0,
);
final ruleKey = p.syncRuleKeyFor(ServerId('missing-server'), 'deleted-playlist');
await expectLater(
p.deleteSyncRuleAndDownloads(ruleKey, serverManager),
throwsA(
isA<SyncRuleCleanupUnavailableException>().having((error) => error.ruleGlobalKey, 'ruleGlobalKey', ruleKey),
),
);
expect(await db.getSyncRule(ruleKey), isNotNull);
expect((await db.getSyncRule(ruleKey))!.enabled, isTrue);
expect(p.hasSyncRule(ruleKey), isTrue);
});
test('watch events target active-profile parent sync rules', () async { test('watch events target active-profile parent sync rules', () async {
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db); final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
await p.ensureInitialized(); await p.ensureInitialized();
@@ -1,3 +1,4 @@
import 'dart:async';
import 'package:drift/native.dart'; import 'package:drift/native.dart';
import 'package:plezy/media/ids.dart'; import 'package:plezy/media/ids.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -85,6 +86,16 @@ MediaItem _show(ServerId serverId, String ratingKey, String title) {
); );
} }
MediaItem _playlist(ServerId serverId, String ratingKey, String title) {
return testMediaItem(
id: ratingKey,
backend: MediaBackend.plex,
kind: MediaKind.playlist,
title: title,
serverId: serverId,
);
}
class _FakeConnectionRegistry extends ConnectionRegistry { class _FakeConnectionRegistry extends ConnectionRegistry {
_FakeConnectionRegistry(super.db, this.connections); _FakeConnectionRegistry(super.db, this.connections);
@@ -141,6 +152,15 @@ void main() {
); );
} }
Future<void> insertPlaylistRule(ServerId serverId, String ratingKey) {
return downloadProvider.createSyncRule(
serverId: serverId,
ratingKey: ratingKey,
targetType: 'playlist',
episodeCount: 0,
);
}
Future<void> pumpScreen(WidgetTester tester, {bool keyboardMode = false}) async { Future<void> pumpScreen(WidgetTester tester, {bool keyboardMode = false}) async {
downloadProvider.debugSeedState( downloadProvider.debugSeedState(
metadata: { metadata: {
@@ -148,6 +168,7 @@ void main() {
'jf-machine:show-2': _show(ServerId('jf-machine'), 'show-2', 'Jellyfin Show'), 'jf-machine:show-2': _show(ServerId('jf-machine'), 'show-2', 'Jellyfin Show'),
'auth-jf:show-3': _show(ServerId('auth-jf'), 'show-3', 'Auth Show'), 'auth-jf:show-3': _show(ServerId('auth-jf'), 'show-3', 'Auth Show'),
'unknown-srv:show-4': _show(ServerId('unknown-srv'), 'show-4', 'Unknown Show'), 'unknown-srv:show-4': _show(ServerId('unknown-srv'), 'show-4', 'Unknown Show'),
'playlist-srv:playlist-1': _playlist(ServerId('playlist-srv'), 'playlist-1', 'Road Trip'),
}, },
); );
@@ -255,6 +276,58 @@ void main() {
expect(find.text('No sync rules'), findsOneWidget); expect(find.text('No sync rules'), findsOneWidget);
}); });
testWidgets('playlist rule removal exposes and runs the destructive cleanup choice', (tester) async {
multiServerProvider = MultiServerProvider(serverManager, DataAggregationService(serverManager));
await insertPlaylistRule(ServerId('playlist-srv'), 'playlist-1');
final ruleKey = downloadProvider.syncRuleKeyFor(ServerId('playlist-srv'), 'playlist-1');
await db.markSyncRuleDownloadLinksInitialized(ruleKey);
await pumpScreen(tester);
await tester.drag(find.text('Road Trip'), const Offset(-140, 0));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('sync_rule_swipe_delete')));
await tester.pumpAndSettle();
expect(find.text('Stop syncing "Road Trip"?'), findsOneWidget);
final toggle = tester.widget<SwitchListTile>(
find.descendant(
of: find.byKey(const ValueKey('delete_sync_rule_downloads')),
matching: find.byType(SwitchListTile),
),
);
expect(toggle.value, isFalse);
final removalCompleted = Completer<void>();
void handleRemoval() {
if (!downloadProvider.hasSyncRule(ruleKey) && !removalCompleted.isCompleted) {
removalCompleted.complete();
}
}
downloadProvider.addListener(handleRemoval);
addTearDown(() => downloadProvider.removeListener(handleRemoval));
await tester.tap(find.byKey(const ValueKey('delete_sync_rule_downloads')));
await tester.pump();
expect(
tester
.widget<SwitchListTile>(
find.descendant(
of: find.byKey(const ValueKey('delete_sync_rule_downloads')),
matching: find.byType(SwitchListTile),
),
)
.value,
isTrue,
);
await tester.tap(find.widgetWithText(FilledButton, 'Remove sync rule'));
await tester.runAsync(() => removalCompleted.future.timeout(const Duration(seconds: 5)));
await tester.pumpAndSettle();
expect(await db.getSyncRule(ruleKey), isNull);
expect(find.text('Sync rule and associated downloads removed'), findsOneWidget);
});
testWidgets('provider rebuilds reuse the connection stream subscription', (tester) async { testWidgets('provider rebuilds reuse the connection stream subscription', (tester) async {
multiServerProvider = MultiServerProvider(serverManager, DataAggregationService(serverManager)); multiServerProvider = MultiServerProvider(serverManager, DataAggregationService(serverManager));
await insertRule(ServerId('orphan-srv'), '76672'); await insertRule(ServerId('orphan-srv'), '76672');
+141
View File
@@ -106,6 +106,7 @@ void main() {
serverManager: manager, serverManager: manager,
downloads: const {}, downloads: const {},
metadata: const {}, metadata: const {},
associateDownload: (_, _) async {},
queueSingleDownload: (item, client, {int mediaIndex = 0}) async { queueSingleDownload: (item, client, {int mediaIndex = 0}) async {
queued.add((item: item, client: client)); queued.add((item: item, client: client));
return true; return true;
@@ -177,6 +178,7 @@ void main() {
serverManager: manager, serverManager: manager,
downloads: const {}, downloads: const {},
metadata: const {}, metadata: const {},
associateDownload: (_, _) async {},
queueSingleDownload: (item, client, {int mediaIndex = 0}) async { queueSingleDownload: (item, client, {int mediaIndex = 0}) async {
queued.add(item); queued.add(item);
return true; return true;
@@ -225,6 +227,7 @@ void main() {
serverManager: manager, serverManager: manager,
downloads: const {}, downloads: const {},
metadata: const {}, metadata: const {},
associateDownload: (_, _) async {},
queueSingleDownload: (item, client, {int mediaIndex = 0}) async => true, queueSingleDownload: (item, client, {int mediaIndex = 0}) async => true,
isOffline: false, isOffline: false,
force: true, force: true,
@@ -278,6 +281,7 @@ void main() {
); );
final queued = <MediaItem>[]; final queued = <MediaItem>[];
final associated = <String>[];
final executor = SyncRuleExecutor(database: db); final executor = SyncRuleExecutor(database: db);
final results = await executor.executeSyncRules( final results = await executor.executeSyncRules(
profileId: 'profile-b', profileId: 'profile-b',
@@ -286,6 +290,7 @@ void main() {
'jf-machine:ep-1': DownloadProgress(globalKey: 'jf-machine:ep-1', status: DownloadStatus.completed), 'jf-machine:ep-1': DownloadProgress(globalKey: 'jf-machine:ep-1', status: DownloadStatus.completed),
}, },
metadata: const {}, metadata: const {},
associateDownload: (_, globalKey) async => associated.add(globalKey),
queueSingleDownload: (item, client, {int mediaIndex = 0}) async { queueSingleDownload: (item, client, {int mediaIndex = 0}) async {
queued.add(item); queued.add(item);
return true; return true;
@@ -297,6 +302,8 @@ void main() {
expect(results, isEmpty); expect(results, isEmpty);
expect(queued, isEmpty); expect(queued, isEmpty);
expect(paths.where((p) => p.startsWith('GET /Items?')), isNotEmpty); expect(paths.where((p) => p.startsWith('GET /Items?')), isNotEmpty);
expect(associated, ['jf-machine:ep-1']);
expect((await db.getSyncRule('profile-b|jf-machine:show-1'))!.downloadLinksInitialized, isTrue);
}); });
test('show sync rule respects includeSpecials=false when expanding episodes', () async { test('show sync rule respects includeSpecials=false when expanding episodes', () async {
@@ -333,6 +340,7 @@ void main() {
serverManager: manager, serverManager: manager,
downloads: const {}, downloads: const {},
metadata: {ruleKey: show}, metadata: {ruleKey: show},
associateDownload: (_, _) async {},
queueSingleDownload: (item, client, {int mediaIndex = 0}) async { queueSingleDownload: (item, client, {int mediaIndex = 0}) async {
queued.add(item); queued.add(item);
return true; return true;
@@ -346,6 +354,49 @@ void main() {
expect(client.fetchPlayableDescendantsCalls, ['show-1']); expect(client.fetchPlayableDescendantsCalls, ['show-1']);
}); });
test('Jellyfin playlist sync associates an already-downloaded member', () async {
final db = AppDatabase.forTesting(NativeDatabase.memory());
final manager = MultiServerManager();
addTearDown(() async {
manager.dispose();
await db.close();
});
final client = _PlaylistPagingClient();
manager.debugRegisterClientForTesting(client);
const ruleKey = 'profile-a|jf-machine:playlist-1';
await db.insertSyncRule(
profileId: 'profile-a',
serverId: ServerId('jf-machine'),
ratingKey: 'playlist-1',
globalKey: ruleKey,
targetType: 'playlist',
episodeCount: 0,
downloadFilter: SyncRuleFilter.all,
);
final associated = <String>[];
final results = await SyncRuleExecutor(database: db).executeSyncRules(
profileId: 'profile-a',
serverManager: manager,
downloads: const {
'jf-machine:episode-1': DownloadProgress(globalKey: 'jf-machine:episode-1', status: DownloadStatus.completed),
},
metadata: const {},
associateDownload: (_, globalKey) async => associated.add(globalKey),
queueSingleDownload: (_, _, {int mediaIndex = 0}) async {
fail('an already-downloaded playlist member must not be queued');
},
isOffline: false,
force: true,
);
expect(results, isEmpty);
expect(associated, ['jf-machine:episode-1']);
expect(client.playlistPageCalls, [(start: 0, size: 200)]);
expect((await db.getSyncRule(ruleKey))!.downloadLinksInitialized, isTrue);
});
test('collection sync rule pages through collection API instead of metadata children', () async { test('collection sync rule pages through collection API instead of metadata children', () async {
final db = AppDatabase.forTesting(NativeDatabase.memory()); final db = AppDatabase.forTesting(NativeDatabase.memory());
final manager = MultiServerManager(); final manager = MultiServerManager();
@@ -377,12 +428,14 @@ void main() {
); );
final queued = <MediaItem>[]; final queued = <MediaItem>[];
final associated = <String>[];
final executor = SyncRuleExecutor(database: db); final executor = SyncRuleExecutor(database: db);
final results = await executor.executeSyncRules( final results = await executor.executeSyncRules(
profileId: 'profile-a', profileId: 'profile-a',
serverManager: manager, serverManager: manager,
downloads: const {}, downloads: const {},
metadata: {ruleKey: collection}, metadata: {ruleKey: collection},
associateDownload: (_, globalKey) async => associated.add(globalKey),
queueSingleDownload: (item, client, {int mediaIndex = 0}) async { queueSingleDownload: (item, client, {int mediaIndex = 0}) async {
queued.add(item); queued.add(item);
return true; return true;
@@ -393,10 +446,57 @@ void main() {
expect(results.single.queuedCount, 1); expect(results.single.queuedCount, 1);
expect(queued.single.id, 'movie-1'); expect(queued.single.id, 'movie-1');
expect(associated, ['plex-machine:movie-1']);
expect((await db.getSyncRule(ruleKey))!.downloadLinksInitialized, isTrue);
expect(client.collectionPageCalls, [(start: 0, size: 100)]); expect(client.collectionPageCalls, [(start: 0, size: 100)]);
expect(client.fetchChildrenCalled, isFalse); expect(client.fetchChildrenCalled, isFalse);
}); });
test('legacy list backfill associates active members without queueing', () async {
final db = AppDatabase.forTesting(NativeDatabase.memory());
final manager = MultiServerManager();
addTearDown(() async {
manager.dispose();
await db.close();
});
final client = _CollectionPagingClient();
manager.debugRegisterClientForTesting(client);
const ruleKey = 'profile-a|plex-machine:collection-1';
final collection = testMediaItem(
id: 'collection-1',
backend: MediaBackend.plex,
kind: MediaKind.collection,
title: 'Collection',
serverId: 'plex-machine',
);
await db.insertSyncRule(
profileId: 'profile-a',
serverId: ServerId('plex-machine'),
ratingKey: 'collection-1',
globalKey: ruleKey,
targetType: 'collection',
episodeCount: 0,
downloadFilter: SyncRuleFilter.all,
);
final rule = (await db.getSyncRule(ruleKey))!;
final associated = <String>[];
final backfilled = await SyncRuleExecutor(database: db).backfillListRuleDownloadLinks(
rule: rule,
serverManager: manager,
downloads: const {
'plex-machine:movie-1': DownloadProgress(globalKey: 'plex-machine:movie-1', status: DownloadStatus.completed),
},
metadata: {ruleKey: collection},
associateDownload: (_, globalKey) async => associated.add(globalKey),
);
expect(backfilled, isTrue);
expect(associated, ['plex-machine:movie-1']);
expect((await db.getSyncRule(ruleKey))!.downloadLinksInitialized, isTrue);
});
test('collectItemsForList accepts tracks and expands albums/artists', () async { test('collectItemsForList accepts tracks and expands albums/artists', () async {
final db = AppDatabase.forTesting(NativeDatabase.memory()); final db = AppDatabase.forTesting(NativeDatabase.memory());
addTearDown(db.close); addTearDown(db.close);
@@ -484,6 +584,47 @@ class _PlayableDescendantsClient implements MediaServerClient {
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
} }
class _PlaylistPagingClient implements MediaServerClient {
final playlistPageCalls = <({int? start, int? size})>[];
@override
ServerId get serverId => ServerId('jf-machine');
@override
String? get serverName => 'Jellyfin';
@override
MediaBackend get backend => MediaBackend.jellyfin;
@override
ServerCapabilities get capabilities => ServerCapabilities.jellyfin;
@override
bool get isOfflineMode => false;
@override
void close() {}
@override
Future<MediaItem?> fetchItem(String id) async => null;
@override
Future<LibraryPage<MediaItem>> fetchPlaylistPage(String id, {int? start, int? size, abort}) async {
playlistPageCalls.add((start: start, size: size));
expect(id, 'playlist-1');
return LibraryPage(
items: [
testMediaItem(id: 'episode-1', backend: MediaBackend.jellyfin, kind: MediaKind.episode, title: 'Episode'),
],
totalCount: 1,
offset: start ?? 0,
);
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _CollectionPagingClient implements MediaServerClient { class _CollectionPagingClient implements MediaServerClient {
bool fetchChildrenCalled = false; bool fetchChildrenCalled = false;
final collectionPageCalls = <({int? start, int? size})>[]; final collectionPageCalls = <({int? start, int? size})>[];