import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { ArtistReleaseTypeItem, useAppStore } from '/@/renderer/store'; import { useArtistReleaseTypeItems } from '/@/renderer/store/settings.store'; import { titleCase } from '/@/renderer/utils'; import { SEPARATOR_STRING } from '/@/shared/api/utils'; import { Album } from '/@/shared/types/domain-types'; const collator = new Intl.Collator(); export type GroupingType = 'all' | 'primary'; const PRIMARY_RELEASE_TYPES = ['album', 'broadcast', 'ep', 'other', 'single']; const getNormalizedReleaseTypes = (album: Album): string[] => { const rawReleaseTypes = [...(album.releaseTypes || []), album.releaseType || '']; const normalizedReleaseTypes = rawReleaseTypes .map((type) => type.trim().toLowerCase()) .filter(Boolean); return [...new Set(normalizedReleaseTypes)]; }; export const groupAlbumsByReleaseType = ( albums: Album[], routeId: string, groupingType: GroupingType = 'primary', ): Record => { if (groupingType === 'all') { // Group by all individual release types const grouped = albums.reduce( (acc, album) => { // Priority 1: Appears on - artist is not an album artist const isAlbumArtist = album.albumArtists?.some((artist) => artist.id === routeId); if (!isAlbumArtist) { const appearsOnKey = 'appears-on'; if (!acc[appearsOnKey]) { acc[appearsOnKey] = []; } acc[appearsOnKey].push(album); return acc; } // Priority 2: Compilations if (album.isCompilation) { const compilationKey = 'compilation'; if (!acc[compilationKey]) { acc[compilationKey] = []; } acc[compilationKey].push(album); return acc; } // Group by all release types const normalizedTypes = getNormalizedReleaseTypes(album); if (normalizedTypes.length > 0) { // Sort release types: primaries first (alphabetically), then secondaries (alphabetically) const primaryTypes = normalizedTypes .filter((type) => PRIMARY_RELEASE_TYPES.includes(type)) .sort(); const secondaryTypes = normalizedTypes .filter((type) => !PRIMARY_RELEASE_TYPES.includes(type)) .sort(); const sortedTypes = [...primaryTypes, ...secondaryTypes]; const combinedKey = sortedTypes.join('/'); if (!acc[combinedKey]) { acc[combinedKey] = []; } acc[combinedKey].push(album); } else { // If no release types, use "album" as fallback const albumKey = 'album'; if (!acc[albumKey]) { acc[albumKey] = []; } acc[albumKey].push(album); } return acc; }, {} as Record, ); return grouped; } // Group by primary release types const grouped = albums.reduce( (acc, album) => { // Priority 1: Appears on - artist is not an album artist const isAlbumArtist = album.albumArtists?.some((artist) => artist.id === routeId); if (!isAlbumArtist) { const appearsOnKey = 'appears-on'; if (!acc[appearsOnKey]) { acc[appearsOnKey] = []; } acc[appearsOnKey].push(album); return acc; } const normalizedTypes = getNormalizedReleaseTypes(album); let matchedType: null | string = null; if (normalizedTypes.includes('album')) { matchedType = 'album'; } else if (normalizedTypes.includes('single')) { matchedType = 'single'; } else if (normalizedTypes.includes('ep')) { matchedType = 'ep'; } else if (normalizedTypes.includes('broadcast')) { matchedType = 'broadcast'; } else if (normalizedTypes.includes('other')) { matchedType = 'other'; } else if (normalizedTypes.length > 0) { matchedType = normalizedTypes[0]; } else { matchedType = 'album'; } const releaseTypeKey = matchedType; if (!acc[releaseTypeKey]) { acc[releaseTypeKey] = []; } acc[releaseTypeKey].push(album); return acc; }, {} as Record, ); return grouped; }; export const releaseTypeToEnumMap: Record = { album: ArtistReleaseTypeItem.RELEASE_TYPE_ALBUM, 'appears-on': ArtistReleaseTypeItem.APPEARS_ON, audiobook: ArtistReleaseTypeItem.RELEASE_TYPE_AUDIOBOOK, 'audio drama': ArtistReleaseTypeItem.RELEASE_TYPE_AUDIO_DRAMA, broadcast: ArtistReleaseTypeItem.RELEASE_TYPE_BROADCAST, compilation: ArtistReleaseTypeItem.RELEASE_TYPE_COMPILATION, demo: ArtistReleaseTypeItem.RELEASE_TYPE_DEMO, 'dj-mix': ArtistReleaseTypeItem.RELEASE_TYPE_DJ_MIX, ep: ArtistReleaseTypeItem.RELEASE_TYPE_EP, 'field recording': ArtistReleaseTypeItem.RELEASE_TYPE_FIELD_RECORDING, interview: ArtistReleaseTypeItem.RELEASE_TYPE_INTERVIEW, live: ArtistReleaseTypeItem.RELEASE_TYPE_LIVE, 'mixtape/street': ArtistReleaseTypeItem.RELEASE_TYPE_MIXTAPE_STREET, other: ArtistReleaseTypeItem.RELEASE_TYPE_OTHER, remix: ArtistReleaseTypeItem.RELEASE_TYPE_REMIX, single: ArtistReleaseTypeItem.RELEASE_TYPE_SINGLE, soundtrack: ArtistReleaseTypeItem.RELEASE_TYPE_SOUNDTRACK, spokenword: ArtistReleaseTypeItem.RELEASE_TYPE_SPOKENWORD, }; export const getArtistAlbumsGrouped = ( albums: Album[], routeId: string, groupingType: GroupingType, artistReleaseTypeItems: { disabled: boolean; id: string }[], t: (key: string, options?: any) => string, ) => { const albumsByReleaseType = groupAlbumsByReleaseType(albums, routeId, groupingType); const enabledReleaseTypeEnums = new Set( artistReleaseTypeItems.filter((item) => !item.disabled).map((item) => item.id), ); const priorityMap = new Map(); artistReleaseTypeItems .filter((item) => !item.disabled) .forEach((item, index) => { const releaseTypeKey = Object.keys(releaseTypeToEnumMap).find( (key) => releaseTypeToEnumMap[key] === item.id, ); if (releaseTypeKey) { priorityMap.set(releaseTypeKey, index); } }); const getDisplayNameForType = (releaseType: string): string => { switch (releaseType) { case 'album': return t('releaseType.primary.album'); case 'appears-on': return t('page.albumArtistDetail.appearsOn'); case 'audiobook': return t('releaseType.secondary.audiobook'); case 'audio drama': return t('releaseType.secondary.audioDrama'); case 'broadcast': return t('releaseType.primary.broadcast'); case 'compilation': return t('releaseType.secondary.compilation'); case 'demo': return t('releaseType.secondary.demo'); case 'dj-mix': return t('releaseType.secondary.djMix'); case 'ep': return t('releaseType.primary.ep', { postProcess: 'upperCase', }); case 'field recording': return t('releaseType.secondary.fieldRecording'); case 'interview': return t('releaseType.secondary.interview'); case 'live': return t('releaseType.secondary.live'); case 'mixtape/street': return t('releaseType.secondary.mixtape'); case 'other': return t('releaseType.primary.other'); case 'remix': return t('releaseType.secondary.remix'); case 'single': return t('releaseType.primary.single'); case 'soundtrack': return t('releaseType.secondary.soundtrack'); case 'spokenword': return t('releaseType.secondary.spokenWord'); default: return titleCase(releaseType); } }; const getPriority = (releaseType: string) => { if (releaseType.includes('/')) { const types = releaseType.split('/'); // Check if there's a primary type in the joined types const primaryTypes = types.filter((type) => PRIMARY_RELEASE_TYPES.includes(type)); if (primaryTypes.length > 0) { // Use the primary type's priority (first primary if multiple) const primaryPriority = priorityMap.get(primaryTypes[0]) ?? 999; return primaryPriority; } else { // Only secondary types - use minimum priority from settings const priorities = types .map((type) => priorityMap.get(type) ?? 999) .filter((p) => p !== 999); return priorities.length > 0 ? Math.min(...priorities) : 999; } } return priorityMap.get(releaseType) ?? 999; }; const getSecondaryTypePriorityKey = (releaseType: string): string => { if (releaseType.includes('/')) { const types = releaseType.split('/'); const secondaryTypes = types.filter((type) => !PRIMARY_RELEASE_TYPES.includes(type)); if (secondaryTypes.length > 0) { const priorities = secondaryTypes .map((type) => priorityMap.get(type) ?? 999) .filter((p) => p !== 999) .sort((a, b) => a - b); return priorities.map((p) => String(p).padStart(3, '0')).join(','); } } return ''; }; const isReleaseTypeEnabled = (releaseType: string): boolean => { if (releaseType.includes('/')) { const types = releaseType.split('/'); return types.some((type) => { const enumValue = releaseTypeToEnumMap[type]; return enumValue ? enabledReleaseTypeEnums.has(enumValue) : true; }); } const enumValue = releaseTypeToEnumMap[releaseType]; return enumValue ? enabledReleaseTypeEnums.has(enumValue) : true; }; const releaseTypeEntries = Object.entries(albumsByReleaseType) .filter(([releaseType]) => isReleaseTypeEnabled(releaseType)) .map(([releaseType, albums]) => { let displayName: React.ReactNode | string; if (releaseType.includes('/')) { const types = releaseType.split('/'); const displayNames = types.map((type) => getDisplayNameForType(type)); displayName = displayNames.join(SEPARATOR_STRING); } else { displayName = getDisplayNameForType(releaseType); } return { albums, displayName, releaseType }; }) .sort((a, b) => { const priorityA = getPriority(a.releaseType); const priorityB = getPriority(b.releaseType); if (priorityA !== priorityB) { return priorityA - priorityB; } const isCombinedA = a.releaseType.includes('/'); const isCombinedB = b.releaseType.includes('/'); if (isCombinedA && isCombinedB) { const secondaryKeyA = getSecondaryTypePriorityKey(a.releaseType); const secondaryKeyB = getSecondaryTypePriorityKey(b.releaseType); if (secondaryKeyA && secondaryKeyB) { return collator.compare(secondaryKeyA, secondaryKeyB); } } return collator.compare(a.releaseType, b.releaseType); }); const flatSortedAlbums = releaseTypeEntries.flatMap((entry) => entry.albums); return { flatSortedAlbums, releaseTypeEntries }; }; export const useArtistAlbumsGrouped = (albums: Album[], routeId: string) => { const { t } = useTranslation(); const artistReleaseTypeItems = useArtistReleaseTypeItems(); const albumArtistDetailSort = useAppStore((state) => state.albumArtistDetailSort); const groupingType = albumArtistDetailSort.groupingType; return useMemo(() => { return getArtistAlbumsGrouped(albums, routeId, groupingType, artistReleaseTypeItems, t); }, [albums, routeId, groupingType, artistReleaseTypeItems, t]); };