# Copyright (C) 2023,2024,2025,2026 Giovanni Fulco
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

# this should contain all methods which interact directly with the subsonic server

from subsonic_connector.connector import Connector
from subsonic_connector.list_type import ListType
from subsonic_connector.response import Response
from subsonic_connector.album_list import AlbumList
from subsonic_connector.album import Album
from subsonic_connector.artist import Artist
from subsonic_connector.song import Song
from subsonic_connector.search_result import SearchResult
from subsonic_connector.playlist_entry import PlaylistEntry

import cache_actions
from tag_type import TagType
from element_type import ElementType
from artist_from_album import ArtistFromAlbum
from typing import Optional
from album_metadata import AlbumMetadata
from song_data_structures import SongArtist
from song_data_structures import SongContributor
from song_data_structures import SongArtistType
from common_data_structures import ArtistIdNameCoverArt
from album_property_key import AlbumPropertyKey
from metadata_model import AlbumMetadataModel
import audio_codec

import request_cache
import connector_provider
import cache_manager_provider

import album_util
import upnp_util
import config
import persistence
import persistence_constants
import upmpdmeta

import secrets
import constants
import requests
import mimetypes
import glob
import copy
import os
import time
import datetime

import musicbrainzutils

from functools import cmp_to_key
from typing import Callable
from typing import Any
from enum import Enum
from song_info import SongInfo
from disc_title import DiscTitle
from msgproc_provider import msgproc


class __StreamingDictKey(Enum):
    DICT_KEY_BITDEPTH = "BIT_DEPTH"
    DICT_KEY_SAMPLERATE = "SAMPLE_RATE"
    DICT_KEY_SUFFIX = "SUFFIX"
    DICT_KEY_BITRATE = "BITRATE"


def convert_album_properties(album_properties: dict[str, list[Any]]) -> dict[AlbumMetadataModel, Any]:
    upd_dict: dict[AlbumMetadataModel, Any] = {}
    if AlbumPropertyKey.LOSSLESS_STATUS.property_key in album_properties:
        v: str = album_properties[AlbumPropertyKey.LOSSLESS_STATUS.property_key][0]
        lossless_status: audio_codec.LosslessStatus = audio_codec.get_lossless_status_by_value(v=v)
        if lossless_status:
            upd_dict[AlbumMetadataModel.ALBUM_LOSSLESS_STATUS] = lossless_status.value
    quality_badge: str = (album_properties[AlbumPropertyKey.QUALITY_BADGE.property_key][0]
                          if AlbumPropertyKey.QUALITY_BADGE.property_key in album_properties else None)
    if quality_badge:
        upd_dict[AlbumMetadataModel.QUALITY_BADGE] = quality_badge
    return upd_dict


def build_album_properties(album: Album) -> dict[str, list[Any]]:
    res: dict[str, list[str]] = {}
    # has musicbrainz?
    mb_id: str = get_album_musicbrainz_id(album=album)
    res[AlbumPropertyKey.HAS_MUSICBRAINZ.property_key] = ["yes" if mb_id and len(mb_id) > 0 else "no"]
    cover_art: str = album.getCoverArt()
    res[AlbumPropertyKey.HAS_COVER_ART.property_key] = ["yes" if cover_art and len(cover_art) > 0 else "no"]
    genres: list[str] = album.getGenres()
    res[AlbumPropertyKey.GENRE.property_key] = list(set(genres))
    year: int = album.getYear()
    res[AlbumPropertyKey.MOOD.property_key] = list(set(get_album_moods(album=album)))
    if year:
        res[AlbumPropertyKey.YEAR.property_key] = [str(year)]
        res[AlbumPropertyKey.DECADE.property_key] = [__get_decade(year)]
    # label
    res[AlbumPropertyKey.LABEL.property_key] = get_album_record_label_names(album=album)
    # label (initial)
    res[AlbumPropertyKey.LABEL_INITIAL.property_key] = [x[0:1] for x in get_album_record_label_names(album=album)]
    # artists
    album_artist_set: set[str] = set()
    album_artist_set.add(get_album_display_artist(album=album))
    album_artist_set.update([x.artist_name for x in get_artists_from_album(
        album=album,
        item_key=constants.ItemKey.ALBUM_ARTISTS)])
    album_artist_set.update([x.artist_name for x in get_artists_from_album(
        album=album,
        item_key=constants.ItemKey.ARTISTS)])
    # at this point I can just extract the album artist list
    res[AlbumPropertyKey.ALBUM_ARTIST.property_key] = list(album_artist_set)
    # release type
    release_types: list[str] = get_album_release_types(album=album).types
    res[AlbumPropertyKey.RELEASE_TYPE.property_key] = release_types
    # suffixes and lossless status
    song_list: list[Song] = album.getSongs()
    if len(song_list if song_list else []) > 0:
        song_properties: dict[str, any] = build_album_properties_from_songs(song_list=song_list)
        # contribute to main dictionary
        res.update(song_properties)
    return res


def build_album_properties_from_songs(song_list: list[Song]) -> dict[str, list[Any]]:
    res: dict[str, list[str]] = {}
    # suffixes and lossless status
    non_empty_suffix_list: list[str] = [x.getSuffix().lower() for x in list(filter(lambda x: x.getSuffix() is not None, song_list))]
    suffixes: list[str] = list(set(non_empty_suffix_list))
    res[AlbumPropertyKey.SUFFIX.property_key] = suffixes
    lossless_statuses: list[audio_codec.LosslessStatus] = list(map(
        lambda x: audio_codec.get_lossless_status(x),
        suffixes))
    if len(lossless_statuses) == 1:
        res[AlbumPropertyKey.LOSSLESS_STATUS.property_key] = [lossless_statuses[0].value]
    elif len(lossless_statuses) == 2:
        res[AlbumPropertyKey.LOSSLESS_STATUS.property_key] = [audio_codec.LosslessStatus.MIXED.value]
    # quality badge?
    quality_badge: str = calc_song_list_quality_badge(song_list=song_list)
    if quality_badge:
        res[AlbumPropertyKey.QUALITY_BADGE.property_key] = [quality_badge]
    bit_depth_list: list[str] = list(set([str(get_song_bit_depth(x)) for x in song_list]))
    if bit_depth_list:
        res[AlbumPropertyKey.BIT_DEPTH.property_key] = bit_depth_list
    sampling_rate_list: list[str] = list(set([str(get_song_sampling_rate(x)) for x in song_list]))
    if sampling_rate_list:
        res[AlbumPropertyKey.SAMPLING_RATE.property_key] = sampling_rate_list
    channel_count_list: list[str] = list(set([str(get_song_channel_count(x)) for x in song_list]))
    if channel_count_list:
        res[AlbumPropertyKey.CHANNEL_COUNT.property_key] = channel_count_list
    # get artists from songs (both types)
    song_artist_set: set[str] = set()
    song_artist_list_by_type: list[SongArtist]
    for song_artist_list_by_type in ([
            get_song_artists_by_type(
                song=s,
                song_artist_type=SongArtistType.SONG_ALBUM_ARTIST) + get_song_artists_by_type(
                    song=s,
                    song_artist_type=SongArtistType.SONG_ARTIST)
            for s in song_list]):
        curr_song_artist: SongArtist
        for curr_song_artist in song_artist_list_by_type:
            song_artist_set.add(curr_song_artist.artist_name)
    # set artist propery
    res[AlbumPropertyKey.ARTIST.property_key] = list(song_artist_set)
    # contributors
    contributor_name_set: set[str] = set()
    song_contributor_list: list[SongContributor]
    for song_contributor_list in ([get_song_contributors(song=s) for s in song_list]):
        curr_song_contributor: SongContributor
        for curr_song_contributor in song_contributor_list:
            contributor_name_set.add(curr_song_contributor.artist_name)
    res[AlbumPropertyKey.CONTRIBUTOR.property_key] = list(contributor_name_set)
    # all artists
    all_song_artist_names: list[str] = contributor_name_set.union(song_artist_set)
    res[AlbumPropertyKey.ARTIST_CONTRIBUTOR.property_key] = all_song_artist_names
    return res


def __get_decade(year: int) -> str:
    return f"{(year // 10) * 10}s"


def calc_song_list_quality_badge(song_list: list[Song], list_identifier: str = None) -> str:
    prop_dict: dict[str, list[int]] = _get_track_list_streaming_properties(song_list=song_list)
    song_info_list: list[SongInfo] = get_song_info_list(song_list=song_list)
    if not song_info_list or len(song_info_list) == 0:
        # msgproc.log(f"calc_song_list_quality_badge for [{list_identifier}] no tracks were processed")
        return None
    if (__StreamingDictKey.DICT_KEY_BITDEPTH.value not in prop_dict or
            __StreamingDictKey.DICT_KEY_SAMPLERATE.value not in prop_dict):
        msgproc.log(f"No streaming information available in [{list_identifier}]")
        return None
    # information are available, go on ...
    # are they all lossy?
    all_lossy: bool = __all_lossy(song_info_list)
    # do they all have the same bitrate?
    unique_bitrate: int = _get_unique_bitrate(prop_dict)
    # do they all have the same suffix?
    unique_suffix: str = _get_unique_suffix(prop_dict)
    # do they all have the same sampling rate?
    unique_sampling_rate: int = __get_unique_sampling_rate(prop_dict)
    readable_unique_sampling_rate: str = __get_readable_sampling_rate(unique_sampling_rate)
    if all_lossy:
        # we always return from this branch
        if (unique_suffix is not None) and (unique_sampling_rate is not None):
            return f"{unique_suffix}/{readable_unique_sampling_rate}"
        else:
            if unique_suffix:
                return unique_suffix
            else:
                # fallback
                return "lossy"
    bit_depth_list: list[int] = prop_dict[__StreamingDictKey.DICT_KEY_BITDEPTH.value]
    bit_depth_list.sort(reverse=True)
    sampling_rate_list: list[int] = prop_dict[__StreamingDictKey.DICT_KEY_SAMPLERATE.value]
    sampling_rate_list.sort(reverse=True)
    if len(bit_depth_list) == 0 or len(sampling_rate_list) == 0:
        msgproc.log("Empty streaming info in [{list_identifier}]")
        return None
    best_bit_depth: int = bit_depth_list[0]
    worst_bit_depth: int = bit_depth_list[len(bit_depth_list) - 1]
    best_sampling_rate: int = sampling_rate_list[0]
    worst_sampling_rate: int = sampling_rate_list[len(sampling_rate_list) - 1]
    if len(bit_depth_list) > 1 or len(sampling_rate_list) > 1:
        if best_bit_depth >= 24 and best_sampling_rate >= 44100:
            # can we show HD instead of ~HD?
            # yes if worst bitrate and samplerate are still up to be considered HD
            if worst_bit_depth >= 24 and worst_sampling_rate >= 44100:
                # so all hires
                return "HD"
            # so almost HD
            return "~HD"
        if best_bit_depth == 16 and best_sampling_rate == 44100:
            return "~CD"
        if best_bit_depth == 16 and best_sampling_rate >= 48000:
            return f"~16/{__get_readable_sampling_rate(best_sampling_rate)}"
        if best_bit_depth == 0:
            return f"~Lossy/{__get_readable_sampling_rate(best_sampling_rate)}"
        # other cases?
        return f"~{best_bit_depth}/{__get_readable_sampling_rate(best_sampling_rate)}"
    else:
        # list sizes or bit_depth and sampling rate are 1
        sr: str = __get_readable_sampling_rate(best_sampling_rate)
        if best_bit_depth == 0:
            # lossy
            suffix_list: list[str] = (prop_dict[__StreamingDictKey.DICT_KEY_SUFFIX.value]
                                      if __StreamingDictKey.DICT_KEY_SUFFIX.value in prop_dict
                                      else list())
            display_codec: str = (suffix_list[0]
                                  if len(suffix_list) == 1
                                  else "lossy")
            if unique_bitrate:
                display_codec = f"{display_codec}@{unique_bitrate}"
            return f"{display_codec}/{sr}"
        if unique_suffix and unique_suffix.lower() in config.get_whitelist_codecs():
            if best_bit_depth == 1:
                return f"DSD {sr}"
            else:
                return f"{best_bit_depth}/{sr}"
        elif unique_suffix:
            # mention suffix
            if best_bit_depth == 1:
                return f"{unique_suffix}@DSD/{sr}"
            else:
                return f"{unique_suffix}@{best_bit_depth}/{sr}"
        else:
            # use ~ as we don't have an unique suffix
            if best_bit_depth == 1:
                return f"~DSD {sr}"
            else:
                return f"~{best_bit_depth}/{sr}"


