Add release-tag naming-scheme fields (resolution, source, codecs, group)
Match now also parses filename-only release-tag text - resolution, source
(BluRay/HDTV/WEB-DL/...), video/audio codec, and release group - via a new
guess_release_info() in renamr-core's matching module, wired through a new
guess_release_info Tauri command and called alongside the existing TV/movie
guess for every file. Naming schemes can now reference
{resolution}/{source}/{video codec}/{audio codec}/{release group}, or
FileBot's own short names ({vf}/{source}/{vc}/{ac}/{group}).
This commit is contained in:
@@ -171,6 +171,161 @@ pub fn guess_movie(filename: &str) -> Option<MovieGuess> {
|
||||
Some(MovieGuess { title, year })
|
||||
}
|
||||
|
||||
/// Release-tag metadata guessed from a filename's own text: resolution, source
|
||||
/// (capture/rip type), video codec, audio codec, and release group. Ports the
|
||||
/// filename-parsing half of the original FileBot's `ReleaseInfo` class - the same
|
||||
/// regex-based tag recognition `MediaBindingBean` falls back to when no real media
|
||||
/// probe (ffprobe/MediaInfo) is available, which Renamr never has, so this is the
|
||||
/// only source for these fields here. Deliberately independent of `guess_episode`/
|
||||
/// `guess_movie`: a release name carries these tags (or doesn't) regardless of
|
||||
/// whether it's a TV episode or a movie, so callers run this alongside whichever of
|
||||
/// those two applies.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ReleaseInfo {
|
||||
pub resolution: Option<String>,
|
||||
pub source: Option<String>,
|
||||
pub video_codec: Option<String>,
|
||||
pub audio_codec: Option<String>,
|
||||
pub group: Option<String>,
|
||||
}
|
||||
|
||||
impl ReleaseInfo {
|
||||
fn is_empty(&self) -> bool {
|
||||
self.resolution.is_none()
|
||||
&& self.source.is_none()
|
||||
&& self.video_codec.is_none()
|
||||
&& self.audio_codec.is_none()
|
||||
&& self.group.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
fn resolution_pattern() -> &'static Regex {
|
||||
static RE: OnceLock<Regex> = OnceLock::new();
|
||||
RE.get_or_init(|| Regex::new(r"(?i)\b(480p|576p|720p|1080p|1080i|2160p|4320p)\b").unwrap())
|
||||
}
|
||||
|
||||
// Longer/more specific alternatives are listed before shorter ones they'd otherwise be
|
||||
// shadowed by (e.g. "web-dl" before "web"), since regex alternation takes the first
|
||||
// branch that matches at a given position, not the longest.
|
||||
fn source_pattern() -> &'static Regex {
|
||||
static RE: OnceLock<Regex> = OnceLock::new();
|
||||
RE.get_or_init(|| {
|
||||
Regex::new(r"(?i)\b(web-?dl|webrip|web|blu-?ray|bdrip|brrip|hddvd|dvdscr|dvdrip|dvd|hdtv|pdtv|sdtv|dsr)\b")
|
||||
.unwrap()
|
||||
})
|
||||
}
|
||||
|
||||
fn video_codec_pattern() -> &'static Regex {
|
||||
static RE: OnceLock<Regex> = OnceLock::new();
|
||||
RE.get_or_init(|| Regex::new(r"(?i)\b(x264|x265|h\.?264|h\.?265|hevc|xvid|divx|avc)\b").unwrap())
|
||||
}
|
||||
|
||||
fn audio_codec_pattern() -> &'static Regex {
|
||||
static RE: OnceLock<Regex> = OnceLock::new();
|
||||
RE.get_or_init(|| Regex::new(r"(?i)\b(dts-hd|dts|ddp5\.1|ddp|eac3|ac3|truehd|flac|aac|mp3)\b").unwrap())
|
||||
}
|
||||
|
||||
// The release group is conventionally the last `-TAG` right before the file
|
||||
// extension (e.g. `...x264-KILLERS.mkv`); only matches when there's a leading dash so
|
||||
// a bare trailing quality tag like `...x264` (no group) is correctly left alone.
|
||||
fn release_group_pattern() -> &'static Regex {
|
||||
static RE: OnceLock<Regex> = OnceLock::new();
|
||||
RE.get_or_init(|| Regex::new(r"-([A-Za-z0-9][A-Za-z0-9.]{1,14})$").unwrap())
|
||||
}
|
||||
|
||||
/// Strips a trailing `.ext` (if any) so end-anchored patterns like the release-group
|
||||
/// one can match against the release name itself rather than the file extension.
|
||||
fn strip_extension(filename: &str) -> &str {
|
||||
match filename.rfind('.') {
|
||||
Some(i) if i > 0 => &filename[..i],
|
||||
_ => filename,
|
||||
}
|
||||
}
|
||||
|
||||
fn canonical_source(raw: &str) -> String {
|
||||
match raw.to_lowercase().replace(['-', ' '], "").as_str() {
|
||||
"webdl" => "WEB-DL",
|
||||
"webrip" => "WEBRip",
|
||||
"web" => "WEB",
|
||||
"bluray" => "BluRay",
|
||||
"bdrip" => "BDRip",
|
||||
"brrip" => "BRRip",
|
||||
"hddvd" => "HDDVD",
|
||||
"dvdscr" => "DVDSCR",
|
||||
"dvdrip" => "DVDRip",
|
||||
"dvd" => "DVD",
|
||||
"hdtv" => "HDTV",
|
||||
"pdtv" => "PDTV",
|
||||
"sdtv" => "SDTV",
|
||||
"dsr" => "DSR",
|
||||
_ => return raw.to_string(),
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn canonical_video_codec(raw: &str) -> String {
|
||||
match raw.to_lowercase().replace(['.', ' '], "").as_str() {
|
||||
"x264" => "x264",
|
||||
"x265" => "x265",
|
||||
"h264" => "H.264",
|
||||
"h265" => "H.265",
|
||||
"hevc" => "HEVC",
|
||||
"xvid" => "XviD",
|
||||
"divx" => "DivX",
|
||||
"avc" => "AVC",
|
||||
_ => return raw.to_string(),
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn canonical_audio_codec(raw: &str) -> String {
|
||||
match raw.to_lowercase().replace(['-', '.', ' '], "").as_str() {
|
||||
"dtshd" => "DTS-HD",
|
||||
"dts" => "DTS",
|
||||
"ddp51" => "DDP5.1",
|
||||
"ddp" => "DDP",
|
||||
"eac3" => "EAC3",
|
||||
"ac3" => "AC3",
|
||||
"truehd" => "TrueHD",
|
||||
"flac" => "FLAC",
|
||||
"aac" => "AAC",
|
||||
"mp3" => "MP3",
|
||||
_ => return raw.to_string(),
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Guesses release-tag metadata from a filename's own text (see `ReleaseInfo`).
|
||||
/// Every field is independent and optional; returns `None` only when nothing at all
|
||||
/// was recognized, matching the `guess_episode`/`guess_movie` convention of `None`
|
||||
/// meaning "nothing local to offer here", not "this file has no metadata".
|
||||
pub fn guess_release_info(filename: &str) -> Option<ReleaseInfo> {
|
||||
let base = strip_extension(filename);
|
||||
|
||||
let info = ReleaseInfo {
|
||||
resolution: resolution_pattern()
|
||||
.find(base)
|
||||
.map(|m| m.as_str().to_lowercase()),
|
||||
source: source_pattern().find(base).map(|m| canonical_source(m.as_str())),
|
||||
video_codec: video_codec_pattern()
|
||||
.find(base)
|
||||
.map(|m| canonical_video_codec(m.as_str())),
|
||||
audio_codec: audio_codec_pattern()
|
||||
.find(base)
|
||||
.map(|m| canonical_audio_codec(m.as_str())),
|
||||
group: release_group_pattern()
|
||||
.captures(base)
|
||||
.map(|c| c[1].to_string()),
|
||||
};
|
||||
|
||||
if info.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(info)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -256,4 +411,62 @@ mod tests {
|
||||
fn returns_none_when_theres_nothing_but_a_year_left() {
|
||||
assert!(guess_movie("2010").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_full_release_info_from_a_tv_episode_filename() {
|
||||
let info = guess_release_info("Breaking.Bad.S05E14.720p.HDTV.x264-KILLERS.mkv").unwrap();
|
||||
assert_eq!(info.resolution.as_deref(), Some("720p"));
|
||||
assert_eq!(info.source.as_deref(), Some("HDTV"));
|
||||
assert_eq!(info.video_codec.as_deref(), Some("x264"));
|
||||
assert_eq!(info.audio_codec, None);
|
||||
assert_eq!(info.group.as_deref(), Some("KILLERS"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_release_info_with_mixed_case_group_preserved() {
|
||||
let info = guess_release_info("The.Office.US.S03E01.DVDRip.XviD-SAiNTS.avi").unwrap();
|
||||
assert_eq!(info.source.as_deref(), Some("DVDRip"));
|
||||
assert_eq!(info.video_codec.as_deref(), Some("XviD"));
|
||||
assert_eq!(info.group.as_deref(), Some("SAiNTS"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_release_info_from_a_movie_filename_with_audio_codec_and_no_group() {
|
||||
let info = guess_release_info("Inception.2010.1080p.BluRay.DTS.x264.mkv").unwrap();
|
||||
assert_eq!(info.resolution.as_deref(), Some("1080p"));
|
||||
assert_eq!(info.source.as_deref(), Some("BluRay"));
|
||||
assert_eq!(info.video_codec.as_deref(), Some("x264"));
|
||||
assert_eq!(info.audio_codec.as_deref(), Some("DTS"));
|
||||
assert_eq!(info.group, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_web_dl_distinct_from_bare_web() {
|
||||
let webdl = guess_release_info("Show.S01E01.WEB-DL.mkv").unwrap();
|
||||
assert_eq!(webdl.source.as_deref(), Some("WEB-DL"));
|
||||
|
||||
let web = guess_release_info("Show.S01E02.WEB.mkv").unwrap();
|
||||
assert_eq!(web.source.as_deref(), Some("WEB"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_h264_and_hevc_video_codec_spellings() {
|
||||
let dotted = guess_release_info("Movie.2020.h.264.mkv").unwrap();
|
||||
assert_eq!(dotted.video_codec.as_deref(), Some("H.264"));
|
||||
|
||||
let hevc = guess_release_info("Movie.2020.HEVC.mkv").unwrap();
|
||||
assert_eq!(hevc.video_codec.as_deref(), Some("HEVC"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_mistake_a_bare_trailing_quality_tag_for_a_release_group() {
|
||||
// No leading dash before "x264" here - nothing to call a release group.
|
||||
let info = guess_release_info("Some.Movie.2020.1080p.x264.mkv").unwrap();
|
||||
assert_eq!(info.group, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_none_for_a_filename_with_no_recognizable_release_tags() {
|
||||
assert!(guess_release_info("The Matrix.mkv").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user