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:
@@ -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