# -*- coding: utf-8 -*-
"""
For API info see:
    https://api.jellyfin.org/
"""
from typing import List
from datetime import datetime
from urllib.parse import quote
import requests
import json
import logging

LOG = logging.getLogger('JELLYFIN.' + __name__)


def jellyfin_url(client, handler):
    base_url = client.config.data['auth.server'].rstrip('/')
    return f"{base_url}/{handler.lstrip('/')}"


def basic_info():
    return "Etag"


def info():
    return (
        "Path,Genres,SortName,Studios,Writer,Taglines,LocalTrailerCount,"
        "OfficialRating,CumulativeRunTimeTicks,ItemCounts,"
        "Metascore,AirTime,DateCreated,People,Overview,"
        "CriticRating,CriticRatingSummary,Etag,ShortOverview,ProductionLocations,"
        "Tags,ProviderIds,ParentId,RemoteTrailers,SpecialEpisodeNumbers,"
        "MediaSources,VoteCount,RecursiveItemCount,PrimaryImageAspectRatio"
    )


def music_info():
    return (
        "Etag,Genres,SortName,Studios,Writer,"
        "OfficialRating,CumulativeRunTimeTicks,Metascore,"
        "AirTime,DateCreated,MediaStreams,People,ProviderIds,Overview,ItemCounts"
    )


class InternalAPIMixin:
    """
    A mixin class containing a common set of internal calls the other mixin
    classes will use.
    """

    def _http(self, action, url, request={}):
        request.update({'type': action, 'handler': url})

        return self.client.request(request)

    def _http_url(self, action, url, request={}):
        request.update({"type": action, "handler": url})

        return self.client.request_url(request)

    def _http_stream(self, action, url, dest_file, request={}):
        request.update({'type': action, 'handler': url})

        self.client.request(request, dest_file=dest_file)

    def _get(self, handler, params=None):
        return self._http("GET", handler, {'params': params})

    def _get_url(self, handler, params=None):
        return self._http_url("GET", handler, {"params": params})

    def _post(self, handler, json=None, params=None, data=None, headers=None):
        return self._http("POST", handler, {'params': params, 'json': json,
                                            'data': data, 'headers': headers})

    def _delete(self, handler, params=None):
        return self._http("DELETE", handler, {'params': params})

    def _get_stream(self, handler, dest_file, params=None):
        self._http_stream("GET", handler, dest_file, {'params': params})


class BiggerAPIMixin:
    """
    Bigger section of the Jellyfin api
    """

    def try_server(self):
        return self._get("System/Info/Public")

    def command(self, id, command, params=None, json=None):
        return self._post(
            "Sessions/%s/Command" % id,
            json={"Name": command, "Arguments": json},
            params=params,
        )

    def remote(self, id, command, params=None, json=None):
        handler = (
            "Sessions/%s/Playing/%s" % (id, command)
            if command
            else "Sessions/%s/Playing" % id
        )
        return self._post(
            handler,
            json=json,
            params=params,
        )

    def sessions(self, handler="", action="GET", params=None, json=None):
        if action == "POST":
            return self._post("Sessions%s" % handler, json, params)
        elif action == "DELETE":
            return self._delete("Sessions%s" % handler, params)
        else:
            return self._get("Sessions%s" % handler, params)

    def users(self, handler="", action="GET", params=None, json=None):
        if action == "POST":
            return self._post("Users/{UserId}%s" % handler, json, params)
        elif action == "DELETE":
            return self._delete("Users/{UserId}%s" % handler, params)
        else:
            return self._get("Users/{UserId}%s" % handler, params)

    def media_folders(self, params=None):
        return self._get("Library/MediaFolders/", params)

    def virtual_folders(self, action="GET", params=None, json=None):
        if action == "POST":
            return self._post("Library/VirtualFolders", json, params)
        elif action == "DELETE":
            return self._delete("Library/VirtualFolders", params)
        else:
            return self._get("Library/VirtualFolders", params)

    def physical_paths(self, params=None):
        return self._get("Library/PhysicalPaths/", params)

    def folder_contents(self, abspath="/", params=None, json=None):
        params = {} if params is None else params.copy()
        params['path'] = abspath
        params['includeFiles'] = params.get('includeFiles', True)
        params['includeDirectories'] = params.get('includeDirectories', True)
        return self._get("Environment/DirectoryContents", params)

    def refresh_library(self):
        """
        Starts a library scan.
        """
        return self._post("Library/Refresh")

    def add_media_library(self, name, collectionType, paths, refreshLibrary=True):
        """
        Create a new media library.

        Args:
            name (str): name of the new library

            collectionType (str): one of "movies" "tvshows" "music" "musicvideos"
                "homevideos" "boxsets" "books" "mixed"

            paths (List[str]):
                paths on the server to use in the media library

        References:
            .. [AddVirtualFolder] https://api.jellyfin.org/#tag/LibraryStructure/operation/AddVirtualFolder
        """
        params = {
            'name': name,
            'collectionType': collectionType,
            'paths': paths,
            'refreshLibrary': refreshLibrary,

        }
        return self.virtual_folders('POST', params=params)

    def items(self, handler="", action="GET", params=None, json=None, data=None, headers=None):
        if action == "POST":
            return self._post("Items%s" % handler, json, params, data, headers)
        elif action == "DELETE":
            return self._delete("Items%s" % handler, params)
        else:
            return self._get("Items%s" % handler, params)

    def user_items(self, handler="", params=None):
        return self.users("/Items%s" % handler, params=params)

    def shows(self, handler, params):
        return self._get("Shows%s" % handler, params)

    def videos(self, handler):
        return self._get("Videos%s" % handler)

    def media_segments(self, handler, params=None):
        return self._get("MediaSegments%s" % handler, params)

    def artwork(self, item_id, art, max_width, ext="jpg", index=None):
        params = {"MaxWidth": max_width, "format": ext}
        handler = ("Items/%s/Images/%s" % (item_id, art) if index is None
                   else "items/%s/images/%s/%s" % (item_id, art, index)
                   )

        return self._get_url(handler, params)

    def audio_url(self, item_id, container=None, audio_codec=None, max_streaming_bitrate=140000000):
        params = {
            "UserId": "{UserId}",
            "DeviceId": "{DeviceId}",
            "MaxStreamingBitrate": max_streaming_bitrate,
        }

        if container:
            params["Container"] = container

        if audio_codec:
            params["AudioCodec"] = audio_codec

        return self._get_url("Audio/%s/universal" % item_id, params)

    def video_url(self, item_id, media_source_id=None):
        params = {
            "static": "true",
            "DeviceId": "{DeviceId}"
        }
        if media_source_id is not None:
            params["MediaSourceId"] = media_source_id

        return self._get_url("Videos/%s/stream" % item_id, params)

    def download_url(self, item_id):
        params = {}
        return self._get_url("Items/%s/Download" % item_id, params)

    def image_url(self, item_id, image_type="Primary", index=None, tag=None,
                  max_width=None, fill_width=None, fill_height=None, quality=90):
        """Build an image URL for an item.

        Pass ``fill_width``/``fill_height`` to crop to an exact box, or
        ``max_width`` to scale within a width. ``index`` selects a numbered
        image (e.g. ``Backdrop`` index ``0``).
        """
        handler = "Items/%s/Images/%s" % (item_id, image_type)
        if index is not None:
            handler = "%s/%s" % (handler, index)
        params = {}
        if quality is not None:
            params["quality"] = quality
        if fill_width is not None and fill_height is not None:
            params["fillWidth"] = int(fill_width)
            params["fillHeight"] = int(fill_height)
        elif max_width is not None:
            params["maxWidth"] = int(max_width)
        if tag is not None:
            params["tag"] = tag
        return self._get_url(handler, params)

    def subtitle_url(self, item_id, media_source_id, index, fmt, fmt_index=0):
        """Build the external-subtitle sidecar stream URL for one stream."""
        return self._get_url(
            "Videos/%s/%s/Subtitles/%s/%s/Stream.%s"
            % (item_id, media_source_id, index, fmt_index, fmt), {})

    def trickplay_tile_url(self, item_id, width, index, media_source_id=None):
        """Build the URL for a single trickplay (scrubbing preview) tile."""
        params = {}
        if media_source_id is not None:
            params["MediaSourceId"] = media_source_id
        return self._get_url(
            "Videos/%s/Trickplay/%s/%s.jpg" % (item_id, width, index), params)


