From f902bb64e7063ad167e9f4840fa35797b15e9129 Mon Sep 17 00:00:00 2001 From: vyrti Date: Sat, 12 Sep 2026 21:19:51 +0300 Subject: [PATCH 1/8] fix(xml): handle Unicode Samsung titles --- crates/vuio-core/src/web/xml/rendering.rs | 8 +++----- crates/vuio-core/src/web/xml/tests.rs | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/crates/vuio-core/src/web/xml/rendering.rs b/crates/vuio-core/src/web/xml/rendering.rs index 4ed67776..e39f4a4c 100644 --- a/crates/vuio-core/src/web/xml/rendering.rs +++ b/crates/vuio-core/src/web/xml/rendering.rs @@ -110,11 +110,9 @@ pub(super) fn strip_matching_media_extension(base: &str, filename: &str) -> Stri else { return base.to_string(); }; - let suffix_len = ext.len() + 1; // '.' + ext - if base.len() > suffix_len { - let maybe = &base[base.len() - suffix_len..]; - if maybe.as_bytes()[0] == b'.' && maybe[1..].eq_ignore_ascii_case(ext) { - return base[..base.len() - suffix_len].to_string(); + if let Some((stem, base_ext)) = base.rsplit_once('.') { + if !stem.is_empty() && base_ext.eq_ignore_ascii_case(ext) { + return stem.to_string(); } } base.to_string() diff --git a/crates/vuio-core/src/web/xml/tests.rs b/crates/vuio-core/src/web/xml/tests.rs index 323347a0..909fe7a5 100644 --- a/crates/vuio-core/src/web/xml/tests.rs +++ b/crates/vuio-core/src/web/xml/tests.rs @@ -43,6 +43,21 @@ fn samsung_keeps_titles_without_matching_extension() { ); } +#[test] +fn samsung_handles_unicode_near_the_extension_boundary() { + let title = "09 - Symphony No. 8 in E-Flat Major, Pt. 2 V. Wie Felsenabgrund mir zu Füßen"; + let filename = format!("{title}.flac"); + + assert_eq!( + didl_display_title(Some(title), &filename, DlnaClientProfile::SamsungTv), + title + ); + assert_eq!( + didl_display_title(None, &filename, DlnaClientProfile::SamsungTv), + title + ); +} + #[test] fn non_samsung_keeps_filename_when_title_missing() { assert_eq!( From 30d5c2f9d68677ee5c0908625c49adc2a0ac0c64 Mon Sep 17 00:00:00 2001 From: vyrti Date: Sat, 12 Sep 2026 21:34:21 +0300 Subject: [PATCH 2/8] test(xml): exhaustively cover Unicode rendering --- crates/vuio-core/src/web/xml/rendering.rs | 19 ++++++-- crates/vuio-core/src/web/xml/tests.rs | 55 +++++++++++++++++++++++ 2 files changed, 71 insertions(+), 3 deletions(-) diff --git a/crates/vuio-core/src/web/xml/rendering.rs b/crates/vuio-core/src/web/xml/rendering.rs index e39f4a4c..e900c4bc 100644 --- a/crates/vuio-core/src/web/xml/rendering.rs +++ b/crates/vuio-core/src/web/xml/rendering.rs @@ -1,3 +1,5 @@ +#![deny(clippy::string_slice)] + use super::*; use std::fmt::Write as _; @@ -17,12 +19,20 @@ pub(super) fn write_xml_escaped( _ => None, }; if let Some(replacement) = replacement { - target.write_str(&value[unescaped_start..offset])?; + target.write_str( + value + .get(unescaped_start..offset) + .expect("char indices are UTF-8 boundaries"), + )?; target.write_str(replacement)?; unescaped_start = offset + character.len_utf8(); } } - target.write_str(&value[unescaped_start..]) + target.write_str( + value + .get(unescaped_start..) + .expect("char indices are UTF-8 boundaries"), + ) } pub(super) fn is_valid_xml_character(character: char) -> bool { @@ -417,7 +427,10 @@ pub(super) fn directory_container_id(object_id: &str, path: &str, name: &str) -> || path == "radio" { path.to_owned() - } else if path.starts_with('d') && path[1..].chars().all(|c| c.is_ascii_digit()) { + } else if path + .strip_prefix('d') + .is_some_and(|suffix| suffix.chars().all(|c| c.is_ascii_digit())) + { format!("{}/{}", object_id.trim_end_matches('/'), path) } else { format!("{}/{}", object_id.trim_end_matches('/'), name) diff --git a/crates/vuio-core/src/web/xml/tests.rs b/crates/vuio-core/src/web/xml/tests.rs index 909fe7a5..d160ba34 100644 --- a/crates/vuio-core/src/web/xml/tests.rs +++ b/crates/vuio-core/src/web/xml/tests.rs @@ -12,6 +12,14 @@ fn xml_escape_handles_markup_unicode_and_invalid_controls() { ); } +#[test] +fn xml_escape_accepts_every_unicode_scalar_and_emits_valid_xml_characters() { + let all_scalars: String = (0..=char::MAX as u32).filter_map(char::from_u32).collect(); + let escaped = xml_escape(&all_scalars).to_string(); + + assert!(escaped.chars().all(is_valid_xml_character)); +} + #[test] fn soap_result_writer_applies_the_required_second_escape_layer() { let mut output = String::new(); @@ -58,6 +66,53 @@ fn samsung_handles_unicode_near_the_extension_boundary() { ); } +#[test] +fn samsung_extension_handling_accepts_every_unicode_scalar() { + for code_point in 0..=char::MAX as u32 { + let Some(character) = char::from_u32(code_point) else { + continue; + }; + + // With a one-byte extension, the old byte-offset implementation tried to + // start its suffix one byte before `x`, bisecting every multibyte scalar. + let title = format!("title{character}x"); + assert_eq!( + didl_display_title(Some(&title), "file.z", DlnaClientProfile::SamsungTv), + title, + "metadata title containing U+{code_point:04X}" + ); + + // Exercise the filename-fallback and matching-extension path as well. + // Both separators are excluded so this assertion has the same meaning on + // Unix and Windows; they remain covered in the metadata-title assertion. + if !matches!(character, '/' | '\\') { + let stem = format!("title{character}"); + let filename = format!("{stem}.Z"); + assert_eq!( + didl_display_title(None, &filename, DlnaClientProfile::SamsungTvQ), + stem, + "filename containing U+{code_point:04X}" + ); + } + } +} + +#[test] +fn samsung_extension_handling_covers_suffix_edge_cases() { + for (base, filename, expected) in [ + ("", "file.flac", ""), + (".flac", "file.flac", ".flac"), + ("archive.part.FLAC", "file.flac", "archive.part"), + ("track.mp3", "file.flac", "track.mp3"), + ("track.flac", "file", "track.flac"), + ("track.flac", "file.", "track.flac"), + ("曲.音", "file.音", "曲"), + ("曲.音", "file.樂", "曲.音"), + ] { + assert_eq!(strip_matching_media_extension(base, filename), expected); + } +} + #[test] fn non_samsung_keeps_filename_when_title_missing() { assert_eq!( From 43084d37f30c20c49a417e927f16feebdac01b87 Mon Sep 17 00:00:00 2001 From: vyrti Date: Sat, 12 Sep 2026 21:45:53 +0300 Subject: [PATCH 3/8] fix(text): one normalization form for every language, not just Samsung titles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #51 fixed the byte slice that split a `ü`. This covers the rest of the class it belonged to. A `clippy::string_slice` sweep over the app crates enumerated all 28 string index sites. Indices from `find`/`rsplit_once`/`split_at`, and offsets past an ASCII prefix already proven by `starts_with`, are boundary-safe. One site was not: `has_drive_letter` checked characters 1 and 2 but never constrained character 0, so `Ü:\Musik` reached a `path_str[1..]` that splits it in half. The deeper problem is that the same name has more than one spelling. `Füßen` is NFC from a Windows tagger and NFD from macOS, canonically equivalent and sharing no bytes, so `LIKE` and FTS silently said no. Worse, FTS shredded a decomposed term into two tokens, because a combining mark is `Mn` and the tokenizer splits on anything non-alphanumeric — a user searching for their own music found nothing. Stored text is now folded to NFC at the single write funnel and search terms are folded on the way in. Paths are deliberately excluded: a path is a filesystem key, and on ext4 the two spellings are two different files. Rows written before this release are folded once by migration v8, via an `nfc()` SQL scalar, rather than left unfindable until a rescan. Tests pad every sample through eight alignments, because a flat list of awkward strings does not catch an offset bug — the panic needs the slice point to land inside a character, which depends on the string's length. --- Cargo.lock | 25 ++++ crates/vuio-core/Cargo.toml | 6 +- .../src/database/sqlite/media_repo/bulk.rs | 26 +++- crates/vuio-core/src/database/sqlite/query.rs | 8 +- .../vuio-core/src/database/sqlite/schema.rs | 66 ++++++++- crates/vuio-core/src/database/sqlite/tests.rs | 125 ++++++++++++++++++ crates/vuio-core/src/lib.rs | 5 + .../src/platform/filesystem/tests.rs | 48 +++++++ .../src/platform/filesystem/windows.rs | 5 + crates/vuio-core/src/text.rs | 94 +++++++++++++ crates/vuio-core/src/unicode_corpus.rs | 67 ++++++++++ crates/vuio-core/src/web/subtitles.rs | 12 ++ 12 files changed, 480 insertions(+), 7 deletions(-) create mode 100644 crates/vuio-core/src/text.rs create mode 100644 crates/vuio-core/src/unicode_corpus.rs diff --git a/Cargo.lock b/Cargo.lock index 7287ec14..10522c0b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2943,6 +2943,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf0ded5c4e56918d8f8a339e1bb67d038d3bc6d144ac407904015ba2e4cde9b" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.53.1" @@ -3195,6 +3210,15 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "unindent" version = "0.2.4" @@ -3399,6 +3423,7 @@ dependencies = [ "tower", "tracing", "tracing-subscriber", + "unicode-normalization", "uuid", "vuio-cast", "vuio-codec-ac3", diff --git a/crates/vuio-core/Cargo.toml b/crates/vuio-core/Cargo.toml index 3ba5fc5e..761c469a 100644 --- a/crates/vuio-core/Cargo.toml +++ b/crates/vuio-core/Cargo.toml @@ -164,6 +164,9 @@ bytes = "1.12" rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } quick-xml = "0.41" percent-encoding = "2.3" +# Media text arrives in whichever normalization form the tagger or filesystem +# used; comparing and indexing it requires one form. See `src/text.rs`. +unicode-normalization = "0.1" sysinfo = { version = "0.39", default-features = false, features = ["system", "disk", "network"], optional = true } socket2 = { version = "0.6", features = ["all"] } mdns-sd = { version = "0.21", default-features = false, features = ["async"] } @@ -190,7 +193,8 @@ oxideav-core = { package = "vuio-codec-core", version = "0.0.1", path = "../vuio vuio-codec-ac3 = { version = "0.0.1", path = "../vuio-codec-ac3", optional = true } oxideav-dts = { package = "vuio-codec-dts", version = "0.0.1", path = "../vuio-codec-dts", optional = true } xaac-rs = { version = "0.2", path = "../vendor/xaac-rs", optional = true } -rusqlite = { version = "0.40.2", features = ["bundled", "collation"] } +# `functions` registers the `nfc()` scalar the normalization migration uses. +rusqlite = { version = "0.40.2", features = ["bundled", "collation", "functions"] } # Only the `mediainfo` feature uses this, and only to talk to public metadata APIs # over TLS. `rustls-no-provider` rather than a provider-selecting feature because # `Runtime::start` already installs the ring provider process-wide, and a second 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 6a48b3cf..472a4939 100644 --- a/crates/vuio-core/src/database/sqlite/media_repo/bulk.rs +++ b/crates/vuio-core/src/database/sqlite/media_repo/bulk.rs @@ -218,16 +218,34 @@ pub(in crate::database::sqlite) fn upsert_media_file( } } +/// Fold the comparable and displayable text of a record to NFC. +/// +/// Every write goes through here, whatever produced the record — an embedded +/// tag, a filename, a scraper, a playlist — so one spelling reaches the columns +/// that `LIKE` and FTS read, and the same spelling reaches a renderer. `path` is +/// pointedly absent: it is the key the filesystem is opened with and keeps the +/// bytes the filesystem reported. See `crate::text`. +fn normalize_text_fields(file: &mut MediaFile) { + file.filename = crate::text::into_nfc(std::mem::take(&mut file.filename)); + crate::text::normalize_field(&mut file.title); + crate::text::normalize_field(&mut file.artist); + crate::text::normalize_field(&mut file.album); + crate::text::normalize_field(&mut file.genre); + crate::text::normalize_field(&mut file.album_artist); +} + impl SqliteDatabase { fn prepared_records(files: &[MediaFile], already_canonical: bool) -> Result> { files .iter() .map(|file| { - if already_canonical { - Ok(file.clone()) + let mut record = if already_canonical { + file.clone() } else { - Self::canonical_file(file) - } + Self::canonical_file(file)? + }; + normalize_text_fields(&mut record); + Ok(record) }) .collect() } diff --git a/crates/vuio-core/src/database/sqlite/query.rs b/crates/vuio-core/src/database/sqlite/query.rs index 6825d4d7..38a50cb1 100644 --- a/crates/vuio-core/src/database/sqlite/query.rs +++ b/crates/vuio-core/src/database/sqlite/query.rs @@ -147,6 +147,10 @@ const FTS_JOIN: &str = "media_files JOIN (\ /// Returns `None` when nothing searchable is left, which the caller turns into /// an empty result rather than an unfiltered one. pub(super) fn fts5_query_from_user_text(text: &str) -> Option { + // Folded first: a combining mark is `Mn`, not alphanumeric, so a decomposed + // `Fu\u{308}\u{df}en` would split here into `Fu` and `\u{df}en` and be searched as + // two tokens, while the composed spelling of the same word stays one. + let text = crate::text::to_nfc(text); let tokens: Vec = text .split(|character: char| !character.is_alphanumeric()) .filter(|token| !token.is_empty()) @@ -270,7 +274,9 @@ pub(super) fn plan(query: &MediaFileQuery) -> MediaQueryPlan { OR media_files.album LIKE ? ESCAPE '\')" .to_owned(), ); - let pattern = format!("%{}%", escape_like(text)); + // The stored columns are NFC (see `crate::text`), so the needle + // has to be, or a decomposed term matches nothing. + let pattern = format!("%{}%", escape_like(&crate::text::to_nfc(text))); for _ in 0..4 { params.push(Value::Text(pattern.clone())); } diff --git a/crates/vuio-core/src/database/sqlite/schema.rs b/crates/vuio-core/src/database/sqlite/schema.rs index 22465786..b4764edb 100644 --- a/crates/vuio-core/src/database/sqlite/schema.rs +++ b/crates/vuio-core/src/database/sqlite/schema.rs @@ -21,7 +21,7 @@ use crate::database::{ /// [`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 = 7; +pub(super) const SCHEMA_VERSION: i64 = 8; /// Name of the collation that carries the application's natural ordering into /// SQL. Registered on every connection; see [`register_collations`]. @@ -350,6 +350,7 @@ fn migrations() -> Vec<(i64, String)> { (5, MIGRATION_V5.to_owned()), (6, MIGRATION_V6.to_owned()), (7, MIGRATION_V7.to_owned()), + (8, MIGRATION_V8.to_owned()), ] } @@ -495,6 +496,44 @@ const MIGRATION_V7: &str = r#" ALTER TABLE media_files ADD COLUMN video_codec TEXT; "#; +/// v7 → v8: one normalization form for stored text. +/// +/// Rows scanned before this release hold whatever form the filesystem or tagger +/// used, so a library scanned on macOS is largely NFD while the same files +/// scanned on Windows are NFC. Searches are folded to NFC now, which would leave +/// those older rows unfindable until something happened to rewrite them, so they +/// are folded once here instead. +/// +/// The `WHERE` keeps the write to rows that actually change: an ASCII-only +/// library — most libraries — matches nothing and the migration is a scan. +/// Updating `media_files` fires the FTS triggers, so the index follows. +/// `path` is not touched; it is the key the file is opened with. See `crate::text`. +const MIGRATION_V8: &str = r#" +UPDATE media_files SET + filename = nfc(filename), + title = nfc(title), + artist = nfc(artist), + album = nfc(album), + genre = nfc(genre), + album_artist = nfc(album_artist) +WHERE filename IS NOT nfc(filename) + OR title IS NOT nfc(title) + OR artist IS NOT nfc(artist) + OR album IS NOT nfc(album) + OR genre IS NOT nfc(genre) + OR album_artist IS NOT nfc(album_artist); + +UPDATE mediainfo SET + title = nfc(title), + original_title = nfc(original_title), + overview = nfc(overview), + genres = nfc(genres) +WHERE title IS NOT nfc(title) + OR original_title IS NOT nfc(original_title) + OR overview IS NOT nfc(overview) + OR genres IS NOT nfc(genres); +"#; + /// Positions within [`MEDIA_COLUMNS`], shared by the owned decoder and the /// borrowed views so the two can never drift apart. pub(super) mod column { @@ -570,6 +609,31 @@ pub(super) fn register_collations(connection: &Connection) -> Result<()> { crate::natural_cmp(left, right) }) .context("Failed to register the natural collation")?; + register_nfc(connection)?; + Ok(()) +} + +/// Expose Unicode NFC folding to SQL, so a migration can normalize stored text +/// without pulling every row through Rust. Deterministic and null-preserving, +/// which is what lets `MIGRATION_V8` compare a column against its folded self. +fn register_nfc(connection: &Connection) -> Result<()> { + connection + .create_scalar_function( + "nfc", + 1, + rusqlite::functions::FunctionFlags::SQLITE_UTF8 + | rusqlite::functions::FunctionFlags::SQLITE_DETERMINISTIC, + |context| { + let value = context.get_raw(0); + match value { + rusqlite::types::ValueRef::Text(_) => { + Ok(Some(crate::text::to_nfc(value.as_str()?).into_owned())) + } + _ => Ok(None), + } + }, + ) + .context("Failed to register the nfc function")?; Ok(()) } diff --git a/crates/vuio-core/src/database/sqlite/tests.rs b/crates/vuio-core/src/database/sqlite/tests.rs index c4d9e279..540ee0b8 100644 --- a/crates/vuio-core/src/database/sqlite/tests.rs +++ b/crates/vuio-core/src/database/sqlite/tests.rs @@ -446,3 +446,128 @@ async fn a_write_ahead_log_left_behind_does_not_resurrect_records() { .unwrap() .is_some()); } + +/// macOS hands back `Fu\u{308}\u{df}en` where Windows wrote `F\u{fc}\u{df}en`. The two look +/// identical, share no bytes, and used to be two different libraries as far as +/// search was concerned. Both spellings must reach the same row from either +/// spelling of the query, through the FTS index and the `LIKE` filter alike. +#[tokio::test] +async fn either_normalization_form_finds_the_other() { + use crate::database::{DatabaseReadSession, MediaFileView, MediaFileQuery}; + + const NFC: &str = "Füßen"; + const NFD: &str = "Fu\u{308}\u{df}en"; + assert_ne!(NFC, NFD, "the premise: the two spellings differ byte for byte"); + + let temp = tempdir().unwrap(); + let db = database(&temp, "normalization").await; + + // Stored decomposed, as a scan of an HFS+ volume would produce. + let mut decomposed = MediaFile::new( + PathBuf::from(format!("/media/{NFD}.flac")), + 1, + "audio/flac".to_owned(), + ); + decomposed.title = Some(NFD.to_owned()); + decomposed.artist = Some(NFD.to_owned()); + let id = db.store_media_file(&decomposed).await.unwrap(); + + // Found by the composed spelling, which is what a browser or a phone sends. + assert_eq!(search_ids(&db, NFC).await, vec![Some(id)], "FTS missed NFC"); + assert_eq!(search_ids(&db, NFD).await, vec![Some(id)], "FTS missed NFD"); + + let filtered = |text: &str| { + let db = db.clone(); + let text = text.to_owned(); + async move { + db.read(move |session| { + let mut ids = Vec::new(); + session.visit_files( + &MediaFileQuery::Filtered { + after_id: None, + mime_family: None, + text: Some(text), + }, + 0, + 10, + |file| { + ids.push(file.id()); + Ok(()) + }, + )?; + Ok(ids) + }) + .await + .unwrap() + } + }; + assert_eq!(filtered(NFC).await, vec![Some(id)], "LIKE missed NFC"); + assert_eq!(filtered(NFD).await, vec![Some(id)], "LIKE missed NFD"); + + // What we hand a renderer is the composed spelling, whatever was scanned: + // a TV that cannot place a combining mark shows "Fu" followed by a stray + // diaeresis otherwise. + let stored = db.get_file_by_path(&decomposed.path).await.unwrap().unwrap(); + assert_eq!(stored.title.as_deref(), Some(NFC)); + assert_eq!(stored.artist.as_deref(), Some(NFC)); + assert_eq!(stored.filename, format!("{NFC}.flac")); + + // The path is the exception: it is the key the file is opened with, so it + // keeps the bytes the filesystem reported. + assert_eq!(stored.path, decomposed.path); +} + +/// A library scanned before this release holds whatever form the filesystem gave +/// it. Those rows have to be folded by the migration, not left waiting for a +/// rescan — a user who upgrades and searches for their own music would otherwise +/// find nothing, which is exactly the state the fold is meant to end. +#[tokio::test] +async fn a_legacy_database_has_its_text_folded_by_the_migration() { + const NFC: &str = "Füßen"; + const NFD: &str = "Fu\u{308}\u{df}en"; + + let temp = tempdir().unwrap(); + let path = temp.path().join("legacy.db"); + + { + let connection = rusqlite::Connection::open(&path).unwrap(); + crate::database::sqlite::schema::register_collations(&connection).unwrap(); + connection.execute_batch(SCHEMA_V1).unwrap(); + connection + .execute( + "INSERT INTO media_files + (id, path, parent_path, filename, size, modified_secs, mime_type, + mime_family, title, artist, album, created_at_secs, updated_at_secs) + VALUES (11, ?1, '/media', ?2, 10, 100, 'audio/flac', 'audio', ?3, ?3, ?3, + 100, 100)", + rusqlite::params![ + format!("/media/{NFD}.flac"), + format!("{NFD}.flac"), + NFD, + ], + ) + .unwrap(); + } + + let db = SqliteDatabase::new(path.clone()).await.unwrap(); + db.initialize().await.unwrap(); + + // The path is untouched — it still has to open the file that is on disk. + let file = db + .get_file_by_path(std::path::Path::new(&format!("/media/{NFD}.flac"))) + .await + .unwrap() + .expect("the migrated record is still there"); + assert_eq!(file.id, Some(11), "the record kept its DIDL object id"); + + // Its text was folded in place. + assert_eq!(file.title.as_deref(), Some(NFC)); + assert_eq!(file.artist.as_deref(), Some(NFC)); + assert_eq!(file.album.as_deref(), Some(NFC)); + assert_eq!(file.filename, format!("{NFC}.flac")); + + // And the folded text reached the index the migration rebuilt. + let db = std::sync::Arc::new(db); + assert_eq!(search_ids(&db, NFC).await, vec![Some(11)]); + assert_eq!(search_ids(&db, NFD).await, vec![Some(11)]); +} diff --git a/crates/vuio-core/src/lib.rs b/crates/vuio-core/src/lib.rs index 4b908c72..d89b53dd 100644 --- a/crates/vuio-core/src/lib.rs +++ b/crates/vuio-core/src/lib.rs @@ -99,6 +99,11 @@ internal_modules!( web, ); +mod text; + +#[cfg(test)] +mod unicode_corpus; + // ── The stable public API ────────────────────────────────────────────────── pub use crate::error::{Error, ErrorKind, Result}; pub use crate::runtime::{Runtime, RuntimeHandle, RuntimeOptions, RuntimeStatus}; diff --git a/crates/vuio-core/src/platform/filesystem/tests.rs b/crates/vuio-core/src/platform/filesystem/tests.rs index d6821f80..666892c2 100644 --- a/crates/vuio-core/src/platform/filesystem/tests.rs +++ b/crates/vuio-core/src/platform/filesystem/tests.rs @@ -416,3 +416,51 @@ mod path_normalizer_tests { assert!(result.is_ok()); } } + +/// `fallback_parse_filename` splits on " - " and on the first space, then indexes +/// what it finds. Every sample is run as a bare stem, as an "artist - title" pair +/// and behind a track number, because each shape takes a different branch. +#[test] +fn filename_fallback_survives_every_script_and_alignment() { + use std::path::PathBuf; + use std::time::SystemTime; + + let blank = |path: PathBuf, filename: String| MediaFile { + id: None, + path, + filename, + size: 0, + modified: SystemTime::UNIX_EPOCH, + mime_type: "audio/mpeg".to_string(), + duration: None, + title: None, + artist: None, + album: None, + genre: None, + 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, + }; + + for sample in crate::unicode_corpus::alignment_sweep() { + for stem in [ + sample.clone(), + format!("{sample} - {sample}"), + format!("{sample} - {sample} - {sample}"), + format!("01 - {sample}"), + format!("{sample}.{sample}"), + ] { + let filename = format!("{stem}.mp3"); + let mut file = blank(PathBuf::from(format!("/music/{filename}")), filename); + fallback_parse_filename(&mut file); + assert!(file.title.is_some(), "no title parsed from {stem:?}"); + } + } +} diff --git a/crates/vuio-core/src/platform/filesystem/windows.rs b/crates/vuio-core/src/platform/filesystem/windows.rs index f797eb45..7569d686 100644 --- a/crates/vuio-core/src/platform/filesystem/windows.rs +++ b/crates/vuio-core/src/platform/filesystem/windows.rs @@ -32,9 +32,14 @@ impl WindowsFileSystemManager { } /// Check if a path contains a drive letter (C:\path) + /// + /// The first character must be ASCII: callers strip it with `path_str[1..]`, + /// which would split a multi-byte character mid-codepoint on a path like + /// `Ü:\Musik`. A drive letter is A-Z anyway. fn has_drive_letter(&self, path: &Path) -> bool { let path_str = path.to_string_lossy(); path_str.len() >= 3 + && path_str.starts_with(|c: char| c.is_ascii_alphabetic()) && path_str.chars().nth(1) == Some(':') && path_str.chars().nth(2) == Some('\\') } diff --git a/crates/vuio-core/src/text.rs b/crates/vuio-core/src/text.rs new file mode 100644 index 00000000..add9995d --- /dev/null +++ b/crates/vuio-core/src/text.rs @@ -0,0 +1,94 @@ +//! Normalization for the text VuIO compares, indexes and displays. +//! +//! The same name can be spelled more than one way in Unicode. `Füßen` is either +//! `F U+00FC ß e n` (NFC, what Windows and most taggers write) or +//! `F u U+0308 ß e n` (NFD, what macOS wrote for years and what HFS+ still hands +//! back). The two are canonically equivalent and look identical, but they share +//! no bytes, so every byte-oriented comparison we make — a `LIKE` clause, an FTS +//! token, a `==` between a scraped title and a parsed one — silently says "no". +//! +//! Everything we store for comparison or display is therefore folded to NFC on +//! the way into the database, and every search term is folded on the way in, so +//! both sides of a comparison are spelled the same way whatever the source used. +//! +//! # Paths are deliberately excluded +//! +//! A path is not text, it is a filesystem key. Linux and Windows compare the +//! bytes exactly: on ext4 the NFC and NFD spellings of `Füßen.flac` are two +//! different files, and only one of them exists. `canonical_to_platform` hands +//! the stored path straight to `PathBuf`, so normalizing it would produce a name +//! the kernel cannot open. Paths keep whatever the filesystem reported, byte for +//! byte; only `filename`, the display copy, is folded. + +use std::borrow::Cow; +use unicode_normalization::{is_nfc_quick, IsNormalized, UnicodeNormalization}; + +/// Fold `value` to NFC, borrowing when it is already in that form. +/// +/// `is_nfc_quick` answers `Yes` or `No` from character properties alone for the +/// overwhelming majority of input — ASCII included — so the common path costs a +/// scan and no allocation. Only a `Maybe` pays for the full composition. +pub(crate) fn to_nfc(value: &str) -> Cow<'_, str> { + match is_nfc_quick(value.chars()) { + IsNormalized::Yes => Cow::Borrowed(value), + _ => Cow::Owned(value.nfc().collect()), + } +} + +/// Fold an owned string in place, keeping the allocation when it is already NFC. +pub(crate) fn into_nfc(value: String) -> String { + match to_nfc(&value) { + Cow::Borrowed(_) => value, + Cow::Owned(normalized) => normalized, + } +} + +/// Fold an optional field, as media metadata columns are. +pub(crate) fn normalize_field(field: &mut Option) { + if let Some(value) = field.take() { + *field = Some(into_nfc(value)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const NFC: &str = "Füßen"; + const NFD: &str = "Fu\u{308}\u{df}en"; + + #[test] + fn the_two_spellings_differ_until_they_are_folded() { + // The premise of this module: equal to a reader, unequal to a comparison. + assert_ne!(NFC, NFD); + assert_eq!(to_nfc(NFC), to_nfc(NFD)); + assert_eq!(to_nfc(NFD), NFC); + } + + #[test] + fn text_already_in_nfc_is_not_copied() { + assert!(matches!(to_nfc("plain ascii"), Cow::Borrowed(_))); + assert!(matches!(to_nfc(NFC), Cow::Borrowed(_))); + assert!(matches!(to_nfc(NFD), Cow::Owned(_))); + } + + #[test] + fn folding_is_idempotent_across_the_corpus() { + for sample in crate::unicode_corpus::alignment_sweep() { + let once = to_nfc(&sample).into_owned(); + let twice = to_nfc(&once).into_owned(); + assert_eq!(once, twice, "folding {sample:?} was not idempotent"); + } + } + + #[test] + fn an_empty_or_absent_field_stays_that_way() { + let mut absent = None; + normalize_field(&mut absent); + assert_eq!(absent, None); + + let mut empty = Some(String::new()); + normalize_field(&mut empty); + assert_eq!(empty.as_deref(), Some("")); + } +} diff --git a/crates/vuio-core/src/unicode_corpus.rs b/crates/vuio-core/src/unicode_corpus.rs new file mode 100644 index 00000000..190bb119 --- /dev/null +++ b/crates/vuio-core/src/unicode_corpus.rs @@ -0,0 +1,67 @@ +//! Text that byte-oriented string handling has historically broken on, plus the +//! alignment sweep that turns offset bugs into deterministic test failures. +//! +//! Issue #51 was `&title[title.len() - 5..]` splitting a `ü` in half. A flat list +//! of awkward strings would not have caught it: the panic needs the slice point to +//! land *inside* a multi-byte character, which depends on the string's length +//! relative to that character's width. [`alignment_sweep`] therefore repeats every +//! sample at eight different paddings, so whatever index the code under test +//! computes eventually lands on every byte position of every sample — including +//! the continuation bytes that are never valid boundaries. + +/// Strings that stress encoding width, combining marks, direction and escaping. +pub(crate) const SAMPLES: &[&str] = &[ + "", + "plain ascii title", + // Issue #51, and the umlauts that reported it. + "Wie Felsenabgrund mir zu Füßen", + "Grüße aus München", + "Ärger mit Öl", + // The same word pre-composed (NFC) and decomposed (NFD). macOS hands back + // whichever form was written, so both reach us from the same directory. + "Füßen", + "Fu\u{308}\u{df}en", + // Scripts with no ASCII at all, including right-to-left and Indic clusters. + "Лунная соната", + "Ἀχιλλεύς", + "交響曲第八番 変ホ長調", + "안녕하세요", + "שלום עולם", + "مرحبا بالعالم", + "नमस्ते", + "สวัสดีชาวโลก", + // Four-byte scalars, and graphemes built from several of them. + "🎵🎶", + "👩‍👩‍👧‍👦", + "👋🏽", + "🇩🇪", + "𝄞 Clair de Lune", + "\u{10ffff}", + // Marks and invisibles that make character count disagree with byte count. + "e\u{301}\u{302}\u{303}", + "a\u{200b}b\u{200d}c", + "a\u{202e}reversed", + "\u{feff}leading bom", + // Case mapping that changes length, and the dotted/dotless Turkish pair. + "ß vs SS", + "Džunglica", + "filigree", + "İstanbul", + "ırmak", + // Characters an XML writer has to replace rather than pass through. + "A&B \"quoted\" 'single'", + "bell\u{7}vertical\u{b}form\u{c}feed", + "\u{fffe} noncharacter", + "…ellipsis…", +]; + +/// Every sample at eight paddings on each side, so a computed byte offset lands +/// on every position within every sample across the whole sweep. +pub(crate) fn alignment_sweep() -> impl Iterator { + SAMPLES.iter().flat_map(|sample| { + (0..8).flat_map(move |pad| { + let filler = "x".repeat(pad); + [format!("{filler}{sample}"), format!("{sample}{filler}")] + }) + }) +} diff --git a/crates/vuio-core/src/web/subtitles.rs b/crates/vuio-core/src/web/subtitles.rs index 562d95a2..05a8c5a5 100644 --- a/crates/vuio-core/src/web/subtitles.rs +++ b/crates/vuio-core/src/web/subtitles.rs @@ -106,6 +106,18 @@ fn digits_only(value: &str) -> bool { mod tests { use super::*; + /// Cue text is author-supplied and arrives in every script there is; timing + /// lines are indexed by byte offset, so malformed ones are fed in too. + #[test] + fn srt_conversion_survives_every_script_and_alignment() { + for sample in crate::unicode_corpus::alignment_sweep() { + let srt = format!("1\n00:00:01,000 --> 00:00:04,500\n{sample}\n"); + assert!(srt_to_vtt(&srt).starts_with("WEBVTT")); + let malformed = format!("1\n{sample} --> {sample}\n{sample}\n"); + assert!(srt_to_vtt(&malformed).starts_with("WEBVTT")); + } + } + #[test] fn converts_a_basic_cue() { let vtt = srt_to_vtt("1\n00:00:01,000 --> 00:00:04,500\nHello\n"); From c0702c9e46ad3d540e7b4b72ff341813e0cd8ea9 Mon Sep 17 00:00:00 2001 From: vyrti Date: Mon, 14 Sep 2026 21:14:40 +0300 Subject: [PATCH 4/8] new ver --- Cargo.lock | 358 +++++++++++++-------------- crates/vendor/libxaac-sys/Cargo.toml | 2 +- crates/vendor/xaac-rs/Cargo.toml | 2 +- crates/vuio-cli/Cargo.toml | 2 +- crates/vuio-codec-ac3/Cargo.toml | 2 +- crates/vuio-core/Cargo.toml | 6 +- 6 files changed, 186 insertions(+), 186 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 10522c0b..b49a1133 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -122,7 +122,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.5", ] [[package]] @@ -216,6 +216,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "base64ct" version = "1.8.3" @@ -224,9 +230,9 @@ checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] name = "bitflags" -version = "2.13.1" +version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" [[package]] name = "block-buffer" @@ -272,9 +278,9 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cc" -version = "1.4.3" +version = "1.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" +checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4" dependencies = [ "find-msvc-tools", "shlex", @@ -366,7 +372,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.5", ] [[package]] @@ -389,9 +395,9 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "combine" -version = "4.6.7" +version = "4.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" dependencies = [ "bytes", "memchr", @@ -425,6 +431,12 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -436,27 +448,27 @@ dependencies = [ [[package]] name = "cpufeatures" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" dependencies = [ "libc", ] [[package]] name = "crc32fast" -version = "1.5.0" +version = "1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" dependencies = [ "cfg-if", ] [[package]] name = "crossbeam" -version = "0.8.4" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1137cd7e7fc0fb5d3c5a8678be38ec56e819125d8d7907411fe24ccb943faca8" +checksum = "e71406cd8807725f7ac2f999a4cdd32e98f829fdf65f528343cebf945e41df1e" dependencies = [ "crossbeam-channel", "crossbeam-deque", @@ -467,18 +479,18 @@ dependencies = [ [[package]] name = "crossbeam-channel" -version = "0.5.16" +version = "0.5.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-deque" -version = "0.8.7" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" dependencies = [ "crossbeam-epoch", "crossbeam-utils", @@ -486,27 +498,27 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.20" +version = "0.9.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-queue" -version = "0.3.13" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" +checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.22" +version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" [[package]] name = "crypto-common" @@ -605,9 +617,9 @@ dependencies = [ [[package]] name = "dirs" -version = "6.0.0" +version = "7.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +checksum = "8d57d423b3c82e89b9a24ca3091fee61f456a26edbd28d26c65906f4bc1dcd8f" dependencies = [ "dirs-sys", ] @@ -632,7 +644,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.5", ] [[package]] @@ -662,17 +674,23 @@ dependencies = [ [[package]] name = "either" -version = "1.17.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" [[package]] name = "encoding_rs" -version = "0.8.35" +version = "0.8.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +checksum = "7b5ef0006ac9ab233c38522f5ae99cae3625151de8f706cacee1cba4b8e2832a" dependencies = [ "cfg-if", + "core_detect", + "multiversion", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", ] [[package]] @@ -732,18 +750,19 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.11" +version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" [[package]] name = "flate2" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" dependencies = [ "crc32fast", "miniz_oxide", + "zlib-rs", ] [[package]] @@ -804,7 +823,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.5", ] [[package]] @@ -897,14 +916,14 @@ dependencies = [ [[package]] name = "hap-transport" -version = "1.3.0" +version = "1.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52fcc34631adc245cd0f9614a6c7b4aaa210e462813a2a206f21e7524ddfb1b6" +checksum = "645e484ca0c495ae17b16ed1091f55f94565c90e0e25e1360f9efbebfc986009" dependencies = [ - "base64", + "base64 0.22.1", "hap-crypto", "hap-tlv8", - "mdns-sd 0.20.3", + "mdns-sd", "thiserror 2.0.20", "tokio", ] @@ -929,9 +948,9 @@ dependencies = [ [[package]] name = "hashlink" -version = "0.12.1" +version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32069d97bb81e38fa67eab65e3393bf804bb85969f2bc06bf13f64aef5aba248" +checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb" dependencies = [ "hashbrown 0.17.1", ] @@ -1042,18 +1061,18 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hybrid-array" -version = "0.4.14" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" dependencies = [ "typenum", ] [[package]] name = "hyper" -version = "1.11.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" dependencies = [ "atomic-waker", "bytes", @@ -1091,7 +1110,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", @@ -1202,9 +1221,9 @@ checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.3.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", @@ -1259,28 +1278,19 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.14.0" +version = "2.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", "hashbrown 0.17.1", ] -[[package]] -name = "indoc" -version = "2.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" -dependencies = [ - "rustversion", -] - [[package]] name = "inotify" -version = "0.11.4" +version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "153be1941a183ec9ccd095ddbe17a8b8d435ef6c76e9e02451b933c3999af2c8" +checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592" dependencies = [ "bitflags", "inotify-sys", @@ -1307,9 +1317,9 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.12.1" +version = "2.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" [[package]] name = "is_terminal_polyfill" @@ -1383,9 +1393,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.104" +version = "0.3.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" dependencies = [ "cfg-if", "futures-util", @@ -1436,9 +1446,9 @@ checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libredox" -version = "0.1.20" +version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a" +checksum = "6480ccc157a1389bb2e4891b24751b0f798ba640d22386f23143fbcc89da195a" dependencies = [ "libc", ] @@ -1484,9 +1494,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.33" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "matchers" @@ -1505,24 +1515,9 @@ checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" [[package]] name = "mdns-sd" -version = "0.20.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86dbb9f00c8c367f75ed3a775d3eb31d0375a72f58275ef64a1bc53c255a2ce2" -dependencies = [ - "fastrand", - "flume", - "if-addrs", - "log", - "mio", - "socket-pktinfo", - "socket2", -] - -[[package]] -name = "mdns-sd" -version = "0.21.0" +version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba97b4c886eea3c2823388120d92063c011ac866919ffc4200a0e4b1642a54a5" +checksum = "a63c9b854b5ad0812ac5969f8db92a59f28b45d0a5ae599b9a45b84c5c8a08e8" dependencies = [ "fastrand", "flume", @@ -1539,15 +1534,6 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" -[[package]] -name = "memoffset" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" -dependencies = [ - "autocfg", -] - [[package]] name = "metaflac" version = "0.2.8" @@ -1566,9 +1552,9 @@ checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] name = "miniz_oxide" -version = "0.8.9" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" dependencies = [ "adler2", "simd-adler32", @@ -1576,9 +1562,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.2" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" dependencies = [ "libc", "log", @@ -1619,6 +1605,33 @@ dependencies = [ "version_check", ] +[[package]] +name = "multiversion" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ca4bea16ffc3f443cf7d866912118196bfef4c6a1556ca00f9f9b00bb43f7c" +dependencies = [ + "multiversion-macros", +] + +[[package]] +name = "multiversion-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d416831a7317ef4b08bee00b69cbbb9c8763da7959a7026244d6266869f9c83" +dependencies = [ + "proc-macro2", + "quote", + "rustversion", + "syn 3.0.5", +] + +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + [[package]] name = "notify" version = "8.2.0" @@ -1841,11 +1854,11 @@ checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "plist" -version = "1.10.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" +checksum = "2896bade328c13f7042a297ea5ac5b0951f6cf989dea5f32c2fd98da398195cb" dependencies = [ - "base64", + "base64 0.23.1", "indexmap", "quick-xml", "serde", @@ -1927,35 +1940,32 @@ dependencies = [ [[package]] name = "pyo3" -version = "0.27.2" +version = "0.29.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab53c047fcd1a1d2a8820fe84f05d6be69e9526be40cb03b73f86b6b03e6d87d" +checksum = "4688ddedf473e32662b9b067670129a8afb8c18e351482c70d62ba4a88171e8b" dependencies = [ - "indoc", "libc", - "memoffset", "once_cell", "portable-atomic", "pyo3-build-config", "pyo3-ffi", "pyo3-macros", - "unindent", ] [[package]] name = "pyo3-build-config" -version = "0.27.2" +version = "0.29.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b455933107de8642b4487ed26d912c2d899dec6114884214a0b3bb3be9261ea6" +checksum = "f41027e41b4bd03f6e60f9f417fe24a6341a6bb744edd62b6f709f2a52ea30e9" dependencies = [ "target-lexicon", ] [[package]] name = "pyo3-ffi" -version = "0.27.2" +version = "0.29.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c85c9cbfaddf651b1221594209aed57e9e5cff63c4d11d1feead529b872a089" +checksum = "e591a95526fead067432c3b3a33fc74770b87b1e04e73671090d9c2055a2b327" dependencies = [ "libc", "pyo3-build-config", @@ -1963,9 +1973,9 @@ dependencies = [ [[package]] name = "pyo3-macros" -version = "0.27.2" +version = "0.29.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a5b10c9bf9888125d917fb4d2ca2d25c8df94c7ab5a52e13313a07e050a3b02" +checksum = "73225868fc1cd84eef2c3c230ddb91273bf1de46aeb8a4248da76d32a0924a1c" dependencies = [ "proc-macro2", "pyo3-macros-backend", @@ -1975,22 +1985,21 @@ dependencies = [ [[package]] name = "pyo3-macros-backend" -version = "0.27.2" +version = "0.29.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03b51720d314836e53327f5871d4c0cfb4fb37cc2c4a11cc71907a86342c40f9" +checksum = "571575aa3749fa6216757dd47d2a3e7ef360f329a40f0666a9fbd14889024952" dependencies = [ "heck", "proc-macro2", - "pyo3-build-config", "quote", "syn 2.0.119", ] [[package]] name = "quick-xml" -version = "0.41.0" +version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" dependencies = [ "memchr", ] @@ -2090,11 +2099,11 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "reqwest" -version = "0.13.4" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" dependencies = [ - "base64", + "base64 0.23.1", "bytes", "futures-core", "http", @@ -2201,9 +2210,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.43" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "log", "once_cell", @@ -2264,9 +2273,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.14" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "ring", "rustls-pki-types", @@ -2365,7 +2374,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.5", ] [[package]] @@ -2431,7 +2440,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "digest 0.11.3", ] @@ -2499,9 +2508,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.2" +version = "1.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" [[package]] name = "socket-pktinfo" @@ -2790,9 +2799,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.3" +version = "3.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" dependencies = [ "proc-macro2", "quote", @@ -2889,7 +2898,7 @@ checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.5", ] [[package]] @@ -2945,18 +2954,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cf0ded5c4e56918d8f8a339e1bb67d038d3bc6d144ac407904015ba2e4cde9b" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "tokio" @@ -2983,14 +2983,14 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.5", ] [[package]] name = "tokio-rustls" -version = "0.26.4" +version = "0.26.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" dependencies = [ "rustls", "tokio", @@ -3023,9 +3023,9 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.4+spec-1.1.0" +version = "1.1.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" +checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" dependencies = [ "indexmap", "serde_core", @@ -3047,9 +3047,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.13+spec-1.1.0" +version = "0.25.15+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" dependencies = [ "indexmap", "toml_datetime", @@ -3219,12 +3219,6 @@ dependencies = [ "tinyvec", ] -[[package]] -name = "unindent" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" - [[package]] name = "universal-hash" version = "0.5.1" @@ -3267,9 +3261,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.24.0" +version = "1.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -3315,7 +3309,7 @@ version = "0.0.4" dependencies = [ "axum", "bytes", - "mdns-sd 0.21.0", + "mdns-sd", "prost", "rustls", "rustls-native-certs", @@ -3397,7 +3391,7 @@ dependencies = [ "ipnet", "jwalk", "libc", - "mdns-sd 0.21.0", + "mdns-sd", "notify", "notify-debouncer-full", "num-bigint 0.5.1", @@ -3469,9 +3463,9 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasm-bindgen" -version = "0.2.127" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" dependencies = [ "cfg-if", "once_cell", @@ -3482,9 +3476,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.77" +version = "0.4.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" dependencies = [ "js-sys", "wasm-bindgen", @@ -3492,9 +3486,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.127" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3502,31 +3496,31 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.127" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.5", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.127" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" dependencies = [ "unicode-ident", ] [[package]] name = "web-sys" -version = "0.3.104" +version = "0.3.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" dependencies = [ "js-sys", "wasm-bindgen", @@ -3950,9 +3944,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.7" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ "yoke", "zerofrom", @@ -3961,15 +3955,21 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.4" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47402523226a02bfe5230160dc3ccc089aa6f6f19e7fcbb4e6f824bbb1b4aa62" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.5", ] +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + [[package]] name = "zmij" version = "1.0.23" diff --git a/crates/vendor/libxaac-sys/Cargo.toml b/crates/vendor/libxaac-sys/Cargo.toml index 27e7d5d6..92a4fd4b 100644 --- a/crates/vendor/libxaac-sys/Cargo.toml +++ b/crates/vendor/libxaac-sys/Cargo.toml @@ -67,4 +67,4 @@ path = "examples/sample.rs" [dependencies] [build-dependencies] -cc = "1.0" +cc = "1.4" diff --git a/crates/vendor/xaac-rs/Cargo.toml b/crates/vendor/xaac-rs/Cargo.toml index d3a0eb19..830147fa 100644 --- a/crates/vendor/xaac-rs/Cargo.toml +++ b/crates/vendor/xaac-rs/Cargo.toml @@ -76,6 +76,6 @@ version = "0.1.0" path = "../libxaac-sys" [dependencies.pyo3] -version = "0.27" +version = "0.29" features = ["extension-module"] optional = true diff --git a/crates/vuio-cli/Cargo.toml b/crates/vuio-cli/Cargo.toml index 8cc973f4..01f3e43d 100644 --- a/crates/vuio-cli/Cargo.toml +++ b/crates/vuio-cli/Cargo.toml @@ -41,6 +41,6 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" tokio = { version = "1.53", features = ["rt-multi-thread", "macros", "signal", "io-std", "io-util"] } tracing = "0.1" -uuid = { version = "1.24", features = ["v4"] } +uuid = { version = "1.26", features = ["v4"] } vuio-core = { path = "../vuio-core", version = "0.0.49" } diff --git a/crates/vuio-codec-ac3/Cargo.toml b/crates/vuio-codec-ac3/Cargo.toml index e4ae88a5..fe23d101 100644 --- a/crates/vuio-codec-ac3/Cargo.toml +++ b/crates/vuio-codec-ac3/Cargo.toml @@ -24,7 +24,7 @@ include = [ [dependencies] oxideav-core = { package = "vuio-codec-core", version = "0.0.1", path = "../vuio-codec-core" } -rayon = "1.10" +rayon = "1.12" [lints.rust] warnings = "allow" diff --git a/crates/vuio-core/Cargo.toml b/crates/vuio-core/Cargo.toml index 761c469a..5d1d4e83 100644 --- a/crates/vuio-core/Cargo.toml +++ b/crates/vuio-core/Cargo.toml @@ -137,7 +137,7 @@ unstable-internals = [] axum = { version = "0.8", features = ["multipart"] } tokio = { version = "1.53", features = ["rt-multi-thread", "net", "fs", "time", "sync", "macros", "io-util"] } serde = { version = "1.0", features = ["derive"] } -uuid = { version = "1.24", features = ["v4"] } +uuid = { version = "1.26", features = ["v4"] } tokio-util = { version = "0.7", features = ["io", "rt"] } thiserror = "2.0" anyhow = "1.0" @@ -153,7 +153,7 @@ notify-debouncer-full = "0.7" toml = "1.1" toml_edit = "0.25" serde_json = "1.0" -dirs = "6.0" +dirs = "7.0" hostname = "0.4" ipnet = "2.12" http = "1.5" @@ -162,7 +162,7 @@ hyper = { version = "1.11", features = ["client", "http1"] } hyper-util = { version = "0.1", features = ["client-legacy", "http1", "tokio"] } bytes = "1.12" rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } -quick-xml = "0.41" +quick-xml = "0.42" percent-encoding = "2.3" # Media text arrives in whichever normalization form the tagger or filesystem # used; comparing and indexing it requires one form. See `src/text.rs`. From 0f7e037d0e55ef4419af1eb2194e6bab94f7a977 Mon Sep 17 00:00:00 2001 From: vyrti Date: Mon, 14 Sep 2026 21:26:50 +0300 Subject: [PATCH 5/8] fix(xml): support quick-xml 0.42 --- crates/vuio-core/src/casting/airplay/mod.rs | 4 ++-- crates/vuio-core/src/tv_control.rs | 7 +++---- crates/vuio-core/src/web/soap/parser.rs | 19 ++++++------------- 3 files changed, 11 insertions(+), 19 deletions(-) diff --git a/crates/vuio-core/src/casting/airplay/mod.rs b/crates/vuio-core/src/casting/airplay/mod.rs index f26f8183..c17fe775 100644 --- a/crates/vuio-core/src/casting/airplay/mod.rs +++ b/crates/vuio-core/src/casting/airplay/mod.rs @@ -2170,10 +2170,10 @@ fn parse_playback_info(xml: &str) -> anyhow::Result> { loop { match reader.read_event()? { Event::Start(element) => { - current = String::from_utf8_lossy(element.name().as_ref()).into_owned(); + current = element.name().as_ref().to_owned(); } Event::Text(text) => { - let value = reader.decoder().decode(text.as_ref())?.into_owned(); + let value = text.as_ref().to_owned(); match current.as_str() { "key" => pending_key = Some(value), "real" | "integer" => { diff --git a/crates/vuio-core/src/tv_control.rs b/crates/vuio-core/src/tv_control.rs index 0d24804b..31c2f0b3 100644 --- a/crates/vuio-core/src/tv_control.rs +++ b/crates/vuio-core/src/tv_control.rs @@ -193,11 +193,10 @@ async fn fetch_tv_info( loop { match reader.read_event_into(&mut buf) { Ok(Event::Start(e)) => { - current_element = String::from_utf8_lossy(e.name().as_ref()).to_string(); + current_element = e.name().as_ref().to_owned(); } Ok(Event::Text(e)) => { - let decoded = reader.decoder().decode(e.as_ref())?; - let text = quick_xml::escape::unescape(&decoded)?.into_owned(); + let text = quick_xml::escape::unescape(e.as_ref())?.into_owned(); match current_element.as_str() { "friendlyName" if friendly_name.is_empty() => { friendly_name = text; @@ -225,7 +224,7 @@ async fn fetch_tv_info( } } Ok(Event::End(e)) => { - let name = String::from_utf8_lossy(e.name().as_ref()).to_string(); + let name = e.name().as_ref().to_owned(); if name == "service" { if !current_service_type.contains("AVTransport") { in_av_transport_service = false; diff --git a/crates/vuio-core/src/web/soap/parser.rs b/crates/vuio-core/src/web/soap/parser.rs index 766c6860..8c940a99 100644 --- a/crates/vuio-core/src/web/soap/parser.rs +++ b/crates/vuio-core/src/web/soap/parser.rs @@ -114,11 +114,7 @@ pub(super) fn xml_element_text(body: &str, expected_name: &str) -> Option { - return reader - .decoder() - .decode(text.as_ref()) - .ok() - .map(|value| value.into_owned()); + return Some(text.as_ref().to_owned()); } Event::End(_) => capture = false, Event::Eof => return None, @@ -128,13 +124,10 @@ pub(super) fn xml_element_text(body: &str, expected_name: &str) -> Option &str { - let local = name - .iter() - .rposition(|byte| *byte == b':') - .map(|position| &name[position + 1..]) - .unwrap_or(name); - std::str::from_utf8(local).unwrap_or_default() +fn local_xml_name(name: &str) -> &str { + name.rsplit_once(':') + .map(|(_, local)| local) + .unwrap_or(name) } fn invalid_soap_request(message: &'static str) -> Response { @@ -167,7 +160,7 @@ pub(super) fn parse_browse_params(body: &str) -> BrowseParams { current_element = local_xml_name(element.name().as_ref()).to_string(); } Ok(Event::Text(ref text)) => { - let text = reader.decoder().decode(text.as_ref()).unwrap_or_default(); + let text = text.as_ref(); match current_element.as_str() { "ObjectID" => { object_id = text.trim().to_string(); From cfe0710e6d4182ec98f89f65cc6e45325cd1dd6e Mon Sep 17 00:00:00 2001 From: vyrti Date: Mon, 14 Sep 2026 21:26:57 +0300 Subject: [PATCH 6/8] chore: bump version to 0.0.50 --- .claude-plugin/marketplace.json | 2 +- Cargo.lock | 8 ++++---- claude/mcpb/manifest.json | 2 +- claude/plugin/.claude-plugin/plugin.json | 2 +- crates/vuio-bench/Cargo.toml | 4 ++-- crates/vuio-cli/Cargo.toml | 4 ++-- crates/vuio-core/Cargo.toml | 6 +++--- crates/vuio-web/Cargo.toml | 2 +- docs/api.md | 2 +- docs/install.md | 2 +- docs/kubernetes.md | 2 +- packaging/docker/builddocker.sh | 2 +- packaging/linux/generate-repo.sh | 2 +- 13 files changed, 20 insertions(+), 20 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 2d7beeab..48051a35 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "name": "vuio", "displayName": "VuIO Media Server", "description": "Browse, search and cast your VuIO media library — and control the TVs and speakers on your network — from Claude.", - "version": "0.0.49", + "version": "0.0.50", "author": { "name": "vyrti", "url": "https://github.com/vuiodev" diff --git a/Cargo.lock b/Cargo.lock index b49a1133..96941c8a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3290,7 +3290,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vuio-bench" -version = "0.0.49" +version = "0.0.50" dependencies = [ "anyhow", "clap", @@ -3325,7 +3325,7 @@ dependencies = [ [[package]] name = "vuio-cli" -version = "0.0.49" +version = "0.0.50" dependencies = [ "anyhow", "audiotags", @@ -3366,7 +3366,7 @@ dependencies = [ [[package]] name = "vuio-core" -version = "0.0.49" +version = "0.0.50" dependencies = [ "anyhow", "async-stream", @@ -3431,7 +3431,7 @@ dependencies = [ [[package]] name = "vuio-web" -version = "0.0.49" +version = "0.0.50" dependencies = [ "axum", ] diff --git a/claude/mcpb/manifest.json b/claude/mcpb/manifest.json index 384e9d4a..678504b5 100644 --- a/claude/mcpb/manifest.json +++ b/claude/mcpb/manifest.json @@ -2,7 +2,7 @@ "manifest_version": "0.3", "name": "vuio", "display_name": "VuIO Media Server", - "version": "0.0.49", + "version": "0.0.50", "description": "Browse, search and cast your VuIO media library from Claude.", "long_description": "Connects Claude to a VuIO media server on your network. Search the library, browse folders, build playlists, and cast to DLNA, Chromecast and AirPlay devices.\n\nThis bundle runs `vuio mcp`, which bridges Claude's stdio connection to a VuIO server that is already running. It does not start a server or open the library database itself — point it at the machine that does.", "author": { diff --git a/claude/plugin/.claude-plugin/plugin.json b/claude/plugin/.claude-plugin/plugin.json index ef7413d2..aa4a9100 100644 --- a/claude/plugin/.claude-plugin/plugin.json +++ b/claude/plugin/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "vuio", "displayName": "VuIO Media Server", - "version": "0.0.49", + "version": "0.0.50", "description": "Browse, search and cast your VuIO media library — and control the TVs and speakers on your network — from Claude.", "author": { "name": "vyrti", diff --git a/crates/vuio-bench/Cargo.toml b/crates/vuio-bench/Cargo.toml index 4bfb688a..b0228283 100644 --- a/crates/vuio-bench/Cargo.toml +++ b/crates/vuio-bench/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "vuio-bench" -version = "0.0.49" +version = "0.0.50" edition = "2021" authors = ["vyrti"] description = "Generates large VuIO libraries for performance work. Not published." @@ -37,7 +37,7 @@ tokio = { version = "1.53", features = ["rt-multi-thread", "macros"] } # because it opens every internal module and carries no stability promise. This # crate is `publish = false` and exists only to drive the database from the # inside, which is the same category as core's own dev-dependency on itself. -vuio-core = { path = "../vuio-core", version = "0.0.49", features = ["unstable-internals", "transcode-aac", "transcode-ac3", "transcode-dts"] } +vuio-core = { path = "../vuio-core", version = "0.0.50", features = ["unstable-internals", "transcode-aac", "transcode-ac3", "transcode-dts"] } vuio-codec-ac3 = { path = "../vuio-codec-ac3" } oxideav-dts = { package = "vuio-codec-dts", path = "../vuio-codec-dts" } oxideav-core = { package = "vuio-codec-core", path = "../vuio-codec-core" } diff --git a/crates/vuio-cli/Cargo.toml b/crates/vuio-cli/Cargo.toml index 01f3e43d..21786029 100644 --- a/crates/vuio-cli/Cargo.toml +++ b/crates/vuio-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "vuio-cli" -version = "0.0.49" +version = "0.0.50" edition = "2021" rust-version = "1.98" authors = ["vyrti"] @@ -42,5 +42,5 @@ serde_json = "1.0" tokio = { version = "1.53", features = ["rt-multi-thread", "macros", "signal", "io-std", "io-util"] } tracing = "0.1" uuid = { version = "1.26", features = ["v4"] } -vuio-core = { path = "../vuio-core", version = "0.0.49" } +vuio-core = { path = "../vuio-core", version = "0.0.50" } diff --git a/crates/vuio-core/Cargo.toml b/crates/vuio-core/Cargo.toml index 5d1d4e83..a7eef154 100644 --- a/crates/vuio-core/Cargo.toml +++ b/crates/vuio-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "vuio-core" -version = "0.0.49" +version = "0.0.50" edition = "2021" rust-version = "1.98" authors = ["vyrti"] @@ -171,7 +171,7 @@ sysinfo = { version = "0.39", default-features = false, features = ["system", "d socket2 = { version = "0.6", features = ["all"] } mdns-sd = { version = "0.21", default-features = false, features = ["async"] } vuio-cast = { path = "../vuio-cast", version = "0.0.4", default-features = false, optional = true } -vuio-web = { path = "../vuio-web", version = "0.0.49", optional = true } +vuio-web = { path = "../vuio-web", version = "0.0.50", optional = true } jwalk = "0.9" tokio-stream = "0.1" hap-crypto = { version = "1.4", optional = true } @@ -210,7 +210,7 @@ windows = { version = "0.62", features = [ ] } [dev-dependencies] -vuio-core = { path = ".", version = "0.0.49", features = ["unstable-internals"] } +vuio-core = { path = ".", version = "0.0.50", 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" diff --git a/crates/vuio-web/Cargo.toml b/crates/vuio-web/Cargo.toml index 83a15784..0ca17d42 100644 --- a/crates/vuio-web/Cargo.toml +++ b/crates/vuio-web/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "vuio-web" -version = "0.0.49" +version = "0.0.50" edition = "2021" rust-version = "1.98" authors = ["vyrti"] diff --git a/docs/api.md b/docs/api.md index b9f13682..f789155f 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.49", + "version": "0.0.50", // 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/docs/install.md b/docs/install.md index 31a274c6..c70d0101 100644 --- a/docs/install.md +++ b/docs/install.md @@ -242,7 +242,7 @@ Deploy VuIO to a Kubernetes cluster using the official Helm chart from GHCR. For ```bash # Install directly from GitHub Container Registry -helm install vuio oci://ghcr.io/vuiodev/charts/vuio --version 0.0.49 +helm install vuio oci://ghcr.io/vuiodev/charts/vuio --version 0.0.50 ``` Or install from local source: diff --git a/docs/kubernetes.md b/docs/kubernetes.md index c061af22..3e0ff8c3 100644 --- a/docs/kubernetes.md +++ b/docs/kubernetes.md @@ -11,7 +11,7 @@ VuIO provides an official Helm 3 chart to deploy the media server directly onto 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.49 +helm install vuio oci://ghcr.io/vuiodev/charts/vuio --version 0.0.50 ``` ### Local Installation diff --git a/packaging/docker/builddocker.sh b/packaging/docker/builddocker.sh index 1e238a9b..d55feb99 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.49" +export VERSION_TAG="v0.0.50" docker login ghcr.io diff --git a/packaging/linux/generate-repo.sh b/packaging/linux/generate-repo.sh index 9ff2ace7..741cff95 100755 --- a/packaging/linux/generate-repo.sh +++ b/packaging/linux/generate-repo.sh @@ -250,7 +250,7 @@ for arch in x86_64 aarch64; do zstd -d "$pkg" -o pkgtemp/pkg.tar --quiet 2>/dev/null && \ tar -xf pkgtemp/pkg.tar -C pkgtemp .PKGINFO 2>/dev/null || true pname="vuio" - pver="0.0.49-1" + pver="0.0.50-1" pdesc="Cross-platform DLNA media server" purl="https://github.com/vuiodev/vuio" psize="15000000" From 54582f4838559bda78acde59454dd2b071379509 Mon Sep 17 00:00:00 2001 From: vyrti Date: Mon, 14 Sep 2026 21:40:18 +0300 Subject: [PATCH 7/8] fix(text): forbid unchecked UTF-8 slicing --- .github/workflows/ci.yml | 2 +- crates/vuio-codec-core/src/lib.rs | 1 + crates/vuio-codec-core/src/registry/source.rs | 5 +--- crates/vuio-core/src/casting/airplay/raop.rs | 16 +++++----- .../src/database/playlist_formats.rs | 29 +++++++++---------- crates/vuio-core/src/http_client.rs | 2 +- crates/vuio-core/src/lib.rs | 1 + crates/vuio-core/src/mediainfo/artwork.rs | 7 +++-- crates/vuio-core/src/mediainfo/job.rs | 3 +- crates/vuio-core/src/mediainfo/matching.rs | 13 ++------- .../src/platform/filesystem/metadata.rs | 16 +++++----- .../src/platform/filesystem/normalization.rs | 4 +-- crates/vuio-core/src/web/radio.rs | 5 ++-- crates/vuio-core/src/web/soap/common.rs | 9 +++--- crates/vuio-core/src/web/subtitles.rs | 2 +- crates/vuio-core/src/web/ui.rs | 7 ++++- .../tests/music_browse_integration_tests.rs | 4 ++- crates/vuio-core/tests/regression_tests.rs | 2 +- .../tests/web_ui_integration_tests.rs | 6 ++-- 19 files changed, 67 insertions(+), 67 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fcc1d481..dbb93a1f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -143,7 +143,7 @@ jobs: - name: Run Clippy env: RUSTC_WRAPPER: sccache - run: cargo clippy -p vuio-core -p vuio-cli -p vuio-cast -p vuio-web --all-targets --all-features -- -D clippy::all -D warnings + run: cargo clippy -p vuio-core -p vuio-cli -p vuio-cast -p vuio-web --all-targets --all-features -- -D clippy::all -D clippy::string_slice -D warnings - name: Show sccache stats if: always() run: sccache --show-stats || true diff --git a/crates/vuio-codec-core/src/lib.rs b/crates/vuio-codec-core/src/lib.rs index 15760f3b..af1b5533 100644 --- a/crates/vuio-codec-core/src/lib.rs +++ b/crates/vuio-codec-core/src/lib.rs @@ -7,6 +7,7 @@ //! source / filter) into a single value that consumers pass around. #![warn(missing_docs)] +#![deny(clippy::string_slice)] pub mod arena; pub mod bits; diff --git a/crates/vuio-codec-core/src/registry/source.rs b/crates/vuio-codec-core/src/registry/source.rs index 35bf2e62..a3b9cc29 100644 --- a/crates/vuio-codec-core/src/registry/source.rs +++ b/crates/vuio-codec-core/src/registry/source.rs @@ -309,10 +309,7 @@ fn dispatch(entry: &OpenerEntry, uri_str: &str) -> Result { /// `"file"` and `rest = uri`. Path-like inputs that happen to start with /// `c:` on Windows are treated as bare paths. pub(crate) fn split_scheme(uri: &str) -> (&str, &str) { - if let Some(idx) = uri.find(':') { - let (scheme, rest) = uri.split_at(idx); - let rest = &rest[1..]; // skip ':' - + if let Some((scheme, rest)) = uri.split_once(':') { // Reject single-letter scheme that looks like a Windows drive letter. if scheme.len() == 1 && scheme.chars().next().unwrap().is_ascii_alphabetic() { return ("file", uri); diff --git a/crates/vuio-core/src/casting/airplay/raop.rs b/crates/vuio-core/src/casting/airplay/raop.rs index 157d6847..d7f49ea1 100644 --- a/crates/vuio-core/src/casting/airplay/raop.rs +++ b/crates/vuio-core/src/casting/airplay/raop.rs @@ -460,15 +460,15 @@ mod tests { bits.push_str(&format!("{byte:08b}")); } assert!(bits.starts_with("001"), "element type must be CPE: {bits}"); - assert_eq!(&bits[3..7], "0000"); - assert_eq!(&bits[7..19], "000000000000"); - assert_eq!(&bits[19..20], "0", "hasSize"); - assert_eq!(&bits[20..22], "00"); - assert_eq!(&bits[22..23], "1", "isNotCompressed"); + assert_eq!(bits.get(3..7), Some("0000")); + assert_eq!(bits.get(7..19), Some("000000000000")); + assert_eq!(bits.get(19..20), Some("0"), "hasSize"); + assert_eq!(bits.get(20..22), Some("00")); + assert_eq!(bits.get(22..23), Some("1"), "isNotCompressed"); // Samples follow MSB-first, left then right. - assert_eq!(&bits[23..39], "0001001000110100", "left = 0x1234"); - assert_eq!(&bits[39..55], "0101011001111000", "right = 0x5678"); - assert_eq!(&bits[55..58], "111", "END element"); + assert_eq!(bits.get(23..39), Some("0001001000110100"), "left = 0x1234"); + assert_eq!(bits.get(39..55), Some("0101011001111000"), "right = 0x5678"); + assert_eq!(bits.get(55..58), Some("111"), "END element"); // A full packet: 23 + 352*32 + 3 bits, rounded up to whole bytes. let packet = vec![0u8; FRAMES_PER_PACKET * BYTES_PER_FRAME]; diff --git a/crates/vuio-core/src/database/playlist_formats.rs b/crates/vuio-core/src/database/playlist_formats.rs index 87a0d2e3..3ccea8c8 100644 --- a/crates/vuio-core/src/database/playlist_formats.rs +++ b/crates/vuio-core/src/database/playlist_formats.rs @@ -186,13 +186,10 @@ impl PlaylistFileManager { for line in content.lines() { let line = line.trim(); - if line.starts_with("File") { - if let Some(eq_pos) = line.find('=') { - let (key, value) = line.split_at(eq_pos); - let value = &value[1..]; // Skip the '=' - + if let Some(line) = line.strip_prefix("File") { + if let Some((key, value)) = line.split_once('=') { // Extract the number from "File1", "File2", etc. - if let Ok(track_num) = key[4..].parse::() { + if let Ok(track_num) = key.parse::() { tracks.push((track_num, resolve_playlist_entry(base_dir, value.trim()))); } } @@ -520,8 +517,8 @@ impl PlaylistFileManager { while i < lines.len() { let line = lines[i].trim(); if line.starts_with("#EXTINF") { - let name = if let Some(comma_pos) = line.find(',') { - line[comma_pos + 1..].trim().to_string() + let name = if let Some((_, name)) = line.split_once(',') { + name.trim().to_string() } else { "Unknown Radio".to_string() }; @@ -545,19 +542,19 @@ impl PlaylistFileManager { for line in file_content.lines() { let line = line.trim(); - if line.starts_with("File") { - if let Some(eq_pos) = line.find('=') { - if let Ok(num) = line[4..eq_pos].parse::() { - let val = line[eq_pos + 1..].trim().to_string(); + if let Some(line) = line.strip_prefix("File") { + if let Some((number, value)) = line.split_once('=') { + if let Ok(num) = number.parse::() { + let val = value.trim().to_string(); if is_http_stream(&val) { urls.insert(num, val); } } } - } else if line.starts_with("Title") { - if let Some(eq_pos) = line.find('=') { - if let Ok(num) = line[5..eq_pos].parse::() { - let val = line[eq_pos + 1..].trim().to_string(); + } else if let Some(line) = line.strip_prefix("Title") { + if let Some((number, value)) = line.split_once('=') { + if let Ok(num) = number.parse::() { + let val = value.trim().to_string(); titles.insert(num, val); } } diff --git a/crates/vuio-core/src/http_client.rs b/crates/vuio-core/src/http_client.rs index 3e0ca8e7..429a3d77 100644 --- a/crates/vuio-core/src/http_client.rs +++ b/crates/vuio-core/src/http_client.rs @@ -179,7 +179,7 @@ pub(crate) fn join_path(base: &Uri, reference: &str) -> Result { } else { let base_path = base.path(); let directory = match base_path.rfind('/') { - Some(index) => &base_path[..=index], + Some(index) => base_path.split_at(index + 1).0, None => "/", }; format!("{directory}{target_path}") diff --git a/crates/vuio-core/src/lib.rs b/crates/vuio-core/src/lib.rs index d89b53dd..df411e06 100644 --- a/crates/vuio-core/src/lib.rs +++ b/crates/vuio-core/src/lib.rs @@ -51,6 +51,7 @@ // every target platform rather than a macOS-only build. #![cfg_attr(not(feature = "unstable-internals"), allow(dead_code))] #![deny(clippy::undocumented_unsafe_blocks)] +#![deny(clippy::string_slice)] // Everything below is internal. `vuio-core` commits to the facade re-exported // after this block and nothing else: a surface small enough to keep stable for diff --git a/crates/vuio-core/src/mediainfo/artwork.rs b/crates/vuio-core/src/mediainfo/artwork.rs index 7c8aa2e0..14a23cca 100644 --- a/crates/vuio-core/src/mediainfo/artwork.rs +++ b/crates/vuio-core/src/mediainfo/artwork.rs @@ -48,7 +48,8 @@ impl ArtworkCache { /// Where a key's file lives, given the extension implied by its content type. fn path_for(&self, key: &str, extension: &str) -> PathBuf { - self.root.join(&key[..2]).join(format!("{key}.{extension}")) + let shard = key.get(..2).unwrap_or(key); + self.root.join(shard).join(format!("{key}.{extension}")) } /// Find a cached file for `key`, whatever image type it was stored as. @@ -141,7 +142,9 @@ mod tests { let path = cache.path_for(&key, "jpg"); assert_eq!( path, - Path::new("/tmp/artwork").join(&key[..2]).join(format!("{key}.jpg")) + Path::new("/tmp/artwork") + .join(key.get(..2).unwrap()) + .join(format!("{key}.jpg")) ); } diff --git a/crates/vuio-core/src/mediainfo/job.rs b/crates/vuio-core/src/mediainfo/job.rs index 6bd7b42d..191d8586 100644 --- a/crates/vuio-core/src/mediainfo/job.rs +++ b/crates/vuio-core/src/mediainfo/job.rs @@ -74,8 +74,7 @@ fn is_season_folder(folder_name: &str) -> bool { { return true; } - if trimmed.starts_with('s') && trimmed.len() <= 4 { - let rest = &trimmed[1..]; + if let Some(rest) = trimmed.strip_prefix('s').filter(|_| trimmed.len() <= 4) { if !rest.is_empty() && rest.bytes().all(|b| b.is_ascii_digit()) { return true; } diff --git a/crates/vuio-core/src/mediainfo/matching.rs b/crates/vuio-core/src/mediainfo/matching.rs index 3f5af935..d1ad2720 100644 --- a/crates/vuio-core/src/mediainfo/matching.rs +++ b/crates/vuio-core/src/mediainfo/matching.rs @@ -72,13 +72,8 @@ fn as_year(token: &str) -> Option { /// `S02E05`, `s2e5`, and the `2x05` form. fn as_season_episode(token: &str) -> Option<(u32, u32)> { let lowered = token.to_ascii_lowercase(); - let bytes = lowered.as_bytes(); - - if bytes.first() == Some(&b's') { - let rest = &lowered[1..]; - if let Some(split) = rest.find('e') { - let (season, episode) = rest.split_at(split); - let episode = &episode[1..]; + if let Some(rest) = lowered.strip_prefix('s') { + if let Some((season, episode)) = rest.split_once('e') { if !season.is_empty() && !episode.is_empty() && season.bytes().all(|b| b.is_ascii_digit()) @@ -90,9 +85,7 @@ fn as_season_episode(token: &str) -> Option<(u32, u32)> { return None; } - let split = lowered.find('x')?; - let (season, episode) = lowered.split_at(split); - let episode = &episode[1..]; + let (season, episode) = lowered.split_once('x')?; if season.is_empty() || episode.is_empty() || !season.bytes().all(|b| b.is_ascii_digit()) diff --git a/crates/vuio-core/src/platform/filesystem/metadata.rs b/crates/vuio-core/src/platform/filesystem/metadata.rs index f1f0fd95..390583f9 100644 --- a/crates/vuio-core/src/platform/filesystem/metadata.rs +++ b/crates/vuio-core/src/platform/filesystem/metadata.rs @@ -484,7 +484,7 @@ fn set_date(probed: &mut ProbedMetadata, value: &str, authoritative: bool) { fn standard_tag_name(tag: &StandardTag) -> String { let rendered = format!("{tag:?}"); match rendered.find('(') { - Some(index) => rendered[..index].to_owned(), + Some(index) => rendered.split_at(index).0.to_owned(), None => rendered, } } @@ -560,12 +560,13 @@ pub(crate) fn fallback_parse_filename(media_file: &mut MediaFile) { let mut artist_name = part0; let mut track_num = None; if let Some(first_space) = part0.find(' ') { - let maybe_num = &part0[..first_space].trim_end_matches('.'); + let (maybe_num, remainder) = part0.split_at(first_space); + let maybe_num = maybe_num.trim_end_matches('.'); let clean: String = maybe_num.chars().filter(|c| c.is_ascii_digit()).collect(); - if !clean.is_empty() && clean == *maybe_num { + if !clean.is_empty() && clean == maybe_num { if let Ok(num) = clean.parse::() { track_num = Some(num); - artist_name = &part0[first_space + 1..]; + artist_name = remainder.trim_start_matches(' '); } } } @@ -583,14 +584,15 @@ pub(crate) fn fallback_parse_filename(media_file: &mut MediaFile) { let mut title_part = filename_sans_ext.as_str(); if let Some(first_space) = filename_sans_ext.find(' ') { - let maybe_num = &filename_sans_ext[..first_space].trim_end_matches('.'); + let (maybe_num, remainder) = filename_sans_ext.split_at(first_space); + let maybe_num = maybe_num.trim_end_matches('.'); let clean: String = maybe_num.chars().filter(|c| c.is_ascii_digit()).collect(); - if !clean.is_empty() && clean == *maybe_num { + if !clean.is_empty() && clean == maybe_num { if let Ok(num) = clean.parse::() { if media_file.track_number.is_none() { media_file.track_number = Some(num); } - title_part = &filename_sans_ext[first_space + 1..]; + title_part = remainder.trim_start_matches(' '); } } } diff --git a/crates/vuio-core/src/platform/filesystem/normalization.rs b/crates/vuio-core/src/platform/filesystem/normalization.rs index 92d4f5c3..c25449fd 100644 --- a/crates/vuio-core/src/platform/filesystem/normalization.rs +++ b/crates/vuio-core/src/platform/filesystem/normalization.rs @@ -60,9 +60,9 @@ impl WindowsPathNormalizer { canonical = canonical.replace('\\', "/"); // Deduplicate slashes - if canonical.starts_with("//") { + if let Some(rest) = canonical.strip_prefix("//") { // UNC path: preserve leading double slash, clean the rest - let rest = canonical[2..].replace("//", "/"); + let rest = rest.replace("//", "/"); // Iterate until stable to handle multiple consecutive slashes let mut cleaned = rest; while cleaned.contains("//") { diff --git a/crates/vuio-core/src/web/radio.rs b/crates/vuio-core/src/web/radio.rs index 743e55ab..89a7f073 100644 --- a/crates/vuio-core/src/web/radio.rs +++ b/crates/vuio-core/src/web/radio.rs @@ -614,10 +614,9 @@ mod tests { let payload = &out[17..17 + units * 16]; let text = String::from_utf8_lossy(payload); assert!(text.starts_with("StreamTitle='Artist - Song';"), "{text}"); + let (_, padding) = text.split_once(';').expect("metadata terminator"); assert!( - text[text.find(';').unwrap() + 1..] - .bytes() - .all(|byte| byte == 0), + padding.bytes().all(|byte| byte == 0), "the block must be padded with zeroes" ); diff --git a/crates/vuio-core/src/web/soap/common.rs b/crates/vuio-core/src/web/soap/common.rs index 353f4508..393ad8b0 100644 --- a/crates/vuio-core/src/web/soap/common.rs +++ b/crates/vuio-core/src/web/soap/common.rs @@ -14,11 +14,10 @@ pub(super) fn parse_dir_index_prefix(path_prefix_str: &str) -> (Option, & if !num_str.is_empty() { if let Ok(idx) = num_str.parse::() { let prefix_len = 1 + num_str.len(); - let rem = if path_prefix_str.len() > prefix_len { - path_prefix_str[prefix_len..].trim_start_matches('/') - } else { - "" - }; + let rem = path_prefix_str + .get(prefix_len..) + .unwrap_or_default() + .trim_start_matches('/'); (Some(idx), rem) } else { (None, path_prefix_str) diff --git a/crates/vuio-core/src/web/subtitles.rs b/crates/vuio-core/src/web/subtitles.rs index 05a8c5a5..63a81b7c 100644 --- a/crates/vuio-core/src/web/subtitles.rs +++ b/crates/vuio-core/src/web/subtitles.rs @@ -94,7 +94,7 @@ fn normalize_timestamp(stamp: &str) -> String { parts[0], parts[1], parts[2], - &millis[..millis.len().min(3)] + millis.get(..millis.len().min(3)).unwrap_or(millis) ) } diff --git a/crates/vuio-core/src/web/ui.rs b/crates/vuio-core/src/web/ui.rs index 6165c181..cdbc058e 100644 --- a/crates/vuio-core/src/web/ui.rs +++ b/crates/vuio-core/src/web/ui.rs @@ -722,7 +722,12 @@ mod tests { let sources = dashboard_sources(); let mut checked = 0; for (_, tail) in sources.match_indices("/assets/").map(|(index, matched)| { - (index, &sources[index + matched.len()..]) + ( + index, + sources + .get(index + matched.len()..) + .expect("match ends on a character boundary"), + ) }) { let name: String = tail .chars() diff --git a/crates/vuio-core/tests/music_browse_integration_tests.rs b/crates/vuio-core/tests/music_browse_integration_tests.rs index 9e19be1b..bdd466f3 100644 --- a/crates/vuio-core/tests/music_browse_integration_tests.rs +++ b/crates/vuio-core/tests/music_browse_integration_tests.rs @@ -567,7 +567,9 @@ async fn child_counts_match_the_children_actually_returned() { let start = response .find(&anchor) .unwrap_or_else(|| panic!("{container_id} not in response")); - response[start..] + response + .get(start..) + .expect("find returns a character boundary") .split("childCount="") .nth(1) .and_then(|rest| rest.split(""").next()) diff --git a/crates/vuio-core/tests/regression_tests.rs b/crates/vuio-core/tests/regression_tests.rs index 53479612..3eaff3ba 100644 --- a/crates/vuio-core/tests/regression_tests.rs +++ b/crates/vuio-core/tests/regression_tests.rs @@ -237,7 +237,7 @@ mod critical_path_scenarios { "Testing long path ({} chars): {}", long_path.len(), if long_path.len() > 100 { - &long_path[..100] + long_path.get(..100).expect("test path is ASCII") } else { &long_path } diff --git a/crates/vuio-core/tests/web_ui_integration_tests.rs b/crates/vuio-core/tests/web_ui_integration_tests.rs index ad1b4e49..2c0ebd59 100644 --- a/crates/vuio-core/tests/web_ui_integration_tests.rs +++ b/crates/vuio-core/tests/web_ui_integration_tests.rs @@ -354,7 +354,7 @@ async fn the_two_surfaces_differ_only_at_the_root() { assert!( shell.contains("/_app/immutable/"), "the web UI surface should serve the Svelte shell, got: {}", - &shell[..shell.len().min(200)] + shell.chars().take(200).collect::() ); let (status, body, _) = get(&state, Surface::Primary, "/").await; @@ -452,7 +452,9 @@ async fn the_apps_bundles_are_served_with_immutable_caching() { let start = shell .find("/_app/immutable/") .expect("the shell loads a bundle"); - let bundle: String = shell[start..] + let bundle: String = shell + .get(start..) + .expect("find returns a character boundary") .chars() .take_while(|character| !"\"'".contains(*character)) .collect(); From d246f45f8955425dd129122a0e2210fc67c3f51b Mon Sep 17 00:00:00 2001 From: vyrti Date: Mon, 14 Sep 2026 21:44:40 +0300 Subject: [PATCH 8/8] fix(linux): avoid unchecked string slicing --- .../src/platform/network/linux/discovery.rs | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/crates/vuio-core/src/platform/network/linux/discovery.rs b/crates/vuio-core/src/platform/network/linux/discovery.rs index c7794fa0..7dab5c1e 100644 --- a/crates/vuio-core/src/platform/network/linux/discovery.rs +++ b/crates/vuio-core/src/platform/network/linux/discovery.rs @@ -68,10 +68,8 @@ impl LinuxNetworkManager { let line = line.trim(); // Interface line: "2: eth0: mtu 1500 qdisc pfifo_fast state UP group default qlen 1000" - if let Some(colon_pos) = line.find(':') { - if let Some(second_colon) = line[colon_pos + 1..].find(':') { - let second_colon_pos = colon_pos + 1 + second_colon; - + if let Some((_, interface)) = line.split_once(':') { + if let Some((interface_name, _)) = interface.split_once(':') { // Save previous interface with the best IP if let Some(name) = ¤t_interface { if !name.starts_with("lo") && !current_ips.is_empty() { @@ -96,15 +94,14 @@ impl LinuxNetworkManager { } // Parse new interface - let interface_name = line[colon_pos + 1..second_colon_pos].trim().to_string(); + let interface_name = interface_name.trim().to_string(); current_interface = Some(interface_name.clone()); current_ips.clear(); is_loopback = interface_name.starts_with("lo"); // Parse flags - if let Some(flags_start) = line.find('<') { - if let Some(flags_end) = line.find('>') { - let flags = &line[flags_start + 1..flags_end]; + if let Some((_, flags)) = line.split_once('<') { + if let Some((flags, _)) = flags.split_once('>') { is_up = flags.contains("UP"); supports_multicast = flags.contains("MULTICAST"); } @@ -114,8 +111,7 @@ impl LinuxNetworkManager { // IP address line: " inet 192.168.1.100/24 brd 192.168.1.255 scope global dynamic eth0" if line.contains("inet ") && !line.contains("inet6") { - if let Some(inet_pos) = line.find("inet ") { - let after_inet = &line[inet_pos + 5..]; + if let Some((_, after_inet)) = line.split_once("inet ") { if let Some(ip_part) = after_inet.split_whitespace().next() { // Remove CIDR notation if present let ip_str = ip_part.split('/').next().unwrap_or(ip_part); @@ -446,8 +442,7 @@ impl LinuxNetworkManager { let output_str = String::from_utf8_lossy(&output.stdout); for line in output_str.lines() { if line.contains("inet ") && !line.contains("inet6") { - if let Some(inet_pos) = line.find("inet ") { - let after_inet = &line[inet_pos + 5..]; + if let Some((_, after_inet)) = line.split_once("inet ") { if let Some(ip_part) = after_inet.split_whitespace().next() { let ip_str = ip_part.split('/').next().unwrap_or(ip_part); if let Ok(ip) = ip_str.parse::() {