├── .gitignore ├── clippy.toml ├── testdata ├── SYLT.mp3 ├── id3v22.id3 ├── id3v23.id3 ├── id3v24.id3 ├── image.jpg ├── quiet.mp3 ├── mpeg-header ├── aiff │ ├── quiet.aiff │ └── padding.aiff ├── geob_serato.id3 ├── id3v23_chap.id3 ├── id3v23_geob.id3 ├── id3v24_ext.id3 ├── multi-tags.mp3 ├── wav │ ├── tagged-end.wav │ ├── tagged-mid.wav │ ├── tagless.wav │ ├── tagless-corrupted-2.wav │ ├── tagless-corrupted.wav │ ├── tagged-mid-corrupted.wav │ └── tagless-trailing-data.wav ├── github-issue-147.id3 ├── github-issue-60.id3 ├── github-issue-86a.id3 ├── github-issue-86b.id3 ├── github-issue-91.id3 ├── github-issue-156a.id3 ├── github-issue-156b.id3 ├── picard-2.12.3-id3v23-utf16.id3 ├── picard-2.12.3-id3v24-utf8.id3 ├── id3v1.id3 └── github-issue-73.id3 ├── src ├── stream │ ├── mod.rs │ ├── frame │ │ ├── v2.rs │ │ ├── v3.rs │ │ ├── mod.rs │ │ └── v4.rs │ ├── unsynch.rs │ ├── encoding.rs │ └── tag.rs ├── frame │ ├── content_cmp.rs │ ├── timestamp.rs │ └── mod.rs ├── lib.rs ├── storage │ ├── mod.rs │ └── plain.rs ├── tcon.rs ├── error.rs ├── v1v2.rs ├── v1.rs └── chunk.rs ├── .cz.toml ├── .github ├── workflows │ ├── deploy.yaml │ ├── bump.yaml │ └── test.yaml └── dependabot.yml ├── Cargo.toml ├── LICENSE ├── examples ├── tagdump.rs └── id3info.rs ├── README.md └── CHANGELOG.md /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | Cargo.lock 3 | -------------------------------------------------------------------------------- /clippy.toml: -------------------------------------------------------------------------------- 1 | doc-valid-idents = ["ID3v1", "ID3v2"] 2 | -------------------------------------------------------------------------------- /testdata/SYLT.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polyfloyd/rust-id3/HEAD/testdata/SYLT.mp3 -------------------------------------------------------------------------------- /testdata/id3v22.id3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polyfloyd/rust-id3/HEAD/testdata/id3v22.id3 -------------------------------------------------------------------------------- /testdata/id3v23.id3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polyfloyd/rust-id3/HEAD/testdata/id3v23.id3 -------------------------------------------------------------------------------- /testdata/id3v24.id3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polyfloyd/rust-id3/HEAD/testdata/id3v24.id3 -------------------------------------------------------------------------------- /testdata/image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polyfloyd/rust-id3/HEAD/testdata/image.jpg -------------------------------------------------------------------------------- /testdata/quiet.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polyfloyd/rust-id3/HEAD/testdata/quiet.mp3 -------------------------------------------------------------------------------- /src/stream/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod encoding; 2 | pub mod frame; 3 | pub mod tag; 4 | pub mod unsynch; 5 | -------------------------------------------------------------------------------- /testdata/mpeg-header: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polyfloyd/rust-id3/HEAD/testdata/mpeg-header -------------------------------------------------------------------------------- /testdata/aiff/quiet.aiff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polyfloyd/rust-id3/HEAD/testdata/aiff/quiet.aiff -------------------------------------------------------------------------------- /testdata/geob_serato.id3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polyfloyd/rust-id3/HEAD/testdata/geob_serato.id3 -------------------------------------------------------------------------------- /testdata/id3v23_chap.id3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polyfloyd/rust-id3/HEAD/testdata/id3v23_chap.id3 -------------------------------------------------------------------------------- /testdata/id3v23_geob.id3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polyfloyd/rust-id3/HEAD/testdata/id3v23_geob.id3 -------------------------------------------------------------------------------- /testdata/id3v24_ext.id3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polyfloyd/rust-id3/HEAD/testdata/id3v24_ext.id3 -------------------------------------------------------------------------------- /testdata/multi-tags.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polyfloyd/rust-id3/HEAD/testdata/multi-tags.mp3 -------------------------------------------------------------------------------- /testdata/aiff/padding.aiff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polyfloyd/rust-id3/HEAD/testdata/aiff/padding.aiff -------------------------------------------------------------------------------- /testdata/wav/tagged-end.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polyfloyd/rust-id3/HEAD/testdata/wav/tagged-end.wav -------------------------------------------------------------------------------- /testdata/wav/tagged-mid.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polyfloyd/rust-id3/HEAD/testdata/wav/tagged-mid.wav -------------------------------------------------------------------------------- /testdata/wav/tagless.wav: -------------------------------------------------------------------------------- 1 | RIFFHWAVEJUNK 2 | some junk!fmt abcdefghijklmnopdatahere is some music -------------------------------------------------------------------------------- /testdata/github-issue-147.id3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polyfloyd/rust-id3/HEAD/testdata/github-issue-147.id3 -------------------------------------------------------------------------------- /testdata/github-issue-60.id3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polyfloyd/rust-id3/HEAD/testdata/github-issue-60.id3 -------------------------------------------------------------------------------- /testdata/github-issue-86a.id3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polyfloyd/rust-id3/HEAD/testdata/github-issue-86a.id3 -------------------------------------------------------------------------------- /testdata/github-issue-86b.id3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polyfloyd/rust-id3/HEAD/testdata/github-issue-86b.id3 -------------------------------------------------------------------------------- /testdata/github-issue-91.id3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polyfloyd/rust-id3/HEAD/testdata/github-issue-91.id3 -------------------------------------------------------------------------------- /testdata/github-issue-156a.id3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polyfloyd/rust-id3/HEAD/testdata/github-issue-156a.id3 -------------------------------------------------------------------------------- /testdata/github-issue-156b.id3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polyfloyd/rust-id3/HEAD/testdata/github-issue-156b.id3 -------------------------------------------------------------------------------- /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/HEAD/testdata/wav/tagless-corrupted.wav -------------------------------------------------------------------------------- /.cz.toml: -------------------------------------------------------------------------------- 1 | [tool.commitizen] 2 | name = "cz_conventional_commits" 3 | version_provider = "scm" 4 | tag_format = "v$version" 5 | -------------------------------------------------------------------------------- /testdata/wav/tagged-mid-corrupted.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polyfloyd/rust-id3/HEAD/testdata/wav/tagged-mid-corrupted.wav -------------------------------------------------------------------------------- /testdata/picard-2.12.3-id3v23-utf16.id3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polyfloyd/rust-id3/HEAD/testdata/picard-2.12.3-id3v23-utf16.id3 -------------------------------------------------------------------------------- /testdata/picard-2.12.3-id3v24-utf8.id3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polyfloyd/rust-id3/HEAD/testdata/picard-2.12.3-id3v24-utf8.id3 -------------------------------------------------------------------------------- /testdata/id3v1.id3: -------------------------------------------------------------------------------- 1 | TAGTitleArtistAlbum2017Comment -------------------------------------------------------------------------------- /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/github-issue-73.id3: -------------------------------------------------------------------------------- 1 | ID3sTRCK9TIT2 TEST TITLETALB TEST ALBUMTDRC2016TPE1 TEST ARTISTTPE2TEST ALBUM ARTISTTCON TEST GENRETCOMTEST COMPOSERTEXTTEST LYRICISTTXXXCategoryTEST CATEGORYTPUB TEST LABEL -------------------------------------------------------------------------------- /.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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | pull_request: 6 | workflow_call: 7 | 8 | # Cancel previous runs for PRs but not pushes to main 9 | concurrency: 10 | group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.head_ref || github.sha }} 11 | cancel-in-progress: true 12 | 13 | env: 14 | CARGO_TERM_COLOR: always 15 | 16 | jobs: 17 | cargo-toml-features: 18 | name: Generate Feature Combinations 19 | runs-on: ubuntu-latest 20 | outputs: 21 | feature-combinations: ${{ steps.cargo-toml-features.outputs.feature-combinations }} 22 | steps: 23 | - name: Check out repository 24 | uses: actions/checkout@v4 25 | - name: Determine Cargo Features 26 | id: cargo-toml-features 27 | uses: Holzhaus/cargo-toml-features-action@3afa751aae4071b2d1ca1c5fa42528a351c995f4 28 | 29 | build: 30 | needs: cargo-toml-features 31 | strategy: 32 | matrix: 33 | os: [ubuntu-latest, macos-latest, windows-latest] 34 | features: ${{ fromJson(needs.cargo-toml-features.outputs.feature-combinations) }} 35 | fail-fast: false 36 | 37 | runs-on: ${{ matrix.os }} 38 | steps: 39 | - name: Check out source repository 40 | uses: actions/checkout@v4 41 | 42 | - name: Install FFmpeg (Ubuntu) 43 | if: runner.os == 'Linux' 44 | run: | 45 | sudo apt update 46 | sudo apt install -y ffmpeg 47 | 48 | - name: Install FFmpeg (macOS) 49 | if: runner.os == 'macOS' 50 | run: | 51 | brew install ffmpeg 52 | 53 | - name: Install FFmpeg (Windows) 54 | if: runner.os == 'Windows' 55 | run: | 56 | choco install ffmpeg 57 | shell: powershell 58 | 59 | - name: Set up Rust toolchain 60 | uses: dtolnay/rust-toolchain@stable 61 | 62 | - name: Cache dependencies 63 | uses: actions/cache@v4 64 | continue-on-error: false 65 | with: 66 | path: | 67 | ~/.cargo/bin/ 68 | ~/.cargo/registry/index/ 69 | ~/.cargo/registry/cache/ 70 | ~/.cargo/git/db/ 71 | target/ 72 | key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} 73 | restore-keys: ${{ runner.os }}-cargo- 74 | 75 | - name: Build 76 | run: cargo build --no-default-features --features "${{ join(matrix.features, ',') }}" 77 | 78 | - name: Run tests 79 | run: cargo test --no-default-features --features "${{ join(matrix.features, ',') }}" --no-fail-fast 80 | 81 | msrv: 82 | name: Current MSRV is 1.70.0 83 | runs-on: ubuntu-latest 84 | steps: 85 | - uses: actions/checkout@v4 86 | # Now check that `cargo build` works with respect to the oldest possible 87 | # deps and the stated MSRV 88 | - uses: dtolnay/rust-toolchain@1.70.0 89 | - run: cargo build --all-features 90 | 91 | style: 92 | runs-on: ubuntu-latest 93 | steps: 94 | - name: Check out source repository 95 | uses: actions/checkout@v4 96 | 97 | - name: Set up Rust toolchain 98 | uses: dtolnay/rust-toolchain@stable 99 | 100 | - name: Cache dependencies 101 | uses: actions/cache@v4 102 | continue-on-error: false 103 | with: 104 | path: | 105 | ~/.cargo/bin/ 106 | ~/.cargo/registry/index/ 107 | ~/.cargo/registry/cache/ 108 | ~/.cargo/git/db/ 109 | target/ 110 | key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} 111 | restore-keys: ${{ runner.os }}-cargo- 112 | 113 | - name: Format 114 | run: cargo fmt --check 115 | 116 | - name: Lint 117 | run: cargo clippy --all-features -- -Dwarnings 118 | 119 | - name: Check for debug macro 120 | run: "! grep -r 'dbg!' ./src" 121 | 122 | conventional-commits: 123 | if: github.event_name == 'pull_request' 124 | runs-on: ubuntu-latest 125 | steps: 126 | - uses: actions/checkout@v4 127 | with: 128 | fetch-depth: 0 129 | - run: pip3 install -U Commitizen 130 | - run: cz check --rev-range origin/${{ github.base_ref }}..HEAD 131 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Moved to https://codeberg.org/polyfloyd/rust-id3 2 | 3 | # rust-id3 4 | 5 | [![Crate](https://img.shields.io/crates/v/id3.svg)](https://crates.io/crates/id3) 6 | [![Documentation](https://docs.rs/id3/badge.svg)](https://docs.rs/id3/) 7 | 8 | A library for reading and writing ID3 metadata. 9 | 10 | ## Implemented Features 11 | 12 | * ID3v1 reading 13 | * ID3v2.2, ID3v2.3, ID3v2.4 reading/writing 14 | * MP3, WAV and AIFF files 15 | * Latin1, UTF16 and UTF8 encodings 16 | * Text frames 17 | * Extended Text frames 18 | * Link frames 19 | * Extended Link frames 20 | * Comment frames 21 | * Lyrics frames 22 | * Synchronised Lyrics frames 23 | * Picture frames 24 | * Encapsulated Object frames 25 | * Chapter frames 26 | * Unsynchronisation 27 | * Compression 28 | * MPEG Location Lookup Table frames 29 | * Unique File Identifier frames 30 | * Involved People List frames 31 | * Tag and File Alter Preservation bits 32 | 33 | ## Examples 34 | 35 | ### Reading tag frames 36 | 37 | ```rust 38 | use id3::{Tag, TagLike}; 39 | 40 | fn main() -> Result<(), Box> { 41 | let tag = Tag::read_from_path("testdata/id3v24.id3")?; 42 | 43 | // Get a bunch of frames... 44 | if let Some(artist) = tag.artist() { 45 | println!("artist: {}", artist); 46 | } 47 | if let Some(title) = tag.title() { 48 | println!("title: {}", title); 49 | } 50 | if let Some(album) = tag.album() { 51 | println!("album: {}", album); 52 | } 53 | 54 | // Get frames before getting their content for more complex tags. 55 | if let Some(artist) = tag.get("TPE1").and_then(|frame| frame.content().text()) { 56 | println!("artist: {}", artist); 57 | } 58 | Ok(()) 59 | } 60 | ``` 61 | 62 | ### Modifying any existing tag 63 | 64 | ```rust 65 | use id3::{Error, ErrorKind, Tag, TagLike, Version}; 66 | use std::fs::copy; 67 | 68 | fn main() -> Result<(), Box> { 69 | let temp_file = std::env::temp_dir().join("music.mp3"); 70 | copy("testdata/quiet.mp3", &temp_file)?; 71 | 72 | let mut tag = match Tag::read_from_path(&temp_file) { 73 | Ok(tag) => tag, 74 | Err(Error{kind: ErrorKind::NoTag, ..}) => Tag::new(), 75 | Err(err) => return Err(Box::new(err)), 76 | }; 77 | 78 | tag.set_album("Fancy Album Title"); 79 | 80 | tag.write_to_path(temp_file, Version::Id3v24)?; 81 | Ok(()) 82 | } 83 | ``` 84 | 85 | ### Creating a new tag, overwriting any old tag 86 | 87 | ```rust 88 | use id3::{Tag, TagLike, Frame, Version}; 89 | use id3::frame::Content; 90 | use std::fs::copy; 91 | 92 | fn main() -> Result<(), Box> { 93 | let temp_file = std::env::temp_dir().join("music.mp3"); 94 | copy("testdata/quiet.mp3", &temp_file)?; 95 | 96 | let mut tag = Tag::new(); 97 | tag.set_album("Fancy Album Title"); 98 | 99 | // Set the album the hard way. 100 | tag.add_frame(Frame::text("TALB", "album")); 101 | 102 | tag.write_to_path(temp_file, Version::Id3v24)?; 103 | Ok(()) 104 | } 105 | ``` 106 | 107 | ### Handling damaged or files without a tag 108 | 109 | ```rust 110 | use id3::{Tag, TagLike, partial_tag_ok, no_tag_ok}; 111 | 112 | fn main() -> Result<(), Box> { 113 | let tag_result = Tag::read_from_path("testdata/id3v24.id3"); 114 | 115 | // A partially decoded tag is set on the Err. partial_tag_ok takes it out and maps it to Ok. 116 | let tag_result = partial_tag_ok(tag_result); 117 | 118 | // no_tag_ok maps the NoTag error variant and maps it to Ok(None). 119 | let tag_result = no_tag_ok(tag_result); 120 | 121 | if let Some(tag) = tag_result? { 122 | // .. 123 | } 124 | 125 | Ok(()) 126 | } 127 | ``` 128 | 129 | ## Contributing 130 | 131 | Do you think you have found a bug? Then please report it via the GitHub issue tracker. Make sure to 132 | attach any problematic files that can be used to reproduce the issue. Such files are also used to 133 | create regression tests that ensure that your bug will never return. 134 | 135 | When submitting pull requests, please prefix your commit messages with `fix:` or `feat:` for bug 136 | fixes and new features respectively. This is the 137 | [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) scheme that is used to 138 | automate some maintenance chores such as generating the changelog and inferring the next version 139 | number. 140 | 141 | ## Running tests 142 | 143 | Tests require `ffprobe` (part of ffmpeg) to be present in $PATH. 144 | 145 | ```shell 146 | cargo test --all-features 147 | ``` 148 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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, "-{month:02}",)?; 50 | if let Some(day) = self.day { 51 | write!(f, "-{day:02}",)?; 52 | if let Some(hour) = self.hour { 53 | write!(f, "T{hour:02}",)?; 54 | if let Some(minute) = self.minute { 55 | write!(f, ":{minute:02}",)?; 56 | if let Some(second) = self.second { 57 | write!(f, ":{second:02}",)?; 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/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/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/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/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!("Frame with ID {id} and content type {content_kind} can not be written as valid ID3"), 117 | )) 118 | } 119 | } 120 | } 121 | 122 | /// Creates a frame with the specified ID and content. 123 | /// 124 | /// Both ID3v2.2 and >ID3v2.3 IDs are accepted, although they will be converted to ID3v2.3 125 | /// format. If an ID3v2.2 ID is supplied but could not be remapped, it is stored as-is. 126 | /// 127 | /// # Panics 128 | /// If the id's length is not 3 or 4 bytes long. 129 | pub fn with_content(id: impl AsRef, content: Content) -> Self { 130 | assert!({ 131 | let l = id.as_ref().len(); 132 | l == 3 || l == 4 133 | }); 134 | Frame { 135 | id: if id.as_ref().len() == 3 { 136 | match convert_id_2_to_3(id.as_ref()) { 137 | Some(translated) => ID::Valid(translated.to_string()), 138 | None => ID::Invalid(id.as_ref().to_string()), 139 | } 140 | } else { 141 | ID::Valid(id.as_ref().to_string()) 142 | }, 143 | content, 144 | tag_alter_preservation: false, 145 | file_alter_preservation: false, 146 | encoding: None, 147 | } 148 | } 149 | 150 | /// Sets the encoding for this frame. 151 | /// 152 | /// The encoding is actually a property of individual content and its serialization format. 153 | /// Public interfaces of ID3 typically follow Rust conventions such as UTF-8. 154 | /// 155 | /// # Caveat 156 | /// According to the standard, distinct encodings do not count towards uniqueness. However, 157 | /// some applications such as Serato do write multiple frames that should not co-exist in a 158 | /// single tag and uses the encoding to distinguish between such frames. 159 | /// 160 | /// When set using this function, the encoding influences the way uniqueness is determined and 161 | /// using other interfaces to alter the tag this frame belongs to has the potential to remove 162 | /// this or other tags. 163 | /// 164 | /// After decoding a tag, the initial encoding is only set for TXXX and GEOB frames. 165 | pub fn set_encoding(mut self, encoding: Option) -> Self { 166 | self.encoding = encoding; 167 | self 168 | } 169 | 170 | /// Creates a new text frame with the specified ID and text content. 171 | /// 172 | /// This function does not verify whether the ID is valid for text frames. 173 | /// 174 | /// # Example 175 | /// ``` 176 | /// use id3::Frame; 177 | /// 178 | /// let frame = Frame::text("TPE1", "Armin van Buuren"); 179 | /// assert_eq!(frame.content().text(), Some("Armin van Buuren")); 180 | /// ``` 181 | pub fn text(id: impl AsRef, content: impl Into) -> Self { 182 | Self::with_content(id, Content::Text(content.into())) 183 | } 184 | 185 | /// Creates a new link frame with the specified ID and link content. 186 | /// 187 | /// This function does not verify whether the ID is valid for link frames. 188 | /// 189 | /// # Example 190 | /// ``` 191 | /// use id3::Frame; 192 | /// 193 | /// let frame = Frame::link("WCOM", "https://wwww.arminvanbuuren.com"); 194 | /// assert_eq!(frame.content().link(), Some("https://wwww.arminvanbuuren.com")); 195 | /// ``` 196 | pub fn link(id: impl AsRef, content: impl Into) -> Self { 197 | Self::with_content(id, Content::Link(content.into())) 198 | } 199 | 200 | /// Returns the ID of this frame. 201 | /// 202 | /// The string returned us usually 4 bytes long except when the frame was read from an ID3v2.2 203 | /// tag and the ID could not be mapped to an ID3v2.3 ID. 204 | pub fn id(&self) -> &str { 205 | match self.id { 206 | ID::Valid(ref id) | ID::Invalid(ref id) => id, 207 | } 208 | } 209 | 210 | /// Returns the ID that is compatible with specified version or None if no ID is available in 211 | /// that version. 212 | pub fn id_for_version(&self, version: Version) -> Option<&str> { 213 | match (version, &self.id) { 214 | (Version::Id3v22, ID::Valid(id)) => convert_id_3_to_2(id), 215 | (Version::Id3v23, ID::Valid(id)) 216 | | (Version::Id3v24, ID::Valid(id)) 217 | | (Version::Id3v22, ID::Invalid(id)) => Some(id), 218 | (_, ID::Invalid(_)) => None, 219 | } 220 | } 221 | 222 | /// Returns the content of the frame. 223 | pub fn content(&self) -> &Content { 224 | &self.content 225 | } 226 | 227 | /// Returns whether the tag_alter_preservation flag is set. 228 | pub fn tag_alter_preservation(&self) -> bool { 229 | self.tag_alter_preservation 230 | } 231 | 232 | /// Sets the tag_alter_preservation flag. 233 | pub fn set_tag_alter_preservation(&mut self, tag_alter_preservation: bool) { 234 | self.tag_alter_preservation = tag_alter_preservation; 235 | } 236 | 237 | /// Returns whether the file_alter_preservation flag is set. 238 | pub fn file_alter_preservation(&self) -> bool { 239 | self.file_alter_preservation 240 | } 241 | 242 | /// Sets the file_alter_preservation flag. 243 | pub fn set_file_alter_preservation(&mut self, file_alter_preservation: bool) { 244 | self.file_alter_preservation = file_alter_preservation; 245 | } 246 | 247 | /// Returns the encoding of this frame 248 | /// 249 | /// # Caveat 250 | /// See [`Frame::set_encoding`]. 251 | pub fn encoding(&self) -> Option { 252 | self.encoding 253 | } 254 | 255 | /// Returns the name of the frame. 256 | /// 257 | /// The name is the _human-readable_ representation of a frame 258 | /// id. For example, the id `"TCOM"` corresponds to the name 259 | /// `"Composer"`. The names are taken from the 260 | /// [ID3v2.4](http://id3.org/id3v2.4.0-frames), 261 | /// [ID3v2.3](http://id3.org/d3v2.3.0) and 262 | /// [ID3v2.2](http://id3.org/d3v2-00) standards. 263 | pub fn name(&self) -> &str { 264 | match self.id() { 265 | // Ids and names defined in section 4 of http://id3.org/id3v2.4.0-frames 266 | "AENC" => "Audio encryption", 267 | "APIC" => "Attached picture", 268 | "ASPI" => "Audio seek point index", 269 | "COMM" => "Comments", 270 | "COMR" => "Commercial frame", 271 | "ENCR" => "Encryption method registration", 272 | "EQU2" => "Equalisation (2)", 273 | "ETCO" => "Event timing codes", 274 | "GEOB" => "General encapsulated object", 275 | "GRID" => "Group identification registration", 276 | "LINK" => "Linked information", 277 | "MCDI" => "Music CD identifier", 278 | "MLLT" => "MPEG location lookup table", 279 | "OWNE" => "Ownership frame", 280 | "PRIV" => "Private frame", 281 | "PCNT" => "Play counter", 282 | "POPM" => "Popularimeter", 283 | "POSS" => "Position synchronisation frame", 284 | "RBUF" => "Recommended buffer size", 285 | "RVA2" => "Relative volume adjustment (2)", 286 | "RVRB" => "Reverb", 287 | "SEEK" => "Seek frame", 288 | "SIGN" => "Signature frame", 289 | "SYLT" => "Synchronised lyric/text", 290 | "SYTC" => "Synchronised tempo codes", 291 | "TALB" => "Album/Movie/Show title", 292 | "TBPM" => "BPM (beats per minute)", 293 | "TCOM" => "Composer", 294 | "TCON" => "Content type", 295 | "TCOP" => "Copyright message", 296 | "TDEN" => "Encoding time", 297 | "TDLY" => "Playlist delay", 298 | "TDOR" => "Original release time", 299 | "TDRC" => "Recording time", 300 | "TDRL" => "Release time", 301 | "TDTG" => "Tagging time", 302 | "TENC" => "Encoded by", 303 | "TEXT" => "Lyricist/Text writer", 304 | "TFLT" => "File type", 305 | "TIPL" => "Involved people list", 306 | "TIT1" => "Content group description", 307 | "TIT2" => "Title/songname/content description", 308 | "TIT3" => "Subtitle/Description refinement", 309 | "TKEY" => "Initial key", 310 | "TLAN" => "Language(s)", 311 | "TLEN" => "Length", 312 | "TMCL" => "Musician credits list", 313 | "TMED" => "Media type", 314 | "TMOO" => "Mood", 315 | "TOAL" => "Original album/movie/show title", 316 | "TOFN" => "Original filename", 317 | "TOLY" => "Original lyricist(s)/text writer(s)", 318 | "TOPE" => "Original artist(s)/performer(s)", 319 | "TOWN" => "File owner/licensee", 320 | "TPE1" => "Lead performer(s)/Soloist(s)", 321 | "TPE2" => "Band/orchestra/accompaniment", 322 | "TPE3" => "Conductor/performer refinement", 323 | "TPE4" => "Interpreted, remixed, or otherwise modified by", 324 | "TPOS" => "Part of a set", 325 | "TPRO" => "Produced notice", 326 | "TPUB" => "Publisher", 327 | "TRCK" => "Track number/Position in set", 328 | "TRSN" => "Internet radio station name", 329 | "TRSO" => "Internet radio station owner", 330 | "TSOA" => "Album sort order", 331 | "TSOP" => "Performer sort order", 332 | "TSOT" => "Title sort order", 333 | "TSRC" => "ISRC (international standard recording code)", 334 | "TSSE" => "Software/Hardware and settings used for encoding", 335 | "TSST" => "Set subtitle", 336 | "TXXX" => "User defined text information frame", 337 | "UFID" => "Unique file identifier", 338 | "USER" => "Terms of use", 339 | "USLT" => "Unsynchronised lyric/text transcription", 340 | "WCOM" => "Commercial information", 341 | "WCOP" => "Copyright/Legal information", 342 | "WOAF" => "Official audio file webpage", 343 | "WOAR" => "Official artist/performer webpage", 344 | "WOAS" => "Official audio source webpage", 345 | "WORS" => "Official Internet radio station homepage", 346 | "WPAY" => "Payment", 347 | "WPUB" => "Publishers official webpage", 348 | "WXXX" => "User defined URL link frame", 349 | 350 | // Ids and names defined in section 4 of 351 | // http://id3.org/d3v2.3.0 which have not been previously 352 | // defined above 353 | "EQUA" => "Equalization", 354 | "IPLS" => "Involved people list", 355 | "RVAD" => "Relative volume adjustment", 356 | "TDAT" => "Date", 357 | "TIME" => "Time", 358 | "TORY" => "Original release year", 359 | "TRDA" => "Recording dates", 360 | "TSIZ" => "Size", 361 | "TYER" => "Year", 362 | 363 | // Ids and names defined in section 4 of 364 | // http://id3.org/d3v2-00 which have not been previously 365 | // defined above 366 | "BUF" => "Recommended buffer size", 367 | "CNT" => "Play counter", 368 | "COM" => "Comments", 369 | "CRA" => "Audio encryption", 370 | "CRM" => "Encrypted meta frame", 371 | "ETC" => "Event timing codes", 372 | "EQU" => "Equalization", 373 | "GEO" => "General encapsulated object", 374 | "IPL" => "Involved people list", 375 | "LNK" => "Linked information", 376 | "MCI" => "Music CD Identifier", 377 | "MLL" => "MPEG location lookup table", 378 | "PIC" => "Attached picture", 379 | "POP" => "Popularimeter", 380 | "REV" => "Reverb", 381 | "RVA" => "Relative volume adjustment", 382 | "SLT" => "Synchronized lyric/text", 383 | "STC" => "Synced tempo codes", 384 | "TAL" => "Album/Movie/Show title", 385 | "TBP" => "BPM (Beats Per Minute)", 386 | "TCM" => "Composer", 387 | "TCO" => "Content type", 388 | "TCR" => "Copyright message", 389 | "TDA" => "Date", 390 | "TDY" => "Playlist delay", 391 | "TEN" => "Encoded by", 392 | "TFT" => "File type", 393 | "TIM" => "Time", 394 | "TKE" => "Initial key", 395 | "TLA" => "Language(s)", 396 | "TLE" => "Length", 397 | "TMT" => "Media type", 398 | "TOA" => "Original artist(s)/performer(s)", 399 | "TOF" => "Original filename", 400 | "TOL" => "Original Lyricist(s)/text writer(s)", 401 | "TOR" => "Original release year", 402 | "TOT" => "Original album/Movie/Show title", 403 | "TP1" => "Lead artist(s)/Lead performer(s)/Soloist(s)/Performing group", 404 | "TP2" => "Band/Orchestra/Accompaniment", 405 | "TP3" => "Conductor/Performer refinement", 406 | "TP4" => "Interpreted, remixed, or otherwise modified by", 407 | "TPA" => "Part of a set", 408 | "TPB" => "Publisher", 409 | "TRC" => "ISRC (International Standard Recording Code)", 410 | "TRD" => "Recording dates", 411 | "TRK" => "Track number/Position in set", 412 | "TSI" => "Size", 413 | "TSS" => "Software/hardware and settings used for encoding", 414 | "TT1" => "Content group description", 415 | "TT2" => "Title/Songname/Content description", 416 | "TT3" => "Subtitle/Description refinement", 417 | "TXT" => "Lyricist/text writer", 418 | "TXX" => "User defined text information frame", 419 | "TYE" => "Year", 420 | "UFI" => "Unique file identifier", 421 | "ULT" => "Unsychronized lyric/text transcription", 422 | "WAF" => "Official audio file webpage", 423 | "WAR" => "Official artist/performer webpage", 424 | "WAS" => "Official audio source webpage", 425 | "WCM" => "Commercial information", 426 | "WCP" => "Copyright/Legal information", 427 | "WPB" => "Publishers official webpage", 428 | "WXX" => "User defined URL link frame", 429 | 430 | v => v, 431 | } 432 | } 433 | } 434 | 435 | impl PartialEq for Frame { 436 | fn eq(&self, other: &Self) -> bool { 437 | self.id == other.id 438 | && self.content == other.content 439 | && self.tag_alter_preservation == other.tag_alter_preservation 440 | && self.file_alter_preservation == other.file_alter_preservation 441 | && (self.encoding.is_none() 442 | || other.encoding.is_none() 443 | || self.encoding == other.encoding) 444 | } 445 | } 446 | 447 | impl fmt::Display for Frame { 448 | fn fmt(&self, f: &mut fmt::Formatter) -> Result<(), fmt::Error> { 449 | write!(f, "{} = {}", self.name(), self.content) 450 | } 451 | } 452 | 453 | macro_rules! convert_2_to_3_and_back { 454 | ( $( $id2:expr, $id3:expr ),* ) => { 455 | fn convert_id_2_to_3(id: impl AsRef) -> Option<&'static str> { 456 | match id.as_ref() { 457 | $($id2 => Some($id3),)* 458 | _ => None, 459 | } 460 | } 461 | 462 | fn convert_id_3_to_2(id: impl AsRef) -> Option<&'static str> { 463 | match id.as_ref() { 464 | $($id3 => Some($id2),)* 465 | _ => None, 466 | } 467 | } 468 | } 469 | } 470 | 471 | #[rustfmt::skip] 472 | convert_2_to_3_and_back!( 473 | "BUF", "RBUF", 474 | 475 | "CNT", "PCNT", 476 | "COM", "COMM", 477 | "CRA", "AENC", 478 | // "CRM" does not exist in ID3v2.3 479 | 480 | "ETC", "ETCO", 481 | "EQU", "EQUA", 482 | 483 | "GEO", "GEOB", 484 | 485 | "IPL", "IPLS", 486 | 487 | "LNK", "LINK", 488 | 489 | "MCI", "MCDI", 490 | "MLL", "MLLT", 491 | 492 | "PIC", "APIC", 493 | "POP", "POPM", 494 | 495 | "REV", "RVRB", 496 | "RVA", "RVA2", 497 | 498 | "SLT", "SYLT", 499 | "STC", "SYTC", 500 | 501 | "TAL", "TALB", 502 | "TBP", "TBPM", 503 | "TCM", "TCOM", 504 | "TCO", "TCON", 505 | "TCR", "TCOP", 506 | "TDA", "TDAT", 507 | "TDY", "TDLY", 508 | "TEN", "TENC", 509 | "TFT", "TFLT", 510 | "TIM", "TIME", 511 | "TKE", "TKEY", 512 | "TLA", "TLAN", 513 | "TLE", "TLEN", 514 | "TMT", "TMED", 515 | "TOA", "TOPE", 516 | "TOF", "TOFN", 517 | "TOL", "TOLY", 518 | "TOT", "TOAL", 519 | "TOR", "TORY", 520 | "TP1", "TPE1", 521 | "TP2", "TPE2", 522 | "TP3", "TPE3", 523 | "TP4", "TPE4", 524 | "TPA", "TPOS", 525 | "TPB", "TPUB", 526 | "TRC", "TSRC", 527 | "TRD", "TRDA", 528 | "TRK", "TRCK", 529 | "TSI", "TSIZ", 530 | "TSS", "TSSE", 531 | "TT1", "TIT1", 532 | "TT2", "TIT2", 533 | "TT3", "TIT3", 534 | "TXT", "TEXT", 535 | "TXX", "TXXX", 536 | "TYE", "TYER", 537 | 538 | "UFI", "UFID", 539 | "ULT", "USLT", 540 | 541 | "WAF", "WOAF", 542 | "WAR", "WOAR", 543 | "WAS", "WOAS", 544 | "WCM", "WCOM", 545 | "WCP", "WCOP", 546 | "WPB", "WPUB", 547 | "WXX", "WXXX" 548 | ); 549 | 550 | #[cfg(test)] 551 | mod tests { 552 | use super::*; 553 | 554 | #[test] 555 | fn test_display() { 556 | let title_frame = Frame::with_content("TIT2", Content::Text("title".to_owned())); 557 | assert_eq!( 558 | format!("{}", title_frame), 559 | "Title/songname/content description = title" 560 | ); 561 | 562 | let txxx_frame = Frame::with_content( 563 | "TXXX", 564 | Content::ExtendedText(ExtendedText { 565 | description: "description".to_owned(), 566 | value: "value".to_owned(), 567 | }), 568 | ); 569 | assert_eq!( 570 | format!("{}", txxx_frame), 571 | "User defined text information frame = description: value" 572 | ); 573 | } 574 | 575 | #[test] 576 | fn test_frame_cmp_text() { 577 | let frame_a = Frame::with_content("TIT2", Content::Text("A".to_owned())); 578 | let frame_b = Frame::with_content("TIT2", Content::Text("B".to_owned())); 579 | 580 | assert!( 581 | frame_a.compare(&frame_b), 582 | "frames should be counted as equal" 583 | ); 584 | } 585 | 586 | #[test] 587 | fn test_frame_cmp_wcom() { 588 | let frame_a = Frame::with_content("WCOM", Content::Link("A".to_owned())); 589 | let frame_b = Frame::with_content("WCOM", Content::Link("B".to_owned())); 590 | 591 | assert!( 592 | !frame_a.compare(&frame_b), 593 | "frames should not be counted as equal" 594 | ); 595 | } 596 | 597 | #[test] 598 | fn test_frame_cmp_priv() { 599 | let frame_a = Frame::with_content( 600 | "PRIV", 601 | Content::Unknown(Unknown { 602 | data: vec![1, 2, 3], 603 | version: Version::Id3v24, 604 | }), 605 | ); 606 | let frame_b = Frame::with_content( 607 | "PRIV", 608 | Content::Unknown(Unknown { 609 | data: vec![1, 2, 3], 610 | version: Version::Id3v24, 611 | }), 612 | ); 613 | 614 | assert!( 615 | !frame_a.compare(&frame_b), 616 | "frames should not be counted as equal" 617 | ); 618 | } 619 | 620 | #[test] 621 | fn test_frame_cmp_ufid() { 622 | let frame_a = Frame::with_content( 623 | "UFID", 624 | Content::UniqueFileIdentifier(UniqueFileIdentifier { 625 | owner_identifier: String::from("http://www.id3.org/dummy/ufid.html"), 626 | identifier: String::from("A").into(), 627 | }), 628 | ); 629 | let frame_b = Frame::with_content( 630 | "UFID", 631 | Content::UniqueFileIdentifier(UniqueFileIdentifier { 632 | owner_identifier: String::from("http://www.id3.org/dummy/ufid.html"), 633 | identifier: String::from("B").into(), 634 | }), 635 | ); 636 | let frame_c = Frame::with_content( 637 | "UFID", 638 | Content::UniqueFileIdentifier(UniqueFileIdentifier { 639 | owner_identifier: String::from("https://example.com"), 640 | identifier: String::from("C").into(), 641 | }), 642 | ); 643 | 644 | assert!( 645 | frame_a.compare(&frame_b), 646 | "frames should be equal because they share the same owner_identifier" 647 | ); 648 | 649 | assert!( 650 | !frame_a.compare(&frame_c), 651 | "frames should not be equal because they share have different owner_identifiers" 652 | ); 653 | } 654 | 655 | #[test] 656 | fn test_frame_cmp_popularimeter() { 657 | let frame_a = Frame::with_content( 658 | "POPM", 659 | Content::Popularimeter(Popularimeter { 660 | user: "A".to_owned(), 661 | rating: 1, 662 | counter: 1, 663 | }), 664 | ); 665 | let frame_b = Frame::with_content( 666 | "POPM", 667 | Content::Popularimeter(Popularimeter { 668 | user: "A".to_owned(), 669 | rating: 1, 670 | counter: 1, 671 | }), 672 | ); 673 | let frame_c = Frame::with_content( 674 | "POPM", 675 | Content::Popularimeter(Popularimeter { 676 | user: "C".to_owned(), 677 | rating: 1, 678 | counter: 1, 679 | }), 680 | ); 681 | 682 | assert!( 683 | frame_a.compare(&frame_b), 684 | "frames should be counted as equal" 685 | ); 686 | assert!( 687 | !frame_a.compare(&frame_c), 688 | "frames should not be counted as equal" 689 | ); 690 | } 691 | } 692 | -------------------------------------------------------------------------------- /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!("Unsupported id3 tag version: v2.{ver_major}.{ver_minor}"), 176 | )); 177 | } 178 | }; 179 | let flags = Flags::from_bits(header[5]) 180 | .ok_or_else(|| Error::new(ErrorKind::Parsing, "unknown tag header flags are set"))?; 181 | let tag_size = unsynch::decode_u32(BigEndian::read_u32(&header[6..10])); 182 | 183 | // compression only exists on 2.2 and conflicts with 2.3+'s extended header 184 | if version == Version::Id3v22 && flags.contains(Flags::COMPRESSION) { 185 | return Err(Error::new( 186 | ErrorKind::UnsupportedFeature, 187 | "id3v2.2 compression is not supported", 188 | )); 189 | } 190 | 191 | Ok(HeaderBuilder { 192 | version, 193 | flags, 194 | tag_size, 195 | }) 196 | } 197 | } 198 | 199 | pub fn decode(mut reader: impl io::Read) -> crate::Result { 200 | let header = Header::decode(&mut reader)?; 201 | 202 | decode_remaining(reader, header) 203 | } 204 | 205 | #[cfg(feature = "tokio")] 206 | pub async fn async_decode( 207 | mut reader: impl tokio::io::AsyncRead + std::marker::Unpin, 208 | ) -> crate::Result { 209 | let header = Header::async_decode(&mut reader).await?; 210 | 211 | let reader = { 212 | use tokio::io::AsyncReadExt; 213 | 214 | let mut buf = Vec::new(); 215 | 216 | reader 217 | .take(header.frame_bytes()) 218 | .read_to_end(&mut buf) 219 | .await?; 220 | std::io::Cursor::new(buf) 221 | }; 222 | 223 | decode_remaining(reader, header) 224 | } 225 | 226 | fn decode_remaining(mut reader: impl io::Read, header: Header) -> crate::Result { 227 | match header.version { 228 | Version::Id3v22 => { 229 | // Limit the reader only to the given tag_size, don't return any more bytes after that. 230 | let v2_reader = reader.take(header.frame_bytes()); 231 | 232 | if header.flags.contains(Flags::UNSYNCHRONISATION) { 233 | // Unwrap all 'unsynchronized' bytes in the tag before parsing frames. 234 | decode_v2_frames(unsynch::Reader::new(v2_reader)) 235 | } else { 236 | decode_v2_frames(v2_reader) 237 | } 238 | } 239 | Version::Id3v23 => { 240 | // Unsynchronization is applied to the whole tag, excluding the header. 241 | let mut reader: Box = if header.flags.contains(Flags::UNSYNCHRONISATION) { 242 | Box::new(unsynch::Reader::new(reader)) 243 | } else { 244 | Box::new(reader) 245 | }; 246 | 247 | let mut offset = 0; 248 | let mut tag = Tag::with_version(header.version); 249 | while offset < header.frame_bytes() { 250 | let v = match frame::v3::decode(&mut reader) { 251 | Ok(v) => v, 252 | Err(err) => return Err(err.with_tag(tag)), 253 | }; 254 | let (bytes_read, frame) = match v { 255 | Some(v) => v, 256 | None => break, // Padding. 257 | }; 258 | tag.add_frame(frame); 259 | offset += bytes_read as u64; 260 | } 261 | Ok(tag) 262 | } 263 | Version::Id3v24 => { 264 | let mut offset = 0; 265 | let mut tag = Tag::with_version(header.version); 266 | 267 | while offset < header.frame_bytes() { 268 | let v = match frame::v4::decode(&mut reader) { 269 | Ok(v) => v, 270 | Err(err) => return Err(err.with_tag(tag)), 271 | }; 272 | let (bytes_read, frame) = match v { 273 | Some(v) => v, 274 | None => break, // Padding. 275 | }; 276 | tag.add_frame(frame); 277 | offset += bytes_read as u64; 278 | } 279 | Ok(tag) 280 | } 281 | } 282 | } 283 | 284 | pub fn decode_v2_frames(mut reader: impl io::Read) -> crate::Result { 285 | let mut tag = Tag::with_version(Version::Id3v22); 286 | // Add all frames, until either an error is thrown or there are no more frames to parse 287 | // (because of EOF or a Padding). 288 | loop { 289 | let v = match frame::v2::decode(&mut reader) { 290 | Ok(v) => v, 291 | Err(err) => return Err(err.with_tag(tag)), 292 | }; 293 | match v { 294 | Some((_bytes_read, frame)) => { 295 | tag.add_frame(frame); 296 | } 297 | None => break Ok(tag), 298 | } 299 | } 300 | } 301 | 302 | /// The `Encoder` may be used to encode tags with custom settings. 303 | #[derive(Clone, Debug)] 304 | pub struct Encoder { 305 | version: Version, 306 | unsynchronisation: bool, 307 | compression: bool, 308 | file_altered: bool, 309 | padding: Option, 310 | } 311 | 312 | impl Encoder { 313 | /// Constructs a new `Encoder` with the following configuration: 314 | /// 315 | /// * [`Version`] is ID3v2.4 316 | /// * Unsynchronization is disabled due to compatibility issues 317 | /// * No compression 318 | /// * File is not marked as altered 319 | pub fn new() -> Self { 320 | Self { 321 | version: Version::Id3v24, 322 | unsynchronisation: false, 323 | compression: false, 324 | file_altered: false, 325 | padding: None, 326 | } 327 | } 328 | 329 | /// Sets the padding that is written after the tag. 330 | /// 331 | /// Should be only used when writing to a MP3 file 332 | pub fn padding(mut self, padding: usize) -> Self { 333 | self.padding = Some(padding); 334 | self 335 | } 336 | 337 | /// Sets the ID3 version. 338 | pub fn version(mut self, version: Version) -> Self { 339 | self.version = version; 340 | self 341 | } 342 | 343 | /// Enables or disables the unsynchronisation scheme. 344 | /// 345 | /// This avoids patterns that resemble MP3-frame headers from being 346 | /// encoded. If you are encoding to MP3 files and wish to be compatible 347 | /// with very old tools, you probably want this enabled. 348 | pub fn unsynchronisation(mut self, unsynchronisation: bool) -> Self { 349 | self.unsynchronisation = unsynchronisation; 350 | self 351 | } 352 | 353 | /// Enables or disables compression. 354 | pub fn compression(mut self, compression: bool) -> Self { 355 | self.compression = compression; 356 | self 357 | } 358 | 359 | /// Informs the encoder whether the file this tag belongs to has been changed. 360 | /// 361 | /// This subsequently discards any tags that have their File Alter Preservation bits set and 362 | /// that have a relation to the file contents: 363 | /// 364 | /// AENC, ETCO, EQUA, MLLT, POSS, SYLT, SYTC, RVAD, TENC, TLEN, TSIZ 365 | pub fn file_altered(mut self, file_altered: bool) -> Self { 366 | self.file_altered = file_altered; 367 | self 368 | } 369 | 370 | /// Encodes the specified [`Tag`] using the settings set in the [`Encoder`]. 371 | /// 372 | /// Note that the plain tag is written, regardless of the original contents. To safely encode a 373 | /// tag to an MP3 file, use [`Encoder::encode_to_path`]. 374 | pub fn encode(&self, tag: &Tag, mut writer: impl io::Write) -> crate::Result<()> { 375 | // remove frames which have the flags indicating they should be removed 376 | let saved_frames = tag 377 | .frames() 378 | // Assert that by encoding, we are changing the tag. If the Tag Alter Preservation bit 379 | // is set, discard the frame. 380 | .filter(|frame| !frame.tag_alter_preservation()) 381 | // If the file this tag belongs to is updated, check for the File Alter Preservation 382 | // bit. 383 | .filter(|frame| !self.file_altered || !frame.file_alter_preservation()) 384 | // Check whether this frame is part of the set of frames that should always be 385 | // discarded when the file is changed. 386 | .filter(|frame| !self.file_altered || !DEFAULT_FILE_DISCARD.contains(&frame.id())); 387 | 388 | let mut flags = Flags::empty(); 389 | flags.set(Flags::UNSYNCHRONISATION, self.unsynchronisation); 390 | if self.version == Version::Id3v22 { 391 | flags.set(Flags::COMPRESSION, self.compression); 392 | } 393 | 394 | let mut frame_data = Vec::new(); 395 | for frame in saved_frames { 396 | frame.validate()?; 397 | frame::encode(&mut frame_data, frame, self.version, self.unsynchronisation)?; 398 | } 399 | // In ID3v2.2/ID3v2.3, Unsynchronization is applied to the whole tag data at once, not for 400 | // each frame separately. 401 | if self.unsynchronisation { 402 | match self.version { 403 | Version::Id3v22 | Version::Id3v23 => unsynch::encode_vec(&mut frame_data), 404 | Version::Id3v24 => {} 405 | }; 406 | } 407 | let tag_size = frame_data.len() + self.padding.unwrap_or(0); 408 | writer.write_all(b"ID3")?; 409 | writer.write_all(&[self.version.minor(), 0])?; 410 | writer.write_u8(flags.bits())?; 411 | writer.write_u32::(unsynch::encode_u32(tag_size as u32))?; 412 | writer.write_all(&frame_data[..])?; 413 | 414 | if let Some(padding) = self.padding { 415 | writer.write_all(&vec![0; padding])?; 416 | } 417 | Ok(()) 418 | } 419 | 420 | /// Encodes a [`Tag`] and replaces any existing tag in the file. 421 | pub fn write_to_file(&self, tag: &Tag, mut file: impl StorageFile) -> crate::Result<()> { 422 | let mut probe = [0; 12]; 423 | let nread = file.read(&mut probe)?; 424 | file.seek(io::SeekFrom::Start(0))?; 425 | let storage_format = Format::magic(&probe[..nread]); 426 | 427 | match storage_format { 428 | Some(Format::Aiff) => { 429 | chunk::write_id3_chunk_file::(file, tag, self.version)?; 430 | } 431 | Some(Format::Wav) => { 432 | chunk::write_id3_chunk_file::(file, tag, self.version)?; 433 | } 434 | Some(Format::Header) => { 435 | let location = locate_id3v2(&mut file)?; 436 | let mut storage = PlainStorage::new(file, location); 437 | let mut w = storage.writer()?; 438 | self.encode(tag, &mut w)?; 439 | w.flush()?; 440 | } 441 | None => { 442 | let mut storage = PlainStorage::new(file, 0..0); 443 | let mut w = storage.writer()?; 444 | self.encode(tag, &mut w)?; 445 | w.flush()?; 446 | } 447 | }; 448 | 449 | Ok(()) 450 | } 451 | 452 | /// Encodes a [`Tag`] and replaces any existing tag in the file. 453 | #[deprecated(note = "Use write_to_file")] 454 | pub fn encode_to_file(&self, tag: &Tag, file: &mut fs::File) -> crate::Result<()> { 455 | self.write_to_file(tag, file) 456 | } 457 | 458 | /// Encodes a [`Tag`] and replaces any existing tag in the file pointed to by the specified path. 459 | pub fn write_to_path(&self, tag: &Tag, path: impl AsRef) -> crate::Result<()> { 460 | let mut file = fs::OpenOptions::new().read(true).write(true).open(path)?; 461 | self.write_to_file(tag, &mut file)?; 462 | file.flush()?; 463 | Ok(()) 464 | } 465 | 466 | /// Encodes a [`Tag`] and replaces any existing tag in the file pointed to by the specified path. 467 | #[deprecated(note = "Use write_to_path")] 468 | pub fn encode_to_path(&self, tag: &Tag, path: impl AsRef) -> crate::Result<()> { 469 | self.write_to_path(tag, path) 470 | } 471 | } 472 | 473 | impl Default for Encoder { 474 | fn default() -> Self { 475 | Self::new() 476 | } 477 | } 478 | 479 | pub fn locate_id3v2(reader: impl io::Read + io::Seek) -> crate::Result> { 480 | let mut reader = io::BufReader::new(reader); 481 | let start = reader.stream_position()?; 482 | 483 | let file_size = reader.seek(io::SeekFrom::End(0))?; 484 | reader.seek(io::SeekFrom::Start(start))?; 485 | 486 | let header = Header::decode(&mut reader)?; 487 | let tag_size = header.tag_size(); 488 | 489 | if start + tag_size >= file_size { 490 | // Seen in the wild: tags that are encoded to be larger than the files actuall are. 491 | reader.seek(io::SeekFrom::End(0))?; 492 | return Ok(start..file_size); 493 | } 494 | 495 | reader.seek(io::SeekFrom::Start(tag_size))?; 496 | let num_padding = reader 497 | .bytes() 498 | .take_while(|rs| rs.as_ref().map(|b| *b == 0x00).unwrap_or(false)) 499 | .count(); 500 | Ok(start..tag_size + num_padding as u64) 501 | } 502 | 503 | #[cfg(test)] 504 | mod tests { 505 | use super::*; 506 | use crate::frame::{ 507 | Chapter, Content, EncapsulatedObject, Frame, MpegLocationLookupTable, 508 | MpegLocationLookupTableReference, Picture, PictureType, Popularimeter, Private, 509 | SynchronisedLyrics, SynchronisedLyricsType, TableOfContents, TimestampFormat, 510 | UniqueFileIdentifier, Unknown, 511 | }; 512 | use std::fs::{self}; 513 | use std::io::{self, Read}; 514 | 515 | fn make_tag(version: Version) -> Tag { 516 | let mut tag = Tag::new(); 517 | tag.set_title("Title"); 518 | tag.set_artist("Artist"); 519 | tag.set_genre("Genre"); 520 | tag.add_frame(Frame::with_content( 521 | "TPE1", 522 | Content::new_text_values(["artist 1", "artist 2", "artist 3"]), 523 | )); 524 | tag.set_duration(1337); 525 | tag.add_frame(EncapsulatedObject { 526 | mime_type: "Some Object".to_string(), 527 | filename: "application/octet-stream".to_string(), 528 | description: "".to_string(), 529 | data: b"\xC0\xFF\xEE\x00".to_vec(), 530 | }); 531 | let mut image_data = Vec::new(); 532 | fs::File::open("testdata/image.jpg") 533 | .unwrap() 534 | .read_to_end(&mut image_data) 535 | .unwrap(); 536 | tag.add_frame(Picture { 537 | mime_type: "image/jpeg".to_string(), 538 | picture_type: PictureType::CoverFront, 539 | description: "an image".to_string(), 540 | data: image_data, 541 | }); 542 | tag.add_frame(Popularimeter { 543 | user: "user@example.com".to_string(), 544 | rating: 255, 545 | counter: 1337, 546 | }); 547 | tag.add_frame(SynchronisedLyrics { 548 | lang: "eng".to_string(), 549 | timestamp_format: TimestampFormat::Ms, 550 | content_type: SynchronisedLyricsType::Lyrics, 551 | content: vec![ 552 | (1000, "he".to_string()), 553 | (1100, "llo".to_string()), 554 | (1200, "world".to_string()), 555 | ], 556 | description: String::from("description"), 557 | }); 558 | if let Version::Id3v23 | Version::Id3v24 = version { 559 | tag.add_frame(Chapter { 560 | element_id: "01".to_string(), 561 | start_time: 1000, 562 | end_time: 2000, 563 | start_offset: 0xff, 564 | end_offset: 0xff, 565 | frames: vec![ 566 | Frame::with_content("TIT2", Content::Text("Foo".to_string())), 567 | Frame::with_content("TALB", Content::Text("Bar".to_string())), 568 | Frame::with_content("TCON", Content::Text("Baz".to_string())), 569 | ], 570 | }); 571 | tag.add_frame(TableOfContents { 572 | element_id: "table01".to_string(), 573 | top_level: true, 574 | ordered: true, 575 | elements: vec!["01".to_string()], 576 | frames: Vec::new(), 577 | }); 578 | tag.add_frame(MpegLocationLookupTable { 579 | frames_between_reference: 1, 580 | bytes_between_reference: 418, 581 | millis_between_reference: 12, 582 | bits_for_bytes: 4, 583 | bits_for_millis: 4, 584 | references: vec![ 585 | MpegLocationLookupTableReference { 586 | deviate_bytes: 0xa, 587 | deviate_millis: 0xf, 588 | }, 589 | MpegLocationLookupTableReference { 590 | deviate_bytes: 0xa, 591 | deviate_millis: 0x0, 592 | }, 593 | ], 594 | }); 595 | tag.add_frame(Private { 596 | owner_identifier: "PrivateFrameIdentifier1".to_string(), 597 | private_data: "SomePrivateBytes".into(), 598 | }); 599 | tag.add_frame(UniqueFileIdentifier { 600 | owner_identifier: String::from("http://www.id3.org/dummy/ufid.html"), 601 | identifier: "7FZo5fMqyG5Ys1dm8F1FHa".into(), 602 | }); 603 | tag.add_frame(UniqueFileIdentifier { 604 | owner_identifier: String::from("example.com"), 605 | identifier: "3107f6e3-99c0-44c1-9785-655fc9c32d8b".into(), 606 | }); 607 | } 608 | tag 609 | } 610 | 611 | #[test] 612 | fn read_id3v22() { 613 | let mut file = fs::File::open("testdata/id3v22.id3").unwrap(); 614 | let tag: Tag = decode(&mut file).unwrap(); 615 | assert_eq!("Henry Frottey INTRO", tag.title().unwrap()); 616 | assert_eq!("Hörbuch & Gesprochene Inhalte", tag.genre().unwrap()); 617 | assert_eq!(1, tag.disc().unwrap()); 618 | assert_eq!(27, tag.total_discs().unwrap()); 619 | assert_eq!(2015, tag.year().unwrap()); 620 | if cfg!(feature = "decode_picture") { 621 | assert_eq!( 622 | PictureType::Other, 623 | tag.pictures().next().unwrap().picture_type 624 | ); 625 | assert_eq!("", tag.pictures().next().unwrap().description); 626 | assert_eq!("image/jpeg", tag.pictures().next().unwrap().mime_type); 627 | } 628 | } 629 | 630 | #[cfg(feature = "tokio")] 631 | #[tokio::test] 632 | async fn read_id3v22_tokio() { 633 | let mut file = tokio::fs::File::open("testdata/id3v22.id3").await.unwrap(); 634 | let tag: Tag = async_decode(&mut file).await.unwrap(); 635 | assert_eq!("Henry Frottey INTRO", tag.title().unwrap()); 636 | assert_eq!("Hörbuch & Gesprochene Inhalte", tag.genre().unwrap()); 637 | assert_eq!(1, tag.disc().unwrap()); 638 | assert_eq!(27, tag.total_discs().unwrap()); 639 | assert_eq!(2015, tag.year().unwrap()); 640 | if cfg!(feature = "decode_picture") { 641 | assert_eq!( 642 | PictureType::Other, 643 | tag.pictures().next().unwrap().picture_type 644 | ); 645 | assert_eq!("", tag.pictures().next().unwrap().description); 646 | assert_eq!("image/jpeg", tag.pictures().next().unwrap().mime_type); 647 | } 648 | } 649 | 650 | #[test] 651 | fn read_id3v23() { 652 | let mut file = fs::File::open("testdata/id3v23.id3").unwrap(); 653 | let tag = decode(&mut file).unwrap(); 654 | assert_eq!("Title", tag.title().unwrap()); 655 | assert_eq!("Genre", tag.genre().unwrap()); 656 | assert_eq!(1, tag.disc().unwrap()); 657 | assert_eq!(1, tag.total_discs().unwrap()); 658 | if cfg!(feature = "decode_picture") { 659 | assert_eq!( 660 | PictureType::CoverFront, 661 | tag.pictures().next().unwrap().picture_type 662 | ); 663 | } 664 | } 665 | 666 | #[cfg(feature = "tokio")] 667 | #[tokio::test] 668 | async fn read_id3v23_tokio() { 669 | let mut file = tokio::fs::File::open("testdata/id3v23.id3").await.unwrap(); 670 | let tag = async_decode(&mut file).await.unwrap(); 671 | assert_eq!("Title", tag.title().unwrap()); 672 | assert_eq!("Genre", tag.genre().unwrap()); 673 | assert_eq!(1, tag.disc().unwrap()); 674 | assert_eq!(1, tag.total_discs().unwrap()); 675 | if cfg!(feature = "decode_picture") { 676 | assert_eq!( 677 | PictureType::CoverFront, 678 | tag.pictures().next().unwrap().picture_type 679 | ); 680 | } 681 | } 682 | 683 | #[test] 684 | fn read_id3v23_geob() { 685 | let mut file = fs::File::open("testdata/id3v23_geob.id3").unwrap(); 686 | let tag = decode(&mut file).unwrap(); 687 | assert_eq!(tag.encapsulated_objects().count(), 7); 688 | 689 | let geob = tag.encapsulated_objects().next().unwrap(); 690 | assert_eq!(geob.description, "Serato Overview"); 691 | assert_eq!(geob.mime_type, "application/octet-stream"); 692 | assert_eq!(geob.filename, ""); 693 | assert_eq!(geob.data.len(), 3842); 694 | 695 | let geob = tag.encapsulated_objects().nth(1).unwrap(); 696 | assert_eq!(geob.description, "Serato Analysis"); 697 | assert_eq!(geob.mime_type, "application/octet-stream"); 698 | assert_eq!(geob.filename, ""); 699 | assert_eq!(geob.data.len(), 2); 700 | 701 | let geob = tag.encapsulated_objects().nth(2).unwrap(); 702 | assert_eq!(geob.description, "Serato Autotags"); 703 | assert_eq!(geob.mime_type, "application/octet-stream"); 704 | assert_eq!(geob.filename, ""); 705 | assert_eq!(geob.data.len(), 21); 706 | 707 | let geob = tag.encapsulated_objects().nth(3).unwrap(); 708 | assert_eq!(geob.description, "Serato Markers_"); 709 | assert_eq!(geob.mime_type, "application/octet-stream"); 710 | assert_eq!(geob.filename, ""); 711 | assert_eq!(geob.data.len(), 318); 712 | 713 | let geob = tag.encapsulated_objects().nth(4).unwrap(); 714 | assert_eq!(geob.description, "Serato Markers2"); 715 | assert_eq!(geob.mime_type, "application/octet-stream"); 716 | assert_eq!(geob.filename, ""); 717 | assert_eq!(geob.data.len(), 470); 718 | 719 | let geob = tag.encapsulated_objects().nth(5).unwrap(); 720 | assert_eq!(geob.description, "Serato BeatGrid"); 721 | assert_eq!(geob.mime_type, "application/octet-stream"); 722 | assert_eq!(geob.filename, ""); 723 | assert_eq!(geob.data.len(), 39); 724 | 725 | let geob = tag.encapsulated_objects().nth(6).unwrap(); 726 | assert_eq!(geob.description, "Serato Offsets_"); 727 | assert_eq!(geob.mime_type, "application/octet-stream"); 728 | assert_eq!(geob.filename, ""); 729 | assert_eq!(geob.data.len(), 29829); 730 | } 731 | 732 | #[test] 733 | fn read_id3v23_chap() { 734 | let mut file = fs::File::open("testdata/id3v23_chap.id3").unwrap(); 735 | let tag = decode(&mut file).unwrap(); 736 | assert_eq!(tag.chapters().count(), 7); 737 | 738 | let chapter_titles = tag 739 | .chapters() 740 | .map(|chap| chap.frames.first().unwrap().content().text().unwrap()) 741 | .collect::>(); 742 | assert_eq!( 743 | chapter_titles, 744 | &[ 745 | "MPU 554", 746 | "Read-it-Later Services?", 747 | "Safari Reading List", 748 | "Third-Party Services", 749 | "What We’re Using", 750 | "David’s Research Workflow", 751 | "Apple’s September" 752 | ] 753 | ); 754 | } 755 | 756 | #[test] 757 | fn read_id3v23_ctoc() { 758 | let mut file = fs::File::open("testdata/id3v23_chap.id3").unwrap(); 759 | let tag = decode(&mut file).unwrap(); 760 | assert_eq!(tag.tables_of_contents().count(), 1); 761 | 762 | for x in tag.tables_of_contents() { 763 | println!("{:?}", x); 764 | } 765 | 766 | let ctoc = tag.tables_of_contents().last().unwrap(); 767 | 768 | assert_eq!(ctoc.element_id, "toc"); 769 | assert!(ctoc.top_level); 770 | assert!(ctoc.ordered); 771 | assert_eq!( 772 | ctoc.elements, 773 | &["chp0", "chp1", "chp2", "chp3", "chp4", "chp5", "chp6"] 774 | ); 775 | assert!(ctoc.frames.is_empty()); 776 | } 777 | 778 | #[test] 779 | fn read_id3v24() { 780 | let mut file = fs::File::open("testdata/id3v24.id3").unwrap(); 781 | let tag = decode(&mut file).unwrap(); 782 | assert_eq!("Title", tag.title().unwrap()); 783 | assert_eq!(1, tag.disc().unwrap()); 784 | assert_eq!(1, tag.total_discs().unwrap()); 785 | if cfg!(feature = "decode_picture") { 786 | assert_eq!( 787 | PictureType::CoverFront, 788 | tag.pictures().next().unwrap().picture_type 789 | ); 790 | } 791 | } 792 | 793 | #[test] 794 | fn read_id3v24_extended() { 795 | let mut file = fs::File::open("testdata/id3v24_ext.id3").unwrap(); 796 | let tag = decode(&mut file).unwrap(); 797 | assert_eq!("Title", tag.title().unwrap()); 798 | assert_eq!("Genre", tag.genre().unwrap()); 799 | assert_eq!("Artist", tag.artist().unwrap()); 800 | assert_eq!("Album", tag.album().unwrap()); 801 | assert_eq!(2, tag.track().unwrap()); 802 | } 803 | 804 | #[cfg(feature = "tokio")] 805 | #[tokio::test] 806 | async fn read_id3v24_extended_tokio() { 807 | let mut file = tokio::fs::File::open("testdata/id3v24_ext.id3") 808 | .await 809 | .unwrap(); 810 | let tag = async_decode(&mut file).await.unwrap(); 811 | assert_eq!("Title", tag.title().unwrap()); 812 | assert_eq!("Genre", tag.genre().unwrap()); 813 | assert_eq!("Artist", tag.artist().unwrap()); 814 | assert_eq!("Album", tag.album().unwrap()); 815 | assert_eq!(2, tag.track().unwrap()); 816 | } 817 | 818 | #[test] 819 | fn write_id3v22() { 820 | if !cfg!(feature = "decode_picture") { 821 | return; 822 | } 823 | 824 | let tag = make_tag(Version::Id3v22); 825 | let mut buffer = Vec::new(); 826 | Encoder::new() 827 | .version(Version::Id3v22) 828 | .encode(&tag, &mut buffer) 829 | .unwrap(); 830 | let tag_read = decode(&mut io::Cursor::new(buffer)).unwrap(); 831 | assert_eq!(tag, tag_read); 832 | } 833 | 834 | #[test] 835 | fn write_id3v22_unsynch() { 836 | if !cfg!(feature = "decode_picture") { 837 | return; 838 | } 839 | 840 | let tag = make_tag(Version::Id3v22); 841 | let mut buffer = Vec::new(); 842 | Encoder::new() 843 | .unsynchronisation(true) 844 | .version(Version::Id3v22) 845 | .encode(&tag, &mut buffer) 846 | .unwrap(); 847 | let tag_read = decode(&mut io::Cursor::new(buffer)).unwrap(); 848 | assert_eq!(tag, tag_read); 849 | } 850 | 851 | #[test] 852 | fn write_id3v22_invalid_id() { 853 | if !cfg!(feature = "decode_picture") { 854 | return; 855 | } 856 | 857 | let mut tag = make_tag(Version::Id3v22); 858 | tag.add_frame(Frame::with_content( 859 | "XXX", 860 | Content::Unknown(Unknown { 861 | version: Version::Id3v22, 862 | data: vec![1, 2, 3], 863 | }), 864 | )); 865 | tag.add_frame(Frame::with_content( 866 | "YYY", 867 | Content::Unknown(Unknown { 868 | version: Version::Id3v22, 869 | data: vec![4, 5, 6], 870 | }), 871 | )); 872 | tag.add_frame(Frame::with_content( 873 | "ZZZ", 874 | Content::Unknown(Unknown { 875 | version: Version::Id3v22, 876 | data: vec![7, 8, 9], 877 | }), 878 | )); 879 | let mut buffer = Vec::new(); 880 | Encoder::new() 881 | .version(Version::Id3v22) 882 | .encode(&tag, &mut buffer) 883 | .unwrap(); 884 | let tag_read = decode(&mut io::Cursor::new(buffer)).unwrap(); 885 | assert_eq!(tag, tag_read); 886 | } 887 | 888 | #[test] 889 | fn write_id3v23() { 890 | if !cfg!(feature = "decode_picture") { 891 | return; 892 | } 893 | 894 | let tag = make_tag(Version::Id3v23); 895 | let mut buffer = Vec::new(); 896 | Encoder::new() 897 | .version(Version::Id3v23) 898 | .encode(&tag, &mut buffer) 899 | .unwrap(); 900 | let tag_read = decode(&mut io::Cursor::new(buffer)).unwrap(); 901 | assert_eq!(tag, tag_read); 902 | } 903 | 904 | #[test] 905 | fn write_id3v23_compression() { 906 | if !cfg!(feature = "decode_picture") { 907 | return; 908 | } 909 | 910 | let tag = make_tag(Version::Id3v23); 911 | let mut buffer = Vec::new(); 912 | Encoder::new() 913 | .compression(true) 914 | .version(Version::Id3v23) 915 | .encode(&tag, &mut buffer) 916 | .unwrap(); 917 | let tag_read = decode(&mut io::Cursor::new(buffer)).unwrap(); 918 | assert_eq!(tag, tag_read); 919 | } 920 | 921 | #[test] 922 | fn write_id3v23_unsynch() { 923 | if !cfg!(feature = "decode_picture") { 924 | return; 925 | } 926 | 927 | let tag = make_tag(Version::Id3v23); 928 | let mut buffer = Vec::new(); 929 | Encoder::new() 930 | .unsynchronisation(true) 931 | .version(Version::Id3v23) 932 | .encode(&tag, &mut buffer) 933 | .unwrap(); 934 | let tag_read = decode(&mut io::Cursor::new(buffer)).unwrap(); 935 | assert_eq!(tag, tag_read); 936 | } 937 | 938 | #[test] 939 | fn write_id3v24() { 940 | if !cfg!(feature = "decode_picture") { 941 | return; 942 | } 943 | 944 | let tag = make_tag(Version::Id3v24); 945 | let mut buffer = Vec::new(); 946 | Encoder::new() 947 | .version(Version::Id3v24) 948 | .encode(&tag, &mut buffer) 949 | .unwrap(); 950 | let tag_read = decode(&mut io::Cursor::new(buffer)).unwrap(); 951 | assert_eq!(tag, tag_read); 952 | } 953 | 954 | #[test] 955 | fn write_id3v24_compression() { 956 | if !cfg!(feature = "decode_picture") { 957 | return; 958 | } 959 | 960 | let tag = make_tag(Version::Id3v24); 961 | let mut buffer = Vec::new(); 962 | Encoder::new() 963 | .compression(true) 964 | .version(Version::Id3v24) 965 | .encode(&tag, &mut buffer) 966 | .unwrap(); 967 | let tag_read = decode(&mut io::Cursor::new(buffer)).unwrap(); 968 | assert_eq!(tag, tag_read); 969 | } 970 | 971 | #[test] 972 | fn write_id3v24_unsynch() { 973 | if !cfg!(feature = "decode_picture") { 974 | return; 975 | } 976 | 977 | let tag = make_tag(Version::Id3v24); 978 | let mut buffer = Vec::new(); 979 | Encoder::new() 980 | .unsynchronisation(true) 981 | .version(Version::Id3v24) 982 | .encode(&tag, &mut buffer) 983 | .unwrap(); 984 | let tag_read = decode(&mut io::Cursor::new(buffer)).unwrap(); 985 | assert_eq!(tag, tag_read); 986 | } 987 | 988 | #[test] 989 | fn write_id3v24_alter_file() { 990 | if !cfg!(feature = "decode_picture") { 991 | return; 992 | } 993 | 994 | let mut tag = Tag::new(); 995 | tag.set_duration(1337); 996 | 997 | let mut buffer = Vec::new(); 998 | Encoder::new() 999 | .version(Version::Id3v24) 1000 | .file_altered(true) 1001 | .encode(&tag, &mut buffer) 1002 | .unwrap(); 1003 | 1004 | let tag_read = decode(&mut io::Cursor::new(buffer)).unwrap(); 1005 | assert!(tag_read.get("TLEN").is_none()); 1006 | } 1007 | 1008 | #[test] 1009 | fn test_locate_id3v22() { 1010 | let file = fs::File::open("testdata/id3v22.id3").unwrap(); 1011 | let location = locate_id3v2(file).unwrap(); 1012 | assert_eq!(0..0x0000c3ea, location); 1013 | } 1014 | 1015 | #[test] 1016 | fn test_locate_id3v23() { 1017 | let file = fs::File::open("testdata/id3v23.id3").unwrap(); 1018 | let location = locate_id3v2(file).unwrap(); 1019 | assert_eq!(0..0x00006c0a, location); 1020 | } 1021 | 1022 | #[test] 1023 | fn test_locate_id3v24() { 1024 | let file = fs::File::open("testdata/id3v24.id3").unwrap(); 1025 | let location = locate_id3v2(file).unwrap(); 1026 | assert_eq!(0..0x00006c0a, location); 1027 | } 1028 | 1029 | #[test] 1030 | fn test_locate_id3v24_ext() { 1031 | let file = fs::File::open("testdata/id3v24_ext.id3").unwrap(); 1032 | let location = locate_id3v2(file).unwrap(); 1033 | assert_eq!(0..0x0000018d, location); 1034 | } 1035 | 1036 | #[test] 1037 | fn test_locate_no_tag() { 1038 | let file = fs::File::open("testdata/mpeg-header").unwrap(); 1039 | let location = locate_id3v2(file).unwrap_err(); 1040 | assert!(matches!( 1041 | location, 1042 | Error { 1043 | kind: ErrorKind::NoTag, 1044 | .. 1045 | } 1046 | )); 1047 | } 1048 | 1049 | #[test] 1050 | fn read_github_issue_60() { 1051 | let mut file = fs::File::open("testdata/github-issue-60.id3").unwrap(); 1052 | let _tag = decode(&mut file).unwrap(); 1053 | } 1054 | 1055 | #[test] 1056 | fn read_github_issue_73() { 1057 | let mut file = fs::File::open("testdata/github-issue-73.id3").unwrap(); 1058 | let mut tag = decode(&mut file).unwrap(); 1059 | assert_eq!(tag.track(), Some(9)); 1060 | 1061 | tag.set_total_tracks(16); 1062 | assert_eq!(tag.track(), Some(9)); 1063 | assert_eq!(tag.total_tracks(), Some(16)); 1064 | } 1065 | 1066 | #[test] 1067 | fn write_id3v24_ufids() { 1068 | let mut tag = make_tag(Version::Id3v24); 1069 | tag.add_frame(UniqueFileIdentifier { 1070 | owner_identifier: String::from("http://www.id3.org/dummy/ufid.html"), 1071 | identifier: "7FZo5fMqyG5Ys1dm8F1FHa".into(), 1072 | }); 1073 | assert_eq!(tag.unique_file_identifiers().count(), 2); 1074 | 1075 | tag.add_frame(UniqueFileIdentifier { 1076 | owner_identifier: String::from("http://www.id3.org/dummy/ufid.html"), 1077 | identifier: "09FxXfNTQsCgzkPmCeFwlr".into(), 1078 | }); 1079 | assert_eq!(tag.unique_file_identifiers().count(), 2); 1080 | 1081 | tag.add_frame(UniqueFileIdentifier { 1082 | owner_identifier: String::from("open.blotchify.com"), 1083 | identifier: "09FxXfNTQsCgzkPmCeFwlr".into(), 1084 | }); 1085 | 1086 | assert_eq!(tag.unique_file_identifiers().count(), 3); 1087 | 1088 | let mut buffer = Vec::new(); 1089 | Encoder::new() 1090 | .compression(true) 1091 | .version(Version::Id3v24) 1092 | .encode(&tag, &mut buffer) 1093 | .unwrap(); 1094 | let mut tag_read = decode(&mut io::Cursor::new(buffer)).unwrap(); 1095 | 1096 | if !cfg!(feature = "decode_picture") { 1097 | tag_read.remove_all_pictures(); 1098 | tag.remove_all_pictures(); 1099 | } 1100 | 1101 | assert_eq!(tag, tag_read); 1102 | } 1103 | 1104 | #[test] 1105 | fn test_frame_bytes_underflow() { 1106 | let header = Header { 1107 | version: Version::Id3v24, 1108 | flags: Flags::empty(), 1109 | tag_size: 10, 1110 | ext_header_size: 20, 1111 | }; 1112 | 1113 | // Without saturating_sub, this would underflow and cause a panic. 1114 | assert_eq!(header.frame_bytes(), 0); 1115 | } 1116 | } 1117 | --------------------------------------------------------------------------------