├── .cargo └── config.toml ├── .github ├── FUNDING.yml └── workflows │ └── build.yml ├── .gitignore ├── CHANGELOG.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── assets ├── 128x128.png ├── 128x128@2x.png ├── 32x32.png ├── 64x64.bin ├── headerimage.bmp ├── icon.icns ├── icon.ico ├── installer-icon.ico ├── installer.nsi ├── mac-cross.toml ├── onetagger-logo-github.png ├── spotify_callback.html └── welcomebanner.bmp ├── client ├── .gitignore ├── index.html ├── package.json ├── src │ ├── App.vue │ ├── assets │ │ ├── apps │ │ │ ├── crossdj.png │ │ │ ├── dex.png │ │ │ ├── djaypro.png │ │ │ ├── djuced.png │ │ │ ├── engineprime.png │ │ │ ├── mixxx.png │ │ │ ├── rekordbox.png │ │ │ ├── serato.png │ │ │ ├── traktor.png │ │ │ └── virtualdj.png │ │ ├── bg.jpg │ │ ├── favicon.png │ │ ├── icon.png │ │ ├── logo-full.png │ │ ├── logo.svg │ │ ├── placeholder.png │ │ └── shazam_icon.svg │ ├── components │ │ ├── AddAlbumArt.vue │ │ ├── AdvancedSettingsToggle.vue │ │ ├── AutotaggerAdvanced.vue │ │ ├── AutotaggerPlatformSpecific.vue │ │ ├── AutotaggerPlatforms.vue │ │ ├── AutotaggerProfile.vue │ │ ├── AutotaggerTags.vue │ │ ├── CliDialog.vue │ │ ├── DJAppIcons.vue │ │ ├── DevTools.vue │ │ ├── ExitDialog.vue │ │ ├── FolderBrowser.vue │ │ ├── HelpButton.vue │ │ ├── HelpRenamerExamples.vue │ │ ├── Keybind.vue │ │ ├── LogoText.vue │ │ ├── ManualTag.vue │ │ ├── PlatformsRepo.vue │ │ ├── PlayerBar.vue │ │ ├── PlaylistDropZone.vue │ │ ├── QuickTagContextMenu.vue │ │ ├── QuickTagFileBrowser.vue │ │ ├── QuickTagGenreBar.vue │ │ ├── QuickTagMoods.vue │ │ ├── QuickTagRight.vue │ │ ├── QuickTagTile.vue │ │ ├── QuickTagTileThin.vue │ │ ├── RenamerTokenName.vue │ │ ├── Separators.vue │ │ ├── Settings.vue │ │ ├── SpotifyLogin.vue │ │ ├── TagEditorAlbumArt.vue │ │ ├── TagField.vue │ │ ├── TagFields.vue │ │ └── Waveform.vue │ ├── main.ts │ ├── scripts │ │ ├── autotagger.ts │ │ ├── manualtag.ts │ │ ├── onetagger.ts │ │ ├── player.ts │ │ ├── quicktag.ts │ │ ├── router.ts │ │ ├── settings.ts │ │ ├── tags.ts │ │ └── utils.ts │ ├── style │ │ ├── app.scss │ │ ├── fonts │ │ │ ├── Dosis-Bold.ttf │ │ │ ├── Dosis-ExtraBold.ttf │ │ │ ├── Dosis-Light.ttf │ │ │ ├── Dosis-Medium.ttf │ │ │ ├── Dosis-Regular.ttf │ │ │ ├── Dosis-SemiBold.ttf │ │ │ ├── OFL.txt │ │ │ └── wavefont.woff2 │ │ └── quasar.scss │ ├── views │ │ ├── AudioFeatures.vue │ │ ├── Autotagger.vue │ │ ├── AutotaggerStatus.vue │ │ ├── Index.vue │ │ ├── QuickTag.vue │ │ ├── Renamer.vue │ │ └── TagEditor.vue │ └── vite-env.d.ts ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts └── crates ├── onetagger-autotag ├── Cargo.toml └── src │ ├── audiofeatures.rs │ ├── lib.rs │ ├── platforms.rs │ ├── repo.rs │ └── shazam.rs ├── onetagger-cli ├── Cargo.toml ├── build.rs └── src │ └── main.rs ├── onetagger-platforms ├── Cargo.toml ├── assets │ ├── bandcamp.png │ ├── beatport.png │ ├── beatsource.png │ ├── bpmsupreme.png │ ├── deezer.png │ ├── discogs.png │ ├── itunes.png │ ├── junodownload.png │ ├── musicbrainz.png │ ├── musixmatch.png │ ├── spotify.png │ └── traxsource.png └── src │ ├── bandcamp.rs │ ├── bandcamp_genres.rs │ ├── beatport.rs │ ├── beatsource.rs │ ├── bpmsupreme.rs │ ├── deezer.rs │ ├── discogs.rs │ ├── itunes.rs │ ├── junodownload.rs │ ├── lib.rs │ ├── musicbrainz.rs │ ├── musixmatch.rs │ ├── spotify.rs │ └── traxsource.rs ├── onetagger-player ├── Cargo.toml └── src │ ├── aiff.rs │ ├── alac.rs │ ├── flac.rs │ ├── lib.rs │ ├── mp3.rs │ ├── mp4.rs │ ├── ogg.rs │ └── wav.rs ├── onetagger-playlist ├── Cargo.toml └── src │ └── lib.rs ├── onetagger-renamer ├── Cargo.toml └── src │ ├── ac.rs │ ├── docs.rs │ ├── lib.rs │ └── parser.rs ├── onetagger-shared ├── Cargo.toml ├── build.rs └── src │ └── lib.rs ├── onetagger-tag ├── Cargo.toml └── src │ ├── flac.rs │ ├── id3.rs │ ├── lib.rs │ ├── mp4.rs │ ├── vorbis.rs │ └── wav.rs ├── onetagger-tagger ├── Cargo.toml └── src │ ├── custom.rs │ └── lib.rs ├── onetagger-ui ├── Cargo.toml └── src │ ├── browser.rs │ ├── lib.rs │ ├── quicktag.rs │ ├── socket.rs │ └── tageditor.rs └── onetagger ├── Cargo.toml ├── build.rs └── src └── main.rs /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | # Mac OS libraries 2 | [target.x86_64-apple-darwin] 3 | rustflags = ["-lz", "-lbz2", "-llzma", "-C", "link-args=-framework AudioUnit"] 4 | 5 | # Windows static build 6 | [target.stable-x86_64-pc-windows-msvc] 7 | rustflags = ["-Ctarget-feature=+crt-static"] 8 | 9 | # Use ldd for faster compile on Linux 10 | [target.x86_64-unknown-linux-gnu] 11 | rustflags = ["-C", "link-arg=-fuse-ld=lld"] 12 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: marekkon5 2 | patreon: onetagger 3 | custom: ["https://paypal.me/marekkon5"] 4 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | build-linux: 6 | name: Linux 7 | runs-on: ubuntu-22.04 8 | 9 | steps: 10 | 11 | - name: Checkout 12 | uses: actions/checkout@v3 13 | 14 | - name: Rust Cache 15 | uses: actions/cache@v3 16 | with: 17 | path: | 18 | ~/.cargo/bin/ 19 | ~/.cargo/registry/index/ 20 | ~/.cargo/registry/cache/ 21 | ~/.cargo/git/db/ 22 | target/ 23 | key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} 24 | 25 | - name: Install dependencies 26 | run: | 27 | sudo apt update 28 | sudo apt install -y lld autogen libasound2-dev pkg-config make libssl-dev gcc g++ curl wget git libwebkit2gtk-4.1-dev 29 | 30 | - name: Install NodeJS 31 | uses: actions/setup-node@v3 32 | with: 33 | node-version: 18 34 | 35 | - name: Install pnpm 36 | uses: pnpm/action-setup@v2 37 | with: 38 | version: 8 39 | 40 | - name: Build 41 | run: | 42 | cd client 43 | pnpm i 44 | pnpm run build 45 | cd .. 46 | cargo update 47 | cargo build --release 48 | 49 | - name: Bundle 50 | run: | 51 | tar zcf OneTagger-linux.tar.gz -C target/release onetagger 52 | tar zcf OneTagger-linux-cli.tar.gz -C target/release onetagger-cli 53 | mkdir dist 54 | mv OneTagger-linux.tar.gz dist/ 55 | mv OneTagger-linux-cli.tar.gz dist/ 56 | 57 | - name: Upload Linux 58 | uses: actions/upload-artifact@v4 59 | with: 60 | name: onetagger-linux 61 | path: dist/OneTagger-linux.tar.gz 62 | 63 | - name: Upload Linux CLI 64 | uses: actions/upload-artifact@v4 65 | with: 66 | name: onetagger-linux-cli 67 | path: dist/OneTagger-linux-cli.tar.gz 68 | 69 | 70 | build-win: 71 | name: Windows 72 | runs-on: windows-2019 73 | 74 | steps: 75 | - name: Checkout 76 | uses: actions/checkout@v3 77 | 78 | - name: Rust Cache 79 | uses: actions/cache@v3 80 | with: 81 | path: | 82 | ~/.cargo/bin/ 83 | ~/.cargo/registry/index/ 84 | ~/.cargo/registry/cache/ 85 | ~/.cargo/git/db/ 86 | target/ 87 | key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} 88 | 89 | - name: Install NodeJS 90 | uses: actions/setup-node@v3 91 | with: 92 | node-version: 18 93 | 94 | - name: Install pnpm 95 | uses: pnpm/action-setup@v2 96 | with: 97 | version: 8 98 | 99 | - name: Install Dependencies 100 | run: | 101 | choco install nsis -y 102 | rustup update 103 | 104 | - name: Build 105 | run: | 106 | cd client 107 | pnpm i 108 | pnpm run build 109 | cd .. 110 | cargo update 111 | cargo build --release 112 | 113 | - name: Bundle 114 | run: | 115 | mkdir dist 116 | powershell -command "(new-object System.Net.WebClient).DownloadFile('https://aka.ms/vs/16/release/vc_redist.x64.exe','vc_redist.x64.exe')" 117 | powershell -command "(new-object System.Net.WebClient).DownloadFile('https://go.microsoft.com/fwlink/p/?LinkId=2124703','MicrosoftEdgeWebview2Setup.exe')" 118 | &'C:\Program Files (x86)\NSIS\makensis.exe' 'assets\installer.nsi' 119 | copy target\release\onetagger.exe dist\OneTagger-windows.exe 120 | copy target\release\onetagger-cli.exe dist\OneTagger-windows-cli.exe 121 | 122 | - name: Upload Archive 123 | uses: actions/upload-artifact@v4 124 | with: 125 | name: onetagger-win 126 | path: | 127 | dist/OneTagger-windows.exe 128 | 129 | - name: Upload Archive 130 | uses: actions/upload-artifact@v4 131 | with: 132 | name: onetagger-win-setup 133 | path: dist/OneTagger-windows-setup.exe 134 | 135 | - name: Upload CLI 136 | uses: actions/upload-artifact@v4 137 | with: 138 | name: onetagger-win-cli 139 | path: | 140 | dist/OneTagger-windows-cli.exe 141 | 142 | build-mac: 143 | name: Mac 144 | runs-on: macos-13 145 | 146 | steps: 147 | 148 | - name: Checkout 149 | uses: actions/checkout@v3 150 | 151 | - name: Rust Cache 152 | uses: actions/cache@v3 153 | with: 154 | path: | 155 | ~/.cargo/bin/ 156 | ~/.cargo/registry/index/ 157 | ~/.cargo/registry/cache/ 158 | ~/.cargo/git/db/ 159 | target/ 160 | key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} 161 | 162 | - name: Install dependencies 163 | run: | 164 | brew install rustup nodejs pnpm 165 | rustup install stable 166 | cargo install cargo-bundle || true 167 | 168 | - name: Build 169 | run: | 170 | cd client 171 | pnpm i 172 | pnpm run build 173 | cd .. 174 | cargo update 175 | cargo build --release 176 | cargo bundle --release 177 | 178 | - name: Bundle 179 | run: | 180 | mkdir dist 181 | 182 | cd target/release/bundle/osx 183 | chmod +x OneTagger.app/Contents/MacOS/onetagger 184 | zip -r OneTagger-mac.zip . 185 | cd - 186 | 187 | cd target/release 188 | chmod +x onetagger-cli 189 | zip OneTagger-mac-cli.zip onetagger-cli 190 | cd - 191 | 192 | mv target/release/bundle/osx/OneTagger-mac.zip dist/ 193 | mv target/release/OneTagger-mac-cli.zip dist/ 194 | 195 | - name: Upload Mac 196 | uses: actions/upload-artifact@v4 197 | with: 198 | name: onetagger-mac 199 | path: dist/OneTagger-mac.zip 200 | 201 | - name: Upload Mac CLI 202 | uses: actions/upload-artifact@v4 203 | with: 204 | name: onetagger-mac-cli 205 | path: dist/OneTagger-mac-cli.zip 206 | 207 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | sample/ 3 | GPUCache/ 4 | *.log 5 | client/node_modules/ 6 | client/dist/ 7 | .idea/ 8 | .spotify* 9 | dist/ 10 | osxcross/ 11 | .vscode/ 12 | test/ 13 | vc_redist.x64.exe 14 | assets/vc_redist.x64.exe 15 | *.dll 16 | 17 | perf.data 18 | perf.data.old 19 | flamegraph.svg -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 1.7.0 2 | **(03.08.2023)** 3 | 4 | ### Auto Tag: 5 | - Updated Beatport to support the new site 6 | - Improved match rates 7 | - Selectable overwrite 8 | - Internal refactor to how tags work 9 | - Explicit tag 10 | - BPMSupreme > Latino 11 | 12 | ### Quick Tag: 13 | - **Multiple files mode** 14 | - Thin view mode 15 | - External player support 16 | 17 | ### Renamer: 18 | - Fixed input updating 19 | - BPM in MP4 20 | 21 | ### Other: 22 | - Various bug fixes and improvements in all sections 23 | 24 | 25 | # 1.6.0 26 | **(14.04.2023)** 27 | 28 | ### OneTagger: 29 | - Support for WAV (ID3) and OGG files 30 | 31 | ### Auto Tag: 32 | - **Android version** 33 | - Added Bandcamp 34 | - Added Musixmatch 35 | - Added Deezer 36 | - Lyrics support 37 | - Logging in custom platforms 38 | - Stop button 39 | - Better platform info (supported tags, whether auth is required) 40 | - Profiles (multiple configurations) 41 | 42 | ### Other 43 | - Many bug fixes in all sections 44 | - Dropped dependency on libsndfile, so compilling 1T should be less painful and more portable 45 | 46 | 47 | # 1.5.1 48 | **(31.10.2022)** 49 | 50 | ### OneTagger: 51 | - Older MacOS warning and server version restart 52 | 53 | ### Auto Tag: 54 | - HOTFIX: Make sure the Move files feature is more safe 55 | 56 | 57 | # 1.5.0 58 | **(14.10.2022)** 59 | 60 | ### OneTagger: 61 | - **Rewrite entire frontend to Vue3 + Typescript** 62 | 63 | ### Auto Tag: 64 | - Only write year 65 | - Track/disc number/total tags 66 | - BPMSupreme, iTunes improvements 67 | - Tag each track using multiple platforms 68 | - Merging styles and genres fixes 69 | - Regex title cleanup 70 | - Move failed/successful files 71 | 72 | ### Audio Features: 73 | - Spofiy rate limits 74 | - Bug fixes 75 | 76 | ### Quick Tag: 77 | - List of failed files with reasons 78 | 79 | 80 | # 1.4.0 81 | **(20.04.2022)** 82 | 83 | ### OneTagger: 84 | - Added Auto Rename tab (rename your files by to your tags) 85 | - CLI version 86 | 87 | ### Auto Tag: 88 | - Internal platform system redesign 89 | - Custom platforms support (https://github.com/Marekkon5/onetagger-platform-template/) 90 | - Update Spotify implementation 91 | - Improved match rates, less skipped files 92 | - Various minor platform fixes 93 | - Added reason of failure into status list 94 | 95 | ### Quick Tag: 96 | - Redesign 97 | - Sorting 98 | - Multiple genres 99 | - File browser 100 | - Subgenres 101 | 102 | 103 | ### Other: 104 | - Windows install to registry 105 | - New logging system 106 | - Split to bunch of different crates 107 | - Minor improvements and bug fixes 108 | 109 | 110 | # 1.3.0: 111 | **(18.11.2021)** 112 | 113 | ### Auto Tag: 114 | - Match by exact ID for Discogs, Beatport 115 | - Filename template fixes 116 | - Duration tag 117 | - `VINYLTRACK` Tag for Discogs 118 | - Discogs now faster for smaller batches 119 | - Album artist tag 120 | - iTunes, Musicbrainz, Beatsource and Spotify support 121 | - Beatport subgenres, more tags 122 | - Meta tag 123 | - Remixer tag 124 | - Track number tag 125 | - ISRC tag 126 | - Shazam to find songs without tag and filename parsing 127 | - Filter in status page 128 | 129 | ### Audio Features: 130 | - Added popularity tag 131 | - Renamed danceability value to `dance-high, dance-med, dance-low` 132 | 133 | ### Quick Tag: 134 | - Internal rewrite, cleaner code, more stable 135 | - Search and filter 136 | 137 | ### Tag Editor: 138 | - CTRL + S 139 | - Filtering 140 | - Refactored some code 141 | 142 | ### Other: 143 | - General UI improvements 144 | - Windows: Replace CEF with webview2 - smaller install sizes, more portable. 145 | - `--expose` command line option to make the server listen on 0.0.0.0 146 | - Updated dependencies 147 | - Bug fixes 148 | - .mp4 Extension support 149 | 150 | 151 | # 1.2.1: 152 | **(03.07.2021)** 153 | 154 | ### Auto Tagger 155 | - Fixed bug in 1.2.0 causing Beatport and Traxsource having low match rate 156 | 157 | ### Other 158 | - Added more info to logs for debugging 159 | - Fixed path pickers not opening with bad path 160 | 161 | 162 | # 1.2.0 163 | **(02.07.2021)** 164 | 165 | ### Shared: 166 | - Added M3U playlist support with drag and drop 167 | 168 | ### Auto Tag: 169 | - Added catalog number, track ID, release ID, version, URL tags 170 | - Added duration matching (WARNING: strict, should be used only in specific situations) 171 | - Tag files without metadata (using filename with custom templates) 172 | - Single page design changes 173 | - Improved matching rates, bug fixes 174 | 175 | ### Quick Tag: 176 | - Option to load recursively 177 | 178 | ### Tag Editor: 179 | - Minor design changes 180 | 181 | ### Other 182 | - Benchmark mode (for testing / debugging purproses, can be ran with `--benchmark` command line argument) 183 | - If you specify path as command line argument, it will be automatically prefilled. 184 | 185 | 186 | 187 | # 1.1.0 188 | **(31.05.2021)** 189 | 190 | ### Shared: 191 | - Added MP4/M4A support 192 | 193 | ### Auto Tag: 194 | - Redesign 195 | - Better status page 196 | - Single page setup (enable in settings) 197 | - Camelot key notation 198 | - Juno Download is single thread now 199 | - Bug fixes and improvements related to matching 200 | 201 | ### Audio Features: 202 | - Fix searching in some edge cases 203 | - Cache Spotify token 204 | 205 | ### Quick Tag: 206 | - Player UI improvements 207 | - Autosave, Autoplay (can be enabled in settings) 208 | - Bug fixes 209 | 210 | ## **WARNING: Due to many breaking changes, SETTINGS WILL BE RESET TO DEFAULT. This will hopefully not happen in future again. Sorry for the inconvenience.** 211 | 212 | 213 | 214 | # 1.0.0 215 | **(13.05.2021)** 216 | 217 | First public release 218 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | resolver = "2" 3 | members = [ 4 | "crates/onetagger", 5 | "crates/onetagger-ui", 6 | "crates/onetagger-cli", 7 | "crates/onetagger-tag", 8 | "crates/onetagger-shared", 9 | "crates/onetagger-player", 10 | "crates/onetagger-tagger", 11 | "crates/onetagger-renamer", 12 | "crates/onetagger-autotag", 13 | "crates/onetagger-playlist", 14 | "crates/onetagger-platforms" 15 | ] 16 | 17 | # Workaround for MacOS 18 | [workspace.dependencies] 19 | lzma-sys = { version = "*", features = ["static"] } 20 | 21 | [profile.release] 22 | opt-level = 3 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Logo 3 |

