From 94eb26f778fee9f9ca87a5e8ad8511036503351e Mon Sep 17 00:00:00 2001 From: Renamr Scaffold Date: Sat, 29 Aug 2026 09:29:14 +0000 Subject: [PATCH] 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}). --- README.md | 26 ++- crates/renamr-core/src/matching/mod.rs | 213 +++++++++++++++++++++++++ src-tauri/src/lib.rs | 15 +- src/components/RenamePanel.tsx | 115 ++++++++++--- 4 files changed, 343 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 88381dc..0738923 100644 --- a/README.md +++ b/README.md @@ -17,8 +17,13 @@ target). It can also organize files into subfolders — including ones that don' yet — when the naming scheme includes a `/`. Every completed batch is logged to a history panel that can undo it (moving files back for Move, or just removing what was created for Copy/Symlink). A single loaded batch can freely mix TV episodes and movies -— each file only fills in the fields that apply to it. Full media-metadata bindings are -not yet ported. See "Porting roadmap" below. +— each file only fills in the fields that apply to it. "Match" also parses release-tag +text straight out of the filename — resolution, source (BluRay/HDTV/WEB-DL/…), +video/audio codec, release group — so naming schemes can reference +`{resolution}`/`{source}`/`{video codec}`/`{audio codec}`/`{release group}` (or +FileBot's own `{vf}`/`{source}`/`{vc}`/`{ac}`/`{group}`) without a media prober; the +rest of FileBot's full media-metadata bindings (real probed video/audio stream info) +are not yet ported. See "Porting roadmap" below. The naming scheme is fully user-editable from the app (the "Naming Scheme" button next to the New Names list) — the default is `{show name} - {season number}x{episode @@ -111,9 +116,12 @@ that item is skipped rather than overwritten. ``` crates/renamr-core/ Pure Rust core: naming-expression engine (ported), local filename-based season/episode *and movie title/year* matching - (ported, `matching/`), text-similarity scoring for disambiguating - search results (`similarity/`, partial `EpisodeMetrics` port), a - TMDB metadata client (`web/tmdb.rs` — TV search + episode lookup + (ported, `matching/`), plus filename-based release-tag parsing in + the same module (`guess_release_info` — resolution, source, video/ + audio codec, release group), text-similarity scoring for + disambiguating search results (`similarity/`, partial + `EpisodeMetrics` port), a TMDB metadata client (`web/tmdb.rs` — TV + search + episode lookup and movie search + lookup, both disambiguated via `similarity/`, tested against a mocked HTTP server via `mockito`), the on-disk rename engine (`rename/` — skip-on-conflict, Move/Copy/Symlink @@ -188,7 +196,13 @@ ported from, for anyone picking this up: 1. **Full naming-scheme bindings** — `net.filebot.format.MediaBindingBean` (1354 lines): all the `{n}`, `{s00e00}`, `{airdate}`, `{vf}` (video format), etc. binding - properties. Currently only whatever bindings you pass in manually are available. + properties. A filename-tag-based subset is now ported and wired to "Match" + (`guess_release_info` in `crates/renamr-core/src/matching`): `{vf}`/`{resolution}`, + `{source}`, `{vc}`/`{video codec}`, `{ac}`/`{audio codec}`, `{group}`/`{release + group}`. What's still missing is everything MediaBindingBean derives from actually + *probing* the media file (real video/audio stream resolution, duration, bitrate, + frame rate, file size/hash, etc.) — Renamr ships no ffprobe/MediaInfo dependency, so + those bindings only work when a release name happens to carry the info as text. 2. **String/expression helper methods** — `net.filebot.format.ExpressionFormatMethods` (636 lines): FileBot-specific Groovy string extensions like `.pad()`, `.sortName()`. Only a small subset (`any`, `all_of`, `concat`) is ported so far diff --git a/crates/renamr-core/src/matching/mod.rs b/crates/renamr-core/src/matching/mod.rs index ec5610e..0bad2ff 100644 --- a/crates/renamr-core/src/matching/mod.rs +++ b/crates/renamr-core/src/matching/mod.rs @@ -171,6 +171,161 @@ pub fn guess_movie(filename: &str) -> Option { 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, + pub source: Option, + pub video_codec: Option, + pub audio_codec: Option, + pub group: Option, +} + +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 = 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 = 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 = 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 = 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 = 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 { + 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()); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 77286f2..24fcb79 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -4,7 +4,7 @@ mod metadata; mod settings; use renamr_core::format::ExpressionFormat; -use renamr_core::matching::{self, EpisodeGuess, MovieGuess}; +use renamr_core::matching::{self, EpisodeGuess, MovieGuess, ReleaseInfo}; use renamr_core::rename::{self, RenameAction, RenamePlanItem, RenameResult}; use renamr_core::string_bindings; use std::collections::HashMap; @@ -39,6 +39,18 @@ fn guess_movie(filename: String) -> Option { matching::guess_movie(&filename) } +/// Guesses release-tag metadata (resolution, source, video/audio codec, release +/// group) from a filename's own text using local regex patterns only - no network +/// calls, no real media probing (Renamr never ships an ffprobe/MediaInfo dependency, +/// so filename tags are the only source for these fields). Independent of +/// `guess_episode`/`guess_movie`: called alongside whichever of those two applies, +/// since a release name carries (or doesn't carry) these tags regardless of whether +/// it's an episode or a movie. +#[tauri::command] +fn guess_release_info(filename: String) -> Option { + matching::guess_release_info(&filename) +} + /// Executes a batch of planned on-disk rename/copy/symlink operations (optionally /// moving/copying files into subfolders - see `renamr_core::rename`; conflicting /// targets are always skipped, never overwritten). `action` picks which of the three @@ -67,6 +79,7 @@ pub fn run() { render_expression, guess_episode, guess_movie, + guess_release_info, execute_rename, metadata::fetch_metadata, metadata::fetch_movie_metadata, diff --git a/src/components/RenamePanel.tsx b/src/components/RenamePanel.tsx index c7c0b38..1d69667 100644 --- a/src/components/RenamePanel.tsx +++ b/src/components/RenamePanel.tsx @@ -52,6 +52,19 @@ interface MovieGuess { // What runMatching resolved a file to, before it's written into the item's bindings. type MatchGuess = { kind: "tv"; episode: EpisodeGuess } | { kind: "movie"; movie: MovieGuess }; +// Result of the local, filename-only release-tag matcher (renamr-core's +// `matching::guess_release_info`, exposed via the `guess_release_info` Tauri command). +// Independent of the TV/movie guess above - a release name carries (or doesn't carry) +// these tags regardless of what kind of media it is, so this runs alongside whichever +// of `guess_episode`/`guess_movie` applied. Every field is optional on its own. +interface ReleaseInfo { + resolution: string | null; + source: string | null; + videoCodec: string | null; + audioCodec: string | null; + group: string | null; +} + function pad2(n: number): string { return n.toString().padStart(2, "0"); } @@ -178,6 +191,13 @@ const SAMPLE_ITEMS: Item[] = [ "season number": "5", "episode number": "14", "episode name": "Ozymandias", + vf: "720p", + source: "HDTV", + vc: "x264", + group: "KILLERS", + resolution: "720p", + "video codec": "x264", + "release group": "KILLERS", }, isSample: true, mediaKind: "tv", @@ -194,6 +214,13 @@ const SAMPLE_ITEMS: Item[] = [ "season number": "5", "episode number": "15", "episode name": "Granite State", + vf: "720p", + source: "HDTV", + vc: "x264", + group: "KILLERS", + resolution: "720p", + "video codec": "x264", + "release group": "KILLERS", }, isSample: true, mediaKind: "tv", @@ -210,6 +237,11 @@ const SAMPLE_ITEMS: Item[] = [ "season number": "3", "episode number": "01", "episode name": "Gay Witch Hunt", + source: "DVDRip", + vc: "XviD", + group: "SAiNTS", + "video codec": "XviD", + "release group": "SAiNTS", }, isSample: true, mediaKind: "tv", @@ -483,39 +515,68 @@ export default function RenamePanel() { return; } - const results: Array = await Promise.all( - targets.map(async (item): Promise => { + const results: Array = await Promise.all( + targets.map(async (item): Promise => { + // Release tags (resolution/source/codecs/group) are independent of TV-vs-movie, + // so kick that guess off alongside whichever of the two below applies. + const releasePromise = invoke("guess_release_info", { + filename: item.originalName, + }).catch(() => null); + + let mediaGuess: MatchGuess | null = null; try { const episode = await invoke("guess_episode", { filename: item.originalName, }); - if (episode) return [item.id, { kind: "tv", episode }]; + if (episode) mediaGuess = { kind: "tv", episode }; } catch { // Fall through and try a movie guess instead. } - try { - const movie = await invoke("guess_movie", { - filename: item.originalName, - }); - if (movie) return [item.id, { kind: "movie", movie }]; - } catch { - // Neither guess worked - leave this item unmatched. + if (!mediaGuess) { + try { + const movie = await invoke("guess_movie", { + filename: item.originalName, + }); + if (movie) mediaGuess = { kind: "movie", movie }; + } catch { + // Neither guess worked - leave this item unmatched. + } } - return [item.id, null]; + + return [item.id, mediaGuess, await releasePromise]; }) ); - const guesses = new Map(results); + const guesses = new Map(results.map(([id, guess]) => [id, guess] as const)); + const releaseGuesses = new Map(results.map(([id, , release]) => [id, release] as const)); // Count matches from the results directly, not inside the setItems updater below: // React doesn't run a functional updater synchronously, so a counter mutated in // there wouldn't be ready yet for the status message/auto-upgrade check right after. const matchedTv = results.filter(([, guess]) => guess?.kind === "tv").length; const matchedMovie = results.filter(([, guess]) => guess?.kind === "movie").length; const matched = matchedTv + matchedMovie; + const tagged = results.filter(([, , release]) => release !== null).length; setItems((prev) => prev.map((item) => { const guess = guesses.get(item.id); - if (!guess) return item; + const release = releaseGuesses.get(item.id); + + // Release-tag fields apply on top of whatever the TV/movie guess below + // contributes (or nothing at all, for a file with no season/episode or movie + // title recognized but a quality tag in its name). + const releaseBindings: Record | null = release + ? { + ...(release.resolution ? { vf: release.resolution, resolution: release.resolution } : {}), + ...(release.source ? { source: release.source } : {}), + ...(release.videoCodec ? { vc: release.videoCodec, "video codec": release.videoCodec } : {}), + ...(release.audioCodec ? { ac: release.audioCodec, "audio codec": release.audioCodec } : {}), + ...(release.group ? { group: release.group, "release group": release.group } : {}), + } + : null; + + if (!guess) { + return releaseBindings ? { ...item, bindings: { ...item.bindings, ...releaseBindings } } : item; + } if (guess.kind === "tv") { const { episode } = guess; @@ -534,6 +595,7 @@ export default function RenamePanel() { "show name": seriesName, "season number": String(episode.season), "episode number": pad2(episode.episode), + ...releaseBindings, }, }; } @@ -551,6 +613,7 @@ export default function RenamePanel() { // Human-readable aliases. "movie name": title, ...(movie.year ? { year: String(movie.year) } : {}), + ...releaseBindings, }, }; }) @@ -566,7 +629,7 @@ export default function RenamePanel() { setStatus( matched > 0 - ? `Matched ${matched} of ${targets.length} file${targets.length === 1 ? "" : "s"} (${matchedTv} TV, ${matchedMovie} movie${matchedMovie === 1 ? "" : "s"}; filename-based, local only).` + ? `Matched ${matched} of ${targets.length} file${targets.length === 1 ? "" : "s"} (${matchedTv} TV, ${matchedMovie} movie${matchedMovie === 1 ? "" : "s"}${tagged > 0 ? `, ${tagged} with release tags` : ""}; filename-based, local only).` : "No season/episode or movie title recognized in the loaded filenames." ); window.clearTimeout((runMatching as any)._t); @@ -1094,13 +1157,27 @@ export default function RenamePanel() { {"{year}"} {"{airdate}"} +
+ + Release tags, after Match (TV or movies, local, offline) + + {"{resolution}"} + {"{source}"} + {"{video codec}"} + {"{audio codec}"} + {"{release group}"} +

- Match scans filenames locally for season/episode or movie title/year patterns (no network calls); Fetch - Data resolves that guess against real TMDB metadata for the real title, episode title, and air/release - date — needs a free TMDB API token set in TMDB Settings first. A batch can freely mix TV - and movie files — each file only fills in the fields that apply to it. FileBot's own short field names - ({"{n}"}, {"{s00e00}"}, {"{t}"}, {"{y}"}, etc.) still + Match scans filenames locally for season/episode or movie title/year patterns, plus release-tag text + like resolution, source (BluRay/HDTV/WEB-DL/…), video/audio codec, and release group — none of this + needs a network call, since it's all parsed straight from the filename itself (Renamr has no video/audio + file prober, so a tag that isn't in the filename text just won't be available). Fetch Data resolves the + season/episode or movie guess against real TMDB metadata for the real title, episode title, and + air/release date — needs a free TMDB API token set in TMDB Settings first. A batch can + freely mix TV and movie files — each file only fills in the fields that apply to it. FileBot's own short + field names ({"{n}"}, {"{s00e00}"}, {"{t}"}, {"{y}"}, + {"{vf}"}, {"{vc}"}, {"{ac}"}, {"{group}"}, etc.) still work too, side by side with the names above.