diff --git a/config.example.toml b/config.example.toml index d7f6bce..68ae2e4 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 0f66af0..eda9818 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 6dd25ca..92d53e5 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 67bf12d..ae16276 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 a128c22..94498d1 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 22b8b37..dafc5c4 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 64ceb0a..c1e72eb 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 fa9243a..796e41b 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 686f724..ae1006d 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 0000000..9bc7376 --- /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 afb798e..586a5ee 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 1ebfd63..9dec1c3 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 4484b53..18ddb12 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 3019099..4ca9e1d 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 15f6364..9130c2b 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 1f71a53..f6c66a3 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 82e130c..6a0b4c0 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 0000000..7c8aa2e --- /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 0000000..6b2d9e3 --- /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 0000000..e71665d --- /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 0000000..ff173f5 --- /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 0000000..3f5af93 --- /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 0000000..ca1a9f0 --- /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 0000000..1b82c76 --- /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 0000000..1614193 --- /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 0000000..9461524 --- /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 0000000..06fa99f --- /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 0000000..39d6c62 --- /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 0000000..867055d --- /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 0000000..4b7ba4c --- /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 0000000..3d10443 --- /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 0000000..c8a6987 --- /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 0000000..29be6c3 --- /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 0000000..c8e605b --- /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 0000000..097df6c --- /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 0000000..c3fb737 --- /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 e5fdcb9..99c514c 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 cd78a14..b7f9844 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 0000000..2842b47 --- /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 2089625..02b9cb1 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 146cc94..c2523cb 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 2d087ca..457b12e 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 5155bbc..75fc1c1 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 44cacab..0e01d9a 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 0ac182c..12e50c2 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 56e399b..a442f96 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 c27e0d9..fff3812 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 b72d949..f94009d 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 094c7a8..b701395 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 0000000..f78547a --- /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 7ff449e..0e66224 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 a6821ab..fe87491 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 970b999..f514c2d 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 e7509f8..0d35665 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 49b0260..1db2bcd 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 0000000..1100bf8 --- /dev/null +++ b/crates/vuio-core/tests/mediainfo_integration_tests.rs @@ -0,0 +1,604 @@ +//! 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 3061ed0..009ddde 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 b70d5a2..5395761 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 919b4da..c9c0ebf 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 7cbc02f..c795454 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.