diff --git a/tiddl/cli/commands/subcommands/__init__.py b/tiddl/cli/commands/subcommands/__init__.py index a543eb6..d17071e 100644 --- a/tiddl/cli/commands/subcommands/__init__.py +++ b/tiddl/cli/commands/subcommands/__init__.py @@ -2,9 +2,10 @@ from typer import Typer from .url import url_subcommand from .fav import fav_subcommand +from .search import search_subcommand -SUBCOMMANDS: list[Typer] = [url_subcommand, fav_subcommand] +SUBCOMMANDS: list[Typer] = [url_subcommand, fav_subcommand, search_subcommand] def register_subcommands(app: Typer): diff --git a/tiddl/cli/commands/subcommands/search.py b/tiddl/cli/commands/subcommands/search.py new file mode 100644 index 0000000..fe95e8a --- /dev/null +++ b/tiddl/cli/commands/subcommands/search.py @@ -0,0 +1,110 @@ +import typer +from typing_extensions import Annotated + +from tiddl.cli.ctx import Context +from tiddl.cli.utils.resource import TidalResource +from tiddl.core.api.models.base import Search, SearchArtist +from tiddl.core.api.models.resources import Track, Album, Playlist + +from rich.panel import Panel +from rich.table import Table + + +search_subcommand = typer.Typer() + +@search_subcommand.command( + no_args_is_help=True, +) + + + +def search( + ctx: Context, + query: Annotated[str, typer.Argument()], + resource_types: Annotated[ + list[str], + typer.Option( + "-t", + "--types", + metavar="", + help="Narrow resource types, usage: -t track -t album etc. Available resources: track, video, album, playlist, artist.", + ), + ] = ["track", "video", "album", "playlist", "artist"], top_per_type: Annotated[int, typer.Option("--num-top", "-n", help="Number of top results to display per resource type.")] = 3, + pick_top_hit: Annotated[bool, typer.Option("--top", "-T", help="Automatically pick the top hit if it exists and matches the specified resource types.")] = False, +): + """ + Search Tidal for tracks, videos, albums, playlists, artists, and mixes. + + By default, it searches for all resource types. You can specify which resource types to search for using the `--type` option. + """ + results: Search = ctx.obj.api.get_search(query=query) + table = _prepare_table(query) + + results_to_display = [] + if results.topHit is not None: + top_hit = results.topHit + top_hit_type = top_hit.type.rstrip("S").lower() # "ARTISTS" -> "artist" + if top_hit_type in resource_types: + if pick_top_hit: + ctx.obj.resources.append(TidalResource.from_string(f"{top_hit_type}/{_display_id(top_hit.value)}")) + ctx.obj.console.print(f"[green]Automatically added top hit: {top_hit.type.title()} '{_display_name(top_hit.value)}'") + return + else: + results_to_display.append( + (top_hit_type.title(), _display_name(top_hit.value), _display_id(top_hit.value)) + ) + + type_to_items = { + "artist": results.artists.items, + "album": results.albums.items, + "playlist": results.playlists.items, + "track": results.tracks.items, + "video": results.videos.items, + } + + for resource_type, items in type_to_items.items(): + if resource_type in resource_types: + results_to_display.extend( + (resource_type.title(), _display_name(item), _display_id(item)) + for item in items[:top_per_type] + ) + + for i, (resource_type, name, id) in enumerate(results_to_display, start=1): + table.add_row(str(i), resource_type, name, id) + panel = Panel(table, title="Search Results", highlight=True, expand=True) + ctx.obj.console.print(panel) + selection = ctx.obj.console.input("[bold green]Enter the number of the resource to add to your list (comma-separated for multiple, q/empty = quit): ") + selected_numbers = [s.strip() for s in selection.split(",")] + for num in selected_numbers: + if num.lower() == "q": + return + if not num.isdigit() or int(num) < 1 or int(num) > len(results_to_display): + ctx.obj.console.print(f"[red]Invalid selection: {num}") + continue + selected_resource = results_to_display[int(num)-1] + resource_type, name, id = selected_resource + ctx.obj.resources.append(TidalResource.from_string(f"{resource_type.lower()}/{id}")) + ctx.obj.console.print(f"[green]Added {resource_type} '{name}' to your list") + + +def _display_name(item) -> str: + # if searchArtist, else if track/album, else playlist + if isinstance(item, SearchArtist): + return item.name + elif isinstance(item, (Track, Album)): + # Try to format as "Main Artist - Title" + main_artist = item.artists[0] if item.artists else None + return f"{main_artist.name} - {item.title}" if main_artist else f"{item.title}" + else: # Playlist + return item.title + +def _display_id(item) -> str: + return item.uuid if isinstance(item, Playlist) else str(item.id) + +def _prepare_table(query: str) -> Table: + table = Table(title=f"{query}", expand=True) + table.add_column("#", style="yellow", ratio=1) + table.add_column("Type", style="cyan", ratio=1) + table.add_column("Title", style="green", ratio=8) + table.add_column("ID", style="magenta", ratio=2) + return table \ No newline at end of file diff --git a/tiddl/core/api/models/base.py b/tiddl/core/api/models/base.py index aedeba3..d2c93e1 100644 --- a/tiddl/core/api/models/base.py +++ b/tiddl/core/api/models/base.py @@ -4,7 +4,6 @@ from pydantic import BaseModel from .resources import ( Album, - Artist, Playlist, StreamVideoQuality, Track, @@ -149,10 +148,21 @@ class VideoStream(BaseModel): manifest: str +# It seemed like the search API doesn't return `artist.type`, so this is used instead of resources.Artist for search results to avoid validation errors. +# FIXME: This can be discarded if we are okay with making the `type` field optional in resources.Artist, but I don't think it's my decision to make lol +class SearchArtist(BaseModel): # search-specific, fewer required fields + id: int + name: str + type: Optional[Literal["MAIN", "FEATURED"]] = None + url: Optional[str] = None + picture: Optional[str] = None + popularity: Optional[int] = None + class Search(BaseModel): + class Artists(Items): - items: List[Artist] + items: List[SearchArtist] # ← uses the inner model, not resources.Artist class Albums(Items): items: List[Album] @@ -167,7 +177,7 @@ class Search(BaseModel): items: List[Video] class TopHit(BaseModel): - value: Union[Artist, Track, Playlist, Album] + value: Union[SearchArtist, Track, Playlist, Album] type: Literal["ARTISTS", "TRACKS", "PLAYLISTS", "ALBUMS"] artists: Artists