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:
@@ -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
|
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
|
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
|
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
|
— each file only fills in the fields that apply to it. "Match" also parses release-tag
|
||||||
not yet ported. See "Porting roadmap" below.
|
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
|
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
|
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
|
crates/renamr-core/ Pure Rust core: naming-expression engine (ported), local
|
||||||
filename-based season/episode *and movie title/year* matching
|
filename-based season/episode *and movie title/year* matching
|
||||||
(ported, `matching/`), text-similarity scoring for disambiguating
|
(ported, `matching/`), plus filename-based release-tag parsing in
|
||||||
search results (`similarity/`, partial `EpisodeMetrics` port), a
|
the same module (`guess_release_info` — resolution, source, video/
|
||||||
TMDB metadata client (`web/tmdb.rs` — TV search + episode lookup
|
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/`,
|
and movie search + lookup, both disambiguated via `similarity/`,
|
||||||
tested against a mocked HTTP server via `mockito`), the on-disk
|
tested against a mocked HTTP server via `mockito`), the on-disk
|
||||||
rename engine (`rename/` — skip-on-conflict, Move/Copy/Symlink
|
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):
|
1. **Full naming-scheme bindings** — `net.filebot.format.MediaBindingBean` (1354 lines):
|
||||||
all the `{n}`, `{s00e00}`, `{airdate}`, `{vf}` (video format), etc. binding
|
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`
|
2. **String/expression helper methods** — `net.filebot.format.ExpressionFormatMethods`
|
||||||
(636 lines): FileBot-specific Groovy string extensions like `.pad()`, `.sortName()`.
|
(636 lines): FileBot-specific Groovy string extensions like `.pad()`, `.sortName()`.
|
||||||
Only a small subset (`any`, `all_of`, `concat`) is ported so far
|
Only a small subset (`any`, `all_of`, `concat`) is ported so far
|
||||||
|
|||||||
@@ -171,6 +171,161 @@ pub fn guess_movie(filename: &str) -> Option<MovieGuess> {
|
|||||||
Some(MovieGuess { title, year })
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -256,4 +411,62 @@ mod tests {
|
|||||||
fn returns_none_when_theres_nothing_but_a_year_left() {
|
fn returns_none_when_theres_nothing_but_a_year_left() {
|
||||||
assert!(guess_movie("2010").is_none());
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-1
@@ -4,7 +4,7 @@ mod metadata;
|
|||||||
mod settings;
|
mod settings;
|
||||||
|
|
||||||
use renamr_core::format::ExpressionFormat;
|
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::rename::{self, RenameAction, RenamePlanItem, RenameResult};
|
||||||
use renamr_core::string_bindings;
|
use renamr_core::string_bindings;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
@@ -39,6 +39,18 @@ fn guess_movie(filename: String) -> Option<MovieGuess> {
|
|||||||
matching::guess_movie(&filename)
|
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<ReleaseInfo> {
|
||||||
|
matching::guess_release_info(&filename)
|
||||||
|
}
|
||||||
|
|
||||||
/// Executes a batch of planned on-disk rename/copy/symlink operations (optionally
|
/// Executes a batch of planned on-disk rename/copy/symlink operations (optionally
|
||||||
/// moving/copying files into subfolders - see `renamr_core::rename`; conflicting
|
/// moving/copying files into subfolders - see `renamr_core::rename`; conflicting
|
||||||
/// targets are always skipped, never overwritten). `action` picks which of the three
|
/// targets are always skipped, never overwritten). `action` picks which of the three
|
||||||
@@ -67,6 +79,7 @@ pub fn run() {
|
|||||||
render_expression,
|
render_expression,
|
||||||
guess_episode,
|
guess_episode,
|
||||||
guess_movie,
|
guess_movie,
|
||||||
|
guess_release_info,
|
||||||
execute_rename,
|
execute_rename,
|
||||||
metadata::fetch_metadata,
|
metadata::fetch_metadata,
|
||||||
metadata::fetch_movie_metadata,
|
metadata::fetch_movie_metadata,
|
||||||
|
|||||||
@@ -52,6 +52,19 @@ interface MovieGuess {
|
|||||||
// What runMatching resolved a file to, before it's written into the item's bindings.
|
// 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 };
|
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 {
|
function pad2(n: number): string {
|
||||||
return n.toString().padStart(2, "0");
|
return n.toString().padStart(2, "0");
|
||||||
}
|
}
|
||||||
@@ -178,6 +191,13 @@ const SAMPLE_ITEMS: Item[] = [
|
|||||||
"season number": "5",
|
"season number": "5",
|
||||||
"episode number": "14",
|
"episode number": "14",
|
||||||
"episode name": "Ozymandias",
|
"episode name": "Ozymandias",
|
||||||
|
vf: "720p",
|
||||||
|
source: "HDTV",
|
||||||
|
vc: "x264",
|
||||||
|
group: "KILLERS",
|
||||||
|
resolution: "720p",
|
||||||
|
"video codec": "x264",
|
||||||
|
"release group": "KILLERS",
|
||||||
},
|
},
|
||||||
isSample: true,
|
isSample: true,
|
||||||
mediaKind: "tv",
|
mediaKind: "tv",
|
||||||
@@ -194,6 +214,13 @@ const SAMPLE_ITEMS: Item[] = [
|
|||||||
"season number": "5",
|
"season number": "5",
|
||||||
"episode number": "15",
|
"episode number": "15",
|
||||||
"episode name": "Granite State",
|
"episode name": "Granite State",
|
||||||
|
vf: "720p",
|
||||||
|
source: "HDTV",
|
||||||
|
vc: "x264",
|
||||||
|
group: "KILLERS",
|
||||||
|
resolution: "720p",
|
||||||
|
"video codec": "x264",
|
||||||
|
"release group": "KILLERS",
|
||||||
},
|
},
|
||||||
isSample: true,
|
isSample: true,
|
||||||
mediaKind: "tv",
|
mediaKind: "tv",
|
||||||
@@ -210,6 +237,11 @@ const SAMPLE_ITEMS: Item[] = [
|
|||||||
"season number": "3",
|
"season number": "3",
|
||||||
"episode number": "01",
|
"episode number": "01",
|
||||||
"episode name": "Gay Witch Hunt",
|
"episode name": "Gay Witch Hunt",
|
||||||
|
source: "DVDRip",
|
||||||
|
vc: "XviD",
|
||||||
|
group: "SAiNTS",
|
||||||
|
"video codec": "XviD",
|
||||||
|
"release group": "SAiNTS",
|
||||||
},
|
},
|
||||||
isSample: true,
|
isSample: true,
|
||||||
mediaKind: "tv",
|
mediaKind: "tv",
|
||||||
@@ -483,39 +515,68 @@ export default function RenamePanel() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const results: Array<readonly [string, MatchGuess | null]> = await Promise.all(
|
const results: Array<readonly [string, MatchGuess | null, ReleaseInfo | null]> = await Promise.all(
|
||||||
targets.map(async (item): Promise<readonly [string, MatchGuess | null]> => {
|
targets.map(async (item): Promise<readonly [string, MatchGuess | null, ReleaseInfo | null]> => {
|
||||||
|
// 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<ReleaseInfo | null>("guess_release_info", {
|
||||||
|
filename: item.originalName,
|
||||||
|
}).catch(() => null);
|
||||||
|
|
||||||
|
let mediaGuess: MatchGuess | null = null;
|
||||||
try {
|
try {
|
||||||
const episode = await invoke<EpisodeGuess | null>("guess_episode", {
|
const episode = await invoke<EpisodeGuess | null>("guess_episode", {
|
||||||
filename: item.originalName,
|
filename: item.originalName,
|
||||||
});
|
});
|
||||||
if (episode) return [item.id, { kind: "tv", episode }];
|
if (episode) mediaGuess = { kind: "tv", episode };
|
||||||
} catch {
|
} catch {
|
||||||
// Fall through and try a movie guess instead.
|
// Fall through and try a movie guess instead.
|
||||||
}
|
}
|
||||||
try {
|
if (!mediaGuess) {
|
||||||
const movie = await invoke<MovieGuess | null>("guess_movie", {
|
try {
|
||||||
filename: item.originalName,
|
const movie = await invoke<MovieGuess | null>("guess_movie", {
|
||||||
});
|
filename: item.originalName,
|
||||||
if (movie) return [item.id, { kind: "movie", movie }];
|
});
|
||||||
} catch {
|
if (movie) mediaGuess = { kind: "movie", movie };
|
||||||
// Neither guess worked - leave this item unmatched.
|
} 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:
|
// 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
|
// 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.
|
// 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 matchedTv = results.filter(([, guess]) => guess?.kind === "tv").length;
|
||||||
const matchedMovie = results.filter(([, guess]) => guess?.kind === "movie").length;
|
const matchedMovie = results.filter(([, guess]) => guess?.kind === "movie").length;
|
||||||
const matched = matchedTv + matchedMovie;
|
const matched = matchedTv + matchedMovie;
|
||||||
|
const tagged = results.filter(([, , release]) => release !== null).length;
|
||||||
|
|
||||||
setItems((prev) =>
|
setItems((prev) =>
|
||||||
prev.map((item) => {
|
prev.map((item) => {
|
||||||
const guess = guesses.get(item.id);
|
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<string, string> | 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") {
|
if (guess.kind === "tv") {
|
||||||
const { episode } = guess;
|
const { episode } = guess;
|
||||||
@@ -534,6 +595,7 @@ export default function RenamePanel() {
|
|||||||
"show name": seriesName,
|
"show name": seriesName,
|
||||||
"season number": String(episode.season),
|
"season number": String(episode.season),
|
||||||
"episode number": pad2(episode.episode),
|
"episode number": pad2(episode.episode),
|
||||||
|
...releaseBindings,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -551,6 +613,7 @@ export default function RenamePanel() {
|
|||||||
// Human-readable aliases.
|
// Human-readable aliases.
|
||||||
"movie name": title,
|
"movie name": title,
|
||||||
...(movie.year ? { year: String(movie.year) } : {}),
|
...(movie.year ? { year: String(movie.year) } : {}),
|
||||||
|
...releaseBindings,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
@@ -566,7 +629,7 @@ export default function RenamePanel() {
|
|||||||
|
|
||||||
setStatus(
|
setStatus(
|
||||||
matched > 0
|
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."
|
: "No season/episode or movie title recognized in the loaded filenames."
|
||||||
);
|
);
|
||||||
window.clearTimeout((runMatching as any)._t);
|
window.clearTimeout((runMatching as any)._t);
|
||||||
@@ -1094,13 +1157,27 @@ export default function RenamePanel() {
|
|||||||
<code>{"{year}"}</code>
|
<code>{"{year}"}</code>
|
||||||
<code>{"{airdate}"}</code>
|
<code>{"{airdate}"}</code>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="format-fields-group">
|
||||||
|
<span className="format-fields-group-title">
|
||||||
|
Release tags, after <strong>Match</strong> (TV or movies, local, offline)
|
||||||
|
</span>
|
||||||
|
<code>{"{resolution}"}</code>
|
||||||
|
<code>{"{source}"}</code>
|
||||||
|
<code>{"{video codec}"}</code>
|
||||||
|
<code>{"{audio codec}"}</code>
|
||||||
|
<code>{"{release group}"}</code>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="format-hint">
|
<p className="format-hint">
|
||||||
Match scans filenames locally for season/episode or movie title/year patterns (no network calls); Fetch
|
Match scans filenames locally for season/episode or movie title/year patterns, plus release-tag text
|
||||||
Data resolves that guess against real TMDB metadata for the real title, episode title, and air/release
|
like resolution, source (BluRay/HDTV/WEB-DL/…), video/audio codec, and release group — none of this
|
||||||
date — needs a free TMDB API token set in <strong>TMDB Settings</strong> first. A batch can freely mix TV
|
needs a network call, since it's all parsed straight from the filename itself (Renamr has no video/audio
|
||||||
and movie files — each file only fills in the fields that apply to it. FileBot's own short field names
|
file prober, so a tag that isn't in the filename text just won't be available). Fetch Data resolves the
|
||||||
(<code>{"{n}"}</code>, <code>{"{s00e00}"}</code>, <code>{"{t}"}</code>, <code>{"{y}"}</code>, etc.) still
|
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 <strong>TMDB Settings</strong> 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 (<code>{"{n}"}</code>, <code>{"{s00e00}"}</code>, <code>{"{t}"}</code>, <code>{"{y}"}</code>,
|
||||||
|
<code>{"{vf}"}</code>, <code>{"{vc}"}</code>, <code>{"{ac}"}</code>, <code>{"{group}"}</code>, etc.) still
|
||||||
work too, side by side with the names above.
|
work too, side by side with the names above.
|
||||||
</p>
|
</p>
|
||||||
<p className="format-hint">
|
<p className="format-hint">
|
||||||
|
|||||||
Reference in New Issue
Block a user