mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-07 04:20:12 +02:00
Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 91ce2cd8a1 | |||
| 4f61e82068 | |||
| 1e6673fabd | |||
| 02951c92af | |||
| 05f8fb3114 | |||
| 169da10c1b | |||
| 5a79fee77e | |||
| 7ef80f14b0 | |||
| 36cc37e39f | |||
| d4e7c6bd18 | |||
| 90f79b4ae7 | |||
| cf74625bfc | |||
| f068d6e4b8 | |||
| e1aa8d74f3 | |||
| b0d86ee5c9 | |||
| 89e27ec6ff | |||
| 39c714a137 | |||
| a8fb7ff11e | |||
| 9b95f47a91 | |||
| 2267e9bc9d | |||
| 089311c673 | |||
| 773f349b66 | |||
| 3980c8ea97 | |||
| 257a5ceef0 | |||
| fb022891fe | |||
| 5d9906b8f2 | |||
| 6f7cb468b2 | |||
| 076693e969 | |||
| 781d8055b5 | |||
| 960bb5c660 | |||
| 42bb2bf66f | |||
| f03d88cd8c | |||
| 58f6535ba6 | |||
| 9a59ce3613 |
Generated
+3
-4
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "feishin",
|
||||
"version": "0.12.5",
|
||||
"version": "0.13.0",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "feishin",
|
||||
"version": "0.12.5",
|
||||
"version": "0.13.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "GPL-3.0",
|
||||
"dependencies": {
|
||||
@@ -30224,8 +30224,7 @@
|
||||
}
|
||||
},
|
||||
"domhandler": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
|
||||
"version": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
|
||||
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "feishin",
|
||||
"productName": "Feishin",
|
||||
"description": "Feishin music server",
|
||||
"version": "0.12.5",
|
||||
"version": "0.13.0",
|
||||
"scripts": {
|
||||
"build": "concurrently \"npm run build:main\" \"npm run build:renderer\" \"npm run build:remote\"",
|
||||
"build:main": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.main.prod.ts",
|
||||
|
||||
Generated
+10
-9
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "feishin",
|
||||
"version": "0.12.5",
|
||||
"version": "0.12.8",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "feishin",
|
||||
"version": "0.12.5",
|
||||
"version": "0.12.8",
|
||||
"hasInstallScript": true,
|
||||
"license": "GPL-3.0",
|
||||
"dependencies": {
|
||||
@@ -1318,9 +1318,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/xml2js": {
|
||||
"version": "0.4.23",
|
||||
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz",
|
||||
"integrity": "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==",
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz",
|
||||
"integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"sax": ">=0.6.0",
|
||||
"xmlbuilder": "~11.0.0"
|
||||
@@ -1584,7 +1585,7 @@
|
||||
"jsbi": "^2.0.5",
|
||||
"long": "^4.0.0",
|
||||
"safe-buffer": "^5.1.1",
|
||||
"xml2js": "^0.4.17"
|
||||
"xml2js": "0.5.0"
|
||||
}
|
||||
},
|
||||
"debug": {
|
||||
@@ -2317,9 +2318,9 @@
|
||||
"integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="
|
||||
},
|
||||
"xml2js": {
|
||||
"version": "0.4.23",
|
||||
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz",
|
||||
"integrity": "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==",
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz",
|
||||
"integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==",
|
||||
"requires": {
|
||||
"sax": ">=0.6.0",
|
||||
"xmlbuilder": "~11.0.0"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "feishin",
|
||||
"version": "0.12.5",
|
||||
"version": "0.13.0",
|
||||
"description": "",
|
||||
"main": "./dist/main/main.js",
|
||||
"author": {
|
||||
@@ -20,5 +20,11 @@
|
||||
"devDependencies": {
|
||||
"electron": "36.1.0"
|
||||
},
|
||||
"resolutions": {
|
||||
"xml2js": "0.5.0"
|
||||
},
|
||||
"overrides": {
|
||||
"xml2js": "0.5.0"
|
||||
},
|
||||
"license": "GPL-3.0"
|
||||
}
|
||||
|
||||
@@ -257,7 +257,9 @@
|
||||
"translationTargetLanguage": "cílový jazyk překladu",
|
||||
"translationTargetLanguage_description": "cílový jazyk pro překlad",
|
||||
"lastfmApiKey": "klíč API {{lastfm}}",
|
||||
"lastfmApiKey_description": "klíč API pro {{lastfm}}. vyžadováno pro obaly alb"
|
||||
"lastfmApiKey_description": "klíč API pro {{lastfm}}. vyžadováno pro obaly alb",
|
||||
"discordServeImage": "načítat obrázky {{discord}} ze serveru",
|
||||
"discordServeImage_description": "sdílet obaly alb pro {{discord}} rich presence ze samotného serveru, dostupné pouze pro jellyfin a navidrome"
|
||||
},
|
||||
"action": {
|
||||
"editPlaylist": "upravit $t(entity.playlist_one)",
|
||||
@@ -375,7 +377,9 @@
|
||||
"codec": "kodek",
|
||||
"trackPeak": "vrchol skladby",
|
||||
"preview": "náhled",
|
||||
"translation": "překlad"
|
||||
"translation": "překlad",
|
||||
"additionalParticipants": "další přispívající",
|
||||
"tags": "štítky"
|
||||
},
|
||||
"table": {
|
||||
"config": {
|
||||
@@ -474,7 +478,8 @@
|
||||
"loginRateError": "příliš mnoho pokusů o přihlášení, zkuste to znovu za pár vteřin",
|
||||
"badAlbum": "tuto stránku vidíte, protože tato skladba není součástí alba. tento problém může nastat, pokud máte skladbu na nejvyšší úrovni vaší složky s hudbou. jellyfin seskupuje skladby pouze, pokud se nacházejí ve složce.",
|
||||
"networkError": "vyskytla se chyba sítě",
|
||||
"openError": "nepodařilo se otevřít soubor"
|
||||
"openError": "nepodařilo se otevřít soubor",
|
||||
"badValue": "neplatná možnost „{{value}}“. tato možnost již neexistuje"
|
||||
},
|
||||
"filter": {
|
||||
"mostPlayed": "nejvíce přehráváno",
|
||||
@@ -747,8 +752,8 @@
|
||||
"folderWithCount_few": "{{count}} složky",
|
||||
"folderWithCount_other": "{{count}} složek",
|
||||
"albumArtist_one": "umělec alba",
|
||||
"albumArtist_few": "umělci alba",
|
||||
"albumArtist_other": "umělců alba",
|
||||
"albumArtist_few": "umělci alb",
|
||||
"albumArtist_other": "umělci alb",
|
||||
"track_one": "skladba",
|
||||
"track_few": "skladby",
|
||||
"track_other": "skladby",
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"action_one": "action",
|
||||
"action_other": "actions",
|
||||
"add": "add",
|
||||
"additionalParticipants": "additional participants",
|
||||
"albumGain": "album gain",
|
||||
"albumPeak": "album peak",
|
||||
"areYouSure": "are you sure?",
|
||||
@@ -106,6 +107,7 @@
|
||||
"share": "share",
|
||||
"size": "size",
|
||||
"sortOrder": "order",
|
||||
"tags": "tags",
|
||||
"title": "title",
|
||||
"trackNumber": "track",
|
||||
"trackGain": "track gain",
|
||||
@@ -158,6 +160,7 @@
|
||||
"audioDeviceFetchError": "an error occurred when trying to get audio devices",
|
||||
"authenticationFailed": "authentication failed",
|
||||
"badAlbum": "you are seeing this page because this song is not part of an album. you are most likely seeing this issue if you have a song at the top level of your music folder. jellyfin only groups tracks if they are in a folder.",
|
||||
"badValue": "invalid option \"{{value}}\". this value no longer exists",
|
||||
"credentialsRequired": "credentials required",
|
||||
"endpointNotImplementedError": "endpoint {{endpoint}} is not implemented for {{serverType}}",
|
||||
"genericError": "an error occurred",
|
||||
@@ -513,6 +516,8 @@
|
||||
"discordListening_description": "show status as listening instead of playing",
|
||||
"discordRichPresence": "{{discord}} rich presence",
|
||||
"discordRichPresence_description": "enable playback status in {{discord}} rich presence. Image keys are: {{icon}}, {{playing}}, and {{paused}} ",
|
||||
"discordServeImage": "serve {{discord}} images from server",
|
||||
"discordServeImage_description": "share cover art for {{discord}} rich presence from server itself, only available for jellyfin and navidrome",
|
||||
"discordUpdateInterval": "{{discord}} rich presence update interval",
|
||||
"discordUpdateInterval_description": "the time in seconds between each update (minimum 15 seconds)",
|
||||
"doubleClickBehavior": "queue all searched tracks when double clicking",
|
||||
|
||||
@@ -257,7 +257,9 @@
|
||||
"translationTargetLanguage": "idioma final de la traducción",
|
||||
"translationTargetLanguage_description": "lengua de destino de la traducción",
|
||||
"lastfmApiKey_description": "la clave API para {{lastfm}}. Requerida para la portada",
|
||||
"lastfmApiKey": "Clave API para {{lastfm}}"
|
||||
"lastfmApiKey": "Clave API para {{lastfm}}",
|
||||
"discordServeImage": "Servir imágenes de {{discord}} desde el servidor",
|
||||
"discordServeImage_description": "Comparte el arte de la portada para el estado de actividad de {{discord}} desde el propio servidor, solo disponible para Jellyfin y Navidrome"
|
||||
},
|
||||
"action": {
|
||||
"editPlaylist": "editar $t(entity.playlist_one)",
|
||||
@@ -375,7 +377,9 @@
|
||||
"share": "Compartir",
|
||||
"trackGain": "Ganancia de pista",
|
||||
"preview": "Vista previa",
|
||||
"translation": "traducción"
|
||||
"translation": "traducción",
|
||||
"additionalParticipants": "Participantes adicionales",
|
||||
"tags": "Etiquetas"
|
||||
},
|
||||
"error": {
|
||||
"remotePortWarning": "reiniciar el servidor para aplicar el nuevo puerto",
|
||||
@@ -399,7 +403,8 @@
|
||||
"loginRateError": "demasiados intentos de inicio de sesión, por favor inténtalo en unos segundos",
|
||||
"badAlbum": "Estás viendo esta página porque esta canción no forma parte de un álbum. Este problema puede ocurrir si tienes una canción en el nivel superior de tu carpeta de música. Jellyfin solo agrupa pistas si están en una carpeta.",
|
||||
"networkError": "Ocurrió un error de red",
|
||||
"openError": "No se pudo abrir el archivo"
|
||||
"openError": "No se pudo abrir el archivo",
|
||||
"badValue": "Opción inválida \"{{value}}\". Este valor ya no existe"
|
||||
},
|
||||
"filter": {
|
||||
"mostPlayed": "más reproducido",
|
||||
|
||||
@@ -504,7 +504,9 @@
|
||||
"webAudio_description": "käytä web-ääntä. tämä mahdollistaa edistyneet ominaisuudet, kuten replaygainin. poista käytöstä, jos koet ongelmia",
|
||||
"startMinimized": "käynnistä pienennettynä",
|
||||
"useSystemTheme": "käytä järjestelmän teemaa",
|
||||
"volumeWheelStep": "äänenvoimakkuusrullan askel"
|
||||
"volumeWheelStep": "äänenvoimakkuusrullan askel",
|
||||
"discordServeImage": "jaa {{discord}} kuvat palvelimelta",
|
||||
"discordServeImage_description": "jaa kansikuvat {{discord}}n rich presenceä varten suoraan palvelimelta. saatavilla vain jellyfinille ja navidromelle"
|
||||
},
|
||||
"page": {
|
||||
"itemDetail": {
|
||||
|
||||
@@ -148,7 +148,9 @@
|
||||
"trackGain": "gain de la piste",
|
||||
"trackPeak": "crête de la piste",
|
||||
"codec": "codec",
|
||||
"translation": "traduction"
|
||||
"translation": "traduction",
|
||||
"additionalParticipants": "participants additionnels",
|
||||
"tags": "tags"
|
||||
},
|
||||
"error": {
|
||||
"remotePortWarning": "redémarrer le serveur pour appliquer le nouveau port",
|
||||
@@ -172,7 +174,8 @@
|
||||
"loginRateError": "trop de tentative de connexion, merci de réessayer dans quelques secondes",
|
||||
"openError": "impossible d'ouvrir le fichier",
|
||||
"networkError": "une erreur de réseau est survenue",
|
||||
"badAlbum": "vous voyez cette page parce que cette chanson ne fait pas parti d'un album. vous rencontrez probablement cette erreur si vous avez une chanson qui n'est pas dans votre répertoire de musique. jellyfin gère les chansons uniquement si elles sont dans un sous-dossier, qui est lui-même dans un dossier \"Musique(s)\"."
|
||||
"badAlbum": "vous voyez cette page parce que cette chanson ne fait pas parti d'un album. vous rencontrez probablement cette erreur si vous avez une chanson qui n'est pas dans votre répertoire de musique. jellyfin gère les chansons uniquement si elles sont dans un sous-dossier, qui est lui-même dans un dossier \"Musique(s)\".",
|
||||
"badValue": "option {{value}} invalide. Cette valeur n'existe plus"
|
||||
},
|
||||
"filter": {
|
||||
"mostPlayed": "plus joués",
|
||||
@@ -593,7 +596,9 @@
|
||||
"doubleClickBehavior_description": "si vrai, toutes les pistes correspondantes dans une recherche de piste seront mises en file d'attente. sinon, seule celle sur laquelle vous avez cliqué sera mise en file d'attente",
|
||||
"albumBackgroundBlur": "taille du flou de l'image d'arrière-plan de l'album",
|
||||
"lastfmApiKey": "clé API {{lastfm}}",
|
||||
"lastfmApiKey_description": "la clé API pour {{lastfm}} est requise pour la pochette d'album"
|
||||
"lastfmApiKey_description": "la clé API pour {{lastfm}} . requise pour la pochette d'album",
|
||||
"discordServeImage": "servir l'image {{discord}} depuis le serveur",
|
||||
"discordServeImage_description": "partage pochette du status d'activité {{discord}} depuis le serveur lui même, disponible uniquement pour jellyfin et navidrome"
|
||||
},
|
||||
"form": {
|
||||
"deletePlaylist": {
|
||||
|
||||
@@ -109,7 +109,9 @@
|
||||
"codec": "编解码器",
|
||||
"share": "分享",
|
||||
"preview": "预览",
|
||||
"translation": "翻译"
|
||||
"translation": "翻译",
|
||||
"additionalParticipants": "其他参与者",
|
||||
"tags": "标签"
|
||||
},
|
||||
"entity": {
|
||||
"albumArtist_other": "专辑艺术家",
|
||||
@@ -389,7 +391,9 @@
|
||||
"translationTargetLanguage": "目标翻译语言",
|
||||
"translationTargetLanguage_description": "目标翻译语言",
|
||||
"lastfmApiKey": "{{lastfm}} API 密钥",
|
||||
"lastfmApiKey_description": "{{lastfm}} 的 API 密钥。封面艺术图所需"
|
||||
"lastfmApiKey_description": "{{lastfm}} 的 API 密钥。封面艺术图所需",
|
||||
"discordServeImage": "从服务器提供 {{discord}} 图像",
|
||||
"discordServeImage_description": "分享 {{discord}} 封面艺术图,来自 rich presence 服务器,仅适用于 jellyfin 和 navidrome"
|
||||
},
|
||||
"error": {
|
||||
"remotePortWarning": "重启服务器使新端口生效",
|
||||
@@ -413,7 +417,8 @@
|
||||
"loginRateError": "登录请求尝试次数过多,请稍后再试",
|
||||
"badAlbum": "您看到此页面是因为这首歌不是专辑的一部分。如果您的音乐文件夹顶层有一首歌曲,您很可能会遇到此问题。jellyfin 仅对位于文件夹中的曲目进行分组。",
|
||||
"networkError": "发生网络错误",
|
||||
"openError": "无法打开文件"
|
||||
"openError": "无法打开文件",
|
||||
"badValue": "无效的选项 \"{{value}}\". 此值不再存在"
|
||||
},
|
||||
"filter": {
|
||||
"mostPlayed": "最多播放过",
|
||||
|
||||
@@ -494,6 +494,9 @@ const createWindow = async (first = true) => {
|
||||
|
||||
app.commandLine.appendSwitch('disable-features', 'HardwareMediaKeyHandling,MediaSessionService');
|
||||
|
||||
// https://github.com/electron/electron/issues/46538#issuecomment-2808806722
|
||||
app.commandLine.appendSwitch('gtk-version', '3');
|
||||
|
||||
// Must duplicate with the one in renderer process settings.store.ts
|
||||
enum BindingActions {
|
||||
GLOBAL_SEARCH = 'globalSearch',
|
||||
|
||||
@@ -93,6 +93,9 @@ export const controller: GeneralController = {
|
||||
getAlbumDetail(args) {
|
||||
return apiController('getAlbumDetail', args.apiClientProps.server?.type)?.(args);
|
||||
},
|
||||
getAlbumInfo(args) {
|
||||
return apiController('getAlbumInfo', args.apiClientProps.server?.type)?.(args);
|
||||
},
|
||||
getAlbumList(args) {
|
||||
return apiController('getAlbumList', args.apiClientProps.server?.type)?.(args);
|
||||
},
|
||||
@@ -153,6 +156,9 @@ export const controller: GeneralController = {
|
||||
getStructuredLyrics(args) {
|
||||
return apiController('getStructuredLyrics', args.apiClientProps.server?.type)?.(args);
|
||||
},
|
||||
getTags(args) {
|
||||
return apiController('getTags', args.apiClientProps.server?.type)?.(args);
|
||||
},
|
||||
getTopSongs(args) {
|
||||
return apiController('getTopSongs', args.apiClientProps.server?.type)?.(args);
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@ export enum ServerFeature {
|
||||
PLAYLISTS_SMART = 'playlistsSmart',
|
||||
PUBLIC_PLAYLIST = 'publicPlaylist',
|
||||
SHARING_ALBUM_SONG = 'sharingAlbumSong',
|
||||
TAGS = 'tags',
|
||||
}
|
||||
|
||||
export type ServerFeatures = Partial<Record<ServerFeature, boolean>>;
|
||||
export type ServerFeatures = Partial<Record<ServerFeature, number[]>>;
|
||||
|
||||
@@ -104,6 +104,15 @@ export const contract = c.router({
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
getFilterList: {
|
||||
method: 'GET',
|
||||
path: 'items/filters',
|
||||
query: jfType._parameters.filterList,
|
||||
responses: {
|
||||
200: jfType._response.filters,
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
getGenreList: {
|
||||
method: 'GET',
|
||||
path: 'genres',
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Song,
|
||||
Played,
|
||||
ControllerEndpoint,
|
||||
LibraryItem,
|
||||
} from '/@/renderer/api/types';
|
||||
import { jfApiClient } from '/@/renderer/api/jellyfin/jellyfin-api';
|
||||
import { jfNormalize } from './jellyfin-normalize';
|
||||
@@ -15,7 +16,7 @@ import { jfType } from '/@/renderer/api/jellyfin/jellyfin-types';
|
||||
import { z } from 'zod';
|
||||
import { JFSongListSort, JFSortOrder } from '/@/renderer/api/jellyfin.types';
|
||||
import { ServerFeature } from '/@/renderer/api/features-types';
|
||||
import { VersionInfo, getFeatures } from '/@/renderer/api/utils';
|
||||
import { VersionInfo, getFeatures, hasFeature } from '/@/renderer/api/utils';
|
||||
import chunk from 'lodash/chunk';
|
||||
|
||||
const formatCommaDelimitedString = (value: string[]) => {
|
||||
@@ -35,6 +36,7 @@ const VERSION_INFO: VersionInfo = [
|
||||
[ServerFeature.PUBLIC_PLAYLIST]: [1],
|
||||
},
|
||||
],
|
||||
['10.0.0', { [ServerFeature.TAGS]: [1] }],
|
||||
];
|
||||
|
||||
export const JellyfinController: ControllerEndpoint = {
|
||||
@@ -246,7 +248,7 @@ export const JellyfinController: ControllerEndpoint = {
|
||||
userId: apiClientProps.server.userId,
|
||||
},
|
||||
query: {
|
||||
Fields: 'Genres, DateCreated, ChildCount',
|
||||
Fields: 'Genres, DateCreated, ChildCount, People, Tags',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -255,7 +257,7 @@ export const JellyfinController: ControllerEndpoint = {
|
||||
userId: apiClientProps.server.userId,
|
||||
},
|
||||
query: {
|
||||
Fields: 'Genres, DateCreated, MediaSources, ParentId',
|
||||
Fields: 'Genres, DateCreated, MediaSources, ParentId, People, Tags',
|
||||
IncludeItemTypes: 'Audio',
|
||||
ParentId: query.id,
|
||||
SortBy: 'ParentIndexNumber,IndexNumber,SortName',
|
||||
@@ -300,6 +302,7 @@ export const JellyfinController: ControllerEndpoint = {
|
||||
query.artistIds && {
|
||||
ContributingArtistIds: query.artistIds[0],
|
||||
}),
|
||||
Fields: 'People, Tags',
|
||||
GenreIds: query.genres ? query.genres.join(',') : undefined,
|
||||
IncludeItemTypes: 'MusicAlbum',
|
||||
IsFavorite: query.favorite,
|
||||
@@ -523,7 +526,7 @@ export const JellyfinController: ControllerEndpoint = {
|
||||
id: query.id,
|
||||
},
|
||||
query: {
|
||||
Fields: 'Genres, DateCreated, MediaSources, UserData, ParentId',
|
||||
Fields: 'Genres, DateCreated, MediaSources, UserData, ParentId, People, Tags',
|
||||
IncludeItemTypes: 'Audio',
|
||||
Limit: query.limit,
|
||||
SortBy: query.sortBy ? songListSortMap.jellyfin[query.sortBy] : undefined,
|
||||
@@ -564,7 +567,7 @@ export const JellyfinController: ControllerEndpoint = {
|
||||
userId: apiClientProps.server?.userId,
|
||||
},
|
||||
query: {
|
||||
Fields: 'Genres, DateCreated, MediaSources, ParentId',
|
||||
Fields: 'Genres, DateCreated, MediaSources, ParentId, People, Tags',
|
||||
GenreIds: query.genre ? query.genre : undefined,
|
||||
IncludeItemTypes: 'Audio',
|
||||
IsPlayed:
|
||||
@@ -695,58 +698,98 @@ export const JellyfinController: ControllerEndpoint = {
|
||||
}
|
||||
|
||||
const yearsFilter = yearsGroup.length ? formatCommaDelimitedString(yearsGroup) : undefined;
|
||||
const albumIdsFilter = query.albumIds
|
||||
? formatCommaDelimitedString(query.albumIds)
|
||||
: undefined;
|
||||
const artistIdsFilter = query.artistIds
|
||||
? formatCommaDelimitedString(query.artistIds)
|
||||
: query.albumArtistIds
|
||||
? formatCommaDelimitedString(query.albumArtistIds)
|
||||
: undefined;
|
||||
|
||||
const res = await jfApiClient(apiClientProps).getSongList({
|
||||
params: {
|
||||
userId: apiClientProps.server?.userId,
|
||||
},
|
||||
query: {
|
||||
AlbumIds: albumIdsFilter,
|
||||
ArtistIds: artistIdsFilter,
|
||||
Fields: 'Genres, DateCreated, MediaSources, ParentId',
|
||||
GenreIds: query.genreIds?.join(','),
|
||||
IncludeItemTypes: 'Audio',
|
||||
IsFavorite: query.favorite,
|
||||
Limit: query.limit,
|
||||
ParentId: query.musicFolderId,
|
||||
Recursive: true,
|
||||
SearchTerm: query.searchTerm,
|
||||
SortBy: songListSortMap.jellyfin[query.sortBy] || 'Album,SortName',
|
||||
SortOrder: sortOrderMap.jellyfin[query.sortOrder],
|
||||
StartIndex: query.startIndex,
|
||||
...query._custom?.jellyfin,
|
||||
Years: yearsFilter,
|
||||
},
|
||||
});
|
||||
let items: z.infer<typeof jfType._response.song>[] = [];
|
||||
let totalRecordCount = 0;
|
||||
const batchSize = 50;
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get song list');
|
||||
}
|
||||
// Handle albumIds fetches in batches to prevent HTTP 414 errors
|
||||
if (query.albumIds && query.albumIds.length > batchSize) {
|
||||
const albumIdBatches = chunk(query.albumIds, batchSize);
|
||||
|
||||
let items: z.infer<typeof jfType._response.song>[];
|
||||
for (const batch of albumIdBatches) {
|
||||
const albumIdsFilter = formatCommaDelimitedString(batch);
|
||||
|
||||
// Jellyfin Bodge because of code from https://github.com/jellyfin/jellyfin/blob/c566ccb63bf61f9c36743ddb2108a57c65a2519b/Emby.Server.Implementations/Data/SqliteItemRepository.cs#L3622
|
||||
// If the Album ID filter is passed, Jellyfin will search for
|
||||
// 1. the matching album id
|
||||
// 2. An album with the name of the album.
|
||||
// It is this second condition causing issues,
|
||||
if (query.albumIds) {
|
||||
const albumIdSet = new Set(query.albumIds);
|
||||
items = res.body.Items.filter((item) => albumIdSet.has(item.AlbumId!));
|
||||
const res = await jfApiClient(apiClientProps).getSongList({
|
||||
params: {
|
||||
userId: apiClientProps.server?.userId,
|
||||
},
|
||||
query: {
|
||||
AlbumIds: albumIdsFilter,
|
||||
ArtistIds: artistIdsFilter,
|
||||
Fields: 'Genres, DateCreated, MediaSources, ParentId, People, Tags',
|
||||
GenreIds: query.genreIds?.join(','),
|
||||
IncludeItemTypes: 'Audio',
|
||||
IsFavorite: query.favorite,
|
||||
Limit: query.limit,
|
||||
ParentId: query.musicFolderId,
|
||||
Recursive: true,
|
||||
SearchTerm: query.searchTerm,
|
||||
SortBy: songListSortMap.jellyfin[query.sortBy] || 'Album,SortName',
|
||||
SortOrder: sortOrderMap.jellyfin[query.sortOrder],
|
||||
StartIndex: query.startIndex,
|
||||
...query._custom?.jellyfin,
|
||||
Years: yearsFilter,
|
||||
},
|
||||
});
|
||||
|
||||
if (items.length < res.body.Items.length) {
|
||||
res.body.TotalRecordCount -= res.body.Items.length - items.length;
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get song list');
|
||||
}
|
||||
|
||||
items = [...items, ...res.body.Items];
|
||||
totalRecordCount += res.body.Items.length;
|
||||
}
|
||||
} else {
|
||||
items = res.body.Items;
|
||||
const albumIdsFilter = query.albumIds
|
||||
? formatCommaDelimitedString(query.albumIds)
|
||||
: undefined;
|
||||
|
||||
const res = await jfApiClient(apiClientProps).getSongList({
|
||||
params: {
|
||||
userId: apiClientProps.server?.userId,
|
||||
},
|
||||
query: {
|
||||
AlbumIds: albumIdsFilter,
|
||||
ArtistIds: artistIdsFilter,
|
||||
Fields: 'Genres, DateCreated, MediaSources, ParentId, People, Tags',
|
||||
GenreIds: query.genreIds?.join(','),
|
||||
IncludeItemTypes: 'Audio',
|
||||
IsFavorite: query.favorite,
|
||||
Limit: query.limit,
|
||||
ParentId: query.musicFolderId,
|
||||
Recursive: true,
|
||||
SearchTerm: query.searchTerm,
|
||||
SortBy: songListSortMap.jellyfin[query.sortBy] || 'Album,SortName',
|
||||
SortOrder: sortOrderMap.jellyfin[query.sortOrder],
|
||||
StartIndex: query.startIndex,
|
||||
...query._custom?.jellyfin,
|
||||
Years: yearsFilter,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get song list');
|
||||
}
|
||||
|
||||
// Jellyfin Bodge because of code from https://github.com/jellyfin/jellyfin/blob/c566ccb63bf61f9c36743ddb2108a57c65a2519b/Emby.Server.Implementations/Data/SqliteItemRepository.cs#L3622
|
||||
// If the Album ID filter is passed, Jellyfin will search for
|
||||
// 1. the matching album id
|
||||
// 2. An album with the name of the album.
|
||||
// It is this second condition causing issues,
|
||||
if (query.albumIds) {
|
||||
const albumIdSet = new Set(query.albumIds);
|
||||
items = res.body.Items.filter((item) => albumIdSet.has(item.AlbumId!));
|
||||
totalRecordCount = items.length;
|
||||
} else {
|
||||
items = res.body.Items;
|
||||
totalRecordCount = res.body.TotalRecordCount;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -754,7 +797,7 @@ export const JellyfinController: ControllerEndpoint = {
|
||||
jfNormalize.song(item, apiClientProps.server, '', query.imageSize),
|
||||
),
|
||||
startIndex: query.startIndex,
|
||||
totalRecordCount: res.body.TotalRecordCount,
|
||||
totalRecordCount,
|
||||
};
|
||||
},
|
||||
getSongListCount: async ({ apiClientProps, query }) =>
|
||||
@@ -762,6 +805,31 @@ export const JellyfinController: ControllerEndpoint = {
|
||||
apiClientProps,
|
||||
query: { ...query, limit: 1, startIndex: 0 },
|
||||
}).then((result) => result!.totalRecordCount!),
|
||||
getTags: async (args) => {
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
if (!hasFeature(apiClientProps.server, ServerFeature.TAGS)) {
|
||||
return { boolTags: undefined, enumTags: undefined };
|
||||
}
|
||||
|
||||
const res = await jfApiClient(apiClientProps).getFilterList({
|
||||
query: {
|
||||
IncludeItemTypes: query.type === LibraryItem.SONG ? 'Audio' : 'MusicAlbum',
|
||||
ParentId: query.folder,
|
||||
UserId: apiClientProps.server?.userId ?? '',
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('failed to get tags');
|
||||
}
|
||||
|
||||
return {
|
||||
boolTags: res.body.Tags?.sort((a, b) =>
|
||||
a.toLocaleLowerCase().localeCompare(b.toLocaleLowerCase()),
|
||||
),
|
||||
};
|
||||
},
|
||||
getTopSongs: async (args) => {
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
@@ -927,6 +995,7 @@ export const JellyfinController: ControllerEndpoint = {
|
||||
},
|
||||
query: {
|
||||
EnableTotalRecordCount: true,
|
||||
Fields: 'People, Tags',
|
||||
ImageTypeLimit: 1,
|
||||
IncludeItemTypes: 'MusicAlbum',
|
||||
Limit: query.albumLimit,
|
||||
@@ -974,7 +1043,7 @@ export const JellyfinController: ControllerEndpoint = {
|
||||
},
|
||||
query: {
|
||||
EnableTotalRecordCount: true,
|
||||
Fields: 'Genres, DateCreated, MediaSources, ParentId',
|
||||
Fields: 'Genres, DateCreated, MediaSources, ParentId, People, Tags',
|
||||
IncludeItemTypes: 'Audio',
|
||||
Limit: query.songLimit,
|
||||
Recursive: true,
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Genre,
|
||||
ServerListItem,
|
||||
ServerType,
|
||||
RelatedArtist,
|
||||
} from '/@/renderer/api/types';
|
||||
|
||||
const getStreamUrl = (args: {
|
||||
@@ -121,6 +122,48 @@ const getPlaylistCoverArtUrl = (args: { baseUrl: string; item: JFPlaylist; size:
|
||||
);
|
||||
};
|
||||
|
||||
type AlbumOrSong = z.infer<typeof jfType._response.song> | z.infer<typeof jfType._response.album>;
|
||||
|
||||
const getPeople = (item: AlbumOrSong): Record<string, RelatedArtist[]> | null => {
|
||||
if (item.People) {
|
||||
const participants: Record<string, RelatedArtist[]> = {};
|
||||
|
||||
for (const person of item.People) {
|
||||
const key = person.Type || '';
|
||||
const item: RelatedArtist = {
|
||||
// for other roles, we just want to display this and not filter.
|
||||
// filtering (and links) would require a separate field, PersonIds
|
||||
id: '',
|
||||
imageUrl: null,
|
||||
name: person.Name,
|
||||
};
|
||||
|
||||
if (key in participants) {
|
||||
participants[key].push(item);
|
||||
} else {
|
||||
participants[key] = [item];
|
||||
}
|
||||
}
|
||||
|
||||
return participants;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const getTags = (item: AlbumOrSong): Record<string, string[]> | null => {
|
||||
if (item.Tags) {
|
||||
const tags: Record<string, string[]> = {};
|
||||
for (const tag of item.Tags) {
|
||||
tags[tag] = [];
|
||||
}
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const normalizeSong = (
|
||||
item: z.infer<typeof jfType._response.song>,
|
||||
server: ServerListItem | null,
|
||||
@@ -176,7 +219,7 @@ const normalizeSong = (
|
||||
lastPlayedAt: null,
|
||||
lyrics: null,
|
||||
name: item.Name,
|
||||
participants: null,
|
||||
participants: getPeople(item),
|
||||
path: (item.MediaSources && item.MediaSources[0]?.Path) || null,
|
||||
peak: null,
|
||||
playCount: (item.UserData && item.UserData.PlayCount) || 0,
|
||||
@@ -198,6 +241,7 @@ const normalizeSong = (
|
||||
mediaSourceId: item.MediaSources?.[0]?.Id,
|
||||
server,
|
||||
}),
|
||||
tags: getTags(item),
|
||||
trackNumber: item.IndexNumber,
|
||||
uniqueId: nanoid(),
|
||||
updatedAt: item.DateCreated,
|
||||
@@ -247,7 +291,7 @@ const normalizeAlbum = (
|
||||
mbzId: item.ProviderIds?.MusicBrainzAlbum || null,
|
||||
name: item.Name,
|
||||
originalDate: null,
|
||||
participants: null,
|
||||
participants: getPeople(item),
|
||||
playCount: item.UserData?.PlayCount || 0,
|
||||
releaseDate: item.PremiereDate?.split('T')[0] || null,
|
||||
releaseYear: item.ProductionYear || null,
|
||||
@@ -256,6 +300,7 @@ const normalizeAlbum = (
|
||||
size: null,
|
||||
songCount: item?.ChildCount || null,
|
||||
songs: item.Songs?.map((song) => normalizeSong(song, server, '', imageSize)),
|
||||
tags: getTags(item),
|
||||
uniqueId: nanoid(),
|
||||
updatedAt: item?.DateLastMediaAdded || item.DateCreated,
|
||||
userFavorite: item.UserData?.IsFavorite || false,
|
||||
@@ -284,7 +329,7 @@ const normalizeAlbumArtist = (
|
||||
) || [];
|
||||
|
||||
return {
|
||||
albumCount: null,
|
||||
albumCount: item.AlbumCount ?? null,
|
||||
backgroundImageUrl: null,
|
||||
biography: item.Overview || null,
|
||||
duration: item.RunTimeTicks / 10000,
|
||||
@@ -308,7 +353,7 @@ const normalizeAlbumArtist = (
|
||||
serverId: server?.id || '',
|
||||
serverType: ServerType.JELLYFIN,
|
||||
similarArtists,
|
||||
songCount: null,
|
||||
songCount: item.SongCount ?? null,
|
||||
userFavorite: item.UserData?.IsFavorite || false,
|
||||
userRating: null,
|
||||
};
|
||||
|
||||
@@ -387,6 +387,12 @@ const genericItem = z.object({
|
||||
Name: z.string(),
|
||||
});
|
||||
|
||||
const participant = z.object({
|
||||
Id: z.string(),
|
||||
Name: z.string(),
|
||||
Type: z.string().optional(),
|
||||
});
|
||||
|
||||
const songDetailParameters = baseParameters;
|
||||
|
||||
const song = z.object({
|
||||
@@ -415,12 +421,14 @@ const song = z.object({
|
||||
Name: z.string(),
|
||||
NormalizationGain: z.number().optional(),
|
||||
ParentIndexNumber: z.number(),
|
||||
People: participant.array().optional(),
|
||||
PlaylistItemId: z.string().optional(),
|
||||
PremiereDate: z.string().optional(),
|
||||
ProductionYear: z.number(),
|
||||
RunTimeTicks: z.number(),
|
||||
ServerId: z.string(),
|
||||
SortName: z.string(),
|
||||
Tags: z.string().array().optional(),
|
||||
Type: z.string(),
|
||||
UserData: userData.optional(),
|
||||
});
|
||||
@@ -431,6 +439,7 @@ const providerIds = z.object({
|
||||
});
|
||||
|
||||
const albumArtist = z.object({
|
||||
AlbumCount: z.number().optional(),
|
||||
BackdropImageTags: z.array(z.string()),
|
||||
ChannelId: z.null(),
|
||||
DateCreated: z.string(),
|
||||
@@ -446,6 +455,7 @@ const albumArtist = z.object({
|
||||
ProviderIds: providerIds.optional(),
|
||||
RunTimeTicks: z.number(),
|
||||
ServerId: z.string(),
|
||||
SongCount: z.number().optional(),
|
||||
Type: z.string(),
|
||||
UserData: userData.optional(),
|
||||
});
|
||||
@@ -473,12 +483,14 @@ const album = z.object({
|
||||
Name: z.string(),
|
||||
ParentLogoImageTag: z.string(),
|
||||
ParentLogoItemId: z.string(),
|
||||
People: participant.array().optional(),
|
||||
PremiereDate: z.string().optional(),
|
||||
ProductionYear: z.number(),
|
||||
ProviderIds: providerIds.optional(),
|
||||
RunTimeTicks: z.number(),
|
||||
ServerId: z.string(),
|
||||
Songs: z.array(song).optional(), // This is not a native Jellyfin property -- this is used for combined album detail
|
||||
Tags: z.string().array().optional(),
|
||||
Type: z.string(),
|
||||
UserData: userData.optional(),
|
||||
});
|
||||
@@ -685,6 +697,18 @@ export enum JellyfinExtensions {
|
||||
|
||||
const moveItem = z.null();
|
||||
|
||||
const filterListParameters = z.object({
|
||||
IncludeItemTypes: z.string().optional(),
|
||||
ParentId: z.string().optional(),
|
||||
UserId: z.string().optional(),
|
||||
});
|
||||
|
||||
const filters = z.object({
|
||||
Genres: z.string().array().optional(),
|
||||
Tags: z.string().array().optional(),
|
||||
Years: z.number().array().optional(),
|
||||
});
|
||||
|
||||
export const jfType = {
|
||||
_enum: {
|
||||
albumArtistList: albumArtistListSort,
|
||||
@@ -706,6 +730,7 @@ export const jfType = {
|
||||
createPlaylist: createPlaylistParameters,
|
||||
deletePlaylist: deletePlaylistParameters,
|
||||
favorite: favoriteParameters,
|
||||
filterList: filterListParameters,
|
||||
genreList: genreListParameters,
|
||||
musicFolderList: musicFolderListParameters,
|
||||
playlistDetail: playlistDetailParameters,
|
||||
@@ -730,6 +755,7 @@ export const jfType = {
|
||||
deletePlaylist,
|
||||
error,
|
||||
favorite,
|
||||
filters,
|
||||
genre,
|
||||
genreList,
|
||||
lyrics,
|
||||
|
||||
@@ -138,6 +138,14 @@ export const contract = c.router({
|
||||
500: resultWithHeaders(ndType._response.error),
|
||||
},
|
||||
},
|
||||
getTags: {
|
||||
method: 'GET',
|
||||
path: 'tag',
|
||||
responses: {
|
||||
200: resultWithHeaders(ndType._response.tags),
|
||||
500: resultWithHeaders(ndType._response.error),
|
||||
},
|
||||
},
|
||||
getUserList: {
|
||||
method: 'GET',
|
||||
path: 'user',
|
||||
|
||||
@@ -46,6 +46,8 @@ const NAVIDROME_ROLES: Array<string | { label: string; value: string }> = [
|
||||
'remixer',
|
||||
];
|
||||
|
||||
const EXCLUDED_TAGS = new Set<string>(['disctotal', 'genre', 'tracktotal']);
|
||||
|
||||
const excludeMissing = (server: ServerListItem | null) => {
|
||||
if (hasFeature(server, ServerFeature.BFR)) {
|
||||
return { missing: false };
|
||||
@@ -242,6 +244,26 @@ export const NavidromeController: ControllerEndpoint = {
|
||||
apiClientProps.server,
|
||||
);
|
||||
},
|
||||
getAlbumInfo: async (args) => {
|
||||
const { query, apiClientProps } = args;
|
||||
|
||||
const albumInfo = await ssApiClient(apiClientProps).getAlbumInfo2({
|
||||
query: {
|
||||
id: query.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (albumInfo.status !== 200) {
|
||||
throw new Error('Failed to get album info');
|
||||
}
|
||||
|
||||
const info = albumInfo.body.albumInfo;
|
||||
|
||||
return {
|
||||
imageUrl: info.largeImageUrl || info.mediumImageUrl || info.smallImageUrl || null,
|
||||
notes: info.notes || null,
|
||||
};
|
||||
},
|
||||
getAlbumList: async (args) => {
|
||||
const { query, apiClientProps } = args;
|
||||
|
||||
@@ -464,11 +486,12 @@ export const NavidromeController: ControllerEndpoint = {
|
||||
}
|
||||
|
||||
const features: ServerFeatures = {
|
||||
bfr: !!navidromeFeatures[ServerFeature.BFR],
|
||||
lyricsMultipleStructured: !!navidromeFeatures[SubsonicExtensions.SONG_LYRICS],
|
||||
playlistsSmart: !!navidromeFeatures[ServerFeature.PLAYLISTS_SMART],
|
||||
publicPlaylist: true,
|
||||
sharingAlbumSong: !!navidromeFeatures[ServerFeature.SHARING_ALBUM_SONG],
|
||||
bfr: navidromeFeatures[ServerFeature.BFR],
|
||||
lyricsMultipleStructured: navidromeFeatures[SubsonicExtensions.SONG_LYRICS],
|
||||
playlistsSmart: navidromeFeatures[ServerFeature.PLAYLISTS_SMART],
|
||||
publicPlaylist: [1],
|
||||
sharingAlbumSong: navidromeFeatures[ServerFeature.SHARING_ALBUM_SONG],
|
||||
tags: navidromeFeatures[ServerFeature.BFR],
|
||||
};
|
||||
|
||||
return { features, id: apiClientProps.server?.id, version: ping.body.serverVersion! };
|
||||
@@ -577,6 +600,45 @@ export const NavidromeController: ControllerEndpoint = {
|
||||
query: { ...query, limit: 1, startIndex: 0 },
|
||||
}).then((result) => result!.totalRecordCount!),
|
||||
getStructuredLyrics: SubsonicController.getStructuredLyrics,
|
||||
getTags: async (args) => {
|
||||
const { apiClientProps } = args;
|
||||
|
||||
if (!hasFeature(apiClientProps.server, ServerFeature.TAGS)) {
|
||||
return { boolTags: undefined, enumTags: undefined };
|
||||
}
|
||||
|
||||
const res = await ndApiClient(apiClientProps).getTags();
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('failed to get tags');
|
||||
}
|
||||
|
||||
const tagsToValues = new Map<string, string[]>();
|
||||
|
||||
for (const tag of res.body.data) {
|
||||
if (!EXCLUDED_TAGS.has(tag.tagName)) {
|
||||
if (tagsToValues.has(tag.tagName)) {
|
||||
tagsToValues.get(tag.tagName)!.push(tag.tagValue);
|
||||
} else {
|
||||
tagsToValues.set(tag.tagName, [tag.tagValue]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
boolTags: undefined,
|
||||
enumTags: Array.from(tagsToValues)
|
||||
.map((data) => ({
|
||||
name: data[0],
|
||||
options: data[1].sort((a, b) =>
|
||||
a.toLocaleLowerCase().localeCompare(b.toLocaleLowerCase()),
|
||||
),
|
||||
}))
|
||||
.sort((a, b) =>
|
||||
a.name.toLocaleLowerCase().localeCompare(b.name.toLocaleLowerCase()),
|
||||
),
|
||||
};
|
||||
},
|
||||
getTopSongs: SubsonicController.getTopSongs,
|
||||
getTranscodingUrl: SubsonicController.getTranscodingUrl,
|
||||
getUserList: async (args) => {
|
||||
|
||||
@@ -191,6 +191,7 @@ const normalizeSong = (
|
||||
serverType: ServerType.NAVIDROME,
|
||||
size: item.size,
|
||||
streamUrl: `${server?.url}/rest/stream.view?id=${id}&v=1.13.0&c=Feishin&${server?.credential}`,
|
||||
tags: item.tags || null,
|
||||
trackNumber: item.trackNumber,
|
||||
uniqueId: nanoid(),
|
||||
updatedAt: item.updatedAt,
|
||||
@@ -236,17 +237,18 @@ const normalizeAlbum = (
|
||||
isCompilation: item.compilation,
|
||||
itemType: LibraryItem.ALBUM,
|
||||
lastPlayedAt: normalizePlayDate(item),
|
||||
|
||||
mbzId: item.mbzAlbumId || null,
|
||||
name: item.name,
|
||||
originalDate: item.originalDate
|
||||
? new Date(item.originalDate).toISOString()
|
||||
: item.originalYear
|
||||
? new Date(item.originalYear, 0, 1).toISOString()
|
||||
? new Date(Date.UTC(item.originalYear, 0, 1)).toISOString()
|
||||
: null,
|
||||
playCount: item.playCount || 0,
|
||||
releaseDate: (item.releaseDate
|
||||
? new Date(item.releaseDate)
|
||||
: new Date(item.minYear, 0, 1)
|
||||
: new Date(Date.UTC(item.minYear, 0, 1))
|
||||
).toISOString(),
|
||||
releaseYear: item.minYear,
|
||||
serverId: server?.id || 'unknown',
|
||||
@@ -254,6 +256,7 @@ const normalizeAlbum = (
|
||||
size: item.size,
|
||||
songCount: item.songCount,
|
||||
songs: item.songs ? item.songs.map((song) => normalizeSong(song, server)) : undefined,
|
||||
tags: item.tags || null,
|
||||
uniqueId: nanoid(),
|
||||
updatedAt: item.updatedAt,
|
||||
userFavorite: item.starred,
|
||||
@@ -278,11 +281,25 @@ const normalizeAlbumArtist = (
|
||||
});
|
||||
}
|
||||
|
||||
let albumCount: number;
|
||||
let songCount: number;
|
||||
|
||||
if (item.stats) {
|
||||
albumCount = Math.max(
|
||||
item.stats.albumartist?.albumCount ?? 0,
|
||||
item.stats.artist?.albumCount ?? 0,
|
||||
);
|
||||
songCount = Math.max(
|
||||
item.stats.albumartist?.songCount ?? 0,
|
||||
item.stats.artist?.songCount ?? 0,
|
||||
);
|
||||
} else {
|
||||
albumCount = item.albumCount;
|
||||
songCount = item.songCount;
|
||||
}
|
||||
|
||||
return {
|
||||
albumCount: Math.max(
|
||||
item.stats?.albumartist?.albumCount || item.albumCount,
|
||||
item.stats?.artist?.albumCount || 0,
|
||||
),
|
||||
albumCount,
|
||||
backgroundImageUrl: null,
|
||||
biography: item.biography || null,
|
||||
duration: null,
|
||||
@@ -307,7 +324,7 @@ const normalizeAlbumArtist = (
|
||||
imageUrl: artist?.artistImageUrl || null,
|
||||
name: artist.name,
|
||||
})) || null,
|
||||
songCount: item.stats?.albumartist?.songCount || item.songCount,
|
||||
songCount,
|
||||
userFavorite: item.starred,
|
||||
userRating: item.rating,
|
||||
};
|
||||
|
||||
@@ -155,6 +155,7 @@ const album = z.object({
|
||||
sortArtistName: z.string(),
|
||||
starred: z.boolean(),
|
||||
starredAt: z.string().optional(),
|
||||
tags: z.record(z.string(), z.array(z.string())).optional(),
|
||||
updatedAt: z.string(),
|
||||
});
|
||||
|
||||
@@ -337,6 +338,16 @@ const moveItemParameters = z.object({
|
||||
|
||||
const moveItem = z.null();
|
||||
|
||||
const tag = z.object({
|
||||
albumCount: z.number().optional(),
|
||||
id: z.string(),
|
||||
songCount: z.number().optional(),
|
||||
tagName: z.string(),
|
||||
tagValue: z.string(),
|
||||
});
|
||||
|
||||
const tags = z.array(tag);
|
||||
|
||||
export const ndType = {
|
||||
_enum: {
|
||||
albumArtistList: NDAlbumArtistListSort,
|
||||
@@ -382,6 +393,7 @@ export const ndType = {
|
||||
shareItem,
|
||||
song,
|
||||
songList,
|
||||
tags,
|
||||
updatePlaylist,
|
||||
user,
|
||||
userList,
|
||||
|
||||
@@ -294,6 +294,9 @@ export const queryKeys: Record<
|
||||
return [serverId, 'song', 'similar'] as const;
|
||||
},
|
||||
},
|
||||
tags: {
|
||||
list: (serverId: string, type: string) => [serverId, 'tags', type] as const,
|
||||
},
|
||||
users: {
|
||||
list: (serverId: string, query?: UserListQuery) => {
|
||||
if (query) return [serverId, 'users', 'list', query] as const;
|
||||
|
||||
@@ -51,6 +51,14 @@ export const contract = c.router({
|
||||
200: ssType._response.getAlbum,
|
||||
},
|
||||
},
|
||||
getAlbumInfo2: {
|
||||
method: 'GET',
|
||||
path: 'getAlbumInfo2.view',
|
||||
query: ssType._parameters.albumInfo,
|
||||
responses: {
|
||||
200: ssType._response.albumInfo,
|
||||
},
|
||||
},
|
||||
getAlbumList2: {
|
||||
method: 'GET',
|
||||
path: 'getAlbumList2.view',
|
||||
|
||||
@@ -190,7 +190,7 @@ export const SubsonicController: ControllerEndpoint = {
|
||||
|
||||
return {
|
||||
...ssNormalize.albumArtist(artist, apiClientProps.server, 300),
|
||||
albums: artist.album.map((album) => ssNormalize.album(album, apiClientProps.server)),
|
||||
albums: artist.album?.map((album) => ssNormalize.album(album, apiClientProps.server)),
|
||||
similarArtists:
|
||||
artistInfo?.similarArtist?.map((artist) =>
|
||||
ssNormalize.albumArtist(artist, apiClientProps.server, 300),
|
||||
@@ -305,7 +305,7 @@ export const SubsonicController: ControllerEndpoint = {
|
||||
return [];
|
||||
}
|
||||
|
||||
return artist.body.artist.album;
|
||||
return artist.body.artist.album ?? [];
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -529,7 +529,6 @@ export const SubsonicController: ControllerEndpoint = {
|
||||
}
|
||||
|
||||
let artists = (res.body.artists?.index || []).flatMap((index) => index.artist);
|
||||
console.log(artists.length);
|
||||
if (query.role) {
|
||||
artists = artists.filter(
|
||||
(artist) => !artist.roles || artist.roles.includes(query.role!),
|
||||
@@ -811,7 +810,7 @@ export const SubsonicController: ControllerEndpoint = {
|
||||
}
|
||||
|
||||
if (subsonicFeatures[SubsonicExtensions.SONG_LYRICS]) {
|
||||
features.lyricsMultipleStructured = true;
|
||||
features.lyricsMultipleStructured = [1];
|
||||
}
|
||||
|
||||
return { features, id: apiClientProps.server?.id, version: ping.body.serverVersion };
|
||||
@@ -935,7 +934,9 @@ export const SubsonicController: ControllerEndpoint = {
|
||||
};
|
||||
}
|
||||
|
||||
if (query.albumIds || query.artistIds) {
|
||||
const artistIds = query.albumArtistIds || query.artistIds;
|
||||
|
||||
if (query.albumIds || artistIds) {
|
||||
if (query.albumIds) {
|
||||
for (const albumId of query.albumIds) {
|
||||
fromAlbumPromises.push(
|
||||
@@ -948,8 +949,8 @@ export const SubsonicController: ControllerEndpoint = {
|
||||
}
|
||||
}
|
||||
|
||||
if (query.artistIds) {
|
||||
for (const artistId of query.artistIds) {
|
||||
if (artistIds) {
|
||||
for (const artistId of artistIds) {
|
||||
artistDetailPromises.push(
|
||||
ssApiClient(apiClientProps).getArtist({
|
||||
query: {
|
||||
@@ -966,7 +967,7 @@ export const SubsonicController: ControllerEndpoint = {
|
||||
return [];
|
||||
}
|
||||
|
||||
return artist.body.artist.album;
|
||||
return artist.body.artist.album ?? [];
|
||||
});
|
||||
|
||||
const albumIds = albums.map((album) => album.id);
|
||||
|
||||
@@ -181,6 +181,7 @@ const normalizeSong = (
|
||||
serverType: ServerType.SUBSONIC,
|
||||
size: item.size,
|
||||
streamUrl,
|
||||
tags: null,
|
||||
trackNumber: item.track || 1,
|
||||
uniqueId: nanoid(),
|
||||
updatedAt: '',
|
||||
@@ -267,6 +268,7 @@ const normalizeAlbum = (
|
||||
(item as z.infer<typeof ssType._response.album>).song?.map((song) =>
|
||||
normalizeSong(song, server),
|
||||
) || [],
|
||||
tags: item.tags || null,
|
||||
uniqueId: nanoid(),
|
||||
updatedAt: item.created,
|
||||
userFavorite: item.starred || false,
|
||||
|
||||
@@ -156,7 +156,7 @@ const albumListParameters = z.object({
|
||||
const albumList = z.array(album.omit({ song: true }));
|
||||
|
||||
const albumArtist = z.object({
|
||||
album: z.array(album),
|
||||
album: z.array(album).optional(),
|
||||
albumCount: z.string(),
|
||||
artistImageUrl: z.string().optional(),
|
||||
coverArt: z.string().optional(),
|
||||
@@ -522,8 +522,24 @@ const getAlbumList2 = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
const albumInfoParameters = z.object({
|
||||
id: z.string(),
|
||||
});
|
||||
|
||||
const albumInfo = z.object({
|
||||
albumInfo: z.object({
|
||||
largeImageUrl: z.string().optional(),
|
||||
lastFmUrl: z.string().optional(),
|
||||
mediumImageUrl: z.string().optional(),
|
||||
musicBrainzId: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
smallImageUrl: z.string().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const ssType = {
|
||||
_parameters: {
|
||||
albumInfo: albumInfoParameters,
|
||||
albumList: albumListParameters,
|
||||
artistInfo: artistInfoParameters,
|
||||
authenticate: authenticateParameters,
|
||||
@@ -555,6 +571,7 @@ export const ssType = {
|
||||
album,
|
||||
albumArtist,
|
||||
albumArtistList,
|
||||
albumInfo,
|
||||
albumList,
|
||||
albumListEntry,
|
||||
artistInfo,
|
||||
|
||||
@@ -177,6 +177,7 @@ export type Album = {
|
||||
size: number | null;
|
||||
songCount: number | null;
|
||||
songs?: Song[];
|
||||
tags: Record<string, string[]> | null;
|
||||
uniqueId: string;
|
||||
updatedAt: string;
|
||||
userFavorite: boolean;
|
||||
@@ -224,6 +225,7 @@ export type Song = {
|
||||
serverType: ServerType;
|
||||
size: number;
|
||||
streamUrl: string;
|
||||
tags: Record<string, string[]> | null;
|
||||
trackNumber: number;
|
||||
uniqueId: string;
|
||||
updatedAt: string;
|
||||
@@ -465,6 +467,11 @@ export type AlbumDetailQuery = { id: string };
|
||||
|
||||
export type AlbumDetailArgs = { query: AlbumDetailQuery } & BaseEndpointArgs;
|
||||
|
||||
export type AlbumInfo = {
|
||||
imageUrl: string | null;
|
||||
notes: string | null;
|
||||
};
|
||||
|
||||
// Song List
|
||||
export type SongListResponse = BasePaginatedResponse<Song[]> | null | undefined;
|
||||
|
||||
@@ -1232,6 +1239,25 @@ export type TranscodingArgs = {
|
||||
query: TranscodingQuery;
|
||||
} & BaseEndpointArgs;
|
||||
|
||||
export type TagQuery = {
|
||||
folder?: string;
|
||||
type: LibraryItem.ALBUM | LibraryItem.SONG;
|
||||
};
|
||||
|
||||
export type TagArgs = {
|
||||
query: TagQuery;
|
||||
} & BaseEndpointArgs;
|
||||
|
||||
export type Tag = {
|
||||
name: string;
|
||||
options: string[];
|
||||
};
|
||||
|
||||
export type TagResponses = {
|
||||
boolTags?: string[];
|
||||
enumTags?: Tag[];
|
||||
};
|
||||
|
||||
export type ControllerEndpoint = {
|
||||
addToPlaylist: (args: AddToPlaylistArgs) => Promise<AddToPlaylistResponse>;
|
||||
authenticate: (
|
||||
@@ -1246,6 +1272,7 @@ export type ControllerEndpoint = {
|
||||
getAlbumArtistList: (args: AlbumArtistListArgs) => Promise<AlbumArtistListResponse>;
|
||||
getAlbumArtistListCount: (args: AlbumArtistListArgs) => Promise<number>;
|
||||
getAlbumDetail: (args: AlbumDetailArgs) => Promise<AlbumDetailResponse>;
|
||||
getAlbumInfo?: (args: AlbumDetailArgs) => Promise<AlbumInfo>;
|
||||
getAlbumList: (args: AlbumListArgs) => Promise<AlbumListResponse>;
|
||||
getAlbumListCount: (args: AlbumListArgs) => Promise<number>;
|
||||
// getArtistInfo?: (args: any) => void;
|
||||
@@ -1267,6 +1294,7 @@ export type ControllerEndpoint = {
|
||||
getSongList: (args: SongListArgs) => Promise<SongListResponse>;
|
||||
getSongListCount: (args: SongListArgs) => Promise<number>;
|
||||
getStructuredLyrics?: (args: StructuredLyricsArgs) => Promise<StructuredLyric[]>;
|
||||
getTags?: (args: TagArgs) => Promise<TagResponses>;
|
||||
getTopSongs: (args: TopSongListArgs) => Promise<TopSongListResponse>;
|
||||
getTranscodingUrl: (args: TranscodingArgs) => string;
|
||||
getUserList?: (args: UserListArgs) => Promise<UserListResponse>;
|
||||
|
||||
@@ -48,7 +48,7 @@ export const hasFeature = (server: ServerListItem | null, feature: ServerFeature
|
||||
return false;
|
||||
}
|
||||
|
||||
return server.features[feature] ?? false;
|
||||
return (server.features[feature]?.length || 0) > 0;
|
||||
};
|
||||
|
||||
export type VersionInfo = ReadonlyArray<[string, Record<string, readonly number[]>]>;
|
||||
|
||||
@@ -207,11 +207,14 @@ export const FeatureCarousel = ({ data }: FeatureCarouselProps) => {
|
||||
</Badge>
|
||||
))}
|
||||
<Badge size="lg">{currentItem?.releaseYear}</Badge>
|
||||
<Badge size="lg">
|
||||
{t('entity.trackWithCount', {
|
||||
count: currentItem?.songCount || 0,
|
||||
})}
|
||||
</Badge>
|
||||
{currentItem?.songCount !== null &&
|
||||
currentItem?.songCount !== undefined && (
|
||||
<Badge size="lg">
|
||||
{t('entity.trackWithCount', {
|
||||
count: currentItem?.songCount || 0,
|
||||
})}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Group position="apart">
|
||||
<Button
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { MultiSelect, MultiSelectProps, Select, SelectProps } from '/@/renderer/components/select';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
export const SelectWithInvalidData = ({ data, defaultValue, ...props }: SelectProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [fullData, hasError] = useMemo(() => {
|
||||
if (typeof defaultValue === 'string') {
|
||||
const missingField =
|
||||
data.find((item) =>
|
||||
typeof item === 'string' ? item === defaultValue : item.value === defaultValue,
|
||||
) === undefined;
|
||||
|
||||
if (missingField) {
|
||||
return [data.concat(defaultValue), true];
|
||||
}
|
||||
}
|
||||
|
||||
return [data, false];
|
||||
}, [data, defaultValue]);
|
||||
|
||||
return (
|
||||
<Select
|
||||
data={fullData}
|
||||
defaultValue={defaultValue}
|
||||
error={
|
||||
hasError
|
||||
? t('error.badValue', { postProcess: 'sentenceCase', value: defaultValue })
|
||||
: undefined
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const MultiSelectWithInvalidData = ({ data, defaultValue, ...props }: MultiSelectProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [fullData, missing] = useMemo(() => {
|
||||
if (defaultValue?.length) {
|
||||
const validValues = new Set<string>();
|
||||
for (const item of data) {
|
||||
if (typeof item === 'string') {
|
||||
validValues.add(item);
|
||||
} else {
|
||||
validValues.add(item.value);
|
||||
}
|
||||
}
|
||||
|
||||
const missingFields: string[] = [];
|
||||
|
||||
for (const value of defaultValue) {
|
||||
if (!validValues.has(value)) {
|
||||
missingFields.push(value);
|
||||
}
|
||||
}
|
||||
|
||||
if (missingFields.length > 0) {
|
||||
return [data.concat(missingFields), missingFields];
|
||||
}
|
||||
}
|
||||
|
||||
return [data, []];
|
||||
}, [data, defaultValue]);
|
||||
|
||||
return (
|
||||
<MultiSelect
|
||||
data={fullData}
|
||||
defaultValue={defaultValue}
|
||||
error={
|
||||
missing.length
|
||||
? t('error.badValue', { postProcess: 'sentenceCase', value: missing })
|
||||
: undefined
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
import { Select as MantineSelect, MultiSelect as MantineMultiSelect } from '@mantine/core';
|
||||
import styled from 'styled-components';
|
||||
|
||||
interface SelectProps extends MantineSelectProps {
|
||||
export interface SelectProps extends MantineSelectProps {
|
||||
maxWidth?: number | string;
|
||||
width?: number | string;
|
||||
}
|
||||
|
||||
@@ -388,8 +388,8 @@ export const useVirtualTable = <TFilter extends BaseQuery<any>>({
|
||||
break;
|
||||
case LibraryItem.ARTIST:
|
||||
navigate(
|
||||
generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
|
||||
albumArtistId: e.data.id,
|
||||
generatePath(AppRoute.LIBRARY_ARTISTS_DETAIL, {
|
||||
artistId: e.data.id,
|
||||
}),
|
||||
);
|
||||
break;
|
||||
|
||||
@@ -10,10 +10,12 @@ import {
|
||||
LibraryItem,
|
||||
SortOrder,
|
||||
} from '/@/renderer/api/types';
|
||||
import { MultiSelect, NumberInput, SpinnerIcon, Switch, Text } from '/@/renderer/components';
|
||||
import { NumberInput, SpinnerIcon, Switch, Text } from '/@/renderer/components';
|
||||
import { useAlbumArtistList } from '/@/renderer/features/artists/queries/album-artist-list-query';
|
||||
import { useGenreList } from '/@/renderer/features/genres';
|
||||
import { AlbumListFilter, useListStoreActions } from '/@/renderer/store';
|
||||
import { useTagList } from '/@/renderer/features/tag/queries/use-tag-list';
|
||||
import { MultiSelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data';
|
||||
|
||||
interface JellyfinAlbumFiltersProps {
|
||||
customFilters?: Partial<AlbumListFilter>;
|
||||
@@ -53,6 +55,18 @@ export const JellyfinAlbumFilters = ({
|
||||
}));
|
||||
}, [genreListQuery.data]);
|
||||
|
||||
const tagsQuery = useTagList({
|
||||
query: {
|
||||
folder: filter?.musicFolderId,
|
||||
type: LibraryItem.ALBUM,
|
||||
},
|
||||
serverId,
|
||||
});
|
||||
|
||||
const selectedTags = useMemo(() => {
|
||||
return filter?._custom?.jellyfin?.Tags?.split('|');
|
||||
}, [filter?._custom?.jellyfin?.Tags]);
|
||||
|
||||
const toggleFilters = [
|
||||
{
|
||||
label: t('filter.isFavorited', { postProcess: 'sentenceCase' }),
|
||||
@@ -150,6 +164,24 @@ export const JellyfinAlbumFilters = ({
|
||||
onFilterChange(updatedFilters);
|
||||
};
|
||||
|
||||
const handleTagFilter = debounce((e: string[] | undefined) => {
|
||||
const updatedFilters = setFilter({
|
||||
customFilters,
|
||||
data: {
|
||||
_custom: {
|
||||
...filter?._custom,
|
||||
jellyfin: {
|
||||
...filter?._custom?.jellyfin,
|
||||
Tags: e?.join('|') || undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
itemType: LibraryItem.SONG,
|
||||
key: pageKey,
|
||||
}) as AlbumListFilter;
|
||||
onFilterChange(updatedFilters);
|
||||
}, 250);
|
||||
|
||||
return (
|
||||
<Stack p="0.8rem">
|
||||
{toggleFilters.map((filter) => (
|
||||
@@ -187,7 +219,7 @@ export const JellyfinAlbumFilters = ({
|
||||
/>
|
||||
</Group>
|
||||
<Group grow>
|
||||
<MultiSelect
|
||||
<MultiSelectWithInvalidData
|
||||
clearable
|
||||
searchable
|
||||
data={genreList}
|
||||
@@ -198,7 +230,7 @@ export const JellyfinAlbumFilters = ({
|
||||
</Group>
|
||||
|
||||
<Group grow>
|
||||
<MultiSelect
|
||||
<MultiSelectWithInvalidData
|
||||
clearable
|
||||
searchable
|
||||
data={selectableAlbumArtists}
|
||||
@@ -213,6 +245,19 @@ export const JellyfinAlbumFilters = ({
|
||||
onSearchChange={setAlbumArtistSearchTerm}
|
||||
/>
|
||||
</Group>
|
||||
{tagsQuery.data?.boolTags?.length && (
|
||||
<Group grow>
|
||||
<MultiSelectWithInvalidData
|
||||
clearable
|
||||
searchable
|
||||
data={tagsQuery.data.boolTags}
|
||||
defaultValue={selectedTags}
|
||||
label={t('common.tags', { postProcess: 'sentenceCase' })}
|
||||
width={250}
|
||||
onChange={handleTagFilter}
|
||||
/>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ChangeEvent, useMemo, useState } from 'react';
|
||||
import { Divider, Group, Stack } from '@mantine/core';
|
||||
import { NumberInput, Switch, Text, Select, SpinnerIcon } from '/@/renderer/components';
|
||||
import { NumberInput, Switch, Text, SpinnerIcon } from '/@/renderer/components';
|
||||
import { AlbumListFilter, useListStoreActions, useListStoreByKey } from '/@/renderer/store';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { useGenreList } from '/@/renderer/features/genres';
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
SortOrder,
|
||||
} from '/@/renderer/api/types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTagList } from '/@/renderer/features/tag/queries/use-tag-list';
|
||||
import { SelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data';
|
||||
|
||||
interface NavidromeAlbumFiltersProps {
|
||||
customFilters?: Partial<AlbumListFilter>;
|
||||
@@ -63,6 +65,13 @@ export const NavidromeAlbumFilters = ({
|
||||
onFilterChange(updatedFilters);
|
||||
}, 250);
|
||||
|
||||
const tagsQuery = useTagList({
|
||||
query: {
|
||||
type: LibraryItem.ALBUM,
|
||||
},
|
||||
serverId,
|
||||
});
|
||||
|
||||
const toggleFilters = [
|
||||
{
|
||||
label: t('filter.isRated', { postProcess: 'sentenceCase' }),
|
||||
@@ -200,6 +209,25 @@ export const NavidromeAlbumFilters = ({
|
||||
onFilterChange(updatedFilters);
|
||||
};
|
||||
|
||||
const handleTagFilter = debounce((tag: string, e: string | null) => {
|
||||
const updatedFilters = setFilter({
|
||||
customFilters,
|
||||
data: {
|
||||
_custom: {
|
||||
...filter._custom,
|
||||
navidrome: {
|
||||
...filter._custom?.navidrome,
|
||||
[tag]: e || undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
itemType: LibraryItem.SONG,
|
||||
key: pageKey,
|
||||
}) as AlbumListFilter;
|
||||
|
||||
onFilterChange(updatedFilters);
|
||||
}, 250);
|
||||
|
||||
return (
|
||||
<Stack p="0.8rem">
|
||||
{toggleFilters.map((filter) => (
|
||||
@@ -224,7 +252,7 @@ export const NavidromeAlbumFilters = ({
|
||||
min={0}
|
||||
onChange={(e) => handleYearFilter(e)}
|
||||
/>
|
||||
<Select
|
||||
<SelectWithInvalidData
|
||||
clearable
|
||||
searchable
|
||||
data={genreList}
|
||||
@@ -234,7 +262,7 @@ export const NavidromeAlbumFilters = ({
|
||||
/>
|
||||
</Group>
|
||||
<Group grow>
|
||||
<Select
|
||||
<SelectWithInvalidData
|
||||
clearable
|
||||
searchable
|
||||
data={selectableAlbumArtists}
|
||||
@@ -248,6 +276,25 @@ export const NavidromeAlbumFilters = ({
|
||||
onSearchChange={setAlbumArtistSearchTerm}
|
||||
/>
|
||||
</Group>
|
||||
{tagsQuery.data?.enumTags?.length &&
|
||||
tagsQuery.data.enumTags.map((tag) => (
|
||||
<Group
|
||||
key={tag.name}
|
||||
grow
|
||||
>
|
||||
<SelectWithInvalidData
|
||||
clearable
|
||||
searchable
|
||||
data={tag.options}
|
||||
defaultValue={
|
||||
filter._custom?.navidrome?.[tag.name] as string | undefined
|
||||
}
|
||||
label={tag.name}
|
||||
width={150}
|
||||
onChange={(value) => handleTagFilter(tag.name, value)}
|
||||
/>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -16,7 +16,7 @@ import { queryClient } from '/@/renderer/lib/react-query';
|
||||
import { useCurrentServer, useListFilterByKey } from '/@/renderer/store';
|
||||
import { Play } from '/@/renderer/types';
|
||||
import { useGenreList } from '/@/renderer/features/genres';
|
||||
import { titleCase } from '/@/renderer/utils';
|
||||
import { sentenceCase, titleCase } from '/@/renderer/utils';
|
||||
import { useAlbumListCount } from '/@/renderer/features/albums/queries/album-list-count-query';
|
||||
|
||||
const AlbumListRoute = () => {
|
||||
@@ -129,9 +129,9 @@ const AlbumListRoute = () => {
|
||||
|
||||
const artist = searchParams.get('artistName');
|
||||
const title = artist
|
||||
? t('page.albumList.artistAlbums', { artist })
|
||||
? sentenceCase(t('page.albumList.artistAlbums', { artist }))
|
||||
: genreId
|
||||
? t('page.albumList.genreAlbums', { genre: titleCase(genreTitle) })
|
||||
? sentenceCase(t('page.albumList.genreAlbums', { genre: titleCase(genreTitle) }))
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
|
||||
@@ -297,7 +297,7 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
|
||||
handlePlayQueueAdd?.({
|
||||
byItemType: {
|
||||
id: [routeId],
|
||||
type: albumArtistId ? LibraryItem.ALBUM : LibraryItem.ALBUM_ARTIST,
|
||||
type: albumArtistId ? LibraryItem.ALBUM_ARTIST : LibraryItem.ARTIST,
|
||||
},
|
||||
playType: playType || playButtonBehavior,
|
||||
});
|
||||
@@ -340,9 +340,15 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
|
||||
}
|
||||
};
|
||||
|
||||
const albumCount = detailQuery?.data?.albumCount;
|
||||
const artistContextItems =
|
||||
(albumCount ?? 1) > 0
|
||||
? ARTIST_CONTEXT_MENU_ITEMS
|
||||
: ARTIST_CONTEXT_MENU_ITEMS.filter((item) => !item.id.toLowerCase().includes('play'));
|
||||
|
||||
const handleGeneralContextMenu = useHandleGeneralContextMenu(
|
||||
LibraryItem.ALBUM_ARTIST,
|
||||
ARTIST_CONTEXT_MENU_ITEMS,
|
||||
artistContextItems,
|
||||
);
|
||||
|
||||
const topSongs = topSongsQuery?.data?.items?.slice(0, 10);
|
||||
@@ -369,7 +375,10 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
|
||||
<LibraryBackgroundOverlay $backgroundColor={background} />
|
||||
<DetailContainer>
|
||||
<Group spacing="md">
|
||||
<PlayButton onClick={() => handlePlay(playButtonBehavior)} />
|
||||
<PlayButton
|
||||
disabled={albumCount === 0}
|
||||
onClick={() => handlePlay(playButtonBehavior)}
|
||||
/>
|
||||
<Group spacing="xs">
|
||||
<Button
|
||||
compact
|
||||
|
||||
@@ -28,25 +28,29 @@ export const AlbumArtistDetailHeader = forwardRef(
|
||||
serverId: server?.id,
|
||||
});
|
||||
|
||||
const albumCount = detailQuery?.data?.albumCount;
|
||||
const songCount = detailQuery?.data?.songCount;
|
||||
const duration = detailQuery?.data?.duration;
|
||||
const durationEnabled = duration !== null && duration !== undefined;
|
||||
|
||||
const metadataItems = [
|
||||
{
|
||||
enabled: detailQuery?.data?.albumCount,
|
||||
enabled: albumCount !== null && albumCount !== undefined,
|
||||
id: 'albumCount',
|
||||
secondary: false,
|
||||
value: t('entity.albumWithCount', { count: detailQuery?.data?.albumCount || 0 }),
|
||||
value: t('entity.albumWithCount', { count: albumCount || 0 }),
|
||||
},
|
||||
{
|
||||
enabled: detailQuery?.data?.songCount,
|
||||
enabled: songCount !== null && songCount !== undefined,
|
||||
id: 'songCount',
|
||||
secondary: false,
|
||||
value: t('entity.trackWithCount', { count: detailQuery?.data?.songCount || 0 }),
|
||||
value: t('entity.trackWithCount', { count: songCount || 0 }),
|
||||
},
|
||||
{
|
||||
enabled: detailQuery.data?.duration,
|
||||
enabled: durationEnabled,
|
||||
id: 'duration',
|
||||
secondary: true,
|
||||
value:
|
||||
detailQuery?.data?.duration && formatDurationString(detailQuery.data.duration),
|
||||
value: durationEnabled && formatDurationString(duration),
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import isElectron from 'is-electron';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import {
|
||||
getServerById,
|
||||
useCurrentSong,
|
||||
useCurrentStatus,
|
||||
useDiscordSetttings,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
import { SetActivity } from '@xhayper/discord-rpc';
|
||||
import { PlayerStatus } from '/@/renderer/types';
|
||||
import { ServerType } from '/@/renderer/api/types';
|
||||
import { controller } from '/@/renderer/api/controller';
|
||||
|
||||
const discordRpc = isElectron() ? window.electron.discordRpc : null;
|
||||
|
||||
@@ -61,16 +63,33 @@ export const useDiscordRpc = () => {
|
||||
activity.smallImageKey = 'paused';
|
||||
}
|
||||
|
||||
if (
|
||||
song?.serverType === ServerType.JELLYFIN &&
|
||||
discordSettings.showServerImage &&
|
||||
song?.imageUrl
|
||||
) {
|
||||
activity.largeImageKey = song?.imageUrl;
|
||||
if (discordSettings.showServerImage && song) {
|
||||
if (song.serverType === ServerType.JELLYFIN && song.imageUrl) {
|
||||
activity.largeImageKey = song.imageUrl;
|
||||
} else if (song.serverType === ServerType.NAVIDROME) {
|
||||
const server = getServerById(song.serverId);
|
||||
|
||||
try {
|
||||
const info = await controller.getAlbumInfo({
|
||||
apiClientProps: { server },
|
||||
query: { id: song.albumId },
|
||||
});
|
||||
|
||||
if (info.imageUrl) {
|
||||
activity.largeImageKey = info.imageUrl;
|
||||
}
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (generalSettings.lastfmApiKey && song?.album && song?.albumArtists.length) {
|
||||
console.log('Fetching album info for', song.album, song.albumArtists[0].name);
|
||||
if (
|
||||
activity.largeImageKey === undefined &&
|
||||
generalSettings.lastfmApiKey &&
|
||||
song?.album &&
|
||||
song?.albumArtists.length
|
||||
) {
|
||||
const albumInfo = await fetch(
|
||||
`https://ws.audioscrobbler.com/2.0/?method=album.getinfo&api_key=${generalSettings.lastfmApiKey}&artist=${encodeURIComponent(song.albumArtists[0].name)}&album=${encodeURIComponent(song.album)}&format=json`,
|
||||
);
|
||||
|
||||
@@ -21,6 +21,7 @@ import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { Separator } from '/@/renderer/components/separator';
|
||||
import { useGenreRoute } from '/@/renderer/hooks/use-genre-route';
|
||||
import { formatDateRelative, formatRating } from '/@/renderer/utils/format';
|
||||
import { SEPARATOR_STRING } from '/@/renderer/api/utils';
|
||||
|
||||
export type ItemDetailsModalProps = {
|
||||
item: Album | AlbumArtist | Song;
|
||||
@@ -277,9 +278,40 @@ const SongPropertyMapping: ItemDetailRow<Song>[] = [
|
||||
{ label: 'filter.comment', render: formatComment },
|
||||
];
|
||||
|
||||
const handleParticipants = (item: Album | Song) => {
|
||||
const handleTags = (item: Album | Song, t: TFunction) => {
|
||||
if (item.tags) {
|
||||
const tags = Object.entries(item.tags).map(([tag, fields]) => {
|
||||
return (
|
||||
<tr key={tag}>
|
||||
<td>
|
||||
{tag.slice(0, 1).toLocaleUpperCase()}
|
||||
{tag.slice(1)}
|
||||
</td>
|
||||
<td>{fields.length === 0 ? BoolField(true) : fields.join(SEPARATOR_STRING)}</td>
|
||||
</tr>
|
||||
);
|
||||
});
|
||||
|
||||
if (tags.length) {
|
||||
return [
|
||||
<tr key="tags">
|
||||
<td>
|
||||
<h3>{t('common.tags', { postProcess: 'sentenceCase' })}</h3>
|
||||
</td>
|
||||
<td>
|
||||
<h3>{tags.length}</h3>
|
||||
</td>
|
||||
</tr>,
|
||||
].concat(tags);
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
const handleParticipants = (item: Album | Song, t: TFunction) => {
|
||||
if (item.participants) {
|
||||
return Object.entries(item.participants).map(([role, participants]) => {
|
||||
const participants = Object.entries(item.participants).map(([role, participants]) => {
|
||||
return (
|
||||
<tr key={role}>
|
||||
<td>
|
||||
@@ -290,6 +322,23 @@ const handleParticipants = (item: Album | Song) => {
|
||||
</tr>
|
||||
);
|
||||
});
|
||||
|
||||
if (participants.length) {
|
||||
return [
|
||||
<tr key="participants">
|
||||
<td>
|
||||
<h3>
|
||||
{t('common.additionalParticipants', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</h3>
|
||||
</td>
|
||||
<td>
|
||||
<h3>{participants.length}</h3>
|
||||
</td>
|
||||
</tr>,
|
||||
].concat(participants);
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
@@ -302,14 +351,16 @@ export const ItemDetailsModal = ({ item }: ItemDetailsModalProps) => {
|
||||
switch (item.itemType) {
|
||||
case LibraryItem.ALBUM:
|
||||
body = AlbumPropertyMapping.map((rule) => handleRow(t, item, rule));
|
||||
body.push(...handleParticipants(item));
|
||||
body.push(...handleParticipants(item, t));
|
||||
body.push(...handleTags(item, t));
|
||||
break;
|
||||
case LibraryItem.ALBUM_ARTIST:
|
||||
body = AlbumArtistPropertyMapping.map((rule) => handleRow(t, item, rule));
|
||||
break;
|
||||
case LibraryItem.SONG:
|
||||
body = SongPropertyMapping.map((rule) => handleRow(t, item, rule));
|
||||
body.push(...handleParticipants(item));
|
||||
body.push(...handleParticipants(item, t));
|
||||
body.push(...handleTags(item, t));
|
||||
break;
|
||||
default:
|
||||
body = [];
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { useCallback, useState, Fragment, useRef } from 'react';
|
||||
import { ActionIcon, Group, Kbd, ScrollArea } from '@mantine/core';
|
||||
import { useDisclosure, useDebouncedValue } from '@mantine/hooks';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RiSearchLine, RiCloseFill } from 'react-icons/ri';
|
||||
import { generatePath, useNavigate } from 'react-router';
|
||||
import styled from 'styled-components';
|
||||
@@ -37,6 +38,7 @@ export const CommandPalette = ({ modalProps }: CommandPaletteProps) => {
|
||||
const activePage = pages[pages.length - 1];
|
||||
const isHome = activePage === CommandPalettePages.HOME;
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const popPage = useCallback(() => {
|
||||
setPages((pages) => {
|
||||
@@ -187,13 +189,17 @@ export const CommandPalette = ({ modalProps }: CommandPaletteProps) => {
|
||||
}}
|
||||
>
|
||||
<LibraryCommandItem
|
||||
disabled={artist?.albumCount === 0}
|
||||
handlePlayQueueAdd={handlePlayQueueAdd}
|
||||
id={artist.id}
|
||||
imageUrl={artist.imageUrl}
|
||||
itemType={LibraryItem.ALBUM_ARTIST}
|
||||
subtitle={
|
||||
(artist?.albumCount || 0) > 0
|
||||
? `${artist.albumCount} albums`
|
||||
artist?.albumCount !== undefined &&
|
||||
artist?.albumCount !== null
|
||||
? t('entity.albumWithCount', {
|
||||
count: artist.albumCount,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
title={artist.name}
|
||||
|
||||
@@ -53,6 +53,7 @@ const StyledImage = styled.img`
|
||||
const ActionsContainer = styled(Flex)``;
|
||||
|
||||
interface LibraryCommandItemProps {
|
||||
disabled?: boolean;
|
||||
handlePlayQueueAdd?: (options: PlayQueueAddOptions) => void;
|
||||
id: string;
|
||||
imageUrl: string | null;
|
||||
@@ -62,6 +63,7 @@ interface LibraryCommandItemProps {
|
||||
}
|
||||
|
||||
export const LibraryCommandItem = ({
|
||||
disabled,
|
||||
id,
|
||||
imageUrl,
|
||||
subtitle,
|
||||
@@ -154,6 +156,7 @@ export const LibraryCommandItem = ({
|
||||
>
|
||||
<Button
|
||||
compact
|
||||
disabled={disabled}
|
||||
size="md"
|
||||
tooltip={{
|
||||
label: t('player.play', { postProcess: 'sentenceCase' }),
|
||||
@@ -166,6 +169,7 @@ export const LibraryCommandItem = ({
|
||||
</Button>
|
||||
<Button
|
||||
compact
|
||||
disabled={disabled}
|
||||
size="md"
|
||||
tooltip={{
|
||||
label: t('player.addLast', { postProcess: 'sentenceCase' }),
|
||||
@@ -179,6 +183,7 @@ export const LibraryCommandItem = ({
|
||||
</Button>
|
||||
<Button
|
||||
compact
|
||||
disabled={disabled}
|
||||
size="md"
|
||||
tooltip={{
|
||||
label: t('player.addNext', { postProcess: 'sentenceCase' }),
|
||||
|
||||
@@ -147,6 +147,32 @@ export const DiscordSettings = () => {
|
||||
postProcess: 'sentenceCase',
|
||||
}),
|
||||
},
|
||||
{
|
||||
control: (
|
||||
<Switch
|
||||
checked={settings.showServerImage}
|
||||
onChange={(e) => {
|
||||
setSettings({
|
||||
discord: {
|
||||
...settings,
|
||||
showServerImage: e.currentTarget.checked,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
),
|
||||
description: t('setting.discordServeImage', {
|
||||
context: 'description',
|
||||
|
||||
discord: 'Discord',
|
||||
postProcess: 'sentenceCase',
|
||||
}),
|
||||
isHidden: !isElectron(),
|
||||
title: t('setting.discordServeImage', {
|
||||
discord: 'Discord',
|
||||
postProcess: 'sentenceCase',
|
||||
}),
|
||||
},
|
||||
{
|
||||
control: (
|
||||
<TextInput
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { forwardRef, ReactNode, Ref, useState } from 'react';
|
||||
import { Group } from '@mantine/core';
|
||||
import { forwardRef, ReactNode, Ref, useCallback, useState } from 'react';
|
||||
import { Center, Group } from '@mantine/core';
|
||||
import { closeAllModals, openModal } from '@mantine/modals';
|
||||
import { AutoTextSize } from 'auto-text-size';
|
||||
import clsx from 'clsx';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -58,6 +59,35 @@ export const LibraryHeader = forwardRef(
|
||||
}
|
||||
};
|
||||
|
||||
const openImage = useCallback(() => {
|
||||
if (imageUrl && !isImageError) {
|
||||
const fullSized = imageUrl.replace(/&?(size|width|height)=\d+/, '');
|
||||
|
||||
openModal({
|
||||
children: (
|
||||
<Center
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
height: 'calc(100vh - 80px)',
|
||||
width: '100%',
|
||||
}}
|
||||
onClick={() => closeAllModals()}
|
||||
>
|
||||
<img
|
||||
alt="cover"
|
||||
src={fullSized}
|
||||
style={{
|
||||
maxHeight: '100%',
|
||||
maxWidth: '100%',
|
||||
}}
|
||||
/>
|
||||
</Center>
|
||||
),
|
||||
fullScreen: true,
|
||||
});
|
||||
}
|
||||
}, [imageUrl, isImageError]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
@@ -72,7 +102,16 @@ export const LibraryHeader = forwardRef(
|
||||
[styles.opaqueOverlay]: albumBackground,
|
||||
})}
|
||||
/>
|
||||
<div className={styles.imageSection}>
|
||||
<div
|
||||
className={styles.imageSection}
|
||||
role="button"
|
||||
style={{ cursor: 'pointer' }}
|
||||
tabIndex={0}
|
||||
onClick={() => openImage()}
|
||||
onKeyDown={(event) =>
|
||||
['Spacebar', ' ', 'Enter'].includes(event.key) && openImage()
|
||||
}
|
||||
>
|
||||
{imageUrl && !isImageError ? (
|
||||
<img
|
||||
alt="cover"
|
||||
|
||||
@@ -15,7 +15,7 @@ const MotionButton = styled(UnstyledButton)`
|
||||
fill: var(--btn-filled-fg);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
&:hover:not([disabled]) {
|
||||
background: var(--btn-filled-bg);
|
||||
transform: scale(1.1);
|
||||
|
||||
@@ -28,6 +28,10 @@ const MotionButton = styled(UnstyledButton)`
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
transition: background-color 0.2s ease-in-out;
|
||||
transition: transform 0.2s ease-in-out;
|
||||
`;
|
||||
|
||||
@@ -2,10 +2,12 @@ import { ChangeEvent, useMemo } from 'react';
|
||||
import { Divider, Group, Stack } from '@mantine/core';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { GenreListSort, LibraryItem, SongListQuery, SortOrder } from '/@/renderer/api/types';
|
||||
import { MultiSelect, NumberInput, Switch, Text } from '/@/renderer/components';
|
||||
import { useGenreList } from '/@/renderer/features/genres';
|
||||
import { NumberInput, Switch, Text } from '/@/renderer/components';
|
||||
import { SongListFilter, useListFilterByKey, useListStoreActions } from '/@/renderer/store';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTagList } from '/@/renderer/features/tag/queries/use-tag-list';
|
||||
import { useGenreList } from '/@/renderer/features/genres';
|
||||
import { MultiSelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data';
|
||||
|
||||
interface JellyfinSongFiltersProps {
|
||||
customFilters?: Partial<SongListFilter>;
|
||||
@@ -24,9 +26,10 @@ export const JellyfinSongFilters = ({
|
||||
const { setFilter } = useListStoreActions();
|
||||
const filter = useListFilterByKey<SongListQuery>({ key: pageKey });
|
||||
|
||||
const isGenrePage = customFilters?._custom?.jellyfin?.GenreIds !== undefined;
|
||||
const isGenrePage = customFilters?.genreIds !== undefined;
|
||||
|
||||
// TODO - eventually replace with /items/filters endpoint to fetch genres and tags specific to the selected library
|
||||
// Despite the fact that getTags returns genres, it only returns genre names.
|
||||
// We prefer using IDs, hence the double query
|
||||
const genreListQuery = useGenreList({
|
||||
query: {
|
||||
musicFolderId: filter?.musicFolderId,
|
||||
@@ -45,10 +48,22 @@ export const JellyfinSongFilters = ({
|
||||
}));
|
||||
}, [genreListQuery.data]);
|
||||
|
||||
const tagsQuery = useTagList({
|
||||
query: {
|
||||
folder: filter?.musicFolderId,
|
||||
type: LibraryItem.SONG,
|
||||
},
|
||||
serverId,
|
||||
});
|
||||
|
||||
const selectedGenres = useMemo(() => {
|
||||
return filter?._custom?.jellyfin?.GenreIds?.split(',');
|
||||
}, [filter?._custom?.jellyfin?.GenreIds]);
|
||||
|
||||
const selectedTags = useMemo(() => {
|
||||
return filter?._custom?.jellyfin?.Tags?.split('|');
|
||||
}, [filter?._custom?.jellyfin?.Tags]);
|
||||
|
||||
const toggleFilters = [
|
||||
{
|
||||
label: t('filter.isFavorited', { postProcess: 'sentenceCase' }),
|
||||
@@ -133,6 +148,25 @@ export const JellyfinSongFilters = ({
|
||||
onFilterChange(updatedFilters);
|
||||
}, 250);
|
||||
|
||||
const handleTagFilter = debounce((e: string[] | undefined) => {
|
||||
const updatedFilters = setFilter({
|
||||
customFilters,
|
||||
data: {
|
||||
_custom: {
|
||||
...filter?._custom,
|
||||
jellyfin: {
|
||||
...filter?._custom?.jellyfin,
|
||||
IncludeItemTypes: 'Audio',
|
||||
Tags: e?.join('|') || undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
itemType: LibraryItem.SONG,
|
||||
key: pageKey,
|
||||
}) as SongListFilter;
|
||||
onFilterChange(updatedFilters);
|
||||
}, 250);
|
||||
|
||||
return (
|
||||
<Stack p="0.8rem">
|
||||
{toggleFilters.map((filter) => (
|
||||
@@ -168,7 +202,7 @@ export const JellyfinSongFilters = ({
|
||||
</Group>
|
||||
{!isGenrePage && (
|
||||
<Group grow>
|
||||
<MultiSelect
|
||||
<MultiSelectWithInvalidData
|
||||
clearable
|
||||
searchable
|
||||
data={genreList}
|
||||
@@ -179,6 +213,19 @@ export const JellyfinSongFilters = ({
|
||||
/>
|
||||
</Group>
|
||||
)}
|
||||
{tagsQuery.data?.boolTags?.length && (
|
||||
<Group grow>
|
||||
<MultiSelectWithInvalidData
|
||||
clearable
|
||||
searchable
|
||||
data={tagsQuery.data.boolTags}
|
||||
defaultValue={selectedTags}
|
||||
label={t('common.tags', { postProcess: 'sentenceCase' })}
|
||||
width={250}
|
||||
onChange={handleTagFilter}
|
||||
/>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,10 +2,12 @@ import { ChangeEvent, useMemo } from 'react';
|
||||
import { Divider, Group, Stack } from '@mantine/core';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { GenreListSort, LibraryItem, SongListQuery, SortOrder } from '/@/renderer/api/types';
|
||||
import { NumberInput, Select, Switch, Text } from '/@/renderer/components';
|
||||
import { NumberInput, Switch, Text } from '/@/renderer/components';
|
||||
import { useGenreList } from '/@/renderer/features/genres';
|
||||
import { SongListFilter, useListFilterByKey, useListStoreActions } from '/@/renderer/store';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTagList } from '/@/renderer/features/tag/queries/use-tag-list';
|
||||
import { SelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data';
|
||||
|
||||
interface NavidromeSongFiltersProps {
|
||||
customFilters?: Partial<SongListFilter>;
|
||||
@@ -35,6 +37,13 @@ export const NavidromeSongFilters = ({
|
||||
serverId,
|
||||
});
|
||||
|
||||
const tagsQuery = useTagList({
|
||||
query: {
|
||||
type: LibraryItem.SONG,
|
||||
},
|
||||
serverId,
|
||||
});
|
||||
|
||||
const genreList = useMemo(() => {
|
||||
if (!genreListQuery?.data) return [];
|
||||
return genreListQuery.data.items.map((genre) => ({
|
||||
@@ -57,6 +66,25 @@ export const NavidromeSongFilters = ({
|
||||
onFilterChange(updatedFilters);
|
||||
}, 250);
|
||||
|
||||
const handleTagFilter = debounce((tag: string, e: string | null) => {
|
||||
const updatedFilters = setFilter({
|
||||
customFilters,
|
||||
data: {
|
||||
_custom: {
|
||||
...filter._custom,
|
||||
navidrome: {
|
||||
...filter._custom?.navidrome,
|
||||
[tag]: e || undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
itemType: LibraryItem.SONG,
|
||||
key: pageKey,
|
||||
}) as SongListFilter;
|
||||
|
||||
onFilterChange(updatedFilters);
|
||||
}, 250);
|
||||
|
||||
const toggleFilters = [
|
||||
{
|
||||
label: t('filter.isFavorited', { postProcess: 'sentenceCase' }),
|
||||
@@ -84,6 +112,7 @@ export const NavidromeSongFilters = ({
|
||||
_custom: {
|
||||
...filter._custom,
|
||||
navidrome: {
|
||||
...filter._custom?.navidrome,
|
||||
year: e === '' ? undefined : (e as number),
|
||||
},
|
||||
},
|
||||
@@ -121,7 +150,7 @@ export const NavidromeSongFilters = ({
|
||||
onChange={(e) => handleYearFilter(e)}
|
||||
/>
|
||||
{!isGenrePage && (
|
||||
<Select
|
||||
<SelectWithInvalidData
|
||||
clearable
|
||||
searchable
|
||||
data={genreList}
|
||||
@@ -132,6 +161,25 @@ export const NavidromeSongFilters = ({
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
{tagsQuery.data?.enumTags?.length &&
|
||||
tagsQuery.data.enumTags.map((tag) => (
|
||||
<Group
|
||||
key={tag.name}
|
||||
grow
|
||||
>
|
||||
<SelectWithInvalidData
|
||||
clearable
|
||||
searchable
|
||||
data={tag.options}
|
||||
defaultValue={
|
||||
filter._custom?.navidrome?.[tag.name] as string | undefined
|
||||
}
|
||||
label={tag.name}
|
||||
width={150}
|
||||
onChange={(value) => handleTagFilter(tag.name, value)}
|
||||
/>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -12,7 +12,7 @@ import { SongListContent } from '/@/renderer/features/songs/components/song-list
|
||||
import { SongListHeader } from '/@/renderer/features/songs/components/song-list-header';
|
||||
import { useCurrentServer, useListFilterByKey } from '/@/renderer/store';
|
||||
import { Play } from '/@/renderer/types';
|
||||
import { titleCase } from '/@/renderer/utils';
|
||||
import { sentenceCase, titleCase } from '/@/renderer/utils';
|
||||
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
|
||||
import { useSongListCount } from '/@/renderer/features/songs/queries/song-list-count-query';
|
||||
|
||||
@@ -122,12 +122,13 @@ const TrackListRoute = () => {
|
||||
|
||||
const artist = searchParams.get('artistName');
|
||||
const title = artist
|
||||
? t('page.trackList.artistTracks', { artist, postProcess: 'sentenceCase' })
|
||||
? sentenceCase(t('page.trackList.artistTracks', { artist }))
|
||||
: genreId
|
||||
? t('page.trackList.genreTracks', {
|
||||
genre: titleCase(genreTitle),
|
||||
postProcess: 'sentenceCase',
|
||||
})
|
||||
? sentenceCase(
|
||||
t('page.trackList.genreTracks', {
|
||||
genre: titleCase(genreTitle),
|
||||
}),
|
||||
)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { QueryHookArgs } from '/@/renderer/lib/react-query';
|
||||
import { getServerById } from '/@/renderer/store';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { hasFeature } from '/@/renderer/api/utils';
|
||||
import { ServerFeature } from '/@/renderer/api/features-types';
|
||||
import { TagQuery } from '/@/renderer/api/types';
|
||||
|
||||
export const useTagList = (args: QueryHookArgs<TagQuery>) => {
|
||||
const { query, options, serverId } = args || {};
|
||||
const server = getServerById(serverId);
|
||||
|
||||
return useQuery({
|
||||
enabled: !!server && hasFeature(server, ServerFeature.TAGS),
|
||||
queryFn: ({ signal }) => {
|
||||
if (!server) throw new Error('Server not found');
|
||||
return api.controller.getTags({ apiClientProps: { server, signal }, query });
|
||||
},
|
||||
queryKey: queryKeys.tags.list(server?.id || '', query.type),
|
||||
staleTime: 1000 * 60,
|
||||
...options,
|
||||
});
|
||||
};
|
||||
@@ -734,6 +734,22 @@ export const useSettingsStore = create<SettingsSlice>()(
|
||||
),
|
||||
{
|
||||
merge: mergeOverridingColumns,
|
||||
migrate(persistedState, version) {
|
||||
if (version === 8) {
|
||||
const state = persistedState as SettingsSlice;
|
||||
state.general.sidebarItems = state.general.sidebarItems.filter(
|
||||
(item) => item.id !== 'Folders',
|
||||
);
|
||||
state.general.sidebarItems.push({
|
||||
disabled: false,
|
||||
id: 'Artists-all',
|
||||
label: i18n.t('page.sidebar.artists'),
|
||||
route: AppRoute.LIBRARY_ARTISTS,
|
||||
});
|
||||
}
|
||||
|
||||
return persistedState;
|
||||
},
|
||||
name: 'store_settings',
|
||||
version: 9,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user