mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-08 13:00:13 +02:00
improve date parsing for partial dates (#1683)
This commit is contained in:
@@ -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<typeof jfType._response.song>,
|
||||
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<typeof jfType._response.album>,
|
||||
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)),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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<typeof ssType._response.song>,
|
||||
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:
|
||||
|
||||
@@ -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<string, RelatedArtist[]>;
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user