mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-10 04:30:25 +02:00
555 lines
17 KiB
TypeScript
555 lines
17 KiB
TypeScript
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 {
|
|
Album,
|
|
AlbumArtist,
|
|
ExplicitStatus,
|
|
Genre,
|
|
InternetRadioStation,
|
|
LibraryItem,
|
|
Playlist,
|
|
RelatedArtist,
|
|
Song,
|
|
User,
|
|
} from '/@/shared/types/domain-types';
|
|
import { ServerListItem, ServerType } from '/@/shared/types/types';
|
|
|
|
// const getImageUrl = (args: { url: null | string }) => {
|
|
// const { url } = args;
|
|
// if (url === '/app/artist-placeholder.webp') {
|
|
// return null;
|
|
// }
|
|
|
|
// return url;
|
|
// };
|
|
|
|
const navidromeImageIdWithCacheBust = (
|
|
id: string,
|
|
uploadedImage: string | undefined,
|
|
updatedAt: string | undefined,
|
|
): string => (!uploadedImage ? id : `${id}&_=${updatedAt ?? ''}`);
|
|
|
|
interface WithDate {
|
|
playDate?: string;
|
|
}
|
|
|
|
const normalizePlayDate = (item: WithDate): null | string => {
|
|
return !item.playDate || item.playDate.includes('0001-') ? null : item.playDate;
|
|
};
|
|
|
|
const normalizeNavidromeReleaseDate = (item: {
|
|
date?: string;
|
|
minYear?: number;
|
|
releaseDate?: string;
|
|
}): { date: null | string; year: number } => {
|
|
const fromRelease = parsePartialIsoDate(item.releaseDate);
|
|
if (fromRelease.date) {
|
|
return fromRelease;
|
|
}
|
|
|
|
const fromDateField = parsePartialIsoDate(item.date);
|
|
if (fromDateField.date) {
|
|
return fromDateField;
|
|
}
|
|
|
|
const y = coerceYear(item.minYear);
|
|
if (y > 0) {
|
|
return { date: String(y), year: y };
|
|
}
|
|
|
|
return { date: null, year: 0 };
|
|
};
|
|
|
|
const normalizeNavidromeOriginalDate = (item: {
|
|
date?: string;
|
|
minOriginalYear?: number;
|
|
minYear?: number;
|
|
originalDate?: string;
|
|
releaseDate?: string;
|
|
}): { date: null | string; year: number } => {
|
|
const fromOriginal = parsePartialIsoDate(item.originalDate);
|
|
if (fromOriginal.date) {
|
|
return fromOriginal;
|
|
}
|
|
|
|
const fromRelease = parsePartialIsoDate(item.releaseDate);
|
|
if (fromRelease.date) {
|
|
return fromRelease;
|
|
}
|
|
|
|
const fromDateField = parsePartialIsoDate(item.date);
|
|
if (fromDateField.date) {
|
|
return fromDateField;
|
|
}
|
|
|
|
const y = coerceYear(item.minOriginalYear ?? item.minYear);
|
|
if (y > 0) {
|
|
return { date: String(y), year: y };
|
|
}
|
|
|
|
return { date: null, year: 0 };
|
|
};
|
|
|
|
const getArtists = (
|
|
item:
|
|
| z.infer<typeof ndType._response.album>
|
|
| z.infer<typeof ndType._response.playlistSong>
|
|
| z.infer<typeof ndType._response.song>,
|
|
includeRemixers = true,
|
|
) => {
|
|
let albumArtists: RelatedArtist[] | undefined;
|
|
let artists: RelatedArtist[] | undefined;
|
|
let remixers: RelatedArtist[] | undefined;
|
|
let participants: null | Record<string, RelatedArtist[]> = null;
|
|
|
|
if (item.participants) {
|
|
participants = {};
|
|
for (const [role, list] of Object.entries(item.participants)) {
|
|
if (role === 'albumartist' || role === 'artist' || role === 'remixer') {
|
|
const roleList = list.map((item) => ({
|
|
id: item.id,
|
|
imageId: null,
|
|
imageUrl: null,
|
|
name: item.name,
|
|
userFavorite: false,
|
|
userRating: null,
|
|
}));
|
|
|
|
if (role === 'albumartist') {
|
|
albumArtists = roleList;
|
|
} else if (role === 'remixer' && includeRemixers) {
|
|
remixers = roleList;
|
|
participants['remixer'] = remixers;
|
|
} else {
|
|
artists = roleList;
|
|
}
|
|
} else {
|
|
const subRoles = new Map<string | undefined, RelatedArtist[]>();
|
|
|
|
for (const artist of list) {
|
|
const item: RelatedArtist = {
|
|
id: artist.id,
|
|
imageId: null,
|
|
imageUrl: null,
|
|
name: artist.name,
|
|
userFavorite: false,
|
|
userRating: null,
|
|
};
|
|
|
|
if (subRoles.has(artist.subRole)) {
|
|
subRoles.get(artist.subRole)!.push(item);
|
|
} else {
|
|
subRoles.set(artist.subRole, [item]);
|
|
}
|
|
}
|
|
|
|
for (const [subRole, items] of subRoles.entries()) {
|
|
if (subRole) {
|
|
participants[`${role} (${subRole})`] = items;
|
|
} else {
|
|
participants[role] = items;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (albumArtists === undefined) {
|
|
albumArtists = [
|
|
{
|
|
id: item.albumArtistId,
|
|
imageId: null,
|
|
imageUrl: null,
|
|
name: item.albumArtist,
|
|
userFavorite: false,
|
|
userRating: null,
|
|
},
|
|
];
|
|
}
|
|
|
|
if (artists === undefined) {
|
|
artists = [
|
|
{
|
|
id: item.artistId,
|
|
imageId: null,
|
|
imageUrl: null,
|
|
name: item.artist,
|
|
userFavorite: false,
|
|
userRating: null,
|
|
},
|
|
];
|
|
}
|
|
|
|
if (remixers?.length && includeRemixers) {
|
|
const existingIds = new Set(artists.map((artist) => artist.id));
|
|
for (const remixer of remixers) {
|
|
if (!existingIds.has(remixer.id)) {
|
|
artists.push(remixer);
|
|
}
|
|
}
|
|
}
|
|
|
|
return { albumArtists, artists, participants };
|
|
};
|
|
|
|
const normalizeSong = (
|
|
item: z.infer<typeof ndType._response.playlistSong> | z.infer<typeof ndType._response.song>,
|
|
server?: null | ServerListItem,
|
|
pathReplace?: string,
|
|
pathReplaceWith?: string,
|
|
): Song => {
|
|
let id;
|
|
let playlistItemId;
|
|
|
|
// Dynamically determine the id field based on whether or not the item is a playlist song
|
|
if ('mediaFileId' in item) {
|
|
id = item.mediaFileId;
|
|
playlistItemId = item.id;
|
|
} else {
|
|
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,
|
|
...getArtists(item, true),
|
|
_itemType: LibraryItem.SONG,
|
|
_serverId: server?.id || 'unknown',
|
|
_serverType: ServerType.NAVIDROME,
|
|
albumArtistName: item.albumArtist,
|
|
artistName: item.artist,
|
|
bitDepth: item.bitDepth || null,
|
|
bitRate: item.bitRate,
|
|
bpm: item.bpm ? item.bpm : null,
|
|
channels: item.channels ? item.channels : null,
|
|
comment: item.comment ? item.comment : null,
|
|
compilation: item.compilation,
|
|
container: item.suffix,
|
|
createdAt: item.createdAt,
|
|
discNumber: item.discNumber,
|
|
discSubtitle: item.discSubtitle ? item.discSubtitle : null,
|
|
duration: item.duration * 1000,
|
|
explicitStatus:
|
|
item.explicitStatus === 'e'
|
|
? ExplicitStatus.EXPLICIT
|
|
: item.explicitStatus === 'c'
|
|
? ExplicitStatus.CLEAN
|
|
: null,
|
|
gain:
|
|
item.rgAlbumGain || item.rgTrackGain
|
|
? { album: item.rgAlbumGain, track: item.rgTrackGain }
|
|
: null,
|
|
genres: (item.genres || []).map((genre) => ({
|
|
_itemType: LibraryItem.GENRE,
|
|
_serverId: server?.id || 'unknown',
|
|
_serverType: ServerType.NAVIDROME,
|
|
albumCount: null,
|
|
id: genre.id,
|
|
imageId: null,
|
|
imageUrl: null,
|
|
name: genre.name,
|
|
songCount: null,
|
|
})),
|
|
id,
|
|
imageId: id,
|
|
imageUrl: null,
|
|
lastPlayedAt: normalizePlayDate(item),
|
|
lyrics: item.lyrics ? item.lyrics : null,
|
|
mbzRecordingId: item.mbzReleaseTrackId || null,
|
|
mbzTrackId: item.mbzReleaseTrackId || null,
|
|
name: item.title,
|
|
// Thankfully, Windows is merciful and allows a mix of separators. So, we can use the
|
|
// POSIX separator here instead
|
|
path: item.path ? replacePathPrefix(item.path, pathReplace, pathReplaceWith) : null,
|
|
peak:
|
|
item.rgAlbumPeak || item.rgTrackPeak
|
|
? { album: item.rgAlbumPeak, track: item.rgTrackPeak }
|
|
: null,
|
|
playCount: item.playCount || 0,
|
|
playlistItemId,
|
|
releaseDate,
|
|
releaseYear,
|
|
sampleRate: item.sampleRate || null,
|
|
size: item.size,
|
|
sortName: item.orderTitle,
|
|
tags: item.tags || null,
|
|
trackNumber: item.trackNumber,
|
|
trackSubtitle: item.tags?.subtitle ? item.tags.subtitle.join(' · ') : null,
|
|
updatedAt: item.updatedAt,
|
|
userFavorite: item.starred || false,
|
|
userRating: item.rating || null,
|
|
};
|
|
};
|
|
|
|
const parseAlbumTags = (
|
|
item: z.infer<typeof ndType._response.album>,
|
|
): Pick<Album, 'recordLabels' | 'releaseTypes' | 'tags' | 'version'> => {
|
|
if (!item.tags) {
|
|
return {
|
|
recordLabels: [],
|
|
releaseTypes: [],
|
|
tags: null,
|
|
version: null,
|
|
};
|
|
}
|
|
|
|
// We get the genre from elsewhere. We don't need genre twice
|
|
delete item.tags['genre'];
|
|
|
|
let recordLabels: string[] = [];
|
|
if (item.tags['recordlabel']) {
|
|
recordLabels = item.tags['recordlabel'];
|
|
delete item.tags['recordlabel'];
|
|
}
|
|
|
|
let releaseTypes: string[] = [];
|
|
if (item.tags['releasetype']) {
|
|
releaseTypes = item.tags['releasetype'];
|
|
delete item.tags['releasetype'];
|
|
}
|
|
|
|
let version: null | string = null;
|
|
if (item.tags['albumversion']) {
|
|
version = item.tags['albumversion'].join(' · ');
|
|
delete item.tags['albumversion'];
|
|
}
|
|
|
|
return {
|
|
recordLabels,
|
|
releaseTypes,
|
|
tags: item.tags,
|
|
version,
|
|
};
|
|
};
|
|
|
|
const normalizeAlbum = (
|
|
item: z.infer<typeof ndType._response.album> & {
|
|
songs?: z.infer<typeof ndType._response.songList>;
|
|
},
|
|
server?: null | ServerListItem,
|
|
pathReplace?: string,
|
|
pathReplaceWith?: string,
|
|
): Album => {
|
|
const releaseDate = normalizeNavidromeReleaseDate(item);
|
|
const originalDate = normalizeNavidromeOriginalDate(item);
|
|
|
|
return {
|
|
...parseAlbumTags(item),
|
|
...getArtists(item, false),
|
|
_itemType: LibraryItem.ALBUM,
|
|
_serverId: server?.id || 'unknown',
|
|
_serverType: ServerType.NAVIDROME,
|
|
albumArtistName: item.albumArtist,
|
|
comment: item.comment || null,
|
|
createdAt: item.createdAt,
|
|
duration: item.duration !== undefined ? item.duration * 1000 : null,
|
|
explicitStatus:
|
|
item.explicitStatus === 'e'
|
|
? ExplicitStatus.EXPLICIT
|
|
: item.explicitStatus === 'c'
|
|
? ExplicitStatus.CLEAN
|
|
: null,
|
|
genres: (item.genres || []).map((genre) => ({
|
|
_itemType: LibraryItem.GENRE,
|
|
_serverId: server?.id || 'unknown',
|
|
_serverType: ServerType.NAVIDROME,
|
|
albumCount: null,
|
|
id: genre.id,
|
|
imageId: null,
|
|
imageUrl: null,
|
|
name: genre.name,
|
|
songCount: null,
|
|
})),
|
|
id: item.id,
|
|
imageId: item.coverArtId || item.id,
|
|
imageUrl: null,
|
|
isCompilation: item.compilation,
|
|
lastPlayedAt: normalizePlayDate(item),
|
|
mbzId: item.mbzAlbumId || null,
|
|
mbzReleaseGroupId: item.mbzReleaseGroupId || null,
|
|
name: item.name,
|
|
originalDate: originalDate.date,
|
|
originalYear: originalDate.year,
|
|
playCount: item.playCount || 0,
|
|
releaseDate: releaseDate.date,
|
|
releaseType: item.mbzAlbumType || null,
|
|
releaseYear: releaseDate.year > 0 ? releaseDate.year : null,
|
|
size: item.size,
|
|
songCount: item.songCount,
|
|
songs: item.songs
|
|
? item.songs.map((song) => normalizeSong(song, server, pathReplace, pathReplaceWith))
|
|
: undefined,
|
|
sortName: item.orderAlbumName,
|
|
tags: item.tags || null,
|
|
updatedAt: item.updatedAt,
|
|
userFavorite: item.starred || false,
|
|
userRating: item.rating || null,
|
|
};
|
|
};
|
|
|
|
const normalizeAlbumArtist = (
|
|
item: z.infer<typeof ndType._response.albumArtist> & {
|
|
similarArtists?: z.infer<typeof ssType._response.artistInfo>['artistInfo']['similarArtist'];
|
|
},
|
|
server?: null | ServerListItem,
|
|
): AlbumArtist => {
|
|
// const imageUrl = getImageUrl({ url: item?.largeImageUrl?.replace(/\?size=\d+/, '') || null });
|
|
|
|
let albumCount: number;
|
|
let songCount: number;
|
|
|
|
if (item.stats) {
|
|
albumCount = Math.max(
|
|
item.stats.albumartist?.albumCount ?? 0,
|
|
item.stats.artist?.albumCount ?? 0,
|
|
);
|
|
songCount = Math.max(
|
|
item.stats.albumartist?.songCount ?? 0,
|
|
item.stats.artist?.songCount ?? 0,
|
|
);
|
|
} else {
|
|
albumCount = item.albumCount;
|
|
songCount = item.songCount;
|
|
}
|
|
|
|
const imageId = navidromeImageIdWithCacheBust(
|
|
item.id,
|
|
item.uploadedImage,
|
|
item.updatedAt ?? item.externalInfoUpdatedAt,
|
|
);
|
|
|
|
return {
|
|
_itemType: LibraryItem.ALBUM_ARTIST,
|
|
_serverId: server?.id || 'unknown',
|
|
_serverType: ServerType.NAVIDROME,
|
|
albumCount,
|
|
biography: item.biography || null,
|
|
duration: null,
|
|
genres: (item.genres || []).map((genre) => ({
|
|
_itemType: LibraryItem.GENRE,
|
|
_serverId: server?.id || 'unknown',
|
|
_serverType: ServerType.NAVIDROME,
|
|
albumCount: null,
|
|
id: genre.id,
|
|
imageId: null,
|
|
imageUrl: null,
|
|
name: genre.name,
|
|
songCount: null,
|
|
})),
|
|
id: item.id,
|
|
imageId,
|
|
imageUrl: null,
|
|
lastPlayedAt: normalizePlayDate(item),
|
|
mbz: item.mbzArtistId || null,
|
|
name: item.name,
|
|
playCount: item.playCount || 0,
|
|
similarArtists:
|
|
item.similarArtists?.map((artist) => ({
|
|
id: artist.id,
|
|
imageId: artist.id,
|
|
imageUrl: null,
|
|
name: artist.name,
|
|
userFavorite: Boolean(artist.starred) || false,
|
|
userRating: artist.userRating || null,
|
|
})) || [],
|
|
songCount,
|
|
uploadedImage: item.uploadedImage,
|
|
userFavorite: item.starred || false,
|
|
userRating: item.rating || null,
|
|
};
|
|
};
|
|
|
|
const normalizePlaylist = (
|
|
item: z.infer<typeof ndType._response.playlist>,
|
|
server?: null | ServerListItem,
|
|
): Playlist => {
|
|
const imageId = navidromeImageIdWithCacheBust(item.id, item.uploadedImage, item.updatedAt);
|
|
|
|
return {
|
|
_itemType: LibraryItem.PLAYLIST,
|
|
_serverId: server?.id || 'unknown',
|
|
_serverType: ServerType.NAVIDROME,
|
|
description: item.comment,
|
|
duration: item.duration * 1000,
|
|
genres: [],
|
|
id: item.id,
|
|
imageId,
|
|
imageUrl: null,
|
|
name: item.name,
|
|
owner: item.ownerName,
|
|
ownerId: item.ownerId,
|
|
public: item.public,
|
|
rules: item?.rules || null,
|
|
size: item.size,
|
|
songCount: item.songCount,
|
|
sync: item.sync,
|
|
uploadedImage: item.uploadedImage,
|
|
};
|
|
};
|
|
|
|
const normalizeGenre = (
|
|
item: z.infer<typeof ndType._response.genre> & { albumCount?: number; songCount?: number },
|
|
server: null | ServerListItem,
|
|
): Genre => {
|
|
return {
|
|
_itemType: LibraryItem.GENRE,
|
|
_serverId: server?.id || 'unknown',
|
|
_serverType: ServerType.NAVIDROME,
|
|
albumCount: item.albumCount ?? null,
|
|
id: item.id,
|
|
imageId: null,
|
|
imageUrl: null,
|
|
name: item.name,
|
|
songCount: item.songCount ?? null,
|
|
};
|
|
};
|
|
|
|
const normalizeUser = (item: z.infer<typeof ndType._response.user>): User => {
|
|
return {
|
|
createdAt: item.createdAt,
|
|
email: item.email || null,
|
|
id: item.id,
|
|
isAdmin: item.isAdmin,
|
|
lastLoginAt: item.lastLoginAt,
|
|
name: item.userName,
|
|
updatedAt: item.updatedAt,
|
|
};
|
|
};
|
|
|
|
const normalizeInternetRadioStation = (
|
|
item: z.infer<typeof ndType._response.radioStation>,
|
|
): InternetRadioStation => {
|
|
const homepageUrl = item.homePageUrl?.trim() ? item.homePageUrl : null;
|
|
const imageId = navidromeImageIdWithCacheBust(item.id, item.uploadedImage, item.updatedAt);
|
|
|
|
return {
|
|
homepageUrl,
|
|
id: item.id,
|
|
imageId,
|
|
imageUrl: null,
|
|
name: item.name,
|
|
streamUrl: item.streamUrl,
|
|
uploadedImage: item.uploadedImage || null,
|
|
};
|
|
};
|
|
|
|
export const ndNormalize = {
|
|
album: normalizeAlbum,
|
|
albumArtist: normalizeAlbumArtist,
|
|
genre: normalizeGenre,
|
|
internetRadioStation: normalizeInternetRadioStation,
|
|
playlist: normalizePlaylist,
|
|
song: normalizeSong,
|
|
user: normalizeUser,
|
|
};
|