import axios from 'axios';
import { set } from 'idb-keyval';
import chunk from 'lodash/chunk';
import filter from 'lodash/filter';
import orderBy from 'lodash/orderBy';
import { z } from 'zod';

import { createAuthHeader, jfApiClient } from '/@/renderer/api/jellyfin/jellyfin-api';
import { useRadioStore } from '/@/renderer/features/radio/store/radio-store';
import { getServerUrl } from '/@/renderer/utils/normalize-server-url';
import { jfNormalize } from '/@/shared/api/jellyfin/jellyfin-normalize';
import { JFSongListSort, JFSortOrder, jfType } from '/@/shared/api/jellyfin/jellyfin-types';
import { getFeatures, hasFeature, sortSongList, VersionInfo } from '/@/shared/api/utils';
import {
    albumArtistListSortMap,
    albumListSortMap,
    DeleteArtistImageArgs,
    DeleteArtistImageResponse,
    DeletePlaylistImageArgs,
    DeletePlaylistImageResponse,
    Folder,
    genreListSortMap,
    ImageArgs,
    ImageRequest,
    InternalControllerEndpoint,
    LibraryItem,
    Played,
    playlistListSortMap,
    ReplaceApiClientProps,
    ServerType,
    Song,
    SongListSort,
    songListSortMap,
    SortOrder,
    sortOrderMap,
    Tag,
    UploadArtistImageArgs,
    UploadArtistImageResponse,
    UploadPlaylistImageArgs,
    UploadPlaylistImageResponse,
} from '/@/shared/types/domain-types';
import { ServerFeature } from '/@/shared/types/features-types';

const getJellyfinImageRequest = ({
    apiClientProps: { server },
    baseUrl,
    query,
}: ReplaceApiClientProps<ImageArgs>): ImageRequest | null => {
    const { id, size } = query;
    const imageSize = size;

    if (!server) {
        return null;
    }

    const url = baseUrl || getServerUrl(server);

    if (!url) {
        return null;
    }

    return {
        cacheKey: ['jellyfin', server.id, baseUrl || '', id, imageSize || ''].join(':'),
        headers: server.credential
            ? { Authorization: createAuthHeader().concat(`, Token="${server.credential}"`) }
            : { Authorization: createAuthHeader() },
        url: `${url}/Items/${id}/Images/Primary?quality=96${imageSize ? `&width=${imageSize}` : ''}`,
    };
};

const formatCommaDelimitedString = (value: string[]) => {
    return value.join(',');
};

const getImageContentType = (bytes: Uint8Array): string => {
    if (bytes[0] === 0x89 && bytes[1] === 0x50) {
        return 'image/png';
    }
    if (bytes[0] === 0xff && bytes[1] === 0xd8) {
        return 'image/jpeg';
    }
    if (bytes[0] === 0x47 && bytes[1] === 0x49) {
        return 'image/gif';
    }
    if (bytes[0] === 0x52 && bytes[1] === 0x49) {
        return 'image/webp';
    }

    return 'image/jpeg';
};

const uint8ArrayToBase64 = (bytes: Uint8Array): string => {
    let binary = '';
    const chunkSize = 0x8000;

    for (let i = 0; i < bytes.length; i += chunkSize) {
        const chunk = bytes.subarray(i, i + chunkSize);
        binary += String.fromCharCode(...chunk);
    }

    return btoa(binary);
};

type JellyfinApiClientProps = DeletePlaylistImageArgs['apiClientProps'];

const deleteItemPrimaryImage = async (
    apiClientProps: JellyfinApiClientProps,
    id: string,
    errorMessage: string,
): Promise<boolean> => {
    const res = await jfApiClient({
        ...apiClientProps,
        server: apiClientProps.server ?? null,
    }).deleteArtistImage({
        params: {
            id,
        },
    });

    if (res.status !== 204) {
        throw new Error(errorMessage);
    }

    return true;
};

const uploadItemPrimaryImage = async (
    apiClientProps: JellyfinApiClientProps,
    id: string,
    image: Uint8Array,
    errorMessage: string,
): Promise<boolean> => {
    const server = apiClientProps.server;
    const serverUrl = getServerUrl(server);

    if (!serverUrl) {
        throw new Error('Server is required');
    }

    const contentType = getImageContentType(image);
    const base64 = uint8ArrayToBase64(image);

    const authHeader = createAuthHeader();
    const authorization = server?.credential
        ? authHeader.concat(`, Token="${server.credential}"`)
        : authHeader;

    const res = await axios.post(`${serverUrl}/Items/${id}/Images/Primary`, base64, {
        headers: {
            Authorization: authorization,
            'Content-Type': contentType,
        },
        signal: apiClientProps.signal,
    });

    if (res.status !== 204) {
        throw new Error(errorMessage);
    }

    return true;
};

// Limit the query to 50 at a time to be *extremely* conservative on the
// length of the full URL, since the ids are part of the query string and
// not the POST body
const MAX_ITEMS_PER_PLAYLIST_ADD = 50;

// Defining a re-usable Collator instance for performance reasons.
const numericSortCollator = new Intl.Collator(undefined, { numeric: true });
const collator = new Intl.Collator();

const VERSION_INFO: VersionInfo = [
    [
        '10.9.0',
        {
            [ServerFeature.LYRICS_SINGLE_STRUCTURED]: [1],
            [ServerFeature.PUBLIC_PLAYLIST]: [1],
        },
    ],
    [
        '10.0.0',
        {
            [ServerFeature.ARTIST_IMAGE_UPLOAD]: [1],
            [ServerFeature.PLAYLIST_IMAGE_UPLOAD]: [1],
            [ServerFeature.TAGS]: [1],
        },
    ],
];

const JF_FIELDS = {
    ALBUM_ARTIST_DETAIL: ['Genres', 'Overview', 'SortName', 'ProviderIds'],
    ALBUM_ARTIST_LIST: [
        'Genres',
        'DateCreated',
        'ExternalUrls',
        'Overview',
        'SortName',
        'ProviderIds',
    ],
    ALBUM_DETAIL: ['Genres', 'DateCreated', 'ChildCount', 'Tags', 'ProviderIds'],
    ALBUM_LIST: ['Tags', 'Studios', 'SortName', 'ProviderIds', 'ChildCount'],
    FOLDER: ['Genres', 'DateCreated', 'MediaSources', 'ParentId'],
    GENRE: ['ItemCounts'],
    PLAYLIST_DETAIL: [
        'Genres',
        'DateCreated',
        'MediaSources',
        'ChildCount',
        'ParentId',
        'SortName',
    ],
    PLAYLIST_LIST: ['ChildCount', 'Genres', 'DateCreated', 'ParentId', 'Overview'],
    SONG: ['Genres', 'DateCreated', 'MediaSources', 'ParentId', 'Tags', 'SortName', 'ProviderIds'],
} as const;

