├── .envrc ├── .github └── workflows │ └── build.yaml ├── .gitignore ├── CONTRIBUTING.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── Readme.md ├── anni-common ├── CHANGELOG.md ├── Cargo.toml ├── src │ ├── decode.rs │ ├── diagnostic.rs │ ├── encode.rs │ ├── fs.rs │ ├── lib.rs │ ├── lint.rs │ ├── models.rs │ ├── traits.rs │ └── validator.rs └── tests │ ├── GNCA-0337.cue │ ├── decode.rs │ └── identifier.rs ├── anni-flac ├── CHANGELOG.md ├── Cargo.toml ├── src │ ├── blocks │ │ ├── application.rs │ │ ├── comment.rs │ │ ├── cue_sheet.rs │ │ ├── mod.rs │ │ ├── picture.rs │ │ ├── seek_table.rs │ │ └── stream_info.rs │ ├── error.rs │ ├── frames.rs │ ├── header.rs │ ├── lib.rs │ ├── prelude.rs │ └── utils.rs └── tests │ ├── application.rs │ ├── comments.rs │ ├── common.rs │ ├── save.rs │ └── stream_info.rs ├── anni-metadata ├── Cargo.toml ├── Readme.md ├── build.rs ├── queries │ ├── add-album.graphql │ ├── add-tag.graphql │ ├── album.graphql │ ├── albums.graphql │ ├── set-metadata-tags.graphql │ ├── set-organize-level.graphql │ ├── tag.graphql │ └── update-tag-relation.graphql ├── schemas │ └── annim.graphql └── src │ ├── annim.rs │ ├── annim │ ├── client.rs │ ├── mutation.rs │ ├── mutation │ │ ├── add_album.rs │ │ ├── add_tag.rs │ │ ├── set_metadata_tags.rs │ │ ├── set_organize_level.rs │ │ └── update_tag_relation.rs │ ├── query.rs │ └── query │ │ ├── album.rs │ │ ├── albums.rs │ │ └── tag.rs │ ├── error.rs │ ├── lib.rs │ ├── model.rs │ ├── model │ ├── album.rs │ ├── date.rs │ └── tag.rs │ └── utils.rs ├── anni-playback ├── CHANGELOG.md ├── Cargo.toml ├── LICENSE ├── Readme.md ├── examples │ ├── gapless.rs │ ├── player.rs │ └── tui.rs └── src │ ├── controls.rs │ ├── cpal_output.rs │ ├── decoder │ ├── decoder.rs │ ├── mod.rs │ └── opus.rs │ ├── dsp │ ├── mod.rs │ ├── normalizer.rs │ └── resampler.rs │ ├── lib.rs │ ├── player.rs │ ├── sources │ ├── cached_http │ │ ├── cache.rs │ │ ├── mod.rs │ │ └── provider.rs │ ├── http.rs │ ├── mod.rs │ └── streamable.rs │ ├── types.rs │ └── utils │ ├── blocking_rb.rs │ └── mod.rs ├── anni-provider ├── CHANGELOG.md ├── Cargo.toml └── src │ ├── cache.rs │ ├── common.rs │ ├── fs │ ├── local.rs │ └── mod.rs │ ├── lib.rs │ ├── providers │ ├── convention.rs │ ├── drive.rs │ ├── mod.rs │ ├── multiple.rs │ ├── no_cache.rs │ ├── priority.rs │ ├── proxy.rs │ └── strict.rs │ └── utils.rs ├── anni-repo ├── CHANGELOG.md ├── Cargo.toml ├── README.md ├── src │ ├── db │ │ ├── fs.rs │ │ ├── mod.rs │ │ ├── read.rs │ │ ├── rows.rs │ │ └── write.rs │ ├── error.rs │ ├── lib.rs │ ├── library.rs │ ├── manager.rs │ ├── models │ │ ├── album.rs │ │ ├── json.rs │ │ ├── mod.rs │ │ └── repo.rs │ ├── search.rs │ └── utils │ │ ├── git.rs │ │ └── mod.rs └── tests │ ├── album.rs │ ├── fixtures │ ├── format │ │ ├── disc-artist-to-album-artist-on-not-unknown │ │ │ ├── formatted.toml │ │ │ └── unformatted.toml │ │ ├── disc-artist-to-album-artist-on-unknown │ │ │ ├── formatted.toml │ │ │ └── unformatted.toml │ │ ├── disc-type-to-album-type │ │ │ ├── formatted.toml │ │ │ └── unformatted.toml │ │ ├── overall │ │ │ ├── formatted.toml │ │ │ └── unformatted.toml │ │ ├── track-artist-to-disc-artist │ │ │ ├── formatted.toml │ │ │ └── unformatted.toml │ │ └── track-type-to-disc-type │ │ │ ├── formatted.toml │ │ │ └── unformatted.toml │ └── test-album.toml │ ├── format.rs │ ├── repo.rs │ └── repos │ ├── album-tags │ ├── album │ │ └── album.toml │ ├── repo.toml │ └── tag │ │ └── default.toml │ ├── duplicated-tag-name-different-type │ ├── album │ ├── repo.toml │ └── tag │ │ └── default.toml │ ├── duplicated-tags │ ├── album │ ├── repo.toml │ └── tag │ │ └── default.toml │ └── empty │ ├── album │ └── .gitkeep │ ├── repo.toml │ └── tag │ └── .gitkeep ├── anni-split ├── CHANGELOG.md ├── Cargo.toml └── src │ ├── codec │ ├── command.rs │ ├── mod.rs │ └── wav.rs │ ├── cue.rs │ ├── error.rs │ ├── lib.rs │ └── split.rs ├── anni-workspace ├── CHANGELOG.md ├── Cargo.toml └── src │ ├── config.rs │ ├── error.rs │ ├── lib.rs │ ├── state.rs │ └── utils │ ├── lock.rs │ └── mod.rs ├── anni ├── CHANGELOG.md ├── Cargo.toml ├── build.rs ├── i18n.toml ├── i18n │ ├── en-US │ │ └── anni.ftl │ └── zh-CN │ │ └── anni.ftl ├── src │ ├── args │ │ ├── action_file.rs │ │ ├── input_path.rs │ │ └── mod.rs │ ├── config.rs │ ├── i18n.rs │ ├── main.rs │ ├── subcommands │ │ ├── completions.rs │ │ ├── convention.rs │ │ ├── flac.rs │ │ ├── library.rs │ │ ├── mod.rs │ │ ├── repo │ │ │ ├── add.rs │ │ │ ├── get.rs │ │ │ ├── lint.rs │ │ │ ├── migrate.rs │ │ │ ├── mod.rs │ │ │ ├── print.rs │ │ │ └── watch.rs │ │ ├── split.rs │ │ └── workspace │ │ │ ├── add.rs │ │ │ ├── create.rs │ │ │ ├── fsck.rs │ │ │ ├── init.rs │ │ │ ├── mod.rs │ │ │ ├── publish.rs │ │ │ ├── recover_published.rs │ │ │ ├── rm.rs │ │ │ ├── serve.rs │ │ │ ├── status.rs │ │ │ ├── target │ │ │ ├── drive.rs │ │ │ ├── file.rs │ │ │ └── mod.rs │ │ │ └── update.rs │ └── utils │ │ ├── log.rs │ │ └── mod.rs └── tests │ ├── common.rs │ └── flac.rs ├── annil ├── .gitignore ├── CHANGELOG.md ├── Cargo.toml └── src │ ├── extractor │ ├── admin.rs │ ├── auth.rs │ ├── mod.rs │ ├── token.rs │ └── track.rs │ ├── lib.rs │ ├── main.rs │ ├── metadata.rs │ ├── provider.rs │ ├── route │ ├── admin │ │ ├── mod.rs │ │ ├── reload.rs │ │ └── sign.rs │ ├── mod.rs │ └── user │ │ ├── albums.rs │ │ ├── audio.rs │ │ ├── cover.rs │ │ ├── info.rs │ │ └── mod.rs │ ├── state.rs │ ├── transcode.rs │ └── utils.rs ├── annim ├── Cargo.toml ├── Readme.md └── src │ ├── auth.rs │ ├── entities │ ├── helper.rs │ ├── helper │ │ ├── postgres.rs │ │ └── sqlite.rs │ ├── mod.rs │ ├── postgres │ │ ├── album.rs │ │ ├── album_tag_relation.rs │ │ ├── disc.rs │ │ ├── mod.rs │ │ ├── prelude.rs │ │ ├── sea_orm_active_enums.rs │ │ ├── tag_info.rs │ │ ├── tag_relation.rs │ │ └── track.rs │ └── sqlite │ │ ├── album.rs │ │ ├── album_tag_relation.rs │ │ ├── disc.rs │ │ ├── mod.rs │ │ ├── prelude.rs │ │ ├── tag_info.rs │ │ ├── tag_relation.rs │ │ └── track.rs │ ├── graphql │ ├── cursor.rs │ ├── input.rs │ ├── mod.rs │ └── types.rs │ ├── lib.rs │ ├── main.rs │ ├── migrator │ ├── helper.rs │ ├── m20240817_000001_create_basic_tables.rs │ ├── m20240824_000002_create_tag_tables.rs │ ├── m20240905_000003_add_tag_type_category.rs │ ├── m20240905_000004_album_extra_jsonb.rs │ └── mod.rs │ └── search.rs ├── assets ├── 1s-cover.png ├── 1s-full.flac └── 1s.flac ├── config └── convention.toml ├── docker ├── .gitignore ├── Dockerfile └── Dockerfile.annim ├── flake.lock ├── flake.nix ├── rust-toolchain.toml ├── rustfmt.toml └── third_party └── google-drive3 ├── Cargo.toml ├── LICENSE.md ├── README.md └── src ├── api.rs ├── client.rs └── lib.rs /.envrc: -------------------------------------------------------------------------------- 1 | use_flake 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | .idea 3 | *.db 4 | *.glade~ 5 | *.token 6 | 7 | flamegraph.svg 8 | perf.data 9 | perf.data.old 10 | *.db-shm 11 | *.db-wal 12 | repo.json 13 | 14 | .cargo 15 | /fleet.toml 16 | 17 | .direnv -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Releasing a new version for libraries 4 | 5 | Current dependency graph looks like this: 6 | 7 | 1. Leaf: anni-common, anni-flac, anni-playback, anni-artist 8 | 2. anni-provider: anni-repo, anni-common, anni-flac 9 | 3. anni-repo: anni-common, anni-artist 10 | 4. anni-split: anni-common 11 | 5. anni-workspace: anni-repo, anni-common, anni-flac 12 | 6. annil: anni-flac, anni-repo, anni-provider 13 | 14 | So if you want to upgrade a library depended by others, you need to upgrade the libraries directly depending on it too. Here is a list of libraries that need to be upgraded together: 15 | 16 | 1. anni-common: anni-provider, anni-repo, anni-split, anni-workspace 17 | 2. anni-flac: anni-provider, anni-workspace, annil 18 | 3. anni-playback: annix(in another repository) 19 | 4. anni-artist: anni-repo 20 | 5. anni-repo: anni-provider, anni-workspace, annil 21 | 6. anni-provider: annil 22 | 23 | If the list was outdated, contact the maintainer to update it. 24 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = [ 3 | "anni", 4 | "annil", 5 | "annim", 6 | "anni-provider", 7 | "anni-common", 8 | "anni-flac", 9 | "anni-split", 10 | "anni-repo", 11 | "anni-workspace", 12 | "anni-playback", 13 | "anni-metadata", 14 | "third_party/google-drive3", 15 | ] 16 | resolver = "2" 17 | 18 | [workspace.package] 19 | edition = "2024" 20 | authors = ["Yesterday17 "] 21 | repository = "https://github.com/ProjectAnni/anni" 22 | license = "Apache-2.0" 23 | 24 | [workspace.dependencies] 25 | anni-common = { version = "0.2.0", path = "./anni-common" } 26 | anni-metadata = { path = "./anni-metadata" } 27 | 28 | log = "0.4" 29 | uuid = { version = "1", features = ["v4"] } 30 | reqwest = { version = "0.12", features = [ 31 | "rustls-tls", 32 | ], default-features = false } 33 | serde = { version = "1.0", features = ["derive"] } 34 | serde_json = "1.0" 35 | toml = "0.7.3" 36 | anyhow = "1.0" 37 | thiserror = "1.0" 38 | once_cell = "1" 39 | 40 | axum = "0.7.3" 41 | 42 | [patch.crates-io] 43 | # Dropping wasm support before anni 1.0 44 | # https://github.com/rusqlite/rusqlite/pull/1010 may not be merged recently 45 | # rusqlite = { git = "https://github.com/ProjectAnni/rusqlite", branch = "wasm32-unknown-unknown" } 46 | 47 | # Remove this patch after https://github.com/mackwic/colored/pull/119 was merged 48 | colored = { git = "https://github.com/ProjectAnni/colored", branch = "master" } 49 | cpal = { git = "https://github.com/sidit77/cpal.git", branch = "master" } 50 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # Anni 2 | 3 | [![](https://img.shields.io/badge/book-%E4%BD%BF%E7%94%A8%E6%89%8B%E5%86%8C-brightgreen)](https://book.anni.rs/) 4 | [![](https://img.shields.io/badge/chat-on%20disord-green.svg?logo=discord)](https://discord.gg/6mMzdVXeRJ) 5 | 6 |
7 | 8 |
護りたい  
 9 | あなたとの毎日と  
