@@ -62,6 +62,7 @@ final class AppDatabaseBootstrap {
|
||||
ApiCache,
|
||||
OfflineWatchProgress,
|
||||
SyncRules,
|
||||
SyncRuleDownloads,
|
||||
Connections,
|
||||
Profiles,
|
||||
ProfileConnections,
|
||||
@@ -495,7 +496,7 @@ class AppDatabase extends _$AppDatabase {
|
||||
}
|
||||
|
||||
@override
|
||||
int get schemaVersion => 19;
|
||||
int get schemaVersion => 20;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration {
|
||||
@@ -911,6 +912,18 @@ class AppDatabase extends _$AppDatabase {
|
||||
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();
|
||||
}
|
||||
|
||||
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({
|
||||
String profileId = '',
|
||||
required ServerId serverId,
|
||||
@@ -1290,6 +1378,9 @@ class AppDatabase extends _$AppDatabase {
|
||||
await (update(syncRules)..where((t) => t.id.equals(rule.id))).write(
|
||||
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 {
|
||||
await (delete(syncRules)..where((t) => t.globalKey.equals(globalKey))).go();
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -51,8 +51,13 @@ extension DownloadDatabaseOperations on AppDatabase {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> removeDownloadOwner({required String profileId, required String globalKey}) async {
|
||||
await (delete(downloadOwners)..where((t) => t.profileId.equals(profileId) & t.globalKey.equals(globalKey))).go();
|
||||
Future<void> removeDownloadOwner({required String profileId, required String globalKey}) {
|
||||
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
|
||||
@@ -102,8 +107,12 @@ extension DownloadDatabaseOperations on AppDatabase {
|
||||
)..where((t) => t.profileId.equals(profileId) & t.globalKey.equals(globalKey))).getSingleOrNull();
|
||||
}
|
||||
|
||||
Future<void> clearAllDownloadOwners() async {
|
||||
await delete(downloadOwners).go();
|
||||
Future<void> clearAllDownloadOwners() {
|
||||
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 {
|
||||
@@ -620,6 +629,7 @@ extension DownloadDatabaseOperations on AppDatabase {
|
||||
late String? safRootUri;
|
||||
await transaction(() async {
|
||||
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(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).go();
|
||||
await (delete(downloadQueue)..where((t) => t.mediaGlobalKey.equals(globalKey))).go();
|
||||
|
||||
@@ -105,6 +105,27 @@ class SyncRules extends Table {
|
||||
IntColumn get mediaIndex => integer().withDefault(const Constant(0))();
|
||||
TextColumn get downloadFilter => text().withDefault(const Constant('unwatched'))();
|
||||
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.
|
||||
|
||||
@@ -1191,9 +1191,15 @@
|
||||
"editSyncRule": "Редактирай правило за синхронизация",
|
||||
"removeSyncRule": "Премахни правило за синхронизация",
|
||||
"removeSyncRuleConfirm": "Да се спре ли синхронизацията за \"${title}\"? Изтеглените епизоди ще останат.",
|
||||
"removeListSyncRuleConfirm": "",
|
||||
"deleteSyncRuleDownloads": "",
|
||||
"deleteSyncRuleDownloadsDescription": "",
|
||||
"syncRuleCreated": "Правилото за синхронизация е създадено — запазват се ${count} негледани епизода",
|
||||
"syncRuleUpdated": "Правилото за синхронизация е обновено",
|
||||
"syncRuleRemoved": "Правилото за синхронизация е премахнато",
|
||||
"syncRuleAndDownloadsRemoved": "",
|
||||
"syncRuleCleanupBusy": "",
|
||||
"syncRuleCleanupUnavailable": "",
|
||||
"syncedNewEpisodes": "Синхронизирани са ${count} нови епизода за ${title}",
|
||||
"activeSyncRules": "Правила за синхронизация",
|
||||
"noSyncRules": "Няма правила за синхронизация",
|
||||
|
||||
@@ -1191,9 +1191,15 @@
|
||||
"editSyncRule": "Rediger synkroniseringsregel",
|
||||
"removeSyncRule": "Fjern synkroniseringsregel",
|
||||
"removeSyncRuleConfirm": "Stop synkronisering af \"${title}\"? Downloadede episoder beholdes.",
|
||||
"removeListSyncRuleConfirm": "",
|
||||
"deleteSyncRuleDownloads": "",
|
||||
"deleteSyncRuleDownloadsDescription": "",
|
||||
"syncRuleCreated": "Synkroniseringsregel oprettet — beholder ${count} usete episoder",
|
||||
"syncRuleUpdated": "Synkroniseringsregel opdateret",
|
||||
"syncRuleRemoved": "Synkroniseringsregel fjernet",
|
||||
"syncRuleAndDownloadsRemoved": "",
|
||||
"syncRuleCleanupBusy": "",
|
||||
"syncRuleCleanupUnavailable": "",
|
||||
"syncedNewEpisodes": "Synkroniserede ${count} nye episoder for ${title}",
|
||||
"activeSyncRules": "Synkroniseringsregler",
|
||||
"noSyncRules": "Ingen synkroniseringsregler",
|
||||
|
||||
@@ -1191,9 +1191,15 @@
|
||||
"editSyncRule": "Synchronisierungsregel bearbeiten",
|
||||
"removeSyncRule": "Synchronisierungsregel entfernen",
|
||||
"removeSyncRuleConfirm": "Synchronisierung von „${title}“ beenden? Heruntergeladene Episoden werden behalten.",
|
||||
"removeListSyncRuleConfirm": "",
|
||||
"deleteSyncRuleDownloads": "",
|
||||
"deleteSyncRuleDownloadsDescription": "",
|
||||
"syncRuleCreated": "Synchronisierungsregel erstellt – ${count} ungesehene Episoden werden behalten",
|
||||
"syncRuleUpdated": "Synchronisierungsregel aktualisiert",
|
||||
"syncRuleRemoved": "Synchronisierungsregel entfernt",
|
||||
"syncRuleAndDownloadsRemoved": "",
|
||||
"syncRuleCleanupBusy": "",
|
||||
"syncRuleCleanupUnavailable": "",
|
||||
"syncedNewEpisodes": "${count} neue Episoden für ${title} synchronisiert",
|
||||
"activeSyncRules": "Synchronisierungsregeln",
|
||||
"noSyncRules": "Keine Synchronisierungsregeln",
|
||||
|
||||
@@ -1191,9 +1191,15 @@
|
||||
"editSyncRule": "Edit sync rule",
|
||||
"removeSyncRule": "Remove sync rule",
|
||||
"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",
|
||||
"syncRuleUpdated": "Sync rule updated",
|
||||
"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}",
|
||||
"activeSyncRules": "Sync rules",
|
||||
"noSyncRules": "No sync rules",
|
||||
|
||||
@@ -1191,9 +1191,15 @@
|
||||
"editSyncRule": "Editar regla de sincronización",
|
||||
"removeSyncRule": "Eliminar regla de sincronizació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",
|
||||
"syncRuleUpdated": "Regla de sincronización actualizada",
|
||||
"syncRuleRemoved": "Regla de sincronización eliminada",
|
||||
"syncRuleAndDownloadsRemoved": "",
|
||||
"syncRuleCleanupBusy": "",
|
||||
"syncRuleCleanupUnavailable": "",
|
||||
"syncedNewEpisodes": "${count} nuevos episodios sincronizados para ${title}",
|
||||
"activeSyncRules": "Reglas de sincronización",
|
||||
"noSyncRules": "Sin reglas de sincronización",
|
||||
|
||||
@@ -1191,9 +1191,15 @@
|
||||
"editSyncRule": "Modifier 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.",
|
||||
"removeListSyncRuleConfirm": "",
|
||||
"deleteSyncRuleDownloads": "",
|
||||
"deleteSyncRuleDownloadsDescription": "",
|
||||
"syncRuleCreated": "Règle de synchronisation créée — ${count} épisodes non vus conservés",
|
||||
"syncRuleUpdated": "Règle de synchronisation mise à jour",
|
||||
"syncRuleRemoved": "Règle de synchronisation supprimée",
|
||||
"syncRuleAndDownloadsRemoved": "",
|
||||
"syncRuleCleanupBusy": "",
|
||||
"syncRuleCleanupUnavailable": "",
|
||||
"syncedNewEpisodes": "${count} nouveaux épisodes synchronisés pour ${title}",
|
||||
"activeSyncRules": "Règles de synchronisation",
|
||||
"noSyncRules": "Aucune règle de synchronisation",
|
||||
|
||||
@@ -1191,9 +1191,15 @@
|
||||
"editSyncRule": "Szinkronizálási szabály szerkesztése",
|
||||
"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.",
|
||||
"removeListSyncRuleConfirm": "",
|
||||
"deleteSyncRuleDownloads": "",
|
||||
"deleteSyncRuleDownloadsDescription": "",
|
||||
"syncRuleCreated": "Szinkronizálási szabály létrehozva — ${count} nem látott epizód megtartása",
|
||||
"syncRuleUpdated": "Szinkronizálási szabály frissítve",
|
||||
"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}",
|
||||
"activeSyncRules": "Szinkronizálási szabályok",
|
||||
"noSyncRules": "Nincsenek szinkronizálási szabályok",
|
||||
|
||||
@@ -1191,9 +1191,15 @@
|
||||
"editSyncRule": "Modifica regola di sincronizzazione",
|
||||
"removeSyncRule": "Rimuovi regola di sincronizzazione",
|
||||
"removeSyncRuleConfirm": "Interrompere la sincronizzazione di \"${title}\"? Gli episodi scaricati verranno mantenuti.",
|
||||
"removeListSyncRuleConfirm": "",
|
||||
"deleteSyncRuleDownloads": "",
|
||||
"deleteSyncRuleDownloadsDescription": "",
|
||||
"syncRuleCreated": "Regola di sincronizzazione creata — ${count} episodi non visti mantenuti",
|
||||
"syncRuleUpdated": "Regola di sincronizzazione aggiornata",
|
||||
"syncRuleRemoved": "Regola di sincronizzazione rimossa",
|
||||
"syncRuleAndDownloadsRemoved": "",
|
||||
"syncRuleCleanupBusy": "",
|
||||
"syncRuleCleanupUnavailable": "",
|
||||
"syncedNewEpisodes": "${count} nuovi episodi sincronizzati per ${title}",
|
||||
"activeSyncRules": "Regole di sincronizzazione",
|
||||
"noSyncRules": "Nessuna regola di sincronizzazione",
|
||||
|
||||
@@ -1188,9 +1188,15 @@
|
||||
"editSyncRule": "同期ルールを編集",
|
||||
"removeSyncRule": "同期ルールを削除",
|
||||
"removeSyncRuleConfirm": "「${title}」の同期を停止しますか?ダウンロード済みのエピソードは保持されます。",
|
||||
"removeListSyncRuleConfirm": "",
|
||||
"deleteSyncRuleDownloads": "",
|
||||
"deleteSyncRuleDownloadsDescription": "",
|
||||
"syncRuleCreated": "同期ルールを作成しました — 未視聴のエピソードを${count}件保持",
|
||||
"syncRuleUpdated": "同期ルールを更新しました",
|
||||
"syncRuleRemoved": "同期ルールを削除しました",
|
||||
"syncRuleAndDownloadsRemoved": "",
|
||||
"syncRuleCleanupBusy": "",
|
||||
"syncRuleCleanupUnavailable": "",
|
||||
"syncedNewEpisodes": "${title}の新しいエピソードを${count}件同期しました",
|
||||
"activeSyncRules": "同期ルール",
|
||||
"noSyncRules": "同期ルールなし",
|
||||
|
||||
@@ -1188,9 +1188,15 @@
|
||||
"editSyncRule": "동기화 규칙 편집",
|
||||
"removeSyncRule": "동기화 규칙 제거",
|
||||
"removeSyncRuleConfirm": "\"${title}\" 동기화를 중단하시겠습니까? 다운로드된 에피소드는 유지됩니다.",
|
||||
"removeListSyncRuleConfirm": "",
|
||||
"deleteSyncRuleDownloads": "",
|
||||
"deleteSyncRuleDownloadsDescription": "",
|
||||
"syncRuleCreated": "동기화 규칙 생성됨 — 미시청 에피소드 ${count}개 유지",
|
||||
"syncRuleUpdated": "동기화 규칙 업데이트됨",
|
||||
"syncRuleRemoved": "동기화 규칙 제거됨",
|
||||
"syncRuleAndDownloadsRemoved": "",
|
||||
"syncRuleCleanupBusy": "",
|
||||
"syncRuleCleanupUnavailable": "",
|
||||
"syncedNewEpisodes": "${title}의 새 에피소드 ${count}개 동기화됨",
|
||||
"activeSyncRules": "동기화 규칙",
|
||||
"noSyncRules": "동기화 규칙 없음",
|
||||
|
||||
@@ -1191,9 +1191,15 @@
|
||||
"editSyncRule": "Rediger synkroniseringsregel",
|
||||
"removeSyncRule": "Fjern synkroniseringsregel",
|
||||
"removeSyncRuleConfirm": "Slutte å synkronisere \"${title}\"? Nedlastede episoder beholdes.",
|
||||
"removeListSyncRuleConfirm": "",
|
||||
"deleteSyncRuleDownloads": "",
|
||||
"deleteSyncRuleDownloadsDescription": "",
|
||||
"syncRuleCreated": "Synkroniseringsregel opprettet — beholder ${count} usette episoder",
|
||||
"syncRuleUpdated": "Synkroniseringsregel oppdatert",
|
||||
"syncRuleRemoved": "Synkroniseringsregel fjernet",
|
||||
"syncRuleAndDownloadsRemoved": "",
|
||||
"syncRuleCleanupBusy": "",
|
||||
"syncRuleCleanupUnavailable": "",
|
||||
"syncedNewEpisodes": "Synkroniserte ${count} nye episoder for ${title}",
|
||||
"activeSyncRules": "Synkroniseringsregler",
|
||||
"noSyncRules": "Ingen synkroniseringsregler",
|
||||
|
||||
@@ -1191,9 +1191,15 @@
|
||||
"editSyncRule": "Synchronisatieregel bewerken",
|
||||
"removeSyncRule": "Synchronisatieregel verwijderen",
|
||||
"removeSyncRuleConfirm": "Synchronisatie van \"${title}\" stoppen? Gedownloade afleveringen worden behouden.",
|
||||
"removeListSyncRuleConfirm": "",
|
||||
"deleteSyncRuleDownloads": "",
|
||||
"deleteSyncRuleDownloadsDescription": "",
|
||||
"syncRuleCreated": "Synchronisatieregel aangemaakt — ${count} onbekeken afleveringen behouden",
|
||||
"syncRuleUpdated": "Synchronisatieregel bijgewerkt",
|
||||
"syncRuleRemoved": "Synchronisatieregel verwijderd",
|
||||
"syncRuleAndDownloadsRemoved": "",
|
||||
"syncRuleCleanupBusy": "",
|
||||
"syncRuleCleanupUnavailable": "",
|
||||
"syncedNewEpisodes": "${count} nieuwe afleveringen gesynchroniseerd voor ${title}",
|
||||
"activeSyncRules": "Synchronisatieregels",
|
||||
"noSyncRules": "Geen synchronisatieregels",
|
||||
|
||||
@@ -1197,9 +1197,15 @@
|
||||
"editSyncRule": "Edytuj regułę synchronizacji",
|
||||
"removeSyncRule": "Usuń regułę synchronizacji",
|
||||
"removeSyncRuleConfirm": "Zatrzymać synchronizację \"${title}\"? Pobrane odcinki zostaną zachowane.",
|
||||
"removeListSyncRuleConfirm": "",
|
||||
"deleteSyncRuleDownloads": "",
|
||||
"deleteSyncRuleDownloadsDescription": "",
|
||||
"syncRuleCreated": "Reguła synchronizacji utworzona — zachowywanie ${count} nieobejrzanych odcinków",
|
||||
"syncRuleUpdated": "Reguła synchronizacji zaktualizowana",
|
||||
"syncRuleRemoved": "Reguła synchronizacji usunięta",
|
||||
"syncRuleAndDownloadsRemoved": "",
|
||||
"syncRuleCleanupBusy": "",
|
||||
"syncRuleCleanupUnavailable": "",
|
||||
"syncedNewEpisodes": "Zsynchronizowano ${count} nowych odcinków dla ${title}",
|
||||
"activeSyncRules": "Reguły synchronizacji",
|
||||
"noSyncRules": "Brak reguł synchronizacji",
|
||||
|
||||
@@ -1191,9 +1191,15 @@
|
||||
"editSyncRule": "Editar regra de sincronização",
|
||||
"removeSyncRule": "Remover regra de sincronização",
|
||||
"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",
|
||||
"syncRuleUpdated": "Regra de sincronização atualizada",
|
||||
"syncRuleRemoved": "Regra de sincronização removida",
|
||||
"syncRuleAndDownloadsRemoved": "",
|
||||
"syncRuleCleanupBusy": "",
|
||||
"syncRuleCleanupUnavailable": "",
|
||||
"syncedNewEpisodes": "${count} novos episódios sincronizados para ${title}",
|
||||
"activeSyncRules": "Regras de sincronização",
|
||||
"noSyncRules": "Nenhuma regra de sincronização",
|
||||
|
||||
@@ -1197,9 +1197,15 @@
|
||||
"editSyncRule": "Редактировать правило синхронизации",
|
||||
"removeSyncRule": "Удалить правило синхронизации",
|
||||
"removeSyncRuleConfirm": "Прекратить синхронизацию «${title}»? Скачанные эпизоды будут сохранены.",
|
||||
"removeListSyncRuleConfirm": "",
|
||||
"deleteSyncRuleDownloads": "",
|
||||
"deleteSyncRuleDownloadsDescription": "",
|
||||
"syncRuleCreated": "Правило синхронизации создано — хранится ${count} непросмотренных эпизодов",
|
||||
"syncRuleUpdated": "Правило синхронизации обновлено",
|
||||
"syncRuleRemoved": "Правило синхронизации удалено",
|
||||
"syncRuleAndDownloadsRemoved": "",
|
||||
"syncRuleCleanupBusy": "",
|
||||
"syncRuleCleanupUnavailable": "",
|
||||
"syncedNewEpisodes": "Синхронизировано ${count} новых эпизодов для ${title}",
|
||||
"activeSyncRules": "Правила синхронизации",
|
||||
"noSyncRules": "Нет правил синхронизации",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
/// To regenerate, run: `dart run slang`
|
||||
///
|
||||
/// Locales: 18
|
||||
/// Strings: 26501 (1472 per locale)
|
||||
/// Strings: 26507 (1472 per locale)
|
||||
|
||||
// coverage:ignore-file
|
||||
// ignore_for_file: type=lint, unused_import
|
||||
|
||||
@@ -3450,6 +3450,15 @@ class Translations$downloads$en {
|
||||
/// en: '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'
|
||||
String syncRuleCreated({required Object count}) => 'Sync rule created — keeping ${count} unwatched episodes';
|
||||
|
||||
@@ -3459,6 +3468,15 @@ class Translations$downloads$en {
|
||||
/// en: '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}'
|
||||
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.removeSyncRule' => 'Remove sync rule',
|
||||
'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.syncRuleUpdated' => 'Sync rule updated',
|
||||
'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.activeSyncRules' => 'Sync rules',
|
||||
'downloads.noSyncRules' => 'No sync rules',
|
||||
|
||||
@@ -1191,9 +1191,15 @@
|
||||
"editSyncRule": "Redigera synkregel",
|
||||
"removeSyncRule": "Ta bort synkregel",
|
||||
"removeSyncRuleConfirm": "Sluta synkronisera \"${title}\"? Nedladdade avsnitt behålls.",
|
||||
"removeListSyncRuleConfirm": "",
|
||||
"deleteSyncRuleDownloads": "",
|
||||
"deleteSyncRuleDownloadsDescription": "",
|
||||
"syncRuleCreated": "Synkregel skapad — behåller ${count} osedda avsnitt",
|
||||
"syncRuleUpdated": "Synkregel uppdaterad",
|
||||
"syncRuleRemoved": "Synkregel borttagen",
|
||||
"syncRuleAndDownloadsRemoved": "",
|
||||
"syncRuleCleanupBusy": "",
|
||||
"syncRuleCleanupUnavailable": "",
|
||||
"syncedNewEpisodes": "Synkroniserade ${count} nya avsnitt för ${title}",
|
||||
"activeSyncRules": "Synkregler",
|
||||
"noSyncRules": "Inga synkregler",
|
||||
|
||||
@@ -1188,9 +1188,15 @@
|
||||
"editSyncRule": "編輯同步規則",
|
||||
"removeSyncRule": "刪除同步規則",
|
||||
"removeSyncRuleConfirm": "停止同步「${title}」?已下載的單集將會保留。",
|
||||
"removeListSyncRuleConfirm": "",
|
||||
"deleteSyncRuleDownloads": "",
|
||||
"deleteSyncRuleDownloadsDescription": "",
|
||||
"syncRuleCreated": "同步規則已建立 — 將保留 ${count} 個未觀看單集",
|
||||
"syncRuleUpdated": "同步規則已更新",
|
||||
"syncRuleRemoved": "同步規則已刪除",
|
||||
"syncRuleAndDownloadsRemoved": "",
|
||||
"syncRuleCleanupBusy": "",
|
||||
"syncRuleCleanupUnavailable": "",
|
||||
"syncedNewEpisodes": "已為 ${title} 同步 ${count} 個新單集",
|
||||
"activeSyncRules": "同步規則",
|
||||
"noSyncRules": "沒有同步規則",
|
||||
|
||||
@@ -1188,9 +1188,15 @@
|
||||
"editSyncRule": "编辑同步规则",
|
||||
"removeSyncRule": "删除同步规则",
|
||||
"removeSyncRuleConfirm": "停止同步“${title}”?已下载的剧集将被保留。",
|
||||
"removeListSyncRuleConfirm": "",
|
||||
"deleteSyncRuleDownloads": "",
|
||||
"deleteSyncRuleDownloadsDescription": "",
|
||||
"syncRuleCreated": "同步规则已创建 — 保留 ${count} 集未观看内容",
|
||||
"syncRuleUpdated": "同步规则已更新",
|
||||
"syncRuleRemoved": "同步规则已删除",
|
||||
"syncRuleAndDownloadsRemoved": "",
|
||||
"syncRuleCleanupBusy": "",
|
||||
"syncRuleCleanupUnavailable": "",
|
||||
"syncedNewEpisodes": "已为 ${title} 同步 ${count} 个新剧集",
|
||||
"activeSyncRules": "同步规则",
|
||||
"noSyncRules": "没有同步规则",
|
||||
|
||||
@@ -29,6 +29,7 @@ import '../utils/deletion_notifier.dart';
|
||||
import '../utils/downloaded_version_match.dart';
|
||||
import '../media/episode_collection.dart';
|
||||
import '../utils/global_key_utils.dart';
|
||||
import '../utils/content_utils.dart';
|
||||
import '../utils/notification_permission.dart';
|
||||
import '../utils/watch_state_notifier.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
|
||||
// (profileId|serverId:ratingKey). Downloads remain public/shared.
|
||||
final Map<String, SyncRuleItem> _syncRules = {};
|
||||
final Set<String> _removingSyncRuleKeys = {};
|
||||
bool _syncRuleCleanupInProgress = false;
|
||||
|
||||
String? _activeProfileId;
|
||||
int _profileGeneration = 0;
|
||||
@@ -1150,12 +1153,12 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
/// Movies, episodes, and tracks are queued directly. Shows and seasons are
|
||||
/// expanded into their episodes and albums/artists into their tracks (when
|
||||
/// [expandShows] is true). Nested collections/playlists and unknown types
|
||||
/// are skipped.
|
||||
Future<int> queueListDownload(
|
||||
List<MediaItem> items,
|
||||
MediaServerClient client, {
|
||||
DownloadFilter filter = DownloadFilter.all,
|
||||
bool expandShows = true,
|
||||
SyncRuleItem? syncRule,
|
||||
}) async {
|
||||
if (!_downloadManager.downloadsSupported) return 0;
|
||||
|
||||
@@ -1165,44 +1168,61 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
}
|
||||
if (!_isQueueOwnershipCurrent(ownership)) return 0;
|
||||
|
||||
final unwatchedOnly = filter == DownloadFilter.unwatched;
|
||||
final relatedContext = _RelatedMetadataDownloadContext();
|
||||
int count = 0;
|
||||
final membership = <MediaItem>[];
|
||||
final candidates = <MediaItem>[];
|
||||
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 (unwatchedOnly && !item.isUnwatchedOrInProgress) return;
|
||||
final queued = await _queueSingleDownload(item, client, ownership: ownership, relatedContext: relatedContext);
|
||||
if (queued) count++;
|
||||
if (syncRule != null) {
|
||||
for (final item in membership) {
|
||||
final withServer = _ensureServerId(item, client.serverId);
|
||||
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 (item.isMovie || item.isEpisode || item.kind == MediaKind.track) {
|
||||
await queueItem(item);
|
||||
} else if (item.isShow || item.isSeason) {
|
||||
if (!expandShows) continue;
|
||||
// One-shot recursive expansion for both shows and seasons.
|
||||
final episodes = <MediaItem>[];
|
||||
await collectEpisodes(client, item.id, unwatchedOnly: unwatchedOnly, out: episodes, fallback: item);
|
||||
if (!_isQueueOwnershipCurrent(ownership)) return count;
|
||||
for (final ep in episodes) {
|
||||
await queueItem(ep);
|
||||
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;
|
||||
final withServer = _ensureServerId(item, client.serverId);
|
||||
if (_hasActiveOwnedDownload(withServer.globalKey)) continue;
|
||||
final queued = await _queueSingleDownload(
|
||||
withServer,
|
||||
client,
|
||||
ownership: ownership,
|
||||
relatedContext: relatedContext,
|
||||
);
|
||||
if (syncRule != null) {
|
||||
await _associateSyncRuleDownload(syncRule, withServer.globalKey, ownership);
|
||||
}
|
||||
if (queued) count++;
|
||||
}
|
||||
if (syncRule != null && _isQueueOwnershipCurrent(ownership)) {
|
||||
await markSyncRuleDownloadLinksInitialized(syncRule.globalKey);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
@@ -1793,6 +1813,56 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
/// Get a sync rule for the given item
|
||||
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.
|
||||
///
|
||||
/// [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 {
|
||||
_requireActiveProfileId();
|
||||
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
|
||||
? globalKey
|
||||
: buildGlobalKey(ServerId(existing.serverId), existing.ratingKey);
|
||||
@@ -1891,7 +2084,6 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
if (!_downloads.containsKey(publicGlobalKey)) {
|
||||
_metadata.remove(publicGlobalKey);
|
||||
}
|
||||
safeNotifyListeners();
|
||||
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).
|
||||
Future<List<String>> executeSyncRules(MultiServerManager serverManager, {bool force = false}) async {
|
||||
if (!_downloadManager.downloadsSupported) return [];
|
||||
if (_syncRuleCleanupInProgress) return [];
|
||||
|
||||
final profileId = _activeProfileId;
|
||||
if (profileId == null || profileId.isEmpty) return [];
|
||||
@@ -1916,17 +2109,17 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
serverManager: serverManager,
|
||||
downloads: downloads,
|
||||
metadata: Map.unmodifiable(_metadata),
|
||||
queueSingleDownload: (episode, client, {int mediaIndex = 0}) async {
|
||||
// A profile switch mid-pass must not keep queueing the old
|
||||
// profile's rules; whatever does get queued is claimed for the
|
||||
// rule's owner, never the new active profile.
|
||||
if (!_isQueueOwnershipCurrent(ownership)) return false;
|
||||
return _queueSingleDownload(
|
||||
associateDownload: (rule, downloadGlobalKey) => _associateSyncRuleDownload(rule, downloadGlobalKey, ownership),
|
||||
queueSingleDownload: (episode, client, {int mediaIndex = 0}) {
|
||||
// A profile switch mid-pass must not keep queueing the old profile's
|
||||
// rules; whatever does get queued is claimed for the rule's owner,
|
||||
// never the new active profile.
|
||||
return _queueSyncRuleDownload(
|
||||
episode,
|
||||
client,
|
||||
ownership: ownership,
|
||||
mediaIndex: mediaIndex,
|
||||
relatedContext: relatedContext,
|
||||
mediaIndex: mediaIndex,
|
||||
);
|
||||
},
|
||||
isOffline: _offlineSource?.isOffline ?? false,
|
||||
@@ -1943,6 +2136,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
/// `addToCollection`). Bypasses the cooldown.
|
||||
Future<SyncRuleResult?> executeSyncRuleFor(String globalKey, MultiServerManager serverManager) async {
|
||||
if (!_downloadManager.downloadsSupported) return null;
|
||||
if (_syncRuleCleanupInProgress) return null;
|
||||
|
||||
final profileId = _activeProfileId;
|
||||
if (profileId == null || profileId.isEmpty) return null;
|
||||
@@ -1956,16 +2150,14 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
serverManager: serverManager,
|
||||
downloads: downloads,
|
||||
metadata: Map.unmodifiable(_metadata),
|
||||
queueSingleDownload: (episode, client, {int mediaIndex = 0}) async {
|
||||
if (!_isQueueOwnershipCurrent(ownership)) return false;
|
||||
return _queueSingleDownload(
|
||||
episode,
|
||||
client,
|
||||
ownership: ownership,
|
||||
mediaIndex: mediaIndex,
|
||||
relatedContext: relatedContext,
|
||||
);
|
||||
},
|
||||
associateDownload: (rule, downloadGlobalKey) => _associateSyncRuleDownload(rule, downloadGlobalKey, ownership),
|
||||
queueSingleDownload: (episode, client, {int mediaIndex = 0}) => _queueSyncRuleDownload(
|
||||
episode,
|
||||
client,
|
||||
ownership: ownership,
|
||||
relatedContext: relatedContext,
|
||||
mediaIndex: mediaIndex,
|
||||
),
|
||||
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
|
||||
class CellularDownloadBlockedException implements Exception {
|
||||
String get message => t.settings.cellularDownloadBlocked;
|
||||
|
||||
@@ -1304,8 +1304,8 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
globalKey: ruleKey,
|
||||
displayTitle: metadata.displayTitle,
|
||||
);
|
||||
if (removed && context.mounted) {
|
||||
showSuccessSnackBar(context, t.downloads.syncRuleRemoved);
|
||||
if (removed != null && context.mounted) {
|
||||
showSuccessSnackBar(context, syncRuleRemovalMessage(removed));
|
||||
}
|
||||
|
||||
case _SyncRuleAction.delete:
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../media/ids.dart';
|
||||
|
||||
import '../database/app_database.dart';
|
||||
@@ -22,6 +21,11 @@ class SyncRuleFilter {
|
||||
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.
|
||||
class SyncRuleResult {
|
||||
final String globalKey;
|
||||
@@ -61,14 +65,16 @@ class SyncRuleExecutor {
|
||||
/// to bypass it: we already know state changed and the UX expectation is
|
||||
/// immediate feedback.
|
||||
///
|
||||
/// [queueSingleDownload] queues a single movie/episode and returns `true` if it
|
||||
/// was actually queued (false when the item was already present).
|
||||
/// [associateDownload] records coverage for an already-present download.
|
||||
/// [queueSingleDownload] queues and records a missing download, returning
|
||||
/// whether a queue entry was created.
|
||||
Future<List<SyncRuleResult>> executeSyncRules({
|
||||
required String profileId,
|
||||
required MultiServerManager serverManager,
|
||||
required Map<String, DownloadProgress> downloads,
|
||||
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,
|
||||
bool force = false,
|
||||
}) async {
|
||||
@@ -120,6 +126,7 @@ class SyncRuleExecutor {
|
||||
downloads: downloads,
|
||||
metadata: metadata,
|
||||
queueSingleDownload: queueSingleDownload,
|
||||
associateDownload: associateDownload,
|
||||
);
|
||||
if (result != null && result.queuedCount > 0) {
|
||||
results.add(result);
|
||||
@@ -144,7 +151,8 @@ class SyncRuleExecutor {
|
||||
required MultiServerManager serverManager,
|
||||
required Map<String, DownloadProgress> downloads,
|
||||
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,
|
||||
}) async {
|
||||
if (_isExecuting) {
|
||||
@@ -175,6 +183,7 @@ class SyncRuleExecutor {
|
||||
downloads: downloads,
|
||||
metadata: metadata,
|
||||
queueSingleDownload: queueSingleDownload,
|
||||
associateDownload: associateDownload,
|
||||
);
|
||||
} catch (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({
|
||||
required SyncRuleItem rule,
|
||||
required MultiServerManager serverManager,
|
||||
required Map<String, DownloadProgress> downloads,
|
||||
required Map<String, MediaItem> metadata,
|
||||
required Future<bool> Function(MediaItem episode, MediaServerClient client, {int mediaIndex}) queueSingleDownload,
|
||||
required AssociateSyncRuleDownload associateDownload,
|
||||
required QueueSyncRuleDownload queueSingleDownload,
|
||||
}) async {
|
||||
final client = serverManager.getClient(ServerId(rule.serverId));
|
||||
if (client == null || !serverManager.isServerOnline(ServerId(rule.serverId))) {
|
||||
@@ -222,6 +275,7 @@ class SyncRuleExecutor {
|
||||
profileId: rule.profileId,
|
||||
downloads: downloads,
|
||||
metadata: resolvedMetadata,
|
||||
associateDownload: associateDownload,
|
||||
queueSingleDownload: queueSingleDownload,
|
||||
);
|
||||
case ContentTypes.collection:
|
||||
@@ -233,6 +287,7 @@ class SyncRuleExecutor {
|
||||
profileId: rule.profileId,
|
||||
downloads: downloads,
|
||||
metadata: resolvedMetadata,
|
||||
associateDownload: associateDownload,
|
||||
queueSingleDownload: queueSingleDownload,
|
||||
);
|
||||
default:
|
||||
@@ -246,6 +301,10 @@ class SyncRuleExecutor {
|
||||
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
|
||||
/// (0 = all). Always "unwatched" — watched/all filtering doesn't apply here.
|
||||
Future<SyncRuleResult?> _executeEpisodeRule({
|
||||
@@ -255,7 +314,8 @@ class SyncRuleExecutor {
|
||||
required String profileId,
|
||||
required Map<String, DownloadProgress> downloads,
|
||||
required Map<String, MediaItem> metadata,
|
||||
required Future<bool> Function(MediaItem episode, MediaServerClient client, {int mediaIndex}) queueSingleDownload,
|
||||
required AssociateSyncRuleDownload associateDownload,
|
||||
required QueueSyncRuleDownload queueSingleDownload,
|
||||
}) async {
|
||||
final fromServer = <MediaItem>[];
|
||||
final sourceMetadata = metadata[rule.globalKey];
|
||||
@@ -277,14 +337,17 @@ class SyncRuleExecutor {
|
||||
|
||||
if (unwatchedEpisodes.isEmpty) {
|
||||
appLogger.d('Sync rule ${rule.globalKey}: no unwatched episodes available');
|
||||
await _database.updateSyncRuleLastExecuted(rule.globalKey);
|
||||
await _completeRuleExecution(rule.globalKey);
|
||||
return null;
|
||||
}
|
||||
|
||||
int alreadyHave = 0;
|
||||
for (final ep in unwatchedEpisodes) {
|
||||
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
|
||||
@@ -292,7 +355,7 @@ class SyncRuleExecutor {
|
||||
final deficit = targetCount - alreadyHave;
|
||||
if (deficit <= 0) {
|
||||
appLogger.d('Sync rule ${rule.globalKey}: no deficit ($alreadyHave/$targetCount already have)');
|
||||
await _database.updateSyncRuleLastExecuted(rule.globalKey);
|
||||
await _completeRuleExecution(rule.globalKey);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -305,13 +368,14 @@ class SyncRuleExecutor {
|
||||
|
||||
final episodeWithServer = ep.serverId != null ? ep : ep.copyWith(serverId: rule.serverId);
|
||||
final ok = await queueSingleDownload(episodeWithServer, client, mediaIndex: rule.mediaIndex);
|
||||
await associateDownload(rule, gk);
|
||||
if (ok) {
|
||||
queued++;
|
||||
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;
|
||||
appLogger.i('Sync rule ${rule.globalKey}: queued $queued episodes (had $alreadyHave/$targetCount)');
|
||||
@@ -329,47 +393,31 @@ class SyncRuleExecutor {
|
||||
required String profileId,
|
||||
required Map<String, DownloadProgress> downloads,
|
||||
required Map<String, MediaItem> metadata,
|
||||
required Future<bool> Function(MediaItem episode, MediaServerClient client, {int mediaIndex}) queueSingleDownload,
|
||||
required AssociateSyncRuleDownload associateDownload,
|
||||
required QueueSyncRuleDownload queueSingleDownload,
|
||||
}) async {
|
||||
final List<MediaItem> rootItems;
|
||||
final _ResolvedListRuleItems resolved;
|
||||
try {
|
||||
// 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.
|
||||
if (rule.targetType == ContentTypes.collection) {
|
||||
rootItems = await _fetchAllCollectionItems(client, rule.ratingKey, source: metadata[rule.globalKey]);
|
||||
} else {
|
||||
rootItems = await _fetchAllPlaylistItems(client, rule.ratingKey);
|
||||
}
|
||||
resolved = await _resolveListRuleItems(
|
||||
rule: rule,
|
||||
client: client,
|
||||
clientScopeId: clientScopeId,
|
||||
profileId: profileId,
|
||||
metadata: metadata,
|
||||
);
|
||||
} catch (e) {
|
||||
appLogger.w('Sync rule ${rule.globalKey}: failed to fetch list items: $e');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (rootItems.isEmpty) {
|
||||
appLogger.d('Sync rule ${rule.globalKey}: list is empty');
|
||||
await _database.updateSyncRuleLastExecuted(rule.globalKey);
|
||||
return null;
|
||||
for (final item in resolved.membership) {
|
||||
final globalKey = buildGlobalKey(ServerId(rule.serverId), item.id);
|
||||
if (_isActiveDownload(downloads[globalKey])) {
|
||||
await associateDownload(rule, globalKey);
|
||||
}
|
||||
}
|
||||
|
||||
final unwatchedOnly = rule.downloadFilter == SyncRuleFilter.unwatched;
|
||||
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;
|
||||
}
|
||||
final candidates = resolved.candidates;
|
||||
|
||||
int queued = 0;
|
||||
for (final item in candidates) {
|
||||
@@ -378,13 +426,14 @@ class SyncRuleExecutor {
|
||||
|
||||
final itemWithServer = item.serverId != null ? item : item.copyWith(serverId: rule.serverId);
|
||||
final ok = await queueSingleDownload(itemWithServer, client, mediaIndex: 0);
|
||||
await associateDownload(rule, gk);
|
||||
if (ok) {
|
||||
queued++;
|
||||
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;
|
||||
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);
|
||||
}
|
||||
|
||||
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.
|
||||
Future<List<MediaItem>> _fetchAllPlaylistItems(MediaServerClient client, String playlistId) async {
|
||||
return fetchAllPlaylistItems(client, playlistId);
|
||||
@@ -417,7 +502,6 @@ class SyncRuleExecutor {
|
||||
/// sync rules). Clips, nested collections/playlists, and unknown types are
|
||||
/// skipped. [unwatchedOnly] applies the same played-state filter to every
|
||||
/// kind — for tracks that means Plex/Jellyfin play counts.
|
||||
@visibleForTesting
|
||||
Future<void> collectItemsForList(
|
||||
MediaServerClient client,
|
||||
List<MediaItem> items, {
|
||||
|
||||
+107
-16
@@ -2,15 +2,20 @@ import 'package:flutter/material.dart';
|
||||
import '../media/ids.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../media/media_item.dart';
|
||||
import '../media/media_kind.dart';
|
||||
import '../media/media_server_client.dart';
|
||||
import '../database/app_database.dart';
|
||||
import '../providers/download_provider.dart';
|
||||
import '../providers/multi_server_provider.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../services/sync_rule_executor.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 'dialogs.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.
|
||||
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.
|
||||
class DownloadResult {
|
||||
final int count;
|
||||
@@ -251,6 +262,7 @@ Future<DownloadResult?> showListDownloadOptionsAndQueue(
|
||||
|
||||
bool syncRuleCreated = false;
|
||||
bool syncRuleUpdated = false;
|
||||
SyncRuleItem? syncRule;
|
||||
|
||||
if (syncChoice == _SyncChoice.keepSynced) {
|
||||
final ruleKey = downloadProvider.syncRuleKeyFor(ServerId(serverId), rootMetadata.id);
|
||||
@@ -269,9 +281,10 @@ Future<DownloadResult?> showListDownloadOptionsAndQueue(
|
||||
);
|
||||
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(
|
||||
count: count,
|
||||
@@ -356,8 +369,8 @@ Future<bool> editSyncRuleCount(
|
||||
globalKey: globalKey,
|
||||
displayTitle: displayTitle ?? globalKey,
|
||||
);
|
||||
if (removed && context.mounted) {
|
||||
showSuccessSnackBar(context, t.downloads.syncRuleRemoved);
|
||||
if (removed != null && context.mounted) {
|
||||
showSuccessSnackBar(context, syncRuleRemovalMessage(removed));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -388,23 +401,101 @@ Future<bool> editSyncRuleFilter(
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Shows a confirmation dialog to remove a sync rule. Returns true if removed.
|
||||
Future<bool> confirmAndRemoveSyncRule(
|
||||
/// Shows a confirmation dialog to remove a sync rule.
|
||||
Future<SyncRuleRemovalResult?> confirmAndRemoveSyncRule(
|
||||
BuildContext context, {
|
||||
required DownloadProvider downloadProvider,
|
||||
required String globalKey,
|
||||
required String displayTitle,
|
||||
}) async {
|
||||
final confirmed = await showConfirmDialog(
|
||||
context,
|
||||
title: t.downloads.removeSyncRule,
|
||||
message: t.downloads.removeSyncRuleConfirm(title: displayTitle),
|
||||
confirmText: t.downloads.removeSyncRule,
|
||||
);
|
||||
if (!confirmed || !context.mounted) return false;
|
||||
final rule = downloadProvider.getSyncRule(globalKey);
|
||||
if (rule == null) return null;
|
||||
|
||||
await downloadProvider.deleteSyncRule(globalKey);
|
||||
return true;
|
||||
final bool deleteDownloads;
|
||||
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
|
||||
@@ -462,7 +553,7 @@ Future<void> removeSyncRuleAndSnack(
|
||||
globalKey: globalKey,
|
||||
displayTitle: displayTitle,
|
||||
);
|
||||
if (removed && context.mounted) {
|
||||
showSuccessSnackBar(context, t.downloads.syncRuleRemoved);
|
||||
if (removed != null && context.mounted) {
|
||||
showSuccessSnackBar(context, syncRuleRemovalMessage(removed));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -781,6 +781,55 @@ class _AppDatabaseTestSuite {
|
||||
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();
|
||||
@@ -2133,6 +2182,55 @@ class _AppDatabaseTestSuite {
|
||||
expect(remaining, hasLength(1));
|
||||
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));
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import 'package:plezy/models/download_models.dart';
|
||||
import 'package:plezy/providers/download_provider.dart';
|
||||
import 'package:plezy/services/download_manager_service.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/jellyfin_api_cache.dart';
|
||||
import 'package:plezy/services/plex_api_cache.dart';
|
||||
@@ -587,6 +588,30 @@ void main() {
|
||||
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 {
|
||||
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
|
||||
await p.ensureInitialized();
|
||||
@@ -646,6 +671,116 @@ void main() {
|
||||
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 {
|
||||
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
|
||||
await p.ensureInitialized();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:plezy/media/ids.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 {
|
||||
_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 {
|
||||
downloadProvider.debugSeedState(
|
||||
metadata: {
|
||||
@@ -148,6 +168,7 @@ void main() {
|
||||
'jf-machine:show-2': _show(ServerId('jf-machine'), 'show-2', 'Jellyfin 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'),
|
||||
'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);
|
||||
});
|
||||
|
||||
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 {
|
||||
multiServerProvider = MultiServerProvider(serverManager, DataAggregationService(serverManager));
|
||||
await insertRule(ServerId('orphan-srv'), '76672');
|
||||
|
||||
@@ -106,6 +106,7 @@ void main() {
|
||||
serverManager: manager,
|
||||
downloads: const {},
|
||||
metadata: const {},
|
||||
associateDownload: (_, _) async {},
|
||||
queueSingleDownload: (item, client, {int mediaIndex = 0}) async {
|
||||
queued.add((item: item, client: client));
|
||||
return true;
|
||||
@@ -177,6 +178,7 @@ void main() {
|
||||
serverManager: manager,
|
||||
downloads: const {},
|
||||
metadata: const {},
|
||||
associateDownload: (_, _) async {},
|
||||
queueSingleDownload: (item, client, {int mediaIndex = 0}) async {
|
||||
queued.add(item);
|
||||
return true;
|
||||
@@ -225,6 +227,7 @@ void main() {
|
||||
serverManager: manager,
|
||||
downloads: const {},
|
||||
metadata: const {},
|
||||
associateDownload: (_, _) async {},
|
||||
queueSingleDownload: (item, client, {int mediaIndex = 0}) async => true,
|
||||
isOffline: false,
|
||||
force: true,
|
||||
@@ -278,6 +281,7 @@ void main() {
|
||||
);
|
||||
|
||||
final queued = <MediaItem>[];
|
||||
final associated = <String>[];
|
||||
final executor = SyncRuleExecutor(database: db);
|
||||
final results = await executor.executeSyncRules(
|
||||
profileId: 'profile-b',
|
||||
@@ -286,6 +290,7 @@ void main() {
|
||||
'jf-machine:ep-1': DownloadProgress(globalKey: 'jf-machine:ep-1', status: DownloadStatus.completed),
|
||||
},
|
||||
metadata: const {},
|
||||
associateDownload: (_, globalKey) async => associated.add(globalKey),
|
||||
queueSingleDownload: (item, client, {int mediaIndex = 0}) async {
|
||||
queued.add(item);
|
||||
return true;
|
||||
@@ -297,6 +302,8 @@ void main() {
|
||||
expect(results, isEmpty);
|
||||
expect(queued, isEmpty);
|
||||
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 {
|
||||
@@ -333,6 +340,7 @@ void main() {
|
||||
serverManager: manager,
|
||||
downloads: const {},
|
||||
metadata: {ruleKey: show},
|
||||
associateDownload: (_, _) async {},
|
||||
queueSingleDownload: (item, client, {int mediaIndex = 0}) async {
|
||||
queued.add(item);
|
||||
return true;
|
||||
@@ -346,6 +354,49 @@ void main() {
|
||||
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 {
|
||||
final db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||
final manager = MultiServerManager();
|
||||
@@ -377,12 +428,14 @@ void main() {
|
||||
);
|
||||
|
||||
final queued = <MediaItem>[];
|
||||
final associated = <String>[];
|
||||
final executor = SyncRuleExecutor(database: db);
|
||||
final results = await executor.executeSyncRules(
|
||||
profileId: 'profile-a',
|
||||
serverManager: manager,
|
||||
downloads: const {},
|
||||
metadata: {ruleKey: collection},
|
||||
associateDownload: (_, globalKey) async => associated.add(globalKey),
|
||||
queueSingleDownload: (item, client, {int mediaIndex = 0}) async {
|
||||
queued.add(item);
|
||||
return true;
|
||||
@@ -393,10 +446,57 @@ void main() {
|
||||
|
||||
expect(results.single.queuedCount, 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.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 {
|
||||
final db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||
addTearDown(db.close);
|
||||
@@ -484,6 +584,47 @@ class _PlayableDescendantsClient implements MediaServerClient {
|
||||
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 {
|
||||
bool fetchChildrenCalled = false;
|
||||
final collectionPageCalls = <({int? start, int? size})>[];
|
||||
|
||||
Reference in New Issue
Block a user