import type { ServerInferResponses } from '@ts-rest/core'; import dayjs from 'dayjs'; import { set } from 'idb-keyval'; import filter from 'lodash/filter'; import orderBy from 'lodash/orderBy'; import md5 from 'md5'; import { z } from 'zod'; import { contract, ssApiClient } from '/@/renderer/api/subsonic/subsonic-api'; import { randomString } from '/@/renderer/utils'; import { getServerUrl } from '/@/renderer/utils/normalize-server-url'; import { ssNormalize } from '/@/shared/api/subsonic/subsonic-normalize'; import { AlbumListSortType, ssType, SubsonicExtensions, } from '/@/shared/api/subsonic/subsonic-types'; import { hasFeature, sortAlbumArtistList, sortAlbumList, sortSongList } from '/@/shared/api/utils'; import { AlbumListSort, GenreListSort, ImageArgs, ImageRequest, InternalControllerEndpoint, LibraryItem, PlaylistListSort, ReplaceApiClientProps, ServerType, Song, SongListSort, SortOrder, } from '/@/shared/types/domain-types'; import { ServerFeature, ServerFeatures } from '/@/shared/types/features-types'; const getSubsonicImageRequest = ({ apiClientProps: { server }, baseUrl, query, }: ReplaceApiClientProps): ImageRequest | null => { const { id, size } = query; const imageSize = size; const url = baseUrl || getServerUrl(server); if (!url || !server?.credential) { return null; } // Check for default placeholder image ID if (id.match('2a96cbd8b46e442fc41c2b86b821562f')) { return null; } return { cacheKey: ['subsonic', server.id, baseUrl || '', id, imageSize || ''].join(':'), url: `${url}/rest/getCoverArt.view` + `?id=${id}` + `&${server.credential}` + '&v=1.13.0' + '&c=Feishin' + (imageSize ? `&size=${imageSize}` : ''), }; }; const ALBUM_LIST_SORT_MAPPING: Record = { [AlbumListSort.ALBUM_ARTIST]: AlbumListSortType.ALPHABETICAL_BY_ARTIST, [AlbumListSort.ARTIST]: undefined, [AlbumListSort.COMMUNITY_RATING]: undefined, [AlbumListSort.CRITIC_RATING]: undefined, [AlbumListSort.DURATION]: undefined, [AlbumListSort.EXPLICIT_STATUS]: undefined, [AlbumListSort.FAVORITED]: AlbumListSortType.STARRED, [AlbumListSort.ID]: undefined, [AlbumListSort.NAME]: AlbumListSortType.ALPHABETICAL_BY_NAME, [AlbumListSort.PLAY_COUNT]: AlbumListSortType.FREQUENT, [AlbumListSort.RANDOM]: AlbumListSortType.RANDOM, [AlbumListSort.RATING]: undefined, [AlbumListSort.RECENTLY_ADDED]: AlbumListSortType.NEWEST, [AlbumListSort.RECENTLY_PLAYED]: AlbumListSortType.RECENT, [AlbumListSort.RELEASE_DATE]: AlbumListSortType.BY_YEAR, [AlbumListSort.SONG_COUNT]: undefined, [AlbumListSort.SORT_NAME]: AlbumListSortType.ALPHABETICAL_BY_NAME, [AlbumListSort.YEAR]: AlbumListSortType.BY_YEAR, }; const MAX_SUBSONIC_ITEMS = 500; const SUBSONIC_FAST_BATCH_SIZE = MAX_SUBSONIC_ITEMS * 10; function sortAndPaginate( items: T[], options: { limit?: number; sortBy?: any; sortFn?: (items: T[], sortBy: any, sortOrder: SortOrder) => T[]; sortOrder?: SortOrder; startIndex?: number; }, ): { items: T[]; startIndex: number; totalRecordCount: number; } { let sortedItems = items; if (options.sortFn && options.sortBy) { const sortOrder = options.sortOrder || SortOrder.ASC; sortedItems = options.sortFn(items, options.sortBy, sortOrder); } const totalCount = sortedItems.length; const startIndex = options.startIndex || 0; const limit = options.limit || totalCount; const paginatedItems = sortedItems.slice(startIndex, startIndex + limit); return { items: paginatedItems, startIndex: startIndex, totalRecordCount: totalCount, }; } export const SubsonicController: InternalControllerEndpoint = { addToPlaylist: async ({ apiClientProps, body, query }) => { const res = await ssApiClient(apiClientProps).updatePlaylist({ query: { playlistId: query.id, songIdToAdd: body.songId, }, }); if (res.status !== 200) { throw new Error('Failed to add to playlist'); } return null; }, authenticate: async (url, body) => { let credential: string; let credentialParams: { p?: string; s?: string; t?: string; u: string; }; const cleanServerUrl = `${url.replace(/\/$/, '')}/rest`; if (body.legacy) { credential = `u=${encodeURIComponent(body.username)}&p=${encodeURIComponent(body.password)}`; credentialParams = { p: body.password, u: body.username, }; } else { const salt = randomString(12); const hash = md5(body.password + salt); credential = `u=${encodeURIComponent(body.username)}&s=${encodeURIComponent(salt)}&t=${encodeURIComponent(hash)}`; credentialParams = { s: salt, t: hash, u: body.username, }; } const resp = await ssApiClient({ server: null, url: cleanServerUrl }).authenticate({ query: { c: 'Feishin', f: 'json', username: body.username, v: '1.13.0', ...credentialParams, }, }); if (resp.status !== 200) { throw new Error('Failed to log in'); } return { credential, isAdmin: Boolean(resp.body.user.adminRole), userId: resp.body.user.username, username: body.username, }; }, createFavorite: async (args) => { const { apiClientProps, query } = args; const res = await ssApiClient(apiClientProps).createFavorite({ query: { albumId: query.type === LibraryItem.ALBUM ? query.id : undefined, artistId: query.type === LibraryItem.ALBUM_ARTIST || query.type === LibraryItem.ARTIST ? query.id : undefined, id: query.type === LibraryItem.SONG ? query.id : undefined, }, }); if (res.status !== 200) { throw new Error('Failed to create favorite'); } return null; }, createInternetRadioStation: async (args) => { const { apiClientProps, body } = args; const res = await ssApiClient(apiClientProps).createInternetRadioStation({ query: { homepageUrl: body.homepageUrl, name: body.name, streamUrl: body.streamUrl, }, }); if (res.status !== 200) { throw new Error('Failed to create internet radio station'); } return null; }, createPlaylist: async ({ apiClientProps, body }) => { const res = await ssApiClient(apiClientProps).createPlaylist({ query: { name: body.name, }, }); if (res.status !== 200) { throw new Error('Failed to create playlist'); } return { id: res.body.playlist.id.toString(), name: res.body.playlist.name, }; }, deleteFavorite: async (args) => { const { apiClientProps, query } = args; const res = await ssApiClient(apiClientProps).removeFavorite({ query: { albumId: query.type === LibraryItem.ALBUM ? query.id : undefined, artistId: query.type === LibraryItem.ALBUM_ARTIST || query.type === LibraryItem.ARTIST ? query.id : undefined, id: query.type === LibraryItem.SONG ? query.id : undefined, }, }); if (res.status !== 200) { throw new Error('Failed to delete favorite'); } return null; }, deleteInternetRadioStation: async (args) => { const { apiClientProps, query } = args; const res = await ssApiClient(apiClientProps).deleteInternetRadioStation({ query: { id: query.id, }, }); if (res.status !== 200) { throw new Error('Failed to delete internet radio station'); } return null; }, deletePlaylist: async (args) => { const { apiClientProps, query } = args; const res = await ssApiClient(apiClientProps).deletePlaylist({ query: { id: query.id, }, }); if (res.status !== 200) { throw new Error('Failed to delete playlist'); } return null; }, getAlbumArtistDetail: async (args) => { const { apiClientProps, query } = args; const res = await ssApiClient(apiClientProps).getArtist({ query: { id: query.id, }, }); if (res.status !== 200) { throw new Error('Failed to get album artist detail'); } const artist = res.body.artist; return { ...ssNormalize.albumArtist(artist, apiClientProps.server), albums: artist.album?.map((album) => ssNormalize.album( album, apiClientProps.server, args.context?.pathReplace, args.context?.pathReplaceWith, ), ), similarArtists: null, }; }, getAlbumArtistInfo: async (args) => { const { apiClientProps, query } = args; const artistInfoRes = await ssApiClient(apiClientProps).getArtistInfo({ query: { id: query.id, ...(query.limit != null && { count: query.limit }), }, }); if (artistInfoRes.status !== 200) { return null; } const artistInfo = artistInfoRes.body.artistInfo; return { biography: artistInfo?.biography || null, similarArtists: artistInfo?.similarArtist?.map((artist) => ({ id: artist.id, imageId: null, imageUrl: null, name: artist.name, userFavorite: Boolean(artist.starred) || false, userRating: artist.userRating ?? null, })) ?? null, }; }, getAlbumArtistList: async (args) => { const { apiClientProps, query } = args; const res = await ssApiClient(apiClientProps).getArtists({ query: { musicFolderId: getLibraryId(query.musicFolderId), }, }); if (res.status !== 200) { throw new Error('Failed to get album artist list'); } const artists = (res.body.artists?.index || []).flatMap((index) => index.artist); let results = artists.map((artist) => ssNormalize.albumArtist(artist, apiClientProps.server), ); if (query.searchTerm) { const searchResults = filter(results, (artist) => { return artist.name.toLowerCase().includes(query.searchTerm!.toLowerCase()); }); results = searchResults; } if (query.favorite) { results = results.filter((artist) => artist.userFavorite); } return sortAndPaginate(results, { limit: query.limit, sortBy: query.sortBy, sortFn: query.sortBy ? sortAlbumArtistList : undefined, sortOrder: query.sortOrder, startIndex: query.startIndex, }); }, getAlbumArtistListCount: (args) => SubsonicController.getAlbumArtistList({ ...args, context: args.context, query: { ...args.query, startIndex: 0 }, }).then((res) => res!.totalRecordCount!), getAlbumDetail: async (args) => { const { apiClientProps, query } = args; const res = await ssApiClient(apiClientProps).getAlbum({ query: { id: query.id, }, }); if (res.status !== 200) { throw new Error('Failed to get album detail'); } return ssNormalize.album( res.body.album, apiClientProps.server, args.context?.pathReplace, args.context?.pathReplaceWith, ); }, getAlbumList: async (args) => { const { apiClientProps, query } = args; if (query.searchTerm) { const res = await ssApiClient(apiClientProps).search3({ query: { albumCount: query.limit, albumOffset: query.startIndex, artistCount: 0, artistOffset: 0, musicFolderId: getLibraryId(query.musicFolderId), query: query.searchTerm || '', songCount: 0, songOffset: 0, }, }); if (res.status !== 200) { throw new Error('Failed to get album list'); } const results = res.body.searchResult3?.album?.map((album) => ssNormalize.album( album, apiClientProps.server, args.context?.pathReplace, args.context?.pathReplaceWith, ), ) || []; return { items: results, startIndex: query.startIndex, totalRecordCount: null, }; } let type = ALBUM_LIST_SORT_MAPPING[query.sortBy] ?? AlbumListSortType.ALPHABETICAL_BY_NAME; if (query.artistIds) { const promises: Promise>[] = []; for (const artistId of query.artistIds) { promises.push( ssApiClient(apiClientProps).getArtist({ query: { id: artistId, }, }), ); } const artistResult = await Promise.all(promises); const albums = artistResult.flatMap((artist) => { if (artist.status !== 200) { return []; } return artist.body.artist.album ?? []; }); const items = albums.map((album) => ssNormalize.album( album, apiClientProps.server, args.context?.pathReplace, args.context?.pathReplaceWith, ), ); return { items: sortAlbumList(items, query.sortBy, query.sortOrder), startIndex: 0, totalRecordCount: albums.length, }; } if (query.favorite) { const res = await ssApiClient(apiClientProps).getStarred({ query: { musicFolderId: getLibraryId(query.musicFolderId), }, }); if (res.status !== 200) { throw new Error('Failed to get album list'); } const allResults = res.body.starred?.album?.map((album) => ssNormalize.album( album, apiClientProps.server, args.context?.pathReplace, args.context?.pathReplaceWith, ), ) || []; return sortAndPaginate(allResults, { limit: query.limit, sortBy: query.sortBy, sortFn: sortAlbumList, sortOrder: query.sortOrder, startIndex: query.startIndex, }); } if (query.genreIds?.length) { type = AlbumListSortType.BY_GENRE; } if (query.minYear || query.maxYear) { type = AlbumListSortType.BY_YEAR; } let fromYear: number | undefined; let toYear: number | undefined; if (query.minYear) { fromYear = query.minYear; toYear = dayjs().year(); } if (query.maxYear) { toYear = query.maxYear; if (!query.minYear) { fromYear = 0; } } if (type === AlbumListSortType.BY_YEAR && !fromYear && !toYear) { if (query.sortOrder === SortOrder.ASC) { fromYear = 0; toYear = dayjs().year(); } else { fromYear = dayjs().year(); toYear = 0; } } const res = await ssApiClient(apiClientProps).getAlbumList2({ query: { fromYear, genre: query.genreIds?.length ? query.genreIds[0] : undefined, musicFolderId: getLibraryId(query.musicFolderId), offset: query.startIndex, size: query.limit, toYear, type, }, }); if (res.status !== 200) { throw new Error('Failed to get album list'); } return { items: res.body.albumList2.album?.map((album) => ssNormalize.album( album, apiClientProps.server, args.context?.pathReplace, args.context?.pathReplaceWith, ), ) || [], startIndex: query.startIndex, totalRecordCount: null, }; }, getAlbumListCount: async (args) => { const { apiClientProps, query } = args; if (query.searchTerm) { let fetchNextPage = true; let startIndex = 0; let totalRecordCount = 0; while (fetchNextPage) { const res = await ssApiClient(apiClientProps).search3({ query: { albumCount: MAX_SUBSONIC_ITEMS, albumOffset: startIndex, artistCount: 0, artistOffset: 0, musicFolderId: getLibraryId(query.musicFolderId), query: query.searchTerm || '', songCount: 0, songOffset: 0, }, }); if (res.status !== 200) { throw new Error('Failed to get album list count'); } const albumCount = (res.body.searchResult3?.album || [])?.length; totalRecordCount += albumCount; startIndex += albumCount; fetchNextPage = albumCount === MAX_SUBSONIC_ITEMS; } return totalRecordCount; } if (query.artistIds) { const promises: Promise>[] = []; for (const artistId of query.artistIds) { promises.push( ssApiClient(apiClientProps).getArtist({ query: { id: artistId, }, }), ); } const artistResult = await Promise.all(promises); const albums = artistResult.reduce((total: number, artist) => { if (artist.status !== 200) { return 0; } const length = artist.body.artist.album?.length ?? 0; return length + total; }, 0); return albums; } if (query.favorite) { const res = await ssApiClient(apiClientProps).getStarred({ query: { musicFolderId: getLibraryId(query.musicFolderId), }, }); if (res.status !== 200) { throw new Error('Failed to get album list'); } return (res.body.starred?.album || []).length || 0; } let type = AlbumListSortType.ALPHABETICAL_BY_NAME; let fetchNextPage = true; let startIndex = 0; let totalRecordCount = 0; if (query.genreIds?.length) { type = AlbumListSortType.BY_GENRE; } if (query.minYear || query.maxYear) { type = AlbumListSortType.BY_YEAR; } let fromYear: number | undefined; let toYear: number | undefined; if (query.minYear) { fromYear = query.minYear; toYear = dayjs().year(); } if (query.maxYear) { toYear = query.maxYear; if (!query.minYear) { fromYear = 0; } } while (fetchNextPage) { const res = await ssApiClient(apiClientProps).getAlbumList2({ query: { fromYear, genre: query.genreIds?.length ? query.genreIds[0] : undefined, musicFolderId: getLibraryId(query.musicFolderId), offset: startIndex, size: MAX_SUBSONIC_ITEMS, toYear, type, }, }); const headers = res.headers; // Navidrome returns the total count in the header if (headers.get('x-total-count')) { fetchNextPage = false; totalRecordCount = Number(headers.get('x-total-count')); break; } if (res.status !== 200) { throw new Error('Failed to get album list count'); } const albumCount = res.body.albumList2.album.length; totalRecordCount += albumCount; startIndex += albumCount; fetchNextPage = albumCount === MAX_SUBSONIC_ITEMS; } return totalRecordCount; }, getAlbumRadio: async (args) => { const { apiClientProps, context, query } = args; const res = await ssApiClient(apiClientProps).getSimilarSongs({ query: { count: query.count, id: query.albumId, }, }); if (res.status !== 200) { throw new Error('Failed to get album radio songs'); } if (!res.body.similarSongs?.song) { return []; } return res.body.similarSongs.song.map((song) => ssNormalize.song( song, apiClientProps.server, context?.pathReplace, context?.pathReplaceWith, ), ); }, getArtistList: async (args) => { const { apiClientProps, query } = args; const res = await ssApiClient(apiClientProps).getArtists({ query: { musicFolderId: getLibraryId(query.musicFolderId), }, }); if (res.status !== 200) { throw new Error('Failed to get artist list'); } let artists = (res.body.artists?.index || []).flatMap((index) => index.artist); if (query.role) { artists = artists.filter( (artist) => !artist.roles || artist.roles.includes(query.role!), ); } let results = artists.map((artist) => ssNormalize.albumArtist(artist, apiClientProps.server), ); if (query.searchTerm) { const searchResults = filter(results, (artist) => { return artist.name.toLowerCase().includes(query.searchTerm!.toLowerCase()); }); results = searchResults; } return sortAndPaginate(results, { limit: query.limit, sortBy: query.sortBy, sortFn: query.sortBy ? sortAlbumArtistList : undefined, sortOrder: query.sortOrder, startIndex: query.startIndex, }); }, getArtistListCount: async (args) => SubsonicController.getArtistList({ ...args, context: args.context, query: { ...args.query, startIndex: 0 }, }).then((res) => res!.totalRecordCount!), getArtistRadio: async (args) => { const { apiClientProps, context, query } = args; const res = await ssApiClient(apiClientProps).getSimilarSongs2({ query: { count: query.count, id: query.artistId, }, }); if (res.status !== 200) { throw new Error('Failed to get artist radio songs'); } if (!res.body.similarSongs2?.song) { return []; } return res.body.similarSongs2.song.map((song) => ssNormalize.song( song, apiClientProps.server, context?.pathReplace, context?.pathReplaceWith, ), ); }, getDownloadUrl: (args) => { const { apiClientProps, query } = args; return ( `${apiClientProps.server?.url}/rest/download.view` + `?id=${query.id}` + `&${apiClientProps.server?.credential}` + '&v=1.13.0' + '&c=Feishin' ); }, getFolder: async ({ apiClientProps, context, query }) => { const sortOrder = (query.sortOrder?.toLowerCase() ?? 'asc') as 'asc' | 'desc'; const isRootFolderId = query.id === '0'; if (isRootFolderId) { const res = await ssApiClient(apiClientProps).getIndexes({ query: { musicFolderId: getLibraryId(query.musicFolderId), }, }); if (res.status !== 200) { throw new Error(`Failed to get folder list: ${JSON.stringify(res.body)}`); } let items = res.body.indexes?.index?.flatMap((idx) => idx.artist.map((artist) => ({ artist: artist.name, coverArt: artist.coverArt, id: artist.id.toString(), isDir: true, title: artist.name, })), ) || []; if (query.searchTerm) { items = filter(items, (item) => { return item.title.toLowerCase().includes(query.searchTerm!.toLowerCase()); }); } let folders = items.map((item) => ssNormalize.folder( item, apiClientProps.server, context?.pathReplace, context?.pathReplaceWith, ), ); folders = orderBy(folders, [(v) => v.name.toLowerCase()], [sortOrder]); return { _itemType: LibraryItem.FOLDER, _serverId: apiClientProps.server?.id || 'unknown', _serverType: ServerType.SUBSONIC, children: { folders, songs: [], }, id: query.id, name: '~', parentId: undefined, }; } const directoryRes = await ssApiClient(apiClientProps).getMusicDirectory({ query: { id: query.id, }, }); if (directoryRes.status !== 200) { throw new Error('Failed to get folder'); } const folder = ssNormalize.folder( directoryRes.body.directory, apiClientProps.server, context?.pathReplace, context?.pathReplaceWith, ); let filteredFolders = folder.children?.folders || []; let filteredSongs = folder.children?.songs || []; if (query.searchTerm) { const searchTermLower = query.searchTerm.toLowerCase(); filteredFolders = filter(filteredFolders, (f) => f.name.toLowerCase().includes(searchTermLower), ); filteredSongs = filter(filteredSongs, (s) => { const name = s.name?.toLowerCase() || ''; const album = s.album?.toLowerCase() || ''; const artist = s.artistName?.toLowerCase() || ''; return ( name.includes(searchTermLower) || album.includes(searchTermLower) || artist.includes(searchTermLower) ); }); } filteredFolders = orderBy(filteredFolders, [(v) => v.name.toLowerCase()], [sortOrder]); if (filteredSongs.length > 0) { filteredSongs = sortSongList( filteredSongs, query.sortBy || SongListSort.NAME, query.sortOrder || SortOrder.ASC, ); } return { ...folder, children: { folders: filteredFolders, songs: filteredSongs, }, }; }, getGenreList: async ({ apiClientProps, query }) => { const sortOrder = (query.sortOrder?.toLowerCase() ?? 'asc') as 'asc' | 'desc'; const res = await ssApiClient(apiClientProps).getGenres({}); if (res.status !== 200) { throw new Error('Failed to get genre list'); } let results = res.body.genres?.genre || []; if (query.searchTerm) { const searchResults = filter(results, (genre) => genre.value.toLowerCase().includes(query.searchTerm!.toLowerCase()), ); results = searchResults; } switch (query.sortBy) { case GenreListSort.NAME: results = orderBy(results, [(v) => v.value.toLowerCase()], [sortOrder]); break; default: break; } const genres = results.map((genre) => ssNormalize.genre(genre, apiClientProps.server)); return sortAndPaginate(genres, { limit: query.limit, startIndex: query.startIndex, }); }, getImageRequest: getSubsonicImageRequest, getImageUrl: (args) => getSubsonicImageRequest(args)?.url || null, getInternetRadioStations: async (args) => { const { apiClientProps } = args; const res = await ssApiClient(apiClientProps).getInternetRadioStations(); if (res.status !== 200) { throw new Error('Failed to get internet radio stations'); } const stations = res.body.internetRadioStations?.internetRadioStation || []; return stations.map((station) => ssNormalize.internetRadioStation(station)); }, getMusicFolderList: async (args) => { const { apiClientProps } = args; const res = await ssApiClient(apiClientProps).getMusicFolderList({}); if (res.status !== 200) { throw new Error('Failed to get music folder list'); } return { items: res.body.musicFolders.musicFolder.map((folder) => ({ id: folder.id.toString(), name: folder.name, })), startIndex: 0, totalRecordCount: res.body.musicFolders.musicFolder.length, }; }, getPlaylistDetail: async (args) => { const { apiClientProps, query } = args; const res = await ssApiClient(apiClientProps).getPlaylist({ query: { id: query.id, }, }); if (res.status !== 200) { throw new Error('Failed to get playlist detail'); } return ssNormalize.playlist(res.body.playlist, apiClientProps.server); }, getPlaylistList: async ({ apiClientProps, query }) => { const sortOrder = query.sortOrder.toLowerCase() as 'asc' | 'desc'; const res = await ssApiClient(apiClientProps).getPlaylists({}); if (res.status !== 200) { throw new Error('Failed to get playlist list'); } let results = res.body.playlists?.playlist || []; if (query.searchTerm) { const searchResults = filter(results, (playlist) => { return playlist.name.toLowerCase().includes(query.searchTerm!.toLowerCase()); }); results = searchResults; } switch (query.sortBy) { case PlaylistListSort.DURATION: results = orderBy(results, ['duration'], [sortOrder]); break; case PlaylistListSort.NAME: results = orderBy(results, [(v) => v.name?.toLowerCase()], [sortOrder]); break; case PlaylistListSort.OWNER: results = orderBy(results, [(v) => v.owner?.toLowerCase()], [sortOrder]); break; case PlaylistListSort.PUBLIC: results = orderBy(results, ['public'], [sortOrder]); break; case PlaylistListSort.SONG_COUNT: results = orderBy(results, ['songCount'], [sortOrder]); break; case PlaylistListSort.UPDATED_AT: results = orderBy(results, ['changed'], [sortOrder]); break; default: break; } const playlists = results.map((playlist) => ssNormalize.playlist(playlist, apiClientProps.server), ); return sortAndPaginate(playlists, { limit: query.limit, startIndex: query.startIndex, }); }, getPlaylistListCount: async ({ apiClientProps, query }) => { const res = await ssApiClient(apiClientProps).getPlaylists({}); if (res.status !== 200) { throw new Error('Failed to get playlist list'); } let results = res.body.playlists?.playlist || []; if (query.searchTerm) { const searchResults = filter(results, (playlist) => { return playlist.name.toLowerCase().includes(query.searchTerm!.toLowerCase()); }); results = searchResults; } return results.length; }, getPlaylistSongList: async ({ apiClientProps, context, query }) => { const res = await ssApiClient(apiClientProps).getPlaylist({ query: { id: query.id, }, }); if (res.status !== 200) { throw new Error('Failed to get playlist song list'); } const items = res.body.playlist.entry?.map((song, index) => ssNormalize.song( song, apiClientProps.server, context?.pathReplace, context?.pathReplaceWith, index, ), ) || []; return { items, startIndex: 0, totalRecordCount: items.length, }; }, getPlayQueue: async ({ apiClientProps, context }) => { if (hasFeature(apiClientProps.server, ServerFeature.SERVER_PLAY_QUEUE)) { const res = await ssApiClient(apiClientProps).getPlayQueueByIndex(); if (res.status !== 200) { throw new Error('Failed to get random songs'); } const { changed, changedBy, currentIndex, entry, position, username } = res.body.playQueueByIndex; return { changed, changedBy, currentIndex: currentIndex ?? 0, entry: entry?.map((song) => ssNormalize.song( song, apiClientProps.server, context?.pathReplace, context?.pathReplaceWith, ), ) || [], positionMs: position ?? 0, username, }; } else { const res = await ssApiClient(apiClientProps).getPlayQueue(); if (res.status !== 200) { throw new Error('Failed to get random songs'); } const { changed, changedBy, current, entry, position, username } = res.body.playQueue; return { changed, changedBy, currentIndex: current ? entry.findIndex((item) => item.id === current) : 0, entry: entry?.map((song) => ssNormalize.song( song, apiClientProps.server, context?.pathReplace, context?.pathReplaceWith, ), ) || [], positionMs: position ?? 0, username, }; } }, getRandomSongList: async (args) => { const { apiClientProps, context, query } = args; const res = await ssApiClient(apiClientProps).getRandomSongList({ query: { fromYear: query.minYear, genre: query.genre, musicFolderId: getLibraryId(query.musicFolderId), size: query.limit, toYear: query.maxYear, }, }); if (res.status !== 200) { throw new Error('Failed to get random songs'); } const results = res.body.randomSongs?.song || []; const normalizedResults = results.map((song) => ssNormalize.song( song, apiClientProps.server, context?.pathReplace, context?.pathReplaceWith, ), ); return { items: normalizedResults, startIndex: 0, totalRecordCount: normalizedResults.length, }; }, getRoles: async (args) => { const { apiClientProps } = args; const res = await ssApiClient(apiClientProps).getArtists({}); if (res.status !== 200) { throw new Error('Failed to get artist list'); } const roles = new Set(); for (const index of res.body.artists?.index || []) { for (const artist of index.artist) { for (const role of artist.roles || []) { roles.add(role); } } } const final: Array = Array.from(roles).sort(); // Always add 'all artist' filter, even if there are no other roles // This is relevant when switching from a server which has roles to one with // no roles. final.splice(0, 0, { label: 'all artists', value: '' }); return final; }, getServerInfo: async (args) => { const { apiClientProps } = args; const ping = await ssApiClient(apiClientProps).ping(); if (ping.status !== 200) { throw new Error('Failed to ping server'); } const features: ServerFeatures = {}; if (!ping.body.openSubsonic || !ping.body.serverVersion) { return { features, version: ping.body.version }; } const res = await ssApiClient(apiClientProps).getServerInfo(); if (res.status !== 200) { throw new Error('Failed to get server extensions'); } const subsonicFeatures: Record = {}; if (Array.isArray(res.body.openSubsonicExtensions)) { for (const extension of res.body.openSubsonicExtensions) { subsonicFeatures[extension.name] = extension.versions; } } if (subsonicFeatures[SubsonicExtensions.SONG_LYRICS]) { features.lyricsMultipleStructured = [1]; } if (subsonicFeatures[SubsonicExtensions.FORM_POST]) { features.osFormPost = [1]; } if (subsonicFeatures[SubsonicExtensions.INDEX_BASED_QUEUE]) { features.serverPlayQueue = [1]; } return { features, id: apiClientProps.server?.id, version: ping.body.serverVersion }; }, getSimilarSongs: async (args) => { const { apiClientProps, context, query } = args; const res = await ssApiClient(apiClientProps).getSimilarSongs({ query: { count: query.count, id: query.songId, }, }); if (res.status !== 200) { throw new Error('Failed to get similar songs'); } if (!res.body.similarSongs?.song) { return []; } return res.body.similarSongs.song.reduce((acc, song) => { if (song.id !== query.songId) { acc.push( ssNormalize.song( song, apiClientProps.server, context?.pathReplace, context?.pathReplaceWith, ), ); } return acc; }, []); }, getSongDetail: async (args) => { const { apiClientProps, context, query } = args; const res = await ssApiClient(apiClientProps).getSong({ query: { id: query.id, }, }); if (res.status !== 200) { throw new Error('Failed to get song detail'); } return ssNormalize.song( res.body.song, apiClientProps.server, context?.pathReplace, context?.pathReplaceWith, ); }, getSongList: async ({ apiClientProps, context, query }) => { const fromAlbumPromises: Promise>[] = []; const artistDetailPromises: Promise>[] = []; if (query.searchTerm) { const res = await ssApiClient(apiClientProps).search3({ query: { albumCount: 0, albumOffset: 0, artistCount: 0, artistOffset: 0, musicFolderId: getLibraryId(query.musicFolderId), query: query.searchTerm || '', songCount: query.limit, songOffset: query.startIndex, }, }); if (res.status !== 200) { throw new Error('Failed to get song list'); } return { items: res.body.searchResult3?.song?.map((song) => ssNormalize.song( song, apiClientProps.server, context?.pathReplace, context?.pathReplaceWith, ), ) || [], startIndex: query.startIndex, totalRecordCount: null, }; } if (query.genreIds) { const res = await ssApiClient(apiClientProps).getSongsByGenre({ query: { count: query.limit, genre: query.genreIds[0], musicFolderId: getLibraryId(query.musicFolderId), offset: query.startIndex, }, }); if (res.status !== 200) { throw new Error('Failed to get song list'); } const results = res.body.songsByGenre?.song || []; return { items: results.map((song) => ssNormalize.song( song, apiClientProps.server, context?.pathReplace, context?.pathReplaceWith, ), ) || [], startIndex: 0, totalRecordCount: null, }; } if (query.favorite) { const res = await ssApiClient(apiClientProps).getStarred({ query: { musicFolderId: getLibraryId(query.musicFolderId), }, }); if (res.status !== 200) { throw new Error('Failed to get song list'); } let allResults = (res.body.starred?.song || []).map((song) => ssNormalize.song( song, apiClientProps.server, context?.pathReplace, context?.pathReplaceWith, ), ) || []; const filterArtistIds = query.albumArtistIds || query.artistIds; if (filterArtistIds?.length) { const idSet = new Set(filterArtistIds); allResults = allResults.filter((song) => song.albumArtists?.some((aa) => idSet.has(aa.id)), ); } return sortAndPaginate(allResults, { limit: query.limit, sortBy: query.sortBy, sortFn: sortSongList, sortOrder: query.sortOrder, startIndex: query.startIndex, }); } const artistIds = query.albumArtistIds || query.artistIds; if (query.albumIds || artistIds) { if (query.albumIds) { for (const albumId of query.albumIds) { fromAlbumPromises.push( ssApiClient(apiClientProps).getAlbum({ query: { id: albumId, }, }), ); } } if (artistIds) { for (const artistId of artistIds) { artistDetailPromises.push( ssApiClient(apiClientProps).getArtist({ query: { id: artistId, }, }), ); } const artistResult = await Promise.all(artistDetailPromises); const albums = artistResult.flatMap((artist) => { if (artist.status !== 200) { return []; } return artist.body.artist.album ?? []; }); const albumIds = albums.map((album) => album.id); for (const albumId of albumIds) { fromAlbumPromises.push( ssApiClient(apiClientProps).getAlbum({ query: { id: albumId.toString(), }, }), ); } } let results: z.infer[] = []; if (fromAlbumPromises) { const albumsResult = await Promise.all(fromAlbumPromises); results = albumsResult.flatMap((album) => { if (album.status !== 200) { return []; } return album.body.album.song; }); } return { items: results.map((song) => ssNormalize.song( song, apiClientProps.server, context?.pathReplace, context?.pathReplaceWith, ), ) || [], startIndex: 0, totalRecordCount: results.length, }; } const res = await ssApiClient(apiClientProps).search3({ query: { albumCount: 0, albumOffset: 0, artistCount: 0, artistOffset: 0, musicFolderId: getLibraryId(query.musicFolderId), query: query.searchTerm || '', songCount: query.limit, songOffset: query.startIndex, }, }); if (res.status !== 200) { throw new Error('Failed to get song list'); } return { items: res.body.searchResult3?.song?.map((song) => ssNormalize.song( song, apiClientProps.server, context?.pathReplace, context?.pathReplaceWith, ), ) || [], startIndex: 0, totalRecordCount: null, }; }, getSongListCount: async (args) => { const { apiClientProps, query } = args; let fetchNextPage = true; let startIndex = 0; let fetchNextSection = true; let sectionIndex = 0; if (query.searchTerm) { let fetchNextPage = true; let startIndex = 0; let totalRecordCount = 0; while (fetchNextPage) { const res = await ssApiClient(apiClientProps).search3({ query: { albumCount: 0, albumOffset: 0, artistCount: 0, artistOffset: 0, musicFolderId: getLibraryId(query.musicFolderId), query: query.searchTerm || '', songCount: MAX_SUBSONIC_ITEMS, songOffset: startIndex, }, }); if (res.status !== 200) { throw new Error('Failed to get song list count'); } const songCount = (res.body.searchResult3?.song || []).length || 0; totalRecordCount += songCount; startIndex += songCount; fetchNextPage = songCount === MAX_SUBSONIC_ITEMS; } return totalRecordCount; } if (query.genreIds) { let totalRecordCount = 0; // Rather than just do `getSongsByGenre` by groups of 500, instead // jump the offset 10x, and then backtrack on the last chunk. This improves // performance for extremely large libraries while (fetchNextSection) { const res = await ssApiClient(apiClientProps).getSongsByGenre({ query: { count: 1, genre: query.genreIds[0], musicFolderId: getLibraryId(query.musicFolderId), offset: sectionIndex, }, }); if (res.status !== 200) { throw new Error('Failed to get song list count'); } const numberOfResults = (res.body.songsByGenre?.song || []).length || 0; if (numberOfResults !== 1) { fetchNextSection = false; startIndex = sectionIndex === 0 ? 0 : sectionIndex - SUBSONIC_FAST_BATCH_SIZE; break; } else { sectionIndex += SUBSONIC_FAST_BATCH_SIZE; } } while (fetchNextPage) { const res = await ssApiClient(apiClientProps).getSongsByGenre({ query: { count: MAX_SUBSONIC_ITEMS, genre: query.genreIds[0], musicFolderId: getLibraryId(query.musicFolderId), offset: startIndex, }, }); if (res.status !== 200) { throw new Error('Failed to get song list count'); } const numberOfResults = (res.body.songsByGenre?.song || []).length || 0; totalRecordCount = startIndex + numberOfResults; startIndex += numberOfResults; fetchNextPage = numberOfResults === MAX_SUBSONIC_ITEMS; } return totalRecordCount; } if (query.favorite) { const res = await ssApiClient(apiClientProps).getStarred({ query: { musicFolderId: getLibraryId(query.musicFolderId), }, }); if (res.status !== 200) { throw new Error('Failed to get song list'); } return (res.body.starred?.song || []).length || 0; } const artistIds = query.albumArtistIds || query.artistIds; if (query.albumIds || artistIds) { const fromAlbumPromises: Promise>[] = []; const artistDetailPromises: Promise>[] = []; if (query.albumIds) { for (const albumId of query.albumIds) { fromAlbumPromises.push( ssApiClient(apiClientProps).getAlbum({ query: { id: albumId, }, }), ); } } if (artistIds) { for (const artistId of artistIds) { artistDetailPromises.push( ssApiClient(apiClientProps).getArtist({ query: { id: artistId, }, }), ); } const artistResult = await Promise.all(artistDetailPromises); const albums = artistResult.flatMap((artist) => { if (artist.status !== 200) { return []; } return artist.body.artist.album ?? []; }); const albumIds = albums.map((album) => album.id); for (const albumId of albumIds) { fromAlbumPromises.push( ssApiClient(apiClientProps).getAlbum({ query: { id: albumId.toString(), }, }), ); } } let results: z.infer[] = []; if (fromAlbumPromises.length > 0) { const albumsResult = await Promise.all(fromAlbumPromises); results = albumsResult.flatMap((album) => { if (album.status !== 200) { return []; } return album.body.album.song; }); } return results.length; } let totalRecordCount = 0; // Rather than just do `search3` by groups of 500, instead // jump the offset 10x, and then backtrack on the last chunk. This improves // performance for extremely large libraries while (fetchNextSection) { const res = await ssApiClient(apiClientProps).search3({ query: { albumCount: 0, albumOffset: 0, artistCount: 0, artistOffset: 0, musicFolderId: getLibraryId(query.musicFolderId), query: query.searchTerm || '', songCount: 1, songOffset: sectionIndex, }, }); if (res.status !== 200) { throw new Error('Failed to get song list count'); } const numberOfResults = (res.body.searchResult3?.song || []).length || 0; if (numberOfResults !== 1) { fetchNextSection = false; startIndex = sectionIndex === 0 ? 0 : sectionIndex - SUBSONIC_FAST_BATCH_SIZE; break; } else { sectionIndex += SUBSONIC_FAST_BATCH_SIZE; } } while (fetchNextPage) { const res = await ssApiClient(apiClientProps).search3({ query: { albumCount: 0, albumOffset: 0, artistCount: 0, artistOffset: 0, musicFolderId: getLibraryId(query.musicFolderId), query: query.searchTerm || '', songCount: MAX_SUBSONIC_ITEMS, songOffset: startIndex, }, }); if (res.status !== 200) { throw new Error('Failed to get song list count'); } const numberOfResults = (res.body.searchResult3?.song || []).length || 0; totalRecordCount = startIndex + numberOfResults; startIndex += numberOfResults; fetchNextPage = numberOfResults === MAX_SUBSONIC_ITEMS; } return totalRecordCount; }, getStreamUrl: ({ apiClientProps: { server }, query }) => { const { bitrate, format, id, transcode } = query; let url = `${server?.url}/rest/stream.view?id=${id}&v=1.13.0&c=Feishin&${server?.credential}`; if (transcode) { if (format) { url += `&format=${format}`; } if (bitrate !== undefined) { url += `&maxBitRate=${bitrate}`; } } return url; }, getStructuredLyrics: async (args) => { const { apiClientProps, query } = args; const res = await ssApiClient(apiClientProps).getStructuredLyrics({ query: { id: query.songId, }, }); if (res.status !== 200) { throw new Error('Failed to get structured lyrics'); } const lyrics = res.body.lyricsList?.structuredLyrics; if (!lyrics) { return []; } return lyrics.map((lyric) => { const baseLyric = { artist: lyric.displayArtist || '', lang: lyric.lang, name: lyric.displayTitle || '', remote: false, source: apiClientProps.server?.name || 'music server', }; if (lyric.synced) { return { ...baseLyric, lyrics: lyric.line.map((line) => [line.start!, line.value]), synced: true, }; } return { ...baseLyric, lyrics: lyric.line.map((line) => [line.value]).join('\n'), synced: false, }; }); }, getTopSongs: async (args) => { const { apiClientProps, context, query } = args; const type = query.type === 'personal' ? 'personal' : 'community'; if (type === 'community') { const res = await ssApiClient(apiClientProps).getTopSongsList({ query: { artist: query.artist, count: query.limit, }, }); if (res.status !== 200) { throw new Error('Failed to get top songs'); } return { items: (res.body.topSongs?.song || []).map((song) => ssNormalize.song( song, apiClientProps.server, context?.pathReplace, context?.pathReplaceWith, ), ), startIndex: 0, totalRecordCount: res.body.topSongs?.song?.length || 0, }; } const res = await SubsonicController.getSongList({ apiClientProps, query: { artistIds: [query.artistId], sortBy: SongListSort.PLAY_COUNT, sortOrder: SortOrder.DESC, startIndex: 0, }, }); const songsWithPlayCount = orderBy( res.items.filter((song) => song.playCount > 0), ['playCount', 'albumId', 'trackNumber'], ['desc', 'asc', 'asc'], ); return { items: songsWithPlayCount, startIndex: 0, totalRecordCount: res.totalRecordCount, }; }, getUserInfo: async (args) => { const { apiClientProps, query } = args; const res = await ssApiClient(apiClientProps).getUser({ query: { username: query.username, }, }); if (res.status !== 200) { throw new Error('Failed to get user info'); } return { id: res.body.user.username, isAdmin: Boolean(res.body.user.adminRole), name: res.body.user.username, }; }, removeFromPlaylist: async ({ apiClientProps, query }) => { const res = await ssApiClient(apiClientProps).updatePlaylist({ query: { playlistId: query.id, songIndexToRemove: query.songId, }, }); if (res.status !== 200) { throw new Error('Failed to add to playlist'); } return null; }, replacePlaylist: async (args) => { const { apiClientProps, body, context, query } = args; // 1. Fetch existing songs from the playlist const existingSongsRes = await ssApiClient(apiClientProps).getPlaylist({ query: { id: query.id, }, }); if (existingSongsRes.status !== 200) { throw new Error('Failed to fetch existing playlist songs'); } const existingSongs = existingSongsRes.body.playlist.entry?.map((song) => ssNormalize.song( song, apiClientProps.server, context?.pathReplace, context?.pathReplaceWith, ), ) || []; // 2. Get playlist detail to get the name const playlistDetailRes = await ssApiClient(apiClientProps).getPlaylist({ query: { id: query.id, }, }); if (playlistDetailRes.status !== 200) { throw new Error('Failed to get playlist detail'); } const playlist = ssNormalize.playlist( playlistDetailRes.body.playlist, apiClientProps.server, ); // 3. Make a backup of the playlist ids and their order, along with the id of the playlist and name const backup = { id: query.id, name: playlist.name, songIds: existingSongs.map((song) => song.id), timestamp: Date.now(), }; // Store backup in IndexedDB using idb-keyval const backupKey = `playlist-backup-${query.id}`; await set(backupKey, backup); // 4. Remove all songs from the playlist (Subsonic uses indices, not IDs) if (existingSongs.length > 0) { // Get indices of all songs (0-based) // Remove in reverse order to avoid index shifting issues const songIndices = existingSongs.map((_, index) => index).reverse(); const removeRes = await ssApiClient(apiClientProps).updatePlaylist({ query: { playlistId: query.id, songIndexToRemove: songIndices.map((index) => index.toString()), }, }); if (removeRes.status !== 200) { throw new Error('Failed to remove songs from playlist'); } } // 5. Add the new song ids to the playlist if (body.songId.length > 0) { const addRes = await ssApiClient(apiClientProps).updatePlaylist({ query: { playlistId: query.id, songIdToAdd: body.songId, }, }); if (addRes.status !== 200) { throw new Error('Failed to add songs to playlist'); } } return null; }, savePlayQueue: async ({ apiClientProps, query }) => { if (hasFeature(apiClientProps.server, ServerFeature.SERVER_PLAY_QUEUE)) { const res = await ssApiClient(apiClientProps).savePlayQueueByIndex({ query: { currentIndex: query.currentIndex !== undefined ? query.currentIndex : undefined, id: query.songs, position: query.positionMs, }, }); if (res.status !== 200) { throw new Error('Failed to save play queue'); } } else { const res = await ssApiClient(apiClientProps).savePlayQueue({ query: { current: query.currentIndex !== undefined && query.currentIndex < query.songs.length ? query.songs[query.currentIndex] : undefined, id: query.songs, position: query.positionMs, }, }); if (res.status !== 200) { throw new Error('Failed to save play queue'); } } }, scrobble: async (args) => { const { apiClientProps, query } = args; const res = await ssApiClient(apiClientProps).scrobble({ query: { id: query.id, submission: query.submission, }, }); if (res.status !== 200) { throw new Error('Failed to scrobble'); } return null; }, search: async (args) => { const { apiClientProps, context, query } = args; const res = await ssApiClient(apiClientProps).search3({ query: { albumCount: query.albumLimit, albumOffset: query.albumStartIndex, artistCount: query.albumArtistLimit, artistOffset: query.albumArtistStartIndex, musicFolderId: getLibraryId(query.musicFolderId), query: query.query, songCount: query.songLimit, songOffset: query.songStartIndex, }, }); if (res.status !== 200) { throw new Error('Failed to search'); } return { albumArtists: (res.body.searchResult3?.artist || [])?.map((artist) => ssNormalize.albumArtist(artist, apiClientProps.server), ), albums: (res.body.searchResult3?.album || []).map((album) => ssNormalize.album( album, apiClientProps.server, args.context?.pathReplace, args.context?.pathReplaceWith, ), ), songs: (res.body.searchResult3?.song || []).map((song) => ssNormalize.song( song, apiClientProps.server, context?.pathReplace, context?.pathReplaceWith, ), ), }; }, setRating: async (args) => { const { apiClientProps, query } = args; const itemIds = query.id; for (const id of itemIds) { await ssApiClient(apiClientProps).setRating({ query: { id, rating: query.rating, }, }); } return null; }, updateInternetRadioStation: async (args) => { const { apiClientProps, body, query } = args; const res = await ssApiClient(apiClientProps).updateInternetRadioStation({ query: { homepageUrl: body.homepageUrl, id: query.id, name: body.name, streamUrl: body.streamUrl, }, }); if (res.status !== 200) { throw new Error('Failed to update internet radio station'); } return null; }, updatePlaylist: async (args) => { const { apiClientProps, body, query } = args; const res = await ssApiClient(apiClientProps).updatePlaylist({ query: { comment: body.comment, name: body.name, playlistId: query.id, public: body.public, }, }); if (res.status !== 200) { throw new Error('Failed to add to playlist'); } return null; }, }; function getLibraryId(musicFolderId?: string | string[]) { return Array.isArray(musicFolderId) ? musicFolderId[0] : musicFolderId; }