10 | これからの未来へ続く道を  
11 | たくさんの愛に満ちた温もり  
12 | そう それは 陽だまりに咲くリバティ  
13 | 束ねては 贈る ひとひら  
14 | Anniversary
15 |
16 | 17 | ## Child Projects 18 | 19 | - anni: Cli-tool with all features. 20 | - annil: Simple music library backend. 21 | - anni-provider: Music music/cover provider. 22 | - anni-common: Common part used by anni projects. 23 | - anni-flac: FLAC parsing library(header only for now). 24 | - anni-repo: Music-repository related works. 25 | 26 | ## Use Cases 27 | 28 | - Print FLAC tags to stdout directly. 29 | - Perform FLAC tag content check. 30 | - Split `WAVE` or `FLAC` files. 31 | -------------------------------------------------------------------------------- /anni-common/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## 0.2.0 9 | 10 | - Removed default feature `trash` 11 | 12 | ## 0.1.4 13 | 14 | - Added `models::RawTrackIdentifier` 15 | 16 | ## 0.1.3 17 | 18 | - Added `fs::move_dir` to move a directory to another location. 19 | 20 | ## 0.1.2 21 | 22 | - Upgrade `trash` to `3.0.1` 23 | - Change signature of `fs::copy_dir` to fix error reported by `rust_analyzer` 24 | -------------------------------------------------------------------------------- /anni-common/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "anni-common" 3 | version = "0.2.0" 4 | description = "Common library used by Project Anni." 5 | 6 | edition.workspace = true 7 | authors.workspace = true 8 | repository.workspace = true 9 | license.workspace = true 10 | 11 | [dependencies] 12 | byteorder = "1" 13 | thiserror.workspace = true 14 | path-absolutize = "3.0.13" 15 | pathdiff = "0.2.1" 16 | log.workspace = true 17 | anni-artist = "0.1.1" 18 | 19 | # Remove the dependency for encoding_rs after https://github.com/hsivonen/chardetng/pull/10 was merged 20 | encoding_rs = { version = "0.8.31", features = ["alloc"] } 21 | chardetng = "0.1.17" 22 | 23 | regex = "1" 24 | once_cell.workspace = true 25 | serde.workspace = true 26 | serde_json.workspace = true 27 | 28 | trash = { version = "3.0.1", optional = true } 29 | -------------------------------------------------------------------------------- /anni-common/src/encode.rs: -------------------------------------------------------------------------------- 1 | use byteorder::{BigEndian, LittleEndian, WriteBytesExt}; 2 | use std::io::{Result, Write}; 3 | 4 | pub fn btoken_w(writer: &mut W, tag: &[u8]) -> Result<()> { 5 | writer.write_all(tag) 6 | } 7 | 8 | #[inline] 9 | pub fn u32_le_w(writer: &mut W, n: u32) -> Result<()> { 10 | writer.write_u32::(n)?; 11 | Ok(()) 12 | } 13 | 14 | #[inline] 15 | pub fn u32_be_w(writer: &mut W, n: u32) -> Result<()> { 16 | writer.write_u32::(n)?; 17 | Ok(()) 18 | } 19 | 20 | #[inline] 21 | pub fn u24_le_w(writer: &mut W, n: u32) -> Result<()> { 22 | writer.write_u24::(n)?; 23 | Ok(()) 24 | } 25 | 26 | #[inline] 27 | pub fn u24_be_w(writer: &mut W, n: u32) -> Result<()> { 28 | writer.write_u24::(n)?; 29 | Ok(()) 30 | } 31 | 32 | #[inline] 33 | pub fn u16_le_w(writer: &mut W, n: u16) -> Result<()> { 34 | writer.write_u16::(n)?; 35 | Ok(()) 36 | } 37 | 38 | #[inline] 39 | pub fn u16_be_w(writer: &mut W, n: u16) -> Result<()> { 40 | writer.write_u16::(n)?; 41 | Ok(()) 42 | } 43 | -------------------------------------------------------------------------------- /anni-common/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod decode; 2 | pub mod diagnostic; 3 | pub mod encode; 4 | pub mod fs; 5 | pub mod lint; 6 | pub mod models; 7 | pub mod traits; 8 | pub mod validator; 9 | -------------------------------------------------------------------------------- /anni-common/src/lint.rs: -------------------------------------------------------------------------------- 1 | use crate::diagnostic::{Diagnostic, DiagnosticSeverity}; 2 | use serde::Serialize; 3 | 4 | pub trait AnniLinter { 5 | fn add(&mut self, msg: Diagnostic); 6 | fn flush(&self) -> bool; 7 | } 8 | 9 | #[derive(Default)] 10 | pub struct AnniLinterReviewDogJsonLineFormat(bool); 11 | 12 | impl AnniLinterReviewDogJsonLineFormat { 13 | pub fn new() -> Self { 14 | AnniLinterReviewDogJsonLineFormat(false) 15 | } 16 | } 17 | 18 | impl AnniLinter for AnniLinterReviewDogJsonLineFormat { 19 | fn add(&mut self, msg: Diagnostic) { 20 | if let DiagnosticSeverity::Error = msg.severity { 21 | self.0 = true; 22 | } 23 | println!("{}", serde_json::to_string(&msg).unwrap()); 24 | } 25 | 26 | fn flush(&self) -> bool { 27 | !self.0 28 | } 29 | } 30 | 31 | pub struct AnniLinterTextFormat { 32 | errors: Vec>, 33 | warnings: Vec>, 34 | } 35 | 36 | impl Default for AnniLinterTextFormat { 37 | fn default() -> Self { 38 | Self { 39 | errors: Vec::new(), 40 | warnings: Vec::new(), 41 | } 42 | } 43 | } 44 | 45 | impl AnniLinter for AnniLinterTextFormat { 46 | fn add(&mut self, msg: Diagnostic) { 47 | match msg.severity { 48 | DiagnosticSeverity::Error => self.errors.push(msg), 49 | DiagnosticSeverity::Warning => self.warnings.push(msg), 50 | _ => {} 51 | } 52 | } 53 | 54 | fn flush(&self) -> bool { 55 | println!( 56 | "{} errors, {} warnings", 57 | self.errors.len(), 58 | self.warnings.len() 59 | ); 60 | println!(); 61 | for error in self.errors.iter() { 62 | println!( 63 | "[ERROR][{}] {}:{}: {}", 64 | error.location.path, 65 | error.location.start_line(), 66 | error.location.start_column().unwrap_or(0), 67 | error.message.message 68 | ); 69 | } 70 | println!(); 71 | for warn in self.warnings.iter() { 72 | println!( 73 | "[WARN][{}] {}:{}: {}", 74 | warn.location.path, 75 | warn.location.start_line(), 76 | warn.location.start_column().unwrap_or(0), 77 | warn.message.message 78 | ); 79 | } 80 | 81 | self.errors.is_empty() 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /anni-common/src/traits.rs: -------------------------------------------------------------------------------- 1 | use std::io::{Read, Write}; 2 | 3 | pub trait Decode: Sized { 4 | type Err; 5 | 6 | fn from_reader(reader: &mut R) -> Result; 7 | } 8 | 9 | pub trait Encode { 10 | type Err; 11 | 12 | fn write_to(&self, writer: &mut W) -> Result<(), Self::Err>; 13 | } 14 | -------------------------------------------------------------------------------- /anni-common/tests/GNCA-0337.cue: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ProjectAnni/anni/9a26bf91d85b96aa11b8a20acccf2ffb5bc3deb5/anni-common/tests/GNCA-0337.cue -------------------------------------------------------------------------------- /anni-common/tests/decode.rs: -------------------------------------------------------------------------------- 1 | use std::io::Cursor; 2 | 3 | use anni_common::decode; 4 | use anni_common::decode::{raw_to_string, DecodeError}; 5 | 6 | #[test] 7 | fn take_token() { 8 | let arr = b"fLaC|2333|114515"; 9 | let mut cursor = Cursor::new(arr); 10 | assert!(decode::token(&mut cursor, b"fLaC").is_ok()); 11 | assert!(decode::token(&mut cursor, b"|2333|").is_ok()); 12 | assert_eq!( 13 | decode::token(&mut cursor, b"114514").map_err(|e| match e { 14 | DecodeError::InvalidTokenError { expected, got } => { 15 | &expected == b"114514" && &got == b"114515" 16 | } 17 | _ => false, 18 | }), 19 | Err(true) 20 | ); 21 | } 22 | 23 | #[test] 24 | fn u32_le() -> Result<(), decode::DecodeError> { 25 | let arr = vec![1, 2, 3, 4, 5, 6, 7, 8]; 26 | let mut cursor = Cursor::new(arr); 27 | assert_eq!(decode::u32_le(&mut cursor)?, 0x04030201); 28 | assert_eq!(decode::u32_le(&mut cursor)?, 0x08070605); 29 | Ok(()) 30 | } 31 | 32 | #[test] 33 | fn u32_be() -> Result<(), decode::DecodeError> { 34 | let arr = vec![1, 2, 3, 4, 5, 6, 7, 8]; 35 | let mut cursor = Cursor::new(arr); 36 | assert_eq!(decode::u32_be(&mut cursor)?, 0x01020304); 37 | assert_eq!(decode::u32_be(&mut cursor)?, 0x05060708); 38 | Ok(()) 39 | } 40 | 41 | #[test] 42 | fn test_raw_to_string() { 43 | let input = include_bytes!("GNCA-0337.cue"); 44 | let str = raw_to_string(input); 45 | assert_eq!( 46 | str, 47 | r#"TITLE "TVアニメ「ご注文はうさぎですか?」キャラクターソング①" 48 | REM DATE "2014" 49 | FILE "GNCA-0337.flac" WAVE 50 | TRACK 01 AUDIO 51 | TITLE "全天候型いらっしゃいませ" 52 | PERFORMER "ココア(佐倉綾音)、チノ(水瀬いのり)" 53 | INDEX 01 00:00:00 54 | TRACK 02 AUDIO 55 | TITLE "ハミングsoon!" 56 | PERFORMER "ココア(佐倉綾音)" 57 | INDEX 00 03:44:10 58 | INDEX 01 03:45:48 59 | TRACK 03 AUDIO 60 | TITLE "a cup of happiness" 61 | PERFORMER "チノ(水瀬いのり)" 62 | INDEX 00 08:22:22 63 | INDEX 01 08:23:23 64 | TRACK 04 AUDIO 65 | TITLE "全天候型いらっしゃいませ (Instrumental)" 66 | PERFORMER "ココア(佐倉綾音)、チノ(水瀬いのり)" 67 | INDEX 00 12:56:41 68 | INDEX 01 12:58:46 69 | TRACK 05 AUDIO 70 | TITLE "ハミングsoon!(Instrumental)" 71 | PERFORMER "ココア(佐倉綾音)" 72 | INDEX 00 16:42:18 73 | INDEX 01 16:43:56 74 | TRACK 06 AUDIO 75 | TITLE "a cup of happiness (Instrumental)" 76 | PERFORMER "チノ(水瀬いのり)" 77 | INDEX 00 21:20:30 78 | INDEX 01 21:21:31 79 | "# 80 | ) 81 | } 82 | -------------------------------------------------------------------------------- /anni-common/tests/identifier.rs: -------------------------------------------------------------------------------- 1 | use std::num::NonZeroU8; 2 | 3 | use anni_common::models::{ParseError, TrackIdentifier}; 4 | 5 | #[test] 6 | fn parse() -> Result<(), ParseError> { 7 | let identifier = "65cf12dc-9717-4503-9901-848e8cd3ebff/1/8".parse::()?; 8 | 9 | assert_eq!( 10 | identifier.inner.album_id, 11 | "65cf12dc-9717-4503-9901-848e8cd3ebff" 12 | ); 13 | assert_eq!(identifier.inner.disc_id, NonZeroU8::new(1).unwrap()); 14 | assert_eq!(identifier.inner.track_id, NonZeroU8::new(8).unwrap()); 15 | 16 | Ok(()) 17 | } 18 | -------------------------------------------------------------------------------- /anni-flac/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | - Remove dependency of `num-traits` and `num-derive` 11 | -------------------------------------------------------------------------------- /anni-flac/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "anni-flac" 3 | version = "0.2.2" 4 | description = "FLAC parser implemented for Project Anni." 5 | 6 | edition.workspace = true 7 | authors.workspace = true 8 | repository.workspace = true 9 | license.workspace = true 10 | 11 | [dependencies] 12 | hex = "0.4" 13 | thiserror.workspace = true 14 | byteorder = "1" 15 | image = "0.24" 16 | tokio = { version = "1", features = ["io-util"], optional = true } 17 | async-trait = { version = "0.1", optional = true } 18 | log.workspace = true 19 | 20 | [dev-dependencies] 21 | tempfile = "3.2.0" 22 | 23 | [features] 24 | async = ["tokio", "async-trait"] 25 | -------------------------------------------------------------------------------- /anni-flac/src/blocks/application.rs: -------------------------------------------------------------------------------- 1 | use crate::prelude::*; 2 | use crate::utils::*; 3 | use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt}; 4 | use std::fmt; 5 | use std::io::{Read, Write}; 6 | 7 | pub struct BlockApplication { 8 | /// Registered application ID. 9 | /// (Visit the [registration page](https://xiph.org/flac/id.html) to register an ID with FLAC.) 10 | pub application_id: u32, 11 | /// Application data (n must be a multiple of 8) 12 | pub data: Vec, 13 | } 14 | 15 | impl Decode for BlockApplication { 16 | fn from_reader(reader: &mut R) -> Result { 17 | Ok(BlockApplication { 18 | application_id: reader.read_u32::()?, 19 | data: take_to_end(reader)?, 20 | }) 21 | } 22 | } 23 | 24 | #[cfg(feature = "async")] 25 | #[async_trait::async_trait] 26 | impl AsyncDecode for BlockApplication { 27 | async fn from_async_reader(reader: &mut R) -> Result 28 | where 29 | R: AsyncRead + Unpin + Send, 30 | { 31 | Ok(BlockApplication { 32 | application_id: reader.read_u32().await?, 33 | data: take_to_end_async(reader).await?, 34 | }) 35 | } 36 | } 37 | 38 | impl Encode for BlockApplication { 39 | fn write_to(&self, writer: &mut W) -> Result<()> { 40 | writer.write_u32::(self.application_id)?; 41 | writer.write_all(&self.data)?; 42 | Ok(()) 43 | } 44 | } 45 | 46 | impl fmt::Debug for BlockApplication { 47 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 48 | let mut prefix = "".to_owned(); 49 | if let Some(width) = f.width() { 50 | prefix = " ".repeat(width); 51 | } 52 | writeln!( 53 | f, 54 | "{prefix}application ID: {:x}", 55 | self.application_id, 56 | prefix = prefix 57 | )?; 58 | writeln!(f, "{prefix}data contents:", prefix = prefix)?; 59 | // TODO: hexdump 60 | writeln!(f, "{prefix}", prefix = prefix) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /anni-flac/src/blocks/mod.rs: -------------------------------------------------------------------------------- 1 | mod application; 2 | mod comment; 3 | mod cue_sheet; 4 | mod picture; 5 | mod seek_table; 6 | mod stream_info; 7 | 8 | pub use application::*; 9 | pub use comment::*; 10 | pub use cue_sheet::*; 11 | pub use picture::*; 12 | pub use seek_table::*; 13 | pub use stream_info::*; 14 | -------------------------------------------------------------------------------- /anni-flac/src/error.rs: -------------------------------------------------------------------------------- 1 | use std::string::FromUtf8Error; 2 | use thiserror::Error; 3 | 4 | #[derive(Error, Debug)] 5 | pub enum FlacError { 6 | #[error("invalid magic number")] 7 | InvalidMagicNumber, 8 | #[error("invalid first block, must be StreamInfo")] 9 | InvalidFirstBlock, 10 | #[error("invalid block type 0xff")] 11 | InvalidBlockType, 12 | #[error("invalid seek table size")] 13 | InvalidSeekTableSize, 14 | #[error("invalid picture type")] 15 | InvalidPictureType, 16 | #[error(transparent)] 17 | InvalidString(#[from] FromUtf8Error), 18 | #[error(transparent)] 19 | IO(#[from] std::io::Error), 20 | #[error(transparent)] 21 | ImageError(#[from] image::ImageError), 22 | } 23 | -------------------------------------------------------------------------------- /anni-flac/src/lib.rs: -------------------------------------------------------------------------------- 1 | mod header; 2 | mod utils; 3 | 4 | pub use header::*; 5 | 6 | pub mod blocks; 7 | pub mod error; 8 | pub mod frames; 9 | pub mod prelude; 10 | -------------------------------------------------------------------------------- /anni-flac/src/prelude.rs: -------------------------------------------------------------------------------- 1 | use std::io::{Read, Write}; 2 | 3 | pub type Result = std::result::Result; 4 | 5 | pub trait Decode: Sized { 6 | fn from_reader(reader: &mut R) -> Result; 7 | } 8 | 9 | #[cfg(feature = "async")] 10 | pub(crate) use tokio::io::{AsyncRead, AsyncReadExt}; 11 | 12 | #[cfg(feature = "async")] 13 | #[async_trait::async_trait] 14 | pub trait AsyncDecode: Sized { 15 | async fn from_async_reader(reader: &mut R) -> Result 16 | where 17 | R: AsyncRead + Unpin + Send; 18 | } 19 | 20 | pub trait Encode: Sized { 21 | fn write_to(&self, writer: &mut W) -> Result<()>; 22 | } 23 | -------------------------------------------------------------------------------- /anni-flac/src/utils.rs: -------------------------------------------------------------------------------- 1 | use crate::prelude::*; 2 | use std::io::Read; 3 | 4 | pub(crate) fn take(reader: &mut R, len: usize) -> std::io::Result> { 5 | let mut r = Vec::with_capacity(len); 6 | std::io::copy(&mut reader.take(len as u64), &mut r)?; 7 | Ok(r) 8 | } 9 | 10 | #[cfg(feature = "async")] 11 | pub(crate) async fn take_async( 12 | reader: &mut R, 13 | len: usize, 14 | ) -> std::io::Result> { 15 | let mut r = Vec::with_capacity(len); 16 | tokio::io::copy(&mut reader.take(len as u64), &mut r).await?; 17 | Ok(r) 18 | } 19 | 20 | pub(crate) fn take_to_end(reader: &mut R) -> std::io::Result> { 21 | let mut r = Vec::new(); 22 | reader.read_to_end(&mut r)?; 23 | Ok(r) 24 | } 25 | 26 | #[cfg(feature = "async")] 27 | pub(crate) async fn take_to_end_async( 28 | reader: &mut R, 29 | ) -> std::io::Result> { 30 | let mut r = Vec::new(); 31 | reader.read_to_end(&mut r).await?; 32 | Ok(r) 33 | } 34 | 35 | pub(crate) fn take_string(reader: &mut R, len: usize) -> Result { 36 | let r = take(reader, len)?; 37 | Ok(String::from_utf8_lossy(&r).to_string()) 38 | } 39 | 40 | #[cfg(feature = "async")] 41 | pub(crate) async fn take_string_async( 42 | reader: &mut R, 43 | len: usize, 44 | ) -> Result { 45 | let r = take_async(reader, len).await?; 46 | Ok(String::from_utf8_lossy(&r).to_string()) 47 | } 48 | 49 | pub(crate) fn skip(reader: &mut R, len: usize) -> std::io::Result { 50 | std::io::copy(&mut reader.take(len as u64), &mut std::io::sink()) 51 | } 52 | 53 | #[cfg(feature = "async")] 54 | pub(crate) async fn skip_async( 55 | reader: &mut R, 56 | len: usize, 57 | ) -> std::io::Result { 58 | tokio::io::copy(&mut reader.take(len as u64), &mut tokio::io::sink()).await 59 | } 60 | 61 | #[cfg(feature = "async")] 62 | pub(crate) async fn read_u24_async(reader: &mut R) -> std::io::Result { 63 | use byteorder::ByteOrder; 64 | 65 | let mut buf = [0; 3]; 66 | reader.read_exact(&mut buf).await?; 67 | Ok(byteorder::BigEndian::read_u24(&buf)) 68 | } 69 | -------------------------------------------------------------------------------- /anni-flac/tests/application.rs: -------------------------------------------------------------------------------- 1 | use anni_flac::prelude::{Decode, Encode}; 2 | use anni_flac::{MetadataBlock, MetadataBlockData}; 3 | use std::io::Cursor; 4 | 5 | #[test] 6 | fn block_application_encode_decode() { 7 | let block = vec![2, 0, 0, 5, 0, 0x99, 0x99, 0xff, 0xfe]; 8 | let mut reader = Cursor::new(block); 9 | let block = MetadataBlock::from_reader(&mut reader).unwrap(); 10 | assert_eq!(reader.position(), 9); 11 | 12 | let buf = Vec::new(); 13 | let mut buf = Cursor::new(buf); 14 | block.write_to(&mut buf).expect("Failed to write to buf"); 15 | assert_eq!(reader.into_inner(), buf.into_inner()); 16 | 17 | // assert_eq!(block.is_last, false); 18 | // assert_eq!(block.length, 5); 19 | 20 | assert!(match block.data { 21 | MetadataBlockData::Application(a) => { 22 | a.application_id == 0x009999ff && a.data.len() == 1 && a.data[0] == 0xfe 23 | } 24 | _ => false, 25 | }); 26 | } 27 | -------------------------------------------------------------------------------- /anni-flac/tests/comments.rs: -------------------------------------------------------------------------------- 1 | use anni_flac::blocks::{BlockVorbisComment, UserComment}; 2 | 3 | mod common; 4 | 5 | #[test] 6 | fn user_comment_lowercase() { 7 | let c = UserComment::new("a=b".to_string()); 8 | assert_eq!(c.key(), "A"); 9 | assert_eq!(c.key_raw(), "a"); 10 | assert_eq!(c.value(), "b"); 11 | assert!(!c.is_key_uppercase()); 12 | } 13 | 14 | #[test] 15 | fn user_comment_uppercase_key() { 16 | let c = UserComment::new("A=b".to_string()); 17 | assert_eq!(c.key(), "A"); 18 | assert_eq!(c.key_raw(), "A"); 19 | assert_eq!(c.value(), "b"); 20 | assert!(c.is_key_uppercase()); 21 | } 22 | 23 | #[test] 24 | fn user_comment_no_equal() { 25 | let c = UserComment::new("A_WITHOUT_EQUAL".to_string()); 26 | assert_eq!(c.key(), "A_WITHOUT_EQUAL"); 27 | assert_eq!(c.key_raw(), "A_WITHOUT_EQUAL"); 28 | assert_eq!(c.value(), ""); 29 | assert!(c.is_key_uppercase()); 30 | } 31 | 32 | #[test] 33 | fn user_comment_no_value() { 34 | let c = UserComment::new("A_WITHOUT_VaLuE=".to_string()); 35 | assert_eq!(c.key(), "A_WITHOUT_VALUE"); 36 | assert_eq!(c.key_raw(), "A_WITHOUT_VaLuE"); 37 | assert_eq!(c.value(), ""); 38 | assert!(!c.is_key_uppercase()); 39 | } 40 | 41 | #[test] 42 | fn user_comment_encode_decode() { 43 | let comment = BlockVorbisComment { 44 | vendor_string: "Project Anni".to_string(), 45 | comments: vec![ 46 | UserComment::new("KEY1=value1".to_string()), 47 | UserComment::new("KEY2=value2".to_string()), 48 | UserComment::new("KEY3=".to_string()), 49 | ], 50 | }; 51 | let parsed = common::encode_and_decode(&comment); 52 | assert_eq!(format!("{:?}", parsed), format!("{:?}", comment)); 53 | } 54 | -------------------------------------------------------------------------------- /anni-flac/tests/save.rs: -------------------------------------------------------------------------------- 1 | use anni_flac::blocks::{ 2 | BlockPicture, BlockSeekTable, PictureType, SeekPoint, UserComment, UserCommentExt, 3 | }; 4 | use anni_flac::{MetadataBlock, MetadataBlockData}; 5 | 6 | mod common; 7 | 8 | #[test] 9 | fn test_save() { 10 | let mut header = common::parse_1s_audio(); 11 | header.blocks.insert( 12 | 1, 13 | MetadataBlock::new(MetadataBlockData::SeekTable(BlockSeekTable { 14 | seek_points: vec![SeekPoint { 15 | sample_number: 0, 16 | stream_offset: 0, 17 | frame_samples: 4608, 18 | }], 19 | })), 20 | ); 21 | 22 | // Write new metadata 23 | let comments = header.comments_mut(); 24 | comments.vendor_string = "Lavf58.45.100".to_string(); 25 | comments.clear(); 26 | comments.push(UserComment::title("TRACK ONE")); 27 | comments.push(UserComment::album("TestAlbum")); 28 | comments.push(UserComment::artist("TestArtist")); 29 | comments.push(UserComment::date("2021-01-24")); 30 | comments.push(UserComment::track_number(1)); 31 | comments.push(UserComment::track_total(1)); 32 | comments.push(UserComment::disc_number(1)); 33 | comments.push(UserComment::disc_total(1)); 34 | 35 | header 36 | .blocks 37 | .push(MetadataBlock::new(MetadataBlockData::Picture( 38 | BlockPicture::new( 39 | "../assets/1s-cover.png", 40 | PictureType::CoverFront, 41 | "".to_string(), 42 | ) 43 | .unwrap(), 44 | ))); 45 | 46 | let file = tempfile::NamedTempFile::new().unwrap().into_temp_path(); 47 | header.save(Some(file)).unwrap(); 48 | //TODO: assert(file == 1s-full) 49 | } 50 | -------------------------------------------------------------------------------- /anni-flac/tests/stream_info.rs: -------------------------------------------------------------------------------- 1 | use anni_flac::blocks::BlockStreamInfo; 2 | 3 | mod common; 4 | 5 | #[test] 6 | fn block_stream_info_encode_decode() { 7 | let block = BlockStreamInfo { 8 | min_block_size: 4608, 9 | max_block_size: 4608, 10 | min_frame_size: 798, 11 | max_frame_size: 1817, 12 | sample_rate: 44100, 13 | channels: 1, 14 | bits_per_sample: 16, 15 | total_samples: 44100, 16 | md5_signature: [ 17 | 0xee, 0xc1, 0xef, 0x02, 0x73, 0xe8, 0xc0, 0x26, 0x1e, 0x52, 0x15, 0x9f, 0xc2, 0x13, 18 | 0x67, 0xb0, 19 | ], 20 | }; 21 | let info = common::encode_and_decode(&block); 22 | assert_eq!(format!("{:?}", info), format!("{:?}", block)); 23 | } 24 | -------------------------------------------------------------------------------- /anni-metadata/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "anni-metadata" 3 | version = "0.1.0" 4 | edition.workspace = true 5 | authors.workspace = true 6 | repository.workspace = true 7 | license.workspace = true 8 | 9 | [dependencies] 10 | anyhow.workspace = true 11 | thiserror.workspace = true 12 | chrono = { version = "0.4.38", features = ["serde"] } 13 | 14 | serde = { workspace = true, features = ["derive"] } 15 | serde_json.workspace = true 16 | 17 | toml.workspace = true 18 | toml_edit = "0.22.20" 19 | indexmap = "2.5.0" 20 | uuid = { workspace = true, features = ["serde"] } 21 | 22 | cynic = { version = "3", features = ["http-reqwest"], optional = true } 23 | reqwest = { workspace = true, optional = true } 24 | 25 | [build-dependencies] 26 | cynic-codegen = { version = "3" } 27 | 28 | [dev-dependencies] 29 | tokio = { version = "1", features = ["full"] } 30 | 31 | [features] 32 | default = [] 33 | annim = ["reqwest", "cynic"] 34 | -------------------------------------------------------------------------------- /anni-metadata/Readme.md: -------------------------------------------------------------------------------- 1 | # anni-metadata 2 | 3 | ## Update schema 4 | 5 | ```bash 6 | cargo install --locked cynic-cli 7 | cynic introspect http://localhost:8000/ -o schemas/annim.graphql 8 | ``` 9 | -------------------------------------------------------------------------------- /anni-metadata/build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | cynic_codegen::register_schema("annim") 3 | .from_sdl_file("schemas/annim.graphql") 4 | .unwrap() 5 | .as_default() 6 | .unwrap(); 7 | } 8 | -------------------------------------------------------------------------------- /anni-metadata/queries/add-album.graphql: -------------------------------------------------------------------------------- 1 | fragment AlbumDetail on Album { 2 | id 3 | albumId 4 | level 5 | 6 | title 7 | edition 8 | catalog 9 | artist 10 | 11 | year 12 | month 13 | day 14 | 15 | tags { 16 | ...TagBase 17 | } 18 | 19 | createdAt 20 | updatedAt 21 | extra 22 | 23 | discs { 24 | id 25 | index 26 | title 27 | catalog 28 | artist 29 | 30 | tags { 31 | ...TagBase 32 | } 33 | 34 | createdAt 35 | updatedAt 36 | 37 | tracks { 38 | id 39 | index 40 | title 41 | artist 42 | type 43 | artists 44 | 45 | tags { 46 | ...TagBase 47 | } 48 | 49 | createdAt 50 | updatedAt 51 | } 52 | } 53 | } 54 | 55 | fragment TagBase on Tag { 56 | id 57 | name 58 | type 59 | createdAt 60 | updatedAt 61 | } 62 | 63 | mutation addAlbum($album: AddAlbumInput!, $commit: Boolean) { 64 | addAlbum(input: $album, commit: $commit) { 65 | ...AlbumDetail 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /anni-metadata/queries/add-tag.graphql: -------------------------------------------------------------------------------- 1 | fragment TagBase on Tag { 2 | id 3 | name 4 | type 5 | createdAt 6 | updatedAt 7 | } 8 | 9 | fragment TagDetail on Tag { 10 | ...TagBase 11 | includes { 12 | ...TagBase 13 | } 14 | includedBy { 15 | ...TagBase 16 | } 17 | } 18 | 19 | mutation addTag($name: String!, $type: TagType!) { 20 | addTag(name: $name, type: $type) { 21 | ...TagDetail 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /anni-metadata/queries/album.graphql: -------------------------------------------------------------------------------- 1 | fragment AlbumDetail on Album { 2 | id 3 | albumId 4 | level 5 | 6 | title 7 | edition 8 | catalog 9 | artist 10 | 11 | year 12 | month 13 | day 14 | 15 | tags { 16 | ...TagBase 17 | } 18 | 19 | createdAt 20 | updatedAt 21 | extra 22 | 23 | discs { 24 | id 25 | index 26 | title 27 | catalog 28 | artist 29 | 30 | tags { 31 | ...TagBase 32 | } 33 | 34 | createdAt 35 | updatedAt 36 | 37 | tracks { 38 | id 39 | index 40 | title 41 | artist 42 | type 43 | artists 44 | 45 | tags { 46 | ...TagBase 47 | } 48 | 49 | createdAt 50 | updatedAt 51 | } 52 | } 53 | } 54 | 55 | query album($albumId: UUID) { 56 | album(albumId: $albumId) { 57 | ...AlbumDetail 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /anni-metadata/queries/albums.graphql: -------------------------------------------------------------------------------- 1 | fragment AlbumDetail on Album { 2 | id 3 | albumId 4 | level 5 | 6 | title 7 | edition 8 | catalog 9 | artist 10 | 11 | year 12 | month 13 | day 14 | 15 | tags { 16 | ...TagBase 17 | } 18 | 19 | createdAt 20 | updatedAt 21 | extra 22 | 23 | discs { 24 | id 25 | index 26 | title 27 | catalog 28 | artist 29 | 30 | tags { 31 | ...TagBase 32 | } 33 | 34 | createdAt 35 | updatedAt 36 | 37 | tracks { 38 | id 39 | index 40 | title 41 | artist 42 | type 43 | artists 44 | 45 | tags { 46 | ...TagBase 47 | } 48 | 49 | createdAt 50 | updatedAt 51 | } 52 | } 53 | } 54 | 55 | query albums($albumIds: [UUID!]!) { 56 | albums(by: { albumIds: $albumIds }) { 57 | pageInfo { 58 | hasPreviousPage 59 | hasNextPage 60 | startCursor 61 | endCursor 62 | } 63 | nodes { 64 | ...AlbumDetail 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /anni-metadata/queries/set-metadata-tags.graphql: -------------------------------------------------------------------------------- 1 | fragment AlbumDetail on Album { 2 | id 3 | albumId 4 | level 5 | 6 | title 7 | edition 8 | catalog 9 | artist 10 | 11 | year 12 | month 13 | day 14 | 15 | tags { 16 | ...TagBase 17 | } 18 | 19 | createdAt 20 | updatedAt 21 | extra 22 | 23 | discs { 24 | id 25 | index 26 | title 27 | catalog 28 | artist 29 | 30 | tags { 31 | ...TagBase 32 | } 33 | 34 | createdAt 35 | updatedAt 36 | 37 | tracks { 38 | id 39 | index 40 | title 41 | artist 42 | type 43 | artists 44 | 45 | tags { 46 | ...TagBase 47 | } 48 | 49 | createdAt 50 | updatedAt 51 | } 52 | } 53 | } 54 | 55 | fragment TagBase on Tag { 56 | id 57 | name 58 | type 59 | 60 | createdAt 61 | updatedAt 62 | } 63 | 64 | mutation setMetadataTags($target: MetadataIDInput!, $tags: [ID!]!) { 65 | updateMetadataTags(input: $target, tags: $tags) { 66 | ...AlbumDetail 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /anni-metadata/queries/set-organize-level.graphql: -------------------------------------------------------------------------------- 1 | mutation setMetadataTags($id: ID!, $level: MetadataOrganizeLevel!) { 2 | updateOrganizeLevel(input: { id: $id, level: $level }) { 3 | id 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /anni-metadata/queries/tag.graphql: -------------------------------------------------------------------------------- 1 | fragment TagBase on Tag { 2 | id 3 | name 4 | type 5 | createdAt 6 | updatedAt 7 | } 8 | 9 | fragment TagDetail on Tag { 10 | ...TagBase 11 | includes { 12 | ...TagBase 13 | } 14 | includedBy { 15 | ...TagBase 16 | } 17 | } 18 | 19 | query tag($name: String!, $type: TagType) { 20 | tag(tagName: $name, tagType: $type) { 21 | ...TagDetail 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /anni-metadata/queries/update-tag-relation.graphql: -------------------------------------------------------------------------------- 1 | fragment TagBase on Tag { 2 | id 3 | name 4 | type 5 | 6 | createdAt 7 | updatedAt 8 | } 9 | 10 | fragment TagRelationBase on TagRelation { 11 | id 12 | tag { 13 | ...TagBase 14 | } 15 | parent { 16 | ...TagBase 17 | } 18 | } 19 | 20 | mutation updateTagRelation($tag: ID!, $parent: ID!, $remove: Boolean!) { 21 | updateTagRelation(tagId: $tag, parentId: $parent, remove: $remove) { 22 | ...TagRelationBase 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /anni-metadata/src/annim.rs: -------------------------------------------------------------------------------- 1 | #[cynic::schema("annim")] 2 | pub(crate) mod schema {} 3 | cynic::impl_scalar!(uuid::Uuid, schema::UUID); 4 | cynic::impl_scalar!(chrono::DateTime, schema::DateTime); 5 | cynic::impl_scalar!(serde_json::Value, schema::JSON); 6 | 7 | pub(crate) type Uuid = uuid::Uuid; 8 | pub(crate) type DateTime = chrono::DateTime; 9 | pub(crate) type Json = serde_json::Value; 10 | 11 | pub use client::AnnimClient; 12 | pub use schema::ID; 13 | 14 | mod client; 15 | // TODO: make this private 16 | pub mod mutation; 17 | pub mod query; 18 | -------------------------------------------------------------------------------- /anni-metadata/src/annim/mutation.rs: -------------------------------------------------------------------------------- 1 | pub mod add_album; 2 | pub mod add_tag; 3 | pub mod set_metadata_tags; 4 | pub mod update_tag_relation; 5 | pub mod set_organize_level; 6 | -------------------------------------------------------------------------------- /anni-metadata/src/annim/mutation/add_album.rs: -------------------------------------------------------------------------------- 1 | use crate::annim::query::album::{AlbumFragment, TrackTypeInput}; 2 | use crate::annim::{schema, Json, Uuid}; 3 | 4 | #[derive(cynic::QueryVariables, Debug)] 5 | pub struct AddAlbumVariables<'a> { 6 | pub album: AddAlbumInput<'a>, 7 | pub commit: Option, 8 | } 9 | 10 | #[derive(cynic::QueryFragment, Debug)] 11 | #[cynic(graphql_type = "MetadataMutation", variables = "AddAlbumVariables")] 12 | pub struct AddAlbumMutation { 13 | #[arguments(input: $album, commit: $commit)] 14 | pub add_album: AlbumFragment, 15 | } 16 | 17 | #[derive(cynic::InputObject, Debug)] 18 | pub struct AddAlbumInput<'a> { 19 | pub album_id: Option, 20 | pub title: &'a str, 21 | pub edition: Option<&'a str>, 22 | pub catalog: Option<&'a str>, 23 | pub artist: &'a str, 24 | pub year: i32, 25 | pub month: Option, 26 | pub day: Option, 27 | pub extra: Option, 28 | pub discs: Vec>, 29 | } 30 | 31 | #[derive(cynic::InputObject, Debug)] 32 | pub struct CreateAlbumDiscInput<'a> { 33 | pub title: Option<&'a str>, 34 | pub catalog: Option<&'a str>, 35 | pub artist: Option<&'a str>, 36 | pub tracks: Vec>, 37 | } 38 | 39 | #[derive(cynic::InputObject, Debug)] 40 | pub struct CreateAlbumTrackInput<'a> { 41 | pub title: &'a str, 42 | pub artist: &'a str, 43 | #[cynic(rename = "type")] 44 | pub type_: TrackTypeInput, 45 | } 46 | 47 | impl<'album, 'disc> From> for CreateAlbumTrackInput<'album> 48 | where 49 | 'disc: 'album, 50 | { 51 | fn from(track: crate::model::TrackRef<'album, 'disc>) -> Self { 52 | Self { 53 | title: track.title(), 54 | artist: track.artist(), 55 | type_: track.track_type().into(), 56 | } 57 | } 58 | } 59 | 60 | impl<'a> From<&'a crate::model::Track> for CreateAlbumTrackInput<'a> { 61 | fn from(track: &'a crate::model::Track) -> Self { 62 | Self { 63 | title: &track.title, 64 | artist: track 65 | .artist 66 | .as_deref() 67 | .unwrap_or(crate::model::UNKNOWN_ARTIST), 68 | type_: track 69 | .track_type 70 | .as_ref() 71 | .unwrap_or_else(|| &crate::model::TrackType::Normal) 72 | .into(), 73 | } 74 | } 75 | } 76 | 77 | impl<'a> From<&'a crate::model::Disc> for CreateAlbumDiscInput<'a> { 78 | fn from(value: &'a crate::model::Disc) -> Self { 79 | Self { 80 | title: value.title.as_deref(), 81 | catalog: Some(value.catalog.as_str()), 82 | artist: value.artist.as_deref(), 83 | tracks: value.tracks.iter().map(Into::into).collect(), 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /anni-metadata/src/annim/mutation/add_tag.rs: -------------------------------------------------------------------------------- 1 | use crate::annim::{ 2 | query::{album::TagTypeInput, tag::Tag}, 3 | schema, 4 | }; 5 | 6 | #[derive(cynic::QueryVariables, Debug)] 7 | pub struct AddTagVariables<'a> { 8 | pub name: &'a str, 9 | #[cynic(rename = "type")] 10 | pub type_: TagTypeInput, 11 | } 12 | 13 | #[derive(cynic::QueryFragment, Debug)] 14 | #[cynic(graphql_type = "MetadataMutation", variables = "AddTagVariables")] 15 | pub struct AddTagMutation { 16 | #[arguments(name: $name, type: $type_)] 17 | pub add_tag: Tag, 18 | } 19 | -------------------------------------------------------------------------------- /anni-metadata/src/annim/mutation/set_metadata_tags.rs: -------------------------------------------------------------------------------- 1 | use crate::annim::{query::album::AlbumFragment, schema}; 2 | 3 | #[derive(cynic::QueryVariables, Debug)] 4 | pub struct SetMetadataTagsVariables<'a> { 5 | pub tags: Vec<&'a cynic::Id>, 6 | pub target: MetadataIdinput<'a>, 7 | } 8 | 9 | #[derive(cynic::QueryFragment, Debug)] 10 | #[cynic( 11 | graphql_type = "MetadataMutation", 12 | variables = "SetMetadataTagsVariables" 13 | )] 14 | pub struct SetMetadataTags { 15 | #[arguments(input: $target, tags: $tags)] 16 | pub update_metadata_tags: AlbumFragment, 17 | } 18 | 19 | #[derive(cynic::InputObject, Debug)] 20 | #[cynic(graphql_type = "MetadataIDInput")] 21 | pub struct MetadataIdinput<'a> { 22 | #[cynic(skip_serializing_if = "Option::is_none")] 23 | pub album: Option<&'a cynic::Id>, 24 | #[cynic(skip_serializing_if = "Option::is_none")] 25 | pub disc: Option<&'a cynic::Id>, 26 | #[cynic(skip_serializing_if = "Option::is_none")] 27 | pub track: Option<&'a cynic::Id>, 28 | } 29 | -------------------------------------------------------------------------------- /anni-metadata/src/annim/mutation/set_organize_level.rs: -------------------------------------------------------------------------------- 1 | use crate::annim::{query::album::MetadataOrganizeLevel, schema}; 2 | 3 | #[derive(cynic::QueryVariables, Debug)] 4 | pub struct SetMetadataTagsVariables<'a> { 5 | pub id: &'a cynic::Id, 6 | pub level: MetadataOrganizeLevel, 7 | } 8 | 9 | #[derive(cynic::QueryFragment, Debug)] 10 | #[cynic( 11 | graphql_type = "MetadataMutation", 12 | variables = "SetMetadataTagsVariables" 13 | )] 14 | pub struct SetMetadataTags { 15 | #[arguments(input: { id: $id, level: $level })] 16 | pub update_organize_level: Option, 17 | } 18 | 19 | #[derive(cynic::QueryFragment, Debug)] 20 | pub struct Album { 21 | pub id: cynic::Id, 22 | } 23 | -------------------------------------------------------------------------------- /anni-metadata/src/annim/mutation/update_tag_relation.rs: -------------------------------------------------------------------------------- 1 | use crate::annim::{query::album::TagBase, schema}; 2 | 3 | #[derive(cynic::QueryVariables, Debug)] 4 | pub struct UpdateTagRelationVariables<'a> { 5 | pub parent: &'a cynic::Id, 6 | pub remove: bool, 7 | pub tag: &'a cynic::Id, 8 | } 9 | 10 | #[derive(cynic::QueryFragment, Debug)] 11 | #[cynic( 12 | graphql_type = "MetadataMutation", 13 | variables = "UpdateTagRelationVariables" 14 | )] 15 | pub struct UpdateTagRelation { 16 | #[arguments(tagId: $tag, parentId: $parent, remove: $remove)] 17 | pub update_tag_relation: Option, 18 | } 19 | 20 | #[derive(cynic::QueryFragment, Debug)] 21 | pub struct TagRelation { 22 | pub id: cynic::Id, 23 | pub tag: TagBase, 24 | pub parent: TagBase, 25 | } 26 | -------------------------------------------------------------------------------- /anni-metadata/src/annim/query.rs: -------------------------------------------------------------------------------- 1 | pub mod album; 2 | pub mod albums; 3 | pub mod tag; 4 | -------------------------------------------------------------------------------- /anni-metadata/src/annim/query/albums.rs: -------------------------------------------------------------------------------- 1 | use crate::annim::{schema, Uuid}; 2 | 3 | use super::album::AlbumFragment; 4 | 5 | #[derive(cynic::QueryVariables, Debug)] 6 | pub struct AlbumsVariables { 7 | pub album_ids: Option>, 8 | pub after: Option, 9 | pub first: Option, 10 | } 11 | 12 | #[derive(cynic::QueryFragment, Debug)] 13 | #[cynic(graphql_type = "MetadataQuery", variables = "AlbumsVariables")] 14 | pub struct AlbumsQuery { 15 | #[arguments(by: { albumIds: $album_ids }, after: $after, first: $first )] 16 | pub albums: Option, 17 | } 18 | 19 | #[derive(cynic::QueryFragment, Debug)] 20 | pub struct AlbumConnection { 21 | pub page_info: PageInfo, 22 | pub nodes: Vec, 23 | } 24 | 25 | #[derive(cynic::QueryFragment, Debug)] 26 | pub struct PageInfo { 27 | pub end_cursor: Option, 28 | pub has_next_page: bool, 29 | pub has_previous_page: bool, 30 | pub start_cursor: Option, 31 | } 32 | -------------------------------------------------------------------------------- /anni-metadata/src/annim/query/tag.rs: -------------------------------------------------------------------------------- 1 | use super::album::{TagBase, TagTypeInput}; 2 | use crate::annim::{schema, DateTime}; 3 | 4 | #[derive(cynic::QueryVariables, Debug)] 5 | pub struct TagVariables<'a> { 6 | pub name: &'a str, 7 | #[cynic(rename = "type")] 8 | pub type_: Option, 9 | } 10 | 11 | #[derive(cynic::QueryFragment, Debug)] 12 | #[cynic(graphql_type = "MetadataQuery", variables = "TagVariables")] 13 | pub struct TagQuery { 14 | #[arguments(tagName: $name, tagType: $type_)] 15 | pub tag: Vec, 16 | } 17 | 18 | #[derive(cynic::QueryFragment, Debug)] 19 | #[cynic(graphql_type = "Tag")] 20 | pub struct Tag { 21 | pub id: cynic::Id, 22 | pub name: String, 23 | #[cynic(rename = "type")] 24 | pub type_: TagTypeInput, 25 | pub created_at: DateTime, 26 | pub updated_at: DateTime, 27 | pub includes: Vec, 28 | pub included_by: Vec, 29 | } 30 | -------------------------------------------------------------------------------- /anni-metadata/src/error.rs: -------------------------------------------------------------------------------- 1 | #[derive(thiserror::Error, Debug)] 2 | pub enum Error { 3 | #[error("invalid {target} toml: {err:?}\n{input}")] 4 | TomlParseError { 5 | target: &'static str, 6 | input: String, 7 | err: toml::de::Error, 8 | }, 9 | 10 | #[error("invalid track type: {0}")] 11 | InvalidTrackType(String), 12 | 13 | #[error("invalid date: {0}")] 14 | InvalidDate(String), 15 | 16 | #[error("invalid tag type: {0}")] 17 | InvalidTagType(String), 18 | 19 | #[error(transparent)] 20 | IOError(#[from] std::io::Error), 21 | 22 | #[error("multiple errors detected: {0:#?}")] 23 | MultipleErrors(Vec), 24 | } 25 | 26 | pub type MetadataResult = Result; 27 | -------------------------------------------------------------------------------- /anni-metadata/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod error; 2 | pub mod model; 3 | #[doc(hidden)] 4 | pub mod utils; 5 | 6 | #[cfg(feature = "annim")] 7 | pub mod annim; 8 | -------------------------------------------------------------------------------- /anni-metadata/src/model.rs: -------------------------------------------------------------------------------- 1 | mod album; 2 | mod date; 3 | mod tag; 4 | 5 | pub use album::*; 6 | pub use date::*; 7 | pub use tag::*; 8 | -------------------------------------------------------------------------------- /anni-metadata/src/utils.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | // https://github.com/serde-rs/serde/issues/1425#issuecomment-439729881 4 | pub fn non_empty_str<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { 5 | use serde::Deserialize; 6 | let o: Option = Option::deserialize(d)?; 7 | Ok(o.filter(|s| !s.is_empty())) 8 | } 9 | 10 | pub fn is_artists_empty(artists: &Option>) -> bool { 11 | match artists { 12 | Some(artists) => artists.is_empty(), 13 | None => true, 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /anni-playback/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | - Make `decoder::CODEC_REGISTRY` public 11 | - Upgraded `ratatui` used by example -------------------------------------------------------------------------------- /anni-playback/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "anni-playback" 3 | version = "0.1.0" 4 | 5 | edition.workspace = true 6 | authors.workspace = true 7 | repository.workspace = true 8 | license = "LGPL-3.0" 9 | 10 | [dependencies] 11 | cpal = "0.15.3" 12 | reqwest = { workspace = true, features = [ 13 | "blocking", 14 | "rustls-tls", 15 | ], default-features = false } 16 | symphonia = { version = "0.5.4", default-features = false, features = [ 17 | # codecs 18 | "aac", 19 | "flac", 20 | # formats 21 | "ogg", 22 | ] } 23 | symphonia-core = "0.5.4" 24 | crossbeam = { version = "0.8.2", features = ["crossbeam-channel"] } 25 | rubato = "0.14.0" 26 | rangemap = "1.3.0" 27 | arrayvec = "0.7.2" 28 | ebur128 = "0.1.7" 29 | anyhow.workspace = true 30 | once_cell.workspace = true 31 | audiopus = { git = "https://github.com/ProjectAnni/audiopus" } 32 | log.workspace = true 33 | anni-provider = { version = "0.3.1", path = "../anni-provider", default-features = false, features = [ 34 | "priority", 35 | ] } 36 | anni-common = { version = "0.2", path = "../anni-common" } 37 | thiserror.workspace = true 38 | serde.workspace = true 39 | serde_json.workspace = true 40 | 41 | [dev-dependencies] 42 | # used by tui example 43 | ratatui = { version = "0.25.0", features = ["crossterm"] } 44 | crossterm = "0.27.0" 45 | -------------------------------------------------------------------------------- /anni-playback/Readme.md: -------------------------------------------------------------------------------- 1 | # anni-playback 2 | 3 | A simple audio playback library based on [SimpleAudio](https://github.com/erikas-taroza/simple_audio). 4 | 5 | ## What's the difference? 6 | 7 | We've changed the following parts: 8 | 9 | 1. Removed Media Control 10 | As it is a simple playback library, controls should be implemented outside it. 11 | 2. Removed flutter_rust_bridge related code 12 | 3. Removed `update_*` callbacks and setters 13 | It can be implemented by simply listening events emitted by `Control::event_handler()`. 14 | 4. Removed the `Player` 15 | Users of this library can follow the example and write their own `Player` struct. -------------------------------------------------------------------------------- /anni-playback/examples/gapless.rs: -------------------------------------------------------------------------------- 1 | use std::{ops::Deref, sync::mpsc::Receiver, thread}; 2 | 3 | use anni_playback::{create_unbound_channel, types::PlayerEvent, Controls, Decoder}; 4 | 5 | pub struct Player { 6 | controls: Controls, 7 | } 8 | 9 | impl Player { 10 | pub fn new() -> (Player, Receiver) { 11 | let (sender, receiver) = std::sync::mpsc::channel(); 12 | let controls = Controls::new(sender); 13 | let thread_killer = create_unbound_channel(); 14 | 15 | thread::spawn({ 16 | let controls = controls.clone(); 17 | move || { 18 | let decoder = Decoder::new(controls, 48000, thread_killer.1.clone()); 19 | decoder.start(); 20 | } 21 | }); 22 | 23 | (Player { controls }, receiver) 24 | } 25 | } 26 | 27 | impl Deref for Player { 28 | type Target = Controls; 29 | 30 | fn deref(&self) -> &Self::Target { 31 | &self.controls 32 | } 33 | } 34 | 35 | fn main() -> anyhow::Result<()> { 36 | let (Some(first), Some(second), Some(third)) = ( 37 | std::env::args().nth(1), 38 | std::env::args().nth(2), 39 | std::env::args().nth(3), 40 | ) else { 41 | println!("Please provide three filenames to play!"); 42 | std::process::exit(1); 43 | }; 44 | 45 | let (player, receiver) = Player::new(); 46 | 47 | let thread = thread::spawn({ 48 | let controls = player.controls.clone(); 49 | 50 | move || loop { 51 | match receiver.recv() { 52 | Ok(msg) => match msg { 53 | PlayerEvent::Play => { 54 | println!("Play"); 55 | 56 | // Preload the second track after first track has started playing 57 | let _ = controls.open_file(second.clone(), true); 58 | } 59 | PlayerEvent::Pause => println!("Pause"), 60 | PlayerEvent::PreloadPlayed => { 61 | println!("PreloadPlayed"); 62 | 63 | // The second track is played, load the third track 64 | // FIXME: only load once 65 | let _ = controls.open_file(third.clone(), true); 66 | } 67 | PlayerEvent::Progress(progress) => { 68 | println!("Progress: {}/{}", progress.position, progress.duration); 69 | } 70 | PlayerEvent::Stop => println!("Stop"), 71 | }, 72 | Err(e) => { 73 | eprintln!("{}", e); 74 | } 75 | } 76 | } 77 | }); 78 | 79 | player.open_file(first, false)?; 80 | player.play(); 81 | thread.join().unwrap(); 82 | 83 | Ok(()) 84 | } 85 | -------------------------------------------------------------------------------- /anni-playback/examples/player.rs: -------------------------------------------------------------------------------- 1 | use std::{ops::Deref, sync::mpsc::Receiver, thread}; 2 | 3 | use anni_playback::{create_unbound_channel, types::PlayerEvent, Controls, Decoder}; 4 | 5 | pub struct Player { 6 | controls: Controls, 7 | } 8 | 9 | impl Player { 10 | pub fn new() -> (Player, Receiver) { 11 | let (sender, receiver) = std::sync::mpsc::channel(); 12 | let controls = Controls::new(sender); 13 | let thread_killer = create_unbound_channel(); 14 | 15 | thread::spawn({ 16 | let controls = controls.clone(); 17 | move || { 18 | let decoder = Decoder::new(controls, 48000, thread_killer.1.clone()); 19 | decoder.start(); 20 | } 21 | }); 22 | 23 | (Player { controls }, receiver) 24 | } 25 | } 26 | 27 | impl Deref for Player { 28 | type Target = Controls; 29 | 30 | fn deref(&self) -> &Self::Target { 31 | &self.controls 32 | } 33 | } 34 | 35 | fn main() -> anyhow::Result<()> { 36 | let Some(filename) = std::env::args().nth(1) else { 37 | println!("Please provide the filename to play!"); 38 | std::process::exit(1); 39 | }; 40 | 41 | let (player, receiver) = Player::new(); 42 | 43 | let thread = thread::spawn({ 44 | move || loop { 45 | match receiver.recv() { 46 | Ok(msg) => match msg { 47 | PlayerEvent::Play => println!("Play"), 48 | PlayerEvent::Pause => println!("Pause"), 49 | PlayerEvent::PreloadPlayed => println!("PreloadPlayed"), 50 | PlayerEvent::Progress(progress) => { 51 | println!("Progress: {}/{}", progress.position, progress.duration); 52 | } 53 | PlayerEvent::Stop => break, 54 | }, 55 | Err(e) => { 56 | eprintln!("{}", e); 57 | } 58 | } 59 | } 60 | }); 61 | 62 | player.open_file(filename, false)?; 63 | player.play(); 64 | thread.join().unwrap(); 65 | 66 | Ok(()) 67 | } 68 | -------------------------------------------------------------------------------- /anni-playback/src/decoder/mod.rs: -------------------------------------------------------------------------------- 1 | mod decoder; 2 | mod opus; 3 | 4 | pub use decoder::{Decoder, CODEC_REGISTRY}; 5 | -------------------------------------------------------------------------------- /anni-playback/src/dsp/mod.rs: -------------------------------------------------------------------------------- 1 | // This file is a part of simple_audio 2 | // Copyright (c) 2022-2023 Erikas Taroza 3 | // 4 | // This program is free software: you can redistribute it and/or 5 | // modify it under the terms of the GNU Lesser General Public License as 6 | // published by the Free Software Foundation, either version 3 of 7 | // the License, or (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. 12 | // See the GNU Lesser General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Lesser General Public License along with this program. 15 | // If not, see . 16 | 17 | pub mod normalizer; 18 | pub mod resampler; 19 | -------------------------------------------------------------------------------- /anni-playback/src/dsp/normalizer.rs: -------------------------------------------------------------------------------- 1 | // This file is a part of simple_audio 2 | // Copyright (c) 2022-2023 Erikas Taroza 3 | // 4 | // This program is free software: you can redistribute it and/or 5 | // modify it under the terms of the GNU Lesser General Public License as 6 | // published by the Free Software Foundation, either version 3 of 7 | // the License, or (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. 12 | // See the GNU Lesser General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Lesser General Public License along with this program. 15 | // If not, see . 16 | 17 | use ebur128::*; 18 | 19 | /// The target LUFS value. 20 | const NORMALIZE_TO: f64 = -14.0; 21 | const LOWER_THRESHOLD: f32 = 0.2; 22 | 23 | pub struct Normalizer { 24 | ebur128: EbuR128, 25 | buffer: Vec, 26 | /// True if the input samples are loud enough to start being normalized. 27 | /// This prevents normalizing parts of a song that the artist intented to be quiet. 28 | passed_lower_threshold: bool, 29 | } 30 | 31 | impl Normalizer { 32 | pub fn new(channels: usize, sample_rate: u32) -> Self { 33 | let ebur128 = EbuR128::new(channels as u32, sample_rate, Mode::I.union(Mode::M)).unwrap(); 34 | 35 | Normalizer { 36 | ebur128, 37 | buffer: Vec::new(), 38 | passed_lower_threshold: false, 39 | } 40 | } 41 | 42 | pub fn normalize(&mut self, input: &[f32]) -> Option<&[f32]> { 43 | // Completely quiet inputs cause a crackling sound to be made. 44 | if !input.iter().any(|x| *x != 0.0) { 45 | return None; 46 | } 47 | 48 | // Don't apply any gain when threshold is not passed. 49 | if !self.passed_lower_threshold { 50 | let samples_passing_threshold = input[0..3].iter().find(|e| **e >= LOWER_THRESHOLD); 51 | self.passed_lower_threshold = samples_passing_threshold.is_some(); 52 | return None; 53 | } 54 | 55 | let _ = self.ebur128.add_frames_f32(input); 56 | 57 | let global_loudness = self.ebur128.loudness_global().unwrap(); 58 | let gain = if global_loudness.is_finite() { 59 | calc_gain(global_loudness) 60 | } else { 61 | let loudness = self.ebur128.loudness_momentary().unwrap(); 62 | calc_gain(loudness) 63 | }; 64 | 65 | let gain = gain.clamp(0.0, 1.2); 66 | 67 | self.buffer.clear(); 68 | self.buffer.extend_from_slice(input); 69 | 70 | self.buffer.iter_mut().for_each(|sample| *sample *= gain); 71 | Some(&self.buffer) 72 | } 73 | } 74 | 75 | fn calc_gain(loudness: f64) -> f32 { 76 | let gain_db = NORMALIZE_TO - loudness; 77 | 10.0_f32.powf(gain_db as f32 / 20.0) 78 | } 79 | -------------------------------------------------------------------------------- /anni-playback/src/lib.rs: -------------------------------------------------------------------------------- 1 | // This file is a part of simple_audio 2 | // Copyright (c) 2022-2023 Erikas Taroza 3 | // 4 | // This program is free software: you can redistribute it and/or 5 | // modify it under the terms of the GNU Lesser General Public License as 6 | // published by the Free Software Foundation, either version 3 of 7 | // the License, or (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. 12 | // See the GNU Lesser General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Lesser General Public License along with this program. 15 | // If not, see . 16 | 17 | mod controls; 18 | mod cpal_output; 19 | mod decoder; 20 | mod dsp; 21 | mod utils; 22 | 23 | pub use controls::Controls; 24 | pub use decoder::*; 25 | pub mod player; 26 | pub mod sources; 27 | pub mod types; 28 | 29 | pub use utils::create_unbound_channel; 30 | -------------------------------------------------------------------------------- /anni-playback/src/sources/cached_http/provider.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Display; 2 | 3 | use reqwest::blocking::{Client, Response}; 4 | 5 | use anni_common::models::RawTrackIdentifier; 6 | 7 | #[derive(Debug, Copy, Clone)] 8 | pub enum AudioQuality { 9 | Low, 10 | Medium, 11 | High, 12 | Lossless, 13 | } 14 | 15 | impl Display for AudioQuality { 16 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 17 | match self { 18 | AudioQuality::Low => write!(f, "low"), 19 | AudioQuality::Medium => write!(f, "medium"), 20 | AudioQuality::High => write!(f, "high"), 21 | AudioQuality::Lossless => write!(f, "lossless"), 22 | } 23 | } 24 | } 25 | 26 | pub struct ProviderProxy { 27 | url: String, 28 | client: Client, 29 | auth: String, 30 | } 31 | 32 | impl ProviderProxy { 33 | pub fn new(url: String, auth: String, client: Client) -> Self { 34 | Self { url, auth, client } 35 | } 36 | 37 | pub fn format_url( 38 | &self, 39 | track: RawTrackIdentifier, 40 | quality: AudioQuality, 41 | opus: bool, 42 | ) -> String { 43 | format!( 44 | "{}/{}?auth={}&quality={}&opus={}", 45 | self.url, track, self.auth, quality, opus 46 | ) 47 | } 48 | 49 | pub fn get( 50 | &self, 51 | track: RawTrackIdentifier, 52 | quality: AudioQuality, 53 | opus: bool, 54 | ) -> reqwest::Result { 55 | self.client 56 | .get(self.format_url(track, quality, opus)) 57 | .send() 58 | } 59 | 60 | pub fn head( 61 | &self, 62 | track: RawTrackIdentifier, 63 | quality: AudioQuality, 64 | opus: bool, 65 | ) -> reqwest::Result { 66 | self.client 67 | .get(self.format_url(track, quality, opus)) 68 | // .header("Authorization", &self.auth) 69 | .send() 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /anni-playback/src/sources/mod.rs: -------------------------------------------------------------------------------- 1 | // This file is a part of simple_audio 2 | // Copyright (c) 2022-2023 Erikas Taroza 3 | // 4 | // This program is free software: you can redistribute it and/or 5 | // modify it under the terms of the GNU Lesser General Public License as 6 | // published by the Free Software Foundation, either version 3 of 7 | // the License, or (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. 12 | // See the GNU Lesser General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Lesser General Public License along with this program. 15 | // If not, see . 16 | 17 | use symphonia_core::io::MediaSource; 18 | 19 | pub mod cached_http; 20 | pub mod http; 21 | pub mod streamable; 22 | 23 | /// A type that holds an ID and a `std::sync::mpsc::Receiver`. 24 | /// Used for multithreaded download of audio data. 25 | struct Receiver { 26 | id: u128, 27 | receiver: std::sync::mpsc::Receiver<(usize, Vec)>, 28 | } 29 | 30 | pub trait AnniSource: MediaSource + IntoBoxedMediaSource { 31 | /// The duration of underlying source in seconds. 32 | fn duration_hint(&self) -> Option { 33 | None 34 | } 35 | } 36 | 37 | impl MediaSource for Box { 38 | fn is_seekable(&self) -> bool { 39 | self.as_ref().is_seekable() 40 | } 41 | 42 | fn byte_len(&self) -> Option { 43 | self.as_ref().byte_len() 44 | } 45 | } 46 | 47 | impl AnniSource for std::fs::File {} 48 | 49 | // helper trait to do upcasting 50 | pub trait IntoBoxedMediaSource { 51 | fn into_media_source(self: Box) -> Box; 52 | } 53 | 54 | impl IntoBoxedMediaSource for T { 55 | fn into_media_source(self: Box) -> Box { 56 | self 57 | } 58 | } 59 | 60 | impl From> for Box { 61 | fn from(value: Box) -> Self { 62 | value.into_media_source() 63 | } 64 | } 65 | 66 | // Specialization is not well-supported so far (even the unstable feature is unstable ww). 67 | // Therefore, we do not provide the default implementation below. 68 | // Users can use a newtype pattern if needed. 69 | // 70 | // default impl AnniSource for T { 71 | // fn duration_hint(&self) -> Option { 72 | // None 73 | // } 74 | // } 75 | -------------------------------------------------------------------------------- /anni-playback/src/sources/streamable.rs: -------------------------------------------------------------------------------- 1 | // This file is a part of simple_audio 2 | // Copyright (c) 2022-2023 Erikas Taroza 3 | // 4 | // This program is free software: you can redistribute it and/or 5 | // modify it under the terms of the GNU Lesser General Public License as 6 | // published by the Free Software Foundation, either version 3 of 7 | // the License, or (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. 12 | // See the GNU Lesser General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Lesser General Public License along with this program. 15 | // If not, see . 16 | 17 | use std::{ 18 | io::{Read, Seek}, 19 | sync::mpsc::Sender, 20 | }; 21 | 22 | use symphonia::core::io::MediaSource; 23 | 24 | pub const CHUNK_SIZE: usize = 1024 * 256; 25 | 26 | pub trait Streamable: Read + Seek + Send + Sync + MediaSource { 27 | fn read_chunk( 28 | tx: Sender<(usize, Vec)>, 29 | url: String, 30 | start: usize, 31 | file_size: usize, 32 | ) -> anyhow::Result<()>; 33 | 34 | fn try_write_chunk(&mut self, should_buffer: bool); 35 | fn should_get_chunk(&self) -> (bool, usize); 36 | } 37 | -------------------------------------------------------------------------------- /anni-playback/src/types.rs: -------------------------------------------------------------------------------- 1 | // This file is a part of simple_audio 2 | // Copyright (c) 2022-2023 Erikas Taroza 3 | // 4 | // This program is free software: you can redistribute it and/or 5 | // modify it under the terms of the GNU Lesser General Public License as 6 | // published by the Free Software Foundation, either version 3 of 7 | // the License, or (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. 12 | // See the GNU Lesser General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Lesser General Public License along with this program. 15 | // If not, see . 16 | 17 | use std::sync::{atomic::AtomicBool, Arc}; 18 | 19 | pub use crossbeam::channel::{Receiver, Sender}; 20 | pub use symphonia_core::io::MediaSource; 21 | 22 | pub use crate::sources::AnniSource; 23 | 24 | /// Provides the current progress of the player. 25 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 26 | pub struct ProgressState { 27 | /// The position, in milliseconds, of the player. 28 | pub position: u64, 29 | /// The duration, in milliseconds, of the file that 30 | /// is being played. 31 | pub duration: u64, 32 | } 33 | 34 | pub(crate) enum InternalPlayerEvent { 35 | Open(Box, Arc), 36 | Play, 37 | Pause, 38 | Stop, 39 | /// Called by `cpal_output` in the event the device outputting 40 | /// audio was changed/disconnected. 41 | DeviceChanged, 42 | Preload(Box, Arc), 43 | PlayPreloaded, 44 | } 45 | 46 | #[derive(Debug)] 47 | pub enum PlayerEvent { 48 | /// Started playing 49 | Play, 50 | /// Paused 51 | Pause, 52 | /// Stopped 53 | Stop, 54 | /// Preload track is played. Should set next track to play 55 | PreloadPlayed, 56 | /// Playback progress updated 57 | Progress(ProgressState), 58 | } 59 | -------------------------------------------------------------------------------- /anni-playback/src/utils/mod.rs: -------------------------------------------------------------------------------- 1 | // This file is a part of simple_audio 2 | // Copyright (c) 2022-2023 Erikas Taroza 3 | // 4 | // This program is free software: you can redistribute it and/or 5 | // modify it under the terms of the GNU Lesser General Public License as 6 | // published by the Free Software Foundation, either version 3 of 7 | // the License, or (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. 12 | // See the GNU Lesser General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Lesser General Public License along with this program. 15 | // If not, see . 16 | 17 | pub mod blocking_rb; 18 | 19 | pub fn create_unbound_channel() -> ( 20 | crossbeam::channel::Sender, 21 | crossbeam::channel::Receiver, 22 | ) { 23 | crossbeam::channel::unbounded() 24 | } 25 | -------------------------------------------------------------------------------- /anni-provider/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## 0.3.1 9 | 10 | - Upgrade `anni-common` to `0.2.0` 11 | 12 | ## 0.3.0 13 | 14 | - **[Breaking]** Change definition of `AudioInfo::duration`. Now this value uses milliseconds instead of seconds. 15 | - Added `PriorityProvider` and `priority` feature 16 | - Upgrade `anni-common` to 0.1.4 17 | 18 | ## 0.2.0 19 | 20 | - Upgrade `anni-repo` to `0.3.0` 21 | 22 | ## 0.1.3 23 | 24 | - Upgrade `lru` to `0.10.0` 25 | - Upgrade `anni-repo` to `0.2.0` 26 | 27 | ## 0.1.2 28 | 29 | - Added `MultipleProviders`, allows user to combine multiple `AnniProviders` and serve as a whole. 30 | - Implemented `AnniProvider::album` for `NoCacheStrictLocalProvider` correctly. 31 | - Fixed `range` returned by `NoCacheStrictLocalProvider::get_audio`. 32 | -------------------------------------------------------------------------------- /anni-provider/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "anni-provider" 3 | version = "0.3.1" 4 | description = "Storage providers for Project Anni." 5 | 6 | edition.workspace = true 7 | authors.workspace = true 8 | repository.workspace = true 9 | license.workspace = true 10 | 11 | [dependencies] 12 | tokio = { version = "1", features = ["time", "fs", "rt"] } 13 | tokio-util = { version = "0.7.2", features = ["compat", "io"] } 14 | tokio-stream = "0.1.8" 15 | futures = "0.3" 16 | async-trait = "0.1" 17 | 18 | anni-google-drive3 = { version = "0.1.0", path = "../third_party/google-drive3", optional = true } 19 | anni-repo = { version = "0.4.2", path = "../anni-repo", features = [ 20 | "db-read", 21 | ], optional = true } 22 | anni-common.workspace = true 23 | 24 | thiserror.workspace = true 25 | log.workspace = true 26 | uuid.workspace = true 27 | parking_lot = "0.12.0" 28 | dashmap = "5.2.0" 29 | lru = "0.12.0" 30 | anni-flac = { version = "0.2.2", path = "../anni-flac", features = ["async"] } 31 | reqwest = { workspace = true, features = ["json", "stream"], optional = true } 32 | 33 | [features] 34 | default = ["full"] 35 | full = ["convention", "drive", "proxy", "strict", "priority"] 36 | convention = ["repo"] 37 | drive = ["repo", "anni-google-drive3"] 38 | proxy = ["reqwest"] 39 | repo = ["anni-repo"] 40 | strict = [] 41 | priority = [] 42 | -------------------------------------------------------------------------------- /anni-provider/src/fs/local.rs: -------------------------------------------------------------------------------- 1 | use crate::{FileEntry, FileSystemProvider, ProviderError, Range, ResourceReader}; 2 | use async_trait::async_trait; 3 | use std::io::SeekFrom; 4 | use std::path::PathBuf; 5 | use std::pin::Pin; 6 | use tokio::fs::read_dir; 7 | use tokio::io::{AsyncReadExt, AsyncSeekExt}; 8 | use tokio_stream::{self as stream, Stream}; 9 | 10 | pub struct LocalFileSystemProvider; 11 | 12 | #[async_trait] 13 | impl FileSystemProvider for LocalFileSystemProvider { 14 | async fn children( 15 | &self, 16 | path: &PathBuf, 17 | ) -> crate::Result + Send>>> { 18 | Ok(Box::pin(stream::iter(std::fs::read_dir(path)?.filter_map( 19 | |entry| { 20 | let entry = entry.ok()?; 21 | let path = entry.path(); 22 | let name = path.file_name()?.to_string_lossy().to_string(); 23 | if path.is_dir() { 24 | Some(FileEntry { name, path }) 25 | } else { 26 | None 27 | } 28 | }, 29 | )))) 30 | } 31 | 32 | async fn get_file_entry_by_prefix( 33 | &self, 34 | parent: &PathBuf, 35 | prefix: &str, 36 | ) -> crate::Result { 37 | let mut dir = read_dir(parent).await?; 38 | loop { 39 | match dir.next_entry().await? { 40 | Some(entry) if entry.file_name().to_string_lossy().starts_with(prefix) => { 41 | return Ok(FileEntry { 42 | name: entry.file_name().to_string_lossy().to_string(), 43 | path: entry.path(), 44 | }); 45 | } 46 | None => return Err(ProviderError::FileNotFound), 47 | _ => {} 48 | } 49 | } 50 | } 51 | 52 | async fn get_file(&self, path: &PathBuf, range: Range) -> crate::Result { 53 | let mut file = tokio::fs::File::open(path).await?; 54 | let metadata = file.metadata().await?; 55 | let file_size = metadata.len(); 56 | 57 | file.seek(SeekFrom::Start(range.start)).await?; 58 | let file = file.take(range.length_limit(file_size)); 59 | Ok(Box::pin(file)) 60 | } 61 | 62 | async fn get_audio_info(&self, path: &PathBuf) -> crate::Result<(String, usize)> { 63 | let extension = path 64 | .extension() 65 | .map(|e| e.to_string_lossy().to_string()) 66 | .unwrap_or_default(); 67 | let size = tokio::fs::metadata(path).await.map(|m| m.len())?; 68 | Ok((extension, size as usize)) 69 | } 70 | 71 | async fn reload(&mut self) -> crate::Result<()> { 72 | Ok(()) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /anni-provider/src/fs/mod.rs: -------------------------------------------------------------------------------- 1 | mod local; 2 | pub use local::LocalFileSystemProvider; 3 | -------------------------------------------------------------------------------- /anni-provider/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub use common::*; 2 | 3 | pub mod cache; 4 | mod common; 5 | pub mod fs; 6 | pub mod providers; 7 | mod utils; 8 | 9 | #[cfg(feature = "repo")] 10 | pub use anni_repo::db::RepoDatabaseRead; 11 | -------------------------------------------------------------------------------- /anni-provider/src/providers/mod.rs: -------------------------------------------------------------------------------- 1 | #[cfg(feature = "convention")] 2 | pub use convention::CommonConventionProvider; 3 | #[cfg(feature = "drive")] 4 | pub use drive::DriveProvider; 5 | pub use multiple::MultipleProviders; 6 | pub use no_cache::NoCacheStrictLocalProvider; 7 | #[cfg(feature = "priority")] 8 | pub use priority::{PriorityProvider, TypedPriorityProvider}; 9 | #[cfg(feature = "proxy")] 10 | pub use proxy::ProxyBackend; 11 | #[cfg(feature = "strict")] 12 | pub use strict::CommonStrictProvider; 13 | 14 | #[cfg(feature = "convention")] 15 | mod convention; 16 | #[cfg(feature = "drive")] 17 | pub mod drive; 18 | mod multiple; 19 | mod no_cache; 20 | #[cfg(feature = "priority")] 21 | mod priority; 22 | #[cfg(feature = "proxy")] 23 | mod proxy; 24 | #[cfg(feature = "strict")] 25 | mod strict; 26 | -------------------------------------------------------------------------------- /anni-provider/src/providers/multiple.rs: -------------------------------------------------------------------------------- 1 | use crate::{AnniProvider, AudioInfo, AudioResourceReader, ProviderError, Range, ResourceReader}; 2 | use async_trait::async_trait; 3 | use std::borrow::Cow; 4 | use std::collections::HashSet; 5 | use std::num::NonZeroU8; 6 | 7 | /// [MultipleProviders] combines multiple anni providers as a whole. 8 | pub struct MultipleProviders(Vec>); 9 | 10 | impl MultipleProviders { 11 | pub fn new(providers: Vec>) -> Self { 12 | Self(providers) 13 | } 14 | } 15 | 16 | #[async_trait] 17 | impl AnniProvider for MultipleProviders { 18 | async fn albums(&self) -> crate::Result>> { 19 | let mut albums: HashSet> = HashSet::new(); 20 | for provider in self.0.iter() { 21 | albums.extend(provider.albums().await?); 22 | } 23 | Ok(albums) 24 | } 25 | 26 | async fn has_album(&self, album_id: &str) -> bool { 27 | for provider in self.0.iter() { 28 | if provider.has_album(album_id).await { 29 | return true; 30 | } 31 | } 32 | 33 | return false; 34 | } 35 | 36 | async fn get_audio_info( 37 | &self, 38 | album_id: &str, 39 | disc_id: NonZeroU8, 40 | track_id: NonZeroU8, 41 | ) -> crate::Result { 42 | for provider in self.0.iter() { 43 | if provider.has_album(album_id).await { 44 | return provider.get_audio_info(album_id, disc_id, track_id).await; 45 | } 46 | } 47 | 48 | Err(ProviderError::FileNotFound) 49 | } 50 | 51 | async fn get_audio( 52 | &self, 53 | album_id: &str, 54 | disc_id: NonZeroU8, 55 | track_id: NonZeroU8, 56 | range: Range, 57 | ) -> crate::Result { 58 | for provider in self.0.iter() { 59 | if provider.has_album(album_id).await { 60 | return provider.get_audio(album_id, disc_id, track_id, range).await; 61 | } 62 | } 63 | 64 | Err(ProviderError::FileNotFound) 65 | } 66 | 67 | async fn get_cover( 68 | &self, 69 | album_id: &str, 70 | disc_id: Option, 71 | ) -> crate::Result { 72 | for provider in self.0.iter() { 73 | if provider.has_album(album_id).await { 74 | return provider.get_cover(album_id, disc_id).await; 75 | } 76 | } 77 | 78 | Err(ProviderError::FileNotFound) 79 | } 80 | 81 | async fn reload(&mut self) -> crate::Result<()> { 82 | let mut error = Ok(()); 83 | for provider in self.0.iter_mut() { 84 | if let (Ok(()), Err(e)) = (&error, provider.reload().await) { 85 | error = Err(e); 86 | } 87 | } 88 | 89 | error 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /anni-provider/src/utils.rs: -------------------------------------------------------------------------------- 1 | use crate::{Range, ResourceReader}; 2 | use anni_flac::blocks::BlockStreamInfo; 3 | use anni_flac::prelude::{AsyncDecode, Encode, Result}; 4 | use std::io::Cursor; 5 | use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt}; 6 | 7 | async fn read_header(mut reader: R) -> Result<(BlockStreamInfo, ResourceReader)> 8 | where 9 | R: AsyncRead + Unpin + Send + 'static, 10 | { 11 | let first = reader.read_u32().await.unwrap(); 12 | let second = reader.read_u32().await.unwrap(); 13 | let info = BlockStreamInfo::from_async_reader(&mut reader).await?; 14 | 15 | let mut header = Cursor::new(Vec::with_capacity(4 + 4 + 34)); 16 | header.write_u32(first).await.unwrap(); 17 | header.write_u32(second).await.unwrap(); 18 | info.write_to(&mut header).unwrap(); 19 | header.set_position(0); 20 | 21 | Ok((info, Box::pin(header.chain(reader)))) 22 | } 23 | 24 | pub(crate) async fn read_duration( 25 | reader: ResourceReader, 26 | range: Range, 27 | ) -> Result<(u64, ResourceReader)> { 28 | if !range.contains_flac_header() { 29 | return Ok((0, reader)); 30 | } 31 | 32 | let (info, reader) = read_header(reader).await?; 33 | let duration = info.total_samples * 1000 / info.sample_rate as u64; 34 | Ok((duration, Box::pin(reader))) 35 | } 36 | -------------------------------------------------------------------------------- /anni-repo/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## Unreleased 9 | 10 | - Changed internal structure of AnniDate 11 | - Changed return type of `Tag::parents` from `&[TagString]` to `Iterator<&TagRef>` 12 | 13 | ## 0.4.2 14 | 15 | - Upgraded `anni-common` to `0.2.0` 16 | - Upgraded `git2`, `rusqlite` and `tantivy` 17 | 18 | ## 0.4.1 19 | 20 | - `load_albums` now return error on tag resolve failure instead of panic 21 | - Add `AnniDate::to_short_string` to print date in `YYMMDD` format 22 | - Upgrade `anni-common` to 0.1.4 23 | 24 | ## 0.3.2 25 | 26 | - [Deprecation] Rename `RepoDatabaseRead::get_tag` to `get_item_tags` 27 | - Added `RepoDatabaseRead::get_tag_relationship` 28 | - Removed `RepoDatabase::get_tags`, a never-used method 29 | 30 | ## 0.3.1 31 | 32 | - impl `ToString` for `Album` 33 | - Upgrade `toml` and `toml_edit` 34 | 35 | ## 0.3.0 36 | 37 | - [Breaking] Change signature of `DiscInfo::new` and `Track::new` 38 | - `Album::format` now works as expected 39 | - Added `UNKNOWN_ARTIST` constant. 40 | 41 | ## 0.2.1 42 | 43 | - Fix build when `search` feature is used 44 | 45 | ## 0.2.0 46 | 47 | - [Breaking] Remove `From<&Album> for serde_json::Value`, add new `JsonAlbum` for json exchange format under `json` 48 | - Upgrade `lindera-tantivy` to `0.23.0`. Use `ipadic-compress` by default. 49 | - Upgrade `tantivy` to `0.19.2` 50 | - Upgrade `git2` to `0.16.1` 51 | - Fix tag type check constraint defined in `repo_tag` table 52 | feature 53 | - Use `toml` instead of deprecated `toml_edit::easy` 54 | - Add `apply` feature to enable `apply` method in `Album` 55 | -------------------------------------------------------------------------------- /anni-repo/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "anni-repo" 3 | version = "0.4.2" 4 | description = "Operate on anni metadata repository." 5 | 6 | edition.workspace = true 7 | authors.workspace = true 8 | repository.workspace = true 9 | license.workspace = true 10 | 11 | [lib] 12 | crate-type = ["cdylib", "rlib"] 13 | 14 | [dependencies] 15 | toml_edit = "0.21.0" 16 | # TODO: remove usage of toml, use toml_edit directly 17 | toml.workspace = true 18 | serde.workspace = true 19 | serde_json = { workspace = true, optional = true } 20 | regex = "1" 21 | thiserror.workspace = true 22 | anni-common.workspace = true 23 | uuid = { workspace = true, features = ["serde"] } 24 | log.workspace = true 25 | once_cell.workspace = true 26 | pathdiff = "0.2.1" 27 | indexmap = "2.5.0" 28 | anni-artist = "0.1.1" 29 | 30 | # flac 31 | anni-flac = { version = "0.2.2", path = "../anni-flac", optional = true } 32 | alphanumeric-sort = { version = "1.4.4", optional = true } 33 | 34 | # Git related 35 | git2 = { version = "0.18.1", optional = true, default-features = false, features = [ 36 | "vendored-libgit2", 37 | ] } 38 | git2-ureq = { version = "0.3.0", optional = true, features = ["socks-proxy"] } 39 | 40 | # SQLite related 41 | rusqlite = { version = "0.30.0", optional = true, features = [ 42 | "uuid", 43 | "bundled", 44 | "serde_json", 45 | ] } 46 | serde_rusqlite = { version = "0.34.0", optional = true } 47 | 48 | # Search 49 | tantivy = { version = "0.21.1", optional = true } 50 | lindera-core = { version = "0.27.2", optional = true } 51 | lindera-dictionary = { version = "0.27.2", optional = true } 52 | lindera-tantivy = { version = "0.27.1", optional = true, features = [ 53 | "ipadic-compress", 54 | ] } 55 | anni-metadata.workspace = true 56 | 57 | 58 | # WASM dependencies 59 | # comment those dependencies when publishing to crates.io 60 | #[target.wasm32-unknown-unknown.dependencies] 61 | #getrandom = { version = "0.2", features = ["js"] } 62 | #js-sys = "0.3.56" 63 | #wasm-bindgen = { version = "0.2.79", features = ["serde-serialize"] } 64 | #serde-wasm-bindgen = "0.4.2" 65 | #sqlite-vfs = { git = "https://github.com/ProjectAnni/sqlite-vfs" } 66 | 67 | [features] 68 | default = ["json"] 69 | apply = ["flac", "alphanumeric-sort"] 70 | db = ["db-read", "db-write"] 71 | db-read = ["rusqlite", "serde_rusqlite"] 72 | db-write = ["rusqlite"] 73 | git = ["git2", "git2-ureq"] 74 | flac = ["anni-flac"] 75 | json = ["serde_json"] 76 | search = ["tantivy", "lindera-core", "lindera-dictionary", "lindera-tantivy"] 77 | -------------------------------------------------------------------------------- /anni-repo/README.md: -------------------------------------------------------------------------------- 1 | # anni-repo 2 | 3 | ## Publish 4 | 5 | ```bash 6 | # 1. Increase version 7 | # 2. Build 8 | wasm-pack build ./anni-repo --release --out-dir ../npm/repo --scope project-anni -- --features db-read 9 | # 3. Rename package, from @project-anni/anni-repo to @project-anni/repo 10 | # 4. Publish 11 | cd npm/repo && npm publish --access public 12 | ``` -------------------------------------------------------------------------------- /anni-repo/src/db/mod.rs: -------------------------------------------------------------------------------- 1 | mod rows; 2 | 3 | pub const DB_VERSION: &str = "1.1"; 4 | 5 | #[cfg(feature = "db-read")] 6 | mod read; 7 | 8 | #[cfg(feature = "db-read")] 9 | pub use read::RepoDatabaseRead; 10 | 11 | #[cfg(feature = "db-write")] 12 | mod write; 13 | 14 | #[cfg(feature = "db-write")] 15 | pub use write::RepoDatabaseWrite; 16 | 17 | #[cfg(target_arch = "wasm32")] 18 | pub(crate) mod fs; 19 | -------------------------------------------------------------------------------- /anni-repo/src/error.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use anni_metadata::model::TagRef; 4 | 5 | #[derive(thiserror::Error, Debug)] 6 | pub enum Error { 7 | #[error("invalid {target} toml: {err:?}\n{input}")] 8 | TomlParseError { 9 | target: &'static str, 10 | input: String, 11 | err: toml::de::Error, 12 | }, 13 | 14 | #[error("album with the same catalog already exists: {0}")] 15 | RepoAlbumExists(String), 16 | 17 | #[error("duplicated album: {0}")] 18 | RepoDuplicatedAlbumId(String), 19 | 20 | #[error("failed to load album {album:?} in repository")] 21 | RepoAlbumLoadError { album: String }, 22 | 23 | #[error("failed to load tags from {file:?}")] 24 | RepoTagLoadError { file: PathBuf }, 25 | 26 | #[error("undefined tags {0:?}")] 27 | RepoTagsUndefined(Vec>), 28 | 29 | #[error("duplicated tag: {0}")] 30 | RepoTagDuplicated(TagRef<'static>), 31 | 32 | #[error("repo is locked by another instance")] 33 | RepoInUse, 34 | 35 | #[error("invalid track type: {0}")] 36 | InvalidTrackType(String), 37 | 38 | #[error("invalid date: {0}")] 39 | InvalidDate(String), 40 | 41 | #[error(transparent)] 42 | IOError(#[from] std::io::Error), 43 | 44 | #[cfg(any(feature = "db-read", feature = "db-write"))] 45 | #[error(transparent)] 46 | SqliteError(#[from] rusqlite::Error), 47 | 48 | #[cfg(feature = "db-read")] 49 | #[error(transparent)] 50 | SqliteDeserializeError(#[from] serde_rusqlite::Error), 51 | 52 | #[error(transparent)] 53 | MetadataError(#[from] anni_metadata::error::Error), 54 | 55 | #[cfg(feature = "git")] 56 | #[error(transparent)] 57 | GitError(#[from] git2::Error), 58 | 59 | #[error("multiple errors detected: {0:#?}")] 60 | MultipleErrors(Vec), 61 | } 62 | 63 | #[cfg(feature = "apply")] 64 | #[derive(thiserror::Error, Debug)] 65 | pub enum AlbumApplyError { 66 | #[cfg(feature = "apply")] 67 | #[error("Disc count mismatch when applying album {path}: expected {expected}, got {actual}")] 68 | DiscMismatch { 69 | path: PathBuf, 70 | expected: usize, 71 | actual: usize, 72 | }, 73 | 74 | #[error("Track count mismatch when applying album {path}: expected {expected}, got {actual}")] 75 | TrackMismatch { 76 | path: PathBuf, 77 | expected: usize, 78 | actual: usize, 79 | }, 80 | 81 | #[error("Invalid disc folder name {0} got.")] 82 | InvalidDiscFolder(PathBuf), 83 | 84 | #[error("Missing cover file at: {0}")] 85 | MissingCover(PathBuf), 86 | 87 | #[error(transparent)] 88 | IOError(#[from] std::io::Error), 89 | 90 | #[error(transparent)] 91 | FlacParseError(#[from] anni_flac::error::FlacError), 92 | } 93 | -------------------------------------------------------------------------------- /anni-repo/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod error; 2 | pub mod library; 3 | mod manager; 4 | pub mod models; 5 | 6 | #[cfg(feature = "search")] 7 | pub mod search; 8 | 9 | pub mod prelude { 10 | pub use crate::error::Error; 11 | pub use crate::models::*; 12 | 13 | pub type RepoResult = Result; 14 | } 15 | 16 | pub mod db; 17 | pub(crate) mod utils; 18 | 19 | pub use manager::{OwnedRepositoryManager, RepositoryManager}; 20 | 21 | #[cfg(feature = "git")] 22 | pub use utils::git::setup_git2; 23 | -------------------------------------------------------------------------------- /anni-repo/src/models/mod.rs: -------------------------------------------------------------------------------- 1 | mod album; 2 | mod repo; 3 | 4 | pub use album::*; 5 | pub use repo::*; 6 | 7 | #[cfg(feature = "json")] 8 | mod json; 9 | #[cfg(feature = "json")] 10 | pub use json::*; 11 | -------------------------------------------------------------------------------- /anni-repo/src/models/repo.rs: -------------------------------------------------------------------------------- 1 | use crate::prelude::*; 2 | use serde::{Deserialize, Serialize}; 3 | use std::str::FromStr; 4 | 5 | #[derive(Serialize, Deserialize)] 6 | pub struct Repository { 7 | repo: RepositoryInner, 8 | } 9 | 10 | #[derive(Serialize, Deserialize)] 11 | struct RepositoryInner { 12 | name: String, 13 | edition: String, 14 | #[serde(default = "default_albums")] 15 | albums: Vec, 16 | } 17 | 18 | fn default_albums() -> Vec { 19 | vec!["album".into()] 20 | } 21 | 22 | impl FromStr for Repository { 23 | type Err = Error; 24 | 25 | fn from_str(s: &str) -> Result { 26 | let val: Repository = toml::from_str(s).map_err(|e| Error::TomlParseError { 27 | target: "Repository", 28 | input: s.to_string(), 29 | err: e, 30 | })?; 31 | Ok(val) 32 | } 33 | } 34 | 35 | impl ToString for Repository { 36 | fn to_string(&self) -> String { 37 | toml::to_string_pretty(&self).unwrap() 38 | } 39 | } 40 | 41 | impl Repository { 42 | pub fn name(&self) -> &str { 43 | self.repo.name.as_ref() 44 | } 45 | 46 | pub fn edition(&self) -> &str { 47 | self.repo.edition.as_ref() 48 | } 49 | 50 | pub fn albums(&self) -> &[String] { 51 | self.repo.albums.as_ref() 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /anni-repo/src/utils/mod.rs: -------------------------------------------------------------------------------- 1 | #[cfg(feature = "git")] 2 | pub(crate) mod git; 3 | -------------------------------------------------------------------------------- /anni-repo/tests/album.rs: -------------------------------------------------------------------------------- 1 | use std::str::FromStr; 2 | 3 | use anni_metadata::model::{Album, TrackType}; 4 | 5 | fn album_from_str() -> Album { 6 | Album::from_str(include_str!("fixtures/test-album.toml")).expect("Failed to parse album toml.") 7 | } 8 | 9 | #[test] 10 | fn test_serialize_album() { 11 | let mut album = album_from_str(); 12 | assert_eq!( 13 | album.format_to_string(), 14 | include_str!("fixtures/test-album.toml") 15 | ); 16 | } 17 | 18 | #[test] 19 | fn test_deserialize_album() { 20 | let album = album_from_str(); 21 | assert_eq!( 22 | album.album_id().to_string(), 23 | "15006392-e2ae-4204-b7db-e59211f3cdcf".to_string() 24 | ); 25 | assert_eq!(album.full_title(), "夏凪ぎ/宝物になった日【Test】"); 26 | assert_eq!(album.artist(), "やなぎなぎ"); 27 | assert_eq!(album.release_date().to_string(), "2020-12-16"); 28 | assert_eq!(album.track_type().as_ref(), "normal"); 29 | assert_eq!(album.catalog(), "KSLA-0178"); 30 | 31 | let tags = album.album_tags(); 32 | assert_eq!(tags[0].name(), "tag1"); 33 | assert_eq!(tags[1].name(), "tag2"); 34 | 35 | // TODO: assert for tags 36 | for disc in album.iter() { 37 | assert_eq!(disc.catalog(), "KSLA-0178"); 38 | for (i, track) in disc.iter().enumerate() { 39 | match i { 40 | 0 => { 41 | assert_eq!(track.title(), "夏凪ぎ"); 42 | assert_eq!(track.artist(), "やなぎなぎ"); 43 | assert!(matches!(track.track_type(), TrackType::Normal)); 44 | } 45 | 1 => { 46 | assert_eq!(track.title(), "宝物になった日"); 47 | assert_eq!(track.artist(), "やなぎなぎ"); 48 | assert!(matches!(track.track_type(), TrackType::Normal)); 49 | } 50 | 2 => { 51 | assert_eq!(track.title(), "夏凪ぎ(Episode 9 Ver.)"); 52 | assert_eq!(track.artist(), "やなぎなぎ"); 53 | assert!(matches!(track.track_type(), TrackType::Normal)); 54 | } 55 | 3 => { 56 | assert_eq!(track.title(), "宝物になった日(Episode 5 Ver.)"); 57 | assert_eq!(track.artist(), "やなぎなぎ"); 58 | assert!(matches!(track.track_type(), TrackType::Normal)); 59 | } 60 | 4 => { 61 | assert_eq!(track.title(), "夏凪ぎ(Instrumental)"); 62 | assert_eq!(track.artist(), "麻枝准"); 63 | assert!(matches!(track.track_type(), TrackType::Instrumental)); 64 | } 65 | 5 => { 66 | assert_eq!(track.title(), "宝物になった日(Instrumental)"); 67 | assert_eq!(track.artist(), "麻枝准"); 68 | assert!(matches!(track.track_type(), TrackType::Instrumental)); 69 | } 70 | _ => unreachable!(), 71 | } 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /anni-repo/tests/fixtures/format/disc-artist-to-album-artist-on-not-unknown/formatted.toml: -------------------------------------------------------------------------------- 1 | [album] 2 | album_id = "15006392-e2ae-4204-b7db-e59211f3cdcf" 3 | title = "Title" 4 | edition = "Edition" 5 | artist = "Album Artist" 6 | date = 2020-12-16 7 | type = "normal" 8 | catalog = "1" 9 | tags = [ 10 | "tag1", 11 | "tag2", 12 | ] 13 | 14 | [[discs]] 15 | catalog = "1-1" 16 | artist = "Artist" 17 | 18 | [[discs.tracks]] 19 | title = "Title 01" 20 | 21 | [[discs.tracks]] 22 | title = "Title 02" 23 | 24 | [[discs]] 25 | catalog = "1-2" 26 | artist = "Artist" 27 | 28 | [[discs.tracks]] 29 | title = "1" 30 | -------------------------------------------------------------------------------- /anni-repo/tests/fixtures/format/disc-artist-to-album-artist-on-not-unknown/unformatted.toml: -------------------------------------------------------------------------------- 1 | [album] 2 | album_id = "15006392-e2ae-4204-b7db-e59211f3cdcf" 3 | title = "Title" 4 | edition = "Edition" 5 | artist = "Album Artist" 6 | date = 2020-12-16 7 | type = "normal" 8 | catalog = "1" 9 | tags = ["tag1", "tag2"] 10 | 11 | [[discs]] 12 | catalog = "1-1" 13 | artist = "Artist" 14 | 15 | [[discs.tracks]] 16 | title = "Title 01" 17 | 18 | [[discs.tracks]] 19 | title = "Title 02" 20 | 21 | [[discs]] 22 | catalog = "1-2" 23 | artist = "Artist" # `artist` field of both discs are the same, it should be formatted to album. 24 | 25 | [[discs.tracks]] 26 | title = "1" 27 | -------------------------------------------------------------------------------- /anni-repo/tests/fixtures/format/disc-artist-to-album-artist-on-unknown/formatted.toml: -------------------------------------------------------------------------------- 1 | [album] 2 | album_id = "15006392-e2ae-4204-b7db-e59211f3cdcf" 3 | title = "Title" 4 | edition = "Edition" 5 | artist = "Artist" 6 | date = 2020-12-16 7 | type = "normal" 8 | catalog = "1" 9 | tags = [ 10 | "tag1", 11 | "tag2", 12 | ] 13 | 14 | [[discs]] 15 | catalog = "1-1" 16 | 17 | [[discs.tracks]] 18 | title = "Title 01" 19 | 20 | [[discs.tracks]] 21 | title = "Title 02" 22 | 23 | [[discs]] 24 | catalog = "1-2" 25 | 26 | [[discs.tracks]] 27 | title = "1" 28 | -------------------------------------------------------------------------------- /anni-repo/tests/fixtures/format/disc-artist-to-album-artist-on-unknown/unformatted.toml: -------------------------------------------------------------------------------- 1 | [album] 2 | album_id = "15006392-e2ae-4204-b7db-e59211f3cdcf" 3 | title = "Title" 4 | edition = "Edition" 5 | artist = "[Unknown Artist]" 6 | date = 2020-12-16 7 | type = "normal" 8 | catalog = "1" 9 | tags = ["tag1", "tag2"] 10 | 11 | [[discs]] 12 | catalog = "1-1" 13 | artist = "Artist" 14 | 15 | [[discs.tracks]] 16 | title = "Title 01" 17 | 18 | [[discs.tracks]] 19 | title = "Title 02" 20 | 21 | [[discs]] 22 | catalog = "1-2" 23 | artist = "Artist" # `artist` field of both discs are the same, it should be formatted to album. 24 | 25 | [[discs.tracks]] 26 | title = "1" 27 | -------------------------------------------------------------------------------- /anni-repo/tests/fixtures/format/disc-type-to-album-type/formatted.toml: -------------------------------------------------------------------------------- 1 | [album] 2 | album_id = "15006392-e2ae-4204-b7db-e59211f3cdcf" 3 | title = "Title" 4 | edition = "Edition" 5 | artist = "Artist" 6 | date = 2020-12-16 7 | type = "instrumental" 8 | catalog = "1" 9 | tags = [ 10 | "tag1", 11 | "tag2", 12 | ] 13 | 14 | [[discs]] 15 | catalog = "1-1" 16 | 17 | [[discs.tracks]] 18 | title = "Title 01" 19 | 20 | [[discs.tracks]] 21 | title = "Title 02" 22 | 23 | [[discs]] 24 | catalog = "1-2" 25 | 26 | [[discs.tracks]] 27 | title = "1" 28 | -------------------------------------------------------------------------------- /anni-repo/tests/fixtures/format/disc-type-to-album-type/unformatted.toml: -------------------------------------------------------------------------------- 1 | [album] 2 | album_id = "15006392-e2ae-4204-b7db-e59211f3cdcf" 3 | title = "Title" 4 | edition = "Edition" 5 | artist = "Artist" 6 | date = 2020-12-16 7 | type = "normal" 8 | catalog = "1" 9 | tags = ["tag1", "tag2"] 10 | 11 | [[discs]] 12 | catalog = "1-1" 13 | type = "instrumental" 14 | 15 | [[discs.tracks]] 16 | title = "Title 01" 17 | 18 | [[discs.tracks]] 19 | title = "Title 02" 20 | 21 | [[discs]] 22 | catalog = "1-2" 23 | type = "instrumental" 24 | 25 | [[discs.tracks]] 26 | title = "1" 27 | -------------------------------------------------------------------------------- /anni-repo/tests/fixtures/format/overall/formatted.toml: -------------------------------------------------------------------------------- 1 | [album] 2 | album_id = "15006392-e2ae-4204-b7db-e59211f3cdcf" 3 | title = "Title" 4 | edition = "Edition" 5 | artist = "Artist" 6 | date = 2020-12-16 7 | type = "vocal" 8 | catalog = "@ALBUM" 9 | tags = [ 10 | "tag1", 11 | "tag2", 12 | ] 13 | 14 | [[discs]] 15 | catalog = "@DISC-0001" 16 | 17 | [[discs.tracks]] 18 | title = "Title 01" 19 | 20 | [[discs.tracks]] 21 | title = "Title 02" 22 | 23 | [[discs]] 24 | catalog = "@DISC-0002" 25 | 26 | [[discs.tracks]] 27 | title = "Title 01" 28 | 29 | [[discs.tracks]] 30 | title = "Title 02" 31 | -------------------------------------------------------------------------------- /anni-repo/tests/fixtures/format/overall/unformatted.toml: -------------------------------------------------------------------------------- 1 | [album] 2 | album_id = "15006392-e2ae-4204-b7db-e59211f3cdcf" 3 | title = "Title" 4 | edition = "Edition" 5 | artist = "[Unknown Artist]" 6 | date = 2020-12-16 7 | type = "normal" 8 | catalog = "@ALBUM" 9 | tags = ["tag1", "tag2"] 10 | 11 | [[discs]] 12 | catalog = "@DISC-0001" 13 | 14 | [[discs.tracks]] 15 | title = "Title 01" 16 | artist = "Artist" 17 | type = "vocal" 18 | 19 | [[discs.tracks]] 20 | title = "Title 02" 21 | artist = "Artist" 22 | type = "vocal" 23 | 24 | [[discs]] 25 | catalog = "@DISC-0002" 26 | 27 | [[discs.tracks]] 28 | title = "Title 01" 29 | artist = "Artist" 30 | type = "vocal" 31 | 32 | [[discs.tracks]] 33 | title = "Title 02" 34 | artist = "Artist" 35 | type = "vocal" 36 | -------------------------------------------------------------------------------- /anni-repo/tests/fixtures/format/track-artist-to-disc-artist/formatted.toml: -------------------------------------------------------------------------------- 1 | [album] 2 | album_id = "15006392-e2ae-4204-b7db-e59211f3cdcf" 3 | title = "Title" 4 | edition = "Edition" 5 | artist = "Album Artist" 6 | date = 2020-12-16 7 | type = "normal" 8 | catalog = "1" 9 | tags = [ 10 | "tag1", 11 | "tag2", 12 | ] 13 | 14 | [[discs]] 15 | catalog = "1-1" 16 | artist = "Artist" 17 | 18 | [[discs.tracks]] 19 | title = "Title 01" 20 | 21 | [[discs.tracks]] 22 | title = "Title 02" 23 | 24 | [[discs]] 25 | catalog = "1-2" 26 | artist = "Placeholder" 27 | 28 | [[discs.tracks]] 29 | title = "1" 30 | -------------------------------------------------------------------------------- /anni-repo/tests/fixtures/format/track-artist-to-disc-artist/unformatted.toml: -------------------------------------------------------------------------------- 1 | # The only part that can be formatted is `disc[0].tracks[..].artist`. 2 | [album] 3 | album_id = "15006392-e2ae-4204-b7db-e59211f3cdcf" 4 | title = "Title" 5 | edition = "Edition" 6 | artist = "Album Artist" 7 | date = 2020-12-16 8 | type = "normal" 9 | catalog = "1" 10 | tags = ["tag1", "tag2"] 11 | 12 | [[discs]] 13 | catalog = "1-1" 14 | 15 | [[discs.tracks]] 16 | title = "Title 01" 17 | artist = "Artist" 18 | 19 | [[discs.tracks]] 20 | title = "Title 02" 21 | artist = "Artist" 22 | 23 | [[discs]] 24 | catalog = "1-2" 25 | artist = "Placeholder" # Add placeholder so that it would not be merged to album artist 26 | 27 | [[discs.tracks]] 28 | title = "1" 29 | -------------------------------------------------------------------------------- /anni-repo/tests/fixtures/format/track-type-to-disc-type/formatted.toml: -------------------------------------------------------------------------------- 1 | [album] 2 | album_id = "15006392-e2ae-4204-b7db-e59211f3cdcf" 3 | title = "Title" 4 | edition = "Edition" 5 | artist = "Album Artist" 6 | date = 2020-12-16 7 | type = "normal" 8 | catalog = "1" 9 | tags = [ 10 | "tag1", 11 | "tag2", 12 | ] 13 | 14 | [[discs]] 15 | catalog = "1-1" 16 | type = "vocal" 17 | 18 | [[discs.tracks]] 19 | title = "Title 01" 20 | 21 | [[discs.tracks]] 22 | title = "Title 02" 23 | 24 | [[discs]] 25 | catalog = "1-2" 26 | type = "instrumental" 27 | 28 | [[discs.tracks]] 29 | title = "1" 30 | -------------------------------------------------------------------------------- /anni-repo/tests/fixtures/format/track-type-to-disc-type/unformatted.toml: -------------------------------------------------------------------------------- 1 | [album] 2 | album_id = "15006392-e2ae-4204-b7db-e59211f3cdcf" 3 | title = "Title" 4 | edition = "Edition" 5 | artist = "Album Artist" 6 | date = 2020-12-16 7 | type = "normal" 8 | catalog = "1" 9 | tags = ["tag1", "tag2"] 10 | 11 | [[discs]] 12 | catalog = "1-1" 13 | type = "absolute" 14 | 15 | [[discs.tracks]] 16 | title = "Title 01" 17 | type = "vocal" 18 | 19 | [[discs.tracks]] 20 | title = "Title 02" 21 | type = "vocal" 22 | 23 | [[discs]] 24 | catalog = "1-2" 25 | type = "instrumental" 26 | 27 | [[discs.tracks]] 28 | title = "1" 29 | -------------------------------------------------------------------------------- /anni-repo/tests/fixtures/test-album.toml: -------------------------------------------------------------------------------- 1 | [album] 2 | album_id = "15006392-e2ae-4204-b7db-e59211f3cdcf" 3 | title = "夏凪ぎ/宝物になった日" 4 | edition = "Test" 5 | artist = "やなぎなぎ" 6 | date = 2020-12-16 7 | type = "normal" 8 | catalog = "KSLA-0178" 9 | tags = [ 10 | "tag1", 11 | "tag2", 12 | ] 13 | 14 | [[discs]] 15 | catalog = "KSLA-0178" 16 | 17 | [[discs.tracks]] 18 | title = "夏凪ぎ" 19 | artist = "やなぎなぎ" 20 | 21 | [[discs.tracks]] 22 | title = "宝物になった日" 23 | 24 | [[discs.tracks]] 25 | title = "夏凪ぎ(Episode 9 Ver.)" 26 | 27 | [[discs.tracks]] 28 | title = "宝物になった日(Episode 5 Ver.)" 29 | 30 | [[discs.tracks]] 31 | title = "夏凪ぎ(Instrumental)" 32 | artist = "麻枝准" 33 | type = "instrumental" 34 | 35 | [[discs.tracks]] 36 | title = "宝物になった日(Instrumental)" 37 | artist = "麻枝准" 38 | type = "instrumental" 39 | -------------------------------------------------------------------------------- /anni-repo/tests/repos/album-tags/album/album.toml: -------------------------------------------------------------------------------- 1 | [album] 2 | album_id = "3e5ff166-f800-4433-a413-6cfa3c2b3cdd" 3 | title = "Title" 4 | artist = "Artist" 5 | date = 2999-12-31 6 | type = "normal" 7 | catalog = "album" 8 | tags = ["Test"] 9 | 10 | [[discs]] 11 | catalog = "TEST-0001" 12 | tags = ["artist: Test-dup"] 13 | 14 | [[discs.tracks]] 15 | title = "Track 1" 16 | type = "absolute" 17 | artist = "Artist1" 18 | tags = ["group: Test-dup"] 19 | -------------------------------------------------------------------------------- /anni-repo/tests/repos/album-tags/repo.toml: -------------------------------------------------------------------------------- 1 | ../empty/repo.toml -------------------------------------------------------------------------------- /anni-repo/tests/repos/album-tags/tag/default.toml: -------------------------------------------------------------------------------- 1 | [[tag]] 2 | name = "Test" 3 | type = "artist" 4 | 5 | [[tag]] 6 | name = "Test-dup" 7 | type = "artist" 8 | names.zh-cn = "Test-artist" 9 | 10 | [[tag]] 11 | name = "Test-dup" 12 | type = "group" 13 | names.zh-cn = "Test-group" 14 | -------------------------------------------------------------------------------- /anni-repo/tests/repos/duplicated-tag-name-different-type/album: -------------------------------------------------------------------------------- 1 | ../empty/album -------------------------------------------------------------------------------- /anni-repo/tests/repos/duplicated-tag-name-different-type/repo.toml: -------------------------------------------------------------------------------- 1 | ../empty/repo.toml -------------------------------------------------------------------------------- /anni-repo/tests/repos/duplicated-tag-name-different-type/tag/default.toml: -------------------------------------------------------------------------------- 1 | [[tag]] 2 | name = "Test" 3 | type = "artist" 4 | names.zh-cn = "Test-artist" 5 | 6 | [[tag]] 7 | name = "Test" 8 | type = "group" 9 | names.zh-cn = "Test-group" 10 | -------------------------------------------------------------------------------- /anni-repo/tests/repos/duplicated-tags/album: -------------------------------------------------------------------------------- 1 | ../empty/album -------------------------------------------------------------------------------- /anni-repo/tests/repos/duplicated-tags/repo.toml: -------------------------------------------------------------------------------- 1 | ../empty/repo.toml -------------------------------------------------------------------------------- /anni-repo/tests/repos/duplicated-tags/tag/default.toml: -------------------------------------------------------------------------------- 1 | [[tag]] 2 | name = "Test" 3 | type = "artist" 4 | 5 | [[tag]] 6 | name = "Test" 7 | type = "artist" 8 | -------------------------------------------------------------------------------- /anni-repo/tests/repos/empty/album/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ProjectAnni/anni/9a26bf91d85b96aa11b8a20acccf2ffb5bc3deb5/anni-repo/tests/repos/empty/album/.gitkeep -------------------------------------------------------------------------------- /anni-repo/tests/repos/empty/repo.toml: -------------------------------------------------------------------------------- 1 | [repo] 2 | name = "Metadata repo test cases" 3 | edition = "1.0+alpha.1.5.1" 4 | -------------------------------------------------------------------------------- /anni-repo/tests/repos/empty/tag/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ProjectAnni/anni/9a26bf91d85b96aa11b8a20acccf2ffb5bc3deb5/anni-repo/tests/repos/empty/tag/.gitkeep -------------------------------------------------------------------------------- /anni-split/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | - Upgraded `which` to `5.0.0` 11 | 12 | ## 0.1.0 13 | 14 | - Initial release 15 | 16 | -------------------------------------------------------------------------------- /anni-split/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "anni-split" 3 | version = "0.1.0" 4 | description = "Audio splitting library." 5 | 6 | edition.workspace = true 7 | authors.workspace = true 8 | repository.workspace = true 9 | license.workspace = true 10 | 11 | [dependencies] 12 | anni-common.workspace = true 13 | 14 | thiserror.workspace = true 15 | log.workspace = true 16 | which = "5.0.0" 17 | cuna = "0.7.0" 18 | -------------------------------------------------------------------------------- /anni-split/src/codec/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod command; 2 | pub mod wav; 3 | 4 | /// [Decoder] trait to decode from specified format to WAVE. 5 | pub trait Decoder { 6 | type Output: std::io::Read + Send; 7 | 8 | fn decode(self) -> Result; 9 | } 10 | 11 | /// [Encoder] trait to encoder from WAVE to specified format. 12 | pub trait Encoder: Sized { 13 | fn encode(self, input: impl std::io::Read) -> Result<(), crate::error::SplitError>; 14 | } 15 | 16 | // Command En/Decoders 17 | use crate::codec::command::FILE_PLACEHOLDER; 18 | use crate::{command_decoder, command_encoder}; 19 | 20 | command_decoder!(FlacCommandDecoder, "flac", ["-c", "-d", FILE_PLACEHOLDER]); 21 | command_encoder!( 22 | FlacCommandEncoder, 23 | "flac", 24 | ["--totally-silent", "-", "-o", FILE_PLACEHOLDER] 25 | ); 26 | command_decoder!(ApeCommandDecoder, "mac", [FILE_PLACEHOLDER, "-", "-d"]); 27 | command_decoder!(TakCommandDecoder, "takc", ["-d", FILE_PLACEHOLDER, "-"]); 28 | command_decoder!( 29 | TtaCommandDecoder, 30 | "ttaenc", 31 | ["-d", "-o", "-", FILE_PLACEHOLDER] 32 | ); 33 | 34 | #[cfg(test)] 35 | mod tests { 36 | use crate::codec::wav::WavEncoder; 37 | use crate::codec::{Decoder, Encoder, FlacCommandDecoder}; 38 | use crate::error::SplitError; 39 | 40 | #[test] 41 | fn test_decode_flac() -> Result<(), SplitError> { 42 | let decoded = FlacCommandDecoder("/tmp/test.flac").decode()?; 43 | WavEncoder("/tmp/result.wav").encode(decoded)?; 44 | Ok(()) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /anni-split/src/cue.rs: -------------------------------------------------------------------------------- 1 | use crate::error::SplitError; 2 | use crate::{codec::wav::WaveHeader, split::Breakpoint}; 3 | use cuna::Cuna; 4 | 5 | /// `Cue` files uses format like `mm:ss.ff` to describe time of tracks. 6 | /// [CueBreakpoint] reuses this value, and can be used to split wave files, depending on its byte-rate. 7 | pub struct CueBreakpoint { 8 | seconds: u32, 9 | frames: u32, 10 | } 11 | 12 | impl Breakpoint for CueBreakpoint { 13 | fn position(&self, header: &WaveHeader) -> u32 { 14 | header.offset_from_second_frames(self.seconds, self.frames) 15 | } 16 | } 17 | 18 | /// Extract breakpoints from a cue file. 19 | /// Behavior should be the same as `--append-gaps` flag enabled in [cuebreakpoints](https://github.com/svend/cuetools/blob/master/src/tools/cuebreakpoints.c). 20 | /// 21 | /// It returns an iterator of breakpoints, and a [Cuna] object. 22 | pub fn cue_breakpoints( 23 | cue: C, 24 | ) -> Result<(impl IntoIterator, Cuna), SplitError> 25 | where 26 | C: AsRef, 27 | { 28 | let cue = Cuna::new(cue.as_ref())?; 29 | 30 | let total_tracks = cue.files.iter().map(|f| f.tracks.len()).sum(); 31 | let mut result = Vec::with_capacity(total_tracks); 32 | 33 | for file in cue.files.iter() { 34 | for track in file.tracks.iter() { 35 | for index in track.index.iter() { 36 | if index.id() == 1 { 37 | let time = index.begin_time(); 38 | result.push(CueBreakpoint { 39 | seconds: time.total_seconds(), 40 | frames: time.frames(), 41 | }); 42 | } 43 | } 44 | } 45 | } 46 | 47 | if let Some(CueBreakpoint { 48 | seconds: 0, 49 | frames: 0, 50 | }) = result.get(0) 51 | { 52 | result.remove(0); 53 | } 54 | 55 | Ok((result, cue)) 56 | } 57 | -------------------------------------------------------------------------------- /anni-split/src/error.rs: -------------------------------------------------------------------------------- 1 | use std::io; 2 | use thiserror::Error; 3 | 4 | #[derive(Error, Debug)] 5 | pub enum SplitError { 6 | #[error(transparent)] 7 | ExecutableNotFound(#[from] which::Error), 8 | 9 | #[error(transparent)] 10 | CueError(#[from] cuna::error::Error), 11 | 12 | #[error(transparent)] 13 | DecodeError(#[from] anni_common::decode::DecodeError), 14 | 15 | #[error(transparent)] 16 | IOError(#[from] io::Error), 17 | } 18 | -------------------------------------------------------------------------------- /anni-split/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![feature(impl_trait_in_assoc_type)] 2 | 3 | pub mod codec; 4 | pub mod cue; 5 | pub mod error; 6 | pub mod split; 7 | 8 | pub use cue::cue_breakpoints; 9 | pub use split::split; 10 | -------------------------------------------------------------------------------- /anni-workspace/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## Unreleased 9 | 10 | - Added `AnniWorkspace::destroy` for some purposes. 11 | - Added some internal-only methods. 12 | 13 | ## 0.2.2 14 | 15 | - Make scan result of `AnniWorkspace::scan` a `BTreeMap` instead of a `HashMap` 16 | - Added `AnniWorkspace::new` to quickly find a workspace from `current_dir` 17 | - Added `AnniWorkspace::open` to open a workspace from a path without checking its parents recursively 18 | - Upgrade `anni-repo` to `0.4.1` 19 | - Upgrade `anni-common` to `0.1.4` 20 | 21 | ## 0.2.1 22 | 23 | - Use `fs::move_dir` in publish for cross-filesystem move 24 | 25 | ## 0.2.0 26 | 27 | - Upgrade to `anni-repo` 0.3.0 28 | 29 | ## 0.1.0 30 | 31 | - Use `toml` instead of deprecated `toml_edit::easy` 32 | -------------------------------------------------------------------------------- /anni-workspace/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "anni-workspace" 3 | version = "0.2.2" 4 | description = "A library to operate on anni workspace." 5 | 6 | edition.workspace = true 7 | authors.workspace = true 8 | repository.workspace = true 9 | license.workspace = true 10 | 11 | [dependencies] 12 | anni-repo = { version = "0.4.2", path = "../anni-repo", features = [ 13 | "git", 14 | "flac", 15 | "apply", 16 | ] } 17 | anni-common.workspace = true 18 | anni-flac = { version = "0.2.2", path = "../anni-flac" } 19 | 20 | uuid.workspace = true 21 | serde.workspace = true 22 | thiserror.workspace = true 23 | toml.workspace = true 24 | 25 | log.workspace = true 26 | alphanumeric-sort = "1.4.4" 27 | anni-metadata.workspace = true 28 | -------------------------------------------------------------------------------- /anni-workspace/src/config.rs: -------------------------------------------------------------------------------- 1 | use crate::WorkspaceError; 2 | use anni_common::fs; 3 | use serde::{Deserialize, Serialize}; 4 | use std::collections::HashMap; 5 | use std::path::{Path, PathBuf}; 6 | 7 | #[derive(Serialize, Deserialize)] 8 | #[serde(deny_unknown_fields)] 9 | pub struct WorkspaceConfig { 10 | #[serde(rename = "workspace")] 11 | inner: WorkspaceConfigInner, 12 | #[serde(rename = "library")] 13 | #[serde(default)] 14 | libraries: HashMap, 15 | } 16 | 17 | #[derive(Serialize, Deserialize)] 18 | #[serde(rename_all = "kebab-case")] 19 | #[serde(deny_unknown_fields)] 20 | pub struct WorkspaceConfigInner { 21 | publish_to: Option, 22 | metadata: Option, 23 | } 24 | 25 | #[derive(Serialize, Deserialize, Clone)] 26 | #[serde(tag = "type", rename_all = "kebab-case")] 27 | pub enum WorkspaceMetadata { 28 | Repo, 29 | Remote { 30 | endpoint: String, 31 | token: Option, 32 | }, 33 | } 34 | 35 | #[derive(Serialize, Deserialize)] 36 | #[serde(deny_unknown_fields)] 37 | pub struct LibraryConfig { 38 | pub path: PathBuf, 39 | pub layers: Option, 40 | } 41 | 42 | impl WorkspaceConfig { 43 | pub fn new