def _get_avg_bitrate(song_info_list: list[SongInfo]) -> float:
    sum_v: float = 0.0
    current: SongInfo
    for current in song_info_list:
        if not current.bitrate or not current.duration:
            # we just skip it
            continue
        sum_v += float(current.bitrate) * float(current.duration)
    duration_sum: float = sum([x.duration for x in song_info_list])
    return sum_v / float(duration_sum)


def _get_avg_bitrate_int(song_info_list: list[SongInfo]):
    return int(_get_avg_bitrate(song_info_list))


def get_song_info_list(song_list: list[Song]) -> list[SongInfo]:
    return list(map(
        lambda song: SongInfo(
            song_id=song.getId(),
            bit_depth=get_song_bit_depth(song=song),
            bitrate=song.getBitRate(),
            sampling_rate=get_song_sampling_rate(song=song),
            duration=song.getDuration(),
            suffix=song.getSuffix()),
        song_list))


def __get_unique_sampling_rate(prop_dict: dict[str, list[int]]) -> int:
    sampling_rate_list: list[int] = (prop_dict[__StreamingDictKey.DICT_KEY_SAMPLERATE.value]
                                     if __StreamingDictKey.DICT_KEY_SAMPLERATE.value in prop_dict
                                     else list())
    if len(sampling_rate_list) == 1:
        return sampling_rate_list[0]
    return None


def _get_unique_bitrate(prop_dict: dict[str, list[int]]) -> int:
    bitrate_list: list[int] = (prop_dict[__StreamingDictKey.DICT_KEY_BITRATE.value]
                               if __StreamingDictKey.DICT_KEY_BITRATE.value in prop_dict
                               else list())
    if len(bitrate_list) == 1:
        return bitrate_list[0]
    return None


def _get_unique_suffix(prop_dict: dict[str, list[int]]) -> str:
    suffix_list: list[str] = list(set(list(map(
        lambda x: x.lower(),
        (prop_dict[__StreamingDictKey.DICT_KEY_SUFFIX.value]
            if __StreamingDictKey.DICT_KEY_SUFFIX.value in prop_dict
            else list())))))
    if len(suffix_list) == 1:
        return suffix_list[0]
    return None


def _get_track_list_streaming_properties(song_list: list[Song]) -> dict[str, list[int]]:
    result: dict[str, list[int]] = dict()
    song: Song
    for song in song_list:
        # bit depth
        bit_depth: int = get_song_bit_depth(song=song)
        __maybe_append_to_dict_list(result, __StreamingDictKey.DICT_KEY_BITDEPTH.value, bit_depth)
        # sampling rate
        sampling_rate: int = get_song_sampling_rate(song=song)
        __maybe_append_to_dict_list(result, __StreamingDictKey.DICT_KEY_SAMPLERATE.value, sampling_rate)
        # suffix
        suffix: str = song.getSuffix().lower() if song.getSuffix() else None
        __maybe_append_to_dict_list(result, __StreamingDictKey.DICT_KEY_SUFFIX.value, suffix)
        # bitrate
        bitrate: int = song.getBitRate()
        __maybe_append_to_dict_list(result, __StreamingDictKey.DICT_KEY_BITRATE.value, bitrate)
    return result


def calc_lossless_status(song_info_list: list[SongInfo]) -> audio_codec.LosslessStatus:
    if not song_info_list:
        raise Exception("calc_lossless_status requires a valid list of songs")
    lossy_count: int = 0
    lossless_count: int = 0
    curr: SongInfo
    for curr in song_info_list:
        if is_lossy(suffix=curr.suffix, bit_depth=curr.bit_depth):
            lossy_count += 1
        else:
            lossless_count += 1
    if lossy_count > 0 and lossless_count > 0:
        return audio_codec.LosslessStatus.MIXED
    elif lossless_count == 0:
        return audio_codec.LosslessStatus.LOSSY
    else:
        return audio_codec.LosslessStatus.LOSSLESS


def is_lossy(suffix: str, bit_depth: int) -> bool:
    if not bit_depth or bit_depth == 0:
        return True
    # also select suffix are lossy
    if suffix is not None:
        # return suffix in additional_lossy_prefix_set
        return audio_codec.is_lossy(suffix)
    return False


def __all_lossy(song_info_list: list[SongInfo]) -> bool:
    current: SongInfo
    for current in song_info_list if song_info_list else []:
        if not is_lossy(suffix=current.suffix, bit_depth=current.bit_depth):
            return False
    return True


def __maybe_append_to_dict_list(
        prop_dict: dict[str, list[any]],
        dict_key: str,
        new_value: any):
    if new_value is None:
        return
    item_list: list[any] = None
    if dict_key not in prop_dict:
        item_list = list()
        # list was not there, safe to add
        item_list.append(new_value)
        prop_dict[dict_key] = item_list
    else:
        item_list = prop_dict[dict_key]
        if new_value not in item_list:
            item_list.append(new_value)


__readable_sr: dict[int, str] = {
    8000: "8k",
    11025: "11k",
    12000: "12k",
    22050: "22k",
    24000: "24k",
    32000: "32k",
    44100: "44k",
    48000: "48k",
    88200: "88k",
    96000: "96k",
    176400: "176k",
    192000: "192k",
    352800: "352k",
    384000: "384k",
    705600: "705k",
    768000: "768k",
    1411200: "1411k",
    1536000: "1536k",
    2822400: "2.8M",
    5644800: "5.6M",
    11289600: "11.2M",
    22579200: "22.4M"
}


def __get_readable_sampling_rate(sampling_rate: int) -> str:
    sr: str = (__readable_sr[sampling_rate]
               if sampling_rate in __readable_sr
               else str(sampling_rate))
    return sr


def get_random_art_by_genre(
        genre: str,
        max_items: int = 100) -> str:
    connector: Connector = connector_provider.get()
    response: Response[AlbumList] = connector.getAlbumList(
        ltype=ListType.BY_GENRE,
        genre=genre,
        size=max_items)
    if not response.isOk():
        return None
    album: Album = secrets.choice(response.getObj().getAlbums())
    if album:
        return album.getCoverArt()
    return None


def try_get_album(
        album_id: str,
        propagate_fail: bool = False) -> Album:
    start: float = time.time()
    try:
        res: Response[Album] = connector_provider.get().getAlbum(album_id)
        if res and res.getObj() and res.isOk():
            album: Album = res.getObj()
            elapsed: float = time.time() - start
            if config.get_verbose_logging():
                msgproc.log(f"try_get_album loaded album for album_id [{album_id}] in [{elapsed:.3f}]")
            return album
    except Exception as e:
        msgproc.log(f"Cannot find Album by album_id [{album_id}] due to [{type(e)}] [{e}]")
        if propagate_fail:
            raise e


def try_get_artist(artist_id: str) -> Artist:
    start: float = time.time()
    try:
        res: Response[Artist] = connector_provider.get().getArtist(artist_id)
        artist: Artist = res.getObj() if res and res.isOk() else None
        if config.get_verbose_logging():
            msgproc.log(f"try_get_artist loaded artist_id [{artist_id}] "
                        f"success [{artist is not None}] in "
                        f"[{(time.time() - start):.3f}] sec")
        return artist
    except Exception as e:
        msgproc.log(f"Cannot find Artist by artist_id [{artist_id}] due to [{type(e)}] [{e}]")


def get_cover_art_by_song(song: Song) -> str:
    return song.getCoverArt() if song else None


def get_cover_art_by_album(album: Album) -> str:
    return album.getCoverArt() if album else None


def get_cover_art_by_album_id(album_id: str) -> str:
    album: Album = try_get_album(album_id=album_id)
    return get_cover_art_by_album(album=album)


def get_cover_art_url_by_album(album: Album) -> str:
    return build_cover_art_url(item_id=get_cover_art_by_album(album=album))


def get_cover_art_url_by_song(song: Song) -> str:
    return build_cover_art_url(item_id=get_cover_art_by_song(song=song))


def get_cover_art_url_by_album_id(album_id: str) -> str:
    return build_cover_art_url(item_id=get_cover_art_by_album_id(album_id=album_id))


def get_album_tracks(album_id: str) -> tuple[Album, album_util.AlbumTracks]:
    verbose: bool = config.get_verbose_logging()
    result: list[Song] = []
    album: Album = try_get_album(album_id=album_id)
    if album:
        msgproc.log(f"get_album_tracks executing on_album on album_id [{album_id}] "
                    f"artist [{get_album_display_artist(album=album)}] ...")
        cache_actions.on_album(album=album)
        if verbose:
            msgproc.log(f"get_album_tracks executing on_album on album_id [{album_id}] completed.")
    else:
        msgproc.log(f"get_album_tracks will not execute on_album on album_id [{album_id}] ...")
        return None, None
    album_cover_art_url: str = build_cover_art_url(item_id=album.getCoverArt())
    song_list: list[Song] = album.getSongs()
    sort_song_list_result: album_util.SortSongListResult = album_util.sort_song_list(song_list)
    current_song: Song
    for current_song in sort_song_list_result.getSongList():
        result.append(current_song)
    return album, album_util.AlbumTracks(
        codec_set_by_path=sort_song_list_result.getCodecSetByPath(),
        album=album,
        song_list=result,
        art_uri=album_cover_art_url,
        multi_codec_album=sort_song_list_result.getMultiCodecAlbum())


