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:
Renamr Scaffold
2026-08-29 09:29:14 +00:00
parent fd4e6f66f0
commit 94eb26f778
4 changed files with 343 additions and 26 deletions
+213
View File
@@ -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());
}
}