(root: P) -> Result 44 | where 45 | P: AsRef, 46 | { 47 | let data = fs::read_to_string(root.as_ref().join("config.toml"))?; 48 | Ok(toml::from_str(&data)?) 49 | } 50 | 51 | pub fn metadata(&self) -> WorkspaceMetadata { 52 | self.inner 53 | .metadata 54 | .clone() 55 | .unwrap_or(WorkspaceMetadata::Repo) 56 | } 57 | 58 | pub fn publish_to(&self) -> Option<&LibraryConfig> { 59 | self.inner 60 | .publish_to 61 | .as_ref() 62 | .and_then(|p| self.libraries.get(p)) 63 | } 64 | 65 | #[allow(dead_code)] 66 | pub fn get_library(&self, name: &str) -> Option<&LibraryConfig> { 67 | self.libraries.get(name) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /anni-workspace/src/error.rs: -------------------------------------------------------------------------------- 1 | use crate::WorkspaceAlbumState; 2 | use anni_repo::error::AlbumApplyError; 3 | use std::path::PathBuf; 4 | use uuid::Uuid; 5 | 6 | #[derive(thiserror::Error, Debug)] 7 | pub enum WorkspaceError { 8 | #[error("Workspace does not exist in given path.")] 9 | NotAWorkspace, 10 | 11 | #[error("Workspace was not found.")] 12 | WorkspaceNotFound, 13 | 14 | #[error("Album with id: {0} exists in workspace")] 15 | DuplicatedAlbumId(Uuid), 16 | 17 | #[error("Directory is not an album directory: {0}")] 18 | NotAnAlbum(PathBuf), 19 | 20 | #[error("Invalid album state: {0:?}")] 21 | InvalidAlbumState(WorkspaceAlbumState), 22 | 23 | #[error("Album {album_id} already exists at {path}")] 24 | AlbumExists { album_id: Uuid, path: PathBuf }, 25 | 26 | #[error("Album at {0} was locked")] 27 | AlbumLocked(PathBuf), 28 | 29 | #[error("Album cover not found at {0}")] 30 | CoverNotFound(PathBuf), 31 | 32 | #[error("Invalid album symlink at: {0}")] 33 | InvalidAlbumLink(PathBuf), 34 | 35 | #[error("Album not found: {0}")] 36 | AlbumNotFound(Uuid), 37 | 38 | #[error("Invalid album found at {0}. If there's only one disc, then subdirectories are not allowed. If there're multiple discs, then having flac files in root directory is unacceptable.")] 39 | InvalidAlbumDiscStructure(PathBuf), 40 | 41 | #[error("User aborted")] 42 | UserAborted, 43 | 44 | #[error(transparent)] 45 | DeserializeError(#[from] toml::de::Error), 46 | 47 | #[error(transparent)] 48 | InvalidUuid(#[from] uuid::Error), 49 | 50 | #[error(transparent)] 51 | IOError(#[from] std::io::Error), 52 | 53 | #[error(transparent)] 54 | RepoError(#[from] anni_repo::error::Error), 55 | 56 | #[error("Invalid flac file {path}: {error}")] 57 | FlacError { 58 | path: PathBuf, 59 | error: anni_flac::error::FlacError, 60 | }, 61 | 62 | // TODO: print full string 63 | #[error("Failed to extract album info from dir name")] 64 | FailedToExtractAlbumInfo, 65 | 66 | #[error("Unexpected file {0} found.")] 67 | UnexpectedFile(PathBuf), 68 | 69 | #[error("Publish target directory {0} was not found.")] 70 | PublishTargetNotFound(PathBuf), 71 | 72 | #[error(transparent)] 73 | ApplyError(#[from] AlbumApplyError), 74 | } 75 | -------------------------------------------------------------------------------- /anni-workspace/src/state.rs: -------------------------------------------------------------------------------- 1 | use serde::Serialize; 2 | use std::path::PathBuf; 3 | use uuid::Uuid; 4 | 5 | #[derive(Debug, Serialize)] 6 | pub struct WorkspaceAlbum { 7 | pub album_id: Uuid, 8 | #[serde(flatten)] 9 | pub state: WorkspaceAlbumState, 10 | } 11 | 12 | pub struct UntrackedWorkspaceAlbum { 13 | pub album_id: Uuid, 14 | pub path: PathBuf, 15 | /// For album with only one disc, the disc directory is not required. 16 | /// Users can choose to put all tracks in the album directory. 17 | /// This is called `simplified` album structure. 18 | pub simplified: bool, 19 | pub discs: Vec, 20 | } 21 | 22 | pub struct UntrackedWorkspaceDisc { 23 | pub index: usize, 24 | pub path: PathBuf, 25 | pub cover: PathBuf, 26 | pub tracks: Vec, 27 | } 28 | 29 | /// State of album directory in workspace 30 | #[derive(Debug, Serialize)] 31 | #[serde(tag = "type", content = "path")] 32 | #[serde(rename_all = "kebab-case")] 33 | pub enum WorkspaceAlbumState { 34 | // Normal states 35 | /// `Untracked` album directory. 36 | /// Controlled part of the album directory is empty. 37 | Untracked(PathBuf), 38 | /// `Committed` album directory. 39 | /// Controlled part of the album directory is not empty, and User part contains symlinks to the actual file. 40 | Committed(PathBuf), 41 | /// `Published` album directory. 42 | /// Controlled part of the album directory is not empty, and `.publish` file exists. 43 | Published, 44 | 45 | // Error states 46 | /// User part of an album exists, but controlled part does not exist, or the symlink is broken. 47 | Dangling(PathBuf), 48 | /// User part of an album does not exist, and controlled part is empty. 49 | Garbage, 50 | } 51 | -------------------------------------------------------------------------------- /anni-workspace/src/utils/lock.rs: -------------------------------------------------------------------------------- 1 | use std::path::{Path, PathBuf}; 2 | 3 | use crate::WorkspaceError; 4 | 5 | pub(crate) struct WorkspaceAlbumLock { 6 | lock_path: PathBuf, 7 | } 8 | 9 | impl WorkspaceAlbumLock { 10 | pub fn new