class GranularAPIMixin:
    """
    Mixin class containing Jellyfin API granular user-level calls
    """

    def get_users(self):
        return self._get("Users")

    def get_public_users(self):
        return self._get("Users/Public")

    def get_user(self, user_id=None):
        return self.users() if user_id is None else self._get("Users/%s" % user_id)

    def get_user_settings(self, client="emby"):
        return self._get("DisplayPreferences/usersettings", params={
            "userId": "{UserId}",
            "client": client
        })

    def new_user(self, name, pw):
        return self._post("Users/New", {
            "name": name,
            "Password": pw
        })

    def delete_user(self, userID):
        return self._delete(f"Users/{userID}")

    def get_views(self):
        return self.users("/Views")

    def get_media_folders(self, fields=None):
        params = None
        if fields is not None:
            params = {'fields': fields}
        return self.users("/Items", params=params)

    def get_item(self, item_id, fields=None):
        """
        Lookup metadata for an item.

        Args:
            item_id (str): item uuid to lookup metadata for
            fields (str): comma-separated Fields to request; defaults to the
                standard ``info()`` field set.

        Returns:
            Dict[str, Any]: metadata keys and values for the queried item.
        """
        return self.users("/Items/%s" % item_id, params={
            'Fields': fields if fields is not None else info()
        })

    def get_items(self, item_ids):
        """
        Lookup metadata for multiple items.

        Args:
            item_ids (List[str]): item uuids to lookup metadata for

        Returns:
            Dict[str, Any]: A result dictionary where the info from each
                item is stored in the "Items" list.
        """
        return self.users("/Items", params={
            'Ids': ','.join(str(x) for x in item_ids),
            'Fields': info()
        })

    def update_item(self, item_id, data):
        """
        Updates the metadata for an item.

        Requires a user with elevated permissions [UpdateItem]_.

        Args:
            item_id (str): item uuid to update metadata for

            data (Dict): the new information to add to this item.
                Note: any specified items are completely overwritten.

        References:
            .. [UpdateItem] https://api.jellyfin.org/#tag/ItemUpdate/operation/UpdateItem
        """
        # Force us to get the entire original item, we need to pass
        # all information, otherwise all info is overwritten
        body = self.get_item(item_id)
        body.update(data)
        assert body['Id'] == item_id
        return self.items('/' + item_id, action='POST', params=None, json=body)

    def get_sessions(self):
        return self.sessions(params={'ControllableByUserId': "{UserId}"})

    def get_device(self, device_id):
        return self.sessions(params={'DeviceId': device_id})

    def post_session(self, session_id, url, params=None, data=None):
        return self.sessions("/%s/%s" % (session_id, url), "POST", params, data)

    def get_images(self, item_id):
        return self.items("/%s/Images" % item_id)

    def get_suggestion(self, media="Movie,Episode", limit=1):
        return self.users("/Suggestions", params={
            'Type': media,
            'Limit': limit
        })

    def get_recently_added(self, media=None, parent_id=None, limit=20):
        return self.user_items("/Latest", {
            'Limit': limit,
            'UserId': "{UserId}",
            'IncludeItemTypes': media,
            'ParentId': parent_id,
            'Fields': info()
        })

    def get_next(self, index=None, limit=1, series_id=None, fields=None,
                 enable_image_types=None):
        params = {
            'Limit': limit,
            'UserId': "{UserId}",
            'StartIndex': None if index is None else int(index)
        }
        if series_id is not None:
            params['SeriesId'] = series_id
        if fields is not None:
            params['Fields'] = fields
        if enable_image_types is not None:
            params['EnableImageTypes'] = enable_image_types
        return self.shows("/NextUp", params)

    def get_adjacent_episodes(self, show_id, item_id):
        return self.shows("/%s/Episodes" % show_id, {
            'UserId': "{UserId}",
            'AdjacentTo': item_id,
            'Fields': "Overview"
        })

    def get_season(self, show_id, season_id):
        return self.shows("/%s/Episodes" % show_id, {
            'UserId': "{UserId}",
            'SeasonId': season_id
        })

    def get_episodes(self, series_id, season_id=None, start_item_id=None,
                     fields=None, limit=None):
        """Episodes for a series, optionally scoped to one season or starting
        at a given episode (``start_item_id`` crosses season boundaries)."""
        params = {'UserId': "{UserId}"}
        if season_id is not None:
            params['SeasonId'] = season_id
        if start_item_id is not None:
            params['StartItemId'] = start_item_id
        if fields is not None:
            params['Fields'] = fields
        if limit is not None:
            params['Limit'] = limit
        return self.shows("/%s/Episodes" % series_id, params)

    def get_genres(self, parent_id=None):
        return self._get("Genres", {
            'ParentId': parent_id,
            'UserId': "{UserId}",
            'Fields': info()
        })

    def get_recommendation(self, parent_id=None, limit=20):
        return self._get("Movies/Recommendations", {
            'ParentId': parent_id,
            'UserId': "{UserId}",
            'Fields': info(),
            'Limit': limit
        })

    def get_items_by_letter(self, parent_id=None, media=None, letter=None):
        return self.user_items(params={
            'ParentId': parent_id,
            'NameStartsWith': letter,
            'Fields': info(),
            'Recursive': True,
            'IncludeItemTypes': media
        })

    def search_media_items(self, term=None, year=None, media=None, limit=20, parent_id=None):
        """
        Description:
            Search for media using terms, production year(s) and media type

        Args:
            term (str):
            year (int):
            media (str):
            limit (int):
            parent_id (str):

        Returns:
            dict

        Example:
            >>> result = client.jellyfin.search_media_items(term='The Lion King', year=1994, media='Movie', limit=1)
            >>> result['Items']
            [
                {
                    'Name': 'The Lion King',
                    ...
                    'ProductionYear': 1994
                    ...
                    'Type': 'Movie'
                }
            ]
        """
        return self.user_items(params={
            'searchTerm': term,
            'years': year,
            'Recursive': True,
            'IncludeItemTypes': media,
            'Limit': limit,
            'parentId': parent_id,
        })

    def get_channels(self):
        return self._get("LiveTv/Channels", {
            'UserId': "{UserId}",
            'EnableImages': True,
            'EnableUserData': True
        })

    def get_intros(self, item_id):
        return self.user_items("/%s/Intros" % item_id)

    def get_additional_parts(self, item_id):
        return self.videos("/%s/AdditionalParts" % item_id)

    def get_media_segments(self, item_id):
        return self.media_segments("/%s" % item_id)

    def delete_item(self, item_id):
        return self.items("/%s" % item_id, "DELETE")

    def get_local_trailers(self, item_id):
        return self.user_items("/%s/LocalTrailers" % item_id)

    def get_transcode_settings(self):
        return self._get('System/Configuration/encoding')

    def get_ancestors(self, item_id):
        return self.items("/%s/Ancestors" % item_id, params={
            'UserId': "{UserId}"
        })

    def get_items_theme_video(self, parent_id):
        return self.users("/Items", params={
            'HasThemeVideo': True,
            'ParentId': parent_id
        })

    def get_themes(self, item_id):
        return self.items("/%s/ThemeMedia" % item_id, params={
            'UserId': "{UserId}",
            'InheritFromParent': True
        })

    def get_items_theme_song(self, parent_id):
        return self.users("/Items", params={
            'HasThemeSong': True,
            'ParentId': parent_id
        })

    def get_plugins(self):
        return self._get("Plugins")

    def check_companion_installed(self):
        try:
            self._get("/Jellyfin.Plugin.KodiSyncQueue/GetServerDateTime")
            return True
        except Exception:
            return False

    def get_seasons(self, show_id):
        return self.shows("/%s/Seasons" % show_id, params={
            'UserId': "{UserId}",
            'EnableImages': True,
            'Fields': info()
        })

    def get_date_modified(self, date, parent_id, media=None):
        return self.users("/Items", params={
            'ParentId': parent_id,
            'Recursive': False,
            'IsMissing': False,
            'IsVirtualUnaired': False,
            'IncludeItemTypes': media or None,
            'MinDateLastSaved': date,
            'Fields': info()
        })

    def get_userdata_date_modified(self, date, parent_id, media=None):
        return self.users("/Items", params={
            'ParentId': parent_id,
            'Recursive': True,
            'IsMissing': False,
            'IsVirtualUnaired': False,
            'IncludeItemTypes': media or None,
            'MinDateLastSavedForUser': date,
            'Fields': info()
        })

    def get_userdata_for_item(self, item_id):
        return self._get(
            f"UserItems/{item_id}/UserData", params={"UserId": "{UserId}"}
        )

    def update_userdata_for_item(self, item_id, data):
        """
        Updates the userdata for an item.

        Args:
            item_id (str): item uuid to update userdata for

            data (dict): the information to add to the current user's
                userdata for the item. Any fields in data overwrite the
                equivalent fields in UserData, other UserData fields are
                left untouched.

        References:
            .. [UpdateItemUserData] https://api.jellyfin.org/#tag/Items/operation/UpdateItemUserData
        """
        return self._post(f"UserItems/{item_id}/UserData", params={"UserId": "{UserId}"}, json=data)


    def refresh_item(self, item_id, recursive=True, image_refresh='FullRefresh', metadata_refresh='FullRefresh', replace_images=False, replace_metadata=True, preset=None):
        """
        Description:

            - Refreshes media items on server. Pass a single item or pass multiple as a list.
            - Use of presets lets you run a refresh similar to Jellyfin's Web UI.
            - preset='missing' searches for missing metadata, while preset='replace' replaces all metadata.
            - You may also configure the refresh manually by passing a value for each parameter.

        Args:
            item_id (str | List[str]): one or more items to refresh
            recursive (bool):
            image_refresh (str):  'Default' or 'ValidationOnly' or 'FullRefresh'
            image_refresh (str): 'Default' or 'ValidationOnly' or 'FullRefresh'
            replace_images (bool):
            replace_metadata (bool)
            preset (str): 'missing' or 'replace'

        Examples:
            >>> client.jellyfin.refresh_item('123456abcd', preset='missing')
            -
            >>> client.jellyfin.refresh_item(['123456abcd', 'abcd123456'])
        """

        # Presets modeled after Jellyfin's Web UI
        if preset:
            if preset.lower() == 'missing':
                recursive = True
                image_refresh = 'FullRefresh'
                metadata_refresh = 'FullRefresh'
                replace_images = False
                replace_metadata = False
            elif preset.lower() == 'replace':
                recursive = True
                image_refresh = 'FullRefresh'
                metadata_refresh = 'FullRefresh'
                replace_images = True
                replace_metadata = True

        params = {
            'Recursive': recursive,
            'ImageRefreshMode': image_refresh,
            'MetadataRefreshMode': metadata_refresh,
            'ReplaceAllImages': replace_images,
            'ReplaceAllMetadata': replace_metadata
        }

        # If item_id is a list, loop through each item and refresh it
        if isinstance(item_id, list):
            results = []
            for i in item_id:
                result = self.items("/%s/Refresh" % i, "POST", params=params)
                results.append(result)
            return results
        else:
            # If item_id is a single string, just refresh that item
            return self.items("/%s/Refresh" % item_id, "POST", params=params)

    def favorite(self, item_id, option=True):
        return self.users("/FavoriteItems/%s" % item_id, "POST" if option else "DELETE")

    def get_system_info(self):
        """Returns configuration for legacy reasons, not System/Info, use get_system_info_new for that"""
        return self._get("System/Configuration")

    def get_system_info_new(self):
        """Actual System/Info API call"""
        return self._get("System/Info")

    def get_system_configuration(self):
        return self._get("System/Configuration")

    def get_server_logs(self):
        """
        Returns:
            List[Dict] - list of information about available log files

        References:
            .. [GetServerLogs] https://api.jellyfin.org/#tag/System/operation/GetServerLogs
        """
        return self._get("System/Logs")

    def get_log_entries(self, startIndex=None, limit=None, minDate=None, hasUserId=None):
        """
        Returns a list of recent log entries

        Returns:
            Dict: with main key "Items"
        """
        params = {}
        if limit is not None:
            params['limit'] = limit
        if startIndex is not None:
            params['startIndex'] = startIndex
        if minDate is not None:
            params['minDate'] = minDate
        if hasUserId is not None:
            params['hasUserId'] = hasUserId
        return self._get("System/ActivityLog/Entries", params=params)

    def post_capabilities(self, data):
        return self.sessions("/Capabilities/Full", "POST", json=data)

    def session_add_user(self, session_id, user_id, option=True):
        return self.sessions("/%s/Users/%s" % (session_id, user_id), "POST" if option else "DELETE")

    def session_playing(self, data):
        return self.sessions("/Playing", "POST", json=data)

    def session_progress(self, data):
        return self.sessions("/Playing/Progress", "POST", json=data)

    def session_stop(self, data):
        return self.sessions("/Playing/Stopped", "POST", json=data)

    def remote_pause(self, id):
        return self.remote(id, "Pause")

    def remote_playpause(self, id):
        return self.remote(id, "PlayPause")

    def remote_seek(self, id, ticks, params={}, json={}):
        """
        Seek to a specific position in the specified session.

        Args:
            id (int): The session id to control
            ticks (int): The position (in ticks) to seek to
        """
        return self.remote(
            id, "Seek", params={"seekPositionTicks": ticks, **params}, json=json
        )

    def remote_stop(self, id):
        return self.remote(id, "Stop")

    def remote_unpause(self, id):
        return self.remote(id, "Unpause")

    def remote_play_media(
        self, id: str, item_ids: List[str], command: str = "PlayNow", params={}, json={}
    ):
        """Instruct the session to play some media

        Args:
            id (str): The session id to control
            item_ids (List[str]): A list of items to play
            command (str): When to play. (*PlayNow*, PlayNext, PlayLast, PlayInstantMix, PlayShuffle)
        """
        return self.remote(
            id,
            None,
            json=json,
            params={"playCommand": command, "itemIds": item_ids, **params},
        )

    def remote_set_volume(self, id: str, volume: int, json={}):
        """
        Set the volume on the sessions.

        Args:
            id (int): The session id to control
            volume (int): The volume normalized from 0 to 100
        """
        return self.command(id, "SetVolume", json={"Volume": volume, **json})

    def remote_mute(self, id):
        return self.command(id, "Mute")

    def remote_unmute(self, id):
        return self.command(id, "Unmute")

    def item_played(self, item_id, watched, date=None):
        params = {}
        if watched and date is not None:
            params["datePlayed"] = date
        return self.users("/PlayedItems/%s" % item_id, "POST" if watched else "DELETE", params=params)

    def get_sync_queue(self, date, filters=None):
        return self._get("Jellyfin.Plugin.KodiSyncQueue/{UserId}/GetItems", params={
            'LastUpdateDT': date,
            'filter': filters or None
        })

    def get_server_time(self):
        return self._get("Jellyfin.Plugin.KodiSyncQueue/GetServerDateTime")

    def get_play_info(self, item_id, profile=None, aid=None, sid=None, start_time_ticks=None, is_playback=True, media_source_id=None):
        args = {
            'UserId': "{UserId}",
            'AutoOpenLiveStream': is_playback,
            'IsPlayback': is_playback
        }
        if profile is not None:
            args['DeviceProfile'] = profile
        if sid:
            args['SubtitleStreamIndex'] = sid
        if aid:
            args['AudioStreamIndex'] = aid
        if start_time_ticks:
            args['StartTimeTicks'] = start_time_ticks
        if media_source_id:
            args['MediaSourceId'] = media_source_id

        return self.items("/%s/PlaybackInfo" % item_id, "POST", json=args)

    def get_live_stream(self, item_id, play_id, token, profile):
        return self._post("LiveStreams/Open", json={
            'UserId': "{UserId}",
            'DeviceProfile': profile,
            'OpenToken': token,
            'PlaySessionId': play_id,
            'ItemId': item_id
        })

    def close_live_stream(self, live_id):
        return self._post("LiveStreams/Close", json={
            'LiveStreamId': live_id
        })

    def close_transcode(self, device_id, play_session_id):
        return self._delete("Videos/ActiveEncodings", params={
            'DeviceId': device_id,
            'PlaySessionId': play_session_id,
        })

    def get_audio_stream(self, dest_file, item_id, play_id, container, max_streaming_bitrate=140000000, audio_codec=None):
        self._get_stream("Audio/%s/universal" % item_id, dest_file, params={
            'UserId': "{UserId}",
            'DeviceId': "{DeviceId}",
            'PlaySessionId': play_id,
            'Container': container,
            'AudioCodec': audio_codec,
            "MaxStreamingBitrate": max_streaming_bitrate,
        })

    def get_default_headers(self):
        return self.client._get_default_headers(content_type="application/x-www-form-urlencoded; charset=UTF-8")

    def send_request(self, url, path, method="get", timeout=None, headers=None, data=None, session=None):
        request_method = getattr(session or requests, method.lower())
        url = "%s/%s" % (url, path)
        request_settings = {
            "timeout": timeout or self.default_timeout,
            "headers": headers or self.get_default_headers(),
            "data": data
        }

        # Changed to use non-Kodi specific setting.
        if self.config.data.get('auth.ssl') is False:
            request_settings["verify"] = False

        LOG.info("Sending %s request to %s" % (method, path))
        LOG.debug(request_settings['timeout'])
        LOG.debug(request_settings['headers'])

        return request_method(url, **request_settings)

    def login(self, server_url, username, password="", session=None):
        path = "Users/AuthenticateByName"
        authData = {
                    "username": username,
                    "Pw": password
                }

        headers = self.get_default_headers()
        headers.update({'Content-type': "application/json"})

        try:
            LOG.info("Trying to login to %s/%s as %s" % (server_url, path, username))
            response = self.send_request(server_url, path, method="post", headers=headers,
                                         data=json.dumps(authData), timeout=(5, 30), session=session)

            if response.status_code == 200:
                return response.json()
            else:
                LOG.error("Failed to login to server with status code: " + str(response.status_code))
                LOG.error("Server Response:\n" + str(response.content))
                LOG.debug(headers)

                return {}
        except Exception as e:  # Find exceptions for likely cases i.e, server timeout, etc
            LOG.error(e)

        return {}

    def quick_connect_enabled(self, server_url, session=None):
        """Return whether Quick Connect is enabled on the server."""
        try:
            response = self.send_request(
                server_url, "QuickConnect/Enabled", session=session
            )
            if response.status_code == 200:
                return bool(response.json())
            LOG.error(
                "Failed to query Quick Connect status: " + str(response.status_code)
            )
        except Exception as e:
            LOG.error(e)

        return False

    def quick_connect_initiate(self, server_url, session=None):
        """Start a Quick Connect request, returning a dict with Secret and Code."""
        headers = self.get_default_headers()

        try:
            response = self.send_request(
                server_url, "QuickConnect/Initiate", method="post",
                headers=headers, timeout=(5, 30), session=session
            )
            if response.status_code == 200:
                return response.json()
            LOG.error(
                "Failed to initiate Quick Connect: " + str(response.status_code)
            )
        except Exception as e:
            LOG.error(e)

        return {}

    def quick_connect_state(self, server_url, secret, session=None):
        """Return the current state of a Quick Connect request as a dict.

        The returned dict's ``Authenticated`` field indicates whether the user
        has approved the request yet.
        """
        path = "QuickConnect/Connect?secret=" + quote(secret, safe="")

        try:
            response = self.send_request(server_url, path, session=session)
            if response.status_code == 200:
                return response.json()
            LOG.error(
                "Failed to query Quick Connect state: " + str(response.status_code)
            )
        except Exception as e:
            LOG.error(e)

        return {}

    def login_with_quick_connect(self, server_url, secret, session=None):
        """Exchange an authorized Quick Connect secret for an AuthenticationResult.

        Returns the same payload shape as ``login()`` (AccessToken, User, ...)
        on success, or an empty dict on failure.
        """
        path = "Users/AuthenticateWithQuickConnect"
        authData = {"Secret": secret}

        headers = self.get_default_headers()
        headers.update({'Content-type': "application/json"})

        try:
            response = self.send_request(
                server_url, path, method="post", headers=headers,
                data=json.dumps(authData), timeout=(5, 30), session=session
            )
            if response.status_code == 200:
                return response.json()
            LOG.error(
                "Failed to authenticate with Quick Connect, status code: "
                + str(response.status_code)
            )
            LOG.error("Server Response:\n" + str(response.content))
        except Exception as e:
            LOG.error(e)

        return {}

    def validate_authentication_token(self, server, session=None):
        headers = self.get_default_headers()
        comma = "," if "app.device_name" in self.config.data else ""
        headers["Authorization"] += f"{comma} Token=\"{server['AccessToken']}\""

        response = self.send_request(server['address'], "system/info", headers=headers, session=session)
        return response.json() if response.status_code == 200 else {}

    def get_public_info(self, server_address, session=None):
        response = self.send_request(server_address, "system/info/public", session=session)
        return response.json() if response.status_code == 200 else {}

    def check_redirect(self, server_address, session=None):
        ''' Checks if the server is redirecting traffic to a new URL and
        returns the URL the server prefers to use
        '''
        response = self.send_request(server_address, "system/info/public", session=session)
        url = response.url.replace('/system/info/public', '')
        return url


