Added yt-dlp cookies
Build and Publish / Build and Publish Docker Image (push) Successful in 3m46s

This commit is contained in:
Ultradesu
2026-09-01 13:16:38 +01:00
parent b1ce504db6
commit a0964b651b
2 changed files with 169 additions and 38 deletions
Generated
+1 -1
View File
@@ -1938,7 +1938,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
[[package]]
name = "furumusic"
version = "0.10.4"
version = "0.10.5"
dependencies = [
"anyhow",
"async-stream",
+168 -37
View File
@@ -12,7 +12,7 @@ use image::codecs::jpeg::JpegEncoder;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use sqlx::{FromRow, PgPool};
use tokio::io::{AsyncBufReadExt, AsyncRead, BufReader};
use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncReadExt, BufReader};
use tokio::process::Command;
use tokio::sync::{Mutex, OnceCell, Semaphore};
use tokio_util::sync::CancellationToken;
@@ -1518,11 +1518,7 @@ async fn prepare_downloaded_folder(
preserve_track_numbers(&mut audio_files, chapter_count, item.playlist_index, cancel).await?;
for audio in &audio_files {
let data = tokio::select! {
data = tokio::fs::read(audio) => data?,
_ = cancel.cancelled() => bail!("YouTube import cancelled"),
};
let hash = format!("{:x}", Sha256::digest(&data));
let hash = sha256_file(audio, cancel).await?;
if crate::agent::rag::file_hash_exists(pool, &hash)
.await
.unwrap_or(false)
@@ -1581,6 +1577,23 @@ async fn prepare_downloaded_folder(
})
}
async fn sha256_file(path: &Path, cancel: &CancellationToken) -> anyhow::Result<String> {
let mut file = tokio::fs::File::open(path).await?;
let mut hasher = Sha256::new();
let mut buffer = vec![0_u8; 1024 * 1024];
loop {
let read = tokio::select! {
read = file.read(&mut buffer) => read?,
_ = cancel.cancelled() => bail!("YouTube import cancelled"),
};
if read == 0 {
break;
}
hasher.update(&buffer[..read]);
}
Ok(format!("{:x}", hasher.finalize()))
}
async fn preserve_track_numbers(
audio_files: &mut [PathBuf],
chapter_count: i32,
@@ -1604,7 +1617,17 @@ async fn preserve_track_numbers(
tokio::fs::rename(&*audio, &numbered).await?;
*audio = numbered;
}
embed_track_number(audio, track_number, cancel).await?;
match embed_track_number(audio, track_number, cancel).await? {
TrackNumberEmbedOutcome::Embedded => {}
TrackNumberEmbedOutcome::Skipped(reason) => {
tracing::warn!(
path = %audio.display(),
track_number,
%reason,
"continuing YouTube import without embedded track number"
);
}
}
}
Ok(())
}
@@ -1625,17 +1648,12 @@ fn track_number_from_file_name(path: &Path) -> Option<i32> {
.filter(|number| *number > 0)
}
async fn embed_track_number(
audio: &Path,
track_number: i32,
cancel: &CancellationToken,
) -> anyhow::Result<()> {
let extension = audio
.extension()
.and_then(|value| value.to_str())
.unwrap_or("audio");
let temporary =
audio.with_file_name(format!(".furumusic-track-{}.{}", Uuid::new_v4(), extension));
enum TrackNumberEmbedOutcome {
Embedded,
Skipped(String),
}
fn track_number_ffmpeg_command(audio: &Path, temporary: &Path, track_number: i32) -> Command {
let mut command = Command::new("ffmpeg");
command
.arg("-hide_banner")
@@ -1644,21 +1662,51 @@ async fn embed_track_number(
.arg("-y")
.arg("-i")
.arg(audio)
// A downloaded audio container may also contain cover art, subtitles,
// attachments, or data streams. Some containers cannot accept those
// streams again, so remux only the primary audio stream.
.arg("-map")
.arg("0:a:0")
.arg("-map_metadata")
.arg("0")
.arg("-c")
.arg("-map_chapters")
.arg("-1")
.arg("-c:a")
.arg("copy")
.arg("-metadata")
.arg(format!("track={track_number}"))
.arg(&temporary)
.arg(temporary)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.kill_on_drop(true);
command
}
async fn embed_track_number(
audio: &Path,
track_number: i32,
cancel: &CancellationToken,
) -> anyhow::Result<TrackNumberEmbedOutcome> {
let extension = audio
.extension()
.and_then(|value| value.to_str())
.unwrap_or("audio");
let temporary =
audio.with_file_name(format!(".furumusic-track-{}.{}", Uuid::new_v4(), extension));
let mut command = track_number_ffmpeg_command(audio, &temporary, track_number);
configure_process_group(&mut command);
let output = tokio::select! {
output = command.output() => output?,
output = command.output() => match output {
Ok(output) => output,
Err(error) => {
let _ = tokio::fs::remove_file(&temporary).await;
return Ok(TrackNumberEmbedOutcome::Skipped(
format!("could not start ffmpeg metadata remux: {error}")
));
}
},
_ = cancel.cancelled() => {
let _ = tokio::fs::remove_file(&temporary).await;
bail!("YouTube import cancelled");
@@ -1666,26 +1714,62 @@ async fn embed_track_number(
};
if !output.status.success() {
let _ = tokio::fs::remove_file(&temporary).await;
bail!(
"ffmpeg could not preserve track number {}: {}",
track_number,
useful_error(&String::from_utf8_lossy(&output.stderr))
);
return Ok(TrackNumberEmbedOutcome::Skipped(format!(
"ffmpeg metadata remux failed: {}",
useful_error_tail(&String::from_utf8_lossy(&output.stderr), 8)
)));
}
let backup = audio.with_file_name(format!(
".furumusic-track-backup-{}.{}",
Uuid::new_v4(),
extension
));
tokio::fs::rename(audio, &backup).await?;
if let Err(error) = tokio::fs::rename(&temporary, audio).await {
let _ = tokio::fs::rename(&backup, audio).await;
let temporary_size = tokio::fs::metadata(&temporary)
.await
.map(|metadata| metadata.len())
.unwrap_or(0);
if temporary_size == 0 {
let _ = tokio::fs::remove_file(&temporary).await;
return Err(error.into());
return Ok(TrackNumberEmbedOutcome::Skipped(
"ffmpeg metadata remux produced an empty file".to_owned(),
));
}
#[cfg(unix)]
{
if let Err(error) = tokio::fs::rename(&temporary, audio).await {
let _ = tokio::fs::remove_file(&temporary).await;
return Ok(TrackNumberEmbedOutcome::Skipped(format!(
"could not replace audio with metadata remux: {error}"
)));
}
return Ok(TrackNumberEmbedOutcome::Embedded);
}
#[cfg(not(unix))]
let backup = audio.with_file_name(format!(".furumusic-track-backup-{}.bak", Uuid::new_v4()));
#[cfg(not(unix))]
{
if let Err(error) = tokio::fs::rename(audio, &backup).await {
let _ = tokio::fs::remove_file(&temporary).await;
return Ok(TrackNumberEmbedOutcome::Skipped(format!(
"could not prepare audio replacement: {error}"
)));
}
if let Err(error) = tokio::fs::rename(&temporary, audio).await {
let restore = tokio::fs::rename(&backup, audio).await;
let _ = tokio::fs::remove_file(&temporary).await;
if let Err(restore_error) = restore {
bail!(
"could not install track metadata ({error}) or restore original audio from {} ({restore_error})",
backup.display()
);
}
return Ok(TrackNumberEmbedOutcome::Skipped(format!(
"could not replace audio with metadata remux: {error}"
)));
}
if let Err(error) = tokio::fs::remove_file(&backup).await {
tracing::warn!(path = %backup.display(), %error, "could not remove track metadata backup");
}
Ok(TrackNumberEmbedOutcome::Embedded)
}
tokio::fs::remove_file(backup).await?;
Ok(())
}
async fn read_info_json(stage: &Path) -> anyhow::Result<Option<serde_json::Value>> {
@@ -2261,6 +2345,19 @@ fn useful_error(value: &str) -> String {
trim_error(lines.last().copied().unwrap_or("unknown error"))
}
fn useful_error_tail(value: &str, line_count: usize) -> String {
let lines = value
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.collect::<Vec<_>>();
if lines.is_empty() {
return "unknown error".to_owned();
}
let start = lines.len().saturating_sub(line_count.max(1));
trim_error(&lines[start..].join(" | "))
}
fn trim_error(value: &str) -> String {
value.chars().take(MAX_ERROR_LEN).collect()
}
@@ -2469,6 +2566,40 @@ mod tests {
assert!(!args.iter().any(|argument| argument == "--no-cookies"));
}
#[test]
fn track_metadata_remux_maps_only_the_primary_audio_stream() {
let command =
track_number_ffmpeg_command(Path::new("input.webm"), Path::new("temporary.webm"), 7);
let args = command
.as_std()
.get_args()
.map(|argument| argument.to_string_lossy().into_owned())
.collect::<Vec<_>>();
assert!(
args.windows(2)
.any(|pair| pair == ["-map".to_owned(), "0:a:0".to_owned()])
);
assert!(
!args
.windows(2)
.any(|pair| pair == ["-map".to_owned(), "0".to_owned()])
);
assert!(
args.windows(2)
.any(|pair| pair == ["-c:a".to_owned(), "copy".to_owned()])
);
assert!(
args.windows(2)
.any(|pair| pair == ["-metadata".to_owned(), "track=7".to_owned()])
);
}
#[test]
fn ffmpeg_error_tail_keeps_the_specific_context() {
let error = useful_error_tail("first\ncodec mismatch\nError initializing output stream", 2);
assert_eq!(error, "codec mismatch | Error initializing output stream");
}
#[test]
fn temporary_cookie_file_is_private_and_removed() {
let path;