export const JellyfinController: InternalControllerEndpoint = {
    addToPlaylist: async (args) => {
        const { apiClientProps, body, query } = args;

        if (!apiClientProps.server?.userId) {
            throw new Error('No userId found');
        }

        const chunks = chunk(body.songId, MAX_ITEMS_PER_PLAYLIST_ADD);

        for (const chunk of chunks) {
            const res = await jfApiClient(apiClientProps).addToPlaylist({
                body: null,
                params: {
                    id: query.id,
                },
                query: {
                    Ids: chunk.join(','),
                    UserId: apiClientProps.server?.userId,
                },
            });

            if (res.status !== 204) {
                throw new Error('Failed to add to playlist');
            }
        }

        return null;
    },
    authenticate: async (url, body) => {
        const cleanServerUrl = url.replace(/\/$/, '');

        const res = await jfApiClient({ server: null, url: cleanServerUrl }).authenticate({
            body: {
                Pw: body.password,
                Username: body.username,
            },
        });

        if (res.status !== 200) {
            throw new Error('Failed to authenticate');
        }

        return {
            credential: res.body.AccessToken,
            isAdmin: Boolean(res.body.User.Policy.IsAdministrator),
            userId: res.body.User.Id,
            username: res.body.User.Name,
        };
    },
    createFavorite: async (args) => {
        const { apiClientProps, query } = args;

        if (!apiClientProps.server?.userId) {
            throw new Error('No userId found');
        }

        for (const id of query.id) {
            await jfApiClient(apiClientProps).createFavorite({
                body: {},
                params: {
                    id,
                    userId: apiClientProps.server?.userId,
                },
            });
        }

        return null;
    },
    createInternetRadioStation: async (args) => {
        const { apiClientProps, body } = args;

        if (!apiClientProps.serverId) {
            throw new Error('No serverId found');
        }

        const state = useRadioStore.getState();
        if (!state?.actions?.createStation) {
            throw new Error('Radio store not initialized');
        }

        state.actions.createStation(apiClientProps.serverId, {
            homepageUrl: body.homepageUrl || null,
            name: body.name,
            streamUrl: body.streamUrl,
        });

        return null;
    },
    createPlaylist: async (args) => {
        const { apiClientProps, body } = args;

        if (!apiClientProps.server?.userId) {
            throw new Error('No userId found');
        }

        const res = await jfApiClient(apiClientProps).createPlaylist({
            body: {
                IsPublic: body.public,
                MediaType: 'Audio',
                Name: body.name,
                UserId: apiClientProps.server.userId,
            },
        });

        if (res.status !== 200) {
            throw new Error('Failed to create playlist');
        }

        return {
            id: res.body.Id,
        };
    },
    deleteArtistImage: async (args: DeleteArtistImageArgs): Promise<DeleteArtistImageResponse> => {
        const { apiClientProps, query } = args;

        return deleteItemPrimaryImage(apiClientProps, query.id, 'Failed to delete artist image');
    },
    deleteFavorite: async (args) => {
        const { apiClientProps, query } = args;

        if (!apiClientProps.server?.userId) {
            throw new Error('No userId found');
        }

        for (const id of query.id) {
            await jfApiClient(apiClientProps).removeFavorite({
                body: {},
                params: {
                    id,
                    userId: apiClientProps.server?.userId,
                },
            });
        }

        return null;
    },
    deleteInternetRadioStation: async (args) => {
        const { apiClientProps, query } = args;

        if (!apiClientProps.serverId) {
            throw new Error('No serverId found');
        }

        const state = useRadioStore.getState();
        if (!state?.actions?.deleteStation) {
            throw new Error('Radio store not initialized');
        }

        state.actions.deleteStation(apiClientProps.serverId, query.id);

        return null;
    },
    deletePlaylist: async (args) => {
        const { apiClientProps, query } = args;

        const res = await jfApiClient(apiClientProps).deletePlaylist({
            params: {
                id: query.id,
            },
        });

        if (res.status !== 204) {
            throw new Error('Failed to delete playlist');
        }

        return null;
    },
    deletePlaylistImage: async (
        args: DeletePlaylistImageArgs,
    ): Promise<DeletePlaylistImageResponse> => {
        const { apiClientProps, query } = args;

        return deleteItemPrimaryImage(apiClientProps, query.id, 'Failed to delete playlist image');
    },
    getAlbumArtistDetail: async (args) => {
        const { apiClientProps, query } = args;

        if (!apiClientProps.server?.userId) {
            throw new Error('No userId found');
        }

        const res = await jfApiClient(apiClientProps).getAlbumArtistDetail({
            params: {
                id: query.id,
                userId: apiClientProps.server?.userId,
            },
            query: {
                Fields: ['Genres', 'Overview', 'SortName'],
            },
        });

        if (res.status !== 200) {
            throw new Error('Failed to get album artist detail');
        }

        return jfNormalize.albumArtist(res.body, apiClientProps.server);
    },
    getAlbumArtistInfo: async (args) => {
        const { apiClientProps, query } = args;

        const similarArtistsRes = await jfApiClient(apiClientProps).getSimilarArtistList({
            params: {
                id: query.id,
            },
            query: {
                Limit: query.limit ?? 10,
            },
        });

        if (similarArtistsRes.status !== 200) {
            return null;
        }

        const items = similarArtistsRes.body?.Items?.filter(
            (entry) => entry.Name !== 'Various Artists',
        );
        const similarArtists =
            items?.map((entry) => ({
                id: entry.Id,
                imageId: entry.ImageTags?.Primary ? entry.Id : null,
                imageUrl: null,
                name: entry.Name,
                userFavorite: entry.UserData?.IsFavorite || false,
                userRating: null,
            })) ?? null;

        return {
            similarArtists,
        };
    },
    getAlbumArtistList: async (args) => {
        const { apiClientProps, query } = args;

        const res = await jfApiClient(apiClientProps).getAlbumArtistList({
            query: {
                Fields: JF_FIELDS.ALBUM_ARTIST_LIST,
                ImageTypeLimit: 1,
                IsFavorite: query.favorite,
                Limit: query.limit,
                ParentId: getLibraryId(query.musicFolderId),
                Recursive: true,
                SearchTerm: query.searchTerm,
                SortBy: albumArtistListSortMap.jellyfin[query.sortBy] || 'SortName,Name',
                SortOrder: sortOrderMap.jellyfin[query.sortOrder],
                StartIndex: query.startIndex,
                UserId: apiClientProps.server?.userId || undefined,
            },
        });

        if (res.status !== 200) {
            throw new Error('Failed to get album artist list');
        }

        return {
            items: res.body.Items.map((item) =>
                jfNormalize.albumArtist(item, apiClientProps.server),
            ),
            startIndex: query.startIndex,
            totalRecordCount: res.body.TotalRecordCount,
        };
    },
    getAlbumArtistListCount: async ({ apiClientProps, query }) =>
        JellyfinController.getAlbumArtistList({
            apiClientProps,
            query: { ...query, limit: 1, startIndex: 0 },
        }).then((result) => result!.totalRecordCount!),
    getAlbumDetail: async (args) => {
        const { apiClientProps, query } = args;

        if (!apiClientProps.server?.userId) {
            throw new Error('No userId found');
        }

        const res = await jfApiClient(apiClientProps).getAlbumDetail({
            params: {
                id: query.id,
                userId: apiClientProps.server.userId,
            },
            query: {
                Fields: JF_FIELDS.ALBUM_DETAIL,
            },
        });

        const songsRes = await jfApiClient(apiClientProps).getSongList({
            params: {
                userId: apiClientProps.server.userId,
            },
            query: {
                AlbumIds: query.id,
                EnableUserData: true,
                Fields: JF_FIELDS.SONG,
                IncludeItemTypes: 'Audio',
                Recursive: true,
                SortBy: 'ParentIndexNumber,IndexNumber,SortName',
                SortOrder: JFSortOrder.ASC,
                StartIndex: 0,
                UserId: apiClientProps.server.userId,
            },
        });

        if (res.status !== 200 || songsRes.status !== 200) {
            throw new Error('Failed to get album detail');
        }

        // Workaround for Jellyfin bug that returns items that share the same album name
        const albumIdSet = new Set([query.id]);
        const songs = songsRes.body.Items.filter((item) => albumIdSet.has(item.AlbumId!));

        return jfNormalize.album({ ...res.body, Songs: songs }, apiClientProps.server);
    },
    getAlbumList: async (args) => {
        const { apiClientProps, query } = args;

        if (!apiClientProps.server?.userId) {
            throw new Error('No userId found');
        }

        const yearsGroup: string[] = [];
        if (query.minYear && query.maxYear) {
            for (let i = Number(query.minYear); i <= Number(query.maxYear); i += 1) {
                yearsGroup.push(String(i));
            }
        }

        const yearsFilter = yearsGroup.length ? yearsGroup.join(',') : undefined;

        let artistQuery:
            | Omit<z.infer<typeof jfType._parameters.albumList>, 'IncludeItemTypes'>
            | undefined;

        if (query.artistIds) {
            // Based mostly off of observation, this is the behavior I've seen:
            // ContributingArtistIds is the _closest_ to where the album is a compilation and the artist is involved
            // AlbumArtistIds is where the artist is an album artist
            // ArtistIds is all credits
            if (query.compilation) {
                artistQuery = {
                    ContributingArtistIds: formatCommaDelimitedString(query.artistIds),
                };
            } else if (query.compilation === false) {
                artistQuery = { AlbumArtistIds: formatCommaDelimitedString(query.artistIds) };
            } else {
                artistQuery = { ArtistIds: formatCommaDelimitedString(query.artistIds) };
            }
        }

        const res = await jfApiClient(apiClientProps).getAlbumList({
            params: {
                userId: apiClientProps.server?.userId,
            },
            query: {
                ...artistQuery,
                Fields: JF_FIELDS.ALBUM_LIST,
                GenreIds: query.genreIds ? query.genreIds.join(',') : undefined,
                IncludeItemTypes: 'MusicAlbum',
                IsFavorite: query.favorite,
                Limit: query.limit === -1 ? undefined : query.limit,
                ParentId: getLibraryId(query.musicFolderId),
                Recursive: true,
                SearchTerm: query.searchTerm,
                SortBy: albumListSortMap.jellyfin[query.sortBy] || 'SortName',
                SortOrder: sortOrderMap.jellyfin[query.sortOrder],
                StartIndex: query.startIndex,
                ...query._custom,
                Years: yearsFilter,
            },
        });

        if (res.status !== 200) {
            throw new Error('Failed to get album list');
        }

        return {
            items: res.body.Items.map((item) => jfNormalize.album(item, apiClientProps.server)),
            startIndex: query.startIndex,
            totalRecordCount: res.body.TotalRecordCount,
        };
    },
    getAlbumListCount: async ({ apiClientProps, query }) =>
        JellyfinController.getAlbumList({
            apiClientProps,
            query: { ...query, limit: 1, startIndex: 0 },
        }).then((result) => result!.totalRecordCount!),
    getAlbumRadio: async (args) => {
        const { apiClientProps, query } = args;

        // For Jellyfin, use instant mix for album radio
        const res = await jfApiClient(apiClientProps).getInstantMix({
            params: {
                itemId: query.albumId,
            },
            query: {
                Fields: JF_FIELDS.SONG,
                Limit: query.count,
                UserId: apiClientProps.server?.userId || undefined,
            },
        });

        if (res.status !== 200) {
            throw new Error('Failed to get album radio songs');
        }

        return res.body.Items.map((song) => jfNormalize.song(song, apiClientProps.server));
    },
    getArtistList: async (args) => {
        const { apiClientProps, query } = args;

        const res = await jfApiClient(apiClientProps).getArtistList({
            query: {
                Fields: JF_FIELDS.ALBUM_ARTIST_LIST,
                ImageTypeLimit: 1,
                Limit: query.limit,
                ParentId: getLibraryId(query.musicFolderId),
                Recursive: true,
                SearchTerm: query.searchTerm,
                SortBy: albumArtistListSortMap.jellyfin[query.sortBy] || 'SortName,Name',
                SortOrder: sortOrderMap.jellyfin[query.sortOrder],
                StartIndex: query.startIndex,
                UserId: apiClientProps.server?.userId || undefined,
            },
        });

        if (res.status !== 200) {
            throw new Error('Failed to get album artist list');
        }

        return {
            items: res.body.Items.map((item) =>
                jfNormalize.albumArtist(item, apiClientProps.server),
            ),
            startIndex: query.startIndex,
            totalRecordCount: res.body.TotalRecordCount,
        };
    },
    getArtistListCount: async ({ apiClientProps, query }) =>
        JellyfinController.getArtistList({
            apiClientProps,
            query: { ...query, limit: 1, startIndex: 0 },
        }).then((result) => result!.totalRecordCount!),
    getArtistRadio: async (args) => {
        const { apiClientProps, query } = args;

        // For Jellyfin, use instant mix for artist radio
        const res = await jfApiClient(apiClientProps).getInstantMix({
            params: {
                itemId: query.artistId,
            },
            query: {
                Fields: JF_FIELDS.SONG,
                Limit: query.count,
                UserId: apiClientProps.server?.userId || undefined,
            },
        });

        if (res.status !== 200) {
            throw new Error('Failed to get artist radio songs');
        }

        return res.body.Items.map((song) => jfNormalize.song(song, apiClientProps.server));
    },
    getDownloadUrl: (args) => {
        const { apiClientProps, query } = args;

        return `${apiClientProps.server?.url}/items/${query.id}/download?apiKey=${apiClientProps.server?.credential}`;
    },
    getFavoriteSongs: async (args) => {
        const { apiClientProps, query } = args;

        if (!apiClientProps.server?.userId) {
            throw new Error('No userId found');
        }

        // Gets songs sorted by play count and filters favorited songs
        const res = await jfApiClient(apiClientProps).getTopSongsList({
            params: {
                userId: apiClientProps.server?.userId,
            },
            query: {
                ArtistIds: query.artistId,
                Fields: JF_FIELDS.SONG,
                IncludeItemTypes: 'Audio',
                IsFavorite: true,
                Limit: query.limit,
                Recursive: true,
                SortBy: JFSongListSort.PLAY_COUNT,
                SortOrder: 'Descending',
                UserId: apiClientProps.server?.userId,
            },
        });

        if (res.status !== 200) {
            throw new Error('Failed to get top song list');
        }

        const items = res.body.Items.map((item) => jfNormalize.song(item, apiClientProps.server));

        return {
            items,
            startIndex: 0,
            totalRecordCount: res.body.TotalRecordCount,
        };
    },
    getFolder: async (args) => {
        const { apiClientProps, query } = args;
        const userId = apiClientProps.server?.userId;

        if (!userId) throw new Error('No userId found');

        const sortOrder = (query.sortOrder?.toLowerCase() ?? 'asc') as 'asc' | 'desc';
        const isRootFolderId = query.id === '0';

        if (isRootFolderId) {
            if (query.musicFolderId) {
                // If music folder is provided, directly get the folder
                const musicFolderRes = await jfApiClient(apiClientProps).getFolder({
                    params: {
                        userId,
                    },
                    query: {
                        ParentId: getLibraryId(query.musicFolderId)!,
                    },
                });

                if (musicFolderRes.status !== 200) {
                    throw new Error('Failed to get music folder list');
                }

                let items = musicFolderRes.body.Items.filter((item) => item.Type !== 'Audio');

                if (query.searchTerm) {
                    items = filter(items, (item) => {
                        return item.Name.toLowerCase().includes(query.searchTerm!.toLowerCase());
                    });
                }

                const folders = items
                    .filter((item) => item.Type !== 'Audio')
                    .map((item) => jfNormalize.folder(item, apiClientProps.server));

                const sortedFolders = orderBy(folders, [(v) => v.name.toLowerCase()], [sortOrder]);

                return {
                    _itemType: LibraryItem.FOLDER,
                    _serverId: apiClientProps.server?.id || 'unknown',
                    _serverType: ServerType.JELLYFIN,
                    children: {
                        folders: sortedFolders,
                        songs: [],
                    },
                    id: query.id,
                    name: '~',
                    parentId: undefined,
                };
            } else {
                // Use the root music folder list if no music folder id is provided
                const musicFolderRes = await jfApiClient(apiClientProps).getMusicFolderList({
                    params: {
                        userId,
                    },
                });

                if (musicFolderRes.status !== 200) {
                    throw new Error('Failed to get music folder list');
                }

                let items = musicFolderRes.body.Items.filter((item) => item.Type !== 'Audio');

                if (query.searchTerm) {
                    items = filter(items, (item) => {
                        return item.Name.toLowerCase().includes(query.searchTerm!.toLowerCase());
                    });
                }

                const folders = items
                    .filter((item) => item.Type !== 'Audio')
                    .map((item) =>
                        jfNormalize.folder(
                            item as unknown as z.infer<typeof jfType._response.folder>,
                            apiClientProps.server,
                        ),
                    );

                const sortedFolders = orderBy(folders, [(v) => v.name.toLowerCase()], [sortOrder]);

                return {
                    _itemType: LibraryItem.FOLDER,
                    _serverId: apiClientProps.server?.id || 'unknown',
                    _serverType: ServerType.JELLYFIN,
                    children: {
                        folders: sortedFolders,
                        songs: [],
                    },
                    id: query.id,
                    name: '~',
                    parentId: undefined,
                };
            }
        }

        const folderDetailRes = await jfApiClient(apiClientProps).getFolder({
            params: {
                userId,
            },
            query: {
                Fields: JF_FIELDS.FOLDER,
                ParentId: query.id,
                SortBy: query.sortBy
                    ? (songListSortMap.jellyfin[query.sortBy] as string) || 'SortName'
                    : 'SortName',
                SortOrder: sortOrderMap.jellyfin[query.sortOrder || SortOrder.ASC],
            },
        });

        if (folderDetailRes.status !== 200) {
            throw new Error('Failed to get folder');
        }

        // Get parent folder info - we'll use the first child's ParentId to infer the folder's parentId
        // The folder name will be inferred from the query.id or we can try to get it from a parent query
        let parentId: string | undefined;
        let folderName = 'Unknown folder';

        if (folderDetailRes.body.Items?.length > 0) {
            const firstItem = folderDetailRes.body.Items[0];
            parentId = firstItem.ParentId;

            // Try to get the folder name by querying its parent's children
            if (parentId) {
                const parentFolderRes = await jfApiClient(apiClientProps).getFolder({
                    params: {
                        userId,
                    },
                    query: {
                        Fields: JF_FIELDS.FOLDER,
                        ParentId: parentId,
                    },
                });

                if (parentFolderRes.status === 200) {
                    const parentFolderItem = parentFolderRes.body.Items?.find(
                        (item) => item.Id === query.id,
                    );
                    if (parentFolderItem) {
                        folderName = parentFolderItem.Name || 'Unknown folder';
                        parentId = parentFolderItem.ParentId;
                    }
                }
            }
        }

        const items = folderDetailRes.body.Items || [];

        let filteredFolders = items
            .filter((item) => item.Type !== 'Audio')
            .map((item) => jfNormalize.folder(item, apiClientProps.server));
        let filteredSongs = items
            .filter(
                (item) =>
                    item.Type === 'Audio' &&
                    (item as unknown as z.infer<typeof jfType._response.song>).MediaSources,
            )
            .map((item) =>
                jfNormalize.song(
                    item as unknown as z.infer<typeof jfType._response.song>,
                    apiClientProps.server,
                ),
            );

        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,
            );
        }

        const folder: Folder = {
            _itemType: LibraryItem.FOLDER,
            _serverId: apiClientProps.server?.id || 'unknown',
            _serverType: ServerType.JELLYFIN,
            children: {
                folders: filteredFolders,
                songs: filteredSongs,
            },
            id: query.id,
            name: folderName,
            parentId,
        };

        return folder;
    },
    getGenreList: async (args) => {
        const { apiClientProps, query } = args;

        if (!apiClientProps.server?.userId) {
            throw new Error('No userId found');
        }

        const res = await jfApiClient(apiClientProps).getGenreList({
            query: {
                EnableTotalRecordCount: true,
                Fields: JF_FIELDS.GENRE,
                Limit: query.limit === -1 ? undefined : query.limit,
                ParentId: getLibraryId(query.musicFolderId),
                Recursive: true,
                SearchTerm: query?.searchTerm,
                SortBy: genreListSortMap.jellyfin[query.sortBy] || 'SortName',
                SortOrder: sortOrderMap.jellyfin[query.sortOrder],
                StartIndex: query.startIndex,
                UserId: apiClientProps.server?.userId,
            },
        });

        if (res.status !== 200) {
            throw new Error('Failed to get genre list');
        }

        return {
            items: res.body.Items.map((item) => jfNormalize.genre(item, apiClientProps.server)),
            startIndex: query.startIndex || 0,
            totalRecordCount: res.body?.TotalRecordCount || 0,
        };
    },
    getImageRequest: getJellyfinImageRequest,
    getImageUrl: (args) => getJellyfinImageRequest(args)?.url || null,
    getInternetRadioStations: async (args) => {
        const { apiClientProps } = args;

        if (!apiClientProps.serverId) {
            throw new Error('No serverId found');
        }

        const state = useRadioStore.getState();
        if (!state?.actions?.getStations) {
            throw new Error('Radio store not initialized');
        }

        return state.actions.getStations(apiClientProps.serverId);
    },
    getLyrics: async (args) => {
        const { apiClientProps, query } = args;

        if (!apiClientProps.server?.userId) {
            throw new Error('No userId found');
        }

        const res = await jfApiClient(apiClientProps).getSongLyrics({
            params: {
                id: query.songId,
            },
        });

        if (res.status !== 200) {
            throw new Error('Failed to get lyrics');
        }

        if (res.body.Lyrics.length > 0 && res.body.Lyrics[0].Start === undefined) {
            return res.body.Lyrics.map((lyric) => lyric.Text).join('\n');
        }

        return res.body.Lyrics.map((lyric) => ({
            startMs: lyric.Start! / 1e4,
            text: lyric.Text,
        }));
    },
    getMusicFolderList: async (args) => {
        const { apiClientProps } = args;
        const userId = apiClientProps.server?.userId;

        if (!userId) throw new Error('No userId found');

        const res = await jfApiClient(apiClientProps).getMusicFolderList({
            params: {
                userId,
            },
        });

        if (res.status !== 200) {
            throw new Error('Failed to get genre list');
        }

        const musicFolders = res.body.Items.filter(
            (folder) => folder.CollectionType === jfType._enum.collection.MUSIC,
        );

        return {
            items: musicFolders.map(jfNormalize.musicFolder),
            startIndex: 0,
            totalRecordCount: musicFolders?.length || 0,
        };
    },
    getPlaylistDetail: async (args) => {
        const { apiClientProps, query } = args;

        if (!apiClientProps.server?.userId) {
            throw new Error('No userId found');
        }

        const res = await jfApiClient(apiClientProps).getPlaylistDetail({
            params: {
                id: query.id,
                userId: apiClientProps.server?.userId,
            },
            query: {
                Fields: JF_FIELDS.PLAYLIST_DETAIL,
                Ids: query.id,
            },
        });

        if (res.status !== 200) {
            throw new Error('Failed to get playlist detail');
        }

        return jfNormalize.playlist(res.body, apiClientProps.server);
    },
    getPlaylistList: async (args) => {
        const { apiClientProps, query } = args;

        if (!apiClientProps.server?.userId) {
            throw new Error('No userId found');
        }

        const res = await jfApiClient(apiClientProps).getPlaylistList({
            params: {
                userId: apiClientProps.server?.userId,
            },
            query: {
                Fields: JF_FIELDS.PLAYLIST_LIST,
                IncludeItemTypes: 'Playlist',
                Limit: query.limit,
                MediaTypes: 'Audio, Unknown',
                Recursive: true,
                SearchTerm: query.searchTerm,
                SortBy: playlistListSortMap.jellyfin[query.sortBy],
                SortOrder: sortOrderMap.jellyfin[query.sortOrder],
                StartIndex: query.startIndex,
            },
        });

        if (res.status !== 200) {
            throw new Error('Failed to get playlist list');
        }

        return {
            items: res.body.Items.map((item) => jfNormalize.playlist(item, apiClientProps.server)),
            startIndex: 0,
            totalRecordCount: res.body.TotalRecordCount,
        };
    },
    getPlaylistListCount: async ({ apiClientProps, query }) =>
        JellyfinController.getPlaylistList({
            apiClientProps,
            query: { ...query, limit: 1, startIndex: 0 },
        }).then((result) => result!.totalRecordCount!),
    getPlaylistSongIds: async (args) => {
        const { apiClientProps, query } = args;

        if (!apiClientProps.server?.userId) {
            throw new Error('No userId found');
        }

        const res = await jfApiClient(apiClientProps).getPlaylistSongList({
            params: {
                id: query.id,
            },
            query: {
                // XXX: No fields are required for only IDs, which saves processing time between
                // the Jellyfin server query, network (MBs vs KBs), and in-app parsing.
                IncludeItemTypes: 'Audio',
                UserId: apiClientProps.server?.userId,
            },
        });

        if (res.status !== 200) {
            throw new Error('Failed to get playlist song list IDs');
        }

        return {
            items: res.body.Items.map((item) => item.Id),
            startIndex: 0,
            totalRecordCount: res.body.TotalRecordCount,
        };
    },
    getPlaylistSongList: async (args) => {
        const { apiClientProps, query } = args;

        if (!apiClientProps.server?.userId) {
            throw new Error('No userId found');
        }

        const res = await jfApiClient(apiClientProps).getPlaylistSongList({
            params: {
                id: query.id,
            },
            query: {
                Fields: JF_FIELDS.PLAYLIST_DETAIL,
                IncludeItemTypes: 'Audio',
                UserId: apiClientProps.server?.userId,
            },
        });

        if (res.status !== 200) {
            throw new Error('Failed to get playlist song list');
        }

        return {
            items: res.body.Items.map((item) => jfNormalize.song(item, apiClientProps.server)),
            startIndex: 0,
            totalRecordCount: res.body.TotalRecordCount,
        };
    },
    getPlayQueue: async () => {
        throw new Error('Not supported');
    },
    getRandomSongList: async (args) => {
        const { apiClientProps, query } = args;

        if (!apiClientProps.server?.userId) {
            throw new Error('No userId found');
        }

        const yearsGroup: string[] = [];
        if (query.minYear && query.maxYear) {
            for (let i = Number(query.minYear); i <= Number(query.maxYear); i += 1) {
                yearsGroup.push(String(i));
            }
        }

        const yearsFilter = yearsGroup.length ? formatCommaDelimitedString(yearsGroup) : undefined;

        const res = await jfApiClient(apiClientProps).getSongList({
            params: {
                userId: apiClientProps.server?.userId,
            },
            query: {
                Fields: JF_FIELDS.SONG,
                GenreIds: query.genre ? query.genre : undefined,
                IncludeItemTypes: 'Audio',
                IsPlayed:
                    query.played === Played.Never
                        ? false
                        : query.played === Played.Played
                          ? true
                          : undefined,
                Limit: query.limit,
                ParentId: getLibraryId(query.musicFolderId),
                Recursive: true,
                SortBy: JFSongListSort.RANDOM,
                SortOrder: JFSortOrder.ASC,
                StartIndex: 0,
                Years: yearsFilter,
            },
        });

        if (res.status !== 200) {
            throw new Error('Failed to get random songs');
        }

        return {
            items: res.body.Items.map((item) => jfNormalize.song(item, apiClientProps.server)),
            startIndex: 0,
            totalRecordCount: res.body.Items.length || 0,
        };
    },
    getRoles: async () => [],
    getScanStatus: async (args) => {
        const { apiClientProps } = args;

        const res = await jfApiClient(apiClientProps).getScheduledTasks();

        if (res.status !== 200) {
            throw new Error('Failed to get scan status');
        }

        const task =
            res.body.find((t) => t.Key === 'RefreshLibrary') ||
            res.body.find((t) => t.Name === 'Scan Media Library');

        if (!task) {
            return {
                count: 0,
                folderCount: 0,
                scanning: false,
            };
        }

        return {
            count: 0,
            folderCount: 0,
            lastScan: task.LastExecutionResult?.EndTimeUtc ?? undefined,
            scanning: task.State === 'Running' || task.State === 'Cancelling',
        };
    },
    getServerInfo: async (args) => {
        const { apiClientProps } = args;

        const res = await jfApiClient(apiClientProps).getServerInfo();

        if (res.status !== 200) {
            throw new Error('Failed to get server info');
        }

        const defaultFeatures = {
            [ServerFeature.REPORT_PLAYBACK]: [1],
        };

        const features = {
            ...defaultFeatures,
            ...getFeatures(VERSION_INFO, res.body.Version),
        };

        return {
            features,
            id: apiClientProps.server?.id,
            version: res.body.Version,
        };
    },
    getSimilarSongs: async (args) => {
        const { apiClientProps, query } = args;

        if (apiClientProps.server?.preferInstantMix !== true) {
            // Prefer getSimilarSongs, where possible, and not overridden.
            // InstantMix can be overridden by plugins, so this may be preferred by the user.
            // Otherwise, similarSongs may have a better output than InstantMix, if sufficient
            // data exists from the server.
            const res = await jfApiClient(apiClientProps).getSimilarSongs({
                params: {
                    itemId: query.songId,
                },
                query: {
                    Fields: JF_FIELDS.SONG,
                    Limit: query.count,
                    UserId: apiClientProps.server?.userId || undefined,
                },
            });

            if (res.status === 200 && res.body.Items.length) {
                const results = res.body.Items.reduce<Song[]>((acc, song) => {
                    if (song.Id !== query.songId) {
                        acc.push(jfNormalize.song(song, apiClientProps.server));
                    }

                    return acc;
                }, []);

                if (results.length > 0) {
                    return results;
                }
            }
        }

        const mix = await jfApiClient(apiClientProps).getInstantMix({
            params: {
                itemId: query.songId,
            },
            query: {
                Fields: JF_FIELDS.SONG,
                Limit: query.count,
                UserId: apiClientProps.server?.userId || undefined,
            },
        });

        if (mix.status !== 200) {
            throw new Error('Failed to get similar songs');
        }

        return mix.body.Items.reduce<Song[]>((acc, song) => {
            if (song.Id !== query.songId) {
                acc.push(jfNormalize.song(song, apiClientProps.server));
            }

            return acc;
        }, []);
    },
    getSongDetail: async (args) => {
        const { apiClientProps, query } = args;

        const res = await jfApiClient(apiClientProps).getSongDetail({
            params: {
                id: query.id,
                userId: apiClientProps.server?.userId ?? '',
            },
        });

        if (res.status !== 200) {
            throw new Error('Failed to get song detail');
        }

        return jfNormalize.song(res.body, apiClientProps.server);
    },
    getSongList: async (args) => {
        const { apiClientProps, query } = args;

        if (!apiClientProps.server?.userId) {
            throw new Error('No userId found');
        }

        const yearsGroup: string[] = [];
        if (query.minYear && query.maxYear) {
            for (let i = Number(query.minYear); i <= Number(query.maxYear); i += 1) {
                yearsGroup.push(String(i));
            }
        }

        const yearsFilter = yearsGroup.length ? formatCommaDelimitedString(yearsGroup) : undefined;
        const artistIdsFilter = query.artistIds
            ? formatCommaDelimitedString(query.artistIds)
            : query.albumArtistIds
              ? formatCommaDelimitedString(query.albumArtistIds)
              : undefined;

        let items: z.infer<typeof jfType._response.song>[] = [];
        let totalRecordCount = 0;
        const batchSize = 50;

        // Handle albumIds fetches in batches to prevent HTTP 414 errors
        if (query.albumIds && query.albumIds.length > batchSize) {
            const albumIdBatches = chunk(query.albumIds, batchSize);

            for (const batch of albumIdBatches) {
                const albumIdsFilter = formatCommaDelimitedString(batch);

                const res = await jfApiClient(apiClientProps).getSongList({
                    params: {
                        userId: apiClientProps.server?.userId,
                    },
                    query: {
                        AlbumIds: albumIdsFilter,
                        ArtistIds: artistIdsFilter,
                        Fields: JF_FIELDS.SONG,
                        GenreIds: query.genreIds?.join(','),
                        IncludeItemTypes: 'Audio',
                        IsFavorite: query.favorite,
                        Limit: query.limit === -1 ? undefined : query.limit,
                        ParentId: getLibraryId(query.musicFolderId),
                        Recursive: true,
                        SearchTerm: query.searchTerm,
                        SortBy: songListSortMap.jellyfin[query.sortBy] || 'Album,SortName',
                        SortOrder: sortOrderMap.jellyfin[query.sortOrder],
                        StartIndex: query.startIndex,
                        ...query._custom,
                        Years: yearsFilter,
                    },
                });

                if (res.status !== 200) {
                    throw new Error('Failed to get song list');
                }

                items = [...items, ...res.body.Items];
                totalRecordCount += res.body.Items.length;
            }
        } else {
            const albumIdsFilter = query.albumIds
                ? formatCommaDelimitedString(query.albumIds)
                : undefined;

            const res = await jfApiClient(apiClientProps).getSongList({
                params: {
                    userId: apiClientProps.server?.userId,
                },
                query: {
                    AlbumIds: albumIdsFilter,
                    ArtistIds: artistIdsFilter,
                    Fields: JF_FIELDS.SONG,
                    GenreIds: query.genreIds?.join(','),
                    IncludeItemTypes: 'Audio',
                    IsFavorite: query.favorite,
                    Limit: query.limit === -1 ? undefined : query.limit,
                    ParentId: getLibraryId(query.musicFolderId),
                    Recursive: true,
                    SearchTerm: query.searchTerm,
                    SortBy: songListSortMap.jellyfin[query.sortBy] || 'Album,SortName',
                    SortOrder: sortOrderMap.jellyfin[query.sortOrder],
                    StartIndex: query.startIndex,
                    ...query._custom,
                    Years: yearsFilter,
                },
            });

            if (res.status !== 200) {
                throw new Error('Failed to get song list');
            }

            // Jellyfin Bodge because of code from https://github.com/jellyfin/jellyfin/blob/c566ccb63bf61f9c36743ddb2108a57c65a2519b/Emby.Server.Implementations/Data/SqliteItemRepository.cs#L3622
            // If the Album ID filter is passed, Jellyfin will search for
            //  1. the matching album id
            //  2. An album with the name of the album.
            // It is this second condition causing issues,
            if (query.albumIds) {
                const albumIdSet = new Set(query.albumIds);
                items = res.body.Items.filter((item) => albumIdSet.has(item.AlbumId!));
                totalRecordCount = items.length;
            } else {
                items = res.body.Items;
                totalRecordCount = res.body.TotalRecordCount;
            }
        }

        return {
            items: items.map((item) => jfNormalize.song(item, apiClientProps.server)),
            startIndex: query.startIndex,
            totalRecordCount,
        };
    },
    getSongListCount: async ({ apiClientProps, query }) =>
        JellyfinController.getSongList({
            apiClientProps,
            query: { ...query, limit: 1, startIndex: 0 },
        }).then((result) => result!.totalRecordCount!),
    getStreamUrl: async ({ apiClientProps: { server }, query }) => {
        const { bitrate, format, id, transcode } = query;
        const deviceId = '';

        let url = `${server?.url}/Items/${id}/Download?apiKey=${server?.credential}&playSessionId=${deviceId}`;

        if (transcode) {
            // Some format appears to be required. Fall back to trusty MP3 if not specified
            // Otherwise, ffmpeg appears to crash
            const realFormat = format || 'mp3';

            url =
                `${server?.url}/audio` +
                `/${id}/universal` +
                `?userId=${server?.userId}` +
                `&deviceId=${deviceId}` +
                '&audioCodec=aac' +
                `&apiKey=${server?.credential}` +
                `&playSessionId=${deviceId}` +
                '&container=opus,mp3,aac,m4a,m4b,flac,wav,ogg';

            url += `&transcodingProtocol=http&transcodingContainer=${realFormat}`;
            url = url.replace('audioCodec=aac', `audioCodec=${realFormat}`);
            url = url.replace(
                '&container=opus,mp3,aac,m4a,m4b,flac,wav,ogg',
                `&container=${realFormat}`,
            );

            if (bitrate !== undefined) {
                url += `&maxStreamingBitrate=${bitrate * 1000}`;
            }
        }

        return url;
    },
    getTagList: async (args) => {
        const { apiClientProps, query } = args;

        if (!hasFeature(apiClientProps.server, ServerFeature.TAGS)) {
            return { boolTags: undefined, enumTags: undefined, excluded: { album: [], song: [] } };
        }

        const res = await jfApiClient(apiClientProps).getFilterList({
            query: {
                IncludeItemTypes: query.type === LibraryItem.SONG ? 'Audio' : 'MusicAlbum',
                ParentId: query.folder,
                UserId: apiClientProps.server?.userId ?? '',
            },
        });

        if (res.status !== 200) {
            throw new Error('failed to get tags');
        }

        const studioRes = await jfApiClient(apiClientProps).getStudioList({
            query: {
                EnableTotalRecordCount: true,
                IncludeItemTypes: query.type === LibraryItem.SONG ? 'Audio' : 'MusicAlbum',
                ParentId: query.folder,
            },
        });

        if (studioRes.status !== 200) {
            throw new Error('failed to get studios');
        }

        const tags: Tag[] = [];
        if (res.body.Tags?.length) {
            tags.push({
                name: 'Tags',
                options: res.body.Tags.sort((a, b) => {
                    return numericSortCollator.compare(
                        a.toLocaleLowerCase(),
                        b.toLocaleLowerCase(),
                    );
                }).map((tag) => ({ id: tag, name: tag })),
            });
        }

        if (studioRes.body.Items.length) {
            tags.push({
                name: 'Studios',
                options: studioRes.body.Items.sort((a, b) =>
                    collator.compare(a.Name.toLocaleLowerCase(), b.Name.toLocaleLowerCase()),
                ).map((option) => ({ id: option.Name, name: option.Name })),
            });
        }

        return { excluded: { album: [], song: [] }, tags };
    },
    getTopSongs: async (args) => {
        const { apiClientProps, query } = args;

        if (!apiClientProps.server?.userId) {
            throw new Error('No userId found');
        }

        const type = query.type === 'personal' ? 'personal' : 'community';

        const res = await jfApiClient(apiClientProps).getTopSongsList({
            params: {
                userId: apiClientProps.server?.userId,
            },
            query: {
                ArtistIds: query.artistId,
                Fields: JF_FIELDS.SONG,
                IncludeItemTypes: 'Audio',
                Limit: query.limit,
                Recursive: true,
                SortBy:
                    type === 'personal'
                        ? JFSongListSort.PLAY_COUNT
                        : JFSongListSort.COMMUNITY_RATING,
                SortOrder: 'Descending',
                UserId: apiClientProps.server?.userId,
            },
        });

        if (res.status !== 200) {
            throw new Error('Failed to get top song list');
        }

        const items = res.body.Items.map((item) => jfNormalize.song(item, apiClientProps.server));

        if (type === 'personal') {
            const sorted = orderBy(
                items,
                ['playCount', 'albumId', 'trackNumber'],
                ['desc', 'asc', 'asc'],
            );

            return {
                items: sorted,
                startIndex: 0,
                totalRecordCount: res.body.TotalRecordCount,
            };
        }

        return {
            items,
            startIndex: 0,
            totalRecordCount: res.body.TotalRecordCount,
        };
    },
    getUserInfo: async (args) => {
        const { apiClientProps, query } = args;

        const res = await jfApiClient(apiClientProps).getUser({
            params: {
                id: query.id,
            },
        });

        if (res.status !== 200) {
            throw new Error('Failed to get user info');
        }

        return {
            id: res.body.Id,
            isAdmin: Boolean(res.body.Policy.IsAdministrator),
            name: res.body.Name,
        };
    },
    movePlaylistItem: async (args) => {
        const { apiClientProps, query } = args;

        const res = await jfApiClient(apiClientProps).movePlaylistItem({
            params: {
                itemId: query.trackId,
                newIdx: query.endingIndex.toString(),
                playlistId: query.playlistId,
            },
        });

        if (res.status !== 204) {
            throw new Error('Failed to move item in playlist');
        }
    },
    refreshItems: async (args) => {
        const { apiClientProps, query } = args;

        await Promise.all(
            query.ids.map((id) =>
                jfApiClient(apiClientProps).refreshItem({
                    body: null,
                    params: { id },
                    query: { MetadataRefreshMode: 'FullRefresh' },
                }),
            ),
        );

        return null;
    },
    removeFromPlaylist: async (args) => {
        const { apiClientProps, query } = args;

        const chunks = chunk(query.songId, MAX_ITEMS_PER_PLAYLIST_ADD);

        for (const chunk of chunks) {
            const res = await jfApiClient(apiClientProps).removeFromPlaylist({
                params: {
                    id: query.id,
                },
                query: {
                    EntryIds: chunk.join(','),
                },
            });

            if (res.status !== 204) {
                throw new Error('Failed to remove from playlist');
            }
        }

        return null;
    },
    replacePlaylist: async (args) => {
        const { apiClientProps, body, query } = args;

        if (!apiClientProps.server?.userId) {
            throw new Error('No userId found');
        }

        // 1. Fetch existing songs from the playlist
        const existingSongsRes = await jfApiClient(apiClientProps).getPlaylistSongList({
            params: {
                id: query.id,
            },
            query: {
                Fields: JF_FIELDS.SONG,
                IncludeItemTypes: 'Audio',
                UserId: apiClientProps.server?.userId,
            },
        });

        if (existingSongsRes.status !== 200) {
            throw new Error('Failed to fetch existing playlist songs');
        }

        const existingSongs = existingSongsRes.body.Items.map((item) =>
            jfNormalize.song(item, apiClientProps.server),
        );

        // 2. Get playlist detail to get the name
        const playlistDetailRes = await jfApiClient(apiClientProps).getPlaylistDetail({
            params: {
                id: query.id,
                userId: apiClientProps.server?.userId,
            },
            query: {
                Fields: JF_FIELDS.PLAYLIST_DETAIL,
                Ids: query.id,
            },
        });

        if (playlistDetailRes.status !== 200) {
            throw new Error('Failed to get playlist detail');
        }

        const playlist = jfNormalize.playlist(playlistDetailRes.body, 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
        if (existingSongs.length > 0) {
            const existingPlaylistItemIds = existingSongs
                .map((song) => song.playlistItemId)
                .filter((id): id is string => id !== undefined && id !== null);

            if (existingPlaylistItemIds.length > 0) {
                const chunks = chunk(existingPlaylistItemIds, MAX_ITEMS_PER_PLAYLIST_ADD);

                for (const chunk of chunks) {
                    const removeRes = await jfApiClient(apiClientProps).removeFromPlaylist({
                        params: {
                            id: query.id,
                        },
                        query: {
                            EntryIds: chunk.join(','),
                        },
                    });

                    if (removeRes.status !== 204) {
                        throw new Error('Failed to remove songs from playlist');
                    }
                }
            }
        }

        // 5. Add the new song ids to the playlist
        if (body.songId.length > 0) {
            const chunks = chunk(body.songId, MAX_ITEMS_PER_PLAYLIST_ADD);

            for (const chunk of chunks) {
                const addRes = await jfApiClient(apiClientProps).addToPlaylist({
                    body: null,
                    params: {
                        id: query.id,
                    },
                    query: {
                        Ids: chunk.join(','),
                        UserId: apiClientProps.server?.userId,
                    },
                });

                if (addRes.status !== 204) {
                    throw new Error('Failed to add songs to playlist');
                }
            }
        }

        return null;
    },
    savePlayQueue: async () => {
        throw new Error('Not supported');
    },
    scrobble: async (args) => {
        const { apiClientProps, query } = args;

        const position = query.position && Math.round(query.position);

        if (query.submission) {
            // Checked by jellyfin-plugin-lastfm for whether or not to send the "finished" scrobble (uses PositionTicks)
            jfApiClient(apiClientProps).scrobbleStopped({
                body: {
                    IsPaused: true,
                    ItemId: query.id,
                    PositionTicks: position,
                },
            });

            return null;
        }

        if (query.event === 'start') {
            jfApiClient(apiClientProps).scrobblePlaying({
                body: {
                    ItemId: query.id,
                    PositionTicks: position,
                },
            });

            return null;
        }

        if (query.event === 'pause') {
            jfApiClient(apiClientProps).scrobbleProgress({
                body: {
                    EventName: query.event,
                    IsPaused: true,
                    ItemId: query.id,
                    PositionTicks: position,
                },
            });

            return null;
        }

        if (query.event === 'unpause') {
            jfApiClient(apiClientProps).scrobbleProgress({
                body: {
                    EventName: query.event,
                    IsPaused: false,
                    ItemId: query.id,
                    PositionTicks: position,
                },
            });

            return null;
        }

        if (query.event === 'stop') {
            jfApiClient(apiClientProps).scrobbleStopped({
                body: {
                    ItemId: query.id,
                    PositionTicks: position,
                },
            });

            return null;
        }

        jfApiClient(apiClientProps).scrobbleProgress({
            body: {
                ItemId: query.id,
                PositionTicks: position,
            },
        });

        return null;
    },
    search: async (args) => {
        const { apiClientProps, query } = args;

        if (!apiClientProps.server?.userId) {
            throw new Error('No userId found');
        }

        let albums: z.infer<typeof jfType._response.albumList>['Items'] = [];
        let albumArtists: z.infer<typeof jfType._response.albumArtistList>['Items'] = [];
        let songs: z.infer<typeof jfType._response.songList>['Items'] = [];

        if (query.albumLimit) {
            const res = await jfApiClient(apiClientProps).getAlbumList({
                params: {
                    userId: apiClientProps.server?.userId,
                },
                query: {
                    EnableTotalRecordCount: true,
                    Fields: JF_FIELDS.ALBUM_LIST,
                    ImageTypeLimit: 1,
                    IncludeItemTypes: 'MusicAlbum',
                    Limit: query.albumLimit,
                    Recursive: true,
                    SearchTerm: query.query,
                    SortBy: 'SortName',
                    SortOrder: 'Ascending',
                    StartIndex: query.albumStartIndex || 0,
                },
            });

            if (res.status !== 200) {
                throw new Error('Failed to get album list');
            }

            albums = res.body.Items;
        }

        if (query.albumArtistLimit) {
            const res = await jfApiClient(apiClientProps).getAlbumArtistList({
                query: {
                    EnableTotalRecordCount: true,
                    Fields: JF_FIELDS.ALBUM_ARTIST_LIST,
                    ImageTypeLimit: 1,
                    IncludeArtists: true,
                    Limit: query.albumArtistLimit,
                    Recursive: true,
                    SearchTerm: query.query,
                    StartIndex: query.albumArtistStartIndex || 0,
                    UserId: apiClientProps.server?.userId,
                },
            });

            if (res.status !== 200) {
                throw new Error('Failed to get album artist list');
            }

            albumArtists = res.body.Items;
        }

        if (query.songLimit) {
            const res = await jfApiClient(apiClientProps).getSongList({
                params: {
                    userId: apiClientProps.server?.userId,
                },
                query: {
                    EnableTotalRecordCount: true,
                    Fields: JF_FIELDS.SONG,
                    IncludeItemTypes: 'Audio',
                    Limit: query.songLimit,
                    Recursive: true,
                    SearchTerm: query.query,
                    SortBy: 'Album,SortName',
                    SortOrder: 'Ascending',
                    StartIndex: query.songStartIndex || 0,
                    UserId: apiClientProps.server?.userId,
                },
            });

            if (res.status !== 200) {
                throw new Error('Failed to get song list');
            }

            songs = res.body.Items;
        }

        return {
            albumArtists: albumArtists.map((item) =>
                jfNormalize.albumArtist(item, apiClientProps.server),
            ),
            albums: albums.map((item) => jfNormalize.album(item, apiClientProps.server)),
            songs: songs.map((item) => jfNormalize.song(item, apiClientProps.server)),
        };
    },
    setPlaylistSongs: async (args) => {
        const { apiClientProps, body } = args;

        const res = await jfApiClient(apiClientProps).updatePlaylist({
            body: {
                Ids: body.songIds,
            },
            params: {
                id: body.id,
            },
        });

        if (res.status !== 204) {
            throw new Error('Failed to update playlist songs');
        }

        return null;
    },
    startLibraryScan: async (args) => {
        const { apiClientProps } = args;
        const server = apiClientProps.server;
        const userId = server?.userId;

        if (!userId) {
            throw new Error('No userId found');
        }

        let musicFolderIds = server.musicFolderId?.filter(Boolean) ?? [];

        if (musicFolderIds.length === 0) {
            const res = await jfApiClient(apiClientProps).getMusicFolderList({
                params: { userId },
            });

            if (res.status !== 200) {
                throw new Error('Failed to get music folders');
            }

            musicFolderIds = res.body.Items.filter(
                (folder) => folder.CollectionType === jfType._enum.collection.MUSIC,
            ).map((folder) => folder.Id);
        }

        if (musicFolderIds.length === 0) {
            throw new Error('No music folders found');
        }

        await Promise.all(
            musicFolderIds.map((id) =>
                jfApiClient(apiClientProps).refreshItem({
                    body: null,
                    params: { id },
                    query: {
                        ImageRefreshMode: 'Default',
                        MetadataRefreshMode: 'Default',
                        Recursive: true,
                        RegenerateTrickplay: false,
                        ReplaceAllImages: false,
                        ReplaceAllMetadata: false,
                    },
                }),
            ),
        );

        return null;
    },
    updateInternetRadioStation: async (args) => {
        const { apiClientProps, body, query } = args;

        if (!apiClientProps.serverId) {
            throw new Error('No serverId found');
        }

        const state = useRadioStore.getState();
        if (!state?.actions?.updateStation) {
            throw new Error('Radio store not initialized');
        }

        state.actions.updateStation(apiClientProps.serverId, query.id, {
            homepageUrl: body.homepageUrl || null,
            name: body.name,
            streamUrl: body.streamUrl,
        });

        return null;
    },
    updatePlaylist: async (args) => {
        const { apiClientProps, body, query } = args;

        if (!apiClientProps.server?.userId) {
            throw new Error('No userId found');
        }

        const res = await jfApiClient(apiClientProps).updatePlaylist({
            body: {
                IsPublic: body.public,
                Name: body.name,
            },
            params: {
                id: query.id,
            },
        });

        if (res.status !== 204) {
            throw new Error('Failed to update playlist');
        }

        return null;
    },
    uploadArtistImage: async (args: UploadArtistImageArgs): Promise<UploadArtistImageResponse> => {
        const { apiClientProps, body, query } = args;

        return uploadItemPrimaryImage(
            apiClientProps,
            query.id,
            body.image,
            'Failed to upload artist image',
        );
    },
    uploadPlaylistImage: async (
        args: UploadPlaylistImageArgs,
    ): Promise<UploadPlaylistImageResponse> => {
        const { apiClientProps, body, query } = args;

        return uploadItemPrimaryImage(
            apiClientProps,
            query.id,
            body.image,
            'Failed to upload playlist image',
        );
    },
};

function getLibraryId(musicFolderId?: string | string[]) {
    return Array.isArray(musicFolderId) ? musicFolderId[0] : musicFolderId;
}
