use crate::app::structures::{AlbumOrUploadAlbumID, ListSong, ListSongAlbum};
use crate::core::create_or_clean_directory;
use crate::get_data_dir;
use anyhow::{Context, anyhow};
use async_cell::sync::AsyncCell;
use futures::FutureExt;
use futures::future::try_join;
use rusty_ytdl::reqwest;
use std::path::PathBuf;
use std::sync::Arc;
use tracing::{error, info};
use ytmapi_rs::common::{AlbumID, UploadAlbumID, VideoID, YoutubeID};
const ALBUM_ART_DIR_PATH: &str = "album_art";
const ALBUM_ART_FILENAME_PREFIX: &str = "YAA_";
const ALBUM_ART_IMAGE_MAX_AGE: std::time::Duration =
std::time::Duration::from_secs(60 * 60 * 24 * 10);
fn get_album_art_dir() -> anyhow::Result<PathBuf> {
get_data_dir().map(|dir| dir.join(ALBUM_ART_DIR_PATH))
}
#[derive(Clone, Hash, PartialEq, Eq, Debug)]
pub enum SongThumbnailID<'a> {
Album(AlbumID<'a>),
UploadAlbum(UploadAlbumID<'a>),
Video(VideoID<'a>),
}
impl<'a> From<&'a ListSong> for SongThumbnailID<'a> {
fn from(song: &'a ListSong) -> SongThumbnailID<'a> {
match song.album.as_deref() {
Some(ListSongAlbum {
id: AlbumOrUploadAlbumID::Album(a),
..
}) => SongThumbnailID::Album(a.into()),
Some(ListSongAlbum {
id: AlbumOrUploadAlbumID::UploadAlbum(a),
..
}) => SongThumbnailID::UploadAlbum(a.into()),
None => SongThumbnailID::Video((&song.video_id).into()),
}
}
}
impl std::fmt::Display for SongThumbnailID<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SongThumbnailID::Album(id) => write!(f, "A_{}", id.get_raw()),
SongThumbnailID::UploadAlbum(id) => write!(f, "U_{}", id.get_raw()),
SongThumbnailID::Video(id) => write!(f, "V_{}", id.get_raw()),
}
}
}
impl<'a> SongThumbnailID<'a> {
pub fn into_owned(self) -> SongThumbnailID<'static> {
match self {
SongThumbnailID::Album(id) => {
let id_string = id.get_raw().to_owned();
SongThumbnailID::Album(AlbumID::from_raw(id_string))
}
SongThumbnailID::UploadAlbum(id) => {
let id_string = id.get_raw().to_owned();
SongThumbnailID::UploadAlbum(UploadAlbumID::from_raw(id_string))
}
SongThumbnailID::Video(id) => {
let id_string = id.get_raw().to_owned();
SongThumbnailID::Video(VideoID::from_raw(id_string))
}
}
}
}
#[derive(PartialEq)]
pub struct SongThumbnail {
pub in_mem_image: image::DynamicImage,
pub on_disk_path: std::path::PathBuf,
pub song_thumbnail_id: SongThumbnailID<'static>,
}
impl std::fmt::Debug for SongThumbnail {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AlbumArt")
.field("in_mem_image", &"image::DynamicImage")
.field("on_disk_path", &self.on_disk_path)
.field("song_thumbnail_id", &self.song_thumbnail_id)
.finish()
}
}
pub struct SongThumbnailDownloader {
client: reqwest::Client,
status: Arc<AsyncCell<Result<(), String>>>,
}
impl SongThumbnailDownloader {
pub fn new(client: reqwest::Client) -> Self {
let status = AsyncCell::new().into_shared();
let status_clone = status.clone();
tokio::spawn(async move {
info!("Setting up and cleaning album art directory");
let Ok(album_art_dir) = get_album_art_dir() else {
status_clone.set(Err("Error getting album art dir".to_string()));
return;
};
match create_or_clean_directory(
&album_art_dir,
ALBUM_ART_FILENAME_PREFIX,
ALBUM_ART_IMAGE_MAX_AGE,
)
.await
{
Ok(n) => {
info!("Cleaned up {n} old album art files");
status_clone.set(Ok(()));
}
Err(e) => {
error!("Error {e} setting up and cleaning album art directory");
status_clone.set(Err(format!("{e}")))
}
}
});
Self { client, status }
}
pub async fn download_song_thumbnail(
&self,
thumbnail_id: SongThumbnailID<'static>,
thumbnail_url: String,
) -> anyhow::Result<SongThumbnail> {
self.status.get().await.map_err(|e| anyhow!(e))?;
let url = reqwest::Url::parse(&thumbnail_url)?;
let image_bytes = self.client.get(url).send().await?.bytes().await?;
let image_reader = image::ImageReader::new(std::io::Cursor::new(image_bytes.clone()))
.with_guessed_format()?;
let image_format = image_reader
.format()
.context("Unable to determine album art image format")?;
let on_disk_path = get_album_art_dir()?
.join(format!("{}{}", ALBUM_ART_FILENAME_PREFIX, thumbnail_id))
.with_extension(image_format.extensions_str()[0]);
let image_decoding_task = tokio::task::spawn_blocking(|| image_reader.decode());
let (in_mem_image, _) = try_join(
image_decoding_task.map(|res| res.map_err(anyhow::Error::from)),
tokio::fs::write(&on_disk_path, image_bytes)
.map(|res| res.map_err(anyhow::Error::from)),
)
.await?;
Ok(SongThumbnail {
in_mem_image: in_mem_image?,
on_disk_path,
song_thumbnail_id: thumbnail_id,
})
}
}