prepare bfr changes (#882)

* prepare bfr changes

* contributors to subsonic/navidrome

* show performer roles

* Add BFR smart playlist fields

* Fix upload-artifact action to handle v4

---------

Co-authored-by: jeffvli <jeffvictorli@gmail.com>
This commit is contained in:
Kendall Garner 2025-03-09 23:55:27 +00:00 committed by GitHub
parent 571aacbaa0
commit c6d7dc0b32
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 378 additions and 88 deletions

View file

@ -11,7 +11,7 @@ jobs:
strategy: strategy:
matrix: matrix:
os: [macos-latest] os: [macos-latest, ubuntu-latest, windows-latest]
steps: steps:
- name: Checkout git repo - name: Checkout git repo
@ -27,34 +27,69 @@ jobs:
run: | run: |
npm install --legacy-peer-deps npm install --legacy-peer-deps
- name: Build releases - name: Build for Windows
if: ${{ matrix.os == 'windows-latest' }}
uses: nick-invision/retry@v2.8.2 uses: nick-invision/retry@v2.8.2
with: with:
timeout_minutes: 30 timeout_minutes: 30
max_attempts: 3 max_attempts: 3
retry_on: error retry_on: error
command: | command: |
npm run postinstall npm run package:pr:windows
npm run build
npm run package:pr
on_retry_command: npm cache clean --force
- uses: actions/upload-artifact@v3 - name: Build for Linux
if: ${{ matrix.os == 'ubuntu-latest' }}
uses: nick-invision/retry@v2.8.2
with:
timeout_minutes: 30
max_attempts: 3
retry_on: error
command: |
npm run package:pr:linux
- name: Build for MacOS
if: ${{ matrix.os == 'macos-latest' }}
uses: nick-invision/retry@v2.8.2
with:
timeout_minutes: 30
max_attempts: 3
retry_on: error
command: |
npm run package:pr:macos
- name: Zip Windows Binaries
if: ${{ matrix.os == 'windows-latest' }}
shell: pwsh
run: |
Compress-Archive -Path "release/build/*.exe" -DestinationPath "release/build/windows-binaries.zip" -Force
- name: Zip Linux Binaries
if: ${{ matrix.os == 'ubuntu-latest' }}
run: |
zip -r release/build/linux-binaries.zip release/build/*.{AppImage,deb,rpm}
- name: Zip MacOS Binaries
if: ${{ matrix.os == 'macos-latest' }}
run: |
zip -r release/build/macos-binaries.zip release/build/*.dmg
- name: Upload Windows Binaries
if: ${{ matrix.os == 'windows-latest' }}
uses: actions/upload-artifact@v4
with: with:
name: windows-binaries name: windows-binaries
path: | path: release/build/windows-binaries.zip
release/build/*.exe
- uses: actions/upload-artifact@v3 - name: Upload Linux Binaries
if: ${{ matrix.os == 'ubuntu-latest' }}
uses: actions/upload-artifact@v4
with: with:
name: linux-binaries name: linux-binaries
path: | path: release/build/linux-binaries.zip
release/build/*.AppImage
release/build/*.deb
release/build/*.rpm
- uses: actions/upload-artifact@v3 - name: Upload MacOS Binaries
if: ${{ matrix.os == 'macos-latest' }}
uses: actions/upload-artifact@v4
with: with:
name: macos-binaries name: macos-binaries
path: | path: release/build/macos-binaries.zip
release/build/*.dmg

View file

@ -16,6 +16,9 @@
"lint:styles": "npx stylelint **/*.tsx --fix", "lint:styles": "npx stylelint **/*.tsx --fix",
"package": "node --import tsx ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never", "package": "node --import tsx ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never",
"package:pr": "node --import tsx ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never --win --mac --linux", "package:pr": "node --import tsx ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never --win --mac --linux",
"package:pr:macos": "node --import tsx ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never --mac",
"package:pr:windows": "node --import tsx ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never --win",
"package:pr:linux": "node --import tsx ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never --linux",
"package:dev": "node --import tsx ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never --dir", "package:dev": "node --import tsx ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never --dir",
"postinstall": "node --import tsx .erb/scripts/check-native-dep.js && electron-builder install-app-deps && cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.renderer.dev.dll.ts", "postinstall": "node --import tsx .erb/scripts/check-native-dep.js && electron-builder install-app-deps && cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.renderer.dev.dll.ts",
"start": "node --import tsx ./.erb/scripts/check-port-in-use.js && npm run start:renderer", "start": "node --import tsx ./.erb/scripts/check-port-in-use.js && npm run start:renderer",

View file

@ -1,6 +1,7 @@
// Should follow a strict naming convention: "<FEATURE GROUP>_<FEATURE NAME>" // Should follow a strict naming convention: "<FEATURE GROUP>_<FEATURE NAME>"
// For example: <FEATURE GROUP>: "Playlists", <FEATURE NAME>: "Smart" = "PLAYLISTS_SMART" // For example: <FEATURE GROUP>: "Playlists", <FEATURE NAME>: "Smart" = "PLAYLISTS_SMART"
export enum ServerFeature { export enum ServerFeature {
BFR = 'bfr',
LYRICS_MULTIPLE_STRUCTURED = 'lyricsMultipleStructured', LYRICS_MULTIPLE_STRUCTURED = 'lyricsMultipleStructured',
LYRICS_SINGLE_STRUCTURED = 'lyricsSingleStructured', LYRICS_SINGLE_STRUCTURED = 'lyricsSingleStructured',
PLAYLISTS_SMART = 'playlistsSmart', PLAYLISTS_SMART = 'playlistsSmart',

View file

@ -176,6 +176,7 @@ const normalizeSong = (
lastPlayedAt: null, lastPlayedAt: null,
lyrics: null, lyrics: null,
name: item.Name, name: item.Name,
participants: null,
path: (item.MediaSources && item.MediaSources[0]?.Path) || null, path: (item.MediaSources && item.MediaSources[0]?.Path) || null,
peak: null, peak: null,
playCount: (item.UserData && item.UserData.PlayCount) || 0, playCount: (item.UserData && item.UserData.PlayCount) || 0,
@ -235,6 +236,7 @@ const normalizeAlbum = (
})), })),
id: item.Id, id: item.Id,
imagePlaceholderUrl: null, imagePlaceholderUrl: null,
participants: null,
imageUrl: getAlbumCoverArtUrl({ imageUrl: getAlbumCoverArtUrl({
baseUrl: server?.url || '', baseUrl: server?.url || '',
item, item,

View file

@ -381,39 +381,99 @@ export type NDPlaylistSongList = {
export const NDSongQueryFields = [ export const NDSongQueryFields = [
{ label: 'Album', type: 'string', value: 'album' }, { label: 'Album', type: 'string', value: 'album' },
{ label: 'Album Artist', type: 'string', value: 'albumartist' }, { label: 'Album Artist', type: 'string', value: 'albumartist' },
{ label: 'Album Artists', type: 'string', value: 'albumartists' },
{ label: 'Album Comment', type: 'string', value: 'albumcomment' }, { label: 'Album Comment', type: 'string', value: 'albumcomment' },
{ label: 'Album Type', type: 'string', value: 'albumtype' }, { label: 'Album Type', type: 'string', value: 'albumtype' },
{ label: 'Album Version', type: 'string', value: 'albumversion' },
{ label: 'Arranger', type: 'string', value: 'arranger' },
{ label: 'Artist', type: 'string', value: 'artist' }, { label: 'Artist', type: 'string', value: 'artist' },
{ label: 'Artists', type: 'string', value: 'artists' },
{ label: 'Barcode', type: 'string', value: 'barcode' },
{ label: 'Bitrate', type: 'number', value: 'bitrate' }, { label: 'Bitrate', type: 'number', value: 'bitrate' },
{ label: 'BPM', type: 'number', value: 'bpm' }, { label: 'BPM', type: 'number', value: 'bpm' },
{ label: 'Catalog Number', type: 'string', value: 'catalognumber' }, { label: 'Catalog Number', type: 'string', value: 'catalognumber' },
{ label: 'Channels', type: 'number', value: 'channels' }, { label: 'Channels', type: 'number', value: 'channels' },
{ label: 'Comment', type: 'string', value: 'comment' }, { label: 'Comment', type: 'string', value: 'comment' },
{ label: 'Composer', type: 'string', value: 'composer' },
{ label: 'Conductor', type: 'string', value: 'conductor' },
{ label: 'Copyright', type: 'string', value: 'copyright' },
{ label: 'Date Added', type: 'date', value: 'dateadded' }, { label: 'Date Added', type: 'date', value: 'dateadded' },
{ label: 'Date Favorited', type: 'date', value: 'dateloved' }, { label: 'Date Favorited', type: 'date', value: 'dateloved' },
{ label: 'Date Last Played', type: 'date', value: 'lastplayed' }, { label: 'Date Last Played', type: 'date', value: 'lastplayed' },
{ label: 'Date Modified', type: 'date', value: 'datemodified' }, { label: 'Date Modified', type: 'date', value: 'datemodified' },
{ label: 'Disc Subtitle', type: 'string', value: 'discsubtitle' }, { label: 'DJ Mixer', type: 'string', value: 'djmixer' },
{ label: 'Director', type: 'string', value: 'director' },
{ label: 'Disc Number', type: 'number', value: 'discnumber' }, { label: 'Disc Number', type: 'number', value: 'discnumber' },
{ label: 'Disc Subtitle', type: 'string', value: 'discsubtitle' },
{ label: 'Disc Total', type: 'number', value: 'disctotal' },
{ label: 'Duration', type: 'number', value: 'duration' }, { label: 'Duration', type: 'number', value: 'duration' },
{ label: 'Encoded By', type: 'string', value: 'encodedby' },
{ label: 'Encoder Settings', type: 'string', value: 'encodersettings' },
{ label: 'Engineer', type: 'string', value: 'engineer' },
{ label: 'Explicit Status', type: 'string', value: 'explicitstatus' },
{ label: 'File Path', type: 'string', value: 'filepath' }, { label: 'File Path', type: 'string', value: 'filepath' },
{ label: 'File Type', type: 'string', value: 'filetype' }, { label: 'File Type', type: 'string', value: 'filetype' },
{ label: 'Genre', type: 'string', value: 'genre' }, { label: 'Genre', type: 'string', value: 'genre' },
{ label: 'Grouping', type: 'string', value: 'grouping' },
{ label: 'Has CoverArt', type: 'boolean', value: 'hascoverart' }, { label: 'Has CoverArt', type: 'boolean', value: 'hascoverart' },
{ label: 'Playlist', type: 'playlist', value: 'id' },
{ label: 'Is Compilation', type: 'boolean', value: 'compilation' }, { label: 'Is Compilation', type: 'boolean', value: 'compilation' },
{ label: 'Is Favorite', type: 'boolean', value: 'loved' }, { label: 'Is Favorite', type: 'boolean', value: 'loved' },
{ label: 'ISRC', type: 'string', value: 'isrc' },
{ label: 'Key', type: 'string', value: 'key' },
{ label: 'Language', type: 'string', value: 'language' },
{ label: 'License', type: 'string', value: 'license' },
{ label: 'Lyricist', type: 'string', value: 'lyricist' },
{ label: 'Lyrics', type: 'string', value: 'lyrics' }, { label: 'Lyrics', type: 'string', value: 'lyrics' },
{ label: 'Media', type: 'string', value: 'media' },
{ label: 'Mixer', type: 'string', value: 'mixer' },
{ label: 'Mood', type: 'string', value: 'mood' },
{ label: 'Movement', type: 'string', value: 'movement' },
{ label: 'Movement Name', type: 'string', value: 'movementname' },
{ label: 'Movement Total', type: 'number', value: 'movementtotal' },
{ label: 'MusicBrainz Artist Id', type: 'string', value: 'musicbrainz_albumartistid' },
{ label: 'MusicBrainz Album Artist Id', type: 'string', value: 'musicbrainz_albumartistid' },
{ label: 'MusicBrainz Album Id', type: 'string', value: 'musicbrainz_albumid' },
{ label: 'MusicBrainz Disc Id', type: 'string', value: 'musicbrainz_discid' },
{ label: 'MusicBrainz Recording Id', type: 'string', value: 'musicbrainz_recordingid' },
{ label: 'MusicBrainz Release Group Id', type: 'string', value: 'musicbrainz_releasegroupid' },
{ label: 'MusicBrainz Track Id', type: 'string', value: 'musicbrainz_trackid' },
{ label: 'MusicBrainz Work Id', type: 'string', value: 'musicbrainz_workid' },
{ label: 'Name', type: 'string', value: 'title' }, { label: 'Name', type: 'string', value: 'title' },
{ label: 'Original Date', type: 'date', value: 'originaldate' },
{ label: 'Performer', type: 'string', value: 'performer' },
{ label: 'Play Count', type: 'number', value: 'playcount' }, { label: 'Play Count', type: 'number', value: 'playcount' },
{ label: 'Playlist', type: 'playlist', value: 'id' },
{ label: 'Producer', type: 'string', value: 'producer' },
{ label: 'R128 Album Gain', type: 'number', value: 'r128_album_gain' },
{ label: 'R128 Track Gain', type: 'number', value: 'r128_track_gain' },
{ label: 'Rating', type: 'number', value: 'rating' }, { label: 'Rating', type: 'number', value: 'rating' },
{ label: 'Record Label', type: 'string', value: 'recordlabel' },
{ label: 'Recording Date', type: 'date', value: 'recordingdate' },
{ label: 'Release Country', type: 'string', value: 'releasecountry' },
{ label: 'Release Date', type: 'date', value: 'releasedate' },
{ label: 'Release Status', type: 'string', value: 'releasestatus' },
{ label: 'Release Type', type: 'string', value: 'releasetype' },
{ label: 'ReplayGain Album Gain', type: 'number', value: 'replaygain_album_gain' },
{ label: 'ReplayGain Album Peak', type: 'number', value: 'replaygain_album_peak' },
{ label: 'ReplayGain Track Gain', type: 'number', value: 'replaygain_track_gain' },
{ label: 'ReplayGain Track Peak', type: 'number', value: 'replaygain_track_peak' },
{ label: 'Remixer', type: 'string', value: 'remixer' },
{ label: 'Script', type: 'string', value: 'script' },
{ label: 'Size', type: 'number', value: 'size' }, { label: 'Size', type: 'number', value: 'size' },
{ label: 'Sort Album', type: 'string', value: 'sortalbum' }, { label: 'Sort Album', type: 'string', value: 'albumsort' },
{ label: 'Sort Album Artist', type: 'string', value: 'sortalbumartist' }, { label: 'Sort Album Artist', type: 'string', value: 'albumartistsort' },
{ label: 'Sort Artist', type: 'string', value: 'sortartist' }, { label: 'Sort Album Artists', type: 'string', value: 'albumartistssort' },
{ label: 'Sort Name', type: 'string', value: 'sorttitle' }, { label: 'Sort Artist', type: 'string', value: 'artistsort' },
{ label: 'Track Number', type: 'number', value: 'tracknumber' }, { label: 'Sort Artists', type: 'string', value: 'artistssort' },
{ label: 'Sort Composer', type: 'string', value: 'composersort' },
{ label: 'Sort Lyricist', type: 'string', value: 'lyricistsort' },
{ label: 'Sort Name', type: 'string', value: 'titlesort' },
{ label: 'Subtitle', type: 'string', value: 'subtitle' },
{ label: 'Track Number', type: 'number', value: 'track' },
{ label: 'Track Total', type: 'number', value: 'tracktotal' },
{ label: 'Year', type: 'number', value: 'year' }, { label: 'Year', type: 'number', value: 'year' },
{ label: 'Website', type: 'string', value: 'website' },
{ label: 'Work', type: 'string', value: 'work' },
]; ];
export const NDSongQueryPlaylistOperators = [ export const NDSongQueryPlaylistOperators = [

View file

@ -15,6 +15,7 @@ import {
genreListSortMap, genreListSortMap,
Song, Song,
ControllerEndpoint, ControllerEndpoint,
ServerListItem,
} from '../types'; } from '../types';
import { VersionInfo, getFeatures, hasFeature } from '/@/renderer/api/utils'; import { VersionInfo, getFeatures, hasFeature } from '/@/renderer/api/utils';
import { ServerFeature, ServerFeatures } from '/@/renderer/api/features-types'; import { ServerFeature, ServerFeatures } from '/@/renderer/api/features-types';
@ -24,10 +25,19 @@ import { ssNormalize } from '/@/renderer/api/subsonic/subsonic-normalize';
import { SubsonicController } from '/@/renderer/api/subsonic/subsonic-controller'; import { SubsonicController } from '/@/renderer/api/subsonic/subsonic-controller';
const VERSION_INFO: VersionInfo = [ const VERSION_INFO: VersionInfo = [
['0.55.0', { [ServerFeature.BFR]: [1] }],
['0.49.3', { [ServerFeature.SHARING_ALBUM_SONG]: [1] }], ['0.49.3', { [ServerFeature.SHARING_ALBUM_SONG]: [1] }],
['0.48.0', { [ServerFeature.PLAYLISTS_SMART]: [1] }], ['0.48.0', { [ServerFeature.PLAYLISTS_SMART]: [1] }],
]; ];
const excludeMissing = (server: ServerListItem | null) => {
if (hasFeature(server, ServerFeature.BFR)) {
return { missing: false };
}
return undefined;
};
export const NavidromeController: ControllerEndpoint = { export const NavidromeController: ControllerEndpoint = {
addToPlaylist: async (args) => { addToPlaylist: async (args) => {
const { body, query, apiClientProps } = args; const { body, query, apiClientProps } = args;
@ -159,6 +169,7 @@ export const NavidromeController: ControllerEndpoint = {
_start: query.startIndex, _start: query.startIndex,
name: query.searchTerm, name: query.searchTerm,
...query._custom?.navidrome, ...query._custom?.navidrome,
role: hasFeature(apiClientProps.server, ServerFeature.BFR) ? 'albumartist' : '',
}, },
}); });
@ -231,6 +242,7 @@ export const NavidromeController: ControllerEndpoint = {
name: query.searchTerm, name: query.searchTerm,
...query._custom?.navidrome, ...query._custom?.navidrome,
starred: query.favorite, starred: query.favorite,
...excludeMissing(apiClientProps.server),
}, },
}); });
@ -367,6 +379,10 @@ export const NavidromeController: ControllerEndpoint = {
throw new Error('Failed to ping server'); throw new Error('Failed to ping server');
} }
if (ping.body.serverVersion?.includes('pr-2709')) {
ping.body.serverVersion = '0.55.0';
}
const navidromeFeatures: Record<string, number[]> = getFeatures( const navidromeFeatures: Record<string, number[]> = getFeatures(
VERSION_INFO, VERSION_INFO,
ping.body.serverVersion!, ping.body.serverVersion!,
@ -390,6 +406,7 @@ export const NavidromeController: ControllerEndpoint = {
} }
const features: ServerFeatures = { const features: ServerFeatures = {
bfr: !!navidromeFeatures[ServerFeature.BFR],
lyricsMultipleStructured: !!navidromeFeatures[SubsonicExtensions.SONG_LYRICS], lyricsMultipleStructured: !!navidromeFeatures[SubsonicExtensions.SONG_LYRICS],
playlistsSmart: !!navidromeFeatures[ServerFeature.PLAYLISTS_SMART], playlistsSmart: !!navidromeFeatures[ServerFeature.PLAYLISTS_SMART],
publicPlaylist: true, publicPlaylist: true,
@ -479,6 +496,7 @@ export const NavidromeController: ControllerEndpoint = {
starred: query.favorite, starred: query.favorite,
title: query.searchTerm, title: query.searchTerm,
...query._custom?.navidrome, ...query._custom?.navidrome,
...excludeMissing(apiClientProps.server),
}, },
}); });

View file

@ -9,6 +9,7 @@ import {
Genre, Genre,
ServerListItem, ServerListItem,
ServerType, ServerType,
RelatedArtist,
} from '/@/renderer/api/types'; } from '/@/renderer/api/types';
import z from 'zod'; import z from 'zod';
import { ndType } from './navidrome-types'; import { ndType } from './navidrome-types';
@ -54,6 +55,70 @@ const normalizePlayDate = (item: WithDate): string | null => {
return !item.playDate || item.playDate.includes('0001-') ? null : item.playDate; return !item.playDate || item.playDate.includes('0001-') ? null : item.playDate;
}; };
const getArtists = (
item:
| z.infer<typeof ndType._response.song>
| z.infer<typeof ndType._response.playlistSong>
| z.infer<typeof ndType._response.album>,
) => {
let albumArtists: RelatedArtist[] | undefined;
let artists: RelatedArtist[] | undefined;
let participants: Record<string, RelatedArtist[]> | null = null;
if (item.participants) {
participants = {};
for (const [role, list] of Object.entries(item.participants)) {
if (role === 'albumartist' || role === 'artist') {
const roleList = list.map((item) => ({
id: item.id,
imageUrl: null,
name: item.name,
}));
if (role === 'albumartist') {
albumArtists = roleList;
} else {
artists = roleList;
}
} else {
const subRoles = new Map<string | undefined, RelatedArtist[]>();
for (const artist of list) {
const item: RelatedArtist = {
id: artist.id,
imageUrl: null,
name: artist.name,
};
if (subRoles.has(artist.subRole)) {
subRoles.get(artist.subRole)!.push(item);
} else {
subRoles.set(artist.subRole, [item]);
}
}
for (const [subRole, items] of subRoles.entries()) {
if (subRole) {
participants[`${role} (${subRole})`] = items;
} else {
participants[role] = items;
}
}
}
}
}
if (albumArtists === undefined) {
albumArtists = [{ id: item.albumArtistId, imageUrl: null, name: item.albumArtist }];
}
if (artists === undefined) {
artists = [{ id: item.artistId, imageUrl: null, name: item.artist }];
}
return { albumArtists, artists, participants };
};
const normalizeSong = ( const normalizeSong = (
item: z.infer<typeof ndType._response.song> | z.infer<typeof ndType._response.playlistSong>, item: z.infer<typeof ndType._response.song> | z.infer<typeof ndType._response.playlistSong>,
server: ServerListItem | null, server: ServerListItem | null,
@ -80,10 +145,9 @@ const normalizeSong = (
const imagePlaceholderUrl = null; const imagePlaceholderUrl = null;
return { return {
album: item.album, album: item.album,
albumArtists: [{ id: item.albumArtistId, imageUrl: null, name: item.albumArtist }],
albumId: item.albumId, albumId: item.albumId,
...getArtists(item),
artistName: item.artist, artistName: item.artist,
artists: [{ id: item.artistId, imageUrl: null, name: item.artist }],
bitRate: item.bitRate, bitRate: item.bitRate,
bpm: item.bpm ? item.bpm : null, bpm: item.bpm ? item.bpm : null,
channels: item.channels ? item.channels : null, channels: item.channels ? item.channels : null,
@ -116,7 +180,7 @@ const normalizeSong = (
item.rgAlbumPeak || item.rgTrackPeak item.rgAlbumPeak || item.rgTrackPeak
? { album: item.rgAlbumPeak, track: item.rgTrackPeak } ? { album: item.rgAlbumPeak, track: item.rgTrackPeak }
: null, : null,
playCount: item.playCount, playCount: item.playCount || 0,
playlistItemId, playlistItemId,
releaseDate: (item.releaseDate releaseDate: (item.releaseDate
? new Date(item.releaseDate) ? new Date(item.releaseDate)
@ -155,12 +219,11 @@ const normalizeAlbum = (
return { return {
albumArtist: item.albumArtist, albumArtist: item.albumArtist,
albumArtists: [{ id: item.albumArtistId, imageUrl: null, name: item.albumArtist }], ...getArtists(item),
artists: [{ id: item.artistId, imageUrl: null, name: item.artist }],
backdropImageUrl: imageBackdropUrl, backdropImageUrl: imageBackdropUrl,
comment: item.comment || null, comment: item.comment || null,
createdAt: item.createdAt.split('T')[0], createdAt: item.createdAt.split('T')[0],
duration: item.duration * 1000 || null, duration: item.duration !== undefined ? item.duration * 1000 : null,
genres: (item.genres || []).map((genre) => ({ genres: (item.genres || []).map((genre) => ({
id: genre.id, id: genre.id,
imageUrl: null, imageUrl: null,
@ -180,7 +243,7 @@ const normalizeAlbum = (
: item.originalYear : item.originalYear
? new Date(item.originalYear, 0, 1).toISOString() ? new Date(item.originalYear, 0, 1).toISOString()
: null, : null,
playCount: item.playCount, playCount: item.playCount || 0,
releaseDate: (item.releaseDate releaseDate: (item.releaseDate
? new Date(item.releaseDate) ? new Date(item.releaseDate)
: new Date(item.minYear, 0, 1) : new Date(item.minYear, 0, 1)
@ -232,7 +295,7 @@ const normalizeAlbumArtist = (
lastPlayedAt: normalizePlayDate(item), lastPlayedAt: normalizePlayDate(item),
mbz: item.mbzArtistId || null, mbz: item.mbzArtistId || null,
name: item.name, name: item.name,
playCount: item.playCount, playCount: item.playCount || 0,
serverId: server?.id || 'unknown', serverId: server?.id || 'unknown',
serverType: ServerType.NAVIDROME, serverType: ServerType.NAVIDROME,
similarArtists: similarArtists:

View file

@ -83,7 +83,7 @@ const albumArtist = z.object({
mediumImageUrl: z.string().optional(), mediumImageUrl: z.string().optional(),
name: z.string(), name: z.string(),
orderArtistName: z.string(), orderArtistName: z.string(),
playCount: z.number(), playCount: z.number().optional(),
playDate: z.string().optional(), playDate: z.string().optional(),
rating: z.number(), rating: z.number(),
size: z.number(), size: z.number(),
@ -98,10 +98,20 @@ const albumArtistList = z.array(albumArtist);
const albumArtistListParameters = paginationParameters.extend({ const albumArtistListParameters = paginationParameters.extend({
_sort: z.nativeEnum(NDAlbumArtistListSort).optional(), _sort: z.nativeEnum(NDAlbumArtistListSort).optional(),
genre_id: z.string().optional(), genre_id: z.string().optional(),
missing: z.boolean().optional(),
name: z.string().optional(), name: z.string().optional(),
role: z.string().optional(),
starred: z.boolean().optional(), starred: z.boolean().optional(),
}); });
const participant = z.object({
id: z.string(),
name: z.string(),
subRole: z.string().optional(),
});
const participants = z.record(z.string(), z.array(participant));
const album = z.object({ const album = z.object({
albumArtist: z.string(), albumArtist: z.string(),
albumArtistId: z.string(), albumArtistId: z.string(),
@ -113,7 +123,7 @@ const album = z.object({
coverArtId: z.string().optional(), // Removed after v0.48.0 coverArtId: z.string().optional(), // Removed after v0.48.0
coverArtPath: z.string().optional(), // Removed after v0.48.0 coverArtPath: z.string().optional(), // Removed after v0.48.0
createdAt: z.string(), createdAt: z.string(),
duration: z.number(), duration: z.number().optional(),
fullText: z.string(), fullText: z.string(),
genre: z.string(), genre: z.string(),
genres: z.array(genre).nullable(), genres: z.array(genre).nullable(),
@ -127,7 +137,8 @@ const album = z.object({
orderAlbumName: z.string(), orderAlbumName: z.string(),
originalDate: z.string().optional(), originalDate: z.string().optional(),
originalYear: z.number().optional(), originalYear: z.number().optional(),
playCount: z.number(), participants: z.optional(participants),
playCount: z.number().optional(),
playDate: z.string().optional(), playDate: z.string().optional(),
rating: z.number().optional(), rating: z.number().optional(),
releaseDate: z.string().optional(), releaseDate: z.string().optional(),
@ -195,8 +206,9 @@ const song = z.object({
orderAlbumName: z.string(), orderAlbumName: z.string(),
orderArtistName: z.string(), orderArtistName: z.string(),
orderTitle: z.string(), orderTitle: z.string(),
participants: z.optional(participants),
path: z.string(), path: z.string(),
playCount: z.number(), playCount: z.number().optional(),
playDate: z.string().optional(), playDate: z.string().optional(),
rating: z.number().optional(), rating: z.number().optional(),
releaseDate: z.string().optional(), releaseDate: z.string().optional(),
@ -211,6 +223,7 @@ const song = z.object({
starred: z.boolean(), starred: z.boolean(),
starredAt: z.string().optional(), starredAt: z.string().optional(),
suffix: z.string(), suffix: z.string(),
tags: z.record(z.string(), z.array(z.string())).optional(),
title: z.string(), title: z.string(),
trackNumber: z.number(), trackNumber: z.number(),
updatedAt: z.string(), updatedAt: z.string(),

View file

@ -10,6 +10,7 @@ import {
ServerType, ServerType,
Playlist, Playlist,
Genre, Genre,
RelatedArtist,
} from '/@/renderer/api/types'; } from '/@/renderer/api/types';
const getCoverArtUrl = (args: { const getCoverArtUrl = (args: {
@ -34,6 +35,92 @@ const getCoverArtUrl = (args: {
); );
}; };
const getArtists = (
item:
| z.infer<typeof ssType._response.song>
| z.infer<typeof ssType._response.album>
| z.infer<typeof ssType._response.albumListEntry>,
) => {
const albumArtists: RelatedArtist[] = item.albumArtists
? item.albumArtists.map((item) => ({
id: item.id.toString(),
imageUrl: null,
name: item.name,
}))
: [
{
id: item.artistId?.toString() || '',
imageUrl: null,
name: item.artist || '',
},
];
const artists: RelatedArtist[] = item.artists
? item.artists.map((item) => ({
id: item.id.toString(),
imageUrl: null,
name: item.name,
}))
: [
{
id: item.artistId?.toString() || '',
imageUrl: null,
name: item.artist || '',
},
];
let participants: Record<string, RelatedArtist[]> | null = null;
if (item.contributors) {
participants = {};
for (const contributor of item.contributors) {
const artist = {
id: contributor.artist.id?.toString() || '',
imageUrl: null,
name: contributor.artist.name || '',
};
const role = contributor.subRole
? `${contributor.role} (${contributor.subRole})`
: contributor.role;
if (role in participants) {
participants[role].push(artist);
} else {
participants[role] = [artist];
}
}
}
return { albumArtists, artists, participants };
};
const getGenres = (
item:
| z.infer<typeof ssType._response.song>
| z.infer<typeof ssType._response.album>
| z.infer<typeof ssType._response.albumListEntry>,
): Genre[] => {
return item.genres
? item.genres.map((genre) => ({
id: genre.name,
imageUrl: null,
itemType: LibraryItem.GENRE,
name: genre.name,
}))
: item.genre
? [
{
id: item.genre,
imageUrl: null,
itemType: LibraryItem.GENRE,
name: item.genre,
},
]
: [];
};
const normalizeSong = ( const normalizeSong = (
item: z.infer<typeof ssType._response.song>, item: z.infer<typeof ssType._response.song>,
server: ServerListItem | null, server: ServerListItem | null,
@ -51,22 +138,9 @@ const normalizeSong = (
return { return {
album: item.album || '', album: item.album || '',
albumArtists: [
{
id: item.artistId?.toString() || '',
imageUrl: null,
name: item.artist || '',
},
],
albumId: item.albumId?.toString() || '', albumId: item.albumId?.toString() || '',
artistName: item.artist || '', artistName: item.artist || '',
artists: [ ...getArtists(item),
{
id: item.artistId?.toString() || '',
imageUrl: null,
name: item.artist || '',
},
],
bitRate: item.bitRate || 0, bitRate: item.bitRate || 0,
bpm: item.bpm || null, bpm: item.bpm || null,
channels: null, channels: null,
@ -84,16 +158,7 @@ const normalizeSong = (
track: item.replayGain.trackGain, track: item.replayGain.trackGain,
} }
: null, : null,
genres: item.genre genres: getGenres(item),
? [
{
id: item.genre,
imageUrl: null,
itemType: LibraryItem.GENRE,
name: item.genre,
},
]
: [],
id: item.id.toString(), id: item.id.toString(),
imagePlaceholderUrl: null, imagePlaceholderUrl: null,
imageUrl, imageUrl,
@ -176,26 +241,12 @@ const normalizeAlbum = (
return { return {
albumArtist: item.artist, albumArtist: item.artist,
albumArtists: item.artistId ...getArtists(item),
? [{ id: item.artistId.toString(), imageUrl: null, name: item.artist }]
: [],
artists: item.artistId
? [{ id: item.artistId.toString(), imageUrl: null, name: item.artist }]
: [],
backdropImageUrl: null, backdropImageUrl: null,
comment: null, comment: null,
createdAt: item.created, createdAt: item.created,
duration: item.duration * 1000, duration: item.duration * 1000,
genres: item.genre genres: getGenres(item),
? [
{
id: item.genre,
imageUrl: null,
itemType: LibraryItem.GENRE,
name: item.genre,
},
]
: [],
id: item.id.toString(), id: item.id.toString(),
imagePlaceholderUrl: null, imagePlaceholderUrl: null,
imageUrl, imageUrl,

View file

@ -66,15 +66,29 @@ const genreItem = z.object({
name: z.string(), name: z.string(),
}); });
const simpleArtist = z.object({
id: z.string(),
name: z.string(),
});
const contributor = z.object({
artist: simpleArtist,
role: z.string(),
subRole: z.string().optional(),
});
const song = z.object({ const song = z.object({
album: z.string().optional(), album: z.string().optional(),
albumArtists: z.array(simpleArtist),
albumId: id.optional(), albumId: id.optional(),
artist: z.string().optional(), artist: z.string().optional(),
artistId: id.optional(), artistId: id.optional(),
artists: z.array(simpleArtist),
averageRating: z.number().optional(), averageRating: z.number().optional(),
bitRate: z.number().optional(), bitRate: z.number().optional(),
bpm: z.number().optional(), bpm: z.number().optional(),
contentType: z.string(), contentType: z.string(),
contributors: z.array(contributor).optional(),
coverArt: z.string().optional(), coverArt: z.string().optional(),
created: z.string(), created: z.string(),
discNumber: z.number(), discNumber: z.number(),
@ -101,12 +115,16 @@ const song = z.object({
const album = z.object({ const album = z.object({
album: z.string(), album: z.string(),
albumArtists: z.array(simpleArtist),
artist: z.string(), artist: z.string(),
artistId: id, artistId: id,
artists: z.array(simpleArtist),
contributors: z.array(contributor).optional(),
coverArt: z.string(), coverArt: z.string(),
created: z.string(), created: z.string(),
duration: z.number(), duration: z.number(),
genre: z.string().optional(), genre: z.string().optional(),
genres: z.array(genreItem).optional(),
id, id,
isCompilation: z.boolean().optional(), isCompilation: z.boolean().optional(),
isDir: z.boolean(), isDir: z.boolean(),

View file

@ -168,6 +168,7 @@ export type Album = {
mbzId: string | null; mbzId: string | null;
name: string; name: string;
originalDate: string | null; originalDate: string | null;
participants: Record<string, RelatedArtist[]> | null;
playCount: number | null; playCount: number | null;
releaseDate: string | null; releaseDate: string | null;
releaseYear: number | null; releaseYear: number | null;
@ -212,6 +213,7 @@ export type Song = {
lastPlayedAt: string | null; lastPlayedAt: string | null;
lyrics: string | null; lyrics: string | null;
name: string; name: string;
participants: Record<string, RelatedArtist[]> | null;
path: string | null; path: string | null;
peak: GainInfo | null; peak: GainInfo | null;
playCount: number; playCount: number;

View file

@ -88,10 +88,7 @@ export const AlbumArtistListGridView = ({ itemCount, gridRef }: AlbumArtistListG
server, server,
signal, signal,
}, },
query: { query,
limit,
...filter,
},
}), }),
{ cacheTime: 1000 * 60 * 1 }, { cacheTime: 1000 * 60 * 1 },
); );

View file

@ -2,7 +2,14 @@ import { Group, Table } from '@mantine/core';
import { RiCheckFill, RiCloseFill } from 'react-icons/ri'; import { RiCheckFill, RiCloseFill } from 'react-icons/ri';
import { TFunction, useTranslation } from 'react-i18next'; import { TFunction, useTranslation } from 'react-i18next';
import { ReactNode } from 'react'; import { ReactNode } from 'react';
import { Album, AlbumArtist, AnyLibraryItem, LibraryItem, Song } from '/@/renderer/api/types'; import {
Album,
AlbumArtist,
AnyLibraryItem,
LibraryItem,
RelatedArtist,
Song,
} from '/@/renderer/api/types';
import { formatDurationString, formatSizeString } from '/@/renderer/utils'; import { formatDurationString, formatSizeString } from '/@/renderer/utils';
import { replaceURLWithHTMLLinks } from '/@/renderer/utils/linkify'; import { replaceURLWithHTMLLinks } from '/@/renderer/utils/linkify';
import { Spoiler, Text } from '/@/renderer/components'; import { Spoiler, Text } from '/@/renderer/components';
@ -46,8 +53,8 @@ const handleRow = <T extends AnyLibraryItem>(t: TFunction, item: T, rule: ItemDe
); );
}; };
const formatArtists = (isAlbumArtist: boolean) => (item: Album | Song) => const formatArtists = (artists: RelatedArtist[] | undefined | null) =>
(isAlbumArtist ? item.albumArtists : item.artists)?.map((artist, index) => ( artists?.map((artist, index) => (
<span key={artist.id || artist.name}> <span key={artist.id || artist.name}>
{index > 0 && <Separator />} {index > 0 && <Separator />}
{artist.id ? ( {artist.id ? (
@ -106,7 +113,7 @@ const BoolField = (key: boolean) =>
const AlbumPropertyMapping: ItemDetailRow<Album>[] = [ const AlbumPropertyMapping: ItemDetailRow<Album>[] = [
{ key: 'name', label: 'common.title' }, { key: 'name', label: 'common.title' },
{ label: 'entity.albumArtist_one', render: formatArtists(true) }, { label: 'entity.albumArtist_one', render: (item) => formatArtists(item.albumArtists) },
{ label: 'entity.genre_other', render: FormatGenre }, { label: 'entity.genre_other', render: FormatGenre },
{ {
label: 'common.duration', label: 'common.duration',
@ -198,8 +205,8 @@ const AlbumArtistPropertyMapping: ItemDetailRow<AlbumArtist>[] = [
const SongPropertyMapping: ItemDetailRow<Song>[] = [ const SongPropertyMapping: ItemDetailRow<Song>[] = [
{ key: 'name', label: 'common.title' }, { key: 'name', label: 'common.title' },
{ key: 'path', label: 'common.path', render: SongPath }, { key: 'path', label: 'common.path', render: SongPath },
{ label: 'entity.albumArtist_one', render: formatArtists(true) }, { label: 'entity.albumArtist_one', render: (item) => formatArtists(item.albumArtists) },
{ key: 'artists', label: 'entity.artist_other', render: formatArtists(false) }, { key: 'artists', label: 'entity.artist_other', render: (item) => formatArtists(item.artists) },
{ {
key: 'album', key: 'album',
label: 'entity.album_one', label: 'entity.album_one',
@ -270,22 +277,42 @@ const SongPropertyMapping: ItemDetailRow<Song>[] = [
{ label: 'filter.comment', render: formatComment }, { label: 'filter.comment', render: formatComment },
]; ];
const handleParticipants = (item: Album | Song) => {
if (item.participants) {
return Object.entries(item.participants).map(([role, participants]) => {
return (
<tr key={role}>
<td>
{role.slice(0, 1).toLocaleUpperCase()}
{role.slice(1)}
</td>
<td>{formatArtists(participants)}</td>
</tr>
);
});
}
return [];
};
export const ItemDetailsModal = ({ item }: ItemDetailsModalProps) => { export const ItemDetailsModal = ({ item }: ItemDetailsModalProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
let body: ReactNode; let body: ReactNode[] = [];
switch (item.itemType) { switch (item.itemType) {
case LibraryItem.ALBUM: case LibraryItem.ALBUM:
body = AlbumPropertyMapping.map((rule) => handleRow(t, item, rule)); body = AlbumPropertyMapping.map((rule) => handleRow(t, item, rule));
body.push(...handleParticipants(item));
break; break;
case LibraryItem.ALBUM_ARTIST: case LibraryItem.ALBUM_ARTIST:
body = AlbumArtistPropertyMapping.map((rule) => handleRow(t, item, rule)); body = AlbumArtistPropertyMapping.map((rule) => handleRow(t, item, rule));
break; break;
case LibraryItem.SONG: case LibraryItem.SONG:
body = SongPropertyMapping.map((rule) => handleRow(t, item, rule)); body = SongPropertyMapping.map((rule) => handleRow(t, item, rule));
body.push(...handleParticipants(item));
break; break;
default: default:
body = null; body = [];
} }
return ( return (