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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 11 additions & 3 deletions crates/vuio-cli/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -16,15 +16,23 @@ 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"] }
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" }
23 changes: 19 additions & 4 deletions crates/vuio-core/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "vuio-core"
version = "0.0.42"
version = "0.0.43"
edition = "2021"
rust-version = "1.95"
authors = ["vyrti"]
Expand Down Expand Up @@ -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",
Expand All @@ -50,7 +59,7 @@ casting = [
dashboard = []
diagnostics = ["dep:sysinfo"]
mcp = []
metadata = ["dep:audiotags"]
metadata = ["dep:symphonia"]
unstable-internals = []

[dependencies]
Expand Down Expand Up @@ -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 }
Expand All @@ -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 }
Expand All @@ -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"] }
227 changes: 227 additions & 0 deletions crates/vuio-core/src/database/conformance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -679,6 +679,229 @@ pub async fn album_tracks_order_by_track_number<B: DatabaseBackend>() {
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<B: DatabaseBackend>() {
let temp = tempfile::tempdir().unwrap();
let database = open::<B>(&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<B: DatabaseBackend>() {
use crate::database::{MusicCategoryFilter, MusicCategoryType};

let temp = tempfile::tempdir().unwrap();
let database = open::<B>(&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::<Vec<_>>()
}
};
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::<Vec<_>>();
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<B: DatabaseBackend>() {
let temp = tempfile::tempdir().unwrap();
let database = open::<B>(&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<crate::database::MusicCategory>| {
categories
.into_iter()
.map(|category| category.name)
.collect::<Vec<_>>()
};
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<B: DatabaseBackend>() {
let temp = tempfile::tempdir().unwrap();
let database = open::<B>(&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<B: DatabaseBackend>() {
let temp = tempfile::tempdir().unwrap();
let database = open::<B>(&temp, "filtered").await;
Expand Down Expand Up @@ -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);
Expand Down
Loading