From 2ce081946477df50dc73a8ada091c609758513e1 Mon Sep 17 00:00:00 2001
From: InstaZDLL
Date: Wed, 21 Oct 2026 04:56:48 +0200
Subject: [PATCH 1/4] build(deps): bump waveflow-core to 102df003
The pin moves from WaveFlow #883 (9b50f3de, 2026-10-17) to the main of
2026-10-21 (102df003, #926), 167 commits later. What the server takes
from the core is unchanged by them apart from `codec_label` moving under
`audio_format`, which nothing here names.
The one addition worth having is `text_encoding` (WaveFlow #910): a
decoder for the text files users bring, which the next two commits put
behind sidecar lyrics and playlist import.
`default-features = false` stays, and the new `playback` feature stays
off: the server plays nothing, and it would pull in `rubato`. lofty
(0.25.4) and symphonia (0.6.1) are unchanged, and `cargo tree -i` shows
one copy of each. The only crates the bump adds are `chardetng`,
`encoding_rs` and their small helpers.
Signed-off-by: InstaZDLL
---
Cargo.lock | 50 ++++++++++++++++++++++++++++++++++++++++++++++++--
Cargo.toml | 2 +-
2 files changed, 49 insertions(+), 3 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index 9b2496e2..958f6931 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -432,6 +432,17 @@ dependencies = [
"poly1305",
]
+[[package]]
+name = "chardetng"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13de944a44b5064ee5d3a5ceccc49a41bfec50f2580e66f82e87703acdb88b53"
+dependencies = [
+ "cfg-if",
+ "encoding_rs",
+ "memchr",
+]
+
[[package]]
name = "chrono"
version = "0.4.45"
@@ -551,6 +562,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"
@@ -749,6 +766,20 @@ dependencies = [
"serde",
]
+[[package]]
+name = "encoding_rs"
+version = "0.8.42"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679"
+dependencies = [
+ "cfg-if",
+ "core_detect",
+ "multiversion_no_op",
+ "rustversion",
+ "scopeguard",
+ "simdutf8",
+]
+
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -1645,6 +1676,12 @@ dependencies = [
"pxfm",
]
+[[package]]
+name = "multiversion_no_op"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d"
+
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
@@ -2444,6 +2481,12 @@ version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
+[[package]]
+name = "simdutf8"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
+
[[package]]
name = "simplecss"
version = "0.2.2"
@@ -2932,7 +2975,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
- "getrandom 0.4.3",
+ "getrandom 0.3.4",
"once_cell",
"rustix",
"windows-sys 0.61.2",
@@ -3558,12 +3601,14 @@ dependencies = [
[[package]]
name = "waveflow-core"
version = "1.9.1"
-source = "git+https://github.com/InstaZDLL/WaveFlow?rev=9b50f3de288d62b0ff9f867ab7bda504b255cc5c#9b50f3de288d62b0ff9f867ab7bda504b255cc5c"
+source = "git+https://github.com/InstaZDLL/WaveFlow?rev=102df0031bef8ba44062e03337807592b3d51102#102df0031bef8ba44062e03337807592b3d51102"
dependencies = [
"anyhow",
"async-trait",
"blake3",
+ "chardetng",
"chrono",
+ "encoding_rs",
"fast_image_resize",
"globset",
"hex",
@@ -3585,6 +3630,7 @@ dependencies = [
"tracing",
"usvg",
"uuid",
+ "windows-sys 0.61.2",
]
[[package]]
diff --git a/Cargo.toml b/Cargo.toml
index 845e927c..a10fe185 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -75,7 +75,7 @@ utoipa-scalar = { version = "0.4", features = ["axum"] }
url = "2"
uuid = { version = "1", features = ["v4", "v8", "serde"] }
walkdir = "2"
-waveflow-core = { git = "https://github.com/InstaZDLL/WaveFlow", rev = "9b50f3de288d62b0ff9f867ab7bda504b255cc5c", default-features = false }
+waveflow-core = { git = "https://github.com/InstaZDLL/WaveFlow", rev = "102df0031bef8ba44062e03337807592b3d51102", default-features = false }
[dev-dependencies]
http-body-util = "0.1"
From 29b47e9fe2a2d14eb3901c77ce3c8f22f4094762 Mon Sep 17 00:00:00 2001
From: InstaZDLL
Date: Wed, 21 Oct 2026 05:00:37 +0200
Subject: [PATCH 2/4] fix(scanner): read sidecar lyrics that are not utf-8
`read_sidecar` ended in `String::from_utf8(bytes).ok()`, so an `.lrc`
or `.txt` in a system code page (GB18030 on a Chinese Windows, Big5,
Shift_JIS, EUC-KR, a Western Windows code page) was dropped whole and
the track reached every client without lyrics. A UTF-8 file with a
byte-order mark was read, but kept the mark in front of its first
timestamp.
It now decodes with `waveflow_core::text_encoding::decode`, the decoder
the desktop reads the same files with since WaveFlow #910: a byte-order
mark names the encoding and is dropped, valid UTF-8 is returned as it
is, and anything else has its encoding guessed by chardetng. The size
cap and the symlink refusal are untouched.
Tests: a Shift_JIS sidecar and a marked UTF-8 one through
`read_sidecar`, and a GB18030 `.lrc` through a scan to the lyrics
service. Both fail on the old decoding, the first on the missing
lyrics and the second on an empty `structured_lyrics`. The `[0xff]`
case is gone from the rejection test: such a file is now read, which
is the change.
Signed-off-by: InstaZDLL
---
Cargo.lock | 1 +
Cargo.toml | 1 +
docs/api-v2-guide.md | 4 ++-
docs/subsonic-api-guide.md | 6 ++--
src/scanner.rs | 36 ++++++++++++++++---
tests/scanner.rs | 71 ++++++++++++++++++++++++++++++++++++++
6 files changed, 112 insertions(+), 7 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index 958f6931..47eef8c6 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3649,6 +3649,7 @@ dependencies = [
"clap",
"dashmap",
"dotenvy",
+ "encoding_rs",
"futures-util",
"hex",
"http-body-util",
diff --git a/Cargo.toml b/Cargo.toml
index a10fe185..c5f03eb6 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -78,6 +78,7 @@ walkdir = "2"
waveflow-core = { git = "https://github.com/InstaZDLL/WaveFlow", rev = "102df0031bef8ba44062e03337807592b3d51102", default-features = false }
[dev-dependencies]
+encoding_rs = "0.8.42"
http-body-util = "0.1"
tempfile = "3"
tokio-tungstenite = "0.30"
diff --git a/docs/api-v2-guide.md b/docs/api-v2-guide.md
index 17d0333f..e0b5e6a4 100644
--- a/docs/api-v2-guide.md
+++ b/docs/api-v2-guide.md
@@ -424,7 +424,9 @@ its hash — a shared cache keyed on the URL would hand one tenant's artwork to
another with no credential presented. A private cache is the client's own and
loses nothing by the restriction.
-Lyrics return embedded or UTF-8 `.lrc`/`.txt` sidecar content. Synchronized
+Lyrics return embedded or `.lrc`/`.txt` sidecar content. A sidecar need not be
+UTF-8: a byte-order mark names its encoding, and otherwise one such as GB18030,
+Big5, Shift_JIS or a Windows code page is recognised from the bytes. Synchronized
line starts are milliseconds; plain lines omit `start`:
```bash
diff --git a/docs/subsonic-api-guide.md b/docs/subsonic-api-guide.md
index fd839843..75896d9b 100644
--- a/docs/subsonic-api-guide.md
+++ b/docs/subsonic-api-guide.md
@@ -193,8 +193,10 @@ curl -X POST https://music.example.com/rest/getLyricsBySongId.view \
--data-urlencode "id=TRACK_UUID"
```
-WaveFlow returns embedded text and UTF-8 `.lrc`/`.txt` sidecars. LRC timestamps
-are milliseconds. The legacy `getLyrics` exact artist/title lookup is also
+WaveFlow returns embedded text and `.lrc`/`.txt` sidecars. A sidecar need not
+be UTF-8: a byte-order mark names its encoding, and otherwise one such as
+GB18030, Big5, Shift_JIS or a Windows code page is recognised from the bytes.
+LRC timestamps are milliseconds. The legacy `getLyrics` exact artist/title lookup is also
implemented.
Stream an original with a byte range:
diff --git a/src/scanner.rs b/src/scanner.rs
index 32921f7b..ac276593 100644
--- a/src/scanner.rs
+++ b/src/scanner.rs
@@ -970,7 +970,11 @@ fn read_sidecar(path: &Path) -> Option {
if bytes.len() as u64 > MAX_LYRICS_BYTES {
return None;
}
- String::from_utf8(bytes).ok()
+ // Not `String::from_utf8`: lyrics written by a tool on a Chinese, Japanese
+ // or Korean Windows are in that system's code page, and a strict UTF-8
+ // read dropped them whole. The core decoder is the one the desktop reads
+ // the same files with, so both answer the same text for them.
+ Some(waveflow_core::text_encoding::decode(&bytes).into_owned())
}
fn lyrics_hash(entries: &[LyricsInput]) -> String {
@@ -1082,9 +1086,6 @@ mod tests {
std::fs::write(&oversized, vec![b'x'; MAX_LYRICS_BYTES as usize + 1]).unwrap();
assert!(read_sidecar(&oversized).is_none());
- let invalid_utf8 = temp.path().join("invalid.lrc");
- std::fs::write(&invalid_utf8, [0xff]).unwrap();
- assert!(read_sidecar(&invalid_utf8).is_none());
assert!(read_sidecar(temp.path()).is_none());
let link = temp.path().join("linked.lrc");
@@ -1096,4 +1097,31 @@ mod tests {
assert!(read_sidecar(&link).is_none());
}
}
+
+ /// Lyrics saved by a tool on a Japanese Windows, in that system's code
+ /// page. A strict UTF-8 read dropped the file whole, so the track had no
+ /// lyrics at all.
+ #[test]
+ fn sidecar_lyrics_that_are_not_utf8_are_read_in_their_own_encoding() {
+ let temp = tempfile::tempdir().unwrap();
+ let text = "[00:01.00]今日はいい天気ですね、一緒に公園を散歩しましょう\n\
+ [00:05.50]湖に光が映って、風が森を通り抜ける音がする";
+ let (bytes, _, unmappable) = encoding_rs::SHIFT_JIS.encode(text);
+ assert!(!unmappable, "Shift_JIS holds the sample");
+ assert!(
+ std::str::from_utf8(&bytes).is_err(),
+ "the sample is not UTF-8 by accident"
+ );
+ let japanese = temp.path().join("japanese.lrc");
+ std::fs::write(&japanese, &bytes).unwrap();
+ assert_eq!(read_sidecar(&japanese).as_deref(), Some(text));
+
+ // A byte-order mark names the encoding and is not part of the text.
+ // Kept, it sat in front of the first timestamp.
+ let marked = temp.path().join("marked.lrc");
+ let mut bytes = vec![0xEF, 0xBB, 0xBF];
+ bytes.extend_from_slice(b"[00:01]marked");
+ std::fs::write(&marked, bytes).unwrap();
+ assert_eq!(read_sidecar(&marked).as_deref(), Some("[00:01]marked"));
+ }
}
diff --git a/tests/scanner.rs b/tests/scanner.rs
index ef814efa..9e7d153b 100644
--- a/tests/scanner.rs
+++ b/tests/scanner.rs
@@ -109,6 +109,77 @@ async fn scanner_indexes_moves_and_marks_tracks_unavailable() {
assert!(!tracks[0].available);
}
+/// Sidecar lyrics in GB18030, the way a tool on a Chinese Windows saves them
+/// (WaveFlow#910). The scanner read sidecars as strict UTF-8 and dropped this
+/// one whole, so the track reached every client with no lyrics.
+#[tokio::test]
+async fn sidecar_lyrics_that_are_not_utf8_reach_the_lyrics_service() {
+ let (_temp, config, state) = test_app().await;
+ // Inserted rather than created: a scan needs an owner, not a login, and
+ // hashing a literal password is what `rust/hard-coded-cryptographic-value`
+ // reports.
+ let owner = uuid::Uuid::new_v4();
+ sqlx::query(
+ "INSERT INTO account (id, username, password_hash, role, disabled, created_at, updated_at) \
+ VALUES (?, ?, 'this-account-never-authenticates', 'admin', 0, ?, ?)",
+ )
+ .bind(owner.to_string())
+ .bind("scanner-encodings")
+ .bind(now_ms())
+ .bind(now_ms())
+ .execute(state.db.pool())
+ .await
+ .unwrap();
+ let music = config.data_dir.join("encoded-lyrics");
+ std::fs::create_dir_all(&music).unwrap();
+ write_test_wav(&music.join("Song.wav"));
+ let (bytes, _, unmappable) = encoding_rs::GB18030
+ .encode("[00:01.00]今天的天气很好,我们一起去公园散步吧\n[00:05.50]阳光照在湖面上");
+ assert!(!unmappable);
+ assert!(
+ std::str::from_utf8(&bytes).is_err(),
+ "the sample is not UTF-8 by accident"
+ );
+ std::fs::write(music.join("Song.lrc"), &bytes).unwrap();
+ let root = std::fs::canonicalize(&music).unwrap();
+ let library_id = state
+ .db
+ .create_library(
+ owner,
+ "Encoded lyrics",
+ &root,
+ LibraryVisibility::Private,
+ now_ms(),
+ )
+ .await
+ .unwrap();
+ let library = LibraryRecord {
+ id: library_id,
+ name: "Encoded lyrics".into(),
+ root_path: root,
+ };
+
+ run_scan(&state, owner, library).await;
+ let tracks = state
+ .db
+ .list_tracks_for_user(owner, library_id)
+ .await
+ .unwrap();
+ assert_eq!(tracks.len(), 1);
+ let lyrics = state.services.lyrics(owner, tracks[0].id).await.unwrap();
+ assert_eq!(
+ lyrics.structured_lyrics.len(),
+ 1,
+ "the sidecar was read rather than dropped"
+ );
+ let lines = &lyrics.structured_lyrics[0].lines;
+ assert!(lyrics.structured_lyrics[0].synced);
+ assert_eq!(lines.len(), 2);
+ assert_eq!(lines[0].start, Some(1_000));
+ assert_eq!(lines[0].value, "今天的天气很好,我们一起去公园散步吧");
+ assert_eq!(lines[1].value, "阳光照在湖面上");
+}
+
#[tokio::test]
async fn scanner_batches_more_than_one_write_group_without_deduplicating_copies() {
let (_temp, config, state) = test_app().await;
From c27e66aec1d9da335f1064e2d6b505748eb46234 Mon Sep 17 00:00:00 2001
From: InstaZDLL
Date: Wed, 21 Oct 2026 05:00:59 +0200
Subject: [PATCH 3/4] fix(playlists): read imported playlists with the core
decoder
`playlist_file::decode` read UTF-8 when the bytes were UTF-8 and
Windows-1252 otherwise. A playlist written on a Chinese, Japanese or
Korean Windows came back as Latin nonsense that matched no path, and a
UTF-16 one, as Notepad saves "Unicode", came back with a NUL between
every character. Both were reported as read, and both resolved nothing.
The text is now `waveflow_core::text_encoding::decode`, the decoder the
desktop uses: a byte-order mark names the encoding, valid UTF-8 is
UTF-8, and anything else has its encoding guessed by chardetng.
The report still says which encoding was used, which the core does not
answer, so `encoding_read_as` works it out by the same steps with the
detector set up the same way. A test decodes samples in six encodings
with the encoding it names and compares against the core, so a core
that changes its guess fails there instead of having a report name one
encoding while the text was read as another.
The wire contract is extended on purpose. `encoding` was `utf-8` or
`windows-1252`. It is now the encoding's WHATWG name in lower case, an
open set (`utf-16le`, `shift_jis`, `gbk`, `big5`, `euc-kr`, ...), and
the two old values are spelled as they were. The schema says `string`.
The web client already showed any value other than `utf-8`, so only
its type changes.
Tests: a UTF-16 LE file with its mark and a Shift_JIS file through
`parse`, and a UTF-16 LE import through `/api/v2/playlists/import`.
All three fail on the old decoding.
Signed-off-by: InstaZDLL
---
Cargo.lock | 1 +
Cargo.toml | 7 +-
docs/api-v2-guide.md | 8 +-
src/playlist_file.rs | 197 ++++++++++++++++------
src/services/mod.rs | 7 +-
tests/native_api.rs | 23 +++
webapp/src/api/playlists.ts | 8 +-
webapp/src/components/playlist-import.tsx | 8 +-
8 files changed, 200 insertions(+), 59 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index 47eef8c6..90f74f6e 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3645,6 +3645,7 @@ dependencies = [
"blake3",
"bytes",
"chacha20poly1305",
+ "chardetng",
"chrono",
"clap",
"dashmap",
diff --git a/Cargo.toml b/Cargo.toml
index c5f03eb6..5c2dda97 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -26,10 +26,16 @@ base64 = "0.23"
blake3 = "1"
bytes = "1"
chacha20poly1305 = "0.11"
+# Both arrive with `waveflow-core`, whose `text_encoding` decodes the text
+# files users bring. Named here so an imported playlist can say which
+# encoding it was read as (`playlist_file`), which the core does not answer.
+# Same versions as the core's, so nothing new is compiled.
+chardetng = "1"
chrono = "0.4"
clap = { version = "4", features = ["derive"] }
dashmap = "6"
dotenvy = "0.15"
+encoding_rs = "0.8.42"
futures-util = "0.3"
hex = "0.4"
lofty = "0.25"
@@ -78,7 +84,6 @@ walkdir = "2"
waveflow-core = { git = "https://github.com/InstaZDLL/WaveFlow", rev = "102df0031bef8ba44062e03337807592b3d51102", default-features = false }
[dev-dependencies]
-encoding_rs = "0.8.42"
http-body-util = "0.1"
tempfile = "3"
tokio-tungstenite = "0.30"
diff --git a/docs/api-v2-guide.md b/docs/api-v2-guide.md
index e0b5e6a4..7ef65f9a 100644
--- a/docs/api-v2-guide.md
+++ b/docs/api-v2-guide.md
@@ -740,8 +740,12 @@ name `comment` in `clear`. Shares use the same rule for `description` and
JSON. The encoding of a playlist file is part of the problem (`.m3u` is
frequently not UTF-8), so the bytes have to arrive as bytes; a JSON envelope
would have forced the caller to decode them first, which is the one decision it
-cannot make correctly. The server reads UTF-8 when the bytes are UTF-8 and
-Windows-1252 otherwise, and **says which it used** in the response.
+cannot make correctly. A byte-order mark names the encoding (UTF-8, UTF-16 LE or
+BE); otherwise the server reads UTF-8 when the bytes are UTF-8 and recognises
+the encoding from the bytes when they are not — GB18030, Big5, Shift_JIS,
+EUC-KR, Windows-1252 and the like — and **says which it used** in the response,
+as the encoding's WHATWG name in lower case (`utf-8`, `utf-16le`, `shift_jis`,
+`windows-1252`…).
```bash
# What it would do, without doing it
diff --git a/src/playlist_file.rs b/src/playlist_file.rs
index 90e69e23..be21fa67 100644
--- a/src/playlist_file.rs
+++ b/src/playlist_file.rs
@@ -11,24 +11,36 @@
//! `#EXTINF` metadata is kept because it is the only thing left to match on
//! when a path resolves to nothing.
-use serde::Serialize;
-use utoipa::ToSchema;
+use chardetng::{EncodingDetector, Iso2022JpDetection, Utf8Detection};
+use encoding_rs::Encoding;
+use serde::{Serialize, Serializer};
/// How the bytes were read as text.
///
/// Reported rather than assumed, because the guess can be wrong and a wrong
-/// guess mangles every accented title. A caller that sees `Windows1252` on a
-/// file it believed was UTF-8 has been told something.
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, ToSchema)]
-#[serde(rename_all = "kebab-case")]
-pub enum TextEncoding {
- // Named rather than derived, both of them: kebab-case puts no hyphen before
- // a digit, so the derive spelled these "utf8" and "windows1252" and no
- // encoding is called either.
- #[serde(rename = "utf-8")]
- Utf8,
- #[serde(rename = "windows-1252")]
- Windows1252,
+/// guess mangles every title. A caller that sees `shift_jis` on a file it
+/// believed was UTF-8 has been told something.
+///
+/// On the wire, the encoding's WHATWG name in lower case: `utf-8`,
+/// `utf-16le`, `windows-1252`, `shift_jis`, `gbk`, `big5`, `euc-kr` and so on.
+/// The two this answered before it read anything but UTF-8 and Windows-1252
+/// are spelled as they were.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct TextEncoding(&'static Encoding);
+
+impl TextEncoding {
+ pub const UTF_8: Self = Self(encoding_rs::UTF_8);
+
+ /// The name a report gives, `utf-8` rather than encoding_rs's `UTF-8`.
+ pub fn name(self) -> String {
+ self.0.name().to_ascii_lowercase()
+ }
+}
+
+impl Serialize for TextEncoding {
+ fn serialize(&self, serializer: S) -> Result {
+ serializer.serialize_str(&self.name())
+ }
}
/// One line of the file that names something to play.
@@ -122,44 +134,39 @@ pub fn parse(bytes: &[u8]) -> PlaylistFile {
}
}
-/// UTF-8 if it is UTF-8, Windows-1252 otherwise.
+/// The text, read by `waveflow_core::text_encoding`, and the encoding it was
+/// read as.
///
/// `.m3u8` is UTF-8 by definition and `.m3u` frequently is not, but the
/// extension is a claim and the bytes are evidence, so the bytes decide. The
-/// order is what makes the fallback safe: valid UTF-8 is never reinterpreted,
-/// and accented text in Windows-1252 is not valid UTF-8, so it cannot be
-/// mistaken the other way round either.
-///
-/// Windows-1252 rather than Latin-1 because the files that are not UTF-8 were
-/// written by Windows players: the two agree from `0xA0` up, and below it
-/// Latin-1 has control codes where Windows-1252 has the typographic
-/// punctuation that actually appears in track titles.
+/// core decoder is the one the desktop reads the same files with: a
+/// byte-order mark names the encoding, valid UTF-8 is UTF-8, and anything
+/// else has its encoding guessed. A fixed fallback, Windows-1252 as this was
+/// until the core had a decoder, read a CJK playlist into Latin nonsense and
+/// a UTF-16 one into NULs.
fn decode(bytes: &[u8]) -> (String, TextEncoding) {
- let bytes = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]).unwrap_or(bytes);
- match std::str::from_utf8(bytes) {
- Ok(text) => (text.to_owned(), TextEncoding::Utf8),
- Err(_) => (
- bytes.iter().map(|byte| windows_1252_char(*byte)).collect(),
- TextEncoding::Windows1252,
- ),
- }
+ let text = waveflow_core::text_encoding::decode(bytes).into_owned();
+ (text, TextEncoding(encoding_read_as(bytes)))
}
-/// The thirty-two code points Windows-1252 puts where Latin-1 keeps C1
-/// control codes. Everything else in the range is Latin-1, which is the
-/// identity mapping onto Unicode.
-fn windows_1252_char(byte: u8) -> char {
- const HIGH: [char; 32] = [
- '\u{20AC}', '\u{0081}', '\u{201A}', '\u{0192}', '\u{201E}', '\u{2026}', '\u{2020}',
- '\u{2021}', '\u{02C6}', '\u{2030}', '\u{0160}', '\u{2039}', '\u{0152}', '\u{008D}',
- '\u{017D}', '\u{008F}', '\u{0090}', '\u{2018}', '\u{2019}', '\u{201C}', '\u{201D}',
- '\u{2022}', '\u{2013}', '\u{2014}', '\u{02DC}', '\u{2122}', '\u{0161}', '\u{203A}',
- '\u{0153}', '\u{009D}', '\u{017E}', '\u{0178}',
- ];
- match byte {
- 0x80..=0x9F => HIGH[(byte - 0x80) as usize],
- other => char::from(other),
+/// The encoding `waveflow_core::text_encoding::decode` reads `bytes` as.
+///
+/// The core answers the text and not its encoding, so the encoding is worked
+/// out again here by the same steps in the same order, with the detector set
+/// up the same way. Duplicated rather than trusted: a core that changed its
+/// guess would have a report name one encoding while the text was read as
+/// another, which is what `the_encoding_named_is_the_one_the_core_read`
+/// catches on the next bump.
+fn encoding_read_as(bytes: &[u8]) -> &'static Encoding {
+ if let Some((encoding, _)) = Encoding::for_bom(bytes) {
+ return encoding;
}
+ if std::str::from_utf8(bytes).is_ok() {
+ return encoding_rs::UTF_8;
+ }
+ let mut detector = EncodingDetector::new(Iso2022JpDetection::Deny);
+ detector.feed(bytes, true);
+ detector.guess(None, Utf8Detection::Deny)
}
/// `#EXTINF:duration,display text`.
@@ -246,7 +253,7 @@ mod tests {
#[test]
fn a_plain_file_is_one_target_per_line() {
let file = parse(b"Artist/Album/01.flac\nArtist/Album/02.flac\n");
- assert_eq!(file.encoding, TextEncoding::Utf8);
+ assert_eq!(file.encoding, TextEncoding::UTF_8);
assert_eq!(file.name, None);
assert_eq!(
targets(&file),
@@ -291,7 +298,7 @@ mod tests {
let mut bytes = vec![0xEF, 0xBB, 0xBF];
bytes.extend_from_slice(b"#EXTM3U\r\nArtist/Album/01.flac\r\n");
let file = parse(&bytes);
- assert_eq!(file.encoding, TextEncoding::Utf8);
+ assert_eq!(file.encoding, TextEncoding::UTF_8);
assert_eq!(targets(&file), ["Artist/Album/01.flac"]);
}
@@ -303,7 +310,7 @@ mod tests {
bytes.push(0xF6);
bytes.extend_from_slice(b"rk/Vespertine/01.flac\n");
let file = parse(&bytes);
- assert_eq!(file.encoding, TextEncoding::Windows1252);
+ assert_eq!(file.encoding.name(), "windows-1252");
assert_eq!(targets(&file), ["Björk/Vespertine/01.flac"]);
// And the thirty-two code points that tell Windows-1252 from Latin-1:
@@ -318,6 +325,100 @@ mod tests {
);
}
+ /// What Notepad saves as "Unicode", and what some players export: UTF-16
+ /// little-endian behind its byte-order mark. Read as Windows-1252, every
+ /// other character was a NUL and no path matched anything.
+ #[test]
+ fn a_utf16_file_is_read_by_its_byte_order_mark() {
+ let text = "#EXTM3U\r\n#EXTINF:245,Björk - Hidden Place\r\nBjörk/Vespertine/01.flac\r\n";
+ let mut bytes = vec![0xFF, 0xFE];
+ bytes.extend(text.encode_utf16().flat_map(u16::to_le_bytes));
+ let file = parse(&bytes);
+ assert_eq!(serde_json::to_value(file.encoding).unwrap(), "utf-16le");
+ assert_eq!(targets(&file), ["Björk/Vespertine/01.flac"]);
+ assert_eq!(
+ file.entries[0].artist_and_title(),
+ Some(("Björk", "Hidden Place"))
+ );
+ }
+
+ /// A playlist written on a Japanese Windows, in Shift_JIS. The Windows-1252
+ /// fallback read it without failing, which was worse: every name came back
+ /// as Latin nonsense that matched no path and no title.
+ #[test]
+ fn a_file_in_an_east_asian_code_page_is_read_in_it() {
+ let text = "#EXTM3U\r\n#PLAYLIST:夏の夜に聴きたい歌\r\n\
+ #EXTINF:245,宇多田ヒカル - 花束を君に\r\n\
+ 宇多田ヒカル/ファントーム/01 花束を君に.flac\r\n\
+ #EXTINF:301,椎名林檎 - 丸の内サディスティック\r\n\
+ 椎名林檎/無罪モラトリアム/03 丸の内サディスティック.flac\r\n";
+ let (bytes, _, unmappable) = encoding_rs::SHIFT_JIS.encode(text);
+ assert!(!unmappable, "Shift_JIS holds the sample");
+ assert!(std::str::from_utf8(&bytes).is_err());
+ let file = parse(&bytes);
+ assert_eq!(serde_json::to_value(file.encoding).unwrap(), "shift_jis");
+ assert_eq!(file.name.as_deref(), Some("夏の夜に聴きたい歌"));
+ assert_eq!(
+ targets(&file),
+ [
+ "宇多田ヒカル/ファントーム/01 花束を君に.flac",
+ "椎名林檎/無罪モラトリアム/03 丸の内サディスティック.flac",
+ ]
+ );
+ assert_eq!(
+ file.entries[1].artist_and_title(),
+ Some(("椎名林檎", "丸の内サディスティック"))
+ );
+ }
+
+ /// The report names the encoding the core read, not a second guess that
+ /// happens to agree today: decoding each sample with the named encoding
+ /// gives back the core's text exactly.
+ #[test]
+ fn the_encoding_named_is_the_one_the_core_read() {
+ let samples: [(&str, &'static Encoding); 6] = [
+ (
+ "#EXTINF:1,Björk - Cocoon\nBjörk/Vespertine/02.flac\n",
+ encoding_rs::WINDOWS_1252,
+ ),
+ (
+ "#EXTINF:1,宇多田ヒカル - 花束を君に\n宇多田ヒカル/01 花束を君に.flac\n",
+ encoding_rs::SHIFT_JIS,
+ ),
+ (
+ "#EXTINF:1,王菲 - 红豆\n王菲/唱游/03 红豆.flac\n",
+ encoding_rs::GB18030,
+ ),
+ (
+ "#EXTINF:1,周杰倫 - 晴天\n周杰倫/葉惠美/01 晴天.flac\n",
+ encoding_rs::BIG5,
+ ),
+ (
+ "#EXTINF:1,아이유 - 밤편지\n아이유/팔레트/01 밤편지.flac\n",
+ encoding_rs::EUC_KR,
+ ),
+ (
+ "#EXTINF:1,Björk - Cocoon\nBjörk/Vespertine/02.flac\n",
+ encoding_rs::UTF_8,
+ ),
+ ];
+ for (text, encoding) in samples {
+ let (bytes, _, unmappable) = encoding.encode(text);
+ assert!(!unmappable, "{} holds the sample", encoding.name());
+ let named = encoding_read_as(&bytes);
+ assert_eq!(
+ named.decode_without_bom_handling(&bytes).0,
+ waveflow_core::text_encoding::decode(&bytes),
+ "{} was named {}",
+ encoding.name(),
+ named.name()
+ );
+ }
+ let mut marked = vec![0xFE, 0xFF];
+ marked.extend("Björk/01.flac".encode_utf16().flat_map(u16::to_be_bytes));
+ assert_eq!(encoding_read_as(&marked), encoding_rs::UTF_16BE);
+ }
+
#[test]
fn every_shape_of_target_reduces_to_the_same_segments() {
let expected = ["Artist", "Album", "01.flac"];
diff --git a/src/services/mod.rs b/src/services/mod.rs
index 2d44f8bf..e2cc2e79 100644
--- a/src/services/mod.rs
+++ b/src/services/mod.rs
@@ -1067,8 +1067,11 @@ pub struct PlaylistImport {
pub playlist: Option,
/// The name used, from the request or from the file's own `#PLAYLIST`.
pub name: String,
- /// How the bytes were read as text. A file that announced itself as UTF-8
- /// and came back `windows-1252` has been read, not mangled in silence.
+ /// How the bytes were read as text, by the encoding's WHATWG name in lower
+ /// case: `utf-8`, `utf-16le`, `windows-1252`, `shift_jis`, `gbk`… A file
+ /// that announced itself as UTF-8 and came back anything else has been
+ /// read, not mangled in silence.
+ #[schema(value_type = String, example = "utf-8")]
pub encoding: TextEncoding,
/// Targets the file named, resolved or not.
pub entries: usize,
diff --git a/tests/native_api.rs b/tests/native_api.rs
index 5de0f74d..73032790 100644
--- a/tests/native_api.rs
+++ b/tests/native_api.rs
@@ -3629,6 +3629,29 @@ async fn an_m3u_import_places_what_it_can_and_reports_the_rest() {
"and the name did not become an entry of its own"
);
+ // **A UTF-16 file is read by its byte-order mark.** Notepad's "Unicode"
+ // and some players' exports write it. Read as Windows-1252, every other
+ // character came back a NUL and nothing in the file resolved.
+ let mut utf16 = vec![0xFF, 0xFE];
+ utf16.extend(
+ "#EXTM3U\r\n#EXTINF:300,Björk - Hidden Place\r\nBjörk/Vespertine/01 Hidden Place.flac\r\n"
+ .encode_utf16()
+ .flat_map(u16::to_le_bytes),
+ );
+ let (status, wide) = import(
+ "/api/v2/playlists/import?name=Wide&dry_run=true".into(),
+ utf16,
+ token.clone(),
+ )
+ .await;
+ assert_eq!(status, StatusCode::OK, "{wide}");
+ assert_eq!(wide["entries"], 1);
+ assert_eq!(wide["resolved"], 1, "the path it names is found: {wide}");
+ assert_eq!(
+ wide["encoding"], "utf-16le",
+ "and the mark named the encoding"
+ );
+
// A file nothing names is refused rather than stored namelessly.
let (status, _body) = import(
"/api/v2/playlists/import".into(),
diff --git a/webapp/src/api/playlists.ts b/webapp/src/api/playlists.ts
index 42b9888e..dbb80cb2 100644
--- a/webapp/src/api/playlists.ts
+++ b/webapp/src/api/playlists.ts
@@ -23,8 +23,12 @@ export type PlaylistImport = {
/** The playlist as created; `null` for a dry run. */
playlist: Playlist | null;
name: string;
- /** How the server read the bytes: `.m3u` is frequently not UTF-8. */
- encoding: "utf-8" | "windows-1252";
+ /**
+ * How the server read the bytes: `.m3u` is frequently not UTF-8. The
+ * encoding's WHATWG name in lower case — `utf-8`, `utf-16le`,
+ * `windows-1252`, `shift_jis`, `gbk`… — so an open set, not a union.
+ */
+ encoding: string;
/** Targets the file named, placed or not. */
entries: number;
resolved: number;
diff --git a/webapp/src/components/playlist-import.tsx b/webapp/src/components/playlist-import.tsx
index 029217dc..9113457c 100644
--- a/webapp/src/components/playlist-import.tsx
+++ b/webapp/src/components/playlist-import.tsx
@@ -165,10 +165,10 @@ export function PlaylistImportPanel({
entries: preview.report.entries,
})}
- {/* Said only when it is worth saying. A file read as Windows-1252
- was not UTF-8, and a wrong guess there mangles accented titles —
- so the reader is told which guess was made rather than left to
- wonder why a name looks odd. */}
+ {/* Said only when it is worth saying. A file read as anything but
+ UTF-8 had its encoding named by a byte-order mark or guessed, and
+ a wrong guess mangles every title — so the reader is told which
+ one was used rather than left to wonder why a name looks odd. */}
{preview.report.encoding !== "utf-8" ? (
{t("import.encoding", { encoding: preview.report.encoding })}
From 2c76a66ec43453d6f0446918049caf5805e9b963 Mon Sep 17 00:00:00 2001
From: InstaZDLL
Date: Wed, 21 Oct 2026 05:07:35 +0200
Subject: [PATCH 4/4] docs(api): say that a gb18030 playlist is reported as gbk
chardetng never names GB18030: its README lists it as "detected as
GBK", so a GB18030 `.m3u` comes back with `"encoding": "gbk"`. The text
is read in full all the same, because encoding_rs's GBK decoder is its
GB18030 decoder. Checked with a GB18030 file holding a four-byte
character outside GBK (U+20000): reported `gbk`, and every character
read back.
Raised in review.
Signed-off-by: InstaZDLL
---
docs/api-v2-guide.md | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/docs/api-v2-guide.md b/docs/api-v2-guide.md
index 7ef65f9a..d0467e99 100644
--- a/docs/api-v2-guide.md
+++ b/docs/api-v2-guide.md
@@ -745,7 +745,9 @@ BE); otherwise the server reads UTF-8 when the bytes are UTF-8 and recognises
the encoding from the bytes when they are not — GB18030, Big5, Shift_JIS,
EUC-KR, Windows-1252 and the like — and **says which it used** in the response,
as the encoding's WHATWG name in lower case (`utf-8`, `utf-16le`, `shift_jis`,
-`windows-1252`…).
+`windows-1252`…). A GB18030 file is reported as `gbk`: the detector never
+names GB18030, and the GBK decoder is the GB18030 one, so the text is read in
+full under either name.
```bash
# What it would do, without doing it