feat(device-sync): USB/SD card sync page (WIP)

Adds a new Device Sync page for transferring music from Navidrome to
USB drives or SD cards. Sidebar entry is hidden by default.

- Four new Tauri commands: sync_track_to_device, compute_sync_paths,
  list_device_dir_files, delete_device_file
- Filename template engine with cross-platform path sanitization
- 4-concurrent-worker download, live progress via device:sync:progress event
- Persistent source list (albums/playlists/artists) with checkbox deletion
- Expandable artist tree in browser panel (per-album selection)
- i18n: DE + EN complete; other locales have stub keys

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-14 00:02:13 +02:00
parent 58f99ae846
commit 17a5c92174
15 changed files with 1341 additions and 2 deletions
+32
View File
@@ -24,6 +24,7 @@ export const deTranslation = {
mostPlayed: 'Meistgehört',
radio: 'Internetradio',
folderBrowser: 'Ordner-Browser',
deviceSync: 'Gerätesync',
libraryScope: 'Bibliotheksumfang',
allLibraries: 'Alle Bibliotheken',
expandPlaylists: 'Playlists ausklappen',
@@ -1021,4 +1022,35 @@ export const deTranslation = {
empty: 'Leerer Ordner',
error: 'Laden fehlgeschlagen',
},
deviceSync: {
title: 'Gerätesync',
targetFolder: 'Zielordner',
noFolderChosen: 'Kein Ordner gewählt',
chooseFolder: 'Auswählen…',
filenameTemplate: 'Dateinamen-Vorlage',
templateHint: 'Variablen: {artist}, {album}, {title}, {track_number}, {disc_number}, {year}',
onDevice: 'Auf dem Gerät',
addSources: 'Hinzufügen…',
colName: 'Name',
colType: 'Typ',
syncResult: '{{done}} übertragen, {{skipped}} bereits aktuell ({{total}} gesamt)',
deleteFromDevice: 'Vom Gerät löschen ({{count}})',
confirmDelete: '{{count}} Eintrag/Einträge vom Gerät löschen: {{names}}?',
deleteComplete: '{{count}} Eintrag/Einträge vom Gerät entfernt.',
selectedSources: 'Ausgewählte Quellen',
noSourcesSelected: 'Keine Quellen ausgewählt. Klicke auf Einträge im Browser um sie hinzuzufügen.',
clearAll: 'Alle entfernen',
tabPlaylists: 'Wiedergabelisten',
tabAlbums: 'Alben',
tabArtists: 'Künstler',
searchPlaceholder: 'Suche…',
syncButton: 'Auf Gerät übertragen',
cancel: 'Abbrechen',
noTargetDir: 'Bitte zuerst einen Zielordner auswählen.',
noSources: 'Bitte mindestens eine Quelle auswählen.',
noTracks: 'Keine Tracks in den ausgewählten Quellen gefunden.',
fetchError: 'Fehler beim Laden der Tracks vom Server.',
syncComplete: 'Übertragung abgeschlossen.',
dismiss: 'Schließen',
},
};
+32
View File
@@ -25,6 +25,7 @@ export const enTranslation = {
mostPlayed: 'Most Played',
radio: 'Internet Radio',
folderBrowser: 'Folder Browser',
deviceSync: 'Device Sync',
libraryScope: 'Library scope',
allLibraries: 'All libraries',
expandPlaylists: 'Expand playlists',
@@ -1023,4 +1024,35 @@ export const enTranslation = {
empty: 'Empty folder',
error: 'Failed to load',
},
deviceSync: {
title: 'Device Sync',
targetFolder: 'Target Folder',
noFolderChosen: 'No folder chosen',
chooseFolder: 'Choose…',
filenameTemplate: 'Filename Template',
templateHint: 'Variables: {artist}, {album}, {title}, {track_number}, {disc_number}, {year}',
onDevice: 'On Device',
addSources: 'Add…',
colName: 'Name',
colType: 'Type',
syncResult: '{{done}} transferred, {{skipped}} already up to date ({{total}} total)',
deleteFromDevice: 'Delete from device ({{count}})',
confirmDelete: 'Delete {{count}} item(s) from the device: {{names}}?',
deleteComplete: '{{count}} item(s) removed from device.',
selectedSources: 'Selected Sources',
noSourcesSelected: 'No sources selected. Click items in the browser to add them.',
clearAll: 'Clear all',
tabPlaylists: 'Playlists',
tabAlbums: 'Albums',
tabArtists: 'Artists',
searchPlaceholder: 'Search…',
syncButton: 'Sync to Device',
cancel: 'Cancel',
noTargetDir: 'Please choose a target folder first.',
noSources: 'Please select at least one source.',
noTracks: 'No tracks found in the selected sources.',
fetchError: 'Failed to fetch tracks from server.',
syncComplete: 'Sync complete.',
dismiss: 'Dismiss',
},
};
+28
View File
@@ -25,6 +25,7 @@ export const esTranslation = {
mostPlayed: 'Más Reproducidos',
radio: 'Radio por Internet',
folderBrowser: 'Explorar Carpetas',
deviceSync: 'Sincronizar dispositivo',
libraryScope: 'Ámbito de biblioteca',
allLibraries: 'Todas las bibliotecas',
expandPlaylists: 'Expandir listas',
@@ -1024,4 +1025,31 @@ export const esTranslation = {
empty: 'Carpeta vacía',
error: 'Error al cargar',
},
deviceSync: {
title: 'Sincronizar dispositivo',
targetFolder: 'Carpeta de destino',
noFolderChosen: 'Ninguna carpeta seleccionada',
chooseFolder: 'Elegir…',
filenameTemplate: 'Plantilla de nombre de archivo',
templateHint: 'Variables: {artist}, {album}, {title}, {track_number}, {disc_number}, {year}',
cleanupButton: 'Eliminar archivos fuera de la selección',
cleanupNothingToDelete: 'Nada que eliminar — el dispositivo ya está sincronizado.',
confirmCleanup: '¿Eliminar {{count}} archivo(s) del dispositivo que no están en la selección actual? ¿Continuar?',
cleanupComplete: '{{count}} archivo(s) eliminado(s) del dispositivo.',
selectedSources: 'Fuentes seleccionadas',
noSourcesSelected: 'No hay fuentes seleccionadas. Haz clic en elementos del navegador para agregarlos.',
clearAll: 'Limpiar todo',
tabPlaylists: 'Listas de reproducción',
tabAlbums: 'Álbumes',
tabArtists: 'Artistas',
searchPlaceholder: 'Buscar…',
syncButton: 'Sincronizar al dispositivo',
cancel: 'Cancelar',
noTargetDir: 'Por favor, elige primero una carpeta de destino.',
noSources: 'Por favor, selecciona al menos una fuente.',
noTracks: 'No se encontraron pistas en las fuentes seleccionadas.',
fetchError: 'Error al obtener pistas del servidor.',
syncComplete: 'Sincronización completada.',
dismiss: 'Cerrar',
},
};
+28
View File
@@ -24,6 +24,7 @@ export const frTranslation = {
mostPlayed: 'Les plus joués',
radio: 'Radio Internet',
folderBrowser: 'Explorateur de dossiers',
deviceSync: 'Sync appareil',
libraryScope: 'Portée de la bibliothèque',
allLibraries: 'Toutes les bibliothèques',
expandPlaylists: 'Développer les playlists',
@@ -1019,4 +1020,31 @@ export const frTranslation = {
empty: 'Dossier vide',
error: 'Échec du chargement',
},
deviceSync: {
title: 'Sync appareil',
targetFolder: 'Dossier cible',
noFolderChosen: 'Aucun dossier sélectionné',
chooseFolder: 'Choisir…',
filenameTemplate: 'Modèle de nom de fichier',
templateHint: 'Variables : {artist}, {album}, {title}, {track_number}, {disc_number}, {year}',
cleanupButton: 'Supprimer les fichiers absents',
cleanupNothingToDelete: 'Rien à supprimer — l\'appareil est déjà synchronisé.',
confirmCleanup: 'Supprimer {{count}} fichier(s) de l\'appareil qui ne font pas partie de la sélection actuelle. Continuer ?',
cleanupComplete: '{{count}} fichier(s) supprimé(s) de l\'appareil.',
selectedSources: 'Sources sélectionnées',
noSourcesSelected: 'Aucune source sélectionnée. Cliquez sur des éléments dans le navigateur pour les ajouter.',
clearAll: 'Tout effacer',
tabPlaylists: 'Listes de lecture',
tabAlbums: 'Albums',
tabArtists: 'Artistes',
searchPlaceholder: 'Rechercher…',
syncButton: 'Synchroniser vers l\'appareil',
cancel: 'Annuler',
noTargetDir: 'Veuillez d\'abord choisir un dossier cible.',
noSources: 'Veuillez sélectionner au moins une source.',
noTracks: 'Aucune piste trouvée dans les sources sélectionnées.',
fetchError: 'Échec du chargement des pistes depuis le serveur.',
syncComplete: 'Synchronisation terminée.',
dismiss: 'Fermer',
},
};
+28
View File
@@ -24,6 +24,7 @@ export const nbTranslation = {
mostPlayed: 'Mest spilt',
radio: 'Internettradio',
folderBrowser: 'Mappeleser',
deviceSync: 'Enhetssynk',
libraryScope: 'Biblioteksomfang',
allLibraries: 'Alle biblioteker',
expandPlaylists: 'Utvid spillelister',
@@ -1018,4 +1019,31 @@ export const nbTranslation = {
empty: 'Tom mappe',
error: 'Kunne ikke laste',
},
deviceSync: {
title: 'Enhetssynk',
targetFolder: 'Målmappe',
noFolderChosen: 'Ingen mappe valgt',
chooseFolder: 'Velg…',
filenameTemplate: 'Filnavnmal',
templateHint: 'Variabler: {artist}, {album}, {title}, {track_number}, {disc_number}, {year}',
cleanupButton: 'Fjern filer som ikke er i utvalget',
cleanupNothingToDelete: 'Ingenting å fjerne — enheten er allerede synkronisert.',
confirmCleanup: 'Slette {{count}} fil(er) fra enheten som ikke er i det gjeldende utvalget. Fortsette?',
cleanupComplete: '{{count}} fil(er) fjernet fra enheten.',
selectedSources: 'Valgte kilder',
noSourcesSelected: 'Ingen kilder valgt. Klikk på elementer i nettleseren for å legge dem til.',
clearAll: 'Fjern alle',
tabPlaylists: 'Spillelister',
tabAlbums: 'Album',
tabArtists: 'Artister',
searchPlaceholder: 'Søk…',
syncButton: 'Synkroniser til enhet',
cancel: 'Avbryt',
noTargetDir: 'Velg en målmappe først.',
noSources: 'Velg minst én kilde.',
noTracks: 'Ingen spor funnet i de valgte kildene.',
fetchError: 'Kunne ikke hente spor fra serveren.',
syncComplete: 'Synkronisering fullført.',
dismiss: 'Lukk',
},
};
+28
View File
@@ -24,6 +24,7 @@ export const nlTranslation = {
mostPlayed: 'Meest gespeeld',
radio: 'Internetradio',
folderBrowser: 'Mappenverkenner',
deviceSync: 'Apparaatsync',
libraryScope: 'Bibliotheekbereik',
allLibraries: 'Alle bibliotheken',
expandPlaylists: 'Afspeellijsten uitklappen',
@@ -1016,4 +1017,31 @@ export const nlTranslation = {
empty: 'Lege map',
error: 'Laden mislukt',
},
deviceSync: {
title: 'Apparaatsync',
targetFolder: 'Doelmap',
noFolderChosen: 'Geen map gekozen',
chooseFolder: 'Kiezen…',
filenameTemplate: 'Bestandsnaamsjabloon',
templateHint: 'Variabelen: {artist}, {album}, {title}, {track_number}, {disc_number}, {year}',
cleanupButton: 'Niet-geselecteerde bestanden verwijderen',
cleanupNothingToDelete: 'Niets te verwijderen — apparaat is al gesynchroniseerd.',
confirmCleanup: '{{count}} bestand(en) verwijderen van het apparaat die niet in de huidige selectie staan. Doorgaan?',
cleanupComplete: '{{count}} bestand(en) van het apparaat verwijderd.',
selectedSources: 'Geselecteerde bronnen',
noSourcesSelected: 'Geen bronnen geselecteerd. Klik op items in de browser om ze toe te voegen.',
clearAll: 'Alles wissen',
tabPlaylists: 'Afspeellijsten',
tabAlbums: 'Albums',
tabArtists: 'Artiesten',
searchPlaceholder: 'Zoeken…',
syncButton: 'Synchroniseren naar apparaat',
cancel: 'Annuleren',
noTargetDir: 'Kies eerst een doelmap.',
noSources: 'Selecteer minimaal één bron.',
noTracks: 'Geen nummers gevonden in de geselecteerde bronnen.',
fetchError: 'Ophalen van nummers van de server mislukt.',
syncComplete: 'Synchronisatie voltooid.',
dismiss: 'Sluiten',
},
};
+28
View File
@@ -25,6 +25,7 @@ export const ruTranslation = {
mostPlayed: 'Популярное',
radio: 'Онлайн-радио',
folderBrowser: 'Браузер папок',
deviceSync: 'Синхронизация устройства',
libraryScope: 'Область медиатеки',
allLibraries: 'Все библиотеки',
expandPlaylists: 'Развернуть плейлисты',
@@ -1077,4 +1078,31 @@ export const ruTranslation = {
empty: 'Папка пуста',
error: 'Ошибка загрузки',
},
deviceSync: {
title: 'Синхронизация устройства',
targetFolder: 'Папка назначения',
noFolderChosen: 'Папка не выбрана',
chooseFolder: 'Выбрать…',
filenameTemplate: 'Шаблон имени файла',
templateHint: 'Переменные: {artist}, {album}, {title}, {track_number}, {disc_number}, {year}',
cleanupButton: 'Удалить файлы вне выборки',
cleanupNothingToDelete: 'Нечего удалять — устройство уже синхронизировано.',
confirmCleanup: 'Удалить {{count}} файл(ов) с устройства, не входящих в текущую выборку. Продолжить?',
cleanupComplete: '{{count}} файл(ов) удалено с устройства.',
selectedSources: 'Выбранные источники',
noSourcesSelected: 'Источники не выбраны. Нажмите на элементы в браузере, чтобы добавить их.',
clearAll: 'Очистить всё',
tabPlaylists: 'Плейлисты',
tabAlbums: 'Альбомы',
tabArtists: 'Исполнители',
searchPlaceholder: 'Поиск…',
syncButton: 'Синхронизировать на устройство',
cancel: 'Отмена',
noTargetDir: 'Сначала выберите папку назначения.',
noSources: 'Выберите хотя бы один источник.',
noTracks: 'В выбранных источниках не найдено треков.',
fetchError: 'Ошибка загрузки треков с сервера.',
syncComplete: 'Синхронизация завершена.',
dismiss: 'Закрыть',
},
};
+28
View File
@@ -24,6 +24,7 @@ export const zhTranslation = {
mostPlayed: '最常播放',
radio: '网络电台',
folderBrowser: '文件夹浏览器',
deviceSync: '设备同步',
libraryScope: '资料库范围',
allLibraries: '所有资料库',
expandPlaylists: '展开播放列表',
@@ -1012,4 +1013,31 @@ export const zhTranslation = {
empty: '空文件夹',
error: '加载失败',
},
deviceSync: {
title: '设备同步',
targetFolder: '目标文件夹',
noFolderChosen: '未选择文件夹',
chooseFolder: '选择…',
filenameTemplate: '文件名模板',
templateHint: '变量:{artist}、{album}、{title}、{track_number}、{disc_number}、{year}',
cleanupButton: '删除不在选择范围内的文件',
cleanupNothingToDelete: '无需删除 — 设备已同步。',
confirmCleanup: '将从设备中删除 {{count}} 个不在当前选择范围内的文件。继续?',
cleanupComplete: '已从设备中删除 {{count}} 个文件。',
selectedSources: '已选来源',
noSourcesSelected: '未选择来源。点击浏览器中的项目以添加。',
clearAll: '全部清除',
tabPlaylists: '播放列表',
tabAlbums: '专辑',
tabArtists: '艺术家',
searchPlaceholder: '搜索…',
syncButton: '同步到设备',
cancel: '取消',
noTargetDir: '请先选择目标文件夹。',
noSources: '请至少选择一个来源。',
noTracks: '所选来源中未找到曲目。',
fetchError: '从服务器加载曲目失败。',
syncComplete: '同步完成。',
dismiss: '关闭',
},
};