def get_albums(
        query_type: str,
        size: int = config.get_items_per_page(),
        offset: int = 0,
        fromYear=None,
        toYear=None) -> list[Album]:
    connector: Connector = connector_provider.get()
    albumListResponse: Response[AlbumList]
    if TagType.RECENTLY_ADDED_ALBUMS.query_type == query_type:
        albumListResponse = connector.getNewestAlbumList(
            size=size,
            offset=offset)
    elif TagType.NEWEST_ALBUMS.query_type == query_type:
        albumListResponse = connector.getAlbumList(
            ltype=ListType.BY_YEAR,
            size=size,
            offset=offset,
            fromYear=fromYear,
            toYear=toYear)
    elif TagType.OLDEST_ALBUMS.query_type == query_type:
        albumListResponse = connector.getAlbumList(
            ltype=ListType.BY_YEAR,
            size=size,
            offset=offset,
            fromYear=fromYear,
            toYear=toYear)
    elif TagType.RANDOM.query_type == query_type:
        albumListResponse = request_cache.get_random_album_list(
            size=size,
            offset=offset)
    elif TagType.RECENTLY_PLAYED_ALBUMS.query_type == query_type:
        albumListResponse = connector.getAlbumList(
            ltype=ListType.RECENT,
            size=size,
            offset=offset)
    elif TagType.MOST_PLAYED_ALBUMS.query_type == query_type:
        albumListResponse = connector.getAlbumList(
            ltype=ListType.FREQUENT,
            size=size,
            offset=offset)
    elif TagType.HIGHEST_RATED_ALBUMS.query_type == query_type:
        albumListResponse = connector.getAlbumList(
            ltype=ListType.HIGHEST,
            size=size,
            offset=offset)
    elif TagType.ALPHABETICAL_BY_NAME_ALBUMS.query_type == query_type:
        albumListResponse = connector.getAlbumList(
            ltype=ListType.ALPHABETICAL_BY_NAME,
            size=size,
            offset=offset)
    elif TagType.ALPHABETICAL_BY_ARTIST_ALBUMS.query_type == query_type:
        albumListResponse = connector.getAlbumList(
            ltype=ListType.ALPHABETICAL_BY_ARTIST,
            size=size,
            offset=offset)
    elif TagType.FAVORITE_ALBUMS.query_type == query_type:
        albumListResponse = connector.getAlbumList(
            ltype=ListType.STARRED,
            size=size,
            offset=offset)
    if not albumListResponse.isOk():
        raise Exception(f"Cannot execute query {query_type} "
                        f"for size {size} offset {offset}")
    return albumListResponse.getObj().getAlbums()


# this can be slow with large libraries
def load_artists_by_genre(genre: str, artist_offset: int, max_artists: int) -> list[ArtistIdNameCoverArt]:
    artist_set: set[ArtistIdNameCoverArt] = set()
    artist_id_set: set[str] = set()
    req_offset: int = 0
    loaded_album_count: int = 0
    while (len(artist_set) < (artist_offset + max_artists)):
        # we optimistically use max_artists as album size
        album_list_response: Response[AlbumList] = connector_provider.get().getAlbumList(
            ltype=ListType.BY_GENRE,
            genre=genre,
            offset=req_offset,
            size=100)
        if not album_list_response.isOk():
            return set()
        album_list: list[Album] = album_list_response.getObj().getAlbums()
        # exit when no data
        if (not album_list) or len(album_list) == 0:
            break
        # log how many albums have been retrieved
        loaded_album_count += len(album_list)
        msgproc.log(f"load_artists_by_genre for [{genre}] from offset [{artist_offset}] "
                    f"loaded [{loaded_album_count}] albums for [{len(artist_set)}] artists")
        cached: bool = False
        album: Album
        for album in album_list:
            artist_id: str = album.getArtistId()
            if not artist_id:
                msgproc.log(f"load_artists_by_genre skipping artist [{get_album_display_artist(album=album)}] (no artist_id)")
                continue
            if artist_id not in artist_id_set:
                artist_set.add(ArtistIdNameCoverArt(
                    artist_id=artist_id,
                    artist_name=get_album_display_artist(album=album),
                    cover_art=album.getCoverArt()))
                artist_id_set.add(artist_id)
                if not cached:
                    cache_manager_provider.get().cache_element_value(
                        ElementType.GENRE_ARTIST_LIST,
                        genre,
                        album.getId())
                    cached = True
                if len(artist_set) >= (artist_offset + max_artists):
                    break
        req_offset += len(album_list)
    # publishing how many artists in the set
    msgproc.log(f"load_artists_by_genre for [{genre}] from artist_offset [{artist_offset}] "
                f"total artists found [{len(artist_set)}], providing offsetted list ...")
    artist_id_list: list[ArtistIdNameCoverArt] = list()
    # store set to a list
    for artist in artist_set:
        artist_id_list.append(artist)
    # slice the list
    artist_id_list = artist_id_list[artist_offset:] if len(artist_id_list) > artist_offset else list()
    return artist_id_list


def get_album_list_by_artist_genre(
        artist: Artist,
        genre_name: str) -> list[Album]:
    result: list[Album] = list()
    album_list: list[Album] = None
    offset: int = 0
    while not album_list or len(album_list) == constants.subsonic_max_return_size:
        album_list_response: Response[AlbumList] = connector_provider.get().getAlbumList(
            ltype=ListType.BY_GENRE,
            offset=offset,
            size=constants.subsonic_max_return_size,
            genre=genre_name)
        if not album_list_response.isOk():
            raise Exception(f"Failed to load albums for "
                            f"genre {genre_name} offset {offset}")
        album_list: list[Album] = album_list_response.getObj().getAlbums()
        current_album: Album
        for current_album in album_list if album_list and len(album_list) > 0 else []:
            if artist.getName() in get_album_display_artist(album=current_album):
                result.append(current_album)
        offset += len(album_list)
    return result


class ArtistsOccurrence:

    def __init__(self, id: str, name: str):
        self.__id = id
        self.__name = name

    @property
    def artist_id(self) -> str:
        return self.__id

    @property
    def artist_name(self) -> str:
        return self.__name


class Contributor:

    def __init__(self, role: str, artist_reference: ArtistsOccurrence):
        self.__role: str = role
        self.__artist_reference: ArtistsOccurrence = artist_reference

    @property
    def role(self) -> str:
        return self.__role

    @property
    def artist_reference(self) -> ArtistsOccurrence:
        return self.__artist_reference


def get_album_artists_from_album(album: Album) -> list[dict[str, str]]:
    return album.getItem().getListByName(constants.ItemKey.ARTISTS.value)


def get_album_artist_id_list_from_album(album: Album) -> list[str]:
    return list(map(lambda x: x[constants.DictKey.ID.value], get_album_artists_from_album(album=album)))


def get_album_artist_name_list_from_album(album: Album) -> list[str]:
    return list(map(lambda x: x[constants.DictKey.NAME.value], get_album_artists_from_album(album=album)))


def __get_contributors_in_song_or_album(obj: Song | Album) -> list[ArtistsOccurrence]:
    contributor_list: list[dict[str, str]] = obj.getItem().getListByName(constants.ItemKey.CONTRIBUTORS.value)
    result: list[Contributor] = []
    current: dict[str, str]
    for current in contributor_list if contributor_list else []:
        if constants.DictKey.ROLE.value in current and constants.DictKey.ARTIST.value in current:
            role: str = current[constants.DictKey.ROLE.value]
            artist_dict: dict[str, str] = current[constants.DictKey.ARTIST.value]
            if not isinstance(artist_dict, dict):
                raise Exception(f"Item {constants.ItemKey.CONTRIBUTORS.value} does not contain the "
                                f"{constants.DictKey.ARTIST.value} dict")
            if constants.DictKey.ID.value not in artist_dict or constants.DictKey.NAME.value not in artist_dict:
                raise Exception(f"Item {constants.ItemKey.CONTRIBUTORS.value} does not contain the "
                                f"{constants.DictKey.ID.value} or {constants.DictKey.NAME.value} key")
            contributor: Contributor = Contributor(
                role=role,
                artist_reference=ArtistsOccurrence(
                    id=artist_dict[constants.DictKey.ID.value],
                    name=artist_dict[constants.DictKey.NAME.value]))
            result.append(contributor)
        else:
            raise Exception(f"Item {constants.ItemKey.CONTRIBUTORS.value} does not contain the expected keys")
    return result


def get_artists_in_song_or_album_by_artist_type(obj: Song | Album, item_key: constants.ItemKey) -> list[ArtistsOccurrence]:
    valid_object_types: list[type] = [Song, Album]
    if not obj or type(obj) not in valid_object_types:
        raise Exception(f"obj must be in [{valid_object_types}]")
    valid_types: list[constants.ItemKey] = [constants.ItemKey.ARTISTS, constants.ItemKey.ALBUM_ARTISTS]
    if not item_key or item_key not in [constants.ItemKey.ARTISTS, constants.ItemKey.ALBUM_ARTISTS]:
        raise Exception(f"item_key must be in [{valid_types}]")
    artist_list: list[dict[str, str]] = obj.getItem().getListByName(item_key.value)
    result: list[ArtistsOccurrence] = []
    current: dict[str, str]
    for current in artist_list if artist_list else []:
        if constants.DictKey.NAME.value in current and constants.DictKey.ID.value in current:
            occ: ArtistsOccurrence = ArtistsOccurrence(current[constants.DictKey.ID.value], current[constants.DictKey.NAME.value])
            result.append(occ)
        else:
            raise Exception(f"Item {item_key.value} does not contain the expected keys")
    return result


def get_all_artists_in_album(album: Album, in_songs: bool = True) -> list[ArtistsOccurrence]:
    occ_list: list[ArtistsOccurrence] = list()
    artist_id_set: set[str] = set()
    # add id,name from album itself
    artist_id: str = album.getArtistId()
    artist_name: str = get_album_display_artist(album=album)
    if artist_id and artist_name:
        occ_list.append(ArtistsOccurrence(id=artist_id, name=artist_name))
        artist_id_set.add(artist_id)
    lst: list[dict[str, str]] = get_album_artists_from_album(album)
    if not lst:
        lst = list()
    current: dict[str, str]
    for current in lst:
        if ((constants.DictKey.NAME.value in current and constants.DictKey.ID.value in current)
                and current[constants.DictKey.ID.value] not in artist_id_set):
            occ_list.append(ArtistsOccurrence(id=current[constants.DictKey.ID.value], name=current[constants.DictKey.NAME.value]))
            artist_id_set.add(current[constants.DictKey.ID.value])
    song_list: list[Song] = album.getSongs() if in_songs else list()
    if song_list:
        song: Song
        for song in song_list:
            song_artist_list: list[ArtistsOccurrence] = []
            album_artist_list: list[ArtistsOccurrence] = get_artists_in_song_or_album_by_artist_type(
                obj=song,
                item_key=constants.ItemKey.ALBUM_ARTISTS)
            artist_list: list[ArtistsOccurrence] = get_artists_in_song_or_album_by_artist_type(
                obj=song,
                item_key=constants.ItemKey.ARTISTS)
            if album_artist_list:
                song_artist_list.extend(album_artist_list)
            if artist_list:
                song_artist_list.extend(artist_list)
            song_dict: ArtistsOccurrence
            for song_dict in song_artist_list:
                if song_dict.artist_id not in artist_id_set:
                    occ_list.append(song_dict)
                    artist_id_set.add(song_dict.artist_id)
    return occ_list


def is_artist_id_in_artist_occurrence_list(artist_id: str, lst: list[ArtistsOccurrence]) -> bool:
    curr: ArtistsOccurrence
    for curr in lst:
        if curr.artist_id == artist_id:
            return True
    return False


def __is_artist_id_in_contributor_list(artist_id: str, lst: list[Contributor], with_role: str = None) -> bool:
    curr: Contributor
    for curr in lst:
        if with_role and not curr.role == with_role:
            # does not match role
            continue
        if curr.artist_reference.artist_id == artist_id:
            return True
    return False


def __is_authored_or_contributed_by_artist_id(obj: Song | Album, artist_id: str, with_role: str = None) -> bool:
    album_artist_list: list[ArtistsOccurrence] = get_artists_in_song_or_album_by_artist_type(obj, constants.ItemKey.ALBUM_ARTISTS)
    if is_artist_id_in_artist_occurrence_list(artist_id, album_artist_list):
        return True
    artist_list: list[ArtistsOccurrence] = get_artists_in_song_or_album_by_artist_type(obj, constants.ItemKey.ARTISTS)
    if is_artist_id_in_artist_occurrence_list(artist_id, artist_list):
        return True
    # look in contributors
    contributor_list: list[Contributor] = __get_contributors_in_song_or_album(obj)
    return __is_artist_id_in_contributor_list(artist_id, contributor_list, with_role=with_role)