class SyncPlayAPIMixin:
    """
    Mixin class containing Jellyfin API calls related to Syncplay
    """

    def _parse_precise_time(self, time):
        # We have to remove the Z and the least significant digit.
        return datetime.strptime(time[:-2], "%Y-%m-%dT%H:%M:%S.%f")

    def utc_time(self):
        # Measure time as close to the call as is possible.
        server_address = self.config.data.get("auth.server")
        session = self.client.session

        response = self.send_request(server_address, "GetUTCTime", session=session)
        response_received = datetime.utcnow()
        request_sent = response_received - response.elapsed

        response_obj = response.json()
        request_received = self._parse_precise_time(response_obj["RequestReceptionTime"])
        response_sent = self._parse_precise_time(response_obj["ResponseTransmissionTime"])

        return {
            "request_sent": request_sent,
            "request_received": request_received,
            "response_sent": response_sent,
            "response_received": response_received
        }

    def get_sync_play(self, item_id=None):
        params = {}
        if item_id is not None:
            params["FilterItemId"] = item_id
        return self._get("SyncPlay/List", params)

    def join_sync_play(self, group_id):
        return self._post("SyncPlay/Join", {
            "GroupId": group_id
        })

    def leave_sync_play(self):
        return self._post("SyncPlay/Leave")

    def play_sync_play(self):
        """deprecated (<= 10.7.0)"""
        return self._post("SyncPlay/Play")

    def pause_sync_play(self):
        return self._post("SyncPlay/Pause")

    def unpause_sync_play(self):
        """10.7.0+ only"""
        return self._post("SyncPlay/Unpause")

    def seek_sync_play(self, position_ticks):
        return self._post("SyncPlay/Seek", {
            "PositionTicks": position_ticks
        })

    def buffering_sync_play(self, when, position_ticks, is_playing, item_id):
        return self._post("SyncPlay/Buffering", {
            "When": when.isoformat() + "Z",
            "PositionTicks": position_ticks,
            "IsPlaying": is_playing,
            "PlaylistItemId": item_id
        })

    def ready_sync_play(self, when, position_ticks, is_playing, item_id):
        """10.7.0+ only"""
        return self._post("SyncPlay/Ready", {
            "When": when.isoformat() + "Z",
            "PositionTicks": position_ticks,
            "IsPlaying": is_playing,
            "PlaylistItemId": item_id
        })

    def reset_queue_sync_play(self, queue_item_ids, position=0, position_ticks=0):
        """10.7.0+ only"""
        return self._post("SyncPlay/SetNewQueue", {
            "PlayingQueue": queue_item_ids,
            "PlayingItemPosition": position,
            "StartPositionTicks": position_ticks
        })

    def ignore_sync_play(self, should_ignore):
        """10.7.0+ only"""
        return self._post("SyncPlay/SetIgnoreWait", {
            "IgnoreWait": should_ignore
        })

    def next_sync_play(self, item_id):
        """10.7.0+ only"""
        return self._post("SyncPlay/NextItem", {
            "PlaylistItemId": item_id
        })

    def prev_sync_play(self, item_id):
        """10.7.0+ only"""
        return self._post("SyncPlay/PreviousItem", {
            "PlaylistItemId": item_id
        })

    def set_item_sync_play(self, item_id):
        """10.7.0+ only"""
        return self._post("SyncPlay/SetPlaylistItem", {
            "PlaylistItemId": item_id
        })

    def ping_sync_play(self, ping):
        return self._post("SyncPlay/Ping", {
            "Ping": ping
        })

    def new_sync_play(self):
        """deprecated (< 10.7.0)"""
        return self._post("SyncPlay/New")

    def new_sync_play_v2(self, group_name):
        """10.7.0+ only"""
        return self._post("SyncPlay/New", {
            "GroupName": group_name
        })


