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 <deluan@navidrome.org>

* 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 <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan Quintão
2026-03-15 22:19:55 -04:00
committed by GitHub
parent be06196168
commit ab8a58157a
57 changed files with 1169 additions and 567 deletions
+3 -9
View File
@@ -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} минути"
}
}
}
+3 -9
View File
@@ -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"
}
}
}
+3 -9
View File
@@ -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"
}
}
}
+10 -10
View File
@@ -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"
}
}
}
+3 -9
View File
@@ -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} λεπτά πριν"
}
}
}
+3 -9
View File
@@ -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"
}
}
}
+10 -10
View File
@@ -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"
}
}
}
+3 -9
View File
@@ -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"
}
}
}
+3 -9
View File
@@ -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"
}
}
}
+7 -7
View File
@@ -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",
+3 -9
View File
@@ -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} минут назад"
}
}
}
+3 -9
View File
@@ -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"
}
}
}
+3 -9
View File
@@ -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"
}
}
}
+3 -9
View File
@@ -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} นาทีที่แล้ว"
}
}
}
+10 -10
View File
@@ -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} 分鐘前"
}
}
}