anni_workspace/
lib.rs

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", // KDE Dolphin
25    ".DS_Store",  // macOS
26];
27
28pub struct AnniWorkspace {
29    /// Full path of `.anni` directory.
30    dot_anni: PathBuf,
31}
32
33impl AnniWorkspace {
34    /// # Safety
35    ///
36    /// If you're sure that the directory is `.anni`, you can use this method to avoid unnecessary checking steps.
37    pub unsafe fn new_unchecked(dot_anni: PathBuf) -> Self {
38        AnniWorkspace { dot_anni }
39    }
40
41    /// Find [AnniWorkspace] from current working directory.
42    pub fn new() -> Result<Self, WorkspaceError> {
43        Self::find(std::env::current_dir()?)
44    }
45
46    /// Open a [AnniWorkspace] from given path.
47    ///
48    /// If the path is not a valid workspace, [WorkspaceError::NotAWorkspace] will be returned.
49    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    /// Find and open a [AnniWorkspace] from given path.
65    ///
66    /// This method will try to open all parent directories until it finds a valid workspace.
67    /// If workspace is not found, [WorkspaceError::WorkspaceNotFound] will be returned.
68    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    /// Get root path of the workspace
83    pub fn workspace_root(&self) -> &Path {
84        self.dot_anni.parent().unwrap()
85    }
86
87    /// Get root path of the metadata repository.
88    ///
89    /// # Warn
90    /// This method may be removed in the future
91    pub fn repo_root(&self) -> PathBuf {
92        self.dot_anni.join("repo")
93    }
94
95    /// Get root path of internal audio files
96    pub fn objects_root(&self) -> PathBuf {
97        self.dot_anni.join("objects")
98    }
99
100    /// Get album id from symlink target.
101    ///
102    /// Returns [WorkspaceError::NotAnAlbum] if the symlink is not valid.
103    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        // 1. validate album path
110        // if it does not exist, or is not a symlink, return None
111        if !album_path.is_symlink() {
112            return Err(WorkspaceError::NotAnAlbum(path.as_ref().to_path_buf()));
113        }
114
115        // 2. get album_id
116        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    /// Get controlled path of an album with album id.
123    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    /// Get album path with given `album_id` in workspace with no extra checks.
133    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    /// Try to get [WorkspaceAlbum] from given path
149    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        // valid album_id, it's an album directory
157        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                        // symlink is broken
164                        WorkspaceAlbumState::Dangling(path)
165                    } else if fs::read_dir(controlled_path)?.next().is_some() {
166                        // controlled part is not empty
167                        WorkspaceAlbumState::Committed(path)
168                    } else {
169                        // controlled part is empty
170                        WorkspaceAlbumState::Untracked(path)
171                    }
172                }
173                // controlled part does not exist
174                Err(WorkspaceError::AlbumNotFound(_)) => WorkspaceAlbumState::Dangling(path),
175                _ => unreachable!(),
176            },
177        })
178    }
179
180    /// Scan the whole workspace and return all available albums
181    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    /// Internal: scan userland
189    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                // look for .album folder
206                match self.get_workspace_album(entry.path()) {
207                    // valid album_id, it's an album directory
208                    Ok(album) => {
209                        albums.insert(album.album_id.clone(), album);
210                    }
211                    // symlink was not found, scan recursively
212                    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    /// Internal: scan controlled part
224    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    /// Create album with given `album_id` and `discs` at given `path`
259    ///
260    /// Creation would fail if:
261    /// - Album with given `album_id` already exists in workspace
262    /// - Directory at `path` is an album directory
263    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            // `path` is an album directory
279            return Err(WorkspaceError::AlbumExists {
280                album_id,
281                path: userland_path.as_ref().to_path_buf(),
282            });
283        }
284
285        // create album directories and symlink
286        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        // create disc directories
291        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
318// Operations
319impl AnniWorkspace {
320    /// Get album or disc cover path from album or disc path
321    ///
322    /// `path` MUST be a valid album or disc path
323    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    /// Take a overview of an untracked album directory.
331    ///
332    /// If the path provided is not an UNTRACKED album directory, or the album is incomplete, an error will be returned.
333    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            // check current state of the album
343            WorkspaceAlbumState::Untracked(p) => p,
344            state => {
345                return Err(WorkspaceError::InvalidAlbumState(state));
346            }
347        };
348
349        // validate album cover
350        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        // iterate over me.path to find all discs
356        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 there's only one disc, then there should be no sub directories, [true, true]
360        // if there are multiple discs, then there should be no flac files in the root directory, [false, false]
361        // other conditions are invalid
362        if flac_in_album_root ^ discs.is_empty() {
363            // both files and discs are empty, or both are not empty
364            return Err(WorkspaceError::InvalidAlbumDiscStructure(
365                album_path.clone(),
366            ));
367        }
368
369        // add album as disc if there's only one disc
370        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                // iterate over all flac files
382                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    /// Add album to workspace
422    ///
423    /// `Untracked` -> `Committed`
424    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        // validate album lock
432        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        // Add action
445        // 1. lock album
446        lock.lock()?;
447
448        // 2. copy or move album cover
449        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            // cover might be used by discs, copy it
454            fs::copy(&album_cover, &album_cover_controlled)?;
455        } else {
456            // move directly
457            fs::rename(&album_cover, &album_cover_controlled)?;
458            fs::symlink_file(&album_cover_controlled, &album_cover)?;
459        }
460
461        // 3. move discs
462        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            // move tracks
467            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            // move disc cover
475            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    /// Import tag from **committed** album.
485    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                // recover files from controlled album path
576                AnniWorkspace::recover_symlinks(&album_path)?;
577
578                // remove and re-create controlled album path
579                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            // ignore .album directories
593            if let Some(file_name) = path.as_ref().file_name() {
594                if file_name == ".album" {
595                    return Ok(());
596                }
597            }
598
599            // copy pointing file to current path
600            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        // TODO: do not panic here
624        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        // valdiate target path
643        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                // validate current path first
653                // if normal files exist, abort the operation
654                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                        // skip ignored files
661                        continue;
662                    }
663
664                    return Err(WorkspaceError::UnexpectedFile(file));
665                }
666
667                // TODO: validate whether track number matches in the repository
668                if let Some(layers) = publish_to.layers {
669                    // publish as strict
670                    self.do_publish_strict(album_path, publish_to, layers, soft)?;
671                } else {
672                    // publish as convention
673                    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        // publish as strict
696        // 1. get destination path
697        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        // 2. create parent directory
702        if !result_parent.exists() {
703            fs::create_dir_all(&result_parent)?;
704        }
705
706        // 3. move/copy album
707        if soft {
708            // copy the whole album
709            fs::copy_dir(&album_controlled_path, &result_path)?;
710            // add soft published mark
711            fs::write(album_controlled_path.join(".publish"), "")?;
712        } else {
713            // move directory
714            fs::move_dir(&album_controlled_path, &result_path)?;
715        }
716        // 4. clean album folder
717        fs::remove_dir_all(&album_path, true)?; // TODO: add an option to disable trash feature
718
719        Ok(())
720    }
721}