class ExperimentalAPIMixin:
    """
    This is a location for testing proposed additions to the API Client.
    """

    def identify(self, item_id, name=None, provider_ids=None, year=None, replaceAllImages=True):
        """
        Applies search criteria to an item and refreshes metadata.

        This method requires an authenticated user with elevated permissions
        [RequiresElevation]_.

        Args:
            item_id (str): item uuid to identify and update metadata for.

            name (str):
                name for the identified item
            provider_ids (dict):
                maps providers to the content id. (E.g. {"Imdb": "tt1254207"})
                Valid keys will depend on available providers. Common ones are:
                    "Tvdb" and "Imdb".
            year (int):
                production year for the identified idem
            replaceAllImages(bool):
                whether all images should be replaced by default

        References:
            .. [ApplySearchCriteria] https://api.jellyfin.org/#tag/ItemLookup/operation/ApplySearchCriteria
        """

        data = {
            'Name': name, 
            'ProviderIds': provider_ids, 
            'ProductionYear': year
        }
        params = {
            'replaceAllImages': replaceAllImages
        }

        return self._post(
            f'Items/RemoteSearch/Apply/{item_id}', params=params, json=data
        )


    def get_now_playing(self, session_id):
        """
        Simplified API to get now playing information for a session including the
        play state.

        References:
            https://github.com/jellyfin/jellyfin/issues/9665
        """
        resp = self.sessions(params={
            'Id': session_id,
            'fields': ['PlayState']
        })
        found = None
        for item in resp:
            if item['Id'] == session_id:
                found = item
        if not found:
            raise KeyError(f'No session_id={session_id}')
        play_state = found['PlayState']
        now_playing = found.get('NowPlayingItem', None)
        if now_playing is None:
            # handle case if nothing is playing
            now_playing = {'Name': None}
        now_playing['PlayState'] = play_state
        return now_playing

    @staticmethod
    def _coerce_image_bytes(image_data) -> bytes:
        """
        Transform data into a common b64 representation with associated mime
        type
        """
        import os
        import base64
        import mimetypes

        image_bytes = None

        # It doesn't seem to matter which image mimetype we choose
        mimetype = 'image/jpeg'

        if isinstance(image_data, (str, os.PathLike)):
            file_path = image_data

            mimetype, encoding = mimetypes.guess_type(file_path)

            with open(file_path, 'rb') as f:
                raw_data = f.read()
                img_bytes1 = base64.b64encode(raw_data)

            image_bytes = img_bytes1
        elif isinstance(image_data, bytes):
            image_bytes = image_data

        if image_bytes is None:
            raise Exception("unable to construct image bytes")

        return image_bytes, mimetype

    def set_item_image(self, item_id, image_data, image_type='Primary',
                       mimetype='auto'):
        """
        Args:
            item_id (str): item to set the image of

            image_data (str | PathLike | bytes):
                A path to an image on disk or raw bytes of an image.

            image_type (str): A valid image type. I.e. one of
                'Primary', 'Art', 'Backdrop', 'Banner', 'Logo', 'Thumb',
                'Disc', 'Box', 'Screenshot', 'Menu', 'Chapter', 'BoxRear',
                'Profile'.

            mimetype (str): if "auto", attempt to infer the mimetype.
                falls back to image/jpeg if unable. Otherwise this is used.

        References:
            .. [SetItemImageByIndex] https://api.jellyfin.org/#tag/Image/operation/SetItemImageByIndex
        """
        from jellyfin_apiclient_python.constants import ImageType

        image_bytes, auto_mimetype = self._coerce_image_bytes(image_data)

        if mimetype == 'auto':
            mimetype = auto_mimetype

        if image_type not in ImageType:
            raise KeyError(f'image_type must be one of: {ImageType}')

        data = image_bytes.decode()

        # Overriding headers are important for this call
        headers = {
            'Accept': '*/*',
            'Content-type': mimetype,
        }
        resp = self.items(f'/{item_id}/Images/{image_type}', action='POST',
                          data=data, headers=headers)
        return resp

    def set_user_image(self, user_id, image_data, mimetype='auto'):
        """
        Args:
            item_id (str): user id to set the image for

            image_data (str | PathLike | bytes):
                A path to an image on disk or raw bytes of an image.

            mimetype (str): if "auto", attempt to infer the mimetype.
                falls back to image/jpeg if unable. Otherwise this is used.

        References:
            .. [PostUserImage] https://api.jellyfin.org/#tag/Image/operation/PostUserImage
        """
        image_bytes, auto_mimetype = self._coerce_image_bytes(image_data)

        if mimetype == 'auto':
            mimetype = auto_mimetype

        data = image_bytes.decode()
        # Overriding headers are important for this call
        headers = {
            'Accept': '*/*',
            'Content-type': mimetype,
        }
        resp = self._post("/UserImage", params={'user_id': user_id}, data=data,
                          headers=headers)
        return resp


