diff --git a/README.md b/README.md index f07435d..f61684c 100644 --- a/README.md +++ b/README.md @@ -220,7 +220,7 @@ You can deploy VuIO to a Kubernetes cluster using the provided Helm chart. You can install the chart directly from GitHub Container Registry without cloning the repository: ```bash -helm install vuio oci://ghcr.io/vuiodev/charts/vuio --version 0.0.42 +helm install vuio oci://ghcr.io/vuiodev/charts/vuio --version 0.0.43 ``` #### Local Installation diff --git a/crates/vuio-cli/Cargo.toml b/crates/vuio-cli/Cargo.toml index 5be07da..b240e2a 100644 --- a/crates/vuio-cli/Cargo.toml +++ b/crates/vuio-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "vuio-cli" -version = "0.0.42" +version = "0.0.43" edition = "2021" authors = ["vyrti"] description = "VuIO media server command-line application" @@ -16,10 +16,18 @@ path = "src/main.rs" [[bin]] name = "generate_test_media" path = "src/bin/generate_test_media.rs" +required-features = ["testdata"] + +[features] +default = [] +# The test-media generator needs a tag *writer*, which is the only thing +# audiotags is still here for. Gated so the shipped `vuio` binary does not link +# it. +testdata = ["dep:audiotags"] [dependencies] anyhow = "1.0" -audiotags = "0.5" +audiotags = { version = "0.5", optional = true } clap = { version = "4.6", features = ["derive"] } rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } reqwest = { version = "0.13", default-features = false, features = ["json", "rustls-no-provider"] } @@ -27,4 +35,4 @@ serde = { version = "1.0", features = ["derive"] } tokio = { version = "1.53", features = ["rt-multi-thread", "macros", "signal"] } tracing = "0.1" uuid = { version = "1.24", features = ["v4"] } -vuio-core = { path = "../vuio-core", version = "0.0.42" } +vuio-core = { path = "../vuio-core", version = "0.0.43" } diff --git a/crates/vuio-core/Cargo.toml b/crates/vuio-core/Cargo.toml index 479914e..0f66af0 100644 --- a/crates/vuio-core/Cargo.toml +++ b/crates/vuio-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "vuio-core" -version = "0.0.42" +version = "0.0.43" edition = "2021" rust-version = "1.95" authors = ["vyrti"] @@ -34,9 +34,18 @@ path = "src/lib.rs" default = ["casting", "dashboard", "diagnostics", "mcp", "metadata"] # Cast to Chromecast, AirPlay and DLNA renderers. +# +# `symphonia/opt-simd` lives here rather than on `metadata` because all it does +# is swap symphonia's own FFT for rustfft's SIMD one, and the FFT is only +# reached by the transform-based decoders. Casting is the only thing that +# decodes audio: remux demuxes without decoding, and reading tags never decodes +# at all. Scoping it this way keeps the rustfft tree out of a metadata-only +# build while still giving SSE/AVX and NEON to the AirPlay path, where +# real-time decode on a small box is tight. casting = [ "dep:vuio-cast", "dep:symphonia", + "symphonia/opt-simd", "dep:hap-crypto", "dep:hap-transport", "dep:hap-tlv8", @@ -50,7 +59,7 @@ casting = [ dashboard = [] diagnostics = ["dep:sysinfo"] mcp = [] -metadata = ["dep:audiotags"] +metadata = ["dep:symphonia"] unstable-internals = [] [dependencies] @@ -82,7 +91,6 @@ hyper = { version = "1.8", features = ["client", "http1"] } hyper-util = { version = "0.1", features = ["client-legacy", "http1", "tokio"] } bytes = "1.11" rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } -audiotags = { version = "0.5", optional = true } quick-xml = "0.41" percent-encoding = "2.3" sysinfo = { version = "0.39", default-features = false, features = ["system", "disk", "network"], optional = true } @@ -97,7 +105,11 @@ hap-tlv8 = { version = "1.0", optional = true } hkdf = { version = "0.12", optional = true } sha2 = { version = "0.10", optional = true } num-bigint = { version = "0.4", optional = true } -symphonia = { version = "0.6", default-features = false, features = ["mkv", "mp3", "aac", "isomp4", "flac", "wav", "pcm", "ogg", "vorbis", "alac", "id3v1", "id3v2", "ape"], optional = true } +# `all` is `all-codecs` + `all-formats` + `all-meta`: every container symphonia +# can demux and every tag dialect it can read, including APEv1/APEv2. Listed +# rather than inherited from upstream's `default` so what ships stays auditable +# and cannot drift when that default changes. SIMD is added by `casting` above. +symphonia = { version = "0.6", default-features = false, features = ["all"], optional = true } getrandom = { version = "0.3", optional = true } plist = { version = "1.8", optional = true } hex = { version = "0.4", optional = true } @@ -113,6 +125,9 @@ windows = { version = "0.62", features = [ [dev-dependencies] vuio-core = { path = ".", features = ["unstable-internals"] } +# Symphonia reads tags but cannot write them, and the audio tests build their +# fixtures by tagging generated files. Test-only, so it ships with nothing. +audiotags = "0.5" tempfile = "3.27" futures-util = "0.3" tower = { version = "0.5", features = ["util"] } diff --git a/crates/vuio-core/src/database/conformance.rs b/crates/vuio-core/src/database/conformance.rs index 044ae59..ae0ff01 100644 --- a/crates/vuio-core/src/database/conformance.rs +++ b/crates/vuio-core/src/database/conformance.rs @@ -679,6 +679,229 @@ pub async fn album_tracks_order_by_track_number() { assert_eq!(names, ["a.mp3", "b.mp3", "c.mp3"]); } +/// A multi-disc album plays in the order it was pressed. +/// +/// Disc 2 track 1 belongs after disc 1 track 12, not before it, which is what +/// ordering on the track number alone would give. +pub async fn album_tracks_order_by_disc_then_track() { + let temp = tempfile::tempdir().unwrap(); + let database = open::(&temp, "disc-order").await; + + let mut records = Vec::new(); + for (disc, track, name) in [ + (Some(2), Some(1), "d2t1.mp3"), + (Some(1), Some(12), "d1t12.mp3"), + (Some(1), Some(2), "d1t2.mp3"), + // No disc tag at all belongs with disc one, which is how a + // single-disc release is tagged. + (None, Some(1), "d0t1.mp3"), + ] { + let mut file = audio(&format!("/music/box/{name}"), 1); + file.album = Some("Boxed".to_string()); + file.tags.disc_number = disc; + file.track_number = track; + records.push(file); + } + // An untagged record sorts last, as it does everywhere else. + let mut untagged = audio("/music/box/zz.mp3", 1); + untagged.album = Some("Boxed".to_string()); + records.push(untagged); + + database.bulk_store_media_files(&records).await.unwrap(); + + let query = MediaFileQuery::Album { + album: "Boxed".to_string(), + artist: None, + }; + let names = database + .clone() + .read(move |session| { + let mut names = Vec::new(); + session.visit_files(&query, 0, 10, |file| { + names.push(file.filename().to_owned()); + Ok(()) + })?; + Ok(names) + }) + .await + .unwrap(); + assert_eq!( + names, + ["d0t1.mp3", "d1t2.mp3", "d1t12.mp3", "zz.mp3", "d2t1.mp3"] + ); +} + +/// One query serves both a flat category listing and a nested one. +pub async fn music_categories_narrow_to_their_filter() { + use crate::database::{MusicCategoryFilter, MusicCategoryType}; + + let temp = tempfile::tempdir().unwrap(); + let database = open::(&temp, "nested-categories").await; + + let mut records = Vec::new(); + for (index, (artist, album, genre)) in [ + ("Metallica", "Ride the Lightning", "Metal"), + ("Metallica", "Load", "Rock"), + ("Portishead", "Dummy", "Trip Hop"), + // Two different artists with an identically named album: the reason a + // nested album listing has to be scoped to its artist. + ("Artist A", "Greatest Hits", "Rock"), + ("Artist B", "Greatest Hits", "Rock"), + ] + .into_iter() + .enumerate() + { + let mut file = audio(&format!("/music/t{index}.mp3"), 1); + file.artist = Some(artist.to_string()); + file.album = Some(album.to_string()); + file.genre = Some(genre.to_string()); + records.push(file); + } + database.bulk_store_media_files(&records).await.unwrap(); + + let albums_of = |artist: &str| { + let filter = MusicCategoryFilter::artist(artist); + let database = database.clone(); + async move { + database + .get_music_categories(MusicCategoryType::Album, &filter, None) + .await + .unwrap() + .into_iter() + .map(|category| category.name) + .collect::>() + } + }; + assert_eq!(albums_of("Metallica").await, ["Load", "Ride the Lightning"]); + assert_eq!(albums_of("Portishead").await, ["Dummy"]); + + // Same title, two artists: one album container each, not one shared. + assert_eq!(albums_of("Artist A").await, ["Greatest Hits"]); + assert_eq!(albums_of("Artist B").await, ["Greatest Hits"]); + + // A genre lists the artists within it, which is the level minidlna puts + // between a genre and its albums. + let rock = database + .get_music_categories( + MusicCategoryType::Artist, + &MusicCategoryFilter::genre("Rock"), + Some(MusicCategoryType::Album), + ) + .await + .unwrap(); + let rock_artists = rock + .iter() + .map(|category| category.name.clone()) + .collect::>(); + assert_eq!(rock_artists, ["Artist A", "Artist B", "Metallica"]); + + // A container whose children are containers must count the containers. + // Metallica has two Rock tracks but only one Rock album, and announcing + // two would promise a child the browse never returns. + let metallica = rock + .iter() + .find(|category| category.name == "Metallica") + .unwrap(); + assert_eq!(metallica.count, 1, "one Metallica track is tagged Rock"); + assert_eq!(metallica.child_count, Some(1), "in one album"); + + // Without a child tag there is nothing to count, and the caller falls back + // to the record count. + assert!(database + .get_music_categories( + MusicCategoryType::Artist, + &MusicCategoryFilter::default(), + None, + ) + .await + .unwrap() + .iter() + .all(|category| category.child_count.is_none())); + + // And a genre-and-artist pair narrows to that artist's albums in it. + let scoped = database + .get_music_categories( + MusicCategoryType::Album, + &MusicCategoryFilter::genre("Rock").with_artist("Metallica"), + None, + ) + .await + .unwrap(); + assert_eq!(scoped.len(), 1); + assert_eq!(scoped[0].name, "Load"); + assert!( + scoped[0].sample_id.is_some(), + "a category must name a record whose cover art can represent it" + ); +} + +/// Internet radio is audio but not part of a music library. +/// +/// A radio station is stored with its source playlist path as the album, so a +/// category listing that does not exclude it grows a container named after a +/// file path — one that lists nothing when opened, because every track query +/// the browse tree builds does exclude radio. +pub async fn radio_records_do_not_become_music_categories() { + let temp = tempfile::tempdir().unwrap(); + let database = open::(&temp, "radio-categories").await; + + let mut station = MediaFile::new( + PathBuf::from("https://radio.example/stream"), + 0, + "audio/radio".to_string(), + ); + station.album = Some("/media/radio/stations.m3u".to_string()); + station.artist = Some("Example Radio".to_string()); + + let mut track = audio("/music/real.mp3", 1); + track.album = Some("A Real Album".to_string()); + track.artist = Some("A Real Artist".to_string()); + + database + .bulk_store_media_files(&[station, track]) + .await + .unwrap(); + + let names = |categories: Vec| { + categories + .into_iter() + .map(|category| category.name) + .collect::>() + }; + assert_eq!(names(database.get_albums(None).await.unwrap()), ["A Real Album"]); + assert_eq!( + names(database.get_artists().await.unwrap()), + ["A Real Artist"] + ); +} + +/// Browsing the playlist list needs one child count per playlist. +pub async fn playlist_entry_counts_are_returned_together() { + let temp = tempfile::tempdir().unwrap(); + let database = open::(&temp, "playlist-counts").await; + + let ids = database + .bulk_store_media_files(&[ + audio("/music/one.mp3", 1), + audio("/music/two.mp3", 1), + audio("/music/three.mp3", 1), + ]) + .await + .unwrap(); + + let full = database.create_playlist("Full", None).await.unwrap(); + let empty = database.create_playlist("Empty", None).await.unwrap(); + database + .batch_add_to_playlist(full, &[(ids[0], 0), (ids[1], 1), (ids[2], 2)]) + .await + .unwrap(); + + let counts = database.count_playlist_entries().await.unwrap(); + assert_eq!(counts.get(&full), Some(&3)); + // A playlist with no entries has no row to group, so it is simply absent. + assert_eq!(counts.get(&empty), None); +} + pub async fn filtered_query_searches_text_and_pages_by_cursor() { let temp = tempfile::tempdir().unwrap(); let database = open::(&temp, "filtered").await; @@ -1331,6 +1554,10 @@ macro_rules! backend_conformance_tests { conformance_case!(directory_visitor_orders_and_pages); conformance_case!(file_visitor_pages_a_directory_in_natural_order); conformance_case!(album_tracks_order_by_track_number); + conformance_case!(album_tracks_order_by_disc_then_track); + conformance_case!(music_categories_narrow_to_their_filter); + conformance_case!(radio_records_do_not_become_music_categories); + conformance_case!(playlist_entry_counts_are_returned_together); conformance_case!(filtered_query_searches_text_and_pages_by_cursor); conformance_case!(read_session_finds_records_by_id_and_path); conformance_case!(playlist_lifecycle); diff --git a/crates/vuio-core/src/database/mod.rs b/crates/vuio-core/src/database/mod.rs index 7d71386..686f724 100644 --- a/crates/vuio-core/src/database/mod.rs +++ b/crates/vuio-core/src/database/mod.rs @@ -2,7 +2,7 @@ use anyhow::Result; use async_trait::async_trait; use futures_util::Stream; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::pin::Pin; use std::time::{Duration, SystemTime}; @@ -59,7 +59,58 @@ pub struct MusicCategory { pub id: String, pub name: String, pub category_type: MusicCategoryType, + /// How many records carry this value. pub count: usize, + /// How many distinct sub-categories it contains, when one was asked for. + /// + /// A container whose children are containers cannot report `count` as its + /// `childCount` — an artist with forty tracks across three albums has three + /// children, not forty. + pub child_count: Option, + /// One record belonging to this category, used to point a container's + /// `upnp:albumArtURI` at cover art without a second query. + pub sample_id: Option, +} + +/// Which records a category listing is drawn from. +/// +/// Every field is an `AND`, which is what lets one query serve a flat list of +/// artists and the albums of one artist within one genre alike. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct MusicCategoryFilter { + pub artist: Option, + pub album_artist: Option, + pub album: Option, + pub genre: Option, + pub year: Option, +} + +impl MusicCategoryFilter { + pub fn artist(artist: impl Into) -> Self { + Self { + artist: Some(artist.into()), + ..Self::default() + } + } + + pub fn album_artist(album_artist: impl Into) -> Self { + Self { + album_artist: Some(album_artist.into()), + ..Self::default() + } + } + + pub fn genre(genre: impl Into) -> Self { + Self { + genre: Some(genre.into()), + ..Self::default() + } + } + + pub fn with_artist(mut self, artist: impl Into) -> Self { + self.artist = Some(artist.into()); + self + } } /// Types of music categorization @@ -73,6 +124,46 @@ pub enum MusicCategoryType { Playlist, } +/// Tag fields promoted to columns of their own. +/// +/// These are the ones browsing, sorting or DIDL read directly. Everything else +/// a tag reader finds is kept verbatim in `extra_tags`, so learning to use a +/// new tag later is a query rather than a migration. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct AudioTags { + pub disc_number: Option, + pub disc_total: Option, + pub track_total: Option, + pub composer: Option, + pub comment: Option, + pub bpm: Option, + pub compilation: Option, + pub sort_title: Option, + pub sort_artist: Option, + pub sort_album: Option, + /// The full release date string. `MediaFile::year` keeps the integer the + /// Years category groups on. + pub release_date: Option, + pub musicbrainz_track_id: Option, + pub musicbrainz_album_id: Option, + pub musicbrainz_artist_id: Option, +} + +/// Stream properties read off the container while its tags are parsed. +/// +/// DLNA renderers use these as `res` attributes to decide whether they can play +/// a track before fetching it. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct StreamInfo { + pub codec: Option, + pub sample_rate: Option, + pub channels: Option, + pub bits_per_sample: Option, + /// Average bits per second. DLNA's `res@bitrate` wants *bytes* per second, + /// so the renderer layer divides by eight. + pub bit_rate: Option, +} + /// Enhanced MediaFile structure for database storage #[derive(Clone, Debug)] pub struct MediaFile { @@ -90,6 +181,17 @@ pub struct MediaFile { pub track_number: Option, pub year: Option, pub album_artist: Option, + pub tags: AudioTags, + pub stream: StreamInfo, + /// Every other tag the reader found, as (normalized key, value). + /// + /// Write-only: the scanner fills it and the writer persists it to + /// `media_tags`. Reads that only need to render a browse response leave it + /// empty rather than pay for the join. + pub extra_tags: Vec<(String, String)>, + /// Which version of the tag reader wrote this record. Drives re-reads when + /// the reader improves; see `platform::filesystem::TAGS_VERSION`. + pub tags_version: u32, pub subtitle_available: bool, pub created_at: SystemTime, pub updated_at: SystemTime, @@ -106,6 +208,9 @@ pub struct FileFingerprint { pub size: u64, pub modified: SystemTime, pub created_at: SystemTime, + /// Which tag reader wrote the record. A record whose file is unchanged is + /// still re-read when this trails the current reader. + pub tags_version: u32, } /// Minimal owned state needed after a database session to serve one resource. @@ -229,6 +334,10 @@ impl MediaFile { track_number: None, year: None, album_artist: None, + tags: AudioTags::default(), + stream: StreamInfo::default(), + extra_tags: Vec::new(), + tags_version: 0, subtitle_available: false, created_at: now, updated_at: now, @@ -282,6 +391,27 @@ impl MediaFileView for MediaFile { fn album_artist(&self) -> Option<&str> { self.album_artist.as_deref() } + fn tags_version(&self) -> u32 { + self.tags_version + } + fn disc_number(&self) -> Option { + self.tags.disc_number + } + fn composer(&self) -> Option<&str> { + self.tags.composer.as_deref() + } + fn sample_rate(&self) -> Option { + self.stream.sample_rate + } + fn channels(&self) -> Option { + self.stream.channels + } + fn bits_per_sample(&self) -> Option { + self.stream.bits_per_sample + } + fn bit_rate(&self) -> Option { + self.stream.bit_rate + } fn subtitle_available(&self) -> bool { self.subtitle_available } @@ -322,6 +452,34 @@ pub trait MediaFileView { fn created_at_secs(&self) -> u64; fn updated_at_secs(&self) -> u64; + /// Which version of the tag reader wrote this record. + fn tags_version(&self) -> u32 { + 0 + } + + // The remaining tag and stream fields default to absent so a view that has + // no use for them — the index snapshot, say — need not carry them. + + fn disc_number(&self) -> Option { + None + } + fn composer(&self) -> Option<&str> { + None + } + fn sample_rate(&self) -> Option { + None + } + fn channels(&self) -> Option { + None + } + fn bits_per_sample(&self) -> Option { + None + } + /// Average bits per second; `res@bitrate` is bytes per second. + fn bit_rate(&self) -> Option { + None + } + fn to_file_location(&self) -> Option { Some(FileLocation { id: self.id()?, @@ -382,6 +540,20 @@ pub enum MediaFileQuery { Genre(String), Year(u32), AlbumArtist(String), + /// Tracks matching any combination of music tags. + /// + /// The single-tag variants above stay for the `get_music_by_*` API; this is + /// what a nested browse tree needs, where an album is only meaningful + /// alongside the artist or genre it was reached through. + Music { + artist: Option, + album_artist: Option, + album: Option, + genre: Option, + year: Option, + /// Leave out internet-radio records, which are audio but not music. + exclude_radio: bool, + }, Playlist(i64), /// Cursor-paged library scan. Filtering is performed against borrowed /// views inside the database read transaction, so rejected rows are never @@ -555,6 +727,22 @@ pub trait MediaRepository: Send + Sync { /// Get all album artists async fn get_album_artists(&self) -> Result>; + /// Distinct values of one tag, restricted to the records a filter selects. + /// + /// The flat listings above are this with an empty filter; a nested browse + /// tree is this with the ancestors it descended through. + /// + /// `child_of` names the tag one level further down. When given, each result + /// also reports how many distinct values of *that* tag it contains, which + /// is what a container whose children are containers must announce as its + /// `childCount`. + async fn get_music_categories( + &self, + kind: MusicCategoryType, + filter: &MusicCategoryFilter, + child_of: Option, + ) -> Result>; + /// Get music files by artist async fn get_music_by_artist(&self, artist: &str) -> Result>; @@ -571,6 +759,12 @@ pub trait MediaRepository: Send + Sync { /// Get music files by album artist async fn get_music_by_album_artist(&self, album_artist: &str) -> Result>; + /// Every tag stored for one record that has no column of its own. + /// + /// Returned as (normalized key, value) pairs, sorted by key. A tag with + /// several values — two artists, three genres — appears once per value. + async fn get_media_tags(&self, media_file_id: i64) -> Result>; + /// Get multiple files by their paths in a single query. async fn get_files_by_paths(&self, paths: &[PathBuf]) -> Result>; @@ -682,6 +876,12 @@ pub trait PlaylistRepository: Send + Sync { /// Get all tracks in a playlist async fn get_playlist_tracks(&self, playlist_id: i64) -> Result>; + /// Track counts for every playlist, keyed by playlist id. + /// + /// Browsing the playlist list needs one child count per container, which + /// would otherwise be a query per row. + async fn count_playlist_entries(&self) -> Result>; + /// Reorder tracks in a playlist async fn reorder_playlist( &self, diff --git a/crates/vuio-core/src/database/playlist_formats/tests.rs b/crates/vuio-core/src/database/playlist_formats/tests.rs index 3132f5b..320e2ae 100644 --- a/crates/vuio-core/src/database/playlist_formats/tests.rs +++ b/crates/vuio-core/src/database/playlist_formats/tests.rs @@ -213,6 +213,10 @@ async fn test_m3u_export() { track_number: Some(1), year: Some(2023), album_artist: Some("Test Artist".to_string()), + tags: Default::default(), + stream: Default::default(), + extra_tags: Vec::new(), + tags_version: 0, subtitle_available: false, created_at: std::time::SystemTime::now(), updated_at: std::time::SystemTime::now(), diff --git a/crates/vuio-core/src/database/sqlite/media_repo/bulk.rs b/crates/vuio-core/src/database/sqlite/media_repo/bulk.rs index 0994ac8..fbc7528 100644 --- a/crates/vuio-core/src/database/sqlite/media_repo/bulk.rs +++ b/crates/vuio-core/src/database/sqlite/media_repo/bulk.rs @@ -18,18 +18,30 @@ pub(in crate::database::sqlite) const INSERT_MEDIA: &str = "\ INSERT INTO media_files ( id, path, parent_path, filename, size, modified_secs, mime_type, mime_family, duration_secs, title, artist, album, genre, track_number, year, album_artist, - subtitle_available, created_at_secs, updated_at_secs -) VALUES (?19, ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)"; + subtitle_available, created_at_secs, updated_at_secs, + disc_number, disc_total, track_total, composer, comment, bpm, compilation, + sort_title, sort_artist, sort_album, release_date, + musicbrainz_track_id, musicbrainz_album_id, musicbrainz_artist_id, + codec, sample_rate, channels, bits_per_sample, bit_rate, tags_version +) VALUES (?39, ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, + ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27, ?28, ?29, ?30, ?31, ?32, ?33, ?34, ?35, + ?36, ?37, ?38)"; pub(in crate::database::sqlite) const UPDATE_MEDIA: &str = "\ UPDATE media_files SET path = ?1, parent_path = ?2, filename = ?3, size = ?4, modified_secs = ?5, mime_type = ?6, mime_family = ?7, duration_secs = ?8, title = ?9, artist = ?10, album = ?11, genre = ?12, track_number = ?13, year = ?14, album_artist = ?15, - subtitle_available = ?16, created_at_secs = ?17, updated_at_secs = ?18 -WHERE id = ?19"; + subtitle_available = ?16, created_at_secs = ?17, updated_at_secs = ?18, + disc_number = ?19, disc_total = ?20, track_total = ?21, composer = ?22, + comment = ?23, bpm = ?24, compilation = ?25, sort_title = ?26, + sort_artist = ?27, sort_album = ?28, release_date = ?29, + musicbrainz_track_id = ?30, musicbrainz_album_id = ?31, musicbrainz_artist_id = ?32, + codec = ?33, sample_rate = ?34, channels = ?35, bits_per_sample = ?36, + bit_rate = ?37, tags_version = ?38 +WHERE id = ?39"; -/// The eighteen stored fields, in the order both statements bind them. +/// The thirty-eight stored fields, in the order both statements bind them. pub(in crate::database::sqlite) fn bind_media_file(file: &MediaFile) -> Vec { let path = file.path.to_string_lossy().into_owned(); let parent = SqliteDatabase::parent_directory(&path).unwrap_or_default(); @@ -57,9 +69,64 @@ pub(in crate::database::sqlite) fn bind_media_file(file: &MediaFile) -> Vec Value::Integer(i64::from(flag)), + None => Value::Null, + }, + optional_text(&file.tags.sort_title), + optional_text(&file.tags.sort_artist), + optional_text(&file.tags.sort_album), + optional_text(&file.tags.release_date), + optional_text(&file.tags.musicbrainz_track_id), + optional_text(&file.tags.musicbrainz_album_id), + optional_text(&file.tags.musicbrainz_artist_id), + optional_text(&file.stream.codec), + optional_integer(file.stream.sample_rate), + optional_integer(file.stream.channels.map(u32::from)), + optional_integer(file.stream.bits_per_sample.map(u32::from)), + optional_integer(file.stream.bit_rate), + Value::Integer(i64::from(file.tags_version)), ] } +/// Replace the long tail of tags for one record. +/// +/// Unconditional, so that a record's tag state is always internally consistent: +/// the same write that clears the promoted columns clears the side table with +/// them. Guarding this on `tags_version` would leave a file whose tags became +/// unreadable — a truncated download, a corrupted re-encode — with empty +/// columns but its old `media_tags` rows still answering `get_media_tags`. +/// +/// This costs nothing for records that did not change, because the scanner only +/// reaches a write when the fingerprint says the file itself moved on. +fn write_extra_tags( + transaction: &Transaction<'_>, + media_file_id: i64, + file: &MediaFile, +) -> Result<()> { + transaction.execute( + "DELETE FROM media_tags WHERE media_file_id = ?", + [media_file_id], + )?; + if file.extra_tags.is_empty() { + return Ok(()); + } + + let mut insert = transaction.prepare_cached( + "INSERT OR IGNORE INTO media_tags (media_file_id, key, value) VALUES (?, ?, ?)", + )?; + for (key, value) in &file.extra_tags { + insert.execute(rusqlite::params![media_file_id, key, value])?; + } + Ok(()) +} + fn optional_text(value: &Option) -> Value { match value { Some(value) => Value::Text(value.clone()), @@ -126,6 +193,7 @@ pub(in crate::database::sqlite) fn upsert_media_file( transaction .prepare_cached(UPDATE_MEDIA)? .execute(rusqlite::params_from_iter(params.iter()))?; + write_extra_tags(transaction, id, file)?; Ok(id) } None => { @@ -137,7 +205,9 @@ pub(in crate::database::sqlite) fn upsert_media_file( transaction .prepare_cached(INSERT_MEDIA)? .execute(rusqlite::params_from_iter(params.iter()))?; - Ok(transaction.last_insert_rowid()) + let id = transaction.last_insert_rowid(); + write_extra_tags(transaction, id, file)?; + Ok(id) } } } diff --git a/crates/vuio-core/src/database/sqlite/media_repo/music.rs b/crates/vuio-core/src/database/sqlite/media_repo/music.rs index c301633..c2fafdd 100644 --- a/crates/vuio-core/src/database/sqlite/media_repo/music.rs +++ b/crates/vuio-core/src/database/sqlite/media_repo/music.rs @@ -7,31 +7,79 @@ use anyhow::Result; use crate::database::sqlite::SqliteDatabase; -use crate::database::{MediaFile, MediaFileQuery, MusicCategory, MusicCategoryType}; +use crate::database::{ + MediaFile, MediaFileQuery, MusicCategory, MusicCategoryFilter, MusicCategoryType, +}; + +/// The column each category groups on. +fn category_column(kind: &MusicCategoryType) -> &'static str { + match kind { + MusicCategoryType::Artist => "artist", + MusicCategoryType::Album => "album", + MusicCategoryType::Genre => "genre", + MusicCategoryType::AlbumArtist => "album_artist", + MusicCategoryType::Year => "year", + // Playlists live in their own table and never reach this query. + MusicCategoryType::Playlist => "album", + } +} impl SqliteDatabase { /// Distinct values of one tag column, with the number of records carrying each. async fn categories( &self, - column: &'static str, - category_type: MusicCategoryType, - filter: Option<(&'static str, String)>, + kind: MusicCategoryType, + filter: MusicCategoryFilter, + child_of: Option, ) -> Result> { + let column = category_column(&kind); + let child_column = child_of.as_ref().map(category_column); + self.execute_read(move |connection| { let mut params: Vec = Vec::new(); - let extra = match &filter { - Some((filter_column, value)) => { - params.push(rusqlite::types::Value::Text(value.clone())); - format!(" AND {filter_column} = ?") - } - None => String::new(), + let mut extra = String::new(); + + let mut restrict = |filter_column: &str, value: rusqlite::types::Value| { + params.push(value); + extra.push_str(&format!(" AND {filter_column} = ?")); }; + if let Some(artist) = &filter.artist { + restrict("artist", rusqlite::types::Value::Text(artist.clone())); + } + if let Some(album_artist) = &filter.album_artist { + restrict( + "album_artist", + rusqlite::types::Value::Text(album_artist.clone()), + ); + } + if let Some(album) = &filter.album { + restrict("album", rusqlite::types::Value::Text(album.clone())); + } + if let Some(genre) = &filter.genre { + restrict("genre", rusqlite::types::Value::Text(genre.clone())); + } + if let Some(year) = filter.year { + restrict("year", rusqlite::types::Value::Integer(i64::from(year))); + } // An empty tag is as absent as a missing one; neither should // produce a browsable container. + // + // `MIN(id)` picks a stable representative for the container's cover + // art, and the optional `COUNT(DISTINCT …)` counts the containers + // one level down. Both are free: the grouping has already visited + // every row. + let child_total = match child_column { + Some(child) => format!(", COUNT(DISTINCT {child}) AS children"), + None => String::new(), + }; let sql = format!( - "SELECT {column} AS label, COUNT(*) AS total FROM media_files \ + "SELECT {column} AS label, COUNT(*) AS total, MIN(media_files.id) AS sample\ + {child_total} \ + FROM media_files \ WHERE {column} IS NOT NULL AND {column} <> ''{extra} \ + AND media_files.mime_family = 'audio' \ + AND media_files.mime_type <> 'audio/radio' \ GROUP BY label ORDER BY label COLLATE natural_order" ); let mut statement = connection.prepare_cached(&sql)?; @@ -42,8 +90,13 @@ impl SqliteDatabase { Ok(MusicCategory { id: name.clone(), name, - category_type: category_type.clone(), + category_type: kind.clone(), count: count.max(0) as usize, + child_count: match child_column { + Some(_) => Some(row.get::<_, i64>(3)?.max(0) as usize), + None => None, + }, + sample_id: row.get(2)?, }) })? .collect::>>()?; @@ -52,8 +105,17 @@ impl SqliteDatabase { .await } + pub(in crate::database::sqlite) async fn get_music_categories_impl( + &self, + kind: MusicCategoryType, + filter: &MusicCategoryFilter, + child_of: Option, + ) -> Result> { + self.categories(kind, filter.clone(), child_of).await + } + pub(in crate::database::sqlite) async fn get_artists_impl(&self) -> Result> { - self.categories("artist", MusicCategoryType::Artist, None) + self.categories(MusicCategoryType::Artist, MusicCategoryFilter::default(), None) .await } @@ -61,27 +123,27 @@ impl SqliteDatabase { &self, artist_filter: Option<&str>, ) -> Result> { - self.categories( - "album", - MusicCategoryType::Album, - artist_filter.map(|artist| ("artist", artist.to_owned())), - ) - .await + let filter = match artist_filter { + Some(artist) => MusicCategoryFilter::artist(artist), + None => MusicCategoryFilter::default(), + }; + self.categories(MusicCategoryType::Album, filter, None).await } pub(in crate::database::sqlite) async fn get_genres_impl(&self) -> Result> { - self.categories("genre", MusicCategoryType::Genre, None) + self.categories(MusicCategoryType::Genre, MusicCategoryFilter::default(), None) .await } pub(in crate::database::sqlite) async fn get_years_impl(&self) -> Result> { - self.categories("year", MusicCategoryType::Year, None).await + self.categories(MusicCategoryType::Year, MusicCategoryFilter::default(), None) + .await } pub(in crate::database::sqlite) async fn get_album_artists_impl( &self, ) -> Result> { - self.categories("album_artist", MusicCategoryType::AlbumArtist, None) + self.categories(MusicCategoryType::AlbumArtist, MusicCategoryFilter::default(), None) .await } @@ -120,6 +182,23 @@ impl SqliteDatabase { self.query_media(MediaFileQuery::Year(year)).await } + pub(in crate::database::sqlite) async fn get_media_tags_impl( + &self, + media_file_id: i64, + ) -> Result> { + self.execute_read(move |connection| { + let mut statement = connection.prepare_cached( + "SELECT key, value FROM media_tags WHERE media_file_id = ? \ + ORDER BY key, value", + )?; + let tags = statement + .query_map([media_file_id], |row| Ok((row.get(0)?, row.get(1)?)))? + .collect::>>()?; + Ok(tags) + }) + .await + } + pub(in crate::database::sqlite) async fn get_music_by_album_artist_impl( &self, album_artist: &str, diff --git a/crates/vuio-core/src/database/sqlite/playlist_repo.rs b/crates/vuio-core/src/database/sqlite/playlist_repo.rs index 76ead47..fe2a684 100644 --- a/crates/vuio-core/src/database/sqlite/playlist_repo.rs +++ b/crates/vuio-core/src/database/sqlite/playlist_repo.rs @@ -7,6 +7,7 @@ use anyhow::{anyhow, Result}; use rusqlite::{OptionalExtension, Transaction}; +use std::collections::HashMap; use std::path::Path; use std::time::SystemTime; @@ -100,6 +101,25 @@ impl SqliteDatabase { .await } + /// How many tracks each playlist holds, in one pass. + /// + /// Browsing the playlist list needs a child count per container, and asking + /// per playlist would be one query per row. + pub(super) async fn count_playlist_entries_impl(&self) -> Result> { + self.execute_read(move |connection| { + let mut statement = connection.prepare_cached( + "SELECT playlist_id, COUNT(*) FROM playlist_entries GROUP BY playlist_id", + )?; + let counts = statement + .query_map([], |row| { + Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?.max(0) as usize)) + })? + .collect::>>()?; + Ok(counts) + }) + .await + } + pub(super) async fn get_playlist_impl(&self, playlist_id: i64) -> Result> { self.execute_read(move |connection| { Ok(connection diff --git a/crates/vuio-core/src/database/sqlite/query.rs b/crates/vuio-core/src/database/sqlite/query.rs index 0c9c08b..fc6f175 100644 --- a/crates/vuio-core/src/database/sqlite/query.rs +++ b/crates/vuio-core/src/database/sqlite/query.rs @@ -9,10 +9,11 @@ use rusqlite::types::Value; use super::{MediaFileQuery, SqliteDatabase}; use crate::database::sqlite::schema::MEDIA_COLUMNS; -/// Browse order: track number where present, then natural filename, with -/// untagged records last. `track_sort` materializes the first two rules so an -/// index can serve the whole clause. -const BROWSE_ORDER: &str = "media_files.track_sort, media_files.filename COLLATE natural_order"; +/// Browse order: disc, then track number where present, then natural filename, +/// with untagged records last. `disc_sort` and `track_sort` materialize the +/// first two rules so an index can serve the whole clause. +const BROWSE_ORDER: &str = + "media_files.disc_sort, media_files.track_sort, media_files.filename COLLATE natural_order"; /// Insertion order, which is also cursor order for paged scans. const ID_ORDER: &str = "media_files.id"; @@ -163,6 +164,36 @@ pub(super) fn plan(query: &MediaFileQuery) -> MediaQueryPlan { clauses.push("media_files.album_artist = ?".to_owned()); params.push(Value::Text(album_artist.clone())); } + MediaFileQuery::Music { + artist, + album_artist, + album, + genre, + year, + exclude_radio, + } => { + for (column, value) in [ + ("artist", artist), + ("album_artist", album_artist), + ("album", album), + ("genre", genre), + ] { + if let Some(value) = value { + clauses.push(format!("media_files.{column} = ?")); + params.push(Value::Text(value.clone())); + } + } + if let Some(year) = year { + clauses.push("media_files.year = ?".to_owned()); + params.push(Value::Integer(i64::from(*year))); + } + if *exclude_radio { + // Radio streams share the audio family but are not part of a + // music library, and they have no tags to categorize by. + clauses.push("media_files.mime_family = 'audio'".to_owned()); + clauses.push("media_files.mime_type <> 'audio/radio'".to_owned()); + } + } MediaFileQuery::Playlist(playlist_id) => { source = MEDIA_WITH_ENTRIES; clauses.push("playlist_entries.playlist_id = ?".to_owned()); diff --git a/crates/vuio-core/src/database/sqlite/schema.rs b/crates/vuio-core/src/database/sqlite/schema.rs index d5d6d93..1ebfd63 100644 --- a/crates/vuio-core/src/database/sqlite/schema.rs +++ b/crates/vuio-core/src/database/sqlite/schema.rs @@ -10,14 +10,15 @@ use rusqlite::{Connection, OpenFlags, Row}; use std::path::PathBuf; use std::time::{Duration, SystemTime, UNIX_EPOCH}; -use crate::database::{FileFingerprint, FileLocation, MediaFile, Playlist}; +use crate::database::{AudioTags, FileFingerprint, FileLocation, MediaFile, Playlist, StreamInfo}; -/// Bumped only for a change that makes an existing file unreadable. +/// The schema version this build writes and expects. /// -/// Startup treats a mismatch the way it treats corruption: the file is -/// quarantined and rebuilt from a rescan, so this is a last resort rather than -/// a routine migration mechanism. -pub(super) const SCHEMA_VERSION: i64 = 1; +/// Bumped whenever the tables change. An older file is brought forward by +/// [`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; /// Name of the collation that carries the application's natural ordering into /// SQL. Registered on every connection; see [`register_collations`]. @@ -50,26 +51,56 @@ CREATE TABLE IF NOT EXISTS media_files ( track_number INTEGER, year INTEGER, album_artist TEXT, + disc_number INTEGER, + disc_total INTEGER, + track_total INTEGER, + composer TEXT, + comment TEXT, + bpm INTEGER, + compilation INTEGER, + sort_title TEXT, + sort_artist TEXT, + sort_album TEXT, + release_date TEXT, + musicbrainz_track_id TEXT, + musicbrainz_album_id TEXT, + musicbrainz_artist_id TEXT, + codec TEXT, + sample_rate INTEGER, + channels INTEGER, + bits_per_sample INTEGER, + bit_rate INTEGER, + -- Which tag reader wrote this record. A file whose bytes have not changed + -- is still re-read when this trails the current reader, which is how a + -- better extractor reaches records that were already indexed. + tags_version INTEGER NOT NULL DEFAULT 0, subtitle_available INTEGER NOT NULL DEFAULT 0, created_at_secs INTEGER NOT NULL, updated_at_secs INTEGER NOT NULL, - -- Browse ordering is "track number, then natural filename", with untagged - -- records last. Materializing the rank keeps that an index scan instead of - -- a sort over the whole directory. - track_sort INTEGER GENERATED ALWAYS AS (COALESCE(track_number, 4294967296)) STORED + -- Browse ordering is "disc, track number, then natural filename", with + -- untagged records last. Materializing the rank keeps that an index scan + -- instead of a sort over the whole directory. + -- + -- `track_sort` is STORED and predates `disc_sort`; SQLite cannot alter a + -- generated column, and ALTER TABLE only accepts VIRTUAL ones, so the disc + -- rank is a separate VIRTUAL column that the same indexes cover. Declared + -- here exactly as the migration adds it, so old and new files agree. + track_sort INTEGER GENERATED ALWAYS AS (COALESCE(track_number, 4294967296)) STORED, + disc_sort INTEGER GENERATED ALWAYS AS (COALESCE(disc_number, 1)) VIRTUAL ) STRICT; CREATE INDEX IF NOT EXISTS idx_media_dir_order - ON media_files(parent_path, track_sort, filename COLLATE {NATURAL}); + ON media_files(parent_path, disc_sort, track_sort, filename COLLATE {NATURAL}); CREATE INDEX IF NOT EXISTS idx_media_dir_family ON media_files(parent_path, mime_family); CREATE INDEX IF NOT EXISTS idx_media_album - ON media_files(album, track_sort, filename COLLATE {NATURAL}); + ON media_files(album, disc_sort, track_sort, filename COLLATE {NATURAL}); CREATE INDEX IF NOT EXISTS idx_media_artist ON media_files(artist); CREATE INDEX IF NOT EXISTS idx_media_genre ON media_files(genre); CREATE INDEX IF NOT EXISTS idx_media_year ON media_files(year); CREATE INDEX IF NOT EXISTS idx_media_album_artist ON media_files(album_artist); CREATE INDEX IF NOT EXISTS idx_media_family ON media_files(mime_family); +CREATE INDEX IF NOT EXISTS idx_media_tags_version ON media_files(tags_version); -- Directories exist only by implication from the paths of files, so unlike the -- music indexes they cannot be recomputed by a query at browse time. @@ -126,6 +157,68 @@ CREATE TABLE IF NOT EXISTS secrets ( key TEXT PRIMARY KEY, value BLOB NOT NULL ) STRICT; + +-- Every tag the reader found that has no column of its own, kept verbatim so +-- that using a new one later is a query rather than another migration. The +-- composite key is what makes multi-valued tags — two artists, three genres — +-- representable at all. +CREATE TABLE IF NOT EXISTS media_tags ( + media_file_id INTEGER NOT NULL REFERENCES media_files(id) ON DELETE CASCADE, + key TEXT NOT NULL, + value TEXT NOT NULL, + PRIMARY KEY (media_file_id, key, value) +) STRICT; + +CREATE INDEX IF NOT EXISTS idx_media_tags_key ON media_tags(key, value); +"#; + +/// Schema upgrades, applied in order to any file older than [`SCHEMA_VERSION`]. +/// +/// Each entry is `(version_it_produces, sql)`. The SQL runs inside the same +/// transaction that bumps `user_version`, so a failure part-way leaves the file +/// exactly as it was. +/// +/// Migrations are additive by construction: they add columns, tables and +/// indexes, never drop or rewrite user data. Anything that cannot be expressed +/// 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)]; + +/// v1 → v2: full tag extraction. +/// +/// Adds the promoted tag and stream columns, the `media_tags` side table, and +/// disc-aware browse ordering. `tags_version` defaults to 0 on existing rows, +/// which is below the current reader's version, so the next scan re-writes them +/// with the new fields filled in. +const MIGRATION_V2: &str = r#" +ALTER TABLE media_files ADD COLUMN disc_number INTEGER; +ALTER TABLE media_files ADD COLUMN disc_total INTEGER; +ALTER TABLE media_files ADD COLUMN track_total INTEGER; +ALTER TABLE media_files ADD COLUMN composer TEXT; +ALTER TABLE media_files ADD COLUMN comment TEXT; +ALTER TABLE media_files ADD COLUMN bpm INTEGER; +ALTER TABLE media_files ADD COLUMN compilation INTEGER; +ALTER TABLE media_files ADD COLUMN sort_title TEXT; +ALTER TABLE media_files ADD COLUMN sort_artist TEXT; +ALTER TABLE media_files ADD COLUMN sort_album TEXT; +ALTER TABLE media_files ADD COLUMN release_date TEXT; +ALTER TABLE media_files ADD COLUMN musicbrainz_track_id TEXT; +ALTER TABLE media_files ADD COLUMN musicbrainz_album_id TEXT; +ALTER TABLE media_files ADD COLUMN musicbrainz_artist_id TEXT; +ALTER TABLE media_files ADD COLUMN codec TEXT; +ALTER TABLE media_files ADD COLUMN sample_rate INTEGER; +ALTER TABLE media_files ADD COLUMN channels INTEGER; +ALTER TABLE media_files ADD COLUMN bits_per_sample INTEGER; +ALTER TABLE media_files ADD COLUMN bit_rate INTEGER; +ALTER TABLE media_files ADD COLUMN tags_version INTEGER NOT NULL DEFAULT 0; +ALTER TABLE media_files ADD COLUMN disc_sort INTEGER + GENERATED ALWAYS AS (COALESCE(disc_number, 1)) VIRTUAL; + +-- The ordering indexes now lead with the disc rank, and `CREATE INDEX IF NOT +-- EXISTS` will not redefine one that already exists. +DROP INDEX IF EXISTS idx_media_dir_order; +DROP INDEX IF EXISTS idx_media_album; "#; /// Columns of `media_files`, qualified so the list can be used inside joins. @@ -134,7 +227,14 @@ media_files.id, media_files.path, media_files.filename, media_files.size, \ media_files.modified_secs, media_files.mime_type, media_files.duration_secs, \ media_files.title, media_files.artist, media_files.album, media_files.genre, \ media_files.track_number, media_files.year, media_files.album_artist, \ -media_files.subtitle_available, media_files.created_at_secs, media_files.updated_at_secs"; +media_files.subtitle_available, media_files.created_at_secs, media_files.updated_at_secs, \ +media_files.disc_number, media_files.disc_total, media_files.track_total, \ +media_files.composer, media_files.comment, media_files.bpm, media_files.compilation, \ +media_files.sort_title, media_files.sort_artist, media_files.sort_album, \ +media_files.release_date, media_files.musicbrainz_track_id, \ +media_files.musicbrainz_album_id, media_files.musicbrainz_artist_id, \ +media_files.codec, media_files.sample_rate, media_files.channels, \ +media_files.bits_per_sample, media_files.bit_rate, media_files.tags_version"; /// Positions within [`MEDIA_COLUMNS`], shared by the owned decoder and the /// borrowed views so the two can never drift apart. @@ -156,6 +256,26 @@ pub(super) mod column { pub const SUBTITLE_AVAILABLE: usize = 14; pub const CREATED_AT_SECS: usize = 15; pub const UPDATED_AT_SECS: usize = 16; + pub const DISC_NUMBER: usize = 17; + pub const DISC_TOTAL: usize = 18; + pub const TRACK_TOTAL: usize = 19; + pub const COMPOSER: usize = 20; + pub const COMMENT: usize = 21; + pub const BPM: usize = 22; + pub const COMPILATION: usize = 23; + pub const SORT_TITLE: usize = 24; + pub const SORT_ARTIST: usize = 25; + pub const SORT_ALBUM: usize = 26; + pub const RELEASE_DATE: usize = 27; + pub const MUSICBRAINZ_TRACK_ID: usize = 28; + pub const MUSICBRAINZ_ALBUM_ID: usize = 29; + pub const MUSICBRAINZ_ARTIST_ID: usize = 30; + pub const CODEC: usize = 31; + pub const SAMPLE_RATE: usize = 32; + pub const CHANNELS: usize = 33; + pub const BITS_PER_SAMPLE: usize = 34; + pub const BIT_RATE: usize = 35; + pub const TAGS_VERSION: usize = 36; } /// Open one connection and put it in the state every caller expects. @@ -210,7 +330,7 @@ fn apply_pragmas(connection: &Connection, cache_mb: usize) -> Result<()> { Ok(()) } -/// Create the schema, or reject a file written by an incompatible version. +/// Create the schema, migrate an older file forward, or reject a newer one. pub(super) fn initialize_schema(connection: &Connection) -> Result<()> { let version: i64 = connection .query_row("PRAGMA user_version", [], |row| row.get(0)) @@ -226,13 +346,17 @@ pub(super) fn initialize_schema(connection: &Connection) -> Result<()> { return Ok(()); } - if version != SCHEMA_VERSION { + if version > SCHEMA_VERSION { anyhow::bail!( "Incompatible database schema {version}; expected {SCHEMA_VERSION}. \ - The file was written by a different version of VuIO and is left untouched." + The file was written by a newer version of VuIO and is left untouched." ); } + if version < SCHEMA_VERSION { + migrate(connection, version)?; + } + // A file at the right version may still predate an additive index, and // creating them is idempotent. connection @@ -241,6 +365,39 @@ pub(super) fn initialize_schema(connection: &Connection) -> Result<()> { Ok(()) } +/// Apply every migration between `from` and [`SCHEMA_VERSION`]. +/// +/// Each step runs with its `user_version` bump in one transaction, so an +/// interrupted upgrade leaves the file at a version that describes it. +fn migrate(connection: &Connection, from: i64) -> Result<()> { + let pending = MIGRATIONS + .iter() + .filter(|(produces, _)| *produces > from) + .collect::>(); + + if let Some((unreachable_from, _)) = pending.first().filter(|(first, _)| *first > from + 1) { + anyhow::bail!( + "No migration path from database schema {from} to {unreachable_from}; \ + the file is left untouched." + ); + } + + for (produces, sql) in pending { + tracing::info!( + "Migrating the media database to schema version {}", + produces + ); + connection + .execute_batch(&format!( + "BEGIN;\n{}\nPRAGMA user_version = {produces};\nCOMMIT;", + sql.replace("{NATURAL}", NATURAL) + )) + .with_context(|| format!("Failed to migrate the database to schema {produces}"))?; + } + + Ok(()) +} + /// Confirm a file is a readable database at the expected schema version. /// /// Used before a restore overwrites anything, so it must not create or modify @@ -260,12 +417,14 @@ pub(super) fn validate_database_file(path: &std::path::Path) -> Result<()> { anyhow::bail!("{} failed its integrity check: {integrity}", path.display()); } + // An older file is acceptable: opening it will migrate it forward. Only a + // newer one has no path back to this build. let version: i64 = connection .query_row("PRAGMA user_version", [], |row| row.get(0)) .context("Failed to read the schema version")?; - if version != SCHEMA_VERSION { + if !(1..=SCHEMA_VERSION).contains(&version) { anyhow::bail!( - "{} has schema version {version}; expected {SCHEMA_VERSION}", + "{} has schema version {version}; expected 1..={SCHEMA_VERSION}", path.display() ); } @@ -319,12 +478,45 @@ pub(super) fn media_file_from_row(row: &Row<'_>) -> rusqlite::Result .get::<_, Option>(column::YEAR)? .map(|value| value as u32), album_artist: row.get(column::ALBUM_ARTIST)?, + tags: AudioTags { + disc_number: optional_u32(row, column::DISC_NUMBER)?, + disc_total: optional_u32(row, column::DISC_TOTAL)?, + track_total: optional_u32(row, column::TRACK_TOTAL)?, + composer: row.get(column::COMPOSER)?, + comment: row.get(column::COMMENT)?, + bpm: optional_u32(row, column::BPM)?, + compilation: row + .get::<_, Option>(column::COMPILATION)? + .map(|value| value != 0), + sort_title: row.get(column::SORT_TITLE)?, + sort_artist: row.get(column::SORT_ARTIST)?, + sort_album: row.get(column::SORT_ALBUM)?, + release_date: row.get(column::RELEASE_DATE)?, + musicbrainz_track_id: row.get(column::MUSICBRAINZ_TRACK_ID)?, + musicbrainz_album_id: row.get(column::MUSICBRAINZ_ALBUM_ID)?, + musicbrainz_artist_id: row.get(column::MUSICBRAINZ_ARTIST_ID)?, + }, + stream: StreamInfo { + codec: row.get(column::CODEC)?, + sample_rate: optional_u32(row, column::SAMPLE_RATE)?, + channels: optional_u32(row, column::CHANNELS)?.map(|value| value as u16), + bits_per_sample: optional_u32(row, column::BITS_PER_SAMPLE)?.map(|value| value as u16), + bit_rate: optional_u32(row, column::BIT_RATE)?, + }, + // Reading a record for a browse response never needs the long tail of + // tags, so it is not joined in. + extra_tags: Vec::new(), + tags_version: row.get::<_, i64>(column::TAGS_VERSION)? as u32, subtitle_available: row.get::<_, i64>(column::SUBTITLE_AVAILABLE)? != 0, created_at: seconds_to_time(row.get(column::CREATED_AT_SECS)?), updated_at: seconds_to_time(row.get(column::UPDATED_AT_SECS)?), }) } +fn optional_u32(row: &Row<'_>, index: usize) -> rusqlite::Result> { + Ok(row.get::<_, Option>(index)?.map(|value| value as u32)) +} + pub(super) fn file_location_from_row(row: &Row<'_>) -> rusqlite::Result { Ok(FileLocation { id: row.get(0)?, @@ -340,7 +532,8 @@ pub(super) fn file_location_from_row(row: &Row<'_>) -> rusqlite::Result) -> rusqlite::Result { Ok(FileFingerprint { @@ -349,6 +542,7 @@ pub(super) fn fingerprint_from_row(row: &Row<'_>) -> rusqlite::Result(2)? as u64, modified: seconds_to_time(row.get(3)?), created_at: seconds_to_time(row.get(4)?), + tags_version: row.get::<_, i64>(5)? as u32, }) } diff --git a/crates/vuio-core/src/database/sqlite/session.rs b/crates/vuio-core/src/database/sqlite/session.rs index 32ebc46..4484b53 100644 --- a/crates/vuio-core/src/database/sqlite/session.rs +++ b/crates/vuio-core/src/database/sqlite/session.rs @@ -134,6 +134,32 @@ impl MediaFileView for SqliteMediaFileView<'_> { fn album_artist(&self) -> Option<&str> { self.optional_text(column::ALBUM_ARTIST) } + fn tags_version(&self) -> u32 { + self.integer(column::TAGS_VERSION).max(0) as u32 + } + fn disc_number(&self) -> Option { + self.optional_integer(column::DISC_NUMBER) + .map(|value| value as u32) + } + fn composer(&self) -> Option<&str> { + self.optional_text(column::COMPOSER) + } + fn sample_rate(&self) -> Option { + self.optional_integer(column::SAMPLE_RATE) + .map(|value| value as u32) + } + fn channels(&self) -> Option { + self.optional_integer(column::CHANNELS) + .map(|value| value as u16) + } + fn bits_per_sample(&self) -> Option { + self.optional_integer(column::BITS_PER_SAMPLE) + .map(|value| value as u16) + } + fn bit_rate(&self) -> Option { + self.optional_integer(column::BIT_RATE) + .map(|value| value as u32) + } fn subtitle_available(&self) -> bool { self.integer(column::SUBTITLE_AVAILABLE) != 0 } diff --git a/crates/vuio-core/src/database/sqlite/tests.rs b/crates/vuio-core/src/database/sqlite/tests.rs index f70f7f4..3019099 100644 --- a/crates/vuio-core/src/database/sqlite/tests.rs +++ b/crates/vuio-core/src/database/sqlite/tests.rs @@ -66,6 +66,157 @@ async fn the_natural_collation_orders_embedded_numbers_by_value() { assert_eq!(ordered, ["S01E1", "s01e2", "s01e10"]); } +/// The v1 schema, frozen. +/// +/// A migration is only tested by the schema it actually has to upgrade, so this +/// is a copy rather than something derived from the current DDL. It must never +/// be edited to track later changes. +const SCHEMA_V1: &str = r#" +CREATE TABLE media_files ( + id INTEGER PRIMARY KEY, + path TEXT NOT NULL UNIQUE, + parent_path TEXT NOT NULL, + filename TEXT NOT NULL, + size INTEGER NOT NULL, + modified_secs INTEGER NOT NULL, + mime_type TEXT NOT NULL, + mime_family TEXT NOT NULL, + duration_secs REAL, + title TEXT, + artist TEXT, + album TEXT, + genre TEXT, + track_number INTEGER, + year INTEGER, + album_artist TEXT, + subtitle_available INTEGER NOT NULL DEFAULT 0, + created_at_secs INTEGER NOT NULL, + updated_at_secs INTEGER NOT NULL, + track_sort INTEGER GENERATED ALWAYS AS (COALESCE(track_number, 4294967296)) STORED +) STRICT; + +CREATE INDEX idx_media_dir_order + ON media_files(parent_path, track_sort, filename COLLATE natural_order); +CREATE INDEX idx_media_album + ON media_files(album, track_sort, filename COLLATE natural_order); + +CREATE TABLE directories ( + path TEXT PRIMARY KEY, + parent_path TEXT NOT NULL, + name TEXT NOT NULL +) STRICT; + +CREATE TABLE directory_mime_counts ( + dir_path TEXT NOT NULL REFERENCES directories(path) ON DELETE CASCADE, + family TEXT NOT NULL, + count INTEGER NOT NULL, + PRIMARY KEY (dir_path, family) +) STRICT; + +CREATE TABLE playlists ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + source_path TEXT, + created_at_secs INTEGER NOT NULL, + updated_at_secs INTEGER NOT NULL +) STRICT; + +CREATE TABLE playlist_entries ( + playlist_id INTEGER NOT NULL REFERENCES playlists(id) ON DELETE CASCADE, + media_file_id INTEGER NOT NULL REFERENCES media_files(id) ON DELETE CASCADE, + position INTEGER NOT NULL, + PRIMARY KEY (playlist_id, position) +) STRICT; + +CREATE TABLE root_availability ( + path TEXT PRIMARY KEY, + last_seen_secs INTEGER NOT NULL, + unavailable_since_secs INTEGER, + indexed_count INTEGER NOT NULL, + reason TEXT NOT NULL +) STRICT; + +CREATE TABLE secrets ( + key TEXT PRIMARY KEY, + value BLOB NOT NULL +) STRICT; + +PRAGMA user_version = 1; +"#; + +/// Opening a v1 file must carry it forward without losing anything. +/// +/// The alternative — rebuilding from a rescan — would drop AirPlay pairings and +/// imported playlists, and would renumber every record. Those numbers are the +/// object ids DIDL hands to renderers, so a rebuild breaks every favourite and +/// resume point a TV has saved. +#[tokio::test] +async fn a_v1_database_migrates_forward_without_losing_anything() { + let temp = tempdir().unwrap(); + let path = temp.path().join("v1.db"); + + { + let connection = rusqlite::Connection::open(&path).unwrap(); + crate::database::sqlite::schema::register_collations(&connection).unwrap(); + connection.execute_batch(SCHEMA_V1).unwrap(); + connection + .execute_batch( + "INSERT INTO media_files + (id, path, parent_path, filename, size, modified_secs, mime_type, + mime_family, title, artist, album, track_number, + created_at_secs, updated_at_secs) + VALUES (7, '/media/one.mp3', '/media', 'one.mp3', 10, 100, 'audio/mpeg', + 'audio', 'One', 'Artist', 'Album', 1, 100, 100); + INSERT INTO playlists (id, name, created_at_secs, updated_at_secs) + VALUES (3, 'Roadtrip', 100, 100); + INSERT INTO playlist_entries (playlist_id, media_file_id, position) + VALUES (3, 7, 0); + INSERT INTO secrets (key, value) VALUES ('airplay.pairings', x'0102'); + INSERT INTO root_availability + (path, last_seen_secs, indexed_count, reason) + VALUES ('/media', 100, 1, 'present');", + ) + .unwrap(); + } + + let db = SqliteDatabase::new(path.clone()).await.unwrap(); + db.initialize().await.unwrap(); + + let version: i64 = db + .execute_read(|connection| { + Ok(connection.query_row("PRAGMA user_version", [], |row| row.get(0))?) + }) + .await + .unwrap(); + assert_eq!(version, super::schema::SCHEMA_VERSION); + + // The record kept its identity, which is what DIDL object ids depend on. + let file = db + .get_file_by_path(std::path::Path::new("/media/one.mp3")) + .await + .unwrap() + .expect("the migrated record is still there"); + assert_eq!(file.id, Some(7)); + assert_eq!(file.artist.as_deref(), Some("Artist")); + // New columns exist and are empty until a scan re-reads the file. + assert_eq!(file.tags.disc_number, None); + assert_eq!(file.tags_version, 0); + + // Everything a rebuild would have thrown away. + assert_eq!(db.get_playlists().await.unwrap().len(), 1); + assert_eq!(db.get_playlist_tracks(3).await.unwrap().len(), 1); + assert_eq!( + db.get_secret("airplay.pairings").await.unwrap().as_deref(), + Some(&[1u8, 2][..]) + ); + assert_eq!(db.list_root_availability().await.unwrap().len(), 1); + + // 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); +} + #[tokio::test] async fn an_incompatible_schema_version_is_refused() { let temp = tempdir().unwrap(); diff --git a/crates/vuio-core/src/database/sqlite/traits.rs b/crates/vuio-core/src/database/sqlite/traits.rs index 58e73b1..15f6364 100644 --- a/crates/vuio-core/src/database/sqlite/traits.rs +++ b/crates/vuio-core/src/database/sqlite/traits.rs @@ -4,6 +4,8 @@ //! forwards to, so the trait surface reads as an index of the module. use super::*; +use crate::database::{MusicCategoryFilter, MusicCategoryType}; +use std::collections::HashMap; #[async_trait] impl MediaRepository for SqliteDatabase { @@ -103,6 +105,15 @@ impl MediaRepository for SqliteDatabase { SqliteDatabase::get_album_artists_impl(self).await } + async fn get_music_categories( + &self, + kind: MusicCategoryType, + filter: &MusicCategoryFilter, + child_of: Option, + ) -> Result> { + SqliteDatabase::get_music_categories_impl(self, kind, filter, child_of).await + } + async fn get_music_by_artist(&self, artist: &str) -> Result> { SqliteDatabase::get_music_by_artist_impl(self, artist).await } @@ -127,6 +138,10 @@ impl MediaRepository for SqliteDatabase { SqliteDatabase::get_music_by_album_artist_impl(self, album_artist).await } + async fn get_media_tags(&self, media_file_id: i64) -> Result> { + SqliteDatabase::get_media_tags_impl(self, media_file_id).await + } + async fn get_files_by_paths(&self, paths: &[PathBuf]) -> Result> { SqliteDatabase::get_files_by_paths_impl(self, paths).await } @@ -205,6 +220,10 @@ impl PlaylistRepository for SqliteDatabase { SqliteDatabase::get_playlists_impl(self).await } + async fn count_playlist_entries(&self) -> Result> { + SqliteDatabase::count_playlist_entries_impl(self).await + } + async fn get_playlist(&self, playlist_id: i64) -> Result> { SqliteDatabase::get_playlist_impl(self, playlist_id).await } diff --git a/crates/vuio-core/src/logging.rs b/crates/vuio-core/src/logging.rs index 2b7c1a0..d03fe70 100644 --- a/crates/vuio-core/src/logging.rs +++ b/crates/vuio-core/src/logging.rs @@ -94,8 +94,9 @@ pub fn init_logging_with_options( "warn" }; + let default_console_directives = format!("{console_level},symphonia=off,symphonia_core=off,symphonia_format_isomp4=off,symphonia_bundle_mp3=off,symphonia_format_mkv=off,symphonia_format_ogg=off,symphonia_format_riff=off"); let console_filter = EnvFilter::try_from_default_env() - .or_else(|_| EnvFilter::try_new(console_level)) + .or_else(|_| EnvFilter::try_new(&default_console_directives)) .map_err(|error| { PlatformError::Configuration(crate::platform::ConfigurationError::ValidationFailed { reason: format!("Invalid console log level: {error}"), @@ -135,8 +136,9 @@ pub fn init_logging_with_options( let file_layer = match RotatingFile::open(resolved_log_file.clone()) { Ok(file) => { let file_level = if debug { "debug" } else { "info" }; - let file_filter = - EnvFilter::try_new(file_level).unwrap_or_else(|_| EnvFilter::new("info")); + let default_file_directives = format!("{file_level},symphonia=off,symphonia_core=off,symphonia_format_isomp4=off,symphonia_bundle_mp3=off,symphonia_format_mkv=off,symphonia_format_ogg=off,symphonia_format_riff=off"); + let file_filter = EnvFilter::try_new(&default_file_directives) + .unwrap_or_else(|_| EnvFilter::new("info")); Some( fmt::layer() .with_target(true) diff --git a/crates/vuio-core/src/media/scanner.rs b/crates/vuio-core/src/media/scanner.rs index 6c9cafb..c303325 100644 --- a/crates/vuio-core/src/media/scanner.rs +++ b/crates/vuio-core/src/media/scanner.rs @@ -15,6 +15,7 @@ impl MediaScanner { size: file.size, modified: file.modified, created_at: file.created_at, + tags_version: file.tags_version, } } @@ -244,6 +245,14 @@ impl MediaScanner { return true; } + // A record written by an older tag reader is stale even though its file + // is not. Non-recursive roots come through here rather than through + // `fingerprint_needs_update`, and without this they would never pick up + // an improved extractor. + if existing.tags_version < current.tags_version { + return true; + } + // Compare modification times with tolerance for Windows timestamp precision issues // Windows can have different precision depending on filesystem and access method let time_diff = if existing.modified > current.modified { @@ -263,6 +272,12 @@ impl MediaScanner { if existing.size != current.size { return true; } + // A record written by an older tag reader is stale even though its file + // is not. The file has already been parsed by the time we get here, so + // rewriting it costs one database write and no extra I/O. + if existing.tags_version < current.tags_version { + return true; + } let time_diff = if existing.modified > current.modified { existing.modified.duration_since(current.modified) } else { @@ -568,6 +583,10 @@ impl MediaScanner { track_number: None, year: None, album_artist: None, + tags: Default::default(), + stream: Default::default(), + extra_tags: Vec::new(), + tags_version: 0, subtitle_available: tokio::fs::try_exists(path.with_extension("srt")) .await .unwrap_or(false), diff --git a/crates/vuio-core/src/media/tests.rs b/crates/vuio-core/src/media/tests.rs index d9385a1..e674650 100644 --- a/crates/vuio-core/src/media/tests.rs +++ b/crates/vuio-core/src/media/tests.rs @@ -110,6 +110,10 @@ async fn test_scan_result_operations() { track_number: None, year: None, album_artist: None, + tags: Default::default(), + stream: Default::default(), + extra_tags: Vec::new(), + tags_version: 0, subtitle_available: false, created_at: SystemTime::now(), updated_at: SystemTime::now(), @@ -132,6 +136,10 @@ async fn test_scan_result_operations() { track_number: None, year: None, album_artist: None, + tags: Default::default(), + stream: Default::default(), + extra_tags: Vec::new(), + tags_version: 0, subtitle_available: false, created_at: SystemTime::now(), updated_at: SystemTime::now(), diff --git a/crates/vuio-core/src/platform/filesystem/manager.rs b/crates/vuio-core/src/platform/filesystem/manager.rs index 1d408cd..9a0a182 100644 --- a/crates/vuio-core/src/platform/filesystem/manager.rs +++ b/crates/vuio-core/src/platform/filesystem/manager.rs @@ -484,6 +484,10 @@ impl BaseFileSystemManager { track_number: None, year: None, album_artist: None, + tags: Default::default(), + stream: Default::default(), + extra_tags: Vec::new(), + tags_version: 0, subtitle_available, created_at: now, updated_at: now, diff --git a/crates/vuio-core/src/platform/filesystem/metadata.rs b/crates/vuio-core/src/platform/filesystem/metadata.rs index 768cbd9..1b7284b 100644 --- a/crates/vuio-core/src/platform/filesystem/metadata.rs +++ b/crates/vuio-core/src/platform/filesystem/metadata.rs @@ -1,80 +1,375 @@ +//! Reading tags, stream properties and cover art with symphonia. +//! +//! One reader covers every container symphonia can demux, so a library of OGG, +//! Opus, FLAC, AIFF or MP4 files categorizes the same way an MP3 library does. +//! APEv1 and APEv2 tags come along for free: symphonia registers its APE reader +//! as a probeable metadata source and scans for trailing metadata before it +//! looks for a container, which is exactly where APE tags live. +//! +//! Three gaps are worth knowing about, all of them upstream: +//! +//! - `.wma` has no ASF reader at all. +//! - A bare Monkey's Audio `.ape` file has no demuxer. The APE *tag* reader +//! cannot rescue a container that never probes. +//! - `.wav` reads no tags. symphonia 0.6.0's WAV reader parses the RIFF INFO +//! list into a metadata log and then overwrites that log with the empty one +//! from its options before returning, so the tags it collected are dropped. +//! AIFF, which shares the same crate, does this correctly. +//! +//! All three fall back to parsing the filename. + use super::*; +use crate::database::{AudioTags, MediaFile, StreamInfo}; +use std::time::Duration; +use symphonia::core::formats::probe::Hint; +use symphonia::core::formats::{FormatOptions, TrackType}; +use symphonia::core::io::MediaSourceStream; +use symphonia::core::meta::{MetadataOptions, MetadataRevision, RawValue, StandardTag}; + +/// Bumped whenever this module learns to extract something it used to miss. +/// +/// Records carry the version that wrote them, so a scan re-reads anything +/// written by an older extractor even though the file itself has not changed. +/// The file is opened and parsed on every scan regardless, so a bump costs one +/// database write per record and no extra I/O. +pub(crate) const TAGS_VERSION: u32 = 1; + +/// Longest tag value kept in `media_tags`. +/// +/// Lyrics sheets and acoustic fingerprints run to kilobytes and are indexed +/// alongside everything else, so they are dropped rather than allowed to +/// dominate the table. +const MAX_TAG_VALUE_LEN: usize = 4096; + +/// Tags whose values are large enough to be worth storing nowhere. +const OVERSIZED_TAGS: &[&str] = &["Lyrics", "AcoustIdFingerprint", "CdToc"]; -/// Extract audio metadata using audiotags library pub(crate) async fn extract_audio_metadata( media_file: &mut MediaFile, ) -> Result<(), Box> { - use std::time::Duration; - - // Clone the path for the blocking operation let path = media_file.path.clone(); - // Wrap the synchronous I/O operation in spawn_blocking to prevent blocking the async runtime - let metadata_result = - tokio::task::spawn_blocking(move || audiotags::Tag::new().read_from_path(&path)).await; + // Probing is synchronous file I/O and parsing, so it stays off the async + // runtime the same way the previous reader did. + match tokio::task::spawn_blocking(move || probe_metadata(&path)).await { + Ok(Ok(probed)) => probed.apply(media_file), + Ok(Err(error)) => { + tracing::debug!( + path = %media_file.path.display(), + %error, + "Failed to extract audio metadata during format probe; falling back to filename metadata" + ); + } + Err(error) => { + tracing::debug!( + path = %media_file.path.display(), + %error, + "Failed to execute blocking metadata extraction" + ); + } + } - // Handle the result from spawn_blocking - match metadata_result { - Ok(Ok(tag)) => { - // Extract basic metadata - if let Some(title) = tag.title() { - media_file.title = Some(title.to_string()); - } + // Always fall back to parsing from filename for missing fields + fallback_parse_filename(media_file); - if let Some(artist) = tag.artist() { - media_file.artist = Some(artist.to_string()); - } + Ok(()) +} - if let Some(album) = tag.album_title() { - media_file.album = Some(album.to_string()); - } +/// Read the first embedded picture from a file, if it has one. +/// +/// Used to serve cover art for tracks with no image file beside them. +pub(crate) fn extract_embedded_cover(path: &Path) -> Option<(String, Vec)> { + let mut format = open_format(path).ok()?; + let mut log = format.metadata(); + let mut cover = None; - if let Some(genre) = tag.genre() { - media_file.genre = Some(genre.to_string()); - } + // The newest revision wins, so keep overwriting as the log is drained from + // oldest to newest. + let mut absorb = |revision: &MetadataRevision| { + if let Some(visual) = revision.media.visuals.first() { + cover = Some(( + visual + .media_type + .clone() + .unwrap_or_else(|| "image/jpeg".to_owned()), + visual.data.to_vec(), + )); + } + }; + while let Some(revision) = log.pop() { + absorb(&revision); + } + if let Some(revision) = log.current() { + absorb(revision); + } + cover +} - // Extract track number - if let Some(track_num) = tag.track_number() { - media_file.track_number = Some(track_num as u32); - } +/// Everything one probe of a file yields. +#[derive(Default)] +struct ProbedMetadata { + title: Option, + artist: Option, + album: Option, + genre: Option, + track_number: Option, + year: Option, + album_artist: Option, + duration: Option, + tags: AudioTags, + stream: StreamInfo, + extra_tags: Vec<(String, String)>, +} - // Extract year - if let Some(year) = tag.year() { - media_file.year = Some(year as u32); +impl ProbedMetadata { + fn apply(self, media_file: &mut MediaFile) { + // A probe that found nothing must not clear what a caller already set, + // so every field is only written when the probe produced one. + if self.title.is_some() { + media_file.title = self.title; + } + if self.artist.is_some() { + media_file.artist = self.artist; + } + if self.album.is_some() { + media_file.album = self.album; + } + if self.genre.is_some() { + media_file.genre = self.genre; + } + if self.track_number.is_some() { + media_file.track_number = self.track_number; + } + if self.year.is_some() { + media_file.year = self.year; + } + if self.album_artist.is_some() { + media_file.album_artist = self.album_artist; + } + if self.duration.is_some() { + media_file.duration = self.duration; + } + + media_file.tags = self.tags; + media_file.stream = self.stream; + media_file.extra_tags = self.extra_tags; + + // Average bit rate over the whole file. No container reports this + // directly and DLNA wants an average anyway, so derive it from the two + // numbers that are always available. + if media_file.stream.bit_rate.is_none() { + if let Some(seconds) = media_file + .duration + .map(|duration| duration.as_secs_f64()) + .filter(|seconds| *seconds > 0.0) + { + let bits_per_second = (media_file.size as f64 * 8.0) / seconds; + if bits_per_second.is_finite() && bits_per_second > 0.0 { + media_file.stream.bit_rate = Some(bits_per_second as u32); + } } + } + + media_file.tags_version = TAGS_VERSION; + } +} - // Extract album artist - if let Some(album_artist) = tag.album_artist() { - media_file.album_artist = Some(album_artist.to_string()); +fn open_format( + path: &Path, +) -> anyhow::Result> { + let file = std::fs::File::open(path)?; + let stream = MediaSourceStream::new(Box::new(file), Default::default()); + let mut hint = Hint::new(); + if let Some(extension) = path.extension().and_then(|value| value.to_str()) { + hint.with_extension(extension); + } + let format = symphonia::default::get_probe().probe( + &hint, + stream, + FormatOptions::default(), + MetadataOptions::default(), + )?; + Ok(format) +} + +fn probe_metadata(path: &Path) -> anyhow::Result { + let mut format = open_format(path)?; + let mut probed = ProbedMetadata::default(); + + // Stream properties come off the default audio track. A container with no + // audio track still has usable tags, so this is not an error. + if let Some(track) = format.default_track(TrackType::Audio) { + let num_frames = track.num_frames; + if let Some(audio) = track.codec_params.as_ref().and_then(|params| params.audio()) { + probed.stream.codec = symphonia::default::get_codecs() + .get_audio_decoder(audio.codec) + .map(|registered| registered.codec.info.short_name.to_owned()); + probed.stream.sample_rate = audio.sample_rate; + probed.stream.channels = audio + .channels + .as_ref() + .map(|channels| channels.count() as u16); + probed.stream.bits_per_sample = audio + .bits_per_sample + .or(audio.bits_per_coded_sample) + .map(|bits| bits as u16); + + if let (Some(frames), Some(rate)) = (num_frames, audio.sample_rate.filter(|r| *r > 0)) { + probed.duration = Some(Duration::from_secs_f64(frames as f64 / f64::from(rate))); } + } + } + + // A file can carry more than one revision — ID3v2 at the head and APEv2 at + // the tail, say. Draining the log oldest-first and letting later writes win + // keeps every tag while still preferring the newest revision. + let mut log = format.metadata(); + while let Some(revision) = log.pop() { + absorb_revision(&revision, &mut probed); + } + if let Some(revision) = log.current() { + absorb_revision(revision, &mut probed); + } - // Extract duration if available - if let Some(duration) = tag.duration() { - media_file.duration = Some(Duration::from_secs(duration as u64)); + Ok(probed) +} + +fn absorb_revision(revision: &MetadataRevision, probed: &mut ProbedMetadata) { + for tag in &revision.media.tags { + let key = match &tag.std { + Some(standard) => { + // A tag with a column of its own is stored there, not repeated + // in the side table. + if apply_standard_tag(standard, probed) { + continue; + } + standard_tag_name(standard) } + None => tag.raw.key.clone(), + }; + + if OVERSIZED_TAGS.contains(&key.as_str()) { + continue; } - Ok(Err(e)) => { - // Failed to parse tags, but we still apply fallback filename parsing - debug!( - "Failed to extract metadata for {}: {}", - media_file.path.display(), - e - ); + let Some(value) = raw_value_to_string(&tag.raw.value) else { + continue; + }; + if value.is_empty() || value.len() > MAX_TAG_VALUE_LEN { + continue; } - Err(e) => { - // spawn_blocking failed - debug!( - "Failed to execute blocking metadata extraction for {}: {}", - media_file.path.display(), - e - ); + probed.extra_tags.push((key, value)); + } +} + +/// Fill the promoted fields from a tag symphonia recognised. +/// +/// Returns whether the tag has a column of its own, in which case it does not +/// also belong in the side table. +fn apply_standard_tag(tag: &StandardTag, probed: &mut ProbedMetadata) -> bool { + match tag { + StandardTag::TrackTitle(value) => probed.title = Some(value.to_string()), + StandardTag::Artist(value) => probed.artist = Some(value.to_string()), + StandardTag::Album(value) => probed.album = Some(value.to_string()), + StandardTag::Genre(value) => probed.genre = Some(value.to_string()), + StandardTag::AlbumArtist(value) => probed.album_artist = Some(value.to_string()), + // A value too large to be a real track or disc number is a malformed + // tag. Leaving the field alone keeps whatever an earlier revision of + // the metadata got right, rather than clearing it. + StandardTag::TrackNumber(value) => set_number(&mut probed.track_number, *value), + StandardTag::TrackTotal(value) => set_number(&mut probed.tags.track_total, *value), + StandardTag::DiscNumber(value) => set_number(&mut probed.tags.disc_number, *value), + StandardTag::DiscTotal(value) => set_number(&mut probed.tags.disc_total, *value), + StandardTag::Composer(value) => probed.tags.composer = Some(value.to_string()), + StandardTag::Comment(value) => probed.tags.comment = Some(value.to_string()), + StandardTag::Bpm(value) => set_number(&mut probed.tags.bpm, *value), + StandardTag::CompilationFlag(value) => probed.tags.compilation = Some(*value), + StandardTag::SortTrackTitle(value) => probed.tags.sort_title = Some(value.to_string()), + StandardTag::SortArtist(value) => probed.tags.sort_artist = Some(value.to_string()), + StandardTag::SortAlbum(value) => probed.tags.sort_album = Some(value.to_string()), + StandardTag::MusicBrainzTrackId(value) => { + probed.tags.musicbrainz_track_id = Some(value.to_string()) } + StandardTag::MusicBrainzAlbumId(value) => { + probed.tags.musicbrainz_album_id = Some(value.to_string()) + } + StandardTag::MusicBrainzArtistId(value) => { + probed.tags.musicbrainz_artist_id = Some(value.to_string()) + } + // Release date first, then the recording and original dates as + // fallbacks, so a reissue still reports the year the browse tree groups + // it under. Every dialect spells this differently: Vorbis comments use + // DATE, ID3v2.4 uses TDRC, ID3v2.3 uses TYER, and RIFF uses ICRD. + StandardTag::ReleaseDate(value) => set_date(probed, value, true), + StandardTag::RecordingDate(value) | StandardTag::OriginalReleaseDate(value) => { + set_date(probed, value, false) + } + StandardTag::ReleaseYear(value) => probed.year = Some(u32::from(*value)), + StandardTag::RecordingYear(value) + | StandardTag::OriginalReleaseYear(value) + | StandardTag::OriginalRecordingYear(value) => { + probed.year.get_or_insert(u32::from(*value)); + } + // Everything else keeps its place in the side table. + _ => return false, } + true +} - // Always fall back to parsing from filename for missing fields - fallback_parse_filename(media_file); +/// Store a count, ignoring one that cannot be a real one. +fn set_number(field: &mut Option, value: u64) { + if let Ok(value) = u32::try_from(value) { + *field = Some(value); + } +} - Ok(()) +/// Record a date string, taking its leading year for the Years category. +fn set_date(probed: &mut ProbedMetadata, value: &str, authoritative: bool) { + if authoritative || probed.tags.release_date.is_none() { + probed.tags.release_date = Some(value.to_owned()); + } + let year = value + .trim() + .get(..4) + .filter(|prefix| prefix.chars().all(|c| c.is_ascii_digit())) + .and_then(|prefix| prefix.parse::().ok()); + if let Some(year) = year { + if authoritative { + probed.year = Some(year); + } else { + probed.year.get_or_insert(year); + } + } +} + +/// The variant name of a standard tag, used as its normalized key. +/// +/// `StandardTag` is `#[non_exhaustive]` with around two hundred variants and no +/// accessor for its own name, so the name is taken from the `Debug` rendering, +/// which is `Variant(payload)`. Deriving it this way means new symphonia +/// variants get a sensible key without a match arm each. +fn standard_tag_name(tag: &StandardTag) -> String { + let rendered = format!("{tag:?}"); + match rendered.find('(') { + Some(index) => rendered[..index].to_owned(), + None => rendered, + } +} + +fn raw_value_to_string(value: &RawValue) -> Option { + match value { + RawValue::String(text) => Some(text.as_str().trim().to_owned()), + RawValue::StringList(items) => Some(items.join("; ")), + RawValue::UnsignedInt(number) => Some(number.to_string()), + RawValue::SignedInt(number) => Some(number.to_string()), + RawValue::Float(number) => Some(number.to_string()), + RawValue::Boolean(flag) => Some(flag.to_string()), + RawValue::Flag => Some("1".to_owned()), + // Binary payloads are pictures and fingerprints, which belong nowhere + // near a text index. `RawValue` is non-exhaustive, so anything symphonia + // adds later is skipped until it is handled explicitly. + RawValue::Binary(_) => None, + _ => None, + } } /// Parse metadata fields from a file path when tags are missing diff --git a/crates/vuio-core/src/platform/filesystem/mod.rs b/crates/vuio-core/src/platform/filesystem/mod.rs index e95ee04..6d4222f 100644 --- a/crates/vuio-core/src/platform/filesystem/mod.rs +++ b/crates/vuio-core/src/platform/filesystem/mod.rs @@ -32,5 +32,10 @@ pub(crate) async fn extract_audio_metadata( Ok(()) } +/// Records written without a tag reader carry version 0, so enabling the +/// feature later re-reads them on the next scan. +#[cfg(not(feature = "metadata"))] +pub(crate) const TAGS_VERSION: u32 = 0; + #[cfg(test)] mod tests; diff --git a/crates/vuio-core/src/platform/filesystem/tests.rs b/crates/vuio-core/src/platform/filesystem/tests.rs index 02ad171..d6821f8 100644 --- a/crates/vuio-core/src/platform/filesystem/tests.rs +++ b/crates/vuio-core/src/platform/filesystem/tests.rs @@ -88,6 +88,10 @@ fn test_fallback_parse_filename() { track_number: None, year: None, album_artist: None, + tags: Default::default(), + stream: Default::default(), + extra_tags: Vec::new(), + tags_version: 0, subtitle_available: false, created_at: SystemTime::UNIX_EPOCH, updated_at: SystemTime::UNIX_EPOCH, @@ -112,6 +116,10 @@ fn test_fallback_parse_filename() { track_number: None, year: None, album_artist: None, + tags: Default::default(), + stream: Default::default(), + extra_tags: Vec::new(), + tags_version: 0, subtitle_available: false, created_at: SystemTime::UNIX_EPOCH, updated_at: SystemTime::UNIX_EPOCH, @@ -136,6 +144,10 @@ fn test_fallback_parse_filename() { track_number: None, year: None, album_artist: None, + tags: Default::default(), + stream: Default::default(), + extra_tags: Vec::new(), + tags_version: 0, subtitle_available: false, created_at: SystemTime::UNIX_EPOCH, updated_at: SystemTime::UNIX_EPOCH, diff --git a/crates/vuio-core/src/web/soap.rs b/crates/vuio-core/src/web/soap.rs index 1824a67..39d1be9 100644 --- a/crates/vuio-core/src/web/soap.rs +++ b/crates/vuio-core/src/web/soap.rs @@ -16,19 +16,18 @@ use tracing::{debug, error, info, warn}; mod parser; use parser::*; -mod categories; mod common; mod connection; mod content_directory; mod metadata; +mod music; -use categories::*; use common::*; +use music::*; pub use connection::{ connection_manager_control, connection_manager_scpd, media_receiver_registrar_control, media_receiver_registrar_scpd, }; -use content_directory::ContentDirectoryHandler; pub use content_directory::{ content_directory_control, content_directory_scpd, description_handler, }; diff --git a/crates/vuio-core/src/web/soap/categories.rs b/crates/vuio-core/src/web/soap/categories.rs deleted file mode 100644 index d712e52..0000000 --- a/crates/vuio-core/src/web/soap/categories.rs +++ /dev/null @@ -1,370 +0,0 @@ -use super::*; - -impl ContentDirectoryHandler { - /// Handle artist browse requests with atomic performance tracking and database operations - pub(super) async fn handle_artist_browse( - params: &BrowseParams, - state: &AppState, - audio_path: &str, - ) -> Response { - let database = state.database.clone(); - handle_generic_category_browse( - params, - state, - audio_path, - "artists", - move || async move { database.get_artists().await }, - |artist| crate::database::MediaDirectory { - path: std::path::PathBuf::from(format!("audio/artists/{}", artist.name)), - name: format!("{} ({})", artist.name, artist.count), - }, - ) - .await - } - - /// Handle album browse requests with atomic performance tracking and database operations - pub(super) async fn handle_album_browse( - params: &BrowseParams, - state: &AppState, - audio_path: &str, - ) -> Response { - let database = state.database.clone(); - handle_generic_category_browse( - params, - state, - audio_path, - "albums", - move || async move { database.get_albums(None).await }, - |album| crate::database::MediaDirectory { - path: std::path::PathBuf::from(format!("audio/albums/{}", album.name)), - name: format!("{} ({})", album.name, album.count), - }, - ) - .await - } -} - -/// Handle browsing genres with atomic performance tracking and database operations -pub(super) async fn handle_genres_browse( - params: &BrowseParams, - state: &AppState, - audio_path: &str, -) -> Response { - let database = state.database.clone(); - handle_generic_category_browse( - params, - state, - audio_path, - "genres", - move || async move { database.get_genres().await }, - |genre| crate::database::MediaDirectory { - path: std::path::PathBuf::from(format!("audio/genres/{}", genre.name)), - name: format!("{} ({})", genre.name, genre.count), - }, - ) - .await -} - -/// Handle browsing years with atomic performance tracking and database operations -pub(super) async fn handle_years_browse( - params: &BrowseParams, - state: &AppState, - audio_path: &str, -) -> Response { - let database = state.database.clone(); - handle_generic_category_browse( - params, - state, - audio_path, - "years", - move || async move { database.get_years().await }, - |year| crate::database::MediaDirectory { - path: std::path::PathBuf::from(format!("audio/years/{}", year.name)), - name: format!("{} ({})", year.name, year.count), - }, - ) - .await -} - -/// Handle browsing playlists with atomic performance tracking and database operations -pub(super) async fn handle_playlists_browse( - params: &BrowseParams, - state: &AppState, - audio_path: &str, -) -> Response { - let database = state.database.clone(); - handle_generic_category_browse( - params, - state, - audio_path, - "playlists", - move || async move { database.get_playlists().await }, - |playlist| crate::database::MediaDirectory { - path: std::path::PathBuf::from(format!("audio/playlists/{}", playlist.id.unwrap_or(0))), - name: playlist.name, - }, - ) - .await -} - -/// Helper function to perform generic music category browsing -pub(super) async fn handle_generic_category_browse( - params: &BrowseParams, - state: &AppState, - audio_path: &str, - category_name: &str, - list_categories_fn: F, - map_category_fn: impl Fn(C) -> crate::database::MediaDirectory, -) -> Response -where - D: DatabaseManager + 'static, - F: FnOnce() -> FFuture, - FFuture: std::future::Future, anyhow::Error>>, -{ - use crate::web::xml::generate_browse_response; - - let start_time = Instant::now(); - - let client = crate::web::client::CURRENT_CLIENT - .try_with(|c| *c) - .unwrap_or(crate::web::client::DlnaClientProfile::Standard); - - let current_update_id = state.content_update_id.load(Ordering::SeqCst); - let browse_epoch = state.browse_cache.lock().await.epoch(); - let cache_key = crate::state::SoapCacheKey { - object_id: params.object_id.clone(), - starting_index: params.starting_index, - requested_count: params.requested_count, - client_profile: client, - content_update_id: current_update_id, - browse_epoch, - }; - - // Cache lookup - { - let mut cache = state.browse_cache.lock().await; - let needs_clear = cache - .generation() - .is_some_and(|generation| generation != current_update_id); - if needs_clear { - cache.clear(); - } - if let Some(cached_xml) = cache.get(&cache_key) { - let response_time = start_time.elapsed().as_micros() as u64; - state.web_metrics.record_browse_request(response_time, true); - debug!( - "Browse Cache Hit for Category ObjectID: {} ({}ms)", - params.object_id, response_time - ); - return ( - StatusCode::OK, - [ - (header::CONTENT_TYPE, "text/xml; charset=utf-8"), - (header::HeaderName::from_static("ext"), ""), - ], - cached_xml.clone(), - ) - .into_response(); - } - } - - // Find if we are browsing a category list (e.g. "artists") or filtering by a category value (e.g. "artists/AC/DC") - let (is_category_list, key_str_opt) = if let Some(slash_idx) = audio_path.find('/') { - let key_raw = &audio_path[slash_idx + 1..]; - let key_str = percent_encoding::percent_decode_str(key_raw) - .decode_utf8_lossy() - .into_owned(); - (false, Some(key_str)) - } else { - (true, None) - }; - - if is_category_list { - match list_categories_fn().await { - Ok(categories) => { - let has_data = !categories.is_empty(); - let subdirectories: Vec = - categories.into_iter().map(map_category_fn).collect(); - let total_matches = subdirectories.len(); - let page = browse_page_bounds(params, total_matches); - - let response_time = start_time.elapsed().as_micros() as u64; - state - .web_metrics - .record_browse_request(response_time, has_data); - - debug!( - "Retrieved {} {} in {}ms", - subdirectories.len(), - category_name, - response_time - ); - - let server_ip = state.get_server_ip(); - let response = generate_browse_response( - ¶ms.object_id, - &subdirectories[page], - &[], - state, - &server_ip, - total_matches, - ) - .await; - - // Cache insert - if state.content_update_id.load(Ordering::SeqCst) == current_update_id { - let mut cache = state.browse_cache.lock().await; - let needs_clear = cache - .generation() - .is_some_and(|generation| generation != current_update_id); - if needs_clear { - cache.clear(); - } - cache.insert(cache_key.clone(), response.clone().into()); - } - - ( - StatusCode::OK, - [ - (header::CONTENT_TYPE, "text/xml; charset=utf-8"), - (header::HeaderName::from_static("ext"), ""), - ], - response, - ) - .into_response() - } - Err(e) => { - error!("Database error getting {}: {}", category_name, e); - - let response_time = start_time.elapsed().as_micros() as u64; - state.web_metrics.record_error(); - state - .web_metrics - .record_browse_request(response_time, false); - - ( - StatusCode::INTERNAL_SERVER_ERROR, - [(header::CONTENT_TYPE, "text/plain; charset=utf-8")], - "Internal Server Error", - ) - .into_response() - } - } - } else if let Some(key_str) = key_str_opt { - let query = match category_name { - "artists" => crate::database::MediaFileQuery::Artist(key_str.clone()), - "albums" => crate::database::MediaFileQuery::Album { - album: key_str.clone(), - artist: None, - }, - "genres" => crate::database::MediaFileQuery::Genre(key_str.clone()), - "years" => match key_str.parse() { - Ok(year) => crate::database::MediaFileQuery::Year(year), - Err(_) => return (StatusCode::BAD_REQUEST, "Invalid year").into_response(), - }, - "playlists" => match key_str.parse() { - Ok(id) => crate::database::MediaFileQuery::Playlist(id), - Err(_) => return (StatusCode::BAD_REQUEST, "Invalid playlist ID").into_response(), - }, - _ => return (StatusCode::BAD_REQUEST, "Unknown category").into_response(), - }; - let requested_count = browse_page_limit(params); - let bookmarks = if matches!( - client, - crate::web::client::DlnaClientProfile::SamsungTv - | crate::web::client::DlnaClientProfile::SamsungTvQ - ) { - state.bookmarks.lock().await.snapshot() - } else { - std::collections::HashMap::new() - }; - let context = crate::web::xml::BrowseRenderContext { - client, - server_ip: state.get_server_ip(), - server_port: state.http_binding.port(), - autoplay_enabled: state.current_config().media.autoplay_enabled, - update_id: current_update_id, - bookmarks, - }; - let object_id = params.object_id.clone(); - let starting_index = params.starting_index as usize; - let database = state.database.clone(); - match database - .read(move |session| { - crate::web::xml::generate_indexed_items_response( - session, - query, - &object_id, - starting_index, - requested_count, - context, - ) - }) - .await - { - Ok(response) => { - let response_time = start_time.elapsed().as_micros() as u64; - state.web_metrics.record_browse_request(response_time, true); - - debug!( - "Retrieved {} tracks for {} '{}' in {}ms", - "zero-copy", category_name, key_str, response_time - ); - - // Cache insert - if state.content_update_id.load(Ordering::SeqCst) == current_update_id { - let mut cache = state.browse_cache.lock().await; - let needs_clear = cache - .generation() - .is_some_and(|generation| generation != current_update_id); - if needs_clear { - cache.clear(); - } - cache.insert(cache_key.clone(), response.clone()); - } - - ( - StatusCode::OK, - [ - (header::CONTENT_TYPE, "text/xml; charset=utf-8"), - (header::HeaderName::from_static("ext"), ""), - ], - response, - ) - .into_response() - } - Err(e) => { - error!( - "Database error getting music by {} {}: {}", - category_name, key_str, e - ); - - let response_time = start_time.elapsed().as_micros() as u64; - state.web_metrics.record_error(); - state - .web_metrics - .record_browse_request(response_time, false); - - ( - StatusCode::INTERNAL_SERVER_ERROR, - [(header::CONTENT_TYPE, "text/plain; charset=utf-8")], - "Internal Server Error", - ) - .into_response() - } - } - } else { - let response_time = start_time.elapsed().as_micros() as u64; - state.web_metrics.record_error(); - state - .web_metrics - .record_browse_request(response_time, false); - - ( - StatusCode::NOT_FOUND, - [(header::CONTENT_TYPE, "text/plain; charset=utf-8")], - format!("Invalid {} path", category_name), - ) - .into_response() - } -} diff --git a/crates/vuio-core/src/web/soap/content_directory.rs b/crates/vuio-core/src/web/soap/content_directory.rs index 1366d82..146cc94 100644 --- a/crates/vuio-core/src/web/soap/content_directory.rs +++ b/crates/vuio-core/src/web/soap/content_directory.rs @@ -459,29 +459,17 @@ pub async fn content_directory_control( let path_prefix_str = params.object_id.strip_prefix("video").unwrap_or("").trim_start_matches('/'); return ContentDirectoryHandler::handle_video_browse(¶ms, &state, path_prefix_str).await; } else if params.object_id.starts_with("audio") { - // Handle music categorization within audio section let audio_path = params.object_id.strip_prefix("audio").unwrap_or("").trim_start_matches('/'); - // Check for music categorization paths - if audio_path.is_empty() { - // Root audio container - return categorization containers - return handle_audio_root_browse(¶ms, &state).await; - } else if audio_path.starts_with("artists") { - return ContentDirectoryHandler::handle_artist_browse(¶ms, &state, audio_path).await; - } else if audio_path.starts_with("albums") { - return ContentDirectoryHandler::handle_album_browse(¶ms, &state, audio_path).await; - } else if audio_path.starts_with("genres") { - return handle_genres_browse(¶ms, &state, audio_path).await; - } else if audio_path.starts_with("years") { - return handle_years_browse(¶ms, &state, audio_path).await; - } else if audio_path.starts_with("playlists") { - return handle_playlists_browse(¶ms, &state, audio_path).await; - } else if audio_path.starts_with("folders") { - let folder_path = audio_path.strip_prefix("folders").unwrap_or("").trim_start_matches('/'); - return ContentDirectoryHandler::handle_music_browse(¶ms, &state, folder_path).await; - } else { - // Traditional folder browsing within audio - return ContentDirectoryHandler::handle_music_browse(¶ms, &state, audio_path).await; + // Every music container is one MusicNode. A path that names no + // node is browsed as a plain folder, which keeps object ids + // minted by older versions working. + match parse_music_path(audio_path) { + Some(MusicNode::Folders(folder_path)) => { + return ContentDirectoryHandler::handle_music_browse(¶ms, &state, &folder_path).await + } + Some(node) => return handle_music_node_browse(¶ms, &state, node).await, + None => return ContentDirectoryHandler::handle_music_browse(¶ms, &state, audio_path).await, } } else if params.object_id.starts_with("image") { let path_prefix_str = params.object_id.strip_prefix("image").unwrap_or("").trim_start_matches('/'); diff --git a/crates/vuio-core/src/web/soap/metadata.rs b/crates/vuio-core/src/web/soap/metadata.rs index 7079d87..ecc313e 100644 --- a/crates/vuio-core/src/web/soap/metadata.rs +++ b/crates/vuio-core/src/web/soap/metadata.rs @@ -9,12 +9,13 @@ pub(super) async fn handle_browse_metadata( use crate::web::xml::generate_container_metadata_response; let update_id = state.content_update_id.load(Ordering::SeqCst); - let (parent_id, title, child_count) = + let (parent_id, title, class, child_count) = resolve_container_metadata(¶ms.object_id, state).await; let response = generate_container_metadata_response( ¶ms.object_id, &parent_id, &title, + class, child_count, update_id, ); @@ -32,28 +33,81 @@ pub(super) async fn handle_browse_metadata( pub(super) async fn resolve_container_metadata( object_id: &str, state: &AppState, -) -> (String, String, usize) { +) -> (String, String, &'static str, usize) { + use crate::web::xml::container_class::STORAGE_FOLDER; + if object_id == "0" { return ( "-1".to_string(), state.current_config().server.name.clone(), + STORAGE_FOLDER, 4, // Video, Music, Pictures, Radio ); } - if object_id == "audio" { - return ("0".to_string(), "Music".to_string(), 6); - } if object_id == "video" { let count = count_media_folder_children(state, "video/", "").await; - return ("0".to_string(), "Video".to_string(), count.max(1)); + return ( + "0".to_string(), + "Video".to_string(), + STORAGE_FOLDER, + count.max(1), + ); } if object_id == "image" { let count = count_media_folder_children(state, "image/", "").await; - return ("0".to_string(), "Pictures".to_string(), count.max(1)); + return ( + "0".to_string(), + "Pictures".to_string(), + STORAGE_FOLDER, + count.max(1), + ); } if object_id == "radio" { - return ("0".to_string(), "Radio".to_string(), 1); + return ( + "0".to_string(), + "Radio".to_string(), + STORAGE_FOLDER, + 1, + ); + } + + // Music containers describe themselves, so their title, parent and class + // come from the same place the browse response builds them from. Samsung + // decides a folder is empty from childCount, so a container that is known + // to hold something must never report zero. + if let Some(audio_path) = object_id.strip_prefix("audio") { + let audio_path = audio_path.trim_start_matches('/'); + match parse_music_path(audio_path) { + Some(MusicNode::Folders(folder)) if !folder.is_empty() => { + let count = count_media_folder_children(state, "audio/", &folder).await; + let node = MusicNode::Folders(folder); + return (node.parent_id(), node.title(), node.class(), count.max(1)); + } + Some(node) => { + let count = music_child_count(&node, state).await; + let title = display_title(&node, state).await; + return (node.parent_id(), title, node.class(), count); + } + // An id that names no node is a folder path minted before the + // tree grew its `folders/` segment. It still browses as one, so it + // is still counted as one — with the audio filter, which the + // generic branch below has no way to apply. + None => { + let count = count_media_folder_children(state, "audio/", audio_path).await; + let parent = object_id + .rsplit_once('/') + .map(|(parent, _)| parent.to_string()) + .unwrap_or_else(|| "audio".to_string()); + let title = audio_path + .rsplit('/') + .next() + .filter(|part| !part.is_empty()) + .unwrap_or("Music") + .to_string(); + return (parent, title, STORAGE_FOLDER, count.max(1)); + } + } } let (media_filter, path_prefix, parent_id, title) = @@ -73,46 +127,6 @@ pub(super) async fn resolve_container_metadata( "0".to_string() }; ("video/", rest.to_string(), parent, title) - } else if let Some(rest) = object_id.strip_prefix("audio") { - let rest = rest.trim_start_matches('/'); - if rest.is_empty() - || matches!( - rest, - "artists" | "albums" | "genres" | "years" | "playlists" | "folders" - ) - { - let title = match rest { - "" => "Music", - "artists" => "Artists", - "albums" => "Albums", - "genres" => "Genres", - "years" => "Years", - "playlists" => "Playlists", - "folders" => "Folders", - _ => "Music", - }; - return ( - if rest.is_empty() { - "0".to_string() - } else { - "audio".to_string() - }, - title.to_string(), - 1, - ); - } - let folder = rest.strip_prefix("folders/").unwrap_or(rest); - let title = folder - .rsplit('/') - .next() - .filter(|part| !part.is_empty()) - .unwrap_or("Folders") - .to_string(); - let parent = object_id - .rsplit_once('/') - .map(|(parent, _)| parent.to_string()) - .unwrap_or_else(|| "audio".to_string()); - ("audio/", folder.to_string(), parent, title) } else if let Some(rest) = object_id.strip_prefix("image") { let rest = rest.trim_start_matches('/'); let title = rest @@ -140,7 +154,33 @@ pub(super) async fn resolve_container_metadata( }; let count = count_media_folder_children(state, media_filter, &path_prefix).await; - (parent_id, title, count.max(1)) + ( + parent_id, + title, + crate::web::xml::container_class::STORAGE_FOLDER, + count.max(1), + ) +} + +/// How many children a music container has, for BrowseMetadata. +/// +/// A track listing reports at least one rather than counting rows: Samsung only +/// uses the number to decide whether the container is worth opening, and +/// counting every track of every album to answer a probe is not worth it. +async fn music_child_count( + node: &MusicNode, + state: &AppState, +) -> usize { + if node.track_query().is_some() { + return 1; + } + match child_containers(node, state).await { + Ok(containers) => containers.len().max(1), + Err(error) => { + warn!(%error, "failed to count music container children for BrowseMetadata"); + 1 + } + } } pub(super) async fn count_media_folder_children( @@ -230,51 +270,3 @@ pub(super) async fn count_media_folder_children( } } -/// Handle browsing the root audio container with music categorization -pub(super) async fn handle_audio_root_browse( - params: &BrowseParams, - state: &AppState, -) -> Response { - use crate::web::xml::generate_browse_response; - - // Create virtual categorization containers - let virtual_containers = vec![ - ("audio/artists", "Artists"), - ("audio/albums", "Albums"), - ("audio/genres", "Genres"), - ("audio/years", "Years"), - ("audio/playlists", "Playlists"), - ("audio/folders", "Folders"), - ]; - - // Convert to MediaDirectory for XML generation - let subdirectories: Vec = virtual_containers - .into_iter() - .map(|(id, name)| crate::database::MediaDirectory { - path: std::path::PathBuf::from(id), - name: name.to_string(), - }) - .collect(); - - let total_matches = subdirectories.len(); - let page = browse_page_bounds(params, total_matches); - let server_ip = state.get_server_ip(); - let response = generate_browse_response( - ¶ms.object_id, - &subdirectories[page], - &[], - state, - &server_ip, - total_matches, - ) - .await; - ( - StatusCode::OK, - [ - (header::CONTENT_TYPE, "text/xml; charset=utf-8"), - (header::HeaderName::from_static("ext"), ""), - ], - response, - ) - .into_response() -} diff --git a/crates/vuio-core/src/web/soap/music.rs b/crates/vuio-core/src/web/soap/music.rs new file mode 100644 index 0000000..2d087ca --- /dev/null +++ b/crates/vuio-core/src/web/soap/music.rs @@ -0,0 +1,770 @@ +//! The music browse tree. +//! +//! Everything under `audio` other than the folder view is described by +//! [`MusicNode`]: one enum listing every container the tree can address, one +//! parser turning an object id into it, and one builder turning it back into +//! child ids. Adding a level means adding a variant, not another handler. +//! +//! ## Object ids +//! +//! A node's id is its path through the tree, `audio/artists/Metallica/Ride the +//! Lightning`. Tag values become path segments, so they are percent-encoded: +//! without that, an artist called `AC/DC` is indistinguishable from an artist +//! `AC` holding an album `DC`. +//! +//! Structural segments are spelled `!all` and the encoder escapes `!`, so no +//! tag value can ever collide with one — an album genuinely called `!all` +//! encodes as `%21all` and still resolves to itself. + +use super::*; +use crate::database::{MusicCategoryFilter, MusicCategoryType}; +use crate::web::xml::{container_class, generate_container_list_response, ContainerSpec}; +use percent_encoding::{percent_decode_str, utf8_percent_encode, AsciiSet, CONTROLS}; + +/// Characters that must not survive into an object id verbatim. +/// +/// `/` is the path separator, `%` is the escape itself, and `!` introduces a +/// structural segment. The rest are escaped because they are awkward inside XML +/// attributes and URLs even though the writer already escapes XML. +const ID_SEGMENT: &AsciiSet = &CONTROLS + .add(b'%') + .add(b'/') + .add(b'!') + .add(b'?') + .add(b'#') + .add(b'&') + .add(b'"') + .add(b'\'') + .add(b'<') + .add(b'>'); + +/// The structural segment meaning "every track at this level". +const ALL: &str = "!all"; + +pub(super) fn encode_segment(value: &str) -> String { + utf8_percent_encode(value, ID_SEGMENT).to_string() +} + +fn decode_segment(value: &str) -> String { + percent_decode_str(value).decode_utf8_lossy().into_owned() +} + +/// Every container and track listing the music tree can address. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) enum MusicNode { + /// `audio` — the six category containers. + Root, + /// `audio/!all` — every track in the library. + AllMusic, + + /// `audio/artists` + ArtistList, + /// `audio/artists/{artist}` — that artist's albums, plus All Songs. + Artist(String), + /// `audio/artists/{artist}/!all` + ArtistAll(String), + /// `audio/artists/{artist}/{album}` + ArtistAlbum(String, String), + + /// `audio/albumartists` + AlbumArtistList, + /// `audio/albumartists/{album_artist}` + AlbumArtist(String), + /// `audio/albumartists/{album_artist}/!all` + AlbumArtistAll(String), + /// `audio/albumartists/{album_artist}/{album}` + AlbumArtistAlbum(String, String), + + /// `audio/albums` + AlbumList, + /// `audio/albums/{album}` + Album(String), + + /// `audio/genres` + GenreList, + /// `audio/genres/{genre}` — that genre's artists, plus All Songs. + Genre(String), + /// `audio/genres/{genre}/!all` + GenreAll(String), + /// `audio/genres/{genre}/{artist}` + GenreArtist(String, String), + /// `audio/genres/{genre}/{artist}/!all` + GenreArtistAll(String, String), + /// `audio/genres/{genre}/{artist}/{album}` + GenreArtistAlbum(String, String, String), + + /// `audio/years` + YearList, + /// `audio/years/{year}` + Year(u32), + + /// `audio/playlists` + PlaylistList, + /// `audio/playlists/{id}` + Playlist(i64), + + /// `audio/folders/...` — the filesystem view, handled by the folder browse. + Folders(String), +} + +/// Parse the part of an object id after `audio/`. +/// +/// Returns `None` for a path that names no node, which the caller answers as a +/// legacy folder browse rather than an error. +pub(super) fn parse_music_path(audio_path: &str) -> Option { + let path = audio_path.trim_matches('/'); + if path.is_empty() { + return Some(MusicNode::Root); + } + + let mut segments = path.split('/'); + let head = segments.next()?; + + // The folder view keeps raw filesystem paths, which are not encoded. + if head == "folders" { + let rest = path.strip_prefix("folders").unwrap_or("").trim_start_matches('/'); + return Some(MusicNode::Folders(rest.to_owned())); + } + + let rest: Vec<&str> = segments.collect(); + let value = |index: usize| decode_segment(rest[index]); + let is_all = |index: usize| rest[index] == ALL; + + match (head, rest.len()) { + (ALL, 0) => Some(MusicNode::AllMusic), + + ("artists", 0) => Some(MusicNode::ArtistList), + ("artists", 1) if is_all(0) => None, + ("artists", 1) => Some(MusicNode::Artist(value(0))), + ("artists", 2) if is_all(1) => Some(MusicNode::ArtistAll(value(0))), + ("artists", 2) => Some(MusicNode::ArtistAlbum(value(0), value(1))), + + ("albumartists", 0) => Some(MusicNode::AlbumArtistList), + ("albumartists", 1) if is_all(0) => None, + ("albumartists", 1) => Some(MusicNode::AlbumArtist(value(0))), + ("albumartists", 2) if is_all(1) => Some(MusicNode::AlbumArtistAll(value(0))), + ("albumartists", 2) => Some(MusicNode::AlbumArtistAlbum(value(0), value(1))), + + ("albums", 0) => Some(MusicNode::AlbumList), + ("albums", 1) if is_all(0) => None, + ("albums", 1) => Some(MusicNode::Album(value(0))), + + ("genres", 0) => Some(MusicNode::GenreList), + ("genres", 1) if is_all(0) => None, + ("genres", 1) => Some(MusicNode::Genre(value(0))), + ("genres", 2) if is_all(1) => Some(MusicNode::GenreAll(value(0))), + ("genres", 2) => Some(MusicNode::GenreArtist(value(0), value(1))), + ("genres", 3) if is_all(2) => Some(MusicNode::GenreArtistAll(value(0), value(1))), + ("genres", 3) => Some(MusicNode::GenreArtistAlbum(value(0), value(1), value(2))), + + ("years", 0) => Some(MusicNode::YearList), + ("years", 1) => value(0).parse().ok().map(MusicNode::Year), + + ("playlists", 0) => Some(MusicNode::PlaylistList), + ("playlists", 1) => value(0).parse().ok().map(MusicNode::Playlist), + + _ => None, + } +} + +impl MusicNode { + /// The object id that addresses this node. + pub(super) fn object_id(&self) -> String { + let join = |parts: &[&str]| { + let mut id = String::from("audio"); + for part in parts { + id.push('/'); + id.push_str(part); + } + id + }; + match self { + Self::Root => "audio".to_owned(), + Self::AllMusic => join(&[ALL]), + + Self::ArtistList => join(&["artists"]), + Self::Artist(artist) => join(&["artists", &encode_segment(artist)]), + Self::ArtistAll(artist) => join(&["artists", &encode_segment(artist), ALL]), + Self::ArtistAlbum(artist, album) => join(&[ + "artists", + &encode_segment(artist), + &encode_segment(album), + ]), + + Self::AlbumArtistList => join(&["albumartists"]), + Self::AlbumArtist(who) => join(&["albumartists", &encode_segment(who)]), + Self::AlbumArtistAll(who) => join(&["albumartists", &encode_segment(who), ALL]), + Self::AlbumArtistAlbum(who, album) => join(&[ + "albumartists", + &encode_segment(who), + &encode_segment(album), + ]), + + Self::AlbumList => join(&["albums"]), + Self::Album(album) => join(&["albums", &encode_segment(album)]), + + Self::GenreList => join(&["genres"]), + Self::Genre(genre) => join(&["genres", &encode_segment(genre)]), + Self::GenreAll(genre) => join(&["genres", &encode_segment(genre), ALL]), + Self::GenreArtist(genre, artist) => join(&[ + "genres", + &encode_segment(genre), + &encode_segment(artist), + ]), + Self::GenreArtistAll(genre, artist) => join(&[ + "genres", + &encode_segment(genre), + &encode_segment(artist), + ALL, + ]), + Self::GenreArtistAlbum(genre, artist, album) => join(&[ + "genres", + &encode_segment(genre), + &encode_segment(artist), + &encode_segment(album), + ]), + + Self::YearList => join(&["years"]), + Self::Year(year) => join(&["years", &year.to_string()]), + + Self::PlaylistList => join(&["playlists"]), + Self::Playlist(id) => join(&["playlists", &id.to_string()]), + + Self::Folders(path) if path.is_empty() => join(&["folders"]), + Self::Folders(path) => format!("audio/folders/{path}"), + } + } + + /// The object id of the container this node sits in. + pub(super) fn parent_id(&self) -> String { + match self { + Self::Root => "0".to_owned(), + Self::AllMusic + | Self::ArtistList + | Self::AlbumArtistList + | Self::AlbumList + | Self::GenreList + | Self::YearList + | Self::PlaylistList => "audio".to_owned(), + + Self::Artist(_) => Self::ArtistList.object_id(), + Self::ArtistAll(artist) | Self::ArtistAlbum(artist, _) => { + Self::Artist(artist.clone()).object_id() + } + + Self::AlbumArtist(_) => Self::AlbumArtistList.object_id(), + Self::AlbumArtistAll(who) | Self::AlbumArtistAlbum(who, _) => { + Self::AlbumArtist(who.clone()).object_id() + } + + Self::Album(_) => Self::AlbumList.object_id(), + + Self::Genre(_) => Self::GenreList.object_id(), + Self::GenreAll(genre) | Self::GenreArtist(genre, _) => { + Self::Genre(genre.clone()).object_id() + } + Self::GenreArtistAll(genre, artist) | Self::GenreArtistAlbum(genre, artist, _) => { + Self::GenreArtist(genre.clone(), artist.clone()).object_id() + } + + Self::Year(_) => Self::YearList.object_id(), + Self::Playlist(_) => Self::PlaylistList.object_id(), + + Self::Folders(path) => match path.rsplit_once('/') { + Some((parent, _)) => format!("audio/folders/{parent}"), + None if path.is_empty() => "audio".to_owned(), + None => "audio/folders".to_owned(), + }, + } + } + + /// The title a control point shows for this node. + pub(super) fn title(&self) -> String { + match self { + Self::Root => "Music".to_owned(), + Self::AllMusic => "All Music".to_owned(), + Self::ArtistList => "Artists".to_owned(), + Self::AlbumArtistList => "Album Artists".to_owned(), + Self::AlbumList => "Albums".to_owned(), + Self::GenreList => "Genres".to_owned(), + Self::YearList => "Years".to_owned(), + Self::PlaylistList => "Playlists".to_owned(), + + Self::ArtistAll(_) + | Self::AlbumArtistAll(_) + | Self::GenreAll(_) + | Self::GenreArtistAll(_, _) => "All Songs".to_owned(), + + Self::Artist(value) + | Self::AlbumArtist(value) + | Self::Album(value) + | Self::Genre(value) => value.clone(), + Self::ArtistAlbum(_, album) + | Self::AlbumArtistAlbum(_, album) + | Self::GenreArtistAlbum(_, _, album) => album.clone(), + Self::GenreArtist(_, artist) => artist.clone(), + + Self::Year(year) => year.to_string(), + // A playlist's name lives in the database, not in its object id. + // `display_title` resolves it; this is only the fallback for a + // playlist that has since been deleted. + Self::Playlist(id) => format!("Playlist {id}"), + + Self::Folders(path) if path.is_empty() => "Folders".to_owned(), + Self::Folders(path) => path.rsplit('/').next().unwrap_or("Folders").to_owned(), + } + } + + /// The UPnP class this node announces itself as. + pub(super) fn class(&self) -> &'static str { + match self { + Self::Artist(_) | Self::AlbumArtist(_) | Self::GenreArtist(_, _) => { + container_class::MUSIC_ARTIST + } + Self::Album(_) + | Self::ArtistAlbum(_, _) + | Self::AlbumArtistAlbum(_, _) + | Self::GenreArtistAlbum(_, _, _) => container_class::MUSIC_ALBUM, + Self::Genre(_) => container_class::MUSIC_GENRE, + Self::Playlist(_) => container_class::PLAYLIST, + _ => container_class::STORAGE_FOLDER, + } + } + + /// The tracks this node lists, or `None` if it lists containers instead. + pub(super) fn track_query(&self) -> Option { + use crate::database::MediaFileQuery::{Music, Playlist}; + + let music = |filter: MusicCategoryFilter| { + Some(Music { + artist: filter.artist, + album_artist: filter.album_artist, + album: filter.album, + genre: filter.genre, + year: filter.year, + exclude_radio: true, + }) + }; + + match self { + Self::AllMusic => music(MusicCategoryFilter::default()), + Self::ArtistAll(artist) => music(MusicCategoryFilter::artist(artist)), + Self::ArtistAlbum(artist, album) => music(MusicCategoryFilter { + album: Some(album.clone()), + ..MusicCategoryFilter::artist(artist) + }), + Self::AlbumArtistAll(who) => music(MusicCategoryFilter::album_artist(who)), + Self::AlbumArtistAlbum(who, album) => music(MusicCategoryFilter { + album: Some(album.clone()), + ..MusicCategoryFilter::album_artist(who) + }), + Self::Album(album) => music(MusicCategoryFilter { + album: Some(album.clone()), + ..MusicCategoryFilter::default() + }), + Self::GenreAll(genre) => music(MusicCategoryFilter::genre(genre)), + Self::GenreArtistAll(genre, artist) => { + music(MusicCategoryFilter::genre(genre).with_artist(artist)) + } + Self::GenreArtistAlbum(genre, artist, album) => music(MusicCategoryFilter { + album: Some(album.clone()), + ..MusicCategoryFilter::genre(genre).with_artist(artist) + }), + Self::Year(year) => music(MusicCategoryFilter { + year: Some(*year), + ..MusicCategoryFilter::default() + }), + Self::Playlist(id) => Some(Playlist(*id)), + _ => None, + } + } +} + +/// The six containers directly under Music. +pub(super) fn root_children() -> Vec { + vec![ + MusicNode::AllMusic, + MusicNode::ArtistList, + MusicNode::AlbumArtistList, + MusicNode::AlbumList, + MusicNode::GenreList, + MusicNode::YearList, + MusicNode::PlaylistList, + MusicNode::Folders(String::new()), + ] +} + +/// The containers a node holds. Empty for a node that lists tracks instead; +/// see [`MusicNode::track_query`]. +pub(super) async fn child_containers( + node: &MusicNode, + state: &AppState, +) -> anyhow::Result> { + use MusicCategoryType as Kind; + + let specs = match node { + MusicNode::Root => root_children() + .into_iter() + .map(|child| spec_for(&child, 1, None)) + .collect(), + + MusicNode::ArtistList => { + category_specs( + state, + Kind::Artist, + MusicCategoryFilter::default(), + Some(Kind::Album), + &MusicNode::Artist, + ) + .await? + } + + MusicNode::AlbumArtistList => { + category_specs( + state, + Kind::AlbumArtist, + MusicCategoryFilter::default(), + Some(Kind::Album), + &MusicNode::AlbumArtist, + ) + .await? + } + + MusicNode::AlbumList => { + category_specs( + state, + Kind::Album, + MusicCategoryFilter::default(), + None, + &MusicNode::Album, + ) + .await? + } + + MusicNode::GenreList => { + category_specs( + state, + Kind::Genre, + MusicCategoryFilter::default(), + Some(Kind::Artist), + &MusicNode::Genre, + ) + .await? + } + + // Years are stored as integers, so a value that will not parse is a + // record with a malformed tag rather than a browsable container. + MusicNode::YearList => { + let database = state.database.clone(); + database + .get_music_categories(Kind::Year, &MusicCategoryFilter::default(), None) + .await? + .into_iter() + .filter_map(|category| { + let year = category.name.parse().ok()?; + Some(spec_for( + &MusicNode::Year(year), + category.count, + category.sample_id, + )) + }) + .collect() + } + + // An artist container holds their albums, with All Songs first so a + // renderer can play everything without descending. + MusicNode::Artist(artist) => { + let filter = MusicCategoryFilter::artist(artist); + album_children(state, MusicNode::ArtistAll(artist.clone()), filter, &|album| { + MusicNode::ArtistAlbum(artist.clone(), album) + }) + .await? + } + + MusicNode::AlbumArtist(who) => { + let filter = MusicCategoryFilter::album_artist(who); + album_children( + state, + MusicNode::AlbumArtistAll(who.clone()), + filter, + &|album| MusicNode::AlbumArtistAlbum(who.clone(), album), + ) + .await? + } + + // A genre holds its artists, matching minidlna's genre/artist/album + // shape, with All Songs at the top. + MusicNode::Genre(genre) => { + let filter = MusicCategoryFilter::genre(genre); + let mut specs = vec![spec_for(&MusicNode::GenreAll(genre.clone()), 1, None)]; + specs.extend( + category_specs(state, Kind::Artist, filter, Some(Kind::Album), &|artist| { + MusicNode::GenreArtist(genre.clone(), artist) + }) + .await?, + ); + specs + } + + MusicNode::GenreArtist(genre, artist) => { + let filter = MusicCategoryFilter::genre(genre).with_artist(artist); + album_children( + state, + MusicNode::GenreArtistAll(genre.clone(), artist.clone()), + filter, + &|album| MusicNode::GenreArtistAlbum(genre.clone(), artist.clone(), album), + ) + .await? + } + + MusicNode::PlaylistList => { + let database = state.database.clone(); + let playlists = database.get_playlists().await?; + let counts = database.count_playlist_entries().await?; + playlists + .into_iter() + .filter_map(|playlist| { + let id = playlist.id?; + let count = counts.get(&id).copied().unwrap_or(0); + Some( + ContainerSpec::folder(MusicNode::Playlist(id).object_id(), playlist.name) + .with_class(container_class::PLAYLIST) + .with_child_count(count), + ) + }) + .collect() + } + + _ => Vec::new(), + }; + + Ok(specs) +} + +/// One container per distinct value of a tag, carrying its real child count. +/// +/// `child_of` names the tag one level down. Given it, each container counts its +/// sub-containers — plus the All Songs node the tree inserts — rather than the +/// tracks underneath, which is a different and much larger number. +async fn category_specs( + state: &AppState, + kind: MusicCategoryType, + filter: MusicCategoryFilter, + child_of: Option, + to_node: &(dyn Fn(String) -> MusicNode + Sync), +) -> anyhow::Result> { + let database = state.database.clone(); + let found = database + .get_music_categories(kind, &filter, child_of) + .await?; + Ok(found + .into_iter() + .map(|category| { + let child = to_node(category.name.clone()); + let children = match category.child_count { + Some(count) => count + 1, // the All Songs node + None => category.count, + }; + spec_for(&child, children, category.sample_id) + }) + .collect()) +} + +/// The albums under an artist-like container, preceded by All Songs. +async fn album_children( + state: &AppState, + all_node: MusicNode, + filter: MusicCategoryFilter, + to_album: &(dyn Fn(String) -> MusicNode + Sync), +) -> anyhow::Result> { + let database = state.database.clone(); + let found = database + .get_music_categories(MusicCategoryType::Album, &filter, None) + .await?; + let mut specs = vec![spec_for(&all_node, 1, None)]; + specs.extend(found.into_iter().map(|category| { + let child = to_album(category.name.clone()); + spec_for(&child, category.count, category.sample_id) + })); + Ok(specs) +} + +/// The title to show for a node, resolving the ones the object id cannot carry. +/// +/// A control point that probes a container with `BrowseMetadata` must be told +/// the same name its parent's listing used, or the two disagree about one +/// object. +pub(super) async fn display_title( + node: &MusicNode, + state: &AppState, +) -> String { + if let MusicNode::Playlist(id) = node { + if let Ok(Some(playlist)) = state.database.get_playlist(*id).await { + return playlist.name; + } + } + node.title() +} + +fn spec_for(node: &MusicNode, child_count: usize, album_art_id: Option) -> ContainerSpec { + ContainerSpec::folder(node.object_id(), node.title()) + .with_class(node.class()) + .with_child_count(child_count.max(1)) + .with_album_art(album_art_id) +} + +pub(super) async fn render_context( + state: &AppState, +) -> crate::web::xml::BrowseRenderContext { + let client = crate::web::client::CURRENT_CLIENT + .try_with(|client| *client) + .unwrap_or(crate::web::client::DlnaClientProfile::Standard); + let bookmarks = if matches!( + client, + crate::web::client::DlnaClientProfile::SamsungTv + | crate::web::client::DlnaClientProfile::SamsungTvQ + ) { + state.bookmarks.lock().await.snapshot() + } else { + std::collections::HashMap::new() + }; + crate::web::xml::BrowseRenderContext { + client, + server_ip: state.get_server_ip(), + server_port: state.http_binding.port(), + autoplay_enabled: state.current_config().media.autoplay_enabled, + update_id: state.content_update_id.load(Ordering::SeqCst), + bookmarks, + } +} + +/// Serve any node of the music tree. +/// +/// Containers and tracks differ only in how the body is produced, so the cache +/// lookup, cache insert and metrics around them are written once here. +pub(super) async fn handle_music_node_browse( + params: &BrowseParams, + state: &AppState, + node: MusicNode, +) -> Response { + let start_time = Instant::now(); + let client = crate::web::client::CURRENT_CLIENT + .try_with(|c| *c) + .unwrap_or(crate::web::client::DlnaClientProfile::Standard); + + let current_update_id = state.content_update_id.load(Ordering::SeqCst); + let browse_epoch = state.browse_cache.lock().await.epoch(); + let cache_key = crate::state::SoapCacheKey { + object_id: params.object_id.clone(), + starting_index: params.starting_index, + requested_count: params.requested_count, + client_profile: client, + content_update_id: current_update_id, + browse_epoch, + }; + + { + let mut cache = state.browse_cache.lock().await; + if cache + .generation() + .is_some_and(|generation| generation != current_update_id) + { + cache.clear(); + } + if let Some(cached) = cache.get(&cache_key) { + let elapsed = start_time.elapsed().as_micros() as u64; + state.web_metrics.record_browse_request(elapsed, true); + debug!( + "Browse cache hit for music ObjectID {} ({}us)", + params.object_id, elapsed + ); + return xml_response(cached.clone()); + } + } + + let context = render_context(state).await; + let object_id = node.object_id(); + + let body = match node.track_query() { + // A leaf lists tracks, paged inside the read transaction so no record + // is materialized just to be skipped. + Some(query) => { + let starting_index = params.starting_index as usize; + let requested_count = browse_page_limit(params); + let database = state.database.clone(); + let owned_id = object_id.clone(); + match database + .read(move |session| { + crate::web::xml::generate_indexed_items_response( + session, + query, + &owned_id, + starting_index, + requested_count, + context, + ) + }) + .await + { + Ok(response) => response, + Err(error) => { + error!(%error, "Music browse failed for {}", params.object_id); + state.web_metrics.record_error(); + state + .web_metrics + .record_browse_request(start_time.elapsed().as_micros() as u64, false); + return (StatusCode::INTERNAL_SERVER_ERROR, "Internal Server Error") + .into_response(); + } + } + } + None => match child_containers(&node, state).await { + Ok(containers) => { + let total = containers.len(); + let page = browse_page_bounds(params, total); + generate_container_list_response(&object_id, &containers[page], total, &context) + .into() + } + Err(error) => { + error!(%error, "Music category listing failed for {}", params.object_id); + state.web_metrics.record_error(); + state + .web_metrics + .record_browse_request(start_time.elapsed().as_micros() as u64, false); + return (StatusCode::INTERNAL_SERVER_ERROR, "Internal Server Error") + .into_response(); + } + }, + }; + + let elapsed = start_time.elapsed().as_micros() as u64; + state.web_metrics.record_browse_request(elapsed, false); + debug!("Served music ObjectID {} in {}us", params.object_id, elapsed); + + // A scan that landed while this response was being built has already + // invalidated it, so it must not enter the cache. + if state.content_update_id.load(Ordering::SeqCst) == current_update_id { + let mut cache = state.browse_cache.lock().await; + if cache + .generation() + .is_some_and(|generation| generation != current_update_id) + { + cache.clear(); + } + cache.insert(cache_key, body.clone()); + } + + xml_response(body) +} + +fn xml_response(body: axum::body::Bytes) -> Response { + ( + StatusCode::OK, + [ + (header::CONTENT_TYPE, "text/xml; charset=utf-8"), + (header::HeaderName::from_static("ext"), ""), + ], + body, + ) + .into_response() +} diff --git a/crates/vuio-core/src/web/soap/tests.rs b/crates/vuio-core/src/web/soap/tests.rs index e09e072..718ac4d 100644 --- a/crates/vuio-core/src/web/soap/tests.rs +++ b/crates/vuio-core/src/web/soap/tests.rs @@ -132,6 +132,216 @@ fn test_parse_browse_params_performance_comparison() { assert_eq!(params.requested_count, 50); } +/// An object id must survive the round trip through a control point. +fn round_trip(node: MusicNode) { + let id = node.object_id(); + let audio_path = id.strip_prefix("audio").unwrap().trim_start_matches('/'); + assert_eq!( + parse_music_path(audio_path), + Some(node.clone()), + "id {id} did not parse back to {node:?}" + ); +} + +#[test] +fn music_ids_round_trip_through_every_node_shape() { + round_trip(MusicNode::Root); + round_trip(MusicNode::AllMusic); + + round_trip(MusicNode::ArtistList); + round_trip(MusicNode::Artist("Metallica".into())); + round_trip(MusicNode::ArtistAll("Metallica".into())); + round_trip(MusicNode::ArtistAlbum( + "Metallica".into(), + "Ride the Lightning".into(), + )); + + round_trip(MusicNode::AlbumArtistList); + round_trip(MusicNode::AlbumArtist("Various Artists".into())); + round_trip(MusicNode::AlbumArtistAll("Various Artists".into())); + round_trip(MusicNode::AlbumArtistAlbum( + "Various Artists".into(), + "Now 42".into(), + )); + + round_trip(MusicNode::AlbumList); + round_trip(MusicNode::Album("Ride the Lightning".into())); + + round_trip(MusicNode::GenreList); + round_trip(MusicNode::Genre("Metal".into())); + round_trip(MusicNode::GenreAll("Metal".into())); + round_trip(MusicNode::GenreArtist("Metal".into(), "Metallica".into())); + round_trip(MusicNode::GenreArtistAll("Metal".into(), "Metallica".into())); + round_trip(MusicNode::GenreArtistAlbum( + "Metal".into(), + "Metallica".into(), + "Ride the Lightning".into(), + )); + + round_trip(MusicNode::YearList); + round_trip(MusicNode::Year(1984)); + + round_trip(MusicNode::PlaylistList); + round_trip(MusicNode::Playlist(7)); + + round_trip(MusicNode::Folders(String::new())); + round_trip(MusicNode::Folders("Rock/Live".into())); +} + +/// A slash in a tag value is why the segments are encoded at all: without it +/// the artist "AC/DC" is indistinguishable from an artist "AC" holding an +/// album "DC". +#[test] +fn a_slash_in_a_tag_value_stays_inside_its_segment() { + let node = MusicNode::Artist("AC/DC".into()); + assert_eq!(node.object_id(), "audio/artists/AC%2FDC"); + round_trip(node); + + round_trip(MusicNode::ArtistAlbum( + "AC/DC".into(), + "Back in Black".into(), + )); + round_trip(MusicNode::GenreArtistAlbum( + "Rock/Metal".into(), + "AC/DC".into(), + "Who Made Who".into(), + )); +} + +/// Percent signs must not be read as the escapes the decoder introduces. +#[test] +fn a_percent_in_a_tag_value_is_not_read_as_an_escape() { + let node = MusicNode::Album("50% More".into()); + assert_eq!(node.object_id(), "audio/albums/50%25 More"); + round_trip(node); + + // The classic failure: a literal "%2F" in a name decoding to a slash. + round_trip(MusicNode::Artist("%2F".into())); +} + +/// The reserved segment cannot be forged by a tag value, because the encoder +/// escapes the character that introduces it. +#[test] +fn an_album_named_like_the_reserved_segment_still_resolves() { + let album = MusicNode::ArtistAlbum("Metallica".into(), "!all".into()); + assert_eq!(album.object_id(), "audio/artists/Metallica/%21all"); + round_trip(album); + + // And the reserved segment itself still means All Songs. + assert_eq!( + parse_music_path("artists/Metallica/!all"), + Some(MusicNode::ArtistAll("Metallica".into())) + ); +} + +#[test] +fn music_nodes_report_their_parent_and_class() { + use crate::web::xml::container_class; + + let album = MusicNode::ArtistAlbum("Metallica".into(), "Ride the Lightning".into()); + assert_eq!(album.parent_id(), "audio/artists/Metallica"); + assert_eq!(album.class(), container_class::MUSIC_ALBUM); + + assert_eq!( + MusicNode::Artist("Metallica".into()).class(), + container_class::MUSIC_ARTIST + ); + assert_eq!( + MusicNode::Genre("Metal".into()).class(), + container_class::MUSIC_GENRE + ); + assert_eq!( + MusicNode::Playlist(1).class(), + container_class::PLAYLIST + ); + // A genre's artist is still an artist container. + assert_eq!( + MusicNode::GenreArtist("Metal".into(), "Metallica".into()).class(), + container_class::MUSIC_ARTIST + ); + // The category lists themselves are plain folders. + assert_eq!( + MusicNode::ArtistList.class(), + container_class::STORAGE_FOLDER + ); + assert_eq!(MusicNode::ArtistList.parent_id(), "audio"); + assert_eq!(MusicNode::Root.parent_id(), "0"); +} + +/// Only the leaves list tracks; every other node lists containers. +#[test] +fn track_listings_are_the_leaves_of_the_tree() { + assert!(MusicNode::AllMusic.track_query().is_some()); + assert!(MusicNode::ArtistAll("A".into()).track_query().is_some()); + assert!(MusicNode::ArtistAlbum("A".into(), "B".into()) + .track_query() + .is_some()); + assert!(MusicNode::Album("B".into()).track_query().is_some()); + assert!(MusicNode::Year(1984).track_query().is_some()); + assert!(MusicNode::Playlist(1).track_query().is_some()); + + assert!(MusicNode::Root.track_query().is_none()); + assert!(MusicNode::ArtistList.track_query().is_none()); + assert!(MusicNode::Artist("A".into()).track_query().is_none()); + assert!(MusicNode::Genre("G".into()).track_query().is_none()); + assert!(MusicNode::GenreArtist("G".into(), "A".into()) + .track_query() + .is_none()); +} + +/// An album under an artist must be constrained by that artist, or two records +/// sharing a title merge into one listing. +#[test] +fn an_album_reached_through_an_artist_is_scoped_to_it() { + use crate::database::MediaFileQuery; + + let query = MusicNode::ArtistAlbum("Metallica".into(), "Greatest Hits".into()) + .track_query() + .unwrap(); + match query { + MediaFileQuery::Music { + artist, + album, + exclude_radio, + .. + } => { + assert_eq!(artist.as_deref(), Some("Metallica")); + assert_eq!(album.as_deref(), Some("Greatest Hits")); + assert!(exclude_radio, "radio streams are not music library tracks"); + } + other => panic!("unexpected query: {other:?}"), + } + + let query = MusicNode::GenreArtistAlbum("Metal".into(), "Metallica".into(), "X".into()) + .track_query() + .unwrap(); + match query { + MediaFileQuery::Music { + genre, + artist, + album, + .. + } => { + assert_eq!(genre.as_deref(), Some("Metal")); + assert_eq!(artist.as_deref(), Some("Metallica")); + assert_eq!(album.as_deref(), Some("X")); + } + other => panic!("unexpected query: {other:?}"), + } +} + +/// An id that names no node is browsed as a folder, so object ids minted by +/// older versions keep working. +#[test] +fn an_unrecognized_music_path_is_not_a_node() { + assert_eq!(parse_music_path("artists/A/B/C/D"), None); + assert_eq!(parse_music_path("nonsense/deep/path"), None); + assert_eq!(parse_music_path("years/not-a-year"), None); + assert_eq!(parse_music_path("playlists/not-a-number"), None); + // "!all" is a structural segment, never a tag value. + assert_eq!(parse_music_path("artists/!all"), None); +} + #[test] fn test_parse_dir_index_prefix() { assert_eq!(parse_dir_index_prefix("d0"), (Some(0), "")); diff --git a/crates/vuio-core/src/web/streaming.rs b/crates/vuio-core/src/web/streaming.rs index da02ab1..5155bbc 100644 --- a/crates/vuio-core/src/web/streaming.rs +++ b/crates/vuio-core/src/web/streaming.rs @@ -503,27 +503,22 @@ pub async fn serve_cover( } } - // 2. Secondary: Extract embedded artwork from audio tags using audiotags + // 2. Secondary: Extract embedded artwork from the file's own metadata // (blocking task). Only the embedded path needs a tag reader — the // directory search above still serves cover art without the feature. #[cfg(feature = "metadata")] { let path = file_info.path.clone(); - let tag_result = - tokio::task::spawn_blocking(move || audiotags::Tag::new().read_from_path(&path)).await; - - if let Ok(Ok(tag)) = tag_result { - if let Some(cover) = tag.album_cover() { - let content_type = match cover.mime_type { - audiotags::MimeType::Jpeg => "image/jpeg", - audiotags::MimeType::Png => "image/png", - _ => "image/jpeg", - }; - return Response::builder() - .header(header::CONTENT_TYPE, content_type) - .body(Body::from(cover.data.to_vec())) - .map_err(|_| AppError::NotFound); - } + let cover = tokio::task::spawn_blocking(move || { + crate::platform::filesystem::extract_embedded_cover(&path) + }) + .await; + + if let Ok(Some((content_type, data))) = cover { + return Response::builder() + .header(header::CONTENT_TYPE, content_type) + .body(Body::from(data)) + .map_err(|_| AppError::NotFound); } } diff --git a/crates/vuio-core/src/web/ui/js/admin.js b/crates/vuio-core/src/web/ui/js/admin.js index 5ec55b9..b72d949 100644 --- a/crates/vuio-core/src/web/ui/js/admin.js +++ b/crates/vuio-core/src/web/ui/js/admin.js @@ -518,6 +518,15 @@ function renderAdminLibraries(body) { const effective = (adminData.effective_directories || [])[index] || adminData.library_defaults || {}; + const defaultExcludes = (effective.exclude_patterns && effective.exclude_patterns.length > 0) + ? effective.exclude_patterns + : ((adminData.library_defaults && adminData.library_defaults.exclude_patterns) + ? adminData.library_defaults.exclude_patterns + : ['.*', '.DS_Store', '.AppleDouble', '.Trashes', '*.tmp', '.fseventsd']); + const excludeVal = (directory.exclude_patterns !== undefined && directory.exclude_patterns !== null) + ? directory.exclude_patterns + : defaultExcludes; + grid.appendChild( libraryField( 'Extensions', @@ -530,8 +539,8 @@ function renderAdminLibraries(body) { libraryList( index, 'exclude_patterns', - directory.exclude_patterns, - (effective.exclude_patterns || []).join(', ') || 'None' + excludeVal, + 'One entry per line' ) ) ); @@ -545,7 +554,14 @@ function renderAdminLibraries(body) { add.textContent = 'Add a library folder'; add.disabled = adminReadOnly(); add.onclick = () => { - adminDirectories = adminDirectoryList().concat([{ path: '', recursive: true }]); + const defaultExcludes = (adminData.library_defaults && adminData.library_defaults.exclude_patterns) + ? adminData.library_defaults.exclude_patterns.slice() + : ['.*', '.DS_Store', '.AppleDouble', '.Trashes', '*.tmp', '.fseventsd']; + adminDirectories = adminDirectoryList().concat([{ + path: '', + recursive: true, + exclude_patterns: defaultExcludes + }]); renderAdmin(); }; body.appendChild(add); diff --git a/crates/vuio-core/src/web/xml.rs b/crates/vuio-core/src/web/xml.rs index 2e223f9..b57a50c 100644 --- a/crates/vuio-core/src/web/xml.rs +++ b/crates/vuio-core/src/web/xml.rs @@ -17,7 +17,8 @@ mod rendering; pub use browse::*; pub use descriptions::*; pub use rendering::{ - generate_indexed_browse_response, generate_indexed_items_response, BrowseRenderContext, + container_class, generate_indexed_browse_response, generate_indexed_items_response, + BrowseRenderContext, ContainerSpec, }; #[cfg(test)] diff --git a/crates/vuio-core/src/web/xml/browse.rs b/crates/vuio-core/src/web/xml/browse.rs index a88442b..16b92aa 100644 --- a/crates/vuio-core/src/web/xml/browse.rs +++ b/crates/vuio-core/src/web/xml/browse.rs @@ -50,50 +50,18 @@ pub async fn generate_browse_response( } let path_str = container.path.to_string_lossy(); - let container_id = if path_str.starts_with("audio/") - || path_str.starts_with("video/") - || path_str.starts_with("image/") - || path_str.starts_with("radio/") - || path_str == "audio" - || path_str == "video" - || path_str == "image" - || path_str == "radio" - { - path_str.into_owned() - } else if path_str.starts_with('d') && path_str[1..].chars().all(|c| c.is_ascii_digit()) - { - format!("{}/{}", object_id.trim_end_matches('/'), path_str) - } else { - format!("{}/{}", object_id.trim_end_matches('/'), container.name) - }; - - let _ = write!( + let spec = ContainerSpec::folder( + directory_container_id(object_id, &path_str, &container.name), + &container.name, + ); + let _ = write_container( &mut didl, - r#"{}object.container.storageFolder"#, - xml_escape(&container_id), - xml_escape(object_id), - xml_escape(&container.name) + &spec, + object_id, + client, + server_ip, + state.http_binding.port(), ); - - if client == crate::web::client::DlnaClientProfile::SonyBdp - || client == crate::web::client::DlnaClientProfile::SonyBravia - || client == crate::web::client::DlnaClientProfile::PlayStation - { - let class_char = if container_id.contains("audio") || container_id.contains("music") - { - "A" - } else if container_id.contains("image") || container_id.contains("picture") { - "P" - } else { - "V" - }; - let _ = write!( - &mut didl, - r#"{}"#, - class_char - ); - } - didl.push_str(""); } let mut bookmarks_guard = if client == crate::web::client::DlnaClientProfile::SamsungTv @@ -388,12 +356,63 @@ pub async fn generate_browse_response( final_response } +/// A DIDL document listing containers only. +/// +/// Every level of the music tree above the tracks is built from this, which is +/// why it takes fully-formed [`ContainerSpec`]s rather than deriving ids and +/// classes from a path the way the folder browse does. +pub fn generate_container_list_response( + object_id: &str, + containers: &[ContainerSpec], + total_matches: usize, + context: &BrowseRenderContext, +) -> String { + use std::fmt::Write; + + let mut response = String::with_capacity(750 + containers.len() * 300); + response.push_str( + r#" + + + + "#, + ); + let mut didl = SoapResultWriter(&mut response); + didl.push_str(r#""#); + for container in containers { + let _ = write_container( + &mut didl, + container, + object_id, + context.client, + &context.server_ip, + context.server_port, + ); + } + didl.push_str(""); + let _ = write!( + &mut response, + r#" + {} + {} + {} + + +"#, + containers.len(), + total_matches, + context.update_id + ); + response +} + /// Single-container BrowseMetadata response. Samsung TVs probe folders this way and /// use `childCount` to decide whether a folder is empty. pub fn generate_container_metadata_response( object_id: &str, parent_id: &str, title: &str, + class: &str, child_count: usize, update_id: u32, ) -> String { @@ -411,10 +430,11 @@ pub fn generate_container_metadata_response( didl.push_str(r#""#); let _ = write!( &mut didl, - r#"{}object.container.storageFolder"#, + r#"{}{}"#, xml_escape(object_id), xml_escape(parent_id), - xml_escape(title) + xml_escape(title), + class ); didl.push_str(""); let _ = write!( diff --git a/crates/vuio-core/src/web/xml/rendering.rs b/crates/vuio-core/src/web/xml/rendering.rs index 68d80e8..a6821ab 100644 --- a/crates/vuio-core/src/web/xml/rendering.rs +++ b/crates/vuio-core/src/web/xml/rendering.rs @@ -145,44 +145,91 @@ pub struct BrowseRenderContext { pub bookmarks: HashMap, } -pub(super) fn write_directory( +/// UPnP container classes. +/// +/// Control points key display off these: a `musicAlbum` gets album art and +/// track listing, a `musicArtist` gets grouped under Artists, a +/// `playlistContainer` offers "play all". Announcing everything as a +/// `storageFolder` is what makes a categorized library look like a file tree. +pub mod container_class { + pub const STORAGE_FOLDER: &str = "object.container.storageFolder"; + pub const MUSIC_ARTIST: &str = "object.container.person.musicArtist"; + pub const MUSIC_ALBUM: &str = "object.container.album.musicAlbum"; + pub const MUSIC_GENRE: &str = "object.container.genre.musicGenre"; + pub const PLAYLIST: &str = "object.container.playlistContainer"; +} + +/// One container as it will be rendered into a DIDL document. +#[derive(Clone, Debug)] +pub struct ContainerSpec { + pub id: String, + pub title: String, + pub class: &'static str, + pub child_count: usize, + /// A record whose cover art represents this container, if it has one. + pub album_art_id: Option, +} + +impl ContainerSpec { + pub fn folder(id: impl Into, title: impl Into) -> Self { + Self { + id: id.into(), + title: title.into(), + class: container_class::STORAGE_FOLDER, + child_count: 1, + album_art_id: None, + } + } + + pub fn with_class(mut self, class: &'static str) -> Self { + self.class = class; + self + } + + pub fn with_child_count(mut self, child_count: usize) -> Self { + self.child_count = child_count; + self + } + + pub fn with_album_art(mut self, album_art_id: Option) -> Self { + self.album_art_id = album_art_id; + self + } +} + +/// The one place a `` element is written. +pub(super) fn write_container( output: &mut W, - object_id: &str, - container: &D, + spec: &ContainerSpec, + parent_id: &str, client: crate::web::client::DlnaClientProfile, + server_ip: &str, + server_port: u16, ) -> std::fmt::Result { - let path = container.path(); - let container_id = if path.starts_with("audio/") - || path.starts_with("video/") - || path.starts_with("image/") - || path.starts_with("radio/") - || path == "audio" - || path == "video" - || path == "image" - || path == "radio" - { - path.to_owned() - } else if path.starts_with('d') && path[1..].chars().all(|c| c.is_ascii_digit()) { - format!("{}/{}", object_id.trim_end_matches('/'), path) - } else { - format!("{}/{}", object_id.trim_end_matches('/'), container.name()) - }; write!( output, - r#"{}object.container.storageFolder"#, - xml_escape(&container_id), - xml_escape(object_id), - xml_escape(container.name()) + r#"{}{}"#, + xml_escape(&spec.id), + xml_escape(parent_id), + spec.child_count, + xml_escape(&spec.title), + spec.class )?; + if let Some(art_id) = spec.album_art_id { + write!( + output, + "http://{server_ip}:{server_port}/media/{art_id}/cover" + )?; + } if matches!( client, crate::web::client::DlnaClientProfile::SonyBdp | crate::web::client::DlnaClientProfile::SonyBravia | crate::web::client::DlnaClientProfile::PlayStation ) { - let class = if container_id.contains("audio") || container_id.contains("music") { + let class = if spec.id.contains("audio") || spec.id.contains("music") { "A" - } else if container_id.contains("image") || container_id.contains("picture") { + } else if spec.id.contains("image") || spec.id.contains("picture") { "P" } else { "V" @@ -195,6 +242,45 @@ pub(super) fn write_directory( output.write_str("") } +/// Derive the object id a filesystem-backed subdirectory browses under. +pub(super) fn directory_container_id(object_id: &str, path: &str, name: &str) -> String { + if path.starts_with("audio/") + || path.starts_with("video/") + || path.starts_with("image/") + || path.starts_with("radio/") + || path == "audio" + || path == "video" + || path == "image" + || path == "radio" + { + path.to_owned() + } else if path.starts_with('d') && path[1..].chars().all(|c| c.is_ascii_digit()) { + format!("{}/{}", object_id.trim_end_matches('/'), path) + } else { + format!("{}/{}", object_id.trim_end_matches('/'), name) + } +} + +pub(super) fn write_directory( + output: &mut W, + object_id: &str, + container: &D, + context: &BrowseRenderContext, +) -> std::fmt::Result { + let spec = ContainerSpec::folder( + directory_container_id(object_id, container.path(), container.name()), + container.name(), + ); + write_container( + output, + &spec, + object_id, + context.client, + &context.server_ip, + context.server_port, + ) +} + pub(super) fn write_media_view( output: &mut W, object_id: &str, @@ -246,6 +332,13 @@ pub(super) fn write_media_view( xml_escape(value) )?; } + if let Some(value) = file.composer() { + write!( + output, + r#"{}"#, + xml_escape(value) + )?; + } write!( output, "http://{}:{}/media/{}/cover", @@ -300,6 +393,23 @@ pub(super) fn write_media_view( )?; } } + if !is_radio { + // Renderers use these to decide whether they can play a track before + // fetching a byte of it. Note that DLNA's `res@bitrate` is *bytes* per + // second, not bits, which is the usual thing to get wrong. + if let Some(bits_per_second) = file.bit_rate().filter(|rate| *rate > 0) { + write!(output, r#" bitrate="{}""#, bits_per_second / 8)?; + } + if let Some(sample_rate) = file.sample_rate().filter(|rate| *rate > 0) { + write!(output, r#" sampleFrequency="{sample_rate}""#)?; + } + if let Some(channels) = file.channels().filter(|count| *count > 0) { + write!(output, r#" nrAudioChannels="{channels}""#)?; + } + if let Some(bits) = file.bits_per_sample().filter(|bits| *bits > 0) { + write!(output, r#" bitsPerSample="{bits}""#)?; + } + } if matches!( context.client, crate::web::client::DlnaClientProfile::LgTv @@ -388,7 +498,7 @@ pub fn generate_indexed_browse_response( starting_index, directory_limit, |directory| { - write_directory(&mut result, object_id, &directory, context.client) + write_directory(&mut result, object_id, &directory, &context) .map_err(|_| anyhow::anyhow!("failed to construct directory XML")) }, )?; diff --git a/crates/vuio-core/tests/audio_integration_tests.rs b/crates/vuio-core/tests/audio_integration_tests.rs index 7d3576a..970b999 100644 --- a/crates/vuio-core/tests/audio_integration_tests.rs +++ b/crates/vuio-core/tests/audio_integration_tests.rs @@ -595,3 +595,247 @@ https://cast1.asurahosting.com/proxy/julien/stream "https://cast1.asurahosting.com/proxy/julien/stream" ); } + +/// An ID3v2.3 tag, built by hand. +/// +/// The tests need a *writer*, and symphonia only reads, so the tag bytes are +/// assembled here rather than by a second tagging library. +fn id3v2_tag(frames: &[(&[u8; 4], &str)]) -> Vec { + let mut body = Vec::new(); + for (id, text) in frames { + let mut payload = vec![0u8]; // ISO-8859-1 encoding marker + payload.extend_from_slice(text.as_bytes()); + body.extend_from_slice(*id); + // v2.3 frame sizes are plain big-endian, unlike the synchsafe header. + body.extend_from_slice(&(payload.len() as u32).to_be_bytes()); + body.extend_from_slice(&[0, 0]); // flags + body.extend_from_slice(&payload); + } + + let size = body.len(); + let mut tag = b"ID3".to_vec(); + tag.extend_from_slice(&[3, 0, 0]); // version 2.3, no flags + tag.extend_from_slice(&[ + ((size >> 21) & 0x7f) as u8, + ((size >> 14) & 0x7f) as u8, + ((size >> 7) & 0x7f) as u8, + (size & 0x7f) as u8, + ]); + tag.extend_from_slice(&body); + tag +} + +/// A minimal AIFF carrying an ID3 chunk. +/// +/// AIFF is the point of the exercise: it is a container the previous tag reader +/// could not open at all, so a library of these indexed with no artist, album +/// or genre — which is what "the categories are empty" on issue #11 looked like. +fn aiff_with_id3(title: &str, artist: &str, album: &str, genre: &str, year: &str) -> Vec { + fn chunk(id: &[u8; 4], payload: &[u8]) -> Vec { + let mut out = Vec::with_capacity(8 + payload.len() + 1); + out.extend_from_slice(id); + out.extend_from_slice(&(payload.len() as u32).to_be_bytes()); + out.extend_from_slice(payload); + if payload.len() % 2 == 1 { + out.push(0); // IFF chunks are word aligned + } + out + } + + // COMM: channels, frame count, bits per sample, then a 10-byte extended + // float sample rate. 0x400EAC44… is 44100 Hz. + let mut comm = Vec::new(); + comm.extend_from_slice(&2i16.to_be_bytes()); + comm.extend_from_slice(&2u32.to_be_bytes()); + comm.extend_from_slice(&16i16.to_be_bytes()); + comm.extend_from_slice(&[0x40, 0x0e, 0xac, 0x44, 0, 0, 0, 0, 0, 0]); + + let tag = id3v2_tag(&[ + (b"TIT2", title), + (b"TPE1", artist), + (b"TALB", album), + (b"TCON", genre), + (b"TYER", year), + (b"TRCK", "4"), + ]); + + let mut ssnd = vec![0u8; 8]; // offset and block size + ssnd.extend_from_slice(&[0u8; 8]); // one frame of silence + + let mut body = b"AIFF".to_vec(); + body.extend(chunk(b"COMM", &comm)); + body.extend(chunk(b"ID3 ", &tag)); + body.extend(chunk(b"SSND", &ssnd)); + + let mut file = b"FORM".to_vec(); + file.extend_from_slice(&(body.len() as u32).to_be_bytes()); + file.extend_from_slice(&body); + file +} + +/// Append an APEv2 tag, the way a tagger writes one onto an existing file. +fn with_apev2_tag(mut audio: Vec, items: &[(&str, &str)]) -> Vec { + let mut body = Vec::new(); + for (key, value) in items { + body.extend_from_slice(&(value.len() as u32).to_le_bytes()); + body.extend_from_slice(&0u32.to_le_bytes()); // flags: UTF-8 text + body.extend_from_slice(key.as_bytes()); + body.push(0); + body.extend_from_slice(value.as_bytes()); + } + + audio.extend_from_slice(&body); + audio.extend_from_slice(b"APETAGEX"); + audio.extend_from_slice(&2000u32.to_le_bytes()); // version 2 + audio.extend_from_slice(&((body.len() + 32) as u32).to_le_bytes()); + audio.extend_from_slice(&(items.len() as u32).to_le_bytes()); + audio.extend_from_slice(&0u32.to_le_bytes()); // footer only, no header + audio.extend_from_slice(&[0u8; 8]); // reserved + audio +} + +/// The regression behind issue #11: a library in a container the old tag reader +/// could not open produced categories with nothing in them. +#[tokio::test] +#[cfg_attr( + target_os = "freebsd", + ignore = "SIGSEGV in FreeBSD CI QEMU guest (scanner harness)" +)] +async fn tags_from_a_container_the_old_reader_could_not_open() { + use vuio_core::database::{MusicCategoryFilter, MusicCategoryType}; + + let temp_dir = tempdir().unwrap(); + let raw_media_dir = temp_dir.path().join("media"); + fs::create_dir_all(&raw_media_dir).unwrap(); + let media_dir = fs::canonicalize(raw_media_dir).unwrap(); + + let db = Arc::new( + SqliteDatabase::new(temp_dir.path().join("aiff.db")) + .await + .unwrap(), + ); + db.initialize().await.unwrap(); + + let path = media_dir.join("silence.aiff"); + fs::write( + &path, + aiff_with_id3("Quiet", "Aphex Twin", "Selected Ambient", "Ambient", "1992"), + ) + .unwrap(); + + let scanner = MediaScanner::with_database(db.clone()); + scanner.scan_directory_recursive(&media_dir).await.unwrap(); + + let file = db.get_file_by_path(&path).await.unwrap().unwrap(); + assert_eq!(file.title.as_deref(), Some("Quiet")); + assert_eq!(file.artist.as_deref(), Some("Aphex Twin")); + assert_eq!(file.album.as_deref(), Some("Selected Ambient")); + assert_eq!(file.genre.as_deref(), Some("Ambient")); + assert_eq!(file.year, Some(1992)); + assert_eq!(file.track_number, Some(4)); + + // Stream properties come off the same probe, and DIDL advertises them. + assert_eq!(file.stream.sample_rate, Some(44_100)); + assert_eq!(file.stream.channels, Some(2)); + assert_eq!(file.stream.bits_per_sample, Some(16)); + + // Stamped with the reader's version, so it is not rewritten on every scan. + assert!(file.tags_version >= 1); + + // And the categories the issue asked for are populated, not empty. + let names = |categories: Vec| { + categories + .into_iter() + .map(|category| category.name) + .collect::>() + }; + assert_eq!(names(db.get_artists().await.unwrap()), ["Aphex Twin"]); + assert_eq!( + names(db.get_albums(None).await.unwrap()), + ["Selected Ambient"] + ); + assert_eq!(names(db.get_genres().await.unwrap()), ["Ambient"]); + assert_eq!(names(db.get_years().await.unwrap()), ["1992"]); + assert_eq!( + names( + db.get_music_categories( + MusicCategoryType::Album, + &MusicCategoryFilter::artist("Aphex Twin"), + None, + ) + .await + .unwrap() + ), + ["Selected Ambient"] + ); +} + +/// APEv2 tags sit at the end of the file, in a metadata revision of their own. +/// +/// A file can carry both those and ID3 frames, so the reader drains the whole +/// metadata log rather than skipping to the newest revision, which would drop +/// whichever set came first. +#[tokio::test] +#[cfg_attr( + target_os = "freebsd", + ignore = "SIGSEGV in FreeBSD CI QEMU guest (scanner harness)" +)] +async fn apev2_tags_are_read_alongside_id3() { + let temp_dir = tempdir().unwrap(); + let raw_media_dir = temp_dir.path().join("media"); + fs::create_dir_all(&raw_media_dir).unwrap(); + let media_dir = fs::canonicalize(raw_media_dir).unwrap(); + + let db = Arc::new( + SqliteDatabase::new(temp_dir.path().join("ape.db")) + .await + .unwrap(), + ); + db.initialize().await.unwrap(); + + let silent_mp3_base64 = "SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjU2LjM2LjEwMAAAAAAAAAAAAAAA//OEAAAAAAAAAAAAAAAAAAAAAAAASW5mbwAAAA8AAAAEAAABIADAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV6urq6urq6urq6urq6urq6urq6urq6urq6v////////////////////////////////8AAAAATGF2YzU2LjQxAAAAAAAAAAAAAAAAJAAAAAAAAAAAASDs90hvAAAAAAAAAAAAAAAAAAAA//MUZAAAAAGkAAAAAAAAA0gAAAAATEFN//MUZAMAAAGkAAAAAAAAA0gAAAAARTMu//MUZAYAAAGkAAAAAAAAA0gAAAAAOTku//MUZAkAAAGkAAAAAAAAA0gAAAAANVVV"; + let path = media_dir.join("apetagged.mp3"); + fs::write( + &path, + with_apev2_tag( + decode_base64(silent_mp3_base64), + &[ + ("Title", "Roygbiv"), + ("Artist", "Boards of Canada"), + ("Album", "Music Has the Right"), + ("Genre", "Electronic"), + ("Year", "1998"), + ("Track", "4"), + ], + ), + ) + .unwrap(); + + let scanner = MediaScanner::with_database(db.clone()); + scanner.scan_directory_recursive(&media_dir).await.unwrap(); + + let file = db.get_file_by_path(&path).await.unwrap().unwrap(); + assert_eq!(file.title.as_deref(), Some("Roygbiv")); + assert_eq!(file.artist.as_deref(), Some("Boards of Canada")); + assert_eq!(file.album.as_deref(), Some("Music Has the Right")); + assert_eq!(file.genre.as_deref(), Some("Electronic")); + assert_eq!(file.year, Some(1998)); + assert_eq!(file.track_number, Some(4)); + + // The ID3 frame the encoder wrote lives in a different revision of the + // metadata log than the APE items, and both survive: the APE values reached + // the columns asserted above, and the ID3 one reached the side table. + let stored = db.get_media_tags(file.id.unwrap()).await.unwrap(); + assert!( + stored + .iter() + .any(|(key, value)| key == "Encoder" && value.starts_with("Lavf")), + "the ID3 revision must not be dropped in favour of the APE one: {stored:?}" + ); + + // A tag with a column of its own is not repeated in the side table. + assert!( + !stored.iter().any(|(key, _)| key == "Artist"), + "promoted tags belong in their column, not both places: {stored:?}" + ); +} diff --git a/crates/vuio-core/tests/mcp_integration_tests.rs b/crates/vuio-core/tests/mcp_integration_tests.rs index 15a8cf3..49b0260 100644 --- a/crates/vuio-core/tests/mcp_integration_tests.rs +++ b/crates/vuio-core/tests/mcp_integration_tests.rs @@ -167,6 +167,10 @@ async fn test_mcp_initialize_and_tools_list() { track_number: Some(4), year: Some(1971), album_artist: None, + tags: Default::default(), + stream: Default::default(), + extra_tags: Vec::new(), + tags_version: 0, subtitle_available: false, created_at: std::time::SystemTime::now(), updated_at: std::time::SystemTime::now(), diff --git a/crates/vuio-core/tests/music_browse_integration_tests.rs b/crates/vuio-core/tests/music_browse_integration_tests.rs new file mode 100644 index 0000000..b70d5a2 --- /dev/null +++ b/crates/vuio-core/tests/music_browse_integration_tests.rs @@ -0,0 +1,643 @@ +//! Walking the music tree the way a control point does. +//! +//! These drive the real SOAP endpoint through the real router, so what they +//! assert is what a renderer receives: the containers, their UPnP classes, and +//! the tracks at the leaves. + +use axum::{ + body::Body, + http::{Request, StatusCode}, +}; +use std::path::PathBuf; +use std::sync::Arc; +use tempfile::{tempdir, TempDir}; +use tower::ServiceExt; + +use vuio_core::config::AppConfig; +use vuio_core::database::sqlite::SqliteDatabase; +use vuio_core::database::{DatabaseManager, MediaFile, MediaRepository, PlaylistRepository}; +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; +use vuio_core::web::diagnostics::WebHandlerMetrics; + +async fn make_test_state() -> (TempDir, AppState) { + let temp = tempdir().unwrap(); + let database = Arc::new( + SqliteDatabase::new(temp.path().join("music-browse.db")) + .await + .unwrap(), + ); + database.initialize().await.unwrap(); + let config = Arc::new(AppConfig::default()); + + let state = 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)), + 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(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(), + )), + 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(), + }; + + (temp, state) +} + +fn track(path: &str, artist: &str, album: &str, genre: &str, track_number: u32) -> MediaFile { + let mut file = MediaFile::new(PathBuf::from(path), 4096, "audio/mpeg".to_string()); + file.artist = Some(artist.to_string()); + file.album_artist = Some(artist.to_string()); + file.album = Some(album.to_string()); + file.genre = Some(genre.to_string()); + file.year = Some(1984); + file.track_number = Some(track_number); + file.title = Some(format!("{album} {track_number}")); + file.stream.sample_rate = Some(44_100); + file.stream.channels = Some(2); + file.stream.bits_per_sample = Some(16); + file.stream.bit_rate = Some(320_000); + file +} + +/// A library with two artists, three albums, and an artist whose name contains +/// the path separator. +async fn seed(state: &AppState) { + let records = vec![ + track( + "/music/m/rtl1.mp3", + "Metallica", + "Ride the Lightning", + "Metal", + 1, + ), + track( + "/music/m/rtl2.mp3", + "Metallica", + "Ride the Lightning", + "Metal", + 2, + ), + track("/music/m/load1.mp3", "Metallica", "Load", "Rock", 1), + // A slash inside a tag value is the case that nesting breaks without + // encoded object ids. + track("/music/acdc/bib1.mp3", "AC/DC", "Back in Black", "Rock", 1), + ]; + state + .database + .bulk_store_media_files(&records) + .await + .unwrap(); +} + +async fn browse(state: &AppState, object_id: &str) -> String { + browse_page(state, object_id, "BrowseDirectChildren", 0, 0).await +} + +async fn browse_with_flag(state: &AppState, object_id: &str, flag: &str) -> String { + browse_page(state, object_id, flag, 0, 0).await +} + +async fn browse_page( + state: &AppState, + object_id: &str, + flag: &str, + starting_index: u32, + requested_count: u32, +) -> String { + let body = format!( + r#" + + + + {object_id} + {flag} + * + {starting_index} + {requested_count} + + + +"# + ); + + let response = create_router(state.clone()) + .oneshot( + Request::post("/control/ContentDirectory") + .header("content-type", "text/xml") + .header( + "soapaction", + "\"urn:schemas-upnp-org:service:ContentDirectory:1#Browse\"", + ) + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!( + response.status(), + StatusCode::OK, + "browsing {object_id} failed" + ); + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + String::from_utf8(bytes.to_vec()).unwrap() +} + +/// Container ids in a response, in order. +/// +/// The DIDL arrives XML-escaped inside ``, so the markup this reads is +/// `<container id="…`. +fn container_ids(response: &str) -> Vec { + response + .split("<container id="") + .skip(1) + .filter_map(|rest| rest.split(""").next()) + .map(|id| id.replace("&", "&")) + .collect() +} + +fn container_titles(response: &str) -> Vec { + response + .split("<dc:title>") + .skip(1) + .filter_map(|rest| rest.split("</dc:title>").next()) + .map(|title| title.to_string()) + .collect() +} + +fn item_count(response: &str) -> usize { + response.matches("<item id="").count() +} + +#[tokio::test] +#[cfg_attr( + target_os = "freebsd", + ignore = "SIGSEGV in FreeBSD CI QEMU guest (integration harness)" +)] +async fn music_root_offers_every_category() { + let (_temp, state) = make_test_state().await; + seed(&state).await; + + let response = browse(&state, "audio").await; + let ids = container_ids(&response); + assert_eq!( + ids, + [ + "audio/!all", + "audio/artists", + "audio/albumartists", + "audio/albums", + "audio/genres", + "audio/years", + "audio/playlists", + "audio/folders", + ], + "the Music container must offer the whole categorization" + ); + + let titles = container_titles(&response); + assert!(titles.contains(&"All Music".to_string())); + assert!(titles.contains(&"Album Artists".to_string())); +} + +/// The shape the issue asked for: artist, then album, then tracks. +#[tokio::test] +#[cfg_attr( + target_os = "freebsd", + ignore = "SIGSEGV in FreeBSD CI QEMU guest (integration harness)" +)] +async fn artists_descend_through_albums_to_tracks() { + let (_temp, state) = make_test_state().await; + seed(&state).await; + + let artists = browse(&state, "audio/artists").await; + assert_eq!( + container_ids(&artists), + ["audio/artists/AC%2FDC", "audio/artists/Metallica"] + ); + assert!( + artists.contains("object.container.person.musicArtist"), + "an artist must announce itself as an artist, not a folder" + ); + // The name is the artist, with no count spliced into it. + assert_eq!( + container_titles(&artists), + ["AC/DC", "Metallica"], + "counts belong in childCount, not the title" + ); + + let metallica = browse(&state, "audio/artists/Metallica").await; + assert_eq!( + container_ids(&metallica), + [ + "audio/artists/Metallica/!all", + "audio/artists/Metallica/Load", + "audio/artists/Metallica/Ride the Lightning", + ], + "an artist holds their albums, with All Songs first" + ); + assert!(metallica.contains("object.container.album.musicAlbum")); + + let album = browse(&state, "audio/artists/Metallica/Ride the Lightning").await; + assert_eq!(item_count(&album), 2); + assert!(album.contains("object.item.audioItem.musicTrack")); + assert!(album.contains("/media/"), "tracks must carry a res URL"); + + // All Songs reaches every track by the artist regardless of album. + let all_songs = browse(&state, "audio/artists/Metallica/!all").await; + assert_eq!(item_count(&all_songs), 3); +} + +/// The slash case, end to end: an artist named "AC/DC" must be browsable. +#[tokio::test] +#[cfg_attr( + target_os = "freebsd", + ignore = "SIGSEGV in FreeBSD CI QEMU guest (integration harness)" +)] +async fn an_artist_whose_name_contains_a_slash_is_browsable() { + let (_temp, state) = make_test_state().await; + seed(&state).await; + + let artist = browse(&state, "audio/artists/AC%2FDC").await; + assert_eq!( + container_ids(&artist), + [ + "audio/artists/AC%2FDC/!all", + "audio/artists/AC%2FDC/Back in Black", + ] + ); + assert_eq!(container_titles(&artist)[1], "Back in Black"); + + let album = browse(&state, "audio/artists/AC%2FDC/Back in Black").await; + assert_eq!(item_count(&album), 1); +} + +#[tokio::test] +#[cfg_attr( + target_os = "freebsd", + ignore = "SIGSEGV in FreeBSD CI QEMU guest (integration harness)" +)] +async fn genres_descend_through_artists_and_albums() { + let (_temp, state) = make_test_state().await; + seed(&state).await; + + let genres = browse(&state, "audio/genres").await; + assert_eq!( + container_ids(&genres), + ["audio/genres/Metal", "audio/genres/Rock"] + ); + assert!(genres.contains("object.container.genre.musicGenre")); + + let rock = browse(&state, "audio/genres/Rock").await; + assert_eq!( + container_ids(&rock), + [ + "audio/genres/Rock/!all", + "audio/genres/Rock/AC%2FDC", + "audio/genres/Rock/Metallica", + ], + "a genre holds its artists" + ); + + let metallica_rock = browse(&state, "audio/genres/Rock/Metallica").await; + assert_eq!( + container_ids(&metallica_rock), + [ + "audio/genres/Rock/Metallica/!all", + "audio/genres/Rock/Metallica/Load", + ], + "only the albums this artist has in this genre" + ); + + let tracks = browse(&state, "audio/genres/Rock/Metallica/Load").await; + assert_eq!(item_count(&tracks), 1); +} + +/// Two artists with an identically titled album must not merge. +#[tokio::test] +#[cfg_attr( + target_os = "freebsd", + ignore = "SIGSEGV in FreeBSD CI QEMU guest (integration harness)" +)] +async fn same_titled_albums_stay_separate_under_their_artists() { + let (_temp, state) = make_test_state().await; + state + .database + .bulk_store_media_files(&[ + track("/music/a/hits.mp3", "Artist A", "Greatest Hits", "Pop", 1), + track("/music/b/hits1.mp3", "Artist B", "Greatest Hits", "Pop", 1), + track("/music/b/hits2.mp3", "Artist B", "Greatest Hits", "Pop", 2), + ]) + .await + .unwrap(); + + let a = browse(&state, "audio/artists/Artist A/Greatest Hits").await; + assert_eq!(item_count(&a), 1); + + let b = browse(&state, "audio/artists/Artist B/Greatest Hits").await; + assert_eq!(item_count(&b), 2); + + // The flat Albums view still shows one container for the shared title, + // holding every track that carries it. That is the flat view's meaning. + let flat = browse(&state, "audio/albums/Greatest Hits").await; + assert_eq!(item_count(&flat), 3); +} + +#[tokio::test] +#[cfg_attr( + target_os = "freebsd", + ignore = "SIGSEGV in FreeBSD CI QEMU guest (integration harness)" +)] +async fn playlists_are_browsable_and_play_in_stored_order() { + let (_temp, state) = make_test_state().await; + let ids = state + .database + .bulk_store_media_files(&[ + track("/music/p/one.mp3", "Artist", "Album", "Pop", 1), + track("/music/p/two.mp3", "Artist", "Album", "Pop", 2), + track("/music/p/three.mp3", "Artist", "Album", "Pop", 3), + ]) + .await + .unwrap(); + + let playlist = state + .database + .create_playlist("Roadtrip", None) + .await + .unwrap(); + // Deliberately not track order: a playlist plays in the order it stores. + state + .database + .batch_add_to_playlist(playlist, &[(ids[2], 0), (ids[0], 1), (ids[1], 2)]) + .await + .unwrap(); + + let list = browse(&state, "audio/playlists").await; + assert_eq!( + container_ids(&list), + [format!("audio/playlists/{playlist}")] + ); + assert_eq!(container_titles(&list), ["Roadtrip"]); + assert!( + list.contains("object.container.playlistContainer"), + "a playlist must announce itself as a playlist so renderers offer play-all" + ); + assert!( + list.contains("childCount="3""), + "a playlist reports how many tracks it holds" + ); + + let tracks = browse(&state, &format!("audio/playlists/{playlist}")).await; + assert_eq!(item_count(&tracks), 3); + // Stored order, not track-number order: the entries were added 3, 1, 2. + assert_eq!( + container_titles(&tracks), + ["Album 3", "Album 1", "Album 2"], + "playlist entries must render in stored position order" + ); +} + +/// Renderers use these to decide whether they can play a track at all. +#[tokio::test] +#[cfg_attr( + target_os = "freebsd", + ignore = "SIGSEGV in FreeBSD CI QEMU guest (integration harness)" +)] +async fn tracks_advertise_their_stream_properties() { + let (_temp, state) = make_test_state().await; + seed(&state).await; + + let album = browse(&state, "audio/albums/Load").await; + assert!(album.contains("sampleFrequency="44100"")); + assert!(album.contains("nrAudioChannels="2"")); + assert!(album.contains("bitsPerSample="16"")); + // DLNA's res@bitrate is bytes per second, so 320 kbit/s is 40000. + assert!( + album.contains("bitrate="40000""), + "res@bitrate is bytes per second, not bits" + ); +} + +/// Samsung probes a container before opening it and reads childCount to decide +/// whether it is worth showing. +#[tokio::test] +#[cfg_attr( + target_os = "freebsd", + ignore = "SIGSEGV in FreeBSD CI QEMU guest (integration harness)" +)] +async fn browse_metadata_describes_music_containers() { + let (_temp, state) = make_test_state().await; + seed(&state).await; + + let artists = browse_with_flag(&state, "audio/artists", "BrowseMetadata").await; + assert!(artists.contains("<dc:title>Artists</dc:title>")); + assert!( + artists.contains("childCount="2""), + "two artists were indexed" + ); + assert!(artists.contains("parentID="audio"")); + + let artist = browse_with_flag(&state, "audio/artists/Metallica", "BrowseMetadata").await; + assert!(artist.contains("<dc:title>Metallica</dc:title>")); + assert!(artist.contains("object.container.person.musicArtist")); + assert!(artist.contains("parentID="audio/artists"")); + assert!( + !artist.contains("childCount="0""), + "a container that holds albums must never report itself empty" + ); +} + +/// A control point that pages must still learn how much there is to page +/// through, or it stops after the first response. +#[tokio::test] +#[cfg_attr( + target_os = "freebsd", + ignore = "SIGSEGV in FreeBSD CI QEMU guest (integration harness)" +)] +async fn paging_reports_the_full_total_at_every_offset() { + let (_temp, state) = make_test_state().await; + state + .database + .bulk_store_media_files(&[ + track("/music/p/a.mp3", "Artist A", "Album", "Pop", 1), + track("/music/p/b.mp3", "Artist B", "Album", "Pop", 1), + track("/music/p/c.mp3", "Artist C", "Album", "Pop", 1), + track("/music/p/d.mp3", "Artist D", "Album", "Pop", 1), + ]) + .await + .unwrap(); + + let field = |response: &str, name: &str| -> usize { + response + .split(&format!("<{name}>")) + .nth(1) + .and_then(|rest| rest.split(&format!("")).next()) + .and_then(|value| value.parse().ok()) + .unwrap_or_else(|| panic!("no <{name}> in response")) + }; + + // Containers: four artists, taken two at a time. + let first = browse_page(&state, "audio/artists", "BrowseDirectChildren", 0, 2).await; + assert_eq!(container_ids(&first).len(), 2); + assert_eq!(field(&first, "NumberReturned"), 2); + assert_eq!(field(&first, "TotalMatches"), 4); + + let second = browse_page(&state, "audio/artists", "BrowseDirectChildren", 2, 2).await; + assert_eq!(container_titles(&second), ["Artist C", "Artist D"]); + assert_eq!(field(&second, "TotalMatches"), 4); + + // Past the end is an empty page, not an error or a wrapped one. + let past_end = browse_page(&state, "audio/artists", "BrowseDirectChildren", 10, 2).await; + assert_eq!(container_ids(&past_end).len(), 0); + assert_eq!(field(&past_end, "NumberReturned"), 0); + assert_eq!(field(&past_end, "TotalMatches"), 4); + + // Items page the same way, through the read session rather than a slice. + let tracks = browse_page(&state, "audio/albums/Album", "BrowseDirectChildren", 1, 2).await; + assert_eq!(item_count(&tracks), 2); + assert_eq!(field(&tracks, "TotalMatches"), 4); +} + +/// A container announcing more children than it returns sends a control point +/// looking for items that are not there. +/// +/// The count of an artist is its albums, not its tracks — those are different +/// numbers, and the tracks number is the larger and wrong one. +#[tokio::test] +#[cfg_attr( + target_os = "freebsd", + ignore = "SIGSEGV in FreeBSD CI QEMU guest (integration harness)" +)] +async fn child_counts_match_the_children_actually_returned() { + let (_temp, state) = make_test_state().await; + seed(&state).await; + // Push Metallica to four tracks across two albums, so the track count and + // the child count are different numbers and the assertion can tell them + // apart. + state + .database + .bulk_store_media_files(&[track( + "/music/m/rtl3.mp3", + "Metallica", + "Ride the Lightning", + "Metal", + 3, + )]) + .await + .unwrap(); + + let child_count = |response: &str, container_id: &str| -> usize { + // Each container renders as `id="…" parentID="…" … childCount="N"`. + let anchor = format!("id="{container_id}""); + let start = response + .find(&anchor) + .unwrap_or_else(|| panic!("{container_id} not in response")); + response[start..] + .split("childCount="") + .nth(1) + .and_then(|rest| rest.split(""").next()) + .and_then(|value| value.parse().ok()) + .expect("no childCount") + }; + + // Metallica: 4 tracks but 2 albums, so 3 children (All Songs + 2 albums). + let artists = browse(&state, "audio/artists").await; + let announced = child_count(&artists, "audio/artists/Metallica"); + let actual = container_ids(&browse(&state, "audio/artists/Metallica").await).len(); + assert_eq!( + announced, actual, + "artist announced {announced} children but returned {actual}" + ); + assert_eq!(actual, 3); + + // Rock: 2 artists, so 3 children (All Songs + 2 artists). + let genres = browse(&state, "audio/genres").await; + let announced = child_count(&genres, "audio/genres/Rock"); + let actual = container_ids(&browse(&state, "audio/genres/Rock").await).len(); + assert_eq!( + announced, actual, + "genre announced {announced} children but returned {actual}" + ); + + // An album's children really are tracks, so there the record count is right. + let metallica = browse(&state, "audio/artists/Metallica").await; + let announced = child_count(&metallica, "audio/artists/Metallica/Ride the Lightning"); + let actual = item_count(&browse(&state, "audio/artists/Metallica/Ride the Lightning").await); + assert_eq!(announced, actual); + assert_eq!(actual, 3); + + // BrowseMetadata must agree with the parent listing about the same object. + let probed = browse_with_flag(&state, "audio/artists/Metallica", "BrowseMetadata").await; + assert_eq!(child_count(&probed, "audio/artists/Metallica"), 3); +} + +/// A playlist is named the same whether it is listed or probed. +#[tokio::test] +#[cfg_attr( + target_os = "freebsd", + ignore = "SIGSEGV in FreeBSD CI QEMU guest (integration harness)" +)] +async fn browse_metadata_names_a_playlist_the_way_its_listing_did() { + let (_temp, state) = make_test_state().await; + let ids = state + .database + .bulk_store_media_files(&[track("/music/p/one.mp3", "Artist", "Album", "Pop", 1)]) + .await + .unwrap(); + let playlist = state + .database + .create_playlist("Roadtrip", None) + .await + .unwrap(); + state + .database + .batch_add_to_playlist(playlist, &[(ids[0], 0)]) + .await + .unwrap(); + + let listed = browse(&state, "audio/playlists").await; + assert_eq!(container_titles(&listed), ["Roadtrip"]); + + let probed = browse_with_flag( + &state, + &format!("audio/playlists/{playlist}"), + "BrowseMetadata", + ) + .await; + assert_eq!( + container_titles(&probed), + ["Roadtrip"], + "a probe must not rename the container its listing already named" + ); +} diff --git a/docs/api.md b/docs/api.md index 01c9ac8..7cbc02f 100644 --- a/docs/api.md +++ b/docs/api.md @@ -98,7 +98,7 @@ configuration file actually writes. Backs the dashboard's Admin tab. "read_only_reason": null, "auth_enabled": false, "is_docker": false, - "version": "0.0.42", + "version": "0.0.43", // Where the server is actually accepting, which is what every advertised URL uses. "bound_addr": "0.0.0.0:8080", "desired_addr": null, diff --git a/packaging/docker/builddocker.sh b/packaging/docker/builddocker.sh index 4ce1721..2782e4a 100755 --- a/packaging/docker/builddocker.sh +++ b/packaging/docker/builddocker.sh @@ -1,6 +1,6 @@ export GITHUB_ORG="vuiodev" export IMAGE_NAME="vuio" -export VERSION_TAG="v0.0.42" +export VERSION_TAG="v0.0.43" docker login ghcr.io