(album_path: P) -> Result 11 | where 12 | P: AsRef, 13 | { 14 | let lock_path = album_path.as_ref().join(".album.lock"); 15 | if lock_path.exists() { 16 | return Err(WorkspaceError::AlbumLocked( 17 | album_path.as_ref().to_path_buf(), 18 | )); 19 | } 20 | 21 | Ok(Self { lock_path }) 22 | } 23 | 24 | pub fn lock(&self) -> std::io::Result<()> { 25 | std::fs::File::create(&self.lock_path)?; 26 | Ok(()) 27 | } 28 | } 29 | 30 | impl Drop for WorkspaceAlbumLock { 31 | fn drop(&mut self) { 32 | let _ = std::fs::remove_file(&self.lock_path); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /anni-workspace/src/utils/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod lock; 2 | -------------------------------------------------------------------------------- /anni/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | - Use `toml` instead of deprecated `toml_edit::easy` 11 | -------------------------------------------------------------------------------- /anni/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "anni" 3 | version = "0.1.1" 4 | publish = false 5 | default-run = "anni" 6 | 7 | edition.workspace = true 8 | repository.workspace = true 9 | license.workspace = true 10 | 11 | [dependencies] 12 | tokio = { version = "1", features = ["full"] } 13 | clap = { version = "4.0.4", features = ["derive", "cargo", "env"] } 14 | clap_complete = "4.0.2" 15 | regex = "1" 16 | edit = "0.1.2" 17 | once_cell.workspace = true 18 | 19 | serde.workspace = true 20 | serde_json.workspace = true 21 | toml.workspace = true 22 | directories-next = "2.0.0" 23 | 24 | anni-common = { workspace = true, features = ["trash"] } 25 | anni-flac = { path = "../anni-flac" } 26 | anni-split = { path = "../anni-split" } 27 | anni-repo = { path = "../anni-repo", features = [ 28 | "db", 29 | "git", 30 | "flac", 31 | "apply", 32 | # "search", 33 | ] } 34 | anni-provider = { path = "../anni-provider" } 35 | annil = { path = "../annil", default-features = false } 36 | anni-workspace = { path = "../anni-workspace" } 37 | anni-metadata = { workspace = true, features = ["annim"] } 38 | clap-handler = { version = "0.1.1", features = ["async"] } 39 | 40 | i18n-embed = { version = "0.14.1", features = [ 41 | "fluent-system", 42 | "desktop-requester", 43 | "filesystem-assets", 44 | ] } 45 | i18n-embed-fl = "0.7.0" 46 | rust-embed = "8.2.0" 47 | 48 | log.workspace = true 49 | env_logger = "0.10.0" 50 | anyhow.workspace = true 51 | 52 | cuna = "0.7.0" 53 | id3 = "1" 54 | anni-vgmdb = "0.3.1" 55 | musicbrainz_rs = { git = "https://github.com/ProjectAnni/musicbrainz_rs.git", default-features = false, features = [ 56 | "rustls", 57 | "async", 58 | ] } 59 | 60 | uuid.workspace = true 61 | alphanumeric-sort = "1.4.4" 62 | ptree = { version = "0.4.0", default-features = false, features = [ 63 | "petgraph", 64 | "ansi", 65 | "value", 66 | ] } 67 | colored = "2.0.0" 68 | chrono = "0.4" 69 | 70 | inquire = "0.6.0" 71 | notify = { version = "6.1.1", default-features = false, features = [ 72 | "macos_kqueue", 73 | ] } 74 | notify-debouncer-mini = { version = "0.4.1", default-features = false } 75 | anni-google-drive3 = { path = "../third_party/google-drive3" } 76 | 77 | axum.workspace = true 78 | reqwest = { workspace = true, features = ["json"] } 79 | 80 | [dev-dependencies] 81 | tempfile = "3.2.0" 82 | -------------------------------------------------------------------------------- /anni/build.rs: -------------------------------------------------------------------------------- 1 | use std::error::Error; 2 | use std::process::Command; 3 | 4 | fn get_hash() -> Result> { 5 | let output = Command::new("git") 6 | .args(["rev-parse", "--short", "HEAD"]) 7 | .output()?; 8 | let hash = String::from_utf8(output.stdout)?; 9 | Ok(hash.trim().to_string()) 10 | } 11 | 12 | fn main() { 13 | let version = env!("CARGO_PKG_VERSION"); 14 | let hash = get_hash().unwrap_or_else(|_| "unknown".to_string()); 15 | println!("cargo:rustc-env=ANNI_VERSION={version} ({hash})"); 16 | } 17 | -------------------------------------------------------------------------------- /anni/i18n.toml: -------------------------------------------------------------------------------- 1 | fallback_language = "en-US" 2 | 3 | [fluent] 4 | assets_dir = "i18n" -------------------------------------------------------------------------------- /anni/i18n/zh-CN/anni.ftl: -------------------------------------------------------------------------------- 1 | ## anni 2 | anni-about = 为自建音乐站点构建的一整套工具 3 | export-to = 导出内容存放的路径 4 | 5 | 6 | ## flac 7 | flac = 提供 FLAC 处理相关的功能 8 | flac-export = 导出内容 9 | flac-export-type = 导出内容类型 10 | 11 | 12 | ## split 13 | split = 提供音频分割相关的功能 14 | split-format-input = 待切分音频的文件类型 15 | split-format-output = 切分后输出音频的文件类型 16 | split-clean = 不向切分后的音频文件中写入元数据和封面等信息 17 | split-no-import-cover = 不从切分目录寻找封面写入音频文件 18 | split-output-file-exist = 输出路径下已存在文件 {$filename},请删除文件后重试 19 | 20 | 21 | ## convention 22 | convention = 提供定制化的音频检查约定检测 23 | convention-check = 检查音频是否符合约定 24 | convention-check-fix = 对不符合约定的音频文件进行修复 25 | 26 | 27 | ## repo 28 | repo = 提供 Anni 元数据仓库的管理功能 29 | repo-root = 需要管理的 Anni 元数据仓库根路径 30 | 31 | repo-clone = 克隆元数据仓库 32 | repo-clone-start = 准备克隆元数据仓库至{$path}... 33 | repo-clone-done = 元数据仓库克隆完成 34 | 35 | repo-add = 向元数据仓库中导入专辑 36 | repo-add-edit = 在导入完成后打开文件编辑器 37 | repo-invalid-album = 专辑目录格式错误:{$name} 38 | repo-album-exists = 专辑 {$catalog} 已存在 39 | repo-album-not-found = 不存在品番为 {$catalog} 的专辑 40 | repo-album-info-mismatch = 专辑信息与专辑目录不一致 41 | 42 | repo-import = 导入专辑 43 | repo-import-format = 导入专辑的数据格式 44 | 45 | repo-lint-start = 仓库校验开始 46 | repo-lint-end = 仓库校验结束 47 | repo-lint-failed = 仓库校验失败 48 | repo-catalog-filename-mismatch = 专辑 {$album_catalog} 的品番与文件名不一致 49 | repo-invalid-artist = 艺术家名称不可用:{$artist} 50 | 51 | repo-get = 从远程数据源获取专辑信息并导入 52 | repo-get-print = 将获取的专辑信息输出到控制台而非导入 53 | repo-get-cue-keyword = 当元数据缺失时,使用该关键字搜索 VGMdb 54 | repo-get-cue-catalog = 当 catalog 不存在时,手动指定 55 | repo-cue-insufficient-information = CUE 文件未能提供足够的信息 56 | 57 | repo-edit = 当元数据仓库中存在该专辑时,打开仓库中对应的文件 58 | repo-lint = 检查仓库数据的合法性 59 | 60 | repo-print = 根据品番输出元数据仓库中的数据 61 | repo-print-type = 输出数据的类型 62 | repo-print-clean = 省略 cue 输出中的 REM COMMENT "Generated by Anni" 63 | repo-print-input = 需要输出的对象。可以是标签名称或专辑品番。当表示专辑品番时,可以通过get_albums_by_tag后缀 '/{"{disc_id}"}' 指定需要输出信息的碟片编号,0 和 1 均代表第一张碟片 64 | 65 | repo-db = 生成元数据仓库对应的数据库文件 66 | 67 | repo-migrate = 迁移旧版本元数据仓库到新版本 68 | repo-migrate-album-id = 为缺少 album_id 字段的专辑添加这一字段 69 | 70 | 71 | ## Library 72 | library = 提供音频仓库的管理功能 73 | library-tag = 将元数据仓库中的数据应用到专辑 74 | library-link = 以符号链接将约定目录格式转换为严格目录格式 75 | 76 | 77 | ## Workspace 78 | workspace = 管理音频整理工作空间 79 | workspace-init = 初始化工作空间 80 | workspace-create = 在工作空间中创建新专辑 81 | 82 | workspace-add = 将工作空间中专辑对状态从未跟踪转换为已跟踪 83 | workspace-add-import-tags=从音频文件中导入元数据 84 | workspace-add-dry-run=仅展示,不实际移动文件或创建链接 85 | workspace-add-skip-check=跳过对专辑结构的检查 86 | workspace-add-open-editor=导入完成后通过文本编辑器打开元数据文件 87 | 88 | workspace-rm = 从工作空间中移除专辑 89 | workspace-status = 显示工作空间中所有专辑的状态 90 | workspace-update = 更新工作空间中的专辑 91 | workspace-publish = 将工作空间中的专辑发布到音频仓库 92 | workspace-serve = 将工作空间作为 http 服务启动 93 | workspace-fsck = 检查并修复工作空间 94 | 95 | 96 | ## Completions 97 | completions = 生成 Shell 的补全脚本 98 | completions-shell = 生成补全脚本的 Shell 99 | -------------------------------------------------------------------------------- /anni/src/args/action_file.rs: -------------------------------------------------------------------------------- 1 | use std::convert::Infallible; 2 | use std::fs::File; 3 | use std::io::{stdin, stdout, Read, Write}; 4 | use std::str::FromStr; 5 | 6 | /// ActionFile for file input or output 7 | /// 8 | /// ActionFile can be used to create `FileReader` with `to_reader` 9 | /// method or to `FileWriter` with `to_writer` method. 10 | #[derive(Debug, Clone)] 11 | pub struct ActionFile(String); 12 | 13 | impl FromStr for ActionFile { 14 | type Err = Infallible; 15 | 16 | fn from_str(s: &str) -> Result { 17 | Ok(ActionFile(s.to_string())) 18 | } 19 | } 20 | 21 | impl ActionFile { 22 | /// Create a `FileReader` from `ActionFile` 23 | pub fn to_reader(&self) -> anyhow::Result> { 24 | if self.0 == "-" { 25 | // open stdin 26 | Ok(Box::new(stdin().lock())) 27 | } else { 28 | // open file 29 | Ok(Box::new(File::open(&self.0)?)) 30 | } 31 | } 32 | 33 | /// Create a `FileWriter` from `ActionFile` 34 | pub fn to_writer(&self) -> anyhow::Result> { 35 | if self.0 == "-" { 36 | // open stdout 37 | Ok(Box::new(stdout().lock())) 38 | } else { 39 | // open file 40 | Ok(Box::new(File::create(&self.0)?)) 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /anni/src/args/mod.rs: -------------------------------------------------------------------------------- 1 | mod action_file; 2 | mod input_path; 3 | 4 | pub use action_file::*; 5 | pub use input_path::*; 6 | -------------------------------------------------------------------------------- /anni/src/config.rs: -------------------------------------------------------------------------------- 1 | use anni_common::fs::read_to_string; 2 | use directories_next::ProjectDirs; 3 | use once_cell::sync::Lazy; 4 | use serde::de::DeserializeOwned; 5 | use std::path::PathBuf; 6 | 7 | static CONFIG_ROOT: Lazy = Lazy::new(|| init_config()); 8 | 9 | fn init_config() -> PathBuf { 10 | let config = std::env::var("ANNI_ROOT").map(PathBuf::from).unwrap_or({ 11 | let dir = ProjectDirs::from("moe", "mmf", "anni").expect("Failed to get project dirs."); 12 | dir.config_dir().to_path_buf() 13 | }); 14 | 15 | if config.exists() { 16 | debug!("Config root: {:?}", config); 17 | } else { 18 | debug!("Config root does not exist: {:?}", config); 19 | } 20 | config 21 | } 22 | 23 | pub(crate) fn read_config(name: &'static str) -> anyhow::Result 24 | where 25 | T: DeserializeOwned, 26 | { 27 | let file = CONFIG_ROOT.join(format!("{}.toml", name)); 28 | let file = read_to_string(file)?; 29 | Ok(toml::from_str(&file)?) 30 | } 31 | -------------------------------------------------------------------------------- /anni/src/i18n.rs: -------------------------------------------------------------------------------- 1 | use i18n_embed::{ 2 | fluent::{fluent_language_loader, FluentLanguageLoader}, 3 | DesktopLanguageRequester, LanguageLoader, 4 | }; 5 | use once_cell::sync::Lazy; 6 | use rust_embed::RustEmbed; 7 | 8 | #[derive(RustEmbed)] 9 | #[folder = "i18n"] 10 | struct Localizations; 11 | 12 | fn init_i18n() -> FluentLanguageLoader { 13 | let loader: FluentLanguageLoader = fluent_language_loader!(); 14 | let requested_languages = DesktopLanguageRequester::requested_languages(); 15 | let mut references: Vec<_> = requested_languages.iter().collect(); 16 | references.push(loader.fallback_language()); 17 | loader 18 | .load_languages(&Localizations, &references) 19 | .expect("Failed to load localization."); 20 | loader 21 | } 22 | 23 | pub static LOCALIZATION_LOADER: Lazy = Lazy::new(|| init_i18n()); 24 | 25 | #[macro_export] 26 | macro_rules! fl { 27 | ($message_id: literal) => { 28 | i18n_embed_fl::fl!(crate::i18n::LOCALIZATION_LOADER, $message_id) 29 | }; 30 | 31 | ($message_id: literal, $($args: expr),*) => { 32 | i18n_embed_fl::fl!(crate::i18n::LOCALIZATION_LOADER, $message_id, $($args), *) 33 | }; 34 | } 35 | 36 | #[macro_export] 37 | macro_rules! ll { 38 | ($message_id: literal) => { 39 | Box::leak(crate::fl!($message_id).into_boxed_str()) as &'static str 40 | }; 41 | } 42 | 43 | #[macro_export] 44 | macro_rules! ball { 45 | ($message_id: literal) => { 46 | bail!(crate::fl!($message_id)) 47 | }; 48 | 49 | ($message_id: literal, $($args: expr),*) => { 50 | bail!(crate::fl!($message_id, $($args), *)) 51 | }; 52 | } 53 | 54 | pub trait ClapI18n { 55 | fn about_ll(self, key: &'static str) -> Self; 56 | 57 | fn long_about_ll(self, key: &'static str) -> Self; 58 | } 59 | 60 | impl ClapI18n for clap::Command { 61 | fn about_ll(self, key: &'static str) -> Self { 62 | self.about(Box::leak(LOCALIZATION_LOADER.get(key).into_boxed_str()) as &'static str) 63 | } 64 | 65 | fn long_about_ll(self, key: &'static str) -> Self { 66 | self.long_about(Box::leak(LOCALIZATION_LOADER.get(key).into_boxed_str()) as &'static str) 67 | } 68 | } 69 | 70 | impl ClapI18n for clap::Arg { 71 | fn about_ll(self, key: &'static str) -> Self { 72 | self.help(Box::leak(LOCALIZATION_LOADER.get(key).into_boxed_str()) as &'static str) 73 | } 74 | 75 | fn long_about_ll(self, key: &'static str) -> Self { 76 | self.long_help(Box::leak(LOCALIZATION_LOADER.get(key).into_boxed_str()) as &'static str) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /anni/src/main.rs: -------------------------------------------------------------------------------- 1 | #![feature(try_blocks)] 2 | #![allow(incomplete_features)] 3 | #![feature(impl_trait_in_assoc_type)] 4 | 5 | use crate::subcommands::*; 6 | use clap::Parser; 7 | use clap_handler::Handler; 8 | use log::LevelFilter; 9 | 10 | mod args; 11 | mod config; 12 | mod i18n; 13 | mod subcommands; 14 | mod utils; 15 | 16 | #[macro_use] 17 | extern crate anyhow; 18 | 19 | #[macro_use] 20 | extern crate log; 21 | 22 | #[derive(Parser, Handler, Debug, Clone)] 23 | #[clap(name = "Project Anni", version = env!("ANNI_VERSION"), author)] 24 | #[clap(about = ll!("anni-about"))] 25 | #[clap(infer_subcommands = true)] 26 | pub struct AnniArguments { 27 | #[clap(subcommand)] 28 | subcommand: AnniSubcommand, 29 | } 30 | 31 | #[derive(Parser, Handler, Debug, Clone)] 32 | pub enum AnniSubcommand { 33 | Flac(FlacSubcommand), 34 | Split(SplitSubcommand), 35 | Convention(ConventionSubcommand), 36 | Repo(RepoSubcommand), 37 | Library(LibrarySubcommand), 38 | Completions(CompletionsSubcommand), 39 | Workspace(WorkspaceSubcommand), 40 | } 41 | 42 | #[tokio::main] 43 | async fn main() -> anyhow::Result<()> { 44 | // initialize env_logger 45 | env_logger::builder() 46 | .filter_level(LevelFilter::Info) 47 | .filter_module("i18n_embed::requester", LevelFilter::Off) 48 | .filter_module("sqlx::query", LevelFilter::Warn) 49 | .parse_env("ANNI_LOG") 50 | .format(utils::log::formatter) 51 | .init(); 52 | 53 | // parse arguments 54 | let args = AnniArguments::parse(); 55 | log::debug!("{:#?}", args); 56 | args.run().await 57 | } 58 | -------------------------------------------------------------------------------- /anni/src/subcommands/completions.rs: -------------------------------------------------------------------------------- 1 | use crate::{ll, AnniArguments}; 2 | use clap::{Args, CommandFactory}; 3 | use clap_complete::{generate, Shell as CompletionShell}; 4 | use clap_handler::handler; 5 | 6 | #[derive(Args, Debug, Clone)] 7 | #[clap(about = ll!("completions"))] 8 | pub struct CompletionsSubcommand { 9 | #[clap(value_enum)] 10 | #[clap(help = ll!("completions-shell"))] 11 | shell: CompletionShell, 12 | } 13 | 14 | #[handler(CompletionsSubcommand)] 15 | fn handle_completions(me: &CompletionsSubcommand) -> anyhow::Result<()> { 16 | generate( 17 | me.shell, 18 | &mut AnniArguments::command(), 19 | "anni", 20 | &mut std::io::stdout().lock(), 21 | ); 22 | Ok(()) 23 | } 24 | -------------------------------------------------------------------------------- /anni/src/subcommands/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod completions; 2 | pub mod convention; 3 | pub mod flac; 4 | pub mod library; 5 | pub mod repo; 6 | pub mod split; 7 | pub mod workspace; 8 | 9 | pub use completions::CompletionsSubcommand; 10 | pub use convention::ConventionSubcommand; 11 | pub use flac::FlacSubcommand; 12 | pub use library::LibrarySubcommand; 13 | pub use repo::RepoSubcommand; 14 | pub use split::SplitSubcommand; 15 | pub use workspace::WorkspaceSubcommand; 16 | -------------------------------------------------------------------------------- /anni/src/subcommands/repo/watch.rs: -------------------------------------------------------------------------------- 1 | use crate::RepoSubcommand; 2 | use clap::Args; 3 | use clap_handler::handler; 4 | use notify::event::{AccessKind, AccessMode}; 5 | use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher}; 6 | use std::path::Path; 7 | use tokio::runtime::Runtime; 8 | use tokio::sync::mpsc::{channel, Receiver}; 9 | 10 | #[derive(Args, Debug, Clone)] 11 | pub struct RepoWatchAction; 12 | 13 | #[handler(RepoWatchAction)] 14 | fn repo_watch(_: RepoWatchAction, repo: RepoSubcommand) -> anyhow::Result<()> { 15 | let root = repo.repo_root(); 16 | async_watch(root).await?; 17 | Ok(()) 18 | } 19 | 20 | fn async_watcher() -> notify::Result<(RecommendedWatcher, Receiver>)> { 21 | let (tx, rx) = channel(1); 22 | 23 | // Automatically select the best implementation for your platform. 24 | // You can also access each implementation directly e.g. INotifyWatcher. 25 | let watcher = RecommendedWatcher::new( 26 | move |res| { 27 | let rt = Runtime::new().unwrap(); 28 | rt.block_on(async { 29 | tx.send(res).await.unwrap(); 30 | }) 31 | }, 32 | Config::default(), 33 | )?; 34 | 35 | Ok((watcher, rx)) 36 | } 37 | 38 | async fn async_watch>(path: P) -> notify::Result<()> { 39 | let (mut watcher, mut rx) = async_watcher()?; 40 | 41 | // Add a path to be watched. All files and directories at that path and 42 | // below will be monitored for changes. 43 | watcher.watch(path.as_ref(), RecursiveMode::Recursive)?; 44 | 45 | while let Some(res) = rx.recv().await { 46 | match res { 47 | Ok(event) => match event.kind { 48 | EventKind::Access(AccessKind::Close(AccessMode::Write)) => { 49 | log::info!("modified: {:?}", event.paths); 50 | } 51 | EventKind::Create(_) => { 52 | log::info!("created: {:?}", event.paths); 53 | } 54 | EventKind::Remove(notify::event::RemoveKind::Any) => { 55 | log::info!("removed: {:?}", event.paths); 56 | } 57 | _ => {} 58 | }, 59 | Err(e) => log::error!("watch error: {:?}", e), 60 | } 61 | } 62 | 63 | Ok(()) 64 | } 65 | -------------------------------------------------------------------------------- /anni/src/subcommands/workspace/create.rs: -------------------------------------------------------------------------------- 1 | use anni_workspace::AnniWorkspace; 2 | use clap::Args; 3 | use clap_handler::handler; 4 | use std::num::NonZeroU8; 5 | use std::path::PathBuf; 6 | use uuid::Uuid; 7 | 8 | #[derive(Args, Debug, Clone)] 9 | pub struct WorkspaceCreateAction { 10 | #[clap(short = 'a', long)] 11 | album_id: Option, 12 | #[clap(short = 'd', long, default_value = "1")] 13 | disc_num: NonZeroU8, 14 | #[clap(short = 'f', long)] 15 | force: bool, 16 | 17 | path: PathBuf, 18 | } 19 | 20 | #[handler(WorkspaceCreateAction)] 21 | fn handle_workspace_create(me: WorkspaceCreateAction) -> anyhow::Result<()> { 22 | let workspace = AnniWorkspace::new()?; 23 | 24 | let album_id = me.album_id.unwrap_or_else(|| Uuid::new_v4()); 25 | 26 | // check whether the target path exists 27 | let user_album_path = me.path; 28 | if user_album_path.exists() && !me.force { 29 | bail!("Target path already exists"); 30 | } 31 | 32 | workspace.create_album(&album_id, &user_album_path, me.disc_num)?; 33 | 34 | Ok(()) 35 | } 36 | 37 | // #[cfg(test)] 38 | // mod test { 39 | // use super::{WorkspaceCreateAction}; 40 | // use clap_handler::Handler; 41 | // 42 | // #[tokio::test] 43 | // async fn test_create_album() -> anyhow::Result<()> { 44 | // let path = tempfile::tempdir()?; 45 | // 46 | // WorkspaceCreateAction { 47 | // album_id: None, 48 | // disc_num: 1.into(), 49 | // path: path.path().to_path_buf(), 50 | // name: None, 51 | // }.run().await?; 52 | // 53 | // Ok(()) 54 | // } 55 | // } 56 | -------------------------------------------------------------------------------- /anni/src/subcommands/workspace/fsck.rs: -------------------------------------------------------------------------------- 1 | use anni_common::fs; 2 | use anni_workspace::{AnniWorkspace, WorkspaceAlbumState}; 3 | use clap::Args; 4 | use clap_handler::handler; 5 | 6 | #[derive(Args, Debug, Clone)] 7 | pub struct WorkspaceFsckAction { 8 | #[clap(short = 'd', long)] 9 | fix_dangling: bool, 10 | #[clap(long)] 11 | gc: bool, 12 | } 13 | 14 | #[handler(WorkspaceFsckAction)] 15 | fn handle_workspace_fsck(me: WorkspaceFsckAction) -> anyhow::Result<()> { 16 | let workspace = AnniWorkspace::new()?; 17 | 18 | if me.fix_dangling { 19 | let albums = workspace.scan()?; 20 | for album in albums { 21 | if let WorkspaceAlbumState::Dangling(album_path) = album.state { 22 | let result: anyhow::Result<()> = try { 23 | let dot_album = album_path.join(".album"); 24 | let real_path = workspace.controlled_album_path(&album.album_id, 2); 25 | if !real_path.exists() { 26 | fs::create_dir_all(&real_path)?; 27 | } 28 | fs::remove_file(&dot_album, false)?; 29 | fs::symlink_dir(&real_path, &dot_album)?; 30 | }; 31 | 32 | if let Err(e) = result { 33 | log::error!( 34 | "Error while fixing album at {}: {}", 35 | album_path.display(), 36 | e 37 | ); 38 | } 39 | } 40 | } 41 | } 42 | 43 | if me.gc { 44 | let albums = workspace.scan()?; 45 | for album in albums { 46 | if let WorkspaceAlbumState::Garbage = album.state { 47 | let result: anyhow::Result<()> = try { 48 | if let Ok(real_path) = workspace.get_album_controlled_path(&album.album_id) { 49 | // 1. remove garbage album directory 50 | fs::remove_dir_all(&real_path, true)?; 51 | 52 | // 2. try to remove parent 53 | if let Some(parent) = real_path.parent() { 54 | if parent.read_dir()?.next().is_none() { 55 | fs::remove_dir_all(&parent, true)?; 56 | 57 | // 3. try to remove parent's parent 58 | if let Some(parent) = parent.parent() { 59 | if parent.read_dir()?.next().is_none() { 60 | fs::remove_dir_all(&parent, true)?; 61 | } 62 | } 63 | } 64 | } 65 | } 66 | }; 67 | 68 | if let Err(e) = result { 69 | log::error!("Error while collecting garbage: {}", e); 70 | } 71 | } 72 | } 73 | } 74 | 75 | Ok(()) 76 | } 77 | -------------------------------------------------------------------------------- /anni/src/subcommands/workspace/mod.rs: -------------------------------------------------------------------------------- 1 | mod add; 2 | mod create; 3 | mod fsck; 4 | mod init; 5 | mod publish; 6 | mod recover_published; 7 | mod rm; 8 | mod serve; 9 | mod status; 10 | mod target; 11 | mod update; 12 | 13 | use add::*; 14 | use create::*; 15 | use fsck::*; 16 | use init::*; 17 | use publish::*; 18 | use rm::*; 19 | use status::*; 20 | use update::*; 21 | 22 | use crate::ll; 23 | use crate::subcommands::workspace::recover_published::WorkspaceRecoverPublishedAction; 24 | use crate::subcommands::workspace::serve::WorkspaceServeAction; 25 | use clap::{Args, Subcommand}; 26 | use clap_handler::Handler; 27 | 28 | #[derive(Args, Handler, Debug, Clone)] 29 | #[clap(about = ll!("workspace"))] 30 | #[clap(visible_alias = "ws")] 31 | pub struct WorkspaceSubcommand { 32 | #[clap(subcommand)] 33 | action: WorkspaceAction, 34 | } 35 | 36 | #[derive(Subcommand, Handler, Debug, Clone)] 37 | pub enum WorkspaceAction { 38 | #[clap(about = ll!("workspace-init"))] 39 | Init(WorkspaceInitAction), 40 | #[clap(about = ll!("workspace-create"))] 41 | Create(WorkspaceCreateAction), 42 | #[clap(about = ll!("workspace-add"))] 43 | Add(WorkspaceAddAction), 44 | #[clap(about = ll!("workspace-rm"))] 45 | Rm(WorkspaceRmAction), 46 | #[clap(about = ll!("workspace-status"))] 47 | Status(WorkspaceStatusAction), 48 | #[clap(about = ll!("workspace-update"))] 49 | Update(WorkspaceUpdateAction), 50 | #[clap(about = ll!("workspace-publish"))] 51 | Publish(WorkspacePublishAction), 52 | RecoverPublished(WorkspaceRecoverPublishedAction), 53 | #[clap(about = ll!("workspace-serve"))] 54 | Serve(WorkspaceServeAction), 55 | #[clap(about = ll!("workspace-fsck"))] 56 | Fsck(WorkspaceFsckAction), 57 | } 58 | -------------------------------------------------------------------------------- /anni/src/subcommands/workspace/publish.rs: -------------------------------------------------------------------------------- 1 | use anni_workspace::{AnniWorkspace, WorkspaceAlbumState}; 2 | use clap::Args; 3 | use clap_handler::handler; 4 | use std::collections::HashMap; 5 | use std::path::PathBuf; 6 | 7 | #[derive(Args, Debug, Clone)] 8 | pub struct WorkspacePublishAction { 9 | // #[clap(long)] 10 | // copy: bool, 11 | #[clap(long)] 12 | detailed: bool, 13 | 14 | #[clap(short = 'u', long = "uuid")] 15 | parse_path_as_uuid: bool, 16 | 17 | #[clap(long)] 18 | soft: bool, 19 | 20 | // publish_to: Option, 21 | path: Vec, 22 | } 23 | 24 | #[handler(WorkspacePublishAction)] 25 | pub async fn handle_workspace_publish(mut me: WorkspacePublishAction) -> anyhow::Result<()> { 26 | let workspace = AnniWorkspace::new()?; 27 | 28 | let map = if me.parse_path_as_uuid { 29 | let scan_result = workspace.scan()?; 30 | let mut map = HashMap::new(); 31 | for album in scan_result.into_iter() { 32 | if let WorkspaceAlbumState::Committed(album_path) = album.state { 33 | map.insert(album.album_id, album_path); 34 | } 35 | } 36 | map 37 | } else { 38 | HashMap::new() 39 | }; 40 | me.path = me 41 | .path 42 | .into_iter() 43 | .filter_map(|path| { 44 | if me.parse_path_as_uuid { 45 | let uuid = path.file_name().unwrap().to_str().unwrap(); 46 | let uuid = uuid.parse().expect("Failed to parse uuid"); 47 | 48 | let album_path = map.get(&uuid).cloned(); 49 | if album_path.is_none() { 50 | warn!("Album with uuid {} is not found in workspace", uuid); 51 | } 52 | 53 | album_path 54 | } else { 55 | Some(path) 56 | } 57 | }) 58 | .collect(); 59 | 60 | for path in me.path { 61 | workspace.apply_tags(&path, me.detailed)?; 62 | workspace.publish(path, me.soft)?; 63 | } 64 | Ok(()) 65 | } 66 | -------------------------------------------------------------------------------- /anni/src/subcommands/workspace/rm.rs: -------------------------------------------------------------------------------- 1 | use anni_workspace::AnniWorkspace; 2 | use clap::Args; 3 | use clap_handler::handler; 4 | use inquire::Confirm; 5 | use std::path::PathBuf; 6 | 7 | /// Revert workspace album back to uncommitted state 8 | #[derive(Args, Debug, Clone)] 9 | pub struct WorkspaceRmAction { 10 | #[clap(short = 'y', long = "yes")] 11 | skip_check: bool, 12 | 13 | path: PathBuf, 14 | } 15 | 16 | #[handler(WorkspaceRmAction)] 17 | pub async fn handle_workspace_rm(me: WorkspaceRmAction) -> anyhow::Result<()> { 18 | let workspace = AnniWorkspace::new()?; 19 | let album_id = workspace.get_album_id(&me.path)?; 20 | 21 | if !me.skip_check { 22 | match Confirm::new(&format!("Are you going to remove album {album_id}?")) 23 | .with_default(false) 24 | .prompt() 25 | { 26 | Err(_) | Ok(false) => bail!("Aborted"), 27 | _ => {} 28 | } 29 | } 30 | 31 | workspace.revert(me.path)?; 32 | Ok(()) 33 | } 34 | -------------------------------------------------------------------------------- /anni/src/subcommands/workspace/serve.rs: -------------------------------------------------------------------------------- 1 | use anni_provider::providers::NoCacheStrictLocalProvider; 2 | use anni_workspace::AnniWorkspace; 3 | use annil::provider::AnnilProvider; 4 | use annil::route::user; 5 | use annil::state::{AnnilKeys, AnnilState}; 6 | use axum::routing::get; 7 | use axum::{Extension, Router}; 8 | use std::net::SocketAddr; 9 | use std::sync::Arc; 10 | use tokio::net::TcpListener; 11 | 12 | /// The `serve` subcommand would launch three web services: 13 | /// 14 | /// 1. An `annil` server to serve music and cover. 15 | /// 2. A GraphQL API server to serve the metadata. 16 | /// 3. [Optional] A WebSocket based server to redirect terminal interactions. 17 | /// 18 | /// The server reuses the same port and use different endpoints for services. 19 | /// All service endpoints are customizable. 20 | use clap::Args; 21 | use clap_handler::handler; 22 | 23 | #[derive(Args, Debug, Clone)] 24 | pub struct WorkspaceServeAction { 25 | // Let's use a delicious duck as default port 26 | #[clap(long, default_value = "6655")] 27 | pub port: u16, 28 | #[clap(long = "annil", default_value = "/l")] 29 | pub annil_endpoint: String, 30 | #[clap(long = "metadata", default_value = "/metadata")] 31 | pub metadata_endpoint: String, 32 | #[clap(long = "ws", default_value = "/ws")] 33 | pub websocket_endpoint: String, 34 | } 35 | 36 | #[handler(WorkspaceServeAction)] 37 | pub async fn handle_workspace_serve(this: WorkspaceServeAction) -> anyhow::Result<()> { 38 | let workspace = AnniWorkspace::new()?; 39 | let _repo_root = workspace.repo_root(); 40 | let audio_root = workspace.objects_root(); 41 | 42 | let annil_state = AnnilState { 43 | version: "0.0.1-SNAPSHOT".to_string(), 44 | last_update: Default::default(), 45 | etag: Default::default(), 46 | metadata: None, 47 | }; 48 | let annil_provider = AnnilProvider::new(NoCacheStrictLocalProvider { 49 | root: audio_root, 50 | layer: 2, 51 | }); 52 | let annil_keys = AnnilKeys::new("a token here".as_bytes(), "".as_bytes(), String::new()); 53 | 54 | type Provider = NoCacheStrictLocalProvider; 55 | let annil = Router::new() 56 | .route("/info", get(user::info)) 57 | .route("/albums", get(user::albums::)) 58 | .route( 59 | "/:album_id/:disc_id/:track_id", 60 | get(user::audio::).head(user::audio_head::), 61 | ) 62 | .route("/:album_id/cover", get(user::cover::)) 63 | .route("/:album_id/:disc_id/cover", get(user::cover::)) 64 | .layer(Extension(Arc::new(annil_state))) 65 | .layer(Extension(Arc::new(annil_provider))) 66 | .layer(Extension(Arc::new(annil_keys))); 67 | 68 | let app = Router::new().nest("/l", annil); 69 | 70 | let addr = SocketAddr::from(([127, 0, 0, 1], this.port)); 71 | let listener = TcpListener::bind(&addr).await.unwrap(); 72 | axum::serve(listener, app).await.unwrap(); 73 | 74 | Ok(()) 75 | } 76 | -------------------------------------------------------------------------------- /anni/src/subcommands/workspace/target/drive.rs: -------------------------------------------------------------------------------- 1 | // use crate::workspace::target::WorkspaceTarget; 2 | // use anni_provider::providers::drive::DriveProviderSettings; 3 | // use google_drive3::api::File; 4 | // use google_drive3::hyper::client::HttpConnector; 5 | // use google_drive3::hyper_rustls::HttpsConnector; 6 | // use google_drive3::DriveHub; 7 | // use std::path::Path; 8 | 9 | // pub struct WorkspaceDriveTarget { 10 | // hub: Box>>, 11 | // settings: DriveProviderSettings, 12 | // } 13 | 14 | // impl WorkspaceTarget for WorkspaceDriveTarget { 15 | // async fn mkdir

(&self, path: P) -> std::io::Result<()> 16 | // where 17 | // P: AsRef, 18 | // { 19 | // todo!() 20 | // } 21 | 22 | // async fn copy

(&self, src: P, dst: P) -> std::io::Result<()> 23 | // where 24 | // P: AsRef, 25 | // { 26 | // todo!() 27 | // } 28 | // } 29 | -------------------------------------------------------------------------------- /anni/src/subcommands/workspace/target/file.rs: -------------------------------------------------------------------------------- 1 | use crate::workspace::target::WorkspaceTarget; 2 | use std::path::{Path, PathBuf}; 3 | 4 | pub struct WorkspaceFileTarget(pub PathBuf); 5 | 6 | impl WorkspaceTarget for WorkspaceFileTarget { 7 | async fn mkdir

(&self, path: P) -> std::io::Result<()> 8 | where 9 | P: AsRef, 10 | { 11 | tokio::fs::create_dir_all(self.0.join(path)).await 12 | } 13 | 14 | async fn copy

(&self, src: P, dst: P) -> std::io::Result<()> 15 | where 16 | P: AsRef, 17 | { 18 | tokio::fs::copy(src, self.0.join(dst)).await.map(|_| ()) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /anni/src/subcommands/workspace/target/mod.rs: -------------------------------------------------------------------------------- 1 | use std::path::Path; 2 | 3 | mod drive; 4 | mod file; 5 | 6 | pub trait WorkspaceTarget { 7 | async fn mkdir

(&self, path: P) -> std::io::Result<()> 8 | where 9 | P: AsRef; 10 | 11 | async fn copy

(&self, src: P, dst: P) -> std::io::Result<()> 12 | where 13 | P: AsRef; 14 | } 15 | -------------------------------------------------------------------------------- /anni/src/subcommands/workspace/update.rs: -------------------------------------------------------------------------------- 1 | use anni_workspace::AnniWorkspace; 2 | use clap::Args; 3 | use clap_handler::handler; 4 | use std::path::PathBuf; 5 | 6 | #[derive(Args, Debug, Clone)] 7 | pub struct WorkspaceUpdateAction { 8 | #[clap(long)] 9 | detailed: bool, 10 | 11 | path: PathBuf, 12 | } 13 | 14 | #[handler(WorkspaceUpdateAction)] 15 | pub async fn handle_workspace_update(me: WorkspaceUpdateAction) -> anyhow::Result<()> { 16 | let workspace = AnniWorkspace::new()?; 17 | workspace.apply_tags(me.path, me.detailed)?; 18 | 19 | Ok(()) 20 | } 21 | -------------------------------------------------------------------------------- /anni/src/utils/log.rs: -------------------------------------------------------------------------------- 1 | /// Modified from https://github.com/ProjectAnni/pretty-env-logger 2 | use std::fmt; 3 | use std::sync::atomic::{AtomicUsize, Ordering}; 4 | 5 | use env_logger::fmt::{Color, Formatter, Style, StyledValue}; 6 | use log::{Level, Record}; 7 | 8 | /// Formatter used in env_logger builder 9 | /// 10 | /// Formats output with colored level 11 | pub fn formatter(f: &mut Formatter, record: &Record) -> std::io::Result<()> { 12 | use std::io::Write; 13 | let target = record.target(); 14 | let max_width = max_target_width(target); 15 | 16 | let mut style = f.style(); 17 | let level = colored_level(&mut style, record.level()); 18 | 19 | let mut style = f.style(); 20 | let target = style.set_bold(true).value(Padded { 21 | value: target, 22 | width: max_width, 23 | }); 24 | 25 | writeln!(f, " {level} {target} > {}", record.args(),) 26 | } 27 | 28 | struct Padded { 29 | value: T, 30 | width: usize, 31 | } 32 | 33 | impl fmt::Display for Padded { 34 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 35 | write!(f, "{: usize { 42 | let max_width = MAX_MODULE_WIDTH.load(Ordering::Relaxed); 43 | if max_width < target.len() { 44 | MAX_MODULE_WIDTH.store(target.len(), Ordering::Relaxed); 45 | target.len() 46 | } else { 47 | max_width 48 | } 49 | } 50 | 51 | fn colored_level<'a>(style: &'a mut Style, level: Level) -> StyledValue<'a, &'static str> { 52 | match level { 53 | Level::Trace => style.set_color(Color::Magenta).value("TRACE"), 54 | Level::Debug => style.set_color(Color::Blue).value("DEBUG"), 55 | Level::Info => style.set_color(Color::Green).value("INFO "), 56 | Level::Warn => style.set_color(Color::Yellow).value("WARN "), 57 | Level::Error => style.set_color(Color::Red).value("ERROR"), 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /anni/src/utils/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod log; 2 | -------------------------------------------------------------------------------- /anni/tests/common.rs: -------------------------------------------------------------------------------- 1 | use std::env; 2 | use std::path::PathBuf; 3 | use std::process::Command; 4 | 5 | // https://github.com/rust-lang/cargo/blob/7fa132c7272fb9faca365c1d350e8e3c4c0d45e9/tests/cargotest/support/mod.rs#L316-L333 6 | pub fn anni_dir() -> PathBuf { 7 | env::current_exe() 8 | .ok() 9 | .map(|mut path| { 10 | path.pop(); 11 | if path.ends_with("deps") { 12 | path.pop(); 13 | } 14 | path 15 | }) 16 | .expect("Cannot get anni_dir path.") 17 | } 18 | 19 | pub fn anni_exe() -> PathBuf { 20 | anni_dir().join(format!("anni{}", env::consts::EXE_SUFFIX)) 21 | } 22 | 23 | pub fn run(subcommands: &[&str]) -> Command { 24 | let mut cmd = Command::new(anni_exe()); 25 | cmd.args(subcommands); 26 | cmd 27 | } 28 | -------------------------------------------------------------------------------- /anni/tests/flac.rs: -------------------------------------------------------------------------------- 1 | use std::fs::File; 2 | use std::io::Read; 3 | 4 | mod common; 5 | 6 | const TEST_TAGS: &str = r#"TITLE=TRACK ONE 7 | ALBUM=TestAlbum 8 | ARTIST=TestArtist 9 | DATE=2021-01-24 10 | TRACKNUMBER=1 11 | TRACKTOTAL=1 12 | DISCNUMBER=1 13 | DISCTOTAL=1 14 | "#; 15 | 16 | const FLAC_PATH: &str = "../assets/1s-full.flac"; 17 | const COVER_PATH: &str = "../assets/1s-cover.png"; 18 | 19 | #[test] 20 | fn flac_export_default() { 21 | let cmd = common::run(&["flac", "export", FLAC_PATH]) 22 | .output() 23 | .unwrap(); 24 | assert_eq!( 25 | String::from_utf8(cmd.stdout).expect("Invalid UTF-8 output."), 26 | TEST_TAGS 27 | ); 28 | } 29 | 30 | #[test] 31 | fn flac_export_tags() { 32 | let cmd = common::run(&["flac", "export", "--type=tag", FLAC_PATH]) 33 | .output() 34 | .unwrap(); 35 | assert_eq!( 36 | String::from_utf8(cmd.stdout).expect("Invalid UTF-8 output."), 37 | TEST_TAGS 38 | ); 39 | 40 | let cmd = common::run(&["flac", "export", "-t=comment", FLAC_PATH]) 41 | .output() 42 | .unwrap(); 43 | assert_eq!( 44 | String::from_utf8(cmd.stdout).expect("Invalid UTF-8 output."), 45 | TEST_TAGS 46 | ); 47 | } 48 | 49 | #[test] 50 | fn flac_export_cover() { 51 | let cmd = common::run(&["flac", "export", "-t=picture", FLAC_PATH]) 52 | .output() 53 | .unwrap(); 54 | 55 | let mut file = File::open(COVER_PATH).expect("Failed to open cover."); 56 | let mut data = Vec::new(); 57 | file.read_to_end(&mut data).expect("Failed to read cover."); 58 | assert_eq!(cmd.stdout, data); 59 | } 60 | -------------------------------------------------------------------------------- /annil/.gitignore: -------------------------------------------------------------------------------- 1 | config.toml -------------------------------------------------------------------------------- /annil/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## 0.3.0 [Unreleased] 9 | 10 | - Implemented OPUS transcoding. 11 | - Fixed http range logic for audio needs transcode. 12 | - Upgraded `axum` to `0.7` 13 | 14 | ## 0.2.0 15 | 16 | - Upgrade `anni-repo` to `0.3.0` 17 | 18 | ## 0.1.1 19 | 20 | - Upgrade `tower-http` to `0.4.0` 21 | - Upgrade `anni-repo` to `0.2.0` 22 | - Upgrade `anni-provider` to `0.1.3` 23 | 24 | ## 0.1.0 25 | 26 | - Refactor with `axum`. Now all route handlers can be easily reused. 27 | -------------------------------------------------------------------------------- /annil/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "annil" 3 | version = "0.2.0" 4 | description = "A basic implementation of annil protocol." 5 | 6 | edition.workspace = true 7 | authors.workspace = true 8 | repository.workspace = true 9 | license.workspace = true 10 | 11 | [dependencies] 12 | axum = { workspace = true, features = ["macros"] } 13 | tower-http = { version = "0.5.0", features = ["cors"] } 14 | tokio = { version = "1", features = ["full"] } 15 | tokio-util = { version = "0.7.4", features = ["io"] } 16 | futures = "0.3" 17 | 18 | anyhow.workspace = true 19 | thiserror.workspace = true 20 | async-trait = "0.1" 21 | 22 | anni-flac = { version = "0.2.2", path = "../anni-flac", features = ["async"] } 23 | anni-repo = { version = "0.4.2", path = "../anni-repo", features = [ 24 | "git", 25 | "db-write", 26 | ], optional = true } 27 | anni-provider = { version = "0.3.1", path = "../anni-provider" } 28 | 29 | serde.workspace = true 30 | toml.workspace = true 31 | log.workspace = true 32 | env_logger = "0.10" 33 | jwt-simple = "0.11.9" 34 | uuid.workspace = true 35 | base64 = "0.21.0" 36 | 37 | [features] 38 | default = ["metadata", "transcode"] 39 | metadata = ["anni-repo"] 40 | transcode = [] 41 | -------------------------------------------------------------------------------- /annil/src/extractor/admin.rs: -------------------------------------------------------------------------------- 1 | use crate::extractor::auth::AuthExtractor; 2 | use crate::{error::AnnilError, state::AnnilKeys}; 3 | use async_trait::async_trait; 4 | use axum::{extract::FromRequestParts, http::request::Parts, Extension}; 5 | use std::sync::Arc; 6 | 7 | pub struct AnnilAdmin; 8 | 9 | #[async_trait] 10 | impl FromRequestParts for AnnilAdmin 11 | where 12 | S: Send + Sync, 13 | { 14 | type Rejection = AnnilError; 15 | 16 | async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { 17 | let AuthExtractor(auth) = AuthExtractor::from_request_parts(parts, state).await?; 18 | let keys = Extension::>::from_request_parts(parts, state) 19 | .await 20 | .expect("Failed to extract keys from extension. Please re-check your code first."); 21 | 22 | if auth != keys.admin_token { 23 | return Err(AnnilError::Unauthorized); 24 | } 25 | 26 | Ok(AnnilAdmin) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /annil/src/extractor/auth.rs: -------------------------------------------------------------------------------- 1 | use crate::error::AnnilError; 2 | use async_trait::async_trait; 3 | use axum::extract::{FromRequestParts, Query}; 4 | use axum::http::request::Parts; 5 | use serde::Deserialize; 6 | 7 | /// Auth extractor for Annil. 8 | /// 9 | /// Extracts auth from `Authorization` header or `auth` query parameter. 10 | pub struct AuthExtractor(pub String); 11 | 12 | #[derive(Deserialize)] 13 | struct AuthQuery { 14 | auth: String, 15 | } 16 | 17 | #[async_trait] 18 | impl FromRequestParts for AuthExtractor 19 | where 20 | S: Send + Sync, 21 | { 22 | type Rejection = AnnilError; 23 | 24 | async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { 25 | let auth = if let Some(auth) = parts.headers.get("Authorization") { 26 | auth.to_str() 27 | .map_err(|_| AnnilError::Unauthorized)? 28 | .to_string() 29 | } else { 30 | let query = Query::::from_request_parts(parts, state) 31 | .await 32 | .map_err(|_| AnnilError::Unauthorized)?; 33 | query.auth.to_string() 34 | }; 35 | 36 | Ok(Self(auth)) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /annil/src/extractor/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod admin; 2 | pub mod auth; 3 | pub mod token; 4 | pub mod track; 5 | -------------------------------------------------------------------------------- /annil/src/extractor/track.rs: -------------------------------------------------------------------------------- 1 | use crate::error::AnnilError; 2 | use async_trait::async_trait; 3 | use axum::extract::{FromRequestParts, Path}; 4 | use axum::http::request::Parts; 5 | use serde::Deserialize; 6 | use std::num::NonZeroU8; 7 | use uuid::Uuid; 8 | 9 | #[derive(Deserialize)] 10 | pub struct TrackIdentifier { 11 | pub album_id: Uuid, 12 | pub disc_id: NonZeroU8, 13 | pub track_id: NonZeroU8, 14 | } 15 | 16 | #[async_trait] 17 | impl FromRequestParts for TrackIdentifier 18 | where 19 | S: Send + Sync, 20 | { 21 | type Rejection = AnnilError; 22 | 23 | async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { 24 | let Path(track) = Path::::from_request_parts(parts, &()) 25 | .await 26 | .map_err(|_| AnnilError::UnknownPath)?; 27 | 28 | Ok(track) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /annil/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![feature(int_roundings)] 2 | 3 | pub mod extractor; 4 | pub mod provider; 5 | pub mod route; 6 | pub mod state; 7 | pub mod utils; 8 | 9 | pub mod metadata; 10 | mod transcode; 11 | 12 | pub mod error { 13 | use axum::http::StatusCode; 14 | use axum::response::{IntoResponse, Response}; 15 | use thiserror::Error; 16 | 17 | #[derive(Error, Debug)] 18 | pub enum AnnilError { 19 | #[error("unauthorized")] 20 | Unauthorized, 21 | #[error("unknown path")] 22 | UnknownPath, 23 | #[error("not found")] 24 | NotFound, 25 | } 26 | 27 | impl IntoResponse for AnnilError { 28 | fn into_response(self) -> Response { 29 | match self { 30 | AnnilError::Unauthorized => StatusCode::UNAUTHORIZED, 31 | AnnilError::UnknownPath => StatusCode::FORBIDDEN, 32 | AnnilError::NotFound => StatusCode::NOT_FOUND, 33 | } 34 | .into_response() 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /annil/src/metadata.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | use std::path::PathBuf; 3 | 4 | #[derive(Deserialize, Clone)] 5 | pub struct MetadataConfig { 6 | pub repo: String, 7 | pub branch: String, 8 | pub base: PathBuf, 9 | #[serde(default = "default_true")] 10 | pub pull: bool, 11 | pub proxy: Option, 12 | } 13 | 14 | fn default_true() -> bool { 15 | true 16 | } 17 | 18 | #[cfg(feature = "metadata")] 19 | impl MetadataConfig { 20 | pub fn init(&self) -> anyhow::Result { 21 | use anni_repo::RepositoryManager; 22 | 23 | log::info!("Fetching metadata repository..."); 24 | 25 | let repo_root = self.base.join("repo"); 26 | let repo = if !repo_root.exists() { 27 | log::debug!("Cloning metadata repository from {}", self.repo); 28 | RepositoryManager::clone(&self.repo, repo_root)? 29 | } else if self.pull { 30 | log::debug!("Updating metadata repository at branch: {}", self.branch); 31 | RepositoryManager::pull(repo_root, &self.branch)? 32 | } else { 33 | log::debug!("Loading metadata repository at {}", repo_root.display()); 34 | RepositoryManager::new(repo_root)? 35 | }; 36 | 37 | log::debug!("Generating metadata database..."); 38 | let repo = repo.into_owned_manager()?; 39 | let database_path = self.base.join("repo.db"); 40 | repo.to_database(&database_path)?; 41 | 42 | log::info!("Metadata repository fetched."); 43 | Ok(database_path) 44 | } 45 | 46 | pub fn into_db(self) -> LazyDb { 47 | use anni_repo::setup_git2; 48 | // proxy settings 49 | if let Some(proxy) = &self.proxy { 50 | // if metadata.proxy is an empty string, do not use proxy 51 | if proxy.is_empty() { 52 | setup_git2(None); 53 | } else { 54 | // otherwise, set proxy in config file 55 | setup_git2(Some(proxy.clone())); 56 | } 57 | // if no proxy was provided, use default behavior (http_proxy) 58 | } 59 | 60 | LazyDb { 61 | metadata: self, 62 | db_path: None, 63 | } 64 | } 65 | } 66 | 67 | #[cfg(feature = "metadata")] 68 | pub struct LazyDb { 69 | metadata: MetadataConfig, 70 | db_path: Option, 71 | } 72 | 73 | #[cfg(feature = "metadata")] 74 | impl LazyDb { 75 | pub fn open(&mut self) -> anyhow::Result { 76 | let db = match self.db_path { 77 | Some(ref p) => p, 78 | None => { 79 | let p = self.metadata.init()?; 80 | self.db_path.insert(p) 81 | } 82 | }; 83 | Ok(anni_repo::db::RepoDatabaseRead::new(db)?) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /annil/src/provider.rs: -------------------------------------------------------------------------------- 1 | use anni_provider::{AnniProvider, ProviderError}; 2 | use std::ops::{Deref, DerefMut}; 3 | use tokio::sync::RwLock; 4 | 5 | pub struct AnnilProvider(RwLock); 6 | 7 | impl AnnilProvider { 8 | pub fn new(provider: T) -> Self { 9 | Self(RwLock::new(provider)) 10 | } 11 | 12 | pub async fn compute_etag(&self) -> Result { 13 | use base64::engine::general_purpose::STANDARD; 14 | use base64::Engine; 15 | 16 | let provider = self.0.read().await; 17 | 18 | let mut etag = 0; 19 | for album in provider.albums().await? { 20 | if let Ok(uuid) = uuid::Uuid::parse_str(album.as_ref()) { 21 | etag ^= uuid.as_u128(); 22 | } else { 23 | log::error!("Failed to parse uuid: {album}"); 24 | } 25 | } 26 | 27 | Ok(format!(r#""{}""#, STANDARD.encode(etag.to_be_bytes()))) 28 | } 29 | } 30 | 31 | impl Deref for AnnilProvider { 32 | type Target = RwLock; 33 | 34 | fn deref(&self) -> &Self::Target { 35 | &self.0 36 | } 37 | } 38 | 39 | impl DerefMut for AnnilProvider { 40 | fn deref_mut(&mut self) -> &mut Self::Target { 41 | &mut self.0 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /annil/src/route/admin/mod.rs: -------------------------------------------------------------------------------- 1 | mod reload; 2 | mod sign; 3 | 4 | pub use reload::*; 5 | pub use sign::*; 6 | -------------------------------------------------------------------------------- /annil/src/route/admin/reload.rs: -------------------------------------------------------------------------------- 1 | use crate::extractor::admin::AnnilAdmin; 2 | use crate::provider::AnnilProvider; 3 | use crate::state::AnnilState; 4 | use anni_provider::AnniProvider; 5 | use axum::Extension; 6 | use std::sync::Arc; 7 | use std::time::{SystemTime, UNIX_EPOCH}; 8 | 9 | pub async fn reload

( 10 | _: AnnilAdmin, 11 | Extension(data): Extension>, 12 | Extension(provider): Extension>>, 13 | ) where 14 | P: AnniProvider + Send + Sync, 15 | { 16 | #[cfg(feature = "metadata")] 17 | if let Some(metadata) = &data.metadata { 18 | use anni_repo::RepositoryManager; 19 | 20 | if metadata.pull { 21 | let repo = 22 | RepositoryManager::pull(metadata.base.join("repo"), &metadata.branch).unwrap(); 23 | let repo = repo.into_owned_manager().unwrap(); 24 | 25 | let database_path = metadata.base.join("repo.db"); 26 | repo.to_database(&database_path).unwrap(); 27 | } 28 | } 29 | 30 | if let Err(e) = provider.write().await.reload().await { 31 | log::error!("Failed to reload provider: {:?}", e); 32 | } 33 | 34 | *data.etag.write().await = provider.compute_etag().await.unwrap(); 35 | *data.last_update.write().await = SystemTime::now() 36 | .duration_since(UNIX_EPOCH) 37 | .unwrap() 38 | .as_secs(); 39 | } 40 | -------------------------------------------------------------------------------- /annil/src/route/admin/sign.rs: -------------------------------------------------------------------------------- 1 | use crate::extractor::admin::AnnilAdmin; 2 | use crate::extractor::token::{AnnilClaim, ShareToken, UserClaim}; 3 | use crate::state::AnnilKeys; 4 | use axum::{Extension, Json}; 5 | use jwt_simple::prelude::*; 6 | use std::sync::Arc; 7 | 8 | #[derive(Deserialize, Clone)] 9 | pub struct SignPayload { 10 | user_id: String, 11 | #[serde(default)] 12 | share: bool, 13 | } 14 | 15 | pub async fn sign( 16 | _: AnnilAdmin, 17 | Extension(keys): Extension>, 18 | Json(info): Json, 19 | ) -> String { 20 | let custom = AnnilClaim::User(UserClaim { 21 | user_id: info.user_id, 22 | share: if info.share { 23 | Some(ShareToken { 24 | key_id: keys.share_key.key_id().as_deref().unwrap().to_string(), 25 | secret: unsafe { String::from_utf8_unchecked(keys.share_key.to_bytes().to_vec()) }, 26 | allowed: None, 27 | }) 28 | } else { 29 | None 30 | }, 31 | }); 32 | 33 | let now = Some(Clock::now_since_epoch()); 34 | let claim = JWTClaims { 35 | issued_at: now, 36 | expires_at: None, 37 | invalid_before: None, 38 | issuer: None, 39 | subject: None, 40 | audiences: None, 41 | jwt_id: None, 42 | nonce: None, 43 | custom, 44 | }; 45 | keys.sign_key 46 | .authenticate(claim) 47 | .expect("Failed to sign user token") 48 | } 49 | -------------------------------------------------------------------------------- /annil/src/route/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod admin; 2 | pub mod user; 3 | -------------------------------------------------------------------------------- /annil/src/route/user/albums.rs: -------------------------------------------------------------------------------- 1 | use crate::extractor::token::AnnilClaim; 2 | use crate::provider::AnnilProvider; 3 | use crate::state::AnnilState; 4 | use anni_provider::AnniProvider; 5 | use axum::http::header::{ETAG, IF_NONE_MATCH}; 6 | use axum::http::{HeaderMap, StatusCode}; 7 | use axum::response::{IntoResponse, Response}; 8 | use axum::{Extension, Json}; 9 | use std::collections::HashSet; 10 | use std::sync::Arc; 11 | 12 | /// Get available albums of current annil server 13 | pub async fn albums

( 14 | claims: AnnilClaim, 15 | Extension(provider): Extension>>, 16 | Extension(data): Extension>, 17 | headers: HeaderMap, 18 | ) -> Response 19 | where 20 | P: AnniProvider + Send + Sync, 21 | { 22 | match claims { 23 | AnnilClaim::User(_) => { 24 | let etag_now = data.etag.read().await.to_string(); 25 | 26 | if let Some(Ok(mut etag)) = headers.get(IF_NONE_MATCH).map(|v| v.to_str()) { 27 | if etag.starts_with("W/") { 28 | etag = &etag[2..]; 29 | } 30 | if etag == etag_now { 31 | return StatusCode::NOT_MODIFIED.into_response(); 32 | } 33 | } 34 | 35 | // users can get real album list 36 | let provider = provider.read().await; 37 | let albums = provider.albums().await.unwrap_or(HashSet::new()); 38 | ([(ETAG, etag_now)], Json(albums)).into_response() 39 | } 40 | AnnilClaim::Share(share) => { 41 | // guests can only get album list defined in jwt 42 | Json(share.audios.keys().collect::>()).into_response() 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /annil/src/route/user/cover.rs: -------------------------------------------------------------------------------- 1 | use axum::body::Body; 2 | use axum::extract::Path; 3 | use axum::http::header::{CACHE_CONTROL, CONTENT_TYPE}; 4 | use axum::http::StatusCode; 5 | use axum::response::{IntoResponse, Response}; 6 | use axum::Extension; 7 | use std::num::NonZeroU8; 8 | use std::sync::Arc; 9 | 10 | use crate::provider::AnnilProvider; 11 | use anni_provider::AnniProvider; 12 | use serde::Deserialize; 13 | use tokio_util::io::ReaderStream; 14 | use uuid::Uuid; 15 | 16 | #[derive(Deserialize)] 17 | pub struct CoverPath { 18 | album_id: Uuid, 19 | disc_id: Option, 20 | } 21 | 22 | /// Get audio cover of an album with {album_id} and optional {disc_id} 23 | pub async fn cover

( 24 | Path(CoverPath { album_id, disc_id }): Path, 25 | Extension(provider): Extension>>, 26 | ) -> Response 27 | where 28 | P: AnniProvider + Send + Sync, 29 | { 30 | let provider = provider.read().await; 31 | let album_id = album_id.to_string(); 32 | 33 | if !provider.has_album(&album_id).await { 34 | return (StatusCode::NOT_FOUND, [(CACHE_CONTROL, "private")]).into_response(); 35 | } 36 | 37 | match provider.get_cover(&album_id, disc_id).await { 38 | Ok(cover) => ( 39 | ([ 40 | (CONTENT_TYPE, "image/jpeg"), 41 | (CACHE_CONTROL, "public, max-age=31536000"), 42 | ]), 43 | Body::from_stream(ReaderStream::new(cover)), 44 | ) 45 | .into_response(), 46 | Err(_) => (StatusCode::NOT_FOUND, [(CACHE_CONTROL, "private")]).into_response(), 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /annil/src/route/user/info.rs: -------------------------------------------------------------------------------- 1 | use crate::state::AnnilState; 2 | use axum::{Extension, Json}; 3 | use jwt_simple::reexports::serde_json::{json, Value}; 4 | use std::sync::Arc; 5 | 6 | pub async fn info(Extension(data): Extension>) -> Json { 7 | Json(json!({ 8 | "version": data.version, 9 | "protocol_version": "0.4.1", 10 | "last_update": *data.last_update.read().await, 11 | })) 12 | } 13 | -------------------------------------------------------------------------------- /annil/src/route/user/mod.rs: -------------------------------------------------------------------------------- 1 | mod albums; 2 | mod audio; 3 | mod cover; 4 | mod info; 5 | 6 | pub use albums::*; 7 | pub use audio::*; 8 | pub use cover::*; 9 | pub use info::*; 10 | -------------------------------------------------------------------------------- /annil/src/state.rs: -------------------------------------------------------------------------------- 1 | use jwt_simple::prelude::HS256Key; 2 | use tokio::sync::RwLock; 3 | 4 | /// Readonly keys 5 | pub struct AnnilKeys { 6 | pub sign_key: HS256Key, 7 | pub share_key: HS256Key, 8 | pub admin_token: String, 9 | } 10 | 11 | impl AnnilKeys { 12 | pub fn new(sign_key: &[u8], share_key: &[u8], admin_token: String) -> Self { 13 | Self { 14 | sign_key: HS256Key::from_bytes(sign_key), 15 | share_key: HS256Key::from_bytes(share_key), 16 | admin_token, 17 | } 18 | } 19 | } 20 | 21 | pub struct AnnilState { 22 | pub version: String, 23 | pub last_update: RwLock, 24 | pub etag: RwLock, 25 | 26 | pub metadata: Option, 27 | } 28 | -------------------------------------------------------------------------------- /annim/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "annim" 3 | version = "0.1.0" 4 | publish = false 5 | 6 | edition.workspace = true 7 | authors.workspace = true 8 | repository.workspace = true 9 | 10 | [dependencies] 11 | tokio = { version = "1", features = ["full"] } 12 | axum.workspace = true 13 | tower-http = { version = "0.5.0", features = ["cors"] } 14 | async-graphql = { version = "7.0.0", features = [ 15 | "uuid", 16 | "decimal", 17 | "chrono", 18 | "dataloader", 19 | "dynamic-schema", 20 | "graphiql", 21 | "raw_value", 22 | ] } 23 | async-graphql-axum = "7.0.0" 24 | sea-orm = { version = "1.0.0", features = [ 25 | "sqlx-sqlite", 26 | "sqlx-postgres", 27 | "runtime-tokio-rustls", 28 | "with-uuid", 29 | ] } 30 | sea-orm-migration = { version = "1.0.0", features = [ 31 | "runtime-tokio-rustls", 32 | "with-uuid", 33 | ] } 34 | 35 | anyhow.workspace = true 36 | thiserror.workspace = true 37 | 38 | tracing = { version = "0.1.37" } 39 | tracing-subscriber = { version = "0.3.17" } 40 | serde_json = "1.0" 41 | chrono = "0.4.38" 42 | serde.workspace = true 43 | 44 | # Search 45 | tantivy = "0.22.0" 46 | lindera-core = "0.32.2" 47 | lindera-dictionary = "0.32.2" 48 | lindera-tantivy = { git = "https://github.com/ProjectAnni/lindera-tantivy", features = [ 49 | "ipadic", 50 | "compress", 51 | ] } 52 | 53 | rmp-serde = "1.3.0" 54 | base64 = "0.22.1" 55 | 56 | [features] 57 | default = ["postgres"] 58 | sqlite = ["sea-orm/sqlx-sqlite"] 59 | postgres = ["sea-orm/sqlx-postgres"] 60 | -------------------------------------------------------------------------------- /annim/Readme.md: -------------------------------------------------------------------------------- 1 | # Annim 2 | 3 | ## Debug 4 | 5 | ```bash 6 | export ANNIM_DATABASE_URL=postgres://postgres:password@localhost:5432/annim 7 | export ANNIM_SEARCH_DIRECTORY=/tmp/tantivy 8 | cargo run -p annim --release 9 | ``` 10 | 11 | ## Installation 12 | 13 | ```bash 14 | cargo install seaography-cli 15 | ``` 16 | 17 | ## Code generation 18 | 19 | ```bash 20 | sea-orm-cli generate entity --database-url 'sqlite:///tmp/annim.sqlite?mode=rwc' 21 | ``` 22 | -------------------------------------------------------------------------------- /annim/src/auth.rs: -------------------------------------------------------------------------------- 1 | use std::sync::LazyLock; 2 | 3 | /// https://github.com/async-graphql/examples/blob/0c4e5e29e97a41c8877c126cbcefb82721ae81af/models/token/src/lib.rs 4 | use async_graphql::{Data, Result}; 5 | use serde::Deserialize; 6 | 7 | static TOKEN: LazyLock = 8 | LazyLock::new(|| std::env::var("ANNIM_AUTH_TOKEN").unwrap_or_else(|_| "114514".to_string())); 9 | 10 | pub struct AuthToken(String); 11 | 12 | impl AuthToken { 13 | pub fn new(token: String) -> Self { 14 | Self(token) 15 | } 16 | 17 | pub fn is_valid(&self) -> bool { 18 | self.0 == TOKEN.as_str() 19 | } 20 | } 21 | 22 | pub async fn on_connection_init(value: serde_json::Value) -> Result { 23 | #[derive(Deserialize)] 24 | struct Payload { 25 | token: String, 26 | } 27 | 28 | // Coerce the connection params into our `Payload` struct so we can 29 | // validate the token exists in the headers. 30 | if let Ok(payload) = serde_json::from_value::(value) { 31 | let mut data = Data::default(); 32 | data.insert(AuthToken(payload.token)); 33 | Ok(data) 34 | } else { 35 | Err("Token is required".into()) 36 | } 37 | } 38 | 39 | pub(crate) struct AdminGuard; 40 | 41 | impl async_graphql::Guard for AdminGuard { 42 | async fn check(&self, ctx: &async_graphql::Context<'_>) -> async_graphql::Result<()> { 43 | let token = ctx 44 | .data::() 45 | .map_err(|_| async_graphql::Error::new("Token is required"))?; 46 | if !token.is_valid() { 47 | return Err(async_graphql::Error::new("Invalid token")); 48 | } 49 | 50 | Ok(()) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /annim/src/entities/helper.rs: -------------------------------------------------------------------------------- 1 | #[cfg(feature = "postgres")] 2 | mod postgres; 3 | #[cfg(feature = "postgres")] 4 | pub use postgres::*; 5 | 6 | #[cfg(feature = "sqlite")] 7 | mod sqlite; 8 | #[cfg(feature = "sqlite")] 9 | pub use sqlite::*; 10 | -------------------------------------------------------------------------------- /annim/src/entities/helper/sqlite.rs: -------------------------------------------------------------------------------- 1 | use std::str::FromStr; 2 | 3 | use sea_orm::prelude::DateTimeUtc; 4 | 5 | use crate::graphql::types::{ 6 | MetadataOrganizeLevel as MetadataOrganizeLevelEnum, TagType as TagTypeEnum, 7 | TrackType as TrackTypeEnum, 8 | }; 9 | 10 | pub fn now() -> DateTimeUtc { 11 | chrono::Utc::now() 12 | } 13 | 14 | pub fn timestamp(input: DateTimeUtc) -> DateTimeUtc { 15 | input 16 | } 17 | 18 | impl From<&String> for TagTypeEnum { 19 | fn from(value: &String) -> Self { 20 | TagTypeEnum::from_str(&value).unwrap() 21 | } 22 | } 23 | 24 | impl From<&String> for TrackTypeEnum { 25 | fn from(value: &String) -> Self { 26 | TrackTypeEnum::from_str(&value).unwrap() 27 | } 28 | } 29 | 30 | impl From<&String> for MetadataOrganizeLevelEnum { 31 | fn from(value: &String) -> Self { 32 | MetadataOrganizeLevelEnum::from_str(&value).unwrap() 33 | } 34 | } 35 | 36 | impl From for String { 37 | fn from(value: TagTypeEnum) -> Self { 38 | value.to_string() 39 | } 40 | } 41 | 42 | impl From for String { 43 | fn from(value: TrackTypeEnum) -> Self { 44 | value.to_string() 45 | } 46 | } 47 | 48 | impl From for String { 49 | fn from(value: MetadataOrganizeLevelEnum) -> Self { 50 | value.to_string() 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /annim/src/entities/mod.rs: -------------------------------------------------------------------------------- 1 | #[cfg(feature = "postgres")] 2 | mod postgres; 3 | #[cfg(feature = "postgres")] 4 | pub use postgres::*; 5 | 6 | #[cfg(feature = "sqlite")] 7 | mod sqlite; 8 | #[cfg(feature = "sqlite")] 9 | pub use sqlite::*; 10 | 11 | pub(crate) mod helper; 12 | -------------------------------------------------------------------------------- /annim/src/entities/postgres/album.rs: -------------------------------------------------------------------------------- 1 | //! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0 2 | 3 | use super::sea_orm_active_enums::MetadataOrganizeLevel; 4 | use sea_orm::entity::prelude::*; 5 | 6 | #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] 7 | #[sea_orm(table_name = "album")] 8 | pub struct Model { 9 | #[sea_orm(primary_key)] 10 | pub id: i32, 11 | #[sea_orm(unique)] 12 | pub album_id: Uuid, 13 | pub title: String, 14 | pub edition: Option, 15 | pub catalog: Option, 16 | pub artist: String, 17 | pub release_year: i32, 18 | pub release_month: Option, 19 | pub release_day: Option, 20 | pub level: MetadataOrganizeLevel, 21 | pub created_at: DateTime, 22 | pub updated_at: DateTime, 23 | #[sea_orm(column_type = "JsonBinary", nullable)] 24 | pub extra: Option, 25 | } 26 | 27 | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] 28 | pub enum Relation { 29 | #[sea_orm(has_many = "super::album_tag_relation::Entity")] 30 | AlbumTagRelation, 31 | #[sea_orm(has_many = "super::disc::Entity")] 32 | Disc, 33 | #[sea_orm(has_many = "super::track::Entity")] 34 | Track, 35 | } 36 | 37 | impl Related for Entity { 38 | fn to() -> RelationDef { 39 | Relation::AlbumTagRelation.def() 40 | } 41 | } 42 | 43 | impl Related for Entity { 44 | fn to() -> RelationDef { 45 | Relation::Disc.def() 46 | } 47 | } 48 | 49 | impl Related for Entity { 50 | fn to() -> RelationDef { 51 | Relation::Track.def() 52 | } 53 | } 54 | 55 | impl ActiveModelBehavior for ActiveModel {} 56 | -------------------------------------------------------------------------------- /annim/src/entities/postgres/album_tag_relation.rs: -------------------------------------------------------------------------------- 1 | //! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0 2 | 3 | use sea_orm::entity::prelude::*; 4 | 5 | #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] 6 | #[sea_orm(table_name = "album_tag_relation")] 7 | pub struct Model { 8 | #[sea_orm(primary_key)] 9 | pub id: i32, 10 | pub tag_db_id: i32, 11 | pub album_db_id: i32, 12 | pub disc_db_id: Option, 13 | pub track_db_id: Option, 14 | } 15 | 16 | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] 17 | pub enum Relation { 18 | #[sea_orm( 19 | belongs_to = "super::album::Entity", 20 | from = "Column::AlbumDbId", 21 | to = "super::album::Column::Id", 22 | on_update = "NoAction", 23 | on_delete = "Cascade" 24 | )] 25 | Album, 26 | #[sea_orm( 27 | belongs_to = "super::disc::Entity", 28 | from = "Column::DiscDbId", 29 | to = "super::disc::Column::Id", 30 | on_update = "NoAction", 31 | on_delete = "Cascade" 32 | )] 33 | Disc, 34 | #[sea_orm( 35 | belongs_to = "super::tag_info::Entity", 36 | from = "Column::TagDbId", 37 | to = "super::tag_info::Column::Id", 38 | on_update = "NoAction", 39 | on_delete = "Cascade" 40 | )] 41 | TagInfo, 42 | #[sea_orm( 43 | belongs_to = "super::track::Entity", 44 | from = "Column::TrackDbId", 45 | to = "super::track::Column::Id", 46 | on_update = "NoAction", 47 | on_delete = "Cascade" 48 | )] 49 | Track, 50 | } 51 | 52 | impl Related for Entity { 53 | fn to() -> RelationDef { 54 | Relation::Album.def() 55 | } 56 | } 57 | 58 | impl Related for Entity { 59 | fn to() -> RelationDef { 60 | Relation::Disc.def() 61 | } 62 | } 63 | 64 | impl Related for Entity { 65 | fn to() -> RelationDef { 66 | Relation::TagInfo.def() 67 | } 68 | } 69 | 70 | impl Related for Entity { 71 | fn to() -> RelationDef { 72 | Relation::Track.def() 73 | } 74 | } 75 | 76 | impl ActiveModelBehavior for ActiveModel {} 77 | -------------------------------------------------------------------------------- /annim/src/entities/postgres/disc.rs: -------------------------------------------------------------------------------- 1 | //! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0 2 | 3 | use sea_orm::entity::prelude::*; 4 | 5 | #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] 6 | #[sea_orm(table_name = "disc")] 7 | pub struct Model { 8 | #[sea_orm(primary_key)] 9 | pub id: i32, 10 | pub album_db_id: i32, 11 | pub index: i32, 12 | pub title: Option, 13 | pub catalog: Option, 14 | pub artist: Option, 15 | pub created_at: DateTime, 16 | pub updated_at: DateTime, 17 | } 18 | 19 | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] 20 | pub enum Relation { 21 | #[sea_orm( 22 | belongs_to = "super::album::Entity", 23 | from = "Column::AlbumDbId", 24 | to = "super::album::Column::Id", 25 | on_update = "NoAction", 26 | on_delete = "Cascade" 27 | )] 28 | Album, 29 | #[sea_orm(has_many = "super::album_tag_relation::Entity")] 30 | AlbumTagRelation, 31 | #[sea_orm(has_many = "super::track::Entity")] 32 | Track, 33 | } 34 | 35 | impl Related for Entity { 36 | fn to() -> RelationDef { 37 | Relation::Album.def() 38 | } 39 | } 40 | 41 | impl Related for Entity { 42 | fn to() -> RelationDef { 43 | Relation::AlbumTagRelation.def() 44 | } 45 | } 46 | 47 | impl Related for Entity { 48 | fn to() -> RelationDef { 49 | Relation::Track.def() 50 | } 51 | } 52 | 53 | impl ActiveModelBehavior for ActiveModel {} 54 | -------------------------------------------------------------------------------- /annim/src/entities/postgres/mod.rs: -------------------------------------------------------------------------------- 1 | //! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0 2 | 3 | pub mod prelude; 4 | 5 | pub mod album; 6 | pub mod album_tag_relation; 7 | pub mod disc; 8 | pub mod sea_orm_active_enums; 9 | pub mod tag_info; 10 | pub mod tag_relation; 11 | pub mod track; 12 | -------------------------------------------------------------------------------- /annim/src/entities/postgres/prelude.rs: -------------------------------------------------------------------------------- 1 | //! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0 2 | 3 | pub use super::album::Entity as Album; 4 | pub use super::album_tag_relation::Entity as AlbumTagRelation; 5 | pub use super::disc::Entity as Disc; 6 | pub use super::tag_info::Entity as TagInfo; 7 | pub use super::tag_relation::Entity as TagRelation; 8 | pub use super::track::Entity as Track; 9 | -------------------------------------------------------------------------------- /annim/src/entities/postgres/sea_orm_active_enums.rs: -------------------------------------------------------------------------------- 1 | //! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0 2 | 3 | use sea_orm::entity::prelude::*; 4 | 5 | #[derive(Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum)] 6 | #[sea_orm( 7 | rs_type = "String", 8 | db_type = "Enum", 9 | enum_name = "metadata_organize_level" 10 | )] 11 | pub enum MetadataOrganizeLevel { 12 | #[sea_orm(string_value = "finished")] 13 | Finished, 14 | #[sea_orm(string_value = "initial")] 15 | Initial, 16 | #[sea_orm(string_value = "partial")] 17 | Partial, 18 | #[sea_orm(string_value = "reviewed")] 19 | Reviewed, 20 | } 21 | #[derive(Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum)] 22 | #[sea_orm(rs_type = "String", db_type = "Enum", enum_name = "tag_type")] 23 | pub enum TagType { 24 | #[sea_orm(string_value = "animation")] 25 | Animation, 26 | #[sea_orm(string_value = "artist")] 27 | Artist, 28 | #[sea_orm(string_value = "category")] 29 | Category, 30 | #[sea_orm(string_value = "game")] 31 | Game, 32 | #[sea_orm(string_value = "group")] 33 | Group, 34 | #[sea_orm(string_value = "organization")] 35 | Organization, 36 | #[sea_orm(string_value = "others")] 37 | Others, 38 | #[sea_orm(string_value = "project")] 39 | Project, 40 | #[sea_orm(string_value = "radio")] 41 | Radio, 42 | #[sea_orm(string_value = "series")] 43 | Series, 44 | } 45 | #[derive(Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum)] 46 | #[sea_orm(rs_type = "String", db_type = "Enum", enum_name = "track_type")] 47 | pub enum TrackType { 48 | #[sea_orm(string_value = "absolute")] 49 | Absolute, 50 | #[sea_orm(string_value = "drama")] 51 | Drama, 52 | #[sea_orm(string_value = "instrumental")] 53 | Instrumental, 54 | #[sea_orm(string_value = "normal")] 55 | Normal, 56 | #[sea_orm(string_value = "radio")] 57 | Radio, 58 | #[sea_orm(string_value = "unknown")] 59 | Unknown, 60 | #[sea_orm(string_value = "vocal")] 61 | Vocal, 62 | } 63 | -------------------------------------------------------------------------------- /annim/src/entities/postgres/tag_info.rs: -------------------------------------------------------------------------------- 1 | //! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0 2 | 3 | use super::sea_orm_active_enums::TagType; 4 | use sea_orm::entity::prelude::*; 5 | 6 | #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] 7 | #[sea_orm(table_name = "tag_info")] 8 | pub struct Model { 9 | #[sea_orm(primary_key)] 10 | pub id: i32, 11 | pub name: String, 12 | pub r#type: TagType, 13 | pub created_at: DateTime, 14 | pub updated_at: DateTime, 15 | } 16 | 17 | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] 18 | pub enum Relation { 19 | #[sea_orm(has_many = "super::album_tag_relation::Entity")] 20 | AlbumTagRelation, 21 | } 22 | 23 | impl Related for Entity { 24 | fn to() -> RelationDef { 25 | Relation::AlbumTagRelation.def() 26 | } 27 | } 28 | 29 | impl ActiveModelBehavior for ActiveModel {} 30 | -------------------------------------------------------------------------------- /annim/src/entities/postgres/tag_relation.rs: -------------------------------------------------------------------------------- 1 | //! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0 2 | 3 | use sea_orm::entity::prelude::*; 4 | 5 | #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] 6 | #[sea_orm(table_name = "tag_relation")] 7 | pub struct Model { 8 | #[sea_orm(primary_key)] 9 | pub id: i32, 10 | pub tag_db_id: i32, 11 | pub parent_tag_db_id: i32, 12 | } 13 | 14 | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] 15 | pub enum Relation { 16 | #[sea_orm( 17 | belongs_to = "super::tag_info::Entity", 18 | from = "Column::ParentTagDbId", 19 | to = "super::tag_info::Column::Id", 20 | on_update = "NoAction", 21 | on_delete = "Cascade" 22 | )] 23 | TagInfo2, 24 | #[sea_orm( 25 | belongs_to = "super::tag_info::Entity", 26 | from = "Column::TagDbId", 27 | to = "super::tag_info::Column::Id", 28 | on_update = "NoAction", 29 | on_delete = "Cascade" 30 | )] 31 | TagInfo1, 32 | } 33 | 34 | impl ActiveModelBehavior for ActiveModel {} 35 | -------------------------------------------------------------------------------- /annim/src/entities/postgres/track.rs: -------------------------------------------------------------------------------- 1 | //! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0 2 | 3 | use super::sea_orm_active_enums::TrackType; 4 | use sea_orm::entity::prelude::*; 5 | 6 | #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] 7 | #[sea_orm(table_name = "track")] 8 | pub struct Model { 9 | #[sea_orm(primary_key)] 10 | pub id: i32, 11 | pub album_db_id: i32, 12 | pub disc_db_id: i32, 13 | pub index: i32, 14 | pub title: String, 15 | pub artist: String, 16 | pub artists: Option, 17 | pub created_at: DateTime, 18 | pub updated_at: DateTime, 19 | pub r#type: TrackType, 20 | } 21 | 22 | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] 23 | pub enum Relation { 24 | #[sea_orm( 25 | belongs_to = "super::album::Entity", 26 | from = "Column::AlbumDbId", 27 | to = "super::album::Column::Id", 28 | on_update = "NoAction", 29 | on_delete = "Cascade" 30 | )] 31 | Album, 32 | #[sea_orm(has_many = "super::album_tag_relation::Entity")] 33 | AlbumTagRelation, 34 | #[sea_orm( 35 | belongs_to = "super::disc::Entity", 36 | from = "Column::DiscDbId", 37 | to = "super::disc::Column::Id", 38 | on_update = "NoAction", 39 | on_delete = "Cascade" 40 | )] 41 | Disc, 42 | } 43 | 44 | impl Related for Entity { 45 | fn to() -> RelationDef { 46 | Relation::Album.def() 47 | } 48 | } 49 | 50 | impl Related for Entity { 51 | fn to() -> RelationDef { 52 | Relation::AlbumTagRelation.def() 53 | } 54 | } 55 | 56 | impl Related for Entity { 57 | fn to() -> RelationDef { 58 | Relation::Disc.def() 59 | } 60 | } 61 | 62 | impl ActiveModelBehavior for ActiveModel {} 63 | -------------------------------------------------------------------------------- /annim/src/entities/sqlite/album.rs: -------------------------------------------------------------------------------- 1 | //! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0 2 | 3 | use sea_orm::entity::prelude::*; 4 | 5 | #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] 6 | #[sea_orm(table_name = "album")] 7 | pub struct Model { 8 | #[sea_orm(primary_key)] 9 | pub id: i32, 10 | #[sea_orm(unique)] 11 | pub album_id: Uuid, 12 | pub title: String, 13 | pub edition: Option, 14 | pub catalog: Option, 15 | pub artist: String, 16 | pub release_year: i32, 17 | pub release_month: Option, 18 | pub release_day: Option, 19 | #[sea_orm(column_type = "custom(\"enum_text\")")] 20 | pub level: String, 21 | pub created_at: DateTimeUtc, 22 | pub updated_at: DateTimeUtc, 23 | pub extra: Option, 24 | } 25 | 26 | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] 27 | pub enum Relation { 28 | #[sea_orm(has_many = "super::album_tag_relation::Entity")] 29 | AlbumTagRelation, 30 | #[sea_orm(has_many = "super::disc::Entity")] 31 | Disc, 32 | #[sea_orm(has_many = "super::track::Entity")] 33 | Track, 34 | } 35 | 36 | impl Related for Entity { 37 | fn to() -> RelationDef { 38 | Relation::AlbumTagRelation.def() 39 | } 40 | } 41 | 42 | impl Related for Entity { 43 | fn to() -> RelationDef { 44 | Relation::Disc.def() 45 | } 46 | } 47 | 48 | impl Related for Entity { 49 | fn to() -> RelationDef { 50 | Relation::Track.def() 51 | } 52 | } 53 | 54 | impl ActiveModelBehavior for ActiveModel {} 55 | -------------------------------------------------------------------------------- /annim/src/entities/sqlite/album_tag_relation.rs: -------------------------------------------------------------------------------- 1 | //! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0 2 | 3 | use sea_orm::entity::prelude::*; 4 | 5 | #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] 6 | #[sea_orm(table_name = "album_tag_relation")] 7 | pub struct Model { 8 | #[sea_orm(primary_key)] 9 | pub id: i32, 10 | pub tag_db_id: i32, 11 | pub album_db_id: i32, 12 | pub disc_db_id: Option, 13 | pub track_db_id: Option, 14 | } 15 | 16 | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] 17 | pub enum Relation { 18 | #[sea_orm( 19 | belongs_to = "super::album::Entity", 20 | from = "Column::AlbumDbId", 21 | to = "super::album::Column::Id", 22 | on_update = "NoAction", 23 | on_delete = "Cascade" 24 | )] 25 | Album, 26 | #[sea_orm( 27 | belongs_to = "super::disc::Entity", 28 | from = "Column::DiscDbId", 29 | to = "super::disc::Column::Id", 30 | on_update = "NoAction", 31 | on_delete = "Cascade" 32 | )] 33 | Disc, 34 | #[sea_orm( 35 | belongs_to = "super::tag_info::Entity", 36 | from = "Column::TagDbId", 37 | to = "super::tag_info::Column::Id", 38 | on_update = "NoAction", 39 | on_delete = "Cascade" 40 | )] 41 | TagInfo, 42 | #[sea_orm( 43 | belongs_to = "super::track::Entity", 44 | from = "Column::TrackDbId", 45 | to = "super::track::Column::Id", 46 | on_update = "NoAction", 47 | on_delete = "Cascade" 48 | )] 49 | Track, 50 | } 51 | 52 | impl Related for Entity { 53 | fn to() -> RelationDef { 54 | Relation::Album.def() 55 | } 56 | } 57 | 58 | impl Related for Entity { 59 | fn to() -> RelationDef { 60 | Relation::Disc.def() 61 | } 62 | } 63 | 64 | impl Related for Entity { 65 | fn to() -> RelationDef { 66 | Relation::TagInfo.def() 67 | } 68 | } 69 | 70 | impl Related for Entity { 71 | fn to() -> RelationDef { 72 | Relation::Track.def() 73 | } 74 | } 75 | 76 | impl ActiveModelBehavior for ActiveModel {} 77 | -------------------------------------------------------------------------------- /annim/src/entities/sqlite/disc.rs: -------------------------------------------------------------------------------- 1 | //! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0 2 | 3 | use sea_orm::entity::prelude::*; 4 | 5 | #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] 6 | #[sea_orm(table_name = "disc")] 7 | pub struct Model { 8 | #[sea_orm(primary_key)] 9 | pub id: i32, 10 | pub album_db_id: i32, 11 | pub index: i32, 12 | pub title: Option, 13 | pub catalog: Option, 14 | pub artist: Option, 15 | pub created_at: DateTimeUtc, 16 | pub updated_at: DateTimeUtc, 17 | } 18 | 19 | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] 20 | pub enum Relation { 21 | #[sea_orm( 22 | belongs_to = "super::album::Entity", 23 | from = "Column::AlbumDbId", 24 | to = "super::album::Column::Id", 25 | on_update = "NoAction", 26 | on_delete = "Cascade" 27 | )] 28 | Album, 29 | #[sea_orm(has_many = "super::album_tag_relation::Entity")] 30 | AlbumTagRelation, 31 | #[sea_orm(has_many = "super::track::Entity")] 32 | Track, 33 | } 34 | 35 | impl Related for Entity { 36 | fn to() -> RelationDef { 37 | Relation::Album.def() 38 | } 39 | } 40 | 41 | impl Related for Entity { 42 | fn to() -> RelationDef { 43 | Relation::AlbumTagRelation.def() 44 | } 45 | } 46 | 47 | impl Related for Entity { 48 | fn to() -> RelationDef { 49 | Relation::Track.def() 50 | } 51 | } 52 | 53 | impl ActiveModelBehavior for ActiveModel {} 54 | -------------------------------------------------------------------------------- /annim/src/entities/sqlite/mod.rs: -------------------------------------------------------------------------------- 1 | //! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0 2 | 3 | pub mod prelude; 4 | 5 | pub mod album; 6 | pub mod album_tag_relation; 7 | pub mod disc; 8 | pub mod tag_info; 9 | pub mod tag_relation; 10 | pub mod track; 11 | -------------------------------------------------------------------------------- /annim/src/entities/sqlite/prelude.rs: -------------------------------------------------------------------------------- 1 | //! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0 2 | 3 | pub use super::album::Entity as Album; 4 | pub use super::album_tag_relation::Entity as AlbumTagRelation; 5 | pub use super::disc::Entity as Disc; 6 | pub use super::tag_info::Entity as TagInfo; 7 | pub use super::tag_relation::Entity as TagRelation; 8 | pub use super::track::Entity as Track; 9 | -------------------------------------------------------------------------------- /annim/src/entities/sqlite/tag_info.rs: -------------------------------------------------------------------------------- 1 | //! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0 2 | 3 | use sea_orm::entity::prelude::*; 4 | 5 | #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] 6 | #[sea_orm(table_name = "tag_info")] 7 | pub struct Model { 8 | #[sea_orm(primary_key)] 9 | pub id: i32, 10 | pub name: String, 11 | #[sea_orm(column_type = "custom(\"enum_text\")")] 12 | pub r#type: String, 13 | pub created_at: DateTimeUtc, 14 | pub updated_at: DateTimeUtc, 15 | } 16 | 17 | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] 18 | pub enum Relation { 19 | #[sea_orm(has_many = "super::album_tag_relation::Entity")] 20 | AlbumTagRelation, 21 | } 22 | 23 | impl Related for Entity { 24 | fn to() -> RelationDef { 25 | Relation::AlbumTagRelation.def() 26 | } 27 | } 28 | 29 | impl ActiveModelBehavior for ActiveModel {} 30 | -------------------------------------------------------------------------------- /annim/src/entities/sqlite/tag_relation.rs: -------------------------------------------------------------------------------- 1 | //! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0 2 | 3 | use sea_orm::entity::prelude::*; 4 | 5 | #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] 6 | #[sea_orm(table_name = "tag_relation")] 7 | pub struct Model { 8 | #[sea_orm(primary_key)] 9 | pub id: i32, 10 | pub tag_db_id: i32, 11 | pub parent_tag_db_id: i32, 12 | } 13 | 14 | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] 15 | pub enum Relation { 16 | #[sea_orm( 17 | belongs_to = "super::tag_info::Entity", 18 | from = "Column::ParentTagDbId", 19 | to = "super::tag_info::Column::Id", 20 | on_update = "NoAction", 21 | on_delete = "Cascade" 22 | )] 23 | TagInfo2, 24 | #[sea_orm( 25 | belongs_to = "super::tag_info::Entity", 26 | from = "Column::TagDbId", 27 | to = "super::tag_info::Column::Id", 28 | on_update = "NoAction", 29 | on_delete = "Cascade" 30 | )] 31 | TagInfo1, 32 | } 33 | 34 | impl ActiveModelBehavior for ActiveModel {} 35 | -------------------------------------------------------------------------------- /annim/src/entities/sqlite/track.rs: -------------------------------------------------------------------------------- 1 | //! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0 2 | 3 | use sea_orm::entity::prelude::*; 4 | 5 | #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] 6 | #[sea_orm(table_name = "track")] 7 | pub struct Model { 8 | #[sea_orm(primary_key)] 9 | pub id: i32, 10 | pub album_db_id: i32, 11 | pub disc_db_id: i32, 12 | pub index: i32, 13 | pub title: String, 14 | pub artist: String, 15 | pub artists: Option, 16 | pub created_at: DateTimeUtc, 17 | pub updated_at: DateTimeUtc, 18 | #[sea_orm(column_type = "custom(\"enum_text\")")] 19 | pub r#type: String, 20 | } 21 | 22 | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] 23 | pub enum Relation { 24 | #[sea_orm( 25 | belongs_to = "super::album::Entity", 26 | from = "Column::AlbumDbId", 27 | to = "super::album::Column::Id", 28 | on_update = "NoAction", 29 | on_delete = "Cascade" 30 | )] 31 | Album, 32 | #[sea_orm(has_many = "super::album_tag_relation::Entity")] 33 | AlbumTagRelation, 34 | #[sea_orm( 35 | belongs_to = "super::disc::Entity", 36 | from = "Column::DiscDbId", 37 | to = "super::disc::Column::Id", 38 | on_update = "NoAction", 39 | on_delete = "Cascade" 40 | )] 41 | Disc, 42 | } 43 | 44 | impl Related for Entity { 45 | fn to() -> RelationDef { 46 | Relation::Album.def() 47 | } 48 | } 49 | 50 | impl Related for Entity { 51 | fn to() -> RelationDef { 52 | Relation::AlbumTagRelation.def() 53 | } 54 | } 55 | 56 | impl Related for Entity { 57 | fn to() -> RelationDef { 58 | Relation::Disc.def() 59 | } 60 | } 61 | 62 | impl ActiveModelBehavior for ActiveModel {} 63 | -------------------------------------------------------------------------------- /annim/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod auth; 2 | pub mod entities; 3 | pub mod graphql; 4 | pub mod migrator; 5 | pub mod search; 6 | -------------------------------------------------------------------------------- /annim/src/migrator/helper.rs: -------------------------------------------------------------------------------- 1 | use sea_orm::sea_query::{ColumnDef, IntoIden}; 2 | use sea_orm_migration::schema::{integer, integer_null}; 3 | 4 | pub fn pk_foreign(name: T) -> ColumnDef { 5 | integer(name).take() 6 | } 7 | 8 | pub fn pk_foreign_null(name: T) -> ColumnDef { 9 | integer_null(name).take() 10 | } 11 | -------------------------------------------------------------------------------- /annim/src/migrator/m20240905_000003_add_tag_type_category.rs: -------------------------------------------------------------------------------- 1 | use extension::postgres::Type; 2 | use sea_orm::DbErr; 3 | use sea_orm_migration::prelude::*; 4 | 5 | pub struct Migration; 6 | 7 | impl MigrationName for Migration { 8 | fn name(&self) -> &str { 9 | "m20240905_000003_add_tag_type_category" 10 | } 11 | } 12 | 13 | #[async_trait::async_trait] 14 | impl MigrationTrait for Migration { 15 | async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { 16 | match manager.get_database_backend() { 17 | sea_orm::DatabaseBackend::Postgres => { 18 | manager 19 | .alter_type( 20 | Type::alter() 21 | .name(Alias::new("tag_type")) 22 | .add_value(Alias::new("category")) 23 | .before(super::m20240824_000002_create_tag_tables::TagType::Others), 24 | ) 25 | .await?; 26 | } 27 | _ => {} 28 | } 29 | 30 | Ok(()) 31 | } 32 | 33 | async fn down(&self, _manager: &SchemaManager) -> Result<(), DbErr> { 34 | // Can not revert the type change 35 | Ok(()) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /annim/src/migrator/m20240905_000004_album_extra_jsonb.rs: -------------------------------------------------------------------------------- 1 | use sea_orm::DbErr; 2 | use sea_orm_migration::{prelude::*, schema::*}; 3 | 4 | use super::m20240817_000001_create_basic_tables::Album; 5 | 6 | pub struct Migration; 7 | 8 | impl MigrationName for Migration { 9 | fn name(&self) -> &str { 10 | "m20240905_000004_album_extra_jsonb" 11 | } 12 | } 13 | 14 | #[async_trait::async_trait] 15 | impl MigrationTrait for Migration { 16 | async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { 17 | manager 18 | .alter_table( 19 | Table::alter() 20 | .table(Album::Table) 21 | .modify_column(json_binary_null(Album::Extra)) 22 | .to_owned(), 23 | ) 24 | .await?; 25 | 26 | Ok(()) 27 | } 28 | 29 | async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { 30 | manager 31 | .alter_table( 32 | Table::alter() 33 | .table(Album::Table) 34 | .modify_column(json_null(Album::Extra)) 35 | .to_owned(), 36 | ) 37 | .await?; 38 | 39 | Ok(()) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /annim/src/migrator/mod.rs: -------------------------------------------------------------------------------- 1 | use sea_orm_migration::prelude::*; 2 | mod helper; 3 | 4 | mod m20240817_000001_create_basic_tables; 5 | mod m20240824_000002_create_tag_tables; 6 | mod m20240905_000003_add_tag_type_category; 7 | mod m20240905_000004_album_extra_jsonb; 8 | 9 | pub struct Migrator; 10 | 11 | #[async_trait::async_trait] 12 | impl MigratorTrait for Migrator { 13 | fn migrations() -> Vec> { 14 | vec![ 15 | Box::new(m20240817_000001_create_basic_tables::Migration), 16 | Box::new(m20240824_000002_create_tag_tables::Migration), 17 | Box::new(m20240905_000003_add_tag_type_category::Migration), 18 | Box::new(m20240905_000004_album_extra_jsonb::Migration), 19 | ] 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /assets/1s-cover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ProjectAnni/anni/9a26bf91d85b96aa11b8a20acccf2ffb5bc3deb5/assets/1s-cover.png -------------------------------------------------------------------------------- /assets/1s-full.flac: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ProjectAnni/anni/9a26bf91d85b96aa11b8a20acccf2ffb5bc3deb5/assets/1s-full.flac -------------------------------------------------------------------------------- /assets/1s.flac: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ProjectAnni/anni/9a26bf91d85b96aa11b8a20acccf2ffb5bc3deb5/assets/1s.flac -------------------------------------------------------------------------------- /config/convention.toml: -------------------------------------------------------------------------------- 1 | # Stream Info 2 | [stream-info] 3 | sample-rate = 44100 4 | channels = 2 5 | bit-per-sample = 16 6 | 7 | # Global type validators 8 | [types] 9 | string = ["trim", "dot", "tidle"] 10 | number = ["number"] 11 | 12 | # Required Tags 13 | [[tags.required]] 14 | name = "TITLE" 15 | type = "string" 16 | 17 | [[tags.required]] 18 | name = "ARTIST" 19 | type = "string" 20 | validators = ["artist"] 21 | 22 | [[tags.required]] 23 | name = "ALBUM" 24 | type = "string" 25 | 26 | [[tags.required]] 27 | name = "DATE" 28 | type = "string" 29 | validators = ["date"] 30 | 31 | [[tags.required]] 32 | name = "TRACKNUMBER" 33 | type = "number" 34 | 35 | [[tags.required]] 36 | name = "TRACKTOTAL" 37 | alias = ["TOTALTRACKS"] 38 | type = "number" 39 | 40 | # Optional Tags 41 | [[tags.optional]] 42 | name = "DISCNUMBER" 43 | type = "number" 44 | 45 | [[tags.optional]] 46 | name = "DISCTOTAL" 47 | alias = ["TOTALDISCS"] 48 | type = "number" 49 | 50 | [[tags.optional]] 51 | name = "ALBUMARTIST" 52 | type = "string" 53 | -------------------------------------------------------------------------------- /docker/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !Dockerfile 3 | !Dockerfile.* 4 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine 2 | VOLUME /app/data 3 | 4 | RUN apk add --no-cache ffmpeg 5 | RUN apk add --no-cache opus-tools 6 | COPY annil /app/ 7 | COPY anni /app/ 8 | 9 | WORKDIR /app/data 10 | ENTRYPOINT ["/app/annil"] -------------------------------------------------------------------------------- /docker/Dockerfile.annim: -------------------------------------------------------------------------------- 1 | FROM alpine 2 | 3 | COPY annim /app/ 4 | 5 | VOLUME /app/data 6 | ENTRYPOINT ["/app/annim"] 7 | EXPOSE 8000/tcp 8 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "nixpkgs": { 4 | "locked": { 5 | "lastModified": 1742422364, 6 | "narHash": "sha256-mNqIplmEohk5jRkqYqG19GA8MbQ/D4gQSK0Mu4LvfRQ=", 7 | "owner": "nixos", 8 | "repo": "nixpkgs", 9 | "rev": "a84ebe20c6bc2ecbcfb000a50776219f48d134cc", 10 | "type": "github" 11 | }, 12 | "original": { 13 | "owner": "nixos", 14 | "ref": "nixos-unstable", 15 | "repo": "nixpkgs", 16 | "type": "github" 17 | } 18 | }, 19 | "root": { 20 | "inputs": { 21 | "nixpkgs": "nixpkgs", 22 | "rust-overlay": "rust-overlay" 23 | } 24 | }, 25 | "rust-overlay": { 26 | "inputs": { 27 | "nixpkgs": [ 28 | "nixpkgs" 29 | ] 30 | }, 31 | "locked": { 32 | "lastModified": 1742524367, 33 | "narHash": "sha256-KzTwk/5ETJavJZYV1DEWdCx05M4duFCxCpRbQSKWpng=", 34 | "owner": "oxalica", 35 | "repo": "rust-overlay", 36 | "rev": "70bf752d176b2ce07417e346d85486acea9040ef", 37 | "type": "github" 38 | }, 39 | "original": { 40 | "owner": "oxalica", 41 | "repo": "rust-overlay", 42 | "type": "github" 43 | } 44 | } 45 | }, 46 | "root": "root", 47 | "version": 7 48 | } 49 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "A Nix-flake-based Rust development environment"; 3 | 4 | inputs = { 5 | nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; 6 | rust-overlay = { 7 | url = "github:oxalica/rust-overlay"; 8 | inputs.nixpkgs.follows = "nixpkgs"; 9 | }; 10 | }; 11 | 12 | outputs = 13 | { 14 | self, 15 | nixpkgs, 16 | rust-overlay, 17 | }: 18 | let 19 | supportedSystems = [ 20 | "x86_64-linux" 21 | "aarch64-linux" 22 | "x86_64-darwin" 23 | "aarch64-darwin" 24 | ]; 25 | forEachSupportedSystem = 26 | f: 27 | nixpkgs.lib.genAttrs supportedSystems ( 28 | system: 29 | f { 30 | pkgs = import nixpkgs { 31 | inherit system; 32 | overlays = [ 33 | rust-overlay.overlays.default 34 | self.overlays.default 35 | ]; 36 | }; 37 | } 38 | ); 39 | in 40 | { 41 | overlays.default = final: prev: { 42 | rustToolchain = 43 | let 44 | rust = prev.rust-bin; 45 | in 46 | if builtins.pathExists ./rust-toolchain.toml then 47 | rust.fromRustupToolchainFile ./rust-toolchain.toml 48 | else if builtins.pathExists ./rust-toolchain then 49 | rust.fromRustupToolchainFile ./rust-toolchain 50 | else 51 | rust.stable.latest.default.override { 52 | extensions = [ 53 | "rust-src" 54 | "rustfmt" 55 | ]; 56 | }; 57 | }; 58 | 59 | devShells = forEachSupportedSystem ( 60 | { pkgs }: 61 | { 62 | default = pkgs.mkShell { 63 | packages = with pkgs; [ 64 | rustToolchain 65 | openssl 66 | pkg-config 67 | cargo-deny 68 | cargo-edit 69 | cargo-watch 70 | rust-analyzer 71 | cmake 72 | alsa-lib 73 | alsa-lib.dev 74 | ]; 75 | 76 | env = { 77 | # Required by rust-analyzer 78 | RUST_SRC_PATH = "${pkgs.rustToolchain}/lib/rustlib/src/rust/library"; 79 | }; 80 | }; 81 | } 82 | ); 83 | }; 84 | } 85 | -------------------------------------------------------------------------------- /rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | [toolchain] 2 | channel = "nightly" -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | edition = "2021" 2 | -------------------------------------------------------------------------------- /third_party/google-drive3/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "anni-google-drive3" 3 | version = "0.1.0+4.0.0-20220225" 4 | authors = ["Sebastian Thiel "] 5 | description = "Patched library to interact with drive (protocol v3) with range support" 6 | repository = "https://github.com/ProjectAnni/anni/tree/master/third_party/google-drive3" 7 | homepage = "https://developers.google.com/drive/" 8 | license = "MIT" 9 | keywords = ["drive", "google", "protocol", "web", "api"] 10 | autobins = false 11 | edition.workspace = true 12 | 13 | 14 | [dependencies] 15 | hyper-rustls = "0.23" 16 | mime = "0.3.16" 17 | serde.workspace = true 18 | serde_json.workspace = true 19 | serde_derive = "1.0" 20 | yup-oauth2 = "^ 7.0" 21 | itertools = "0.10" 22 | hyper = { version = "0.14", features = ["stream"] } 23 | http = "0.2" 24 | tokio = "1.24" 25 | tower-service = "0.3.1" 26 | url = "2.3.1" 27 | 28 | [lib] 29 | doctest = false 30 | -------------------------------------------------------------------------------- /third_party/google-drive3/LICENSE.md: -------------------------------------------------------------------------------- 1 | 6 | The MIT License (MIT) 7 | ===================== 8 | 9 | Copyright © `2015-2020` `Sebastian Thiel` 10 | 11 | Permission is hereby granted, free of charge, to any person 12 | obtaining a copy of this software and associated documentation 13 | files (the “Software”), to deal in the Software without 14 | restriction, including without limitation the rights to use, 15 | copy, modify, merge, publish, distribute, sublicense, and/or sell 16 | copies of the Software, and to permit persons to whom the 17 | Software is furnished to do so, subject to the following 18 | conditions: 19 | 20 | The above copyright notice and this permission notice shall be 21 | included in all copies or substantial portions of the Software. 22 | 23 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, 24 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 25 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 26 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 27 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 28 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 29 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 30 | OTHER DEALINGS IN THE SOFTWARE. 31 | --------------------------------------------------------------------------------