improve date parsing for partial dates (#1683)

This commit is contained in:
jeffvli
2026-04-02 19:39:08 -07:00
parent ad11a9303c
commit 72f1d2f9f9
13 changed files with 268 additions and 202 deletions
@@ -22,9 +22,9 @@ import { AppRoute } from '/@/renderer/router/routes';
import { useShowRatings } from '/@/renderer/store'; import { useShowRatings } from '/@/renderer/store';
import { import {
formatDateAbsolute, formatDateAbsolute,
formatDateAbsoluteUTC,
formatDateRelative, formatDateRelative,
formatDurationString, formatDurationString,
formatPartialIsoDateUTC,
formatRating, formatRating,
} from '/@/renderer/utils/format'; } from '/@/renderer/utils/format';
import { SEPARATOR_STRING } from '/@/shared/api/utils'; import { SEPARATOR_STRING } from '/@/shared/api/utils';
@@ -1161,12 +1161,10 @@ export const getDataRows = (type?: 'compact' | 'default' | 'poster'): DataRow[]
}, },
{ {
format: (data) => { format: (data) => {
if ('releaseYear' in data && data.releaseYear !== null) { if ('releaseYear' in data && data.releaseYear != null) {
const releaseYear = data.releaseYear; const releaseYear = data.releaseYear;
const originalYear = const originalYear =
'originalYear' in data && data.originalYear !== null 'originalYear' in data && data.originalYear > 0 ? data.originalYear : null;
? data.originalYear
: null;
if (originalYear !== null && originalYear !== releaseYear) { if (originalYear !== null && originalYear !== releaseYear) {
return `${originalYear}${SEPARATOR_STRING}${releaseYear}`; return `${originalYear}${SEPARATOR_STRING}${releaseYear}`;
@@ -1186,10 +1184,10 @@ export const getDataRows = (type?: 'compact' | 'default' | 'poster'): DataRow[]
data.originalDate && data.originalDate &&
data.originalDate !== data.releaseDate 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 ''; return '';
}, },
@@ -1,6 +1,21 @@
import { ItemDetailListCellProps } from './types'; 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) => export const ReleaseDateColumn = ({ song }: ItemDetailListCellProps) => {
song.releaseDate ? formatDateAbsoluteUTC(song.releaseDate) : <>&nbsp;</>; const row = song as typeof song & { originalDate?: null | string };
const releaseDate = row.releaseDate;
if (!releaseDate) {
return <>&nbsp;</>;
}
const originalDate =
row.originalDate && row.originalDate !== releaseDate ? row.originalDate : null;
if (originalDate) {
return `${formatPartialIsoDateUTC(originalDate)}${SEPARATOR_STRING}${formatPartialIsoDateUTC(releaseDate)}`;
}
return formatPartialIsoDateUTC(releaseDate);
};
@@ -67,7 +67,7 @@ import { songsQueries } from '/@/renderer/features/songs/api/songs-api';
import { useDragDrop } from '/@/renderer/hooks/use-drag-drop'; import { useDragDrop } from '/@/renderer/hooks/use-drag-drop';
import { AppRoute } from '/@/renderer/router/routes'; import { AppRoute } from '/@/renderer/router/routes';
import { useSettingsStore, useShowRatings } from '/@/renderer/store'; 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 { SEPARATOR_STRING } from '/@/shared/api/utils';
import { ExplicitIndicator } from '/@/shared/components/explicit-indicator/explicit-indicator'; import { ExplicitIndicator } from '/@/shared/components/explicit-indicator/explicit-indicator';
import { Skeleton } from '/@/shared/components/skeleton/skeleton'; import { Skeleton } from '/@/shared/components/skeleton/skeleton';
@@ -489,9 +489,9 @@ const MetadataSection = memo(
let releaseStr = ''; let releaseStr = '';
if (item.releaseDate) { if (item.releaseDate) {
if (item.originalDate && item.originalDate !== 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 { } else {
releaseStr = formatDateAbsoluteUTC(item.releaseDate); releaseStr = formatPartialIsoDateUTC(item.releaseDate);
} }
} else if (item.releaseYear != null) { } else if (item.releaseYear != null) {
releaseStr = String(item.releaseYear); releaseStr = String(item.releaseYear);
@@ -8,49 +8,20 @@ import {
} from '/@/renderer/components/item-list/item-table-list/item-table-list-column'; } from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
import { import {
formatDateAbsolute, formatDateAbsolute,
formatDateAbsoluteUTC,
formatDateRelative, formatDateRelative,
formatHrDateTime, formatPartialIsoDateUTC,
} from '/@/renderer/utils/format'; } from '/@/renderer/utils/format';
import { SEPARATOR_STRING } from '/@/shared/api/utils'; 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'; import { TableColumn } from '/@/shared/types/types';
const getDateTooltipLabel = (utcString: string) => {
return (
<Stack gap="xs" justify="center">
<Text size="md" ta="center">
{formatHrDateTime(utcString)}
</Text>
<Text isMuted size="sm" ta="center">
{utcString}
</Text>
</Stack>
);
};
const DateColumnBase = (props: ItemTableListInnerColumn) => { const DateColumnBase = (props: ItemTableListInnerColumn) => {
const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex]; 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 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) { if (typeof row === 'string' && row) {
return ( return (
<TableColumnTextContainer {...props}> <TableColumnTextContainer {...props}>
<Tooltip label={tooltipLabel} multiline={false}> <span>{formatDateAbsolute(row)}</span>
<span>{formattedDate}</span>
</Tooltip>
</TableColumnTextContainer> </TableColumnTextContainer>
); );
} }
@@ -79,44 +50,32 @@ const AbsoluteDateColumnBase = (props: ItemTableListInnerColumn) => {
: null; : null;
if (originalDate) { if (originalDate) {
const formattedOriginalDate = formatDateAbsoluteUTC(originalDate); const formattedOriginalDate = formatPartialIsoDateUTC(originalDate);
const formattedReleaseDate = formatDateAbsoluteUTC(releaseDate); const formattedReleaseDate = formatPartialIsoDateUTC(releaseDate);
const displayText = `${formattedOriginalDate}${SEPARATOR_STRING}${formattedReleaseDate}`; return `${formattedOriginalDate}${SEPARATOR_STRING}${formattedReleaseDate}`;
return {
displayText,
tooltipLabel: getDateTooltipLabel(releaseDate),
};
} }
if (typeof releaseDate === 'string' && releaseDate) { if (typeof releaseDate === 'string' && releaseDate) {
return { return formatPartialIsoDateUTC(releaseDate);
displayText: formatDateAbsoluteUTC(releaseDate),
tooltipLabel: getDateTooltipLabel(releaseDate),
};
} }
} }
} }
return null; return null;
}, [props.type, rowItem]); }, [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 (props.type === TableColumn.RELEASE_DATE) {
if (releaseDateContent) { if (releaseDateContent) {
return ( return (
<TableColumnTextContainer {...props}> <TableColumnTextContainer {...props}>
<Tooltip label={releaseDateContent.tooltipLabel} multiline={false}> <span>{releaseDateContent}</span>
<span>{releaseDateContent.displayText}</span> </TableColumnTextContainer>
</Tooltip> );
}
if (typeof row === 'string' && row) {
return (
<TableColumnTextContainer {...props}>
<span>{formatPartialIsoDateUTC(row)}</span>
</TableColumnTextContainer> </TableColumnTextContainer>
); );
} }
@@ -128,20 +87,6 @@ const AbsoluteDateColumnBase = (props: ItemTableListInnerColumn) => {
return <ColumnSkeletonFixed {...props} />; return <ColumnSkeletonFixed {...props} />;
} }
if (typeof row === 'string' && row) {
return (
<TableColumnTextContainer {...props}>
<Tooltip label={tooltipLabel} multiline={false}>
<span>{formattedDate}</span>
</Tooltip>
</TableColumnTextContainer>
);
}
if (row === null) {
return <ColumnNullFallback {...props} />;
}
return <ColumnSkeletonFixed {...props} />; return <ColumnSkeletonFixed {...props} />;
}; };
@@ -151,22 +96,10 @@ const RelativeDateColumnBase = (props: ItemTableListInnerColumn) => {
const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex]; 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 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') { if (typeof row === 'string') {
return ( return (
<TableColumnTextContainer {...props}> <TableColumnTextContainer {...props}>
<Tooltip label={tooltipLabel} multiline={false}> <span>{formatDateRelative(row)}</span>
<span>{formattedDate}</span>
</Tooltip>
</TableColumnTextContainer> </TableColumnTextContainer>
); );
} }
@@ -13,10 +13,10 @@ const YearColumnBase = (props: ItemTableListInnerColumn) => {
const item = rowItem as any; const item = rowItem as any;
const yearDisplay = useMemo(() => { const yearDisplay = useMemo(() => {
if (item && 'releaseYear' in item && item.releaseYear !== null) { if (item && 'releaseYear' in item && item.releaseYear != null) {
const releaseYear = item.releaseYear; const releaseYear = item.releaseYear;
const originalYear = 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) { if (originalYear !== null && originalYear !== releaseYear) {
return `${originalYear}${SEPARATOR_STRING}${releaseYear}`; return `${originalYear}${SEPARATOR_STRING}${releaseYear}`;
@@ -20,7 +20,7 @@ import { songsQueries } from '/@/renderer/features/songs/api/songs-api';
import { AppRoute } from '/@/renderer/router/routes'; import { AppRoute } from '/@/renderer/router/routes';
import { useCurrentServer, useShowRatings } from '/@/renderer/store'; import { useCurrentServer, useShowRatings } from '/@/renderer/store';
import { useArtistRadioCount, usePlayButtonBehavior } from '/@/renderer/store/settings.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 { normalizeReleaseTypes } from '/@/renderer/utils/normalize-release-types';
import { Group } from '/@/shared/components/group/group'; import { Group } from '/@/shared/components/group/group';
import { Separator } from '/@/shared/components/separator/separator'; import { Separator } from '/@/shared/components/separator/separator';
@@ -131,7 +131,10 @@ export const AlbumDetailHeader = forwardRef<HTMLDivElement>((_props, ref) => {
const originalDifferentFromRelease = const originalDifferentFromRelease =
album?.originalDate && album?.originalDate !== album?.releaseDate; 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; const playCount = album?.playCount;
@@ -147,17 +150,17 @@ export const AlbumDetailHeader = forwardRef<HTMLDivElement>((_props, ref) => {
if (originalDifferentFromRelease) { if (originalDifferentFromRelease) {
items.push({ items.push({
id: 'originalDate', id: 'originalDate',
value: `${formatDateAbsoluteUTC(album.originalDate)}`, value: `${formatPartialIsoDateUTC(album.originalDate)}`,
}); });
} }
if (releaseDate) { if (releaseDate) {
items.push({ items.push({
id: 'releaseDate', id: 'releaseDate',
value: `${releasePrefix} ${formatDateAbsoluteUTC(releaseDate)}`, value: `${releasePrefix} ${formatPartialIsoDateUTC(releaseDate)}`,
}); });
} }
} else if (album.originalYear) { } else if (album.originalYear > 0) {
if (originalYearDifferentFromRelease) { if (originalYearDifferentFromRelease) {
items.push({ items.push({
id: 'originalYear', id: 'originalYear',
@@ -168,14 +171,24 @@ export const AlbumDetailHeader = forwardRef<HTMLDivElement>((_props, ref) => {
if (releaseDate) { if (releaseDate) {
items.push({ items.push({
id: 'releaseDate', id: 'releaseDate',
value: `${releaseYearPrefix} ${formatDateAbsoluteUTC(releaseDate)}`, value: `${releaseYearPrefix} ${formatPartialIsoDateUTC(releaseDate)}`,
}); });
} else if (releaseYear) { } else if (releaseYear != null && releaseYear > 0) {
items.push({ items.push({
id: 'releaseYear', id: 'releaseYear',
value: `${releaseYearPrefix} ${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( items.push(
+1 -1
View File
@@ -36,7 +36,7 @@ export function playlistSongsToAlbums(songs: Song[]): PlaylistAlbumRow[] {
mbzReleaseGroupId: null, mbzReleaseGroupId: null,
name: song.album ?? '', name: song.album ?? '',
originalDate: null, originalDate: null,
originalYear: null, originalYear: 0,
participants: song.participants, participants: song.participants,
playCount: null, playCount: null,
recordLabels: [], recordLabels: [],
+48
View File
@@ -76,6 +76,16 @@ const getDayjsLocale = (i18nLang: string): string => {
return localeMap[i18nLang] || 'en'; 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<string, string> = {
'zh-Hans': 'zh-CN',
'zh-Hant': 'zh-TW',
};
return localeMap[i18nLang] ?? i18nLang;
};
const updateDayjsLocale = () => { const updateDayjsLocale = () => {
const dayjsLocale = getDayjsLocale(i18n.language); const dayjsLocale = getDayjsLocale(i18n.language);
dayjs.locale(dayjsLocale); dayjs.locale(dayjsLocale);
@@ -92,6 +102,44 @@ export const formatDateAbsolute = (key: null | string) => (key ? dayjs(key).form
export const formatDateAbsoluteUTC = (key: null | string) => export const formatDateAbsoluteUTC = (key: null | string) =>
key ? dayjs.utc(key).format('ll') : ''; 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 formatHrDateTime = (key: null | string) => (key ? dayjs(key).format('lll') : '');
export const formatDateRelative = (key: null | string) => (key ? dayjs(key).fromNow() : ''); export const formatDateRelative = (key: null | string) => (key ? dayjs(key).fromNow() : '');
+25 -6
View File
@@ -1,6 +1,7 @@
import { z } from 'zod'; import { z } from 'zod';
import { jfType } from '/@/shared/api/jellyfin/jellyfin-types'; import { jfType } from '/@/shared/api/jellyfin/jellyfin-types';
import { coerceYear, parsePartialIsoDateFromApi } from '/@/shared/api/partial-iso-date';
import { replacePathPrefix } from '/@/shared/api/utils'; import { replacePathPrefix } from '/@/shared/api/utils';
import { import {
Album, Album,
@@ -138,6 +139,20 @@ const getArtists = (
return result; 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 = ( const normalizeSong = (
item: z.infer<typeof jfType._response.song>, item: z.infer<typeof jfType._response.song>,
server: null | ServerListItem, server: null | ServerListItem,
@@ -181,6 +196,8 @@ const normalizeSong = (
const artists = getArtists(item, participants); const artists = getArtists(item, participants);
const { releaseDate, releaseYear } = jellyfinPremiereFields(item);
return { return {
_itemType: LibraryItem.SONG, _itemType: LibraryItem.SONG,
_serverId: server?.id || '', _serverId: server?.id || '',
@@ -244,8 +261,8 @@ const normalizeSong = (
peak: null, peak: null,
playCount: (item.UserData && item.UserData.PlayCount) || 0, playCount: (item.UserData && item.UserData.PlayCount) || 0,
playlistItemId: item.PlaylistItemId, playlistItemId: item.PlaylistItemId,
releaseDate: item.PremiereDate || null, releaseDate,
releaseYear: item.ProductionYear || null, releaseYear,
sampleRate, sampleRate,
size, size,
sortName: item.SortName || item.Name, sortName: item.SortName || item.Name,
@@ -262,6 +279,8 @@ const normalizeAlbum = (
item: z.infer<typeof jfType._response.album>, item: z.infer<typeof jfType._response.album>,
server: null | ServerListItem, server: null | ServerListItem,
): Album => { ): Album => {
const { originalYear, releaseDate, releaseYear } = jellyfinPremiereFields(item);
return { return {
_itemType: LibraryItem.ALBUM, _itemType: LibraryItem.ALBUM,
_serverId: server?.id || '', _serverId: server?.id || '',
@@ -310,15 +329,15 @@ const normalizeAlbum = (
mbzId: item.ProviderIds?.MusicBrainzAlbum || null, mbzId: item.ProviderIds?.MusicBrainzAlbum || null,
mbzReleaseGroupId: item.ProviderIds?.MusicBrainzReleaseGroup || null, mbzReleaseGroupId: item.ProviderIds?.MusicBrainzReleaseGroup || null,
name: item.Name, name: item.Name,
originalDate: item.PremiereDate || null, originalDate: releaseDate,
originalYear: item.ProductionYear || null, originalYear,
participants: getPeople(item), participants: getPeople(item),
playCount: item.UserData?.PlayCount || 0, playCount: item.UserData?.PlayCount || 0,
recordLabels: item.Studios?.map((entry) => entry.Name) || [], recordLabels: item.Studios?.map((entry) => entry.Name) || [],
releaseDate: item.PremiereDate || null, releaseDate,
releaseType: null, releaseType: null,
releaseTypes: [], releaseTypes: [],
releaseYear: item.ProductionYear || null, releaseYear,
size: null, size: null,
songCount: item?.ChildCount || null, songCount: item?.ChildCount || null,
songs: item.Songs?.map((song) => normalizeSong(song, server)), songs: item.Songs?.map((song) => normalizeSong(song, server)),
+44 -75
View File
@@ -1,6 +1,7 @@
import z from 'zod'; import z from 'zod';
import { ndType } from '/@/shared/api/navidrome/navidrome-types'; 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 { ssType } from '/@/shared/api/subsonic/subsonic-types';
import { replacePathPrefix } from '/@/shared/api/utils'; import { replacePathPrefix } from '/@/shared/api/utils';
import { import {
@@ -34,95 +35,57 @@ const normalizePlayDate = (item: WithDate): null | string => {
return !item.playDate || item.playDate.includes('0001-') ? null : item.playDate; return !item.playDate || item.playDate.includes('0001-') ? null : item.playDate;
}; };
const matchesFullDate = (date: string) => { const normalizeNavidromeReleaseDate = (item: {
return Boolean(date.match(/^\d{4}-\d{2}-\d{2}$/));
};
const matchesYearOnly = (date: string) => {
return Boolean(date.match(/^\d{4}$/));
};
const normalizeReleaseDate = (item: {
date?: string; date?: string;
minYear?: number; minYear?: number;
releaseDate?: string; releaseDate?: string;
}): { date: null | string; year: null | number } => { }): { date: null | string; year: number } => {
if (item.releaseDate && matchesFullDate(item.releaseDate)) { const fromRelease = parsePartialIsoDate(item.releaseDate);
return { if (fromRelease.date) {
date: item.releaseDate, return fromRelease;
year: parseInt(item.releaseDate.split('-')[0]),
};
} else if (item.releaseDate && matchesYearOnly(item.releaseDate)) {
return {
date: null,
year: parseInt(item.releaseDate),
};
} }
if (item.date && matchesFullDate(item.date)) { const fromDateField = parsePartialIsoDate(item.date);
return { if (fromDateField.date) {
date: item.date, return fromDateField;
year: parseInt(item.date.split('-')[0]),
};
} else if (item.date && matchesYearOnly(item.date)) {
return {
date: null,
year: parseInt(item.date),
};
} }
return { const y = coerceYear(item.minYear);
date: null, if (y > 0) {
year: item.minYear ?? null, return { date: String(y), year: y };
}; }
return { date: null, year: 0 };
}; };
const normalizeOriginalDate = (item: { const normalizeNavidromeOriginalDate = (item: {
date?: string; date?: string;
minOriginalYear?: number;
minYear?: number; minYear?: number;
originalDate?: string; originalDate?: string;
releaseDate?: string; releaseDate?: string;
}): { date: null | string; year: null | number } => { }): { date: null | string; year: number } => {
if (item.originalDate && matchesFullDate(item.originalDate)) { const fromOriginal = parsePartialIsoDate(item.originalDate);
return { if (fromOriginal.date) {
date: item.originalDate, return fromOriginal;
year: parseInt(item.originalDate.split('-')[0]),
};
} else if (item.originalDate && matchesYearOnly(item.originalDate)) {
return {
date: null,
year: parseInt(item.originalDate),
};
} }
if (item.releaseDate && matchesFullDate(item.releaseDate)) { const fromRelease = parsePartialIsoDate(item.releaseDate);
return { if (fromRelease.date) {
date: item.releaseDate, return fromRelease;
year: parseInt(item.releaseDate.split('-')[0]),
};
} else if (item.releaseDate && matchesYearOnly(item.releaseDate)) {
return {
date: null,
year: parseInt(item.releaseDate),
};
} }
if (item.date && matchesFullDate(item.date)) { const fromDateField = parsePartialIsoDate(item.date);
return { if (fromDateField.date) {
date: item.date, return fromDateField;
year: parseInt(item.date.split('-')[0]),
};
} else if (item.date && matchesYearOnly(item.date)) {
return {
date: null,
year: parseInt(item.date),
};
} }
return { const y = coerceYear(item.minOriginalYear ?? item.minYear);
date: null, if (y > 0) {
year: item.minYear ?? null, return { date: String(y), year: y };
}; }
return { date: null, year: 0 };
}; };
const getArtists = ( const getArtists = (
@@ -244,6 +207,12 @@ const normalizeSong = (
id = item.id; 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 { return {
album: item.album, album: item.album,
albumId: item.albumId, albumId: item.albumId,
@@ -302,8 +271,8 @@ const normalizeSong = (
: null, : null,
playCount: item.playCount || 0, playCount: item.playCount || 0,
playlistItemId, playlistItemId,
releaseDate: normalizeReleaseDate(item).date, releaseDate,
releaseYear: item.year || null, releaseYear,
sampleRate: item.sampleRate || null, sampleRate: item.sampleRate || null,
size: item.size, size: item.size,
sortName: item.orderTitle, sortName: item.orderTitle,
@@ -365,8 +334,8 @@ const normalizeAlbum = (
pathReplace?: string, pathReplace?: string,
pathReplaceWith?: string, pathReplaceWith?: string,
): Album => { ): Album => {
const releaseDate = normalizeReleaseDate(item); const releaseDate = normalizeNavidromeReleaseDate(item);
const originalDate = normalizeOriginalDate(item); const originalDate = normalizeNavidromeOriginalDate(item);
return { return {
...parseAlbumTags(item), ...parseAlbumTags(item),
@@ -408,7 +377,7 @@ const normalizeAlbum = (
playCount: item.playCount || 0, playCount: item.playCount || 0,
releaseDate: releaseDate.date, releaseDate: releaseDate.date,
releaseType: item.mbzAlbumType || null, releaseType: item.mbzAlbumType || null,
releaseYear: releaseDate.year, releaseYear: releaseDate.year > 0 ? releaseDate.year : null,
size: item.size, size: item.size,
songCount: item.songCount, songCount: item.songCount,
songs: item.songs songs: item.songs
+46
View File
@@ -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 };
};
+34 -11
View File
@@ -1,5 +1,6 @@
import { z } from 'zod'; import { z } from 'zod';
import { coerceYear, parsePartialIsoDate } from '/@/shared/api/partial-iso-date';
import { ssType } from '/@/shared/api/subsonic/subsonic-types'; import { ssType } from '/@/shared/api/subsonic/subsonic-types';
import { replacePathPrefix } from '/@/shared/api/utils'; import { replacePathPrefix } from '/@/shared/api/utils';
import { 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 = ( const normalizeSong = (
item: z.infer<typeof ssType._response.song>, item: z.infer<typeof ssType._response.song>,
server?: null | ServerListItemWithCredential, server?: null | ServerListItemWithCredential,
@@ -148,6 +175,8 @@ const normalizeSong = (
? item.albumArtists.map((a) => a.name).join(', ') ? item.albumArtists.map((a) => a.name).join(', ')
: item.artist || ''; : item.artist || '';
const { releaseDate, releaseYear } = subsonicReleaseFields(item);
return { return {
_itemType: LibraryItem.SONG, _itemType: LibraryItem.SONG,
_serverId: server?.id || 'unknown', _serverId: server?.id || 'unknown',
@@ -202,8 +231,8 @@ const normalizeSong = (
: null, : null,
playCount: item?.playCount || 0, playCount: item?.playCount || 0,
playlistItemId: playlistIndex !== undefined ? playlistIndex.toString() : undefined, playlistItemId: playlistIndex !== undefined ? playlistIndex.toString() : undefined,
releaseDate: null, releaseDate,
releaseYear: item.year || null, releaseYear,
sampleRate: item.samplingRate || null, sampleRate: item.samplingRate || null,
size: item.size, size: item.size,
sortName: item.title, sortName: item.title,
@@ -285,13 +314,7 @@ const normalizeAlbum = (
discTitleMap.set(discTitle.disc, discTitle.title); discTitleMap.set(discTitle.disc, discTitle.title);
}); });
const releaseDate = const { releaseDate, releaseYear } = subsonicReleaseFields(item);
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;
return { return {
_itemType: LibraryItem.ALBUM, _itemType: LibraryItem.ALBUM,
@@ -319,14 +342,14 @@ const normalizeAlbum = (
mbzReleaseGroupId: null, mbzReleaseGroupId: null,
name: item.name, name: item.name,
originalDate: releaseDate, originalDate: releaseDate,
originalYear: item.year || null, originalYear: releaseYear ?? 0,
participants: getParticipants(item), participants: getParticipants(item),
playCount: null, playCount: null,
recordLabels: item.recordLabels?.map((item) => item.name) || [], recordLabels: item.recordLabels?.map((item) => item.name) || [],
releaseDate, releaseDate,
releaseType: getReleaseType(item), releaseType: getReleaseType(item),
releaseTypes: item.releaseTypes || [], releaseTypes: item.releaseTypes || [],
releaseYear: item.year || null, releaseYear,
size: null, size: null,
songCount: item.songCount, songCount: item.songCount,
songs: songs:
+6 -4
View File
@@ -188,12 +188,12 @@ export type Album = {
mbzId: null | string; mbzId: null | string;
mbzReleaseGroupId: null | string; mbzReleaseGroupId: null | string;
name: string; name: string;
originalDate: null | string; originalDate: null | PartialIsoDateString;
originalYear: null | number; originalYear: number;
participants: null | Record<string, RelatedArtist[]>; participants: null | Record<string, RelatedArtist[]>;
playCount: null | number; playCount: null | number;
recordLabels: string[]; recordLabels: string[];
releaseDate: null | string; releaseDate: null | PartialIsoDateString;
releaseType: null | string; releaseType: null | string;
releaseTypes: string[]; releaseTypes: string[];
releaseYear: null | number; releaseYear: null | number;
@@ -326,6 +326,8 @@ export type MusicFolder = {
export type MusicFoldersResponse = MusicFolder[]; export type MusicFoldersResponse = MusicFolder[];
export type PartialIsoDateString = string;
export type Playlist = { export type Playlist = {
_itemType: LibraryItem.PLAYLIST; _itemType: LibraryItem.PLAYLIST;
_serverId: string; _serverId: string;
@@ -398,7 +400,7 @@ export type Song = {
peak: GainInfo | null; peak: GainInfo | null;
playCount: number; playCount: number;
playlistItemId?: string; playlistItemId?: string;
releaseDate: null | string; releaseDate: null | PartialIsoDateString;
releaseYear: null | number; releaseYear: null | number;
sampleRate: null | number; sampleRate: null | number;
size: number; size: number;