From 40ec16e1918a24a6ffacd38940847ac12adc017a Mon Sep 17 00:00:00 2001 From: jeffvli Date: Fri, 6 Feb 2026 20:13:58 -0800 Subject: [PATCH] support mbz album detail view --- src/renderer/api/query-keys.ts | 1 + .../components/item-card/item-card.tsx | 19 +- src/renderer/features/albums/api/album-api.ts | 11 +- .../components/album-detail-content.tsx | 19 +- .../albums/components/album-detail-header.tsx | 20 +- .../albums/routes/album-detail-route.tsx | 19 +- .../musicbrainz/api/musicbrainz-api.ts | 558 ++++++++---------- src/renderer/features/musicbrainz/utils.ts | 295 +++++++++ .../shared/components/library-header-bar.tsx | 7 +- .../shared/components/library-header.tsx | 62 +- .../shared/components/play-button.tsx | 2 + 11 files changed, 652 insertions(+), 361 deletions(-) create mode 100644 src/renderer/features/musicbrainz/utils.ts diff --git a/src/renderer/api/query-keys.ts b/src/renderer/api/query-keys.ts index 5b03e9f2b..d30dfb2be 100644 --- a/src/renderer/api/query-keys.ts +++ b/src/renderer/api/query-keys.ts @@ -291,6 +291,7 @@ export const queryKeys: Record< ] : null, ] as const, + release: (releaseId: string) => ['musicbrainz', 'release', releaseId] as const, root: () => ['musicbrainz'] as const, }, musicFolders: { diff --git a/src/renderer/components/item-card/item-card.tsx b/src/renderer/components/item-card/item-card.tsx index 016e2c610..ea0770132 100644 --- a/src/renderer/components/item-card/item-card.tsx +++ b/src/renderer/components/item-card/item-card.tsx @@ -1043,18 +1043,20 @@ export const getDataRows = (type?: 'compact' | 'default' | 'poster'): DataRow[] if ('id' in data && data.id) { if ('_itemType' in data) { switch (data._itemType) { - case LibraryItem.ALBUM: - return ( - + case LibraryItem.ALBUM: { + const albumPath = getTitlePath(LibraryItem.ALBUM, data.id); + return albumPath ? ( + {data.name} + ) : ( + <> + + {data.name} + ); + } case LibraryItem.ALBUM_ARTIST: return ( ) => { return queryOptions({ - queryFn: ({ signal }) => { + queryFn: async ({ signal }) => { + const mbzReleaseId = getMbzReleaseIdFromAlbumId(args.query.id); + + if (mbzReleaseId !== null) { + return fetchMbzReleaseAsAlbum(mbzReleaseId); + } + return api.controller.getAlbumDetail({ apiClientProps: { serverId: args.serverId, signal }, query: args.query, diff --git a/src/renderer/features/albums/components/album-detail-content.tsx b/src/renderer/features/albums/components/album-detail-content.tsx index dbea9ba67..6d0e1a822 100644 --- a/src/renderer/features/albums/components/album-detail-content.tsx +++ b/src/renderer/features/albums/components/album-detail-content.tsx @@ -20,6 +20,7 @@ import { ItemTableListColumn } from '/@/renderer/components/item-list/item-table import { ItemControls } from '/@/renderer/components/item-list/types'; import { albumQueries } from '/@/renderer/features/albums/api/album-api'; import { AlbumInfiniteCarousel } from '/@/renderer/features/albums/components/album-infinite-carousel'; +import { isMbzAlbumId } from '/@/renderer/features/musicbrainz/utils'; import { usePlayer } from '/@/renderer/features/player/context/player-context'; import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu'; import { @@ -29,7 +30,7 @@ import { import { ListSortOrderToggleButtonControlled } from '/@/renderer/features/shared/components/list-sort-order-toggle-button'; import { FILTER_KEYS, searchLibraryItems } from '/@/renderer/features/shared/utils'; import { AppRoute } from '/@/renderer/router/routes'; -import { useCurrentServer, usePlayerSong } from '/@/renderer/store'; +import { useCurrentServerId, usePlayerSong } from '/@/renderer/store'; import { useExternalLinks, useSettingsStore } from '/@/renderer/store/settings.store'; import { sentenceCase, titleCase } from '/@/renderer/utils'; import { replaceURLWithHTMLLinks } from '/@/renderer/utils/linkify'; @@ -119,6 +120,13 @@ const AlbumMetadataTags = ({ album }: AlbumMetadataTagsProps) => { const items: Array<{ id: string; value: ReactNode | string | undefined }> = []; + if (album._serverType === ServerType.EXTERNAL) { + items.push({ + id: 'unavailable', + value: t('common.unavailable', { postProcess: 'sentenceCase' }), + }); + } + items.push( ...releaseTypes, { @@ -362,9 +370,14 @@ const AlbumMetadataExternalLinks = ({ export const AlbumDetailContent = () => { const { albumId } = useParams() as { albumId: string }; - const server = useCurrentServer(); + const serverId = useCurrentServerId(); + const isMbz = isMbzAlbumId(albumId); + const detailQuery = useSuspenseQuery( - albumQueries.detail({ query: { id: albumId }, serverId: server.id }), + albumQueries.detail({ + query: { id: albumId }, + serverId: isMbz ? 'musicbrainz' : serverId, + }), ); const { externalLinks, lastFM, musicBrainz } = useExternalLinks(); diff --git a/src/renderer/features/albums/components/album-detail-header.tsx b/src/renderer/features/albums/components/album-detail-header.tsx index 9d3bd8331..a2cada321 100644 --- a/src/renderer/features/albums/components/album-detail-header.tsx +++ b/src/renderer/features/albums/components/album-detail-header.tsx @@ -8,6 +8,7 @@ import styles from './album-detail-header.module.css'; import { albumQueries } from '/@/renderer/features/albums/api/album-api'; import { JoinedArtists } from '/@/renderer/features/albums/components/joined-artists'; import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller'; +import { isMbzAlbumId } from '/@/renderer/features/musicbrainz/utils'; import { usePlayer } from '/@/renderer/features/player/context/player-context'; import { LibraryHeader, @@ -16,7 +17,7 @@ import { import { useSetFavorite } from '/@/renderer/features/shared/hooks/use-set-favorite'; import { useSetRating } from '/@/renderer/features/shared/hooks/use-set-rating'; import { AppRoute } from '/@/renderer/router/routes'; -import { useCurrentServer, useShowRatings } from '/@/renderer/store'; +import { useCurrentServerId, useShowRatings } from '/@/renderer/store'; import { usePlayButtonBehavior } from '/@/renderer/store/settings.store'; import { formatDateAbsoluteUTC, formatDurationString } from '/@/renderer/utils'; import { normalizeReleaseTypes } from '/@/renderer/utils/normalize-release-types'; @@ -30,13 +31,21 @@ import { Play } from '/@/shared/types/types'; export const AlbumDetailHeader = forwardRef((_props, ref) => { const { albumId } = useParams() as { albumId: string }; const { t } = useTranslation(); - const server = useCurrentServer(); + const serverId = useCurrentServerId(); const showRatings = useShowRatings(); + + const isMbz = isMbzAlbumId(albumId); const detailQuery = useQuery( - albumQueries.detail({ query: { id: albumId }, serverId: server?.id }), + albumQueries.detail({ + query: { id: albumId }, + serverId: isMbz ? 'musicbrainz' : serverId, + }), ); + const isExternal = detailQuery?.data?._serverType === ServerType.EXTERNAL; + const showRating = + !isExternal && showRatings && (detailQuery?.data?._serverType === ServerType.NAVIDROME || detailQuery?.data?._serverType === ServerType.SUBSONIC); @@ -80,8 +89,8 @@ export const AlbumDetailHeader = forwardRef((_props, ref) => { : undefined; const handlePlay = (type?: Play) => { - if (!server?.id || !albumId) return; - addToQueueByFetch(server.id, [albumId], LibraryItem.ALBUM, type || playButtonBehavior); + if (isExternal || !serverId || !albumId) return; + addToQueueByFetch(serverId, [albumId], LibraryItem.ALBUM, type || playButtonBehavior); }; const handleMoreOptions = (e: React.MouseEvent) => { @@ -248,6 +257,7 @@ export const AlbumDetailHeader = forwardRef((_props, ref) => { /> { const scrollAreaRef = useRef(null); @@ -26,18 +27,23 @@ const AlbumDetailRoute = () => { const { albumBackground, albumBackgroundBlur } = useAlbumBackground(); const { albumId } = useParams() as { albumId: string }; - const server = useCurrentServer(); + const serverId = useCurrentServerId(); + const isMbz = isMbzAlbumId(albumId); const location = useLocation(); const detailQuery = useQuery({ - ...albumQueries.detail({ query: { id: albumId }, serverId: server?.id }), + ...albumQueries.detail({ + query: { id: albumId }, + serverId: isMbz ? 'musicbrainz' : serverId, + }), placeholderData: location.state?.item, }); const imageUrl = useItemImageUrl({ id: detailQuery?.data?.imageId || undefined, + imageUrl: detailQuery?.data?.imageUrl || undefined, itemType: LibraryItem.ALBUM, type: 'itemCard', }) || ''; @@ -52,10 +58,12 @@ const AlbumDetailRoute = () => { const showBlurredImage = albumBackground; - if (isColorLoading) { + if (isColorLoading || (detailQuery.isLoading && !detailQuery.data)) { return ; } + const isExternal = detailQuery?.data?._serverType === ServerType.EXTERNAL; + return ( { children: ( (); const ownedMbzReleaseIds = new Set(); @@ -78,21 +82,8 @@ const artistSelect = memoize( } } - console.log('existingMbzReleaseGroupIds', ownedMbzReleaseGroupIds); - console.log('existingMbzReleaseIds', ownedMbzReleaseIds); - console.log('counts', counts); - const albumArtistName = meta.albumArtist.name; - // const releaseGroupMap = new Map< - // string, - // { - // release: IRelease; - // releaseGroup: NonNullable; - // score: number; - // } - // >(); - const existingReleaseGroups = new Map(); const existingReleases = new Map(); const unownedReleases = new Map(); @@ -111,9 +102,6 @@ const artistSelect = memoize( } } - console.log('existingReleaseGroups', existingReleaseGroups); - console.log('existingReleases', existingReleases); - for (const release of data.releases.releases) { const releaseGroupId = release['release-group']?.id; if ( @@ -131,8 +119,6 @@ const artistSelect = memoize( } } - console.log('unownedReleases', unownedReleases); - console.log('unownedReleaseGroups', unownedReleaseGroups); const excludeReleaseTypes = (meta.excludeReleaseTypes ?? []).map((t) => t.toLowerCase()); const excludeSet = new Set(excludeReleaseTypes); const prioritizeCountries = (meta.prioritizeCountries ?? []).map((c) => c.toUpperCase()); @@ -191,7 +177,7 @@ const artistSelect = memoize( const album: Album = { _itemType: LibraryItem.ALBUM, - _serverId: '', + _serverId: 'musicbrainz', _serverType: ServerType.EXTERNAL, albumArtistName: albumArtistName, albumArtists: [albumArtist], @@ -236,7 +222,28 @@ const artistSelect = memoize( }, ); -async function fetchAllReleases(mbzArtistId: string): Promise { +function collectWorksFromRelease(release: IRelease): IWork[] { + const works: IWork[] = []; + const seenIds = new Set(); + + for (const medium of release.media ?? []) { + for (const track of medium.tracks ?? []) { + const recording = track.recording; + const relations = (recording as { relations?: IRelationWithWork[] })?.relations ?? []; + for (const rel of relations) { + const work = (rel as IRelationWithWork).work; + if (work?.id && !seenIds.has(work.id)) { + seenIds.add(work.id); + works.push(work); + } + } + } + } + + return works; +} + +async function fetchMbzReleasesByArtistId(mbzArtistId: string): Promise { const PAGE_SIZE = 100; const includes: Array<'media' | 'release-groups'> = ['media', 'release-groups']; @@ -254,16 +261,13 @@ async function fetchAllReleases(mbzArtistId: string): Promise= totalCount) { return firstPage; } - // Calculate number of additional pages needed const remainingCount = totalCount - allReleases.length; const numberOfPages = Math.ceil(remainingCount / PAGE_SIZE); - // Fetch all remaining pages in parallel const pagePromises = Array.from({ length: numberOfPages }, (_, i) => { const offset = (i + 1) * PAGE_SIZE; return musicbrainzApi.browse( @@ -290,6 +294,196 @@ async function fetchAllReleases(mbzArtistId: string): Promise = ['artist-credits', 'artists', 'media', 'recording-level-rels', 'recordings', 'release-groups']; + +function normalizeArtistCreditToRelatedArtists( + artistCredit: Array<{ artist: IArtist; name: string }>, +): RelatedArtist[] { + return artistCredit.map((ac) => ({ + id: `musicbrainz-${ac.artist.id}`, + imageId: null, + imageUrl: null, + name: ac.name || ac.artist.name, + userFavorite: false, + userRating: null, + })); +} + +function normalizeRecordingToSong( + release: IRelease, + medium: IMedium, + track: ITrack, + albumArtistName: string, + albumArtists: RelatedArtist[], + albumId: string, + imageUrl: null | string, + releaseDate: null | string, + releaseYear: null | number, +): Song { + const recording = track.recording; + const trackArtistCredit = track['artist-credit'] ?? recording['artist-credit'] ?? []; + + const artistName = + trackArtistCredit.map((ac) => ac.name).join('') || recording.title || track.title; + + const artists = normalizeArtistCreditToRelatedArtists( + trackArtistCredit as Array<{ artist: IArtist; name: string }>, + ); + + const durationMilliseconds = track.length || recording.length || 0; + const trackNumber = track.position || parseInt(track.number, 10) || 0; + + return { + _itemType: LibraryItem.SONG, + _serverId: 'musicbrainz', + _serverType: ServerType.EXTERNAL, + album: release.title, + albumArtistName, + albumArtists, + albumId, + artistName, + artists, + bitDepth: null, + bitRate: 0, + bpm: null, + channels: null, + comment: null, + compilation: null, + container: null, + createdAt: '', + discNumber: medium.position || 1, + discSubtitle: medium.title || null, + duration: durationMilliseconds, + explicitStatus: null, + gain: null, + genres: [], + id: `musicbrainz-${release.id}-${recording.id}-${track.position}-${track.number}`, + imageId: null, + imageUrl, + lastPlayedAt: null, + lyrics: null, + mbzRecordingId: recording.id, + mbzTrackId: track.id, + name: track.title || recording.title, + participants: {}, + path: null, + peak: null, + playCount: 0, + releaseDate, + releaseYear, + sampleRate: null, + size: 0, + sortName: track.title || recording.title, + tags: null, + trackNumber, + trackSubtitle: null, + updatedAt: '', + userFavorite: false, + userRating: null, + }; +} + +function normalizeReleaseToAlbum(release: IRelease): Album { + const releaseGroup = release['release-group']; + const artistCredit = release['artist-credit'] ?? releaseGroup?.['artist-credit'] ?? []; + const albumArtistName = artistCredit.map((ac) => ac.name).join('') || release.title; + const albumArtists: RelatedArtist[] = (artistCredit as { artist: IArtist; name: string }[]).map( + (ac) => ({ + id: `musicbrainz-${ac.artist.id}`, + imageId: null, + imageUrl: null, + name: ac.name || ac.artist.name, + userFavorite: false, + userRating: null, + }), + ); + + const hasArtwork = + release['cover-art-archive']?.artwork === true && + release['cover-art-archive']?.front === true; + const primaryReleaseType = releaseGroup?.['primary-type']?.toLowerCase() || null; + const secondaryReleaseTypes = + releaseGroup?.['secondary-types']?.map((type) => type.toLowerCase()) || []; + const releaseTypes = [primaryReleaseType, ...secondaryReleaseTypes].filter( + (type) => type !== null, + ) as string[]; + const isCompilation = releaseTypes.includes('compilation'); + const originalDate = releaseGroup?.['first-release-date'] || null; + const originalYear = originalDate ? Number(originalDate.split('-')[0]) : null; + const releaseDate = release.date ? release.date : null; + const releaseYear = release.date ? Number(release.date.split('-')[0]) : null; + const imageUrl = hasArtwork ? getImageUrl(release.id) : null; + const albumId = `musicbrainz-${release.id}`; + + const songs: Song[] = []; + for (const medium of release.media ?? []) { + for (const track of medium.tracks ?? []) { + songs.push( + normalizeRecordingToSong( + release, + medium, + track, + albumArtistName, + albumArtists, + albumId, + imageUrl, + releaseDate, + releaseYear, + ), + ); + } + } + + const totalDuration = songs.reduce((sum, s) => sum + s.duration, 0); + + return { + _itemType: LibraryItem.ALBUM, + _serverId: 'musicbrainz', + _serverType: ServerType.EXTERNAL, + albumArtistName, + albumArtists, + artists: [], + comment: null, + createdAt: '', + duration: totalDuration || null, + explicitStatus: null, + genres: [], + id: albumId, + imageId: null, + imageUrl, + isCompilation, + lastPlayedAt: null, + mbzId: release.id, + mbzReleaseGroupId: releaseGroup?.id || null, + name: release.title, + originalDate, + originalYear, + participants: {}, + playCount: null, + recordLabels: [], + releaseDate, + releaseType: primaryReleaseType, + releaseTypes, + releaseYear, + size: null, + songCount: songs.length, + songs, + sortName: release.title, + tags: {}, + updatedAt: '', + userFavorite: false, + userRating: null, + version: null, + }; +} + export const musicbrainzQueries = { artist: (args: { excludeReleaseTypes?: string[]; @@ -305,7 +499,7 @@ export const musicbrainzQueries = { gcTime: CACHE_TIME, queryFn: async ({ meta }) => { const artist = await musicbrainzApi.lookup('artist', args.mbzArtistId); - const releases = await fetchAllReleases(args.mbzArtistId); + const releases = await fetchMbzReleasesByArtistId(args.mbzArtistId); return { data: { artist, releases }, @@ -317,293 +511,27 @@ export const musicbrainzQueries = { staleTime: CACHE_TIME, }); }, + release: (args: { releaseId: string }) => + queryOptions({ + gcTime: CACHE_TIME, + queryFn: async () => { + const mbzRelease = await musicbrainzApi.lookup( + 'release', + args.releaseId, + RELEASE_INCLUDES, + ); + const release = normalizeReleaseToAlbum(mbzRelease); + const works = collectWorksFromRelease(mbzRelease); + return { release, works }; + }, + queryKey: queryKeys.musicbrainz.release(args.releaseId), + staleTime: CACHE_TIME, + }), }; -function getImageUrl(releaseId: string): string { - return `https://coverartarchive.org/release/${releaseId}/front-250.jpg`; +export const MUSICBRAINZ_ID_PREFIX = 'musicbrainz-'; + +export async function fetchMbzReleaseAsAlbum(releaseId: string): Promise { + const mbzRelease = await musicbrainzApi.lookup('release', releaseId, RELEASE_INCLUDES); + return normalizeReleaseToAlbum(mbzRelease); } - -function getImageUrlByReleaseGroupId(releaseGroupId: string): string { - return `https://coverartarchive.org/release-group/${releaseGroupId}/front-250.jpg`; -} - -const MBZ_COUNTRY_CODES = { - AD: 'Andorra', - AE: 'United Arab Emirates', - AF: 'Afghanistan', - AG: 'Antigua and Barbuda', - AI: 'Anguilla', - AL: 'Albania', - AM: 'Armenia', - AN: 'Netherlands Antilles', - AO: 'Angola', - AQ: 'Antarctica', - AR: 'Argentina', - AS: 'American Samoa', - AT: 'Austria', - AU: 'Australia', - AW: 'Aruba', - AX: 'Åland Islands', - AZ: 'Azerbaijan', - BA: 'Bosnia and Herzegovina', - BB: 'Barbados', - BD: 'Bangladesh', - BE: 'Belgium', - BF: 'Burkina Faso', - BG: 'Bulgaria', - BH: 'Bahrain', - BI: 'Burundi', - BJ: 'Benin', - BL: 'Saint Barthélemy', - BM: 'Bermuda', - BN: 'Brunei', - BO: 'Bolivia', - BQ: 'Bonaire, Sint Eustatius and Saba', - BR: 'Brazil', - BS: 'Bahamas', - BT: 'Bhutan', - BV: 'Bouvet Island', - BW: 'Botswana', - BY: 'Belarus', - BZ: 'Belize', - CA: 'Canada', - CC: 'Cocos (Keeling) Islands', - CD: 'Democratic Republic of the Congo', - CF: 'Central African Republic', - CG: 'Congo', - CH: 'Switzerland', - CI: "Côte d'Ivoire", - CK: 'Cook Islands', - CL: 'Chile', - CM: 'Cameroon', - CN: 'China', - CO: 'Colombia', - CR: 'Costa Rica', - CS: 'Serbia and Montenegro', - CU: 'Cuba', - CV: 'Cape Verde', - CW: 'Curaçao', - CX: 'Christmas Island', - CY: 'Cyprus', - CZ: 'Czechia', - DE: 'Germany', - DJ: 'Djibouti', - DK: 'Denmark', - DM: 'Dominica', - DO: 'Dominican Republic', - DZ: 'Algeria', - EC: 'Ecuador', - EE: 'Estonia', - EG: 'Egypt', - EH: 'Western Sahara', - ER: 'Eritrea', - ES: 'Spain', - ET: 'Ethiopia', - FI: 'Finland', - FJ: 'Fiji', - FK: 'Falkland Islands', - FM: 'Federated States of Micronesia', - FO: 'Faroe Islands', - FR: 'France', - GA: 'Gabon', - GB: 'United Kingdom', - GD: 'Grenada', - GE: 'Georgia', - GF: 'French Guiana', - GG: 'Guernsey', - GH: 'Ghana', - GI: 'Gibraltar', - GL: 'Greenland', - GM: 'Gambia', - GN: 'Guinea', - GP: 'Guadeloupe', - GQ: 'Equatorial Guinea', - GR: 'Greece', - GS: 'South Georgia and the South Sandwich Islands', - GT: 'Guatemala', - GU: 'Guam', - GW: 'Guinea-Bissau', - GY: 'Guyana', - HK: 'Hong Kong', - HM: 'Heard Island and McDonald Islands', - HN: 'Honduras', - HR: 'Croatia', - HT: 'Haiti', - HU: 'Hungary', - ID: 'Indonesia', - IE: 'Ireland', - IL: 'Israel', - IM: 'Isle of Man', - IN: 'India', - IO: 'British Indian Ocean Territory', - IQ: 'Iraq', - IR: 'Iran', - IS: 'Iceland', - IT: 'Italy', - JE: 'Jersey', - JM: 'Jamaica', - JO: 'Jordan', - JP: 'Japan', - KE: 'Kenya', - KG: 'Kyrgyzstan', - KH: 'Cambodia', - KI: 'Kiribati', - KM: 'Comoros', - KN: 'Saint Kitts and Nevis', - KP: 'North Korea', - KR: 'South Korea', - KW: 'Kuwait', - KY: 'Cayman Islands', - KZ: 'Kazakhstan', - LA: 'Laos', - LB: 'Lebanon', - LC: 'Saint Lucia', - LI: 'Liechtenstein', - LK: 'Sri Lanka', - LR: 'Liberia', - LS: 'Lesotho', - LT: 'Lithuania', - LU: 'Luxembourg', - LV: 'Latvia', - LY: 'Libya', - MA: 'Morocco', - MC: 'Monaco', - MD: 'Moldova', - ME: 'Montenegro', - MF: 'Saint Martin (French part)', - MG: 'Madagascar', - MH: 'Marshall Islands', - MK: 'North Macedonia', - ML: 'Mali', - MM: 'Myanmar', - MN: 'Mongolia', - MO: 'Macao', - MP: 'Northern Mariana Islands', - MQ: 'Martinique', - MR: 'Mauritania', - MS: 'Montserrat', - MT: 'Malta', - MU: 'Mauritius', - MV: 'Maldives', - MW: 'Malawi', - MX: 'Mexico', - MY: 'Malaysia', - MZ: 'Mozambique', - NA: 'Namibia', - NC: 'New Caledonia', - NE: 'Niger', - NF: 'Norfolk Island', - NG: 'Nigeria', - NI: 'Nicaragua', - NL: 'Netherlands', - NO: 'Norway', - NP: 'Nepal', - NR: 'Nauru', - NU: 'Niue', - NZ: 'New Zealand', - OM: 'Oman', - PA: 'Panama', - PE: 'Peru', - PF: 'French Polynesia', - PG: 'Papua New Guinea', - PH: 'Philippines', - PK: 'Pakistan', - PL: 'Poland', - PM: 'Saint Pierre and Miquelon', - PN: 'Pitcairn', - PR: 'Puerto Rico', - PS: 'Palestine', - PT: 'Portugal', - PW: 'Palau', - PY: 'Paraguay', - QA: 'Qatar', - RE: 'Réunion', - RO: 'Romania', - RS: 'Serbia', - RU: 'Russia', - RW: 'Rwanda', - SA: 'Saudi Arabia', - SB: 'Solomon Islands', - SC: 'Seychelles', - SD: 'Sudan', - SE: 'Sweden', - SG: 'Singapore', - SH: 'Saint Helena, Ascension and Tristan da Cunha', - SI: 'Slovenia', - SJ: 'Svalbard and Jan Mayen', - SK: 'Slovakia', - SL: 'Sierra Leone', - SM: 'San Marino', - SN: 'Senegal', - SO: 'Somalia', - SR: 'Suriname', - SS: 'South Sudan', - ST: 'Sao Tome and Principe', - SU: 'Soviet Union', - SV: 'El Salvador', - SX: 'Sint Maarten (Dutch part)', - SY: 'Syria', - SZ: 'Eswatini', - TC: 'Turks and Caicos Islands', - TD: 'Chad', - TF: 'French Southern Territories', - TG: 'Togo', - TH: 'Thailand', - TJ: 'Tajikistan', - TK: 'Tokelau', - TL: 'Timor-Leste', - TM: 'Turkmenistan', - TN: 'Tunisia', - TO: 'Tonga', - TR: 'Turkey', - TT: 'Trinidad and Tobago', - TV: 'Tuvalu', - TW: 'Taiwan', - TZ: 'Tanzania', - UA: 'Ukraine', - UG: 'Uganda', - UM: 'United States Minor Outlying Islands', - US: 'United States', - UY: 'Uruguay', - UZ: 'Uzbekistan', - VA: 'Vatican City', - VC: 'Saint Vincent and The Grenadines', - VE: 'Venezuela', - VG: 'British Virgin Islands', - VI: 'U.S. Virgin Islands', - VN: 'Vietnam', - VU: 'Vanuatu', - WF: 'Wallis and Futuna', - WS: 'Samoa', - XC: 'Czechoslovakia', - XE: 'Europe', - XG: 'East Germany', - XK: 'Kosovo', - XW: '[Worldwide]', - YE: 'Yemen', - YT: 'Mayotte', - YU: 'Yugoslavia', - ZA: 'South Africa', - ZM: 'Zambia', - ZW: 'Zimbabwe', -}; - -const MBZ_RELEASE_TYPES = { - album: 'album', - audiobook: 'audiobook', - 'audio drama': 'audio drama', - broadcast: 'broadcast', - compilation: 'compilation', - demo: 'demo', - 'dj-mix': 'dj-mix', - ep: 'ep', - 'field recording': 'field recording', - interview: 'interview', - live: 'live', - 'mixtape/street': 'mixtape/street', - other: 'other', - remix: 'remix', - single: 'single', - soundtrack: 'soundtrack', - spokenword: 'spokenword', -}; diff --git a/src/renderer/features/musicbrainz/utils.ts b/src/renderer/features/musicbrainz/utils.ts new file mode 100644 index 000000000..a667582bb --- /dev/null +++ b/src/renderer/features/musicbrainz/utils.ts @@ -0,0 +1,295 @@ +import { MUSICBRAINZ_ID_PREFIX } from '/@/renderer/features/musicbrainz/api/musicbrainz-api'; + +export function getImageUrl(releaseId: string): string { + return `https://coverartarchive.org/release/${releaseId}/front-250.jpg`; +} + +export function getMbzReleaseIdFromAlbumId(albumId: string): null | string { + if (!albumId.startsWith(MUSICBRAINZ_ID_PREFIX)) return null; + return albumId.slice(MUSICBRAINZ_ID_PREFIX.length); +} +export function isMbzAlbumId(albumId: string): boolean { + return albumId.startsWith(MUSICBRAINZ_ID_PREFIX); +} +function getImageUrlByReleaseGroupId(releaseGroupId: string): string { + return `https://coverartarchive.org/release-group/${releaseGroupId}/front-250.jpg`; +} +const MBZ_COUNTRY_CODES = { + AD: 'Andorra', + AE: 'United Arab Emirates', + AF: 'Afghanistan', + AG: 'Antigua and Barbuda', + AI: 'Anguilla', + AL: 'Albania', + AM: 'Armenia', + AN: 'Netherlands Antilles', + AO: 'Angola', + AQ: 'Antarctica', + AR: 'Argentina', + AS: 'American Samoa', + AT: 'Austria', + AU: 'Australia', + AW: 'Aruba', + AX: 'Åland Islands', + AZ: 'Azerbaijan', + BA: 'Bosnia and Herzegovina', + BB: 'Barbados', + BD: 'Bangladesh', + BE: 'Belgium', + BF: 'Burkina Faso', + BG: 'Bulgaria', + BH: 'Bahrain', + BI: 'Burundi', + BJ: 'Benin', + BL: 'Saint Barthélemy', + BM: 'Bermuda', + BN: 'Brunei', + BO: 'Bolivia', + BQ: 'Bonaire, Sint Eustatius and Saba', + BR: 'Brazil', + BS: 'Bahamas', + BT: 'Bhutan', + BV: 'Bouvet Island', + BW: 'Botswana', + BY: 'Belarus', + BZ: 'Belize', + CA: 'Canada', + CC: 'Cocos (Keeling) Islands', + CD: 'Democratic Republic of the Congo', + CF: 'Central African Republic', + CG: 'Congo', + CH: 'Switzerland', + CI: "Côte d'Ivoire", + CK: 'Cook Islands', + CL: 'Chile', + CM: 'Cameroon', + CN: 'China', + CO: 'Colombia', + CR: 'Costa Rica', + CS: 'Serbia and Montenegro', + CU: 'Cuba', + CV: 'Cape Verde', + CW: 'Curaçao', + CX: 'Christmas Island', + CY: 'Cyprus', + CZ: 'Czechia', + DE: 'Germany', + DJ: 'Djibouti', + DK: 'Denmark', + DM: 'Dominica', + DO: 'Dominican Republic', + DZ: 'Algeria', + EC: 'Ecuador', + EE: 'Estonia', + EG: 'Egypt', + EH: 'Western Sahara', + ER: 'Eritrea', + ES: 'Spain', + ET: 'Ethiopia', + FI: 'Finland', + FJ: 'Fiji', + FK: 'Falkland Islands', + FM: 'Federated States of Micronesia', + FO: 'Faroe Islands', + FR: 'France', + GA: 'Gabon', + GB: 'United Kingdom', + GD: 'Grenada', + GE: 'Georgia', + GF: 'French Guiana', + GG: 'Guernsey', + GH: 'Ghana', + GI: 'Gibraltar', + GL: 'Greenland', + GM: 'Gambia', + GN: 'Guinea', + GP: 'Guadeloupe', + GQ: 'Equatorial Guinea', + GR: 'Greece', + GS: 'South Georgia and the South Sandwich Islands', + GT: 'Guatemala', + GU: 'Guam', + GW: 'Guinea-Bissau', + GY: 'Guyana', + HK: 'Hong Kong', + HM: 'Heard Island and McDonald Islands', + HN: 'Honduras', + HR: 'Croatia', + HT: 'Haiti', + HU: 'Hungary', + ID: 'Indonesia', + IE: 'Ireland', + IL: 'Israel', + IM: 'Isle of Man', + IN: 'India', + IO: 'British Indian Ocean Territory', + IQ: 'Iraq', + IR: 'Iran', + IS: 'Iceland', + IT: 'Italy', + JE: 'Jersey', + JM: 'Jamaica', + JO: 'Jordan', + JP: 'Japan', + KE: 'Kenya', + KG: 'Kyrgyzstan', + KH: 'Cambodia', + KI: 'Kiribati', + KM: 'Comoros', + KN: 'Saint Kitts and Nevis', + KP: 'North Korea', + KR: 'South Korea', + KW: 'Kuwait', + KY: 'Cayman Islands', + KZ: 'Kazakhstan', + LA: 'Laos', + LB: 'Lebanon', + LC: 'Saint Lucia', + LI: 'Liechtenstein', + LK: 'Sri Lanka', + LR: 'Liberia', + LS: 'Lesotho', + LT: 'Lithuania', + LU: 'Luxembourg', + LV: 'Latvia', + LY: 'Libya', + MA: 'Morocco', + MC: 'Monaco', + MD: 'Moldova', + ME: 'Montenegro', + MF: 'Saint Martin (French part)', + MG: 'Madagascar', + MH: 'Marshall Islands', + MK: 'North Macedonia', + ML: 'Mali', + MM: 'Myanmar', + MN: 'Mongolia', + MO: 'Macao', + MP: 'Northern Mariana Islands', + MQ: 'Martinique', + MR: 'Mauritania', + MS: 'Montserrat', + MT: 'Malta', + MU: 'Mauritius', + MV: 'Maldives', + MW: 'Malawi', + MX: 'Mexico', + MY: 'Malaysia', + MZ: 'Mozambique', + NA: 'Namibia', + NC: 'New Caledonia', + NE: 'Niger', + NF: 'Norfolk Island', + NG: 'Nigeria', + NI: 'Nicaragua', + NL: 'Netherlands', + NO: 'Norway', + NP: 'Nepal', + NR: 'Nauru', + NU: 'Niue', + NZ: 'New Zealand', + OM: 'Oman', + PA: 'Panama', + PE: 'Peru', + PF: 'French Polynesia', + PG: 'Papua New Guinea', + PH: 'Philippines', + PK: 'Pakistan', + PL: 'Poland', + PM: 'Saint Pierre and Miquelon', + PN: 'Pitcairn', + PR: 'Puerto Rico', + PS: 'Palestine', + PT: 'Portugal', + PW: 'Palau', + PY: 'Paraguay', + QA: 'Qatar', + RE: 'Réunion', + RO: 'Romania', + RS: 'Serbia', + RU: 'Russia', + RW: 'Rwanda', + SA: 'Saudi Arabia', + SB: 'Solomon Islands', + SC: 'Seychelles', + SD: 'Sudan', + SE: 'Sweden', + SG: 'Singapore', + SH: 'Saint Helena, Ascension and Tristan da Cunha', + SI: 'Slovenia', + SJ: 'Svalbard and Jan Mayen', + SK: 'Slovakia', + SL: 'Sierra Leone', + SM: 'San Marino', + SN: 'Senegal', + SO: 'Somalia', + SR: 'Suriname', + SS: 'South Sudan', + ST: 'Sao Tome and Principe', + SU: 'Soviet Union', + SV: 'El Salvador', + SX: 'Sint Maarten (Dutch part)', + SY: 'Syria', + SZ: 'Eswatini', + TC: 'Turks and Caicos Islands', + TD: 'Chad', + TF: 'French Southern Territories', + TG: 'Togo', + TH: 'Thailand', + TJ: 'Tajikistan', + TK: 'Tokelau', + TL: 'Timor-Leste', + TM: 'Turkmenistan', + TN: 'Tunisia', + TO: 'Tonga', + TR: 'Turkey', + TT: 'Trinidad and Tobago', + TV: 'Tuvalu', + TW: 'Taiwan', + TZ: 'Tanzania', + UA: 'Ukraine', + UG: 'Uganda', + UM: 'United States Minor Outlying Islands', + US: 'United States', + UY: 'Uruguay', + UZ: 'Uzbekistan', + VA: 'Vatican City', + VC: 'Saint Vincent and The Grenadines', + VE: 'Venezuela', + VG: 'British Virgin Islands', + VI: 'U.S. Virgin Islands', + VN: 'Vietnam', + VU: 'Vanuatu', + WF: 'Wallis and Futuna', + WS: 'Samoa', + XC: 'Czechoslovakia', + XE: 'Europe', + XG: 'East Germany', + XK: 'Kosovo', + XW: '[Worldwide]', + YE: 'Yemen', + YT: 'Mayotte', + YU: 'Yugoslavia', + ZA: 'South Africa', + ZM: 'Zambia', + ZW: 'Zimbabwe', +}; +const MBZ_RELEASE_TYPES = { + album: 'album', + audiobook: 'audiobook', + 'audio drama': 'audio drama', + broadcast: 'broadcast', + compilation: 'compilation', + demo: 'demo', + 'dj-mix': 'dj-mix', + ep: 'ep', + 'field recording': 'field recording', + interview: 'interview', + live: 'live', + 'mixtape/street': 'mixtape/street', + other: 'other', + remix: 'remix', + single: 'single', + soundtrack: 'soundtrack', + spokenword: 'spokenword', +}; diff --git a/src/renderer/features/shared/components/library-header-bar.tsx b/src/renderer/features/shared/components/library-header-bar.tsx index 2c270e2e3..d89eb43aa 100644 --- a/src/renderer/features/shared/components/library-header-bar.tsx +++ b/src/renderer/features/shared/components/library-header-bar.tsx @@ -32,6 +32,7 @@ const LibraryHeaderBarComponent = ({ children, ignoreMaxWidth }: LibraryHeaderBa interface HeaderPlayButtonProps { className?: string; + disabled?: boolean; ids?: string[]; itemType: LibraryItem; listQuery?: Record; @@ -46,6 +47,7 @@ interface TitleProps { const HeaderPlayButton = ({ className, + disabled, ids, itemType, listQuery, @@ -58,6 +60,8 @@ const HeaderPlayButton = ({ const handlePlay = useCallback( (playType: Play) => { + if (disabled) return; + if (listQuery) { player.addToQueueByListQuery(serverId, listQuery, itemType, playType); } else if (ids) { @@ -68,7 +72,7 @@ const HeaderPlayButton = ({ closeAllModals(); }, - [listQuery, ids, songs, player, serverId, itemType], + [disabled, listQuery, ids, songs, player, serverId, itemType], ); const isPlayerFetching = useIsPlayerFetching(); @@ -80,6 +84,7 @@ const HeaderPlayButton = ({
setIsOpen((prev) => !prev)} ref={buttonRef} diff --git a/src/renderer/features/shared/components/library-header.tsx b/src/renderer/features/shared/components/library-header.tsx index 6242a69c0..4380521f3 100644 --- a/src/renderer/features/shared/components/library-header.tsx +++ b/src/renderer/features/shared/components/library-header.tsx @@ -49,12 +49,14 @@ interface LibraryHeaderProps { export const LibraryHeader = forwardRef( ( - { children, containerClassName, imageUrl, item, title }: LibraryHeaderProps, + { children, containerClassName, imageUrl: imageUrlProp, item, title }: LibraryHeaderProps, ref: Ref, ) => { const { t } = useTranslation(); const [isImageError, setIsImageError] = useState(false); + const effectiveImageUrl = imageUrlProp ?? item.imageUrl ?? undefined; + const onImageError = () => { setIsImageError(true); }; @@ -77,20 +79,18 @@ export const LibraryHeader = forwardRef( }; const openImage = useCallback(() => { - const imageId = item.imageId; const itemType = item.type as LibraryItem; - if (!imageId || !itemType) { - return; + let modalImageUrl = effectiveImageUrl; + + if (!modalImageUrl && item.imageId && itemType) { + modalImageUrl = getItemImageUrl({ + id: item.imageId, + itemType, + }); } - const imageUrl = getItemImageUrl({ - id: imageId, - itemType, - }); - - if (!imageUrl) { - console.error('No image URL found'); + if (!modalImageUrl) { return; } @@ -110,7 +110,7 @@ export const LibraryHeader = forwardRef( enableViewport={false} fetchPriority="high" isExplicit={item.explicitStatus === ExplicitStatus.EXPLICIT} - src={imageUrl} + src={modalImageUrl} style={{ maxHeight: '100%', maxWidth: '100%', @@ -122,7 +122,7 @@ export const LibraryHeader = forwardRef( ), fullScreen: true, }); - }, [item.explicitStatus, item.imageId, item.type]); + }, [effectiveImageUrl, item.explicitStatus, item.imageId, item.type]); return (
@@ -149,7 +149,7 @@ export const LibraryHeader = forwardRef( id={item.imageId} itemType={item.type as LibraryItem} onError={onImageError} - src={imageUrl || ''} + src={effectiveImageUrl ?? ''} type="header" /> )} @@ -263,6 +263,7 @@ export const calculateTitleSize = (title: string) => { }; interface LibraryHeaderMenuProps { + disabled?: boolean; favorite?: boolean; onArtistRadio?: () => void; onFavorite?: (e: React.MouseEvent) => void; @@ -274,6 +275,7 @@ interface LibraryHeaderMenuProps { } export const LibraryHeaderMenu = ({ + disabled, favorite, onArtistRadio, onFavorite, @@ -319,15 +321,30 @@ export const LibraryHeaderMenu = ({ return (
- {onPlay && } {onPlay && ( - + )} {onPlay && ( - + + )} + {onPlay && ( + )} {onArtistRadio && (