tail-fin-cli 0.4.0

Multi-site browser automation CLI — attaches to Chrome or auto-launches a stealth browser to drive 14+ sites
use clap::Subcommand;
use tail_fin_common::TailFinError;

use crate::session::{browser_session, print_json, Ctx};

#[derive(Subcommand)]
pub enum SpotifyAction {
    /// Search across tracks, albums, artists, and playlists
    Search { query: String },
    /// Track details by id (the part after `spotify:track:`)
    Track { id: String },
    /// Album details + tracklist
    Album { id: String },
    /// Playlist details + tracks
    Playlist { id: String },
    /// Artist overview + top tracks
    Artist { id: String },
    /// Full discography (albums / singles / compilations) for an artist
    Discography { id: String },
    /// "Fans also like" related artists for an artist
    Related { id: String },
    /// Current authenticated user's account info (country, product, attributes)
    Me,
    /// Personalized home feed (recommendations)
    Home,
    /// Podcast show details + first page of episodes
    Show { id: String },
    /// Podcast episode details
    Episode { id: String },
    /// Audiobook details + first page of chapters
    Audiobook { id: String },
    /// Public user profile (by username)
    User { username: String },
    /// User library (playlists + Liked Songs) via sidebar freebies on `/`.
    /// Best-effort — see `docs/sites/spotify.md#library` for caveats.
    Library,
    /// Recently played feed (tracks / playlists / albums / podcasts)
    #[command(name = "recently-played")]
    RecentlyPlayed,
    /// User's recent search history (queries + entity jumps)
    #[command(name = "recent-searches")]
    RecentSearches,
    /// Debug: dump all captured pathfinder responses for a given path
    #[command(hide = true)]
    Discover {
        #[arg(default_value = "/")]
        path: String,
    },
}

pub async fn run(action: SpotifyAction, ctx: &Ctx) -> Result<(), TailFinError> {
    let session = if let Some(ref host) = ctx.connect {
        browser_session(host, ctx.headed).await?
    } else {
        crate::session::auto_launch_stealth("https://open.spotify.com", ctx.headed).await?
    };
    tokio::time::sleep(std::time::Duration::from_secs(2)).await;

    let client = tail_fin_spotify::SpotifyClient::new(session);

    if let Some(ref cookies_flag) = ctx.cookies {
        let path = if cookies_flag == "auto" {
            crate::session::default_cookies_path("spotify")
        } else {
            std::path::PathBuf::from(cookies_flag)
        };
        eprintln!("Injecting auth cookies from {}...", path.display());
        client.inject_cookies(&path).await?;
    }

    match action {
        SpotifyAction::Search { query } => print_json(&client.search(&query).await?)?,
        SpotifyAction::Track { id } => print_json(&client.track(&id).await?)?,
        SpotifyAction::Album { id } => print_json(&client.album(&id).await?)?,
        SpotifyAction::Playlist { id } => print_json(&client.playlist(&id).await?)?,
        SpotifyAction::Artist { id } => print_json(&client.artist(&id).await?)?,
        SpotifyAction::Discography { id } => print_json(&client.discography(&id).await?)?,
        SpotifyAction::Related { id } => print_json(&client.related(&id).await?)?,
        SpotifyAction::Me => print_json(&client.me().await?)?,
        SpotifyAction::Home => print_json(&client.home().await?)?,
        SpotifyAction::Show { id } => print_json(&client.show(&id).await?)?,
        SpotifyAction::Episode { id } => print_json(&client.episode(&id).await?)?,
        SpotifyAction::Audiobook { id } => print_json(&client.audiobook(&id).await?)?,
        SpotifyAction::User { username } => print_json(&client.user(&username).await?)?,
        SpotifyAction::Library => print_json(&client.library().await?)?,
        SpotifyAction::RecentlyPlayed => print_json(&client.recently_played().await?)?,
        SpotifyAction::RecentSearches => print_json(&client.recent_searches().await?)?,
        SpotifyAction::Discover { path } => print_json(&client.discover(&path).await?)?,
    }
    Ok(())
}

pub struct Adapter;

impl crate::adapter::CliAdapter for Adapter {
    fn name(&self) -> &'static str {
        "spotify"
    }
    fn about(&self) -> &'static str {
        "Spotify Web Player (browser + pathfinder capture)"
    }

    fn command(&self) -> clap::Command {
        <SpotifyAction as clap::Subcommand>::augment_subcommands(
            clap::Command::new("spotify")
                .about("Spotify Web Player (browser + pathfinder capture)"),
        )
    }

    fn dispatch<'a>(
        &'a self,
        matches: &'a clap::ArgMatches,
        ctx: &'a crate::session::Ctx,
    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), TailFinError>> + Send + 'a>>
    {
        Box::pin(async move {
            let action = <SpotifyAction as clap::FromArgMatches>::from_arg_matches(matches)
                .map_err(|e| TailFinError::Api(e.to_string()))?;
            run(action, ctx).await
        })
    }
}