Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
18 changes: 17 additions & 1 deletion crates/vuio-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
#
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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 = [
Expand Down
7 changes: 5 additions & 2 deletions crates/vuio-core/src/config/generator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -474,6 +475,7 @@ mod tests {
cache_mb: 128,
},
management: ManagementConfig::default(),
mediainfo: MediaInfoConfig::default(),
};

// Generate TOML
Expand Down Expand Up @@ -583,6 +585,7 @@ mod tests {
cache_mb: 128,
},
management: ManagementConfig::default(),
mediainfo: MediaInfoConfig::default(),
};

// Generate TOML
Expand Down
35 changes: 35 additions & 0 deletions crates/vuio-core/src/config/loading.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Vec<_>>()
})
.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),
},
})
}

Expand Down Expand Up @@ -325,6 +359,7 @@ impl AppConfig {
cache_mb: default_cache_mb(),
},
management: ManagementConfig::default(),
mediainfo: MediaInfoConfig::default(),
}
}

Expand Down
5 changes: 3 additions & 2 deletions crates/vuio-core/src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};

Expand Down
66 changes: 66 additions & 0 deletions crates/vuio-core/src/config/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
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.
///
Expand Down Expand Up @@ -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<String>,
#[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<String>,
/// 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)]
Expand Down
18 changes: 18 additions & 0 deletions crates/vuio-core/src/config/platform.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
6 changes: 6 additions & 0 deletions crates/vuio-core/src/config/template.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
100 changes: 100 additions & 0 deletions crates/vuio-core/src/database/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -612,6 +612,38 @@ pub trait DatabaseReadSession {
) -> Result<VisitSummary>
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<std::collections::HashMap<i64, MediaInfoOverlay>> {
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<String>,
pub overview: Option<String>,
pub genres: Vec<String>,
pub has_artwork: bool,
}

/// Media-library storage and query operations implemented by a database backend.
Expand Down Expand Up @@ -918,6 +950,73 @@ pub trait SecretStore: Send + Sync {
async fn delete_secret(&self, key: &str) -> Result<bool>;
}

/// 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<String>,
pub original_title: Option<String>,
pub overview: Option<String>,
pub release_date: Option<String>,
pub year: Option<u32>,
pub rating: Option<f64>,
pub genres: Vec<String>,
pub season: Option<u32>,
pub episode: Option<u32>,
/// Key into the artwork cache, if a poster was downloaded.
pub artwork_key: Option<String>,
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<Option<MediaInfoRecord>>;
/// 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<Vec<MediaInfoRecord>>;
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<Vec<MediaInfoRecord>>;
async fn mediainfo_stats(&self, threshold: u8) -> Result<MediaInfoStats>;
/// Forget everything, so the next run starts over.
async fn clear_mediainfo(&self) -> Result<u64>;
/// 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<Vec<i64>>;
}

/// Aggregate database capability used by the application.
#[async_trait]
pub trait DatabaseManager:
Expand All @@ -926,6 +1025,7 @@ pub trait DatabaseManager:
+ HealthRepository
+ StatsRepository
+ SecretStore
+ MediaInfoRepository
+ Send
+ Sync
{
Expand Down
Loading