use crate::classify::{classify, Classification, Flag, Site, VersionDraft};
use crate::context::{is_known_artist, warn, Ctx};
use crate::scanner::{
concat_skel, has_group, render_skel, slice_from, slice_skel, split_at_spaced_dashes, start_pos,
trim_skel, Segment, Skel, PH,
};
use crate::types::{ArtistSource, Mode, VersionDelimiter, Warning};
use crate::words::{
at, before, collapse_chars, in_vocab, is_letter_at, is_letter_before, is_upper_at,
is_whitespace, split_words_chars, tail, year_of,
};
#[derive(Debug, Clone)]
pub(crate) enum Peelable {
Junk(String),
Flag(Flag),
Year(u32),
Versions(Vec<VersionDraft>),
}
#[derive(Debug, Clone)]
pub(crate) struct Peeled {
pub cls: Peelable,
pub raw: String,
pub pos: i64,
}
fn classify_segment(seg: &Skel, ctx: &mut Ctx<'_>) -> Classification {
if has_group(seg) {
return Classification::Unknown;
}
let text = collapse_chars(&seg.text);
let site = Site {
source: ArtistSource::Title,
delimiter: VersionDelimiter::Dash,
pos: start_pos(seg, 0),
};
classify(&text, site, ctx)
}
fn peelable(c: Classification) -> Option<Peelable> {
match c {
Classification::Junk(kind) => Some(Peelable::Junk(kind)),
Classification::Flag(flag) => Some(Peelable::Flag(flag)),
Classification::Year(year) => Some(Peelable::Year(year)),
Classification::Versions(versions) => Some(Peelable::Versions(versions)),
Classification::Feat(_) | Classification::Producer(_) | Classification::Unknown => None,
}
}
pub(crate) fn peel_suffixes(
segments: Vec<Segment>,
relaxed: bool,
ctx: &mut Ctx<'_>,
) -> (Vec<Segment>, Vec<Peeled>) {
let mut remaining = segments;
let mut peeled: Vec<Peeled> = Vec::new();
while remaining.len() >= 2 {
let Some(last) = remaining.last() else { break };
let Some(cls) = peelable(classify_segment(&last.skel, ctx)) else { break };
let words = split_words_chars(&last.skel.text);
let prefix_form =
matches!(&cls, Peelable::Versions(vs) if vs.iter().all(|v| v.prefix_form));
let ok = relaxed
|| remaining.len() > 2
|| (words.len() >= 2 && !prefix_form)
|| words.iter().any(|w| year_of(w).is_some())
|| matches!(cls, Peelable::Junk(_));
if !ok {
break;
}
peeled.push(Peeled {
cls,
raw: collapse_chars(&last.skel.text),
pos: start_pos(&last.skel, 0),
});
remaining.pop();
}
peeled.reverse();
(remaining, peeled)
}
pub(crate) struct SeparatorResult {
pub artist: Option<Skel>,
pub title: Skel,
pub split: bool,
pub peeled: Vec<Peeled>,
}
pub(crate) struct SeparatorInput<'a> {
pub skel: &'a Skel,
pub mode: Mode,
pub uploader: Option<Skel>,
pub list_context: bool,
}
fn non_empty(s: &Skel) -> Option<Skel> {
let t = trim_skel(s);
(!t.is_empty()).then_some(t)
}
fn split_at(s: &Skel, cut_start: usize, cut_end: usize) -> (Option<Skel>, Skel) {
(non_empty(&slice_skel(s, 0, cut_start)), trim_skel(&slice_from(s, cut_end)))
}
fn result(parts: (Option<Skel>, Skel), peeled: Vec<Peeled>) -> SeparatorResult {
let split = parts.0.is_some();
SeparatorResult { artist: parts.0, title: parts.1, split, peeled }
}
fn first_dash_split(skel: &Skel, segments: &[Segment]) -> Option<(Option<Skel>, Skel)> {
let [first, second, ..] = segments else { return None };
let last = segments.last()?;
let artist = (!first.skel.is_empty()).then(|| first.skel.clone());
Some((artist, slice_skel(skel, second.start, last.end)))
}
pub(crate) fn find_separator(input: SeparatorInput<'_>, ctx: &mut Ctx<'_>) -> SeparatorResult {
let skel = input.skel;
let mode = input.mode;
let segments = split_at_spaced_dashes(skel, true);
if mode == Mode::Tracklist {
return tracklist_separator(skel, &segments, input.list_context, ctx);
}
let (remaining, peeled) = peel_suffixes(segments, false, ctx);
if let Some(parts) = first_dash_split(skel, &remaining) {
return result(parts, peeled);
}
let rest = remaining.first().map_or_else(|| slice_skel(skel, 0, 0), |s| s.skel.clone());
if let Some(other) = split_without_spaced_dash(&rest, mode, ctx) {
return result(other, peeled);
}
let artist = if mode == Mode::Youtube { input.uploader } else { None };
if !rest.is_empty() && artist.is_none() {
warn(ctx, Warning::NoSeparator);
}
SeparatorResult { artist, title: rest, split: false, peeled }
}
fn tracklist_separator(
skel: &Skel,
segments: &[Segment],
list_context: bool,
ctx: &mut Ctx<'_>,
) -> SeparatorResult {
if let Some(parts) = first_dash_split(skel, segments) {
return result(parts, Vec::new());
}
let rest = segments.first().map_or_else(|| slice_skel(skel, 0, 0), |s| s.skel.clone());
if let Some(asym) = asymmetric_dash(&rest.text) {
warn(ctx, Warning::AsymmetricDashSplit);
return result(split_at(&rest, asym, asym + 1), Vec::new());
}
if let Some(underscore) = rest.text.windows(3).position(|w| w == ['_', '-', '_']) {
warn(ctx, Warning::UnspacedDashSplit);
return result(split_at(&rest, underscore, underscore + 3), Vec::new());
}
if list_context {
if let Some(tight) = tight_upper_dash(&rest.text) {
warn(ctx, Warning::UnspacedDashSplit);
return result(split_at(&rest, tight, tight + 1), Vec::new());
}
}
if !rest.is_empty() {
warn(ctx, Warning::NoSeparator);
}
SeparatorResult { artist: None, title: rest, split: false, peeled: Vec::new() }
}
fn tight_upper_dash(t: &[char]) -> Option<usize> {
(1..t.len().saturating_sub(1))
.rev()
.find(|&i| at(t, i) == Some('-') && is_letter_before(t, i) && is_upper_at(t, i + 1))
}
fn split_without_spaced_dash(
s: &Skel,
mode: Mode,
ctx: &mut Ctx<'_>,
) -> Option<(Option<Skel>, Skel)> {
if let Some(asym) = asymmetric_dash(&s.text) {
warn(ctx, Warning::AsymmetricDashSplit);
return Some(split_at(s, asym, asym + 1));
}
if mode == Mode::Youtube {
if let Some(quoted) = quoted_title(s) {
warn(ctx, Warning::QuotedTitleSplit);
return Some(quoted);
}
if let Some(by) = by_split(s, ctx) {
warn(ctx, Warning::BySplit);
let (left, right) = split_at(s, by, by + 4);
let artist = (!right.is_empty()).then_some(right);
return Some((artist, left.unwrap_or_else(|| s.clone())));
}
}
if mode == Mode::Youtube || mode == Mode::Filename {
if let Some(dash) = unspaced_dash(s, ctx) {
warn(ctx, Warning::UnspacedDashSplit);
return Some(split_at(s, dash, dash + 1));
}
}
None
}
fn asymmetric_dash(t: &[char]) -> Option<usize> {
(1..t.len().saturating_sub(1)).find(|&i| {
at(t, i) == Some('-') && is_whitespace(before(t, i)) != is_whitespace(at(t, i + 1))
})
}
fn quoted_title(s: &Skel) -> Option<(Option<Skel>, Skel)> {
let t = &s.text;
let open = t.iter().position(|&c| c == '"').filter(|&i| i > 0)?;
let close = tail(t, open + 1).iter().position(|&c| c == '"')? + open + 1;
let mut artist = trim_skel(&slice_skel(s, 0, open));
if matches!(artist.text.last(), Some(':' | '-')) {
artist = trim_skel(&slice_skel(&artist, 0, artist.len() - 1));
}
let title = trim_skel(&concat_skel(&slice_skel(s, open + 1, close), &slice_from(s, close + 1)));
Some(((!artist.is_empty()).then_some(artist), title))
}
fn by_split(s: &Skel, ctx: &Ctx<'_>) -> Option<usize> {
let t = &s.text;
let mut i = t.len().checked_sub(4)?;
while i > 0 {
if t.get(i..i + 4) == Some(&[' ', 'b', 'y', ' ']) {
let right: Vec<String> = split_words_chars(tail(t, i + 4))
.into_iter()
.filter(|w| !(w.chars().count() == 1 && w.starts_with(PH)))
.collect();
let single_stopword =
right.len() == 1 && right.first().is_some_and(|w| in_vocab(w, &ctx.t.stopwords));
if !right.is_empty() && !single_stopword {
return Some(i);
}
}
i -= 1;
}
None
}
fn unspaced_dash(s: &Skel, ctx: &Ctx<'_>) -> Option<usize> {
let t = &s.text;
let mut last_candidate = None;
for i in 1..t.len().saturating_sub(1) {
if at(t, i) != Some('-') || is_whitespace(before(t, i)) || is_whitespace(at(t, i + 1)) {
continue;
}
if !(at(t, i + 1) == Some('"') || is_letter_at(t, i + 1)) {
continue;
}
if !ctx.known_keys.is_empty() && is_known_artist(&render_skel(&slice_skel(s, 0, i)), ctx) {
return Some(i);
}
last_candidate = Some(i);
}
last_candidate
}