From e0cbb983ed6f0e1c9d47350b164a2cfa85d698db Mon Sep 17 00:00:00 2001 From: vyrti Date: Thu, 13 Aug 2026 13:11:11 +0300 Subject: [PATCH 1/2] feat(mediainfo): fetch titles, synopses and artwork from public metadata APIs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VuIO indexed media purely from local sources: symphonia read audio tags, and video files got no metadata at all — the scanner gates extraction on `audio/`, so a movie was a filename with no title, synopsis, year or artwork. Cover art existed only for audio, was re-read from disk on every request, and was never cached. This adds a MediaInfo section to the dashboard's Admin tab with a button that looks the whole library up against public metadata services, and feeds the results through to the dashboard, to DLNA ContentDirectory, and to /media/{id}/cover. Ten providers. Five answer without an account and are on by default — TVmaze, MusicBrainz (with Cover Art Archive for artwork), Jikan, AniList and Kitsu. Five more are listed but idle until a credential is saved: TMDb, OMDb, Discogs, Last.fm and Genius. Notable decisions: - Credentials live in the `secrets` table, not config.toml. Under Docker the configuration is built from environment variables and the admin API refuses to write the file, so a token kept there would be unsettable in exactly the deployment most likely to need one. - Fetched records go in their own `mediainfo` table rather than `media_tags`, which is cleared and rewritten on every rescan. What a provider said is not re-derivable from the file, so it has to survive a scan; it still goes when the file does, via the cascade. - Matches are scored 0-100 and only shown above the configured threshold. Weaker ones are stored and listed in the Admin tab for review rather than used, because relabelling a film called Arrival as "Dead on Arrival" is worse than showing the filename. - Requests are paced per provider to each publisher's documented limit. MusicBrainz enforces one request per second per IP at the server, which makes it the slowest path by design rather than by accident. - reqwest 0.13 is behind the default-on `mediainfo` feature. http_client.rs is left alone: it is deliberately cleartext with no TLS, redirects or name resolution, and all three are required here. Schema goes to v3; the migration only adds a table, so an upgraded file behaves exactly as before until someone presses Fetch. --- config.example.toml | 20 + crates/vuio-core/Cargo.toml | 18 +- crates/vuio-core/src/config/generator.rs | 7 +- crates/vuio-core/src/config/loading.rs | 35 ++ crates/vuio-core/src/config/mod.rs | 5 +- crates/vuio-core/src/config/model.rs | 66 ++ crates/vuio-core/src/config/platform.rs | 18 + crates/vuio-core/src/config/template.toml | 6 + crates/vuio-core/src/database/mod.rs | 100 +++ .../src/database/sqlite/mediainfo_repo.rs | 224 +++++++ crates/vuio-core/src/database/sqlite/mod.rs | 5 +- .../vuio-core/src/database/sqlite/schema.rs | 65 +- .../vuio-core/src/database/sqlite/session.rs | 40 +- crates/vuio-core/src/database/sqlite/tests.rs | 31 + .../vuio-core/src/database/sqlite/traits.rs | 35 ++ crates/vuio-core/src/lib.rs | 1 + crates/vuio-core/src/lifecycle/runner.rs | 2 + crates/vuio-core/src/mediainfo/artwork.rs | 167 +++++ crates/vuio-core/src/mediainfo/client.rs | 307 ++++++++++ crates/vuio-core/src/mediainfo/credentials.rs | 182 ++++++ crates/vuio-core/src/mediainfo/job.rs | 462 ++++++++++++++ crates/vuio-core/src/mediainfo/matching.rs | 482 +++++++++++++++ crates/vuio-core/src/mediainfo/mod.rs | 54 ++ crates/vuio-core/src/mediainfo/provider.rs | 220 +++++++ .../src/mediainfo/providers/anilist.rs | 139 +++++ .../src/mediainfo/providers/discogs.rs | 113 ++++ .../src/mediainfo/providers/genius.rs | 120 ++++ .../src/mediainfo/providers/jikan.rs | 125 ++++ .../src/mediainfo/providers/kitsu.rs | 111 ++++ .../src/mediainfo/providers/lastfm.rs | 144 +++++ .../vuio-core/src/mediainfo/providers/mod.rs | 178 ++++++ .../src/mediainfo/providers/musicbrainz.rs | 162 +++++ .../vuio-core/src/mediainfo/providers/omdb.rs | 122 ++++ .../vuio-core/src/mediainfo/providers/tmdb.rs | 132 ++++ .../src/mediainfo/providers/tvmaze.rs | 176 ++++++ crates/vuio-core/src/mediainfo/rate_limit.rs | 103 ++++ crates/vuio-core/src/state.rs | 10 + crates/vuio-core/src/web/admin.rs | 85 +++ crates/vuio-core/src/web/mediainfo.rs | 274 +++++++++ crates/vuio-core/src/web/mod.rs | 17 + .../src/web/soap/content_directory.rs | 6 + crates/vuio-core/src/web/soap/music.rs | 3 + crates/vuio-core/src/web/streaming.rs | 51 +- crates/vuio-core/src/web/ui.rs | 44 +- crates/vuio-core/src/web/ui/css/admin.css | 95 +++ crates/vuio-core/src/web/ui/css/browse.css | 14 + crates/vuio-core/src/web/ui/dashboard.html | 1 + crates/vuio-core/src/web/ui/js/admin.js | 10 + crates/vuio-core/src/web/ui/js/browse.js | 14 +- crates/vuio-core/src/web/ui/js/mediainfo.js | 357 +++++++++++ crates/vuio-core/src/web/ui/js/nav.js | 8 +- crates/vuio-core/src/web/xml/rendering.rs | 92 ++- .../tests/audio_integration_tests.rs | 4 + crates/vuio-core/tests/issue_24_pagination.rs | 4 + .../vuio-core/tests/mcp_integration_tests.rs | 4 + .../tests/mediainfo_integration_tests.rs | 574 ++++++++++++++++++ .../tests/metrics_integration_tests.rs | 2 + .../tests/music_browse_integration_tests.rs | 2 + crates/vuio-core/tests/samsungtv_browse.rs | 2 + docs/api.md | 85 +++ 60 files changed, 5915 insertions(+), 20 deletions(-) create mode 100644 crates/vuio-core/src/database/sqlite/mediainfo_repo.rs create mode 100644 crates/vuio-core/src/mediainfo/artwork.rs create mode 100644 crates/vuio-core/src/mediainfo/client.rs create mode 100644 crates/vuio-core/src/mediainfo/credentials.rs create mode 100644 crates/vuio-core/src/mediainfo/job.rs create mode 100644 crates/vuio-core/src/mediainfo/matching.rs create mode 100644 crates/vuio-core/src/mediainfo/mod.rs create mode 100644 crates/vuio-core/src/mediainfo/provider.rs create mode 100644 crates/vuio-core/src/mediainfo/providers/anilist.rs create mode 100644 crates/vuio-core/src/mediainfo/providers/discogs.rs create mode 100644 crates/vuio-core/src/mediainfo/providers/genius.rs create mode 100644 crates/vuio-core/src/mediainfo/providers/jikan.rs create mode 100644 crates/vuio-core/src/mediainfo/providers/kitsu.rs create mode 100644 crates/vuio-core/src/mediainfo/providers/lastfm.rs create mode 100644 crates/vuio-core/src/mediainfo/providers/mod.rs create mode 100644 crates/vuio-core/src/mediainfo/providers/musicbrainz.rs create mode 100644 crates/vuio-core/src/mediainfo/providers/omdb.rs create mode 100644 crates/vuio-core/src/mediainfo/providers/tmdb.rs create mode 100644 crates/vuio-core/src/mediainfo/providers/tvmaze.rs create mode 100644 crates/vuio-core/src/mediainfo/rate_limit.rs create mode 100644 crates/vuio-core/src/web/mediainfo.rs create mode 100644 crates/vuio-core/src/web/ui/js/mediainfo.js create mode 100644 crates/vuio-core/tests/mediainfo_integration_tests.rs diff --git a/config.example.toml b/config.example.toml index d7f6bced..68ae2e49 100644 --- a/config.example.toml +++ b/config.example.toml @@ -72,3 +72,23 @@ path = "./config/media.db" # session_ttl_hours = 12 # Management is loopback-only unless CIDRs are explicitly listed. # allowed_networks = [] + +[mediainfo] +# Fetch titles, synopses, ratings and artwork from public metadata services. +# This is the only part of VuIO that contacts anything outside the local +# network, and nothing is requested until you press Fetch in the dashboard. +# enabled = false +# Providers to consult. The five listed here need no account; tmdb, omdb, +# discogs, lastfm and genius work once you save a key in the dashboard. +# providers = ["tvmaze", "musicbrainz", "jikan", "anilist", "kitsu"] +# Download posters and cover art into a local cache so DLNA clients, which +# usually cannot reach the internet, can still display them. +# artwork_enabled = true +# Omit to keep the cache beside the database. +# artwork_path = "./config/artwork" +# Matches scoring below this are stored but flagged for review rather than +# trusted. 0-100. +# min_confidence = 60 +# Whether a fetched title outranks the one read from the file's own tags. +# prefer_online_titles = true +# request_timeout_seconds = 15 diff --git a/crates/vuio-core/Cargo.toml b/crates/vuio-core/Cargo.toml index 0f66af0b..eda9818d 100644 --- a/crates/vuio-core/Cargo.toml +++ b/crates/vuio-core/Cargo.toml @@ -31,7 +31,7 @@ path = "src/lib.rs" # Never gated, because they are what a media server *is*: SSDP discovery, UPnP # ContentDirectory, HTTP range streaming, media scanning and indexing, the # database, and configuration. -default = ["casting", "dashboard", "diagnostics", "mcp", "metadata"] +default = ["casting", "dashboard", "diagnostics", "mcp", "metadata", "mediainfo"] # Cast to Chromecast, AirPlay and DLNA renderers. # @@ -60,6 +60,17 @@ dashboard = [] diagnostics = ["dep:sysinfo"] mcp = [] metadata = ["dep:symphonia"] + +# Fetch titles, synopses, ratings and artwork for the library from public metadata +# APIs. This is the one part of VuIO that leaves the LAN, and it is the reason +# `reqwest` is in the tree at all: `http_client.rs` is deliberately cleartext with +# no TLS, no redirects and no name resolution, which is right for talking to a TV +# on the same subnet and useless for talking to musicbrainz.org. +# +# On by default because the dashboard section has to be visible for anyone to find +# the feature, and nothing here opens a socket until the operator presses the +# button. Drop it for a build that must never reach the internet. +mediainfo = ["dep:reqwest"] unstable-internals = [] [dependencies] @@ -114,6 +125,11 @@ getrandom = { version = "0.3", optional = true } plist = { version = "1.8", optional = true } hex = { version = "0.4", optional = true } rusqlite = { version = "0.40.2", features = ["bundled", "collation"] } +# Only the `mediainfo` feature uses this, and only to talk to public metadata APIs +# over TLS. `rustls-no-provider` rather than a provider-selecting feature because +# `Runtime::start` already installs the ring provider process-wide, and a second +# installation from a dependency's default would be the one that loses. +reqwest = { version = "0.13", default-features = false, features = ["json", "rustls-no-provider"], optional = true } [target.'cfg(windows)'.dependencies] windows = { version = "0.62", features = [ diff --git a/crates/vuio-core/src/config/generator.rs b/crates/vuio-core/src/config/generator.rs index 6dd25ca0..92d53e52 100644 --- a/crates/vuio-core/src/config/generator.rs +++ b/crates/vuio-core/src/config/generator.rs @@ -425,8 +425,9 @@ mod tests { use super::*; use crate::config::validation::ConfigValidator; use crate::config::{ - AppConfig, DatabaseConfig, ManagementConfig, MediaConfig, MonitoredDirectoryConfig, - NetworkConfig, NetworkInterfaceConfig, ServerConfig, ValidationMode, + AppConfig, DatabaseConfig, ManagementConfig, MediaConfig, MediaInfoConfig, + MonitoredDirectoryConfig, NetworkConfig, NetworkInterfaceConfig, ServerConfig, + ValidationMode, }; use uuid::Uuid; @@ -474,6 +475,7 @@ mod tests { cache_mb: 128, }, management: ManagementConfig::default(), + mediainfo: MediaInfoConfig::default(), }; // Generate TOML @@ -583,6 +585,7 @@ mod tests { cache_mb: 128, }, management: ManagementConfig::default(), + mediainfo: MediaInfoConfig::default(), }; // Generate TOML diff --git a/crates/vuio-core/src/config/loading.rs b/crates/vuio-core/src/config/loading.rs index 67bf12dc..ae162766 100644 --- a/crates/vuio-core/src/config/loading.rs +++ b/crates/vuio-core/src/config/loading.rs @@ -162,6 +162,40 @@ impl AppConfig { .map(str::to_owned) .collect(), }, + mediainfo: MediaInfoConfig { + enabled: std::env::var("VUIO_MEDIAINFO_ENABLED") + .map(|value| value.eq_ignore_ascii_case("true")) + .unwrap_or(false), + // An empty list would mean "consult nothing", which is not what an + // unset variable asks for, so fall back to the key-free default. + providers: std::env::var("VUIO_MEDIAINFO_PROVIDERS") + .ok() + .map(|value| { + value + .split(',') + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_owned) + .collect::>() + }) + .filter(|providers| !providers.is_empty()) + .unwrap_or_else(default_mediainfo_providers), + artwork_enabled: std::env::var("VUIO_MEDIAINFO_ARTWORK") + .map(|value| value.eq_ignore_ascii_case("true")) + .unwrap_or(true), + artwork_path: std::env::var("VUIO_MEDIAINFO_ARTWORK_PATH").ok(), + min_confidence: std::env::var("VUIO_MEDIAINFO_MIN_CONFIDENCE") + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or_else(default_min_confidence), + prefer_online_titles: std::env::var("VUIO_MEDIAINFO_PREFER_ONLINE_TITLES") + .map(|value| value.eq_ignore_ascii_case("true")) + .unwrap_or(true), + request_timeout_seconds: std::env::var("VUIO_MEDIAINFO_TIMEOUT_SECONDS") + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or_else(default_mediainfo_timeout_seconds), + }, }) } @@ -325,6 +359,7 @@ impl AppConfig { cache_mb: default_cache_mb(), }, management: ManagementConfig::default(), + mediainfo: MediaInfoConfig::default(), } } diff --git a/crates/vuio-core/src/config/mod.rs b/crates/vuio-core/src/config/mod.rs index a128c223..94498d1b 100644 --- a/crates/vuio-core/src/config/mod.rs +++ b/crates/vuio-core/src/config/mod.rs @@ -13,10 +13,11 @@ mod model; pub mod validation; use model::{ - default_cache_mb, default_session_ttl_hours, default_unavailable_root_grace_hours, + default_cache_mb, default_mediainfo_providers, default_mediainfo_timeout_seconds, + default_min_confidence, default_session_ttl_hours, default_unavailable_root_grace_hours, }; pub use model::{ - AppConfig, ConfigOverrides, DatabaseConfig, ManagementConfig, MediaConfig, + AppConfig, ConfigOverrides, DatabaseConfig, ManagementConfig, MediaConfig, MediaInfoConfig, MonitoredDirectoryConfig, NetworkConfig, NetworkInterfaceConfig, ServerConfig, ValidationMode, }; diff --git a/crates/vuio-core/src/config/model.rs b/crates/vuio-core/src/config/model.rs index 22b8b370..dafc5c48 100644 --- a/crates/vuio-core/src/config/model.rs +++ b/crates/vuio-core/src/config/model.rs @@ -32,6 +32,28 @@ pub(super) fn default_cache_mb() -> usize { 128 } +pub(super) fn default_true() -> bool { + true +} + +/// The providers that need no account. Everything else stays off until the +/// operator supplies a credential, so the default configuration cannot produce a +/// run that fails half its lookups on 401. +pub(super) fn default_mediainfo_providers() -> Vec { + crate::mediainfo::DEFAULT_PROVIDER_IDS + .iter() + .map(|id| (*id).to_string()) + .collect() +} + +pub(super) fn default_min_confidence() -> u8 { + 60 +} + +pub(super) fn default_mediainfo_timeout_seconds() -> u64 { + 15 +} + /// Settings the host supplied on the command line, which win over the file for the /// lifetime of the run. /// @@ -111,6 +133,50 @@ pub struct AppConfig { pub database: DatabaseConfig, #[serde(default)] pub management: ManagementConfig, + #[serde(default)] + pub mediainfo: MediaInfoConfig, +} + +/// Fetching titles, synopses and artwork from public metadata APIs. +/// +/// Defaulted as a whole, like `[management]`: a config file written before this +/// existed has no `[mediainfo]` table and must keep loading. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct MediaInfoConfig { + /// Whether the feature may run at all. Off until asked for — this is the only + /// part of VuIO that talks to anything outside the local network. + #[serde(default = "default_false")] + pub enabled: bool, + /// Provider ids to consult, in preference order. Empty means the key-free set. + #[serde(default = "default_mediainfo_providers")] + pub providers: Vec, + #[serde(default = "default_true")] + pub artwork_enabled: bool, + /// Where downloaded posters are cached. Filled in from the database directory + /// by `apply_platform_defaults` when absent. + pub artwork_path: Option, + /// Below this score a match is stored but flagged rather than trusted. + #[serde(default = "default_min_confidence")] + pub min_confidence: u8, + /// Whether a fetched title outranks the one read from local tags. + #[serde(default = "default_true")] + pub prefer_online_titles: bool, + #[serde(default = "default_mediainfo_timeout_seconds")] + pub request_timeout_seconds: u64, +} + +impl Default for MediaInfoConfig { + fn default() -> Self { + Self { + enabled: false, + providers: default_mediainfo_providers(), + artwork_enabled: true, + artwork_path: None, + min_confidence: default_min_confidence(), + prefer_online_titles: true, + request_timeout_seconds: default_mediainfo_timeout_seconds(), + } + } } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] diff --git a/crates/vuio-core/src/config/platform.rs b/crates/vuio-core/src/config/platform.rs index 64ceb0ad..c1e72eba 100644 --- a/crates/vuio-core/src/config/platform.rs +++ b/crates/vuio-core/src/config/platform.rs @@ -15,6 +15,24 @@ impl AppConfig { ); } + // Artwork sits beside the database rather than in the media folders: those + // may be read-only mounts, and a cache written into someone's library would + // then be picked up by the scanner as media. + // + // Filled in here rather than left as None because the reload path compares + // whole configs for equality, and a field that normalises later would make + // every reload look like a change. + if self.mediainfo.artwork_path.is_none() { + let artwork = self + .database + .path + .as_ref() + .map(std::path::PathBuf::from) + .and_then(|path| path.parent().map(|parent| parent.join("artwork"))) + .unwrap_or_else(|| platform_config.get_database_path().with_file_name("artwork")); + self.mediainfo.artwork_path = Some(artwork.to_string_lossy().to_string()); + } + // Ensure media directories have platform-appropriate exclude patterns for dir_config in &mut self.media.directories { if dir_config.exclude_patterns.is_none() { diff --git a/crates/vuio-core/src/config/template.toml b/crates/vuio-core/src/config/template.toml index fa9243ac..796e41b3 100644 --- a/crates/vuio-core/src/config/template.toml +++ b/crates/vuio-core/src/config/template.toml @@ -57,5 +57,11 @@ path = "PLACEHOLDER_DATABASE_PATH" vacuum_on_startup = false backup_enabled = false +# Online media info +# The only part of VuIO that contacts anything outside the local network, and +# only when asked to from the dashboard. +[mediainfo] +enabled = false + # Platform-specific notes: # PLACEHOLDER_PLATFORM_NOTES diff --git a/crates/vuio-core/src/database/mod.rs b/crates/vuio-core/src/database/mod.rs index 686f724e..ae1006d1 100644 --- a/crates/vuio-core/src/database/mod.rs +++ b/crates/vuio-core/src/database/mod.rs @@ -612,6 +612,38 @@ pub trait DatabaseReadSession { ) -> Result where F: for<'a> FnMut(Self::Playlist<'a>) -> Result<()>; + + /// The fetched media info for `ids`, for rendering a browse page. + /// + /// Inside the session because DIDL is written from a blocking read and cannot + /// await a lookup mid-row. Defaulted to empty so a backend that stores no + /// media info still satisfies the trait. + /// + /// Rows below `min_confidence` are not returned. They are kept in the table so + /// the operator can review them, but a guess that was not good enough to trust + /// must not end up relabelling the library — showing "Dead on Arrival" for a + /// film called Arrival is worse than showing the filename. + fn mediainfo_overlays( + &mut self, + ids: &[i64], + min_confidence: u8, + ) -> Result> { + let _ = (ids, min_confidence); + Ok(std::collections::HashMap::new()) + } +} + +/// The few fetched fields a browse response actually renders. +/// +/// Deliberately not [`MediaInfoRecord`]: that carries the provider's whole JSON +/// payload, and pulling one of those per row would dwarf the DIDL document it is +/// decorating. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct MediaInfoOverlay { + pub title: Option, + pub overview: Option, + pub genres: Vec, + pub has_artwork: bool, } /// Media-library storage and query operations implemented by a database backend. @@ -918,6 +950,73 @@ pub trait SecretStore: Send + Sync { async fn delete_secret(&self, key: &str) -> Result; } +/// What a public metadata service said about one file. +/// +/// Kept apart from [`MediaFile`] because the two have different lifetimes: a +/// media record is re-derived from the file on every scan, and this is not +/// derivable from the file at all. `payload` holds the provider's own record +/// whole, so a field that turns out to be worth showing is a query rather than +/// another migration — the same bargain `media_tags` makes for local tags. +#[derive(Clone, Debug, PartialEq)] +pub struct MediaInfoRecord { + pub media_file_id: i64, + pub provider: String, + pub remote_id: String, + /// `movie` | `series` | `episode` | `album` | `track` | `anime` + pub kind: String, + pub title: Option, + pub original_title: Option, + pub overview: Option, + pub release_date: Option, + pub year: Option, + pub rating: Option, + pub genres: Vec, + pub season: Option, + pub episode: Option, + /// Key into the artwork cache, if a poster was downloaded. + pub artwork_key: Option, + pub payload: String, + /// 0–100. Below the configured threshold the row is kept but flagged. + pub confidence: u8, + pub fetched_at: SystemTime, + pub mediainfo_version: u32, +} + +/// How much of the library has been looked up. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize)] +pub struct MediaInfoStats { + pub total: u64, + /// Rows at or above the confidence threshold they were stored with. + pub confident: u64, + pub low_confidence: u64, + pub with_artwork: u64, +} + +/// Storage for fetched media info. +/// +/// Not gated behind the `mediainfo` feature: it is plain SQL with no HTTP in it, +/// and gating it would fragment this trait and the conformance suite for no gain. +/// A build without the feature simply never writes a row. +#[async_trait] +pub trait MediaInfoRepository: Send + Sync { + async fn get_mediainfo(&self, media_file_id: i64) -> Result>; + /// Look up many at once. The browse path renders a page at a time and must not + /// issue one query per row. + async fn get_mediainfo_batch(&self, media_file_ids: &[i64]) -> Result>; + async fn bulk_store_mediainfo(&self, records: &[MediaInfoRecord]) -> Result<()>; + /// The least certain matches first, for the operator to review. + async fn list_low_confidence(&self, threshold: u8, limit: usize) + -> Result>; + async fn mediainfo_stats(&self, threshold: u8) -> Result; + /// Forget everything, so the next run starts over. + async fn clear_mediainfo(&self) -> Result; + /// Ids of files that have no usable row yet, oldest first. + /// + /// `version` is the current reader version: rows written by an older one are + /// treated as absent so a bumped version re-fetches. + async fn media_ids_missing_mediainfo(&self, version: u32, threshold: u8) -> Result>; +} + /// Aggregate database capability used by the application. #[async_trait] pub trait DatabaseManager: @@ -926,6 +1025,7 @@ pub trait DatabaseManager: + HealthRepository + StatsRepository + SecretStore + + MediaInfoRepository + Send + Sync { diff --git a/crates/vuio-core/src/database/sqlite/mediainfo_repo.rs b/crates/vuio-core/src/database/sqlite/mediainfo_repo.rs new file mode 100644 index 00000000..9bc7376e --- /dev/null +++ b/crates/vuio-core/src/database/sqlite/mediainfo_repo.rs @@ -0,0 +1,224 @@ +//! Storage for what public metadata services said about a file. + +use anyhow::Result; +use rusqlite::{OptionalExtension, Row}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use super::SqliteDatabase; +use crate::database::{MediaInfoRecord, MediaInfoStats}; + +/// Column order shared by every read here, so the index constants below stay +/// meaningful. +const COLUMNS: &str = "\ +media_file_id, provider, remote_id, kind, title, original_title, overview, \ +release_date, year, rating, genres, season, episode, artwork_key, payload, \ +confidence, fetched_at, mediainfo_version"; + +fn seconds_since_epoch(time: SystemTime) -> i64 { + time.duration_since(UNIX_EPOCH) + .map(|elapsed| elapsed.as_secs() as i64) + .unwrap_or(0) +} + +fn record_from_row(row: &Row<'_>) -> rusqlite::Result { + let genres: Option = row.get(10)?; + Ok(MediaInfoRecord { + media_file_id: row.get(0)?, + provider: row.get(1)?, + remote_id: row.get(2)?, + kind: row.get(3)?, + title: row.get(4)?, + original_title: row.get(5)?, + overview: row.get(6)?, + release_date: row.get(7)?, + year: row.get::<_, Option>(8)?.map(|year| year as u32), + rating: row.get(9)?, + // Stored as a JSON array. A row whose genres will not parse is not worth + // failing the whole listing over — it comes back with none. + genres: genres + .and_then(|genres| serde_json::from_str::>(&genres).ok()) + .unwrap_or_default(), + season: row.get::<_, Option>(11)?.map(|season| season as u32), + episode: row.get::<_, Option>(12)?.map(|episode| episode as u32), + artwork_key: row.get(13)?, + payload: row.get(14)?, + confidence: row.get::<_, i64>(15)?.clamp(0, 100) as u8, + fetched_at: UNIX_EPOCH + Duration::from_secs(row.get::<_, i64>(16)?.max(0) as u64), + mediainfo_version: row.get::<_, i64>(17)?.max(0) as u32, + }) +} + +impl SqliteDatabase { + pub(super) async fn get_mediainfo_impl( + &self, + media_file_id: i64, + ) -> Result> { + self.execute_read(move |connection| { + Ok(connection + .prepare_cached(&format!( + "SELECT {COLUMNS} FROM mediainfo WHERE media_file_id = ?" + ))? + .query_row([media_file_id], record_from_row) + .optional()?) + }) + .await + } + + pub(super) async fn get_mediainfo_batch_impl( + &self, + media_file_ids: &[i64], + ) -> Result> { + if media_file_ids.is_empty() { + return Ok(Vec::new()); + } + let ids = media_file_ids.to_vec(); + self.execute_read(move |connection| { + // A placeholder list rather than a temp table: browse pages are bounded + // by the requested count, so this stays well inside SQLite's limit. + let placeholders = std::iter::repeat_n("?", ids.len()).collect::>().join(","); + let mut statement = connection.prepare(&format!( + "SELECT {COLUMNS} FROM mediainfo WHERE media_file_id IN ({placeholders})" + ))?; + let rows = statement.query_map(rusqlite::params_from_iter(ids.iter()), record_from_row)?; + let mut records = Vec::new(); + for record in rows { + records.push(record?); + } + Ok(records) + }) + .await + } + + pub(super) async fn bulk_store_mediainfo_impl(&self, records: &[MediaInfoRecord]) -> Result<()> { + if records.is_empty() { + return Ok(()); + } + let records = records.to_vec(); + self.transact(move |transaction| { + let mut statement = transaction.prepare_cached( + "INSERT INTO mediainfo (\ + media_file_id, provider, remote_id, kind, title, original_title, overview, \ + release_date, year, rating, genres, season, episode, artwork_key, payload, \ + confidence, fetched_at, mediainfo_version\ + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18) \ + ON CONFLICT(media_file_id) DO UPDATE SET \ + provider = excluded.provider, remote_id = excluded.remote_id, \ + kind = excluded.kind, title = excluded.title, \ + original_title = excluded.original_title, overview = excluded.overview, \ + release_date = excluded.release_date, year = excluded.year, \ + rating = excluded.rating, genres = excluded.genres, \ + season = excluded.season, episode = excluded.episode, \ + artwork_key = excluded.artwork_key, payload = excluded.payload, \ + confidence = excluded.confidence, fetched_at = excluded.fetched_at, \ + mediainfo_version = excluded.mediainfo_version", + )?; + for record in &records { + let genres = serde_json::to_string(&record.genres).unwrap_or_else(|_| "[]".into()); + statement.execute(rusqlite::params![ + record.media_file_id, + record.provider, + record.remote_id, + record.kind, + record.title, + record.original_title, + record.overview, + record.release_date, + record.year.map(|year| year as i64), + record.rating, + genres, + record.season.map(|season| season as i64), + record.episode.map(|episode| episode as i64), + record.artwork_key, + record.payload, + record.confidence as i64, + seconds_since_epoch(record.fetched_at), + record.mediainfo_version as i64, + ])?; + } + Ok(()) + }) + .await + } + + pub(super) async fn list_low_confidence_impl( + &self, + threshold: u8, + limit: usize, + ) -> Result> { + self.execute_read(move |connection| { + let mut statement = connection.prepare_cached(&format!( + "SELECT {COLUMNS} FROM mediainfo WHERE confidence < ? \ + ORDER BY confidence ASC, media_file_id ASC LIMIT ?" + ))?; + let rows = statement + .query_map(rusqlite::params![threshold as i64, limit as i64], record_from_row)?; + let mut records = Vec::new(); + for record in rows { + records.push(record?); + } + Ok(records) + }) + .await + } + + pub(super) async fn mediainfo_stats_impl(&self, threshold: u8) -> Result { + self.execute_read(move |connection| { + let row = connection + .prepare_cached( + "SELECT COUNT(*), \ + COALESCE(SUM(confidence >= ?1), 0), \ + COALESCE(SUM(confidence < ?1), 0), \ + COALESCE(SUM(artwork_key IS NOT NULL), 0) \ + FROM mediainfo", + )? + .query_row([threshold as i64], |row| { + Ok(MediaInfoStats { + total: row.get::<_, i64>(0)?.max(0) as u64, + confident: row.get::<_, i64>(1)?.max(0) as u64, + low_confidence: row.get::<_, i64>(2)?.max(0) as u64, + with_artwork: row.get::<_, i64>(3)?.max(0) as u64, + }) + })?; + Ok(row) + }) + .await + } + + pub(super) async fn clear_mediainfo_impl(&self) -> Result { + self.transact(move |transaction| { + Ok(transaction.execute("DELETE FROM mediainfo", [])? as u64) + }) + .await + } + + pub(super) async fn media_ids_missing_mediainfo_impl( + &self, + version: u32, + threshold: u8, + ) -> Result> { + self.execute_read(move |connection| { + // Three cases count as "needs looking up": never tried, tried by an + // older reader, or tried and the answer was not good enough to trust. + // The last is what lets a run with a new provider key improve on a + // previous run without clearing the table first. + let mut statement = connection.prepare_cached( + "SELECT media_files.id FROM media_files \ + LEFT JOIN mediainfo ON mediainfo.media_file_id = media_files.id \ + WHERE mediainfo.media_file_id IS NULL \ + OR mediainfo.mediainfo_version < ?1 \ + OR mediainfo.confidence < ?2 \ + ORDER BY media_files.id", + )?; + let rows = statement.query_map( + rusqlite::params![version as i64, threshold as i64], + |row| row.get::<_, i64>(0), + )?; + let mut ids = Vec::new(); + for id in rows { + ids.push(id?); + } + Ok(ids) + }) + .await + } +} diff --git a/crates/vuio-core/src/database/sqlite/mod.rs b/crates/vuio-core/src/database/sqlite/mod.rs index afb798e5..586a5ee7 100644 --- a/crates/vuio-core/src/database/sqlite/mod.rs +++ b/crates/vuio-core/src/database/sqlite/mod.rs @@ -16,13 +16,14 @@ use tracing::{debug, info}; use super::{ DatabaseBackend, DatabaseHealth, DatabaseManager, DatabaseSettings, DatabaseStats, FileFingerprint, FileLocation, HealthRepository, MediaDirectory, MediaFile, MediaFileQuery, - MediaRepository, MusicCategory, Playlist, PlaylistRepository, RemovalSummary, RootAvailability, - SecretStore, StatsRepository, + MediaInfoRecord, MediaInfoRepository, MediaInfoStats, MediaRepository, MusicCategory, Playlist, + PlaylistRepository, RemovalSummary, RootAvailability, SecretStore, StatsRepository, }; mod directory; mod health; mod media_repo; +mod mediainfo_repo; mod playlist_repo; mod query; mod root_repo; diff --git a/crates/vuio-core/src/database/sqlite/schema.rs b/crates/vuio-core/src/database/sqlite/schema.rs index 1ebfd63a..9dec1c38 100644 --- a/crates/vuio-core/src/database/sqlite/schema.rs +++ b/crates/vuio-core/src/database/sqlite/schema.rs @@ -18,7 +18,7 @@ use crate::database::{AudioTags, FileFingerprint, FileLocation, MediaFile, Playl /// [`MIGRATIONS`]; only a *newer* file — one written by a build that knows /// something this one does not — is refused, because there is no way to /// downgrade a schema without guessing at what to discard. -pub(super) const SCHEMA_VERSION: i64 = 2; +pub(super) const SCHEMA_VERSION: i64 = 3; /// Name of the collation that carries the application's natural ordering into /// SQL. Registered on every connection; see [`register_collations`]. @@ -170,6 +170,39 @@ CREATE TABLE IF NOT EXISTS media_tags ( ) STRICT; CREATE INDEX IF NOT EXISTS idx_media_tags_key ON media_tags(key, value); + +-- What a public metadata service said about a file: title, synopsis, rating and +-- a pointer into the artwork cache. +-- +-- Deliberately not `media_tags`. That table is cleared and rewritten in full +-- every time a record is re-scanned, because it holds what the file itself +-- claims and the file is the authority on that. This holds what somebody else +-- said, which no amount of re-reading the file can reproduce, so it has to +-- survive a scan. It still goes when the file does, via the cascade. +CREATE TABLE IF NOT EXISTS mediainfo ( + media_file_id INTEGER PRIMARY KEY REFERENCES media_files(id) ON DELETE CASCADE, + provider TEXT NOT NULL, + remote_id TEXT NOT NULL, + kind TEXT NOT NULL, + title TEXT, + original_title TEXT, + overview TEXT, + release_date TEXT, + year INTEGER, + rating REAL, + genres TEXT, + season INTEGER, + episode INTEGER, + artwork_key TEXT, + payload TEXT NOT NULL, + confidence INTEGER NOT NULL, + fetched_at INTEGER NOT NULL, + mediainfo_version INTEGER NOT NULL +) STRICT; + +-- The dashboard lists the least certain matches first, which is a sort over the +-- whole table. +CREATE INDEX IF NOT EXISTS idx_mediainfo_confidence ON mediainfo(confidence); "#; /// Schema upgrades, applied in order to any file older than [`SCHEMA_VERSION`]. @@ -183,7 +216,7 @@ CREATE INDEX IF NOT EXISTS idx_media_tags_key ON media_tags(key, value); /// that way needs a different plan, not a destructive one — the file holds /// AirPlay pairings, imported playlists, and the record ids that DIDL hands out /// as object ids, none of which survive a rebuild. -const MIGRATIONS: &[(i64, &str)] = &[(2, MIGRATION_V2)]; +const MIGRATIONS: &[(i64, &str)] = &[(2, MIGRATION_V2), (3, MIGRATION_V3)]; /// v1 → v2: full tag extraction. /// @@ -221,6 +254,34 @@ DROP INDEX IF EXISTS idx_media_dir_order; DROP INDEX IF EXISTS idx_media_album; "#; +/// v2 → v3: online media info. +/// +/// Only adds the `mediainfo` table. Existing rows are untouched and the table +/// starts empty, so an upgraded file behaves exactly as before until someone +/// presses Fetch. The idempotent DDL creates the same table on a fresh file. +const MIGRATION_V3: &str = r#" +CREATE TABLE IF NOT EXISTS mediainfo ( + media_file_id INTEGER PRIMARY KEY REFERENCES media_files(id) ON DELETE CASCADE, + provider TEXT NOT NULL, + remote_id TEXT NOT NULL, + kind TEXT NOT NULL, + title TEXT, + original_title TEXT, + overview TEXT, + release_date TEXT, + year INTEGER, + rating REAL, + genres TEXT, + season INTEGER, + episode INTEGER, + artwork_key TEXT, + payload TEXT NOT NULL, + confidence INTEGER NOT NULL, + fetched_at INTEGER NOT NULL, + mediainfo_version INTEGER NOT NULL +) STRICT; +"#; + /// Columns of `media_files`, qualified so the list can be used inside joins. pub(super) const MEDIA_COLUMNS: &str = "\ media_files.id, media_files.path, media_files.filename, media_files.size, \ diff --git a/crates/vuio-core/src/database/sqlite/session.rs b/crates/vuio-core/src/database/sqlite/session.rs index 4484b537..18ddb12c 100644 --- a/crates/vuio-core/src/database/sqlite/session.rs +++ b/crates/vuio-core/src/database/sqlite/session.rs @@ -11,7 +11,8 @@ use super::query::{self, MimeFilter}; use super::schema::column; use super::PooledConnection; use crate::database::{ - DatabaseReadSession, DirectoryView, MediaFileQuery, MediaFileView, PlaylistView, VisitSummary, + DatabaseReadSession, DirectoryView, MediaFileQuery, MediaFileView, MediaInfoOverlay, + PlaylistView, VisitSummary, }; /// A read transaction plus the connection it runs on. @@ -371,6 +372,43 @@ impl DatabaseReadSession for SqliteReadSession { } Ok(summary) } + + fn mediainfo_overlays( + &mut self, + ids: &[i64], + min_confidence: u8, + ) -> Result> { + let mut overlays = std::collections::HashMap::new(); + if ids.is_empty() { + return Ok(overlays); + } + // Bounded by the browse page size, so a placeholder list is well inside + // SQLite's parameter limit. `payload` is deliberately not selected. + let placeholders = std::iter::repeat_n("?", ids.len()) + .collect::>() + .join(","); + let mut statement = self.connection.prepare(&format!( + "SELECT media_file_id, title, overview, genres, artwork_key IS NOT NULL \ + FROM mediainfo WHERE confidence >= ? AND media_file_id IN ({placeholders})" + ))?; + let parameters = std::iter::once(min_confidence as i64).chain(ids.iter().copied()); + let mut rows = statement.query(rusqlite::params_from_iter(parameters))?; + while let Some(row) = rows.next()? { + let genres: Option = row.get(3)?; + overlays.insert( + row.get::<_, i64>(0)?, + MediaInfoOverlay { + title: row.get(1)?, + overview: row.get(2)?, + genres: genres + .and_then(|genres| serde_json::from_str::>(&genres).ok()) + .unwrap_or_default(), + has_artwork: row.get::<_, i64>(4)? != 0, + }, + ); + } + Ok(overlays) + } } /// Ordered direct children of a directory, filtered by MIME family. diff --git a/crates/vuio-core/src/database/sqlite/tests.rs b/crates/vuio-core/src/database/sqlite/tests.rs index 30190999..4ca9e1df 100644 --- a/crates/vuio-core/src/database/sqlite/tests.rs +++ b/crates/vuio-core/src/database/sqlite/tests.rs @@ -215,6 +215,37 @@ async fn a_v1_database_migrates_forward_without_losing_anything() { // A record left at tags_version 0 is stale against any real reader, so the // next scan rewrites it even though the file has not changed. assert!(file.tags_version < 1); + + // v3 added the media info table. It arrives empty and usable, and the file it + // hangs off keeps the id it already had. + use crate::database::MediaInfoRepository; + assert!(db.get_mediainfo(7).await.unwrap().is_none()); + db.bulk_store_mediainfo(&[crate::database::MediaInfoRecord { + media_file_id: 7, + provider: "musicbrainz".to_string(), + remote_id: "release-1".to_string(), + kind: "album".to_string(), + title: Some("Album".to_string()), + original_title: None, + overview: None, + release_date: None, + year: Some(1971), + rating: None, + genres: Vec::new(), + season: None, + episode: None, + artwork_key: None, + payload: "null".to_string(), + confidence: 90, + fetched_at: std::time::SystemTime::now(), + mediainfo_version: 1, + }]) + .await + .unwrap(); + assert_eq!( + db.get_mediainfo(7).await.unwrap().unwrap().year, + Some(1971) + ); } #[tokio::test] diff --git a/crates/vuio-core/src/database/sqlite/traits.rs b/crates/vuio-core/src/database/sqlite/traits.rs index 15f63649..9130c2b2 100644 --- a/crates/vuio-core/src/database/sqlite/traits.rs +++ b/crates/vuio-core/src/database/sqlite/traits.rs @@ -328,3 +328,38 @@ impl SecretStore for SqliteDatabase { SqliteDatabase::delete_secret_impl(self, key).await } } + +#[async_trait] +impl MediaInfoRepository for SqliteDatabase { + async fn get_mediainfo(&self, media_file_id: i64) -> Result> { + SqliteDatabase::get_mediainfo_impl(self, media_file_id).await + } + + async fn get_mediainfo_batch(&self, media_file_ids: &[i64]) -> Result> { + SqliteDatabase::get_mediainfo_batch_impl(self, media_file_ids).await + } + + async fn bulk_store_mediainfo(&self, records: &[MediaInfoRecord]) -> Result<()> { + SqliteDatabase::bulk_store_mediainfo_impl(self, records).await + } + + async fn list_low_confidence( + &self, + threshold: u8, + limit: usize, + ) -> Result> { + SqliteDatabase::list_low_confidence_impl(self, threshold, limit).await + } + + async fn mediainfo_stats(&self, threshold: u8) -> Result { + SqliteDatabase::mediainfo_stats_impl(self, threshold).await + } + + async fn clear_mediainfo(&self) -> Result { + SqliteDatabase::clear_mediainfo_impl(self).await + } + + async fn media_ids_missing_mediainfo(&self, version: u32, threshold: u8) -> Result> { + SqliteDatabase::media_ids_missing_mediainfo_impl(self, version, threshold).await + } +} diff --git a/crates/vuio-core/src/lib.rs b/crates/vuio-core/src/lib.rs index 1f71a537..f6c66a32 100644 --- a/crates/vuio-core/src/lib.rs +++ b/crates/vuio-core/src/lib.rs @@ -86,6 +86,7 @@ internal_modules!( logging, mdns, media, + mediainfo, platform, runtime, runtime_state, diff --git a/crates/vuio-core/src/lifecycle/runner.rs b/crates/vuio-core/src/lifecycle/runner.rs index 82e130cf..6a0b4c09 100644 --- a/crates/vuio-core/src/lifecycle/runner.rs +++ b/crates/vuio-core/src/lifecycle/runner.rs @@ -175,6 +175,8 @@ where active_casts: Arc::new(tokio::sync::Mutex::new( crate::runtime_state::ActiveCastRegistry::new(), )), + #[cfg(feature = "mediainfo")] + mediainfo_job: Arc::new(tokio::sync::Mutex::new(Default::default())), #[cfg(feature = "casting")] discovered_tvs: Arc::new(renderer_cache), upnp_subscriptions: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), diff --git a/crates/vuio-core/src/mediainfo/artwork.rs b/crates/vuio-core/src/mediainfo/artwork.rs new file mode 100644 index 00000000..7c8aa2e0 --- /dev/null +++ b/crates/vuio-core/src/mediainfo/artwork.rs @@ -0,0 +1,167 @@ +//! The poster cache. +//! +//! VuIO has never stored artwork: `serve_cover` re-reads a sidecar file or the +//! embedded tag on every request, which is fine when the bytes are already on +//! local disk and impossible when they are on someone else's server. Downloaded +//! posters therefore need somewhere to live, and it is not the database — a few +//! thousand JPEGs would multiply the size of a file that gets vacuumed, backed up +//! and copied around. +//! +//! Files are addressed by a hash of their source URL and sharded a byte deep, so a +//! large library does not produce a single directory with one entry per item. + +use super::client::Fetcher; +use anyhow::{bail, Context, Result}; +use std::path::{Path, PathBuf}; + +/// Posters are a few hundred KiB; anything an order of magnitude past that is not +/// a poster and is not worth the disk. +const MAX_ARTWORK_BYTES: usize = 8 * 1024 * 1024; + +#[derive(Clone)] +pub struct ArtworkCache { + root: PathBuf, +} + +impl ArtworkCache { + pub fn new(root: impl Into) -> Self { + Self { root: root.into() } + } + + pub fn root(&self) -> &Path { + &self.root + } + + /// The cache key for a source URL: a hex digest, so it is a valid filename on + /// every platform regardless of what the URL contained. + pub fn key_for(url: &str) -> String { + // FNV-1a, the same hash `ui.rs` uses for asset ETags. This is a cache + // address, not a security boundary — the only cost of a collision is one + // wrong thumbnail, and a 64-bit space makes that vanishingly unlikely. + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + for byte in url.as_bytes() { + hash ^= *byte as u64; + hash = hash.wrapping_mul(0x1000_0000_01b3); + } + format!("{hash:016x}") + } + + /// Where a key's file lives, given the extension implied by its content type. + fn path_for(&self, key: &str, extension: &str) -> PathBuf { + self.root.join(&key[..2]).join(format!("{key}.{extension}")) + } + + /// Find a cached file for `key`, whatever image type it was stored as. + pub fn lookup(&self, key: &str) -> Option { + if key.len() < 2 { + return None; + } + ["jpg", "png", "webp"] + .iter() + .map(|extension| self.path_for(key, extension)) + .find(|path| path.is_file()) + } + + /// Download `url` into the cache and return its key. + /// + /// Already-cached URLs are not re-fetched, which is what makes a second run of + /// the library fetch cheap. + pub async fn store(&self, http: &Fetcher, provider: &'static str, url: &str) -> Result { + let key = Self::key_for(url); + if self.lookup(&key).is_some() { + return Ok(key); + } + + let (content_type, bytes) = http.get_image(provider, url, MAX_ARTWORK_BYTES).await?; + let extension = match content_type.as_str() { + "image/jpeg" | "image/jpg" => "jpg", + "image/png" => "png", + "image/webp" => "webp", + // Refuse anything that is not an image we would serve back. A provider + // handing us an HTML error page should not become a cached "poster". + other => bail!("{provider}: artwork had unexpected content type {other:?}"), + }; + if bytes.is_empty() { + bail!("{provider}: artwork was empty"); + } + + let path = self.path_for(&key, extension); + let parent = path + .parent() + .context("artwork cache path has no parent directory")? + .to_path_buf(); + let bytes_to_write = bytes; + let write_path = path.clone(); + tokio::task::spawn_blocking(move || -> std::io::Result<()> { + std::fs::create_dir_all(&parent)?; + // Write beside the target and rename, so an interrupted download cannot + // leave a truncated image that later reads would serve as valid. + let temporary = write_path.with_extension("part"); + std::fs::write(&temporary, &bytes_to_write)?; + std::fs::rename(&temporary, &write_path) + }) + .await + .context("artwork cache write task failed")? + .context("Failed to write artwork to the cache")?; + + Ok(key) + } +} + +/// The content type to serve a cached file as, from its extension. +pub fn content_type_for(path: &Path) -> &'static str { + match path + .extension() + .and_then(|extension| extension.to_str()) + .unwrap_or_default() + { + "png" => "image/png", + "webp" => "image/webp", + _ => "image/jpeg", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_key_is_stable_and_url_specific() { + let one = ArtworkCache::key_for("https://example.test/a.jpg"); + assert_eq!(one, ArtworkCache::key_for("https://example.test/a.jpg")); + assert_ne!(one, ArtworkCache::key_for("https://example.test/b.jpg")); + assert_eq!(one.len(), 16); + assert!(one.chars().all(|c| c.is_ascii_hexdigit())); + } + + #[test] + fn files_are_sharded_by_the_first_two_characters() { + let cache = ArtworkCache::new("/tmp/artwork"); + let key = ArtworkCache::key_for("https://example.test/poster.jpg"); + let path = cache.path_for(&key, "jpg"); + assert_eq!( + path, + Path::new("/tmp/artwork").join(&key[..2]).join(format!("{key}.jpg")) + ); + } + + #[test] + fn lookup_finds_a_stored_file_of_any_supported_type() { + let temp = tempfile::tempdir().unwrap(); + let cache = ArtworkCache::new(temp.path()); + let key = ArtworkCache::key_for("https://example.test/poster.png"); + assert!(cache.lookup(&key).is_none()); + + let path = cache.path_for(&key, "png"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, b"bytes").unwrap(); + assert_eq!(cache.lookup(&key), Some(path)); + } + + #[test] + fn content_type_follows_the_extension() { + assert_eq!(content_type_for(Path::new("a/b.png")), "image/png"); + assert_eq!(content_type_for(Path::new("a/b.webp")), "image/webp"); + assert_eq!(content_type_for(Path::new("a/b.jpg")), "image/jpeg"); + } +} diff --git a/crates/vuio-core/src/mediainfo/client.rs b/crates/vuio-core/src/mediainfo/client.rs new file mode 100644 index 00000000..6b2d9e35 --- /dev/null +++ b/crates/vuio-core/src/mediainfo/client.rs @@ -0,0 +1,307 @@ +//! The outbound HTTP surface, and the types every provider speaks in. +//! +//! `http_client.rs` cannot serve this: it has no TLS, no redirects and no name +//! resolution, all three of which are load-bearing here (every endpoint is HTTPS +//! on a hostname, and Cover Art Archive answers with a redirect to archive.org). +//! So this is the one place `reqwest` is used, kept behind one small wrapper so +//! providers cannot each invent their own timeout, cap or header policy. + +use super::provider::ProviderInfo; +use super::rate_limit::RateLimiters; +use anyhow::{bail, Context, Result}; +use std::time::Duration; + +/// Identifies VuIO to the services it queries. +/// +/// MusicBrainz rejects requests that do not identify their client, and blocks +/// clients that share a generic one, so this is a requirement rather than a +/// courtesy. It is sent to every provider for consistency. +pub const USER_AGENT: &str = "MediaServer (http://github/media)"; + +/// A JSON response is read with an explicit cap. These are third-party services +/// rather than trusted peers, and a body that never ends must not be able to +/// exhaust memory — the same rule `http_client.rs` applies to devices on the LAN. +const MAX_JSON_BYTES: usize = 4 * 1024 * 1024; + +/// What kind of thing a file looks like, which decides the providers it is put to. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MediaQueryKind { + Movie, + Episode, + Music, + Anime, +} + +/// What we know about a file before asking anyone. +#[derive(Clone, Debug, Default)] +pub struct MediaQuery { + pub title: String, + pub year: Option, + pub season: Option, + pub episode: Option, + pub artist: Option, + pub album: Option, + /// An exact identifier read out of the file's own tags. When present there is + /// nothing to guess and no search to run. + pub musicbrainz_release_id: Option, + pub musicbrainz_track_id: Option, +} + +impl MediaQuery { + /// What to put in a plain text search box. + pub fn search_terms(&self) -> String { + match (&self.artist, &self.album) { + (Some(artist), Some(album)) => format!("{artist} {album}"), + (Some(artist), None) => format!("{artist} {}", self.title), + _ => self.title.clone(), + } + } +} + +/// One possible answer from one provider, before scoring. +#[derive(Clone, Debug)] +pub struct Candidate { + pub provider: &'static str, + pub remote_id: String, + /// `movie` | `series` | `episode` | `album` | `track` | `anime` + pub kind: &'static str, + pub title: String, + pub original_title: Option, + pub overview: Option, + pub release_date: Option, + pub year: Option, + pub rating: Option, + pub genres: Vec, + pub season: Option, + pub episode: Option, + pub artwork_url: Option, + /// The provider's own record, kept whole so a field we did not give a column + /// to is a query away rather than another migration. + pub payload: serde_json::Value, +} + +impl Candidate { + pub fn new(provider: &'static str, kind: &'static str, remote_id: String, title: String) -> Self { + Self { + provider, + remote_id, + kind, + title, + original_title: None, + overview: None, + release_date: None, + year: None, + rating: None, + genres: Vec::new(), + season: None, + episode: None, + artwork_url: None, + payload: serde_json::Value::Null, + } + } +} + +#[async_trait::async_trait] +pub trait MetadataProvider: Send + Sync { + fn info(&self) -> &'static ProviderInfo; + + /// Ask this provider about `query`. Returning an empty vec means "nothing + /// matched", which is not an error; `Err` means the provider could not be + /// reached or refused us. + async fn search( + &self, + http: &Fetcher, + query: &MediaQuery, + credential: Option<&str>, + ) -> Result>; +} + +/// A rate-limited, capped HTTP client shared by every provider. +pub struct Fetcher { + client: reqwest::Client, + limiters: RateLimiters, +} + +impl Fetcher { + pub fn new(timeout: Duration) -> Result { + // `rustls-no-provider` means the client panics at build time unless a + // provider is already installed. `Runtime::start` installs one, but this + // type is also constructed by tests and by hosts embedding the crate + // without going through `Runtime`. Installation is process-global and + // returns an error when someone got there first, which is fine. + let _ = rustls::crypto::ring::default_provider().install_default(); + + let client = reqwest::Client::builder() + .user_agent(USER_AGENT) + .timeout(timeout) + // Cover Art Archive answers a release lookup with a redirect to + // archive.org, so following them is required, not optional. + .redirect(reqwest::redirect::Policy::limited(5)) + .build() + .context("Failed to build the metadata HTTP client")?; + Ok(Self { + client, + limiters: RateLimiters::new(), + }) + } + + /// GET a JSON document, waiting for the provider's rate limit first. + pub async fn get_json( + &self, + provider: &'static str, + url: &str, + headers: &[(&str, &str)], + ) -> Result { + self.limiters.acquire(provider).await; + let mut request = self.client.get(url); + for (name, value) in headers { + request = request.header(*name, *value); + } + let response = request + .send() + .await + .with_context(|| format!("{provider}: request to {url} failed"))?; + self.read_json(provider, response).await + } + + /// POST a JSON body and read a JSON response. Only AniList needs this — it is + /// a GraphQL endpoint rather than a REST one. + pub async fn post_json( + &self, + provider: &'static str, + url: &str, + body: &serde_json::Value, + ) -> Result { + self.limiters.acquire(provider).await; + let response = self + .client + .post(url) + .json(body) + .send() + .await + .with_context(|| format!("{provider}: request to {url} failed"))?; + self.read_json(provider, response).await + } + + async fn read_json( + &self, + provider: &'static str, + response: reqwest::Response, + ) -> Result { + let status = response.status(); + // A 404 is a legitimate "no such record" for several of these APIs, so it + // is reported as an empty document rather than an error the job would + // count as a failure. + if status == reqwest::StatusCode::NOT_FOUND { + return Ok(serde_json::Value::Null); + } + if !status.is_success() { + bail!("{provider}: responded {status}"); + } + let body = read_capped(response, MAX_JSON_BYTES) + .await + .with_context(|| format!("{provider}: reading the response body failed"))?; + if body.is_empty() { + return Ok(serde_json::Value::Null); + } + serde_json::from_slice(&body).with_context(|| format!("{provider}: response was not JSON")) + } + + /// Download an image, returning its content type and bytes. + pub async fn get_image( + &self, + provider: &'static str, + url: &str, + limit: usize, + ) -> Result<(String, Vec)> { + self.limiters.acquire(provider).await; + let response = self + .client + .get(url) + .send() + .await + .with_context(|| format!("{provider}: image request to {url} failed"))?; + let status = response.status(); + if !status.is_success() { + bail!("{provider}: image request responded {status}"); + } + let content_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .split(';') + .next() + .unwrap_or_default() + .trim() + .to_ascii_lowercase(); + let bytes = read_capped(response, limit) + .await + .with_context(|| format!("{provider}: reading the image body failed"))?; + Ok((content_type, bytes)) + } +} + +/// Read a body, stopping at `limit`. +/// +/// `Response::bytes()` would buffer whatever the peer sends, which for an +/// untrusted host is an unbounded allocation, so the body is drained a chunk at a +/// time and abandoned once it exceeds what the caller asked for. +async fn read_capped(mut response: reqwest::Response, limit: usize) -> Result> { + let mut body = Vec::new(); + while let Some(chunk) = response.chunk().await? { + if body.len() + chunk.len() > limit { + bail!("response exceeded {limit} bytes"); + } + body.extend_from_slice(&chunk); + } + Ok(body) +} + +/// Percent-encode a value for use in a query string. +/// +/// `percent_encoding` is already a dependency for the DLNA paths; QUERY_ENCODE +/// leaves the characters a query component allows and escapes the rest, including +/// the `&` and `=` that would otherwise let a filename forge extra parameters. +pub fn query_escape(value: &str) -> String { + const QUERY_ENCODE: &percent_encoding::AsciiSet = &percent_encoding::NON_ALPHANUMERIC + .remove(b'-') + .remove(b'_') + .remove(b'.') + .remove(b'~'); + percent_encoding::utf8_percent_encode(value, QUERY_ENCODE).to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn query_escape_neutralises_parameter_injection() { + // A filename is attacker-controlled as far as this code is concerned. + let escaped = query_escape("a&api_key=leak b"); + assert!(!escaped.contains('&')); + assert!(!escaped.contains('=')); + assert!(!escaped.contains(' ')); + } + + #[test] + fn search_terms_prefer_artist_and_album() { + let query = MediaQuery { + title: "Black Dog".to_string(), + artist: Some("Led Zeppelin".to_string()), + album: Some("Led Zeppelin IV".to_string()), + ..MediaQuery::default() + }; + assert_eq!(query.search_terms(), "Led Zeppelin Led Zeppelin IV"); + } + + #[test] + fn search_terms_fall_back_to_the_title() { + let query = MediaQuery { + title: "Arrival".to_string(), + ..MediaQuery::default() + }; + assert_eq!(query.search_terms(), "Arrival"); + } +} diff --git a/crates/vuio-core/src/mediainfo/credentials.rs b/crates/vuio-core/src/mediainfo/credentials.rs new file mode 100644 index 00000000..e71665da --- /dev/null +++ b/crates/vuio-core/src/mediainfo/credentials.rs @@ -0,0 +1,182 @@ +//! API keys and tokens for the providers that require an account. +//! +//! These live in the `secrets` table rather than `config.toml`, following the +//! AirPlay pairing store. That is a functional requirement and not a matter of +//! taste: under Docker the configuration is built from environment variables and +//! the admin API refuses to write the file at all, so a credential kept in the +//! file would be unsettable in exactly the deployment most likely to need one. It +//! also keeps tokens out of the file operators paste into bug reports. + +use crate::database::SecretStore; +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::{collections::HashMap, sync::Arc}; +use tokio::sync::Mutex; + +/// Key under which the whole credential document lives in the `secrets` table. +const SECRET_KEY: &str = "mediainfo.credentials"; + +#[derive(Clone)] +pub struct CredentialStore { + inner: Arc, +} + +struct Inner { + /// `None` keeps the store in memory, which is what the tests use. + secrets: Option>, + document: Mutex, +} + +#[derive(Default, Serialize, Deserialize)] +struct Document { + #[serde(default = "document_version")] + version: u32, + #[serde(default)] + tokens: HashMap, +} + +const fn document_version() -> u32 { + 1 +} + +impl CredentialStore { + pub fn memory() -> Self { + Self { + inner: Arc::new(Inner { + secrets: None, + document: Mutex::new(Document { + version: document_version(), + tokens: HashMap::new(), + }), + }), + } + } + + /// Load the document, treating an unreadable one as empty. + /// + /// A corrupt secret should cost the operator their saved keys, not the ability + /// to start the server or to save a replacement. + pub async fn load(secrets: Arc) -> Result { + let document = match secrets.get_secret(SECRET_KEY).await { + Ok(Some(bytes)) => serde_json::from_slice::(&bytes).unwrap_or_else(|error| { + tracing::warn!(%error, "Stored media info credentials were unreadable, starting empty"); + Document { + version: document_version(), + tokens: HashMap::new(), + } + }), + Ok(None) => Document { + version: document_version(), + tokens: HashMap::new(), + }, + Err(error) => { + tracing::warn!(%error, "Could not read media info credentials"); + Document { + version: document_version(), + tokens: HashMap::new(), + } + } + }; + + Ok(Self { + inner: Arc::new(Inner { + secrets: Some(secrets), + document: Mutex::new(document), + }), + }) + } + + pub async fn get(&self, provider: &str) -> Option { + self.inner.document.lock().await.tokens.get(provider).cloned() + } + + /// Which providers have a credential stored. + /// + /// The dashboard is told only this, never the values — a saved token must not + /// be readable back out of the API that set it. + pub async fn stored_providers(&self) -> Vec { + let mut stored: Vec = self + .inner + .document + .lock() + .await + .tokens + .keys() + .cloned() + .collect(); + stored.sort(); + stored + } + + /// Store a token, or remove it when `token` is empty. + pub async fn set(&self, provider: &str, token: &str) -> Result<()> { + let mut document = self.inner.document.lock().await; + let token = token.trim(); + if token.is_empty() { + document.tokens.remove(provider); + } else { + document.tokens.insert(provider.to_string(), token.to_string()); + } + self.persist(&document).await + } + + pub async fn clear(&self, provider: &str) -> Result<()> { + let mut document = self.inner.document.lock().await; + document.tokens.remove(provider); + self.persist(&document).await + } + + async fn persist(&self, document: &Document) -> Result<()> { + let Some(secrets) = self.inner.secrets.as_ref() else { + return Ok(()); + }; + let encoded = serde_json::to_vec(document).context("Failed to encode media info credentials")?; + secrets + .set_secret(SECRET_KEY, &encoded) + .await + .context("Failed to store media info credentials") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn a_stored_token_can_be_read_back_and_cleared() { + let store = CredentialStore::memory(); + assert!(store.get("tmdb").await.is_none()); + + store.set("tmdb", "abc123").await.unwrap(); + assert_eq!(store.get("tmdb").await.as_deref(), Some("abc123")); + assert_eq!(store.stored_providers().await, vec!["tmdb".to_string()]); + + store.clear("tmdb").await.unwrap(); + assert!(store.get("tmdb").await.is_none()); + assert!(store.stored_providers().await.is_empty()); + } + + #[tokio::test] + async fn setting_an_empty_token_removes_it() { + // The dashboard's Clear button sends an empty string rather than a + // separate verb, so this is the path that has to erase. + let store = CredentialStore::memory(); + store.set("omdb", "key").await.unwrap(); + store.set("omdb", " ").await.unwrap(); + assert!(store.get("omdb").await.is_none()); + } + + #[tokio::test] + async fn tokens_are_trimmed_before_storing() { + let store = CredentialStore::memory(); + store.set("lastfm", " key ").await.unwrap(); + assert_eq!(store.get("lastfm").await.as_deref(), Some("key")); + } + + #[test] + fn an_unreadable_document_decodes_as_empty() { + // Whatever else happens, a corrupt secret must not stop the server. + let document: Document = serde_json::from_slice(b"not json").unwrap_or_default(); + assert!(document.tokens.is_empty()); + } +} diff --git a/crates/vuio-core/src/mediainfo/job.rs b/crates/vuio-core/src/mediainfo/job.rs new file mode 100644 index 00000000..ff173f5c --- /dev/null +++ b/crates/vuio-core/src/mediainfo/job.rs @@ -0,0 +1,462 @@ +//! The library-wide fetch. +//! +//! One run walks every file that has no usable record yet, asks the providers +//! that suit it, scores what comes back and keeps the best answer. It is slow by +//! construction — MusicBrainz alone allows one request a second — so it reports +//! progress as it goes and can be cancelled, rather than being a request that +//! either returns or times out. + +use super::artwork::ArtworkCache; +use super::client::{Candidate, Fetcher, MediaQuery, MediaQueryKind}; +use super::credentials::CredentialStore; +use super::matching::{parse_media_name, score_candidate}; +use super::MEDIAINFO_VERSION; +use crate::database::{DatabaseManager, MediaFile, MediaInfoRecord}; +use crate::state::AppState; +use anyhow::{bail, Result}; +use std::sync::Arc; +use std::time::{Duration, SystemTime}; +use tokio_util::sync::CancellationToken; + +/// Records are written in batches: one transaction per file would make the write +/// lock the bottleneck in a job that is otherwise waiting on the network. +const WRITE_BATCH: usize = 25; + +/// What the dashboard polls while a run is in progress. +#[derive(Clone, Debug, Default)] +pub struct MediaInfoJobState { + pub running: bool, + pub total: usize, + pub processed: usize, + pub matched: usize, + pub low_confidence: usize, + pub failed: usize, + /// The file being looked at, for the progress line. + pub current: Option, + pub started_at: Option, + pub finished_at: Option, + pub last_error: Option, + pub cancelled: bool, + /// Held so a later request can stop the run. Not reported to the client. + pub cancel: Option, +} + +impl MediaInfoJobState { + fn begin(&mut self, total: usize, cancel: CancellationToken) { + *self = Self { + running: true, + total, + started_at: Some(SystemTime::now()), + cancel: Some(cancel), + ..Self::default() + }; + } + + fn finish(&mut self, cancelled: bool, error: Option) { + self.running = false; + self.cancelled = cancelled; + self.finished_at = Some(SystemTime::now()); + self.current = None; + self.cancel = None; + if error.is_some() { + self.last_error = error; + } + } +} + +/// Turn a media record into something worth searching for. +/// +/// Returns `None` for anything there is no point asking about — images, and +/// audio that is really an internet radio stream. +fn query_for(file: &MediaFile) -> Option<(MediaQueryKind, MediaQuery)> { + if file.mime_type.starts_with("image/") || file.mime_type == "audio/radio" { + return None; + } + + if file.mime_type.starts_with("audio/") { + // Audio already went through a tag reader, so the filename is the worst + // source available and is only used when the tags gave nothing. + let title = file + .title + .clone() + .unwrap_or_else(|| file.filename.clone()); + let query = MediaQuery { + title, + year: file.year, + artist: file.artist.clone().or_else(|| file.album_artist.clone()), + album: file.album.clone(), + musicbrainz_release_id: file.tags.musicbrainz_album_id.clone(), + musicbrainz_track_id: file.tags.musicbrainz_track_id.clone(), + ..MediaQuery::default() + }; + if query.title.is_empty() && query.album.is_none() { + return None; + } + return Some((MediaQueryKind::Music, query)); + } + + if !file.mime_type.starts_with("video/") { + return None; + } + + let stem = std::path::Path::new(&file.filename) + .file_stem() + .map(|stem| stem.to_string_lossy().to_string()) + .unwrap_or_else(|| file.filename.clone()); + let parsed = parse_media_name(&stem); + if parsed.title.is_empty() { + return None; + } + let query = MediaQuery { + title: parsed.title, + year: parsed.year, + season: parsed.season, + episode: parsed.episode, + ..MediaQuery::default() + }; + Some((parsed.kind, query)) +} + +fn record_from( + media_file_id: i64, + candidate: &Candidate, + confidence: u8, + artwork_key: Option, +) -> MediaInfoRecord { + MediaInfoRecord { + media_file_id, + provider: candidate.provider.to_string(), + remote_id: candidate.remote_id.clone(), + kind: candidate.kind.to_string(), + title: Some(candidate.title.clone()), + original_title: candidate.original_title.clone(), + overview: candidate.overview.clone(), + release_date: candidate.release_date.clone(), + year: candidate.year, + rating: candidate.rating, + genres: candidate.genres.clone(), + season: candidate.season, + episode: candidate.episode, + artwork_key, + payload: serde_json::to_string(&candidate.payload).unwrap_or_else(|_| "null".to_string()), + confidence, + fetched_at: SystemTime::now(), + mediainfo_version: MEDIAINFO_VERSION, + } +} + +/// Run a fetch over the whole library. +/// +/// Returns as soon as the run is set up; the work happens on `background_tasks` +/// so the HTTP request that started it does not have to stay open for what may be +/// hours. +pub async fn run_library_fetch(state: AppState) -> Result { + let config = state.current_config(); + let settings = config.mediainfo.clone(); + if !settings.enabled { + bail!("Online media info is turned off"); + } + + { + let job = state.mediainfo_job.lock().await; + if job.running { + bail!("A media info fetch is already running"); + } + } + + let threshold = settings.min_confidence.min(100); + let pending = state + .database + .media_ids_missing_mediainfo(MEDIAINFO_VERSION, threshold) + .await?; + let total = pending.len(); + + // A child of the application token, so shutdown stops the run without the + // caller having to remember to cancel it. + let cancel = state.cancellation.child_token(); + { + let mut job = state.mediainfo_job.lock().await; + job.begin(total, cancel.clone()); + } + + let tracker = state.background_tasks.clone(); + tracker.spawn(async move { + let outcome = fetch_all(&state, pending, cancel.clone()).await; + let cancelled = cancel.is_cancelled(); + let error = outcome.err().map(|error| error.to_string()); + if let Some(error) = error.as_deref() { + tracing::warn!(error, "Media info fetch ended early"); + } + { + let mut job = state.mediainfo_job.lock().await; + job.finish(cancelled, error); + } + // Titles, descriptions and artwork all just changed. This bumps the + // ContentDirectory revision, drops the browse cache and notifies every + // UPnP subscriber, which is what makes a TV redraw with the new data. + crate::web::eventing::publish_content_change(&state).await; + }); + + Ok(total) +} + +async fn fetch_all( + state: &AppState, + pending: Vec, + cancel: CancellationToken, +) -> Result<()> { + let config = state.current_config(); + let settings = &config.mediainfo; + let threshold = settings.min_confidence.min(100); + + let credentials = + CredentialStore::load(state.database.clone() as Arc) + .await?; + let http = Fetcher::new(Duration::from_secs(settings.request_timeout_seconds.max(1)))?; + let providers = super::providers::build(&settings.providers); + if providers.is_empty() { + bail!("No media info providers are enabled"); + } + + let artwork = settings + .artwork_enabled + .then(|| settings.artwork_path.as_ref().map(ArtworkCache::new)) + .flatten(); + + let mut batch: Vec = Vec::with_capacity(WRITE_BATCH); + + for media_file_id in pending { + if cancel.is_cancelled() { + break; + } + + let Some(file) = state.database.get_file_by_id(media_file_id).await? else { + continue; + }; + { + let mut job = state.mediainfo_job.lock().await; + job.current = Some(file.filename.clone()); + } + + let outcome = match query_for(&file) { + Some((kind, query)) => { + fetch_one( + &http, + &providers, + &credentials, + artwork.as_ref(), + kind, + &query, + media_file_id, + ) + .await + } + // Nothing worth asking about is not a failure, it is a file this + // feature does not apply to. + None => Ok(None), + }; + + let mut job = state.mediainfo_job.lock().await; + job.processed += 1; + match outcome { + Ok(Some(record)) => { + if record.confidence >= threshold { + job.matched += 1; + } else { + job.low_confidence += 1; + } + batch.push(record); + } + Ok(None) => {} + Err(error) => { + job.failed += 1; + job.last_error = Some(error.to_string()); + tracing::debug!(file = %file.filename, %error, "Media info lookup failed"); + } + } + drop(job); + + if batch.len() >= WRITE_BATCH { + state.database.bulk_store_mediainfo(&batch).await?; + batch.clear(); + } + } + + if !batch.is_empty() { + state.database.bulk_store_mediainfo(&batch).await?; + } + Ok(()) +} + +/// Ask every suitable provider and keep the best-scoring answer. +/// +/// Providers are tried in the order configured, and the search stops as soon as +/// one returns a candidate good enough to be trusted — a second opinion costs a +/// second of rate limit and cannot improve on a match already above the bar. +#[allow(clippy::too_many_arguments)] +async fn fetch_one( + http: &Fetcher, + providers: &[Box], + credentials: &CredentialStore, + artwork: Option<&ArtworkCache>, + kind: MediaQueryKind, + query: &MediaQuery, + media_file_id: i64, +) -> Result> { + let mut best: Option<(u8, Candidate)> = None; + let mut last_error: Option = None; + + for provider in providers { + let info = provider.info(); + if !super::providers::serves(info.kind, kind) { + continue; + } + let credential = credentials.get(info.id).await; + // A provider that needs a key it has not been given is skipped silently: + // it is off, not broken. + if info.needs_credential() && credential.is_none() { + continue; + } + + match provider.search(http, query, credential.as_deref()).await { + Ok(candidates) => { + for candidate in candidates { + let score = score_candidate(query, &candidate); + if best.as_ref().is_none_or(|(current, _)| score > *current) { + best = Some((score, candidate)); + } + } + } + Err(error) => last_error = Some(error), + } + + if best.as_ref().is_some_and(|(score, _)| *score >= 90) { + break; + } + } + + let Some((confidence, candidate)) = best else { + // Only report a failure if a provider actually errored. Everyone + // answering "no such thing" is a miss, and counting it as an error would + // fill the dashboard with noise for a library of home videos. + return match last_error { + Some(error) => Err(error), + None => Ok(None), + }; + }; + + let artwork_key = match (artwork, candidate.artwork_url.as_deref()) { + (Some(cache), Some(url)) => match cache.store(http, candidate.provider, url).await { + Ok(key) => Some(key), + // A read-only cache directory, or a poster that 404s, must not cost us + // the metadata we already have. + Err(error) => { + tracing::debug!(%error, "Could not cache artwork"); + None + } + }, + _ => None, + }; + + Ok(Some(record_from( + media_file_id, + &candidate, + confidence, + artwork_key, + ))) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::UNIX_EPOCH; + + fn media_file(filename: &str, mime: &str) -> MediaFile { + let mut file = MediaFile::new( + std::path::PathBuf::from(format!("/media/{filename}")), + 1024, + mime.to_string(), + ); + file.filename = filename.to_string(); + file + } + + #[test] + fn a_video_query_comes_from_the_filename() { + let file = media_file("Show.Name.S02E05.1080p.WEB-DL.mkv", "video/x-matroska"); + let (kind, query) = query_for(&file).unwrap(); + assert_eq!(kind, MediaQueryKind::Episode); + assert_eq!(query.title, "Show Name"); + assert_eq!(query.season, Some(2)); + assert_eq!(query.episode, Some(5)); + } + + #[test] + fn an_audio_query_prefers_tags_over_the_filename() { + // The filename here is useless; the tags are not. Parsing the name anyway + // would throw away the better source. + let mut file = media_file("01 - track.mp3", "audio/mpeg"); + file.title = Some("Black Dog".to_string()); + file.artist = Some("Led Zeppelin".to_string()); + file.album = Some("Led Zeppelin IV".to_string()); + + let (kind, query) = query_for(&file).unwrap(); + assert_eq!(kind, MediaQueryKind::Music); + assert_eq!(query.title, "Black Dog"); + assert_eq!(query.artist.as_deref(), Some("Led Zeppelin")); + assert_eq!(query.album.as_deref(), Some("Led Zeppelin IV")); + } + + #[test] + fn a_musicbrainz_id_from_the_tags_is_carried_into_the_query() { + let mut file = media_file("track.flac", "audio/flac"); + file.title = Some("A Song".to_string()); + file.tags.musicbrainz_album_id = Some("release-123".to_string()); + let (_, query) = query_for(&file).unwrap(); + assert_eq!(query.musicbrainz_release_id.as_deref(), Some("release-123")); + } + + #[test] + fn images_and_radio_streams_are_not_looked_up() { + assert!(query_for(&media_file("photo.jpg", "image/jpeg")).is_none()); + assert!(query_for(&media_file("Some Station", "audio/radio")).is_none()); + } + + #[test] + fn a_record_carries_the_current_version_so_a_bump_invalidates_it() { + let candidate = Candidate::new("tvmaze", "series", "1".into(), "Show".into()); + let record = record_from(7, &candidate, 88, Some("abc".into())); + assert_eq!(record.media_file_id, 7); + assert_eq!(record.confidence, 88); + assert_eq!(record.mediainfo_version, MEDIAINFO_VERSION); + assert_eq!(record.artwork_key.as_deref(), Some("abc")); + assert!(record.fetched_at > UNIX_EPOCH); + } + + #[test] + fn beginning_a_run_clears_the_previous_ones_counters() { + let mut job = MediaInfoJobState { + processed: 99, + failed: 4, + last_error: Some("old".into()), + ..MediaInfoJobState::default() + }; + job.begin(10, CancellationToken::new()); + assert!(job.running); + assert_eq!(job.total, 10); + assert_eq!(job.processed, 0); + assert_eq!(job.failed, 0); + assert!(job.last_error.is_none()); + } + + #[test] + fn finishing_records_why_it_stopped() { + let mut job = MediaInfoJobState::default(); + job.begin(1, CancellationToken::new()); + job.finish(true, Some("boom".into())); + assert!(!job.running); + assert!(job.cancelled); + assert!(job.cancel.is_none()); + assert_eq!(job.last_error.as_deref(), Some("boom")); + } +} diff --git a/crates/vuio-core/src/mediainfo/matching.rs b/crates/vuio-core/src/mediainfo/matching.rs new file mode 100644 index 00000000..3f5af935 --- /dev/null +++ b/crates/vuio-core/src/mediainfo/matching.rs @@ -0,0 +1,482 @@ +//! Turning a filename into something worth searching for, and deciding whether +//! what came back is actually the same thing. +//! +//! Release names are not metadata, they are a naming convention with thirty years +//! of accreted habits: `Show.Name.S02E05.1080p.WEB-DL.x265-GRP.mkv` carries a +//! title, a season and an episode buried in noise that would sink any search that +//! passed it through verbatim. The parser's whole job is deciding where the title +//! stops. +//! +//! Scoring exists because a search always returns something. "Arrival" matches a +//! 2016 film and a 1996 one; asking for episode 5 and being handed the series is +//! not a match at all. A number that says how sure we are lets the caller store +//! the good ones and show the operator the rest, rather than silently relabelling +//! a library with plausible-looking wrong answers. + +use super::client::{Candidate, MediaQuery, MediaQueryKind}; + +/// What a filename turned out to describe. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ParsedName { + pub kind: MediaQueryKind, + pub title: String, + pub year: Option, + pub season: Option, + pub episode: Option, +} + +/// Tokens that are never part of a title: quality, source, codec, audio and +/// release-status markers. Anything from the first of these to the end of the +/// name is noise, which is what makes this list the parser's main lever. +const NOISE: &[&str] = &[ + "2160p", "1080p", "1080i", "720p", "480p", "576p", "4k", "uhd", "hdr", "hdr10", "sdr", + "dolbyvision", "dv", "10bit", "8bit", "x264", "x265", "h264", "h265", "avc", "hevc", "xvid", + "divx", "av1", "remux", "bluray", "bdrip", "brrip", "bdremux", "webrip", "webdl", "web", + "hdtv", "hdrip", "dvdrip", "dvd", "vhsrip", "cam", "ts", "tc", "proper", "repack", "internal", + "limited", "extended", "uncut", "unrated", "remastered", "complete", "aac", "aac2", "ac3", + "eac3", "dts", "dtshd", "truehd", "atmos", "flac", "mp3", "opus", "dd", "ddp", "5", "7", + "multi", "dual", "dubbed", "subbed", "subs", "hardsub", "raw", "bd", "ma", +]; + +/// Tokens that mark a file as anime even without an obvious fansub group. +const ANIME_MARKERS: &[&str] = &["anime", "subsplease", "erai", "horriblesubs", "ohys", "judas"]; + +fn is_noise(token: &str) -> bool { + let token = token.trim_matches(|c: char| !c.is_alphanumeric()); + if token.is_empty() { + return true; + } + let lowered = token.to_ascii_lowercase(); + if NOISE.contains(&lowered.as_str()) { + return true; + } + // `DD5.1`, `DDP5.1`, `H.264` and friends survive separator splitting as + // fragments that are not in the table but are plainly not title words. + matches!(lowered.as_str(), "1" | "2" | "0") + || lowered.starts_with("dd5") + || lowered.starts_with("ddp") + || lowered.ends_with("bit") + || lowered.ends_with("fps") +} + +/// A four-digit number that could plausibly be a release year. +fn as_year(token: &str) -> Option { + let digits = token.trim_matches(|c: char| !c.is_ascii_digit()); + if digits.len() != 4 { + return None; + } + let year: u32 = digits.parse().ok()?; + (1900..=2099).contains(&year).then_some(year) +} + +/// `S02E05`, `s2e5`, and the `2x05` form. +fn as_season_episode(token: &str) -> Option<(u32, u32)> { + let lowered = token.to_ascii_lowercase(); + let bytes = lowered.as_bytes(); + + if bytes.first() == Some(&b's') { + let rest = &lowered[1..]; + if let Some(split) = rest.find('e') { + let (season, episode) = rest.split_at(split); + let episode = &episode[1..]; + if !season.is_empty() + && !episode.is_empty() + && season.bytes().all(|b| b.is_ascii_digit()) + && episode.bytes().all(|b| b.is_ascii_digit()) + { + return Some((season.parse().ok()?, episode.parse().ok()?)); + } + } + return None; + } + + let split = lowered.find('x')?; + let (season, episode) = lowered.split_at(split); + let episode = &episode[1..]; + if season.is_empty() + || episode.is_empty() + || !season.bytes().all(|b| b.is_ascii_digit()) + || !episode.bytes().all(|b| b.is_ascii_digit()) + { + return None; + } + Some((season.parse().ok()?, episode.parse().ok()?)) +} + +/// Drop `[SubsPlease]`-style bracketed groups, returning what they contained so a +/// fansub tag can still be used as an anime signal. +fn strip_bracketed(name: &str) -> (String, Vec) { + let mut out = String::with_capacity(name.len()); + let mut captured = Vec::new(); + let mut current = String::new(); + let mut depth = 0usize; + + for character in name.chars() { + match character { + '[' | '(' | '{' => { + depth += 1; + current.clear(); + } + ']' | ')' | '}' => { + if depth > 0 { + depth -= 1; + captured.push(std::mem::take(&mut current)); + out.push(' '); + } else { + out.push(character); + } + } + _ if depth > 0 => current.push(character), + _ => out.push(character), + } + } + (out, captured) +} + +/// Split a release name into words. +/// +/// Dots are separators in scene naming but real punctuation in `S.W.A.T.`, so they +/// only become separators when the name has no spaces of its own to separate by. +fn tokenize(name: &str) -> Vec { + let separators_are_dots = !name.contains(' ') && name.contains('.'); + name.split(|c: char| { + c.is_whitespace() || c == '_' || (separators_are_dots && c == '.') || c == '+' + }) + .filter(|token| !token.is_empty()) + .map(str::to_string) + .collect() +} + +/// Read a release name. +/// +/// `stem` is the filename without its extension. +pub fn parse_media_name(stem: &str) -> ParsedName { + let (without_brackets, bracketed) = strip_bracketed(stem); + let mut tokens = tokenize(&without_brackets); + + let looks_like_anime = bracketed + .iter() + .chain(std::iter::once(&without_brackets)) + .any(|text| { + let lowered = text.to_ascii_lowercase(); + ANIME_MARKERS.iter().any(|marker| lowered.contains(marker)) + }); + + // An anime release names its episode as a bare number after a dash: + // `[Group] Title - 12 [1080p]`. This has to run before the noise scan, which + // would stop at the dash and never see the number. Only fansub-marked names + // get this reading, since a trailing number is otherwise usually part of the + // title. + let mut anime_episode = None; + if looks_like_anime { + if let Some(position) = tokens.iter().rposition(|token| token == "-") { + if let Some(number) = tokens.get(position + 1) { + if !number.is_empty() + && number.len() <= 4 + && number.bytes().all(|byte| byte.is_ascii_digit()) + { + anime_episode = number.parse().ok(); + tokens.truncate(position); + } + } + } + } + + let mut title_tokens: Vec = Vec::new(); + let mut year = None; + let mut season = None; + let mut episode = None; + + for token in &tokens { + // A season/episode marker ends the title outright — everything after it is + // either noise or a redundant episode name. + if season.is_none() { + if let Some((found_season, found_episode)) = as_season_episode(token) { + season = Some(found_season); + episode = Some(found_episode); + break; + } + } + // A year only ends the title if something already came before it, so + // `2012.1080p.mkv` keeps its title instead of parsing as a bare year. + if year.is_none() && !title_tokens.is_empty() { + if let Some(found) = as_year(token) { + year = Some(found); + break; + } + } + if is_noise(token) { + break; + } + title_tokens.push(token.clone()); + } + + // An explicit SxxEyy always wins over the bare-number reading. + if episode.is_none() { + episode = anime_episode; + } + + let title = title_tokens + .join(" ") + .trim_matches(|c: char| !c.is_alphanumeric()) + .to_string(); + + let kind = if looks_like_anime { + MediaQueryKind::Anime + } else if season.is_some() || episode.is_some() { + MediaQueryKind::Episode + } else { + MediaQueryKind::Movie + }; + + ParsedName { + kind, + title, + year, + season, + episode, + } +} + +/// Reduce a title to comparable words: lowercase, punctuation dropped, and the +/// leading article removed so "The Matrix" and "Matrix" agree. +fn normalize_tokens(title: &str) -> Vec { + let mut tokens: Vec = title + .split(|c: char| !c.is_alphanumeric()) + .filter(|token| !token.is_empty()) + .map(|token| token.to_ascii_lowercase()) + .collect(); + if tokens.len() > 1 && matches!(tokens[0].as_str(), "the" | "a" | "an") { + tokens.remove(0); + } + tokens +} + +/// Dice coefficient over the two token sets, as a percentage. +fn token_similarity(left: &str, right: &str) -> u32 { + let left: std::collections::BTreeSet = normalize_tokens(left).into_iter().collect(); + let right: std::collections::BTreeSet = normalize_tokens(right).into_iter().collect(); + if left.is_empty() || right.is_empty() { + return 0; + } + if left == right { + return 100; + } + let shared = left.intersection(&right).count(); + ((2 * shared * 100) / (left.len() + right.len())) as u32 +} + +/// How confident we are that `candidate` is what `query` was looking for, 0–100. +pub fn score_candidate(query: &MediaQuery, candidate: &Candidate) -> u8 { + let against_title = token_similarity(&query.title, &candidate.title); + let against_original = candidate + .original_title + .as_deref() + .map(|original| token_similarity(&query.title, original)) + .unwrap_or(0); + // For music the album is usually what the provider's record is named after. + let against_album = query + .album + .as_deref() + .map(|album| token_similarity(album, &candidate.title)) + .unwrap_or(0); + + let mut score = against_title.max(against_original).max(against_album) as i32; + + // Episode agreement is decisive: the right series and the wrong episode is + // not a near miss, it is the wrong record. + if let (Some(wanted_season), Some(wanted_episode)) = (query.season, query.episode) { + match (candidate.season, candidate.episode) { + (Some(season), Some(episode)) => { + if season == wanted_season && episode == wanted_episode { + score += 10; + } else { + return 0; + } + } + // A series-level record when an episode was asked for is usable but + // clearly less specific. + _ => score -= 15, + } + } + + match (query.year, candidate.year) { + (Some(wanted), Some(found)) => { + let drift = wanted.abs_diff(found); + if drift == 0 { + score += 10; + } else if drift == 1 { + // Release year and air year disagree by one all the time. + score -= 10; + } else { + // Enough to put an otherwise perfect title below any sane + // threshold: same name, different decade means a different work. + score -= 45; + } + } + (Some(_), None) => score -= 5, + _ => {} + } + + score.clamp(0, 100) as u8 +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parsed(stem: &str) -> ParsedName { + parse_media_name(stem) + } + + #[test] + fn reads_a_scene_episode_name() { + let name = parsed("Show.Name.S02E05.1080p.WEB-DL.x265-GRP"); + assert_eq!(name.title, "Show Name"); + assert_eq!(name.season, Some(2)); + assert_eq!(name.episode, Some(5)); + assert_eq!(name.kind, MediaQueryKind::Episode); + } + + #[test] + fn reads_a_scene_movie_name() { + let name = parsed("Some.Movie.Title.2019.1080p.BluRay.x264-GRP"); + assert_eq!(name.title, "Some Movie Title"); + assert_eq!(name.year, Some(2019)); + assert_eq!(name.season, None); + assert_eq!(name.kind, MediaQueryKind::Movie); + } + + #[test] + fn reads_the_lowercase_and_x_forms() { + assert_eq!(parsed("Show Name s2e5 720p").season, Some(2)); + let cross = parsed("Show Name 2x05 720p"); + assert_eq!(cross.season, Some(2)); + assert_eq!(cross.episode, Some(5)); + } + + #[test] + fn keeps_dots_that_are_part_of_the_title() { + // The name has spaces of its own, so dots are punctuation here. + let name = parsed("S.W.A.T. 2017 1080p"); + assert!( + name.title.starts_with("S.W.A.T"), + "title was {:?}", + name.title + ); + assert_eq!(name.year, Some(2017)); + } + + #[test] + fn a_leading_year_is_not_mistaken_for_a_release_year() { + let name = parsed("2012.2009.1080p.BluRay.x264"); + assert_eq!(name.title, "2012"); + assert_eq!(name.year, Some(2009)); + } + + #[test] + fn drops_bracketed_groups_and_reads_an_anime_episode() { + let name = parsed("[SubsPlease] Some Show - 12 [1080p][ABCD1234]"); + assert_eq!(name.title, "Some Show"); + assert_eq!(name.episode, Some(12)); + assert_eq!(name.kind, MediaQueryKind::Anime); + } + + #[test] + fn a_plain_name_survives_untouched() { + let name = parsed("Arrival"); + assert_eq!(name.title, "Arrival"); + assert_eq!(name.year, None); + assert_eq!(name.kind, MediaQueryKind::Movie); + } + + fn candidate(title: &str) -> Candidate { + Candidate::new("tvmaze", "movie", "1".to_string(), title.to_string()) + } + + #[test] + fn an_exact_title_scores_full_marks() { + let query = MediaQuery { + title: "Arrival".to_string(), + ..MediaQuery::default() + }; + assert_eq!(score_candidate(&query, &candidate("Arrival")), 100); + } + + #[test] + fn a_leading_article_does_not_cost_anything() { + let query = MediaQuery { + title: "The Matrix".to_string(), + ..MediaQuery::default() + }; + assert_eq!(score_candidate(&query, &candidate("Matrix")), 100); + } + + #[test] + fn the_wrong_episode_scores_zero() { + let query = MediaQuery { + title: "Show Name".to_string(), + season: Some(2), + episode: Some(5), + ..MediaQuery::default() + }; + let mut wrong = candidate("Show Name"); + wrong.season = Some(2); + wrong.episode = Some(6); + assert_eq!(score_candidate(&query, &wrong), 0); + + let mut right = candidate("Show Name"); + right.season = Some(2); + right.episode = Some(5); + assert_eq!(score_candidate(&query, &right), 100); + } + + #[test] + fn a_year_that_disagrees_sinks_an_otherwise_perfect_title() { + let query = MediaQuery { + title: "Arrival".to_string(), + year: Some(2016), + ..MediaQuery::default() + }; + let mut wrong = candidate("Arrival"); + wrong.year = Some(1996); + // Same title, twenty years apart: it is a different film, and the score has + // to fall below the default threshold of 60 or it would be stored as good. + assert!(score_candidate(&query, &wrong) < 60); + + let mut right = candidate("Arrival"); + right.year = Some(2016); + assert_eq!(score_candidate(&query, &right), 100); + } + + #[test] + fn a_year_off_by_one_is_only_a_small_penalty() { + let query = MediaQuery { + title: "Some Film".to_string(), + year: Some(2019), + ..MediaQuery::default() + }; + let mut near = candidate("Some Film"); + near.year = Some(2020); + assert!(score_candidate(&query, &near) >= 60); + } + + #[test] + fn an_unrelated_title_scores_low() { + let query = MediaQuery { + title: "Arrival".to_string(), + ..MediaQuery::default() + }; + assert!(score_candidate(&query, &candidate("Departures")) < 60); + } + + #[test] + fn an_album_name_can_carry_the_match_for_music() { + let query = MediaQuery { + title: "Black Dog".to_string(), + album: Some("Led Zeppelin IV".to_string()), + ..MediaQuery::default() + }; + assert_eq!(score_candidate(&query, &candidate("Led Zeppelin IV")), 100); + } +} diff --git a/crates/vuio-core/src/mediainfo/mod.rs b/crates/vuio-core/src/mediainfo/mod.rs new file mode 100644 index 00000000..ca1a9f0a --- /dev/null +++ b/crates/vuio-core/src/mediainfo/mod.rs @@ -0,0 +1,54 @@ +//! Fetching titles, synopses, ratings and artwork from public metadata APIs. +//! +//! Everything local metadata cannot supply lives here. Symphonia reads whatever an +//! audio file carries in its tags, but a video file carries nothing VuIO reads at +//! all — a movie is a filename until something looks it up — and even a well-tagged +//! album has no synopsis or cover unless the artwork happens to sit next to it on +//! disk. +//! +//! This is the only part of VuIO that talks to anything off the local network, and +//! the split in this module is drawn around that fact: [`provider`] is static data +//! and always compiles, while everything that opens a socket is behind the +//! `mediainfo` feature. A build without the feature still parses a `[mediainfo]` +//! config section and still knows what a provider id means; it just has no way to +//! act on either. + +pub mod provider; + +// `DEFAULT_PROVIDER_IDS` is needed by the config layer either way; the lookup is +// only reached by the endpoints, which the feature gates. +pub use provider::DEFAULT_PROVIDER_IDS; +#[cfg(feature = "mediainfo")] +pub use provider::provider_info; + +// Submodules stay public rather than being re-exported item by item: the module +// is already crate-internal, and the integration tests reach the parser and the +// scorer by path. Only the handful of names used elsewhere in the crate are +// lifted to the top. +#[cfg(feature = "mediainfo")] +pub mod artwork; +#[cfg(feature = "mediainfo")] +pub mod client; +#[cfg(feature = "mediainfo")] +pub mod credentials; +#[cfg(feature = "mediainfo")] +pub mod job; +#[cfg(feature = "mediainfo")] +pub mod matching; +#[cfg(feature = "mediainfo")] +mod providers; +#[cfg(feature = "mediainfo")] +mod rate_limit; + +#[cfg(feature = "mediainfo")] +pub use artwork::{content_type_for as artwork_content_type, ArtworkCache}; +#[cfg(feature = "mediainfo")] +pub use credentials::CredentialStore; +#[cfg(feature = "mediainfo")] +pub use job::{run_library_fetch, MediaInfoJobState}; + +/// Bumping this marks every stored row stale, so a later run re-fetches instead of +/// skipping what it already has. The same lever as `TAGS_VERSION` for local tags: +/// raise it when the matching or the fields we keep change enough that old rows are +/// worth discarding. +pub const MEDIAINFO_VERSION: u32 = 1; diff --git a/crates/vuio-core/src/mediainfo/provider.rs b/crates/vuio-core/src/mediainfo/provider.rs new file mode 100644 index 00000000..1b82c76e --- /dev/null +++ b/crates/vuio-core/src/mediainfo/provider.rs @@ -0,0 +1,220 @@ +//! The provider roster. +//! +//! This is static data — no network, no `reqwest` — so it compiles whether or not +//! the `mediainfo` feature is on. The config layer needs it to know what a valid +//! provider id is, and the admin schema needs it to describe the credential fields, +//! and neither of those should require the feature that does the fetching. + +/// What a provider knows about. A file is only offered to providers whose kind +/// matches what the filename parsed as, so a music lookup never burns a TV API's +/// rate limit. +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ProviderKind { + Tv, + Movie, + /// Movies and TV from one endpoint. + Screen, + Music, + Anime, +} + +impl ProviderKind { + /// The heading this provider appears under in the dashboard. + pub fn group(&self) -> &'static str { + match self { + Self::Tv | Self::Movie | Self::Screen => "Movies & TV", + Self::Music => "Music", + Self::Anime => "Anime & Manga", + } + } + + pub fn serves_screen(&self) -> bool { + matches!(self, Self::Tv | Self::Movie | Self::Screen) + } +} + +/// The credential a provider needs before it will answer. +#[derive(Clone, Copy, Debug, serde::Serialize)] +pub struct CredentialSpec { + /// What the provider calls it, so the field label matches their signup page. + pub label: &'static str, + /// Where to get one. + pub signup_url: &'static str, +} + +#[derive(Clone, Copy, Debug, serde::Serialize)] +pub struct ProviderInfo { + pub id: &'static str, + pub label: &'static str, + pub kind: ProviderKind, + /// What this provider contributes, in the dashboard's words. + pub provides: &'static str, + /// `None` means it answers without an account. + pub credential: Option, +} + +impl ProviderInfo { + pub fn needs_credential(&self) -> bool { + self.credential.is_some() + } +} + +const fn free( + id: &'static str, + label: &'static str, + kind: ProviderKind, + provides: &'static str, +) -> ProviderInfo { + ProviderInfo { + id, + label, + kind, + provides, + credential: None, + } +} + +const fn keyed( + id: &'static str, + label: &'static str, + kind: ProviderKind, + provides: &'static str, + credential_label: &'static str, + signup_url: &'static str, +) -> ProviderInfo { + ProviderInfo { + id, + label, + kind, + provides, + credential: Some(CredentialSpec { + label: credential_label, + signup_url, + }), + } +} + +/// Every provider VuIO can consult. +/// +/// Cover Art Archive is deliberately absent: it has no search of its own and is +/// only ever reached through a MusicBrainz release id, so it is part of the +/// MusicBrainz provider rather than something to switch on separately. +pub const PROVIDERS: &[ProviderInfo] = &[ + free( + "tvmaze", + "TVmaze", + ProviderKind::Tv, + "TV shows, episode guides, cast and artwork.", + ), + keyed( + "tmdb", + "TheMovieDB", + ProviderKind::Screen, + "Movies, TV, posters, trailers and ratings.", + "API key", + "https://developer.themoviedb.org", + ), + keyed( + "omdb", + "OMDb", + ProviderKind::Screen, + "IMDb ratings, posters and plot summaries.", + "API key", + "https://omdbapi.com", + ), + free( + "musicbrainz", + "MusicBrainz", + ProviderKind::Music, + "Artists, albums, tracklists and release dates, with Cover Art Archive artwork.", + ), + keyed( + "discogs", + "Discogs", + ProviderKind::Music, + "Vinyl, CD and master releases, and artist discographies.", + "Personal access token", + "https://www.discogs.com/settings/developers", + ), + keyed( + "lastfm", + "Last.fm", + ProviderKind::Music, + "Artist biographies, tags and album art.", + "API key", + "https://www.last.fm/api/account/create", + ), + keyed( + "genius", + "Genius", + ProviderKind::Music, + "Song, artist and album metadata.", + "Access token", + "https://genius.com/api-clients", + ), + free( + "jikan", + "Jikan", + ProviderKind::Anime, + "Anime and manga from MyAnimeList, with characters and ratings.", + ), + free( + "anilist", + "AniList", + ProviderKind::Anime, + "Anime and manga metadata and artwork.", + ), + free( + "kitsu", + "Kitsu", + ProviderKind::Anime, + "Anime, manga and drama info, categories and characters.", + ), +]; + +/// The providers enabled out of the box: every one that answers without an +/// account. Anything needing a credential stays off until the operator supplies +/// one, so a default install cannot produce a run that 401s on half its lookups. +pub const DEFAULT_PROVIDER_IDS: &[&str] = &["tvmaze", "musicbrainz", "jikan", "anilist", "kitsu"]; + +pub fn provider_info(id: &str) -> Option<&'static ProviderInfo> { + PROVIDERS.iter().find(|provider| provider.id == id) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_providers_all_exist_and_need_no_account() { + for id in DEFAULT_PROVIDER_IDS { + let provider = provider_info(id).unwrap_or_else(|| panic!("unknown provider {id}")); + assert!( + !provider.needs_credential(), + "{id} is on by default but needs a credential" + ); + } + } + + #[test] + fn every_free_provider_is_on_by_default() { + // Otherwise a provider that costs nothing to use would sit unused because + // someone forgot to add it to the list. + for provider in PROVIDERS.iter().filter(|p| !p.needs_credential()) { + assert!( + DEFAULT_PROVIDER_IDS.contains(&provider.id), + "{} needs no account but is not on by default", + provider.id + ); + } + } + + #[test] + fn no_provider_id_is_repeated() { + let mut seen = std::collections::HashSet::new(); + for provider in PROVIDERS { + assert!(seen.insert(provider.id), "duplicate provider {}", provider.id); + } + } +} diff --git a/crates/vuio-core/src/mediainfo/providers/anilist.rs b/crates/vuio-core/src/mediainfo/providers/anilist.rs new file mode 100644 index 00000000..1614193e --- /dev/null +++ b/crates/vuio-core/src/mediainfo/providers/anilist.rs @@ -0,0 +1,139 @@ +//! AniList — a GraphQL API, free and without an account for public queries. + +use super::super::client::{Candidate, Fetcher, MediaQuery, MetadataProvider}; +use super::super::provider::{provider_info, ProviderInfo}; +use super::{strip_html, text}; +use anyhow::Result; + +const ID: &str = "anilist"; +const ENDPOINT: &str = "https://graphql.anilist.co"; + +/// Asking for exactly the fields that map onto a candidate. AniList charges rate +/// against complexity, so a narrower query is also a cheaper one. +const QUERY: &str = r#" +query ($search: String) { + Page(perPage: 5) { + media(search: $search, type: ANIME) { + id + title { romaji english native } + description + startDate { year } + averageScore + genres + coverImage { extraLarge large } + } + } +}"#; + +pub struct AniList; + +#[async_trait::async_trait] +impl MetadataProvider for AniList { + fn info(&self) -> &'static ProviderInfo { + provider_info(ID).expect("anilist is in the registry") + } + + async fn search( + &self, + http: &Fetcher, + query: &MediaQuery, + _credential: Option<&str>, + ) -> Result> { + let body = serde_json::json!({ + "query": QUERY, + "variables": { "search": query.title }, + }); + let response = http.post_json(ID, ENDPOINT, &body).await?; + Ok(parse(&response, query.episode)) + } +} + +fn parse(body: &serde_json::Value, wanted_episode: Option) -> Vec { + let Some(results) = body + .get("data") + .and_then(|data| data.get("Page")) + .and_then(|page| page.get("media")) + .and_then(|media| media.as_array()) + else { + return Vec::new(); + }; + + results + .iter() + .filter_map(|entry| { + let id = entry.get("id")?.as_i64()?; + let titles = entry.get("title"); + let title = titles + .and_then(|title| text(title, "romaji")) + .or_else(|| titles.and_then(|title| text(title, "english"))) + .or_else(|| titles.and_then(|title| text(title, "native")))?; + let mut candidate = Candidate::new(ID, "anime", id.to_string(), title); + candidate.original_title = titles.and_then(|title| text(title, "english")); + // Descriptions come back as HTML with
runs in them. + candidate.overview = text(entry, "description").map(|text| strip_html(&text)); + candidate.year = entry + .get("startDate") + .and_then(|date| date.get("year")) + .and_then(|year| year.as_u64()) + .map(|year| year as u32); + // AniList scores out of 100; every other provider is out of 10. + candidate.rating = entry + .get("averageScore") + .and_then(|score| score.as_f64()) + .map(|score| score / 10.0); + candidate.genres = super::named_list(entry, "genres"); + candidate.episode = wanted_episode; + candidate.artwork_url = entry.get("coverImage").and_then(|cover| { + text(cover, "extraLarge").or_else(|| text(cover, "large")) + }); + candidate.payload = entry.clone(); + Some(candidate) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture() -> serde_json::Value { + serde_json::json!({ + "data": { "Page": { "media": [{ + "id": 1535, + "title": { "romaji": "Some Anime", "english": "Some Anime EN", "native": "アニメ" }, + "description": "A
synopsis.", + "startDate": { "year": 2006 }, + "averageScore": 84, + "genres": ["Psychological", "Thriller"], + "coverImage": { "large": "https://x.test/l.jpg", "extraLarge": "https://x.test/xl.jpg" } + }]}} + }) + } + + #[test] + fn reads_a_graphql_response() { + let candidates = parse(&fixture(), None); + assert_eq!(candidates.len(), 1); + let anime = &candidates[0]; + assert_eq!(anime.remote_id, "1535"); + assert_eq!(anime.title, "Some Anime"); + assert_eq!(anime.original_title.as_deref(), Some("Some Anime EN")); + assert_eq!(anime.year, Some(2006)); + assert_eq!(anime.overview.as_deref(), Some("A synopsis.")); + assert_eq!(anime.genres, vec!["Psychological", "Thriller"]); + assert_eq!(anime.artwork_url.as_deref(), Some("https://x.test/xl.jpg")); + } + + #[test] + fn the_score_is_rescaled_to_ten() { + // Storing 84 next to another provider's 8.4 would make the column + // meaningless. + assert_eq!(parse(&fixture(), None)[0].rating, Some(8.4)); + } + + #[test] + fn a_graphql_error_response_yields_nothing() { + let body = serde_json::json!({ "errors": [{ "message": "Too Many Requests" }] }); + assert!(parse(&body, None).is_empty()); + } +} diff --git a/crates/vuio-core/src/mediainfo/providers/discogs.rs b/crates/vuio-core/src/mediainfo/providers/discogs.rs new file mode 100644 index 00000000..94615240 --- /dev/null +++ b/crates/vuio-core/src/mediainfo/providers/discogs.rs @@ -0,0 +1,113 @@ +//! Discogs — releases, masters and discographies. Needs a free personal token. + +use super::super::client::{query_escape, Candidate, Fetcher, MediaQuery, MetadataProvider}; +use super::super::provider::{provider_info, ProviderInfo}; +use super::{text, year_of}; +use anyhow::{bail, Result}; + +const ID: &str = "discogs"; +const SEARCH: &str = "https://api.discogs.com/database/search"; + +pub struct Discogs; + +#[async_trait::async_trait] +impl MetadataProvider for Discogs { + fn info(&self) -> &'static ProviderInfo { + provider_info(ID).expect("discogs is in the registry") + } + + async fn search( + &self, + http: &Fetcher, + query: &MediaQuery, + credential: Option<&str>, + ) -> Result> { + let Some(token) = credential else { + bail!("Discogs needs a personal access token"); + }; + let url = format!( + "{SEARCH}?q={}&type=release&per_page=5", + query_escape(&query.search_terms()) + ); + // The token goes in the header rather than the query string so it stays out + // of any log that records the URL. + let authorization = format!("Discogs token={token}"); + let body = http + .get_json(ID, &url, &[("Authorization", authorization.as_str())]) + .await?; + Ok(parse(&body)) + } +} + +fn parse(body: &serde_json::Value) -> Vec { + let Some(results) = body.get("results").and_then(|value| value.as_array()) else { + return Vec::new(); + }; + results + .iter() + .filter_map(|entry| { + let id = entry.get("id")?.as_i64()?; + // Discogs titles are "Artist - Album"; the album half is what a tag + // would have called it, so scoring wants that rather than the pair. + let full = text(entry, "title")?; + let (artist, album) = match full.split_once(" - ") { + Some((artist, album)) => (Some(artist.trim().to_string()), album.trim().to_string()), + None => (None, full.clone()), + }; + let mut candidate = Candidate::new(ID, "album", id.to_string(), album); + candidate.original_title = artist; + candidate.release_date = text(entry, "released").or_else(|| text(entry, "year")); + candidate.year = text(entry, "year") + .as_deref() + .and_then(year_of) + .or_else(|| candidate.release_date.as_deref().and_then(year_of)); + candidate.genres = { + let mut genres = super::named_list(entry, "genre"); + genres.extend(super::named_list(entry, "style")); + genres + }; + candidate.artwork_url = + text(entry, "cover_image").or_else(|| text(entry, "thumb")); + candidate.payload = entry.clone(); + Some(candidate) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn splits_the_artist_off_the_title() { + let body = serde_json::json!({ + "results": [{ + "id": 1234, "title": "Led Zeppelin - Led Zeppelin IV", "year": "1971", + "genre": ["Rock"], "style": ["Hard Rock", "Blues Rock"], + "cover_image": "https://x.test/c.jpg", "thumb": "https://x.test/t.jpg" + }] + }); + + let candidates = parse(&body); + assert_eq!(candidates.len(), 1); + let release = &candidates[0]; + assert_eq!(release.title, "Led Zeppelin IV"); + assert_eq!(release.original_title.as_deref(), Some("Led Zeppelin")); + assert_eq!(release.year, Some(1971)); + assert_eq!(release.genres, vec!["Rock", "Hard Rock", "Blues Rock"]); + assert_eq!(release.artwork_url.as_deref(), Some("https://x.test/c.jpg")); + } + + #[test] + fn a_title_without_a_dash_is_kept_whole() { + let body = serde_json::json!({ "results": [{ "id": 1, "title": "Untitled" }] }); + let candidates = parse(&body); + assert_eq!(candidates[0].title, "Untitled"); + assert_eq!(candidates[0].original_title, None); + } + + #[test] + fn an_empty_result_set_yields_nothing() { + assert!(parse(&serde_json::json!({ "results": [] })).is_empty()); + } +} diff --git a/crates/vuio-core/src/mediainfo/providers/genius.rs b/crates/vuio-core/src/mediainfo/providers/genius.rs new file mode 100644 index 00000000..06fa99f9 --- /dev/null +++ b/crates/vuio-core/src/mediainfo/providers/genius.rs @@ -0,0 +1,120 @@ +//! Genius — song, artist and album metadata. Needs a free access token. + +use super::super::client::{query_escape, Candidate, Fetcher, MediaQuery, MetadataProvider}; +use super::super::provider::{provider_info, ProviderInfo}; +use super::{text, year_of}; +use anyhow::{bail, Result}; + +const ID: &str = "genius"; +const SEARCH: &str = "https://api.genius.com/search"; + +pub struct Genius; + +#[async_trait::async_trait] +impl MetadataProvider for Genius { + fn info(&self) -> &'static ProviderInfo { + provider_info(ID).expect("genius is in the registry") + } + + async fn search( + &self, + http: &Fetcher, + query: &MediaQuery, + credential: Option<&str>, + ) -> Result> { + let Some(token) = credential else { + bail!("Genius needs an access token"); + }; + let terms = match &query.artist { + Some(artist) => format!("{artist} {}", query.title), + None => query.title.clone(), + }; + let url = format!("{SEARCH}?q={}", query_escape(&terms)); + let authorization = format!("Bearer {token}"); + let body = http + .get_json(ID, &url, &[("Authorization", authorization.as_str())]) + .await?; + Ok(parse(&body)) + } +} + +fn parse(body: &serde_json::Value) -> Vec { + let Some(hits) = body + .get("response") + .and_then(|response| response.get("hits")) + .and_then(|hits| hits.as_array()) + else { + return Vec::new(); + }; + + hits.iter() + .filter_map(|hit| { + // Genius returns other hit types alongside songs. + if text(hit, "type").is_some_and(|kind| kind != "song") { + return None; + } + let result = hit.get("result")?; + let id = result.get("id")?.as_i64()?; + let title = text(result, "title")?; + let mut candidate = Candidate::new(ID, "track", id.to_string(), title); + candidate.original_title = result + .get("primary_artist") + .and_then(|artist| text(artist, "name")); + candidate.release_date = text(result, "release_date_for_display"); + candidate.year = result + .get("release_date_components") + .and_then(|components| components.get("year")) + .and_then(|year| year.as_u64()) + .map(|year| year as u32) + .or_else(|| candidate.release_date.as_deref().and_then(year_of)); + candidate.artwork_url = text(result, "song_art_image_url") + .or_else(|| text(result, "header_image_url")); + candidate.payload = result.clone(); + Some(candidate) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reads_a_song_search_response() { + let body = serde_json::json!({ + "response": { "hits": [{ + "type": "song", + "result": { + "id": 378195, + "title": "Black Dog", + "primary_artist": { "name": "Led Zeppelin" }, + "release_date_for_display": "November 8, 1971", + "release_date_components": { "year": 1971, "month": 11, "day": 8 }, + "song_art_image_url": "https://x.test/a.jpg" + } + }]} + }); + + let candidates = parse(&body); + assert_eq!(candidates.len(), 1); + let song = &candidates[0]; + assert_eq!(song.remote_id, "378195"); + assert_eq!(song.title, "Black Dog"); + assert_eq!(song.original_title.as_deref(), Some("Led Zeppelin")); + assert_eq!(song.year, Some(1971)); + assert_eq!(song.kind, "track"); + } + + #[test] + fn non_song_hits_are_dropped() { + let body = serde_json::json!({ + "response": { "hits": [{ "type": "artist", "result": { "id": 1, "title": "X" } }] } + }); + assert!(parse(&body).is_empty()); + } + + #[test] + fn a_missing_response_yields_nothing() { + assert!(parse(&serde_json::json!({})).is_empty()); + } +} diff --git a/crates/vuio-core/src/mediainfo/providers/jikan.rs b/crates/vuio-core/src/mediainfo/providers/jikan.rs new file mode 100644 index 00000000..39d6c626 --- /dev/null +++ b/crates/vuio-core/src/mediainfo/providers/jikan.rs @@ -0,0 +1,125 @@ +//! Jikan — the unofficial MyAnimeList API. No account required. + +use super::super::client::{query_escape, Candidate, Fetcher, MediaQuery, MetadataProvider}; +use super::super::provider::{provider_info, ProviderInfo}; +use super::{named_list, number, text, year_of}; +use anyhow::Result; + +const ID: &str = "jikan"; +const SEARCH: &str = "https://api.jikan.moe/v4/anime"; + +pub struct Jikan; + +#[async_trait::async_trait] +impl MetadataProvider for Jikan { + fn info(&self) -> &'static ProviderInfo { + provider_info(ID).expect("jikan is in the registry") + } + + async fn search( + &self, + http: &Fetcher, + query: &MediaQuery, + _credential: Option<&str>, + ) -> Result> { + let url = format!("{SEARCH}?q={}&limit=5", query_escape(&query.title)); + let body = http.get_json(ID, &url, &[]).await?; + Ok(parse(&body, query.episode)) + } +} + +/// `wanted_episode` is carried onto the candidate rather than looked up: Jikan +/// indexes anime at series level, and an episode number from the filename is the +/// only episode information available. Recording it keeps the scorer from docking +/// the match for a missing episode it was never going to find. +fn parse(body: &serde_json::Value, wanted_episode: Option) -> Vec { + let Some(results) = body.get("data").and_then(|data| data.as_array()) else { + return Vec::new(); + }; + results + .iter() + .filter_map(|entry| { + let id = entry.get("mal_id")?.as_i64()?; + let title = text(entry, "title") + .or_else(|| text(entry, "title_english")) + .or_else(|| text(entry, "title_japanese"))?; + let mut candidate = Candidate::new(ID, "anime", id.to_string(), title); + candidate.original_title = text(entry, "title_english") + .or_else(|| text(entry, "title_japanese")); + candidate.overview = text(entry, "synopsis"); + candidate.rating = number(entry, "score"); + candidate.genres = named_list(entry, "genres"); + candidate.year = entry + .get("year") + .and_then(|year| year.as_u64()) + .map(|year| year as u32) + .or_else(|| { + entry + .get("aired") + .and_then(|aired| text(aired, "from")) + .as_deref() + .and_then(year_of) + }); + candidate.release_date = entry.get("aired").and_then(|aired| text(aired, "from")); + candidate.episode = wanted_episode; + candidate.artwork_url = entry + .get("images") + .and_then(|images| images.get("jpg")) + .and_then(|jpg| { + text(jpg, "large_image_url").or_else(|| text(jpg, "image_url")) + }); + candidate.payload = entry.clone(); + Some(candidate) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture() -> serde_json::Value { + serde_json::json!({ + "data": [{ + "mal_id": 5114, + "title": "Some Anime", + "title_english": "Some Anime: English", + "synopsis": "A synopsis.", + "score": 9.1, + "year": 2009, + "aired": { "from": "2009-04-05T00:00:00+00:00" }, + "genres": [{ "name": "Action" }, { "name": "Drama" }], + "images": { "jpg": { "image_url": "https://x.test/s.jpg", "large_image_url": "https://x.test/l.jpg" } } + }] + }) + } + + #[test] + fn reads_an_anime_search_response() { + let candidates = parse(&fixture(), None); + assert_eq!(candidates.len(), 1); + let anime = &candidates[0]; + assert_eq!(anime.remote_id, "5114"); + assert_eq!(anime.title, "Some Anime"); + assert_eq!(anime.original_title.as_deref(), Some("Some Anime: English")); + assert_eq!(anime.year, Some(2009)); + assert_eq!(anime.rating, Some(9.1)); + assert_eq!(anime.genres, vec!["Action", "Drama"]); + assert_eq!(anime.artwork_url.as_deref(), Some("https://x.test/l.jpg")); + assert_eq!(anime.kind, "anime"); + } + + #[test] + fn the_episode_from_the_filename_is_carried_through() { + // Jikan has no episode-level record, so without this the scorer would + // penalise every anime episode for a season/episode it cannot supply. + let candidates = parse(&fixture(), Some(12)); + assert_eq!(candidates[0].episode, Some(12)); + } + + #[test] + fn a_missing_data_array_yields_nothing() { + assert!(parse(&serde_json::json!({}), None).is_empty()); + assert!(parse(&serde_json::Value::Null, None).is_empty()); + } +} diff --git a/crates/vuio-core/src/mediainfo/providers/kitsu.rs b/crates/vuio-core/src/mediainfo/providers/kitsu.rs new file mode 100644 index 00000000..867055d4 --- /dev/null +++ b/crates/vuio-core/src/mediainfo/providers/kitsu.rs @@ -0,0 +1,111 @@ +//! Kitsu — anime, manga and drama. JSON:API, no account required. + +use super::super::client::{query_escape, Candidate, Fetcher, MediaQuery, MetadataProvider}; +use super::super::provider::{provider_info, ProviderInfo}; +use super::{number, text, year_of}; +use anyhow::Result; + +const ID: &str = "kitsu"; +const SEARCH: &str = "https://kitsu.io/api/edge/anime"; + +pub struct Kitsu; + +#[async_trait::async_trait] +impl MetadataProvider for Kitsu { + fn info(&self) -> &'static ProviderInfo { + provider_info(ID).expect("kitsu is in the registry") + } + + async fn search( + &self, + http: &Fetcher, + query: &MediaQuery, + _credential: Option<&str>, + ) -> Result> { + let url = format!( + "{SEARCH}?filter%5Btext%5D={}&page%5Blimit%5D=5", + query_escape(&query.title) + ); + // JSON:API servers are entitled to refuse a request that does not ask for + // their media type. + let body = http + .get_json(ID, &url, &[("Accept", "application/vnd.api+json")]) + .await?; + Ok(parse(&body, query.episode)) + } +} + +fn parse(body: &serde_json::Value, wanted_episode: Option) -> Vec { + let Some(results) = body.get("data").and_then(|data| data.as_array()) else { + return Vec::new(); + }; + results + .iter() + .filter_map(|entry| { + let id = text(entry, "id")?; + let attributes = entry.get("attributes")?; + let title = text(attributes, "canonicalTitle").or_else(|| { + attributes + .get("titles") + .and_then(|titles| text(titles, "en").or_else(|| text(titles, "en_jp"))) + })?; + let mut candidate = Candidate::new(ID, "anime", id, title); + candidate.original_title = attributes + .get("titles") + .and_then(|titles| text(titles, "ja_jp")); + candidate.overview = text(attributes, "synopsis"); + candidate.release_date = text(attributes, "startDate"); + candidate.year = candidate.release_date.as_deref().and_then(year_of); + // Kitsu rates out of 100 like AniList, not out of 10. + candidate.rating = number(attributes, "averageRating").map(|rating| rating / 10.0); + candidate.episode = wanted_episode; + candidate.artwork_url = attributes.get("posterImage").and_then(|poster| { + text(poster, "original") + .or_else(|| text(poster, "large")) + .or_else(|| text(poster, "medium")) + }); + candidate.payload = entry.clone(); + Some(candidate) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reads_a_json_api_response() { + let body = serde_json::json!({ + "data": [{ + "id": "7442", + "type": "anime", + "attributes": { + "canonicalTitle": "Some Anime", + "titles": { "en": "Some Anime", "ja_jp": "アニメ" }, + "synopsis": "A synopsis.", + "startDate": "2013-04-07", + "averageRating": "82.53", + "posterImage": { "medium": "https://x.test/m.jpg", "original": "https://x.test/o.jpg" } + } + }] + }); + + let candidates = parse(&body, Some(3)); + assert_eq!(candidates.len(), 1); + let anime = &candidates[0]; + // Kitsu ids are strings, not numbers, unlike every other provider here. + assert_eq!(anime.remote_id, "7442"); + assert_eq!(anime.title, "Some Anime"); + assert_eq!(anime.year, Some(2013)); + assert_eq!(anime.rating, Some(8.253)); + assert_eq!(anime.episode, Some(3)); + assert_eq!(anime.artwork_url.as_deref(), Some("https://x.test/o.jpg")); + } + + #[test] + fn an_entry_without_a_usable_title_is_skipped() { + let body = serde_json::json!({ "data": [{ "id": "1", "attributes": {} }] }); + assert!(parse(&body, None).is_empty()); + } +} diff --git a/crates/vuio-core/src/mediainfo/providers/lastfm.rs b/crates/vuio-core/src/mediainfo/providers/lastfm.rs new file mode 100644 index 00000000..4b7ba4c2 --- /dev/null +++ b/crates/vuio-core/src/mediainfo/providers/lastfm.rs @@ -0,0 +1,144 @@ +//! Last.fm — album art, artist biographies and tags. Needs a free API key. + +use super::super::client::{query_escape, Candidate, Fetcher, MediaQuery, MetadataProvider}; +use super::super::provider::{provider_info, ProviderInfo}; +use super::text; +use anyhow::{bail, Result}; + +const ID: &str = "lastfm"; +const BASE: &str = "https://ws.audioscrobbler.com/2.0/"; + +pub struct LastFm; + +#[async_trait::async_trait] +impl MetadataProvider for LastFm { + fn info(&self) -> &'static ProviderInfo { + provider_info(ID).expect("lastfm is in the registry") + } + + async fn search( + &self, + http: &Fetcher, + query: &MediaQuery, + credential: Option<&str>, + ) -> Result> { + let Some(key) = credential else { + bail!("Last.fm needs an API key"); + }; + let album = query.album.as_deref().unwrap_or(&query.title); + let url = format!( + "{BASE}?method=album.search&album={}&api_key={}&format=json&limit=5", + query_escape(album), + query_escape(key) + ); + let body = http.get_json(ID, &url, &[]).await?; + Ok(parse(&body)) + } +} + +/// The largest image in Last.fm's `[{"#text":…,"size":…}]` array. +/// +/// The list is ordered smallest-first, but relying on that would silently degrade +/// to a 34px thumbnail if it ever changed, so the size names are ranked. +fn best_image(entry: &serde_json::Value) -> Option { + let images = entry.get("image")?.as_array()?; + let rank = |size: Option<&str>| match size { + Some("mega") => 5, + Some("extralarge") => 4, + Some("large") => 3, + Some("medium") => 2, + Some("small") => 1, + _ => 0, + }; + images + .iter() + .filter_map(|image| { + let url = text(image, "#text")?; + Some((rank(image.get("size").and_then(|size| size.as_str())), url)) + }) + .max_by_key(|(rank, _)| *rank) + .map(|(_, url)| url) +} + +fn parse(body: &serde_json::Value) -> Vec { + let Some(matches) = body + .get("results") + .and_then(|results| results.get("albummatches")) + .and_then(|matches| matches.get("album")) + .and_then(|album| album.as_array()) + else { + return Vec::new(); + }; + + matches + .iter() + .filter_map(|entry| { + let name = text(entry, "name")?; + // Last.fm has no stable numeric id for an album; the MBID is there when + // it knows one, otherwise artist/name is the only handle it offers. + let artist = text(entry, "artist"); + let id = text(entry, "mbid").unwrap_or_else(|| match &artist { + Some(artist) => format!("{artist} - {name}"), + None => name.clone(), + }); + let mut candidate = Candidate::new(ID, "album", id, name); + candidate.original_title = artist; + candidate.artwork_url = best_image(entry); + candidate.payload = entry.clone(); + Some(candidate) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture() -> serde_json::Value { + serde_json::json!({ + "results": { "albummatches": { "album": [{ + "name": "Led Zeppelin IV", + "artist": "Led Zeppelin", + "mbid": "abc-123", + "image": [ + { "#text": "https://x.test/s.png", "size": "small" }, + { "#text": "https://x.test/xl.png", "size": "extralarge" }, + { "#text": "https://x.test/m.png", "size": "medium" } + ] + }]}} + }) + } + + #[test] + fn reads_an_album_search_response() { + let candidates = parse(&fixture()); + assert_eq!(candidates.len(), 1); + assert_eq!(candidates[0].title, "Led Zeppelin IV"); + assert_eq!(candidates[0].original_title.as_deref(), Some("Led Zeppelin")); + assert_eq!(candidates[0].remote_id, "abc-123"); + } + + #[test] + fn the_largest_image_wins_regardless_of_array_order() { + assert_eq!( + parse(&fixture())[0].artwork_url.as_deref(), + Some("https://x.test/xl.png") + ); + } + + #[test] + fn an_album_without_an_mbid_still_gets_an_id() { + let body = serde_json::json!({ + "results": { "albummatches": { "album": [ + { "name": "An Album", "artist": "An Artist" } + ]}} + }); + assert_eq!(parse(&body)[0].remote_id, "An Artist - An Album"); + } + + #[test] + fn an_error_response_yields_nothing() { + let body = serde_json::json!({ "error": 6, "message": "Invalid parameters" }); + assert!(parse(&body).is_empty()); + } +} diff --git a/crates/vuio-core/src/mediainfo/providers/mod.rs b/crates/vuio-core/src/mediainfo/providers/mod.rs new file mode 100644 index 00000000..3d104435 --- /dev/null +++ b/crates/vuio-core/src/mediainfo/providers/mod.rs @@ -0,0 +1,178 @@ +//! One module per service. +//! +//! Every provider is split the same way: a `search` that does the I/O, and a pure +//! `parse` that turns the decoded JSON into [`Candidate`]s. The split is what makes +//! the response shapes testable — the parsers run against recorded fixtures, so the +//! test suite never touches the network and a provider changing its schema shows up +//! as a failing assertion rather than an empty library. + +use super::client::MetadataProvider; +use super::provider::ProviderKind; + +mod anilist; +mod discogs; +mod genius; +mod jikan; +mod kitsu; +mod lastfm; +mod musicbrainz; +mod omdb; +mod tmdb; +mod tvmaze; + +/// Build the providers named by `ids`, skipping any that are not recognised. +/// +/// Unknown ids are ignored rather than rejected: a config written by a newer +/// version, or one carrying a typo, should cost that one provider and not the +/// whole run. +pub fn build(ids: &[String]) -> Vec> { + let mut built: Vec> = Vec::new(); + for id in ids { + let provider: Box = match id.as_str() { + "tvmaze" => Box::new(tvmaze::TvMaze), + "tmdb" => Box::new(tmdb::Tmdb), + "omdb" => Box::new(omdb::Omdb), + "musicbrainz" => Box::new(musicbrainz::MusicBrainz), + "discogs" => Box::new(discogs::Discogs), + "lastfm" => Box::new(lastfm::LastFm), + "genius" => Box::new(genius::Genius), + "jikan" => Box::new(jikan::Jikan), + "anilist" => Box::new(anilist::AniList), + "kitsu" => Box::new(kitsu::Kitsu), + unknown => { + tracing::warn!(provider = unknown, "Ignoring unknown media info provider"); + continue; + } + }; + built.push(provider); + } + built +} + +/// Whether a provider of this kind should be asked about this sort of file. +pub fn serves(kind: ProviderKind, query: super::client::MediaQueryKind) -> bool { + use super::client::MediaQueryKind as Q; + match query { + Q::Movie | Q::Episode => kind.serves_screen(), + Q::Music => kind == ProviderKind::Music, + // Anime files go to the anime providers, and fall back to the general + // screen providers when none are enabled. + Q::Anime => kind == ProviderKind::Anime || kind.serves_screen(), + } +} + +// ── Shared JSON helpers ──────────────────────────────────────────────────── + +pub(super) fn text(value: &serde_json::Value, key: &str) -> Option { + value + .get(key) + .and_then(|found| found.as_str()) + .map(str::trim) + .filter(|found| !found.is_empty() && *found != "N/A") + .map(str::to_string) +} + +pub(super) fn number(value: &serde_json::Value, key: &str) -> Option { + value.get(key).and_then(|found| match found { + serde_json::Value::Number(number) => number.as_f64(), + serde_json::Value::String(text) => text.parse().ok(), + _ => None, + }) +} + +/// The year from an ISO-ish date, or from a bare year. +pub(super) fn year_of(date: &str) -> Option { + let digits: String = date.chars().take_while(|c| c.is_ascii_digit()).collect(); + if digits.len() != 4 { + return None; + } + let year = digits.parse().ok()?; + (1800..=2200).contains(&year).then_some(year) +} + +/// Collect `[{ "name": ... }]` style genre lists. +pub(super) fn named_list(value: &serde_json::Value, key: &str) -> Vec { + value + .get(key) + .and_then(|found| found.as_array()) + .map(|items| { + items + .iter() + .filter_map(|item| match item { + serde_json::Value::String(name) => Some(name.clone()), + other => text(other, "name"), + }) + .collect() + }) + .unwrap_or_default() +} + +/// Strip HTML tags and decode the handful of entities these APIs emit. +/// +/// TVmaze summaries and AniList descriptions are HTML fragments. Passing those +/// through would put `

