use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant};
use mineral_model::{BitRate, Song};
use mineral_playback::{OpenOptions, PlaybackRegistry, PlaybackRequest};
use mineral_protocol::{DownloadFailure, DownloadId};
use tokio_util::sync::CancellationToken;
use super::environment::DownloadEnv;
use super::partials::{owned_partial_path, remove_owned_partial};
use crate::media_cache::library_relpath;
#[derive(Debug, thiserror::Error)]
pub(crate) enum Error {
#[error("no playback provider for {0:?}")]
NoProvider(mineral_model::SourceKind),
#[error("download playback preparation failed")]
Playback(#[from] mineral_playback::Error),
#[error("download rewrite has no direct replacement")]
InvalidRewrite,
#[error("download stopped")]
Stopped,
#[error("opened reader returned invalid byte count")]
InvalidReadCount,
#[error("download media read failed")]
Read(#[source] std::io::Error),
#[error("download file operation failed at {path:?}")]
File {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("download writer task failed")]
Writer(#[from] tokio::task::JoinError),
#[error("download byte or time count overflowed")]
Count(#[from] std::num::TryFromIntError),
#[error("download truncated: {done} / {expected} bytes")]
Truncated {
done: u64,
expected: u64,
},
#[error("download export directory is unavailable")]
Unavailable,
}
impl Error {
pub(crate) fn failure_kind(&self) -> DownloadFailure {
match self {
Self::NoProvider(_) | Self::Unavailable => DownloadFailure::Unavailable,
Self::Playback(_) => DownloadFailure::Preparation,
Self::Read(_) => DownloadFailure::Read,
Self::File { .. } => DownloadFailure::Storage,
Self::InvalidReadCount | Self::Truncated { .. } => DownloadFailure::InvalidMedia,
Self::InvalidRewrite => DownloadFailure::InvalidRewrite,
Self::Stopped | Self::Writer(_) | Self::Count(_) => DownloadFailure::Internal,
}
}
}
type TransferReporter = Arc<dyn Fn(TransferUpdate) + Send + Sync>;
pub(crate) struct DownloadAttempt<'a> {
pub(crate) id: &'a DownloadId,
pub(crate) cancellation: &'a CancellationToken,
}
#[derive(Clone, Copy)]
pub(crate) enum TransferUpdate {
Downloading {
quality: BitRate,
bytes_done: u64,
bytes_total: Option<u64>,
speed_bps: u64,
},
Finalizing,
}
pub(crate) enum DownloadOutcome {
Downloaded {
path: PathBuf,
quality: mineral_model::BitRate,
format: Option<mineral_model::AudioFormat>,
hooked: mineral_stats::DownloadHook,
},
Skipped {
cause: SkipCause,
quality: BitRate,
},
}
#[derive(Clone, Copy, Debug)]
pub(crate) enum SkipCause {
AlreadyExists,
HookVeto,
}
pub(crate) async fn download_song(
playback: &PlaybackRegistry,
env: &DownloadEnv<'_>,
song: &Song,
quality: BitRate,
attempt: DownloadAttempt<'_>,
reporter: TransferReporter,
speed_tick: Duration,
) -> Result<DownloadOutcome, Error> {
let DownloadEnv { music_dir, hooks } = *env;
if crate::resolve::probe_export(music_dir, song, quality).is_some() {
mineral_log::debug!(target: "download", song_id = song.id.as_str(), "已下载,跳过");
return Ok(DownloadOutcome::Skipped {
cause: SkipCause::AlreadyExists,
quality,
});
}
let provider = playback
.get(song.source())
.ok_or(Error::NoProvider(song.source()))?;
let mut prepared = provider
.resolve(
PlaybackRequest::new(song.id.clone(), quality),
attempt.cancellation.child_token(),
)
.await?;
ensure_not_cancelled(attempt.cancellation)?;
let mut hooked = mineral_stats::DownloadHook::None;
let mut export_quality = quality;
match hooks
.before_download(song, quality, prepared.direct_media())
.await
{
mineral_script::HookDecision::Continue => {}
mineral_script::HookDecision::Rewrite(spec) => {
hooked = mineral_stats::DownloadHook::Rewrite;
export_quality = spec.new_quality().unwrap_or(quality);
let original_direct = prepared.direct_media().cloned();
prepared =
crate::hook_bridge::rewrite_prepared(&song.id, original_direct.as_ref(), &spec)
.ok_or(Error::InvalidRewrite)?;
}
mineral_script::HookDecision::Skip { reason } => {
mineral_log::info!(
target: "script",
song_id = song.id.as_str(),
reason,
"before_download 跳过本曲"
);
return Ok(DownloadOutcome::Skipped {
cause: SkipCause::HookVeto,
quality,
});
}
}
let opened = prepared
.open(OpenOptions::new(
attempt.cancellation.child_token(),
0,
))
.await?;
ensure_not_cancelled(attempt.cancellation)?;
let format = opened.info().format.clone();
let byte_len = opened.byte_len();
let (subdir, file_name) = library_relpath(song, export_quality, format.as_ref());
let export = music_dir.join(&subdir).join(&file_name);
if let Some(parent) = export.parent() {
tokio::fs::create_dir_all(parent)
.await
.map_err(|source| Error::File {
path: parent.to_path_buf(),
source,
})?;
}
let part = owned_partial_path(&export, attempt.id);
let reader = opened.into_reader();
reporter(TransferUpdate::Downloading {
quality: export_quality,
bytes_done: 0,
bytes_total: byte_len,
speed_bps: 0,
});
let progress_reporter = Arc::clone(&reporter);
let cancellation = attempt.cancellation.clone();
let part_for_write = part.clone();
let write_result = tokio::task::spawn_blocking(move || {
drain_opened(
reader,
&part_for_write,
export_quality,
byte_len,
&cancellation,
&progress_reporter,
speed_tick,
)
})
.await?;
if let Err(error) = write_result {
remove_owned_partial(&part).await;
return Err(error);
}
if let Err(error) = ensure_not_cancelled(attempt.cancellation) {
remove_owned_partial(&part).await;
return Err(error);
}
reporter(TransferUpdate::Finalizing);
if let Err(error) = ensure_not_cancelled(attempt.cancellation) {
remove_owned_partial(&part).await;
return Err(error);
}
match tokio::fs::hard_link(&part, &export).await {
Ok(()) => {
remove_owned_partial(&part).await;
mineral_log::info!(target: "download", song_id = song.id.as_str(), path = %export.display(), "下载完成");
Ok(DownloadOutcome::Downloaded {
path: export,
quality: export_quality,
format,
hooked,
})
}
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
remove_owned_partial(&part).await;
Ok(DownloadOutcome::Skipped {
cause: SkipCause::AlreadyExists,
quality: export_quality,
})
}
Err(error) => {
remove_owned_partial(&part).await;
Err(Error::File {
path: export,
source: error,
})
}
}
}
fn drain_opened(
mut reader: Box<dyn mineral_playback::MediaReader>,
part: &Path,
quality: BitRate,
byte_len: Option<u64>,
cancellation: &CancellationToken,
reporter: &TransferReporter,
speed_tick: Duration,
) -> Result<(), Error> {
use std::io::{Read as _, Write as _};
let mut writer = std::fs::File::create(part).map_err(|source| Error::File {
path: part.to_path_buf(),
source,
})?;
let mut buffer = vec![0u8; 64 * 1024];
let mut done = 0u64;
let mut ema = None::<u64>;
let mut window_start = Instant::now();
let mut window_bytes = 0u64;
loop {
ensure_not_cancelled(cancellation)?;
let read = reader.read(&mut buffer).map_err(Error::Read)?;
if read == 0 {
break;
}
let bytes = buffer.get(..read).ok_or(Error::InvalidReadCount)?;
writer.write_all(bytes).map_err(|source| Error::File {
path: part.to_path_buf(),
source,
})?;
done = done.saturating_add(u64::try_from(read)?);
let elapsed = window_start.elapsed();
if elapsed >= speed_tick {
let elapsed_ms = u64::try_from(elapsed.as_millis())?.max(1);
let instant = done.saturating_sub(window_bytes).saturating_mul(1000) / elapsed_ms;
let smoothed = ema.map_or(instant, |old| {
(old.saturating_mul(3) + instant.saturating_mul(2)) / 5
});
ema = Some(smoothed);
reporter(TransferUpdate::Downloading {
quality,
bytes_done: done,
bytes_total: byte_len,
speed_bps: smoothed,
});
window_start = Instant::now();
window_bytes = done;
}
}
writer.flush().map_err(|source| Error::File {
path: part.to_path_buf(),
source,
})?;
reporter(TransferUpdate::Downloading {
quality,
bytes_done: done,
bytes_total: byte_len,
speed_bps: ema.unwrap_or_default(),
});
if let Some(expected) = byte_len
&& done < expected
{
return Err(Error::Truncated { done, expected });
}
Ok(())
}
fn ensure_not_cancelled(cancellation: &CancellationToken) -> Result<(), Error> {
if cancellation.is_cancelled() {
Err(Error::Stopped)
} else {
Ok(())
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use crate::persistence::ServerStore;
use mineral_model::{AlbumId, AlbumRef, BitRate, Song, SongId, SourceKind};
use mineral_playback::{PlaybackProvider, PlaybackRegistry};
use mineral_protocol::DownloadId;
use mineral_test::mock::{UrlChannel, serve_once};
use tokio_util::sync::CancellationToken;
use std::time::Duration;
use super::{DownloadAttempt, DownloadEnv, DownloadOutcome, download_song};
use crate::media_cache::MediaCache;
fn song() -> Song {
Song::builder()
.id(SongId::new(SourceKind::NETEASE, "1"))
.name("t".to_owned())
.album(Some(AlbumRef {
id: AlbumId::new(SourceKind::NETEASE, "0"),
name: "A".to_owned(),
}))
.build()
}
fn playback(channel: UrlChannel) -> color_eyre::Result<PlaybackRegistry> {
let provider: Arc<dyn PlaybackProvider> = Arc::new(channel);
Ok(PlaybackRegistry::new(vec![provider])?)
}
async fn download_for_test(
playback: &PlaybackRegistry,
env: &DownloadEnv<'_>,
song: &Song,
quality: BitRate,
) -> color_eyre::Result<DownloadOutcome> {
let id = DownloadId::new("test-download".to_owned());
let cancellation = CancellationToken::new();
Ok(download_song(
playback,
env,
song,
quality,
DownloadAttempt {
id: &id,
cancellation: &cancellation,
},
Arc::new(|_update| {}),
Duration::from_millis(150),
)
.await?)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn download_does_not_populate_cache() -> color_eyre::Result<()> {
let dir = tempfile::tempdir()?;
let persist = ServerStore::open(&dir.path().join("t.db")).await?;
let media_cache =
MediaCache::open(&persist, dir.path().join("cache"), 1_000_000_000).await?;
let music_dir = dir.path().join("music");
let url = serve_once(b"FAKEFLACDATA".to_vec()).await?;
let playback = playback(UrlChannel { url })?;
let s = song();
let outcome = download_for_test(
&playback,
&DownloadEnv {
music_dir: &music_dir,
hooks: &crate::hook_bridge::HookGate::disabled(),
},
&s,
BitRate::Lossless,
)
.await?;
assert!(
matches!(outcome, DownloadOutcome::Downloaded { .. }),
"应真正下载"
);
assert!(
crate::resolve::probe_export(&music_dir, &s, BitRate::Lossless).is_some(),
"永久下载文件应已落盘"
);
assert!(
media_cache.get(&s.id, BitRate::Lossless).is_none(),
"下载不应填充 audio cache(避免双份 + 播放走缓存副本)"
);
Ok(())
}
fn script_gate(
script: &str,
) -> color_eyre::Result<(mineral_script::ScriptRuntime, crate::hook_bridge::HookGate)> {
use mineral_script::{ScriptHost, ScriptRuntime, ScriptSender, install_daemon_api};
let (cmd_tx, _cmd_rx) = tokio::sync::mpsc::unbounded_channel();
let (push_tx, _push_rx) = tokio::sync::mpsc::unbounded_channel();
let host = ScriptHost::new(cmd_tx, push_tx);
let lua = mineral_script::mlua::Lua::new();
install_daemon_api(&lua, &host)?;
lua.load(format!(
"local mineral = require(\"mineral.daemon\")\n{script}"
))
.exec()?;
let sender = ScriptSender::detached();
let watchdog = mineral_script::WatchdogConfig::builder()
.instruction_interval(10_000)
.soft_wall(Duration::from_millis(200))
.hard_wall(Duration::from_secs(1))
.build();
let runtime = ScriptRuntime::spawn(lua, host, watchdog, &sender)?;
let gate = crate::hook_bridge::HookGate::with_sender(sender, Duration::from_secs(5));
Ok((runtime, gate))
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn download_skipped_by_hook() -> color_eyre::Result<()> {
let dir = tempfile::tempdir()?;
let music_dir = dir.path().join("music");
let playback = playback(UrlChannel {
url: "http://127.0.0.1:9/dead.flac".parse()?,
})?;
let (runtime, gate) = script_gate(
r#"
mineral.hook("before_download", function(ctx)
return { skip = "脚本拒绝" }
end)
"#,
)?;
let outcome = download_for_test(
&playback,
&DownloadEnv {
music_dir: &music_dir,
hooks: &gate,
},
&song(),
BitRate::Lossless,
)
.await?;
assert!(
matches!(outcome, DownloadOutcome::Skipped { .. }),
"hook 跳过应记 Skipped"
);
assert!(
crate::resolve::probe_export(&music_dir, &song(), BitRate::Lossless).is_none(),
"跳过不应落盘"
);
drop(runtime);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn download_rewritten_by_hook() -> color_eyre::Result<()> {
let dir = tempfile::tempdir()?;
let music_dir = dir.path().join("music");
let live = serve_once(b"FAKEFLACDATA".to_vec()).await?;
let playback = playback(UrlChannel {
url: "http://127.0.0.1:9/dead.flac".parse()?,
})?;
let (runtime, gate) = script_gate(&format!(
r#"
mineral.hook("before_download", function(ctx)
return {{ url = "{live}", quality = "standard" }}
end)
"#
))?;
let outcome = download_for_test(
&playback,
&DownloadEnv {
music_dir: &music_dir,
hooks: &gate,
},
&song(),
BitRate::Lossless,
)
.await?;
assert!(
matches!(outcome, DownloadOutcome::Downloaded { .. }),
"改写到活地址应下载成功"
);
assert!(
crate::resolve::probe_export(&music_dir, &song(), BitRate::Standard).is_some(),
"导出路径应按改写后的音质(standard)标注"
);
drop(runtime);
Ok(())
}
}