Add real media-file probing (session 9, sixteenth delivery)
Wires real container-header probing into the Rename panel's Match step, the
first item on the porting roadmap: aurora-curator-core::probe reads actual
resolution, frame rate, duration, and video/audio codecs straight from a
file's own header (mp4 crate for MP4/M4V/MOV, matroska crate for MKV/WebM,
no ffmpeg/MediaInfo dependency), batched through a new probe_media_files
Tauri command. Probed values fill {resolution}/{video codec}/{audio codec}
only when a filename release tag hasn't already, and populate five bindings
with no filename-tag equivalent at all: {width}, {height}, {fps},
{duration}, {file size}. 8 new Rust unit tests against real ffmpeg-generated
fixture files (161 -> 169 total), verified end to end with a 15-check
Playwright script against npm run dev, and README updated (status
paragraph, project layout, a new "Real media-file probing" section, and
roadmap item 1 narrowed to what's still missing).
This commit is contained in:
Generated
+75
@@ -120,8 +120,10 @@ name = "aurora-curator-core"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crc32fast",
|
"crc32fast",
|
||||||
|
"matroska",
|
||||||
"md-5",
|
"md-5",
|
||||||
"mockito",
|
"mockito",
|
||||||
|
"mp4",
|
||||||
"regex",
|
"regex",
|
||||||
"reqwest 0.12.28",
|
"reqwest 0.12.28",
|
||||||
"rhai",
|
"rhai",
|
||||||
@@ -180,6 +182,15 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bitstream-io"
|
||||||
|
version = "4.10.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f"
|
||||||
|
dependencies = [
|
||||||
|
"no_std_io2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "block-buffer"
|
name = "block-buffer"
|
||||||
version = "0.10.4"
|
version = "0.10.4"
|
||||||
@@ -1936,6 +1947,16 @@ dependencies = [
|
|||||||
"web_atoms",
|
"web_atoms",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "matroska"
|
||||||
|
version = "0.30.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5d3e606fd165c30a7f5daebca07f9707b2cdca959e0b3dd89538e3e0441b7f52"
|
||||||
|
dependencies = [
|
||||||
|
"bitstream-io",
|
||||||
|
"phf",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "md-5"
|
name = "md-5"
|
||||||
version = "0.10.6"
|
version = "0.10.6"
|
||||||
@@ -2023,6 +2044,20 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mp4"
|
||||||
|
version = "0.14.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c9ef834d5ed55e494a2ae350220314dc4aacd1c43a9498b00e320e0ea352a5c3"
|
||||||
|
dependencies = [
|
||||||
|
"byteorder",
|
||||||
|
"bytes",
|
||||||
|
"num-rational",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror 1.0.69",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "muda"
|
name = "muda"
|
||||||
version = "0.19.3"
|
version = "0.19.3"
|
||||||
@@ -2083,12 +2118,52 @@ dependencies = [
|
|||||||
"spin",
|
"spin",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "no_std_io2"
|
||||||
|
version = "0.9.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-bigint"
|
||||||
|
version = "0.4.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
|
||||||
|
dependencies = [
|
||||||
|
"num-integer",
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-conv"
|
name = "num-conv"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-integer"
|
||||||
|
version = "0.1.47"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-rational"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
|
||||||
|
dependencies = [
|
||||||
|
"num-bigint",
|
||||||
|
"num-integer",
|
||||||
|
"num-traits",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-traits"
|
name = "num-traits"
|
||||||
version = "0.2.19"
|
version = "0.2.19"
|
||||||
|
|||||||
@@ -30,9 +30,13 @@ TV episodes and movies
|
|||||||
text straight out of the filename — resolution, source (BluRay/HDTV/WEB-DL/…),
|
text straight out of the filename — resolution, source (BluRay/HDTV/WEB-DL/…),
|
||||||
video/audio codec, release group — so naming schemes can reference
|
video/audio codec, release group — so naming schemes can reference
|
||||||
`{resolution}`/`{source}`/`{video codec}`/`{audio codec}`/`{release group}` (or
|
`{resolution}`/`{source}`/`{video codec}`/`{audio codec}`/`{release group}` (or
|
||||||
FileBot's own `{vf}`/`{source}`/`{vc}`/`{ac}`/`{group}`) without a media prober; the
|
FileBot's own `{vf}`/`{source}`/`{vc}`/`{ac}`/`{group}`), and now also probes each
|
||||||
rest of FileBot's full media-metadata bindings (real probed video/audio stream info)
|
file's own container header for its real resolution, frame rate, duration, and
|
||||||
are not yet ported. The Align Up/Down buttons reorder the (shared) file list — the two
|
codecs — no ffmpeg/MediaInfo dependency required — filling in `{resolution}`/
|
||||||
|
`{video codec}`/`{audio codec}` whenever the filename itself didn't already carry
|
||||||
|
that tag, plus `{width}`/`{height}`/`{fps}`/`{duration}`/`{file size}`, which have no
|
||||||
|
filename-tag equivalent at all; see "Real media-file probing" below. The Align
|
||||||
|
Up/Down buttons reorder the (shared) file list — the two
|
||||||
panes always stay positionally paired — and up to 9 naming schemes can be saved as
|
panes always stay positionally paired — and up to 9 naming schemes can be saved as
|
||||||
presets, recalled with a click or a `1`-`9` keypress (persisted locally, same pattern as
|
presets, recalled with a click or a `1`-`9` keypress (persisted locally, same pattern as
|
||||||
Settings/History). The app can also be driven entirely from the command line —
|
Settings/History). The app can also be driven entirely from the command line —
|
||||||
@@ -57,7 +61,10 @@ user-editable naming-scheme-style expression per loaded file (the same `{ }` syn
|
|||||||
matching "Match"/Filter use), with a live "N of M lines rendered" count, a Copy List
|
matching "Match"/Filter use), with a live "N of M lines rendered" count, a Copy List
|
||||||
clipboard export, and an Export to File… button that writes the whole list to a `.txt`
|
clipboard export, and an Export to File… button that writes the whole list to a `.txt`
|
||||||
file via a native Save dialog — this closes out the full original panel set (Rename/
|
file via a native Save dialog — this closes out the full original panel set (Rename/
|
||||||
Episodes/Subtitles/SFV/Filter/List); see "List" below. See "Porting roadmap" below.
|
Episodes/Subtitles/SFV/Filter/List); see "List" below. Real media-file probing (real
|
||||||
|
resolution, frame rate, duration, and codecs read straight from a video file's own
|
||||||
|
container header, not guessed from its filename) is wired into the Rename panel's
|
||||||
|
"Match" step — see "Real media-file probing" below. 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
|
||||||
@@ -188,12 +195,20 @@ crates/aurora-curator-core/ Pure Rust core: naming-expression engine (ported),
|
|||||||
into (auto-created) subfolders when
|
into (auto-created) subfolders when
|
||||||
the naming scheme includes `/`), a rename history/undo log
|
the naming scheme includes `/`), a rename history/undo log
|
||||||
(`history/`, a partial `HistoryDialog` port, undo-aware of which
|
(`history/`, a partial `HistoryDialog` port, undo-aware of which
|
||||||
action produced each batch), and pure `-rename` command-line
|
action produced each batch), pure `-rename` command-line
|
||||||
argument parsing/file expansion (`cli/` — no Tauri or network
|
argument parsing/file expansion (`cli/` — no Tauri or network
|
||||||
dependency at all, see "Command-line usage" below). Not yet
|
dependency at all, see "Command-line usage" below), and real
|
||||||
ported: full media metadata bindings, other web datasource
|
media-file container probing (`probe/` — real resolution, frame
|
||||||
clients (TheTVDB/AniDB/etc). No Tauri dependency, so
|
rate, duration, and codecs read straight from a video file's own
|
||||||
it builds and unit-tests standalone.
|
header via two pure-Rust container parsers, the `mp4` crate for
|
||||||
|
ISO-BMFF containers and the `matroska` crate for EBML/Matroska
|
||||||
|
containers, no ffmpeg/MediaInfo system dependency; see "Real
|
||||||
|
media-file probing" below). Not yet ported: other web datasource
|
||||||
|
clients (TheTVDB/AniDB/etc), and everything MediaBindingBean
|
||||||
|
derives that neither a filename tag nor `probe/`'s two supported
|
||||||
|
container families can provide (other containers like .avi/.wmv,
|
||||||
|
audio-only metadata like bitrate/sample rate). No Tauri
|
||||||
|
dependency, so it builds and unit-tests standalone.
|
||||||
src-tauri/ Tauri app shell: exposes aurora-curator-core as commands to the frontend
|
src-tauri/ Tauri app shell: exposes aurora-curator-core as commands to the frontend
|
||||||
(naming-scheme rendering, filename matching for both TV and
|
(naming-scheme rendering, filename matching for both TV and
|
||||||
movies, metadata fetch — `metadata.rs` for TV episodes (against
|
movies, metadata fetch — `metadata.rs` for TV episodes (against
|
||||||
@@ -229,7 +244,11 @@ src-tauri/ Tauri app shell: exposes aurora-curator-core as commands
|
|||||||
whatever already-rendered, newline-joined text the frontend hands
|
whatever already-rendered, newline-joined text the frontend hands
|
||||||
it to a `.txt` file — the only new Tauri surface List needed, since
|
it to a `.txt` file — the only new Tauri surface List needed, since
|
||||||
matching each file and rendering its line both reuse existing
|
matching each file and rendering its line both reuse existing
|
||||||
commands, see below) — `cli.rs` (the
|
commands, see below), `probe.rs` (a single `probe_media_files`
|
||||||
|
command backing real media probing in the Rename panel's Match
|
||||||
|
step: batch-probes every loaded file's real container header off
|
||||||
|
the main thread, wrapping `aurora-curator-core::probe` — see "Real
|
||||||
|
media-file probing" below) — `cli.rs` (the
|
||||||
`-rename` command-line mode's orchestration: builds and executes a
|
`-rename` command-line mode's orchestration: builds and executes a
|
||||||
rename plan against `aurora-curator-core::cli`'s parsed arguments,
|
rename plan against `aurora-curator-core::cli`'s parsed arguments,
|
||||||
with no Tauri `App`/window ever created for it — `main.rs` branches
|
with no Tauri `App`/window ever created for it — `main.rs` branches
|
||||||
@@ -238,7 +257,9 @@ src-tauri/ Tauri app shell: exposes aurora-curator-core as commands
|
|||||||
src/ React + TypeScript UI (Vite). Faithful recreation of the original
|
src/ React + TypeScript UI (Vite). Faithful recreation of the original
|
||||||
two-pane rename screen (Aurora theme) wired to real file loading,
|
two-pane rename screen (Aurora theme) wired to real file loading,
|
||||||
drag-and-drop, live naming-scheme preview, local TV/movie
|
drag-and-drop, live naming-scheme preview, local TV/movie
|
||||||
matching, metadata fetch for both (behind a Fetch Data Settings
|
matching, real media-file probing (`probe_media_files`, batched
|
||||||
|
alongside the local matching calls in `RenamePanel.tsx`'s
|
||||||
|
`runMatching`), metadata fetch for both (behind a Fetch Data Settings
|
||||||
modal holding independent TV-source (TMDB/TVmaze/OMDb) and
|
modal holding independent TV-source (TMDB/TVmaze/OMDb) and
|
||||||
movie-source (TMDB/OMDb) pickers plus the TMDB token and OMDb key
|
movie-source (TMDB/OMDb) pickers plus the TMDB token and OMDb key
|
||||||
inputs), a
|
inputs), a
|
||||||
@@ -519,6 +540,37 @@ and the results header tracks "N of M lines rendered" live as the expression cha
|
|||||||
to File…** opens a native Save dialog (defaulted to `list.txt`) and writes the same text
|
to File…** opens a native Save dialog (defaulted to `list.txt`) and writes the same text
|
||||||
to disk.
|
to disk.
|
||||||
|
|
||||||
|
## Real media-file probing
|
||||||
|
|
||||||
|
Unlike every panel above, this isn't a new sidebar screen — it's a naming-scheme
|
||||||
|
binding source wired into the Rename panel's existing "Match" step, closing (part of)
|
||||||
|
the gap the porting roadmap has called out since the beginning: FileBot's
|
||||||
|
`MediaBindingBean` doesn't just parse tags out of a release name, it also reads a
|
||||||
|
video file's own container header for its real resolution, frame rate, duration, and
|
||||||
|
codecs — a naming scheme can reference those even when the filename itself doesn't
|
||||||
|
carry a tag for them, or came from a mislabeled/renamed-already release. Aurora
|
||||||
|
Curator ships no ffmpeg/MediaInfo dependency for this — two pure-Rust container
|
||||||
|
parsers do the actual reading: the `mp4` crate for ISO-BMFF containers (`.mp4`/`.m4v`/
|
||||||
|
`.mov`) and the `matroska` crate for EBML/Matroska containers (`.mkv`/`.webm`). Any
|
||||||
|
other extension, or a file that fails to parse as its container, just yields no probed
|
||||||
|
values — probing is always a best-effort enrichment layered on top of filename
|
||||||
|
matching, never a hard requirement, so an unsupported or corrupt file can't break
|
||||||
|
Match.
|
||||||
|
|
||||||
|
Clicking **Match** now also probes every loaded file's real container header
|
||||||
|
(`probe_media_files`, one batched Tauri round trip off the main thread) alongside the
|
||||||
|
existing filename-based guessing. The result feeds the same bindings the release-tag
|
||||||
|
parser already populates — `{resolution}`/`{vf}`, `{video codec}`/`{vc}`,
|
||||||
|
`{audio codec}`/`{ac}` — but only when the filename itself didn't already carry that
|
||||||
|
tag; an explicit release-group tag always wins over a probed value, since a release
|
||||||
|
group's own tag is usually deliberate. Five bindings have no filename-tag equivalent
|
||||||
|
at all, so they're only ever available this way: `{width}`, `{height}`, `{fps}`,
|
||||||
|
`{duration}` (`h:mm:ss`, or `m:ss` under an hour), and `{file size}` (human-readable,
|
||||||
|
e.g. `1.4 GB`) — all formatted once on the Rust side so the UI never reimplements that
|
||||||
|
logic. The Match status line reports how many loaded files were actually probed
|
||||||
|
(`"N probed from file"`), and the naming-scheme editor's field-reference list has a
|
||||||
|
new "Real file probing" group listing all five.
|
||||||
|
|
||||||
## Porting roadmap (not yet done)
|
## Porting roadmap (not yet done)
|
||||||
|
|
||||||
Roughly in priority order — each references the original FileBot source file it's
|
Roughly in priority order — each references the original FileBot source file it's
|
||||||
@@ -526,13 +578,19 @@ 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. A filename-tag-based subset is now ported and wired to "Match"
|
properties. A filename-tag-based subset is ported and wired to "Match"
|
||||||
(`guess_release_info` in `crates/aurora-curator-core/src/matching`): `{vf}`/`{resolution}`,
|
(`guess_release_info` in `crates/aurora-curator-core/src/matching`): `{vf}`/`{resolution}`,
|
||||||
`{source}`, `{vc}`/`{video codec}`, `{ac}`/`{audio codec}`, `{group}`/`{release
|
`{source}`, `{vc}`/`{video codec}`, `{ac}`/`{audio codec}`, `{group}`/`{release
|
||||||
group}`. What's still missing is everything MediaBindingBean derives from actually
|
group}`. Real container probing (`crates/aurora-curator-core/src/probe`, see "Real
|
||||||
*probing* the media file (real video/audio stream resolution, duration, bitrate,
|
media-file probing" below) now covers the other half MediaBindingBean derives from
|
||||||
frame rate, file size/hash, etc.) — Aurora Curator ships no ffprobe/MediaInfo dependency, so
|
actually reading the file — real resolution/video codec/audio codec fill in
|
||||||
those bindings only work when a release name happens to carry the info as text.
|
whenever the filename didn't already carry that tag, plus `{width}`/`{height}`/
|
||||||
|
`{fps}`/`{duration}`/`{file size}`, which have no filename-tag equivalent at all —
|
||||||
|
for the two container families it supports (MP4/M4V/MOV and MKV/WebM). Still
|
||||||
|
missing: probing for other containers (.avi, .wmv, ...), audio-only metadata
|
||||||
|
(bitrate, sample rate, channel layout), file hashing (`{crc32}` reuses the SFV
|
||||||
|
panel's own hasher today but isn't wired into naming-scheme bindings yet), and
|
||||||
|
wiring probed bindings into the List/Filter panels (currently Rename-panel-only).
|
||||||
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
|
||||||
|
|||||||
@@ -31,6 +31,13 @@ crc32fast = "1"
|
|||||||
md-5 = "0.10"
|
md-5 = "0.10"
|
||||||
sha1 = "0.10"
|
sha1 = "0.10"
|
||||||
|
|
||||||
|
# Real media-file container parsing for the media-probing binding source (`probe`
|
||||||
|
# module) - reads actual resolution/duration/codec/frame-rate straight from a file's
|
||||||
|
# header, no ffmpeg/MediaInfo system dependency needed. `mp4` covers ISO-BMFF
|
||||||
|
# containers (.mp4/.m4v/.mov); `matroska` covers EBML/Matroska containers (.mkv/.webm).
|
||||||
|
mp4 = "0.14"
|
||||||
|
matroska = "0.30"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
# Local HTTP mock server so the TMDB client's request/response handling can be unit
|
# Local HTTP mock server so the TMDB client's request/response handling can be unit
|
||||||
# tested without live network access or a real API key.
|
# tested without live network access or a real API key.
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ pub mod cli;
|
|||||||
pub mod format;
|
pub mod format;
|
||||||
pub mod history;
|
pub mod history;
|
||||||
pub mod matching;
|
pub mod matching;
|
||||||
|
pub mod probe;
|
||||||
pub mod rename;
|
pub mod rename;
|
||||||
pub mod sfv;
|
pub mod sfv;
|
||||||
pub mod similarity;
|
pub mod similarity;
|
||||||
|
|||||||
@@ -0,0 +1,318 @@
|
|||||||
|
//! Real media-file probing: reads actual container metadata (resolution, duration,
|
||||||
|
//! frame rate, codecs) straight from a video file's own header, rather than guessing
|
||||||
|
//! from the filename the way `matching::guess_release_info` does. This is the piece
|
||||||
|
//! the README's porting roadmap calls out as still missing from `MediaBindingBean`
|
||||||
|
//! parity - "everything MediaBindingBean derives from actually probing the media
|
||||||
|
//! file" - and it's meant to complement filename-tag guessing, not replace it: the
|
||||||
|
//! caller (the Rename panel's binding merge) only uses a probed value for a field the
|
||||||
|
//! filename didn't already carry a tag for.
|
||||||
|
//!
|
||||||
|
//! No ffmpeg/MediaInfo system dependency is needed - two pure-Rust container parsers
|
||||||
|
//! do the actual reading: the `mp4` crate for ISO-BMFF containers (.mp4/.m4v/.mov)
|
||||||
|
//! and the `matroska` crate for EBML/Matroska containers (.mkv/.webm). Anything else
|
||||||
|
//! (.avi, .wmv, an unrecognized extension, or a file that fails to parse as its
|
||||||
|
//! container) yields a [`MediaProbe`] with only `file_size_bytes` populated - probing
|
||||||
|
//! is always a best-effort enrichment layered on top of filename matching, never a
|
||||||
|
//! hard requirement, so a corrupt or unsupported file must never error the caller out.
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// Everything real container probing could determine about one media file. Every
|
||||||
|
/// field but `file_size_bytes` is `None` when the file isn't a supported container,
|
||||||
|
/// couldn't be parsed, or simply doesn't carry that particular piece of information
|
||||||
|
/// (e.g. a video track with no frame-rate box).
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct MediaProbe {
|
||||||
|
pub file_size_bytes: u64,
|
||||||
|
pub duration_seconds: Option<f64>,
|
||||||
|
pub width: Option<u32>,
|
||||||
|
pub height: Option<u32>,
|
||||||
|
pub frame_rate: Option<f64>,
|
||||||
|
pub video_codec: Option<String>,
|
||||||
|
pub audio_codec: Option<String>,
|
||||||
|
pub container: Option<&'static str>,
|
||||||
|
// Pre-formatted display labels for the three fields above that a naming-scheme
|
||||||
|
// binding actually wants human-readable rather than raw - computed here (not in
|
||||||
|
// the UI layer) so there's exactly one place that knows how to format them, shared
|
||||||
|
// by every caller. `resolution` mirrors `matching::ReleaseInfo::resolution`'s
|
||||||
|
// "1080p"-style bucketing so a probed value and a filename-tag value read
|
||||||
|
// identically; `file_size` is always present since `file_size_bytes` always is.
|
||||||
|
pub resolution: Option<String>,
|
||||||
|
pub duration: Option<String>,
|
||||||
|
pub file_size: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Probes one file by its extension. Never fails - a file that can't be opened, isn't
|
||||||
|
/// a recognized container, or fails to parse just yields a mostly-empty [`MediaProbe`]
|
||||||
|
/// (still carrying a real `file_size_bytes`/`file_size` when the file itself is readable).
|
||||||
|
pub fn probe_media_file(path: &Path) -> MediaProbe {
|
||||||
|
let file_size_bytes = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
|
||||||
|
let mut probe = MediaProbe {
|
||||||
|
file_size_bytes,
|
||||||
|
file_size: file_size_label(file_size_bytes),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let ext = path.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase());
|
||||||
|
match ext.as_deref() {
|
||||||
|
Some("mp4") | Some("m4v") | Some("mov") => probe_mp4(path, &mut probe),
|
||||||
|
Some("mkv") | Some("webm") => probe_matroska(path, &mut probe),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(height) = probe.height {
|
||||||
|
probe.resolution = Some(resolution_label(height).to_string());
|
||||||
|
}
|
||||||
|
if let Some(seconds) = probe.duration_seconds {
|
||||||
|
probe.duration = Some(duration_label(seconds));
|
||||||
|
}
|
||||||
|
|
||||||
|
probe
|
||||||
|
}
|
||||||
|
|
||||||
|
fn probe_mp4(path: &Path, probe: &mut MediaProbe) {
|
||||||
|
let Ok(file) = std::fs::File::open(path) else { return };
|
||||||
|
let Ok(size) = file.metadata().map(|m| m.len()) else { return };
|
||||||
|
let reader = std::io::BufReader::new(file);
|
||||||
|
let Ok(reader) = mp4::Mp4Reader::read_header(reader, size) else { return };
|
||||||
|
|
||||||
|
probe.container = Some("MP4");
|
||||||
|
let total = reader.duration();
|
||||||
|
if total > Duration::ZERO {
|
||||||
|
probe.duration_seconds = Some(total.as_secs_f64());
|
||||||
|
}
|
||||||
|
|
||||||
|
for track in reader.tracks().values() {
|
||||||
|
match track.track_type() {
|
||||||
|
Ok(mp4::TrackType::Video) => {
|
||||||
|
let (w, h) = (track.width(), track.height());
|
||||||
|
if w > 0 && h > 0 {
|
||||||
|
probe.width = Some(w as u32);
|
||||||
|
probe.height = Some(h as u32);
|
||||||
|
}
|
||||||
|
let fps = track.frame_rate();
|
||||||
|
if fps > 0.0 {
|
||||||
|
probe.frame_rate = Some(fps);
|
||||||
|
}
|
||||||
|
if let Ok(media_type) = track.media_type() {
|
||||||
|
probe.video_codec = Some(mp4_codec_label(media_type));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(mp4::TrackType::Audio) => {
|
||||||
|
if probe.audio_codec.is_none() {
|
||||||
|
if let Ok(media_type) = track.media_type() {
|
||||||
|
probe.audio_codec = Some(mp4_codec_label(media_type));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn probe_matroska(path: &Path, probe: &mut MediaProbe) {
|
||||||
|
let Ok(mat) = matroska::open(path) else { return };
|
||||||
|
|
||||||
|
probe.container = Some("Matroska");
|
||||||
|
if let Some(duration) = mat.info.duration {
|
||||||
|
probe.duration_seconds = Some(duration.as_secs_f64());
|
||||||
|
}
|
||||||
|
|
||||||
|
for track in &mat.tracks {
|
||||||
|
match &track.settings {
|
||||||
|
matroska::Settings::Video(video) => {
|
||||||
|
if video.pixel_width > 0 && video.pixel_height > 0 {
|
||||||
|
probe.width = Some(video.pixel_width as u32);
|
||||||
|
probe.height = Some(video.pixel_height as u32);
|
||||||
|
}
|
||||||
|
probe.video_codec = Some(codec_label(&track.codec_id));
|
||||||
|
}
|
||||||
|
matroska::Settings::Audio(_) => {
|
||||||
|
if probe.audio_codec.is_none() {
|
||||||
|
probe.audio_codec = Some(codec_label(&track.codec_id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
matroska::Settings::None => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Friendly label for an mp4 track's [`mp4::MediaType`] - matches the short labels
|
||||||
|
/// `codec_label` below produces from a Matroska codec ID, so a probed codec reads the
|
||||||
|
/// same way regardless of which container it came from.
|
||||||
|
fn mp4_codec_label(media_type: mp4::MediaType) -> String {
|
||||||
|
match media_type {
|
||||||
|
mp4::MediaType::H264 => "H.264".to_string(),
|
||||||
|
mp4::MediaType::H265 => "H.265".to_string(),
|
||||||
|
mp4::MediaType::VP9 => "VP9".to_string(),
|
||||||
|
mp4::MediaType::AAC => "AAC".to_string(),
|
||||||
|
mp4::MediaType::TTXT => "Text".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Matroska codec IDs (see <https://www.matroska.org/technical/codec_specs.html>) are
|
||||||
|
/// machine-readable strings like `"V_MPEG4/ISO/AVC"` rather than a friendly name - map
|
||||||
|
/// the ones scene releases actually use to the same short labels
|
||||||
|
/// `matching::guess_release_info` already produces from filename tags. An unrecognized
|
||||||
|
/// codec ID is passed through as-is rather than dropped, so it's still visible even if
|
||||||
|
/// not prettified.
|
||||||
|
fn codec_label(codec_id: &str) -> String {
|
||||||
|
match codec_id {
|
||||||
|
"V_MPEG4/ISO/AVC" => "H.264".to_string(),
|
||||||
|
"V_MPEGH/ISO/HEVC" => "H.265".to_string(),
|
||||||
|
"V_VP9" => "VP9".to_string(),
|
||||||
|
"V_VP8" => "VP8".to_string(),
|
||||||
|
"V_AV1" => "AV1".to_string(),
|
||||||
|
"V_MPEG4/ISO/ASP" => "XviD".to_string(),
|
||||||
|
"A_AAC" | "A_AAC/MPEG4/LC" => "AAC".to_string(),
|
||||||
|
"A_AC3" => "AC3".to_string(),
|
||||||
|
"A_EAC3" => "E-AC3".to_string(),
|
||||||
|
"A_DTS" => "DTS".to_string(),
|
||||||
|
"A_TRUEHD" => "TrueHD".to_string(),
|
||||||
|
"A_FLAC" => "FLAC".to_string(),
|
||||||
|
"A_OPUS" => "Opus".to_string(),
|
||||||
|
"A_VORBIS" => "Vorbis".to_string(),
|
||||||
|
"A_MPEG/L3" => "MP3".to_string(),
|
||||||
|
other => other.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Buckets a real probed pixel height into the same standard-resolution labels
|
||||||
|
/// `matching::guess_release_info`'s filename-tag parsing recognizes (`"1080p"`,
|
||||||
|
/// `"720p"`, ...), so a probed resolution and a filename-tag resolution read
|
||||||
|
/// identically in a naming scheme's `{resolution}`/`{vf}` binding.
|
||||||
|
pub fn resolution_label(height: u32) -> &'static str {
|
||||||
|
if height >= 2000 {
|
||||||
|
"2160p"
|
||||||
|
} else if height >= 1000 {
|
||||||
|
"1080p"
|
||||||
|
} else if height >= 700 {
|
||||||
|
"720p"
|
||||||
|
} else if height >= 540 {
|
||||||
|
"576p"
|
||||||
|
} else if height >= 400 {
|
||||||
|
"480p"
|
||||||
|
} else {
|
||||||
|
"SD"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Formats a duration in seconds as `h:mm:ss` (or `m:ss` under an hour), for a
|
||||||
|
/// `{duration}` binding.
|
||||||
|
pub fn duration_label(seconds: f64) -> String {
|
||||||
|
let total = seconds.round() as u64;
|
||||||
|
let h = total / 3600;
|
||||||
|
let m = (total % 3600) / 60;
|
||||||
|
let s = total % 60;
|
||||||
|
if h > 0 {
|
||||||
|
format!("{h}:{m:02}:{s:02}")
|
||||||
|
} else {
|
||||||
|
format!("{m}:{s:02}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Formats a byte count as a human-readable size (`"1.4 GB"`, `"512 MB"`, ...), for a
|
||||||
|
/// `{file size}` binding.
|
||||||
|
pub fn file_size_label(bytes: u64) -> String {
|
||||||
|
const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
|
||||||
|
let mut size = bytes as f64;
|
||||||
|
let mut unit = 0;
|
||||||
|
while size >= 1024.0 && unit < UNITS.len() - 1 {
|
||||||
|
size /= 1024.0;
|
||||||
|
unit += 1;
|
||||||
|
}
|
||||||
|
if unit == 0 {
|
||||||
|
format!("{bytes} {}", UNITS[unit])
|
||||||
|
} else {
|
||||||
|
format!("{size:.1} {}", UNITS[unit])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn fixture(name: &str) -> std::path::PathBuf {
|
||||||
|
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures").join(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn probes_a_real_mp4_file() {
|
||||||
|
let probe = probe_media_file(&fixture("sample.mp4"));
|
||||||
|
assert_eq!(probe.container, Some("MP4"));
|
||||||
|
assert_eq!(probe.width, Some(320));
|
||||||
|
assert_eq!(probe.height, Some(240));
|
||||||
|
assert_eq!(probe.video_codec.as_deref(), Some("H.264"));
|
||||||
|
assert_eq!(probe.audio_codec.as_deref(), Some("AAC"));
|
||||||
|
assert!(probe.duration_seconds.unwrap() > 0.5);
|
||||||
|
assert!(probe.file_size_bytes > 0);
|
||||||
|
assert_eq!(probe.resolution.as_deref(), Some("SD")); // 240px tall - below the 480p bucket
|
||||||
|
assert_eq!(probe.duration.as_deref(), Some("0:01"));
|
||||||
|
assert!(!probe.file_size.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn probes_a_real_matroska_file() {
|
||||||
|
let probe = probe_media_file(&fixture("sample.mkv"));
|
||||||
|
assert_eq!(probe.container, Some("Matroska"));
|
||||||
|
assert_eq!(probe.width, Some(640));
|
||||||
|
assert_eq!(probe.height, Some(360));
|
||||||
|
assert_eq!(probe.video_codec.as_deref(), Some("H.264"));
|
||||||
|
assert_eq!(probe.audio_codec.as_deref(), Some("AAC"));
|
||||||
|
assert!(probe.duration_seconds.unwrap() > 0.5);
|
||||||
|
assert_eq!(probe.resolution.as_deref(), Some("SD")); // 360px tall - below the 480p bucket
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_media_file_yields_only_file_size() {
|
||||||
|
let probe = probe_media_file(&fixture("not_media.txt"));
|
||||||
|
assert_eq!(probe.container, None);
|
||||||
|
assert_eq!(probe.width, None);
|
||||||
|
assert_eq!(probe.resolution, None);
|
||||||
|
assert_eq!(probe.duration, None);
|
||||||
|
assert!(probe.file_size_bytes > 0);
|
||||||
|
assert!(!probe.file_size.is_empty()); // file_size is always computed from the real byte count
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn corrupt_container_degrades_gracefully() {
|
||||||
|
let probe = probe_media_file(&fixture("corrupt.mp4"));
|
||||||
|
assert_eq!(probe.container, None);
|
||||||
|
assert_eq!(probe.width, None);
|
||||||
|
assert!(probe.file_size_bytes > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_file_yields_all_none() {
|
||||||
|
let probe = probe_media_file(Path::new("/does/not/exist.mp4"));
|
||||||
|
assert_eq!(probe.file_size_bytes, 0);
|
||||||
|
assert_eq!(probe.container, None);
|
||||||
|
assert_eq!(probe.file_size, "0 B");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolution_label_buckets_common_heights() {
|
||||||
|
assert_eq!(resolution_label(2160), "2160p");
|
||||||
|
assert_eq!(resolution_label(1080), "1080p");
|
||||||
|
assert_eq!(resolution_label(720), "720p");
|
||||||
|
assert_eq!(resolution_label(480), "480p");
|
||||||
|
assert_eq!(resolution_label(240), "SD");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn duration_label_formats_with_and_without_hours() {
|
||||||
|
assert_eq!(duration_label(45.0), "0:45");
|
||||||
|
assert_eq!(duration_label(125.0), "2:05");
|
||||||
|
assert_eq!(duration_label(3725.0), "1:02:05");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn file_size_label_formats_common_magnitudes() {
|
||||||
|
assert_eq!(file_size_label(512), "512 B");
|
||||||
|
assert_eq!(file_size_label(1536), "1.5 KB");
|
||||||
|
assert_eq!(file_size_label(1_500_000_000), "1.4 GB");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
not actually a valid mp4 container, just bytes with an .mp4 extension
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
this is not a media file, just plain text used to test that probing degrades gracefully
|
||||||
Binary file not shown.
Binary file not shown.
@@ -5,6 +5,7 @@ mod history;
|
|||||||
mod list;
|
mod list;
|
||||||
mod metadata;
|
mod metadata;
|
||||||
mod presets;
|
mod presets;
|
||||||
|
mod probe;
|
||||||
mod settings;
|
mod settings;
|
||||||
mod sfv;
|
mod sfv;
|
||||||
mod subtitles;
|
mod subtitles;
|
||||||
@@ -47,11 +48,13 @@ fn guess_movie(filename: String) -> Option<MovieGuess> {
|
|||||||
|
|
||||||
/// Guesses release-tag metadata (resolution, source, video/audio codec, release
|
/// Guesses release-tag metadata (resolution, source, video/audio codec, release
|
||||||
/// group) from a filename's own text using local regex patterns only - no network
|
/// group) from a filename's own text using local regex patterns only - no network
|
||||||
/// calls, no real media probing (Aurora Curator never ships an ffprobe/MediaInfo dependency,
|
/// calls, no file I/O. Complements `probe::probe_media_files` below, which reads the
|
||||||
/// so filename tags are the only source for these fields). Independent of
|
/// same kind of resolution/codec data straight from a file's real container header
|
||||||
/// `guess_episode`/`guess_movie`: called alongside whichever of those two applies,
|
/// instead of guessing it from filename text - the Rename panel merges both,
|
||||||
/// since a release name carries (or doesn't carry) these tags regardless of whether
|
/// preferring an explicit filename tag over a probed value when a file has both.
|
||||||
/// it's an episode or a movie.
|
/// 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]
|
#[tauri::command]
|
||||||
fn guess_release_info(filename: String) -> Option<ReleaseInfo> {
|
fn guess_release_info(filename: String) -> Option<ReleaseInfo> {
|
||||||
matching::guess_release_info(&filename)
|
matching::guess_release_info(&filename)
|
||||||
@@ -123,6 +126,7 @@ pub fn run() {
|
|||||||
sfv::save_checksum_file,
|
sfv::save_checksum_file,
|
||||||
sfv::verify_checksum_file,
|
sfv::verify_checksum_file,
|
||||||
list::export_text_list,
|
list::export_text_list,
|
||||||
|
probe::probe_media_files,
|
||||||
history::list_rename_history,
|
history::list_rename_history,
|
||||||
history::undo_rename_batch,
|
history::undo_rename_batch,
|
||||||
presets::list_presets,
|
presets::list_presets,
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
//! The Tauri-facing side of real media-file probing: reads real container metadata
|
||||||
|
//! (resolution, duration, frame rate, codecs) straight from each loaded file's own
|
||||||
|
//! header via `aurora_curator_core::probe`, batched the same way `sfv::compute_checksums`
|
||||||
|
//! batches checksum hashing - one Tauri round trip for the whole loaded set, run off
|
||||||
|
//! the main thread since this is real file I/O (a probe only reads a container's
|
||||||
|
//! header/index boxes, not the whole file, but a movie file can still be tens of GB
|
||||||
|
//! and may live on a slow network share).
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use aurora_curator_core::probe::{self, MediaProbe};
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
/// One file's probe result, paired with the path it came from so the frontend can
|
||||||
|
/// match results back to the loaded items it requested (order isn't otherwise
|
||||||
|
/// guaranteed to be preserved end to end through `spawn_blocking`).
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ProbeOutcome {
|
||||||
|
pub path: String,
|
||||||
|
pub probe: MediaProbe,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Probes a batch of loaded files. Never fails per-file - `aurora_curator_core::probe::probe_media_file`
|
||||||
|
/// is always best-effort (see its doc comment), so every path in `paths` gets exactly
|
||||||
|
/// one `ProbeOutcome` back, even a file that isn't a supported container or can't be
|
||||||
|
/// read at all (its `MediaProbe` just comes back mostly empty).
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn probe_media_files(paths: Vec<String>) -> Vec<ProbeOutcome> {
|
||||||
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
paths
|
||||||
|
.into_iter()
|
||||||
|
.map(|path| {
|
||||||
|
let probe = probe::probe_media_file(Path::new(&path));
|
||||||
|
ProbeOutcome { path, probe }
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
@@ -65,6 +65,32 @@ interface ReleaseInfo {
|
|||||||
group: string | null;
|
group: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Result of real container-header probing (aurora-curator-core's `probe` module, exposed
|
||||||
|
// via the batched `probe_media_files` Tauri command) - resolution/duration/codecs read
|
||||||
|
// straight from the file's own header, not guessed from its name. Complements (never
|
||||||
|
// overrides) `ReleaseInfo` above: a filename-tag value wins whenever a file has both,
|
||||||
|
// since a release group's own tag is usually deliberate. `resolution`/`duration`/
|
||||||
|
// `fileSize` are pre-formatted display strings computed on the Rust side, so this
|
||||||
|
// component never reimplements that formatting.
|
||||||
|
interface MediaProbe {
|
||||||
|
fileSizeBytes: number;
|
||||||
|
durationSeconds: number | null;
|
||||||
|
width: number | null;
|
||||||
|
height: number | null;
|
||||||
|
frameRate: number | null;
|
||||||
|
videoCodec: string | null;
|
||||||
|
audioCodec: string | null;
|
||||||
|
container: string | null;
|
||||||
|
resolution: string | null;
|
||||||
|
duration: string | null;
|
||||||
|
fileSize: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProbeOutcome {
|
||||||
|
path: string;
|
||||||
|
probe: MediaProbe;
|
||||||
|
}
|
||||||
|
|
||||||
function pad2(n: number): string {
|
function pad2(n: number): string {
|
||||||
return n.toString().padStart(2, "0");
|
return n.toString().padStart(2, "0");
|
||||||
}
|
}
|
||||||
@@ -623,7 +649,11 @@ export default function RenamePanel() {
|
|||||||
// movie, never both - see the doc comment on `guess_movie` in aurora-curator-core). Populates
|
// movie, never both - see the doc comment on `guess_movie` in aurora-curator-core). Populates
|
||||||
// TV bindings (`{n}`/`{s00e00}`/`{season}`/`{episode}`/etc.) or movie bindings
|
// TV bindings (`{n}`/`{s00e00}`/`{season}`/`{episode}`/etc.) or movie bindings
|
||||||
// (`{n}`/`{y}`/etc.) accordingly - no network calls, no metadata lookup, so it's
|
// (`{n}`/`{y}`/etc.) accordingly - no network calls, no metadata lookup, so it's
|
||||||
// distinct from (and much cheaper than) "Fetch Data" online matching.
|
// distinct from (and much cheaper than) "Fetch Data" online matching. Alongside all
|
||||||
|
// of that, also probes each target's real file (aurora-curator-core's `probe` module via the
|
||||||
|
// batched `probe_media_files` command) for resolution/duration/codecs read straight
|
||||||
|
// from its container header - see the bindings merge below for how a probed value
|
||||||
|
// and a filename-tag value combine.
|
||||||
const runMatching = async () => {
|
const runMatching = async () => {
|
||||||
const targets = items.filter((i) => !i.isSample);
|
const targets = items.filter((i) => !i.isSample);
|
||||||
if (targets.length === 0) {
|
if (targets.length === 0) {
|
||||||
@@ -633,6 +663,12 @@ export default function RenamePanel() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One batched round trip for real probing of every target, run alongside the
|
||||||
|
// per-file filename guessing below rather than after it.
|
||||||
|
const probePromise = invoke<ProbeOutcome[]>("probe_media_files", {
|
||||||
|
paths: targets.map((item) => item.id),
|
||||||
|
}).catch(() => [] as ProbeOutcome[]);
|
||||||
|
|
||||||
const results: Array<readonly [string, MatchGuess | null, ReleaseInfo | 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, ReleaseInfo | null]> => {
|
targets.map(async (item): Promise<readonly [string, MatchGuess | null, ReleaseInfo | null]> => {
|
||||||
// Release tags (resolution/source/codecs/group) are independent of TV-vs-movie,
|
// Release tags (resolution/source/codecs/group) are independent of TV-vs-movie,
|
||||||
@@ -666,6 +702,7 @@ export default function RenamePanel() {
|
|||||||
);
|
);
|
||||||
const guesses = new Map(results.map(([id, guess]) => [id, guess] as const));
|
const guesses = new Map(results.map(([id, guess]) => [id, guess] as const));
|
||||||
const releaseGuesses = new Map(results.map(([id, , release]) => [id, release] as const));
|
const releaseGuesses = new Map(results.map(([id, , release]) => [id, release] as const));
|
||||||
|
const probes = new Map((await probePromise).map((outcome) => [outcome.path, outcome.probe] 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.
|
||||||
@@ -673,11 +710,13 @@ export default function RenamePanel() {
|
|||||||
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;
|
const tagged = results.filter(([, , release]) => release !== null).length;
|
||||||
|
const probed = [...probes.values()].filter((probe) => probe.container !== null).length;
|
||||||
|
|
||||||
setItems((prev) =>
|
setItems((prev) =>
|
||||||
prev.map((item) => {
|
prev.map((item) => {
|
||||||
const guess = guesses.get(item.id);
|
const guess = guesses.get(item.id);
|
||||||
const release = releaseGuesses.get(item.id);
|
const release = releaseGuesses.get(item.id);
|
||||||
|
const probe = probes.get(item.id);
|
||||||
|
|
||||||
// Release-tag fields apply on top of whatever the TV/movie guess below
|
// 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
|
// contributes (or nothing at all, for a file with no season/episode or movie
|
||||||
@@ -692,8 +731,34 @@ export default function RenamePanel() {
|
|||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
// Real-probed fields: `resolution`/`video codec`/`audio codec` only fill in
|
||||||
|
// when the filename tag above didn't already provide that field (an explicit
|
||||||
|
// release-group tag wins over a probed value), but `width`/`height`/`fps`/
|
||||||
|
// `duration`/`file size` have no filename-tag equivalent at all, so those
|
||||||
|
// always apply whenever probing found them.
|
||||||
|
const probeBindings: Record<string, string> | null = probe
|
||||||
|
? {
|
||||||
|
"file size": probe.fileSize,
|
||||||
|
...(probe.width ? { width: String(probe.width) } : {}),
|
||||||
|
...(probe.height ? { height: String(probe.height) } : {}),
|
||||||
|
...(probe.frameRate ? { fps: String(Math.round(probe.frameRate * 100) / 100) } : {}),
|
||||||
|
...(probe.duration ? { duration: probe.duration } : {}),
|
||||||
|
...(!release?.resolution && probe.resolution
|
||||||
|
? { vf: probe.resolution, resolution: probe.resolution }
|
||||||
|
: {}),
|
||||||
|
...(!release?.videoCodec && probe.videoCodec
|
||||||
|
? { vc: probe.videoCodec, "video codec": probe.videoCodec }
|
||||||
|
: {}),
|
||||||
|
...(!release?.audioCodec && probe.audioCodec
|
||||||
|
? { ac: probe.audioCodec, "audio codec": probe.audioCodec }
|
||||||
|
: {}),
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
if (!guess) {
|
if (!guess) {
|
||||||
return releaseBindings ? { ...item, bindings: { ...item.bindings, ...releaseBindings } } : item;
|
return releaseBindings || probeBindings
|
||||||
|
? { ...item, bindings: { ...item.bindings, ...probeBindings, ...releaseBindings } }
|
||||||
|
: item;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (guess.kind === "tv") {
|
if (guess.kind === "tv") {
|
||||||
@@ -713,6 +778,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),
|
||||||
|
...probeBindings,
|
||||||
...releaseBindings,
|
...releaseBindings,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -731,6 +797,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) } : {}),
|
||||||
|
...probeBindings,
|
||||||
...releaseBindings,
|
...releaseBindings,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -747,7 +814,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"}${tagged > 0 ? `, ${tagged} with release tags` : ""}; 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` : ""}${probed > 0 ? `, ${probed} probed from file` : ""}; local only, no network calls).`
|
||||||
: "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);
|
||||||
@@ -1448,12 +1515,27 @@ export default function RenamePanel() {
|
|||||||
<code>{"{audio codec}"}</code>
|
<code>{"{audio codec}"}</code>
|
||||||
<code>{"{release group}"}</code>
|
<code>{"{release group}"}</code>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="format-fields-group">
|
||||||
|
<span className="format-fields-group-title">
|
||||||
|
Real file probing, after <strong>Match</strong> (reads the actual file, local, offline)
|
||||||
|
</span>
|
||||||
|
<code>{"{width}"}</code>
|
||||||
|
<code>{"{height}"}</code>
|
||||||
|
<code>{"{fps}"}</code>
|
||||||
|
<code>{"{duration}"}</code>
|
||||||
|
<code>{"{file size}"}</code>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="format-hint">
|
<p className="format-hint">
|
||||||
Match scans filenames locally for season/episode or movie title/year patterns, plus release-tag text
|
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
|
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 (Aurora Curator has no video/audio
|
needs a network call, since it's all parsed straight from the filename itself. Alongside that, Match also
|
||||||
file prober, so a tag that isn't in the filename text just won't be available). Fetch Data resolves the
|
probes each file's own container header (no ffmpeg/MediaInfo needed) for its real resolution, frame
|
||||||
|
rate, duration, and codecs — a filename tag wins whenever a file has both, but <code>{"{resolution}"}</code>,{" "}
|
||||||
|
<code>{"{video codec}"}</code>, and <code>{"{audio codec}"}</code> still fill in from the real file when
|
||||||
|
the filename doesn't carry that tag, and <code>{"{width}"}</code>/<code>{"{height}"}</code>/
|
||||||
|
<code>{"{fps}"}</code>/<code>{"{duration}"}</code>/<code>{"{file size}"}</code> are only ever available
|
||||||
|
this way. Fetch Data resolves the
|
||||||
season/episode or movie guess against real online metadata for the real title, episode title, and
|
season/episode or movie guess against real online metadata for the real title, episode title, and
|
||||||
air/release date. TV episodes can come from TMDB, TVmaze, or OMDb, and movies from TMDB or OMDb — pick
|
air/release date. TV episodes can come from TMDB, TVmaze, or OMDb, and movies from TMDB or OMDb — pick
|
||||||
each independently in <strong>Fetch Data Settings</strong> (TVmaze is free and needs no credential at
|
each independently in <strong>Fetch Data Settings</strong> (TVmaze is free and needs no credential at
|
||||||
|
|||||||
Reference in New Issue
Block a user