def get_authored_or_contributed_songs(album: Album, artist_id: str) -> list[Song]:
    result: list[Song] = []
    song: Song
    for song in album.getSongs():
        if song.getArtistId() == artist_id or __is_authored_or_contributed_by_artist_id(song, artist_id):
            result.append(song)
    return result


def filter_out_artist_id(artist_list: list[ArtistsOccurrence], artist_id: str) -> list[ArtistsOccurrence]:
    result: list[ArtistsOccurrence] = []
    occ: ArtistsOccurrence
    for occ in artist_list:
        if not occ.artist_id == artist_id:
            result.append(occ)
    return result


def get_album_date_for_sorting(album: Album) -> str:
    result: str = album_util.get_formatted_album_original_release_date(album)
    if not result:
        # fallback to date.
        y: int = album.getYear()
        if y and isinstance(y, int):
            # assume january 1st
            result = f"{y:04}-01-01"
    return result if result else "0000-01-01"


def ensure_directory(base_dir: str, sub_dir_list: list[str]) -> str:
    verbose: bool = config.get_verbose_logging()
    curr_sub_dir: str
    curr_dir: str = base_dir
    for curr_sub_dir in sub_dir_list:
        new_dir: str = os.path.join(curr_dir, curr_sub_dir)
        if not os.path.exists(new_dir):
            if verbose:
                msgproc.log(f"creating dir [{new_dir}] ...")
            os.mkdir(new_dir)
        curr_dir = new_dir
    return curr_dir


def get_artist_albums_as_main_artist(artist_id: str, album_list: list[Album]) -> list[Album]:
    return get_artist_albums_as_appears_on(
        artist_id=artist_id,
        album_list=album_list,
        opposite=True)


def artist_id_among_main_artists(artist_id: str, album: Album) -> bool:
    if artist_id == album.getArtistId():
        return True
    artists: list[str] = list(map(lambda x: x.artist_id, get_artists_in_song_or_album_by_artist_type(
        obj=album,
        item_key=constants.ItemKey.ARTISTS)))
    if artist_id in artists:
        return True
    album_artists: list[str] = list(map(lambda x: x.artist_id, get_artists_in_song_or_album_by_artist_type(
        obj=album,
        item_key=constants.ItemKey.ALBUM_ARTISTS)))
    if artist_id in album_artists:
        return True
    return False


def get_artist_albums_as_appears_on(artist_id: str, album_list: list[Album], opposite: bool = False) -> list[Album]:
    result: list[Album] = list()
    current: Album
    for current in album_list if album_list else list():
        # the following also consider artists and albumartists, not only current.getArtistId()
        among_main: bool = artist_id_among_main_artists(artist_id=artist_id, album=current)
        check: bool = among_main if opposite else not among_main
        if check:
            result.append(current)
    return result


class AlbumReleaseTypes:

    def __init__(self, types: list[str]):
        self.__types: list[str] = list()
        t: str
        for t in types:
            splitted: list[str] = t.split("/")
            s: str
            for s in splitted if splitted and len(splitted) > 0 else list():
                self.__types.append(s)

    @property
    def types(self) -> list[str]:
        return copy.deepcopy(self.__types)

    @property
    def num_items(self) -> int:
        return len(self.__types)

    @property
    def display_name(self) -> str:
        if (len(self.__types) == 0 or
           (len(self.__types) == 1 and len(self.__types[0]) == 0)):
            return "*EMPTY*"
        return " / ".join(musicbrainzutils.display_value(x) for x in self.__types)

    @property
    def key(self) -> str:
        return "/".join(self.__types)

    @property
    def empty(self) -> str:
        return len(self.__types) == 0


class ReleaseTypeAndCount:

    def __init__(self, rt: AlbumReleaseTypes, count: int):
        self.__rt: AlbumReleaseTypes = rt
        self.__count: int = count

    @property
    def album_release_type(self) -> AlbumReleaseTypes:
        return self.__rt

    @property
    def count(self) -> int:
        return self.__count


def compareAlbumReleaseTypes(left: AlbumReleaseTypes, right: AlbumReleaseTypes) -> int:
    left_str: str = left.key
    right_str: str = right.key
    return 0 if left_str == right_str else 1 if left_str > right_str else -1


def get_release_types(album_list: list[Album]) -> dict[str, int]:
    result: dict[str, int] = dict()
    current: Album
    for current in album_list if album_list else list():
        album_release_types: AlbumReleaseTypes = get_album_release_types(current)
        key: str = album_release_types.key.lower()
        if key not in result:
            result[key] = 1
        else:
            result[key] = result[key] + 1
    return result


def release_type_to_album_list_label(release_type: str, album_count: int = None) -> str:
    if album_count is not None:
        return f"Release Type: {release_type.title()} [{album_count}]"
    else:
        return f"Release Type: {release_type.title()}"


def get_artists_by_same_name(artist: Artist) -> list[Artist]:
    artist_list: list[Artist] = list()
    search_result: SearchResult = connector_provider.get().search(
        query=artist.getName(),
        artistCount=100,
        albumCount=0,
        songCount=0)
    matching_list: list[Artist] = search_result.getArtists()
    matching: Artist
    for matching in matching_list:
        if matching.getId() == artist.getId():
            # skip same artist of course
            continue
        if not (matching.getName().lower() == artist.getName().lower()):
            # skip artist which simply contain the artist name
            continue
        artist_list.append(matching)
    return artist_list


def album_has_release_types(album: Album) -> bool:
    return album.getItem().hasName(constants.ItemKey.RELEASE_TYPES.value)


def get_album_release_types(album: Album) -> AlbumReleaseTypes:
    has_release_types: bool = album_has_release_types(album)
    album_release_types: list[str] = (album.getItem().getByName(constants.ItemKey.RELEASE_TYPES.value)
                                      if has_release_types
                                      else list())
    return AlbumReleaseTypes(
        types=musicbrainzutils.sanitize_release_types(
            value_list=album_release_types,
            print_function=lambda x: msgproc.log(x),
            album_id=album.getId(),
            album_title=album.getTitle(),
            album_artist=album.getArtist()))


def uncategorized_releases_only(release_types: dict[str, int]) -> bool:
    rl: int = len(release_types)
    if rl > 1:
        return False
    if rl == 0:
        return True
    # rl is 1
    if "" in release_types:
        return True
    else:
        return False


def get_song_size(song: Song) -> str:
    return song.getItem().getByName(constants.ItemKey.ITEM_SIZE.value)


def get_explicit_status(obj: Album | Song) -> str:
    return obj.getItem().getByName(constants.ItemKey.EXPLICIT_STATUS.value)


def get_explicit_status_display_value(
        explicit_status: str,
        display_mode: constants.ExplicitDiplayMode = constants.ExplicitDiplayMode.SHORT) -> str:
    for _, v in constants.ExplicitStatus.__members__.items():
        explicit_info: constants._ExplicitStatusData = v.value
        if explicit_info.tag_value == explicit_status:
            return (explicit_info.display_value
                    if display_mode == constants.ExplicitDiplayMode.SHORT
                    else explicit_info.display_value_long)
    return None


def append_something_to_album_title(
        current_albumtitle: str,
        something: str,
        album_entry_type: constants.AlbumEntryType,
        is_search_result: bool,
        container_config: constants.ConfigParam,
        view_config: constants.ConfigParam,
        search_res_config: constants.ConfigParam) -> str:
    if not something:
        return current_albumtitle
    album_title: str = current_albumtitle
    do_append: bool = False
    if constants.AlbumEntryType.ALBUM_CONTAINER == album_entry_type:
        do_append = config.get_config_param_as_bool(container_config)
    elif constants.AlbumEntryType.ALBUM_VIEW == album_entry_type:
        # do we want the badge?
        if is_search_result:
            do_append = config.get_config_param_as_bool(search_res_config)
        else:
            do_append = config.get_config_param_as_bool(view_config)
    if do_append:
        album_title = f"{album_title} [{something}]"
    return album_title


def append_album_badge_to_album_title(
        current_albumtitle: str,
        album_quality_badge: str,
        album_entry_type: constants.AlbumEntryType,
        is_search_result: bool) -> str:
    return append_something_to_album_title(
        current_albumtitle=current_albumtitle,
        something=album_quality_badge,
        album_entry_type=album_entry_type,
        is_search_result=is_search_result,
        container_config=constants.ConfigParam.ALLOW_QUALITY_BADGE_IN_ALBUM_CONTAINER,
        view_config=constants.ConfigParam.ALLOW_QUALITY_BADGE_IN_ALBUM_VIEW,
        search_res_config=constants.ConfigParam.ALLOW_QUALITY_BADGE_IN_ALBUM_SEARCH_RES)


def append_album_version_to_album_title(
        current_albumtitle: str,
        clean_album_title: str,
        album_version: str,
        album_entry_type: constants.AlbumEntryType,
        is_search_result: bool) -> str:
    # don't append version if clean title ends with that already
    if clean_album_title.endswith(f" ({album_version})") or clean_album_title.endswith(f" [{album_version}]"):
        # do nothing, the clean title already contains the version
       return current_albumtitle
    return append_something_to_album_title(
        current_albumtitle=current_albumtitle,
        something=album_version,
        album_entry_type=album_entry_type,
        is_search_result=is_search_result,
        container_config=constants.ConfigParam.ALLOW_ALBUM_VERSION_IN_ALBUM_CONTAINER,
        view_config=constants.ConfigParam.ALLOW_ALBUM_VERSION_IN_ALBUM_VIEW,
        search_res_config=constants.ConfigParam.ALLOW_ALBUM_VERSION_IN_ALBUM_SEARCH_RES)


def append_explicit_if_needed(current_albumtitle: str, album: Album) -> str:
    explicit_status: str = get_explicit_status(album)
    if (explicit_status is not None
            and len(explicit_status) > 0):
        if config.get_config_param_as_bool(constants.ConfigParam.DUMP_EXPLICIT_STATUS):
            msgproc.log(f"Explicit status is [{explicit_status}] for album [{album.getId()}] "
                        f"[{album.getTitle()}] "
                        f"by [{get_album_display_artist(album=album)}]")
        # find match ...
        display_value: str = get_explicit_status_display_value(explicit_status)
        explicit_expression = display_value if display_value else explicit_status
        return f"{current_albumtitle} [{explicit_expression}]"
    return current_albumtitle


def append_number_of_tracks_to_album_title(
        current_albumtitle: str,
        album: Album,
        config_getter: Callable[[], bool]) -> str:
    result: str = current_albumtitle
    number_of_tracks: int = album.getSongCount()
    if config_getter() and number_of_tracks is not None:
        result = f"{result} [{number_of_tracks}]"
    return result


def get_genres_from_album(album: Album) -> list[str]:
    lst: list[str] = []
    data_list: list[dict[str, any]] = album.getItem().getByName(
        constants.ItemKey.ALBUM_GENRES.value,
        [])
    itm: dict[str, any]
    for itm in data_list:
        genre: str = itm[constants.DictKey.NAME.value] if constants.DictKey.NAME.value in itm else None
        if genre and len(genre) > 0:
            lst.append(genre)
    return lst


def get_disc_titles_from_album(album: Album) -> list[DiscTitle]:
    lst: list[DiscTitle] = []
    disc_title_list: list[dict[str, any]] = album.getItem().getByName(
        constants.ItemKey.DISC_TITLES.value,
        [])
    dt: dict[str, any]
    for dt in disc_title_list:
        disc_n: int = dt[constants.DictKey.DISC.value] if constants.DictKey.DISC.value in dt else len(lst) + 1
        disc_t: int = dt[constants.DictKey.TITLE.value] if constants.DictKey.TITLE.value in dt else f"Disc {disc_n}"
        disc_title: DiscTitle = DiscTitle(disc_n, disc_t)
        lst.append(disc_title)
    return lst


