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
|
||||
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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
+14
-1
@@ -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<MovieGuess> {
|
||||
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
|
||||
/// 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,
|
||||
|
||||
@@ -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<readonly [string, MatchGuess | null]> = await Promise.all(
|
||||
targets.map(async (item): Promise<readonly [string, MatchGuess | null]> => {
|
||||
const results: Array<readonly [string, MatchGuess | null, ReleaseInfo | null]> = await Promise.all(
|
||||
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 {
|
||||
const episode = await invoke<EpisodeGuess | null>("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<MovieGuess | null>("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<MovieGuess | null>("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<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") {
|
||||
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() {
|
||||
<code>{"{year}"}</code>
|
||||
<code>{"{airdate}"}</code>
|
||||
</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>
|
||||
<p className="format-hint">
|
||||
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 <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>, 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 <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.
|
||||
</p>
|
||||
<p className="format-hint">
|
||||
|
||||
Reference in New Issue
Block a user