diff --git a/.github/workflows/publish-pr.yml b/.github/workflows/publish-pr.yml index 2008ceb3..a6f12b06 100644 --- a/.github/workflows/publish-pr.yml +++ b/.github/workflows/publish-pr.yml @@ -11,7 +11,7 @@ jobs: strategy: matrix: - os: [macos-latest] + os: [macos-latest, ubuntu-latest, windows-latest] steps: - name: Checkout git repo @@ -27,34 +27,69 @@ jobs: run: | npm install --legacy-peer-deps - - name: Build releases + - name: Build for Windows + if: ${{ matrix.os == 'windows-latest' }} uses: nick-invision/retry@v2.8.2 with: timeout_minutes: 30 max_attempts: 3 retry_on: error command: | - npm run postinstall - npm run build - npm run package:pr - on_retry_command: npm cache clean --force + npm run package:pr:windows - - 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: name: windows-binaries - path: | - release/build/*.exe + path: release/build/windows-binaries.zip - - uses: actions/upload-artifact@v3 + - name: Upload Linux Binaries + if: ${{ matrix.os == 'ubuntu-latest' }} + uses: actions/upload-artifact@v4 with: name: linux-binaries - path: | - release/build/*.AppImage - release/build/*.deb - release/build/*.rpm + path: release/build/linux-binaries.zip - - uses: actions/upload-artifact@v3 + - name: Upload MacOS Binaries + if: ${{ matrix.os == 'macos-latest' }} + uses: actions/upload-artifact@v4 with: name: macos-binaries - path: | - release/build/*.dmg + path: release/build/macos-binaries.zip diff --git a/package.json b/package.json index ff8b08ab..69eb7e93 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,9 @@ "lint:styles": "npx stylelint **/*.tsx --fix", "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: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", "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", diff --git a/src/renderer/api/features-types.ts b/src/renderer/api/features-types.ts index fb5e1318..a7997d92 100644 --- a/src/renderer/api/features-types.ts +++ b/src/renderer/api/features-types.ts @@ -1,6 +1,7 @@ // Should follow a strict naming convention: "_" // For example: : "Playlists", : "Smart" = "PLAYLISTS_SMART" export enum ServerFeature { + BFR = 'bfr', LYRICS_MULTIPLE_STRUCTURED = 'lyricsMultipleStructured', LYRICS_SINGLE_STRUCTURED = 'lyricsSingleStructured', PLAYLISTS_SMART = 'playlistsSmart', diff --git a/src/renderer/api/jellyfin/jellyfin-normalize.ts b/src/renderer/api/jellyfin/jellyfin-normalize.ts index 368545d7..bb6642a1 100644 --- a/src/renderer/api/jellyfin/jellyfin-normalize.ts +++ b/src/renderer/api/jellyfin/jellyfin-normalize.ts @@ -176,6 +176,7 @@ const normalizeSong = ( lastPlayedAt: null, lyrics: null, name: item.Name, + participants: null, path: (item.MediaSources && item.MediaSources[0]?.Path) || null, peak: null, playCount: (item.UserData && item.UserData.PlayCount) || 0, @@ -235,6 +236,7 @@ const normalizeAlbum = ( })), id: item.Id, imagePlaceholderUrl: null, + participants: null, imageUrl: getAlbumCoverArtUrl({ baseUrl: server?.url || '', item, diff --git a/src/renderer/api/navidrome.types.ts b/src/renderer/api/navidrome.types.ts index 0b34b4ad..81eef3a0 100644 --- a/src/renderer/api/navidrome.types.ts +++ b/src/renderer/api/navidrome.types.ts @@ -381,39 +381,99 @@ export type NDPlaylistSongList = { export const NDSongQueryFields = [ { label: 'Album', type: 'string', value: 'album' }, { label: 'Album Artist', type: 'string', value: 'albumartist' }, + { label: 'Album Artists', type: 'string', value: 'albumartists' }, { label: 'Album Comment', type: 'string', value: 'albumcomment' }, { 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: 'Artists', type: 'string', value: 'artists' }, + { label: 'Barcode', type: 'string', value: 'barcode' }, { label: 'Bitrate', type: 'number', value: 'bitrate' }, { label: 'BPM', type: 'number', value: 'bpm' }, { label: 'Catalog Number', type: 'string', value: 'catalognumber' }, { label: 'Channels', type: 'number', value: 'channels' }, { 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 Favorited', type: 'date', value: 'dateloved' }, { label: 'Date Last Played', type: 'date', value: 'lastplayed' }, { 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 Subtitle', type: 'string', value: 'discsubtitle' }, + { label: 'Disc Total', type: 'number', value: 'disctotal' }, { 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 Type', type: 'string', value: 'filetype' }, { label: 'Genre', type: 'string', value: 'genre' }, + { label: 'Grouping', type: 'string', value: 'grouping' }, { label: 'Has CoverArt', type: 'boolean', value: 'hascoverart' }, - { label: 'Playlist', type: 'playlist', value: 'id' }, { label: 'Is Compilation', type: 'boolean', value: 'compilation' }, { 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: '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: 'Original Date', type: 'date', value: 'originaldate' }, + { label: 'Performer', type: 'string', value: 'performer' }, { 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: '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: 'Sort Album', type: 'string', value: 'sortalbum' }, - { label: 'Sort Album Artist', type: 'string', value: 'sortalbumartist' }, - { label: 'Sort Artist', type: 'string', value: 'sortartist' }, - { label: 'Sort Name', type: 'string', value: 'sorttitle' }, - { label: 'Track Number', type: 'number', value: 'tracknumber' }, + { label: 'Sort Album', type: 'string', value: 'albumsort' }, + { label: 'Sort Album Artist', type: 'string', value: 'albumartistsort' }, + { label: 'Sort Album Artists', type: 'string', value: 'albumartistssort' }, + { label: 'Sort Artist', type: 'string', value: 'artistsort' }, + { 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: 'Website', type: 'string', value: 'website' }, + { label: 'Work', type: 'string', value: 'work' }, ]; export const NDSongQueryPlaylistOperators = [ diff --git a/src/renderer/api/navidrome/navidrome-controller.ts b/src/renderer/api/navidrome/navidrome-controller.ts index 23e21400..20f35594 100644 --- a/src/renderer/api/navidrome/navidrome-controller.ts +++ b/src/renderer/api/navidrome/navidrome-controller.ts @@ -15,6 +15,7 @@ import { genreListSortMap, Song, ControllerEndpoint, + ServerListItem, } from '../types'; import { VersionInfo, getFeatures, hasFeature } from '/@/renderer/api/utils'; 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'; const VERSION_INFO: VersionInfo = [ + ['0.55.0', { [ServerFeature.BFR]: [1] }], ['0.49.3', { [ServerFeature.SHARING_ALBUM_SONG]: [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 = { addToPlaylist: async (args) => { const { body, query, apiClientProps } = args; @@ -159,6 +169,7 @@ export const NavidromeController: ControllerEndpoint = { _start: query.startIndex, name: query.searchTerm, ...query._custom?.navidrome, + role: hasFeature(apiClientProps.server, ServerFeature.BFR) ? 'albumartist' : '', }, }); @@ -231,6 +242,7 @@ export const NavidromeController: ControllerEndpoint = { name: query.searchTerm, ...query._custom?.navidrome, starred: query.favorite, + ...excludeMissing(apiClientProps.server), }, }); @@ -367,6 +379,10 @@ export const NavidromeController: ControllerEndpoint = { throw new Error('Failed to ping server'); } + if (ping.body.serverVersion?.includes('pr-2709')) { + ping.body.serverVersion = '0.55.0'; + } + const navidromeFeatures: Record = getFeatures( VERSION_INFO, ping.body.serverVersion!, @@ -390,6 +406,7 @@ export const NavidromeController: ControllerEndpoint = { } const features: ServerFeatures = { + bfr: !!navidromeFeatures[ServerFeature.BFR], lyricsMultipleStructured: !!navidromeFeatures[SubsonicExtensions.SONG_LYRICS], playlistsSmart: !!navidromeFeatures[ServerFeature.PLAYLISTS_SMART], publicPlaylist: true, @@ -479,6 +496,7 @@ export const NavidromeController: ControllerEndpoint = { starred: query.favorite, title: query.searchTerm, ...query._custom?.navidrome, + ...excludeMissing(apiClientProps.server), }, }); diff --git a/src/renderer/api/navidrome/navidrome-normalize.ts b/src/renderer/api/navidrome/navidrome-normalize.ts index debbba40..60579e4d 100644 --- a/src/renderer/api/navidrome/navidrome-normalize.ts +++ b/src/renderer/api/navidrome/navidrome-normalize.ts @@ -9,6 +9,7 @@ import { Genre, ServerListItem, ServerType, + RelatedArtist, } from '/@/renderer/api/types'; import z from 'zod'; 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; }; +const getArtists = ( + item: + | z.infer + | z.infer + | z.infer, +) => { + let albumArtists: RelatedArtist[] | undefined; + let artists: RelatedArtist[] | undefined; + let participants: Record | 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(); + + 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 = ( item: z.infer | z.infer, server: ServerListItem | null, @@ -80,10 +145,9 @@ const normalizeSong = ( const imagePlaceholderUrl = null; return { album: item.album, - albumArtists: [{ id: item.albumArtistId, imageUrl: null, name: item.albumArtist }], albumId: item.albumId, + ...getArtists(item), artistName: item.artist, - artists: [{ id: item.artistId, imageUrl: null, name: item.artist }], bitRate: item.bitRate, bpm: item.bpm ? item.bpm : null, channels: item.channels ? item.channels : null, @@ -116,7 +180,7 @@ const normalizeSong = ( item.rgAlbumPeak || item.rgTrackPeak ? { album: item.rgAlbumPeak, track: item.rgTrackPeak } : null, - playCount: item.playCount, + playCount: item.playCount || 0, playlistItemId, releaseDate: (item.releaseDate ? new Date(item.releaseDate) @@ -155,12 +219,11 @@ const normalizeAlbum = ( return { albumArtist: item.albumArtist, - albumArtists: [{ id: item.albumArtistId, imageUrl: null, name: item.albumArtist }], - artists: [{ id: item.artistId, imageUrl: null, name: item.artist }], + ...getArtists(item), backdropImageUrl: imageBackdropUrl, comment: item.comment || null, createdAt: item.createdAt.split('T')[0], - duration: item.duration * 1000 || null, + duration: item.duration !== undefined ? item.duration * 1000 : null, genres: (item.genres || []).map((genre) => ({ id: genre.id, imageUrl: null, @@ -180,7 +243,7 @@ const normalizeAlbum = ( : item.originalYear ? new Date(item.originalYear, 0, 1).toISOString() : null, - playCount: item.playCount, + playCount: item.playCount || 0, releaseDate: (item.releaseDate ? new Date(item.releaseDate) : new Date(item.minYear, 0, 1) @@ -232,7 +295,7 @@ const normalizeAlbumArtist = ( lastPlayedAt: normalizePlayDate(item), mbz: item.mbzArtistId || null, name: item.name, - playCount: item.playCount, + playCount: item.playCount || 0, serverId: server?.id || 'unknown', serverType: ServerType.NAVIDROME, similarArtists: diff --git a/src/renderer/api/navidrome/navidrome-types.ts b/src/renderer/api/navidrome/navidrome-types.ts index ba8eb14b..9699cb18 100644 --- a/src/renderer/api/navidrome/navidrome-types.ts +++ b/src/renderer/api/navidrome/navidrome-types.ts @@ -83,7 +83,7 @@ const albumArtist = z.object({ mediumImageUrl: z.string().optional(), name: z.string(), orderArtistName: z.string(), - playCount: z.number(), + playCount: z.number().optional(), playDate: z.string().optional(), rating: z.number(), size: z.number(), @@ -98,10 +98,20 @@ const albumArtistList = z.array(albumArtist); const albumArtistListParameters = paginationParameters.extend({ _sort: z.nativeEnum(NDAlbumArtistListSort).optional(), genre_id: z.string().optional(), + missing: z.boolean().optional(), name: z.string().optional(), + role: z.string().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({ albumArtist: z.string(), albumArtistId: z.string(), @@ -113,7 +123,7 @@ const album = z.object({ coverArtId: z.string().optional(), // Removed after v0.48.0 coverArtPath: z.string().optional(), // Removed after v0.48.0 createdAt: z.string(), - duration: z.number(), + duration: z.number().optional(), fullText: z.string(), genre: z.string(), genres: z.array(genre).nullable(), @@ -127,7 +137,8 @@ const album = z.object({ orderAlbumName: z.string(), originalDate: z.string().optional(), originalYear: z.number().optional(), - playCount: z.number(), + participants: z.optional(participants), + playCount: z.number().optional(), playDate: z.string().optional(), rating: z.number().optional(), releaseDate: z.string().optional(), @@ -195,8 +206,9 @@ const song = z.object({ orderAlbumName: z.string(), orderArtistName: z.string(), orderTitle: z.string(), + participants: z.optional(participants), path: z.string(), - playCount: z.number(), + playCount: z.number().optional(), playDate: z.string().optional(), rating: z.number().optional(), releaseDate: z.string().optional(), @@ -211,6 +223,7 @@ const song = z.object({ starred: z.boolean(), starredAt: z.string().optional(), suffix: z.string(), + tags: z.record(z.string(), z.array(z.string())).optional(), title: z.string(), trackNumber: z.number(), updatedAt: z.string(), diff --git a/src/renderer/api/subsonic/subsonic-normalize.ts b/src/renderer/api/subsonic/subsonic-normalize.ts index b3b08f07..1050285f 100644 --- a/src/renderer/api/subsonic/subsonic-normalize.ts +++ b/src/renderer/api/subsonic/subsonic-normalize.ts @@ -10,6 +10,7 @@ import { ServerType, Playlist, Genre, + RelatedArtist, } from '/@/renderer/api/types'; const getCoverArtUrl = (args: { @@ -34,6 +35,92 @@ const getCoverArtUrl = (args: { ); }; +const getArtists = ( + item: + | z.infer + | z.infer + | z.infer, +) => { + 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 | 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 + | z.infer + | z.infer, +): 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 = ( item: z.infer, server: ServerListItem | null, @@ -51,22 +138,9 @@ const normalizeSong = ( return { album: item.album || '', - albumArtists: [ - { - id: item.artistId?.toString() || '', - imageUrl: null, - name: item.artist || '', - }, - ], albumId: item.albumId?.toString() || '', artistName: item.artist || '', - artists: [ - { - id: item.artistId?.toString() || '', - imageUrl: null, - name: item.artist || '', - }, - ], + ...getArtists(item), bitRate: item.bitRate || 0, bpm: item.bpm || null, channels: null, @@ -84,16 +158,7 @@ const normalizeSong = ( track: item.replayGain.trackGain, } : null, - genres: item.genre - ? [ - { - id: item.genre, - imageUrl: null, - itemType: LibraryItem.GENRE, - name: item.genre, - }, - ] - : [], + genres: getGenres(item), id: item.id.toString(), imagePlaceholderUrl: null, imageUrl, @@ -176,26 +241,12 @@ const normalizeAlbum = ( return { albumArtist: item.artist, - albumArtists: item.artistId - ? [{ id: item.artistId.toString(), imageUrl: null, name: item.artist }] - : [], - artists: item.artistId - ? [{ id: item.artistId.toString(), imageUrl: null, name: item.artist }] - : [], + ...getArtists(item), backdropImageUrl: null, comment: null, createdAt: item.created, duration: item.duration * 1000, - genres: item.genre - ? [ - { - id: item.genre, - imageUrl: null, - itemType: LibraryItem.GENRE, - name: item.genre, - }, - ] - : [], + genres: getGenres(item), id: item.id.toString(), imagePlaceholderUrl: null, imageUrl, diff --git a/src/renderer/api/subsonic/subsonic-types.ts b/src/renderer/api/subsonic/subsonic-types.ts index d3922d31..b1d84877 100644 --- a/src/renderer/api/subsonic/subsonic-types.ts +++ b/src/renderer/api/subsonic/subsonic-types.ts @@ -66,15 +66,29 @@ const genreItem = z.object({ 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({ album: z.string().optional(), + albumArtists: z.array(simpleArtist), albumId: id.optional(), artist: z.string().optional(), artistId: id.optional(), + artists: z.array(simpleArtist), averageRating: z.number().optional(), bitRate: z.number().optional(), bpm: z.number().optional(), contentType: z.string(), + contributors: z.array(contributor).optional(), coverArt: z.string().optional(), created: z.string(), discNumber: z.number(), @@ -101,12 +115,16 @@ const song = z.object({ const album = z.object({ album: z.string(), + albumArtists: z.array(simpleArtist), artist: z.string(), artistId: id, + artists: z.array(simpleArtist), + contributors: z.array(contributor).optional(), coverArt: z.string(), created: z.string(), duration: z.number(), genre: z.string().optional(), + genres: z.array(genreItem).optional(), id, isCompilation: z.boolean().optional(), isDir: z.boolean(), diff --git a/src/renderer/api/types.ts b/src/renderer/api/types.ts index 860fb131..f6df5ce0 100644 --- a/src/renderer/api/types.ts +++ b/src/renderer/api/types.ts @@ -168,6 +168,7 @@ export type Album = { mbzId: string | null; name: string; originalDate: string | null; + participants: Record | null; playCount: number | null; releaseDate: string | null; releaseYear: number | null; @@ -212,6 +213,7 @@ export type Song = { lastPlayedAt: string | null; lyrics: string | null; name: string; + participants: Record | null; path: string | null; peak: GainInfo | null; playCount: number; diff --git a/src/renderer/features/artists/components/album-artist-list-grid-view.tsx b/src/renderer/features/artists/components/album-artist-list-grid-view.tsx index 3e6dd746..890df18b 100644 --- a/src/renderer/features/artists/components/album-artist-list-grid-view.tsx +++ b/src/renderer/features/artists/components/album-artist-list-grid-view.tsx @@ -88,10 +88,7 @@ export const AlbumArtistListGridView = ({ itemCount, gridRef }: AlbumArtistListG server, signal, }, - query: { - limit, - ...filter, - }, + query, }), { cacheTime: 1000 * 60 * 1 }, ); 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 c0553fd3..b0984617 100644 --- a/src/renderer/features/item-details/components/item-details-modal.tsx +++ b/src/renderer/features/item-details/components/item-details-modal.tsx @@ -2,7 +2,14 @@ import { Group, Table } from '@mantine/core'; import { RiCheckFill, RiCloseFill } from 'react-icons/ri'; import { TFunction, useTranslation } from 'react-i18next'; 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 { replaceURLWithHTMLLinks } from '/@/renderer/utils/linkify'; import { Spoiler, Text } from '/@/renderer/components'; @@ -46,8 +53,8 @@ const handleRow = (t: TFunction, item: T, rule: ItemDe ); }; -const formatArtists = (isAlbumArtist: boolean) => (item: Album | Song) => - (isAlbumArtist ? item.albumArtists : item.artists)?.map((artist, index) => ( +const formatArtists = (artists: RelatedArtist[] | undefined | null) => + artists?.map((artist, index) => ( {index > 0 && } {artist.id ? ( @@ -106,7 +113,7 @@ const BoolField = (key: boolean) => const AlbumPropertyMapping: ItemDetailRow[] = [ { 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: 'common.duration', @@ -198,8 +205,8 @@ const AlbumArtistPropertyMapping: ItemDetailRow[] = [ const SongPropertyMapping: ItemDetailRow[] = [ { key: 'name', label: 'common.title' }, { key: 'path', label: 'common.path', render: SongPath }, - { label: 'entity.albumArtist_one', render: formatArtists(true) }, - { key: 'artists', label: 'entity.artist_other', render: formatArtists(false) }, + { label: 'entity.albumArtist_one', render: (item) => formatArtists(item.albumArtists) }, + { key: 'artists', label: 'entity.artist_other', render: (item) => formatArtists(item.artists) }, { key: 'album', label: 'entity.album_one', @@ -270,22 +277,42 @@ const SongPropertyMapping: ItemDetailRow[] = [ { label: 'filter.comment', render: formatComment }, ]; +const handleParticipants = (item: Album | Song) => { + if (item.participants) { + return Object.entries(item.participants).map(([role, participants]) => { + return ( + + + {role.slice(0, 1).toLocaleUpperCase()} + {role.slice(1)} + + {formatArtists(participants)} + + ); + }); + } + + return []; +}; + export const ItemDetailsModal = ({ item }: ItemDetailsModalProps) => { const { t } = useTranslation(); - let body: ReactNode; + let body: ReactNode[] = []; switch (item.itemType) { case LibraryItem.ALBUM: body = AlbumPropertyMapping.map((rule) => handleRow(t, item, rule)); + body.push(...handleParticipants(item)); 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)); break; default: - body = null; + body = []; } return (