class CollectionAPIMixin:
    """
    Methods for creating and modifying collections.

    Note: there does not seem to be an API endpoint for removing a collection.
    """

    def get_collection_folders(self, term=None):
        """
        Queries for top-level default collections

        I.e. Movies, Music, Shows, Collections, Playlists, etc...

        Returns:
            Dict: pagenated result with key "Items"
        """
        from jellyfin_apiclient_python.constants import ItemType
        # For whatever reason, including search term in the query does nothing.
        # Furthermore, recursive has to base False, and the basic Music,
        # Collections, Movies, Playlists folders are always returned.
        result = self.user_items(params={
            'recursive': False,
            # 'searchTerm': term,
            'includeItemTypes': [ItemType.COLLECTION_FOLDER],
        })
        items = result['Items']
        if term is not None:
            # manual filter
            name_lower = term.lower()
            items = [item for item in items if name_lower in item['Name'].lower()]
        result['Items'] = items
        return result

    def get_collections(self, term=None):
        """
        Queries for user-created collections

        Args:
            term (str): query string to match

        Returns:
            Dict: pagenated result with key "Items"
        """
        # note: pagenation not yet implemented
        from jellyfin_apiclient_python.constants import ItemType
        result = self.user_items(params={
            'recursive': True,
            'searchTerm': term,
            'includeItemTypes': [ItemType.BOX_SET],
        })
        return result

    def delete_collection(self, item_id=None, name=None):
        """
        Delete a collection by name or ID.

        This is mostly a wraper around delete_item, but with additional safety
        checks that ensure the item you are deleting is a collection.
        """
        if bool(name) ^ bool(item_id):
            raise ValueError('Exactly one of item_id or name must be given')

        results = self.user_items(params={'searchTerm': name, 'recursive': True})
        items = results['Items']

        if name is not None:
            # Filter to a case insensitive exact name match
            lower_name = name.lower()
            items = [item for item in items if item['Name'].lower() == lower_name]

        if len(items) == 0:
            raise Exception('No items matched the given input')
        assert len(items) == 1, 'filtered to length 0 or 1'
        item = items[0]

        # It looks like what the UI calls collections are called box sets in
        # the backend.  there is a collection type, but these seem to be for
        # special groups like Music / Movies that should likely not be deleted.
        collection_types = {'BoxSet'}

        if item['Type'] not in collection_types:
            raise ValueError('Given item={item} is not a collection')

        return self.delete_item(item['Id'])

    def new_collection(self, name, item_ids=None, parent_id=None, is_locked=False):
        """
        Create a new collection, or search for a collection with a given name.

        Args:
            name (str):
                Name of the collection to create or lookup

            item_ids (List[str] | None):
                List of item ids to initialize the collection with.

            parent_id (str | None):
                Create the collection within a specific folder.

            is_locked (str | None):
                Whether or not to lock the new collection.

        Returns:
            Dict:
                with one entry: "Id", which contains the id of the new or found
                collection.

        References:
            .. [CreateCollection] https://api.jellyfin.org/#tag/Collection/operation/CreateCollection
        """
        params = {}
        params['name'] = name
        params['isLocked'] = is_locked
        json = {}
        if parent_id is not None:
            params['parentId'] = parent_id
        if item_ids is not None:
            params['ids'] = item_ids
        return self._post("Collections", json, params)

    def add_collection_items(self, collection_id, item_ids):
        """
        Adds items to a collection.

        Args:
            collection_id (str):
                Id of the collection to add items to.

            item_ids (List[str]):
                List of item ids to add to the collection.

        References:
            .. [AddToCollection] https://api.jellyfin.org/#tag/Collection/operation/AddToCollection
        """
        params = {}
        json = {}
        params['ids'] = ','.join(item_ids)
        return self._post(f"Collections/{collection_id}/Items", json, params)

    def remove_collection_items(self, collection_id, item_ids=None):
        """
        Removes items from a collection.

        Args:
            collection_id (str):
                Id of the collection to remove items from.

            item_ids (List[str]):
                List of item ids to remove from the collection.

        References:
            .. [RemoveFromCollection] https://api.jellyfin.org/#tag/Collection/operation/RemoveFromCollection
        """
        params = {}
        json = {}
        params['ids'] = ','.join(item_ids)
        return self._delete(f"Collections/{collection_id}/Items", json, params)