4 |

The ultimate cross-platform tagger for DJs

5 | 6 |

7 | Website | Latest Release 8 |

9 |
10 | 11 |

12 | Version Badge 13 | Supported OS 14 | Build Status 15 |

16 | 17 |

18 |
19 | 20 | Cross-platform music tagger. 21 | It can fetch metadata from Beatport, Traxsource, Juno Download, Discogs, Musicbrainz and Spotify. 22 | It is also able to fetch Spotify's Audio Features based on ISRC & exact match. 23 | There is a manual tag editor and quick tag editor which lets you use keyboard shortcuts. Written in Rust, Vue.js and Quasar. 24 | 25 | MP3, AIFF, FLAC, M4A (AAC, ALAC) supported. 26 | 27 | *For more info and tutorials check out our [website](https://onetagger.github.io/).* 28 | 29 | https://user-images.githubusercontent.com/15169286/193469224-cbf3af71-f6d7-4ecd-bdbf-5a1dca2d99c8.mp4 30 | 31 | 32 | ## Installing 33 | 34 | You can download latest binaries from [releases](https://github.com/Marekkon5/onetagger/releases) 35 | 36 | 37 | ## Credits 38 | Bas Curtiz - UI, Idea, Help 39 | SongRec (Shazam support) - https://github.com/marin-m/SongRec 40 | 41 | ## Support 42 | You can support this project by donating on [PayPal](https://paypal.me/marekkon5) or [Patreon](https://www.patreon.com/onetagger) 43 | 44 | ## Compilling 45 | 46 | ### Linux & Mac 47 | Install dependencies: [rustup](https://rustup.rs), [node](https://nodejs.org/en/download/package-manager/), [pnpm](https://pnpm.io/installation) 48 | 49 | **Install remaining dependencies** 50 | ``` 51 | sudo apt install -y lld autogen libasound2-dev pkg-config make libssl-dev gcc g++ curl wget git libwebkit2gtk-4.1-dev 52 | ``` 53 | 54 | **Compile UI** 55 | ``` 56 | cd client 57 | pnpm i 58 | pnpm run build 59 | cd .. 60 | ``` 61 | 62 | **Compile** 63 | ``` 64 | cargo build --release 65 | ``` 66 | Output will be in: `target/release/onetagger` 67 | 68 | 69 | ### Windows 70 | You need to install dependencies: [rustup](https://rustup.rs), [nodejs](https://nodejs.org/en/download/), [Visual Studio 2019 Build Tools](https://aka.ms/vs/16/release/vs_buildtools.exe), [pnpm](https://pnpm.io/installation) 71 | 72 | **Compile UI:** 73 | ``` 74 | cd client 75 | pnpm i 76 | pnpm run build 77 | cd .. 78 | ``` 79 | 80 | **Compile OneTagger:** 81 | ``` 82 | cargo build --release 83 | ``` 84 | 85 | Output will be inside `target\release` folder. 86 | 87 | -------------------------------------------------------------------------------- /assets/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Marekkon5/onetagger/f3e1409c6c8ebfbaa80759915a2f56a760e3f407/assets/128x128.png -------------------------------------------------------------------------------- /assets/128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Marekkon5/onetagger/f3e1409c6c8ebfbaa80759915a2f56a760e3f407/assets/128x128@2x.png -------------------------------------------------------------------------------- /assets/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Marekkon5/onetagger/f3e1409c6c8ebfbaa80759915a2f56a760e3f407/assets/32x32.png -------------------------------------------------------------------------------- /assets/64x64.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Marekkon5/onetagger/f3e1409c6c8ebfbaa80759915a2f56a760e3f407/assets/64x64.bin -------------------------------------------------------------------------------- /assets/headerimage.bmp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Marekkon5/onetagger/f3e1409c6c8ebfbaa80759915a2f56a760e3f407/assets/headerimage.bmp -------------------------------------------------------------------------------- /assets/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Marekkon5/onetagger/f3e1409c6c8ebfbaa80759915a2f56a760e3f407/assets/icon.icns -------------------------------------------------------------------------------- /assets/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Marekkon5/onetagger/f3e1409c6c8ebfbaa80759915a2f56a760e3f407/assets/icon.ico -------------------------------------------------------------------------------- /assets/installer-icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Marekkon5/onetagger/f3e1409c6c8ebfbaa80759915a2f56a760e3f407/assets/installer-icon.ico -------------------------------------------------------------------------------- /assets/installer.nsi: -------------------------------------------------------------------------------- 1 | ; Source: https://gist.github.com/drewchapin/246de6d0c404a79ee66a5ead35b480bc 2 | 3 | ;------------------------------------------------------------------------------- 4 | ; Includes 5 | !include "MUI2.nsh" 6 | !include "LogicLib.nsh" 7 | !include "WinVer.nsh" 8 | !include "x64.nsh" 9 | 10 | ;------------------------------------------------------------------------------- 11 | ; Constants 12 | !define PRODUCT_NAME "One Tagger" 13 | !define PRODUCT_DESCRIPTION "App to tag your music library." 14 | !define COPYRIGHT "Marekkon5" 15 | !define PRODUCT_VERSION "1.0.0.0" 16 | !define SETUP_VERSION 1.0.0.0 17 | 18 | ;------------------------------------------------------------------------------- 19 | ; Attributes 20 | Name "One Tagger" 21 | OutFile "..\dist\OneTagger-windows-setup.exe" 22 | InstallDir "$PROGRAMFILES\OneTagger" 23 | RequestExecutionLevel admin ; user|highest|admin 24 | SetCompressor /SOLID lzma 25 | 26 | ;------------------------------------------------------------------------------- 27 | ; Version Info 28 | VIProductVersion "${PRODUCT_VERSION}" 29 | VIAddVersionKey "ProductName" "${PRODUCT_NAME}" 30 | VIAddVersionKey "ProductVersion" "${PRODUCT_VERSION}" 31 | VIAddVersionKey "FileDescription" "${PRODUCT_DESCRIPTION}" 32 | VIAddVersionKey "LegalCopyright" "${COPYRIGHT}" 33 | VIAddVersionKey "FileVersion" "${SETUP_VERSION}" 34 | 35 | ;------------------------------------------------------------------------------- 36 | ; Modern UI Appearance 37 | !define MUI_ICON "..\assets\installer-icon.ico" 38 | !define MUI_HEADERIMAGE 39 | !define MUI_HEADERIMAGE_BITMAP "..\assets\headerimage.bmp" 40 | !define MUI_WELCOMEFINISHPAGE_BITMAP "..\assets\welcomebanner.bmp" 41 | !define MUI_FINISHPAGE_NOAUTOCLOSE 42 | 43 | ; Modern UI Desktop Shortcut 44 | !define MUI_FINISHPAGE_SHOWREADME "" 45 | !define MUI_FINISHPAGE_SHOWREADME_NOTCHECKED 46 | !define MUI_FINISHPAGE_SHOWREADME_TEXT "Create Desktop Shortcut" 47 | !define MUI_FINISHPAGE_SHOWREADME_FUNCTION desktopshortcut 48 | 49 | ;------------------------------------------------------------------------------- 50 | ; Installer Pages 51 | !insertmacro MUI_PAGE_WELCOME 52 | ;!insertmacro MUI_PAGE_LICENSE "${NSISDIR}\Docs\Modern UI\License.txt" 53 | ;!insertmacro MUI_PAGE_COMPONENTS 54 | !insertmacro MUI_PAGE_DIRECTORY 55 | !insertmacro MUI_PAGE_INSTFILES 56 | !insertmacro MUI_PAGE_FINISH 57 | 58 | ;------------------------------------------------------------------------------- 59 | ; Uninstaller Pages 60 | !insertmacro MUI_UNPAGE_WELCOME 61 | !insertmacro MUI_UNPAGE_CONFIRM 62 | !insertmacro MUI_UNPAGE_INSTFILES 63 | !insertmacro MUI_UNPAGE_FINISH 64 | 65 | ;------------------------------------------------------------------------------- 66 | ; Languages 67 | !insertmacro MUI_LANGUAGE "English" 68 | 69 | ;------------------------------------------------------------------------------- 70 | ; Installer Sections 71 | Section "One Tagger" OneTagger 72 | ; Clean old 73 | ExecWait "taskkill /f /im onetagger.exe" 74 | RMDir /r "$INSTDIR\*" 75 | ; Copy new 76 | SetOutPath $INSTDIR 77 | File "..\target\release\onetagger.exe" 78 | File "..\assets\icon.ico" 79 | File "..\vc_redist.x64.exe" 80 | File "..\MicrosoftEdgeWebview2Setup.exe" 81 | ; Uninstaller 82 | WriteUninstaller "$INSTDIR\Uninstall.exe" 83 | CreateDirectory "$SMPROGRAMS\OneTagger" 84 | CreateShortcut "$SMPROGRAMS\OneTagger\${PRODUCT_NAME}.lnk" "$INSTDIR\onetagger.exe" "" "$INSTDIR\icon.ico" 85 | ; Registry 86 | WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}" \ 87 | "DisplayName" "${PRODUCT_NAME}" 88 | WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}" \ 89 | "UninstallString" "$INSTDIR\Uninstall.exe" 90 | WriteRegDWORD HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}" \ 91 | "EstimatedSize" "70656" 92 | WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}" \ 93 | "DisplayIcon" "$INSTDIR\icon.ico" 94 | WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}" \ 95 | "InstallLocation" "$INSTDIR" 96 | ; Dependencies 97 | ExecWait '"$INSTDIR\vc_redist.x64.exe" /install /quiet /norestart' 98 | ExecWait '"$INSTDIR\MicrosoftEdgeWebview2Setup.exe' 99 | Delete "$INSTDIR\vc_redist.x64.exe" 100 | Delete "$INSTDIR\MicrosoftEdgeWebview2Setup.exe" 101 | SectionEnd 102 | 103 | ;------------------------------------------------------------------------------- 104 | ; Uninstaller Sections 105 | Section "Uninstall" 106 | Delete "$SMPROGRAMS\OneTagger\${PRODUCT_NAME}.lnk" 107 | Delete "$DESKTOP\${PRODUCT_NAME}.lnk" 108 | RMDir "$SMPROGRAMS\OneTagger" 109 | Delete "$INSTDIR\*" 110 | RMDir /r "$INSTDIR\*" 111 | RMDir "$INSTDIR" 112 | SectionEnd 113 | 114 | ;------ 115 | ; Desktop icon 116 | Function desktopshortcut 117 | CreateShortcut "$DESKTOP\${PRODUCT_NAME}.lnk" "$INSTDIR\onetagger.exe" "" "$INSTDIR\icon.ico" 118 | FunctionEnd 119 | -------------------------------------------------------------------------------- /assets/mac-cross.toml: -------------------------------------------------------------------------------- 1 | [target.x86_64-apple-darwin] 2 | linker = "x86_64-apple-darwin14-clang" 3 | ar = "x86_64-apple-darwin14-ar" 4 | rustflags = ["-lz", "-lbz2", "-llzma", "-l", "framework=WebKit", "-C", "link-args=-framework AudioUnit"] 5 | -------------------------------------------------------------------------------- /assets/onetagger-logo-github.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Marekkon5/onetagger/f3e1409c6c8ebfbaa80759915a2f56a760e3f407/assets/onetagger-logo-github.png -------------------------------------------------------------------------------- /assets/spotify_callback.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |

Spotify authorized successfully, you can close this window.

