diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 80be1378..5d3a21a6 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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", diff --git a/src/renderer/api/jellyfin/jellyfin-controller.ts b/src/renderer/api/jellyfin/jellyfin-controller.ts index 2c3b4385..9fe65dc2 100644 --- a/src/renderer/api/jellyfin/jellyfin-controller.ts +++ b/src/renderer/api/jellyfin/jellyfin-controller.ts @@ -246,7 +246,7 @@ export const JellyfinController: ControllerEndpoint = { userId: apiClientProps.server.userId, }, query: { - Fields: 'Genres, DateCreated, ChildCount', + Fields: 'Genres, DateCreated, ChildCount, People, Tags', }, }); @@ -255,7 +255,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 +300,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 +524,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 +565,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: @@ -719,7 +720,7 @@ export const JellyfinController: ControllerEndpoint = { query: { AlbumIds: albumIdsFilter, ArtistIds: artistIdsFilter, - Fields: 'Genres, DateCreated, MediaSources, ParentId', + Fields: 'Genres, DateCreated, MediaSources, ParentId, People, Tags', GenreIds: query.genreIds?.join(','), IncludeItemTypes: 'Audio', IsFavorite: query.favorite, @@ -754,7 +755,7 @@ export const JellyfinController: ControllerEndpoint = { query: { AlbumIds: albumIdsFilter, ArtistIds: artistIdsFilter, - Fields: 'Genres, DateCreated, MediaSources, ParentId', + Fields: 'Genres, DateCreated, MediaSources, ParentId, People, Tags', GenreIds: query.genreIds?.join(','), IncludeItemTypes: 'Audio', IsFavorite: query.favorite, @@ -967,6 +968,7 @@ export const JellyfinController: ControllerEndpoint = { }, query: { EnableTotalRecordCount: true, + Fields: 'People, Tags', ImageTypeLimit: 1, IncludeItemTypes: 'MusicAlbum', Limit: query.albumLimit, @@ -1014,7 +1016,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, diff --git a/src/renderer/api/jellyfin/jellyfin-normalize.ts b/src/renderer/api/jellyfin/jellyfin-normalize.ts index 7e64fb9f..5bfd7b25 100644 --- a/src/renderer/api/jellyfin/jellyfin-normalize.ts +++ b/src/renderer/api/jellyfin/jellyfin-normalize.ts @@ -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 | z.infer; + +const getPeople = (item: AlbumOrSong): Record | null => { + if (item.People) { + const participants: Record = {}; + + 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 | null => { + if (item.Tags) { + const tags: Record = {}; + for (const tag of item.Tags) { + tags[tag] = []; + } + + return tags; + } + + return null; +}; + const normalizeSong = ( item: z.infer, 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, diff --git a/src/renderer/api/jellyfin/jellyfin-types.ts b/src/renderer/api/jellyfin/jellyfin-types.ts index 2a2a37b4..1ace9416 100644 --- a/src/renderer/api/jellyfin/jellyfin-types.ts +++ b/src/renderer/api/jellyfin/jellyfin-types.ts @@ -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(), }); @@ -475,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(), }); diff --git a/src/renderer/api/navidrome/navidrome-normalize.ts b/src/renderer/api/navidrome/navidrome-normalize.ts index 9e55f3ba..c1508777 100644 --- a/src/renderer/api/navidrome/navidrome-normalize.ts +++ b/src/renderer/api/navidrome/navidrome-normalize.ts @@ -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,6 +237,7 @@ const normalizeAlbum = ( isCompilation: item.compilation, itemType: LibraryItem.ALBUM, lastPlayedAt: normalizePlayDate(item), + mbzId: item.mbzAlbumId || null, name: item.name, originalDate: item.originalDate @@ -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, diff --git a/src/renderer/api/navidrome/navidrome-types.ts b/src/renderer/api/navidrome/navidrome-types.ts index f6ac617a..3607a2ac 100644 --- a/src/renderer/api/navidrome/navidrome-types.ts +++ b/src/renderer/api/navidrome/navidrome-types.ts @@ -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(), }); diff --git a/src/renderer/api/subsonic/subsonic-normalize.ts b/src/renderer/api/subsonic/subsonic-normalize.ts index 1050285f..7f0e2b56 100644 --- a/src/renderer/api/subsonic/subsonic-normalize.ts +++ b/src/renderer/api/subsonic/subsonic-normalize.ts @@ -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).song?.map((song) => normalizeSong(song, server), ) || [], + tags: item.tags || null, uniqueId: nanoid(), updatedAt: item.created, userFavorite: item.starred || false, diff --git a/src/renderer/api/types.ts b/src/renderer/api/types.ts index 303da366..dff50044 100644 --- a/src/renderer/api/types.ts +++ b/src/renderer/api/types.ts @@ -177,6 +177,7 @@ export type Album = { size: number | null; songCount: number | null; songs?: Song[]; + tags: Record | null; uniqueId: string; updatedAt: string; userFavorite: boolean; @@ -224,6 +225,7 @@ export type Song = { serverType: ServerType; size: number; streamUrl: string; + tags: Record | null; trackNumber: number; uniqueId: string; updatedAt: string; diff --git a/src/renderer/features/item-details/components/item-details-modal.tsx b/src/renderer/features/item-details/components/item-details-modal.tsx index b0984617..1174b740 100644 --- a/src/renderer/features/item-details/components/item-details-modal.tsx +++ b/src/renderer/features/item-details/components/item-details-modal.tsx @@ -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[] = [ { 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 ( + + + {tag.slice(0, 1).toLocaleUpperCase()} + {tag.slice(1)} + + {fields.length === 0 ? BoolField(true) : fields.join(SEPARATOR_STRING)} + + ); + }); + + if (tags.length) { + return [ + + +

{t('common.tags', { postProcess: 'sentenceCase' })}

+ + +

{tags.length}

+ + , + ].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 ( @@ -290,6 +322,23 @@ const handleParticipants = (item: Album | Song) => { ); }); + + if (participants.length) { + return [ + + +

+ {t('common.additionalParticipants', { + postProcess: 'sentenceCase', + })} +

+ + +

{participants.length}

+ + , + ].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 = [];