Compare commits

..

36 Commits

Author SHA1 Message Date
jeffvli 54b18601b8 Remove playlist detail route file 2023-12-19 14:59:32 -08:00
jeffvli 0cd0032966 Fix list sort 2023-12-19 14:59:15 -08:00
jeffvli d6cc6a4745 Support subsonic song filters 2023-12-19 14:58:52 -08:00
jeffvli f7fcf6c079 Support subsonic album filters 2023-12-18 12:02:41 -08:00
jeffvli 4051e9dfa3 Use imported jellyfin controller 2023-12-18 11:46:05 -08:00
jeffvli 5a94f70e63 Add list count endpoints to jf/nd 2023-12-18 11:45:04 -08:00
jeffvli 50dd70df81 Add global sort utils 2023-12-13 18:19:58 -08:00
jeffvli 8493668c97 Remove default playlist page 2023-12-13 18:19:58 -08:00
jeffvli d347221be5 Support playlists 2023-12-13 18:19:58 -08:00
jeffvli 18ec50b2a3 Support album and artist detail pages for subsonic 2023-12-13 18:19:58 -08:00
jeffvli 3c691d23d9 Return similar artists on artist detail 2023-12-13 18:19:57 -08:00
jeffvli 8ce2a99d37 Refactor sidebar playlist 2023-12-13 18:19:57 -08:00
jeffvli 567424011f Add subsonic in server entry form 2023-12-13 18:19:57 -08:00
jeffvli b2f14d7369 Support entity list pages for subsonic 2023-12-13 18:19:57 -08:00
jeffvli 2ecafea759 Fix album count translation string 2023-12-13 18:19:57 -08:00
jeffvli b7bbba928d Update log format 2023-12-13 18:19:57 -08:00
jeffvli 33b522a2f3 Fix expected controller responses 2023-12-13 18:19:57 -08:00
jeffvli f8d109fce4 Set search query to required 2023-12-13 18:19:57 -08:00
jeffvli 8fcf5291c4 Add first iteration of new subsonic controller 2023-12-13 18:19:57 -08:00
jeffvli 3b155cc6e8 Remove throw from log function
- Typescript cannot determine if a function throws an error
- Does not work as a type guard when using ts-rest
2023-12-13 18:19:57 -08:00
jeffvli 509627a0ad Allow null totalRecordCount on paginated response 2023-12-13 18:19:57 -08:00
jeffvli d08d3686de Add logger function 2023-12-13 18:19:57 -08:00
jeffvli ca695ca155 Add all relevant subsonic endpoints to ts-rest 2023-12-13 18:19:57 -08:00
jeffvli 7b639b45f7 Add new translations 2023-12-13 18:19:24 -08:00
Hosted Weblate 85d9162b12 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (519 of 519 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: 無情天 <kofzhanganguo@126.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/zh_Hans/
Translation: feishin/Translation
2023-12-13 09:28:56 +01:00
Hosted Weblate 36670b330f Translated using Weblate (Swedish)
Currently translated at 52.6% (273 of 519 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Mattias <mattiasghodsian@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/sv/
Translation: feishin/Translation
2023-12-13 09:28:56 +01:00
Hosted Weblate 9c380a8241 Translated using Weblate (French)
Currently translated at 99.4% (516 of 519 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: KosmoMoustache <KosmoMoustache@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/fr/
Translation: feishin/Translation
2023-12-13 09:28:56 +01:00
Hosted Weblate c26820ee82 Translated using Weblate (Dutch)
Currently translated at 35.8% (186 of 519 strings)

Translated using Weblate (Dutch)

Currently translated at 32.3% (168 of 519 strings)

Translated using Weblate (Dutch)

Currently translated at 15.2% (79 of 519 strings)

Added translation using Weblate (Dutch)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Idris Saklou <idrissaklou@hotmail.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/nl/
Translation: feishin/Translation
2023-12-13 09:28:56 +01:00
Hosted Weblate dccd6afc3d Translated using Weblate (Italian)
Currently translated at 99.0% (514 of 519 strings)

Translated using Weblate (Italian)

Currently translated at 96.5% (501 of 519 strings)

Co-authored-by: Aurora <arci@anche.no>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: NicKoehler <grillinicola@proton.me>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/it/
Translation: feishin/Translation
2023-12-13 09:28:56 +01:00
Hosted Weblate c6a520b0d7 Translated using Weblate (Polish)
Currently translated at 99.8% (518 of 519 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Mistify <fabianszafranski@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/pl/
Translation: feishin/Translation
2023-12-13 09:28:56 +01:00
Hosted Weblate 1f4f3a5497 Translated using Weblate (Czech)
Currently translated at 100.0% (519 of 519 strings)

Co-authored-by: Fjuro <ifjuro@proton.me>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/cs/
Translation: feishin/Translation
2023-12-13 09:28:56 +01:00
Hosted Weblate 58d04b3126 Translated using Weblate (German)
Currently translated at 88.8% (461 of 519 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Maik <maikguenes2003@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/de/
Translation: feishin/Translation
2023-12-13 09:28:56 +01:00
Hosted Weblate fcac4a5547 Translated using Weblate (Portuguese (Brazil))
Currently translated at 28.3% (147 of 519 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Rafael Vieira <rafaelvieiras@pm.me>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/pt_BR/
Translation: feishin/Translation
2023-12-13 09:28:56 +01:00
Kendall Garner c05b474827 fix navi null date (#408) 2023-12-13 00:28:53 -08:00
mcneb10 a8814d3e8a Fix 'undefined' in window title when song has no artist name (#402) 2023-12-05 19:05:08 -08:00
Kendall Garner 3f9cdab450 convert value to number on set (#390) 2023-12-04 20:20:19 -08:00
66 changed files with 4736 additions and 1634 deletions
+15 -2
View File
@@ -14,6 +14,8 @@ import ptBr from './locales/pt-BR.json';
import sr from './locales/sr.json'; import sr from './locales/sr.json';
import sv from './locales/sv.json'; import sv from './locales/sv.json';
import cs from './locales/cs.json'; import cs from './locales/cs.json';
import nbNO from './locales/nb-NO.json';
import nl from './locales/nl.json';
const resources = { const resources = {
en: { translation: en }, en: { translation: en },
@@ -29,6 +31,8 @@ const resources = {
sr: { translation: sr }, sr: { translation: sr },
sv: { translation: sv }, sv: { translation: sv },
cs: { translation: cs }, cs: { translation: cs },
nl: { translation: nl },
'nb-NO': { translation: nbNO },
}; };
export const languages = [ export const languages = [
@@ -61,9 +65,14 @@ export const languages = [
value: 'ja', value: 'ja',
}, },
{ {
label: 'Русский', label: 'Nederlands',
value: 'ru', value: 'nl',
}, },
{
label: 'Norsk (Bokmål)',
value: 'nb-NO',
},
{ {
label: 'Português (Brasil)', label: 'Português (Brasil)',
value: 'pt-BR', value: 'pt-BR',
@@ -72,6 +81,10 @@ export const languages = [
label: 'Polski', label: 'Polski',
value: 'pl', value: 'pl',
}, },
{
label: 'Русский',
value: 'ru',
},
{ {
label: 'Srpski', label: 'Srpski',
value: 'sr', value: 'sr',
+5 -5
View File
@@ -584,16 +584,16 @@
"entity": { "entity": {
"genre_one": "žánr", "genre_one": "žánr",
"genre_few": "žánry", "genre_few": "žánry",
"genre_other": "žánrů", "genre_other": "žánry",
"playlistWithCount_one": "{{count}} playlist", "playlistWithCount_one": "{{count}} playlist",
"playlistWithCount_few": "{{count}} playlisty", "playlistWithCount_few": "{{count}} playlisty",
"playlistWithCount_other": "{{count}} playlistů", "playlistWithCount_other": "{{count}} playlistů",
"playlist_one": "playlist", "playlist_one": "playlist",
"playlist_few": "playlisty", "playlist_few": "playlisty",
"playlist_other": "playlistů", "playlist_other": "playlisty",
"artist_one": "umělec", "artist_one": "umělec",
"artist_few": "umělci", "artist_few": "umělci",
"artist_other": "umělců", "artist_other": "umělci",
"folderWithCount_one": "{{count}} složka", "folderWithCount_one": "{{count}} složka",
"folderWithCount_few": "{{count}} složky", "folderWithCount_few": "{{count}} složky",
"folderWithCount_other": "{{count}} složek", "folderWithCount_other": "{{count}} složek",
@@ -602,7 +602,7 @@
"albumArtist_other": "umělců alba", "albumArtist_other": "umělců alba",
"track_one": "skladba", "track_one": "skladba",
"track_few": "skladby", "track_few": "skladby",
"track_other": "skladeb", "track_other": "skladby",
"albumArtistCount_one": "{{count}} umělec alba", "albumArtistCount_one": "{{count}} umělec alba",
"albumArtistCount_few": "{{count}} umělci alba", "albumArtistCount_few": "{{count}} umělci alba",
"albumArtistCount_other": "{{count}} umělců alba", "albumArtistCount_other": "{{count}} umělců alba",
@@ -621,7 +621,7 @@
"smartPlaylist": "chytrý $t(entity.playlist_one)", "smartPlaylist": "chytrý $t(entity.playlist_one)",
"album_one": "album", "album_one": "album",
"album_few": "alba", "album_few": "alba",
"album_other": "alb", "album_other": "alba",
"genreWithCount_one": "{{count}} žánr", "genreWithCount_one": "{{count}} žánr",
"genreWithCount_few": "{{count}} žánry", "genreWithCount_few": "{{count}} žánry",
"genreWithCount_other": "{{count}} žánrů", "genreWithCount_other": "{{count}} žánrů",
+15 -1
View File
@@ -250,7 +250,18 @@
"config": { "config": {
"view": { "view": {
"table": "Tabelle" "table": "Tabelle"
},
"general": {
"tableColumns": "Tabellenspalten"
} }
},
"column": {
"releaseYear": "Jahr",
"biography": "Biografie",
"releaseDate": "Veröffentlichungsdatum",
"bitrate": "Bitrate",
"title": "Titel",
"path": "Pfad"
} }
}, },
"page": { "page": {
@@ -537,6 +548,9 @@
"fontType": "Schriftartenquelle", "fontType": "Schriftartenquelle",
"followLyric": "Songtext synchronisieren", "followLyric": "Songtext synchronisieren",
"floatingQueueArea_description": "Zeige ein Icon auf der rechten Seite, um beim Darüberfahren die Wartschlange anzuzeigen", "floatingQueueArea_description": "Zeige ein Icon auf der rechten Seite, um beim Darüberfahren die Wartschlange anzuzeigen",
"font_description": "Wähle die Schriftart für die Anwendung" "font_description": "Wähle die Schriftart für die Anwendung",
"themeLight": "Thema (hell)",
"sidePlayQueueStyle_optionDetached": "lösgelöst",
"windowBarStyle_description": "Wähle den Stil der Windows-Leiste"
} }
} }
+1 -1
View File
@@ -552,7 +552,7 @@
"column": { "column": {
"album": "album", "album": "album",
"albumArtist": "album artist", "albumArtist": "album artist",
"albumCount": "$t(entity.album_other)", "albumCount": "$t(entity.album_one)",
"artist": "$t(entity.artist_one)", "artist": "$t(entity.artist_one)",
"biography": "biography", "biography": "biography",
"bitrate": "bitrate", "bitrate": "bitrate",
+25 -13
View File
@@ -62,7 +62,7 @@
"left": "gauche", "left": "gauche",
"save": "sauvegarder", "save": "sauvegarder",
"right": "droite", "right": "droite",
"currentSong": "actuelle $t(entity.track_one)", "currentSong": "$t(entity.track_one) actuelle",
"collapse": "réduire", "collapse": "réduire",
"trackNumber": "piste", "trackNumber": "piste",
"descending": "décroisant", "descending": "décroisant",
@@ -225,7 +225,7 @@
"lyricSize": "Taille des paroles", "lyricSize": "Taille des paroles",
"lyricGap": "espacement des lettres" "lyricGap": "espacement des lettres"
}, },
"upNext": "suivant", "upNext": "à suivre",
"lyrics": "paroles", "lyrics": "paroles",
"related": "similaire" "related": "similaire"
}, },
@@ -332,7 +332,7 @@
"mpvExecutablePath_description": "définit le chemin vers l'exécutable mpv", "mpvExecutablePath_description": "définit le chemin vers l'exécutable mpv",
"hotkey_favoriteCurrentSong": "favori $t(common.currentSong)", "hotkey_favoriteCurrentSong": "favori $t(common.currentSong)",
"sampleRate": "taux d'échantillonnage", "sampleRate": "taux d'échantillonnage",
"sampleRate_description": "sélectionnez le taux d'échantillonnage de sortie utilisé si la fréquence d'échantillonnage sélectionnée est différente du média actuel", "sampleRate_description": "sélectionnez le taux d'échantillonnage de sortie utilisé si la fréquence d'échantillonnage sélectionnée est différente de celle du média actuel",
"hotkey_zoomIn": "zoom avant", "hotkey_zoomIn": "zoom avant",
"scrobble_description": "scrobble les lectures à votre serveur multimédia", "scrobble_description": "scrobble les lectures à votre serveur multimédia",
"hotkey_browserForward": "avancer", "hotkey_browserForward": "avancer",
@@ -341,7 +341,7 @@
"hotkey_playbackPlayPause": "lecture / pause", "hotkey_playbackPlayPause": "lecture / pause",
"hotkey_rate1": "noter 1 étoile", "hotkey_rate1": "noter 1 étoile",
"hotkey_skipForward": "avancer", "hotkey_skipForward": "avancer",
"disableLibraryUpdateOnStartup": "désactive la vérification de mise à jour au démarrage", "disableLibraryUpdateOnStartup": "désactive la recherche de mise à jour au démarrage",
"gaplessAudio": "audio sans interruption", "gaplessAudio": "audio sans interruption",
"minimizeToTray_description": "réduit l'application vers la barre des tâches", "minimizeToTray_description": "réduit l'application vers la barre des tâches",
"hotkey_playbackPlay": "lecture", "hotkey_playbackPlay": "lecture",
@@ -402,10 +402,10 @@
"followLyric": "suivre les paroles actuelles", "followLyric": "suivre les paroles actuelles",
"discordIdleStatus": "afficher l'état d'inactivité dans le status de l'activité", "discordIdleStatus": "afficher l'état d'inactivité dans le status de l'activité",
"hotkey_zoomOut": "zoom arrière", "hotkey_zoomOut": "zoom arrière",
"hotkey_unfavoriteCurrentSong": "favorisé $t(common.currentSong)", "hotkey_unfavoriteCurrentSong": "retirer des favoris la $t(common.currentSong)",
"hotkey_rate0": "supprimer la note", "hotkey_rate0": "supprimer la note",
"hotkey_volumeMute": "couper le son", "hotkey_volumeMute": "couper le son",
"hotkey_toggleCurrentSongFavorite": "basculer $t(common.currentSong) favori", "hotkey_toggleCurrentSongFavorite": "basculer favori de la $t(common.currentSong)",
"remoteUsername": "nom d'utilisateur du serveur de contrôle à distance", "remoteUsername": "nom d'utilisateur du serveur de contrôle à distance",
"hotkey_browserBack": "retour arrière", "hotkey_browserBack": "retour arrière",
"showSkipButton": "affiche les boutons suivants et précédents", "showSkipButton": "affiche les boutons suivants et précédents",
@@ -414,21 +414,21 @@
"minimumScrobbleSeconds": "scrobble minimum (secondes)", "minimumScrobbleSeconds": "scrobble minimum (secondes)",
"hotkey_playbackStop": "stop", "hotkey_playbackStop": "stop",
"font_description": "définit la police à utiliser pour l'application", "font_description": "définit la police à utiliser pour l'application",
"savePlayQueue_description": "sauvegarde la liste de lecture quand l'application est fermée et restaure quand l'application est ouverte", "savePlayQueue_description": "sauvegarde la liste de lecture quand l'application est fermée et la restaure quand l'application est ouverte",
"sidebarCollapsedNavigation_description": "affiche ou cache la navigation dans la barre latérale réduite", "sidebarCollapsedNavigation_description": "affiche ou cache la navigation dans la barre latérale réduite",
"sidebarConfiguration": "configuration de la barre latérale", "sidebarConfiguration": "configuration de la barre latérale",
"sidebarConfiguration_description": "sélectionnez les items et l'ordre dans lesquels ils seront affichaient dans la barre latérale", "sidebarConfiguration_description": "sélectionnez les items et l'ordre dans lesquels ils seront affichaient dans la barre latérale",
"sidebarPlaylistList": "liste de playlist de la barre latérale", "sidebarPlaylistList": "liste de playlist de la barre latérale",
"sidebarCollapsedNavigation": "navigation de la barre latéral (réduite)", "sidebarCollapsedNavigation": "navigation de la barre latéral (réduite)",
"skipDuration": "temps de l'avance rapide", "skipDuration": "durée de l'avance rapide",
"sidePlayQueueStyle_optionAttached": "attaché", "sidePlayQueueStyle_optionAttached": "attaché",
"sidePlayQueueStyle": "style de la liste de lecture latérale", "sidePlayQueueStyle": "style de la liste de lecture latérale",
"sidebarPlaylistList_description": "affiche ou cache la liste de playlist de la barre latérale", "sidebarPlaylistList_description": "affiche ou cache la liste de playlist de la barre latérale",
"sidePlayQueueStyle_description": "définit le style de la liste de lecture latérale", "sidePlayQueueStyle_description": "définit le style de la liste de lecture latérale",
"sidePlayQueueStyle_optionDetached": "détaché", "sidePlayQueueStyle_optionDetached": "détaché",
"volumeWheelStep_description": "la quantité de volume à modifier lors du défilement de la molette de la souris sur le curseur de volume", "volumeWheelStep_description": "la valeur de volume à modifier lors du défilement de la molette de la souris sur le curseur de volume",
"theme_description": "définit le thème à utiliser pour l'application", "theme_description": "définit le thème à utiliser pour l'application",
"skipDuration_description": "définit le durée de l'avance rapide, lors de l'utilisation des boutons skip dans la barre de lecture", "skipDuration_description": "définit le durée du saut rapide, lors de l'utilisation des boutons avancer/reculer de la barre de lecture",
"themeLight": "thème (clair)", "themeLight": "thème (clair)",
"zoom": "pourcentage de zoom", "zoom": "pourcentage de zoom",
"themeDark_description": "définit le thème sombre à utiliser pour l'application", "themeDark_description": "définit le thème sombre à utiliser pour l'application",
@@ -436,7 +436,7 @@
"zoom_description": "définit le pourcentage de zoom de l'application", "zoom_description": "définit le pourcentage de zoom de l'application",
"theme": "thème", "theme": "thème",
"skipPlaylistPage_description": "lors de la navigation dans une playlist, aller directement vers le liste des morceaux, au lieu de la page par défaut", "skipPlaylistPage_description": "lors de la navigation dans une playlist, aller directement vers le liste des morceaux, au lieu de la page par défaut",
"volumeWheelStep": "marche du curseur de volume", "volumeWheelStep": "valeur du pas de volume",
"windowBarStyle": "style de la barre de la fenêtre", "windowBarStyle": "style de la barre de la fenêtre",
"useSystemTheme_description": "suivre les préférence du système (sombre ou clair)", "useSystemTheme_description": "suivre les préférence du système (sombre ou clair)",
"skipPlaylistPage": "sauter la page de playlist", "skipPlaylistPage": "sauter la page de playlist",
@@ -453,7 +453,15 @@
"playButtonBehavior_optionAddLast": "$t(player.addLast)", "playButtonBehavior_optionAddLast": "$t(player.addLast)",
"replayGainMode_optionAlbum": "$t(entity.album_one)", "replayGainMode_optionAlbum": "$t(entity.album_one)",
"replayGainMode_optionTrack": "$t(entity.track_one)", "replayGainMode_optionTrack": "$t(entity.track_one)",
"playButtonBehavior_optionAddNext": "$t(player.addNext)" "playButtonBehavior_optionAddNext": "$t(player.addNext)",
"replayGainMode_description": "ajuste le gain de volume accordement à la valeur de {{ReplayGain}} sauvegardé dans les métadonnées du fichier",
"replayGainFallback": "{{ReplayGain}} fallback",
"replayGainClipping_description": "Préviens le clipping causé par {{ReplayGain}} en baissant automatiquement le gain",
"replayGainPreamp": "préamplificateur (dB) de {{ReplayGain}}",
"replayGainClipping": "{{ReplayGain}} clipping",
"replayGainMode": "mode de {{ReplayGain}}",
"replayGainFallback_description": "gain en dB à appliquer si le fichier n'a pas de tag {{ReplayGain}}",
"replayGainPreamp_description": "ajuste le gain de préampli appliqué a la valeur de {{ReplayGain}}"
}, },
"form": { "form": {
"deletePlaylist": { "deletePlaylist": {
@@ -589,7 +597,11 @@
"rating": "$t(common.rating)", "rating": "$t(common.rating)",
"note": "$t(common.note)", "note": "$t(common.note)",
"owner": "$t(common.owner)", "owner": "$t(common.owner)",
"path": "$t(common.path)" "path": "$t(common.path)",
"title": "$t(common.title)",
"size": "$t(common.size)",
"genre": "$t(entity.genre_one)",
"year": "$t(common.year)"
} }
}, },
"column": { "column": {
+29 -14
View File
@@ -46,7 +46,7 @@
"left": "sinistra", "left": "sinistra",
"save": "salva", "save": "salva",
"right": "destra", "right": "destra",
"currentSong": "$t(entity.track_one) corrent", "currentSong": "$t(entity.track_one) corrente",
"trackNumber": "traccia", "trackNumber": "traccia",
"descending": "decrescente", "descending": "decrescente",
"gap": "gap", "gap": "gap",
@@ -102,9 +102,9 @@
"note": "nota" "note": "nota"
}, },
"player": { "player": {
"repeat_all": "ripeti tutto", "repeat_all": "ripeti coda",
"stop": "ferma", "stop": "ferma",
"repeat": "ripeti", "repeat": "ripeti traccia",
"queue_remove": "rimuovi selezionati", "queue_remove": "rimuovi selezionati",
"playRandom": "riproduci casuale", "playRandom": "riproduci casuale",
"skip": "salta", "skip": "salta",
@@ -113,21 +113,21 @@
"skip_back": "salta indietro", "skip_back": "salta indietro",
"favorite": "preferito", "favorite": "preferito",
"next": "successivo", "next": "successivo",
"shuffle": "mischia", "shuffle": "mescola",
"playbackFetchNoResults": "nessuna canzone trovata", "playbackFetchNoResults": "nessuna canzone trovata",
"playbackFetchInProgress": "caricamento canzoni…", "playbackFetchInProgress": "caricamento canzoni…",
"addNext": "aggiungi successivo", "addNext": "aggiungi successivo",
"playbackSpeed": "velocità riproduzione", "playbackSpeed": "velocità di riproduzione",
"playbackFetchCancel": "ci sta mettendo un po'... chiudi la notifica per annullare", "playbackFetchCancel": "ci sta mettendo un po'... chiudi la notifica per annullare",
"play": "riproduci", "play": "riproduci",
"repeat_off": "ripeti disabilitato", "repeat_off": "non ripetere",
"pause": "pausa", "pause": "pausa",
"queue_clear": "cancella coda", "queue_clear": "cancella coda",
"muted": "silenziato", "muted": "silenziato",
"unfavorite": "togli dai preferiti", "unfavorite": "togli dai preferiti",
"queue_moveToTop": "sposta selezionati in fondo", "queue_moveToTop": "sposta selezionati in fondo",
"queue_moveToBottom": "sposta selezionati in cima", "queue_moveToBottom": "sposta selezionati in cima",
"shuffle_off": "mischia disabilitato", "shuffle_off": "non mescolare",
"addLast": "aggiungi in coda", "addLast": "aggiungi in coda",
"mute": "silenzia", "mute": "silenzia",
"skip_forward": "salta avanti" "skip_forward": "salta avanti"
@@ -156,8 +156,8 @@
"crossfadeStyle": "stile dissolvenza", "crossfadeStyle": "stile dissolvenza",
"sidebarConfiguration": "configurazione barra laterale", "sidebarConfiguration": "configurazione barra laterale",
"replayGainMode_optionNone": "$t(common.none)", "replayGainMode_optionNone": "$t(common.none)",
"hotkey_zoomIn": "ingrandisci", "hotkey_zoomIn": "ingrandisci layout",
"scrobble_description": "esegui scrobble delle riproduzioni al tuo media server", "scrobble_description": "invia lo scrobble delle riproduzioni al tuo media server",
"audioExclusiveMode_description": "abilità modalità output esclusiva. In questa modalità il sistema è di solito chiuso fuori, e solo mpv potrà riprodurre audio", "audioExclusiveMode_description": "abilità modalità output esclusiva. In questa modalità il sistema è di solito chiuso fuori, e solo mpv potrà riprodurre audio",
"discordUpdateInterval": "intervallo aggiornamento stato attività {{discord}}", "discordUpdateInterval": "intervallo aggiornamento stato attività {{discord}}",
"themeLight": "tema (chiaro)", "themeLight": "tema (chiaro)",
@@ -207,7 +207,7 @@
"crossfadeDuration_description": "imposta la durata dell'effetto di dissolvenza", "crossfadeDuration_description": "imposta la durata dell'effetto di dissolvenza",
"language": "lingua", "language": "lingua",
"playbackStyle": "stile riproduzione", "playbackStyle": "stile riproduzione",
"hotkey_toggleShuffle": "attiva/disattiva mischia", "hotkey_toggleShuffle": "attiva/disattiva mescolamento",
"theme": "tema", "theme": "tema",
"playbackStyle_description": "selezione lo stile di riproduzione da usare per il player audio", "playbackStyle_description": "selezione lo stile di riproduzione da usare per il player audio",
"discordRichPresence_description": "abilita lo status del playback nello stato attività di {{discord}}. Le chiavi immagine sono: {{icon}}, {{playing}} e {{paused}} ", "discordRichPresence_description": "abilita lo status del playback nello stato attività di {{discord}}. Le chiavi immagine sono: {{icon}}, {{playing}} e {{paused}} ",
@@ -247,7 +247,7 @@
"crossfadeDuration": "durata dissolvenza", "crossfadeDuration": "durata dissolvenza",
"discordIdleStatus": "visualizza lo stato attività in stato inattivo", "discordIdleStatus": "visualizza lo stato attività in stato inattivo",
"audioPlayer": "player audio", "audioPlayer": "player audio",
"hotkey_zoomOut": "rimpicciolisci", "hotkey_zoomOut": "rimpicciolisci layout",
"hotkey_rate0": "rimuovi voto", "hotkey_rate0": "rimuovi voto",
"discordApplicationId": "application id {{discord}}", "discordApplicationId": "application id {{discord}}",
"applicationHotkeys_description": "configura tasti a scelta rapida dell'applicazione. attiva/disattiva la casella per impostare un tasto a scelta rapida globale (solo desktop)", "applicationHotkeys_description": "configura tasti a scelta rapida dell'applicazione. attiva/disattiva la casella per impostare un tasto a scelta rapida globale (solo desktop)",
@@ -275,7 +275,21 @@
"showSkipButton_description": "mostra o nascondi i pulsanti per saltare nella barra del player", "showSkipButton_description": "mostra o nascondi i pulsanti per saltare nella barra del player",
"hotkey_unfavoriteCurrentSong": "rimuovi $t(common.currentSong) dai preferiti", "hotkey_unfavoriteCurrentSong": "rimuovi $t(common.currentSong) dai preferiti",
"hotkey_toggleCurrentSongFavorite": "imposta/rimuovi $t(common.currentSong) favorito", "hotkey_toggleCurrentSongFavorite": "imposta/rimuovi $t(common.currentSong) favorito",
"showSkipButton": "mostra pulsanti per saltare" "showSkipButton": "mostra pulsanti per saltare",
"hotkey_browserForward": "Vai avanti di una pagina",
"hotkey_browserBack": "Torna indietro di una pagina",
"sidebarCollapsedNavigation_description": "mostra o nascondi la navigazione nella barra laterale collassata",
"replayGainClipping_description": "Previeni il clipping causato da {{ReplayGain}} abbassando automaticamente il gain",
"replayGainPreamp": "preamplificazione {{ReplayGain}} (dB)",
"sidePlayQueueStyle": "stile della coda di riproduzione laterale",
"showSkipButtons_description": "mostra o nascondi i pulsanti per saltare dalla barra di riproduzione",
"skipPlaylistPage_description": "quando si naviga in una playlist, si va alla pagina dell'elenco dei brani della playlist invece che alla pagina predefinita",
"sidePlayQueueStyle_description": "imposta lo stile della coda di riproduzione laterale",
"replayGainMode": "modalità {{ReplayGain}}",
"replayGainFallback_description": "gain in db da applicare se il file non possiede tag {{ReplayGain}}",
"replayGainPreamp_description": "aggiusta la preamplificazione del gain applicato sui valori {{ReplayGain}}",
"skipPlaylistPage": "Salta la pagina playlist",
"sidebarCollapsedNavigation": "navigazione con barra laterale (collassata)"
}, },
"error": { "error": {
"remotePortWarning": "riavvia il server per applicare la nuova porta", "remotePortWarning": "riavvia il server per applicare la nuova porta",
@@ -378,7 +392,7 @@
"selectServer": "seleziona server", "selectServer": "seleziona server",
"version": "versione {{version}}", "version": "versione {{version}}",
"settings": "$t(common.setting_other)", "settings": "$t(common.setting_other)",
"manageServers": "gestisci sever", "manageServers": "gestisci server",
"expandSidebar": "espandi barra laterale", "expandSidebar": "espandi barra laterale",
"collapseSidebar": "collassa barra laterale", "collapseSidebar": "collassa barra laterale",
"openBrowserDevtools": "apri devtools browser", "openBrowserDevtools": "apri devtools browser",
@@ -505,7 +519,8 @@
"size": "$t(common.size)" "size": "$t(common.size)"
}, },
"view": { "view": {
"table": "tabella" "table": "tabella",
"card": "Scheda"
}, },
"label": { "label": {
"releaseDate": "data rilascio", "releaseDate": "data rilascio",
+232
View File
@@ -0,0 +1,232 @@
{
"action": {
"editPlaylist": "pas $t(entity.playlist_one) aan",
"goToPage": "ga naar pagina",
"moveToTop": "verplaats naar top",
"addToFavorites": "toevoegen aan $t(entity.favorite_other)",
"addToPlaylist": "toevoegen aan $t(entity.playlist_one)",
"createPlaylist": "maak $t(entity.playlist_one)",
"removeFromPlaylist": "verwijder van $t(entity.playlist_one)",
"viewPlaylists": "bekijk $t(entity.playlist_other)",
"refresh": "$t(common.refresh)",
"deletePlaylist": "verwijder $t(entity.playlist_one)",
"removeFromQueue": "verwijder van lijst",
"deselectAll": "deselecteer alles",
"moveToBottom": "verplaats naar bodem",
"setRating": "selecteer rating",
"toggleSmartPlaylistEditor": "editor $t(entity.smartPlaylist) schakelen",
"removeFromFavorites": "verwijder van $t(entity.favorite_other)",
"clearQueue": "lijst leegmaken"
},
"common": {
"backward": "achteruit",
"increase": "verhogen",
"rating": "rating",
"bpm": "bpm",
"areYouSure": "weet je het zeker?",
"edit": "aanpassen",
"favorite": "favoriet",
"left": "links",
"currentSong": "huidig $t(entity.track_one)",
"collapse": "samenvouwen",
"descending": "aflopend",
"add": "toevoegen",
"gap": "gat",
"ascending": "oplopend",
"dismiss": "negeren",
"manage": "beheren",
"limit": "limiet",
"minimize": "minimaliseren",
"modified": "aangepast",
"duration": "duur",
"name": "naam",
"maximize": "maximaliseren",
"decrease": "verminder",
"ok": "ok",
"description": "beschrijving",
"configure": "configureren",
"path": "pad",
"center": "centreren",
"no": "nee",
"owner": "eigenaar",
"enable": "activeren",
"clear": "opschonen",
"forward": "vooruit",
"delete": "verwijder",
"cancel": "annuleer",
"forceRestartRequired": "herstart om aanpassingen toe te passen... wanneer de notificatie gesloten wordt zal de applicatie herstarten",
"filter_one": "filter",
"filter_other": "filters",
"filters": "filters",
"create": "aanmaken",
"bitrate": "bitrate",
"action_one": "actie",
"action_other": "acties",
"playerMustBePaused": "player moet gepauzeerd zijn",
"confirm": "bevestig",
"home": "home",
"comingSoon": "komt binnenkort…",
"channel_one": "kanaal",
"channel_other": "kanalen",
"disable": "deactiveren",
"none": "geen",
"menu": "menu",
"previousSong": "vorige $t(entity.track_one)",
"noResultsFromQuery": "de zoekopdracht leverde geen resultaten op",
"quit": "sluiten",
"expand": "vergroten",
"disc": "disk",
"random": "willekeurig",
"biography": "biografie",
"note": "Opmerking",
"refresh": "verversen",
"unknown": "onbekend",
"save": "opslaan",
"right": "rechts",
"trackNumber": "track",
"year": "jaar",
"version": "versie",
"title": "titel",
"saveAndReplace": "opslaan en vervangen",
"resetToDefault": "herstellen naar standaard",
"reset": "terugzetten",
"sortOrder": "volgorde",
"restartRequired": "herstart is nodig",
"search": "zoeken",
"saveAs": "opslaan als",
"yes": "ja",
"size": "grootte"
},
"filter": {
"rating": "rating",
"communityRating": "community rating",
"criticRating": "criticus rating",
"mostPlayed": "meest gespeeld",
"comment": "commentaar",
"playCount": "aantal keer afgespeeld",
"recentlyUpdated": "recentelijk geüpdate",
"channels": "$t(common.channel_other)",
"isCompilation": "is compilatie",
"recentlyPlayed": "recentelijk afgespeeld",
"isRated": "is rated",
"owner": "$t(common.owner)",
"bitrate": "bitrate",
"genre": "$t(entity.genre_one)",
"recentlyAdded": "recentelijk toegevoegd",
"note": "notitie",
"name": "naam",
"dateAdded": "datum toegevoegd",
"albumCount": "$t(entity.album_other) totaal",
"path": "pad",
"favorited": "favoriet",
"albumArtist": "$t(entity.albumArtist_one)",
"isRecentlyPlayed": "is recentelijk afgespeeld",
"isFavorited": "is favoriet",
"bpm": "bpm",
"id": "id",
"disc": "disk",
"biography": "biografie",
"artist": "$t(entity.artist_one)",
"duration": "duratie",
"isPublic": "is publiek",
"random": "willekeurig",
"lastPlayed": "laatst gespeeld",
"fromYear": "van jaar",
"album": "$t(entity.album_one)",
"title": "titel",
"search": "zoeken",
"releaseDate": "releasedatum",
"releaseYear": "release jaar",
"songCount": "aantal nummers",
"toYear": "tot jaar",
"trackNumber": "track"
},
"page": {
"contextMenu": {
"setRating": "$t(action.setRating)"
}
},
"error": {
"remotePortWarning": "herstart de server om de nieuwe poort in te stellen",
"systemFontError": "er is iets fout gegaan tijdens het verkrijgen van systeem fonts",
"playbackError": "er is iets fout gegaan bij het afspelen van de media",
"endpointNotImplementedError": "endpoint {{endpoint}} is niet geïmplementeerd voor {{serverType}}",
"remotePortError": "er is iets fout gegaan tijdens het selecteren van de remote server",
"serverRequired": "server vereist",
"authenticationFailed": "authenticatie mislukt",
"apiRouteError": "verzoek kan niet doorgestuurd worden",
"genericError": "er is iets fout gegaan",
"credentialsRequired": "inloggegevens vereist",
"sessionExpiredError": "jouw sessie is verlopen",
"remoteEnableError": "er is iets fout gegaan tijdens het $t(common.enable) van de remote server",
"localFontAccessDenied": "toegang geweigerd tot lokale fonts",
"serverNotSelectedError": "geen server geselecteerd",
"remoteDisableError": "er is iets fout gegaan tijdens het $t(common.disable) van de remote server",
"mpvRequired": "MPV vereist",
"audioDeviceFetchError": "er is iets mis gegaan met het ophalen van de audioapparaten",
"invalidServer": "ongeldige server",
"loginRateError": "te veel login pogingen, probeer het opnieuw in een paar seconde"
},
"entity": {
"genre_one": "genre",
"genre_other": "genres",
"playlistWithCount_one": "{{count}} afspeellijst",
"playlistWithCount_other": "{{count}} afspeellijsten",
"playlist_one": "afspeellijst",
"playlist_other": "afspeellijsten",
"artist_one": "artiest",
"artist_other": "artiesten",
"folderWithCount_one": "{{count}} folder",
"folderWithCount_other": "{{count}} folders",
"albumArtist_one": "album artiest",
"albumArtist_other": "album artiesten",
"track_one": "track",
"track_other": "tracks",
"albumArtistCount_one": "{{count}} album artiest",
"albumArtistCount_other": "{{count}} album artiesten",
"albumWithCount_one": "{{count}} album",
"albumWithCount_other": "{{count}} albums",
"favorite_one": "favoriet",
"favorite_other": "favorieten",
"artistWithCount_one": "{{count}} artiest",
"artistWithCount_other": "{{count}} artiesten",
"folder_one": "folder",
"folder_other": "folders",
"smartPlaylist": "smart $t(entity.playlist_one)",
"album_one": "album",
"album_other": "albums",
"genreWithCount_one": "{{count}} genre",
"genreWithCount_other": "{{count}} genres",
"trackWithCount_one": "{{count}} track",
"trackWithCount_other": "{{count}} tracks"
},
"table": {
"column": {
"rating": "rating"
},
"config": {
"label": {
"rating": "$t(common.rating)"
}
}
},
"setting": {
"hotkey_rate5": "rating 5 sterren",
"hotkey_rate4": "rating 4 sterren"
},
"form": {
"addServer": {
"title": "server toevoegen",
"input_username": "gebruikersnaam",
"input_url": "url",
"input_password": "wachtwoord",
"input_legacyAuthentication": "activeer legacy authenticatie",
"input_name": "server naam",
"success": "server met succes toegevoegd",
"input_savePassword": "wachtwoord opslaan",
"ignoreSsl": "negeer ssl $t(common.restartRequired)",
"ignoreCors": "negeer cors $t(common.restartRequired)",
"error_savePassword": "er is iets mis gegaan met het opslaan van het wachtwoord"
}
}
}
+3 -3
View File
@@ -501,18 +501,18 @@
"mpvExecutablePath": "ścieżka pliku wykonywalnego mpv", "mpvExecutablePath": "ścieżka pliku wykonywalnego mpv",
"playButtonBehavior_description": "ustaw domyślne zachowanie dla przycisku odtwarzania kiedy piosenka zostanie dodana do kolejki", "playButtonBehavior_description": "ustaw domyślne zachowanie dla przycisku odtwarzania kiedy piosenka zostanie dodana do kolejki",
"minimumScrobblePercentage_description": "minimalny czas odtwarzania piosenki który musi upłynąć aby uznać ją za scrobble", "minimumScrobblePercentage_description": "minimalny czas odtwarzania piosenki który musi upłynąć aby uznać ją za scrobble",
"minimumScrobbleSeconds_description": "minimalny czas odtwarzania piosenki w sekundach jaki musi upłynąć aby uznać ją za scrobble", "minimumScrobbleSeconds_description": "minimalny czas odtwarzania piosenki w sekundach jaki musi upłynąć aby uznać ją za scrobbling",
"playButtonBehavior": "zachowanie przycisku odtwarzania", "playButtonBehavior": "zachowanie przycisku odtwarzania",
"playbackStyle_optionNormal": "normalny", "playbackStyle_optionNormal": "normalny",
"playButtonBehavior_optionAddNext": "$t(player.addNext)", "playButtonBehavior_optionAddNext": "$t(player.addNext)",
"minimumScrobbleSeconds": "minimalne scrobble (sekund)", "minimumScrobbleSeconds": "minimalne scrobble (w sekundach)",
"remotePort_description": "ustaw port dla serwera zdalnej kontroli", "remotePort_description": "ustaw port dla serwera zdalnej kontroli",
"replayGainMode_description": "dostosuj wzmocnienie dźwięku zgodnie z wartościami {{ReplayGain}} przechowywanymi w metadanych do pliku", "replayGainMode_description": "dostosuj wzmocnienie dźwięku zgodnie z wartościami {{ReplayGain}} przechowywanymi w metadanych do pliku",
"replayGainFallback": "rezerwowy {{ReplayGain}}", "replayGainFallback": "rezerwowy {{ReplayGain}}",
"sidebarCollapsedNavigation_description": "pokaż lub ukryj nawigację na zwiniętym pasku bocznym", "sidebarCollapsedNavigation_description": "pokaż lub ukryj nawigację na zwiniętym pasku bocznym",
"skipDuration": "czas trwania pominięcia", "skipDuration": "czas trwania pominięcia",
"showSkipButtons": "pokaż przyciski pomijania", "showSkipButtons": "pokaż przyciski pomijania",
"scrobble": "scrobble", "scrobble": "scrobbling",
"skipDuration_description": "ustaw czas pominięcia kiedy zostanie użyty przycisk pominięcia na pasku odtwarzania", "skipDuration_description": "ustaw czas pominięcia kiedy zostanie użyty przycisk pominięcia na pasku odtwarzania",
"replayGainClipping_description": "Zapobiegaj wzmocnieniu spowodowanemu przez {{ReplayGain}} na automatyczne obniżanie wzmocnienia", "replayGainClipping_description": "Zapobiegaj wzmocnieniu spowodowanemu przez {{ReplayGain}} na automatyczne obniżanie wzmocnienia",
"replayGainPreamp": "przedwzmacniacz {{ReplayGain}} (db)", "replayGainPreamp": "przedwzmacniacz {{ReplayGain}} (db)",
+47 -11
View File
@@ -9,7 +9,7 @@
"bitrate": "taxa de bits", "bitrate": "taxa de bits",
"action_one": "ação", "action_one": "ação",
"action_many": "ações", "action_many": "ações",
"action_other": "(n == 0 || n == 1) ? ação : ações", "action_other": "ações",
"biography": "biografia", "biography": "biografia",
"bpm": "bpm", "bpm": "bpm",
"edit": "editar", "edit": "editar",
@@ -31,7 +31,7 @@
"comingSoon": "em breve…", "comingSoon": "em breve…",
"channel_one": "canal", "channel_one": "canal",
"channel_many": "canais", "channel_many": "canais",
"channel_other": "(n == 0 || n == 1) ? canal : canais", "channel_other": "canais",
"disable": "desabilitar", "disable": "desabilitar",
"expand": "expandir", "expand": "expandir",
"disc": "disco", "disc": "disco",
@@ -62,7 +62,7 @@
"version": "versão", "version": "versão",
"filter_one": "filtro", "filter_one": "filtro",
"filter_many": "filtros", "filter_many": "filtros",
"filter_other": "(n == 0 || n == 1) ? filtro : filtros", "filter_other": "filtros",
"filters": "filtros", "filters": "filtros",
"saveAndReplace": "salvar e substituir", "saveAndReplace": "salvar e substituir",
"playerMustBePaused": "o player deve estar pausado", "playerMustBePaused": "o player deve estar pausado",
@@ -184,27 +184,63 @@
"entity": { "entity": {
"albumArtist_one": "artista do álbum", "albumArtist_one": "artista do álbum",
"albumArtist_many": "artistas do álbum", "albumArtist_many": "artistas do álbum",
"albumArtist_other": "(n == 0 || n == 1) ? artista do álbum : artistas do álbum", "albumArtist_other": "artistas do álbum",
"albumArtistCount_one": "{{count}} artista do álbum", "albumArtistCount_one": "{{count}} artista do álbum",
"albumArtistCount_many": "{{count}} artistas do álbum", "albumArtistCount_many": "{{count}} artistas do álbum",
"albumArtistCount_other": "(n == 0 || n == 1) ? {{count}} artista do álbum : {{count}} artistas do álbum", "albumArtistCount_other": "{{count}} artistas do álbum",
"album_one": "álbum", "album_one": "álbum",
"album_many": "álbuns", "album_many": "álbuns",
"album_other": "(n == 0 || n == 1) ? álbum : álbuns", "album_other": "álbuns",
"artist_one": "artista", "artist_one": "artista",
"artist_many": "artistas", "artist_many": "artistas",
"artist_other": "(n == 0 || n == 1) ? artista : artistas", "artist_other": "artistas",
"albumWithCount_one": "{{count}} álbum", "albumWithCount_one": "{{count}} álbum",
"albumWithCount_many": "{{count}} álbuns", "albumWithCount_many": "{{count}} álbuns",
"albumWithCount_other": "(n == 0 || n == 1) ? {{count}} álbum : {{count}} álbuns", "albumWithCount_other": "{{count}} álbuns",
"favorite_one": "favorito", "favorite_one": "favorito",
"favorite_many": "favoritos", "favorite_many": "favoritos",
"favorite_other": "(n == 0 || n == 1) ? favorito : favoritos", "favorite_other": "favoritos",
"artistWithCount_one": "{{count}} artista", "artistWithCount_one": "{{count}} artista",
"artistWithCount_many": "{{count}} artistas", "artistWithCount_many": "{{count}} artistas",
"artistWithCount_other": "(n == 0 || n == 1) ? artista : artistas", "artistWithCount_other": "{{count}} artistas",
"folder_one": "pasta", "folder_one": "pasta",
"folder_many": "pastas", "folder_many": "pastas",
"folder_other": "(n == 0 || n == 1) ? pasta : pastas" "folder_other": "pastas",
"genre_one": "gênero",
"genre_many": "gêneros",
"genre_other": "gêneros",
"playlistWithCount_one": "{{count}} playlist",
"playlistWithCount_many": "{{count}} playlists",
"playlistWithCount_other": "{{count}} playlists",
"playlist_one": "playlist",
"playlist_many": "playlists",
"playlist_other": "playlists",
"folderWithCount_one": "{{count}} pasta",
"folderWithCount_many": "{{count}} pastas",
"folderWithCount_other": "{{count}} pastas",
"genreWithCount_one": "{{count}} gênero",
"genreWithCount_many": "{{count}} gêneros",
"genreWithCount_other": "{{count}} gêneros"
},
"error": {
"remotePortWarning": "reinicie o servidor para aplicar a nova porta",
"systemFontError": "ocorreu um erro ao tentar obter fontes do sistema",
"playbackError": "ocorreu um erro ao tentar reproduzir a mídia",
"endpointNotImplementedError": "endpoint {{endpoint}} não está implementado para {{serverType}}",
"remotePortError": "ocorreu um erro ao tentar definir a porta do servidor remoto",
"serverRequired": "servidor necessário",
"authenticationFailed": "falha na autenticação",
"apiRouteError": "não é possível encaminhar a solicitação",
"genericError": "um erro ocorreu",
"credentialsRequired": "credenciais necessárias",
"sessionExpiredError": "sua sessão expirou",
"remoteEnableError": "ocorreu um erro ao tentar $t(common.enable) o servidor remoto",
"localFontAccessDenied": "acesso negado a fontes locais",
"serverNotSelectedError": "nenhum servidor selecionado",
"remoteDisableError": "ocorreu um erro ao tentar $t(common.disable) o servidor remoto",
"mpvRequired": "MPV necessário",
"audioDeviceFetchError": "ocorreu um erro ao tentar obter dispositivos de áudio",
"invalidServer": "servidor inválido",
"loginRateError": "muitas tentativas de login, tente novamente em alguns segundos"
} }
} }
+70 -7
View File
@@ -95,7 +95,8 @@
"random": "slumpmässig", "random": "slumpmässig",
"size": "storlek", "size": "storlek",
"biography": "biografi", "biography": "biografi",
"note": "anteckning" "note": "anteckning",
"center": "center"
}, },
"error": { "error": {
"remotePortWarning": "starta om servern för att tillämpa den nya porten", "remotePortWarning": "starta om servern för att tillämpa den nya porten",
@@ -157,7 +158,9 @@
"toYear": "till år", "toYear": "till år",
"fromYear": "från år", "fromYear": "från år",
"album": "$t(entity.album_one)", "album": "$t(entity.album_one)",
"trackNumber": "spår" "trackNumber": "spår",
"songCount": "sångräkning",
"criticRating": "kritikerbetyg"
}, },
"form": { "form": {
"deletePlaylist": { "deletePlaylist": {
@@ -218,8 +221,13 @@
"opacity": "ogenomskinlighet", "opacity": "ogenomskinlighet",
"lyricSize": "låttext storlek", "lyricSize": "låttext storlek",
"lyricAlignment": "låttext justering", "lyricAlignment": "låttext justering",
"lyricGap": "låttext mellanrum" "lyricGap": "låttext mellanrum",
} "synchronized": "synkroniserad",
"showLyricProvider": "visa sångtextleverantör",
"unsynchronized": "osynkroniserad"
},
"lyrics": "sångtext",
"related": "relaterad"
}, },
"appMenu": { "appMenu": {
"selectServer": "välj server", "selectServer": "välj server",
@@ -230,7 +238,8 @@
"openBrowserDevtools": "öppna webbläsarens utvecklingsverktyg", "openBrowserDevtools": "öppna webbläsarens utvecklingsverktyg",
"quit": "$t(common.quit)", "quit": "$t(common.quit)",
"goBack": "gå tillbaka", "goBack": "gå tillbaka",
"goForward": "gå framåt" "goForward": "gå framåt",
"collapseSidebar": "växla sidofältet"
}, },
"contextMenu": { "contextMenu": {
"addToPlaylist": "$t(action.addToPlaylist)", "addToPlaylist": "$t(action.addToPlaylist)",
@@ -251,7 +260,7 @@
"removeFromQueue": "$t(action.removeFromQueue)" "removeFromQueue": "$t(action.removeFromQueue)"
}, },
"albumDetail": { "albumDetail": {
"moreFromArtist": "Mer från $t(entity.genre_one)", "moreFromArtist": "mer från $t(entity.genre_one)",
"moreFromGeneric": "mer från {{item}}" "moreFromGeneric": "mer från {{item}}"
}, },
"albumArtistList": { "albumArtistList": {
@@ -259,6 +268,29 @@
}, },
"albumList": { "albumList": {
"title": "$t(entity.album_other)" "title": "$t(entity.album_other)"
},
"sidebar": {
"nowPlaying": "nu spelas"
},
"home": {
"mostPlayed": "mest spelade",
"newlyAdded": "nytillkomna utgåvor",
"explore": "utforska från ditt bibliotek",
"recentlyPlayed": "nyligen spelat"
},
"setting": {
"playbackTab": "uppspelning",
"generalTab": "allmänt",
"hotkeysTab": "snabbtangenter",
"windowTab": "fönster"
},
"globalSearch": {
"commands": {
"serverCommands": "serverkommandon",
"goToPage": "gå till sidan",
"searchFor": "sök efter {{query}}"
},
"title": "kommandon"
} }
}, },
"entity": { "entity": {
@@ -277,6 +309,37 @@
"folder_one": "mapp", "folder_one": "mapp",
"folder_other": "mappar", "folder_other": "mappar",
"album_one": "album", "album_one": "album",
"album_other": "album" "album_other": "album",
"playlistWithCount_one": "{{count}} spellista",
"playlistWithCount_other": "{{count}} spellistor",
"folderWithCount_one": "{{count}} mapp",
"folderWithCount_other": "{{count}} mappar",
"track_one": "spår",
"track_other": "spår",
"trackWithCount_one": "{{count}} spår",
"trackWithCount_other": "{{count}} spår"
},
"player": {
"repeat_all": "repetera alla",
"repeat": "repetera",
"queue_remove": "ta bort markerad",
"playRandom": "spela slumpmässigt",
"previous": "föregående",
"favorite": "favorit",
"next": "nästa",
"shuffle": "blanda",
"playbackFetchNoResults": "inga låtar hittades",
"playbackFetchInProgress": "laddar låtar…",
"addNext": "lägg till nästa",
"playbackSpeed": "uppspelningshastighet",
"playbackFetchCancel": "det här tar ett tag... stäng aviseringen för att avbryta",
"play": "spela",
"repeat_off": "repetera inaktiverad",
"queue_clear": "rensa kö",
"muted": "mutad",
"queue_moveToTop": "flytta markerad till botten",
"queue_moveToBottom": "flytta markerad till toppen",
"addLast": "lägg till sist",
"mute": "muta"
} }
} }
+3 -3
View File
@@ -120,7 +120,7 @@
"queue_remove": "移除所选", "queue_remove": "移除所选",
"playRandom": "随机播放", "playRandom": "随机播放",
"skip": "跳过", "skip": "跳过",
"previous": "一首", "previous": "一首",
"toggleFullscreenPlayer": "全屏", "toggleFullscreenPlayer": "全屏",
"skip_back": "向后跳过", "skip_back": "向后跳过",
"favorite": "收藏", "favorite": "收藏",
@@ -208,7 +208,7 @@
"hotkey_skipForward": "向后跳过", "hotkey_skipForward": "向后跳过",
"sidePlayQueueStyle": "侧边播放列表样式", "sidePlayQueueStyle": "侧边播放列表样式",
"playButtonBehavior_optionAddLast": "$t(player.addLast)", "playButtonBehavior_optionAddLast": "$t(player.addLast)",
"zoom": "放率", "zoom": "放率",
"minimizeToTray_description": "将应用程序最小化到系统托盘", "minimizeToTray_description": "将应用程序最小化到系统托盘",
"hotkey_playbackPlay": "播放", "hotkey_playbackPlay": "播放",
"hotkey_togglePreviousSongFavorite": "收藏 / 取消收藏$t(common.previousSong)", "hotkey_togglePreviousSongFavorite": "收藏 / 取消收藏$t(common.previousSong)",
@@ -233,7 +233,7 @@
"hotkey_toggleFullScreenPlayer": "全屏播放", "hotkey_toggleFullScreenPlayer": "全屏播放",
"hotkey_localSearch": "页面内搜索", "hotkey_localSearch": "页面内搜索",
"hotkey_toggleQueue": "显示 / 隐藏播放队列", "hotkey_toggleQueue": "显示 / 隐藏播放队列",
"zoom_description": "设置应用的放大率", "zoom_description": "设置应用程序的缩放率",
"remotePassword_description": "设置远程控制服务器的密码。这些凭据默认以不安全的方式传输,因此您应该使用一个您不在意的唯一密码", "remotePassword_description": "设置远程控制服务器的密码。这些凭据默认以不安全的方式传输,因此您应该使用一个您不在意的唯一密码",
"hotkey_rate5": "评为 5 星", "hotkey_rate5": "评为 5 星",
"hotkey_playbackPrevious": "上一曲", "hotkey_playbackPrevious": "上一曲",
+41
View File
@@ -0,0 +1,41 @@
import dayjs from 'dayjs';
const reset = '\x1b[0m';
const baseLog = (errorType: 'error' | 'info' | 'success' | 'warn') => {
let logString = '';
switch (errorType) {
case 'error':
logString = '\x1b[31m[ERROR] ';
break;
case 'info':
logString = '\x1b[34m[INFO] ';
break;
case 'success':
logString = '\x1b[32m[SUCCESS] ';
break;
case 'warn':
logString = '\x1b[33m[WARNING] ';
break;
default:
logString = '\x1b[34m[INFO] ';
break;
}
return (text: string, options?: { context?: Record<string, any>; toast?: boolean }): void => {
// const { toast } = options || {};
const now = dayjs().toISOString();
console.log(
`${logString}${now}: ${text} | ${
options?.context && JSON.stringify(options.context)
}${reset}`,
);
};
};
export const fsLog = {
error: baseLog('error'),
info: baseLog('info'),
success: baseLog('success'),
warn: baseLog('warn'),
};
+66 -189
View File
@@ -1,100 +1,38 @@
import { useAuthStore } from '/@/renderer/store'; import { RandomSongListArgs } from './types';
import { toast } from '/@/renderer/components/toast/index'; import i18n from '/@/i18n/i18n';
import { JellyfinController } from '/@/renderer/api/jellyfin/jellyfin-controller';
import { NavidromeController } from '/@/renderer/api/navidrome/navidrome-controller';
import { SubsonicController } from '/@/renderer/api/subsonic/subsonic-controller';
import type { import type {
AlbumDetailArgs, AddToPlaylistArgs,
AlbumListArgs,
SongListArgs,
SongDetailArgs,
AlbumArtistDetailArgs, AlbumArtistDetailArgs,
AlbumArtistListArgs, AlbumArtistListArgs,
SetRatingArgs, AlbumDetailArgs,
GenreListArgs, AlbumListArgs,
ArtistListArgs,
ControllerEndpoint,
CreatePlaylistArgs, CreatePlaylistArgs,
DeletePlaylistArgs, DeletePlaylistArgs,
FavoriteArgs,
GenreListArgs,
LyricsArgs,
MusicFolderListArgs,
PlaylistDetailArgs, PlaylistDetailArgs,
PlaylistListArgs, PlaylistListArgs,
MusicFolderListArgs,
PlaylistSongListArgs, PlaylistSongListArgs,
ArtistListArgs, RemoveFromPlaylistArgs,
ScrobbleArgs,
SearchArgs,
SetRatingArgs,
SongDetailArgs,
SongListArgs,
TopSongListArgs,
UpdatePlaylistArgs, UpdatePlaylistArgs,
UserListArgs, UserListArgs,
FavoriteArgs,
TopSongListArgs,
AddToPlaylistArgs,
AddToPlaylistResponse,
RemoveFromPlaylistArgs,
RemoveFromPlaylistResponse,
ScrobbleArgs,
ScrobbleResponse,
AlbumArtistDetailResponse,
FavoriteResponse,
CreatePlaylistResponse,
AlbumArtistListResponse,
AlbumDetailResponse,
AlbumListResponse,
ArtistListResponse,
GenreListResponse,
MusicFolderListResponse,
PlaylistDetailResponse,
PlaylistListResponse,
RatingResponse,
SongDetailResponse,
SongListResponse,
TopSongListResponse,
UpdatePlaylistResponse,
UserListResponse,
AuthenticationResponse,
SearchArgs,
SearchResponse,
LyricsArgs,
LyricsResponse,
} from '/@/renderer/api/types'; } from '/@/renderer/api/types';
import { toast } from '/@/renderer/components/toast/index';
import { useAuthStore } from '/@/renderer/store';
import { ServerType } from '/@/renderer/types'; import { ServerType } from '/@/renderer/types';
import { DeletePlaylistResponse, RandomSongListArgs } from './types';
import { ndController } from '/@/renderer/api/navidrome/navidrome-controller';
import { ssController } from '/@/renderer/api/subsonic/subsonic-controller';
import { jfController } from '/@/renderer/api/jellyfin/jellyfin-controller';
import i18n from '/@/i18n/i18n';
export type ControllerEndpoint = Partial<{
addToPlaylist: (args: AddToPlaylistArgs) => Promise<AddToPlaylistResponse>;
authenticate: (
url: string,
body: { password: string; username: string },
) => Promise<AuthenticationResponse>;
clearPlaylist: () => void;
createFavorite: (args: FavoriteArgs) => Promise<FavoriteResponse>;
createPlaylist: (args: CreatePlaylistArgs) => Promise<CreatePlaylistResponse>;
deleteFavorite: (args: FavoriteArgs) => Promise<FavoriteResponse>;
deletePlaylist: (args: DeletePlaylistArgs) => Promise<DeletePlaylistResponse>;
getAlbumArtistDetail: (args: AlbumArtistDetailArgs) => Promise<AlbumArtistDetailResponse>;
getAlbumArtistList: (args: AlbumArtistListArgs) => Promise<AlbumArtistListResponse>;
getAlbumDetail: (args: AlbumDetailArgs) => Promise<AlbumDetailResponse>;
getAlbumList: (args: AlbumListArgs) => Promise<AlbumListResponse>;
getArtistDetail: () => void;
getArtistInfo: (args: any) => void;
getArtistList: (args: ArtistListArgs) => Promise<ArtistListResponse>;
getFavoritesList: () => void;
getFolderItemList: () => void;
getFolderList: () => void;
getFolderSongs: () => void;
getGenreList: (args: GenreListArgs) => Promise<GenreListResponse>;
getLyrics: (args: LyricsArgs) => Promise<LyricsResponse>;
getMusicFolderList: (args: MusicFolderListArgs) => Promise<MusicFolderListResponse>;
getPlaylistDetail: (args: PlaylistDetailArgs) => Promise<PlaylistDetailResponse>;
getPlaylistList: (args: PlaylistListArgs) => Promise<PlaylistListResponse>;
getPlaylistSongList: (args: PlaylistSongListArgs) => Promise<SongListResponse>;
getRandomSongList: (args: RandomSongListArgs) => Promise<SongListResponse>;
getSongDetail: (args: SongDetailArgs) => Promise<SongDetailResponse>;
getSongList: (args: SongListArgs) => Promise<SongListResponse>;
getTopSongs: (args: TopSongListArgs) => Promise<TopSongListResponse>;
getUserList: (args: UserListArgs) => Promise<UserListResponse>;
removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise<RemoveFromPlaylistResponse>;
scrobble: (args: ScrobbleArgs) => Promise<ScrobbleResponse>;
search: (args: SearchArgs) => Promise<SearchResponse>;
setRating: (args: SetRatingArgs) => Promise<RatingResponse>;
updatePlaylist: (args: UpdatePlaylistArgs) => Promise<UpdatePlaylistResponse>;
}>;
type ApiController = { type ApiController = {
jellyfin: ControllerEndpoint; jellyfin: ControllerEndpoint;
@@ -103,110 +41,9 @@ type ApiController = {
}; };
const endpoints: ApiController = { const endpoints: ApiController = {
jellyfin: { jellyfin: JellyfinController,
addToPlaylist: jfController.addToPlaylist, navidrome: NavidromeController,
authenticate: jfController.authenticate, subsonic: SubsonicController,
clearPlaylist: undefined,
createFavorite: jfController.createFavorite,
createPlaylist: jfController.createPlaylist,
deleteFavorite: jfController.deleteFavorite,
deletePlaylist: jfController.deletePlaylist,
getAlbumArtistDetail: jfController.getAlbumArtistDetail,
getAlbumArtistList: jfController.getAlbumArtistList,
getAlbumDetail: jfController.getAlbumDetail,
getAlbumList: jfController.getAlbumList,
getArtistDetail: undefined,
getArtistInfo: undefined,
getArtistList: undefined,
getFavoritesList: undefined,
getFolderItemList: undefined,
getFolderList: undefined,
getFolderSongs: undefined,
getGenreList: jfController.getGenreList,
getLyrics: jfController.getLyrics,
getMusicFolderList: jfController.getMusicFolderList,
getPlaylistDetail: jfController.getPlaylistDetail,
getPlaylistList: jfController.getPlaylistList,
getPlaylistSongList: jfController.getPlaylistSongList,
getRandomSongList: jfController.getRandomSongList,
getSongDetail: jfController.getSongDetail,
getSongList: jfController.getSongList,
getTopSongs: jfController.getTopSongList,
getUserList: undefined,
removeFromPlaylist: jfController.removeFromPlaylist,
scrobble: jfController.scrobble,
search: jfController.search,
setRating: undefined,
updatePlaylist: jfController.updatePlaylist,
},
navidrome: {
addToPlaylist: ndController.addToPlaylist,
authenticate: ndController.authenticate,
clearPlaylist: undefined,
createFavorite: ssController.createFavorite,
createPlaylist: ndController.createPlaylist,
deleteFavorite: ssController.removeFavorite,
deletePlaylist: ndController.deletePlaylist,
getAlbumArtistDetail: ndController.getAlbumArtistDetail,
getAlbumArtistList: ndController.getAlbumArtistList,
getAlbumDetail: ndController.getAlbumDetail,
getAlbumList: ndController.getAlbumList,
getArtistDetail: undefined,
getArtistInfo: undefined,
getArtistList: undefined,
getFavoritesList: undefined,
getFolderItemList: undefined,
getFolderList: undefined,
getFolderSongs: undefined,
getGenreList: ndController.getGenreList,
getLyrics: undefined,
getMusicFolderList: ssController.getMusicFolderList,
getPlaylistDetail: ndController.getPlaylistDetail,
getPlaylistList: ndController.getPlaylistList,
getPlaylistSongList: ndController.getPlaylistSongList,
getRandomSongList: ssController.getRandomSongList,
getSongDetail: ndController.getSongDetail,
getSongList: ndController.getSongList,
getTopSongs: ssController.getTopSongList,
getUserList: ndController.getUserList,
removeFromPlaylist: ndController.removeFromPlaylist,
scrobble: ssController.scrobble,
search: ssController.search3,
setRating: ssController.setRating,
updatePlaylist: ndController.updatePlaylist,
},
subsonic: {
authenticate: ssController.authenticate,
clearPlaylist: undefined,
createFavorite: ssController.createFavorite,
createPlaylist: undefined,
deleteFavorite: ssController.removeFavorite,
deletePlaylist: undefined,
getAlbumArtistDetail: undefined,
getAlbumArtistList: undefined,
getAlbumDetail: undefined,
getAlbumList: undefined,
getArtistDetail: undefined,
getArtistInfo: undefined,
getArtistList: undefined,
getFavoritesList: undefined,
getFolderItemList: undefined,
getFolderList: undefined,
getFolderSongs: undefined,
getGenreList: undefined,
getLyrics: undefined,
getMusicFolderList: ssController.getMusicFolderList,
getPlaylistDetail: undefined,
getPlaylistList: undefined,
getSongDetail: undefined,
getSongList: undefined,
getTopSongs: ssController.getTopSongList,
getUserList: undefined,
scrobble: ssController.scrobble,
search: ssController.search3,
setRating: undefined,
updatePlaylist: undefined,
},
}; };
const apiController = (endpoint: keyof ControllerEndpoint, type?: ServerType) => { const apiController = (endpoint: keyof ControllerEndpoint, type?: ServerType) => {
@@ -259,6 +96,15 @@ const getAlbumList = async (args: AlbumListArgs) => {
)?.(args); )?.(args);
}; };
const getAlbumListCount = async (args: AlbumListArgs) => {
return (
apiController(
'getAlbumListCount',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getAlbumListCount']
)?.(args);
};
const getAlbumDetail = async (args: AlbumDetailArgs) => { const getAlbumDetail = async (args: AlbumDetailArgs) => {
return ( return (
apiController( apiController(
@@ -277,6 +123,15 @@ const getSongList = async (args: SongListArgs) => {
)?.(args); )?.(args);
}; };
const getSongListCount = async (args: SongListArgs) => {
return (
apiController(
'getSongListCount',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getSongListCount']
)?.(args);
};
const getSongDetail = async (args: SongDetailArgs) => { const getSongDetail = async (args: SongDetailArgs) => {
return ( return (
apiController( apiController(
@@ -322,6 +177,15 @@ const getAlbumArtistList = async (args: AlbumArtistListArgs) => {
)?.(args); )?.(args);
}; };
const getAlbumArtistListCount = async (args: AlbumArtistListArgs) => {
return (
apiController(
'getAlbumArtistListCount',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getAlbumArtistListCount']
)?.(args);
};
const getArtistList = async (args: ArtistListArgs) => { const getArtistList = async (args: ArtistListArgs) => {
return ( return (
apiController( apiController(
@@ -340,6 +204,15 @@ const getPlaylistList = async (args: PlaylistListArgs) => {
)?.(args); )?.(args);
}; };
const getPlaylistListCount = async (args: PlaylistListArgs) => {
return (
apiController(
'getPlaylistListCount',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getPlaylistListCount']
)?.(args);
};
const createPlaylist = async (args: CreatePlaylistArgs) => { const createPlaylist = async (args: CreatePlaylistArgs) => {
return ( return (
apiController( apiController(
@@ -490,18 +363,22 @@ export const controller = {
deletePlaylist, deletePlaylist,
getAlbumArtistDetail, getAlbumArtistDetail,
getAlbumArtistList, getAlbumArtistList,
getAlbumArtistListCount,
getAlbumDetail, getAlbumDetail,
getAlbumList, getAlbumList,
getAlbumListCount,
getArtistList, getArtistList,
getGenreList, getGenreList,
getLyrics, getLyrics,
getMusicFolderList, getMusicFolderList,
getPlaylistDetail, getPlaylistDetail,
getPlaylistList, getPlaylistList,
getPlaylistListCount,
getPlaylistSongList, getPlaylistSongList,
getRandomSongList, getRandomSongList,
getSongDetail, getSongDetail,
getSongList, getSongList,
getSongListCount,
getTopSongList, getTopSongList,
getUserList, getUserList,
removeFromPlaylist, removeFromPlaylist,
+227 -62
View File
@@ -1,62 +1,64 @@
import isElectron from 'is-electron';
import { z } from 'zod';
import packageJson from '../../../../package.json';
import { jfNormalize } from './jellyfin-normalize';
import { JFSongListSort, JFSortOrder } from '/@/renderer/api/jellyfin.types';
import { jfApiClient } from '/@/renderer/api/jellyfin/jellyfin-api';
import { jfType } from '/@/renderer/api/jellyfin/jellyfin-types';
import { import {
AuthenticationResponse,
MusicFolderListArgs,
MusicFolderListResponse,
GenreListArgs,
AlbumArtistDetailArgs,
AlbumArtistListArgs,
albumArtistListSortMap,
sortOrderMap,
ArtistListArgs,
artistListSortMap,
AlbumDetailArgs,
AlbumListArgs,
albumListSortMap,
TopSongListArgs,
SongListArgs,
songListSortMap,
AddToPlaylistArgs, AddToPlaylistArgs,
RemoveFromPlaylistArgs, AddToPlaylistResponse,
PlaylistDetailArgs, AlbumArtistDetailArgs,
PlaylistSongListArgs, AlbumArtistDetailResponse,
PlaylistListArgs, AlbumArtistListArgs,
playlistListSortMap, AlbumArtistListResponse,
AlbumDetailArgs,
AlbumDetailResponse,
AlbumListArgs,
AlbumListResponse,
AuthenticationResponse,
ControllerEndpoint,
CreatePlaylistArgs, CreatePlaylistArgs,
CreatePlaylistResponse, CreatePlaylistResponse,
UpdatePlaylistArgs,
UpdatePlaylistResponse,
DeletePlaylistArgs, DeletePlaylistArgs,
FavoriteArgs, FavoriteArgs,
FavoriteResponse, FavoriteResponse,
ScrobbleArgs, GenreListArgs,
ScrobbleResponse,
GenreListResponse, GenreListResponse,
AlbumArtistDetailResponse,
AlbumArtistListResponse,
AlbumDetailResponse,
AlbumListResponse,
SongListResponse,
AddToPlaylistResponse,
RemoveFromPlaylistResponse,
PlaylistDetailResponse,
PlaylistListResponse,
SearchArgs,
SearchResponse,
RandomSongListResponse,
RandomSongListArgs,
LyricsArgs, LyricsArgs,
LyricsResponse, LyricsResponse,
genreListSortMap, MusicFolderListArgs,
MusicFolderListResponse,
PlaylistDetailArgs,
PlaylistDetailResponse,
PlaylistListArgs,
PlaylistListResponse,
PlaylistSongListArgs,
RandomSongListArgs,
RandomSongListResponse,
RemoveFromPlaylistArgs,
RemoveFromPlaylistResponse,
ScrobbleArgs,
ScrobbleResponse,
SearchArgs,
SearchResponse,
SongDetailArgs, SongDetailArgs,
SongDetailResponse, SongDetailResponse,
SongListArgs,
SongListResponse,
SongListSort,
SortOrder,
TopSongListArgs,
UpdatePlaylistArgs,
UpdatePlaylistResponse,
albumArtistListSortMap,
albumListSortMap,
genreListSortMap,
playlistListSortMap,
songListSortMap,
sortOrderMap,
} from '/@/renderer/api/types'; } from '/@/renderer/api/types';
import { jfApiClient } from '/@/renderer/api/jellyfin/jellyfin-api'; import { sortSongList } from '/@/renderer/api/utils';
import { jfNormalize } from './jellyfin-normalize';
import { jfType } from '/@/renderer/api/jellyfin/jellyfin-types';
import packageJson from '../../../../package.json';
import { z } from 'zod';
import { JFSongListSort, JFSortOrder } from '/@/renderer/api/jellyfin.types';
import isElectron from 'is-electron';
const formatCommaDelimitedString = (value: string[]) => { const formatCommaDelimitedString = (value: string[]) => {
return value.join(','); return value.join(',');
@@ -244,31 +246,56 @@ const getAlbumArtistList = async (args: AlbumArtistListArgs): Promise<AlbumArtis
}; };
}; };
const getArtistList = async (args: ArtistListArgs): Promise<AlbumArtistListResponse> => { const getAlbumArtistListCount = async (args: AlbumArtistListArgs): Promise<number> => {
const { query, apiClientProps } = args; const { query, apiClientProps } = args;
const res = await jfApiClient(apiClientProps).getAlbumArtistList({ const res = await jfApiClient(apiClientProps).getAlbumArtistList({
query: { query: {
Limit: query.limit, Fields: 'Genres, DateCreated, ExternalUrls, Overview',
ImageTypeLimit: 1,
Limit: 1,
ParentId: query.musicFolderId, ParentId: query.musicFolderId,
Recursive: true, Recursive: true,
SortBy: artistListSortMap.jellyfin[query.sortBy] || 'Name,SortName', SearchTerm: query.searchTerm,
SortBy: albumArtistListSortMap.jellyfin[query.sortBy] || 'Name,SortName',
SortOrder: sortOrderMap.jellyfin[query.sortOrder], SortOrder: sortOrderMap.jellyfin[query.sortOrder],
StartIndex: query.startIndex, StartIndex: 0,
UserId: apiClientProps.server?.userId || undefined,
}, },
}); });
if (res.status !== 200) { if (res.status !== 200) {
throw new Error('Failed to get artist list'); throw new Error('Failed to get album artist list count');
} }
return { return res.body.TotalRecordCount;
items: res.body.Items.map((item) => jfNormalize.albumArtist(item, apiClientProps.server)),
startIndex: query.startIndex,
totalRecordCount: res.body.TotalRecordCount,
};
}; };
// const getArtistList = async (args: ArtistListArgs): Promise<ArtistListResponse> => {
// const { query, apiClientProps } = args;
// const res = await jfApiClient(apiClientProps).getAlbumArtistList({
// query: {
// Limit: query.limit,
// ParentId: query.musicFolderId,
// Recursive: true,
// SortBy: artistListSortMap.jellyfin[query.sortBy] || 'Name,SortName',
// SortOrder: sortOrderMap.jellyfin[query.sortOrder],
// StartIndex: query.startIndex,
// },
// });
// if (res.status !== 200) {
// throw new Error('Failed to get artist list');
// }
// return {
// items: res.body.Items.map((item) => jfNormalize.albumArtist(item, apiClientProps.server)),
// startIndex: query.startIndex,
// totalRecordCount: res.body.TotalRecordCount,
// };
// };
const getAlbumDetail = async (args: AlbumDetailArgs): Promise<AlbumDetailResponse> => { const getAlbumDetail = async (args: AlbumDetailArgs): Promise<AlbumDetailResponse> => {
const { query, apiClientProps } = args; const { query, apiClientProps } = args;
@@ -333,6 +360,7 @@ const getAlbumList = async (args: AlbumListArgs): Promise<AlbumListResponse> =>
AlbumArtistIds: query.artistIds AlbumArtistIds: query.artistIds
? formatCommaDelimitedString(query.artistIds) ? formatCommaDelimitedString(query.artistIds)
: undefined, : undefined,
ContributingArtistIds: query.isCompilation ? query.artistIds?.[0] : undefined,
IncludeItemTypes: 'MusicAlbum', IncludeItemTypes: 'MusicAlbum',
Limit: query.limit, Limit: query.limit,
ParentId: query.musicFolderId, ParentId: query.musicFolderId,
@@ -357,6 +385,55 @@ const getAlbumList = async (args: AlbumListArgs): Promise<AlbumListResponse> =>
}; };
}; };
const getAlbumListCount = async (args: AlbumListArgs): Promise<number> => {
const { query, apiClientProps } = args;
if (!apiClientProps.server?.userId) {
throw new Error('No userId found');
}
const yearsGroup = [];
if (query._custom?.jellyfin?.minYear && query._custom?.jellyfin?.maxYear) {
for (
let i = Number(query._custom?.jellyfin?.minYear);
i <= Number(query._custom?.jellyfin?.maxYear);
i += 1
) {
yearsGroup.push(String(i));
}
}
const yearsFilter = yearsGroup.length ? yearsGroup.join(',') : undefined;
const res = await jfApiClient(apiClientProps).getAlbumList({
params: {
userId: apiClientProps.server?.userId,
},
query: {
AlbumArtistIds: query.artistIds
? formatCommaDelimitedString(query.artistIds)
: undefined,
ContributingArtistIds: query.isCompilation ? query.artistIds?.[0] : undefined,
IncludeItemTypes: 'MusicAlbum',
Limit: 1,
ParentId: query.musicFolderId,
Recursive: true,
SearchTerm: query.searchTerm,
SortBy: albumListSortMap.jellyfin[query.sortBy] || 'SortName',
SortOrder: sortOrderMap.jellyfin[query.sortOrder],
StartIndex: 0,
...query._custom?.jellyfin,
Years: yearsFilter,
},
});
if (res.status !== 200) {
throw new Error('Failed to get album list count');
}
return res.body.TotalRecordCount;
};
const getTopSongList = async (args: TopSongListArgs): Promise<SongListResponse> => { const getTopSongList = async (args: TopSongListArgs): Promise<SongListResponse> => {
const { apiClientProps, query } = args; const { apiClientProps, query } = args;
@@ -384,8 +461,11 @@ const getTopSongList = async (args: TopSongListArgs): Promise<SongListResponse>
throw new Error('Failed to get top song list'); throw new Error('Failed to get top song list');
} }
const songs = res.body.Items.map((item) => jfNormalize.song(item, apiClientProps.server, ''));
const songsByPlayCount = sortSongList(songs, SongListSort.PLAY_COUNT, SortOrder.DESC);
return { return {
items: res.body.Items.map((item) => jfNormalize.song(item, apiClientProps.server, '')), items: songsByPlayCount,
startIndex: 0, startIndex: 0,
totalRecordCount: res.body.TotalRecordCount, totalRecordCount: res.body.TotalRecordCount,
}; };
@@ -449,6 +529,58 @@ const getSongList = async (args: SongListArgs): Promise<SongListResponse> => {
}; };
}; };
const getSongListCount = async (args: SongListArgs): Promise<number> => {
const { query, apiClientProps } = args;
if (!apiClientProps.server?.userId) {
throw new Error('No userId found');
}
const yearsGroup = [];
if (query._custom?.jellyfin?.minYear && query._custom?.jellyfin?.maxYear) {
for (
let i = Number(query._custom?.jellyfin?.minYear);
i <= Number(query._custom?.jellyfin?.maxYear);
i += 1
) {
yearsGroup.push(String(i));
}
}
const yearsFilter = yearsGroup.length ? formatCommaDelimitedString(yearsGroup) : undefined;
const albumIdsFilter = query.albumIds ? formatCommaDelimitedString(query.albumIds) : undefined;
const artistIdsFilter = query.artistIds
? formatCommaDelimitedString(query.artistIds)
: undefined;
const res = await jfApiClient(apiClientProps).getSongList({
params: {
userId: apiClientProps.server?.userId,
},
query: {
AlbumIds: albumIdsFilter,
ArtistIds: artistIdsFilter,
Fields: 'Genres, DateCreated, MediaSources, ParentId',
IncludeItemTypes: 'Audio',
Limit: 1,
ParentId: query.musicFolderId,
Recursive: true,
SearchTerm: query.searchTerm,
SortBy: songListSortMap.jellyfin[query.sortBy] || 'Album,SortName',
SortOrder: sortOrderMap.jellyfin[query.sortOrder],
StartIndex: 0,
...query._custom?.jellyfin,
Years: yearsFilter,
},
});
if (res.status !== 200) {
throw new Error('Failed to get song list count');
}
return res.body.TotalRecordCount;
};
const addToPlaylist = async (args: AddToPlaylistArgs): Promise<AddToPlaylistResponse> => { const addToPlaylist = async (args: AddToPlaylistArgs): Promise<AddToPlaylistResponse> => {
const { query, body, apiClientProps } = args; const { query, body, apiClientProps } = args;
@@ -535,7 +667,6 @@ const getPlaylistSongList = async (args: PlaylistSongListArgs): Promise<SongList
query: { query: {
Fields: 'Genres, DateCreated, MediaSources, UserData, ParentId', Fields: 'Genres, DateCreated, MediaSources, UserData, ParentId',
IncludeItemTypes: 'Audio', IncludeItemTypes: 'Audio',
Limit: query.limit,
SortBy: query.sortBy ? songListSortMap.jellyfin[query.sortBy] : undefined, SortBy: query.sortBy ? songListSortMap.jellyfin[query.sortBy] : undefined,
SortOrder: query.sortOrder ? sortOrderMap.jellyfin[query.sortOrder] : undefined, SortOrder: query.sortOrder ? sortOrderMap.jellyfin[query.sortOrder] : undefined,
StartIndex: 0, StartIndex: 0,
@@ -549,7 +680,7 @@ const getPlaylistSongList = async (args: PlaylistSongListArgs): Promise<SongList
return { return {
items: res.body.Items.map((item) => jfNormalize.song(item, apiClientProps.server, '')), items: res.body.Items.map((item) => jfNormalize.song(item, apiClientProps.server, '')),
startIndex: query.startIndex, startIndex: 0,
totalRecordCount: res.body.TotalRecordCount, totalRecordCount: res.body.TotalRecordCount,
}; };
}; };
@@ -589,6 +720,37 @@ const getPlaylistList = async (args: PlaylistListArgs): Promise<PlaylistListResp
}; };
}; };
const getPlaylistListCount = async (args: PlaylistListArgs): Promise<number> => {
const { query, apiClientProps } = args;
if (!apiClientProps.server?.userId) {
throw new Error('No userId found');
}
const res = await jfApiClient(apiClientProps).getPlaylistList({
params: {
userId: apiClientProps.server?.userId,
},
query: {
Fields: 'ChildCount, Genres, DateCreated, ParentId, Overview',
IncludeItemTypes: 'Playlist',
Limit: 1,
MediaTypes: 'Audio',
Recursive: true,
SearchTerm: query.searchTerm,
SortBy: playlistListSortMap.jellyfin[query.sortBy],
SortOrder: sortOrderMap.jellyfin[query.sortOrder],
StartIndex: 0,
},
});
if (res.status !== 200) {
throw new Error('Failed to get playlist list count');
}
return res.body.TotalRecordCount;
};
const createPlaylist = async (args: CreatePlaylistArgs): Promise<CreatePlaylistResponse> => { const createPlaylist = async (args: CreatePlaylistArgs): Promise<CreatePlaylistResponse> => {
const { body, apiClientProps } = args; const { body, apiClientProps } = args;
@@ -946,7 +1108,7 @@ const getSongDetail = async (args: SongDetailArgs): Promise<SongDetailResponse>
return jfNormalize.song(res.body, apiClientProps.server, ''); return jfNormalize.song(res.body, apiClientProps.server, '');
}; };
export const jfController = { export const JellyfinController: ControllerEndpoint = {
addToPlaylist, addToPlaylist,
authenticate, authenticate,
createFavorite, createFavorite,
@@ -955,19 +1117,22 @@ export const jfController = {
deletePlaylist, deletePlaylist,
getAlbumArtistDetail, getAlbumArtistDetail,
getAlbumArtistList, getAlbumArtistList,
getAlbumArtistListCount,
getAlbumDetail, getAlbumDetail,
getAlbumList, getAlbumList,
getArtistList, getAlbumListCount,
getGenreList, getGenreList,
getLyrics, getLyrics,
getMusicFolderList, getMusicFolderList,
getPlaylistDetail, getPlaylistDetail,
getPlaylistList, getPlaylistList,
getPlaylistListCount,
getPlaylistSongList, getPlaylistSongList,
getRandomSongList, getRandomSongList,
getSongDetail, getSongDetail,
getSongList, getSongList,
getTopSongList, getSongListCount,
getTopSongs: getTopSongList,
removeFromPlaylist, removeFromPlaylist,
scrobble, scrobble,
search, search,
@@ -39,11 +39,13 @@ import {
RemoveFromPlaylistResponse, RemoveFromPlaylistResponse,
RemoveFromPlaylistArgs, RemoveFromPlaylistArgs,
genreListSortMap, genreListSortMap,
ControllerEndpoint,
} from '../types'; } from '../types';
import { ndApiClient } from '/@/renderer/api/navidrome/navidrome-api'; import { ndApiClient } from '/@/renderer/api/navidrome/navidrome-api';
import { ndNormalize } from '/@/renderer/api/navidrome/navidrome-normalize'; import { ndNormalize } from '/@/renderer/api/navidrome/navidrome-normalize';
import { ndType } from '/@/renderer/api/navidrome/navidrome-types'; import { ndType } from '/@/renderer/api/navidrome/navidrome-types';
import { ssApiClient } from '/@/renderer/api/subsonic/subsonic-api'; import { subsonicApiClient } from '/@/renderer/api/subsonic/subsonic-api';
import { SubsonicController } from '/@/renderer/api/subsonic/subsonic-controller';
const authenticate = async ( const authenticate = async (
url: string, url: string,
@@ -129,7 +131,7 @@ const getAlbumArtistDetail = async (
}, },
}); });
const artistInfoRes = await ssApiClient(apiClientProps).getArtistInfo({ const artistInfoRes = await subsonicApiClient(apiClientProps).getArtistInfo({
query: { query: {
count: 10, count: 10,
id: query.id, id: query.id,
@@ -148,15 +150,16 @@ const getAlbumArtistDetail = async (
{ {
...res.body.data, ...res.body.data,
...(artistInfoRes.status === 200 && { ...(artistInfoRes.status === 200 && {
similarArtists: artistInfoRes.body.artistInfo.similarArtist, similarArtists: artistInfoRes.body['subsonic-response'].artistInfo.similarArtist,
...(!res.body.data.largeImageUrl && { ...(!res.body.data.largeImageUrl && {
largeImageUrl: artistInfoRes.body.artistInfo.largeImageUrl, largeImageUrl: artistInfoRes.body['subsonic-response'].artistInfo.largeImageUrl,
}), }),
...(!res.body.data.mediumImageUrl && { ...(!res.body.data.mediumImageUrl && {
largeImageUrl: artistInfoRes.body.artistInfo.mediumImageUrl, largeImageUrl:
artistInfoRes.body['subsonic-response'].artistInfo.mediumImageUrl,
}), }),
...(!res.body.data.smallImageUrl && { ...(!res.body.data.smallImageUrl && {
largeImageUrl: artistInfoRes.body.artistInfo.smallImageUrl, largeImageUrl: artistInfoRes.body['subsonic-response'].artistInfo.smallImageUrl,
}), }),
}), }),
}, },
@@ -191,6 +194,27 @@ const getAlbumArtistList = async (args: AlbumArtistListArgs): Promise<AlbumArtis
}; };
}; };
const getAlbumArtistListCount = async (args: AlbumArtistListArgs): Promise<number> => {
const { query, apiClientProps } = args;
const res = await ndApiClient(apiClientProps).getAlbumArtistList({
query: {
_end: 1,
_order: sortOrderMap.navidrome[query.sortOrder],
_sort: albumArtistListSortMap.navidrome[query.sortBy],
_start: 0,
name: query.searchTerm,
...query._custom?.navidrome,
},
});
if (res.status !== 200) {
throw new Error('Failed to get album artist list count');
}
return Number(res.body.headers.get('x-total-count') || 0);
};
const getAlbumDetail = async (args: AlbumDetailArgs): Promise<AlbumDetailResponse> => { const getAlbumDetail = async (args: AlbumDetailArgs): Promise<AlbumDetailResponse> => {
const { query, apiClientProps } = args; const { query, apiClientProps } = args;
@@ -230,6 +254,8 @@ const getAlbumList = async (args: AlbumListArgs): Promise<AlbumListResponse> =>
_sort: albumListSortMap.navidrome[query.sortBy], _sort: albumListSortMap.navidrome[query.sortBy],
_start: query.startIndex, _start: query.startIndex,
artist_id: query.artistIds?.[0], artist_id: query.artistIds?.[0],
compilation: query.isCompilation,
genre_id: query.genre,
name: query.searchTerm, name: query.searchTerm,
...query._custom?.navidrome, ...query._custom?.navidrome,
}, },
@@ -246,6 +272,30 @@ const getAlbumList = async (args: AlbumListArgs): Promise<AlbumListResponse> =>
}; };
}; };
const getAlbumListCount = async (args: AlbumListArgs): Promise<number> => {
const { query, apiClientProps } = args;
const res = await ndApiClient(apiClientProps).getAlbumList({
query: {
_end: 1,
_order: sortOrderMap.navidrome[query.sortOrder],
_sort: albumListSortMap.navidrome[query.sortBy],
_start: 0,
artist_id: query.artistIds?.[0],
compilation: query.isCompilation,
genre_id: query.genre,
name: query.searchTerm,
...query._custom?.navidrome,
},
});
if (res.status !== 200) {
throw new Error('Failed to get album list');
}
return Number(res.body.headers.get('x-total-count') || 0);
};
const getSongList = async (args: SongListArgs): Promise<SongListResponse> => { const getSongList = async (args: SongListArgs): Promise<SongListResponse> => {
const { query, apiClientProps } = args; const { query, apiClientProps } = args;
@@ -275,6 +325,29 @@ const getSongList = async (args: SongListArgs): Promise<SongListResponse> => {
}; };
}; };
const getSongListCount = async (args: SongListArgs): Promise<number> => {
const { query, apiClientProps } = args;
const res = await ndApiClient(apiClientProps).getSongList({
query: {
_end: 1,
_order: sortOrderMap.navidrome[query.sortOrder],
_sort: songListSortMap.navidrome[query.sortBy],
_start: 0,
album_artist_id: query.artistIds,
album_id: query.albumIds,
title: query.searchTerm,
...query._custom?.navidrome,
},
});
if (res.status !== 200) {
throw new Error('Failed to get song list count');
}
return Number(res.body.headers.get('x-total-count') || 0);
};
const getSongDetail = async (args: SongDetailArgs): Promise<SongDetailResponse> => { const getSongDetail = async (args: SongDetailArgs): Promise<SongDetailResponse> => {
const { query, apiClientProps } = args; const { query, apiClientProps } = args;
@@ -298,7 +371,7 @@ const createPlaylist = async (args: CreatePlaylistArgs): Promise<CreatePlaylistR
body: { body: {
comment: body.comment, comment: body.comment,
name: body.name, name: body.name,
public: body._custom?.navidrome?.public, public: body.public,
rules: body._custom?.navidrome?.rules, rules: body._custom?.navidrome?.rules,
sync: body._custom?.navidrome?.sync, sync: body._custom?.navidrome?.sync,
}, },
@@ -322,7 +395,7 @@ const updatePlaylist = async (args: UpdatePlaylistArgs): Promise<UpdatePlaylistR
name: body.name, name: body.name,
public: body._custom?.navidrome?.public || false, public: body._custom?.navidrome?.public || false,
rules: body._custom?.navidrome?.rules ? body._custom.navidrome.rules : undefined, rules: body._custom?.navidrome?.rules ? body._custom.navidrome.rules : undefined,
sync: body._custom?.navidrome?.sync || undefined, sync: body._custom?.navidrome?.sync,
}, },
params: { params: {
id: query.id, id: query.id,
@@ -360,7 +433,9 @@ const getPlaylistList = async (args: PlaylistListArgs): Promise<PlaylistListResp
query: { query: {
_end: query.startIndex + (query.limit || 0), _end: query.startIndex + (query.limit || 0),
_order: sortOrderMap.navidrome[query.sortOrder], _order: sortOrderMap.navidrome[query.sortOrder],
_sort: query.sortBy ? playlistListSortMap.navidrome[query.sortBy] : undefined, _sort: query.sortBy
? playlistListSortMap.navidrome[query.sortBy]
: playlistListSortMap.navidrome.name,
_start: query.startIndex, _start: query.startIndex,
q: query.searchTerm, q: query.searchTerm,
...query._custom?.navidrome, ...query._custom?.navidrome,
@@ -378,6 +453,29 @@ const getPlaylistList = async (args: PlaylistListArgs): Promise<PlaylistListResp
}; };
}; };
const getPlaylistListCount = async (args: PlaylistListArgs): Promise<number> => {
const { query, apiClientProps } = args;
const res = await ndApiClient(apiClientProps).getPlaylistList({
query: {
_end: 1,
_order: sortOrderMap.navidrome[query.sortOrder],
_sort: query.sortBy
? playlistListSortMap.navidrome[query.sortBy]
: playlistListSortMap.navidrome.name,
_start: 0,
q: query.searchTerm,
...query._custom?.navidrome,
},
});
if (res.status !== 200) {
throw new Error('Failed to get playlist list count');
}
return Number(res.body.headers.get('x-total-count') || 0);
};
const getPlaylistDetail = async (args: PlaylistDetailArgs): Promise<PlaylistDetailResponse> => { const getPlaylistDetail = async (args: PlaylistDetailArgs): Promise<PlaylistDetailResponse> => {
const { query, apiClientProps } = args; const { query, apiClientProps } = args;
@@ -404,12 +502,11 @@ const getPlaylistSongList = async (
id: query.id, id: query.id,
}, },
query: { query: {
_end: query.startIndex + (query.limit || 0),
_order: query.sortOrder ? sortOrderMap.navidrome[query.sortOrder] : 'ASC', _order: query.sortOrder ? sortOrderMap.navidrome[query.sortOrder] : 'ASC',
_sort: query.sortBy _sort: query.sortBy
? songListSortMap.navidrome[query.sortBy] ? songListSortMap.navidrome[query.sortBy]
: ndType._enum.songList.ID, : ndType._enum.songList.ID,
_start: query.startIndex, _start: 0,
}, },
}); });
@@ -419,7 +516,7 @@ const getPlaylistSongList = async (
return { return {
items: res.body.data.map((item) => ndNormalize.song(item, apiClientProps.server, '')), items: res.body.data.map((item) => ndNormalize.song(item, apiClientProps.server, '')),
startIndex: query?.startIndex || 0, startIndex: 0,
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0), totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
}; };
}; };
@@ -465,22 +562,41 @@ const removeFromPlaylist = async (
return null; return null;
}; };
export const ndController = { export const NavidromeController: ControllerEndpoint = {
addToPlaylist, addToPlaylist,
authenticate, authenticate,
clearPlaylist: undefined,
createFavorite: SubsonicController.createFavorite,
createPlaylist, createPlaylist,
deleteFavorite: SubsonicController.deleteFavorite,
deletePlaylist, deletePlaylist,
getAlbumArtistDetail, getAlbumArtistDetail,
getAlbumArtistList, getAlbumArtistList,
getAlbumArtistListCount,
getAlbumDetail, getAlbumDetail,
getAlbumList, getAlbumList,
getAlbumListCount,
getArtistDetail: undefined,
getArtistInfo: undefined,
getFavoritesList: undefined,
getFolderItemList: undefined,
getFolderList: undefined,
getFolderSongs: undefined,
getGenreList, getGenreList,
getMusicFolderList: SubsonicController.getMusicFolderList,
getPlaylistDetail, getPlaylistDetail,
getPlaylistList, getPlaylistList,
getPlaylistListCount,
getPlaylistSongList, getPlaylistSongList,
getRandomSongList: SubsonicController.getRandomSongList,
getSongDetail, getSongDetail,
getSongList, getSongList,
getSongListCount,
getTopSongs: SubsonicController.getTopSongs,
getUserList, getUserList,
removeFromPlaylist, removeFromPlaylist,
scrobble: SubsonicController.scrobble,
search: SubsonicController.search,
setRating: SubsonicController.setRating,
updatePlaylist, updatePlaylist,
}; };
@@ -11,8 +11,8 @@ import {
import { ServerListItem, ServerType } from '/@/renderer/types'; import { ServerListItem, ServerType } from '/@/renderer/types';
import z from 'zod'; import z from 'zod';
import { ndType } from './navidrome-types'; import { ndType } from './navidrome-types';
import { ssType } from '/@/renderer/api/subsonic/subsonic-types';
import { NDGenre } from '/@/renderer/api/navidrome.types'; import { NDGenre } from '/@/renderer/api/navidrome.types';
import { SubsonicApi } from '/@/renderer/api/subsonic/subsonic-types';
const getImageUrl = (args: { url: string | null }) => { const getImageUrl = (args: { url: string | null }) => {
const { url } = args; const { url } = args;
@@ -45,6 +45,14 @@ const getCoverArtUrl = (args: {
); );
}; };
interface WithDate {
playDate?: string;
}
const normalizePlayDate = (item: WithDate): string | null => {
return !item.playDate || item.playDate.includes('0001-') ? null : item.playDate;
};
const normalizeSong = ( const normalizeSong = (
item: z.infer<typeof ndType._response.song> | z.infer<typeof ndType._response.playlistSong>, item: z.infer<typeof ndType._response.song> | z.infer<typeof ndType._response.playlistSong>,
server: ServerListItem | null, server: ServerListItem | null,
@@ -100,7 +108,7 @@ const normalizeSong = (
imagePlaceholderUrl, imagePlaceholderUrl,
imageUrl, imageUrl,
itemType: LibraryItem.SONG, itemType: LibraryItem.SONG,
lastPlayedAt: item.playDate.includes('0001-') ? null : item.playDate, lastPlayedAt: normalizePlayDate(item),
lyrics: item.lyrics ? item.lyrics : null, lyrics: item.lyrics ? item.lyrics : null,
name: item.title, name: item.title,
path: item.path, path: item.path,
@@ -159,7 +167,7 @@ const normalizeAlbum = (
imageUrl, imageUrl,
isCompilation: item.compilation, isCompilation: item.compilation,
itemType: LibraryItem.ALBUM, itemType: LibraryItem.ALBUM,
lastPlayedAt: item.playDate.includes('0001-') ? null : item.playDate, lastPlayedAt: normalizePlayDate(item),
name: item.name, name: item.name,
playCount: item.playCount, playCount: item.playCount,
releaseDate: new Date(item.minYear, 0, 1).toISOString(), releaseDate: new Date(item.minYear, 0, 1).toISOString(),
@@ -178,7 +186,9 @@ const normalizeAlbum = (
const normalizeAlbumArtist = ( const normalizeAlbumArtist = (
item: z.infer<typeof ndType._response.albumArtist> & { item: z.infer<typeof ndType._response.albumArtist> & {
similarArtists?: z.infer<typeof ssType._response.artistInfo>['artistInfo']['similarArtist']; similarArtists?: z.infer<
typeof SubsonicApi.getArtistInfo2.response
>['subsonic-response']['artistInfo2']['similarArtist'];
}, },
server: ServerListItem | null, server: ServerListItem | null,
): AlbumArtist => { ): AlbumArtist => {
@@ -207,7 +217,7 @@ const normalizeAlbumArtist = (
id: item.id, id: item.id,
imageUrl: imageUrl || null, imageUrl: imageUrl || null,
itemType: LibraryItem.ALBUM_ARTIST, itemType: LibraryItem.ALBUM_ARTIST,
lastPlayedAt: item.playDate.includes('0001-') ? null : item.playDate, lastPlayedAt: normalizePlayDate(item),
name: item.name, name: item.name,
playCount: item.playCount, playCount: item.playCount,
serverId: server?.id || 'unknown', serverId: server?.id || 'unknown',
@@ -78,7 +78,7 @@ const albumArtist = z.object({
name: z.string(), name: z.string(),
orderArtistName: z.string(), orderArtistName: z.string(),
playCount: z.number(), playCount: z.number(),
playDate: z.string(), playDate: z.string().optional(),
rating: z.number(), rating: z.number(),
size: z.number(), size: z.number(),
smallImageUrl: z.string().optional(), smallImageUrl: z.string().optional(),
@@ -128,7 +128,7 @@ const album = z.object({
orderAlbumArtistName: z.string(), orderAlbumArtistName: z.string(),
orderAlbumName: z.string(), orderAlbumName: z.string(),
playCount: z.number(), playCount: z.number(),
playDate: z.string(), playDate: z.string().optional(),
rating: z.number().optional(), rating: z.number().optional(),
size: z.number(), size: z.number(),
songCount: z.number(), songCount: z.number(),
@@ -211,7 +211,7 @@ const song = z.object({
orderTitle: z.string(), orderTitle: z.string(),
path: z.string(), path: z.string(),
playCount: z.number(), playCount: z.number(),
playDate: z.string(), playDate: z.string().optional(),
rating: z.number().optional(), rating: z.number().optional(),
rgAlbumGain: z.number().optional(), rgAlbumGain: z.number().optional(),
rgAlbumPeak: z.number().optional(), rgAlbumPeak: z.number().optional(),
+49 -6
View File
@@ -49,6 +49,19 @@ export const queryKeys: Record<
Record<string, (...props: any) => QueryFunctionContext['queryKey']> Record<string, (...props: any) => QueryFunctionContext['queryKey']>
> = { > = {
albumArtists: { albumArtists: {
count: (serverId: string, query?: AlbumArtistListQuery) => {
const { pagination, filter } = splitPaginatedQuery(query);
if (query && pagination) {
return [serverId, 'albumArtists', 'count', filter, pagination] as const;
}
if (query) {
return [serverId, 'albumArtists', 'count', filter] as const;
}
return [serverId, 'albumArtists', 'count'] as const;
},
detail: (serverId: string, query?: AlbumArtistDetailQuery) => { detail: (serverId: string, query?: AlbumArtistDetailQuery) => {
if (query) return [serverId, 'albumArtists', 'detail', query] as const; if (query) return [serverId, 'albumArtists', 'detail', query] as const;
return [serverId, 'albumArtists', 'detail'] as const; return [serverId, 'albumArtists', 'detail'] as const;
@@ -72,23 +85,40 @@ export const queryKeys: Record<
}, },
}, },
albums: { albums: {
detail: (serverId: string, query?: AlbumDetailQuery) => count: (serverId: string, query?: AlbumListQuery, artistId?: string) => {
[serverId, 'albums', 'detail', query] as const,
list: (serverId: string, query?: AlbumListQuery, artistId?: string) => {
const { pagination, filter } = splitPaginatedQuery(query); const { pagination, filter } = splitPaginatedQuery(query);
if (query && pagination && artistId) { if (query && pagination && artistId) {
return [serverId, 'albums', 'list', artistId, filter, pagination] as const; return [serverId, 'albums', 'count', artistId, filter, pagination] as const;
} }
if (query && pagination) { if (query && pagination) {
return [serverId, 'albums', 'list', filter, pagination] as const; return [serverId, 'albums', 'count', filter, pagination] as const;
} }
if (query && artistId) { if (query && artistId) {
return [serverId, 'albums', 'list', artistId, filter] as const; return [serverId, 'albums', 'count', artistId, filter] as const;
} }
if (query) {
return [serverId, 'albums', 'count', filter] as const;
}
return [serverId, 'albums', 'count'] as const;
},
detail: (serverId: string, query?: AlbumDetailQuery) =>
[serverId, 'albums', 'detail', query] as const,
list: (
serverId: string,
query?: {
artistIds?: string[];
maxYear?: number;
minYear?: number;
searchTerm?: string;
},
) => {
const { filter } = splitPaginatedQuery(query);
if (query) { if (query) {
return [serverId, 'albums', 'list', filter] as const; return [serverId, 'albums', 'list', filter] as const;
} }
@@ -207,6 +237,19 @@ export const queryKeys: Record<
root: (serverId: string) => [serverId] as const, root: (serverId: string) => [serverId] as const,
}, },
songs: { songs: {
count: (serverId: string, query?: AlbumArtistListQuery) => {
const { pagination, filter } = splitPaginatedQuery(query);
if (query && pagination) {
return [serverId, 'songs', 'count', filter, pagination] as const;
}
if (query) {
return [serverId, 'songs', 'count', filter] as const;
}
return [serverId, 'songs', 'count'] as const;
},
detail: (serverId: string, query?: SongDetailQuery) => { detail: (serverId: string, query?: SongDetailQuery) => {
if (query) return [serverId, 'songs', 'detail', query] as const; if (query) return [serverId, 'songs', 'detail', query] as const;
return [serverId, 'songs', 'detail'] as const; return [serverId, 'songs', 'detail'] as const;
+380 -42
View File
@@ -1,93 +1,426 @@
import { initClient, initContract } from '@ts-rest/core'; import { initClient, initContract } from '@ts-rest/core';
import axios, { Method, AxiosError, isAxiosError, AxiosResponse } from 'axios'; import axios, { AxiosError, AxiosResponse, Method, isAxiosError } from 'axios';
import omitBy from 'lodash/omitBy'; import omitBy from 'lodash/omitBy';
import qs from 'qs'; import qs from 'qs';
import { z } from 'zod'; import i18n from '/@/i18n/i18n';
import { ssType } from '/@/renderer/api/subsonic/subsonic-types'; import { SubsonicApi } from '/@/renderer/api/subsonic/subsonic-types';
import { ServerListItem } from '/@/renderer/api/types'; import { ServerListItem } from '/@/renderer/api/types';
import { toast } from '/@/renderer/components/toast/index'; import { toast } from '/@/renderer/components/toast/index';
import i18n from '/@/i18n/i18n';
const c = initContract(); const c = initContract();
export const contract = c.router({ export const contract = c.router({
authenticate: { changePassword: {
method: 'GET', method: 'GET',
path: 'ping.view', path: 'changePassword.view',
query: ssType._parameters.authenticate, query: SubsonicApi.changePassword.parameters,
responses: { responses: {
200: ssType._response.authenticate, 200: SubsonicApi.changePassword.response,
}, },
}, },
createFavorite: { createInternetRadioStation: {
method: 'GET', method: 'GET',
path: 'star.view', path: 'createInternetRadioStation.view',
query: ssType._parameters.createFavorite, query: SubsonicApi.createInternetRadioStation.parameters,
responses: { responses: {
200: ssType._response.createFavorite, 200: SubsonicApi.createInternetRadioStation.response,
},
},
createPlaylist: {
method: 'GET',
path: 'createPlaylist.view',
query: SubsonicApi.createPlaylist.parameters,
responses: {
200: SubsonicApi.createPlaylist.response,
},
},
createShare: {
method: 'GET',
path: 'createShare.view',
query: SubsonicApi.createShare.parameters,
responses: {
200: SubsonicApi.createShare.response,
},
},
createUser: {
method: 'GET',
path: 'createUser.view',
query: SubsonicApi.createUser.parameters,
responses: {
200: SubsonicApi.createUser.response,
},
},
deleteInternetRadioStation: {
method: 'GET',
path: 'deleteInternetRadioStation.view',
query: SubsonicApi.deleteInternetRadioStation.parameters,
responses: {
200: SubsonicApi.deleteInternetRadioStation.response,
},
},
deletePlaylist: {
method: 'GET',
path: 'deletePlaylist.view',
query: SubsonicApi.deletePlaylist.parameters,
responses: {
200: SubsonicApi.deletePlaylist.response,
},
},
deleteShare: {
method: 'GET',
path: 'deleteShare.view',
query: SubsonicApi.deleteShare.parameters,
responses: {
200: SubsonicApi.deleteShare.response,
},
},
deleteUser: {
method: 'GET',
path: 'deleteUser.view',
query: SubsonicApi.deleteUser.parameters,
responses: {
200: SubsonicApi.deleteUser.response,
},
},
getAlbum: {
method: 'GET',
path: 'getAlbum.view',
query: SubsonicApi.getAlbum.parameters,
responses: {
200: SubsonicApi.getAlbum.response,
},
},
getAlbumInfo: {
method: 'GET',
path: 'getAlbumInfo.view',
query: SubsonicApi.getAlbumInfo.parameters,
responses: {
200: SubsonicApi.getAlbumInfo.response,
},
},
getAlbumInfo2: {
method: 'GET',
path: 'getAlbumInfo2.view',
query: SubsonicApi.getAlbumInfo2.parameters,
responses: {
200: SubsonicApi.getAlbumInfo2.response,
},
},
getAlbumList: {
method: 'GET',
path: 'getAlbumList.view',
query: SubsonicApi.getAlbumList.parameters,
responses: {
200: SubsonicApi.getAlbumList.response,
},
},
getAlbumList2: {
method: 'GET',
path: 'getAlbumList2.view',
query: SubsonicApi.getAlbumList2.parameters,
responses: {
200: SubsonicApi.getAlbumList2.response,
},
},
getArtist: {
method: 'GET',
path: 'getArtist.view',
query: SubsonicApi.getArtist.parameters,
responses: {
200: SubsonicApi.getArtist.response,
}, },
}, },
getArtistInfo: { getArtistInfo: {
method: 'GET', method: 'GET',
path: 'getArtistInfo.view', path: 'getArtistInfo.view',
query: ssType._parameters.artistInfo, query: SubsonicApi.getArtistInfo.parameters,
responses: { responses: {
200: ssType._response.artistInfo, 200: SubsonicApi.getArtistInfo.response,
}, },
}, },
getMusicFolderList: { getArtistInfo2: {
method: 'GET',
path: 'getArtistInfo2.view',
query: SubsonicApi.getArtistInfo2.parameters,
responses: {
200: SubsonicApi.getArtistInfo2.response,
},
},
getArtists: {
method: 'GET',
path: 'getArtists.view',
query: SubsonicApi.getArtists.parameters,
responses: {
200: SubsonicApi.getArtists.response,
},
},
getGenres: {
method: 'GET',
path: 'getGenres.view',
query: SubsonicApi.getGenres.parameters,
responses: {
200: SubsonicApi.getGenres.response,
},
},
getIndexes: {
method: 'GET',
path: 'getIndexes.view',
query: SubsonicApi.getIndexes.parameters,
responses: {
200: SubsonicApi.getIndexes.response,
},
},
getInternetRadioStations: {
method: 'GET',
path: 'getInternetRadioStations.view',
query: SubsonicApi.getInternetRadioStations.parameters,
responses: {
200: SubsonicApi.getInternetRadioStations.response,
},
},
getLicense: {
method: 'GET',
path: 'getLicense.view',
query: SubsonicApi.getLicense.parameters,
responses: {
200: SubsonicApi.getLicense.response,
},
},
getLyrics: {
method: 'GET',
path: 'getLyrics.view',
query: SubsonicApi.getLyrics.parameters,
responses: {
200: SubsonicApi.getLyrics.response,
},
},
getMusicDirectory: {
method: 'GET',
path: 'getMusicDirectory.view',
query: SubsonicApi.getMusicDirectory.parameters,
responses: {
200: SubsonicApi.getMusicDirectory.response,
},
},
getMusicFolders: {
method: 'GET', method: 'GET',
path: 'getMusicFolders.view', path: 'getMusicFolders.view',
responses: { responses: {
200: ssType._response.musicFolderList, 200: SubsonicApi.getMusicFolders.response,
}, },
}, },
getRandomSongList: { getNowPlaying: {
method: 'GET',
path: 'getNowPlaying.view',
query: SubsonicApi.getNowPlaying.parameters,
responses: {
200: SubsonicApi.getNowPlaying.response,
},
},
getOpenSubsonicExtensions: {
method: 'GET',
path: 'getOpenSubsonicExtensions.view',
query: SubsonicApi.getOpenSubsonicExtensions.parameters,
responses: {
200: SubsonicApi.getOpenSubsonicExtensions.response,
},
},
getPlaylist: {
method: 'GET',
path: 'getPlaylist.view',
query: SubsonicApi.getPlaylist.parameters,
responses: {
200: SubsonicApi.getPlaylist.response,
},
},
getPlaylists: {
method: 'GET',
path: 'getPlaylists.view',
query: SubsonicApi.getPlaylists.parameters,
responses: {
200: SubsonicApi.getPlaylists.response,
},
},
getRandomSongs: {
method: 'GET', method: 'GET',
path: 'getRandomSongs.view', path: 'getRandomSongs.view',
query: ssType._parameters.randomSongList, query: SubsonicApi.getRandomSongs.parameters,
responses: { responses: {
200: ssType._response.randomSongList, 200: SubsonicApi.getRandomSongs.response,
}, },
}, },
getTopSongsList: { getScanStatus: {
method: 'GET',
path: 'getScanStatus.view',
responses: {
200: SubsonicApi.getScanStatus.response,
},
},
getShares: {
method: 'GET',
path: 'getShares.view',
query: SubsonicApi.getShares.parameters,
responses: {
200: SubsonicApi.getShares.response,
},
},
getSimilarSongs: {
method: 'GET',
path: 'getSimilarSongs.view',
query: SubsonicApi.getSimilarSongs.parameters,
responses: {
200: SubsonicApi.getSimilarSongs.response,
},
},
getSimilarSongs2: {
method: 'GET',
path: 'getSimilarSongs2.view',
query: SubsonicApi.getSimilarSongs2.parameters,
responses: {
200: SubsonicApi.getSimilarSongs2.response,
},
},
getSong: {
method: 'GET',
path: 'getSong.view',
query: SubsonicApi.getSong.parameters,
responses: {
200: SubsonicApi.getSong.response,
},
},
getSongsByGenre: {
method: 'GET',
path: 'getSongsByGenre.view',
query: SubsonicApi.getSongsByGenre.parameters,
responses: {
200: SubsonicApi.getSongsByGenre.response,
},
},
getStarred: {
method: 'GET',
path: 'getStarred.view',
query: SubsonicApi.getStarred.parameters,
responses: {
200: SubsonicApi.getStarred.response,
},
},
getStarred2: {
method: 'GET',
path: 'getStarred2.view',
query: SubsonicApi.getStarred2.parameters,
responses: {
200: SubsonicApi.getStarred2.response,
},
},
getTopSongs: {
method: 'GET', method: 'GET',
path: 'getTopSongs.view', path: 'getTopSongs.view',
query: ssType._parameters.topSongsList, query: SubsonicApi.getTopSongs.parameters,
responses: { responses: {
200: ssType._response.topSongsList, 200: SubsonicApi.getTopSongs.response,
}, },
}, },
removeFavorite: { getUser: {
method: 'GET', method: 'GET',
path: 'unstar.view', path: 'getUser.view',
query: ssType._parameters.removeFavorite, query: SubsonicApi.getUser.parameters,
responses: { responses: {
200: ssType._response.removeFavorite, 200: SubsonicApi.getUser.response,
},
},
getUsers: {
method: 'GET',
path: 'getUsers.view',
query: SubsonicApi.getUsers.parameters,
responses: {
200: SubsonicApi.getUsers.response,
},
},
ping: {
method: 'GET',
path: 'ping.view',
query: SubsonicApi.ping.parameters,
responses: {
200: SubsonicApi.ping.response,
}, },
}, },
scrobble: { scrobble: {
method: 'GET', method: 'GET',
path: 'scrobble.view', path: 'scrobble.view',
query: ssType._parameters.scrobble, query: SubsonicApi.scrobble.parameters,
responses: { responses: {
200: ssType._response.scrobble, 200: SubsonicApi.scrobble.response,
}, },
}, },
search3: { search3: {
method: 'GET', method: 'GET',
path: 'search3.view', path: 'search3.view',
query: ssType._parameters.search3, query: SubsonicApi.search3.parameters,
responses: { responses: {
200: ssType._response.search3, 200: SubsonicApi.search3.response,
}, },
}, },
setRating: { setRating: {
method: 'GET', method: 'GET',
path: 'setRating.view', path: 'setRating.view',
query: ssType._parameters.setRating, query: SubsonicApi.setRating.parameters,
responses: { responses: {
200: ssType._response.setRating, 200: SubsonicApi.setRating.response,
},
},
star: {
method: 'GET',
path: 'star.view',
query: SubsonicApi.star.parameters,
responses: {
200: SubsonicApi.star.response,
},
},
startScan: {
method: 'GET',
path: 'startScan.view',
responses: {
200: SubsonicApi.startScan.response,
},
},
unstar: {
method: 'GET',
path: 'unstar.view',
query: SubsonicApi.unstar.parameters,
responses: {
200: SubsonicApi.unstar.response,
},
},
updateInternetRadioStation: {
method: 'GET',
path: 'updateInternetRadioStation.view',
query: SubsonicApi.updateInternetRadioStation.parameters,
responses: {
200: SubsonicApi.updateInternetRadioStation.response,
},
},
updatePlaylist: {
method: 'GET',
path: 'updatePlaylist.view',
query: SubsonicApi.updatePlaylist.parameters,
responses: {
200: SubsonicApi.updatePlaylist.response,
},
},
updateShare: {
method: 'GET',
path: 'updateShare.view',
query: SubsonicApi.updateShare.parameters,
responses: {
200: SubsonicApi.updateShare.response,
},
},
updateUser: {
method: 'GET',
path: 'updateUser.view',
query: SubsonicApi.updateUser.parameters,
responses: {
200: SubsonicApi.updateUser.response,
}, },
}, },
}); });
@@ -102,14 +435,21 @@ axiosClient.interceptors.response.use(
(response) => { (response) => {
const data = response.data; const data = response.data;
if (data['subsonic-response'].status !== 'ok') { // Ping endpoint returns a string
if (typeof data === 'string') {
return response;
}
if (data['subsonic-response']?.status !== 'ok') {
// Suppress code related to non-linked lastfm or spotify from Navidrome // Suppress code related to non-linked lastfm or spotify from Navidrome
if (data['subsonic-response'].error.code !== 0) { if (data['subsonic-response']?.error.code !== 0) {
toast.error({ toast.error({
message: data['subsonic-response'].error.message, message: data['subsonic-response']?.error.message,
title: i18n.t('error.genericError', { postProcess: 'sentenceCase' }) as string, title: i18n.t('error.genericError', { postProcess: 'sentenceCase' }) as string,
}); });
} }
return Promise.reject(data['subsonic-response']?.error);
} }
return response; return response;
@@ -131,7 +471,7 @@ const parsePath = (fullPath: string) => {
}; };
}; };
export const ssApiClient = (args: { export const subsonicApiClient = (args: {
server: ServerListItem | null; server: ServerListItem | null;
signal?: AbortSignal; signal?: AbortSignal;
url?: string; url?: string;
@@ -162,9 +502,7 @@ export const ssApiClient = (args: {
} }
try { try {
const result = await axiosClient.request< const result = await axiosClient.request({
z.infer<typeof ssType._response.baseResponse>
>({
data: body, data: body,
headers, headers,
method: method as Method, method: method as Method,
@@ -180,9 +518,9 @@ export const ssApiClient = (args: {
}); });
return { return {
body: result.data['subsonic-response'], body: result?.data,
headers: result.headers as any, headers: result?.headers as any,
status: result.status, status: result?.status,
}; };
} catch (e: Error | AxiosError | any) { } catch (e: Error | AxiosError | any) {
if (isAxiosError(e)) { if (isAxiosError(e)) {
File diff suppressed because it is too large Load Diff
+82 -11
View File
@@ -1,7 +1,15 @@
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import { z } from 'zod'; import { z } from 'zod';
import { ssType } from '/@/renderer/api/subsonic/subsonic-types'; import { SubsonicApi } from '/@/renderer/api/subsonic/subsonic-types';
import { QueueSong, LibraryItem, AlbumArtist, Album } from '/@/renderer/api/types'; import {
QueueSong,
LibraryItem,
AlbumArtist,
Album,
Genre,
MusicFolder,
Playlist,
} from '/@/renderer/api/types';
import { ServerListItem, ServerType } from '/@/renderer/types'; import { ServerListItem, ServerType } from '/@/renderer/types';
const getCoverArtUrl = (args: { const getCoverArtUrl = (args: {
@@ -27,16 +35,17 @@ const getCoverArtUrl = (args: {
}; };
const normalizeSong = ( const normalizeSong = (
item: z.infer<typeof ssType._response.song>, item: z.infer<typeof SubsonicApi._baseTypes.song>,
server: ServerListItem | null, server: ServerListItem | null,
deviceId: string, deviceId: string,
size?: number,
): QueueSong => { ): QueueSong => {
const imageUrl = const imageUrl =
getCoverArtUrl({ getCoverArtUrl({
baseUrl: server?.url, baseUrl: server?.url,
coverArtId: item.coverArt, coverArtId: item.coverArt,
credential: server?.credential, credential: server?.credential,
size: 100, size: size || 300,
}) || null; }) || null;
const streamUrl = `${server?.url}/rest/stream.view?id=${item.id}&v=1.13.0&c=feishin_${deviceId}&${server?.credential}`; const streamUrl = `${server?.url}/rest/stream.view?id=${item.id}&v=1.13.0&c=feishin_${deviceId}&${server?.credential}`;
@@ -105,15 +114,18 @@ const normalizeSong = (
}; };
const normalizeAlbumArtist = ( const normalizeAlbumArtist = (
item: z.infer<typeof ssType._response.albumArtist>, item:
| z.infer<typeof SubsonicApi._baseTypes.artist>
| z.infer<typeof SubsonicApi._baseTypes.artistListEntry>,
server: ServerListItem | null, server: ServerListItem | null,
imageSize?: number,
): AlbumArtist => { ): AlbumArtist => {
const imageUrl = const imageUrl =
getCoverArtUrl({ getCoverArtUrl({
baseUrl: server?.url, baseUrl: server?.url,
coverArtId: item.coverArt, coverArtId: item.coverArt,
credential: server?.credential, credential: server?.credential,
size: 100, size: imageSize || 100,
}) || null; }) || null;
return { return {
@@ -138,15 +150,18 @@ const normalizeAlbumArtist = (
}; };
const normalizeAlbum = ( const normalizeAlbum = (
item: z.infer<typeof ssType._response.album>, item:
| z.infer<typeof SubsonicApi._baseTypes.album>
| z.infer<typeof SubsonicApi._baseTypes.albumListEntry>,
server: ServerListItem | null, server: ServerListItem | null,
size?: number,
): Album => { ): Album => {
const imageUrl = const imageUrl =
getCoverArtUrl({ getCoverArtUrl({
baseUrl: server?.url, baseUrl: server?.url,
coverArtId: item.coverArt, coverArtId: item.coverArt,
credential: server?.credential, credential: server?.credential,
size: 300, size: size || 300,
}) || null; }) || null;
return { return {
@@ -156,7 +171,7 @@ const normalizeAlbum = (
artists: item.artistId ? [{ id: item.artistId, imageUrl: null, name: item.artist }] : [], artists: item.artistId ? [{ id: item.artistId, imageUrl: null, name: item.artist }] : [],
backdropImageUrl: null, backdropImageUrl: null,
createdAt: item.created, createdAt: item.created,
duration: item.duration, duration: item.duration * 1000,
genres: item.genre genres: item.genre
? [ ? [
{ {
@@ -181,7 +196,10 @@ const normalizeAlbum = (
serverType: ServerType.SUBSONIC, serverType: ServerType.SUBSONIC,
size: null, size: null,
songCount: item.songCount, songCount: item.songCount,
songs: [], songs:
(item as z.infer<typeof SubsonicApi._baseTypes.album>).song?.map((song) =>
normalizeSong(song, server, ''),
) || [],
uniqueId: nanoid(), uniqueId: nanoid(),
updatedAt: item.created, updatedAt: item.created,
userFavorite: item.starred || false, userFavorite: item.starred || false,
@@ -189,8 +207,61 @@ const normalizeAlbum = (
}; };
}; };
export const ssNormalize = { const normalizeGenre = (item: z.infer<typeof SubsonicApi._baseTypes.genre>): Genre => {
return {
albumCount: item.albumCount,
id: item.value,
imageUrl: null,
itemType: LibraryItem.GENRE,
name: item.value,
songCount: item.songCount,
};
};
const normalizeMusicFolder = (
item: z.infer<typeof SubsonicApi._baseTypes.musicFolder>,
): MusicFolder => {
return {
id: item.id,
name: item.name,
};
};
const normalizePlaylist = (
item:
| z.infer<typeof SubsonicApi._baseTypes.playlist>
| z.infer<typeof SubsonicApi._baseTypes.playlistListEntry>,
server: ServerListItem | null,
): Playlist => {
return {
description: item.comment || null,
duration: item.duration,
genres: [],
id: item.id,
imagePlaceholderUrl: null,
imageUrl: getCoverArtUrl({
baseUrl: server?.url,
coverArtId: item.coverArt,
credential: server?.credential,
size: 300,
}),
itemType: LibraryItem.PLAYLIST,
name: item.name,
owner: item.owner,
ownerId: item.owner,
public: item.public,
serverId: server?.id || 'unknown',
serverType: ServerType.SUBSONIC,
size: null,
songCount: item.songCount,
};
};
export const subsonicNormalize = {
album: normalizeAlbum, album: normalizeAlbum,
albumArtist: normalizeAlbumArtist, albumArtist: normalizeAlbumArtist,
genre: normalizeGenre,
musicFolder: normalizeMusicFolder,
playlist: normalizePlaylist,
song: normalizeSong, song: normalizeSong,
}; };
File diff suppressed because it is too large Load Diff
+72 -6
View File
@@ -124,7 +124,7 @@ export interface BasePaginatedResponse<T> {
error?: string | any; error?: string | any;
items: T; items: T;
startIndex: number; startIndex: number;
totalRecordCount: number; totalRecordCount: number | null;
} }
export type AuthenticationResponse = { export type AuthenticationResponse = {
@@ -306,7 +306,9 @@ export type GenreListResponse = BasePaginatedResponse<Genre[]> | null | undefine
export type GenreListArgs = { query: GenreListQuery } & BaseEndpointArgs; export type GenreListArgs = { query: GenreListQuery } & BaseEndpointArgs;
export enum GenreListSort { export enum GenreListSort {
ALBUM_COUNT = 'albumCount',
NAME = 'name', NAME = 'name',
SONG_COUNT = 'songCount',
} }
export type GenreListQuery = { export type GenreListQuery = {
@@ -330,10 +332,14 @@ type GenreListSortMap = {
export const genreListSortMap: GenreListSortMap = { export const genreListSortMap: GenreListSortMap = {
jellyfin: { jellyfin: {
albumCount: undefined,
name: JFGenreListSort.NAME, name: JFGenreListSort.NAME,
songCount: undefined,
}, },
navidrome: { navidrome: {
albumCount: undefined,
name: NDGenreListSort.NAME, name: NDGenreListSort.NAME,
songCount: undefined,
}, },
subsonic: { subsonic: {
name: undefined, name: undefined,
@@ -370,7 +376,12 @@ export type AlbumListQuery = {
navidrome?: Partial<z.infer<typeof ndType._parameters.albumList>>; navidrome?: Partial<z.infer<typeof ndType._parameters.albumList>>;
}; };
artistIds?: string[]; artistIds?: string[];
genre?: string;
isCompilation?: boolean;
isFavorite?: boolean;
limit?: number; limit?: number;
maxYear?: number;
minYear?: number;
musicFolderId?: string; musicFolderId?: string;
searchTerm?: string; searchTerm?: string;
sortBy: AlbumListSort; sortBy: AlbumListSort;
@@ -481,8 +492,13 @@ export type SongListQuery = {
}; };
albumIds?: string[]; albumIds?: string[];
artistIds?: string[]; artistIds?: string[];
genre?: string;
genreId?: string;
imageSize?: number; imageSize?: number;
isFavorite?: boolean;
limit?: number; limit?: number;
maxYear?: number;
minYear?: number;
musicFolderId?: string; musicFolderId?: string;
searchTerm?: string; searchTerm?: string;
sortBy: SongListSort; sortBy: SongListSort;
@@ -802,6 +818,7 @@ export type CreatePlaylistBody = {
}; };
comment?: string; comment?: string;
name: string; name: string;
public?: boolean;
}; };
export type CreatePlaylistArgs = { body: CreatePlaylistBody; serverId?: string } & BaseEndpointArgs; export type CreatePlaylistArgs = { body: CreatePlaylistBody; serverId?: string } & BaseEndpointArgs;
@@ -826,6 +843,11 @@ export type UpdatePlaylistBody = {
comment?: string; comment?: string;
genres?: Genre[]; genres?: Genre[];
name: string; name: string;
owner?: string;
ownerId?: string;
public?: boolean;
rules?: Record<string, any>;
sync?: boolean;
}; };
export type UpdatePlaylistArgs = { export type UpdatePlaylistArgs = {
@@ -917,10 +939,9 @@ export type PlaylistSongListResponse = BasePaginatedResponse<Song[]> | null | un
export type PlaylistSongListQuery = { export type PlaylistSongListQuery = {
id: string; id: string;
limit?: number; searchTerm?: string;
sortBy?: SongListSort; sortBy: SongListSort;
sortOrder?: SortOrder; sortOrder: SortOrder;
startIndex: number;
}; };
export type PlaylistSongListArgs = { query: PlaylistSongListQuery } & BaseEndpointArgs; export type PlaylistSongListArgs = { query: PlaylistSongListQuery } & BaseEndpointArgs;
@@ -1014,7 +1035,7 @@ export type SearchQuery = {
albumLimit?: number; albumLimit?: number;
albumStartIndex?: number; albumStartIndex?: number;
musicFolderId?: string; musicFolderId?: string;
query?: string; query: string;
songLimit?: number; songLimit?: number;
songStartIndex?: number; songStartIndex?: number;
}; };
@@ -1139,3 +1160,48 @@ export type FontData = {
postscriptName: string; postscriptName: string;
style: string; style: string;
}; };
export type ControllerEndpoint = Partial<{
addToPlaylist: (args: AddToPlaylistArgs) => Promise<AddToPlaylistResponse>;
authenticate: (
url: string,
body: { password: string; username: string },
) => Promise<AuthenticationResponse>;
clearPlaylist: () => void;
createFavorite: (args: FavoriteArgs) => Promise<FavoriteResponse>;
createPlaylist: (args: CreatePlaylistArgs) => Promise<CreatePlaylistResponse>;
deleteFavorite: (args: FavoriteArgs) => Promise<FavoriteResponse>;
deletePlaylist: (args: DeletePlaylistArgs) => Promise<DeletePlaylistResponse>;
getAlbumArtistDetail: (args: AlbumArtistDetailArgs) => Promise<AlbumArtistDetailResponse>;
getAlbumArtistList: (args: AlbumArtistListArgs) => Promise<AlbumArtistListResponse>;
getAlbumArtistListCount: (args: AlbumArtistListArgs) => Promise<number>;
getAlbumDetail: (args: AlbumDetailArgs) => Promise<AlbumDetailResponse>;
getAlbumList: (args: AlbumListArgs) => Promise<AlbumListResponse>;
getAlbumListCount: (args: AlbumListArgs) => Promise<number>;
getAlbumSongList: (args: AlbumDetailArgs) => Promise<SongListResponse>; // TODO
getArtistDetail: () => void;
getArtistInfo: (args: any) => void;
getArtistList: (args: ArtistListArgs) => Promise<ArtistListResponse>;
getFavoritesList: () => void;
getFolderItemList: () => void;
getFolderList: () => void;
getFolderSongs: () => void;
getGenreList: (args: GenreListArgs) => Promise<GenreListResponse>;
getLyrics: (args: LyricsArgs) => Promise<LyricsResponse>;
getMusicFolderList: (args: MusicFolderListArgs) => Promise<MusicFolderListResponse>;
getPlaylistDetail: (args: PlaylistDetailArgs) => Promise<PlaylistDetailResponse>;
getPlaylistList: (args: PlaylistListArgs) => Promise<PlaylistListResponse>;
getPlaylistListCount: (args: PlaylistListArgs) => Promise<number>;
getPlaylistSongList: (args: PlaylistSongListArgs) => Promise<SongListResponse>;
getRandomSongList: (args: RandomSongListArgs) => Promise<SongListResponse>;
getSongDetail: (args: SongDetailArgs) => Promise<SongDetailResponse>;
getSongList: (args: SongListArgs) => Promise<SongListResponse>;
getSongListCount: (args: SongListArgs) => Promise<number>;
getTopSongs: (args: TopSongListArgs) => Promise<TopSongListResponse>;
getUserList: (args: UserListArgs) => Promise<UserListResponse>;
removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise<RemoveFromPlaylistResponse>;
scrobble: (args: ScrobbleArgs) => Promise<ScrobbleResponse>;
search: (args: SearchArgs) => Promise<SearchResponse>;
setRating: (args: SetRatingArgs) => Promise<RatingResponse>;
updatePlaylist: (args: UpdatePlaylistArgs) => Promise<UpdatePlaylistResponse>;
}>;
+188 -1
View File
@@ -1,8 +1,20 @@
import { AxiosHeaders } from 'axios'; import { AxiosHeaders } from 'axios';
import { z } from 'zod';
import { toast } from '/@/renderer/components'; import { toast } from '/@/renderer/components';
import { useAuthStore } from '/@/renderer/store'; import { useAuthStore } from '/@/renderer/store';
import { ServerListItem } from '/@/renderer/types'; import { ServerListItem } from '/@/renderer/types';
import {
Album,
AlbumArtist,
AlbumArtistListSort,
AlbumListSort,
QueueSong,
SongListSort,
SortOrder,
} from '/@/renderer/api/types';
import orderBy from 'lodash/orderBy';
import reverse from 'lodash/reverse';
import shuffle from 'lodash/shuffle';
import { z } from 'zod';
// Since ts-rest client returns a strict response type, we need to add the headers to the body object // Since ts-rest client returns a strict response type, we need to add the headers to the body object
export const resultWithHeaders = <ItemType extends z.ZodTypeAny>(itemSchema: ItemType) => { export const resultWithHeaders = <ItemType extends z.ZodTypeAny>(itemSchema: ItemType) => {
@@ -38,3 +50,178 @@ export const authenticationFailure = (currentServer: ServerListItem | null) => {
useAuthStore.getState().actions.setCurrentServer(null); useAuthStore.getState().actions.setCurrentServer(null);
} }
}; };
export const sortAlbumList = (albums: Album[], sortBy: AlbumListSort, sortOrder: SortOrder) => {
let results = albums;
const order = sortOrder === SortOrder.ASC ? 'asc' : 'desc';
switch (sortBy) {
case AlbumListSort.ALBUM_ARTIST:
results = orderBy(
results,
['albumArtist', (v) => v.name.toLowerCase()],
[order, 'asc'],
);
break;
case AlbumListSort.DURATION:
results = orderBy(results, ['duration'], [order]);
break;
case AlbumListSort.FAVORITED:
results = orderBy(results, ['starred'], [order]);
break;
case AlbumListSort.NAME:
results = orderBy(results, [(v) => v.name.toLowerCase()], [order]);
break;
case AlbumListSort.PLAY_COUNT:
results = orderBy(results, ['playCount'], [order]);
break;
case AlbumListSort.RANDOM:
results = shuffle(results);
break;
case AlbumListSort.RECENTLY_ADDED:
results = orderBy(results, ['createdAt'], [order]);
break;
case AlbumListSort.RECENTLY_PLAYED:
results = orderBy(results, ['lastPlayedAt'], [order]);
break;
case AlbumListSort.RATING:
results = orderBy(results, ['userRating'], [order]);
break;
case AlbumListSort.YEAR:
results = orderBy(results, ['releaseYear'], [order]);
break;
case AlbumListSort.SONG_COUNT:
results = orderBy(results, ['songCount'], [order]);
break;
default:
break;
}
return results;
};
export const sortSongList = (songs: QueueSong[], sortBy: SongListSort, sortOrder: SortOrder) => {
let results = songs;
const order = sortOrder === SortOrder.ASC ? 'asc' : 'desc';
switch (sortBy) {
case SongListSort.ALBUM:
results = orderBy(
results,
[(v) => v.album?.toLowerCase(), 'discNumber', 'trackNumber'],
[order, 'asc', 'asc'],
);
break;
case SongListSort.ALBUM_ARTIST:
results = orderBy(
results,
['albumArtist', (v) => v.album?.toLowerCase(), 'discNumber', 'trackNumber'],
[order, order, 'asc', 'asc'],
);
break;
case SongListSort.ARTIST:
results = orderBy(
results,
['artist', (v) => v.album?.toLowerCase(), 'discNumber', 'trackNumber'],
[order, order, 'asc', 'asc'],
);
break;
case SongListSort.DURATION:
results = orderBy(results, ['duration'], [order]);
break;
case SongListSort.FAVORITED:
results = orderBy(results, ['userFavorite', (v) => v.name.toLowerCase()], [order]);
break;
case SongListSort.GENRE:
results = orderBy(
results,
[
(v) => v.genres?.[0].name.toLowerCase(),
(v) => v.album?.toLowerCase(),
'discNumber',
'trackNumber',
],
[order, order, 'asc', 'asc'],
);
break;
case SongListSort.ID:
if (order === 'desc') {
results = reverse(results);
}
break;
case SongListSort.NAME:
results = orderBy(results, [(v) => v.name.toLowerCase()], [order]);
break;
case SongListSort.PLAY_COUNT:
results = orderBy(results, ['playCount'], [order]);
break;
case SongListSort.RANDOM:
results = shuffle(results);
break;
case SongListSort.RATING:
results = orderBy(results, ['userRating', (v) => v.name.toLowerCase()], [order]);
break;
case SongListSort.RECENTLY_ADDED:
results = orderBy(results, ['created'], [order]);
break;
case SongListSort.YEAR:
results = orderBy(
results,
['year', (v) => v.album?.toLowerCase(), 'discNumber', 'track'],
[order, 'asc', 'asc', 'asc'],
);
break;
default:
break;
}
return results;
};
export const sortAlbumArtistList = (
artists: AlbumArtist[],
sortBy: AlbumArtistListSort,
sortOrder: SortOrder,
) => {
const order = sortOrder === SortOrder.ASC ? 'asc' : 'desc';
let results = artists;
switch (sortBy) {
case AlbumArtistListSort.ALBUM_COUNT:
results = orderBy(artists, ['albumCount', (v) => v.name.toLowerCase()], [order, 'asc']);
break;
case AlbumArtistListSort.NAME:
results = orderBy(artists, [(v) => v.name.toLowerCase()], [order]);
break;
case AlbumArtistListSort.FAVORITED:
results = orderBy(artists, ['starred'], [order]);
break;
case AlbumArtistListSort.RATING:
results = orderBy(artists, ['userRating'], [order]);
break;
default:
break;
}
return results;
};
+1 -1
View File
@@ -275,7 +275,7 @@ export const PLAYLIST_CARD_ROWS: { [key: string]: CardRow<Playlist> } = {
name: { name: {
property: 'name', property: 'name',
route: { route: {
route: AppRoute.PLAYLISTS_DETAIL, route: AppRoute.PLAYLISTS_DETAIL_SONGS,
slugs: [{ idProperty: 'id', slugProperty: 'playlistId' }], slugs: [{ idProperty: 'id', slugProperty: 'playlistId' }],
}, },
}, },
@@ -34,6 +34,7 @@ interface UseAgGridProps<TFilter> {
columnType?: 'albumDetail' | 'generic'; columnType?: 'albumDetail' | 'generic';
contextMenu: SetContextMenuItems; contextMenu: SetContextMenuItems;
customFilters?: Partial<TFilter>; customFilters?: Partial<TFilter>;
isClientSide?: boolean;
isClientSideSort?: boolean; isClientSideSort?: boolean;
isSearchParams?: boolean; isSearchParams?: boolean;
itemCount?: number; itemCount?: number;
@@ -43,6 +44,8 @@ interface UseAgGridProps<TFilter> {
tableRef: MutableRefObject<AgGridReactType | null>; tableRef: MutableRefObject<AgGridReactType | null>;
} }
const BLOCK_SIZE = 500;
export const useVirtualTable = <TFilter>({ export const useVirtualTable = <TFilter>({
server, server,
tableRef, tableRef,
@@ -52,6 +55,7 @@ export const useVirtualTable = <TFilter>({
itemCount, itemCount,
customFilters, customFilters,
isSearchParams, isSearchParams,
isClientSide,
isClientSideSort, isClientSideSort,
columnType, columnType,
}: UseAgGridProps<TFilter>) => { }: UseAgGridProps<TFilter>) => {
@@ -182,6 +186,19 @@ export const useVirtualTable = <TFilter>({
return; return;
} }
if (results.totalRecordCount === null) {
const hasMoreRows = results?.items?.length === BLOCK_SIZE;
const lastRowIndex = hasMoreRows
? undefined
: (properties.filter.offset || 0) + results.items.length;
params.successCallback(
results?.items || [],
hasMoreRows ? undefined : lastRowIndex,
);
return;
}
params.successCallback(results?.items || [], results?.totalRecordCount || 0); params.successCallback(results?.items || [], results?.totalRecordCount || 0);
}, },
rowCount: undefined, rowCount: undefined,
@@ -321,6 +338,7 @@ export const useVirtualTable = <TFilter>({
alwaysShowHorizontalScroll: true, alwaysShowHorizontalScroll: true,
autoFitColumns: properties.table.autoFit, autoFitColumns: properties.table.autoFit,
blockLoadDebounceMillis: 200, blockLoadDebounceMillis: 200,
cacheBlockSize: 500,
getRowId: (data: GetRowIdParams<any>) => data.data.id, getRowId: (data: GetRowIdParams<any>) => data.data.id,
infiniteInitialRowCount: itemCount || 100, infiniteInitialRowCount: itemCount || 100,
pagination: isPaginationEnabled, pagination: isPaginationEnabled,
@@ -335,10 +353,11 @@ export const useVirtualTable = <TFilter>({
: undefined, : undefined,
rowBuffer: 20, rowBuffer: 20,
rowHeight: properties.table.rowHeight || 40, rowHeight: properties.table.rowHeight || 40,
rowModelType: 'infinite' as RowModelType, rowModelType: isClientSide ? 'clientSide' : ('infinite' as RowModelType),
suppressRowDrag: true, suppressRowDrag: true,
}; };
}, [ }, [
isClientSide,
isPaginationEnabled, isPaginationEnabled,
isSearchParams, isSearchParams,
itemCount, itemCount,
@@ -370,7 +389,9 @@ export const useVirtualTable = <TFilter>({
); );
break; break;
case LibraryItem.PLAYLIST: case LibraryItem.PLAYLIST:
navigate(generatePath(AppRoute.PLAYLISTS_DETAIL, { playlistId: e.data.id })); navigate(
generatePath(AppRoute.PLAYLISTS_DETAIL_SONGS, { playlistId: e.data.id }),
);
break; break;
default: default:
break; break;
@@ -22,6 +22,7 @@ import { ALBUM_TABLE_COLUMNS } from '/@/renderer/components/virtual-table';
import { useListContext } from '/@/renderer/context/list-context'; import { useListContext } from '/@/renderer/context/list-context';
import { JellyfinAlbumFilters } from '/@/renderer/features/albums/components/jellyfin-album-filters'; import { JellyfinAlbumFilters } from '/@/renderer/features/albums/components/jellyfin-album-filters';
import { NavidromeAlbumFilters } from '/@/renderer/features/albums/components/navidrome-album-filters'; import { NavidromeAlbumFilters } from '/@/renderer/features/albums/components/navidrome-album-filters';
import { SubsonicAlbumFilters } from '/@/renderer/features/albums/components/subsonic-album-filters';
import { OrderToggleButton, useMusicFolders } from '/@/renderer/features/shared'; import { OrderToggleButton, useMusicFolders } from '/@/renderer/features/shared';
import { useContainerQuery } from '/@/renderer/hooks'; import { useContainerQuery } from '/@/renderer/hooks';
import { useListFilterRefresh } from '/@/renderer/hooks/use-list-filter-refresh'; import { useListFilterRefresh } from '/@/renderer/hooks/use-list-filter-refresh';
@@ -139,14 +140,61 @@ const FILTERS = {
value: AlbumListSort.YEAR, value: AlbumListSort.YEAR,
}, },
], ],
subsonic: [
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.albumArtist', { postProcess: 'titleCase' }),
value: AlbumListSort.ALBUM_ARTIST,
},
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.mostPlayed', { postProcess: 'titleCase' }),
value: AlbumListSort.PLAY_COUNT,
},
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.name', { postProcess: 'titleCase' }),
value: AlbumListSort.NAME,
},
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.random', { postProcess: 'titleCase' }),
value: AlbumListSort.RANDOM,
},
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.recentlyAdded', { postProcess: 'titleCase' }),
value: AlbumListSort.RECENTLY_ADDED,
},
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.recentlyPlayed', { postProcess: 'titleCase' }),
value: AlbumListSort.RECENTLY_PLAYED,
},
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.favorited', { postProcess: 'titleCase' }),
value: AlbumListSort.FAVORITED,
},
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.releaseYear', { postProcess: 'titleCase' }),
value: AlbumListSort.YEAR,
},
],
}; };
interface AlbumListHeaderFiltersProps { interface AlbumListHeaderFiltersProps {
gridRef: MutableRefObject<VirtualInfiniteGridRef | null>; gridRef: MutableRefObject<VirtualInfiniteGridRef | null>;
itemCount: number | undefined;
tableRef: MutableRefObject<AgGridReactType | null>; tableRef: MutableRefObject<AgGridReactType | null>;
} }
export const AlbumListHeaderFilters = ({ gridRef, tableRef }: AlbumListHeaderFiltersProps) => { export const AlbumListHeaderFilters = ({
gridRef,
tableRef,
itemCount,
}: AlbumListHeaderFiltersProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { pageKey, customFilters, handlePlay } = useListContext(); const { pageKey, customFilters, handlePlay } = useListContext();
@@ -159,6 +207,7 @@ export const AlbumListHeaderFilters = ({ gridRef, tableRef }: AlbumListHeaderFil
const cq = useContainerQuery(); const cq = useContainerQuery();
const { handleRefreshTable, handleRefreshGrid } = useListFilterRefresh({ const { handleRefreshTable, handleRefreshGrid } = useListFilterRefresh({
itemCount,
itemType: LibraryItem.ALBUM, itemType: LibraryItem.ALBUM,
server, server,
}); });
@@ -185,27 +234,35 @@ export const AlbumListHeaderFilters = ({ gridRef, tableRef }: AlbumListHeaderFil
); );
const handleOpenFiltersModal = () => { const handleOpenFiltersModal = () => {
let FilterComponent;
switch (server?.type) {
case ServerType.NAVIDROME:
FilterComponent = NavidromeAlbumFilters;
break;
case ServerType.JELLYFIN:
FilterComponent = JellyfinAlbumFilters;
break;
case ServerType.SUBSONIC:
FilterComponent = SubsonicAlbumFilters;
break;
default:
break;
}
if (!FilterComponent) {
return;
}
openModal({ openModal({
children: ( children: (
<> <FilterComponent
{server?.type === ServerType.NAVIDROME ? ( customFilters={customFilters}
<NavidromeAlbumFilters disableArtistFilter={!!customFilters}
customFilters={customFilters} pageKey={pageKey}
disableArtistFilter={!!customFilters} serverId={server?.id}
pageKey={pageKey} onFilterChange={onFilterChange}
serverId={server?.id} />
onFilterChange={onFilterChange}
/>
) : (
<JellyfinAlbumFilters
customFilters={customFilters}
disableArtistFilter={!!customFilters}
pageKey={pageKey}
serverId={server?.id}
onFilterChange={onFilterChange}
/>
)}
</>
), ),
title: 'Album Filters', title: 'Album Filters',
}); });
@@ -341,8 +398,20 @@ export const AlbumListHeaderFilters = ({ gridRef, tableRef }: AlbumListHeaderFil
filter?._custom?.jellyfin && filter?._custom?.jellyfin &&
Object.values(filter?._custom?.jellyfin).some((value) => value !== undefined); Object.values(filter?._custom?.jellyfin).some((value) => value !== undefined);
return isNavidromeFilterApplied || isJellyfinFilterApplied; const isSubsonicFilterApplied =
}, [filter?._custom?.jellyfin, filter?._custom?.navidrome, server?.type]); server?.type === ServerType.SUBSONIC &&
(filter.maxYear || filter.minYear || filter.genre || filter.isFavorite);
return isNavidromeFilterApplied || isJellyfinFilterApplied || isSubsonicFilterApplied;
}, [
filter?._custom?.jellyfin,
filter?._custom?.navidrome,
filter.genre,
filter.isFavorite,
filter.maxYear,
filter.minYear,
server?.type,
]);
const isFolderFilterApplied = useMemo(() => { const isFolderFilterApplied = useMemo(() => {
return filter.musicFolderId !== undefined; return filter.musicFolderId !== undefined;
@@ -38,6 +38,7 @@ export const AlbumListHeader = ({ itemCount, gridRef, tableRef, title }: AlbumLi
const playButtonBehavior = usePlayButtonBehavior(); const playButtonBehavior = usePlayButtonBehavior();
const { handleRefreshGrid, handleRefreshTable } = useListFilterRefresh({ const { handleRefreshGrid, handleRefreshTable } = useListFilterRefresh({
itemCount,
itemType: LibraryItem.ALBUM, itemType: LibraryItem.ALBUM,
server, server,
}); });
@@ -94,6 +95,7 @@ export const AlbumListHeader = ({ itemCount, gridRef, tableRef, title }: AlbumLi
<FilterBar> <FilterBar>
<AlbumListHeaderFilters <AlbumListHeaderFilters
gridRef={gridRef} gridRef={gridRef}
itemCount={itemCount}
tableRef={tableRef} tableRef={tableRef}
/> />
</FilterBar> </FilterBar>
@@ -0,0 +1,143 @@
import { Divider, Group, Stack } from '@mantine/core';
import debounce from 'lodash/debounce';
import { ChangeEvent, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { GenreListSort, LibraryItem, SortOrder } from '/@/renderer/api/types';
import { NumberInput, Select, Switch, Text } from '/@/renderer/components';
import { useGenreList } from '/@/renderer/features/genres';
import { AlbumListFilter, useListStoreActions, useListStoreByKey } from '/@/renderer/store';
interface SubsonicAlbumFiltersProps {
onFilterChange: (filters: AlbumListFilter) => void;
pageKey: string;
serverId?: string;
}
export const SubsonicAlbumFilters = ({
onFilterChange,
pageKey,
serverId,
}: SubsonicAlbumFiltersProps) => {
const { t } = useTranslation();
const { filter } = useListStoreByKey({ key: pageKey });
const { setFilter } = useListStoreActions();
const genreListQuery = useGenreList({
query: {
sortBy: GenreListSort.NAME,
sortOrder: SortOrder.ASC,
startIndex: 0,
},
serverId,
});
const genreList = useMemo(() => {
if (!genreListQuery?.data) return [];
return genreListQuery.data.items.map((genre) => ({
label: genre.name,
value: genre.id,
}));
}, [genreListQuery.data]);
const handleGenresFilter = debounce((e: string | null) => {
const updatedFilters = setFilter({
data: {
genre: e || undefined,
},
itemType: LibraryItem.ALBUM,
key: pageKey,
}) as AlbumListFilter;
onFilterChange(updatedFilters);
}, 250);
const toggleFilters = [
{
label: t('filter.isFavorited', { postProcess: 'sentenceCase' }),
onChange: (e: ChangeEvent<HTMLInputElement>) => {
const updatedFilters = setFilter({
data: {
isFavorite: e.target.checked ? true : undefined,
},
itemType: LibraryItem.ALBUM,
key: pageKey,
}) as AlbumListFilter;
onFilterChange(updatedFilters);
},
value: filter.isFavorite,
},
];
const handleYearFilter = debounce((e: number | string, type: 'min' | 'max') => {
let data = {};
if (type === 'min') {
data = {
minYear: e || undefined,
};
} else {
data = {
maxYear: e || undefined,
};
}
console.log('data', data);
const updatedFilters = setFilter({
data,
itemType: LibraryItem.ALBUM,
key: pageKey,
}) as AlbumListFilter;
onFilterChange(updatedFilters);
}, 500);
return (
<Stack p="0.8rem">
{toggleFilters.map((filter) => (
<Group
key={`nd-filter-${filter.label}`}
position="apart"
>
<Text>{filter.label}</Text>
<Switch
checked={filter?.value || false}
onChange={filter.onChange}
/>
</Group>
))}
<Divider my="0.5rem" />
<Group grow>
<NumberInput
defaultValue={filter.minYear}
disabled={filter.genre}
hideControls={false}
label={t('filter.fromYear', { postProcess: 'sentenceCase' })}
max={5000}
min={0}
onChange={(e) => handleYearFilter(e, 'min')}
/>
<NumberInput
defaultValue={filter.maxYear}
disabled={filter.genre}
hideControls={false}
label={t('filter.toYear', { postProcess: 'sentenceCase' })}
max={5000}
min={0}
onChange={(e) => handleYearFilter(e, 'max')}
/>
</Group>
<Group grow>
<Select
clearable
searchable
data={genreList}
defaultValue={filter.genre}
disabled={filter.minYear || filter.maxYear}
label={t('entity.genre', { count: 1, postProcess: 'titleCase' })}
onChange={handleGenresFilter}
/>
</Group>
</Stack>
);
};
@@ -0,0 +1,44 @@
import { useQuery } from '@tanstack/react-query';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import type { AlbumListQuery } from '/@/renderer/api/types';
import type { QueryHookArgs } from '/@/renderer/lib/react-query';
import { getServerById } from '/@/renderer/store';
export const getAlbumListCountQuery = (query: AlbumListQuery) => {
const filter: Record<string, unknown> = {};
if (query.artistIds) filter.artistIds = query.artistIds;
if (query.maxYear) filter.maxYear = query.maxYear;
if (query.minYear) filter.minYear = query.minYear;
if (query.searchTerm) filter.searchTerm = query.searchTerm;
if (query.genre) filter.genre = query.genre;
if (query.musicFolderId) filter.musicFolderId = query.musicFolderId;
if (query.isCompilation) filter.isCompilation = query.isCompilation;
if (query.isFavorite) filter.isCompilation = query.isFavorite;
if (Object.keys(filter).length === 0) return undefined;
return filter;
};
export const useAlbumListCount = (args: QueryHookArgs<AlbumListQuery>) => {
const { options, query, serverId } = args;
const server = getServerById(serverId);
return useQuery({
enabled: !!serverId,
queryFn: ({ signal }) => {
if (!server) throw new Error('Server not found');
return api.controller.getAlbumListCount({
apiClientProps: {
server,
signal,
},
query,
});
},
queryKey: queryKeys.albums.count(serverId || '', getAlbumListCountQuery(query)),
...options,
});
};
@@ -9,12 +9,12 @@ import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
import { ListContext } from '/@/renderer/context/list-context'; import { ListContext } from '/@/renderer/context/list-context';
import { AlbumListContent } from '/@/renderer/features/albums/components/album-list-content'; import { AlbumListContent } from '/@/renderer/features/albums/components/album-list-content';
import { AlbumListHeader } from '/@/renderer/features/albums/components/album-list-header'; import { AlbumListHeader } from '/@/renderer/features/albums/components/album-list-header';
import { useAlbumList } from '/@/renderer/features/albums/queries/album-list-query';
import { usePlayQueueAdd } from '/@/renderer/features/player'; import { usePlayQueueAdd } from '/@/renderer/features/player';
import { AnimatedPage } from '/@/renderer/features/shared'; import { AnimatedPage } from '/@/renderer/features/shared';
import { queryClient } from '/@/renderer/lib/react-query'; import { queryClient } from '/@/renderer/lib/react-query';
import { useCurrentServer, useListFilterByKey } from '/@/renderer/store'; import { useCurrentServer, useListFilterByKey } from '/@/renderer/store';
import { Play } from '/@/renderer/types'; import { Play } from '/@/renderer/types';
import { useAlbumListCount } from '/@/renderer/features/albums/queries/album-list-count-query';
const AlbumListRoute = () => { const AlbumListRoute = () => {
const gridRef = useRef<VirtualInfiniteGridRef | null>(null); const gridRef = useRef<VirtualInfiniteGridRef | null>(null);
@@ -42,23 +42,18 @@ const AlbumListRoute = () => {
key: pageKey, key: pageKey,
}); });
const itemCountCheck = useAlbumList({ const itemCountCheck = useAlbumListCount({
options: { options: {
cacheTime: 1000 * 60, cacheTime: 1000 * 60,
staleTime: 1000 * 60, staleTime: 1000 * 60,
}, },
query: { query: {
limit: 1,
startIndex: 0,
...albumListFilter, ...albumListFilter,
}, },
serverId: server?.id, serverId: server?.id,
}); });
const itemCount = const itemCount = itemCountCheck.data === null ? undefined : itemCountCheck.data;
itemCountCheck.data?.totalRecordCount === null
? undefined
: itemCountCheck.data?.totalRecordCount;
const handlePlay = useCallback( const handlePlay = useCallback(
async (args: { initialSongId?: string; playType: Play }) => { async (args: { initialSongId?: string; playType: Play }) => {
@@ -100,6 +100,7 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
: undefined), : undefined),
}, },
}, },
artistIds: [albumArtistId],
limit: 15, limit: 15,
sortBy: AlbumListSort.RELEASE_DATE, sortBy: AlbumListSort.RELEASE_DATE,
sortOrder: SortOrder.DESC, sortOrder: SortOrder.DESC,
@@ -122,6 +123,8 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
: undefined), : undefined),
}, },
}, },
artistIds: [albumArtistId],
isCompilation: true,
limit: 15, limit: 15,
sortBy: AlbumListSort.RELEASE_DATE, sortBy: AlbumListSort.RELEASE_DATE,
sortOrder: SortOrder.DESC, sortOrder: SortOrder.DESC,
@@ -85,6 +85,28 @@ const FILTERS = {
value: AlbumArtistListSort.SONG_COUNT, value: AlbumArtistListSort.SONG_COUNT,
}, },
], ],
subsonic: [
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.albumCount', { postProcess: 'titleCase' }),
value: AlbumArtistListSort.ALBUM_COUNT,
},
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.isFavorited', { postProcess: 'titleCase' }),
value: AlbumArtistListSort.FAVORITED,
},
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.name', { postProcess: 'titleCase' }),
value: AlbumArtistListSort.NAME,
},
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.rating', { postProcess: 'titleCase' }),
value: AlbumArtistListSort.RATING,
},
],
}; };
interface AlbumArtistListHeaderFiltersProps { interface AlbumArtistListHeaderFiltersProps {
@@ -35,6 +35,7 @@ export const AlbumArtistListHeader = ({
const cq = useContainerQuery(); const cq = useContainerQuery();
const { handleRefreshGrid, handleRefreshTable } = useListFilterRefresh({ const { handleRefreshGrid, handleRefreshTable } = useListFilterRefresh({
itemCount,
itemType: LibraryItem.ALBUM_ARTIST, itemType: LibraryItem.ALBUM_ARTIST,
server, server,
}); });
@@ -0,0 +1,38 @@
import { useQuery } from '@tanstack/react-query';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { AlbumArtistListQuery } from '/@/renderer/api/types';
import { QueryHookArgs } from '/@/renderer/lib/react-query';
import { getServerById } from '/@/renderer/store';
export const getAlbumArtistListCountQuery = (query: AlbumArtistListQuery) => {
const filter: Record<string, unknown> = {};
if (query.searchTerm) filter.searchTerm = query.searchTerm;
if (query.musicFolderId) filter.musicFolderId = query.musicFolderId;
if (Object.keys(filter).length === 0) return undefined;
return filter;
};
export const useAlbumArtistListCount = (args: QueryHookArgs<AlbumArtistListQuery>) => {
const { options, query, serverId } = args;
const server = getServerById(serverId);
return useQuery({
enabled: !!serverId,
queryFn: ({ signal }) => {
if (!server) throw new Error('Server not found');
return api.controller.getAlbumArtistListCount({
apiClientProps: {
server,
signal,
},
query,
});
},
queryKey: queryKeys.albumArtists.count(serverId || '', getAlbumArtistListCountQuery(query)),
...options,
});
};
@@ -7,7 +7,7 @@ import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
import { ListContext } from '/@/renderer/context/list-context'; import { ListContext } from '/@/renderer/context/list-context';
import { AlbumArtistListContent } from '/@/renderer/features/artists/components/album-artist-list-content'; import { AlbumArtistListContent } from '/@/renderer/features/artists/components/album-artist-list-content';
import { AlbumArtistListHeader } from '/@/renderer/features/artists/components/album-artist-list-header'; import { AlbumArtistListHeader } from '/@/renderer/features/artists/components/album-artist-list-header';
import { useAlbumArtistList } from '/@/renderer/features/artists/queries/album-artist-list-query'; import { useAlbumArtistListCount } from '/@/renderer/features/artists/queries/album-artist-list-count-query';
import { AnimatedPage } from '/@/renderer/features/shared'; import { AnimatedPage } from '/@/renderer/features/shared';
const AlbumArtistListRoute = () => { const AlbumArtistListRoute = () => {
@@ -18,23 +18,18 @@ const AlbumArtistListRoute = () => {
const albumArtistListFilter = useListFilterByKey({ key: pageKey }); const albumArtistListFilter = useListFilterByKey({ key: pageKey });
const itemCountCheck = useAlbumArtistList({ const itemCountCheck = useAlbumArtistListCount({
options: { options: {
cacheTime: 1000 * 60, cacheTime: 1000 * 60,
staleTime: 1000 * 60, staleTime: 1000 * 60,
}, },
query: { query: {
limit: 1,
startIndex: 0,
...albumArtistListFilter, ...albumArtistListFilter,
}, },
serverId: server?.id, serverId: server?.id,
}); });
const itemCount = const itemCount = itemCountCheck.data === null ? undefined : itemCountCheck.data;
itemCountCheck.data?.totalRecordCount === null
? undefined
: itemCountCheck.data?.totalRecordCount;
const providerValue = useMemo(() => { const providerValue = useMemo(() => {
return { return {
@@ -37,14 +37,36 @@ const FILTERS = {
value: GenreListSort.NAME, value: GenreListSort.NAME,
}, },
], ],
subsonic: [
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.name', { postProcess: 'titleCase' }),
value: GenreListSort.NAME,
},
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.albumCount', { postProcess: 'titleCase' }),
value: GenreListSort.ALBUM_COUNT,
},
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.songCount', { postProcess: 'titleCase' }),
value: GenreListSort.SONG_COUNT,
},
],
}; };
interface GenreListHeaderFiltersProps { interface GenreListHeaderFiltersProps {
gridRef: MutableRefObject<VirtualInfiniteGridRef | null>; gridRef: MutableRefObject<VirtualInfiniteGridRef | null>;
itemCount: number | undefined;
tableRef: MutableRefObject<AgGridReactType | null>; tableRef: MutableRefObject<AgGridReactType | null>;
} }
export const GenreListHeaderFilters = ({ gridRef, tableRef }: GenreListHeaderFiltersProps) => { export const GenreListHeaderFilters = ({
gridRef,
tableRef,
itemCount,
}: GenreListHeaderFiltersProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { pageKey, customFilters } = useListContext(); const { pageKey, customFilters } = useListContext();
@@ -54,6 +76,7 @@ export const GenreListHeaderFilters = ({ gridRef, tableRef }: GenreListHeaderFil
const cq = useContainerQuery(); const cq = useContainerQuery();
const { handleRefreshTable, handleRefreshGrid } = useListFilterRefresh({ const { handleRefreshTable, handleRefreshGrid } = useListFilterRefresh({
itemCount,
itemType: LibraryItem.GENRE, itemType: LibraryItem.GENRE,
server, server,
}); });
@@ -34,6 +34,7 @@ export const GenreListHeader = ({ itemCount, gridRef, tableRef }: GenreListHeade
const { setFilter, setTablePagination } = useListStoreActions(); const { setFilter, setTablePagination } = useListStoreActions();
const { handleRefreshGrid, handleRefreshTable } = useListFilterRefresh({ const { handleRefreshGrid, handleRefreshTable } = useListFilterRefresh({
itemCount,
itemType: LibraryItem.GENRE, itemType: LibraryItem.GENRE,
server, server,
}); });
@@ -89,6 +90,7 @@ export const GenreListHeader = ({ itemCount, gridRef, tableRef }: GenreListHeade
<FilterBar> <FilterBar>
<GenreListHeaderFilters <GenreListHeaderFilters
gridRef={gridRef} gridRef={gridRef}
itemCount={itemCount}
tableRef={tableRef} tableRef={tableRef}
/> />
</FilterBar> </FilterBar>
+3 -2
View File
@@ -23,7 +23,6 @@ export const getPlaylistSongsById = async (args: {
id, id,
sortBy: SongListSort.ID, sortBy: SongListSort.ID,
sortOrder: SortOrder.ASC, sortOrder: SortOrder.ASC,
startIndex: 0,
...query, ...query,
}; };
@@ -139,7 +138,9 @@ export const getGenreSongsById = async (args: {
); );
data.items.push(...res!.items); data.items.push(...res!.items);
data.totalRecordCount += res!.totalRecordCount; if (data.totalRecordCount) {
data.totalRecordCount += res!.totalRecordCount || 0;
}
} }
return data; return data;
@@ -136,7 +136,6 @@ export const AddToPlaylistContextModal = ({
if (values.skipDuplicates) { if (values.skipDuplicates) {
const query = { const query = {
id: playlistId, id: playlistId,
startIndex: 0,
}; };
const queryKey = queryKeys.playlists.songList(server?.id || '', playlistId, query); const queryKey = queryKeys.playlists.songList(server?.id || '', playlistId, query);
@@ -151,7 +150,11 @@ export const AddToPlaylistContextModal = ({
server, server,
signal, signal,
}, },
query: { id: playlistId, startIndex: 0 }, query: {
id: playlistId,
sortBy: SongListSort.ID,
sortOrder: SortOrder.ASC,
},
}); });
}); });
@@ -32,6 +32,7 @@ export const CreatePlaylistForm = ({ onCancel }: CreatePlaylistFormProps) => {
}, },
comment: '', comment: '',
name: '', name: '',
public: false,
}, },
}); });
const [isSmartPlaylist, setIsSmartPlaylist] = useState(false); const [isSmartPlaylist, setIsSmartPlaylist] = useState(false);
@@ -86,7 +87,8 @@ export const CreatePlaylistForm = ({ onCancel }: CreatePlaylistFormProps) => {
); );
}); });
const isPublicDisplayed = server?.type === ServerType.NAVIDROME; const isPublicDisplayed =
server?.type === ServerType.NAVIDROME || server?.type === ServerType.SUBSONIC;
const isSubmitDisabled = !form.values.name || mutation.isLoading; const isSubmitDisabled = !form.values.name || mutation.isLoading;
return ( return (
@@ -115,7 +117,7 @@ export const CreatePlaylistForm = ({ onCancel }: CreatePlaylistFormProps) => {
context: 'public', context: 'public',
postProcess: 'titleCase', postProcess: 'titleCase',
})} })}
{...form.getInputProps('_custom.navidrome.public', { {...form.getInputProps('public', {
type: 'checkbox', type: 'checkbox',
})} })}
/> />
@@ -1,254 +0,0 @@
import { MutableRefObject, useMemo, useRef } from 'react';
import { ColDef, RowDoubleClickedEvent } from '@ag-grid-community/core';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { Box, Group } from '@mantine/core';
import { closeAllModals, openModal } from '@mantine/modals';
import { useTranslation } from 'react-i18next';
import { RiMoreFill } from 'react-icons/ri';
import { generatePath, useNavigate, useParams } from 'react-router';
import { Link } from 'react-router-dom';
import styled from 'styled-components';
import { useListStoreByKey } from '../../../store/list.store';
import { LibraryItem, QueueSong } from '/@/renderer/api/types';
import { Button, ConfirmModal, DropdownMenu, MotionGroup, toast } from '/@/renderer/components';
import { getColumnDefs, VirtualTable } from '/@/renderer/components/virtual-table';
import { useCurrentSongRowStyles } from '/@/renderer/components/virtual-table/hooks/use-current-song-row-styles';
import { useHandleTableContextMenu } from '/@/renderer/features/context-menu';
import {
PLAYLIST_SONG_CONTEXT_MENU_ITEMS,
SMART_PLAYLIST_SONG_CONTEXT_MENU_ITEMS,
} from '/@/renderer/features/context-menu/context-menu-items';
import { usePlayQueueAdd } from '/@/renderer/features/player';
import { openUpdatePlaylistModal } from '/@/renderer/features/playlists/components/update-playlist-form';
import { useDeletePlaylist } from '/@/renderer/features/playlists/mutations/delete-playlist-mutation';
import { usePlaylistDetail } from '/@/renderer/features/playlists/queries/playlist-detail-query';
import { usePlaylistSongListInfinite } from '/@/renderer/features/playlists/queries/playlist-song-list-query';
import { PlayButton, PLAY_TYPES } from '/@/renderer/features/shared';
import { AppRoute } from '/@/renderer/router/routes';
import { useCurrentServer } from '/@/renderer/store';
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
import { Play } from '/@/renderer/types';
const ContentContainer = styled.div`
position: relative;
display: flex;
flex-direction: column;
padding: 1rem 2rem 5rem;
overflow: hidden;
.ag-theme-alpine-dark {
--ag-header-background-color: rgb(0 0 0 / 0%) !important;
}
`;
interface PlaylistDetailContentProps {
tableRef: MutableRefObject<AgGridReactType | null>;
}
export const PlaylistDetailContent = ({ tableRef }: PlaylistDetailContentProps) => {
const { t } = useTranslation();
const navigate = useNavigate();
const { playlistId } = useParams() as { playlistId: string };
const { table } = useListStoreByKey({ key: LibraryItem.SONG });
const handlePlayQueueAdd = usePlayQueueAdd();
const server = useCurrentServer();
const detailQuery = usePlaylistDetail({ query: { id: playlistId }, serverId: server?.id });
const playButtonBehavior = usePlayButtonBehavior();
const playlistSongsQueryInfinite = usePlaylistSongListInfinite({
options: {
cacheTime: 0,
keepPreviousData: false,
},
query: {
id: playlistId,
limit: 50,
startIndex: 0,
},
serverId: server?.id,
});
const handleLoadMore = () => {
playlistSongsQueryInfinite.fetchNextPage();
};
const columnDefs: ColDef[] = useMemo(
() =>
getColumnDefs(table.columns).filter((c) => c.colId !== 'album' && c.colId !== 'artist'),
[table.columns],
);
const contextMenuItems = useMemo(() => {
if (detailQuery?.data?.rules) {
return SMART_PLAYLIST_SONG_CONTEXT_MENU_ITEMS;
}
return PLAYLIST_SONG_CONTEXT_MENU_ITEMS;
}, [detailQuery?.data?.rules]);
const handleContextMenu = useHandleTableContextMenu(LibraryItem.SONG, contextMenuItems, {
playlistId,
});
const playlistSongData = useMemo(
() => playlistSongsQueryInfinite.data?.pages.flatMap((p) => p?.items),
[playlistSongsQueryInfinite.data?.pages],
);
const deletePlaylistMutation = useDeletePlaylist({});
const handleDeletePlaylist = () => {
deletePlaylistMutation.mutate(
{ query: { id: playlistId }, serverId: server?.id },
{
onError: (err) => {
toast.error({
message: err.message,
title: t('error.genericError', { postProcess: 'sentenceCase' }),
});
},
onSuccess: () => {
closeAllModals();
navigate(AppRoute.PLAYLISTS);
},
},
);
};
const openDeletePlaylist = () => {
openModal({
children: (
<ConfirmModal
loading={deletePlaylistMutation.isLoading}
onConfirm={handleDeletePlaylist}
>
Are you sure you want to delete this playlist?
</ConfirmModal>
),
title: t('form.deletePlaylist.title', { postProcess: 'sentenceCase' }),
});
};
const handlePlay = (playType?: Play) => {
handlePlayQueueAdd?.({
byItemType: {
id: [playlistId],
type: LibraryItem.PLAYLIST,
},
playType: playType || playButtonBehavior,
});
};
const handleRowDoubleClick = (e: RowDoubleClickedEvent<QueueSong>) => {
if (!e.data) return;
handlePlayQueueAdd?.({
byItemType: {
id: [playlistId],
type: LibraryItem.PLAYLIST,
},
initialSongId: e.data.id,
playType: playButtonBehavior,
});
};
const { rowClassRules } = useCurrentSongRowStyles({ tableRef });
const loadMoreRef = useRef<HTMLButtonElement | null>(null);
return (
<ContentContainer>
<Group
p="1rem"
position="apart"
>
<Group>
<PlayButton onClick={() => handlePlay()} />
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button
compact
variant="subtle"
>
<RiMoreFill size={20} />
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
{PLAY_TYPES.filter((type) => type.play !== playButtonBehavior).map(
(type) => (
<DropdownMenu.Item
key={`playtype-${type.play}`}
onClick={() => handlePlay(type.play)}
>
{type.label}
</DropdownMenu.Item>
),
)}
<DropdownMenu.Divider />
<DropdownMenu.Item
onClick={() => {
if (!detailQuery.data || !server) return;
openUpdatePlaylistModal({ playlist: detailQuery.data, server });
}}
>
Edit playlist
</DropdownMenu.Item>
<DropdownMenu.Item onClick={openDeletePlaylist}>
Delete playlist
</DropdownMenu.Item>
</DropdownMenu.Dropdown>
</DropdownMenu>
<Button
compact
uppercase
component={Link}
to={generatePath(AppRoute.PLAYLISTS_DETAIL_SONGS, { playlistId })}
variant="subtle"
>
View full playlist
</Button>
</Group>
</Group>
<Box>
<VirtualTable
ref={tableRef}
autoFitColumns
autoHeight
deselectOnClickOutside
stickyHeader
suppressCellFocus
suppressHorizontalScroll
suppressLoadingOverlay
suppressRowDrag
columnDefs={columnDefs}
getRowId={(data) => {
// It's possible that there are duplicate song ids in a playlist
return `${data.data.id}-${data.data.pageIndex}`;
}}
rowClassRules={rowClassRules}
rowData={playlistSongData}
rowHeight={60}
rowSelection="multiple"
onCellContextMenu={handleContextMenu}
onRowDoubleClicked={handleRowDoubleClick}
/>
</Box>
<MotionGroup
p="2rem"
position="center"
onViewportEnter={handleLoadMore}
>
<Button
ref={loadMoreRef}
compact
disabled={!playlistSongsQueryInfinite.hasNextPage}
loading={playlistSongsQueryInfinite.isFetchingNextPage}
variant="subtle"
onClick={handleLoadMore}
>
{playlistSongsQueryInfinite.hasNextPage ? 'Load more' : 'End of playlist'}
</Button>
</MotionGroup>
</ContentContainer>
);
};
@@ -1,79 +0,0 @@
import { forwardRef, Fragment, Ref } from 'react';
import { Group, Stack } from '@mantine/core';
import { useParams } from 'react-router';
import { Badge, Text } from '/@/renderer/components';
import { usePlaylistDetail } from '/@/renderer/features/playlists/queries/playlist-detail-query';
import { LibraryHeader } from '/@/renderer/features/shared';
import { AppRoute } from '/@/renderer/router/routes';
import { formatDurationString } from '/@/renderer/utils';
import { LibraryItem } from '/@/renderer/api/types';
import { useCurrentServer } from '../../../store/auth.store';
interface PlaylistDetailHeaderProps {
background: string;
imagePlaceholderUrl?: string | null;
imageUrl?: string | null;
}
export const PlaylistDetailHeader = forwardRef(
(
{ background, imageUrl, imagePlaceholderUrl }: PlaylistDetailHeaderProps,
ref: Ref<HTMLDivElement>,
) => {
const { playlistId } = useParams() as { playlistId: string };
const server = useCurrentServer();
const detailQuery = usePlaylistDetail({ query: { id: playlistId }, serverId: server?.id });
const metadataItems = [
{
id: 'songCount',
secondary: false,
value: `${detailQuery?.data?.songCount || 0} songs`,
},
{
id: 'duration',
secondary: true,
value:
detailQuery?.data?.duration && formatDurationString(detailQuery.data.duration),
},
];
const isSmartPlaylist = detailQuery?.data?.rules;
return (
<Stack>
<LibraryHeader
ref={ref}
background={background}
imagePlaceholderUrl={imagePlaceholderUrl}
imageUrl={imageUrl}
item={{ route: AppRoute.PLAYLISTS, type: LibraryItem.PLAYLIST }}
title={detailQuery?.data?.name || ''}
>
<Stack>
<Group spacing="sm">
{metadataItems.map((item, index) => (
<Fragment key={`item-${item.id}-${index}`}>
{index > 0 && <Text $noSelect></Text>}
<Text $secondary={item.secondary}>{item.value}</Text>
</Fragment>
))}
{isSmartPlaylist && (
<>
<Text $noSelect></Text>
<Badge
radius="sm"
size="md"
>
Smart Playlist
</Badge>
</>
)}
</Group>
<Text lineClamp={3}>{detailQuery?.data?.description}</Text>
</Stack>
</LibraryHeader>
</Stack>
);
},
);
@@ -2,25 +2,15 @@ import type {
BodyScrollEvent, BodyScrollEvent,
ColDef, ColDef,
GridReadyEvent, GridReadyEvent,
IDatasource,
PaginationChangedEvent, PaginationChangedEvent,
RowDoubleClickedEvent, RowDoubleClickedEvent,
} from '@ag-grid-community/core'; } from '@ag-grid-community/core';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { useQueryClient } from '@tanstack/react-query';
import { AnimatePresence } from 'framer-motion'; import { AnimatePresence } from 'framer-motion';
import debounce from 'lodash/debounce'; import debounce from 'lodash/debounce';
import { MutableRefObject, useCallback, useMemo } from 'react'; import { MutableRefObject, useCallback, useMemo } from 'react';
import { useParams } from 'react-router'; import { useParams } from 'react-router';
import { api } from '/@/renderer/api'; import { LibraryItem, QueueSong, Song } from '/@/renderer/api/types';
import { queryKeys } from '/@/renderer/api/query-keys';
import {
LibraryItem,
PlaylistSongListQuery,
QueueSong,
SongListSort,
SortOrder,
} from '/@/renderer/api/types';
import { VirtualGridAutoSizerContainer } from '/@/renderer/components/virtual-grid'; import { VirtualGridAutoSizerContainer } from '/@/renderer/components/virtual-grid';
import { TablePagination, VirtualTable, getColumnDefs } from '/@/renderer/components/virtual-table'; import { TablePagination, VirtualTable, getColumnDefs } from '/@/renderer/components/virtual-table';
import { useCurrentSongRowStyles } from '/@/renderer/components/virtual-table/hooks/use-current-song-row-styles'; import { useCurrentSongRowStyles } from '/@/renderer/components/virtual-table/hooks/use-current-song-row-styles';
@@ -31,7 +21,7 @@ import {
} from '/@/renderer/features/context-menu/context-menu-items'; } from '/@/renderer/features/context-menu/context-menu-items';
import { usePlayQueueAdd } from '/@/renderer/features/player'; import { usePlayQueueAdd } from '/@/renderer/features/player';
import { usePlaylistDetail } from '/@/renderer/features/playlists/queries/playlist-detail-query'; import { usePlaylistDetail } from '/@/renderer/features/playlists/queries/playlist-detail-query';
import { usePlaylistSongList } from '/@/renderer/features/playlists/queries/playlist-song-list-query'; import { useAppFocus } from '/@/renderer/hooks';
import { import {
useCurrentServer, useCurrentServer,
useCurrentSong, useCurrentSong,
@@ -43,26 +33,19 @@ import {
} from '/@/renderer/store'; } from '/@/renderer/store';
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store'; import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
import { ListDisplayType } from '/@/renderer/types'; import { ListDisplayType } from '/@/renderer/types';
import { useAppFocus } from '/@/renderer/hooks';
interface PlaylistDetailContentProps { interface PlaylistDetailContentProps {
songs: Song[];
tableRef: MutableRefObject<AgGridReactType | null>; tableRef: MutableRefObject<AgGridReactType | null>;
} }
export const PlaylistDetailSongListContent = ({ tableRef }: PlaylistDetailContentProps) => { export const PlaylistDetailSongListContent = ({ songs, tableRef }: PlaylistDetailContentProps) => {
const { playlistId } = useParams() as { playlistId: string }; const { playlistId } = useParams() as { playlistId: string };
const queryClient = useQueryClient();
const status = useCurrentStatus(); const status = useCurrentStatus();
const isFocused = useAppFocus(); const isFocused = useAppFocus();
const currentSong = useCurrentSong(); const currentSong = useCurrentSong();
const server = useCurrentServer(); const server = useCurrentServer();
const page = usePlaylistDetailStore(); const page = usePlaylistDetailStore();
const filters: Partial<PlaylistSongListQuery> = useMemo(() => {
return {
sortBy: page?.table.id[playlistId]?.filter?.sortBy || SongListSort.ID,
sortOrder: page?.table.id[playlistId]?.filter?.sortOrder || SortOrder.ASC,
};
}, [page?.table.id, playlistId]);
const detailQuery = usePlaylistDetail({ query: { id: playlistId }, serverId: server?.id }); const detailQuery = usePlaylistDetail({ query: { id: playlistId }, serverId: server?.id });
@@ -82,15 +65,6 @@ export const PlaylistDetailSongListContent = ({ tableRef }: PlaylistDetailConten
const isPaginationEnabled = page.display === ListDisplayType.TABLE_PAGINATED; const isPaginationEnabled = page.display === ListDisplayType.TABLE_PAGINATED;
const checkPlaylistList = usePlaylistSongList({
query: {
id: playlistId,
limit: 1,
startIndex: 0,
},
serverId: server?.id,
});
const columnDefs: ColDef[] = useMemo( const columnDefs: ColDef[] = useMemo(
() => getColumnDefs(page.table.columns, false, 'generic'), () => getColumnDefs(page.table.columns, false, 'generic'),
[page.table.columns], [page.table.columns],
@@ -98,44 +72,9 @@ export const PlaylistDetailSongListContent = ({ tableRef }: PlaylistDetailConten
const onGridReady = useCallback( const onGridReady = useCallback(
(params: GridReadyEvent) => { (params: GridReadyEvent) => {
const dataSource: IDatasource = {
getRows: async (params) => {
const limit = params.endRow - params.startRow;
const startIndex = params.startRow;
const query: PlaylistSongListQuery = {
id: playlistId,
limit,
startIndex,
...filters,
};
const queryKey = queryKeys.playlists.songList(
server?.id || '',
playlistId,
query,
);
if (!server) return;
const songsRes = await queryClient.fetchQuery(queryKey, async ({ signal }) =>
api.controller.getPlaylistSongList({
apiClientProps: {
server,
signal,
},
query,
}),
);
params.successCallback(songsRes?.items || [], songsRes?.totalRecordCount || 0);
},
rowCount: undefined,
};
params.api.setDatasource(dataSource);
params.api?.ensureIndexVisible(pagination.scrollOffset, 'top'); params.api?.ensureIndexVisible(pagination.scrollOffset, 'top');
}, },
[filters, pagination.scrollOffset, playlistId, queryClient, server], [pagination.scrollOffset],
); );
const handleGridSizeChange = () => { const handleGridSizeChange = () => {
@@ -249,13 +188,13 @@ export const PlaylistDetailSongListContent = ({ tableRef }: PlaylistDetailConten
status, status,
}} }}
getRowId={(data) => data.data.uniqueId} getRowId={(data) => data.data.uniqueId}
infiniteInitialRowCount={checkPlaylistList.data?.totalRecordCount || 100}
pagination={isPaginationEnabled} pagination={isPaginationEnabled}
paginationAutoPageSize={isPaginationEnabled} paginationAutoPageSize={isPaginationEnabled}
paginationPageSize={pagination.itemsPerPage || 100} paginationPageSize={pagination.itemsPerPage || 100}
rowClassRules={rowClassRules} rowClassRules={rowClassRules}
rowData={songs}
rowHeight={page.table.rowHeight || 40} rowHeight={page.table.rowHeight || 40}
rowModelType="infinite" rowModelType="clientSide"
onBodyScrollEnd={handleScroll} onBodyScrollEnd={handleScroll}
onCellContextMenu={handleContextMenu} onCellContextMenu={handleContextMenu}
onColumnMoved={handleColumnChange} onColumnMoved={handleColumnChange}
@@ -1,53 +1,50 @@
import { useCallback, ChangeEvent, MutableRefObject, MouseEvent } from 'react';
import { IDatasource } from '@ag-grid-community/core';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { Divider, Flex, Group, Stack } from '@mantine/core'; import { Divider, Flex, Group, Stack } from '@mantine/core';
import { closeAllModals, openModal } from '@mantine/modals'; import { closeAllModals, openModal } from '@mantine/modals';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { ChangeEvent, MouseEvent, MutableRefObject, useCallback } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
RiMoreFill,
RiSettings3Fill,
RiPlayFill,
RiAddCircleFill,
RiAddBoxFill, RiAddBoxFill,
RiEditFill, RiAddCircleFill,
RiDeleteBinFill, RiDeleteBinFill,
RiEditFill,
RiMoreFill,
RiPlayFill,
RiRefreshLine, RiRefreshLine,
RiSettings3Fill,
} from 'react-icons/ri'; } from 'react-icons/ri';
import { api } from '/@/renderer/api'; import { useNavigate, useParams } from 'react-router';
import i18n from '/@/i18n/i18n';
import { queryKeys } from '/@/renderer/api/query-keys'; import { queryKeys } from '/@/renderer/api/query-keys';
import { LibraryItem, PlaylistSongListQuery, SongListSort, SortOrder } from '/@/renderer/api/types'; import { LibraryItem, PlaylistSongListQuery, SongListSort, SortOrder } from '/@/renderer/api/types';
import { import {
DropdownMenu,
Button, Button,
Slider, ConfirmModal,
DropdownMenu,
MultiSelect, MultiSelect,
Slider,
Switch, Switch,
Text, Text,
ConfirmModal,
toast, toast,
} from '/@/renderer/components'; } from '/@/renderer/components';
import { SONG_TABLE_COLUMNS } from '/@/renderer/components/virtual-table';
import { usePlayQueueAdd } from '/@/renderer/features/player'; import { usePlayQueueAdd } from '/@/renderer/features/player';
import { openUpdatePlaylistModal } from '/@/renderer/features/playlists/components/update-playlist-form';
import { useDeletePlaylist } from '/@/renderer/features/playlists/mutations/delete-playlist-mutation';
import { usePlaylistDetail } from '/@/renderer/features/playlists/queries/playlist-detail-query';
import { OrderToggleButton } from '/@/renderer/features/shared';
import { useContainerQuery } from '/@/renderer/hooks'; import { useContainerQuery } from '/@/renderer/hooks';
import { AppRoute } from '/@/renderer/router/routes';
import { import {
useCurrentServer, useCurrentServer,
SongListFilter,
usePlaylistDetailStore, usePlaylistDetailStore,
useSetPlaylistDetailFilters, useSetPlaylistDetailFilters,
useSetPlaylistDetailTable, useSetPlaylistDetailTable,
useSetPlaylistStore, useSetPlaylistStore,
useSetPlaylistTablePagination, useSetPlaylistTablePagination,
} from '/@/renderer/store'; } from '/@/renderer/store';
import { ListDisplayType, ServerType, Play, TableColumn } from '/@/renderer/types'; import { ListDisplayType, Play, ServerType, TableColumn } from '/@/renderer/types';
import { usePlaylistDetail } from '/@/renderer/features/playlists/queries/playlist-detail-query';
import { useParams, useNavigate } from 'react-router';
import { SONG_TABLE_COLUMNS } from '/@/renderer/components/virtual-table';
import { openUpdatePlaylistModal } from '/@/renderer/features/playlists/components/update-playlist-form';
import { useDeletePlaylist } from '/@/renderer/features/playlists/mutations/delete-playlist-mutation';
import { AppRoute } from '/@/renderer/router/routes';
import { OrderToggleButton } from '/@/renderer/features/shared';
import i18n from '/@/i18n/i18n';
const FILTERS = { const FILTERS = {
jellyfin: [ jellyfin: [
@@ -150,7 +147,7 @@ const FILTERS = {
}, },
{ {
defaultOrder: SortOrder.ASC, defaultOrder: SortOrder.ASC,
name: i18n.t('filter.playCount', { postProcess: 'titleCase' }), name: i18n.t('filter.genre', { postProcess: 'titleCase' }),
value: SongListSort.GENRE, value: SongListSort.GENRE,
}, },
{ {
@@ -184,6 +181,68 @@ const FILTERS = {
value: SongListSort.YEAR, value: SongListSort.YEAR,
}, },
], ],
subsonic: [
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.id', { postProcess: 'titleCase' }),
value: SongListSort.ID,
},
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.album', { postProcess: 'titleCase' }),
value: SongListSort.ALBUM,
},
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.albumArtist', { postProcess: 'titleCase' }),
value: SongListSort.ALBUM_ARTIST,
},
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.artist', { postProcess: 'titleCase' }),
value: SongListSort.ARTIST,
},
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.duration', { postProcess: 'titleCase' }),
value: SongListSort.DURATION,
},
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.isFavorited', { postProcess: 'titleCase' }),
value: SongListSort.FAVORITED,
},
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.genre', { postProcess: 'titleCase' }),
value: SongListSort.GENRE,
},
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.name', { postProcess: 'titleCase' }),
value: SongListSort.NAME,
},
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.rating', { postProcess: 'titleCase' }),
value: SongListSort.RATING,
},
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.recentlyAdded', { postProcess: 'titleCase' }),
value: SongListSort.RECENTLY_ADDED,
},
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.recentlyPlayed', { postProcess: 'titleCase' }),
value: SongListSort.RECENTLY_PLAYED,
},
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.releaseYear', { postProcess: 'titleCase' }),
value: SongListSort.YEAR,
},
],
}; };
interface PlaylistDetailSongListHeaderFiltersProps { interface PlaylistDetailSongListHeaderFiltersProps {
@@ -228,56 +287,18 @@ export const PlaylistDetailSongListHeaderFilters = ({
setTable({ rowHeight: e }); setTable({ rowHeight: e });
}; };
const handleFilterChange = useCallback( const handleFilterChange = useCallback(async () => {
async (filters: SongListFilter) => { tableRef.current?.api.redrawRows();
const dataSource: IDatasource = { tableRef.current?.api.ensureIndexVisible(0, 'top');
getRows: async (params) => {
const limit = params.endRow - params.startRow;
const startIndex = params.startRow;
const queryKey = queryKeys.playlists.songList(server?.id || '', playlistId, { if (page.display === ListDisplayType.TABLE_PAGINATED) {
id: playlistId, setPagination({ data: { currentPage: 0 } });
limit, }
startIndex, }, [tableRef, page.display, setPagination]);
...filters,
});
const songsRes = await queryClient.fetchQuery(
queryKey,
async ({ signal }) =>
api.controller.getPlaylistSongList({
apiClientProps: {
server,
signal,
},
query: {
id: playlistId,
limit,
startIndex,
...filters,
},
}),
{ cacheTime: 1000 * 60 * 1 },
);
params.successCallback(songsRes?.items || [], songsRes?.totalRecordCount || 0);
},
rowCount: undefined,
};
tableRef.current?.api.setDatasource(dataSource);
tableRef.current?.api.purgeInfiniteCache();
tableRef.current?.api.ensureIndexVisible(0, 'top');
if (page.display === ListDisplayType.TABLE_PAGINATED) {
setPagination({ data: { currentPage: 0 } });
}
},
[tableRef, page.display, server, playlistId, queryClient, setPagination],
);
const handleRefresh = () => { const handleRefresh = () => {
queryClient.invalidateQueries(queryKeys.albums.list(server?.id || '')); queryClient.invalidateQueries(queryKeys.albums.list(server?.id || ''));
handleFilterChange({ ...page?.table.id[playlistId].filter, ...filters }); handleFilterChange();
}; };
const handleSetSortBy = useCallback( const handleSetSortBy = useCallback(
@@ -288,20 +309,20 @@ export const PlaylistDetailSongListHeaderFilters = ({
(f) => f.value === e.currentTarget.value, (f) => f.value === e.currentTarget.value,
)?.defaultOrder; )?.defaultOrder;
const updatedFilters = setFilter(playlistId, { setFilter(playlistId, {
sortBy: e.currentTarget.value as SongListSort, sortBy: e.currentTarget.value as SongListSort,
sortOrder: sortOrder || SortOrder.ASC, sortOrder: sortOrder || SortOrder.ASC,
}); });
handleFilterChange(updatedFilters); handleFilterChange();
}, },
[handleFilterChange, playlistId, server?.type, setFilter], [handleFilterChange, playlistId, server?.type, setFilter],
); );
const handleToggleSortOrder = useCallback(() => { const handleToggleSortOrder = useCallback(() => {
const newSortOrder = filters.sortOrder === SortOrder.ASC ? SortOrder.DESC : SortOrder.ASC; const newSortOrder = filters.sortOrder === SortOrder.ASC ? SortOrder.DESC : SortOrder.ASC;
const updatedFilters = setFilter(playlistId, { sortOrder: newSortOrder }); setFilter(playlistId, { sortOrder: newSortOrder });
handleFilterChange(updatedFilters); handleFilterChange();
}, [filters.sortOrder, handleFilterChange, playlistId, setFilter]); }, [filters.sortOrder, handleFilterChange, playlistId, setFilter]);
const handleSetViewType = useCallback( const handleSetViewType = useCallback(
@@ -1,6 +1,6 @@
import { MutableRefObject } from 'react'; import { MutableRefObject } from 'react';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { Stack } from '@mantine/core'; import { Flex, Stack } from '@mantine/core';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router'; import { useParams } from 'react-router';
import { LibraryItem } from '/@/renderer/api/types'; import { LibraryItem } from '/@/renderer/api/types';
@@ -45,23 +45,30 @@ export const PlaylistDetailSongListHeader = ({
return ( return (
<Stack spacing={0}> <Stack spacing={0}>
<PageHeader backgroundColor="var(--titlebar-bg)"> <PageHeader backgroundColor="var(--titlebar-bg)">
<LibraryHeaderBar> <Flex
<LibraryHeaderBar.PlayButton onClick={() => handlePlay(playButtonBehavior)} /> justify="space-between"
<LibraryHeaderBar.Title>{detailQuery?.data?.name}</LibraryHeaderBar.Title> w="100%"
<Paper >
fw="600" <LibraryHeaderBar>
px="1rem" <LibraryHeaderBar.PlayButton
py="0.3rem" onClick={() => handlePlay(playButtonBehavior)}
radius="sm" />
> <LibraryHeaderBar.Title>{detailQuery?.data?.name}</LibraryHeaderBar.Title>
{itemCount === null || itemCount === undefined ? ( <Paper
<SpinnerIcon /> fw="600"
) : ( px="1rem"
itemCount py="0.3rem"
)} radius="sm"
</Paper> >
{isSmartPlaylist && <Badge size="lg">{t('entity.smartPlaylist')}</Badge>} {itemCount === null || itemCount === undefined ? (
</LibraryHeaderBar> <SpinnerIcon />
) : (
itemCount
)}
</Paper>
{isSmartPlaylist && <Badge size="lg">{t('entity.smartPlaylist')}</Badge>}
</LibraryHeaderBar>
</Flex>
</PageHeader> </PageHeader>
<Paper p="1rem"> <Paper p="1rem">
<PlaylistDetailSongListHeaderFilters <PlaylistDetailSongListHeaderFilters
@@ -1,5 +1,5 @@
import { QueryKey, useQueryClient } from '@tanstack/react-query';
import { MutableRefObject, useCallback, useMemo } from 'react'; import { MutableRefObject, useCallback, useMemo } from 'react';
import { QueryKey, useQueryClient } from '@tanstack/react-query';
import AutoSizer, { Size } from 'react-virtualized-auto-sizer'; import AutoSizer, { Size } from 'react-virtualized-auto-sizer';
import { ListOnScrollProps } from 'react-window'; import { ListOnScrollProps } from 'react-window';
import { useListContext } from '../../../context/list-context'; import { useListContext } from '../../../context/list-context';
@@ -22,7 +22,7 @@ import {
import { usePlayQueueAdd } from '/@/renderer/features/player'; import { usePlayQueueAdd } from '/@/renderer/features/player';
import { useCreateFavorite, useDeleteFavorite } from '/@/renderer/features/shared'; import { useCreateFavorite, useDeleteFavorite } from '/@/renderer/features/shared';
import { AppRoute } from '/@/renderer/router/routes'; import { AppRoute } from '/@/renderer/router/routes';
import { useCurrentServer, useGeneralSettings, useListStoreByKey } from '/@/renderer/store'; import { useCurrentServer, useListStoreByKey } from '/@/renderer/store';
import { CardRow, ListDisplayType } from '/@/renderer/types'; import { CardRow, ListDisplayType } from '/@/renderer/types';
interface PlaylistListGridViewProps { interface PlaylistListGridViewProps {
@@ -37,7 +37,6 @@ export const PlaylistListGridView = ({ gridRef, itemCount }: PlaylistListGridVie
const handlePlayQueueAdd = usePlayQueueAdd(); const handlePlayQueueAdd = usePlayQueueAdd();
const { display, grid, filter } = useListStoreByKey({ key: pageKey }); const { display, grid, filter } = useListStoreByKey({ key: pageKey });
const { setGrid } = useListStoreActions(); const { setGrid } = useListStoreActions();
const { defaultFullPlaylist } = useGeneralSettings();
const createFavoriteMutation = useCreateFavorite({}); const createFavoriteMutation = useCreateFavorite({});
const deleteFavoriteMutation = useDeleteFavorite({}); const deleteFavoriteMutation = useDeleteFavorite({});
@@ -68,9 +67,7 @@ export const PlaylistListGridView = ({ gridRef, itemCount }: PlaylistListGridVie
}; };
const cardRows = useMemo(() => { const cardRows = useMemo(() => {
const rows: CardRow<Playlist>[] = defaultFullPlaylist const rows: CardRow<Playlist>[] = [PLAYLIST_CARD_ROWS.name];
? [PLAYLIST_CARD_ROWS.nameFull]
: [PLAYLIST_CARD_ROWS.name];
switch (filter.sortBy) { switch (filter.sortBy) {
case PlaylistListSort.DURATION: case PlaylistListSort.DURATION:
@@ -93,7 +90,7 @@ export const PlaylistListGridView = ({ gridRef, itemCount }: PlaylistListGridVie
} }
return rows; return rows;
}, [defaultFullPlaylist, filter.sortBy]); }, [filter.sortBy]);
const handleGridScroll = useCallback( const handleGridScroll = useCallback(
(e: ListOnScrollProps) => { (e: ListOnScrollProps) => {
@@ -187,9 +184,7 @@ export const PlaylistListGridView = ({ gridRef, itemCount }: PlaylistListGridVie
loading={itemCount === undefined || itemCount === null} loading={itemCount === undefined || itemCount === null}
minimumBatchSize={40} minimumBatchSize={40}
route={{ route={{
route: defaultFullPlaylist route: AppRoute.PLAYLISTS_DETAIL_SONGS,
? AppRoute.PLAYLISTS_DETAIL_SONGS
: AppRoute.PLAYLISTS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'playlistId' }], slugs: [{ idProperty: 'id', slugProperty: 'playlistId' }],
}} }}
width={width} width={width}
@@ -69,6 +69,38 @@ const FILTERS = {
value: PlaylistListSort.UPDATED_AT, value: PlaylistListSort.UPDATED_AT,
}, },
], ],
subsonic: [
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.duration', { postProcess: 'titleCase' }),
value: PlaylistListSort.DURATION,
},
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.name', { postProcess: 'titleCase' }),
value: PlaylistListSort.NAME,
},
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.owner', { postProcess: 'titleCase' }),
value: PlaylistListSort.OWNER,
},
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.isPublic', { postProcess: 'titleCase' }),
value: PlaylistListSort.PUBLIC,
},
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.songCount', { postProcess: 'titleCase' }),
value: PlaylistListSort.SONG_COUNT,
},
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.recentlyUpdated', { postProcess: 'titleCase' }),
value: PlaylistListSort.UPDATED_AT,
},
],
}; };
interface PlaylistListHeaderFiltersProps { interface PlaylistListHeaderFiltersProps {
@@ -44,6 +44,7 @@ export const PlaylistListHeader = ({ itemCount, tableRef, gridRef }: PlaylistLis
}; };
const { handleRefreshGrid, handleRefreshTable } = useListFilterRefresh({ const { handleRefreshGrid, handleRefreshTable } = useListFilterRefresh({
itemCount,
itemType: LibraryItem.PLAYLIST, itemType: LibraryItem.PLAYLIST,
server, server,
}); });
@@ -8,7 +8,7 @@ import { VirtualTable } from '/@/renderer/components/virtual-table';
import { useVirtualTable } from '/@/renderer/components/virtual-table/hooks/use-virtual-table'; import { useVirtualTable } from '/@/renderer/components/virtual-table/hooks/use-virtual-table';
import { PLAYLIST_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items'; import { PLAYLIST_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items';
import { AppRoute } from '/@/renderer/router/routes'; import { AppRoute } from '/@/renderer/router/routes';
import { useCurrentServer, useGeneralSettings } from '/@/renderer/store'; import { useCurrentServer } from '/@/renderer/store';
interface PlaylistListTableViewProps { interface PlaylistListTableViewProps {
itemCount?: number; itemCount?: number;
@@ -18,16 +18,11 @@ interface PlaylistListTableViewProps {
export const PlaylistListTableView = ({ tableRef, itemCount }: PlaylistListTableViewProps) => { export const PlaylistListTableView = ({ tableRef, itemCount }: PlaylistListTableViewProps) => {
const navigate = useNavigate(); const navigate = useNavigate();
const server = useCurrentServer(); const server = useCurrentServer();
const { defaultFullPlaylist } = useGeneralSettings();
const pageKey = 'playlist'; const pageKey = 'playlist';
const handleRowDoubleClick = (e: RowDoubleClickedEvent) => { const handleRowDoubleClick = (e: RowDoubleClickedEvent) => {
if (!e.data) return; if (!e.data) return;
if (defaultFullPlaylist) { navigate(generatePath(AppRoute.PLAYLISTS_DETAIL_SONGS, { playlistId: e.data.id }));
navigate(generatePath(AppRoute.PLAYLISTS_DETAIL_SONGS, { playlistId: e.data.id }));
} else {
navigate(generatePath(AppRoute.PLAYLISTS_DETAIL, { playlistId: e.data.id }));
}
}; };
const tableProps = useVirtualTable({ const tableProps = useVirtualTable({
@@ -1,9 +1,9 @@
import { useQuery, useInfiniteQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys'; import { queryKeys } from '/@/renderer/api/query-keys';
import type { PlaylistSongListQuery, PlaylistSongListResponse } from '/@/renderer/api/types'; import type { PlaylistSongListQuery } from '/@/renderer/api/types';
import type { QueryHookArgs } from '/@/renderer/lib/react-query'; import type { QueryHookArgs } from '/@/renderer/lib/react-query';
import { getServerById } from '/@/renderer/store'; import { getServerById } from '/@/renderer/store';
import { api } from '/@/renderer/api';
export const usePlaylistSongList = (args: QueryHookArgs<PlaylistSongListQuery>) => { export const usePlaylistSongList = (args: QueryHookArgs<PlaylistSongListQuery>) => {
const { options, query, serverId } = args || {}; const { options, query, serverId } = args || {};
@@ -23,31 +23,31 @@ export const usePlaylistSongList = (args: QueryHookArgs<PlaylistSongListQuery>)
}); });
}; };
export const usePlaylistSongListInfinite = (args: QueryHookArgs<PlaylistSongListQuery>) => { // export const usePlaylistSongListInfinite = (args: QueryHookArgs<PlaylistSongListQuery>) => {
const { options, query, serverId } = args || {}; // const { options, query, serverId } = args || {};
const server = getServerById(serverId); // const server = getServerById(serverId);
return useInfiniteQuery({ // return useInfiniteQuery({
enabled: !!server, // enabled: !!server,
getNextPageParam: (lastPage: PlaylistSongListResponse | undefined, pages) => { // getNextPageParam: (lastPage: PlaylistSongListResponse | undefined, pages) => {
if (!lastPage?.items) return undefined; // if (!lastPage?.items) return undefined;
if (lastPage?.items?.length >= (query?.limit || 50)) { // if (lastPage?.items?.length >= (query?.limit || 50)) {
return pages?.length; // return pages?.length;
} // }
return undefined; // return undefined;
}, // },
queryFn: ({ pageParam = 0, signal }) => { // queryFn: ({ pageParam = 0, signal }) => {
return api.controller.getPlaylistSongList({ // return api.controller.getPlaylistSongList({
apiClientProps: { server, signal }, // apiClientProps: { server, signal },
query: { // query: {
...query, // ...query,
limit: query.limit || 50, // limit: query.limit || 50,
startIndex: pageParam * (query.limit || 50), // startIndex: pageParam * (query.limit || 50),
}, // },
}); // });
}, // },
queryKey: queryKeys.playlists.detailSongList(server?.id || '', query.id, query), // queryKey: queryKeys.playlists.detailSongList(server?.id || '', query.id, query),
...options, // ...options,
}); // });
}; // };
@@ -1,77 +0,0 @@
import { useRef } from 'react';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { useParams } from 'react-router';
import { LibraryItem } from '/@/renderer/api/types';
import { NativeScrollArea, Spinner } from '/@/renderer/components';
import { usePlayQueueAdd } from '/@/renderer/features/player';
import { PlaylistDetailContent } from '/@/renderer/features/playlists/components/playlist-detail-content';
import { PlaylistDetailHeader } from '/@/renderer/features/playlists/components/playlist-detail-header';
import { usePlaylistDetail } from '/@/renderer/features/playlists/queries/playlist-detail-query';
import { AnimatedPage, LibraryHeaderBar } from '/@/renderer/features/shared';
import { useFastAverageColor } from '/@/renderer/hooks';
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
import { useCurrentServer } from '../../../store/auth.store';
const PlaylistDetailRoute = () => {
const tableRef = useRef<AgGridReactType | null>(null);
const scrollAreaRef = useRef<HTMLDivElement>(null);
const headerRef = useRef<HTMLDivElement>(null);
const { playlistId } = useParams() as { playlistId: string };
const server = useCurrentServer();
const detailQuery = usePlaylistDetail({ query: { id: playlistId }, serverId: server?.id });
const { color: background, colorId } = useFastAverageColor({
algorithm: 'sqrt',
id: playlistId,
src: detailQuery?.data?.imageUrl,
srcLoaded: !detailQuery?.isLoading,
});
const handlePlayQueueAdd = usePlayQueueAdd();
const playButtonBehavior = usePlayButtonBehavior();
const handlePlay = () => {
handlePlayQueueAdd?.({
byItemType: {
id: [playlistId],
type: LibraryItem.PLAYLIST,
},
playType: playButtonBehavior,
});
};
if (!background || colorId !== playlistId) {
return <Spinner container />;
}
return (
<AnimatedPage key={`playlist-detail-${playlistId}`}>
<NativeScrollArea
ref={scrollAreaRef}
pageHeaderProps={{
backgroundColor: background,
children: (
<LibraryHeaderBar>
<LibraryHeaderBar.PlayButton onClick={handlePlay} />
<LibraryHeaderBar.Title>
{detailQuery?.data?.name}
</LibraryHeaderBar.Title>
</LibraryHeaderBar>
),
offset: 200,
target: headerRef,
}}
>
<PlaylistDetailHeader
ref={headerRef}
background={background}
imagePlaceholderUrl={detailQuery?.data?.imageUrl}
imageUrl={detailQuery?.data?.imageUrl}
/>
<PlaylistDetailContent tableRef={tableRef} />
</NativeScrollArea>
</AnimatedPage>
);
};
export default PlaylistDetailRoute;
@@ -139,28 +139,20 @@ const PlaylistDetailSongListRoute = () => {
const page = usePlaylistDetailStore(); const page = usePlaylistDetailStore();
const filters: Partial<PlaylistSongListQuery> = { const filters: Partial<PlaylistSongListQuery> = {
sortBy: page?.table.id[playlistId]?.filter?.sortBy || SongListSort.ID, sortBy: page?.table.id[playlistId]?.filter?.sortBy,
sortOrder: page?.table.id[playlistId]?.filter?.sortOrder || SortOrder.ASC, sortOrder: page?.table.id[playlistId]?.filter?.sortOrder,
}; };
const itemCountCheck = usePlaylistSongList({ const { data } = usePlaylistSongList({
options: {
cacheTime: 1000 * 60 * 60 * 2,
staleTime: 1000 * 60 * 60 * 2,
},
query: { query: {
id: playlistId, id: playlistId,
limit: 1, sortBy: filters.sortBy || SongListSort.ID,
startIndex: 0, sortOrder: filters.sortOrder || SortOrder.ASC,
...filters,
}, },
serverId: server?.id, serverId: server?.id,
}); });
const itemCount = const itemCount = data?.items.length;
itemCountCheck.data?.totalRecordCount === null
? undefined
: itemCountCheck.data?.totalRecordCount;
return ( return (
<AnimatedPage key={`playlist-detail-songList-${playlistId}`}> <AnimatedPage key={`playlist-detail-songList-${playlistId}`}>
@@ -206,7 +198,10 @@ const PlaylistDetailSongListRoute = () => {
</Paper> </Paper>
</Box> </Box>
)} )}
<PlaylistDetailSongListContent tableRef={tableRef} /> <PlaylistDetailSongListContent
songs={data?.items || []}
tableRef={tableRef}
/>
</AnimatedPage> </AnimatedPage>
); );
}; };
@@ -17,7 +17,7 @@ const localSettings = isElectron() ? window.electron.localSettings : null;
const SERVER_TYPES = [ const SERVER_TYPES = [
{ label: 'Jellyfin', value: ServerType.JELLYFIN }, { label: 'Jellyfin', value: ServerType.JELLYFIN },
{ label: 'Navidrome', value: ServerType.NAVIDROME }, { label: 'Navidrome', value: ServerType.NAVIDROME },
// { label: 'Subsonic', value: ServerType.SUBSONIC }, { label: 'Subsonic', value: ServerType.SUBSONIC },
]; ];
interface AddServerFormProps { interface AddServerFormProps {
@@ -246,28 +246,6 @@ export const ControlSettings = () => {
isHidden: !isElectron(), isHidden: !isElectron(),
title: t('setting.savePlayQueue', { postProcess: 'sentenceCase' }), title: t('setting.savePlayQueue', { postProcess: 'sentenceCase' }),
}, },
{
control: (
<Switch
aria-label="Go to playlist songs page by default"
defaultChecked={settings.defaultFullPlaylist}
onChange={(e) =>
setSettings({
general: {
...settings,
defaultFullPlaylist: e.currentTarget.checked,
},
})
}
/>
),
description: t('setting.skipPlaylistPage', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: false,
title: t('setting.skipPlaylistPage', { postProcess: 'sentenceCase' }),
},
]; ];
return <SettingsSection options={controlOptions} />; return <SettingsSection options={controlOptions} />;
@@ -326,7 +326,9 @@ export const MpvSettings = () => {
<NumberInput <NumberInput
defaultValue={settings.mpvProperties.replayGainFallbackDB} defaultValue={settings.mpvProperties.replayGainFallbackDB}
width={75} width={75}
onBlur={(e) => handleSetMpvProperty('replayGainFallbackDB', e)} onBlur={(e) =>
handleSetMpvProperty('replayGainFallbackDB', Number(e.currentTarget.value))
}
/> />
), ),
description: t('setting.replayGainFallback', { description: t('setting.replayGainFallback', {
@@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next';
import { RiAddBoxFill, RiAddCircleFill, RiPlayFill } from 'react-icons/ri'; import { RiAddBoxFill, RiAddCircleFill, RiPlayFill } from 'react-icons/ri';
import { generatePath } from 'react-router'; import { generatePath } from 'react-router';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { LibraryItem } from '/@/renderer/api/types'; import { LibraryItem, PlaylistListSort, SortOrder } from '/@/renderer/api/types';
import { Button, Text } from '/@/renderer/components'; import { Button, Text } from '/@/renderer/components';
import { usePlayQueueAdd } from '/@/renderer/features/player'; import { usePlayQueueAdd } from '/@/renderer/features/player';
import { usePlaylistList } from '/@/renderer/features/playlists'; import { usePlaylistList } from '/@/renderer/features/playlists';
@@ -14,20 +14,12 @@ import { Play } from '/@/renderer/types';
import AutoSizer from 'react-virtualized-auto-sizer'; import AutoSizer from 'react-virtualized-auto-sizer';
import { FixedSizeList, ListChildComponentProps } from 'react-window'; import { FixedSizeList, ListChildComponentProps } from 'react-window';
import { useHideScrollbar } from '/@/renderer/hooks'; import { useHideScrollbar } from '/@/renderer/hooks';
import { useGeneralSettings } from '/@/renderer/store'; import { useCurrentServer } from '/@/renderer/store';
interface SidebarPlaylistListProps {
data: ReturnType<typeof usePlaylistList>['data'];
}
const PlaylistRow = ({ index, data, style }: ListChildComponentProps) => { const PlaylistRow = ({ index, data, style }: ListChildComponentProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const path = data?.items[index].id const path = data?.items[index].id
? data.defaultFullPlaylist ? generatePath(AppRoute.PLAYLISTS_DETAIL_SONGS, { playlistId: data.items[index].id })
? generatePath(AppRoute.PLAYLISTS_DETAIL_SONGS, { playlistId: data.items[index].id })
: generatePath(AppRoute.PLAYLISTS_DETAIL, {
playlistId: data?.items[index].id,
})
: undefined; : undefined;
return ( return (
@@ -121,10 +113,19 @@ const PlaylistRow = ({ index, data, style }: ListChildComponentProps) => {
); );
}; };
export const SidebarPlaylistList = ({ data }: SidebarPlaylistListProps) => { export const SidebarPlaylistList = () => {
const { isScrollbarHidden, hideScrollbarElementProps } = useHideScrollbar(0); const { isScrollbarHidden, hideScrollbarElementProps } = useHideScrollbar(0);
const handlePlayQueueAdd = usePlayQueueAdd(); const handlePlayQueueAdd = usePlayQueueAdd();
const { defaultFullPlaylist } = useGeneralSettings(); const server = useCurrentServer();
const playlistsQuery = usePlaylistList({
query: {
sortBy: PlaylistListSort.NAME,
sortOrder: SortOrder.ASC,
startIndex: 0,
},
serverId: server?.id,
});
const [rect, setRect] = useState({ const [rect, setRect] = useState({
height: 0, height: 0,
@@ -148,11 +149,10 @@ export const SidebarPlaylistList = ({ data }: SidebarPlaylistListProps) => {
const memoizedItemData = useMemo(() => { const memoizedItemData = useMemo(() => {
return { return {
defaultFullPlaylist,
handlePlay: handlePlayPlaylist, handlePlay: handlePlayPlaylist,
items: data?.items, items: playlistsQuery?.data?.items,
}; };
}, [data?.items, defaultFullPlaylist, handlePlayPlaylist]); }, [playlistsQuery?.data?.items, handlePlayPlaylist]);
return ( return (
<Flex <Flex
@@ -168,7 +168,7 @@ export const SidebarPlaylistList = ({ data }: SidebarPlaylistListProps) => {
: 'overlay-scrollbar' : 'overlay-scrollbar'
} }
height={debounced.height} height={debounced.height}
itemCount={data?.items?.length || 0} itemCount={playlistsQuery?.data?.items?.length || 0}
itemData={memoizedItemData} itemData={memoizedItemData}
itemSize={25} itemSize={25}
overscanCount={20} overscanCount={20}
@@ -1,7 +1,7 @@
import { MouseEvent, useMemo } from 'react';
import { Box, Center, Divider, Group, Stack } from '@mantine/core'; import { Box, Center, Divider, Group, Stack } from '@mantine/core';
import { closeAllModals, openModal } from '@mantine/modals'; import { closeAllModals, openModal } from '@mantine/modals';
import { AnimatePresence, motion } from 'framer-motion'; import { AnimatePresence, motion } from 'framer-motion';
import { MouseEvent, useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { RiAddFill, RiArrowDownSLine, RiDiscLine, RiListUnordered } from 'react-icons/ri'; import { RiAddFill, RiArrowDownSLine, RiDiscLine, RiListUnordered } from 'react-icons/ri';
import { Link, useLocation } from 'react-router-dom'; import { Link, useLocation } from 'react-router-dom';
@@ -11,9 +11,9 @@ import {
useGeneralSettings, useGeneralSettings,
useWindowSettings, useWindowSettings,
} from '../../../store/settings.store'; } from '../../../store/settings.store';
import { PlaylistListSort, ServerType, SortOrder } from '/@/renderer/api/types'; import { ServerType } from '/@/renderer/api/types';
import { Button, MotionStack, Spinner, Tooltip } from '/@/renderer/components'; import { Button, MotionStack, Tooltip } from '/@/renderer/components';
import { CreatePlaylistForm, usePlaylistList } from '/@/renderer/features/playlists'; import { CreatePlaylistForm } from '/@/renderer/features/playlists';
import { ActionBar } from '/@/renderer/features/sidebar/components/action-bar'; import { ActionBar } from '/@/renderer/features/sidebar/components/action-bar';
import { SidebarIcon } from '/@/renderer/features/sidebar/components/sidebar-icon'; import { SidebarIcon } from '/@/renderer/features/sidebar/components/sidebar-icon';
import { SidebarItem } from '/@/renderer/features/sidebar/components/sidebar-item'; import { SidebarItem } from '/@/renderer/features/sidebar/components/sidebar-item';
@@ -110,15 +110,6 @@ export const Sidebar = () => {
}); });
}; };
const playlistsQuery = usePlaylistList({
query: {
sortBy: PlaylistListSort.NAME,
sortOrder: SortOrder.ASC,
startIndex: 0,
},
serverId: server?.id,
});
const setFullScreenPlayerStore = useSetFullScreenPlayerStore(); const setFullScreenPlayerStore = useSetFullScreenPlayerStore();
const { expanded: isFullScreenPlayerExpanded } = useFullScreenPlayerStore(); const { expanded: isFullScreenPlayerExpanded } = useFullScreenPlayerStore();
const expandFullScreenPlayer = () => { const expandFullScreenPlayer = () => {
@@ -198,7 +189,6 @@ export const Sidebar = () => {
> >
{t('page.sidebar.playlists', { postProcess: 'titleCase' })} {t('page.sidebar.playlists', { postProcess: 'titleCase' })}
</Box> </Box>
{playlistsQuery.isLoading && <Spinner />}
</Group> </Group>
<Group spacing="sm"> <Group spacing="sm">
<Button <Button
@@ -233,7 +223,7 @@ export const Sidebar = () => {
</Button> </Button>
</Group> </Group>
</Group> </Group>
<SidebarPlaylistList data={playlistsQuery.data} /> <SidebarPlaylistList />
</> </>
)} )}
</MotionStack> </MotionStack>
@@ -29,6 +29,7 @@ import { queryClient } from '/@/renderer/lib/react-query';
import { SongListFilter, useCurrentServer, useListStoreActions } from '/@/renderer/store'; import { SongListFilter, useCurrentServer, useListStoreActions } from '/@/renderer/store';
import { ListDisplayType, Play, ServerType, TableColumn } from '/@/renderer/types'; import { ListDisplayType, Play, ServerType, TableColumn } from '/@/renderer/types';
import i18n from '/@/i18n/i18n'; import i18n from '/@/i18n/i18n';
import { SubsonicSongFilters } from '/@/renderer/features/songs/components/subsonic-song-filters';
const FILTERS = { const FILTERS = {
jellyfin: [ jellyfin: [
@@ -160,14 +161,26 @@ const FILTERS = {
value: SongListSort.YEAR, value: SongListSort.YEAR,
}, },
], ],
subsonic: [
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.name', { postProcess: 'titleCase' }),
value: SongListSort.NAME,
},
],
}; };
interface SongListHeaderFiltersProps { interface SongListHeaderFiltersProps {
gridRef: MutableRefObject<VirtualInfiniteGridRef | null>; gridRef: MutableRefObject<VirtualInfiniteGridRef | null>;
itemCount: number | undefined;
tableRef: MutableRefObject<AgGridReactType | null>; tableRef: MutableRefObject<AgGridReactType | null>;
} }
export const SongListHeaderFilters = ({ gridRef, tableRef }: SongListHeaderFiltersProps) => { export const SongListHeaderFilters = ({
gridRef,
tableRef,
itemCount,
}: SongListHeaderFiltersProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const server = useCurrentServer(); const server = useCurrentServer();
const { pageKey, handlePlay, customFilters } = useListContext(); const { pageKey, handlePlay, customFilters } = useListContext();
@@ -179,6 +192,7 @@ export const SongListHeaderFilters = ({ gridRef, tableRef }: SongListHeaderFilte
useListStoreActions(); useListStoreActions();
const { handleRefreshTable, handleRefreshGrid } = useListFilterRefresh({ const { handleRefreshTable, handleRefreshGrid } = useListFilterRefresh({
itemCount,
itemType: LibraryItem.SONG, itemType: LibraryItem.SONG,
server, server,
}); });
@@ -387,25 +401,34 @@ export const SongListHeaderFilters = ({ gridRef, tableRef }: SongListHeaderFilte
}; };
const handleOpenFiltersModal = () => { const handleOpenFiltersModal = () => {
let FilterComponent;
switch (server?.type) {
case ServerType.NAVIDROME:
FilterComponent = NavidromeSongFilters;
break;
case ServerType.JELLYFIN:
FilterComponent = JellyfinSongFilters;
break;
case ServerType.SUBSONIC:
FilterComponent = SubsonicSongFilters;
break;
default:
break;
}
if (!FilterComponent) {
return;
}
openModal({ openModal({
children: ( children: (
<> <FilterComponent
{server?.type === ServerType.NAVIDROME ? ( customFilters={customFilters}
<NavidromeSongFilters pageKey={pageKey}
customFilters={customFilters} serverId={server?.id}
pageKey={pageKey} onFilterChange={onFilterChange}
serverId={server?.id} />
onFilterChange={onFilterChange}
/>
) : (
<JellyfinSongFilters
customFilters={customFilters}
pageKey={pageKey}
serverId={server?.id}
onFilterChange={onFilterChange}
/>
)}
</>
), ),
title: 'Song Filters', title: 'Song Filters',
}); });
@@ -424,8 +447,17 @@ export const SongListHeaderFilters = ({ gridRef, tableRef }: SongListHeaderFilte
.filter((value) => value !== 'Audio') // Don't account for includeItemTypes: Audio .filter((value) => value !== 'Audio') // Don't account for includeItemTypes: Audio
.some((value) => value !== undefined); .some((value) => value !== undefined);
return isNavidromeFilterApplied || isJellyfinFilterApplied; const isSubsonicFilterApplied =
}, [filter?._custom?.jellyfin, filter?._custom?.navidrome, server?.type]); server?.type === ServerType.SUBSONIC && (filter?.isFavorite || filter?.genre);
return isNavidromeFilterApplied || isJellyfinFilterApplied || isSubsonicFilterApplied;
}, [
filter._custom?.jellyfin,
filter._custom?.navidrome,
filter?.genre,
filter?.isFavorite,
server?.type,
]);
const isFolderFilterApplied = useMemo(() => { const isFolderFilterApplied = useMemo(() => {
return filter.musicFolderId !== undefined; return filter.musicFolderId !== undefined;
@@ -462,11 +494,15 @@ export const SongListHeaderFilters = ({ gridRef, tableRef }: SongListHeaderFilte
))} ))}
</DropdownMenu.Dropdown> </DropdownMenu.Dropdown>
</DropdownMenu> </DropdownMenu>
<Divider orientation="vertical" /> {server?.type !== ServerType.SUBSONIC && (
<OrderToggleButton <>
sortOrder={filter.sortOrder} <Divider orientation="vertical" />
onToggle={handleToggleSortOrder} <OrderToggleButton
/> sortOrder={filter.sortOrder}
onToggle={handleToggleSortOrder}
/>
</>
)}
{server?.type === ServerType.JELLYFIN && ( {server?.type === ServerType.JELLYFIN && (
<> <>
<Divider orientation="vertical" /> <Divider orientation="vertical" />
@@ -32,6 +32,7 @@ export const SongListHeader = ({ gridRef, title, itemCount, tableRef }: SongList
const cq = useContainerQuery(); const cq = useContainerQuery();
const { handleRefreshTable, handleRefreshGrid } = useListFilterRefresh({ const { handleRefreshTable, handleRefreshGrid } = useListFilterRefresh({
itemCount,
itemType: LibraryItem.SONG, itemType: LibraryItem.SONG,
server, server,
}); });
@@ -94,6 +95,7 @@ export const SongListHeader = ({ gridRef, title, itemCount, tableRef }: SongList
<FilterBar> <FilterBar>
<SongListHeaderFilters <SongListHeaderFilters
gridRef={gridRef} gridRef={gridRef}
itemCount={itemCount}
tableRef={tableRef} tableRef={tableRef}
/> />
</FilterBar> </FilterBar>
@@ -0,0 +1,109 @@
import { ChangeEvent, useMemo } from 'react';
import { Divider, Group, Stack } from '@mantine/core';
import debounce from 'lodash/debounce';
import { GenreListSort, LibraryItem, SortOrder } from '/@/renderer/api/types';
import { Select, Switch, Text } from '/@/renderer/components';
import { useGenreList } from '/@/renderer/features/genres';
import { SongListFilter, useListFilterByKey, useListStoreActions } from '/@/renderer/store';
import { useTranslation } from 'react-i18next';
interface SubsonicSongFiltersProps {
customFilters?: Partial<SongListFilter>;
onFilterChange: (filters: SongListFilter) => void;
pageKey: string;
serverId?: string;
}
export const SubsonicSongFilters = ({
customFilters,
onFilterChange,
pageKey,
serverId,
}: SubsonicSongFiltersProps) => {
const { t } = useTranslation();
const { setFilter } = useListStoreActions();
const filter = useListFilterByKey({ key: pageKey });
const isGenrePage = customFilters?._custom?.navidrome?.genre_id !== undefined;
const genreListQuery = useGenreList({
query: {
sortBy: GenreListSort.NAME,
sortOrder: SortOrder.ASC,
startIndex: 0,
},
serverId,
});
const genreList = useMemo(() => {
if (!genreListQuery?.data) return [];
return genreListQuery.data.items.map((genre) => ({
label: genre.name,
value: genre.id,
}));
}, [genreListQuery.data]);
const handleGenresFilter = debounce((e: string | null) => {
const updatedFilters = setFilter({
customFilters,
data: {
genre: e || undefined,
},
itemType: LibraryItem.SONG,
key: pageKey,
}) as SongListFilter;
onFilterChange(updatedFilters);
}, 250);
const toggleFilters = [
{
label: t('filter.isFavorited', { postProcess: 'sentenceCase' }),
onChange: (e: ChangeEvent<HTMLInputElement>) => {
const updatedFilters = setFilter({
customFilters,
data: {
isFavorite: e.target.checked,
},
itemType: LibraryItem.SONG,
key: pageKey,
}) as SongListFilter;
onFilterChange(updatedFilters);
},
value: filter.isFavorite,
},
];
return (
<Stack p="0.8rem">
{toggleFilters.map((filter) => (
<Group
key={`ss-filter-${filter.label}`}
position="apart"
>
<Text>{filter.label}</Text>
<Switch
checked={filter?.value || false}
size="xs"
onChange={filter.onChange}
/>
</Group>
))}
<Divider my="0.5rem" />
<Group grow>
{!isGenrePage && (
<Select
clearable
searchable
data={genreList}
defaultValue={filter.genre}
label={t('entity.genre', { count: 1, postProcess: 'titleCase' })}
width={150}
onChange={handleGenresFilter}
/>
)}
</Group>
</Stack>
);
};
@@ -0,0 +1,41 @@
import { useQuery } from '@tanstack/react-query';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import type { SongListQuery } from '/@/renderer/api/types';
import type { QueryHookArgs } from '/@/renderer/lib/react-query';
import { getServerById } from '/@/renderer/store';
export const getSongListCountQuery = (query: SongListQuery) => {
const filter: Record<string, unknown> = {};
if (query.searchTerm) filter.searchTerm = query.searchTerm;
if (query.genreId) filter.genreId = query.genreId;
if (query.musicFolderId) filter.musicFolderId = query.musicFolderId;
if (query.isFavorite) filter.isFavorite = query.isFavorite;
if (query.genre) filter.genre = query.genre;
if (Object.keys(filter).length === 0) return undefined;
return filter;
};
export const useSongListCount = (args: QueryHookArgs<SongListQuery>) => {
const { options, query, serverId } = args;
const server = getServerById(serverId);
return useQuery({
enabled: !!serverId,
queryFn: ({ signal }) => {
if (!server) throw new Error('Server not found');
return api.controller.getSongListCount({
apiClientProps: {
server,
signal,
},
query,
});
},
queryKey: queryKeys.songs.count(serverId || '', getSongListCountQuery(query)),
...options,
});
};
@@ -9,11 +9,11 @@ import { usePlayQueueAdd } from '/@/renderer/features/player';
import { AnimatedPage } from '/@/renderer/features/shared'; import { AnimatedPage } from '/@/renderer/features/shared';
import { SongListContent } from '/@/renderer/features/songs/components/song-list-content'; import { SongListContent } from '/@/renderer/features/songs/components/song-list-content';
import { SongListHeader } from '/@/renderer/features/songs/components/song-list-header'; import { SongListHeader } from '/@/renderer/features/songs/components/song-list-header';
import { useSongList } from '/@/renderer/features/songs/queries/song-list-query';
import { useCurrentServer, useListFilterByKey } from '/@/renderer/store'; import { useCurrentServer, useListFilterByKey } from '/@/renderer/store';
import { Play } from '/@/renderer/types'; import { Play } from '/@/renderer/types';
import { titleCase } from '/@/renderer/utils'; import { titleCase } from '/@/renderer/utils';
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid'; import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
import { useSongListCount } from '/@/renderer/features/songs/queries/song-list-count-query';
const TrackListRoute = () => { const TrackListRoute = () => {
const gridRef = useRef<VirtualInfiniteGridRef | null>(null); const gridRef = useRef<VirtualInfiniteGridRef | null>(null);
@@ -36,6 +36,8 @@ const TrackListRoute = () => {
genre_id: genreId, genre_id: genreId,
}, },
}, },
genre: genreId,
genreId,
}), }),
}; };
@@ -74,7 +76,7 @@ const TrackListRoute = () => {
return genre?.name; return genre?.name;
}, [genreId, genreList.data]); }, [genreId, genreList.data]);
const itemCountCheck = useSongList({ const itemCountCheck = useSongListCount({
options: { options: {
cacheTime: 1000 * 60, cacheTime: 1000 * 60,
staleTime: 1000 * 60, staleTime: 1000 * 60,
@@ -87,10 +89,7 @@ const TrackListRoute = () => {
serverId: server?.id, serverId: server?.id,
}); });
const itemCount = const itemCount = itemCountCheck.data === null ? undefined : itemCountCheck.data;
itemCountCheck.data?.totalRecordCount === null
? undefined
: itemCountCheck.data?.totalRecordCount;
const handlePlay = useCallback( const handlePlay = useCallback(
async (args: { initialSongId?: string; playType: Play }) => { async (args: { initialSongId?: string; playType: Play }) => {
+30 -7
View File
@@ -10,14 +10,18 @@ import orderBy from 'lodash/orderBy';
interface UseHandleListFilterChangeProps { interface UseHandleListFilterChangeProps {
isClientSideSort?: boolean; isClientSideSort?: boolean;
itemCount?: number;
itemType: LibraryItem; itemType: LibraryItem;
server: ServerListItem | null; server: ServerListItem | null;
} }
const BLOCK_SIZE = 500;
export const useListFilterRefresh = ({ export const useListFilterRefresh = ({
server, server,
itemType, itemType,
isClientSideSort, isClientSideSort,
itemCount,
}: UseHandleListFilterChangeProps) => { }: UseHandleListFilterChangeProps) => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -78,7 +82,7 @@ export const useListFilterRefresh = ({
const queryKey = queryKeyFn(server?.id || '', query); const queryKey = queryKeyFn(server?.id || '', query);
const res = await queryClient.fetchQuery({ const results = (await queryClient.fetchQuery({
queryFn: async ({ signal }) => { queryFn: async ({ signal }) => {
return queryFn({ return queryFn({
apiClientProps: { apiClientProps: {
@@ -89,20 +93,39 @@ export const useListFilterRefresh = ({
}); });
}, },
queryKey, queryKey,
}); })) as BasePaginatedResponse<any>;
if (isClientSideSort && res?.items) { if (isClientSideSort && results?.items) {
const sortedResults = orderBy( const sortedResults = orderBy(
res.items, results.items,
[(item) => String(item[filter.sortBy]).toLowerCase()], [(item) => String(item[filter.sortBy]).toLowerCase()],
filter.sortOrder === 'DESC' ? ['desc'] : ['asc'], filter.sortOrder === 'DESC' ? ['desc'] : ['asc'],
); );
params.successCallback(sortedResults || [], res?.totalRecordCount || 0); params.successCallback(
sortedResults || [],
results?.totalRecordCount || itemCount,
);
return; return;
} }
params.successCallback(res?.items || [], res?.totalRecordCount || 0); if (results.totalRecordCount === null) {
const hasMoreRows = results?.items?.length === BLOCK_SIZE;
const lastRowIndex = hasMoreRows
? undefined
: (filter.offset || 0) + results.items.length;
params.successCallback(
results?.items || [],
hasMoreRows ? undefined : lastRowIndex,
);
return;
}
params.successCallback(
results?.items || [],
results?.totalRecordCount || itemCount,
);
}, },
rowCount: undefined, rowCount: undefined,
@@ -112,7 +135,7 @@ export const useListFilterRefresh = ({
tableRef.current?.api.purgeInfiniteCache(); tableRef.current?.api.purgeInfiniteCache();
tableRef.current?.api.ensureIndexVisible(0, 'top'); tableRef.current?.api.ensureIndexVisible(0, 'top');
}, },
[isClientSideSort, queryClient, queryFn, queryKeyFn, server], [isClientSideSort, itemCount, queryClient, queryFn, queryKeyFn, server],
); );
const handleRefreshGrid = useCallback( const handleRefreshGrid = useCallback(
+3 -1
View File
@@ -222,7 +222,9 @@ export const WindowBar = () => {
const statusString = playerStatus === PlayerStatus.PAUSED ? '(Paused) ' : ''; const statusString = playerStatus === PlayerStatus.PAUSED ? '(Paused) ' : '';
const queueString = length ? `(${index + 1} / ${length}) ` : ''; const queueString = length ? `(${index + 1} / ${length}) ` : '';
const title = length const title = length
? `${statusString}${queueString}${currentSong?.name}${currentSong?.artistName}` ? currentSong?.artistName
? `${statusString}${queueString}${currentSong?.name}${currentSong?.artistName}`
: `${statusString}${queueString}${currentSong?.name}`
: 'Feishin'; : 'Feishin';
document.title = title; document.title = title;
-9
View File
@@ -18,10 +18,6 @@ const AlbumListRoute = lazy(() => import('/@/renderer/features/albums/routes/alb
const SongListRoute = lazy(() => import('/@/renderer/features/songs/routes/song-list-route')); const SongListRoute = lazy(() => import('/@/renderer/features/songs/routes/song-list-route'));
const PlaylistDetailRoute = lazy(
() => import('/@/renderer/features/playlists/routes/playlist-detail-route'),
);
const PlaylistDetailSongListRoute = lazy( const PlaylistDetailSongListRoute = lazy(
() => import('/@/renderer/features/playlists/routes/playlist-detail-song-list-route'), () => import('/@/renderer/features/playlists/routes/playlist-detail-song-list-route'),
); );
@@ -136,11 +132,6 @@ export const AppRouter = () => {
errorElement={<RouteErrorBoundary />} errorElement={<RouteErrorBoundary />}
path={AppRoute.PLAYLISTS} path={AppRoute.PLAYLISTS}
/> />
<Route
element={<PlaylistDetailRoute />}
errorElement={<RouteErrorBoundary />}
path={AppRoute.PLAYLISTS_DETAIL}
/>
<Route <Route
element={<PlaylistDetailSongListRoute />} element={<PlaylistDetailSongListRoute />}
errorElement={<RouteErrorBoundary />} errorElement={<RouteErrorBoundary />}