diff --git a/lib/i18n/az.i18n.json b/lib/i18n/az.i18n.json index f0fa8286..ff6f620e 100644 --- a/lib/i18n/az.i18n.json +++ b/lib/i18n/az.i18n.json @@ -518,9 +518,35 @@ "shufflePlay": "Qarışıq oynat", "shuffleNotAvailableOffline": "Qarışıq oynatma oflayn rejimdə əlçatan deyil", "fileInfo": "Fayl məlumatı", - "deleteFromServer": "Serverdən sil", - "confirmDelete": "Bu media və faylları serverinizdən silinsin?", + "deleteEpisodeFromServer": "", + "deleteSeasonFromServer": "", + "deleteShowFromServer": "", + "deleteMovieFromServer": "", + "deleteEpisodeTitle": "", + "deleteSeasonTitle": "", + "deleteShowTitle": "", + "deleteMovieTitle": "", + "deleteEpisodeConfirm": "", + "deleteSeasonConfirm": "", + "deleteShowConfirm": "", + "deleteMovieConfirm": "", + "deleteAnyway": "", + "confirmDeleteTarget": "", "deleteMultipleWarning": "Bu bütün seriyaları və faylları əhatə edir.", + "deleteEpisodeCountWarning": { + "one": "", + "other": "" + }, + "deleteMultiPartWarning": { + "one": "", + "other": "" + }, + "deleteSharedFileHeading": { + "one": "", + "other": "" + }, + "deleteScopeUnverifiedProbeFailed": "", + "deleteScopeUnverifiedNoFileInfo": "", "mediaDeletedSuccessfully": "Media elementi uğurla silindi", "mediaFailedToDelete": "Media elementi silinə bilmədi", "rate": "Qiymətləndir", diff --git a/lib/i18n/bg.i18n.json b/lib/i18n/bg.i18n.json index df5f0fc3..09e00fa0 100644 --- a/lib/i18n/bg.i18n.json +++ b/lib/i18n/bg.i18n.json @@ -518,9 +518,35 @@ "shufflePlay": "Разбъркано възпроизвеждане", "shuffleNotAvailableOffline": "Разбърканото възпроизвеждане не е налично офлайн", "fileInfo": "Информация за файла", - "deleteFromServer": "Изтрий от сървъра", - "confirmDelete": "Да се изтрият ли този елемент и файловете му от вашия сървър?", + "deleteEpisodeFromServer": "", + "deleteSeasonFromServer": "", + "deleteShowFromServer": "", + "deleteMovieFromServer": "", + "deleteEpisodeTitle": "", + "deleteSeasonTitle": "", + "deleteShowTitle": "", + "deleteMovieTitle": "", + "deleteEpisodeConfirm": "", + "deleteSeasonConfirm": "", + "deleteShowConfirm": "", + "deleteMovieConfirm": "", + "deleteAnyway": "", + "confirmDeleteTarget": "", "deleteMultipleWarning": "Това включва всички епизоди и техните файлове.", + "deleteEpisodeCountWarning": { + "one": "", + "other": "" + }, + "deleteMultiPartWarning": { + "one": "", + "other": "" + }, + "deleteSharedFileHeading": { + "one": "", + "other": "" + }, + "deleteScopeUnverifiedProbeFailed": "", + "deleteScopeUnverifiedNoFileInfo": "", "mediaDeletedSuccessfully": "Елементът е изтрит успешно", "mediaFailedToDelete": "Неуспешно изтриване на елемента", "rate": "Оцени", diff --git a/lib/i18n/da.i18n.json b/lib/i18n/da.i18n.json index 87692e10..7ad346e0 100644 --- a/lib/i18n/da.i18n.json +++ b/lib/i18n/da.i18n.json @@ -518,9 +518,35 @@ "shufflePlay": "Afspil tilfældigt", "shuffleNotAvailableOffline": "Tilfældig afspilning er ikke tilgængelig offline", "fileInfo": "Filinfo", - "deleteFromServer": "Slet fra server", - "confirmDelete": "Slet dette medie og dets filer fra din server?", + "deleteEpisodeFromServer": "", + "deleteSeasonFromServer": "", + "deleteShowFromServer": "", + "deleteMovieFromServer": "", + "deleteEpisodeTitle": "", + "deleteSeasonTitle": "", + "deleteShowTitle": "", + "deleteMovieTitle": "", + "deleteEpisodeConfirm": "", + "deleteSeasonConfirm": "", + "deleteShowConfirm": "", + "deleteMovieConfirm": "", + "deleteAnyway": "", + "confirmDeleteTarget": "", "deleteMultipleWarning": "Dette inkluderer alle episoder og deres filer.", + "deleteEpisodeCountWarning": { + "one": "", + "other": "" + }, + "deleteMultiPartWarning": { + "one": "", + "other": "" + }, + "deleteSharedFileHeading": { + "one": "", + "other": "" + }, + "deleteScopeUnverifiedProbeFailed": "", + "deleteScopeUnverifiedNoFileInfo": "", "mediaDeletedSuccessfully": "Mediet blev slettet", "mediaFailedToDelete": "Mediet kunne ikke slettes", "rate": "Bedøm", diff --git a/lib/i18n/de.i18n.json b/lib/i18n/de.i18n.json index b19668b4..95b32ded 100644 --- a/lib/i18n/de.i18n.json +++ b/lib/i18n/de.i18n.json @@ -518,9 +518,35 @@ "shufflePlay": "Zufallswiedergabe", "shuffleNotAvailableOffline": "Zufallswiedergabe ist offline nicht verfügbar", "fileInfo": "Dateiinfo", - "deleteFromServer": "Vom Server löschen", - "confirmDelete": "Dieses Medium und seine Dateien von deinem Server löschen?", + "deleteEpisodeFromServer": "", + "deleteSeasonFromServer": "", + "deleteShowFromServer": "", + "deleteMovieFromServer": "", + "deleteEpisodeTitle": "", + "deleteSeasonTitle": "", + "deleteShowTitle": "", + "deleteMovieTitle": "", + "deleteEpisodeConfirm": "", + "deleteSeasonConfirm": "", + "deleteShowConfirm": "", + "deleteMovieConfirm": "", + "deleteAnyway": "", + "confirmDeleteTarget": "", "deleteMultipleWarning": "Dies umfasst alle Episoden und deren Dateien.", + "deleteEpisodeCountWarning": { + "one": "", + "other": "" + }, + "deleteMultiPartWarning": { + "one": "", + "other": "" + }, + "deleteSharedFileHeading": { + "one": "", + "other": "" + }, + "deleteScopeUnverifiedProbeFailed": "", + "deleteScopeUnverifiedNoFileInfo": "", "mediaDeletedSuccessfully": "Medienelement gelöscht", "mediaFailedToDelete": "Medienelement konnte nicht gelöscht werden", "rate": "Bewerten", diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index 037cea9d..157c9a9f 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -518,9 +518,35 @@ "shufflePlay": "Shuffle Play", "shuffleNotAvailableOffline": "Shuffle not available offline", "fileInfo": "File Info", - "deleteFromServer": "Delete from server", - "confirmDelete": "Delete this media and its files from your server?", + "deleteEpisodeFromServer": "Delete episode from server", + "deleteSeasonFromServer": "Delete season from server", + "deleteShowFromServer": "Delete show from server", + "deleteMovieFromServer": "Delete movie from server", + "deleteEpisodeTitle": "Delete this episode?", + "deleteSeasonTitle": "Delete this season?", + "deleteShowTitle": "Delete this show?", + "deleteMovieTitle": "Delete this movie?", + "deleteEpisodeConfirm": "Delete episode", + "deleteSeasonConfirm": "Delete season", + "deleteShowConfirm": "Delete show", + "deleteMovieConfirm": "Delete movie", + "deleteAnyway": "Delete anyway", + "confirmDeleteTarget": "Permanently delete ${title} from your server?", "deleteMultipleWarning": "This includes all episodes and their files.", + "deleteEpisodeCountWarning": { + "one": "This deletes all ${n} episode in it, and its file.", + "other": "This deletes all ${n} episodes in it, and their files." + }, + "deleteMultiPartWarning": { + "one": "This item is stored as ${n} file, which will be deleted.", + "other": "This item is stored across ${n} files, and all of them will be deleted." + }, + "deleteSharedFileHeading": { + "one": "${n} other episode is stored in the same file and will be deleted too:", + "other": "${n} other episodes are stored in the same file and will be deleted too:" + }, + "deleteScopeUnverifiedProbeFailed": "Plezy could not check which files this will remove, so it may delete more than the item named above. Cancel and try again, or delete anyway.", + "deleteScopeUnverifiedNoFileInfo": "Your server did not provide file details for this item, so Plezy cannot check which files this will remove. It may delete more than the item named above.", "mediaDeletedSuccessfully": "Media item deleted successfully", "mediaFailedToDelete": "Failed to delete media item", "rate": "Rate", diff --git a/lib/i18n/es.i18n.json b/lib/i18n/es.i18n.json index 174d0cbe..49a7737b 100644 --- a/lib/i18n/es.i18n.json +++ b/lib/i18n/es.i18n.json @@ -518,9 +518,35 @@ "shufflePlay": "Reproducción aleatoria", "shuffleNotAvailableOffline": "La reproducción aleatoria no está disponible sin conexión", "fileInfo": "Información del archivo", - "deleteFromServer": "Eliminar del servidor", - "confirmDelete": "¿Eliminar este medio y sus archivos de tu servidor?", + "deleteEpisodeFromServer": "", + "deleteSeasonFromServer": "", + "deleteShowFromServer": "", + "deleteMovieFromServer": "", + "deleteEpisodeTitle": "", + "deleteSeasonTitle": "", + "deleteShowTitle": "", + "deleteMovieTitle": "", + "deleteEpisodeConfirm": "", + "deleteSeasonConfirm": "", + "deleteShowConfirm": "", + "deleteMovieConfirm": "", + "deleteAnyway": "", + "confirmDeleteTarget": "", "deleteMultipleWarning": "Esto incluye todos los episodios y sus archivos.", + "deleteEpisodeCountWarning": { + "one": "", + "other": "" + }, + "deleteMultiPartWarning": { + "one": "", + "other": "" + }, + "deleteSharedFileHeading": { + "one": "", + "other": "" + }, + "deleteScopeUnverifiedProbeFailed": "", + "deleteScopeUnverifiedNoFileInfo": "", "mediaDeletedSuccessfully": "Elemento multimedia eliminado con éxito", "mediaFailedToDelete": "Error al eliminar el elemento multimedia", "rate": "Calificar", diff --git a/lib/i18n/fr.i18n.json b/lib/i18n/fr.i18n.json index d3237534..3db1aa96 100644 --- a/lib/i18n/fr.i18n.json +++ b/lib/i18n/fr.i18n.json @@ -518,9 +518,35 @@ "shufflePlay": "Lecture aléatoire", "shuffleNotAvailableOffline": "La lecture aléatoire n’est pas disponible hors ligne", "fileInfo": "Informations sur le fichier", - "deleteFromServer": "Supprimer du serveur", - "confirmDelete": "Supprimer ce média et ses fichiers de votre serveur ?", + "deleteEpisodeFromServer": "", + "deleteSeasonFromServer": "", + "deleteShowFromServer": "", + "deleteMovieFromServer": "", + "deleteEpisodeTitle": "", + "deleteSeasonTitle": "", + "deleteShowTitle": "", + "deleteMovieTitle": "", + "deleteEpisodeConfirm": "", + "deleteSeasonConfirm": "", + "deleteShowConfirm": "", + "deleteMovieConfirm": "", + "deleteAnyway": "", + "confirmDeleteTarget": "", "deleteMultipleWarning": "Cela inclut tous les épisodes et leurs fichiers.", + "deleteEpisodeCountWarning": { + "one": "", + "other": "" + }, + "deleteMultiPartWarning": { + "one": "", + "other": "" + }, + "deleteSharedFileHeading": { + "one": "", + "other": "" + }, + "deleteScopeUnverifiedProbeFailed": "", + "deleteScopeUnverifiedNoFileInfo": "", "mediaDeletedSuccessfully": "Élément média supprimé avec succès", "mediaFailedToDelete": "Échec de la suppression de l'élément média", "rate": "Noter", diff --git a/lib/i18n/hu.i18n.json b/lib/i18n/hu.i18n.json index 79a44bbd..76cbe2c5 100644 --- a/lib/i18n/hu.i18n.json +++ b/lib/i18n/hu.i18n.json @@ -518,9 +518,35 @@ "shufflePlay": "Véletlenszerű lejátszás", "shuffleNotAvailableOffline": "A véletlenszerű lejátszás nem érhető el offline", "fileInfo": "Fájlinformáció", - "deleteFromServer": "Törlés a szerverről", - "confirmDelete": "Törlöd ezt a médiát és a fájljait a szerveredről?", + "deleteEpisodeFromServer": "", + "deleteSeasonFromServer": "", + "deleteShowFromServer": "", + "deleteMovieFromServer": "", + "deleteEpisodeTitle": "", + "deleteSeasonTitle": "", + "deleteShowTitle": "", + "deleteMovieTitle": "", + "deleteEpisodeConfirm": "", + "deleteSeasonConfirm": "", + "deleteShowConfirm": "", + "deleteMovieConfirm": "", + "deleteAnyway": "", + "confirmDeleteTarget": "", "deleteMultipleWarning": "Ez magában foglalja az összes epizódot és azok fájljait.", + "deleteEpisodeCountWarning": { + "one": "", + "other": "" + }, + "deleteMultiPartWarning": { + "one": "", + "other": "" + }, + "deleteSharedFileHeading": { + "one": "", + "other": "" + }, + "deleteScopeUnverifiedProbeFailed": "", + "deleteScopeUnverifiedNoFileInfo": "", "mediaDeletedSuccessfully": "Médiaelem sikeresen törölve", "mediaFailedToDelete": "Nem sikerült a médiaelem törlése", "rate": "Értékelés", diff --git a/lib/i18n/it.i18n.json b/lib/i18n/it.i18n.json index 3dd77e46..043eca61 100644 --- a/lib/i18n/it.i18n.json +++ b/lib/i18n/it.i18n.json @@ -518,9 +518,35 @@ "shufflePlay": "Riproduzione casuale", "shuffleNotAvailableOffline": "Riproduzione casuale non disponibile offline", "fileInfo": "Info sul file", - "deleteFromServer": "Elimina dal server", - "confirmDelete": "Eliminare questo media e i suoi file dal server?", + "deleteEpisodeFromServer": "", + "deleteSeasonFromServer": "", + "deleteShowFromServer": "", + "deleteMovieFromServer": "", + "deleteEpisodeTitle": "", + "deleteSeasonTitle": "", + "deleteShowTitle": "", + "deleteMovieTitle": "", + "deleteEpisodeConfirm": "", + "deleteSeasonConfirm": "", + "deleteShowConfirm": "", + "deleteMovieConfirm": "", + "deleteAnyway": "", + "confirmDeleteTarget": "", "deleteMultipleWarning": "Sono inclusi tutti gli episodi e i relativi file.", + "deleteEpisodeCountWarning": { + "one": "", + "other": "" + }, + "deleteMultiPartWarning": { + "one": "", + "other": "" + }, + "deleteSharedFileHeading": { + "one": "", + "other": "" + }, + "deleteScopeUnverifiedProbeFailed": "", + "deleteScopeUnverifiedNoFileInfo": "", "mediaDeletedSuccessfully": "Elemento multimediale eliminato correttamente", "mediaFailedToDelete": "Impossibile eliminare l'elemento multimediale", "rate": "Valuta", diff --git a/lib/i18n/ja.i18n.json b/lib/i18n/ja.i18n.json index 22b2046b..a8aee63d 100644 --- a/lib/i18n/ja.i18n.json +++ b/lib/i18n/ja.i18n.json @@ -518,9 +518,32 @@ "shufflePlay": "シャッフル再生", "shuffleNotAvailableOffline": "オフラインではシャッフルを利用できません", "fileInfo": "ファイル情報", - "deleteFromServer": "サーバーから削除", - "confirmDelete": "このメディアとそのファイルをサーバーから削除しますか?", + "deleteEpisodeFromServer": "", + "deleteSeasonFromServer": "", + "deleteShowFromServer": "", + "deleteMovieFromServer": "", + "deleteEpisodeTitle": "", + "deleteSeasonTitle": "", + "deleteShowTitle": "", + "deleteMovieTitle": "", + "deleteEpisodeConfirm": "", + "deleteSeasonConfirm": "", + "deleteShowConfirm": "", + "deleteMovieConfirm": "", + "deleteAnyway": "", + "confirmDeleteTarget": "", "deleteMultipleWarning": "すべてのエピソードとそのファイルが含まれます。", + "deleteEpisodeCountWarning": { + "other": "" + }, + "deleteMultiPartWarning": { + "other": "" + }, + "deleteSharedFileHeading": { + "other": "" + }, + "deleteScopeUnverifiedProbeFailed": "", + "deleteScopeUnverifiedNoFileInfo": "", "mediaDeletedSuccessfully": "メディアアイテムを正常に削除しました", "mediaFailedToDelete": "メディアアイテムの削除に失敗しました", "rate": "評価", diff --git a/lib/i18n/kk.i18n.json b/lib/i18n/kk.i18n.json index 0af77916..59bf2898 100644 --- a/lib/i18n/kk.i18n.json +++ b/lib/i18n/kk.i18n.json @@ -518,9 +518,35 @@ "shufflePlay": "Араластырып ойнату", "shuffleNotAvailableOffline": "Араластырып ойнату офлайн режимде қолжетімсіз", "fileInfo": "Файл туралы ақпарат", - "deleteFromServer": "Серверден өшіру", - "confirmDelete": "Осы медиа мен файлдар серверден өшірілсін бе?", + "deleteEpisodeFromServer": "", + "deleteSeasonFromServer": "", + "deleteShowFromServer": "", + "deleteMovieFromServer": "", + "deleteEpisodeTitle": "", + "deleteSeasonTitle": "", + "deleteShowTitle": "", + "deleteMovieTitle": "", + "deleteEpisodeConfirm": "", + "deleteSeasonConfirm": "", + "deleteShowConfirm": "", + "deleteMovieConfirm": "", + "deleteAnyway": "", + "confirmDeleteTarget": "", "deleteMultipleWarning": "Бұл барлық бөлімдер мен файлдарға әсер етеді.", + "deleteEpisodeCountWarning": { + "one": "", + "other": "" + }, + "deleteMultiPartWarning": { + "one": "", + "other": "" + }, + "deleteSharedFileHeading": { + "one": "", + "other": "" + }, + "deleteScopeUnverifiedProbeFailed": "", + "deleteScopeUnverifiedNoFileInfo": "", "mediaDeletedSuccessfully": "Медиа элементі сәтті өшірілді", "mediaFailedToDelete": "Медиа элементін өшіру мүмкін болмады", "rate": "Бағалау", diff --git a/lib/i18n/ko.i18n.json b/lib/i18n/ko.i18n.json index 152e5364..d6b2a13f 100644 --- a/lib/i18n/ko.i18n.json +++ b/lib/i18n/ko.i18n.json @@ -518,9 +518,32 @@ "shufflePlay": "무작위 재생", "shuffleNotAvailableOffline": "오프라인에서는 무작위 재생을 사용할 수 없습니다", "fileInfo": "파일 정보", - "deleteFromServer": "서버에서 삭제", - "confirmDelete": "이 미디어와 해당 파일을 서버에서 삭제할까요?", + "deleteEpisodeFromServer": "", + "deleteSeasonFromServer": "", + "deleteShowFromServer": "", + "deleteMovieFromServer": "", + "deleteEpisodeTitle": "", + "deleteSeasonTitle": "", + "deleteShowTitle": "", + "deleteMovieTitle": "", + "deleteEpisodeConfirm": "", + "deleteSeasonConfirm": "", + "deleteShowConfirm": "", + "deleteMovieConfirm": "", + "deleteAnyway": "", + "confirmDeleteTarget": "", "deleteMultipleWarning": "모든 에피소드와 파일이 포함됩니다.", + "deleteEpisodeCountWarning": { + "other": "" + }, + "deleteMultiPartWarning": { + "other": "" + }, + "deleteSharedFileHeading": { + "other": "" + }, + "deleteScopeUnverifiedProbeFailed": "", + "deleteScopeUnverifiedNoFileInfo": "", "mediaDeletedSuccessfully": "미디어 항목이 성공적으로 삭제되었습니다", "mediaFailedToDelete": "미디어 항목 삭제 실패", "rate": "평가", diff --git a/lib/i18n/nb.i18n.json b/lib/i18n/nb.i18n.json index 5d3c5a37..f57b1365 100644 --- a/lib/i18n/nb.i18n.json +++ b/lib/i18n/nb.i18n.json @@ -518,9 +518,35 @@ "shufflePlay": "Tilfeldig avspilling", "shuffleNotAvailableOffline": "Tilfeldig avspilling er ikke tilgjengelig uten nett", "fileInfo": "Filinformasjon", - "deleteFromServer": "Slett fra server", - "confirmDelete": "Slette dette mediet og filene fra serveren?", + "deleteEpisodeFromServer": "", + "deleteSeasonFromServer": "", + "deleteShowFromServer": "", + "deleteMovieFromServer": "", + "deleteEpisodeTitle": "", + "deleteSeasonTitle": "", + "deleteShowTitle": "", + "deleteMovieTitle": "", + "deleteEpisodeConfirm": "", + "deleteSeasonConfirm": "", + "deleteShowConfirm": "", + "deleteMovieConfirm": "", + "deleteAnyway": "", + "confirmDeleteTarget": "", "deleteMultipleWarning": "Dette inkluderer alle episoder og deres filer.", + "deleteEpisodeCountWarning": { + "one": "", + "other": "" + }, + "deleteMultiPartWarning": { + "one": "", + "other": "" + }, + "deleteSharedFileHeading": { + "one": "", + "other": "" + }, + "deleteScopeUnverifiedProbeFailed": "", + "deleteScopeUnverifiedNoFileInfo": "", "mediaDeletedSuccessfully": "Medieelement slettet", "mediaFailedToDelete": "Kunne ikke slette medieelement", "rate": "Vurder", diff --git a/lib/i18n/nl.i18n.json b/lib/i18n/nl.i18n.json index 75388869..eeb1f0fb 100644 --- a/lib/i18n/nl.i18n.json +++ b/lib/i18n/nl.i18n.json @@ -518,9 +518,35 @@ "shufflePlay": "Willekeurig afspelen", "shuffleNotAvailableOffline": "Willekeurig afspelen is offline niet beschikbaar", "fileInfo": "Bestandsinformatie", - "deleteFromServer": "Van server verwijderen", - "confirmDelete": "Deze media en bestanden van je server verwijderen?", + "deleteEpisodeFromServer": "", + "deleteSeasonFromServer": "", + "deleteShowFromServer": "", + "deleteMovieFromServer": "", + "deleteEpisodeTitle": "", + "deleteSeasonTitle": "", + "deleteShowTitle": "", + "deleteMovieTitle": "", + "deleteEpisodeConfirm": "", + "deleteSeasonConfirm": "", + "deleteShowConfirm": "", + "deleteMovieConfirm": "", + "deleteAnyway": "", + "confirmDeleteTarget": "", "deleteMultipleWarning": "Dit omvat alle afleveringen en hun bestanden.", + "deleteEpisodeCountWarning": { + "one": "", + "other": "" + }, + "deleteMultiPartWarning": { + "one": "", + "other": "" + }, + "deleteSharedFileHeading": { + "one": "", + "other": "" + }, + "deleteScopeUnverifiedProbeFailed": "", + "deleteScopeUnverifiedNoFileInfo": "", "mediaDeletedSuccessfully": "Media-item succesvol verwijderd", "mediaFailedToDelete": "Verwijderen van media-item mislukt", "rate": "Beoordelen", diff --git a/lib/i18n/pl.i18n.json b/lib/i18n/pl.i18n.json index 6f0e60c9..eb1e737c 100644 --- a/lib/i18n/pl.i18n.json +++ b/lib/i18n/pl.i18n.json @@ -518,9 +518,41 @@ "shufflePlay": "Odtwarzanie losowe", "shuffleNotAvailableOffline": "Odtwarzanie losowe nie jest dostępne offline", "fileInfo": "Informacje o pliku", - "deleteFromServer": "Usuń z serwera", - "confirmDelete": "Usunąć to medium i jego pliki z serwera?", + "deleteEpisodeFromServer": "", + "deleteSeasonFromServer": "", + "deleteShowFromServer": "", + "deleteMovieFromServer": "", + "deleteEpisodeTitle": "", + "deleteSeasonTitle": "", + "deleteShowTitle": "", + "deleteMovieTitle": "", + "deleteEpisodeConfirm": "", + "deleteSeasonConfirm": "", + "deleteShowConfirm": "", + "deleteMovieConfirm": "", + "deleteAnyway": "", + "confirmDeleteTarget": "", "deleteMultipleWarning": "Obejmuje to wszystkie odcinki i ich pliki.", + "deleteEpisodeCountWarning": { + "one": "", + "few": "", + "many": "", + "other": "" + }, + "deleteMultiPartWarning": { + "one": "", + "few": "", + "many": "", + "other": "" + }, + "deleteSharedFileHeading": { + "one": "", + "few": "", + "many": "", + "other": "" + }, + "deleteScopeUnverifiedProbeFailed": "", + "deleteScopeUnverifiedNoFileInfo": "", "mediaDeletedSuccessfully": "Usunięto element multimedialny", "mediaFailedToDelete": "Nie udało się usunąć elementu multimedialnego", "rate": "Oceń", diff --git a/lib/i18n/pt.i18n.json b/lib/i18n/pt.i18n.json index a4939aba..c2499d73 100644 --- a/lib/i18n/pt.i18n.json +++ b/lib/i18n/pt.i18n.json @@ -518,9 +518,35 @@ "shufflePlay": "Reprodução Aleatória", "shuffleNotAvailableOffline": "Reprodução aleatória indisponível offline", "fileInfo": "Informações do arquivo", - "deleteFromServer": "Excluir do servidor", - "confirmDelete": "Excluir esta mídia e seus arquivos do servidor?", + "deleteEpisodeFromServer": "", + "deleteSeasonFromServer": "", + "deleteShowFromServer": "", + "deleteMovieFromServer": "", + "deleteEpisodeTitle": "", + "deleteSeasonTitle": "", + "deleteShowTitle": "", + "deleteMovieTitle": "", + "deleteEpisodeConfirm": "", + "deleteSeasonConfirm": "", + "deleteShowConfirm": "", + "deleteMovieConfirm": "", + "deleteAnyway": "", + "confirmDeleteTarget": "", "deleteMultipleWarning": "Isso inclui todos os episódios e seus arquivos.", + "deleteEpisodeCountWarning": { + "one": "", + "other": "" + }, + "deleteMultiPartWarning": { + "one": "", + "other": "" + }, + "deleteSharedFileHeading": { + "one": "", + "other": "" + }, + "deleteScopeUnverifiedProbeFailed": "", + "deleteScopeUnverifiedNoFileInfo": "", "mediaDeletedSuccessfully": "Item de mídia excluído com sucesso", "mediaFailedToDelete": "Falha ao excluir item de mídia", "rate": "Avaliar", diff --git a/lib/i18n/ru.i18n.json b/lib/i18n/ru.i18n.json index f5a8642b..dd1a5304 100644 --- a/lib/i18n/ru.i18n.json +++ b/lib/i18n/ru.i18n.json @@ -518,9 +518,41 @@ "shufflePlay": "Случайное воспроизведение", "shuffleNotAvailableOffline": "Перемешивание недоступно офлайн", "fileInfo": "Информация о файле", - "deleteFromServer": "Удалить с сервера", - "confirmDelete": "Удалить это медиа и его файлы с сервера?", + "deleteEpisodeFromServer": "", + "deleteSeasonFromServer": "", + "deleteShowFromServer": "", + "deleteMovieFromServer": "", + "deleteEpisodeTitle": "", + "deleteSeasonTitle": "", + "deleteShowTitle": "", + "deleteMovieTitle": "", + "deleteEpisodeConfirm": "", + "deleteSeasonConfirm": "", + "deleteShowConfirm": "", + "deleteMovieConfirm": "", + "deleteAnyway": "", + "confirmDeleteTarget": "", "deleteMultipleWarning": "Это включает все эпизоды и их файлы.", + "deleteEpisodeCountWarning": { + "one": "", + "few": "", + "many": "", + "other": "" + }, + "deleteMultiPartWarning": { + "one": "", + "few": "", + "many": "", + "other": "" + }, + "deleteSharedFileHeading": { + "one": "", + "few": "", + "many": "", + "other": "" + }, + "deleteScopeUnverifiedProbeFailed": "", + "deleteScopeUnverifiedNoFileInfo": "", "mediaDeletedSuccessfully": "Медиаэлемент успешно удалён", "mediaFailedToDelete": "Не удалось удалить медиаэлемент", "rate": "Оценить", diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index 2060ef18..aa314d60 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -4,7 +4,7 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 22 -/// Strings: 32781 (1490 per locale) +/// Strings: 32759 (1489 per locale) // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/lib/i18n/strings_az.g.dart b/lib/i18n/strings_az.g.dart index d79d24de..927a0865 100644 --- a/lib/i18n/strings_az.g.dart +++ b/lib/i18n/strings_az.g.dart @@ -549,8 +549,6 @@ class _Translations$mediaMenu$az extends Translations$mediaMenu$en { @override String get shufflePlay => 'Qarışıq oynat'; @override String get shuffleNotAvailableOffline => 'Qarışıq oynatma oflayn rejimdə əlçatan deyil'; @override String get fileInfo => 'Fayl məlumatı'; - @override String get deleteFromServer => 'Serverdən sil'; - @override String get confirmDelete => 'Bu media və faylları serverinizdən silinsin?'; @override String get deleteMultipleWarning => 'Bu bütün seriyaları və faylları əhatə edir.'; @override String get mediaDeletedSuccessfully => 'Media elementi uğurla silindi'; @override String get mediaFailedToDelete => 'Media elementi silinə bilmədi'; @@ -2597,8 +2595,6 @@ extension on TranslationsAz { 'mediaMenu.shufflePlay' => 'Qarışıq oynat', 'mediaMenu.shuffleNotAvailableOffline' => 'Qarışıq oynatma oflayn rejimdə əlçatan deyil', 'mediaMenu.fileInfo' => 'Fayl məlumatı', - 'mediaMenu.deleteFromServer' => 'Serverdən sil', - 'mediaMenu.confirmDelete' => 'Bu media və faylları serverinizdən silinsin?', 'mediaMenu.deleteMultipleWarning' => 'Bu bütün seriyaları və faylları əhatə edir.', 'mediaMenu.mediaDeletedSuccessfully' => 'Media elementi uğurla silindi', 'mediaMenu.mediaFailedToDelete' => 'Media elementi silinə bilmədi', @@ -2714,10 +2710,10 @@ extension on TranslationsAz { 'videoControls.queue' => 'Növbə', 'videoControls.noQueueItems' => 'Növbədə element yoxdur', 'videoControls.searchSubtitles' => 'Altyazı axtar', - _ => null, - } ?? switch (path) { 'videoControls.language' => 'Dil', 'videoControls.noSubtitlesFound' => 'Altyazı tapılmadı', + _ => null, + } ?? switch (path) { 'videoControls.subtitleDownloaded' => 'Altyazı yükləndi', 'videoControls.subtitleDownloadedNotApplied' => 'Altyazı yükləndi, lakin seçilə bilmədi', 'videoControls.subtitleDownloadFailed' => 'Altyazı yüklənə bilmədi', @@ -3228,10 +3224,10 @@ extension on TranslationsAz { 'watchTogether.pasteFromClipboard' => 'Buferdən yapışdır', 'watchTogether.pleaseEnterCode' => 'Lütfən seans kodunu daxil edin', 'watchTogether.codeMustBe5Chars' => 'Seans kodu 5 simvol olmalıdır', - _ => null, - } ?? switch (path) { 'watchTogether.joinInstructions' => 'Qoşulmaq üçün təşkilatçının seans kodunu daxil edin.', 'watchTogether.failedToCreate' => 'Seans yaradıla bilmədi', + _ => null, + } ?? switch (path) { 'watchTogether.failedToJoin' => 'Seansa qoşuluna bilmədi', 'watchTogether.sessionCodeCopied' => 'Seans kodu buferə kopyalandı', 'watchTogether.relayUnreachable' => 'Rele serverinə çatmaq olmur. İnternet provayderinin bloklaması Birlikdə İzləməyə mane ola bilər.', diff --git a/lib/i18n/strings_bg.g.dart b/lib/i18n/strings_bg.g.dart index 3fc8e330..219c0303 100644 --- a/lib/i18n/strings_bg.g.dart +++ b/lib/i18n/strings_bg.g.dart @@ -546,8 +546,6 @@ class _Translations$mediaMenu$bg extends Translations$mediaMenu$en { @override String get shufflePlay => 'Разбъркано възпроизвеждане'; @override String get shuffleNotAvailableOffline => 'Разбърканото възпроизвеждане не е налично офлайн'; @override String get fileInfo => 'Информация за файла'; - @override String get deleteFromServer => 'Изтрий от сървъра'; - @override String get confirmDelete => 'Да се изтрият ли този елемент и файловете му от вашия сървър?'; @override String get deleteMultipleWarning => 'Това включва всички епизоди и техните файлове.'; @override String get mediaDeletedSuccessfully => 'Елементът е изтрит успешно'; @override String get mediaFailedToDelete => 'Неуспешно изтриване на елемента'; @@ -2583,8 +2581,6 @@ extension on TranslationsBg { 'mediaMenu.shufflePlay' => 'Разбъркано възпроизвеждане', 'mediaMenu.shuffleNotAvailableOffline' => 'Разбърканото възпроизвеждане не е налично офлайн', 'mediaMenu.fileInfo' => 'Информация за файла', - 'mediaMenu.deleteFromServer' => 'Изтрий от сървъра', - 'mediaMenu.confirmDelete' => 'Да се изтрият ли този елемент и файловете му от вашия сървър?', 'mediaMenu.deleteMultipleWarning' => 'Това включва всички епизоди и техните файлове.', 'mediaMenu.mediaDeletedSuccessfully' => 'Елементът е изтрит успешно', 'mediaMenu.mediaFailedToDelete' => 'Неуспешно изтриване на елемента', @@ -2703,10 +2699,10 @@ extension on TranslationsBg { 'videoControls.language' => 'Език', 'videoControls.noSubtitlesFound' => 'Не са намерени субтитри', 'videoControls.subtitleDownloaded' => 'Субтитърът е изтеглен', - _ => null, - } ?? switch (path) { 'videoControls.subtitleDownloadedNotApplied' => 'Субтитрите са изтеглени, но не можаха да бъдат избрани', 'videoControls.subtitleDownloadFailed' => 'Неуспешно изтегляне на субтитър', + _ => null, + } ?? switch (path) { 'videoControls.searchLanguages' => 'Търсене на езици...', 'messages.markedAsWatched' => 'Маркирано като гледано', 'messages.markedAsUnwatched' => 'Маркирано като негледано', @@ -3217,10 +3213,10 @@ extension on TranslationsBg { 'watchTogether.failedToJoin' => 'Неуспешно присъединяване към сесия', 'watchTogether.sessionCodeCopied' => 'Кодът на сесията е копиран в клипборда', 'watchTogether.relayUnreachable' => 'Релейният сървър е недостъпен. Възможно е интернет доставчикът да блокира гледането заедно.', - _ => null, - } ?? switch (path) { 'watchTogether.reconnectingToHost' => 'Повторно свързване с организатора...', 'watchTogether.currentPlayback' => 'Текущо възпроизвеждане', + _ => null, + } ?? switch (path) { 'watchTogether.joinCurrentPlayback' => 'Присъедини се към текущото възпроизвеждане', 'watchTogether.joinCurrentPlaybackDescription' => 'Върнете се към това, което организаторът гледа в момента', 'watchTogether.failedToOpenCurrentPlayback' => 'Неуспешно отваряне на текущото възпроизвеждане', diff --git a/lib/i18n/strings_da.g.dart b/lib/i18n/strings_da.g.dart index 602fd7ac..f34e4eaa 100644 --- a/lib/i18n/strings_da.g.dart +++ b/lib/i18n/strings_da.g.dart @@ -546,8 +546,6 @@ class _Translations$mediaMenu$da extends Translations$mediaMenu$en { @override String get shufflePlay => 'Afspil tilfældigt'; @override String get shuffleNotAvailableOffline => 'Tilfældig afspilning er ikke tilgængelig offline'; @override String get fileInfo => 'Filinfo'; - @override String get deleteFromServer => 'Slet fra server'; - @override String get confirmDelete => 'Slet dette medie og dets filer fra din server?'; @override String get deleteMultipleWarning => 'Dette inkluderer alle episoder og deres filer.'; @override String get mediaDeletedSuccessfully => 'Mediet blev slettet'; @override String get mediaFailedToDelete => 'Mediet kunne ikke slettes'; @@ -2583,8 +2581,6 @@ extension on TranslationsDa { 'mediaMenu.shufflePlay' => 'Afspil tilfældigt', 'mediaMenu.shuffleNotAvailableOffline' => 'Tilfældig afspilning er ikke tilgængelig offline', 'mediaMenu.fileInfo' => 'Filinfo', - 'mediaMenu.deleteFromServer' => 'Slet fra server', - 'mediaMenu.confirmDelete' => 'Slet dette medie og dets filer fra din server?', 'mediaMenu.deleteMultipleWarning' => 'Dette inkluderer alle episoder og deres filer.', 'mediaMenu.mediaDeletedSuccessfully' => 'Mediet blev slettet', 'mediaMenu.mediaFailedToDelete' => 'Mediet kunne ikke slettes', @@ -2703,10 +2699,10 @@ extension on TranslationsDa { 'videoControls.language' => 'Sprog', 'videoControls.noSubtitlesFound' => 'Ingen undertekster fundet', 'videoControls.subtitleDownloaded' => 'Undertekst downloadet', - _ => null, - } ?? switch (path) { 'videoControls.subtitleDownloadedNotApplied' => 'Underteksten blev downloadet, men kunne ikke vælges', 'videoControls.subtitleDownloadFailed' => 'Kunne ikke downloade undertekst', + _ => null, + } ?? switch (path) { 'videoControls.searchLanguages' => 'Søg sprog...', 'messages.markedAsWatched' => 'Markeret som set', 'messages.markedAsUnwatched' => 'Markeret som uset', @@ -3217,10 +3213,10 @@ extension on TranslationsDa { 'watchTogether.failedToJoin' => 'Kunne ikke deltage i session', 'watchTogether.sessionCodeCopied' => 'Sessionskode kopieret til udklipsholder', 'watchTogether.relayUnreachable' => 'Relayserveren kan ikke nås. Blokering hos internetudbyderen kan forhindre Se sammen.', - _ => null, - } ?? switch (path) { 'watchTogether.reconnectingToHost' => 'Genopretter forbindelse til vært...', 'watchTogether.currentPlayback' => 'Nuværende afspilning', + _ => null, + } ?? switch (path) { 'watchTogether.joinCurrentPlayback' => 'Deltag i nuværende afspilning', 'watchTogether.joinCurrentPlaybackDescription' => 'Hop tilbage til det værten ser nu', 'watchTogether.failedToOpenCurrentPlayback' => 'Kunne ikke åbne nuværende afspilning', diff --git a/lib/i18n/strings_de.g.dart b/lib/i18n/strings_de.g.dart index f48b9225..f0740b11 100644 --- a/lib/i18n/strings_de.g.dart +++ b/lib/i18n/strings_de.g.dart @@ -546,8 +546,6 @@ class _Translations$mediaMenu$de extends Translations$mediaMenu$en { @override String get shufflePlay => 'Zufallswiedergabe'; @override String get shuffleNotAvailableOffline => 'Zufallswiedergabe ist offline nicht verfügbar'; @override String get fileInfo => 'Dateiinfo'; - @override String get deleteFromServer => 'Vom Server löschen'; - @override String get confirmDelete => 'Dieses Medium und seine Dateien von deinem Server löschen?'; @override String get deleteMultipleWarning => 'Dies umfasst alle Episoden und deren Dateien.'; @override String get mediaDeletedSuccessfully => 'Medienelement gelöscht'; @override String get mediaFailedToDelete => 'Medienelement konnte nicht gelöscht werden'; @@ -2583,8 +2581,6 @@ extension on TranslationsDe { 'mediaMenu.shufflePlay' => 'Zufallswiedergabe', 'mediaMenu.shuffleNotAvailableOffline' => 'Zufallswiedergabe ist offline nicht verfügbar', 'mediaMenu.fileInfo' => 'Dateiinfo', - 'mediaMenu.deleteFromServer' => 'Vom Server löschen', - 'mediaMenu.confirmDelete' => 'Dieses Medium und seine Dateien von deinem Server löschen?', 'mediaMenu.deleteMultipleWarning' => 'Dies umfasst alle Episoden und deren Dateien.', 'mediaMenu.mediaDeletedSuccessfully' => 'Medienelement gelöscht', 'mediaMenu.mediaFailedToDelete' => 'Medienelement konnte nicht gelöscht werden', @@ -2703,10 +2699,10 @@ extension on TranslationsDe { 'videoControls.language' => 'Sprache', 'videoControls.noSubtitlesFound' => 'Keine Untertitel gefunden', 'videoControls.subtitleDownloaded' => 'Untertitel heruntergeladen', - _ => null, - } ?? switch (path) { 'videoControls.subtitleDownloadedNotApplied' => 'Der Untertitel wurde heruntergeladen, konnte aber nicht ausgewählt werden', 'videoControls.subtitleDownloadFailed' => 'Untertitel konnte nicht heruntergeladen werden', + _ => null, + } ?? switch (path) { 'videoControls.searchLanguages' => 'Sprachen suchen...', 'messages.markedAsWatched' => 'Als gesehen markiert', 'messages.markedAsUnwatched' => 'Als ungesehen markiert', @@ -3217,10 +3213,10 @@ extension on TranslationsDe { 'watchTogether.failedToJoin' => 'Beitritt zur Sitzung fehlgeschlagen', 'watchTogether.sessionCodeCopied' => 'Sitzungscode in Zwischenablage kopiert', 'watchTogether.relayUnreachable' => 'Relay-Server nicht erreichbar. Eine Sperre durch den Internetanbieter kann gemeinsames Schauen verhindern.', - _ => null, - } ?? switch (path) { 'watchTogether.reconnectingToHost' => 'Verbindung zum Host wird wiederhergestellt …', 'watchTogether.currentPlayback' => 'Aktuelle Wiedergabe', + _ => null, + } ?? switch (path) { 'watchTogether.joinCurrentPlayback' => 'Aktueller Wiedergabe beitreten', 'watchTogether.joinCurrentPlaybackDescription' => 'Zu dem Inhalt wechseln, den der Host gerade ansieht', 'watchTogether.failedToOpenCurrentPlayback' => 'Aktuelle Wiedergabe konnte nicht geöffnet werden', diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index cc44fcb0..646acdad 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -1565,15 +1565,75 @@ class Translations$mediaMenu$en { /// en: 'File Info' String get fileInfo => 'File Info'; - /// en: 'Delete from server' - String get deleteFromServer => 'Delete from server'; + /// en: 'Delete episode from server' + String get deleteEpisodeFromServer => 'Delete episode from server'; - /// en: 'Delete this media and its files from your server?' - String get confirmDelete => 'Delete this media and its files from your server?'; + /// en: 'Delete season from server' + String get deleteSeasonFromServer => 'Delete season from server'; + + /// en: 'Delete show from server' + String get deleteShowFromServer => 'Delete show from server'; + + /// en: 'Delete movie from server' + String get deleteMovieFromServer => 'Delete movie from server'; + + /// en: 'Delete this episode?' + String get deleteEpisodeTitle => 'Delete this episode?'; + + /// en: 'Delete this season?' + String get deleteSeasonTitle => 'Delete this season?'; + + /// en: 'Delete this show?' + String get deleteShowTitle => 'Delete this show?'; + + /// en: 'Delete this movie?' + String get deleteMovieTitle => 'Delete this movie?'; + + /// en: 'Delete episode' + String get deleteEpisodeConfirm => 'Delete episode'; + + /// en: 'Delete season' + String get deleteSeasonConfirm => 'Delete season'; + + /// en: 'Delete show' + String get deleteShowConfirm => 'Delete show'; + + /// en: 'Delete movie' + String get deleteMovieConfirm => 'Delete movie'; + + /// en: 'Delete anyway' + String get deleteAnyway => 'Delete anyway'; + + /// en: 'Permanently delete ${title} from your server?' + String confirmDeleteTarget({required Object title}) => 'Permanently delete ${title} from your server?'; /// en: 'This includes all episodes and their files.' String get deleteMultipleWarning => 'This includes all episodes and their files.'; + /// en: '(one) {This deletes all ${n} episode in it, and its file.} (other) {This deletes all ${n} episodes in it, and their files.}' + String deleteEpisodeCountWarning({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('en'))(n, + one: 'This deletes all ${n} episode in it, and its file.', + other: 'This deletes all ${n} episodes in it, and their files.', + ); + + /// en: '(one) {This item is stored as ${n} file, which will be deleted.} (other) {This item is stored across ${n} files, and all of them will be deleted.}' + String deleteMultiPartWarning({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('en'))(n, + one: 'This item is stored as ${n} file, which will be deleted.', + other: 'This item is stored across ${n} files, and all of them will be deleted.', + ); + + /// en: '(one) {${n} other episode is stored in the same file and will be deleted too:} (other) {${n} other episodes are stored in the same file and will be deleted too:}' + String deleteSharedFileHeading({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('en'))(n, + one: '${n} other episode is stored in the same file and will be deleted too:', + other: '${n} other episodes are stored in the same file and will be deleted too:', + ); + + /// en: 'Plezy could not check which files this will remove, so it may delete more than the item named above. Cancel and try again, or delete anyway.' + String get deleteScopeUnverifiedProbeFailed => 'Plezy could not check which files this will remove, so it may delete more than the item named above. Cancel and try again, or delete anyway.'; + + /// en: 'Your server did not provide file details for this item, so Plezy cannot check which files this will remove. It may delete more than the item named above.' + String get deleteScopeUnverifiedNoFileInfo => 'Your server did not provide file details for this item, so Plezy cannot check which files this will remove. It may delete more than the item named above.'; + /// en: 'Media item deleted successfully' String get mediaDeletedSuccessfully => 'Media item deleted successfully'; @@ -6539,9 +6599,28 @@ extension on Translations { 'mediaMenu.shufflePlay' => 'Shuffle Play', 'mediaMenu.shuffleNotAvailableOffline' => 'Shuffle not available offline', 'mediaMenu.fileInfo' => 'File Info', - 'mediaMenu.deleteFromServer' => 'Delete from server', - 'mediaMenu.confirmDelete' => 'Delete this media and its files from your server?', + 'mediaMenu.deleteEpisodeFromServer' => 'Delete episode from server', + 'mediaMenu.deleteSeasonFromServer' => 'Delete season from server', + 'mediaMenu.deleteShowFromServer' => 'Delete show from server', + 'mediaMenu.deleteMovieFromServer' => 'Delete movie from server', + 'mediaMenu.deleteEpisodeTitle' => 'Delete this episode?', + 'mediaMenu.deleteSeasonTitle' => 'Delete this season?', + 'mediaMenu.deleteShowTitle' => 'Delete this show?', + 'mediaMenu.deleteMovieTitle' => 'Delete this movie?', + 'mediaMenu.deleteEpisodeConfirm' => 'Delete episode', + 'mediaMenu.deleteSeasonConfirm' => 'Delete season', + 'mediaMenu.deleteShowConfirm' => 'Delete show', + 'mediaMenu.deleteMovieConfirm' => 'Delete movie', + 'mediaMenu.deleteAnyway' => 'Delete anyway', + 'mediaMenu.confirmDeleteTarget' => ({required Object title}) => 'Permanently delete ${title} from your server?', 'mediaMenu.deleteMultipleWarning' => 'This includes all episodes and their files.', + 'mediaMenu.deleteEpisodeCountWarning' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('en'))(n, one: 'This deletes all ${n} episode in it, and its file.', other: 'This deletes all ${n} episodes in it, and their files.', ), + _ => null, + } ?? switch (path) { + 'mediaMenu.deleteMultiPartWarning' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('en'))(n, one: 'This item is stored as ${n} file, which will be deleted.', other: 'This item is stored across ${n} files, and all of them will be deleted.', ), + 'mediaMenu.deleteSharedFileHeading' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('en'))(n, one: '${n} other episode is stored in the same file and will be deleted too:', other: '${n} other episodes are stored in the same file and will be deleted too:', ), + 'mediaMenu.deleteScopeUnverifiedProbeFailed' => 'Plezy could not check which files this will remove, so it may delete more than the item named above. Cancel and try again, or delete anyway.', + 'mediaMenu.deleteScopeUnverifiedNoFileInfo' => 'Your server did not provide file details for this item, so Plezy cannot check which files this will remove. It may delete more than the item named above.', 'mediaMenu.mediaDeletedSuccessfully' => 'Media item deleted successfully', 'mediaMenu.mediaFailedToDelete' => 'Failed to delete media item', 'mediaMenu.rate' => 'Rate', @@ -6555,8 +6634,6 @@ extension on Translations { 'rateSheet.notAvailable' => 'No match found', 'rateSheet.noConnectedServices' => 'Connect a service in Settings to rate there.', 'accessibility.mediaCardMovie' => ({required Object title}) => '${title}, movie', - _ => null, - } ?? switch (path) { 'accessibility.mediaCardShow' => ({required Object title}) => '${title}, TV show', 'accessibility.mediaCardEpisode' => ({required Object title, required Object episodeInfo}) => '${title}, ${episodeInfo}', 'accessibility.mediaCardSeason' => ({required Object title, required Object seasonInfo}) => '${title}, ${seasonInfo}', @@ -7052,6 +7129,8 @@ extension on Translations { 'explore.badge.nextEpisodeIn' => ({required Object episode, required Object duration}) => 'Ep ${episode} in ${duration}', 'explore.badge.nextAiringIn' => ({required Object duration}) => 'Next in ${duration}', 'explore.badge.episodesShort' => ({required Object n}) => '${n} eps', + _ => null, + } ?? switch (path) { 'explore.badge.minutesPerEpisode' => ({required Object n}) => '${n} min/ep', 'explore.badge.adult' => '18+', 'explore.stats.listed' => ({required Object n}) => '${n} listed', @@ -7069,8 +7148,6 @@ extension on Translations { 'explore.stats.completed' => ({required Object n}) => '${n} completed', 'explore.stats.onHold' => ({required Object n}) => '${n} on hold', 'explore.stats.dropped' => ({required Object n}) => '${n} dropped', - _ => null, - } ?? switch (path) { 'explore.season.winter' => 'Winter', 'explore.season.spring' => 'Spring', 'explore.season.summer' => 'Summer', @@ -7566,6 +7643,8 @@ extension on Translations { 'performanceOverlay.maxCll' => 'MaxCLL', 'performanceOverlay.maxFall' => 'MaxFALL', 'performanceOverlay.cacheUsed' => 'Cache Used', + _ => null, + } ?? switch (path) { 'performanceOverlay.cacheLimit' => 'Cache Limit', 'performanceOverlay.speed' => 'Speed', 'performanceOverlay.player' => 'Player', @@ -7583,8 +7662,6 @@ extension on Translations { 'externalPlayer.playerCommand' => 'Command', 'externalPlayer.playerPackage' => 'Package Name', 'externalPlayer.playerUrlScheme' => 'URL Scheme', - _ => null, - } ?? switch (path) { 'externalPlayer.off' => 'Off', 'externalPlayer.launchFailed' => 'Failed to open external player', 'externalPlayer.appNotInstalled' => ({required Object name}) => '${name} is not installed', diff --git a/lib/i18n/strings_es.g.dart b/lib/i18n/strings_es.g.dart index ac918556..33f4c8b6 100644 --- a/lib/i18n/strings_es.g.dart +++ b/lib/i18n/strings_es.g.dart @@ -546,8 +546,6 @@ class _Translations$mediaMenu$es extends Translations$mediaMenu$en { @override String get shufflePlay => 'Reproducción aleatoria'; @override String get shuffleNotAvailableOffline => 'La reproducción aleatoria no está disponible sin conexión'; @override String get fileInfo => 'Información del archivo'; - @override String get deleteFromServer => 'Eliminar del servidor'; - @override String get confirmDelete => '¿Eliminar este medio y sus archivos de tu servidor?'; @override String get deleteMultipleWarning => 'Esto incluye todos los episodios y sus archivos.'; @override String get mediaDeletedSuccessfully => 'Elemento multimedia eliminado con éxito'; @override String get mediaFailedToDelete => 'Error al eliminar el elemento multimedia'; @@ -2583,8 +2581,6 @@ extension on TranslationsEs { 'mediaMenu.shufflePlay' => 'Reproducción aleatoria', 'mediaMenu.shuffleNotAvailableOffline' => 'La reproducción aleatoria no está disponible sin conexión', 'mediaMenu.fileInfo' => 'Información del archivo', - 'mediaMenu.deleteFromServer' => 'Eliminar del servidor', - 'mediaMenu.confirmDelete' => '¿Eliminar este medio y sus archivos de tu servidor?', 'mediaMenu.deleteMultipleWarning' => 'Esto incluye todos los episodios y sus archivos.', 'mediaMenu.mediaDeletedSuccessfully' => 'Elemento multimedia eliminado con éxito', 'mediaMenu.mediaFailedToDelete' => 'Error al eliminar el elemento multimedia', @@ -2703,10 +2699,10 @@ extension on TranslationsEs { 'videoControls.language' => 'Idioma', 'videoControls.noSubtitlesFound' => 'No se encontraron subtítulos', 'videoControls.subtitleDownloaded' => 'Subtítulo descargado', - _ => null, - } ?? switch (path) { 'videoControls.subtitleDownloadedNotApplied' => 'El subtítulo se descargó, pero no se pudo seleccionar', 'videoControls.subtitleDownloadFailed' => 'Error al descargar subtítulo', + _ => null, + } ?? switch (path) { 'videoControls.searchLanguages' => 'Buscar idiomas...', 'messages.markedAsWatched' => 'Marcado como visto', 'messages.markedAsUnwatched' => 'Marcado como no visto', @@ -3217,10 +3213,10 @@ extension on TranslationsEs { 'watchTogether.failedToJoin' => 'Error al unirse a la sesión', 'watchTogether.sessionCodeCopied' => 'Código de sesión copiado al portapapeles', 'watchTogether.relayUnreachable' => 'No se puede acceder al servidor de retransmisión. Es posible que tu proveedor de internet esté bloqueando Ver juntos.', - _ => null, - } ?? switch (path) { 'watchTogether.reconnectingToHost' => 'Reconectando con el anfitrión...', 'watchTogether.currentPlayback' => 'Reproducción actual', + _ => null, + } ?? switch (path) { 'watchTogether.joinCurrentPlayback' => 'Unirse a la reproducción actual', 'watchTogether.joinCurrentPlaybackDescription' => 'Vuelve a lo que el anfitrión está viendo ahora mismo', 'watchTogether.failedToOpenCurrentPlayback' => 'No se pudo abrir la reproducción actual', diff --git a/lib/i18n/strings_fr.g.dart b/lib/i18n/strings_fr.g.dart index 33c08e09..afe8ae2e 100644 --- a/lib/i18n/strings_fr.g.dart +++ b/lib/i18n/strings_fr.g.dart @@ -546,8 +546,6 @@ class _Translations$mediaMenu$fr extends Translations$mediaMenu$en { @override String get shufflePlay => 'Lecture aléatoire'; @override String get shuffleNotAvailableOffline => 'La lecture aléatoire n’est pas disponible hors ligne'; @override String get fileInfo => 'Informations sur le fichier'; - @override String get deleteFromServer => 'Supprimer du serveur'; - @override String get confirmDelete => 'Supprimer ce média et ses fichiers de votre serveur ?'; @override String get deleteMultipleWarning => 'Cela inclut tous les épisodes et leurs fichiers.'; @override String get mediaDeletedSuccessfully => 'Élément média supprimé avec succès'; @override String get mediaFailedToDelete => 'Échec de la suppression de l\'élément média'; @@ -2583,8 +2581,6 @@ extension on TranslationsFr { 'mediaMenu.shufflePlay' => 'Lecture aléatoire', 'mediaMenu.shuffleNotAvailableOffline' => 'La lecture aléatoire n’est pas disponible hors ligne', 'mediaMenu.fileInfo' => 'Informations sur le fichier', - 'mediaMenu.deleteFromServer' => 'Supprimer du serveur', - 'mediaMenu.confirmDelete' => 'Supprimer ce média et ses fichiers de votre serveur ?', 'mediaMenu.deleteMultipleWarning' => 'Cela inclut tous les épisodes et leurs fichiers.', 'mediaMenu.mediaDeletedSuccessfully' => 'Élément média supprimé avec succès', 'mediaMenu.mediaFailedToDelete' => 'Échec de la suppression de l\'élément média', @@ -2703,10 +2699,10 @@ extension on TranslationsFr { 'videoControls.language' => 'Langue', 'videoControls.noSubtitlesFound' => 'Aucun sous-titre trouvé', 'videoControls.subtitleDownloaded' => 'Sous-titre téléchargé', - _ => null, - } ?? switch (path) { 'videoControls.subtitleDownloadedNotApplied' => 'Le sous-titre a été téléchargé, mais n’a pas pu être sélectionné', 'videoControls.subtitleDownloadFailed' => 'Échec du téléchargement du sous-titre', + _ => null, + } ?? switch (path) { 'videoControls.searchLanguages' => 'Rechercher des langues...', 'messages.markedAsWatched' => 'Marqué comme vu', 'messages.markedAsUnwatched' => 'Marqué comme non vu', @@ -3217,10 +3213,10 @@ extension on TranslationsFr { 'watchTogether.failedToJoin' => 'Échec de la connexion à la session', 'watchTogether.sessionCodeCopied' => 'Code de session copié dans le presse-papiers', 'watchTogether.relayUnreachable' => 'Serveur relais inaccessible. Un blocage par le fournisseur d’accès peut empêcher le fonctionnement de Regarder ensemble.', - _ => null, - } ?? switch (path) { 'watchTogether.reconnectingToHost' => 'Reconnexion à l\'hôte...', 'watchTogether.currentPlayback' => 'Lecture en cours', + _ => null, + } ?? switch (path) { 'watchTogether.joinCurrentPlayback' => 'Rejoindre la lecture en cours', 'watchTogether.joinCurrentPlaybackDescription' => 'Reprendre le contenu que l’hôte regarde actuellement', 'watchTogether.failedToOpenCurrentPlayback' => 'Impossible d\'ouvrir la lecture en cours', diff --git a/lib/i18n/strings_hu.g.dart b/lib/i18n/strings_hu.g.dart index 5169c7db..7939ae45 100644 --- a/lib/i18n/strings_hu.g.dart +++ b/lib/i18n/strings_hu.g.dart @@ -546,8 +546,6 @@ class _Translations$mediaMenu$hu extends Translations$mediaMenu$en { @override String get shufflePlay => 'Véletlenszerű lejátszás'; @override String get shuffleNotAvailableOffline => 'A véletlenszerű lejátszás nem érhető el offline'; @override String get fileInfo => 'Fájlinformáció'; - @override String get deleteFromServer => 'Törlés a szerverről'; - @override String get confirmDelete => 'Törlöd ezt a médiát és a fájljait a szerveredről?'; @override String get deleteMultipleWarning => 'Ez magában foglalja az összes epizódot és azok fájljait.'; @override String get mediaDeletedSuccessfully => 'Médiaelem sikeresen törölve'; @override String get mediaFailedToDelete => 'Nem sikerült a médiaelem törlése'; @@ -2583,8 +2581,6 @@ extension on TranslationsHu { 'mediaMenu.shufflePlay' => 'Véletlenszerű lejátszás', 'mediaMenu.shuffleNotAvailableOffline' => 'A véletlenszerű lejátszás nem érhető el offline', 'mediaMenu.fileInfo' => 'Fájlinformáció', - 'mediaMenu.deleteFromServer' => 'Törlés a szerverről', - 'mediaMenu.confirmDelete' => 'Törlöd ezt a médiát és a fájljait a szerveredről?', 'mediaMenu.deleteMultipleWarning' => 'Ez magában foglalja az összes epizódot és azok fájljait.', 'mediaMenu.mediaDeletedSuccessfully' => 'Médiaelem sikeresen törölve', 'mediaMenu.mediaFailedToDelete' => 'Nem sikerült a médiaelem törlése', @@ -2703,10 +2699,10 @@ extension on TranslationsHu { 'videoControls.language' => 'Nyelv', 'videoControls.noSubtitlesFound' => 'Nem találhatók feliratok', 'videoControls.subtitleDownloaded' => 'Felirat letöltve', - _ => null, - } ?? switch (path) { 'videoControls.subtitleDownloadedNotApplied' => 'Felirat letöltve, de nem sikerült kiválasztani', 'videoControls.subtitleDownloadFailed' => 'Nem sikerült a felirat letöltése', + _ => null, + } ?? switch (path) { 'videoControls.searchLanguages' => 'Nyelvek keresése...', 'messages.markedAsWatched' => 'Megjelölve megtekintettként', 'messages.markedAsUnwatched' => 'Megjelölve nem megtekintettként', @@ -3217,10 +3213,10 @@ extension on TranslationsHu { 'watchTogether.failedToJoin' => 'Nem sikerült csatlakozni a munkamenethez', 'watchTogether.sessionCodeCopied' => 'A munkamenetkód a vágólapra másolva', 'watchTogether.relayUnreachable' => 'A relészerver nem érhető el. Az internetszolgáltató blokkolása megakadályozhatja a közös nézést.', - _ => null, - } ?? switch (path) { 'watchTogether.reconnectingToHost' => 'Újracsatlakozás a házigazdához...', 'watchTogether.currentPlayback' => 'Jelenlegi lejátszás', + _ => null, + } ?? switch (path) { 'watchTogether.joinCurrentPlayback' => 'Csatlakozás a jelenlegi lejátszáshoz', 'watchTogether.joinCurrentPlaybackDescription' => 'Visszatérés ahhoz, amit a házigazda éppen néz', 'watchTogether.failedToOpenCurrentPlayback' => 'Nem sikerült megnyitni a jelenlegi lejátszást', diff --git a/lib/i18n/strings_it.g.dart b/lib/i18n/strings_it.g.dart index 8bc8af9a..69c39e12 100644 --- a/lib/i18n/strings_it.g.dart +++ b/lib/i18n/strings_it.g.dart @@ -546,8 +546,6 @@ class _Translations$mediaMenu$it extends Translations$mediaMenu$en { @override String get shufflePlay => 'Riproduzione casuale'; @override String get shuffleNotAvailableOffline => 'Riproduzione casuale non disponibile offline'; @override String get fileInfo => 'Info sul file'; - @override String get deleteFromServer => 'Elimina dal server'; - @override String get confirmDelete => 'Eliminare questo media e i suoi file dal server?'; @override String get deleteMultipleWarning => 'Sono inclusi tutti gli episodi e i relativi file.'; @override String get mediaDeletedSuccessfully => 'Elemento multimediale eliminato correttamente'; @override String get mediaFailedToDelete => 'Impossibile eliminare l\'elemento multimediale'; @@ -2583,8 +2581,6 @@ extension on TranslationsIt { 'mediaMenu.shufflePlay' => 'Riproduzione casuale', 'mediaMenu.shuffleNotAvailableOffline' => 'Riproduzione casuale non disponibile offline', 'mediaMenu.fileInfo' => 'Info sul file', - 'mediaMenu.deleteFromServer' => 'Elimina dal server', - 'mediaMenu.confirmDelete' => 'Eliminare questo media e i suoi file dal server?', 'mediaMenu.deleteMultipleWarning' => 'Sono inclusi tutti gli episodi e i relativi file.', 'mediaMenu.mediaDeletedSuccessfully' => 'Elemento multimediale eliminato correttamente', 'mediaMenu.mediaFailedToDelete' => 'Impossibile eliminare l\'elemento multimediale', @@ -2703,10 +2699,10 @@ extension on TranslationsIt { 'videoControls.language' => 'Lingua', 'videoControls.noSubtitlesFound' => 'Nessun sottotitolo trovato', 'videoControls.subtitleDownloaded' => 'Sottotitolo scaricato', - _ => null, - } ?? switch (path) { 'videoControls.subtitleDownloadedNotApplied' => 'Il sottotitolo è stato scaricato, ma non è stato possibile selezionarlo', 'videoControls.subtitleDownloadFailed' => 'Impossibile scaricare il sottotitolo', + _ => null, + } ?? switch (path) { 'videoControls.searchLanguages' => 'Cerca lingue...', 'messages.markedAsWatched' => 'Segnato come visto', 'messages.markedAsUnwatched' => 'Segnato come non visto', @@ -3217,10 +3213,10 @@ extension on TranslationsIt { 'watchTogether.failedToJoin' => 'Impossibile unirsi alla sessione', 'watchTogether.sessionCodeCopied' => 'Codice della sessione copiato negli appunti', 'watchTogether.relayUnreachable' => 'Il server relay non è raggiungibile. Eventuali blocchi dell\'ISP potrebbero impedire l\'uso di Guarda insieme.', - _ => null, - } ?? switch (path) { 'watchTogether.reconnectingToHost' => 'Riconnessione all\'host...', 'watchTogether.currentPlayback' => 'Riproduzione corrente', + _ => null, + } ?? switch (path) { 'watchTogether.joinCurrentPlayback' => 'Unisciti alla riproduzione corrente', 'watchTogether.joinCurrentPlaybackDescription' => 'Torna a ciò che l\'host sta guardando in questo momento', 'watchTogether.failedToOpenCurrentPlayback' => 'Impossibile aprire la riproduzione corrente', diff --git a/lib/i18n/strings_ja.g.dart b/lib/i18n/strings_ja.g.dart index 11e4d785..2bb8e8d5 100644 --- a/lib/i18n/strings_ja.g.dart +++ b/lib/i18n/strings_ja.g.dart @@ -546,8 +546,6 @@ class _Translations$mediaMenu$ja extends Translations$mediaMenu$en { @override String get shufflePlay => 'シャッフル再生'; @override String get shuffleNotAvailableOffline => 'オフラインではシャッフルを利用できません'; @override String get fileInfo => 'ファイル情報'; - @override String get deleteFromServer => 'サーバーから削除'; - @override String get confirmDelete => 'このメディアとそのファイルをサーバーから削除しますか?'; @override String get deleteMultipleWarning => 'すべてのエピソードとそのファイルが含まれます。'; @override String get mediaDeletedSuccessfully => 'メディアアイテムを正常に削除しました'; @override String get mediaFailedToDelete => 'メディアアイテムの削除に失敗しました'; @@ -2580,8 +2578,6 @@ extension on TranslationsJa { 'mediaMenu.shufflePlay' => 'シャッフル再生', 'mediaMenu.shuffleNotAvailableOffline' => 'オフラインではシャッフルを利用できません', 'mediaMenu.fileInfo' => 'ファイル情報', - 'mediaMenu.deleteFromServer' => 'サーバーから削除', - 'mediaMenu.confirmDelete' => 'このメディアとそのファイルをサーバーから削除しますか?', 'mediaMenu.deleteMultipleWarning' => 'すべてのエピソードとそのファイルが含まれます。', 'mediaMenu.mediaDeletedSuccessfully' => 'メディアアイテムを正常に削除しました', 'mediaMenu.mediaFailedToDelete' => 'メディアアイテムの削除に失敗しました', @@ -2700,10 +2696,10 @@ extension on TranslationsJa { 'videoControls.language' => '言語', 'videoControls.noSubtitlesFound' => '字幕が見つかりません', 'videoControls.subtitleDownloaded' => '字幕をダウンロードしました', - _ => null, - } ?? switch (path) { 'videoControls.subtitleDownloadedNotApplied' => '字幕はダウンロードされましたが、選択できませんでした', 'videoControls.subtitleDownloadFailed' => '字幕のダウンロードに失敗しました', + _ => null, + } ?? switch (path) { 'videoControls.searchLanguages' => '言語を検索…', 'messages.markedAsWatched' => '視聴済みにしました', 'messages.markedAsUnwatched' => '未視聴にしました', @@ -3214,10 +3210,10 @@ extension on TranslationsJa { 'watchTogether.failedToJoin' => 'セッションへの参加に失敗しました', 'watchTogether.sessionCodeCopied' => 'セッションコードをクリップボードにコピーしました', 'watchTogether.relayUnreachable' => 'リレーサーバーに接続できません。ISPによるブロックのため「一緒に見る」を利用できない可能性があります。', - _ => null, - } ?? switch (path) { 'watchTogether.reconnectingToHost' => 'ホストに再接続中…', 'watchTogether.currentPlayback' => '現在の再生', + _ => null, + } ?? switch (path) { 'watchTogether.joinCurrentPlayback' => '現在の再生に参加', 'watchTogether.joinCurrentPlaybackDescription' => 'ホストが現在視聴中のコンテンツに戻る', 'watchTogether.failedToOpenCurrentPlayback' => '現在の再生を開けませんでした', diff --git a/lib/i18n/strings_kk.g.dart b/lib/i18n/strings_kk.g.dart index 0dff39f7..3a188f9f 100644 --- a/lib/i18n/strings_kk.g.dart +++ b/lib/i18n/strings_kk.g.dart @@ -549,8 +549,6 @@ class _Translations$mediaMenu$kk extends Translations$mediaMenu$en { @override String get shufflePlay => 'Араластырып ойнату'; @override String get shuffleNotAvailableOffline => 'Араластырып ойнату офлайн режимде қолжетімсіз'; @override String get fileInfo => 'Файл туралы ақпарат'; - @override String get deleteFromServer => 'Серверден өшіру'; - @override String get confirmDelete => 'Осы медиа мен файлдар серверден өшірілсін бе?'; @override String get deleteMultipleWarning => 'Бұл барлық бөлімдер мен файлдарға әсер етеді.'; @override String get mediaDeletedSuccessfully => 'Медиа элементі сәтті өшірілді'; @override String get mediaFailedToDelete => 'Медиа элементін өшіру мүмкін болмады'; @@ -2597,8 +2595,6 @@ extension on TranslationsKk { 'mediaMenu.shufflePlay' => 'Араластырып ойнату', 'mediaMenu.shuffleNotAvailableOffline' => 'Араластырып ойнату офлайн режимде қолжетімсіз', 'mediaMenu.fileInfo' => 'Файл туралы ақпарат', - 'mediaMenu.deleteFromServer' => 'Серверден өшіру', - 'mediaMenu.confirmDelete' => 'Осы медиа мен файлдар серверден өшірілсін бе?', 'mediaMenu.deleteMultipleWarning' => 'Бұл барлық бөлімдер мен файлдарға әсер етеді.', 'mediaMenu.mediaDeletedSuccessfully' => 'Медиа элементі сәтті өшірілді', 'mediaMenu.mediaFailedToDelete' => 'Медиа элементін өшіру мүмкін болмады', @@ -2714,10 +2710,10 @@ extension on TranslationsKk { 'videoControls.queue' => 'Кезек', 'videoControls.noQueueItems' => 'Кезекте элементтер жоқ', 'videoControls.searchSubtitles' => 'Субтитр іздеу', - _ => null, - } ?? switch (path) { 'videoControls.language' => 'Тіл', 'videoControls.noSubtitlesFound' => 'Субтитр табылмады', + _ => null, + } ?? switch (path) { 'videoControls.subtitleDownloaded' => 'Субтитр жүктелді', 'videoControls.subtitleDownloadedNotApplied' => 'Субтитр жүктелді, бірақ қолданылмады', 'videoControls.subtitleDownloadFailed' => 'Субтитрді жүктеу мүмкін болмады', @@ -3228,10 +3224,10 @@ extension on TranslationsKk { 'watchTogether.pasteFromClipboard' => 'Алмасу буферінен қою', 'watchTogether.pleaseEnterCode' => 'Сеанс кодын енгізіңіз', 'watchTogether.codeMustBe5Chars' => 'Сеанс коды 5 таңбадан тұруы керек', - _ => null, - } ?? switch (path) { 'watchTogether.joinInstructions' => 'Ұйымдастырушының сеанс кодын енгізіңіз.', 'watchTogether.failedToCreate' => 'Сеансты жасау мүмкін болмады', + _ => null, + } ?? switch (path) { 'watchTogether.failedToJoin' => 'Сеансқа қосылу мүмкін болмады', 'watchTogether.sessionCodeCopied' => 'Сеанс коды көшірілді', 'watchTogether.relayUnreachable' => 'Реле сервері қолжетімсіз.', diff --git a/lib/i18n/strings_ko.g.dart b/lib/i18n/strings_ko.g.dart index 3a83e81e..89e2c6f2 100644 --- a/lib/i18n/strings_ko.g.dart +++ b/lib/i18n/strings_ko.g.dart @@ -546,8 +546,6 @@ class _Translations$mediaMenu$ko extends Translations$mediaMenu$en { @override String get shufflePlay => '무작위 재생'; @override String get shuffleNotAvailableOffline => '오프라인에서는 무작위 재생을 사용할 수 없습니다'; @override String get fileInfo => '파일 정보'; - @override String get deleteFromServer => '서버에서 삭제'; - @override String get confirmDelete => '이 미디어와 해당 파일을 서버에서 삭제할까요?'; @override String get deleteMultipleWarning => '모든 에피소드와 파일이 포함됩니다.'; @override String get mediaDeletedSuccessfully => '미디어 항목이 성공적으로 삭제되었습니다'; @override String get mediaFailedToDelete => '미디어 항목 삭제 실패'; @@ -2580,8 +2578,6 @@ extension on TranslationsKo { 'mediaMenu.shufflePlay' => '무작위 재생', 'mediaMenu.shuffleNotAvailableOffline' => '오프라인에서는 무작위 재생을 사용할 수 없습니다', 'mediaMenu.fileInfo' => '파일 정보', - 'mediaMenu.deleteFromServer' => '서버에서 삭제', - 'mediaMenu.confirmDelete' => '이 미디어와 해당 파일을 서버에서 삭제할까요?', 'mediaMenu.deleteMultipleWarning' => '모든 에피소드와 파일이 포함됩니다.', 'mediaMenu.mediaDeletedSuccessfully' => '미디어 항목이 성공적으로 삭제되었습니다', 'mediaMenu.mediaFailedToDelete' => '미디어 항목 삭제 실패', @@ -2700,10 +2696,10 @@ extension on TranslationsKo { 'videoControls.language' => '언어', 'videoControls.noSubtitlesFound' => '자막을 찾을 수 없습니다', 'videoControls.subtitleDownloaded' => '자막이 다운로드되었습니다', - _ => null, - } ?? switch (path) { 'videoControls.subtitleDownloadedNotApplied' => '자막을 다운로드했지만 선택할 수 없습니다', 'videoControls.subtitleDownloadFailed' => '자막 다운로드에 실패했습니다', + _ => null, + } ?? switch (path) { 'videoControls.searchLanguages' => '언어 검색...', 'messages.markedAsWatched' => '시청 완료로 표시됨', 'messages.markedAsUnwatched' => '미시청으로 표시됨', @@ -3214,10 +3210,10 @@ extension on TranslationsKo { 'watchTogether.failedToJoin' => '세션 참여 실패', 'watchTogether.sessionCodeCopied' => '세션 코드가 클립보드에 복사되었습니다', 'watchTogether.relayUnreachable' => '릴레이 서버에 연결할 수 없습니다. ISP 차단으로 함께 보기를 사용하지 못할 수 있습니다.', - _ => null, - } ?? switch (path) { 'watchTogether.reconnectingToHost' => '호스트에 재연결 중...', 'watchTogether.currentPlayback' => '현재 재생', + _ => null, + } ?? switch (path) { 'watchTogether.joinCurrentPlayback' => '현재 재생 참여', 'watchTogether.joinCurrentPlaybackDescription' => '호스트가 현재 시청 중인 콘텐츠로 이동합니다', 'watchTogether.failedToOpenCurrentPlayback' => '현재 재생을 열 수 없습니다', diff --git a/lib/i18n/strings_nb.g.dart b/lib/i18n/strings_nb.g.dart index 60213b49..51a09f90 100644 --- a/lib/i18n/strings_nb.g.dart +++ b/lib/i18n/strings_nb.g.dart @@ -546,8 +546,6 @@ class _Translations$mediaMenu$nb extends Translations$mediaMenu$en { @override String get shufflePlay => 'Tilfeldig avspilling'; @override String get shuffleNotAvailableOffline => 'Tilfeldig avspilling er ikke tilgjengelig uten nett'; @override String get fileInfo => 'Filinformasjon'; - @override String get deleteFromServer => 'Slett fra server'; - @override String get confirmDelete => 'Slette dette mediet og filene fra serveren?'; @override String get deleteMultipleWarning => 'Dette inkluderer alle episoder og deres filer.'; @override String get mediaDeletedSuccessfully => 'Medieelement slettet'; @override String get mediaFailedToDelete => 'Kunne ikke slette medieelement'; @@ -2583,8 +2581,6 @@ extension on TranslationsNb { 'mediaMenu.shufflePlay' => 'Tilfeldig avspilling', 'mediaMenu.shuffleNotAvailableOffline' => 'Tilfeldig avspilling er ikke tilgjengelig uten nett', 'mediaMenu.fileInfo' => 'Filinformasjon', - 'mediaMenu.deleteFromServer' => 'Slett fra server', - 'mediaMenu.confirmDelete' => 'Slette dette mediet og filene fra serveren?', 'mediaMenu.deleteMultipleWarning' => 'Dette inkluderer alle episoder og deres filer.', 'mediaMenu.mediaDeletedSuccessfully' => 'Medieelement slettet', 'mediaMenu.mediaFailedToDelete' => 'Kunne ikke slette medieelement', @@ -2703,10 +2699,10 @@ extension on TranslationsNb { 'videoControls.language' => 'Språk', 'videoControls.noSubtitlesFound' => 'Ingen undertekster funnet', 'videoControls.subtitleDownloaded' => 'Undertekst lastet ned', - _ => null, - } ?? switch (path) { 'videoControls.subtitleDownloadedNotApplied' => 'Underteksten ble lastet ned, men kunne ikke velges', 'videoControls.subtitleDownloadFailed' => 'Kunne ikke laste ned undertekst', + _ => null, + } ?? switch (path) { 'videoControls.searchLanguages' => 'Søk etter språk...', 'messages.markedAsWatched' => 'Merket som sett', 'messages.markedAsUnwatched' => 'Merket som usett', @@ -3217,10 +3213,10 @@ extension on TranslationsNb { 'watchTogether.failedToJoin' => 'Kunne ikke bli med i økt', 'watchTogether.sessionCodeCopied' => 'Øktkode kopiert til utklippstavle', 'watchTogether.relayUnreachable' => 'Reléserveren kan ikke nås. Blokkering hos internettleverandøren kan hindre Se sammen.', - _ => null, - } ?? switch (path) { 'watchTogether.reconnectingToHost' => 'Kobler til verten på nytt...', 'watchTogether.currentPlayback' => 'Gjeldende avspilling', + _ => null, + } ?? switch (path) { 'watchTogether.joinCurrentPlayback' => 'Bli med i gjeldende avspilling', 'watchTogether.joinCurrentPlaybackDescription' => 'Hopp tilbake til det verten ser på nå', 'watchTogether.failedToOpenCurrentPlayback' => 'Kunne ikke åpne gjeldende avspilling', diff --git a/lib/i18n/strings_nl.g.dart b/lib/i18n/strings_nl.g.dart index 8960b7fa..a9c4bde0 100644 --- a/lib/i18n/strings_nl.g.dart +++ b/lib/i18n/strings_nl.g.dart @@ -546,8 +546,6 @@ class _Translations$mediaMenu$nl extends Translations$mediaMenu$en { @override String get shufflePlay => 'Willekeurig afspelen'; @override String get shuffleNotAvailableOffline => 'Willekeurig afspelen is offline niet beschikbaar'; @override String get fileInfo => 'Bestandsinformatie'; - @override String get deleteFromServer => 'Van server verwijderen'; - @override String get confirmDelete => 'Deze media en bestanden van je server verwijderen?'; @override String get deleteMultipleWarning => 'Dit omvat alle afleveringen en hun bestanden.'; @override String get mediaDeletedSuccessfully => 'Media-item succesvol verwijderd'; @override String get mediaFailedToDelete => 'Verwijderen van media-item mislukt'; @@ -2583,8 +2581,6 @@ extension on TranslationsNl { 'mediaMenu.shufflePlay' => 'Willekeurig afspelen', 'mediaMenu.shuffleNotAvailableOffline' => 'Willekeurig afspelen is offline niet beschikbaar', 'mediaMenu.fileInfo' => 'Bestandsinformatie', - 'mediaMenu.deleteFromServer' => 'Van server verwijderen', - 'mediaMenu.confirmDelete' => 'Deze media en bestanden van je server verwijderen?', 'mediaMenu.deleteMultipleWarning' => 'Dit omvat alle afleveringen en hun bestanden.', 'mediaMenu.mediaDeletedSuccessfully' => 'Media-item succesvol verwijderd', 'mediaMenu.mediaFailedToDelete' => 'Verwijderen van media-item mislukt', @@ -2703,10 +2699,10 @@ extension on TranslationsNl { 'videoControls.language' => 'Taal', 'videoControls.noSubtitlesFound' => 'Geen ondertitels gevonden', 'videoControls.subtitleDownloaded' => 'Ondertitel gedownload', - _ => null, - } ?? switch (path) { 'videoControls.subtitleDownloadedNotApplied' => 'De ondertiteling is gedownload, maar kon niet worden geselecteerd', 'videoControls.subtitleDownloadFailed' => 'Ondertitel downloaden mislukt', + _ => null, + } ?? switch (path) { 'videoControls.searchLanguages' => 'Talen zoeken...', 'messages.markedAsWatched' => 'Gemarkeerd als gekeken', 'messages.markedAsUnwatched' => 'Gemarkeerd als ongekeken', @@ -3217,10 +3213,10 @@ extension on TranslationsNl { 'watchTogether.failedToJoin' => 'Deelnemen aan sessie mislukt', 'watchTogether.sessionCodeCopied' => 'Sessiecode naar het klembord gekopieerd', 'watchTogether.relayUnreachable' => 'De relayserver is onbereikbaar. Een blokkering door je internetprovider kan Samen kijken verhinderen.', - _ => null, - } ?? switch (path) { 'watchTogether.reconnectingToHost' => 'Opnieuw verbinden met host...', 'watchTogether.currentPlayback' => 'Wat nu wordt afgespeeld', + _ => null, + } ?? switch (path) { 'watchTogether.joinCurrentPlayback' => 'Deelnemen aan huidige weergave', 'watchTogether.joinCurrentPlaybackDescription' => 'Ga terug naar wat de host nu kijkt', 'watchTogether.failedToOpenCurrentPlayback' => 'Wat nu wordt afgespeeld kon niet worden geopend', diff --git a/lib/i18n/strings_pl.g.dart b/lib/i18n/strings_pl.g.dart index 2102060a..b63d17db 100644 --- a/lib/i18n/strings_pl.g.dart +++ b/lib/i18n/strings_pl.g.dart @@ -546,8 +546,6 @@ class _Translations$mediaMenu$pl extends Translations$mediaMenu$en { @override String get shufflePlay => 'Odtwarzanie losowe'; @override String get shuffleNotAvailableOffline => 'Odtwarzanie losowe nie jest dostępne offline'; @override String get fileInfo => 'Informacje o pliku'; - @override String get deleteFromServer => 'Usuń z serwera'; - @override String get confirmDelete => 'Usunąć to medium i jego pliki z serwera?'; @override String get deleteMultipleWarning => 'Obejmuje to wszystkie odcinki i ich pliki.'; @override String get mediaDeletedSuccessfully => 'Usunięto element multimedialny'; @override String get mediaFailedToDelete => 'Nie udało się usunąć elementu multimedialnego'; @@ -2589,8 +2587,6 @@ extension on TranslationsPl { 'mediaMenu.shufflePlay' => 'Odtwarzanie losowe', 'mediaMenu.shuffleNotAvailableOffline' => 'Odtwarzanie losowe nie jest dostępne offline', 'mediaMenu.fileInfo' => 'Informacje o pliku', - 'mediaMenu.deleteFromServer' => 'Usuń z serwera', - 'mediaMenu.confirmDelete' => 'Usunąć to medium i jego pliki z serwera?', 'mediaMenu.deleteMultipleWarning' => 'Obejmuje to wszystkie odcinki i ich pliki.', 'mediaMenu.mediaDeletedSuccessfully' => 'Usunięto element multimedialny', 'mediaMenu.mediaFailedToDelete' => 'Nie udało się usunąć elementu multimedialnego', @@ -2709,10 +2705,10 @@ extension on TranslationsPl { 'videoControls.language' => 'Język', 'videoControls.noSubtitlesFound' => 'Nie znaleziono napisów', 'videoControls.subtitleDownloaded' => 'Napisy pobrane', - _ => null, - } ?? switch (path) { 'videoControls.subtitleDownloadedNotApplied' => 'Napisy zostały pobrane, ale nie można ich było wybrać', 'videoControls.subtitleDownloadFailed' => 'Nie udało się pobrać napisów', + _ => null, + } ?? switch (path) { 'videoControls.searchLanguages' => 'Szukaj języków...', 'messages.markedAsWatched' => 'Oznaczono jako obejrzane', 'messages.markedAsUnwatched' => 'Oznaczono jako nieobejrzane', @@ -3223,10 +3219,10 @@ extension on TranslationsPl { 'watchTogether.failedToJoin' => 'Nie udało się dołączyć do sesji', 'watchTogether.sessionCodeCopied' => 'Kod sesji skopiowany do schowka', 'watchTogether.relayUnreachable' => 'Serwer pośredniczący jest nieosiągalny. Blokada operatora internetowego może uniemożliwiać korzystanie z funkcji „Oglądaj razem”.', - _ => null, - } ?? switch (path) { 'watchTogether.reconnectingToHost' => 'Ponowne łączenie z gospodarzem...', 'watchTogether.currentPlayback' => 'Bieżące odtwarzanie', + _ => null, + } ?? switch (path) { 'watchTogether.joinCurrentPlayback' => 'Dołącz do bieżącego odtwarzania', 'watchTogether.joinCurrentPlaybackDescription' => 'Wróć do treści oglądanej obecnie przez gospodarza', 'watchTogether.failedToOpenCurrentPlayback' => 'Nie udało się otworzyć bieżącego odtwarzania', diff --git a/lib/i18n/strings_pt.g.dart b/lib/i18n/strings_pt.g.dart index 9bcdcd42..455c7077 100644 --- a/lib/i18n/strings_pt.g.dart +++ b/lib/i18n/strings_pt.g.dart @@ -546,8 +546,6 @@ class _Translations$mediaMenu$pt extends Translations$mediaMenu$en { @override String get shufflePlay => 'Reprodução Aleatória'; @override String get shuffleNotAvailableOffline => 'Reprodução aleatória indisponível offline'; @override String get fileInfo => 'Informações do arquivo'; - @override String get deleteFromServer => 'Excluir do servidor'; - @override String get confirmDelete => 'Excluir esta mídia e seus arquivos do servidor?'; @override String get deleteMultipleWarning => 'Isso inclui todos os episódios e seus arquivos.'; @override String get mediaDeletedSuccessfully => 'Item de mídia excluído com sucesso'; @override String get mediaFailedToDelete => 'Falha ao excluir item de mídia'; @@ -2583,8 +2581,6 @@ extension on TranslationsPt { 'mediaMenu.shufflePlay' => 'Reprodução Aleatória', 'mediaMenu.shuffleNotAvailableOffline' => 'Reprodução aleatória indisponível offline', 'mediaMenu.fileInfo' => 'Informações do arquivo', - 'mediaMenu.deleteFromServer' => 'Excluir do servidor', - 'mediaMenu.confirmDelete' => 'Excluir esta mídia e seus arquivos do servidor?', 'mediaMenu.deleteMultipleWarning' => 'Isso inclui todos os episódios e seus arquivos.', 'mediaMenu.mediaDeletedSuccessfully' => 'Item de mídia excluído com sucesso', 'mediaMenu.mediaFailedToDelete' => 'Falha ao excluir item de mídia', @@ -2703,10 +2699,10 @@ extension on TranslationsPt { 'videoControls.language' => 'Idioma', 'videoControls.noSubtitlesFound' => 'Nenhuma legenda encontrada', 'videoControls.subtitleDownloaded' => 'Legenda baixada', - _ => null, - } ?? switch (path) { 'videoControls.subtitleDownloadedNotApplied' => 'A legenda foi baixada, mas não foi possível selecioná-la', 'videoControls.subtitleDownloadFailed' => 'Falha ao baixar legenda', + _ => null, + } ?? switch (path) { 'videoControls.searchLanguages' => 'Pesquisar idiomas...', 'messages.markedAsWatched' => 'Marcado como assistido', 'messages.markedAsUnwatched' => 'Marcado como não assistido', @@ -3217,10 +3213,10 @@ extension on TranslationsPt { 'watchTogether.failedToJoin' => 'Falha ao entrar na sessão', 'watchTogether.sessionCodeCopied' => 'Código da sessão copiado para a área de transferência', 'watchTogether.relayUnreachable' => 'Servidor de retransmissão inacessível. O bloqueio pelo provedor de internet pode impedir o uso do Assistir Juntos.', - _ => null, - } ?? switch (path) { 'watchTogether.reconnectingToHost' => 'Reconectando ao anfitrião...', 'watchTogether.currentPlayback' => 'Reprodução atual', + _ => null, + } ?? switch (path) { 'watchTogether.joinCurrentPlayback' => 'Entrar na reprodução atual', 'watchTogether.joinCurrentPlaybackDescription' => 'Voltar ao conteúdo que o anfitrião está assistindo agora', 'watchTogether.failedToOpenCurrentPlayback' => 'Falha ao abrir a reprodução atual', diff --git a/lib/i18n/strings_ru.g.dart b/lib/i18n/strings_ru.g.dart index 1ec083a7..01b6e8de 100644 --- a/lib/i18n/strings_ru.g.dart +++ b/lib/i18n/strings_ru.g.dart @@ -546,8 +546,6 @@ class _Translations$mediaMenu$ru extends Translations$mediaMenu$en { @override String get shufflePlay => 'Случайное воспроизведение'; @override String get shuffleNotAvailableOffline => 'Перемешивание недоступно офлайн'; @override String get fileInfo => 'Информация о файле'; - @override String get deleteFromServer => 'Удалить с сервера'; - @override String get confirmDelete => 'Удалить это медиа и его файлы с сервера?'; @override String get deleteMultipleWarning => 'Это включает все эпизоды и их файлы.'; @override String get mediaDeletedSuccessfully => 'Медиаэлемент успешно удалён'; @override String get mediaFailedToDelete => 'Не удалось удалить медиаэлемент'; @@ -2589,8 +2587,6 @@ extension on TranslationsRu { 'mediaMenu.shufflePlay' => 'Случайное воспроизведение', 'mediaMenu.shuffleNotAvailableOffline' => 'Перемешивание недоступно офлайн', 'mediaMenu.fileInfo' => 'Информация о файле', - 'mediaMenu.deleteFromServer' => 'Удалить с сервера', - 'mediaMenu.confirmDelete' => 'Удалить это медиа и его файлы с сервера?', 'mediaMenu.deleteMultipleWarning' => 'Это включает все эпизоды и их файлы.', 'mediaMenu.mediaDeletedSuccessfully' => 'Медиаэлемент успешно удалён', 'mediaMenu.mediaFailedToDelete' => 'Не удалось удалить медиаэлемент', @@ -2709,10 +2705,10 @@ extension on TranslationsRu { 'videoControls.language' => 'Язык', 'videoControls.noSubtitlesFound' => 'Субтитры не найдены', 'videoControls.subtitleDownloaded' => 'Субтитры загружены', - _ => null, - } ?? switch (path) { 'videoControls.subtitleDownloadedNotApplied' => 'Субтитры загружены, но их не удалось выбрать', 'videoControls.subtitleDownloadFailed' => 'Не удалось загрузить субтитры', + _ => null, + } ?? switch (path) { 'videoControls.searchLanguages' => 'Поиск языков...', 'messages.markedAsWatched' => 'Отмечено как просмотренное', 'messages.markedAsUnwatched' => 'Отмечено как непросмотренное', @@ -3223,10 +3219,10 @@ extension on TranslationsRu { 'watchTogether.failedToJoin' => 'Не удалось присоединиться к сессии', 'watchTogether.sessionCodeCopied' => 'Код сессии скопирован в буфер обмена', 'watchTogether.relayUnreachable' => 'Сервер ретрансляции недоступен. Блокировка интернет-провайдером может помешать совместному просмотру.', - _ => null, - } ?? switch (path) { 'watchTogether.reconnectingToHost' => 'Повторное подключение к организатору...', 'watchTogether.currentPlayback' => 'Текущее воспроизведение', + _ => null, + } ?? switch (path) { 'watchTogether.joinCurrentPlayback' => 'Присоединиться к текущему воспроизведению', 'watchTogether.joinCurrentPlaybackDescription' => 'Вернуться к материалу, который сейчас смотрит организатор', 'watchTogether.failedToOpenCurrentPlayback' => 'Не удалось открыть текущее воспроизведение', diff --git a/lib/i18n/strings_sv.g.dart b/lib/i18n/strings_sv.g.dart index f68060e5..f195cdc0 100644 --- a/lib/i18n/strings_sv.g.dart +++ b/lib/i18n/strings_sv.g.dart @@ -546,8 +546,6 @@ class _Translations$mediaMenu$sv extends Translations$mediaMenu$en { @override String get shufflePlay => 'Blanda uppspelning'; @override String get shuffleNotAvailableOffline => 'Blandad uppspelning är inte tillgänglig offline'; @override String get fileInfo => 'Filinformation'; - @override String get deleteFromServer => 'Ta bort från servern'; - @override String get confirmDelete => 'Ta bort det här medieobjektet och dess filer från servern?'; @override String get deleteMultipleWarning => 'Detta omfattar alla avsnitt och deras filer.'; @override String get mediaDeletedSuccessfully => 'Medieobjektet har tagits bort'; @override String get mediaFailedToDelete => 'Det gick inte att ta bort medieobjektet'; @@ -2583,8 +2581,6 @@ extension on TranslationsSv { 'mediaMenu.shufflePlay' => 'Blanda uppspelning', 'mediaMenu.shuffleNotAvailableOffline' => 'Blandad uppspelning är inte tillgänglig offline', 'mediaMenu.fileInfo' => 'Filinformation', - 'mediaMenu.deleteFromServer' => 'Ta bort från servern', - 'mediaMenu.confirmDelete' => 'Ta bort det här medieobjektet och dess filer från servern?', 'mediaMenu.deleteMultipleWarning' => 'Detta omfattar alla avsnitt och deras filer.', 'mediaMenu.mediaDeletedSuccessfully' => 'Medieobjektet har tagits bort', 'mediaMenu.mediaFailedToDelete' => 'Det gick inte att ta bort medieobjektet', @@ -2703,10 +2699,10 @@ extension on TranslationsSv { 'videoControls.language' => 'Språk', 'videoControls.noSubtitlesFound' => 'Inga undertexter hittades', 'videoControls.subtitleDownloaded' => 'Undertexten har laddats ned', - _ => null, - } ?? switch (path) { 'videoControls.subtitleDownloadedNotApplied' => 'Undertexten laddades ned men kunde inte väljas', 'videoControls.subtitleDownloadFailed' => 'Det gick inte att ladda ned undertexten', + _ => null, + } ?? switch (path) { 'videoControls.searchLanguages' => 'Sök språk...', 'messages.markedAsWatched' => 'Markerad som sedd', 'messages.markedAsUnwatched' => 'Markerad som osedd', @@ -3217,10 +3213,10 @@ extension on TranslationsSv { 'watchTogether.failedToJoin' => 'Det gick inte att gå med i sessionen', 'watchTogether.sessionCodeCopied' => 'Sessionskoden har kopierats till urklipp', 'watchTogether.relayUnreachable' => 'Reläservern kan inte nås. Din internetleverantör kan blockera Titta tillsammans.', - _ => null, - } ?? switch (path) { 'watchTogether.reconnectingToHost' => 'Återansluter till värden...', 'watchTogether.currentPlayback' => 'Aktuell uppspelning', + _ => null, + } ?? switch (path) { 'watchTogether.joinCurrentPlayback' => 'Gå med i aktuell uppspelning', 'watchTogether.joinCurrentPlaybackDescription' => 'Hoppa tillbaka till det värden tittar på just nu', 'watchTogether.failedToOpenCurrentPlayback' => 'Kunde inte öppna aktuell uppspelning', diff --git a/lib/i18n/strings_tr.g.dart b/lib/i18n/strings_tr.g.dart index 3ed56be2..dcca3d41 100644 --- a/lib/i18n/strings_tr.g.dart +++ b/lib/i18n/strings_tr.g.dart @@ -549,8 +549,6 @@ class _Translations$mediaMenu$tr extends Translations$mediaMenu$en { @override String get shufflePlay => 'Karışık Oynat'; @override String get shuffleNotAvailableOffline => 'Karışık oynatma çevrimdışıyken kullanılamaz'; @override String get fileInfo => 'Dosya Bilgisi'; - @override String get deleteFromServer => 'Sunucudan sil'; - @override String get confirmDelete => 'Bu medya ve dosyaları sunucunuzdan silinsin mi?'; @override String get deleteMultipleWarning => 'Bu işlem tüm bölümleri ve dosyalarını içerir.'; @override String get mediaDeletedSuccessfully => 'Medya ögesi başarıyla silindi'; @override String get mediaFailedToDelete => 'Medya ögesi silinemedi'; @@ -2597,8 +2595,6 @@ extension on TranslationsTr { 'mediaMenu.shufflePlay' => 'Karışık Oynat', 'mediaMenu.shuffleNotAvailableOffline' => 'Karışık oynatma çevrimdışıyken kullanılamaz', 'mediaMenu.fileInfo' => 'Dosya Bilgisi', - 'mediaMenu.deleteFromServer' => 'Sunucudan sil', - 'mediaMenu.confirmDelete' => 'Bu medya ve dosyaları sunucunuzdan silinsin mi?', 'mediaMenu.deleteMultipleWarning' => 'Bu işlem tüm bölümleri ve dosyalarını içerir.', 'mediaMenu.mediaDeletedSuccessfully' => 'Medya ögesi başarıyla silindi', 'mediaMenu.mediaFailedToDelete' => 'Medya ögesi silinemedi', @@ -2714,10 +2710,10 @@ extension on TranslationsTr { 'videoControls.queue' => 'Kuyruk', 'videoControls.noQueueItems' => 'Kuyrukta öge yok', 'videoControls.searchSubtitles' => 'Altyazı Ara', - _ => null, - } ?? switch (path) { 'videoControls.language' => 'Dil', 'videoControls.noSubtitlesFound' => 'Altyazı bulunamadı', + _ => null, + } ?? switch (path) { 'videoControls.subtitleDownloaded' => 'Altyazı indirildi', 'videoControls.subtitleDownloadedNotApplied' => 'Altyazı indirildi ancak seçilemedi', 'videoControls.subtitleDownloadFailed' => 'Altyazı indirilemedi', @@ -3228,10 +3224,10 @@ extension on TranslationsTr { 'watchTogether.pasteFromClipboard' => 'Panodan yapıştır', 'watchTogether.pleaseEnterCode' => 'Lütfen bir oturum kodu girin', 'watchTogether.codeMustBe5Chars' => 'Oturum kodu 5 karakter olmalıdır', - _ => null, - } ?? switch (path) { 'watchTogether.joinInstructions' => 'Katılmak için kurucunun oturum kodunu girin.', 'watchTogether.failedToCreate' => 'Oturum oluşturulamadı', + _ => null, + } ?? switch (path) { 'watchTogether.failedToJoin' => 'Oturuma katılınamadı', 'watchTogether.sessionCodeCopied' => 'Oturum kodu panoya kopyalandı', 'watchTogether.relayUnreachable' => 'Aktarıcı sunucusuna ulaşılamıyor. İSS engellemesi Birlikte İzle\'yi önleyebilir.', diff --git a/lib/i18n/strings_uz.g.dart b/lib/i18n/strings_uz.g.dart index f656eabf..c6c80941 100644 --- a/lib/i18n/strings_uz.g.dart +++ b/lib/i18n/strings_uz.g.dart @@ -549,8 +549,6 @@ class _Translations$mediaMenu$uz extends Translations$mediaMenu$en { @override String get shufflePlay => 'Aralashtirib ijro etish'; @override String get shuffleNotAvailableOffline => 'Aralashtirib ijro etish oflayn rejimda mavjud emas'; @override String get fileInfo => 'Fayl haqida maʼlumot'; - @override String get deleteFromServer => 'Serverdan oʻchirish'; - @override String get confirmDelete => 'Ushbu media va fayllar serverdan oʻchirilsinmi?'; @override String get deleteMultipleWarning => 'Bu barcha qismlar va fayllarga taʼsir qiladi.'; @override String get mediaDeletedSuccessfully => 'Media elementi muvaffaqiyatli oʻchirildi'; @override String get mediaFailedToDelete => 'Media elementini oʻchirib boʻlmadi'; @@ -2597,8 +2595,6 @@ extension on TranslationsUz { 'mediaMenu.shufflePlay' => 'Aralashtirib ijro etish', 'mediaMenu.shuffleNotAvailableOffline' => 'Aralashtirib ijro etish oflayn rejimda mavjud emas', 'mediaMenu.fileInfo' => 'Fayl haqida maʼlumot', - 'mediaMenu.deleteFromServer' => 'Serverdan oʻchirish', - 'mediaMenu.confirmDelete' => 'Ushbu media va fayllar serverdan oʻchirilsinmi?', 'mediaMenu.deleteMultipleWarning' => 'Bu barcha qismlar va fayllarga taʼsir qiladi.', 'mediaMenu.mediaDeletedSuccessfully' => 'Media elementi muvaffaqiyatli oʻchirildi', 'mediaMenu.mediaFailedToDelete' => 'Media elementini oʻchirib boʻlmadi', @@ -2714,10 +2710,10 @@ extension on TranslationsUz { 'videoControls.queue' => 'Navbat', 'videoControls.noQueueItems' => 'Navbatda elementlar yoʻq', 'videoControls.searchSubtitles' => 'Subtitr qidirish', - _ => null, - } ?? switch (path) { 'videoControls.language' => 'Til', 'videoControls.noSubtitlesFound' => 'Subtitr topilmadi', + _ => null, + } ?? switch (path) { 'videoControls.subtitleDownloaded' => 'Subtitr yuklab olindi', 'videoControls.subtitleDownloadedNotApplied' => 'Subtitr yuklab olindi, lekin qoʻllanilmadi', 'videoControls.subtitleDownloadFailed' => 'Subtitrni yuklab boʻlmadi', @@ -3228,10 +3224,10 @@ extension on TranslationsUz { 'watchTogether.pasteFromClipboard' => 'Xotiradan joylash', 'watchTogether.pleaseEnterCode' => 'Seans kodini kiriting', 'watchTogether.codeMustBe5Chars' => 'Seans kodi 5 ta belgidan iborat boʻlishi kerak', - _ => null, - } ?? switch (path) { 'watchTogether.joinInstructions' => 'Tashkilotchining seans kodini kiriting.', 'watchTogether.failedToCreate' => 'Seansni yaratib boʻlmadi', + _ => null, + } ?? switch (path) { 'watchTogether.failedToJoin' => 'Seansga qoʻshilib boʻlmadi', 'watchTogether.sessionCodeCopied' => 'Seans kodi nusxalandi', 'watchTogether.relayUnreachable' => 'Rele serveriga ulanib boʻlmadi.', diff --git a/lib/i18n/strings_zh.g.dart b/lib/i18n/strings_zh.g.dart index db244eb8..284179e6 100644 --- a/lib/i18n/strings_zh.g.dart +++ b/lib/i18n/strings_zh.g.dart @@ -546,8 +546,6 @@ class Translations$mediaMenu$zh extends Translations$mediaMenu$en { @override String get shufflePlay => '随机播放'; @override String get shuffleNotAvailableOffline => '离线时无法随机播放'; @override String get fileInfo => '文件信息'; - @override String get deleteFromServer => '从服务器删除'; - @override String get confirmDelete => '要从服务器删除此媒体及其文件吗?'; @override String get deleteMultipleWarning => '这包括所有剧集及其文件。'; @override String get mediaDeletedSuccessfully => '媒体项已成功删除'; @override String get mediaFailedToDelete => '删除媒体项失败'; @@ -2580,8 +2578,6 @@ extension on TranslationsZh { 'mediaMenu.shufflePlay' => '随机播放', 'mediaMenu.shuffleNotAvailableOffline' => '离线时无法随机播放', 'mediaMenu.fileInfo' => '文件信息', - 'mediaMenu.deleteFromServer' => '从服务器删除', - 'mediaMenu.confirmDelete' => '要从服务器删除此媒体及其文件吗?', 'mediaMenu.deleteMultipleWarning' => '这包括所有剧集及其文件。', 'mediaMenu.mediaDeletedSuccessfully' => '媒体项已成功删除', 'mediaMenu.mediaFailedToDelete' => '删除媒体项失败', @@ -2700,10 +2696,10 @@ extension on TranslationsZh { 'videoControls.language' => '语言', 'videoControls.noSubtitlesFound' => '未找到字幕', 'videoControls.subtitleDownloaded' => '字幕已下载', - _ => null, - } ?? switch (path) { 'videoControls.subtitleDownloadedNotApplied' => '字幕已下载,但无法选择', 'videoControls.subtitleDownloadFailed' => '字幕下载失败', + _ => null, + } ?? switch (path) { 'videoControls.searchLanguages' => '搜索语言…', 'messages.markedAsWatched' => '已标记为已观看', 'messages.markedAsUnwatched' => '已标记为未观看', @@ -3214,10 +3210,10 @@ extension on TranslationsZh { 'watchTogether.failedToJoin' => '加入会话失败', 'watchTogether.sessionCodeCopied' => '会话代码已复制到剪贴板', 'watchTogether.relayUnreachable' => '无法访问中继服务器。网络运营商的屏蔽可能导致“一起看”不可用。', - _ => null, - } ?? switch (path) { 'watchTogether.reconnectingToHost' => '正在重新连接到主持人…', 'watchTogether.currentPlayback' => '当前播放', + _ => null, + } ?? switch (path) { 'watchTogether.joinCurrentPlayback' => '加入当前播放', 'watchTogether.joinCurrentPlaybackDescription' => '加入主持人当前正在观看的内容', 'watchTogether.failedToOpenCurrentPlayback' => '无法打开当前播放', diff --git a/lib/i18n/strings_zh_Hant.g.dart b/lib/i18n/strings_zh_Hant.g.dart index 5a1f4875..ee73d105 100644 --- a/lib/i18n/strings_zh_Hant.g.dart +++ b/lib/i18n/strings_zh_Hant.g.dart @@ -547,8 +547,6 @@ class _Translations$mediaMenu$zh_Hant extends Translations$mediaMenu$zh { @override String get shufflePlay => '隨機播放'; @override String get shuffleNotAvailableOffline => '離線時無法隨機播放'; @override String get fileInfo => '檔案資訊'; - @override String get deleteFromServer => '從伺服器刪除'; - @override String get confirmDelete => '確定要從伺服器刪除此媒體及其檔案嗎?'; @override String get deleteMultipleWarning => '這將會刪除所有單集及其檔案。'; @override String get mediaDeletedSuccessfully => '媒體已成功刪除'; @override String get mediaFailedToDelete => '刪除媒體失敗'; @@ -2581,8 +2579,6 @@ extension on TranslationsZhHant { 'mediaMenu.shufflePlay' => '隨機播放', 'mediaMenu.shuffleNotAvailableOffline' => '離線時無法隨機播放', 'mediaMenu.fileInfo' => '檔案資訊', - 'mediaMenu.deleteFromServer' => '從伺服器刪除', - 'mediaMenu.confirmDelete' => '確定要從伺服器刪除此媒體及其檔案嗎?', 'mediaMenu.deleteMultipleWarning' => '這將會刪除所有單集及其檔案。', 'mediaMenu.mediaDeletedSuccessfully' => '媒體已成功刪除', 'mediaMenu.mediaFailedToDelete' => '刪除媒體失敗', @@ -2701,10 +2697,10 @@ extension on TranslationsZhHant { 'videoControls.language' => '語言', 'videoControls.noSubtitlesFound' => '找不到字幕', 'videoControls.subtitleDownloaded' => '字幕下載成功', - _ => null, - } ?? switch (path) { 'videoControls.subtitleDownloadedNotApplied' => '字幕已下載,但無法套用', 'videoControls.subtitleDownloadFailed' => '字幕下載失敗', + _ => null, + } ?? switch (path) { 'videoControls.searchLanguages' => '搜尋語言…', 'messages.markedAsWatched' => '已標記為已觀看', 'messages.markedAsUnwatched' => '已標記為未觀看', @@ -3215,10 +3211,10 @@ extension on TranslationsZhHant { 'watchTogether.failedToJoin' => '加入工作階段失敗', 'watchTogether.sessionCodeCopied' => '工作階段代碼已複製到剪貼簿', 'watchTogether.relayUnreachable' => '無法連線至中繼伺服器。ISP 封鎖可能會導致「一起看」無法使用。', - _ => null, - } ?? switch (path) { 'watchTogether.reconnectingToHost' => '正在重新連線至主持人…', 'watchTogether.currentPlayback' => '目前播放內容', + _ => null, + } ?? switch (path) { 'watchTogether.joinCurrentPlayback' => '加入目前播放點', 'watchTogether.joinCurrentPlaybackDescription' => '同步至主持人目前的觀看進度', 'watchTogether.failedToOpenCurrentPlayback' => '無法開啟目前播放點', diff --git a/lib/i18n/sv.i18n.json b/lib/i18n/sv.i18n.json index bc3a1ae4..7b157fb4 100644 --- a/lib/i18n/sv.i18n.json +++ b/lib/i18n/sv.i18n.json @@ -518,9 +518,35 @@ "shufflePlay": "Blanda uppspelning", "shuffleNotAvailableOffline": "Blandad uppspelning är inte tillgänglig offline", "fileInfo": "Filinformation", - "deleteFromServer": "Ta bort från servern", - "confirmDelete": "Ta bort det här medieobjektet och dess filer från servern?", + "deleteEpisodeFromServer": "", + "deleteSeasonFromServer": "", + "deleteShowFromServer": "", + "deleteMovieFromServer": "", + "deleteEpisodeTitle": "", + "deleteSeasonTitle": "", + "deleteShowTitle": "", + "deleteMovieTitle": "", + "deleteEpisodeConfirm": "", + "deleteSeasonConfirm": "", + "deleteShowConfirm": "", + "deleteMovieConfirm": "", + "deleteAnyway": "", + "confirmDeleteTarget": "", "deleteMultipleWarning": "Detta omfattar alla avsnitt och deras filer.", + "deleteEpisodeCountWarning": { + "one": "", + "other": "" + }, + "deleteMultiPartWarning": { + "one": "", + "other": "" + }, + "deleteSharedFileHeading": { + "one": "", + "other": "" + }, + "deleteScopeUnverifiedProbeFailed": "", + "deleteScopeUnverifiedNoFileInfo": "", "mediaDeletedSuccessfully": "Medieobjektet har tagits bort", "mediaFailedToDelete": "Det gick inte att ta bort medieobjektet", "rate": "Betygsätt", diff --git a/lib/i18n/tr.i18n.json b/lib/i18n/tr.i18n.json index 9b8f9657..f15375b1 100644 --- a/lib/i18n/tr.i18n.json +++ b/lib/i18n/tr.i18n.json @@ -518,9 +518,35 @@ "shufflePlay": "Karışık Oynat", "shuffleNotAvailableOffline": "Karışık oynatma çevrimdışıyken kullanılamaz", "fileInfo": "Dosya Bilgisi", - "deleteFromServer": "Sunucudan sil", - "confirmDelete": "Bu medya ve dosyaları sunucunuzdan silinsin mi?", + "deleteEpisodeFromServer": "", + "deleteSeasonFromServer": "", + "deleteShowFromServer": "", + "deleteMovieFromServer": "", + "deleteEpisodeTitle": "", + "deleteSeasonTitle": "", + "deleteShowTitle": "", + "deleteMovieTitle": "", + "deleteEpisodeConfirm": "", + "deleteSeasonConfirm": "", + "deleteShowConfirm": "", + "deleteMovieConfirm": "", + "deleteAnyway": "", + "confirmDeleteTarget": "", "deleteMultipleWarning": "Bu işlem tüm bölümleri ve dosyalarını içerir.", + "deleteEpisodeCountWarning": { + "one": "", + "other": "" + }, + "deleteMultiPartWarning": { + "one": "", + "other": "" + }, + "deleteSharedFileHeading": { + "one": "", + "other": "" + }, + "deleteScopeUnverifiedProbeFailed": "", + "deleteScopeUnverifiedNoFileInfo": "", "mediaDeletedSuccessfully": "Medya ögesi başarıyla silindi", "mediaFailedToDelete": "Medya ögesi silinemedi", "rate": "Oyla", diff --git a/lib/i18n/uz.i18n.json b/lib/i18n/uz.i18n.json index 97ee9966..50862114 100644 --- a/lib/i18n/uz.i18n.json +++ b/lib/i18n/uz.i18n.json @@ -518,9 +518,35 @@ "shufflePlay": "Aralashtirib ijro etish", "shuffleNotAvailableOffline": "Aralashtirib ijro etish oflayn rejimda mavjud emas", "fileInfo": "Fayl haqida maʼlumot", - "deleteFromServer": "Serverdan oʻchirish", - "confirmDelete": "Ushbu media va fayllar serverdan oʻchirilsinmi?", + "deleteEpisodeFromServer": "", + "deleteSeasonFromServer": "", + "deleteShowFromServer": "", + "deleteMovieFromServer": "", + "deleteEpisodeTitle": "", + "deleteSeasonTitle": "", + "deleteShowTitle": "", + "deleteMovieTitle": "", + "deleteEpisodeConfirm": "", + "deleteSeasonConfirm": "", + "deleteShowConfirm": "", + "deleteMovieConfirm": "", + "deleteAnyway": "", + "confirmDeleteTarget": "", "deleteMultipleWarning": "Bu barcha qismlar va fayllarga taʼsir qiladi.", + "deleteEpisodeCountWarning": { + "one": "", + "other": "" + }, + "deleteMultiPartWarning": { + "one": "", + "other": "" + }, + "deleteSharedFileHeading": { + "one": "", + "other": "" + }, + "deleteScopeUnverifiedProbeFailed": "", + "deleteScopeUnverifiedNoFileInfo": "", "mediaDeletedSuccessfully": "Media elementi muvaffaqiyatli oʻchirildi", "mediaFailedToDelete": "Media elementini oʻchirib boʻlmadi", "rate": "Baho berish", diff --git a/lib/i18n/zh-Hant.i18n.json b/lib/i18n/zh-Hant.i18n.json index 5cd52d6a..1dc30090 100644 --- a/lib/i18n/zh-Hant.i18n.json +++ b/lib/i18n/zh-Hant.i18n.json @@ -518,9 +518,32 @@ "shufflePlay": "隨機播放", "shuffleNotAvailableOffline": "離線時無法隨機播放", "fileInfo": "檔案資訊", - "deleteFromServer": "從伺服器刪除", - "confirmDelete": "確定要從伺服器刪除此媒體及其檔案嗎?", + "deleteEpisodeFromServer": "", + "deleteSeasonFromServer": "", + "deleteShowFromServer": "", + "deleteMovieFromServer": "", + "deleteEpisodeTitle": "", + "deleteSeasonTitle": "", + "deleteShowTitle": "", + "deleteMovieTitle": "", + "deleteEpisodeConfirm": "", + "deleteSeasonConfirm": "", + "deleteShowConfirm": "", + "deleteMovieConfirm": "", + "deleteAnyway": "", + "confirmDeleteTarget": "", "deleteMultipleWarning": "這將會刪除所有單集及其檔案。", + "deleteEpisodeCountWarning": { + "other": "" + }, + "deleteMultiPartWarning": { + "other": "" + }, + "deleteSharedFileHeading": { + "other": "" + }, + "deleteScopeUnverifiedProbeFailed": "", + "deleteScopeUnverifiedNoFileInfo": "", "mediaDeletedSuccessfully": "媒體已成功刪除", "mediaFailedToDelete": "刪除媒體失敗", "rate": "評分", diff --git a/lib/i18n/zh.i18n.json b/lib/i18n/zh.i18n.json index 1e9ec05b..828a21e1 100644 --- a/lib/i18n/zh.i18n.json +++ b/lib/i18n/zh.i18n.json @@ -518,9 +518,32 @@ "shufflePlay": "随机播放", "shuffleNotAvailableOffline": "离线时无法随机播放", "fileInfo": "文件信息", - "deleteFromServer": "从服务器删除", - "confirmDelete": "要从服务器删除此媒体及其文件吗?", + "deleteEpisodeFromServer": "", + "deleteSeasonFromServer": "", + "deleteShowFromServer": "", + "deleteMovieFromServer": "", + "deleteEpisodeTitle": "", + "deleteSeasonTitle": "", + "deleteShowTitle": "", + "deleteMovieTitle": "", + "deleteEpisodeConfirm": "", + "deleteSeasonConfirm": "", + "deleteShowConfirm": "", + "deleteMovieConfirm": "", + "deleteAnyway": "", + "confirmDeleteTarget": "", "deleteMultipleWarning": "这包括所有剧集及其文件。", + "deleteEpisodeCountWarning": { + "other": "" + }, + "deleteMultiPartWarning": { + "other": "" + }, + "deleteSharedFileHeading": { + "other": "" + }, + "deleteScopeUnverifiedProbeFailed": "", + "deleteScopeUnverifiedNoFileInfo": "", "mediaDeletedSuccessfully": "媒体项已成功删除", "mediaFailedToDelete": "删除媒体项失败", "rate": "评分", diff --git a/lib/media/media_item_labels.dart b/lib/media/media_item_labels.dart index 989d357a..a40d8eaf 100644 --- a/lib/media/media_item_labels.dart +++ b/lib/media/media_item_labels.dart @@ -1,4 +1,6 @@ +import '../utils/formatters.dart'; import 'media_item.dart'; +import 'media_kind.dart'; String formatQueueItemSubtitle(MediaItem item) { final grandparentTitle = item.grandparentTitle; @@ -12,3 +14,33 @@ String formatQueueItemSubtitle(MediaItem item) { } return item.kind.name; } + +/// Unambiguous label for the exact item a destructive action will affect. +/// +/// [MediaItem.displayTitle] deliberately collapses an episode or season to its +/// show name so cards can headline the series. That makes it actively +/// dangerous in a delete confirmation: a show and one of its episodes render +/// the identical string, which is how #1781 was reported as "deleting one +/// episode deleted them all". This names the target instead. +String formatMediaTargetLabel(MediaItem item) { + final segments = []; + void add(String? value) { + if (value != null && value.isNotEmpty) segments.add(value); + } + + switch (item.kind) { + case MediaKind.episode: + add(item.grandparentTitle); + add(formatSeasonEpisodeLabel(item.parentIndex, item.index)); + add(item.title); + case MediaKind.season: + add(item.grandparentTitle ?? item.parentTitle); + add(item.title); + default: + add(item.title); + } + + // Every segment can be absent on a sparse row; the collapsed show name still + // beats an empty confirmation. + return segments.isEmpty ? item.displayTitle : toBulletedString(segments); +} diff --git a/lib/utils/delete_impact.dart b/lib/utils/delete_impact.dart new file mode 100644 index 00000000..93bca3c2 --- /dev/null +++ b/lib/utils/delete_impact.dart @@ -0,0 +1,200 @@ +import 'dart:async'; + +import '../media/media_item.dart'; +import '../media/media_kind.dart'; +import '../media/media_server_client.dart'; +import '../media/media_version.dart'; +import 'app_logger.dart'; +import 'media_server_timeouts.dart'; + +/// How much a server-side delete will actually destroy. +enum DeleteImpactScope { + /// Verified: the delete removes exactly one file and no other library item + /// is backed by it. + exclusive, + + /// Verified: the delete removes more than one file, or files that also back + /// other items (a Plex `S01E01-E03.mkv` multi-episode file). + broad, + + /// Not established. The confirmation must not claim a scope. + unverified, +} + +/// Why a scope could not be established, which decides whether retrying helps. +enum DeleteImpactUnverifiedReason { + /// A metadata request failed or timed out. Transient; retrying may work. + probeFailed, + + /// The server answered but withheld file paths — Plex hides them from + /// restricted users. Retrying will never help. + noFileInfo, +} + +/// The verified blast radius of deleting one item. +class DeleteImpact { + final DeleteImpactScope scope; + + /// Unique server-side paths this delete removes. Empty when [scope] is + /// [DeleteImpactScope.unverified]. + final Set files; + + /// Other library items backed by one of [files], which the server will + /// destroy alongside the target. + final List sharedWith; + + final DeleteImpactUnverifiedReason? reason; + + const DeleteImpact._({required this.scope, required this.files, required this.sharedWith, this.reason}); + + const DeleteImpact._unverified(DeleteImpactUnverifiedReason reason) + : this._(scope: DeleteImpactScope.unverified, files: const {}, sharedWith: const [], reason: reason); + + bool get isUnverified => scope == DeleteImpactScope.unverified; + bool get isBroad => scope == DeleteImpactScope.broad; +} + +/// Every distinct file backing [versions], or null when the list cannot be +/// trusted to be complete. +/// +/// Deliberately NOT [MediaItem.allPartFiles]: that getter drops parts whose +/// `file` is null, so a three-part version exposing one path yields a +/// one-entry set that silently understates the delete. Anything a +/// confirmation asserts must survive that case, so a single path-less part +/// invalidates the whole answer. `null` versions, an empty version list, and a +/// version whose parts were synthesized by `PlexMappers.mediaVersion` (which +/// fabricates one file-less part when the payload carried none) all fail here, +/// which is the intent. +Set? completePartFiles(List versions) { + // No early return for an empty list: the `sawPart` gate below already + // rejects it, and every other "nothing to trust" shape with it. + final files = {}; + var sawPart = false; + for (final version in versions) { + for (final part in version.parts) { + sawPart = true; + final file = part.file; + if (file == null || file.isEmpty) return null; + files.add(file); + } + } + return sawPart ? files : null; +} + +/// Cooperative stop signal. `Future.timeout` completes the future the caller +/// awaits but leaves the work behind it running, so the probe has to be told +/// to stand down explicitly. +class _ProbeCancellation { + bool isCancelled = false; +} + +/// Establish what deleting [item] will destroy, before asking the user. +/// +/// Returns [DeleteImpactScope.unverified] rather than guessing whenever any +/// input is missing. Callers must surface that as an explicit danger, never as +/// a normal confirmation: a silent fall-through would let a dialog assert +/// single-file scope on unknown data, which is the failure mode behind #1781. +/// +/// Bounded by [timeout] because it runs behind a modal spinner. Expiry also +/// cancels the probe, so a season of thin rows cannot keep walking itself +/// after the confirmation has already given up on it. The request already in +/// flight cannot be recalled — the neutral client exposes no abort handle for +/// item lookups — so exactly one may outlive the deadline, and no further one +/// starts. +Future resolveDeleteImpact({ + required MediaItem item, + required MediaServerClient client, + Duration timeout = MediaServerTimeouts.deleteImpactProbe, +}) async { + final cancellation = _ProbeCancellation(); + try { + return await _resolve(item, client, cancellation).timeout( + timeout, + onTimeout: () { + cancellation.isCancelled = true; + appLogger.w('Delete impact probe timed out for ${item.backend.id} item ${item.id}'); + return const DeleteImpact._unverified(DeleteImpactUnverifiedReason.probeFailed); + }, + ); + } catch (e, st) { + cancellation.isCancelled = true; + appLogger.w('Delete impact probe failed for ${item.backend.id} item ${item.id}', error: e, stackTrace: st); + return const DeleteImpact._unverified(DeleteImpactUnverifiedReason.probeFailed); + } +} + +/// The value every abandoned path returns. Nothing reads it — the caller's +/// future has already completed — it only has to stop the walk. +const _abandoned = DeleteImpact._unverified(DeleteImpactUnverifiedReason.probeFailed); + +Future _resolve(MediaItem item, MediaServerClient client, _ProbeCancellation cancellation) async { + final own = await _completeFilesFor(item, client); + // Cancelled while the item's own lookup was outstanding: stop before asking + // the server for the season's children. + if (cancellation.isCancelled) return _abandoned; + final ownFiles = own.$1; + if (ownFiles == null) return DeleteImpact._unverified(own.$2!); + + // Only an episode can be one of several items sharing a file. A movie's + // blast radius is its own part list. + final parentId = item.kind == MediaKind.episode ? item.parentId : null; + if (parentId == null) return _verified(ownFiles, const []); + + final siblings = (await client.fetchChildren(parentId)).where((sibling) => sibling.id != item.id).toList(); + + // Resolved one sibling at a time, bailing on the first whose paths stay + // unknown. Both backends normally carry paths on browse rows (Plex + // `/children` includes `Part.file`; Jellyfin episode rows request + // `MediaSources`), so a detail refetch here is the degraded path — but on a + // thin season it is the rule, and `Future.wait` would then fan out one + // request per episode. Rows that already carry paths cost no request at all. + final shared = []; + for (final sibling in siblings) { + // Re-checked every iteration, because the deadline may have expired while + // the previous sibling's lookup was outstanding. This is the gate that + // keeps an abandoned probe from walking the rest of the season; a check + // after the await would only skip discarded bookkeeping. + if (cancellation.isCancelled) return _abandoned; + final (files, reason) = await _completeFilesFor(sibling, client); + // A sibling whose paths stayed unknown could be backed by this very file. + // Absence of evidence is not evidence of absence. + if (files == null) return DeleteImpact._unverified(reason!); + if (files.intersection(ownFiles).isNotEmpty) shared.add(sibling); + } + + return _verified(ownFiles, shared); +} + +/// Every file backing [item], or the reason the list could not be trusted. +/// +/// Deliberately not `resolveMediaVersions`: that helper returns inline +/// versions without ever consulting the detail endpoint, so a thin browse row +/// — including the single file-less part `PlexMappers.mediaVersion` +/// fabricates when a payload carried none — would be mistaken for a server +/// that withholds paths. It also flattens fetch failures into an empty list, +/// erasing the transient-vs-permanent distinction the confirmation copy needs. +Future<(Set?, DeleteImpactUnverifiedReason?)> _completeFilesFor( + MediaItem item, + MediaServerClient client, +) async { + final inline = completePartFiles(item.mediaVersions ?? const []); + if (inline != null) return (inline, null); + + final MediaItem? full; + try { + full = await client.fetchItem(item.id); + } catch (e, st) { + appLogger.w('Delete impact: failed to resolve files for item ${item.id}', error: e, stackTrace: st); + return (null, DeleteImpactUnverifiedReason.probeFailed); + } + if (full == null) return (null, DeleteImpactUnverifiedReason.probeFailed); + + final resolved = completePartFiles(full.mediaVersions ?? const []); + return resolved != null ? (resolved, null) : (null, DeleteImpactUnverifiedReason.noFileInfo); +} + +DeleteImpact _verified(Set files, List shared) => DeleteImpact._( + scope: files.length > 1 || shared.isNotEmpty ? DeleteImpactScope.broad : DeleteImpactScope.exclusive, + files: files, + sharedWith: shared, +); diff --git a/lib/utils/dialogs.dart b/lib/utils/dialogs.dart index 3f6bff5c..c1505e04 100644 --- a/lib/utils/dialogs.dart +++ b/lib/utils/dialogs.dart @@ -37,6 +37,7 @@ Future showConfirmDialog( required String confirmText, String? cancelText, bool isDestructive = false, + String? warning, }) async { final confirmed = await showScopedDialog( context: context, @@ -44,7 +45,27 @@ Future showConfirmDialog( final colorScheme = Theme.of(dialogContext).colorScheme; return AlertDialog( title: Text(title), - content: Text(message), + content: warning == null + ? Text(message) + : SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(message), + const SizedBox(height: 16), + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.errorContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Text(warning, style: TextStyle(color: colorScheme.onErrorContainer)), + ), + ], + ), + ), actions: [ DialogActionButton( autofocus: true, @@ -75,11 +96,16 @@ Future showConfirmDialog( /// Shows a non-dismissible loading-spinner dialog. Caller is responsible for /// closing it via `Navigator.pop(context)` when the work completes. +/// +/// `barrierDismissible: false` only blocks the barrier, so the spinner also +/// traps system back. Without that, back would dismiss the spinner and the +/// caller's cleanup pop would land on the route underneath — closing the +/// screen the user was working in. void showLoadingDialog(BuildContext context) { showScopedDialog( context: context, barrierDismissible: false, - builder: (_) => const Center(child: CircularProgressIndicator()), + builder: (_) => const PopScope(canPop: false, child: Center(child: CircularProgressIndicator())), ); } @@ -133,6 +159,7 @@ Future showDeleteConfirmation( required String title, required String message, String? confirmText, + String? warning, }) { return showConfirmDialog( context, @@ -140,6 +167,7 @@ Future showDeleteConfirmation( message: message, confirmText: confirmText ?? t.common.delete, isDestructive: true, + warning: warning, ); } diff --git a/lib/utils/media_server_timeouts.dart b/lib/utils/media_server_timeouts.dart index d8782647..06bba152 100644 --- a/lib/utils/media_server_timeouts.dart +++ b/lib/utils/media_server_timeouts.dart @@ -72,6 +72,20 @@ class MediaServerTimeouts { /// only ever costs an entry, never safety. static const jellyfinDeletePermission = Duration(seconds: 3); + /// Whole-probe deadline for establishing what a server-side delete will + /// destroy (see `resolveDeleteImpact`). Generous compared to + /// [jellyfinDeletePermission] for two reasons: it runs behind a modal + /// spinner *after* the user asked to delete, not while a menu is opening, + /// and it may fan out one detail request per sibling episode when browse + /// rows omit file paths. + /// + /// Unlike the permission probe, expiry cannot fail closed — Plex never + /// sends file paths to restricted users, so refusing the delete would + /// permanently remove a feature the server itself authorizes. Expiry + /// instead downgrades the confirmation to its explicit "scope unverified" + /// form. + static const deleteImpactProbe = Duration(seconds: 10); + /// Best-effort `/Sessions/Logout` timeout — short because the call is /// fire-and-forget; the token is removed locally regardless. static const jellyfinSignOut = Duration(seconds: 5); diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index d19c27cd..6abbf970 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -8,6 +8,7 @@ import 'package:provider/provider.dart'; import '../providers/watch_state_store.dart'; import '../media/media_backend.dart'; import '../media/media_item.dart'; +import '../media/media_item_labels.dart'; import '../media/media_item_types.dart'; import '../media/media_kind.dart'; import '../media/library_query.dart'; @@ -24,6 +25,7 @@ import '../services/playlist_items_loader.dart'; import '../services/watch_actions.dart'; import '../models/transcode_quality_preset.dart'; import '../utils/content_utils.dart'; +import '../utils/delete_impact.dart'; import '../utils/download_version_utils.dart'; import '../utils/download_utils.dart'; import '../utils/quality_preset_labels.dart'; @@ -634,12 +636,17 @@ class MediaContextMenuState extends State { // implements (DELETE /library/metadata/{id} for Plex and // DELETE /Items/{id} for MediaBrowser servers); the kind and permission checks were // resolved together above. + // + // The label names the kind. One shared "Delete from server" string for + // an episode, a season and a whole show is what let #1781 happen: the + // reporter hit the show-level entry believing it acted on the episode + // he had highlighted. if (canDeleteFromServer) { menuActions.add( _MenuAction( value: 'delete_media', icon: Symbols.delete_forever_rounded, - label: t.mediaMenu.deleteFromServer, + label: _deleteMenuLabel(mediaKind), destructive: true, ), ); @@ -667,7 +674,7 @@ class MediaContextMenuState extends State { // fall back to a hostless modal sheet). final selected = await showAdaptiveAppMenu( this.context, - title: _itemDisplayTitle(), + title: _itemMenuTitle(), entries: _menuEntries(menuActions), position: position, focusFirstItem: openedFromKeyboard, @@ -1744,6 +1751,23 @@ class MediaContextMenuState extends State { _ => '', }; + /// Header for the menu itself. Unlike [_itemDisplayTitle] — which stays on + /// [MediaItem.displayTitle] for snackbars and the file-info sheet — this + /// names the exact item the entries will act on, so an episode's menu is no + /// longer headed by its show's name (#1781). + String _itemMenuTitle() => switch (widget.item) { + final MediaItem item => formatMediaTargetLabel(item), + MediaPlaylist(:final displayTitle) => displayTitle, + _ => '', + }; + + String _deleteMenuLabel(MediaKind? kind) => switch (kind) { + MediaKind.season => t.mediaMenu.deleteSeasonFromServer, + MediaKind.show => t.mediaMenu.deleteShowFromServer, + MediaKind.movie => t.mediaMenu.deleteMovieFromServer, + _ => t.mediaMenu.deleteEpisodeFromServer, + }; + Future _handleManageSyncRule(BuildContext context) => manageSyncRule( context, downloadProvider: context.read(), @@ -1781,24 +1805,47 @@ class MediaContextMenuState extends State { displayTitle: _itemDisplayTitle(), ); - /// Handle delete media item action - /// This permanently removes the media item and its associated files from the server + /// Handle delete media item action. + /// + /// Permanently removes the item and its files from the server. Everything + /// before the DELETE exists to make the blast radius legible: the dialog + /// names the exact target, states the kind, and — for a single playable + /// item — reports what the server will actually destroy. When that cannot + /// be established, the dialog says so instead of implying single-file + /// scope (#1781). Future _handleDeleteMediaItem(BuildContext context, MediaKind? mediaKind) async { final item = _mediaItem!; - final isMultipleMediaItems = mediaKind == MediaKind.show || mediaKind == MediaKind.season; + final client = _getMediaClientForItem(); + + // Shows and seasons are known-broad by definition; probing their parts + // would be a per-episode fan-out to restate what the copy already says. + final probesImpact = mediaKind == MediaKind.episode || mediaKind == MediaKind.movie; + DeleteImpact? impact; + if (probesImpact) { + showLoadingDialog(context); + try { + impact = await resolveDeleteImpact(item: item, client: client); + } finally { + // The spinner traps system back and nothing else pushes during the + // probe, so it is still the top route here. A `Navigator.canPop` + // guard would only test "is anything poppable", which is true of the + // screen underneath — exactly the route that must not close. + if (context.mounted) Navigator.pop(context); + } + if (!context.mounted) return; + } - // Show confirmation dialog final confirmed = await showDeleteConfirmation( context, - title: t.mediaMenu.deleteFromServer, - message: "${t.mediaMenu.confirmDelete}${isMultipleMediaItems ? "\n\n${t.mediaMenu.deleteMultipleWarning}" : ""}", - confirmText: t.mediaMenu.deleteFromServer, + title: _deleteDialogTitle(mediaKind), + message: t.mediaMenu.confirmDeleteTarget(title: formatMediaTargetLabel(item)), + warning: _deleteWarning(item, mediaKind, impact), + confirmText: impact != null && impact.isUnverified ? t.mediaMenu.deleteAnyway : _deleteConfirmLabel(mediaKind), ); if (!confirmed || !context.mounted) return; try { - final client = _getMediaClientForItem(); final success = await client.deleteMediaItem(item); if (context.mounted) { @@ -1806,6 +1853,11 @@ class MediaContextMenuState extends State { showSuccessSnackBar(context, t.mediaMenu.mediaDeletedSuccessfully); // Broadcast deletion event for cross-screen propagation DeletionNotifier().notifyDeletedItem(item: item); + // Siblings sharing the deleted file died with it server-side. Without + // their own events their rows linger until a full refresh. + for (final sibling in impact?.sharedWith ?? const []) { + DeletionNotifier().notifyDeletedItem(item: sibling); + } // Backward-compatible list refresh for screens that are not DeletionAware yet _notifyListRefresh(); } else { @@ -1820,6 +1872,52 @@ class MediaContextMenuState extends State { } } + String _deleteDialogTitle(MediaKind? kind) => switch (kind) { + MediaKind.season => t.mediaMenu.deleteSeasonTitle, + MediaKind.show => t.mediaMenu.deleteShowTitle, + MediaKind.movie => t.mediaMenu.deleteMovieTitle, + _ => t.mediaMenu.deleteEpisodeTitle, + }; + + String _deleteConfirmLabel(MediaKind? kind) => switch (kind) { + MediaKind.season => t.mediaMenu.deleteSeasonConfirm, + MediaKind.show => t.mediaMenu.deleteShowConfirm, + MediaKind.movie => t.mediaMenu.deleteMovieConfirm, + _ => t.mediaMenu.deleteEpisodeConfirm, + }; + + /// The danger block under the confirmation message, or null when the delete + /// is verified to remove exactly the named item. + String? _deleteWarning(MediaItem item, MediaKind? kind, DeleteImpact? impact) { + if (kind == MediaKind.show || kind == MediaKind.season) { + // leafCount is the episode total; a season falls back to its direct + // children. Neither is guaranteed, so keep the countless sentence. + final episodes = item.leafCount ?? (kind == MediaKind.season ? item.childCount : null); + return episodes != null && episodes > 0 + ? t.mediaMenu.deleteEpisodeCountWarning(n: episodes) + : t.mediaMenu.deleteMultipleWarning; + } + + if (impact == null) return null; + + if (impact.isUnverified) { + return switch (impact.reason) { + DeleteImpactUnverifiedReason.noFileInfo => t.mediaMenu.deleteScopeUnverifiedNoFileInfo, + _ => t.mediaMenu.deleteScopeUnverifiedProbeFailed, + }; + } + + if (!impact.isBroad) return null; + + final lines = []; + if (impact.files.length > 1) lines.add(t.mediaMenu.deleteMultiPartWarning(n: impact.files.length)); + if (impact.sharedWith.isNotEmpty) { + lines.add(t.mediaMenu.deleteSharedFileHeading(n: impact.sharedWith.length)); + lines.addAll(impact.sharedWith.map((sibling) => '\u2022 ${formatMediaTargetLabel(sibling)}')); + } + return lines.join('\n'); + } + @override Widget build(BuildContext context) { // GestureDetector wrapping removed — gesture callbacks are now on InkWell diff --git a/test/utils/delete_impact_test.dart b/test/utils/delete_impact_test.dart new file mode 100644 index 00000000..c150d8a7 --- /dev/null +++ b/test/utils/delete_impact_test.dart @@ -0,0 +1,602 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:drift/native.dart'; +import 'package:fake_async/fake_async.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:plezy/database/app_database.dart'; +import 'package:plezy/media/ids.dart'; +import 'package:plezy/media/media_backend.dart'; +import 'package:plezy/media/media_item.dart'; +import 'package:plezy/media/media_kind.dart'; +import 'package:plezy/media/media_part.dart'; +import 'package:plezy/media/media_server_client.dart'; +import 'package:plezy/media/media_version.dart'; +import 'package:plezy/models/plex/plex_config.dart'; +import 'package:plezy/services/plex_api_cache.dart'; +import 'package:plezy/services/plex_client.dart'; +import 'package:plezy/utils/delete_impact.dart'; + +import '../test_helpers/backend_client_fixtures.dart'; +import '../test_helpers/media_items.dart'; + +/// Plex is the backend that actually produces multi-episode files, and #1781 +/// was reported against it. These drive the real [PlexClient] and Plex mappers +/// over a [MockClient], so the wire shape Plezy depends on — `Media` → `Part` → +/// `file` on both `/library/metadata/{id}` and `/library/metadata/{id}/children` +/// — is asserted rather than assumed. +/// +/// Plain `test`, not `testWidgets`: the Plex metadata cache is a real Drift +/// database, and its I/O never completes under the widget tester's fake clock. +/// Awaiting the resolver directly keeps this deterministic with no clock +/// pumping and no wall-clock waiting. +void main() { + const sharedFile = '/tv/Breaking Bad/Season 01/S01E02-E03.mkv'; + + /// Episode `Metadata` entry. [file]/[files] land on nested `Part` entries, + /// which is the attribute the shared-file signal reads. + /// + /// [partWithoutPath] emits a `Media` whose `Part` carries no `file` — what a + /// Plex restricted user receives. Passing none of the three omits `Media` + /// entirely, the thin-row shape. + Map episodeMetadata({ + required String id, + required int index, + String? file, + List? files, + bool partWithoutPath = false, + }) => { + 'ratingKey': id, + 'key': '/library/metadata/$id', + 'type': 'episode', + 'title': 'Episode $index', + 'index': index, + 'parentIndex': 1, + 'parentRatingKey': 'season-1', + 'grandparentRatingKey': 'show-1', + 'grandparentTitle': 'Breaking Bad', + if (file != null || files != null || partWithoutPath) + 'Media': [ + { + 'id': 'media-$id', + 'container': 'mkv', + 'Part': [ + if (partWithoutPath) + {'id': 'part-$id', 'key': '/library/parts/$id/file.mkv', 'size': 1024} + else + for (final path in files ?? [file!]) + {'id': 'part-$id-${path.hashCode}', 'key': '/library/parts/$id/file.mkv', 'file': path, 'size': 1024}, + ], + }, + ], + }; + + http.Response container(List> metadata) => http.Response( + jsonEncode({ + 'MediaContainer': {'size': metadata.length, 'totalSize': metadata.length, 'Metadata': metadata}, + }), + 200, + headers: const {'content-type': 'application/json'}, + ); + + /// A real PlexClient over [handler], with the metadata cache backed by an + /// in-memory database. Recorded request paths let a test prove which + /// endpoints the resolver actually consulted. + ({PlexClient client, List paths, List deleted}) plexClient( + Future Function(String path) handler, + ) { + final db = AppDatabase.forTesting(NativeDatabase.memory()); + PlexApiCache.initialize(db); + final paths = []; + final deleted = []; + final client = testPlexClient( + config: PlexConfig( + baseUrl: 'https://plex.example.com', + token: 'token', + clientIdentifier: 'client-id', + product: 'Plezy', + version: '1', + ), + serverId: ServerId('plex-1'), + httpClient: MockClient((request) async { + paths.add(request.url.path); + if (request.method == 'DELETE') { + deleted.add(request.url.path.split('/').last); + return http.Response('', 200); + } + return handler(request.url.path); + }), + ); + addTearDown(() async { + client.close(); + await db.close(); + }); + return (client: client, paths: paths, deleted: deleted); + } + + /// The reported shape: an episode row Plezy holds with no media at all. + final thinEpisode = testMediaItem( + id: 'ep-2', + backend: MediaBackend.plex, + kind: MediaKind.episode, + title: 'Cat in the Bag', + parentId: 'season-1', + parentIndex: 1, + index: 2, + grandparentId: 'show-1', + grandparentTitle: 'Breaking Bad', + serverId: 'plex-1', + ); + + test('a thin row recovers scope from detail and finds the episode sharing its file', () async { + final plex = plexClient( + (path) async => switch (path) { + '/library/metadata/ep-2' => container([episodeMetadata(id: 'ep-2', index: 2, file: sharedFile)]), + '/library/metadata/season-1/children' => container([ + episodeMetadata(id: 'ep-1', index: 1, file: '/tv/Breaking Bad/Season 01/S01E01.mkv'), + episodeMetadata(id: 'ep-2', index: 2, file: sharedFile), + episodeMetadata(id: 'ep-3', index: 3, file: sharedFile), + ]), + _ => http.Response('not found', 404), + }, + ); + + final impact = await resolveDeleteImpact(item: thinEpisode, client: plex.client); + + expect(impact.scope, DeleteImpactScope.broad); + expect(impact.files, {sharedFile}); + expect(impact.sharedWith.map((item) => item.id), ['ep-3']); + // The inline row carried nothing, so both endpoints had to be consulted. + expect(plex.paths, contains('/library/metadata/ep-2')); + expect(plex.paths, contains('/library/metadata/season-1/children')); + }); + + test('deletes only the resolved episode rating key', () async { + final plex = plexClient( + (path) async => switch (path) { + '/library/metadata/ep-2' => container([episodeMetadata(id: 'ep-2', index: 2, file: sharedFile)]), + '/library/metadata/season-1/children' => container([ + episodeMetadata(id: 'ep-2', index: 2, file: sharedFile), + episodeMetadata(id: 'ep-3', index: 3, file: sharedFile), + ]), + _ => http.Response('not found', 404), + }, + ); + + expect(await plex.client.deleteMediaItem(thinEpisode), isTrue); + + expect(plex.deleted, ['ep-2'], reason: 'a shared file must not widen the DELETE to its siblings'); + expect(plex.paths, contains('/library/metadata/ep-2')); + }); + + test('an episode alone in its file resolves as exclusive', () async { + const ownFile = '/tv/Breaking Bad/Season 01/S01E02.mkv'; + final plex = plexClient( + (path) async => switch (path) { + '/library/metadata/ep-2' => container([episodeMetadata(id: 'ep-2', index: 2, file: ownFile)]), + '/library/metadata/season-1/children' => container([ + episodeMetadata(id: 'ep-2', index: 2, file: ownFile), + episodeMetadata(id: 'ep-3', index: 3, file: '/tv/Breaking Bad/Season 01/S01E03.mkv'), + ]), + _ => http.Response('not found', 404), + }, + ); + + final impact = await resolveDeleteImpact(item: thinEpisode, client: plex.client); + + expect(impact.scope, DeleteImpactScope.exclusive); + expect(impact.sharedWith, isEmpty); + }); + + test('a multi-part episode counts every file it will destroy', () async { + const parts = ['/tv/bb/S01E02-cd1.mkv', '/tv/bb/S01E02-cd2.mkv']; + final plex = plexClient( + (path) async => switch (path) { + '/library/metadata/ep-2' => container([episodeMetadata(id: 'ep-2', index: 2, files: parts)]), + '/library/metadata/season-1/children' => container([episodeMetadata(id: 'ep-2', index: 2, files: parts)]), + _ => http.Response('not found', 404), + }, + ); + + final impact = await resolveDeleteImpact(item: thinEpisode, client: plex.client); + + expect(impact.scope, DeleteImpactScope.broad); + expect(impact.files, parts.toSet()); + expect(impact.sharedWith, isEmpty); + }); + + test('a Part carrying no path is unverified, not a single known file', () async { + // A restricted user gets Media and Part but never `file`. Skipping such a + // part instead of invalidating the answer would report one path-less part + // as an exclusive single-file delete. + final plex = plexClient( + (path) async => switch (path) { + '/library/metadata/ep-2' => container([episodeMetadata(id: 'ep-2', index: 2, partWithoutPath: true)]), + '/library/metadata/season-1/children' => container([ + episodeMetadata(id: 'ep-2', index: 2, partWithoutPath: true), + ]), + _ => http.Response('not found', 404), + }, + ); + + final impact = await resolveDeleteImpact(item: thinEpisode, client: plex.client); + + expect(impact.scope, DeleteImpactScope.unverified); + expect(impact.reason, DeleteImpactUnverifiedReason.noFileInfo); + expect(impact.files, isEmpty); + }); + + test('a payload with no media at all is unverified', () async { + final plex = plexClient( + (path) async => switch (path) { + '/library/metadata/ep-2' => container([episodeMetadata(id: 'ep-2', index: 2)]), + '/library/metadata/season-1/children' => container([episodeMetadata(id: 'ep-2', index: 2)]), + _ => http.Response('not found', 404), + }, + ); + + final impact = await resolveDeleteImpact(item: thinEpisode, client: plex.client); + + expect(impact.scope, DeleteImpactScope.unverified); + expect(impact.reason, DeleteImpactUnverifiedReason.noFileInfo); + }); + + test('a sibling whose path stays unknown blocks an exclusive verdict', () async { + // Neither the browse row nor the detail refetch names ep-3's file, so it + // could be backed by this very file. Assuming it is distinct would hide a + // shared-file delete behind a confident confirmation. + final plex = plexClient( + (path) async => switch (path) { + '/library/metadata/ep-2' => container([episodeMetadata(id: 'ep-2', index: 2, file: sharedFile)]), + '/library/metadata/season-1/children' => container([ + episodeMetadata(id: 'ep-2', index: 2, file: sharedFile), + episodeMetadata(id: 'ep-3', index: 3, partWithoutPath: true), + ]), + '/library/metadata/ep-3' => container([episodeMetadata(id: 'ep-3', index: 3, partWithoutPath: true)]), + _ => http.Response('not found', 404), + }, + ); + + final impact = await resolveDeleteImpact(item: thinEpisode, client: plex.client); + + expect(impact.scope, DeleteImpactScope.unverified); + expect(impact.reason, DeleteImpactUnverifiedReason.noFileInfo); + expect(plex.paths, contains('/library/metadata/ep-3')); + }); + + test('a sibling row without a path is resolved before ruling out a shared file', () async { + final plex = plexClient( + (path) async => switch (path) { + '/library/metadata/ep-2' => container([episodeMetadata(id: 'ep-2', index: 2, file: sharedFile)]), + // The browse row omits ep-3's path; trusting it would report the delete + // as exclusive while the server destroys ep-3 with the same file. + '/library/metadata/season-1/children' => container([ + episodeMetadata(id: 'ep-2', index: 2, file: sharedFile), + episodeMetadata(id: 'ep-3', index: 3), + ]), + '/library/metadata/ep-3' => container([episodeMetadata(id: 'ep-3', index: 3, file: sharedFile)]), + _ => http.Response('not found', 404), + }, + ); + + final impact = await resolveDeleteImpact(item: thinEpisode, client: plex.client); + + expect(impact.scope, DeleteImpactScope.broad); + expect(impact.sharedWith.map((item) => item.id), ['ep-3']); + expect(plex.paths, contains('/library/metadata/ep-3')); + }); + + // Direct contract of the predicate every verdict above rests on. Neither + // mapper can currently produce a version with zero parts, so this is the + // only place that shape is pinned. + group('completePartFiles', () { + test('rejects an empty version list', () { + expect(completePartFiles(const []), isNull); + }); + + test('rejects a version with no parts rather than reporting zero files', () { + expect(completePartFiles(const [MediaVersion(id: 'v-1')]), isNull); + }); + + test('rejects a version where any single part lacks a path', () { + expect( + completePartFiles(const [ + MediaVersion( + id: 'v-1', + parts: [ + MediaPart(id: 'p-1', file: '/tv/a.mkv'), + MediaPart(id: 'p-2'), + ], + ), + ]), + isNull, + reason: 'one unknown path makes the whole file list untrustworthy', + ); + }); + + test('collapses duplicate paths across versions into one file', () { + expect( + completePartFiles(const [ + MediaVersion( + id: 'v-1', + parts: [MediaPart(id: 'p-1', file: '/tv/a.mkv')], + ), + MediaVersion( + id: 'v-2', + parts: [MediaPart(id: 'p-2', file: '/tv/a.mkv')], + ), + ]), + {'/tv/a.mkv'}, + ); + }); + + test('returns every distinct path of a multi-part version', () { + expect( + completePartFiles(const [ + MediaVersion( + id: 'v-1', + parts: [ + MediaPart(id: 'p-1', file: '/tv/cd1.mkv'), + MediaPart(id: 'p-2', file: '/tv/cd2.mkv'), + ], + ), + ]), + {'/tv/cd1.mkv', '/tv/cd2.mkv'}, + ); + }); + }); + + test('a failed sibling lookup is a transient probe failure', () async { + final plex = plexClient( + (path) async => switch (path) { + '/library/metadata/ep-2' => container([episodeMetadata(id: 'ep-2', index: 2, file: sharedFile)]), + _ => http.Response('boom', 500), + }, + ); + + final impact = await resolveDeleteImpact(item: thinEpisode, client: plex.client); + + expect(impact.scope, DeleteImpactScope.unverified); + expect(impact.reason, DeleteImpactUnverifiedReason.probeFailed); + }); + + group('bounded probing', () { + /// A season whose rows all arrive without paths — the shape that would fan + /// out one detail request per episode. + List> pathlessSeason(int count) => [ + episodeMetadata(id: 'ep-2', index: 2, file: sharedFile), + for (var index = 3; index < 3 + count; index++) + episodeMetadata(id: 'ep-$index', index: index, partWithoutPath: true), + ]; + + int detailFetches(List paths) => + paths.where((path) => path.startsWith('/library/metadata/ep-') && !path.endsWith('/children')).length; + + test('stops at the first sibling it cannot resolve instead of fanning out', () async { + final plex = plexClient( + (path) async => switch (path) { + '/library/metadata/ep-2' => container([episodeMetadata(id: 'ep-2', index: 2, file: sharedFile)]), + '/library/metadata/season-1/children' => container(pathlessSeason(60)), + // Every sibling detail refuses; only the first may ever be asked. + _ => http.Response('boom', 500), + }, + ); + + final impact = await resolveDeleteImpact(item: thinEpisode, client: plex.client); + + expect(impact.scope, DeleteImpactScope.unverified); + expect(impact.reason, DeleteImpactUnverifiedReason.probeFailed); + expect( + detailFetches(plex.paths), + 2, + reason: 'the target plus exactly one sibling — not one request per path-less episode', + ); + }); + + test('asks nothing extra of a season whose rows already carry paths', () async { + final plex = plexClient( + (path) async => switch (path) { + '/library/metadata/ep-2' => container([episodeMetadata(id: 'ep-2', index: 2, file: sharedFile)]), + '/library/metadata/season-1/children' => container([ + episodeMetadata(id: 'ep-2', index: 2, file: sharedFile), + for (var index = 3; index < 40; index++) + episodeMetadata(id: 'ep-$index', index: index, file: '/tv/bb/S01E$index.mkv'), + ]), + _ => http.Response('should not be fetched', 500), + }, + ); + + final impact = await resolveDeleteImpact(item: thinEpisode, client: plex.client); + + expect(impact.scope, DeleteImpactScope.exclusive); + expect(detailFetches(plex.paths), 1, reason: 'only the thin target itself needs a detail request'); + }); + }); + // The Plex cases above cannot express a deadline: their cache is a real + // database, so they need a real clock. Cancellation is backend-neutral, so it + // is pinned here against a narrow fake under `fakeAsync` — deterministic, + // and no wall-clock waiting. + group('cancellation on timeout', () { + const timeout = Duration(seconds: 10); + + MediaItem pathlessEpisode(String id, int index) => testMediaItem( + id: id, + kind: MediaKind.episode, + title: 'Episode $index', + parentId: 'season-1', + parentIndex: 1, + index: index, + grandparentId: 'show-1', + serverId: 'srv-1', + mediaVersions: const [ + MediaVersion( + id: 'v', + parts: [MediaPart(id: 'p')], + ), + ], + ); + + test('no further sibling is fetched once the deadline has returned', () { + final target = testMediaItem( + id: 'ep-2', + kind: MediaKind.episode, + title: 'Cat in the Bag', + parentId: 'season-1', + parentIndex: 1, + index: 2, + grandparentId: 'show-1', + serverId: 'srv-1', + mediaVersions: const [ + MediaVersion( + id: 'v-2', + parts: [MediaPart(id: 'p-2', file: '/tv/bb/S01E02.mkv')], + ), + ], + ); + // Every sibling row lacks a path, so each one costs a detail lookup. + final client = _GatedClient( + children: [target, for (var index = 3; index < 30; index++) pathlessEpisode('ep-$index', index)], + ); + + DeleteImpact? impact; + fakeAsync((async) { + resolveDeleteImpact(item: target, client: client, timeout: timeout).then((value) => impact = value); + + // Let the children lookup land and the first sibling fetch begin. + async.flushMicrotasks(); + expect(client.itemFetches, 1, reason: 'one sibling in flight'); + expect(impact, isNull); + + async.elapse(timeout + const Duration(seconds: 1)); + expect(impact?.scope, DeleteImpactScope.unverified); + expect(impact?.reason, DeleteImpactUnverifiedReason.probeFailed); + expect(client.itemFetches, 1, reason: 'the deadline must not have started another'); + + // The abandoned request finally answers. The loop resumes here — and + // must stop instead of walking the remaining 26 siblings. + client.releaseFetch(); + async.flushMicrotasks(); + async.elapse(const Duration(minutes: 1)); + + expect( + client.itemFetches, + 1, + reason: 'a completed in-flight fetch must not restart the walk after the probe gave up', + ); + }); + }); + + test('an uninterrupted probe still visits every sibling', () { + final target = testMediaItem( + id: 'ep-2', + kind: MediaKind.episode, + parentId: 'season-1', + index: 2, + grandparentId: 'show-1', + serverId: 'srv-1', + mediaVersions: const [ + MediaVersion( + id: 'v-2', + parts: [MediaPart(id: 'p-2', file: '/tv/bb/S01E02.mkv')], + ), + ], + ); + final client = _GatedClient(children: [target, pathlessEpisode('ep-3', 3), pathlessEpisode('ep-4', 4)]) + ..autoRelease = true + ..detailFile = '/tv/bb/S01E02.mkv'; + + DeleteImpact? impact; + fakeAsync((async) { + resolveDeleteImpact(item: target, client: client, timeout: timeout).then((value) => impact = value); + async.flushMicrotasks(); + + expect(client.itemFetches, 2, reason: 'both path-less siblings resolved'); + expect(impact?.scope, DeleteImpactScope.broad); + expect(impact?.sharedWith.map((item) => item.id), ['ep-3', 'ep-4']); + }); + }); + + test('the season is never listed when the deadline expires on the item itself', () { + // The target's own row carries no path, so its lookup is the first thing + // outstanding. Resuming past it would ask the server for the children of + // a season nobody is waiting on any more. + final target = pathlessEpisode('ep-2', 2); + final client = _GatedClient(children: [target, pathlessEpisode('ep-3', 3)]); + + DeleteImpact? impact; + fakeAsync((async) { + resolveDeleteImpact(item: target, client: client, timeout: timeout).then((value) => impact = value); + async.flushMicrotasks(); + expect(client.itemFetches, 1); + expect(client.childrenFetches, 0); + + async.elapse(timeout + const Duration(seconds: 1)); + expect(impact?.reason, DeleteImpactUnverifiedReason.probeFailed); + + client.releaseFetch(); + async.flushMicrotasks(); + async.elapse(const Duration(minutes: 1)); + + expect(client.childrenFetches, 0, reason: 'an abandoned probe must not go on to list the season'); + expect(client.itemFetches, 1); + }); + }); + }); +} + +/// Neutral client whose item lookups can be held open, so a test can place the +/// probe deadline in the middle of one. +class _GatedClient implements MediaServerClient { + _GatedClient({required this.children}); + + final List children; + + /// Answer item lookups immediately instead of gating them. + bool autoRelease = false; + + /// Path reported by a released lookup. + String detailFile = '/tv/bb/other.mkv'; + + int itemFetches = 0; + int childrenFetches = 0; + final List> _pending = []; + + void releaseFetch() { + for (final completer in _pending) { + if (!completer.isCompleted) completer.complete(_detail()); + } + _pending.clear(); + } + + MediaItem _detail() => testMediaItem( + id: 'resolved', + kind: MediaKind.episode, + serverId: 'srv-1', + mediaVersions: [ + MediaVersion( + id: 'v', + parts: [MediaPart(id: 'p', file: detailFile)], + ), + ], + ); + + @override + Future> fetchChildren(String parentId) async { + childrenFetches++; + return children; + } + + @override + Future fetchItem(String id) { + itemFetches++; + if (autoRelease) return Future.value(_detail()); + final completer = Completer(); + _pending.add(completer); + return completer.future; + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/test/widgets/media_context_menu_test.dart b/test/widgets/media_context_menu_test.dart index 10bc4867..1dba58c9 100644 --- a/test/widgets/media_context_menu_test.dart +++ b/test/widgets/media_context_menu_test.dart @@ -19,8 +19,10 @@ import 'package:plezy/media/media_backend.dart'; import 'package:plezy/media/media_file_info.dart'; import 'package:plezy/media/media_item.dart'; import 'package:plezy/media/media_kind.dart'; +import 'package:plezy/media/media_part.dart'; import 'package:plezy/media/media_playlist.dart'; import 'package:plezy/media/media_server_client.dart'; +import 'package:plezy/media/media_version.dart'; import 'package:plezy/media/server_capabilities.dart'; import 'package:plezy/metadata_edit/metadata_edit_adapters.dart'; import 'package:plezy/models/plex/plex_home_user.dart'; @@ -41,6 +43,7 @@ import 'package:plezy/services/music/music_playback_service.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/plex_api_cache.dart'; import 'package:plezy/services/settings_service.dart'; +import 'package:plezy/utils/deletion_notifier.dart'; import 'package:plezy/theme/mono_theme.dart'; import 'package:plezy/utils/media_server_http_client.dart'; import 'package:plezy/utils/media_server_timeouts.dart'; @@ -193,7 +196,7 @@ void main() { await _openMenu(tester, menuKey); - expect(find.text(t.mediaMenu.deleteFromServer), findsNothing); + expect(find.text(t.mediaMenu.deleteMovieFromServer), findsNothing); }); testWidgets('shows delete for a non-admin the server permits', (tester) async { @@ -207,7 +210,7 @@ void main() { await _openMenu(tester, menuKey); - expect(find.text(t.mediaMenu.deleteFromServer), findsOneWidget); + expect(find.text(t.mediaMenu.deleteMovieFromServer), findsOneWidget); final probes = requests.where((uri) => uri.queryParameters['Fields'] == 'CanDelete').toList(); expect(probes, hasLength(1)); expect(probes.single.queryParameters['ids'], 'movie-1'); @@ -223,7 +226,7 @@ void main() { await _openMenu(tester, menuKey); - expect(find.text(t.mediaMenu.deleteFromServer), findsOneWidget); + expect(find.text(t.mediaMenu.deleteMovieFromServer), findsOneWidget); }); testWidgets('hides delete for a library-granted user outside the grant', (tester) async { @@ -236,7 +239,7 @@ void main() { await _openMenu(tester, menuKey); - expect(find.text(t.mediaMenu.deleteFromServer), findsNothing); + expect(find.text(t.mediaMenu.deleteMovieFromServer), findsNothing); }); testWidgets('hides delete when the permission probe fails', (tester) async { @@ -248,7 +251,7 @@ void main() { await _openMenu(tester, menuKey); - expect(find.text(t.mediaMenu.deleteFromServer), findsNothing); + expect(find.text(t.mediaMenu.deleteMovieFromServer), findsNothing); expect(tester.takeException(), isNull); // The menu itself still opened; only the destructive entry is missing. expect(find.text(t.mediaMenu.fileInfo), findsOneWidget); @@ -266,7 +269,7 @@ void main() { await _openMenu(tester, menuKey); - expect(find.text(t.mediaMenu.deleteFromServer), findsNothing); + expect(find.text(t.mediaMenu.deleteMovieFromServer), findsNothing); expect(requests.where((uri) => uri.queryParameters['Fields'] == 'CanDelete'), isEmpty); }); @@ -285,7 +288,374 @@ void main() { await tester.pumpAndSettle(); expect(find.text(t.mediaMenu.fileInfo), findsOneWidget); - expect(find.text(t.mediaMenu.deleteFromServer), findsNothing); + expect(find.text(t.mediaMenu.deleteMovieFromServer), findsNothing); + }); + }); + + group('MediaContextMenu delete confirmation', () { + testWidgets('names the episode, never the show, at every step (issue #1781)', (tester) async { + final menuKey = await _pumpJellyfinItemMenu( + tester, + isAdministrator: true, + item: _episode(id: 'ep-2', index: 2, file: '/tv/bb/S01E02.mkv'), + handler: _deleteFlowHandler( + episodes: [_episodeJson(id: 'ep-2', index: 2, path: '/tv/bb/S01E02.mkv')], + ), + ); + + await _openMenu(tester, menuKey); + expect(find.text(t.mediaMenu.deleteEpisodeFromServer), findsOneWidget); + expect(find.text(t.mediaMenu.deleteShowFromServer), findsNothing); + + await tester.tap(find.text(t.mediaMenu.deleteEpisodeFromServer)); + await tester.pumpAndSettle(); + + final dialog = find.byType(AlertDialog); + expect(dialog, findsOneWidget); + expect(find.descendant(of: dialog, matching: find.text(t.mediaMenu.deleteEpisodeTitle)), findsOneWidget); + expect( + find.descendant( + of: dialog, + matching: find.text(t.mediaMenu.confirmDeleteTarget(title: 'Breaking Bad · S1 E2 · Cat in the Bag')), + ), + findsOneWidget, + reason: 'the confirmation must name the episode; displayTitle would render only "Breaking Bad"', + ); + expect(find.descendant(of: dialog, matching: find.text(t.mediaMenu.deleteEpisodeConfirm)), findsOneWidget); + expect(find.text(t.mediaMenu.deleteMultipleWarning), findsNothing); + }); + + testWidgets('a show delete says so and states the episode count', (tester) async { + final menuKey = await _pumpJellyfinItemMenu( + tester, + isAdministrator: true, + item: testMediaItem( + id: 'show-1', + backend: MediaBackend.jellyfin, + kind: MediaKind.show, + title: 'Breaking Bad', + serverId: 'srv-1', + leafCount: 62, + ), + handler: _deleteFlowHandler(episodes: const []), + ); + + await _openMenu(tester, menuKey); + expect(find.text(t.mediaMenu.deleteShowFromServer), findsOneWidget); + expect(find.text(t.mediaMenu.deleteEpisodeFromServer), findsNothing); + + await tester.tap(find.text(t.mediaMenu.deleteShowFromServer)); + await tester.pumpAndSettle(); + + final dialog = find.byType(AlertDialog); + expect(find.descendant(of: dialog, matching: find.text(t.mediaMenu.deleteShowTitle)), findsOneWidget); + expect( + find.descendant(of: dialog, matching: find.text(t.mediaMenu.deleteEpisodeCountWarning(n: 62))), + findsOneWidget, + ); + expect(find.descendant(of: dialog, matching: find.text(t.mediaMenu.deleteShowConfirm)), findsOneWidget); + }); + + testWidgets('cancelling issues no delete request', (tester) async { + final requests = []; + final menuKey = await _pumpJellyfinItemMenu( + tester, + isAdministrator: true, + requests: requests, + item: _episode(id: 'ep-2', index: 2, file: '/tv/bb/S01E02.mkv'), + handler: _deleteFlowHandler( + episodes: [_episodeJson(id: 'ep-2', index: 2, path: '/tv/bb/S01E02.mkv')], + ), + ); + + await _openMenu(tester, menuKey); + await tester.tap(find.text(t.mediaMenu.deleteEpisodeFromServer)); + await tester.pumpAndSettle(); + await tester.tap(find.text(t.common.cancel)); + await tester.pumpAndSettle(); + + expect(requests.where((uri) => uri.path.contains('/Items/ep-2')), isEmpty); + }); + + testWidgets('confirming deletes exactly the focused episode', (tester) async { + final deleted = []; + final menuKey = await _pumpJellyfinItemMenu( + tester, + isAdministrator: true, + item: _episode(id: 'ep-2', index: 2, file: '/tv/bb/S01E02.mkv'), + handler: _deleteFlowHandler( + episodes: [ + _episodeJson(id: 'ep-1', index: 1, path: '/tv/bb/S01E01.mkv'), + _episodeJson(id: 'ep-2', index: 2, path: '/tv/bb/S01E02.mkv'), + _episodeJson(id: 'ep-3', index: 3, path: '/tv/bb/S01E03.mkv'), + ], + onDelete: deleted.add, + ), + ); + + await _confirmDelete(tester, menuKey, t.mediaMenu.deleteEpisodeConfirm); + + expect(deleted, ['ep-2'], reason: 'siblings in the same folder must be untouched'); + }); + + testWidgets('warns about episodes sharing the file and reports them deleted', (tester) async { + const sharedFile = '/tv/bb/S01E02-E03.mkv'; + final events = []; + final subscription = DeletionNotifier().stream.listen((event) => events.add(event.itemId)); + addTearDown(subscription.cancel); + + final menuKey = await _pumpJellyfinItemMenu( + tester, + isAdministrator: true, + item: _episode(id: 'ep-2', index: 2, file: sharedFile), + handler: _deleteFlowHandler( + episodes: [ + _episodeJson(id: 'ep-1', index: 1, path: '/tv/bb/S01E01.mkv'), + _episodeJson(id: 'ep-2', index: 2, path: sharedFile), + _episodeJson(id: 'ep-3', index: 3, path: sharedFile), + ], + ), + ); + + await _openMenu(tester, menuKey); + await tester.tap(find.text(t.mediaMenu.deleteEpisodeFromServer)); + await tester.pumpAndSettle(); + + expect(find.textContaining(t.mediaMenu.deleteSharedFileHeading(n: 1)), findsOneWidget); + expect(find.textContaining('S1 E3'), findsOneWidget); + + await tester.tap(find.text(t.mediaMenu.deleteEpisodeConfirm)); + await tester.pumpAndSettle(); + + expect(events, containsAll(['ep-2', 'ep-3'])); + expect(events, isNot(contains('ep-1'))); + }); + + testWidgets('a long shared-file list scrolls instead of overflowing a phone dialog', (tester) async { + const sharedFile = '/tv/bb/S01E01-E08.mkv'; + tester.view.physicalSize = const Size(360, 640); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final menuKey = await _pumpJellyfinItemMenu( + tester, + isAdministrator: true, + item: _episode(id: 'ep-1', index: 1, file: sharedFile), + handler: _deleteFlowHandler( + episodes: [for (var i = 1; i <= 8; i++) _episodeJson(id: 'ep-$i', index: i, path: sharedFile)], + ), + ); + + await _openMenu(tester, menuKey); + await tester.tap(find.text(t.mediaMenu.deleteEpisodeFromServer)); + await tester.pumpAndSettle(); + + expect(find.textContaining(t.mediaMenu.deleteSharedFileHeading(n: 7)), findsOneWidget); + expect(tester.takeException(), isNull, reason: 'the warning block must scroll, not overflow'); + }); + + testWidgets('system back during the impact probe keeps the spinner and the screen', (tester) async { + // The spinner is documented non-dismissible, but barrierDismissible + // alone does not stop system back. If back popped it, the probe's + // cleanup would pop the screen underneath instead. + final gate = Completer(); + final menuKey = await _pumpJellyfinItemMenu( + tester, + isAdministrator: true, + onPushedRoute: true, + item: _episode(id: 'ep-2', index: 2, file: '/tv/bb/S01E02.mkv'), + handler: (request) async { + if (request.url.queryParameters.containsKey('ParentId')) { + await gate.future; + return jsonResponse({ + 'Items': [_episodeJson(id: 'ep-2', index: 2, path: '/tv/bb/S01E02.mkv')], + 'TotalRecordCount': 1, + }); + } + if (request.url.queryParameters['Fields'] == 'CanDelete') { + return _canDeleteResponse(request.url.queryParameters['ids'] ?? '', true); + } + if (request.url.path.contains('/Seasons')) return jsonResponse({'Items': []}); + return jsonResponse({'Items': []}); + }, + ); + + await _openMenu(tester, menuKey); + await tester.tap(find.text(t.mediaMenu.deleteEpisodeFromServer)); + await tester.pump(); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + + await tester.binding.handlePopRoute(); + // Bounded pumps only: pumpAndSettle would run the fake clock past + // MediaServerTimeouts.deleteImpactProbe and close the spinner by + // timeout, hiding whatever back actually did. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(find.byType(CircularProgressIndicator), findsOneWidget, reason: 'the spinner is non-dismissible'); + expect(find.text('home screen'), findsNothing, reason: 'the screen under the spinner must survive back'); + + gate.complete(); + await tester.pumpAndSettle(); + + expect(find.byType(AlertDialog), findsOneWidget); + expect(find.byType(CircularProgressIndicator), findsNothing); + expect(find.text('home screen'), findsNothing); + expect( + find.descendant(of: find.byType(AlertDialog), matching: find.text(t.mediaMenu.deleteEpisodeConfirm)), + findsOneWidget, + ); + }); + + testWidgets('warns when the episode is split across several files', (tester) async { + final menuKey = await _pumpJellyfinItemMenu( + tester, + isAdministrator: true, + item: _episode( + id: 'ep-2', + index: 2, + versions: [ + MediaVersion( + id: 'v-1', + parts: const [ + MediaPart(id: 'p-1', file: '/tv/bb/S01E02-cd1.mkv'), + MediaPart(id: 'p-2', file: '/tv/bb/S01E02-cd2.mkv'), + ], + ), + ], + ), + handler: _deleteFlowHandler( + episodes: [_episodeJson(id: 'ep-2', index: 2, path: '/tv/bb/S01E02-cd1.mkv')], + ), + ); + + await _openMenu(tester, menuKey); + await tester.tap(find.text(t.mediaMenu.deleteEpisodeFromServer)); + await tester.pumpAndSettle(); + + expect(find.textContaining(t.mediaMenu.deleteMultiPartWarning(n: 2)), findsOneWidget); + }); + + testWidgets('refuses to imply single-file scope when the server withholds paths', (tester) async { + final menuKey = await _pumpJellyfinItemMenu( + tester, + isAdministrator: true, + item: _episode( + id: 'ep-2', + index: 2, + versions: const [ + MediaVersion( + id: 'v-1', + parts: [MediaPart(id: 'p-1')], + ), + ], + ), + handler: _deleteFlowHandler( + episodes: [_episodeJson(id: 'ep-2', index: 2, path: null)], + details: {'ep-2': _episodeJson(id: 'ep-2', index: 2, path: null)}, + ), + ); + + await _openMenu(tester, menuKey); + await tester.tap(find.text(t.mediaMenu.deleteEpisodeFromServer)); + await tester.pumpAndSettle(); + + final dialog = find.byType(AlertDialog); + expect( + find.descendant(of: dialog, matching: find.text(t.mediaMenu.deleteScopeUnverifiedNoFileInfo)), + findsOneWidget, + ); + expect( + find.descendant(of: dialog, matching: find.text(t.mediaMenu.deleteAnyway)), + findsOneWidget, + reason: 'an unverified scope must not offer the confident per-kind confirm label', + ); + expect(find.text(t.mediaMenu.deleteEpisodeConfirm), findsNothing); + }); + + testWidgets('recovers scope from the detail endpoint when the browse row omitted paths', (tester) async { + // PlexMappers synthesizes a single file-less part for a thin row, and + // Jellyfin resume rows can arrive without MediaSources. Treating that as + // "server withholds paths" would make the danger warning the default + // even though the detail endpoint answers. + final menuKey = await _pumpJellyfinItemMenu( + tester, + isAdministrator: true, + item: _episode( + id: 'ep-2', + index: 2, + versions: const [ + MediaVersion( + id: 'v-1', + parts: [MediaPart(id: 'p-1')], + ), + ], + ), + handler: _deleteFlowHandler( + episodes: [ + _episodeJson(id: 'ep-1', index: 1, path: '/tv/bb/S01E01.mkv'), + _episodeJson(id: 'ep-2', index: 2, path: '/tv/bb/S01E02.mkv'), + ], + details: {'ep-2': _episodeJson(id: 'ep-2', index: 2, path: '/tv/bb/S01E02.mkv')}, + ), + ); + + await _openMenu(tester, menuKey); + await tester.tap(find.text(t.mediaMenu.deleteEpisodeFromServer)); + await tester.pumpAndSettle(); + + final dialog = find.byType(AlertDialog); + expect(find.descendant(of: dialog, matching: find.text(t.mediaMenu.deleteEpisodeConfirm)), findsOneWidget); + expect(find.text(t.mediaMenu.deleteAnyway), findsNothing); + expect(find.text(t.mediaMenu.deleteScopeUnverifiedNoFileInfo), findsNothing); + expect(find.text(t.mediaMenu.deleteScopeUnverifiedProbeFailed), findsNothing); + }); + + testWidgets('resolves a path-less sibling row before ruling out a shared file', (tester) async { + // The sibling browse row carries no path. Trusting it would report the + // delete as exclusive while the server destroys ep-3 along with it. + const sharedFile = '/tv/bb/S01E02-E03.mkv'; + final menuKey = await _pumpJellyfinItemMenu( + tester, + isAdministrator: true, + item: _episode(id: 'ep-2', index: 2, file: sharedFile), + handler: _deleteFlowHandler( + episodes: [ + _episodeJson(id: 'ep-2', index: 2, path: sharedFile), + _episodeJson(id: 'ep-3', index: 3, path: null), + ], + details: {'ep-3': _episodeJson(id: 'ep-3', index: 3, path: sharedFile)}, + ), + ); + + await _openMenu(tester, menuKey); + await tester.tap(find.text(t.mediaMenu.deleteEpisodeFromServer)); + await tester.pumpAndSettle(); + + expect(find.textContaining(t.mediaMenu.deleteSharedFileHeading(n: 1)), findsOneWidget); + expect(find.textContaining('S1 E3'), findsOneWidget); + }); + + testWidgets('flags an unverified scope when the sibling lookup fails, and still deletes', (tester) async { + final deleted = []; + final menuKey = await _pumpJellyfinItemMenu( + tester, + isAdministrator: true, + item: _episode(id: 'ep-2', index: 2, file: '/tv/bb/S01E02.mkv'), + handler: _deleteFlowHandler(episodes: null, onDelete: deleted.add), + ); + + await _openMenu(tester, menuKey); + await tester.tap(find.text(t.mediaMenu.deleteEpisodeFromServer)); + await tester.pumpAndSettle(); + + expect(find.text(t.mediaMenu.deleteScopeUnverifiedProbeFailed), findsOneWidget); + + await tester.tap(find.text(t.mediaMenu.deleteAnyway)); + await tester.pumpAndSettle(); + + expect(deleted, ['ep-2']); }); }); @@ -928,6 +1298,88 @@ Future _libraryGrantedToOneMovie(http.Request request) async { return _canDeleteResponse(id, id == 'movie-in-grant'); } +/// Jellyfin episode fixture. [file] populates the inline media source so the +/// impact probe resolves without a detail refetch; pass [versions] instead to +/// model a split or path-less item. +MediaItem _episode({required String id, required int index, String? file, List? versions}) { + return testMediaItem( + id: id, + backend: MediaBackend.jellyfin, + kind: MediaKind.episode, + title: 'Cat in the Bag', + parentId: 'season-1', + parentIndex: 1, + index: index, + grandparentId: 'show-1', + grandparentTitle: 'Breaking Bad', + serverId: 'srv-1', + mediaVersions: + versions ?? + [ + MediaVersion( + id: 'v-$id', + parts: [MediaPart(id: 'p-$id', file: file)], + ), + ], + ); +} + +Map _episodeJson({required String id, required int index, required String? path}) => { + 'Id': id, + 'Name': 'Episode $index', + 'Type': 'Episode', + 'IndexNumber': index, + 'ParentIndexNumber': 1, + 'SeriesId': 'show-1', + 'SeriesName': 'Breaking Bad', + 'SeasonId': 'season-1', + 'MediaSources': [ + {'Id': 'ms-$id', 'Path': ?path}, + ], +}; + +/// Answers every request the delete flow makes: the CanDelete probe, the +/// season-children lookup, per-item detail refetches, and the DELETE itself. +/// +/// A null [episodes] makes the children lookup fail, which is the +/// probe-failure path. [details] is keyed by item id and models the detail +/// endpoint carrying file paths that the browse row omitted. +Future Function(http.Request) _deleteFlowHandler({ + required List>? episodes, + Map> details = const {}, + void Function(String id)? onDelete, +}) { + return (request) async { + final path = request.url.path; + final lastSegment = path.split('/').last; + if (request.method == 'DELETE') { + onDelete?.call(lastSegment); + return http.Response('', 204); + } + if (request.url.queryParameters['Fields'] == 'CanDelete') { + return _canDeleteResponse(request.url.queryParameters['ids'] ?? '', true); + } + // fetchChildren probes /Shows/{id}/Seasons first and falls through to the + // generic ParentId query when it comes back empty. + if (path.contains('/Seasons')) return jsonResponse({'Items': []}); + if (request.url.queryParameters.containsKey('ParentId')) { + if (episodes == null) return http.Response('boom', 500); + return jsonResponse({'Items': episodes, 'TotalRecordCount': episodes.length}); + } + final detail = details[lastSegment]; + if (detail != null) return jsonResponse(detail); + return jsonResponse({'Items': []}); + }; +} + +Future _confirmDelete(WidgetTester tester, GlobalKey menuKey, String confirmLabel) async { + await _openMenu(tester, menuKey); + await tester.tap(find.text(t.mediaMenu.deleteEpisodeFromServer)); + await tester.pumpAndSettle(); + await tester.tap(find.text(confirmLabel)); + await tester.pumpAndSettle(); +} + Future> _pumpJellyfinMovieMenu( WidgetTester tester, { required bool isAdministrator, @@ -935,6 +1387,34 @@ Future> _pumpJellyfinMovieMenu( String itemId = 'movie-1', bool serverOnline = true, List? requests, +}) { + return _pumpJellyfinItemMenu( + tester, + isAdministrator: isAdministrator, + handler: handler, + serverOnline: serverOnline, + requests: requests, + item: testMediaItem( + id: itemId, + backend: MediaBackend.jellyfin, + kind: MediaKind.movie, + title: 'Movie', + serverId: 'srv-1', + ), + ); +} + +Future> _pumpJellyfinItemMenu( + WidgetTester tester, { + required bool isAdministrator, + required Future Function(http.Request request) handler, + required MediaItem item, + bool serverOnline = true, + List? requests, + + /// Push the menu onto a route above a home screen, so the navigator has + /// something below the menu that a stray pop could reach. + bool onPushedRoute = false, }) async { LocaleSettings.setLocaleSync(AppLocale.en); TvDetectionService.debugSetAppleTVOverride(true); @@ -959,12 +1439,15 @@ Future> _pumpJellyfinMovieMenu( }); final menuKey = GlobalKey(); - final item = testMediaItem( - id: itemId, - backend: MediaBackend.jellyfin, - kind: MediaKind.movie, - title: 'Movie', - serverId: 'srv-1', + final navigatorKey = GlobalKey(); + final menuScreen = Scaffold( + body: Center( + child: MediaContextMenu( + key: menuKey, + item: item, + child: const SizedBox(width: 120, height: 80, child: Text('delete target')), + ), + ), ); await tester.pumpWidget( TranslationProvider( @@ -975,20 +1458,17 @@ Future> _pumpJellyfinMovieMenu( ChangeNotifierProvider.value(value: offlineMode), ], child: MaterialApp( + navigatorKey: navigatorKey, theme: monoTheme(dark: true), - home: Scaffold( - body: Center( - child: MediaContextMenu( - key: menuKey, - item: item, - child: const SizedBox(width: 120, height: 80, child: Text('delete target')), - ), - ), - ), + home: onPushedRoute ? const Scaffold(body: Center(child: Text('home screen'))) : menuScreen, ), ), ), ); + if (onPushedRoute) { + unawaited(navigatorKey.currentState!.push(MaterialPageRoute(builder: (_) => menuScreen))); + await tester.pumpAndSettle(); + } return menuKey; }