Token Classification
Transformers
ONNX
Safetensors
English
Japanese
Chinese
bert
anime
filename-parsing
Eval Results (legacy)
Instructions to use ModerRAS/AniFileBERT with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use ModerRAS/AniFileBERT with Transformers:
# Use a pipeline as a high-level helper from transformers import pipeline pipe = pipeline("token-classification", model="ModerRAS/AniFileBERT")# Load model directly from transformers import AutoTokenizer, AutoModelForTokenClassification tokenizer = AutoTokenizer.from_pretrained("ModerRAS/AniFileBERT") model = AutoModelForTokenClassification.from_pretrained("ModerRAS/AniFileBERT") - Notebooks
- Google Colab
- Kaggle
| use anyhow::{bail, Context, Result}; | |
| use chrono::Utc; | |
| use clap::Parser; | |
| use once_cell::sync::{Lazy, OnceCell}; | |
| use rayon::prelude::*; | |
| use regex::Regex; | |
| use serde::{Deserialize, Serialize}; | |
| use serde_json::{json, Value}; | |
| use std::collections::{HashMap, HashSet}; | |
| use std::fs::{self, File}; | |
| use std::io::{BufRead, BufReader, BufWriter, Write}; | |
| use std::path::PathBuf; | |
| use std::sync::atomic::{AtomicUsize, Ordering}; | |
| struct Args { | |
| cluster: bool, | |
| audit_low_frequency: bool, | |
| verify_generated_output: bool, | |
| rich_annotations: bool, | |
| input: PathBuf, | |
| recipes: PathBuf, | |
| output: PathBuf, | |
| manifest_output: PathBuf, | |
| summary_output: PathBuf, | |
| samples_output: PathBuf, | |
| clusters_output: PathBuf, | |
| recipes_output: PathBuf, | |
| review_output: PathBuf, | |
| audit_output: PathBuf, | |
| rich_output: PathBuf, | |
| audit_max_count: u64, | |
| limit: Option<usize>, | |
| limit_templates: Option<usize>, | |
| min_count: u64, | |
| top: usize, | |
| recipe_top: usize, | |
| review_top: usize, | |
| examples: usize, | |
| recipe_min_count: usize, | |
| confidence: String, | |
| expand: String, | |
| sample_per_template: usize, | |
| keep_encoding_noise: bool, | |
| preserve_parent_paths: bool, | |
| title_whitelist: PathBuf, | |
| group_whitelist: PathBuf, | |
| threads: Option<usize>, | |
| } | |
| struct Whitelists { | |
| title_phrases: Vec<Vec<String>>, | |
| group_names: HashSet<String>, | |
| } | |
| static RUNTIME_WHITELISTS: OnceCell<Whitelists> = OnceCell::new(); | |
| struct Recipe { | |
| template_id: String, | |
| template: String, | |
| roles: Vec<String>, | |
| confidence: Option<String>, | |
| count: Option<u64>, | |
| } | |
| struct Record { | |
| filename: String, | |
| tokens: Vec<String>, | |
| labels: Vec<String>, | |
| template_id: String, | |
| template: String, | |
| source_filename: Option<String>, | |
| path_trimmed: Option<bool>, | |
| dropped_title_candidate_positions: Option<Vec<String>>, | |
| } | |
| struct Group { | |
| indices: Vec<usize>, | |
| class_name: String, | |
| } | |
| struct Stats { | |
| seen: usize, | |
| skipped_encoding_noise: usize, | |
| skipped_music_audio_collection: usize, | |
| trimmed_parent_path: usize, | |
| skipped_no_recipe: usize, | |
| skipped_sample_cap: usize, | |
| skipped_role_mismatch: usize, | |
| skipped_low_frequency_audit_warning: usize, | |
| written: usize, | |
| } | |
| struct Cluster { | |
| count: usize, | |
| examples: Vec<String>, | |
| literal_counts: HashMap<String, usize>, | |
| class_counts: HashMap<String, usize>, | |
| position_literals: Vec<HashMap<String, usize>>, | |
| } | |
| enum Processed { | |
| Written { | |
| record: Record, | |
| trimmed_parent: bool, | |
| }, | |
| Skipped { | |
| reason: &'static str, | |
| trimmed_parent: bool, | |
| example: Option<String>, | |
| warnings: Vec<String>, | |
| }, | |
| } | |
| static HASH_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[A-Fa-f0-9]{8,}$").unwrap()); | |
| static RESOLUTION_RE: Lazy<Regex> = | |
| Lazy::new(|| Regex::new(r"(?i)^(?:\d{3,4}p|\dK|\d{3,4}[xX×]\d{3,4})$").unwrap()); | |
| static BARE_RESOLUTION_RE: Lazy<Regex> = | |
| Lazy::new(|| Regex::new(r"^(?:360|480|540|576|720|1080|2160)$").unwrap()); | |
| static EPISODE_VERSION_RE: Lazy<Regex> = | |
| Lazy::new(|| Regex::new(r"(?i)^(?:EP?)?\d{1,4}(?:v|ver|version|rev)\d{1,3}$").unwrap()); | |
| static EPISODE_WITH_SUFFIX_RE: Lazy<Regex> = Lazy::new(|| { | |
| Regex::new(r"(?i)^\d{1,4}[_ .-]?(?:Notice|Full|R18|R|Uncut|Director'?s?Cut)$").unwrap() | |
| }); | |
| static EPISODE_RE: Lazy<Regex> = | |
| Lazy::new(|| Regex::new(r"(?i)^(?:EP?|#)?\d{1,4}(?:\.\d{1,2})?(?:END)?$").unwrap()); | |
| static DECIMAL_EPISODE_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"^\d{1,3}\.\d{1,2}$").unwrap()); | |
| static NUMERIC_TITLE_PREFIX_RE: Lazy<Regex> = | |
| Lazy::new(|| Regex::new(r"^\d{1,3}(?:[./-]\d{1,3})?$").unwrap()); | |
| static EPISODE_CJK_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"^第?\d{1,4}[话話回集]$").unwrap()); | |
| static EPISODE_CJK_PREFIX_RE: Lazy<Regex> = | |
| Lazy::new(|| Regex::new(r"^第?\d{1,4}[话話回集]").unwrap()); | |
| static EPISODE_RANGE_RE: Lazy<Regex> = | |
| Lazy::new(|| Regex::new(r"(?i)^\d{1,4}\s*[-~]\s*\d{1,4}(?:\s*END)?$").unwrap()); | |
| static EPISODE_BATCH_RE: Lazy<Regex> = Lazy::new(|| { | |
| Regex::new(r"(?i)^\d{1,4}\s*[-~]\s*\d{1,4}(?:\s*(?:TV|全集|全|END|Fin|Complete|SP|OVA|OAD|NCOP|NCED)|[+_./-])*.{0,16}$").unwrap() | |
| }); | |
| static SXE_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?i)^S\d{1,2}E\d{1,4}(?:v\d+)?$").unwrap()); | |
| static SXE_VALUE_RE: Lazy<Regex> = | |
| Lazy::new(|| Regex::new(r"(?i)^S(\d{1,2})E(\d{1,4})(?:v(\d+))?$").unwrap()); | |
| static EPISODE_VALUE_RE: Lazy<Regex> = | |
| Lazy::new(|| Regex::new(r"(?i)^(EP|E|#)(\d{1,4}(?:\.\d{1,2})?)(?:v(\d+))?$").unwrap()); | |
| static SEASON_RE: Lazy<Regex> = Lazy::new(|| { | |
| Regex::new(r"(?i)^(?:S\d{1,2}|Season\s*\d{1,2}|第[一二三四五六七八九十\d]+[季期部])$").unwrap() | |
| }); | |
| static CJK_SEASON_TOKEN_RE: Lazy<Regex> = | |
| Lazy::new(|| Regex::new(r"^第[一二三四五六七八九十\d]+[季期部]$").unwrap()); | |
| static CJK_SEASON_EMBEDDED_RE: Lazy<Regex> = | |
| Lazy::new(|| Regex::new(r"^(.+?)(第[一二三四五六七八九十\d]+[季期部])(.{0,12})$").unwrap()); | |
| static CJK_EPISODE_EMBEDDED_RE: Lazy<Regex> = | |
| Lazy::new(|| Regex::new(r"^(.+?)(第?\d{1,4}[话話回集])(.{0,32})$").unwrap()); | |
| static CJK_TITLE_TRAILING_EPISODE_RE: Lazy<Regex> = | |
| Lazy::new(|| Regex::new(r"^(.+[\p{Han}\p{Hiragana}\p{Katakana}])(\d{2,3})$").unwrap()); | |
| static ASCII_SEASON_SUFFIX_RE: Lazy<Regex> = | |
| Lazy::new(|| Regex::new(r"(?i)^(.+?)[\s_.-]+(S\d{1,2})$").unwrap()); | |
| static ORDINAL_SEASON_TOKEN_RE: Lazy<Regex> = | |
| Lazy::new(|| Regex::new(r"(?i)^\d{1,2}(?:st|nd|rd|th)$").unwrap()); | |
| static WORD_ORDINAL_SEASON_TOKEN_RE: Lazy<Regex> = Lazy::new(|| { | |
| Regex::new(r"(?i)^(?:First|Second|Third|Fourth|Fifth|Sixth|Seventh|Eighth|Ninth|Tenth)$") | |
| .unwrap() | |
| }); | |
| static SEASON_WORD_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?i)^(?:Season|Saison)$").unwrap()); | |
| static CJK_TITLE_LANG_PREFIX_RE: Lazy<Regex> = Lazy::new(|| { | |
| Regex::new(r"^(.+?)(国日双语|國日雙語|日语版|日語版|国语版|國語版|双语|雙語)(第?)$").unwrap() | |
| }); | |
| static SEASON_VALUE_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?i)^S(\d{1,2})$").unwrap()); | |
| static SPECIAL_RE: Lazy<Regex> = Lazy::new(|| { | |
| Regex::new(r"(?i)^(?:(?:NCOP|NCED|OP|ED|PV|CM)(?:[\s_.-]?(?:\d{1,4}|v\d{1,3}|[A-Z]))?|SP(?:[\s_.-]?\d{0,4})?|(?:OVA|OAD|IV)(?:[\s_.-]?\d{0,4})?|(?:BD)?Menu(?:[\s_.-]?(?:\d{0,4}|Ep\d{1,4}|[A-Z]))?|(?:BD[-_. ]?)?Spot(?:[\s_.-]?(?:\d{0,4}|Ep\d{1,4}|[A-Z]))?|(?:Intro|Preview|Trailer|Teaser|Animatics?)(?:[\s_.-]?(?:\d{0,4}|Ep\d{1,4}|[A-Z]))?)$").unwrap() | |
| }); | |
| static VOLUME_RE: Lazy<Regex> = | |
| Lazy::new(|| Regex::new(r"(?i)^(?:Vol(?:ume)?\.?|Disc|CD|BD|DVD|D)\s*\d{1,3}$").unwrap()); | |
| static DATE_RE: Lazy<Regex> = | |
| Lazy::new(|| Regex::new(r"^(?:19|20)\d{2}(?:[._-]\d{1,2}){0,2}$").unwrap()); | |
| static DATE_RANGE_MIXED_RE: Lazy<Regex> = Lazy::new(|| { | |
| Regex::new( | |
| r"^(?:19|20)\d{2}(?:[._-]\d{1,2}){0,2}\s*[-~]\s*(?:19|20)\d{2}(?:[._-]\d{1,2}){0,2}$", | |
| ) | |
| .unwrap() | |
| }); | |
| static CJK_DATE_RE: Lazy<Regex> = | |
| Lazy::new(|| Regex::new(r"^(?:19|20)\d{2}年\d{1,2}月\d{1,2}日$").unwrap()); | |
| static LANG_RE: Lazy<Regex> = Lazy::new(|| { | |
| Regex::new(r"(?i)^(?:CHS|CHT|ZHS|ZHT|GB|BIG5|JPN?|JP|JA|JAP|JPTC|JPSC|ENG|EN|SC|TC|简[体體]?|繁[体體]?|简日|繁日|字幕|内封|外挂|Sub|Subs|MSubs?)$").unwrap() | |
| }); | |
| static MEDIA_RE: Lazy<Regex> = Lazy::new(|| { | |
| Regex::new(r"(?i)^(?:WEB|WEB[-_. ]?DL|WEB[-_. ]?Rip|BDRip|BluRay|BDMV|BD|DVDRip|DVD|HD|UHD|HDTV|TVRip|REMUX|xvid|x26[45]|h\.?26[45]|HEVC|AVC|AV1|YUV\d+P?\d*|AAC\s*\d*(?:\.\d+)?|DDP\s*\d*(?:\.\d+)?|FLAC|MP3|DTS|HDMA|DTS-HDMA|E?AC3x?\d*(?:\.\d+)?|Opus|WMV\d*|\d(?:\.\d)?ch|10[-_. ]?bit|8[-_. ]?bit|Hi10p?|Ma10p|ASSx?\d*|SRTx?\d*|SUP|R\d[A-Z]*|NoSub|MKV|MP4|AVI|RAW|Raws?)$").unwrap() | |
| }); | |
| static SPECIAL_TITLE_PHRASE_RE: Lazy<Regex> = Lazy::new(|| { | |
| Regex::new(r"(?i)\b(?:theater\s+greeting\s+event|world\s+prem(?:eie|iere)|picture\s+drama)\b") | |
| .unwrap() | |
| }); | |
| static YEAR_RANGE_RE: Lazy<Regex> = | |
| Lazy::new(|| Regex::new(r"^\(?\s*(?:19|20)\d{2}\s*[-~]\s*(?:19|20)\d{2}\s*\)?$").unwrap()); | |
| static VERSIONISH_TITLE_RE: Lazy<Regex> = | |
| Lazy::new(|| Regex::new(r"(?i)^(?:19|20)\d{2}(?:版|ver\.?|version)?$").unwrap()); | |
| static PATH_SEGMENT_SEASON_RE: Lazy<Regex> = Lazy::new(|| { | |
| Regex::new(r"(?i)(?:^|[\s_.\-/])(?:season\s*\d{1,2}|s\d{1,2})(?:$|[\s_.\-/])").unwrap() | |
| }); | |
| static SEASON_WORD_NUMBER_RE: Lazy<Regex> = | |
| Lazy::new(|| Regex::new(r"(?i)(?:season|saison)\s*0?(\d{1,2})").unwrap()); | |
| static PLAIN_SEASON_SEGMENT_RE: Lazy<Regex> = | |
| Lazy::new(|| Regex::new(r"(?i)^(?:season|saison)\s*0?\d{1,2}$|^s0?\d{1,2}$").unwrap()); | |
| static S_NUMBER_SEGMENT_RE: Lazy<Regex> = | |
| Lazy::new(|| Regex::new(r"(?i)(?:^|[^\p{L}\p{N}])s0?(\d{1,2})(?:$|[^\p{L}\p{N}])").unwrap()); | |
| static SXE_SEASON_RE: Lazy<Regex> = Lazy::new(|| { | |
| Regex::new(r"(?i)(?:^|[^\p{L}\p{N}])s0?(\d{1,2})e\d{1,4}(?:$|[^\p{L}\p{N}])").unwrap() | |
| }); | |
| static TOKEN_REGEXES: Lazy<Vec<Regex>> = Lazy::new(|| { | |
| [ | |
| r"^\d{3,4}[xX×]\d{3,4}", | |
| r"(?i)^(?:AAC|AC3|EAC3|DTS|FLAC|DDP)\s*\d+(?:\.\d+)?", | |
| r"(?i)^h\.?26[45]", | |
| r"(?i)^x\.?26[45]", | |
| r"^[\\/]+", | |
| r"^[-_.::+&|]+", | |
| r"^\s+", | |
| r"(?i)^Season\s*\d{1,2}", | |
| r"^[A-Za-z]+(?:\d+[A-Za-z]*)*", | |
| r"^\d+[A-Za-z]+\d*", | |
| r"^\d{1,4}(?:[._-]\d{1,4})*", | |
| r"^[\p{Hiragana}\p{Katakana}\p{Han}]+", | |
| ] | |
| .into_iter() | |
| .map(|pattern| Regex::new(pattern).unwrap()) | |
| .collect() | |
| }); | |
| static SIMPLE_EPISODE_RE: Lazy<Regex> = | |
| Lazy::new(|| Regex::new(r"(?i)^(?:EP?|#)?\d{1,4}$").unwrap()); | |
| static SPECIAL_SPACE_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"[\s_.-]+").unwrap()); | |
| static MUSIC_COLLECTION_RE: Lazy<Regex> = Lazy::new(|| { | |
| Regex::new( | |
| r"(?i)(?:^|[^A-Z0-9])(?:MUSIC\s*CLIP|MUSIC\s+COLLECTION|SOUNDTRACK|OST|CHARACTER\s+SONG|DRAMA\s+CD|CD\s+ALBUM|BONUS\s+CD)(?:$|[^A-Z0-9])", | |
| ) | |
| .unwrap() | |
| }); | |
| fn main() -> Result<()> { | |
| let args = Args::parse(); | |
| if let Some(threads) = args.threads { | |
| rayon::ThreadPoolBuilder::new() | |
| .num_threads(threads) | |
| .build_global() | |
| .context("failed to configure rayon thread pool")?; | |
| } | |
| let _ = RUNTIME_WHITELISTS.set(load_whitelists(&args)?); | |
| if args.cluster { | |
| return run_cluster(&args); | |
| } | |
| if args.audit_low_frequency { | |
| return run_low_frequency_audit(&args); | |
| } | |
| if args.verify_generated_output { | |
| return run_verify_generated_output(&args); | |
| } | |
| if args.rich_annotations { | |
| return run_rich_annotations(&args); | |
| } | |
| if args.expand != "all" && args.expand != "sample" { | |
| bail!("--expand must be all or sample"); | |
| } | |
| let recipes = load_recipes(&args)?; | |
| if recipes.is_empty() { | |
| bail!("no recipes selected; adjust --recipes/--confidence/--min-count/--limit-templates"); | |
| } | |
| let inputs = load_input(&args.input, args.limit)?; | |
| let sample_counters: HashMap<String, AtomicUsize> = recipes | |
| .values() | |
| .map(|recipe| (recipe.template_id.clone(), AtomicUsize::new(0))) | |
| .collect(); | |
| let processed: Vec<Processed> = inputs | |
| .par_iter() | |
| .map(|filename| process_filename(filename, &args, &recipes, &sample_counters)) | |
| .collect(); | |
| if let Some(parent) = args.output.parent() { | |
| fs::create_dir_all(parent)?; | |
| } | |
| if let Some(parent) = args.manifest_output.parent() { | |
| fs::create_dir_all(parent)?; | |
| } | |
| let mut stats = Stats { | |
| seen: inputs.len(), | |
| ..Stats::default() | |
| }; | |
| let mut label_counts: HashMap<String, usize> = HashMap::new(); | |
| let mut template_counts: HashMap<String, usize> = HashMap::new(); | |
| let mut examples = Vec::new(); | |
| let mut skipped_music_audio_collection_examples = Vec::new(); | |
| let mut skipped_low_frequency_audit_warning_counts: HashMap<String, usize> = HashMap::new(); | |
| let mut skipped_low_frequency_audit_warning_examples: HashMap<String, Vec<String>> = | |
| HashMap::new(); | |
| let mut writer = BufWriter::new(File::create(&args.output)?); | |
| for item in processed { | |
| match item { | |
| Processed::Written { | |
| record, | |
| trimmed_parent, | |
| } => { | |
| if trimmed_parent { | |
| stats.trimmed_parent_path += 1; | |
| } | |
| for label in &record.labels { | |
| *label_counts.entry(label.clone()).or_default() += 1; | |
| } | |
| *template_counts | |
| .entry(record.template_id.clone()) | |
| .or_default() += 1; | |
| if examples.len() < 20 { | |
| examples.push(serde_json::to_value(&record)?); | |
| } | |
| serde_json::to_writer(&mut writer, &record)?; | |
| writer.write_all(b"\n")?; | |
| stats.written += 1; | |
| } | |
| Processed::Skipped { | |
| reason, | |
| trimmed_parent, | |
| example, | |
| warnings, | |
| } => { | |
| if trimmed_parent { | |
| stats.trimmed_parent_path += 1; | |
| } | |
| match reason { | |
| "encoding_noise" => stats.skipped_encoding_noise += 1, | |
| "music_audio_collection" => { | |
| stats.skipped_music_audio_collection += 1; | |
| if let Some(example) = example { | |
| if skipped_music_audio_collection_examples.len() < 20 { | |
| skipped_music_audio_collection_examples.push(example); | |
| } | |
| } | |
| } | |
| "no_recipe" => stats.skipped_no_recipe += 1, | |
| "sample_cap" => stats.skipped_sample_cap += 1, | |
| "role_mismatch" => stats.skipped_role_mismatch += 1, | |
| "low_frequency_audit_warning" => { | |
| stats.skipped_low_frequency_audit_warning += 1; | |
| for warning in warnings { | |
| *skipped_low_frequency_audit_warning_counts | |
| .entry(warning.clone()) | |
| .or_default() += 1; | |
| if let Some(example) = example.as_ref() { | |
| let bucket = skipped_low_frequency_audit_warning_examples | |
| .entry(warning) | |
| .or_default(); | |
| if bucket.len() < 10 { | |
| bucket.push(example.clone()); | |
| } | |
| } | |
| } | |
| } | |
| _ => {} | |
| } | |
| } | |
| } | |
| } | |
| writer.flush()?; | |
| let mut top_template_counts: Vec<_> = template_counts.into_iter().collect(); | |
| top_template_counts.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); | |
| top_template_counts.truncate(20); | |
| let manifest = json!({ | |
| "generated_at": Utc::now().to_rfc3339(), | |
| "input": args.input.to_string_lossy(), | |
| "recipes": args.recipes.to_string_lossy(), | |
| "output": args.output.to_string_lossy(), | |
| "selected_templates": recipes.len(), | |
| "confidence": args.confidence, | |
| "min_count": args.min_count, | |
| "low_frequency_audit_max_count": args.audit_max_count, | |
| "low_frequency_blocking_warnings": [ | |
| "ambiguous_no_episode_title", | |
| "encoding_noise_survived", | |
| "episode_version_missing_label", | |
| "episode_in_title", | |
| "generic_title_only", | |
| "hash_labeled", | |
| "multiple_title_spans", | |
| "no_title", | |
| "path_retained", | |
| "sxe_compact_unexpanded", | |
| "tech_in_title", | |
| "template_episode_missing_label", | |
| "template_sxe_missing_label" | |
| ], | |
| "expand": args.expand, | |
| "sample_per_template": if args.expand == "sample" { Some(args.sample_per_template) } else { None }, | |
| "stats": stats, | |
| "label_counts": label_counts, | |
| "top_template_counts": top_template_counts, | |
| "examples": examples, | |
| "skipped_music_audio_collection_examples": skipped_music_audio_collection_examples, | |
| "skipped_low_frequency_audit_warning_counts": skipped_low_frequency_audit_warning_counts, | |
| "skipped_low_frequency_audit_warning_examples": skipped_low_frequency_audit_warning_examples, | |
| "implementation": "rust_dmhy_template_apply" | |
| }); | |
| fs::write( | |
| &args.manifest_output, | |
| serde_json::to_string_pretty(&manifest)?, | |
| )?; | |
| println!("{}", serde_json::to_string_pretty(&manifest)?); | |
| Ok(()) | |
| } | |
| fn load_whitelists(args: &Args) -> Result<Whitelists> { | |
| Ok(Whitelists { | |
| title_phrases: load_title_whitelist(&args.title_whitelist)?, | |
| group_names: load_name_whitelist(&args.group_whitelist)?, | |
| }) | |
| } | |
| fn load_title_whitelist(path: &PathBuf) -> Result<Vec<Vec<String>>> { | |
| let mut phrases = Vec::new(); | |
| for line in load_whitelist_lines(path)? { | |
| let phrase = phrase_parts_for_whitelist(&line); | |
| if !phrase.is_empty() { | |
| phrases.push(phrase); | |
| } | |
| } | |
| Ok(phrases) | |
| } | |
| fn load_name_whitelist(path: &PathBuf) -> Result<HashSet<String>> { | |
| Ok(load_whitelist_lines(path)? | |
| .into_iter() | |
| .map(|line| normalize_whitelist_name(&line)) | |
| .filter(|line| !line.is_empty()) | |
| .collect()) | |
| } | |
| fn load_whitelist_lines(path: &PathBuf) -> Result<Vec<String>> { | |
| if !path.exists() { | |
| return Ok(Vec::new()); | |
| } | |
| let file = | |
| File::open(path).with_context(|| format!("failed to open whitelist {}", path.display()))?; | |
| let mut lines = Vec::new(); | |
| for line in BufReader::new(file).lines() { | |
| let line = line?; | |
| let line = line.trim(); | |
| if line.is_empty() || line.starts_with('#') { | |
| continue; | |
| } | |
| let value = line | |
| .split_once('\t') | |
| .map(|(_, value)| value) | |
| .unwrap_or(line) | |
| .trim(); | |
| if !value.is_empty() { | |
| lines.push(value.to_string()); | |
| } | |
| } | |
| Ok(lines) | |
| } | |
| fn load_recipes(args: &Args) -> Result<HashMap<String, Recipe>> { | |
| let file = File::open(&args.recipes) | |
| .with_context(|| format!("recipe JSONL not found: {}", args.recipes.display()))?; | |
| let mut recipes = HashMap::new(); | |
| for (line_number, line) in BufReader::new(file).lines().enumerate() { | |
| let line = line?; | |
| if line.trim().is_empty() { | |
| continue; | |
| } | |
| let row: Recipe = serde_json::from_str(&line).with_context(|| { | |
| format!( | |
| "invalid recipe JSON at {}:{}", | |
| args.recipes.display(), | |
| line_number + 1 | |
| ) | |
| })?; | |
| if !args.confidence.is_empty() | |
| && row.confidence.as_deref() != Some(args.confidence.as_str()) | |
| { | |
| continue; | |
| } | |
| if row.count.unwrap_or(0) < args.min_count { | |
| continue; | |
| } | |
| recipes.insert(row.template.clone(), row); | |
| if args | |
| .limit_templates | |
| .is_some_and(|limit| recipes.len() >= limit) | |
| { | |
| break; | |
| } | |
| } | |
| Ok(recipes) | |
| } | |
| fn load_input(path: &PathBuf, limit: Option<usize>) -> Result<Vec<String>> { | |
| let file = | |
| File::open(path).with_context(|| format!("input JSONL not found: {}", path.display()))?; | |
| let mut values = Vec::new(); | |
| for (line_number, line) in BufReader::new(file).lines().enumerate() { | |
| if limit.is_some_and(|limit| values.len() >= limit) { | |
| break; | |
| } | |
| let line = line?; | |
| if line.trim().is_empty() { | |
| continue; | |
| } | |
| let row: Value = serde_json::from_str(&line) | |
| .with_context(|| format!("invalid JSON at {}:{}", path.display(), line_number + 1))?; | |
| if let Some(value) = row.get("value").and_then(Value::as_str) { | |
| let value = value.trim(); | |
| if !value.is_empty() { | |
| values.push(value.to_string()); | |
| } | |
| } | |
| } | |
| Ok(values) | |
| } | |
| fn run_cluster(args: &Args) -> Result<()> { | |
| let inputs = load_input(&args.input, args.limit)?; | |
| let source_rows = inputs.len(); | |
| let mut clusters: HashMap<String, Cluster> = HashMap::new(); | |
| let mut skipped_encoding_noise = 0usize; | |
| let mut trimmed_parent_path = 0usize; | |
| let mut total_rows = 0usize; | |
| for original in inputs { | |
| if !args.keep_encoding_noise | |
| && (has_encoding_noise(&original) | |
| || has_non_anime_noise(&original) | |
| || has_music_collection_noise(&original) | |
| || has_abstract_path_noise(&original)) | |
| { | |
| skipped_encoding_noise += 1; | |
| continue; | |
| } | |
| let filename = if args.preserve_parent_paths { | |
| original | |
| } else { | |
| let (training_filename, was_trimmed) = training_filename_for(&original); | |
| if was_trimmed { | |
| trimmed_parent_path += 1; | |
| } | |
| training_filename | |
| }; | |
| add_cluster(&mut clusters, &filename, args.examples); | |
| total_rows += 1; | |
| } | |
| let mut sorted_clusters: Vec<_> = clusters.into_iter().collect(); | |
| sorted_clusters.sort_by(|a, b| b.1.count.cmp(&a.1.count).then_with(|| a.0.cmp(&b.0))); | |
| let cluster_rows: Vec<Value> = sorted_clusters | |
| .iter() | |
| .enumerate() | |
| .map(|(index, (key, cluster))| cluster_row(index + 1, key, cluster, total_rows)) | |
| .collect(); | |
| let samples: Vec<Value> = cluster_rows.iter().take(args.top).cloned().collect(); | |
| let recipe_candidates: Vec<Value> = | |
| cluster_rows.iter().take(args.recipe_top).cloned().collect(); | |
| let recipes: Vec<Value> = recipe_candidates | |
| .iter() | |
| .filter(|row| is_high_confidence_recipe(row, args.recipe_min_count)) | |
| .map(|row| recipe_row(row, "high")) | |
| .collect(); | |
| let review: Vec<Value> = recipe_candidates | |
| .iter() | |
| .filter(|row| !is_high_confidence_recipe(row, args.recipe_min_count)) | |
| .take(args.review_top) | |
| .cloned() | |
| .collect(); | |
| write_jsonl_values(&args.clusters_output, &cluster_rows)?; | |
| write_jsonl_values(&args.samples_output, &samples)?; | |
| write_jsonl_values(&args.recipes_output, &recipes)?; | |
| write_jsonl_values(&args.review_output, &review)?; | |
| let mut histogram: HashMap<usize, usize> = HashMap::new(); | |
| for (_, cluster) in &sorted_clusters { | |
| *histogram.entry(cluster.count).or_default() += 1; | |
| } | |
| let mut count_histogram_top: Vec<_> = histogram.into_iter().collect(); | |
| count_histogram_top.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); | |
| count_histogram_top.truncate(20); | |
| let rows_covered_by_repeated_templates: usize = sorted_clusters | |
| .iter() | |
| .map(|(_, cluster)| cluster) | |
| .filter(|cluster| cluster.count as u64 >= args.min_count) | |
| .map(|cluster| cluster.count) | |
| .sum(); | |
| let templates_at_least_min_count = sorted_clusters | |
| .iter() | |
| .filter(|(_, cluster)| cluster.count as u64 >= args.min_count) | |
| .count(); | |
| let top_templates: Vec<Value> = cluster_rows.iter().take(20).cloned().collect(); | |
| let summary = json!({ | |
| "input": args.input.to_string_lossy(), | |
| "source_rows": source_rows, | |
| "skipped_encoding_noise": skipped_encoding_noise, | |
| "trimmed_parent_path": trimmed_parent_path, | |
| "total_rows": total_rows, | |
| "unique_templates": sorted_clusters.len(), | |
| "min_count": args.min_count, | |
| "templates_at_least_min_count": templates_at_least_min_count, | |
| "rows_covered_by_repeated_templates": rows_covered_by_repeated_templates, | |
| "rows_covered_by_repeated_templates_ratio": if total_rows == 0 { 0.0 } else { rows_covered_by_repeated_templates as f64 / total_rows as f64 }, | |
| "top_output_rows": samples.len(), | |
| "clusters_output": args.clusters_output.to_string_lossy(), | |
| "cluster_rows": cluster_rows.len(), | |
| "recipes_output": args.recipes_output.to_string_lossy(), | |
| "recipe_rows": recipes.len(), | |
| "review_output": args.review_output.to_string_lossy(), | |
| "review_rows": review.len(), | |
| "recipe_top": args.recipe_top, | |
| "recipe_min_count": args.recipe_min_count, | |
| "top_templates": top_templates, | |
| "count_histogram_top": count_histogram_top, | |
| "implementation": "rust_dmhy_template_cluster", | |
| "generated_at": Utc::now().to_rfc3339(), | |
| }); | |
| if let Some(parent) = args.summary_output.parent() { | |
| fs::create_dir_all(parent)?; | |
| } | |
| fs::write( | |
| &args.summary_output, | |
| serde_json::to_string_pretty(&summary)?, | |
| )?; | |
| println!("{}", serde_json::to_string_pretty(&summary)?); | |
| Ok(()) | |
| } | |
| fn add_cluster(clusters: &mut HashMap<String, Cluster>, filename: &str, example_limit: usize) { | |
| let (key, tokens, classes, groups) = template_key_for_filename(filename); | |
| let cluster = clusters.entry(key).or_default(); | |
| cluster.count += 1; | |
| if cluster.examples.len() < example_limit { | |
| cluster.examples.push(filename.to_string()); | |
| } | |
| for (token, class_name) in tokens.iter().zip(classes.iter()) { | |
| *cluster.class_counts.entry(class_name.clone()).or_default() += 1; | |
| if matches!(class_name.as_str(), "TEXT" | "BRACKET_TEXT") { | |
| let cleaned = strip_wrapper(token); | |
| if !cleaned.is_empty() { | |
| *cluster.literal_counts.entry(cleaned).or_default() += 1; | |
| } | |
| } | |
| } | |
| while cluster.position_literals.len() < groups.len() { | |
| cluster.position_literals.push(HashMap::new()); | |
| } | |
| for (index, group) in groups.iter().enumerate() { | |
| if matches!(group.class_name.as_str(), "TEXT" | "BRACKET_TEXT") { | |
| let text = group_text(&tokens, group); | |
| if !text.is_empty() { | |
| *cluster.position_literals[index].entry(text).or_default() += 1; | |
| } | |
| } | |
| } | |
| } | |
| fn cluster_row(rank: usize, key: &str, cluster: &Cluster, total: usize) -> Value { | |
| json!({ | |
| "template_id": format!("tpl_{rank:06}"), | |
| "template": key, | |
| "count": cluster.count, | |
| "coverage": if total == 0 { 0.0 } else { cluster.count as f64 / total as f64 }, | |
| "top_literals": top_counts(&cluster.literal_counts, 12), | |
| "suggested_roles": suggested_roles(key), | |
| "position_top_literals": cluster.position_literals.iter().map(|counts| top_counts(counts, 5)).collect::<Vec<_>>(), | |
| "class_counts": top_counts(&cluster.class_counts, 20), | |
| "examples": cluster.examples, | |
| }) | |
| } | |
| fn top_counts(counts: &HashMap<String, usize>, limit: usize) -> Vec<(String, usize)> { | |
| let mut items: Vec<_> = counts | |
| .iter() | |
| .map(|(key, count)| (key.clone(), *count)) | |
| .collect(); | |
| items.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); | |
| items.truncate(limit); | |
| items | |
| } | |
| fn is_high_confidence_recipe(row: &Value, min_count: usize) -> bool { | |
| if row.get("count").and_then(Value::as_u64).unwrap_or(0) < min_count as u64 { | |
| return false; | |
| } | |
| let roles = match row.get("suggested_roles").and_then(Value::as_array) { | |
| Some(roles) => roles, | |
| None => return false, | |
| }; | |
| let role_strings: Vec<&str> = roles.iter().filter_map(Value::as_str).collect(); | |
| if role_strings.iter().any(|role| role.contains("_OR_")) { | |
| return false; | |
| } | |
| if !role_strings.contains(&"TITLE") | |
| || !role_strings.iter().any(|role| { | |
| role.starts_with("EPISODE") || matches!(*role, "SPECIAL" | "SOURCE" | "RESOLUTION") | |
| }) | |
| { | |
| return false; | |
| } | |
| let template = row.get("template").and_then(Value::as_str).unwrap_or(""); | |
| if template.contains("BRACKET_TEXT BRACKET_TEXT") && !role_strings.contains(&"GROUP") { | |
| return false; | |
| } | |
| !role_strings.contains(&"TITLE_OR_TEXT") | |
| } | |
| fn recipe_row(row: &Value, confidence: &str) -> Value { | |
| json!({ | |
| "template_id": row["template_id"], | |
| "template": row["template"], | |
| "roles": row["suggested_roles"], | |
| "confidence": confidence, | |
| "count": row["count"], | |
| "examples": row["examples"], | |
| }) | |
| } | |
| fn write_jsonl_values(path: &PathBuf, rows: &[Value]) -> Result<()> { | |
| if let Some(parent) = path.parent() { | |
| fs::create_dir_all(parent)?; | |
| } | |
| let mut writer = BufWriter::new(File::create(path)?); | |
| for row in rows { | |
| serde_json::to_writer(&mut writer, row)?; | |
| writer.write_all(b"\n")?; | |
| } | |
| writer.flush()?; | |
| Ok(()) | |
| } | |
| fn run_low_frequency_audit(args: &Args) -> Result<()> { | |
| let recipes = load_recipes(args)?; | |
| let inputs = load_input(&args.input, args.limit)?; | |
| let low_template_total = recipes | |
| .values() | |
| .filter(|recipe| recipe.count.unwrap_or(0) <= args.audit_max_count) | |
| .count(); | |
| let mut seen_templates = HashSet::new(); | |
| let mut rows = Vec::new(); | |
| for original in inputs { | |
| if !args.keep_encoding_noise | |
| && (has_encoding_noise(&original) | |
| || has_non_anime_noise(&original) | |
| || has_music_collection_noise(&original) | |
| || has_abstract_path_noise(&original)) | |
| { | |
| continue; | |
| } | |
| let (training_filename, trimmed_parent) = training_filename_for(&original); | |
| let (key, _tokens, _classes, groups) = template_key_for_filename(&training_filename); | |
| let Some(recipe) = recipes.get(&key) else { | |
| continue; | |
| }; | |
| let count = recipe.count.unwrap_or(0); | |
| if count > args.audit_max_count || !seen_templates.insert(recipe.template_id.clone()) { | |
| continue; | |
| } | |
| if recipe.roles.len() != groups.len() { | |
| continue; | |
| } | |
| let Some(mut record) = dmhy_record(&training_filename, &recipe.template_id, &recipe.roles) | |
| else { | |
| continue; | |
| }; | |
| if trimmed_parent { | |
| record.source_filename = Some(original.clone()); | |
| record.path_trimmed = Some(true); | |
| } | |
| rows.push(json!({ | |
| "template_id": recipe.template_id, | |
| "count": count, | |
| "template": recipe.template, | |
| "filename": record.filename, | |
| "source_filename": record.source_filename, | |
| "path_trimmed": record.path_trimmed.unwrap_or(false), | |
| "spans": entity_spans(&record.tokens, &record.labels), | |
| "warnings": audit_warnings(&record), | |
| "tokens": record.tokens, | |
| "labels": record.labels, | |
| })); | |
| if seen_templates.len() >= low_template_total { | |
| break; | |
| } | |
| } | |
| rows.sort_by(|a, b| { | |
| let count_a = a.get("count").and_then(Value::as_u64).unwrap_or(0); | |
| let count_b = b.get("count").and_then(Value::as_u64).unwrap_or(0); | |
| let id_a = a.get("template_id").and_then(Value::as_str).unwrap_or(""); | |
| let id_b = b.get("template_id").and_then(Value::as_str).unwrap_or(""); | |
| count_a.cmp(&count_b).then_with(|| id_a.cmp(id_b)) | |
| }); | |
| write_jsonl_values(&args.audit_output, &rows)?; | |
| let warning_counts = warning_counts(&rows); | |
| let manifest = json!({ | |
| "generated_at": Utc::now().to_rfc3339(), | |
| "input": args.input.to_string_lossy(), | |
| "recipes": args.recipes.to_string_lossy(), | |
| "audit_output": args.audit_output.to_string_lossy(), | |
| "audit_max_count": args.audit_max_count, | |
| "low_template_total": low_template_total, | |
| "audited_templates": rows.len(), | |
| "warning_counts": warning_counts, | |
| "implementation": "rust_dmhy_low_frequency_audit" | |
| }); | |
| println!("{}", serde_json::to_string_pretty(&manifest)?); | |
| Ok(()) | |
| } | |
| fn run_verify_generated_output(args: &Args) -> Result<()> { | |
| let file = File::open(&args.input) | |
| .with_context(|| format!("generated JSONL not found: {}", args.input.display()))?; | |
| let recipes_by_id: HashMap<String, u64> = load_recipes(args)? | |
| .into_values() | |
| .map(|recipe| (recipe.template_id, recipe.count.unwrap_or(0))) | |
| .collect(); | |
| let mut rows = 0usize; | |
| let mut low_frequency_rows = 0usize; | |
| let mut warning_counts: HashMap<String, usize> = HashMap::new(); | |
| let mut examples: HashMap<String, Vec<Value>> = HashMap::new(); | |
| for (line_number, line) in BufReader::new(file).lines().enumerate() { | |
| let line = line?; | |
| if line.trim().is_empty() { | |
| continue; | |
| } | |
| let record: Record = serde_json::from_str(&line).with_context(|| { | |
| format!( | |
| "invalid generated record at {}:{}", | |
| args.input.display(), | |
| line_number + 1 | |
| ) | |
| })?; | |
| rows += 1; | |
| let count = recipes_by_id | |
| .get(&record.template_id) | |
| .copied() | |
| .unwrap_or(u64::MAX); | |
| if count > args.audit_max_count { | |
| continue; | |
| } | |
| low_frequency_rows += 1; | |
| for warning in audit_warnings(&record) { | |
| if !matches!( | |
| warning.as_str(), | |
| "ambiguous_no_episode_title" | |
| | "encoding_noise_survived" | |
| | "episode_version_missing_label" | |
| | "episode_in_title" | |
| | "generic_title_only" | |
| | "hash_labeled" | |
| | "multiple_title_spans" | |
| | "no_title" | |
| | "path_retained" | |
| | "sxe_compact_unexpanded" | |
| | "tech_in_title" | |
| | "template_episode_missing_label" | |
| | "template_sxe_missing_label" | |
| ) { | |
| continue; | |
| } | |
| *warning_counts.entry(warning.clone()).or_default() += 1; | |
| let bucket = examples.entry(warning).or_default(); | |
| if bucket.len() < 5 { | |
| bucket.push(json!({ | |
| "template_id": record.template_id, | |
| "template_count": count, | |
| "filename": record.filename, | |
| "spans": entity_spans(&record.tokens, &record.labels), | |
| })); | |
| } | |
| } | |
| } | |
| let manifest = json!({ | |
| "generated_at": Utc::now().to_rfc3339(), | |
| "input": args.input.to_string_lossy(), | |
| "recipes": args.recipes.to_string_lossy(), | |
| "audit_max_count": args.audit_max_count, | |
| "rows": rows, | |
| "low_frequency_rows": low_frequency_rows, | |
| "blocking_warning_counts": warning_counts, | |
| "examples": examples, | |
| "implementation": "rust_dmhy_generated_output_verify" | |
| }); | |
| println!("{}", serde_json::to_string_pretty(&manifest)?); | |
| if !warning_counts.is_empty() { | |
| bail!("generated output still has low-frequency blocking warnings"); | |
| } | |
| Ok(()) | |
| } | |
| fn run_rich_annotations(args: &Args) -> Result<()> { | |
| let inputs = load_input(&args.input, args.limit)?; | |
| if let Some(parent) = args.rich_output.parent() { | |
| fs::create_dir_all(parent)?; | |
| } | |
| let rows: Vec<Value> = inputs | |
| .par_iter() | |
| .filter_map(|original| { | |
| if !args.keep_encoding_noise | |
| && (has_encoding_noise(original) | |
| || has_non_anime_noise(original) | |
| || has_music_collection_noise(original) | |
| || has_abstract_path_noise(original)) | |
| { | |
| return None; | |
| } | |
| Some(rich_annotation_for(original)) | |
| }) | |
| .collect(); | |
| let mut writer = BufWriter::new(File::create(&args.rich_output)?); | |
| for row in &rows { | |
| serde_json::to_writer(&mut writer, row)?; | |
| writer.write_all(b"\n")?; | |
| } | |
| writer.flush()?; | |
| let manifest = json!({ | |
| "generated_at": Utc::now().to_rfc3339(), | |
| "input": args.input.to_string_lossy(), | |
| "rich_output": args.rich_output.to_string_lossy(), | |
| "rows": rows.len(), | |
| "implementation": "rust_dmhy_rich_annotations", | |
| "notes": [ | |
| "rich roles are metadata for review/projection, not final training BIO labels", | |
| "TITLE_* candidates may be collapsed or filtered before dmhy_weak generation" | |
| ] | |
| }); | |
| println!("{}", serde_json::to_string_pretty(&manifest)?); | |
| Ok(()) | |
| } | |
| fn rich_annotation_for(original: &str) -> Value { | |
| let (training_filename, path_trimmed) = training_filename_for(original); | |
| let parts: Vec<&str> = original | |
| .split(|ch| ch == '/' || ch == '\\') | |
| .map(str::trim) | |
| .filter(|part| !part.is_empty()) | |
| .collect(); | |
| let leaf_index = parts.len().saturating_sub(1); | |
| let segments = parts | |
| .iter() | |
| .enumerate() | |
| .map(|(index, segment)| rich_segment(segment, index, index == leaf_index)) | |
| .collect::<Vec<_>>(); | |
| let projection = dmhy_record( | |
| &training_filename, | |
| "rich_projection", | |
| &suggested_roles(&template_key_for_filename(&training_filename).0), | |
| ) | |
| .map(|record| { | |
| json!({ | |
| "filename": record.filename, | |
| "spans": entity_spans(&record.tokens, &record.labels), | |
| "warnings": audit_warnings(&record), | |
| }) | |
| }); | |
| json!({ | |
| "source_filename": original, | |
| "training_filename": training_filename, | |
| "path_trimmed": path_trimmed, | |
| "segments": segments, | |
| "projection_preview": projection, | |
| }) | |
| } | |
| fn rich_segment(segment: &str, index: usize, is_leaf: bool) -> Value { | |
| let (key, tokens, _classes, groups) = template_key_for_filename(segment); | |
| let suggested = suggested_roles(&key); | |
| let roles = adjust_contextual_roles(&tokens, &groups, &suggested); | |
| let roles = refine_semantic_roles(&tokens, &groups, &roles); | |
| let candidates = rich_candidates_for_segment(segment, &tokens, &groups, &roles, is_leaf); | |
| json!({ | |
| "index": index, | |
| "text": segment, | |
| "kind": rich_segment_kind(segment, is_leaf), | |
| "template": key, | |
| "candidates": candidates, | |
| }) | |
| } | |
| fn rich_segment_kind(segment: &str, is_leaf: bool) -> &'static str { | |
| if path_segment_is_media_noise(segment) { | |
| "media_noise" | |
| } else if path_segment_is_plain_season(segment) { | |
| "season_dir" | |
| } else if is_leaf { | |
| "leaf" | |
| } else { | |
| "parent" | |
| } | |
| } | |
| fn rich_candidates_for_segment( | |
| segment: &str, | |
| tokens: &[String], | |
| groups: &[Group], | |
| roles: &[String], | |
| is_leaf: bool, | |
| ) -> Vec<Value> { | |
| let mut output = Vec::new(); | |
| let title_ranges = title_candidates(groups, roles); | |
| for (candidate_index, (start, end)) in title_ranges.iter().copied().enumerate() { | |
| let text = candidate_text(tokens, groups, start, end); | |
| if text.trim().is_empty() { | |
| continue; | |
| } | |
| output.push(json!({ | |
| "role": fine_title_role_for_candidate(&roles, start, end) | |
| .unwrap_or_else(|| fine_title_role(segment, &text, is_leaf, candidate_index, title_ranges.len()).to_string()), | |
| "coarse_role": "TITLE", | |
| "text": text, | |
| "group_start": start, | |
| "group_end": end, | |
| })); | |
| } | |
| for (group_index, role) in roles.iter().enumerate() { | |
| if is_title_role(role) || role == "O" || role == "HASH" { | |
| continue; | |
| } | |
| let text = group_text(tokens, &groups[group_index]); | |
| if text.trim().is_empty() { | |
| continue; | |
| } | |
| let coarse_role = role_label(role) | |
| .strip_prefix("B-") | |
| .map(str::to_string) | |
| .unwrap_or_else(|| "O".to_string()); | |
| output.push(json!({ | |
| "role": fine_non_title_role(role), | |
| "coarse_role": coarse_role, | |
| "text": text, | |
| "group_start": group_index, | |
| "group_end": group_index + 1, | |
| })); | |
| } | |
| output | |
| } | |
| fn fine_title_role_for_candidate(roles: &[String], start: usize, end: usize) -> Option<String> { | |
| let mut entities: Vec<&str> = roles[start..end] | |
| .iter() | |
| .filter_map(|role| title_entity_from_role(role)) | |
| .filter(|entity| *entity != "TITLE") | |
| .collect(); | |
| entities.sort(); | |
| entities.dedup(); | |
| match entities.len() { | |
| 0 => None, | |
| 1 => Some(entities[0].to_string()), | |
| _ => Some("TITLE_MIXED".to_string()), | |
| } | |
| } | |
| fn candidate_text(tokens: &[String], groups: &[Group], start: usize, end: usize) -> String { | |
| let Some(first) = groups.get(start).and_then(|group| group.indices.first()) else { | |
| return String::new(); | |
| }; | |
| let Some(last) = groups | |
| .get(end.saturating_sub(1)) | |
| .and_then(|group| group.indices.last()) | |
| else { | |
| return String::new(); | |
| }; | |
| strip_wrapper(&tokens[*first..=*last].join("")) | |
| } | |
| fn fine_title_role( | |
| segment: &str, | |
| text: &str, | |
| is_leaf: bool, | |
| candidate_index: usize, | |
| candidate_count: usize, | |
| ) -> &'static str { | |
| let cleaned = text.trim(); | |
| if VERSIONISH_TITLE_RE.is_match(cleaned) { | |
| return "RELEASE_VERSION"; | |
| } | |
| if matches!( | |
| cleaned.to_ascii_lowercase().as_str(), | |
| "国漫" | "國漫" | "anime" | "movie" | "movies" | |
| ) { | |
| return "TITLE_CATEGORY"; | |
| } | |
| if is_leaf && path_segment_starts_with_episode(segment) { | |
| return "EPISODE_TITLE"; | |
| } | |
| if !is_leaf { | |
| return "PATH_TITLE"; | |
| } | |
| if candidate_count > 1 && candidate_index > 0 { | |
| return "TITLE_ALIAS"; | |
| } | |
| "TITLE_MAIN" | |
| } | |
| fn fine_non_title_role(role: &str) -> &'static str { | |
| match role { | |
| "GROUP" => "RELEASE_GROUP", | |
| "EPISODE" | "EPISODE_VERSION" | "EPISODE_RANGE" => "EPISODE", | |
| "SEASON" => "SEASON", | |
| "PATH_SEASON" => "PATH_SEASON", | |
| "TAG" => "TAG", | |
| "SPECIAL" | "VOLUME" => "SPECIAL", | |
| "RESOLUTION" => "RESOLUTION", | |
| "SOURCE" => "SOURCE", | |
| _ => "OTHER", | |
| } | |
| } | |
| fn entity_spans(tokens: &[String], labels: &[String]) -> Vec<Value> { | |
| let mut spans = Vec::new(); | |
| let mut current_label: Option<String> = None; | |
| let mut current_text = String::new(); | |
| for (token, label) in tokens.iter().zip(labels.iter()) { | |
| let entity = label | |
| .strip_prefix("B-") | |
| .or_else(|| label.strip_prefix("I-")) | |
| .unwrap_or("O"); | |
| if current_label.as_deref() == Some(entity) { | |
| current_text.push_str(token); | |
| continue; | |
| } | |
| if let Some(label) = current_label.take() { | |
| if label != "O" { | |
| spans.push(json!({ "label": label, "text": current_text })); | |
| } | |
| } | |
| current_label = Some(entity.to_string()); | |
| current_text = token.clone(); | |
| } | |
| if let Some(label) = current_label { | |
| if label != "O" { | |
| spans.push(json!({ "label": label, "text": current_text })); | |
| } | |
| } | |
| spans | |
| } | |
| fn audit_warnings(record: &Record) -> Vec<String> { | |
| let mut warnings = Vec::new(); | |
| let title_texts = title_entity_texts(&record.tokens, &record.labels); | |
| let title_spans = title_texts.len(); | |
| if title_spans == 0 { | |
| warnings.push("no_title".to_string()); | |
| } else if repeated_title_entity_spans(&record.labels) { | |
| warnings.push("multiple_title_spans".to_string()); | |
| } | |
| if !title_texts.is_empty() && title_texts.iter().all(|title| generic_title_text(title)) { | |
| warnings.push("generic_title_only".to_string()); | |
| } | |
| if title_texts.iter().any(|title| technical_title_text(title)) { | |
| warnings.push("tech_in_title".to_string()); | |
| } | |
| if title_texts.iter().any(|title| episodeish_title_text(title)) { | |
| warnings.push("episode_in_title".to_string()); | |
| } | |
| let has_episode = record.labels.iter().any(|label| label.ends_with("EPISODE")); | |
| let has_season = record.labels.iter().any(|label| label.ends_with("SEASON")); | |
| let has_special = record.labels.iter().any(|label| label.ends_with("SPECIAL")); | |
| if !has_episode { | |
| warnings.push("no_episode".to_string()); | |
| if record.template.contains("EPISODE") && !has_special { | |
| warnings.push("template_episode_missing_label".to_string()); | |
| } | |
| if record | |
| .dropped_title_candidate_positions | |
| .as_ref() | |
| .is_some_and(|dropped| !dropped.is_empty()) | |
| { | |
| warnings.push("ambiguous_no_episode_title".to_string()); | |
| } | |
| } | |
| if record.template.contains("SXE") && (!has_season || !has_episode) { | |
| warnings.push("template_sxe_missing_label".to_string()); | |
| } | |
| if record.filename.contains('/') || record.filename.contains('\\') { | |
| warnings.push("path_retained".to_string()); | |
| } | |
| if has_encoding_noise(&record.filename) | |
| || record | |
| .source_filename | |
| .as_ref() | |
| .is_some_and(|source| has_encoding_noise(source)) | |
| { | |
| warnings.push("encoding_noise_survived".to_string()); | |
| } | |
| for (index, token) in record.tokens.iter().enumerate() { | |
| let entity = record | |
| .labels | |
| .get(index) | |
| .and_then(|label| label_entity(label)); | |
| let cleaned = strip_wrapper(token); | |
| if HASH_RE.is_match(token) && record.labels.get(index).is_some_and(|label| label != "O") { | |
| warnings.push("hash_labeled".to_string()); | |
| break; | |
| } | |
| if EPISODE_VERSION_RE.is_match(&compact_for_classify(&cleaned)) && entity != Some("EPISODE") | |
| { | |
| warnings.push("episode_version_missing_label".to_string()); | |
| } | |
| if SXE_VALUE_RE.is_match(&cleaned) && entity != Some("EPISODE") && entity != Some("SEASON") | |
| { | |
| warnings.push("sxe_compact_unexpanded".to_string()); | |
| } | |
| } | |
| warnings.sort(); | |
| warnings.dedup(); | |
| warnings | |
| } | |
| fn label_entity(label: &str) -> Option<&str> { | |
| label | |
| .strip_prefix("B-") | |
| .or_else(|| label.strip_prefix("I-")) | |
| } | |
| fn title_entity_texts(tokens: &[String], labels: &[String]) -> Vec<String> { | |
| let mut spans = Vec::new(); | |
| let mut current = String::new(); | |
| let mut current_entity: Option<String> = None; | |
| for (token, label) in tokens.iter().zip(labels.iter()) { | |
| let entity = label_entity(label).filter(|entity| is_title_entity(entity)); | |
| if entity.is_some() && current_entity.as_deref() == entity { | |
| current.push_str(token); | |
| } else { | |
| if !current.trim().is_empty() { | |
| spans.push(current.trim().to_string()); | |
| } | |
| current.clear(); | |
| current_entity = entity.map(str::to_string); | |
| if entity.is_some() { | |
| current.push_str(token); | |
| } | |
| } | |
| } | |
| if !current.trim().is_empty() { | |
| spans.push(current.trim().to_string()); | |
| } | |
| spans | |
| } | |
| fn repeated_title_entity_spans(labels: &[String]) -> bool { | |
| let mut seen = HashSet::new(); | |
| let mut previous: Option<String> = None; | |
| for label in labels { | |
| let entity = label_entity(label) | |
| .filter(|entity| is_title_entity(entity)) | |
| .map(str::to_string); | |
| if entity.is_some() && entity != previous { | |
| let entity = entity.clone().unwrap(); | |
| if !seen.insert(entity) { | |
| return true; | |
| } | |
| } | |
| previous = entity; | |
| } | |
| false | |
| } | |
| fn generic_title_text(text: &str) -> bool { | |
| matches!( | |
| text.trim().to_ascii_lowercase().as_str(), | |
| "tv" | "movie" | |
| | "mov" | |
| | "sample" | |
| | "commercial" | |
| | "commercials" | |
| | "cm" | |
| | "pv" | |
| | "op" | |
| | "ed" | |
| | "ncop" | |
| | "nced" | |
| | "menu" | |
| | "trailer" | |
| | "spot" | |
| | "bdmv" | |
| | "stream" | |
| ) | |
| } | |
| fn technical_title_text(text: &str) -> bool { | |
| let normalized = text.to_ascii_lowercase(); | |
| normalized.contains("bdrip") | |
| || normalized.contains("webrip") | |
| || normalized.contains("web-dl") | |
| || normalized.contains("hevc") | |
| || normalized.contains("x264") | |
| || normalized.contains("x265") | |
| || normalized.contains("aac") | |
| || normalized.contains("flac") | |
| || normalized.contains("sourceunknown") | |
| } | |
| fn episodeish_title_text(text: &str) -> bool { | |
| let trimmed = text.trim(); | |
| EPISODE_VALUE_RE.is_match(trimmed) | |
| || EPISODE_CJK_RE.is_match(trimmed) | |
| || EPISODE_RANGE_RE.is_match(trimmed) | |
| || trimmed.chars().all(|ch| ch.is_ascii_digit()) | |
| } | |
| fn warning_counts(rows: &[Value]) -> HashMap<String, usize> { | |
| let mut counts = HashMap::new(); | |
| for row in rows { | |
| if let Some(warnings) = row.get("warnings").and_then(Value::as_array) { | |
| for warning in warnings { | |
| if let Some(warning) = warning.as_str() { | |
| *counts.entry(warning.to_string()).or_default() += 1; | |
| } | |
| } | |
| } | |
| } | |
| counts | |
| } | |
| fn process_filename( | |
| original: &str, | |
| args: &Args, | |
| recipes: &HashMap<String, Recipe>, | |
| sample_counters: &HashMap<String, AtomicUsize>, | |
| ) -> Processed { | |
| if !args.keep_encoding_noise && has_music_collection_noise(original) { | |
| return Processed::Skipped { | |
| reason: "music_audio_collection", | |
| trimmed_parent: false, | |
| example: Some(original.to_string()), | |
| warnings: Vec::new(), | |
| }; | |
| } | |
| if !args.keep_encoding_noise | |
| && (has_encoding_noise(original) | |
| || has_non_anime_noise(original) | |
| || has_abstract_path_noise(original)) | |
| { | |
| return Processed::Skipped { | |
| reason: "encoding_noise", | |
| trimmed_parent: false, | |
| example: None, | |
| warnings: Vec::new(), | |
| }; | |
| } | |
| let (training_filename, trimmed_parent) = training_filename_for(original); | |
| let (key, _tokens, _classes, groups) = template_key_for_filename(&training_filename); | |
| let recipe = match recipes.get(&key) { | |
| Some(recipe) => recipe, | |
| None => { | |
| return Processed::Skipped { | |
| reason: "no_recipe", | |
| trimmed_parent, | |
| example: None, | |
| warnings: Vec::new(), | |
| } | |
| } | |
| }; | |
| if args.expand == "sample" { | |
| let counter = sample_counters.get(&recipe.template_id).unwrap(); | |
| if counter.fetch_add(1, Ordering::Relaxed) >= args.sample_per_template { | |
| return Processed::Skipped { | |
| reason: "sample_cap", | |
| trimmed_parent, | |
| example: None, | |
| warnings: Vec::new(), | |
| }; | |
| } | |
| } | |
| if recipe.roles.len() != groups.len() { | |
| return Processed::Skipped { | |
| reason: "role_mismatch", | |
| trimmed_parent, | |
| example: None, | |
| warnings: Vec::new(), | |
| }; | |
| } | |
| let mut record = match dmhy_record(&training_filename, &recipe.template_id, &recipe.roles) { | |
| Some(record) => record, | |
| None => { | |
| return Processed::Skipped { | |
| reason: "role_mismatch", | |
| trimmed_parent, | |
| example: None, | |
| warnings: Vec::new(), | |
| } | |
| } | |
| }; | |
| let warnings = audit_warnings(&record); | |
| if warnings.iter().any(|warning| warning == "no_title") || has_blocking_warnings(&warnings) { | |
| return Processed::Skipped { | |
| reason: "low_frequency_audit_warning", | |
| trimmed_parent, | |
| example: Some(record.filename.clone()), | |
| warnings, | |
| }; | |
| } | |
| if trimmed_parent { | |
| record.source_filename = Some(original.to_string()); | |
| record.path_trimmed = Some(true); | |
| return Processed::Written { | |
| record, | |
| trimmed_parent: true, | |
| }; | |
| } | |
| Processed::Written { | |
| record, | |
| trimmed_parent: false, | |
| } | |
| } | |
| fn has_blocking_warnings(warnings: &[String]) -> bool { | |
| warnings.iter().any(|warning| { | |
| matches!( | |
| warning.as_str(), | |
| "ambiguous_no_episode_title" | |
| | "encoding_noise_survived" | |
| | "episode_version_missing_label" | |
| | "episode_in_title" | |
| | "generic_title_only" | |
| | "hash_labeled" | |
| | "multiple_title_spans" | |
| | "no_title" | |
| | "path_retained" | |
| | "sxe_compact_unexpanded" | |
| | "tech_in_title" | |
| | "template_episode_missing_label" | |
| | "template_sxe_missing_label" | |
| ) | |
| }) | |
| } | |
| fn tokenize(value: &str) -> Vec<String> { | |
| let mut output = Vec::new(); | |
| let mut index = 0; | |
| while index < value.len() { | |
| let rest = &value[index..]; | |
| if let Some((token, len)) = next_token(rest) { | |
| output.push(token); | |
| index += len; | |
| } else { | |
| let ch = rest.chars().next().unwrap(); | |
| output.push(ch.to_string()); | |
| index += ch.len_utf8(); | |
| } | |
| } | |
| output | |
| } | |
| fn next_token(rest: &str) -> Option<(String, usize)> { | |
| let first = rest.chars().next()?; | |
| if first == '[' { | |
| if let Some(end) = rest.find(']') { | |
| if end <= 121 { | |
| return Some((rest[..=end].to_string(), end + 1)); | |
| } | |
| } | |
| } | |
| if first == '(' { | |
| if let Some(end) = rest.find(')') { | |
| if end <= 121 { | |
| return Some((rest[..=end].to_string(), end + 1)); | |
| } | |
| } | |
| } | |
| if first == '【' { | |
| if let Some(end) = rest.find('】') { | |
| if rest[..end].chars().count() <= 120 { | |
| return Some(( | |
| rest[..end + '】'.len_utf8()].to_string(), | |
| end + '】'.len_utf8(), | |
| )); | |
| } | |
| } | |
| } | |
| for re in TOKEN_REGEXES.iter() { | |
| if let Some(mat) = re.find(rest) { | |
| if mat.start() == 0 && mat.end() > 0 { | |
| return Some((mat.as_str().to_string(), mat.end())); | |
| } | |
| } | |
| } | |
| None | |
| } | |
| fn strip_wrapper(token: &str) -> String { | |
| let chars: Vec<char> = token.chars().collect(); | |
| if chars.len() >= 2 { | |
| let first = chars[0]; | |
| let last = chars[chars.len() - 1]; | |
| if (first == '[' && last == ']') | |
| || (first == '(' && last == ')') | |
| || (first == '【' && last == '】') | |
| { | |
| return chars[1..chars.len() - 1] | |
| .iter() | |
| .collect::<String>() | |
| .trim() | |
| .to_string(); | |
| } | |
| } | |
| token.trim().to_string() | |
| } | |
| fn split_inner(inner: &str) -> Vec<String> { | |
| let mut parts = Vec::new(); | |
| let mut current = String::new(); | |
| for ch in inner.chars() { | |
| if ch.is_whitespace() || "_.,+/&|-()()".contains(ch) { | |
| if !current.is_empty() { | |
| parts.push(std::mem::take(&mut current)); | |
| } | |
| } else { | |
| current.push(ch); | |
| } | |
| } | |
| if !current.is_empty() { | |
| parts.push(current); | |
| } | |
| parts | |
| } | |
| fn compact_for_classify(text: &str) -> String { | |
| text.chars() | |
| .filter(|ch| !ch.is_whitespace() && !matches!(ch, '_' | '.' | ',' | '-')) | |
| .collect() | |
| } | |
| fn classify_atom(text: &str) -> String { | |
| let cleaned = strip_wrapper(text); | |
| let compact = compact_for_classify(&cleaned); | |
| if cleaned.is_empty() { | |
| return "EMPTY".to_string(); | |
| } | |
| if HASH_RE.is_match(&cleaned) { | |
| return "HASH".to_string(); | |
| } | |
| if RESOLUTION_RE.is_match(&cleaned) { | |
| return "RESOLUTION".to_string(); | |
| } | |
| if DATE_RE.is_match(&cleaned) | |
| || DATE_RANGE_MIXED_RE.is_match(&cleaned) | |
| || CJK_DATE_RE.is_match(&cleaned) | |
| { | |
| return "DATE".to_string(); | |
| } | |
| if EPISODE_VERSION_RE.is_match(&compact) { | |
| return "EPISODE_VERSION".to_string(); | |
| } | |
| if EPISODE_WITH_SUFFIX_RE.is_match(&cleaned) { | |
| return "EPISODE_VERSION".to_string(); | |
| } | |
| if SXE_RE.is_match(&compact) { | |
| return "SXE".to_string(); | |
| } | |
| if EPISODE_RE.is_match(&compact) { | |
| return "EPISODE".to_string(); | |
| } | |
| if EPISODE_CJK_RE.is_match(&cleaned) { | |
| return "EPISODE".to_string(); | |
| } | |
| if EPISODE_BATCH_RE.is_match(&cleaned) { | |
| return "EPISODE_RANGE".to_string(); | |
| } | |
| if EPISODE_RANGE_RE.is_match(&cleaned) { | |
| return "EPISODE_RANGE".to_string(); | |
| } | |
| if EPISODE_RE.is_match(&cleaned) { | |
| return "EPISODE".to_string(); | |
| } | |
| if SEASON_RE.is_match(&cleaned) { | |
| return "SEASON".to_string(); | |
| } | |
| if SPECIAL_RE.is_match(&cleaned) { | |
| return "SPECIAL".to_string(); | |
| } | |
| if VOLUME_RE.is_match(&cleaned) { | |
| return "VOLUME".to_string(); | |
| } | |
| if LANG_RE.is_match(&cleaned) || lang_block_matches(&cleaned) { | |
| return "LANG".to_string(); | |
| } | |
| if MEDIA_RE.is_match(&cleaned) { | |
| return "MEDIA".to_string(); | |
| } | |
| "TEXT".to_string() | |
| } | |
| fn lang_block_matches(text: &str) -> bool { | |
| let upper = text.to_ascii_uppercase(); | |
| if ["CHS", "CHT", "ZHS", "ZHT", "BIG5"] | |
| .iter() | |
| .any(|marker| upper.contains(marker)) | |
| { | |
| return true; | |
| } | |
| if upper.contains("GB") { | |
| return true; | |
| } | |
| if [ | |
| "简繁", "简日", "繁日", "简体", "繁体", "雙語", "双语", "内封", "外挂", | |
| ] | |
| .iter() | |
| .any(|marker| text.contains(marker)) | |
| { | |
| return true; | |
| } | |
| let chars: Vec<char> = text.chars().collect(); | |
| chars.windows(2).enumerate().any(|(index, pair)| { | |
| pair[0] == '字' && pair[1] == '幕' && !matches!(chars.get(index + 2), Some('组' | '組')) | |
| }) | |
| } | |
| fn classify_token(token: &str) -> String { | |
| if token.is_empty() { | |
| return "EMPTY".to_string(); | |
| } | |
| if token.chars().all(char::is_whitespace) { | |
| return "SPACE".to_string(); | |
| } | |
| if token.chars().all(|ch| ch == '/' || ch == '\\') { | |
| return "PATH".to_string(); | |
| } | |
| if token.chars().all(|ch| "-_.::+&|".contains(ch)) { | |
| return "SEP".to_string(); | |
| } | |
| if token.starts_with('[') || token.starts_with('(') || token.starts_with('【') { | |
| let inner = strip_wrapper(token); | |
| let parts = split_inner(&inner); | |
| let whole_class = classify_atom(&inner); | |
| let inner_class = if whole_class != "TEXT" { | |
| if whole_class == "LANG" && parts.len() > 1 { | |
| let part_classes: Vec<String> = | |
| parts.iter().map(|part| classify_atom(part)).collect(); | |
| if part_classes.iter().all(|item| item == &part_classes[0]) { | |
| part_classes[0].clone() | |
| } else if part_classes.iter().all(|item| is_media_block_class(item)) { | |
| "MEDIA_BLOCK".to_string() | |
| } else { | |
| whole_class | |
| } | |
| } else { | |
| whole_class | |
| } | |
| } else if parts.is_empty() { | |
| "EMPTY".to_string() | |
| } else { | |
| let part_classes: Vec<String> = parts.iter().map(|part| classify_atom(part)).collect(); | |
| if part_classes.iter().all(|item| item == &part_classes[0]) { | |
| part_classes[0].clone() | |
| } else if part_classes.iter().all(|item| is_media_block_class(item)) { | |
| "MEDIA_BLOCK".to_string() | |
| } else if part_classes.iter().any(|item| is_media_block_class(item)) | |
| && parts.iter().zip(part_classes.iter()).all(|(part, item)| { | |
| is_media_block_class(item) | |
| || matches!(part.to_ascii_lowercase().as_str(), "anime" | "アニメ") | |
| }) | |
| { | |
| "MEDIA_BLOCK".to_string() | |
| } else if part_classes.iter().any(|item| item == "TEXT") { | |
| "TEXT".to_string() | |
| } else { | |
| let mut set: Vec<String> = part_classes | |
| .into_iter() | |
| .collect::<HashSet<_>>() | |
| .into_iter() | |
| .collect(); | |
| set.sort(); | |
| set.join("_") | |
| } | |
| }; | |
| return format!("BRACKET_{inner_class}"); | |
| } | |
| classify_atom(token) | |
| } | |
| fn is_media_block_class(value: &str) -> bool { | |
| matches!(value, "MEDIA" | "RESOLUTION" | "LANG" | "HASH" | "DATE") | |
| } | |
| fn compact_token_groups(_tokens: &[String], classes: &[String]) -> Vec<Group> { | |
| let mut groups: Vec<Group> = Vec::new(); | |
| let mut previous: Option<String> = None; | |
| for (index, token_class) in classes.iter().enumerate() { | |
| let current = if token_class == "SPACE" { | |
| "SEP" | |
| } else { | |
| token_class | |
| } | |
| .to_string(); | |
| if previous.as_deref() == Some(current.as_str()) | |
| && matches!(current.as_str(), "SEP" | "TEXT") | |
| { | |
| groups.last_mut().unwrap().indices.push(index); | |
| } else { | |
| groups.push(Group { | |
| indices: vec![index], | |
| class_name: current.clone(), | |
| }); | |
| } | |
| previous = Some(current); | |
| } | |
| groups | |
| } | |
| fn template_key_for_filename(filename: &str) -> (String, Vec<String>, Vec<String>, Vec<Group>) { | |
| let tokens = tokenize(filename); | |
| let classes: Vec<String> = tokens.iter().map(|token| classify_token(token)).collect(); | |
| let groups = compact_token_groups(&tokens, &classes); | |
| let key = groups | |
| .iter() | |
| .map(|group| group.class_name.as_str()) | |
| .collect::<Vec<_>>() | |
| .join(" "); | |
| (key, tokens, classes, groups) | |
| } | |
| fn suggested_roles(template: &str) -> Vec<String> { | |
| let items: Vec<&str> = template.split_whitespace().collect(); | |
| let mut roles = vec!["O".to_string(); items.len()]; | |
| let mut segment_starts = vec![0usize]; | |
| for (index, item) in items.iter().enumerate() { | |
| if *item == "PATH" { | |
| segment_starts.push(index + 1); | |
| } | |
| } | |
| for (index, item) in items.iter().enumerate() { | |
| roles[index] = if item.contains("EPISODE_VERSION") { | |
| "EPISODE_VERSION" | |
| } else if item.contains("EPISODE_RANGE") { | |
| "EPISODE_RANGE" | |
| } else if item.contains("EPISODE") || item.contains("SXE") { | |
| "EPISODE" | |
| } else if item.contains("RESOLUTION") { | |
| "RESOLUTION" | |
| } else if item.contains("HASH") { | |
| "HASH" | |
| } else if item.contains("LANG") || item.contains("MEDIA") { | |
| "SOURCE" | |
| } else if item.contains("SPECIAL") { | |
| "SPECIAL" | |
| } else if item.contains("SEASON") { | |
| "SEASON" | |
| } else if item.contains("VOLUME") { | |
| "VOLUME" | |
| } else { | |
| "O" | |
| } | |
| .to_string(); | |
| } | |
| for (offset, start) in segment_starts.iter().enumerate() { | |
| let end = if offset + 1 < segment_starts.len() { | |
| segment_starts[offset + 1] - 1 | |
| } else { | |
| items.len() | |
| }; | |
| if *start >= end { | |
| continue; | |
| } | |
| let first_structural = (*start..end) | |
| .find(|&index| { | |
| items[index].contains("EPISODE") | |
| || matches!(items[index], "SXE" | "SPECIAL" | "SEASON") | |
| }) | |
| .unwrap_or(end); | |
| let bracket_text: Vec<usize> = (*start..first_structural) | |
| .filter(|&index| items[index] == "BRACKET_TEXT" && roles[index] == "O") | |
| .collect(); | |
| let text: Vec<usize> = (*start..first_structural) | |
| .filter(|&index| items[index] == "TEXT" && roles[index] == "O") | |
| .collect(); | |
| if bracket_text.len() >= 2 { | |
| roles[bracket_text[0]] = "GROUP".to_string(); | |
| for index in bracket_text.iter().skip(1) { | |
| roles[*index] = "TITLE".to_string(); | |
| } | |
| } else if bracket_text.len() == 1 { | |
| roles[bracket_text[0]] = if text.is_empty() { | |
| "TITLE" | |
| } else if bracket_text[0] == *start { | |
| "GROUP" | |
| } else { | |
| "TITLE" | |
| } | |
| .to_string(); | |
| } | |
| for index in text { | |
| roles[index] = "TITLE".to_string(); | |
| } | |
| if !roles[*start..end].iter().any(|role| role == "TITLE") | |
| && !items[*start..end].is_empty() | |
| && items[*start].contains("EPISODE") | |
| { | |
| let mut run = Vec::new(); | |
| for index in (*start + 1)..end { | |
| if items[index] == "TEXT" && roles[index] == "O" { | |
| run.push(index); | |
| continue; | |
| } | |
| if items[index] == "SEP" { | |
| continue; | |
| } | |
| if !run.is_empty() { | |
| break; | |
| } | |
| } | |
| if run.len() >= 2 { | |
| for index in run { | |
| roles[index] = "TITLE".to_string(); | |
| } | |
| } | |
| } | |
| } | |
| roles | |
| } | |
| fn refine_semantic_roles(tokens: &[String], groups: &[Group], roles: &[String]) -> Vec<String> { | |
| let mut output = roles.to_vec(); | |
| let mut segment_end = groups | |
| .iter() | |
| .position(|group| group.class_name == "PATH") | |
| .unwrap_or(groups.len()); | |
| let mut is_path_segment = segment_end < groups.len(); | |
| for index in 0..groups.len() { | |
| if groups[index].class_name == "PATH" { | |
| segment_end = groups[index + 1..] | |
| .iter() | |
| .position(|group| group.class_name == "PATH") | |
| .map(|offset| index + 1 + offset) | |
| .unwrap_or(groups.len()); | |
| is_path_segment = segment_end < groups.len(); | |
| continue; | |
| } | |
| let text = group_text(tokens, &groups[index]); | |
| let bracketed = is_bracket_group(&groups[index]); | |
| if is_category_tag_text(&text, bracketed, is_path_segment) | |
| && matches!(output[index].as_str(), "O" | "TITLE" | "GROUP" | "SPECIAL") | |
| { | |
| output[index] = "TAG".to_string(); | |
| continue; | |
| } | |
| if output[index] == "SEASON" && is_path_segment { | |
| output[index] = "PATH_SEASON".to_string(); | |
| continue; | |
| } | |
| if output[index] == "TITLE" { | |
| output[index] = title_role_for_text(&text, is_path_segment); | |
| } | |
| } | |
| output | |
| } | |
| fn filename_has_title(filename: &str) -> bool { | |
| let (key, _, _, _) = template_key_for_filename(filename); | |
| suggested_roles(&key).iter().any(|role| is_title_role(role)) | |
| } | |
| fn training_filename_for(original: &str) -> (String, bool) { | |
| let parts: Vec<&str> = original | |
| .split(|ch| ch == '/' || ch == '\\') | |
| .map(str::trim) | |
| .filter(|part| !part.is_empty()) | |
| .collect(); | |
| if parts.len() >= 2 | |
| && (path_segment_is_episodeish(parts[parts.len() - 1]) | |
| || (!path_segment_is_plain_season(parts[parts.len() - 2]) | |
| && path_segment_starts_with_episode(parts[parts.len() - 1]) | |
| && !leaf_has_full_title_after_episode(parts[parts.len() - 1]))) | |
| { | |
| if let Some(parent) = parts[..parts.len() - 1].iter().rev().find(|part| { | |
| let trimmed = trim_parent_title_segment(part); | |
| filename_has_title(&trimmed) && !path_segment_is_media_noise(&trimmed) | |
| }) { | |
| let parent = trim_parent_title_segment(parent.trim()); | |
| return ( | |
| format!("{} {}", parent, parts[parts.len() - 1].trim()), | |
| true, | |
| ); | |
| } | |
| } | |
| if parts.len() >= 2 && filename_has_title(parts[parts.len() - 1]) { | |
| if path_segment_has_season(parts[parts.len() - 2]) { | |
| if !path_segment_is_plain_season(parts[parts.len() - 2]) { | |
| return (parts[parts.len() - 1].to_string(), true); | |
| } | |
| let parent_seasons = path_segment_seasons(parts[parts.len() - 2]); | |
| let leaf_seasons = path_segment_seasons(parts[parts.len() - 1]); | |
| if parent_seasons | |
| .iter() | |
| .any(|season| leaf_seasons.contains(season)) | |
| { | |
| (parts[parts.len() - 1].to_string(), true) | |
| } else { | |
| ( | |
| format!( | |
| "{} {}", | |
| parts[parts.len() - 2].trim(), | |
| parts[parts.len() - 1].trim() | |
| ), | |
| true, | |
| ) | |
| } | |
| } else { | |
| (parts[parts.len() - 1].to_string(), true) | |
| } | |
| } else { | |
| (original.to_string(), false) | |
| } | |
| } | |
| fn path_segment_is_plain_season(segment: &str) -> bool { | |
| let cleaned = strip_wrapper(segment).trim().to_string(); | |
| PLAIN_SEASON_SEGMENT_RE.is_match(&cleaned) | |
| } | |
| fn trim_terminal_series_kind(segment: &str) -> String { | |
| let mut output = segment.trim().to_string(); | |
| for suffix in ["_TV", ".TV", " TV", "_tv", ".tv", " tv"] { | |
| if output.ends_with(suffix) { | |
| output.truncate(output.len() - suffix.len()); | |
| return output.trim_end_matches(['_', '.', ' ']).to_string(); | |
| } | |
| } | |
| output | |
| } | |
| fn trim_parent_title_segment(segment: &str) -> String { | |
| let mut output = trim_terminal_series_kind(segment); | |
| loop { | |
| let trimmed = output.trim_end(); | |
| let Some(last) = trimmed.chars().next_back() else { | |
| return output; | |
| }; | |
| let open = match last { | |
| ')' => '(', | |
| ']' => '[', | |
| '】' => '【', | |
| _ => return output, | |
| }; | |
| let Some(start) = trimmed.rfind(open) else { | |
| return output; | |
| }; | |
| let suffix = &trimmed[start..]; | |
| if path_segment_is_media_noise(suffix) { | |
| output.truncate(start); | |
| output = output.trim_end_matches([' ', '_', '.', '-']).to_string(); | |
| continue; | |
| } | |
| return output; | |
| } | |
| } | |
| fn path_segment_has_season(value: &str) -> bool { | |
| PATH_SEGMENT_SEASON_RE.is_match(value) | |
| } | |
| fn path_segment_seasons(value: &str) -> HashSet<u8> { | |
| SEASON_WORD_NUMBER_RE | |
| .captures_iter(value) | |
| .chain(S_NUMBER_SEGMENT_RE.captures_iter(value)) | |
| .chain(SXE_SEASON_RE.captures_iter(value)) | |
| .filter_map(|captures| captures.get(1)) | |
| .filter_map(|item| item.as_str().parse::<u8>().ok()) | |
| .collect() | |
| } | |
| fn has_encoding_noise(value: &str) -> bool { | |
| if value.contains('\u{fffd}') { | |
| return true; | |
| } | |
| let markers = [ | |
| "譁", "蜈", "螟", "蟄", "謇", "邱", "荳", "縺", "繧", "莨", "鬆", "髯", "瀛", "楀", "箷", | |
| "绲", "刔", "鏃", "湪", "鏍", "犲", "儚", "鐗", "吀", "铦", "躲", "伄", "椋", "伓", "姘", | |
| "帽", "娆", "洖", "浜", "堝", "澶", "湴", "鐒", "銇", "銈", "銉", "偅", "偗", "儱", "儫", | |
| "兗", "仧", "鏉变", "鍠靛", "銉熴", "銈︺", "瀵掕", "潐楦", "常涔", "涓歖", "缁堟", "湯鍒", | |
| "瀵诲", "線浣", "曟柟", "瓒呴", "绁炪", "偘銉", "兇銈", "銉砡", "銉砕", "杩风", "硦澶", | |
| "銇淬", "仧銉", "銉嗐", "偅銈", "銈躲", | |
| ]; | |
| let marker_hits = markers | |
| .iter() | |
| .map(|marker| value.matches(marker).count()) | |
| .sum::<usize>(); | |
| let halfwidth_hits = value | |
| .chars() | |
| .filter(|ch| ('\u{ff61}'..='\u{ff9f}').contains(ch)) | |
| .count(); | |
| let latin_mojibake = value.split_whitespace().any(|part| { | |
| part.chars() | |
| .any(|ch| matches!(ch, '帽' | '茅' | '脳' | '锛')) | |
| && part.chars().any(|ch| ch.is_ascii_alphabetic()) | |
| }); | |
| marker_hits >= 2 || (marker_hits >= 1 && halfwidth_hits >= 1) || latin_mojibake | |
| } | |
| fn has_non_anime_noise(value: &str) -> bool { | |
| let normalized = value.replace('\\', "/").trim().to_ascii_lowercase(); | |
| normalized == "mtv" | |
| || normalized.starts_with("mtv/") | |
| || normalized.contains("/mtv/") | |
| || value.contains("[旅游") | |
| || value.contains("[旅游番") | |
| || normalized.contains("tokyo deep") | |
| || value.contains("日本不思议铁路之旅") | |
| || value.contains("ニッポンぶらり鉄道旅") | |
| } | |
| fn normalized_path_segment(value: &str) -> String { | |
| value | |
| .split_whitespace() | |
| .collect::<String>() | |
| .to_ascii_lowercase() | |
| } | |
| fn normalized_tag_text(value: &str) -> String { | |
| value | |
| .replace(['_', '.', '-', '・'], " ") | |
| .split_whitespace() | |
| .collect::<Vec<_>>() | |
| .join(" ") | |
| .trim() | |
| .to_ascii_lowercase() | |
| } | |
| fn compact_tag_text(value: &str) -> String { | |
| value | |
| .chars() | |
| .filter(|ch| ch.is_alphanumeric()) | |
| .collect::<String>() | |
| .to_ascii_lowercase() | |
| } | |
| fn is_bracket_group(group: &Group) -> bool { | |
| group.class_name.starts_with("BRACKET_") | |
| } | |
| fn is_category_tag_text(text: &str, bracketed: bool, path_segment: bool) -> bool { | |
| let cleaned = strip_wrapper(text); | |
| let trimmed = cleaned.trim(); | |
| if trimmed.is_empty() { | |
| return false; | |
| } | |
| if (bracketed || path_segment) && (DATE_RE.is_match(trimmed) || YEAR_RANGE_RE.is_match(trimmed)) | |
| { | |
| return true; | |
| } | |
| if (bracketed || path_segment) | |
| && matches!( | |
| trimmed, | |
| "国漫" | "國漫" | "日漫" | "剧场版" | "劇場版" | "新番" | |
| ) | |
| { | |
| return true; | |
| } | |
| if (bracketed || path_segment) | |
| && (trimmed.ends_with("月新番") || trimmed.ends_with("月新番合集")) | |
| { | |
| return true; | |
| } | |
| let normalized = normalized_tag_text(trimmed); | |
| (bracketed || path_segment) | |
| && matches!( | |
| normalized.as_str(), | |
| "anime" | "gekijouban" | "movie" | "movies" | "the movie" | "tv" | "tv series" | |
| ) | |
| } | |
| fn has_music_collection_noise(value: &str) -> bool { | |
| let normalized = value | |
| .replace(['_', '.', '-', '・', '/', '\\'], " ") | |
| .split_whitespace() | |
| .collect::<Vec<_>>() | |
| .join(" "); | |
| let compact = compact_tag_text(value); | |
| MUSIC_COLLECTION_RE.is_match(&normalized) || compact.contains("musicclip") | |
| } | |
| fn is_title_role(role: &str) -> bool { | |
| role == "TITLE" || role.starts_with("TITLE_") || role.starts_with("PATH_TITLE_") | |
| } | |
| fn is_path_title_role(role: &str) -> bool { | |
| role.starts_with("PATH_TITLE_") | |
| } | |
| fn title_entity_from_role(role: &str) -> Option<&str> { | |
| if role == "TITLE" { | |
| Some("TITLE") | |
| } else if role.starts_with("TITLE_") || role.starts_with("PATH_TITLE_") { | |
| Some(role) | |
| } else { | |
| None | |
| } | |
| } | |
| fn is_title_entity(entity: &str) -> bool { | |
| entity == "TITLE" | |
| || matches!( | |
| entity, | |
| "TITLE_CHS" | |
| | "TITLE_CHT" | |
| | "TITLE_JPN" | |
| | "TITLE_LATIN" | |
| | "TITLE_MIXED" | |
| | "PATH_TITLE_CHS" | |
| | "PATH_TITLE_CHT" | |
| | "PATH_TITLE_JPN" | |
| | "PATH_TITLE_LATIN" | |
| | "PATH_TITLE_MIXED" | |
| ) | |
| } | |
| fn is_title_label(label: &str) -> bool { | |
| label_entity(label).is_some_and(is_title_entity) | |
| } | |
| fn title_language_suffix(text: &str) -> &'static str { | |
| let mut has_latin = false; | |
| let mut has_han = false; | |
| let mut has_kana = false; | |
| for ch in text.chars() { | |
| if ch.is_ascii_alphabetic() { | |
| has_latin = true; | |
| } else if ('\u{3040}'..='\u{30ff}').contains(&ch) || ('\u{31f0}'..='\u{31ff}').contains(&ch) | |
| { | |
| has_kana = true; | |
| } else if ('\u{4e00}'..='\u{9fff}').contains(&ch) { | |
| has_han = true; | |
| } | |
| } | |
| if has_kana { | |
| return "JPN"; | |
| } | |
| if has_latin && has_han { | |
| return "MIXED"; | |
| } | |
| if has_han { | |
| return cjk_title_language_suffix(text); | |
| } | |
| if has_latin { | |
| return "LATIN"; | |
| } | |
| "MIXED" | |
| } | |
| fn cjk_title_language_suffix(text: &str) -> &'static str { | |
| let japanese_markers = [ | |
| '々', 'ヶ', '君', '戦', '気', '辺', '沢', '桜', '竜', '広', '処', '歩', '黒', '円', | |
| ]; | |
| if text.chars().any(|ch| japanese_markers.contains(&ch)) { | |
| return "JPN"; | |
| } | |
| let simplified_markers = [ | |
| '国', '剧', '场', '农', '闲', '汉', '龙', '门', '击', '战', '体', '后', '爱', '边', '声', | |
| '岛', '学', '万', | |
| ]; | |
| if text.chars().any(|ch| simplified_markers.contains(&ch)) { | |
| return "CHS"; | |
| } | |
| let traditional_markers = [ | |
| '國', '劇', '場', '農', '閒', '漢', '龍', '門', '擊', '戰', '體', '後', '愛', '邊', '聲', | |
| '島', '學', '萬', '縛', '異', '臺', '灣', '搖', '滾', | |
| ]; | |
| if text.chars().any(|ch| traditional_markers.contains(&ch)) { | |
| return "CHT"; | |
| } | |
| "CHS" | |
| } | |
| fn title_role_for_text(text: &str, path_title: bool) -> String { | |
| let prefix = if path_title { "PATH_TITLE" } else { "TITLE" }; | |
| format!("{prefix}_{}", title_language_suffix(text)) | |
| } | |
| fn path_segment_is_episodeish(value: &str) -> bool { | |
| let (_, _, _, groups) = template_key_for_filename(value); | |
| let structural: Vec<&String> = groups | |
| .iter() | |
| .map(|group| &group.class_name) | |
| .filter(|item| item.as_str() != "SEP") | |
| .collect(); | |
| !structural.is_empty() | |
| && structural.iter().all(|item| { | |
| item.starts_with("EPISODE") | |
| || item.as_str() == "SPECIAL" | |
| || item.as_str() == "VOLUME" | |
| || item.as_str() == "BRACKET_VOLUME" | |
| }) | |
| } | |
| fn path_segment_starts_with_episode(value: &str) -> bool { | |
| if EPISODE_CJK_PREFIX_RE.is_match(value.trim()) { | |
| return true; | |
| } | |
| let (key, _, _, groups) = template_key_for_filename(value); | |
| let roles = suggested_roles(&key); | |
| groups | |
| .iter() | |
| .zip(roles.iter()) | |
| .find(|(group, _)| group.class_name != "SEP") | |
| .is_some_and(|(_, role)| role.starts_with("EPISODE")) | |
| } | |
| fn leaf_has_full_title_after_episode(value: &str) -> bool { | |
| let (key, _, _, groups) = template_key_for_filename(value); | |
| let roles = suggested_roles(&key); | |
| let first_structural = roles.iter().position(|role| role.starts_with("EPISODE")); | |
| let Some(first_episode) = first_structural else { | |
| return false; | |
| }; | |
| groups | |
| .iter() | |
| .zip(roles.iter()) | |
| .skip(first_episode + 1) | |
| .filter(|(group, _)| group.class_name != "SEP") | |
| .any(|(_, role)| role == "TITLE") | |
| } | |
| fn path_segment_is_media_noise(value: &str) -> bool { | |
| let normalized = value.to_ascii_lowercase(); | |
| if normalized.contains("sourceunknown") || normalized.contains("sourceunknow") { | |
| return true; | |
| } | |
| if (normalized.contains("dvdrip") | |
| || normalized.contains("bdrip") | |
| || normalized.contains("webrip") | |
| || normalized.contains("web-dl") | |
| || normalized.contains("bluray")) | |
| && tokenize(value) | |
| .iter() | |
| .map(|token| classify_atom(token)) | |
| .any(|class_name| class_name == "RESOLUTION") | |
| { | |
| return true; | |
| } | |
| let (_, _, _, groups) = template_key_for_filename(value); | |
| let structural: Vec<&String> = groups | |
| .iter() | |
| .map(|group| &group.class_name) | |
| .filter(|item| item.as_str() != "SEP") | |
| .collect(); | |
| !structural.is_empty() | |
| && structural.iter().all(|item| { | |
| matches!( | |
| item.as_str(), | |
| "MEDIA" | |
| | "RESOLUTION" | |
| | "LANG" | |
| | "HASH" | |
| | "DATE" | |
| | "BRACKET_MEDIA" | |
| | "BRACKET_RESOLUTION" | |
| | "BRACKET_LANG" | |
| | "BRACKET_HASH" | |
| | "BRACKET_DATE" | |
| | "MEDIA_BLOCK" | |
| | "BRACKET_MEDIA_BLOCK" | |
| ) | |
| }) | |
| } | |
| fn has_abstract_path_noise(value: &str) -> bool { | |
| let parts: Vec<&str> = value | |
| .split(|ch| ch == '/' || ch == '\\') | |
| .map(str::trim) | |
| .filter(|part| !part.is_empty()) | |
| .collect(); | |
| if parts.len() < 3 { | |
| return false; | |
| } | |
| if normalized_path_segment(parts[0]) == normalized_path_segment(parts[parts.len() - 1]) { | |
| return true; | |
| } | |
| path_segment_is_episodeish(parts[0]) && path_segment_is_episodeish(parts[parts.len() - 1]) | |
| } | |
| fn role_label(role: &str) -> String { | |
| let entity = match role { | |
| "GROUP" => Some("GROUP"), | |
| role if is_title_role(role) => Some("TITLE"), | |
| "EPISODE" | "EPISODE_VERSION" | "EPISODE_RANGE" => Some("EPISODE"), | |
| "SEASON" => Some("SEASON"), | |
| "PATH_SEASON" => Some("PATH_SEASON"), | |
| "SPECIAL" | "VOLUME" => Some("SPECIAL"), | |
| "RESOLUTION" => Some("RESOLUTION"), | |
| "SOURCE" => Some("SOURCE"), | |
| "TAG" => Some("TAG"), | |
| _ => None, | |
| }; | |
| entity.map_or_else(|| "O".to_string(), |entity| format!("B-{entity}")) | |
| } | |
| fn is_separator(piece: &str) -> bool { | |
| piece.is_empty() | |
| || piece | |
| .chars() | |
| .all(|ch| ch.is_whitespace() || !ch.is_alphanumeric()) | |
| } | |
| fn char_kind(ch: char) -> &'static str { | |
| if ch.is_whitespace() || !ch.is_alphanumeric() { | |
| "sep" | |
| } else if ch.is_ascii_digit() { | |
| "digit" | |
| } else if ch.is_ascii_alphabetic() { | |
| "alpha" | |
| } else { | |
| "text" | |
| } | |
| } | |
| fn split_refined_token(token: &str) -> Vec<String> { | |
| let whole_class = classify_atom(token); | |
| let is_wrapped = { | |
| let chars: Vec<char> = token.chars().collect(); | |
| chars.len() >= 2 | |
| && ((chars[0] == '[' && chars[chars.len() - 1] == ']') | |
| || (chars[0] == '(' && chars[chars.len() - 1] == ')') | |
| || (chars[0] == '【' && chars[chars.len() - 1] == '】')) | |
| }; | |
| if !is_wrapped | |
| && matches!( | |
| whole_class.as_str(), | |
| "RESOLUTION" | "MEDIA" | "LANG" | "HASH" | "SXE" | "EPISODE_VERSION" | |
| ) | |
| && token.chars().all(char::is_alphanumeric) | |
| { | |
| return vec![token.to_string()]; | |
| } | |
| if !is_wrapped && whole_class == "EPISODE" && SIMPLE_EPISODE_RE.is_match(token) { | |
| return vec![token.to_string()]; | |
| } | |
| let mut pieces = Vec::new(); | |
| let mut current = String::new(); | |
| let mut current_kind: Option<&str> = None; | |
| for ch in token.chars() { | |
| let kind = char_kind(ch); | |
| if kind == "sep" { | |
| if !current.is_empty() { | |
| pieces.push(std::mem::take(&mut current)); | |
| current_kind = None; | |
| } | |
| pieces.push(ch.to_string()); | |
| continue; | |
| } | |
| if !current.is_empty() && current_kind != Some(kind) { | |
| pieces.push(std::mem::take(&mut current)); | |
| } | |
| current.push(ch); | |
| current_kind = Some(kind); | |
| } | |
| if !current.is_empty() { | |
| pieces.push(current); | |
| } | |
| let mut merged = Vec::new(); | |
| let mut index = 0; | |
| while index < pieces.len() { | |
| if index + 2 < pieces.len() | |
| && !is_separator(&pieces[index]) | |
| && is_separator(&pieces[index + 1]) | |
| && !is_separator(&pieces[index + 2]) | |
| { | |
| let combined = format!( | |
| "{}{}{}", | |
| pieces[index], | |
| pieces[index + 1], | |
| pieces[index + 2] | |
| ); | |
| let combined_class = classify_atom(&combined); | |
| if !pieces[index + 1].chars().any(char::is_whitespace) | |
| && matches!(pieces[index + 1].as_str(), "." | "x" | "X" | "×") | |
| && matches!( | |
| combined_class.as_str(), | |
| "RESOLUTION" | "MEDIA" | "LANG" | "HASH" | "SXE" | "EPISODE_VERSION" | |
| ) | |
| { | |
| merged.push(combined); | |
| index += 3; | |
| continue; | |
| } | |
| } | |
| if !is_separator(&pieces[index]) { | |
| let mut end = index; | |
| let mut combined = String::new(); | |
| while end < pieces.len() && !is_separator(&pieces[end]) { | |
| combined.push_str(&pieces[end]); | |
| end += 1; | |
| } | |
| if end > index + 1 && is_mergeable_refined_class(&classify_atom(&combined)) { | |
| merged.push(combined); | |
| index = end; | |
| continue; | |
| } | |
| } | |
| if index + 1 < pieces.len() | |
| && !is_separator(&pieces[index]) | |
| && !is_separator(&pieces[index + 1]) | |
| { | |
| let combined = format!("{}{}", pieces[index], pieces[index + 1]); | |
| if is_mergeable_refined_class(&classify_atom(&combined)) { | |
| merged.push(combined); | |
| index += 2; | |
| continue; | |
| } | |
| } | |
| merged.push(pieces[index].clone()); | |
| index += 1; | |
| } | |
| merged | |
| } | |
| fn is_mergeable_refined_class(value: &str) -> bool { | |
| matches!( | |
| value, | |
| "RESOLUTION" | "MEDIA" | "LANG" | "HASH" | "SXE" | "EPISODE_VERSION" | "SEASON" | |
| ) | |
| } | |
| fn label_for_refined_piece(piece: &str, role: &str, token_class: &str) -> String { | |
| if is_separator(piece) { | |
| return "O".to_string(); | |
| } | |
| let atom_class = classify_atom(piece); | |
| let upper = piece.to_ascii_uppercase(); | |
| if matches!(role, "EPISODE" | "EPISODE_VERSION" | "EPISODE_RANGE") { | |
| if atom_class == "SEASON" { | |
| return "B-SEASON".to_string(); | |
| } | |
| if matches!(atom_class.as_str(), "EPISODE" | "EPISODE_VERSION" | "SXE") | |
| || piece.chars().all(|ch| ch.is_ascii_digit()) | |
| { | |
| return "B-EPISODE".to_string(); | |
| } | |
| if matches!(atom_class.as_str(), "SPECIAL" | "VOLUME") | |
| || matches!( | |
| upper.as_str(), | |
| "OVA" | "OAD" | "SP" | "PV" | "CM" | "OP" | "ED" | "NCOP" | "NCED" | |
| ) | |
| { | |
| return "B-SPECIAL".to_string(); | |
| } | |
| return "O".to_string(); | |
| } | |
| if role == "SOURCE" || matches!(token_class, "BRACKET_MEDIA_BLOCK" | "MEDIA_BLOCK") { | |
| if atom_class == "EPISODE_VERSION" { | |
| return "B-EPISODE".to_string(); | |
| } | |
| if atom_class == "RESOLUTION" { | |
| return "B-RESOLUTION".to_string(); | |
| } | |
| if atom_class == "HASH" { | |
| return "O".to_string(); | |
| } | |
| if matches!(atom_class.as_str(), "MEDIA" | "LANG") { | |
| return "B-SOURCE".to_string(); | |
| } | |
| if matches!(atom_class.as_str(), "SPECIAL" | "VOLUME") { | |
| return "B-SPECIAL".to_string(); | |
| } | |
| return if matches!( | |
| upper.as_str(), | |
| "END" | "FIN" | "COMPLETE" | "TV" | "全集" | "全" | |
| ) { | |
| "O".to_string() | |
| } else { | |
| "B-SOURCE".to_string() | |
| }; | |
| } | |
| if role == "RESOLUTION" { | |
| return if atom_class == "RESOLUTION" || piece.chars().all(|ch| ch.is_ascii_digit()) { | |
| "B-RESOLUTION".to_string() | |
| } else { | |
| "O".to_string() | |
| }; | |
| } | |
| role_label(role) | |
| } | |
| fn split_sxe_token(token: &str) -> Option<(Vec<String>, Vec<String>)> { | |
| let caps = SXE_VALUE_RE.captures(token)?; | |
| let mut pieces = vec![ | |
| "S".to_string(), | |
| caps[1].to_string(), | |
| "E".to_string(), | |
| caps[2].to_string(), | |
| ]; | |
| let mut labels = vec![ | |
| "O".to_string(), | |
| "B-SEASON".to_string(), | |
| "O".to_string(), | |
| "B-EPISODE".to_string(), | |
| ]; | |
| if let Some(version) = caps.get(3) { | |
| pieces.push("v".to_string()); | |
| pieces.push(version.as_str().to_string()); | |
| labels.push("O".to_string()); | |
| labels.push("O".to_string()); | |
| } | |
| Some((pieces, labels)) | |
| } | |
| fn repair_compact_sxe_tokens( | |
| tokens: Vec<String>, | |
| labels: Vec<String>, | |
| ) -> (Vec<String>, Vec<String>) { | |
| let mut output_tokens = Vec::new(); | |
| let mut output_labels = Vec::new(); | |
| for (token, label) in tokens.into_iter().zip(labels.into_iter()) { | |
| if let Some((pieces, piece_labels)) = split_sxe_token(&token) { | |
| output_tokens.extend(pieces); | |
| output_labels.extend(piece_labels); | |
| } else { | |
| output_tokens.push(token); | |
| output_labels.push(label); | |
| } | |
| } | |
| (output_tokens, output_labels) | |
| } | |
| fn split_episode_token(token: &str) -> Option<(Vec<String>, Vec<String>)> { | |
| if DECIMAL_EPISODE_RE.is_match(token) { | |
| let pieces = split_generated_token(token); | |
| let labels = pieces.iter().map(|_| "B-EPISODE".to_string()).collect(); | |
| return Some((pieces, labels)); | |
| } | |
| let caps = EPISODE_VALUE_RE.captures(token)?; | |
| let mut pieces = vec![caps[1].to_string()]; | |
| let mut labels = vec!["O".to_string()]; | |
| for piece in split_generated_token(&caps[2]) { | |
| pieces.push(piece); | |
| labels.push("B-EPISODE".to_string()); | |
| } | |
| if let Some(version) = caps.get(3) { | |
| pieces.push("v".to_string()); | |
| pieces.push(version.as_str().to_string()); | |
| labels.push("O".to_string()); | |
| labels.push("O".to_string()); | |
| } | |
| Some((pieces, labels)) | |
| } | |
| fn split_season_token(token: &str) -> Option<(Vec<String>, Vec<String>)> { | |
| let caps = SEASON_VALUE_RE.captures(token)?; | |
| Some(( | |
| vec!["S".to_string(), caps[1].to_string()], | |
| vec!["O".to_string(), "B-SEASON".to_string()], | |
| )) | |
| } | |
| fn group_text(tokens: &[String], group: &Group) -> String { | |
| strip_wrapper( | |
| &group | |
| .indices | |
| .iter() | |
| .map(|&index| tokens[index].as_str()) | |
| .collect::<String>(), | |
| ) | |
| } | |
| fn normalize_whitelist_name(value: &str) -> String { | |
| value.split_whitespace().collect::<Vec<_>>().join(" ") | |
| } | |
| fn phrase_parts_for_whitelist(value: &str) -> Vec<String> { | |
| let tokens = tokenize(value); | |
| let classes: Vec<String> = tokens.iter().map(|token| classify_token(token)).collect(); | |
| let groups = compact_token_groups(&tokens, &classes); | |
| groups | |
| .iter() | |
| .filter(|group| whitelist_phrase_group(group)) | |
| .map(|group| group_text(&tokens, group)) | |
| .filter(|part| !part.trim().is_empty()) | |
| .collect() | |
| } | |
| fn whitelist_phrase_group(group: &Group) -> bool { | |
| matches!( | |
| group.class_name.as_str(), | |
| "TEXT" | "EPISODE" | "SPECIAL" | "SEASON" | "BRACKET_TEXT" | |
| ) | |
| } | |
| fn is_special_title_phrase(text: &str) -> bool { | |
| let normalized = SPECIAL_SPACE_RE | |
| .replace_all(text, " ") | |
| .trim() | |
| .to_ascii_uppercase(); | |
| matches!( | |
| normalized.as_str(), | |
| "CM" | "EVENT" | |
| | "EIZOU" | |
| | "EXTRA" | |
| | "EXTRAS" | |
| | "LOGO" | |
| | "MENU" | |
| | "OMAKE" | |
| | "PREVIEW" | |
| | "PV" | |
| | "THEATER GREETING EVENT" | |
| | "TOKUTEN" | |
| | "TRAILER" | |
| | "TV SPOT" | |
| | "SPOT" | |
| | "WORLD PREMIERE" | |
| | "予告" | |
| | "番宣" | |
| | "宣番" | |
| | "映像特典" | |
| | "特典" | |
| ) || normalized.contains("映像特典") | |
| || normalized.contains("特典映像") | |
| || normalized.contains("番宣") | |
| || normalized.contains("宣番") | |
| || normalized.contains("TV SPOT") | |
| || normalized.contains("BD SPOT") | |
| || text.contains("予告") | |
| || SPECIAL_TITLE_PHRASE_RE.is_match(text) | |
| } | |
| fn looks_like_release_group(text: &str) -> bool { | |
| let normalized = text.to_ascii_lowercase(); | |
| normalized.contains("fansub") | |
| || normalized.ends_with("sub") | |
| || normalized.contains("sub&") | |
| || normalized.contains("&sub") | |
| || normalized.contains("字幕组") | |
| || normalized.contains("字幕組") | |
| } | |
| fn title_context_before( | |
| tokens: &[String], | |
| groups: &[Group], | |
| roles: &[String], | |
| index: usize, | |
| ) -> String { | |
| (0..index) | |
| .filter(|&cursor| roles[cursor] == "TITLE") | |
| .map(|cursor| group_text(tokens, &groups[cursor])) | |
| .collect::<Vec<_>>() | |
| .join(" ") | |
| } | |
| fn short_number_title_exception(context: &str, number: &str) -> bool { | |
| let normalized = normalized_tag_text(context); | |
| let compact = compact_tag_text(context); | |
| matches!( | |
| (normalized.as_str(), number), | |
| ("kamisama hajimemashita", "2") | ("ghiblies episode", "2") | ("r", "15") | |
| ) || (normalized.contains("91 days") && number == "91") | |
| || (context.contains("銀河鉄道") && number == "999") | |
| || compact.contains("highschooldd") | |
| || (context.contains("機動戦士ガンダム") && number == "00") | |
| } | |
| fn group_followed_by_quote(tokens: &[String], groups: &[Group], index: usize) -> bool { | |
| let Some(last_token) = groups.get(index).and_then(|group| group.indices.last()) else { | |
| return false; | |
| }; | |
| for token in &tokens[*last_token + 1..] { | |
| if token.chars().all(char::is_whitespace) { | |
| continue; | |
| } | |
| return matches!(token.as_str(), "「" | "「" | "\"" | "'"); | |
| } | |
| false | |
| } | |
| const KNOWN_TITLE_PHRASES: &[&[&str]] = &[ | |
| &["SPY", "x", "FAMILY"], | |
| &["Spy", "x", "Family"], | |
| &["Slime", "300"], | |
| &["Zom", "100"], | |
| &["Kamisama", "Hajimemashita", "2"], | |
| &["Phantasy", "Star", "Online", "2", "Episode", "Oracle"], | |
| &["Durarara", "2", "Ketsu"], | |
| &["Ghiblies", "Episode", "2"], | |
| &["Eien", "no", "831"], | |
| &["Lupin The Thrid Jigen Daisuke no Bohyou"], | |
| &["Lupin The Third Jigen Daisuke no Bohyou"], | |
| ]; | |
| fn apply_known_title_phrases(tokens: &[String], groups: &[Group], roles: &mut [String]) { | |
| if let Some(whitelists) = RUNTIME_WHITELISTS.get() { | |
| for (index, group) in groups.iter().enumerate() { | |
| let previous_structural = roles[..index].iter().any(|role| { | |
| role.starts_with("EPISODE") | |
| || matches!( | |
| role.as_str(), | |
| "SEASON" | "SPECIAL" | "SOURCE" | "RESOLUTION" | |
| ) | |
| }); | |
| if group.class_name == "BRACKET_TEXT" | |
| && !previous_structural | |
| && whitelists | |
| .group_names | |
| .contains(&normalize_whitelist_name(&group_text(tokens, group))) | |
| && !roles.get(index).is_some_and(|role| { | |
| matches!( | |
| role.as_str(), | |
| "EPISODE" | |
| | "EPISODE_VERSION" | |
| | "EPISODE_RANGE" | |
| | "SEASON" | |
| | "SOURCE" | |
| | "RESOLUTION" | |
| | "SPECIAL" | |
| ) | |
| }) | |
| { | |
| roles[index] = "GROUP".to_string(); | |
| } | |
| } | |
| } | |
| let searchable: Vec<(usize, String)> = groups | |
| .iter() | |
| .enumerate() | |
| .filter(|(_, group)| whitelist_phrase_group(group)) | |
| .map(|(index, group)| (index, group_text(tokens, group))) | |
| .collect(); | |
| for phrase in KNOWN_TITLE_PHRASES { | |
| apply_title_phrase(&searchable, phrase, roles, true); | |
| } | |
| if let Some(whitelists) = RUNTIME_WHITELISTS.get() { | |
| for phrase in &whitelists.title_phrases { | |
| if phrase.len() >= 2 { | |
| apply_title_phrase(&searchable, phrase, roles, false); | |
| } | |
| } | |
| } | |
| } | |
| fn apply_title_phrase( | |
| searchable: &[(usize, String)], | |
| phrase: &[impl AsRef<str>], | |
| roles: &mut [String], | |
| allow_structural_override: bool, | |
| ) { | |
| if phrase.is_empty() || phrase.len() > searchable.len() { | |
| return; | |
| } | |
| for window in searchable.windows(phrase.len()) { | |
| if window | |
| .iter() | |
| .zip(phrase.iter()) | |
| .all(|((_, text), expected)| text.eq_ignore_ascii_case(expected.as_ref())) | |
| { | |
| for (group_index, _) in window { | |
| if roles.get(*group_index).is_some_and(|role| role == "GROUP") { | |
| let is_known_group = RUNTIME_WHITELISTS.get().is_some_and(|whitelists| { | |
| whitelists | |
| .group_names | |
| .contains(&normalize_whitelist_name(&window[0].1)) | |
| }); | |
| if is_known_group { | |
| continue; | |
| } | |
| } | |
| if !allow_structural_override | |
| && roles.get(*group_index).is_some_and(|role| { | |
| matches!( | |
| role.as_str(), | |
| "EPISODE" | |
| | "EPISODE_VERSION" | |
| | "EPISODE_RANGE" | |
| | "SEASON" | |
| | "SOURCE" | |
| | "RESOLUTION" | |
| ) | |
| }) | |
| { | |
| continue; | |
| } | |
| { | |
| roles[*group_index] = "TITLE".to_string(); | |
| } | |
| } | |
| } | |
| } | |
| } | |
| fn adjust_contextual_roles(tokens: &[String], groups: &[Group], roles: &[String]) -> Vec<String> { | |
| let mut output = roles.to_vec(); | |
| let ep_markers = ["EP", "E", "Episode", "ep", "episode"]; | |
| let roman = ["I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX"]; | |
| apply_known_title_phrases(tokens, groups, &mut output); | |
| if output.first().is_some_and(|role| role == "GROUP") { | |
| let first_text = group_text(tokens, &groups[0]); | |
| let first_is_known_group = RUNTIME_WHITELISTS.get().is_some_and(|whitelists| { | |
| whitelists | |
| .group_names | |
| .contains(&normalize_whitelist_name(&first_text)) | |
| }); | |
| if !first_is_known_group { | |
| if let Some(groupish_index) = (1..groups.len()).find(|&index| { | |
| output[index] == "TITLE" | |
| && looks_like_release_group(&group_text(tokens, &groups[index])) | |
| }) { | |
| output[0] = "TITLE".to_string(); | |
| output[groupish_index] = "GROUP".to_string(); | |
| } | |
| } | |
| } | |
| if roles | |
| .first() | |
| .is_some_and(|role| role.starts_with("EPISODE")) | |
| && YEAR_RANGE_RE.is_match(&group_text(tokens, &groups[0])) | |
| { | |
| let first_real_structural = (1..roles.len()) | |
| .find(|&index| { | |
| roles[index].starts_with("EPISODE") | |
| || matches!(roles[index].as_str(), "SEASON" | "SPECIAL") | |
| }) | |
| .unwrap_or(roles.len()); | |
| for index in 1..first_real_structural { | |
| if groups[index].class_name == "TEXT" | |
| && !matches!( | |
| group_text(tokens, &groups[index]) | |
| .to_ascii_uppercase() | |
| .as_str(), | |
| "TV" | "OVA" | "OAD" | "SP" | |
| ) | |
| { | |
| output[index] = "TITLE".to_string(); | |
| } | |
| } | |
| } | |
| if !output.iter().any(|role| role == "TITLE") | |
| && roles | |
| .first() | |
| .is_some_and(|role| role.starts_with("EPISODE")) | |
| { | |
| let mut title_run = Vec::new(); | |
| for index in 1..roles.len() { | |
| if groups[index].class_name == "TEXT" && output[index] == "O" { | |
| title_run.push(index); | |
| continue; | |
| } | |
| if groups[index].class_name == "SEP" { | |
| continue; | |
| } | |
| if !title_run.is_empty() { | |
| break; | |
| } | |
| } | |
| if title_run.len() >= 2 { | |
| let last_title_index = *title_run.last().unwrap(); | |
| let later_structural = roles[last_title_index + 1..].iter().any(|role| { | |
| role.starts_with("EPISODE") || matches!(role.as_str(), "SEASON" | "SPECIAL") | |
| }); | |
| if group_text(tokens, &groups[0]) | |
| .chars() | |
| .all(|ch| ch.is_ascii_digit()) | |
| && later_structural | |
| { | |
| output[0] = "TITLE".to_string(); | |
| } | |
| for index in title_run { | |
| output[index] = "TITLE".to_string(); | |
| } | |
| } | |
| } | |
| if roles | |
| .first() | |
| .is_some_and(|role| role.starts_with("EPISODE")) | |
| && group_text(tokens, &groups[0]) | |
| .chars() | |
| .all(|ch| ch.is_ascii_digit()) | |
| { | |
| if let Some(first_title) = output.iter().position(|role| role == "TITLE") { | |
| let later_structural = roles[first_title + 1..].iter().any(|role| { | |
| role.starts_with("EPISODE") || matches!(role.as_str(), "SEASON" | "SPECIAL") | |
| }); | |
| if later_structural { | |
| output[0] = "TITLE".to_string(); | |
| } | |
| } | |
| } | |
| for index in 0..roles.len() { | |
| let text = group_text(tokens, &groups[index]); | |
| if output[index] == "O" && groups[index].class_name.contains("SXE") { | |
| output[index] = "EPISODE".to_string(); | |
| } | |
| if text.eq_ignore_ascii_case("TV") { | |
| let next_text = (index + 1..roles.len()) | |
| .find(|&cursor| groups[cursor].class_name != "SEP") | |
| .map(|cursor| (cursor, group_text(tokens, &groups[cursor]))); | |
| if let Some((spot_index, spot_text)) = next_text { | |
| if spot_text.eq_ignore_ascii_case("Spot") { | |
| output[index] = "SPECIAL".to_string(); | |
| output[spot_index] = "SPECIAL".to_string(); | |
| continue; | |
| } | |
| } | |
| } | |
| if roles[index].starts_with("EPISODE") && YEAR_RANGE_RE.is_match(&text) { | |
| output[index] = "O".to_string(); | |
| continue; | |
| } | |
| if roles[index].starts_with("EPISODE") | |
| && index >= 2 | |
| && matches!( | |
| group_text(tokens, &groups[index - 1]).as_str(), | |
| "×" | "x" | "X" | |
| ) | |
| && output[index - 2] == "TITLE" | |
| && !roles[index + 1..] | |
| .iter() | |
| .any(|role| role.starts_with("EPISODE")) | |
| { | |
| output[index] = "TITLE".to_string(); | |
| if let Some(next_text_index) = (index + 1..roles.len()).find(|&cursor| { | |
| groups[cursor].class_name != "SEP" && groups[cursor].class_name == "TEXT" | |
| }) { | |
| output[next_text_index] = "TITLE".to_string(); | |
| } | |
| continue; | |
| } | |
| if roles[index].starts_with("EPISODE") | |
| && !output[..index] | |
| .iter() | |
| .any(|role| role.starts_with("EPISODE")) | |
| && group_text( | |
| tokens, | |
| &groups[(0..index) | |
| .rev() | |
| .find(|&cursor| groups[cursor].class_name != "SEP") | |
| .unwrap_or(index)], | |
| ) | |
| .eq_ignore_ascii_case("Movie") | |
| { | |
| output[index] = "TITLE".to_string(); | |
| continue; | |
| } | |
| if output[index] == "TITLE" && matches!(text.as_str(), "中日" | "日中" | "英日" | "日英") | |
| { | |
| let next_source_lang = (index + 1..roles.len()) | |
| .find(|&cursor| groups[cursor].class_name != "SEP") | |
| .is_some_and(|cursor| { | |
| output[cursor] == "SOURCE" && group_text(tokens, &groups[cursor]).contains('语') | |
| }); | |
| if next_source_lang { | |
| output[index] = "SOURCE".to_string(); | |
| continue; | |
| } | |
| } | |
| if roles[index].starts_with("EPISODE") | |
| && index >= 1 | |
| && output[..index].iter().any(|role| role == "TITLE") | |
| && text.chars().all(|ch| ch.is_ascii_digit()) | |
| && short_number_title_exception( | |
| &title_context_before(tokens, groups, &output, index), | |
| &text, | |
| ) | |
| { | |
| output[index] = "TITLE".to_string(); | |
| continue; | |
| } | |
| if roles[index].starts_with("EPISODE") | |
| && index >= 1 | |
| && output[index - 1] == "TITLE" | |
| && groups[index - 1].class_name != "SEP" | |
| && text.chars().all(|ch| ch.is_ascii_digit()) | |
| && text.len() <= 2 | |
| && roles[index + 1..] | |
| .iter() | |
| .any(|role| role.starts_with("EPISODE")) | |
| && !group_followed_by_quote(tokens, groups, index) | |
| { | |
| let context = title_context_before(tokens, groups, &output, index); | |
| output[index] = if short_number_title_exception(&context, &text) { | |
| "TITLE" | |
| } else { | |
| "SEASON" | |
| } | |
| .to_string(); | |
| continue; | |
| } | |
| if roles[index].starts_with("EPISODE") && (2..roles.len()).contains(&index) { | |
| let previous_text = group_text(tokens, &groups[index - 2]); | |
| let next_special = output[index + 1..roles.len().min(index + 4)] | |
| .iter() | |
| .any(|role| role == "SPECIAL"); | |
| let next_episode = roles[index + 1..] | |
| .iter() | |
| .any(|role| role.starts_with("EPISODE")); | |
| if groups[index - 1].class_name == "SEP" | |
| && matches!( | |
| previous_text.to_ascii_lowercase().as_str(), | |
| "vol" | "volume" | |
| ) | |
| { | |
| let next_text_before_episode = (index + 1..roles.len()) | |
| .find(|&cursor| groups[cursor].class_name != "SEP") | |
| .is_some_and(|cursor| { | |
| groups[cursor].class_name == "TEXT" | |
| && roles[cursor + 1..] | |
| .iter() | |
| .any(|role| role.starts_with("EPISODE")) | |
| }); | |
| if next_text_before_episode { | |
| output[index - 2] = "TITLE".to_string(); | |
| output[index] = "TITLE".to_string(); | |
| continue; | |
| } | |
| output[index - 2] = "SPECIAL".to_string(); | |
| output[index] = "SPECIAL".to_string(); | |
| continue; | |
| } | |
| if index >= 1 | |
| && output[index - 1] == "TITLE" | |
| && groups[index - 1].class_name != "SEP" | |
| && text.chars().all(|ch| ch.is_ascii_digit()) | |
| && text.len() <= 2 | |
| && roles[index + 1..] | |
| .iter() | |
| .any(|role| role.starts_with("EPISODE")) | |
| && !group_followed_by_quote(tokens, groups, index) | |
| { | |
| let context = title_context_before(tokens, groups, &output, index); | |
| output[index] = if short_number_title_exception(&context, &text) { | |
| "TITLE" | |
| } else { | |
| "SEASON" | |
| } | |
| .to_string(); | |
| continue; | |
| } | |
| if !output[..index].iter().any(|role| role == "TITLE") | |
| && NUMERIC_TITLE_PREFIX_RE.is_match(&text) | |
| && output[..index].iter().any(|role| role == "GROUP") | |
| && roles[index + 1..] | |
| .iter() | |
| .any(|role| role.starts_with("EPISODE")) | |
| { | |
| output[index] = "TITLE".to_string(); | |
| continue; | |
| } | |
| if !output[..index].iter().any(|role| role == "TITLE") | |
| && NUMERIC_TITLE_PREFIX_RE.is_match(&text) | |
| && index + 2 < roles.len() | |
| && groups[index + 1].class_name == "SEP" | |
| && groups[index + 2].class_name == "TEXT" | |
| && group_text(tokens, &groups[index + 2]) | |
| .chars() | |
| .any(|ch| ch.is_alphabetic()) | |
| && roles[index + 3..] | |
| .iter() | |
| .any(|role| role.starts_with("EPISODE")) | |
| { | |
| output[index] = "TITLE".to_string(); | |
| output[index + 2] = "TITLE".to_string(); | |
| continue; | |
| } | |
| if output[index - 2] == "TITLE" | |
| && groups[index - 1].class_name == "SEP" | |
| && previous_text.len() <= 48 | |
| && previous_text.chars().any(|ch| ch.is_alphabetic()) | |
| && text.chars().all(|ch| ch.is_ascii_digit()) | |
| && text.len() <= 2 | |
| && !(index + 2 < roles.len() | |
| && groups[index + 1].class_name == "SEP" | |
| && group_text(tokens, &groups[index + 2]).eq_ignore_ascii_case("episode")) | |
| && !(index + 1 < roles.len() | |
| && groups[index + 1].class_name == "SEP" | |
| && group_text(tokens, &groups[index + 1]) | |
| .chars() | |
| .any(|ch| matches!(ch, '「' | '「' | '"' | '\''))) | |
| && !group_followed_by_quote(tokens, groups, index) | |
| && (next_episode | |
| || (next_special | |
| && (text.parse::<u16>().is_ok_and(|value| value >= 100) | |
| || (previous_text.len() <= 4 | |
| && previous_text.is_ascii() | |
| && previous_text.chars().all(|ch| ch.is_ascii_alphabetic()))))) | |
| { | |
| output[index] = if next_episode | |
| && !short_number_title_exception( | |
| &title_context_before(tokens, groups, &output, index), | |
| &text, | |
| ) { | |
| "SEASON" | |
| } else { | |
| "TITLE" | |
| } | |
| .to_string(); | |
| continue; | |
| } | |
| } | |
| if roles[index].starts_with("EPISODE") | |
| && (text.chars().all(|ch| ch.is_ascii_digit()) | |
| || matches!(classify_atom(&text).as_str(), "EPISODE" | "EPISODE_VERSION")) | |
| && output[..index].iter().any(|role| role == "SPECIAL") | |
| && !output[..index] | |
| .iter() | |
| .any(|role| role.starts_with("EPISODE")) | |
| { | |
| let previous_structural = (0..index) | |
| .rev() | |
| .find(|&cursor| groups[cursor].class_name != "SEP") | |
| .and_then(|cursor| output.get(cursor)) | |
| .map(String::as_str); | |
| let next_real = (index + 1..roles.len()) | |
| .find(|&cursor| groups[cursor].class_name != "SEP") | |
| .and_then(|cursor| roles.get(cursor)) | |
| .map(String::as_str); | |
| if matches!(previous_structural, Some("SPECIAL")) | |
| && !matches!(next_real, Some("TITLE" | "SEASON")) | |
| { | |
| output[index] = "SPECIAL".to_string(); | |
| continue; | |
| } | |
| } | |
| if roles[index].starts_with("EPISODE") | |
| && BARE_RESOLUTION_RE.is_match(&text) | |
| && index >= 2 | |
| && groups[index - 1].class_name == "SEP" | |
| { | |
| let previous_text = group_text(tokens, &groups[index - 2]); | |
| let next_sourceish = (index + 1..roles.len()) | |
| .find(|&cursor| groups[cursor].class_name != "SEP") | |
| .is_some_and(|cursor| matches!(roles[cursor].as_str(), "SOURCE" | "RESOLUTION")); | |
| if previous_text | |
| .chars() | |
| .any(|ch| ch.is_ascii_digit() || matches!(ch, '.' | '-' | '_' | '.')) | |
| || next_sourceish | |
| { | |
| output[index] = "RESOLUTION".to_string(); | |
| continue; | |
| } | |
| } | |
| if roles[index].starts_with("EPISODE") | |
| && index >= 2 | |
| && output[..index].iter().any(|role| role == "TITLE") | |
| && group_text(tokens, &groups[index]) | |
| .chars() | |
| .all(|ch| ch.is_ascii_digit()) | |
| { | |
| let next_episode_word = index + 2 < roles.len() | |
| && groups[index + 1].class_name == "SEP" | |
| && group_text(tokens, &groups[index + 2]).eq_ignore_ascii_case("episode"); | |
| if next_episode_word { | |
| let mut run = Vec::new(); | |
| let mut cursor = index + 2; | |
| while cursor < roles.len() { | |
| if groups[cursor].class_name == "SEP" { | |
| cursor += 1; | |
| continue; | |
| } | |
| if groups[cursor].class_name == "TEXT" && !roles[cursor].starts_with("EPISODE") | |
| { | |
| run.push(cursor); | |
| cursor += 1; | |
| continue; | |
| } | |
| break; | |
| } | |
| let later_episode = roles[cursor..] | |
| .iter() | |
| .any(|role| role.starts_with("EPISODE")); | |
| if run.len() >= 2 && later_episode { | |
| output[index] = "TITLE".to_string(); | |
| for item in run { | |
| output[item] = "TITLE".to_string(); | |
| } | |
| continue; | |
| } | |
| } | |
| } | |
| if roles[index] == "TITLE" && is_special_title_phrase(&text) { | |
| output[index] = "SPECIAL".to_string(); | |
| continue; | |
| } | |
| if roles[index] == "TITLE" | |
| && matches!(text.to_ascii_uppercase().as_str(), "TV" | "TV版") | |
| && output | |
| .iter() | |
| .enumerate() | |
| .any(|(other, role)| other != index && role == "TITLE") | |
| { | |
| output[index] = "O".to_string(); | |
| continue; | |
| } | |
| if roles[index] == "TITLE" | |
| && matches!(text.as_str(), "TVアニメ" | "テレビアニメ") | |
| && output | |
| .iter() | |
| .enumerate() | |
| .any(|(other, role)| other != index && role == "TITLE") | |
| { | |
| output[index] = "O".to_string(); | |
| continue; | |
| } | |
| if output[index] == "TITLE" && text.eq_ignore_ascii_case("Creditless") { | |
| let later_special = output[index + 1..].iter().any(|role| role == "SPECIAL"); | |
| if later_special { | |
| output[index] = "SPECIAL".to_string(); | |
| continue; | |
| } | |
| } | |
| if roles[index] == "TITLE" && matches!(text.as_str(), "第" | "話" | "话" | "回" | "集") | |
| { | |
| output[index] = "O".to_string(); | |
| continue; | |
| } | |
| if output[index] == "O" | |
| && groups[index].class_name == "TEXT" | |
| && roles[index + 1..] | |
| .iter() | |
| .any(|role| role.starts_with("EPISODE")) | |
| && text.chars().any(|ch| ch.is_alphabetic()) | |
| && !ep_markers.contains(&text.as_str()) | |
| { | |
| if !output[..index].iter().any(|role| role == "TITLE") { | |
| let previous_structural = (0..index) | |
| .rev() | |
| .find(|&cursor| groups[cursor].class_name != "SEP") | |
| .and_then(|cursor| output.get(cursor)) | |
| .map(String::as_str); | |
| if matches!(previous_structural, Some("SPECIAL")) { | |
| output[index] = "TITLE".to_string(); | |
| continue; | |
| } | |
| } | |
| if let Some(last_title) = output[..index].iter().rposition(|role| role == "TITLE") { | |
| let episode_since_title = output[last_title + 1..index] | |
| .iter() | |
| .any(|role| role.starts_with("EPISODE")); | |
| if !episode_since_title { | |
| output[index] = "TITLE".to_string(); | |
| continue; | |
| } | |
| } | |
| } | |
| if roles[index] == "TITLE" | |
| && matches!(text.to_ascii_lowercase().as_str(), "season" | "saison") | |
| && index + 2 < roles.len() | |
| && groups[index + 1].class_name == "SEP" | |
| && roles[index + 2].starts_with("EPISODE") | |
| { | |
| if !output[..index].iter().any(|role| role == "TITLE") { | |
| output[index] = "O".to_string(); | |
| output[index + 2] = "SEASON".to_string(); | |
| } | |
| continue; | |
| } | |
| if roles[index] == "TITLE" | |
| && text == text.to_ascii_uppercase() | |
| && roman.contains(&text.as_str()) | |
| { | |
| let previous_title = output[..index].iter().any(|role| role == "TITLE"); | |
| let next_structural = roles[index + 1..] | |
| .iter() | |
| .any(|role| role.starts_with("EPISODE") || role == "SPECIAL"); | |
| if previous_title && next_structural { | |
| output[index] = "SEASON".to_string(); | |
| continue; | |
| } | |
| } | |
| if roles[index].starts_with("EPISODE") && index + 4 < roles.len() { | |
| if groups[index + 1].class_name == "SEP" | |
| && ep_markers.contains(&group_text(tokens, &groups[index + 2]).as_str()) | |
| && groups[index + 3].class_name == "SEP" | |
| && roles[index + 4].starts_with("EPISODE") | |
| { | |
| output[index] = "TITLE".to_string(); | |
| output[index + 2] = "O".to_string(); | |
| } | |
| } | |
| if roles[index].starts_with("EPISODE") | |
| && !output[index + 1..].iter().any(|role| role == "TITLE") | |
| { | |
| let mut run = Vec::new(); | |
| let mut cursor = index + 1; | |
| while cursor < roles.len() { | |
| if groups[cursor].class_name == "SEP" { | |
| cursor += 1; | |
| continue; | |
| } | |
| if groups[cursor].class_name == "TEXT" | |
| && !matches!( | |
| roles[cursor].as_str(), | |
| "SOURCE" | "RESOLUTION" | "SEASON" | "SPECIAL" | |
| ) | |
| { | |
| run.push(cursor); | |
| cursor += 1; | |
| continue; | |
| } | |
| if !run.is_empty() { | |
| break; | |
| } | |
| cursor += 1; | |
| } | |
| if run.len() >= 2 { | |
| for item in run { | |
| output[item] = "TITLE".to_string(); | |
| } | |
| } | |
| } | |
| if roles[index].starts_with("EPISODE") { | |
| let previous_text = if index >= 1 { | |
| group_text(tokens, &groups[index - 1]) | |
| } else { | |
| String::new() | |
| }; | |
| let next_text = if index + 1 < roles.len() { | |
| group_text(tokens, &groups[index + 1]) | |
| } else { | |
| String::new() | |
| }; | |
| let previous_real_text = (0..index) | |
| .rev() | |
| .find(|&cursor| groups[cursor].class_name != "SEP") | |
| .map(|cursor| group_text(tokens, &groups[cursor])) | |
| .unwrap_or_default(); | |
| if previous_text.ends_with('第') && next_text.starts_with('期') { | |
| output[index] = "SEASON".to_string(); | |
| continue; | |
| } | |
| if matches!( | |
| previous_real_text.to_ascii_lowercase().as_str(), | |
| "lesson" | "part" | "no" | |
| ) { | |
| output[index] = "O".to_string(); | |
| continue; | |
| } | |
| if previous_real_text.contains("予告") | |
| || previous_real_text.eq_ignore_ascii_case("Spot") | |
| { | |
| output[index] = "SPECIAL".to_string(); | |
| continue; | |
| } | |
| if output[..index].iter().any(|role| role == "TITLE") | |
| && (output[..index].iter().enumerate().any(|(cursor, role)| { | |
| role == "TITLE" && is_special_title_phrase(&group_text(tokens, &groups[cursor])) | |
| })) | |
| && !output[..index] | |
| .iter() | |
| .any(|role| role.starts_with("EPISODE")) | |
| && text.chars().all(|ch| ch.is_ascii_digit()) | |
| && text.len() <= 3 | |
| { | |
| output[index] = "SPECIAL".to_string(); | |
| continue; | |
| } | |
| if previous_text.contains('点') | |
| || previous_text.contains('點') | |
| || previous_text.contains("晚上") | |
| || previous_text.contains("上午") | |
| || previous_text.contains("下午") | |
| || previous_text.contains('年') | |
| || previous_text.contains('月') | |
| || previous_text.contains('秒') | |
| || next_text.contains('点') | |
| || next_text.contains('點') | |
| || next_text.contains('半') | |
| || next_text.contains('月') | |
| || next_text.contains('日') | |
| || next_text.contains('秒') | |
| { | |
| output[index] = "O".to_string(); | |
| } | |
| } | |
| } | |
| output | |
| } | |
| fn title_candidates(groups: &[Group], roles: &[String]) -> Vec<(usize, usize)> { | |
| let mut candidates = Vec::new(); | |
| let mut index = 0; | |
| while index < roles.len() { | |
| if !is_title_role(&roles[index]) { | |
| index += 1; | |
| continue; | |
| } | |
| let start = index; | |
| index += 1; | |
| loop { | |
| if index < roles.len() | |
| && is_title_role(&roles[index]) | |
| && !(groups[index - 1].class_name == "BRACKET_TEXT" | |
| && groups[index].class_name == "BRACKET_TEXT") | |
| { | |
| index += 1; | |
| continue; | |
| } | |
| if index + 1 < roles.len() | |
| && roles[index] == "O" | |
| && groups[index].class_name == "SEP" | |
| && is_title_role(&roles[index + 1]) | |
| { | |
| index += 2; | |
| continue; | |
| } | |
| break; | |
| } | |
| candidates.push((start, index)); | |
| } | |
| candidates | |
| } | |
| fn enforce_single_title_candidate( | |
| tokens: &[String], | |
| groups: &[Group], | |
| roles: &[String], | |
| ) -> (Vec<String>, Vec<String>) { | |
| let candidates = title_candidates(groups, roles); | |
| if candidates.len() <= 1 { | |
| return (roles.to_vec(), Vec::new()); | |
| } | |
| let first_anchor = roles | |
| .iter() | |
| .position(|role| { | |
| role.starts_with("EPISODE") | |
| || matches!( | |
| role.as_str(), | |
| "SEASON" | "PATH_SEASON" | "SPECIAL" | "SOURCE" | "RESOLUTION" | |
| ) | |
| }) | |
| .unwrap_or(roles.len()); | |
| let before_anchor: Vec<(usize, usize)> = candidates | |
| .iter() | |
| .copied() | |
| .filter(|(_, end)| *end <= first_anchor) | |
| .collect(); | |
| let before_anchor_only_path_titles = !before_anchor.is_empty() | |
| && before_anchor.iter().all(|(start, end)| { | |
| (*start..*end) | |
| .all(|index| !is_title_role(&roles[index]) || is_path_title_role(&roles[index])) | |
| }); | |
| let selected_pool = if before_anchor.is_empty() || before_anchor_only_path_titles { | |
| &candidates | |
| } else { | |
| &before_anchor | |
| }; | |
| let mut selected_by_kind: HashMap<String, ((usize, usize), (isize, usize, usize))> = | |
| HashMap::new(); | |
| for (start, end) in selected_pool.iter().copied() { | |
| let score = ( | |
| title_candidate_score(tokens, groups, start, end), | |
| end, | |
| end - start, | |
| ); | |
| let key = title_candidate_key(tokens, groups, roles, start, end); | |
| match selected_by_kind.get(&key) { | |
| Some((_, best_score)) if *best_score >= score => {} | |
| _ => { | |
| selected_by_kind.insert(key, ((start, end), score)); | |
| } | |
| } | |
| } | |
| let selected: HashSet<(usize, usize)> = | |
| selected_by_kind.values().map(|(range, _)| *range).collect(); | |
| let mut output = roles.to_vec(); | |
| let mut dropped = Vec::new(); | |
| for (start, end) in candidates { | |
| if selected.contains(&(start, end)) { | |
| continue; | |
| } | |
| for index in start..end { | |
| if is_title_role(&output[index]) { | |
| output[index] = "O".to_string(); | |
| dropped.push(index.to_string()); | |
| } | |
| } | |
| } | |
| (output, dropped) | |
| } | |
| fn title_candidate_key( | |
| tokens: &[String], | |
| groups: &[Group], | |
| roles: &[String], | |
| start: usize, | |
| end: usize, | |
| ) -> String { | |
| let mut entities: Vec<String> = (start..end) | |
| .filter_map(|index| title_entity_from_role(&roles[index]).map(str::to_string)) | |
| .filter(|entity| entity != "TITLE") | |
| .collect(); | |
| entities.sort(); | |
| entities.dedup(); | |
| if entities.is_empty() { | |
| let text = candidate_text(tokens, groups, start, end); | |
| return title_role_for_text(&text, false); | |
| } | |
| entities.join("+") | |
| } | |
| fn title_candidate_score(tokens: &[String], groups: &[Group], start: usize, end: usize) -> isize { | |
| let text = (start..end) | |
| .filter(|&index| roles_candidate_text_group(&groups[index])) | |
| .map(|index| group_text(tokens, &groups[index])) | |
| .collect::<Vec<_>>() | |
| .join(""); | |
| let cleaned = text.trim(); | |
| if cleaned.is_empty() { | |
| return -1000; | |
| } | |
| let mut score = cleaned.chars().filter(|ch| ch.is_alphanumeric()).count() as isize; | |
| if VERSIONISH_TITLE_RE.is_match(cleaned) { | |
| score -= 500; | |
| } | |
| if matches!( | |
| cleaned.to_ascii_lowercase().as_str(), | |
| "国漫" | "國漫" | "anime" | "movie" | "movies" | |
| ) { | |
| score -= 500; | |
| } | |
| if title_noise_score_penalty(cleaned) { | |
| score -= 700; | |
| } | |
| score | |
| } | |
| fn title_noise_score_penalty(text: &str) -> bool { | |
| let normalized = text | |
| .replace(['_', '-', '.'], " ") | |
| .split_whitespace() | |
| .collect::<Vec<_>>() | |
| .join(" ") | |
| .to_ascii_lowercase(); | |
| normalized.contains("bdrip") | |
| || normalized.contains("webrip") | |
| || normalized.contains("web dl") | |
| || normalized.contains("bluray") | |
| || normalized.contains("full hd") | |
| || normalized.contains("hdtv") | |
| } | |
| fn roles_candidate_text_group(group: &Group) -> bool { | |
| matches!(group.class_name.as_str(), "TEXT" | "BRACKET_TEXT") | |
| } | |
| fn normalize_generated_tokens(tokens: &[String], labels: &[String]) -> (Vec<String>, Vec<String>) { | |
| let mut output_tokens = Vec::new(); | |
| let mut output_labels = Vec::new(); | |
| for (token, label) in tokens.iter().zip(labels.iter()) { | |
| for piece in split_generated_token(token) { | |
| output_labels.push(if label == "O" || is_standalone_separator(&piece) { | |
| "O".to_string() | |
| } else { | |
| label.clone() | |
| }); | |
| output_tokens.push(piece); | |
| } | |
| } | |
| (output_tokens, output_labels) | |
| } | |
| fn normalize_title_token(token: &str) -> (Vec<String>, Vec<String>) { | |
| let pieces = split_generated_token(token); | |
| let mut output_pieces = Vec::new(); | |
| let mut labels = Vec::new(); | |
| for piece in pieces { | |
| if is_standalone_separator(&piece) { | |
| output_pieces.push(piece); | |
| labels.push("O".to_string()); | |
| continue; | |
| } | |
| if let Some((pieces, piece_labels)) = split_sxe_token(&piece) { | |
| output_pieces.extend(pieces); | |
| labels.extend(piece_labels); | |
| continue; | |
| } | |
| if EPISODE_VERSION_RE.is_match(&compact_for_classify(&piece)) { | |
| output_pieces.push(piece); | |
| labels.push("B-EPISODE".to_string()); | |
| continue; | |
| } | |
| if CJK_SEASON_TOKEN_RE.is_match(&piece) || SEASON_RE.is_match(&piece) { | |
| output_pieces.push(piece); | |
| labels.push("B-SEASON".to_string()); | |
| continue; | |
| } | |
| if EPISODE_CJK_RE.is_match(&piece) { | |
| output_pieces.push(piece); | |
| labels.push("B-EPISODE".to_string()); | |
| continue; | |
| } | |
| if let Some(caps) = ASCII_SEASON_SUFFIX_RE.captures(&piece) { | |
| let before = caps.get(1).map(|m| m.as_str()).unwrap_or_default(); | |
| let season = caps.get(2).map(|m| m.as_str()).unwrap_or_default(); | |
| if !before.is_empty() { | |
| output_pieces.push(before.to_string()); | |
| labels.push("B-TITLE".to_string()); | |
| } | |
| output_pieces.push(season.to_string()); | |
| labels.push("B-SEASON".to_string()); | |
| continue; | |
| } | |
| if let Some(caps) = CJK_SEASON_EMBEDDED_RE.captures(&piece) { | |
| let before = caps.get(1).map(|m| m.as_str()).unwrap_or_default(); | |
| let season = caps.get(2).map(|m| m.as_str()).unwrap_or_default(); | |
| let after = caps.get(3).map(|m| m.as_str()).unwrap_or_default(); | |
| if !before.is_empty() { | |
| output_pieces.push(before.to_string()); | |
| labels.push("B-TITLE".to_string()); | |
| } | |
| output_pieces.push(season.to_string()); | |
| labels.push("B-SEASON".to_string()); | |
| if !after.is_empty() { | |
| output_pieces.push(after.to_string()); | |
| labels.push("O".to_string()); | |
| } | |
| continue; | |
| } | |
| if let Some(caps) = CJK_EPISODE_EMBEDDED_RE.captures(&piece) { | |
| let before = caps.get(1).map(|m| m.as_str()).unwrap_or_default(); | |
| let episode = caps.get(2).map(|m| m.as_str()).unwrap_or_default(); | |
| let after = caps.get(3).map(|m| m.as_str()).unwrap_or_default(); | |
| if !before.is_empty() { | |
| output_pieces.push(before.to_string()); | |
| labels.push("B-TITLE".to_string()); | |
| } | |
| output_pieces.push(episode.to_string()); | |
| labels.push("B-EPISODE".to_string()); | |
| if !after.is_empty() { | |
| output_pieces.push(after.to_string()); | |
| labels.push("O".to_string()); | |
| } | |
| continue; | |
| } | |
| if let Some(caps) = CJK_TITLE_TRAILING_EPISODE_RE.captures(&piece) { | |
| let before = caps.get(1).map(|m| m.as_str()).unwrap_or_default(); | |
| let episode = caps.get(2).map(|m| m.as_str()).unwrap_or_default(); | |
| if before.contains("銀河鉄道") && episode == "999" { | |
| output_pieces.push(before.to_string()); | |
| labels.push("B-TITLE".to_string()); | |
| output_pieces.push(episode.to_string()); | |
| labels.push("B-TITLE".to_string()); | |
| continue; | |
| } | |
| if !before.is_empty() { | |
| output_pieces.push(before.to_string()); | |
| labels.push("B-TITLE".to_string()); | |
| } | |
| output_pieces.push(episode.to_string()); | |
| labels.push("B-EPISODE".to_string()); | |
| continue; | |
| } | |
| output_pieces.push(piece); | |
| labels.push("B-TITLE".to_string()); | |
| } | |
| (output_pieces, labels) | |
| } | |
| fn split_generated_token(token: &str) -> Vec<String> { | |
| let mut pieces = Vec::new(); | |
| let mut current = String::new(); | |
| for ch in token.chars() { | |
| if ch.is_whitespace() || !ch.is_alphanumeric() { | |
| if !current.is_empty() { | |
| pieces.push(std::mem::take(&mut current)); | |
| } | |
| pieces.push(ch.to_string()); | |
| } else { | |
| current.push(ch); | |
| } | |
| } | |
| if !current.is_empty() { | |
| pieces.push(current); | |
| } | |
| pieces | |
| } | |
| fn is_standalone_separator(token: &str) -> bool { | |
| token.chars().count() == 1 | |
| && token | |
| .chars() | |
| .next() | |
| .is_some_and(|ch| ch.is_whitespace() || !ch.is_alphanumeric()) | |
| } | |
| fn is_unicode_roman_season(token: &str) -> bool { | |
| matches!( | |
| token, | |
| "Ⅰ" | "Ⅱ" | "Ⅲ" | "Ⅳ" | "Ⅴ" | "Ⅵ" | "Ⅶ" | "Ⅷ" | "Ⅸ" | "Ⅹ" | "Ⅺ" | "Ⅻ" | |
| ) | |
| } | |
| fn split_cjk_title_lang_prefix(token: &str) -> Option<(Vec<String>, Vec<String>)> { | |
| let caps = CJK_TITLE_LANG_PREFIX_RE.captures(token)?; | |
| let title = caps.get(1)?.as_str(); | |
| let lang = caps.get(2)?.as_str(); | |
| let marker = caps.get(3).map(|m| m.as_str()).unwrap_or_default(); | |
| if title.chars().count() < 2 { | |
| return None; | |
| } | |
| let mut pieces = vec![title.to_string(), lang.to_string()]; | |
| let mut labels = vec!["B-TITLE".to_string(), "B-SOURCE".to_string()]; | |
| if !marker.is_empty() { | |
| pieces.push(marker.to_string()); | |
| labels.push("O".to_string()); | |
| } | |
| Some((pieces, labels)) | |
| } | |
| fn project_refined_tokens( | |
| tokens: &[String], | |
| groups: &[Group], | |
| roles: &[String], | |
| ) -> (Vec<String>, Vec<String>) { | |
| let mut output_tokens = Vec::new(); | |
| let mut output_labels = Vec::new(); | |
| for (group_index, group) in groups.iter().enumerate() { | |
| let mut role = roles.get(group_index).map(String::as_str).unwrap_or("O"); | |
| if matches!(group.class_name.as_str(), "SEP" | "PATH" | "EMPTY") { | |
| role = "O"; | |
| } | |
| for &index in &group.indices { | |
| let token = &tokens[index]; | |
| if matches!( | |
| role, | |
| "EPISODE" | |
| | "EPISODE_VERSION" | |
| | "EPISODE_RANGE" | |
| | "SOURCE" | |
| | "RESOLUTION" | |
| | "SEASON" | |
| | "PATH_SEASON" | |
| ) { | |
| if matches!(role, "SEASON" | "PATH_SEASON") { | |
| if let Some((pieces, labels)) = split_season_token(token) { | |
| output_tokens.extend(pieces); | |
| output_labels.extend(labels); | |
| continue; | |
| } | |
| } | |
| if role == "SOURCE" { | |
| if let Some((pieces, labels)) = split_cjk_title_lang_prefix(token) { | |
| output_tokens.extend(pieces); | |
| output_labels.extend(labels); | |
| continue; | |
| } | |
| } | |
| if matches!(role, "EPISODE" | "EPISODE_VERSION" | "EPISODE_RANGE") { | |
| if let Some((pieces, labels)) = split_sxe_token(&strip_wrapper(token)) { | |
| output_tokens.extend(pieces); | |
| output_labels.extend(labels); | |
| continue; | |
| } | |
| if let Some((pieces, labels)) = split_episode_token(&strip_wrapper(token)) { | |
| output_tokens.extend(pieces); | |
| output_labels.extend(labels); | |
| continue; | |
| } | |
| } | |
| for piece in split_refined_token(token) { | |
| if matches!(role, "EPISODE" | "EPISODE_VERSION" | "EPISODE_RANGE") { | |
| if let Some((pieces, labels)) = split_season_token(&piece) { | |
| output_tokens.extend(pieces); | |
| output_labels.extend(labels); | |
| continue; | |
| } | |
| if let Some((pieces, labels)) = split_episode_token(&piece) { | |
| output_tokens.extend(pieces); | |
| output_labels.extend(labels); | |
| continue; | |
| } | |
| } | |
| let label = label_for_refined_piece(&piece, role, &group.class_name); | |
| let (pieces, labels) = normalize_generated_tokens(&[piece], &[label]); | |
| output_tokens.extend(pieces); | |
| output_labels.extend(labels); | |
| } | |
| } else { | |
| if is_title_role(role) && matches!(token.as_str(), "第" | "話" | "话" | "回" | "集") | |
| { | |
| output_tokens.push(token.clone()); | |
| output_labels.push("O".to_string()); | |
| continue; | |
| } | |
| if is_title_role(role) && token.ends_with('第') && token.chars().count() > 1 { | |
| let trimmed = token.trim_end_matches('第').to_string(); | |
| let (pieces, labels) = normalize_generated_tokens( | |
| &[trimmed, "第".to_string()], | |
| &["B-TITLE".to_string(), "O".to_string()], | |
| ); | |
| output_tokens.extend(pieces); | |
| output_labels.extend(labels); | |
| continue; | |
| } | |
| if is_title_role(role) { | |
| let (pieces, labels) = normalize_title_token(token); | |
| output_tokens.extend(pieces); | |
| output_labels.extend(labels); | |
| continue; | |
| } | |
| let (pieces, labels) = | |
| normalize_generated_tokens(&[token.clone()], &[role_label(role)]); | |
| output_tokens.extend(pieces); | |
| output_labels.extend(labels); | |
| } | |
| } | |
| } | |
| (output_tokens, output_labels) | |
| } | |
| fn smooth_title_spans(tokens: &[String], labels: &[String]) -> Vec<String> { | |
| let joiners = [ | |
| " ", ".", "-", "_", "·", "・", "×", "/", "/", "'", "’", ":", ":", "!", "!", "?", "?", | |
| ";", ";", ",", ",", "、", "。", "~", "~", "-", "+", "+", "(", ")", "(", ")", "[", | |
| "]", "【", "】", "<", ">", "<", ">", "「", "」", "「", "」", "《", "》", "☆", "♪", "`", | |
| "@", "‐", "‑", "–", "—", "−", "$", "$", "∽", "꞉", "♥", | |
| ]; | |
| let title_terminal_punctuation = ["!", "!", "?", "?"]; | |
| let entity_joiners = [ | |
| " ", ".", "-", "_", "·", "・", "×", "/", "/", "'", "’", ":", ":", "!", "!", "?", "?", | |
| ";", ";", ",", ",", "、", "。", "~", "~", "-", "+", "+", "(", ")", "(", ")", "[", | |
| "]", "【", "】", "<", ">", "<", ">", "「", "」", "「", "」", "《", "》", "☆", "♪", "`", | |
| "@", "&", "&", "‐", "‑", "–", "—", "−", "$", "$", "∽", "꞉", "♥", | |
| ]; | |
| let mut output = labels.to_vec(); | |
| for (index, (token, label)) in tokens.iter().zip(labels.iter()).enumerate() { | |
| if label == "B-TITLE" | |
| && token == "TV" | |
| && index + 1 < tokens.len() | |
| && tokens[index + 1] == "アニメ" | |
| && output[index + 2..].iter().any(|label| label == "B-TITLE") | |
| { | |
| output[index] = "O".to_string(); | |
| output[index + 1] = "O".to_string(); | |
| continue; | |
| } | |
| if label == "B-TITLE" | |
| && token == "アニメ" | |
| && output[index + 1..].iter().any(|label| label == "B-TITLE") | |
| { | |
| output[index] = "O".to_string(); | |
| continue; | |
| } | |
| if label == "B-TITLE" && token.eq_ignore_ascii_case("part") { | |
| let next_number = (index + 1..tokens.len()).find(|&cursor| { | |
| !joiners.contains(&tokens[cursor].as_str()) | |
| && !tokens[cursor].chars().all(char::is_whitespace) | |
| }); | |
| let nearby_lupin = tokens[..index] | |
| .iter() | |
| .rev() | |
| .take(8) | |
| .any(|item| item.eq_ignore_ascii_case("lupin")) | |
| || tokens[index + 1..] | |
| .iter() | |
| .take(12) | |
| .any(|item| item.eq_ignore_ascii_case("lupin")); | |
| if nearby_lupin | |
| && next_number.is_some_and(|cursor| { | |
| tokens[cursor].chars().all(|ch| ch.is_ascii_digit()) | |
| && tokens[cursor].len() <= 2 | |
| }) | |
| { | |
| output[index] = "B-SEASON".to_string(); | |
| continue; | |
| } | |
| } | |
| if label == "B-TITLE" | |
| && token.chars().all(|ch| ch.is_ascii_digit()) | |
| && token.len() == 3 | |
| && index + 1 < tokens.len() | |
| && matches!(tokens[index + 1].as_str(), "「" | "「" | "\"" | "'") | |
| { | |
| output[index] = "B-EPISODE".to_string(); | |
| let mut cursor = index + 1; | |
| while cursor < tokens.len() { | |
| output[cursor] = "O".to_string(); | |
| if matches!(tokens[cursor].as_str(), "」" | "」" | "\"" | "'") && cursor > index + 1 | |
| { | |
| break; | |
| } | |
| cursor += 1; | |
| } | |
| continue; | |
| } | |
| if label == "B-TITLE" && matches!(token.as_str(), "中日" | "日中" | "英日" | "日英") | |
| { | |
| let next_word = (index + 1..tokens.len()) | |
| .find(|&cursor| tokens[cursor].chars().any(|ch| ch.is_alphanumeric())); | |
| if next_word | |
| .is_some_and(|cursor| labels[cursor] == "B-SOURCE" && tokens[cursor].contains('语')) | |
| { | |
| output[index] = "B-SOURCE".to_string(); | |
| continue; | |
| } | |
| } | |
| if label == "B-TITLE" && matches!(token.to_ascii_lowercase().as_str(), "ep" | "episode") { | |
| let next_episode = (index + 1..tokens.len()).find(|&cursor| { | |
| !joiners.contains(&tokens[cursor].as_str()) || labels[cursor] != "O" | |
| }); | |
| if next_episode.is_some_and(|cursor| labels[cursor] == "B-EPISODE") { | |
| output[index] = "O".to_string(); | |
| continue; | |
| } | |
| } | |
| if label == "B-TITLE" && is_unicode_roman_season(token) { | |
| let previous_title_word = (0..index).rev().find(|&cursor| { | |
| output[cursor] == "B-TITLE" | |
| && tokens[cursor] | |
| .chars() | |
| .any(|ch| ch.is_alphanumeric() || ('\u{4e00}'..='\u{9fff}').contains(&ch)) | |
| }); | |
| let later_episode = | |
| (index + 1..tokens.len()).any(|cursor| labels[cursor] == "B-EPISODE"); | |
| if previous_title_word.is_none() && later_episode { | |
| output[index] = "B-SEASON".to_string(); | |
| continue; | |
| } | |
| let previous_word = | |
| previous_title_word.map(|cursor| tokens[cursor].to_ascii_lowercase()); | |
| if previous_title_word.is_some() && !matches!(previous_word.as_deref(), Some("lupin")) { | |
| output[index] = "B-SEASON".to_string(); | |
| continue; | |
| } | |
| } | |
| if label == "B-TITLE" | |
| && (ORDINAL_SEASON_TOKEN_RE.is_match(token) | |
| || WORD_ORDINAL_SEASON_TOKEN_RE.is_match(token)) | |
| { | |
| let next_word = (index + 1..tokens.len()).find(|&cursor| { | |
| !joiners.contains(&tokens[cursor].as_str()) | |
| && tokens[cursor].chars().any(|ch| ch.is_alphabetic()) | |
| }); | |
| if next_word.is_some_and(|cursor| { | |
| labels[cursor] == "B-TITLE" && SEASON_WORD_RE.is_match(&tokens[cursor]) | |
| }) { | |
| output[index] = "B-SEASON".to_string(); | |
| if let Some(cursor) = next_word { | |
| for joiner_index in index + 1..cursor { | |
| if joiners.contains(&tokens[joiner_index].as_str()) { | |
| output[joiner_index] = "B-SEASON".to_string(); | |
| } | |
| } | |
| output[cursor] = "B-SEASON".to_string(); | |
| } | |
| continue; | |
| } | |
| } | |
| if label == "O" | |
| && (EPISODE_CJK_RE.is_match(token) | |
| || EPISODE_VALUE_RE.is_match(token) | |
| || EPISODE_RANGE_RE.is_match(token)) | |
| { | |
| output[index] = "B-EPISODE".to_string(); | |
| continue; | |
| } | |
| if label == "O" && token.chars().all(|ch| ch.is_ascii_digit()) { | |
| let previous_non_space = (0..index) | |
| .rev() | |
| .find(|&cursor| !tokens[cursor].chars().all(char::is_whitespace)); | |
| let next_non_space = (index + 1..tokens.len()) | |
| .find(|&cursor| !tokens[cursor].chars().all(char::is_whitespace)); | |
| if previous_non_space.is_some_and(|cursor| tokens[cursor] == "#") { | |
| output[index] = "B-EPISODE".to_string(); | |
| if next_non_space.is_some_and(|cursor| matches!(tokens[cursor].as_str(), "-" | "~")) | |
| { | |
| if let Some(separator) = next_non_space { | |
| output[separator] = "B-EPISODE".to_string(); | |
| if let Some(right) = (separator + 1..tokens.len()) | |
| .find(|&cursor| !tokens[cursor].chars().all(char::is_whitespace)) | |
| { | |
| if tokens[right].chars().all(|ch| ch.is_ascii_digit()) { | |
| output[right] = "B-EPISODE".to_string(); | |
| } | |
| } | |
| } | |
| } | |
| continue; | |
| } | |
| if previous_non_space.is_some_and(|cursor| tokens[cursor] == "第") | |
| && next_non_space.is_some_and(|cursor| { | |
| matches!(tokens[cursor].as_str(), "话" | "話" | "回" | "集") | |
| || tokens[cursor].starts_with('话') | |
| || tokens[cursor].starts_with('話') | |
| || tokens[cursor].starts_with('回') | |
| || tokens[cursor].starts_with('集') | |
| }) | |
| { | |
| if let Some(cursor) = previous_non_space { | |
| output[cursor] = "B-EPISODE".to_string(); | |
| } | |
| output[index] = "B-EPISODE".to_string(); | |
| if let Some(cursor) = next_non_space { | |
| if matches!(tokens[cursor].as_str(), "话" | "話" | "回" | "集") { | |
| output[cursor] = "B-EPISODE".to_string(); | |
| } | |
| } | |
| continue; | |
| } | |
| } | |
| if matches!(label.as_str(), "B-TITLE" | "O") | |
| && token.chars().all(|ch| ch.is_ascii_digit()) | |
| && token.len() <= 3 | |
| { | |
| let previous_word = (0..index) | |
| .rev() | |
| .find(|&cursor| { | |
| !joiners.contains(&tokens[cursor].as_str()) | |
| && tokens[cursor].chars().any(|ch| ch.is_alphabetic()) | |
| }) | |
| .map(|cursor| tokens[cursor].to_ascii_lowercase()); | |
| let next_structural = (index + 1..tokens.len()) | |
| .find(|&cursor| !joiners.contains(&tokens[cursor].as_str())) | |
| .map(|cursor| tokens[cursor].as_str()); | |
| let next_non_space = (index + 1..tokens.len()) | |
| .find(|&cursor| !tokens[cursor].chars().all(char::is_whitespace)) | |
| .map(|cursor| tokens[cursor].as_str()); | |
| let later_technical_block = output[index + 1..] | |
| .iter() | |
| .any(|label| matches!(label.as_str(), "B-SOURCE" | "B-RESOLUTION")); | |
| let nearby_lupin_part = previous_word.as_deref() == Some("part") | |
| && (tokens[..index] | |
| .iter() | |
| .rev() | |
| .take(8) | |
| .any(|item| item.eq_ignore_ascii_case("lupin")) | |
| || tokens[index + 1..] | |
| .iter() | |
| .take(12) | |
| .any(|item| item.eq_ignore_ascii_case("lupin"))); | |
| if nearby_lupin_part { | |
| output[index] = "B-SEASON".to_string(); | |
| continue; | |
| } | |
| let followed_by_title_word = (index + 1..tokens.len()) | |
| .find(|&cursor| { | |
| !joiners.contains(&tokens[cursor].as_str()) | |
| && !matches!( | |
| tokens[cursor].as_str(), | |
| "-" | "-" | "," | "," | ":" | ":" | |
| ) | |
| }) | |
| .is_some_and(|cursor| { | |
| !matches!( | |
| tokens[cursor].as_str(), | |
| "[" | "【" | "(" | "(" | "]" | "】" | |
| ) && output.get(cursor).is_some_and(|label| label == "B-TITLE") | |
| && tokens[cursor].chars().any(|ch| ch.is_alphabetic()) | |
| }); | |
| if followed_by_title_word && matches!(previous_word.as_deref(), Some("movie" | "part")) | |
| { | |
| output[index] = "B-TITLE".to_string(); | |
| continue; | |
| } | |
| if (later_technical_block | |
| || next_non_space.is_some_and(|token| matches!(token, "[" | "【" | "(" | "(")) | |
| || next_structural.is_some_and(|token| matches!(token, "[" | "【" | "(" | "("))) | |
| && matches!(previous_word.as_deref(), Some("movie" | "part")) | |
| { | |
| output[index] = "B-SPECIAL".to_string(); | |
| continue; | |
| } | |
| let eien_title_number = token == "831" | |
| && previous_word.as_deref() == Some("no") | |
| && (0..index).any(|cursor| { | |
| output[cursor] == "B-TITLE" && tokens[cursor].eq_ignore_ascii_case("Eien") | |
| }); | |
| if eien_title_number { | |
| for joiner_index in (0..index).rev() { | |
| if tokens[joiner_index].eq_ignore_ascii_case("no") { | |
| break; | |
| } | |
| if joiners.contains(&tokens[joiner_index].as_str()) { | |
| output[joiner_index] = "B-TITLE".to_string(); | |
| } | |
| } | |
| output[index] = "B-TITLE".to_string(); | |
| continue; | |
| } | |
| } | |
| if label == "O" && token.chars().all(|ch| ch.is_ascii_digit()) && token.len() <= 3 { | |
| let previous_non_space = (0..index) | |
| .rev() | |
| .find(|&cursor| !tokens[cursor].chars().all(char::is_whitespace)); | |
| let next_non_space = (index + 1..tokens.len()) | |
| .find(|&cursor| !tokens[cursor].chars().all(char::is_whitespace)); | |
| if previous_non_space | |
| .is_some_and(|cursor| matches!(tokens[cursor].as_str(), "[" | "【")) | |
| && next_non_space | |
| .is_some_and(|cursor| matches!(tokens[cursor].as_str(), "]" | "】")) | |
| && output[..index].iter().any(|label| label == "B-TITLE") | |
| && output[index + 1..] | |
| .iter() | |
| .any(|label| matches!(label.as_str(), "B-SOURCE" | "B-RESOLUTION")) | |
| { | |
| output[index] = "B-EPISODE".to_string(); | |
| continue; | |
| } | |
| if previous_non_space | |
| .is_some_and(|cursor| matches!(tokens[cursor].as_str(), "-" | "-")) | |
| && output[..index].iter().any(|label| label == "B-TITLE") | |
| && output[index + 1..] | |
| .iter() | |
| .any(|label| matches!(label.as_str(), "B-SOURCE" | "B-RESOLUTION")) | |
| { | |
| output[index] = "B-EPISODE".to_string(); | |
| continue; | |
| } | |
| if next_non_space.is_none() | |
| && previous_non_space.is_some_and(|cursor| { | |
| output[cursor] == "B-TITLE" | |
| && tokens[cursor].chars().any(|ch| { | |
| ('\u{4e00}'..='\u{9fff}').contains(&ch) | |
| || ('\u{3040}'..='\u{30ff}').contains(&ch) | |
| }) | |
| }) | |
| { | |
| output[index] = "B-EPISODE".to_string(); | |
| continue; | |
| } | |
| } | |
| if label == "B-EPISODE" && token.chars().all(|ch| ch.is_ascii_digit()) { | |
| let previous_non_space = (0..index) | |
| .rev() | |
| .find(|&cursor| !tokens[cursor].chars().all(char::is_whitespace)); | |
| let next_non_space = (index + 1..tokens.len()) | |
| .find(|&cursor| !tokens[cursor].chars().all(char::is_whitespace)); | |
| if previous_non_space.is_some_and(|cursor| tokens[cursor] == "第") | |
| && next_non_space.is_some_and(|cursor| { | |
| matches!(tokens[cursor].as_str(), "话" | "話" | "回" | "集") | |
| }) | |
| { | |
| if let Some(cursor) = previous_non_space { | |
| output[cursor] = "B-EPISODE".to_string(); | |
| } | |
| if let Some(cursor) = next_non_space { | |
| output[cursor] = "B-EPISODE".to_string(); | |
| } | |
| } | |
| if previous_non_space | |
| .is_some_and(|cursor| matches!(tokens[cursor].as_str(), "×" | "x" | "X")) | |
| { | |
| let left_title = (0..previous_non_space.unwrap()) | |
| .rev() | |
| .find(|&cursor| labels[cursor] != "O") | |
| .is_some_and(|cursor| labels[cursor] == "B-TITLE"); | |
| if left_title { | |
| output[index] = "B-TITLE".to_string(); | |
| if let Some(next_word) = (index + 1..tokens.len()).find(|&cursor| { | |
| labels[cursor] == "O" && tokens[cursor].chars().any(|ch| ch.is_alphabetic()) | |
| }) { | |
| output[next_word] = "B-TITLE".to_string(); | |
| } | |
| continue; | |
| } | |
| } | |
| let mut previous_word = None; | |
| for cursor in (0..index).rev() { | |
| if matches!(tokens[cursor].as_str(), "]" | "】" | ")" | ")") { | |
| break; | |
| } | |
| if joiners.contains(&tokens[cursor].as_str()) { | |
| continue; | |
| } | |
| if tokens[cursor].chars().any(|ch| ch.is_alphabetic()) { | |
| previous_word = Some(tokens[cursor].to_ascii_lowercase()); | |
| } | |
| break; | |
| } | |
| let previous_non_space = (0..index) | |
| .rev() | |
| .find(|&cursor| !tokens[cursor].chars().all(char::is_whitespace)) | |
| .map(|cursor| tokens[cursor].as_str()); | |
| if matches!(previous_word.as_deref(), Some("lesson" | "part")) | |
| || (previous_word.as_deref() == Some("no") && previous_non_space == Some(".")) | |
| { | |
| output[index] = "O".to_string(); | |
| continue; | |
| } | |
| } | |
| if label != "O" || !entity_joiners.contains(&token.as_str()) { | |
| continue; | |
| } | |
| let mut left = index as isize - 1; | |
| while left >= 0 | |
| && joiners.contains(&tokens[left as usize].as_str()) | |
| && labels[left as usize] == "O" | |
| { | |
| left -= 1; | |
| } | |
| let mut right = index + 1; | |
| while right < tokens.len() | |
| && joiners.contains(&tokens[right].as_str()) | |
| && labels[right] == "O" | |
| { | |
| right += 1; | |
| } | |
| if left >= 0 && right < tokens.len() { | |
| let left_label = output[left as usize].clone(); | |
| let right_label = labels[right].clone(); | |
| if left_label == right_label && matches!(left_label.as_str(), "B-TITLE" | "B-GROUP") { | |
| output[index] = left_label.clone(); | |
| } | |
| if token == "." && left_label == "B-EPISODE" && right_label == "B-EPISODE" { | |
| output[index] = "B-EPISODE".to_string(); | |
| } | |
| } | |
| if title_terminal_punctuation.contains(&token.as_str()) && index > 0 { | |
| let left_label = &output[index - 1]; | |
| if left_label == "B-TITLE" { | |
| output[index] = "B-TITLE".to_string(); | |
| } | |
| } | |
| if matches!( | |
| token.as_str(), | |
| "]" | "】" | ")" | ")" | ">" | ">" | "」" | "」" | |
| ) && index > 0 | |
| && output[index - 1] == "B-TITLE" | |
| && title_span_has_labeled_opener(&tokens[..index], &output[..index], token) | |
| { | |
| output[index] = "B-TITLE".to_string(); | |
| } | |
| } | |
| output | |
| } | |
| fn title_span_has_labeled_opener(tokens: &[String], labels: &[String], closer: &str) -> bool { | |
| for (token, label) in tokens.iter().zip(labels.iter()).rev() { | |
| if label != "B-TITLE" { | |
| return false; | |
| } | |
| if closer_matches_opener(closer, token) { | |
| return true; | |
| } | |
| } | |
| false | |
| } | |
| fn closer_matches_opener(closer: &str, opener: &str) -> bool { | |
| matches!( | |
| (closer, opener), | |
| ("]", "[") | |
| | ("】", "【") | |
| | (")", "(") | |
| | (")", "(") | |
| | (">", "<") | |
| | (">", "<") | |
| | ("」", "「") | |
| | ("」", "「") | |
| ) | |
| } | |
| fn retag_semantic_labels(tokens: &[String], labels: &[String]) -> Vec<String> { | |
| let last_path = tokens | |
| .iter() | |
| .rposition(|token| token == "/" || token == "\\"); | |
| let mut output = labels.to_vec(); | |
| for index in 0..labels.len() { | |
| let Some(entity) = label_entity(&labels[index]) else { | |
| continue; | |
| }; | |
| let prefix = if labels[index].starts_with("I-") { | |
| "I" | |
| } else { | |
| "B" | |
| }; | |
| if entity == "TITLE" { | |
| let path_title = last_path.is_some_and(|path_index| index < path_index); | |
| let suffix = title_suffix_for_label_index(tokens, labels, index); | |
| output[index] = format!( | |
| "{prefix}-{}_{}", | |
| if path_title { "PATH_TITLE" } else { "TITLE" }, | |
| suffix | |
| ); | |
| } else if entity == "SEASON" && last_path.is_some_and(|path_index| index < path_index) { | |
| output[index] = format!("{prefix}-PATH_SEASON"); | |
| } | |
| } | |
| output | |
| } | |
| fn title_suffix_for_label_index( | |
| tokens: &[String], | |
| labels: &[String], | |
| index: usize, | |
| ) -> &'static str { | |
| if let Some(suffix) = direct_title_suffix(&tokens[index]) { | |
| return suffix; | |
| } | |
| let left = nearest_title_suffix(tokens, labels, index, true); | |
| let right = nearest_title_suffix(tokens, labels, index, false); | |
| match (left, right) { | |
| (Some(left), Some(right)) if left == right => left, | |
| (Some(left), None) => left, | |
| (None, Some(right)) => right, | |
| _ => "MIXED", | |
| } | |
| } | |
| fn nearest_title_suffix( | |
| tokens: &[String], | |
| labels: &[String], | |
| index: usize, | |
| search_left: bool, | |
| ) -> Option<&'static str> { | |
| let mut cursor = index as isize; | |
| loop { | |
| cursor += if search_left { -1 } else { 1 }; | |
| if cursor < 0 || cursor as usize >= tokens.len() { | |
| return None; | |
| } | |
| let cursor = cursor as usize; | |
| if !is_title_label(&labels[cursor]) { | |
| if tokens[cursor] | |
| .chars() | |
| .all(|ch| ch.is_whitespace() || !ch.is_alphanumeric()) | |
| { | |
| continue; | |
| } | |
| return None; | |
| } | |
| if let Some(suffix) = direct_title_suffix(&tokens[cursor]) { | |
| return Some(suffix); | |
| } | |
| } | |
| } | |
| fn direct_title_suffix(token: &str) -> Option<&'static str> { | |
| if !token.chars().any(|ch| { | |
| ch.is_ascii_alphabetic() | |
| || ('\u{3040}'..='\u{30ff}').contains(&ch) | |
| || ('\u{31f0}'..='\u{31ff}').contains(&ch) | |
| || ('\u{4e00}'..='\u{9fff}').contains(&ch) | |
| }) { | |
| return None; | |
| } | |
| Some(title_language_suffix(token)) | |
| } | |
| fn dmhy_record(filename: &str, template_id: &str, roles: &[String]) -> Option<Record> { | |
| let (key, tokens, _classes, groups) = template_key_for_filename(filename); | |
| if groups.len() != roles.len() { | |
| return None; | |
| } | |
| let roles = adjust_contextual_roles(&tokens, &groups, roles); | |
| let roles = refine_semantic_roles(&tokens, &groups, &roles); | |
| let (roles, dropped) = enforce_single_title_candidate(&tokens, &groups, &roles); | |
| let (tokens, labels) = project_refined_tokens(&tokens, &groups, &roles); | |
| let (tokens, labels) = repair_compact_sxe_tokens(tokens, labels); | |
| let labels = smooth_title_spans(&tokens, &labels); | |
| let labels = retag_semantic_labels(&tokens, &labels); | |
| if tokens.len() != labels.len() { | |
| return None; | |
| } | |
| Some(Record { | |
| filename: filename.to_string(), | |
| tokens, | |
| labels, | |
| template_id: template_id.to_string(), | |
| template: key, | |
| source_filename: None, | |
| path_trimmed: None, | |
| dropped_title_candidate_positions: if dropped.is_empty() { | |
| None | |
| } else { | |
| Some(dropped) | |
| }, | |
| }) | |
| } | |
| mod tests { | |
| use super::*; | |
| fn schema_labels_for(filename: &str) -> Vec<(String, String)> { | |
| let (key, _, _, _) = template_key_for_filename(filename); | |
| let roles = suggested_roles(&key); | |
| let record = dmhy_record(filename, "tpl_test", &roles).unwrap(); | |
| record.tokens.into_iter().zip(record.labels).collect() | |
| } | |
| fn labels_for(filename: &str) -> Vec<(String, String)> { | |
| schema_labels_for(filename) | |
| .into_iter() | |
| .map(|(token, label)| (token, legacy_label(&label))) | |
| .collect() | |
| } | |
| fn legacy_label(label: &str) -> String { | |
| let Some(entity) = label_entity(label) else { | |
| return label.to_string(); | |
| }; | |
| let prefix = if label.starts_with("I-") { "I" } else { "B" }; | |
| if is_title_entity(entity) { | |
| return format!("{prefix}-TITLE"); | |
| } | |
| if entity == "PATH_SEASON" { | |
| return format!("{prefix}-SEASON"); | |
| } | |
| if entity == "TAG" { | |
| return format!("{prefix}-SPECIAL"); | |
| } | |
| label.to_string() | |
| } | |
| fn rich_title_candidates_keep_readable_spacing() { | |
| let row = rich_annotation_for( | |
| "(1998) Initial D First Stage [1080p BDRip AVC AAC DTS-HD]/Initial D First Stage - 01 [1080p BDRip AVC AAC DTS-HD]", | |
| ); | |
| assert_eq!( | |
| row.pointer("/segments/1/candidates/0/text") | |
| .and_then(Value::as_str), | |
| Some("Initial D First Stage") | |
| ); | |
| } | |
| fn semantic_schema_roles_cover_multilingual_tags_paths_and_music_skips() { | |
| let gm = schema_labels_for( | |
| "[GM-Team][国漫][神印王座][Throne of Seal][2022][200][AVC][GB][1080P].mp4", | |
| ); | |
| assert!(gm.contains(&("GM".to_string(), "B-GROUP".to_string()))); | |
| assert!(gm.contains(&("国漫".to_string(), "B-TAG".to_string()))); | |
| assert!(gm.contains(&("神印王座".to_string(), "B-TITLE_CHS".to_string()))); | |
| assert!(gm.contains(&("Throne".to_string(), "B-TITLE_LATIN".to_string()))); | |
| assert!(gm.contains(&("Seal".to_string(), "B-TITLE_LATIN".to_string()))); | |
| assert!(gm.contains(&("2022".to_string(), "B-TAG".to_string()))); | |
| assert!(gm.contains(&("200".to_string(), "B-EPISODE".to_string()))); | |
| let sky = schema_labels_for("[Skytree][海贼王][One_Piece][918][GB_JP][1080P]"); | |
| assert!(sky.contains(&("Skytree".to_string(), "B-GROUP".to_string()))); | |
| assert!(sky.contains(&("海贼王".to_string(), "B-TITLE_CHS".to_string()))); | |
| assert!(sky.contains(&("One".to_string(), "B-TITLE_LATIN".to_string()))); | |
| assert!(sky.contains(&("Piece".to_string(), "B-TITLE_LATIN".to_string()))); | |
| assert!(sky.contains(&("918".to_string(), "B-EPISODE".to_string()))); | |
| let farming = schema_labels_for("異世界悠閒農家 2 - 06"); | |
| assert!(farming.contains(&("異世界悠閒農家".to_string(), "B-TITLE_CHT".to_string()))); | |
| assert!(farming.contains(&("2".to_string(), "B-SEASON".to_string()))); | |
| assert!(farming.contains(&("06".to_string(), "B-EPISODE".to_string()))); | |
| let hanako = schema_labels_for("地縛少年花子君 2 - 13"); | |
| assert!(hanako.contains(&("地縛少年花子君".to_string(), "B-TITLE_JPN".to_string()))); | |
| assert!(hanako.contains(&("2".to_string(), "B-SEASON".to_string()))); | |
| assert!(hanako.contains(&("13".to_string(), "B-EPISODE".to_string()))); | |
| let one_piece = schema_labels_for("One.Piece.1110"); | |
| assert!(one_piece.contains(&("One".to_string(), "B-TITLE_LATIN".to_string()))); | |
| assert!(one_piece.contains(&("Piece".to_string(), "B-TITLE_LATIN".to_string()))); | |
| assert!(one_piece.contains(&("1110".to_string(), "B-EPISODE".to_string()))); | |
| assert!(!one_piece.contains(&("1110".to_string(), "B-SEASON".to_string()))); | |
| let nekomoe_prefix = schema_labels_for("[喵萌奶茶屋][7月新番][Lycoris Recoil][01][1080P]"); | |
| assert!(nekomoe_prefix.contains(&("喵萌奶茶屋".to_string(), "B-GROUP".to_string()))); | |
| assert!(nekomoe_prefix.contains(&("7月新番".to_string(), "B-TAG".to_string()))); | |
| assert!(nekomoe_prefix.contains(&("Lycoris".to_string(), "B-TITLE_LATIN".to_string()))); | |
| let subtitle_group = schema_labels_for("[桜都字幕组][Title][01][1080P]"); | |
| assert!(subtitle_group.contains(&("桜都字幕组".to_string(), "B-GROUP".to_string()))); | |
| let path = schema_labels_for("海贼王/Season 2/One Piece - 01 [1080P]"); | |
| assert!(path.contains(&("海贼王".to_string(), "B-PATH_TITLE_CHS".to_string()))); | |
| assert!(path.contains(&("2".to_string(), "B-PATH_SEASON".to_string()))); | |
| assert!(path.contains(&("One".to_string(), "B-TITLE_LATIN".to_string()))); | |
| assert!(path.contains(&("01".to_string(), "B-EPISODE".to_string()))); | |
| let tags = schema_labels_for("[日漫][剧场版][Movie][TV][2024][Title][01][1080P]"); | |
| assert!(tags.contains(&("日漫".to_string(), "B-TAG".to_string()))); | |
| assert!(tags.contains(&("剧场版".to_string(), "B-TAG".to_string()))); | |
| assert!(tags.contains(&("Movie".to_string(), "B-TAG".to_string()))); | |
| assert!(tags.contains(&("TV".to_string(), "B-TAG".to_string()))); | |
| assert!(tags.contains(&("2024".to_string(), "B-TAG".to_string()))); | |
| assert!(tags.contains(&("Title".to_string(), "B-TITLE_LATIN".to_string()))); | |
| for skipped in [ | |
| "[Group] Title OST [FLAC]", | |
| "[Group] Title MUSICCLIP [BDRip]", | |
| "[Group] Title Music Collection [FLAC]", | |
| "[Group] Title Character Song [MP3]", | |
| "[Group] Title Drama CD [FLAC]", | |
| "[Group] Title CD Album [FLAC]", | |
| "[Group] Title Bonus CD [FLAC]", | |
| "[Group] Title Soundtrack [FLAC]", | |
| ] { | |
| assert!(has_music_collection_noise(skipped), "{skipped}"); | |
| } | |
| for preserved in [ | |
| "[Group] Title OP [FLAC]", | |
| "[Group] Title ED [FLAC]", | |
| "[Group] Title NCOP [FLAC]", | |
| "[Group] Title NCED [FLAC]", | |
| "[Group] Title PV [1080P]", | |
| "[Group] Title CM [1080P]", | |
| "[Group] Title Menu [1080P]", | |
| "[Group] Title Trailer [1080P]", | |
| ] { | |
| assert!(!has_music_collection_noise(preserved), "{preserved}"); | |
| } | |
| } | |
| fn required_regressions() { | |
| let title_91 = labels_for("Title 91 EP 01 [1080p]"); | |
| assert!(title_91.contains(&("91".to_string(), "B-SEASON".to_string()))); | |
| assert!(title_91.contains(&("EP".to_string(), "O".to_string()))); | |
| assert!(title_91.contains(&("01".to_string(), "B-EPISODE".to_string()))); | |
| let event = labels_for("[HYSUB]Dragon Ball Super Broly[Theater Greeting Event][1080P]"); | |
| assert!(event.contains(&("Theater".to_string(), "B-SPECIAL".to_string()))); | |
| assert!(!event.contains(&("Theater".to_string(), "B-TITLE".to_string()))); | |
| let roman = labels_for("Chibi Maruko-chan I 001"); | |
| assert!(roman.contains(&("I".to_string(), "B-SEASON".to_string()))); | |
| assert!(roman.contains(&("001".to_string(), "B-EPISODE".to_string()))); | |
| let dxd = labels_for("High School D×D"); | |
| assert!(dxd.contains(&("×".to_string(), "B-TITLE".to_string()))); | |
| let colon_title = labels_for("Megumi no Daigo:Kyuukoku no Orange 06"); | |
| assert!(colon_title.contains(&(":".to_string(), "B-TITLE".to_string()))); | |
| let sxe = labels_for("S01E02"); | |
| assert_eq!( | |
| sxe, | |
| vec![ | |
| ("S".to_string(), "O".to_string()), | |
| ("01".to_string(), "B-SEASON".to_string()), | |
| ("E".to_string(), "O".to_string()), | |
| ("02".to_string(), "B-EPISODE".to_string()) | |
| ] | |
| ); | |
| let ep_prefix = labels_for("Toradora! EP01 [BD 1080p]"); | |
| assert!(ep_prefix.contains(&("EP".to_string(), "O".to_string()))); | |
| assert!(ep_prefix.contains(&("01".to_string(), "B-EPISODE".to_string()))); | |
| let bracket_sxe = labels_for("[FLsnow.feat.PO][Himitsu_no_Aipri][1080P][S2E01]"); | |
| assert!(bracket_sxe.contains(&("2".to_string(), "B-SEASON".to_string()))); | |
| assert!(bracket_sxe.contains(&("01".to_string(), "B-EPISODE".to_string()))); | |
| let bocchi_sxe = | |
| labels_for("Bocchi the Rock! 孤獨搖滾!S01E12「早起的日頭光照佇你的身上」"); | |
| assert!(bocchi_sxe.contains(&("01".to_string(), "B-SEASON".to_string()))); | |
| assert!(bocchi_sxe.contains(&("12".to_string(), "B-EPISODE".to_string()))); | |
| assert!(!bocchi_sxe.contains(&("S01E12".to_string(), "O".to_string()))); | |
| let sxe_range = labels_for( | |
| "【CXRAW】【TMNT 2012 TV series】【S5E12-S5E14】【Wanted:Bebop & Rocksteady】【DVDrip】【480p】【AVC Hi10P AAC MP4】", | |
| ); | |
| assert!(sxe_range.contains(&("5".to_string(), "B-SEASON".to_string()))); | |
| assert!(sxe_range.contains(&("12".to_string(), "B-EPISODE".to_string()))); | |
| assert!(sxe_range.contains(&("14".to_string(), "B-EPISODE".to_string()))); | |
| let episode_version_title = labels_for("[DHR][Dumbbell[10v2][BIG5][720P][AVC_AAC]"); | |
| assert!(episode_version_title.contains(&("10v2".to_string(), "B-EPISODE".to_string()))); | |
| assert!(!episode_version_title.contains(&("10v2".to_string(), "B-TITLE".to_string()))); | |
| let episode_version_lang = | |
| labels_for("[GalaxyRailroad-888] Yu-Gi-Oh! GO RUSH !! [043v2_GB]"); | |
| assert!(episode_version_lang.contains(&("043v2".to_string(), "B-EPISODE".to_string()))); | |
| assert!(episode_version_lang.contains(&("GB".to_string(), "B-SOURCE".to_string()))); | |
| let cursed = labels_for("[Coalgirls]_C3-Cube_x_Cursed_x_Curious_01_[8E416230]"); | |
| assert!(cursed.contains(&("x".to_string(), "B-TITLE".to_string()))); | |
| assert!(!cursed.contains(&("x".to_string(), "B-SEASON".to_string()))); | |
| let beyblade = labels_for("[jibaketa]Beyblade X - 118 (WEB 1920x1080 AVC AAC)"); | |
| assert!(beyblade.contains(&("X".to_string(), "B-TITLE".to_string()))); | |
| assert!(!beyblade.contains(&("X".to_string(), "B-SEASON".to_string()))); | |
| let bang_title = labels_for("[Dymy][Gugure! Kokkuri-san][06][BIG5][1280X720]"); | |
| assert!(bang_title.contains(&("!".to_string(), "B-TITLE".to_string()))); | |
| let pso2 = labels_for("[Lilith-Raws] Phantasy Star Online 2 Episode Oracle - 01 [1080p]"); | |
| assert!(pso2.contains(&("2".to_string(), "B-TITLE".to_string()))); | |
| assert!(pso2.contains(&("Episode".to_string(), "B-TITLE".to_string()))); | |
| assert!(pso2.contains(&("Oracle".to_string(), "B-TITLE".to_string()))); | |
| assert!(pso2.contains(&("01".to_string(), "B-EPISODE".to_string()))); | |
| let aikatsu = labels_for("Aikatsu Friends! - S2E01 (BD 1920x1080 x264 FLAC)"); | |
| assert!(aikatsu.contains(&("!".to_string(), "B-TITLE".to_string()))); | |
| let intro = labels_for("[VCB-Studio] LoveLive! µ's Live Collection [01][intro][1080p]"); | |
| assert!(intro.contains(&("intro".to_string(), "B-SPECIAL".to_string()))); | |
| let hash = labels_for("[Group][Title][01][1080p][00270AC8]"); | |
| assert!(hash.contains(&("00270AC8".to_string(), "O".to_string()))); | |
| let yamato = labels_for("[1995.01] YAMATO2520 Vol.1 明日への希望-0001"); | |
| assert!(yamato.contains(&("YAMATO2520".to_string(), "B-TITLE".to_string()))); | |
| assert!(yamato.contains(&("明日への希望".to_string(), "B-TITLE".to_string()))); | |
| let ubw = labels_for("Fate/stay night [Unlimited Blade Works] #00 「プロローグ」"); | |
| assert!(ubw.contains(&("Unlimited".to_string(), "B-TITLE".to_string()))); | |
| assert!(!ubw.contains(&("Unlimited".to_string(), "B-GROUP".to_string()))); | |
| let alias_title = labels_for("[Koten_Gars] Tegami Bachi; Letter Bee - 01 [1080p]"); | |
| assert!(alias_title.contains(&(";".to_string(), "B-TITLE".to_string()))); | |
| let comma_title = | |
| labels_for("[Studio GreenTea] Kamiina Botan, Yoeru Sugata wa Yuri no Hana [01]"); | |
| assert!(comma_title.contains(&(",".to_string(), "B-TITLE".to_string()))); | |
| let backtick_title = | |
| labels_for("[Hayate no Gotoku! Can`t Take My Eyes Off You][01][BDrip X264 AAC 720P]"); | |
| assert!(backtick_title.contains(&("`".to_string(), "B-TITLE".to_string()))); | |
| assert!(backtick_title.contains(&("t".to_string(), "B-TITLE".to_string()))); | |
| let cjk_period_title = | |
| labels_for("[云光字幕组]剃须。然后捡到高中生 Hige o Soru. Soshite Joshikousei o Hirou-[ 01 ][简体双语][1080p]"); | |
| assert!(cjk_period_title.contains(&("。".to_string(), "B-TITLE".to_string()))); | |
| let music_title = | |
| labels_for("[アニメ BD] うたの☆プリンスさまっ♪ マジLOVE2000% 第01話「ポワゾンKISS」(1920x1080 x264 Hi10p AAC)"); | |
| assert!(music_title.contains(&("♪".to_string(), "B-TITLE".to_string()))); | |
| let cm_version = | |
| labels_for("[U2-Rip]Inari, Konkon, Koi Iroha[CMv2][Hi10p_1080p][x264_flac]"); | |
| assert!(cm_version.contains(&("CMv2".to_string(), "B-SPECIAL".to_string()))); | |
| assert!(!cm_version.contains(&("CMv2".to_string(), "B-TITLE".to_string()))); | |
| let hdma_block = labels_for( | |
| "[Niconeiko Works] Gekijouban Violet Evergarden [1080P_Ma10p_DTS-HDMA][CM01]", | |
| ); | |
| assert!(hdma_block.contains(&("Gekijouban".to_string(), "B-TITLE".to_string()))); | |
| assert!(hdma_block.contains(&("1080P".to_string(), "B-RESOLUTION".to_string()))); | |
| assert!(hdma_block.contains(&("HDMA".to_string(), "B-SOURCE".to_string()))); | |
| assert!(!hdma_block.contains(&("1080P".to_string(), "B-TITLE".to_string()))); | |
| let extra_menu = labels_for("Extra Menu OVA"); | |
| assert!(extra_menu.contains(&("Extra".to_string(), "B-SPECIAL".to_string()))); | |
| assert!(!extra_menu.contains(&("Extra".to_string(), "B-TITLE".to_string()))); | |
| let eizou_tokuten = labels_for("おジャ魔女どれみ♯ 映像特典「ともだちの唄」(DVD 640x480 )"); | |
| assert!(eizou_tokuten.contains(&("映像特典".to_string(), "B-SPECIAL".to_string()))); | |
| let happy_lesson = labels_for("【DVD】 HAPPY☆LESSON THE TV 第01話"); | |
| assert!(happy_lesson.contains(&("☆".to_string(), "B-TITLE".to_string()))); | |
| let idolmaster = labels_for("[CASO&SumiSora][THE_IDOLM@STER_CINDERELLA_GIRLS][07.5_SP]"); | |
| assert!(idolmaster.contains(&("@".to_string(), "B-TITLE".to_string()))); | |
| let soul_taker = labels_for("[AI-Raws] THE SOUL TAKER~魂狩~ #01 (HEVC 1312x720)"); | |
| assert!(soul_taker.contains(&("~".to_string(), "B-TITLE".to_string()))); | |
| let mayoi = labels_for("[Snow-Raws] 迷家[マヨイガ] 第01話"); | |
| assert!(mayoi.contains(&("迷家".to_string(), "B-TITLE".to_string()))); | |
| assert!(mayoi.contains(&("マヨイガ".to_string(), "B-TITLE".to_string()))); | |
| assert!(mayoi.contains(&("]".to_string(), "B-TITLE".to_string()))); | |
| let conan_time = labels_for("【蓝色狂想】名侦探柯南TV版第036集-周一晚上7点半杀人事件"); | |
| assert!(conan_time.contains(&("036".to_string(), "B-EPISODE".to_string()))); | |
| assert!(!conan_time.contains(&("7".to_string(), "B-EPISODE".to_string()))); | |
| let zom = | |
| labels_for("[Nekomoe kissaten&VCB-Studio] Zom 100 [Animatics02][Ma10p_1080p][x265]"); | |
| assert!(zom.contains(&("100".to_string(), "B-TITLE".to_string()))); | |
| assert!(!zom.contains(&("100".to_string(), "B-EPISODE".to_string()))); | |
| assert!(zom.contains(&("Animatics02".to_string(), "B-SPECIAL".to_string()))); | |
| let sky = schema_labels_for("[Skytree][海贼王][One_Piece][918][GB_JP][1080P]"); | |
| assert!(sky.contains(&("海贼王".to_string(), "B-TITLE_CHS".to_string()))); | |
| assert!(sky.contains(&("One".to_string(), "B-TITLE_LATIN".to_string()))); | |
| assert!(sky.contains(&("Piece".to_string(), "B-TITLE_LATIN".to_string()))); | |
| assert!(sky.contains(&("918".to_string(), "B-EPISODE".to_string()))); | |
| let happy = | |
| labels_for("My.Happy.Marriage.S01E01.The.Meeting.1080p.NF.WEB-DL.AAC2.0.H.264-VARYG"); | |
| assert!(happy.contains(&("01".to_string(), "B-SEASON".to_string()))); | |
| assert!(happy.contains(&("01".to_string(), "B-EPISODE".to_string()))); | |
| assert!(!happy.contains(&("0".to_string(), "B-EPISODE".to_string()))); | |
| let garo = labels_for("[牙狼<GARO>~炎の刻印~][01][1080p]"); | |
| assert!(garo.contains(&("牙狼".to_string(), "B-TITLE".to_string()))); | |
| assert!(garo.contains(&("<".to_string(), "B-TITLE".to_string()))); | |
| assert!(garo.contains(&(">".to_string(), "B-TITLE".to_string()))); | |
| assert!(garo.contains(&("炎の刻印".to_string(), "B-TITLE".to_string()))); | |
| let akira = labels_for("[QYQ][AKIRA][AVC_AC3x2][1080p]"); | |
| assert!(akira.contains(&("AKIRA".to_string(), "B-TITLE".to_string()))); | |
| assert!(!akira.contains(&("AVC".to_string(), "B-TITLE".to_string()))); | |
| assert!(akira.contains(&("AVC".to_string(), "B-SOURCE".to_string()))); | |
| let doraemon = labels_for( | |
| "[DORASUB][DORAEMON1979][1998.03.07][WEB][1998x1080][AVC][简日]哆啦A梦归来了", | |
| ); | |
| assert!(doraemon.contains(&("DORAEMON1979".to_string(), "B-TITLE".to_string()))); | |
| assert!(doraemon.contains(&("WEB".to_string(), "B-SOURCE".to_string()))); | |
| assert!(!doraemon.contains(&("WEB".to_string(), "B-TITLE".to_string()))); | |
| let devilman = labels_for("[DBD-Raws][恶魔人][1972版][01][1080P][BDRip][HEVC-10bit][FLAC]"); | |
| assert!(devilman.contains(&("恶魔人".to_string(), "B-TITLE".to_string()))); | |
| assert!(!devilman.contains(&("1972版".to_string(), "B-TITLE".to_string()))); | |
| let classroom = labels_for("[Dymy][Assassination Classroom (2016)][01][BIG5][1280X720]"); | |
| assert!(classroom.contains(&("(".to_string(), "B-TITLE".to_string()))); | |
| assert!(classroom.contains(&(")".to_string(), "B-TITLE".to_string()))); | |
| assert!(!classroom.contains(&("]".to_string(), "B-TITLE".to_string()))); | |
| let bang_season = | |
| labels_for("[LoliHouse] Bang Dream! 2nd Season - 01 [BDRip 1080p HEVC-10bit FLAC]"); | |
| assert!(bang_season.contains(&("Bang".to_string(), "B-TITLE".to_string()))); | |
| assert!(bang_season.contains(&("2nd".to_string(), "B-SEASON".to_string()))); | |
| assert!(bang_season.contains(&("Season".to_string(), "B-SEASON".to_string()))); | |
| assert!(bang_season.contains(&("01".to_string(), "B-EPISODE".to_string()))); | |
| assert!(!bang_season.contains(&("01".to_string(), "B-SEASON".to_string()))); | |
| let basket = labels_for( | |
| "[Nekomoe kissaten&VCB-Studio] Fruits Basket 1st Season [24][1080p][x264_aac][sc]", | |
| ); | |
| assert!(basket.contains(&("Fruits".to_string(), "B-TITLE".to_string()))); | |
| assert!(basket.contains(&("1st".to_string(), "B-SEASON".to_string()))); | |
| assert!(basket.contains(&("Season".to_string(), "B-SEASON".to_string()))); | |
| assert!(basket.contains(&("24".to_string(), "B-EPISODE".to_string()))); | |
| assert!(!basket.contains(&("24".to_string(), "B-SEASON".to_string()))); | |
| let notice = labels_for("[KTXP][Zankyou_no_Terror][08_Notice][GB_BIG5][X264_AAC][720p]"); | |
| assert!(notice.contains(&("Zankyou".to_string(), "B-TITLE".to_string()))); | |
| assert!(notice.contains(&("08".to_string(), "B-EPISODE".to_string()))); | |
| assert!(!notice.contains(&("08".to_string(), "B-TITLE".to_string()))); | |
| let full = labels_for("[POPGO][Soukyuu_no_Fafner_Exodus][01_Full][GB][720p]"); | |
| assert!(full.contains(&("01".to_string(), "B-EPISODE".to_string()))); | |
| assert!(!full.contains(&("01".to_string(), "B-TITLE".to_string()))); | |
| let r18 = | |
| labels_for("[HYSUB]Skirt no Naka wa Kedamono Deshita.[01_R18][BIG5_MP4][1280X720]"); | |
| assert!(r18.contains(&("01".to_string(), "B-EPISODE".to_string()))); | |
| assert!(!r18.contains(&("01".to_string(), "B-TITLE".to_string()))); | |
| let ddp = labels_for("Akuma.Kun.S01E02.1080p.NF.WEB-DL.DDP5.1.H.264"); | |
| assert!(ddp.contains(&("02".to_string(), "B-EPISODE".to_string()))); | |
| assert!(!ddp.contains(&("1".to_string(), "B-EPISODE".to_string()))); | |
| assert!(ddp | |
| .iter() | |
| .any(|(token, label)| token.starts_with("DDP") && label == "B-SOURCE")); | |
| let aac_space = labels_for("Bleach S01E02 AAC 2.0 H.264"); | |
| assert!(aac_space.contains(&("02".to_string(), "B-EPISODE".to_string()))); | |
| assert!(!aac_space.contains(&("2".to_string(), "B-EPISODE".to_string()))); | |
| assert!(aac_space | |
| .iter() | |
| .any(|(token, label)| token.starts_with("AAC") && label == "B-SOURCE")); | |
| let bare_resolution = labels_for("日本桥15.03.30 720"); | |
| assert!(bare_resolution.contains(&("日本桥".to_string(), "B-TITLE".to_string()))); | |
| assert!(bare_resolution.contains(&("720".to_string(), "B-RESOLUTION".to_string()))); | |
| assert!(!bare_resolution.contains(&("720".to_string(), "B-EPISODE".to_string()))); | |
| let air_episode = labels_for("Air 01"); | |
| assert!(air_episode.contains(&("Air".to_string(), "B-TITLE".to_string()))); | |
| assert!(air_episode.contains(&("01".to_string(), "B-EPISODE".to_string()))); | |
| let decimal_episode = | |
| labels_for("[HoneyGod] Usagi Drop [02.5][x264_10bit][粤日双语][BDrip_1080p]"); | |
| assert!(decimal_episode.contains(&("02".to_string(), "B-EPISODE".to_string()))); | |
| assert!(decimal_episode.contains(&(".".to_string(), "B-EPISODE".to_string()))); | |
| assert!(decimal_episode.contains(&("5".to_string(), "B-EPISODE".to_string()))); | |
| let _ = RUNTIME_WHITELISTS.set(Whitelists { | |
| title_phrases: Vec::new(), | |
| group_names: [ | |
| "LowPower-Raws".to_string(), | |
| "ANi".to_string(), | |
| "LoliHouse".to_string(), | |
| "QTS".to_string(), | |
| ] | |
| .into_iter() | |
| .collect(), | |
| }); | |
| let lowpower = labels_for("[LowPower-Raws] 91 Days - 01 (BD 720P x264 10bit AAC)"); | |
| assert!(lowpower.contains(&("LowPower".to_string(), "B-GROUP".to_string()))); | |
| assert!(lowpower.contains(&("91".to_string(), "B-TITLE".to_string()))); | |
| assert!(lowpower.contains(&("Days".to_string(), "B-TITLE".to_string()))); | |
| assert!(lowpower.contains(&("01".to_string(), "B-EPISODE".to_string()))); | |
| let ririsa = labels_for("[ANi] 2.5 次元的誘惑 - 01 [1080P][Baha][WEB-DL][AAC AVC][CHT]"); | |
| assert!(ririsa.contains(&("2".to_string(), "B-TITLE".to_string()))); | |
| assert!(ririsa.contains(&(".".to_string(), "B-TITLE".to_string()))); | |
| assert!(ririsa.contains(&("5".to_string(), "B-TITLE".to_string()))); | |
| assert!(ririsa.contains(&("次元的誘惑".to_string(), "B-TITLE".to_string()))); | |
| assert!(ririsa.contains(&("01".to_string(), "B-EPISODE".to_string()))); | |
| let nanabun = labels_for("[LoliHouse] 22-7 - 01 [WebRip 1080p HEVC-10bit AAC ASS]"); | |
| assert!(nanabun.contains(&("22".to_string(), "B-TITLE".to_string()))); | |
| assert!(nanabun.contains(&("-".to_string(), "B-TITLE".to_string()))); | |
| assert!(nanabun.contains(&("7".to_string(), "B-TITLE".to_string()))); | |
| assert!(nanabun.contains(&("01".to_string(), "B-EPISODE".to_string()))); | |
| let saint = labels_for("[QTS] OVA Saint Seiya The Lost Canvas Meiou Shinwa ep 01 (BD H264 1920x1080 24fps FLAC)"); | |
| assert!(saint.contains(&("OVA".to_string(), "B-SPECIAL".to_string()))); | |
| assert!(saint.contains(&("Saint".to_string(), "B-TITLE".to_string()))); | |
| assert!(saint.contains(&("Seiya".to_string(), "B-TITLE".to_string()))); | |
| assert!(saint.contains(&("01".to_string(), "B-EPISODE".to_string()))); | |
| let gundam = labels_for("機動戦士ガンダム00 セカンドシーズン/Ep.01 「# 天使再臨」"); | |
| assert!(gundam.contains(&("機動戦士ガンダム".to_string(), "B-TITLE".to_string()))); | |
| assert!(gundam.contains(&("00".to_string(), "B-TITLE".to_string()))); | |
| assert!(gundam.contains(&("01".to_string(), "B-EPISODE".to_string()))); | |
| let spy = | |
| labels_for("[Studio GreenTea] Spy x Family [38][WebRip][HEVC-10bit 1080p AAC ASSx2]"); | |
| assert!(spy.contains(&("Studio".to_string(), "B-GROUP".to_string()))); | |
| assert!(spy.contains(&("Spy".to_string(), "B-TITLE".to_string()))); | |
| assert!(spy.contains(&("x".to_string(), "B-TITLE".to_string()))); | |
| assert!(spy.contains(&("Family".to_string(), "B-TITLE".to_string()))); | |
| assert!(spy.contains(&("38".to_string(), "B-EPISODE".to_string()))); | |
| assert!(!spy.contains(&("Spy".to_string(), "B-SPECIAL".to_string()))); | |
| let spy_s3 = labels_for( | |
| "[Feibanyama] SPY x FAMILY S3 - 01 [IQIYI WebRip 2160p HEVC-10bit OPUS Multi-Subs]", | |
| ); | |
| assert!(spy_s3.contains(&("Feibanyama".to_string(), "B-GROUP".to_string()))); | |
| assert!(spy_s3.contains(&("SPY".to_string(), "B-TITLE".to_string()))); | |
| assert!(spy_s3.contains(&("FAMILY".to_string(), "B-TITLE".to_string()))); | |
| assert!(spy_s3.contains(&("3".to_string(), "B-SEASON".to_string()))); | |
| assert!(spy_s3.contains(&("01".to_string(), "B-EPISODE".to_string()))); | |
| let slime = | |
| labels_for("[Nekomoe kissaten&VCB-Studio] Slime 300 [Menu01][Ma10p_1080p][x265_flac]"); | |
| assert!(slime.contains(&("Slime".to_string(), "B-TITLE".to_string()))); | |
| assert!( | |
| slime.contains(&("300".to_string(), "B-TITLE".to_string())), | |
| "{slime:?}" | |
| ); | |
| assert!(!slime.contains(&("300".to_string(), "B-EPISODE".to_string()))); | |
| let kamisama = | |
| labels_for("[SFEO-Raws] Kamisama Hajimemashita 2 - 01 (BD 720P x264 10bit AAC)"); | |
| assert!(kamisama.contains(&("Kamisama".to_string(), "B-TITLE".to_string()))); | |
| assert!(kamisama.contains(&("2".to_string(), "B-TITLE".to_string()))); | |
| assert!(kamisama.contains(&("01".to_string(), "B-EPISODE".to_string()))); | |
| } | |
| fn updated_python_alignment_regressions() { | |
| let original = "The New Woody Woodpecker Show (Season 1-4) (1999-2002) WEB-DL 720p [Hurtom]/Season 4/E07 - The New Woody Woodpecker Show (Season 1-4) (1999-2002) WEB-DL 720p"; | |
| let (trimmed, was_trimmed) = training_filename_for(original); | |
| assert!(was_trimmed); | |
| assert_eq!( | |
| trimmed, | |
| "Season 4 E07 - The New Woody Woodpecker Show (Season 1-4) (1999-2002) WEB-DL 720p" | |
| ); | |
| let pokemon = "Pokémon Season 2 - Orange League [Ep. 83-118]/Pokemon - 084 - OL002 - A Scare in the Air [DVD][PM-Dragon-x264-AC3][00270AC8]"; | |
| let (trimmed_pokemon, pokemon_was_trimmed) = training_filename_for(pokemon); | |
| assert!(pokemon_was_trimmed); | |
| assert_eq!( | |
| trimmed_pokemon, | |
| "Pokemon - 084 - OL002 - A Scare in the Air [DVD][PM-Dragon-x264-AC3][00270AC8]" | |
| ); | |
| let woody = labels_for(&trimmed); | |
| assert!(woody.contains(&("4".to_string(), "B-SEASON".to_string()))); | |
| assert!(woody.contains(&("E".to_string(), "O".to_string()))); | |
| assert!(woody.contains(&("07".to_string(), "B-EPISODE".to_string()))); | |
| assert!(woody.contains(&("The".to_string(), "B-TITLE".to_string()))); | |
| assert!(woody.contains(&("Show".to_string(), "B-TITLE".to_string()))); | |
| assert!(!woody.contains(&("1999".to_string(), "B-EPISODE".to_string()))); | |
| let group = labels_for("[DBD-Raws][Title][01][1080P]"); | |
| assert!(group.contains(&("-".to_string(), "B-GROUP".to_string()))); | |
| let amp_group = labels_for("[SumiSora&CASO][Title][01][1080P]"); | |
| assert!(amp_group.contains(&("&".to_string(), "B-GROUP".to_string()))); | |
| let cjk_season = | |
| labels_for("[DBD-Raws][魔道祖师 第一季][08][1080P][BDRip][HEVC-10bit][FLAC]"); | |
| assert!(cjk_season.contains(&("魔道祖师".to_string(), "B-TITLE".to_string()))); | |
| assert!(cjk_season.contains(&("第一季".to_string(), "B-SEASON".to_string()))); | |
| assert!(!cjk_season.contains(&("第一季".to_string(), "B-TITLE".to_string()))); | |
| let (trimmed, was_trimmed) = | |
| training_filename_for("12/小剧场/[LKSUB][KAGE-JITSU!][01][GB][720P]"); | |
| assert!(was_trimmed); | |
| assert_eq!(trimmed, "[LKSUB][KAGE-JITSU!][01][GB][720P]"); | |
| let (key, _, _, _) = template_key_for_filename(&trimmed); | |
| assert_eq!( | |
| key, | |
| "BRACKET_TEXT BRACKET_TEXT BRACKET_EPISODE BRACKET_LANG BRACKET_RESOLUTION" | |
| ); | |
| let short = labels_for("[Snow-Raws] R-15 CM&PV12 (BD 1920x1080 HEVC-YUV420P10 FLAC)"); | |
| assert!(short.contains(&("R".to_string(), "B-TITLE".to_string()))); | |
| assert!(short.contains(&("-".to_string(), "B-TITLE".to_string()))); | |
| assert!(short.contains(&("15".to_string(), "B-TITLE".to_string()))); | |
| assert!(!short.contains(&("15".to_string(), "B-EPISODE".to_string()))); | |
| let short_before_episode = | |
| labels_for("[Snow-Raws] R-15 第01話 (BD 1920x1080 HEVC-YUV420P10 FLAC)"); | |
| assert!(short_before_episode.contains(&("R".to_string(), "B-TITLE".to_string()))); | |
| assert!(short_before_episode.contains(&("-".to_string(), "B-TITLE".to_string()))); | |
| assert!(short_before_episode.contains(&("15".to_string(), "B-TITLE".to_string()))); | |
| assert!(short_before_episode.contains(&("01".to_string(), "B-EPISODE".to_string()))); | |
| assert!(!short_before_episode.contains(&("15".to_string(), "B-EPISODE".to_string()))); | |
| let avatar = "Avatar The Last Airbender S2/Avatar The Last Airbender S2 14 [1080p]"; | |
| let (trimmed, was_trimmed) = training_filename_for(avatar); | |
| assert!(was_trimmed); | |
| assert_eq!(trimmed, "Avatar The Last Airbender S2 14 [1080p]"); | |
| let plain_season_dir = | |
| "Season 1/[Kamigami] Junjou Romantica 1 - 01 [BD 1280x720 x264 AAC Sub(Chs,Jap)]"; | |
| let (trimmed, was_trimmed) = training_filename_for(plain_season_dir); | |
| assert!(was_trimmed); | |
| assert_eq!( | |
| trimmed, | |
| "Season 1 [Kamigami] Junjou Romantica 1 - 01 [BD 1280x720 x264 AAC Sub(Chs,Jap)]" | |
| ); | |
| let plain_season_labels = labels_for(&trimmed); | |
| assert!(plain_season_labels.contains(&("1".to_string(), "B-SEASON".to_string()))); | |
| assert!(plain_season_labels.contains(&("01".to_string(), "B-EPISODE".to_string()))); | |
| let menu_parent = | |
| "[Airota&ANK-Raws] 亜人ちゃんは語りたい (BDrip 1920x1080 HEVC-YUV420P10 FLAC SUP)/Menu (Vol.1)"; | |
| let (trimmed, was_trimmed) = training_filename_for(menu_parent); | |
| assert!(was_trimmed); | |
| assert_eq!( | |
| trimmed, | |
| "[Airota&ANK-Raws] 亜人ちゃんは語りたい Menu (Vol.1)" | |
| ); | |
| assert!(has_encoding_noise( | |
| "[4K_SDR][DBD-Raws&HKG瀛楀箷绲刔[鏃ュ湪鏍″湌][01][2160P]" | |
| )); | |
| assert!(has_encoding_noise( | |
| "ATRI -My Dear Moments-/娆″洖浜堝憡 EP01 Log01" | |
| )); | |
| assert!(has_encoding_noise( | |
| "[2002-2003] Mew Mew_鏉变含鍠靛柕(鏉变含銉熴儱銈︺儫銉ャ偊)_TV" | |
| )); | |
| assert!(has_encoding_noise("[DAY][Megami no Caf茅 Terrace][01]")); | |
| assert!(has_encoding_noise( | |
| "[4K_SDR][DBD-Raws][瀵掕潐楦f常涔嬫椂 涓歖[NCED1]" | |
| )); | |
| assert!(has_non_anime_noise( | |
| "13-[旅游番][花丸字幕组][日本不思议铁路之旅][15.03.19-16.02.03][720&1080][中日双语]/铁道旅 15.03.19 720" | |
| )); | |
| let tintin = "Adventures of Tintin (1991) Season 1-3 S01-S03 (1080p BluRay x265 HEVC 10bit EAC3 2.0 Garshasp)/Season 1/Adventures of Tintin (1991) - S01E06 - Cigars of the Pharaoh (Part 1) (1080p BluRay x265 Garshasp)"; | |
| let (trimmed, was_trimmed) = training_filename_for(tintin); | |
| assert!(was_trimmed); | |
| assert_eq!( | |
| trimmed, | |
| "Adventures of Tintin (1991) - S01E06 - Cigars of the Pharaoh (Part 1) (1080p BluRay x265 Garshasp)" | |
| ); | |
| let (key, _, _, _) = template_key_for_filename(&trimmed); | |
| assert_eq!( | |
| key, | |
| "TEXT SEP TEXT SEP TEXT SEP BRACKET_DATE SEP SXE SEP TEXT SEP TEXT SEP TEXT SEP TEXT SEP BRACKET_TEXT SEP BRACKET_TEXT" | |
| ); | |
| let bocchi = "Bocchi the Rock S01 孤獨搖滾!第一季 [Taiwanese Hokkien Dub][臺灣閩南語配音]/Bocchi the Rock S01 孤獨搖滾!第一季 [Taiwanese Hokkien Dub][Hàn-jī Hardsub][臺灣閩南語配音][漢字字幕]/Bocchi the Rock! 孤獨搖滾!S01E01「孤獨反輾轉」"; | |
| let (leaf_key, _, _, _) = | |
| template_key_for_filename("Bocchi the Rock! 孤獨搖滾!S01E01「孤獨反輾轉」"); | |
| assert_eq!(leaf_key, "TEXT SEP TEXT SEP TEXT SEP TEXT SXE TEXT"); | |
| assert!(filename_has_title( | |
| "Bocchi the Rock! 孤獨搖滾!S01E01「孤獨反輾轉」" | |
| )); | |
| let (trimmed, was_trimmed) = training_filename_for(bocchi); | |
| assert!(was_trimmed); | |
| assert_eq!(trimmed, "Bocchi the Rock! 孤獨搖滾!S01E01「孤獨反輾轉」"); | |
| let (key, _, _, _) = template_key_for_filename(&trimmed); | |
| assert_eq!(key, "TEXT SEP TEXT SEP TEXT SEP TEXT SXE TEXT"); | |
| let usagi = "Gochuumon wa Usagi Desuka-60fps/Gochuumon wa Usagi Desuka S1/Usagi S1[01][60fps][8bit_1080p][x265_flac]"; | |
| let (trimmed, was_trimmed) = training_filename_for(usagi); | |
| assert!(was_trimmed); | |
| assert_eq!(trimmed, "Usagi S1[01][60fps][8bit_1080p][x265_flac]"); | |
| let (key, _, _, _) = template_key_for_filename(&trimmed); | |
| assert_eq!( | |
| key, | |
| "TEXT SEP SEASON BRACKET_EPISODE BRACKET_TEXT BRACKET_MEDIA_BLOCK BRACKET_MEDIA" | |
| ); | |
| let woody_parent = | |
| "Season 4/E07 - The New Woody Woodpecker Show (Season 1-4) (1999-2002) WEB-DL 720p"; | |
| let (trimmed, was_trimmed) = training_filename_for(&format!("Batch/{woody_parent}")); | |
| assert!(was_trimmed); | |
| assert_eq!( | |
| trimmed, | |
| "Season 4 E07 - The New Woody Woodpecker Show (Season 1-4) (1999-2002) WEB-DL 720p" | |
| ); | |
| let najica = | |
| "[2001] Najica_七虹香電擊作戰(ナジカ電撃作戦)_TV/SourceUnknown.RMVB.640x480.twHard/01"; | |
| let (trimmed, was_trimmed) = training_filename_for(najica); | |
| assert!(was_trimmed); | |
| assert_eq!(trimmed, "[2001] Najica_七虹香電擊作戰(ナジカ電撃作戦) 01"); | |
| let najica_labels = labels_for(&trimmed); | |
| assert!(najica_labels.contains(&("Najica".to_string(), "B-TITLE".to_string()))); | |
| assert!(!najica_labels.contains(&("SourceUnknown".to_string(), "B-TITLE".to_string()))); | |
| assert!(najica_labels.contains(&("01".to_string(), "B-EPISODE".to_string()))); | |
| let galient = "[1984-1986] Galient_機甲界(機甲界ガリアン)_TV.OVA/[1984-1985] Galient_機甲界(機甲界ガリアン)_TV/DVDRip.MKV.720x480.ruSub.左右黑邊保留/01"; | |
| let (trimmed, was_trimmed) = training_filename_for(galient); | |
| assert!(was_trimmed); | |
| assert_eq!(trimmed, "[1984-1985] Galient_機甲界(機甲界ガリアン) 01"); | |
| let galient_labels = labels_for(&trimmed); | |
| assert!(galient_labels.contains(&("Galient".to_string(), "B-TITLE".to_string()))); | |
| assert!(!galient_labels.contains(&("TV".to_string(), "B-TITLE".to_string()))); | |
| assert!(galient_labels.contains(&("01".to_string(), "B-EPISODE".to_string()))); | |
| let nced = "[BDrip] Ao no Exorcist Yuki no Hate Hen S04 [343-Labs]/NCED"; | |
| let (trimmed, was_trimmed) = training_filename_for(nced); | |
| assert!(was_trimmed); | |
| assert_eq!( | |
| trimmed, | |
| "[BDrip] Ao no Exorcist Yuki no Hate Hen S04 [343-Labs] NCED" | |
| ); | |
| let sakura = | |
| "Card Captor Sakura Chinese/魔卡少女樱(台配国语)/第01集 小樱与不可思议的魔法书"; | |
| let (trimmed, was_trimmed) = training_filename_for(sakura); | |
| assert!(was_trimmed); | |
| assert_eq!( | |
| trimmed, | |
| "魔卡少女樱(台配国语) 第01集 小樱与不可思议的魔法书" | |
| ); | |
| let sakura_labels = labels_for(&trimmed); | |
| assert!(sakura_labels.contains(&("魔卡少女樱".to_string(), "B-TITLE".to_string()))); | |
| assert!(sakura_labels.contains(&("01".to_string(), "B-EPISODE".to_string()))); | |
| let volume = | |
| labels_for("[Snow-Raws] 生徒会役員共 Vol.01 MENU02 (BD 1920x1080 HEVC-YUV420P10 FLAC)"); | |
| assert!(volume.contains(&("生徒会役員共".to_string(), "B-TITLE".to_string()))); | |
| assert!(volume.contains(&("Vol".to_string(), "B-SPECIAL".to_string()))); | |
| assert!(volume.contains(&("01".to_string(), "B-SPECIAL".to_string()))); | |
| assert!(volume.contains(&("MENU02".to_string(), "B-SPECIAL".to_string()))); | |
| assert!(!volume.contains(&("01".to_string(), "B-EPISODE".to_string()))); | |
| let aria_notice = labels_for( | |
| "[KNA-Subs&ANK-Raws] 緋弾のアリアAA 番宣1 (BDrip 1920x1080 HEVC-YUV420P10 FLAC)", | |
| ); | |
| assert!(aria_notice.contains(&("緋弾のアリア".to_string(), "B-TITLE".to_string()))); | |
| assert!(aria_notice.contains(&("番宣".to_string(), "B-SPECIAL".to_string()))); | |
| assert!(aria_notice.contains(&("1".to_string(), "B-SPECIAL".to_string()))); | |
| assert!(!aria_notice.contains(&("1".to_string(), "B-EPISODE".to_string()))); | |
| let lost_song = | |
| labels_for("[Snow-Raws] LOST SONG CM&PV 01(BD 1920x1080 HEVC-YUV420P10 FLAC)"); | |
| assert!(lost_song.contains(&("LOST".to_string(), "B-TITLE".to_string()))); | |
| assert!(lost_song.contains(&("CM".to_string(), "B-SPECIAL".to_string()))); | |
| assert!(lost_song.contains(&("PV".to_string(), "B-SPECIAL".to_string()))); | |
| assert!(lost_song.contains(&("01".to_string(), "B-SPECIAL".to_string()))); | |
| assert!(!lost_song.contains(&("01".to_string(), "B-EPISODE".to_string()))); | |
| let numeric_title = | |
| labels_for("3000.Leagues.in.Search.of.Mother.S01E01.1080p.WEB-DL.H.264-D00oo00M"); | |
| assert!(numeric_title.contains(&("3000".to_string(), "B-TITLE".to_string()))); | |
| assert!(numeric_title.contains(&("01".to_string(), "B-SEASON".to_string()))); | |
| assert!(numeric_title.contains(&("01".to_string(), "B-EPISODE".to_string()))); | |
| assert!(numeric_title.contains(&("1080p".to_string(), "B-RESOLUTION".to_string()))); | |
| assert!(numeric_title.contains(&("H".to_string(), "B-SOURCE".to_string()))); | |
| assert!(numeric_title.contains(&("264".to_string(), "B-SOURCE".to_string()))); | |
| assert!(!numeric_title.contains(&("264".to_string(), "B-EPISODE".to_string()))); | |
| let media_block = | |
| labels_for("[Kamigami] Kantai Collection - 06v2 [1920×1080 x264 AAC Sub(Chs,Cht,Jap)]"); | |
| assert!(media_block.contains(&("1920".to_string(), "B-RESOLUTION".to_string()))); | |
| assert!(media_block.contains(&("1080".to_string(), "B-RESOLUTION".to_string()))); | |
| assert!(media_block.contains(&("x264".to_string(), "B-SOURCE".to_string()))); | |
| assert!(media_block.contains(&("Chs".to_string(), "B-SOURCE".to_string()))); | |
| let ge999 = labels_for("GE999 第024話 「次元航海惑星」1979年02月22日 (720x540 x264 AAC2)"); | |
| assert!(ge999.contains(&("GE999".to_string(), "B-TITLE".to_string()))); | |
| assert!(ge999.contains(&("024".to_string(), "B-EPISODE".to_string()))); | |
| assert!(!ge999.contains(&("22".to_string(), "B-EPISODE".to_string()))); | |
| let galaxy = labels_for("銀河鉄道999 第024話 「次元航海惑星」 (DVD 640x480 WMV9)"); | |
| assert!(galaxy.contains(&("銀河鉄道".to_string(), "B-TITLE".to_string()))); | |
| assert!(galaxy.contains(&("999".to_string(), "B-TITLE".to_string()))); | |
| assert!(galaxy.contains(&("024".to_string(), "B-EPISODE".to_string()))); | |
| let mahoro = labels_for("[POPGO][FREEWIND][Mahoro_Matic][Full_HD-BDRIP][01]"); | |
| assert!(mahoro.contains(&("Mahoro".to_string(), "B-TITLE".to_string()))); | |
| assert!(!mahoro.contains(&("Full".to_string(), "B-TITLE".to_string()))); | |
| assert!(mahoro.contains(&("01".to_string(), "B-EPISODE".to_string()))); | |
| let kitaro = labels_for( | |
| "[1985.10-1988.02] Kitaro_鬼太郎 第3期(ゲゲゲの鬼太郎)_TV 036 異次元妖怪かまなり", | |
| ); | |
| assert!(kitaro.contains(&("Kitaro".to_string(), "B-TITLE".to_string()))); | |
| assert!(kitaro.contains(&("3".to_string(), "B-SEASON".to_string()))); | |
| assert!(kitaro.contains(&("036".to_string(), "B-EPISODE".to_string()))); | |
| assert!(!kitaro.contains(&("1985".to_string(), "B-EPISODE".to_string()))); | |
| let urusei = labels_for("Urusei_Yatsura_DVD_Ep042.5_Simu"); | |
| assert!(urusei.contains(&("Urusei".to_string(), "B-TITLE".to_string()))); | |
| assert!(urusei.contains(&("042".to_string(), "B-EPISODE".to_string()))); | |
| assert!(urusei.contains(&(".".to_string(), "B-EPISODE".to_string()))); | |
| assert!(urusei.contains(&("5".to_string(), "B-EPISODE".to_string()))); | |
| let lupin = | |
| labels_for("[Lupin The Thrid Jigen Daisuke no Bohyou][Logo][BDRIP][1080P][H264_FLAC]"); | |
| assert!(lupin.contains(&("Lupin".to_string(), "B-TITLE".to_string()))); | |
| assert!(!lupin.contains(&("Lupin".to_string(), "B-GROUP".to_string()))); | |
| let mirumo = labels_for("【咪路fans】魔法咪路咪路第二季日语版 01[GB][MP4]"); | |
| assert!(mirumo.contains(&("魔法咪路咪路".to_string(), "B-TITLE".to_string()))); | |
| assert!(mirumo.contains(&("第二季".to_string(), "B-SEASON".to_string()))); | |
| assert!(mirumo.contains(&("01".to_string(), "B-EPISODE".to_string()))); | |
| let doremi_bonus = labels_for( | |
| "おジャ魔女どれみナ・イ・ショ 特典映像07「おジャ魔女どれみナ・イ・ショ エンドテロップ集」(DVD 640x480 )", | |
| ); | |
| assert!(doremi_bonus.contains(&("おジャ魔女どれみナ".to_string(), "B-TITLE".to_string()))); | |
| assert!(doremi_bonus.contains(&("07".to_string(), "B-SPECIAL".to_string()))); | |
| assert!(!doremi_bonus.contains(&("07".to_string(), "B-EPISODE".to_string()))); | |
| let bd_menu = | |
| labels_for("[HYSUB]Kuusen Madoushi Kouhosei no Kyoukan[BDMenu][01v1][MP4][1280X720]"); | |
| assert!(bd_menu.contains(&("BDMenu".to_string(), "B-SPECIAL".to_string()))); | |
| assert!(bd_menu.contains(&("01v1".to_string(), "B-SPECIAL".to_string()))); | |
| assert!(!bd_menu.contains(&("BDMenu".to_string(), "B-TITLE".to_string()))); | |
| let ura_on = labels_for("K-ON !! (TV S2 2010). URA-ON !! 01; 1080_h264_flac"); | |
| assert!(ura_on.contains(&("K".to_string(), "B-TITLE".to_string()))); | |
| assert!(ura_on.contains(&("01".to_string(), "B-EPISODE".to_string()))); | |
| assert!(ura_on.contains(&("1080".to_string(), "B-RESOLUTION".to_string()))); | |
| assert!(!ura_on.contains(&("1080".to_string(), "B-EPISODE".to_string()))); | |
| let machikado = labels_for("[KTXP][Machikado_Mazoku_S2][Mini][01][GB][1080p][BDrip][HEVC]"); | |
| assert!(machikado.contains(&("Machikado".to_string(), "B-TITLE".to_string()))); | |
| assert!(machikado.contains(&("S2".to_string(), "B-SEASON".to_string()))); | |
| assert!(machikado.contains(&("01".to_string(), "B-EPISODE".to_string()))); | |
| let ronin = labels_for("【蓝色狂想】魔神坛斗士国日双语第01集"); | |
| assert!(ronin.contains(&("魔神坛斗士".to_string(), "B-TITLE".to_string()))); | |
| assert!(ronin.contains(&("国日双语".to_string(), "B-SOURCE".to_string()))); | |
| assert!(ronin.contains(&("01".to_string(), "B-EPISODE".to_string()))); | |
| let ghiblies = labels_for("Ghiblies - Episode 2 op"); | |
| assert!(ghiblies.contains(&("Ghiblies".to_string(), "B-TITLE".to_string()))); | |
| assert!(ghiblies.contains(&("2".to_string(), "B-TITLE".to_string()))); | |
| assert!(!ghiblies.contains(&("2".to_string(), "B-EPISODE".to_string()))); | |
| let tv_spot = | |
| labels_for("[RUELL-Next] Fruits Basket TV Spot 1 (DVD 768x576 x264 AAC) [49531416]"); | |
| assert!(tv_spot.contains(&("TV".to_string(), "B-SPECIAL".to_string()))); | |
| assert!(tv_spot.contains(&("1".to_string(), "B-SPECIAL".to_string()))); | |
| assert!(!tv_spot.contains(&("1".to_string(), "B-EPISODE".to_string()))); | |
| let preview_seconds = | |
| labels_for("[DVD] 鋼鉄天使くるみ 予告 第03話 30秒バージョン (640x480 WMV9)"); | |
| assert!(preview_seconds.contains(&("03".to_string(), "B-EPISODE".to_string()))); | |
| assert!(!preview_seconds.contains(&("30".to_string(), "B-EPISODE".to_string()))); | |
| let hi10_source = | |
| labels_for("[POPGO][Shigatsu wa Kimi no Uso] [01][Hi10][720P][GB][A964DA24]"); | |
| assert!(hi10_source.contains(&("Hi10".to_string(), "B-SOURCE".to_string()))); | |
| assert!(!hi10_source.contains(&("Hi10".to_string(), "B-GROUP".to_string()))); | |
| let souten = labels_for( | |
| "[苍天之拳].[Fosky_Fansub][Souten_No_Ken][DVDRIP][01][H.264_FLAC][848x480][CDD495FC]", | |
| ); | |
| assert!(souten.contains(&("Fosky".to_string(), "B-GROUP".to_string()))); | |
| assert!(!souten.contains(&("苍天之拳".to_string(), "B-GROUP".to_string()))); | |
| assert!(souten.contains(&("Souten".to_string(), "B-TITLE".to_string()))); | |
| let bonjour = labels_for( | |
| "(2014Q4) Bonjour♪恋味パティスリー 第01話 「Lesson 1」 (1280x720 x265 10bit AAC)", | |
| ); | |
| assert!(bonjour.contains(&("01".to_string(), "B-EPISODE".to_string()))); | |
| assert!(!bonjour.contains(&("1".to_string(), "B-EPISODE".to_string()))); | |
| let durarara = | |
| labels_for("[VCB-Studio] Durarara!!×2 Ketsu [Menu01][Ma10p_1080p][x265_flac]"); | |
| assert!(durarara.contains(&("Durarara".to_string(), "B-TITLE".to_string()))); | |
| assert!(durarara.contains(&("2".to_string(), "B-TITLE".to_string()))); | |
| assert!(!durarara.contains(&("2".to_string(), "B-EPISODE".to_string()))); | |
| let bd_spot = | |
| labels_for("[Moozzi2] Amanchu! [SP05] BD-Spot - 01 (BD 1920x1080 x.264 Flac)"); | |
| assert!(bd_spot.contains(&("Spot".to_string(), "B-SPECIAL".to_string()))); | |
| assert!(bd_spot.contains(&("01".to_string(), "B-SPECIAL".to_string()))); | |
| assert!(!bd_spot.contains(&("01".to_string(), "B-EPISODE".to_string()))); | |
| let preview_number = | |
| labels_for("[Snow-Raws] 刀使ノ巫女 第02話 予告01 (BD 1920x1080 HEVC-YUV420P10 FLAC)"); | |
| assert!(preview_number.contains(&("02".to_string(), "B-EPISODE".to_string()))); | |
| assert!(preview_number.contains(&("01".to_string(), "B-SPECIAL".to_string()))); | |
| let bleach_movie = labels_for("Bleach the Movie 3 - Fade to Black, I Call Your Name"); | |
| assert!(bleach_movie.contains(&("3".to_string(), "B-TITLE".to_string()))); | |
| assert!(!bleach_movie.contains(&("3".to_string(), "B-EPISODE".to_string()))); | |
| let conan_movie = labels_for( | |
| "[DBD-Raws][Detective Conan Movie 27 The Million-Dollar Pentagram][PV][01][1080P]", | |
| ); | |
| assert!(conan_movie.contains(&("27".to_string(), "B-TITLE".to_string()))); | |
| assert!(conan_movie.contains(&("PV".to_string(), "B-SPECIAL".to_string()))); | |
| let madoka_movie = labels_for( | |
| "[DBD-Raws][Puella Magi Madoka Magica the Movie 01 Beginnings][NCED][1080P]", | |
| ); | |
| assert!(madoka_movie.contains(&("01".to_string(), "B-TITLE".to_string()))); | |
| assert!(madoka_movie.contains(&("Beginnings".to_string(), "B-TITLE".to_string()))); | |
| let fate_first_order = | |
| labels_for("[DBD-Raws][Fate Grand Order ‐First Order‐][PV][01][1080P]"); | |
| assert!(fate_first_order.contains(&("Fate".to_string(), "B-TITLE".to_string()))); | |
| assert!(fate_first_order.contains(&("‐".to_string(), "B-TITLE".to_string()))); | |
| assert!(fate_first_order.contains(&("First".to_string(), "B-TITLE".to_string()))); | |
| let trillion_game = labels_for("[ANi] 一兆$遊戲 - 03 [1080P][Baha][WEB-DL][AAC AVC][CHT]"); | |
| assert!(trillion_game.contains(&("一兆".to_string(), "B-TITLE".to_string()))); | |
| assert!(trillion_game.contains(&("$".to_string(), "B-TITLE".to_string()))); | |
| assert!(trillion_game.contains(&("遊戲".to_string(), "B-TITLE".to_string()))); | |
| let lapis = labels_for("[Nekomoe kissaten&LoliHouse] Lapis Re꞉LiGHTs - PV01 [BDRip 1080p]"); | |
| assert!(lapis.contains(&("Re".to_string(), "B-TITLE".to_string()))); | |
| assert!(lapis.contains(&("꞉".to_string(), "B-TITLE".to_string()))); | |
| assert!(lapis.contains(&("LiGHTs".to_string(), "B-TITLE".to_string()))); | |
| let rezero = | |
| labels_for("TVアニメ『Re:ゼロから始める異世界生活』第10話「鬼がかったやり方」予告"); | |
| assert!(!rezero.contains(&("TV".to_string(), "B-TITLE".to_string()))); | |
| assert!(!rezero.contains(&("アニメ".to_string(), "B-TITLE".to_string()))); | |
| assert!(rezero.contains(&("Re".to_string(), "B-TITLE".to_string()))); | |
| assert!(rezero.contains(&("第".to_string(), "B-EPISODE".to_string()))); | |
| assert!(rezero.contains(&("話".to_string(), "B-EPISODE".to_string()))); | |
| let shark = labels_for("アニメ『おでかけ子ザメ』第10話「かじゅえん」"); | |
| assert!(!shark.contains(&("アニメ".to_string(), "B-TITLE".to_string()))); | |
| assert!(shark.contains(&("おでかけ子ザメ".to_string(), "B-TITLE".to_string()))); | |
| let creditless = | |
| labels_for("[ANK-Raws] デート・ア・ライブⅡ Creditless ED (Bdrip 1920x1080 HEVC FLAC)"); | |
| assert!(creditless.contains(&("Creditless".to_string(), "B-SPECIAL".to_string()))); | |
| assert!(creditless.contains(&("ED".to_string(), "B-SPECIAL".to_string()))); | |
| let no_number = labels_for("[甜甜圈字幕组] 小讨厌 081「爷爷的礼物 No.1」"); | |
| assert!(no_number.contains(&("081".to_string(), "B-EPISODE".to_string()))); | |
| assert!(!no_number.contains(&("1".to_string(), "B-EPISODE".to_string()))); | |
| let bilingual = labels_for( | |
| "辉夜大小姐想让我告白~天才们的恋爱头脑战~.S2-01.中日双语.云光字幕组.[1080p]", | |
| ); | |
| assert!(bilingual.contains(&("中日".to_string(), "B-SOURCE".to_string()))); | |
| assert!(!bilingual.contains(&("中日".to_string(), "B-TITLE".to_string()))); | |
| let nekomoe_lang = labels_for("[Nekomoe kissaten][UniteUp!][05][720p][JPTC]"); | |
| assert!(nekomoe_lang.contains(&("JPTC".to_string(), "B-SOURCE".to_string()))); | |
| assert!(!nekomoe_lang.contains(&("JPTC".to_string(), "B-TITLE".to_string()))); | |
| let hayate = | |
| labels_for("[漏勺rip][Hayate_the_combat_butler_2nd_Season][23][BDrip X264 AAC 720P]"); | |
| assert!(hayate.contains(&("Hayate".to_string(), "B-TITLE".to_string()))); | |
| assert!(hayate.contains(&("2nd".to_string(), "B-SEASON".to_string()))); | |
| assert!(hayate.contains(&("Season".to_string(), "B-SEASON".to_string()))); | |
| assert!(hayate.contains(&("23".to_string(), "B-EPISODE".to_string()))); | |
| let yama = labels_for("[A.I.R.nesSub][Yama_no_Susume_Second_Season][08][720p]"); | |
| assert!(yama.contains(&("Yama".to_string(), "B-TITLE".to_string()))); | |
| assert!(yama.contains(&("Second".to_string(), "B-SEASON".to_string()))); | |
| assert!(yama.contains(&("Season".to_string(), "B-SEASON".to_string()))); | |
| let one_room = labels_for("[DMG][One Room Second Season][00][1080P][BIG5]"); | |
| assert!(one_room.contains(&("One".to_string(), "B-TITLE".to_string()))); | |
| assert!(one_room.contains(&("Second".to_string(), "B-SEASON".to_string()))); | |
| assert!(one_room.contains(&("Season".to_string(), "B-SEASON".to_string()))); | |
| let jade = | |
| labels_for("[GM-Team][国漫][诛仙 第2季][Jade Dynasty Ⅱ][2024][12][AVC][GB][1080P]"); | |
| assert!(jade.contains(&("Jade".to_string(), "B-TITLE".to_string()))); | |
| assert!(jade.contains(&("Dynasty".to_string(), "B-TITLE".to_string()))); | |
| assert!(jade.contains(&("Ⅱ".to_string(), "B-SEASON".to_string()))); | |
| assert!(jade.contains(&("12".to_string(), "B-EPISODE".to_string()))); | |
| let yu_no = labels_for( | |
| "[JYFanSub][Kono_Yo_no_Hate_de_Koi_wo_Utau_Shoujo_YU-NO][23][BIG5][720P][AVC]", | |
| ); | |
| assert!(yu_no.contains(&("NO".to_string(), "B-TITLE".to_string()))); | |
| assert!(yu_no.contains(&("23".to_string(), "B-EPISODE".to_string()))); | |
| let yu_no_dash = | |
| labels_for("[LowPower-Raws] この世の果てで恋を唄う少女YU-NO - 01 (BD 1080P x264 FLAC)"); | |
| assert!(yu_no_dash.contains(&("NO".to_string(), "B-TITLE".to_string()))); | |
| assert!(yu_no_dash.contains(&("01".to_string(), "B-EPISODE".to_string()))); | |
| let fox = labels_for( | |
| "[GM-Team][国漫][狐妖小红娘 尾生篇][Fox Spirit Matchmaker Ⅷ][2019][05][AVC][GB][1080P]", | |
| ); | |
| assert!(fox.contains(&("Fox".to_string(), "B-TITLE".to_string()))); | |
| assert!(fox.contains(&("Ⅷ".to_string(), "B-SEASON".to_string()))); | |
| let kage = | |
| labels_for("[LKSUB][Kage no Jitsuryokusha ni Naritakute! 2nd Season][03][GB][720P]"); | |
| assert!(kage.contains(&("2nd".to_string(), "B-SEASON".to_string()))); | |
| assert!(kage.contains(&(" ".to_string(), "B-SEASON".to_string()))); | |
| assert!(kage.contains(&("Season".to_string(), "B-SEASON".to_string()))); | |
| let tiger = labels_for("[虎面人W][Tiger Mask W][01][简日][720p]"); | |
| assert!(tiger.contains(&("Tiger".to_string(), "B-TITLE".to_string()))); | |
| assert!(tiger.contains(&("W".to_string(), "B-TITLE".to_string()))); | |
| assert!(tiger.contains(&("01".to_string(), "B-EPISODE".to_string()))); | |
| let date_live_special = | |
| labels_for("[ANK-Raws] デート・ア・ライブⅡ CM01 (BDrip 1920x1080 HEVC-YUV420P10 FLAC)"); | |
| assert!(date_live_special.contains(&("Ⅱ".to_string(), "B-SEASON".to_string()))); | |
| assert!(date_live_special.contains(&("CM01".to_string(), "B-SPECIAL".to_string()))); | |
| let lupin_part = labels_for("[SnowDream][Part 5_Lupin Sansei Part 5][01][BIG5][720P]"); | |
| assert!(lupin_part.contains(&("Lupin".to_string(), "B-TITLE".to_string()))); | |
| assert!(lupin_part.contains(&("Sansei".to_string(), "B-TITLE".to_string()))); | |
| assert!(!lupin_part.contains(&("Part".to_string(), "B-TITLE".to_string()))); | |
| assert!(lupin_part.contains(&("5".to_string(), "B-SEASON".to_string()))); | |
| assert!(!lupin_part.contains(&("5".to_string(), "B-SPECIAL".to_string()))); | |
| let roman_leaf = dmhy_record( | |
| "Ⅰ 001 魯邦燃起了鬥志", | |
| "tpl_test", | |
| &suggested_roles("TEXT SEP EPISODE SEP TEXT"), | |
| ) | |
| .unwrap(); | |
| assert!(roman_leaf | |
| .tokens | |
| .iter() | |
| .zip(roman_leaf.labels.iter()) | |
| .any(|(token, label)| token == "Ⅰ" && label == "B-SEASON")); | |
| assert!(audit_warnings(&roman_leaf).contains(&"no_title".to_string())); | |
| let hallow = labels_for("[c.c动漫 ccwzz.cc][驱魔少年HALLOW][第09话][GB][720p]"); | |
| assert!(hallow.contains(&("驱魔少年HALLOW".to_string(), "B-TITLE".to_string()))); | |
| assert!(hallow.contains(&("第09话".to_string(), "B-EPISODE".to_string()))); | |
| let fairy = labels_for("[魔導少年 最終章][EP35][繁体][1080P]"); | |
| assert!(fairy.contains(&("魔導少年".to_string(), "B-TITLE".to_string()))); | |
| assert!(fairy.contains(&("EP35".to_string(), "B-EPISODE".to_string()))); | |
| let mebius = labels_for("【CXRAW】【ウルトラマンメビウス】【22】【日々の未来】【DVDrip】【x264 Hi10P AAC】【MP4】"); | |
| assert!(mebius.contains(&("ウルトラマンメビウス".to_string(), "B-TITLE".to_string()))); | |
| assert!(mebius.contains(&("22".to_string(), "B-EPISODE".to_string()))); | |
| let battle = labels_for("斗破苍穹三年之约第01话"); | |
| assert!(battle.contains(&("斗破苍穹三年之约".to_string(), "B-TITLE".to_string()))); | |
| assert!(battle.contains(&("第".to_string(), "B-EPISODE".to_string()))); | |
| assert!(battle.contains(&("01".to_string(), "B-EPISODE".to_string()))); | |
| assert!(battle.contains(&("话".to_string(), "B-EPISODE".to_string()))); | |
| let hakumei = labels_for("妖精森林的小不点01"); | |
| assert!(hakumei.contains(&("妖精森林的小不点".to_string(), "B-TITLE".to_string()))); | |
| assert!(hakumei.contains(&("01".to_string(), "B-EPISODE".to_string()))); | |
| let decimal_episode_title = labels_for("无限系统树:第1话可能性的起点"); | |
| assert!(decimal_episode_title.contains(&("无限系统树".to_string(), "B-TITLE".to_string()))); | |
| assert!(decimal_episode_title.contains(&("第".to_string(), "B-EPISODE".to_string()))); | |
| assert!(decimal_episode_title.contains(&("1".to_string(), "B-EPISODE".to_string()))); | |
| let hash_range = labels_for("花田少年史#1-3"); | |
| assert!(hash_range.contains(&("花田少年史".to_string(), "B-TITLE".to_string()))); | |
| assert!(hash_range.contains(&("1".to_string(), "B-EPISODE".to_string()))); | |
| assert!(hash_range.contains(&("-".to_string(), "B-EPISODE".to_string()))); | |
| assert!(hash_range.contains(&("3".to_string(), "B-EPISODE".to_string()))); | |
| let movie_number = labels_for("[Kamigami] Haikyuu!! Movie - 01 [BD 1080p x265 Ma10p AAC]"); | |
| assert!(movie_number.contains(&("Haikyuu".to_string(), "B-TITLE".to_string()))); | |
| assert!(movie_number.contains(&("01".to_string(), "B-SPECIAL".to_string()))); | |
| assert!(!movie_number.contains(&("01".to_string(), "B-EPISODE".to_string()))); | |
| let ajin_movie = labels_for("[Moozzi2] Ajin The Movie - 01 (BD 1920x1080 x.264 FLACx2)"); | |
| assert!(ajin_movie.contains(&("Ajin".to_string(), "B-TITLE".to_string()))); | |
| assert!(ajin_movie.contains(&("01".to_string(), "B-SPECIAL".to_string()))); | |
| let eien = labels_for( | |
| "[Nekomoe kissaten&LoliHouse] Eien no 831 [WebRip 1080p HEVC-10bit AAC ASSx2]", | |
| ); | |
| assert!(eien.contains(&("Eien".to_string(), "B-TITLE".to_string()))); | |
| assert!(eien.contains(&("831".to_string(), "B-TITLE".to_string()))); | |
| let ep_only = | |
| dmhy_record("Ep.25", "tpl_test", &suggested_roles("TEXT SEP EPISODE")).unwrap(); | |
| assert!(audit_warnings(&ep_only).contains(&"no_title".to_string())); | |
| } | |
| } | |