diff --git a/src/renderer/api/navidrome/navidrome-controller.ts b/src/renderer/api/navidrome/navidrome-controller.ts new file mode 100644 index 00000000..fabc73b2 --- /dev/null +++ b/src/renderer/api/navidrome/navidrome-controller.ts @@ -0,0 +1,505 @@ +import { + AlbumArtistDetailArgs, + AlbumArtistDetailResponse, + AddToPlaylistArgs, + AddToPlaylistResponse, + CreatePlaylistResponse, + CreatePlaylistArgs, + DeletePlaylistArgs, + DeletePlaylistResponse, + AlbumArtistListResponse, + AlbumArtistListArgs, + albumArtistListSortMap, + sortOrderMap, + AuthenticationResponse, + UserListResponse, + UserListArgs, + userListSortMap, + GenreListArgs, + GenreListResponse, + AlbumDetailResponse, + AlbumDetailArgs, + AlbumListArgs, + albumListSortMap, + AlbumListResponse, + SongListResponse, + SongListArgs, + songListSortMap, + SongDetailResponse, + SongDetailArgs, + UpdatePlaylistArgs, + UpdatePlaylistResponse, + PlaylistListResponse, + PlaylistDetailArgs, + PlaylistListArgs, + playlistListSortMap, + PlaylistDetailResponse, + PlaylistSongListArgs, + PlaylistSongListResponse, + RemoveFromPlaylistResponse, + RemoveFromPlaylistArgs, +} from '../types'; +import { ndApiClient } from '/@/renderer/api/navidrome/navidrome-api'; +import { ndNormalize } from '/@/renderer/api/navidrome/navidrome-normalize'; +import { ndType } from '/@/renderer/api/navidrome/navidrome-types'; + +const authenticate = async ( + url: string, + body: { password: string; username: string }, +): Promise => { + const cleanServerUrl = url.replace(/\/$/, ''); + + const res = await ndApiClient({ server: cleanServerUrl }).authenticate({ + body: { + password: body.password, + username: body.username, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to authenticate'); + } + + return { + credential: `u=${body.username}&s=${res.body.data.subsonicSalt}&t=${res.body.data.subsonicToken}`, + ndCredential: res.body.data.token, + userId: res.body.data.id, + username: res.body.data.username, + }; +}; + +const getUserList = async (args: UserListArgs): Promise => { + const { query, server, signal } = args; + + if (!server) { + throw new Error('No server'); + } + + const res = await ndApiClient({ server, signal }).getUserList({ + query: { + _end: query.startIndex + (query.limit || 0), + _order: sortOrderMap.navidrome[query.sortOrder], + _sort: userListSortMap.navidrome[query.sortBy], + _start: query.startIndex, + ...query.ndParams, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get user list'); + } + + return { + items: res.body.data.map((user) => ndNormalize.user(user)), + startIndex: query?.startIndex || 0, + totalRecordCount: Number(res.body.headers.get('x-total-count') || 0), + }; +}; + +const getGenreList = async (args: GenreListArgs): Promise => { + const { server, signal } = args; + + if (!server) { + throw new Error('No server'); + } + + const res = await ndApiClient({ server, signal }).getGenreList({}); + + if (res.status !== 200) { + throw new Error('Failed to get genre list'); + } + + return { + items: res.body.data, + startIndex: 0, + totalRecordCount: Number(res.body.headers.get('x-total-count') || 0), + }; +}; + +const getAlbumArtistDetail = async ( + args: AlbumArtistDetailArgs, +): Promise => { + const { query, server, signal } = args; + + if (!server) { + throw new Error('No server'); + } + + const res = await ndApiClient({ server, signal }).getAlbumArtistDetail({ + params: { + id: query.id, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get album artist detail'); + } + + return ndNormalize.albumArtist(res.body.data, server); +}; + +const getAlbumArtistList = async (args: AlbumArtistListArgs): Promise => { + const { query, server, signal } = args; + + if (!server) { + throw new Error('No server'); + } + + const res = await ndApiClient({ server, signal }).getAlbumArtistList({ + query: { + _end: query.startIndex + (query.limit || 0), + _order: sortOrderMap.navidrome[query.sortOrder], + _sort: albumArtistListSortMap.navidrome[query.sortBy], + _start: query.startIndex, + name: query.searchTerm, + ...query._custom?.navidrome, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get album artist list'); + } + + return { + items: res.body.data.map((albumArtist) => ndNormalize.albumArtist(albumArtist, server)), + startIndex: query.startIndex, + totalRecordCount: Number(res.body.headers.get('x-total-count') || 0), + }; +}; + +const getAlbumDetail = async (args: AlbumDetailArgs): Promise => { + const { query, server, signal } = args; + + if (!server) { + throw new Error('No server'); + } + + const albumRes = await ndApiClient({ server, signal }).getAlbumDetail({ + params: { + id: query.id, + }, + }); + + const songsData = await ndApiClient({ server, signal }).getSongList({ + query: { + _end: 0, + _order: 'ASC', + _sort: 'album', + _start: 0, + album_id: [query.id], + }, + }); + + if (albumRes.status !== 200 || songsData.status !== 200) { + throw new Error('Failed to get album detail'); + } + + return ndNormalize.album({ ...albumRes.body.data, songs: songsData.body.data }, server); +}; + +const getAlbumList = async (args: AlbumListArgs): Promise => { + const { query, server, signal } = args; + + if (!server) { + throw new Error('No server'); + } + + const res = await ndApiClient({ server, signal }).getAlbumList({ + query: { + _end: query.startIndex + (query.limit || 0), + _order: sortOrderMap.navidrome[query.sortOrder], + _sort: albumListSortMap.navidrome[query.sortBy], + _start: query.startIndex, + artist_id: query.artistIds?.[0], + name: query.searchTerm, + ...query._custom?.navidrome, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get album list'); + } + + return { + items: res.body.data.map((album) => ndNormalize.album(album, server)), + startIndex: query?.startIndex || 0, + totalRecordCount: Number(res.body.headers.get('x-total-count') || 0), + }; +}; + +const getSongList = async (args: SongListArgs): Promise => { + const { query, server, signal } = args; + + if (!server) { + throw new Error('No server'); + } + + const res = await ndApiClient({ server, signal }).getSongList({ + query: { + _end: query.startIndex + (query.limit || -1), + _order: sortOrderMap.navidrome[query.sortOrder], + _sort: songListSortMap.navidrome[query.sortBy], + _start: query.startIndex, + album_id: query.albumIds, + artist_id: query.artistIds, + title: query.searchTerm, + ...query._custom?.navidrome, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get song list'); + } + + return { + items: res.body.data.map((song) => ndNormalize.song(song, server, '')), + startIndex: query?.startIndex || 0, + totalRecordCount: Number(res.body.headers.get('x-total-count') || 0), + }; +}; + +const getSongDetail = async (args: SongDetailArgs): Promise => { + const { query, server, signal } = args; + + if (!server) { + throw new Error('No server'); + } + + const res = await ndApiClient({ server, signal }).getSongDetail({ + params: { + id: query.id, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get song detail'); + } + + return ndNormalize.song(res.body.data, server, ''); +}; + +const createPlaylist = async (args: CreatePlaylistArgs): Promise => { + const { body, server } = args; + + if (!server) { + throw new Error('No server'); + } + + const res = await ndApiClient({ server }).createPlaylist({ + body: { + comment: body.comment, + name: body.name, + public: body._custom.navidrome?.public, + rules: body._custom.navidrome?.rules, + sync: body._custom.navidrome?.sync, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to create playlist'); + } + + return { + id: res.body.data.id, + name: body.name, + }; +}; + +const updatePlaylist = async (args: UpdatePlaylistArgs): Promise => { + const { query, body, server, signal } = args; + + if (!server) { + throw new Error('No server'); + } + + const res = await ndApiClient({ server, signal }).updatePlaylist({ + body: { + comment: body.comment || '', + name: body.name, + public: body.ndParams?.public || false, + rules: body.ndParams?.rules ? body.ndParams?.rules : undefined, + sync: body.ndParams?.sync || undefined, + }, + params: { + id: query.id, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to update playlist'); + } + + return { + id: res.body.data.id, + }; +}; + +const deletePlaylist = async (args: DeletePlaylistArgs): Promise => { + const { query, server } = args; + + if (!server) { + throw new Error('No server'); + } + + const res = await ndApiClient({ server }).deletePlaylist({ + body: null, + params: { + id: query.id, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to delete playlist'); + } + + return null; +}; + +const getPlaylistList = async (args: PlaylistListArgs): Promise => { + const { query, server, signal } = args; + + if (!server) { + throw new Error('No server'); + } + + const res = await ndApiClient({ server, signal }).getPlaylistList({ + query: { + _end: query.startIndex + (query.limit || 0), + _order: sortOrderMap.navidrome[query.sortOrder], + _sort: query.sortBy ? playlistListSortMap.navidrome[query.sortBy] : undefined, + _start: query.startIndex, + ...query._custom?.navidrome, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get playlist list'); + } + + return { + items: res.body.data.map((item) => ndNormalize.playlist(item, server)), + startIndex: query?.startIndex || 0, + totalRecordCount: Number(res.body.headers.get('x-total-count') || 0), + }; +}; + +const getPlaylistDetail = async (args: PlaylistDetailArgs): Promise => { + const { query, server, signal } = args; + + if (!server) { + throw new Error('No server'); + } + + const res = await ndApiClient({ server, signal }).getPlaylistDetail({ + params: { + id: query.id, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get playlist detail'); + } + + return ndNormalize.playlist(res.body.data, server); +}; + +const getPlaylistSongList = async ( + args: PlaylistSongListArgs, +): Promise => { + const { query, server, signal } = args; + + if (!server) { + throw new Error('No server'); + } + + const res = await ndApiClient({ server, signal }).getPlaylistSongList({ + params: { + id: query.id, + }, + query: { + _end: query.startIndex + (query.limit || 0), + _order: query.sortOrder ? sortOrderMap.navidrome[query.sortOrder] : 'ASC', + _sort: query.sortBy ? songListSortMap.navidrome[query.sortBy] : ndType._enum.songList.ID, + _start: query.startIndex, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get playlist song list'); + } + + return { + items: res.body.data.map((item) => ndNormalize.song(item, server, '')), + startIndex: query?.startIndex || 0, + totalRecordCount: Number(res.body.headers.get('x-total-count') || 0), + }; +}; + +const addToPlaylist = async (args: AddToPlaylistArgs): Promise => { + const { body, query, server } = args; + + if (!server) { + throw new Error('No server'); + } + + const res = await ndApiClient({ server }).addToPlaylist({ + body: { + ids: body.songId, + }, + params: { + id: query.id, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to add to playlist'); + } + + return null; +}; + +const removeFromPlaylist = async ( + args: RemoveFromPlaylistArgs, +): Promise => { + const { query, server, signal } = args; + + if (!server) { + throw new Error('No server'); + } + + const res = await ndApiClient({ server, signal }).removeFromPlaylist({ + body: null, + params: { + id: query.id, + }, + query: { + ids: query.songId, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to remove from playlist'); + } + + return null; +}; + +export const ndController = { + addToPlaylist, + authenticate, + createPlaylist, + deletePlaylist, + getAlbumArtistDetail, + getAlbumArtistList, + getAlbumDetail, + getAlbumList, + getGenreList, + getPlaylistDetail, + getPlaylistList, + getPlaylistSongList, + getSongDetail, + getSongList, + getUserList, + removeFromPlaylist, + updatePlaylist, +}; diff --git a/src/renderer/api/navidrome/navidrome-normalize.ts b/src/renderer/api/navidrome/navidrome-normalize.ts new file mode 100644 index 00000000..9012ed62 --- /dev/null +++ b/src/renderer/api/navidrome/navidrome-normalize.ts @@ -0,0 +1,228 @@ +import { nanoid } from 'nanoid'; +import { Song, LibraryItem, Album, AlbumArtist, Playlist, User } from '/@/renderer/api/types'; +import { ServerListItem, ServerType } from '/@/renderer/types'; +import z from 'zod'; +import { ndType } from './navidrome-types'; + +const getCoverArtUrl = (args: { + baseUrl: string; + coverArtId: string; + credential: string; + size: number; +}) => { + const size = args.size ? args.size : 250; + + if (!args.coverArtId || args.coverArtId.match('2a96cbd8b46e442fc41c2b86b821562f')) { + return null; + } + + return ( + `${args.baseUrl}/rest/getCoverArt.view` + + `?id=${args.coverArtId}` + + `&${args.credential}` + + '&v=1.13.0' + + '&c=feishin' + + `&size=${size}` + ); +}; + +const normalizeSong = ( + item: z.infer | z.infer, + server: ServerListItem, + deviceId: string, + imageSize?: number, +): 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 imageUrl = getCoverArtUrl({ + baseUrl: server.url, + coverArtId: id, + credential: server.credential, + size: imageSize || 100, + }); + + const imagePlaceholderUrl = null; + + return { + album: item.album, + albumArtists: [{ id: item.artistId, imageUrl: null, name: item.artist }], + albumId: item.albumId, + artistName: item.artist, + artists: [{ id: item.artistId, imageUrl: null, name: item.artist }], + 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.split('T')[0], + discNumber: item.discNumber, + duration: item.duration, + genres: item.genres, + id, + imagePlaceholderUrl, + imageUrl, + itemType: LibraryItem.SONG, + lastPlayedAt: item.playDate.includes('0001-') ? null : item.playDate, + name: item.title, + path: item.path, + playCount: item.playCount, + playlistItemId, + releaseDate: new Date(item.year, 0, 1).toISOString(), + releaseYear: String(item.year), + serverId: server.id, + serverType: ServerType.NAVIDROME, + size: item.size, + streamUrl: `${server.url}/rest/stream.view?id=${id}&v=1.13.0&c=feishin_${deviceId}&${server.credential}`, + trackNumber: item.trackNumber, + uniqueId: nanoid(), + updatedAt: item.updatedAt, + userFavorite: item.starred || false, + userRating: item.rating || null, + }; +}; + +const normalizeAlbum = ( + item: z.infer & { + songs?: z.infer; + }, + server: ServerListItem, + imageSize?: number, +): Album => { + const imageUrl = getCoverArtUrl({ + baseUrl: server.url, + coverArtId: item.coverArtId || item.id, + credential: server.credential, + size: imageSize || 300, + }); + + const imagePlaceholderUrl = null; + + const imageBackdropUrl = imageUrl?.replace(/size=\d+/, 'size=1000') || null; + + return { + albumArtists: [{ id: item.albumArtistId, imageUrl: null, name: item.albumArtist }], + artists: [{ id: item.artistId, imageUrl: null, name: item.artist }], + backdropImageUrl: imageBackdropUrl, + createdAt: item.createdAt.split('T')[0], + duration: item.duration * 1000 || null, + genres: item.genres, + id: item.id, + imagePlaceholderUrl, + imageUrl, + isCompilation: item.compilation, + itemType: LibraryItem.ALBUM, + lastPlayedAt: item.playDate.includes('0001-') ? null : item.playDate, + name: item.name, + playCount: item.playCount, + releaseDate: new Date(item.minYear, 0, 1).toISOString(), + releaseYear: item.minYear, + serverId: server.id, + serverType: ServerType.NAVIDROME, + size: item.size, + songCount: item.songCount, + songs: item.songs ? item.songs.map((song) => normalizeSong(song, server, '')) : undefined, + uniqueId: nanoid(), + updatedAt: item.updatedAt, + userFavorite: item.starred, + userRating: item.rating || null, + }; +}; + +const normalizeAlbumArtist = ( + item: z.infer, + server: ServerListItem, +): AlbumArtist => { + const imageUrl = + item.largeImageUrl === '/app/artist-placeholder.webp' ? null : item.largeImageUrl; + + return { + albumCount: item.albumCount, + backgroundImageUrl: null, + biography: item.biography || null, + duration: null, + genres: item.genres, + id: item.id, + imageUrl: imageUrl || null, + itemType: LibraryItem.ALBUM_ARTIST, + lastPlayedAt: item.playDate.includes('0001-') ? null : item.playDate, + name: item.name, + playCount: item.playCount, + serverId: server?.id || '', + serverType: ServerType.NAVIDROME, + similarArtists: null, + // similarArtists: + // item.similarArtists?.map((artist) => ({ + // id: artist.id, + // imageUrl: artist?.artistImageUrl || null, + // name: artist.name, + // })) || null, + songCount: item.songCount, + userFavorite: item.starred, + userRating: item.rating, + }; +}; + +const normalizePlaylist = ( + item: z.infer, + server: ServerListItem, + imageSize?: number, +): Playlist => { + const imageUrl = getCoverArtUrl({ + baseUrl: server.url, + coverArtId: item.id, + credential: server.credential, + size: imageSize || 300, + }); + + const imagePlaceholderUrl = null; + + return { + description: item.comment, + duration: item.duration * 1000, + genres: [], + id: item.id, + imagePlaceholderUrl, + imageUrl, + itemType: LibraryItem.PLAYLIST, + name: item.name, + owner: item.ownerName, + ownerId: item.ownerId, + public: item.public, + rules: item?.rules || null, + serverId: server.id, + serverType: ServerType.NAVIDROME, + size: item.size, + songCount: item.songCount, + sync: item.sync, + }; +}; + +const normalizeUser = (item: z.infer): User => { + return { + createdAt: item.createdAt, + email: item.email || null, + id: item.id, + isAdmin: item.isAdmin, + lastLoginAt: item.lastLoginAt, + name: item.userName, + updatedAt: item.updatedAt, + }; +}; + +export const ndNormalize = { + album: normalizeAlbum, + albumArtist: normalizeAlbumArtist, + playlist: normalizePlaylist, + song: normalizeSong, + user: normalizeUser, +};