Compare commits

..

34 Commits

Author SHA1 Message Date
Hosted Weblate 91ce2cd8a1 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (662 of 662 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
2025-05-26 11:11:24 +02:00
Hosted Weblate 4f61e82068 Translated using Weblate (Finnish)
Currently translated at 100.0% (659 of 659 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: jonoafi <joona@jonottaa.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/fi/
Translation: feishin/Translation
2025-05-26 11:11:24 +02:00
Hosted Weblate 1e6673fabd Translated using Weblate (French)
Currently translated at 100.0% (662 of 662 strings)

Co-authored-by: KosmoMoustache <hosted.weblate.org@kosmo.ovh>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/fr/
Translation: feishin/Translation
2025-05-26 11:11:24 +02:00
Hosted Weblate 02951c92af Translated using Weblate (Spanish)
Currently translated at 100.0% (662 of 662 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (659 of 659 strings)

Co-authored-by: Fordas <fordas15@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/es/
Translation: feishin/Translation
2025-05-26 11:11:24 +02:00
Hosted Weblate 05f8fb3114 Translated using Weblate (Czech)
Currently translated at 100.0% (662 of 662 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (659 of 659 strings)

Co-authored-by: Fjuro <git@alius.cz>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/cs/
Translation: feishin/Translation
2025-05-26 11:11:24 +02:00
jeffvli 169da10c1b fix release version 2025-05-26 02:11:14 -07:00
jeffvli 5a79fee77e use missing UTC transform on navidrome dates (#928) 2025-05-26 02:08:08 -07:00
jeffvli 7ef80f14b0 fix casing on filtered list route titels (#929) 2025-05-26 01:58:16 -07:00
jeffvli 36cc37e39f update to v0.13.0 2025-05-26 01:47:13 -07:00
Kendall Garner d4e7c6bd18 fix copypasta 2025-05-20 08:40:24 -07:00
Kendall Garner 90f79b4ae7 add multiselect with invalid data handling to jellyfin album 2025-05-18 18:27:17 -07:00
Kendall Garner cf74625bfc warn if a value in select no longer exists 2025-05-18 10:59:45 -07:00
Kendall Garner f068d6e4b8 actually add type to query key 2025-05-18 09:29:13 -07:00
Kendall Garner e1aa8d74f3 Tag filter support
- Jellyfin: Uses `/items/filters` to get list of boolean tags. Notably, does not use this same filter for genres. Separate filter for song/album
- Navidrome: Uses `/api/tags`, which appears to be album-level as multiple independent selects. Same filter for song/album
2025-05-18 09:23:52 -07:00
Kendall Garner b0d86ee5c9 Support tags, and better participants for servers
- Parses `tags` for Navidrome (mapping string: string[])
- Parses `Tags` (and fetches for it) for Jellyfin (map a string to empty, and display as a bool)
- Clean parsing of participants for Navidrome/Subsonic
- Only show `People` for Jellyfin, not clickable
2025-05-17 21:35:58 -07:00
Kendall Garner 89e27ec6ff remove console.log 2025-05-16 11:50:26 -07:00
Kendall Garner 39c714a137 navidrome cover art workaround 2025-05-15 19:10:15 -07:00
Kendall Garner a8fb7ff11e fullscreen header image on click 2025-05-14 08:25:02 -07:00
jeffvli 9b95f47a91 update to v0.12.7 2025-05-12 18:27:37 -07:00
Hosted Weblate 2267e9bc9d Translated using Weblate (Czech)
Currently translated at 100.0% (657 of 657 strings)

Co-authored-by: Fjuro <fjuro@alius.cz>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/cs/
Translation: feishin/Translation
2025-05-13 03:24:48 +02:00
Kendall Garner 089311c673 add migrate from v8 (#925) 2025-05-12 18:24:42 -07:00
Kendall Garner 773f349b66 don't show song count if not present for home carousel 2025-05-09 19:08:36 -07:00
Kendall Garner 3980c8ea97 save the package-logk.json changes as well 2025-05-08 08:23:58 -07:00
Kendall Garner 257a5ceef0 force xmljs to 0.5.0 2025-05-08 08:08:31 -07:00
jeffvli fb022891fe update to v0.12.6 2025-05-08 00:44:13 -07:00
Kendall Garner 5d9906b8f2 include album artist song/album count for jellyfin, and disable playing/adding playinsts for artists with no albums 2025-05-07 21:16:47 -07:00
jeffvli 6f7cb468b2 fix regression on subsonic album artist play 2025-05-07 20:59:16 -07:00
Kendall Garner 076693e969 Merge branch 'development' of github.com:jeffvli/feishin into development 2025-05-07 20:01:04 -07:00
Kendall Garner 781d8055b5 minor artist count fixes 2025-05-07 19:53:23 -07:00
jeffvli 960bb5c660 fix navigation to detail page on artist list 2025-05-07 19:40:54 -07:00
jeffvli 42bb2bf66f fix regression on album artist play button 2025-05-07 19:25:25 -07:00
jeffvli f03d88cd8c batch jellyfin song list requests when fetching by albumId (#922) 2025-05-07 01:42:32 -07:00
jeffvli 58f6535ba6 revert electron to gtk 3 (#923) 2025-05-07 01:28:54 -07:00
jeffvli 9a59ce3613 fix casing on artist albums title 2025-05-07 01:15:00 -07:00
49 changed files with 984 additions and 162 deletions
+3 -4
View File
@@ -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
View File
@@ -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",
+10 -9
View File
@@ -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"
+7 -1
View File
@@ -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"
}
+10 -5
View File
@@ -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",
+5
View File
@@ -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",
+8 -3
View File
@@ -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",
+3 -1
View File
@@ -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": {
+8 -3
View File
@@ -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": {
+8 -3
View File
@@ -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": "最多播放过",
+3
View File
@@ -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',
+6
View File
@@ -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);
},
+2 -1
View File
@@ -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',
+116 -47
View File
@@ -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,
+3
View File
@@ -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,
+18 -1
View File
@@ -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,
+28
View File
@@ -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>;
+1 -1
View File
@@ -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}
/>
);
};
+1 -1
View File
@@ -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,
});
};
+16
View File
@@ -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,
},