def get_disc_titles_from_album_as_dict(album: Album) -> dict[int, str]:
    res: dict[int, str] = {}
    lst: list[DiscTitle] = get_disc_titles_from_album(album)
    dt: DiscTitle
    for dt in lst:
        if dt.disc_num not in res:
            res[dt.disc_num] = dt.title
    return res


def append_cached_mb_id_to_artist_entry_name_if_allowed(entry_name: str, artist_id: str) -> str:
    if config.get_config_param_as_bool(constants.ConfigParam.SHOW_ARTIST_MB_ID):
        # see if we have it cached.
        artist_metadata: persistence.ArtistMetadata = persistence.get_artist_metadata(artist_id=artist_id)
        artist_mb_id: str = artist_metadata.artist_musicbrainz_id if artist_metadata else None
        if artist_mb_id:
            if config.get_config_param_as_bool(constants.ConfigParam.DUMP_ACTION_ON_MB_ALBUM_CACHE):
                msgproc.log(f"Found mbid for artist_id [{artist_id}] -> [{artist_mb_id}]")
            as_ph: bool = config.get_config_param_as_bool(constants.ConfigParam.SHOW_ARTIST_MB_ID_AS_PLACEHOLDER)
            mb_val: str = ('mb' if as_ph else artist_mb_id)
            entry_name = f"{entry_name} [{mb_val}]"
        else:
            if config.get_config_param_as_bool(constants.ConfigParam.DUMP_ACTION_ON_MB_ALBUM_CACHE):
                msgproc.log(f"Cannot find mbid for artist_id [{artist_id}]")
    return entry_name


def append_mb_id_to_artist_entry_name_if_allowed(entry_name: str, artist_mb_id: str) -> str:
    if config.get_config_param_as_bool(constants.ConfigParam.SHOW_ARTIST_MB_ID):
        if artist_mb_id:
            as_ph: bool = config.get_config_param_as_bool(constants.ConfigParam.SHOW_ARTIST_MB_ID_AS_PLACEHOLDER)
            mb_val: str = ('mb' if as_ph else artist_mb_id)
            entry_name = f"{entry_name} [{mb_val}]"
    return entry_name


def append_genre_to_artist_entry_name_if_allowed(
        entry_name: str,
        album: Album,
        config_getter: Callable[[], bool]) -> str:
    result: str = entry_name
    if config_getter():
        genres: list[str] = album.getGenres()
        if genres and len(genres) > 0:
            result = f"{result} [{', '.join(genres)}]"
    return result


def __compare_album_by_date(left: Album, right: Album) -> int:
    cmp: int = 0
    left_v: str = get_album_date_for_sorting(left)
    right_v: str = get_album_date_for_sorting(right)
    cmp = -1 if left_v < right_v else 0 if left_v == right_v else 1
    return cmp


def sort_albums_by_date(album_list: list[Album]):
    reverse: bool = config.get_config_param_as_bool(constants.ConfigParam.ARTIST_ALBUM_NEWEST_FIRST)
    if album_list:
        album_list.sort(key=cmp_to_key(mycmp=__compare_album_by_date), reverse=reverse)


def get_album_song_count(album: Album) -> int | None:
    return album.getItem().getByName(constants.ItemKey.ALBUM_SONG_COUNT.value) if album else None


def get_album_play_count(album: Album) -> int | None:
    return album.getItem().getByName(constants.ItemKey.ALBUM_PLAY_COUNT.value) if album else None


def get_song_play_count(song: Song) -> int | None:
    return song.getItem().getByName(constants.ItemKey.SONG_PLAY_COUNT.value) if song else None


def get_album_is_compilation(album: Album) -> bool | None:
    return album.getItem().getByName(constants.ItemKey.IS_COMPILATION.value) if album else False


def get_song_musicbrainz_id(song: Song) -> str | None:
    return song.getItem().getByName(constants.ItemKey.MUSICBRAINZ_ID.value) if song else None


def get_album_musicbrainz_id(album: Album) -> str | None:
    return album.getItem().getByName(constants.ItemKey.MUSICBRAINZ_ID.value) if album else None


def __parse_flexible(ts_str: str) -> datetime.datetime:
    if not ts_str:
        return None
    # Clean up whitespace and handle bytes if necessary
    if isinstance(ts_str, bytes):
        ts_str = ts_str.decode('utf-8')
    ts_str = ts_str.strip().replace('Z', '+00:00')
    # Strategy 1: The Smart Way (Handles variable precision)
    try:
        # We replace ' ' with 'T' here because older fromisoformat
        # versions (3.7-3.10) require the 'T' separator
        return datetime.datetime.fromisoformat(ts_str.replace(' ', 'T'))
    except (ValueError, TypeError):
        pass
    # Strategy 2: Strict Microseconds (2025-09-18 20:30:14.199000+00:00)
    try:
        return datetime.datetime.strptime(ts_str, "%Y-%m-%d %H:%M:%S.%f%z")
    except ValueError:
        pass
    # Strategy 3: Strict Seconds (2025-09-23 14:59:45+00:00)
    try:
        return datetime.datetime.strptime(ts_str, "%Y-%m-%d %H:%M:%S%z")
    except ValueError:
        # Final fallback: Try without the timezone if it's missing
        return datetime.datetime.strptime(ts_str.split('+')[0], "%Y-%m-%d %H:%M:%S")


def get_artist_starred(artist: Artist) -> datetime.datetime | None:
    return get_item_timestamp(
        obj=artist,
        item_key=constants.ItemKey.ARTIST_STARRED,
        item_id_extractor=lambda x: x.getId())


def get_album_created(album: Album) -> datetime.datetime | None:
    return get_item_timestamp(
        obj=album,
        item_key=constants.ItemKey.ALBUM_CREATED,
        item_id_extractor=lambda x: x.getId())


def get_album_starred(album: Album) -> datetime.datetime | None:
    return get_item_timestamp(
        obj=album,
        item_key=constants.ItemKey.ALBUM_STARRED,
        item_id_extractor=lambda x: x.getId())


def get_song_created(song: Song) -> datetime.datetime | None:
    return get_item_timestamp(
        obj=song,
        item_key=constants.ItemKey.SONG_CREATED,
        item_id_extractor=lambda x: x.getId())


def get_song_starred(song: Song) -> datetime.datetime | None:
    return get_item_timestamp(
        obj=song,
        item_key=constants.ItemKey.SONG_STARRED,
        item_id_extractor=lambda x: x.getId())


def get_item_timestamp(
        obj: Album | Song,
        item_key: constants.ItemKey,
        item_id_extractor: Callable[[any], str]) -> datetime.datetime | None:
    try:
        v: any = obj.getItem().getByName(item_key.value) if obj else None
        as_str = v.decode('utf-8') if isinstance(v, bytes) else v
        return __parse_flexible(as_str) if as_str else None
    except Exception as ex:
        msgproc.log(f"get_item_timestamp for item [{item_id_extractor(obj)}] [{v}] "
                    f"could not be converted due to [{type(ex)}] [{ex}]")


def get_album_user_rating(album: Album) -> int | None:
    return album.getItem().getByName(constants.ItemKey.ALBUM_USER_RATING.value) if album else None


def get_artist_musicbrainz_id(artist: Artist) -> str | None:
    return artist.getItem().getByName(constants.ItemKey.MUSICBRAINZ_ID.value) if artist else None


def get_artist_cover_art(artist: Artist) -> str | None:
    if config.get_config_param_as_bool(constants.ConfigParam.ALLOW_ARTIST_COVER_ART):
        return artist.getItem().getByName(constants.ItemKey.COVER_ART.value) if artist else None
    else:
        None


def get_album_version(album: Album) -> str | None:
    return album.getItem().getByName(constants.ItemKey.ALBUM_VERSION.value) if album else None


def get_song_type(song: Song) -> str | None:
    return song.getItem().getByName(constants.ItemKey.SONG_TYPE.value) if song else None


def get_song_display_composer(song: Song) -> str | None:
    return song.getItem().getByName(constants.ItemKey.SONG_DISPLAY_COMPOSER.value) if song else None


def get_song_sort_name(song: Song) -> str | None:
    return song.getItem().getByName(constants.ItemKey.SONG_SORT_NAME.value) if song else None


def get_song_year(song: Song) -> int | None:
    return song.getItem().getByName(constants.ItemKey.SONG_YEAR.value) if song else None


def get_album_played(album: Album) -> str | None:
    return album.getItem().getByName(constants.ItemKey.ALBUM_PLAYED.value) if album else None


def get_playlist_entry_display_artist(playlist_entry: PlaylistEntry) -> str:
    display_artist: str = (playlist_entry.getItem().getByName(constants.ItemKey.PLAYLIST_ENTRY_DISPLAY_ARTIST.value)
                           if playlist_entry
                           else None)
    if not display_artist:
        # fallback to artist
        display_artist = playlist_entry.getArtist()
    return display_artist


def get_song_display_artist(song: Song) -> str:
    display_artist: str = song.getItem().getByName(constants.ItemKey.SONG_DISPLAY_ARTIST.value) if song else None
    if not display_artist:
        # fallback to artist
        display_artist = song.getArtist()
    return display_artist


def get_song_bit_depth(song: Song) -> int:
    return song.getItem().getByName(constants.ItemKey.BIT_DEPTH.value) if song else None


def get_song_sampling_rate(song: Song) -> int:
    return song.getItem().getByName(constants.ItemKey.SAMPLING_RATE.value) if song else None


def get_song_channel_count(song: Song) -> int:
    return song.getItem().getByName(constants.ItemKey.CHANNEL_COUNT.value) if song else None


def get_song_is_dir(song: Song) -> bool:
    return song.getItem().getByName(constants.ItemKey.SONG_IS_DIR.value) if song else None


def get_song_comment(song: Song) -> str:
    return song.getItem().getByName(constants.ItemKey.SONG_COMMENT.value) if song else None


def get_song_display_album_artist(song: Song) -> str:
    return song.getItem().getByName(constants.ItemKey.SONG_DISPLAY_ALBUM_ARTIST.value) if song else None


def get_album_display_artist(album: Album) -> str:
    display_artist: str = album.getItem().getByName(constants.ItemKey.ALBUM_DISPLAY_ARTIST.value) if album else None
    if not display_artist:
        # fallback to artist
        display_artist = album.getArtist()
    return display_artist


def get_song_played(song: Song) -> str | None:
    return song.getItem().getByName(constants.ItemKey.SONG_PLAYED.value) if song else None


def get_media_type(obj: Song | Album | Artist) -> str | None:
    return obj.getItem().getByName(constants.ItemKey.MEDIA_TYPE.value) if obj else None


def get_artist_sort_name(artist: Artist) -> str | None:
    return artist.getItem().getByName(constants.ItemKey.ARTIST_SORT_NAME.value) if artist else None


def get_album_sort_name(album: Album) -> str | None:
    return album.getItem().getByName(constants.ItemKey.ALBUM_SORT_NAME.value) if album else None


def get_album_record_label_names(album: Album) -> list[str]:
    result: list[str] = []
    rl: list[dict[str, str]] = album.getItem().getListByName(constants.ItemKey.ALBUM_RECORD_LABELS.value) if album else None
    current: dict[str, str]
    for current in rl:
        if constants.DictKey.NAME.value in current:
            result.append(current[constants.DictKey.NAME.value])
    return result


