stremio-addon-core 0.1.1

Reusable Rust core for authenticated Stremio addon servers
Documentation
use crate::metadata::TitleInfo;

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum QueryProfile {
    Webshare,
    Hellspy,
    Balanced,
}

#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct QueryInput {
    pub content_type: Option<String>,
    pub titles: Vec<String>,
    pub year: Option<String>,
    pub season: Option<u32>,
    pub episode: Option<u32>,
}

impl QueryInput {
    pub fn from_title_info(info: &TitleInfo) -> Self {
        Self {
            content_type: info.content_type.clone(),
            titles: info.title_candidates(),
            year: info.year.clone(),
            season: info.season,
            episode: info.episode,
        }
    }
}

pub fn build_search_queries(profile: QueryProfile, input: &QueryInput) -> Vec<String> {
    match profile {
        QueryProfile::Webshare => webshare_queries(input),
        QueryProfile::Hellspy => hellspy_queries_for_titles(input),
        QueryProfile::Balanced => balanced_queries(input),
    }
}

pub fn balanced_queries(input: &QueryInput) -> Vec<String> {
    title_year_episode_queries(input)
}

pub fn webshare_queries(input: &QueryInput) -> Vec<String> {
    title_year_episode_queries(input)
}

fn title_year_episode_queries(input: &QueryInput) -> Vec<String> {
    let titles = dedupe_non_empty(input.titles.clone());
    if input.content_type.as_deref() == Some("series") {
        let (Some(season), Some(episode)) = (input.season, input.episode) else {
            return Vec::new();
        };
        let season = format!("{season:02}");
        let episode = format!("{episode:02}");
        return titles
            .into_iter()
            .flat_map(|title| {
                [
                    format!("{title} S{season}E{episode}"),
                    format!("{title} {season}x{episode}"),
                ]
            })
            .collect();
    }

    let mut queries = titles.clone();
    if let Some(year) = input.year.as_deref().filter(|year| !year.is_empty()) {
        queries.extend(titles.into_iter().map(|title| format!("{title} {year}")));
    }
    dedupe_non_empty(queries)
}

pub fn hellspy_queries_for_titles(input: &QueryInput) -> Vec<String> {
    let mut queries = Vec::new();
    for title in &input.titles {
        queries.extend(hellspy_queries(
            input.content_type.as_deref(),
            title,
            input.year.as_deref(),
            input.season,
            input.episode,
        ));
    }
    dedupe_non_empty(queries)
}

pub fn hellspy_queries(
    content_type: Option<&str>,
    name: &str,
    year: Option<&str>,
    season: Option<u32>,
    episode: Option<u32>,
) -> Vec<String> {
    let simplified_name = name.split(':').next().unwrap_or(name);
    let mut queries = Vec::new();

    if let (Some("series"), Some(season), Some(episode)) = (content_type, season, episode) {
        let season_str = format!("{season:02}");
        let episode_str = format!("{episode:02}");
        queries.extend([
            format!("{name} S{season_str}E{episode_str}"),
            format!("{name} {season_str}x{episode_str}"),
            format!("{name}.S{season_str}E{episode_str}"),
            format!("{name} - {episode_str}"),
            format!("{simplified_name} S{season_str}E{episode_str}"),
        ]);
    } else {
        let suffix = year
            .filter(|year| !year.is_empty())
            .map(|year| format!(" {year}"))
            .unwrap_or_default();
        let dotted_suffix = year
            .filter(|year| !year.is_empty())
            .map(|year| format!(".{year}"))
            .unwrap_or_default();
        queries.extend([
            format!("{name}{suffix}"),
            format!("{simplified_name}{suffix}"),
            name.to_string(),
            simplified_name.to_string(),
            format!("{}{}", dotted_title(name), dotted_suffix),
        ]);
    }

    dedupe_non_empty(queries)
}

pub fn select_search_titles(
    info: &TitleInfo,
    fallback_name: Option<&str>,
    id: Option<&str>,
) -> Vec<String> {
    let mut titles = Vec::new();
    titles.extend(info.title_candidates());

    if let Some(fallback_name) = fallback_name.filter(|name| !name.is_empty()) {
        let should_add_fallback = id.is_none_or(|id| id != fallback_name)
            && !fallback_name.contains(":")
            && !titles.iter().any(|title| title == fallback_name);
        if should_add_fallback {
            titles.push(fallback_name.to_string());
        }
    }

    if titles.is_empty() {
        fallback_name.map(str::to_string).into_iter().collect()
    } else {
        dedupe_non_empty(titles)
    }
}

pub fn dotted_title(name: &str) -> String {
    name.chars()
        .map(|char| {
            if char.is_whitespace() || matches!(char, ':' | '-' | '/') {
                '.'
            } else {
                char
            }
        })
        .collect::<String>()
        .split('.')
        .filter(|part| !part.is_empty())
        .collect::<Vec<_>>()
        .join(".")
}

pub fn dedupe_non_empty(values: Vec<String>) -> Vec<String> {
    let mut out = Vec::new();
    for value in values {
        if !value.is_empty() && !out.contains(&value) {
            out.push(value);
        }
    }
    out
}

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

    #[test]
    fn webshare_movie_queries_include_year_variants() {
        let input = QueryInput {
            content_type: Some("movie".to_string()),
            titles: vec!["Soul".to_string(), "Duše".to_string()],
            year: Some("2020".to_string()),
            ..QueryInput::default()
        };

        assert_eq!(
            webshare_queries(&input),
            vec!["Soul", "Duše", "Soul 2020", "Duše 2020"]
        );
    }

    #[test]
    fn webshare_series_queries_match_existing_addon() {
        let input = QueryInput {
            content_type: Some("series".to_string()),
            titles: vec!["Breaking Bad".to_string()],
            season: Some(1),
            episode: Some(2),
            ..QueryInput::default()
        };

        assert_eq!(
            webshare_queries(&input),
            vec!["Breaking Bad S01E02", "Breaking Bad 01x02"]
        );
    }

    #[test]
    fn hellspy_movie_queries_match_rewrite_order() {
        assert_eq!(
            hellspy_queries(Some("movie"), "Alien: Covenant", Some("2017"), None, None),
            vec![
                "Alien: Covenant 2017",
                "Alien 2017",
                "Alien: Covenant",
                "Alien",
                "Alien.Covenant.2017"
            ]
        );
    }

    #[test]
    fn hellspy_series_queries_match_rewrite_order() {
        assert_eq!(
            hellspy_queries(Some("series"), "English Title", None, Some(1), Some(2)),
            vec![
                "English Title S01E02",
                "English Title 01x02",
                "English Title.S01E02",
                "English Title - 02"
            ]
        );
    }
}