class BackupAPIMixin:
    """
    Methods for creating, restoring and listing system backups

    10.11.0+ only

    Note: backup manifests are dictionary objects with the following structure:

        {
            "ServerVersion": "string",
            "BackupEngineVersion": "string",
            "DateCreated": "2019-08-24T14:15:22Z",
            "Path": "string",
            "Options": {
                "Metadata": true,
                "Trickplay": true,
                "Subtitles": true,
                "Database": true
            }
        }

    """

    def create_backup(
        self,
        include_metadata: bool = False,
        include_subtitles: bool = False,
        include_trickplay: bool = False,
    ):
        """
        Creates a new backup including the database and any of the optional
        elements requested. If metadata is included, this can be a time-consuming
        operation.

        Args:
            include_metadata (bool)
                Whether or not to include metadata in the backup

            include_subtitles (bool)
                Whether or not to include subtitles in the backup

            include_trickplay (bool)
                Whether or not to include trickplay information in the backup

        Returns:
            The manifest of the backup if it was created, an empty directory
            otherwise.

        """
        params = {
            "database": True,
            "metadata": include_metadata,
            "subtitles": include_subtitles,
            "trickplay": include_trickplay,
        }
        return self._post("Backup/Create", params)

    def get_backup_manifest(self, path: str):
        """
        Gets the manifest of a backup at the path passed

        Args:
            path (str):
                Location of the backup

        Returns:
            The backup's manifest, if the backup exists, an empty dictionary
            otherwise.

        References:
            .. [BackupManifest] https://api.jellyfin.org/#tag/Backup/operation/GetBackup
        """
        params = {"path": path}
        return self._get("Backup/Manifest", params)

    def get_backups(self):
        """
        Gets a list of all currently present backups in the backup directory.

        Args:
            None

        Returns:
            List of manifests for the backup that exist, one per backup.

        References:
            .. [Backup] https://api.jellyfin.org/#tag/Backup/operation/ListBackups
        """
        return self._get("Backup")

    def restore_backup(self, backup_name: str):
        """
        Starts the process of restoring a backup with the name passed.

        Args:
            backup_name (str)
                The name of the backup archive, which must exist in the
                backups directory for the restore process to start

        Returns:
            True if the restore process started, False otherwise

        """
        params = {"ArchiveFileName": backup_name}
        try:
            # This post call returns a 204 to indicate that the restore has
            # been requested, but the client ingests the non-error code
            # and the _post() call returns nothing.
            self._post("Backup/Restore", params)
            return True

        except Exception as e:
            LOG.error(e)

        return False


