tail-fin-cli 0.3.0

Multi-site browser automation CLI — Twitter, Reddit, Bloomberg, Coupang, PCC, Instagram, YouTube, Grok, SeekingAlpha, Xiaohongshu, 591, Nansen
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,
    /// 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 {
        eprintln!("Launching stealth browser for Spotify...");
        let s = night_fury_core::BrowserSession::builder()
            .headed(ctx.headed)
            .cloudflare_timeout(std::time::Duration::from_secs(30))
            .launch_stealth("https://open.spotify.com")
            .await?;
        tokio::time::sleep(std::time::Duration::from_secs(2)).await;
        s
    };

    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::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
        })
    }
}