Added search command (#315)

* feat: search command

* fix: top match resource type

* fix: top hit resource type parsing logic

* fix: SearchArtist as non-nested class
This commit is contained in:
Reyth
2026-04-28 16:43:04 +02:00
committed by GitHub
parent 79c21f7842
commit 859d50772d
3 changed files with 125 additions and 4 deletions
+2 -1
View File
@@ -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):
+110
View File
@@ -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="<resource>",
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
+13 -3
View File
@@ -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