class API(
    InternalAPIMixin,
    BiggerAPIMixin,
    GranularAPIMixin,
    SyncPlayAPIMixin,
    ExperimentalAPIMixin,
    CollectionAPIMixin,
    BackupAPIMixin,
):
    """
    The Jellyfin Python API client containing all api calls to the server.

    This class implements a subset of the [JellyfinWebAPI]_.

    References:
        .. [JellyfinWebAPI] https://api.jellyfin.org/

    Example:
        >>> from jellyfin_apiclient_python import JellyfinClient
        >>> client = JellyfinClient()
        >>> #
        >>> client.config.app(
        >>>     name='your_brilliant_app',
        >>>     version='0.0.1',
        >>>     device_name='machine_name',
        >>>     device_id='unique_id')
        >>> client.config.data["auth.ssl"] = True
        >>> #
        >>> your_jellyfin_url = 'http://127.0.0.1:8096'  # Use your jellyfin IP / port
        >>> your_jellyfin_username = 'jellyfin'          # Use your jellyfin userid
        >>> your_jellyfin_password = ''                  # Use your user's password
        >>> #
        >>> client.auth.connect_to_address(your_jellyfin_url)
        >>> client.auth.login(
        >>>     server_url=your_jellyfin_url,
        >>>     username=your_jellyfin_username,
        >>>     password=your_jellyfin_password
        >>> )
        >>> #
        >>> # Test basic calls
        >>> system_info = client.jellyfin.get_system_info()
        >>> print(system_info)
        >>> media_folders = client.jellyfin.get_media_folders()
        >>> print(media_folders)
    """

    def __init__(self, client, *args, **kwargs):
        """
        Args:
            client (jellyfin_apiclient_python.client.JellyfinClient): the client object
            *args: unused
            **kwargs: unused
        """
        self.client = client
        self.config = client.config
        self.default_timeout = 5
