A decentralized music tracking and discovery platform built on AT Protocol 馃幍
at feat/discord-webhook 454 lines 15 kB view raw
1use std::{env, fs::File, io::Write, path::Path, sync::Arc}; 2 3use anyhow::Error; 4use futures::future::BoxFuture; 5use lofty::{ 6 file::TaggedFileExt, 7 picture::{MimeType, Picture}, 8 probe::Probe, 9 tag::Accessor, 10}; 11use owo_colors::OwoColorize; 12use reqwest::{multipart, Client}; 13use serde_json::json; 14use sqlx::{Pool, Postgres}; 15use symphonia::core::{ 16 formats::FormatOptions, io::MediaSourceStream, meta::MetadataOptions, probe::Hint, 17}; 18use tempfile::TempDir; 19 20use crate::{ 21 client::{get_access_token, BASE_URL, CONTENT_URL}, 22 consts::AUDIO_EXTENSIONS, 23 crypto::decrypt_aes_256_ctr, 24 repo::{ 25 dropbox_directory::create_dropbox_directory, 26 dropbox_path::create_dropbox_path, 27 dropbox_token::{find_dropbox_refresh_token, find_dropbox_refresh_tokens}, 28 track::get_track_by_hash, 29 }, 30 token::generate_token, 31 types::file::{Entry, EntryList}, 32}; 33 34pub async fn scan_dropbox(pool: Arc<Pool<Postgres>>) -> Result<(), Error> { 35 let refresh_tokens = find_dropbox_refresh_tokens(&pool).await?; 36 for token in refresh_tokens { 37 let refresh_token = decrypt_aes_256_ctr( 38 &token.refresh_token, 39 &hex::decode(env::var("SPOTIFY_ENCRYPTION_KEY")?)?, 40 )?; 41 scan_audio_files( 42 pool.clone(), 43 "/Music".to_string(), 44 refresh_token, 45 token.did, 46 token.xata_id, 47 ) 48 .await?; 49 } 50 Ok(()) 51} 52 53pub async fn scan_folder(pool: Arc<Pool<Postgres>>, did: &str, path: &str) -> Result<(), Error> { 54 let refresh_tokens = find_dropbox_refresh_token(&pool, did).await?; 55 if let Some((refresh_token, dropbox_id)) = refresh_tokens { 56 let refresh_token = decrypt_aes_256_ctr( 57 &refresh_token, 58 &hex::decode(env::var("SPOTIFY_ENCRYPTION_KEY")?)?, 59 )?; 60 61 scan_audio_files( 62 pool.clone(), 63 path.to_string(), 64 refresh_token, 65 did.to_string(), 66 dropbox_id, 67 ) 68 .await?; 69 } 70 Ok(()) 71} 72 73pub fn scan_audio_files( 74 pool: Arc<Pool<Postgres>>, 75 path: String, 76 refresh_token: String, 77 did: String, 78 dropbox_id: String, 79) -> BoxFuture<'static, Result<(), Error>> { 80 Box::pin(async move { 81 let res = get_access_token(&refresh_token).await?; 82 let access_token = res.access_token; 83 84 let client = Client::new(); 85 86 let res = client 87 .post(&format!("{}/files/get_metadata", BASE_URL)) 88 .bearer_auth(&access_token) 89 .json(&json!({ "path": path })) 90 .send() 91 .await?; 92 93 if res.status().as_u16() == 400 || res.status().as_u16() == 409 { 94 println!("Path not found: {}", path.bright_red()); 95 return Ok(()); 96 } 97 98 let entry = res.json::<Entry>().await?; 99 100 if entry.tag.clone().unwrap().as_str() == "folder" { 101 println!("Scanning folder: {}", path.bright_green()); 102 103 let parent_path = Path::new(&path) 104 .parent() 105 .map(|p| p.to_string_lossy().to_string()) 106 .unwrap_or_else(|| "".to_string()); 107 108 create_dropbox_directory(&pool, &entry, &dropbox_id, &parent_path).await?; 109 110 // TODO: publish folder metadata to nats 111 112 let mut entries: Vec<Entry> = Vec::new(); 113 114 let res = client 115 .post(&format!("{}/files/list_folder", BASE_URL)) 116 .bearer_auth(&access_token) 117 .json(&json!({ "path": path })) 118 .send() 119 .await?; 120 121 let mut entry_list = res.json::<EntryList>().await?; 122 entries.extend(entry_list.entries); 123 124 // Handle pagination using list_folder/continue 125 while entry_list.has_more { 126 let res = client 127 .post(&format!("{}/files/list_folder/continue", BASE_URL)) 128 .bearer_auth(&access_token) 129 .json(&json!({ "cursor": entry_list.cursor })) 130 .send() 131 .await?; 132 133 tokio::time::sleep(std::time::Duration::from_secs(1)).await; 134 135 entry_list = res.json::<EntryList>().await?; 136 entries.extend(entry_list.entries); 137 } 138 139 for entry in entries { 140 scan_audio_files( 141 pool.clone(), 142 entry.path_display, 143 refresh_token.clone(), 144 did.clone(), 145 dropbox_id.clone(), 146 ) 147 .await?; 148 tokio::time::sleep(std::time::Duration::from_secs(1)).await; 149 } 150 151 return Ok(()); 152 } 153 154 if !AUDIO_EXTENSIONS 155 .into_iter() 156 .any(|ext| path.ends_with(&format!(".{}", ext))) 157 { 158 return Ok(()); 159 } 160 161 let client = Client::new(); 162 163 println!("Downloading file: {}", path.bright_green()); 164 165 let res = client 166 .post(&format!("{}/files/download", CONTENT_URL)) 167 .bearer_auth(&access_token) 168 .header("Dropbox-API-Arg", &json!({ "path": path }).to_string()) 169 .send() 170 .await?; 171 172 let bytes = res.bytes().await?; 173 174 let temp_dir = TempDir::new()?; 175 let tmppath = temp_dir.path().join(&format!("{}", entry.name)); 176 let mut tmpfile = File::create(&tmppath)?; 177 tmpfile.write_all(&bytes)?; 178 179 println!( 180 "Reading file: {}", 181 &tmppath.clone().display().to_string().bright_green() 182 ); 183 184 let tagged_file = match Probe::open(&tmppath)?.read() { 185 Ok(tagged_file) => tagged_file, 186 Err(e) => { 187 println!("Error opening file: {}", e); 188 return Ok(()); 189 } 190 }; 191 192 let primary_tag = tagged_file.primary_tag(); 193 let tag = match primary_tag { 194 Some(tag) => tag, 195 None => { 196 println!("No tag found in file"); 197 return Ok(()); 198 } 199 }; 200 201 let pictures = tag.pictures(); 202 203 println!( 204 "Title: {}", 205 tag.get_string(&lofty::tag::ItemKey::TrackTitle) 206 .unwrap_or_default() 207 .bright_green() 208 ); 209 println!( 210 "Artist: {}", 211 tag.get_string(&lofty::tag::ItemKey::TrackArtist) 212 .unwrap_or_default() 213 .bright_green() 214 ); 215 println!( 216 "Album Artist: {}", 217 tag.get_string(&lofty::tag::ItemKey::AlbumArtist) 218 .unwrap_or_default() 219 .bright_green() 220 ); 221 println!( 222 "Album: {}", 223 tag.get_string(&lofty::tag::ItemKey::AlbumTitle) 224 .unwrap_or_default() 225 .bright_green() 226 ); 227 println!( 228 "Lyrics: {}", 229 tag.get_string(&lofty::tag::ItemKey::Lyrics) 230 .unwrap_or_default() 231 .bright_green() 232 ); 233 println!("Year: {}", tag.year().unwrap_or_default().bright_green()); 234 println!( 235 "Track Number: {}", 236 tag.track().unwrap_or_default().bright_green() 237 ); 238 println!( 239 "Track Total: {}", 240 tag.track_total().unwrap_or_default().bright_green() 241 ); 242 println!( 243 "Release Date: {:?}", 244 tag.get_string(&lofty::tag::ItemKey::OriginalReleaseDate) 245 .unwrap_or_default() 246 .bright_green() 247 ); 248 println!( 249 "Recording Date: {:?}", 250 tag.get_string(&lofty::tag::ItemKey::RecordingDate) 251 .unwrap_or_default() 252 .bright_green() 253 ); 254 println!( 255 "Copyright Message: {}", 256 tag.get_string(&lofty::tag::ItemKey::CopyrightMessage) 257 .unwrap_or_default() 258 .bright_green() 259 ); 260 println!("Pictures: {:?}", pictures); 261 262 let title = tag 263 .get_string(&lofty::tag::ItemKey::TrackTitle) 264 .unwrap_or_default(); 265 let artist = tag 266 .get_string(&lofty::tag::ItemKey::TrackArtist) 267 .unwrap_or_default(); 268 let album = tag 269 .get_string(&lofty::tag::ItemKey::AlbumTitle) 270 .unwrap_or_default(); 271 let album_artist = tag 272 .get_string(&lofty::tag::ItemKey::AlbumArtist) 273 .unwrap_or_default(); 274 275 let access_token = generate_token(&did)?; 276 277 // check if track exists 278 // 279 // if not, create track 280 // upload album art 281 // 282 // link path to track 283 284 let hash = sha256::digest(format!("{} - {} - {}", title, artist, album).to_lowercase()); 285 286 let track = get_track_by_hash(&pool, &hash).await?; 287 let duration = get_track_duration(&tmppath).await?; 288 let albumart_id = md5::compute(&format!("{} - {}", album_artist, album).to_lowercase()); 289 let albumart_id = format!("{:x}", albumart_id); 290 291 match track { 292 Some(track) => { 293 println!("Track exists: {}", title.bright_green()); 294 let parent_path = Path::new(&path) 295 .parent() 296 .map(|p| p.to_string_lossy().to_string()); 297 let status = 298 create_dropbox_path(&pool, &entry, &track, &dropbox_id, parent_path).await; 299 println!("status: {:?}", status); 300 301 // TODO: publish file metadata to nats 302 } 303 None => { 304 println!("Creating track: {}", title.bright_green()); 305 let album_art = 306 upload_album_cover(albumart_id.into(), pictures, &access_token).await?; 307 let client = Client::new(); 308 const URL: &str = "https://api.rocksky.app/tracks"; 309 let response = client 310 .post(URL) 311 .header("Authorization", format!("Bearer {}", access_token)) 312 .json(&serde_json::json!({ 313 "title": tag.get_string(&lofty::tag::ItemKey::TrackTitle), 314 "album": tag.get_string(&lofty::tag::ItemKey::AlbumTitle), 315 "artist": tag.get_string(&lofty::tag::ItemKey::TrackArtist), 316 "albumArtist": match tag.get_string(&lofty::tag::ItemKey::AlbumArtist) { 317 Some(album_artist) => Some(album_artist), 318 None => Some(tag.get_string(&lofty::tag::ItemKey::TrackArtist).unwrap_or_default()), 319 }, 320 "duration": duration, 321 "trackNumber": tag.track(), 322 "releaseDate": tag.get_string(&lofty::tag::ItemKey::OriginalReleaseDate).map(|date| match date.contains("-") { 323 true => Some(date), 324 false => None, 325 }), 326 "year": tag.year(), 327 "discNumber": tag.disk().map(|disc| match disc { 328 0 => Some(1), 329 _ => Some(disc), 330 }).unwrap_or(Some(1)), 331 "composer": tag.get_string(&lofty::tag::ItemKey::Composer), 332 "albumArt": match album_art{ 333 Some(album_art) => Some(format!("https://cdn.rocksky.app/covers/{}", album_art)), 334 None => None 335 }, 336 "lyrics": tag.get_string(&lofty::tag::ItemKey::Lyrics), 337 "copyrightMessage": tag.get_string(&lofty::tag::ItemKey::CopyrightMessage), 338 })) 339 .send() 340 .await?; 341 println!("Track Saved: {} {}", title, response.status()); 342 tokio::time::sleep(std::time::Duration::from_secs(3)).await; 343 344 let track = get_track_by_hash(&pool, &hash).await?; 345 if let Some(track) = track { 346 let parent_path = Path::new(&path) 347 .parent() 348 .map(|p| p.to_string_lossy().to_string()); 349 create_dropbox_path(&pool, &entry, &track, &dropbox_id, parent_path).await?; 350 351 // TODO: publish file metadata to nats 352 353 return Ok(()); 354 } 355 356 println!("Failed to create track: {}", title.bright_green()); 357 } 358 } 359 360 Ok(()) 361 }) 362} 363 364pub async fn upload_album_cover( 365 name: String, 366 pictures: &[Picture], 367 token: &str, 368) -> Result<Option<String>, Error> { 369 if pictures.is_empty() { 370 return Ok(None); 371 } 372 373 let picture = &pictures[0]; 374 375 let buffer = match picture.mime_type() { 376 Some(MimeType::Jpeg) => Some(picture.data().to_vec()), 377 Some(MimeType::Png) => Some(picture.data().to_vec()), 378 Some(MimeType::Gif) => Some(picture.data().to_vec()), 379 Some(MimeType::Bmp) => Some(picture.data().to_vec()), 380 Some(MimeType::Tiff) => Some(picture.data().to_vec()), 381 _ => None, 382 }; 383 384 if buffer.is_none() { 385 return Ok(None); 386 } 387 388 let buffer = buffer.unwrap(); 389 390 let ext = match picture.mime_type() { 391 Some(MimeType::Jpeg) => "jpg", 392 Some(MimeType::Png) => "png", 393 Some(MimeType::Gif) => "gif", 394 Some(MimeType::Bmp) => "bmp", 395 Some(MimeType::Tiff) => "tiff", 396 _ => { 397 return Ok(None); 398 } 399 }; 400 401 let name = format!("{}.{}", name, ext); 402 403 let part = multipart::Part::bytes(buffer).file_name(name.clone()); 404 let form = multipart::Form::new().part("file", part); 405 let client = Client::new(); 406 407 const URL: &str = "https://uploads.rocksky.app"; 408 409 let response = client 410 .post(URL) 411 .header("Authorization", format!("Bearer {}", token)) 412 .multipart(form) 413 .send() 414 .await?; 415 416 println!("Cover uploaded: {}", response.status()); 417 418 Ok(Some(name)) 419} 420 421pub async fn get_track_duration(path: &Path) -> Result<u64, Error> { 422 let duration = 0; 423 let media_source = 424 MediaSourceStream::new(Box::new(std::fs::File::open(path)?), Default::default()); 425 let mut hint = Hint::new(); 426 427 if let Some(extension) = path.extension() { 428 if let Some(extension) = extension.to_str() { 429 hint.with_extension(extension); 430 } 431 } 432 433 let meta_opts = MetadataOptions::default(); 434 let format_opts = FormatOptions::default(); 435 436 let probed = 437 match symphonia::default::get_probe().format(&hint, media_source, &format_opts, &meta_opts) 438 { 439 Ok(probed) => probed, 440 Err(_) => { 441 println!("Error probing file"); 442 return Ok(duration); 443 } 444 }; 445 446 if let Some(track) = probed.format.tracks().first() { 447 if let Some(duration) = track.codec_params.n_frames { 448 if let Some(sample_rate) = track.codec_params.sample_rate { 449 return Ok((duration as f64 / sample_rate as f64) as u64 * 1000); 450 } 451 } 452 } 453 Ok(duration) 454}