support mbz album detail view

This commit is contained in:
jeffvli
2026-02-06 20:13:58 -08:00
parent 0bb30ab0da
commit 40ec16e191
11 changed files with 652 additions and 361 deletions
+1
View File
@@ -291,6 +291,7 @@ export const queryKeys: Record<
]
: null,
] as const,
release: (releaseId: string) => ['musicbrainz', 'release', releaseId] as const,
root: () => ['musicbrainz'] as const,
},
musicFolders: {
@@ -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 (
<Link
state={{ item: data }}
to={generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {
albumId: data.id,
})}
>
case LibraryItem.ALBUM: {
const albumPath = getTitlePath(LibraryItem.ALBUM, data.id);
return albumPath ? (
<Link state={{ item: data }} to={albumPath}>
<ExplicitIndicator explicitStatus={explicitStatus} />
{data.name}
</Link>
) : (
<>
<ExplicitIndicator explicitStatus={explicitStatus} />
{data.name}
</>
);
}
case LibraryItem.ALBUM_ARTIST:
return (
<Link
@@ -1351,7 +1353,6 @@ const getItemNavigationPath = (
}
const effectiveItemType = '_itemType' in data && data._itemType ? data._itemType : itemType;
return getTitlePath(effectiveItemType, data.id);
};
+10 -1
View File
@@ -1,16 +1,25 @@
import { queryOptions } from '@tanstack/react-query';
import { getMbzReleaseIdFromAlbumId } from '../../musicbrainz/utils';
import { api } from '/@/renderer/api';
import { controller } from '/@/renderer/api/controller';
import { queryKeys } from '/@/renderer/api/query-keys';
import { getOptimizedListCount } from '/@/renderer/api/utils-list-count';
import { fetchMbzReleaseAsAlbum } from '/@/renderer/features/musicbrainz/api/musicbrainz-api';
import { QueryHookArgs } from '/@/renderer/lib/react-query';
import { AlbumDetailQuery, AlbumListQuery, ListCountQuery } from '/@/shared/types/domain-types';
export const albumQueries = {
detail: (args: QueryHookArgs<AlbumDetailQuery>) => {
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,
@@ -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();
@@ -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<HTMLDivElement>((_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<HTMLDivElement>((_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<HTMLButtonElement>) => {
@@ -248,6 +257,7 @@ export const AlbumDetailHeader = forwardRef<HTMLDivElement>((_props, ref) => {
/>
</Group>
<LibraryHeaderMenu
disabled={isExternal}
favorite={detailQuery?.data?.userFavorite}
onFavorite={handleFavorite}
onMore={handleMoreOptions}
@@ -7,6 +7,7 @@ import { NativeScrollArea } from '/@/renderer/components/native-scroll-area/nati
import { albumQueries } from '/@/renderer/features/albums/api/album-api';
import { AlbumDetailContent } from '/@/renderer/features/albums/components/album-detail-content';
import { AlbumDetailHeader } from '/@/renderer/features/albums/components/album-detail-header';
import { isMbzAlbumId } from '/@/renderer/features/musicbrainz/utils';
import { AnimatedPage } from '/@/renderer/features/shared/components/animated-page';
import {
LibraryBackgroundImage,
@@ -16,9 +17,9 @@ import { LibraryContainer } from '/@/renderer/features/shared/components/library
import { LibraryHeaderBar } from '/@/renderer/features/shared/components/library-header-bar';
import { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary';
import { useFastAverageColor } from '/@/renderer/hooks';
import { useAlbumBackground, useCurrentServer } from '/@/renderer/store';
import { useAlbumBackground, useCurrentServerId } from '/@/renderer/store';
import { Spinner } from '/@/shared/components/spinner/spinner';
import { LibraryItem } from '/@/shared/types/domain-types';
import { LibraryItem, ServerType } from '/@/shared/types/domain-types';
const AlbumDetailRoute = () => {
const scrollAreaRef = useRef<HTMLDivElement>(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 <Spinner container />;
}
const isExternal = detailQuery?.data?._serverType === ServerType.EXTERNAL;
return (
<AnimatedPage key={`album-detail-${albumId}`}>
<NativeScrollArea
@@ -64,6 +72,7 @@ const AlbumDetailRoute = () => {
children: (
<LibraryHeaderBar>
<LibraryHeaderBar.PlayButton
disabled={isExternal}
ids={[albumId]}
itemType={LibraryItem.ALBUM}
variant="default"
@@ -3,20 +3,26 @@ import memoize from 'lodash/memoize';
import {
IArtist,
IBrowseReleasesResult,
IMedium,
IRelation,
IRelease,
IReleaseGroup,
ITrack,
IWork,
MusicBrainzApi,
} from 'musicbrainz-api';
import packageJson from '../../../../../package.json';
import { queryKeys } from '/@/renderer/api/query-keys';
import { getImageUrl } from '/@/renderer/features/musicbrainz/utils';
import {
Album,
AlbumArtist,
LibraryItem,
RelatedArtist,
ServerType,
Song,
} from '/@/shared/types/domain-types';
export const musicbrainzApi = new MusicBrainzApi({
@@ -30,12 +36,12 @@ const CACHE_TIME = 1000 * 60 * 5;
export type MusicBrainzArtistSelectMeta = {
albumArtist: AlbumArtist;
albums?: Album[];
/** Release types to exclude (e.g. 'single', 'ep'). Matches primary and secondary types. */
excludeReleaseTypes?: string[];
/** Country codes (e.g. 'US', 'GB') to sort releases by; earlier in the list = higher priority. */
prioritizeCountries?: string[];
};
type IRelationWithWork = IRelation & { work?: IWork };
const artistSelect = memoize(
({
data,
@@ -56,8 +62,6 @@ const artistSelect = memoize(
userRating: meta.albumArtist.userRating,
};
console.log('meta', meta);
const ownedMbzReleaseGroupIds = new Set<string>();
const ownedMbzReleaseIds = new Set<string>();
@@ -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<IRelease['release-group']>;
// score: number;
// }
// >();
const existingReleaseGroups = new Map<string, IRelease>();
const existingReleases = new Map<string, IRelease>();
const unownedReleases = new Map<string, IRelease>();
@@ -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<IBrowseReleasesResult> {
function collectWorksFromRelease(release: IRelease): IWork[] {
const works: IWork[] = [];
const seenIds = new Set<string>();
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<IBrowseReleasesResult> {
const PAGE_SIZE = 100;
const includes: Array<'media' | 'release-groups'> = ['media', 'release-groups'];
@@ -254,16 +261,13 @@ async function fetchAllReleases(mbzArtistId: string): Promise<IBrowseReleasesRes
const totalCount = firstPage['release-count'];
const allReleases = [...firstPage.releases];
// If we got all releases in the first page, return early
if (allReleases.length >= 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<IBrowseReleasesRes
};
}
const RELEASE_INCLUDES: Array<
| 'artist-credits'
| 'artists'
| 'media'
| 'recording-level-rels'
| 'recordings'
| 'release-groups'
> = ['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<Album> {
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',
};
+295
View File
@@ -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',
};
@@ -32,6 +32,7 @@ const LibraryHeaderBarComponent = ({ children, ignoreMaxWidth }: LibraryHeaderBa
interface HeaderPlayButtonProps {
className?: string;
disabled?: boolean;
ids?: string[];
itemType: LibraryItem;
listQuery?: Record<string, any>;
@@ -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 = ({
<div className={styles.playButtonContainer}>
<DefaultPlayButton
className={className}
disabled={disabled}
loading={isPlayerFetching}
onClick={() => setIsOpen((prev) => !prev)}
ref={buttonRef}
@@ -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<HTMLDivElement>,
) => {
const { t } = useTranslation();
const [isImageError, setIsImageError] = useState<boolean | null>(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 (
<div className={clsx(styles.libraryHeader, containerClassName)} ref={ref}>
@@ -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<HTMLButtonElement>) => void;
@@ -274,6 +275,7 @@ interface LibraryHeaderMenuProps {
}
export const LibraryHeaderMenu = ({
disabled,
favorite,
onArtistRadio,
onFavorite,
@@ -319,15 +321,30 @@ export const LibraryHeaderMenu = ({
return (
<div className={styles.libraryHeaderMenu}>
<Group wrap="nowrap">
{onPlay && <PlayTextButton {...handlePlayNow.handlers} {...handlePlayNow.props} />}
{onPlay && (
<PlayNextTextButton {...handlePlayNext.handlers} {...handlePlayNext.props} />
<PlayTextButton
{...handlePlayNow.handlers}
{...handlePlayNow.props}
disabled={disabled}
/>
)}
{onPlay && (
<PlayLastTextButton {...handlePlayLast.handlers} {...handlePlayLast.props} />
<PlayNextTextButton
{...handlePlayNext.handlers}
{...handlePlayNext.props}
disabled={disabled}
/>
)}
{onPlay && (
<PlayLastTextButton
{...handlePlayLast.handlers}
{...handlePlayLast.props}
disabled={disabled}
/>
)}
{onArtistRadio && (
<Button
disabled={disabled}
leftSection={
isPlayerFetching ? (
<Spinner color="white" />
@@ -344,17 +361,17 @@ export const LibraryHeaderMenu = ({
)}
</Group>
<Group gap="sm" wrap="nowrap">
{onRating && (
{onRating && !disabled && (
<Rating
onChange={onRating}
readOnly={isMutatingRating}
readOnly={isMutatingRating || disabled}
size="lg"
value={rating || 0}
/>
)}
{onFavorite && (
{onFavorite && !disabled && (
<ActionIcon
disabled={isMutatingFavorite}
disabled={isMutatingFavorite || disabled}
icon="favorite"
iconProps={{
fill: favorite ? 'primary' : undefined,
@@ -364,8 +381,9 @@ export const LibraryHeaderMenu = ({
variant="transparent"
/>
)}
{onMore && (
{onMore && !disabled && (
<ActionIcon
disabled={disabled}
icon="ellipsisHorizontal"
onClick={onMore}
size="lg"
@@ -45,6 +45,7 @@ interface TextPlayButtonProps extends ButtonProps {
export const PlayTextButton = ({
className,
disabled,
showTooltip = true,
variant = 'default',
...props
@@ -58,6 +59,7 @@ export const PlayTextButton = ({
label: styles.wideTextButtonLabel,
root: styles.wideTextButton,
}}
disabled={disabled}
variant="subtle"
{...props}
>