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
This commit is contained in:
Kendall Garner 2025-05-17 21:35:58 -07:00
parent 89e27ec6ff
commit b0d86ee5c9
No known key found for this signature in database
GPG key ID: 9355F387FE765C94
9 changed files with 131 additions and 13 deletions

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",

View file

@ -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,

View file

@ -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,

View file

@ -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(),
});

View file

@ -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,

View file

@ -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(),
});

View file

@ -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,

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;

View file

@ -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 = [];