Skip to content

Commit

Permalink
feat: support hls downloads
Browse files Browse the repository at this point in the history
  • Loading branch information
damaredayo committed Nov 4, 2024
1 parent b187a25 commit fb1af47
Show file tree
Hide file tree
Showing 6 changed files with 171 additions and 87 deletions.
58 changes: 52 additions & 6 deletions src/audio.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ impl Downloader {
///
/// # Returns
/// Result indicating success or failure
pub fn process_mp3<P: AsRef<Path>>(
pub async fn process_mp3<P: AsRef<Path>>(
&self,
path: P,
audio: Bytes,
Expand Down Expand Up @@ -66,7 +66,7 @@ impl Downloader {
///
/// # Returns
/// Result indicating success or failure
pub fn process_m4a<P: AsRef<Path>>(
pub async fn process_m4a<P: AsRef<Path>>(
&self,
path: P,
audio: Bytes,
Expand All @@ -76,6 +76,47 @@ impl Downloader {
.reformat_m4a(audio, thumbnail, path.as_ref().to_path_buf())
}

/// Processes and saves an OGG file, currently without any additional metadata
/// This may be extended in the future to support album art
///
/// # Arguments
/// * `path` - Output path for the file
/// * `audio` - Audio file bytes
/// * `thumbnail` - Thumbnail image bytes
/// * `thumbnail_ext` - Thumbnail image file extension
///
/// # Returns
/// Result indicating success or failure
pub async fn process_ogg<P: AsRef<Path>> (
&self,
path: P,
audio: Bytes,
_thumbnail: Option<DownloadedFile>,
) -> Result<()> {
let file = File::create(path.as_ref())?;
let mut writer = BufWriter::new(file);
writer.write_all(&audio)?;
writer.flush()?;

Ok(())
}

pub async fn process_m3u8<P: AsRef<Path>>(
&self,
path: P,
playlist_data: Bytes,
thumbnail: Option<DownloadedFile>,
) -> Result<()> {
// Use FFmpeg to convert the concatenated segments to m4a
self.ffmpeg.process_m3u8(
Bytes::from(playlist_data),
thumbnail,
path.as_ref().to_path_buf(),
)?;

Ok(())
}

/// Processes and saves an audio file with the appropriate format handler
///
/// # Arguments
Expand All @@ -87,16 +128,21 @@ impl Downloader {
///
/// # Returns
/// Result indicating success or failure
pub fn process_audio<P: AsRef<Path>>(
pub async fn process_audio<P: AsRef<Path>>(
&self,
path: P,
audio: Bytes,
audio: DownloadedFile,
audio_ext: &str,
thumbnail: Option<DownloadedFile>,
) -> Result<()> {
if audio.file_ext == "m3u8" {
return self.process_m3u8(path, audio.data, thumbnail).await;
}

match audio_ext {
"mp3" => self.process_mp3(path, audio, thumbnail),
"m4a" => self.process_m4a(path, audio, thumbnail),
"mp3" => self.process_mp3(path, audio.data, thumbnail).await,
"m4a" => self.process_m4a(path, audio.data, thumbnail).await,
"ogg" => self.process_ogg(path, audio.data, thumbnail).await,
_ => Err(AppError::Audio(format!(
"Unsupported audio format: {}",
audio_ext
Expand Down
27 changes: 20 additions & 7 deletions src/downloader.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use crate::error::Result;
use crate::soundcloud::model::Playlist;
use crate::soundcloud::model::{Format, Playlist};
use crate::soundcloud::{model::Track, SoundcloudClient};
use crate::{ffmpeg, util};
use futures::stream::{FuturesUnordered, StreamExt};
Expand All @@ -10,10 +10,10 @@ use tokio::sync::Semaphore;
const MAX_CONCURRENT_DOWNLOADS: usize = 3;

pub struct Downloader {
client: SoundcloudClient,
pub client: SoundcloudClient,
pub ffmpeg: ffmpeg::FFmpeg<PathBuf>,
output_dir: PathBuf,
semaphore: Arc<Semaphore>,
pub ffmpeg: ffmpeg::FFmpeg<PathBuf>,
}

impl Downloader {
Expand Down Expand Up @@ -50,6 +50,8 @@ impl Downloader {
pub async fn download_playlist(&self, playlist: Playlist) -> Result<()> {
tracing::info!("Fetching playlist from: {}", playlist.permalink_url);

let playlist = self.client.fetch_playlist(playlist.id).await?;

let tracks_len = playlist.tracks.len();

let mut futures = FuturesUnordered::new();
Expand Down Expand Up @@ -145,16 +147,27 @@ impl Downloader {
}

async fn process_track(&self, track: &Track) -> Result<PathBuf> {
let audio = self.client.download_track(track).await?;
let artwork = self.client.download_cover(track).await?;
let (transcoding, audio) = self.client.download_track(track).await?;
let thumbnail = self.client.download_cover(track).await?;

let path = self.prepare_file_path(track, &audio.file_ext);
let audio_ext = Self::mime_type_to_ext(&transcoding.format);

self.process_audio(&path, audio.data, &audio.file_ext, artwork)?;
let path = self.prepare_file_path(track, &audio_ext);

self.process_audio(&path, audio, &audio_ext, thumbnail).await?;

Ok(path)
}

fn mime_type_to_ext(format: &Format) -> String {
match format.mime_type.as_str().split(';').next().unwrap() {
"audio/mpeg" => "mp3",
"audio/mp4" | "audio/x-m4a" => "m4a",
"audio/ogg" => "ogg",
_ => "m4a",
}.to_string()
}

fn prepare_file_path(&self, track: &Track, ext: &str) -> PathBuf {
let username = util::sanitize(&track.user.username);
let artist = if util::is_empty(&username) {
Expand Down
68 changes: 40 additions & 28 deletions src/ffmpeg/ffmpeg.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const BINARY_NAME: &str = "ffmpeg.exe";
#[cfg(not(target_os = "windows"))]
const BINARY_NAME: &str = "ffmpeg";

/// FFmpeg wrapper for audio processing operations
pub struct FFmpeg<P>(P)
where
P: AsRef<Path>;
Expand All @@ -33,7 +34,6 @@ impl FFmpeg<PathBuf> {
}

/// Creates a new FFmpeg instance from a specified path
/// Will append binary name if path is a directory
pub fn new(mut path: PathBuf) -> Result<Self> {
if path.is_dir() {
path.push(BINARY_NAME);
Expand Down Expand Up @@ -65,17 +65,19 @@ impl<P: AsRef<Path>> FFmpeg<P> {
}

/// Reformats M4A audio file with optional thumbnail
/// Handles temporary file creation and cleanup automatically
pub fn reformat_m4a(
&self,
m4a: Bytes,
thumbnail: Option<DownloadedFile>,
output_path: P,
) -> Result<()> {
let tmp_audio = self.create_temp_file(&m4a)?;
let tmp_audio = NamedTempFile::with_suffix(".m4a")?;
File::create(&tmp_audio)?.write_all(&m4a)?;

let mut cmd = Command::new(self.path().as_ref());
cmd.args(&["-y", "-i", tmp_audio.path().to_str().unwrap()]);
cmd.args(&["-y", "-i", tmp_audio.path().to_str().unwrap()])
.args(&["-threads", "0"]) // Use all available CPU threads
.args(&["-preset", "ultrafast"]); // Fastest encoding preset

if let Some(thumb) = thumbnail {
self.add_thumbnail_args(&mut cmd, &thumb)?;
Expand All @@ -86,12 +88,32 @@ impl<P: AsRef<Path>> FFmpeg<P> {
self.run_command(cmd, output_path)
}

fn create_temp_file(&self, data: &Bytes) -> Result<NamedTempFile> {
let tmp = NamedTempFile::new()?;
File::create(&tmp)?.write_all(data)?;
Ok(tmp)
/// Processes M3U8 playlist data with optional thumbnail
pub fn process_m3u8(
&self,
m3u8: Bytes,
thumbnail: Option<DownloadedFile>,
output_path: P,
) -> Result<()> {
let tmp_playlist = NamedTempFile::with_suffix(".m3u8")?;
File::create(&tmp_playlist)?.write_all(&m3u8)?;

let mut cmd = Command::new(self.path().as_ref());
cmd.arg("-y")
.args(&["-protocol_whitelist", "file,http,https,tcp,tls"])
.args(&["-threads", "0"])
.args(&["-i", tmp_playlist.path().to_str().unwrap()]);

if let Some(thumb) = thumbnail {
self.add_thumbnail_args(&mut cmd, &thumb)?;
} else {
cmd.args(&["-c", "copy"]);
}

self.run_command(cmd, output_path)
}

/// Adds thumbnail metadata to FFmpeg command
fn add_thumbnail_args(&self, cmd: &mut Command, thumb: &DownloadedFile) -> Result<()> {
let tmp_thumb = NamedTempFile::new()?
.into_temp_path()
Expand All @@ -102,33 +124,23 @@ impl<P: AsRef<Path>> FFmpeg<P> {
cmd.args(&[
"-i",
tmp_thumb.to_str().unwrap(),
"-map",
"0:a",
"-map",
"1:v",
"-c:a",
"copy",
"-c:v",
"copy",
"-metadata:s:v",
"title=Album cover",
"-metadata:s:v",
"comment=Cover (front)",
"-disposition:v",
"attached_pic",
"-map", "0:a",
"-map", "1:v",
"-c:a", "copy",
"-c:v", "copy",
"-metadata:s:v", "title=Album cover",
"-metadata:s:v", "comment=Cover (front)",
"-disposition:v", "attached_pic",
]);

Ok(())
}

/// Runs FFmpeg command with common output arguments
fn run_command(&self, mut cmd: Command, output_path: P) -> Result<()> {
cmd.args(&[
"-f",
"mp4",
"-movflags",
"+faststart",
"-loglevel",
"error",
"-movflags", "+faststart",
"-loglevel", "error",
output_path.as_ref().to_str().unwrap(),
])
.stdout(Stdio::null())
Expand Down
1 change: 1 addition & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ async fn main() -> Result<()> {

let downloader = Downloader::new(client, &output, ffmpeg)?;
downloader.download_playlist(playlist).await?;

tracing::info!("Playlist download completed successfully!");
}
None => {
Expand Down
2 changes: 2 additions & 0 deletions src/soundcloud/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ pub struct Like {

#[derive(Clone, Debug, Deserialize)]
pub struct Playlist {
pub id: u64,
pub permalink: String,
pub permalink_url: String,
pub title: String,
Expand Down Expand Up @@ -76,6 +77,7 @@ pub struct Transcoding {
#[derive(Clone, Debug, Deserialize)]
pub struct Format {
pub protocol: String,
pub mime_type: String,
}

#[derive(Clone, Debug, Deserialize)]
Expand Down
Loading

0 comments on commit fb1af47

Please sign in to comment.