from __future__ import annotations import itertools import os import re import time from functools import cached_property from typing import TYPE_CHECKING, ClassVar, Literal, overload import confuse from beets import ui from beets.autotag import AlbumInfo, TrackInfo from beets.dbcore import types from beets.exceptions import UserError from beets.logging import getLogger from beets.metadata_plugins import MetadataSourcePlugin from .api import TidalAPI if TYPE_CHECKING: import optparse from collections.abc import Callable, Iterable, Sequence from beets.autotag import Info from beets.dbcore.db import Results from beets.library import Library from beets.library.models import Album, Item from .api_types import ( AlbumAttributes, MediaAttributes, ResourceIdentifier, TidalAlbum, TidalArtist, TidalArtwork, TidalTrack, ) log = getLogger("beets.tidal") class TidalPlugin(MetadataSourcePlugin): item_types: ClassVar[dict[str, types.Type]] = { "tidal_track_id": types.STRING, "tidal_artist_id": types.STRING, "tidal_track_popularity": types.INTEGER, "tidal_updated": types.DATE, } album_types: ClassVar[dict[str, types.Type]] = { "tidal_album_id": types.STRING, "tidal_artist_id": types.STRING, "tidal_album_popularity": types.INTEGER, "tidal_updated": types.DATE, } def __init__(self) -> None: super().__init__() self.config.add( {"client_id": "mcjmpl1bPATJXcBT", "tokenfile": "tidal_token.json"} ) self.config["client_id"].redact = True # We need to be authenticated if plugin is used to fetch metadata # otherwise the import cannot run. self.register_listener("import_begin", self.require_authentication) @cached_property def api(self) -> TidalAPI: return TidalAPI( client_id=self.config["client_id"].as_str(), token_path=self._tokenfile(), ) def _tokenfile(self) -> str: """Return the configured path to the token file in the app directory.""" return self.config["tokenfile"].get(confuse.Filename(in_app_dir=True)) def require_authentication(self) -> None: if not os.path.isfile(self._tokenfile()): raise UserError( "Please login to TIDAL" " using `beet tidal --auth` or disable tidal plugin" ) def album_for_id(self, album_id: str) -> AlbumInfo | None: if not (tidal_id := self._extract_id(album_id)): return None if album := list(self.search_albums_by_ids(tidal_ids=[tidal_id])): return album[0] log.warning("Could not find album:{0}", tidal_id) return None def albums_for_ids(self, ids: Iterable[str]) -> Iterable[AlbumInfo | None]: yield from self.search_albums_by_ids(ids=ids) def track_for_id(self, track_id: str) -> TrackInfo | None: if not (tidal_id := self._extract_id(track_id)): return None if track := list(self.search_tracks_by_ids(tidal_ids=[tidal_id])): return track[0] log.warning("Could not find track:{0}", tidal_id) return None def tracks_for_ids(self, ids: Iterable[str]) -> Iterable[TrackInfo | None]: yield from self.search_tracks_by_ids(ids=ids) def candidates( self, items: Sequence[Item], artist: str, album: str, va_likely: bool ) -> Iterable[AlbumInfo]: candidates: list[AlbumInfo] = [] # Tidal allows to lookup via isrc and barcode (nice!) # We just return early here as a lookup via isrc should # return a 100% match barcodes: list[str] = list( filter(None, set(i.get("barcode") for i in items)) ) if barcodes and ( candidates := list( filter(None, self.search_albums_by_ids(barcode_ids=barcodes)) ) ): return candidates for query in self._album_queries(items): candidates += self.search_albums_by_query(query) log.debug("Found {0} candidates", len(candidates)) return candidates def item_candidates( self, item: Item, artist: str, title: str ) -> Iterable[TrackInfo]: candidates: list[TrackInfo] = [] # Tidal allows to lookup via isrc and barcode (nice!) # We just return early here as a lookup via isrc should # return a 100% match if isrc := item.get("isrc"): if candidates := list( filter(None, self.search_tracks_by_ids(isrcs=[isrc])) ): return candidates for query in self._item_queries(item): candidates += self.search_tracks_by_query(query) log.debug("Found {0} candidates", len(candidates)) return candidates @staticmethod def _item_queries(item: Item) -> Iterable[str]: """Search queries for items.""" yield item.title if item.artist: yield f"{item.artist} {item.title}" @staticmethod def _album_queries(items: Sequence[Item]) -> Iterable[str]: """Search queries for albums.""" album_names = set(i.album for i in items) artist_names = set(i.artist for i in items) for album, artist in itertools.product(album_names, artist_names): yield f"{artist} {album}" def search_tracks_by_query(self, query: str) -> Iterable[TrackInfo]: """Search for tracks given a string query.""" search_doc = self.api.search_results(query, include=["tracks.artists"]) track_by_id: dict[str, TidalTrack] = { item["id"]: item for item in search_doc.get("included", []) if item["type"] == "tracks" } artist_by_id: dict[str, TidalArtist] = { item["id"]: item for item in search_doc.get("included", []) if item["type"] == "artists" } for track_rel in search_doc["data"]["relationships"]["tracks"]["data"]: if track := track_by_id.get(track_rel["id"]): yield self._get_track_info(track, artist_by_id=artist_by_id) else: log.warning( "Track with id {0} not found in lookup", track_rel["id"] ) def search_albums_by_query(self, query: str) -> Iterable[AlbumInfo]: """Search for album given a string query.""" search_doc = self.api.search_results( query, include=["albums"], # include="albums.items.artists" <- not supported # This is a bit inconvenient, but we fetch the items and artists # for all albums separately. ) album_ids = [ album_rel["id"] for album_rel in search_doc["data"]["relationships"]["albums"][ "data" ] ] yield from filter(None, self.search_albums_by_ids(tidal_ids=album_ids)) @overload def search_tracks_by_ids( self, *, ids: Iterable[str] ) -> Iterable[TrackInfo | None]: ... @overload def search_tracks_by_ids( self, *, tidal_ids: Iterable[str] ) -> Iterable[TrackInfo | None]: ... @overload def search_tracks_by_ids( self, *, isrcs: Iterable[str] ) -> Iterable[TrackInfo | None]: ... def search_tracks_by_ids( self, ids: Iterable[str] | None = None, tidal_ids: Iterable[str] | None = None, isrcs: Iterable[str] | None = None, ) -> Iterable[TrackInfo | None]: _ids: list[str | None] = list(tidal_ids or []) isrcs = list(isrcs or []) if ids: _ids = list(map(self._extract_id, ids)) tracks_doc = self.api.get_tracks( ids=list(filter(None, _ids)), isrcs=isrcs, include=["artists"] ) track_by_id: dict[str, TidalTrack] = { item["id"]: item for item in tracks_doc.get("data", []) if item["type"] == "tracks" } artist_by_id: dict[str, TidalArtist] = { item["id"]: item for item in tracks_doc.get("included", []) if item["type"] == "artists" } for _id in _ids: if _id is not None and (track := track_by_id.get(_id)): yield self._get_track_info(track, artist_by_id=artist_by_id) else: yield None if isrcs: isrc_to_track: dict[str, TidalTrack] = { t["attributes"]["isrc"]: t for t in track_by_id.values() } for isrc in isrcs: if track := isrc_to_track.get(isrc): yield self._get_track_info(track, artist_by_id=artist_by_id) else: yield None @overload def search_albums_by_ids( self, *, ids: Iterable[str] ) -> Iterable[AlbumInfo | None]: ... @overload def search_albums_by_ids( self, *, tidal_ids: Iterable[str] ) -> Iterable[AlbumInfo | None]: ... @overload def search_albums_by_ids( self, *, barcode_ids: Iterable[str] ) -> Iterable[AlbumInfo | None]: ... def search_albums_by_ids( self, ids: Iterable[str] | None = None, tidal_ids: Iterable[str] | None = None, barcode_ids: Iterable[str] | None = None, ) -> Iterable[AlbumInfo | None]: _ids: list[str | None] = list(tidal_ids or []) barcode_ids = list(barcode_ids or []) if ids: _ids = list(map(self._extract_id, ids)) albums_doc = self.api.get_albums( ids=list(filter(None, _ids)), barcode_ids=barcode_ids, include=["items.artists", "artists", "coverArt"], ) album_by_id: dict[str, TidalAlbum] = { item["id"]: item for item in albums_doc.get("data", []) if item["type"] == "albums" } track_by_id: dict[str, TidalTrack] = { item["id"]: item for item in albums_doc.get("included", []) if item["type"] == "tracks" } artist_by_id: dict[str, TidalArtist] = { item["id"]: item for item in albums_doc.get("included", []) if item["type"] == "artists" } artwork_by_id: dict[str, TidalArtwork] = { item["id"]: item for item in albums_doc.get("included", []) if item["type"] == "artworks" } for _id in _ids: if _id is not None and (album := album_by_id.get(_id)): yield self._get_album_info( album, track_by_id=track_by_id, artist_by_id=artist_by_id, artwork_by_id=artwork_by_id, ) else: yield None if barcode_ids: barcode_to_album: dict[str, TidalAlbum] = { a["attributes"]["barcodeId"]: a for a in album_by_id.values() } for barcode in barcode_ids: if album := barcode_to_album.get(barcode): yield self._get_album_info( album, track_by_id=track_by_id, artist_by_id=artist_by_id, artwork_by_id=artwork_by_id, ) else: yield None def _get_album_info( self, album: TidalAlbum, track_by_id: dict[str, TidalTrack], artist_by_id: dict[str, TidalArtist], artwork_by_id: dict[str, TidalArtwork], ) -> AlbumInfo: track_infos: list[TrackInfo] = [] for i, track_rel in enumerate( album["relationships"]["items"]["data"], start=1 ): if track := track_by_id.get(track_rel["id"]): track_info = self._get_track_info(track, artist_by_id) track_info.index = i track_infos.append(track_info) artist_names, artist_ids = self._parse_artists( album["relationships"]["artists"]["data"], artist_by_id ) date_parts = self._parse_release_date(album["attributes"]) return AlbumInfo( # Identifier data_source=self.data_source, album_id=album["id"], artists_ids=artist_ids, data_url=self._parse_data_url(album["attributes"]), barcode=album["attributes"]["barcodeId"], cover_art_url=self._parse_artwork_url(album, artwork_by_id), # Meta album=self._parse_title(album["attributes"]), tracks=track_infos, artist=", ".join(artist_names), artists=artist_names, duration=self._duration_to_seconds(album["attributes"]["duration"]), albumtypes=[album["attributes"]["albumType"].lower()], label=self._parse_label(album["attributes"]), year=date_parts[0] if date_parts else None, month=date_parts[1] if date_parts else None, day=date_parts[2] if date_parts else None, # Flexattrs tidal_album_id=album["id"], tidal_artist_id=artist_ids[0] if artist_ids else None, tidal_album_popularity=self._parse_popularity(album["attributes"]), tidal_updated=time.time(), ) @staticmethod def _parse_artwork_url( album: TidalAlbum, artwork_by_id: dict[str, TidalArtwork] ) -> str | None: cover_rel = album["relationships"].get("coverArt") if cover_rel is None: return None for rel_data in cover_rel["data"]: if ( rel_data["type"] == "artworks" and (artwork := artwork_by_id.get(rel_data["id"])) and (files := artwork["attributes"]["files"]) ): return files[0]["href"] return None def _get_track_info( self, track: TidalTrack, artist_by_id: dict[str, TidalArtist] ) -> TrackInfo: artist_names, artist_ids = self._parse_artists( track["relationships"]["artists"]["data"], artist_by_id ) return TrackInfo( # Identifier data_source=self.data_source, track_id=track["id"], artists_ids=artist_ids, data_url=self._parse_data_url(track["attributes"]), # Meta title=self._parse_title(track["attributes"]), isrc=track["attributes"]["isrc"], artist=", ".join(artist_names), artists=artist_names, duration=self._duration_to_seconds(track["attributes"]["duration"]), label=self._parse_label(track["attributes"]), # Flexattrs tidal_track_id=track["id"], tidal_artist_id=artist_ids[0] if artist_ids else None, tidal_track_popularity=self._parse_popularity(track["attributes"]), tidal_updated=time.time(), ) @staticmethod def _parse_artists( artist_relationships: list[ResourceIdentifier], artist_by_id: dict[str, TidalArtist], ) -> tuple[list[str], list[str]]: """Extract artists from a relationship. Artists are sorted in the track/album response relationship but not in the track/album responses included items. """ artist_names = [] artist_ids = [] for artist_rel in artist_relationships: if artist := artist_by_id.get(artist_rel["id"]): artist_ids.append(artist["id"]) artist_names.append(artist["attributes"]["name"]) else: log.warning( "Artist with id {0} not found in lookup", artist_rel["id"] ) return artist_names, artist_ids @staticmethod def _parse_title(attributes: MediaAttributes) -> str: """ Tidal UIs append the version string at the end of the title. We do the same here by formatting it as ``"{title} ({version})"`` to stay consistent. """ if version := attributes.get("version"): return f"{attributes['title']} ({version})" return attributes["title"] @staticmethod def _parse_data_url(attributes: MediaAttributes) -> str | None: if external_links := attributes.get("externalLinks"): return external_links[0].get("href") return None @staticmethod def _duration_to_seconds(duration: str) -> int | None: """Convert ISO 8601 duration to seconds. E.g. 'PT15M2S' -> 902.""" match = ISO_8601_RE.match(duration) if not match: log.warning("Invalid ISO 8601 duration: {0}", duration) return None parts = {k: int(v) if v else 0 for k, v in match.groupdict().items()} return parts["seconds"] + parts["minutes"] * 60 + parts["hours"] * 3600 @staticmethod def _parse_label(attributes: MediaAttributes) -> str | None: if copyright_ := attributes.get("copyright"): return copyright_["text"] return None @staticmethod def _parse_release_date( attributes: AlbumAttributes, ) -> tuple[int, int, int] | None: """Returns year, month, day from iso YYYY-MM-DD""" if ( (release_date := attributes.get("releaseDate")) and (parts := release_date.split("-")) and len(parts) == 3 ): return int(parts[0]), int(parts[1]), int(parts[2]) return None @staticmethod def _parse_popularity(attributes: MediaAttributes) -> int: return round(attributes["popularity"] * 100) def sync_item_popularity( self, results: Results[Item], write: bool, force: bool = False ) -> None: """Sync Tidal popularity data for library items.""" self._sync_popularity( results=results, write=write, force=force, id_field="tidal_track_id", popularity_field="tidal_track_popularity", search=lambda ids: self.search_tracks_by_ids(tidal_ids=ids), label="item", ) def sync_album_popularity( self, results: Results[Album], write: bool, force: bool = False ) -> None: """Sync Tidal popularity data for library albums.""" self._sync_popularity( results=results, write=write, force=force, id_field="tidal_album_id", popularity_field="tidal_album_popularity", search=lambda ids: self.search_albums_by_ids(tidal_ids=ids), label="album", ) def _sync_popularity( self, *, results: Results[Item] | Results[Album], write: bool, force: bool, id_field: str, popularity_field: str, search: Callable[[Iterable[str]], Iterable[Info | None]], label: Literal["item", "album"], ) -> None: """Sync Tidal popularity for a generic model (Item or Album).""" log.info("Syncing popularity for {0} {1}s", len(results), label) model_by_id = { tidal_id: model for model in results if (tidal_id := model.get(id_field)) and (force or model.get(popularity_field) is None) } total = len(model_by_id) log.debug("{0} {1}s need updates", total, label) processed = 0 for model, new_info in zip( model_by_id.values(), search(model_by_id.keys()) ): if not new_info: continue model[popularity_field] = new_info.get(popularity_field) model["tidal_updated"] = time.time() model.store() if write: model.try_write() processed += 1 if processed % 100 == 0 or processed == total: log.debug("Synced {0}/{1} {2}s", processed, total, label) log.info( "Successfully synchronised popularity for {0} {1}s", total, label ) def commands(self) -> list[ui.Subcommand]: tidal_cmd = ui.Subcommand( "tidal", help="Tidal metadata plugin commands" ) tidal_cmd.parser.add_option( "-a", "--auth", action="store_true", help="Authenticate and login to Tidal", default=False, ) def auth_func( lib: Library, opts: optparse.Values, args: list[str] ) -> None: if opts.auth: self.api.ui_authenticate_flow() else: tidal_cmd.print_help() tidal_cmd.func = auth_func # It would be nice to combine both auth and sync commands but # currently not really supported in beets. We might be able to # revist this if our subcommand parsing changes sync_help = ( "Synchronize Tidal popularity data for library items and albums" ) tidalsync_cmd = ui.Subcommand("tidalsync", help=sync_help) tidalsync_cmd.parser.add_album_option() tidalsync_cmd.parser.add_option( "-f", "--force", action="store_true", dest="force", default=False, help=( "re-fetch popularity even if already present (default: False)" ), ) tidalsync_cmd.parser.add_option( "-w", "--write", action="store_true", dest="write", default=False, help="write updated tags to media files (default: False)", ) tidalsync_cmd.parser.set_usage( "Usage: beet tidalsync [options]" ) def sync_func( lib: Library, opts: optparse.Values, args: list[str] ) -> None: query = ["data_source:tidal", *args] if opts.album: self.sync_album_popularity( lib.albums(query), write=opts.write, force=opts.force ) else: self.sync_item_popularity( lib.items(query), write=opts.write, force=opts.force ) tidalsync_cmd.func = sync_func return [tidal_cmd, tidalsync_cmd] ISO_8601_RE = re.compile( r"^P" r"T" r"(?:(?P\d+)H)?" r"(?:(?P\d+)M)?" r"(?:(?P\d+)S)?$" )