forked from
rocksky.app/rocksky
A decentralized music tracking and discovery platform built on AT Protocol 馃幍
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}