5 | 6 | -------------------------------------------------------------------------------- /assets/welcomebanner.bmp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Marekkon5/onetagger/f3e1409c6c8ebfbaa80759915a2f56a760e3f407/assets/welcomebanner.bmp -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | pnpm-lock.yaml 27 | -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | One Tagger 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vue-tsc --noEmit && vite build", 9 | "preview": "vite preview" 10 | }, 11 | "dependencies": { 12 | "@quasar/extras": "1.16.16", 13 | "axios": "1.7.9", 14 | "compare-versions": "6.1.1", 15 | "quasar": "2.17.7", 16 | "vue": "3.5.13", 17 | "vue-router": "4.5.0", 18 | "vuedraggable": "4.1.0" 19 | }, 20 | "devDependencies": { 21 | "@quasar/vite-plugin": "1.9.0", 22 | "@vitejs/plugin-vue": "5.2.1", 23 | "path": "^0.12.7", 24 | "sass": "1.83.4", 25 | "typescript": "5.7.3", 26 | "vite": "6.0.11", 27 | "vue-tsc": "2.2.0" 28 | } 29 | } -------------------------------------------------------------------------------- /client/src/assets/apps/crossdj.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Marekkon5/onetagger/f3e1409c6c8ebfbaa80759915a2f56a760e3f407/client/src/assets/apps/crossdj.png -------------------------------------------------------------------------------- /client/src/assets/apps/dex.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Marekkon5/onetagger/f3e1409c6c8ebfbaa80759915a2f56a760e3f407/client/src/assets/apps/dex.png -------------------------------------------------------------------------------- /client/src/assets/apps/djaypro.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Marekkon5/onetagger/f3e1409c6c8ebfbaa80759915a2f56a760e3f407/client/src/assets/apps/djaypro.png -------------------------------------------------------------------------------- /client/src/assets/apps/djuced.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Marekkon5/onetagger/f3e1409c6c8ebfbaa80759915a2f56a760e3f407/client/src/assets/apps/djuced.png -------------------------------------------------------------------------------- /client/src/assets/apps/engineprime.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Marekkon5/onetagger/f3e1409c6c8ebfbaa80759915a2f56a760e3f407/client/src/assets/apps/engineprime.png -------------------------------------------------------------------------------- /client/src/assets/apps/mixxx.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Marekkon5/onetagger/f3e1409c6c8ebfbaa80759915a2f56a760e3f407/client/src/assets/apps/mixxx.png -------------------------------------------------------------------------------- /client/src/assets/apps/rekordbox.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Marekkon5/onetagger/f3e1409c6c8ebfbaa80759915a2f56a760e3f407/client/src/assets/apps/rekordbox.png -------------------------------------------------------------------------------- /client/src/assets/apps/serato.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Marekkon5/onetagger/f3e1409c6c8ebfbaa80759915a2f56a760e3f407/client/src/assets/apps/serato.png -------------------------------------------------------------------------------- /client/src/assets/apps/traktor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Marekkon5/onetagger/f3e1409c6c8ebfbaa80759915a2f56a760e3f407/client/src/assets/apps/traktor.png -------------------------------------------------------------------------------- /client/src/assets/apps/virtualdj.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Marekkon5/onetagger/f3e1409c6c8ebfbaa80759915a2f56a760e3f407/client/src/assets/apps/virtualdj.png -------------------------------------------------------------------------------- /client/src/assets/bg.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Marekkon5/onetagger/f3e1409c6c8ebfbaa80759915a2f56a760e3f407/client/src/assets/bg.jpg -------------------------------------------------------------------------------- /client/src/assets/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Marekkon5/onetagger/f3e1409c6c8ebfbaa80759915a2f56a760e3f407/client/src/assets/favicon.png -------------------------------------------------------------------------------- /client/src/assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Marekkon5/onetagger/f3e1409c6c8ebfbaa80759915a2f56a760e3f407/client/src/assets/icon.png -------------------------------------------------------------------------------- /client/src/assets/logo-full.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Marekkon5/onetagger/f3e1409c6c8ebfbaa80759915a2f56a760e3f407/client/src/assets/logo-full.png -------------------------------------------------------------------------------- /client/src/assets/placeholder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Marekkon5/onetagger/f3e1409c6c8ebfbaa80759915a2f56a760e3f407/client/src/assets/placeholder.png -------------------------------------------------------------------------------- /client/src/assets/shazam_icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /client/src/components/AddAlbumArt.vue: -------------------------------------------------------------------------------- 1 | 44 | 45 | -------------------------------------------------------------------------------- /client/src/components/AdvancedSettingsToggle.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | -------------------------------------------------------------------------------- /client/src/components/AutotaggerPlatformSpecific.vue: -------------------------------------------------------------------------------- 1 | 114 | 115 | -------------------------------------------------------------------------------- /client/src/components/AutotaggerProfile.vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /client/src/components/AutotaggerTags.vue: -------------------------------------------------------------------------------- 1 | 2 | 78 | 79 | 126 | 127 | -------------------------------------------------------------------------------- /client/src/components/CliDialog.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 39 | 40 | -------------------------------------------------------------------------------- /client/src/components/DJAppIcons.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 47 | 48 | -------------------------------------------------------------------------------- /client/src/components/DevTools.vue: -------------------------------------------------------------------------------- 1 | 65 | 66 | 171 | 172 | -------------------------------------------------------------------------------- /client/src/components/ExitDialog.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | -------------------------------------------------------------------------------- /client/src/components/FolderBrowser.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 155 | 156 | -------------------------------------------------------------------------------- /client/src/components/HelpRenamerExamples.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /client/src/components/Keybind.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | 115 | 116 | -------------------------------------------------------------------------------- /client/src/components/PlatformsRepo.vue: -------------------------------------------------------------------------------- 1 | 102 | 103 | 104 | -------------------------------------------------------------------------------- /client/src/components/PlayerBar.vue: -------------------------------------------------------------------------------- 1 | 100 | 101 | -------------------------------------------------------------------------------- /client/src/components/PlaylistDropZone.vue: -------------------------------------------------------------------------------- 1 | 43 | 44 | 98 | 99 | -------------------------------------------------------------------------------- /client/src/components/QuickTagContextMenu.vue: -------------------------------------------------------------------------------- 1 | 39 | 40 | 86 | 87 | -------------------------------------------------------------------------------- /client/src/components/QuickTagFileBrowser.vue: -------------------------------------------------------------------------------- 1 | 49 | 50 | -------------------------------------------------------------------------------- /client/src/components/QuickTagGenreBar.vue: -------------------------------------------------------------------------------- 1 | 37 | 38 | 61 | 62 | -------------------------------------------------------------------------------- /client/src/components/QuickTagMoods.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 40 | 41 | -------------------------------------------------------------------------------- /client/src/components/QuickTagRight.vue: -------------------------------------------------------------------------------- 1 | 55 | 56 | 99 | 100 | -------------------------------------------------------------------------------- /client/src/components/QuickTagTileThin.vue: -------------------------------------------------------------------------------- 1 | 69 | 70 | 123 | 124 | -------------------------------------------------------------------------------- /client/src/components/RenamerTokenName.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 23 | 24 | -------------------------------------------------------------------------------- /client/src/components/Separators.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | -------------------------------------------------------------------------------- /client/src/components/SpotifyLogin.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 37 | 38 | -------------------------------------------------------------------------------- /client/src/components/TagEditorAlbumArt.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 88 | 89 | -------------------------------------------------------------------------------- /client/src/components/TagField.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | -------------------------------------------------------------------------------- /client/src/components/TagFields.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | -------------------------------------------------------------------------------- /client/src/components/Waveform.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 94 | 95 | -------------------------------------------------------------------------------- /client/src/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue'; 2 | import { Quasar, Dialog, Notify } from 'quasar'; 3 | import { get1t } from './scripts/onetagger'; 4 | import router from './scripts/router'; 5 | import iconSet from 'quasar/icon-set/mdi-v6'; 6 | 7 | // Style 8 | import '@quasar/extras/mdi-v6/mdi-v6.css'; 9 | import 'quasar/src/css/index.sass'; 10 | import './style/app.scss'; 11 | 12 | import App from './App.vue'; 13 | 14 | 15 | // Handle WebView events 16 | // @ts-ignore 17 | window.onWebviewEvent = (e) => { 18 | get1t().onOSMessage(e); 19 | } 20 | 21 | 22 | createApp(App) 23 | .use(router) 24 | .use(Quasar, { 25 | plugins: { 26 | Dialog, Notify 27 | }, 28 | iconSet 29 | }) 30 | .mount('#app'); 31 | -------------------------------------------------------------------------------- /client/src/scripts/manualtag.ts: -------------------------------------------------------------------------------- 1 | import { AutotaggerConfig, Track } from "./autotagger"; 2 | import { get1t } from "./onetagger"; 3 | import { wsUrl } from "./utils"; 4 | 5 | class ManualTag { 6 | 7 | ws?: WebSocket; 8 | busy = false; 9 | done = false; 10 | matches: TrackMatch[] = []; 11 | errors: ManualTagError[] = []; 12 | 13 | 14 | _resolveSaving?: Function; 15 | 16 | constructor() {} 17 | 18 | /// Reset current state 19 | reset() { 20 | if (this.ws) { 21 | this.ws.close(); 22 | this.ws = undefined; 23 | } 24 | this.matches = []; 25 | this.errors = []; 26 | this.busy = false; 27 | this.done = false; 28 | } 29 | 30 | /// Start tagging a track 31 | tagTrack(path: string, config: AutotaggerConfig) { 32 | this.reset(); 33 | this.busy = true; 34 | 35 | // Open new WS connection because separate thread 36 | this.ws = new WebSocket(wsUrl()); 37 | this.ws.addEventListener('message', (ev) => { 38 | this.onWsMessage(JSON.parse(ev.data)); 39 | }); 40 | this.ws.addEventListener('open', () => { 41 | this.ws!.send(JSON.stringify({ 42 | action: 'manualTag', 43 | config: config, 44 | path 45 | })) 46 | }); 47 | } 48 | 49 | /// Apply matches 50 | async apply(matches: TrackMatch[], path: string, config: AutotaggerConfig) { 51 | // Send to socket and wait for response 52 | const $1t = get1t(); 53 | let promise = new Promise((res, rej) => this._resolveSaving = res); 54 | $1t.send('manualTagApply', { matches, path, config }); 55 | let r = await promise; 56 | this._resolveSaving = undefined; 57 | return r; 58 | } 59 | 60 | /// WebSocket message handler 61 | onWsMessage(json: any) { 62 | switch (json.action) { 63 | // New result 64 | case 'manualTag': 65 | switch (json.status) { 66 | case 'ok': 67 | this.addMatches(json.matches, json.platform); 68 | break; 69 | case 'error': 70 | this.errors.push({ platform: json.platform, error: json.error }); 71 | break; 72 | } 73 | break; 74 | 75 | // Finished 76 | case 'manualTagDone': 77 | this.busy = false; 78 | this.done = true; 79 | this.ws?.close(); 80 | this.ws = undefined; 81 | break; 82 | } 83 | } 84 | 85 | /// Add new matches to array 86 | addMatches(matches: TrackMatch[], platform: string) { 87 | this.matches.push(...matches.map((m) => { 88 | m.track.platform = platform; 89 | return m; 90 | })); 91 | this.matches.sort((a, b) => b.accuracy - a.accuracy); 92 | } 93 | 94 | } 95 | 96 | /// Matched track 97 | interface TrackMatch { 98 | accuracy: number; 99 | track: Track; 100 | reason: string; 101 | } 102 | 103 | interface ManualTagError { 104 | platform: string; 105 | error: string; 106 | } 107 | 108 | export type { TrackMatch }; 109 | export { ManualTag }; 110 | -------------------------------------------------------------------------------- /client/src/scripts/router.ts: -------------------------------------------------------------------------------- 1 | import { createRouter, createWebHashHistory } from 'vue-router'; 2 | 3 | import Index from '../views/Index.vue'; 4 | import TagEditor from '../views/TagEditor.vue'; 5 | import Renamer from '../views/Renamer.vue'; 6 | 7 | // Required for hot reload, idk why it broke 8 | const AutotaggerStatus = () => import('../views/AutotaggerStatus.vue'); 9 | const Autotagger = () => import('../views/Autotagger.vue'); 10 | const QuickTag = () => import('../views/QuickTag.vue'); 11 | const AudioFeatures = () => import('../views/AudioFeatures.vue'); 12 | 13 | const history = createWebHashHistory(); 14 | 15 | const routes = [ 16 | { 17 | path: '/', 18 | component: Index 19 | }, 20 | { 21 | path: '/autotagger', 22 | component: Autotagger 23 | }, 24 | { 25 | path: '/autotagger/status', 26 | component: AutotaggerStatus 27 | }, 28 | { 29 | path: '/quicktag', 30 | component: QuickTag 31 | }, 32 | { 33 | path: '/audiofeatures', 34 | component: AudioFeatures 35 | }, 36 | { 37 | path: '/audiofeatures/status', 38 | component: AutotaggerStatus 39 | }, 40 | { 41 | path: '/tageditor', 42 | component: TagEditor 43 | }, 44 | { 45 | path: '/renamer', 46 | component: Renamer 47 | } 48 | ]; 49 | 50 | const router = createRouter({ 51 | history, 52 | routes 53 | }); 54 | 55 | export default router; 56 | -------------------------------------------------------------------------------- /client/src/scripts/tags.ts: -------------------------------------------------------------------------------- 1 | const ABSTRACTIONS: Record = { 2 | "TIT2": "Title", 3 | "TCON": "Genre", 4 | "TALB": "Album", 5 | "TPE2": "Album Artist", 6 | "TCOM": "Composer", 7 | "TEXT": "Lyricist", 8 | "TIT3": "Mix Name", 9 | "TOPE": "Original Artist", 10 | "TIT1": "Content Group", 11 | "GRP1": "Grouping iTunes", 12 | "TPUB": "Label", 13 | "TPE4": "Remixer", 14 | "IPLS": "Producer ID3v2.3", 15 | "TIPL": "Producer ID3v2.4", 16 | "TPE3": "Conductor", 17 | "TBPM": "BPM", 18 | "TCOP": "Copyright", 19 | "TDAT": "Release Date", 20 | "TDOR": "Original Year", 21 | "TDRL": "Releasetime", 22 | "TKEY": "Key", 23 | "TLEN": "Length", 24 | "TMOO": "Mood", 25 | "TPE1": "Artist", 26 | "TPOS": "Discnumber", 27 | "TRCK": "Tracknumber", 28 | "TSRC": "ISRC", 29 | "TYER": "Year", 30 | "TDRC": "Date Recorded", 31 | "©alb": "Album", 32 | "aART": "Album Artist", 33 | "tmpo": "BPM", 34 | "©cmt": "Comment", 35 | "cpil": "Compilation iTunes", 36 | "©wrt": "Composer", 37 | "cond": "Conductor", 38 | "cprt": "Copyright", 39 | "©day": "Year/Date", 40 | "desc": "Description", 41 | "disk": "Disk Number", 42 | "©too": "Encoded By", 43 | "©gen": "Genre", 44 | "©ART": "Artist", 45 | "©lyr": "Lyrics", 46 | "©grp": "Grouping", 47 | "©ope": "Original Artist", 48 | "©wrk": "Work", 49 | "soaa": "Album Artist Sort Order", 50 | "soal": "Album Sort Order", 51 | "soar": "Artist Sort Order", 52 | "soco": "Composer Sort Order", 53 | "MVNM": "Movement Name", 54 | "©prd": "(Producer)", 55 | "©mvn": "(Movement Name)", 56 | "©nam": "(Title)" 57 | }; 58 | 59 | const MP4 = [ 60 | "©nam (Title)", 61 | "©alb (Album)", 62 | "©ART (Artist)", 63 | "tmpo (BPM)", 64 | "©cmt (Comment)", 65 | "cpil (Compilation iTunes)", 66 | "©wrt (Composer)", 67 | "cond (Conductor)", 68 | "cprt (Copyright)", 69 | "desc (Description)", 70 | "disk (Disk Number)", 71 | "©too (Encoded By)", 72 | "©gen (Genre)", 73 | "©grp (Grouping)", 74 | "©lyr (Lyrics)", 75 | "©ope (Original Artist)", 76 | "©wrk (Work)", 77 | "©day (Year/Date)", 78 | "aART (Album Artist)", 79 | "©prd (Producer)", 80 | "©mvn (Movement Name)", 81 | "LABEL", 82 | "CATALOGNUMBER" 83 | ]; 84 | 85 | const VORBIS = [ 86 | "ALBUM", 87 | "ALBUMARTIST", 88 | "COMMENT", 89 | "COMPOSER", 90 | "CONDUCTOR", 91 | "GENRE", 92 | "GROUPING", 93 | "LABEL", 94 | "LYRICS", 95 | "ORGANIZATION", 96 | "PUBLISHER", 97 | "MIXARTIST", 98 | "REMIXER", 99 | "VERSION", 100 | ]; 101 | 102 | const ID3 = [ 103 | "TIT2 (Title)", 104 | "TPE1 (Artist)", 105 | "TALB (Album)", 106 | "TPE2 (Album Artist)", 107 | "COMM (Comment)", 108 | "TCOM (Composer)", 109 | "TPE3 (Conductor)", 110 | "TIT1 (Content Group)", 111 | "TCON (Genre)", 112 | "GRP1 (Grouping iTunes)", 113 | "TPUB (Label)", 114 | "TEXT (Lyricist)", 115 | "TIT3 (Mix Name)", 116 | "TOPE (Original Artist)", 117 | "IPLS (Producer ID3v2.3)", 118 | "TIPL (Producer ID3v2.4)", 119 | "TPE4 (Remixer)", 120 | "USLT (Unsynchronized Lyrics)", 121 | "MVNM (Movement Name)" 122 | ]; 123 | 124 | export { ABSTRACTIONS, MP4, VORBIS, ID3 }; -------------------------------------------------------------------------------- /client/src/scripts/utils.ts: -------------------------------------------------------------------------------- 1 | // Returns the WebSocket server URL 2 | function wsUrl(): string { 3 | return import.meta.env.DEV ? `ws://${window.location.hostname}:36913/ws` : `ws://${window.location.host}/ws`; 4 | } 5 | 6 | // Returns the HTTP server URL 7 | function httpUrl(): string { 8 | return import.meta.env.DEV ? `http://${window.location.hostname}:36913` : `${window.location.origin}`; 9 | } 10 | 11 | // Returns the Spotify redirect URL 12 | function spotifyUrl(): string { 13 | return `${httpUrl()}/spotify`; 14 | } 15 | 16 | // Tag value separators 17 | class Separators { 18 | id3: string = ', '; 19 | vorbis?: string; 20 | mp4: string = ', '; 21 | 22 | constructor(id3?: string, vorbis?: string, mp4?: string) { 23 | this.id3 = id3??', '; 24 | this.vorbis = vorbis; 25 | this.mp4 = mp4??', '; 26 | } 27 | } 28 | 29 | // Frame name in different formats 30 | class FrameName { 31 | constructor(public id3: string, public vorbis: string, public mp4: string) {} 32 | 33 | // Create new FrameName where the name is same for all formats 34 | public static same(name: string): FrameName { 35 | return new FrameName(name, name, name); 36 | } 37 | 38 | // Create class from JSON 39 | public static fromJson(json: any): FrameName { 40 | return Object.assign(FrameName.same(''), json); 41 | } 42 | 43 | // Get value by audio or tag format 44 | byFormat(format: string) { 45 | switch (format) { 46 | case 'mp3': 47 | case 'aiff': 48 | case 'aif': 49 | case 'id3': 50 | case 'wav': 51 | return this.id3; 52 | case 'flac': 53 | case 'ogg': 54 | case 'vorbis': 55 | return this.vorbis; 56 | case 'mp4': 57 | case 'm4a': 58 | return this.mp4; 59 | default: 60 | throw new Error(`Invalid format: ${format}`); 61 | } 62 | } 63 | } 64 | 65 | // Keybind data 66 | class Keybind { 67 | ctrl: boolean = false; 68 | key?: string; 69 | alt: boolean = false; 70 | shift: boolean = false; 71 | 72 | // Create class from JSON 73 | public static fromJson(json: any): Keybind { 74 | return Object.assign(new Keybind(), json); 75 | } 76 | 77 | // Check if keybind pressed 78 | public static check(e: KeyboardEvent, keybind?: Keybind) { 79 | if (!keybind) return false; 80 | if (e.code.match(/F\d{1,2}/) || e.code.startsWith('Key') || e.code.startsWith("Digit") || e.code.startsWith("Numpad")) { 81 | let key = e.code.toLowerCase().replace("key", "").replace("digit", "").replace("numpad", ""); 82 | return (key == keybind.key && 83 | e.altKey == keybind.alt && 84 | e.shiftKey == keybind.shift && 85 | (e.ctrlKey || e.metaKey) == keybind.ctrl); 86 | } 87 | } 88 | 89 | // Clear the keybind 90 | clear() { 91 | this.ctrl = false; 92 | this.alt = false; 93 | this.shift = false; 94 | this.key = undefined; 95 | } 96 | } 97 | 98 | // Spotify auth data 99 | class Spotify { 100 | clientId?: string; 101 | clientSecret?: string; 102 | authorized: boolean = false; 103 | } 104 | 105 | // Playlist data 106 | interface Playlist { 107 | data?: string; 108 | format?: string; 109 | filename?: string; 110 | } 111 | 112 | /// Duration from Rust 113 | interface RustDuration { 114 | secs: number; 115 | nanos: number; 116 | } 117 | 118 | export type { Playlist, RustDuration }; 119 | export { wsUrl, httpUrl, spotifyUrl, Separators, FrameName, Keybind, Spotify }; -------------------------------------------------------------------------------- /client/src/style/app.scss: -------------------------------------------------------------------------------- 1 | @import 'quasar.scss'; 2 | 3 | @font-face { 4 | font-family: Dosis; 5 | src: url('./fonts/Dosis-Light.ttf'); 6 | font-weight: 300; 7 | } 8 | 9 | @font-face { 10 | font-family: Dosis; 11 | src: url('./fonts/Dosis-Regular.ttf'); 12 | font-weight: 400; 13 | } 14 | 15 | @font-face { 16 | font-family: Dosis; 17 | src: url('./fonts/Dosis-Medium.ttf'); 18 | font-weight: 500; 19 | } 20 | 21 | @font-face { 22 | font-family: Dosis; 23 | src: url('./fonts/Dosis-SemiBold.ttf'); 24 | font-weight: 600; 25 | } 26 | 27 | @font-face { 28 | font-family: Dosis; 29 | src: url('./fonts/Dosis-Bold.ttf'); 30 | font-weight: 700; 31 | } 32 | 33 | @font-face { 34 | font-family: Dosis; 35 | src: url('./fonts/Dosis-ExtraBold.ttf'); 36 | font-weight: 800; 37 | } 38 | 39 | @font-face { 40 | font-family: wavefont; 41 | src: url('./fonts/wavefont.woff2') format('woff2'); 42 | } 43 | 44 | @font-face { 45 | font-family: blank; 46 | /* src: url(./AdobeBlank2VF.ttf.woff2); */ 47 | src: url('data:application/x-font-woff;charset=utf-8;base64,d09GMgABAAAAAARMABMAAAAAC1AAAAPjAAEAxQAAAAAAAAAAAAAAAAAAAAAAAAAAGUYcID9IVkFSVAZgP1NUQVQkP1ZWQVJjAIIAL0YKWFwwLgE2AiQDCAsGAAQgBYsyBzEXJBgIGwEKEcXkJfmqgCdD4yWAULaeeBovVzstIc6riunXpnmtXivg+BLWsFA1x8PTXP+eO5PNAn8AUCwJsYCgqxxZ1JUELrLsXkOo3TUpDjbRB8Tvo61HSFe6NVBVO1SvlxDEvYxsxE4DfNN8ImIOJ0gqtv/9fq0+Wat/NsTds46FRuqU8lf0gse1iqY3dLfkGRqhqiTVConpREoxCY1FPHelp1Ny+uwFWpZYoBA7dh04oaUEhFLUFDri7o2bj2BaFYGOOeZ1vLhvdQ0IMAUABHoC6yWZLbYAgJ9NX06XhIBcaa5iDdL76Qz2k7njkrV+ci1cx5uLNbeOtx22hbrOrMaqxrO/9QL9NwVgAUJoSZgClkjP3HjyTmDP7j0nIoQAM3AGAJfCk0F+cI8EIeugpw/+AUwGvq0R/IaJbxBM/EE9gRAIQEJCRkaBAg00UKJEE0200EIbbXTQQRdd9NBDH30MMMAQQ4wwEuEmSaYkBnnYDCBXEDnpwW9EJK5WZJBAgQZGmA0oQIMiXw91sjku790e3LzZfHZ461Z5+/ozFVTN2diLFz9/ql67cl8k+nvMWO4OBBKYtXsY6LwaespXbYFXt+TqtTcIhK83bvTx5eGGf1s5V/D9qt/p9najlt/Mj6KUQFCxsYrB4sZY4VoloVbYkpHGKGGx3RT/yaJo4wSfCEucQDLwLLLV3u5asKHhhk9QmpVZaOnEerTNyqaunQW61ucM+umj+BAD6xtRwIJe7SdfPJ0j6BTPIkzxelO3WVhf//c2jpwWnwkGXVHJ7/FoDWDAQVkQ+OBTFKZJu8lXljDZnk7YqITmExH/y0MxUyoCDOBqChwdIU8mZhKKCJ2upGVgbGinpSoKOgThuVdIkAJUCkD1UYqNlj3J5kSRrqElYYogo7sLvSE3eCVefdEJa/SRA1H0BWVMIMAKSoG1TMqBSbtr5Q+DNGe+gASmqJQCTiiX8mISEDDtHgvYUEpCDjCuCZD0oQJ1YQ3sjHRPlXdfK+E/rSY28Bg4cI6cV00RDZaMcjGklsyXggUDJ2BRVwCGvwLAfxa/WTib27k4LwoOHB0NbJMLE/HNdo7AyM7W2AINfIZwPg2I0KNAtGALqE+jIEEkfiTfWAAkgSiiBZJkLDgSCXf6EaZ2NnDRspAUq7MFXOYSaLDGnozJK4dkPCmTRSmRKSxxWiDAMFMxKVLnoZ7iPA0oLgj+UBlRkrmlg3DbW9ieSDMnJbu4KqNIxL1kjISZ9GjJ5+t2TALq0U3AcYLmZeFeX9Ww6+F30lD/+kXetcg6ZJg3EalzO/5jvn7dbo3cSO6f/4dc8dJzzBkJMwsAAAA=') format('woff2'); 48 | } 49 | 50 | :root { 51 | --waveform-wave: 40; 52 | } 53 | 54 | * { 55 | font-family: Dosis !important; 56 | user-select: none; 57 | } 58 | 59 | .text-h5 { 60 | padding-bottom: 6px; 61 | } 62 | 63 | .bg-darker { 64 | background: #181818; 65 | } 66 | 67 | .bg-highlight { 68 | background: #303030; 69 | } 70 | 71 | .bg-onetagger-icon { 72 | background: #1A1A1A; 73 | } 74 | 75 | .q-tab__indicator { 76 | color: var(--q-color-primary); 77 | } 78 | 79 | .q-field__control { 80 | background-color: #99999910 !important; 81 | } 82 | 83 | // Checkbox fix 84 | .checkbox svg { 85 | color: #000; 86 | } 87 | 88 | // Input fix 89 | .input input { 90 | color: #fff !important; 91 | } 92 | 93 | // Select fix 94 | .select span { 95 | color: #fff !important; 96 | } 97 | 98 | // Tooltip fix 99 | .q-slider__pin-text { 100 | color: #fff; 101 | } 102 | 103 | .q-tooltip { 104 | font-size: 13px !important; 105 | } 106 | 107 | // Card shadow fix 108 | .q-card { 109 | box-shadow: none !important; 110 | } 111 | 112 | // https://medium.com/cloud-native-the-gathering/how-to-fix-your-angular-material-input-field-from-being-broken-in-safari-1419b12007ee 113 | input { 114 | -webkit-user-select: text !important; 115 | } 116 | 117 | // Fix the input fields on Webkit 118 | input[type="search"] { 119 | -webkit-appearance: textfield; 120 | } 121 | 122 | .selectable { 123 | user-select: text !important; 124 | cursor: text; 125 | } 126 | 127 | .clickable { 128 | cursor: pointer; 129 | } 130 | 131 | .monospace { 132 | font-family: monospace !important; 133 | } 134 | 135 | .dotted-underline { 136 | border-bottom: 1px dotted; 137 | } 138 | .dotted-underline:hover { 139 | border-bottom: 1px dotted white; 140 | } 141 | 142 | 143 | /* Renamer syntax higlighting colors */ 144 | .__renamer_syntax_text { 145 | color: #9e9e9e; 146 | font-family: monospace !important; 147 | } 148 | .__renamer_syntax_operator { 149 | color: #78909c; 150 | font-family: monospace !important; 151 | } 152 | .__renamer_syntax_string { 153 | color: #4caf50; 154 | font-family: monospace !important; 155 | } 156 | .__renamer_syntax_number { 157 | color: #ff5722; 158 | font-family: monospace !important; 159 | } 160 | .__renamer_syntax_function { 161 | color: #2196f3; 162 | font-family: monospace !important; 163 | } 164 | .__renamer_syntax_property { 165 | color: #cfd8dc; 166 | font-family: monospace !important; 167 | } 168 | .__renamer_syntax_variable { 169 | color: #cfd8dc; 170 | font-family: monospace !important; 171 | } 172 | 173 | code { 174 | font-family: monospace !important; 175 | font-size: 85%; 176 | } 177 | 178 | 179 | // Scrollbar 180 | ::-webkit-scrollbar { 181 | width: 10px; 182 | } 183 | 184 | ::-webkit-scrollbar:horizontal { 185 | height: 10px; 186 | } 187 | 188 | ::-webkit-scrollbar-track { 189 | background-color: #10101044; 190 | border-radius: 5px; 191 | } 192 | 193 | ::-webkit-scrollbar-thumb { 194 | border-radius: 5px; 195 | background-color: #363636; 196 | } 197 | 198 | ::-webkit-scrollbar-thumb:hover { 199 | background-color: #404040; 200 | } 201 | 202 | ::-webkit-scrollbar-thumb:horizontal { 203 | background-color: #36363690; 204 | } 205 | 206 | ::-webkit-scrollbar-thumb:horizontal:hover { 207 | background-color: #404040; 208 | } 209 | 210 | ::-webkit-scrollbar-corner { 211 | background-color: transparent !important; 212 | } -------------------------------------------------------------------------------- /client/src/style/fonts/Dosis-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Marekkon5/onetagger/f3e1409c6c8ebfbaa80759915a2f56a760e3f407/client/src/style/fonts/Dosis-Bold.ttf -------------------------------------------------------------------------------- /client/src/style/fonts/Dosis-ExtraBold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Marekkon5/onetagger/f3e1409c6c8ebfbaa80759915a2f56a760e3f407/client/src/style/fonts/Dosis-ExtraBold.ttf -------------------------------------------------------------------------------- /client/src/style/fonts/Dosis-Light.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Marekkon5/onetagger/f3e1409c6c8ebfbaa80759915a2f56a760e3f407/client/src/style/fonts/Dosis-Light.ttf -------------------------------------------------------------------------------- /client/src/style/fonts/Dosis-Medium.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Marekkon5/onetagger/f3e1409c6c8ebfbaa80759915a2f56a760e3f407/client/src/style/fonts/Dosis-Medium.ttf -------------------------------------------------------------------------------- /client/src/style/fonts/Dosis-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Marekkon5/onetagger/f3e1409c6c8ebfbaa80759915a2f56a760e3f407/client/src/style/fonts/Dosis-Regular.ttf -------------------------------------------------------------------------------- /client/src/style/fonts/Dosis-SemiBold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Marekkon5/onetagger/f3e1409c6c8ebfbaa80759915a2f56a760e3f407/client/src/style/fonts/Dosis-SemiBold.ttf -------------------------------------------------------------------------------- /client/src/style/fonts/OFL.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2011, Edgar Tolentino and Pablo Impallari (www.impallari.com|impallari@gmail.com), 2 | Copyright (c) 2011, Igino Marini. (www.ikern.com|mail@iginomarini.com), 3 | with Reserved Font Names "Dosis". 4 | 5 | This Font Software is licensed under the SIL Open Font License, Version 1.1. 6 | This license is copied below, and is also available with a FAQ at: 7 | http://scripts.sil.org/OFL 8 | 9 | 10 | ----------------------------------------------------------- 11 | SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 12 | ----------------------------------------------------------- 13 | 14 | PREAMBLE 15 | The goals of the Open Font License (OFL) are to stimulate worldwide 16 | development of collaborative font projects, to support the font creation 17 | efforts of academic and linguistic communities, and to provide a free and 18 | open framework in which fonts may be shared and improved in partnership 19 | with others. 20 | 21 | The OFL allows the licensed fonts to be used, studied, modified and 22 | redistributed freely as long as they are not sold by themselves. The 23 | fonts, including any derivative works, can be bundled, embedded, 24 | redistributed and/or sold with any software provided that any reserved 25 | names are not used by derivative works. The fonts and derivatives, 26 | however, cannot be released under any other type of license. The 27 | requirement for fonts to remain under this license does not apply 28 | to any document created using the fonts or their derivatives. 29 | 30 | DEFINITIONS 31 | "Font Software" refers to the set of files released by the Copyright 32 | Holder(s) under this license and clearly marked as such. This may 33 | include source files, build scripts and documentation. 34 | 35 | "Reserved Font Name" refers to any names specified as such after the 36 | copyright statement(s). 37 | 38 | "Original Version" refers to the collection of Font Software components as 39 | distributed by the Copyright Holder(s). 40 | 41 | "Modified Version" refers to any derivative made by adding to, deleting, 42 | or substituting -- in part or in whole -- any of the components of the 43 | Original Version, by changing formats or by porting the Font Software to a 44 | new environment. 45 | 46 | "Author" refers to any designer, engineer, programmer, technical 47 | writer or other person who contributed to the Font Software. 48 | 49 | PERMISSION & CONDITIONS 50 | Permission is hereby granted, free of charge, to any person obtaining 51 | a copy of the Font Software, to use, study, copy, merge, embed, modify, 52 | redistribute, and sell modified and unmodified copies of the Font 53 | Software, subject to the following conditions: 54 | 55 | 1) Neither the Font Software nor any of its individual components, 56 | in Original or Modified Versions, may be sold by itself. 57 | 58 | 2) Original or Modified Versions of the Font Software may be bundled, 59 | redistributed and/or sold with any software, provided that each copy 60 | contains the above copyright notice and this license. These can be 61 | included either as stand-alone text files, human-readable headers or 62 | in the appropriate machine-readable metadata fields within text or 63 | binary files as long as those fields can be easily viewed by the user. 64 | 65 | 3) No Modified Version of the Font Software may use the Reserved Font 66 | Name(s) unless explicit written permission is granted by the corresponding 67 | Copyright Holder. This restriction only applies to the primary font name as 68 | presented to the users. 69 | 70 | 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font 71 | Software shall not be used to promote, endorse or advertise any 72 | Modified Version, except to acknowledge the contribution(s) of the 73 | Copyright Holder(s) and the Author(s) or with their explicit written 74 | permission. 75 | 76 | 5) The Font Software, modified or unmodified, in part or in whole, 77 | must be distributed entirely under this license, and must not be 78 | distributed under any other license. The requirement for fonts to 79 | remain under this license does not apply to any document created 80 | using the Font Software. 81 | 82 | TERMINATION 83 | This license becomes null and void if any of the above conditions are 84 | not met. 85 | 86 | DISCLAIMER 87 | THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 88 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF 89 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT 90 | OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE 91 | COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 92 | INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL 93 | DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 94 | FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM 95 | OTHER DEALINGS IN THE FONT SOFTWARE. 96 | -------------------------------------------------------------------------------- /client/src/style/fonts/wavefont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Marekkon5/onetagger/f3e1409c6c8ebfbaa80759915a2f56a760e3f407/client/src/style/fonts/wavefont.woff2 -------------------------------------------------------------------------------- /client/src/style/quasar.scss: -------------------------------------------------------------------------------- 1 | $primary : #00D2BF; 2 | $secondary : #106c61; 3 | $accent : #181818; 4 | 5 | $dark : #202020; 6 | $dark-page : #181818; 7 | 8 | $positive : #21BA45; 9 | $negative : #C10015; 10 | $info : #31CCEC; 11 | $warning : #F2C037; 12 | 13 | $tooltip-background: #080808 !default; -------------------------------------------------------------------------------- /client/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | declare module '*.vue' { 4 | import type { DefineComponent } from 'vue' 5 | const component: DefineComponent<{}, {}, any> 6 | export default component 7 | } 8 | -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "moduleResolution": "Node", 7 | "strict": true, 8 | "jsx": "preserve", 9 | "sourceMap": true, 10 | "resolveJsonModule": true, 11 | "isolatedModules": true, 12 | "esModuleInterop": true, 13 | "lib": ["ESNext", "DOM"], 14 | "skipLibCheck": true 15 | }, 16 | "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"], 17 | "references": [{ "path": "./tsconfig.node.json" }] 18 | } 19 | -------------------------------------------------------------------------------- /client/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "allowSyntheticDefaultImports": true 7 | }, 8 | "include": ["vite.config.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /client/vite.config.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { defineConfig } from 'vite'; 3 | import { quasar, transformAssetUrls } from '@quasar/vite-plugin'; 4 | import vue from '@vitejs/plugin-vue'; 5 | 6 | 7 | // https://vitejs.dev/config/ 8 | export default defineConfig({ 9 | plugins: [ 10 | vue({ 11 | template: { transformAssetUrls } 12 | }), 13 | quasar({ 14 | sassVariables: path.resolve('src/style/quasar.scss') 15 | }) 16 | ], 17 | server: { 18 | port: 8080 19 | }, 20 | build: { 21 | assetsInlineLimit: 16 * 1024 22 | } 23 | }); 24 | -------------------------------------------------------------------------------- /crates/onetagger-autotag/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "onetagger-autotag" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [dependencies] 9 | log = "0.4" 10 | rand = "0.8" 11 | regex = "1.10" 12 | dunce = "1.0" 13 | image = "0.25" 14 | anyhow = "1.0" 15 | chrono = "0.4" 16 | base64 = "0.22" 17 | execute = "0.2" 18 | walkdir = "2.5" 19 | libloading = "0.8" 20 | serde_json = "1.0" 21 | lazy_static = "1.5" 22 | crossbeam-channel = "0.5" 23 | 24 | serde = { version = "1.0", features = ["derive"] } 25 | reqwest = { version = "0.12", features = ["json", "blocking", "rustls-tls"], default-features = false } 26 | 27 | songrec = { git = "https://github.com/Marekkon5/SongRec.git" } 28 | 29 | onetagger-tag = { path = "../onetagger-tag" } 30 | onetagger-tagger = { path = "../onetagger-tagger" } 31 | onetagger-player = { path = "../onetagger-player" } 32 | onetagger-shared = { path = "../onetagger-shared" } 33 | onetagger-renamer = { path = "../onetagger-renamer" } 34 | onetagger-platforms = { path = "../onetagger-platforms" } 35 | -------------------------------------------------------------------------------- /crates/onetagger-autotag/src/repo.rs: -------------------------------------------------------------------------------- 1 | use std::io::BufWriter; 2 | use std::fs::File; 3 | use anyhow::Error; 4 | use onetagger_shared::Settings; 5 | use serde_json::Value; 6 | 7 | const MANIFEST_URL: &str = "https://raw.githubusercontent.com/Marekkon5/onetagger-platforms/master/platforms.json"; 8 | const DOWNLOAD_URL: &str = "https://github.com/Marekkon5/onetagger-platforms/releases/download/platforms"; 9 | 10 | /// Fetch custom platforms manifest 11 | /// TODO: Serialization not implemented right now since it just gets passed to UI 12 | pub fn fetch_manifest() -> Result { 13 | Ok(reqwest::blocking::get(MANIFEST_URL)?.json()?) 14 | } 15 | 16 | /// Fetch custom platforms repo async version because WS is handled by async runtime 17 | pub async fn fetch_manifest_async() -> Result { 18 | Ok(reqwest::get(MANIFEST_URL).await?.error_for_status()?.json().await?) 19 | } 20 | 21 | /// Download and install custom platform 22 | pub fn install_platform(id: &str, version: &str, is_native: bool) -> Result<(), Error> { 23 | info!("Installing platform {id}@{version}"); 24 | 25 | // Generate filename 26 | let name = format!("{id}_{version}"); 27 | let name = match is_native { 28 | true => { 29 | let name = format!("{name}_{}_{}", std::env::consts::OS, std::env::consts::ARCH); 30 | match std::env::consts::OS { 31 | "windows" => format!("{name}.dll"), 32 | "linux" => format!("{name}.so"), 33 | "macos" => format!("{name}.dylib"), 34 | // Fallback assume linux 35 | _ => format!("{name}.so") 36 | } 37 | }, 38 | false => format!("{name}"), 39 | }; 40 | 41 | // Get paths 42 | let platforms_dir = Settings::get_folder()?.join("platforms"); 43 | let path = platforms_dir.join(&name); 44 | 45 | // Download native 46 | if is_native { 47 | std::io::copy(&mut reqwest::blocking::get(format!("{DOWNLOAD_URL}/{name}"))?, &mut BufWriter::new(File::create(path)?))?; 48 | return Ok(()) 49 | } 50 | 51 | Ok(()) 52 | } -------------------------------------------------------------------------------- /crates/onetagger-autotag/src/shazam.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Error; 2 | use std::path::Path; 3 | use std::thread::Builder; 4 | use onetagger_player::rodio::source::UniformSourceIterator; 5 | use serde::{Serialize, Deserialize}; 6 | use songrec::SignatureGenerator; 7 | use onetagger_player::AudioSources; 8 | 9 | pub struct Shazam; 10 | 11 | impl Shazam { 12 | /// Recognize song on Shazam from path, returns Track, Duration 13 | pub fn recognize_from_file(path: impl AsRef) -> Result<(ShazamTrack, u128), Error> { 14 | // Load file 15 | let source = AudioSources::from_path(path)?; 16 | let duration = source.duration(); 17 | let conv = UniformSourceIterator::new(source.get_source()?, 1, 16000); 18 | // Get 12s part from middle 19 | let buffer = if duration >= 12000 { 20 | // ((duration / 1000) * 16KHz) / 2 (half duration) - (6 * 16KHz) seconds. 21 | conv.skip((duration * 8 - 96000) as usize).take(16000 * 12).collect::>() 22 | } else { 23 | conv.collect::>() 24 | }; 25 | // Calculating singnature requires 6MB stack, because it allocates >2MB of buffers for some reason 26 | let signature = Builder::new() 27 | .stack_size(1024 * 1024 * 6) 28 | .spawn(move || { SignatureGenerator::make_signature_from_buffer(&buffer) }) 29 | .unwrap() 30 | .join() 31 | .unwrap(); 32 | let response = songrec::recognize_song_from_signature(&signature, 0).map_err(|e| anyhow!("{e:?}"))?; 33 | let response: ShazamResponse = serde_json::from_value(response)?; 34 | let track = response.track.ok_or(anyhow!("Shazam returned no matches!"))?; 35 | Ok((track, duration)) 36 | } 37 | } 38 | 39 | #[derive(Debug, Clone, Serialize, Deserialize)] 40 | pub struct ShazamResponse { 41 | pub timestamp: u64, 42 | pub tagid: String, 43 | pub track: Option 44 | } 45 | 46 | #[derive(Debug, Clone, Serialize, Deserialize)] 47 | pub struct ShazamTrack { 48 | pub albumadamid: Option, 49 | pub artists: Option>, 50 | pub genres: Option, 51 | pub images: Option, 52 | pub isrc: Option, 53 | pub key: String, 54 | pub sections: Vec, 55 | /// Song title 56 | pub title: String, 57 | /// Artist 58 | pub subtitle: String, 59 | pub url: String, 60 | } 61 | 62 | #[derive(Debug, Clone, Serialize, Deserialize)] 63 | pub struct ShazamSmall { 64 | pub adamid: String, 65 | pub id: String 66 | } 67 | 68 | #[derive(Debug, Clone, Serialize, Deserialize)] 69 | pub struct ShazamGenres { 70 | pub primary: Option 71 | } 72 | 73 | #[derive(Debug, Clone, Serialize, Deserialize)] 74 | pub struct ShazamImages { 75 | pub background: String, 76 | pub coverart: String, 77 | pub coverarthq: String 78 | } 79 | 80 | #[derive(Debug, Clone, Serialize, Deserialize)] 81 | #[serde(untagged)] 82 | pub enum ShazamSection { 83 | MetaSection { 84 | metadata: Vec 85 | }, 86 | ArtistSection { 87 | id: String, 88 | name: String, 89 | tabname: String, 90 | // Has to == "ARTIST" 91 | #[serde(rename = "type")] 92 | _type: String 93 | }, 94 | Other {} 95 | } 96 | 97 | #[derive(Debug, Clone, Serialize, Deserialize)] 98 | pub struct ShazamMetadataSection { 99 | pub text: String, 100 | pub title: String 101 | } -------------------------------------------------------------------------------- /crates/onetagger-cli/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "onetagger-cli" 3 | version = "1.7.0" 4 | edition = "2021" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [dependencies] 9 | log = "0.4" 10 | anyhow = "1.0" 11 | serde_json = "1.0" 12 | convert_case = "0.6" 13 | 14 | clap = { version = "4.5", features = ["derive"] } 15 | 16 | onetagger-ui = { path = "../onetagger-ui" } 17 | onetagger-tagger = { path = "../onetagger-tagger" } 18 | onetagger-shared = { path = "../onetagger-shared" } 19 | onetagger-autotag = { path = "../onetagger-autotag" } 20 | onetagger-renamer = { path = "../onetagger-renamer" } 21 | onetagger-playlist = { path = "../onetagger-playlist" } 22 | onetagger-platforms = { path = "../onetagger-platforms" } 23 | 24 | [target.'cfg(windows)'.build-dependencies] 25 | winres = "0.1" -------------------------------------------------------------------------------- /crates/onetagger-cli/build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | // Set Windows icon 3 | #[cfg(windows)] 4 | { 5 | let mut res = winres::WindowsResource::new(); 6 | res.set_icon("..\\..\\assets\\icon.ico"); 7 | res.compile().unwrap(); 8 | } 9 | } -------------------------------------------------------------------------------- /crates/onetagger-platforms/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "onetagger-platforms" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [dependencies] 9 | log = "0.4" 10 | url = "2.5" 11 | rand = "0.8" 12 | regex = "1.10" 13 | anyhow = "1.0" 14 | scraper = "0.20" 15 | serde_json = "1.0" 16 | minify-html = "0.15" 17 | 18 | serde = { version = "1.0", features = ["derive"] } 19 | chrono = { version = "0.4", features = ["serde"] } 20 | reqwest = { version = "0.12", features = ["json", "blocking", "rustls-tls"], default-features = false } 21 | rspotify = { version = "0.13", features = ["client-ureq", "ureq-rustls-tls"], default-features = false } 22 | 23 | 24 | onetagger-tag = { path = "../onetagger-tag" } 25 | onetagger-shared = { path = "../onetagger-shared" } 26 | onetagger-tagger = { path = "../onetagger-tagger" } -------------------------------------------------------------------------------- /crates/onetagger-platforms/assets/bandcamp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Marekkon5/onetagger/f3e1409c6c8ebfbaa80759915a2f56a760e3f407/crates/onetagger-platforms/assets/bandcamp.png -------------------------------------------------------------------------------- /crates/onetagger-platforms/assets/beatport.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Marekkon5/onetagger/f3e1409c6c8ebfbaa80759915a2f56a760e3f407/crates/onetagger-platforms/assets/beatport.png -------------------------------------------------------------------------------- /crates/onetagger-platforms/assets/beatsource.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Marekkon5/onetagger/f3e1409c6c8ebfbaa80759915a2f56a760e3f407/crates/onetagger-platforms/assets/beatsource.png -------------------------------------------------------------------------------- /crates/onetagger-platforms/assets/bpmsupreme.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Marekkon5/onetagger/f3e1409c6c8ebfbaa80759915a2f56a760e3f407/crates/onetagger-platforms/assets/bpmsupreme.png -------------------------------------------------------------------------------- /crates/onetagger-platforms/assets/deezer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Marekkon5/onetagger/f3e1409c6c8ebfbaa80759915a2f56a760e3f407/crates/onetagger-platforms/assets/deezer.png -------------------------------------------------------------------------------- /crates/onetagger-platforms/assets/discogs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Marekkon5/onetagger/f3e1409c6c8ebfbaa80759915a2f56a760e3f407/crates/onetagger-platforms/assets/discogs.png -------------------------------------------------------------------------------- /crates/onetagger-platforms/assets/itunes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Marekkon5/onetagger/f3e1409c6c8ebfbaa80759915a2f56a760e3f407/crates/onetagger-platforms/assets/itunes.png -------------------------------------------------------------------------------- /crates/onetagger-platforms/assets/junodownload.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Marekkon5/onetagger/f3e1409c6c8ebfbaa80759915a2f56a760e3f407/crates/onetagger-platforms/assets/junodownload.png -------------------------------------------------------------------------------- /crates/onetagger-platforms/assets/musicbrainz.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Marekkon5/onetagger/f3e1409c6c8ebfbaa80759915a2f56a760e3f407/crates/onetagger-platforms/assets/musicbrainz.png -------------------------------------------------------------------------------- /crates/onetagger-platforms/assets/musixmatch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Marekkon5/onetagger/f3e1409c6c8ebfbaa80759915a2f56a760e3f407/crates/onetagger-platforms/assets/musixmatch.png -------------------------------------------------------------------------------- /crates/onetagger-platforms/assets/spotify.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Marekkon5/onetagger/f3e1409c6c8ebfbaa80759915a2f56a760e3f407/crates/onetagger-platforms/assets/spotify.png -------------------------------------------------------------------------------- /crates/onetagger-platforms/assets/traxsource.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Marekkon5/onetagger/f3e1409c6c8ebfbaa80759915a2f56a760e3f407/crates/onetagger-platforms/assets/traxsource.png -------------------------------------------------------------------------------- /crates/onetagger-platforms/src/lib.rs: -------------------------------------------------------------------------------- 1 | #[macro_use] extern crate log; 2 | #[macro_use] extern crate anyhow; 3 | #[macro_use] extern crate onetagger_shared; 4 | 5 | pub mod beatport; 6 | pub mod traxsource; 7 | pub mod discogs; 8 | pub mod junodownload; 9 | pub mod spotify; 10 | pub mod itunes; 11 | pub mod musicbrainz; 12 | pub mod beatsource; 13 | pub mod bpmsupreme; 14 | pub mod deezer; 15 | pub mod musixmatch; 16 | pub mod bandcamp; 17 | mod bandcamp_genres; 18 | 19 | -------------------------------------------------------------------------------- /crates/onetagger-player/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "onetagger-player" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [dependencies] 9 | log = "0.4" 10 | alac = "0.5" 11 | hound = "3.5" 12 | lofty = "0.21" 13 | anyhow = "1.0" 14 | pacmog = "0.4.2" 15 | mp4parse = "0.17" 16 | 17 | rodio = { version = "0.19", features = ["symphonia-aac", "symphonia-isomp4", "flac", "vorbis", "wav", "minimp3"], default-features = false } 18 | 19 | -------------------------------------------------------------------------------- /crates/onetagger-player/src/aiff.rs: -------------------------------------------------------------------------------- 1 | use std::path::{Path, PathBuf}; 2 | use anyhow::Error; 3 | use lofty::file::AudioFile; 4 | use std::fs::File; 5 | use std::io::Read; 6 | use std::time::Duration; 7 | use pacmog::PcmReader; 8 | use rodio::Source; 9 | 10 | use crate::AudioSource; 11 | 12 | pub struct AIFFSource { 13 | path: PathBuf, 14 | duration: Duration 15 | } 16 | 17 | impl AIFFSource { 18 | // Load from path 19 | pub fn new(path: impl AsRef) -> Result { 20 | // Get duration 21 | let file = lofty::read_from_path(&path)?; 22 | let duration = file.properties().duration(); 23 | 24 | Ok(AIFFSource { 25 | path: path.as_ref().to_owned(), 26 | duration 27 | }) 28 | } 29 | 30 | } 31 | 32 | impl AudioSource for AIFFSource { 33 | // Get duration 34 | fn duration(&self) -> u128 { 35 | self.duration.as_millis() 36 | 37 | } 38 | 39 | // Get rodio source 40 | fn get_source(&self) -> Result + Send>, Error> { 41 | let source = AIFFDecoder::load(&self.path)?; 42 | Ok(Box::new(source.convert_samples())) 43 | } 44 | } 45 | 46 | struct AIFFDecoder { 47 | channels: u32, 48 | samples: u32, 49 | sample_rate: u32, 50 | index: usize, 51 | buffer: Vec 52 | } 53 | 54 | impl AIFFDecoder { 55 | /// Load file into memory 56 | pub fn load(path: impl AsRef) -> Result { 57 | // Load file 58 | let mut data = vec![]; 59 | File::open(path)?.read_to_end(&mut data)?; 60 | 61 | // Parse metadata (catch panic, because weird library) 62 | let reader = std::panic::catch_unwind(|| { 63 | PcmReader::new(&data) 64 | }).map_err(|e| anyhow!("Not an AIFF file: {e:?}"))?; 65 | let specs = reader.get_pcm_specs(); 66 | 67 | // Decode the file (because the library is weeeird) 68 | // TODO: Make better using symphonia / new rodio 69 | let mut samples = vec![0i16; specs.num_channels as usize * specs.num_samples as usize]; 70 | let mut i = 0; 71 | for sample in 0..specs.num_samples { 72 | for channel in 0..specs.num_channels { 73 | let s = std::panic::catch_unwind(|| { 74 | reader.read_sample(channel as u32, sample) 75 | }).map_err(|e| anyhow!("Failed decoding AIFF: {e:?}"))?.map_err(|e| anyhow!("Failed decoding AIFF: {e}"))?; 76 | samples[i] = (s * i16::MAX as f32) as i16; 77 | i += 1; 78 | } 79 | } 80 | 81 | Ok(AIFFDecoder { 82 | channels: specs.num_channels as u32, 83 | samples: specs.num_samples, 84 | sample_rate: specs.sample_rate, 85 | index: 0, 86 | buffer: samples, 87 | }) 88 | } 89 | } 90 | 91 | impl Source for AIFFDecoder { 92 | fn current_frame_len(&self) -> Option { 93 | None 94 | } 95 | 96 | fn channels(&self) -> u16 { 97 | self.channels as u16 98 | } 99 | 100 | fn sample_rate(&self) -> u32 { 101 | self.sample_rate 102 | } 103 | 104 | fn total_duration(&self) -> Option { 105 | Some(Duration::from_secs_f32(self.samples as f32 / self.sample_rate as f32)) 106 | } 107 | } 108 | 109 | impl Iterator for AIFFDecoder { 110 | type Item = i16; 111 | 112 | fn next(&mut self) -> Option { 113 | if self.index >= self.buffer.len() { 114 | return None; 115 | } 116 | let sample = self.buffer[self.index]; 117 | self.index += 1; 118 | Some(sample) 119 | } 120 | } -------------------------------------------------------------------------------- /crates/onetagger-player/src/alac.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Error; 2 | use std::path::Path; 3 | use std::fs::File; 4 | use std::io::BufReader; 5 | use std::time::Duration; 6 | use rodio::Source; 7 | use alac::{Reader, Samples, StreamInfo}; 8 | 9 | pub struct ALACSource { 10 | samples: Samples, i32>, 11 | stream_info: StreamInfo 12 | } 13 | 14 | impl ALACSource { 15 | // Read alac from file 16 | pub fn new(path: impl AsRef) -> Result { 17 | let file = File::open(path)?; 18 | let r = BufReader::new(file); 19 | let reader = Reader::new(r)?; 20 | let stream_info = reader.stream_info().to_owned(); 21 | Ok(ALACSource { 22 | samples: reader.into_samples(), 23 | stream_info 24 | }) 25 | } 26 | } 27 | 28 | impl Source for ALACSource { 29 | fn current_frame_len(&self) -> Option { 30 | None 31 | } 32 | 33 | fn channels(&self) -> u16 { 34 | self.stream_info.channels() as u16 35 | } 36 | 37 | fn sample_rate(&self) -> u32 { 38 | self.stream_info.sample_rate() 39 | } 40 | 41 | fn total_duration(&self) -> Option { 42 | None 43 | } 44 | } 45 | 46 | impl Iterator for ALACSource { 47 | type Item = i16; 48 | 49 | fn next(&mut self) -> Option { 50 | // Wrapper against samples 51 | if let Some(r) = self.samples.next() { 52 | if let Ok(s) = r { 53 | return Some((s >> 16) as i16); 54 | } 55 | } 56 | None 57 | } 58 | } -------------------------------------------------------------------------------- /crates/onetagger-player/src/flac.rs: -------------------------------------------------------------------------------- 1 | use std::path::{PathBuf, Path}; 2 | use anyhow::Error; 3 | use std::io::BufReader; 4 | use std::fs::File; 5 | use rodio::{Source, Decoder}; 6 | use crate::AudioSource; 7 | 8 | pub struct FLACSource { 9 | path: PathBuf, 10 | duration: u128 11 | } 12 | 13 | impl FLACSource { 14 | pub fn new(path: impl AsRef) -> Result { 15 | let mut flac = FLACSource { 16 | path: path.as_ref().to_owned(), 17 | duration: 0 18 | }; 19 | // Get duration from decoder 20 | flac.duration = flac.get_source()?.total_duration().ok_or(anyhow!("Missing duration"))?.as_millis(); 21 | 22 | Ok(flac) 23 | } 24 | } 25 | 26 | impl AudioSource for FLACSource { 27 | // Get duration 28 | fn duration(&self) -> u128 { 29 | self.duration 30 | } 31 | 32 | // Get rodio decoder 33 | fn get_source(&self) -> Result + Send>, Error> { 34 | Ok(Box::new(Decoder::new_flac(BufReader::new(File::open(&self.path)?))?)) 35 | } 36 | } -------------------------------------------------------------------------------- /crates/onetagger-player/src/mp3.rs: -------------------------------------------------------------------------------- 1 | use std::path::{Path, PathBuf}; 2 | use anyhow::Error; 3 | use lofty::file::AudioFile; 4 | use std::io::BufReader; 5 | use std::fs::File; 6 | use rodio::{Source, Decoder}; 7 | use crate::AudioSource; 8 | 9 | pub struct MP3Source { 10 | path: PathBuf, 11 | duration: u128 12 | } 13 | impl MP3Source { 14 | pub fn new(path: impl AsRef) -> Result { 15 | // Get duration 16 | let file = lofty::read_from_path(&path)?; 17 | let duration = file.properties().duration(); 18 | 19 | Ok(MP3Source { 20 | path: path.as_ref().to_owned(), 21 | duration: duration.as_millis() 22 | }) 23 | } 24 | } 25 | 26 | impl AudioSource for MP3Source { 27 | // Get duration 28 | fn duration(&self) -> u128 { 29 | self.duration 30 | } 31 | 32 | // Get rodio decoder 33 | fn get_source(&self) -> Result + Send>, Error> { 34 | Ok(Box::new(Decoder::new_mp3(BufReader::new(File::open(&self.path)?))?)) 35 | } 36 | } -------------------------------------------------------------------------------- /crates/onetagger-player/src/mp4.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Error; 2 | use mp4parse::{SampleEntry, CodecType}; 3 | use rodio::decoder::Mp4Type; 4 | use std::io::BufReader; 5 | use std::fs::File; 6 | use std::path::{PathBuf, Path}; 7 | use rodio::{Source, Decoder}; 8 | 9 | use crate::AudioSource; 10 | use crate::alac::ALACSource; 11 | 12 | pub struct MP4Source { 13 | path: PathBuf, 14 | duration: u128, 15 | alac: bool 16 | } 17 | 18 | impl MP4Source { 19 | pub fn new(path: impl AsRef) -> Result { 20 | let file = File::open(&path)?; 21 | let mp4 = mp4parse::read_mp4(&mut BufReader::new(file))?; 22 | let track = mp4.tracks.first().ok_or(anyhow!("No MP4 tracks"))?; 23 | let duration = track.duration.ok_or(anyhow!("Missing duration"))?.0 as f32 / track.timescale.ok_or(anyhow!("Missing timescale"))?.0 as f32; 24 | // Check if alac 25 | let mut alac = false; 26 | if let SampleEntry::Audio(entry) = track.stsd.as_ref().ok_or(anyhow!("Missing stsd"))?.descriptions.first().ok_or(anyhow!("Missing first stsd"))? { 27 | alac = entry.codec_type == CodecType::ALAC 28 | } 29 | 30 | debug!("Creating MP4 source ok, alac: {}", alac); 31 | 32 | Ok(MP4Source { 33 | path: path.as_ref().to_owned(), 34 | duration: (duration * 1000.0) as u128, 35 | alac 36 | }) 37 | } 38 | } 39 | 40 | impl AudioSource for MP4Source { 41 | fn duration(&self) -> u128 { 42 | self.duration 43 | } 44 | 45 | fn get_source(&self) -> Result + Send>, Error> { 46 | // ALAC MP4 47 | if self.alac { 48 | let alac = ALACSource::new(&self.path)?; 49 | return Ok(Box::new(alac)); 50 | } 51 | 52 | // Symphonia 53 | let decoder = Decoder::new_mp4(BufReader::new(File::open(&self.path)?), Mp4Type::M4a)?; 54 | return Ok(Box::new(decoder)); 55 | 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /crates/onetagger-player/src/ogg.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | use anyhow::Error; 3 | use lofty::file::AudioFile; 4 | use std::path::{PathBuf, Path}; 5 | use std::io::BufReader; 6 | use std::fs::File; 7 | use rodio::{Source, Decoder}; 8 | 9 | use crate::AudioSource; 10 | 11 | pub struct OGGSource { 12 | path: PathBuf, 13 | duration: Duration, 14 | } 15 | 16 | impl OGGSource { 17 | pub fn new(path: impl AsRef) -> Result { 18 | // Get duration 19 | let file = lofty::read_from_path(&path)?; 20 | let duration = file.properties().duration(); 21 | 22 | Ok(OGGSource { 23 | duration, 24 | path: path.as_ref().into() 25 | }) 26 | } 27 | } 28 | 29 | impl AudioSource for OGGSource { 30 | fn duration(&self) -> u128 { 31 | self.duration.as_millis() 32 | } 33 | 34 | fn get_source(&self) -> Result + Send>, Error> { 35 | // Use rodio vorbis 36 | Ok(Box::new(Decoder::new_vorbis(BufReader::new(File::open(&self.path)?))?)) 37 | } 38 | } 39 | 40 | -------------------------------------------------------------------------------- /crates/onetagger-player/src/wav.rs: -------------------------------------------------------------------------------- 1 | use std::path::{PathBuf, Path}; 2 | use anyhow::Error; 3 | use std::io::BufReader; 4 | use std::fs::File; 5 | use rodio::{Source, Decoder}; 6 | use crate::AudioSource; 7 | 8 | pub struct WAVSource { 9 | path: PathBuf, 10 | duration: u128 11 | } 12 | 13 | impl WAVSource { 14 | pub fn new(path: impl AsRef) -> Result { 15 | let mut wav = WAVSource { 16 | path: path.as_ref().to_owned(), 17 | duration: 0 18 | }; 19 | // Get duration from decoder 20 | wav.duration = wav.get_source()?.total_duration().ok_or(anyhow!("Missing duration"))?.as_millis(); 21 | 22 | Ok(wav) 23 | } 24 | } 25 | 26 | impl AudioSource for WAVSource { 27 | // Get duration 28 | fn duration(&self) -> u128 { 29 | self.duration 30 | } 31 | 32 | // Get rodio decoder 33 | fn get_source(&self) -> Result + Send>, Error> { 34 | Ok(Box::new(Decoder::new_wav(BufReader::new(File::open(&self.path)?))?)) 35 | } 36 | } -------------------------------------------------------------------------------- /crates/onetagger-playlist/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "onetagger-playlist" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [dependencies] 9 | log = "0.4" 10 | anyhow = "1.0" 11 | base64 = "0.22" 12 | urlencoding = "2.1" 13 | 14 | serde = { version = "1.0", features = ["derive"] } 15 | 16 | onetagger-tag = { path = "../onetagger-tag" } -------------------------------------------------------------------------------- /crates/onetagger-playlist/src/lib.rs: -------------------------------------------------------------------------------- 1 | #[macro_use] extern crate log; 2 | #[macro_use] extern crate anyhow; 3 | 4 | use anyhow::Error; 5 | use std::fs::File; 6 | use std::io::prelude::*; 7 | use std::path::{Path, PathBuf}; 8 | use serde::{Serialize, Deserialize}; 9 | use base64::Engine; 10 | use onetagger_tag::EXTENSIONS; 11 | 12 | pub const PLAYLIST_EXTENSIONS: [&str; 2] = ["m3u", "m3u8"]; 13 | 14 | // Playlist info from UI 15 | #[derive(Debug, Clone, Serialize, Deserialize)] 16 | pub struct UIPlaylist { 17 | // base64 18 | pub data: String, 19 | pub filename: String, 20 | pub format: PlaylistFormat 21 | } 22 | 23 | impl UIPlaylist { 24 | pub fn get_files(&self) -> Result, Error> { 25 | let files = match self.format { 26 | PlaylistFormat::M3U => { 27 | // Decode base64 from JS 28 | let bytes = base64::engine::general_purpose::STANDARD.decode(self.data[self.data.find(';').ok_or(anyhow!("Invalid data!"))? + 8..].trim())?; 29 | let m3u = String::from_utf8(bytes)?; 30 | get_files_from_m3u(&m3u, None) 31 | } 32 | }; 33 | // Filter extensions 34 | let out = files 35 | .iter() 36 | .filter(|f| EXTENSIONS.iter().any(|e| f.extension().unwrap_or_default().to_ascii_lowercase() == *e)) 37 | .map(PathBuf::from).collect(); 38 | Ok(out) 39 | } 40 | } 41 | 42 | #[derive(Debug, Clone, Serialize, Deserialize)] 43 | #[serde(rename_all = "lowercase")] 44 | pub enum PlaylistFormat { 45 | M3U 46 | } 47 | 48 | 49 | /// Get files from any playlist format 50 | pub fn get_files_from_playlist_file(path: impl AsRef) -> Result, Error> { 51 | // Validate extension 52 | if !PLAYLIST_EXTENSIONS.iter().any(|e| &&path.as_ref().extension().unwrap_or_default().to_string_lossy().to_lowercase() == e) { 53 | return Err(anyhow!("Unsupported playlist!").into()); 54 | }; 55 | 56 | // Load file 57 | let mut file = File::open(&path)?; 58 | let mut buf = vec![]; 59 | file.read_to_end(&mut buf)?; 60 | 61 | // TODO: Check format if multiple 62 | 63 | // M3U 64 | let data = String::from_utf8(buf)?; 65 | Ok(get_files_from_m3u(&data, Some(path.as_ref().parent().unwrap().to_owned()))) 66 | } 67 | 68 | 69 | /// Get file list from M3U playlist 70 | pub fn get_files_from_m3u(m3u: &str, base_path: Option) -> Vec { 71 | let clean = m3u.replace("\r", "\n").replace("\n\n", "\n"); 72 | let entries = clean.split("\n"); 73 | let mut out = vec![]; 74 | for entry in entries { 75 | if !entry.starts_with("#") && !entry.starts_with("http://") && !entry.is_empty() { 76 | // Decode 77 | let entry = match urlencoding::decode(entry) { 78 | Ok(e) => e.to_string(), 79 | Err(e) => { 80 | warn!("Failed URLDecode: {e}"); 81 | entry.to_string() 82 | } 83 | }; 84 | 85 | if base_path.is_none() { 86 | out.push(entry.trim().to_string()); 87 | } else { 88 | // Add base path 89 | out.push(base_path.clone().unwrap().join(entry).to_str().unwrap().to_string()); 90 | } 91 | } 92 | } 93 | out.into_iter().map(|i| i.into()).collect() 94 | } 95 | 96 | /// Generate m3u playlist from paths 97 | pub fn create_m3u_playlist(paths: &Vec) -> String { 98 | let mut playlist = "#EXTM3U\r\n".to_string(); 99 | for path in paths { 100 | playlist = format!("{playlist}{}\r\n", path.to_string_lossy()); 101 | } 102 | playlist 103 | } 104 | -------------------------------------------------------------------------------- /crates/onetagger-renamer/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "onetagger-renamer" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [dependencies] 9 | log = "0.4" 10 | pad = "0.1" 11 | dunce = "1.0" 12 | regex = "1.10" 13 | anyhow = "1.0" 14 | titlecase = "3.3" 15 | lazy_static = "1.5" 16 | pulldown-cmark = "0.12" 17 | 18 | serde = { version = "1.0", features = ["derive"] } 19 | 20 | onetagger-tag = { path = "../onetagger-tag" } 21 | onetagger-tagger = { path = "../onetagger-tagger" } 22 | -------------------------------------------------------------------------------- /crates/onetagger-shared/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "onetagger-shared" 3 | version = "1.7.0" 4 | edition = "2021" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [dependencies] 9 | log = "0.4" 10 | anyhow = "1.0" 11 | chrono = "0.4" 12 | backtrace = "0.3" 13 | crossterm = "0.28" 14 | serde_json = "1.0" 15 | directories = "5.0" 16 | lazy_static = "1.5" 17 | 18 | fern = { version = "0.6", features = ["colored"] } 19 | serde = { version = "1.0", features = ["derive"] } -------------------------------------------------------------------------------- /crates/onetagger-shared/build.rs: -------------------------------------------------------------------------------- 1 | use std::fs::File; 2 | use std::io::Read; 3 | use std::path::Path; 4 | 5 | fn main() { 6 | // Github Actions commit 7 | let mut commit = if let Ok(commit) = std::env::var("GITHUB_SHA") { 8 | commit 9 | } else { 10 | // Local commit 11 | if let Ok(mut f) = File::open(Path::new("../../.git").join("refs").join("heads").join("master")) { 12 | let mut buf = String::new(); 13 | f.read_to_string(&mut buf).ok(); 14 | buf 15 | } else { 16 | String::new() 17 | } 18 | }; 19 | // Trim 20 | if commit.len() > 8 { 21 | commit = commit[..8].to_string() 22 | } 23 | if commit.is_empty() { 24 | commit = "unknown".to_string(); 25 | } 26 | println!("cargo:rustc-env=COMMIT={}", commit); 27 | } -------------------------------------------------------------------------------- /crates/onetagger-tag/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "onetagger-tag" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [dependencies] 9 | chrono = "0.4" 10 | anyhow = "1.0" 11 | 12 | log = { version = "0.4", optional = true } 13 | id3 = { version = "1.12.0", optional = true } 14 | riff = { version = "2.0.0", optional = true } 15 | lofty = { version = "0.21", optional = true } 16 | base64 = { version = "0.22", optional = true } 17 | mp4ameta = { version = "0.11", optional = true } 18 | metaflac = { version = "0.2.5", optional = true } 19 | once_cell = { version = "1.19", optional = true } 20 | 21 | serde = { version = "1.0", features = ["derive"] } 22 | 23 | [features] 24 | default = ["tag"] 25 | tag = ["id3", "mp4ameta", "metaflac", "base64", "log", "riff", "lofty", "once_cell"] -------------------------------------------------------------------------------- /crates/onetagger-tag/src/wav.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Error; 2 | use std::io::{BufReader, BufWriter, Cursor, Seek, SeekFrom}; 3 | use std::fs::File; 4 | use std::collections::HashMap; 5 | use std::path::Path; 6 | use id3::{Tag, TagLike, Frame, Version}; 7 | use once_cell::sync::Lazy; 8 | use riff::{Chunk, ChunkId, LIST_ID, RIFF_ID, ChunkContents}; 9 | 10 | /// RIFF `id3 ` chunk 11 | static ID3_ID_1: ChunkId = ChunkId { value: [0x69, 0x64, 0x33, 0x20] }; 12 | static ID3_ID_2: ChunkId = ChunkId { value: [0x49, 0x44, 0x33, 0x20] }; 13 | /// RIFF WAVE chunk 14 | static WAVE_ID: ChunkId = ChunkId { value: [0x57, 0x41, 0x56, 0x45] }; 15 | static INFO_ID: ChunkId = ChunkId { value: [0x49, 0x4E, 0x46, 0x4F] }; 16 | 17 | /// ID3 Frame to RIFF ChunkID 18 | static ID3_RIFF: Lazy> = Lazy::new(|| { 19 | let mut m = HashMap::new(); 20 | m.insert("TIT2", ChunkId::new("INAM").unwrap()); 21 | m.insert("TALB", ChunkId::new("IPRD").unwrap()); 22 | m.insert("TPE1", ChunkId::new("IART").unwrap()); 23 | m.insert("COMM", ChunkId::new("ICMT").unwrap()); 24 | m.insert("TCON", ChunkId::new("IGNR").unwrap()); 25 | m.insert("TSRC", ChunkId::new("ISRC").unwrap()); 26 | m 27 | }); 28 | 29 | /// Write wav to path 30 | /// Will copy ID3 meta into RIFF INFO chunk 31 | pub(crate) fn write_wav(path: impl AsRef, tag: Tag, version: Version) -> Result<(), Error> { 32 | let mut file = BufReader::new(File::open(&path)?); 33 | let mut offset = 0; 34 | // Read all the chunks 35 | let mut riff_chunks = vec![]; 36 | let mut list_chunks = vec![]; 37 | loop { 38 | let chunk = match Chunk::read(&mut file, offset) { 39 | Ok(chunk) => chunk, 40 | Err(_) => break 41 | }; 42 | // Search for parent chunks 43 | if chunk.id() != LIST_ID && chunk.id() != RIFF_ID { 44 | offset += 4; 45 | continue; 46 | } 47 | // Save chunks 48 | for child in chunk.iter(&mut file) { 49 | let child = child?; 50 | if chunk.id() == LIST_ID { 51 | list_chunks.push(child); 52 | } else { 53 | riff_chunks.push(child); 54 | } 55 | } 56 | 57 | offset += chunk.len() as u64; 58 | } 59 | 60 | 61 | // Get all the RIFF chunks 62 | let mut resolved_riff_chunks = vec![]; 63 | for chunk in riff_chunks { 64 | // Resolve LIST chunks 65 | if chunk.id() == LIST_ID { 66 | for child in chunk.iter(&mut file) { 67 | let child = child?; 68 | if child.id() != RIFF_ID && child.id() != LIST_ID { 69 | list_chunks.push(child); 70 | } 71 | } 72 | continue; 73 | } 74 | // Resolve nested (because of SOME apps) 75 | if chunk.id() == RIFF_ID { 76 | for child in chunk.iter(&mut file) { 77 | let child = child?; 78 | if child.id() != RIFF_ID && child.id() != LIST_ID { 79 | resolved_riff_chunks.push(child); 80 | } 81 | } 82 | continue; 83 | } 84 | resolved_riff_chunks.push(chunk); 85 | } 86 | let riff_chunks = resolved_riff_chunks; 87 | 88 | // Generate the RIFF chunk 89 | let mut riff_data = vec![]; 90 | for chunk in riff_chunks { 91 | // skip old ID3 chunk 92 | if chunk.id() == ID3_ID_1 || chunk.id() == ID3_ID_2 { 93 | continue; 94 | } 95 | // Passthru 96 | let data = ChunkContents::Data(chunk.id(), chunk.read_contents(&mut file)?); 97 | riff_data.push(data); 98 | } 99 | // Add ID3 chunk 100 | let mut out = vec![]; 101 | tag.write_to(Cursor::new(&mut out), version)?; 102 | riff_data.push(ChunkContents::Data(ID3_ID_1.clone(), out)); 103 | let riff_chunk = ChunkContents::Children(RIFF_ID.clone(), WAVE_ID.clone(), riff_data); 104 | 105 | // Generate LIST chunk 106 | let mut list_data = vec![]; 107 | for frame in tag.frames() { 108 | if let Some(chunk_id) = ID3_RIFF.get(frame.id()) { 109 | if let Some(text) = frame.content().text() { 110 | let data = ChunkContents::Data(chunk_id.clone(), format!("{text}").as_bytes().to_owned()); 111 | list_data.push(data); 112 | } 113 | } 114 | } 115 | 116 | // Add original LIST chunks 117 | for chunk in list_chunks { 118 | if list_data.iter().any(|c| matches!(c, ChunkContents::Data(id, _) if id == &chunk.id())) { 119 | continue; 120 | } 121 | let data = ChunkContents::Data(chunk.id(), chunk.read_contents(&mut file)?); 122 | list_data.push(data); 123 | } 124 | let list_chunk = ChunkContents::Children(LIST_ID.clone(), INFO_ID.clone(), list_data); 125 | 126 | // Write to file 127 | let mut file = BufWriter::new(File::create(path)?); 128 | riff_chunk.write(&mut file)?; 129 | list_chunk.write(&mut file)?; 130 | 131 | Ok(()) 132 | } 133 | 134 | /// Read WAV from file, will copy missing tags from RIFF to ID3 135 | pub(crate) fn read_wav(path: impl AsRef) -> Result { 136 | let mut file = BufReader::new(File::open(path)?); 137 | let mut offset = 0; 138 | // Read all the chunks 139 | let mut chunks = vec![]; 140 | loop { 141 | let chunk = match Chunk::read(&mut file, offset) { 142 | Ok(chunk) => chunk, 143 | Err(_) => break 144 | }; 145 | // Search for parent chunks 146 | if chunk.id() != LIST_ID && chunk.id() != RIFF_ID { 147 | offset += 4; 148 | continue; 149 | } 150 | for child in chunk.iter(&mut file) { 151 | chunks.push(child?); 152 | } 153 | offset += chunk.len() as u64; 154 | } 155 | 156 | // Resolve nested chunks because SOME apps do that 157 | let mut new_chunks = vec![]; 158 | for chunk in chunks { 159 | // Resolve nested 160 | if chunk.id() == RIFF_ID || chunk.id() == LIST_ID { 161 | for c in chunk.iter(&mut file) { 162 | new_chunks.push(c?); 163 | } 164 | continue; 165 | } 166 | new_chunks.push(chunk); 167 | } 168 | let chunks = new_chunks; 169 | 170 | // Read ID3 using the new ID3 reader 171 | file.seek(SeekFrom::Start(0))?; 172 | let mut id3 = Tag::read_from2(&mut file).unwrap_or(Tag::new()); 173 | 174 | // Copy tags from RIFF to ID3 if missing 175 | for (frame_name, chunk_id) in ID3_RIFF.iter() { 176 | if id3.get(frame_name).is_none() { 177 | if let Some(chunk) = chunks.iter().find(|c| &c.id() == chunk_id) { 178 | let data = chunk.read_contents(&mut file)?; 179 | if let Ok(data) = String::from_utf8(data) { 180 | id3.add_frame(Frame::text(frame_name, data.replace("\0", ""))); 181 | } 182 | } 183 | } 184 | } 185 | 186 | Ok(id3) 187 | } -------------------------------------------------------------------------------- /crates/onetagger-tagger/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "onetagger-tagger" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [dependencies] 9 | log = "0.4" 10 | regex = "1.10" 11 | anyhow = "1.0" 12 | strsim = "0.11" 13 | unidecode = "0.3" 14 | serde_json = "1.0" 15 | 16 | serde = { version = "1.0", features = ["derive"] } 17 | chrono = { version = "0.4", features = ["serde"] } 18 | 19 | onetagger-tag = { path = "../onetagger-tag", default-features = false } 20 | 21 | [features] 22 | default = [] -------------------------------------------------------------------------------- /crates/onetagger-ui/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "onetagger-ui" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | 7 | [dependencies] 8 | log = "0.4" 9 | mime = "0.3" 10 | dunce = "1.0" 11 | trash = "5.1" 12 | image = "0.25" 13 | anyhow = "1.0" 14 | opener = "0.7" 15 | base64 = "0.22" 16 | walkdir = "2.3" 17 | serde_json = "1.0" 18 | webbrowser = "1.0" 19 | mime_guess = "2.0" 20 | urlencoding = "2.1" 21 | include_dir = "0.7" 22 | directories = "5.0" 23 | tinyfiledialogs = "3.9" 24 | 25 | axum = { version = "0.7", features = ["ws"] } 26 | serde = { version = "1.0", features = ["derive"] } 27 | tokio = { version = "1.39", features = ["rt-multi-thread"] } 28 | 29 | onetagger-tag = { path = "../onetagger-tag" } 30 | onetagger-shared = { path = "../onetagger-shared" } 31 | onetagger-tagger = { path = "../onetagger-tagger" } 32 | onetagger-player = { path = "../onetagger-player" } 33 | onetagger-autotag = { path = "../onetagger-autotag" } 34 | onetagger-renamer = { path = "../onetagger-renamer" } 35 | onetagger-playlist = { path = "../onetagger-playlist" } 36 | onetagger-platforms = { path = "../onetagger-platforms" } 37 | 38 | # Windows specific 39 | [target.'cfg(windows)'.dependencies] 40 | sysinfo = "0.31" 41 | 42 | -------------------------------------------------------------------------------- /crates/onetagger-ui/src/lib.rs: -------------------------------------------------------------------------------- 1 | #[macro_use] extern crate log; 2 | #[macro_use] extern crate anyhow; 3 | #[macro_use] extern crate include_dir; 4 | #[macro_use] extern crate onetagger_shared; 5 | 6 | use std::net::SocketAddr; 7 | use std::time::Duration; 8 | use anyhow::Error; 9 | use axum::body::Body; 10 | use axum::extract::{Query, WebSocketUpgrade, State, Request}; 11 | use axum::http::StatusCode; 12 | use axum::http::header::CONTENT_TYPE; 13 | use axum::response::IntoResponse; 14 | use axum::Router; 15 | use axum::routing::get; 16 | use include_dir::Dir; 17 | use onetagger_player::AudioSources; 18 | use serde::{Serialize, Deserialize}; 19 | use quicktag::QuickTagFile; 20 | use tokio::runtime::Builder; 21 | use tokio::net::TcpListener; 22 | use onetagger_shared::{PORT, WEBSERVER_CALLBACKS}; 23 | 24 | pub mod socket; 25 | pub mod browser; 26 | pub mod quicktag; 27 | pub mod tageditor; 28 | 29 | static CLIENT_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../../client/dist"); 30 | 31 | // Should have data from arguments and other flags (eg. port / host in future) 32 | #[derive(Debug, Clone, Serialize, Deserialize)] 33 | #[serde(rename_all = "camelCase")] 34 | pub struct StartContext { 35 | pub server_mode: bool, 36 | pub start_path: Option, 37 | pub expose: bool, 38 | pub browser: bool, 39 | } 40 | 41 | fn start_async_runtime(context: StartContext) -> Result<(), Error> { 42 | let expose = context.expose; 43 | Builder::new_multi_thread().enable_all().build()?.block_on(async move { 44 | // Register routes 45 | let app = Router::new() 46 | .route("/thumb", get(get_thumb)) 47 | .route("/audio", get(get_audio)) 48 | .route("/ws", get(get_ws)) 49 | .route("/spotify", get(get_spotify_callback)) 50 | .route("/*path", get(get_static_file)) 51 | .route("/", get(get_static_file)) 52 | .with_state(context); 53 | 54 | // Start http server 55 | let host = match expose { 56 | true => format!("0.0.0.0:{PORT}"), 57 | false => format!("127.0.0.1:{PORT}") 58 | }; 59 | info!("Starting web server on: http://{host}"); 60 | let listener = TcpListener::bind(host).await?; 61 | axum::serve(listener, app.into_make_service_with_connect_info::()).await?; 62 | 63 | Ok::<(), Error>(()) 64 | })?; 65 | Ok(()) 66 | } 67 | 68 | /// Serve assets file 69 | async fn get_static_file(request: Request) -> impl IntoResponse { 70 | let mut path = request.uri().to_string(); 71 | // Index HTML 72 | if path == "/" { 73 | path = "/index.html".to_string(); 74 | } 75 | path = path[1..].to_string(); 76 | 77 | // Static files 78 | if let Some(file) = CLIENT_DIR.get_file(&path) { 79 | let mime = mime_guess::from_path(&path).first().unwrap_or(mime::APPLICATION_OCTET_STREAM); 80 | return (StatusCode::OK, [(CONTENT_TYPE, mime.to_string())], file.contents().to_vec()); 81 | } 82 | 83 | (StatusCode::NOT_FOUND, [(CONTENT_TYPE, "text/plain".to_string())], "Not found".as_bytes().to_vec()) 84 | } 85 | 86 | #[derive(Debug, Clone, Deserialize)] 87 | struct GetQueryPath { 88 | path: String 89 | } 90 | 91 | /// Serve thumbnail 92 | async fn get_thumb(Query(GetQueryPath { path }): Query) -> impl IntoResponse { 93 | match QuickTagFile::get_art(&path) { 94 | Ok(art) => (StatusCode::OK, [(CONTENT_TYPE, "image/jpeg".to_string())], art), 95 | Err(e) => { 96 | warn!("Error loading album art: {} File: {}", e, path); 97 | (StatusCode::NOT_FOUND, [(CONTENT_TYPE, "text/plain".to_string())], format!("Error loading album art: {} File: {}", e, path).into_bytes()) 98 | } 99 | } 100 | } 101 | 102 | /// Serve audio 103 | async fn get_audio(Query(GetQueryPath { path }): Query) -> impl IntoResponse { 104 | let data = tokio::task::spawn_blocking(move || { 105 | match AudioSources::from_path(&path).map(|s| s.generate_wav()) { 106 | Ok(Ok(wav)) => wav, 107 | Ok(Err(e)) => { 108 | warn!("Failed generating wav: {e}"); 109 | vec![] 110 | }, 111 | Err(e) => { 112 | warn!("Failed opening audio file {path}: {e}"); 113 | vec![] 114 | } 115 | } 116 | }).await.unwrap_or(vec![]); 117 | 118 | // Empty 404 on error 119 | if data.is_empty() { 120 | return (StatusCode::NOT_FOUND, [(CONTENT_TYPE, "text/plain")], vec![]); 121 | } 122 | (StatusCode::OK, [(CONTENT_TYPE, "audio/wav")], data) 123 | } 124 | 125 | /// WS connection 126 | async fn get_ws(ws: WebSocketUpgrade, State(context): State) -> impl IntoResponse { 127 | ws.on_upgrade(move |socket| { 128 | debug!("WS Connected!"); 129 | async move { 130 | match socket::handle_ws_connection(socket, context).await { 131 | Ok(_) => {}, 132 | Err(e) => warn!("WS connection error: {e}"), 133 | } 134 | debug!("WS Disconnected!"); 135 | } 136 | }) 137 | } 138 | 139 | /// Spotify token callback 140 | async fn get_spotify_callback(request: Request) -> impl IntoResponse { 141 | info!("Got Spotify token from callback"); 142 | WEBSERVER_CALLBACKS.lock().unwrap().insert("spotify".to_string(), request.uri().to_string()); 143 | (StatusCode::OK, [(CONTENT_TYPE, "text/html")], include_str!("../../../assets/spotify_callback.html")) 144 | } 145 | 146 | // Start everything 147 | pub fn start_all(context: StartContext) -> Result<(), Error> { 148 | if context.expose { 149 | warn!("Server is exposed to public!"); 150 | } 151 | 152 | // Open in browser with 1s delay to allow the srever to load 153 | if context.browser { 154 | std::thread::spawn(move || { 155 | std::thread::sleep(Duration::from_secs(1)); 156 | webbrowser::open(&format!("http://127.0.0.1:{PORT}")).ok(); 157 | }); 158 | } 159 | 160 | start_async_runtime(context.clone())?; 161 | Ok(()) 162 | } 163 | 164 | -------------------------------------------------------------------------------- /crates/onetagger-ui/src/quicktag.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Error; 2 | use std::path::PathBuf; 3 | use std::collections::HashMap; 4 | use std::fs::read_dir; 5 | use std::path::Path; 6 | use std::io::Cursor; 7 | use walkdir::WalkDir; 8 | use image::{ImageFormat, ImageReader}; 9 | use serde::{Deserialize, Serialize}; 10 | use onetagger_tag::{AudioFileFormat, Field, Tag, EXTENSIONS, TagSeparators}; 11 | use onetagger_playlist::{UIPlaylist, get_files_from_playlist_file}; 12 | 13 | pub struct QuickTag {} 14 | 15 | impl QuickTag { 16 | /// Load all files from folder 17 | pub fn load_files_path(path: impl AsRef, recursive: bool, separators: &TagSeparators, skip: usize, limit: usize) -> Result { 18 | // Check if path to playlist 19 | if !path.as_ref().is_dir() { 20 | return QuickTag::load_files(get_files_from_playlist_file(path)?, separators); 21 | } 22 | 23 | let mut files = vec![]; 24 | // Load recursivly 25 | if recursive { 26 | for e in WalkDir::new(path) { 27 | if let Ok(entry) = e { 28 | // Filter extensions to not make large arrays 29 | if EXTENSIONS.iter().any(|e| entry.path().extension().unwrap_or_default().to_ascii_lowercase() == *e) { 30 | files.push(entry.path().to_owned()); 31 | } 32 | } 33 | } 34 | } else { 35 | // Load just dir 36 | for entry in read_dir(path)? { 37 | // Check if valid 38 | if entry.is_err() { 39 | continue; 40 | } 41 | let entry = entry.unwrap(); 42 | // Skip dirs 43 | if entry.path().is_dir() { 44 | continue; 45 | } 46 | // Filter extensions to not make large arrays 47 | if EXTENSIONS.iter().any(|e| entry.path().extension().unwrap_or_default().to_ascii_lowercase() == *e) { 48 | files.push(entry.path()); 49 | } 50 | } 51 | } 52 | 53 | let files = files.into_iter().skip(skip).take(limit).collect(); 54 | QuickTag::load_files(files, separators) 55 | } 56 | 57 | /// Load all files from playlist 58 | pub fn load_files_playlist(playlist: &UIPlaylist, separators: &TagSeparators) -> Result { 59 | QuickTag::load_files(playlist.get_files()?, separators) 60 | } 61 | 62 | /// Check extension and load file 63 | pub fn load_files(files: Vec, separators: &TagSeparators) -> Result { 64 | let mut out = vec![]; 65 | let mut failed = vec![]; 66 | for path in files { 67 | if EXTENSIONS.iter().any(|e| path.extension().unwrap_or_default().to_ascii_lowercase() == *e) { 68 | match QuickTagFile::from_path(&path, separators) { 69 | Ok(t) => out.push(t), 70 | Err(e) => { 71 | failed.push(QuickTagFailed::new(&path, e.to_string())); 72 | error!("Error loading file: {:?} {}", path, e); 73 | } 74 | } 75 | } 76 | } 77 | Ok(QuickTagData { 78 | files: out, 79 | failed 80 | }) 81 | } 82 | 83 | } 84 | 85 | #[derive(Debug, Clone, Serialize, Deserialize, Default)] 86 | pub struct QuickTagData { 87 | pub files: Vec, 88 | pub failed: Vec 89 | } 90 | 91 | #[derive(Debug, Clone, Serialize, Deserialize)] 92 | pub struct QuickTagFailed { 93 | pub path: PathBuf, 94 | pub error: String 95 | } 96 | 97 | impl QuickTagFailed { 98 | /// Create new instance 99 | pub fn new(path: impl AsRef, error: impl Into) -> QuickTagFailed { 100 | QuickTagFailed { path: path.as_ref().into(), error: error.into() } 101 | } 102 | } 103 | 104 | #[derive(Serialize, Deserialize, Clone, Debug)] 105 | #[serde(rename_all = "camelCase")] 106 | pub struct QuickTagFile { 107 | path: PathBuf, 108 | format: AudioFileFormat, 109 | title: String, 110 | artists: Vec, 111 | genres: Vec, 112 | bpm: Option, 113 | rating: u8, 114 | tags: HashMap>, 115 | year: Option, 116 | key: Option 117 | } 118 | 119 | impl QuickTagFile { 120 | /// Load tags from path 121 | pub fn from_path(path: impl AsRef, separators: &TagSeparators) -> Result { 122 | let mut tag_wrap = Tag::load_file(&path, false)?; 123 | tag_wrap.set_separators(separators); 124 | Ok(QuickTagFile::from_tag(path, &tag_wrap)?) 125 | } 126 | 127 | /// Load tags from `Tag` 128 | pub fn from_tag(path: impl AsRef, tag_wrap: &Tag) -> Result { 129 | let tag = tag_wrap.tag(); 130 | let mut all_tags = tag.all_tags(); 131 | // Insert overriden tags 132 | if let Some(v) = tag.get_raw("COMM") { 133 | all_tags.insert("COMM".to_string(), v); 134 | } 135 | if let Some(v) = tag.get_raw("USLT") { 136 | all_tags.insert("USLT".to_string(), v); 137 | } 138 | 139 | // Filter null bytes 140 | let all_tags = all_tags.into_iter().map(|(k, v)| (k, v.into_iter().map(|v| v.replace("\0", "")).collect::>())).collect(); 141 | 142 | Ok(QuickTagFile { 143 | path: path.as_ref().to_owned(), 144 | format: tag_wrap.format(), 145 | title: tag.get_field(Field::Title).ok_or(anyhow!("Missing title tag"))?.first().ok_or(anyhow!("Missing title"))?.to_string(), 146 | artists: tag.get_field(Field::Artist).ok_or(anyhow!("Missing artist tag"))?, 147 | genres: tag.get_field(Field::Genre).unwrap_or(vec![]), 148 | rating: tag.get_rating().unwrap_or(0), 149 | bpm: match tag.get_field(Field::BPM) { 150 | Some(t) => t.first().unwrap_or(&"can't parse".to_string()).parse().ok(), 151 | None => None 152 | }, 153 | tags: all_tags, 154 | year: tag.get_date().map(|d| d.year), 155 | key: tag.get_field(Field::Key).map(|f| f.first().map(String::from)).flatten() 156 | }) 157 | } 158 | 159 | /// Load album art from tag and downscale 160 | pub fn get_art(path: impl AsRef) -> Result, Error> { 161 | // Load 162 | let tag_wrap = Tag::load_file(path, false)?; 163 | let tag = tag_wrap.tag(); 164 | let pictures = tag.get_art(); 165 | let picture = pictures.first().ok_or(anyhow!("Missing album art!"))?; 166 | let img = ImageReader::new(Cursor::new(&picture.data)).with_guessed_format()?.decode()?; 167 | // Downscale and save 168 | let scaled = img.thumbnail_exact(50, 50); 169 | let mut out = vec![]; 170 | scaled.write_to(&mut Cursor::new(&mut out), ImageFormat::Jpeg)?; 171 | Ok(out) 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /crates/onetagger-ui/src/tageditor.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Error; 2 | use std::io::Cursor; 3 | use std::collections::HashMap; 4 | use std::path::{Path, PathBuf}; 5 | use base64::Engine; 6 | use serde::{Serialize, Deserialize}; 7 | use image::{GenericImageView, ImageReader}; 8 | 9 | use onetagger_tag::{AudioFileFormat, CoverType, Picture, Tag}; 10 | use onetagger_tag::id3::{ID3Comment, ID3Popularimeter}; 11 | 12 | pub struct TagEditor {} 13 | 14 | impl TagEditor { 15 | // Load tags from file 16 | pub fn load_file(path: impl AsRef) -> Result { 17 | let filename = path.as_ref().file_name().ok_or(anyhow!("Invalid filename"))?.to_str().ok_or(anyhow!("Invalid filename!"))?; 18 | let tag_wrap = Tag::load_file(&path, true)?; 19 | let id3_binary = ID3Binary::from_tag(&tag_wrap); 20 | // Load tags 21 | let tag = tag_wrap.tag(); 22 | let tags = tag.all_tags().iter().map(|(k, v)| { 23 | (k.to_owned(), v.join(",").replace('\0', "")) 24 | }).collect(); 25 | 26 | // Load images 27 | let mut images = vec![]; 28 | for picture in tag.get_art() { 29 | if let Ok(art) = TagEditor::load_art(picture) { 30 | images.push(art); 31 | } 32 | } 33 | 34 | Ok(TagEditorFile { 35 | tags, 36 | filename: filename.to_owned(), 37 | format: tag_wrap.format(), 38 | path: path.as_ref().to_owned(), 39 | images, 40 | id3: id3_binary 41 | }) 42 | } 43 | 44 | // Load art and encode 45 | fn load_art(picture: Picture) -> Result { 46 | let img = ImageReader::new(Cursor::new(&picture.data)).with_guessed_format()?.decode()?; 47 | Ok(TagEditorImage { 48 | mime: picture.mime.to_string(), 49 | data: format!("data:{};base64,{}", &picture.mime, base64::engine::general_purpose::STANDARD.encode(picture.data)), 50 | kind: picture.kind.to_owned(), 51 | description: picture.description.to_owned(), 52 | width: img.dimensions().0, 53 | height: img.dimensions().1, 54 | }) 55 | } 56 | } 57 | 58 | #[derive(Debug, Clone, Serialize, Deserialize)] 59 | pub struct FolderEntry { 60 | pub path: PathBuf, 61 | pub filename: String, 62 | pub dir: bool, 63 | pub playlist: bool 64 | } 65 | 66 | #[derive(Debug, Clone, Serialize, Deserialize)] 67 | pub struct TagEditorFile { 68 | pub tags: HashMap, 69 | pub filename: String, 70 | pub format: AudioFileFormat, 71 | pub path: PathBuf, 72 | pub images: Vec, 73 | pub id3: Option 74 | } 75 | 76 | #[derive(Debug, Clone, Serialize, Deserialize)] 77 | pub struct TagEditorImage { 78 | pub mime: String, 79 | pub data: String, 80 | pub kind: CoverType, 81 | pub description: String, 82 | pub width: u32, 83 | pub height: u32 84 | } 85 | 86 | // Binary ID3 tags 87 | #[derive(Debug, Clone, Serialize, Deserialize)] 88 | pub struct ID3Binary { 89 | pub comments: Vec, 90 | pub unsync_lyrics: Vec, 91 | pub popularimeter: Option 92 | } 93 | 94 | impl ID3Binary { 95 | pub fn from_tag(tag: &Tag) -> Option { 96 | match tag { 97 | Tag::ID3(t) => Some(ID3Binary { 98 | comments: t.get_comments(), 99 | unsync_lyrics: t.get_unsync_lyrics(), 100 | popularimeter: t.get_popularimeter() 101 | }), 102 | _ => None 103 | } 104 | } 105 | } -------------------------------------------------------------------------------- /crates/onetagger/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "onetagger" 3 | version = "1.7.0" 4 | edition = "2021" 5 | description = "App to tag your music library." 6 | keywords = ["gui", "audio"] 7 | categories = ["multimedia::audio"] 8 | 9 | [dependencies] 10 | log = "0.4" 11 | anyhow = "1.0" 12 | urlencoding = "2.1" 13 | 14 | tao = { version = "0.29", features = ["rwh_05"] } 15 | wry = { version = "0.42", features = ["devtools"] } 16 | clap = { version = "4.5", features = ["derive"] } 17 | serde = { version = "1.0", features = ["derive"] } 18 | 19 | onetagger-ui = { path = "../onetagger-ui" } 20 | onetagger-tag = { path = "../onetagger-tag" } 21 | onetagger-shared = { path = "../onetagger-shared" } 22 | onetagger-tagger = { path = "../onetagger-tagger" } 23 | onetagger-player = { path = "../onetagger-player" } 24 | onetagger-autotag = { path = "../onetagger-autotag" } 25 | onetagger-renamer = { path = "../onetagger-renamer" } 26 | onetagger-playlist = { path = "../onetagger-playlist" } 27 | onetagger-platforms = { path = "../onetagger-platforms" } 28 | 29 | [target.'cfg(windows)'.build-dependencies] 30 | winres = "0.1" 31 | 32 | # MacOS specific 33 | [target.'cfg(target_os = "macos")'.dependencies] 34 | muda = "0.14" 35 | native-dialog = "0.7.0" 36 | 37 | 38 | [package.metadata.bundle] 39 | name = "OneTagger" 40 | identifier = "com.marekkon5.onetagger" 41 | icon = ["../../assets/32x32.png", "../../assets/128x128.png", "../../assets/128x128@2x.png", 42 | "../../assets/icon.icns", "../../assets/icon.ico"] 43 | version = "1.7.0" 44 | resources = [] 45 | copyright = "Copyright (c) Marekkon5 2022. All rights reserved." 46 | category = "Music" 47 | short_description = "Tool to tag your audio library." 48 | long_description = """ 49 | Tool to tag your audio library. 50 | """ -------------------------------------------------------------------------------- /crates/onetagger/build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | // Set Windows icon 3 | #[cfg(windows)] 4 | { 5 | let mut res = winres::WindowsResource::new(); 6 | res.set_icon("..\\..\\assets\\icon.ico"); 7 | res.compile().unwrap(); 8 | } 9 | } --------------------------------------------------------------------------------