1pub mod config;
2mod error;
3mod state;
4mod utils;
5
6use crate::config::WorkspaceConfig;
7use anni_common::fs;
8use anni_repo::library::file_name;
9use anni_repo::prelude::{AnniDate, UNKNOWN_ARTIST};
10use anni_repo::RepositoryManager;
11use config::LibraryConfig;
12use std::borrow::Cow;
13use std::collections::BTreeMap;
14use std::num::NonZeroU8;
15use std::path::{Path, PathBuf};
16use std::str::FromStr;
17use utils::lock::WorkspaceAlbumLock;
18use uuid::Uuid;
19
20pub use error::WorkspaceError;
21pub use state::*;
22
23const IGNORED_LIST: [&str; 2] = [
24 ".directory", ".DS_Store", ];
27
28pub struct AnniWorkspace {
29 dot_anni: PathBuf,
31}
32
33impl AnniWorkspace {
34 pub unsafe fn new_unchecked(dot_anni: PathBuf) -> Self {
38 AnniWorkspace { dot_anni }
39 }
40
41 pub fn new() -> Result<Self, WorkspaceError> {
43 Self::find(std::env::current_dir()?)
44 }
45
46 pub fn open<P>(path: P) -> Result<Self, WorkspaceError>
50 where
51 P: AsRef<Path>,
52 {
53 let dot_anni = path.as_ref().join(".anni");
54 if dot_anni.exists() {
55 let config_path = dot_anni.join("config.toml");
56 if config_path.exists() {
57 return Ok(Self { dot_anni });
58 }
59 }
60
61 Err(WorkspaceError::NotAWorkspace)
62 }
63
64 pub fn find<P>(path: P) -> Result<Self, WorkspaceError>
69 where
70 P: AsRef<Path>,
71 {
72 let mut path = path.as_ref();
73 loop {
74 let workspace = Self::open(path);
75 if workspace.is_ok() {
76 return workspace;
77 }
78 path = path.parent().ok_or(WorkspaceError::WorkspaceNotFound)?;
79 }
80 }
81
82 pub fn workspace_root(&self) -> &Path {
84 self.dot_anni.parent().unwrap()
85 }
86
87 pub fn repo_root(&self) -> PathBuf {
92 self.dot_anni.join("repo")
93 }
94
95 pub fn objects_root(&self) -> PathBuf {
97 self.dot_anni.join("objects")
98 }
99
100 pub fn get_album_id<P>(&self, path: P) -> Result<Uuid, WorkspaceError>
104 where
105 P: AsRef<Path>,
106 {
107 let album_path = path.as_ref().join(".album");
108
109 if !album_path.is_symlink() {
112 return Err(WorkspaceError::NotAnAlbum(path.as_ref().to_path_buf()));
113 }
114
115 let real_path = fs::read_link(album_path)?;
117 let album_id = real_path.file_name().unwrap().to_string_lossy();
118 let album_id = Uuid::parse_str(&album_id)?;
119 Ok(album_id)
120 }
121
122 pub fn get_album_controlled_path(&self, album_id: &Uuid) -> Result<PathBuf, WorkspaceError> {
124 let path = self.controlled_album_path(album_id, 2);
125 if !path.exists() {
126 return Err(WorkspaceError::AlbumNotFound(*album_id));
127 }
128
129 Ok(path)
130 }
131
132 pub fn controlled_album_path(&self, album_id: &Uuid, layer: usize) -> PathBuf {
134 AnniWorkspace::strict_album_path(self.objects_root(), album_id, layer)
135 }
136
137 pub fn strict_album_path(mut root: PathBuf, album_id: &Uuid, layer: usize) -> PathBuf {
138 let bytes = album_id.as_bytes();
139
140 for byte in &bytes[0..layer] {
141 root.push(format!("{byte:x}"));
142 }
143 root.push(album_id.to_string());
144
145 root
146 }
147
148 pub fn get_workspace_album<P>(&self, path: P) -> Result<WorkspaceAlbum, WorkspaceError>
150 where
151 P: AsRef<Path>,
152 {
153 let album_id = self.get_album_id(path.as_ref())?;
154 let path = path.as_ref().to_path_buf();
155
156 let album_controlled_path = self.get_album_controlled_path(&album_id);
158 Ok(WorkspaceAlbum {
159 album_id,
160 state: match album_controlled_path {
161 Ok(controlled_path) => {
162 if !path.join(".album").exists() {
163 WorkspaceAlbumState::Dangling(path)
165 } else if fs::read_dir(controlled_path)?.next().is_some() {
166 WorkspaceAlbumState::Committed(path)
168 } else {
169 WorkspaceAlbumState::Untracked(path)
171 }
172 }
173 Err(WorkspaceError::AlbumNotFound(_)) => WorkspaceAlbumState::Dangling(path),
175 _ => unreachable!(),
176 },
177 })
178 }
179
180 pub fn scan(&self) -> Result<Vec<WorkspaceAlbum>, WorkspaceError> {
182 let mut albums = BTreeMap::new();
183 self.scan_userland_directory(&mut albums, self.workspace_root())?;
184 self.scan_controlled_directory(&mut albums, self.objects_root(), 2)?;
185 Ok(albums.into_values().collect())
186 }
187
188 fn scan_userland_directory<P>(
190 &self,
191 albums: &mut BTreeMap<Uuid, WorkspaceAlbum>,
192 path: P,
193 ) -> Result<(), WorkspaceError>
194 where
195 P: AsRef<Path>,
196 {
197 for entry in fs::read_dir(path.as_ref())? {
198 let entry = entry?;
199 if entry.file_name() == ".anni" {
200 continue;
201 }
202
203 let metadata = entry.metadata()?;
204 if metadata.is_dir() {
205 match self.get_workspace_album(entry.path()) {
207 Ok(album) => {
209 albums.insert(album.album_id.clone(), album);
210 }
211 Err(WorkspaceError::NotAnAlbum(path)) => {
213 self.scan_userland_directory(albums, path)?
214 }
215 Err(e) => return Err(e),
216 }
217 }
218 }
219
220 Ok(())
221 }
222
223 fn scan_controlled_directory<P>(
225 &self,
226 albums: &mut BTreeMap<Uuid, WorkspaceAlbum>,
227 parent: P,
228 level: u8,
229 ) -> Result<(), WorkspaceError>
230 where
231 P: AsRef<Path>,
232 {
233 let parent = parent.as_ref();
234 for entry in fs::read_dir(parent)? {
235 let entry = entry?;
236 let path = entry.path();
237 if path.is_dir() {
238 if level > 0 {
239 self.scan_controlled_directory(albums, path, level - 1)?;
240 } else {
241 let album_id = file_name(&path)?;
242 let album_id = Uuid::from_str(&album_id)?;
243 let is_published = path.join(".publish").exists();
244 albums.entry(album_id).or_insert_with(|| WorkspaceAlbum {
245 album_id,
246 state: if is_published {
247 WorkspaceAlbumState::Published
248 } else {
249 WorkspaceAlbumState::Garbage
250 },
251 });
252 }
253 }
254 }
255 Ok(())
256 }
257
258 pub fn create_album<P>(
264 &self,
265 album_id: &Uuid,
266 userland_path: P,
267 discs: NonZeroU8,
268 ) -> Result<(), WorkspaceError>
269 where
270 P: AsRef<Path>,
271 {
272 let controlled_path = self.controlled_album_path(album_id, 2);
273 if controlled_path.exists() {
274 return Err(WorkspaceError::DuplicatedAlbumId(*album_id));
275 }
276
277 if let Ok(album_id) = self.get_album_id(userland_path.as_ref()) {
278 return Err(WorkspaceError::AlbumExists {
280 album_id,
281 path: userland_path.as_ref().to_path_buf(),
282 });
283 }
284
285 fs::create_dir_all(&controlled_path)?;
287 fs::create_dir_all(&userland_path)?;
288 fs::symlink_dir(&controlled_path, userland_path.as_ref().join(".album"))?;
289
290 let discs = discs.get();
292 if discs > 1 {
293 for i in 1..=discs {
294 let disc_path = userland_path.as_ref().join(format!("Disc {i}"));
295 fs::create_dir_all(&disc_path)?;
296 }
297 }
298
299 Ok(())
300 }
301
302 pub fn to_repository_manager(&self) -> Result<RepositoryManager, WorkspaceError> {
303 Ok(RepositoryManager::new(self.repo_root())?)
304 }
305
306 pub fn get_config(&self) -> Result<WorkspaceConfig, WorkspaceError> {
307 WorkspaceConfig::new(&self.dot_anni)
308 }
309}
310
311pub struct ExtractedAlbumInfo<'a> {
312 pub release_date: AnniDate,
313 pub catalog: Cow<'a, str>,
314 pub title: Cow<'a, str>,
315 pub edition: Option<Cow<'a, str>>,
316}
317
318impl AnniWorkspace {
320 fn album_disc_cover_path<P>(path: P) -> PathBuf
324 where
325 P: AsRef<Path>,
326 {
327 path.as_ref().join("cover.jpg")
328 }
329
330 pub fn get_untracked_album_overview<P>(
334 &self,
335 album_path: P,
336 ) -> Result<UntrackedWorkspaceAlbum, WorkspaceError>
337 where
338 P: AsRef<Path>,
339 {
340 let album = self.get_workspace_album(album_path.as_ref())?;
341 let album_path = match album.state {
342 WorkspaceAlbumState::Untracked(p) => p,
344 state => {
345 return Err(WorkspaceError::InvalidAlbumState(state));
346 }
347 };
348
349 let album_cover = AnniWorkspace::album_disc_cover_path(&album_path);
351 if !album_cover.exists() {
352 return Err(WorkspaceError::CoverNotFound(album_cover));
353 }
354
355 let flac_in_album_root = fs::get_ext_file(&album_path, "flac", false)?.is_some();
357 let mut discs = fs::get_subdirectories(&album_path)?;
358
359 if flac_in_album_root ^ discs.is_empty() {
363 return Err(WorkspaceError::InvalidAlbumDiscStructure(
365 album_path.clone(),
366 ));
367 }
368
369 if flac_in_album_root {
371 discs.push(album_path.clone());
372 }
373
374 alphanumeric_sort::sort_path_slice(&mut discs);
375 let discs = discs
376 .into_iter()
377 .enumerate()
378 .map(|(index, disc_path)| {
379 let index = index + 1;
380
381 let mut files = fs::read_dir(&disc_path)?
383 .filter_map(|e| {
384 e.ok().and_then(|e| {
385 let path = e.path();
386 if e.file_type().ok()?.is_file() {
387 if let Some(ext) = path.extension() {
388 if ext == "flac" {
389 return Some(path);
390 }
391 }
392 }
393 None
394 })
395 })
396 .collect::<Vec<_>>();
397 alphanumeric_sort::sort_path_slice(&mut files);
398
399 let disc_cover = AnniWorkspace::album_disc_cover_path(&disc_path);
400 if !disc_cover.exists() {
401 return Err(WorkspaceError::CoverNotFound(disc_cover));
402 }
403
404 Ok(UntrackedWorkspaceDisc {
405 index,
406 path: disc_path,
407 cover: disc_cover,
408 tracks: files,
409 })
410 })
411 .collect::<Result<Vec<_>, WorkspaceError>>()?;
412
413 Ok(UntrackedWorkspaceAlbum {
414 album_id: album.album_id,
415 path: album_path,
416 simplified: flac_in_album_root,
417 discs,
418 })
419 }
420
421 pub fn commit<P, V>(&self, path: P, validator: Option<V>) -> Result<Uuid, WorkspaceError>
425 where
426 P: AsRef<Path>,
427 V: FnOnce(&UntrackedWorkspaceAlbum) -> bool,
428 {
429 let album = self.get_untracked_album_overview(path)?;
430
431 let lock = WorkspaceAlbumLock::new(&album.path)?;
433
434 if let Some(validator) = validator {
435 let pass = validator(&album);
436 if !pass {
437 return Err(WorkspaceError::UserAborted);
438 }
439 }
440
441 let album_id = album.album_id;
442 let album_path = album.path;
443
444 lock.lock()?;
447
448 let album_cover = AnniWorkspace::album_disc_cover_path(&album_path);
450 let album_controlled_path = self.get_album_controlled_path(&album_id)?;
451 let album_cover_controlled = AnniWorkspace::album_disc_cover_path(&album_controlled_path);
452 if album.simplified {
453 fs::copy(&album_cover, &album_cover_controlled)?;
455 } else {
456 fs::rename(&album_cover, &album_cover_controlled)?;
458 fs::symlink_file(&album_cover_controlled, &album_cover)?;
459 }
460
461 for disc in album.discs.iter() {
463 let disc_controlled_path = album_controlled_path.join(disc.index.to_string());
464 fs::create_dir_all(&disc_controlled_path)?;
465
466 for (index, track_path) in disc.tracks.iter().enumerate() {
468 let index = index + 1;
469 let track_controlled_path = disc_controlled_path.join(format!("{index}.flac"));
470 fs::rename(track_path, &track_controlled_path)?;
471 fs::symlink_file(&track_controlled_path, track_path)?;
472 }
473
474 let disc_cover_controlled_path =
476 AnniWorkspace::album_disc_cover_path(&disc_controlled_path);
477 fs::rename(&disc.cover, &disc_cover_controlled_path)?;
478 fs::symlink_file(&disc_cover_controlled_path, &disc.cover)?;
479 }
480
481 Ok(album_id)
482 }
483
484 pub fn import_tags<P, E>(
486 &self,
487 album_path: P,
488 extractor: E,
489 allow_duplicate: bool,
490 ) -> Result<Uuid, WorkspaceError>
491 where
492 P: AsRef<Path>,
493 E: FnOnce(&str) -> Option<ExtractedAlbumInfo>,
494 {
495 use anni_repo::prelude::{Album, AlbumInfo, Disc, DiscInfo};
496
497 let album_id = self.get_album_id(album_path.as_ref())?;
498 let repo = self.to_repository_manager()?;
499 let folder_name = file_name(&album_path)?;
500 let ExtractedAlbumInfo {
501 release_date,
502 catalog,
503 title,
504 edition,
505 ..
506 } = extractor(&folder_name).ok_or_else(|| WorkspaceError::FailedToExtractAlbumInfo)?;
507
508 let album_path = self.get_album_controlled_path(&album_id)?;
509 let mut discs = Vec::new();
510 loop {
511 let disc_id = discs.len() + 1;
512 let disc_path = album_path.join(disc_id.to_string());
513 if !disc_path.exists() {
514 break;
515 }
516
517 let mut tracks = Vec::new();
518 loop {
519 let track_id = tracks.len() + 1;
520 let track_path = disc_path.join(format!("{track_id}.flac"));
521 if !track_path.exists() {
522 break;
523 }
524
525 let flac = anni_flac::FlacHeader::from_file(&track_path).map_err(|error| {
526 WorkspaceError::FlacError {
527 path: track_path,
528 error,
529 }
530 })?;
531 tracks.push(flac.into())
532 }
533 discs.push(Disc::new(
534 DiscInfo::new(
535 catalog.to_string(),
536 None,
537 None,
538 None,
539 None,
540 Default::default(),
541 ),
542 tracks,
543 ));
544 }
545
546 let album = Album::new(
547 AlbumInfo {
548 album_id,
549 title: title.to_string(),
550 edition: edition.map(|c| c.to_string()),
551 artist: UNKNOWN_ARTIST.to_string(),
552 release_date,
553 catalog: catalog.to_string(),
554 ..Default::default()
555 },
556 discs,
557 );
558 repo.add_album(album, allow_duplicate)?;
559
560 Ok(album_id)
561 }
562
563 pub fn revert<P>(&self, path: P) -> Result<(), WorkspaceError>
564 where
565 P: AsRef<Path>,
566 {
567 let album = self.get_workspace_album(path)?;
568 match album.state {
569 WorkspaceAlbumState::Committed(album_path) => {
570 let lock = WorkspaceAlbumLock::new(&album_path)?;
571 lock.lock()?;
572
573 let album_controlled_path = self.get_album_controlled_path(&album.album_id)?;
574
575 AnniWorkspace::recover_symlinks(&album_path)?;
577
578 fs::remove_dir_all(&album_controlled_path, true)?;
580 fs::create_dir_all(&album_controlled_path)?;
581
582 Ok(())
583 }
584 state => Err(WorkspaceError::InvalidAlbumState(state)),
585 }
586 }
587
588 fn recover_symlinks<P: AsRef<Path>>(path: P) -> Result<(), WorkspaceError> {
589 log::debug!("Recovering path: {}", path.as_ref().display());
590 let metadata = fs::symlink_metadata(path.as_ref())?;
591 if metadata.is_symlink() {
592 if let Some(file_name) = path.as_ref().file_name() {
594 if file_name == ".album" {
595 return Ok(());
596 }
597 }
598
599 let actual_path = fs::canonicalize(path.as_ref())?;
601 log::debug!("Actual path: {}", actual_path.display());
602 fs::rename(actual_path, path)?;
603 } else if metadata.is_dir() {
604 for entry in path.as_ref().read_dir()? {
605 let entry = entry?;
606 AnniWorkspace::recover_symlinks(entry.path())?;
607 }
608 }
609
610 Ok(())
611 }
612
613 pub fn apply_tags<P>(&self, album_path: P) -> Result<(), WorkspaceError>
614 where
615 P: AsRef<Path>,
616 {
617 let album_id = self.get_album_id(album_path)?;
618 let controlled_album_path = self.get_album_controlled_path(&album_id)?;
619
620 let repo = self.to_repository_manager()?;
621 let repo = repo.into_owned_manager()?;
622
623 let album = repo
625 .album(&album_id)
626 .expect("Album not found in metadata repository");
627 album.apply_strict(controlled_album_path)?;
628
629 Ok(())
630 }
631
632 pub fn publish<P>(&self, album_path: P, soft: bool) -> Result<(), WorkspaceError>
633 where
634 P: AsRef<Path>,
635 {
636 let config = self.get_config()?;
637
638 let publish_to = config
639 .publish_to()
640 .expect("Target audio library is not specified in workspace config file.");
641
642 if !publish_to.path.exists() {
644 return Err(WorkspaceError::PublishTargetNotFound(
645 publish_to.path.clone(),
646 ));
647 }
648
649 let album = self.get_workspace_album(album_path)?;
650 match album.state {
651 WorkspaceAlbumState::Committed(album_path) => {
652 for file in fs::PathWalker::new(&album_path, true, false, Default::default()) {
655 let file_name = file
656 .file_name()
657 .and_then(|r| r.to_str())
658 .unwrap_or_default();
659 if IGNORED_LIST.contains(&file_name) {
660 continue;
662 }
663
664 return Err(WorkspaceError::UnexpectedFile(file));
665 }
666
667 if let Some(layers) = publish_to.layers {
669 self.do_publish_strict(album_path, publish_to, layers, soft)?;
671 } else {
672 unimplemented!("Publishing as convention is not supported yet. Add `layers` to your library config")
674 }
675
676 Ok(())
677 }
678 state => Err(WorkspaceError::InvalidAlbumState(state)),
679 }
680 }
681
682 fn do_publish_strict<P>(
683 &self,
684 album_path: P,
685 publish_to: &LibraryConfig,
686 layers: usize,
687 soft: bool,
688 ) -> Result<(), WorkspaceError>
689 where
690 P: AsRef<Path>,
691 {
692 let album_id = self.get_album_id(album_path.as_ref())?;
693 let album_controlled_path = self.get_album_controlled_path(&album_id)?;
694
695 let result_path =
698 AnniWorkspace::strict_album_path(publish_to.path.clone(), &album_id, layers);
699 let result_parent = result_path.parent().expect("Invalid path");
700
701 if !result_parent.exists() {
703 fs::create_dir_all(&result_parent)?;
704 }
705
706 if soft {
708 fs::copy_dir(&album_controlled_path, &result_path)?;
710 fs::write(album_controlled_path.join(".publish"), "")?;
712 } else {
713 fs::move_dir(&album_controlled_path, &result_path)?;
715 }
716 fs::remove_dir_all(&album_path, true)?; Ok(())
720 }
721}