/
magnusroot
/
nm
Обзор
Документация
Войти
/
magnusroot
/
nm
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
crates/player/src/decode.rs
525 строк
21 KB
Magnus Root
New smb
25 июл 2026, 21:00
25 июл 2026, 21:00
c2ac57d
Код
Авторство
О чём код?
use std::fs::File; use std::io::{Read, Seek, SeekFrom}; use std::path::Path; use std::sync::mpsc::Sender; use std::time::Duration; use symphonia::core::audio::{SampleBuffer, SignalSpec}; use symphonia::core::codecs::{Decoder, DecoderOptions}; use symphonia::core::formats::{FormatOptions, FormatReader, SeekMode, SeekTo}; use symphonia::core::io::{MediaSource, MediaSourceStream, ReadOnlySource}; use symphonia::core::meta::MetadataOptions; use symphonia::core::probe::Hint; use symphonia::core::units::Time; use crate::types::{PlayerEvent, TrackInfo}; use textfix::fix_mojibake; /// Оборачивает `smbfs::SmbFile` (Read+Seek) в symphonia-совместимый `MediaSource` — /// позволяет декодировать аудио прямо с SMB-шары точно так же, как с локального файла, /// без единого системного вызова вроде gio/gvfs. struct SmbMediaSource { inner: smbfs::SmbFile, } impl Read for SmbMediaSource { fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> { self.inner.read(buf) } } impl Seek for SmbMediaSource { fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> { self.inner.seek(pos) } } impl MediaSource for SmbMediaSource { fn is_seekable(&self) -> bool { true // SMB read всегда по смещению — "перемотка" ничего не стоит } fn byte_len(&self) -> Option<u64> { Some(self.inner.len()) } } /// Строит symphonia-источник (Box<dyn MediaSource>) для локального файла или SMB-пути — /// единая точка входа, дальше `open_track`/`read_tags` работают одинаково для обоих случаев. fn open_media_source(path: &Path) -> anyhow::Result<Box<dyn MediaSource>> { if let Some(loc) = smbfs::parse_smb_path(path) { let smb_file = smbfs::open_read(&loc.server, &loc.share, &loc.path)?; Ok(Box::new(SmbMediaSource { inner: smb_file })) } else { Ok(Box::new(File::open(path)?)) } } pub struct DecodedTrack { pub format: Box<dyn FormatReader>, pub decoder: Box<dyn Decoder>, pub track_id: u32, pub spec: SignalSpec, pub duration: Option<Duration>, } fn build_decoded(mss: MediaSourceStream, hint: Hint) -> anyhow::Result<DecodedTrack> { let probed = symphonia::default::get_probe().format( &hint, mss, &FormatOptions::default(), &MetadataOptions::default(), )?; let format = probed.format; let track = format .tracks() .iter() .find(|t| t.codec_params.codec != symphonia::core::codecs::CODEC_TYPE_NULL) .ok_or_else(|| anyhow::anyhow!("no playable track found"))?; let track_id = track.id; let decoder = symphonia::default::get_codecs() .make(&track.codec_params, &DecoderOptions::default())?; let sample_rate = track .codec_params .sample_rate .ok_or_else(|| anyhow::anyhow!("unknown sample rate"))?; let channels = track .codec_params .channels .ok_or_else(|| anyhow::anyhow!("unknown channel layout"))?; let duration = track .codec_params .n_frames .map(|frames| Duration::from_secs_f64(frames as f64 / sample_rate as f64)); let spec = SignalSpec::new(sample_rate, channels); Ok(DecodedTrack { format, decoder, track_id, spec, duration, }) } pub fn open_track(path: &Path) -> anyhow::Result<DecodedTrack> { let source = open_media_source(path)?; let mss = MediaSourceStream::new(source, Default::default()); let mut hint = Hint::new(); if let Some(ext) = path.extension().and_then(|e| e.to_str()) { hint.with_extension(ext); } build_decoded(mss, hint) } /// Открывает интернет-радио поток по URL. Поток не поддерживает seek — оборачиваем его /// через `ReadOnlySource`, symphonia корректно работает и с несикаемыми источниками /// (типичный случай для live-стримов). /// Читает поток, прозрачно вырезая из него ICY-метаданные (каждые `metaint` байт аудио /// сервер вставляет блок с текстовым описанием текущего трека) и отправляя распарсенный /// заголовок трека через `evt_tx`. Если `metaint == 0`, работает как простой passthrough. struct IcyStream<R> { inner: R, metaint: usize, bytes_since_meta: usize, evt_tx: Sender<PlayerEvent>, last_title: Option<String>, } impl<R: Read> Read for IcyStream<R> { fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> { if self.metaint == 0 { return self.inner.read(buf); } let remaining_before_meta = self.metaint - self.bytes_since_meta; let to_read = buf.len().min(remaining_before_meta).max(1); let n = self.inner.read(&mut buf[..to_read])?; self.bytes_since_meta += n; if self.bytes_since_meta >= self.metaint && n > 0 { self.consume_metadata()?; self.bytes_since_meta = 0; } Ok(n) } } impl<R: Read> IcyStream<R> { fn consume_metadata(&mut self) -> std::io::Result<()> { let mut len_byte = [0u8; 1]; self.inner.read_exact(&mut len_byte)?; let len = len_byte[0] as usize * 16; if len == 0 { return Ok(()); } let mut meta_buf = vec![0u8; len]; self.inner.read_exact(&mut meta_buf)?; let text = String::from_utf8_lossy(&meta_buf); let text = text.trim_matches(char::from(0)); if let Some(title) = parse_stream_title(text) { if self.last_title.as_deref() != Some(title.as_str()) { let _ = self.evt_tx.send(PlayerEvent::RadioMetadata(title.clone())); self.last_title = Some(title); } } Ok(()) } } /// Извлекает значение `StreamTitle='...'` из блока ICY-метаданных вида /// `StreamTitle='Artist - Song';StreamUrl='...';`. fn parse_stream_title(meta: &str) -> Option<String> { let key = "StreamTitle='"; let start = meta.find(key)? + key.len(); let rest = &meta[start..]; let end = rest.find("';")?; let title = rest[..end].trim(); if title.is_empty() { None } else { Some(title.to_string()) } } /// Открывает интернет-радио поток по URL. Поток не поддерживает seek — оборачиваем его /// через `ReadOnlySource`, symphonia корректно работает и с несикаемыми источниками /// (типичный случай для live-стрима). Запрашивает ICY-метаданные (Icy-MetaData: 1) и /// прозрачно вырезает их из аудио-потока, публикуя название трека через `evt_tx`. pub fn open_url(url: &str, evt_tx: Sender<PlayerEvent>) -> anyhow::Result<DecodedTrack> { // ВАЖНО: используем timeout_connect/timeout_read через отдельный Agent, а не общий // .timeout() на запросе — общий таймаут ureq считает от начала запроса ДО КОНЦА чтения // тела, что оборвало бы живой радио-поток ровно через N секунд. timeout_read же — это // таймаут между отдельными чтениями сокета: ловит реально зависший/замолчавший сервер, // не убивая нормально играющее радио. let agent = ureq::AgentBuilder::new() .timeout_connect(Duration::from_secs(10)) .timeout_read(Duration::from_secs(30)) .build(); let response = agent.get(url).set("Icy-MetaData", "1").call()?; let content_type = response.header("Content-Type").map(|s| s.to_string()); let metaint: usize = response .header("icy-metaint") .and_then(|s| s.parse().ok()) .unwrap_or(0); let reader = response.into_reader(); let icy_reader = IcyStream { inner: reader, metaint, bytes_since_meta: 0, evt_tx, last_title: None, }; let source = ReadOnlySource::new(icy_reader); let mss = MediaSourceStream::new(Box::new(source), Default::default()); let mut hint = Hint::new(); if let Some(ext) = guess_extension_from_content_type(content_type.as_deref()) { hint.with_extension(ext); } build_decoded(mss, hint) } fn guess_extension_from_content_type(ct: Option<&str>) -> Option<&'static str> { match ct?.split(';').next()?.trim() { "audio/mpeg" | "audio/mp3" => Some("mp3"), "audio/aac" | "audio/aacp" => Some("aac"), "application/ogg" | "audio/ogg" => Some("ogg"), "audio/flac" => Some("flac"), "audio/wav" | "audio/x-wav" => Some("wav"), _ => None, } } /// Прыгает на указанную позицию внутри уже открытого трека (не работает для радио — /// сетевой поток несикаемый). Сбрасывает состояние декодера после прыжка, как того требует /// symphonia, и возвращает фактическую позицию (seek может быть неточным для некоторых /// форматов — используем `Accurate`, что даёт позицию не позже запрошенной). pub fn seek(track: &mut DecodedTrack, position: Duration) -> anyhow::Result<Duration> { let time = Time { seconds: position.as_secs(), frac: position.subsec_nanos() as f64 / 1_000_000_000.0, }; let seeked = track.format.seek( SeekMode::Accurate, SeekTo::Time { time, track_id: Some(track.track_id), }, )?; track.decoder.reset(); Ok(Duration::from_secs_f64( seeked.actual_ts as f64 / track.spec.rate as f64, )) } /// Читает следующий декодированный пакет как interleaved f32. Возвращает None на EOF. pub fn next_packet_samples(track: &mut DecodedTrack) -> anyhow::Result<Option<Vec<f32>>> { loop { let packet = match track.format.next_packet() { Ok(p) => p, Err(symphonia::core::errors::Error::IoError(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => { return Ok(None) } Err(e) => return Err(e.into()), }; if packet.track_id() != track.track_id { continue; } match track.decoder.decode(&packet) { Ok(decoded) => { let spec = *decoded.spec(); let mut sample_buf = SampleBuffer::<f32>::new(decoded.capacity() as u64, spec); sample_buf.copy_interleaved_ref(decoded); return Ok(Some(sample_buf.samples().to_vec())); } Err(symphonia::core::errors::Error::DecodeError(_)) => continue, Err(e) => return Err(e.into()), } } } /// Читает базовые теги (title/artist/album) и обложку, если есть. Не открывает поток для декодирования. pub fn read_tags(path: &Path) -> anyhow::Result<TrackInfo> { use symphonia::core::meta::StandardTagKey; let source = open_media_source(path)?; let mss = MediaSourceStream::new(source, Default::default()); let mut hint = Hint::new(); if let Some(ext) = path.extension().and_then(|e| e.to_str()) { hint.with_extension(ext); } let mut probed = symphonia::default::get_probe().format( &hint, mss, &FormatOptions::default(), &MetadataOptions::default(), )?; let mut title = None; let mut artist = None; let mut album = None; let mut cover = None; let mut visit = |metadata: &symphonia::core::meta::MetadataRevision| { for tag in metadata.tags() { match tag.std_key { Some(StandardTagKey::TrackTitle) => title = Some(tag.value.to_string()), Some(StandardTagKey::Artist) => artist = Some(tag.value.to_string()), Some(StandardTagKey::Album) => album = Some(tag.value.to_string()), _ => {} } } if cover.is_none() { if let Some(visual) = metadata.visuals().first() { cover = Some(visual.data.to_vec()); } } }; if let Some(metadata) = probed.format.metadata().current() { visit(metadata); } if let Some(mut metadata) = probed.metadata.get() { if let Some(rev) = metadata.skip_to_latest() { visit(rev); } } let fallback_title = path .file_stem() .and_then(|s| s.to_str()) .unwrap_or("Unknown") .to_string(); let track = probed.format.tracks().first(); let duration = track.and_then(|t| { let rate = t.codec_params.sample_rate?; let frames = t.codec_params.n_frames?; Some(Duration::from_secs_f64(frames as f64 / rate as f64)) }); Ok(TrackInfo { path: path.to_path_buf(), title: fix_mojibake(&title.unwrap_or(fallback_title)), artist: fix_mojibake(&artist.unwrap_or_else(|| "Unknown Artist".to_string())), album: fix_mojibake(&album.unwrap_or_else(|| "Unknown Album".to_string())), duration, cover, }) } #[cfg(test)] mod tests { use super::*; #[test] fn parses_stream_title() { let meta = "StreamTitle='Pink Floyd - Money';StreamUrl='http://example.com';"; assert_eq!( parse_stream_title(meta), Some("Pink Floyd - Money".to_string()) ); } #[test] fn returns_none_for_empty_title() { let meta = "StreamTitle='';StreamUrl='';"; assert_eq!(parse_stream_title(meta), None); } #[test] fn returns_none_when_no_stream_title_key() { let meta = "SomeOtherField='value';"; assert_eq!(parse_stream_title(meta), None); } /// Проверяет, что `open_track`/`next_packet_samples` реально декодируют аудио, читая его /// байты со настоящей SMB-шары (через smbfs — чистый Rust клиент, без gio/gvfs), а не /// только что теги читаются. Нужен локальный Samba-сервер с гостевой шарой `test`, /// содержащей `Artist/Album/01.mp3` — поднимается вручную перед прогоном. Запускать /// явно: `cargo test -- --ignored`. #[test] #[ignore] fn open_track_decodes_real_audio_over_smb() { smbfs::connect("127.0.0.1", "test", None, None) .expect("connect failed — is the local samba test server running?"); let smb_path = smbfs::make_smb_path("127.0.0.1", "test", "Artist/Album/01.mp3"); let mut track = open_track(&smb_path).expect("open_track over SMB failed"); let mut total_samples = 0usize; let mut packets = 0usize; while let Some(samples) = next_packet_samples(&mut track).expect("decode failed") { total_samples += samples.len(); packets += 1; if packets >= 5 { break; // достаточно для проверки — не декодируем весь файл } } assert!(packets > 0, "should have decoded at least one packet"); assert!(total_samples > 0, "should have produced actual audio samples"); println!("decoded {packets} packets, {total_samples} samples total, over SMB"); } /// Регрессионный тест на реальный баг: кракозябры чинились в библиотеке (library::scanner), /// но не в TrackInfo, который показывается в "Now Playing" при реальном воспроизведении — /// потому что read_tags жила отдельно и не применяла fix_mojibake. Воспроизводит точный /// сценарий из багрепорта: ID3v2-тег, где байты CP1251 записаны под флагом ISO-8859-1 /// (классический баг старых тегеров на русских Windows) — именно так, а не ID3v1 /// (symphonia, в отличие от lofty, ID3v1 не читает вовсе). Нужен ffmpeg — запускать /// явно: `cargo test -- --ignored`. #[test] #[ignore] fn read_tags_fixes_cp1251_mojibake_for_now_playing() { let dir = std::env::temp_dir(); let plain = dir.join("nsm_moji_plain.mp3"); let tagged = dir.join("nsm_moji_tagged.mp3"); let status = std::process::Command::new("ffmpeg") .args([ "-y", "-f", "lavfi", "-i", "sine=frequency=440:duration=1", "-write_id3v1", "0", "-id3v2_version", "0", plain.to_str().unwrap(), "-loglevel", "error", ]) .status() .expect("ffmpeg must be installed for this test"); assert!(status.success()); // ID3v2.3 фрейм с encoding byte = 0 (ISO-8859-1), но реальные байты — CP1251. // Именно так старые тегеры на русской Windows пишут кириллицу, "забывая" указать // правильную кодировку. fn frame(id: &str, text_cp1251: &[u8]) -> Vec<u8> { let mut payload = vec![0u8]; // encoding = 0 (ISO-8859-1) payload.extend_from_slice(text_cp1251); let mut out = id.as_bytes().to_vec(); out.extend_from_slice(&(payload.len() as u32).to_be_bytes()); out.extend_from_slice(&[0, 0]); // flags out.extend_from_slice(&payload); out } fn synchsafe(n: u32) -> [u8; 4] { [ ((n >> 21) & 0x7f) as u8, ((n >> 14) & 0x7f) as u8, ((n >> 7) & 0x7f) as u8, (n & 0x7f) as u8, ] } let (title_bytes, _, _) = encoding_rs::WINDOWS_1251.encode("Поговори с ней о сексе"); let (artist_bytes, _, _) = encoding_rs::WINDOWS_1251.encode("Мальчишник"); let mut frames = frame("TIT2", &title_bytes); frames.extend(frame("TPE1", &artist_bytes)); let mut header = vec![b'I', b'D', b'3', 3, 0, 0]; header.extend_from_slice(&synchsafe(frames.len() as u32)); let audio = std::fs::read(&plain).unwrap(); let mut data = header; data.extend(frames); data.extend(audio); std::fs::write(&tagged, &data).unwrap(); let info = read_tags(&tagged).expect("should read tags"); assert_eq!(info.title, "Поговори с ней о сексе"); assert_eq!(info.artist, "Мальчишник"); let _ = std::fs::remove_file(&plain); let _ = std::fs::remove_file(&tagged); } /// Ручная проверка реального seek на настоящем файле. Игнорируется по умолчанию /// (нужен ffmpeg в PATH для генерации фикстуры) — запускать явно: `cargo test -- --ignored`. #[test] #[ignore] fn seek_jumps_to_requested_position() { let dir = std::env::temp_dir(); let path = dir.join("nsm_seek_test.mp3"); let status = std::process::Command::new("ffmpeg") .args([ "-y", "-f", "lavfi", "-i", "sine=frequency=440:duration=5", "-c:a", "libmp3lame", path.to_str().unwrap(), "-loglevel", "error", ]) .status() .expect("ffmpeg must be installed for this test"); assert!(status.success()); let mut track = open_track(&path).expect("should open test mp3"); let target = Duration::from_secs(3); let actual = seek(&mut track, target).expect("seek should succeed"); // Accurate-seek гарантирует позицию не позже запрошенной, но достаточно близкую. assert!( actual <= target && actual.as_secs_f64() > target.as_secs_f64() - 0.5, "expected actual seek position close to {target:?}, got {actual:?}" ); // После seek декодирование должно продолжаться без ошибок. let next = next_packet_samples(&mut track).expect("decode after seek should work"); assert!(next.is_some(), "expected audio data after seeking"); let _ = std::fs::remove_file(&path); } }