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
+20 -6
View File
@@ -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
+213
View File
@@ -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
View File
@@ -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,
+90 -13
View File
@@ -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.
} }
if (!mediaGuess) {
try { try {
const movie = await invoke<MovieGuess | null>("guess_movie", { const movie = await invoke<MovieGuess | null>("guess_movie", {
filename: item.originalName, filename: item.originalName,
}); });
if (movie) return [item.id, { kind: "movie", movie }]; if (movie) mediaGuess = { kind: "movie", movie };
} catch { } catch {
// Neither guess worked - leave this item unmatched. // 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">