diff --git a/src/renderer/components/item-card/item-card.tsx b/src/renderer/components/item-card/item-card.tsx index 38ed58539..aecb874bf 100644 --- a/src/renderer/components/item-card/item-card.tsx +++ b/src/renderer/components/item-card/item-card.tsx @@ -22,9 +22,9 @@ import { AppRoute } from '/@/renderer/router/routes'; import { useShowRatings } from '/@/renderer/store'; import { formatDateAbsolute, - formatDateAbsoluteUTC, formatDateRelative, formatDurationString, + formatPartialIsoDateUTC, formatRating, } from '/@/renderer/utils/format'; import { SEPARATOR_STRING } from '/@/shared/api/utils'; @@ -1161,12 +1161,10 @@ export const getDataRows = (type?: 'compact' | 'default' | 'poster'): DataRow[] }, { format: (data) => { - if ('releaseYear' in data && data.releaseYear !== null) { + if ('releaseYear' in data && data.releaseYear != null) { const releaseYear = data.releaseYear; const originalYear = - 'originalYear' in data && data.originalYear !== null - ? data.originalYear - : null; + 'originalYear' in data && data.originalYear > 0 ? data.originalYear : null; if (originalYear !== null && originalYear !== releaseYear) { return `${originalYear}${SEPARATOR_STRING}${releaseYear}`; @@ -1186,10 +1184,10 @@ export const getDataRows = (type?: 'compact' | 'default' | 'poster'): DataRow[] data.originalDate && data.originalDate !== data.releaseDate ) { - return `${formatDateAbsoluteUTC(data.originalDate)}${SEPARATOR_STRING}${formatDateAbsoluteUTC(data.releaseDate)}`; + return `${formatPartialIsoDateUTC(data.originalDate)}${SEPARATOR_STRING}${formatPartialIsoDateUTC(data.releaseDate)}`; } - return `${formatDateAbsoluteUTC(data.releaseDate)}`; + return `${formatPartialIsoDateUTC(data.releaseDate)}`; } return ''; }, diff --git a/src/renderer/components/item-list/item-detail-list/columns/release-date-column.tsx b/src/renderer/components/item-list/item-detail-list/columns/release-date-column.tsx index a893fe32b..032d9ae76 100644 --- a/src/renderer/components/item-list/item-detail-list/columns/release-date-column.tsx +++ b/src/renderer/components/item-list/item-detail-list/columns/release-date-column.tsx @@ -1,6 +1,21 @@ import { ItemDetailListCellProps } from './types'; -import { formatDateAbsoluteUTC } from '/@/renderer/utils/format'; +import { formatPartialIsoDateUTC } from '/@/renderer/utils/format'; +import { SEPARATOR_STRING } from '/@/shared/api/utils'; -export const ReleaseDateColumn = ({ song }: ItemDetailListCellProps) => - song.releaseDate ? formatDateAbsoluteUTC(song.releaseDate) : <> ; +export const ReleaseDateColumn = ({ song }: ItemDetailListCellProps) => { + const row = song as typeof song & { originalDate?: null | string }; + const releaseDate = row.releaseDate; + if (!releaseDate) { + return <> ; + } + + const originalDate = + row.originalDate && row.originalDate !== releaseDate ? row.originalDate : null; + + if (originalDate) { + return `${formatPartialIsoDateUTC(originalDate)}${SEPARATOR_STRING}${formatPartialIsoDateUTC(releaseDate)}`; + } + + return formatPartialIsoDateUTC(releaseDate); +}; diff --git a/src/renderer/components/item-list/item-detail-list/item-detail-list.tsx b/src/renderer/components/item-list/item-detail-list/item-detail-list.tsx index da01ba207..9c6dbd94c 100644 --- a/src/renderer/components/item-list/item-detail-list/item-detail-list.tsx +++ b/src/renderer/components/item-list/item-detail-list/item-detail-list.tsx @@ -67,7 +67,7 @@ import { songsQueries } from '/@/renderer/features/songs/api/songs-api'; import { useDragDrop } from '/@/renderer/hooks/use-drag-drop'; import { AppRoute } from '/@/renderer/router/routes'; import { useSettingsStore, useShowRatings } from '/@/renderer/store'; -import { formatDateAbsoluteUTC, formatDurationString } from '/@/renderer/utils'; +import { formatDurationString, formatPartialIsoDateUTC } from '/@/renderer/utils'; import { SEPARATOR_STRING } from '/@/shared/api/utils'; import { ExplicitIndicator } from '/@/shared/components/explicit-indicator/explicit-indicator'; import { Skeleton } from '/@/shared/components/skeleton/skeleton'; @@ -489,9 +489,9 @@ const MetadataSection = memo( let releaseStr = ''; if (item.releaseDate) { if (item.originalDate && item.originalDate !== item.releaseDate) { - releaseStr = `${formatDateAbsoluteUTC(item.originalDate)}${SEPARATOR_STRING}${formatDateAbsoluteUTC(item.releaseDate)}`; + releaseStr = `${formatPartialIsoDateUTC(item.originalDate)}${SEPARATOR_STRING}${formatPartialIsoDateUTC(item.releaseDate)}`; } else { - releaseStr = formatDateAbsoluteUTC(item.releaseDate); + releaseStr = formatPartialIsoDateUTC(item.releaseDate); } } else if (item.releaseYear != null) { releaseStr = String(item.releaseYear); diff --git a/src/renderer/components/item-list/item-table-list/columns/date-column.tsx b/src/renderer/components/item-list/item-table-list/columns/date-column.tsx index 00407ef87..27a6c8cd1 100644 --- a/src/renderer/components/item-list/item-table-list/columns/date-column.tsx +++ b/src/renderer/components/item-list/item-table-list/columns/date-column.tsx @@ -8,49 +8,20 @@ import { } from '/@/renderer/components/item-list/item-table-list/item-table-list-column'; import { formatDateAbsolute, - formatDateAbsoluteUTC, formatDateRelative, - formatHrDateTime, + formatPartialIsoDateUTC, } from '/@/renderer/utils/format'; import { SEPARATOR_STRING } from '/@/shared/api/utils'; -import { Stack } from '/@/shared/components/stack/stack'; -import { Text } from '/@/shared/components/text/text'; -import { Tooltip } from '/@/shared/components/tooltip/tooltip'; import { TableColumn } from '/@/shared/types/types'; -const getDateTooltipLabel = (utcString: string) => { - return ( - - - {formatHrDateTime(utcString)} - - - {utcString} - - - ); -}; - const DateColumnBase = (props: ItemTableListInnerColumn) => { const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex]; const row: string | undefined = (rowItem as any)?.[props.columns[props.columnIndex].id]; - const { formattedDate, tooltipLabel } = useMemo(() => { - if (typeof row === 'string' && row) { - return { - formattedDate: formatDateAbsolute(row), - tooltipLabel: getDateTooltipLabel(row), - }; - } - return { formattedDate: null, tooltipLabel: null }; - }, [row]); - if (typeof row === 'string' && row) { return ( - - {formattedDate} - + {formatDateAbsolute(row)} ); } @@ -79,44 +50,32 @@ const AbsoluteDateColumnBase = (props: ItemTableListInnerColumn) => { : null; if (originalDate) { - const formattedOriginalDate = formatDateAbsoluteUTC(originalDate); - const formattedReleaseDate = formatDateAbsoluteUTC(releaseDate); - const displayText = `${formattedOriginalDate}${SEPARATOR_STRING}${formattedReleaseDate}`; - - return { - displayText, - tooltipLabel: getDateTooltipLabel(releaseDate), - }; + const formattedOriginalDate = formatPartialIsoDateUTC(originalDate); + const formattedReleaseDate = formatPartialIsoDateUTC(releaseDate); + return `${formattedOriginalDate}${SEPARATOR_STRING}${formattedReleaseDate}`; } if (typeof releaseDate === 'string' && releaseDate) { - return { - displayText: formatDateAbsoluteUTC(releaseDate), - tooltipLabel: getDateTooltipLabel(releaseDate), - }; + return formatPartialIsoDateUTC(releaseDate); } } } return null; }, [props.type, rowItem]); - const { formattedDate, tooltipLabel } = useMemo(() => { - if (typeof row === 'string' && row) { - return { - formattedDate: formatDateAbsoluteUTC(row), - tooltipLabel: getDateTooltipLabel(row), - }; - } - return { formattedDate: null, tooltipLabel: null }; - }, [row]); - if (props.type === TableColumn.RELEASE_DATE) { if (releaseDateContent) { return ( - - {releaseDateContent.displayText} - + {releaseDateContent} + + ); + } + + if (typeof row === 'string' && row) { + return ( + + {formatPartialIsoDateUTC(row)} ); } @@ -128,20 +87,6 @@ const AbsoluteDateColumnBase = (props: ItemTableListInnerColumn) => { return ; } - if (typeof row === 'string' && row) { - return ( - - - {formattedDate} - - - ); - } - - if (row === null) { - return ; - } - return ; }; @@ -151,22 +96,10 @@ const RelativeDateColumnBase = (props: ItemTableListInnerColumn) => { const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex]; const row: string | undefined = (rowItem as any)?.[props.columns[props.columnIndex].id]; - const { formattedDate, tooltipLabel } = useMemo(() => { - if (typeof row === 'string') { - return { - formattedDate: formatDateRelative(row), - tooltipLabel: getDateTooltipLabel(row), - }; - } - return { formattedDate: null, tooltipLabel: null }; - }, [row]); - if (typeof row === 'string') { return ( - - {formattedDate} - + {formatDateRelative(row)} ); } diff --git a/src/renderer/components/item-list/item-table-list/columns/year-column.tsx b/src/renderer/components/item-list/item-table-list/columns/year-column.tsx index 47ab985af..7bb2ec7e5 100644 --- a/src/renderer/components/item-list/item-table-list/columns/year-column.tsx +++ b/src/renderer/components/item-list/item-table-list/columns/year-column.tsx @@ -13,10 +13,10 @@ const YearColumnBase = (props: ItemTableListInnerColumn) => { const item = rowItem as any; const yearDisplay = useMemo(() => { - if (item && 'releaseYear' in item && item.releaseYear !== null) { + if (item && 'releaseYear' in item && item.releaseYear != null) { const releaseYear = item.releaseYear; const originalYear = - 'originalYear' in item && item.originalYear !== null ? item.originalYear : null; + 'originalYear' in item && item.originalYear > 0 ? item.originalYear : null; if (originalYear !== null && originalYear !== releaseYear) { return `${originalYear}${SEPARATOR_STRING}${releaseYear}`; diff --git a/src/renderer/features/albums/components/album-detail-header.tsx b/src/renderer/features/albums/components/album-detail-header.tsx index d9df192b5..0844cb63c 100644 --- a/src/renderer/features/albums/components/album-detail-header.tsx +++ b/src/renderer/features/albums/components/album-detail-header.tsx @@ -20,7 +20,7 @@ import { songsQueries } from '/@/renderer/features/songs/api/songs-api'; import { AppRoute } from '/@/renderer/router/routes'; import { useCurrentServer, useShowRatings } from '/@/renderer/store'; import { useArtistRadioCount, usePlayButtonBehavior } from '/@/renderer/store/settings.store'; -import { formatDateAbsoluteUTC, formatDurationString, formatSizeString } from '/@/renderer/utils'; +import { formatDurationString, formatPartialIsoDateUTC, formatSizeString } from '/@/renderer/utils'; import { normalizeReleaseTypes } from '/@/renderer/utils/normalize-release-types'; import { Group } from '/@/shared/components/group/group'; import { Separator } from '/@/shared/components/separator/separator'; @@ -131,7 +131,10 @@ export const AlbumDetailHeader = forwardRef((_props, ref) => { const originalDifferentFromRelease = album?.originalDate && album?.originalDate !== album?.releaseDate; - const originalYearDifferentFromRelease = album?.originalYear !== album?.releaseYear; + const originalYearDifferentFromRelease = + album.originalYear > 0 && + album.releaseYear != null && + album.originalYear !== album.releaseYear; const playCount = album?.playCount; @@ -147,17 +150,17 @@ export const AlbumDetailHeader = forwardRef((_props, ref) => { if (originalDifferentFromRelease) { items.push({ id: 'originalDate', - value: `♫ ${formatDateAbsoluteUTC(album.originalDate)}`, + value: `♫ ${formatPartialIsoDateUTC(album.originalDate)}`, }); } if (releaseDate) { items.push({ id: 'releaseDate', - value: `${releasePrefix} ${formatDateAbsoluteUTC(releaseDate)}`, + value: `${releasePrefix} ${formatPartialIsoDateUTC(releaseDate)}`, }); } - } else if (album.originalYear) { + } else if (album.originalYear > 0) { if (originalYearDifferentFromRelease) { items.push({ id: 'originalYear', @@ -168,14 +171,24 @@ export const AlbumDetailHeader = forwardRef((_props, ref) => { if (releaseDate) { items.push({ id: 'releaseDate', - value: `${releaseYearPrefix} ${formatDateAbsoluteUTC(releaseDate)}`, + value: `${releaseYearPrefix} ${formatPartialIsoDateUTC(releaseDate)}`, }); - } else if (releaseYear) { + } else if (releaseYear != null && releaseYear > 0) { items.push({ id: 'releaseYear', value: `${releaseYearPrefix} ${releaseYear}`, }); } + } else if (releaseDate) { + items.push({ + id: 'releaseDate', + value: `♫ ${formatPartialIsoDateUTC(releaseDate)}`, + }); + } else if (releaseYear != null && releaseYear > 0) { + items.push({ + id: 'releaseYear', + value: `♫ ${releaseYear}`, + }); } items.push( diff --git a/src/renderer/features/playlists/utils.ts b/src/renderer/features/playlists/utils.ts index 082630f17..aba659f3f 100644 --- a/src/renderer/features/playlists/utils.ts +++ b/src/renderer/features/playlists/utils.ts @@ -36,7 +36,7 @@ export function playlistSongsToAlbums(songs: Song[]): PlaylistAlbumRow[] { mbzReleaseGroupId: null, name: song.album ?? '', originalDate: null, - originalYear: null, + originalYear: 0, participants: song.participants, playCount: null, recordLabels: [], diff --git a/src/renderer/utils/format.tsx b/src/renderer/utils/format.tsx index a455e1041..e097ad61e 100644 --- a/src/renderer/utils/format.tsx +++ b/src/renderer/utils/format.tsx @@ -76,6 +76,16 @@ const getDayjsLocale = (i18nLang: string): string => { return localeMap[i18nLang] || 'en'; }; +// BCP 47 tags for Intl (differs from dayjs locale ids for some languages). +const getIntlLocale = (i18nLang: string): string => { + const localeMap: Record = { + 'zh-Hans': 'zh-CN', + 'zh-Hant': 'zh-TW', + }; + + return localeMap[i18nLang] ?? i18nLang; +}; + const updateDayjsLocale = () => { const dayjsLocale = getDayjsLocale(i18n.language); dayjs.locale(dayjsLocale); @@ -92,6 +102,44 @@ export const formatDateAbsolute = (key: null | string) => (key ? dayjs(key).form export const formatDateAbsoluteUTC = (key: null | string) => key ? dayjs.utc(key).format('ll') : ''; +const PARTIAL_ISO_YEAR = /^\d{4}$/; +const PARTIAL_ISO_YEAR_MONTH = /^\d{4}-\d{2}$/; + +export const formatPartialIsoDateUTC = (key: null | string): string => { + if (!key) { + return ''; + } + + const trimmedKey = key.trim(); + const intlLocale = getIntlLocale(i18n.language); + + if (PARTIAL_ISO_YEAR.test(trimmedKey)) { + const year = Number.parseInt(trimmedKey, 10); + if (!Number.isFinite(year)) { + return trimmedKey; + } + + return new Intl.DateTimeFormat(intlLocale, { timeZone: 'UTC', year: 'numeric' }).format( + new Date(Date.UTC(year, 0, 1)), + ); + } + + if (PARTIAL_ISO_YEAR_MONTH.test(trimmedKey)) { + const d = dayjs.utc(`${trimmedKey}-01`); + if (!d.isValid()) { + return trimmedKey; + } + + return new Intl.DateTimeFormat(intlLocale, { + month: 'long', + timeZone: 'UTC', + year: 'numeric', + }).format(d.toDate()); + } + + return dayjs.utc(trimmedKey).format('ll'); +}; + export const formatHrDateTime = (key: null | string) => (key ? dayjs(key).format('lll') : ''); export const formatDateRelative = (key: null | string) => (key ? dayjs(key).fromNow() : ''); diff --git a/src/shared/api/jellyfin/jellyfin-normalize.ts b/src/shared/api/jellyfin/jellyfin-normalize.ts index b9e181cab..59d72c921 100644 --- a/src/shared/api/jellyfin/jellyfin-normalize.ts +++ b/src/shared/api/jellyfin/jellyfin-normalize.ts @@ -1,6 +1,7 @@ import { z } from 'zod'; import { jfType } from '/@/shared/api/jellyfin/jellyfin-types'; +import { coerceYear, parsePartialIsoDateFromApi } from '/@/shared/api/partial-iso-date'; import { replacePathPrefix } from '/@/shared/api/utils'; import { Album, @@ -138,6 +139,20 @@ const getArtists = ( return result; }; + +const jellyfinPremiereFields = (item: { + PremiereDate?: string; + ProductionYear?: number; +}): { originalYear: number; releaseDate: null | string; releaseYear: null | number } => { + const premiere = parsePartialIsoDateFromApi(item.PremiereDate ?? null); + const prodYear = coerceYear(item.ProductionYear); + const releaseYear: null | number = + premiere.year > 0 ? premiere.year : prodYear > 0 ? prodYear : null; + const releaseDate = premiere.date ?? (prodYear > 0 ? String(prodYear) : null); + const originalYear = premiere.year > 0 ? premiere.year : prodYear; + return { originalYear, releaseDate, releaseYear }; +}; + const normalizeSong = ( item: z.infer, server: null | ServerListItem, @@ -181,6 +196,8 @@ const normalizeSong = ( const artists = getArtists(item, participants); + const { releaseDate, releaseYear } = jellyfinPremiereFields(item); + return { _itemType: LibraryItem.SONG, _serverId: server?.id || '', @@ -244,8 +261,8 @@ const normalizeSong = ( peak: null, playCount: (item.UserData && item.UserData.PlayCount) || 0, playlistItemId: item.PlaylistItemId, - releaseDate: item.PremiereDate || null, - releaseYear: item.ProductionYear || null, + releaseDate, + releaseYear, sampleRate, size, sortName: item.SortName || item.Name, @@ -262,6 +279,8 @@ const normalizeAlbum = ( item: z.infer, server: null | ServerListItem, ): Album => { + const { originalYear, releaseDate, releaseYear } = jellyfinPremiereFields(item); + return { _itemType: LibraryItem.ALBUM, _serverId: server?.id || '', @@ -310,15 +329,15 @@ const normalizeAlbum = ( mbzId: item.ProviderIds?.MusicBrainzAlbum || null, mbzReleaseGroupId: item.ProviderIds?.MusicBrainzReleaseGroup || null, name: item.Name, - originalDate: item.PremiereDate || null, - originalYear: item.ProductionYear || null, + originalDate: releaseDate, + originalYear, participants: getPeople(item), playCount: item.UserData?.PlayCount || 0, recordLabels: item.Studios?.map((entry) => entry.Name) || [], - releaseDate: item.PremiereDate || null, + releaseDate, releaseType: null, releaseTypes: [], - releaseYear: item.ProductionYear || null, + releaseYear, size: null, songCount: item?.ChildCount || null, songs: item.Songs?.map((song) => normalizeSong(song, server)), diff --git a/src/shared/api/navidrome/navidrome-normalize.ts b/src/shared/api/navidrome/navidrome-normalize.ts index eb75dddf8..f63b84c06 100644 --- a/src/shared/api/navidrome/navidrome-normalize.ts +++ b/src/shared/api/navidrome/navidrome-normalize.ts @@ -1,6 +1,7 @@ import z from 'zod'; import { ndType } from '/@/shared/api/navidrome/navidrome-types'; +import { coerceYear, parsePartialIsoDate } from '/@/shared/api/partial-iso-date'; import { ssType } from '/@/shared/api/subsonic/subsonic-types'; import { replacePathPrefix } from '/@/shared/api/utils'; import { @@ -34,95 +35,57 @@ const normalizePlayDate = (item: WithDate): null | string => { return !item.playDate || item.playDate.includes('0001-') ? null : item.playDate; }; -const matchesFullDate = (date: string) => { - return Boolean(date.match(/^\d{4}-\d{2}-\d{2}$/)); -}; - -const matchesYearOnly = (date: string) => { - return Boolean(date.match(/^\d{4}$/)); -}; - -const normalizeReleaseDate = (item: { +const normalizeNavidromeReleaseDate = (item: { date?: string; minYear?: number; releaseDate?: string; -}): { date: null | string; year: null | number } => { - if (item.releaseDate && matchesFullDate(item.releaseDate)) { - return { - date: item.releaseDate, - year: parseInt(item.releaseDate.split('-')[0]), - }; - } else if (item.releaseDate && matchesYearOnly(item.releaseDate)) { - return { - date: null, - year: parseInt(item.releaseDate), - }; +}): { date: null | string; year: number } => { + const fromRelease = parsePartialIsoDate(item.releaseDate); + if (fromRelease.date) { + return fromRelease; } - if (item.date && matchesFullDate(item.date)) { - return { - date: item.date, - year: parseInt(item.date.split('-')[0]), - }; - } else if (item.date && matchesYearOnly(item.date)) { - return { - date: null, - year: parseInt(item.date), - }; + const fromDateField = parsePartialIsoDate(item.date); + if (fromDateField.date) { + return fromDateField; } - return { - date: null, - year: item.minYear ?? null, - }; + const y = coerceYear(item.minYear); + if (y > 0) { + return { date: String(y), year: y }; + } + + return { date: null, year: 0 }; }; -const normalizeOriginalDate = (item: { +const normalizeNavidromeOriginalDate = (item: { date?: string; + minOriginalYear?: number; minYear?: number; originalDate?: string; releaseDate?: string; -}): { date: null | string; year: null | number } => { - if (item.originalDate && matchesFullDate(item.originalDate)) { - return { - date: item.originalDate, - year: parseInt(item.originalDate.split('-')[0]), - }; - } else if (item.originalDate && matchesYearOnly(item.originalDate)) { - return { - date: null, - year: parseInt(item.originalDate), - }; +}): { date: null | string; year: number } => { + const fromOriginal = parsePartialIsoDate(item.originalDate); + if (fromOriginal.date) { + return fromOriginal; } - if (item.releaseDate && matchesFullDate(item.releaseDate)) { - return { - date: item.releaseDate, - year: parseInt(item.releaseDate.split('-')[0]), - }; - } else if (item.releaseDate && matchesYearOnly(item.releaseDate)) { - return { - date: null, - year: parseInt(item.releaseDate), - }; + const fromRelease = parsePartialIsoDate(item.releaseDate); + if (fromRelease.date) { + return fromRelease; } - if (item.date && matchesFullDate(item.date)) { - return { - date: item.date, - year: parseInt(item.date.split('-')[0]), - }; - } else if (item.date && matchesYearOnly(item.date)) { - return { - date: null, - year: parseInt(item.date), - }; + const fromDateField = parsePartialIsoDate(item.date); + if (fromDateField.date) { + return fromDateField; } - return { - date: null, - year: item.minYear ?? null, - }; + const y = coerceYear(item.minOriginalYear ?? item.minYear); + if (y > 0) { + return { date: String(y), year: y }; + } + + return { date: null, year: 0 }; }; const getArtists = ( @@ -244,6 +207,12 @@ const normalizeSong = ( id = item.id; } + const fromSongRelease = parsePartialIsoDate(item.releaseDate); + const songApiYear = coerceYear(item.year); + const releaseYear: null | number = + fromSongRelease.year > 0 ? fromSongRelease.year : songApiYear > 0 ? songApiYear : null; + const releaseDate = fromSongRelease.date ?? (songApiYear > 0 ? String(songApiYear) : null); + return { album: item.album, albumId: item.albumId, @@ -302,8 +271,8 @@ const normalizeSong = ( : null, playCount: item.playCount || 0, playlistItemId, - releaseDate: normalizeReleaseDate(item).date, - releaseYear: item.year || null, + releaseDate, + releaseYear, sampleRate: item.sampleRate || null, size: item.size, sortName: item.orderTitle, @@ -365,8 +334,8 @@ const normalizeAlbum = ( pathReplace?: string, pathReplaceWith?: string, ): Album => { - const releaseDate = normalizeReleaseDate(item); - const originalDate = normalizeOriginalDate(item); + const releaseDate = normalizeNavidromeReleaseDate(item); + const originalDate = normalizeNavidromeOriginalDate(item); return { ...parseAlbumTags(item), @@ -408,7 +377,7 @@ const normalizeAlbum = ( playCount: item.playCount || 0, releaseDate: releaseDate.date, releaseType: item.mbzAlbumType || null, - releaseYear: releaseDate.year, + releaseYear: releaseDate.year > 0 ? releaseDate.year : null, size: item.size, songCount: item.songCount, songs: item.songs diff --git a/src/shared/api/partial-iso-date.ts b/src/shared/api/partial-iso-date.ts new file mode 100644 index 000000000..af15a5b2b --- /dev/null +++ b/src/shared/api/partial-iso-date.ts @@ -0,0 +1,46 @@ +const PARTIAL_ISO = /^\d{4}(-\d{2}(-\d{2})?)?$/; + +export const coerceYear = (value: null | number | undefined): number => { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return 0; + } + + return value; +}; + +// Parses `YYYY`, `YYYY-MM`, or `YYYY-MM-DD`. Returns the trimmed string as `date` when valid. +export const parsePartialIsoDate = ( + input: null | string | undefined, +): { date: null | string; year: number } => { + if (input == null || typeof input !== 'string') { + return { date: null, year: 0 }; + } + + const s = input.trim(); + if (!s || !PARTIAL_ISO.test(s)) { + return { date: null, year: 0 }; + } + + const year = Number.parseInt(s.slice(0, 4), 10); + if (!Number.isFinite(year)) { + return { date: null, year: 0 }; + } + + return { date: s, year }; +}; + +// Like `parsePartialIsoDate`, but if the value is a full ISO datetime, uses the `YYYY-MM-DD` prefix. +export const parsePartialIsoDateFromApi = ( + input: null | string | undefined, +): { date: null | string; year: number } => { + const direct = parsePartialIsoDate(input); + if (direct.date) { + return direct; + } + + if (input != null && typeof input === 'string' && input.length >= 10) { + return parsePartialIsoDate(input.slice(0, 10)); + } + + return { date: null, year: 0 }; +}; diff --git a/src/shared/api/subsonic/subsonic-normalize.ts b/src/shared/api/subsonic/subsonic-normalize.ts index f00982854..485f0571f 100644 --- a/src/shared/api/subsonic/subsonic-normalize.ts +++ b/src/shared/api/subsonic/subsonic-normalize.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; +import { coerceYear, parsePartialIsoDate } from '/@/shared/api/partial-iso-date'; import { ssType } from '/@/shared/api/subsonic/subsonic-types'; import { replacePathPrefix } from '/@/shared/api/utils'; import { @@ -133,6 +134,32 @@ const getGenres = ( : []; }; +const pad2 = (n: number) => String(n).padStart(2, '0'); + +const subsonicReleaseFields = (item: { + releaseDate?: { day?: number; month?: number; year?: number }; + year?: number; +}): { releaseDate: null | string; releaseYear: null | number } => { + const rd = item.releaseDate; + if ( + rd && + typeof rd.year === 'number' && + typeof rd.month === 'number' && + typeof rd.day === 'number' + ) { + const iso = `${rd.year}-${pad2(rd.month)}-${pad2(rd.day)}`; + const parsed = parsePartialIsoDate(iso); + return { releaseDate: parsed.date, releaseYear: parsed.year }; + } + + const y = coerceYear(item.year); + if (y > 0) { + return { releaseDate: String(y), releaseYear: y }; + } + + return { releaseDate: null, releaseYear: null }; +}; + const normalizeSong = ( item: z.infer, server?: null | ServerListItemWithCredential, @@ -148,6 +175,8 @@ const normalizeSong = ( ? item.albumArtists.map((a) => a.name).join(', ') : item.artist || ''; + const { releaseDate, releaseYear } = subsonicReleaseFields(item); + return { _itemType: LibraryItem.SONG, _serverId: server?.id || 'unknown', @@ -202,8 +231,8 @@ const normalizeSong = ( : null, playCount: item?.playCount || 0, playlistItemId: playlistIndex !== undefined ? playlistIndex.toString() : undefined, - releaseDate: null, - releaseYear: item.year || null, + releaseDate, + releaseYear, sampleRate: item.samplingRate || null, size: item.size, sortName: item.title, @@ -285,13 +314,7 @@ const normalizeAlbum = ( discTitleMap.set(discTitle.disc, discTitle.title); }); - const releaseDate = - item.releaseDate && - typeof item.releaseDate.year === 'number' && - typeof item.releaseDate.month === 'number' && - typeof item.releaseDate.day === 'number' - ? `${item.releaseDate.year}-${item.releaseDate.month}-${item.releaseDate.day}` - : null; + const { releaseDate, releaseYear } = subsonicReleaseFields(item); return { _itemType: LibraryItem.ALBUM, @@ -319,14 +342,14 @@ const normalizeAlbum = ( mbzReleaseGroupId: null, name: item.name, originalDate: releaseDate, - originalYear: item.year || null, + originalYear: releaseYear ?? 0, participants: getParticipants(item), playCount: null, recordLabels: item.recordLabels?.map((item) => item.name) || [], releaseDate, releaseType: getReleaseType(item), releaseTypes: item.releaseTypes || [], - releaseYear: item.year || null, + releaseYear, size: null, songCount: item.songCount, songs: diff --git a/src/shared/types/domain-types.ts b/src/shared/types/domain-types.ts index 37a47baed..1b144e902 100644 --- a/src/shared/types/domain-types.ts +++ b/src/shared/types/domain-types.ts @@ -188,12 +188,12 @@ export type Album = { mbzId: null | string; mbzReleaseGroupId: null | string; name: string; - originalDate: null | string; - originalYear: null | number; + originalDate: null | PartialIsoDateString; + originalYear: number; participants: null | Record; playCount: null | number; recordLabels: string[]; - releaseDate: null | string; + releaseDate: null | PartialIsoDateString; releaseType: null | string; releaseTypes: string[]; releaseYear: null | number; @@ -326,6 +326,8 @@ export type MusicFolder = { export type MusicFoldersResponse = MusicFolder[]; +export type PartialIsoDateString = string; + export type Playlist = { _itemType: LibraryItem.PLAYLIST; _serverId: string; @@ -398,7 +400,7 @@ export type Song = { peak: GainInfo | null; playCount: number; playlistItemId?: string; - releaseDate: null | string; + releaseDate: null | PartialIsoDateString; releaseYear: null | number; sampleRate: null | number; size: number;