wax-dig 0.1.2

CLI for digging through public music discovery signals from Bandcamp and SoundCloud
Documentation
use std::collections::{HashMap, HashSet};

use regex::Regex;
use scraper::{Html, Selector};
use url::Url;

use crate::error::{AppError, Result};
use crate::model::{Collector, ItemKind, OwnedAlbum, Platform, SeedAlbum};

pub fn normalize_url(url: &str) -> Result<String> {
    let mut parsed = Url::parse(url)?;
    parsed.set_fragment(None);
    parsed.set_query(None);

    if parsed.path() != "/" {
        let trimmed = parsed.path().trim_end_matches('/').to_string();
        parsed.set_path(&trimmed);
    }

    Ok(parsed.to_string())
}

pub fn resolve_seed(url: &str, html: &str) -> Result<SeedAlbum> {
    let document = Html::parse_document(html);
    let canonical_url = meta_content(&document, r#"meta[property="og:url"]"#)
        .or_else(|| Some(url.to_string()))
        .map(|value| normalize_url(&value))
        .transpose()?
        .ok_or_else(|| AppError::Parse("unable to determine canonical URL".to_string()))?;

    let og_title = meta_content(&document, r#"meta[property="og:title"]"#)
        .or_else(|| title_text(&document))
        .unwrap_or_else(|| "Unknown Album".to_string());

    let (title, artist) = split_album_artist(&og_title);
    let artist_name = artist.unwrap_or_else(|| {
        infer_artist_from_html(html).unwrap_or_else(|| "Unknown Artist".to_string())
    });
    let tags = collect_tag_text(&document);

    Ok(SeedAlbum {
        platform: Platform::Bandcamp,
        kind: ItemKind::Album,
        title,
        artist: artist_name,
        url: canonical_url,
        artist_url: None,
        tags,
        label: None,
        release_id: infer_release_id(html),
    })
}

pub fn parse_collectors(html: &str) -> Vec<Collector> {
    let document = Html::parse_document(html);
    let selector = Selector::parse("a[href]").expect("valid selector");
    let mut seen = HashSet::new();
    let mut collectors = Vec::new();

    for anchor in document.select(&selector) {
        let Some(href) = anchor.value().attr("href") else {
            continue;
        };
        let Some(url) = normalize_collector_url(href) else {
            continue;
        };
        if !seen.insert(url.clone()) {
            continue;
        }

        let handle = url
            .trim_end_matches('/')
            .rsplit('/')
            .next()
            .unwrap_or("unknown")
            .to_string();
        let text = anchor
            .text()
            .collect::<Vec<_>>()
            .join(" ")
            .trim()
            .to_string();

        collectors.push(Collector {
            handle,
            url,
            display_name: if text.is_empty() { None } else { Some(text) },
            visible: true,
        });
    }

    collectors
}

pub fn parse_owned_albums(html: &str) -> Vec<OwnedAlbum> {
    let document = Html::parse_document(html);
    let selector = Selector::parse("a[href]").expect("valid selector");
    let mut albums = HashMap::<String, OwnedAlbum>::new();

    for anchor in document.select(&selector) {
        let Some(href) = anchor.value().attr("href") else {
            continue;
        };
        let Some(url) = normalize_album_url(href) else {
            continue;
        };

        let text = anchor.text().collect::<Vec<_>>().join(" ");
        let trimmed = collapse_ws(&text);
        let (title, artist) = split_album_artist(&trimmed);

        albums.entry(url.clone()).or_insert_with(|| OwnedAlbum {
            platform: Platform::Bandcamp,
            kind: ItemKind::Album,
            title: if title.is_empty() {
                "Unknown Album".to_string()
            } else {
                title
            },
            artist: artist.unwrap_or_else(|| "Unknown Artist".to_string()),
            url,
            tags: Vec::new(),
            label: None,
        });
    }

    albums.into_values().collect()
}

fn meta_content(document: &Html, selector: &str) -> Option<String> {
    let selector = Selector::parse(selector).ok()?;
    document
        .select(&selector)
        .next()
        .and_then(|node| node.value().attr("content"))
        .map(|value| value.trim().to_string())
}

fn title_text(document: &Html) -> Option<String> {
    let selector = Selector::parse("title").ok()?;
    document
        .select(&selector)
        .next()
        .map(|node| collapse_ws(&node.text().collect::<Vec<_>>().join(" ")))
}

fn split_album_artist(raw: &str) -> (String, Option<String>) {
    let cleaned = collapse_ws(raw);
    if let Some((title, artist)) = cleaned.split_once(", by ") {
        return (title.trim().to_string(), Some(artist.trim().to_string()));
    }
    if let Some((title, artist)) = cleaned.split_once(" | ") {
        return (title.trim().to_string(), Some(artist.trim().to_string()));
    }
    (cleaned, None)
}

fn collect_tag_text(document: &Html) -> Vec<String> {
    let selector = Selector::parse(r#"a[href*="/tag/"]"#).expect("valid selector");
    let mut seen = HashSet::new();
    let mut tags = Vec::new();
    for tag in document.select(&selector) {
        let text = collapse_ws(&tag.text().collect::<Vec<_>>().join(" "));
        if !text.is_empty() && seen.insert(text.clone()) {
            tags.push(text);
        }
    }
    tags
}

fn infer_artist_from_html(html: &str) -> Option<String> {
    let patterns = [
        Regex::new(r#""artist"\s*:\s*"([^"]+)""#).ok()?,
        Regex::new(r#""byArtist"\s*:\s*"([^"]+)""#).ok()?,
    ];

    for pattern in patterns {
        if let Some(capture) = pattern.captures(html) {
            return Some(capture.get(1)?.as_str().to_string());
        }
    }

    None
}

fn infer_release_id(html: &str) -> Option<String> {
    let pattern = Regex::new(r#""id"\s*:\s*([0-9]+)"#).ok()?;
    pattern
        .captures(html)
        .and_then(|capture| capture.get(1))
        .map(|id| id.as_str().to_string())
}

fn normalize_collector_url(href: &str) -> Option<String> {
    let url = if href.starts_with("http://") || href.starts_with("https://") {
        Url::parse(href).ok()?
    } else {
        Url::parse(&format!(
            "https://bandcamp.com{}",
            ensure_leading_slash(href)
        ))
        .ok()?
    };

    let host = url.host_str()?;
    let path = url.path().trim_end_matches('/');
    let first_segment = path
        .trim_start_matches('/')
        .split('/')
        .next()
        .unwrap_or_default();

    if host == "bandcamp.com"
        && !first_segment.is_empty()
        && !matches!(
            first_segment,
            "album" | "track" | "music" | "discover" | "tag" | "about" | "help" | "search"
        )
    {
        return Some(format!("https://bandcamp.com/{first_segment}"));
    }

    None
}

fn normalize_album_url(href: &str) -> Option<String> {
    let candidate = if href.starts_with("http://") || href.starts_with("https://") {
        Url::parse(href).ok()?
    } else {
        return None;
    };

    if candidate.path().contains("/album/") {
        let mut normalized = candidate;
        normalized.set_query(None);
        normalized.set_fragment(None);
        return Some(normalized.to_string().trim_end_matches('/').to_string());
    }

    None
}

fn ensure_leading_slash(path: &str) -> String {
    if path.starts_with('/') {
        path.to_string()
    } else {
        format!("/{path}")
    }
}

fn collapse_ws(value: &str) -> String {
    value.split_whitespace().collect::<Vec<_>>().join(" ")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn normalizes_album_url() {
        let actual = normalize_url("https://artist.bandcamp.com/album/test?x=1#frag").unwrap();
        assert_eq!(actual, "https://artist.bandcamp.com/album/test");
    }

    #[test]
    fn parses_collectors_from_bandcamp_links() {
        let html = r#"
            <html><body>
                <a href="https://bandcamp.com/fan_a">Fan A</a>
                <a href="/fan_b">Fan B</a>
                <a href="https://bandcamp.com/discover">Discover</a>
            </body></html>
        "#;
        let collectors = parse_collectors(html);
        assert_eq!(collectors.len(), 2);
        assert_eq!(collectors[0].url, "https://bandcamp.com/fan_a");
    }

    #[test]
    fn parses_owned_album_links() {
        let html = r#"
            <html><body>
                <a href="https://artist.bandcamp.com/album/record-a">Record A, by Artist A</a>
                <a href="https://artist.bandcamp.com/track/song-a">Song A</a>
            </body></html>
        "#;
        let albums = parse_owned_albums(html);
        assert_eq!(albums.len(), 1);
        assert_eq!(albums[0].title, "Record A");
        assert_eq!(albums[0].artist, "Artist A");
        assert_eq!(albums[0].platform, Platform::Bandcamp);
        assert_eq!(albums[0].kind, ItemKind::Album);
    }

    #[test]
    fn resolves_seed_from_og_title() {
        let html = r#"
            <html><head>
                <meta property="og:url" content="https://artist.bandcamp.com/album/seed">
                <meta property="og:title" content="Seed Record, by Seed Artist">
            </head></html>
        "#;
        let seed = resolve_seed("https://artist.bandcamp.com/album/seed", html).unwrap();
        assert_eq!(seed.title, "Seed Record");
        assert_eq!(seed.artist, "Seed Artist");
        assert_eq!(seed.platform, Platform::Bandcamp);
        assert_eq!(seed.kind, ItemKind::Album);
    }
}