` into a DIDL `` that a TV then renders +/// literally, so the markup comes out here rather than at every consumer. +pub(super) fn strip_html(input: &str) -> String { + let mut out = String::with_capacity(input.len()); + let mut in_tag = false; + for character in input.chars() { + match character { + '<' => in_tag = true, + '>' => in_tag = false, + _ if !in_tag => out.push(character), + _ => {} + } + } + let out = out + .replace("&", "&") + .replace(""", "\"") + .replace("'", "'") + .replace("'", "'") + .replace("<", "<") + .replace(">", ">") + .replace(" ", " "); + out.split_whitespace().collect::>().join(" ") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn strip_html_removes_markup_and_entities() { + assert_eq!( + strip_html("

A bold claim & a quiet one.

"), + "A bold claim & a quiet one." + ); + } + + #[test] + fn strip_html_collapses_the_whitespace_left_behind() { + assert_eq!(strip_html("

one

\n\n

two

"), "one two"); + } + + #[test] + fn year_of_reads_dates_and_bare_years() { + assert_eq!(year_of("2016-11-11"), Some(2016)); + assert_eq!(year_of("1971"), Some(1971)); + assert_eq!(year_of(""), None); + assert_eq!(year_of("not a date"), None); + } + + #[test] + fn text_treats_omdbs_n_a_as_absent() { + // OMDb writes "N/A" rather than omitting a field, and storing that string + // as a synopsis would put it on screen. + let value = serde_json::json!({ "Plot": "N/A", "Title": "Arrival" }); + assert_eq!(text(&value, "Plot"), None); + assert_eq!(text(&value, "Title").as_deref(), Some("Arrival")); + } + + #[test] + fn number_accepts_the_string_forms_these_apis_use() { + let value = serde_json::json!({ "a": 7.5, "b": "8.1", "c": "N/A" }); + assert_eq!(number(&value, "a"), Some(7.5)); + assert_eq!(number(&value, "b"), Some(8.1)); + assert_eq!(number(&value, "c"), None); + } +} diff --git a/crates/vuio-core/src/mediainfo/providers/musicbrainz.rs b/crates/vuio-core/src/mediainfo/providers/musicbrainz.rs new file mode 100644 index 00000000..c8a69871 --- /dev/null +++ b/crates/vuio-core/src/mediainfo/providers/musicbrainz.rs @@ -0,0 +1,162 @@ +//! MusicBrainz, with artwork from the Cover Art Archive. No account required. +//! +//! Two rules here are not negotiable. The service demands a User-Agent that +//! identifies the client and blocks those that do not supply one — [`USER_AGENT`] +//! is set on every request in `client.rs`. And it enforces one request per second +//! per IP at the server, which `rate_limit.rs` honours by serialising this +//! provider; this is the slowest provider VuIO has, by design rather than by +//! accident. +//! +//! Cover Art Archive has no search of its own: artwork is addressed by release id, +//! so it is reachable only once MusicBrainz has answered. That is why it is folded +//! in here instead of being a provider the operator can switch on alone. + +use super::super::client::{query_escape, Candidate, Fetcher, MediaQuery, MetadataProvider}; +use super::super::provider::{provider_info, ProviderInfo}; +use super::{text, year_of}; +use anyhow::Result; + +const ID: &str = "musicbrainz"; +const BASE: &str = "https://musicbrainz.org/ws/2"; + +pub struct MusicBrainz; + +/// The front cover for a release. This URL redirects to archive.org, which is why +/// the shared client follows redirects. +fn cover_art_url(release_id: &str) -> String { + format!("https://coverartarchive.org/release/{release_id}/front") +} + +#[async_trait::async_trait] +impl MetadataProvider for MusicBrainz { + fn info(&self) -> &'static ProviderInfo { + provider_info(ID).expect("musicbrainz is in the registry") + } + + async fn search( + &self, + http: &Fetcher, + query: &MediaQuery, + _credential: Option<&str>, + ) -> Result> { + // A release id read out of the file's own tags is an exact answer. Looking + // it up directly skips the search, the guessing and the scoring. + if let Some(release_id) = query.musicbrainz_release_id.as_deref() { + let url = format!( + "{BASE}/release/{}?fmt=json&inc=artist-credits+release-groups+genres", + query_escape(release_id) + ); + let body = http.get_json(ID, &url, &[]).await?; + if let Some(candidate) = parse_release(&body) { + return Ok(vec![candidate]); + } + return Ok(Vec::new()); + } + + let lucene = match (&query.artist, &query.album) { + (Some(artist), Some(album)) => format!("release:\"{album}\" AND artist:\"{artist}\""), + (Some(artist), None) => format!("artist:\"{artist}\" AND release:\"{}\"", query.title), + (None, Some(album)) => format!("release:\"{album}\""), + (None, None) => format!("release:\"{}\"", query.title), + }; + let url = format!( + "{BASE}/release?query={}&fmt=json&limit=5", + query_escape(&lucene) + ); + let body = http.get_json(ID, &url, &[]).await?; + Ok(parse_search(&body)) + } +} + +/// The artist name from a `artist-credit` array. +fn artist_credit(value: &serde_json::Value) -> Option { + let credits = value.get("artist-credit")?.as_array()?; + let joined: String = credits + .iter() + .filter_map(|credit| { + text(credit, "name").or_else(|| credit.get("artist").and_then(|a| text(a, "name"))) + }) + .collect::>() + .join(" & "); + (!joined.is_empty()).then_some(joined) +} + +fn release_to_candidate(entry: &serde_json::Value) -> Option { + let id = text(entry, "id")?; + let title = text(entry, "title")?; + let mut candidate = Candidate::new(ID, "album", id.clone(), title); + candidate.original_title = artist_credit(entry); + candidate.release_date = text(entry, "date"); + candidate.year = candidate.release_date.as_deref().and_then(year_of); + candidate.genres = super::named_list(entry, "genres"); + candidate.artwork_url = Some(cover_art_url(&id)); + candidate.payload = entry.clone(); + Some(candidate) +} + +fn parse_search(body: &serde_json::Value) -> Vec { + let Some(releases) = body.get("releases").and_then(|value| value.as_array()) else { + return Vec::new(); + }; + releases.iter().filter_map(release_to_candidate).collect() +} + +fn parse_release(body: &serde_json::Value) -> Option { + release_to_candidate(body) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reads_a_release_search_response() { + let body = serde_json::json!({ + "releases": [{ + "id": "f1e2d3c4-0000-0000-0000-000000000001", + "title": "Led Zeppelin IV", + "date": "1971-11-08", + "artist-credit": [{ "name": "Led Zeppelin" }], + "genres": [{ "name": "hard rock" }] + }] + }); + + let candidates = parse_search(&body); + assert_eq!(candidates.len(), 1); + let release = &candidates[0]; + assert_eq!(release.title, "Led Zeppelin IV"); + assert_eq!(release.original_title.as_deref(), Some("Led Zeppelin")); + assert_eq!(release.year, Some(1971)); + assert_eq!(release.genres, vec!["hard rock"]); + assert_eq!(release.kind, "album"); + } + + #[test] + fn artwork_points_at_the_cover_art_archive_front_cover() { + let body = serde_json::json!({ + "releases": [{ "id": "abc", "title": "An Album" }] + }); + assert_eq!( + parse_search(&body)[0].artwork_url.as_deref(), + Some("https://coverartarchive.org/release/abc/front") + ); + } + + #[test] + fn a_joined_artist_credit_is_flattened() { + let body = serde_json::json!({ + "id": "abc", "title": "A Split", + "artist-credit": [{ "name": "One" }, { "artist": { "name": "Two" } }] + }); + assert_eq!( + parse_release(&body).unwrap().original_title.as_deref(), + Some("One & Two") + ); + } + + #[test] + fn an_empty_search_yields_nothing() { + assert!(parse_search(&serde_json::json!({ "releases": [] })).is_empty()); + assert!(parse_search(&serde_json::Value::Null).is_empty()); + } +} diff --git a/crates/vuio-core/src/mediainfo/providers/omdb.rs b/crates/vuio-core/src/mediainfo/providers/omdb.rs new file mode 100644 index 00000000..29be6c39 --- /dev/null +++ b/crates/vuio-core/src/mediainfo/providers/omdb.rs @@ -0,0 +1,122 @@ +//! OMDb — IMDb-sourced ratings, posters and plots. Needs a free API key. + +use super::super::client::{query_escape, Candidate, Fetcher, MediaQuery, MetadataProvider}; +use super::super::provider::{provider_info, ProviderInfo}; +use super::{number, text, year_of}; +use anyhow::{bail, Result}; + +const ID: &str = "omdb"; +const BASE: &str = "https://www.omdbapi.com/"; + +pub struct Omdb; + +#[async_trait::async_trait] +impl MetadataProvider for Omdb { + fn info(&self) -> &'static ProviderInfo { + provider_info(ID).expect("omdb is in the registry") + } + + async fn search( + &self, + http: &Fetcher, + query: &MediaQuery, + credential: Option<&str>, + ) -> Result> { + let Some(key) = credential else { + bail!("OMDb needs an API key"); + }; + // `t=` returns one fully-populated record — plot, rating, genres — where + // `s=` returns a list of stubs. One good answer beats five thin ones, + // especially against a 1000/day budget. + let mut url = format!( + "{BASE}?apikey={}&t={}&plot=short", + query_escape(key), + query_escape(&query.title) + ); + if let Some(year) = query.year { + url.push_str(&format!("&y={year}")); + } + if query.season.is_some() { + url.push_str("&type=series"); + } + let body = http.get_json(ID, &url, &[]).await?; + Ok(parse(&body).into_iter().collect()) + } +} + +fn parse(body: &serde_json::Value) -> Option { + // OMDb signals failure in the body with HTTP 200: `{"Response":"False", ...}`. + if text(body, "Response").as_deref() == Some("False") { + return None; + } + let id = text(body, "imdbID")?; + let title = text(body, "Title")?; + let kind = match text(body, "Type").as_deref() { + Some("series") => "series", + Some("episode") => "episode", + _ => "movie", + }; + let mut candidate = Candidate::new(ID, kind, id, title); + candidate.overview = text(body, "Plot"); + candidate.release_date = text(body, "Released"); + candidate.year = text(body, "Year").as_deref().and_then(year_of); + candidate.rating = number(body, "imdbRating"); + candidate.genres = text(body, "Genre") + .map(|genres| { + genres + .split(',') + .map(str::trim) + .filter(|genre| !genre.is_empty()) + .map(str::to_string) + .collect() + }) + .unwrap_or_default(); + candidate.season = text(body, "Season").and_then(|season| season.parse().ok()); + candidate.episode = text(body, "Episode").and_then(|episode| episode.parse().ok()); + candidate.artwork_url = text(body, "Poster"); + candidate.payload = body.clone(); + Some(candidate) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reads_a_title_lookup() { + let body = serde_json::json!({ + "Title": "Arrival", "Year": "2016", "Released": "11 Nov 2016", + "Genre": "Drama, Mystery, Sci-Fi", "Plot": "A linguist is recruited.", + "Poster": "https://x.test/p.jpg", "imdbRating": "7.9", + "imdbID": "tt2543164", "Type": "movie", "Response": "True" + }); + + let candidate = parse(&body).unwrap(); + assert_eq!(candidate.remote_id, "tt2543164"); + assert_eq!(candidate.title, "Arrival"); + assert_eq!(candidate.year, Some(2016)); + assert_eq!(candidate.rating, Some(7.9)); + assert_eq!(candidate.genres, vec!["Drama", "Mystery", "Sci-Fi"]); + assert_eq!(candidate.kind, "movie"); + } + + #[test] + fn a_false_response_is_a_miss_not_a_record() { + // OMDb reports "not found" with HTTP 200 and this body, so failing to read + // it would store an empty candidate for every unmatched file. + let body = serde_json::json!({ "Response": "False", "Error": "Movie not found!" }); + assert!(parse(&body).is_none()); + } + + #[test] + fn n_a_fields_do_not_become_content() { + let body = serde_json::json!({ + "Title": "Something", "imdbID": "tt1", "Type": "movie", + "Plot": "N/A", "Poster": "N/A", "imdbRating": "N/A", "Response": "True" + }); + let candidate = parse(&body).unwrap(); + assert_eq!(candidate.overview, None); + assert_eq!(candidate.artwork_url, None); + assert_eq!(candidate.rating, None); + } +} diff --git a/crates/vuio-core/src/mediainfo/providers/tmdb.rs b/crates/vuio-core/src/mediainfo/providers/tmdb.rs new file mode 100644 index 00000000..c8e605b2 --- /dev/null +++ b/crates/vuio-core/src/mediainfo/providers/tmdb.rs @@ -0,0 +1,132 @@ +//! TheMovieDB — movies and TV. Needs a free API key. + +use super::super::client::{query_escape, Candidate, Fetcher, MediaQuery, MetadataProvider}; +use super::super::provider::{provider_info, ProviderInfo}; +use super::{number, strip_html, text, year_of}; +use anyhow::{bail, Result}; + +const ID: &str = "tmdb"; +const SEARCH: &str = "https://api.themoviedb.org/3/search/multi"; +/// Poster paths come back relative; w500 is large enough for a TV's cover slot +/// without pulling the multi-megabyte original for every item in a library. +const IMAGE_BASE: &str = "https://image.tmdb.org/t/p/w500"; + +pub struct Tmdb; + +#[async_trait::async_trait] +impl MetadataProvider for Tmdb { + fn info(&self) -> &'static ProviderInfo { + provider_info(ID).expect("tmdb is in the registry") + } + + async fn search( + &self, + http: &Fetcher, + query: &MediaQuery, + credential: Option<&str>, + ) -> Result> { + let Some(key) = credential else { + bail!("TheMovieDB needs an API key"); + }; + let mut url = format!( + "{SEARCH}?api_key={}&query={}", + query_escape(key), + query_escape(&query.title) + ); + if let Some(year) = query.year { + url.push_str(&format!("&year={year}")); + } + let body = http.get_json(ID, &url, &[]).await?; + Ok(parse(&body)) + } +} + +fn parse(body: &serde_json::Value) -> Vec { + let Some(results) = body.get("results").and_then(|value| value.as_array()) else { + return Vec::new(); + }; + results + .iter() + .filter_map(|entry| { + let media_type = text(entry, "media_type").unwrap_or_else(|| "movie".to_string()); + // `search/multi` also returns people, who have no title and are not + // something a media file can be. + let kind = match media_type.as_str() { + "tv" => "series", + "movie" => "movie", + _ => return None, + }; + let id = entry.get("id")?.as_i64()?; + // Movies carry `title`, TV carries `name`. + let title = text(entry, "title").or_else(|| text(entry, "name"))?; + let mut candidate = Candidate::new(ID, kind, id.to_string(), title); + candidate.original_title = + text(entry, "original_title").or_else(|| text(entry, "original_name")); + candidate.overview = text(entry, "overview").map(|text| strip_html(&text)); + candidate.release_date = + text(entry, "release_date").or_else(|| text(entry, "first_air_date")); + candidate.year = candidate.release_date.as_deref().and_then(year_of); + candidate.rating = number(entry, "vote_average"); + candidate.artwork_url = text(entry, "poster_path") + .or_else(|| text(entry, "backdrop_path")) + .map(|path| format!("{IMAGE_BASE}{path}")); + candidate.payload = entry.clone(); + Some(candidate) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reads_a_multi_search_response() { + let body = serde_json::json!({ + "results": [ + { + "id": 329865, "media_type": "movie", "title": "Arrival", + "original_title": "Arrival", "overview": "A linguist is recruited.", + "release_date": "2016-11-10", "vote_average": 7.6, + "poster_path": "/poster.jpg" + }, + { + "id": 1399, "media_type": "tv", "name": "Some Show", + "first_air_date": "2011-04-17", "vote_average": 8.4, + "poster_path": "/show.jpg" + } + ] + }); + + let candidates = parse(&body); + assert_eq!(candidates.len(), 2); + assert_eq!(candidates[0].kind, "movie"); + assert_eq!(candidates[0].title, "Arrival"); + assert_eq!(candidates[0].year, Some(2016)); + assert_eq!( + candidates[0].artwork_url.as_deref(), + Some("https://image.tmdb.org/t/p/w500/poster.jpg") + ); + // TV entries name their title differently; both have to be read. + assert_eq!(candidates[1].kind, "series"); + assert_eq!(candidates[1].title, "Some Show"); + assert_eq!(candidates[1].year, Some(2011)); + } + + #[test] + fn people_results_are_dropped() { + let body = serde_json::json!({ + "results": [{ "id": 1, "media_type": "person", "name": "Someone" }] + }); + assert!(parse(&body).is_empty()); + } + + #[tokio::test] + async fn searching_without_a_key_is_an_error_rather_than_a_silent_miss() { + let http = Fetcher::new(std::time::Duration::from_secs(1)).unwrap(); + let result = Tmdb + .search(&http, &MediaQuery::default(), None) + .await; + assert!(result.is_err()); + } +} diff --git a/crates/vuio-core/src/mediainfo/providers/tvmaze.rs b/crates/vuio-core/src/mediainfo/providers/tvmaze.rs new file mode 100644 index 00000000..097df6c8 --- /dev/null +++ b/crates/vuio-core/src/mediainfo/providers/tvmaze.rs @@ -0,0 +1,176 @@ +//! TVmaze — TV series and episodes, no account required. + +use super::super::client::{Candidate, Fetcher, MediaQuery, MetadataProvider}; +use super::super::provider::{provider_info, ProviderInfo}; +use super::{named_list, number, strip_html, text, year_of}; +use anyhow::Result; + +const ID: &str = "tvmaze"; +const SEARCH: &str = "https://api.tvmaze.com/search/shows"; + +pub struct TvMaze; + +#[async_trait::async_trait] +impl MetadataProvider for TvMaze { + fn info(&self) -> &'static ProviderInfo { + provider_info(ID).expect("tvmaze is in the registry") + } + + async fn search( + &self, + http: &Fetcher, + query: &MediaQuery, + _credential: Option<&str>, + ) -> Result> { + let url = format!( + "{SEARCH}?q={}", + super::super::client::query_escape(&query.title) + ); + let body = http.get_json(ID, &url, &[]).await?; + let mut candidates = parse_shows(&body); + + // An episode request wants the episode's own title and summary, which the + // show search does not carry. Only the best show is followed up: every + // extra lookup is another second against the rate limit. + if let (Some(season), Some(episode), Some(best)) = + (query.season, query.episode, candidates.first().cloned()) + { + let url = format!( + "https://api.tvmaze.com/shows/{}/episodebynumber?season={season}&number={episode}", + super::super::client::query_escape(&best.remote_id), + ); + match http.get_json(ID, &url, &[]).await { + Ok(body) => { + if let Some(found) = parse_episode(&body, &best) { + candidates.insert(0, found); + } + } + // The show matched but the episode does not exist upstream. That is + // a miss, not a failure — the show-level candidate still stands. + Err(error) => tracing::debug!(%error, "TVmaze episode lookup failed"), + } + } + + Ok(candidates) + } +} + +fn parse_shows(body: &serde_json::Value) -> Vec { + let Some(results) = body.as_array() else { + return Vec::new(); + }; + results + .iter() + .filter_map(|entry| { + let show = entry.get("show")?; + let id = show.get("id")?.as_i64()?; + let name = text(show, "name")?; + let mut candidate = Candidate::new(ID, "series", id.to_string(), name); + candidate.overview = text(show, "summary").map(|summary| strip_html(&summary)); + candidate.release_date = text(show, "premiered"); + candidate.year = candidate.release_date.as_deref().and_then(year_of); + candidate.rating = show.get("rating").and_then(|rating| number(rating, "average")); + candidate.genres = named_list(show, "genres"); + candidate.artwork_url = show + .get("image") + .and_then(|image| text(image, "original").or_else(|| text(image, "medium"))); + candidate.payload = show.clone(); + Some(candidate) + }) + .collect() +} + +/// An episode record, taking the show's artwork when the episode has none of its +/// own — most episodes do not carry a still. +fn parse_episode(body: &serde_json::Value, show: &Candidate) -> Option { + let id = body.get("id")?.as_i64()?; + // "Breakage" on its own says nothing about which show it belongs to, and a + // browse listing is where this title is read. The series leads. + let episode_name = text(body, "name")?; + let mut candidate = Candidate::new( + ID, + "episode", + id.to_string(), + format!("{} — {episode_name}", show.title), + ); + candidate.overview = text(body, "summary").map(|summary| strip_html(&summary)); + candidate.release_date = text(body, "airdate"); + candidate.year = candidate.release_date.as_deref().and_then(year_of); + candidate.season = body.get("season").and_then(|value| value.as_u64()).map(|v| v as u32); + candidate.episode = body.get("number").and_then(|value| value.as_u64()).map(|v| v as u32); + candidate.rating = body.get("rating").and_then(|rating| number(rating, "average")); + candidate.genres = show.genres.clone(); + candidate.artwork_url = body + .get("image") + .and_then(|image| text(image, "original")) + .or_else(|| show.artwork_url.clone()); + candidate.payload = body.clone(); + // The episode title is rarely what the filename said; the series name is. Keep + // the show's name available so scoring can match on it. + candidate.original_title = Some(show.title.clone()); + Some(candidate) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reads_a_show_search_response() { + let body = serde_json::json!([{ + "score": 0.9, + "show": { + "id": 143, + "name": "Some Show", + "premiered": "2011-04-17", + "genres": ["Drama", "Fantasy"], + "rating": { "average": 8.9 }, + "summary": "

A tale.

", + "image": { "medium": "https://x.test/m.jpg", "original": "https://x.test/o.jpg" } + } + }]); + + let candidates = parse_shows(&body); + assert_eq!(candidates.len(), 1); + let show = &candidates[0]; + assert_eq!(show.remote_id, "143"); + assert_eq!(show.title, "Some Show"); + assert_eq!(show.year, Some(2011)); + assert_eq!(show.rating, Some(8.9)); + assert_eq!(show.genres, vec!["Drama", "Fantasy"]); + assert_eq!(show.overview.as_deref(), Some("A tale.")); + assert_eq!(show.artwork_url.as_deref(), Some("https://x.test/o.jpg")); + assert_eq!(show.kind, "series"); + } + + #[test] + fn an_empty_response_yields_nothing_rather_than_failing() { + assert!(parse_shows(&serde_json::json!([])).is_empty()); + assert!(parse_shows(&serde_json::Value::Null).is_empty()); + } + + #[test] + fn an_episode_inherits_the_shows_artwork_and_genres() { + let show = { + let mut candidate = + Candidate::new(ID, "series", "143".to_string(), "Some Show".to_string()); + candidate.artwork_url = Some("https://x.test/o.jpg".to_string()); + candidate.genres = vec!["Drama".to_string()]; + candidate + }; + let body = serde_json::json!({ + "id": 900, "name": "The Episode", "season": 2, "number": 5, + "airdate": "2012-04-01", "summary": "

Things happen.

", "image": null + }); + + let episode = parse_episode(&body, &show).unwrap(); + assert_eq!(episode.kind, "episode"); + assert_eq!(episode.season, Some(2)); + assert_eq!(episode.episode, Some(5)); + // Series first: the episode name alone does not identify anything. + assert_eq!(episode.title, "Some Show — The Episode"); + assert_eq!(episode.original_title.as_deref(), Some("Some Show")); + assert_eq!(episode.artwork_url.as_deref(), Some("https://x.test/o.jpg")); + assert_eq!(episode.genres, vec!["Drama"]); + } +} diff --git a/crates/vuio-core/src/mediainfo/rate_limit.rs b/crates/vuio-core/src/mediainfo/rate_limit.rs new file mode 100644 index 00000000..c3fb7376 --- /dev/null +++ b/crates/vuio-core/src/mediainfo/rate_limit.rs @@ -0,0 +1,103 @@ +//! Per-provider request pacing. +//! +//! These limits are not politeness, they are the terms of use. MusicBrainz +//! enforces one request per second at the server and starts returning 503 to an +//! IP that exceeds it; Jikan and AniList do the same on their own schedules. A +//! library fetch walks thousands of files, so without pacing the first hundred +//! lookups would succeed and the rest would be rejected. +//! +//! The gate holds its lock across the sleep. That is deliberate: it serializes +//! the provider rather than merely spacing whoever happens to check, which is +//! what a hard ceiling requires when several files are in flight at once. + +use std::collections::HashMap; +use std::time::{Duration, Instant}; +use tokio::sync::Mutex; + +/// Minimum spacing between two requests to the same provider. +/// +/// Set from each publisher's documented ceiling with a little headroom, since the +/// limit is enforced on arrival time at their end and not on departure from ours. +fn interval_for(provider: &str) -> Duration { + let millis = match provider { + // Documented as a hard 1/s, applied per IP. + "musicbrainz" | "coverartarchive" => 1_100, + // 60 requests/minute for an authenticated token. + "discogs" => 1_100, + // 3/s and 60/min — the minute budget is the binding one. + "jikan" => 1_050, + // 90/min. + "anilist" => 700, + "tvmaze" => 500, + "kitsu" => 300, + "lastfm" | "omdb" | "genius" => 250, + // TMDb removed its published per-second cap, but hammering it still + // invites a block. + "tmdb" => 60, + _ => 250, + }; + Duration::from_millis(millis) +} + +/// One gate per provider, created on first use. +#[derive(Default)] +pub struct RateLimiters { + gates: Mutex>>>>, +} + +impl RateLimiters { + pub fn new() -> Self { + Self::default() + } + + /// Block until it is this provider's turn. + pub async fn acquire(&self, provider: &'static str) { + let gate = { + let mut gates = self.gates.lock().await; + gates.entry(provider).or_default().clone() + }; + + let interval = interval_for(provider); + let mut last = gate.lock().await; + if let Some(previous) = *last { + let elapsed = previous.elapsed(); + if elapsed < interval { + tokio::time::sleep(interval - elapsed).await; + } + } + *last = Some(Instant::now()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn the_first_request_to_a_provider_is_not_delayed() { + let limiters = RateLimiters::new(); + let started = Instant::now(); + limiters.acquire("musicbrainz").await; + assert!(started.elapsed() < Duration::from_millis(100)); + } + + #[tokio::test] + async fn a_second_request_waits_for_the_interval() { + let limiters = RateLimiters::new(); + limiters.acquire("musicbrainz").await; + let started = Instant::now(); + limiters.acquire("musicbrainz").await; + // MusicBrainz's ceiling is the one that gets an IP blocked, so this is the + // case worth asserting rather than the general shape. + assert!(started.elapsed() >= Duration::from_millis(1_000)); + } + + #[tokio::test] + async fn providers_do_not_wait_on_each_other() { + let limiters = RateLimiters::new(); + limiters.acquire("musicbrainz").await; + let started = Instant::now(); + limiters.acquire("tmdb").await; + assert!(started.elapsed() < Duration::from_millis(100)); + } +} diff --git a/crates/vuio-core/src/state.rs b/crates/vuio-core/src/state.rs index e5fdcb97..99c514c7 100644 --- a/crates/vuio-core/src/state.rs +++ b/crates/vuio-core/src/state.rs @@ -234,6 +234,14 @@ pub struct AppState { >, >, pub active_casts: Arc>, + /// Progress of the online media info fetch, which the dashboard polls. + /// + /// A library run takes minutes to hours — the providers' rate limits see to + /// that — so it cannot be the body of a request. The state lives here for the + /// same reason `active_monitors` does: a handler starts the work, and later + /// handlers need to report on it or stop it. + #[cfg(feature = "mediainfo")] + pub mediainfo_job: Arc>, #[cfg(feature = "casting")] pub discovered_tvs: Arc, pub upnp_subscriptions: @@ -265,6 +273,8 @@ impl Clone for AppState { mcp_clients: self.mcp_clients.clone(), active_monitors: self.active_monitors.clone(), active_casts: self.active_casts.clone(), + #[cfg(feature = "mediainfo")] + mediainfo_job: self.mediainfo_job.clone(), #[cfg(feature = "casting")] discovered_tvs: self.discovered_tvs.clone(), upnp_subscriptions: self.upnp_subscriptions.clone(), diff --git a/crates/vuio-core/src/web/admin.rs b/crates/vuio-core/src/web/admin.rs index cd78a14a..b7f9844b 100644 --- a/crates/vuio-core/src/web/admin.rs +++ b/crates/vuio-core/src/web/admin.rs @@ -88,6 +88,11 @@ struct SectionSpec { /// than a list of fields. #[serde(skip_serializing_if = "std::ops::Not::not")] directories: bool, + /// Marks a section that carries an action panel below its fields — provider + /// credentials and the Fetch button, which are not settings in the file and + /// so cannot be described as `FieldSpec`s. + #[serde(skip_serializing_if = "std::ops::Not::not")] + panel: bool, } /// A setting the file must always carry: `AppConfig` has no serde default for it, @@ -359,6 +364,71 @@ const MANAGEMENT_FIELDS: &[FieldSpec] = &[ ), ]; +const MEDIAINFO_FIELDS: &[FieldSpec] = &[ + noted( + optional( + "mediainfo.enabled", + "Enable online lookups", + FieldKind::Bool, + Impact::Live, + "Allow VuIO to fetch titles, synopses and artwork from public metadata services.", + ), + "This is the only feature that contacts anything outside the local network. Nothing \ + is requested until you press Fetch below.", + ), + noted( + optional( + "mediainfo.providers", + "Providers", + FieldKind::StringList, + Impact::Live, + "Which services to consult, one id per line, in the order they should be tried.", + ), + "tvmaze, musicbrainz, jikan, anilist and kitsu need no account. tmdb, omdb, discogs, \ + lastfm and genius stay idle until you save a credential for them below.", + ), + optional( + "mediainfo.artwork_enabled", + "Download artwork", + FieldKind::Bool, + Impact::Live, + "Cache posters and cover art locally so DLNA clients, which usually cannot reach the \ + internet, can still display them.", + ), + optional( + "mediainfo.artwork_path", + "Artwork cache", + FieldKind::Path, + Impact::Restart, + "Where downloaded artwork is kept. Leave unset to keep it beside the database.", + ), + noted( + optional( + "mediainfo.min_confidence", + "Confidence threshold", + FieldKind::Int { min: 0, max: 100 }, + Impact::Live, + "How sure a match must be before it is trusted, from 0 to 100.", + ), + "Weaker matches are still stored, but are listed below for review instead of being \ + used. Raising this makes a later run reconsider everything below the new value.", + ), + optional( + "mediainfo.prefer_online_titles", + "Prefer fetched titles", + FieldKind::Bool, + Impact::Live, + "Show the fetched title instead of the one read from the file's own tags.", + ), + optional( + "mediainfo.request_timeout_seconds", + "Request timeout", + FieldKind::Int { min: 1, max: 120 }, + Impact::Live, + "Seconds to wait for a provider before giving up on one lookup.", + ), +]; + const SECTIONS: &[SectionSpec] = &[ SectionSpec { id: "server", @@ -366,6 +436,7 @@ const SECTIONS: &[SectionSpec] = &[ blurb: "Identity and the address this server answers on.", fields: SERVER_FIELDS, directories: false, + panel: false, }, SectionSpec { id: "library", @@ -373,6 +444,7 @@ const SECTIONS: &[SectionSpec] = &[ blurb: "The folders scanned for media. Changes apply without a restart.", fields: &[], directories: true, + panel: false, }, SectionSpec { id: "media", @@ -380,6 +452,7 @@ const SECTIONS: &[SectionSpec] = &[ blurb: "What gets indexed, and how playback behaves.", fields: MEDIA_FIELDS, directories: false, + panel: false, }, SectionSpec { id: "network", @@ -387,6 +460,7 @@ const SECTIONS: &[SectionSpec] = &[ blurb: "Discovery and advertisement on the local network.", fields: NETWORK_FIELDS, directories: false, + panel: false, }, SectionSpec { id: "database", @@ -394,6 +468,7 @@ const SECTIONS: &[SectionSpec] = &[ blurb: "Storage for the media index.", fields: DATABASE_FIELDS, directories: false, + panel: false, }, SectionSpec { id: "management", @@ -401,6 +476,16 @@ const SECTIONS: &[SectionSpec] = &[ blurb: "Who may reach the dashboard and the management API.", fields: MANAGEMENT_FIELDS, directories: false, + panel: false, + }, + SectionSpec { + id: "mediainfo", + title: "MediaInfo", + blurb: "Fetch titles, synopses, ratings and artwork for the library from public \ + metadata services.", + fields: MEDIAINFO_FIELDS, + directories: false, + panel: true, }, ]; diff --git a/crates/vuio-core/src/web/mediainfo.rs b/crates/vuio-core/src/web/mediainfo.rs new file mode 100644 index 00000000..2842b47e --- /dev/null +++ b/crates/vuio-core/src/web/mediainfo.rs @@ -0,0 +1,274 @@ +//! The dashboard's MediaInfo endpoints. +//! +//! Four verbs: report what is configured and how a run is going, save or clear a +//! provider credential, start a run, and stop one. The run itself is a background +//! task — the providers' rate limits put a library well past any sensible request +//! timeout — so starting it returns immediately and the dashboard polls for the +//! rest. + +use crate::{ + database::DatabaseManager, + mediainfo::{ + provider::PROVIDERS, provider_info, run_library_fetch, CredentialStore, MEDIAINFO_VERSION, + }, + state::AppState, +}; +use axum::{extract::State, http::StatusCode, response::IntoResponse, Json}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// How many uncertain matches the dashboard is given to show. The list is for +/// spotting a pattern, not for auditing the whole library. +const LOW_CONFIDENCE_LIMIT: usize = 50; + +#[derive(Serialize)] +struct ProviderView { + id: &'static str, + label: &'static str, + group: &'static str, + provides: &'static str, + /// The label of the credential it wants, when it wants one. + #[serde(skip_serializing_if = "Option::is_none")] + credential_label: Option<&'static str>, + #[serde(skip_serializing_if = "Option::is_none")] + signup_url: Option<&'static str>, + needs_credential: bool, + /// Whether a credential is on file. Never the credential itself. + has_credential: bool, + /// Whether this provider is in the configured list. + enabled: bool, +} + +#[derive(Serialize)] +struct JobView { + running: bool, + total: usize, + processed: usize, + matched: usize, + low_confidence: usize, + failed: usize, + cancelled: bool, + #[serde(skip_serializing_if = "Option::is_none")] + current: Option, + #[serde(skip_serializing_if = "Option::is_none")] + started_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + finished_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + last_error: Option, +} + +#[derive(Serialize)] +struct FlaggedView { + media_file_id: i64, + confidence: u8, + provider: String, + #[serde(skip_serializing_if = "Option::is_none")] + matched_title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + filename: Option, +} + +fn epoch_seconds(time: Option) -> Option { + time.and_then(|time| time.duration_since(UNIX_EPOCH).ok()) + .map(|elapsed| elapsed.as_secs()) +} + +/// `GET /api/admin/mediainfo` +pub async fn get_status( + State(state): State>, +) -> impl IntoResponse { + let config = state.current_config(); + let settings = &config.mediainfo; + let threshold = settings.min_confidence.min(100); + + let credentials = + match CredentialStore::load(state.database.clone() as std::sync::Arc) + .await + { + Ok(store) => store, + Err(error) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": error.to_string() })), + ) + .into_response() + } + }; + let stored = credentials.stored_providers().await; + + let providers: Vec = PROVIDERS + .iter() + .map(|provider| ProviderView { + id: provider.id, + label: provider.label, + group: provider.kind.group(), + provides: provider.provides, + credential_label: provider.credential.map(|credential| credential.label), + signup_url: provider.credential.map(|credential| credential.signup_url), + needs_credential: provider.needs_credential(), + has_credential: stored.iter().any(|id| id == provider.id), + enabled: settings.providers.iter().any(|id| id == provider.id), + }) + .collect(); + + let job = { + let job = state.mediainfo_job.lock().await; + JobView { + running: job.running, + total: job.total, + processed: job.processed, + matched: job.matched, + low_confidence: job.low_confidence, + failed: job.failed, + cancelled: job.cancelled, + current: job.current.clone(), + started_at: epoch_seconds(job.started_at), + finished_at: epoch_seconds(job.finished_at), + last_error: job.last_error.clone(), + } + }; + + let stats = state + .database + .mediainfo_stats(threshold) + .await + .unwrap_or_default(); + + let flagged = match state + .database + .list_low_confidence(threshold, LOW_CONFIDENCE_LIMIT) + .await + { + Ok(records) => { + let mut views = Vec::with_capacity(records.len()); + for record in records { + // The filename is what makes a flagged row identifiable; without it + // the operator is looking at a list of database ids. + let filename = state + .database + .get_file_by_id(record.media_file_id) + .await + .ok() + .flatten() + .map(|file| file.filename); + views.push(FlaggedView { + media_file_id: record.media_file_id, + confidence: record.confidence, + provider: record.provider, + matched_title: record.title, + filename, + }); + } + views + } + Err(error) => { + tracing::warn!(%error, "Could not list low-confidence media info"); + Vec::new() + } + }; + + Json(json!({ + "enabled": settings.enabled, + "min_confidence": threshold, + "artwork_enabled": settings.artwork_enabled, + "version": MEDIAINFO_VERSION, + "providers": providers, + "job": job, + "stats": stats, + "flagged": flagged, + })) + .into_response() +} + +#[derive(Deserialize)] +pub struct CredentialRequest { + provider: String, + /// An empty or absent token clears the stored one — the dashboard's Clear + /// button posts the same shape as Save. + #[serde(default)] + token: String, +} + +/// `POST /api/admin/mediainfo/credentials` +pub async fn put_credential( + State(state): State>, + Json(request): Json, +) -> impl IntoResponse { + let Some(provider) = provider_info(&request.provider) else { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ "error": format!("Unknown provider: {}", request.provider) })), + ) + .into_response(); + }; + if !provider.needs_credential() { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ "error": format!("{} does not use a credential", provider.label) })), + ) + .into_response(); + } + + let credentials = + match CredentialStore::load(state.database.clone() as std::sync::Arc) + .await + { + Ok(store) => store, + Err(error) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": error.to_string() })), + ) + .into_response() + } + }; + + if let Err(error) = credentials.set(provider.id, &request.token).await { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": error.to_string() })), + ) + .into_response(); + } + + // Reports whether one is now stored, never what it is. + Json(json!({ "saved": true, "has_credential": !request.token.trim().is_empty() })) + .into_response() +} + +/// `POST /api/admin/mediainfo/run` +pub async fn run( + State(state): State>, +) -> impl IntoResponse { + match run_library_fetch(state).await { + Ok(total) => Json(json!({ "started": true, "total": total })).into_response(), + // "Already running" and "turned off" are both the caller asking for + // something the current state does not allow, which is a conflict rather + // than a server fault. + Err(error) => ( + StatusCode::CONFLICT, + Json(json!({ "error": error.to_string() })), + ) + .into_response(), + } +} + +/// `POST /api/admin/mediainfo/cancel` +pub async fn cancel( + State(state): State>, +) -> impl IntoResponse { + let job = state.mediainfo_job.lock().await; + match job.cancel.as_ref() { + Some(token) => { + token.cancel(); + Json(json!({ "cancelled": true })).into_response() + } + None => ( + StatusCode::CONFLICT, + Json(json!({ "error": "No media info fetch is running" })), + ) + .into_response(), + } +} diff --git a/crates/vuio-core/src/web/mod.rs b/crates/vuio-core/src/web/mod.rs index 2089625e..02b9cb17 100644 --- a/crates/vuio-core/src/web/mod.rs +++ b/crates/vuio-core/src/web/mod.rs @@ -9,6 +9,8 @@ pub mod eventing; mod format; #[cfg(feature = "mcp")] pub mod mcp; +#[cfg(all(feature = "dashboard", feature = "mediainfo"))] +pub mod mediainfo; #[cfg(feature = "casting")] pub mod remux_streaming; pub mod soap; @@ -80,6 +82,16 @@ pub fn create_router(state: AppState) -> Router .route("/api/admin/config", post(admin::put_config::)) .route("/api/admin/restart", post(admin::restart::)); } + #[cfg(all(feature = "dashboard", feature = "mediainfo"))] + { + json_routes = json_routes + .route( + "/api/admin/mediainfo/credentials", + post(mediainfo::put_credential::), + ) + .route("/api/admin/mediainfo/run", post(mediainfo::run::)) + .route("/api/admin/mediainfo/cancel", post(mediainfo::cancel::)); + } let json_routes = json_routes.layer(DefaultBodyLimit::max(JSON_BODY_LIMIT)); #[allow(unused_mut)] @@ -96,6 +108,11 @@ pub fn create_router(state: AppState) -> Router .route("/api/media", get(ui::media_page_handler::)) .route("/api/admin/config", get(admin::get_config::)); } + #[cfg(all(feature = "dashboard", feature = "mediainfo"))] + { + management_routes = + management_routes.route("/api/admin/mediainfo", get(mediainfo::get_status::)); + } #[cfg(feature = "casting")] { management_routes = diff --git a/crates/vuio-core/src/web/soap/content_directory.rs b/crates/vuio-core/src/web/soap/content_directory.rs index 146cc94b..c2523cbf 100644 --- a/crates/vuio-core/src/web/soap/content_directory.rs +++ b/crates/vuio-core/src/web/soap/content_directory.rs @@ -191,6 +191,9 @@ impl ContentDirectoryHandler { autoplay_enabled: state.current_config().media.autoplay_enabled, update_id: current_update_id, bookmarks, + prefer_online_titles: state.current_config().mediainfo.prefer_online_titles, + min_confidence: state.current_config().mediainfo.min_confidence, + mediainfo: Default::default(), }; let canonical_parent = canonical_browse_path.to_string_lossy().into_owned(); let mime_family = media_type_filter.to_owned(); @@ -377,6 +380,9 @@ impl ContentDirectoryHandler { autoplay_enabled: state.current_config().media.autoplay_enabled, update_id: state.content_update_id.load(Ordering::SeqCst), bookmarks: state.bookmarks.lock().await.snapshot(), + prefer_online_titles: state.current_config().mediainfo.prefer_online_titles, + min_confidence: state.current_config().mediainfo.min_confidence, + mediainfo: Default::default(), }; let starting_index = params.starting_index as usize; let requested_count = browse_page_limit(params); diff --git a/crates/vuio-core/src/web/soap/music.rs b/crates/vuio-core/src/web/soap/music.rs index 2d087ca8..457b12e5 100644 --- a/crates/vuio-core/src/web/soap/music.rs +++ b/crates/vuio-core/src/web/soap/music.rs @@ -635,6 +635,9 @@ pub(super) async fn render_context( autoplay_enabled: state.current_config().media.autoplay_enabled, update_id: state.content_update_id.load(Ordering::SeqCst), bookmarks, + prefer_online_titles: state.current_config().mediainfo.prefer_online_titles, + min_confidence: state.current_config().mediainfo.min_confidence, + mediainfo: Default::default(), } } diff --git a/crates/vuio-core/src/web/streaming.rs b/crates/vuio-core/src/web/streaming.rs index 5155bbc0..75fc1c11 100644 --- a/crates/vuio-core/src/web/streaming.rs +++ b/crates/vuio-core/src/web/streaming.rs @@ -467,12 +467,14 @@ pub async fn serve_cover( AppError::NotFound })?; - if !file_info.mime_type.starts_with("audio/") { - return Err(AppError::NotFound); - } + // Video used to be rejected outright, because the only artwork VuIO could find + // was a sidecar file or an embedded audio tag and a video file has neither. A + // fetched poster is artwork it does have, so video falls through to the cache + // below instead of 404ing here. + let local_sources_apply = file_info.mime_type.starts_with("audio/"); // 1. Primary: Search parent directory for cover images (fast) - if let Some(parent) = file_info.path.parent() { + if let Some(parent) = file_info.path.parent().filter(|_| local_sources_apply) { let base_name = file_info .path .file_stem() @@ -507,7 +509,7 @@ pub async fn serve_cover( // (blocking task). Only the embedded path needs a tag reader — the // directory search above still serves cover art without the feature. #[cfg(feature = "metadata")] - { + if local_sources_apply { let path = file_info.path.clone(); let cover = tokio::task::spawn_blocking(move || { crate::platform::filesystem::extract_embedded_cover(&path) @@ -522,9 +524,48 @@ pub async fn serve_cover( } } + // 3. Last: a poster downloaded by the media info fetch. Comes last so anything + // shipped alongside the file still wins — the operator's own artwork is a + // deliberate choice, and a provider's guess is not. + #[cfg(feature = "mediainfo")] + { + if let Some(response) = serve_cached_artwork(&state, file_id).await { + return Ok(response); + } + } + Err(AppError::NotFound) } +/// Serve the artwork the media info fetch cached for this file, if any. +#[cfg(feature = "mediainfo")] +async fn serve_cached_artwork( + state: &AppState, + file_id: i64, +) -> Option { + let config = state.current_config(); + if !config.mediainfo.artwork_enabled { + return None; + } + let root = config.mediainfo.artwork_path.as_ref()?; + let key = state + .database + .get_mediainfo(file_id) + .await + .ok() + .flatten()? + .artwork_key?; + + let cache = crate::mediainfo::ArtworkCache::new(root); + let path = cache.lookup(&key)?; + let content_type = crate::mediainfo::artwork_content_type(&path); + let data = tokio::fs::read(&path).await.ok()?; + Response::builder() + .header(header::CONTENT_TYPE, content_type) + .body(Body::from(data)) + .ok() +} + #[cfg(test)] mod range_tests { use super::*; diff --git a/crates/vuio-core/src/web/ui.rs b/crates/vuio-core/src/web/ui.rs index 44cacab8..0e01d9a3 100644 --- a/crates/vuio-core/src/web/ui.rs +++ b/crates/vuio-core/src/web/ui.rs @@ -39,6 +39,7 @@ const CAST_JS: &str = include_str!("ui/js/cast.js"); const BROWSE_JS: &str = include_str!("ui/js/browse.js"); const STATS_JS: &str = include_str!("ui/js/stats.js"); const ADMIN_JS: &str = include_str!("ui/js/admin.js"); +const MEDIAINFO_JS: &str = include_str!("ui/js/mediainfo.js"); const INIT_JS: &str = include_str!("ui/js/init.js"); // Third-party player libraries, checked in under ui/vendor/ and compiled into the @@ -99,6 +100,7 @@ fn lookup_asset(file: &str) -> Option<(&'static [u8], &'static str, AssetCache)> "browse.js" => (BROWSE_JS, JAVASCRIPT, Revalidate), "stats.js" => (STATS_JS, JAVASCRIPT, Revalidate), "admin.js" => (ADMIN_JS, JAVASCRIPT, Revalidate), + "mediainfo.js" => (MEDIAINFO_JS, JAVASCRIPT, Revalidate), "init.js" => (INIT_JS, JAVASCRIPT, Revalidate), _ => return None, }; @@ -245,6 +247,9 @@ pub async fn media_page_handler( text, }; let fetch_limit = limit + 1; + // Uncertain matches stay out of the listing; they are reviewed in the Admin + // tab rather than shown as though they were the file's real title. + let min_confidence = state.current_config().mediainfo.min_confidence; let response = state .database .clone() @@ -253,6 +258,19 @@ pub async fn media_page_handler( output.extend_from_slice(b"{\"files\":["); let mut emitted = 0_usize; let mut last_id = None; + // Fetched titles and synopses, collected up front because the writer + // cannot query the session while the session is lending it a row. + let mut ids = Vec::with_capacity(fetch_limit); + session.visit_files(&query, 0, fetch_limit, |file| { + if let Some(id) = file.id().filter(|id| *id > 0) { + ids.push(id); + } + Ok(()) + })?; + let overlays = session + .mediainfo_overlays(&ids, min_confidence) + .unwrap_or_default(); + let summary = session.visit_files(&query, 0, fetch_limit, |file| { if emitted >= limit { return Ok(()); @@ -260,7 +278,8 @@ pub async fn media_page_handler( if emitted > 0 { output.push(b','); } - write_web_media_file(&mut output, &file)?; + let overlay = file.id().and_then(|id| overlays.get(&id)); + write_web_media_file(&mut output, &file, overlay)?; last_id = file.id(); emitted += 1; Ok(()) @@ -284,7 +303,11 @@ pub async fn media_page_handler( .into_response()) } -fn write_web_media_file(output: &mut Vec, file: &impl MediaFileView) -> anyhow::Result<()> { +fn write_web_media_file( + output: &mut Vec, + file: &impl MediaFileView, + overlay: Option<&crate::database::MediaInfoOverlay>, +) -> anyhow::Result<()> { let mime_type = file.mime_type(); let category = if mime_type == "audio/radio" { "radio" @@ -330,6 +353,22 @@ fn write_web_media_file(output: &mut Vec, file: &impl MediaFileView) -> anyh output.extend_from_slice(b",\"dur\":"); // serde_json cannot encode NaN/Infinity, and a bad tag must not fail the whole page. serde_json::to_writer(&mut *output, &file.duration_secs().filter(|d| d.is_finite()))?; + + // What the media info fetch found, kept under its own keys so the browse view + // can show it as fetched rather than passing it off as a local tag. + output.extend_from_slice(b",\"info_title\":"); + serde_json::to_writer(&mut *output, &overlay.and_then(|info| info.title.as_deref()))?; + output.extend_from_slice(b",\"info_overview\":"); + serde_json::to_writer( + &mut *output, + &overlay.and_then(|info| info.overview.as_deref()), + )?; + output.extend_from_slice(b",\"info_art\":"); + output.extend_from_slice(if overlay.is_some_and(|info| info.has_artwork) { + b"true".as_slice() + } else { + b"false".as_slice() + }); output.push(b'}'); Ok(()) } @@ -353,6 +392,7 @@ mod tests { BROWSE_JS, STATS_JS, ADMIN_JS, + MEDIAINFO_JS, INIT_JS, ] .concat() diff --git a/crates/vuio-core/src/web/ui/css/admin.css b/crates/vuio-core/src/web/ui/css/admin.css index 0ac182cc..12e50c29 100644 --- a/crates/vuio-core/src/web/ui/css/admin.css +++ b/crates/vuio-core/src/web/ui/css/admin.css @@ -438,3 +438,98 @@ textarea.admin-input { color: var(--accent-color) !important; font-weight: 600; } + +/* MediaInfo panel — provider cards, the fetch action and its progress. Built on + the .admin-library card so a provider reads like the library entries above it. */ +.mediainfo-group { + color: var(--text-secondary); + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.08em; + margin: 1.25rem 0 0.5rem; + text-transform: uppercase; +} + +.mediainfo-provider { + margin-bottom: 0.75rem; +} + +.mediainfo-credential { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.6rem; +} + +.mediainfo-credential .admin-input { + flex: 1 1 14rem; + min-width: 0; +} + +.mediainfo-signup { + color: var(--accent-color); + display: inline-block; + font-size: 0.82rem; + margin-top: 0.5rem; + text-decoration: none; +} + +.mediainfo-signup:hover { + text-decoration: underline; +} + +.mediainfo-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin: 0.75rem 0; +} + +.mediainfo-progress { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-top: 0.75rem; +} + +.mediainfo-bar { + background: var(--card-border); + border-radius: 999px; + height: 0.5rem; + overflow: hidden; +} + +.mediainfo-bar-fill { + background: var(--accent-gradient); + height: 100%; + /* The counter moves once a second; easing keeps it from looking like a jump. */ + transition: width 0.4s ease; +} + +.mediainfo-counts { + color: var(--text-secondary); + display: flex; + flex-wrap: wrap; + font-size: 0.85rem; + gap: 0.25rem 1rem; +} + +.mediainfo-error { + color: #ff6b6b !important; +} + +.mediainfo-flagged { + border: 1px solid var(--card-border); + border-radius: 12px; + margin-top: 1rem; + padding: 0.75rem 1rem; +} + +.mediainfo-flagged summary { + cursor: pointer; + font-weight: 600; +} + +.mediainfo-flagged .admin-runtime { + margin-top: 0.75rem; +} diff --git a/crates/vuio-core/src/web/ui/css/browse.css b/crates/vuio-core/src/web/ui/css/browse.css index 56e399bf..a442f96e 100644 --- a/crates/vuio-core/src/web/ui/css/browse.css +++ b/crates/vuio-core/src/web/ui/css/browse.css @@ -270,3 +270,17 @@ font-size: 0.85rem; color: var(--text-secondary); } + +/* Synopsis from the media info fetch. Clamped to two lines: it is a hint about + what the file is, and a full plot summary would push every other card off. */ +.media-overview { + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + color: var(--text-secondary); + display: -webkit-box; + font-size: 0.78rem; + line-clamp: 2; + line-height: 1.4; + margin-top: 0.2rem; + overflow: hidden; +} diff --git a/crates/vuio-core/src/web/ui/dashboard.html b/crates/vuio-core/src/web/ui/dashboard.html index c27e0d98..fff3812d 100644 --- a/crates/vuio-core/src/web/ui/dashboard.html +++ b/crates/vuio-core/src/web/ui/dashboard.html @@ -29,6 +29,7 @@ + diff --git a/crates/vuio-core/src/web/ui/js/admin.js b/crates/vuio-core/src/web/ui/js/admin.js index b72d949b..f94009d1 100644 --- a/crates/vuio-core/src/web/ui/js/admin.js +++ b/crates/vuio-core/src/web/ui/js/admin.js @@ -49,6 +49,12 @@ async function loadAdminConfig() { adminEdits = {}; adminDirectories = null; renderAdmin(); + // Providers and job progress come from a second endpoint, and a failure + // there must not take the settings screen down with it — the panel renders + // its own loading state until this lands. + loadMediaInfo() + .then(renderAdmin) + .catch(() => {}); } catch (error) { const body = document.getElementById('admin-pane-body'); body.replaceChildren(); @@ -194,6 +200,10 @@ function renderAdminPane() { for (const spec of section.fields) { body.appendChild(renderAdminRow(spec)); } + // Sections may carry actions that are not settings — see mediainfo.js. + if (section.panel) { + renderMediaInfoPanel(body); + } } function adminHeading(title, blurb) { diff --git a/crates/vuio-core/src/web/ui/js/browse.js b/crates/vuio-core/src/web/ui/js/browse.js index 094c7a82..b7013952 100644 --- a/crates/vuio-core/src/web/ui/js/browse.js +++ b/crates/vuio-core/src/web/ui/js/browse.js @@ -31,6 +31,9 @@ function render() { const matchesSearch = searchQuery === '' || file.name.toLowerCase().includes(searchQuery) || (file.title || '').toLowerCase().includes(searchQuery) + // Searching for the real title should find a file whose name is a + // release string that does not contain it. + || (file.info_title || '').toLowerCase().includes(searchQuery) || (file.artist || '').toLowerCase().includes(searchQuery) || (file.album || '').toLowerCase().includes(searchQuery); if (currentTab === 'radio') { @@ -274,7 +277,9 @@ function createFileCard(file) { `; card.querySelector('.media-icon-wrapper').innerHTML = iconSvg; const name = card.querySelector('.media-name'); - name.textContent = file.title || file.name; + // A fetched title is the readable one, and for video it is usually the only + // title there is — nothing reads metadata out of a video file. + name.textContent = file.info_title || file.title || file.name; name.title = file.name; const details = card.querySelector('.media-details'); const metadataParts = [file.artist, file.album].filter(Boolean); @@ -285,6 +290,13 @@ function createFileCard(file) { metadata.textContent = metadataParts.join(' — '); details.insertBefore(metadata, details.querySelector('.media-meta')); } + if (file.info_overview) { + const overview = document.createElement('div'); + overview.className = 'media-overview'; + overview.textContent = file.info_overview; + overview.title = file.info_overview; + details.insertBefore(overview, details.querySelector('.media-meta')); + } card.querySelector('.media-size').textContent = file.size_str; card.querySelector('.media-extension').textContent = file.ext; diff --git a/crates/vuio-core/src/web/ui/js/mediainfo.js b/crates/vuio-core/src/web/ui/js/mediainfo.js new file mode 100644 index 00000000..f78547a4 --- /dev/null +++ b/crates/vuio-core/src/web/ui/js/mediainfo.js @@ -0,0 +1,357 @@ +// The MediaInfo panel: provider credentials, the library fetch, and its progress. +// +// This hangs off the Admin tab's schema-driven pane. The settings above it are +// ordinary config keys and render themselves from the spec the server sends; what +// is here cannot be, because credentials live in the secrets table rather than the +// config file, and a running job is not a setting at all. +// +// Same conventions as admin.js: build nodes with createElement and textContent, +// never innerHTML, and report failures through showToast. + +let mediaInfoData = null; +let mediaInfoPollTimer = null; +let mediaInfoBusy = false; + +// While a run is going the page polls for progress. One second matches the pace a +// person reads a counter at, and the endpoint is a few cheap queries. +const MEDIAINFO_POLL_MS = 1000; + +async function loadMediaInfo() { + const response = await fetch('/api/admin/mediainfo'); + if (!response.ok) throw new Error('Could not load media info status'); + mediaInfoData = await response.json(); + return mediaInfoData; +} + +function stopMediaInfoPolling() { + if (mediaInfoPollTimer) { + clearInterval(mediaInfoPollTimer); + mediaInfoPollTimer = null; + } +} + +function startMediaInfoPolling() { + stopMediaInfoPolling(); + mediaInfoPollTimer = setInterval(async () => { + // A hidden tab has nobody to show a counter to, and the run continues on + // the server regardless. + if (document.hidden) return; + try { + const data = await loadMediaInfo(); + renderAdmin(); + if (!data.job || !data.job.running) stopMediaInfoPolling(); + } catch (error) { + // The server may be restarting or the session may have expired. Give + // up quietly rather than filling the screen with toasts once a second. + stopMediaInfoPolling(); + } + }, MEDIAINFO_POLL_MS); +} + +function mediaInfoPill(text, className) { + const pill = document.createElement('span'); + pill.className = 'admin-pill ' + className; + pill.textContent = text; + return pill; +} + +function renderProviderRow(provider) { + const card = document.createElement('div'); + card.className = 'admin-library mediainfo-provider'; + + const head = document.createElement('div'); + head.className = 'admin-library-head'; + + const name = document.createElement('strong'); + name.textContent = provider.label; + head.appendChild(name); + + if (!provider.needs_credential) { + head.appendChild(mediaInfoPill('No account needed', 'admin-pill-next')); + } else if (provider.has_credential) { + head.appendChild(mediaInfoPill('Credential saved', 'admin-pill-next')); + } else { + head.appendChild(mediaInfoPill('Needs a credential', 'admin-pill-restart')); + } + if (!provider.enabled) { + head.appendChild(mediaInfoPill('Not in use', 'admin-pill-unset')); + } + card.appendChild(head); + + const provides = document.createElement('p'); + provides.className = 'admin-section-blurb'; + provides.textContent = provider.provides; + card.appendChild(provides); + + if (provider.needs_credential) { + const row = document.createElement('div'); + row.className = 'mediainfo-credential'; + + const input = document.createElement('input'); + input.type = 'password'; + input.className = 'admin-input'; + input.autocomplete = 'off'; + input.placeholder = provider.has_credential + ? 'Saved — type a new one to replace it' + : provider.credential_label; + input.setAttribute('aria-label', provider.label + ' ' + provider.credential_label); + + const save = document.createElement('button'); + save.type = 'button'; + save.className = 'admin-btn admin-btn-primary'; + save.textContent = 'Save'; + save.onclick = () => saveMediaInfoCredential(provider.id, input.value, input); + + row.append(input, save); + + if (provider.has_credential) { + const clear = document.createElement('button'); + clear.type = 'button'; + clear.className = 'admin-btn admin-btn-danger'; + clear.textContent = 'Clear'; + clear.onclick = () => saveMediaInfoCredential(provider.id, '', input); + row.appendChild(clear); + } + card.appendChild(row); + + if (provider.signup_url) { + const link = document.createElement('a'); + link.className = 'mediainfo-signup'; + link.href = provider.signup_url; + link.target = '_blank'; + link.rel = 'noreferrer noopener'; + link.textContent = 'Get a free ' + provider.credential_label.toLowerCase(); + card.appendChild(link); + } + } + + return card; +} + +async function saveMediaInfoCredential(providerId, token, input) { + try { + const response = await fetch('/api/admin/mediainfo/credentials', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ provider: providerId, token }), + }); + const result = await response.json().catch(() => ({})); + if (!response.ok || result.error) { + throw new Error(result.error || 'Could not save the credential'); + } + // Never keep the secret in the DOM after it has been sent. + if (input) input.value = ''; + showToast(token.trim() ? 'Credential saved.' : 'Credential cleared.', 'success'); + await loadMediaInfo(); + renderAdmin(); + } catch (error) { + showToast(error.message, 'error'); + } +} + +function renderMediaInfoProgress(job) { + const wrapper = document.createElement('div'); + wrapper.className = 'mediainfo-progress'; + + const done = job.total > 0 ? Math.round((job.processed / job.total) * 100) : 0; + + const bar = document.createElement('div'); + bar.className = 'mediainfo-bar'; + const fill = document.createElement('div'); + fill.className = 'mediainfo-bar-fill'; + fill.style.width = done + '%'; + bar.appendChild(fill); + wrapper.appendChild(bar); + + const line = document.createElement('div'); + line.className = 'mediainfo-counts'; + const counts = [ + job.processed + ' of ' + job.total + ' checked', + job.matched + ' matched', + job.low_confidence + ' uncertain', + job.failed + ' failed', + ]; + for (const text of counts) { + const span = document.createElement('span'); + span.textContent = text; + line.appendChild(span); + } + wrapper.appendChild(line); + + if (job.running && job.current) { + const current = document.createElement('div'); + current.className = 'admin-section-blurb admin-mono'; + current.textContent = job.current; + wrapper.appendChild(current); + } + + if (!job.running && job.finished_at) { + const summary = document.createElement('p'); + summary.className = 'admin-section-blurb'; + summary.textContent = job.cancelled + ? 'Last run was cancelled.' + : 'Last run finished.'; + wrapper.appendChild(summary); + } + + if (job.last_error) { + const error = document.createElement('p'); + error.className = 'admin-section-blurb mediainfo-error'; + error.textContent = 'Most recent error: ' + job.last_error; + wrapper.appendChild(error); + } + + return wrapper; +} + +function renderMediaInfoFlagged(flagged) { + const details = document.createElement('details'); + details.className = 'mediainfo-flagged'; + + const summary = document.createElement('summary'); + summary.textContent = flagged.length + ' uncertain match' + (flagged.length === 1 ? '' : 'es'); + details.appendChild(summary); + + const list = document.createElement('dl'); + list.className = 'admin-runtime'; + for (const item of flagged) { + const term = document.createElement('dt'); + term.textContent = item.filename || ('#' + item.media_file_id); + const value = document.createElement('dd'); + value.textContent = + (item.matched_title || 'no match') + ' — ' + item.confidence + '% via ' + item.provider; + list.append(term, value); + } + details.appendChild(list); + return details; +} + +async function startMediaInfoFetch() { + if (mediaInfoBusy) return; + mediaInfoBusy = true; + try { + const response = await fetch('/api/admin/mediainfo/run', { method: 'POST' }); + const result = await response.json().catch(() => ({})); + if (!response.ok || result.error) { + throw new Error(result.error || 'Could not start the fetch'); + } + if (result.total === 0) { + showToast('Everything in the library already has media info.', 'info'); + } else { + showToast('Fetching media info for ' + result.total + ' items.', 'info'); + } + await loadMediaInfo(); + renderAdmin(); + startMediaInfoPolling(); + } catch (error) { + showToast(error.message, 'error'); + } finally { + mediaInfoBusy = false; + } +} + +async function cancelMediaInfoFetch() { + try { + const response = await fetch('/api/admin/mediainfo/cancel', { method: 'POST' }); + const result = await response.json().catch(() => ({})); + if (!response.ok || result.error) { + throw new Error(result.error || 'Could not cancel the fetch'); + } + // The run stops between items, so the counters keep moving briefly. + showToast('Stopping after the item in flight.', 'info'); + await loadMediaInfo(); + renderAdmin(); + } catch (error) { + showToast(error.message, 'error'); + } +} + +function renderMediaInfoPanel(body) { + if (!mediaInfoData) { + const loading = document.createElement('div'); + loading.className = 'admin-loading'; + loading.textContent = 'Loading providers…'; + body.appendChild(loading); + return; + } + + const job = mediaInfoData.job || {}; + const stats = mediaInfoData.stats || {}; + + const heading = document.createElement('div'); + heading.className = 'admin-section-title'; + heading.textContent = 'Providers'; + body.appendChild(heading); + + // Grouped by what they cover, so the three domains read as three lists rather + // than one alphabetical run of ten names. + const groups = new Map(); + for (const provider of mediaInfoData.providers || []) { + if (!groups.has(provider.group)) groups.set(provider.group, []); + groups.get(provider.group).push(provider); + } + for (const [group, providers] of groups) { + const label = document.createElement('div'); + label.className = 'mediainfo-group'; + label.textContent = group; + body.appendChild(label); + for (const provider of providers) { + body.appendChild(renderProviderRow(provider)); + } + } + + const actionsHeading = document.createElement('div'); + actionsHeading.className = 'admin-section-title'; + actionsHeading.textContent = 'Fetch'; + body.appendChild(actionsHeading); + + const summary = document.createElement('p'); + summary.className = 'admin-section-blurb'; + summary.textContent = + (stats.total || 0) + ' items have media info, ' + + (stats.low_confidence || 0) + ' of them uncertain, ' + + (stats.with_artwork || 0) + ' with artwork.'; + body.appendChild(summary); + + const actions = document.createElement('div'); + actions.className = 'mediainfo-actions'; + + const fetchButton = document.createElement('button'); + fetchButton.type = 'button'; + fetchButton.className = 'admin-btn admin-btn-primary'; + fetchButton.textContent = 'Fetch media info for entire library'; + fetchButton.disabled = job.running || !mediaInfoData.enabled; + fetchButton.onclick = startMediaInfoFetch; + actions.appendChild(fetchButton); + + if (job.running) { + const cancelButton = document.createElement('button'); + cancelButton.type = 'button'; + cancelButton.className = 'admin-btn admin-btn-danger'; + cancelButton.textContent = 'Cancel'; + cancelButton.onclick = cancelMediaInfoFetch; + actions.appendChild(cancelButton); + } + body.appendChild(actions); + + if (!mediaInfoData.enabled) { + const off = document.createElement('p'); + off.className = 'admin-section-blurb mediainfo-error'; + off.textContent = + 'Turn on "Enable online lookups" above and save before fetching.'; + body.appendChild(off); + } + + if (job.total > 0 || job.running) { + body.appendChild(renderMediaInfoProgress(job)); + } + + const flagged = mediaInfoData.flagged || []; + if (flagged.length > 0) { + body.appendChild(renderMediaInfoFlagged(flagged)); + } + + // Survives a re-render: switching away from the tab and back while a run is + // going should pick the polling back up. + if (job.running && !mediaInfoPollTimer) startMediaInfoPolling(); +} diff --git a/crates/vuio-core/src/web/ui/js/nav.js b/crates/vuio-core/src/web/ui/js/nav.js index 7ff449e4..0e66224e 100644 --- a/crates/vuio-core/src/web/ui/js/nav.js +++ b/crates/vuio-core/src/web/ui/js/nav.js @@ -24,7 +24,13 @@ const NAV_VIEWS = { } }, }, - admin: { display: 'flex', enter: () => loadAdminConfig() }, + // The media info fetch keeps running on the server after the tab is left; only + // the polling that draws its progress stops. + admin: { + display: 'flex', + enter: () => loadAdminConfig(), + leave: () => stopMediaInfoPolling(), + }, }; function switchNav(nav) { diff --git a/crates/vuio-core/src/web/xml/rendering.rs b/crates/vuio-core/src/web/xml/rendering.rs index a6821ab9..fe87491b 100644 --- a/crates/vuio-core/src/web/xml/rendering.rs +++ b/crates/vuio-core/src/web/xml/rendering.rs @@ -143,6 +143,27 @@ pub struct BrowseRenderContext { pub autoplay_enabled: bool, pub update_id: u32, pub bookmarks: HashMap, + /// Whether a fetched title outranks the one read from the file's own tags. + pub prefer_online_titles: bool, + /// Matches weaker than this are left out of `mediainfo` entirely. + pub min_confidence: u8, + /// Fetched media info for the items on this page, filled in by the response + /// generators just before rendering. Empty when nothing has been fetched. + pub mediainfo: HashMap, +} + +impl BrowseRenderContext { + /// The fetched title to show for `file_id`, if there is one and it is wanted. + fn online_title(&self, file_id: i64) -> Option<&str> { + if !self.prefer_online_titles { + return None; + } + self.mediainfo + .get(&file_id)? + .title + .as_deref() + .filter(|title| !title.is_empty()) + } } /// UPnP container classes. @@ -293,7 +314,14 @@ pub(super) fn write_media_view( let mime = file.mime_type(); let is_radio = mime == "audio/radio"; let has_srt = file.subtitle_available(); - let title = didl_display_title(file.title(), file.filename(), context.client); + // A fetched title is the readable one — "Arrival" rather than + // "Arrival.2016.1080p.BluRay.x264-GRP" — so it wins when there is one and the + // operator asked for it. + let title = didl_display_title( + context.online_title(file_id).or_else(|| file.title()), + file.filename(), + context.client, + ); write!( output, r#"{}"#, @@ -306,6 +334,32 @@ pub(super) fn write_media_view( } output.write_str("")?; + // Synopsis and genres from the fetch. Video had neither before: nothing read + // metadata out of a video file, so a TV showed a filename and nothing else. + if let Some(overlay) = context.mediainfo.get(&file_id) { + if let Some(overview) = overlay.overview.as_deref().filter(|text| !text.is_empty()) { + write!( + output, + "{}", + xml_escape(overview) + )?; + } + if !mime.starts_with("audio/") { + if let Some(genre) = overlay.genres.first() { + write!(output, "{}", xml_escape(genre))?; + } + // Audio already advertises its cover below; this is what gives a movie + // or an episode a poster for the first time. + if overlay.has_artwork { + write!( + output, + "http://{}:{}/media/{}/cover", + context.server_ip, context.server_port, file_id + )?; + } + } + } + if mime.starts_with("audio/") { if let Some(value) = file.artist() { write!(output, "{}", xml_escape(value))?; @@ -464,6 +518,40 @@ pub(super) fn write_media_view( output.write_str("") } +/// Load the fetched media info for the page about to be rendered. +/// +/// A first pass collects the ids, because the writer cannot ask the session for +/// anything while the session is lending it a row. Both passes run the same +/// indexed query on the same connection, and the first does no formatting, so the +/// cost is one extra index walk rather than a second round trip. +/// +/// A failure here is not worth failing a browse over: the page renders with local +/// metadata, exactly as it did before this feature existed. +fn with_mediainfo( + session: &mut S, + query: &MediaFileQuery, + offset: usize, + limit: usize, + mut context: BrowseRenderContext, +) -> Result { + if limit == 0 { + return Ok(context); + } + let mut ids = Vec::with_capacity(limit); + session.visit_files(query, offset, limit, |file| { + if let Some(id) = file.id().filter(|id| *id > 0) { + ids.push(id); + } + Ok(()) + })?; + + match session.mediainfo_overlays(&ids, context.min_confidence) { + Ok(overlays) => context.mediainfo = overlays, + Err(error) => tracing::debug!(%error, "Could not load media info for this page"), + } + Ok(context) +} + pub fn generate_indexed_browse_response( session: &mut S, canonical_parent: &str, @@ -506,6 +594,7 @@ pub fn generate_indexed_browse_response( path: canonical_parent.to_owned(), mime_family: (!mime_family.is_empty()).then(|| mime_family.to_owned()), }; + let context = with_mediainfo(session, &query, file_offset, file_limit, context)?; let summary = session.visit_files(&query, file_offset, file_limit, |file| { write_media_view(&mut result, object_id, &file, &context) .map_err(|_| anyhow::anyhow!("failed to construct browse XML")) @@ -531,6 +620,7 @@ pub fn generate_indexed_items_response( "#)?; let mut result = SoapResultWriter(&mut response); result.push_str(r#""#); + let context = with_mediainfo(session, &query, starting_index, requested_count, context)?; let summary = session.visit_files(&query, starting_index, requested_count, |file| { write_media_view(&mut result, object_id, &file, &context) .map_err(|_| anyhow::anyhow!("failed to construct browse XML")) diff --git a/crates/vuio-core/tests/audio_integration_tests.rs b/crates/vuio-core/tests/audio_integration_tests.rs index 970b9998..f514c2d9 100644 --- a/crates/vuio-core/tests/audio_integration_tests.rs +++ b/crates/vuio-core/tests/audio_integration_tests.rs @@ -339,6 +339,8 @@ async fn test_cover_art_retrieval_and_xml() { active_casts: Arc::new(tokio::sync::Mutex::new( vuio_core::runtime_state::ActiveCastRegistry::new(), )), + #[cfg(feature = "mediainfo")] + mediainfo_job: Arc::new(tokio::sync::Mutex::new(Default::default())), discovered_tvs: Arc::new(vuio_core::runtime_state::RendererCache::new()), upnp_subscriptions: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), cancellation: tokio_util::sync::CancellationToken::new(), @@ -504,6 +506,8 @@ https://cast1.asurahosting.com/proxy/julien/stream active_casts: Arc::new(tokio::sync::Mutex::new( vuio_core::runtime_state::ActiveCastRegistry::new(), )), + #[cfg(feature = "mediainfo")] + mediainfo_job: Arc::new(tokio::sync::Mutex::new(Default::default())), discovered_tvs: Arc::new(vuio_core::runtime_state::RendererCache::new()), upnp_subscriptions: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), cancellation: tokio_util::sync::CancellationToken::new(), diff --git a/crates/vuio-core/tests/issue_24_pagination.rs b/crates/vuio-core/tests/issue_24_pagination.rs index e7509f82..0d356651 100644 --- a/crates/vuio-core/tests/issue_24_pagination.rs +++ b/crates/vuio-core/tests/issue_24_pagination.rs @@ -140,6 +140,8 @@ async fn issue_24_philips_probe_reports_full_total_and_supports_followup_pages() mcp_clients: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), active_monitors: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), active_casts: Arc::new(tokio::sync::Mutex::new(ActiveCastRegistry::new())), + #[cfg(feature = "mediainfo")] + mediainfo_job: Arc::new(tokio::sync::Mutex::new(Default::default())), discovered_tvs: Arc::new(RendererCache::new()), upnp_subscriptions: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), cancellation: tokio_util::sync::CancellationToken::new(), @@ -243,6 +245,8 @@ async fn dlna_browse_returns_naturally_sorted_episodes() { mcp_clients: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), active_monitors: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), active_casts: Arc::new(tokio::sync::Mutex::new(ActiveCastRegistry::new())), + #[cfg(feature = "mediainfo")] + mediainfo_job: Arc::new(tokio::sync::Mutex::new(Default::default())), discovered_tvs: Arc::new(RendererCache::new()), upnp_subscriptions: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), cancellation: tokio_util::sync::CancellationToken::new(), diff --git a/crates/vuio-core/tests/mcp_integration_tests.rs b/crates/vuio-core/tests/mcp_integration_tests.rs index 49b02601..1db2bcd4 100644 --- a/crates/vuio-core/tests/mcp_integration_tests.rs +++ b/crates/vuio-core/tests/mcp_integration_tests.rs @@ -92,6 +92,8 @@ async fn make_test_state() -> (TempDir, AppState) { active_casts: Arc::new(tokio::sync::Mutex::new( vuio_core::runtime_state::ActiveCastRegistry::new(), )), + #[cfg(feature = "mediainfo")] + mediainfo_job: Arc::new(tokio::sync::Mutex::new(Default::default())), discovered_tvs: Arc::new(vuio_core::runtime_state::RendererCache::new()), upnp_subscriptions: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), cancellation: tokio_util::sync::CancellationToken::new(), @@ -215,6 +217,8 @@ async fn test_mcp_initialize_and_tools_list() { active_casts: Arc::new(tokio::sync::Mutex::new( vuio_core::runtime_state::ActiveCastRegistry::new(), )), + #[cfg(feature = "mediainfo")] + mediainfo_job: Arc::new(tokio::sync::Mutex::new(Default::default())), discovered_tvs: Arc::new(vuio_core::runtime_state::RendererCache::new()), upnp_subscriptions: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), cancellation: tokio_util::sync::CancellationToken::new(), diff --git a/crates/vuio-core/tests/mediainfo_integration_tests.rs b/crates/vuio-core/tests/mediainfo_integration_tests.rs new file mode 100644 index 00000000..6440727d --- /dev/null +++ b/crates/vuio-core/tests/mediainfo_integration_tests.rs @@ -0,0 +1,574 @@ +//! End-to-end coverage for online media info: storage, the endpoints, and the +//! two properties the feature would be quietly broken without — that a rescan +//! does not destroy fetched records, and that a saved credential never comes back +//! out of the API. +//! +//! Parser and scorer cases live beside the code in `src/mediainfo/matching.rs`. + +#![cfg(feature = "mediainfo")] + +use axum::body::Body; +use axum::extract::ConnectInfo; +use axum::http::{Request, StatusCode}; +use std::net::SocketAddr; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::SystemTime; +use tempfile::{tempdir, TempDir}; +use tower::ServiceExt; + +use vuio_core::config::{AppConfig, MonitoredDirectoryConfig, ValidationMode}; +use vuio_core::database::sqlite::SqliteDatabase; +use vuio_core::database::{ + DatabaseManager, MediaFile, MediaInfoRecord, MediaInfoRepository, MediaRepository, +}; +use vuio_core::platform::filesystem::create_platform_filesystem_manager; +use vuio_core::platform::PlatformInfo; +use vuio_core::state::AppState; +use vuio_core::web::create_router; + +/// The fixed token `AuthState::testing()` accepts. +const TEST_TOKEN: &str = "test-management-token-which-is-long-enough"; + +fn test_peer() -> SocketAddr { + "127.0.0.1:54321".parse().unwrap() +} + +fn record_for(media_file_id: i64, confidence: u8) -> MediaInfoRecord { + MediaInfoRecord { + media_file_id, + provider: "tvmaze".to_string(), + remote_id: "143".to_string(), + kind: "series".to_string(), + title: Some("Some Show".to_string()), + original_title: None, + overview: Some("A tale.".to_string()), + release_date: Some("2011-04-17".to_string()), + year: Some(2011), + rating: Some(8.9), + genres: vec!["Drama".to_string(), "Fantasy".to_string()], + season: Some(2), + episode: Some(5), + artwork_key: Some("abcdef0123456789".to_string()), + payload: r#"{"id":143}"#.to_string(), + confidence, + fetched_at: SystemTime::now(), + mediainfo_version: 1, + } +} + +async fn database_with_one_file() -> (Arc, i64, TempDir) { + let temp = tempdir().unwrap(); + let database = Arc::new(SqliteDatabase::new(temp.path().join("test.db")).await.unwrap()); + database.initialize().await.unwrap(); + let file = MediaFile::new( + PathBuf::from("/media/Show.Name.S02E05.1080p.mkv"), + 1024, + "video/x-matroska".to_string(), + ); + let id = database.store_media_file(&file).await.unwrap(); + (database, id, temp) +} + +#[tokio::test] +async fn a_record_round_trips_through_the_database() { + let (database, id, _temp) = database_with_one_file().await; + + database + .bulk_store_mediainfo(&[record_for(id, 92)]) + .await + .unwrap(); + + let stored = database.get_mediainfo(id).await.unwrap().unwrap(); + assert_eq!(stored.title.as_deref(), Some("Some Show")); + assert_eq!(stored.year, Some(2011)); + assert_eq!(stored.season, Some(2)); + assert_eq!(stored.episode, Some(5)); + assert_eq!(stored.confidence, 92); + // Genres go in as a JSON array and have to come back as a list, not a string. + assert_eq!(stored.genres, vec!["Drama", "Fantasy"]); + assert_eq!(stored.rating, Some(8.9)); + + let batch = database.get_mediainfo_batch(&[id, 9999]).await.unwrap(); + assert_eq!(batch.len(), 1); + assert_eq!(batch[0].media_file_id, id); +} + +#[tokio::test] +async fn storing_the_same_file_twice_replaces_rather_than_duplicates() { + let (database, id, _temp) = database_with_one_file().await; + + database.bulk_store_mediainfo(&[record_for(id, 40)]).await.unwrap(); + let mut better = record_for(id, 95); + better.title = Some("A Better Match".to_string()); + database.bulk_store_mediainfo(&[better]).await.unwrap(); + + let stored = database.get_mediainfo(id).await.unwrap().unwrap(); + assert_eq!(stored.confidence, 95); + assert_eq!(stored.title.as_deref(), Some("A Better Match")); + let stats = database.mediainfo_stats(60).await.unwrap(); + assert_eq!(stats.total, 1); +} + +#[tokio::test] +async fn stats_and_the_flagged_list_split_on_the_threshold() { + let temp = tempdir().unwrap(); + let database = Arc::new(SqliteDatabase::new(temp.path().join("t.db")).await.unwrap()); + database.initialize().await.unwrap(); + + let mut ids = Vec::new(); + for (index, confidence) in [95_u8, 80, 30, 10].iter().enumerate() { + let file = MediaFile::new( + PathBuf::from(format!("/media/file{index}.mkv")), + 1024, + "video/x-matroska".to_string(), + ); + let id = database.store_media_file(&file).await.unwrap(); + database + .bulk_store_mediainfo(&[record_for(id, *confidence)]) + .await + .unwrap(); + ids.push(id); + } + + let stats = database.mediainfo_stats(60).await.unwrap(); + assert_eq!(stats.total, 4); + assert_eq!(stats.confident, 2); + assert_eq!(stats.low_confidence, 2); + assert_eq!(stats.with_artwork, 4); + + // Least certain first, so the worst matches are the ones on screen. + let flagged = database.list_low_confidence(60, 10).await.unwrap(); + assert_eq!(flagged.len(), 2); + assert_eq!(flagged[0].confidence, 10); + assert_eq!(flagged[1].confidence, 30); +} + +#[tokio::test] +async fn work_is_whatever_is_missing_stale_or_not_good_enough() { + let (database, id, _temp) = database_with_one_file().await; + + // Never looked up. + assert_eq!( + database.media_ids_missing_mediainfo(1, 60).await.unwrap(), + vec![id] + ); + + // A good match is done. + database.bulk_store_mediainfo(&[record_for(id, 90)]).await.unwrap(); + assert!(database + .media_ids_missing_mediainfo(1, 60) + .await + .unwrap() + .is_empty()); + + // Raising the threshold past it puts it back in the queue. + assert_eq!( + database.media_ids_missing_mediainfo(1, 95).await.unwrap(), + vec![id] + ); + + // So does bumping the reader version, which is the whole point of storing it. + assert_eq!( + database.media_ids_missing_mediainfo(2, 60).await.unwrap(), + vec![id] + ); +} + +#[tokio::test] +async fn a_rescan_does_not_wipe_fetched_media_info() { + // The reason this lives in its own table rather than `media_tags`, which is + // cleared and rewritten every time a record is re-scanned. If a scan could + // destroy this, every run of the fetch would have to start over. + let (database, id, _temp) = database_with_one_file().await; + database.bulk_store_mediainfo(&[record_for(id, 88)]).await.unwrap(); + + let mut rescanned = MediaFile::new( + PathBuf::from("/media/Show.Name.S02E05.1080p.mkv"), + 4096, + "video/x-matroska".to_string(), + ); + rescanned.title = Some("Re-read from the file".to_string()); + database.store_media_file(&rescanned).await.unwrap(); + + let stored = database.get_mediainfo(id).await.unwrap(); + assert!( + stored.is_some(), + "a rescan destroyed the fetched media info" + ); + assert_eq!(stored.unwrap().confidence, 88); +} + +#[tokio::test] +async fn removing_a_file_takes_its_media_info_with_it() { + let (database, id, _temp) = database_with_one_file().await; + database.bulk_store_mediainfo(&[record_for(id, 88)]).await.unwrap(); + + database + .remove_media_file(&PathBuf::from("/media/Show.Name.S02E05.1080p.mkv")) + .await + .unwrap(); + + assert!(database.get_mediainfo(id).await.unwrap().is_none()); +} + +#[tokio::test] +async fn clearing_forgets_everything() { + let (database, id, _temp) = database_with_one_file().await; + database.bulk_store_mediainfo(&[record_for(id, 88)]).await.unwrap(); + + assert_eq!(database.clear_mediainfo().await.unwrap(), 1); + assert!(database.get_mediainfo(id).await.unwrap().is_none()); +} + +// ── Endpoints ────────────────────────────────────────────────────────────── + +async fn state_with(database: Arc, temp: &TempDir) -> AppState { + let media_path = temp.path().join("media"); + tokio::fs::create_dir_all(&media_path).await.unwrap(); + let mut config = AppConfig::default(); + config.media.directories = vec![MonitoredDirectoryConfig { + path: media_path.to_string_lossy().into_owned(), + recursive: true, + case_sensitive: None, + extensions: None, + exclude_patterns: None, + validation_mode: ValidationMode::Warn, + }]; + config.mediainfo.enabled = true; + let config = Arc::new(config); + + AppState { + media_directories: Arc::new(tokio::sync::RwLock::new(config.media.directories.clone())), + unavailable_roots: Arc::new(tokio::sync::RwLock::new(std::collections::HashSet::new())), + config: config.clone(), + config_source: Arc::new(vuio_core::state::ConfigSource::default()), + http_binding: Arc::new(vuio_core::state::HttpBinding::new(8080)), + live_config: Arc::new(vuio_core::state::LiveConfig::new(config.clone())), + database, + auth: Arc::new(vuio_core::web::auth::AuthState::testing()), + platform_info: Arc::new(PlatformInfo::detect().await.unwrap()), + filesystem_manager: Arc::from(create_platform_filesystem_manager()), + content_update_id: Arc::new(std::sync::atomic::AtomicU32::new(1)), + web_metrics: Arc::new(vuio_core::web::diagnostics::WebHandlerMetrics::new()), + runtime_diagnostics: Arc::new( + vuio_core::platform::diagnostics::SystemDiagnosticsSampler::new(), + ), + lifecycle_stats: Arc::new(vuio_core::lifecycle::ApplicationStats::new()), + bookmarks: Arc::new(tokio::sync::Mutex::new( + vuio_core::runtime_state::BookmarkRegistry::new( + vuio_core::runtime_state::BOOKMARK_MAX_ENTRIES, + ), + )), + log_file_path: temp.path().join("vuio.log"), + browse_cache: Arc::new(tokio::sync::Mutex::new( + vuio_core::runtime_state::BrowseResponseCache::new(), + )), + mcp_clients: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), + active_monitors: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), + active_casts: Arc::new(tokio::sync::Mutex::new( + vuio_core::runtime_state::ActiveCastRegistry::new(), + )), + mediainfo_job: Arc::new(tokio::sync::Mutex::new(Default::default())), + #[cfg(feature = "casting")] + discovered_tvs: Arc::new(vuio_core::runtime_state::RendererCache::new()), + upnp_subscriptions: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), + cancellation: tokio_util::sync::CancellationToken::new(), + background_tasks: tokio_util::task::TaskTracker::new(), + } +} + +async fn json_of(response: axum::response::Response) -> serde_json::Value { + let bytes = axum::body::to_bytes(response.into_body(), 4 * 1024 * 1024) + .await + .unwrap(); + serde_json::from_slice(&bytes).unwrap() +} + +fn authed(method: &str, uri: &str, body: Option<&str>) -> Request { + let mut builder = Request::builder() + .method(method) + .uri(uri) + .extension(ConnectInfo(test_peer())) + .header("authorization", format!("Bearer {TEST_TOKEN}")); + if body.is_some() { + builder = builder.header("content-type", "application/json"); + } + builder + .body(body.map(|body| Body::from(body.to_owned())).unwrap_or(Body::empty())) + .unwrap() +} + +#[tokio::test] +async fn the_status_endpoint_lists_every_provider() { + let (database, _id, temp) = database_with_one_file().await; + let router = create_router(state_with(database, &temp).await); + + let response = router + .oneshot(authed("GET", "/api/admin/mediainfo", None)) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + + let body = json_of(response).await; + let providers = body["providers"].as_array().unwrap(); + assert_eq!(providers.len(), 10); + + let by_id = |id: &str| { + providers + .iter() + .find(|provider| provider["id"] == id) + .unwrap_or_else(|| panic!("{id} missing")) + .clone() + }; + // The key-free ones are usable and on out of the box. + assert_eq!(by_id("tvmaze")["needs_credential"], false); + assert_eq!(by_id("tvmaze")["enabled"], true); + // The rest are listed but idle until a credential is saved. + assert_eq!(by_id("tmdb")["needs_credential"], true); + assert_eq!(by_id("tmdb")["has_credential"], false); + assert_eq!(by_id("tmdb")["enabled"], false); + + assert_eq!(body["job"]["running"], false); + assert_eq!(body["stats"]["total"], 0); +} + +#[tokio::test] +async fn a_saved_credential_is_never_returned_by_the_api() { + let (database, _id, temp) = database_with_one_file().await; + let router = create_router(state_with(database, &temp).await); + + let response = router + .clone() + .oneshot(authed( + "POST", + "/api/admin/mediainfo/credentials", + Some(r#"{"provider":"tmdb","token":"super-secret-key"}"#), + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(json_of(response).await["has_credential"], true); + + let response = router + .oneshot(authed("GET", "/api/admin/mediainfo", None)) + .await + .unwrap(); + let body = json_of(response).await; + + // The whole document, not just the field we remembered to check: a token + // leaking through any key at all is the failure worth catching. + let serialized = serde_json::to_string(&body).unwrap(); + assert!( + !serialized.contains("super-secret-key"), + "the status endpoint echoed a stored credential back" + ); + + let tmdb = body["providers"] + .as_array() + .unwrap() + .iter() + .find(|provider| provider["id"] == "tmdb") + .unwrap() + .clone(); + assert_eq!(tmdb["has_credential"], true); +} + +#[tokio::test] +async fn an_empty_token_clears_the_stored_one() { + let (database, _id, temp) = database_with_one_file().await; + let router = create_router(state_with(database, &temp).await); + + router + .clone() + .oneshot(authed( + "POST", + "/api/admin/mediainfo/credentials", + Some(r#"{"provider":"omdb","token":"a-key"}"#), + )) + .await + .unwrap(); + let response = router + .clone() + .oneshot(authed( + "POST", + "/api/admin/mediainfo/credentials", + Some(r#"{"provider":"omdb","token":""}"#), + )) + .await + .unwrap(); + assert_eq!(json_of(response).await["has_credential"], false); + + let body = json_of( + router + .oneshot(authed("GET", "/api/admin/mediainfo", None)) + .await + .unwrap(), + ) + .await; + let omdb = body["providers"] + .as_array() + .unwrap() + .iter() + .find(|provider| provider["id"] == "omdb") + .unwrap() + .clone(); + assert_eq!(omdb["has_credential"], false); +} + +#[tokio::test] +async fn a_credential_for_an_unknown_or_keyless_provider_is_rejected() { + let (database, _id, temp) = database_with_one_file().await; + let router = create_router(state_with(database, &temp).await); + + let response = router + .clone() + .oneshot(authed( + "POST", + "/api/admin/mediainfo/credentials", + Some(r#"{"provider":"nope","token":"x"}"#), + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + // TVmaze needs no account, so offering it a key is a mistake worth reporting + // rather than a value to store and never use. + let response = router + .oneshot(authed( + "POST", + "/api/admin/mediainfo/credentials", + Some(r#"{"provider":"tvmaze","token":"x"}"#), + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn cancelling_when_nothing_is_running_is_a_conflict() { + let (database, _id, temp) = database_with_one_file().await; + let router = create_router(state_with(database, &temp).await); + + let response = router + .oneshot(authed("POST", "/api/admin/mediainfo/cancel", None)) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::CONFLICT); +} + +#[tokio::test] +async fn running_with_the_feature_turned_off_is_refused() { + let (database, _id, temp) = database_with_one_file().await; + let mut state = state_with(database, &temp).await; + let mut config = (*state.config).clone(); + config.mediainfo.enabled = false; + let config = Arc::new(config); + state.config = config.clone(); + state.live_config = Arc::new(vuio_core::state::LiveConfig::new(config)); + + let response = create_router(state) + .oneshot(authed("POST", "/api/admin/mediainfo/run", None)) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::CONFLICT); +} + +#[tokio::test] +async fn the_endpoints_require_management_auth() { + let (database, _id, temp) = database_with_one_file().await; + let router = create_router(state_with(database, &temp).await); + + let response = router + .oneshot( + Request::builder() + .method("GET") + .uri("/api/admin/mediainfo") + .extension(ConnectInfo(test_peer())) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn browse_json_carries_the_fetched_title_and_synopsis() { + let (database, id, temp) = database_with_one_file().await; + database.bulk_store_mediainfo(&[record_for(id, 92)]).await.unwrap(); + + let router = create_router(state_with(database, &temp).await); + let response = router + .oneshot(authed("GET", "/api/media", None)) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + + let body = json_of(response).await; + let file = &body["files"][0]; + assert_eq!(file["info_title"], "Some Show"); + assert_eq!(file["info_overview"], "A tale."); + assert_eq!(file["info_art"], true); +} + +#[tokio::test] +async fn an_uncertain_match_is_stored_but_never_shown() { + // Searching TVmaze for "Arrival" returns the series "Dead on Arrival". It is + // kept so the operator can see what happened, but relabelling the film with it + // would be worse than showing the filename. + let (database, id, temp) = database_with_one_file().await; + let mut weak = record_for(id, 5); + weak.title = Some("Dead on Arrival".to_string()); + database.bulk_store_mediainfo(&[weak]).await.unwrap(); + + // Still on record. + assert_eq!( + database.get_mediainfo(id).await.unwrap().unwrap().confidence, + 5 + ); + + let router = create_router(state_with(database, &temp).await); + let body = json_of( + router + .clone() + .oneshot(authed("GET", "/api/media", None)) + .await + .unwrap(), + ) + .await; + let file = &body["files"][0]; + assert!( + file["info_title"].is_null(), + "a match below the threshold was shown as the title" + ); + assert_eq!(file["info_art"], false); + + // And it is what the Admin tab lists for review. + let status = json_of( + router + .oneshot(authed("GET", "/api/admin/mediainfo", None)) + .await + .unwrap(), + ) + .await; + assert_eq!(status["flagged"][0]["confidence"], 5); + assert_eq!(status["flagged"][0]["matched_title"], "Dead on Arrival"); +} + +#[tokio::test] +async fn browse_json_reports_no_media_info_when_none_was_fetched() { + let (database, _id, temp) = database_with_one_file().await; + let router = create_router(state_with(database, &temp).await); + + let body = json_of( + router + .oneshot(authed("GET", "/api/media", None)) + .await + .unwrap(), + ) + .await; + let file = &body["files"][0]; + assert!(file["info_title"].is_null()); + assert_eq!(file["info_art"], false); +} diff --git a/crates/vuio-core/tests/metrics_integration_tests.rs b/crates/vuio-core/tests/metrics_integration_tests.rs index 3061ed0e..009ddde7 100644 --- a/crates/vuio-core/tests/metrics_integration_tests.rs +++ b/crates/vuio-core/tests/metrics_integration_tests.rs @@ -107,6 +107,8 @@ async fn test_metrics_endpoints_data() { active_casts: Arc::new(tokio::sync::Mutex::new( vuio_core::runtime_state::ActiveCastRegistry::new(), )), + #[cfg(feature = "mediainfo")] + mediainfo_job: Arc::new(tokio::sync::Mutex::new(Default::default())), discovered_tvs: Arc::new(vuio_core::runtime_state::RendererCache::new()), upnp_subscriptions: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), cancellation: tokio_util::sync::CancellationToken::new(), diff --git a/crates/vuio-core/tests/music_browse_integration_tests.rs b/crates/vuio-core/tests/music_browse_integration_tests.rs index b70d5a2d..5395761e 100644 --- a/crates/vuio-core/tests/music_browse_integration_tests.rs +++ b/crates/vuio-core/tests/music_browse_integration_tests.rs @@ -63,6 +63,8 @@ async fn make_test_state() -> (TempDir, AppState) { active_casts: Arc::new(tokio::sync::Mutex::new( vuio_core::runtime_state::ActiveCastRegistry::new(), )), + #[cfg(feature = "mediainfo")] + mediainfo_job: Arc::new(tokio::sync::Mutex::new(Default::default())), discovered_tvs: Arc::new(vuio_core::runtime_state::RendererCache::new()), upnp_subscriptions: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), cancellation: tokio_util::sync::CancellationToken::new(), diff --git a/crates/vuio-core/tests/samsungtv_browse.rs b/crates/vuio-core/tests/samsungtv_browse.rs index 919b4da7..c9c0ebf2 100644 --- a/crates/vuio-core/tests/samsungtv_browse.rs +++ b/crates/vuio-core/tests/samsungtv_browse.rs @@ -168,6 +168,8 @@ async fn samsungtv_state_with_video(temp: &tempfile::TempDir) -> AppState { mcp_clients: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), active_monitors: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), active_casts: Arc::new(tokio::sync::Mutex::new(ActiveCastRegistry::new())), + #[cfg(feature = "mediainfo")] + mediainfo_job: Arc::new(tokio::sync::Mutex::new(Default::default())), discovered_tvs: Arc::new(RendererCache::new()), upnp_subscriptions: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), cancellation: tokio_util::sync::CancellationToken::new(), diff --git a/docs/api.md b/docs/api.md index 7cbc02f9..c7954545 100644 --- a/docs/api.md +++ b/docs/api.md @@ -162,6 +162,81 @@ supervises it — Docker, systemd or launchd. * **Endpoint**: `POST /api/admin/restart` * **Response**: `202 Accepted` — `{"stopping": true, "supervised": false}` +### Online media info + +Fetches titles, synopses, ratings and artwork from public metadata services. This is the +only part of VuIO that contacts anything outside the local network, and it is only reached +by an explicit request to `/run`. Requires the `mediainfo` cargo feature (on by default) +and `mediainfo.enabled` in the configuration. + +Five providers answer without an account — `tvmaze`, `musicbrainz` (with Cover Art Archive +for artwork), `jikan`, `anilist` and `kitsu`. Five more work once a credential is saved: +`tmdb`, `omdb`, `discogs`, `lastfm` and `genius`. Requests are paced per provider to the +rate limits each publisher documents; MusicBrainz's one-per-second ceiling makes a large +music library slow by design. + +#### Status and progress +* **Endpoint**: `GET /api/admin/mediainfo` +* **Response**: `200 OK` + ```jsonc + { + "enabled": true, + "min_confidence": 60, + "providers": [ + { + "id": "tmdb", "label": "TheMovieDB", "group": "Movies & TV", + "provides": "Movies, TV, posters, trailers and ratings.", + "credential_label": "API key", + "signup_url": "https://developer.themoviedb.org", + "needs_credential": true, + "has_credential": true, // whether one is stored — never the value + "enabled": true // whether it is in mediainfo.providers + } + ], + "job": { + "running": true, "total": 1240, "processed": 318, + "matched": 290, "low_confidence": 22, "failed": 6, + "cancelled": false, "current": "Arrival.2016.1080p.mkv", + "started_at": 1765000000 + }, + "stats": { "total": 318, "confident": 290, "low_confidence": 28, "with_artwork": 271 }, + "flagged": [ + { "media_file_id": 91, "confidence": 35, "provider": "tvmaze", + "matched_title": "Some Show", "filename": "unknown.s01e02.mkv" } + ] + } + ``` + Stored credentials are never returned by this or any other endpoint; `has_credential` + is the only thing reported about them. + +#### Save or clear a provider credential +* **Endpoint**: `POST /api/admin/mediainfo/credentials` +* **Body**: `{"provider": "tmdb", "token": "…"}` — an empty `token` clears the stored one. +* **Response**: `200 OK` — `{"saved": true, "has_credential": true}` +* **Errors**: `400 Bad Request` for an unknown provider, or for one that needs no account. + +Credentials are kept in the database's `secrets` table rather than `config.toml`. Under +Docker the configuration is built from environment variables and `PUT /api/admin/config` +returns `409`, so a credential in the file would be unsettable in exactly the deployment +most likely to need one. + +#### Start a library fetch +Walks every file with no usable record — never looked up, looked up by an older reader +version, or matched too weakly to trust — and returns as soon as the run is scheduled. +* **Endpoint**: `POST /api/admin/mediainfo/run` +* **Response**: `200 OK` — `{"started": true, "total": 1240}` +* **Errors**: `409 Conflict` if a run is already going, if the feature is off, or if no + provider is enabled. + +#### Cancel a running fetch +Stops after the item currently in flight; whatever was already matched stays. +* **Endpoint**: `POST /api/admin/mediainfo/cancel` +* **Response**: `200 OK` — `{"cancelled": true}` +* **Errors**: `409 Conflict` when nothing is running. + +When a run finishes it publishes a ContentDirectory revision, so DLNA clients and the +dashboard both pick up the new titles, synopses and artwork without further prompting. + --- ## 2. Media Streaming APIs @@ -176,6 +251,16 @@ Streams the requested media file. Supports HTTP range requests (essential for sc - `Accept-Ranges`: `bytes` - `TransferMode.dlna.org`: `Streaming` +### Serve Cover Art +Returns artwork for an item, trying three sources in order: an image file sitting beside +the media (`cover.jpg`, `folder.png`, `.webp`, …), artwork embedded in the file's +own tags, and finally a poster cached by the media info fetch. The local sources apply to +audio only; video reaches this endpoint through the cache, which is what gives a movie or +an episode a poster at all. +* **Endpoint**: `GET /media/{id}/cover` +* **Response**: `200 OK` with the image, or `404 Not Found` when no source has one. +* Also advertised to DLNA clients as `upnp:albumArtURI`. + ### Serve Subtitles Serves the sidecar subtitle track (`.srt`) if one exists, in either of two formats. Both return `404 Not Found` when there is no sidecar file. From a540ec997b31692d5dd573cea55cc877d3183aba Mon Sep 17 00:00:00 2001 From: vyrti Date: Thu, 13 Aug 2026 13:14:03 +0300 Subject: [PATCH 2/2] style(mediainfo): apply rustfmt to the integration tests --- .../tests/mediainfo_integration_tests.rs | 48 +++++++++++++++---- 1 file changed, 39 insertions(+), 9 deletions(-) diff --git a/crates/vuio-core/tests/mediainfo_integration_tests.rs b/crates/vuio-core/tests/mediainfo_integration_tests.rs index 6440727d..1100bf8b 100644 --- a/crates/vuio-core/tests/mediainfo_integration_tests.rs +++ b/crates/vuio-core/tests/mediainfo_integration_tests.rs @@ -59,7 +59,11 @@ fn record_for(media_file_id: i64, confidence: u8) -> MediaInfoRecord { async fn database_with_one_file() -> (Arc, i64, TempDir) { let temp = tempdir().unwrap(); - let database = Arc::new(SqliteDatabase::new(temp.path().join("test.db")).await.unwrap()); + let database = Arc::new( + SqliteDatabase::new(temp.path().join("test.db")) + .await + .unwrap(), + ); database.initialize().await.unwrap(); let file = MediaFile::new( PathBuf::from("/media/Show.Name.S02E05.1080p.mkv"), @@ -98,7 +102,10 @@ async fn a_record_round_trips_through_the_database() { async fn storing_the_same_file_twice_replaces_rather_than_duplicates() { let (database, id, _temp) = database_with_one_file().await; - database.bulk_store_mediainfo(&[record_for(id, 40)]).await.unwrap(); + database + .bulk_store_mediainfo(&[record_for(id, 40)]) + .await + .unwrap(); let mut better = record_for(id, 95); better.title = Some("A Better Match".to_string()); database.bulk_store_mediainfo(&[better]).await.unwrap(); @@ -155,7 +162,10 @@ async fn work_is_whatever_is_missing_stale_or_not_good_enough() { ); // A good match is done. - database.bulk_store_mediainfo(&[record_for(id, 90)]).await.unwrap(); + database + .bulk_store_mediainfo(&[record_for(id, 90)]) + .await + .unwrap(); assert!(database .media_ids_missing_mediainfo(1, 60) .await @@ -181,7 +191,10 @@ async fn a_rescan_does_not_wipe_fetched_media_info() { // cleared and rewritten every time a record is re-scanned. If a scan could // destroy this, every run of the fetch would have to start over. let (database, id, _temp) = database_with_one_file().await; - database.bulk_store_mediainfo(&[record_for(id, 88)]).await.unwrap(); + database + .bulk_store_mediainfo(&[record_for(id, 88)]) + .await + .unwrap(); let mut rescanned = MediaFile::new( PathBuf::from("/media/Show.Name.S02E05.1080p.mkv"), @@ -202,7 +215,10 @@ async fn a_rescan_does_not_wipe_fetched_media_info() { #[tokio::test] async fn removing_a_file_takes_its_media_info_with_it() { let (database, id, _temp) = database_with_one_file().await; - database.bulk_store_mediainfo(&[record_for(id, 88)]).await.unwrap(); + database + .bulk_store_mediainfo(&[record_for(id, 88)]) + .await + .unwrap(); database .remove_media_file(&PathBuf::from("/media/Show.Name.S02E05.1080p.mkv")) @@ -215,7 +231,10 @@ async fn removing_a_file_takes_its_media_info_with_it() { #[tokio::test] async fn clearing_forgets_everything() { let (database, id, _temp) = database_with_one_file().await; - database.bulk_store_mediainfo(&[record_for(id, 88)]).await.unwrap(); + database + .bulk_store_mediainfo(&[record_for(id, 88)]) + .await + .unwrap(); assert_eq!(database.clear_mediainfo().await.unwrap(), 1); assert!(database.get_mediainfo(id).await.unwrap().is_none()); @@ -295,7 +314,10 @@ fn authed(method: &str, uri: &str, body: Option<&str>) -> Request { builder = builder.header("content-type", "application/json"); } builder - .body(body.map(|body| Body::from(body.to_owned())).unwrap_or(Body::empty())) + .body( + body.map(|body| Body::from(body.to_owned())) + .unwrap_or(Body::empty()), + ) .unwrap() } @@ -496,7 +518,10 @@ async fn the_endpoints_require_management_auth() { #[tokio::test] async fn browse_json_carries_the_fetched_title_and_synopsis() { let (database, id, temp) = database_with_one_file().await; - database.bulk_store_mediainfo(&[record_for(id, 92)]).await.unwrap(); + database + .bulk_store_mediainfo(&[record_for(id, 92)]) + .await + .unwrap(); let router = create_router(state_with(database, &temp).await); let response = router @@ -524,7 +549,12 @@ async fn an_uncertain_match_is_stored_but_never_shown() { // Still on record. assert_eq!( - database.get_mediainfo(id).await.unwrap().unwrap().confidence, + database + .get_mediainfo(id) + .await + .unwrap() + .unwrap() + .confidence, 5 );