From be06196168f267510ab8b24f59f0a762804732de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sun, 15 Mar 2026 20:44:59 -0400 Subject: [PATCH 1/6] fix(ui): update Bulgarian, Catalan, Danish, German, Greek, Spanish, Finnish, French, Galician, Russian, Slovenian, Swedish, Thai, Chinese (traditional) translations from POEditor (#5044) Co-authored-by: navidrome-bot --- resources/i18n/bg.json | 284 +++++++++++++++++++----------------- resources/i18n/ca.json | 22 ++- resources/i18n/da.json | 21 ++- resources/i18n/de.json | 22 ++- resources/i18n/el.json | 22 ++- resources/i18n/es.json | 22 ++- resources/i18n/fi.json | 22 ++- resources/i18n/fr.json | 22 ++- resources/i18n/gl.json | 22 ++- resources/i18n/ru.json | 22 ++- resources/i18n/sl.json | 28 ++-- resources/i18n/sv.json | 22 ++- resources/i18n/th.json | 26 +++- resources/i18n/zh-Hant.json | 166 +++++++++++---------- 14 files changed, 430 insertions(+), 293 deletions(-) diff --git a/resources/i18n/bg.json b/resources/i18n/bg.json index bce5a3a6e..626657129 100644 --- a/resources/i18n/bg.json +++ b/resources/i18n/bg.json @@ -31,13 +31,14 @@ "mood": "Настроение", "participants": "Допълнителни участници", "tags": "Допълнителни етикети", - "mappedTags": "", - "rawTags": "", + "mappedTags": "Картирани тагове", + "rawTags": "Сурови тагове", "bitDepth": "Битова дълбочина", - "sampleRate": "", + "sampleRate": "Честота на семплиране", "missing": "Липсва", - "libraryName": "", - "composer": "" + "libraryName": "Библиотека", + "composer": "Композитор", + "disc": "" }, "actions": { "addToQueue": "Пусни по-късно", @@ -47,8 +48,8 @@ "download": "Свали", "playNext": "Следваща", "info": "Информация", - "showInPlaylist": "", - "instantMix": "" + "showInPlaylist": "Показване в плейлиста", + "instantMix": "Незабавен микс" } }, "album": { @@ -80,7 +81,7 @@ "mood": "Настроение", "date": "Дата на запис", "missing": "Липсва", - "libraryName": "" + "libraryName": "Библиотека" }, "actions": { "playAll": "Пусни", @@ -129,12 +130,12 @@ "remixer": "Ремиксер |||| Ремиксери", "djmixer": "DJ миксер |||| DJ миксери", "performer": "Изпълнител |||| Изпълнители", - "maincredit": "" + "maincredit": "Изпълнител на албума или изпълнител |||| Изпълнители на албума или изпълнители" }, "actions": { - "shuffle": "", - "radio": "", - "topSongs": "" + "shuffle": "Разбъркване", + "radio": "Радио", + "topSongs": "Топ песни" } }, "user": { @@ -152,11 +153,11 @@ "newPassword": "Нова парола", "token": "Токен", "lastAccessAt": "Последен достъп", - "libraries": "" + "libraries": "Библиотеки" }, "helperTexts": { "name": "Промените в името ще бъдат отразени при следващото влизане", - "libraries": "" + "libraries": "Изберете конкретни библиотеки за този потребител или оставете празно, за да използвате библиотеки по подразбиране" }, "notifications": { "created": "Потребителят е създаден", @@ -166,11 +167,11 @@ "message": { "listenBrainzToken": "Въведете Вашия токен за ListenBrainz.", "clickHereForToken": "Кликнете тук, за да получите Вашия токен", - "selectAllLibraries": "", - "adminAutoLibraries": "" + "selectAllLibraries": "Изберете всички библиотеки", + "adminAutoLibraries": "Администраторите автоматично получават достъп до всички библиотеки" }, "validation": { - "librariesRequired": "" + "librariesRequired": "Трябва да бъде избрана поне една библиотека за потребители без администраторски права" } }, "player": { @@ -215,16 +216,22 @@ "export": "Експорт", "makePublic": "Направи публичен", "makePrivate": "Направи личен", - "saveQueue": "", - "searchOrCreate": "", - "pressEnterToCreate": "", - "removeFromSelection": "" + "saveQueue": "Запазване на опашката в плейлист", + "searchOrCreate": "Търсете в плейлисти или пишете, за да създадете нови...", + "pressEnterToCreate": "Натиснете Enter, за да създадете нов плейлист", + "removeFromSelection": "Премахване от селекцията", + "uploadCover": "", + "removeCover": "" }, "message": { "duplicate_song": "Добави дублирани песни", "song_exist": "Към плейлиста се добавят дублиращи. Желаете ли да ги добавите или предпочитате да ги пропуснете?", - "noPlaylistsFound": "", - "noPlaylists": "" + "noPlaylistsFound": "Няма намерени плейлисти", + "noPlaylists": "Няма налични плейлисти", + "coverUploaded": "", + "coverRemoved": "", + "coverUploadError": "", + "coverRemoveError": "" } }, "radio": { @@ -263,7 +270,7 @@ "path": "Път", "size": "Размер", "updatedAt": "Изчезнал на", - "libraryName": "" + "libraryName": "Библиотека" }, "actions": { "remove": "Премахни", @@ -275,134 +282,136 @@ "empty": "Няма липсващи файлове" }, "library": { - "name": "", + "name": "Библиотека |||| Библиотеки", "fields": { - "name": "", - "path": "", - "remotePath": "", - "lastScanAt": "", - "songCount": "", - "albumCount": "", - "artistCount": "", - "totalSongs": "", - "totalAlbums": "", - "totalArtists": "", - "totalFolders": "", - "totalFiles": "", - "totalMissingFiles": "", - "totalSize": "", - "totalDuration": "", - "defaultNewUsers": "", - "createdAt": "", - "updatedAt": "" + "name": "Име", + "path": "Път", + "remotePath": "Отдалечен път", + "lastScanAt": "Последно сканиране", + "songCount": "Песни", + "albumCount": "Албуми", + "artistCount": "Изпълнители", + "totalSongs": "Песни", + "totalAlbums": "Албуми", + "totalArtists": "Изпълнители", + "totalFolders": "Папки", + "totalFiles": "Файлове", + "totalMissingFiles": "Липсващи файлове", + "totalSize": "Общ размер", + "totalDuration": "Продължителност", + "defaultNewUsers": "По подразбиране за нови потребители", + "createdAt": "Създаден", + "updatedAt": "Актуализиран" }, "sections": { - "basic": "", - "statistics": "" + "basic": "Основна информация", + "statistics": "Статистика" }, "actions": { - "scan": "", - "manageUsers": "", - "viewDetails": "", + "scan": "Сканирай библиотеката", + "manageUsers": "Управление на потребителския достъп", + "viewDetails": "Преглед на подробности", "quickScan": "Quick Scan", - "fullScan": "" + "fullScan": "Пълно сканиране" }, "notifications": { - "created": "", - "updated": "", - "deleted": "", - "scanStarted": "", - "scanCompleted": "", - "quickScanStarted": "", - "fullScanStarted": "", - "scanError": "" + "created": "Библиотеката е създадена успешно", + "updated": "Библиотеката е актуализирана успешно", + "deleted": "Библиотеката е изтрита успешно", + "scanStarted": "Сканирането на библиотеката започна", + "scanCompleted": "Сканирането на библиотеката е завършено", + "quickScanStarted": "Бързото сканиране започна", + "fullScanStarted": "Пълното сканиране започна", + "scanError": "Грешка при стартиране на сканирането. Проверете лог файловете" }, "validation": { - "nameRequired": "", - "pathRequired": "", - "pathNotDirectory": "", - "pathNotFound": "", - "pathNotAccessible": "", - "pathInvalid": "" + "nameRequired": "Името на библиотеката е задължително", + "pathRequired": "Пътят към библиотеката е задължителен", + "pathNotDirectory": "Пътят до библиотеката трябва да е директория", + "pathNotFound": "Пътят към библиотеката не е намерен", + "pathNotAccessible": "Пътят до библиотеката не е достъпен", + "pathInvalid": "Невалиден път към библиотеката" }, "messages": { - "deleteConfirm": "", - "scanInProgress": "", - "noLibrariesAssigned": "" + "deleteConfirm": "Сигурни ли сте, че желаете да изтриете тази библиотека? Това ще премахне всички свързани данни и потребителски достъп.", + "scanInProgress": "Сканирането е в ход...", + "noLibrariesAssigned": "Няма библиотеки, присвоени на този потребител" } }, "plugin": { - "name": "", + "name": "Плъгин |||| Плъгини", "fields": { - "id": "", - "name": "", - "description": "", - "version": "", - "author": "", - "website": "", - "permissions": "", - "enabled": "", - "status": "", - "path": "", - "lastError": "", - "hasError": "", - "updatedAt": "", - "createdAt": "", - "configKey": "", - "configValue": "", - "allUsers": "", - "selectedUsers": "", - "allLibraries": "", - "selectedLibraries": "" + "id": "ID номер", + "name": "Име", + "description": "Описание", + "version": "Версия", + "author": "Автор", + "website": "Уебсайт", + "permissions": "Разрешения", + "enabled": "Активирано", + "status": "Статус", + "path": "Път", + "lastError": "Грешка", + "hasError": "Грешка", + "updatedAt": "Актуализирано", + "createdAt": "Инсталирано", + "configKey": "Ключ", + "configValue": "Стойност", + "allUsers": "Разрешаване на всички потребители", + "selectedUsers": "Избрани потребители", + "allLibraries": "Разрешаване на всички библиотеки", + "selectedLibraries": "Избрани библиотеки", + "allowWriteAccess": "" }, "sections": { - "status": "", - "info": "", - "configuration": "", - "manifest": "", - "usersPermission": "", - "libraryPermission": "" + "status": "Статус", + "info": "Информация за плъгина", + "configuration": "Конфигурация", + "manifest": "Манифест", + "usersPermission": "Права за потребители", + "libraryPermission": "Права за библиотека" }, "status": { - "enabled": "", - "disabled": "" + "enabled": "Активирано", + "disabled": "Деактивирано" }, "actions": { - "enable": "", - "disable": "", - "disabledDueToError": "", - "disabledUsersRequired": "", - "disabledLibrariesRequired": "", - "addConfig": "", - "rescan": "" + "enable": "Активирай", + "disable": "Деактивирай", + "disabledDueToError": "Поправете грешката преди активиране", + "disabledUsersRequired": "Изберете потребители преди активиране", + "disabledLibrariesRequired": "Изберете библиотеки преди активиране", + "addConfig": "Добавяне на конфигурация", + "rescan": "Повторно сканиране" }, "notifications": { - "enabled": "", - "disabled": "", - "updated": "", - "error": "" + "enabled": "Плъгинът е активиран", + "disabled": "Плъгинът е деактивиран", + "updated": "Плъгинът е актуализиран", + "error": "Грешка при актуализиране на плъгина" }, "validation": { - "invalidJson": "" + "invalidJson": "Конфигурацията трябва да е валиден JSON" }, "messages": { - "configHelp": "", - "clickPermissions": "", - "noConfig": "", - "allUsersHelp": "", - "noUsers": "", - "permissionReason": "", - "usersRequired": "", - "allLibrariesHelp": "", - "noLibraries": "", - "librariesRequired": "", - "requiredHosts": "", - "configValidationError": "", - "schemaRenderError": "" + "configHelp": "Конфигурирайте плъгина, използвайки двойки ключ-стойност. Оставете празно, ако плъгинът не изисква конфигурация.", + "clickPermissions": "Кликнете върху разрешение за подробности", + "noConfig": "Няма зададена конфигурация", + "allUsersHelp": "Когато е активиран, плъгинът ще има достъп до всички потребители, включително тези, създадени в бъдеще.", + "noUsers": "Няма избрани потребители", + "permissionReason": "Причина", + "usersRequired": "Този плъгин изисква достъп до потребителска информация. Изберете до кои потребители плъгинът може да има достъп или активирайте „Разрешаване на всички потребители“.", + "allLibrariesHelp": "Когато е активиран, плъгинът ще има достъп до всички библиотеки, включително тези, създадени в бъдеще.", + "noLibraries": "Няма избрани библиотеки", + "librariesRequired": "Този плъгин изисква достъп до информация за библиотеката. Изберете до кои библиотеки плъгинът може да има достъп или активирайте „Разрешаване на всички библиотеки“.", + "requiredHosts": "Необходими хостове", + "configValidationError": "Валидирането на конфигурацията не бе успешно:", + "schemaRenderError": "Не може да се изобрази формята за конфигурация. Схемата на плъгина може да е невалидна.", + "allowWriteAccessHelp": "" }, "placeholders": { - "configKey": "", - "configValue": "" + "configKey": "ключ", + "configValue": "стойност" } } }, @@ -586,9 +595,9 @@ "remove_missing_content": "Сигурни ли сте, че желаете да премахнете избраните липсващи файлове от базата данни? Това ще премахне завинаги всички препратки към тях, включително броя на възпроизвежданията и оценките им.", "remove_all_missing_title": "Премахни всички липсващи файлове", "remove_all_missing_content": "Сигурни ли сте, че желаете да премахнете всички липсващи файлове от базата данни? Това ще премахне завинаги всички препратки към тях, включително броя на възпроизвежданията и оценките им.", - "noSimilarSongsFound": "", - "noTopSongsFound": "", - "startingInstantMix": "" + "noSimilarSongsFound": "Не са намерени подобни песни", + "noTopSongsFound": "Няма намерени топ песни", + "startingInstantMix": "Зареждане на незабавен микс..." }, "menu": { "library": "Библиотека", @@ -619,10 +628,10 @@ "playlists": "Плейлисти", "sharedPlaylists": "Споделени плейлисти", "librarySelector": { - "allLibraries": "", - "multipleLibraries": "", - "selectLibraries": "", - "none": "" + "allLibraries": "Всички библиотеки (%{count})", + "multipleLibraries": "%{selected} от %{total} библиотеки", + "selectLibraries": "Изберете библиотеки", + "none": "Няма" } }, "player": { @@ -655,7 +664,7 @@ "homepage": "Начална страница", "source": "Програмен код", "featureRequests": "Заявете функционалност", - "lastInsightsCollection": "", + "lastInsightsCollection": "Последна колекция от анализи", "insights": { "disabled": "Деактивиран", "waiting": "Изчакване" @@ -669,12 +678,13 @@ "configName": "Име на конфигурация", "environmentVariable": "Променлива на средата", "currentValue": "Текуща стойност", - "configurationFile": "", + "configurationFile": "Конфигурационен файл", "exportToml": "Експортиране на конфигурация (TOML)", "exportSuccess": "Конфигурация, експортирана в клипборда във формат TOML", "exportFailed": "Неуспешно копиране на конфигурация", - "devFlagsHeader": "", - "devFlagsComment": "" + "devFlagsHeader": "Флагове за разработка (подлежащи на промяна/премахване)", + "devFlagsComment": "Това са експериментални настройки и е възможно да бъдат премахнати в бъдещи версии.", + "downloadToml": "Изтегляне на конфигурация (TOML)" } }, "activity": { @@ -687,7 +697,7 @@ "scanType": "Последно сканиране", "status": "Грешка при сканиране", "elapsedTime": "Изминало време", - "selectiveScan": "" + "selectiveScan": "Селективен" }, "help": { "title": "Бързи клавиши на Navidrome", @@ -704,8 +714,8 @@ } }, "nowPlaying": { - "title": "", - "empty": "", - "minutesAgo": "" + "title": "Сега свири", + "empty": "Нищо не се възпроизвежда", + "minutesAgo": "преди %{smart_count} минута |||| преди %{smart_count} минути" } } \ No newline at end of file diff --git a/resources/i18n/ca.json b/resources/i18n/ca.json index 264a76639..079c130d1 100644 --- a/resources/i18n/ca.json +++ b/resources/i18n/ca.json @@ -37,7 +37,8 @@ "sampleRate": "Freqüencia de mostreig", "missing": "Desaparegut", "libraryName": "Biblioteca", - "composer": "Compositor" + "composer": "Compositor", + "disc": "" }, "actions": { "addToQueue": "Reprodueix després", @@ -218,13 +219,19 @@ "saveQueue": "Desar la cua a una llista", "searchOrCreate": "Cerca llistes o escriu per crear-ne de noves...", "pressEnterToCreate": "Prem Retorn per crear una nova llista", - "removeFromSelection": "Elimina de la selecció" + "removeFromSelection": "Elimina de la selecció", + "uploadCover": "", + "removeCover": "" }, "message": { "duplicate_song": "Afegeix cançons duplicades", "song_exist": "Heu afegit duplicats a la llista. Voleu afegir-los o ignorar-los?", "noPlaylistsFound": "No s'ha trobat cap llista", - "noPlaylists": "No hi ha cap llista disponible" + "noPlaylists": "No hi ha cap llista disponible", + "coverUploaded": "", + "coverRemoved": "", + "coverUploadError": "", + "coverRemoveError": "" } }, "radio": { @@ -353,7 +360,8 @@ "allUsers": "Permet tots els usuaris", "selectedUsers": "Usuaris seleccionats", "allLibraries": "Permet totes les llibreries", - "selectedLibraries": "Biblioteques seleccionades" + "selectedLibraries": "Biblioteques seleccionades", + "allowWriteAccess": "" }, "sections": { "status": "Estat", @@ -398,7 +406,8 @@ "librariesRequired": "Aquest controlador necessita accedir a la informació de la biblioteca. Selecciona a quines biblioteques pot accedir o activa «Permet totes les biblioteques».", "requiredHosts": "Hosts requerits", "configValidationError": "Ha fallat la validació de la configuració:", - "schemaRenderError": "No s'ha pogut renderitzar el formulari de configuració. És possible que l'esquema del controlador sigui invàlid." + "schemaRenderError": "No s'ha pogut renderitzar el formulari de configuració. És possible que l'esquema del controlador sigui invàlid.", + "allowWriteAccessHelp": "" }, "placeholders": { "configKey": "clau", @@ -674,7 +683,8 @@ "exportSuccess": "Configuració exportada al porta-retalls en format TOML", "exportFailed": "La còpia de la configuració ha fallat", "devFlagsHeader": "Indicadors de desenvolupament (subjecte a canvis o eliminació)", - "devFlagsComment": "Aquests paràmetres són experimentals i és possible que s'eliminin en versions futures" + "devFlagsComment": "Aquests paràmetres són experimentals i és possible que s'eliminin en versions futures", + "downloadToml": "Descarrega la configuració (TOML)" } }, "activity": { diff --git a/resources/i18n/da.json b/resources/i18n/da.json index 01d0856d6..a7c090cca 100644 --- a/resources/i18n/da.json +++ b/resources/i18n/da.json @@ -37,7 +37,8 @@ "sampleRate": "Samplingfrekvens", "missing": "Manglende", "libraryName": "Bibliotek", - "composer": "Komponist" + "composer": "Komponist", + "disc": "" }, "actions": { "addToQueue": "Afspil senere", @@ -218,13 +219,19 @@ "saveQueue": "Gem kø på afspilningsliste", "searchOrCreate": "Søg i afspilningslister eller skriv for at oprette nye...", "pressEnterToCreate": "Tryk Enter for at oprette en ny afspilningsliste", - "removeFromSelection": "Fjern fra valg" + "removeFromSelection": "Fjern fra valg", + "uploadCover": "", + "removeCover": "" }, "message": { "duplicate_song": "Tilføj dubletter af sange", "song_exist": "Der føjes dubletter til playlisten", "noPlaylistsFound": "Ingen playlister fundet", - "noPlaylists": "Ingen tilgængelige playlister" + "noPlaylists": "Ingen tilgængelige playlister", + "coverUploaded": "", + "coverRemoved": "", + "coverUploadError": "", + "coverRemoveError": "" } }, "radio": { @@ -353,7 +360,8 @@ "allUsers": "Tillad alle brugere", "selectedUsers": "Valgte brugere", "allLibraries": "Tillad alle biblioteker", - "selectedLibraries": "Valgte biblioteker" + "selectedLibraries": "Valgte biblioteker", + "allowWriteAccess": "" }, "sections": { "status": "Status", @@ -398,7 +406,8 @@ "librariesRequired": "Dette plugin kræver adgang til biblioteksoplysninger. Vælg hvilke biblioteker pluginet kan tilgå, eller aktivér 'Tillad alle biblioteker'.", "requiredHosts": "Påkrævede hosts", "configValidationError": "Konfigurationsvalidering mislykkedes:", - "schemaRenderError": "Kan ikke vise konfigurationsformularen. Pluginets skema er muligvis ugyldigt." + "schemaRenderError": "Kan ikke vise konfigurationsformularen. Pluginets skema er muligvis ugyldigt.", + "allowWriteAccessHelp": "" }, "placeholders": { "configKey": "nøgle", @@ -675,7 +684,7 @@ "exportFailed": "Kunne ikke kopiere konfigurationen", "devFlagsHeader": "Udviklingsflagget (med forbehold for ændring/fjernelse)", "devFlagsComment": "Disse er eksperimental-indstillinger og kan blive fjernet i fremtidige udgaver", - "downloadToml": "" + "downloadToml": "Download konfigurationen (TOML)" } }, "activity": { diff --git a/resources/i18n/de.json b/resources/i18n/de.json index 568c65c51..6ba8a14eb 100644 --- a/resources/i18n/de.json +++ b/resources/i18n/de.json @@ -37,7 +37,8 @@ "sampleRate": "Samplerate", "missing": "Fehlend", "libraryName": "Bibliothek", - "composer": "Komponist" + "composer": "Komponist", + "disc": "" }, "actions": { "addToQueue": "Später abspielen", @@ -218,13 +219,19 @@ "saveQueue": "Warteschlange in Wiedergabeliste speichern", "searchOrCreate": "Wiedergabeliste suchen oder neue erstellen...", "pressEnterToCreate": "Enter drücken um neue Wiedergabeliste zu erstellen", - "removeFromSelection": "Von Auswahl entfernen" + "removeFromSelection": "Von Auswahl entfernen", + "uploadCover": "Cover hochladen", + "removeCover": "Cover entfernen" }, "message": { "duplicate_song": "Duplikate hinzufügen", "song_exist": "Manche Titel sind bereits in der Playlist. Möchtest du sie trotzdem hinzufügen oder überspringen?", "noPlaylistsFound": "Keine Wiedergabeliste gefunden", - "noPlaylists": "Keine Wiedergabelisten vorhanden" + "noPlaylists": "Keine Wiedergabelisten vorhanden", + "coverUploaded": "Cover aktualisiert", + "coverRemoved": "Cover entfernt", + "coverUploadError": "Fehler beim Hochladen des Covers", + "coverRemoveError": "Fehler beim Entfernen des Covers" } }, "radio": { @@ -353,7 +360,8 @@ "allUsers": "Alle Benutzer", "selectedUsers": "Ausgewählte Benutzer", "allLibraries": "Alle Bibliotheken", - "selectedLibraries": "Ausgewählte Bibliotheken" + "selectedLibraries": "Ausgewählte Bibliotheken", + "allowWriteAccess": "Schreibzugriff erlauben" }, "sections": { "status": "Status", @@ -398,7 +406,8 @@ "librariesRequired": "Dieses Plugin benötigt Zugriff auf Bibliotheken. Wähle aus, auf welche Bibliotheken das Plugin zugreifen darf oder wähle 'Alle Bibliotheken'.", "requiredHosts": "Benötigte Hosts", "configValidationError": "Validierung der Konfiguration fehlgeschlagen:", - "schemaRenderError": "Rendern der Konfiguration fehlgeschlagen. Das Schema das Plugins ist eventuell nicht korrekt." + "schemaRenderError": "Rendern der Konfiguration fehlgeschlagen. Das Schema das Plugins ist eventuell nicht korrekt.", + "allowWriteAccessHelp": "Wenn aktiviert, kann das Plugin Dateien in den Bibliotheken verändern. Als Standard haben Plugins nur Lesezugriff." }, "placeholders": { "configKey": "Schlüssel", @@ -674,7 +683,8 @@ "exportSuccess": "Konfiguration im TOML Format in die Zwischenablage kopiert", "exportFailed": "Fehler beim Kopieren der Konfiguration", "devFlagsHeader": "Entwicklungseinstellungen (können sich ändern)", - "devFlagsComment": "Experimentelle Einstellungen, die eventuell in Zukunft entfernt oder geändert werden" + "devFlagsComment": "Experimentelle Einstellungen, die eventuell in Zukunft entfernt oder geändert werden", + "downloadToml": "Konfiguration Herunterladen (TOML)" } }, "activity": { diff --git a/resources/i18n/el.json b/resources/i18n/el.json index 02d0b06c4..e5d983d6f 100644 --- a/resources/i18n/el.json +++ b/resources/i18n/el.json @@ -37,7 +37,8 @@ "sampleRate": "Ποσοστό δειγματοληψίας", "missing": "Απών", "libraryName": "Βιβλιοθήκη", - "composer": "Συνθέτης" + "composer": "Συνθέτης", + "disc": "" }, "actions": { "addToQueue": "Αναπαραγωγη Μετα", @@ -218,13 +219,19 @@ "saveQueue": "Αποθήκευση ουράς στη λίστα αναπαραγωγής", "searchOrCreate": "Αναζητήστε λίστες αναπαραγωγής ή πληκτρολογήστε για να δημιουργήσετε νέες...", "pressEnterToCreate": "Πατήστε Enter για να δημιουργήσετε νέα λίστα αναπαραγωγής", - "removeFromSelection": "Αφαίρεση από την επιλογή" + "removeFromSelection": "Αφαίρεση από την επιλογή", + "uploadCover": "", + "removeCover": "" }, "message": { "duplicate_song": "Προσθήκη διπλοεγγραφών τραγουδιών", "song_exist": "Υπάρχουν διπλοεγγραφές στην λίστα αναπαραγωγής. Θέλετε να προστεθούν οι διπλοεγγραφές ή να τις παραβλέψετε?", "noPlaylistsFound": "Δεν βρέθηκαν λίστες αναπαραγωγής", - "noPlaylists": "Δεν υπάρχουν διαθέσιμες λίστες αναπαραγωγής" + "noPlaylists": "Δεν υπάρχουν διαθέσιμες λίστες αναπαραγωγής", + "coverUploaded": "", + "coverRemoved": "", + "coverUploadError": "", + "coverRemoveError": "" } }, "radio": { @@ -353,7 +360,8 @@ "allUsers": "Επιτρέψτε όλους τους χρήστες", "selectedUsers": "Επιλογή χρηστών", "allLibraries": "Επιτρέψτε όλες τις βιβλιοθήκες", - "selectedLibraries": "Επιλεγμένες βιβλιοθήκες" + "selectedLibraries": "Επιλεγμένες βιβλιοθήκες", + "allowWriteAccess": "" }, "sections": { "status": "Κατάσταση", @@ -398,7 +406,8 @@ "librariesRequired": "Αυτό το πρόσθετο απαιτεί πρόσβαση στις πληροφορίες βιβλιοθήκης. Επιλέξτε σε ποιές βιβλιοθήκες μπορεί να έχει πρόσβαση το πρόσθετο, ή ενεργοποιήστε το 'Επιτρέψτε όλες τις βιβλιοθήκες'", "requiredHosts": "Απαιτούμενοι hosts", "configValidationError": "Η επικύρωση διαμόρφωσης απέτυχε:", - "schemaRenderError": "Δεν είναι δυνατή η απόδοση της φόρμας διαμόρφωσης. Το σχήμα της προσθήκης ενδέχεται να μην είναι έγκυρο." + "schemaRenderError": "Δεν είναι δυνατή η απόδοση της φόρμας διαμόρφωσης. Το σχήμα της προσθήκης ενδέχεται να μην είναι έγκυρο.", + "allowWriteAccessHelp": "" }, "placeholders": { "configKey": "κλειδί", @@ -674,7 +683,8 @@ "exportSuccess": "Η διαμόρφωση εξήχθη στο πρόχειρο σε μορφή TOML", "exportFailed": "Η αντιγραφή της διαμόρφωσης απέτυχε", "devFlagsHeader": "Σημαίες Ανάπτυξης (υπόκειται σε αλλαγές / αφαίρεση)", - "devFlagsComment": "Αυτές είναι πειραματικές ρυθμίσεις και ενδέχεται να καταργηθούν σε μελλοντικές εκδόσεις" + "devFlagsComment": "Αυτές είναι πειραματικές ρυθμίσεις και ενδέχεται να καταργηθούν σε μελλοντικές εκδόσεις", + "downloadToml": "Λήψη διαμόρφωσης (TOML)" } }, "activity": { diff --git a/resources/i18n/es.json b/resources/i18n/es.json index 38c1379c9..ef246a0e6 100644 --- a/resources/i18n/es.json +++ b/resources/i18n/es.json @@ -37,7 +37,8 @@ "sampleRate": "Frecuencia de muestreo", "missing": "Faltante", "libraryName": "Biblioteca", - "composer": "Compositor" + "composer": "Compositor", + "disc": "" }, "actions": { "addToQueue": "Reproducir después", @@ -218,13 +219,19 @@ "saveQueue": "Guardar la fila de reproducción en una playlist", "searchOrCreate": "Buscar listas de reproducción o escribe para crear una nueva…", "pressEnterToCreate": "Pulsa Enter para crear una nueva lista de reproducción", - "removeFromSelection": "Quitar de la selección" + "removeFromSelection": "Quitar de la selección", + "uploadCover": "", + "removeCover": "" }, "message": { "duplicate_song": "Algunas de las canciones seleccionadas están presentes en la playlist", "song_exist": "Se están agregando duplicados a la playlist. ¿Quieres agregar los duplicados o omitirlos?", "noPlaylistsFound": "No se encontraron listas de reproducción", - "noPlaylists": "No hay listas de reproducción disponibles" + "noPlaylists": "No hay listas de reproducción disponibles", + "coverUploaded": "", + "coverRemoved": "", + "coverUploadError": "", + "coverRemoveError": "" } }, "radio": { @@ -353,7 +360,8 @@ "allUsers": "Permitir todos los usuarios", "selectedUsers": "Usuarios seleccionados", "allLibraries": "Permitir todas las bibliotecas", - "selectedLibraries": "Bibliotecas seleccionadas" + "selectedLibraries": "Bibliotecas seleccionadas", + "allowWriteAccess": "" }, "sections": { "status": "Estado", @@ -398,7 +406,8 @@ "librariesRequired": "Este plugin requiere acceso a la información de las bibliotecas. Selecciona a qué bibliotecas puede acceder el plugin, o activa 'Permitir todas las bibliotecas'.", "requiredHosts": "Hosts requeridos", "configValidationError": "La validación de la configuración falló:", - "schemaRenderError": "No se pudo renderizar el formulario de configuración. Es posible que el esquema del complemento no sea válido." + "schemaRenderError": "No se pudo renderizar el formulario de configuración. Es posible que el esquema del complemento no sea válido.", + "allowWriteAccessHelp": "" }, "placeholders": { "configKey": "clave", @@ -674,7 +683,8 @@ "exportSuccess": "Configuración exportada al portapapeles en formato TOML", "exportFailed": "Error al copiar la configuración", "devFlagsHeader": "Indicadores de desarrollo (sujetos a cambios o eliminación)", - "devFlagsComment": "Estas son configuraciones experimentales y pueden eliminarse en versiones futuras" + "devFlagsComment": "Estas son configuraciones experimentales y pueden eliminarse en versiones futuras", + "downloadToml": "Descargar la configuración (TOML)" } }, "activity": { diff --git a/resources/i18n/fi.json b/resources/i18n/fi.json index 0d260fb44..ea5d22f4c 100644 --- a/resources/i18n/fi.json +++ b/resources/i18n/fi.json @@ -37,7 +37,8 @@ "sampleRate": "Näytteenottotaajuus", "missing": "Puuttuva", "libraryName": "Kirjasto", - "composer": "Säveltäjä" + "composer": "Säveltäjä", + "disc": "" }, "actions": { "addToQueue": "Lisää jonoon", @@ -218,13 +219,19 @@ "saveQueue": "Tallenna jono soittolistaan", "searchOrCreate": "Etsi soittolistoja tai kirjoita luodaksesi uuden...", "pressEnterToCreate": "Paina Enter luodaksesi uuden soittolistan", - "removeFromSelection": "Poista valinnasta" + "removeFromSelection": "Poista valinnasta", + "uploadCover": "Lataa kansikuva", + "removeCover": "Poista kansikuva" }, "message": { "duplicate_song": "Lisää olemassa oleva kappale", "song_exist": "Olet lisäämässä soittolistalla jo olevaa kappaletta. Haluatko lisätä saman kappaleen vai ohittaa sen?", "noPlaylistsFound": "Soittolistoja ei löytynyt", - "noPlaylists": "Soittolistoja ei ole saatavilla" + "noPlaylists": "Soittolistoja ei ole saatavilla", + "coverUploaded": "Kansikuva päivitetty", + "coverRemoved": "Kansikuva poistettu", + "coverUploadError": "Virhe ladattaessa kansikuvaa", + "coverRemoveError": "Virhe poistettaessa kansikuvaa" } }, "radio": { @@ -353,7 +360,8 @@ "allUsers": "Salli kaikki käyttäjät", "selectedUsers": "Valitut käyttäjät", "allLibraries": "Salli kaikki kirjastot", - "selectedLibraries": "Valitut kirjastot" + "selectedLibraries": "Valitut kirjastot", + "allowWriteAccess": "Salli kirjoitusoikeus" }, "sections": { "status": "Tila", @@ -398,7 +406,8 @@ "librariesRequired": "Tämä laajennus vaatii pääsyn kirjastotietoihin. Valitse, mihin kirjastoihin laajennus voi käyttää, tai ota käyttöön 'Salli kaikki kirjastot'.", "requiredHosts": "Vaaditut palvelimet", "configValidationError": "Määrityksen validointi epäonnistui:", - "schemaRenderError": "Konfiguraatiolomaketta ei voi näyttää. Lisäosan skeema saattaa olla virheellinen." + "schemaRenderError": "Konfiguraatiolomaketta ei voi näyttää. Lisäosan skeema saattaa olla virheellinen.", + "allowWriteAccessHelp": "Kun otettu käyttöön, liitännäinen voi muokata tiedostoja kirjastohakemistoissa. Oletuksena liitännäisillä on vain luku -oikeus." }, "placeholders": { "configKey": "avain", @@ -674,7 +683,8 @@ "exportSuccess": "Määritykset viety leikepöydälle TOML-muodossa", "exportFailed": "Määritysten kopiointi epäonnistui", "devFlagsHeader": "Kehitysliput (voivat muuttua/poistua)", - "devFlagsComment": "Nämä ovat kokeellisia asetuksia ja ne voidaan poistaa tulevissa versioissa" + "devFlagsComment": "Nämä ovat kokeellisia asetuksia ja ne voidaan poistaa tulevissa versioissa", + "downloadToml": "Lataa määritykset (TOML)" } }, "activity": { diff --git a/resources/i18n/fr.json b/resources/i18n/fr.json index 66bd454cc..57af0b8cd 100644 --- a/resources/i18n/fr.json +++ b/resources/i18n/fr.json @@ -37,7 +37,8 @@ "sampleRate": "Fréquence d'échantillonnage", "missing": "Manquant", "libraryName": "Bibliothèque", - "composer": "Compositeur·e" + "composer": "Compositeur·e", + "disc": "" }, "actions": { "addToQueue": "Ajouter à la file", @@ -218,13 +219,19 @@ "saveQueue": "Sauvegarder la file de lecture dans la playlist", "searchOrCreate": "Chercher ou créer une nouvelle playlist...", "pressEnterToCreate": "Appuyer sur entrée pour créer une nouvelle playlist", - "removeFromSelection": "Supprimer de la sélection" + "removeFromSelection": "Supprimer de la sélection", + "uploadCover": "", + "removeCover": "" }, "message": { "duplicate_song": "Ajouter les titres déjà présents dans la playlist", "song_exist": "Certains des titres sélectionnés font déjà partie de la playlist. Voulez-vous les ajouter ou les ignorer ?", "noPlaylistsFound": "Aucune playlist trouvée", - "noPlaylists": "Aucune playlist disponible" + "noPlaylists": "Aucune playlist disponible", + "coverUploaded": "", + "coverRemoved": "", + "coverUploadError": "", + "coverRemoveError": "" } }, "radio": { @@ -353,7 +360,8 @@ "allUsers": "Autoriser tous les utilisateur·rices", "selectedUsers": "Utilisateur·rices sélectionné.e.s", "allLibraries": "Autoriser toutes les bibliothèques", - "selectedLibraries": "Bibliothèques sélectionnées" + "selectedLibraries": "Bibliothèques sélectionnées", + "allowWriteAccess": "" }, "sections": { "status": "Statut", @@ -398,7 +406,8 @@ "librariesRequired": "Cette extension nécessite l'accès aux information de la bibliothèque. Sélectionnez à quelles bibliothèque cette extension a accès, ou sélectionnez 'Autoriser toutes les bibliothèques'.", "requiredHosts": "Hôtes requis", "configValidationError": "Erreur lors de la validation de la configuration", - "schemaRenderError": "Impossible de processer la configuration. Le schéma de l'extension n'est peut-être pas valide." + "schemaRenderError": "Impossible de processer la configuration. Le schéma de l'extension n'est peut-être pas valide.", + "allowWriteAccessHelp": "" }, "placeholders": { "configKey": "clef", @@ -674,7 +683,8 @@ "exportSuccess": "La configuration a été copiée vers le presse-papier au format TOML", "exportFailed": "Une erreur est survenue en copiant la configuration", "devFlagsHeader": "Options de développement (peuvent être amenés à changer / être supprimés)", - "devFlagsComment": "Ces paramètres sont expérimentaux et peuvent être amenés à changer dans le futur" + "devFlagsComment": "Ces paramètres sont expérimentaux et peuvent être amenés à changer dans le futur", + "downloadToml": "Télécharger la configuration (TOML)" } }, "activity": { diff --git a/resources/i18n/gl.json b/resources/i18n/gl.json index 32d0d919f..c10560912 100644 --- a/resources/i18n/gl.json +++ b/resources/i18n/gl.json @@ -37,7 +37,8 @@ "sampleRate": "Taxa de mostra", "missing": "Falta", "libraryName": "Biblioteca", - "composer": "Composición" + "composer": "Composición", + "disc": "" }, "actions": { "addToQueue": "Ao final da cola", @@ -218,13 +219,19 @@ "saveQueue": "Salvar a Cola como Lista de reprodución", "searchOrCreate": "Buscar listas ou escribe para crear nova…", "pressEnterToCreate": "Preme Enter para crear nova lista", - "removeFromSelection": "Retirar da selección" + "removeFromSelection": "Retirar da selección", + "uploadCover": "", + "removeCover": "" }, "message": { "duplicate_song": "Engadir cancións duplicadas", "song_exist": "Hai duplicadas que serán engadidas á lista de reprodución. Desexas engadir as duplicadas ou omitilas?", "noPlaylistsFound": "Sen listas de reprodución", - "noPlaylists": "Sen listas dispoñibles" + "noPlaylists": "Sen listas dispoñibles", + "coverUploaded": "", + "coverRemoved": "", + "coverUploadError": "", + "coverRemoveError": "" } }, "radio": { @@ -353,7 +360,8 @@ "allUsers": "Para todas as usuarias", "selectedUsers": "Usuarias seleccionadas", "allLibraries": "Permitir todas as bibliotecas", - "selectedLibraries": "Selecciona bibliotecas" + "selectedLibraries": "Selecciona bibliotecas", + "allowWriteAccess": "" }, "sections": { "status": "Estado", @@ -398,7 +406,8 @@ "librariesRequired": "O complemento precisa acceso á información sobre a biblioteca. Selecciona as bibliotecas ás que pode acceder, ou activa 'Todas as bibliotecas'.", "requiredHosts": "Servidores requeridos", "configValidationError": "Fallou a comprobación da configuración:", - "schemaRenderError": "Non se puido aplicar a configuración. O esquema do complemento podería non ser válido." + "schemaRenderError": "Non se puido aplicar a configuración. O esquema do complemento podería non ser válido.", + "allowWriteAccessHelp": "" }, "placeholders": { "configKey": "clave", @@ -674,7 +683,8 @@ "exportSuccess": "Configuración exportada ao portapapeis no formato TOML", "exportFailed": "Fallou a copia da configuración", "devFlagsHeader": "Configuracións de Desenvolvemento (suxeitas a cambio/retirada)", - "devFlagsComment": "Son axustes experimentais e poden retirarse en futuras versións" + "devFlagsComment": "Son axustes experimentais e poden retirarse en futuras versións", + "downloadToml": "Descargar configuración (TOML)" } }, "activity": { diff --git a/resources/i18n/ru.json b/resources/i18n/ru.json index 5b20c3e19..6f0439a38 100644 --- a/resources/i18n/ru.json +++ b/resources/i18n/ru.json @@ -37,7 +37,8 @@ "sampleRate": "Частота дискретизации (Hz)", "missing": "Поле отсутствует", "libraryName": "Библиотека", - "composer": "Композитор" + "composer": "Композитор", + "disc": "" }, "actions": { "addToQueue": "В очередь", @@ -218,13 +219,19 @@ "saveQueue": "Сохранить очередь в плейлист", "searchOrCreate": "Поиск плейлистов или введите текст для создания новых...", "pressEnterToCreate": "Нажмите Enter, чтобы создать новый список воспроизведения", - "removeFromSelection": "Удалить из списка выделенных" + "removeFromSelection": "Удалить из списка выделенных", + "uploadCover": "", + "removeCover": "" }, "message": { "duplicate_song": "Повторяющиеся треки", "song_exist": "Некоторые треки уже есть в плейлисте. Вы хотите добавить их или пропустить?", "noPlaylistsFound": "Плейлисты не найдены", - "noPlaylists": "Нет доступных плейлистов" + "noPlaylists": "Нет доступных плейлистов", + "coverUploaded": "", + "coverRemoved": "", + "coverUploadError": "", + "coverRemoveError": "" } }, "radio": { @@ -353,7 +360,8 @@ "allUsers": "Разрешить всем пользователям", "selectedUsers": "Выбранные пользователи", "allLibraries": "Разрешить доступ ко всем библиотекам", - "selectedLibraries": "Избранные библиотеки" + "selectedLibraries": "Избранные библиотеки", + "allowWriteAccess": "" }, "sections": { "status": "Статус", @@ -398,7 +406,8 @@ "librariesRequired": "Этому плагину требуется доступ к библиотечной информации. Выберите, к каким библиотекам плагин может получить доступ, или включите \"Разрешить все библиотеки\".", "requiredHosts": "Необходимые хосты", "configValidationError": "Проверка конфигурации завершилась неудачей:", - "schemaRenderError": "Не удалось отобразить форму конфигурации. Возможно, схема плагина недействительна." + "schemaRenderError": "Не удалось отобразить форму конфигурации. Возможно, схема плагина недействительна.", + "allowWriteAccessHelp": "" }, "placeholders": { "configKey": "ключ", @@ -674,7 +683,8 @@ "exportSuccess": "Конфигурация экспортирована в буфер обмена в формате TOML", "exportFailed": "Не удалось скопировать конфигурацию", "devFlagsHeader": "Флаги разработки (могут быть изменены/удалены)", - "devFlagsComment": "Это экспериментальные настройки, которые могут быть удалены в будущих версиях." + "devFlagsComment": "Это экспериментальные настройки, которые могут быть удалены в будущих версиях.", + "downloadToml": "Скачать конфигурацию (TOML)" } }, "activity": { diff --git a/resources/i18n/sl.json b/resources/i18n/sl.json index f499d6ad5..2ea1cad49 100644 --- a/resources/i18n/sl.json +++ b/resources/i18n/sl.json @@ -37,7 +37,8 @@ "sampleRate": "Frekvenca vzorčenja", "missing": "Manjka", "libraryName": "Knjižnica", - "composer": "Skladatelj" + "composer": "Skladatelj", + "disc": "" }, "actions": { "addToQueue": "Predvajaj kasneje", @@ -48,7 +49,7 @@ "playNext": "Naslednji", "info": "Več informacij", "showInPlaylist": "Prikaži na seznamu predvajanja", - "instantMix": "" + "instantMix": "Instant Mix" } }, "album": { @@ -218,13 +219,19 @@ "saveQueue": "Shrani čakalno vrsto na seznam predvajanja", "searchOrCreate": "Iščite po seznamih predvajanja ali vnesite besedilo, da ustvarite nove ...", "pressEnterToCreate": "Pritisnite Enter za ustvarjanje novega seznama predvajanja", - "removeFromSelection": "Odstrani iz izbora" + "removeFromSelection": "Odstrani iz izbora", + "uploadCover": "", + "removeCover": "" }, "message": { "duplicate_song": "Dodaj podvojene pesmi", "song_exist": "Seznamu predvajanja boste dodali duplikate. Jih želite dodati ali izpustiti?", "noPlaylistsFound": "Ni najdenih seznamov predvajanja", - "noPlaylists": "Ni na voljo seznamov predvajanja" + "noPlaylists": "Ni na voljo seznamov predvajanja", + "coverUploaded": "", + "coverRemoved": "", + "coverUploadError": "", + "coverRemoveError": "" } }, "radio": { @@ -353,7 +360,8 @@ "allUsers": "Dovoli vsem uporabnikom", "selectedUsers": "Izbrani uporabniki", "allLibraries": "Dovoli vse knjižnice", - "selectedLibraries": "Izbrane knjižnice" + "selectedLibraries": "Izbrane knjižnice", + "allowWriteAccess": "" }, "sections": { "status": "Status", @@ -397,8 +405,9 @@ "noLibraries": "Ni izbranih knjižnic", "librariesRequired": "Vtičnik zahteva dostop do knjižnih informacij. Izberi do katerih knjižnic lahko dostopa, ali vključi dostop do vseh knjižnic.", "requiredHosts": "Zahtevani gostitelji", - "configValidationError": "", - "schemaRenderError": "" + "configValidationError": "Validacija konfiguracije neuspešna:", + "schemaRenderError": "Konfiguracijskega obrazca ni mogoče upodobiti. Shema vtičnika je morda neveljavna.", + "allowWriteAccessHelp": "" }, "placeholders": { "configKey": "ključ", @@ -588,7 +597,7 @@ "remove_all_missing_content": "Ste prepričani, da želite odstraniti vse manjkajoče datoteke iz baze? Trajno boste odstranili vse reference nanje, vključno s številom predvajanj in ocenami.", "noSimilarSongsFound": "Ni najdenih podobnih pesmi", "noTopSongsFound": "Ni najdenih najboljših pesmi", - "startingInstantMix": "" + "startingInstantMix": "Nalaganje Instant Mix..." }, "menu": { "library": "Knjižnica", @@ -674,7 +683,8 @@ "exportSuccess": "Konfiguracija izvožena v odložišče v formatu TOML", "exportFailed": "Kopiranje konfiguracije ni uspelo", "devFlagsHeader": "Razvojne zastavice (lahko se spremenijo/odstranijo)", - "devFlagsComment": "To so eksperimentalne nastavitve in bodo morda odstranjene v prihodnjih različicah" + "devFlagsComment": "To so eksperimentalne nastavitve in bodo morda odstranjene v prihodnjih različicah", + "downloadToml": "Naloži konfiguracijo (TOML)" } }, "activity": { diff --git a/resources/i18n/sv.json b/resources/i18n/sv.json index 5896b4ed9..b792943d0 100644 --- a/resources/i18n/sv.json +++ b/resources/i18n/sv.json @@ -37,7 +37,8 @@ "sampleRate": "Samplingsfrekvens", "missing": "Saknade", "libraryName": "Bibliotek", - "composer": "Kompositör" + "composer": "Kompositör", + "disc": "" }, "actions": { "addToQueue": "Lägg till i kön", @@ -218,13 +219,19 @@ "saveQueue": "Spara kö till spellista", "searchOrCreate": "Sök spellista eller skapa ny...", "pressEnterToCreate": "Tryck Enter för att skapa ny spellista", - "removeFromSelection": "Ta bort från urval" + "removeFromSelection": "Ta bort från urval", + "uploadCover": "", + "removeCover": "" }, "message": { "duplicate_song": "Lägg till dubletter", "song_exist": "Vissa låtar finns redan i spellistan. Vill du lägga till dubbletterna eller hoppa över dem?", "noPlaylistsFound": "Hittade inga spellistor", - "noPlaylists": "Inga spellistor tillgängliga" + "noPlaylists": "Inga spellistor tillgängliga", + "coverUploaded": "", + "coverRemoved": "", + "coverUploadError": "", + "coverRemoveError": "" } }, "radio": { @@ -353,7 +360,8 @@ "allUsers": "Tillåt alla användare", "selectedUsers": "Valda användare", "allLibraries": "Tillåt alla bibliotek", - "selectedLibraries": "Valda bibliotek" + "selectedLibraries": "Valda bibliotek", + "allowWriteAccess": "" }, "sections": { "status": "Status", @@ -398,7 +406,8 @@ "librariesRequired": "Detta tillägg kräver tillgång till biblioteksinformation. Välj vilka bibliotek tillägget kan komma åt eller aktivera 'Tillåt alla bibliotek'.", "requiredHosts": "Krävda värdar", "configValidationError": "Validering av konfigurationen misslyckades:", - "schemaRenderError": "Kunde inte rendera konfigurationsformuläret. Tilläggets schema kan vara ogiltigt." + "schemaRenderError": "Kunde inte rendera konfigurationsformuläret. Tilläggets schema kan vara ogiltigt.", + "allowWriteAccessHelp": "" }, "placeholders": { "configKey": "nyckel", @@ -674,7 +683,8 @@ "exportSuccess": "Inställningarna kopierade till urklippet i TOML-format", "exportFailed": "Kopiering av inställningarna misslyckades", "devFlagsHeader": "Utvecklingsflaggor (kan ändras eller tas bort)", - "devFlagsComment": "Dessa inställningar är experimentella och kan tas bort i framtida versioner" + "devFlagsComment": "Dessa inställningar är experimentella och kan tas bort i framtida versioner", + "downloadToml": "Ladda ner konfiguration (TOML)" } }, "activity": { diff --git a/resources/i18n/th.json b/resources/i18n/th.json index 45a5e5f34..d3d64bdbf 100644 --- a/resources/i18n/th.json +++ b/resources/i18n/th.json @@ -37,7 +37,8 @@ "sampleRate": "แซมเปิ้ลเรต", "missing": "หายไป", "libraryName": "ห้องสมุด", - "composer": "ผู้แต่ง" + "composer": "ผู้แต่ง", + "disc": "" }, "actions": { "addToQueue": "เพิ่มในคิว", @@ -48,7 +49,7 @@ "playNext": "เล่นถัดไป", "info": "ดูรายละเอียด", "showInPlaylist": "แสดงในเพลย์ลิสต์", - "instantMix": "" + "instantMix": "อินสแตนต์ มิก" } }, "album": { @@ -218,13 +219,19 @@ "saveQueue": "บันทึกคิวลงเพลย์ลิสต์", "searchOrCreate": "ค้นหาเพลย์ลิสต์หรือพิมพ์เพื่อสร้างใหม่", "pressEnterToCreate": "กด Enter เพื่อสร้างเพลย์ลิสต์", - "removeFromSelection": "เอาออกจากที่เลือกไว้" + "removeFromSelection": "เอาออกจากที่เลือกไว้", + "uploadCover": "", + "removeCover": "" }, "message": { "duplicate_song": "เพิ่มเพลงซ้ำ", "song_exist": "เพิ่มเพลงซ้ำกันในเพลย์ลิสต์ คุณจะเพิ่มเพลงต่อหรือข้าม", "noPlaylistsFound": "ไม่พบเพลย์ลิสต์", - "noPlaylists": "ไม่มีเพลย์ลิสต์อยู่" + "noPlaylists": "ไม่มีเพลย์ลิสต์อยู่", + "coverUploaded": "", + "coverRemoved": "", + "coverUploadError": "", + "coverRemoveError": "" } }, "radio": { @@ -353,7 +360,8 @@ "allUsers": "อนุญาติผู้ใช้ทั้งหมด", "selectedUsers": "ผู้ใช้ถูกเลือก", "allLibraries": "อนุญาติห้องสมุดเพลงทั้งหมด", - "selectedLibraries": "ห้องสมุดเพลงถูกเลือก" + "selectedLibraries": "ห้องสมุดเพลงถูกเลือก", + "allowWriteAccess": "" }, "sections": { "status": "สถานะ", @@ -398,7 +406,8 @@ "librariesRequired": "ปลั๊กอินนี้ต้องการเข้าถึงข้อมูลห้องสมุดเพลง เลือกห้องสมุดเพลงที่ต้องการให้ปลั๊กอินเข้าถึงหรือเปิดใช้งานกับห้องสมุดเพลงทั้งหมด", "requiredHosts": "ต้องการ Host", "configValidationError": "การตั้งค่าเกิดความผิดพลาด", - "schemaRenderError": "ไม่สามารถแสดงหน้าจอการตั้งค่า อาจเกิดจากความผิดพลาดจากปลั๊กอิน" + "schemaRenderError": "ไม่สามารถแสดงหน้าจอการตั้งค่า อาจเกิดจากความผิดพลาดจากปลั๊กอิน", + "allowWriteAccessHelp": "" }, "placeholders": { "configKey": "คีย์", @@ -588,7 +597,7 @@ "remove_all_missing_content": "คุณแน่ใจว่าจะเอารายการไฟล์ที่หายไปออกจากดาต้าเบส นี่จะเป็นการลบข้อมูลอ้างอิงทั้งหมดของไฟล์ออกอย่างถาวร", "noSimilarSongsFound": "ไม่มีเพลงคล้ายกัน", "noTopSongsFound": "ไม่พบเพลงยอดนิยม", - "startingInstantMix": "" + "startingInstantMix": "กำลังโหลดอินสแตนท์ มิก..." }, "menu": { "library": "ห้องสมุดเพลง", @@ -674,7 +683,8 @@ "exportSuccess": "นำออกการตั้งค่าไปยังคลิปบอร์ดในรูปแบบ TOML แล้ว", "exportFailed": "คัดลอกการตั้งค่าล้มเหลว", "devFlagsHeader": "ปักธงการพัฒนา (อาจมีการเปลี่ยน/เอาออก)", - "devFlagsComment": "การตั้งค่านี้อยู่ในช่วงทดลองและอาจจะมีการเอาออกในเวอร์ชั่นหลัง" + "devFlagsComment": "การตั้งค่านี้อยู่ในช่วงทดลองและอาจจะมีการเอาออกในเวอร์ชั่นหลัง", + "downloadToml": "ดาวน์โหลดการตั้งค่า (TOML)" } }, "activity": { diff --git a/resources/i18n/zh-Hant.json b/resources/i18n/zh-Hant.json index 1bb59a8b1..aca2e4742 100644 --- a/resources/i18n/zh-Hant.json +++ b/resources/i18n/zh-Hant.json @@ -10,19 +10,14 @@ "playCount": "播放次數", "title": "標題", "artist": "藝人", - "composer": "作曲者", "album": "專輯", "path": "檔案路徑", - "libraryName": "媒體庫", "genre": "曲風", "compilation": "合輯", "year": "發行年份", "size": "檔案大小", "updatedAt": "更新於", "bitRate": "位元率", - "bitDepth": "位元深度", - "sampleRate": "取樣率", - "channels": "聲道", "discSubtitle": "光碟副標題", "starred": "收藏", "comment": "註解", @@ -30,6 +25,7 @@ "quality": "品質", "bpm": "BPM", "playDate": "上次播放", + "channels": "聲道", "createdAt": "建立於", "grouping": "分組", "mood": "情緒", @@ -37,17 +33,22 @@ "tags": "額外標籤", "mappedTags": "分類後標籤", "rawTags": "原始標籤", - "missing": "遺失" + "bitDepth": "位元深度", + "sampleRate": "取樣率", + "missing": "遺失", + "libraryName": "媒體庫", + "composer": "作曲者", + "disc": "" }, "actions": { "addToQueue": "加入至播放佇列", "playNow": "立即播放", "addToPlaylist": "加入至播放清單", - "showInPlaylist": "在播放清單中顯示", "shuffleAll": "全部隨機播放", "download": "下載", "playNext": "下一首播放", "info": "取得資訊", + "showInPlaylist": "在播放清單中顯示", "instantMix": "即時混音" } }, @@ -59,38 +60,38 @@ "duration": "長度", "songCount": "歌曲數", "playCount": "播放次數", - "size": "檔案大小", "name": "名稱", - "libraryName": "媒體庫", "genre": "曲風", "compilation": "合輯", "year": "發行年份", - "date": "錄製日期", - "originalDate": "原始日期", - "releaseDate": "發行日期", - "releases": "發行", - "released": "已發行", "updatedAt": "更新於", "comment": "註解", "rating": "評分", "createdAt": "建立於", + "size": "檔案大小", + "originalDate": "原始日期", + "releaseDate": "發行日期", + "releases": "發行", + "released": "已發行", "recordLabel": "唱片公司", "catalogNum": "目錄編號", "releaseType": "發行類型", "grouping": "分組", "media": "媒體類型", "mood": "情緒", - "missing": "遺失" + "date": "錄製日期", + "missing": "遺失", + "libraryName": "媒體庫" }, "actions": { "playAll": "播放全部", "playNext": "下一首播放", "addToQueue": "加入至播放佇列", - "share": "分享", "shuffle": "隨機播放", "addToPlaylist": "加入至播放清單", "download": "下載", - "info": "取得資訊" + "info": "取得資訊", + "share": "分享" }, "lists": { "all": "所有", @@ -108,10 +109,10 @@ "name": "名稱", "albumCount": "專輯數", "songCount": "歌曲數", - "size": "檔案大小", "playCount": "播放次數", "rating": "評分", "genre": "曲風", + "size": "檔案大小", "role": "參與角色", "missing": "遺失" }, @@ -132,9 +133,9 @@ "maincredit": "專輯藝人或藝人 |||| 專輯藝人或藝人" }, "actions": { - "topSongs": "熱門歌曲", "shuffle": "隨機播放", - "radio": "電台" + "radio": "電台", + "topSongs": "熱門歌曲" } }, "user": { @@ -143,7 +144,6 @@ "userName": "使用者名稱", "isAdmin": "管理員", "lastLoginAt": "上次登入", - "lastAccessAt": "上次存取", "updatedAt": "更新於", "name": "名稱", "password": "密碼", @@ -152,6 +152,7 @@ "currentPassword": "目前密碼", "newPassword": "新密碼", "token": "權杖", + "lastAccessAt": "上次存取", "libraries": "媒體庫" }, "helperTexts": { @@ -163,14 +164,14 @@ "updated": "使用者已更新", "deleted": "使用者已刪除" }, - "validation": { - "librariesRequired": "非管理員使用者必須至少選擇一個媒體庫" - }, "message": { "listenBrainzToken": "輸入您的 ListenBrainz 使用者權杖", "clickHereForToken": "點擊此處來獲得您的 ListenBrainz 權杖", "selectAllLibraries": "選取全部媒體庫", "adminAutoLibraries": "管理員預設可存取所有媒體庫" + }, + "validation": { + "librariesRequired": "非管理員使用者必須至少選擇一個媒體庫" } }, "player": { @@ -213,18 +214,24 @@ "selectPlaylist": "選取播放清單:", "addNewPlaylist": "建立「%{name}」", "export": "匯出", - "saveQueue": "將播放佇列儲存到播放清單", "makePublic": "設為公開", "makePrivate": "設為私人", + "saveQueue": "將播放佇列儲存到播放清單", "searchOrCreate": "搜尋播放清單,或輸入名稱來新建…", "pressEnterToCreate": "按 Enter 鍵建立新的播放清單", - "removeFromSelection": "移除選取項目" + "removeFromSelection": "移除選取項目", + "uploadCover": "上傳封面", + "removeCover": "移除封面" }, "message": { "duplicate_song": "加入重複的歌曲", "song_exist": "有重複歌曲正要加入播放清單,您要加入或略過重複歌曲?", "noPlaylistsFound": "找不到播放清單", - "noPlaylists": "暫無播放清單" + "noPlaylists": "暫無播放清單", + "coverUploaded": "已更新封面圖", + "coverRemoved": "已移除封面圖", + "coverUploadError": "上傳封面圖時發生錯誤", + "coverRemoveError": "移除封面圖時發生錯誤" } }, "radio": { @@ -246,7 +253,6 @@ "username": "分享者", "url": "網址", "description": "描述", - "downloadable": "允許下載?", "contents": "內容", "expiresAt": "過期時間", "lastVisitedAt": "上次造訪時間", @@ -254,19 +260,17 @@ "format": "格式", "maxBitRate": "最大位元率", "updatedAt": "更新於", - "createdAt": "建立於" - }, - "notifications": {}, - "actions": {} + "createdAt": "建立於", + "downloadable": "允許下載?" + } }, "missing": { "name": "遺失檔案 |||| 遺失檔案", - "empty": "無遺失檔案", "fields": { "path": "路徑", "size": "檔案大小", - "libraryName": "媒體庫", - "updatedAt": "遺失於" + "updatedAt": "遺失於", + "libraryName": "媒體庫" }, "actions": { "remove": "刪除", @@ -274,7 +278,8 @@ }, "notifications": { "removed": "遺失檔案已刪除" - } + }, + "empty": "無遺失檔案" }, "library": { "name": "媒體庫 |||| 媒體庫", @@ -304,20 +309,20 @@ }, "actions": { "scan": "掃描媒體庫", - "quickScan": "快速掃描", - "fullScan": "完整掃描", "manageUsers": "管理使用者權限", - "viewDetails": "查看詳細資料" + "viewDetails": "查看詳細資料", + "quickScan": "快速掃描", + "fullScan": "完整掃描" }, "notifications": { "created": "成功建立媒體庫", "updated": "成功更新媒體庫", "deleted": "成功刪除媒體庫", "scanStarted": "開始掃描媒體庫", + "scanCompleted": "媒體庫掃描完成", "quickScanStarted": "快速掃描已開始", "fullScanStarted": "完整掃描已開始", - "scanError": "掃描啟動失敗,請檢查日誌", - "scanCompleted": "媒體庫掃描完成" + "scanError": "掃描啟動失敗,請檢查日誌" }, "validation": { "nameRequired": "請輸入媒體庫名稱", @@ -355,7 +360,8 @@ "allUsers": "允許所有使用者", "selectedUsers": "選定的使用者", "allLibraries": "允許所有媒體庫", - "selectedLibraries": "選定的媒體庫" + "selectedLibraries": "選定的媒體庫", + "allowWriteAccess": "允許寫入權限" }, "sections": { "status": "狀態", @@ -389,8 +395,6 @@ }, "messages": { "configHelp": "使用鍵值對設定插件。若插件無需設定則留空。", - "configValidationError": "設定驗證失敗:", - "schemaRenderError": "無法顯示設定表單。插件的 schema 可能無效。", "clickPermissions": "點擊權限以查看詳細資訊", "noConfig": "無設定", "allUsersHelp": "啟用後,插件將可存取所有使用者,包含未來建立的使用者。", @@ -400,7 +404,10 @@ "allLibrariesHelp": "啟用後,插件將可存取所有媒體庫,包含未來建立的媒體庫。", "noLibraries": "未選擇媒體庫", "librariesRequired": "此插件需要存取媒體庫資訊。請選擇插件可存取的媒體庫,或啟用「允許所有媒體庫」。", - "requiredHosts": "必要的 Hosts" + "requiredHosts": "必要的 Hosts", + "configValidationError": "設定驗證失敗:", + "schemaRenderError": "無法顯示設定表單。插件的 schema 可能無效。", + "allowWriteAccessHelp": "啟用後,插件可以修改媒體庫目錄中的檔案。 預設情況下,插件具有唯讀權限。" }, "placeholders": { "configKey": "鍵", @@ -443,7 +450,6 @@ "add": "加入", "back": "返回", "bulk_actions": "選中 1 項 |||| 選中 %{smart_count} 項", - "bulk_actions_mobile": "1 |||| %{smart_count}", "cancel": "取消", "clear_input_value": "清除", "clone": "複製", @@ -467,6 +473,7 @@ "close_menu": "關閉選單", "unselect": "取消選取", "skip": "略過", + "bulk_actions_mobile": "1 |||| %{smart_count}", "share": "分享", "download": "下載" }, @@ -558,48 +565,42 @@ "transcodingDisabled": "出於安全原因,已禁用了從 Web 介面更改參數。要更改(編輯或新增)轉碼選項,請在啟用 %{config} 設定選項的情況下重新啟動伺服器。", "transcodingEnabled": "Navidrome 目前與 %{config} 一起使用,因此可以透過 Web 介面從轉碼設定中執行系統命令。出於安全考慮,我們建議停用此功能,並僅在設定轉碼選項時啟用。", "songsAddedToPlaylist": "已加入一首歌到播放清單 |||| 已新增 %{smart_count} 首歌到播放清單", - "noSimilarSongsFound": "找不到相似歌曲", - "startingInstantMix": "正在載入即時混音...", - "noTopSongsFound": "找不到熱門歌曲", "noPlaylistsAvailable": "沒有可用的播放清單", "delete_user_title": "刪除使用者「%{name}」", "delete_user_content": "您確定要刪除此使用者及其所有資料(包括播放清單和偏好設定)嗎?", - "remove_missing_title": "刪除遺失檔案", - "remove_missing_content": "您確定要從媒體庫中刪除所選的遺失的檔案嗎?這將永久刪除它們的所有相關資訊,包括其播放次數和評分。", - "remove_all_missing_title": "刪除所有遺失檔案", - "remove_all_missing_content": "您確定要從媒體庫中刪除所有遺失的檔案嗎?這將永久刪除它們的所有相關資訊,包括它們的播放次數和評分。", "notifications_blocked": "您已在瀏覽器設定中封鎖了此網站的通知", "notifications_not_available": "此瀏覽器不支援桌面通知,或您並非透過 HTTPS 存取 Navidrome", "lastfmLinkSuccess": "已成功連接 Last.fm 並開啟音樂記錄", "lastfmLinkFailure": "無法連接 Last.fm", "lastfmUnlinkSuccess": "已取消與 Last.fm 的連接並停用音樂記錄", "lastfmUnlinkFailure": "無法取消與 Last.fm 的連接", - "listenBrainzLinkSuccess": "已成功以 %{user} 的身份連接 ListenBrainz 並開啟音樂記錄", - "listenBrainzLinkFailure": "無法連接 ListenBrainz:%{error}", - "listenBrainzUnlinkSuccess": "已取消與 ListenBrainz 的連接並停用音樂記錄", - "listenBrainzUnlinkFailure": "無法取消與 ListenBrainz 的連接", "openIn": { "lastfm": "在 Last.fm 中開啟", "musicbrainz": "在 MusicBrainz 中開啟" }, "lastfmLink": "查看更多…", + "listenBrainzLinkSuccess": "已成功以 %{user} 的身份連接 ListenBrainz 並開啟音樂記錄", + "listenBrainzLinkFailure": "無法連接 ListenBrainz:%{error}", + "listenBrainzUnlinkSuccess": "已取消與 ListenBrainz 的連接並停用音樂記錄", + "listenBrainzUnlinkFailure": "無法取消與 ListenBrainz 的連接", + "downloadOriginalFormat": "下載原始格式", "shareOriginalFormat": "分享原始格式", "shareDialogTitle": "分享 %{resource} '%{name}'", "shareBatchDialogTitle": "分享 1 個%{resource} |||| 分享 %{smart_count} 個%{resource}", - "shareCopyToClipboard": "複製到剪貼簿:Ctrl+C, Enter", "shareSuccess": "分享成功,連結已複製到剪貼簿:%{url}", "shareFailure": "分享連結複製失敗:%{url}", "downloadDialogTitle": "下載 %{resource} '%{name}' (%{size})", - "downloadOriginalFormat": "下載原始格式" + "shareCopyToClipboard": "複製到剪貼簿:Ctrl+C, Enter", + "remove_missing_title": "刪除遺失檔案", + "remove_missing_content": "您確定要從媒體庫中刪除所選的遺失的檔案嗎?這將永久刪除它們的所有相關資訊,包括其播放次數和評分。", + "remove_all_missing_title": "刪除所有遺失檔案", + "remove_all_missing_content": "您確定要從媒體庫中刪除所有遺失的檔案嗎?這將永久刪除它們的所有相關資訊,包括它們的播放次數和評分。", + "noSimilarSongsFound": "找不到相似歌曲", + "noTopSongsFound": "找不到熱門歌曲", + "startingInstantMix": "正在載入即時混音..." }, "menu": { "library": "媒體庫", - "librarySelector": { - "allLibraries": "所有媒體庫 (%{count})", - "multipleLibraries": "已選 %{selected} 共 %{total} 媒體庫", - "selectLibraries": "選取媒體庫", - "none": "無" - }, "settings": "設定", "version": "版本", "theme": "主題", @@ -610,7 +611,6 @@ "language": "語言", "defaultView": "預設畫面", "desktop_notifications": "桌面通知", - "lastfmNotConfigured": "Last.fm API 金鑰未設定", "lastfmScrobbling": "啟用 Last.fm 音樂記錄", "listenBrainzScrobbling": "啟用 ListenBrainz 音樂記錄", "replaygain": "重播增益模式", @@ -619,13 +619,20 @@ "none": "無", "album": "專輯增益", "track": "曲目增益" - } + }, + "lastfmNotConfigured": "Last.fm API 金鑰未設定" } }, "albumList": "專輯", + "about": "關於", "playlists": "播放清單", "sharedPlaylists": "分享的播放清單", - "about": "關於" + "librarySelector": { + "allLibraries": "所有媒體庫 (%{count})", + "multipleLibraries": "已選 %{selected} 共 %{total} 媒體庫", + "selectLibraries": "選取媒體庫", + "none": "無" + } }, "player": { "playListsText": "播放佇列", @@ -676,7 +683,8 @@ "exportSuccess": "設定已以 TOML 格式匯出至剪貼簿", "exportFailed": "設定複製失敗", "devFlagsHeader": "開發旗標(可能會更改/刪除)", - "devFlagsComment": "這些是實驗性設定,可能會在未來版本中刪除" + "devFlagsComment": "這些是實驗性設定,可能會在未來版本中刪除", + "downloadToml": "下載設定檔 (TOML)" } }, "activity": { @@ -684,17 +692,12 @@ "totalScanned": "已掃描的資料夾總數", "quickScan": "快速掃描", "fullScan": "完全掃描", - "selectiveScan": "選擇性掃描", "serverUptime": "伺服器運作時間", "serverDown": "伺服器已離線", "scanType": "掃描類型", "status": "掃描錯誤", - "elapsedTime": "經過時間" - }, - "nowPlaying": { - "title": "正在播放", - "empty": "無播放內容", - "minutesAgo": "1 分鐘前 |||| %{smart_count} 分鐘前" + "elapsedTime": "經過時間", + "selectiveScan": "選擇性掃描" }, "help": { "title": "Navidrome 快捷鍵", @@ -704,10 +707,15 @@ "toggle_play": "播放/暫停", "prev_song": "上一首歌", "next_song": "下一首歌", - "current_song": "前往目前歌曲", "vol_up": "提高音量", "vol_down": "降低音量", - "toggle_love": "新增此歌曲至收藏" + "toggle_love": "新增此歌曲至收藏", + "current_song": "前往目前歌曲" } + }, + "nowPlaying": { + "title": "正在播放", + "empty": "無播放內容", + "minutesAgo": "1 分鐘前 |||| %{smart_count} 分鐘前" } -} +} \ No newline at end of file From ab8a58157a0b1d25ba699e2751c8c961aa9ab4f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sun, 15 Mar 2026 22:19:55 -0400 Subject: [PATCH 2/6] feat: add artist image uploads and image-folder artwork source (#5198) * feat: add shared ImageUploadService for entity image management * feat: add UploadedImage field and methods to Artist model * feat: add uploaded_image column to artist table * feat: add ArtistImageFolder config option * refactor: wire ImageUploadService and delegate playlist file ops to it Wire ImageUploadService into the DI container and refactor the playlist service to delegate image file operations (SetImage/RemoveImage) to the shared ImageUploadService, removing duplicated file I/O logic. A local ImageUploadService interface is defined in core/playlists to avoid an import cycle between core and core/playlists. * feat: artist artwork reader checks uploaded image first * feat: add image-folder priority source for artist artwork * feat: cache key invalidation for image-folder and uploaded images * refactor: extract shared image upload HTTP helpers * feat: add artist image upload/delete API endpoints * refactor: playlist handlers use shared image upload helpers * feat: add shared ImageUploadOverlay component * feat: add i18n keys for artist image upload * feat: add image upload overlay to artist detail pages * refactor: playlist details uses shared ImageUploadOverlay component * fix: add gosec nolint directive for ParseMultipartForm * refactor: deduplicate image upload code and optimize dir scanning - Remove dead ImageFilename methods from Artist and Playlist models (production code uses core.imageFilename exclusively) - Extract shared uploadedImagePath helper in model/image.go - Extract findImageInArtistFolder to deduplicate dir-scanning logic between fromArtistImageFolder and getArtistImageFolderModTime - Fix fileInputRef in useCallback dependency array * fix: include artist UpdatedAt in artwork cache key Without this, uploading or deleting an artist image would not invalidate the cached artwork because the cache key was only based on album folder timestamps, not the artist's own UpdatedAt field. * feat: add Portuguese translations for artist image upload * refactor: use shared i18n keys for cover art upload messages Move cover art upload/remove translations from per-entity sections (artist, playlist) to a shared top-level "message" section, avoiding duplication across entity types and translation files. * refactor: move cover art i18n keys to shared message section for all languages * refactor: simplify image upload code and eliminate redundancies Extracted duplicate image loading/lightbox state logic from DesktopArtistDetails and MobileArtistDetails into a shared useArtistImageState hook. Moved entity type constants to the consts package and replaced raw string literals throughout model, core, and nativeapi packages. Exported model.UploadedImagePath and reused it in core/image_upload.go to consolidate path construction. Cached the ArtistImageFolder lookup result in artistReader to eliminate a redundant os.ReadDir call on every artwork request. Signed-off-by: Deluan * style: fix prettier formatting in ImageUploadOverlay * fix: address code review feedback on image upload error handling - RemoveImage now returns errors instead of swallowing them - Artist handlers distinguish not-found from other DB errors - Defer multipart temp file cleanup after parsing * fix: enforce hard request size limit with MaxBytesReader for image uploads Signed-off-by: Deluan --------- Signed-off-by: Deluan --- cmd/scan.go | 3 +- cmd/wire_gen.go | 14 +- conf/configuration.go | 1 + consts/consts.go | 6 + core/artwork/reader_artist.go | 79 +++++- core/artwork/reader_artist_test.go | 253 ++++++++++++++++++ core/image_upload.go | 71 +++++ core/image_upload_test.go | 99 +++++++ core/playlists/import_test.go | 31 +-- core/playlists/playlists.go | 50 ++-- core/playlists/playlists_test.go | 17 +- core/playlists/rest_adapter_test.go | 3 +- core/wire_providers.go | 2 + ...0260315233131_add_artist_uploaded_image.go | 22 ++ model/artist.go | 8 + model/artist_test.go | 30 +++ model/image.go | 17 ++ model/playlist.go | 18 +- model/playlist_test.go | 22 -- resources/i18n/bg.json | 12 +- resources/i18n/ca.json | 12 +- resources/i18n/da.json | 12 +- resources/i18n/de.json | 20 +- resources/i18n/el.json | 12 +- resources/i18n/es.json | 12 +- resources/i18n/fi.json | 20 +- resources/i18n/fr.json | 12 +- resources/i18n/gl.json | 12 +- resources/i18n/pt-br.json | 14 +- resources/i18n/ru.json | 12 +- resources/i18n/sl.json | 12 +- resources/i18n/sv.json | 12 +- resources/i18n/th.json | 12 +- resources/i18n/zh-Hant.json | 20 +- scanner/controller_test.go | 3 +- scanner/scanner_benchmark_test.go | 3 +- scanner/scanner_multilibrary_test.go | 3 +- scanner/scanner_selective_test.go | 3 +- scanner/scanner_test.go | 3 +- server/e2e/e2e_suite_test.go | 6 +- server/e2e/subsonic_multilibrary_test.go | 3 +- server/nativeapi/artists.go | 72 +++++ server/nativeapi/config_test.go | 2 +- server/nativeapi/image_upload.go | 120 +++++++++ server/nativeapi/library_test.go | 2 +- server/nativeapi/native_api.go | 7 +- server/nativeapi/native_api_song_test.go | 2 +- server/nativeapi/playlists.go | 118 +------- server/nativeapi/playlists_test.go | 2 +- server/nativeapi/plugin_test.go | 2 +- ui/src/artist/DesktopArtistDetails.jsx | 48 ++-- ui/src/artist/MobileArtistDetails.jsx | 48 ++-- ui/src/artist/useArtistImageState.js | 46 ++++ ui/src/common/ImageUploadOverlay.jsx | 139 ++++++++++ ui/src/common/index.js | 1 + ui/src/i18n/en.json | 14 +- ui/src/playlist/PlaylistDetails.jsx | 137 +--------- 57 files changed, 1169 insertions(+), 567 deletions(-) create mode 100644 core/image_upload.go create mode 100644 core/image_upload_test.go create mode 100644 db/migrations/20260315233131_add_artist_uploaded_image.go create mode 100644 model/artist_test.go create mode 100644 model/image.go create mode 100644 server/nativeapi/artists.go create mode 100644 server/nativeapi/image_upload.go create mode 100644 ui/src/artist/useArtistImageState.js create mode 100644 ui/src/common/ImageUploadOverlay.jsx diff --git a/cmd/scan.go b/cmd/scan.go index ffb77b108..d8a563396 100644 --- a/cmd/scan.go +++ b/cmd/scan.go @@ -8,6 +8,7 @@ import ( "os" "strings" + "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/log" @@ -74,7 +75,7 @@ func runScanner(ctx context.Context) { sqlDB := db.Db() defer db.Db().Close() ds := persistence.New(sqlDB) - pls := playlists.NewPlaylists(ds) + pls := playlists.NewPlaylists(ds, core.NewImageUploadService()) // Parse targets from command line or file var scanTargets []model.ScanTarget diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index c2f6edaf8..5b9fd648f 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -63,7 +63,8 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router { sqlDB := db.Db() dataStore := persistence.New(sqlDB) share := core.NewShare(dataStore) - playlistsPlaylists := playlists.NewPlaylists(dataStore) + imageUploadService := core.NewImageUploadService() + playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService) insights := metrics.GetInstance(dataStore) fileCache := artwork.GetImageCache() fFmpeg := ffmpeg.New() @@ -79,7 +80,7 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router { library := core.NewLibrary(dataStore, modelScanner, watcher, broker, manager) user := core.NewUser(dataStore, manager) maintenance := core.NewMaintenance(dataStore) - router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager) + router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager, imageUploadService) return router } @@ -100,7 +101,8 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router { archiver := core.NewArchiver(mediaStreamer, dataStore, share) players := core.NewPlayers(dataStore) cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache) - playlistsPlaylists := playlists.NewPlaylists(dataStore) + imageUploadService := core.NewImageUploadService() + playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService) modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics) playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager) playbackServer := playback.GetInstance(dataStore) @@ -169,7 +171,8 @@ func CreateScanner(ctx context.Context) model.Scanner { provider := external.NewProvider(dataStore, agentsAgents) artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache) - playlistsPlaylists := playlists.NewPlaylists(dataStore) + imageUploadService := core.NewImageUploadService() + playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService) modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics) return modelScanner } @@ -186,7 +189,8 @@ func CreateScanWatcher(ctx context.Context) scanner.Watcher { provider := external.NewProvider(dataStore, agentsAgents) artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache) - playlistsPlaylists := playlists.NewPlaylists(dataStore) + imageUploadService := core.NewImageUploadService() + playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService) modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics) watcher := scanner.GetWatcher(dataStore, modelScanner) return watcher diff --git a/conf/configuration.go b/conf/configuration.go index dacd42539..9efa352bd 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -71,6 +71,7 @@ type configOptions struct { CoverArtPriority string CoverArtQuality int ArtistArtPriority string + ArtistImageFolder string DiscArtPriority string LyricsPriority string EnableGravatar bool diff --git a/consts/consts.go b/consts/consts.go index 061aebd7b..9f4387ae6 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -103,6 +103,12 @@ const ( DefaultCacheCleanUpInterval = 10 * time.Minute ) +// Entity types +const ( + EntityArtist = "artist" + EntityPlaylist = "playlist" +) + const ( AlbumPlayCountModeAbsolute = "absolute" AlbumPlayCountModeNormalized = "normalized" diff --git a/core/artwork/reader_artist.go b/core/artwork/reader_artist.go index 990942d87..96ba08b8f 100644 --- a/core/artwork/reader_artist.go +++ b/core/artwork/reader_artist.go @@ -29,11 +29,12 @@ const ( type artistReader struct { cacheKey - a *artwork - provider external.Provider - artist model.Artist - artistFolder string - imgFiles []string + a *artwork + provider external.Provider + artist model.Artist + artistFolder string + imgFiles []string + imgFolderImgPath string // cached path from ArtistImageFolder lookup } func newArtistArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID, provider external.Provider) (*artistReader, error) { @@ -71,9 +72,20 @@ func newArtistArtworkReader(ctx context.Context, artwork *artwork, artID model.A //a.cacheKey.lastUpdate = ar.ExternalInfoUpdatedAt a.cacheKey.lastUpdate = *imagesUpdatedAt + if ar.UpdatedAt != nil && ar.UpdatedAt.After(a.cacheKey.lastUpdate) { + a.cacheKey.lastUpdate = *ar.UpdatedAt + } if artistFolderLastUpdate.After(a.cacheKey.lastUpdate) { a.cacheKey.lastUpdate = artistFolderLastUpdate } + if conf.Server.ArtistImageFolder != "" && strings.Contains(strings.ToLower(conf.Server.ArtistArtPriority), "image-folder") { + a.imgFolderImgPath = findImageInArtistFolder(conf.Server.ArtistImageFolder, ar.MbzArtistID, ar.Name) + if a.imgFolderImgPath != "" { + if info, err := os.Stat(a.imgFolderImgPath); err == nil && info.ModTime().After(a.cacheKey.lastUpdate) { + a.cacheKey.lastUpdate = info.ModTime() + } + } + } a.cacheKey.artID = artID return a, nil } @@ -93,10 +105,15 @@ func (a *artistReader) LastUpdated() time.Time { } func (a *artistReader) Reader(ctx context.Context) (io.ReadCloser, string, error) { - var ff = a.fromArtistArtPriority(ctx, conf.Server.ArtistArtPriority) + ff := []sourceFunc{a.fromArtistUploadedImage()} + ff = append(ff, a.fromArtistArtPriority(ctx, conf.Server.ArtistArtPriority)...) return selectImageReader(ctx, a.artID, ff...) } +func (a *artistReader) fromArtistUploadedImage() sourceFunc { + return fromLocalFile(a.artist.UploadedImagePath()) +} + func (a *artistReader) fromArtistArtPriority(ctx context.Context, priority string) []sourceFunc { var ff []sourceFunc for pattern := range strings.SplitSeq(strings.ToLower(priority), ",") { @@ -104,6 +121,8 @@ func (a *artistReader) fromArtistArtPriority(ctx context.Context, priority strin switch { case pattern == "external": ff = append(ff, fromArtistExternalSource(ctx, a.artist, a.provider)) + case pattern == "image-folder": + ff = append(ff, a.fromArtistImageFolder(ctx)) case strings.HasPrefix(pattern, "album/"): ff = append(ff, fromExternalFile(ctx, a.imgFiles, strings.TrimPrefix(pattern, "album/"))) default: @@ -196,3 +215,51 @@ func loadArtistFolder(ctx context.Context, ds model.DataStore, albums model.Albu } return folderPath, folders[0].ImagesUpdatedAt, nil } + +func (a *artistReader) fromArtistImageFolder(ctx context.Context) sourceFunc { + return func() (io.ReadCloser, string, error) { + folder := conf.Server.ArtistImageFolder + if folder == "" { + return nil, "", nil + } + // Use cached path from newArtistArtworkReader if available, + // avoiding a second directory scan. + path := a.imgFolderImgPath + if path == "" { + path = findImageInArtistFolder(folder, a.artist.MbzArtistID, a.artist.Name) + } + if path == "" { + return nil, "", fmt.Errorf("no image found for artist %q in %s", a.artist.Name, folder) + } + f, err := os.Open(path) + if err != nil { + return nil, "", err + } + return f, path, nil + } +} + +// findImageInArtistFolder scans a folder for an image file matching the artist's MBID or name +// (case-insensitive). Returns the full path, or empty string if not found. +func findImageInArtistFolder(folder, mbzArtistID, artistName string) string { + entries, err := os.ReadDir(folder) + if err != nil { + return "" + } + for _, candidate := range []string{mbzArtistID, artistName} { + if candidate == "" { + continue + } + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + base := strings.TrimSuffix(name, filepath.Ext(name)) + if strings.EqualFold(base, candidate) && model.IsImageFile(name) { + return filepath.Join(folder, name) + } + } + } + return "" +} diff --git a/core/artwork/reader_artist_test.go b/core/artwork/reader_artist_test.go index 4aa71c9ca..5e2066aeb 100644 --- a/core/artwork/reader_artist_test.go +++ b/core/artwork/reader_artist_test.go @@ -8,6 +8,8 @@ import ( "path/filepath" "time" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/model" . "github.com/onsi/ginkgo/v2" @@ -413,6 +415,257 @@ var _ = Describe("artistArtworkReader", func() { }) }) }) + + Describe("fromArtistUploadedImage", func() { + var ( + tempDir string + reader *artistReader + ) + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + tempDir = GinkgoT().TempDir() + conf.Server.DataFolder = tempDir + + // Create the artwork/artist directory + Expect(os.MkdirAll(filepath.Join(tempDir, "artwork", "artist"), 0755)).To(Succeed()) + + reader = &artistReader{} + }) + + When("artist has an uploaded image", func() { + It("returns the uploaded image", func() { + imgPath := filepath.Join(tempDir, "artwork", "artist", "ar-1_test.jpg") + Expect(os.WriteFile(imgPath, []byte("uploaded artist image"), 0600)).To(Succeed()) + + reader.artist = model.Artist{ID: "ar-1", UploadedImage: "ar-1_test.jpg"} + sf := reader.fromArtistUploadedImage() + r, path, err := sf() + Expect(err).ToNot(HaveOccurred()) + Expect(r).ToNot(BeNil()) + Expect(path).To(Equal(imgPath)) + + data, err := io.ReadAll(r) + Expect(err).ToNot(HaveOccurred()) + Expect(string(data)).To(Equal("uploaded artist image")) + r.Close() + }) + }) + + When("artist has no uploaded image", func() { + It("returns nil reader (falls through)", func() { + reader.artist = model.Artist{ID: "ar-1"} + sf := reader.fromArtistUploadedImage() + r, path, err := sf() + Expect(err).ToNot(HaveOccurred()) + Expect(r).To(BeNil()) + Expect(path).To(BeEmpty()) + }) + }) + }) + + Describe("fromArtistImageFolder", func() { + var ( + ctx context.Context + tempDir string + ar *artistReader + ) + + BeforeEach(func() { + ctx = context.Background() + DeferCleanup(configtest.SetupConfig()) + tempDir = GinkgoT().TempDir() + ar = &artistReader{} + }) + + When("ArtistImageFolder is not configured", func() { + It("returns nil (skips)", func() { + conf.Server.ArtistImageFolder = "" + ar.artist = model.Artist{Name: "Test Artist"} + sf := ar.fromArtistImageFolder(ctx) + r, path, err := sf() + Expect(err).ToNot(HaveOccurred()) + Expect(r).To(BeNil()) + Expect(path).To(BeEmpty()) + }) + }) + + When("image exists matching MBID", func() { + It("finds the image by MBID", func() { + conf.Server.ArtistImageFolder = tempDir + mbid := "f27ec8db-af05-4f36-916e-3d57f91ecf5e" + imgPath := filepath.Join(tempDir, mbid+".jpg") + Expect(os.WriteFile(imgPath, []byte("mbid image"), 0600)).To(Succeed()) + + ar.artist = model.Artist{Name: "Test Artist", MbzArtistID: mbid} + sf := ar.fromArtistImageFolder(ctx) + r, path, err := sf() + Expect(err).ToNot(HaveOccurred()) + Expect(r).ToNot(BeNil()) + Expect(path).To(Equal(imgPath)) + + data, err := io.ReadAll(r) + Expect(err).ToNot(HaveOccurred()) + Expect(string(data)).To(Equal("mbid image")) + r.Close() + }) + }) + + When("MBID match is case-insensitive", func() { + It("finds the image regardless of case", func() { + conf.Server.ArtistImageFolder = tempDir + mbid := "F27EC8DB-AF05-4F36-916E-3D57F91ECF5E" + imgPath := filepath.Join(tempDir, "f27ec8db-af05-4f36-916e-3d57f91ecf5e.png") + Expect(os.WriteFile(imgPath, []byte("mbid case image"), 0600)).To(Succeed()) + + ar.artist = model.Artist{Name: "Test Artist", MbzArtistID: mbid} + sf := ar.fromArtistImageFolder(ctx) + r, path, err := sf() + Expect(err).ToNot(HaveOccurred()) + Expect(r).ToNot(BeNil()) + Expect(path).To(Equal(imgPath)) + r.Close() + }) + }) + + When("no MBID file exists but artist name file does", func() { + It("falls back to artist name match", func() { + conf.Server.ArtistImageFolder = tempDir + imgPath := filepath.Join(tempDir, "Test Artist.jpg") + Expect(os.WriteFile(imgPath, []byte("name image"), 0600)).To(Succeed()) + + ar.artist = model.Artist{Name: "Test Artist", MbzArtistID: "nonexistent-mbid"} + sf := ar.fromArtistImageFolder(ctx) + r, path, err := sf() + Expect(err).ToNot(HaveOccurred()) + Expect(r).ToNot(BeNil()) + Expect(path).To(Equal(imgPath)) + + data, err := io.ReadAll(r) + Expect(err).ToNot(HaveOccurred()) + Expect(string(data)).To(Equal("name image")) + r.Close() + }) + }) + + When("artist name match is case-insensitive", func() { + It("matches regardless of case", func() { + conf.Server.ArtistImageFolder = tempDir + imgPath := filepath.Join(tempDir, "test artist.jpg") + Expect(os.WriteFile(imgPath, []byte("case insensitive"), 0600)).To(Succeed()) + + ar.artist = model.Artist{Name: "Test Artist"} + sf := ar.fromArtistImageFolder(ctx) + r, path, err := sf() + Expect(err).ToNot(HaveOccurred()) + Expect(r).ToNot(BeNil()) + Expect(path).To(Equal(imgPath)) + r.Close() + }) + }) + + When("both MBID and name files exist", func() { + It("prefers MBID over name match", func() { + conf.Server.ArtistImageFolder = tempDir + mbid := "f27ec8db-af05-4f36-916e-3d57f91ecf5e" + mbidPath := filepath.Join(tempDir, mbid+".jpg") + namePath := filepath.Join(tempDir, "Test Artist.jpg") + Expect(os.WriteFile(mbidPath, []byte("mbid image"), 0600)).To(Succeed()) + Expect(os.WriteFile(namePath, []byte("name image"), 0600)).To(Succeed()) + + ar.artist = model.Artist{Name: "Test Artist", MbzArtistID: mbid} + sf := ar.fromArtistImageFolder(ctx) + r, path, err := sf() + Expect(err).ToNot(HaveOccurred()) + Expect(r).ToNot(BeNil()) + Expect(path).To(Equal(mbidPath)) + + data, err := io.ReadAll(r) + Expect(err).ToNot(HaveOccurred()) + Expect(string(data)).To(Equal("mbid image")) + r.Close() + }) + }) + + When("no matching image found", func() { + It("returns an error", func() { + conf.Server.ArtistImageFolder = tempDir + // Create an unrelated file + Expect(os.WriteFile(filepath.Join(tempDir, "other.jpg"), []byte("other"), 0600)).To(Succeed()) + + ar.artist = model.Artist{Name: "Test Artist"} + sf := ar.fromArtistImageFolder(ctx) + r, _, err := sf() + Expect(err).To(HaveOccurred()) + Expect(r).To(BeNil()) + Expect(err.Error()).To(ContainSubstring("no image found")) + }) + }) + + When("cached imgFolderImgPath is set", func() { + It("uses cached path instead of scanning", func() { + conf.Server.ArtistImageFolder = tempDir + imgPath := filepath.Join(tempDir, "cached.jpg") + Expect(os.WriteFile(imgPath, []byte("cached image"), 0600)).To(Succeed()) + + ar.artist = model.Artist{Name: "Test Artist"} + ar.imgFolderImgPath = imgPath + sf := ar.fromArtistImageFolder(ctx) + r, path, err := sf() + Expect(err).ToNot(HaveOccurred()) + Expect(r).ToNot(BeNil()) + Expect(path).To(Equal(imgPath)) + + data, err := io.ReadAll(r) + Expect(err).ToNot(HaveOccurred()) + Expect(string(data)).To(Equal("cached image")) + r.Close() + }) + }) + }) + + Describe("findImageInArtistFolder", func() { + var tempDir string + + BeforeEach(func() { + tempDir = GinkgoT().TempDir() + }) + + When("matching file exists by MBID", func() { + It("returns the file path", func() { + mbid := "f27ec8db-af05-4f36-916e-3d57f91ecf5e" + imgPath := filepath.Join(tempDir, mbid+".jpg") + Expect(os.WriteFile(imgPath, []byte("image"), 0600)).To(Succeed()) + + path := findImageInArtistFolder(tempDir, mbid, "Test") + Expect(path).To(Equal(imgPath)) + }) + }) + + When("matching file exists by name", func() { + It("returns the file path", func() { + imgPath := filepath.Join(tempDir, "Test Artist.png") + Expect(os.WriteFile(imgPath, []byte("image"), 0600)).To(Succeed()) + + path := findImageInArtistFolder(tempDir, "", "Test Artist") + Expect(path).To(Equal(imgPath)) + }) + }) + + When("no matching file exists", func() { + It("returns empty string", func() { + path := findImageInArtistFolder(tempDir, "", "Unknown Artist") + Expect(path).To(BeEmpty()) + }) + }) + + When("folder does not exist", func() { + It("returns empty string", func() { + path := findImageInArtistFolder("/nonexistent/path", "", "Test") + Expect(path).To(BeEmpty()) + }) + }) + }) }) type fakeFolderRepo struct { diff --git a/core/image_upload.go b/core/image_upload.go new file mode 100644 index 000000000..c2432b647 --- /dev/null +++ b/core/image_upload.go @@ -0,0 +1,71 @@ +package core + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils" +) + +type ImageUploadService interface { + SetImage(ctx context.Context, entityType string, entityID string, name string, oldPath string, reader io.Reader, ext string) (filename string, err error) + RemoveImage(ctx context.Context, path string) error +} + +type imageUploadService struct{} + +func NewImageUploadService() ImageUploadService { + return &imageUploadService{} +} + +func (s *imageUploadService) SetImage(ctx context.Context, entityType string, entityID string, name string, oldPath string, reader io.Reader, ext string) (string, error) { + filename := imageFilename(entityID, name, ext) + absPath := model.UploadedImagePath(entityType, filename) + + if err := os.MkdirAll(filepath.Dir(absPath), 0755); err != nil { + return "", fmt.Errorf("creating image directory: %w", err) + } + + // Remove old image if it exists + if oldPath != "" { + if err := os.Remove(oldPath); err != nil && !os.IsNotExist(err) { + log.Warn(ctx, "Failed to remove old image", "path", oldPath, err) + } + } + + // Save new image + f, err := os.Create(absPath) + if err != nil { + return "", fmt.Errorf("creating image file: %w", err) + } + defer f.Close() + + if _, err := io.Copy(f, reader); err != nil { + return "", fmt.Errorf("writing image file: %w", err) + } + + return filename, nil +} + +func (s *imageUploadService) RemoveImage(ctx context.Context, path string) error { + if path == "" { + return nil + } + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("removing image %q: %w", path, err) + } + return nil +} + +func imageFilename(id, name, ext string) string { + clean := utils.CleanFileName(name) + if clean == "" { + return id + ext + } + return id + "_" + clean + ext +} diff --git a/core/image_upload_test.go b/core/image_upload_test.go new file mode 100644 index 000000000..d13a04775 --- /dev/null +++ b/core/image_upload_test.go @@ -0,0 +1,99 @@ +package core_test + +import ( + "context" + "os" + "path/filepath" + "strings" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("ImageUploadService", func() { + var svc core.ImageUploadService + var tmpDir string + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + tmpDir = GinkgoT().TempDir() + conf.Server.DataFolder = tmpDir + svc = core.NewImageUploadService() + }) + + Describe("SetImage", func() { + It("creates directory and saves image file", func() { + ctx := context.Background() + reader := strings.NewReader("fake image data") + filename, err := svc.SetImage(ctx, consts.EntityArtist, "ar-1", "Pink Floyd", "", reader, ".jpg") + Expect(err).ToNot(HaveOccurred()) + Expect(filename).To(Equal("ar-1_pink_floyd.jpg")) + + absPath := filepath.Join(tmpDir, "artwork", "artist", "ar-1_pink_floyd.jpg") + data, err := os.ReadFile(absPath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(data)).To(Equal("fake image data")) + }) + + It("falls back to ID-only filename when name cleans to empty", func() { + ctx := context.Background() + reader := strings.NewReader("data") + filename, err := svc.SetImage(ctx, consts.EntityPlaylist, "pl-1", "!!!", "", reader, ".png") + Expect(err).ToNot(HaveOccurred()) + Expect(filename).To(Equal("pl-1.png")) + }) + + It("removes old image when replacing", func() { + ctx := context.Background() + oldDir := filepath.Join(tmpDir, "artwork", "artist") + Expect(os.MkdirAll(oldDir, 0755)).To(Succeed()) + oldFile := filepath.Join(oldDir, "ar-1_old.png") + Expect(os.WriteFile(oldFile, []byte("old"), 0600)).To(Succeed()) + + reader := strings.NewReader("new image") + _, err := svc.SetImage(ctx, consts.EntityArtist, "ar-1", "New Name", oldFile, reader, ".jpg") + Expect(err).ToNot(HaveOccurred()) + Expect(oldFile).ToNot(BeAnExistingFile()) + + newPath := filepath.Join(oldDir, "ar-1_new_name.jpg") + Expect(newPath).To(BeAnExistingFile()) + }) + + It("ignores missing old file without error", func() { + ctx := context.Background() + reader := strings.NewReader("data") + _, err := svc.SetImage(ctx, consts.EntityArtist, "ar-1", "Name", "/nonexistent/path.jpg", reader, ".jpg") + Expect(err).ToNot(HaveOccurred()) + }) + }) + + Describe("RemoveImage", func() { + It("removes the file at the given path", func() { + ctx := context.Background() + dir := filepath.Join(tmpDir, "artwork", "artist") + Expect(os.MkdirAll(dir, 0755)).To(Succeed()) + path := filepath.Join(dir, "ar-1_test.jpg") + Expect(os.WriteFile(path, []byte("img"), 0600)).To(Succeed()) + + err := svc.RemoveImage(ctx, path) + Expect(err).ToNot(HaveOccurred()) + Expect(path).ToNot(BeAnExistingFile()) + }) + + It("succeeds when file does not exist", func() { + ctx := context.Background() + err := svc.RemoveImage(ctx, "/nonexistent/file.jpg") + Expect(err).ToNot(HaveOccurred()) + }) + + It("succeeds with empty path", func() { + ctx := context.Background() + err := svc.RemoveImage(ctx, "") + Expect(err).ToNot(HaveOccurred()) + }) + }) +}) diff --git a/core/playlists/import_test.go b/core/playlists/import_test.go index 5312df95d..a6320bc7e 100644 --- a/core/playlists/import_test.go +++ b/core/playlists/import_test.go @@ -11,6 +11,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/criteria" @@ -42,7 +43,7 @@ var _ = Describe("Playlists - Import", func() { var folder *model.Folder BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) - ps = playlists.NewPlaylists(ds) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) ds.MockedMediaFile = &mockedMediaFileRepo{} libPath, _ := os.Getwd() // Set up library with the actual library path that matches the folder @@ -117,7 +118,7 @@ var _ = Describe("Playlists - Import", func() { mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3", "test.ogg"}} - ps = playlists.NewPlaylists(ds) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") @@ -135,7 +136,7 @@ var _ = Describe("Playlists - Import", func() { mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") @@ -154,7 +155,7 @@ var _ = Describe("Playlists - Import", func() { mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") @@ -173,7 +174,7 @@ var _ = Describe("Playlists - Import", func() { mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") @@ -190,7 +191,7 @@ var _ = Describe("Playlists - Import", func() { mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") @@ -207,7 +208,7 @@ var _ = Describe("Playlists - Import", func() { mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") @@ -224,7 +225,7 @@ var _ = Describe("Playlists - Import", func() { mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") @@ -242,7 +243,7 @@ var _ = Describe("Playlists - Import", func() { mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") @@ -256,7 +257,7 @@ var _ = Describe("Playlists - Import", func() { tmpDir := GinkgoT().TempDir() mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) m3u := "#EXTALBUMARTURL:https://example.com/new-cover.jpg\ntest.mp3\n" plsFile := filepath.Join(tmpDir, "test.m3u") @@ -283,7 +284,7 @@ var _ = Describe("Playlists - Import", func() { tmpDir := GinkgoT().TempDir() mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) m3u := "test.mp3\n" plsFile := filepath.Join(tmpDir, "test.m3u") @@ -358,7 +359,7 @@ var _ = Describe("Playlists - Import", func() { tmpDir := GinkgoT().TempDir() mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{}} - ps = playlists.NewPlaylists(ds) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) // Create the playlist file on disk with the filesystem's normalization form plsFile := tmpDir + "/" + filesystemName + ".m3u" @@ -418,7 +419,7 @@ var _ = Describe("Playlists - Import", func() { "def.mp3", // This is playlists/def.mp3 relative to plsDir }, } - ps = playlists.NewPlaylists(ds) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) }) It("handles relative paths that reference files in other libraries", func() { @@ -574,7 +575,7 @@ var _ = Describe("Playlists - Import", func() { }, } // Recreate playlists service to pick up new mock - ps = playlists.NewPlaylists(ds) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) // Create playlist in music library that references both tracks plsContent := "#PLAYLIST:Same Path Test\nalbum/track.mp3\n../classical/album/track.mp3" @@ -617,7 +618,7 @@ var _ = Describe("Playlists - Import", func() { BeforeEach(func() { repo = &mockedMediaFileFromListRepo{} ds.MockedMediaFile = repo - ps = playlists.NewPlaylists(ds) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) mockLibRepo.SetData([]model.Library{{ID: 1, Path: "/music"}, {ID: 2, Path: "/new"}}) ctx = request.WithUser(ctx, model.User{ID: "123"}) }) diff --git a/core/playlists/playlists.go b/core/playlists/playlists.go index 0649b16a9..a0086cd2d 100644 --- a/core/playlists/playlists.go +++ b/core/playlists/playlists.go @@ -2,7 +2,6 @@ package playlists import ( "context" - "fmt" "io" "os" "path/filepath" @@ -12,6 +11,7 @@ import ( "github.com/bmatcuk/doublestar/v4" "github.com/deluan/rest" "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -50,12 +50,20 @@ type Playlists interface { TracksRepository(ctx context.Context, playlistId string, refreshSmartPlaylist bool) rest.Repository } -type playlists struct { - ds model.DataStore +// ImageUploadService is a local interface satisfied by core.ImageUploadService. +// Defined here to avoid an import cycle between core and core/playlists. +type ImageUploadService interface { + SetImage(ctx context.Context, entityType string, entityID string, name string, oldPath string, reader io.Reader, ext string) (filename string, err error) + RemoveImage(ctx context.Context, path string) error } -func NewPlaylists(ds model.DataStore) Playlists { - return &playlists{ds: ds} +type playlists struct { + ds model.DataStore + imgUpload ImageUploadService +} + +func NewPlaylists(ds model.DataStore, imgUpload ImageUploadService) Playlists { + return &playlists{ds: ds, imgUpload: imgUpload} } func InPath(folder model.Folder) bool { @@ -288,33 +296,13 @@ func (s *playlists) SetImage(ctx context.Context, playlistID string, reader io.R return err } - filename := pls.ImageFilename(ext) oldPath := pls.UploadedImagePath() - pls.UploadedImage = filename - absPath := pls.UploadedImagePath() - - if err := os.MkdirAll(filepath.Dir(absPath), 0755); err != nil { - return fmt.Errorf("creating playlist images directory: %w", err) - } - - // Remove old image if it exists - if oldPath != "" { - if err := os.Remove(oldPath); err != nil && !os.IsNotExist(err) { - log.Warn(ctx, "Failed to remove old playlist image", "path", oldPath, err) - } - } - - // Save new image - f, err := os.Create(absPath) + filename, err := s.imgUpload.SetImage(ctx, consts.EntityPlaylist, pls.ID, pls.Name, oldPath, reader, ext) if err != nil { - return fmt.Errorf("creating playlist image file: %w", err) - } - defer f.Close() - - if _, err := io.Copy(f, reader); err != nil { - return fmt.Errorf("writing playlist image file: %w", err) + return err } + pls.UploadedImage = filename return s.ds.Playlist(ctx).Put(pls) } @@ -324,10 +312,8 @@ func (s *playlists) RemoveImage(ctx context.Context, playlistID string) error { return err } - if path := pls.UploadedImagePath(); path != "" { - if err := os.Remove(path); err != nil && !os.IsNotExist(err) { - log.Warn(ctx, "Failed to remove playlist image", "path", path, err) - } + if err := s.imgUpload.RemoveImage(ctx, pls.UploadedImagePath()); err != nil { + return err } pls.UploadedImage = "" diff --git a/core/playlists/playlists_test.go b/core/playlists/playlists_test.go index ec73c329a..52d5c88d8 100644 --- a/core/playlists/playlists_test.go +++ b/core/playlists/playlists_test.go @@ -8,6 +8,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/criteria" @@ -41,7 +42,7 @@ var _ = Describe("Playlists", func() { "pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"}, } mockPlsRepo.TracksRepo = mockTracks - ps = playlists.NewPlaylists(ds) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) }) It("allows owner to delete their playlist", func() { @@ -80,7 +81,7 @@ var _ = Describe("Playlists", func() { "pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1", Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}}, } - ps = playlists.NewPlaylists(ds) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) }) It("creates a new playlist with owner set from context", func() { @@ -138,7 +139,7 @@ var _ = Describe("Playlists", func() { Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}}, } mockPlsRepo.TracksRepo = mockTracks - ps = playlists.NewPlaylists(ds) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) }) It("allows owner to update their playlist", func() { @@ -201,7 +202,7 @@ var _ = Describe("Playlists", func() { "pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"}, } mockPlsRepo.TracksRepo = mockTracks - ps = playlists.NewPlaylists(ds) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) }) It("allows owner to add tracks", func() { @@ -249,7 +250,7 @@ var _ = Describe("Playlists", func() { Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}}, } mockPlsRepo.TracksRepo = mockTracks - ps = playlists.NewPlaylists(ds) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) }) It("allows owner to remove tracks", func() { @@ -283,7 +284,7 @@ var _ = Describe("Playlists", func() { Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}}, } mockPlsRepo.TracksRepo = mockTracks - ps = playlists.NewPlaylists(ds) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) }) It("allows owner to reorder", func() { @@ -312,7 +313,7 @@ var _ = Describe("Playlists", func() { "pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"}, "pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"}, } - ps = playlists.NewPlaylists(ds) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) }) It("saves image file and updates UploadedImage", func() { @@ -382,7 +383,7 @@ var _ = Describe("Playlists", func() { "pls-empty": {ID: "pls-empty", Name: "No Cover", OwnerID: "user-1"}, "pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"}, } - ps = playlists.NewPlaylists(ds) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) }) It("removes file and clears UploadedImage", func() { diff --git a/core/playlists/rest_adapter_test.go b/core/playlists/rest_adapter_test.go index 70ca8a9e4..097bc6310 100644 --- a/core/playlists/rest_adapter_test.go +++ b/core/playlists/rest_adapter_test.go @@ -5,6 +5,7 @@ import ( "time" "github.com/deluan/rest" + "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/criteria" @@ -36,7 +37,7 @@ var _ = Describe("REST Adapter", func() { mockPlsRepo.Data = map[string]*model.Playlist{ "pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"}, } - ps = playlists.NewPlaylists(ds) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) }) Describe("Save", func() { diff --git a/core/wire_providers.go b/core/wire_providers.go index 153df7262..276d9556a 100644 --- a/core/wire_providers.go +++ b/core/wire_providers.go @@ -23,6 +23,8 @@ var Set = wire.NewSet( NewLibrary, NewUser, NewMaintenance, + NewImageUploadService, + wire.Bind(new(playlists.ImageUploadService), new(ImageUploadService)), stream.NewTranscodeDecider, agents.GetAgents, external.NewProvider, diff --git a/db/migrations/20260315233131_add_artist_uploaded_image.go b/db/migrations/20260315233131_add_artist_uploaded_image.go new file mode 100644 index 000000000..964e346f5 --- /dev/null +++ b/db/migrations/20260315233131_add_artist_uploaded_image.go @@ -0,0 +1,22 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upAddArtistUploadedImage, downAddArtistUploadedImage) +} + +func upAddArtistUploadedImage(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, `ALTER TABLE artist ADD COLUMN uploaded_image VARCHAR(255) DEFAULT ''`) + return err +} + +func downAddArtistUploadedImage(ctx context.Context, tx *sql.Tx) error { + // This code is executed when the migration is rolled back. + return nil +} diff --git a/model/artist.go b/model/artist.go index 309ee800f..2085f0051 100644 --- a/model/artist.go +++ b/model/artist.go @@ -4,6 +4,8 @@ import ( "maps" "slices" "time" + + "github.com/navidrome/navidrome/consts" ) type Artist struct { @@ -34,6 +36,8 @@ type Artist struct { Missing bool `structs:"missing" json:"missing"` + UploadedImage string `structs:"uploaded_image" json:"uploadedImage,omitempty"` + CreatedAt *time.Time `structs:"created_at" json:"createdAt,omitempty"` UpdatedAt *time.Time `structs:"updated_at" json:"updatedAt,omitempty"` } @@ -58,6 +62,10 @@ func (a Artist) CoverArtID() ArtworkID { return artworkIDFromArtist(a) } +func (a Artist) UploadedImagePath() string { + return UploadedImagePath(consts.EntityArtist, a.UploadedImage) +} + // Roles returns the roles this artist has participated in., based on the Stats field func (a Artist) Roles() []Role { return slices.Collect(maps.Keys(a.Stats)) diff --git a/model/artist_test.go b/model/artist_test.go new file mode 100644 index 000000000..5a24504eb --- /dev/null +++ b/model/artist_test.go @@ -0,0 +1,30 @@ +package model_test + +import ( + "path/filepath" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Artist", func() { + Describe("UploadedImagePath", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.DataFolder = "/data" + }) + + It("returns empty string when no image uploaded", func() { + a := model.Artist{ID: "ar-1"} + Expect(a.UploadedImagePath()).To(BeEmpty()) + }) + + It("returns full path when image is set", func() { + a := model.Artist{ID: "ar-1", UploadedImage: "ar-1_test.jpg"} + Expect(a.UploadedImagePath()).To(Equal(filepath.Join("/data", "artwork", "artist", "ar-1_test.jpg"))) + }) + }) +}) diff --git a/model/image.go b/model/image.go new file mode 100644 index 000000000..68d8ae64c --- /dev/null +++ b/model/image.go @@ -0,0 +1,17 @@ +package model + +import ( + "path/filepath" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" +) + +// UploadedImagePath returns the absolute filesystem path for a manually uploaded +// entity cover image. Returns empty string if filename is empty. +func UploadedImagePath(entityType, filename string) string { + if filename == "" { + return "" + } + return filepath.Join(conf.Server.DataFolder, consts.ArtworkFolder, entityType, filename) +} diff --git a/model/playlist.go b/model/playlist.go index 5c9052eb7..e2f93993d 100644 --- a/model/playlist.go +++ b/model/playlist.go @@ -1,15 +1,12 @@ package model import ( - "path/filepath" "slices" "strconv" "time" - "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/model/criteria" - "github.com/navidrome/navidrome/utils" ) type Playlist struct { @@ -108,16 +105,6 @@ func (pls *Playlist) AddMediaFiles(mfs MediaFiles) { pls.refreshStats() } -// ImageFilename returns a human-friendly filename for an uploaded playlist cover image. -// Format: _, falling back to if the name cleans to empty. -func (pls Playlist) ImageFilename(ext string) string { - clean := utils.CleanFileName(pls.Name) - if clean == "" { - return pls.ID + ext - } - return pls.ID + "_" + clean + ext -} - func (pls Playlist) CoverArtID() ArtworkID { return artworkIDFromPlaylist(pls) } @@ -127,10 +114,7 @@ func (pls Playlist) CoverArtID() ArtworkID { // This does NOT cover sidecar images or external URLs — those are resolved // by the artwork reader's fallback chain. func (pls Playlist) UploadedImagePath() string { - if pls.UploadedImage == "" { - return "" - } - return filepath.Join(conf.Server.DataFolder, consts.ArtworkFolder, "playlist", pls.UploadedImage) + return UploadedImagePath(consts.EntityPlaylist, pls.UploadedImage) } type Playlists []Playlist diff --git a/model/playlist_test.go b/model/playlist_test.go index 98dd4e978..a54cecd53 100644 --- a/model/playlist_test.go +++ b/model/playlist_test.go @@ -7,28 +7,6 @@ import ( ) var _ = Describe("Playlist", func() { - Describe("ImageFilename", func() { - It("returns ID_cleanname.ext for a normal name", func() { - pls := model.Playlist{ID: "abc123", Name: "My Cool Playlist"} - Expect(pls.ImageFilename(".jpg")).To(Equal("abc123_my_cool_playlist.jpg")) - }) - - It("falls back to ID.ext when name cleans to empty", func() { - pls := model.Playlist{ID: "abc123", Name: "!!!"} - Expect(pls.ImageFilename(".png")).To(Equal("abc123.png")) - }) - - It("falls back to ID.ext for empty name", func() { - pls := model.Playlist{ID: "abc123", Name: ""} - Expect(pls.ImageFilename(".jpg")).To(Equal("abc123.jpg")) - }) - - It("handles names with special characters", func() { - pls := model.Playlist{ID: "x1", Name: "Rock & Roll! (2024)"} - Expect(pls.ImageFilename(".webp")).To(Equal("x1_rock__roll_2024.webp")) - }) - }) - Describe("ToM3U8()", func() { var pls model.Playlist BeforeEach(func() { diff --git a/resources/i18n/bg.json b/resources/i18n/bg.json index 626657129..7a0281f33 100644 --- a/resources/i18n/bg.json +++ b/resources/i18n/bg.json @@ -219,19 +219,13 @@ "saveQueue": "Запазване на опашката в плейлист", "searchOrCreate": "Търсете в плейлисти или пишете, за да създадете нови...", "pressEnterToCreate": "Натиснете Enter, за да създадете нов плейлист", - "removeFromSelection": "Премахване от селекцията", - "uploadCover": "", - "removeCover": "" + "removeFromSelection": "Премахване от селекцията" }, "message": { "duplicate_song": "Добави дублирани песни", "song_exist": "Към плейлиста се добавят дублиращи. Желаете ли да ги добавите или предпочитате да ги пропуснете?", "noPlaylistsFound": "Няма намерени плейлисти", - "noPlaylists": "Няма налични плейлисти", - "coverUploaded": "", - "coverRemoved": "", - "coverUploadError": "", - "coverRemoveError": "" + "noPlaylists": "Няма налични плейлисти" } }, "radio": { @@ -718,4 +712,4 @@ "empty": "Нищо не се възпроизвежда", "minutesAgo": "преди %{smart_count} минута |||| преди %{smart_count} минути" } -} \ No newline at end of file +} diff --git a/resources/i18n/ca.json b/resources/i18n/ca.json index 079c130d1..1ef2ce016 100644 --- a/resources/i18n/ca.json +++ b/resources/i18n/ca.json @@ -219,19 +219,13 @@ "saveQueue": "Desar la cua a una llista", "searchOrCreate": "Cerca llistes o escriu per crear-ne de noves...", "pressEnterToCreate": "Prem Retorn per crear una nova llista", - "removeFromSelection": "Elimina de la selecció", - "uploadCover": "", - "removeCover": "" + "removeFromSelection": "Elimina de la selecció" }, "message": { "duplicate_song": "Afegeix cançons duplicades", "song_exist": "Heu afegit duplicats a la llista. Voleu afegir-los o ignorar-los?", "noPlaylistsFound": "No s'ha trobat cap llista", - "noPlaylists": "No hi ha cap llista disponible", - "coverUploaded": "", - "coverRemoved": "", - "coverUploadError": "", - "coverRemoveError": "" + "noPlaylists": "No hi ha cap llista disponible" } }, "radio": { @@ -718,4 +712,4 @@ "empty": "No s'està reproduint res", "minutesAgo": "Fa %{smart_count} minut |||| Fa %{smart_count} minuts" } -} \ No newline at end of file +} diff --git a/resources/i18n/da.json b/resources/i18n/da.json index a7c090cca..a47b30bbc 100644 --- a/resources/i18n/da.json +++ b/resources/i18n/da.json @@ -219,19 +219,13 @@ "saveQueue": "Gem kø på afspilningsliste", "searchOrCreate": "Søg i afspilningslister eller skriv for at oprette nye...", "pressEnterToCreate": "Tryk Enter for at oprette en ny afspilningsliste", - "removeFromSelection": "Fjern fra valg", - "uploadCover": "", - "removeCover": "" + "removeFromSelection": "Fjern fra valg" }, "message": { "duplicate_song": "Tilføj dubletter af sange", "song_exist": "Der føjes dubletter til playlisten", "noPlaylistsFound": "Ingen playlister fundet", - "noPlaylists": "Ingen tilgængelige playlister", - "coverUploaded": "", - "coverRemoved": "", - "coverUploadError": "", - "coverRemoveError": "" + "noPlaylists": "Ingen tilgængelige playlister" } }, "radio": { @@ -718,4 +712,4 @@ "empty": "Intet afspilles nu", "minutesAgo": "for %{smart_count} minut siden |||| for %{smart_count} minutter siden" } -} \ No newline at end of file +} diff --git a/resources/i18n/de.json b/resources/i18n/de.json index 6ba8a14eb..ab1760ed5 100644 --- a/resources/i18n/de.json +++ b/resources/i18n/de.json @@ -219,19 +219,13 @@ "saveQueue": "Warteschlange in Wiedergabeliste speichern", "searchOrCreate": "Wiedergabeliste suchen oder neue erstellen...", "pressEnterToCreate": "Enter drücken um neue Wiedergabeliste zu erstellen", - "removeFromSelection": "Von Auswahl entfernen", - "uploadCover": "Cover hochladen", - "removeCover": "Cover entfernen" + "removeFromSelection": "Von Auswahl entfernen" }, "message": { "duplicate_song": "Duplikate hinzufügen", "song_exist": "Manche Titel sind bereits in der Playlist. Möchtest du sie trotzdem hinzufügen oder überspringen?", "noPlaylistsFound": "Keine Wiedergabeliste gefunden", - "noPlaylists": "Keine Wiedergabelisten vorhanden", - "coverUploaded": "Cover aktualisiert", - "coverRemoved": "Cover entfernt", - "coverUploadError": "Fehler beim Hochladen des Covers", - "coverRemoveError": "Fehler beim Entfernen des Covers" + "noPlaylists": "Keine Wiedergabelisten vorhanden" } }, "radio": { @@ -597,7 +591,13 @@ "remove_all_missing_content": "Möchtest du wirklich alle Fehlenden Dateien aus der Datenbank entfernen? Alle Referenzen zu den Dateien wie Anzahl Wiedergaben und Bewertungen werden permanent gelöscht.", "noSimilarSongsFound": "Keine ähnlichen Titel gefunden", "noTopSongsFound": "Keine beliebten Titel gefunden", - "startingInstantMix": "Lade Sofort-Mix..." + "startingInstantMix": "Lade Sofort-Mix...", + "uploadCover": "Cover hochladen", + "removeCover": "Cover entfernen", + "coverUploaded": "Cover aktualisiert", + "coverRemoved": "Cover entfernt", + "coverUploadError": "Fehler beim Hochladen des Covers", + "coverRemoveError": "Fehler beim Entfernen des Covers" }, "menu": { "library": "Bibliothek", @@ -718,4 +718,4 @@ "empty": "Keine Wiedergabe", "minutesAgo": "Vor %{smart_count} Minute |||| Vor %{smart_count} Minuten" } -} \ No newline at end of file +} diff --git a/resources/i18n/el.json b/resources/i18n/el.json index e5d983d6f..019d05978 100644 --- a/resources/i18n/el.json +++ b/resources/i18n/el.json @@ -219,19 +219,13 @@ "saveQueue": "Αποθήκευση ουράς στη λίστα αναπαραγωγής", "searchOrCreate": "Αναζητήστε λίστες αναπαραγωγής ή πληκτρολογήστε για να δημιουργήσετε νέες...", "pressEnterToCreate": "Πατήστε Enter για να δημιουργήσετε νέα λίστα αναπαραγωγής", - "removeFromSelection": "Αφαίρεση από την επιλογή", - "uploadCover": "", - "removeCover": "" + "removeFromSelection": "Αφαίρεση από την επιλογή" }, "message": { "duplicate_song": "Προσθήκη διπλοεγγραφών τραγουδιών", "song_exist": "Υπάρχουν διπλοεγγραφές στην λίστα αναπαραγωγής. Θέλετε να προστεθούν οι διπλοεγγραφές ή να τις παραβλέψετε?", "noPlaylistsFound": "Δεν βρέθηκαν λίστες αναπαραγωγής", - "noPlaylists": "Δεν υπάρχουν διαθέσιμες λίστες αναπαραγωγής", - "coverUploaded": "", - "coverRemoved": "", - "coverUploadError": "", - "coverRemoveError": "" + "noPlaylists": "Δεν υπάρχουν διαθέσιμες λίστες αναπαραγωγής" } }, "radio": { @@ -718,4 +712,4 @@ "empty": "Δεν παίζει τίποτα", "minutesAgo": "%{smart_count} λεπτό πριν |||| %{smart_count} λεπτά πριν" } -} \ No newline at end of file +} diff --git a/resources/i18n/es.json b/resources/i18n/es.json index ef246a0e6..29d1a367f 100644 --- a/resources/i18n/es.json +++ b/resources/i18n/es.json @@ -219,19 +219,13 @@ "saveQueue": "Guardar la fila de reproducción en una playlist", "searchOrCreate": "Buscar listas de reproducción o escribe para crear una nueva…", "pressEnterToCreate": "Pulsa Enter para crear una nueva lista de reproducción", - "removeFromSelection": "Quitar de la selección", - "uploadCover": "", - "removeCover": "" + "removeFromSelection": "Quitar de la selección" }, "message": { "duplicate_song": "Algunas de las canciones seleccionadas están presentes en la playlist", "song_exist": "Se están agregando duplicados a la playlist. ¿Quieres agregar los duplicados o omitirlos?", "noPlaylistsFound": "No se encontraron listas de reproducción", - "noPlaylists": "No hay listas de reproducción disponibles", - "coverUploaded": "", - "coverRemoved": "", - "coverUploadError": "", - "coverRemoveError": "" + "noPlaylists": "No hay listas de reproducción disponibles" } }, "radio": { @@ -718,4 +712,4 @@ "empty": "Nada en reproducción", "minutesAgo": "Hace %{smart_count} minuto |||| Hace %{smart_count} minutos" } -} \ No newline at end of file +} diff --git a/resources/i18n/fi.json b/resources/i18n/fi.json index ea5d22f4c..59f353350 100644 --- a/resources/i18n/fi.json +++ b/resources/i18n/fi.json @@ -219,19 +219,13 @@ "saveQueue": "Tallenna jono soittolistaan", "searchOrCreate": "Etsi soittolistoja tai kirjoita luodaksesi uuden...", "pressEnterToCreate": "Paina Enter luodaksesi uuden soittolistan", - "removeFromSelection": "Poista valinnasta", - "uploadCover": "Lataa kansikuva", - "removeCover": "Poista kansikuva" + "removeFromSelection": "Poista valinnasta" }, "message": { "duplicate_song": "Lisää olemassa oleva kappale", "song_exist": "Olet lisäämässä soittolistalla jo olevaa kappaletta. Haluatko lisätä saman kappaleen vai ohittaa sen?", "noPlaylistsFound": "Soittolistoja ei löytynyt", - "noPlaylists": "Soittolistoja ei ole saatavilla", - "coverUploaded": "Kansikuva päivitetty", - "coverRemoved": "Kansikuva poistettu", - "coverUploadError": "Virhe ladattaessa kansikuvaa", - "coverRemoveError": "Virhe poistettaessa kansikuvaa" + "noPlaylists": "Soittolistoja ei ole saatavilla" } }, "radio": { @@ -597,7 +591,13 @@ "remove_all_missing_content": "Haluatko varmasti poistaa kaikki puuttuvat tiedostot tietokannasta? Tämä poistaa pysyvästi kaikki viittaukset niihin, mukaan lukien toistomäärät ja arvostelut.", "noSimilarSongsFound": "Samankaltaisia kappaleita ei löytynyt", "noTopSongsFound": "Suosituimpia kappaleita ei löytynyt", - "startingInstantMix": "Ladataan Pikasekoitus..." + "startingInstantMix": "Ladataan Pikasekoitus...", + "uploadCover": "Lataa kansikuva", + "removeCover": "Poista kansikuva", + "coverUploaded": "Kansikuva päivitetty", + "coverRemoved": "Kansikuva poistettu", + "coverUploadError": "Virhe ladattaessa kansikuvaa", + "coverRemoveError": "Virhe poistettaessa kansikuvaa" }, "menu": { "library": "Kirjasto", @@ -718,4 +718,4 @@ "empty": "Ei soita mitään", "minutesAgo": "%{smart_count} minuutti sitten |||| %{smart_count} minuuttia sitten" } -} \ No newline at end of file +} diff --git a/resources/i18n/fr.json b/resources/i18n/fr.json index 57af0b8cd..891fde03a 100644 --- a/resources/i18n/fr.json +++ b/resources/i18n/fr.json @@ -219,19 +219,13 @@ "saveQueue": "Sauvegarder la file de lecture dans la playlist", "searchOrCreate": "Chercher ou créer une nouvelle playlist...", "pressEnterToCreate": "Appuyer sur entrée pour créer une nouvelle playlist", - "removeFromSelection": "Supprimer de la sélection", - "uploadCover": "", - "removeCover": "" + "removeFromSelection": "Supprimer de la sélection" }, "message": { "duplicate_song": "Ajouter les titres déjà présents dans la playlist", "song_exist": "Certains des titres sélectionnés font déjà partie de la playlist. Voulez-vous les ajouter ou les ignorer ?", "noPlaylistsFound": "Aucune playlist trouvée", - "noPlaylists": "Aucune playlist disponible", - "coverUploaded": "", - "coverRemoved": "", - "coverUploadError": "", - "coverRemoveError": "" + "noPlaylists": "Aucune playlist disponible" } }, "radio": { @@ -718,4 +712,4 @@ "empty": "Aucun titre en cours de lecture", "minutesAgo": "Il y a %{smart_count} minute |||| Il y a %{smart_count} minutes" } -} \ No newline at end of file +} diff --git a/resources/i18n/gl.json b/resources/i18n/gl.json index c10560912..aba22b714 100644 --- a/resources/i18n/gl.json +++ b/resources/i18n/gl.json @@ -219,19 +219,13 @@ "saveQueue": "Salvar a Cola como Lista de reprodución", "searchOrCreate": "Buscar listas ou escribe para crear nova…", "pressEnterToCreate": "Preme Enter para crear nova lista", - "removeFromSelection": "Retirar da selección", - "uploadCover": "", - "removeCover": "" + "removeFromSelection": "Retirar da selección" }, "message": { "duplicate_song": "Engadir cancións duplicadas", "song_exist": "Hai duplicadas que serán engadidas á lista de reprodución. Desexas engadir as duplicadas ou omitilas?", "noPlaylistsFound": "Sen listas de reprodución", - "noPlaylists": "Sen listas dispoñibles", - "coverUploaded": "", - "coverRemoved": "", - "coverUploadError": "", - "coverRemoveError": "" + "noPlaylists": "Sen listas dispoñibles" } }, "radio": { @@ -718,4 +712,4 @@ "empty": "Sen reprodución", "minutesAgo": "hai %{smart_count} minuto |||| hai %{smart_count} minutos" } -} \ No newline at end of file +} diff --git a/resources/i18n/pt-br.json b/resources/i18n/pt-br.json index 6c9e154d1..bc2a5f85e 100644 --- a/resources/i18n/pt-br.json +++ b/resources/i18n/pt-br.json @@ -218,15 +218,9 @@ "saveQueue": "Salvar fila em nova Playlist", "searchOrCreate": "Buscar playlists ou criar nova...", "pressEnterToCreate": "Pressione Enter para criar nova playlist", - "removeFromSelection": "Remover da seleção", - "uploadCover": "Enviar Capa", - "removeCover": "Remover Capa" + "removeFromSelection": "Remover da seleção" }, "message": { - "coverUploaded": "Capa atualizada", - "coverRemoved": "Capa removida", - "coverUploadError": "Erro ao enviar capa", - "coverRemoveError": "Erro ao remover capa", "duplicate_song": "Adicionar músicas duplicadas", "song_exist": "Algumas destas músicas já existem na playlist. Você quer adicionar as duplicadas ou ignorá-las?", "noPlaylistsFound": "Nenhuma playlist encontrada", @@ -560,6 +554,12 @@ } }, "message": { + "uploadCover": "Enviar Capa", + "removeCover": "Remover Capa", + "coverUploaded": "Capa atualizada", + "coverRemoved": "Capa removida", + "coverUploadError": "Erro ao enviar capa", + "coverRemoveError": "Erro ao remover capa", "note": "ATENÇÃO", "transcodingDisabled": "Por questão de segurança, esta tela de configuração está desabilitada. Se você quiser alterar estas configurações, reinicie o servidor com a opção %{config}", "transcodingEnabled": "Navidrome está sendo executado com a opção %{config}. Isto permite que potencialmente se execute comandos do sistema pela interface Web. É recomendado que vc mantenha esta opção desabilitada, e só a habilite quando precisar configurar opções de Conversão", diff --git a/resources/i18n/ru.json b/resources/i18n/ru.json index 6f0439a38..78e7cfa26 100644 --- a/resources/i18n/ru.json +++ b/resources/i18n/ru.json @@ -219,19 +219,13 @@ "saveQueue": "Сохранить очередь в плейлист", "searchOrCreate": "Поиск плейлистов или введите текст для создания новых...", "pressEnterToCreate": "Нажмите Enter, чтобы создать новый список воспроизведения", - "removeFromSelection": "Удалить из списка выделенных", - "uploadCover": "", - "removeCover": "" + "removeFromSelection": "Удалить из списка выделенных" }, "message": { "duplicate_song": "Повторяющиеся треки", "song_exist": "Некоторые треки уже есть в плейлисте. Вы хотите добавить их или пропустить?", "noPlaylistsFound": "Плейлисты не найдены", - "noPlaylists": "Нет доступных плейлистов", - "coverUploaded": "", - "coverRemoved": "", - "coverUploadError": "", - "coverRemoveError": "" + "noPlaylists": "Нет доступных плейлистов" } }, "radio": { @@ -718,4 +712,4 @@ "empty": "Ничего не играет", "minutesAgo": "%{smart_count} минут назад |||| %{smart_count} минут назад" } -} \ No newline at end of file +} diff --git a/resources/i18n/sl.json b/resources/i18n/sl.json index 2ea1cad49..ceb56e9b7 100644 --- a/resources/i18n/sl.json +++ b/resources/i18n/sl.json @@ -219,19 +219,13 @@ "saveQueue": "Shrani čakalno vrsto na seznam predvajanja", "searchOrCreate": "Iščite po seznamih predvajanja ali vnesite besedilo, da ustvarite nove ...", "pressEnterToCreate": "Pritisnite Enter za ustvarjanje novega seznama predvajanja", - "removeFromSelection": "Odstrani iz izbora", - "uploadCover": "", - "removeCover": "" + "removeFromSelection": "Odstrani iz izbora" }, "message": { "duplicate_song": "Dodaj podvojene pesmi", "song_exist": "Seznamu predvajanja boste dodali duplikate. Jih želite dodati ali izpustiti?", "noPlaylistsFound": "Ni najdenih seznamov predvajanja", - "noPlaylists": "Ni na voljo seznamov predvajanja", - "coverUploaded": "", - "coverRemoved": "", - "coverUploadError": "", - "coverRemoveError": "" + "noPlaylists": "Ni na voljo seznamov predvajanja" } }, "radio": { @@ -718,4 +712,4 @@ "empty": "Nič se ne predvaja", "minutesAgo": "Pred %{smart_count} minuto |||| Pred %{smart_count} minutami" } -} \ No newline at end of file +} diff --git a/resources/i18n/sv.json b/resources/i18n/sv.json index b792943d0..23bd5fbc2 100644 --- a/resources/i18n/sv.json +++ b/resources/i18n/sv.json @@ -219,19 +219,13 @@ "saveQueue": "Spara kö till spellista", "searchOrCreate": "Sök spellista eller skapa ny...", "pressEnterToCreate": "Tryck Enter för att skapa ny spellista", - "removeFromSelection": "Ta bort från urval", - "uploadCover": "", - "removeCover": "" + "removeFromSelection": "Ta bort från urval" }, "message": { "duplicate_song": "Lägg till dubletter", "song_exist": "Vissa låtar finns redan i spellistan. Vill du lägga till dubbletterna eller hoppa över dem?", "noPlaylistsFound": "Hittade inga spellistor", - "noPlaylists": "Inga spellistor tillgängliga", - "coverUploaded": "", - "coverRemoved": "", - "coverUploadError": "", - "coverRemoveError": "" + "noPlaylists": "Inga spellistor tillgängliga" } }, "radio": { @@ -718,4 +712,4 @@ "empty": "Inget spelas", "minutesAgo": "%{smart_count} minut sedan |||| %{smart_count} minuter sedan" } -} \ No newline at end of file +} diff --git a/resources/i18n/th.json b/resources/i18n/th.json index d3d64bdbf..b445d7464 100644 --- a/resources/i18n/th.json +++ b/resources/i18n/th.json @@ -219,19 +219,13 @@ "saveQueue": "บันทึกคิวลงเพลย์ลิสต์", "searchOrCreate": "ค้นหาเพลย์ลิสต์หรือพิมพ์เพื่อสร้างใหม่", "pressEnterToCreate": "กด Enter เพื่อสร้างเพลย์ลิสต์", - "removeFromSelection": "เอาออกจากที่เลือกไว้", - "uploadCover": "", - "removeCover": "" + "removeFromSelection": "เอาออกจากที่เลือกไว้" }, "message": { "duplicate_song": "เพิ่มเพลงซ้ำ", "song_exist": "เพิ่มเพลงซ้ำกันในเพลย์ลิสต์ คุณจะเพิ่มเพลงต่อหรือข้าม", "noPlaylistsFound": "ไม่พบเพลย์ลิสต์", - "noPlaylists": "ไม่มีเพลย์ลิสต์อยู่", - "coverUploaded": "", - "coverRemoved": "", - "coverUploadError": "", - "coverRemoveError": "" + "noPlaylists": "ไม่มีเพลย์ลิสต์อยู่" } }, "radio": { @@ -718,4 +712,4 @@ "empty": "ไม่มีเพลงเล่น", "minutesAgo": "%{smart_count} นาทีที่แล้ว |||| %{smart_count} นาทีที่แล้ว" } -} \ No newline at end of file +} diff --git a/resources/i18n/zh-Hant.json b/resources/i18n/zh-Hant.json index aca2e4742..dabf61bdb 100644 --- a/resources/i18n/zh-Hant.json +++ b/resources/i18n/zh-Hant.json @@ -219,19 +219,13 @@ "saveQueue": "將播放佇列儲存到播放清單", "searchOrCreate": "搜尋播放清單,或輸入名稱來新建…", "pressEnterToCreate": "按 Enter 鍵建立新的播放清單", - "removeFromSelection": "移除選取項目", - "uploadCover": "上傳封面", - "removeCover": "移除封面" + "removeFromSelection": "移除選取項目" }, "message": { "duplicate_song": "加入重複的歌曲", "song_exist": "有重複歌曲正要加入播放清單,您要加入或略過重複歌曲?", "noPlaylistsFound": "找不到播放清單", - "noPlaylists": "暫無播放清單", - "coverUploaded": "已更新封面圖", - "coverRemoved": "已移除封面圖", - "coverUploadError": "上傳封面圖時發生錯誤", - "coverRemoveError": "移除封面圖時發生錯誤" + "noPlaylists": "暫無播放清單" } }, "radio": { @@ -597,7 +591,13 @@ "remove_all_missing_content": "您確定要從媒體庫中刪除所有遺失的檔案嗎?這將永久刪除它們的所有相關資訊,包括它們的播放次數和評分。", "noSimilarSongsFound": "找不到相似歌曲", "noTopSongsFound": "找不到熱門歌曲", - "startingInstantMix": "正在載入即時混音..." + "startingInstantMix": "正在載入即時混音...", + "uploadCover": "上傳封面", + "removeCover": "移除封面", + "coverUploaded": "已更新封面圖", + "coverRemoved": "已移除封面圖", + "coverUploadError": "上傳封面圖時發生錯誤", + "coverRemoveError": "移除封面圖時發生錯誤" }, "menu": { "library": "媒體庫", @@ -718,4 +718,4 @@ "empty": "無播放內容", "minutesAgo": "1 分鐘前 |||| %{smart_count} 分鐘前" } -} \ No newline at end of file +} diff --git a/scanner/controller_test.go b/scanner/controller_test.go index 2af52066b..d60d432b4 100644 --- a/scanner/controller_test.go +++ b/scanner/controller_test.go @@ -5,6 +5,7 @@ import ( "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playlists" @@ -31,7 +32,7 @@ var _ = Describe("Controller", func() { DeferCleanup(configtest.SetupConfig()) ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())} ds.MockedProperty = &tests.MockedPropertyRepo{} - ctrl = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), playlists.NewPlaylists(ds), metrics.NewNoopInstance()) + ctrl = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) }) It("includes last scan error", func() { diff --git a/scanner/scanner_benchmark_test.go b/scanner/scanner_benchmark_test.go index 1ac7b50a4..8f0dcd340 100644 --- a/scanner/scanner_benchmark_test.go +++ b/scanner/scanner_benchmark_test.go @@ -12,6 +12,7 @@ import ( "github.com/dustin/go-humanize" "github.com/google/uuid" "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playlists" @@ -40,7 +41,7 @@ func BenchmarkScan(b *testing.B) { ds := persistence.New(db.Db()) conf.Server.DevExternalScanner = false s := scanner.New(context.Background(), ds, artwork.NoopCacheWarmer(), events.NoopBroker(), - playlists.NewPlaylists(ds), metrics.NewNoopInstance()) + playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) fs := storagetest.FakeFS{} storagetest.Register("fake", &fs) diff --git a/scanner/scanner_multilibrary_test.go b/scanner/scanner_multilibrary_test.go index 6990f1984..856015239 100644 --- a/scanner/scanner_multilibrary_test.go +++ b/scanner/scanner_multilibrary_test.go @@ -11,6 +11,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playlists" @@ -77,7 +78,7 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { Expect(ds.User(ctx).Put(&adminUser)).To(Succeed()) s = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), - playlists.NewPlaylists(ds), metrics.NewNoopInstance()) + playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) // Create two test libraries (let DB auto-assign IDs) lib1 = model.Library{Name: "Rock Collection", Path: "rock:///music"} diff --git a/scanner/scanner_selective_test.go b/scanner/scanner_selective_test.go index 6e4511179..594b74e38 100644 --- a/scanner/scanner_selective_test.go +++ b/scanner/scanner_selective_test.go @@ -8,6 +8,7 @@ import ( "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playlists" @@ -63,7 +64,7 @@ var _ = Describe("ScanFolders", Ordered, func() { Expect(ds.User(ctx).Put(&adminUser)).To(Succeed()) s = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), - playlists.NewPlaylists(ds), metrics.NewNoopInstance()) + playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) lib = model.Library{ID: 1, Name: "Fake Library", Path: "fake:///music"} Expect(ds.Library(ctx).Put(&lib)).To(Succeed()) diff --git a/scanner/scanner_test.go b/scanner/scanner_test.go index d5688a1dc..922d21e62 100644 --- a/scanner/scanner_test.go +++ b/scanner/scanner_test.go @@ -11,6 +11,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playlists" @@ -84,7 +85,7 @@ var _ = Describe("Scanner", Ordered, func() { Expect(ds.User(ctx).Put(&adminUser)).To(Succeed()) s = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), - playlists.NewPlaylists(ds), metrics.NewNoopInstance()) + playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) lib = model.Library{ID: 1, Name: "Fake Library", Path: "fake:///music"} Expect(ds.Library(ctx).Put(&lib)).To(Succeed()) diff --git a/server/e2e/e2e_suite_test.go b/server/e2e/e2e_suite_test.go index ef379ad05..cb851debf 100644 --- a/server/e2e/e2e_suite_test.go +++ b/server/e2e/e2e_suite_test.go @@ -442,7 +442,7 @@ var _ = BeforeSuite(func() { buildTestFS() s := scanner.New(ctx, initDS, artwork.NoopCacheWarmer(), events.NoopBroker(), - playlists.NewPlaylists(initDS), metrics.NewNoopInstance()) + playlists.NewPlaylists(initDS, core.NewImageUploadService()), metrics.NewNoopInstance()) _, err = s.ScanAll(ctx, true) Expect(err).ToNot(HaveOccurred()) @@ -479,7 +479,7 @@ func setupTestDB() { streamerSpy = &spyStreamer{} decider := stream.NewTranscodeDecider(ds, noopFFmpeg{}) s := scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), - playlists.NewPlaylists(ds), metrics.NewNoopInstance()) + playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) router = subsonic.New( ds, noopArtwork{}, @@ -489,7 +489,7 @@ func setupTestDB() { noopProvider{}, s, events.NoopBroker(), - playlists.NewPlaylists(ds), + playlists.NewPlaylists(ds, core.NewImageUploadService()), noopPlayTracker{}, core.NewShare(ds), playback.PlaybackServer(nil), diff --git a/server/e2e/subsonic_multilibrary_test.go b/server/e2e/subsonic_multilibrary_test.go index f59187d00..a837da124 100644 --- a/server/e2e/subsonic_multilibrary_test.go +++ b/server/e2e/subsonic_multilibrary_test.go @@ -6,6 +6,7 @@ import ( "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playlists" @@ -53,7 +54,7 @@ var _ = Describe("Multi-Library Support", Ordered, func() { // Run incremental scan to import lib2 content (lib1 files unchanged → skipped) s := scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), - playlists.NewPlaylists(ds), metrics.NewNoopInstance()) + playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) _, err = s.ScanAll(ctx, false) Expect(err).ToNot(HaveOccurred()) diff --git a/server/nativeapi/artists.go b/server/nativeapi/artists.go new file mode 100644 index 000000000..1b78bb93e --- /dev/null +++ b/server/nativeapi/artists.go @@ -0,0 +1,72 @@ +package nativeapi + +import ( + "context" + "errors" + "io" + "net/http" + "time" + + "github.com/deluan/rest" + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server" +) + +func (api *Router) addArtistRoute(r chi.Router) { + constructor := func(ctx context.Context) rest.Repository { + return api.ds.Resource(ctx, model.Artist{}) + } + r.Route("/artist", func(r chi.Router) { + r.Get("/", rest.GetAll(constructor)) + r.Route("/{id}", func(r chi.Router) { + r.Use(server.URLParamsMiddleware) + r.Get("/", rest.Get(constructor)) + r.Post("/image", api.uploadArtistImage()) + r.Delete("/image", api.deleteArtistImage()) + }) + }) +} + +func (api *Router) uploadArtistImage() http.HandlerFunc { + return handleImageUpload(func(ctx context.Context, reader io.Reader, ext string) error { + artistID := chi.URLParamFromCtx(ctx, "id") + ar, err := api.ds.Artist(ctx).Get(artistID) + if err != nil { + if errors.Is(err, model.ErrNotFound) { + return model.ErrNotFound + } + return err + } + oldPath := ar.UploadedImagePath() + filename, err := api.imgUpload.SetImage(ctx, consts.EntityArtist, ar.ID, ar.Name, oldPath, reader, ext) + if err != nil { + return err + } + ar.UploadedImage = filename + now := time.Now() + ar.UpdatedAt = &now + return api.ds.Artist(ctx).Put(ar, "uploaded_image", "updated_at") + }) +} + +func (api *Router) deleteArtistImage() http.HandlerFunc { + return handleImageDelete(func(ctx context.Context) error { + artistID := chi.URLParamFromCtx(ctx, "id") + ar, err := api.ds.Artist(ctx).Get(artistID) + if err != nil { + if errors.Is(err, model.ErrNotFound) { + return model.ErrNotFound + } + return err + } + if err := api.imgUpload.RemoveImage(ctx, ar.UploadedImagePath()); err != nil { + return err + } + ar.UploadedImage = "" + now := time.Now() + ar.UpdatedAt = &now + return api.ds.Artist(ctx).Put(ar, "uploaded_image", "updated_at") + }) +} diff --git a/server/nativeapi/config_test.go b/server/nativeapi/config_test.go index d7368cabf..4e6e9e89b 100644 --- a/server/nativeapi/config_test.go +++ b/server/nativeapi/config_test.go @@ -28,7 +28,7 @@ var _ = Describe("Config API", func() { conf.Server.DevUIShowConfig = true // Enable config endpoint for tests ds = &tests.MockDataStore{} auth.Init(ds) - nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil) + nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil) router = server.JWTVerifier(nativeRouter) // Create test users diff --git a/server/nativeapi/image_upload.go b/server/nativeapi/image_upload.go new file mode 100644 index 000000000..c29f14bdc --- /dev/null +++ b/server/nativeapi/image_upload.go @@ -0,0 +1,120 @@ +package nativeapi + +import ( + "context" + "errors" + "fmt" + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" + "io" + "net/http" + "path/filepath" + "strings" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + _ "golang.org/x/image/webp" +) + +const maxImageSize = 10 << 20 // 10MB + +func checkImageUploadPermission(w http.ResponseWriter, r *http.Request) bool { + user, _ := request.UserFrom(r.Context()) + if !conf.Server.EnableCoverArtUpload && !user.IsAdmin { + http.Error(w, "cover art upload is disabled", http.StatusForbidden) + return false + } + return true +} + +func handleImageUpload(saveFn func(ctx context.Context, reader io.Reader, ext string) error) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + if !checkImageUploadPermission(w, r) { + return + } + r.Body = http.MaxBytesReader(w, r.Body, maxImageSize) + if err := r.ParseMultipartForm(maxImageSize / 2); err != nil { + log.Error(ctx, "Error parsing multipart form", err) + http.Error(w, "file too large or invalid form", http.StatusBadRequest) + return + } + defer func() { + if r.MultipartForm != nil { + if err := r.MultipartForm.RemoveAll(); err != nil { + log.Warn(ctx, "Error removing multipart temp files", err) + } + } + }() + file, header, err := r.FormFile("image") + if err != nil { + log.Error(ctx, "Error reading uploaded file", err) + http.Error(w, "missing image file", http.StatusBadRequest) + return + } + defer file.Close() + _, format, err := image.DecodeConfig(file) + if err != nil { + log.Error(ctx, "Uploaded file is not a valid image", err) + http.Error(w, "invalid image file", http.StatusBadRequest) + return + } + if seeker, ok := file.(io.Seeker); ok { + if _, err := seeker.Seek(0, io.SeekStart); err != nil { + log.Error(ctx, "Error seeking file", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + } + ext := "." + format + if ext == "." { + ext = strings.ToLower(filepath.Ext(header.Filename)) + } + if ext == "" || ext == "." { + log.Error(ctx, "Could not determine image type", "filename", header.Filename) + http.Error(w, "could not determine image type", http.StatusBadRequest) + return + } + if err := saveFn(ctx, file, ext); err != nil { + if errors.Is(err, model.ErrNotAuthorized) { + http.Error(w, "not authorized", http.StatusForbidden) + return + } + if errors.Is(err, model.ErrNotFound) { + http.Error(w, "not found", http.StatusNotFound) + return + } + log.Error(ctx, "Error saving image", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + _, _ = fmt.Fprintf(w, `{"status":"ok"}`) + } +} + +func handleImageDelete(deleteFn func(ctx context.Context) error) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + if !checkImageUploadPermission(w, r) { + return + } + if err := deleteFn(ctx); err != nil { + if errors.Is(err, model.ErrNotAuthorized) { + http.Error(w, "not authorized", http.StatusForbidden) + return + } + if errors.Is(err, model.ErrNotFound) { + http.Error(w, "not found", http.StatusNotFound) + return + } + log.Error(ctx, "Error removing image", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + _, _ = fmt.Fprintf(w, `{"status":"ok"}`) + } +} diff --git a/server/nativeapi/library_test.go b/server/nativeapi/library_test.go index 5b9cf7e4e..ed5564a41 100644 --- a/server/nativeapi/library_test.go +++ b/server/nativeapi/library_test.go @@ -29,7 +29,7 @@ var _ = Describe("Library API", func() { DeferCleanup(configtest.SetupConfig()) ds = &tests.MockDataStore{} auth.Init(ds) - nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil) + nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil) router = server.JWTVerifier(nativeRouter) // Create test users diff --git a/server/nativeapi/native_api.go b/server/nativeapi/native_api.go index 3191991eb..3ef00ebb1 100644 --- a/server/nativeapi/native_api.go +++ b/server/nativeapi/native_api.go @@ -44,10 +44,11 @@ type Router struct { users core.User maintenance core.Maintenance pluginManager PluginManager + imgUpload core.ImageUploadService } -func New(ds model.DataStore, share core.Share, playlists playlistsvc.Playlists, insights metrics.Insights, libraryService core.Library, userService core.User, maintenance core.Maintenance, pluginManager PluginManager) *Router { - r := &Router{ds: ds, share: share, playlists: playlists, insights: insights, libs: libraryService, users: userService, maintenance: maintenance, pluginManager: pluginManager} +func New(ds model.DataStore, share core.Share, playlists playlistsvc.Playlists, insights metrics.Insights, libraryService core.Library, userService core.User, maintenance core.Maintenance, pluginManager PluginManager, imgUpload core.ImageUploadService) *Router { + r := &Router{ds: ds, share: share, playlists: playlists, insights: insights, libs: libraryService, users: userService, maintenance: maintenance, pluginManager: pluginManager, imgUpload: imgUpload} r.Handler = r.routes() return r } @@ -66,7 +67,7 @@ func (api *Router) routes() http.Handler { api.RX(r, "/user", api.users.NewRepository, true) api.R(r, "/song", model.MediaFile{}, false) api.R(r, "/album", model.Album{}, false) - api.R(r, "/artist", model.Artist{}, false) + api.addArtistRoute(r) api.R(r, "/genre", model.Genre{}, false) api.R(r, "/player", model.Player{}, true) api.R(r, "/transcoding", model.Transcoding{}, conf.Server.EnableTranscodingConfig) diff --git a/server/nativeapi/native_api_song_test.go b/server/nativeapi/native_api_song_test.go index b192e00ac..f0ee50ebb 100644 --- a/server/nativeapi/native_api_song_test.go +++ b/server/nativeapi/native_api_song_test.go @@ -94,7 +94,7 @@ var _ = Describe("Song Endpoints", func() { mfRepo.SetData(testSongs) // Create the native API router and wrap it with the JWTVerifier middleware - nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil) + nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil) router = server.JWTVerifier(nativeRouter) w = httptest.NewRecorder() }) diff --git a/server/nativeapi/playlists.go b/server/nativeapi/playlists.go index 118528f68..ea1cf579b 100644 --- a/server/nativeapi/playlists.go +++ b/server/nativeapi/playlists.go @@ -5,25 +5,17 @@ import ( "encoding/json" "errors" "fmt" - "image" - _ "image/gif" - _ "image/jpeg" - _ "image/png" "io" "net/http" - "path/filepath" "strconv" "strings" "github.com/deluan/rest" "github.com/go-chi/chi/v5" - "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/utils/req" - _ "golang.org/x/image/webp" ) type restHandler = func(rest.RepositoryConstructor, ...rest.Logger) http.HandlerFunc @@ -234,110 +226,16 @@ func getSongPlaylists(svc playlists.Playlists) http.HandlerFunc { } } -const maxImageSize = 10 << 20 // 10MB - func uploadPlaylistImage(pls playlists.Playlists) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - user, _ := request.UserFrom(ctx) - if !conf.Server.EnableCoverArtUpload && !user.IsAdmin { - http.Error(w, "cover art upload is disabled", http.StatusForbidden) - return - } - p := req.Params(r) - playlistId, _ := p.String(":id") - - if err := r.ParseMultipartForm(maxImageSize); err != nil { //nolint:gosec // size is limited by maxImageSize parameter - log.Error(ctx, "Error parsing multipart form", err) - http.Error(w, "file too large or invalid form", http.StatusBadRequest) - return - } - - file, header, err := r.FormFile("image") - if err != nil { - log.Error(ctx, "Error reading uploaded file", err) - http.Error(w, "missing image file", http.StatusBadRequest) - return - } - defer file.Close() - - // Validate the uploaded file is a valid image - _, format, err := image.DecodeConfig(file) - if err != nil { - log.Error(ctx, "Uploaded file is not a valid image", err) - http.Error(w, "invalid image file", http.StatusBadRequest) - return - } - - // Reset reader after DecodeConfig consumed some bytes - if seeker, ok := file.(io.Seeker); ok { - if _, err := seeker.Seek(0, io.SeekStart); err != nil { - log.Error(ctx, "Error seeking file", err) - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - } - - // Determine file extension from decoded format or original filename - ext := "." + format - if ext == "." { - ext = strings.ToLower(filepath.Ext(header.Filename)) - } - if ext == "" || ext == "." { - log.Error(ctx, "Could not determine image type", "playlistId", playlistId, "filename", header.Filename) - http.Error(w, "could not determine image type", http.StatusBadRequest) - return - } - - err = pls.SetImage(ctx, playlistId, file, ext) - if errors.Is(err, model.ErrNotAuthorized) { - log.Error(ctx, "Not authorized to upload playlist image", "playlistId", playlistId, err) - http.Error(w, "not authorized", http.StatusForbidden) - return - } - if errors.Is(err, model.ErrNotFound) { - log.Error(ctx, "Playlist not found for image upload", "playlistId", playlistId, err) - http.Error(w, "not found", http.StatusNotFound) - return - } - if err != nil { - log.Error(ctx, "Error saving playlist image", "playlistId", playlistId, err) - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - _, _ = fmt.Fprintf(w, `{"status":"ok"}`) //nolint:gosec - } + return handleImageUpload(func(ctx context.Context, reader io.Reader, ext string) error { + playlistId := chi.URLParamFromCtx(ctx, "id") + return pls.SetImage(ctx, playlistId, reader, ext) + }) } func deletePlaylistImage(pls playlists.Playlists) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - user, _ := request.UserFrom(ctx) - if !conf.Server.EnableCoverArtUpload && !user.IsAdmin { - http.Error(w, "cover art upload is disabled", http.StatusForbidden) - return - } - p := req.Params(r) - playlistId, _ := p.String(":id") - - err := pls.RemoveImage(ctx, playlistId) - if errors.Is(err, model.ErrNotAuthorized) { - log.Error(ctx, "Not authorized to remove playlist image", "playlistId", playlistId, err) - http.Error(w, "not authorized", http.StatusForbidden) - return - } - if errors.Is(err, model.ErrNotFound) { - log.Error(ctx, "Playlist not found for image removal", "playlistId", playlistId, err) - http.Error(w, "not found", http.StatusNotFound) - return - } - if err != nil { - log.Error(ctx, "Error removing playlist image", "playlistId", playlistId, err) - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - _, _ = fmt.Fprintf(w, `{"status":"ok"}`) //nolint:gosec - } + return handleImageDelete(func(ctx context.Context) error { + playlistId := chi.URLParamFromCtx(ctx, "id") + return pls.RemoveImage(ctx, playlistId) + }) } diff --git a/server/nativeapi/playlists_test.go b/server/nativeapi/playlists_test.go index 7f0cd7de1..dfe6b9296 100644 --- a/server/nativeapi/playlists_test.go +++ b/server/nativeapi/playlists_test.go @@ -98,7 +98,7 @@ var _ = Describe("Playlist Tracks Endpoint", func() { err := userRepo.Put(&testUser) Expect(err).ToNot(HaveOccurred()) - nativeRouter := New(ds, nil, plsSvc, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil) + nativeRouter := New(ds, nil, plsSvc, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil) router = server.JWTVerifier(nativeRouter) w = httptest.NewRecorder() }) diff --git a/server/nativeapi/plugin_test.go b/server/nativeapi/plugin_test.go index 7946b90fd..8fc88e09c 100644 --- a/server/nativeapi/plugin_test.go +++ b/server/nativeapi/plugin_test.go @@ -33,7 +33,7 @@ var _ = Describe("Plugin API", func() { ds = &tests.MockDataStore{} mockManager = &tests.MockPluginManager{} auth.Init(ds) - nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, mockManager) + nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, mockManager, nil) router = server.JWTVerifier(nativeRouter) // Create test users diff --git a/ui/src/artist/DesktopArtistDetails.jsx b/ui/src/artist/DesktopArtistDetails.jsx index 1e074ce4e..da8d06014 100644 --- a/ui/src/artist/DesktopArtistDetails.jsx +++ b/ui/src/artist/DesktopArtistDetails.jsx @@ -6,12 +6,13 @@ import CardContent from '@material-ui/core/CardContent' import CardMedia from '@material-ui/core/CardMedia' import ArtistExternalLinks from './ArtistExternalLink' import config from '../config' -import { LoveButton, RatingField } from '../common' +import { LoveButton, RatingField, ImageUploadOverlay } from '../common' import Lightbox from 'react-image-lightbox' import ExpandInfoDialog from '../dialogs/ExpandInfoDialog' import AlbumInfo from '../album/AlbumInfo' import subsonic from '../subsonic' import { SafeHTML } from '../common/SafeHTML' +import useArtistImageState from './useArtistImageState' const useStyles = makeStyles( (theme) => ({ @@ -57,6 +58,7 @@ const useStyles = makeStyles( alignItems: 'center', justifyContent: 'center', boxShadow: 'none', + position: 'relative', }, artistDetail: { flex: '1', @@ -85,36 +87,15 @@ const DesktopArtistDetails = ({ artistInfo, record, biography }) => { const [expanded, setExpanded] = useState(false) const classes = useStyles() const title = record.name - const [isLightboxOpen, setLightboxOpen] = React.useState(false) - const [imageLoading, setImageLoading] = React.useState(false) - const [imageError, setImageError] = React.useState(false) - - // Reset image state when artist changes - React.useEffect(() => { - setImageLoading(true) - setImageError(false) - }, [record.id]) - - const handleImageLoad = React.useCallback(() => { - setImageLoading(false) - setImageError(false) - }, []) - - const handleImageError = React.useCallback(() => { - setImageLoading(false) - setImageError(true) - }, []) - - const handleOpenLightbox = React.useCallback(() => { - if (!imageError) { - setLightboxOpen(true) - } - }, [imageError]) - - const handleCloseLightbox = React.useCallback( - () => setLightboxOpen(false), - [], - ) + const { + imageLoading, + imageError, + isLightboxOpen, + handleImageLoad, + handleImageError, + handleOpenLightbox, + handleCloseLightbox, + } = useArtistImageState(record.id) return (
@@ -135,6 +116,11 @@ const DesktopArtistDetails = ({ artistInfo, record, biography }) => { }} /> )} +
diff --git a/ui/src/artist/MobileArtistDetails.jsx b/ui/src/artist/MobileArtistDetails.jsx index 4947a4634..3add1e994 100644 --- a/ui/src/artist/MobileArtistDetails.jsx +++ b/ui/src/artist/MobileArtistDetails.jsx @@ -4,10 +4,11 @@ import { makeStyles } from '@material-ui/core/styles' import Card from '@material-ui/core/Card' import CardMedia from '@material-ui/core/CardMedia' import config from '../config' -import { LoveButton, RatingField } from '../common' +import { LoveButton, RatingField, ImageUploadOverlay } from '../common' import Lightbox from 'react-image-lightbox' import subsonic from '../subsonic' import { SafeHTML } from '../common/SafeHTML' +import useArtistImageState from './useArtistImageState' const useStyles = makeStyles( (theme) => ({ @@ -67,6 +68,7 @@ const useStyles = makeStyles( minWidth: '7rem', display: 'flex', borderRadius: '5em', + position: 'relative', }, loveButton: { top: theme.spacing(-0.2), @@ -87,36 +89,15 @@ const MobileArtistDetails = ({ artistInfo, biography, record }) => { const [expanded, setExpanded] = useState(false) const classes = useStyles({ img, expanded }) const title = record.name - const [isLightboxOpen, setLightboxOpen] = React.useState(false) - const [imageLoading, setImageLoading] = React.useState(false) - const [imageError, setImageError] = React.useState(false) - - // Reset image state when artist changes - React.useEffect(() => { - setImageLoading(true) - setImageError(false) - }, [record.id]) - - const handleImageLoad = React.useCallback(() => { - setImageLoading(false) - setImageError(false) - }, []) - - const handleImageError = React.useCallback(() => { - setImageLoading(false) - setImageError(true) - }, []) - - const handleOpenLightbox = React.useCallback(() => { - if (!imageError) { - setLightboxOpen(true) - } - }, [imageError]) - - const handleCloseLightbox = React.useCallback( - () => setLightboxOpen(false), - [], - ) + const { + imageLoading, + imageError, + isLightboxOpen, + handleImageLoad, + handleImageError, + handleOpenLightbox, + handleCloseLightbox, + } = useArtistImageState(record.id) return ( <> @@ -138,6 +119,11 @@ const MobileArtistDetails = ({ artistInfo, biography, record }) => { }} /> )} +
{ + const [imageLoading, setImageLoading] = useState(false) + const [imageError, setImageError] = useState(false) + const [isLightboxOpen, setLightboxOpen] = useState(false) + + useEffect(() => { + setImageLoading(true) + setImageError(false) + }, [recordId]) + + const handleImageLoad = useCallback(() => { + setImageLoading(false) + setImageError(false) + }, []) + + const handleImageError = useCallback(() => { + setImageLoading(false) + setImageError(true) + }, []) + + const handleOpenLightbox = useCallback(() => { + if (!imageError) { + setLightboxOpen(true) + } + }, [imageError]) + + const handleCloseLightbox = useCallback(() => setLightboxOpen(false), []) + + return { + imageLoading, + imageError, + isLightboxOpen, + handleImageLoad, + handleImageError, + handleOpenLightbox, + handleCloseLightbox, + } +} + +export default useArtistImageState diff --git a/ui/src/common/ImageUploadOverlay.jsx b/ui/src/common/ImageUploadOverlay.jsx new file mode 100644 index 000000000..e0d0d0a9a --- /dev/null +++ b/ui/src/common/ImageUploadOverlay.jsx @@ -0,0 +1,139 @@ +import { IconButton, Tooltip } from '@material-ui/core' +import { makeStyles } from '@material-ui/core/styles' +import PhotoCameraIcon from '@material-ui/icons/PhotoCamera' +import DeleteIcon from '@material-ui/icons/Delete' +import { useTranslate, useNotify, useRefresh } from 'react-admin' +import { useCallback, useRef } from 'react' +import config from '../config' +import { REST_URL } from '../consts' +import { httpClient } from '../dataProvider' + +const useStyles = makeStyles(() => ({ + coverOverlay: { + position: 'absolute', + bottom: 0, + right: 0, + display: 'flex', + gap: '2px', + padding: '2px', + backgroundColor: 'rgba(0,0,0,0.5)', + borderRadius: '4px 0 0 0', + opacity: 0, + transition: 'opacity 0.2s ease-in-out', + '*:hover > &': { + opacity: 1, + }, + }, + overlayButton: { + color: '#fff', + padding: '4px', + '&:hover': { + backgroundColor: 'rgba(255,255,255,0.2)', + }, + }, + overlayIcon: { + fontSize: '1.2rem', + }, +})) + +export const ImageUploadOverlay = ({ + entityType, + entityId, + hasUploadedImage, + onImageChange, +}) => { + const translate = useTranslate() + const notify = useNotify() + const refresh = useRefresh() + const classes = useStyles() + const fileInputRef = useRef(null) + + const canEdit = + config.enableCoverArtUpload || localStorage.getItem('role') === 'admin' + + const handleUploadClick = useCallback((e) => { + e.stopPropagation() + if (fileInputRef.current) { + fileInputRef.current.click() + } + }, []) + + const handleFileChange = useCallback( + async (e) => { + const file = e.target.files[0] + if (!file || !entityId) return + + const formData = new FormData() + formData.append('image', file) + + try { + await httpClient(`${REST_URL}/${entityType}/${entityId}/image`, { + method: 'POST', + headers: new Headers({}), + body: formData, + }) + notify(`message.coverUploaded`, 'success') + if (onImageChange) onImageChange() + refresh() + } catch (err) { + notify(`message.coverUploadError`, 'warning') + } + + e.target.value = '' + }, + [entityType, entityId, notify, refresh, onImageChange], + ) + + const handleRemoveCover = useCallback( + async (e) => { + e.stopPropagation() + if (!entityId) return + + try { + await httpClient(`${REST_URL}/${entityType}/${entityId}/image`, { + method: 'DELETE', + }) + notify(`message.coverRemoved`, 'success') + if (onImageChange) onImageChange() + refresh() + } catch (err) { + notify(`message.coverRemoveError`, 'warning') + } + }, + [entityType, entityId, notify, refresh, onImageChange], + ) + + if (!canEdit) return null + + return ( +
+ + + + + + {hasUploadedImage && ( + + + + + + )} + +
+ ) +} diff --git a/ui/src/common/index.js b/ui/src/common/index.js index a8dc354cb..953e38cf8 100644 --- a/ui/src/common/index.js +++ b/ui/src/common/index.js @@ -43,3 +43,4 @@ export * from './PathField.jsx' export * from './ParticipantsInfo' export * from './OverflowTooltip' export * from './useSearchRefocus' +export * from './ImageUploadOverlay' diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index baf4c9fa3..6c6592178 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -219,15 +219,9 @@ "makePrivate": "Make Private", "searchOrCreate": "Search playlists or type to create new...", "pressEnterToCreate": "Press Enter to create new playlist", - "removeFromSelection": "Remove from selection", - "uploadCover": "Upload Cover", - "removeCover": "Remove Cover" + "removeFromSelection": "Remove from selection" }, "message": { - "coverUploaded": "Cover art updated", - "coverRemoved": "Cover art removed", - "coverUploadError": "Error uploading cover art", - "coverRemoveError": "Error removing cover art", "duplicate_song": "Add duplicated songs", "song_exist": "There are duplicates being added to the playlist. Would you like to add the duplicates or skip them?", "noPlaylistsFound": "No playlists found", @@ -563,6 +557,12 @@ } }, "message": { + "uploadCover": "Upload Cover", + "removeCover": "Remove Cover", + "coverUploaded": "Cover art updated", + "coverRemoved": "Cover art removed", + "coverUploadError": "Error uploading cover art", + "coverRemoveError": "Error removing cover art", "note": "NOTE", "transcodingDisabled": "Changing the transcoding configuration through the web interface is disabled for security reasons. If you would like to change (edit or add) transcoding options, restart the server with the %{config} configuration option.", "transcodingEnabled": "Navidrome is currently running with %{config}, making it possible to run system commands from the transcoding settings using the web interface. We recommend to disable it for security reasons and only enable it when configuring Transcoding options.", diff --git a/ui/src/playlist/PlaylistDetails.jsx b/ui/src/playlist/PlaylistDetails.jsx index aeddf0d42..a2d5e753b 100644 --- a/ui/src/playlist/PlaylistDetails.jsx +++ b/ui/src/playlist/PlaylistDetails.jsx @@ -2,29 +2,23 @@ import { Card, CardContent, CardMedia, - IconButton, - Tooltip, Typography, useMediaQuery, } from '@material-ui/core' import { makeStyles } from '@material-ui/core/styles' -import PhotoCameraIcon from '@material-ui/icons/PhotoCamera' -import DeleteIcon from '@material-ui/icons/Delete' -import { useTranslate, useNotify, useRefresh } from 'react-admin' -import { useCallback, useRef, useState, useEffect } from 'react' +import { useTranslate } from 'react-admin' +import { useCallback, useState, useEffect } from 'react' import Lightbox from 'react-image-lightbox' import 'react-image-lightbox/style.css' import { CollapsibleComment, DurationField, + ImageUploadOverlay, SizeField, isWritable, OverflowTooltip, } from '../common' -import config from '../config' import subsonic from '../subsonic' -import { REST_URL } from '../consts' -import { httpClient } from '../dataProvider' const useStyles = makeStyles( (theme) => ({ @@ -82,31 +76,6 @@ const useStyles = makeStyles( coverLoading: { opacity: 0.5, }, - coverOverlay: { - position: 'absolute', - bottom: 0, - right: 0, - display: 'flex', - gap: '2px', - padding: '2px', - backgroundColor: 'rgba(0,0,0,0.5)', - borderRadius: '4px 0 0 0', - opacity: 0, - transition: 'opacity 0.2s ease-in-out', - '$coverParent:hover &': { - opacity: 1, - }, - }, - overlayButton: { - color: '#fff', - padding: '4px', - '&:hover': { - backgroundColor: 'rgba(255,255,255,0.2)', - }, - }, - overlayIcon: { - fontSize: '1.2rem', - }, title: { overflow: 'hidden', textOverflow: 'ellipsis', @@ -125,20 +94,14 @@ const useStyles = makeStyles( const PlaylistDetails = (props) => { const { record = {} } = props const translate = useTranslate() - const notify = useNotify() - const refresh = useRefresh() const classes = useStyles() const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('lg')) const [isLightboxOpen, setLightboxOpen] = useState(false) const [imageLoading, setImageLoading] = useState(false) const [imageError, setImageError] = useState(false) - const fileInputRef = useRef(null) const imageUrl = subsonic.getCoverArtUrl(record, 300, true) const fullImageUrl = subsonic.getCoverArtUrl(record) - const canEdit = - isWritable(record.ownerId) && - (config.enableCoverArtUpload || localStorage.getItem('role') === 'admin') // Reset image state when playlist changes useEffect(() => { @@ -164,60 +127,6 @@ const PlaylistDetails = (props) => { const handleCloseLightbox = useCallback(() => setLightboxOpen(false), []) - const handleUploadClick = useCallback( - (e) => { - e.stopPropagation() - if (fileInputRef.current) { - fileInputRef.current.click() - } - }, - [fileInputRef], - ) - - const handleFileChange = useCallback( - async (e) => { - const file = e.target.files[0] - if (!file || !record.id) return - - const formData = new FormData() - formData.append('image', file) - - try { - await httpClient(`${REST_URL}/playlist/${record.id}/image`, { - method: 'POST', - headers: new Headers({}), - body: formData, - }) - notify('resources.playlist.message.coverUploaded', 'success') - refresh() - } catch (err) { - notify('resources.playlist.message.coverUploadError', 'warning') - } - - // Reset file input so the same file can be re-selected - e.target.value = '' - }, - [record.id, notify, refresh], - ) - - const handleRemoveCover = useCallback( - async (e) => { - e.stopPropagation() - if (!record.id) return - - try { - await httpClient(`${REST_URL}/playlist/${record.id}/image`, { - method: 'DELETE', - }) - notify('resources.playlist.message.coverRemoved', 'success') - refresh() - } catch (err) { - notify('resources.playlist.message.coverRemoveError', 'warning') - } - }, - [record.id, notify, refresh], - ) - return (
@@ -237,40 +146,12 @@ const PlaylistDetails = (props) => { cursor: imageError ? 'default' : 'pointer', }} /> - {canEdit && ( -
- - - - - - {record.uploadedImage && ( - - - - - - )} - -
+ {isWritable(record.ownerId) && ( + )}
From cefa6e9619a9025ce07489b9e1d4a6ef190efd20 Mon Sep 17 00:00:00 2001 From: Deluan Date: Mon, 16 Mar 2026 06:39:48 -0400 Subject: [PATCH 3/6] feat(ui): add CoverArtAvatar component and integrate it into artist and playlist lists Signed-off-by: Deluan --- ui/src/artist/ArtistList.jsx | 6 +++++ ui/src/artist/ArtistSimpleList.jsx | 7 +++++- ui/src/common/CoverArtAvatar.jsx | 36 ++++++++++++++++++++++++++++++ ui/src/common/index.js | 1 + ui/src/playlist/PlaylistList.jsx | 29 ++---------------------- 5 files changed, 51 insertions(+), 28 deletions(-) create mode 100644 ui/src/common/CoverArtAvatar.jsx diff --git a/ui/src/artist/ArtistList.jsx b/ui/src/artist/ArtistList.jsx index e175763e3..6c526a5a5 100644 --- a/ui/src/artist/ArtistList.jsx +++ b/ui/src/artist/ArtistList.jsx @@ -22,6 +22,7 @@ import { useDrag } from 'react-dnd' import clsx from 'clsx' import { ArtistContextMenu, + CoverArtAvatar, List, QuickFilter, useGetHandleArtistClick, @@ -43,6 +44,10 @@ const useStyles = makeStyles({ verticalAlign: 'text-top', }, row: { + '& td': { + paddingTop: '4px !important', + paddingBottom: '4px !important', + }, '&:hover': { '& $contextMenu': { visibility: 'visible', @@ -170,6 +175,7 @@ const ArtistListView = ({ hasShow, hasEdit, hasList, width, ...rest }) => { /> ) : ( + linkType(id)}> + + +
{data[id].name}
diff --git a/ui/src/common/CoverArtAvatar.jsx b/ui/src/common/CoverArtAvatar.jsx new file mode 100644 index 000000000..6610cb75e --- /dev/null +++ b/ui/src/common/CoverArtAvatar.jsx @@ -0,0 +1,36 @@ +import { useRecordContext } from 'react-admin' +import { Avatar } from '@material-ui/core' +import { makeStyles } from '@material-ui/core/styles' +import clsx from 'clsx' +import subsonic from '../subsonic' + +const useStyles = makeStyles({ + avatar: { + width: '55px', + height: '55px', + }, + square: { + borderRadius: '4px', + }, +}) + +export const CoverArtAvatar = ({ + record: recordProp, + variant = 'circular', +}) => { + const classes = useStyles() + const recordContext = useRecordContext() + const record = recordProp || recordContext + if (!record) return null + const square = variant !== 'circular' + return ( + + ) +} + +CoverArtAvatar.defaultProps = { label: '', sortable: false } diff --git a/ui/src/common/index.js b/ui/src/common/index.js index 953e38cf8..b93e40219 100644 --- a/ui/src/common/index.js +++ b/ui/src/common/index.js @@ -44,3 +44,4 @@ export * from './ParticipantsInfo' export * from './OverflowTooltip' export * from './useSearchRefocus' export * from './ImageUploadOverlay' +export * from './CoverArtAvatar' diff --git a/ui/src/playlist/PlaylistList.jsx b/ui/src/playlist/PlaylistList.jsx index 67c456f27..8732725bc 100644 --- a/ui/src/playlist/PlaylistList.jsx +++ b/ui/src/playlist/PlaylistList.jsx @@ -16,10 +16,10 @@ import { usePermissions, } from 'react-admin' import Switch from '@material-ui/core/Switch' -import { Avatar } from '@material-ui/core' import { makeStyles } from '@material-ui/core/styles' import { useMediaQuery } from '@material-ui/core' import { + CoverArtAvatar, DurationField, List, Writable, @@ -29,17 +29,11 @@ import { } from '../common' import PlaylistListActions from './PlaylistListActions' import ChangePublicStatusButton from './ChangePublicStatusButton' -import subsonic from '../subsonic' const useStyles = makeStyles((theme) => ({ button: { color: theme.palette.type === 'dark' ? 'white' : undefined, }, - coverArt: { - width: '40px', - height: '40px', - borderRadius: '4px', - }, })) const PlaylistFilter = (props) => { @@ -126,25 +120,6 @@ const ToggleAutoImport = ({ resource, source }) => { ) : null } -const CoverArtField = () => { - const classes = useStyles() - const record = useRecordContext() - if (!record) return null - return ( - - ) -} - -CoverArtField.defaultProps = { - label: '', - sortable: false, -} - const PlaylistListBulkActions = (props) => { const classes = useStyles() return ( @@ -204,7 +179,7 @@ const PlaylistList = (props) => { bulkActionButtons={!isXsmall && } > isWritable(r?.ownerId)}> - + {columns} From 9ae9134a9140be56a13417fd15ee30203dd43349 Mon Sep 17 00:00:00 2001 From: Deluan Date: Mon, 16 Mar 2026 06:46:47 -0400 Subject: [PATCH 4/6] feat(ui): integrate CoverArtAvatar component into AlbumTableView Signed-off-by: Deluan --- ui/src/album/AlbumTableView.jsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/ui/src/album/AlbumTableView.jsx b/ui/src/album/AlbumTableView.jsx index 1fa33d769..d1a89d512 100644 --- a/ui/src/album/AlbumTableView.jsx +++ b/ui/src/album/AlbumTableView.jsx @@ -14,6 +14,7 @@ import { makeStyles } from '@material-ui/core/styles' import { useDrag } from 'react-dnd' import { ArtistLinkField, + CoverArtAvatar, DurationField, RangeField, SimpleList, @@ -161,12 +162,18 @@ const AlbumTableView = ({       )} + leftIcon={(r) => ( + + + + )} linkType={'show'} rightIcon={(r) => } {...rest} /> ) : ( + {columns} Date: Mon, 16 Mar 2026 07:55:22 -0400 Subject: [PATCH 5/6] fix(db): normalize timestamps and fix recently added album sorting (#5176) * fix(db): normalize timestamps and fix recently added album sorting SQLite stores timestamps as TEXT and uses string comparison for ORDER BY. Timestamps in RFC3339 T-format ('2024-01-01T10:00:00Z') sort incorrectly against space-format ('2024-01-01 10:00:00+00:00') because 'T' (ASCII 84) > ' ' (ASCII 32), causing albums with T-format timestamps to appear as newer than they are in the "Recently Added" list. This adds a migration to normalize all T-format timestamps across all tables to the space-format expected by go-sqlite3, wraps the recently_added sort with datetime() to make it format-agnostic, and replaces the plain album timestamp indexes with expression indexes to maintain query performance. * fix(test): improve recently_added sort test robustness Use same-date timestamps (2024-01-15T08:00:00Z vs 2024-01-15 20:00:00) so the T-vs-space character difference at position 10 actually triggers the sorting bug. Initialize index variables to -1 and assert both test albums are found before comparing positions. * chore(db): update migration timestamp to 2026-03-16 --- .../20260316000000_normalize_timestamps.sql | 74 +++++++++++++++++++ persistence/album_repository.go | 4 +- persistence/album_repository_test.go | 47 ++++++++++++ 3 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 db/migrations/20260316000000_normalize_timestamps.sql diff --git a/db/migrations/20260316000000_normalize_timestamps.sql b/db/migrations/20260316000000_normalize_timestamps.sql new file mode 100644 index 000000000..a2e1183e9 --- /dev/null +++ b/db/migrations/20260316000000_normalize_timestamps.sql @@ -0,0 +1,74 @@ +-- +goose Up + +-- Normalize T-format timestamps (RFC3339Nano with 'T' separator) to SQLite-compatible format. +-- SQLite uses string comparison for ORDER BY on TEXT columns, so 'T' (ASCII 84) > ' ' (ASCII 32) +-- causes T-format timestamps to sort after space-format ones, breaking "Recently Added" ordering. + +UPDATE album SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%'; +UPDATE album SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%'; +UPDATE album SET imported_at = replace(replace(imported_at, 'T', ' '), 'Z', '+00:00') WHERE imported_at LIKE '%T%'; +UPDATE album SET external_info_updated_at = replace(replace(external_info_updated_at, 'T', ' '), 'Z', '+00:00') WHERE external_info_updated_at LIKE '%T%'; + +UPDATE media_file SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%'; +UPDATE media_file SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%'; +UPDATE media_file SET birth_time = replace(replace(birth_time, 'T', ' '), 'Z', '+00:00') WHERE birth_time LIKE '%T%'; + +UPDATE artist SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%'; +UPDATE artist SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%'; +UPDATE artist SET external_info_updated_at = replace(replace(external_info_updated_at, 'T', ' '), 'Z', '+00:00') WHERE external_info_updated_at LIKE '%T%'; + +UPDATE annotation SET play_date = replace(replace(play_date, 'T', ' '), 'Z', '+00:00') WHERE play_date LIKE '%T%'; +UPDATE annotation SET starred_at = replace(replace(starred_at, 'T', ' '), 'Z', '+00:00') WHERE starred_at LIKE '%T%'; +UPDATE annotation SET rated_at = replace(replace(rated_at, 'T', ' '), 'Z', '+00:00') WHERE rated_at LIKE '%T%'; + +UPDATE playlist SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%'; +UPDATE playlist SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%'; +UPDATE playlist SET evaluated_at = replace(replace(evaluated_at, 'T', ' '), 'Z', '+00:00') WHERE evaluated_at LIKE '%T%'; + +UPDATE user SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%'; +UPDATE user SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%'; +UPDATE user SET last_login_at = replace(replace(last_login_at, 'T', ' '), 'Z', '+00:00') WHERE last_login_at LIKE '%T%'; +UPDATE user SET last_access_at = replace(replace(last_access_at, 'T', ' '), 'Z', '+00:00') WHERE last_access_at LIKE '%T%'; + +UPDATE player SET last_seen = replace(replace(last_seen, 'T', ' '), 'Z', '+00:00') WHERE last_seen LIKE '%T%'; + +UPDATE playqueue SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%'; +UPDATE playqueue SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%'; + +UPDATE bookmark SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%'; +UPDATE bookmark SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%'; + +UPDATE share SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%'; +UPDATE share SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%'; +UPDATE share SET expires_at = replace(replace(expires_at, 'T', ' '), 'Z', '+00:00') WHERE expires_at LIKE '%T%'; +UPDATE share SET last_visited_at = replace(replace(last_visited_at, 'T', ' '), 'Z', '+00:00') WHERE last_visited_at LIKE '%T%'; + +UPDATE radio SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%'; +UPDATE radio SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%'; + +UPDATE folder SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%'; +UPDATE folder SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%'; +UPDATE folder SET images_updated_at = replace(replace(images_updated_at, 'T', ' '), 'Z', '+00:00') WHERE images_updated_at LIKE '%T%'; + +UPDATE library SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%'; +UPDATE library SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%'; +UPDATE library SET last_scan_at = replace(replace(last_scan_at, 'T', ' '), 'Z', '+00:00') WHERE last_scan_at LIKE '%T%'; +UPDATE library SET last_scan_started_at = replace(replace(last_scan_started_at, 'T', ' '), 'Z', '+00:00') WHERE last_scan_started_at LIKE '%T%'; + +UPDATE scrobble_buffer SET play_time = replace(replace(play_time, 'T', ' '), 'Z', '+00:00') WHERE play_time LIKE '%T%'; +UPDATE scrobble_buffer SET enqueue_time = replace(replace(enqueue_time, 'T', ' '), 'Z', '+00:00') WHERE enqueue_time LIKE '%T%'; + +UPDATE plugin SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%'; +UPDATE plugin SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%'; + +-- Replace plain indexes with expression indexes for datetime()-based sorting +DROP INDEX IF EXISTS album_created_at; +CREATE INDEX album_created_at ON album(datetime(created_at)); +DROP INDEX IF EXISTS album_updated_at; +CREATE INDEX album_updated_at ON album(datetime(updated_at)); + +-- +goose Down +DROP INDEX IF EXISTS album_created_at; +CREATE INDEX album_created_at ON album(created_at); +DROP INDEX IF EXISTS album_updated_at; +CREATE INDEX album_updated_at ON album(updated_at); diff --git a/persistence/album_repository.go b/persistence/album_repository.go index 7207bf5a2..c51a5beb1 100644 --- a/persistence/album_repository.go +++ b/persistence/album_repository.go @@ -143,9 +143,9 @@ var albumFilters = sync.OnceValue(func() map[string]filterFunc { func recentlyAddedSort() string { if conf.Server.RecentlyAddedByModTime { - return "updated_at" + return "datetime(album.updated_at)" } - return "created_at" + return "datetime(album.created_at)" } func recentlyPlayedFilter(string, any) Sqlizer { diff --git a/persistence/album_repository_test.go b/persistence/album_repository_test.go index 66b6eba9f..2792cec97 100644 --- a/persistence/album_repository_test.go +++ b/persistence/album_repository_test.go @@ -85,6 +85,53 @@ var _ = Describe("AlbumRepository", func() { }) }) + Describe("recently_added sort", func() { + It("sorts correctly regardless of timestamp format (T-format vs space-format)", func() { + // Both timestamps share the same date prefix "2024-01-15" so the T vs space + // character at position 10 determines sort order in raw string comparison. + // Without normalization, 'T' (ASCII 84) > ' ' (ASCII 32) makes the older + // T-format timestamp sort AFTER the newer space-format one. + + // Older album: morning of Jan 15, stored in T-format + olderAlbum := &model.Album{LibraryID: 1, ID: "ts-older", Name: "Older Album"} + Expect(albumRepo.Put(olderAlbum)).To(Succeed()) + _, err := albumRepo.executeSQL(squirrel.Update("album"). + Set("created_at", "2024-01-15T08:00:00Z"). + Where(squirrel.Eq{"id": "ts-older"})) + Expect(err).ToNot(HaveOccurred()) + + // Newer album: evening of Jan 15, stored in space-format + newerAlbum := &model.Album{LibraryID: 1, ID: "ts-newer", Name: "Newer Album"} + Expect(albumRepo.Put(newerAlbum)).To(Succeed()) + _, err = albumRepo.executeSQL(squirrel.Update("album"). + Set("created_at", "2024-01-15 20:00:00+00:00"). + Where(squirrel.Eq{"id": "ts-newer"})) + Expect(err).ToNot(HaveOccurred()) + + albums, err := albumRepo.GetAll(model.QueryOptions{Sort: "recently_added", Order: "desc"}) + Expect(err).ToNot(HaveOccurred()) + + // Find positions of our test albums + olderIdx, newerIdx := -1, -1 + for i, a := range albums { + switch a.ID { + case "ts-older": + olderIdx = i + case "ts-newer": + newerIdx = i + } + } + Expect(olderIdx).To(BeNumerically(">=", 0), "older album not found in results") + Expect(newerIdx).To(BeNumerically(">=", 0), "newer album not found in results") + // Newer album (evening, space-format) should come before older album (morning, T-format) in desc order + Expect(newerIdx).To(BeNumerically("<", olderIdx), + "Newer album (20:00 space-format) should sort before older album (08:00 T-format) in desc order") + + // Clean up + _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": []string{"ts-older", "ts-newer"}})) + }) + }) + Context("Filters", func() { var albumWithoutAnnotation model.Album From 2f5b2b51359db24573d965fe4b114a45c72d3b6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Mon, 16 Mar 2026 18:08:39 -0400 Subject: [PATCH 6/6] fix(artwork): fallback mediafile cover art to disc artwork before album (#5216) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(artwork): fallback mediafile cover art to disc artwork before album Changed the mediafile cover art fallback chain to go through disc artwork before album artwork (mediafile → disc → album). Previously, mediafiles without embedded art fell back directly to album cover, bypassing any disc-specific artwork. Renamed AlbumCoverArtID() to DiscCoverArtID() to encapsulate the disc-vs-album decision in a single method, used by both CoverArtID() and the mediafile artwork reader. Signed-off-by: Deluan * fix(artwork): fix cache invalidation for mediafile and album cover art Include imagesUpdatedAt from album folders in the mediafile artwork reader's cache key, so that when a cover image file changes on disk (without audio metadata changes) the mediafile cache properly invalidates. Also include CoverArtPriority unconditionally in the album artwork reader's cache key hash, so that changing the priority order with external services disabled correctly invalidates the album cache. * fix(artwork): skip disc artwork resolution for single-disc albums Single-disc albums with DiscNumber=1 were unnecessarily routed through discArtworkReader, which does extra DB queries only to fall through to album art anyway. Now only multi-disc albums use the disc fallback path. * refactor(artwork): restore AlbumCoverArtID as a separate method Extract AlbumCoverArtID back out of DiscCoverArtID so the single-disc fallback path in reader_mediafile can reference it by name instead of inlining the artwork ID construction. --------- Signed-off-by: Deluan --- core/artwork/artwork_internal_test.go | 28 +++++++++++++++++++++++++-- core/artwork/reader_album.go | 5 +++-- core/artwork/reader_mediafile.go | 20 +++++++++++++++---- model/mediafile.go | 11 ++++++++++- model/mediafile_test.go | 17 ++++++++++++++-- 5 files changed, 70 insertions(+), 11 deletions(-) diff --git a/core/artwork/artwork_internal_test.go b/core/artwork/artwork_internal_test.go index 5ca32f401..4b2359898 100644 --- a/core/artwork/artwork_internal_test.go +++ b/core/artwork/artwork_internal_test.go @@ -28,7 +28,7 @@ var _ = Describe("Artwork", func() { var ffmpeg *tests.MockFFmpeg var folderRepo *fakeFolderRepo ctx := log.NewContext(context.TODO()) - var alOnlyEmbed, alEmbedNotFound, alOnlyExternal, alExternalNotFound, alMultipleCovers model.Album + var alOnlyEmbed, alEmbedNotFound, alOnlyExternal, alExternalNotFound, alMultipleCovers, alSingleDisc model.Album var arMultipleCovers model.Artist var mfWithEmbed, mfAnotherWithEmbed, mfWithoutEmbed, mfCorruptedCover model.MediaFile @@ -44,8 +44,9 @@ var _ = Describe("Artwork", func() { } alOnlyEmbed = model.Album{ID: "222", Name: "Only embed", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}} alEmbedNotFound = model.Album{ID: "333", Name: "Embed not found", EmbedArtPath: "tests/fixtures/NON_EXISTENT.mp3", FolderIDs: []string{"f1"}} - alOnlyExternal = model.Album{ID: "444", Name: "Only external", FolderIDs: []string{"f1"}} + alOnlyExternal = model.Album{ID: "444", Name: "Only external", FolderIDs: []string{"f1"}, Discs: model.Discs{1: "", 2: ""}} alExternalNotFound = model.Album{ID: "555", Name: "External not found", FolderIDs: []string{"f2"}} + alSingleDisc = model.Album{ID: "888", Name: "Single disc", FolderIDs: []string{"f1"}, Discs: model.Discs{1: ""}} arMultipleCovers = model.Artist{ID: "777", Name: "All options"} alMultipleCovers = model.Album{ ID: "666", @@ -193,6 +194,7 @@ var _ = Describe("Artwork", func() { ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{ alOnlyEmbed, alOnlyExternal, + alSingleDisc, }) ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ mfWithEmbed, @@ -236,6 +238,28 @@ var _ = Describe("Artwork", func() { Expect(err).ToNot(HaveOccurred()) Expect(path).To(Equal("al-444_0")) }) + It("falls back to disc cover art when media file has a disc number on a multi-disc album", func() { + mfWithDisc := model.MediaFile{ID: "46", Path: "tests/fixtures/test.ogg", AlbumID: "444", DiscNumber: 2} + Expect(ds.MediaFile(ctx).(*tests.MockMediaFileRepo).Put(&mfWithDisc)).To(Succeed()) + + aw, err := newMediafileArtworkReader(ctx, aw, model.MustParseArtworkID("mf-"+mfWithDisc.ID)) + Expect(err).ToNot(HaveOccurred()) + _, path, err := aw.Reader(ctx) + Expect(err).ToNot(HaveOccurred()) + // Should fall back to disc art, which itself falls back to album art + Expect(path).To(Equal("dc-444:2_0")) + }) + It("falls back to album cover art for single-disc albums even with a disc number", func() { + mfOnSingleDisc := model.MediaFile{ID: "47", Path: "tests/fixtures/test.ogg", AlbumID: "888", DiscNumber: 1} + Expect(ds.MediaFile(ctx).(*tests.MockMediaFileRepo).Put(&mfOnSingleDisc)).To(Succeed()) + + aw, err := newMediafileArtworkReader(ctx, aw, model.MustParseArtworkID("mf-"+mfOnSingleDisc.ID)) + Expect(err).ToNot(HaveOccurred()) + _, path, err := aw.Reader(ctx) + Expect(err).ToNot(HaveOccurred()) + // Single-disc album should skip disc art and go straight to album art + Expect(path).To(Equal("al-888_0")) + }) }) }) Describe("playlistArtworkReader", func() { diff --git a/core/artwork/reader_album.go b/core/artwork/reader_album.go index 98a2105eb..6de1d31d1 100644 --- a/core/artwork/reader_album.go +++ b/core/artwork/reader_album.go @@ -59,10 +59,11 @@ func newAlbumArtworkReader(ctx context.Context, artwork *artwork, artID model.Ar } func (a *albumArtworkReader) Key() string { - var hash [16]byte + hashInput := conf.Server.CoverArtPriority if conf.Server.EnableExternalServices { - hash = md5.Sum([]byte(conf.Server.Agents + conf.Server.CoverArtPriority)) + hashInput += conf.Server.Agents } + hash := md5.Sum([]byte(hashInput)) return fmt.Sprintf( "%s.%x.%t", a.cacheKey.Key(), diff --git a/core/artwork/reader_mediafile.go b/core/artwork/reader_mediafile.go index c72d9543d..cf25c8f5d 100644 --- a/core/artwork/reader_mediafile.go +++ b/core/artwork/reader_mediafile.go @@ -26,16 +26,22 @@ func newMediafileArtworkReader(ctx context.Context, artwork *artwork, artID mode if err != nil { return nil, err } + _, _, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, artwork.ds, *al) + if err != nil { + return nil, err + } a := &mediafileArtworkReader{ a: artwork, mediafile: *mf, album: *al, } a.cacheKey.artID = artID - if al.UpdatedAt.After(mf.UpdatedAt) { + a.cacheKey.lastUpdate = mf.UpdatedAt + if al.UpdatedAt.After(a.cacheKey.lastUpdate) { a.cacheKey.lastUpdate = al.UpdatedAt - } else { - a.cacheKey.lastUpdate = mf.UpdatedAt + } + if imagesUpdatedAt != nil && imagesUpdatedAt.After(a.cacheKey.lastUpdate) { + a.cacheKey.lastUpdate = *imagesUpdatedAt } return a, nil } @@ -60,6 +66,12 @@ func (a *mediafileArtworkReader) Reader(ctx context.Context) (io.ReadCloser, str fromFFmpegTag(ctx, a.a.ffmpeg, path), } } - ff = append(ff, fromAlbum(ctx, a.a, a.mediafile.AlbumCoverArtID())) + // For multi-disc albums, fall back to disc artwork first; for single-disc albums, + // skip disc resolution (it would just fall through to album art anyway). + if len(a.album.Discs) > 1 { + ff = append(ff, fromAlbum(ctx, a.a, a.mediafile.DiscCoverArtID())) + } else { + ff = append(ff, fromAlbum(ctx, a.a, a.mediafile.AlbumCoverArtID())) + } return selectImageReader(ctx, a.artID, ff...) } diff --git a/model/mediafile.go b/model/mediafile.go index 20532bfb9..ec83b76fd 100644 --- a/model/mediafile.go +++ b/model/mediafile.go @@ -119,7 +119,16 @@ func (mf MediaFile) CoverArtID() ArtworkID { if mf.HasCoverArt && conf.Server.EnableMediaFileCoverArt { return artworkIDFromMediaFile(mf) } - // if it does not have a coverArt, fallback to the album cover + // Otherwise fallback to disc (if available) or album cover + return mf.DiscCoverArtID() +} + +// DiscCoverArtID returns the disc artwork ID when the media file has a disc number, +// otherwise it returns the album artwork ID. +func (mf MediaFile) DiscCoverArtID() ArtworkID { + if mf.DiscNumber > 0 { + return NewArtworkID(KindDiscArtwork, DiscArtworkID(mf.AlbumID, mf.DiscNumber), nil) + } return mf.AlbumCoverArtID() } diff --git a/model/mediafile_test.go b/model/mediafile_test.go index 207d3c155..038ac93d5 100644 --- a/model/mediafile_test.go +++ b/model/mediafile_test.go @@ -504,13 +504,26 @@ var _ = Describe("MediaFile", func() { Expect(id.Kind).To(Equal(KindMediaFileArtwork)) Expect(id.ID).To(Equal(mf.ID)) }) - It("returns its album id if HasCoverArt is false", func() { + It("returns disc art id if HasCoverArt is false and DiscNumber > 0", func() { + mf := MediaFile{ID: "111", AlbumID: "1", HasCoverArt: false, DiscNumber: 2} + id := mf.CoverArtID() + Expect(id.Kind).To(Equal(KindDiscArtwork)) + Expect(id.ID).To(Equal("1:2")) + }) + It("returns its album id if HasCoverArt is false and DiscNumber is 0", func() { mf := MediaFile{ID: "111", AlbumID: "1", HasCoverArt: false} id := mf.CoverArtID() Expect(id.Kind).To(Equal(KindAlbumArtwork)) Expect(id.ID).To(Equal(mf.AlbumID)) }) - It("returns its album id if EnableMediaFileCoverArt is disabled", func() { + It("returns disc art id if EnableMediaFileCoverArt is disabled and DiscNumber > 0", func() { + conf.Server.EnableMediaFileCoverArt = false + mf := MediaFile{ID: "111", AlbumID: "1", HasCoverArt: true, DiscNumber: 3} + id := mf.CoverArtID() + Expect(id.Kind).To(Equal(KindDiscArtwork)) + Expect(id.ID).To(Equal("1:3")) + }) + It("returns its album id if EnableMediaFileCoverArt is disabled and DiscNumber is 0", func() { conf.Server.EnableMediaFileCoverArt = false mf := MediaFile{ID: "111", AlbumID: "1", HasCoverArt: true} id := mf.CoverArtID()