def get_artist_roles(artist: Artist) -> list[str]:
    return artist.getItem().getListByName(constants.ItemKey.ROLES.value) if artist else []


def get_artists_from_album(
        album: Album,
        item_key: constants.ItemKey = constants.ItemKey.ARTISTS,
        allow_duplicate_artist_id: bool = True) -> list[ArtistFromAlbum]:
    lst: list[dict[str, str]] = album.getItem().getListByName(item_key.value) if album else []
    # check for duplicates
    res: list[ArtistFromAlbum] = []
    d: dict[str, str]
    id_set: set[str] = set()
    for d in lst:
        artist_id: str = d[constants.DictKey.ID.value] if constants.DictKey.ID.value in d else None
        artist_name: str = d[constants.DictKey.NAME.value] if constants.DictKey.NAME.value in d else None
        if not artist_id or not artist_name:
            raise Exception(f"get_artists_from_album album_id [{album.getId()}] "
                            f"[{album.getTitle()}] by [{album.getArtist()}] "
                            f"{item_key.value} keys "
                            f"[{constants.DictKey.ID.value}, {constants.DictKey.NAME.value}] must be set")
        if (artist_id in id_set) and not allow_duplicate_artist_id:
            # skip, duplicate
            msgproc.log(f"get_artists_from_album album_id [{album.getId()}] "
                        f"[{album.getTitle()}] by [{album.getArtist()}] "
                        f"duplicate artist_id [{artist_id}] "
                        f"name from discarded entry [{artist_name}]")
            continue
        # keep track
        id_set.add(artist_id)
        # add to list
        res.append(ArtistFromAlbum(album_id=album.getId(), artist_id=artist_id, artist_name=artist_name))
    return res


def get_album_moods(album: Album) -> list[str]:
    return album.getItem().getListByName(constants.ItemKey.MOODS.value) if album else []


def get_docroot_base_url() -> str:
    host_port: str = (os.environ["UPMPD_UPNPHOSTPORT"]
                      if "UPMPD_UPNPHOSTPORT" in os.environ
                      else None)
    doc_root: str = (os.environ["UPMPD_UPNPDOCROOT"]
                     if "UPMPD_UPNPDOCROOT" in os.environ
                     else None)
    if not host_port or not doc_root:
        return None
    return f"http://{host_port}"


def compose_docroot_url(right: str) -> str:
    doc_root_base_url: str = get_docroot_base_url()
    return f"{doc_root_base_url}/{right}" if doc_root_base_url else None


def match_supported_image_type_by_name(file_name: str) -> constants.SupportedImageType:
    if not file_name:
        return None
    file_name_lower: str = file_name.lower()
    s: constants.SupportedImageType
    for s in constants.SupportedImageType:
        ext: str
        for ext in s.extension_list:
            if (file_name_lower.endswith("." + ext)):
                return s
    return None


def match_supported_image_type_by_content_type(content_type: str) -> constants.SupportedImageType:
    s: constants.SupportedImageType
    for s in constants.SupportedImageType:
        ct: str
        for ct in s.content_type_list:
            if (content_type == ct):
                return s
    return None


def build_cover_art_url(
        item_id: str,
        force_save: bool = False,
        force_final_url: bool = False) -> str:
    start: float = time.time()
    if force_final_url or not config.get_config_param_as_bool(constants.ConfigParam.ENABLE_COVER_ART_INTERMEDIATE_URL):
        cover_art_url: str = __build_cover_art_url(item_id=item_id, force_save=force_save)
        elapsed: float = time.time() - start
        if config.get_verbose_logging():
            msgproc.log(f"build_cover_art_url for item_id [{item_id}] total elapsed [{elapsed:.3f}]")
    else:
        # build intermediate cover art URL
        http_host_port = os.environ["UPMPD_HTTPHOSTPORT"]
        cover_art_url = (f"http://{http_host_port}/{constants.PluginConstant.PLUGIN_NAME.value}"
                         f"/coverart/version/1/coverartId/{item_id}")
    return cover_art_url


def __validate_image_caching_enabled() -> bool:
    return (config.getWebServerDocumentRoot() and
            config.get_config_param_as_bool(constants.ConfigParam.ENABLE_IMAGE_CACHING))


def __build_cover_art_url(item_id: str, force_save: bool = False) -> str:
    if not item_id or config.get_config_param_as_bool(constants.ConfigParam.DEFEAT_COVER_ART_URL):
        return None
    cover_art_url: str = connector_provider.get().buildCoverArtUrl(item_id=item_id)
    if not cover_art_url:
        return None
    verbose: bool = config.get_verbose_logging()
    if (__validate_image_caching_enabled()):
        save_start: float = time.time()
        if verbose:
            msgproc.log(f"__build_cover_art_url saving image for item_id [{item_id}] ...")
        images_cached_dir: str = ensure_directory(
            config.getWebServerDocumentRoot(),
            config.get_webserver_path_images_cache())
        ensure_directory_elapsed: float = time.time() - save_start
        if verbose:
            msgproc.log(f"__build_cover_art_url ensured directory [{images_cached_dir}] "
                        f"exists in [{ensure_directory_elapsed:.3f}]")
        exists: str = False
        matching_files: list[str] = []
        cached_file_name: str = item_id
        cached_file_path: str = os.path.join(images_cached_dir, item_id)
        item_id_ext: str = os.path.splitext(cached_file_path)[1]
        if item_id_ext:
            # cached_file_path has extension, we convert that to lower case
            item_id_ext = item_id_ext.lower()
        item_id_with_ext: str = cached_file_path if item_id_ext else None
        if not item_id_with_ext:
            # item_id does not have an extension
            matching_files = __match_images_only(glob.glob(f"{cached_file_path}.*"))
            if matching_files and len(matching_files) > 0:
                item_id_with_ext = os.path.basename(matching_files[0])
                # remove other matching_files files ...
                to_remove: str
                for to_remove in matching_files[1:]:
                    msgproc.log(f"__build_cover_art_url removing spurious file [{to_remove}] ...")
                    os.remove(to_remove)
                exists = True
        else:
            exists = os.path.exists(cached_file_path)
            if exists:
                matching_files = [cached_file_path]
        serve_local: bool = False
        if exists and not force_save:
            # file exists or force_save not set
            serve_local = True
        else:
            # file does not exist or must be saved
            if exists and force_save:
                # remove matching_files
                to_remove: str
                for to_remove in matching_files:
                    try:
                        os.remove(to_remove)
                    except Exception as ex:
                        msgproc.log(f"__build_cover_art_url failed to remove [{to_remove}] due to [{type(ex)}] [{ex}]")
            try:
                read_start: float = time.time()
                response = requests.get(cover_art_url)
                content_type = response.headers.get('content-type')
                file_type: str = mimetypes.guess_all_extensions(content_type)
                # if file_type is "application/json", we probably have a failure
                ct_match: constants.SupportedImageType = match_supported_image_type_by_content_type(content_type)
                valid_file_type: bool = ct_match is not None
                read_elapsed: float = time.time() - read_start
                if verbose:
                    msgproc.log(f"__build_cover_art_url reading [{item_id}] "
                                f"[{cover_art_url}] -> "
                                f"content_type [{content_type}] "
                                f"file_type [{file_type}] "
                                f"valid -> [{valid_file_type}] "
                                f"in [{read_elapsed:.3f}]")
                if valid_file_type:
                    item_id_ext: str = os.path.splitext(cached_file_path)[1]
                    # is cached_file_path without extension?
                    if item_id_ext is None or len(item_id_ext) == 0:
                        if file_type and len(file_type) > 0:
                            cached_file_name = cached_file_name + file_type[0]
                            item_id_with_ext = cached_file_name
                            cached_file_path: str = os.path.join(images_cached_dir, cached_file_name)
                        else:
                            # we cannot save!
                            cached_file_path = None
                    else:
                        cached_file_path: str = os.path.join(images_cached_dir, cached_file_name)
                        item_id_with_ext = item_id
                    if cached_file_path:
                        img_data: bytes = response.content
                        with open(cached_file_path, 'wb') as handler:
                            handler.write(img_data)
                        serve_local = True
            except Exception as ex:
                msgproc.log(f"__build_cover_art_url could not save file [{cached_file_path}] due to [{type(ex)}] [{ex}]")
        # can we serve the local file?
        if serve_local:
            cached_image_url: str = __build_image_path_as_list(item_id_with_ext=item_id_with_ext)
            save_elapsed: float = time.time() - save_start
            if verbose:
                msgproc.log(f"__build_cover_art_url serving cached [{cached_image_url}] "
                            f"for item_id [{item_id}] "
                            f"in [{save_elapsed:.3f}]")
            return cached_image_url
    else:
        return cover_art_url


def __build_image_path_as_list(item_id_with_ext: str) -> list[str]:
    path: list[str] = list()
    path.extend(config.get_webserver_path_images_cache())
    path.append(item_id_with_ext)
    return compose_docroot_url(os.path.join(*path))


def __match_images_only(match_list: list[str]) -> list[str]:
    verbose: bool = config.get_verbose_logging()
    result: list[str] = []
    for m in match_list if match_list else []:
        if match_supported_image_type_by_name(m):
            if verbose:
                msgproc.log(f"__match_images_only matching image success for [{m}]")
            result.append(m)
        else:
            if verbose:
                msgproc.log(f"__match_images_only matching image failed for [{m}]")
    return result


def get_album_disc_numbers(album: Album) -> list[int]:
    disc_list: list[int] = []
    song: Song
    for song in album.getSongs():
        dn: int = song.getDiscNumber()
        if dn and dn not in disc_list:
            disc_list.append(dn)
    return disc_list


def get_song_duration_display(song: Song) -> str:
    return upmpdmeta.get_duration_display_from_sec(duration_sec=song.getDuration())


def get_album_duration_display(album: Album) -> str:
    return upmpdmeta.get_duration_display_from_sec(duration_sec=album.getDuration())


def get_album_disc_and_track_counters(album: Album) -> str:
    disc_title_list: list[dict[str, any]] = album.getItem().getByName(
        constants.ItemKey.DISC_TITLES.value,
        [])
    return get_album_disc_and_track_counters_raw(
        disc_count=len(disc_title_list),
        song_count=album.getSongCount())


def get_album_disc_and_track_counters_raw(
        disc_count: int,
        song_count: int) -> str:
    result: str = f"{disc_count if disc_count and disc_count > 1 else 1} Disc{'s' if (disc_count and disc_count > 1) else ''}, "
    result += f"{song_count} Track{'s' if song_count > 1 else ''}"
    return result


def get_songs_by_album_disc_numbers(album: Album) -> dict[int, list[Song]]:
    res: dict[int, list[Song]] = {}
    song: Song
    for song in album.getSongs():
        dn: int = song.getDiscNumber()
        lst: list[Song] = res[dn] if dn in res else None
        if not lst:
            # build and add to dict
            lst = []
            res[dn] = lst
        # append.
        lst.append(song)
    return res


def __get_name_list(song: Song, item_key: constants.ItemKey.SONG_ALBUM_ARTISTS) -> list[str]:
    result: list[str] = []
    rl: list[dict[str, str]] = song.getItem().getListByName(item_key.value) if song else None
    current: dict[str, str]
    for current in rl:
        if constants.DictKey.NAME.value in current:
            result.append(current[constants.DictKey.NAME.value])
    return result


def __get_song_album_artist_id_list(song: Song) -> list[str]:
    return __get_name_list(song=song, item_key=constants.ItemKey.SONG_ALBUM_ARTISTS)


