├── .cz.toml ├── .github ├── dependabot.yml └── workflows │ ├── bump.yaml │ ├── deploy.yaml │ └── test.yaml ├── .gitignore ├── CHANGELOG.md ├── Cargo.toml ├── LICENSE ├── README.md ├── clippy.toml ├── examples ├── id3info.rs └── tagdump.rs ├── src ├── chunk.rs ├── error.rs ├── frame │ ├── content.rs │ ├── content_cmp.rs │ ├── mod.rs │ └── timestamp.rs ├── lib.rs ├── storage │ ├── mod.rs │ └── plain.rs ├── stream │ ├── encoding.rs │ ├── frame │ │ ├── content.rs │ │ ├── mod.rs │ │ ├── v2.rs │ │ ├── v3.rs │ │ └── v4.rs │ ├── mod.rs │ ├── tag.rs │ └── unsynch.rs ├── tag.rs ├── taglike.rs ├── tcon.rs ├── v1.rs └── v1v2.rs └── testdata ├── SYLT.mp3 ├── aiff ├── padding.aiff └── quiet.aiff ├── geob_serato.id3 ├── github-issue-147.id3 ├── github-issue-156a.id3 ├── github-issue-156b.id3 ├── github-issue-60.id3 ├── github-issue-73.id3 ├── github-issue-86a.id3 ├── github-issue-86b.id3 ├── github-issue-91.id3 ├── id3v1.id3 ├── id3v22.id3 ├── id3v23.id3 ├── id3v23_chap.id3 ├── id3v23_geob.id3 ├── id3v24.id3 ├── id3v24_ext.id3 ├── image.jpg ├── mpeg-header ├── multi-tags.mp3 ├── picard-2.12.3-id3v23-utf16.id3 ├── picard-2.12.3-id3v24-utf8.id3 ├── quiet.mp3 └── wav ├── tagged-end.wav ├── tagged-mid-corrupted.wav ├── tagged-mid.wav ├── tagless-corrupted-2.wav ├── tagless-corrupted.wav ├── tagless-trailing-data.wav └── tagless.wav /.cz.toml: -------------------------------------------------------------------------------- 1 | [tool.commitizen] 2 | name = "cz_conventional_commits" 3 | version_provider = "scm" 4 | tag_format = "v$version" 5 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: cargo 4 | directory: / 5 | schedule: 6 | interval: monthly 7 | commit-message: 8 | prefix: "chore: " 9 | 10 | - package-ecosystem: github-actions 11 | directory: / 12 | schedule: 13 | interval: monthly 14 | commit-message: 15 | prefix: "chore: " 16 | -------------------------------------------------------------------------------- /.github/workflows/bump.yaml: -------------------------------------------------------------------------------- 1 | name: Bump 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | 8 | ci: 9 | uses: ./.github/workflows/test.yaml 10 | 11 | bump: 12 | runs-on: ubuntu-latest 13 | 14 | needs: 15 | - ci 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | with: 20 | fetch-depth: 0 21 | ssh-key: "${{ secrets.DEPLOY_SSH_KEY }}" 22 | - run: pip3 install Commitizen==3.12.0 setuptools-scm>=8.0 23 | 24 | - run: git config --local user.email "github-actions@users.noreply.github.com" 25 | - run: git config --local user.name "github-actions" 26 | 27 | - name: Get new version 28 | run: echo "NEW_VERSION=$(cz bump --dry-run | grep -Po 'v\K([0-9]+(\.[0-9]+)+)')" >> $GITHUB_ENV 29 | - name: Update Cargo.toml 30 | run: sed -i 's/^version =.\+$/version = "${{ env.NEW_VERSION }}"/' Cargo.toml 31 | - run: cz bump --changelog 32 | 33 | - run: git push 34 | - run: git push --tags 35 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yaml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | 10 | cargo-publish: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | - run: cargo publish --token ${CRATES_TOKEN} 16 | env: 17 | CRATES_TOKEN: ${{ secrets.CRATES_TOKEN }} 18 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | workflow_call: 11 | 12 | # Cancel previous runs for PRs but not pushes to main 13 | concurrency: 14 | group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.head_ref || github.sha }} 15 | cancel-in-progress: true 16 | 17 | env: 18 | CARGO_TERM_COLOR: always 19 | 20 | jobs: 21 | cargo-toml-features: 22 | name: Generate Feature Combinations 23 | runs-on: ubuntu-latest 24 | outputs: 25 | feature-combinations: ${{ steps.cargo-toml-features.outputs.feature-combinations }} 26 | steps: 27 | - name: Check out repository 28 | uses: actions/checkout@v4 29 | - name: Determine Cargo Features 30 | id: cargo-toml-features 31 | uses: Holzhaus/cargo-toml-features-action@3afa751aae4071b2d1ca1c5fa42528a351c995f4 32 | 33 | build: 34 | needs: cargo-toml-features 35 | strategy: 36 | matrix: 37 | os: [ubuntu-latest, macos-latest, windows-latest] 38 | features: ${{ fromJson(needs.cargo-toml-features.outputs.feature-combinations) }} 39 | fail-fast: false 40 | 41 | runs-on: ${{ matrix.os }} 42 | steps: 43 | - name: Check out source repository 44 | uses: actions/checkout@v4 45 | 46 | - name: Install FFmpeg (Ubuntu) 47 | if: runner.os == 'Linux' 48 | run: | 49 | sudo apt update 50 | sudo apt install -y ffmpeg 51 | 52 | - name: Install FFmpeg (macOS) 53 | if: runner.os == 'macOS' 54 | run: | 55 | brew install ffmpeg 56 | 57 | - name: Install FFmpeg (Windows) 58 | if: runner.os == 'Windows' 59 | run: | 60 | choco install ffmpeg 61 | shell: powershell 62 | 63 | - name: Set up Rust toolchain 64 | uses: dtolnay/rust-toolchain@stable 65 | 66 | - name: Cache dependencies 67 | uses: actions/cache@v4 68 | continue-on-error: false 69 | with: 70 | path: | 71 | ~/.cargo/bin/ 72 | ~/.cargo/registry/index/ 73 | ~/.cargo/registry/cache/ 74 | ~/.cargo/git/db/ 75 | target/ 76 | key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} 77 | restore-keys: ${{ runner.os }}-cargo- 78 | 79 | - name: Build 80 | run: cargo build --no-default-features --features "${{ join(matrix.features, ',') }}" 81 | 82 | - name: Run tests 83 | run: cargo test --no-default-features --features "${{ join(matrix.features, ',') }}" --no-fail-fast 84 | 85 | msrv: 86 | name: Current MSRV is 1.70.0 87 | runs-on: ubuntu-latest 88 | steps: 89 | - uses: actions/checkout@v4 90 | # Now check that `cargo build` works with respect to the oldest possible 91 | # deps and the stated MSRV 92 | - uses: dtolnay/rust-toolchain@1.70.0 93 | - run: cargo build --all-features 94 | 95 | style: 96 | runs-on: ubuntu-latest 97 | steps: 98 | - name: Check out source repository 99 | uses: actions/checkout@v4 100 | 101 | - name: Set up Rust toolchain 102 | uses: dtolnay/rust-toolchain@stable 103 | 104 | - name: Cache dependencies 105 | uses: actions/cache@v4 106 | continue-on-error: false 107 | with: 108 | path: | 109 | ~/.cargo/bin/ 110 | ~/.cargo/registry/index/ 111 | ~/.cargo/registry/cache/ 112 | ~/.cargo/git/db/ 113 | target/ 114 | key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} 115 | restore-keys: ${{ runner.os }}-cargo- 116 | 117 | - name: Format 118 | run: cargo fmt --check 119 | 120 | - name: Lint 121 | run: cargo clippy --all-features -- -Dwarnings 122 | 123 | - name: Check for debug macro 124 | run: "! grep -r 'dbg!' ./src" 125 | 126 | conventional-commits: 127 | if: github.event_name == 'pull_request' 128 | runs-on: ubuntu-latest 129 | steps: 130 | - uses: actions/checkout@v4 131 | with: 132 | fetch-depth: 0 133 | - run: pip3 install -U Commitizen 134 | - run: cz check --rev-range origin/${{ github.base_ref }}..HEAD 135 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | Cargo.lock 3 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## v1.16.3 (2025-06-04) 2 | 3 | ### Fix 4 | 5 | - Handle tags with sizes that exceed the file size (fixes #156) 6 | - Issues reported by clippy 7 | 8 | ## v1.16.2 (2025-02-08) 9 | 10 | ### Fix 11 | 12 | - **content**: Decode IPLS/TIPL/TMCL with odd value count gracefully 13 | 14 | ## v1.16.1 (2025-01-22) 15 | 16 | ### Fix 17 | 18 | - MLLT carry subtraction overflow 19 | - MLLT deviation field overflow 20 | - prevent panics from malformed id3 metadata 21 | - ChunkHeader find method overflow 22 | 23 | ## v1.16.0 (2024-11-27) 24 | 25 | ### Feat 26 | 27 | - **examples**: Add Involved People List support to `id3info` 28 | 29 | ### Fix 30 | 31 | - **content**: Only replace `/` in the frames mentioned in the spec 32 | 33 | ## v1.15.0 (2024-11-22) 34 | 35 | ### Feat 36 | 37 | - Add support for IPLS (v2.3) and TIPL/TMCL (v2.4) frames (#141) 38 | - Bump MSRV to 1.70 39 | 40 | ### Fix 41 | 42 | - Do not compare picture frames when decode_picture is disabled 43 | - Fix tests in stream::tag module if "tokio" feature is disabled 44 | - typo in documentation for date_recorded(); (TRDC -> TDRC) (#139) 45 | 46 | ## v1.14.0 (2024-06-30) 47 | 48 | ### Feat 49 | 50 | - Add support for UFID frames (#136) 51 | 52 | ### Fix 53 | 54 | - Warning about unused Storage::reader 55 | - Fixes bug where PrivateFrame data was missing a delimeter null byte between owner_identifier and private data fields (#138) 56 | 57 | ## v1.13.1 (2024-02-26) 58 | 59 | ### Fix 60 | 61 | - Allow MSRV 1.63 with range match usize behavioral change (#129) 62 | 63 | ## v1.13.0 (2024-02-18) 64 | 65 | ### Feat 66 | 67 | - Add Tag::read_from2 that also reads Aiff/Wav 68 | - Let write_to_file handle all supported formats 69 | 70 | ### Refactor 71 | 72 | - Split storage.rs 73 | 74 | ## v1.12.0 (2023-12-30) 75 | 76 | ### Feat 77 | 78 | - Add the no_tag_ok helper function (fixes #122) 79 | 80 | ## v1.11.0 (2023-12-29) 81 | 82 | ### Feat 83 | 84 | - make file-related APIs compatible with io::Cursor (#121) 85 | 86 | ### Refactor 87 | 88 | - kill redundant array copy (#118) 89 | 90 | ## v1.10.0 (2023-11-16) 91 | 92 | ### Feat 93 | 94 | - Add write_to_file, for encoding to MP3 files and buffers (#117) 95 | 96 | ### Fix 97 | 98 | - **v1**: Unwrap in remove_from_path 99 | - io::Cursor resize should zero-fill 100 | 101 | ## v1.9.0 (2023-10-24) 102 | 103 | ### Feat 104 | 105 | - Added support for Table of contents frame (CTOC) (#116) 106 | 107 | ## v1.8.0 (2023-09-21) 108 | 109 | ### Feat 110 | 111 | - Added support for Private Frames (PRIV) 112 | 113 | ## v1.7.0 (2023-04-13) 114 | 115 | ### Feat 116 | 117 | - Allow disabling expensive picture decoding 118 | 119 | ## v1.6.0 (2023-01-01) 120 | 121 | ### Feat 122 | 123 | - Add Default to Timestamp 124 | 125 | ## v1.5.1 (2022-12-08) 126 | 127 | ### Fix 128 | 129 | - modify Content::unique() so that duplicates of frames with unknown type but identical ID are allowed 130 | - add missing conversation method for Popularimeter in Content 131 | 132 | ## v1.5.0 (2022-11-16) 133 | 134 | ### Feat 135 | 136 | - Adds support for reading/writing TDOR frames 137 | 138 | ## v1.4.0 (2022-10-30) 139 | 140 | ### Feat 141 | 142 | - Add tokio support for parsing (#102) 143 | 144 | ## v1.3.0 (2022-08-05) 145 | 146 | ### Feat 147 | 148 | - Store encoding in Frame for TXXX and GEOB (fixes #97) 149 | 150 | ## v1.2.1 (2022-06-28) 151 | 152 | ### Fix 153 | 154 | - Support Serato GEOB (#96) 155 | 156 | ## v1.2.0 (2022-06-10) 157 | 158 | ### Feat 159 | 160 | - Add the v1v2 module for simultaneously handling ID3v1+ID3v2 tags (fixes #92) 161 | - Add the genre_parsed method (fixes #88) 162 | 163 | ## v1.1.4 (2022-06-09) 164 | 165 | ### Fix 166 | 167 | - Remove dbg! prints 168 | 169 | ## v1.1.3 (2022-06-05) 170 | 171 | ### Fix 172 | 173 | - Increase storage copy buffer size to 2^16 bytes (fixes #94) 174 | - Require bitflags >1.3 (fixes #93) 175 | 176 | ## v1.1.2 (2022-06-01) 177 | 178 | ### Fix 179 | 180 | - Fix reading of tags with extended headers (fixes #91) 181 | 182 | ## Version 1.1.1 183 | 184 | * Fix wrong implementation of unsynchronization for ID3v2.3 185 | * Permit unknown frame header flag bits to be set 186 | * error: Include problematic data in str::Utf8Error derivative error 187 | * Fix typos in Content docs 188 | 189 | ## Version 1.1.0 190 | 191 | * Add partial_tag_ok 192 | * Add helpers to decode multiple artists/genre (when a file has some) (#87) 193 | 194 | ## Version 1.0.3 195 | 196 | * Translate text frame nulls to '/' (fixes #82) 197 | * Fix chunk length when creating new ID3 for AIFF files (#83) 198 | 199 | ## Version 1.0.2 200 | 201 | * Fix GRP1 frames from being erroneously rejected as invalid (#78). 202 | 203 | ## Version 1.0.1 204 | 205 | * Fix missing description field and incorrect text encoding in SYLT content. 206 | 207 | # Version 1.0 208 | 209 | This is the first stable release of rust-id3! This release adds a few new features but mainly 210 | focusses on forward compatibility to allow for easier maintenance in the future. 211 | 212 | ## Breaking changes 213 | 214 | The functions for writing and reading tags in WAV/AIFF containers have been renamed: 215 | 216 | * `Tag::read_from_aiff_reader(reader: impl io::Read + io::Seek)` -> `Tag::read_from_aiff(reader: impl io::Read + io::Seek)` 217 | * `Tag::read_from_aiff(path: impl AsRef)` -> `Tag::read_from_aiff_path(path: impl AsRef)` 218 | * `Tag::read_from_wav_reader(reader: impl io::Read + io::Seek)` -> `Tag::read_from_wav(reader: impl io::Read + io::Seek)` 219 | * `Tag::read_from_wav(path: impl AsRef)` -> `Tag::read_from_wav_path(path: impl AsRef)` 220 | * `Tag::write_to_aiff(&self, path: impl AsRef, version: Version)` -> `Tag::write_to_aiff_path(&self, path: impl AsRef, version: Version)` 221 | * `Tag::write_to_wav(&self, path: impl AsRef, version: Version)` -> `Tag::write_to_wav_path(&self, path: impl AsRef, version: Version)` 222 | 223 | The implementation for PartialEq, Eq and Hash has changed for `Frame` and `Content`. The new 224 | implementations are implemented by Rust's derive macro. 225 | 226 | For errors: 227 | * The implementation for `Error::description` has been removed as it has been deprecated. 228 | * Merge ErrorKind::UnsupportedVersion into UnsupportedFeature 229 | * The description field changed from a `&'static str` to String to permit more useful messages 230 | 231 | The variant names of the TimestampFormat now adhere to Rust naming conventions. 232 | 233 | The majority of the Tag functions for mutating and retrieving frames have been moved to the new 234 | `TagLike` trait. This trait is implemented for `Tag` and `Chapter`, making it possible to use these 235 | functions for both types. As is required by Rust's trait rules, you must now `use id3::TagLike` to 236 | use the functions in this trait. 237 | 238 | ## Compatibility note regarding custom frame decoders 239 | 240 | It is a common use case to write custom frame content decoders by matching on the `Unknown` content 241 | type. However, implementing support for such frames in rust-id3 was always a breaking change. This 242 | was due to 2 reasons: 243 | 244 | * `Content` gains a new variant which breaks exhaustive match expressions 245 | * Frames that previously decoded to `Unknown` now decode to the new content type, which breaks 246 | custom decoders that expect it to be `Unknown` 247 | 248 | To ensure that new frames can be added in the future without breaking compatibility, Content has 249 | been marked as non_exhaustive and a new `Content::to_unknown` function has been added. This function 250 | returns the `Unknown` variant if present or encodes an ad-hoc copy. This way, custom decoders will 251 | not silently break. 252 | 253 | ## New Features 254 | * Add support for MPEG Location Lookup Table (MLLT) frames 255 | * Add support for Chapter (CHAP) frames, containing frames by themselves 256 | * Add support for Popularimeter (POPM) frames 257 | 258 | ## Miscellaneous Changes 259 | * Prevent unrepresentable frames from being written 260 | * Set Rust edition to 2021 261 | * Doc tests should not unwrap() 262 | * Fix SYLT (#74) 263 | * Rewrite frame content encoders and decoders 264 | * Let TagLike::remove return the removed frames 265 | * Derive Ord and PartialOrd for eligible types 266 | * Implement fmt::Display for Version 267 | * Implement Extend for Tag, Chapter and FromIterator for Tag 268 | * Implement adding frames based on `Into` 269 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "id3" 3 | version = "1.16.3" 4 | edition = "2021" 5 | authors = [ 6 | "polyfloyd ", 7 | ] 8 | license = "MIT" 9 | readme = "README.md" 10 | repository = "https://github.com/polyfloyd/rust-id3" 11 | description = "A library for reading and writing ID3 metadata" 12 | keywords = ["id3", "mp3", "wav", "aiff", "metadata"] 13 | categories = ["encoding", "multimedia", "multimedia::audio", "parser-implementations",] 14 | include = ["src/*", "Cargo.toml", "LICENSE", "README.md"] 15 | 16 | [dependencies] 17 | bitflags = "2.0" 18 | byteorder = "1.4" 19 | flate2 = "1" 20 | tokio = { version = "1.21", default-features = false, features = ["rt", "macros", "io-util", "fs"], optional = true} 21 | 22 | [dev-dependencies] 23 | tempfile = "3" 24 | 25 | [features] 26 | default = ["decode_picture"] 27 | 28 | ## Support parsing ID3 tags with Tokio 29 | tokio = ["dep:tokio"] 30 | 31 | ## Picture decoding takes ~20% of time. Allow disabling it if it's unneeded. 32 | decode_picture = [] 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014-2017 James Hurst 4 | 2017 polyfloyd 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy of 7 | this software and associated documentation files (the "Software"), to deal in 8 | the Software without restriction, including without limitation the rights to 9 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 10 | the Software, and to permit persons to whom the Software is furnished to do so, 11 | subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 18 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 19 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 20 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 21 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # rust-id3 2 | 3 | [![Crate](https://img.shields.io/crates/v/id3.svg)](https://crates.io/crates/id3) 4 | [![Documentation](https://docs.rs/id3/badge.svg)](https://docs.rs/id3/) 5 | 6 | A library for reading and writing ID3 metadata. 7 | 8 | ## Implemented Features 9 | 10 | * ID3v1 reading 11 | * ID3v2.2, ID3v2.3, ID3v2.4 reading/writing 12 | * MP3, WAV and AIFF files 13 | * Latin1, UTF16 and UTF8 encodings 14 | * Text frames 15 | * Extended Text frames 16 | * Link frames 17 | * Extended Link frames 18 | * Comment frames 19 | * Lyrics frames 20 | * Synchronised Lyrics frames 21 | * Picture frames 22 | * Encapsulated Object frames 23 | * Chapter frames 24 | * Unsynchronisation 25 | * Compression 26 | * MPEG Location Lookup Table frames 27 | * Unique File Identifier frames 28 | * Involved People List frames 29 | * Tag and File Alter Preservation bits 30 | 31 | ## Examples 32 | 33 | ### Reading tag frames 34 | 35 | ```rust 36 | use id3::{Tag, TagLike}; 37 | 38 | fn main() -> Result<(), Box> { 39 | let tag = Tag::read_from_path("testdata/id3v24.id3")?; 40 | 41 | // Get a bunch of frames... 42 | if let Some(artist) = tag.artist() { 43 | println!("artist: {}", artist); 44 | } 45 | if let Some(title) = tag.title() { 46 | println!("title: {}", title); 47 | } 48 | if let Some(album) = tag.album() { 49 | println!("album: {}", album); 50 | } 51 | 52 | // Get frames before getting their content for more complex tags. 53 | if let Some(artist) = tag.get("TPE1").and_then(|frame| frame.content().text()) { 54 | println!("artist: {}", artist); 55 | } 56 | Ok(()) 57 | } 58 | ``` 59 | 60 | ### Modifying any existing tag 61 | 62 | ```rust 63 | use id3::{Error, ErrorKind, Tag, TagLike, Version}; 64 | use std::fs::copy; 65 | 66 | fn main() -> Result<(), Box> { 67 | let temp_file = std::env::temp_dir().join("music.mp3"); 68 | copy("testdata/quiet.mp3", &temp_file)?; 69 | 70 | let mut tag = match Tag::read_from_path(&temp_file) { 71 | Ok(tag) => tag, 72 | Err(Error{kind: ErrorKind::NoTag, ..}) => Tag::new(), 73 | Err(err) => return Err(Box::new(err)), 74 | }; 75 | 76 | tag.set_album("Fancy Album Title"); 77 | 78 | tag.write_to_path(temp_file, Version::Id3v24)?; 79 | Ok(()) 80 | } 81 | ``` 82 | 83 | ### Creating a new tag, overwriting any old tag 84 | 85 | ```rust 86 | use id3::{Tag, TagLike, Frame, Version}; 87 | use id3::frame::Content; 88 | use std::fs::copy; 89 | 90 | fn main() -> Result<(), Box> { 91 | let temp_file = std::env::temp_dir().join("music.mp3"); 92 | copy("testdata/quiet.mp3", &temp_file)?; 93 | 94 | let mut tag = Tag::new(); 95 | tag.set_album("Fancy Album Title"); 96 | 97 | // Set the album the hard way. 98 | tag.add_frame(Frame::text("TALB", "album")); 99 | 100 | tag.write_to_path(temp_file, Version::Id3v24)?; 101 | Ok(()) 102 | } 103 | ``` 104 | 105 | ### Handling damaged or files without a tag 106 | 107 | ```rust 108 | use id3::{Tag, TagLike, partial_tag_ok, no_tag_ok}; 109 | 110 | fn main() -> Result<(), Box> { 111 | let tag_result = Tag::read_from_path("testdata/id3v24.id3"); 112 | 113 | // A partially decoded tag is set on the Err. partial_tag_ok takes it out and maps it to Ok. 114 | let tag_result = partial_tag_ok(tag_result); 115 | 116 | // no_tag_ok maps the NoTag error variant and maps it to Ok(None). 117 | let tag_result = no_tag_ok(tag_result); 118 | 119 | if let Some(tag) = tag_result? { 120 | // .. 121 | } 122 | 123 | Ok(()) 124 | } 125 | ``` 126 | 127 | ## Contributing 128 | 129 | Do you think you have found a bug? Then please report it via the GitHub issue tracker. Make sure to 130 | attach any problematic files that can be used to reproduce the issue. Such files are also used to 131 | create regression tests that ensure that your bug will never return. 132 | 133 | When submitting pull requests, please prefix your commit messages with `fix:` or `feat:` for bug 134 | fixes and new features respectively. This is the 135 | [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) scheme that is used to 136 | automate some maintenance chores such as generating the changelog and inferring the next version 137 | number. 138 | 139 | ## Running tests 140 | 141 | Tests require `ffprobe` (part of ffmpeg) to be present in $PATH. 142 | 143 | ```shell 144 | cargo test --all-features 145 | ``` 146 | -------------------------------------------------------------------------------- /clippy.toml: -------------------------------------------------------------------------------- 1 | doc-valid-idents = ["ID3v1", "ID3v2"] 2 | -------------------------------------------------------------------------------- /examples/id3info.rs: -------------------------------------------------------------------------------- 1 | //! Read an ID3 tag from a file and print frame information to the command line. 2 | 3 | use id3::{ 4 | frame::{ 5 | Chapter, Comment, EncapsulatedObject, ExtendedLink, ExtendedText, InvolvedPeopleList, 6 | InvolvedPeopleListItem, Lyrics, Picture, Popularimeter, SynchronisedLyrics, 7 | UniqueFileIdentifier, 8 | }, 9 | Content, Tag, 10 | }; 11 | use std::env::args; 12 | use std::error::Error; 13 | use std::fs::File; 14 | 15 | fn main() -> Result<(), Box> { 16 | let Some(path) = args().nth(1) else { 17 | return Err("No path specified!".into()); 18 | }; 19 | 20 | let file = File::open(&path)?; 21 | let tag = Tag::read_from2(file)?; 22 | let frame_count = tag.frames().count(); 23 | println!( 24 | "# ID3 {version} - {frame_count} frames", 25 | version = tag.version() 26 | ); 27 | for frame in tag.frames() { 28 | let id = frame.id(); 29 | match frame.content() { 30 | Content::Text(value) | Content::Link(value) => { 31 | println!("{id}={value:?}"); 32 | } 33 | Content::ExtendedText(ExtendedText { description, value }) => { 34 | println!("{id}:{description}={value:?}"); 35 | } 36 | Content::ExtendedLink(ExtendedLink { description, link }) => { 37 | println!("{id}:{description}={link:?}"); 38 | } 39 | Content::Comment(Comment { 40 | lang, 41 | description, 42 | text, 43 | }) => { 44 | println!("{id}:{description}[{lang}]={text:?}"); 45 | } 46 | Content::Popularimeter(Popularimeter { 47 | user, 48 | rating, 49 | counter, 50 | }) => { 51 | println!("{id}:{user}[{counter}]={rating:?}"); 52 | } 53 | Content::Lyrics(Lyrics { 54 | lang, 55 | description, 56 | text, 57 | }) => { 58 | println!("{id}:{description}[{lang}]={text:?}"); 59 | } 60 | Content::SynchronisedLyrics(SynchronisedLyrics { 61 | lang, 62 | timestamp_format, 63 | content_type, 64 | description, 65 | content, 66 | }) => { 67 | println!( 68 | "{id}:{description}[{lang}] ({timestamp_format}, {content_type})={content:?}" 69 | ); 70 | } 71 | Content::Picture(Picture { 72 | mime_type, 73 | picture_type, 74 | description, 75 | data, 76 | }) => { 77 | let size = data.len(); 78 | println!("{id}:{picture_type}="); 79 | } 80 | Content::EncapsulatedObject(EncapsulatedObject { 81 | mime_type, 82 | filename, 83 | description, 84 | data, 85 | }) => { 86 | let size = data.len(); 87 | println!("{id}:{description}="); 88 | } 89 | Content::Chapter(Chapter { 90 | element_id, 91 | start_time, 92 | end_time, 93 | start_offset, 94 | end_offset, 95 | frames, 96 | }) => { 97 | let chapter_frame_count = frames.len(); 98 | println!("{id}:{element_id}="); 99 | } 100 | Content::UniqueFileIdentifier(UniqueFileIdentifier { 101 | owner_identifier, 102 | identifier, 103 | }) => { 104 | let value = identifier 105 | .iter() 106 | .map(|&byte| { 107 | char::from_u32(byte.into()) 108 | .map(|c| String::from(c)) 109 | .unwrap_or_else(|| format!("\\x{:02X}", byte)) 110 | }) 111 | .collect::(); 112 | println!("{id}:{owner_identifier}=b\"{value}\""); 113 | } 114 | Content::InvolvedPeopleList(InvolvedPeopleList { items }) => { 115 | if items.len() == 0 { 116 | println!("{id}="); 117 | } else { 118 | for InvolvedPeopleListItem { 119 | involvement, 120 | involvee, 121 | } in items 122 | { 123 | println!("{id}:{involvement}={involvee:?}"); 124 | } 125 | } 126 | } 127 | content => { 128 | println!("{id}={content:?}"); 129 | } 130 | } 131 | } 132 | Ok(()) 133 | } 134 | -------------------------------------------------------------------------------- /examples/tagdump.rs: -------------------------------------------------------------------------------- 1 | //! This is not actually an example that uses the `id3` crate, but helps dumping entire ID3 tags 2 | //! from MP3 files to stdout. These files can then be used as testdata for this crate. 3 | 4 | use std::env::args; 5 | use std::error::Error; 6 | use std::fs::File; 7 | use std::io::{self, Read, Write}; 8 | 9 | const CHUNK_SIZE: usize = 2048; 10 | 11 | fn main() -> Result<(), Box> { 12 | let Some(path) = args().nth(1) else { 13 | return Err("No path specified!".into()); 14 | }; 15 | 16 | let mut file = File::open(&path)?; 17 | 18 | let mut header = [0u8; 10]; 19 | file.read_exact(&mut header)?; 20 | assert!(&header[..3] == b"ID3"); 21 | let tag_size: u32 = u32::from(header[9]) 22 | | u32::from(header[8]) << 7 23 | | u32::from(header[7]) << 14 24 | | u32::from(header[6]) << 21; 25 | eprintln!("Tag size: {tag_size}"); 26 | let has_footer = (header[5] & 0x10) != 0; 27 | 28 | let mut bytes_left: usize = tag_size.try_into()?; 29 | if has_footer { 30 | // Footer is present, add 10 bytes to the size. 31 | eprintln!("Footer: yes"); 32 | bytes_left += 10; 33 | } else { 34 | eprintln!("Footer: no"); 35 | } 36 | 37 | eprintln!("Writing {bytes_left} bytes to stdout..."); 38 | let mut stdout = io::stdout().lock(); 39 | stdout.write_all(&header)?; 40 | 41 | let mut buffer: [u8; CHUNK_SIZE] = [0; CHUNK_SIZE]; 42 | while bytes_left > 0 { 43 | let bytes_to_read = bytes_left.min(CHUNK_SIZE); 44 | file.read_exact(&mut buffer[..bytes_to_read])?; 45 | stdout.write_all(&buffer[..bytes_to_read])?; 46 | bytes_left -= bytes_to_read; 47 | } 48 | stdout.flush()?; 49 | eprintln!("Done."); 50 | 51 | Ok(()) 52 | } 53 | -------------------------------------------------------------------------------- /src/chunk.rs: -------------------------------------------------------------------------------- 1 | use crate::storage::{plain::PlainStorage, Storage}; 2 | use crate::stream; 3 | use crate::{Error, ErrorKind, StorageFile, Tag, Version}; 4 | use byteorder::{BigEndian, ByteOrder, LittleEndian}; 5 | use std::convert::TryFrom; 6 | use std::fmt; 7 | use std::io::prelude::*; 8 | use std::io::{BufReader, Seek, SeekFrom}; 9 | use std::{convert::TryInto, io}; 10 | 11 | const TAG_LEN: u32 = 4; // Size of a tag. 12 | const SIZE_LEN: u32 = 4; // Size of a 32 bits integer. 13 | const CHUNK_HEADER_LEN: u32 = TAG_LEN + SIZE_LEN; 14 | 15 | const ID3_TAG: ChunkTag = ChunkTag(*b"ID3 "); 16 | 17 | /// Attempts to load a ID3 tag from the given chunk stream. 18 | pub fn load_id3_chunk(mut reader: R) -> crate::Result 19 | where 20 | F: ChunkFormat, 21 | R: io::Read + io::Seek, 22 | { 23 | let root_chunk = ChunkHeader::read_root_chunk_header::(&mut reader)?; 24 | 25 | // Prevent reading past the root chunk, as there may be non-standard trailing data. 26 | let eof = root_chunk 27 | .size 28 | .checked_sub(TAG_LEN) // We must disconsider the format tag that was already read. 29 | .ok_or_else(|| Error::new(ErrorKind::InvalidInput, "Invalid root chunk size"))?; 30 | 31 | let tag_chunk = ChunkHeader::find_id3::(&mut reader, eof.into())?; 32 | let chunk_reader = reader.take(tag_chunk.size.into()); 33 | stream::tag::decode(chunk_reader) 34 | } 35 | 36 | /// Writes a tag to the given file. If the file contains no previous tag data, a new ID3 37 | /// chunk is created. Otherwise, the tag is overwritten in place. 38 | pub fn write_id3_chunk_file( 39 | mut file: impl StorageFile, 40 | tag: &Tag, 41 | version: Version, 42 | ) -> crate::Result<()> { 43 | // Locate relevant chunks: 44 | let (mut root_chunk, id3_chunk_option) = locate_relevant_chunks::(&mut file)?; 45 | 46 | let root_chunk_pos = SeekFrom::Start(0); 47 | let id3_chunk_pos; 48 | let mut id3_chunk; 49 | 50 | // Prepare and write the chunk: 51 | // We must scope the writer to be able to seek back and update the chunk sizes later. 52 | { 53 | let mut storage; 54 | let mut writer; 55 | let mut offset = 0; 56 | 57 | // If there is a ID3 chunk, use it. Otherwise, create one. 58 | id3_chunk = if let Some(chunk) = id3_chunk_option { 59 | let id3_tag_pos = file.stream_position()?; 60 | let id3_tag_end_pos = id3_tag_pos 61 | .checked_add(chunk.size.into()) 62 | .ok_or_else(|| Error::new(ErrorKind::InvalidInput, "Invalid ID3 chunk size"))?; 63 | 64 | id3_chunk_pos = SeekFrom::Start( 65 | id3_tag_pos 66 | .checked_sub(CHUNK_HEADER_LEN.into()) 67 | .expect("failed to calculate id3 chunk position"), 68 | ); 69 | 70 | storage = PlainStorage::new(&mut file, id3_tag_pos..id3_tag_end_pos); 71 | writer = storage.writer()?; 72 | 73 | // As we'll overwrite the existing tag, we must subtract it's size and sum the 74 | // new size later. 75 | root_chunk.size = root_chunk 76 | .size 77 | .checked_sub(chunk.size) 78 | .ok_or_else(|| Error::new(ErrorKind::InvalidInput, "Invalid root chunk size"))?; 79 | 80 | chunk 81 | } else { 82 | let pos = file.stream_position()?; 83 | 84 | id3_chunk_pos = SeekFrom::Start(pos); 85 | 86 | storage = PlainStorage::new(&mut file, pos..pos); 87 | writer = storage.writer()?; 88 | 89 | // Create a new empty chunk at the end of the file: 90 | let chunk = ChunkHeader { 91 | tag: ID3_TAG, 92 | size: 0, 93 | }; 94 | 95 | chunk.write_to::(&mut writer)?; 96 | 97 | // Update the riff chunk size: 98 | root_chunk.size = root_chunk 99 | .size 100 | .checked_add(CHUNK_HEADER_LEN) 101 | .ok_or_else(|| { 102 | Error::new(ErrorKind::InvalidInput, "root chunk max size reached") 103 | })?; 104 | 105 | // The AIFF header shouldn't be included in the chunk length 106 | offset = CHUNK_HEADER_LEN; 107 | 108 | chunk 109 | }; 110 | 111 | // Write the tag: 112 | tag.write_to(&mut writer, version)?; 113 | 114 | id3_chunk.size = writer 115 | .stream_position()? 116 | .try_into() 117 | .expect("ID3 chunk max size reached"); 118 | id3_chunk.size -= offset; 119 | 120 | // Add padding if necessary. 121 | if id3_chunk.size % 2 == 1 { 122 | let padding = [0]; 123 | writer.write_all(&padding)?; 124 | id3_chunk.size = id3_chunk 125 | .size 126 | .checked_add(padding.len() as u32) 127 | .ok_or_else(|| Error::new(ErrorKind::InvalidInput, "ID3 chunk max size reached"))?; 128 | } 129 | 130 | // We must flush manually to prevent silecing write errors. 131 | writer.flush()?; 132 | } 133 | 134 | // Update chunk sizes in the file: 135 | 136 | file.seek(id3_chunk_pos)?; 137 | id3_chunk.write_to::(&mut file)?; 138 | 139 | root_chunk.size = root_chunk 140 | .size 141 | .checked_add(id3_chunk.size) 142 | .ok_or_else(|| Error::new(ErrorKind::InvalidInput, "root chunk max size reached"))?; 143 | 144 | file.seek(root_chunk_pos)?; 145 | root_chunk.write_to::(file)?; 146 | 147 | Ok(()) 148 | } 149 | 150 | /// Locates the root and ID3 chunks, returning their headers. The ID3 chunk may not be 151 | /// present. Returns a pair of (root chunk header, ID3 header). 152 | fn locate_relevant_chunks(mut input: R) -> crate::Result<(ChunkHeader, Option)> 153 | where 154 | F: ChunkFormat, 155 | R: Read + Seek, 156 | { 157 | let mut reader = BufReader::new(&mut input); 158 | 159 | let root_chunk = ChunkHeader::read_root_chunk_header::(&mut reader)?; 160 | 161 | // Prevent reading past the root chunk, as there may be non-standard trailing data. 162 | let eof = root_chunk 163 | .size 164 | .checked_sub(TAG_LEN) // We must disconsider the WAVE tag that was already read. 165 | .ok_or_else(|| Error::new(ErrorKind::InvalidInput, "Invalid root chunk size"))?; 166 | 167 | let id3_chunk = match ChunkHeader::find_id3::(&mut reader, eof.into()) { 168 | Ok(chunk) => Some(chunk), 169 | Err(Error { 170 | kind: ErrorKind::NoTag, 171 | .. 172 | }) => None, 173 | Err(error) => return Err(error), 174 | }; 175 | 176 | // BufReader may read past the id3 chunk. To seek back to the chunk, we must first 177 | // drop the BufReader, and then seek. 178 | let pos = reader.stream_position()?; 179 | drop(reader); 180 | input.seek(SeekFrom::Start(pos))?; 181 | 182 | Ok((root_chunk, id3_chunk)) 183 | } 184 | 185 | #[derive(Debug, Clone, Copy, Eq)] 186 | pub struct ChunkTag(pub [u8; TAG_LEN as usize]); 187 | 188 | /// Equality for chunk tags is case insensitive. 189 | impl PartialEq for ChunkTag { 190 | fn eq(&self, other: &Self) -> bool { 191 | self.0.eq_ignore_ascii_case(&other.0) 192 | } 193 | } 194 | 195 | impl TryFrom<&[u8]> for ChunkTag { 196 | type Error = std::array::TryFromSliceError; 197 | 198 | fn try_from(tag: &[u8]) -> Result { 199 | let tag = tag.try_into()?; 200 | Ok(Self(tag)) 201 | } 202 | } 203 | 204 | pub trait ChunkFormat { 205 | type Endianness: ByteOrder; 206 | const ROOT_TAG: ChunkTag; 207 | const ROOT_FORMAT: Option; 208 | } 209 | 210 | #[derive(Debug)] 211 | pub struct AiffFormat; 212 | 213 | impl ChunkFormat for AiffFormat { 214 | type Endianness = BigEndian; 215 | 216 | const ROOT_TAG: ChunkTag = ChunkTag(*b"FORM"); 217 | // AIFF may have many formats, beign AIFF and AIFC the most common. Technically, it 218 | // can be anything, so we won't check those. 219 | const ROOT_FORMAT: Option = None; 220 | } 221 | 222 | #[derive(Debug)] 223 | pub struct WavFormat; 224 | 225 | impl ChunkFormat for WavFormat { 226 | type Endianness = LittleEndian; 227 | 228 | const ROOT_TAG: ChunkTag = ChunkTag(*b"RIFF"); 229 | const ROOT_FORMAT: Option = Some(ChunkTag(*b"WAVE")); 230 | } 231 | 232 | #[derive(Clone, Copy, PartialEq, Eq)] 233 | struct ChunkHeader { 234 | tag: ChunkTag, 235 | size: u32, 236 | } 237 | 238 | impl ChunkHeader { 239 | /// Reads a root chunk from the input stream. Such header is composed of: 240 | /// 241 | /// | Field | Size | Type | 242 | /// |---------+------+-----------------| 243 | /// | tag | 4 | ChunkTag | 244 | /// | size | 4 | 32 bits integer | 245 | /// | format | 4 | ChunkTag | 246 | pub fn read_root_chunk_header(mut reader: R) -> crate::Result 247 | where 248 | F: ChunkFormat, 249 | R: io::Read, 250 | { 251 | let invalid_header_error = Error::new(ErrorKind::InvalidInput, "invalid chunk header"); 252 | 253 | const BUFFER_SIZE: usize = (CHUNK_HEADER_LEN + TAG_LEN) as usize; 254 | 255 | let mut buffer = [0; BUFFER_SIZE]; 256 | 257 | // Use a single read call to improve performance on unbuffered readers. 258 | reader.read_exact(&mut buffer)?; 259 | 260 | let tag = buffer[0..4] 261 | .try_into() 262 | .expect("slice with incorrect length"); 263 | 264 | let size = F::Endianness::read_u32(&buffer[4..8]); 265 | 266 | if tag != F::ROOT_TAG { 267 | return Err(invalid_header_error); 268 | } 269 | 270 | let chunk_format: ChunkTag = buffer[8..12] 271 | .try_into() 272 | .expect("slice with incorrect length"); 273 | 274 | if let Some(format_tag) = F::ROOT_FORMAT { 275 | if chunk_format != format_tag { 276 | return Err(invalid_header_error); 277 | } 278 | } 279 | 280 | Ok(Self { tag, size }) 281 | } 282 | 283 | /// Reads a chunk header from the input stream. A header is composed of: 284 | /// 285 | /// | Field | Size | Value | 286 | /// |-------+------+-----------------| 287 | /// | tag | 4 | chunk type | 288 | /// | size | 4 | 32 bits integer | 289 | pub fn read(mut reader: R) -> io::Result 290 | where 291 | F: ChunkFormat, 292 | R: io::Read, 293 | { 294 | const BUFFER_SIZE: usize = CHUNK_HEADER_LEN as usize; 295 | 296 | let mut header = [0; BUFFER_SIZE]; 297 | 298 | // Use a single read call to improve performance on unbuffered readers. 299 | reader.read_exact(&mut header)?; 300 | 301 | let tag = header[0..4] 302 | .try_into() 303 | .expect("slice with incorrect length"); 304 | 305 | let size = F::Endianness::read_u32(&header[4..8]); 306 | 307 | Ok(Self { tag, size }) 308 | } 309 | 310 | /// Finds an ID3 chunk in a flat sequence of chunks. This should be called after reading 311 | /// the root chunk. 312 | /// 313 | /// # Arguments 314 | /// 315 | /// * `reader`: The input stream. The reader must be positioned right after the root chunk 316 | /// header. 317 | /// * `end`: The stream position where the chunk sequence ends. This is used to prevent 318 | /// searching past the end. 319 | pub fn find_id3(reader: R, end: u64) -> crate::Result 320 | where 321 | F: ChunkFormat, 322 | R: io::Read + io::Seek, 323 | { 324 | Self::find::(&ID3_TAG, reader, end)? 325 | .ok_or_else(|| Error::new(ErrorKind::NoTag, "No tag chunk found!")) 326 | } 327 | 328 | /// Finds a chunk in a flat sequence of chunks. This won't search chunks recursively. 329 | /// 330 | /// # Arguments 331 | /// 332 | /// * `tag`: The chunk tag to search for. 333 | /// * `reader`: The input stream. The reader must be positioned at the start of a sequence of 334 | /// chunks. 335 | /// * `end`: The stream position where the chunk sequence ends. This is used to prevent 336 | /// searching past the end. 337 | fn find(tag: &ChunkTag, mut reader: R, end: u64) -> crate::Result> 338 | where 339 | F: ChunkFormat, 340 | R: io::Read + io::Seek, 341 | { 342 | let mut pos = 0; 343 | 344 | while pos < end { 345 | let chunk = Self::read::(&mut reader)?; 346 | 347 | if &chunk.tag == tag { 348 | return Ok(Some(chunk)); 349 | } 350 | 351 | // Skip the chunk's contents, and padding if any. 352 | let skip = chunk.size.saturating_add(chunk.size % 2); 353 | 354 | pos = reader.seek(SeekFrom::Current(skip as i64))?; 355 | } 356 | 357 | Ok(None) 358 | } 359 | 360 | /// Writes a chunk header to the given stream. A header is composed of: 361 | /// 362 | /// | Field | Size | Value | 363 | /// |-------+------+-------------------------------| 364 | /// | tag | 4 | chunk type | 365 | /// | size | 4 | 32 bits little endian integer | 366 | pub fn write_to(&self, mut writer: W) -> io::Result<()> 367 | where 368 | F: ChunkFormat, 369 | W: io::Write, 370 | { 371 | const BUFFER_SIZE: usize = CHUNK_HEADER_LEN as usize; 372 | 373 | let mut buffer = [0; BUFFER_SIZE]; 374 | 375 | buffer[0..4].copy_from_slice(&self.tag.0); 376 | 377 | F::Endianness::write_u32(&mut buffer[4..8], self.size); 378 | 379 | // Use a single write call to improve performance on unbuffered writers. 380 | writer.write_all(&buffer) 381 | } 382 | } 383 | 384 | impl fmt::Debug for ChunkHeader { 385 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 386 | let tag = String::from_utf8_lossy(&self.tag.0); 387 | 388 | f.debug_struct(std::any::type_name::()) 389 | .field("tag", &tag) 390 | .field("size", &self.size) 391 | .finish() 392 | } 393 | } 394 | 395 | #[cfg(test)] 396 | mod tests { 397 | use super::*; 398 | use std::io::Cursor; 399 | 400 | struct MockFormat; 401 | 402 | impl ChunkFormat for MockFormat { 403 | type Endianness = LittleEndian; 404 | const ROOT_TAG: ChunkTag = ChunkTag(*b"MOCK"); 405 | const ROOT_FORMAT: Option = None; 406 | } 407 | 408 | #[test] 409 | fn test_find_saturating_skip() { 410 | // Create a mock stream with chunks 411 | let mut data = Vec::new(); 412 | 413 | // Add a chunk with a normal size 414 | data.extend_from_slice(b"MOCK"); 415 | data.extend_from_slice(&4u32.to_le_bytes()); // size 416 | data.extend_from_slice(b"DATA"); 417 | 418 | // Add a chunk with a size that would overflow if not handled correctly 419 | data.extend_from_slice(b"ID3 "); 420 | data.extend_from_slice(&u32::MAX.to_le_bytes()); // size 421 | data.extend_from_slice(&[0; 8]); // some data 422 | 423 | // Add another chunk to ensure the skip is calculated 424 | data.extend_from_slice(b"TEST"); 425 | data.extend_from_slice(&4u32.to_le_bytes()); // size 426 | data.extend_from_slice(b"DATA"); 427 | 428 | // Create a cursor for the mock data 429 | let mut cursor = Cursor::new(data); 430 | 431 | // Find the TEST chunk 432 | let length = cursor.get_ref().len() as u64; 433 | let result = ChunkHeader::find::(&ChunkTag(*b"TEST"), &mut cursor, length); 434 | 435 | // Verify the result 436 | assert!(result.is_ok()); 437 | assert!(result.unwrap().is_none()); 438 | } 439 | } 440 | -------------------------------------------------------------------------------- /src/error.rs: -------------------------------------------------------------------------------- 1 | use crate::tag::Tag; 2 | use std::error; 3 | use std::fmt; 4 | use std::io; 5 | use std::string; 6 | 7 | /// Type alias for the result of tag operations. 8 | pub type Result = std::result::Result; 9 | 10 | /// Takes a tag result and maps any partial tag to Ok. An Ok result is left untouched. An Err 11 | /// without partial tag is returned as the initial error. 12 | /// 13 | /// # Example 14 | /// ``` 15 | /// use id3::{Tag, Error, ErrorKind, partial_tag_ok}; 16 | /// 17 | /// let rs = Err(Error{ 18 | /// kind: ErrorKind::Parsing, 19 | /// description: "frame 12 could not be decoded".to_string(), 20 | /// partial_tag: Some(Tag::new()), 21 | /// }); 22 | /// assert!(partial_tag_ok(rs).is_ok()); 23 | /// ``` 24 | pub fn partial_tag_ok(rs: Result) -> Result { 25 | match rs { 26 | Ok(tag) => Ok(tag), 27 | Err(Error { 28 | partial_tag: Some(tag), 29 | .. 30 | }) => Ok(tag), 31 | Err(err) => Err(err), 32 | } 33 | } 34 | 35 | /// Takes a tag result and maps the NoTag kind to None. Any other error is returned as Err. 36 | /// 37 | /// # Example 38 | /// ``` 39 | /// use id3::{Tag, Error, ErrorKind, no_tag_ok}; 40 | /// 41 | /// let rs = Err(Error{ 42 | /// kind: ErrorKind::NoTag, 43 | /// description: "the file contains no ID3 tag".to_string(), 44 | /// partial_tag: None, 45 | /// }); 46 | /// assert!(matches!(no_tag_ok(rs), Ok(None))); 47 | /// 48 | /// let rs = Err(Error{ 49 | /// kind: ErrorKind::Parsing, 50 | /// description: "frame 12 could not be decoded".to_string(), 51 | /// partial_tag: Some(Tag::new()), 52 | /// }); 53 | /// assert!(no_tag_ok(rs).is_err()); 54 | /// ``` 55 | pub fn no_tag_ok(rs: Result) -> Result> { 56 | match rs { 57 | Ok(tag) => Ok(Some(tag)), 58 | Err(Error { 59 | kind: ErrorKind::NoTag, 60 | .. 61 | }) => Ok(None), 62 | Err(err) => Err(err), 63 | } 64 | } 65 | 66 | /// Kinds of errors that may occur while performing metadata operations. 67 | #[derive(Debug)] 68 | pub enum ErrorKind { 69 | /// An error kind indicating that an IO error has occurred. Contains the original io::Error. 70 | Io(io::Error), 71 | /// An error kind indicating that a string decoding error has occurred. Contains the invalid 72 | /// bytes. 73 | StringDecoding(Vec), 74 | /// An error kind indicating that the reader does not contain an ID3 tag. 75 | NoTag, 76 | /// An error kind indicating that parsing of some binary data has failed. 77 | Parsing, 78 | /// An error kind indicating that some input to a function was invalid. 79 | InvalidInput, 80 | /// An error kind indicating that a feature is not supported. 81 | UnsupportedFeature, 82 | } 83 | 84 | /// A structure able to represent any error that may occur while performing metadata operations. 85 | pub struct Error { 86 | /// The kind of error. 87 | pub kind: ErrorKind, 88 | /// A human readable string describing the error. 89 | pub description: String, 90 | /// If any, the part of the tag that was able to be decoded before the error occurred. 91 | pub partial_tag: Option, 92 | } 93 | 94 | impl Error { 95 | /// Creates a new `Error` using the error kind and description. 96 | pub fn new(kind: ErrorKind, description: impl Into) -> Error { 97 | Error { 98 | kind, 99 | description: description.into(), 100 | partial_tag: None, 101 | } 102 | } 103 | 104 | /// Creates a new `Error` using the error kind and description. 105 | pub(crate) fn with_tag(self, tag: Tag) -> Error { 106 | Error { 107 | partial_tag: Some(tag), 108 | ..self 109 | } 110 | } 111 | } 112 | 113 | impl error::Error for Error { 114 | fn source(&self) -> Option<&(dyn error::Error + 'static)> { 115 | match self.kind { 116 | ErrorKind::Io(ref err) => Some(err), 117 | _ => None, 118 | } 119 | } 120 | } 121 | 122 | impl From for Error { 123 | fn from(err: io::Error) -> Error { 124 | Error { 125 | kind: ErrorKind::Io(err), 126 | description: "".to_string(), 127 | partial_tag: None, 128 | } 129 | } 130 | } 131 | 132 | impl From for Error { 133 | fn from(err: string::FromUtf8Error) -> Error { 134 | Error { 135 | kind: ErrorKind::StringDecoding(err.into_bytes()), 136 | description: "data is not valid utf-8".to_string(), 137 | partial_tag: None, 138 | } 139 | } 140 | } 141 | 142 | impl fmt::Debug for Error { 143 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 144 | match self.description.is_empty() { 145 | true => write!(f, "{:?}", self.kind), 146 | false => write!(f, "{:?}: {}", self.kind, self.description), 147 | } 148 | } 149 | } 150 | 151 | impl fmt::Display for Error { 152 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 153 | match self.description.is_empty() { 154 | true => write!(f, "{}", self.kind), 155 | false => write!(f, "{}: {}", self.kind, self.description), 156 | } 157 | } 158 | } 159 | 160 | impl fmt::Display for ErrorKind { 161 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 162 | match self { 163 | ErrorKind::Io(io_error) => write!(f, "IO: {}", io_error), 164 | ErrorKind::StringDecoding(_) => write!(f, "StringDecoding"), 165 | ErrorKind::NoTag => write!(f, "NoTag"), 166 | ErrorKind::Parsing => write!(f, "Parsing"), 167 | ErrorKind::InvalidInput => write!(f, "InvalidInput"), 168 | ErrorKind::UnsupportedFeature => write!(f, "UnsupportedFeature"), 169 | } 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /src/frame/content_cmp.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Cow; 2 | 3 | /// used to express that some content should or should not be compared 4 | pub enum ContentCmp<'a> { 5 | Comparable(Vec>), 6 | /// used to mark frames to be always different (for example for unknown frames) 7 | Incomparable, 8 | /// used to mark frames as identical regardless of their content 9 | /// (for example for frames which require an unique id) 10 | ///
(Note: both values must be Same or else they would not be equal) 11 | Same, 12 | } 13 | 14 | impl PartialEq for ContentCmp<'_> { 15 | fn eq(&self, other: &Self) -> bool { 16 | use ContentCmp::*; 17 | 18 | match (self, other) { 19 | (Comparable(c1), Comparable(c2)) => c1 == c2, 20 | (Same, Same) => true, 21 | _ => false, 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/frame/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::error::{Error, ErrorKind}; 2 | use crate::stream::encoding::Encoding; 3 | use crate::tag::Version; 4 | use std::fmt; 5 | use std::str; 6 | 7 | pub use self::content::{ 8 | Chapter, Comment, Content, EncapsulatedObject, ExtendedLink, ExtendedText, InvolvedPeopleList, 9 | InvolvedPeopleListItem, Lyrics, MpegLocationLookupTable, MpegLocationLookupTableReference, 10 | Picture, PictureType, Popularimeter, Private, SynchronisedLyrics, SynchronisedLyricsType, 11 | TableOfContents, TimestampFormat, UniqueFileIdentifier, Unknown, 12 | }; 13 | pub use self::timestamp::Timestamp; 14 | 15 | mod content; 16 | mod content_cmp; 17 | mod timestamp; 18 | 19 | #[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] 20 | enum ID { 21 | /// A valid 4-byte frame ID. 22 | Valid(String), 23 | /// If an ID3v2.2 ID could not be mapped to its ID3v2.4 counterpart, it is stored as is. This 24 | /// allows invalid ID3v2.2 frames to be retained. 25 | Invalid(String), 26 | } 27 | 28 | /// A structure representing an ID3 frame. 29 | /// 30 | /// The [`Content`] must be accompanied by a matching ID. Although this struct allows for invalid 31 | /// combinations to exist, attempting to encode them will yield an error. 32 | #[allow(clippy::derived_hash_with_manual_eq)] 33 | #[derive(Clone, Debug, Eq, Ord, PartialOrd, Hash)] 34 | pub struct Frame { 35 | id: ID, 36 | content: Content, 37 | tag_alter_preservation: bool, 38 | file_alter_preservation: bool, 39 | encoding: Option, 40 | } 41 | 42 | impl Frame { 43 | /// Check if this Frame is identical to another frame 44 | pub(crate) fn compare(&self, other: &Frame) -> bool { 45 | if self.id == other.id { 46 | let content_eq = if let ID::Valid(id) = &self.id { 47 | // some link frames are allowed to have the same id as long their content is different 48 | if id == "WCOM" || id == "WOAR" { 49 | self.content.link() == other.content.link() 50 | } else { 51 | self.content.unique() == other.content.unique() 52 | } 53 | } else { 54 | self.content.unique() == other.content.unique() 55 | }; 56 | content_eq 57 | && (self.encoding.is_none() 58 | || other.encoding.is_none() 59 | || self.encoding == other.encoding) 60 | } else { 61 | false 62 | } 63 | } 64 | 65 | pub(crate) fn validate(&self) -> crate::Result<()> { 66 | // The valid/invalid ID enum exists to be able to read and write back unknown and possibly 67 | // invalid IDs. If it can be read, it can also be written again. 68 | let id = match &self.id { 69 | ID::Valid(v) => v, 70 | ID::Invalid(_) => return Ok(()), 71 | }; 72 | // The matching groups must match the decoding groups of stream/frame/content.rs:decode(). 73 | match (id.as_str(), &self.content) { 74 | ("GRP1", Content::Text(_)) => Ok(()), 75 | (id, Content::Text(_)) if id.starts_with('T') && !matches!(id, "TIPL" | "TMCL") => { 76 | Ok(()) 77 | } 78 | ("TXXX", Content::ExtendedText(_)) => Ok(()), 79 | (id, Content::Link(_)) if id.starts_with('W') => Ok(()), 80 | ("WXXX", Content::ExtendedLink(_)) => Ok(()), 81 | ("GEOB", Content::EncapsulatedObject(_)) => Ok(()), 82 | ("USLT", Content::Lyrics(_)) => Ok(()), 83 | ("SYLT", Content::SynchronisedLyrics(_)) => Ok(()), 84 | ("COMM", Content::Comment(_)) => Ok(()), 85 | ("POPM", Content::Popularimeter(_)) => Ok(()), 86 | ("APIC", Content::Picture(_)) => Ok(()), 87 | ("CHAP", Content::Chapter(_)) => Ok(()), 88 | ("MLLT", Content::MpegLocationLookupTable(_)) => Ok(()), 89 | ("IPLS" | "TIPL" | "TMCL", Content::InvolvedPeopleList(_)) => Ok(()), 90 | ("PRIV", Content::Private(_)) => Ok(()), 91 | ("CTOC", Content::TableOfContents(_)) => Ok(()), 92 | ("UFID", Content::UniqueFileIdentifier(_)) => Ok(()), 93 | (_, Content::Unknown(_)) => Ok(()), 94 | (id, content) => { 95 | let content_kind = match content { 96 | Content::Text(_) => "Text", 97 | Content::ExtendedText(_) => "ExtendedText", 98 | Content::Link(_) => "Link", 99 | Content::ExtendedLink(_) => "ExtendedLink", 100 | Content::Comment(_) => "Comment", 101 | Content::Popularimeter(_) => "Popularimeter", 102 | Content::Lyrics(_) => "Lyrics", 103 | Content::SynchronisedLyrics(_) => "SynchronisedLyrics", 104 | Content::Picture(_) => "Picture", 105 | Content::EncapsulatedObject(_) => "EncapsulatedObject", 106 | Content::Chapter(_) => "Chapter", 107 | Content::MpegLocationLookupTable(_) => "MpegLocationLookupTable", 108 | Content::Private(_) => "PrivateFrame", 109 | Content::TableOfContents(_) => "TableOfContents", 110 | Content::UniqueFileIdentifier(_) => "UFID", 111 | Content::InvolvedPeopleList(_) => "InvolvedPeopleList", 112 | Content::Unknown(_) => "Unknown", 113 | }; 114 | Err(Error::new( 115 | ErrorKind::InvalidInput, 116 | format!( 117 | "Frame with ID {} and content type {} can not be written as valid ID3", 118 | id, content_kind, 119 | ), 120 | )) 121 | } 122 | } 123 | } 124 | 125 | /// Creates a frame with the specified ID and content. 126 | /// 127 | /// Both ID3v2.2 and >ID3v2.3 IDs are accepted, although they will be converted to ID3v2.3 128 | /// format. If an ID3v2.2 ID is supplied but could not be remapped, it is stored as-is. 129 | /// 130 | /// # Panics 131 | /// If the id's length is not 3 or 4 bytes long. 132 | pub fn with_content(id: impl AsRef, content: Content) -> Self { 133 | assert!({ 134 | let l = id.as_ref().len(); 135 | l == 3 || l == 4 136 | }); 137 | Frame { 138 | id: if id.as_ref().len() == 3 { 139 | match convert_id_2_to_3(id.as_ref()) { 140 | Some(translated) => ID::Valid(translated.to_string()), 141 | None => ID::Invalid(id.as_ref().to_string()), 142 | } 143 | } else { 144 | ID::Valid(id.as_ref().to_string()) 145 | }, 146 | content, 147 | tag_alter_preservation: false, 148 | file_alter_preservation: false, 149 | encoding: None, 150 | } 151 | } 152 | 153 | /// Sets the encoding for this frame. 154 | /// 155 | /// The encoding is actually a property of individual content and its serialization format. 156 | /// Public interfaces of ID3 typically follow Rust conventions such as UTF-8. 157 | /// 158 | /// # Caveat 159 | /// According to the standard, distinct encodings do not count towards uniqueness. However, 160 | /// some applications such as Serato do write multiple frames that should not co-exist in a 161 | /// single tag and uses the encoding to distinguish between such frames. 162 | /// 163 | /// When set using this function, the encoding influences the way uniqueness is determined and 164 | /// using other interfaces to alter the tag this frame belongs to has the potential to remove 165 | /// this or other tags. 166 | /// 167 | /// After decoding a tag, the initial encoding is only set for TXXX and GEOB frames. 168 | pub fn set_encoding(mut self, encoding: Option) -> Self { 169 | self.encoding = encoding; 170 | self 171 | } 172 | 173 | /// Creates a new text frame with the specified ID and text content. 174 | /// 175 | /// This function does not verify whether the ID is valid for text frames. 176 | /// 177 | /// # Example 178 | /// ``` 179 | /// use id3::Frame; 180 | /// 181 | /// let frame = Frame::text("TPE1", "Armin van Buuren"); 182 | /// assert_eq!(frame.content().text(), Some("Armin van Buuren")); 183 | /// ``` 184 | pub fn text(id: impl AsRef, content: impl Into) -> Self { 185 | Self::with_content(id, Content::Text(content.into())) 186 | } 187 | 188 | /// Creates a new link frame with the specified ID and link content. 189 | /// 190 | /// This function does not verify whether the ID is valid for link frames. 191 | /// 192 | /// # Example 193 | /// ``` 194 | /// use id3::Frame; 195 | /// 196 | /// let frame = Frame::link("WCOM", "https://wwww.arminvanbuuren.com"); 197 | /// assert_eq!(frame.content().link(), Some("https://wwww.arminvanbuuren.com")); 198 | /// ``` 199 | pub fn link(id: impl AsRef, content: impl Into) -> Self { 200 | Self::with_content(id, Content::Link(content.into())) 201 | } 202 | 203 | /// Returns the ID of this frame. 204 | /// 205 | /// The string returned us usually 4 bytes long except when the frame was read from an ID3v2.2 206 | /// tag and the ID could not be mapped to an ID3v2.3 ID. 207 | pub fn id(&self) -> &str { 208 | match self.id { 209 | ID::Valid(ref id) | ID::Invalid(ref id) => id, 210 | } 211 | } 212 | 213 | /// Returns the ID that is compatible with specified version or None if no ID is available in 214 | /// that version. 215 | pub fn id_for_version(&self, version: Version) -> Option<&str> { 216 | match (version, &self.id) { 217 | (Version::Id3v22, ID::Valid(id)) => convert_id_3_to_2(id), 218 | (Version::Id3v23, ID::Valid(id)) 219 | | (Version::Id3v24, ID::Valid(id)) 220 | | (Version::Id3v22, ID::Invalid(id)) => Some(id), 221 | (_, ID::Invalid(_)) => None, 222 | } 223 | } 224 | 225 | /// Returns the content of the frame. 226 | pub fn content(&self) -> &Content { 227 | &self.content 228 | } 229 | 230 | /// Returns whether the tag_alter_preservation flag is set. 231 | pub fn tag_alter_preservation(&self) -> bool { 232 | self.tag_alter_preservation 233 | } 234 | 235 | /// Sets the tag_alter_preservation flag. 236 | pub fn set_tag_alter_preservation(&mut self, tag_alter_preservation: bool) { 237 | self.tag_alter_preservation = tag_alter_preservation; 238 | } 239 | 240 | /// Returns whether the file_alter_preservation flag is set. 241 | pub fn file_alter_preservation(&self) -> bool { 242 | self.file_alter_preservation 243 | } 244 | 245 | /// Sets the file_alter_preservation flag. 246 | pub fn set_file_alter_preservation(&mut self, file_alter_preservation: bool) { 247 | self.file_alter_preservation = file_alter_preservation; 248 | } 249 | 250 | /// Returns the encoding of this frame 251 | /// 252 | /// # Caveat 253 | /// See [`Frame::set_encoding`]. 254 | pub fn encoding(&self) -> Option { 255 | self.encoding 256 | } 257 | 258 | /// Returns the name of the frame. 259 | /// 260 | /// The name is the _human-readable_ representation of a frame 261 | /// id. For example, the id `"TCOM"` corresponds to the name 262 | /// `"Composer"`. The names are taken from the 263 | /// [ID3v2.4](http://id3.org/id3v2.4.0-frames), 264 | /// [ID3v2.3](http://id3.org/d3v2.3.0) and 265 | /// [ID3v2.2](http://id3.org/d3v2-00) standards. 266 | pub fn name(&self) -> &str { 267 | match self.id() { 268 | // Ids and names defined in section 4 of http://id3.org/id3v2.4.0-frames 269 | "AENC" => "Audio encryption", 270 | "APIC" => "Attached picture", 271 | "ASPI" => "Audio seek point index", 272 | "COMM" => "Comments", 273 | "COMR" => "Commercial frame", 274 | "ENCR" => "Encryption method registration", 275 | "EQU2" => "Equalisation (2)", 276 | "ETCO" => "Event timing codes", 277 | "GEOB" => "General encapsulated object", 278 | "GRID" => "Group identification registration", 279 | "LINK" => "Linked information", 280 | "MCDI" => "Music CD identifier", 281 | "MLLT" => "MPEG location lookup table", 282 | "OWNE" => "Ownership frame", 283 | "PRIV" => "Private frame", 284 | "PCNT" => "Play counter", 285 | "POPM" => "Popularimeter", 286 | "POSS" => "Position synchronisation frame", 287 | "RBUF" => "Recommended buffer size", 288 | "RVA2" => "Relative volume adjustment (2)", 289 | "RVRB" => "Reverb", 290 | "SEEK" => "Seek frame", 291 | "SIGN" => "Signature frame", 292 | "SYLT" => "Synchronised lyric/text", 293 | "SYTC" => "Synchronised tempo codes", 294 | "TALB" => "Album/Movie/Show title", 295 | "TBPM" => "BPM (beats per minute)", 296 | "TCOM" => "Composer", 297 | "TCON" => "Content type", 298 | "TCOP" => "Copyright message", 299 | "TDEN" => "Encoding time", 300 | "TDLY" => "Playlist delay", 301 | "TDOR" => "Original release time", 302 | "TDRC" => "Recording time", 303 | "TDRL" => "Release time", 304 | "TDTG" => "Tagging time", 305 | "TENC" => "Encoded by", 306 | "TEXT" => "Lyricist/Text writer", 307 | "TFLT" => "File type", 308 | "TIPL" => "Involved people list", 309 | "TIT1" => "Content group description", 310 | "TIT2" => "Title/songname/content description", 311 | "TIT3" => "Subtitle/Description refinement", 312 | "TKEY" => "Initial key", 313 | "TLAN" => "Language(s)", 314 | "TLEN" => "Length", 315 | "TMCL" => "Musician credits list", 316 | "TMED" => "Media type", 317 | "TMOO" => "Mood", 318 | "TOAL" => "Original album/movie/show title", 319 | "TOFN" => "Original filename", 320 | "TOLY" => "Original lyricist(s)/text writer(s)", 321 | "TOPE" => "Original artist(s)/performer(s)", 322 | "TOWN" => "File owner/licensee", 323 | "TPE1" => "Lead performer(s)/Soloist(s)", 324 | "TPE2" => "Band/orchestra/accompaniment", 325 | "TPE3" => "Conductor/performer refinement", 326 | "TPE4" => "Interpreted, remixed, or otherwise modified by", 327 | "TPOS" => "Part of a set", 328 | "TPRO" => "Produced notice", 329 | "TPUB" => "Publisher", 330 | "TRCK" => "Track number/Position in set", 331 | "TRSN" => "Internet radio station name", 332 | "TRSO" => "Internet radio station owner", 333 | "TSOA" => "Album sort order", 334 | "TSOP" => "Performer sort order", 335 | "TSOT" => "Title sort order", 336 | "TSRC" => "ISRC (international standard recording code)", 337 | "TSSE" => "Software/Hardware and settings used for encoding", 338 | "TSST" => "Set subtitle", 339 | "TXXX" => "User defined text information frame", 340 | "UFID" => "Unique file identifier", 341 | "USER" => "Terms of use", 342 | "USLT" => "Unsynchronised lyric/text transcription", 343 | "WCOM" => "Commercial information", 344 | "WCOP" => "Copyright/Legal information", 345 | "WOAF" => "Official audio file webpage", 346 | "WOAR" => "Official artist/performer webpage", 347 | "WOAS" => "Official audio source webpage", 348 | "WORS" => "Official Internet radio station homepage", 349 | "WPAY" => "Payment", 350 | "WPUB" => "Publishers official webpage", 351 | "WXXX" => "User defined URL link frame", 352 | 353 | // Ids and names defined in section 4 of 354 | // http://id3.org/d3v2.3.0 which have not been previously 355 | // defined above 356 | "EQUA" => "Equalization", 357 | "IPLS" => "Involved people list", 358 | "RVAD" => "Relative volume adjustment", 359 | "TDAT" => "Date", 360 | "TIME" => "Time", 361 | "TORY" => "Original release year", 362 | "TRDA" => "Recording dates", 363 | "TSIZ" => "Size", 364 | "TYER" => "Year", 365 | 366 | // Ids and names defined in section 4 of 367 | // http://id3.org/d3v2-00 which have not been previously 368 | // defined above 369 | "BUF" => "Recommended buffer size", 370 | "CNT" => "Play counter", 371 | "COM" => "Comments", 372 | "CRA" => "Audio encryption", 373 | "CRM" => "Encrypted meta frame", 374 | "ETC" => "Event timing codes", 375 | "EQU" => "Equalization", 376 | "GEO" => "General encapsulated object", 377 | "IPL" => "Involved people list", 378 | "LNK" => "Linked information", 379 | "MCI" => "Music CD Identifier", 380 | "MLL" => "MPEG location lookup table", 381 | "PIC" => "Attached picture", 382 | "POP" => "Popularimeter", 383 | "REV" => "Reverb", 384 | "RVA" => "Relative volume adjustment", 385 | "SLT" => "Synchronized lyric/text", 386 | "STC" => "Synced tempo codes", 387 | "TAL" => "Album/Movie/Show title", 388 | "TBP" => "BPM (Beats Per Minute)", 389 | "TCM" => "Composer", 390 | "TCO" => "Content type", 391 | "TCR" => "Copyright message", 392 | "TDA" => "Date", 393 | "TDY" => "Playlist delay", 394 | "TEN" => "Encoded by", 395 | "TFT" => "File type", 396 | "TIM" => "Time", 397 | "TKE" => "Initial key", 398 | "TLA" => "Language(s)", 399 | "TLE" => "Length", 400 | "TMT" => "Media type", 401 | "TOA" => "Original artist(s)/performer(s)", 402 | "TOF" => "Original filename", 403 | "TOL" => "Original Lyricist(s)/text writer(s)", 404 | "TOR" => "Original release year", 405 | "TOT" => "Original album/Movie/Show title", 406 | "TP1" => "Lead artist(s)/Lead performer(s)/Soloist(s)/Performing group", 407 | "TP2" => "Band/Orchestra/Accompaniment", 408 | "TP3" => "Conductor/Performer refinement", 409 | "TP4" => "Interpreted, remixed, or otherwise modified by", 410 | "TPA" => "Part of a set", 411 | "TPB" => "Publisher", 412 | "TRC" => "ISRC (International Standard Recording Code)", 413 | "TRD" => "Recording dates", 414 | "TRK" => "Track number/Position in set", 415 | "TSI" => "Size", 416 | "TSS" => "Software/hardware and settings used for encoding", 417 | "TT1" => "Content group description", 418 | "TT2" => "Title/Songname/Content description", 419 | "TT3" => "Subtitle/Description refinement", 420 | "TXT" => "Lyricist/text writer", 421 | "TXX" => "User defined text information frame", 422 | "TYE" => "Year", 423 | "UFI" => "Unique file identifier", 424 | "ULT" => "Unsychronized lyric/text transcription", 425 | "WAF" => "Official audio file webpage", 426 | "WAR" => "Official artist/performer webpage", 427 | "WAS" => "Official audio source webpage", 428 | "WCM" => "Commercial information", 429 | "WCP" => "Copyright/Legal information", 430 | "WPB" => "Publishers official webpage", 431 | "WXX" => "User defined URL link frame", 432 | 433 | v => v, 434 | } 435 | } 436 | } 437 | 438 | impl PartialEq for Frame { 439 | fn eq(&self, other: &Self) -> bool { 440 | self.id == other.id 441 | && self.content == other.content 442 | && self.tag_alter_preservation == other.tag_alter_preservation 443 | && self.file_alter_preservation == other.file_alter_preservation 444 | && (self.encoding.is_none() 445 | || other.encoding.is_none() 446 | || self.encoding == other.encoding) 447 | } 448 | } 449 | 450 | impl fmt::Display for Frame { 451 | fn fmt(&self, f: &mut fmt::Formatter) -> Result<(), fmt::Error> { 452 | write!(f, "{} = {}", self.name(), self.content) 453 | } 454 | } 455 | 456 | macro_rules! convert_2_to_3_and_back { 457 | ( $( $id2:expr, $id3:expr ),* ) => { 458 | fn convert_id_2_to_3(id: impl AsRef) -> Option<&'static str> { 459 | match id.as_ref() { 460 | $($id2 => Some($id3),)* 461 | _ => None, 462 | } 463 | } 464 | 465 | fn convert_id_3_to_2(id: impl AsRef) -> Option<&'static str> { 466 | match id.as_ref() { 467 | $($id3 => Some($id2),)* 468 | _ => None, 469 | } 470 | } 471 | } 472 | } 473 | 474 | #[rustfmt::skip] 475 | convert_2_to_3_and_back!( 476 | "BUF", "RBUF", 477 | 478 | "CNT", "PCNT", 479 | "COM", "COMM", 480 | "CRA", "AENC", 481 | // "CRM" does not exist in ID3v2.3 482 | 483 | "ETC", "ETCO", 484 | "EQU", "EQUA", 485 | 486 | "GEO", "GEOB", 487 | 488 | "IPL", "IPLS", 489 | 490 | "LNK", "LINK", 491 | 492 | "MCI", "MCDI", 493 | "MLL", "MLLT", 494 | 495 | "PIC", "APIC", 496 | "POP", "POPM", 497 | 498 | "REV", "RVRB", 499 | "RVA", "RVA2", 500 | 501 | "SLT", "SYLT", 502 | "STC", "SYTC", 503 | 504 | "TAL", "TALB", 505 | "TBP", "TBPM", 506 | "TCM", "TCOM", 507 | "TCO", "TCON", 508 | "TCR", "TCOP", 509 | "TDA", "TDAT", 510 | "TDY", "TDLY", 511 | "TEN", "TENC", 512 | "TFT", "TFLT", 513 | "TIM", "TIME", 514 | "TKE", "TKEY", 515 | "TLA", "TLAN", 516 | "TLE", "TLEN", 517 | "TMT", "TMED", 518 | "TOA", "TOPE", 519 | "TOF", "TOFN", 520 | "TOL", "TOLY", 521 | "TOT", "TOAL", 522 | "TOR", "TORY", 523 | "TP1", "TPE1", 524 | "TP2", "TPE2", 525 | "TP3", "TPE3", 526 | "TP4", "TPE4", 527 | "TPA", "TPOS", 528 | "TPB", "TPUB", 529 | "TRC", "TSRC", 530 | "TRD", "TRDA", 531 | "TRK", "TRCK", 532 | "TSI", "TSIZ", 533 | "TSS", "TSSE", 534 | "TT1", "TIT1", 535 | "TT2", "TIT2", 536 | "TT3", "TIT3", 537 | "TXT", "TEXT", 538 | "TXX", "TXXX", 539 | "TYE", "TYER", 540 | 541 | "UFI", "UFID", 542 | "ULT", "USLT", 543 | 544 | "WAF", "WOAF", 545 | "WAR", "WOAR", 546 | "WAS", "WOAS", 547 | "WCM", "WCOM", 548 | "WCP", "WCOP", 549 | "WPB", "WPUB", 550 | "WXX", "WXXX" 551 | ); 552 | 553 | #[cfg(test)] 554 | mod tests { 555 | use super::*; 556 | 557 | #[test] 558 | fn test_display() { 559 | let title_frame = Frame::with_content("TIT2", Content::Text("title".to_owned())); 560 | assert_eq!( 561 | format!("{}", title_frame), 562 | "Title/songname/content description = title" 563 | ); 564 | 565 | let txxx_frame = Frame::with_content( 566 | "TXXX", 567 | Content::ExtendedText(ExtendedText { 568 | description: "description".to_owned(), 569 | value: "value".to_owned(), 570 | }), 571 | ); 572 | assert_eq!( 573 | format!("{}", txxx_frame), 574 | "User defined text information frame = description: value" 575 | ); 576 | } 577 | 578 | #[test] 579 | fn test_frame_cmp_text() { 580 | let frame_a = Frame::with_content("TIT2", Content::Text("A".to_owned())); 581 | let frame_b = Frame::with_content("TIT2", Content::Text("B".to_owned())); 582 | 583 | assert!( 584 | frame_a.compare(&frame_b), 585 | "frames should be counted as equal" 586 | ); 587 | } 588 | 589 | #[test] 590 | fn test_frame_cmp_wcom() { 591 | let frame_a = Frame::with_content("WCOM", Content::Link("A".to_owned())); 592 | let frame_b = Frame::with_content("WCOM", Content::Link("B".to_owned())); 593 | 594 | assert!( 595 | !frame_a.compare(&frame_b), 596 | "frames should not be counted as equal" 597 | ); 598 | } 599 | 600 | #[test] 601 | fn test_frame_cmp_priv() { 602 | let frame_a = Frame::with_content( 603 | "PRIV", 604 | Content::Unknown(Unknown { 605 | data: vec![1, 2, 3], 606 | version: Version::Id3v24, 607 | }), 608 | ); 609 | let frame_b = Frame::with_content( 610 | "PRIV", 611 | Content::Unknown(Unknown { 612 | data: vec![1, 2, 3], 613 | version: Version::Id3v24, 614 | }), 615 | ); 616 | 617 | assert!( 618 | !frame_a.compare(&frame_b), 619 | "frames should not be counted as equal" 620 | ); 621 | } 622 | 623 | #[test] 624 | fn test_frame_cmp_ufid() { 625 | let frame_a = Frame::with_content( 626 | "UFID", 627 | Content::UniqueFileIdentifier(UniqueFileIdentifier { 628 | owner_identifier: String::from("http://www.id3.org/dummy/ufid.html"), 629 | identifier: String::from("A").into(), 630 | }), 631 | ); 632 | let frame_b = Frame::with_content( 633 | "UFID", 634 | Content::UniqueFileIdentifier(UniqueFileIdentifier { 635 | owner_identifier: String::from("http://www.id3.org/dummy/ufid.html"), 636 | identifier: String::from("B").into(), 637 | }), 638 | ); 639 | let frame_c = Frame::with_content( 640 | "UFID", 641 | Content::UniqueFileIdentifier(UniqueFileIdentifier { 642 | owner_identifier: String::from("https://example.com"), 643 | identifier: String::from("C").into(), 644 | }), 645 | ); 646 | 647 | assert!( 648 | frame_a.compare(&frame_b), 649 | "frames should be equal because they share the same owner_identifier" 650 | ); 651 | 652 | assert!( 653 | !frame_a.compare(&frame_c), 654 | "frames should not be equal because they share have different owner_identifiers" 655 | ); 656 | } 657 | 658 | #[test] 659 | fn test_frame_cmp_popularimeter() { 660 | let frame_a = Frame::with_content( 661 | "POPM", 662 | Content::Popularimeter(Popularimeter { 663 | user: "A".to_owned(), 664 | rating: 1, 665 | counter: 1, 666 | }), 667 | ); 668 | let frame_b = Frame::with_content( 669 | "POPM", 670 | Content::Popularimeter(Popularimeter { 671 | user: "A".to_owned(), 672 | rating: 1, 673 | counter: 1, 674 | }), 675 | ); 676 | let frame_c = Frame::with_content( 677 | "POPM", 678 | Content::Popularimeter(Popularimeter { 679 | user: "C".to_owned(), 680 | rating: 1, 681 | counter: 1, 682 | }), 683 | ); 684 | 685 | assert!( 686 | frame_a.compare(&frame_b), 687 | "frames should be counted as equal" 688 | ); 689 | assert!( 690 | !frame_a.compare(&frame_c), 691 | "frames should not be counted as equal" 692 | ); 693 | } 694 | } 695 | -------------------------------------------------------------------------------- /src/frame/timestamp.rs: -------------------------------------------------------------------------------- 1 | use std::cmp; 2 | use std::convert::TryFrom; 3 | use std::error; 4 | use std::fmt; 5 | use std::str::FromStr; 6 | 7 | /// Represents a date and time according to the ID3v2.4 spec: 8 | /// 9 | /// The timestamp fields are based on a subset of ISO 8601. When being as 10 | /// precise as possible the format of a time string is 11 | /// yyyy-MM-ddTHH:mm:ss (year, "-", month, "-", day, "T", hour (out of 12 | /// 24), ":", minutes, ":", seconds), but the precision may be reduced by 13 | /// removing as many time indicators as wanted. Hence valid timestamps 14 | /// are yyyy, yyyy-MM, yyyy-MM-dd, yyyy-MM-ddTHH, yyyy-MM-ddTHH:mm and 15 | /// yyyy-MM-ddTHH:mm:ss. All time stamps are UTC. 16 | #[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash)] 17 | #[allow(missing_docs)] 18 | pub struct Timestamp { 19 | pub year: i32, 20 | pub month: Option, 21 | pub day: Option, 22 | pub hour: Option, 23 | pub minute: Option, 24 | pub second: Option, 25 | } 26 | 27 | impl Ord for Timestamp { 28 | fn cmp(&self, other: &Self) -> cmp::Ordering { 29 | self.year 30 | .cmp(&other.year) 31 | .then(self.month.cmp(&other.month)) 32 | .then(self.day.cmp(&other.day)) 33 | .then(self.hour.cmp(&other.hour)) 34 | .then(self.minute.cmp(&other.minute)) 35 | .then(self.second.cmp(&other.second)) 36 | } 37 | } 38 | 39 | impl PartialOrd for Timestamp { 40 | fn partial_cmp(&self, other: &Self) -> Option { 41 | Some(self.cmp(other)) 42 | } 43 | } 44 | 45 | impl fmt::Display for Timestamp { 46 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 47 | write!(f, "{:04}", self.year)?; 48 | if let Some(month) = self.month { 49 | write!(f, "-{:02}", month)?; 50 | if let Some(day) = self.day { 51 | write!(f, "-{:02}", day)?; 52 | if let Some(hour) = self.hour { 53 | write!(f, "T{:02}", hour)?; 54 | if let Some(minute) = self.minute { 55 | write!(f, ":{:02}", minute)?; 56 | if let Some(second) = self.second { 57 | write!(f, ":{:02}", second)?; 58 | } 59 | } 60 | } 61 | } 62 | } 63 | Ok(()) 64 | } 65 | } 66 | 67 | struct Parser<'a>(&'a str); 68 | 69 | impl Parser<'_> { 70 | fn parse_timestamp(&mut self, source: &str) -> Result { 71 | let mut parser = Parser(source); 72 | let mut timestamp = Timestamp { 73 | year: parser.parse_year()?, 74 | month: None, 75 | day: None, 76 | hour: None, 77 | minute: None, 78 | second: None, 79 | }; 80 | 81 | fn parse(mut parser: Parser, timestamp: &mut Timestamp) -> Result<(), ()> { 82 | parser.expect(b'-')?; 83 | timestamp.month = parser.parse_other().map(Some)?; 84 | parser.expect(b'-')?; 85 | timestamp.day = parser.parse_other().map(Some)?; 86 | parser.expect(b'T')?; 87 | timestamp.hour = parser.parse_other().map(Some)?; 88 | parser.expect(b':')?; 89 | timestamp.minute = parser.parse_other().map(Some)?; 90 | parser.expect(b':')?; 91 | timestamp.second = parser.parse_other().ok(); 92 | Ok(()) 93 | } 94 | let _ = parse(parser, &mut timestamp); 95 | 96 | Ok(timestamp) 97 | } 98 | 99 | fn skip_leading_whitespace(&mut self) { 100 | self.0 = self.0.trim_start(); 101 | } 102 | 103 | fn expect(&mut self, ch: u8) -> Result<(), ()> { 104 | self.skip_leading_whitespace(); 105 | if self.0.starts_with(ch as char) { 106 | self.0 = &self.0[1..]; 107 | Ok(()) 108 | } else { 109 | Err(()) 110 | } 111 | } 112 | 113 | fn parse_year(&mut self) -> Result { 114 | self.skip_leading_whitespace(); 115 | self.parse_number() 116 | .and_then(|n| i32::try_from(n).map_err(|_| ())) 117 | } 118 | 119 | fn parse_other(&mut self) -> Result { 120 | self.skip_leading_whitespace(); 121 | self.parse_number() 122 | .and_then(|n| if n < 100 { Ok(n as u8) } else { Err(()) }) 123 | } 124 | 125 | fn parse_number(&mut self) -> Result { 126 | let mut ok = false; 127 | let mut r = 0u32; 128 | while self.0.starts_with(|c: char| c.is_ascii_digit()) { 129 | ok = true; 130 | r = if let Some(r) = r 131 | .checked_mul(10) 132 | .and_then(|r| r.checked_add(u32::from(self.0.as_bytes()[0] - b'0'))) 133 | { 134 | r 135 | } else { 136 | return Err(()); 137 | }; 138 | self.0 = &self.0[1..]; 139 | } 140 | if ok { 141 | Ok(r) 142 | } else { 143 | Err(()) 144 | } 145 | } 146 | } 147 | 148 | impl FromStr for Timestamp { 149 | type Err = ParseError; 150 | 151 | fn from_str(source: &str) -> Result { 152 | Parser(source) 153 | .parse_timestamp(source) 154 | .map_err(|_| ParseError::Unmatched) 155 | } 156 | } 157 | 158 | #[derive(Debug)] 159 | pub enum ParseError { 160 | /// The input text was not matched. 161 | Unmatched, 162 | } 163 | 164 | impl fmt::Display for ParseError { 165 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 166 | match *self { 167 | ParseError::Unmatched => write!(f, "No valid timestamp was found in the input"), 168 | } 169 | } 170 | } 171 | 172 | impl error::Error for ParseError { 173 | fn description(&self) -> &str { 174 | "Timestamp parse error" 175 | } 176 | 177 | fn cause(&self) -> Option<&dyn error::Error> { 178 | None 179 | } 180 | } 181 | 182 | #[test] 183 | fn test_parse_timestamp() { 184 | assert!("December 1989".parse::().is_err()); 185 | assert_eq!( 186 | "1989".parse::().unwrap(), 187 | Timestamp { 188 | year: 1989, 189 | month: None, 190 | day: None, 191 | hour: None, 192 | minute: None, 193 | second: None 194 | } 195 | ); 196 | assert_eq!( 197 | "\t1989".parse::().unwrap(), 198 | Timestamp { 199 | year: 1989, 200 | month: None, 201 | day: None, 202 | hour: None, 203 | minute: None, 204 | second: None 205 | } 206 | ); 207 | assert_eq!( 208 | "1989-01".parse::().unwrap(), 209 | Timestamp { 210 | year: 1989, 211 | month: Some(1), 212 | day: None, 213 | hour: None, 214 | minute: None, 215 | second: None 216 | } 217 | ); 218 | assert_eq!( 219 | "1989 - 1".parse::().unwrap(), 220 | Timestamp { 221 | year: 1989, 222 | month: Some(1), 223 | day: None, 224 | hour: None, 225 | minute: None, 226 | second: None 227 | } 228 | ); 229 | assert_eq!( 230 | "1989-12".parse::().unwrap(), 231 | Timestamp { 232 | year: 1989, 233 | month: Some(12), 234 | day: None, 235 | hour: None, 236 | minute: None, 237 | second: None 238 | } 239 | ); 240 | assert_eq!( 241 | "1989-01-02".parse::().unwrap(), 242 | Timestamp { 243 | year: 1989, 244 | month: Some(1), 245 | day: Some(2), 246 | hour: None, 247 | minute: None, 248 | second: None 249 | } 250 | ); 251 | assert_eq!( 252 | "1989-01-02".parse::().unwrap(), 253 | Timestamp { 254 | year: 1989, 255 | month: Some(1), 256 | day: Some(2), 257 | hour: None, 258 | minute: None, 259 | second: None 260 | } 261 | ); 262 | assert_eq!( 263 | "1989- 1- 2".parse::().unwrap(), 264 | Timestamp { 265 | year: 1989, 266 | month: Some(1), 267 | day: Some(2), 268 | hour: None, 269 | minute: None, 270 | second: None 271 | } 272 | ); 273 | assert_eq!( 274 | "1989-12-27".parse::().unwrap(), 275 | Timestamp { 276 | year: 1989, 277 | month: Some(12), 278 | day: Some(27), 279 | hour: None, 280 | minute: None, 281 | second: None 282 | } 283 | ); 284 | assert_eq!( 285 | "1989-12-27T09".parse::().unwrap(), 286 | Timestamp { 287 | year: 1989, 288 | month: Some(12), 289 | day: Some(27), 290 | hour: Some(9), 291 | minute: None, 292 | second: None 293 | } 294 | ); 295 | assert_eq!( 296 | "1989-12-27T09:15".parse::().unwrap(), 297 | Timestamp { 298 | year: 1989, 299 | month: Some(12), 300 | day: Some(27), 301 | hour: Some(9), 302 | minute: Some(15), 303 | second: None 304 | } 305 | ); 306 | assert_eq!( 307 | "1989-12-27T 9:15".parse::().unwrap(), 308 | Timestamp { 309 | year: 1989, 310 | month: Some(12), 311 | day: Some(27), 312 | hour: Some(9), 313 | minute: Some(15), 314 | second: None 315 | } 316 | ); 317 | assert_eq!( 318 | "1989-12-27T09:15:30".parse::().unwrap(), 319 | Timestamp { 320 | year: 1989, 321 | month: Some(12), 322 | day: Some(27), 323 | hour: Some(9), 324 | minute: Some(15), 325 | second: Some(30) 326 | } 327 | ); 328 | assert_eq!( 329 | "19890-1-2T9:7:2".parse::().unwrap(), 330 | Timestamp { 331 | year: 19890, 332 | month: Some(1), 333 | day: Some(2), 334 | hour: Some(9), 335 | minute: Some(7), 336 | second: Some(2) 337 | } 338 | ); 339 | assert_eq!( 340 | "19890- 1- 2T 9: 7: 2".parse::().unwrap(), 341 | Timestamp { 342 | year: 19890, 343 | month: Some(1), 344 | day: Some(2), 345 | hour: Some(9), 346 | minute: Some(7), 347 | second: Some(2) 348 | } 349 | ); 350 | } 351 | 352 | #[test] 353 | fn test_encode_timestamp() { 354 | assert_eq!("1989".parse::().unwrap().to_string(), "1989"); 355 | assert_eq!( 356 | "1989-01".parse::().unwrap().to_string(), 357 | "1989-01" 358 | ); 359 | assert_eq!( 360 | "1989-12".parse::().unwrap().to_string(), 361 | "1989-12" 362 | ); 363 | assert_eq!( 364 | "1989-01-02".parse::().unwrap().to_string(), 365 | "1989-01-02" 366 | ); 367 | assert_eq!( 368 | "1989-12-27".parse::().unwrap().to_string(), 369 | "1989-12-27" 370 | ); 371 | assert_eq!( 372 | "1989-12-27T09".parse::().unwrap().to_string(), 373 | "1989-12-27T09" 374 | ); 375 | assert_eq!( 376 | "1989-12-27T09:15".parse::().unwrap().to_string(), 377 | "1989-12-27T09:15" 378 | ); 379 | assert_eq!( 380 | "1989-12-27T09:15:30" 381 | .parse::() 382 | .unwrap() 383 | .to_string(), 384 | "1989-12-27T09:15:30" 385 | ); 386 | assert_eq!( 387 | "19890-1-2T9:7:2".parse::().unwrap().to_string(), 388 | "19890-01-02T09:07:02" 389 | ); 390 | } 391 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #![doc = include_str!("../README.md")] 2 | #![deny(missing_docs)] 3 | #![deny(clippy::all)] 4 | 5 | // Resources: 6 | // * ID3v2.2 7 | // * ID3v2.3 8 | // * ID3v2.4 9 | 10 | pub use crate::error::{no_tag_ok, partial_tag_ok, Error, ErrorKind, Result}; 11 | pub use crate::frame::{Content, Frame, Timestamp}; 12 | pub use crate::storage::StorageFile; 13 | pub use crate::stream::encoding::Encoding; 14 | pub use crate::stream::tag::Encoder; 15 | pub use crate::tag::{Tag, Version}; 16 | pub use crate::taglike::TagLike; 17 | 18 | /// Contains types and methods for operating on ID3 frames. 19 | pub mod frame; 20 | /// Utilities for working with ID3v1 tags. 21 | pub mod v1; 22 | /// Combined API that handles both ID3v1 and ID3v2 tags at the same time. 23 | pub mod v1v2; 24 | 25 | mod chunk; 26 | mod error; 27 | mod storage; 28 | mod stream; 29 | mod tag; 30 | mod taglike; 31 | mod tcon; 32 | -------------------------------------------------------------------------------- /src/storage/mod.rs: -------------------------------------------------------------------------------- 1 | //! Abstractions that expose a simple interface for reading and storing tags according to some 2 | //! underlying file format. 3 | //! 4 | //! The need for this abstraction arises from the differences that audiofiles have when storing 5 | //! metadata. For example, MP3 uses a header for ID3v2, a trailer for ID3v1 while WAV has a special 6 | //! "RIFF-chunk" which stores an ID3 tag. 7 | 8 | use std::fs; 9 | use std::io; 10 | 11 | pub mod plain; 12 | 13 | #[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] 14 | pub enum Format { 15 | /// ID3 is typically written as a header that precedes any audio content. For MPEG files, it is 16 | /// always a header. However, other formats where it is possible for the tag to be located 17 | /// elsewhere may also have an ID3 header. 18 | Header, 19 | 20 | /// Aiff is a chunk-y format format like WAV. Here, the file is built up in chunks where every 21 | /// chunk is a size header and some binary data. Audio is a chunk, but an ID3 tag may also be 22 | /// written to a chunk. 23 | Aiff, 24 | 25 | /// Similar to Aiff. 26 | Wav, 27 | } 28 | 29 | impl Format { 30 | pub fn magic(probe: impl AsRef<[u8]>) -> Option { 31 | let probe = probe.as_ref(); 32 | if probe.len() < 12 { 33 | return None; 34 | } 35 | match (&probe[..3], &probe[..4], &probe[8..12]) { 36 | (b"ID3", _, _) => Some(Format::Header), 37 | (_, b"FORM", _) => Some(Format::Aiff), 38 | (_, b"RIFF", b"WAVE") => Some(Format::Wav), 39 | _ => None, 40 | } 41 | } 42 | } 43 | 44 | /// Refer to the module documentation. 45 | pub trait Storage<'a> { 46 | type Reader: io::Read + io::Seek + 'a; 47 | type Writer: io::Write + io::Seek + 'a; 48 | 49 | /// Opens the storage for reading. 50 | #[allow(unused)] // Idk, it would be cool to use this for some formats. 51 | fn reader(&'a mut self) -> io::Result; 52 | 53 | /// Opens the storage for writing. 54 | /// 55 | /// The written data is comitted to persistent storage when the 56 | /// writer is dropped, altough this will ignore any errors. The caller must manually commit by 57 | /// using `io::Write::flush` to check for errors. 58 | fn writer(&'a mut self) -> io::Result; 59 | } 60 | 61 | /// This trait is the combination of the [`std::io`] stream traits with an additional method to resize the 62 | /// file. 63 | pub trait StorageFile: io::Read + io::Write + io::Seek + private::Sealed { 64 | /// Performs the resize. Assumes the same behaviour as [`std::fs::File::set_len`]. 65 | fn set_len(&mut self, new_len: u64) -> io::Result<()>; 66 | } 67 | 68 | impl StorageFile for &mut T { 69 | fn set_len(&mut self, new_len: u64) -> io::Result<()> { 70 | (*self).set_len(new_len) 71 | } 72 | } 73 | 74 | impl StorageFile for fs::File { 75 | fn set_len(&mut self, new_len: u64) -> io::Result<()> { 76 | fs::File::set_len(self, new_len) 77 | } 78 | } 79 | 80 | impl StorageFile for io::Cursor> { 81 | fn set_len(&mut self, new_len: u64) -> io::Result<()> { 82 | self.get_mut().resize(new_len as usize, 0); 83 | Ok(()) 84 | } 85 | } 86 | 87 | // https://rust-lang.github.io/api-guidelines/future-proofing.html#c-sealed 88 | mod private { 89 | pub trait Sealed {} 90 | 91 | impl Sealed for &mut T {} 92 | impl Sealed for std::fs::File {} 93 | impl Sealed for std::io::Cursor> {} 94 | } 95 | 96 | #[cfg(test)] 97 | mod tests { 98 | use super::*; 99 | use std::io::Read; 100 | use std::path::Path; 101 | 102 | fn probe(path: impl AsRef) -> [u8; 12] { 103 | let mut f = fs::File::open(path).unwrap(); 104 | let mut b = [0u8; 12]; 105 | f.read(&mut b[..]).unwrap(); 106 | b 107 | } 108 | 109 | #[test] 110 | fn test_format_magic() { 111 | assert_eq!( 112 | Format::magic(probe("testdata/aiff/padding.aiff")), 113 | Some(Format::Aiff) 114 | ); 115 | assert_eq!( 116 | Format::magic(probe("testdata/aiff/quiet.aiff")), 117 | Some(Format::Aiff) 118 | ); 119 | assert_eq!( 120 | Format::magic(probe("testdata/wav/tagged-end.wav")), 121 | Some(Format::Wav) 122 | ); 123 | assert_eq!( 124 | Format::magic(probe("testdata/wav/tagless.wav")), 125 | Some(Format::Wav) 126 | ); 127 | assert_eq!( 128 | Format::magic(probe("testdata/id3v22.id3")), 129 | Some(Format::Header) 130 | ); 131 | assert_eq!(Format::magic(probe("testdata/mpeg-header")), None); 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/storage/plain.rs: -------------------------------------------------------------------------------- 1 | use super::{Storage, StorageFile}; 2 | use std::cmp::{self, Ordering}; 3 | use std::io::{self, Write}; 4 | use std::ops; 5 | 6 | const COPY_BUF_SIZE: usize = 65536; 7 | 8 | /// `PlainStorage` keeps track of a writeable region in a file and prevents accidental overwrites 9 | /// of unrelated data. Any data following after the region is moved left and right as needed. 10 | /// 11 | /// Padding is included from the reader. 12 | #[derive(Debug)] 13 | pub struct PlainStorage { 14 | /// The backing storage. 15 | file: F, 16 | /// The region that may be writen to including any padding. 17 | region: ops::Range, 18 | } 19 | 20 | impl PlainStorage { 21 | /// Creates a new storage. 22 | pub fn new(file: F, region: ops::Range) -> PlainStorage { 23 | PlainStorage { file, region } 24 | } 25 | } 26 | 27 | impl<'a, F: StorageFile + 'a> Storage<'a> for PlainStorage { 28 | type Reader = PlainReader<'a, F>; 29 | type Writer = PlainWriter<'a, F>; 30 | 31 | fn reader(&'a mut self) -> io::Result { 32 | self.file.seek(io::SeekFrom::Start(self.region.start))?; 33 | Ok(PlainReader::<'a, F> { storage: self }) 34 | } 35 | 36 | fn writer(&'a mut self) -> io::Result { 37 | self.file.seek(io::SeekFrom::Start(self.region.start))?; 38 | Ok(PlainWriter::<'a, F> { 39 | storage: self, 40 | buffer: io::Cursor::new(Vec::new()), 41 | buffer_changed: true, 42 | }) 43 | } 44 | } 45 | 46 | pub struct PlainReader<'a, F: StorageFile + 'a> { 47 | storage: &'a mut PlainStorage, 48 | } 49 | 50 | impl io::Read for PlainReader<'_, F> { 51 | fn read(&mut self, buf: &mut [u8]) -> io::Result { 52 | let cur_pos = self.storage.file.stream_position()?; 53 | assert!(self.storage.region.start <= cur_pos); 54 | if self.storage.region.end <= cur_pos { 55 | return Ok(0); 56 | } 57 | let buf_upper_bound = cmp::min( 58 | buf.len(), 59 | cmp::max(self.storage.region.end - cur_pos, 0) as usize, 60 | ); 61 | self.storage.file.read(&mut buf[0..buf_upper_bound]) 62 | } 63 | } 64 | 65 | impl io::Seek for PlainReader<'_, F> { 66 | fn seek(&mut self, rel_pos: io::SeekFrom) -> io::Result { 67 | let abs_cur_pos = self.storage.file.stream_position()?; 68 | let abs_pos = match rel_pos { 69 | io::SeekFrom::Start(i) => (self.storage.region.start + i) as i64, 70 | io::SeekFrom::End(i) => self.storage.region.end as i64 + i, 71 | io::SeekFrom::Current(i) => abs_cur_pos as i64 + i, 72 | }; 73 | if abs_pos < self.storage.region.start as i64 { 74 | return Err(io::Error::new( 75 | io::ErrorKind::InvalidInput, 76 | "attempted to seek to before the start of the region", 77 | )); 78 | } 79 | let new_abs_pos = self 80 | .storage 81 | .file 82 | .seek(io::SeekFrom::Start(abs_pos as u64))?; 83 | Ok(new_abs_pos - self.storage.region.start) 84 | } 85 | } 86 | 87 | pub struct PlainWriter<'a, F: StorageFile + 'a> { 88 | storage: &'a mut PlainStorage, 89 | /// Data is writen to this buffer before it is committed to the underlying storage. 90 | buffer: io::Cursor>, 91 | /// A flag indicating that the buffer has been written to. 92 | buffer_changed: bool, 93 | } 94 | 95 | impl io::Write for PlainWriter<'_, F> { 96 | fn write(&mut self, buf: &[u8]) -> io::Result { 97 | let nwritten = self.buffer.write(buf)?; 98 | self.buffer_changed = true; 99 | Ok(nwritten) 100 | } 101 | 102 | fn flush(&mut self) -> io::Result<()> { 103 | // Check whether the buffer and file are out of sync. 104 | if !self.buffer_changed { 105 | return Ok(()); 106 | } 107 | 108 | let buf_len = self.buffer.get_ref().len() as u64; 109 | fn range_len(r: &ops::Range) -> u64 { 110 | r.end - r.start 111 | } 112 | 113 | match buf_len.cmp(&range_len(&self.storage.region)) { 114 | Ordering::Greater => { 115 | // The region is not able to store the contents of the buffer. Grow it by moving the 116 | // following data to the end. 117 | let old_file_end = self.storage.file.seek(io::SeekFrom::End(0))?; 118 | let new_file_end = old_file_end + (buf_len - range_len(&self.storage.region)); 119 | let old_region_end = self.storage.region.end; 120 | let new_region_end = self.storage.region.start + buf_len; 121 | 122 | self.storage.file.set_len(new_file_end)?; 123 | let mut rwbuf = [0; COPY_BUF_SIZE]; 124 | let rwbuf_len = rwbuf.len(); 125 | for i in 1.. { 126 | let raw_from = old_file_end as i64 - i as i64 * rwbuf.len() as i64; 127 | let raw_to = new_file_end.saturating_sub(i * rwbuf.len() as u64); 128 | let from = cmp::max(old_region_end as i64, raw_from) as u64; 129 | let to = cmp::max(new_region_end, raw_to); 130 | assert!(from < to); 131 | 132 | let diff = cmp::max(old_region_end as i64 - raw_from, 0) as usize; 133 | let rwbuf_part = &mut rwbuf[cmp::min(diff, rwbuf_len)..]; 134 | self.storage.file.seek(io::SeekFrom::Start(from))?; 135 | self.storage.file.read_exact(rwbuf_part)?; 136 | self.storage.file.seek(io::SeekFrom::Start(to))?; 137 | self.storage.file.write_all(rwbuf_part)?; 138 | if rwbuf_part.len() < rwbuf_len { 139 | break; 140 | } 141 | } 142 | 143 | self.storage.region.end = new_region_end; 144 | } 145 | Ordering::Less => { 146 | // Shrink the file by moving the following data closer to the start. 147 | let old_file_end = self.storage.file.seek(io::SeekFrom::End(0))?; 148 | let old_region_end = self.storage.region.end; 149 | let new_region_end = self.storage.region.start + buf_len; 150 | let new_file_end = 151 | self.storage.region.start + buf_len + (old_file_end - old_region_end); 152 | 153 | let mut rwbuf = [0; COPY_BUF_SIZE]; 154 | let rwbuf_len = rwbuf.len(); 155 | for i in 0.. { 156 | let from = old_region_end + i * rwbuf.len() as u64; 157 | let to = new_region_end + i * rwbuf.len() as u64; 158 | assert!(from > to); 159 | 160 | if from >= old_file_end { 161 | break; 162 | } 163 | let part = cmp::min(rwbuf_len as i64, (old_file_end - from) as i64); 164 | let rwbuf_part = &mut rwbuf[..part as usize]; 165 | self.storage.file.seek(io::SeekFrom::Start(from))?; 166 | self.storage.file.read_exact(rwbuf_part)?; 167 | self.storage.file.seek(io::SeekFrom::Start(to))?; 168 | self.storage.file.write_all(rwbuf_part)?; 169 | } 170 | 171 | self.storage.file.set_len(new_file_end)?; 172 | self.storage.region.end = new_region_end; 173 | } 174 | Ordering::Equal => {} 175 | } 176 | 177 | assert!(buf_len <= range_len(&self.storage.region)); 178 | // Okay, it's safe to commit our buffer to disk now. 179 | self.storage 180 | .file 181 | .seek(io::SeekFrom::Start(self.storage.region.start))?; 182 | self.storage.file.write_all(&self.buffer.get_ref()[..])?; 183 | self.storage.file.flush()?; 184 | self.buffer_changed = false; 185 | Ok(()) 186 | } 187 | } 188 | 189 | impl io::Seek for PlainWriter<'_, F> { 190 | fn seek(&mut self, pos: io::SeekFrom) -> io::Result { 191 | self.buffer.seek(pos) 192 | } 193 | } 194 | 195 | impl Drop for PlainWriter<'_, F> { 196 | fn drop(&mut self) { 197 | let _ = self.flush(); 198 | } 199 | } 200 | 201 | #[cfg(test)] 202 | mod tests { 203 | use super::*; 204 | use std::io::{Read, Seek}; 205 | use std::iter; 206 | 207 | #[test] 208 | fn plain_reader_range() { 209 | let buf: Vec = iter::repeat(0xff) 210 | .take(128) 211 | .chain(iter::repeat(0x00).take(128)) 212 | .chain(iter::repeat(0xff).take(128)) 213 | .collect(); 214 | let mut store = PlainStorage::new(io::Cursor::new(buf), 128..256); 215 | assert_eq!(128, store.reader().unwrap().bytes().count()); 216 | assert!(store.reader().unwrap().bytes().all(|b| b.unwrap() == 0x00)); 217 | } 218 | 219 | #[test] 220 | fn plain_reader_seek() { 221 | let buf: Vec = (0..128).collect(); 222 | let mut store = PlainStorage::new(io::Cursor::new(buf), 32..64); 223 | let mut r = store.reader().unwrap(); 224 | let mut rbuf = [0; 4]; 225 | assert_eq!(28, r.seek(io::SeekFrom::Start(28)).unwrap()); 226 | assert_eq!(4, r.read(&mut rbuf).unwrap()); 227 | assert_eq!(rbuf, [60, 61, 62, 63]); 228 | assert_eq!(0, r.read(&mut rbuf[..1]).unwrap()); 229 | assert_eq!(28, r.seek(io::SeekFrom::End(-4)).unwrap()); 230 | assert_eq!(4, r.read(&mut rbuf).unwrap()); 231 | assert_eq!(0, r.read(&mut rbuf[..1]).unwrap()); 232 | assert_eq!(48, r.seek(io::SeekFrom::Start(48)).unwrap()); 233 | assert_eq!(0, r.read(&mut rbuf[..1]).unwrap()); 234 | assert_eq!(28, r.seek(io::SeekFrom::Current(-20)).unwrap()); 235 | assert_eq!(4, r.read(&mut rbuf).unwrap()); 236 | assert_eq!(0, r.read(&mut rbuf[..1]).unwrap()); 237 | } 238 | 239 | #[test] 240 | fn plain_write_to_padding() { 241 | let buf: Vec = (0..128).collect(); 242 | let buf_reference = buf.clone(); 243 | let mut store = PlainStorage::new(io::Cursor::new(buf), 32..64); 244 | { 245 | let mut w = store.writer().unwrap(); 246 | w.write_all(&[0xff; 32]).unwrap(); 247 | w.flush().unwrap(); 248 | } 249 | assert_eq!(32..64, store.region); 250 | assert_eq!(128, store.file.get_ref().len()); 251 | assert_eq!( 252 | &buf_reference[0..32], 253 | &store.file.get_ref()[..store.region.start as usize] 254 | ); 255 | assert_eq!( 256 | &buf_reference[64..128], 257 | &store.file.get_ref()[store.region.end as usize..] 258 | ); 259 | assert_eq!(32, store.reader().unwrap().bytes().count()); 260 | assert!(store 261 | .reader() 262 | .unwrap() 263 | .bytes() 264 | .take(32) 265 | .all(|b| b.unwrap() == 0xff)); 266 | assert!(store 267 | .reader() 268 | .unwrap() 269 | .bytes() 270 | .skip(32) 271 | .all(|b| b.unwrap() == 0x00)); 272 | } 273 | 274 | #[test] 275 | fn plain_writer_grow() { 276 | let buf: Vec = (0..128).collect(); 277 | let buf_reference = buf.clone(); 278 | let mut store = PlainStorage::new(io::Cursor::new(buf), 64..64); 279 | { 280 | let mut w = store.writer().unwrap(); 281 | w.write_all(&[0xff; 64]).unwrap(); 282 | w.flush().unwrap(); 283 | } 284 | assert_eq!(64..128, store.region); 285 | assert_eq!(192, store.file.get_ref().len()); 286 | assert_eq!( 287 | &buf_reference[0..64], 288 | &store.file.get_ref()[..store.region.start as usize] 289 | ); 290 | assert_eq!( 291 | &buf_reference[64..128], 292 | &store.file.get_ref()[store.region.end as usize..] 293 | ); 294 | assert_eq!(64, store.reader().unwrap().bytes().count()); 295 | assert!(store.reader().unwrap().bytes().all(|b| b.unwrap() == 0xff)); 296 | } 297 | 298 | #[test] 299 | fn plain_writer_grow_large() { 300 | let buf: Vec = (0..40_000).map(|i| (i & 0xff) as u8).collect(); 301 | let buf_reference = buf.clone(); 302 | let mut store = PlainStorage::new(io::Cursor::new(buf), 2_000..22_000); 303 | { 304 | let mut w = store.writer().unwrap(); 305 | w.write_all(&[0xff; 40_000]).unwrap(); 306 | w.flush().unwrap(); 307 | } 308 | assert_eq!(2_000..42_000, store.region); 309 | assert_eq!(60_000, store.file.get_ref().len()); 310 | assert!(buf_reference[..2_000] == store.file.get_ref()[..store.region.start as usize]); 311 | assert!(buf_reference[22_000..] == store.file.get_ref()[store.region.end as usize..]); 312 | assert_eq!(40_000, store.reader().unwrap().bytes().count()); 313 | assert!(store 314 | .reader() 315 | .unwrap() 316 | .bytes() 317 | .take(40_000) 318 | .all(|b| b.unwrap() == 0xff)); 319 | assert!(store 320 | .reader() 321 | .unwrap() 322 | .bytes() 323 | .skip(40_000) 324 | .all(|b| b.unwrap() == 0x00)); 325 | } 326 | 327 | #[test] 328 | fn plain_writer_shrink() { 329 | let buf: Vec = (0..128).collect(); 330 | let mut store = PlainStorage::new(io::Cursor::new(buf), 32..96); 331 | { 332 | let mut w = store.writer().unwrap(); 333 | w.write_all(&[0xff; 32]).unwrap(); 334 | w.flush().unwrap(); 335 | } 336 | assert_eq!(32..64, store.region); 337 | assert_eq!(96, store.file.get_ref().len()); 338 | assert_eq!(32, store.reader().unwrap().bytes().count()); 339 | assert!(store.reader().unwrap().bytes().all(|b| b.unwrap() == 0xff)); 340 | } 341 | 342 | #[test] 343 | fn plain_writer_shrink_large() { 344 | let buf: Vec = (0..40_000).map(|i| (i & 0xff) as u8).collect(); 345 | let buf_reference = buf.clone(); 346 | let mut store = PlainStorage::new(io::Cursor::new(buf), 2_000..22_000); 347 | { 348 | let mut w = store.writer().unwrap(); 349 | w.write_all(&[0xff; 9_000]).unwrap(); 350 | w.flush().unwrap(); 351 | } 352 | assert_eq!(2_000..11_000, store.region); 353 | assert_eq!(29_000, store.file.get_ref().len()); 354 | assert!(buf_reference[22_000..] == store.file.get_ref()[store.region.end as usize..]); 355 | assert_eq!(9_000, store.reader().unwrap().bytes().count()); 356 | assert!(store 357 | .reader() 358 | .unwrap() 359 | .bytes() 360 | .take(9_000) 361 | .all(|b| b.unwrap() == 0xff)); 362 | assert!(store 363 | .reader() 364 | .unwrap() 365 | .bytes() 366 | .skip(9_000) 367 | .all(|b| b.unwrap() == 0x00)); 368 | } 369 | } 370 | -------------------------------------------------------------------------------- /src/stream/encoding.rs: -------------------------------------------------------------------------------- 1 | use crate::{Error, ErrorKind}; 2 | use std::convert::TryInto; 3 | 4 | /// Types of text encodings used in ID3 frames. 5 | #[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] 6 | pub enum Encoding { 7 | /// ISO-8859-1 text encoding, also referred to as latin1 encoding. 8 | Latin1, 9 | /// UTF-16 text encoding with a byte order mark. 10 | UTF16, 11 | /// UTF-16BE text encoding without a byte order mark. This encoding is only used in id3v2.4. 12 | UTF16BE, 13 | /// UTF-8 text encoding. This encoding is only used in id3v2.4. 14 | UTF8, 15 | } 16 | 17 | impl Encoding { 18 | pub(crate) fn decode(&self, bytes: impl AsRef<[u8]>) -> crate::Result { 19 | let bytes = bytes.as_ref(); 20 | if bytes.is_empty() { 21 | // UTF16 decoding requires at least 2 bytes for it not to error. 22 | return Ok("".to_string()); 23 | } 24 | match self { 25 | Encoding::Latin1 => Ok(string_from_latin1(bytes)), 26 | Encoding::UTF8 => Ok(String::from_utf8(bytes.to_vec())?), 27 | Encoding::UTF16 => string_from_utf16(bytes), 28 | Encoding::UTF16BE => string_from_utf16be(bytes), 29 | } 30 | } 31 | 32 | pub(crate) fn encode<'a>(&self, string: impl AsRef + 'a) -> Vec { 33 | let string = string.as_ref(); 34 | match self { 35 | Encoding::Latin1 => string_to_latin1(string), 36 | Encoding::UTF8 => string.as_bytes().to_vec(), 37 | Encoding::UTF16 => string_to_utf16(string), 38 | Encoding::UTF16BE => string_to_utf16be(string), 39 | } 40 | } 41 | } 42 | 43 | /// Returns a string created from the vector using Latin1 encoding. 44 | /// Can never return None because all sequences of u8s are valid Latin1 strings. 45 | fn string_from_latin1(data: &[u8]) -> String { 46 | data.iter().map(|b| *b as char).collect() 47 | } 48 | 49 | /// Returns a string created from the vector using UTF-16 (with byte order mark) encoding. 50 | fn string_from_utf16(data: &[u8]) -> crate::Result { 51 | if data.len() < 2 { 52 | return Err(Error::new( 53 | ErrorKind::StringDecoding(data.to_vec()), 54 | "data is not valid utf16", 55 | )); 56 | } 57 | if data[0] == 0xFF && data[1] == 0xFE { 58 | string_from_utf16le(&data[2..]) 59 | } else { 60 | string_from_utf16be(&data[2..]) 61 | } 62 | } 63 | 64 | fn string_from_utf16le(data: &[u8]) -> crate::Result { 65 | let mut data2 = Vec::with_capacity(data.len() / 2); 66 | for chunk in data.chunks_exact(2) { 67 | let bytes = chunk.try_into().unwrap(); 68 | data2.push(u16::from_le_bytes(bytes)); 69 | } 70 | String::from_utf16(&data2).map_err(|_| { 71 | Error::new( 72 | ErrorKind::StringDecoding(data.to_vec()), 73 | "data is not valid utf16-le", 74 | ) 75 | }) 76 | } 77 | 78 | fn string_from_utf16be(data: &[u8]) -> crate::Result { 79 | let mut data2 = Vec::with_capacity(data.len() / 2); 80 | for chunk in data.chunks_exact(2) { 81 | let bytes = chunk.try_into().unwrap(); 82 | data2.push(u16::from_be_bytes(bytes)); 83 | } 84 | String::from_utf16(&data2).map_err(|_| { 85 | Error::new( 86 | ErrorKind::StringDecoding(data.to_vec()), 87 | "data is not valid utf16-le", 88 | ) 89 | }) 90 | } 91 | 92 | fn string_to_latin1(text: &str) -> Vec { 93 | text.chars().map(|c| c as u8).collect() 94 | } 95 | 96 | /// Returns a UTF-16 (with native byte order) vector representation of the string. 97 | fn string_to_utf16(text: &str) -> Vec { 98 | let mut out = Vec::with_capacity(2 + text.len() * 2); 99 | if cfg!(target_endian = "little") { 100 | out.extend([0xFF, 0xFE]); // add little endian BOM 101 | out.extend(string_to_utf16le(text)); 102 | } else { 103 | out.extend([0xFE, 0xFF]); // add big endian BOM 104 | out.extend(string_to_utf16be(text)); 105 | } 106 | out 107 | } 108 | 109 | fn string_to_utf16be(text: &str) -> Vec { 110 | let encoder = text.encode_utf16(); 111 | let size_hint = encoder.size_hint(); 112 | 113 | let mut out = Vec::with_capacity(size_hint.1.unwrap_or(size_hint.0) * 2); 114 | for encoded_char in encoder { 115 | out.extend_from_slice(&encoded_char.to_be_bytes()); 116 | } 117 | out 118 | } 119 | 120 | fn string_to_utf16le(text: &str) -> Vec { 121 | let encoder = text.encode_utf16(); 122 | let size_hint = encoder.size_hint(); 123 | 124 | let mut out = Vec::with_capacity(size_hint.1.unwrap_or(size_hint.0) * 2); 125 | for encoded_char in encoder { 126 | out.extend_from_slice(&encoded_char.to_le_bytes()); 127 | } 128 | out 129 | } 130 | 131 | #[cfg(test)] 132 | mod tests { 133 | use super::*; 134 | 135 | #[test] 136 | fn test_strings() { 137 | let text: &str = "śốмễ śŧŗỉňĝ"; 138 | 139 | let mut utf8 = text.as_bytes().to_vec(); 140 | utf8.push(0); 141 | 142 | // should use little endian BOM 143 | assert_eq!(&string_to_utf16(text)[..], b"\xFF\xFE\x5B\x01\xD1\x1E\x3C\x04\xC5\x1E\x20\x00\x5B\x01\x67\x01\x57\x01\xC9\x1E\x48\x01\x1D\x01"); 144 | 145 | assert_eq!(&string_to_utf16be(text)[..], b"\x01\x5B\x1E\xD1\x04\x3C\x1E\xC5\x00\x20\x01\x5B\x01\x67\x01\x57\x1E\xC9\x01\x48\x01\x1D"); 146 | assert_eq!(&string_to_utf16le(text)[..], b"\x5B\x01\xD1\x1E\x3C\x04\xC5\x1E\x20\x00\x5B\x01\x67\x01\x57\x01\xC9\x1E\x48\x01\x1D\x01"); 147 | 148 | assert_eq!(&string_from_utf16be(b"\x01\x5B\x1E\xD1\x04\x3C\x1E\xC5\x00\x20\x01\x5B\x01\x67\x01\x57\x1E\xC9\x01\x48\x01\x1D").unwrap()[..], text); 149 | 150 | assert_eq!(&string_from_utf16le(b"\x5B\x01\xD1\x1E\x3C\x04\xC5\x1E\x20\x00\x5B\x01\x67\x01\x57\x01\xC9\x1E\x48\x01\x1D\x01").unwrap()[..], text); 151 | 152 | // big endian BOM 153 | assert_eq!(&string_from_utf16(b"\xFE\xFF\x01\x5B\x1E\xD1\x04\x3C\x1E\xC5\x00\x20\x01\x5B\x01\x67\x01\x57\x1E\xC9\x01\x48\x01\x1D").unwrap()[..], text); 154 | 155 | // little endian BOM 156 | assert_eq!(&string_from_utf16(b"\xFF\xFE\x5B\x01\xD1\x1E\x3C\x04\xC5\x1E\x20\x00\x5B\x01\x67\x01\x57\x01\xC9\x1E\x48\x01\x1D\x01").unwrap()[..], text); 157 | } 158 | 159 | #[test] 160 | fn test_latin1() { 161 | let text: &str = "stringþ"; 162 | assert_eq!(&string_to_latin1(text)[..], b"string\xFE"); 163 | assert_eq!(&string_from_latin1(b"string\xFE")[..], text); 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /src/stream/frame/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::frame::Content; 2 | use crate::frame::Frame; 3 | use crate::stream::encoding::Encoding; 4 | use crate::stream::unsynch; 5 | use crate::tag::Version; 6 | use flate2::read::ZlibDecoder; 7 | use std::io; 8 | use std::str; 9 | 10 | pub mod content; 11 | pub mod v2; 12 | pub mod v3; 13 | pub mod v4; 14 | 15 | pub fn decode(reader: impl io::Read, version: Version) -> crate::Result> { 16 | match version { 17 | Version::Id3v22 => unimplemented!(), 18 | Version::Id3v23 => v3::decode(reader), 19 | Version::Id3v24 => v4::decode(reader), 20 | } 21 | } 22 | 23 | fn decode_content( 24 | reader: impl io::Read, 25 | version: Version, 26 | id: &str, 27 | compression: bool, 28 | unsynchronisation: bool, 29 | ) -> crate::Result<(Content, Option)> { 30 | if unsynchronisation { 31 | let reader_unsynch = unsynch::Reader::new(reader); 32 | if compression { 33 | content::decode(id, version, ZlibDecoder::new(reader_unsynch)) 34 | } else { 35 | content::decode(id, version, reader_unsynch) 36 | } 37 | } else if compression { 38 | content::decode(id, version, ZlibDecoder::new(reader)) 39 | } else { 40 | content::decode(id, version, reader) 41 | } 42 | } 43 | 44 | pub fn encode( 45 | writer: impl io::Write, 46 | frame: &Frame, 47 | version: Version, 48 | unsynchronization: bool, 49 | ) -> crate::Result { 50 | match version { 51 | Version::Id3v22 => v2::encode(writer, frame), 52 | Version::Id3v23 => { 53 | let mut flags = v3::Flags::empty(); 54 | flags.set( 55 | v3::Flags::TAG_ALTER_PRESERVATION, 56 | frame.tag_alter_preservation(), 57 | ); 58 | flags.set( 59 | v3::Flags::FILE_ALTER_PRESERVATION, 60 | frame.file_alter_preservation(), 61 | ); 62 | v3::encode(writer, frame, flags) 63 | } 64 | Version::Id3v24 => { 65 | let mut flags = v4::Flags::empty(); 66 | flags.set(v4::Flags::UNSYNCHRONISATION, unsynchronization); 67 | flags.set( 68 | v4::Flags::TAG_ALTER_PRESERVATION, 69 | frame.tag_alter_preservation(), 70 | ); 71 | flags.set( 72 | v4::Flags::FILE_ALTER_PRESERVATION, 73 | frame.file_alter_preservation(), 74 | ); 75 | v4::encode(writer, frame, flags) 76 | } 77 | } 78 | } 79 | 80 | /// Helper for str::from_utf8 that preserves any problematic pattern if applicable. 81 | pub fn str_from_utf8(b: &[u8]) -> crate::Result<&str> { 82 | str::from_utf8(b).map_err(|err| { 83 | let bad = b[err.valid_up_to()..].to_vec(); 84 | crate::Error { 85 | kind: crate::ErrorKind::StringDecoding(bad.to_vec()), 86 | description: "data is not valid utf-8".to_string(), 87 | partial_tag: None, 88 | } 89 | }) 90 | } 91 | 92 | #[cfg(test)] 93 | mod tests { 94 | use super::*; 95 | use crate::frame::Frame; 96 | use crate::stream::encoding::Encoding; 97 | use crate::stream::unsynch; 98 | 99 | fn u32_to_bytes(n: u32) -> Vec { 100 | vec![ 101 | ((n & 0xFF00_0000) >> 24) as u8, 102 | ((n & 0xFF_0000) >> 16) as u8, 103 | ((n & 0xFF00) >> 8) as u8, 104 | (n & 0xFF) as u8, 105 | ] 106 | } 107 | 108 | #[test] 109 | fn test_to_bytes_v2() { 110 | let id = "TAL"; 111 | let text = "album"; 112 | let encoding = Encoding::UTF16; 113 | 114 | let mut data = Vec::new(); 115 | data.push(encoding as u8); 116 | data.extend(Encoding::UTF16.encode(text).into_iter()); 117 | 118 | let content = decode_content(&data[..], Version::Id3v22, id, false, false) 119 | .unwrap() 120 | .0; 121 | let frame = Frame::with_content(id, content); 122 | 123 | let mut bytes = Vec::new(); 124 | bytes.extend(id.bytes()); 125 | bytes.extend((u32_to_bytes(data.len() as u32)[1..]).iter().cloned()); 126 | bytes.extend(data.into_iter()); 127 | 128 | let mut writer = Vec::new(); 129 | encode(&mut writer, &frame, Version::Id3v22, false).unwrap(); 130 | assert_eq!(writer, bytes); 131 | } 132 | 133 | #[test] 134 | fn test_to_bytes_v3() { 135 | let id = "TALB"; 136 | let text = "album"; 137 | let encoding = Encoding::UTF16; 138 | 139 | let mut data = Vec::new(); 140 | data.push(encoding as u8); 141 | data.extend(Encoding::UTF16.encode(text).into_iter()); 142 | 143 | let content = decode_content(&data[..], Version::Id3v23, id, false, false) 144 | .unwrap() 145 | .0; 146 | let frame = Frame::with_content(id, content); 147 | 148 | let mut bytes = Vec::new(); 149 | bytes.extend(id.bytes()); 150 | bytes.extend(u32_to_bytes(data.len() as u32).into_iter()); 151 | bytes.extend([0x00, 0x00].iter().cloned()); 152 | bytes.extend(data.into_iter()); 153 | 154 | let mut writer = Vec::new(); 155 | encode(&mut writer, &frame, Version::Id3v23, false).unwrap(); 156 | assert_eq!(writer, bytes); 157 | } 158 | 159 | #[test] 160 | fn test_to_bytes_v4() { 161 | let id = "TALB"; 162 | let text = "album"; 163 | let encoding = Encoding::UTF8; 164 | 165 | let mut data = Vec::new(); 166 | data.push(encoding as u8); 167 | data.extend(text.bytes()); 168 | 169 | let content = decode_content(&data[..], Version::Id3v24, id, false, false) 170 | .unwrap() 171 | .0; 172 | let mut frame = Frame::with_content(id, content); 173 | frame.set_tag_alter_preservation(true); 174 | frame.set_file_alter_preservation(true); 175 | 176 | let mut bytes = Vec::new(); 177 | bytes.extend(id.bytes()); 178 | bytes.extend(u32_to_bytes(unsynch::encode_u32(data.len() as u32)).into_iter()); 179 | bytes.extend([0x60, 0x00].iter().cloned()); 180 | bytes.extend(data.into_iter()); 181 | 182 | let mut writer = Vec::new(); 183 | encode(&mut writer, &frame, Version::Id3v24, false).unwrap(); 184 | assert_eq!(writer, bytes); 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /src/stream/frame/v2.rs: -------------------------------------------------------------------------------- 1 | use crate::frame::Frame; 2 | use crate::stream::encoding::Encoding; 3 | use crate::stream::frame; 4 | use crate::tag::Version; 5 | use crate::{Error, ErrorKind}; 6 | use byteorder::{BigEndian, WriteBytesExt}; 7 | use std::io; 8 | 9 | pub fn decode(mut reader: impl io::Read) -> crate::Result> { 10 | let mut frame_header = [0; 6]; 11 | let nread = reader.read(&mut frame_header)?; 12 | if nread < frame_header.len() || frame_header[0] == 0x00 { 13 | return Ok(None); 14 | } 15 | let id = frame::str_from_utf8(&frame_header[0..3])?; 16 | let sizebytes = &frame_header[3..6]; 17 | let read_size = 18 | (u32::from(sizebytes[0]) << 16) | (u32::from(sizebytes[1]) << 8) | u32::from(sizebytes[2]); 19 | let (content, encoding) = 20 | super::content::decode(id, Version::Id3v22, reader.take(u64::from(read_size)))?; 21 | let frame = Frame::with_content(id, content).set_encoding(encoding); 22 | Ok(Some((6 + read_size as usize, frame))) 23 | } 24 | 25 | pub fn encode(mut writer: impl io::Write, frame: &Frame) -> crate::Result { 26 | let mut content_buf = Vec::new(); 27 | frame::content::encode( 28 | &mut content_buf, 29 | frame.content(), 30 | Version::Id3v22, 31 | frame.encoding().unwrap_or(Encoding::UTF16), 32 | )?; 33 | assert_ne!(0, content_buf.len()); 34 | let id = frame.id_for_version(Version::Id3v22).ok_or_else(|| { 35 | Error::new( 36 | ErrorKind::InvalidInput, 37 | "Unable to downgrade frame ID to ID3v2.2", 38 | ) 39 | })?; 40 | assert_eq!(3, id.len()); 41 | writer.write_all(id.as_bytes())?; 42 | writer.write_u24::(content_buf.len() as u32)?; 43 | writer.write_all(&content_buf)?; 44 | Ok(6 + content_buf.len()) 45 | } 46 | -------------------------------------------------------------------------------- /src/stream/frame/v3.rs: -------------------------------------------------------------------------------- 1 | use crate::frame::Frame; 2 | use crate::stream::encoding::Encoding; 3 | use crate::stream::frame; 4 | use crate::tag::Version; 5 | use crate::{Error, ErrorKind}; 6 | use bitflags::bitflags; 7 | use byteorder::{BigEndian, ByteOrder, ReadBytesExt, WriteBytesExt}; 8 | use flate2::write::ZlibEncoder; 9 | use flate2::Compression; 10 | use std::io; 11 | 12 | bitflags! { 13 | pub struct Flags: u16 { 14 | const TAG_ALTER_PRESERVATION = 0x8000; 15 | const FILE_ALTER_PRESERVATION = 0x4000; 16 | const READ_ONLY = 0x2000; 17 | const COMPRESSION = 0x0080; 18 | const ENCRYPTION = 0x0040; 19 | const GROUPING_IDENTITY = 0x0020; 20 | } 21 | } 22 | 23 | pub fn decode(mut reader: impl io::Read) -> crate::Result> { 24 | let mut frame_header = [0; 10]; 25 | let nread = reader.read(&mut frame_header)?; 26 | if nread < frame_header.len() || frame_header[0] == 0x00 { 27 | return Ok(None); 28 | } 29 | let id = frame::str_from_utf8(&frame_header[0..4])?; 30 | 31 | let content_size = BigEndian::read_u32(&frame_header[4..8]) as usize; 32 | let flags = Flags::from_bits_truncate(BigEndian::read_u16(&frame_header[8..10])); 33 | if flags.contains(Flags::ENCRYPTION) { 34 | return Err(Error::new( 35 | ErrorKind::UnsupportedFeature, 36 | "encryption is not supported", 37 | )); 38 | } else if flags.contains(Flags::GROUPING_IDENTITY) { 39 | return Err(Error::new( 40 | ErrorKind::UnsupportedFeature, 41 | "grouping identity is not supported", 42 | )); 43 | } 44 | 45 | let read_size = if flags.contains(Flags::COMPRESSION) { 46 | let _decompressed_size = reader.read_u32::()?; 47 | content_size - 4 48 | } else { 49 | content_size 50 | }; 51 | let mut content_buf = vec![0; read_size]; 52 | reader.read_exact(&mut content_buf)?; 53 | let (content, encoding) = super::decode_content( 54 | &content_buf[..], 55 | Version::Id3v23, 56 | id, 57 | flags.contains(Flags::COMPRESSION), 58 | false, 59 | )?; 60 | let frame = Frame::with_content(id, content).set_encoding(encoding); 61 | Ok(Some((10 + content_size, frame))) 62 | } 63 | 64 | pub fn encode(mut writer: impl io::Write, frame: &Frame, flags: Flags) -> crate::Result { 65 | let (content_buf, comp_hint_delta, decompressed_size) = if flags.contains(Flags::COMPRESSION) { 66 | let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default()); 67 | let content_size = frame::content::encode( 68 | &mut encoder, 69 | frame.content(), 70 | Version::Id3v23, 71 | frame.encoding().unwrap_or(Encoding::UTF16), 72 | )?; 73 | let content_buf = encoder.finish()?; 74 | (content_buf, 4, Some(content_size)) 75 | } else { 76 | let mut content_buf = Vec::new(); 77 | frame::content::encode( 78 | &mut content_buf, 79 | frame.content(), 80 | Version::Id3v23, 81 | frame.encoding().unwrap_or(Encoding::UTF16), 82 | )?; 83 | (content_buf, 0, None) 84 | }; 85 | 86 | writer.write_all({ 87 | let id = frame.id().as_bytes(); 88 | assert_eq!(4, id.len()); 89 | id 90 | })?; 91 | writer.write_u32::((content_buf.len() + comp_hint_delta) as u32)?; 92 | writer.write_u16::(flags.bits())?; 93 | if let Some(s) = decompressed_size { 94 | writer.write_u32::(s as u32)?; 95 | } 96 | writer.write_all(&content_buf)?; 97 | Ok(10 + comp_hint_delta + content_buf.len()) 98 | } 99 | -------------------------------------------------------------------------------- /src/stream/frame/v4.rs: -------------------------------------------------------------------------------- 1 | use crate::frame::Frame; 2 | use crate::stream::encoding::Encoding; 3 | use crate::stream::{frame, unsynch}; 4 | use crate::tag::Version; 5 | use crate::{Error, ErrorKind}; 6 | use bitflags::bitflags; 7 | use byteorder::{BigEndian, ByteOrder, ReadBytesExt, WriteBytesExt}; 8 | use flate2::write::ZlibEncoder; 9 | use flate2::Compression; 10 | use std::io; 11 | 12 | bitflags! { 13 | pub struct Flags: u16 { 14 | const TAG_ALTER_PRESERVATION = 0x4000; 15 | const FILE_ALTER_PRESERVATION = 0x2000; 16 | const READ_ONLY = 0x1000; 17 | const GROUPING_IDENTITY = 0x0040; 18 | const COMPRESSION = 0x0008; 19 | const ENCRYPTION = 0x0004; 20 | const UNSYNCHRONISATION = 0x0002; 21 | const DATA_LENGTH_INDICATOR = 0x0001; 22 | } 23 | } 24 | 25 | pub fn decode(mut reader: impl io::Read) -> crate::Result> { 26 | let mut frame_header = [0; 10]; 27 | let nread = reader.read(&mut frame_header)?; 28 | if nread < frame_header.len() || frame_header[0] == 0x00 { 29 | return Ok(None); 30 | } 31 | let id = frame::str_from_utf8(&frame_header[0..4])?; 32 | let content_size = unsynch::decode_u32(BigEndian::read_u32(&frame_header[4..8])) as usize; 33 | let flags = Flags::from_bits_truncate(BigEndian::read_u16(&frame_header[8..10])); 34 | if flags.contains(Flags::ENCRYPTION) { 35 | return Err(Error::new( 36 | ErrorKind::UnsupportedFeature, 37 | "encryption is not supported", 38 | )); 39 | } else if flags.contains(Flags::GROUPING_IDENTITY) { 40 | return Err(Error::new( 41 | ErrorKind::UnsupportedFeature, 42 | "grouping identity is not supported", 43 | )); 44 | } 45 | 46 | let read_size = if flags.contains(Flags::DATA_LENGTH_INDICATOR) { 47 | let _decompressed_size = unsynch::decode_u32(reader.read_u32::()?); 48 | content_size.saturating_sub(4) 49 | } else { 50 | content_size 51 | }; 52 | 53 | let (content, encoding) = super::decode_content( 54 | reader.take(read_size as u64), 55 | Version::Id3v24, 56 | id, 57 | flags.contains(Flags::COMPRESSION), 58 | flags.contains(Flags::UNSYNCHRONISATION), 59 | )?; 60 | let frame = Frame::with_content(id, content).set_encoding(encoding); 61 | Ok(Some((10 + content_size, frame))) 62 | } 63 | 64 | pub fn encode(mut writer: impl io::Write, frame: &Frame, flags: Flags) -> crate::Result { 65 | let (mut content_buf, comp_hint_delta, decompressed_size) = 66 | if flags.contains(Flags::COMPRESSION) { 67 | let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default()); 68 | let content_size = frame::content::encode( 69 | &mut encoder, 70 | frame.content(), 71 | Version::Id3v24, 72 | frame.encoding().unwrap_or(Encoding::UTF8), 73 | )?; 74 | let content_buf = encoder.finish()?; 75 | let cd = if flags.contains(Flags::DATA_LENGTH_INDICATOR) { 76 | 4 77 | } else { 78 | 0 79 | }; 80 | (content_buf, cd, Some(content_size)) 81 | } else { 82 | let mut content_buf = Vec::new(); 83 | frame::content::encode( 84 | &mut content_buf, 85 | frame.content(), 86 | Version::Id3v24, 87 | frame.encoding().unwrap_or(Encoding::UTF8), 88 | )?; 89 | (content_buf, 0, None) 90 | }; 91 | if flags.contains(Flags::UNSYNCHRONISATION) { 92 | unsynch::encode_vec(&mut content_buf); 93 | } 94 | 95 | writer.write_all({ 96 | let id = frame.id().as_bytes(); 97 | if id.len() != 4 { 98 | return Err(Error::new( 99 | ErrorKind::InvalidInput, 100 | "Frame ID must be 4 bytes long", 101 | )); 102 | } 103 | id 104 | })?; 105 | writer.write_u32::(unsynch::encode_u32( 106 | (content_buf.len() + comp_hint_delta) as u32, 107 | ))?; 108 | writer.write_u16::(flags.bits())?; 109 | if let Some(s) = decompressed_size { 110 | if flags.contains(Flags::DATA_LENGTH_INDICATOR) { 111 | writer.write_u32::(unsynch::encode_u32(s as u32))?; 112 | } 113 | } 114 | writer.write_all(&content_buf)?; 115 | Ok(10 + comp_hint_delta + content_buf.len()) 116 | } 117 | 118 | #[cfg(test)] 119 | mod tests { 120 | use super::*; 121 | use crate::frame::Content; 122 | use std::io::Cursor; 123 | 124 | #[test] 125 | fn test_encode_with_invalid_frame_id() { 126 | let frame = Frame::with_content("TST", Content::Text("Test content".to_string())); 127 | let flags = Flags::empty(); 128 | let mut writer = Cursor::new(Vec::new()); 129 | 130 | let result = encode(&mut writer, &frame, flags); 131 | 132 | assert!(result.is_err()); 133 | if let Err(e) = result { 134 | assert!(matches!(e.kind, ErrorKind::InvalidInput)); 135 | assert_eq!(e.description, "Frame ID must be 4 bytes long"); 136 | } 137 | } 138 | 139 | #[test] 140 | fn test_decode_with_underflow() { 141 | // Create a frame header with DATA_LENGTH_INDICATOR flag set and a content size of 3 142 | let frame_header = [ 143 | b'T', b'E', b'S', b'T', // Frame ID 144 | 0x00, 0x00, 0x00, 0x03, // Content size (3 bytes) 145 | 0x00, 0x01, // Flags (DATA_LENGTH_INDICATOR) 146 | ]; 147 | // Create a reader with the frame header followed by 4 bytes for the decompressed size 148 | let mut data = Vec::new(); 149 | data.extend_from_slice(&frame_header); 150 | data.extend_from_slice(&[0x00, 0x00, 0x00, 0x04]); // Decompressed size (4 bytes) 151 | 152 | let mut reader = Cursor::new(data); 153 | 154 | // Attempt to decode the frame 155 | let result = decode(&mut reader); 156 | 157 | // Ensure that the result is an error due to underflow 158 | assert!(result.is_err()); 159 | if let Err(e) = result { 160 | assert!(matches!(e.kind, ErrorKind::Parsing)); 161 | assert_eq!(e.description, "Insufficient data to decode bytes"); 162 | } 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /src/stream/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod encoding; 2 | pub mod frame; 3 | pub mod tag; 4 | pub mod unsynch; 5 | -------------------------------------------------------------------------------- /src/stream/tag.rs: -------------------------------------------------------------------------------- 1 | use crate::chunk; 2 | use crate::storage::{plain::PlainStorage, Format, Storage, StorageFile}; 3 | use crate::stream::{frame, unsynch}; 4 | use crate::tag::{Tag, Version}; 5 | use crate::taglike::TagLike; 6 | use crate::{Error, ErrorKind}; 7 | use bitflags::bitflags; 8 | use byteorder::{BigEndian, ByteOrder, WriteBytesExt}; 9 | use std::cmp; 10 | use std::fs; 11 | use std::io::{self, Read, Seek, Write}; 12 | use std::ops::Range; 13 | use std::path::Path; 14 | 15 | static DEFAULT_FILE_DISCARD: &[&str] = &[ 16 | "AENC", "ETCO", "EQUA", "MLLT", "POSS", "SYLT", "SYTC", "RVAD", "TENC", "TLEN", "TSIZ", 17 | ]; 18 | 19 | bitflags! { 20 | struct Flags: u8 { 21 | const UNSYNCHRONISATION = 0x80; // All versions 22 | const COMPRESSION = 0x40; // =ID3v2.2 23 | const EXTENDED_HEADER = 0x40; // >ID3v2.3, duplicate with TAG_COMPRESSION :( 24 | const EXPERIMENTAL = 0x20; // >ID3v2.3 25 | const FOOTER = 0x10; // >ID3v2.4 26 | } 27 | 28 | struct ExtFlags: u8 { 29 | const TAG_IS_UPDATE = 0x40; 30 | const CRC_DATA_PRESENT = 0x20; 31 | const TAG_RESTRICTIONS = 0x10; 32 | } 33 | } 34 | 35 | /// Used for sharing code between sync/async parsers, which is mainly complicated by ext_headers. 36 | struct HeaderBuilder { 37 | version: Version, 38 | flags: Flags, 39 | tag_size: u32, 40 | } 41 | 42 | impl HeaderBuilder { 43 | fn with_ext_header(self, size: u32) -> Header { 44 | Header { 45 | version: self.version, 46 | flags: self.flags, 47 | tag_size: self.tag_size, 48 | ext_header_size: size, 49 | } 50 | } 51 | } 52 | 53 | struct Header { 54 | version: Version, 55 | flags: Flags, 56 | tag_size: u32, 57 | 58 | // TODO: Extended header. 59 | ext_header_size: u32, 60 | } 61 | 62 | impl Header { 63 | fn size(&self) -> u64 { 64 | 10 // Raw header. 65 | } 66 | 67 | fn frame_bytes(&self) -> u64 { 68 | u64::from(self.tag_size).saturating_sub(u64::from(self.ext_header_size)) 69 | } 70 | 71 | fn tag_size(&self) -> u64 { 72 | self.size() + self.frame_bytes() 73 | } 74 | } 75 | 76 | impl Header { 77 | fn decode(mut reader: impl io::Read) -> crate::Result
{ 78 | let mut header = [0; 10]; 79 | let nread = reader.read(&mut header)?; 80 | let base_header = Self::decode_base_header(&header[..nread])?; 81 | 82 | // TODO: actually use the extended header data. 83 | let ext_header_size = if base_header.flags.contains(Flags::EXTENDED_HEADER) { 84 | let mut ext_header = [0; 6]; 85 | reader.read_exact(&mut ext_header)?; 86 | let ext_size = unsynch::decode_u32(BigEndian::read_u32(&ext_header[0..4])); 87 | // The extended header size includes itself and always has at least 2 bytes following. 88 | if ext_size < 6 { 89 | return Err(Error::new( 90 | ErrorKind::Parsing, 91 | "Extended header requires has a minimum size of 6", 92 | )); 93 | } 94 | 95 | let _ext_flags = ExtFlags::from_bits_truncate(ext_header[5]); 96 | 97 | let ext_remaining_size = ext_size - ext_header.len() as u32; 98 | let mut ext_header = Vec::with_capacity(cmp::min(ext_remaining_size as usize, 0xffff)); 99 | reader 100 | .by_ref() 101 | .take(ext_remaining_size as u64) 102 | .read_to_end(&mut ext_header)?; 103 | 104 | ext_size 105 | } else { 106 | 0 107 | }; 108 | 109 | Ok(base_header.with_ext_header(ext_header_size)) 110 | } 111 | 112 | #[cfg(feature = "tokio")] 113 | async fn async_decode( 114 | mut reader: impl tokio::io::AsyncRead + std::marker::Unpin, 115 | ) -> crate::Result
{ 116 | use tokio::io::AsyncReadExt; 117 | 118 | let mut header = [0; 10]; 119 | let nread = reader.read(&mut header).await?; 120 | let base_header = Self::decode_base_header(&header[..nread])?; 121 | 122 | // TODO: actually use the extended header data. 123 | let ext_header_size = if base_header.flags.contains(Flags::EXTENDED_HEADER) { 124 | let mut ext_header = [0; 6]; 125 | reader.read_exact(&mut ext_header).await?; 126 | let ext_size = unsynch::decode_u32(BigEndian::read_u32(&ext_header[0..4])); 127 | // The extended header size includes itself and always has at least 2 bytes following. 128 | if ext_size < 6 { 129 | return Err(Error::new( 130 | ErrorKind::Parsing, 131 | "Extended header requires has a minimum size of 6", 132 | )); 133 | } 134 | 135 | let _ext_flags = ExtFlags::from_bits_truncate(ext_header[5]); 136 | 137 | let ext_remaining_size = ext_size - ext_header.len() as u32; 138 | let mut ext_header = Vec::with_capacity(cmp::min(ext_remaining_size as usize, 0xffff)); 139 | reader 140 | .take(ext_remaining_size as u64) 141 | .read_to_end(&mut ext_header) 142 | .await?; 143 | 144 | ext_size 145 | } else { 146 | 0 147 | }; 148 | 149 | Ok(base_header.with_ext_header(ext_header_size)) 150 | } 151 | 152 | fn decode_base_header(header: &[u8]) -> crate::Result { 153 | if header.len() != 10 { 154 | return Err(Error::new( 155 | ErrorKind::NoTag, 156 | "reader is not large enough to contain a id3 tag", 157 | )); 158 | } 159 | 160 | if &header[0..3] != b"ID3" { 161 | return Err(Error::new( 162 | ErrorKind::NoTag, 163 | "reader does not contain an id3 tag", 164 | )); 165 | } 166 | 167 | let (ver_major, ver_minor) = (header[3], header[4]); 168 | let version = match (ver_major, ver_minor) { 169 | (2, _) => Version::Id3v22, 170 | (3, _) => Version::Id3v23, 171 | (4, _) => Version::Id3v24, 172 | (_, _) => { 173 | return Err(Error::new( 174 | ErrorKind::UnsupportedFeature, 175 | format!( 176 | "Unsupported id3 tag version: v2.{}.{}", 177 | ver_major, ver_minor 178 | ), 179 | )); 180 | } 181 | }; 182 | let flags = Flags::from_bits(header[5]) 183 | .ok_or_else(|| Error::new(ErrorKind::Parsing, "unknown tag header flags are set"))?; 184 | let tag_size = unsynch::decode_u32(BigEndian::read_u32(&header[6..10])); 185 | 186 | // compression only exists on 2.2 and conflicts with 2.3+'s extended header 187 | if version == Version::Id3v22 && flags.contains(Flags::COMPRESSION) { 188 | return Err(Error::new( 189 | ErrorKind::UnsupportedFeature, 190 | "id3v2.2 compression is not supported", 191 | )); 192 | } 193 | 194 | Ok(HeaderBuilder { 195 | version, 196 | flags, 197 | tag_size, 198 | }) 199 | } 200 | } 201 | 202 | pub fn decode(mut reader: impl io::Read) -> crate::Result { 203 | let header = Header::decode(&mut reader)?; 204 | 205 | decode_remaining(reader, header) 206 | } 207 | 208 | #[cfg(feature = "tokio")] 209 | pub async fn async_decode( 210 | mut reader: impl tokio::io::AsyncRead + std::marker::Unpin, 211 | ) -> crate::Result { 212 | let header = Header::async_decode(&mut reader).await?; 213 | 214 | let reader = { 215 | use tokio::io::AsyncReadExt; 216 | 217 | let mut buf = Vec::new(); 218 | 219 | reader 220 | .take(header.frame_bytes()) 221 | .read_to_end(&mut buf) 222 | .await?; 223 | std::io::Cursor::new(buf) 224 | }; 225 | 226 | decode_remaining(reader, header) 227 | } 228 | 229 | fn decode_remaining(mut reader: impl io::Read, header: Header) -> crate::Result { 230 | match header.version { 231 | Version::Id3v22 => { 232 | // Limit the reader only to the given tag_size, don't return any more bytes after that. 233 | let v2_reader = reader.take(header.frame_bytes()); 234 | 235 | if header.flags.contains(Flags::UNSYNCHRONISATION) { 236 | // Unwrap all 'unsynchronized' bytes in the tag before parsing frames. 237 | decode_v2_frames(unsynch::Reader::new(v2_reader)) 238 | } else { 239 | decode_v2_frames(v2_reader) 240 | } 241 | } 242 | Version::Id3v23 => { 243 | // Unsynchronization is applied to the whole tag, excluding the header. 244 | let mut reader: Box = if header.flags.contains(Flags::UNSYNCHRONISATION) { 245 | Box::new(unsynch::Reader::new(reader)) 246 | } else { 247 | Box::new(reader) 248 | }; 249 | 250 | let mut offset = 0; 251 | let mut tag = Tag::with_version(header.version); 252 | while offset < header.frame_bytes() { 253 | let v = match frame::v3::decode(&mut reader) { 254 | Ok(v) => v, 255 | Err(err) => return Err(err.with_tag(tag)), 256 | }; 257 | let (bytes_read, frame) = match v { 258 | Some(v) => v, 259 | None => break, // Padding. 260 | }; 261 | tag.add_frame(frame); 262 | offset += bytes_read as u64; 263 | } 264 | Ok(tag) 265 | } 266 | Version::Id3v24 => { 267 | let mut offset = 0; 268 | let mut tag = Tag::with_version(header.version); 269 | 270 | while offset < header.frame_bytes() { 271 | let v = match frame::v4::decode(&mut reader) { 272 | Ok(v) => v, 273 | Err(err) => return Err(err.with_tag(tag)), 274 | }; 275 | let (bytes_read, frame) = match v { 276 | Some(v) => v, 277 | None => break, // Padding. 278 | }; 279 | tag.add_frame(frame); 280 | offset += bytes_read as u64; 281 | } 282 | Ok(tag) 283 | } 284 | } 285 | } 286 | 287 | pub fn decode_v2_frames(mut reader: impl io::Read) -> crate::Result { 288 | let mut tag = Tag::with_version(Version::Id3v22); 289 | // Add all frames, until either an error is thrown or there are no more frames to parse 290 | // (because of EOF or a Padding). 291 | loop { 292 | let v = match frame::v2::decode(&mut reader) { 293 | Ok(v) => v, 294 | Err(err) => return Err(err.with_tag(tag)), 295 | }; 296 | match v { 297 | Some((_bytes_read, frame)) => { 298 | tag.add_frame(frame); 299 | } 300 | None => break Ok(tag), 301 | } 302 | } 303 | } 304 | 305 | /// The `Encoder` may be used to encode tags with custom settings. 306 | #[derive(Clone, Debug)] 307 | pub struct Encoder { 308 | version: Version, 309 | unsynchronisation: bool, 310 | compression: bool, 311 | file_altered: bool, 312 | padding: Option, 313 | } 314 | 315 | impl Encoder { 316 | /// Constructs a new `Encoder` with the following configuration: 317 | /// 318 | /// * [`Version`] is ID3v2.4 319 | /// * Unsynchronization is disabled due to compatibility issues 320 | /// * No compression 321 | /// * File is not marked as altered 322 | pub fn new() -> Self { 323 | Self { 324 | version: Version::Id3v24, 325 | unsynchronisation: false, 326 | compression: false, 327 | file_altered: false, 328 | padding: None, 329 | } 330 | } 331 | 332 | /// Sets the padding that is written after the tag. 333 | /// 334 | /// Should be only used when writing to a MP3 file 335 | pub fn padding(mut self, padding: usize) -> Self { 336 | self.padding = Some(padding); 337 | self 338 | } 339 | 340 | /// Sets the ID3 version. 341 | pub fn version(mut self, version: Version) -> Self { 342 | self.version = version; 343 | self 344 | } 345 | 346 | /// Enables or disables the unsynchronisation scheme. 347 | /// 348 | /// This avoids patterns that resemble MP3-frame headers from being 349 | /// encoded. If you are encoding to MP3 files and wish to be compatible 350 | /// with very old tools, you probably want this enabled. 351 | pub fn unsynchronisation(mut self, unsynchronisation: bool) -> Self { 352 | self.unsynchronisation = unsynchronisation; 353 | self 354 | } 355 | 356 | /// Enables or disables compression. 357 | pub fn compression(mut self, compression: bool) -> Self { 358 | self.compression = compression; 359 | self 360 | } 361 | 362 | /// Informs the encoder whether the file this tag belongs to has been changed. 363 | /// 364 | /// This subsequently discards any tags that have their File Alter Preservation bits set and 365 | /// that have a relation to the file contents: 366 | /// 367 | /// AENC, ETCO, EQUA, MLLT, POSS, SYLT, SYTC, RVAD, TENC, TLEN, TSIZ 368 | pub fn file_altered(mut self, file_altered: bool) -> Self { 369 | self.file_altered = file_altered; 370 | self 371 | } 372 | 373 | /// Encodes the specified [`Tag`] using the settings set in the [`Encoder`]. 374 | /// 375 | /// Note that the plain tag is written, regardless of the original contents. To safely encode a 376 | /// tag to an MP3 file, use [`Encoder::encode_to_path`]. 377 | pub fn encode(&self, tag: &Tag, mut writer: impl io::Write) -> crate::Result<()> { 378 | // remove frames which have the flags indicating they should be removed 379 | let saved_frames = tag 380 | .frames() 381 | // Assert that by encoding, we are changing the tag. If the Tag Alter Preservation bit 382 | // is set, discard the frame. 383 | .filter(|frame| !frame.tag_alter_preservation()) 384 | // If the file this tag belongs to is updated, check for the File Alter Preservation 385 | // bit. 386 | .filter(|frame| !self.file_altered || !frame.file_alter_preservation()) 387 | // Check whether this frame is part of the set of frames that should always be 388 | // discarded when the file is changed. 389 | .filter(|frame| !self.file_altered || !DEFAULT_FILE_DISCARD.contains(&frame.id())); 390 | 391 | let mut flags = Flags::empty(); 392 | flags.set(Flags::UNSYNCHRONISATION, self.unsynchronisation); 393 | if self.version == Version::Id3v22 { 394 | flags.set(Flags::COMPRESSION, self.compression); 395 | } 396 | 397 | let mut frame_data = Vec::new(); 398 | for frame in saved_frames { 399 | frame.validate()?; 400 | frame::encode(&mut frame_data, frame, self.version, self.unsynchronisation)?; 401 | } 402 | // In ID3v2.2/ID3v2.3, Unsynchronization is applied to the whole tag data at once, not for 403 | // each frame separately. 404 | if self.unsynchronisation { 405 | match self.version { 406 | Version::Id3v22 | Version::Id3v23 => unsynch::encode_vec(&mut frame_data), 407 | Version::Id3v24 => {} 408 | }; 409 | } 410 | let tag_size = frame_data.len() + self.padding.unwrap_or(0); 411 | writer.write_all(b"ID3")?; 412 | writer.write_all(&[self.version.minor(), 0])?; 413 | writer.write_u8(flags.bits())?; 414 | writer.write_u32::(unsynch::encode_u32(tag_size as u32))?; 415 | writer.write_all(&frame_data[..])?; 416 | 417 | if let Some(padding) = self.padding { 418 | writer.write_all(&vec![0; padding])?; 419 | } 420 | Ok(()) 421 | } 422 | 423 | /// Encodes a [`Tag`] and replaces any existing tag in the file. 424 | pub fn write_to_file(&self, tag: &Tag, mut file: impl StorageFile) -> crate::Result<()> { 425 | let mut probe = [0; 12]; 426 | let nread = file.read(&mut probe)?; 427 | file.seek(io::SeekFrom::Start(0))?; 428 | let storage_format = Format::magic(&probe[..nread]); 429 | 430 | match storage_format { 431 | Some(Format::Aiff) => { 432 | chunk::write_id3_chunk_file::(file, tag, self.version)?; 433 | } 434 | Some(Format::Wav) => { 435 | chunk::write_id3_chunk_file::(file, tag, self.version)?; 436 | } 437 | Some(Format::Header) => { 438 | let location = locate_id3v2(&mut file)?; 439 | let mut storage = PlainStorage::new(file, location); 440 | let mut w = storage.writer()?; 441 | self.encode(tag, &mut w)?; 442 | w.flush()?; 443 | } 444 | None => { 445 | let mut storage = PlainStorage::new(file, 0..0); 446 | let mut w = storage.writer()?; 447 | self.encode(tag, &mut w)?; 448 | w.flush()?; 449 | } 450 | }; 451 | 452 | Ok(()) 453 | } 454 | 455 | /// Encodes a [`Tag`] and replaces any existing tag in the file. 456 | #[deprecated(note = "Use write_to_file")] 457 | pub fn encode_to_file(&self, tag: &Tag, file: &mut fs::File) -> crate::Result<()> { 458 | self.write_to_file(tag, file) 459 | } 460 | 461 | /// Encodes a [`Tag`] and replaces any existing tag in the file pointed to by the specified path. 462 | pub fn write_to_path(&self, tag: &Tag, path: impl AsRef) -> crate::Result<()> { 463 | let mut file = fs::OpenOptions::new().read(true).write(true).open(path)?; 464 | self.write_to_file(tag, &mut file)?; 465 | file.flush()?; 466 | Ok(()) 467 | } 468 | 469 | /// Encodes a [`Tag`] and replaces any existing tag in the file pointed to by the specified path. 470 | #[deprecated(note = "Use write_to_path")] 471 | pub fn encode_to_path(&self, tag: &Tag, path: impl AsRef) -> crate::Result<()> { 472 | self.write_to_path(tag, path) 473 | } 474 | } 475 | 476 | impl Default for Encoder { 477 | fn default() -> Self { 478 | Self::new() 479 | } 480 | } 481 | 482 | pub fn locate_id3v2(reader: impl io::Read + io::Seek) -> crate::Result> { 483 | let mut reader = io::BufReader::new(reader); 484 | let start = reader.stream_position()?; 485 | 486 | let file_size = reader.seek(io::SeekFrom::End(0))?; 487 | reader.seek(io::SeekFrom::Start(start))?; 488 | 489 | let header = Header::decode(&mut reader)?; 490 | let tag_size = header.tag_size(); 491 | 492 | if start + tag_size >= file_size { 493 | // Seen in the wild: tags that are encoded to be larger than the files actuall are. 494 | reader.seek(io::SeekFrom::End(0))?; 495 | return Ok(start..file_size); 496 | } 497 | 498 | reader.seek(io::SeekFrom::Start(tag_size))?; 499 | let num_padding = reader 500 | .bytes() 501 | .take_while(|rs| rs.as_ref().map(|b| *b == 0x00).unwrap_or(false)) 502 | .count(); 503 | Ok(start..tag_size + num_padding as u64) 504 | } 505 | 506 | #[cfg(test)] 507 | mod tests { 508 | use super::*; 509 | use crate::frame::{ 510 | Chapter, Content, EncapsulatedObject, Frame, MpegLocationLookupTable, 511 | MpegLocationLookupTableReference, Picture, PictureType, Popularimeter, Private, 512 | SynchronisedLyrics, SynchronisedLyricsType, TableOfContents, TimestampFormat, 513 | UniqueFileIdentifier, Unknown, 514 | }; 515 | use std::fs::{self}; 516 | use std::io::{self, Read}; 517 | 518 | fn make_tag(version: Version) -> Tag { 519 | let mut tag = Tag::new(); 520 | tag.set_title("Title"); 521 | tag.set_artist("Artist"); 522 | tag.set_genre("Genre"); 523 | tag.add_frame(Frame::with_content( 524 | "TPE1", 525 | Content::new_text_values(["artist 1", "artist 2", "artist 3"]), 526 | )); 527 | tag.set_duration(1337); 528 | tag.add_frame(EncapsulatedObject { 529 | mime_type: "Some Object".to_string(), 530 | filename: "application/octet-stream".to_string(), 531 | description: "".to_string(), 532 | data: b"\xC0\xFF\xEE\x00".to_vec(), 533 | }); 534 | let mut image_data = Vec::new(); 535 | fs::File::open("testdata/image.jpg") 536 | .unwrap() 537 | .read_to_end(&mut image_data) 538 | .unwrap(); 539 | tag.add_frame(Picture { 540 | mime_type: "image/jpeg".to_string(), 541 | picture_type: PictureType::CoverFront, 542 | description: "an image".to_string(), 543 | data: image_data, 544 | }); 545 | tag.add_frame(Popularimeter { 546 | user: "user@example.com".to_string(), 547 | rating: 255, 548 | counter: 1337, 549 | }); 550 | tag.add_frame(SynchronisedLyrics { 551 | lang: "eng".to_string(), 552 | timestamp_format: TimestampFormat::Ms, 553 | content_type: SynchronisedLyricsType::Lyrics, 554 | content: vec![ 555 | (1000, "he".to_string()), 556 | (1100, "llo".to_string()), 557 | (1200, "world".to_string()), 558 | ], 559 | description: String::from("description"), 560 | }); 561 | if let Version::Id3v23 | Version::Id3v24 = version { 562 | tag.add_frame(Chapter { 563 | element_id: "01".to_string(), 564 | start_time: 1000, 565 | end_time: 2000, 566 | start_offset: 0xff, 567 | end_offset: 0xff, 568 | frames: vec![ 569 | Frame::with_content("TIT2", Content::Text("Foo".to_string())), 570 | Frame::with_content("TALB", Content::Text("Bar".to_string())), 571 | Frame::with_content("TCON", Content::Text("Baz".to_string())), 572 | ], 573 | }); 574 | tag.add_frame(TableOfContents { 575 | element_id: "table01".to_string(), 576 | top_level: true, 577 | ordered: true, 578 | elements: vec!["01".to_string()], 579 | frames: Vec::new(), 580 | }); 581 | tag.add_frame(MpegLocationLookupTable { 582 | frames_between_reference: 1, 583 | bytes_between_reference: 418, 584 | millis_between_reference: 12, 585 | bits_for_bytes: 4, 586 | bits_for_millis: 4, 587 | references: vec![ 588 | MpegLocationLookupTableReference { 589 | deviate_bytes: 0xa, 590 | deviate_millis: 0xf, 591 | }, 592 | MpegLocationLookupTableReference { 593 | deviate_bytes: 0xa, 594 | deviate_millis: 0x0, 595 | }, 596 | ], 597 | }); 598 | tag.add_frame(Private { 599 | owner_identifier: "PrivateFrameIdentifier1".to_string(), 600 | private_data: "SomePrivateBytes".into(), 601 | }); 602 | tag.add_frame(UniqueFileIdentifier { 603 | owner_identifier: String::from("http://www.id3.org/dummy/ufid.html"), 604 | identifier: "7FZo5fMqyG5Ys1dm8F1FHa".into(), 605 | }); 606 | tag.add_frame(UniqueFileIdentifier { 607 | owner_identifier: String::from("example.com"), 608 | identifier: "3107f6e3-99c0-44c1-9785-655fc9c32d8b".into(), 609 | }); 610 | } 611 | tag 612 | } 613 | 614 | #[test] 615 | fn read_id3v22() { 616 | let mut file = fs::File::open("testdata/id3v22.id3").unwrap(); 617 | let tag: Tag = decode(&mut file).unwrap(); 618 | assert_eq!("Henry Frottey INTRO", tag.title().unwrap()); 619 | assert_eq!("Hörbuch & Gesprochene Inhalte", tag.genre().unwrap()); 620 | assert_eq!(1, tag.disc().unwrap()); 621 | assert_eq!(27, tag.total_discs().unwrap()); 622 | assert_eq!(2015, tag.year().unwrap()); 623 | if cfg!(feature = "decode_picture") { 624 | assert_eq!( 625 | PictureType::Other, 626 | tag.pictures().next().unwrap().picture_type 627 | ); 628 | assert_eq!("", tag.pictures().next().unwrap().description); 629 | assert_eq!("image/jpeg", tag.pictures().next().unwrap().mime_type); 630 | } 631 | } 632 | 633 | #[cfg(feature = "tokio")] 634 | #[tokio::test] 635 | async fn read_id3v22_tokio() { 636 | let mut file = tokio::fs::File::open("testdata/id3v22.id3").await.unwrap(); 637 | let tag: Tag = async_decode(&mut file).await.unwrap(); 638 | assert_eq!("Henry Frottey INTRO", tag.title().unwrap()); 639 | assert_eq!("Hörbuch & Gesprochene Inhalte", tag.genre().unwrap()); 640 | assert_eq!(1, tag.disc().unwrap()); 641 | assert_eq!(27, tag.total_discs().unwrap()); 642 | assert_eq!(2015, tag.year().unwrap()); 643 | if cfg!(feature = "decode_picture") { 644 | assert_eq!( 645 | PictureType::Other, 646 | tag.pictures().next().unwrap().picture_type 647 | ); 648 | assert_eq!("", tag.pictures().next().unwrap().description); 649 | assert_eq!("image/jpeg", tag.pictures().next().unwrap().mime_type); 650 | } 651 | } 652 | 653 | #[test] 654 | fn read_id3v23() { 655 | let mut file = fs::File::open("testdata/id3v23.id3").unwrap(); 656 | let tag = decode(&mut file).unwrap(); 657 | assert_eq!("Title", tag.title().unwrap()); 658 | assert_eq!("Genre", tag.genre().unwrap()); 659 | assert_eq!(1, tag.disc().unwrap()); 660 | assert_eq!(1, tag.total_discs().unwrap()); 661 | if cfg!(feature = "decode_picture") { 662 | assert_eq!( 663 | PictureType::CoverFront, 664 | tag.pictures().next().unwrap().picture_type 665 | ); 666 | } 667 | } 668 | 669 | #[cfg(feature = "tokio")] 670 | #[tokio::test] 671 | async fn read_id3v23_tokio() { 672 | let mut file = tokio::fs::File::open("testdata/id3v23.id3").await.unwrap(); 673 | let tag = async_decode(&mut file).await.unwrap(); 674 | assert_eq!("Title", tag.title().unwrap()); 675 | assert_eq!("Genre", tag.genre().unwrap()); 676 | assert_eq!(1, tag.disc().unwrap()); 677 | assert_eq!(1, tag.total_discs().unwrap()); 678 | if cfg!(feature = "decode_picture") { 679 | assert_eq!( 680 | PictureType::CoverFront, 681 | tag.pictures().next().unwrap().picture_type 682 | ); 683 | } 684 | } 685 | 686 | #[test] 687 | fn read_id3v23_geob() { 688 | let mut file = fs::File::open("testdata/id3v23_geob.id3").unwrap(); 689 | let tag = decode(&mut file).unwrap(); 690 | assert_eq!(tag.encapsulated_objects().count(), 7); 691 | 692 | let geob = tag.encapsulated_objects().next().unwrap(); 693 | assert_eq!(geob.description, "Serato Overview"); 694 | assert_eq!(geob.mime_type, "application/octet-stream"); 695 | assert_eq!(geob.filename, ""); 696 | assert_eq!(geob.data.len(), 3842); 697 | 698 | let geob = tag.encapsulated_objects().nth(1).unwrap(); 699 | assert_eq!(geob.description, "Serato Analysis"); 700 | assert_eq!(geob.mime_type, "application/octet-stream"); 701 | assert_eq!(geob.filename, ""); 702 | assert_eq!(geob.data.len(), 2); 703 | 704 | let geob = tag.encapsulated_objects().nth(2).unwrap(); 705 | assert_eq!(geob.description, "Serato Autotags"); 706 | assert_eq!(geob.mime_type, "application/octet-stream"); 707 | assert_eq!(geob.filename, ""); 708 | assert_eq!(geob.data.len(), 21); 709 | 710 | let geob = tag.encapsulated_objects().nth(3).unwrap(); 711 | assert_eq!(geob.description, "Serato Markers_"); 712 | assert_eq!(geob.mime_type, "application/octet-stream"); 713 | assert_eq!(geob.filename, ""); 714 | assert_eq!(geob.data.len(), 318); 715 | 716 | let geob = tag.encapsulated_objects().nth(4).unwrap(); 717 | assert_eq!(geob.description, "Serato Markers2"); 718 | assert_eq!(geob.mime_type, "application/octet-stream"); 719 | assert_eq!(geob.filename, ""); 720 | assert_eq!(geob.data.len(), 470); 721 | 722 | let geob = tag.encapsulated_objects().nth(5).unwrap(); 723 | assert_eq!(geob.description, "Serato BeatGrid"); 724 | assert_eq!(geob.mime_type, "application/octet-stream"); 725 | assert_eq!(geob.filename, ""); 726 | assert_eq!(geob.data.len(), 39); 727 | 728 | let geob = tag.encapsulated_objects().nth(6).unwrap(); 729 | assert_eq!(geob.description, "Serato Offsets_"); 730 | assert_eq!(geob.mime_type, "application/octet-stream"); 731 | assert_eq!(geob.filename, ""); 732 | assert_eq!(geob.data.len(), 29829); 733 | } 734 | 735 | #[test] 736 | fn read_id3v23_chap() { 737 | let mut file = fs::File::open("testdata/id3v23_chap.id3").unwrap(); 738 | let tag = decode(&mut file).unwrap(); 739 | assert_eq!(tag.chapters().count(), 7); 740 | 741 | let chapter_titles = tag 742 | .chapters() 743 | .map(|chap| chap.frames.first().unwrap().content().text().unwrap()) 744 | .collect::>(); 745 | assert_eq!( 746 | chapter_titles, 747 | &[ 748 | "MPU 554", 749 | "Read-it-Later Services?", 750 | "Safari Reading List", 751 | "Third-Party Services", 752 | "What We’re Using", 753 | "David’s Research Workflow", 754 | "Apple’s September" 755 | ] 756 | ); 757 | } 758 | 759 | #[test] 760 | fn read_id3v23_ctoc() { 761 | let mut file = fs::File::open("testdata/id3v23_chap.id3").unwrap(); 762 | let tag = decode(&mut file).unwrap(); 763 | assert_eq!(tag.tables_of_contents().count(), 1); 764 | 765 | for x in tag.tables_of_contents() { 766 | println!("{:?}", x); 767 | } 768 | 769 | let ctoc = tag.tables_of_contents().last().unwrap(); 770 | 771 | assert_eq!(ctoc.element_id, "toc"); 772 | assert!(ctoc.top_level); 773 | assert!(ctoc.ordered); 774 | assert_eq!( 775 | ctoc.elements, 776 | &["chp0", "chp1", "chp2", "chp3", "chp4", "chp5", "chp6"] 777 | ); 778 | assert!(ctoc.frames.is_empty()); 779 | } 780 | 781 | #[test] 782 | fn read_id3v24() { 783 | let mut file = fs::File::open("testdata/id3v24.id3").unwrap(); 784 | let tag = decode(&mut file).unwrap(); 785 | assert_eq!("Title", tag.title().unwrap()); 786 | assert_eq!(1, tag.disc().unwrap()); 787 | assert_eq!(1, tag.total_discs().unwrap()); 788 | if cfg!(feature = "decode_picture") { 789 | assert_eq!( 790 | PictureType::CoverFront, 791 | tag.pictures().next().unwrap().picture_type 792 | ); 793 | } 794 | } 795 | 796 | #[test] 797 | fn read_id3v24_extended() { 798 | let mut file = fs::File::open("testdata/id3v24_ext.id3").unwrap(); 799 | let tag = decode(&mut file).unwrap(); 800 | assert_eq!("Title", tag.title().unwrap()); 801 | assert_eq!("Genre", tag.genre().unwrap()); 802 | assert_eq!("Artist", tag.artist().unwrap()); 803 | assert_eq!("Album", tag.album().unwrap()); 804 | assert_eq!(2, tag.track().unwrap()); 805 | } 806 | 807 | #[cfg(feature = "tokio")] 808 | #[tokio::test] 809 | async fn read_id3v24_extended_tokio() { 810 | let mut file = tokio::fs::File::open("testdata/id3v24_ext.id3") 811 | .await 812 | .unwrap(); 813 | let tag = async_decode(&mut file).await.unwrap(); 814 | assert_eq!("Title", tag.title().unwrap()); 815 | assert_eq!("Genre", tag.genre().unwrap()); 816 | assert_eq!("Artist", tag.artist().unwrap()); 817 | assert_eq!("Album", tag.album().unwrap()); 818 | assert_eq!(2, tag.track().unwrap()); 819 | } 820 | 821 | #[test] 822 | fn write_id3v22() { 823 | if !cfg!(feature = "decode_picture") { 824 | return; 825 | } 826 | 827 | let tag = make_tag(Version::Id3v22); 828 | let mut buffer = Vec::new(); 829 | Encoder::new() 830 | .version(Version::Id3v22) 831 | .encode(&tag, &mut buffer) 832 | .unwrap(); 833 | let tag_read = decode(&mut io::Cursor::new(buffer)).unwrap(); 834 | assert_eq!(tag, tag_read); 835 | } 836 | 837 | #[test] 838 | fn write_id3v22_unsynch() { 839 | if !cfg!(feature = "decode_picture") { 840 | return; 841 | } 842 | 843 | let tag = make_tag(Version::Id3v22); 844 | let mut buffer = Vec::new(); 845 | Encoder::new() 846 | .unsynchronisation(true) 847 | .version(Version::Id3v22) 848 | .encode(&tag, &mut buffer) 849 | .unwrap(); 850 | let tag_read = decode(&mut io::Cursor::new(buffer)).unwrap(); 851 | assert_eq!(tag, tag_read); 852 | } 853 | 854 | #[test] 855 | fn write_id3v22_invalid_id() { 856 | if !cfg!(feature = "decode_picture") { 857 | return; 858 | } 859 | 860 | let mut tag = make_tag(Version::Id3v22); 861 | tag.add_frame(Frame::with_content( 862 | "XXX", 863 | Content::Unknown(Unknown { 864 | version: Version::Id3v22, 865 | data: vec![1, 2, 3], 866 | }), 867 | )); 868 | tag.add_frame(Frame::with_content( 869 | "YYY", 870 | Content::Unknown(Unknown { 871 | version: Version::Id3v22, 872 | data: vec![4, 5, 6], 873 | }), 874 | )); 875 | tag.add_frame(Frame::with_content( 876 | "ZZZ", 877 | Content::Unknown(Unknown { 878 | version: Version::Id3v22, 879 | data: vec![7, 8, 9], 880 | }), 881 | )); 882 | let mut buffer = Vec::new(); 883 | Encoder::new() 884 | .version(Version::Id3v22) 885 | .encode(&tag, &mut buffer) 886 | .unwrap(); 887 | let tag_read = decode(&mut io::Cursor::new(buffer)).unwrap(); 888 | assert_eq!(tag, tag_read); 889 | } 890 | 891 | #[test] 892 | fn write_id3v23() { 893 | if !cfg!(feature = "decode_picture") { 894 | return; 895 | } 896 | 897 | let tag = make_tag(Version::Id3v23); 898 | let mut buffer = Vec::new(); 899 | Encoder::new() 900 | .version(Version::Id3v23) 901 | .encode(&tag, &mut buffer) 902 | .unwrap(); 903 | let tag_read = decode(&mut io::Cursor::new(buffer)).unwrap(); 904 | assert_eq!(tag, tag_read); 905 | } 906 | 907 | #[test] 908 | fn write_id3v23_compression() { 909 | if !cfg!(feature = "decode_picture") { 910 | return; 911 | } 912 | 913 | let tag = make_tag(Version::Id3v23); 914 | let mut buffer = Vec::new(); 915 | Encoder::new() 916 | .compression(true) 917 | .version(Version::Id3v23) 918 | .encode(&tag, &mut buffer) 919 | .unwrap(); 920 | let tag_read = decode(&mut io::Cursor::new(buffer)).unwrap(); 921 | assert_eq!(tag, tag_read); 922 | } 923 | 924 | #[test] 925 | fn write_id3v23_unsynch() { 926 | if !cfg!(feature = "decode_picture") { 927 | return; 928 | } 929 | 930 | let tag = make_tag(Version::Id3v23); 931 | let mut buffer = Vec::new(); 932 | Encoder::new() 933 | .unsynchronisation(true) 934 | .version(Version::Id3v23) 935 | .encode(&tag, &mut buffer) 936 | .unwrap(); 937 | let tag_read = decode(&mut io::Cursor::new(buffer)).unwrap(); 938 | assert_eq!(tag, tag_read); 939 | } 940 | 941 | #[test] 942 | fn write_id3v24() { 943 | if !cfg!(feature = "decode_picture") { 944 | return; 945 | } 946 | 947 | let tag = make_tag(Version::Id3v24); 948 | let mut buffer = Vec::new(); 949 | Encoder::new() 950 | .version(Version::Id3v24) 951 | .encode(&tag, &mut buffer) 952 | .unwrap(); 953 | let tag_read = decode(&mut io::Cursor::new(buffer)).unwrap(); 954 | assert_eq!(tag, tag_read); 955 | } 956 | 957 | #[test] 958 | fn write_id3v24_compression() { 959 | if !cfg!(feature = "decode_picture") { 960 | return; 961 | } 962 | 963 | let tag = make_tag(Version::Id3v24); 964 | let mut buffer = Vec::new(); 965 | Encoder::new() 966 | .compression(true) 967 | .version(Version::Id3v24) 968 | .encode(&tag, &mut buffer) 969 | .unwrap(); 970 | let tag_read = decode(&mut io::Cursor::new(buffer)).unwrap(); 971 | assert_eq!(tag, tag_read); 972 | } 973 | 974 | #[test] 975 | fn write_id3v24_unsynch() { 976 | if !cfg!(feature = "decode_picture") { 977 | return; 978 | } 979 | 980 | let tag = make_tag(Version::Id3v24); 981 | let mut buffer = Vec::new(); 982 | Encoder::new() 983 | .unsynchronisation(true) 984 | .version(Version::Id3v24) 985 | .encode(&tag, &mut buffer) 986 | .unwrap(); 987 | let tag_read = decode(&mut io::Cursor::new(buffer)).unwrap(); 988 | assert_eq!(tag, tag_read); 989 | } 990 | 991 | #[test] 992 | fn write_id3v24_alter_file() { 993 | if !cfg!(feature = "decode_picture") { 994 | return; 995 | } 996 | 997 | let mut tag = Tag::new(); 998 | tag.set_duration(1337); 999 | 1000 | let mut buffer = Vec::new(); 1001 | Encoder::new() 1002 | .version(Version::Id3v24) 1003 | .file_altered(true) 1004 | .encode(&tag, &mut buffer) 1005 | .unwrap(); 1006 | 1007 | let tag_read = decode(&mut io::Cursor::new(buffer)).unwrap(); 1008 | assert!(tag_read.get("TLEN").is_none()); 1009 | } 1010 | 1011 | #[test] 1012 | fn test_locate_id3v22() { 1013 | let file = fs::File::open("testdata/id3v22.id3").unwrap(); 1014 | let location = locate_id3v2(file).unwrap(); 1015 | assert_eq!(0..0x0000c3ea, location); 1016 | } 1017 | 1018 | #[test] 1019 | fn test_locate_id3v23() { 1020 | let file = fs::File::open("testdata/id3v23.id3").unwrap(); 1021 | let location = locate_id3v2(file).unwrap(); 1022 | assert_eq!(0..0x00006c0a, location); 1023 | } 1024 | 1025 | #[test] 1026 | fn test_locate_id3v24() { 1027 | let file = fs::File::open("testdata/id3v24.id3").unwrap(); 1028 | let location = locate_id3v2(file).unwrap(); 1029 | assert_eq!(0..0x00006c0a, location); 1030 | } 1031 | 1032 | #[test] 1033 | fn test_locate_id3v24_ext() { 1034 | let file = fs::File::open("testdata/id3v24_ext.id3").unwrap(); 1035 | let location = locate_id3v2(file).unwrap(); 1036 | assert_eq!(0..0x0000018d, location); 1037 | } 1038 | 1039 | #[test] 1040 | fn test_locate_no_tag() { 1041 | let file = fs::File::open("testdata/mpeg-header").unwrap(); 1042 | let location = locate_id3v2(file).unwrap_err(); 1043 | assert!(matches!( 1044 | location, 1045 | Error { 1046 | kind: ErrorKind::NoTag, 1047 | .. 1048 | } 1049 | )); 1050 | } 1051 | 1052 | #[test] 1053 | fn read_github_issue_60() { 1054 | let mut file = fs::File::open("testdata/github-issue-60.id3").unwrap(); 1055 | let _tag = decode(&mut file).unwrap(); 1056 | } 1057 | 1058 | #[test] 1059 | fn read_github_issue_73() { 1060 | let mut file = fs::File::open("testdata/github-issue-73.id3").unwrap(); 1061 | let mut tag = decode(&mut file).unwrap(); 1062 | assert_eq!(tag.track(), Some(9)); 1063 | 1064 | tag.set_total_tracks(16); 1065 | assert_eq!(tag.track(), Some(9)); 1066 | assert_eq!(tag.total_tracks(), Some(16)); 1067 | } 1068 | 1069 | #[test] 1070 | fn write_id3v24_ufids() { 1071 | let mut tag = make_tag(Version::Id3v24); 1072 | tag.add_frame(UniqueFileIdentifier { 1073 | owner_identifier: String::from("http://www.id3.org/dummy/ufid.html"), 1074 | identifier: "7FZo5fMqyG5Ys1dm8F1FHa".into(), 1075 | }); 1076 | assert_eq!(tag.unique_file_identifiers().count(), 2); 1077 | 1078 | tag.add_frame(UniqueFileIdentifier { 1079 | owner_identifier: String::from("http://www.id3.org/dummy/ufid.html"), 1080 | identifier: "09FxXfNTQsCgzkPmCeFwlr".into(), 1081 | }); 1082 | assert_eq!(tag.unique_file_identifiers().count(), 2); 1083 | 1084 | tag.add_frame(UniqueFileIdentifier { 1085 | owner_identifier: String::from("open.blotchify.com"), 1086 | identifier: "09FxXfNTQsCgzkPmCeFwlr".into(), 1087 | }); 1088 | 1089 | assert_eq!(tag.unique_file_identifiers().count(), 3); 1090 | 1091 | let mut buffer = Vec::new(); 1092 | Encoder::new() 1093 | .compression(true) 1094 | .version(Version::Id3v24) 1095 | .encode(&tag, &mut buffer) 1096 | .unwrap(); 1097 | let mut tag_read = decode(&mut io::Cursor::new(buffer)).unwrap(); 1098 | 1099 | if !cfg!(feature = "decode_picture") { 1100 | tag_read.remove_all_pictures(); 1101 | tag.remove_all_pictures(); 1102 | } 1103 | 1104 | assert_eq!(tag, tag_read); 1105 | } 1106 | 1107 | #[test] 1108 | fn test_frame_bytes_underflow() { 1109 | let header = Header { 1110 | version: Version::Id3v24, 1111 | flags: Flags::empty(), 1112 | tag_size: 10, 1113 | ext_header_size: 20, 1114 | }; 1115 | 1116 | // Without saturating_sub, this would underflow and cause a panic. 1117 | assert_eq!(header.frame_bytes(), 0); 1118 | } 1119 | } 1120 | -------------------------------------------------------------------------------- /src/stream/unsynch.rs: -------------------------------------------------------------------------------- 1 | //! The only purpose of unsynchronisation is to make the ID3v2 tag as compatible as possible with 2 | //! existing software and hardware. There is no use in 'unsynchronising' tags if the file is only 3 | //! to be processed only by ID3v2 aware software and hardware. Unsynchronisation is only useful 4 | //! with tags in MPEG 1/2 layer I, II and III, MPEG 2.5 and AAC files. 5 | use std::io; 6 | 7 | /// Returns the synchsafe variant of a `u32` value. 8 | pub fn encode_u32(n: u32) -> u32 { 9 | assert!(n < 0x1000_0000); 10 | let mut x: u32 = n & 0x7F | (n & 0xFFFF_FF80) << 1; 11 | x = x & 0x7FFF | (x & 0xFFFF_8000) << 1; 12 | x = x & 0x7F_FFFF | (x & 0xFF80_0000) << 1; 13 | x 14 | } 15 | 16 | /// Returns the unsynchsafe varaiant of a `u32` value. 17 | pub fn decode_u32(n: u32) -> u32 { 18 | n & 0xFF | (n & 0xFF00) >> 1 | (n & 0xFF_0000) >> 2 | (n & 0xFF00_0000) >> 3 19 | } 20 | 21 | /// Decoder for an unsynchronized stream of bytes. 22 | /// 23 | /// The decoder has an internal buffer. 24 | pub struct Reader 25 | where 26 | R: io::Read, 27 | { 28 | reader: R, 29 | buf: [u8; 8192], 30 | next: usize, 31 | available: usize, 32 | discard_next_null_byte: bool, 33 | } 34 | 35 | impl Reader 36 | where 37 | R: io::Read, 38 | { 39 | pub fn new(reader: R) -> Reader { 40 | Reader { 41 | reader, 42 | buf: [0; 8192], 43 | next: 0, 44 | available: 0, 45 | discard_next_null_byte: false, 46 | } 47 | } 48 | } 49 | 50 | impl io::Read for Reader 51 | where 52 | R: io::Read, 53 | { 54 | fn read(&mut self, buf: &mut [u8]) -> io::Result { 55 | let mut i = 0; 56 | 57 | while i < buf.len() { 58 | assert!(self.next <= self.available); 59 | if self.next == self.available { 60 | self.available = self.reader.read(&mut self.buf)?; 61 | self.next = 0; 62 | if self.available == 0 { 63 | break; 64 | } 65 | } 66 | 67 | if self.discard_next_null_byte && self.buf[self.next] == 0x00 { 68 | self.discard_next_null_byte = false; 69 | self.next += 1; 70 | continue; 71 | } 72 | self.discard_next_null_byte = false; 73 | 74 | buf[i] = self.buf[self.next]; 75 | i += 1; 76 | 77 | if self.buf[self.next] == 0xff { 78 | self.discard_next_null_byte = true; 79 | } 80 | self.next += 1; 81 | } 82 | 83 | Ok(i) 84 | } 85 | } 86 | 87 | /// Applies the unsynchronization scheme to a byte buffer. 88 | pub fn encode_vec(buffer: &mut Vec) { 89 | let mut repeat_next_null_byte = false; 90 | let mut i = 0; 91 | while i < buffer.len() { 92 | if buffer[i] == 0x00 && repeat_next_null_byte { 93 | buffer.insert(i, 0); 94 | i += 1; 95 | } 96 | repeat_next_null_byte = buffer[i] == 0xFF; 97 | i += 1; 98 | } 99 | } 100 | 101 | #[cfg(test)] 102 | mod tests { 103 | use super::*; 104 | use std::mem; 105 | 106 | fn decode_vec(buffer: &mut Vec) { 107 | let buf_len = buffer.len(); 108 | let from_buf = mem::replace(buffer, Vec::with_capacity(buf_len)); 109 | let mut reader = Reader::new(io::Cursor::new(from_buf)); 110 | io::copy(&mut reader, buffer).unwrap(); 111 | } 112 | 113 | #[test] 114 | fn synchsafe() { 115 | for i in 0..1 << 26 { 116 | assert_eq!(i, decode_u32(encode_u32(i))); 117 | } 118 | assert_eq!(0x7f7f7f7f, encode_u32(0x0fff_ffff)); 119 | assert_eq!(0x0fff_ffff, decode_u32(0x7f7f7f7f)); 120 | } 121 | 122 | #[test] 123 | fn synchronization() { 124 | let mut v = vec![66, 0, 255, 0, 255, 0, 0, 255, 66]; 125 | encode_vec(&mut v); 126 | assert_eq!(v, [66, 0, 255, 0, 0, 255, 0, 0, 0, 255, 66]); 127 | decode_vec(&mut v); 128 | assert_eq!(v, [66, 0, 255, 0, 255, 0, 0, 255, 66]); 129 | } 130 | 131 | #[test] 132 | fn synchronization_jpeg() { 133 | let orig = vec![ 134 | 0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x02, 135 | 0x00, 0x76, 136 | ]; 137 | let mut recoded = orig.clone(); 138 | encode_vec(&mut recoded); 139 | decode_vec(&mut recoded); 140 | assert_eq!(orig, recoded); 141 | } 142 | 143 | #[test] 144 | fn synchronization_large() { 145 | let mut orig = Vec::new(); 146 | for i in 0..1_000_000 { 147 | orig.push(0xff); 148 | orig.push(i as u8); 149 | } 150 | 151 | let mut recoded = orig.clone(); 152 | encode_vec(&mut recoded); 153 | decode_vec(&mut recoded); 154 | assert_eq!(orig, recoded); 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /src/tcon.rs: -------------------------------------------------------------------------------- 1 | use crate::v1::GENRE_LIST; 2 | use std::borrow::Cow; 3 | use std::mem::swap; 4 | 5 | #[derive(Copy, Clone)] 6 | pub struct Parser<'a>(&'a str); 7 | 8 | type ParseFunc = dyn Fn(&mut P) -> Result; 9 | 10 | impl<'a> Parser<'a> { 11 | pub fn parse_tcon(s: &'a str) -> Cow<'a, str> { 12 | let mut parser = Parser(s); 13 | let v1_genre_ids = match parser.one_or_more(&Self::content_type) { 14 | Ok(v) => v, 15 | Err(_) => return Cow::Borrowed(parser.0), 16 | }; 17 | let trailer = parser.trailer(); 18 | 19 | let strs: Vec = v1_genre_ids.into_iter().chain(trailer).collect(); 20 | Cow::Owned(strs.join(" ")) 21 | } 22 | 23 | fn content_type(&mut self) -> Result { 24 | self.first_of([&Self::escaped_content_type, &Self::v1_content_type]) 25 | } 26 | 27 | fn v1_content_type(&mut self) -> Result { 28 | self.expect("(")?; 29 | let t = self.first_of([ 30 | &|p: &mut Self| p.expect("RX").map(|_| "Remix".to_string()), 31 | &|p: &mut Self| p.expect("CR").map(|_| "Cover".to_string()), 32 | &|p: &mut Self| { 33 | p.parse_number() 34 | .map(|index| match GENRE_LIST.get(index as usize) { 35 | Some(v1_genre) => v1_genre.to_string(), 36 | None => format!("({})", index), 37 | }) 38 | }, 39 | ])?; 40 | self.expect(")")?; 41 | Ok(t) 42 | } 43 | 44 | fn escaped_content_type(&mut self) -> Result { 45 | self.expect("((")?; 46 | let t = format!("({}", self.0); 47 | self.0 = ""; 48 | Ok(t) 49 | } 50 | 51 | fn trailer(&mut self) -> Result { 52 | let mut tmp = ""; 53 | swap(&mut tmp, &mut self.0); 54 | if tmp.is_empty() { 55 | return Err(()); 56 | } 57 | Ok(tmp.to_string()) 58 | } 59 | 60 | fn expect<'s>(&mut self, prefix: &'s str) -> Result<&'s str, ()> { 61 | if self.0.starts_with(prefix) { 62 | self.0 = &self.0[prefix.len()..]; 63 | Ok(prefix) 64 | } else { 65 | Err(()) 66 | } 67 | } 68 | 69 | fn one_or_more(&mut self, func: &ParseFunc) -> Result, ()> { 70 | let mut values = Vec::new(); 71 | while let Ok(v) = func(self) { 72 | values.push(v); 73 | } 74 | if values.is_empty() { 75 | return Err(()); 76 | } 77 | Ok(values) 78 | } 79 | 80 | fn first_of(&mut self, funcs: [&ParseFunc; N]) -> Result { 81 | for func in funcs { 82 | let mut p = *self; 83 | if let Ok(v) = func(&mut p) { 84 | *self = p; 85 | return Ok(v); 86 | } 87 | } 88 | Err(()) 89 | } 90 | 91 | fn parse_number(&mut self) -> Result { 92 | let mut ok = false; 93 | let mut r = 0u32; 94 | while self.0.starts_with(|c: char| c.is_ascii_digit()) { 95 | ok = true; 96 | r = if let Some(r) = r 97 | .checked_mul(10) 98 | .and_then(|r| r.checked_add(u32::from(self.0.as_bytes()[0] - b'0'))) 99 | { 100 | r 101 | } else { 102 | return Err(()); 103 | }; 104 | self.0 = &self.0[1..]; 105 | } 106 | if ok { 107 | Ok(r) 108 | } else { 109 | Err(()) 110 | } 111 | } 112 | } 113 | 114 | #[cfg(test)] 115 | mod tests { 116 | use super::*; 117 | 118 | #[test] 119 | fn plain_genre() { 120 | let s = Parser::parse_tcon("Just a regular genre"); 121 | assert_eq!(s, "Just a regular genre"); 122 | } 123 | 124 | #[test] 125 | fn v1_genre() { 126 | let s = Parser::parse_tcon("(0)"); 127 | assert_eq!(s, "Blues"); 128 | let s = Parser::parse_tcon("(28)(31)"); 129 | assert_eq!(s, "Vocal Trance"); 130 | } 131 | 132 | #[test] 133 | fn v1_genre_plain_trailer() { 134 | let s = Parser::parse_tcon("(28)Trance"); 135 | assert_eq!(s, "Vocal Trance"); 136 | } 137 | 138 | #[test] 139 | fn escaping() { 140 | let s = Parser::parse_tcon("((Foo)"); 141 | assert_eq!(s, "(Foo)"); 142 | let s = Parser::parse_tcon("(31)((or is it?)"); 143 | assert_eq!(s, "Trance (or is it?)"); 144 | } 145 | 146 | #[test] 147 | fn v2_genre() { 148 | let s = Parser::parse_tcon("(RX)"); 149 | assert_eq!(s, "Remix"); 150 | let s = Parser::parse_tcon("(CR)"); 151 | assert_eq!(s, "Cover"); 152 | } 153 | 154 | #[test] 155 | fn malformed() { 156 | let s = Parser::parse_tcon("(lol)"); 157 | assert_eq!(s, "(lol)"); 158 | let s = Parser::parse_tcon("(RXlol)"); 159 | assert_eq!(s, "(RXlol)"); 160 | let s = Parser::parse_tcon("(CRlol)"); 161 | assert_eq!(s, "(CRlol)"); 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /src/v1.rs: -------------------------------------------------------------------------------- 1 | use crate::{Error, ErrorKind, StorageFile}; 2 | use std::cmp; 3 | use std::fs; 4 | use std::io; 5 | use std::ops; 6 | use std::path::Path; 7 | 8 | /// Location of the ID3v1 tag chunk relative to the end of the file. 9 | static TAG_CHUNK: ops::Range = -128..0; 10 | /// Location of the ID3v1 extended tag chunk relative to the end of the file. 11 | static XTAG_CHUNK: ops::Range = -355..-128; 12 | 13 | pub(crate) static GENRE_LIST: &[&str] = &[ 14 | "Blues", 15 | "Classic Rock", 16 | "Country", 17 | "Dance", 18 | "Disco", 19 | "Funk", 20 | "Grunge", 21 | "Hip-Hop", 22 | "Jazz", 23 | "Metal", 24 | "New Age", 25 | "Oldies", 26 | "Other", 27 | "Pop", 28 | "R&B", 29 | "Rap", 30 | "Reggae", 31 | "Rock", 32 | "Techno", 33 | "Industrial", 34 | "Alternative", 35 | "Ska", 36 | "Death Metal", 37 | "Pranks", 38 | "Soundtrack", 39 | "Euro-Techno", 40 | "Ambient", 41 | "Trip-Hop", 42 | "Vocal", 43 | "Jazz+Funk", 44 | "Fusion", 45 | "Trance", 46 | "Classical", 47 | "Instrumental", 48 | "Acid", 49 | "House", 50 | "Game", 51 | "Sound Clip", 52 | "Gospel", 53 | "Noise", 54 | "Alternative Rock", 55 | "Bass", 56 | "Soul", 57 | "Punk", 58 | "Space", 59 | "Meditative", 60 | "Instrumental Pop", 61 | "Instrumental Rock", 62 | "Ethnic", 63 | "Gothic", 64 | "Darkwave", 65 | "Techno-Industrial", 66 | "Electronic", 67 | "Pop-Folk", 68 | "Eurodance", 69 | "Dream", 70 | "Southern Rock", 71 | "Comedy", 72 | "Cult", 73 | "Gangsta", 74 | "Top 40", 75 | "Christian Rap", 76 | "Pop/Funk", 77 | "Jungle", 78 | "Native US", 79 | "Cabaret", 80 | "New Wave", 81 | "Psychadelic", 82 | "Rave", 83 | "Showtunes", 84 | "Trailer", 85 | "Lo-Fi", 86 | "Tribal", 87 | "Acid Punk", 88 | "Acid Jazz", 89 | "Polka", 90 | "Retro", 91 | "Musical", 92 | "Rock & Roll", 93 | "Hard Rock", 94 | "Folk", 95 | "Folk-Rock", 96 | "National Folk", 97 | "Swing", 98 | "Fast Fusion", 99 | "Bebob", 100 | "Latin", 101 | "Revival", 102 | "Celtic", 103 | "Bluegrass", 104 | "Avantgarde", 105 | "Gothic Rock", 106 | "Progressive Rock", 107 | "Psychedelic Rock", 108 | "Symphonic Rock", 109 | "Slow Rock", 110 | "Big Band", 111 | "Chorus", 112 | "Easy Listening", 113 | "Acoustic", 114 | "Humour", 115 | "Speech", 116 | "Chanson", 117 | "Opera", 118 | "Chamber Music", 119 | "Sonata", 120 | "Symphony", 121 | "Booty Bass", 122 | "Primus", 123 | "Porn Groove", 124 | "Satire", 125 | "Slow Jam", 126 | "Club", 127 | "Tango", 128 | "Samba", 129 | "Folklore", 130 | "Ballad", 131 | "Power Ballad", 132 | "Rhytmic Soul", 133 | "Freestyle", 134 | "Duet", 135 | "Punk Rock", 136 | "Drum Solo", 137 | "Acapella", 138 | "Euro-House", 139 | "Dance Hall", 140 | "Goa", 141 | "Drum & Bass", 142 | "Club-House", 143 | "Hardcore", 144 | "Terror", 145 | "Indie", 146 | "BritPop", 147 | "Negerpunk", 148 | "Polsk Punk", 149 | "Beat", 150 | "Christian Gangsta", 151 | "Heavy Metal", 152 | "Black Metal", 153 | "Crossover", 154 | "Contemporary C", 155 | "Christian Rock", 156 | "Merengue", 157 | "Salsa", 158 | "Thrash Metal", 159 | "Anime", 160 | "JPop", 161 | "SynthPop", 162 | ]; 163 | 164 | /// A structure containing ID3v1 metadata. 165 | #[derive(Clone, Debug, Default, PartialEq, Eq, Hash)] 166 | pub struct Tag { 167 | /// The full title (ID3v1 + extension if present). 168 | pub title: String, 169 | /// The full artist (ID3v1 + extension if present). 170 | pub artist: String, 171 | /// The full album (ID3v1 + extension if present). 172 | pub album: String, 173 | /// The release year as four digits. 174 | /// 175 | /// The ID3v1 format can only represent values between 0 and 9999 inclusive. 176 | pub year: String, 177 | /// A free-form comment. 178 | pub comment: String, 179 | /// Number of the track. ID3v1.1 data. 180 | pub track: Option, 181 | /// The genre mapping is standardized up to 79, altough this implementation uses the Winamp 182 | /// extended genre list: 183 | /// 184 | pub genre_id: u8, 185 | 186 | /// 1 (slow), 2, 3, 4 (fast) or None when not set. ID3v1 extended data. 187 | pub speed: Option, 188 | /// Free-form genre string. ID3v1 extended data. 189 | pub genre_str: Option, 190 | /// The real start of the track, mmm:ss. ID3v1 extended data. 191 | pub start_time: Option, 192 | /// The real end of the track, mmm:ss. ID3v1 extended data. 193 | pub end_time: Option, 194 | } 195 | 196 | impl Tag { 197 | /// Creates a new empty ID3v1 tag. 198 | pub fn new() -> Tag { 199 | Tag::default() 200 | } 201 | 202 | /// Checks whether the reader contains an ID3v1 tag. 203 | /// 204 | /// The reader position will be reset back to the previous position before returning. 205 | pub fn is_candidate(mut reader: impl io::Read + io::Seek) -> crate::Result { 206 | let initial_position = reader.stream_position()?; 207 | reader.seek(io::SeekFrom::End(TAG_CHUNK.start))?; 208 | let mut buf = [0; 3]; 209 | let nread = reader.read(&mut buf)?; 210 | reader.seek(io::SeekFrom::Start(initial_position))?; 211 | Ok(&buf[..nread] == b"TAG") 212 | } 213 | 214 | /// Seeks to and reads a ID3v1 tag from the reader. 215 | pub fn read_from(mut reader: impl io::Read + io::Seek) -> crate::Result { 216 | let mut tag_buf = [0; 355]; 217 | let file_len = reader.seek(io::SeekFrom::End(0))?; 218 | if file_len >= XTAG_CHUNK.start.unsigned_abs() { 219 | reader.seek(io::SeekFrom::End(XTAG_CHUNK.start))?; 220 | reader.read_exact(&mut tag_buf)?; 221 | } else if file_len >= TAG_CHUNK.start.unsigned_abs() { 222 | let l = tag_buf.len() as i64; 223 | reader.seek(io::SeekFrom::End(TAG_CHUNK.start))?; 224 | reader.read_exact(&mut tag_buf[(l + TAG_CHUNK.start) as usize..])?; 225 | } else { 226 | return Err(Error::new( 227 | ErrorKind::NoTag, 228 | "the file is too small to contain an ID3v1 tag", 229 | )); 230 | } 231 | 232 | let (tag, xtag) = { 233 | let (xtag, tag) = (&tag_buf[..227], &tag_buf[227..]); 234 | if &tag[0..3] != b"TAG" { 235 | return Err(Error::new(ErrorKind::NoTag, "no ID3v1 tag was found")); 236 | } 237 | ( 238 | tag, 239 | if &xtag[0..4] == b"TAG+" { 240 | Some(xtag) 241 | } else { 242 | None 243 | }, 244 | ) 245 | }; 246 | 247 | // Decodes a string consisting out of a base and possible extension to a String. 248 | // The input are one or two null-terminated ISO-8859-1 byte slices. 249 | fn decode_str(base: &[u8], ext: Option<&[u8]>) -> String { 250 | base.iter() 251 | .take_while(|c| **c != 0) 252 | .chain({ 253 | ext.into_iter() 254 | .flat_map(|s| s.iter()) 255 | .take_while(|c| **c != 0) 256 | }) 257 | // This works because the ISO 8859-1 code points match the unicode code 258 | // points. So,`c as char` will map correctly from ISO to unicode. 259 | .map(|c| *c as char) 260 | .collect() 261 | } 262 | let title = decode_str(&tag[3..33], xtag.as_ref().map(|t| &t[4..64])); 263 | let artist = decode_str(&tag[33..63], xtag.as_ref().map(|t| &t[64..124])); 264 | let album = decode_str(&tag[63..93], xtag.as_ref().map(|t| &t[124..184])); 265 | let year = decode_str(&tag[93..97], None); 266 | let (track, comment_raw) = if tag[125] == 0 && tag[126] != 0 { 267 | (Some(tag[126]), &tag[97..125]) 268 | } else { 269 | (None, &tag[97..127]) 270 | }; 271 | let comment = decode_str(comment_raw, None); 272 | let genre_id = tag[127]; 273 | let (speed, genre_str, start_time, end_time) = if let Some(xt) = xtag { 274 | let speed = if xt[184] == 0 { None } else { Some(xt[184]) }; 275 | let genre_str = decode_str(&xt[185..215], None); 276 | let start_time = decode_str(&xt[185..215], None); 277 | let end_time = decode_str(&xt[185..215], None); 278 | (speed, Some(genre_str), Some(start_time), Some(end_time)) 279 | } else { 280 | (None, None, None, None) 281 | }; 282 | 283 | Ok(Tag { 284 | title, 285 | artist, 286 | album, 287 | year, 288 | comment, 289 | track, 290 | genre_id, 291 | speed, 292 | genre_str, 293 | start_time, 294 | end_time, 295 | }) 296 | } 297 | 298 | /// Attempts to read an ID3v1 tag from the file at the indicated path. 299 | pub fn read_from_path(path: impl AsRef) -> crate::Result { 300 | let file = fs::File::open(path)?; 301 | Tag::read_from(file) 302 | } 303 | 304 | /// Removes an ID3v1 tag plus possible extended data if any. 305 | /// 306 | /// The file cursor position will be reset back to the previous position before returning. 307 | /// 308 | /// Returns true if the file initially contained a tag. 309 | #[deprecated(note = "Use remove_from_file")] 310 | pub fn remove(file: &mut fs::File) -> crate::Result { 311 | Self::remove_from_file(file) 312 | } 313 | 314 | /// Removes an ID3v1 tag plus possible extended data if any. 315 | /// 316 | /// The file cursor position will be reset back to the previous position before returning. 317 | /// 318 | /// Returns true if the file initially contained a tag. 319 | pub fn remove_from_file(mut file: impl StorageFile) -> crate::Result { 320 | let cur_pos = file.stream_position()?; 321 | let file_len = file.seek(io::SeekFrom::End(0))?; 322 | let has_ext_tag = if file_len >= XTAG_CHUNK.start.unsigned_abs() { 323 | file.seek(io::SeekFrom::End(XTAG_CHUNK.start))?; 324 | let mut b = [0; 4]; 325 | file.read_exact(&mut b)?; 326 | &b == b"TAG+" 327 | } else { 328 | false 329 | }; 330 | let has_tag = if file_len >= TAG_CHUNK.start.unsigned_abs() { 331 | file.seek(io::SeekFrom::End(TAG_CHUNK.start))?; 332 | let mut b = [0; 3]; 333 | file.read_exact(&mut b)?; 334 | &b == b"TAG" 335 | } else { 336 | false 337 | }; 338 | 339 | let truncate_to = if has_ext_tag && has_tag { 340 | Some(file_len - XTAG_CHUNK.start.unsigned_abs()) 341 | } else if has_tag { 342 | Some(file_len - TAG_CHUNK.start.unsigned_abs()) 343 | } else { 344 | None 345 | }; 346 | file.seek(io::SeekFrom::Start(cmp::min( 347 | truncate_to.unwrap_or(cur_pos), 348 | cur_pos, 349 | )))?; 350 | if let Some(l) = truncate_to { 351 | file.set_len(l)?; 352 | } 353 | Ok(truncate_to.is_some()) 354 | } 355 | 356 | /// Removes an ID3v1 tag plus possible extended data if any. 357 | /// 358 | /// Returns true if the file initially contained a tag. 359 | pub fn remove_from_path(path: impl AsRef) -> crate::Result { 360 | let mut file = fs::OpenOptions::new().read(true).write(true).open(path)?; 361 | Tag::remove_from_file(&mut file) 362 | } 363 | 364 | /// Returns `genre_str`, falling back to translating `genre_id` to a string. 365 | pub fn genre(&self) -> Option<&str> { 366 | if let Some(ref g) = self.genre_str { 367 | if !g.is_empty() { 368 | return Some(g.as_str()); 369 | } 370 | } 371 | GENRE_LIST.get(self.genre_id as usize).cloned() 372 | } 373 | } 374 | 375 | #[cfg(test)] 376 | mod tests { 377 | use super::*; 378 | use std::fs; 379 | use std::io::Seek; 380 | use tempfile::tempdir; 381 | 382 | #[test] 383 | fn read_id3v1() { 384 | let file = fs::File::open("testdata/id3v1.id3").unwrap(); 385 | let tag = Tag::read_from(file).unwrap(); 386 | assert_eq!("Title", tag.title); 387 | assert_eq!("Artist", tag.artist); 388 | assert_eq!("Album", tag.album); 389 | assert_eq!("2017", tag.year); 390 | assert_eq!("Comment", tag.comment); 391 | assert_eq!(Some(1), tag.track); 392 | assert_eq!(31, tag.genre_id); 393 | assert_eq!("Trance", tag.genre().unwrap()); 394 | assert!(tag.speed.is_none()); 395 | assert!(tag.genre_str.is_none()); 396 | assert!(tag.start_time.is_none()); 397 | assert!(tag.end_time.is_none()); 398 | } 399 | 400 | #[test] 401 | fn remove_id3v1() { 402 | let tmp = tempdir().unwrap(); 403 | let tmp_name = tmp.path().join("remove_id3v1_tag"); 404 | { 405 | let mut tag_file = fs::File::create(&tmp_name).unwrap(); 406 | let mut original = fs::File::open("testdata/id3v1.id3").unwrap(); 407 | io::copy(&mut original, &mut tag_file).unwrap(); 408 | } 409 | let mut tag_file = fs::OpenOptions::new() 410 | .read(true) 411 | .write(true) 412 | .open(&tmp_name) 413 | .unwrap(); 414 | tag_file.seek(io::SeekFrom::Start(0)).unwrap(); 415 | assert!(Tag::remove_from_file(&mut tag_file).unwrap()); 416 | tag_file.seek(io::SeekFrom::Start(0)).unwrap(); 417 | assert!(!Tag::remove_from_file(&mut tag_file).unwrap()); 418 | } 419 | } 420 | -------------------------------------------------------------------------------- /src/v1v2.rs: -------------------------------------------------------------------------------- 1 | use crate::{v1, Error, ErrorKind, StorageFile, Tag, Version}; 2 | use std::fs; 3 | use std::fs::File; 4 | use std::io; 5 | use std::path::Path; 6 | 7 | /// Returns which tags are present in the specified file. 8 | pub fn is_candidate(mut file: impl io::Read + io::Seek) -> crate::Result { 9 | let v2 = Tag::is_candidate(&mut file)?; 10 | let v1 = v1::Tag::is_candidate(&mut file)?; 11 | Ok(match (v1, v2) { 12 | (false, false) => FormatVersion::None, 13 | (true, false) => FormatVersion::Id3v1, 14 | (false, true) => FormatVersion::Id3v2, 15 | (true, true) => FormatVersion::Both, 16 | }) 17 | } 18 | 19 | /// Returns which tags are present in the specified file. 20 | pub fn is_candidate_path(path: impl AsRef) -> crate::Result { 21 | is_candidate(File::open(path)?) 22 | } 23 | 24 | /// Attempts to read an ID3v2 or ID3v1 tag, in that order. 25 | /// 26 | /// If neither version tag is found, an error with [`ErrorKind::NoTag`] is returned. 27 | pub fn read_from(mut file: impl io::Read + io::Seek) -> crate::Result { 28 | match Tag::read_from2(&mut file) { 29 | Err(Error { 30 | kind: ErrorKind::NoTag, 31 | .. 32 | }) => {} 33 | Err(err) => return Err(err), 34 | Ok(tag) => return Ok(tag), 35 | } 36 | 37 | match v1::Tag::read_from(file) { 38 | Err(Error { 39 | kind: ErrorKind::NoTag, 40 | .. 41 | }) => {} 42 | Err(err) => return Err(err), 43 | Ok(tag) => return Ok(tag.into()), 44 | } 45 | 46 | Err(Error::new( 47 | ErrorKind::NoTag, 48 | "Neither a ID3v2 or ID3v1 tag was found", 49 | )) 50 | } 51 | 52 | /// Attempts to read an ID3v2 or ID3v1 tag, in that order. 53 | /// 54 | /// If neither version tag is found, an error with [`ErrorKind::NoTag`] is returned. 55 | pub fn read_from_path(path: impl AsRef) -> crate::Result { 56 | read_from(File::open(path)?) 57 | } 58 | 59 | /// Writes the specified tag to a file. Any existing ID3v2 tag is replaced or added if it is not 60 | /// present. 61 | /// 62 | /// If any ID3v1 tag is present it will be REMOVED as it is not able to fully represent a ID3v2 63 | /// tag. 64 | pub fn write_to_file(mut file: impl StorageFile, tag: &Tag, version: Version) -> crate::Result<()> { 65 | tag.write_to_file(&mut file, version)?; 66 | v1::Tag::remove_from_file(&mut file)?; 67 | Ok(()) 68 | } 69 | 70 | /// Conventience function for [`write_to_file`]. 71 | pub fn write_to_path(path: impl AsRef, tag: &Tag, version: Version) -> crate::Result<()> { 72 | let file = fs::OpenOptions::new().read(true).write(true).open(path)?; 73 | write_to_file(file, tag, version) 74 | } 75 | 76 | /// Ensures that both ID3v1 and ID3v2 are not present in the specified file. 77 | /// 78 | /// Returns [`FormatVersion`] representing the previous state. 79 | pub fn remove_from_path(path: impl AsRef) -> crate::Result { 80 | let v2 = Tag::remove_from_path(&path)?; 81 | let v1 = v1::Tag::remove_from_path(path)?; 82 | Ok(match (v1, v2) { 83 | (false, false) => FormatVersion::None, 84 | (true, false) => FormatVersion::Id3v1, 85 | (false, true) => FormatVersion::Id3v2, 86 | (true, true) => FormatVersion::Both, 87 | }) 88 | } 89 | 90 | /// An enum that represents the precense state of both tag format versions. 91 | #[derive(Copy, Clone, Debug, Hash, Ord, PartialOrd, Eq, PartialEq)] 92 | pub enum FormatVersion { 93 | /// No tags. 94 | None, 95 | /// ID3v1 96 | Id3v1, 97 | /// ID3v2 98 | Id3v2, 99 | /// ID3v1 + ID3v2 100 | Both, 101 | } 102 | 103 | #[cfg(test)] 104 | mod tests { 105 | use super::*; 106 | use crate::TagLike; 107 | use std::fs::File; 108 | use std::io::{copy, Write}; 109 | 110 | fn file_with_both_formats() -> tempfile::NamedTempFile { 111 | // Write both ID3v1 and ID3v2 tags to a single file, the ID3v2 should be prefered when 112 | // reading. 113 | let mut v2_testdata = File::open("testdata/id3v24.id3").unwrap(); 114 | let mut v1_testdata = File::open("testdata/id3v1.id3").unwrap(); 115 | let mut tmp = tempfile::NamedTempFile::new().unwrap(); 116 | copy(&mut v2_testdata, &mut tmp).unwrap(); 117 | tmp.write_all(&[0xaa; 1337]).unwrap(); // Dummy data, can be anything. 118 | copy(&mut v1_testdata, &mut tmp).unwrap(); 119 | tmp 120 | } 121 | 122 | #[test] 123 | fn test_is_candidate() { 124 | let tmp = file_with_both_formats(); 125 | assert_eq!(is_candidate_path(&tmp).unwrap(), FormatVersion::Both); 126 | assert_eq!( 127 | is_candidate_path("testdata/image.jpg").unwrap(), 128 | FormatVersion::None 129 | ); 130 | assert_eq!( 131 | is_candidate_path("testdata/id3v1.id3").unwrap(), 132 | FormatVersion::Id3v1 133 | ); 134 | assert_eq!( 135 | is_candidate_path("testdata/id3v24.id3").unwrap(), 136 | FormatVersion::Id3v2 137 | ); 138 | } 139 | 140 | #[test] 141 | fn test_read_from_path() { 142 | let tmp = file_with_both_formats(); 143 | 144 | let v2 = read_from_path(&tmp).unwrap(); 145 | assert_eq!(v2.genre(), Some("Genre")); 146 | 147 | let v1 = read_from_path("testdata/id3v1.id3").unwrap(); 148 | assert_eq!(v1.genre(), Some("Trance")); 149 | } 150 | 151 | #[test] 152 | fn test_write_to_path() { 153 | let tmp = file_with_both_formats(); 154 | 155 | let mut tag = read_from_path(&tmp).unwrap(); 156 | tag.set_artist("High Contrast"); 157 | write_to_path(&tmp, &tag, Version::Id3v24).unwrap(); 158 | 159 | assert_eq!(is_candidate_path(&tmp).unwrap(), FormatVersion::Id3v2); 160 | } 161 | 162 | #[test] 163 | fn test_remove_from_path() { 164 | let tmp = file_with_both_formats(); 165 | 166 | assert_eq!(remove_from_path(&tmp).unwrap(), FormatVersion::Both); 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /testdata/SYLT.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polyfloyd/rust-id3/38edb9fc31edc66976d19b1ed60ba78e655cc3ec/testdata/SYLT.mp3 -------------------------------------------------------------------------------- /testdata/aiff/padding.aiff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polyfloyd/rust-id3/38edb9fc31edc66976d19b1ed60ba78e655cc3ec/testdata/aiff/padding.aiff -------------------------------------------------------------------------------- /testdata/aiff/quiet.aiff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polyfloyd/rust-id3/38edb9fc31edc66976d19b1ed60ba78e655cc3ec/testdata/aiff/quiet.aiff -------------------------------------------------------------------------------- /testdata/geob_serato.id3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polyfloyd/rust-id3/38edb9fc31edc66976d19b1ed60ba78e655cc3ec/testdata/geob_serato.id3 -------------------------------------------------------------------------------- /testdata/github-issue-147.id3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polyfloyd/rust-id3/38edb9fc31edc66976d19b1ed60ba78e655cc3ec/testdata/github-issue-147.id3 -------------------------------------------------------------------------------- /testdata/github-issue-156a.id3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polyfloyd/rust-id3/38edb9fc31edc66976d19b1ed60ba78e655cc3ec/testdata/github-issue-156a.id3 -------------------------------------------------------------------------------- /testdata/github-issue-156b.id3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polyfloyd/rust-id3/38edb9fc31edc66976d19b1ed60ba78e655cc3ec/testdata/github-issue-156b.id3 -------------------------------------------------------------------------------- /testdata/github-issue-60.id3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polyfloyd/rust-id3/38edb9fc31edc66976d19b1ed60ba78e655cc3ec/testdata/github-issue-60.id3 -------------------------------------------------------------------------------- /testdata/github-issue-73.id3: -------------------------------------------------------------------------------- 1 | ID3sTRCK9TIT2 TEST TITLETALB TEST ALBUMTDRC2016TPE1 TEST ARTISTTPE2TEST ALBUM ARTISTTCON TEST GENRETCOMTEST COMPOSERTEXTTEST LYRICISTTXXXCategoryTEST CATEGORYTPUB TEST LABEL -------------------------------------------------------------------------------- /testdata/github-issue-86a.id3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polyfloyd/rust-id3/38edb9fc31edc66976d19b1ed60ba78e655cc3ec/testdata/github-issue-86a.id3 -------------------------------------------------------------------------------- /testdata/github-issue-86b.id3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polyfloyd/rust-id3/38edb9fc31edc66976d19b1ed60ba78e655cc3ec/testdata/github-issue-86b.id3 -------------------------------------------------------------------------------- /testdata/github-issue-91.id3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polyfloyd/rust-id3/38edb9fc31edc66976d19b1ed60ba78e655cc3ec/testdata/github-issue-91.id3 -------------------------------------------------------------------------------- /testdata/id3v1.id3: -------------------------------------------------------------------------------- 1 | TAGTitleArtistAlbum2017Comment -------------------------------------------------------------------------------- /testdata/id3v22.id3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polyfloyd/rust-id3/38edb9fc31edc66976d19b1ed60ba78e655cc3ec/testdata/id3v22.id3 -------------------------------------------------------------------------------- /testdata/id3v23.id3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polyfloyd/rust-id3/38edb9fc31edc66976d19b1ed60ba78e655cc3ec/testdata/id3v23.id3 -------------------------------------------------------------------------------- /testdata/id3v23_chap.id3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polyfloyd/rust-id3/38edb9fc31edc66976d19b1ed60ba78e655cc3ec/testdata/id3v23_chap.id3 -------------------------------------------------------------------------------- /testdata/id3v23_geob.id3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polyfloyd/rust-id3/38edb9fc31edc66976d19b1ed60ba78e655cc3ec/testdata/id3v23_geob.id3 -------------------------------------------------------------------------------- /testdata/id3v24.id3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polyfloyd/rust-id3/38edb9fc31edc66976d19b1ed60ba78e655cc3ec/testdata/id3v24.id3 -------------------------------------------------------------------------------- /testdata/id3v24_ext.id3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polyfloyd/rust-id3/38edb9fc31edc66976d19b1ed60ba78e655cc3ec/testdata/id3v24_ext.id3 -------------------------------------------------------------------------------- /testdata/image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polyfloyd/rust-id3/38edb9fc31edc66976d19b1ed60ba78e655cc3ec/testdata/image.jpg -------------------------------------------------------------------------------- /testdata/mpeg-header: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polyfloyd/rust-id3/38edb9fc31edc66976d19b1ed60ba78e655cc3ec/testdata/mpeg-header -------------------------------------------------------------------------------- /testdata/multi-tags.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polyfloyd/rust-id3/38edb9fc31edc66976d19b1ed60ba78e655cc3ec/testdata/multi-tags.mp3 -------------------------------------------------------------------------------- /testdata/picard-2.12.3-id3v23-utf16.id3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polyfloyd/rust-id3/38edb9fc31edc66976d19b1ed60ba78e655cc3ec/testdata/picard-2.12.3-id3v23-utf16.id3 -------------------------------------------------------------------------------- /testdata/picard-2.12.3-id3v24-utf8.id3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polyfloyd/rust-id3/38edb9fc31edc66976d19b1ed60ba78e655cc3ec/testdata/picard-2.12.3-id3v24-utf8.id3 -------------------------------------------------------------------------------- /testdata/quiet.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polyfloyd/rust-id3/38edb9fc31edc66976d19b1ed60ba78e655cc3ec/testdata/quiet.mp3 -------------------------------------------------------------------------------- /testdata/wav/tagged-end.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polyfloyd/rust-id3/38edb9fc31edc66976d19b1ed60ba78e655cc3ec/testdata/wav/tagged-end.wav -------------------------------------------------------------------------------- /testdata/wav/tagged-mid-corrupted.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polyfloyd/rust-id3/38edb9fc31edc66976d19b1ed60ba78e655cc3ec/testdata/wav/tagged-mid-corrupted.wav -------------------------------------------------------------------------------- /testdata/wav/tagged-mid.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polyfloyd/rust-id3/38edb9fc31edc66976d19b1ed60ba78e655cc3ec/testdata/wav/tagged-mid.wav -------------------------------------------------------------------------------- /testdata/wav/tagless-corrupted-2.wav: -------------------------------------------------------------------------------- 1 | RIFFWAVEJUNK 2 | some junk!fmt abcdefghijklmnopdatahere is some music -------------------------------------------------------------------------------- /testdata/wav/tagless-corrupted.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polyfloyd/rust-id3/38edb9fc31edc66976d19b1ed60ba78e655cc3ec/testdata/wav/tagless-corrupted.wav -------------------------------------------------------------------------------- /testdata/wav/tagless-trailing-data.wav: -------------------------------------------------------------------------------- 1 | RIFFHWAVEJUNK 2 | some junk!fmt abcdefghijklmnopdatahere is some music, and here is some trailing data that should be preserved. -------------------------------------------------------------------------------- /testdata/wav/tagless.wav: -------------------------------------------------------------------------------- 1 | RIFFHWAVEJUNK 2 | some junk!fmt abcdefghijklmnopdatahere is some music --------------------------------------------------------------------------------