diff --git a/beetsplug/tidal.py b/beetsplug/tidal.py new file mode 100644 index 0000000000..c6008000d1 --- /dev/null +++ b/beetsplug/tidal.py @@ -0,0 +1,1002 @@ +from __future__ import annotations + +import json +import os.path +import re +from datetime import datetime +from typing import TYPE_CHECKING, Any + +import backoff +import cachetools +import confuse +import requests +import tidalapi + +from beets import logging, ui +from beets.autotag.hooks import AlbumInfo, TrackInfo +from beets.plugins import BeetsPlugin +from beets.util import bytestring_path, remove, syspath + +if TYPE_CHECKING: + import optparse + + from beets.importer import ImportSession, ImportTask + from beets.library import Item, Library + + +def backoff_handler(details: dict[Any, Any]) -> None: + """Handler for rate limiting backoff""" + # Check to make sure the logger is defined before we log + # Even though this function should never run before __init__ + assert isinstance(TidalPlugin.logger, logging.BeetsLogger) is True + + TidalPlugin.logger.debug( + "Rate limited! Cooling off for {wait:0.1f} seconds \ + after calling function {target.__name__} {tries} times".format( + **details + ) + ) + + +class TidalPlugin(BeetsPlugin): + """TidalPlugin is a TIDAL source for the autotagger""" + + # The built-in beets logger is an instance variable, so to access it + # in the backoff_handler, we have to assign it to a static variable. + logger: logging.BeetsLogger + + data_source: str = "tidal" + track_share_regex: str = r"(tidal.com\/browse\/track\/)([0-9]*)(\?u)" # Format: https://tidal.com/browse/track/221182395?u + album_share_regex: str = r"(tidal.com\/browse\/album\/)([0-9]*)(\?u)" # Format: https://tidal.com/browse/album/221182592?u + + # Grabs record label name from copyright info + # Essentially, this just grabs all non-whitespace, non-numerical characters + # This is needed as the copyright value is freeform for the distributors + # This has been tested with the following formats: + # (C) 2020 record label, 2025 record label, © 2020 record label, © record label, + # and just record label. + copyright_regex: str = r"(?!\()(?![Cc])(?!\))(?!©)(?!\s)[\D]+" + + # List is NOT sorted in _grab_art, must be lowest to highest + valid_art_res: list[int] = [80, 160, 320, 640, 1280] + + # Number of times to retry when we get a TooManyRequests exception + rate_limit_retries: int = 16 + + def __init__(self) -> None: + super().__init__() + TidalPlugin.logger = self._log + + # A separate write handler is needed as import stages are ran + # before any file manipulation is done therefore + # any file writes in the import stage operate on the original file + self.import_stages = [self.stage] + self.register_listener("write", self.write_file) + + # This handler runs before import to load our session and to error out + # if needed... If this code is put in __init__, then the plugin CLI + # cannot run. + self.register_listener("import_begin", self.import_begin) + + # Import config + # The lyrics search limit is much smaller than the metadata search limit + # as the current implementation is very API-heavy and TIDAL heavily + # rate limits the lyrics API so increasing the limit causes + # an __exponential__ increase in API calls. + self.config.add( + { + "auto": False, + "lyrics": True, + "synced_lyrics": True, + "overwrite_lyrics": False, + "metadata_search_limit": 25, + "lyrics_search_limit": 10, + "lyrics_no_duration_valid": False, + "search_max_altartists": 5, + "max_lyrics_time_difference": 5, + "tokenfile": "tidal_token.json", + "write_sidecar": False, + "max_art_resolution": 1280, + } + ) + + # Validate that max_art_resolution is a valid value + if self.config["max_art_resolution"].get() not in self.valid_art_res: + raise ui.UserError( + ( + f"Config value max_art_resolution " + f"has to be one of {self.valid_art_res} " + f"and is currently {self.config['max_art_resolution'].as_number()}" + ) + ) + + self.sessfile = self.config["tokenfile"].get( + confuse.Filename(in_app_dir=True) + ) + + # tidalapi.session.Session object we throw around to execute API calls with + self.sess: tidalapi.Session + + def _load_session(self, fatal: bool = False) -> bool: + """Loads a TIDAL session from a JSON file to the class singleton""" + try: + self.sess + self._log.debug( + "Not attempting to load session state as we already have a session!" + ) + return True + except AttributeError: + # AttributeError in this case is expected as the variable is not + # defined except for the type hinting. + pass + + self._log.debug( + f"Attempting to load session state from {self.sessfile}!" + ) + self.sess = tidalapi.session.Session() + + # Attempt to load OAuth data from token file + try: + with open(self.sessfile) as file: + sess_data = json.load(file) + except (OSError, json.JSONDecodeError): + # Error occured, most likely token file does not exist. + self._log.debug("Session state file does not exist or is corrupt") + if fatal: + raise ui.UserError( + ( + "Please login to TIDAL" + " using `beets tidal --login` or disable tidal plugin" + ) + ) + else: + return False + else: + # Got some JSON data from the file + # Let's load the data into a session and check for validity. + self.sess.load_oauth_session( + sess_data["token_type"], + sess_data["access_token"], + sess_data["refresh_token"], + datetime.fromisoformat(sess_data["expiry_time"]), + ) + + if not self.sess.check_login(): + self._log.debug( + "Session state loaded but check_login() returned False" + ) + + # Clear session file so we don't keep spamming the API + with open(self.sessfile, "w") as file: + self._log.debug( + "Clearing session state file to avoid unneeded API calls" + ) + + remove(bytestring_path(self.sessfile), soft=True) + + if fatal: + raise ui.UserError( + ( + "Please login to TIDAL" + " using `beets tidal --login` or disable tidal plugin" + ) + ) + else: + return False + + # Resave the session if login succeeded and the token has expired + # This means we renewed the token + if datetime.now() > datetime.fromisoformat( + sess_data["expiry_time"] + ): + self._log.debug("Resaving session due to token renewal...") + self._save_session(self.sess) + + return True + + def _save_session(self, sess: tidalapi.session.Session) -> None: + """Saves a TIDAL session to a JSON file""" + self._log.debug(f"Saving session state to {self.sessfile}!") + with open(self.sessfile, "w") as file: + json.dump( + { + "token_type": sess.token_type, + "access_token": sess.access_token, + "refresh_token": sess.refresh_token, + "expiry_time": sess.expiry_time.isoformat(), + }, + file, + ) + + def _login(self) -> None: + """Creates a session to use with the TIDAL API""" + self.sess = tidalapi.session.Session() + login, future = self.sess.login_oauth() + ui.print_( + f"Open the following URL to complete login: https://{login.verification_uri_complete}" + ) + ui.print_(f"The link expires in {int(login.expires_in)} seconds!") + + if not future.result(): + raise ui.UserError("Login failure! See above output for more info.") + else: + ui.print_("Login successful.") + + self._save_session(self.sess) + + def _refresh_metadata(self, lib: Library) -> None: + """Refreshes metadata for TIDAL tagged tracks. + + Currently, this only updates popularity.""" + self._log.debug("Refreshing metadata for TIDAL tracks") + self._load_session(fatal=True) + assert isinstance(self.sess, tidalapi.session.Session) is True + + for item in lib.items("tidal_track_id::[0-9]+"): + self._log.debug(f"Processing item {item.title}") + try: + tidaltrack = self.sess.track(item["tidal_track_id"]) + except tidalapi.exceptions.ObjectNotFound: + self._log.warn( + ( + f"TIDAL ID exists for track {item.title} " + "yet TIDAL returns no track" + ) + ) + continue + + item["tidal_track_popularity"] = tidaltrack.popularity + item.try_sync(ui.should_write(), ui.should_move()) + + for album in lib.albums("tidal_album_id::[0-9]+"): + self._log.debug(f"Processing album {album.album}") + try: + tidalalbum = self.sess.album(item["tidal_album_id"]) + except tidalapi.exceptions.ObjectNotFound: + self._log.warn( + ( + f"TIDAL ID exists for album {album.album} " + " yet TIDAL returns no album" + ) + ) + continue + + album["popularity"] = tidalalbum.popularity + album.try_sync(ui.should_write(), ui.should_move()) + + def cmd_main( + self, lib: Library, opts: optparse.Values, arg: list[Any] + ) -> None: + if opts.login: + self._log.debug("Running login routine!") + self._login() + elif opts.fetch: + self._log.debug(f"Force fetching lyrics for track ID {opts.fetch}") + self._load_session(fatal=True) + assert isinstance(self.sess, tidalapi.session.Session) is True + + try: + track = self.sess.track(opts.fetch) + except tidalapi.exceptions.ObjectNotFound: + raise ui.UserError(f"Track with ID {opts.fetch} not found") + + ui.print_(self._get_lyrics(track)) + elif opts.refresh: + self._refresh_metadata(lib) + + def commands( + self, + ) -> list[ui.Subcommand]: + cmd = ui.Subcommand("tidal", help="fetch metadata from TIDAL") + cmd.parser.add_option( + "-l", + "--login", + dest="login", + action="store_true", + default=False, + help="login to TIDAL", + ) + + cmd.parser.add_option( + "-f", + "--fetch", + dest="fetch", + default=None, + help="Fetch lyrics", + ) + + cmd.parser.add_option( + "-r", + "--refresh", + dest="refresh", + action="store_true", + default=False, + help="Refresh metadata for TIDAL tagged tracks", + ) + + cmd.func = self.cmd_main + return [cmd] + + def album_for_id(self, album_id: str) -> AlbumInfo | None: + """Return TIDAL metadata for a specific TIDAL Album ID""" + assert isinstance(self.sess, tidalapi.session.Session) is True + # This is just the numerical album ID to use with the TIDAL API + tidal_album_id = None + + # Try to use album_id directly, otherwise parse it from URL + try: + tidal_album_id = int(album_id) + self._log.debug("Using track_id directly in album_for_id") + except ValueError: + self._log.debug("album_id is NOT an integer, parsing it with regex") + regx = re.search(self.album_share_regex, album_id) + if not regx: + self._log.debug("Regex returned no matches") + return None + + if len(regx.groups()) != 3: + self._log.debug( + ( + "Album share URL parsing failed because we got" + f"{len(regx.groups())} groups when 3 was expected" + ) + ) + return None + + tidal_album_id = int(regx.groups()[-2]) + + try: + album = self.sess.album(tidal_album_id) + except tidalapi.exceptions.ObjectNotFound: + self._log.debug(f"No album for ID {tidal_album_id}") + return None + + return self._album_to_albuminfo(album) + + def track_for_id(self, track_id: str) -> TrackInfo | None: + """Return TIDAL metadata for a specific TIDAL Track ID""" + self._log.debug(f"Running track_for_id with track {track_id}!") + assert isinstance(self.sess, tidalapi.session.Session) is True + + # This is just the numerical track ID to use with the TIDAL API + tidal_track_id = None + + # Try to use track_id directly, otherwise parse it from URL + try: + tidal_track_id = int(track_id) + self._log.debug("Using track_id directly in track_for_id") + except ValueError: + self._log.debug("track_id is NOT an integer, parsing it with regex") + regx = re.search(self.track_share_regex, track_id) + if not regx: + self._log.debug("Regex returned no matches") + return None + + if len(regx.groups()) != 3: + self._log.debug( + ( + "Track share URL parsing failed because we got" + f"{len(regx.groups())} groups when 3 was expected" + ) + ) + return None + + tidal_track_id = int(regx.groups()[-2]) + + try: + track = self.sess.track(tidal_track_id, with_album=True) + except tidalapi.exceptions.ObjectNotFound: + self._log.debug(f"No track for ID {tidal_track_id}") + return None + + return self._track_to_trackinfo(track, track.album) + + def candidates( + self, + items: Any, + artist: Any, + album: Any, + va_likely: Any, + extra_tags: Any = ..., + ) -> Any: + """Returns TIDAL album candidates for a specific set of items""" + candidates = [] + + self._log.debug( + "Searching for candidates using tidal_album_id from items" + ) + assert isinstance(self.sess, tidalapi.session.Session) is True + for item in items: + if item.get("tidal_album_id", None): + try: + albumi = self._album_to_albuminfo( + self.sess.album(item.tidal_album_id) + ) + candidates.append(albumi) + except tidalapi.exceptions.ObjectNotFound: + self._log.debug( + f"No album found for ID {item.tidal_album_id}" + ) + + self._log.debug( + f"{len(candidates)} Candidates found using tidal_album_id from items!" + ) + self._log.debug("Searching for candidates using _search_album search") + + if va_likely: + candidates += [ + self._album_to_albuminfo(x) + for x in self._tidal_search( + album, + tidalapi.Album, + limit=self.config["metadata_search_limit"].get(int), + ) + ] + + else: + candidates += [ + self._album_to_albuminfo(x) + for x in self._tidal_search( + f"{artist} {album}", + tidalapi.Album, + limit=self.config["metadata_search_limit"].get(int), + ) + ] + + if len(items) == 1: + self._log.debug( + "Searching for candidates using _search_from_metadata due to singleton" + ) + for track in self._search_from_metadata(items[0]): + # Albums might be optional for tracks, + # Let's not break if it isn't defined. + if not track.album: + continue + + try: + candidates.append( + self._album_to_albuminfo( + self.sess.album(track.album.id) + ) + ) + except tidalapi.exceptions.ObjectNotFound: + self._log.debug(f"Album ID {track.album.id} not found") + + return candidates + + def item_candidates( + self, item: Item, artist: str | None, album: str | None + ) -> list[TrackInfo]: + """Returns TIDAL track candidates for a specific item""" + self._log.debug(f"Searching TIDAL for {item}!") + + return [ + self._track_to_trackinfo(x) + for x in self._search_from_metadata( + item, limit=self.config["metadata_search_limit"].as_number() + ) + ] + + def _album_to_albuminfo(self, album: tidalapi.Album) -> AlbumInfo: + """Converts a TIDAL album to a beets AlbumInfo""" + tracks = [] + + # Process tracks + # Not using sparse albums as we already have the album + # so it's not using up any additional API calls. + for track in album.tracks(sparse_album=False): + tracks.append(self._track_to_trackinfo(track, album)) + + # Create basic albuminfo with standard fields + albuminfo = AlbumInfo( + album=album.name, + album_id=album.id, + artist=album.artist.name, + artist_id=album.artist.id, + va=len(album.artists) == 1 + and album.artist.name.lower() == "various artists", + mediums=album.num_volumes, + data_source=self.data_source, + data_url=album.share_url, + tracks=tracks, + barcode=album.universal_product_number, + albumtype=album.type, + artists=[artist.name for artist in album.artists], + artists_ids=[str(artist.id) for artist in album.artists], + label=self._parse_copyright(album.copyright), + cover_art_url=self._grab_art(album), + ) + + # Add TIDAL specific metadata + albuminfo.tidal_album_id = album.id + albuminfo.tidal_artist_id = album.artist.id + albuminfo.tidal_album_popularity = album.popularity # Range: 0 to 100 + + # Add release date if we have one + if album.release_date: + albuminfo.year = album.release_date.year + albuminfo.month = album.release_date.month + albuminfo.day = album.release_date.day + + return albuminfo + + def _grab_art(self, album: tidalapi.Album) -> str | None: + """Grabs the highest resolution valid cover art for a given album.""" + self._log.debug(f"Grabbing album art for album {album.id}") + maxresindx = self.valid_art_res.index( + self.config["max_art_resolution"].get() + ) + artres = self.valid_art_res[: maxresindx + 1] + + # The list of art resolutions to use is reversed as the smaller + # sized art is more likely to succeed. + artres.reverse() + + for res in artres: + # tidalapi.Album.image always returns a string if it does not + # throw an exception. + arturl: str = album.image(res) + if self._validate_art(arturl): + self._log.debug(f"Valid art of resolution {res} found") + return arturl + + self._log.debug(f"No valid art was found for album {album.id}") + return None + + def _validate_art(self, url: str) -> bool: + """Validates album art by attempting to grab it""" + self._log.debug(f"Validating album art URL: {url}") + # HTTP HEAD is used here to reduce load on TIDAL servers + # and we only really need the HTTP response code anyways. + resp = requests.head(url) + try: + resp.raise_for_status() + return True + except requests.exceptions.HTTPError: + return False + + def _parse_copyright(self, copyright: str) -> str: + """Attempts to extract a record label from a freeform + TIDAL copyright string.""" + # This isn't 100% needed, but it makes calling it easier + if not copyright: + return "" + + # The regex module is typed as list[Any] as it can even be a list of lists + # however, the specific regexes that we're using only return strings. + regx: list[str] = re.findall(self.copyright_regex, copyright) + if not regx: + self._log.warn( + ( + "Copyright regex returned no results but " + "we have a copyright, please make a bug report with `beets -vv`." + ) + ) + self._log.debug(f"Copyright: {copyright}") + return "" + + # The last group, if multiple were found, tends to be the correct one. + return regx[-1] + + def _track_to_trackinfo( + self, track: tidalapi.Track, album: tidalapi.Album | None = None + ) -> TrackInfo: + """Converts a TIDAL track to a beets TrackInfo""" + # Create basic trackinfo with standard fields + trackinfo = TrackInfo( + title=track.name, + track_id=track.id, + artist=track.artist.name, + artist_id=track.artist.id, + album=track.album.name, + length=track.duration, + medium=track.volume_num, + medium_index=track.track_num, + index=track.track_num, + data_source=self.data_source, + data_url=track.share_url, + isrc=track.isrc, + artists=[artist.name for artist in track.artists], + artists_ids=[str(artist.id) for artist in track.artists], + label=self._parse_copyright(track.copyright), + ) + + # Add TIDAL specific metadata + trackinfo.tidal_track_id = track.id + trackinfo.tidal_track_popularity = track.popularity # Range: 0 to 100 + trackinfo.tidal_artist_id = track.artist.id + trackinfo.tidal_album_id = track.album.id + + # If we're given an album, add it's data to the track. + # Tidal does NOT return a lot of info on searches to save on bandwidth. + if album: + trackinfo.medium_total = album.num_tracks + + return trackinfo + + def _search_from_metadata( + self, item: Item, limit: int = 10 + ) -> list[tidalapi.Track]: + """Searches TIDAL for tracks matching the given item. + + Currently, this function searches for title, album, artist, + alternative artists, and tracks from album results.""" + self._log.debug(f"_search_from_metadata running for {item}") + + query = [] + tracks = [] + + # Search using title + if item.title: + query = [item.title] + results = self._tidal_search( + " ".join(query), + tidalapi.Track, + limit=limit, + ) + trackids = [x.id for x in results] + tracks += results + + # Search using title + artist + if item.artist: + query = [item.title, item.artist] + results = self._tidal_search( + " ".join(query), + tidalapi.Track, + limit=limit, + ) + trackids += [x.id for x in results] + tracks += results + + # Search using title + artist + album + if item.album: + query = [item.title, item.artist, item.album] + trackids += [x.id for x in tracks] + results = self._tidal_search( + " ".join(query), + tidalapi.Track, + limit=limit, + ) + trackids += [x.id for x in results] + tracks += results + + # Search using title + album + if item.album: + query = [item.title, item.album] + results = self._tidal_search( + " ".join(query), + tidalapi.Track, + limit=limit, + ) + trackids += [x.id for x in results] + tracks += results + + # Search using title + primary artist + alternative artists + maxaltartists = self.config["search_max_altartists"].as_number() + + if item.artists and maxaltartists > 0: + self._log.debug( + "Track has alternative artists... adding them to query" + ) + if len(item.artists) > maxaltartists: + self._log.debug( + ( + f"We have {len(item.artists)} alternative artists" + f"with a max of {maxaltartists}" + ) + ) + + for artist in item.artists[:maxaltartists]: + query.append(artist) + results = self._tidal_search( + " ".join(query), tidalapi.Track, limit=limit + ) + trackids += [x.id for x in results] + tracks += results + + # Search using album + # Does not exist for singletons (or albums imported using -s) + if item.album: + self._log.debug("Searching using album") + for album in self._tidal_search(item.album, tidalapi.Album): + self._log.debug( + f"Using album {album.name} from {album.artist.name}" + ) + for track in album.tracks(): + tracks.append(track) + trackids.append(track.id) + + # Reverse list so the more specific result is first + tracks = list(reversed(tracks)) + + # Remove duplicates + trackids = [] + newtracks = [] + for track in tracks: + if track.id in trackids: + self._log.debug(f"Removing duplicate track {track.id}") + continue + else: + trackids.append(track.id) + newtracks.append(track) + + tracks = newtracks + return tracks + + @cachetools.cached( + cache=cachetools.LFUCache(maxsize=4096), + key=lambda self, query, rtype, *args, **kwargs: (query, rtype), + info=True, + ) + @backoff.on_exception( + backoff.expo, + tidalapi.exceptions.TooManyRequests, + max_tries=rate_limit_retries, + on_backoff=backoff_handler, + factor=2, + ) + def _tidal_search( + self, + query: str, + rtype: tidalapi.Track | tidalapi.Album, + *args: list[Any], + **kwargs: dict[Any, Any], + ) -> tidalapi.Track | tidalapi.Album: + """Simple wrapper for TIDAL search + Used to implement rate limiting and query fixing""" + assert isinstance(self.sess, tidalapi.session.Session) is True + # Both of the substitutions borrowed from https://github.com/arsaboo/beets-tidal/blob/main/beetsplug/tidal.py + # Strip non-word characters from query. Things like "!" and "-" can + # cause a query to return no results, even if they match the artist or + # album title. Use `re.UNICODE` flag to avoid stripping non-english + # word characters. + query = re.sub(r"(?u)\W+", " ", query) + + # Strip medium information from query, Things like "CD1" and "disk 1" + # can also negate an otherwise positive result. + query = re.sub(r"(?i)\b(CD|disc)\s*\d+", "", query) + + if not rtype == tidalapi.Track and not tidalapi.Album == rtype: + raise ValueError( + "Only Track, Album rtypes are supported in _tidal_search" + ) + + # Execute query + self._log.debug( + f"Using query {query} in _tidal_search, returning type {rtype}" + ) + results = self.sess.search(query, [rtype], *args, **kwargs) + + returnresults = [] + + # Process top_hit + # It can not exist and it can also be a completely different type from rtype + if results["top_hit"] and isinstance(results["top_hit"], rtype): + returnresults.append(results["top_hit"]) + + # Strip top_hit from the other results so it is not duplicated + if rtype == tidalapi.Track: + results["tracks"] = [ + x for x in results["tracks"] if x.id != returnresults[0].id + ] + elif rtype == tidalapi.Album: + results["albums"] = [ + x for x in results["albums"] if x.id != returnresults[0].id + ] + + else: + self._log.debug( + ( + "Not using top_hit as it doesn't exist " + "or is the wrong type" + ) + ) + + # Shove the results from the tidalapi call to our list + if rtype == tidalapi.Track: + returnresults = results["tracks"] + elif rtype == tidalapi.Album: + returnresults = results["albums"] + + return returnresults + + @cachetools.cached( + cache=cachetools.LFUCache(maxsize=4096), + key=lambda self, track: track.id, + info=True, + ) + # _get_lyrics has a much higher factor as it is much more rate limited by TIDAL than + # the metadata API + @backoff.on_exception( + backoff.expo, + tidalapi.exceptions.TooManyRequests, + max_tries=rate_limit_retries, + on_backoff=backoff_handler, + base=5, + factor=3, + ) + def _get_lyrics(self, track: tidalapi.Track) -> str | None: + """Obtains lyrics from a TIDAL track""" + self._log.debug(f"Grabbing lyrics for track {track.id}") + + # Grab lyrics + try: + lyrics: tidalapi.Lyrics = track.lyrics() + except tidalapi.exceptions.MetadataNotAvailable: + self._log.info(f"Lyrics not available for track {track.id}") + return None + + # Return either synced lyrics or unsynced depending on config and availability + if self.config["synced_lyrics"]: + if lyrics.subtitles: + self._log.debug( + f"Synced lyrics are available for track {track.id}" + ) + return lyrics.subtitles + else: + self._log.info( + ( + f"Synced lyrics not available for track {track.id}," + "returning unsynced lyrics" + ) + ) + return lyrics.text + else: + return lyrics.text + + def _validate_lyrics( + self, lib_item: Item, tidal_item: tidalapi.Track + ) -> bool: + """Validates lyrics retrieved from TIDAL + + Currently we just use the difference of length in the TIDAL Item vs + the Library item.""" + self._log.debug(f"Validating lyrics for {lib_item.title}!") + maxdiff = self.config["max_lyrics_time_difference"].as_number() + + if maxdiff >= 1: + # Validate that both the item and the Tidal metadata have values + if not lib_item.length or tidal_item.duration == -1: + self._log.debug( + ( + "Not using duration difference to validate lyrics " + "as one or both items don't have a duration!" + ) + ) + return bool(self.config["lyrics_no_duration_valid"]) + + self._log.debug("Using duration difference to validate lyrics") + self._log.debug( + ( + f"Item duration: {lib_item.length}, " + f"Tidal duration: {tidal_item.duration}" + ) + ) + + # Calculate difference + difference = abs(lib_item.length - tidal_item.duration) + difference_over = abs(maxdiff - difference) + + if difference > maxdiff: + self._log.debug( + ( + f"Not using lyrics for {lib_item.title}" + f"as difference is {difference_over} over the max of {maxdiff}" + ) + ) + return False + else: + self._log.debug( + ( + "Not using timestamp lyrics validation " + "due to user configuration" + ) + ) + + # Nothing above invalidated the lyrics, assuming valid + return True + + def _search_lyrics(self, item: Item, limit: int = 10) -> str | None: + """Searches for lyrics using a non-TIDAL metadata source""" + self._log.debug( + f"Searching for lyrics from non-TIDAL metadata for {item.title}" + ) + + tracks = self._search_from_metadata(item, limit=limit) + + # Return lyrics for the first track that has valid lyrics + for track in tracks: + lyric = self._get_lyrics(track) + if lyric and self._validate_lyrics(item, track): + return lyric + + self._log.info(f"No valid results found for {item.title}") + return None + + def _process_item(self, item: Item) -> None: + """Processes an item from the import stage + + This is used to simplify the stage loop.""" + assert isinstance(self.sess, tidalapi.session.Session) is True + + # Fetch lyrics if enabled + if self.config["lyrics"]: + # Don't overwrite lyrics + if not self.config["overwrite_lyrics"] and item.lyrics: + self._log.info( + "Not fetching lyrics because item already has them" + ) + return + + self._log.debug("Fetching lyrics during import... this may fail") + # Use tidal_track_id if defined, aka the metadata came from us + if item.get("tidal_track_id", None): + self._log.debug( + f"Using tidal_track_id of {item.tidal_track_id} to fetch lyrics!" + ) + try: + track = self.sess.track(item.tidal_track_id) + except tidalapi.exceptions.ObjectNotFound: + self._log.warn( + "tidal_track_id is defined but the API returned not found" + ) + return + + item.lyrics = self._get_lyrics(track) + else: + self._log.debug("tidal_track_id is undefined... searching") + item.lyrics = self._search_lyrics( + item, limit=self.config["lyrics_search_limit"].as_number() + ) + + item.store() + + def write_file(self, item: Item, path: str, tags: dict[Any, Any]) -> None: + self._log.debug("Running write handler") + # Write out lyrics to sidecar file if enabled + if self.config["write_sidecar"] and item.lyrics: + # Do tons of os.path operations to get the sidecar path + filepath, filename = os.path.split(syspath(path)) + basename, ext = os.path.splitext(filename) + sidecar_file = f"{basename}.lrc" + sidecar_path = os.path.join(filepath, sidecar_file) + + # Don't overwrite lyrics if we aren't suppose to + if ( + os.path.exists(sidecar_path) + and not self.config["overwrite_lyrics"] + ): + self._log.debug( + ( + "Not writing sidecar file " + "as it already exists and overwrite_lyrics is False" + ) + ) + return + + self._log.debug(f"Saving lyrics to sidecar file {sidecar_path}") + + # Save lyrics + with open(sidecar_path, "w") as file: + file.write(item.lyrics) + + def import_begin(self) -> None: + # Check for session and throw user error if we aren't logged in + self._load_session(fatal=True) + assert isinstance(self.sess, tidalapi.session.Session) is True + + def stage(self, session: ImportSession, task: ImportTask) -> None: + self._log.debug("Running import stage") + if not self.config["auto"]: + self._log.debug("Not processing further due to auto being False") + return + + for item in task.imported_items(): + self._process_item(item) + + self._log.debug( + f"_get_lyrics cache: {self._get_lyrics.cache_info()}" + ) + self._log.debug( + f"_tidal_search cache: {self._tidal_search.cache_info()}" + ) diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index 6705344c9d..d5910abee2 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -128,6 +128,7 @@ following to your configuration:: substitute the thumbnails + tidal types unimported web @@ -151,12 +152,16 @@ Autotagger Extensions :doc:`deezer ` Search for releases in the `Deezer`_ database. +:doc:`tidal ` + Search for releases and lyrics in the `TIDAL`_ database. + :doc:`fromfilename ` Guess metadata for untagged tracks from their filenames. .. _Discogs: https://www.discogs.com/ .. _Spotify: https://www.spotify.com .. _Deezer: https://www.deezer.com/ +.. _TIDAL: https://www.tidal.com/ Metadata -------- diff --git a/docs/plugins/tidal.rst b/docs/plugins/tidal.rst new file mode 100644 index 0000000000..43f8b9515b --- /dev/null +++ b/docs/plugins/tidal.rst @@ -0,0 +1,91 @@ +TIDAL Plugin +============== + +The ``tidal`` plugin provides metadata matches for the importer and lyrics using the +unofficial `TIDAL`_ client APIs. + +.. _TIDAL: https://www.tidal.com +.. _TIDAL API Reference Module: https://github.com/tamland/python-tidal/tree/master + +Basic Usage +----------- + +First, enable the ``tidal`` plugin (see :ref:`using-plugins`). + +Then, login to an active TIDAL account using ``beets tidal --login`` like so:: + + Open the following URL to complete login: https://link.tidal.com/ABCDE + The link expires in 300 seconds! + +Click on the link and follow the TIDAL prompts, the plugin should resume after linking is complete. + +An unpaid account works for basic metadata queries but for lyrics, you'll need a paid account. + +You can enter the URL or ID for an album or song on TIDAL at the ``enter Id`` +prompt during import:: + + Enter search, enter Id, aBort, eDit, edit Candidates, plaY? i + Enter release ID: https://tidal.com/browse/album/20638857?u OR https://tidal.com/browse/track/20638859?u + +Using a browse link works, as well as putting in the numerical ID directly like so:: + + Enter search, enter Id, aBort, eDit, edit Candidates, plaY? i + Enter release ID: 20638857 OR 20638859 + +Other than that, the plugin has no interface outside of the standard beets interface. + +Lyrics are obtained automatically depending on configuration and metadata is either in the candidate list or +entered in manually with ``enter Id``. + +Configuration +------------- + +This plugin can be configured like other metadata source plugins as described in :ref:`metadata-source-plugin-configuration`. + +The defaults are reasonable but the plugin does NOT process any files by default. + +The available options under the ``tidal:`` section are: + +- **auto**: Process files upon import. + Default: ``no`` +- **lyrics**: Grab lyrics when processing files. + Default: ``yes`` +- **synced_lyrics**: Grab synced LRC lyrics. + Default: ``yes`` +- **overwrite_lyrics**: Overwrite existing lyrics. + Default: ``no`` +- **metadata_search_limit**: How many items to query the API for when searching for metadata, ie. on candidate search. + Default: ``25`` +- **lyrics_search_limit**: How many items to query the API for when searching for lyrics. + The lyrics API is heavily rate limited by TIDAL and the plugin does around 5-10 metadata searches per track when searching for lyrics, + so it is best to keep this number as low as possible. The plugin will slow down if rate limited. + Default: ``10`` +- **lyrics_no_duration_valid**: If tracks with no duration are valid candidates when searching for lyrics. + Default: ``no`` +- **search_max_altartists**: Maximum number of non-primary artists to add to searches. + Each additional artist uses 1-2 metadata API calls and tracks can have near infinite alternative artists, + so it is best to keep this as low as possible. + + Can be set to 0 to disable searching with alternative artists. + Default: ``5`` +- **max_lyrics_time_difference**: How far off the duration of tracks can be and still be suitable for lyrics. + This is so technically valid search results don't cause incorrect lyrics to be applied to tracks. + + Can be set to 0 to consider all tracks valid. + Default: ``5`` +- **tokenfile**: What filename in the beets configuration directory to use for storing the login session. + This should never need to be changed, but it is technically user configurable. + Default: ``tidal_token.json`` +- **write_sidecar**: Write lyrics to an accompanying LRC file with the track. + This is useful for certain music players that cannot use embedded lyrics. + Default: ``False`` +- **max_art_resolution**: Maximum resolution to use when grabbing album art. + TIDAL lossy encodes album art, so using this option instead of resizing results in a + higher quality art. + + The format of this option is a single number, as all album art returned from TIDAL is in a 1:1 + aspect ratio. + + Must be one of: ``80, 160, 320, 640, 1280`` + + Default: ``1280`` \ No newline at end of file diff --git a/poetry.lock b/poetry.lock index 8fa603a133..aa2698d129 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.5 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.0.1 and should not be changed by hand. [[package]] name = "accessible-pygments" @@ -6,6 +6,8 @@ version = "0.0.5" description = "A collection of accessible pygments styles" optional = true python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"docs\"" files = [ {file = "accessible_pygments-0.0.5-py3-none-any.whl", hash = "sha256:88ae3211e68a1d0b011504b2ffc1691feafce124b845bd072ab6f9f66f34d4b7"}, {file = "accessible_pygments-0.0.5.tar.gz", hash = "sha256:40918d3e6a2b619ad424cb91e556bd3bd8865443d9f22f1dcdf79e33c8046872"}, @@ -24,6 +26,8 @@ version = "0.7.16" description = "A light, configurable Sphinx theme" optional = true python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"docs\"" files = [ {file = "alabaster-0.7.16-py3-none-any.whl", hash = "sha256:b46733c07dce03ae4e150330b975c75737fa60f0a7c591b6c8bf4928a28e2c92"}, {file = "alabaster-0.7.16.tar.gz", hash = "sha256:75a8b99c28a5dad50dd7f8ccdd447a121ddb3892da9e53d1ca5cca3106d58d65"}, @@ -35,6 +39,7 @@ version = "4.6.2.post1" description = "High level compatibility layer for multiple asynchronous event loop implementations" optional = false python-versions = ">=3.9" +groups = ["main", "test"] files = [ {file = "anyio-4.6.2.post1-py3-none-any.whl", hash = "sha256:6d170c36fba3bdd840c73d3868c1e777e33676a69c3a72cf0a0d5d6d8009b61d"}, {file = "anyio-4.6.2.post1.tar.gz", hash = "sha256:4c8bc31ccdb51c7f7bd251f51c609e038d63e34219b44aa86e47576389880b4c"}, @@ -57,6 +62,8 @@ version = "1.4.4" description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." optional = true python-versions = "*" +groups = ["main"] +markers = "extra == \"sonosupdate\"" files = [ {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"}, {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"}, @@ -68,6 +75,8 @@ version = "3.0.1" description = "Multi-library, cross-platform audio decoding." optional = true python-versions = ">=3.6" +groups = ["main"] +markers = "extra == \"autobpm\" or extra == \"chroma\"" files = [ {file = "audioread-3.0.1-py3-none-any.whl", hash = "sha256:4cdce70b8adc0da0a3c9e0d85fb10b3ace30fbdf8d1670fd443929b61d117c33"}, {file = "audioread-3.0.1.tar.gz", hash = "sha256:ac5460a5498c48bdf2e8e767402583a4dcd13f4414d286f42ce4379e8b35066d"}, @@ -82,6 +91,8 @@ version = "2.16.0" description = "Internationalization utilities" optional = true python-versions = ">=3.8" +groups = ["main"] +markers = "extra == \"docs\"" files = [ {file = "babel-2.16.0-py3-none-any.whl", hash = "sha256:368b5b98b37c06b7daf6696391c3240c938b37767d4584413e8438c5c435fa8b"}, {file = "babel-2.16.0.tar.gz", hash = "sha256:d1f3554ca26605fe173f3de0c65f750f5a42f924499bf134de6423582298e316"}, @@ -90,12 +101,26 @@ files = [ [package.extras] dev = ["freezegun (>=1.0,<2.0)", "pytest (>=6.0)", "pytest-cov"] +[[package]] +name = "backoff" +version = "2.2.1" +description = "Function decoration for backoff and retry" +optional = true +python-versions = ">=3.7,<4.0" +groups = ["main"] +markers = "extra == \"tidal\"" +files = [ + {file = "backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8"}, + {file = "backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba"}, +] + [[package]] name = "beautifulsoup4" version = "4.12.3" description = "Screen-scraping library" optional = false python-versions = ">=3.6.0" +groups = ["main", "test"] files = [ {file = "beautifulsoup4-4.12.3-py3-none-any.whl", hash = "sha256:b80878c9f40111313e55da8ba20bdba06d8fa3969fc68304167741bbf9e082ed"}, {file = "beautifulsoup4-4.12.3.tar.gz", hash = "sha256:74e3d1928edc070d21748185c46e3fb33490f22f52a3addee9aee0f4f7781051"}, @@ -117,6 +142,7 @@ version = "1.9.0" description = "Fast, simple object-to-object and broadcast signaling" optional = false python-versions = ">=3.9" +groups = ["main", "test", "typing"] files = [ {file = "blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc"}, {file = "blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf"}, @@ -128,6 +154,8 @@ version = "1.1.0" description = "Python bindings for the Brotli compression library" optional = false python-versions = "*" +groups = ["main", "test"] +markers = "platform_python_implementation == \"CPython\"" files = [ {file = "Brotli-1.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e1140c64812cb9b06c922e77f1c26a75ec5e3f0fb2bf92cc8c58720dec276752"}, {file = "Brotli-1.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c8fd5270e906eef71d4a8d19b7c6a43760c6abcfcc10c9101d14eb2357418de9"}, @@ -262,6 +290,8 @@ version = "1.1.0.0" description = "Python CFFI bindings to the Brotli library" optional = false python-versions = ">=3.7" +groups = ["main", "test"] +markers = "platform_python_implementation == \"PyPy\"" files = [ {file = "brotlicffi-1.1.0.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9b7ae6bd1a3f0df532b6d67ff674099a96d22bc0948955cb338488c31bfb8851"}, {file = "brotlicffi-1.1.0.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:19ffc919fa4fc6ace69286e0a23b3789b4219058313cf9b45625016bf7ff996b"}, @@ -295,12 +325,26 @@ files = [ [package.dependencies] cffi = ">=1.0.0" +[[package]] +name = "cachetools" +version = "5.5.1" +description = "Extensible memoizing collections and decorators" +optional = true +python-versions = ">=3.7" +groups = ["main"] +markers = "extra == \"tidal\"" +files = [ + {file = "cachetools-5.5.1-py3-none-any.whl", hash = "sha256:b76651fdc3b24ead3c648bbdeeb940c1b04d365b38b4af66788f9ec4a81d42bb"}, + {file = "cachetools-5.5.1.tar.gz", hash = "sha256:70f238fbba50383ef62e55c6aff6d9673175fe59f7c6782c7a0b9e38f4a9df95"}, +] + [[package]] name = "certifi" version = "2024.8.30" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.6" +groups = ["main", "test"] files = [ {file = "certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8"}, {file = "certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9"}, @@ -312,6 +356,7 @@ version = "1.17.1" description = "Foreign Function Interface for Python calling C code." optional = false python-versions = ">=3.8" +groups = ["main", "test"] files = [ {file = "cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14"}, {file = "cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67"}, @@ -381,6 +426,7 @@ files = [ {file = "cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662"}, {file = "cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824"}, ] +markers = {main = "extra == \"autobpm\" or platform_python_implementation == \"PyPy\" or extra == \"reflink\"", test = "platform_python_implementation == \"PyPy\""} [package.dependencies] pycparser = "*" @@ -391,6 +437,7 @@ version = "3.4.0" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7.0" +groups = ["main", "test"] files = [ {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4f9fc98dad6c2eaa32fc3af1417d95b5e3d08aff968df0cd320066def971f9a6"}, {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0de7b687289d3c1b3e8660d0741874abe7888100efe14bd0f9fd7141bcbda92b"}, @@ -505,6 +552,7 @@ version = "8.1.7" description = "Composable command line interface toolkit" optional = false python-versions = ">=3.7" +groups = ["main", "release", "test", "typing"] files = [ {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, @@ -519,6 +567,7 @@ version = "2.1.13" description = "Hosted coverage reports for GitHub, Bitbucket and Gitlab" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +groups = ["test"] files = [ {file = "codecov-2.1.13-py2.py3-none-any.whl", hash = "sha256:c2ca5e51bba9ebb43644c43d0690148a55086f7f5e6fd36170858fa4206744d5"}, {file = "codecov-2.1.13.tar.gz", hash = "sha256:2362b685633caeaf45b9951a9b76ce359cd3581dd515b430c6c3f5dfb4d92a8c"}, @@ -534,6 +583,8 @@ version = "0.4.6" description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["main", "release", "test", "typing"] +markers = "sys_platform == \"win32\" or platform_system == \"Windows\"" files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, @@ -545,6 +596,7 @@ version = "2.0.1" description = "Painless YAML configuration." optional = false python-versions = ">=3.6" +groups = ["main"] files = [ {file = "confuse-2.0.1-py3-none-any.whl", hash = "sha256:9b9e5bbc70e2cb9b318bcab14d917ec88e21bf1b724365e3815eb16e37aabd2a"}, {file = "confuse-2.0.1.tar.gz", hash = "sha256:7379a2ad49aaa862b79600cc070260c1b7974d349f4fa5e01f9afa6c4dd0611f"}, @@ -559,6 +611,7 @@ version = "7.6.8" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.9" +groups = ["test"] files = [ {file = "coverage-7.6.8-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b39e6011cd06822eb964d038d5dff5da5d98652b81f5ecd439277b32361a3a50"}, {file = "coverage-7.6.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:63c19702db10ad79151a059d2d6336fe0c470f2e18d0d4d1a57f7f9713875dcf"}, @@ -636,6 +689,8 @@ version = "1.3.2" description = "Python bindings for libdbus" optional = true python-versions = ">=3.7" +groups = ["main"] +markers = "extra == \"metasync\"" files = [ {file = "dbus-python-1.3.2.tar.gz", hash = "sha256:ad67819308618b5069537be237f8e68ca1c7fcc95ee4a121fe6845b1418248f8"}, ] @@ -650,6 +705,8 @@ version = "5.1.1" description = "Decorators for Humans" optional = true python-versions = ">=3.5" +groups = ["main"] +markers = "extra == \"autobpm\"" files = [ {file = "decorator-5.1.1-py3-none-any.whl", hash = "sha256:b8c3f85900b9dc423225913c5aace94729fe1fa9763b38939a95226f02d37186"}, {file = "decorator-5.1.1.tar.gz", hash = "sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330"}, @@ -661,6 +718,8 @@ version = "0.21.2" description = "Docutils -- Python Documentation Utilities" optional = true python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"docs\"" files = [ {file = "docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2"}, {file = "docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f"}, @@ -672,6 +731,8 @@ version = "1.2.2" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" +groups = ["main", "test"] +markers = "python_version < \"3.11\"" files = [ {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, @@ -686,6 +747,7 @@ version = "1.2.0" description = "Infer file type and MIME type of any file/buffer. No external dependencies." optional = false python-versions = "*" +groups = ["main"] files = [ {file = "filetype-1.2.0-py2.py3-none-any.whl", hash = "sha256:7ce71b6880181241cf7ac8697a2f1eb6a8bd9b429f7ad6d27b8db9ba5f1c2d25"}, {file = "filetype-1.2.0.tar.gz", hash = "sha256:66b56cd6474bf41d8c54660347d37afcc3f7d1970648de365c102ef77548aadb"}, @@ -697,6 +759,7 @@ version = "3.1.0" description = "A simple framework for building complex web applications." optional = false python-versions = ">=3.9" +groups = ["main", "test", "typing"] files = [ {file = "flask-3.1.0-py3-none-any.whl", hash = "sha256:d667207822eb83f1c4b50949b1623c8fc8d51f2341d65f72e1a1815397551136"}, {file = "flask-3.1.0.tar.gz", hash = "sha256:5f873c5184c897c8d9d1b05df1e3d01b14910ce69607a117bd3277098a5836ac"}, @@ -720,6 +783,8 @@ version = "5.0.0" description = "A Flask extension adding a decorator for CORS support" optional = true python-versions = "*" +groups = ["main"] +markers = "extra == \"aura\" or extra == \"web\"" files = [ {file = "Flask_Cors-5.0.0-py2.py3-none-any.whl", hash = "sha256:b9e307d082a9261c100d8fb0ba909eec6a228ed1b60a8315fd85f783d61910bc"}, {file = "flask_cors-5.0.0.tar.gz", hash = "sha256:5aadb4b950c4e93745034594d9f3ea6591f734bb3662e16e255ffbf5e89c88ef"}, @@ -734,6 +799,7 @@ version = "0.14.0" description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" optional = false python-versions = ">=3.7" +groups = ["main", "test"] files = [ {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, @@ -745,6 +811,7 @@ version = "1.0.7" description = "A minimal low-level HTTP client." optional = false python-versions = ">=3.8" +groups = ["main", "test"] files = [ {file = "httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd"}, {file = "httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c"}, @@ -766,6 +833,7 @@ version = "0.28.0" description = "The next generation HTTP client." optional = false python-versions = ">=3.8" +groups = ["main", "test"] files = [ {file = "httpx-0.28.0-py3-none-any.whl", hash = "sha256:dc0b419a0cfeb6e8b34e85167c0da2671206f5095f1baa9663d23bcfd6b535fc"}, {file = "httpx-0.28.0.tar.gz", hash = "sha256:0858d3bab51ba7e386637f22a61d8ccddaeec5f3fe4209da3a6168dbb91573e0"}, @@ -790,6 +858,7 @@ version = "3.10" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.6" +groups = ["main", "test"] files = [ {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, @@ -804,6 +873,8 @@ version = "0.2.0" description = "Cross-platform network interface and IP address enumeration library" optional = true python-versions = "*" +groups = ["main"] +markers = "extra == \"sonosupdate\"" files = [ {file = "ifaddr-0.2.0-py3-none-any.whl", hash = "sha256:085e0305cfe6f16ab12d72e2024030f5d52674afad6911bb1eee207177b8a748"}, {file = "ifaddr-0.2.0.tar.gz", hash = "sha256:cc0cbfcaabf765d44595825fb96a99bb12c79716b73b44330ea38ee2b0c4aed4"}, @@ -815,6 +886,8 @@ version = "1.4.1" description = "Getting image size from png/jpeg/jpeg2000/gif file" optional = true python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +groups = ["main"] +markers = "extra == \"docs\"" files = [ {file = "imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b"}, {file = "imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a"}, @@ -826,6 +899,8 @@ version = "8.5.0" description = "Read metadata from Python packages" optional = false python-versions = ">=3.8" +groups = ["main", "test", "typing"] +markers = "python_version < \"3.10\"" files = [ {file = "importlib_metadata-8.5.0-py3-none-any.whl", hash = "sha256:45e54197d28b7a7f1559e60b95e7c567032b602131fbd588f1497f47880aa68b"}, {file = "importlib_metadata-8.5.0.tar.gz", hash = "sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7"}, @@ -849,6 +924,7 @@ version = "1.0.0" description = "deflate64 compression/decompression library" optional = false python-versions = ">=3.8" +groups = ["main", "test"] files = [ {file = "inflate64-1.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a90c0bdf4a7ecddd8a64cc977181810036e35807f56b0bcacee9abb0fcfd18dc"}, {file = "inflate64-1.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:57fe7c14aebf1c5a74fc3b70d355be1280a011521a76aa3895486e62454f4242"}, @@ -916,17 +992,32 @@ version = "2.0.0" description = "brain-dead simple config-ini parsing" optional = false python-versions = ">=3.7" +groups = ["test"] files = [ {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, ] +[[package]] +name = "isodate" +version = "0.7.2" +description = "An ISO 8601 date/time/duration parser and formatter" +optional = true +python-versions = ">=3.7" +groups = ["main"] +markers = "extra == \"tidal\"" +files = [ + {file = "isodate-0.7.2-py3-none-any.whl", hash = "sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15"}, + {file = "isodate-0.7.2.tar.gz", hash = "sha256:4cd1aa0f43ca76f4a6c6c0292a85f40b35ec2e43e315b59f06e6d32171a953e6"}, +] + [[package]] name = "itsdangerous" version = "2.2.0" description = "Safely pass data to untrusted environments and back." optional = false python-versions = ">=3.8" +groups = ["main", "test", "typing"] files = [ {file = "itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef"}, {file = "itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173"}, @@ -938,6 +1029,7 @@ version = "1.1.0" description = "Approximate and phonetic matching of strings." optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "jellyfish-1.1.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:feb1fa5838f2bb6dbc9f6d07dabf4b9d91e130b289d72bd70dc33b651667688f"}, {file = "jellyfish-1.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:623fa58cca9b8e594a46e7b9cf3af629588a202439d97580a153d6af24736a1b"}, @@ -1028,6 +1120,7 @@ version = "3.1.4" description = "A very fast and expressive template engine." optional = false python-versions = ">=3.7" +groups = ["main", "test", "typing"] files = [ {file = "jinja2-3.1.4-py3-none-any.whl", hash = "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d"}, {file = "jinja2-3.1.4.tar.gz", hash = "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369"}, @@ -1045,6 +1138,8 @@ version = "1.4.2" description = "Lightweight pipelining with Python functions" optional = true python-versions = ">=3.8" +groups = ["main"] +markers = "extra == \"autobpm\"" files = [ {file = "joblib-1.4.2-py3-none-any.whl", hash = "sha256:06d478d5674cbc267e7496a410ee875abd68e4340feff4490bcb7afb88060ae6"}, {file = "joblib-1.4.2.tar.gz", hash = "sha256:2382c5816b2636fbd20a09e0f4e9dad4736765fdfb7dca582943b9c1366b3f0e"}, @@ -1056,6 +1151,8 @@ version = "1.0.9" description = "Language detection library ported from Google's language-detection." optional = true python-versions = "*" +groups = ["main"] +markers = "extra == \"fetchart\" or extra == \"lyrics\"" files = [ {file = "langdetect-1.0.9-py2-none-any.whl", hash = "sha256:7cbc0746252f19e76f77c0b1690aadf01963be835ef0cd4b56dddf2a8f1dfc2a"}, {file = "langdetect-1.0.9.tar.gz", hash = "sha256:cbc1fef89f8d062739774bd51eda3da3274006b3661d199c2655f6b3f6d605a0"}, @@ -1070,6 +1167,7 @@ version = "0.5.12" description = "Linear Assignment Problem solver (LAPJV/LAPMOD)." optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "lap-0.5.12-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8c3a38070b24531949e30d7ebc83ca533fcbef6b1d6562f035cae3b44dfbd5ec"}, {file = "lap-0.5.12-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a301dc9b8a30e41e4121635a0e3d0f6374a08bb9509f618d900e18d209b815c4"}, @@ -1136,6 +1234,8 @@ version = "0.4" description = "Makes it easy to load subpackages and functions on demand." optional = true python-versions = ">=3.7" +groups = ["main"] +markers = "extra == \"autobpm\"" files = [ {file = "lazy_loader-0.4-py3-none-any.whl", hash = "sha256:342aa8e14d543a154047afb4ba8ef17f5563baad3fc610d7b15b213b0f119efc"}, {file = "lazy_loader-0.4.tar.gz", hash = "sha256:47c75182589b91a4e1a85a136c074285a5ad4d9f39c63e0d7fb76391c4574cd1"}, @@ -1155,6 +1255,8 @@ version = "0.10.2.post1" description = "Python module for audio and music processing" optional = true python-versions = ">=3.7" +groups = ["main"] +markers = "extra == \"autobpm\"" files = [ {file = "librosa-0.10.2.post1-py3-none-any.whl", hash = "sha256:dc882750e8b577a63039f25661b7e39ec4cfbacc99c1cffba666cd664fb0a7a0"}, {file = "librosa-0.10.2.post1.tar.gz", hash = "sha256:cd99f16717cbcd1e0983e37308d1db46a6f7dfc2e396e5a9e61e6821e44bd2e7"}, @@ -1186,6 +1288,8 @@ version = "0.43.0" description = "lightweight wrapper around basic LLVM functionality" optional = true python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"autobpm\"" files = [ {file = "llvmlite-0.43.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a289af9a1687c6cf463478f0fa8e8aa3b6fb813317b0d70bf1ed0759eab6f761"}, {file = "llvmlite-0.43.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6d4fd101f571a31acb1559ae1af30f30b1dc4b3186669f92ad780e17c81e91bc"}, @@ -1216,6 +1320,8 @@ version = "5.3.0" description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API." optional = true python-versions = ">=3.6" +groups = ["main"] +markers = "extra == \"sonosupdate\"" files = [ {file = "lxml-5.3.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:dd36439be765e2dde7660212b5275641edbc813e7b24668831a5c8ac91180656"}, {file = "lxml-5.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ae5fe5c4b525aa82b8076c1a59d642c17b6e8739ecf852522c6321852178119d"}, @@ -1370,6 +1476,7 @@ version = "3.0.2" description = "Safely add untrusted strings to HTML/XML markup." optional = false python-versions = ">=3.9" +groups = ["main", "test", "typing"] files = [ {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8"}, {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158"}, @@ -1440,6 +1547,7 @@ version = "0.13.0" description = "Handles low-level interfacing for files' tags. Wraps Mutagen to" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "mediafile-0.13.0-py3-none-any.whl", hash = "sha256:cd8d183d0e0671b5203a86e92cf4e3338ecc892a1ec9dcd7ec0ed87779e514cb"}, {file = "mediafile-0.13.0.tar.gz", hash = "sha256:de71063e1bffe9733d6ccad526ea7dac8a9ce760105827f81ab0cb034c729a6d"}, @@ -1458,6 +1566,7 @@ version = "5.1.0" description = "Rolling backport of unittest.mock for all Pythons" optional = false python-versions = ">=3.6" +groups = ["test"] files = [ {file = "mock-5.1.0-py3-none-any.whl", hash = "sha256:18c694e5ae8a208cdb3d2c20a993ca1a7b0efa258c247a1e565150f477f83744"}, {file = "mock-5.1.0.tar.gz", hash = "sha256:5e96aad5ccda4718e0a229ed94b2024df75cc2d55575ba5762d31f5767b8767d"}, @@ -1468,12 +1577,27 @@ build = ["blurb", "twine", "wheel"] docs = ["sphinx"] test = ["pytest", "pytest-cov"] +[[package]] +name = "mpegdash" +version = "0.4.0" +description = "MPEG-DASH MPD(Media Presentation Description) Parser" +optional = true +python-versions = "*" +groups = ["main"] +markers = "extra == \"tidal\"" +files = [ + {file = "mpegdash-0.4.0-py3-none-any.whl", hash = "sha256:d07f6e1f2a67ddce1be501e3ad7abc29a2d6a7b1830b4da974b49c2ebe99cf2a"}, + {file = "mpegdash-0.4.0.tar.gz", hash = "sha256:65368c7a367c6875eb8c456a08644eb0708981a745044da0c9e942a3bc2b6389"}, +] + [[package]] name = "msgpack" version = "1.1.0" description = "MessagePack serializer" optional = true python-versions = ">=3.8" +groups = ["main"] +markers = "extra == \"autobpm\"" files = [ {file = "msgpack-1.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7ad442d527a7e358a469faf43fda45aaf4ac3249c8310a82f0ccff9164e5dccd"}, {file = "msgpack-1.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:74bed8f63f8f14d75eec75cf3d04ad581da6b914001b474a5d3cd3372c8cc27d"}, @@ -1547,6 +1671,7 @@ version = "0.2.3" description = "multi volume file wrapper library" optional = false python-versions = ">=3.6" +groups = ["main", "test"] files = [ {file = "multivolumefile-0.2.3-py3-none-any.whl", hash = "sha256:237f4353b60af1703087cf7725755a1f6fcaeeea48421e1896940cd1c920d678"}, {file = "multivolumefile-0.2.3.tar.gz", hash = "sha256:a0648d0aafbc96e59198d5c17e9acad7eb531abea51035d08ce8060dcad709d6"}, @@ -1563,6 +1688,7 @@ version = "0.7.1" description = "Python bindings for the MusicBrainz NGS and the Cover Art Archive webservices" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +groups = ["main"] files = [ {file = "musicbrainzngs-0.7.1-py2.py3-none-any.whl", hash = "sha256:e841a8f975104c0a72290b09f59326050194081a5ae62ee512f41915090e1a10"}, {file = "musicbrainzngs-0.7.1.tar.gz", hash = "sha256:ab1c0100fd0b305852e65f2ed4113c6de12e68afd55186987b8ed97e0f98e627"}, @@ -1574,6 +1700,7 @@ version = "1.47.0" description = "read and write audio tags for many formats" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "mutagen-1.47.0-py3-none-any.whl", hash = "sha256:edd96f50c5907a9539d8e5bba7245f62c9f520aef333d13392a79a4f70aca719"}, {file = "mutagen-1.47.0.tar.gz", hash = "sha256:719fadef0a978c31b4cf3c956261b3c58b6948b32023078a2117b1de09f0fc99"}, @@ -1585,6 +1712,7 @@ version = "1.13.0" description = "Optional static typing for Python" optional = false python-versions = ">=3.8" +groups = ["typing"] files = [ {file = "mypy-1.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6607e0f1dd1fb7f0aca14d936d13fd19eba5e17e1cd2a14f808fa5f8f6d8f60a"}, {file = "mypy-1.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8a21be69bd26fa81b1f80a61ee7ab05b076c674d9b18fb56239d72e21d9f4c80"}, @@ -1638,6 +1766,7 @@ version = "1.0.0" description = "Type system extensions for programs checked with the mypy type checker." optional = false python-versions = ">=3.5" +groups = ["typing"] files = [ {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, @@ -1649,6 +1778,8 @@ version = "0.60.0" description = "compiling Python code using LLVM" optional = true python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"autobpm\"" files = [ {file = "numba-0.60.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5d761de835cd38fb400d2c26bb103a2726f548dc30368853121d66201672e651"}, {file = "numba-0.60.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:159e618ef213fba758837f9837fb402bbe65326e60ba0633dbe6c7f274d42c1b"}, @@ -1683,6 +1814,7 @@ version = "2.0.2" description = "Fundamental package for array computing in Python" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "numpy-2.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:51129a29dbe56f9ca83438b706e2e69a39892b5eda6cedcb6b0c9fdc9b0d3ece"}, {file = "numpy-2.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f15975dfec0cf2239224d80e32c3170b1d168335eaedee69da84fbe9f1f9cd04"}, @@ -1737,6 +1869,7 @@ version = "3.2.2" description = "A generic, spec-compliant, thorough implementation of the OAuth request-signing logic" optional = false python-versions = ">=3.6" +groups = ["main", "test"] files = [ {file = "oauthlib-3.2.2-py3-none-any.whl", hash = "sha256:8139f29aac13e25d502680e9e19963e83f16838d48a0d71c287fe40e7067fbca"}, {file = "oauthlib-3.2.2.tar.gz", hash = "sha256:9859c40929662bec5d64f34d01c99e093149682a3f38915dc0655d5a633dd918"}, @@ -1753,6 +1886,7 @@ version = "24.2" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" +groups = ["main", "release", "test"] files = [ {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"}, {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, @@ -1764,6 +1898,8 @@ version = "11.0.0" description = "Python Imaging Library (Fork)" optional = true python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"aura\" or extra == \"embedart\" or extra == \"fetchart\" or extra == \"thumbnails\"" files = [ {file = "pillow-11.0.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:6619654954dc4936fcff82db8eb6401d3159ec6be81e33c6000dfd76ae189947"}, {file = "pillow-11.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b3c5ac4bed7519088103d9450a1107f76308ecf91d6dabc8a33a2fcfb18d0fba"}, @@ -1856,6 +1992,7 @@ version = "4.3.6" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb"}, {file = "platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907"}, @@ -1872,6 +2009,7 @@ version = "1.5.0" description = "plugin and hook calling mechanisms for python" optional = false python-versions = ">=3.8" +groups = ["test"] files = [ {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, @@ -1887,6 +2025,8 @@ version = "1.8.2" description = "A friend to fetch your data files" optional = true python-versions = ">=3.7" +groups = ["main"] +markers = "extra == \"autobpm\"" files = [ {file = "pooch-1.8.2-py3-none-any.whl", hash = "sha256:3529a57096f7198778a5ceefd5ac3ef0e4d06a6ddaf9fc2d609b806f25302c47"}, {file = "pooch-1.8.2.tar.gz", hash = "sha256:76561f0de68a01da4df6af38e9955c4c9d1a5c90da73f7e40276a5728ec83d10"}, @@ -1908,6 +2048,8 @@ version = "6.1.0" description = "Cross-platform lib for process and system monitoring in Python." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" +groups = ["main", "test"] +markers = "sys_platform != \"cygwin\"" files = [ {file = "psutil-6.1.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:ff34df86226c0227c52f38b919213157588a678d049688eded74c76c8ba4a5d0"}, {file = "psutil-6.1.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:c0e0c00aa18ca2d3b2b991643b799a15fc8f0563d2ebb6040f64ce8dc027b942"}, @@ -1938,6 +2080,7 @@ version = "0.22.0" description = "Pure python 7-zip library" optional = false python-versions = ">=3.8" +groups = ["main", "test"] files = [ {file = "py7zr-0.22.0-py3-none-any.whl", hash = "sha256:993b951b313500697d71113da2681386589b7b74f12e48ba13cc12beca79d078"}, {file = "py7zr-0.22.0.tar.gz", hash = "sha256:c6c7aea5913535184003b73938490f9a4d8418598e533f9ca991d3b8e45a139e"}, @@ -1968,6 +2111,8 @@ version = "1.3.0" description = "bindings for Chromaprint acoustic fingerprinting and the Acoustid API" optional = true python-versions = "*" +groups = ["main"] +markers = "extra == \"chroma\"" files = [ {file = "pyacoustid-1.3.0.tar.gz", hash = "sha256:5f4f487191c19ebb908270b1b7b5297f132da332b1568b96a914574c079ed177"}, ] @@ -1982,6 +2127,7 @@ version = "1.0.2" description = "bcj filter library" optional = false python-versions = ">=3.8" +groups = ["main", "test"] files = [ {file = "pybcj-1.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7bff28d97e47047d69a4ac6bf59adda738cf1d00adde8819117fdb65d966bdbc"}, {file = "pybcj-1.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:198e0b4768b4025eb3309273d7e81dc53834b9a50092be6e0d9b3983cfd35c35"}, @@ -2036,6 +2182,8 @@ version = "1.27.0" description = "Python interface for cairo" optional = true python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"bpd\" or extra == \"replaygain\"" files = [ {file = "pycairo-1.27.0-cp310-cp310-win32.whl", hash = "sha256:e20f431244634cf244ab6b4c3a2e540e65746eed1324573cf291981c3e65fc05"}, {file = "pycairo-1.27.0-cp310-cp310-win_amd64.whl", hash = "sha256:03bf570e3919901572987bc69237b648fe0de242439980be3e606b396e3318c9"}, @@ -2056,10 +2204,12 @@ version = "2.22" description = "C parser in Python" optional = false python-versions = ">=3.8" +groups = ["main", "test"] files = [ {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, ] +markers = {main = "extra == \"autobpm\" or platform_python_implementation == \"PyPy\" or extra == \"reflink\"", test = "platform_python_implementation == \"PyPy\""} [[package]] name = "pycryptodomex" @@ -2067,6 +2217,7 @@ version = "3.21.0" description = "Cryptographic library for Python" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" +groups = ["main", "test"] files = [ {file = "pycryptodomex-3.21.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:dbeb84a399373df84a69e0919c1d733b89e049752426041deeb30d68e9867822"}, {file = "pycryptodomex-3.21.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:a192fb46c95489beba9c3f002ed7d93979423d1b2a53eab8771dbb1339eb3ddd"}, @@ -2108,6 +2259,8 @@ version = "0.16.0" description = "Bootstrap-based Sphinx theme from the PyData community" optional = true python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"docs\"" files = [ {file = "pydata_sphinx_theme-0.16.0-py3-none-any.whl", hash = "sha256:18c810ee4e67e05281e371e156c1fb5bb0fa1f2747240461b225272f7d8d57d8"}, {file = "pydata_sphinx_theme-0.16.0.tar.gz", hash = "sha256:721dd26e05fa8b992d66ef545536e6cbe0110afb9865820a08894af1ad6f7707"}, @@ -2135,6 +2288,8 @@ version = "2.18.0" description = "Pygments is a syntax highlighting package written in Python." optional = true python-versions = ">=3.8" +groups = ["main"] +markers = "extra == \"docs\"" files = [ {file = "pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a"}, {file = "pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199"}, @@ -2149,6 +2304,8 @@ version = "3.50.0" description = "Python bindings for GObject Introspection" optional = true python-versions = "<4.0,>=3.9" +groups = ["main"] +markers = "extra == \"bpd\" or extra == \"replaygain\"" files = [ {file = "pygobject-3.50.0.tar.gz", hash = "sha256:4500ad3dbf331773d8dedf7212544c999a76fc96b63a91b3dcac1e5925a1d103"}, ] @@ -2162,6 +2319,7 @@ version = "5.3.0" description = "A Python interface to Last.fm and Libre.fm" optional = false python-versions = ">=3.8" +groups = ["main", "test"] files = [ {file = "pylast-5.3.0-py3-none-any.whl", hash = "sha256:4cc47cdcb05baf24a5cea10a012c17df0fe13e22911296a69835b127458a7308"}, {file = "pylast-5.3.0.tar.gz", hash = "sha256:637943b1b0e6045dd85ed7389db6071a1fea45cc7ff90dc6126fd509ca6fae2f"}, @@ -2179,6 +2337,7 @@ version = "1.1.0" description = "PPMd compression/decompression library" optional = false python-versions = ">=3.8" +groups = ["main", "test"] files = [ {file = "pyppmd-1.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c5cd428715413fe55abf79dc9fc54924ba7e518053e1fc0cbdf80d0d99cf1442"}, {file = "pyppmd-1.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0e96cc43f44b7658be2ea764e7fa99c94cb89164dbb7cdf209178effc2168319"}, @@ -2265,6 +2424,7 @@ version = "8.3.4" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.8" +groups = ["test"] files = [ {file = "pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6"}, {file = "pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761"}, @@ -2287,6 +2447,7 @@ version = "6.0.0" description = "Pytest plugin for measuring coverage." optional = false python-versions = ">=3.9" +groups = ["test"] files = [ {file = "pytest-cov-6.0.0.tar.gz", hash = "sha256:fde0b595ca248bb8e2d76f020b465f3b107c9632e6a1d1705f17834c89dcadc0"}, {file = "pytest_cov-6.0.0-py3-none-any.whl", hash = "sha256:eee6f1b9e61008bd34975a4d5bab25801eb31898b032dd55addc93e96fcaaa35"}, @@ -2305,6 +2466,7 @@ version = "1.3.0" description = "A set of py.test fixtures to test Flask applications." optional = false python-versions = ">=3.7" +groups = ["test"] files = [ {file = "pytest-flask-1.3.0.tar.gz", hash = "sha256:58be1c97b21ba3c4d47e0a7691eb41007748506c36bf51004f78df10691fa95e"}, {file = "pytest_flask-1.3.0-py3-none-any.whl", hash = "sha256:c0e36e6b0fddc3b91c4362661db83fa694d1feb91fa505475be6732b5bc8c253"}, @@ -2324,6 +2486,7 @@ version = "2.9.0.post0" description = "Extensions to the standard Python datetime module" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["main", "test"] files = [ {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, @@ -2338,6 +2501,7 @@ version = "3.1.1" description = "A Python MPD client library" optional = false python-versions = ">=3.6" +groups = ["main", "test"] files = [ {file = "python-mpd2-3.1.1.tar.gz", hash = "sha256:4baec3584cc43ed9948d5559079fafc2679b06b2ade273e909b3582654b2b3f5"}, {file = "python_mpd2-3.1.1-py2.py3-none-any.whl", hash = "sha256:86bf1100a0b135959d74a9a7a58cf0515bf30bb54eb25ae6fb8e175e50300fc3"}, @@ -2352,6 +2516,7 @@ version = "2.7.1" description = "Python API client for Discogs" optional = false python-versions = "*" +groups = ["main", "test"] files = [ {file = "python3_discogs_client-2.7.1-py3-none-any.whl", hash = "sha256:5fb5f3d2f288a8ce2c8c152444258bacedb35b7d61bc466bddae332b6c737444"}, {file = "python3_discogs_client-2.7.1.tar.gz", hash = "sha256:f2453582f5d044ea5847d27cfe56473179e51c9a836913b46db803c20ae598f9"}, @@ -2371,6 +2536,7 @@ version = "0.28" description = "PyXDG contains implementations of freedesktop.org standards in python." optional = false python-versions = "*" +groups = ["main", "test"] files = [ {file = "pyxdg-0.28-py2.py3-none-any.whl", hash = "sha256:bdaf595999a0178ecea4052b7f4195569c1ff4d344567bccdc12dfdf02d545ab"}, {file = "pyxdg-0.28.tar.gz", hash = "sha256:3267bb3074e934df202af2ee0868575484108581e6f3cb006af1da35395e88b4"}, @@ -2382,6 +2548,7 @@ version = "6.0.2" description = "YAML parser and emitter for Python" optional = false python-versions = ">=3.8" +groups = ["main", "test"] files = [ {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, @@ -2444,6 +2611,7 @@ version = "0.16.2" description = "Python bindings to Zstandard (zstd) compression library." optional = false python-versions = ">=3.5" +groups = ["main", "test"] files = [ {file = "pyzstd-0.16.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:637376c8f8cbd0afe1cab613f8c75fd502bd1016bf79d10760a2d5a00905fe62"}, {file = "pyzstd-0.16.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3e7a7118cbcfa90ca2ddbf9890c7cb582052a9a8cf2b7e2c1bbaf544bee0f16a"}, @@ -2536,17 +2704,32 @@ version = "4.2" description = "RAR archive reader for Python" optional = false python-versions = ">=3.6" +groups = ["main", "test"] files = [ {file = "rarfile-4.2-py3-none-any.whl", hash = "sha256:8757e1e3757e32962e229cab2432efc1f15f210823cc96ccba0f6a39d17370c9"}, {file = "rarfile-4.2.tar.gz", hash = "sha256:8e1c8e72d0845ad2b32a47ab11a719bc2e41165ec101fd4d3fe9e92aa3f469ef"}, ] +[[package]] +name = "ratelimit" +version = "2.2.1" +description = "API rate limit decorator" +optional = true +python-versions = "*" +groups = ["main"] +markers = "extra == \"tidal\"" +files = [ + {file = "ratelimit-2.2.1.tar.gz", hash = "sha256:af8a9b64b821529aca09ebaf6d8d279100d766f19e90b5059ac6a718ca6dee42"}, +] + [[package]] name = "reflink" version = "0.2.2" description = "Python reflink wraps around platform specific reflink implementations" optional = true python-versions = "*" +groups = ["main"] +markers = "extra == \"reflink\"" files = [ {file = "reflink-0.2.2-cp36-cp36m-win32.whl", hash = "sha256:8435c7153af4d6e66dc8acb48a9372c8ec6f978a09cdf7b57cd6656d969e343a"}, {file = "reflink-0.2.2-cp36-cp36m-win_amd64.whl", hash = "sha256:be4787c6208faf7fc892390909cf01e34e650ea67c37bf345addefd597ed90e1"}, @@ -2562,6 +2745,7 @@ version = "2.32.3" description = "Python HTTP for Humans." optional = false python-versions = ">=3.8" +groups = ["main", "test"] files = [ {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, @@ -2583,6 +2767,7 @@ version = "1.12.1" description = "Mock out responses from the requests package" optional = false python-versions = ">=3.5" +groups = ["test"] files = [ {file = "requests-mock-1.12.1.tar.gz", hash = "sha256:e9e12e333b525156e82a3c852f22016b9158220d2f47454de9cae8a77d371401"}, {file = "requests_mock-1.12.1-py2.py3-none-any.whl", hash = "sha256:b1e37054004cdd5e56c84454cc7df12b25f90f382159087f4b6915aaeef39563"}, @@ -2600,6 +2785,7 @@ version = "2.0.0" description = "OAuthlib authentication support for Requests." optional = false python-versions = ">=3.4" +groups = ["main", "test"] files = [ {file = "requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9"}, {file = "requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36"}, @@ -2618,6 +2804,8 @@ version = "0.4.3" description = "Efficient signal resampling" optional = true python-versions = "*" +groups = ["main"] +markers = "extra == \"autobpm\"" files = [ {file = "resampy-0.4.3-py3-none-any.whl", hash = "sha256:ad2ed64516b140a122d96704e32bc0f92b23f45419e8b8f478e5a05f83edcebd"}, {file = "resampy-0.4.3.tar.gz", hash = "sha256:a0d1c28398f0e55994b739650afef4e3974115edbe96cd4bb81968425e916e47"}, @@ -2638,6 +2826,7 @@ version = "0.25.3" description = "A utility library for mocking out the `requests` Python library." optional = false python-versions = ">=3.8" +groups = ["test"] files = [ {file = "responses-0.25.3-py3-none-any.whl", hash = "sha256:521efcbc82081ab8daa588e08f7e8a64ce79b91c39f6e62199b19159bea7dbcb"}, {file = "responses-0.25.3.tar.gz", hash = "sha256:617b9247abd9ae28313d57a75880422d55ec63c29d33d629697590a034358dba"}, @@ -2657,6 +2846,7 @@ version = "0.8.1" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" +groups = ["lint"] files = [ {file = "ruff-0.8.1-py3-none-linux_armv6l.whl", hash = "sha256:fae0805bd514066f20309f6742f6ee7904a773eb9e6c17c45d6b1600ca65c9b5"}, {file = "ruff-0.8.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b8a4f7385c2285c30f34b200ca5511fcc865f17578383db154e098150ce0a087"}, @@ -2684,6 +2874,8 @@ version = "1.5.2" description = "A set of python modules for machine learning and data mining" optional = true python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"autobpm\"" files = [ {file = "scikit_learn-1.5.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:299406827fb9a4f862626d0fe6c122f5f87f8910b86fe5daa4c32dcd742139b6"}, {file = "scikit_learn-1.5.2-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:2d4cad1119c77930b235579ad0dc25e65c917e756fe80cab96aa3b9428bd3fb0"}, @@ -2734,6 +2926,8 @@ version = "1.13.1" description = "Fundamental algorithms for scientific computing in Python" optional = true python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"autobpm\"" files = [ {file = "scipy-1.13.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:20335853b85e9a49ff7572ab453794298bcf0354d8068c5f6775a0eabf350aca"}, {file = "scipy-1.13.1-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:d605e9c23906d1994f55ace80e0125c587f96c020037ea6aa98d01b4bd2e222f"}, @@ -2776,6 +2970,7 @@ version = "1.16.0" description = "Python 2 and 3 compatibility utilities" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +groups = ["main", "test"] files = [ {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, @@ -2787,6 +2982,7 @@ version = "1.3.1" description = "Sniff out which async library your code is running under" optional = false python-versions = ">=3.7" +groups = ["main", "test"] files = [ {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, @@ -2798,6 +2994,8 @@ version = "2.2.0" description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms." optional = true python-versions = "*" +groups = ["main"] +markers = "extra == \"docs\"" files = [ {file = "snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a"}, {file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"}, @@ -2809,6 +3007,8 @@ version = "0.30.6" description = "SoCo (Sonos Controller) is a simple library to control Sonos speakers." optional = true python-versions = ">=3.6" +groups = ["main"] +markers = "extra == \"sonosupdate\"" files = [ {file = "soco-0.30.6-py2.py3-none-any.whl", hash = "sha256:06c486218d0558a89276ed573ae2264d8e9bfd95a46a7dc253e03d19a3e6f423"}, {file = "soco-0.30.6.tar.gz", hash = "sha256:7ae48e865dbf1d9fae8023e1b69465c2c4c17048992a05e9c017b35c43d4f4f2"}, @@ -2831,6 +3031,8 @@ version = "0.12.1" description = "An audio library based on libsndfile, CFFI and NumPy" optional = true python-versions = "*" +groups = ["main"] +markers = "extra == \"autobpm\"" files = [ {file = "soundfile-0.12.1-py2.py3-none-any.whl", hash = "sha256:828a79c2e75abab5359f780c81dccd4953c45a2c4cd4f05ba3e233ddf984b882"}, {file = "soundfile-0.12.1-py2.py3-none-macosx_10_9_x86_64.whl", hash = "sha256:d922be1563ce17a69582a352a86f28ed8c9f6a8bc951df63476ffc310c064bfa"}, @@ -2854,6 +3056,7 @@ version = "2.6" description = "A modern CSS selector implementation for Beautiful Soup." optional = false python-versions = ">=3.8" +groups = ["main", "test"] files = [ {file = "soupsieve-2.6-py3-none-any.whl", hash = "sha256:e72c4ff06e4fb6e4b5a9f0f55fe6e81514581fca1515028625d0f299c602ccc9"}, {file = "soupsieve-2.6.tar.gz", hash = "sha256:e2e68417777af359ec65daac1057404a3c8a5455bb8abc36f1a9866ab1a51abb"}, @@ -2865,6 +3068,8 @@ version = "0.5.0.post1" description = "High quality, one-dimensional sample-rate conversion library" optional = true python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"autobpm\"" files = [ {file = "soxr-0.5.0.post1-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:7406d782d85f8cf64e66b65e6b7721973de8a1dc50b9e88bc2288c343a987484"}, {file = "soxr-0.5.0.post1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fa0a382fb8d8e2afed2c1642723b2d2d1b9a6728ff89f77f3524034c8885b8c9"}, @@ -2902,6 +3107,8 @@ version = "7.4.7" description = "Python documentation generator" optional = true python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"docs\"" files = [ {file = "sphinx-7.4.7-py3-none-any.whl", hash = "sha256:c2419e2135d11f1951cd994d6eb18a1835bd8fdd8429f9ca375dc1f3281bd239"}, {file = "sphinx-7.4.7.tar.gz", hash = "sha256:242f92a7ea7e6c5b406fdc2615413890ba9f699114a9c09192d7dfead2ee9cfe"}, @@ -2938,6 +3145,8 @@ version = "2.0.0" description = "sphinxcontrib-applehelp is a Sphinx extension which outputs Apple help books" optional = true python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"docs\"" files = [ {file = "sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5"}, {file = "sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1"}, @@ -2954,6 +3163,8 @@ version = "2.0.0" description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp documents" optional = true python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"docs\"" files = [ {file = "sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2"}, {file = "sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad"}, @@ -2970,6 +3181,8 @@ version = "2.1.0" description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files" optional = true python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"docs\"" files = [ {file = "sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8"}, {file = "sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9"}, @@ -2986,6 +3199,8 @@ version = "1.0.1" description = "A sphinx extension which renders display math in HTML via JavaScript" optional = true python-versions = ">=3.5" +groups = ["main"] +markers = "extra == \"docs\"" files = [ {file = "sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"}, {file = "sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178"}, @@ -3000,6 +3215,8 @@ version = "2.0.0" description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp documents" optional = true python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"docs\"" files = [ {file = "sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb"}, {file = "sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab"}, @@ -3016,6 +3233,8 @@ version = "2.0.0" description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)" optional = true python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"docs\"" files = [ {file = "sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331"}, {file = "sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d"}, @@ -3032,6 +3251,7 @@ version = "1.7.0" description = "module to create simple ASCII tables" optional = false python-versions = "*" +groups = ["main", "test"] files = [ {file = "texttable-1.7.0-py2.py3-none-any.whl", hash = "sha256:72227d592c82b3d7f672731ae73e4d1f88cd8e2ef5b075a7a7f01a23a3743917"}, {file = "texttable-1.7.0.tar.gz", hash = "sha256:2d2068fb55115807d3ac77a4ca68fa48803e84ebb0ee2340f858107a36522638"}, @@ -3043,17 +3263,41 @@ version = "3.5.0" description = "threadpoolctl" optional = true python-versions = ">=3.8" +groups = ["main"] +markers = "extra == \"autobpm\"" files = [ {file = "threadpoolctl-3.5.0-py3-none-any.whl", hash = "sha256:56c1e26c150397e58c4926da8eeee87533b1e32bef131bd4bf6a2f45f3185467"}, {file = "threadpoolctl-3.5.0.tar.gz", hash = "sha256:082433502dd922bf738de0d8bcc4fdcbf0979ff44c42bd40f5af8a282f6fa107"}, ] +[[package]] +name = "tidalapi" +version = "0.8.3" +description = "Unofficial API for TIDAL music streaming service." +optional = true +python-versions = "<4.0,>=3.8" +groups = ["main"] +markers = "extra == \"tidal\"" +files = [ + {file = "tidalapi-0.8.3-py3-none-any.whl", hash = "sha256:b6698e308cc9f4edac4b9c1bc22773f040d4de0bbac212defcbe33a93f66b3c7"}, + {file = "tidalapi-0.8.3.tar.gz", hash = "sha256:dc8e578bdbe6c8095434a066993b869deb5a8a25732f7b04132df59e94599455"}, +] + +[package.dependencies] +isodate = ">=0.7.2,<0.8.0" +mpegdash = ">=0.4.0,<0.5.0" +python-dateutil = ">=2.8.2,<3.0.0" +ratelimit = ">=2.2.1,<3.0.0" +requests = ">=2.32.3,<3.0.0" +typing-extensions = ">=4.12.2,<5.0.0" + [[package]] name = "tomli" version = "2.2.1" description = "A lil' TOML parser" optional = false python-versions = ">=3.8" +groups = ["main", "release", "test", "typing"] files = [ {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, @@ -3095,6 +3339,7 @@ version = "4.12.0.20241020" description = "Typing stubs for beautifulsoup4" optional = false python-versions = ">=3.8" +groups = ["typing"] files = [ {file = "types-beautifulsoup4-4.12.0.20241020.tar.gz", hash = "sha256:158370d08d0cd448bd11b132a50ff5279237a5d4b5837beba074de152a513059"}, {file = "types_beautifulsoup4-4.12.0.20241020-py3-none-any.whl", hash = "sha256:c95e66ce15a4f5f0835f7fbc5cd886321ae8294f977c495424eaf4225307fd30"}, @@ -3109,6 +3354,7 @@ version = "5.0.0.20240902" description = "Typing stubs for Flask-Cors" optional = false python-versions = ">=3.8" +groups = ["typing"] files = [ {file = "types-Flask-Cors-5.0.0.20240902.tar.gz", hash = "sha256:8921b273bf7cd9636df136b66408efcfa6338a935e5c8f53f5eff1cee03f3394"}, {file = "types_Flask_Cors-5.0.0.20240902-py3-none-any.whl", hash = "sha256:595e5f36056cd128ab905832e055f2e5d116fbdc685356eea4490bc77df82137"}, @@ -3123,6 +3369,7 @@ version = "1.1.11.20241018" description = "Typing stubs for html5lib" optional = false python-versions = ">=3.8" +groups = ["typing"] files = [ {file = "types-html5lib-1.1.11.20241018.tar.gz", hash = "sha256:98042555ff78d9e3a51c77c918b1041acbb7eb6c405408d8a9e150ff5beccafa"}, {file = "types_html5lib-1.1.11.20241018-py3-none-any.whl", hash = "sha256:3f1e064d9ed2c289001ae6392c84c93833abb0816165c6ff0abfc304a779f403"}, @@ -3134,6 +3381,7 @@ version = "5.1.0.20240425" description = "Typing stubs for mock" optional = false python-versions = ">=3.8" +groups = ["typing"] files = [ {file = "types-mock-5.1.0.20240425.tar.gz", hash = "sha256:5281a645d72e827d70043e3cc144fe33b1c003db084f789dc203aa90e812a5a4"}, {file = "types_mock-5.1.0.20240425-py3-none-any.whl", hash = "sha256:d586a01d39ad919d3ddcd73de6cde73ca7f3c69707219f722d1b8d7733641ad7"}, @@ -3145,6 +3393,7 @@ version = "10.2.0.20240822" description = "Typing stubs for Pillow" optional = false python-versions = ">=3.8" +groups = ["typing"] files = [ {file = "types-Pillow-10.2.0.20240822.tar.gz", hash = "sha256:559fb52a2ef991c326e4a0d20accb3bb63a7ba8d40eb493e0ecb0310ba52f0d3"}, {file = "types_Pillow-10.2.0.20240822-py3-none-any.whl", hash = "sha256:d9dab025aba07aeb12fd50a6799d4eac52a9603488eca09d7662543983f16c5d"}, @@ -3156,6 +3405,7 @@ version = "6.0.12.20240917" description = "Typing stubs for PyYAML" optional = false python-versions = ">=3.8" +groups = ["typing"] files = [ {file = "types-PyYAML-6.0.12.20240917.tar.gz", hash = "sha256:d1405a86f9576682234ef83bcb4e6fff7c9305c8b1fbad5e0bcd4f7dbdc9c587"}, {file = "types_PyYAML-6.0.12.20240917-py3-none-any.whl", hash = "sha256:392b267f1c0fe6022952462bf5d6523f31e37f6cea49b14cee7ad634b6301570"}, @@ -3167,6 +3417,7 @@ version = "2.32.0.20241016" description = "Typing stubs for requests" optional = false python-versions = ">=3.8" +groups = ["typing"] files = [ {file = "types-requests-2.32.0.20241016.tar.gz", hash = "sha256:0d9cad2f27515d0e3e3da7134a1b6f28fb97129d86b867f24d9c726452634d95"}, {file = "types_requests-2.32.0.20241016-py3-none-any.whl", hash = "sha256:4195d62d6d3e043a4eaaf08ff8a62184584d2e8684e9d2aa178c7915a7da3747"}, @@ -3181,6 +3432,7 @@ version = "1.26.25.14" description = "Typing stubs for urllib3" optional = false python-versions = "*" +groups = ["typing"] files = [ {file = "types-urllib3-1.26.25.14.tar.gz", hash = "sha256:229b7f577c951b8c1b92c1bc2b2fdb0b49847bd2af6d1cc2a2e3dd340f3bda8f"}, {file = "types_urllib3-1.26.25.14-py3-none-any.whl", hash = "sha256:9683bbb7fb72e32bfe9d2be6e04875fbe1b3eeec3cbb4ea231435aa7fd6b4f0e"}, @@ -3192,10 +3444,12 @@ version = "4.12.2" description = "Backported and Experimental Type Hints for Python 3.8+" optional = false python-versions = ">=3.8" +groups = ["main", "test", "typing"] files = [ {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, ] +markers = {main = "python_version < \"3.11\" or extra == \"autobpm\" or extra == \"docs\" or extra == \"tidal\"", test = "python_version < \"3.11\""} [[package]] name = "unidecode" @@ -3203,6 +3457,7 @@ version = "1.3.8" description = "ASCII transliterations of Unicode text" optional = false python-versions = ">=3.5" +groups = ["main"] files = [ {file = "Unidecode-1.3.8-py3-none-any.whl", hash = "sha256:d130a61ce6696f8148a3bd8fe779c99adeb4b870584eeb9526584e9aa091fd39"}, {file = "Unidecode-1.3.8.tar.gz", hash = "sha256:cfdb349d46ed3873ece4586b96aa75258726e2fa8ec21d6f00a591d98806c2f4"}, @@ -3214,6 +3469,7 @@ version = "2.2.3" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.8" +groups = ["main", "test", "typing"] files = [ {file = "urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac"}, {file = "urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9"}, @@ -3231,6 +3487,7 @@ version = "3.1.3" description = "The comprehensive WSGI web application library." optional = false python-versions = ">=3.9" +groups = ["main", "test", "typing"] files = [ {file = "werkzeug-3.1.3-py3-none-any.whl", hash = "sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e"}, {file = "werkzeug-3.1.3.tar.gz", hash = "sha256:60723ce945c19328679790e3282cc758aa4a6040e4bb330f53d30fa546d44746"}, @@ -3248,6 +3505,8 @@ version = "0.14.2" description = "Makes working with XML feel like you are working with JSON" optional = true python-versions = ">=3.6" +groups = ["main"] +markers = "extra == \"sonosupdate\"" files = [ {file = "xmltodict-0.14.2-py2.py3-none-any.whl", hash = "sha256:20cc7d723ed729276e808f26fb6b3599f786cbc37e06c65e192ba77c40f20aac"}, {file = "xmltodict-0.14.2.tar.gz", hash = "sha256:201e7c28bb210e374999d1dde6382923ab0ed1a8a5faeece48ab525b7810a553"}, @@ -3259,6 +3518,8 @@ version = "3.21.0" description = "Backport of pathlib-compatible object wrapper for zip files" optional = false python-versions = ">=3.9" +groups = ["main", "test", "typing"] +markers = "python_version < \"3.10\"" files = [ {file = "zipp-3.21.0-py3-none-any.whl", hash = "sha256:ac1bbe05fd2991f160ebce24ffbac5f6d11d83dc90891255885223d42b3cd931"}, {file = "zipp-3.21.0.tar.gz", hash = "sha256:2c9958f6430a2040341a52eb608ed6dd93ef4392e02ffe219417c1b28b5dd1f4"}, @@ -3297,9 +3558,10 @@ replaygain = ["PyGObject"] scrub = ["mutagen"] sonosupdate = ["soco"] thumbnails = ["Pillow", "pyxdg"] +tidal = ["backoff", "cachetools", "tidalapi"] web = ["flask", "flask-cors"] [metadata] -lock-version = "2.0" +lock-version = "2.1" python-versions = ">=3.9,<4" -content-hash = "d609e83f7ffeefc12e28d627e5646aa5c1a6f5a56d7013bb649a468069550dba" +content-hash = "dee61047878a91f54631503c27fd979224586f7aeb20ee961e79df239dac10e4" diff --git a/pyproject.toml b/pyproject.toml index d985c54ea5..1485071cfb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -77,6 +77,9 @@ soco = { version = "*", optional = true } pydata-sphinx-theme = { version = "*", optional = true } sphinx = { version = "*", optional = true } +tidalapi = {version = "^0.8.3", optional = true} +cachetools = {version = "^5.5.1", optional = true} +backoff = {version = "^2.2.1", optional = true} [tool.poetry.group.test.dependencies] beautifulsoup4 = "*" @@ -146,6 +149,7 @@ replaygain = [ scrub = ["mutagen"] sonosupdate = ["soco"] thumbnails = ["Pillow", "pyxdg"] +tidal = ["tidalapi", "cachetools", "backoff"] web = ["flask", "flask-cors"] [tool.poetry.scripts]