def get_song_artists_by_type(
        song: Song,
        song_artist_type: SongArtistType,
        skip_duplicate_id: bool = True) -> list[SongArtist]:
    if song_artist_type is None or song_artist_type not in SongArtistType:
        raise Exception("get_song_artists_by_type requires a valid song_artist_type")
    res: list[SongArtist] = []
    item_key: constants.ItemKey = (constants.ItemKey.SONG_ALBUM_ARTISTS
                                   if song_artist_type == SongArtistType.SONG_ALBUM_ARTIST
                                   else constants.ItemKey.SONG_ARTISTS)
    lst: list[dict[str, str]] = song.getItem().getListByName(item_key.value) if song else None
    current: dict[str, str]
    id_set: set[str] = set()
    for current in lst:
        id: str = current[constants.DictKey.ID.value] if constants.DictKey.ID.value in current else None
        name: str = current[constants.DictKey.NAME.value] if constants.DictKey.NAME.value in current else None
        if (not id or not name or id in id_set) and not skip_duplicate_id:
            continue
        id_set.add(id)
        res.append(SongArtist(
            song_id=song.getId(),
            artist_id=id,
            artist_name=name))
    return res


def get_song_contributors(song: Song) -> list[SongContributor]:
    res: list[SongContributor] = []
    lst: list[dict[str, str]] = song.getItem().getListByName(constants.ItemKey.CONTRIBUTORS.value) if song else None
    current: dict[str, str]
    for current in lst:
        role: str = current[constants.DictKey.ROLE.value] if constants.DictKey.ROLE.value in current else None
        sub_role: str = current[constants.DictKey.SUB_ROLE.value] if constants.DictKey.SUB_ROLE.value in current else None
        artist: dict[str, str] = current[constants.DictKey.ARTIST.value] if constants.DictKey.ARTIST.value in current else None
        artist_id: str = artist[constants.DictKey.ID.value] if artist and constants.DictKey.ID.value in artist else None
        artist_name: str = artist[constants.DictKey.NAME.value] if artist and constants.DictKey.NAME.value in artist else None
        if not role or not artist_id or not artist_name:
            continue
        # create structure
        res.append(SongContributor(
            song_id=song.getId(),
            role=role,
            sub_role=sub_role,
            artist_id=artist_id,
            artist_name=artist_name))
    return res


def set_artist_metadata_by_artist_id(artist_id: str, target: dict):
    upnp_util.set_upmpd_meta(
        upmpdmeta.UpMpdMeta.ARTIST_ID,
        artist_id,
        target)
    artist_metadata: persistence.ArtistMetadata = persistence.get_artist_metadata(artist_id=artist_id)
    if artist_metadata:
        upnp_util.set_upnp_meta(
            constants.UpnpMeta.ARTIST,
            artist_metadata.artist_name,
            target)
        upnp_util.set_upmpd_meta(
            upmpdmeta.UpMpdMeta.ARTIST_MUSICBRAINZ_ID,
            artist_metadata.artist_musicbrainz_id,
            target)
        upnp_util.set_upmpd_meta(
            upmpdmeta.UpMpdMeta.ARTIST_ALBUM_COUNT,
            (str(artist_metadata.artist_album_count)
                if artist_metadata.artist_album_count
                else None),
            target)
        upnp_util.set_upmpd_meta(
            upmpdmeta.UpMpdMeta.ARTIST_MEDIA_TYPE,
            name_key_to_display(artist_metadata.artist_media_type),
            target)
    # genres?
    genre_list: list[str] = persistence.get_genre_list_by_artist_id(artist_id=artist_id)
    genre_list.sort()
    genres_display_value: str = join_with_comma(genre_list)
    upnp_util.set_upnp_meta(
        metadata_name=constants.UpnpMeta.GENRE,
        metadata_value=genres_display_value,
        target=target)


def name_key_to_display(name_key: str) -> str:
    if not name_key:
        return None
    name_translator: constants.NameTranslator
    for name_translator in constants.NameTranslator:
        if name_translator.name_key == name_key:
            return name_translator.display_name
    # no match.
    return name_key.capitalize()


def set_artist_metadata(artist: Artist, target: dict):
    upnp_util.set_upnp_meta(
        constants.UpnpMeta.ARTIST,
        artist.getName(),
        target)
    upnp_util.set_upmpd_meta(
        upmpdmeta.UpMpdMeta.ARTIST_ID,
        artist.getId(),
        target)
    upnp_util.set_upmpd_meta(
        upmpdmeta.UpMpdMeta.ARTIST_MUSICBRAINZ_ID,
        get_artist_musicbrainz_id(artist),
        target)
    upnp_util.set_upmpd_meta(
        upmpdmeta.UpMpdMeta.ARTIST_ALBUM_COUNT,
        str(artist.getAlbumCount()),
        target)
    upnp_util.set_upmpd_meta(
        upmpdmeta.UpMpdMeta.ARTIST_MEDIA_TYPE,
        name_key_to_display(get_media_type(artist)),
        target)
    artist_roles: list[str] = get_artist_roles(artist=artist)
    artist_roles = list(map(name_key_to_display, artist_roles))
    upnp_util.set_upmpd_meta(
        upmpdmeta.UpMpdMeta.ARTIST_ROLE,
        join_with_comma(artist_roles),
        target)


def join_with_comma(str_list: list[str]) -> str:
    return ", ".join(str_list if str_list else [])


def set_song_metadata(
        song: Song,
        target: dict,
        album_metadata: AlbumMetadata = None):
    upnp_util.set_upmpd_meta(upmpdmeta.UpMpdMeta.ALBUM_ARTIST, join_with_comma(__get_song_album_artist_id_list(song)), target)
    upnp_util.set_upmpd_meta(upmpdmeta.UpMpdMeta.ALBUM_TITLE, song.getAlbum(), target)
    joined_genres: str = join_with_comma(song.getGenres())
    upnp_util.set_upnp_meta(constants.UpnpMeta.GENRE, joined_genres, target)
    upnp_util.set_upmpd_meta(upmpdmeta.UpMpdMeta.TRACK_DURATION, get_song_duration_display(song), target)
    upnp_util.set_upmpd_meta(upmpdmeta.UpMpdMeta.DISC_NUMBER, song.getDiscNumber(), target)
    upnp_util.set_upmpd_meta(upmpdmeta.UpMpdMeta.TRACK_NUMBER, song.getTrack(), target)
    upnp_util.set_upmpd_meta(upmpdmeta.UpMpdMeta.TRACK_LAST_PLAYED, get_song_played(song), target)
    # album quality if available
    album_id: str = song.getAlbumId()
    album_metadata = (persistence.get_album_metadata(album_id=album_id)
                      if album_metadata is None
                      else album_metadata)
    if album_metadata:
        upnp_util.set_upmpd_meta(upmpdmeta.UpMpdMeta.ALBUM_QUALITY, album_metadata.quality_badge, target)
    # single track quality badge
    track_quality_bade: str = calc_song_list_quality_badge(song_list=[song])
    upnp_util.set_upmpd_meta(upmpdmeta.UpMpdMeta.TRACK_QUALITY, track_quality_bade, target)


def set_album_metadata_by_metadata_only(
        album_metadata: AlbumMetadata,
        target: dict):
    upnp_util.set_upmpd_meta(upmpdmeta.UpMpdMeta.ALBUM_ARTIST, album_metadata.album_display_artist, target)
    upnp_util.set_upmpd_meta(upmpdmeta.UpMpdMeta.ALBUM_TITLE, album_metadata.album_name, target)
    # year
    album_year: str = str(album_metadata.album_year) if album_metadata.album_year else None
    upnp_util.set_upmpd_meta(upmpdmeta.UpMpdMeta.ALBUM_YEAR, album_year, target)
    # release date
    upnp_util.set_upmpd_meta(
        metadata_name=upmpdmeta.UpMpdMeta.ALBUM_RELEASE_DATE,
        metadata_value=album_util.get_formatted_album_date_from_metadata(
            album_metadata=album_metadata,
            release_date_type=album_util.AlbumReleaseDateType.RELEASE_DATE),
        target=target)
    # original release date
    upnp_util.set_upmpd_meta(
        metadata_name=upmpdmeta.UpMpdMeta.ALBUM_ORIGINAL_RELEASE_DATE,
        metadata_value=album_util.get_formatted_album_date_from_metadata(
            album_metadata=album_metadata,
            release_date_type=album_util.AlbumReleaseDateType.ORIGINAL_RELEASE_DATE),
        target=target)
    upnp_util.set_upmpd_meta(upmpdmeta.UpMpdMeta.ALBUM_VERSION, album_metadata.album_version, target)
    played: datetime.datetime = album_metadata.get_value(AlbumMetadataModel.ALBUM_PLAYED)
    if played:
        formatted_played = played.strftime("%Y-%m-%d %H:%M:%S")
        upnp_util.set_upmpd_meta(
            upmpdmeta.UpMpdMeta.ALBUM_LAST_PLAYED,
            formatted_played,
            target)
    else:
        upnp_util.set_upmpd_meta(
            upmpdmeta.UpMpdMeta.ALBUM_LAST_PLAYED,
            "Never",
            target)
    upnp_util.set_upmpd_meta(upmpdmeta.UpMpdMeta.ALBUM_ID, album_metadata.album_id, target)
    upnp_util.set_upmpd_meta(upmpdmeta.UpMpdMeta.ALBUM_MUSICBRAINZ_ID, album_metadata.album_musicbrainz_id, target)
    explicit_status: str = get_explicit_status_display_value(
        explicit_status=album_metadata.album_explicit_status,
        display_mode=constants.ExplicitDiplayMode.LONG)
    upnp_util.set_upmpd_meta(upmpdmeta.UpMpdMeta.ALBUM_EXPLICIT_STATUS, explicit_status, target)
    upnp_util.set_upmpd_meta(
        upmpdmeta.UpMpdMeta.ALBUM_DURATION,
        upmpdmeta.get_duration_display_from_sec(album_metadata.album_duration) if album_metadata.album_duration else None,
        target)
    is_compilation: str = "yes" if album_metadata.get_value(AlbumMetadataModel.ALBUM_IS_COMPILATION) == 1 else "no"
    upnp_util.set_upmpd_meta(upmpdmeta.UpMpdMeta.IS_COMPILATION, is_compilation, target)
    if config.get_config_param_as_bool(constants.ConfigParam.SHOW_META_ALBUM_PATH):
        # album path.
        path_str: str = album_metadata.album_path
        # don't show more than ...
        if path_str and len(path_str) > constants.MetadataMaxLength.ALBUM_PATH.value:
            msgproc.log(f"set_album_metadata album_id: [{album_metadata.album_id}] Path: [{path_str}]")
            path_str = f"<Truncated path> [{path_str[0:constants.MetadataMaxLength.ALBUM_PATH.value]}"
        upnp_util.set_upmpd_meta(upmpdmeta.UpMpdMeta.ALBUM_PATH, path_str, target)
    album_genre_list: str = album_metadata.album_genre_list
    if album_genre_list:
        upnp_util.set_upnp_meta(constants.UpnpMeta.GENRE, album_metadata.album_genre_list, target)
    else:
        upnp_util.set_upnp_meta(constants.UpnpMeta.GENRE, album_metadata.album_genre, target)
    upnp_util.set_upmpd_meta(
        upmpdmeta.UpMpdMeta.MOOD,
        album_metadata.album_mood_list,
        target)
    upnp_util.set_upmpd_meta(
        upmpdmeta.UpMpdMeta.ALBUM_QUALITY_OF_TRACKS,
        album_metadata.album_track_quality_summary,
        target)
    upnp_util.set_upmpd_meta(
        upmpdmeta.UpMpdMeta.ALBUM_COMPRESSION_TYPE,
        album_metadata.album_lossless_status,
        target)
    upnp_util.set_upmpd_meta(
        upmpdmeta.UpMpdMeta.ALBUM_AVERAGE_BITRATE,
        album_metadata.album_average_bitrate,
        target)
    upnp_util.set_upmpd_meta(
        upmpdmeta.UpMpdMeta.ALBUM_RECORD_LABELS,
        album_metadata.album_record_label_list,
        target)
    release_type_list: str = album_metadata.album_release_type_list
    if release_type_list:
        rtl_list: list[str] = release_type_list.split(", ")
        art: AlbumReleaseTypes = AlbumReleaseTypes(rtl_list)
        upnp_util.set_upmpd_meta(
            upmpdmeta.UpMpdMeta.RELEASE_TYPES,
            art.display_name,
            target)
    disc_count: int = album_metadata.album_disc_count if album_metadata.album_disc_count else 1
    disc_track_counters: str = get_album_disc_and_track_counters_raw(
        disc_count=disc_count,
        song_count=album_metadata.album_song_count)
    upnp_util.set_upmpd_meta(upmpdmeta.UpMpdMeta.ALBUM_DISC_AND_TRACK_COUNTERS, disc_track_counters, target)
    upnp_util.set_upmpd_meta(upmpdmeta.UpMpdMeta.ALBUM_MEDIA_TYPE, album_metadata.album_media_type, target)
    upnp_util.set_upmpd_meta(upmpdmeta.UpMpdMeta.ALBUM_QUALITY, album_metadata.quality_badge, target)


def __or_else(v: str, or_else: str) -> str:
    return v if v and len(v) > 0 else or_else


def set_album_metadata(
        album: Album,
        target: dict,
        album_metadata: AlbumMetadata = None):
    upnp_util.set_upmpd_meta(upmpdmeta.UpMpdMeta.ALBUM_ARTIST, get_album_display_artist(album=album), target)
    upnp_util.set_upmpd_meta(upmpdmeta.UpMpdMeta.ALBUM_TITLE, album.getTitle(), target)
    # year
    album_year: str = str(album.getYear()) if album.getYear() else None
    upnp_util.set_upmpd_meta(upmpdmeta.UpMpdMeta.ALBUM_YEAR, album_year, target)
    # release date
    upnp_util.set_upmpd_meta(
        metadata_name=upmpdmeta.UpMpdMeta.ALBUM_RELEASE_DATE,
        metadata_value=album_util.get_formatted_album_release_date(album=album),
        target=target)
    # original release date
    upnp_util.set_upmpd_meta(
        metadata_name=upmpdmeta.UpMpdMeta.ALBUM_ORIGINAL_RELEASE_DATE,
        metadata_value=album_util.get_formatted_album_original_release_date(album=album),
        target=target)
    upnp_util.set_upmpd_meta(upmpdmeta.UpMpdMeta.ALBUM_VERSION, get_album_version(album), target)
    upnp_util.set_upmpd_meta(upmpdmeta.UpMpdMeta.ALBUM_LAST_PLAYED, __or_else(get_album_played(album), "Never"), target)
    joined_genres: str = join_with_comma(album.getGenres())
    upnp_util.set_upnp_meta(constants.UpnpMeta.GENRE, joined_genres, target)
    upnp_util.set_upmpd_meta(upmpdmeta.UpMpdMeta.ALBUM_ID, album.getId(), target)
    record_label_names: str = join_with_comma(get_album_record_label_names(album))
    upnp_util.set_upmpd_meta(upmpdmeta.UpMpdMeta.ALBUM_RECORD_LABELS, record_label_names, target)
    upnp_util.set_upmpd_meta(upmpdmeta.UpMpdMeta.ALBUM_MUSICBRAINZ_ID, get_album_musicbrainz_id(album), target)
    explicit_status: str = get_explicit_status_display_value(
        explicit_status=get_explicit_status(album),
        display_mode=constants.ExplicitDiplayMode.LONG)
    upnp_util.set_upmpd_meta(upmpdmeta.UpMpdMeta.ALBUM_EXPLICIT_STATUS, explicit_status, target)
    upnp_util.set_upmpd_meta(upmpdmeta.UpMpdMeta.ALBUM_DURATION, get_album_duration_display(album), target)
    disc_track_counters: str = get_album_disc_and_track_counters(album)
    upnp_util.set_upmpd_meta(upmpdmeta.UpMpdMeta.ALBUM_DISC_AND_TRACK_COUNTERS, disc_track_counters, target)
    is_compilation_bool: bool = album.getItem().getByName(constants.ItemKey.IS_COMPILATION.value, False)
    is_compilation: str = "yes" if is_compilation_bool else "no"
    upnp_util.set_upmpd_meta(upmpdmeta.UpMpdMeta.IS_COMPILATION, is_compilation, target)
    album_release_types: AlbumReleaseTypes = get_album_release_types(album)
    album_release_types_display: str = album_release_types.display_name if album_has_release_types else None
    upnp_util.set_upmpd_meta(upmpdmeta.UpMpdMeta.RELEASE_TYPES, album_release_types_display, target)
    upnp_util.set_upmpd_meta(upmpdmeta.UpMpdMeta.ALBUM_MEDIA_TYPE, name_key_to_display(get_media_type(album)), target)
    upnp_util.set_upmpd_meta(upmpdmeta.UpMpdMeta.MOOD, join_with_comma(get_album_moods(album)), target)
    if config.get_config_param_as_bool(constants.ConfigParam.SHOW_META_ALBUM_PATH):
        # album path.
        path_list: str = album_util.get_album_path_list(album=album)
        if len(path_list) == 0:
            # album does not have the required information. Might be from an album list
            # we try and see if we have the cached information
            if album_metadata and album_metadata.album_path and len(album_metadata.album_path) > 0:
                path_list = album_metadata.album_path.split(persistence_constants.Separator.PATH.value)
        path_str: str = f"[{'], ['.join(path_list)}]" if len(path_list) > 0 else None
        # don't show more than ...
        if path_str and len(path_str) > constants.MetadataMaxLength.ALBUM_PATH.value:
            msgproc.log(f"set_album_metadata album_id: [{album.getId()}] Path: [{path_str}]")
            path_str = f"<Truncated path> [{path_str[0:constants.MetadataMaxLength.ALBUM_PATH.value]}"
        upnp_util.set_upmpd_meta(upmpdmeta.UpMpdMeta.ALBUM_PATH, path_str, target)
    track_detailed_qualities: str = album_metadata.album_track_quality_summary if album_metadata else None
    upnp_util.set_upmpd_meta(upmpdmeta.UpMpdMeta.ALBUM_QUALITY_OF_TRACKS, track_detailed_qualities, target)
    upnp_util.set_upmpd_meta(
        upmpdmeta.UpMpdMeta.ALBUM_COMPRESSION_TYPE,
        album_metadata.get_value(AlbumMetadataModel.ALBUM_LOSSLESS_STATUS),
        target)
    upnp_util.set_upmpd_meta(
        upmpdmeta.UpMpdMeta.ALBUM_AVERAGE_BITRATE,
        album_metadata.get_value(AlbumMetadataModel.ALBUM_AVERAGE_BITRATE),
        target)


def calc_song_quality_summary(song_list: list[Song]) -> str:
    if song_list is None or len(song_list) == 0:
        # nothing to do as we don't have tracks available
        return None
    # go on!
    q_dict: dict[str, int] = {}
    display_result: str = None
    lossy_count: int = 0
    lossless_count: int = 0
    lossy_key_count: int = 0
    lossless_key_count: int = 0
    song: Song
    for song in song_list:
        # get song quality badge
        song_is_lossy: bool = is_lossy(
            suffix=song.getSuffix(),
            bit_depth=get_song_bit_depth(song=song))
        if song_is_lossy:
            lossy_count += 1
        else:
            lossless_count += 1
        song_quality_badge: str = calc_song_list_quality_badge(song_list=[song])
        if song_quality_badge:
            # get current count, 0 if still not in dict
            count: int = q_dict[song_quality_badge] if song_quality_badge in q_dict else 0
            if count == 0:
                # keep track of new key for lossy or lossless
                if song_is_lossy:
                    lossy_key_count += 1
                else:
                    lossless_key_count += 1
            # store count
            q_dict[song_quality_badge] = count + 1
    if lossy_count > 0 and lossless_count > 0:
        # mixed, return a constant string
        display_result = __mixed_lossless_lossy(
            lossless_count=lossless_count,
            lossy_count=lossy_count)
    # not too many lossy/lossless types allowed
    if lossy_key_count > 3 or lossless_key_count > 3:
        display_result = __mixed_lossless_lossy(
            lossless_count=lossless_count,
            lossy_count=lossy_count)
    if not display_result:
        if len(q_dict) > 1:
            to_display: list[str] = []
            k: str
            v: int
            for k, v in q_dict.items():
                to_display.append(f"{k} ({v} {'songs' if v > 1 else 'song'})")
            display_result: str = join_with_comma(to_display)
        else:
            # use the unique value
            display_result = list(q_dict.keys())[0]
    return display_result


def __mixed_lossless_lossy(lossless_count: int, lossy_count: int) -> str:
    if lossy_count == 0 and lossless_count == 0:
        return None
    if lossless_count == 0:
        return f"lossy ({lossy_count} {'songs' if lossy_count > 1 else 'song'})"
    if lossy_count == 0:
        return f"lossless ({lossless_count})"
    return (f"lossless ({lossless_count} {'songs' if lossless_count > 1 else 'song'}), "
            f"lossy ({lossy_count} {'songs' if lossy_count > 1 else 'song'})")


def get_cached_image_subdir_list() -> list[str]:
    return [
        constants.PluginConstant.PLUGIN_NAME.value,
        constants.PluginConstant.CACHED_IMAGES_DIRECTORY.value]


def cached_images_exist(image_file_name: str) -> list[str]:
    document_root_dir: str = config.getWebServerDocumentRoot()
    if document_root_dir:
        sub_dir_list: list[str] = get_cached_image_subdir_list()
        image_dir: str = ensure_directory(base_dir=document_root_dir, sub_dir_list=sub_dir_list)
        cached_file_name_no_ext: str = f"{str(image_file_name)}"
        cached_files: list[str] = __match_images_only(glob.glob(f"{os.path.join(image_dir, cached_file_name_no_ext)}.*"))
        return cached_files if cached_files else []
    return []


def id_list_join(id_list: list[str]) -> str:
    return ",".join(id_list)


def id_list_split(id_list: str) -> list[str]:
    return id_list.split(",")


def get_mime_type_from_extension(extension: str) -> Optional[str]:
    mimetypes.init()
    """
    Guesses the MIME type for a given file extension.
    Args:
        extension: The file extension (e.g., 'mp3', 'flac').
    Returns:
        The MIME type string, or None if not found.
    """
    # 1. Ensure the extension has a leading dot, as guess_type
    #    expects a filename/path (e.g., 'dummy.flac').
    if not extension.startswith('.'):
        extension = '.' + extension
    # 2. Create a dummy filename using the extension
    #    os.path.join handles path separators correctly.
    dummy_filename = 'dummy' + extension
    # 3. Use guess_type()
    #    It returns a tuple (mimetype, encoding)
    mimetype, _ = mimetypes.guess_type(dummy_filename, strict=True)
    return mimetype
