lofty-0.21.1/.cargo_vcs_info.json0000644000000001430000000000100122500ustar { "git": { "sha1": "d69f1cc2f3e23d4d7fd55c574abd01322e9a3aa6" }, "path_in_vcs": "lofty" }lofty-0.21.1/Cargo.lock0000644000000272530000000000100102360ustar # This file is automatically @generated by Cargo. # It is not intended for manual editing. version = 3 [[package]] name = "adler" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" [[package]] name = "bincode" version = "1.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" dependencies = [ "serde", ] [[package]] name = "bitflags" version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" [[package]] name = "byteorder" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "cfg-if" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "clap" version = "2.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0610544180c38b88101fecf2dd634b174a62eef6946f84dfc6a7127512b381c" dependencies = [ "bitflags 1.3.2", "textwrap", "unicode-width", ] [[package]] name = "crc32fast" version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" dependencies = [ "cfg-if", ] [[package]] name = "data-encoding" version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8566979429cf69b49a5c740c60791108e86440e8be149bbea4fe54d2c32d6e2" [[package]] name = "errno" version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" dependencies = [ "libc", "windows-sys", ] [[package]] name = "fastrand" version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fc0510504f03c51ada170672ac806f1f105a88aa97a5281117e1ddc3368e51a" [[package]] name = "flate2" version = "1.0.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f54427cfd1c7829e2a139fcefea601bf088ebca651d2bf53ebc600eac295dae" dependencies = [ "crc32fast", "miniz_oxide", ] [[package]] name = "heck" version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d621efb26863f0e9924c6ac577e8275e5e6b77455db64ffa6c65c904e9e132c" dependencies = [ "unicode-segmentation", ] [[package]] name = "iai-callgrind" version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9cb8104440b95df18524edb6b37c59c412a4eaee28eb52cf5c893fddc79eab30" dependencies = [ "bincode", "iai-callgrind-macros", "iai-callgrind-runner", ] [[package]] name = "iai-callgrind-macros" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "065a2a6ff103151860ce6f416eac6667e87dd7d866e7f5ebfd10f70c0e98c090" dependencies = [ "proc-macro-error", "proc-macro2", "quote", "syn 2.0.72", ] [[package]] name = "iai-callgrind-runner" version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61a004e04c161d16fcbfec3419c5cfc2589dbf8f45429cc7d16fdee1c4af386" dependencies = [ "serde", ] [[package]] name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" version = "0.2.155" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" [[package]] name = "linux-raw-sys" version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" [[package]] name = "lofty" version = "0.21.1" dependencies = [ "byteorder", "data-encoding", "flate2", "iai-callgrind", "lofty_attr", "log", "ogg_pager", "paste", "structopt", "tempfile", ] [[package]] name = "lofty_attr" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28bd4b9d8a5af74808932492521cdd272019b056f75fcc70056bd2c09fceb550" dependencies = [ "proc-macro2", "quote", "syn 2.0.72", ] [[package]] name = "log" version = "0.4.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" [[package]] name = "miniz_oxide" version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8a240ddb74feaf34a79a7add65a741f3167852fba007066dcac1ca548d89c08" dependencies = [ "adler", ] [[package]] name = "ogg_pager" version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "87b0bef808533c5890ab77279538212efdbbbd9aa4ef1ccdfcfbf77a42f7e6fa" dependencies = [ "byteorder", ] [[package]] name = "paste" version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" [[package]] name = "proc-macro-error" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" dependencies = [ "proc-macro-error-attr", "proc-macro2", "quote", "syn 1.0.109", "version_check", ] [[package]] name = "proc-macro-error-attr" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" dependencies = [ "proc-macro2", "quote", "version_check", ] [[package]] name = "proc-macro2" version = "1.0.86" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77" dependencies = [ "unicode-ident", ] [[package]] name = "quote" version = "1.0.36" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" dependencies = [ "proc-macro2", ] [[package]] name = "rustix" version = "0.38.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f" dependencies = [ "bitflags 2.6.0", "errno", "libc", "linux-raw-sys", "windows-sys", ] [[package]] name = "serde" version = "1.0.204" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc76f558e0cbb2a839d37354c575f1dc3fdc6546b5be373ba43d95f231bf7c12" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" version = "1.0.204" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e0cd7e117be63d3c3678776753929474f3b04a43a080c744d6b0ae2a8c28e222" dependencies = [ "proc-macro2", "quote", "syn 2.0.72", ] [[package]] name = "structopt" version = "0.3.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c6b5c64445ba8094a6ab0c3cd2ad323e07171012d9c98b0b15651daf1787a10" dependencies = [ "clap", "lazy_static", "structopt-derive", ] [[package]] name = "structopt-derive" version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dcb5ae327f9cc13b68763b5749770cb9e048a99bd9dfdfa58d0cf05d5f64afe0" dependencies = [ "heck", "proc-macro-error", "proc-macro2", "quote", "syn 1.0.109", ] [[package]] name = "syn" version = "1.0.109" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" dependencies = [ "proc-macro2", "quote", "unicode-ident", ] [[package]] name = "syn" version = "2.0.72" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc4b9b9bf2add8093d3f2c0204471e951b2285580335de42f9d2534f3ae7a8af" dependencies = [ "proc-macro2", "quote", "unicode-ident", ] [[package]] name = "tempfile" version = "3.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85b77fafb263dd9d05cbeac119526425676db3784113aa9295c88498cbf8bff1" dependencies = [ "cfg-if", "fastrand", "rustix", "windows-sys", ] [[package]] name = "textwrap" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060" dependencies = [ "unicode-width", ] [[package]] name = "unicode-ident" version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" [[package]] name = "unicode-segmentation" version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202" [[package]] name = "unicode-width" version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0336d538f7abc86d282a4189614dfaa90810dfc2c6f6427eaf88e16311dd225d" [[package]] name = "version_check" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "windows-sys" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ "windows-targets", ] [[package]] name = "windows-targets" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ "windows_aarch64_gnullvm", "windows_aarch64_msvc", "windows_i686_gnu", "windows_i686_gnullvm", "windows_i686_msvc", "windows_x86_64_gnu", "windows_x86_64_gnullvm", "windows_x86_64_msvc", ] [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" lofty-0.21.1/Cargo.toml0000644000000062730000000000100102600ustar # THIS FILE IS AUTOMATICALLY GENERATED BY CARGO # # When uploading crates to the registry Cargo will automatically # "normalize" Cargo.toml files for maximal compatibility # with all versions of Cargo and also rewrite `path` dependencies # to registry (e.g., crates.io) dependencies. # # If you are reading this file be aware that the original Cargo.toml # will likely look very different (and much more reasonable). # See Cargo.toml.orig for the original contents. [package] edition = "2021" name = "lofty" version = "0.21.1" authors = ["Serial <69764315+Serial-ATA@users.noreply.github.com>"] build = false include = [ "src", "LICENSE-APACHE", "LICENSE-MIT", "SUPPORTED_FORMATS.md", ] autobins = false autoexamples = false autotests = false autobenches = false description = "Audio metadata library" readme = "README.md" keywords = [ "tags", "audio", "metadata", "id3", "vorbis", ] categories = [ "multimedia", "multimedia::audio", "parser-implementations", ] license = "MIT OR Apache-2.0" repository = "https://github.com/Serial-ATA/lofty-rs" [package.metadata.docs.rs] all-features = true rustdoc-args = [ "--cfg", "docsrs", ] [lib] name = "lofty" path = "src/lib.rs" bench = false [dependencies.byteorder] version = "1.5.0" [dependencies.data-encoding] version = "2.6.0" [dependencies.flate2] version = "1.0.30" optional = true [dependencies.lofty_attr] version = "0.11.0" [dependencies.log] version = "0.4.22" [dependencies.ogg_pager] version = "0.6.1" [dependencies.paste] version = "1.0.15" [dev-dependencies.iai-callgrind] version = "0.12.0" [dev-dependencies.structopt] version = "0.3.26" default-features = false [dev-dependencies.tempfile] version = "3.10.1" [features] default = ["id3v2_compression_support"] id3v2_compression_support = ["dep:flate2"] [lints.clippy] bool_to_int_with_if = "allow" cast_possible_truncation = "allow" cast_possible_wrap = "allow" cast_precision_loss = "allow" cast_sign_loss = "allow" dbg_macro = "forbid" doc_markdown = "allow" field_reassign_with_default = "allow" from_over_into = "allow" ignored_unit_patterns = "allow" into_iter_without_iter = "allow" len_without_is_empty = "allow" let_underscore_untyped = "allow" manual_range_patterns = "allow" match_wildcard_for_single_variants = "allow" module_name_repetitions = "allow" must_use_candidate = "allow" needless_late_init = "allow" needless_return = "allow" no_effect_underscore_binding = "allow" redundant_guards = "allow" return_self_not_must_use = "allow" semicolon_if_nothing_returned = "allow" similar_names = "allow" single_match_else = "allow" string_to_string = "forbid" struct_excessive_bools = "allow" tabs_in_doc_comments = "allow" too_many_lines = "allow" type_complexity = "allow" uninlined_format_args = "allow" upper_case_acronyms = "allow" used_underscore_binding = "allow" [lints.clippy.all] level = "deny" priority = -1 [lints.clippy.pedantic] level = "deny" priority = -1 [lints.rust] explicit_outlives_requirements = "deny" missing_docs = "deny" trivial_casts = "deny" trivial_numeric_casts = "deny" unknown_lints = "allow" unused_import_braces = "deny" [lints.rust.rust_2018_idioms] level = "deny" priority = -1 [lints.rustdoc] broken_intra_doc_links = "deny" lofty-0.21.1/Cargo.toml.orig000064400000000000000000000035031046102023000137320ustar 00000000000000[package] name = "lofty" version = "0.21.1" authors = ["Serial <69764315+Serial-ATA@users.noreply.github.com>"] edition = "2021" license = "MIT OR Apache-2.0" description = "Audio metadata library" repository = "https://github.com/Serial-ATA/lofty-rs" keywords = ["tags", "audio", "metadata", "id3", "vorbis"] categories = ["multimedia", "multimedia::audio", "parser-implementations"] readme = "../README.md" include = ["src", "LICENSE-APACHE", "LICENSE-MIT", "SUPPORTED_FORMATS.md"] [dependencies] # Vorbis comments pictures data-encoding = "2.6.0" byteorder = { workspace = true } # ID3 compressed frames flate2 = { version = "1.0.30", optional = true } # Proc macros lofty_attr = "0.11.0" # Debug logging log = "0.4.22" # OGG Vorbis/Opus ogg_pager = "0.6.1" # Key maps paste = "1.0.15" [features] default = ["id3v2_compression_support"] id3v2_compression_support = ["dep:flate2"] [dev-dependencies] # WAV properties validity tests hound = { git = "https://github.com/ruuda/hound.git", rev = "02e66effb33683dd6acb92df792683ee46ad6a59" } # tag_writer example structopt = { version = "0.3.26", default-features = false } tempfile = "3.10.1" iai-callgrind = "0.12.0" [lints] workspace = true [lib] bench = false [[bench]] name = "read_file" path = "../benches/read_file.rs" harness = false [[bench]] name = "create_tag" path = "../benches/create_tag.rs" harness = false [[example]] name = "custom_resolver" path = "../examples/custom_resolver/src/main.rs" [[example]] name = "tag_reader" path = "../examples/tag_reader.rs" [[example]] name = "tag_writer" path = "../examples/tag_writer.rs" [[example]] name = "tag_stripper" path = "../examples/tag_stripper.rs" [package.metadata.docs.rs] all-features = true rustdoc-args = ["--cfg", "docsrs"] lofty-0.21.1/README.md000064400000000000000000000042761046102023000123320ustar 00000000000000Lofty logo # Lofty *Parse, convert, and write metadata to various audio formats.* [![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/Serial-ATA/lofty-rs/ci.yml?branch=main&logo=github&style=for-the-badge)](https://github.com/Serial-ATA/lofty-rs/actions/workflows/ci.yml) [![Downloads](https://img.shields.io/crates/d/lofty?style=for-the-badge&logo=rust)](https://crates.io/crates/lofty) [![Version](https://img.shields.io/crates/v/lofty?style=for-the-badge&logo=rust)](https://crates.io/crates/lofty) [![Documentation](https://img.shields.io/badge/docs.rs-lofty-informational?style=for-the-badge&logo=read-the-docs)](https://docs.rs/lofty/) [![GitHub Sponsors](https://img.shields.io/github/sponsors/Serial-ATA?style=for-the-badge&logo=githubsponsors)](https://github.com/sponsors/Serial-ATA) ⚠️ **LOOKING FOR HELP WITH DOCUMENTATION** ⚠️ I'm looking for help with the refinement of the docs. Any contribution, whether it be typos, grammar, punctuation, or missing examples is highly appreciated! ## Supported Formats [See here](./SUPPORTED_FORMATS.md) ## Examples * [Tag reader](examples/tag_reader.rs) * [Tag stripper](examples/tag_stripper.rs) * [Tag writer](examples/tag_writer.rs) * [Custom resolver](examples/custom_resolver) To try them out, run: ```bash cargo run --example tag_reader /path/to/file cargo run --example tag_stripper /path/to/file cargo run --example tag_writer /path/to/file cargo run --example custom_resolver ``` ## Documentation Available [here](https://docs.rs/lofty) ## Benchmarking There are benchmarks available [here](./benches). To run them, do: ```shell cargo bench ``` ## License Licensed under either of * Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0) * MIT license ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT) at your option. ## Contribution Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions. lofty-0.21.1/SUPPORTED_FORMATS.md000064400000000000000000000013551046102023000141500ustar 00000000000000| File Format | Metadata Format(s) | |-------------|------------------------------| | AAC (ADTS) | `ID3v2`, `ID3v1` | | Ape | `APE`, `ID3v2`\*, `ID3v1` | | AIFF | `ID3v2`, `Text Chunks` | | FLAC | `Vorbis Comments`, `ID3v2`\* | | MP3 | `ID3v2`, `ID3v1`, `APE` | | MP4 | `iTunes-style ilst` | | MPC | `APE`, `ID3v2`\*, `ID3v1`\* | | Opus | `Vorbis Comments` | | Ogg Vorbis | `Vorbis Comments` | | Speex | `Vorbis Comments` | | WAV | `ID3v2`, `RIFF INFO` | | WavPack | `APE`, `ID3v1` | \* The tag will be **read only**, due to lack of official support lofty-0.21.1/src/aac/header.rs000064400000000000000000000102221046102023000141500ustar 00000000000000use crate::config::ParsingMode; use crate::error::Result; use crate::macros::decode_err; use crate::mp4::{AudioObjectType, SAMPLE_RATES}; use crate::mpeg::MpegVersion; use std::io::{Read, Seek, SeekFrom}; // Used to compare the headers up to the home bit. // If they aren't equal, something is broken. pub(super) const HEADER_MASK: u32 = 0xFFFF_FFE0; #[derive(Copy, Clone)] pub(crate) struct ADTSHeader { pub(crate) version: MpegVersion, pub(crate) audio_object_ty: AudioObjectType, pub(crate) sample_rate: u32, pub(crate) channels: u8, pub(crate) copyright: bool, pub(crate) original: bool, pub(crate) len: u16, pub(crate) bitrate: u32, pub(crate) bytes: [u8; 7], pub(crate) has_crc: bool, } impl ADTSHeader { pub(super) fn read(reader: &mut R, _parse_mode: ParsingMode) -> Result> where R: Read + Seek, { // The ADTS header consists of 7 bytes, or 9 bytes with a CRC let mut needs_crc_skip = false; // AAAAAAAA AAAABCCD EEFFFFGH HHIJKLMM MMMMMMMM MMMOOOOO OOOOOOPP (QQQQQQQQ QQQQQQQQ) let mut header = [0; 7]; reader.read_exact(&mut header)?; // Letter Length (bits) Description // A 12 Syncword, all bits must be set to 1. // B 1 MPEG Version, set to 0 for MPEG-4 and 1 for MPEG-2. // C 2 Layer, always set to 0. // D 1 Protection absence, set to 1 if there is no CRC and 0 if there is CRC. // E 2 Profile, the MPEG-4 Audio Object Type minus 1. // F 4 MPEG-4 Sampling Frequency Index (15 is forbidden). // G 1 Private bit, guaranteed never to be used by MPEG, set to 0 when encoding, ignore when decoding. // H 3 MPEG-4 Channel Configuration (in the case of 0, the channel configuration is sent via an inband PCE (Program Config Element)). // I 1 Originality, set to 1 to signal originality of the audio and 0 otherwise. // J 1 Home, set to 1 to signal home usage of the audio and 0 otherwise. // K 1 Copyright ID bit, the next bit of a centrally registered copyright identifier. This is transmitted by sliding over the bit-string in LSB-first order and putting the current bit value in this field and wrapping to start if reached end (circular buffer). // L 1 Copyright ID start, signals that this frame's Copyright ID bit is the first one by setting 1 and 0 otherwise. // M 13 Frame length, length of the ADTS frame including headers and CRC check. // O 11 Buffer fullness, states the bit-reservoir per frame. // P 2 Number of AAC frames (RDBs (Raw Data Blocks)) in ADTS frame minus 1. For maximum compatibility always use one AAC frame per ADTS frame. // Q 16 CRC check (as of ISO/IEC 11172-3, subclause 2.4.3.1), if Protection absent is 0. // AAAABCCD let byte2 = header[1]; let version = match (byte2 >> 3) & 0b1 { 0 => MpegVersion::V4, 1 => MpegVersion::V2, _ => unreachable!(), }; if byte2 & 0b1 == 0 { needs_crc_skip = true; } // EEFFFFGH let byte3 = header[2]; let audio_object_ty = match ((byte3 >> 6) & 0b11) + 1 { 1 => AudioObjectType::AacMain, 2 => AudioObjectType::AacLowComplexity, 3 => AudioObjectType::AacScalableSampleRate, 4 => AudioObjectType::AacLongTermPrediction, _ => unreachable!(), }; let sample_rate_idx = (byte3 >> 2) & 0b1111; if sample_rate_idx == 15 { // 15 is forbidden decode_err!(@BAIL Aac, "File contains an invalid sample frequency index"); } let sample_rate = SAMPLE_RATES[sample_rate_idx as usize]; // HHIJKLMM let byte4 = header[3]; let channel_configuration = ((byte3 & 0b1) << 2) | ((byte4 >> 6) & 0b11); let original = (byte4 >> 5) & 0b1 == 1; let copyright = (byte4 >> 4) & 0b1 == 1; // MMMMMMMM let byte5 = header[4]; // MMMOOOOO let byte6 = header[5]; let len = (u16::from(byte4 & 0b11) << 11) | u16::from(byte5) << 3 | u16::from(byte6) >> 5; let bitrate = ((u32::from(len) * sample_rate / 1024) * 8) / 1024; if needs_crc_skip { log::debug!("Skipping CRC"); reader.seek(SeekFrom::Current(2))?; } Ok(Some(ADTSHeader { version, audio_object_ty, sample_rate, channels: channel_configuration, copyright, original, len, bitrate, bytes: header, has_crc: needs_crc_skip, })) } } lofty-0.21.1/src/aac/mod.rs000064400000000000000000000012171046102023000135030ustar 00000000000000//! AAC (ADTS) specific items // TODO: Currently we only support ADTS, might want to look into ADIF in the future. mod header; mod properties; mod read; use crate::id3::v1::tag::Id3v1Tag; use crate::id3::v2::tag::Id3v2Tag; use lofty_attr::LoftyFile; // Exports pub use properties::AACProperties; /// An AAC (ADTS) file #[derive(LoftyFile, Default)] #[lofty(read_fn = "read::read_from")] #[lofty(internal_write_module_do_not_use_anywhere_else)] pub struct AacFile { #[lofty(tag_type = "Id3v2")] pub(crate) id3v2_tag: Option, #[lofty(tag_type = "Id3v1")] pub(crate) id3v1_tag: Option, pub(crate) properties: AACProperties, } lofty-0.21.1/src/aac/properties.rs000064400000000000000000000060661046102023000151270ustar 00000000000000use crate::aac::header::ADTSHeader; use crate::mp4::AudioObjectType; use crate::mpeg::header::MpegVersion; use crate::properties::{ChannelMask, FileProperties}; use std::time::Duration; /// An AAC file's audio properties #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub struct AACProperties { pub(crate) version: MpegVersion, pub(crate) audio_object_type: AudioObjectType, pub(crate) duration: Duration, pub(crate) overall_bitrate: u32, pub(crate) audio_bitrate: u32, pub(crate) sample_rate: u32, pub(crate) channels: u8, pub(crate) channel_mask: Option, pub(crate) copyright: bool, pub(crate) original: bool, } impl AACProperties { /// MPEG version /// /// The only possible variants are: /// /// * [MpegVersion::V2] /// * [MpegVersion::V4] pub fn version(&self) -> MpegVersion { self.version } /// Audio object type /// /// The only possible variants are: /// /// * [AudioObjectType::AacMain] /// * [AudioObjectType::AacLowComplexity] /// * [AudioObjectType::AacScalableSampleRate] /// * [AudioObjectType::AacLongTermPrediction] pub fn audio_object_type(&self) -> AudioObjectType { self.audio_object_type } /// Duration of the audio pub fn duration(&self) -> Duration { self.duration } /// Overall bitrate (kbps) pub fn overall_bitrate(&self) -> u32 { self.overall_bitrate } /// Audio bitrate (kbps) pub fn audio_bitrate(&self) -> u32 { self.audio_bitrate } /// Sample rate (Hz) pub fn sample_rate(&self) -> u32 { self.sample_rate } /// Channel count pub fn channels(&self) -> u8 { self.channels } /// Channel mask pub fn channel_mask(&self) -> Option { self.channel_mask } /// Whether the audio is copyrighted pub fn copyright(&self) -> bool { self.copyright } /// Whether the media is original or a copy pub fn original(&self) -> bool { self.original } } impl From for FileProperties { fn from(input: AACProperties) -> Self { FileProperties { duration: input.duration, overall_bitrate: Some(input.overall_bitrate), audio_bitrate: Some(input.audio_bitrate), sample_rate: Some(input.sample_rate), bit_depth: None, channels: Some(input.channels), channel_mask: input.channel_mask, } } } pub(super) fn read_properties( properties: &mut AACProperties, first_frame: ADTSHeader, stream_len: u64, ) { properties.version = first_frame.version; properties.audio_object_type = first_frame.audio_object_ty; properties.sample_rate = first_frame.sample_rate; properties.channels = first_frame.channels; match ChannelMask::from_mp4_channels(properties.channels) { Some(mask) => properties.channel_mask = Some(mask), None => { log::warn!( "Unable to create channel mask, invalid channel count: {}", properties.channels ); }, } properties.copyright = first_frame.copyright; properties.original = first_frame.original; let bitrate = first_frame.bitrate; if bitrate > 0 { properties.audio_bitrate = bitrate; properties.overall_bitrate = bitrate; properties.duration = Duration::from_millis((stream_len * 8) / u64::from(bitrate)); } } lofty-0.21.1/src/aac/read.rs000064400000000000000000000123311046102023000136360ustar 00000000000000use super::header::{ADTSHeader, HEADER_MASK}; use super::AacFile; use crate::config::{ParseOptions, ParsingMode}; use crate::error::Result; use crate::id3::v2::header::Id3v2Header; use crate::id3::v2::read::parse_id3v2; use crate::id3::{find_id3v1, ID3FindResults}; use crate::macros::{decode_err, err, parse_mode_choice}; use crate::mpeg::header::{cmp_header, search_for_frame_sync, HeaderCmpResult}; use std::io::{Read, Seek, SeekFrom}; use byteorder::ReadBytesExt; #[allow(clippy::unnecessary_wraps)] pub(super) fn read_from(reader: &mut R, parse_options: ParseOptions) -> Result where R: Read + Seek, { let parse_mode = parse_options.parsing_mode; let mut file = AacFile::default(); let mut first_frame_header = None; let mut first_frame_end = 0; // Skip any invalid padding while reader.read_u8()? == 0 {} reader.seek(SeekFrom::Current(-1))?; let pos = reader.stream_position()?; let mut stream_len = reader.seek(SeekFrom::End(0))?; reader.seek(SeekFrom::Start(pos))?; let mut header = [0; 4]; while let Ok(()) = reader.read_exact(&mut header) { match header { // [I, D, 3, ver_major, ver_minor, flags, size (4 bytes)] [b'I', b'D', b'3', ..] => { // Seek back to read the tag in full reader.seek(SeekFrom::Current(-4))?; let header = Id3v2Header::parse(reader)?; let skip_footer = header.flags.footer; let Some(new_stream_len) = stream_len.checked_sub(u64::from(header.size)) else { err!(SizeMismatch); }; stream_len = new_stream_len; if parse_options.read_tags { let id3v2 = parse_id3v2(reader, header, parse_options)?; if let Some(existing_tag) = &mut file.id3v2_tag { log::warn!("Duplicate ID3v2 tag found, appending frames to previous tag"); // https://github.com/Serial-ATA/lofty-rs/issues/87 // Duplicate tags should have their frames appended to the previous for frame in id3v2.frames { existing_tag.insert(frame); } continue; } file.id3v2_tag = Some(id3v2); } // Skip over the footer if skip_footer { log::debug!("Skipping ID3v2 footer"); let Some(new_stream_len) = stream_len.checked_sub(10) else { err!(SizeMismatch); }; stream_len = new_stream_len; reader.seek(SeekFrom::Current(10))?; } continue; }, // Tags might be followed by junk bytes before the first ADTS frame begins _ => { log::debug!("Searching for first ADTS frame"); // Seek back the length of the temporary header buffer, to include them // in the frame sync search #[allow(clippy::neg_multiply)] reader.seek(SeekFrom::Current(-1 * header.len() as i64))?; if let Some((first_frame_header_, first_frame_end_)) = find_next_frame(reader, parse_mode)? { log::debug!("Found first ADTS frame"); first_frame_header = Some(first_frame_header_); first_frame_end = first_frame_end_; break; } }, } } #[allow(unused_variables)] let ID3FindResults(header, id3v1) = find_id3v1(reader, parse_options.read_tags)?; if header.is_some() { let Some(new_stream_len) = stream_len.checked_sub(128) else { err!(SizeMismatch); }; stream_len = new_stream_len; file.id3v1_tag = id3v1; } if parse_options.read_properties { let Some(mut first_frame_header) = first_frame_header else { // The search for sync bits was unsuccessful decode_err!(@BAIL Mpeg, "File contains an invalid frame"); }; if first_frame_header.sample_rate == 0 { parse_mode_choice!( parse_mode, STRICT: decode_err!(@BAIL Mpeg, "Sample rate is 0"), ); } if first_frame_header.bitrate == 0 { parse_mode_choice!(parse_mode, STRICT: decode_err!(@BAIL Mpeg, "Bitrate is 0"),); } // Read as many frames as we can to try and find the average bitrate reader.seek(SeekFrom::Start(first_frame_end))?; let mut frame_count = 1; while let Some((header, _)) = find_next_frame(reader, parse_mode)? { first_frame_header.bitrate += header.bitrate; frame_count += 1u32; } first_frame_header.bitrate /= frame_count; super::properties::read_properties(&mut file.properties, first_frame_header, stream_len); } Ok(file) } // Searches for the next frame, comparing it to the following one fn find_next_frame( reader: &mut R, parsing_mode: ParsingMode, ) -> Result> where R: Read + Seek, { let mut pos = reader.stream_position()?; while let Ok(Some(first_adts_frame_start_relative)) = search_for_frame_sync(reader) { let first_adts_frame_start_absolute = pos + first_adts_frame_start_relative; // Seek back to the start of the frame and read the header reader.seek(SeekFrom::Start(first_adts_frame_start_absolute))?; if let Some(first_header) = ADTSHeader::read(reader, parsing_mode)? { let header_len = if first_header.has_crc { 9 } else { 7 }; match cmp_header( reader, header_len, u32::from(first_header.len), u32::from_be_bytes(first_header.bytes[..4].try_into().unwrap()), HEADER_MASK, ) { HeaderCmpResult::Equal => { return Ok(Some(( first_header, first_adts_frame_start_absolute + u64::from(header_len), ))) }, HeaderCmpResult::Undetermined => return Ok(None), HeaderCmpResult::NotEqual => {}, } } pos = reader.stream_position()?; } Ok(None) } lofty-0.21.1/src/ape/constants.rs000064400000000000000000000003011046102023000147520ustar 00000000000000pub(super) const INVALID_KEYS: [&str; 4] = ["ID3", "TAG", "OGGS", "MP+"]; // https://wiki.hydrogenaud.io/index.php?title=APE_Tags_Header pub(crate) const APE_PREAMBLE: &[u8; 8] = b"APETAGEX"; lofty-0.21.1/src/ape/header.rs000064400000000000000000000024441046102023000142000ustar 00000000000000use crate::error::Result; use crate::macros::decode_err; use crate::util::io::SeekStreamLen; use std::io::{Read, Seek, SeekFrom}; use std::ops::Neg; use byteorder::{LittleEndian, ReadBytesExt}; #[derive(Copy, Clone)] pub(crate) struct ApeHeader { pub(crate) size: u32, pub(crate) item_count: u32, } pub(crate) fn read_ape_header(data: &mut R, footer: bool) -> Result where R: Read + Seek, { let version = data.read_u32::()?; let mut size = data.read_u32::()?; if size < 32 { // If the size is < 32, something went wrong during encoding // The size includes the footer and all items decode_err!(@BAIL Ape, "APE tag has an invalid size (< 32)"); } let item_count = data.read_u32::()?; if footer { // No point in reading the rest of the footer, just seek back to the end of the header data.seek(SeekFrom::Current(i64::from(size - 12).neg()))?; } else { // There are 12 bytes remaining in the header // Flags (4) // Reserved (8) data.seek(SeekFrom::Current(12))?; } // Version 1 doesn't include a header if version == 2000 { size = size.saturating_add(32); } if u64::from(size) > data.stream_len_hack()? { decode_err!(@BAIL Ape, "APE tag has an invalid size (> file size)"); } Ok(ApeHeader { size, item_count }) } lofty-0.21.1/src/ape/mod.rs000064400000000000000000000021341046102023000135230ustar 00000000000000//! APE specific items //! //! ## File notes //! //! It is possible for an `APE` file to contain an `ID3v2` tag. For the sake of data preservation, //! this tag will be read, but **cannot** be written. The only tags allowed by spec are `APEv1/2` and //! `ID3v1`. pub(crate) mod constants; pub(crate) mod header; mod properties; mod read; pub(crate) mod tag; use crate::id3::v1::tag::Id3v1Tag; use crate::id3::v2::tag::Id3v2Tag; use lofty_attr::LoftyFile; // Exports pub use crate::picture::APE_PICTURE_TYPES; pub use properties::ApeProperties; pub use tag::item::ApeItem; pub use tag::ApeTag; /// An APE file #[derive(LoftyFile)] #[lofty(read_fn = "read::read_from")] #[lofty(internal_write_module_do_not_use_anywhere_else)] pub struct ApeFile { /// An ID3v1 tag #[lofty(tag_type = "Id3v1")] pub(crate) id3v1_tag: Option, /// An ID3v2 tag (Not officially supported) #[lofty(tag_type = "Id3v2")] pub(crate) id3v2_tag: Option, /// An APEv1/v2 tag #[lofty(tag_type = "Ape")] pub(crate) ape_tag: Option, /// The file's audio properties pub(crate) properties: ApeProperties, } lofty-0.21.1/src/ape/properties.rs000064400000000000000000000152251046102023000151450ustar 00000000000000use crate::config::ParsingMode; use crate::error::Result; use crate::macros::decode_err; use crate::properties::FileProperties; use std::io::{Read, Seek, SeekFrom}; use std::time::Duration; use byteorder::{LittleEndian, ReadBytesExt}; /// An APE file's audio properties #[derive(Clone, Debug, PartialEq, Eq, Default)] #[non_exhaustive] pub struct ApeProperties { pub(crate) version: u16, pub(crate) duration: Duration, pub(crate) overall_bitrate: u32, pub(crate) audio_bitrate: u32, pub(crate) sample_rate: u32, pub(crate) bit_depth: u8, pub(crate) channels: u8, } impl From for FileProperties { fn from(input: ApeProperties) -> Self { Self { duration: input.duration, overall_bitrate: Some(input.overall_bitrate), audio_bitrate: Some(input.audio_bitrate), sample_rate: Some(input.sample_rate), bit_depth: Some(input.bit_depth), channels: Some(input.channels), channel_mask: None, } } } impl ApeProperties { /// Duration of the audio pub fn duration(&self) -> Duration { self.duration } /// Overall bitrate (kbps) pub fn overall_bitrate(&self) -> u32 { self.overall_bitrate } /// Audio bitrate (kbps) pub fn bitrate(&self) -> u32 { self.audio_bitrate } /// Sample rate (Hz) pub fn sample_rate(&self) -> u32 { self.sample_rate } /// Bits per sample pub fn bit_depth(&self) -> u8 { self.bit_depth } /// Channel count pub fn channels(&self) -> u8 { self.channels } /// APE version pub fn version(&self) -> u16 { self.version } } pub(super) fn read_properties( data: &mut R, stream_len: u64, file_length: u64, parse_mode: ParsingMode, ) -> Result where R: Read + Seek, { let version = data .read_u16::() .map_err(|_| decode_err!(Ape, "Unable to read APE tag version"))?; // Property reading differs between versions if version >= 3980 { properties_gt_3980(data, version, stream_len, file_length, parse_mode) } else { properties_lt_3980(data, version, stream_len, file_length, parse_mode) } } fn properties_gt_3980( data: &mut R, version: u16, stream_len: u64, file_length: u64, parse_mode: ParsingMode, ) -> Result where R: Read + Seek, { // First read the file descriptor let mut descriptor = [0; 46]; data.read_exact(&mut descriptor).map_err(|_| { decode_err!( Ape, "Not enough data left in reader to finish file descriptor" ) })?; // The only piece of information we need from the file descriptor let descriptor_len = u32::from_le_bytes( descriptor[2..6].try_into().unwrap(), // Infallible ); // The descriptor should be 52 bytes long (including ['M', 'A', 'C', ' '] // Anything extra is unknown, and just gets skipped if descriptor_len > 52 { data.seek(SeekFrom::Current(i64::from(descriptor_len - 52)))?; } // Move on to the header let mut header = [0; 24]; data.read_exact(&mut header) .map_err(|_| decode_err!(Ape, "Not enough data left in reader to finish MAC header"))?; let mut properties = ApeProperties::default(); properties.version = version; // Skip the first 4 bytes of the header // Compression type (2) // Format flags (2) let header_read = &mut &header[4..]; let blocks_per_frame = header_read.read_u32::()?; let final_frame_blocks = header_read.read_u32::()?; let total_frames = header_read.read_u32::()?; properties.bit_depth = header_read.read_u16::()? as u8; properties.channels = header_read.read_u16::()? as u8; properties.sample_rate = header_read.read_u32::()?; match verify(total_frames, properties.channels) { Err(e) if parse_mode == ParsingMode::Strict => return Err(e), Err(_) => return Ok(properties), _ => {}, } get_duration_bitrate( &mut properties, file_length, total_frames, final_frame_blocks, blocks_per_frame, stream_len, ); Ok(properties) } fn properties_lt_3980( data: &mut R, version: u16, stream_len: u64, file_length: u64, parse_mode: ParsingMode, ) -> Result where R: Read, { // Versions < 3980 don't have a descriptor let mut header = [0; 26]; data.read_exact(&mut header) .map_err(|_| decode_err!(Ape, "Not enough data left in reader to finish MAC header"))?; let mut properties = ApeProperties::default(); properties.version = version; let header_reader = &mut &header[..]; // https://github.com/fernandotcl/monkeys-audio/blob/5fe956c7e67c13daa80518a4cc7001e9fa185297/src/MACLib/MACLib.h#L74 let compression_level = header_reader.read_u16::()?; let format_flags = header_reader.read_u16::()?; if (format_flags & 0b1) == 1 { properties.bit_depth = 8 } else if (format_flags & 0b1000) == 8 { properties.bit_depth = 24 } else { properties.bit_depth = 16 }; let blocks_per_frame = match version { _ if version >= 3950 => 73728 * 4, _ if version >= 3900 || (version >= 3800 && compression_level >= 4000) => 73728, _ => 9216, }; properties.channels = header_reader.read_u16::()? as u8; properties.sample_rate = header_reader.read_u32::()?; // Skipping 8 bytes // WAV header length (4) // WAV tail length (4) let mut _skip = [0; 8]; header_reader.read_exact(&mut _skip)?; let total_frames = header_reader.read_u32::()?; let final_frame_blocks = header_reader.read_u32::()?; match verify(total_frames, properties.channels) { Err(e) if parse_mode == ParsingMode::Strict => return Err(e), Err(_) => return Ok(properties), _ => {}, } get_duration_bitrate( &mut properties, file_length, total_frames, final_frame_blocks, blocks_per_frame, stream_len, ); Ok(properties) } /// Verifies the channel count falls within the bounds of the spec, and we have some audio frames to work with. fn verify(total_frames: u32, channels: u8) -> Result<()> { if !(1..=32).contains(&channels) { decode_err!(@BAIL Ape, "File has an invalid channel count (must be between 1 and 32 inclusive)"); } if total_frames == 0 { decode_err!(@BAIL Ape, "File contains no frames"); } Ok(()) } fn get_duration_bitrate( properties: &mut ApeProperties, file_length: u64, total_frames: u32, final_frame_blocks: u32, blocks_per_frame: u32, stream_len: u64, ) { let mut total_samples = u64::from(final_frame_blocks); if total_samples > 1 { total_samples += u64::from(blocks_per_frame) * u64::from(total_frames - 1) } if properties.sample_rate > 0 { let length = (total_samples as f64 * 1000.0) / f64::from(properties.sample_rate); properties.duration = Duration::from_millis((length + 0.5) as u64); properties.audio_bitrate = ((stream_len as f64) * 8.0 / length + 0.5) as u32; properties.overall_bitrate = ((file_length as f64) * 8.0 / length + 0.5) as u32; } } lofty-0.21.1/src/ape/read.rs000064400000000000000000000100231046102023000136530ustar 00000000000000use super::header::read_ape_header; use super::tag::ApeTag; use super::{ApeFile, ApeProperties}; use crate::ape::tag::read::{read_ape_tag, read_ape_tag_with_header}; use crate::config::ParseOptions; use crate::error::Result; use crate::id3::v1::tag::Id3v1Tag; use crate::id3::v2::read::parse_id3v2; use crate::id3::v2::tag::Id3v2Tag; use crate::id3::{find_id3v1, find_id3v2, find_lyrics3v2, FindId3v2Config, ID3FindResults}; use crate::macros::decode_err; use std::io::{Read, Seek, SeekFrom}; pub(crate) fn read_from(data: &mut R, parse_options: ParseOptions) -> Result where R: Read + Seek, { let start = data.stream_position()?; let end = data.seek(SeekFrom::End(0))?; data.seek(SeekFrom::Start(start))?; let mut stream_len = end - start; let mut id3v2_tag: Option = None; let mut id3v1_tag: Option = None; let mut ape_tag: Option = None; let find_id3v2_config = if parse_options.read_tags { FindId3v2Config::READ_TAG } else { FindId3v2Config::NO_READ_TAG }; // ID3v2 tags are unsupported in APE files, but still possible if let ID3FindResults(Some(header), content) = find_id3v2(data, find_id3v2_config)? { log::warn!("Encountered an ID3v2 tag. This tag cannot be rewritten to the APE file!"); stream_len -= u64::from(header.size); // Exclude the footer if header.flags.footer { stream_len -= 10; } if let Some(content) = content { let reader = &mut &*content; let id3v2 = parse_id3v2(reader, header, parse_options)?; id3v2_tag = Some(id3v2); } } let mut found_mac = false; let mut mac_start = 0; let mut header = [0; 4]; data.read_exact(&mut header)?; while !found_mac { match &header { b"MAC " => { mac_start = data.stream_position()?; found_mac = true; }, // An APE tag at the beginning of the file goes against the spec, but is still possible. // This only allows for v2 tags though, since it relies on the header. b"APET" => { log::warn!( "Encountered an APE tag at the beginning of the file, attempting to read" ); // Get the remaining part of the ape tag let mut remaining = [0; 4]; data.read_exact(&mut remaining).map_err(|_| { decode_err!( Ape, "Found partial APE tag, but there isn't enough data left in the reader" ) })?; if &remaining[..4] != b"AGEX" { decode_err!(@BAIL Ape, "Found incomplete APE tag"); } let ape_header = read_ape_header(data, false)?; stream_len -= u64::from(ape_header.size); if parse_options.read_tags { let ape = read_ape_tag_with_header(data, ape_header, parse_options)?; ape_tag = Some(ape); } }, _ => { decode_err!(@BAIL Ape, "Invalid data found while reading header, expected any of [\"MAC \", \"APETAGEX\", \"ID3\"]") }, } } // First see if there's a ID3v1 tag // // Starts with ['T', 'A', 'G'] // Exactly 128 bytes long (including the identifier) #[allow(unused_variables)] let ID3FindResults(id3v1_header, id3v1) = find_id3v1(data, parse_options.read_tags)?; if id3v1_header.is_some() { stream_len -= 128; id3v1_tag = id3v1; } // Next, check for a Lyrics3v2 tag, and skip over it, as it's no use to us let ID3FindResults(lyrics3_header, lyrics3v2_size) = find_lyrics3v2(data)?; if lyrics3_header.is_some() { stream_len -= u64::from(lyrics3v2_size) } // Next, search for an APE tag footer // // Starts with ['A', 'P', 'E', 'T', 'A', 'G', 'E', 'X'] // Exactly 32 bytes long // Strongly recommended to be at the end of the file data.seek(SeekFrom::Current(-32))?; if let (tag, Some(header)) = read_ape_tag(data, true, parse_options)? { stream_len -= u64::from(header.size); ape_tag = tag; } let file_length = data.stream_position()?; // Go back to the MAC header to read properties data.seek(SeekFrom::Start(mac_start))?; Ok(ApeFile { id3v1_tag, id3v2_tag, ape_tag, properties: if parse_options.read_properties { super::properties::read_properties( data, stream_len, file_length, parse_options.parsing_mode, )? } else { ApeProperties::default() }, }) } lofty-0.21.1/src/ape/tag/item.rs000064400000000000000000000044251046102023000144620ustar 00000000000000use crate::ape::constants::INVALID_KEYS; use crate::error::{LoftyError, Result}; use crate::macros::decode_err; use crate::tag::item::ItemValueRef; use crate::tag::{ItemValue, TagItem, TagType}; /// Represents an `APE` tag item /// /// The restrictions for `APE` lie in the key rather than the value, /// so these are still able to use [`ItemValue`]s #[derive(Debug, PartialEq, Eq, Clone)] pub struct ApeItem { /// Whether or not to mark the item as read only pub read_only: bool, pub(crate) key: String, pub(crate) value: ItemValue, } impl ApeItem { /// Create an [`ApeItem`] /// /// # Errors /// /// * `key` is illegal ("ID3", "TAG", "OGGS", "MP+") /// * `key` has a bad length (must be 2 to 255, inclusive) /// * `key` contains invalid characters (must be in the range 0x20 to 0x7E, inclusive) pub fn new(key: String, value: ItemValue) -> Result { if INVALID_KEYS.contains(&&*key.to_uppercase()) { decode_err!(@BAIL Ape, "APE tag item contains an illegal key"); } if !(2..=255).contains(&key.len()) { decode_err!(@BAIL Ape, "APE tag item key has an invalid length (< 2 || > 255)"); } if key.chars().any(|c| !(0x20..=0x7E).contains(&(c as u32))) { decode_err!(@BAIL Ape, "APE tag item key contains invalid characters"); } Ok(Self { read_only: false, key, value, }) } /// Returns the item key pub fn key(&self) -> &str { &self.key } /// Returns the item value pub fn value(&self) -> &ItemValue { &self.value } // Used internally, has no correctness checks pub(crate) fn text(key: &str, value: String) -> Self { Self { read_only: false, key: String::from(key), value: ItemValue::Text(value), } } } impl TryFrom for ApeItem { type Error = LoftyError; fn try_from(value: TagItem) -> std::result::Result { Self::new( value .item_key .map_key(TagType::Ape, false) .ok_or_else(|| decode_err!(Ape, "Attempted to convert an unsupported item key"))? .to_string(), value.item_value, ) } } pub(crate) struct ApeItemRef<'a> { pub read_only: bool, pub key: &'a str, pub value: ItemValueRef<'a>, } impl<'a> Into> for &'a ApeItem { fn into(self) -> ApeItemRef<'a> { ApeItemRef { read_only: self.read_only, key: self.key(), value: (&self.value).into(), } } } lofty-0.21.1/src/ape/tag/mod.rs000064400000000000000000000515751046102023000143130ustar 00000000000000pub(crate) mod item; pub(crate) mod read; mod write; use crate::ape::tag::item::{ApeItem, ApeItemRef}; use crate::config::WriteOptions; use crate::error::{LoftyError, Result}; use crate::id3::v2::util::pairs::{format_number_pair, set_number, NUMBER_PAIR_KEYS}; use crate::tag::item::ItemValueRef; use crate::tag::{ try_parse_year, Accessor, ItemKey, ItemValue, MergeTag, SplitTag, Tag, TagExt, TagItem, TagType, }; use crate::util::flag_item; use crate::util::io::{FileLike, Truncate}; use std::borrow::Cow; use std::io::Write; use std::ops::Deref; use lofty_attr::tag; macro_rules! impl_accessor { ($($name:ident => $($key:literal)|+;)+) => { paste::paste! { $( fn $name(&self) -> Option> { $( if let Some(i) = self.get($key) { if let ItemValue::Text(val) = i.value() { return Some(Cow::Borrowed(val)); } } )+ None } fn [](&mut self, value: String) { self.insert(ApeItem { read_only: false, key: String::from(crate::tag::item::first_key!($($key)|*)), value: ItemValue::Text(value) }) } fn [](&mut self) { $( self.remove($key); )+ } )+ } } } /// ## Item storage /// /// `APE` isn't a very strict format. An [`ApeItem`] only restricted by its name, meaning it can use /// a normal [`ItemValue`](crate::ItemValue) unlike other formats. /// /// Pictures are stored as [`ItemValue::Binary`](crate::ItemValue::Binary), and can be converted with /// [`Picture::from_ape_bytes`](crate::Picture::from_ape_bytes). For the appropriate item keys, see /// [`APE_PICTURE_TYPES`](crate::ape::APE_PICTURE_TYPES). /// /// ## Conversions /// /// ### To `Tag` /// /// Any [`ApeItem`] with an [`ItemKey`] mapping will have a 1:1 conversion to [`TagItem`]. /// /// ### From `Tag` /// /// When converting pictures, any of type [`PictureType::Undefined`](crate::PictureType::Undefined) will be discarded. /// For items, see [`ApeItem::new`]. #[derive(Default, Debug, PartialEq, Eq, Clone)] #[tag( description = "An `APE` tag", supported_formats(Ape, Mpeg, Mpc, WavPack) )] pub struct ApeTag { /// Whether or not to mark the tag as read only pub read_only: bool, pub(super) items: Vec, } impl ApeTag { /// Create a new empty `ApeTag` /// /// # Examples /// /// ```rust /// use lofty::ape::ApeTag; /// use lofty::tag::TagExt; /// /// let ape_tag = ApeTag::new(); /// assert!(ape_tag.is_empty()); /// ``` pub fn new() -> Self { Self::default() } /// Get an [`ApeItem`] by key /// /// NOTE: While `APE` items are supposed to be case-sensitive, /// this rule is rarely followed, so this will ignore case when searching. /// /// # Examples /// /// ```rust /// use lofty::ape::ApeTag; /// use lofty::tag::Accessor; /// /// let mut ape_tag = ApeTag::new(); /// ape_tag.set_title(String::from("Foo title")); /// /// // Get the title by its key /// let title = ape_tag.get("Title"); /// assert!(title.is_some()); /// ``` pub fn get(&self, key: &str) -> Option<&ApeItem> { self.items .iter() .find(|i| i.key().eq_ignore_ascii_case(key)) } /// Insert an [`ApeItem`] /// /// This will remove any item with the same key prior to insertion pub fn insert(&mut self, value: ApeItem) { self.remove(value.key()); self.items.push(value); } /// Remove an [`ApeItem`] by key /// /// NOTE: Like [`ApeTag::get`], this is not case-sensitive /// /// # Examples /// /// ```rust /// use lofty::ape::ApeTag; /// use lofty::tag::Accessor; /// /// let mut ape_tag = ApeTag::new(); /// ape_tag.set_title(String::from("Foo title")); /// /// // Get the title by its key /// let title = ape_tag.get("Title"); /// assert!(title.is_some()); /// /// // Remove the title /// ape_tag.remove("Title"); /// /// let title = ape_tag.get("Title"); /// assert!(title.is_none()); /// ``` pub fn remove(&mut self, key: &str) { self.items.retain(|i| !i.key().eq_ignore_ascii_case(key)); } fn insert_item(&mut self, item: TagItem) { match item.key() { ItemKey::TrackNumber => set_number(&item, |number| self.set_track(number)), ItemKey::TrackTotal => set_number(&item, |number| self.set_track_total(number)), ItemKey::DiscNumber => set_number(&item, |number| self.set_disk(number)), ItemKey::DiscTotal => set_number(&item, |number| self.set_disk_total(number)), // Normalize flag items ItemKey::FlagCompilation => { let Some(text) = item.item_value.text() else { return; }; let Some(flag) = flag_item(text) else { return; }; let value = u8::from(flag).to_string(); self.insert(ApeItem::text("Compilation", value)); }, _ => { if let Ok(item) = item.try_into() { self.insert(item); } }, }; } fn split_num_pair(&self, key: &str) -> (Option, Option) { if let Some(ApeItem { value: ItemValue::Text(ref text), .. }) = self.get(key) { let mut split = text.split('/').flat_map(str::parse::); return (split.next(), split.next()); } (None, None) } fn insert_number_pair(&mut self, key: &'static str, number: Option, total: Option) { if let Some(value) = format_number_pair(number, total) { self.insert(ApeItem::text(key, value)); } else { log::warn!("{key} is not set. number: {number:?}, total: {total:?}"); } } } impl IntoIterator for ApeTag { type Item = ApeItem; type IntoIter = std::vec::IntoIter; fn into_iter(self) -> Self::IntoIter { self.items.into_iter() } } impl<'a> IntoIterator for &'a ApeTag { type Item = &'a ApeItem; type IntoIter = std::slice::Iter<'a, ApeItem>; fn into_iter(self) -> Self::IntoIter { self.items.iter() } } impl Accessor for ApeTag { impl_accessor!( artist => "Artist"; title => "Title"; album => "Album"; genre => "GENRE"; comment => "Comment"; ); fn track(&self) -> Option { self.split_num_pair("Track").0 } fn set_track(&mut self, value: u32) { self.insert_number_pair("Track", Some(value), self.track_total()); } fn remove_track(&mut self) { self.remove("Track"); } fn track_total(&self) -> Option { self.split_num_pair("Track").1 } fn set_track_total(&mut self, value: u32) { self.insert_number_pair("Track", self.track(), Some(value)); } fn remove_track_total(&mut self) { let existing_track_number = self.track(); self.remove("Track"); if let Some(track) = existing_track_number { self.insert(ApeItem::text("Track", track.to_string())); } } fn disk(&self) -> Option { self.split_num_pair("Disc").0 } fn set_disk(&mut self, value: u32) { self.insert_number_pair("Disc", Some(value), self.disk_total()); } fn remove_disk(&mut self) { self.remove("Disc"); } fn disk_total(&self) -> Option { self.split_num_pair("Disc").1 } fn set_disk_total(&mut self, value: u32) { self.insert_number_pair("Disc", self.disk(), Some(value)); } fn remove_disk_total(&mut self) { let existing_track_number = self.track(); self.remove("Disc"); if let Some(track) = existing_track_number { self.insert(ApeItem::text("Disc", track.to_string())); } } fn year(&self) -> Option { if let Some(ApeItem { value: ItemValue::Text(ref text), .. }) = self.get("Year") { return try_parse_year(text); } None } fn set_year(&mut self, value: u32) { self.insert(ApeItem::text("Year", value.to_string())); } fn remove_year(&mut self) { self.remove("Year"); } } impl TagExt for ApeTag { type Err = LoftyError; type RefKey<'a> = &'a str; #[inline] fn tag_type(&self) -> TagType { TagType::Ape } fn len(&self) -> usize { self.items.len() } fn contains<'a>(&'a self, key: Self::RefKey<'a>) -> bool { self.items.iter().any(|i| i.key().eq_ignore_ascii_case(key)) } fn is_empty(&self) -> bool { self.items.is_empty() } /// Write an `APE` tag to a file /// /// # Errors /// /// * Attempting to write the tag to a format that does not support it /// * An existing tag has an invalid size fn save_to( &self, file: &mut F, write_options: WriteOptions, ) -> std::result::Result<(), Self::Err> where F: FileLike, LoftyError: From<::Error>, { ApeTagRef { read_only: self.read_only, items: self.items.iter().map(Into::into), } .write_to(file, write_options) } /// Dumps the tag to a writer /// /// # Errors /// /// * [`std::io::Error`] fn dump_to( &self, writer: &mut W, write_options: WriteOptions, ) -> std::result::Result<(), Self::Err> { ApeTagRef { read_only: self.read_only, items: self.items.iter().map(Into::into), } .dump_to(writer, write_options) } fn clear(&mut self) { self.items.clear(); } } #[derive(Debug, Clone, Default)] pub struct SplitTagRemainder(ApeTag); impl From for ApeTag { fn from(from: SplitTagRemainder) -> Self { from.0 } } impl Deref for SplitTagRemainder { type Target = ApeTag; fn deref(&self) -> &Self::Target { &self.0 } } impl SplitTag for ApeTag { type Remainder = SplitTagRemainder; fn split_tag(mut self) -> (Self::Remainder, Tag) { fn split_pair( content: &str, tag: &mut Tag, current_key: ItemKey, total_key: ItemKey, ) -> Option<()> { let mut split = content.splitn(2, '/'); let current = split.next()?.to_string(); tag.items .push(TagItem::new(current_key, ItemValue::Text(current))); if let Some(total) = split.next() { tag.items .push(TagItem::new(total_key, ItemValue::Text(total.to_string()))) } Some(()) } let mut tag = Tag::new(TagType::Ape); for item in std::mem::take(&mut self.items) { let item_key = ItemKey::from_key(TagType::Ape, item.key()); // The text pairs need some special treatment match (item_key, item.value()) { (ItemKey::TrackNumber | ItemKey::TrackTotal, ItemValue::Text(val)) if split_pair(val, &mut tag, ItemKey::TrackNumber, ItemKey::TrackTotal) .is_some() => { continue; // Item consumed }, (ItemKey::DiscNumber | ItemKey::DiscTotal, ItemValue::Text(val)) if split_pair(val, &mut tag, ItemKey::DiscNumber, ItemKey::DiscTotal) .is_some() => { continue; // Item consumed }, (ItemKey::MovementNumber | ItemKey::MovementTotal, ItemValue::Text(val)) if split_pair( val, &mut tag, ItemKey::MovementNumber, ItemKey::MovementTotal, ) .is_some() => { continue; // Item consumed }, (k, _) => { tag.items.push(TagItem::new(k, item.value)); }, } } (SplitTagRemainder(self), tag) } } impl MergeTag for SplitTagRemainder { type Merged = ApeTag; fn merge_tag(self, tag: Tag) -> Self::Merged { let Self(mut merged) = self; for item in tag.items { merged.insert_item(item); } for pic in tag.pictures { if let Some(key) = pic.pic_type.as_ape_key() { if let Ok(item) = ApeItem::new(key.to_string(), ItemValue::Binary(pic.as_ape_bytes())) { merged.insert(item) } } } merged } } impl From for Tag { fn from(input: ApeTag) -> Self { input.split_tag().1 } } impl From for ApeTag { fn from(input: Tag) -> Self { SplitTagRemainder::default().merge_tag(input) } } pub(crate) struct ApeTagRef<'a, I> where I: Iterator>, { pub(crate) read_only: bool, pub(crate) items: I, } impl<'a, I> ApeTagRef<'a, I> where I: Iterator>, { pub(crate) fn write_to(&mut self, file: &mut F, write_options: WriteOptions) -> Result<()> where F: FileLike, LoftyError: From<::Error>, { write::write_to(file, self, write_options) } pub(crate) fn dump_to( &mut self, writer: &mut W, write_options: WriteOptions, ) -> Result<()> { let temp = write::create_ape_tag(self, std::iter::empty(), write_options)?; writer.write_all(&temp)?; Ok(()) } } pub(crate) fn tagitems_into_ape(tag: &Tag) -> impl Iterator> { fn create_apeitemref_for_number_pair<'a>( number: Option<&str>, total: Option<&str>, key: &'a str, ) -> Option> { format_number_pair(number, total).map(|value| ApeItemRef { read_only: false, key, value: ItemValueRef::Text(Cow::Owned(value)), }) } tag.items() .filter(|item| !NUMBER_PAIR_KEYS.contains(item.key())) .filter_map(|i| { i.key().map_key(TagType::Ape, true).map(|key| ApeItemRef { read_only: false, key, value: (&i.item_value).into(), }) }) .chain(create_apeitemref_for_number_pair( tag.get_string(&ItemKey::TrackNumber), tag.get_string(&ItemKey::TrackTotal), "Track", )) .chain(create_apeitemref_for_number_pair( tag.get_string(&ItemKey::DiscNumber), tag.get_string(&ItemKey::DiscTotal), "Disk", )) } #[cfg(test)] mod tests { use crate::ape::{ApeItem, ApeTag}; use crate::config::{ParseOptions, WriteOptions}; use crate::id3::v2::util::pairs::DEFAULT_NUMBER_IN_PAIR; use crate::prelude::*; use crate::tag::{ItemValue, Tag, TagItem, TagType}; use crate::picture::{MimeType, Picture, PictureType}; use std::io::Cursor; #[test] fn parse_ape() { let mut expected_tag = ApeTag::default(); let title_item = ApeItem::new( String::from("TITLE"), ItemValue::Text(String::from("Foo title")), ) .unwrap(); let artist_item = ApeItem::new( String::from("ARTIST"), ItemValue::Text(String::from("Bar artist")), ) .unwrap(); let album_item = ApeItem::new( String::from("ALBUM"), ItemValue::Text(String::from("Baz album")), ) .unwrap(); let comment_item = ApeItem::new( String::from("COMMENT"), ItemValue::Text(String::from("Qux comment")), ) .unwrap(); let year_item = ApeItem::new(String::from("YEAR"), ItemValue::Text(String::from("1984"))).unwrap(); let track_number_item = ApeItem::new(String::from("TRACK"), ItemValue::Text(String::from("1"))).unwrap(); let genre_item = ApeItem::new( String::from("GENRE"), ItemValue::Text(String::from("Classical")), ) .unwrap(); expected_tag.insert(title_item); expected_tag.insert(artist_item); expected_tag.insert(album_item); expected_tag.insert(comment_item); expected_tag.insert(year_item); expected_tag.insert(track_number_item); expected_tag.insert(genre_item); let tag = crate::tag::utils::test_utils::read_path("tests/tags/assets/test.apev2"); let mut reader = Cursor::new(tag); let (Some(parsed_tag), _) = crate::ape::tag::read::read_ape_tag(&mut reader, false, ParseOptions::new()).unwrap() else { unreachable!(); }; assert_eq!(expected_tag.len(), parsed_tag.len()); for item in &expected_tag.items { assert!(parsed_tag.items.contains(item)); } } #[test] fn ape_re_read() { let tag_bytes = crate::tag::utils::test_utils::read_path("tests/tags/assets/test.apev2"); let mut reader = Cursor::new(tag_bytes); let (Some(parsed_tag), _) = crate::ape::tag::read::read_ape_tag(&mut reader, false, ParseOptions::new()).unwrap() else { unreachable!(); }; let mut writer = Vec::new(); parsed_tag .dump_to(&mut writer, WriteOptions::default()) .unwrap(); let mut temp_reader = Cursor::new(writer); let (Some(temp_parsed_tag), _) = crate::ape::tag::read::read_ape_tag(&mut temp_reader, false, ParseOptions::new()) .unwrap() else { unreachable!(); }; assert_eq!(parsed_tag, temp_parsed_tag); } #[test] fn ape_to_tag() { let tag_bytes = crate::tag::utils::test_utils::read_path("tests/tags/assets/test.apev2"); let mut reader = Cursor::new(tag_bytes); let (Some(ape), _) = crate::ape::tag::read::read_ape_tag(&mut reader, false, ParseOptions::new()).unwrap() else { unreachable!(); }; let tag: Tag = ape.into(); crate::tag::utils::test_utils::verify_tag(&tag, true, true); } #[test] fn tag_to_ape() { fn verify_key(tag: &ApeTag, key: &str, expected_val: &str) { assert_eq!( tag.get(key).map(ApeItem::value), Some(&ItemValue::Text(String::from(expected_val))) ); } let tag = crate::tag::utils::test_utils::create_tag(TagType::Ape); let ape_tag: ApeTag = tag.into(); verify_key(&ape_tag, "Title", "Foo title"); verify_key(&ape_tag, "Artist", "Bar artist"); verify_key(&ape_tag, "Album", "Baz album"); verify_key(&ape_tag, "Comment", "Qux comment"); verify_key(&ape_tag, "Track", "1"); verify_key(&ape_tag, "Genre", "Classical"); } #[test] fn set_track() { let mut ape = ApeTag::default(); let track = 1; ape.set_track(track); assert_eq!(ape.track().unwrap(), track); assert!(ape.track_total().is_none()); } #[test] fn set_track_total() { let mut ape = ApeTag::default(); let track_total = 2; ape.set_track_total(track_total); assert_eq!(ape.track().unwrap(), DEFAULT_NUMBER_IN_PAIR); assert_eq!(ape.track_total().unwrap(), track_total); } #[test] fn set_track_and_track_total() { let mut ape = ApeTag::default(); let track = 1; let track_total = 2; ape.set_track(track); ape.set_track_total(track_total); assert_eq!(ape.track().unwrap(), track); assert_eq!(ape.track_total().unwrap(), track_total); } #[test] fn set_track_total_and_track() { let mut ape = ApeTag::default(); let track_total = 2; let track = 1; ape.set_track_total(track_total); ape.set_track(track); assert_eq!(ape.track_total().unwrap(), track_total); assert_eq!(ape.track().unwrap(), track); } #[test] fn set_disk() { let mut ape = ApeTag::default(); let disk = 1; ape.set_disk(disk); assert_eq!(ape.disk().unwrap(), disk); assert!(ape.disk_total().is_none()); } #[test] fn set_disk_total() { let mut ape = ApeTag::default(); let disk_total = 2; ape.set_disk_total(disk_total); assert_eq!(ape.disk().unwrap(), DEFAULT_NUMBER_IN_PAIR); assert_eq!(ape.disk_total().unwrap(), disk_total); } #[test] fn set_disk_and_disk_total() { let mut ape = ApeTag::default(); let disk = 1; let disk_total = 2; ape.set_disk(disk); ape.set_disk_total(disk_total); assert_eq!(ape.disk().unwrap(), disk); assert_eq!(ape.disk_total().unwrap(), disk_total); } #[test] fn set_disk_total_and_disk() { let mut ape = ApeTag::default(); let disk_total = 2; let disk = 1; ape.set_disk_total(disk_total); ape.set_disk(disk); assert_eq!(ape.disk_total().unwrap(), disk_total); assert_eq!(ape.disk().unwrap(), disk); } #[test] fn track_number_tag_to_ape() { let track_number = 1; let mut tag = Tag::new(TagType::Ape); tag.push(TagItem::new( ItemKey::TrackNumber, ItemValue::Text(track_number.to_string()), )); let tag: ApeTag = tag.into(); assert_eq!(tag.track().unwrap(), track_number); assert!(tag.track_total().is_none()); } #[test] fn track_total_tag_to_ape() { let track_total = 2; let mut tag = Tag::new(TagType::Ape); tag.push(TagItem::new( ItemKey::TrackTotal, ItemValue::Text(track_total.to_string()), )); let tag: ApeTag = tag.into(); assert_eq!(tag.track().unwrap(), DEFAULT_NUMBER_IN_PAIR); assert_eq!(tag.track_total().unwrap(), track_total); } #[test] fn track_number_and_track_total_tag_to_ape() { let track_number = 1; let track_total = 2; let mut tag = Tag::new(TagType::Ape); tag.push(TagItem::new( ItemKey::TrackNumber, ItemValue::Text(track_number.to_string()), )); tag.push(TagItem::new( ItemKey::TrackTotal, ItemValue::Text(track_total.to_string()), )); let tag: ApeTag = tag.into(); assert_eq!(tag.track().unwrap(), track_number); assert_eq!(tag.track_total().unwrap(), track_total); } #[test] fn disk_number_tag_to_ape() { let disk_number = 1; let mut tag = Tag::new(TagType::Ape); tag.push(TagItem::new( ItemKey::DiscNumber, ItemValue::Text(disk_number.to_string()), )); let tag: ApeTag = tag.into(); assert_eq!(tag.disk().unwrap(), disk_number); assert!(tag.disk_total().is_none()); } #[test] fn disk_total_tag_to_ape() { let disk_total = 2; let mut tag = Tag::new(TagType::Ape); tag.push(TagItem::new( ItemKey::DiscTotal, ItemValue::Text(disk_total.to_string()), )); let tag: ApeTag = tag.into(); assert_eq!(tag.disk().unwrap(), DEFAULT_NUMBER_IN_PAIR); assert_eq!(tag.disk_total().unwrap(), disk_total); } #[test] fn disk_number_and_disk_total_tag_to_ape() { let disk_number = 1; let disk_total = 2; let mut tag = Tag::new(TagType::Ape); tag.push(TagItem::new( ItemKey::DiscNumber, ItemValue::Text(disk_number.to_string()), )); tag.push(TagItem::new( ItemKey::DiscTotal, ItemValue::Text(disk_total.to_string()), )); let tag: ApeTag = tag.into(); assert_eq!(tag.disk().unwrap(), disk_number); assert_eq!(tag.disk_total().unwrap(), disk_total); } #[test] fn skip_reading_cover_art() { let p = Picture::new_unchecked( PictureType::CoverFront, Some(MimeType::Jpeg), None, std::iter::repeat(0).take(50).collect::>(), ); let mut tag = Tag::new(TagType::Ape); tag.push_picture(p); tag.set_artist(String::from("Foo artist")); let mut writer = Vec::new(); tag.dump_to(&mut writer, WriteOptions::new()).unwrap(); let mut reader = Cursor::new(writer); let (Some(ape), _) = crate::ape::tag::read::read_ape_tag( &mut reader, false, ParseOptions::new().read_cover_art(false), ) .unwrap() else { unreachable!() }; assert_eq!(ape.len(), 1); } } lofty-0.21.1/src/ape/tag/read.rs000064400000000000000000000056411046102023000144400ustar 00000000000000use super::item::ApeItem; use super::ApeTag; use crate::ape::constants::{APE_PREAMBLE, INVALID_KEYS}; use crate::ape::header::{self, ApeHeader}; use crate::ape::APE_PICTURE_TYPES; use crate::config::ParseOptions; use crate::error::Result; use crate::macros::{decode_err, err, try_vec}; use crate::tag::ItemValue; use crate::util::text::utf8_decode; use std::io::{Read, Seek, SeekFrom}; use byteorder::{LittleEndian, ReadBytesExt}; pub(crate) fn read_ape_tag_with_header( data: &mut R, header: ApeHeader, parse_options: ParseOptions, ) -> Result where R: Read + Seek, { let mut tag = ApeTag::default(); let mut remaining_size = header.size; for _ in 0..header.item_count { if remaining_size < 11 { break; } let value_size = data.read_u32::()?; if value_size > remaining_size { err!(SizeMismatch); } remaining_size -= 4; let flags = data.read_u32::()?; let mut key = Vec::new(); let mut key_char = data.read_u8()?; while key_char != 0 { key.push(key_char); key_char = data.read_u8()?; } let key = utf8_decode(key) .map_err(|_| decode_err!(Ape, "APE tag item contains a non UTF-8 key"))?; if INVALID_KEYS.contains(&&*key.to_uppercase()) { decode_err!(@BAIL Ape, "APE tag item contains an illegal key"); } if APE_PICTURE_TYPES.contains(&&*key) && !parse_options.read_cover_art { data.seek(SeekFrom::Current(i64::from(value_size)))?; continue; } let read_only = (flags & 1) == 1; let item_type = (flags >> 1) & 3; if value_size == 0 || key.len() < 2 || key.len() > 255 { log::warn!("APE: Encountered invalid item key '{}'", key); data.seek(SeekFrom::Current(i64::from(value_size)))?; continue; } let mut value = try_vec![0; value_size as usize]; data.read_exact(&mut value)?; let parsed_value = match item_type { 0 => ItemValue::Text(utf8_decode(value).map_err(|_| { decode_err!(Ape, "Failed to convert text item into a UTF-8 string") })?), 1 => ItemValue::Binary(value), 2 => ItemValue::Locator(utf8_decode(value).map_err(|_| { decode_err!(Ape, "Failed to convert locator item into a UTF-8 string") })?), _ => decode_err!(@BAIL Ape, "APE tag item contains an invalid item type"), }; let mut item = ApeItem::new(key, parsed_value)?; if read_only { item.read_only = true; } tag.insert(item); } // Skip over footer data.seek(SeekFrom::Current(32))?; Ok(tag) } pub(crate) fn read_ape_tag( reader: &mut R, footer: bool, parse_options: ParseOptions, ) -> Result<(Option, Option)> { let mut ape_preamble = [0; 8]; reader.read_exact(&mut ape_preamble)?; let mut ape_tag = None; if &ape_preamble == APE_PREAMBLE { let ape_header = header::read_ape_header(reader, footer)?; if parse_options.read_tags { ape_tag = Some(read_ape_tag_with_header(reader, ape_header, parse_options)?); } return Ok((ape_tag, Some(ape_header))); } Ok((None, None)) } lofty-0.21.1/src/ape/tag/write.rs000064400000000000000000000156071046102023000146620ustar 00000000000000use super::item::ApeItemRef; use super::ApeTagRef; use crate::ape::constants::APE_PREAMBLE; use crate::ape::tag::read; use crate::config::{ParseOptions, WriteOptions}; use crate::error::{LoftyError, Result}; use crate::id3::{find_id3v1, find_id3v2, find_lyrics3v2, FindId3v2Config}; use crate::macros::{decode_err, err}; use crate::probe::Probe; use crate::tag::item::ItemValueRef; use crate::util::io::{FileLike, Truncate}; use std::io::{Cursor, Seek, SeekFrom, Write}; use byteorder::{LittleEndian, WriteBytesExt}; #[allow(clippy::shadow_unrelated)] pub(crate) fn write_to<'a, F, I>( file: &mut F, tag_ref: &mut ApeTagRef<'a, I>, write_options: WriteOptions, ) -> Result<()> where I: Iterator>, F: FileLike, LoftyError: From<::Error>, { let probe = Probe::new(file).guess_file_type()?; match probe.file_type() { Some(ft) if super::ApeTag::SUPPORTED_FORMATS.contains(&ft) => {}, _ => err!(UnsupportedTag), } let file = probe.into_inner(); // We don't actually need the ID3v2 tag, but reading it will seek to the end of it if it exists find_id3v2(file, FindId3v2Config::NO_READ_TAG)?; let mut ape_preamble = [0; 8]; file.read_exact(&mut ape_preamble)?; // We have to check the APE tag for any read only items first let mut read_only = None; // An APE tag in the beginning of a file is against the spec // If one is found, it'll be removed and rewritten at the bottom, where it should be let mut header_ape_tag = (false, (0, 0)); let start = file.stream_position()?; // TODO: Forcing the use of ParseOptions::default() match read::read_ape_tag(file, false, ParseOptions::new())? { (Some(mut existing_tag), Some(header)) => { if write_options.respect_read_only { // Only keep metadata around that's marked read only existing_tag.items.retain(|i| i.read_only); if !existing_tag.items.is_empty() { read_only = Some(existing_tag) } } header_ape_tag = (true, (start, start + u64::from(header.size))) }, _ => { file.seek(SeekFrom::Current(-8))?; }, } // Skip over ID3v1 and Lyrics3v2 tags find_id3v1(file, false)?; find_lyrics3v2(file)?; // In case there's no ape tag already, this is the spot it belongs let ape_position = file.stream_position()?; // Now search for an APE tag at the end file.seek(SeekFrom::Current(-32))?; let mut ape_tag_location = None; // Also check this tag for any read only items let start = file.stream_position()? as usize + 32; // TODO: Forcing the use of ParseOptions::default() if let (Some(mut existing_tag), Some(header)) = read::read_ape_tag(file, true, ParseOptions::new())? { if write_options.respect_read_only { existing_tag.items.retain(|i| i.read_only); if !existing_tag.items.is_empty() { read_only = match read_only { Some(mut read_only) => { read_only.items.extend(existing_tag.items); Some(read_only) }, None => Some(existing_tag), } } } // Since the "start" was really at the end of the tag, this sanity check seems necessary let size = header.size; if let Some(start) = start.checked_sub(size as usize) { ape_tag_location = Some(start..start + size as usize); } else { decode_err!(@BAIL Ape, "File has a tag with an invalid size"); } } // Preserve any metadata marked as read only let tag; if let Some(read_only) = read_only { tag = create_ape_tag( tag_ref, read_only.items.iter().map(Into::into), write_options, )?; } else { tag = create_ape_tag(tag_ref, std::iter::empty(), write_options)?; }; file.rewind()?; let mut file_bytes = Vec::new(); file.read_to_end(&mut file_bytes)?; // Write the tag in the appropriate place if let Some(range) = ape_tag_location { file_bytes.splice(range, tag); } else { file_bytes.splice(ape_position as usize..ape_position as usize, tag); } // Now, if there was a tag at the beginning, remove it if header_ape_tag.0 { file_bytes.drain(header_ape_tag.1 .0 as usize..header_ape_tag.1 .1 as usize); } file.rewind()?; file.truncate(0)?; file.write_all(&file_bytes)?; Ok(()) } pub(super) fn create_ape_tag<'a, 'b, I, R>( tag: &mut ApeTagRef<'a, I>, mut read_only: R, write_options: WriteOptions, ) -> Result> where I: Iterator>, R: Iterator>, { let items = &mut tag.items; let mut peek = items.peekable(); // Unnecessary to write anything if there's no metadata if peek.peek().is_none() { return Ok(Vec::::new()); } if read_only.next().is_some() && write_options.respect_read_only { // TODO: Implement retaining read only items log::warn!("Retaining read only items is not supported yet"); drop(read_only); } let mut tag_write = Cursor::new(Vec::::new()); let mut item_count = 0_u32; for item in peek { let (mut flags, value) = match item.value { ItemValueRef::Binary(value) => { tag_write.write_u32::(value.len() as u32)?; (1_u32 << 1, value) }, ItemValueRef::Text(ref value) => { tag_write.write_u32::(value.len() as u32)?; (0_u32, value.as_bytes()) }, ItemValueRef::Locator(value) => { tag_write.write_u32::(value.len() as u32)?; (2_u32 << 1, value.as_bytes()) }, }; if item.read_only { flags |= 1_u32 } tag_write.write_u32::(flags)?; tag_write.write_all(item.key.as_bytes())?; tag_write.write_u8(0)?; tag_write.write_all(value)?; item_count += 1; } let size = tag_write.get_ref().len(); if size as u64 + 32 > u64::from(u32::MAX) { err!(TooMuchData); } let mut footer = [0_u8; 32]; let mut footer = Cursor::new(&mut footer[..]); footer.write_all(APE_PREAMBLE)?; // This is the APE tag version // Even if we read a v1 tag, we end up adding a header anyway footer.write_u32::(2000)?; // The total size includes the 32 bytes of the footer footer.write_u32::((size + 32) as u32)?; footer.write_u32::(item_count)?; // Bit 29 unset: this is the footer // Bit 30 set: tag contains a footer // Bit 31 set: tag contains a header let mut footer_flags = (1_u32 << 30) | (1_u32 << 31); if tag.read_only { // Bit 0 set: tag is read only footer_flags |= 1 } footer.write_u32::(footer_flags)?; // The header/footer must end in 8 bytes of zeros footer.write_u64::(0)?; tag_write.write_all(footer.get_ref())?; let mut tag_write = tag_write.into_inner(); // The header is exactly the same as the footer, except for the flags // Just reuse the footer and overwrite the flags footer.seek(SeekFrom::Current(-12))?; // Bit 29 set: this is the header // Bit 30 set: tag contains a footer // Bit 31 set: tag contains a header let mut header_flags = (1_u32 << 29) | (1_u32 << 30) | (1_u32 << 31); if tag.read_only { // Bit 0 set: tag is read only header_flags |= 1 } footer.write_u32::(header_flags)?; let header = footer.into_inner(); tag_write.splice(0..0, header.to_vec()); Ok(tag_write) } lofty-0.21.1/src/config/global_options.rs000064400000000000000000000107411046102023000164620ustar 00000000000000use std::cell::UnsafeCell; thread_local! { static GLOBAL_OPTIONS: UnsafeCell = UnsafeCell::new(GlobalOptions::default()); } pub(crate) unsafe fn global_options() -> &'static GlobalOptions { GLOBAL_OPTIONS.with(|global_options| unsafe { &*global_options.get() }) } /// Options that control all interactions with Lofty for the current thread /// /// # Examples /// /// ```rust /// use lofty::config::{apply_global_options, GlobalOptions}; /// /// // I have a custom resolver that I need checked /// let global_options = GlobalOptions::new().use_custom_resolvers(true); /// apply_global_options(global_options); /// ``` #[derive(Copy, Clone, Debug, Ord, PartialOrd, Eq, PartialEq)] #[non_exhaustive] pub struct GlobalOptions { pub(crate) use_custom_resolvers: bool, pub(crate) allocation_limit: usize, pub(crate) preserve_format_specific_items: bool, } impl GlobalOptions { /// Default allocation limit for any single tag item pub const DEFAULT_ALLOCATION_LIMIT: usize = 16 * 1024 * 1024; /// Creates a new `GlobalOptions`, alias for `Default` implementation /// /// See also: [`GlobalOptions::default`] /// /// # Examples /// /// ```rust /// use lofty::config::GlobalOptions; /// /// let global_options = GlobalOptions::new(); /// ``` #[must_use] pub const fn new() -> Self { Self { use_custom_resolvers: true, allocation_limit: Self::DEFAULT_ALLOCATION_LIMIT, preserve_format_specific_items: true, } } /// Whether or not to check registered custom resolvers /// /// See also: [`crate::resolve`] /// /// # Examples /// /// ```rust /// use lofty::config::{apply_global_options, GlobalOptions}; /// /// // By default, `use_custom_resolvers` is enabled. Here, we don't want to use them. /// let global_options = GlobalOptions::new().use_custom_resolvers(false); /// apply_global_options(global_options); /// ``` pub fn use_custom_resolvers(&mut self, use_custom_resolvers: bool) -> Self { self.use_custom_resolvers = use_custom_resolvers; *self } /// The maximum number of bytes to allocate for any single tag item /// /// This is a safety measure to prevent allocating too much memory for a single tag item. If a tag item /// exceeds this limit, the allocator will return [`ErrorKind::TooMuchData`](crate::error::ErrorKind::TooMuchData). /// /// # Examples /// /// ```rust /// use lofty::config::{apply_global_options, GlobalOptions}; /// /// // I have files with gigantic images, I'll double the allocation limit! /// let global_options = GlobalOptions::new().allocation_limit(32 * 1024 * 1024); /// apply_global_options(global_options); /// ``` pub fn allocation_limit(&mut self, allocation_limit: usize) -> Self { self.allocation_limit = allocation_limit; *self } /// Whether or not to preserve format-specific items /// /// When converting a tag from its concrete format (ex. [`Id3v2Tag`](crate::id3::v2::Id3v2Tag)) to /// a [`Tag`], this options controls whether to preserve any special items that /// are unique to the concrete tag. /// /// This will store an extra immutable tag alongside the generic [`Tag`], which will be merged /// back into the concrete tag when converting back. /// /// # Examples /// /// ```rust /// use lofty::config::{apply_global_options, GlobalOptions}; /// /// // I'm just reading tags, I don't need to preserve format-specific items /// let global_options = GlobalOptions::new().preserve_format_specific_items(false); /// apply_global_options(global_options); /// ``` /// /// [`Tag`]: crate::tag::Tag pub fn preserve_format_specific_items(&mut self, preserve_format_specific_items: bool) -> Self { self.preserve_format_specific_items = preserve_format_specific_items; *self } } impl Default for GlobalOptions { /// The default implementation for `GlobalOptions` /// /// The defaults are as follows: /// /// ```rust,ignore /// GlobalOptions { /// use_custom_resolvers: true, /// allocation_limit: Self::DEFAULT_ALLOCATION_LIMIT, /// preserve_format_specific_items: true, /// } /// ``` fn default() -> Self { Self::new() } } /// Applies the given `GlobalOptions` to the current thread /// /// # Examples /// /// ```rust /// use lofty::config::{apply_global_options, GlobalOptions}; /// /// // I have a custom resolver that I need checked /// let global_options = GlobalOptions::new().use_custom_resolvers(true); /// apply_global_options(global_options); /// ``` pub fn apply_global_options(options: GlobalOptions) { GLOBAL_OPTIONS.with(|global_options| unsafe { *global_options.get() = options; }); } lofty-0.21.1/src/config/mod.rs000064400000000000000000000004671046102023000142320ustar 00000000000000//! Various configuration options to control Lofty mod global_options; mod parse_options; mod write_options; pub use global_options::{apply_global_options, GlobalOptions}; pub use parse_options::{ParseOptions, ParsingMode}; pub use write_options::WriteOptions; pub(crate) use global_options::global_options; lofty-0.21.1/src/config/parse_options.rs000064400000000000000000000151361046102023000163370ustar 00000000000000/// Options to control how Lofty parses a file #[derive(Copy, Clone, Debug, Eq, PartialEq)] #[non_exhaustive] pub struct ParseOptions { pub(crate) read_properties: bool, pub(crate) read_tags: bool, pub(crate) parsing_mode: ParsingMode, pub(crate) max_junk_bytes: usize, pub(crate) read_cover_art: bool, pub(crate) implicit_conversions: bool, } impl Default for ParseOptions { /// The default implementation for `ParseOptions` /// /// The defaults are as follows: /// /// ```rust,ignore /// ParseOptions { /// read_properties: true, /// read_tags: true, /// parsing_mode: ParsingMode::BestAttempt, /// max_junk_bytes: 1024, /// read_cover_art: true, /// implicit_conversions: true, /// } /// ``` fn default() -> Self { Self::new() } } impl ParseOptions { /// Default parsing mode pub const DEFAULT_PARSING_MODE: ParsingMode = ParsingMode::BestAttempt; /// Default number of junk bytes to read pub const DEFAULT_MAX_JUNK_BYTES: usize = 1024; /// Creates a new `ParseOptions`, alias for `Default` implementation /// /// See also: [`ParseOptions::default`] /// /// # Examples /// /// ```rust /// use lofty::config::ParseOptions; /// /// let parsing_options = ParseOptions::new(); /// ``` #[must_use] pub const fn new() -> Self { Self { read_properties: true, read_tags: true, parsing_mode: Self::DEFAULT_PARSING_MODE, max_junk_bytes: Self::DEFAULT_MAX_JUNK_BYTES, read_cover_art: true, implicit_conversions: true, } } /// Whether or not to read the audio properties /// /// # Examples /// /// ```rust /// use lofty::config::ParseOptions; /// /// // By default, `read_properties` is enabled. Here, we don't want to read them. /// let parsing_options = ParseOptions::new().read_properties(false); /// ``` pub fn read_properties(&mut self, read_properties: bool) -> Self { self.read_properties = read_properties; *self } /// Whether or not to read the tags /// /// # Examples /// /// ```rust /// use lofty::config::ParseOptions; /// /// // By default, `read_tags` is enabled. Here, we don't want to read them. /// let parsing_options = ParseOptions::new().read_tags(false); /// ``` pub fn read_tags(&mut self, read_tags: bool) -> Self { self.read_tags = read_tags; *self } /// The parsing mode to use, see [`ParsingMode`] for details /// /// # Examples /// /// ```rust /// use lofty::config::{ParseOptions, ParsingMode}; /// /// // By default, `parsing_mode` is ParsingMode::BestAttempt. Here, we need absolute correctness. /// let parsing_options = ParseOptions::new().parsing_mode(ParsingMode::Strict); /// ``` pub fn parsing_mode(&mut self, parsing_mode: ParsingMode) -> Self { self.parsing_mode = parsing_mode; *self } /// The maximum number of allowed junk bytes to search /// /// Some information may be surrounded by junk bytes, such as tag padding remnants. This sets the maximum /// number of junk/unrecognized bytes Lofty will search for required information before giving up. /// /// # Examples /// /// ```rust /// use lofty::config::ParseOptions; /// /// // I have files full of junk, I'll double the search window! /// let parsing_options = ParseOptions::new().max_junk_bytes(2048); /// ``` pub fn max_junk_bytes(&mut self, max_junk_bytes: usize) -> Self { self.max_junk_bytes = max_junk_bytes; *self } /// Whether or not to read cover art /// /// # Examples /// /// ```rust /// use lofty::config::ParseOptions; /// /// // Reading cover art is expensive, and I do not need it! /// let parsing_options = ParseOptions::new().read_cover_art(false); /// ``` pub fn read_cover_art(&mut self, read_cover_art: bool) -> Self { self.read_cover_art = read_cover_art; *self } /// Whether or not to perform implicit conversions /// /// Implicit conversions are conversions that are not explicitly defined by the spec, but are commonly used. /// /// ⚠ **Warning** ⚠ /// /// Turning this off may cause some [`Accessor`](crate::tag::Accessor) methods to return nothing. /// Lofty makes some assumptions about the data, if they are broken, the caller will have more /// responsibility. /// /// Examples include: /// /// * Converting the outdated MP4 `gnre` atom to a `©gen` atom /// * Combining the ID3v2.3 `TYER`, `TDAT`, and `TIME` frames into a single `TDRC` frame /// /// Examples of what this does *not* include: /// /// * Converting a Vorbis `COVERART` field to `METADATA_BLOCK_PICTURE` /// * This is a non-standard field, with a well-defined conversion. Lofty will not support /// the non-standard `COVERART` for [`Picture`](crate::picture::Picture)s. pub fn implicit_conversions(&mut self, implicit_conversions: bool) -> Self { self.implicit_conversions = implicit_conversions; *self } } /// The parsing strictness mode /// /// This can be set with [`Probe::options`](crate::probe::Probe). /// /// # Examples /// /// ```rust,no_run /// use lofty::config::{ParseOptions, ParsingMode}; /// use lofty::probe::Probe; /// /// # fn main() -> lofty::error::Result<()> { /// // We only want to read spec-compliant inputs /// let parsing_options = ParseOptions::new().parsing_mode(ParsingMode::Strict); /// let tagged_file = Probe::open("foo.mp3")?.options(parsing_options).read()?; /// # Ok(()) } /// ``` #[derive(Copy, Clone, Debug, Ord, PartialOrd, Eq, PartialEq, Default)] #[non_exhaustive] pub enum ParsingMode { /// Will eagerly error on invalid input /// /// This mode will eagerly error on any non spec-compliant input. /// /// ## Examples of behavior /// /// * Unable to decode text - The parser will error and the entire input is discarded /// * Unable to determine the sample rate - The parser will error and the entire input is discarded Strict, /// Default mode, less eager to error on recoverably malformed input /// /// This mode will attempt to fill in any holes where possible in otherwise valid, spec-compliant input. /// /// NOTE: A readable input does *not* necessarily make it writeable. /// /// ## Examples of behavior /// /// * Unable to decode text - If valid otherwise, the field will be replaced by an empty string and the parser moves on /// * Unable to determine the sample rate - The sample rate will be 0 #[default] BestAttempt, /// Least eager to error, may produce invalid/partial output /// /// This mode will discard any invalid fields, and ignore the majority of non-fatal errors. /// /// If the input is malformed, the resulting tags may be incomplete, and the properties zeroed. /// /// ## Examples of behavior /// /// * Unable to decode text - The entire item is discarded and the parser moves on /// * Unable to determine the sample rate - The sample rate will be 0 Relaxed, } lofty-0.21.1/src/config/write_options.rs000064400000000000000000000131271046102023000163550ustar 00000000000000/// Options to control how Lofty writes to a file /// /// This acts as a dumping ground for all sorts of format-specific settings. As such, this is best /// used as an application global config that gets set once. #[derive(Copy, Clone, Debug, Eq, PartialEq)] #[non_exhaustive] pub struct WriteOptions { pub(crate) preferred_padding: Option, pub(crate) remove_others: bool, pub(crate) respect_read_only: bool, pub(crate) uppercase_id3v2_chunk: bool, pub(crate) use_id3v23: bool, } impl WriteOptions { /// Default preferred padding size in bytes pub const DEFAULT_PREFERRED_PADDING: u32 = 1024; /// Creates a new `WriteOptions`, alias for `Default` implementation /// /// See also: [`WriteOptions::default`] /// /// # Examples /// /// ```rust /// use lofty::config::WriteOptions; /// /// let write_options = WriteOptions::new(); /// ``` pub const fn new() -> Self { Self { preferred_padding: Some(Self::DEFAULT_PREFERRED_PADDING), remove_others: false, respect_read_only: true, uppercase_id3v2_chunk: true, use_id3v23: false, } } /// Set the preferred padding size in bytes /// /// If the tag format being written supports padding, this will be the size of the padding /// in bytes. /// /// NOTES: /// /// * Not all tag formats support padding /// * The actual padding size may be different from this value, depending on tag size limitations /// /// # Examples /// /// ```rust /// use lofty::config::WriteOptions; /// /// // I really don't want my files rewritten, so I'll double the padding size! /// let options = WriteOptions::new().preferred_padding(2048); /// /// // ...Or I don't want padding under any circumstances! /// let options = WriteOptions::new().preferred_padding(0); /// ``` pub fn preferred_padding(mut self, preferred_padding: u32) -> Self { match preferred_padding { 0 => self.preferred_padding = None, _ => self.preferred_padding = Some(preferred_padding), } self } /// Whether to remove all other tags when writing /// /// If set to `true`, only the tag being written will be kept in the file. /// /// # Examples /// /// ```rust,no_run /// use lofty::config::WriteOptions; /// use lofty::prelude::*; /// use lofty::tag::{Tag, TagType}; /// /// # fn main() -> lofty::error::Result<()> { /// let mut id3v2_tag = Tag::new(TagType::Id3v2); /// /// // ... /// /// // I only want to keep the ID3v2 tag around! /// let options = WriteOptions::new().remove_others(true); /// id3v2_tag.save_to_path("test.mp3", options)?; /// # Ok(()) } /// ``` pub fn remove_others(mut self, remove_others: bool) -> Self { self.remove_others = remove_others; self } /// Whether to respect read-only tag items /// /// Some tag formats allow for items to be marked as read-only. If set to `true`, these items /// will take priority over newly created tag items. /// /// NOTE: In the case of APE tags, one can mark the entire tag as read-only. This will append /// the existing tag items to the new tag. /// /// # Examples /// /// ```rust,no_run /// use lofty::config::WriteOptions; /// use lofty::prelude::*; /// use lofty::tag::{Tag, TagType}; /// /// # fn main() -> lofty::error::Result<()> { /// let mut id3v2_tag = Tag::new(TagType::Id3v2); /// /// // ... /// /// // I don't care about read-only items, I want to write my new items! /// let options = WriteOptions::new().respect_read_only(false); /// id3v2_tag.save_to_path("test.mp3", options)?; /// # Ok(()) } /// ``` pub fn respect_read_only(mut self, respect_read_only: bool) -> Self { self.respect_read_only = respect_read_only; self } /// Whether to uppercase the ID3v2 chunk name /// /// When dealing with RIFF/AIFF files, some software may expect the ID3v2 chunk name to be /// lowercase. /// /// NOTE: The vast majority of software will be able to read both upper and lowercase /// chunk names. /// /// # Examples /// /// ```rust,no_run /// use lofty::config::WriteOptions; /// use lofty::prelude::*; /// use lofty::tag::{Tag, TagType}; /// /// # fn main() -> lofty::error::Result<()> { /// let mut id3v2_tag = Tag::new(TagType::Id3v2); /// /// // ... /// /// // I want to keep the ID3v2 chunk name lowercase /// let options = WriteOptions::new().uppercase_id3v2_chunk(false); /// id3v2_tag.save_to_path("test.mp3", options)?; /// # Ok(()) } pub fn uppercase_id3v2_chunk(mut self, uppercase_id3v2_chunk: bool) -> Self { self.uppercase_id3v2_chunk = uppercase_id3v2_chunk; self } /// Whether or not to use ID3v2.3 when saving [`TagType::Id3v2`](crate::tag::TagType::Id3v2) /// or [`Id3v2Tag`](crate::id3::v2::Id3v2Tag) /// /// By default, Lofty will save ID3v2.4 tags. This option allows you to save ID3v2.3 tags instead. /// /// # Examples /// /// ```rust,no_run /// use lofty::config::WriteOptions; /// use lofty::prelude::*; /// use lofty::tag::{Tag, TagType}; /// /// # fn main() -> lofty::error::Result<()> { /// let mut id3v2_tag = Tag::new(TagType::Id3v2); /// /// // ... /// /// // I need to save ID3v2.3 tags to support older software /// let options = WriteOptions::new().use_id3v23(true); /// id3v2_tag.save_to_path("test.mp3", options)?; /// # Ok(()) } /// ``` pub fn use_id3v23(&mut self, use_id3v23: bool) -> Self { self.use_id3v23 = use_id3v23; *self } } impl Default for WriteOptions { /// The default implementation for `WriteOptions` /// /// The defaults are as follows: /// /// ```rust,ignore /// WriteOptions { /// preferred_padding: 1024, /// remove_others: false, /// respect_read_only: true, /// uppercase_id3v2_chunk: true, /// use_id3v23: false, /// } /// ``` fn default() -> Self { Self::new() } } lofty-0.21.1/src/error.rs000064400000000000000000000411651046102023000133370ustar 00000000000000//! Contains the errors that can arise within Lofty //! //! The primary error is [`LoftyError`]. The type of error is determined by [`ErrorKind`], //! which can be extended at any time. use crate::file::FileType; use crate::id3::v2::FrameId; use crate::tag::ItemKey; use std::collections::TryReserveError; use std::fmt::{Debug, Display, Formatter}; use ogg_pager::PageError; /// Alias for `Result` pub type Result = std::result::Result; /// The types of errors that can occur #[derive(Debug)] #[non_exhaustive] pub enum ErrorKind { // File format related errors /// Unable to guess the format UnknownFormat, // File data related errors /// Attempting to read/write an abnormally large amount of data TooMuchData, /// Expected the data to be a different size than provided /// /// This occurs when the size of an item is written as one value, but that size is either too /// big or small to be valid within the bounds of that item. // TODO: Should probably have context SizeMismatch, /// Errors that occur while decoding a file FileDecoding(FileDecodingError), /// Errors that occur while encoding a file FileEncoding(FileEncodingError), // Picture related errors /// Provided an invalid picture NotAPicture, /// Attempted to write a picture that the format does not support UnsupportedPicture, // Tag related errors /// Arises when writing a tag to a file type that doesn't support it UnsupportedTag, /// Arises when a tag is expected (Ex. found an "ID3 " chunk in a WAV file), but isn't found FakeTag, /// Errors that arise while decoding text TextDecode(&'static str), /// Arises when decoding OR encoding a problematic [`Timestamp`](crate::tag::items::Timestamp) BadTimestamp(&'static str), /// Errors that arise while reading/writing ID3v2 tags Id3v2(Id3v2Error), /// Arises when an atom contains invalid data BadAtom(&'static str), /// Arises when attempting to use [`Atom::merge`](crate::mp4::Atom::merge) with mismatching identifiers AtomMismatch, // Conversions for external errors /// Errors that arise while parsing OGG pages OggPage(ogg_pager::PageError), /// Unable to convert bytes to a String StringFromUtf8(std::string::FromUtf8Error), /// Unable to convert bytes to a str StrFromUtf8(std::str::Utf8Error), /// Represents all cases of [`std::io::Error`]. Io(std::io::Error), /// Represents all cases of [`std::fmt::Error`]. Fmt(std::fmt::Error), /// Failure to allocate enough memory Alloc(TryReserveError), /// This should **never** be encountered Infallible(std::convert::Infallible), } /// The types of errors that can occur while interacting with ID3v2 tags #[derive(Debug)] #[non_exhaustive] pub enum Id3v2ErrorKind { // Header /// Arises when an invalid ID3v2 version is found BadId3v2Version(u8, u8), /// Arises when a compressed ID3v2.2 tag is encountered /// /// At the time the ID3v2.2 specification was written, a compression scheme wasn't decided. /// As such, it is recommended to ignore the tag entirely. V2Compression, /// Arises when an extended header has an invalid size (must be >= 6 bytes and less than the total tag size) BadExtendedHeaderSize, // Frame /// Arises when a frame ID contains invalid characters (must be within `'A'..'Z'` or `'0'..'9'`) /// or if the ID is too short/long. BadFrameId(Vec), /// Arises when no frame ID is available in the ID3v2 specification for an item key /// and the associated value type. UnsupportedFrameId(ItemKey), /// Arises when a frame doesn't have enough data BadFrameLength, /// Arises when a frame with no content is parsed with [ParsingMode::Strict](crate::config::ParsingMode::Strict) EmptyFrame(FrameId<'static>), /// Arises when reading/writing a compressed or encrypted frame with no data length indicator MissingDataLengthIndicator, /// Arises when a frame or tag has its unsynchronisation flag set, but the content is not actually synchsafe /// /// See [`FrameFlags::unsynchronisation`](crate::id3::v2::FrameFlags::unsynchronisation) for an explanation. InvalidUnsynchronisation, /// Arises when a text encoding other than Latin-1 or UTF-16 appear in an ID3v2.2 tag V2InvalidTextEncoding, /// Arises when an invalid picture format is parsed. Only applicable to [`ID3v2Version::V2`](crate::id3::v2::Id3v2Version::V2) BadPictureFormat(String), /// Arises when invalid data is encountered while reading an ID3v2 synchronized text frame BadSyncText, /// Arises when decoding a [`UniqueFileIdentifierFrame`](crate::id3::v2::UniqueFileIdentifierFrame) with no owner MissingUfidOwner, /// Arises when decoding a [`RelativeVolumeAdjustmentFrame`](crate::id3::v2::RelativeVolumeAdjustmentFrame) with an invalid channel type BadRva2ChannelType, /// Arises when decoding a [`TimestampFormat`](crate::id3::v2::TimestampFormat) with an invalid type BadTimestampFormat, // Compression #[cfg(feature = "id3v2_compression_support")] /// Arises when a compressed frame is unable to be decompressed Decompression(flate2::DecompressError), #[cfg(not(feature = "id3v2_compression_support"))] /// Arises when a compressed frame is encountered, but support is disabled CompressedFrameEncountered, // Writing /// Arises when attempting to write an encrypted frame with an invalid encryption method symbol (must be <= 0x80) InvalidEncryptionMethodSymbol(u8), /// Arises when attempting to write an invalid Frame (Bad `FrameId`/`FrameValue` pairing) BadFrame(String, &'static str), /// Arises when attempting to write a [`CommentFrame`](crate::id3::v2::CommentFrame) or [`UnsynchronizedTextFrame`](crate::id3::v2::UnsynchronizedTextFrame) with an invalid language InvalidLanguage([u8; 3]), } impl Display for Id3v2ErrorKind { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { // Header Self::BadId3v2Version(major, minor) => write!( f, "Found an invalid version (v{major}.{minor}), expected any major revision in: (2, \ 3, 4)" ), Self::V2Compression => write!(f, "Encountered a compressed ID3v2.2 tag"), Self::BadExtendedHeaderSize => { write!(f, "Found an extended header with an invalid size") }, // Frame Self::BadFrameId(frame_id) => write!(f, "Failed to parse a frame ID: 0x{frame_id:x?}"), Self::UnsupportedFrameId(item_key) => { write!(f, "Unsupported frame ID for item key {item_key:?}") }, Self::BadFrameLength => write!( f, "Frame isn't long enough to extract the necessary information" ), Self::EmptyFrame(id) => write!(f, "Frame `{id}` is empty"), Self::MissingDataLengthIndicator => write!( f, "Encountered an encrypted frame without a data length indicator" ), Self::InvalidUnsynchronisation => write!(f, "Encountered an invalid unsynchronisation"), Self::V2InvalidTextEncoding => { write!(f, "ID3v2.2 only supports Latin-1 and UTF-16 encodings") }, Self::BadPictureFormat(format) => { write!(f, "Picture: Found unexpected format \"{format}\"") }, Self::BadSyncText => write!(f, "Encountered invalid data in SYLT frame"), Self::MissingUfidOwner => write!(f, "Missing owner in UFID frame"), Self::BadRva2ChannelType => write!(f, "Encountered invalid channel type in RVA2 frame"), Self::BadTimestampFormat => write!( f, "Encountered an invalid timestamp format in a synchronized frame" ), // Compression #[cfg(feature = "id3v2_compression_support")] Self::Decompression(err) => write!(f, "Failed to decompress frame: {err}"), #[cfg(not(feature = "id3v2_compression_support"))] Self::CompressedFrameEncountered => write!( f, "Encountered a compressed ID3v2 frame, support is disabled" ), // Writing Self::InvalidEncryptionMethodSymbol(symbol) => write!( f, "Attempted to write an encrypted frame with an invalid method symbol ({symbol})" ), Self::BadFrame(ref frame_id, frame_value) => write!( f, "Attempted to write an invalid frame. ID: \"{frame_id}\", Value: \"{frame_value}\"", ), Self::InvalidLanguage(lang) => write!( f, "Invalid frame language found: {lang:?} (expected 3 ascii characters)" ), } } } /// An error that arises while interacting with an ID3v2 tag pub struct Id3v2Error { kind: Id3v2ErrorKind, } impl Id3v2Error { /// Create a new `ID3v2Error` from an [`Id3v2ErrorKind`] #[must_use] pub const fn new(kind: Id3v2ErrorKind) -> Self { Self { kind } } /// Returns the [`Id3v2ErrorKind`] pub fn kind(&self) -> &Id3v2ErrorKind { &self.kind } } impl Debug for Id3v2Error { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { write!(f, "ID3v2: {:?}", self.kind) } } impl Display for Id3v2Error { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { write!(f, "ID3v2: {}", self.kind) } } /// An error that arises while decoding a file pub struct FileDecodingError { format: Option, description: &'static str, } impl FileDecodingError { /// Create a `FileDecodingError` from a [`FileType`] and description #[must_use] pub const fn new(format: FileType, description: &'static str) -> Self { Self { format: Some(format), description, } } /// Create a `FileDecodingError` without binding it to a [`FileType`] pub fn from_description(description: &'static str) -> Self { Self { format: None, description, } } /// Returns the associated [`FileType`], if one exists pub fn format(&self) -> Option { self.format } /// Returns the error description pub fn description(&self) -> &str { self.description } } impl Debug for FileDecodingError { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { if let Some(format) = self.format { write!(f, "{:?}: {:?}", format, self.description) } else { write!(f, "{:?}", self.description) } } } impl Display for FileDecodingError { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { if let Some(format) = self.format { write!(f, "{:?}: {}", format, self.description) } else { write!(f, "{}", self.description) } } } /// An error that arises while encoding a file pub struct FileEncodingError { format: Option, description: &'static str, } impl FileEncodingError { /// Create a `FileEncodingError` from a [`FileType`] and description /// /// # Examples /// /// ```rust /// use lofty::error::FileEncodingError; /// use lofty::file::FileType; /// /// // This error is bounded to `FileType::Mpeg`, which will be displayed when the error is formatted /// let mpeg_error = /// FileEncodingError::new(FileType::Mpeg, "Something went wrong in the MPEG file!"); /// ``` #[must_use] pub const fn new(format: FileType, description: &'static str) -> Self { Self { format: Some(format), description, } } /// Create a `FileEncodingError` without binding it to a [`FileType`] /// /// # Examples /// /// ```rust /// use lofty::error::FileEncodingError; /// use lofty::file::FileType; /// /// // The error isn't bounded to FileType::Mpeg, only the message will be displayed when the /// // error is formatted /// let mpeg_error = FileEncodingError::from_description("Something went wrong in the MPEG file!"); /// ``` pub fn from_description(description: &'static str) -> Self { Self { format: None, description, } } /// Returns the associated [`FileType`], if one exists /// /// # Examples /// /// ```rust /// use lofty::error::FileEncodingError; /// use lofty::file::FileType; /// /// let mpeg_error = /// FileEncodingError::new(FileType::Mpeg, "Something went wrong in the MPEG file!"); /// /// assert_eq!(mpeg_error.format(), Some(FileType::Mpeg)); /// ``` pub fn format(&self) -> Option { self.format } /// Returns the error description /// /// # Examples /// /// ```rust /// use lofty::error::FileEncodingError; /// use lofty::file::FileType; /// /// let mpeg_error = /// FileEncodingError::new(FileType::Mpeg, "Something went wrong in the MPEG file!"); /// /// assert_eq!( /// mpeg_error.description(), /// "Something went wrong in the MPEG file!" /// ); /// ``` pub fn description(&self) -> &str { self.description } } impl Debug for FileEncodingError { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { if let Some(format) = self.format { write!(f, "{:?}: {:?}", format, self.description) } else { write!(f, "{:?}", self.description) } } } impl Display for FileEncodingError { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { if let Some(format) = self.format { write!(f, "{:?}: {:?}", format, self.description) } else { write!(f, "{}", self.description) } } } /// Errors that could occur within Lofty pub struct LoftyError { pub(crate) kind: ErrorKind, } impl LoftyError { /// Create a `LoftyError` from an [`ErrorKind`] /// /// # Examples /// /// ```rust /// use lofty::error::{ErrorKind, LoftyError}; /// /// let unknown_format = LoftyError::new(ErrorKind::UnknownFormat); /// ``` #[must_use] pub const fn new(kind: ErrorKind) -> Self { Self { kind } } /// Returns the [`ErrorKind`] /// /// # Examples /// /// ```rust /// use lofty::error::{ErrorKind, LoftyError}; /// /// let unknown_format = LoftyError::new(ErrorKind::UnknownFormat); /// if let ErrorKind::UnknownFormat = unknown_format.kind() { /// println!("What's the format?"); /// } /// ``` pub fn kind(&self) -> &ErrorKind { &self.kind } } impl std::error::Error for LoftyError {} impl Debug for LoftyError { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { write!(f, "{:?}", self.kind) } } impl From for LoftyError { fn from(input: Id3v2Error) -> Self { Self { kind: ErrorKind::Id3v2(input), } } } impl From for LoftyError { fn from(input: FileDecodingError) -> Self { Self { kind: ErrorKind::FileDecoding(input), } } } impl From for LoftyError { fn from(input: FileEncodingError) -> Self { Self { kind: ErrorKind::FileEncoding(input), } } } impl From for LoftyError { fn from(input: PageError) -> Self { Self { kind: ErrorKind::OggPage(input), } } } impl From for LoftyError { fn from(input: std::io::Error) -> Self { Self { kind: ErrorKind::Io(input), } } } impl From for LoftyError { fn from(input: std::fmt::Error) -> Self { Self { kind: ErrorKind::Fmt(input), } } } impl From for LoftyError { fn from(input: std::string::FromUtf8Error) -> Self { Self { kind: ErrorKind::StringFromUtf8(input), } } } impl From for LoftyError { fn from(input: std::str::Utf8Error) -> Self { Self { kind: ErrorKind::StrFromUtf8(input), } } } impl From for LoftyError { fn from(input: TryReserveError) -> Self { Self { kind: ErrorKind::Alloc(input), } } } impl From for LoftyError { fn from(input: std::convert::Infallible) -> Self { Self { kind: ErrorKind::Infallible(input), } } } impl Display for LoftyError { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self.kind { // Conversions ErrorKind::OggPage(ref err) => write!(f, "{err}"), ErrorKind::StringFromUtf8(ref err) => write!(f, "{err}"), ErrorKind::StrFromUtf8(ref err) => write!(f, "{err}"), ErrorKind::Io(ref err) => write!(f, "{err}"), ErrorKind::Fmt(ref err) => write!(f, "{err}"), ErrorKind::Alloc(ref err) => write!(f, "{err}"), ErrorKind::UnknownFormat => { write!(f, "No format could be determined from the provided file") }, ErrorKind::NotAPicture => write!(f, "Picture: Encountered invalid data"), ErrorKind::UnsupportedPicture => { write!(f, "Picture: attempted to write an unsupported picture") }, ErrorKind::UnsupportedTag => write!( f, "Attempted to write a tag to a format that does not support it" ), ErrorKind::FakeTag => write!(f, "Reading: Expected a tag, found invalid data"), ErrorKind::TextDecode(message) => write!(f, "Text decoding: {message}"), ErrorKind::BadTimestamp(message) => { write!(f, "Encountered an invalid timestamp: {message}") }, ErrorKind::Id3v2(ref id3v2_err) => write!(f, "{id3v2_err}"), ErrorKind::BadAtom(message) => write!(f, "MP4 Atom: {message}"), ErrorKind::AtomMismatch => write!( f, "MP4 Atom: Attempted to use `Atom::merge()` with mismatching identifiers" ), // Files ErrorKind::TooMuchData => write!( f, "Attempted to read/write an abnormally large amount of data" ), ErrorKind::SizeMismatch => write!( f, "Encountered an invalid item size, either too big or too small to be valid" ), ErrorKind::FileDecoding(ref file_decode_err) => write!(f, "{file_decode_err}"), ErrorKind::FileEncoding(ref file_encode_err) => write!(f, "{file_encode_err}"), ErrorKind::Infallible(_) => write!(f, "A expected condition was not upheld"), } } } lofty-0.21.1/src/file/audio_file.rs000064400000000000000000000053731046102023000152260ustar 00000000000000use super::tagged_file::TaggedFile; use crate::config::{ParseOptions, WriteOptions}; use crate::error::{LoftyError, Result}; use crate::tag::TagType; use crate::util::io::{FileLike, Length, Truncate}; use std::fs::OpenOptions; use std::io::{Read, Seek}; use std::path::Path; /// Provides various methods for interaction with a file pub trait AudioFile: Into { /// The struct the file uses for audio properties /// /// Not all formats can use [`FileProperties`](crate::properties::FileProperties) since they may contain additional information type Properties; /// Read a file from a reader /// /// # Errors /// /// Errors depend on the file and tags being read. See [`LoftyError`](crate::error::LoftyError) fn read_from(reader: &mut R, parse_options: ParseOptions) -> Result where R: Read + Seek, Self: Sized; /// Attempts to write all tags to a path /// /// # Errors /// /// * `path` does not exist /// * `path` is not writable /// * See [`AudioFile::save_to`] /// /// # Examples /// /// ```rust,no_run /// use lofty::config::WriteOptions; /// use lofty::file::{AudioFile, TaggedFileExt}; /// /// # fn main() -> lofty::error::Result<()> { /// # let path = "tests/files/assets/minimal/full_test.mp3"; /// let mut tagged_file = lofty::read_from_path(path)?; /// /// // Edit the tags /// /// tagged_file.save_to_path(path, WriteOptions::default())?; /// # Ok(()) } /// ``` fn save_to_path(&self, path: impl AsRef, write_options: WriteOptions) -> Result<()> { self.save_to( &mut OpenOptions::new().read(true).write(true).open(path)?, write_options, ) } /// Attempts to write all tags to a file /// /// # Errors /// /// See [`TagExt::save_to`](crate::tag::TagExt::save_to), however this is applicable to every tag in the file. /// /// # Examples /// /// ```rust,no_run /// use lofty::config::WriteOptions; /// use lofty::file::{AudioFile, TaggedFileExt}; /// use std::fs::OpenOptions; /// /// # fn main() -> lofty::error::Result<()> { /// # let path = "tests/files/assets/minimal/full_test.mp3"; /// let mut tagged_file = lofty::read_from_path(path)?; /// /// // Edit the tags /// /// let mut file = OpenOptions::new().read(true).write(true).open(path)?; /// tagged_file.save_to(&mut file, WriteOptions::default())?; /// # Ok(()) } /// ``` fn save_to(&self, file: &mut F, write_options: WriteOptions) -> Result<()> where F: FileLike, LoftyError: From<::Error>, LoftyError: From<::Error>; /// Returns a reference to the file's properties fn properties(&self) -> &Self::Properties; /// Checks if the file contains any tags fn contains_tag(&self) -> bool; /// Checks if the file contains the given [`TagType`] fn contains_tag_type(&self, tag_type: TagType) -> bool; } lofty-0.21.1/src/file/file_type.rs000064400000000000000000000231121046102023000150750ustar 00000000000000use crate::config::global_options; use crate::resolve::custom_resolvers; use crate::tag::TagType; use std::ffi::OsStr; use std::path::Path; /// The type of file read #[derive(PartialEq, Eq, Copy, Clone, Debug)] #[allow(missing_docs)] #[non_exhaustive] pub enum FileType { Aac, Aiff, Ape, Flac, Mpeg, Mp4, Mpc, Opus, Vorbis, Speex, Wav, WavPack, Custom(&'static str), } impl FileType { /// Returns the file type's "primary" [`TagType`], or the one most likely to be used in the target format /// /// | [`FileType`] | [`TagType`] | /// |-----------------------------------|------------------| /// | `Aac`, `Aiff`, `Mp3`, `Wav` | `Id3v2` | /// | `Ape` , `Mpc`, `WavPack` | `Ape` | /// | `Flac`, `Opus`, `Vorbis`, `Speex` | `VorbisComments` | /// | `Mp4` | `Mp4Ilst` | /// /// # Panics /// /// If an unregistered `FileType` ([`FileType::Custom`]) is encountered. See [`register_custom_resolver`](crate::resolve::register_custom_resolver). /// /// # Examples /// /// ```rust /// use lofty::file::FileType; /// use lofty::tag::TagType; /// /// let file_type = FileType::Mpeg; /// assert_eq!(file_type.primary_tag_type(), TagType::Id3v2); /// ``` pub fn primary_tag_type(&self) -> TagType { match self { FileType::Aac | FileType::Aiff | FileType::Mpeg | FileType::Wav => TagType::Id3v2, FileType::Ape | FileType::Mpc | FileType::WavPack => TagType::Ape, FileType::Flac | FileType::Opus | FileType::Vorbis | FileType::Speex => { TagType::VorbisComments }, FileType::Mp4 => TagType::Mp4Ilst, FileType::Custom(c) => { let resolver = crate::resolve::lookup_resolver(c); resolver.primary_tag_type() }, } } /// Returns if the target `FileType` supports a [`TagType`] /// /// NOTE: This is feature dependent, meaning if you do not have the /// `id3v2` feature enabled, [`FileType::Mpeg`] will return `false` for /// [`TagType::Id3v2`]. /// /// # Panics /// /// If an unregistered `FileType` ([`FileType::Custom`]) is encountered. See [`register_custom_resolver`](crate::resolve::register_custom_resolver). /// /// # Examples /// /// ```rust /// use lofty::file::FileType; /// use lofty::tag::TagType; /// /// let file_type = FileType::Mpeg; /// assert!(file_type.supports_tag_type(TagType::Id3v2)); /// ``` pub fn supports_tag_type(&self, tag_type: TagType) -> bool { if let FileType::Custom(c) = self { let resolver = crate::resolve::lookup_resolver(c); return resolver.supported_tag_types().contains(&tag_type); } match tag_type { TagType::Ape => crate::ape::ApeTag::SUPPORTED_FORMATS.contains(self), TagType::Id3v1 => crate::id3::v1::Id3v1Tag::SUPPORTED_FORMATS.contains(self), TagType::Id3v2 => crate::id3::v2::Id3v2Tag::SUPPORTED_FORMATS.contains(self), TagType::Mp4Ilst => crate::mp4::Ilst::SUPPORTED_FORMATS.contains(self), TagType::VorbisComments => crate::ogg::VorbisComments::SUPPORTED_FORMATS.contains(self), TagType::RiffInfo => crate::iff::wav::RiffInfoList::SUPPORTED_FORMATS.contains(self), TagType::AiffText => crate::iff::aiff::AiffTextChunks::SUPPORTED_FORMATS.contains(self), } } /// Attempts to extract a [`FileType`] from an extension /// /// # Examples /// /// ```rust /// use lofty::file::FileType; /// /// let extension = "mp3"; /// assert_eq!(FileType::from_ext(extension), Some(FileType::Mpeg)); /// ``` pub fn from_ext(ext: E) -> Option where E: AsRef, { let ext = ext.as_ref().to_str()?.to_ascii_lowercase(); // Give custom resolvers priority if unsafe { global_options().use_custom_resolvers } { if let Some((ty, _)) = custom_resolvers() .lock() .ok()? .iter() .find(|(_, f)| f.extension() == Some(ext.as_str())) { return Some(Self::Custom(ty)); } } match ext.as_str() { "aac" => Some(Self::Aac), "ape" => Some(Self::Ape), "aiff" | "aif" | "afc" | "aifc" => Some(Self::Aiff), "mp3" | "mp2" | "mp1" => Some(Self::Mpeg), "wav" | "wave" => Some(Self::Wav), "wv" => Some(Self::WavPack), "opus" => Some(Self::Opus), "flac" => Some(Self::Flac), "ogg" => Some(Self::Vorbis), "mp4" | "m4a" | "m4b" | "m4p" | "m4r" | "m4v" | "3gp" => Some(Self::Mp4), "mpc" | "mp+" | "mpp" => Some(Self::Mpc), "spx" => Some(Self::Speex), _ => None, } } /// Attempts to determine a [`FileType`] from a path /// /// # Examples /// /// ```rust /// use lofty::file::FileType; /// use std::path::Path; /// /// let path = Path::new("path/to/my.mp3"); /// assert_eq!(FileType::from_path(path), Some(FileType::Mpeg)); /// ``` pub fn from_path

(path: P) -> Option where P: AsRef, { let ext = path.as_ref().extension(); ext.and_then(Self::from_ext) } /// Attempts to extract a [`FileType`] from a buffer /// /// NOTES: /// /// * This is for use in [`Probe::guess_file_type`], it is recommended to use it that way /// * This **will not** search past tags at the start of the buffer. /// For this behavior, use [`Probe::guess_file_type`]. /// /// [`Probe::guess_file_type`]: crate::probe::Probe::guess_file_type /// /// # Examples /// /// ```rust /// use lofty::file::FileType; /// use std::fs::File; /// use std::io::Read; /// /// # fn main() -> lofty::error::Result<()> { /// # let path_to_opus = "tests/files/assets/minimal/full_test.opus"; /// let mut file = File::open(path_to_opus)?; /// /// let mut buf = [0; 50]; // Search the first 50 bytes of the file /// file.read_exact(&mut buf)?; /// /// assert_eq!(FileType::from_buffer(&buf), Some(FileType::Opus)); /// # Ok(()) } /// ``` pub fn from_buffer(buf: &[u8]) -> Option { match Self::from_buffer_inner(buf) { Some(FileTypeGuessResult::Determined(file_ty)) => Some(file_ty), // We make no attempt to search past an ID3v2 tag or junk here, since // we only provided a fixed-sized buffer to search from. // // That case is handled in `Probe::guess_file_type` _ => None, } } // TODO: APE tags in the beginning of the file pub(crate) fn from_buffer_inner(buf: &[u8]) -> Option { use crate::id3::v2::util::synchsafe::SynchsafeInteger; // Start out with an empty return let mut ret = None; if buf.is_empty() { return ret; } match Self::quick_type_guess(buf) { Some(f_ty) => ret = Some(FileTypeGuessResult::Determined(f_ty)), // Special case for ID3, gets checked in `Probe::guess_file_type` // The bare minimum size for an ID3v2 header is 10 bytes None if buf.len() >= 10 && &buf[..3] == b"ID3" => { // This is infallible, but preferable to an unwrap if let Ok(arr) = buf[6..10].try_into() { // Set the ID3v2 size ret = Some(FileTypeGuessResult::MaybePrecededById3( u32::from_be_bytes(arr).unsynch(), )); } }, None => ret = Some(FileTypeGuessResult::MaybePrecededByJunk), } ret } fn quick_type_guess(buf: &[u8]) -> Option { use crate::mpeg::header::verify_frame_sync; // Safe to index, since we return early on an empty buffer match buf[0] { 77 if buf.starts_with(b"MAC") => Some(Self::Ape), 255 if buf.len() >= 2 && verify_frame_sync([buf[0], buf[1]]) => { // ADTS and MPEG frame headers are way too similar // ADTS (https://wiki.multimedia.cx/index.php/ADTS#Header): // // AAAAAAAA AAAABCCX // // Letter Length (bits) Description // A 12 Syncword, all bits must be set to 1. // B 1 MPEG Version, set to 0 for MPEG-4 and 1 for MPEG-2. // C 2 Layer, always set to 0. // MPEG (http://www.mp3-tech.org/programmer/frame_header.html): // // AAAAAAAA AAABBCCX // // Letter Length (bits) Description // A 11 Syncword, all bits must be set to 1. // B 2 MPEG Audio version ID // C 2 Layer description // The subtle overlap in the ADTS header's frame sync and MPEG's version ID // is the first condition to check. However, since 0b10 and 0b11 are valid versions // in MPEG, we have to also check the layer. // So, if we have a version 1 (0b11) or version 2 (0b10) MPEG frame AND a layer of 0b00, // we can assume we have an ADTS header. Awesome! if buf[1] & 0b10000 > 0 && buf[1] & 0b110 == 0 { return Some(Self::Aac); } Some(Self::Mpeg) }, 70 if buf.len() >= 12 && &buf[..4] == b"FORM" => { let id = &buf[8..12]; if id == b"AIFF" || id == b"AIFC" { return Some(Self::Aiff); } None }, 79 if buf.len() >= 36 && &buf[..4] == b"OggS" => { if &buf[29..35] == b"vorbis" { return Some(Self::Vorbis); } else if &buf[28..36] == b"OpusHead" { return Some(Self::Opus); } else if &buf[28..36] == b"Speex " { return Some(Self::Speex); } None }, 102 if buf.starts_with(b"fLaC") => Some(Self::Flac), 82 if buf.len() >= 12 && &buf[..4] == b"RIFF" => { if &buf[8..12] == b"WAVE" { return Some(Self::Wav); } None }, 119 if buf.len() >= 4 && &buf[..4] == b"wvpk" => Some(Self::WavPack), _ if buf.len() >= 8 && &buf[4..8] == b"ftyp" => Some(Self::Mp4), _ if buf.starts_with(b"MPCK") || buf.starts_with(b"MP+") => Some(Self::Mpc), _ => None, } } } /// The result of a `FileType` guess /// /// External callers of `FileType::from_buffer()` will only ever see `Determined` cases. /// The remaining cases are used internally in `Probe::guess_file_type()`. pub(crate) enum FileTypeGuessResult { /// The `FileType` was guessed Determined(FileType), /// The stream starts with an ID3v2 tag MaybePrecededById3(u32), /// The stream starts with potential junk data MaybePrecededByJunk, } lofty-0.21.1/src/file/mod.rs000064400000000000000000000004051046102023000136740ustar 00000000000000//! Generic file handling utilities mod audio_file; mod file_type; mod tagged_file; pub use audio_file::AudioFile; pub use file_type::FileType; pub use tagged_file::{BoundTaggedFile, TaggedFile, TaggedFileExt}; pub(crate) use file_type::FileTypeGuessResult; lofty-0.21.1/src/file/tagged_file.rs000064400000000000000000000432251046102023000153560ustar 00000000000000use super::audio_file::AudioFile; use super::file_type::FileType; use crate::config::{ParseOptions, WriteOptions}; use crate::error::{LoftyError, Result}; use crate::properties::FileProperties; use crate::tag::{Tag, TagExt, TagType}; use crate::util::io::{FileLike, Length, Truncate}; use std::fs::File; use std::io::{Read, Seek}; /// Provides a common interface between [`TaggedFile`] and [`BoundTaggedFile`] pub trait TaggedFileExt { /// Returns the file's [`FileType`] /// /// # Examples /// /// ```rust /// use lofty::file::{FileType, TaggedFileExt}; /// /// # fn main() -> lofty::error::Result<()> { /// # let path_to_mp3 = "tests/files/assets/minimal/full_test.mp3"; /// let mut tagged_file = lofty::read_from_path(path_to_mp3)?; /// /// assert_eq!(tagged_file.file_type(), FileType::Mpeg); /// # Ok(()) } /// ``` fn file_type(&self) -> FileType; /// Returns all tags /// /// # Examples /// /// ```rust /// use lofty::file::{FileType, TaggedFileExt}; /// /// # fn main() -> lofty::error::Result<()> { /// # let path_to_mp3 = "tests/files/assets/minimal/full_test.mp3"; /// // An MP3 file with 3 tags /// let mut tagged_file = lofty::read_from_path(path_to_mp3)?; /// /// let tags = tagged_file.tags(); /// /// assert_eq!(tags.len(), 3); /// # Ok(()) } /// ``` fn tags(&self) -> &[Tag]; /// Returns the file type's primary [`TagType`] /// /// See [`FileType::primary_tag_type`] /// /// # Examples /// /// ```rust /// use lofty::file::TaggedFileExt; /// use lofty::tag::TagType; /// /// # fn main() -> lofty::error::Result<()> { /// # let path_to_mp3 = "tests/files/assets/minimal/full_test.mp3"; /// let mut tagged_file = lofty::read_from_path(path_to_mp3)?; /// /// assert_eq!(tagged_file.primary_tag_type(), TagType::Id3v2); /// # Ok(()) } /// ``` fn primary_tag_type(&self) -> TagType { self.file_type().primary_tag_type() } /// Determines whether the file supports the given [`TagType`] /// /// # Examples /// /// ```rust /// use lofty::file::TaggedFileExt; /// use lofty::tag::TagType; /// /// # fn main() -> lofty::error::Result<()> { /// # let path_to_mp3 = "tests/files/assets/minimal/full_test.mp3"; /// let mut tagged_file = lofty::read_from_path(path_to_mp3)?; /// /// assert!(tagged_file.supports_tag_type(TagType::Id3v2)); /// # Ok(()) } /// ``` fn supports_tag_type(&self, tag_type: TagType) -> bool { self.file_type().supports_tag_type(tag_type) } /// Get a reference to a specific [`TagType`] /// /// # Examples /// /// ```rust /// use lofty::file::TaggedFileExt; /// use lofty::tag::TagType; /// /// # fn main() -> lofty::error::Result<()> { /// # let path_to_mp3 = "tests/files/assets/minimal/full_test.mp3"; /// // Read an MP3 file with an ID3v2 tag /// let mut tagged_file = lofty::read_from_path(path_to_mp3)?; /// /// // An ID3v2 tag /// let tag = tagged_file.tag(TagType::Id3v2); /// /// assert!(tag.is_some()); /// assert_eq!(tag.unwrap().tag_type(), TagType::Id3v2); /// # Ok(()) } /// ``` fn tag(&self, tag_type: TagType) -> Option<&Tag>; /// Get a mutable reference to a specific [`TagType`] /// /// # Examples /// /// ```rust /// use lofty::file::TaggedFileExt; /// use lofty::tag::TagType; /// /// # fn main() -> lofty::error::Result<()> { /// # let path_to_mp3 = "tests/files/assets/minimal/full_test.mp3"; /// // Read an MP3 file with an ID3v2 tag /// let mut tagged_file = lofty::read_from_path(path_to_mp3)?; /// /// // An ID3v2 tag /// let tag = tagged_file.tag(TagType::Id3v2); /// /// assert!(tag.is_some()); /// assert_eq!(tag.unwrap().tag_type(), TagType::Id3v2); /// /// // Alter the tag... /// # Ok(()) } /// ``` fn tag_mut(&mut self, tag_type: TagType) -> Option<&mut Tag>; /// Returns the primary tag /// /// See [`FileType::primary_tag_type`] /// /// # Examples /// /// ```rust /// use lofty::file::TaggedFileExt; /// use lofty::tag::TagType; /// /// # fn main() -> lofty::error::Result<()> { /// # let path_to_mp3 = "tests/files/assets/minimal/full_test.mp3"; /// // Read an MP3 file with an ID3v2 tag /// let mut tagged_file = lofty::read_from_path(path_to_mp3)?; /// /// // An ID3v2 tag /// let tag = tagged_file.primary_tag(); /// /// assert!(tag.is_some()); /// assert_eq!(tag.unwrap().tag_type(), TagType::Id3v2); /// # Ok(()) } /// ``` fn primary_tag(&self) -> Option<&Tag> { self.tag(self.primary_tag_type()) } /// Gets a mutable reference to the file's "Primary tag" /// /// See [`FileType::primary_tag_type`] /// /// # Examples /// /// ```rust /// use lofty::file::TaggedFileExt; /// use lofty::tag::TagType; /// /// # fn main() -> lofty::error::Result<()> { /// # let path_to_mp3 = "tests/files/assets/minimal/full_test.mp3"; /// // Read an MP3 file with an ID3v2 tag /// let mut tagged_file = lofty::read_from_path(path_to_mp3)?; /// /// // An ID3v2 tag /// let tag = tagged_file.primary_tag_mut(); /// /// assert!(tag.is_some()); /// assert_eq!(tag.unwrap().tag_type(), TagType::Id3v2); /// /// // Alter the tag... /// # Ok(()) } /// ``` fn primary_tag_mut(&mut self) -> Option<&mut Tag> { self.tag_mut(self.primary_tag_type()) } /// Gets the first tag, if there are any /// /// NOTE: This will grab the first available tag, you cannot rely on the result being /// a specific type /// /// # Examples /// /// ```rust /// use lofty::file::TaggedFileExt; /// /// # fn main() -> lofty::error::Result<()> { /// # let path = "tests/files/assets/minimal/full_test.mp3"; /// // A file we know has tags /// let mut tagged_file = lofty::read_from_path(path)?; /// /// // A tag of a (currently) unknown type /// let tag = tagged_file.first_tag(); /// assert!(tag.is_some()); /// # Ok(()) } /// ``` fn first_tag(&self) -> Option<&Tag> { self.tags().first() } /// Gets a mutable reference to the first tag, if there are any /// /// NOTE: This will grab the first available tag, you cannot rely on the result being /// a specific type /// /// # Examples /// /// ```rust /// use lofty::file::TaggedFileExt; /// /// # fn main() -> lofty::error::Result<()> { /// # let path = "tests/files/assets/minimal/full_test.mp3"; /// // A file we know has tags /// let mut tagged_file = lofty::read_from_path(path)?; /// /// // A tag of a (currently) unknown type /// let tag = tagged_file.first_tag_mut(); /// assert!(tag.is_some()); /// /// // Alter the tag... /// # Ok(()) } /// ``` fn first_tag_mut(&mut self) -> Option<&mut Tag>; /// Inserts a [`Tag`] /// /// NOTE: This will do nothing if the [`FileType`] does not support /// the [`TagType`]. See [`FileType::supports_tag_type`] /// /// If a tag is replaced, it will be returned /// /// # Examples /// /// ```rust /// use lofty::file::{AudioFile, TaggedFileExt}; /// use lofty::tag::{Tag, TagType}; /// /// # fn main() -> lofty::error::Result<()> { /// # let path_to_mp3 = "tests/files/assets/minimal/full_test.mp3"; /// // Read an MP3 file without an ID3v2 tag /// let mut tagged_file = lofty::read_from_path(path_to_mp3)?; /// # let _ = tagged_file.remove(TagType::Id3v2); // sneaky /// /// assert!(!tagged_file.contains_tag_type(TagType::Id3v2)); /// /// // Insert the ID3v2 tag /// let new_id3v2_tag = Tag::new(TagType::Id3v2); /// tagged_file.insert_tag(new_id3v2_tag); /// /// assert!(tagged_file.contains_tag_type(TagType::Id3v2)); /// # Ok(()) } /// ``` fn insert_tag(&mut self, tag: Tag) -> Option; /// Removes a specific [`TagType`] and returns it /// /// # Examples /// /// ```rust /// use lofty::file::{AudioFile, TaggedFileExt}; /// use lofty::tag::TagType; /// /// # fn main() -> lofty::error::Result<()> { /// # let path_to_mp3 = "tests/files/assets/minimal/full_test.mp3"; /// // Read an MP3 file containing an ID3v2 tag /// let mut tagged_file = lofty::read_from_path(path_to_mp3)?; /// /// assert!(tagged_file.contains_tag_type(TagType::Id3v2)); /// /// // Take the ID3v2 tag /// let id3v2 = tagged_file.remove(TagType::Id3v2); /// /// assert!(!tagged_file.contains_tag_type(TagType::Id3v2)); /// # Ok(()) } /// ``` fn remove(&mut self, tag_type: TagType) -> Option; /// Removes all tags from the file /// /// # Examples /// /// ```rust /// use lofty::file::TaggedFileExt; /// /// # fn main() -> lofty::error::Result<()> { /// # let path = "tests/files/assets/minimal/full_test.mp3"; /// let mut tagged_file = lofty::read_from_path(path)?; /// /// tagged_file.clear(); /// /// assert!(tagged_file.tags().is_empty()); /// # Ok(()) } /// ``` fn clear(&mut self); } /// A generic representation of a file /// /// This is used when the [`FileType`] has to be guessed pub struct TaggedFile { /// The file's type pub(crate) ty: FileType, /// The file's audio properties pub(crate) properties: FileProperties, /// A collection of the file's tags pub(crate) tags: Vec, } impl TaggedFile { #[doc(hidden)] /// This exists for use in `lofty_attr`, there's no real use for this externally #[must_use] pub const fn new(ty: FileType, properties: FileProperties, tags: Vec) -> Self { Self { ty, properties, tags, } } /// Changes the [`FileType`] /// /// NOTES: /// /// * This will remove any tag the format does not support. See [`FileType::supports_tag_type`] /// * This will reset the [`FileProperties`] /// /// # Examples /// /// ```rust /// use lofty::file::{AudioFile, FileType, TaggedFileExt}; /// use lofty::tag::TagType; /// /// # fn main() -> lofty::error::Result<()> { /// # let path_to_mp3 = "tests/files/assets/minimal/full_test.mp3"; /// // Read an MP3 file containing an ID3v2 tag /// let mut tagged_file = lofty::read_from_path(path_to_mp3)?; /// /// assert!(tagged_file.contains_tag_type(TagType::Id3v2)); /// /// // Remap our MP3 file to WavPack, which doesn't support ID3v2 /// tagged_file.change_file_type(FileType::WavPack); /// /// assert!(!tagged_file.contains_tag_type(TagType::Id3v2)); /// # Ok(()) } /// ``` pub fn change_file_type(&mut self, file_type: FileType) { self.ty = file_type; self.properties = FileProperties::default(); self.tags .retain(|t| self.ty.supports_tag_type(t.tag_type())); } } impl TaggedFileExt for TaggedFile { fn file_type(&self) -> FileType { self.ty } fn tags(&self) -> &[Tag] { self.tags.as_slice() } fn tag(&self, tag_type: TagType) -> Option<&Tag> { self.tags.iter().find(|i| i.tag_type() == tag_type) } fn tag_mut(&mut self, tag_type: TagType) -> Option<&mut Tag> { self.tags.iter_mut().find(|i| i.tag_type() == tag_type) } fn first_tag_mut(&mut self) -> Option<&mut Tag> { self.tags.first_mut() } fn insert_tag(&mut self, tag: Tag) -> Option { let tag_type = tag.tag_type(); if self.supports_tag_type(tag_type) { let ret = self.remove(tag_type); self.tags.push(tag); return ret; } None } fn remove(&mut self, tag_type: TagType) -> Option { self.tags .iter() .position(|t| t.tag_type() == tag_type) .map(|pos| self.tags.remove(pos)) } fn clear(&mut self) { self.tags.clear() } } impl AudioFile for TaggedFile { type Properties = FileProperties; fn read_from(reader: &mut R, parse_options: ParseOptions) -> Result where R: Read + Seek, Self: Sized, { crate::probe::Probe::new(reader) .guess_file_type()? .options(parse_options) .read() } fn save_to(&self, file: &mut F, write_options: WriteOptions) -> Result<()> where F: FileLike, LoftyError: From<::Error>, LoftyError: From<::Error>, { for tag in &self.tags { // TODO: This is a temporary solution. Ideally we should probe once and use // the format-specific writing to avoid these rewinds. file.rewind()?; tag.save_to(file, write_options)?; } Ok(()) } fn properties(&self) -> &Self::Properties { &self.properties } fn contains_tag(&self) -> bool { !self.tags.is_empty() } fn contains_tag_type(&self, tag_type: TagType) -> bool { self.tags.iter().any(|t| t.tag_type() == tag_type) } } impl From for TaggedFile { fn from(input: BoundTaggedFile) -> Self { input.inner } } /// A variant of [`TaggedFile`] that holds a [`File`] handle, and reflects changes /// such as tag removals. /// /// For example: /// /// ```rust,no_run /// use lofty::config::WriteOptions; /// use lofty::file::{AudioFile, TaggedFileExt}; /// use lofty::tag::{Tag, TagType}; /// # fn main() -> lofty::error::Result<()> { /// # let path = "tests/files/assets/minimal/full_test.mp3"; /// /// // We create an empty tag /// let tag = Tag::new(TagType::Id3v2); /// /// let mut tagged_file = lofty::read_from_path(path)?; /// /// // Push our empty tag into the TaggedFile /// tagged_file.insert_tag(tag); /// /// // After saving, our file still "contains" the ID3v2 tag, but if we were to read /// // "foo.mp3", it would not have an ID3v2 tag. Lofty does not write empty tags, but this /// // change will not be reflected in `TaggedFile`. /// tagged_file.save_to_path("foo.mp3", WriteOptions::default())?; /// assert!(tagged_file.contains_tag_type(TagType::Id3v2)); /// # Ok(()) } /// ``` /// /// However, when using `BoundTaggedFile`: /// /// ```rust,no_run /// use lofty::config::{ParseOptions, WriteOptions}; /// use lofty::file::{AudioFile, BoundTaggedFile, TaggedFileExt}; /// use lofty::tag::{Tag, TagType}; /// use std::fs::OpenOptions; /// # fn main() -> lofty::error::Result<()> { /// # let path = "tests/files/assets/minimal/full_test.mp3"; /// /// // We create an empty tag /// let tag = Tag::new(TagType::Id3v2); /// /// // We'll need to open our file for reading *and* writing /// let file = OpenOptions::new().read(true).write(true).open(path)?; /// let parse_options = ParseOptions::new(); /// /// let mut bound_tagged_file = BoundTaggedFile::read_from(file, parse_options)?; /// /// // Push our empty tag into the TaggedFile /// bound_tagged_file.insert_tag(tag); /// /// // Now when saving, we no longer have to specify a path, and the tags in the `BoundTaggedFile` /// // reflect those in the actual file on disk. /// bound_tagged_file.save(WriteOptions::default())?; /// assert!(!bound_tagged_file.contains_tag_type(TagType::Id3v2)); /// # Ok(()) } /// ``` pub struct BoundTaggedFile { inner: TaggedFile, file_handle: File, } impl BoundTaggedFile { /// Create a new [`BoundTaggedFile`] /// /// # Errors /// /// See [`AudioFile::read_from`] /// /// # Examples /// /// ```rust /// use lofty::config::ParseOptions; /// use lofty::file::{AudioFile, BoundTaggedFile, TaggedFileExt}; /// use lofty::tag::{Tag, TagType}; /// use std::fs::OpenOptions; /// # fn main() -> lofty::error::Result<()> { /// # let path = "tests/files/assets/minimal/full_test.mp3"; /// /// // We'll need to open our file for reading *and* writing /// let file = OpenOptions::new().read(true).write(true).open(path)?; /// let parse_options = ParseOptions::new(); /// /// let bound_tagged_file = BoundTaggedFile::read_from(file, parse_options)?; /// # Ok(()) } /// ``` pub fn read_from(mut file: File, parse_options: ParseOptions) -> Result { let inner = TaggedFile::read_from(&mut file, parse_options)?; file.rewind()?; Ok(Self { inner, file_handle: file, }) } /// Save the tags to the file stored internally /// /// # Errors /// /// See [`TaggedFile::save_to`] /// /// # Examples /// /// ```rust,no_run /// use lofty::config::{ParseOptions, WriteOptions}; /// use lofty::file::{AudioFile, BoundTaggedFile, TaggedFileExt}; /// use lofty::tag::{Tag, TagType}; /// use std::fs::OpenOptions; /// # fn main() -> lofty::error::Result<()> { /// # let path = "tests/files/assets/minimal/full_test.mp3"; /// /// // We'll need to open our file for reading *and* writing /// let file = OpenOptions::new().read(true).write(true).open(path)?; /// let parse_options = ParseOptions::new(); /// /// let mut bound_tagged_file = BoundTaggedFile::read_from(file, parse_options)?; /// /// // Do some work to the tags... /// /// // This will save the tags to the file we provided to `read_from` /// bound_tagged_file.save(WriteOptions::default())?; /// # Ok(()) } /// ``` pub fn save(&mut self, write_options: WriteOptions) -> Result<()> { self.inner.save_to(&mut self.file_handle, write_options)?; self.inner.tags.retain(|tag| !tag.is_empty()); Ok(()) } /// Consume this tagged file and return the internal file "buffer". /// This allows you to reuse the internal file. /// /// Any changes that haven't been commited will be discarded once you /// call this function. pub fn into_inner(self) -> File { self.file_handle } } impl TaggedFileExt for BoundTaggedFile { fn file_type(&self) -> FileType { self.inner.file_type() } fn tags(&self) -> &[Tag] { self.inner.tags() } fn tag(&self, tag_type: TagType) -> Option<&Tag> { self.inner.tag(tag_type) } fn tag_mut(&mut self, tag_type: TagType) -> Option<&mut Tag> { self.inner.tag_mut(tag_type) } fn first_tag_mut(&mut self) -> Option<&mut Tag> { self.inner.first_tag_mut() } fn insert_tag(&mut self, tag: Tag) -> Option { self.inner.insert_tag(tag) } fn remove(&mut self, tag_type: TagType) -> Option { self.inner.remove(tag_type) } fn clear(&mut self) { self.inner.clear() } } impl AudioFile for BoundTaggedFile { type Properties = FileProperties; fn read_from(_: &mut R, _: ParseOptions) -> Result where R: Read + Seek, Self: Sized, { unimplemented!( "BoundTaggedFile can only be constructed through `BoundTaggedFile::read_from`" ) } fn save_to(&self, file: &mut F, write_options: WriteOptions) -> Result<()> where F: FileLike, LoftyError: From<::Error>, LoftyError: From<::Error>, { self.inner.save_to(file, write_options) } fn properties(&self) -> &Self::Properties { self.inner.properties() } fn contains_tag(&self) -> bool { self.inner.contains_tag() } fn contains_tag_type(&self, tag_type: TagType) -> bool { self.inner.contains_tag_type(tag_type) } } lofty-0.21.1/src/flac/block.rs000064400000000000000000000025141046102023000142000ustar 00000000000000#![allow(dead_code)] use crate::error::Result; use crate::macros::try_vec; use std::io::{Read, Seek, SeekFrom}; use byteorder::{BigEndian, ReadBytesExt}; pub(in crate::flac) const BLOCK_ID_STREAMINFO: u8 = 0; pub(in crate::flac) const BLOCK_ID_PADDING: u8 = 1; pub(in crate::flac) const BLOCK_ID_SEEKTABLE: u8 = 3; pub(in crate::flac) const BLOCK_ID_VORBIS_COMMENTS: u8 = 4; pub(in crate::flac) const BLOCK_ID_PICTURE: u8 = 6; const BLOCK_HEADER_SIZE: u64 = 4; pub(crate) struct Block { pub(super) byte: u8, pub(super) ty: u8, pub(super) last: bool, pub(crate) content: Vec, pub(super) start: u64, pub(super) end: u64, } impl Block { pub(crate) fn read(data: &mut R, mut predicate: P) -> Result where R: Read + Seek, P: FnMut(u8) -> bool, { let start = data.stream_position()?; let byte = data.read_u8()?; let last = (byte & 0x80) != 0; let ty = byte & 0x7F; let size = data.read_u24::()?; log::trace!("Reading FLAC block, type: {ty}, size: {size}"); let mut content; if predicate(ty) { content = try_vec![0; size as usize]; data.read_exact(&mut content)?; } else { content = Vec::new(); data.seek(SeekFrom::Current(i64::from(size)))?; } let end = start + u64::from(size) + BLOCK_HEADER_SIZE; Ok(Self { byte, ty, last, content, start, end, }) } } lofty-0.21.1/src/flac/mod.rs000064400000000000000000000075261046102023000136750ustar 00000000000000//! Items for FLAC //! //! ## File notes //! //! * See [`FlacFile`] pub(crate) mod block; pub(crate) mod properties; mod read; pub(crate) mod write; use crate::config::WriteOptions; use crate::error::{LoftyError, Result}; use crate::file::{FileType, TaggedFile}; use crate::id3::v2::tag::Id3v2Tag; use crate::ogg::tag::VorbisCommentsRef; use crate::ogg::{OggPictureStorage, VorbisComments}; use crate::picture::{Picture, PictureInformation}; use crate::tag::TagExt; use crate::util::io::{FileLike, Length, Truncate}; use std::borrow::Cow; use lofty_attr::LoftyFile; // Exports pub use properties::FlacProperties; /// A FLAC file /// /// ## Notes /// /// * The ID3v2 tag is **read only**, and it's use is discouraged by spec /// * Pictures are stored in the `FlacFile` itself, rather than the tag. Any pictures inside the tag will /// be extracted out and stored in their own picture blocks. /// * It is possible to put pictures inside of the tag, that will not be accessible using the available /// methods on `FlacFile` ([`FlacFile::pictures`], [`FlacFile::remove_picture_type`], etc.) /// * When converting to [`TaggedFile`], all pictures will be put inside of a [`VorbisComments`] tag, even if the /// file did not originally contain one. #[derive(LoftyFile)] #[lofty(read_fn = "read::read_from")] #[lofty(write_fn = "Self::write_to")] #[lofty(no_into_taggedfile_impl)] pub struct FlacFile { /// An ID3v2 tag #[lofty(tag_type = "Id3v2")] pub(crate) id3v2_tag: Option, /// The vorbis comments contained in the file #[lofty(tag_type = "VorbisComments")] pub(crate) vorbis_comments_tag: Option, pub(crate) pictures: Vec<(Picture, PictureInformation)>, /// The file's audio properties pub(crate) properties: FlacProperties, } impl FlacFile { // We need a special write fn to append our pictures into a `VorbisComments` tag fn write_to(&self, file: &mut F, write_options: WriteOptions) -> Result<()> where F: FileLike, LoftyError: From<::Error>, LoftyError: From<::Error>, { if let Some(ref id3v2) = self.id3v2_tag { id3v2.save_to(file, write_options)?; file.rewind()?; } // We have an existing vorbis comments tag, we can just append our pictures to it if let Some(ref vorbis_comments) = self.vorbis_comments_tag { return VorbisCommentsRef { vendor: Cow::from(vorbis_comments.vendor.as_str()), items: vorbis_comments .items .iter() .map(|(k, v)| (k.as_str(), v.as_str())), pictures: vorbis_comments .pictures .iter() .map(|(p, i)| (p, *i)) .chain(self.pictures.iter().map(|(p, i)| (p, *i))), } .write_to(file, write_options); } // We have pictures, but no vorbis comments tag, we'll need to create a dummy one if !self.pictures.is_empty() { return VorbisCommentsRef { vendor: Cow::from(""), items: std::iter::empty(), pictures: self.pictures.iter().map(|(p, i)| (p, *i)), } .write_to(file, write_options); } Ok(()) } } impl OggPictureStorage for FlacFile { fn pictures(&self) -> &[(Picture, PictureInformation)] { &self.pictures } } impl From for TaggedFile { fn from(mut value: FlacFile) -> Self { TaggedFile { ty: FileType::Flac, properties: value.properties.into(), tags: { let mut tags = Vec::with_capacity(2); if let Some(id3v2) = value.id3v2_tag { tags.push(id3v2.into()); } // Move our pictures into a `VorbisComments` tag, creating one if necessary match value.vorbis_comments_tag { Some(mut vorbis_comments) => { vorbis_comments.pictures.append(&mut value.pictures); tags.push(vorbis_comments.into()); }, None if !value.pictures.is_empty() => tags.push( VorbisComments { vendor: String::new(), items: Vec::new(), pictures: value.pictures, } .into(), ), _ => {}, } tags }, } } } lofty-0.21.1/src/flac/properties.rs000064400000000000000000000055721046102023000153110ustar 00000000000000use crate::error::Result; use crate::properties::FileProperties; use std::io::Read; use std::time::Duration; use byteorder::{BigEndian, ReadBytesExt}; /// A FLAC file's audio properties #[derive(Copy, Clone, Debug, PartialEq, Eq, Default)] #[non_exhaustive] pub struct FlacProperties { pub(crate) duration: Duration, pub(crate) overall_bitrate: u32, pub(crate) audio_bitrate: u32, pub(crate) sample_rate: u32, pub(crate) bit_depth: u8, pub(crate) channels: u8, pub(crate) signature: u128, } impl From for FileProperties { fn from(input: FlacProperties) -> Self { Self { duration: input.duration, overall_bitrate: Some(input.overall_bitrate), audio_bitrate: Some(input.audio_bitrate), sample_rate: Some(input.sample_rate), bit_depth: Some(input.bit_depth), channels: Some(input.channels), channel_mask: None, } } } impl FlacProperties { /// Duration of the audio pub fn duration(&self) -> Duration { self.duration } /// Overall bitrate (kbps) pub fn overall_bitrate(&self) -> u32 { self.overall_bitrate } /// Audio bitrate (kbps) pub fn audio_bitrate(&self) -> u32 { self.audio_bitrate } /// Sample rate (Hz) pub fn sample_rate(&self) -> u32 { self.sample_rate } /// Bits per sample (usually 16 or 24 bit) pub fn bit_depth(&self) -> u8 { self.bit_depth } /// Channel count pub fn channels(&self) -> u8 { self.channels } /// MD5 signature of the unencoded audio data pub fn signature(&self) -> u128 { self.signature } } pub(crate) fn read_properties( stream_info: &mut R, stream_length: u64, file_length: u64, ) -> Result where R: Read, { // Skip 4 bytes // Minimum block size (2) // Maximum block size (2) stream_info.read_u32::()?; // Skip 6 bytes // Minimum frame size (3) // Maximum frame size (3) stream_info.read_uint::(6)?; // Read 4 bytes // Sample rate (20 bits) // Number of channels (3 bits) // Bits per sample (5 bits) // Total samples (first 4 bits) let info = stream_info.read_u32::()?; let sample_rate = info >> 12; let bits_per_sample = ((info >> 4) & 0b11111) + 1; let channels = ((info >> 9) & 7) + 1; // Read the remaining 32 bits of the total samples let total_samples = stream_info.read_u32::()? | (info << 28); let signature = stream_info.read_u128::()?; let mut properties = FlacProperties { sample_rate, bit_depth: bits_per_sample as u8, channels: channels as u8, signature, ..FlacProperties::default() }; if sample_rate > 0 && total_samples > 0 { let length = (u64::from(total_samples) * 1000) / u64::from(sample_rate); properties.duration = Duration::from_millis(length); if length > 0 && file_length > 0 && stream_length > 0 { properties.overall_bitrate = ((file_length * 8) / length) as u32; properties.audio_bitrate = ((stream_length * 8) / length) as u32; } } Ok(properties) } lofty-0.21.1/src/flac/read.rs000064400000000000000000000102341046102023000140170ustar 00000000000000use super::block::Block; use super::properties::FlacProperties; use super::FlacFile; use crate::config::{ParseOptions, ParsingMode}; use crate::error::Result; use crate::flac::block::{BLOCK_ID_PICTURE, BLOCK_ID_STREAMINFO, BLOCK_ID_VORBIS_COMMENTS}; use crate::id3::v2::read::parse_id3v2; use crate::id3::{find_id3v2, FindId3v2Config, ID3FindResults}; use crate::macros::{decode_err, err}; use crate::ogg::read::read_comments; use crate::picture::Picture; use std::io::{Read, Seek, SeekFrom}; pub(super) fn verify_flac(data: &mut R) -> Result where R: Read + Seek, { let mut marker = [0; 4]; data.read_exact(&mut marker)?; if &marker != b"fLaC" { decode_err!(@BAIL Flac, "File missing \"fLaC\" stream marker"); } let block = Block::read(data, |_| true)?; if block.ty != BLOCK_ID_STREAMINFO { decode_err!(@BAIL Flac, "File missing mandatory STREAMINFO block"); } log::debug!("File verified to be FLAC"); Ok(block) } pub(crate) fn read_from(data: &mut R, parse_options: ParseOptions) -> Result where R: Read + Seek, { let mut flac_file = FlacFile { id3v2_tag: None, vorbis_comments_tag: None, pictures: Vec::new(), properties: FlacProperties::default(), }; let find_id3v2_config = if parse_options.read_tags { FindId3v2Config::READ_TAG } else { FindId3v2Config::NO_READ_TAG }; // It is possible for a FLAC file to contain an ID3v2 tag if let ID3FindResults(Some(header), Some(content)) = find_id3v2(data, find_id3v2_config)? { log::warn!("Encountered an ID3v2 tag. This tag cannot be rewritten to the FLAC file!"); let reader = &mut &*content; let id3v2 = parse_id3v2(reader, header, parse_options)?; flac_file.id3v2_tag = Some(id3v2); } let stream_info = verify_flac(data)?; let stream_info_len = (stream_info.end - stream_info.start) as u32; if stream_info_len < 18 { decode_err!(@BAIL Flac, "File has an invalid STREAMINFO block size (< 18)"); } let mut last_block = stream_info.last; while !last_block { let block = Block::read(data, |block_type| { (block_type == BLOCK_ID_VORBIS_COMMENTS && parse_options.read_tags) || (block_type == BLOCK_ID_PICTURE && parse_options.read_cover_art) })?; last_block = block.last; if block.content.is_empty() { continue; } if block.ty == BLOCK_ID_VORBIS_COMMENTS && parse_options.read_tags { log::debug!("Encountered a Vorbis Comments block, parsing"); // NOTE: According to the spec // // : // "There may be only one VORBIS_COMMENT block in a stream." // // But of course, we can't ever expect any spec compliant inputs, so we just // take whatever happens to be the latest block in the stream. This is safe behavior, // as when writing to a file with multiple tags, we end up removing all `VORBIS_COMMENT` // blocks anyway. if flac_file.vorbis_comments_tag.is_some() && parse_options.parsing_mode == ParsingMode::Strict { decode_err!(@BAIL Flac, "Streams are only allowed one Vorbis Comments block per stream"); } let vorbis_comments = read_comments( &mut &*block.content, block.content.len() as u64, parse_options, )?; flac_file.vorbis_comments_tag = Some(vorbis_comments); continue; } if block.ty == BLOCK_ID_PICTURE && parse_options.read_cover_art { log::debug!("Encountered a FLAC picture block, parsing"); match Picture::from_flac_bytes(&block.content, false, parse_options.parsing_mode) { Ok(picture) => flac_file.pictures.push(picture), Err(e) => { if parse_options.parsing_mode == ParsingMode::Strict { return Err(e); } log::warn!("Unable to read FLAC picture block, discarding"); continue; }, } } } if !parse_options.read_properties { return Ok(flac_file); } let (stream_length, file_length) = { let current = data.stream_position()?; let end = data.seek(SeekFrom::End(0))?; // In the event that a block lies about its size, the current position could be // completely wrong. if current > end { err!(SizeMismatch); } (end - current, end) }; flac_file.properties = super::properties::read_properties(&mut &*stream_info.content, stream_length, file_length)?; Ok(flac_file) } lofty-0.21.1/src/flac/write.rs000064400000000000000000000145171046102023000142460ustar 00000000000000use super::block::{Block, BLOCK_ID_PADDING, BLOCK_ID_PICTURE, BLOCK_ID_VORBIS_COMMENTS}; use super::read::verify_flac; use crate::config::WriteOptions; use crate::error::{LoftyError, Result}; use crate::macros::{err, try_vec}; use crate::ogg::tag::VorbisCommentsRef; use crate::ogg::write::create_comments; use crate::picture::{Picture, PictureInformation}; use crate::tag::{Tag, TagType}; use crate::util::io::{FileLike, Length, Truncate}; use std::borrow::Cow; use std::io::{Cursor, Read, Seek, SeekFrom, Write}; use byteorder::{LittleEndian, ReadBytesExt, WriteBytesExt}; const BLOCK_HEADER_SIZE: usize = 4; const MAX_BLOCK_SIZE: u32 = 16_777_215; pub(crate) fn write_to(file: &mut F, tag: &Tag, write_options: WriteOptions) -> Result<()> where F: FileLike, LoftyError: From<::Error>, LoftyError: From<::Error>, { match tag.tag_type() { TagType::VorbisComments => { let (vendor, items, pictures) = crate::ogg::tag::create_vorbis_comments_ref(tag); let mut comments_ref = VorbisCommentsRef { vendor: Cow::from(vendor), items, pictures, }; write_to_inner(file, &mut comments_ref, write_options) }, // This tag can *only* be removed in this format TagType::Id3v2 => crate::id3::v2::tag::Id3v2TagRef::empty().write_to(file, write_options), _ => err!(UnsupportedTag), } } pub(crate) fn write_to_inner<'a, F, II, IP>( file: &mut F, tag: &mut VorbisCommentsRef<'a, II, IP>, write_options: WriteOptions, ) -> Result<()> where F: FileLike, LoftyError: From<::Error>, II: Iterator, IP: Iterator, { let stream_info = verify_flac(file)?; let mut last_block = stream_info.last; let mut file_bytes = Vec::new(); file.read_to_end(&mut file_bytes)?; let mut cursor = Cursor::new(file_bytes); // TODO: We need to actually use padding (https://github.com/Serial-ATA/lofty-rs/issues/445) let mut end_padding_exists = false; let mut last_block_info = ( stream_info.byte, stream_info.start as usize, stream_info.end as usize, ); let mut blocks_to_remove = Vec::new(); while !last_block { let block = Block::read(&mut cursor, |block_ty| block_ty == BLOCK_ID_VORBIS_COMMENTS)?; let start = block.start; let end = block.end; let block_type = block.ty; last_block = block.last; if last_block { last_block_info = (block.byte, (end - start) as usize, end as usize) } match block_type { BLOCK_ID_VORBIS_COMMENTS => { blocks_to_remove.push((start, end)); // Retain the original vendor string let reader = &mut &block.content[..]; let vendor_len = reader.read_u32::()?; let mut vendor = try_vec![0; vendor_len as usize]; reader.read_exact(&mut vendor)?; // TODO: Error on strict? let Ok(vendor_str) = String::from_utf8(vendor) else { log::warn!("FLAC vendor string is not valid UTF-8, not re-using"); tag.vendor = Cow::Borrowed(""); continue; }; tag.vendor = Cow::Owned(vendor_str); }, BLOCK_ID_PICTURE => blocks_to_remove.push((start, end)), BLOCK_ID_PADDING => { if last_block { end_padding_exists = true } else { blocks_to_remove.push((start, end)) } }, _ => {}, } } let mut file_bytes = cursor.into_inner(); if !end_padding_exists { if let Some(preferred_padding) = write_options.preferred_padding { log::warn!("File is missing a PADDING block. Adding one"); let mut first_byte = 0_u8; first_byte |= last_block_info.0 & 0x7F; file_bytes[last_block_info.1] = first_byte; let block_size = core::cmp::min(preferred_padding, MAX_BLOCK_SIZE); let mut padding_block = try_vec![0; BLOCK_HEADER_SIZE + block_size as usize]; let mut padding_byte = 0; padding_byte |= 0x80; padding_byte |= 1 & 0x7F; padding_block[0] = padding_byte; padding_block[1..4].copy_from_slice(&block_size.to_be_bytes()[1..]); file_bytes.splice(last_block_info.2..last_block_info.2, padding_block); } } let mut comment_blocks = Cursor::new(Vec::new()); create_comment_block(&mut comment_blocks, &tag.vendor, &mut tag.items)?; let mut comment_blocks = comment_blocks.into_inner(); create_picture_blocks(&mut comment_blocks, &mut tag.pictures)?; if blocks_to_remove.is_empty() { file_bytes.splice(0..0, comment_blocks); } else { blocks_to_remove.sort_unstable(); blocks_to_remove.reverse(); let first = blocks_to_remove.pop().unwrap(); // Infallible for (s, e) in &blocks_to_remove { file_bytes.drain(*s as usize..*e as usize); } file_bytes.splice(first.0 as usize..first.1 as usize, comment_blocks); } file.seek(SeekFrom::Start(stream_info.end))?; file.truncate(stream_info.end)?; file.write_all(&file_bytes)?; Ok(()) } fn create_comment_block( writer: &mut Cursor>, vendor: &str, items: &mut dyn Iterator, ) -> Result<()> { let mut peek = items.peekable(); if peek.peek().is_some() { let mut byte = 0_u8; byte |= 4 & 0x7F; writer.write_u8(byte)?; writer.write_u32::(vendor.len() as u32)?; writer.write_all(vendor.as_bytes())?; let item_count_pos = writer.stream_position()?; let mut count = 0; writer.write_u32::(count)?; create_comments(writer, &mut count, &mut peek)?; let len = (writer.get_ref().len() - 1) as u32; if len > MAX_BLOCK_SIZE { err!(TooMuchData); } let comment_end = writer.stream_position()?; writer.seek(SeekFrom::Start(item_count_pos))?; writer.write_u32::(count)?; writer.seek(SeekFrom::Start(comment_end))?; writer .get_mut() .splice(1..1, len.to_be_bytes()[1..].to_vec()); // size = block type + vendor length + vendor + item count + items log::trace!( "Wrote a comment block, size: {}", 1 + 4 + vendor.len() + 4 + (len as usize) ); } Ok(()) } fn create_picture_blocks( writer: &mut Vec, pictures: &mut dyn Iterator, ) -> Result<()> { let mut byte = 0_u8; byte |= 6 & 0x7F; for (pic, info) in pictures { writer.write_u8(byte)?; let pic_bytes = pic.as_flac_bytes(info, false); let pic_len = pic_bytes.len() as u32; if pic_len > MAX_BLOCK_SIZE { err!(TooMuchData); } writer.write_all(&pic_len.to_be_bytes()[1..])?; writer.write_all(pic_bytes.as_slice())?; // size = block type + block length + data log::trace!("Wrote a picture block, size: {}", 1 + 3 + pic_len); } Ok(()) } lofty-0.21.1/src/id3/mod.rs000064400000000000000000000103301046102023000134320ustar 00000000000000//! ID3 specific items //! //! ID3 does things differently than other tags, making working with them a little more effort than other formats. //! Check the other modules for important notes and/or warnings. pub mod v1; pub mod v2; use crate::error::{ErrorKind, LoftyError, Result}; use crate::macros::try_vec; use crate::util::text::utf8_decode_str; use v2::header::Id3v2Header; use std::io::{Read, Seek, SeekFrom}; use std::ops::Neg; pub(crate) struct ID3FindResults(pub Option

, pub Content); pub(crate) fn find_lyrics3v2(data: &mut R) -> Result> where R: Read + Seek, { log::debug!("Searching for a Lyrics3v2 tag"); let mut header = None; let mut size = 0_u32; data.seek(SeekFrom::Current(-15))?; let mut lyrics3v2 = [0; 15]; data.read_exact(&mut lyrics3v2)?; if &lyrics3v2[7..] == b"LYRICS200" { log::warn!("Encountered a Lyrics3v2 tag. This is an outdated format, and will be skipped."); header = Some(()); let lyrics_size = utf8_decode_str(&lyrics3v2[..7])?; let lyrics_size = lyrics_size.parse::().map_err(|_| { LoftyError::new(ErrorKind::TextDecode( "Lyrics3v2 tag has an invalid size string", )) })?; size += lyrics_size; data.seek(SeekFrom::Current(i64::from(lyrics_size + 15).neg()))?; } Ok(ID3FindResults(header, size)) } #[allow(unused_variables)] pub(crate) fn find_id3v1( data: &mut R, read: bool, ) -> Result>> where R: Read + Seek, { log::debug!("Searching for an ID3v1 tag"); let mut id3v1 = None; let mut header = None; // Reader is too small to contain an ID3v2 tag if data.seek(SeekFrom::End(-128)).is_err() { data.seek(SeekFrom::End(0))?; return Ok(ID3FindResults(header, id3v1)); } let mut id3v1_header = [0; 3]; data.read_exact(&mut id3v1_header)?; data.seek(SeekFrom::Current(-3))?; // No ID3v1 tag found if &id3v1_header != b"TAG" { data.seek(SeekFrom::End(0))?; return Ok(ID3FindResults(header, id3v1)); } log::debug!("Found an ID3v1 tag, parsing"); header = Some(()); if read { let mut id3v1_tag = [0; 128]; data.read_exact(&mut id3v1_tag)?; data.seek(SeekFrom::End(-128))?; id3v1 = Some(v1::read::parse_id3v1(id3v1_tag)) } Ok(ID3FindResults(header, id3v1)) } #[derive(Copy, Clone, Debug)] pub(crate) struct FindId3v2Config { pub(crate) read: bool, pub(crate) allowed_junk_window: Option, } impl FindId3v2Config { pub(crate) const NO_READ_TAG: Self = Self { read: false, allowed_junk_window: None, }; pub(crate) const READ_TAG: Self = Self { read: true, allowed_junk_window: None, }; } pub(crate) fn find_id3v2( data: &mut R, config: FindId3v2Config, ) -> Result>>> where R: Read + Seek, { log::debug!( "Searching for an ID3v2 tag at offset: {}", data.stream_position()? ); let mut header = None; let mut id3v2 = None; if let Some(junk_window) = config.allowed_junk_window { let mut id3v2_search_window = data.by_ref().take(junk_window); let Some(id3v2_offset) = find_id3v2_in_junk(&mut id3v2_search_window)? else { return Ok(ID3FindResults(None, None)); }; log::warn!( "Found an ID3v2 tag preceded by junk data, offset: {}", id3v2_offset ); data.seek(SeekFrom::Current(-3))?; } if let Ok(id3v2_header) = Id3v2Header::parse(data) { log::debug!("Found an ID3v2 tag, parsing"); if config.read { let mut tag = try_vec![0; id3v2_header.size as usize]; data.read_exact(&mut tag)?; id3v2 = Some(tag) } else { data.seek(SeekFrom::Current(i64::from(id3v2_header.size)))?; } if id3v2_header.flags.footer { data.seek(SeekFrom::Current(10))?; } header = Some(id3v2_header); } else { data.seek(SeekFrom::Current(-10))?; } Ok(ID3FindResults(header, id3v2)) } /// Searches for an ID3v2 tag in (potential) junk data between the start /// of the file and the first frame fn find_id3v2_in_junk(reader: &mut R) -> Result> where R: Read, { let bytes = reader.bytes(); let mut id3v2_header = [0; 3]; for (index, byte) in bytes.enumerate() { id3v2_header[0] = id3v2_header[1]; id3v2_header[1] = id3v2_header[2]; id3v2_header[2] = byte?; if id3v2_header == *b"ID3" { return Ok(Some((index - 2) as u64)); } } Ok(None) } lofty-0.21.1/src/id3/v1/constants.rs000064400000000000000000000054361046102023000152300ustar 00000000000000/// All possible genres for ID3v1 pub const GENRES: [&str; 192] = [ "Blues", "Classic rock", "Country", "Dance", "Disco", "Funk", "Grunge", "Hip-Hop", "Jazz", "Metal", "New Age", "Oldies", "Other", "Pop", "R&B", "Rap", "Reggae", "Rock", "Techno", "Industrial", "Alternative", "Ska", "Death metal", "Pranks", "Soundtrack", "Euro-Techno", "Ambient", "Trip-Hop", "Vocal", "Jazz & Funk", "Fusion", "Trance", "Classical", "Instrumental", "Acid", "House", "Game", "Sound clip", "Gospel", "Noise", "Alternative Rock", "Bass", "Soul", "Punk", "Space", "Meditative", "Instrumental Pop", "Instrumental Rock", "Ethnic", "Gothic", "Darkwave", "Techno-Industrial", "Electronic", "Pop-Folk", "Eurodance", "Dream", "Southern Rock", "Comedy", "Cult", "Gangsta", "Top 40", "Christian Rap", "Pop/Funk", "Jungle", "Native US", "Cabaret", "New Wave", "Psychedelic", "Rave", "Show tunes", "Trailer", "Lo-Fi", "Tribal", "Acid Punk", "Acid Jazz", "Polka", "Retro", "Musical", "Rock \u{2019}n\u{2019} Roll", "Hard Rock", "Folk", "Folk-Rock", "National Folk", "Swing", "Fast Fusion", "Bebop", "Latin", "Revival", "Celtic", "Bluegrass", "Avantgarde", "Gothic Rock", "Progressive Rock", "Psychedelic Rock", "Symphonic Rock", "Slow rock", "Big Band", "Chorus", "Easy Listening", "Acoustic", "Humour", "Speech", "Chanson", "Opera", "Chamber music", "Sonata", "Symphony", "Booty bass", "Primus", "Porn groove", "Satire", "Slow jam", "Club", "Tango", "Samba", "Folklore", "Ballad", "Power Ballad", "Rhythmic Soul", "Freestyle", "Duet", "Punk Rock", "Drum Solo", "A cappella", "Euro-House", "Dance Hall", "Goa music", "Drum & Bass", "Club-House", "Hardcore Techno", "Terror", "Indie", "BritPop", "Negerpunk", "Polsk Punk", "Beat", "Christian Gangsta Rap", "Heavy Metal", "Black Metal", "Crossover", "Contemporary Christian", "Christian rock", "Merengue", "Salsa", "Thrash Metal", "Anime", "Jpop", "Synthpop", "Abstract", "Art Rock", "Baroque", "Bhangra", "Big beat", "Breakbeat", "Chillout", "Downtempo", "Dub", "EBM", "Eclectic", "Electro", "Electroclash", "Emo", "Experimental", "Garage", "Global", "IDM", "Illbient", "Industro-Goth", "Jam Band", "Krautrock", "Leftfield", "Lounge", "Math Rock", "New Romantic", "Nu-Breakz", "Post-Punk", "Post-Rock", "Psytrance", "Shoegaze", "Space Rock", "Trop Rock", "World Music", "Neoclassical", "Audiobook", "Audio theatre", "Neue Deutsche Welle", "Podcast", "Indie-Rock", "G-Funk", "Dubstep", "Garage Rock", "Psybient", ]; use crate::tag::ItemKey; pub(crate) const VALID_ITEMKEYS: [ItemKey; 7] = [ ItemKey::TrackTitle, ItemKey::TrackArtist, ItemKey::AlbumTitle, ItemKey::Year, ItemKey::Comment, ItemKey::TrackNumber, ItemKey::Genre, ]; lofty-0.21.1/src/id3/v1/mod.rs000064400000000000000000000011061046102023000137610ustar 00000000000000//! ID3v1 items //! //! # ID3v1 notes //! //! See also: [`Id3v1Tag`] //! //! ## Genres //! //! ID3v1 stores the genre in a single byte ranging from 0 to 192 (inclusive). //! All possible genres have been stored in the [`GENRES`] constant. //! //! ## Track Numbers //! //! ID3v1 stores the track number in a non-zero byte. //! A track number of 0 will be treated as an empty field. //! Additionally, there is no track total field. pub(crate) mod constants; pub(crate) mod read; pub(crate) mod tag; pub(crate) mod write; // Exports pub use constants::GENRES; pub use tag::Id3v1Tag; lofty-0.21.1/src/id3/v1/read.rs000064400000000000000000000021531046102023000141200ustar 00000000000000use super::constants::GENRES; use super::tag::Id3v1Tag; pub fn parse_id3v1(reader: [u8; 128]) -> Id3v1Tag { let mut tag = Id3v1Tag { title: None, artist: None, album: None, year: None, comment: None, track_number: None, genre: None, }; let reader = &reader[3..]; tag.title = decode_text(&reader[..30]); tag.artist = decode_text(&reader[30..60]); tag.album = decode_text(&reader[60..90]); tag.year = decode_text(&reader[90..94]); // Determine the range of the comment (30 bytes for ID3v1 and 28 for ID3v1.1) // We check for the null terminator 28 bytes in, and for a non-zero track number after it. // A track number of 0 is invalid. let range = if reader[122] == 0 && reader[123] != 0 { tag.track_number = Some(reader[123]); 94_usize..123 } else { 94..124 }; tag.comment = decode_text(&reader[range]); if reader[124] < GENRES.len() as u8 { tag.genre = Some(reader[124]); } tag } fn decode_text(data: &[u8]) -> Option { let read = data .iter() .filter(|c| **c != 0) .map(|c| *c as char) .collect::(); if read.is_empty() { None } else { Some(read) } } lofty-0.21.1/src/id3/v1/tag.rs000064400000000000000000000311271046102023000137630ustar 00000000000000use crate::config::WriteOptions; use crate::error::{LoftyError, Result}; use crate::id3::v1::constants::GENRES; use crate::tag::{Accessor, ItemKey, ItemValue, MergeTag, SplitTag, Tag, TagExt, TagItem, TagType}; use crate::util::io::{FileLike, Length, Truncate}; use std::borrow::Cow; use std::io::Write; use std::path::Path; use lofty_attr::tag; macro_rules! impl_accessor { ($($name:ident,)+) => { paste::paste! { $( fn $name(&self) -> Option> { if let Some(item) = self.$name.as_deref() { return Some(Cow::Borrowed(item)); } None } fn [](&mut self, value: String) { self.$name = Some(value) } fn [](&mut self) { self.$name = None } )+ } } } /// ID3v1 is a severely limited format, with each field /// being incredibly small in size. All fields have been /// commented with their maximum sizes and any other additional /// restrictions. /// /// Attempting to write a field greater than the maximum size /// will **not** error, it will just be shrunk. /// /// ## Conversions /// /// ### To `Tag` /// /// All fields can be translated to a `TagItem`: /// /// * `title` -> [`ItemKey::TrackTitle`] /// * `artist` -> [`ItemKey::TrackArtist`] /// * `album` -> [`ItemKey::AlbumTitle`] /// * `year` -> [`ItemKey::Year`] /// * `comment` -> [`ItemKey::Comment`] /// * `track_number` -> [`ItemKey::TrackNumber`] /// * `genre` -> [`ItemKey::Genre`] (As long as the genre is a valid index into [`GENRES`]) /// /// /// ### From `Tag` /// /// All of the [`ItemKey`]s referenced in the conversion to [`Tag`] will be checked. /// /// The values will be used as-is, with two exceptions: /// /// * [`ItemKey::TrackNumber`] - Will only be used if the value can be parsed as a `u8` /// * [`ItemKey::Genre`] - Will only be used if: /// /// [`GENRES`] contains the string **OR** The [`ItemValue`](crate::ItemValue) can be parsed into /// a `u8` ***and*** it is a valid index into [`GENRES`] #[derive(Default, Debug, PartialEq, Eq, Clone)] #[tag( description = "An ID3v1 tag", supported_formats(Aac, Ape, Mpeg, WavPack, read_only(Mpc)) )] pub struct Id3v1Tag { /// Track title, 30 bytes max pub title: Option, /// Track artist, 30 bytes max pub artist: Option, /// Album title, 30 bytes max pub album: Option, /// Release year, 4 bytes max pub year: Option, /// A short comment /// /// The number of bytes differs between versions, but not much. /// A V1 tag may have been read, which limits this field to 30 bytes. /// A V1.1 tag, however, only has 28 bytes available. /// /// **Lofty** will *always* write a V1.1 tag. pub comment: Option, /// The track number, 1 byte max /// /// Issues: /// /// * The track number **cannot** be 0. Many readers, including Lofty, /// look for a null byte at the end of the comment to differentiate /// between V1 and V1.1. /// * A V1 tag may have been read, which does *not* have a track number. pub track_number: Option, /// The track's genre, 1 byte max /// /// ID3v1 has a predefined set of genres, see [`GENRES`](crate::id3::v1::GENRES). /// This byte should be an index to a genre. pub genre: Option, } impl Id3v1Tag { /// Create a new empty `ID3v1Tag` /// /// # Examples /// /// ```rust /// use lofty::id3::v1::Id3v1Tag; /// use lofty::tag::TagExt; /// /// let id3v1_tag = Id3v1Tag::new(); /// assert!(id3v1_tag.is_empty()); /// ``` pub fn new() -> Self { Self::default() } } impl Accessor for Id3v1Tag { impl_accessor!(title, artist, album,); fn genre(&self) -> Option> { if let Some(g) = self.genre { let g = g as usize; if g < GENRES.len() { return Some(Cow::Borrowed(GENRES[g])); } } None } fn set_genre(&mut self, genre: String) { let g_str = genre.as_str(); for (i, g) in GENRES.iter().enumerate() { if g.eq_ignore_ascii_case(g_str) { self.genre = Some(i as u8); break; } } } fn remove_genre(&mut self) { self.genre = None } fn track(&self) -> Option { self.track_number.map(u32::from) } fn set_track(&mut self, value: u32) { self.track_number = Some(value as u8); } fn remove_track(&mut self) { self.track_number = None; } fn comment(&self) -> Option> { self.comment.as_deref().map(Cow::Borrowed) } fn set_comment(&mut self, value: String) { let mut resized = String::with_capacity(28); for c in value.chars() { if resized.len() + c.len_utf8() > 28 { break; } resized.push(c); } self.comment = Some(resized); } fn remove_comment(&mut self) { self.comment = None; } fn year(&self) -> Option { if let Some(ref year) = self.year { if let Ok(y) = year.parse() { return Some(y); } } None } fn set_year(&mut self, value: u32) { self.year = Some(value.to_string()); } fn remove_year(&mut self) { self.year = None; } } impl TagExt for Id3v1Tag { type Err = LoftyError; type RefKey<'a> = &'a ItemKey; #[inline] fn tag_type(&self) -> TagType { TagType::Id3v1 } fn len(&self) -> usize { usize::from(self.title.is_some()) + usize::from(self.artist.is_some()) + usize::from(self.album.is_some()) + usize::from(self.year.is_some()) + usize::from(self.comment.is_some()) + usize::from(self.track_number.is_some()) + usize::from(self.genre.is_some()) } fn contains<'a>(&'a self, key: Self::RefKey<'a>) -> bool { match key { ItemKey::TrackTitle => self.title.is_some(), ItemKey::AlbumTitle => self.album.is_some(), ItemKey::TrackArtist => self.artist.is_some(), ItemKey::TrackNumber => self.track_number.is_some(), ItemKey::Year => self.year.is_some(), ItemKey::Genre => self.genre.is_some(), ItemKey::Comment => self.comment.is_some(), _ => false, } } fn is_empty(&self) -> bool { self.title.is_none() && self.artist.is_none() && self.album.is_none() && self.year.is_none() && self.comment.is_none() && self.track_number.is_none() && self.genre.is_none() } fn save_to( &self, file: &mut F, write_options: WriteOptions, ) -> std::result::Result<(), Self::Err> where F: FileLike, LoftyError: From<::Error>, LoftyError: From<::Error>, { Into::>::into(self).write_to(file, write_options) } /// Dumps the tag to a writer /// /// # Errors /// /// * [`std::io::Error`] fn dump_to( &self, writer: &mut W, write_options: WriteOptions, ) -> std::result::Result<(), Self::Err> { Into::>::into(self).dump_to(writer, write_options) } fn remove_from_path>(&self, path: P) -> std::result::Result<(), Self::Err> { TagType::Id3v1.remove_from_path(path) } fn remove_from(&self, file: &mut F) -> std::result::Result<(), Self::Err> where F: FileLike, LoftyError: From<::Error>, LoftyError: From<::Error>, { TagType::Id3v1.remove_from(file) } fn clear(&mut self) { *self = Self::default(); } } #[derive(Debug, Clone, Default)] pub struct SplitTagRemainder; impl SplitTag for Id3v1Tag { type Remainder = SplitTagRemainder; fn split_tag(mut self) -> (Self::Remainder, Tag) { let mut tag = Tag::new(TagType::Id3v1); self.title .take() .map(|t| tag.insert_text(ItemKey::TrackTitle, t)); self.artist .take() .map(|a| tag.insert_text(ItemKey::TrackArtist, a)); self.album .take() .map(|a| tag.insert_text(ItemKey::AlbumTitle, a)); self.year.take().map(|y| tag.insert_text(ItemKey::Year, y)); self.comment .take() .map(|c| tag.insert_text(ItemKey::Comment, c)); if let Some(t) = self.track_number.take() { tag.items.push(TagItem::new( ItemKey::TrackNumber, ItemValue::Text(t.to_string()), )) } if let Some(genre_index) = self.genre.take() { if let Some(genre) = GENRES.get(genre_index as usize) { tag.insert_text(ItemKey::Genre, (*genre).to_string()); } } (SplitTagRemainder, tag) } } impl MergeTag for SplitTagRemainder { type Merged = Id3v1Tag; fn merge_tag(self, tag: Tag) -> Self::Merged { tag.into() } } impl From for Tag { fn from(input: Id3v1Tag) -> Self { input.split_tag().1 } } impl From for Id3v1Tag { fn from(mut input: Tag) -> Self { let title = input.take_strings(&ItemKey::TrackTitle).next(); let artist = input.take_strings(&ItemKey::TrackArtist).next(); let album = input.take_strings(&ItemKey::AlbumTitle).next(); let year = input.year().map(|y| y.to_string()); let comment = input.take_strings(&ItemKey::Comment).next(); Self { title, artist, album, year, comment, track_number: input .get_string(&ItemKey::TrackNumber) .map(|g| g.parse::().ok()) .and_then(|g| g), genre: input .get_string(&ItemKey::Genre) .map(|g| { GENRES .iter() .position(|v| v == &g) .map_or_else(|| g.parse::().ok(), |p| Some(p as u8)) }) .and_then(|g| g), } } } pub(crate) struct Id3v1TagRef<'a> { pub title: Option<&'a str>, pub artist: Option<&'a str>, pub album: Option<&'a str>, pub year: Option<&'a str>, pub comment: Option<&'a str>, pub track_number: Option, pub genre: Option, } impl<'a> Into> for &'a Id3v1Tag { fn into(self) -> Id3v1TagRef<'a> { Id3v1TagRef { title: self.title.as_deref(), artist: self.artist.as_deref(), album: self.album.as_deref(), year: self.year.as_deref(), comment: self.comment.as_deref(), track_number: self.track_number, genre: self.genre, } } } impl<'a> Into> for &'a Tag { fn into(self) -> Id3v1TagRef<'a> { Id3v1TagRef { title: self.get_string(&ItemKey::TrackTitle), artist: self.get_string(&ItemKey::TrackArtist), album: self.get_string(&ItemKey::AlbumTitle), year: self.get_string(&ItemKey::Year), comment: self.get_string(&ItemKey::Comment), track_number: self .get_string(&ItemKey::TrackNumber) .map(|g| g.parse::().ok()) .and_then(|g| g), genre: self .get_string(&ItemKey::Genre) .map(|g| { GENRES .iter() .position(|v| v == &g) .map_or_else(|| g.parse::().ok(), |p| Some(p as u8)) }) .and_then(|g| g), } } } impl<'a> Id3v1TagRef<'a> { pub(super) fn is_empty(&self) -> bool { self.title.is_none() && self.artist.is_none() && self.album.is_none() && self.year.is_none() && self.comment.is_none() && self.track_number.is_none() && self.genre.is_none() } pub(crate) fn write_to(&self, file: &mut F, write_options: WriteOptions) -> Result<()> where F: FileLike, LoftyError: From<::Error>, LoftyError: From<::Error>, { super::write::write_id3v1(file, self, write_options) } pub(crate) fn dump_to( &mut self, writer: &mut W, _write_options: WriteOptions, ) -> Result<()> { let temp = super::write::encode(self)?; writer.write_all(&temp)?; Ok(()) } } #[cfg(test)] mod tests { use crate::config::WriteOptions; use crate::id3::v1::Id3v1Tag; use crate::prelude::*; use crate::tag::{Tag, TagType}; #[test] fn parse_id3v1() { let expected_tag = Id3v1Tag { title: Some(String::from("Foo title")), artist: Some(String::from("Bar artist")), album: Some(String::from("Baz album")), year: Some(String::from("1984")), comment: Some(String::from("Qux comment")), track_number: Some(1), genre: Some(32), }; let tag = crate::tag::utils::test_utils::read_path("tests/tags/assets/test.id3v1"); let parsed_tag = crate::id3::v1::read::parse_id3v1(tag.try_into().unwrap()); assert_eq!(expected_tag, parsed_tag); } #[test] fn id3v2_re_read() { let tag = crate::tag::utils::test_utils::read_path("tests/tags/assets/test.id3v1"); let parsed_tag = crate::id3::v1::read::parse_id3v1(tag.try_into().unwrap()); let mut writer = Vec::new(); parsed_tag .dump_to(&mut writer, WriteOptions::default()) .unwrap(); let temp_parsed_tag = crate::id3::v1::read::parse_id3v1(writer.try_into().unwrap()); assert_eq!(parsed_tag, temp_parsed_tag); } #[test] fn id3v1_to_tag() { let tag_bytes = crate::tag::utils::test_utils::read_path("tests/tags/assets/test.id3v1"); let id3v1 = crate::id3::v1::read::parse_id3v1(tag_bytes.try_into().unwrap()); let tag: Tag = id3v1.into(); crate::tag::utils::test_utils::verify_tag(&tag, true, true); } #[test] fn tag_to_id3v1() { let tag = crate::tag::utils::test_utils::create_tag(TagType::Id3v1); let id3v1_tag: Id3v1Tag = tag.into(); assert_eq!(id3v1_tag.title.as_deref(), Some("Foo title")); assert_eq!(id3v1_tag.artist.as_deref(), Some("Bar artist")); assert_eq!(id3v1_tag.album.as_deref(), Some("Baz album")); assert_eq!(id3v1_tag.comment.as_deref(), Some("Qux comment")); assert_eq!(id3v1_tag.track_number, Some(1)); assert_eq!(id3v1_tag.genre, Some(32)); } } lofty-0.21.1/src/id3/v1/write.rs000064400000000000000000000042251046102023000143410ustar 00000000000000use super::tag::Id3v1TagRef; use crate::config::WriteOptions; use crate::error::{LoftyError, Result}; use crate::id3::{find_id3v1, ID3FindResults}; use crate::macros::err; use crate::probe::Probe; use crate::util::io::{FileLike, Length, Truncate}; use std::io::{Cursor, Seek, Write}; use byteorder::WriteBytesExt; #[allow(clippy::shadow_unrelated)] pub(crate) fn write_id3v1( file: &mut F, tag: &Id3v1TagRef<'_>, _write_options: WriteOptions, ) -> Result<()> where F: FileLike, LoftyError: From<::Error>, LoftyError: From<::Error>, { let probe = Probe::new(file).guess_file_type()?; match probe.file_type() { Some(ft) if super::Id3v1Tag::SUPPORTED_FORMATS.contains(&ft) => {}, _ => err!(UnsupportedTag), } let file = probe.into_inner(); // This will seek us to the writing position let ID3FindResults(header, _) = find_id3v1(file, false)?; if tag.is_empty() && header.is_some() { // An ID3v1 tag occupies the last 128 bytes of the file, so we can just // shrink it down. let new_length = file.len()?.saturating_sub(128); file.truncate(new_length)?; return Ok(()); } let tag = encode(tag)?; file.write_all(&tag)?; Ok(()) } pub(super) fn encode(tag: &Id3v1TagRef<'_>) -> std::io::Result> { fn resize_string(value: Option<&str>, size: usize) -> std::io::Result> { let mut cursor = Cursor::new(vec![0; size]); cursor.rewind()?; if let Some(val) = value { if val.len() > size { cursor.write_all(val.split_at(size).0.as_bytes())?; } else { cursor.write_all(val.as_bytes())?; } } Ok(cursor.into_inner()) } let mut writer = Vec::with_capacity(128); writer.write_all(b"TAG")?; let title = resize_string(tag.title, 30)?; writer.write_all(&title)?; let artist = resize_string(tag.artist, 30)?; writer.write_all(&artist)?; let album = resize_string(tag.album, 30)?; writer.write_all(&album)?; let year = resize_string(tag.year, 4)?; writer.write_all(&year)?; let comment = resize_string(tag.comment, 28)?; writer.write_all(&comment)?; writer.write_u8(0)?; writer.write_u8(tag.track_number.unwrap_or(0))?; writer.write_u8(tag.genre.unwrap_or(255))?; Ok(writer) } lofty-0.21.1/src/id3/v2/frame/content.rs000064400000000000000000000057621046102023000157630ustar 00000000000000use crate::config::ParsingMode; use crate::error::{Id3v2Error, Id3v2ErrorKind, Result}; use crate::id3::v2::header::Id3v2Version; use crate::id3::v2::items::{ AttachedPictureFrame, CommentFrame, EventTimingCodesFrame, ExtendedTextFrame, ExtendedUrlFrame, KeyValueFrame, OwnershipFrame, PopularimeterFrame, PrivateFrame, RelativeVolumeAdjustmentFrame, TextInformationFrame, TimestampFrame, UniqueFileIdentifierFrame, UnsynchronizedTextFrame, UrlLinkFrame, }; use crate::id3::v2::{BinaryFrame, Frame, FrameFlags, FrameId}; use crate::macros::err; use crate::util::text::TextEncoding; use std::io::Read; #[rustfmt::skip] pub(super) fn parse_content( reader: &mut R, id: FrameId<'static>, flags: FrameFlags, version: Id3v2Version, parse_mode: ParsingMode, ) -> Result>> { Ok(match id.as_str() { // The ID was previously upgraded, but the content remains unchanged, so version is necessary "APIC" => { Some(Frame::Picture(AttachedPictureFrame::parse(reader, flags, version)?)) }, "TXXX" => ExtendedTextFrame::parse(reader, flags, version)?.map(Frame::UserText), "WXXX" => ExtendedUrlFrame::parse(reader, flags, version)?.map(Frame::UserUrl), "COMM" => CommentFrame::parse(reader, flags, version)?.map(Frame::Comment), "USLT" => UnsynchronizedTextFrame::parse(reader, flags, version)?.map(Frame::UnsynchronizedText), "TIPL" | "TMCL" => KeyValueFrame::parse(reader, id, flags, version)?.map(Frame::KeyValue), "UFID" => UniqueFileIdentifierFrame::parse(reader, flags, parse_mode)?.map(Frame::UniqueFileIdentifier), "RVA2" => RelativeVolumeAdjustmentFrame::parse(reader, flags, parse_mode)?.map(Frame::RelativeVolumeAdjustment), "OWNE" => OwnershipFrame::parse(reader, flags)?.map(Frame::Ownership), "ETCO" => EventTimingCodesFrame::parse(reader, flags)?.map(Frame::EventTimingCodes), "PRIV" => PrivateFrame::parse(reader, flags)?.map(Frame::Private), "TDEN" | "TDOR" | "TDRC" | "TDRL" | "TDTG" => TimestampFrame::parse(reader, id, flags, parse_mode)?.map(Frame::Timestamp), i if i.starts_with('T') => TextInformationFrame::parse(reader, id, flags, version)?.map(Frame::Text), // Apple proprietary frames // WFED (Podcast URL), GRP1 (Grouping), MVNM (Movement Name), MVIN (Movement Number) "WFED" | "GRP1" | "MVNM" | "MVIN" => TextInformationFrame::parse(reader, id, flags, version)?.map(Frame::Text), i if i.starts_with('W') => UrlLinkFrame::parse(reader, id, flags)?.map(Frame::Url), "POPM" => Some(Frame::Popularimeter(PopularimeterFrame::parse(reader, flags)?)), // SYLT, GEOB, and any unknown frames _ => { Some(Frame::Binary(BinaryFrame::parse(reader, id, flags)?)) }, }) } pub(in crate::id3::v2) fn verify_encoding( encoding: u8, version: Id3v2Version, ) -> Result { if version == Id3v2Version::V2 && (encoding != 0 && encoding != 1) { return Err(Id3v2Error::new(Id3v2ErrorKind::V2InvalidTextEncoding).into()); } match TextEncoding::from_u8(encoding) { None => err!(TextDecode("Found invalid encoding")), Some(e) => Ok(e), } } lofty-0.21.1/src/id3/v2/frame/conversion.rs000064400000000000000000000102641046102023000164670ustar 00000000000000use crate::error::{Id3v2Error, Id3v2ErrorKind, LoftyError, Result}; use crate::id3::v2::frame::{FrameRef, EMPTY_CONTENT_DESCRIPTOR, MUSICBRAINZ_UFID_OWNER}; use crate::id3::v2::tag::{ new_binary_frame, new_comment_frame, new_text_frame, new_unsync_text_frame, new_url_frame, new_user_text_frame, new_user_url_frame, }; use crate::id3::v2::{ ExtendedTextFrame, ExtendedUrlFrame, Frame, FrameFlags, FrameId, PopularimeterFrame, UniqueFileIdentifierFrame, }; use crate::macros::err; use crate::tag::{ItemKey, ItemValue, TagItem, TagType}; use crate::TextEncoding; use std::borrow::Cow; fn frame_from_unknown_item(id: FrameId<'_>, item_value: ItemValue) -> Result> { match item_value { ItemValue::Text(text) => Ok(new_text_frame(id, text)), ItemValue::Locator(locator) => { if TextEncoding::verify_latin1(&locator) { Ok(new_url_frame(id, locator)) } else { err!(TextDecode("ID3v2 URL frames must be Latin-1")); } }, ItemValue::Binary(binary) => Ok(new_binary_frame(id, binary.clone())), } } impl From for Option> { fn from(input: TagItem) -> Self { let value; if let Ok(id) = input.key().try_into().map(FrameId::into_owned) { return frame_from_unknown_item(id, input.item_value).ok(); } match input.item_key.map_key(TagType::Id3v2, true) { Some(desc) => match input.item_value { ItemValue::Text(text) => { value = Frame::UserText(ExtendedTextFrame::new( TextEncoding::UTF8, String::from(desc), text, )) }, ItemValue::Locator(locator) => { value = Frame::UserUrl(ExtendedUrlFrame::new( TextEncoding::UTF8, String::from(desc), locator, )) }, ItemValue::Binary(_) => return None, }, None => match (input.item_key, input.item_value) { (ItemKey::MusicBrainzRecordingId, ItemValue::Text(recording_id)) => { if !recording_id.is_ascii() { return None; } let frame = UniqueFileIdentifierFrame::new( MUSICBRAINZ_UFID_OWNER.to_owned(), recording_id.into_bytes(), ); value = Frame::UniqueFileIdentifier(frame); }, _ => { return None; }, }, } Some(value) } } impl<'a> TryFrom<&'a TagItem> for FrameRef<'a> { type Error = LoftyError; fn try_from(tag_item: &'a TagItem) -> std::result::Result { let id: crate::error::Result> = tag_item.key().try_into(); let value: Frame<'_>; match id { Ok(id) => { let id_str = id.as_str(); match (id_str, tag_item.value()) { ("COMM", ItemValue::Text(text)) => { value = new_comment_frame(text.clone()); }, ("USLT", ItemValue::Text(text)) => { value = new_unsync_text_frame(text.clone()); }, ("WXXX", ItemValue::Locator(text) | ItemValue::Text(text)) => { value = new_user_url_frame(EMPTY_CONTENT_DESCRIPTOR, text.clone()); }, (locator_id, ItemValue::Locator(text)) if locator_id.len() > 4 => { value = new_user_url_frame(String::from(locator_id), text.clone()); }, ("TXXX", ItemValue::Text(text)) => { value = new_user_text_frame(EMPTY_CONTENT_DESCRIPTOR, text.clone()); }, (text_id, ItemValue::Text(text)) if text_id.len() > 4 => { value = new_user_text_frame(String::from(text_id), text.clone()); }, ("POPM", ItemValue::Binary(contents)) => { value = Frame::Popularimeter(PopularimeterFrame::parse( &mut &contents[..], FrameFlags::default(), )?); }, (_, item_value) => value = frame_from_unknown_item(id, item_value.clone())?, }; }, Err(_) => { let item_key = tag_item.key(); let Some(desc) = item_key.map_key(TagType::Id3v2, true) else { return Err(Id3v2Error::new(Id3v2ErrorKind::UnsupportedFrameId( item_key.clone(), )) .into()); }; match tag_item.value() { ItemValue::Text(text) => { value = new_user_text_frame(String::from(desc), text.clone()); }, ItemValue::Locator(locator) => { value = new_user_url_frame(String::from(desc), locator.clone()); }, _ => { return Err(Id3v2Error::new(Id3v2ErrorKind::UnsupportedFrameId( item_key.clone(), )) .into()) }, } }, } Ok(FrameRef(Cow::Owned(value))) } } lofty-0.21.1/src/id3/v2/frame/header/mod.rs000064400000000000000000000100611046102023000163040ustar 00000000000000pub(super) mod parse; use crate::error; use crate::error::{Id3v2Error, Id3v2ErrorKind, LoftyError}; use crate::id3::v2::FrameFlags; use crate::prelude::ItemKey; use crate::tag::TagType; use std::borrow::Cow; use std::fmt::{Display, Formatter}; /// An ID3v2 frame header /// /// These are rarely constructed by hand. Usually they are created in the background /// when making a new [`Frame`](crate::id3::v2::Frame). #[derive(Clone, Debug, PartialEq, Eq, Hash)] #[allow(missing_docs)] pub struct FrameHeader<'a> { pub(crate) id: FrameId<'a>, pub flags: FrameFlags, } impl<'a> FrameHeader<'a> { /// Create a new [`FrameHeader`] /// /// NOTE: Once the header is created, the ID becomes immutable. pub const fn new(id: FrameId<'a>, flags: FrameFlags) -> Self { Self { id, flags } } /// Get the ID of the frame pub const fn id(&'a self) -> &'a FrameId<'a> { &self.id } } /// An `ID3v2` frame ID /// /// ⚠ WARNING ⚠: Be very careful when constructing this by hand. It is recommended to use [`FrameId::new`]. #[derive(PartialEq, Clone, Debug, Eq, Hash)] pub enum FrameId<'a> { /// A valid `ID3v2.3/4` frame Valid(Cow<'a, str>), /// When an `ID3v2.2` key couldn't be upgraded /// /// This **will not** be written. It is up to the user to upgrade and store the key as [`Id3v2Frame::Valid`](Self::Valid). /// /// The entire frame is stored as [`ItemValue::Binary`](crate::tag::ItemValue::Binary). Outdated(Cow<'a, str>), } impl<'a> FrameId<'a> { /// Attempts to create a `FrameId` from an ID string /// /// NOTE: This will not upgrade IDs. /// /// # Errors /// /// * `id` contains invalid characters (must be 'A'..='Z' and '0'..='9') /// * `id` is an invalid length (must be 3 or 4) pub fn new(id: I) -> error::Result where I: Into>, { Self::new_cow(id.into()) } // Split from generic, public method to avoid code bloat by monomorphization. pub(in crate::id3::v2::frame) fn new_cow(id: Cow<'a, str>) -> error::Result { Self::verify_id(&id)?; match id.len() { 3 => Ok(FrameId::Outdated(id)), 4 => Ok(FrameId::Valid(id)), _ => Err( Id3v2Error::new(Id3v2ErrorKind::BadFrameId(id.into_owned().into_bytes())).into(), ), } } /// Extracts the string from the ID pub fn as_str(&self) -> &str { match self { FrameId::Valid(v) | FrameId::Outdated(v) => v, } } pub(in crate::id3::v2::frame) fn verify_id(id_str: &str) -> error::Result<()> { for c in id_str.chars() { if !c.is_ascii_uppercase() && !c.is_ascii_digit() { return Err(Id3v2Error::new(Id3v2ErrorKind::BadFrameId( id_str.as_bytes().to_vec(), )) .into()); } } Ok(()) } /// Obtains a borrowed instance pub fn as_borrowed(&'a self) -> Self { match self { Self::Valid(inner) => Self::Valid(Cow::Borrowed(inner)), Self::Outdated(inner) => Self::Outdated(Cow::Borrowed(inner)), } } /// Obtains an owned instance pub fn into_owned(self) -> FrameId<'static> { match self { Self::Valid(inner) => FrameId::Valid(Cow::Owned(inner.into_owned())), Self::Outdated(inner) => FrameId::Outdated(Cow::Owned(inner.into_owned())), } } /// Consumes the [`FrameId`], returning the inner value pub fn into_inner(self) -> Cow<'a, str> { match self { FrameId::Valid(v) | FrameId::Outdated(v) => v, } } } impl Display for FrameId<'_> { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { f.write_str(self.as_str()) } } impl<'a> Into> for FrameId<'a> { fn into(self) -> Cow<'a, str> { self.into_inner() } } impl<'a> TryFrom<&'a ItemKey> for FrameId<'a> { type Error = LoftyError; fn try_from(value: &'a ItemKey) -> std::prelude::rust_2015::Result { match value { ItemKey::Unknown(unknown) if unknown.len() == 4 => { Self::verify_id(unknown)?; Ok(Self::Valid(Cow::Borrowed(unknown))) }, k => { if let Some(mapped) = k.map_key(TagType::Id3v2, false) { if mapped.len() == 4 { Self::verify_id(mapped)?; return Ok(Self::Valid(Cow::Borrowed(mapped))); } } Err(Id3v2Error::new(Id3v2ErrorKind::UnsupportedFrameId(k.clone())).into()) }, } } } lofty-0.21.1/src/id3/v2/frame/header/parse.rs000064400000000000000000000052521046102023000166450ustar 00000000000000use super::FrameFlags; use crate::error::{Id3v2Error, Id3v2ErrorKind, Result}; use crate::id3::v2::util::synchsafe::SynchsafeInteger; use crate::id3::v2::util::upgrade::{upgrade_v2, upgrade_v3}; use crate::id3::v2::FrameId; use crate::util::text::utf8_decode_str; use crate::config::ParseOptions; use std::borrow::Cow; use std::io::Read; pub(crate) fn parse_v2_header( reader: &mut R, size: &mut u32, ) -> Result, FrameFlags)>> where R: Read, { let mut header = [0; 6]; match reader.read_exact(&mut header) { Ok(_) => {}, Err(_) => return Ok(None), } // Assume we just started reading padding if header[0] == 0 { return Ok(None); } *size = u32::from_be_bytes([0, header[3], header[4], header[5]]); let id_bytes = &header[..3]; let id_str = std::str::from_utf8(id_bytes) .map_err(|_| Id3v2Error::new(Id3v2ErrorKind::BadFrameId(id_bytes.to_vec()))) .map(|id_str| { upgrade_v2(id_str).map_or_else(|| Cow::Owned(id_str.to_owned()), Cow::Borrowed) })?; let id = FrameId::new_cow(id_str)?; // V2 doesn't store flags Ok(Some((id, FrameFlags::default()))) } pub(crate) fn parse_header( reader: &mut R, size: &mut u32, synchsafe: bool, parse_options: ParseOptions, ) -> Result, FrameFlags)>> where R: Read, { let mut header = [0; 10]; match reader.read_exact(&mut header) { Ok(_) => {}, Err(_) => return Ok(None), } // Assume we just started reading padding if header[0] == 0 { return Ok(None); } *size = u32::from_be_bytes([header[4], header[5], header[6], header[7]]); // unsynch the frame size if necessary if synchsafe { *size = size.unsynch(); } // For some reason, some apps make v3 tags with v2 frame IDs. // The actual frame header is v3 though let mut id_end = 4; let mut invalid_v2_frame = false; if header[3] == 0 && !synchsafe { log::warn!("Found a v2 frame ID in a v3 tag, attempting to upgrade"); invalid_v2_frame = true; id_end = 3; } let id_bytes = &header[..id_end]; let id_str = utf8_decode_str(id_bytes) .map_err(|_| Id3v2Error::new(Id3v2ErrorKind::BadFrameId(id_bytes.to_vec())))?; // Now upgrade the FrameId let id = if invalid_v2_frame { if let Some(id) = upgrade_v2(id_str) { Cow::Borrowed(id) } else { Cow::Owned(id_str.to_owned()) } } else if !synchsafe && parse_options.implicit_conversions { upgrade_v3(id_str).map_or_else(|| Cow::Owned(id_str.to_owned()), Cow::Borrowed) } else { Cow::Owned(id_str.to_owned()) }; let frame_id = FrameId::new_cow(id)?; let flags = u16::from_be_bytes([header[8], header[9]]); let flags = if synchsafe { FrameFlags::parse_id3v24(flags) } else { FrameFlags::parse_id3v23(flags) }; Ok(Some((frame_id, flags))) } lofty-0.21.1/src/id3/v2/frame/mod.rs000064400000000000000000000312611046102023000150610ustar 00000000000000pub(super) mod content; mod conversion; pub(super) mod header; pub(super) mod read; use super::header::Id3v2Version; use super::items::{ AttachedPictureFrame, BinaryFrame, CommentFrame, EventTimingCodesFrame, ExtendedTextFrame, ExtendedUrlFrame, KeyValueFrame, OwnershipFrame, PopularimeterFrame, PrivateFrame, RelativeVolumeAdjustmentFrame, TextInformationFrame, TimestampFrame, UniqueFileIdentifierFrame, UnsynchronizedTextFrame, UrlLinkFrame, }; use crate::error::Result; use crate::id3::v2::FrameHeader; use crate::util::text::TextEncoding; use header::FrameId; use std::borrow::Cow; use std::hash::Hash; use std::ops::Deref; pub(super) const MUSICBRAINZ_UFID_OWNER: &str = "http://musicbrainz.org"; /// Empty content descriptor in text frame /// /// Unspecific [`CommentFrame`]s, [`UnsynchronizedTextFrame`]s, and [`ExtendedTextFrame`] frames /// are supposed to have an empty content descriptor. Only those /// are currently supported as [`TagItem`]s to avoid ambiguities /// and to prevent inconsistencies when writing them. pub(super) const EMPTY_CONTENT_DESCRIPTOR: String = String::new(); // TODO: Messy module, rough conversions macro_rules! define_frames { ( $(#[$meta:meta])* pub enum Frame<'a> { $( $(#[$field_meta:meta])+ $variant:ident($type:ty), )* } ) => { $(#[$meta])* pub enum Frame<'a> { $( $(#[$field_meta])+ $variant($type), )* } impl Frame<'_> { /// Get the ID of the frame pub fn id(&self) -> &FrameId<'_> { match self { $( Frame::$variant(frame) => &frame.header.id, )* } } /// Get the flags for the frame pub fn flags(&self) -> FrameFlags { match self { $( Frame::$variant(frame) => frame.flags(), )* } } /// Set the flags for the frame pub fn set_flags(&mut self, flags: FrameFlags) { match self { $( Frame::$variant(frame) => frame.set_flags(flags), )* } } } $( impl<'a> From<$type> for Frame<'a> { fn from(value: $type) -> Self { Frame::$variant(value) } } )* } } define_frames! { /// Represents an `ID3v2` frame /// /// ## Outdated Frames /// /// ### ID3v2.2 /// /// `ID3v2.2` frame IDs are 3 characters. When reading these tags, [`upgrade_v2`](crate::id3::v2::upgrade_v2) is used, which has a list of all of the common IDs /// that have a mapping to `ID3v2.4`. Any ID that fails to be converted will be stored as [`FrameId::Outdated`], and it must be manually /// upgraded before it can be written. **Lofty** will not write `ID3v2.2` tags. /// /// ### ID3v2.3 /// /// `ID3v2.3`, unlike `ID3v2.2`, stores frame IDs in 4 characters like `ID3v2.4`. There are some IDs that need upgrading (See [`upgrade_v3`](crate::id3::v2::upgrade_v3)), /// but anything that fails to be upgraded **will not** be stored as [`FrameId::Outdated`], as it is likely not an issue to write. #[non_exhaustive] #[derive(Clone, Debug, PartialEq, Eq, Hash)] pub enum Frame<'a> { /// Represents a "COMM" frame Comment(CommentFrame<'a>), /// Represents a "USLT" frame UnsynchronizedText(UnsynchronizedTextFrame<'a>), /// Represents a "T..." (excluding TXXX) frame Text(TextInformationFrame<'a>), /// Represents a "TXXX" frame UserText(ExtendedTextFrame<'a>), /// Represents a "W..." (excluding WXXX) frame Url(UrlLinkFrame<'a>), /// Represents a "WXXX" frame UserUrl(ExtendedUrlFrame<'a>), /// Represents an "APIC" or "PIC" frame Picture(AttachedPictureFrame<'a>), /// Represents a "POPM" frame Popularimeter(PopularimeterFrame<'a>), /// Represents an "IPLS" or "TPIL" frame KeyValue(KeyValueFrame<'a>), /// Represents an "RVA2" frame RelativeVolumeAdjustment(RelativeVolumeAdjustmentFrame<'a>), /// Unique file identifier UniqueFileIdentifier(UniqueFileIdentifierFrame<'a>), /// Represents an "OWNE" frame Ownership(OwnershipFrame<'a>), /// Represents an "ETCO" frame EventTimingCodes(EventTimingCodesFrame<'a>), /// Represents a "PRIV" frame Private(PrivateFrame<'a>), /// Represents a timestamp for the "TDEN", "TDOR", "TDRC", "TDRL", and "TDTG" frames Timestamp(TimestampFrame<'a>), /// Binary data /// /// NOTES: /// /// * This is used for rare frames, such as GEOB, SYLT, and ATXT to skip additional unnecessary work. /// See [`GeneralEncapsulatedObject::parse`](crate::id3::v2::GeneralEncapsulatedObject::parse), [`SynchronizedText::parse`](crate::id3::v2::SynchronizedTextFrame::parse), and [`AudioTextFrame::parse`](crate::id3::v2::AudioTextFrame::parse) respectively /// * This is used for **all** frames with an ID of [`FrameId::Outdated`] /// * This is used for unknown frames Binary(BinaryFrame<'a>), } } impl<'a> Frame<'a> { /// Extract the string from the [`FrameId`] pub fn id_str(&self) -> &str { self.id().as_str() } // Used internally, has no correctness checks pub(crate) fn text(id: Cow<'a, str>, content: String) -> Self { Frame::Text(TextInformationFrame { header: FrameHeader::new(FrameId::Valid(id), FrameFlags::default()), encoding: TextEncoding::UTF8, value: content, }) } } impl<'a> Frame<'a> { /// Check for empty content /// /// Returns `None` if the frame type is not supported. pub(super) fn is_empty(&self) -> Option { let is_empty = match self { Frame::Text(text) => text.value.is_empty(), Frame::UserText(extended_text) => extended_text.content.is_empty(), Frame::Url(link) => link.content.is_empty(), Frame::UserUrl(extended_url) => extended_url.content.is_empty(), Frame::Comment(comment) => comment.content.is_empty(), Frame::UnsynchronizedText(unsync_text) => unsync_text.content.is_empty(), Frame::Picture(picture) => picture.picture.data.is_empty(), Frame::KeyValue(key_value) => key_value.key_value_pairs.is_empty(), Frame::UniqueFileIdentifier(ufid) => ufid.identifier.is_empty(), Frame::EventTimingCodes(event_timing) => event_timing.events.is_empty(), Frame::Private(private) => private.private_data.is_empty(), Frame::Binary(binary) => binary.data.is_empty(), Frame::Popularimeter(_) | Frame::RelativeVolumeAdjustment(_) | Frame::Ownership(_) | Frame::Timestamp(_) => { // Undefined. return None; }, }; Some(is_empty) } } impl<'a> Frame<'a> { pub(super) fn as_bytes(&self, is_id3v23: bool) -> Result> { Ok(match self { Frame::Comment(comment) => comment.as_bytes(is_id3v23)?, Frame::UnsynchronizedText(lf) => lf.as_bytes(is_id3v23)?, Frame::Text(tif) => tif.as_bytes(is_id3v23), Frame::UserText(content) => content.as_bytes(is_id3v23), Frame::UserUrl(content) => content.as_bytes(is_id3v23), Frame::Url(link) => link.as_bytes(), Frame::Picture(attached_picture) => { let version = if is_id3v23 { Id3v2Version::V3 } else { Id3v2Version::V4 }; attached_picture.as_bytes(version)? }, Frame::Popularimeter(popularimeter) => popularimeter.as_bytes()?, Frame::KeyValue(content) => content.as_bytes(is_id3v23), Frame::RelativeVolumeAdjustment(frame) => frame.as_bytes(), Frame::UniqueFileIdentifier(frame) => frame.as_bytes(), Frame::Ownership(frame) => frame.as_bytes(is_id3v23)?, Frame::EventTimingCodes(frame) => frame.as_bytes(), Frame::Private(frame) => frame.as_bytes()?, Frame::Timestamp(frame) => frame.as_bytes(is_id3v23)?, Frame::Binary(frame) => frame.as_bytes(), }) } /// Used for errors in write::frame::verify_frame pub(super) fn name(&self) -> &'static str { match self { Frame::Comment(_) => "Comment", Frame::UnsynchronizedText(_) => "UnsynchronizedText", Frame::Text { .. } => "Text", Frame::UserText(_) => "UserText", Frame::Url(_) => "Url", Frame::UserUrl(_) => "UserUrl", Frame::Picture { .. } => "Picture", Frame::Popularimeter(_) => "Popularimeter", Frame::KeyValue(_) => "KeyValue", Frame::UniqueFileIdentifier(_) => "UniqueFileIdentifier", Frame::RelativeVolumeAdjustment(_) => "RelativeVolumeAdjustment", Frame::Ownership(_) => "Ownership", Frame::EventTimingCodes(_) => "EventTimingCodes", Frame::Private(_) => "Private", Frame::Timestamp(_) => "Timestamp", Frame::Binary(_) => "Binary", } } } /// Various flags to describe the content of an item #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Default)] #[allow(clippy::struct_excessive_bools)] pub struct FrameFlags { /// Preserve frame on tag edit pub tag_alter_preservation: bool, /// Preserve frame on file edit pub file_alter_preservation: bool, /// Item cannot be written to pub read_only: bool, /// The group identifier the frame belongs to /// /// All frames with the same group identifier byte belong to the same group. pub grouping_identity: Option, /// Frame is zlib compressed /// /// It is **required** `data_length_indicator` be set if this is set. pub compression: bool, /// Frame encryption method symbol /// /// NOTE: Since the encryption method is unknown, lofty cannot do anything with these frames /// /// The encryption method symbol **must** be > 0x80. pub encryption: Option, /// Frame is unsynchronised /// /// In short, this makes all "0xFF X (X >= 0xE0)" combinations into "0xFF 0x00 X" to avoid confusion /// with the MPEG frame header, which is often identified by its "frame sync" (11 set bits). /// It is preferred an ID3v2 tag is either *completely* unsynchronised or not unsynchronised at all. /// /// NOTE: While unsynchronized data is read, for the sake of simplicity, this flag has no effect when /// writing. There isn't much reason to write unsynchronized data. pub unsynchronisation: bool, /* TODO: Maybe? This doesn't seem very useful, and it is wasted effort if one forgets to make this false when writing. */ /// Frame has a data length indicator /// /// The data length indicator is the size of the frame if the flags were all zeroed out. /// This is usually used in combination with `compression` and `encryption` (depending on encryption method). /// /// If using `encryption`, the final size must be added. pub data_length_indicator: Option, } impl FrameFlags { /// Parse the flags from an ID3v2.4 frame /// /// NOTE: If any of the following flags are set, they will be set to `Some(0)`: /// * `grouping_identity` /// * `encryption` /// * `data_length_indicator` pub fn parse_id3v24(flags: u16) -> Self { FrameFlags { tag_alter_preservation: flags & 0x4000 == 0x4000, file_alter_preservation: flags & 0x2000 == 0x2000, read_only: flags & 0x1000 == 0x1000, grouping_identity: (flags & 0x0040 == 0x0040).then_some(0), compression: flags & 0x0008 == 0x0008, encryption: (flags & 0x0004 == 0x0004).then_some(0), unsynchronisation: flags & 0x0002 == 0x0002, data_length_indicator: (flags & 0x0001 == 0x0001).then_some(0), } } /// Parse the flags from an ID3v2.3 frame /// /// NOTE: If any of the following flags are set, they will be set to `Some(0)`: /// * `grouping_identity` /// * `encryption` pub fn parse_id3v23(flags: u16) -> Self { FrameFlags { tag_alter_preservation: flags & 0x8000 == 0x8000, file_alter_preservation: flags & 0x4000 == 0x4000, read_only: flags & 0x2000 == 0x2000, grouping_identity: (flags & 0x0020 == 0x0020).then_some(0), compression: flags & 0x0080 == 0x0080, encryption: (flags & 0x0040 == 0x0040).then_some(0), unsynchronisation: false, data_length_indicator: None, } } /// Get the ID3v2.4 byte representation of the flags pub fn as_id3v24_bytes(&self) -> u16 { let mut flags = 0; if *self == FrameFlags::default() { return flags; } if self.tag_alter_preservation { flags |= 0x4000 } if self.file_alter_preservation { flags |= 0x2000 } if self.read_only { flags |= 0x1000 } if self.grouping_identity.is_some() { flags |= 0x0040 } if self.compression { flags |= 0x0008 } if self.encryption.is_some() { flags |= 0x0004 } if self.unsynchronisation { flags |= 0x0002 } if self.data_length_indicator.is_some() { flags |= 0x0001 } flags } /// Get the ID3v2.3 byte representation of the flags pub fn as_id3v23_bytes(&self) -> u16 { let mut flags = 0; if *self == FrameFlags::default() { return flags; } if self.tag_alter_preservation { flags |= 0x8000 } if self.file_alter_preservation { flags |= 0x4000 } if self.read_only { flags |= 0x2000 } if self.grouping_identity.is_some() { flags |= 0x0020 } if self.compression { flags |= 0x0080 } if self.encryption.is_some() { flags |= 0x0040 } flags } } #[derive(Clone)] pub(crate) struct FrameRef<'a>(pub(crate) Cow<'a, Frame<'a>>); impl<'a> Deref for FrameRef<'a> { type Target = Frame<'a>; fn deref(&self) -> &Self::Target { self.0.as_ref() } } impl<'a> Frame<'a> { pub(crate) fn as_opt_ref(&'a self) -> Option> { if let FrameId::Valid(_) = self.id() { Some(FrameRef(Cow::Borrowed(self))) } else { None } } } lofty-0.21.1/src/id3/v2/frame/read.rs000064400000000000000000000150201046102023000152100ustar 00000000000000use super::header::parse::{parse_header, parse_v2_header}; use super::Frame; use crate::config::{ParseOptions, ParsingMode}; use crate::error::{Id3v2Error, Id3v2ErrorKind, Result}; use crate::id3::v2::frame::content::parse_content; use crate::id3::v2::header::Id3v2Version; use crate::id3::v2::util::synchsafe::{SynchsafeInteger, UnsynchronizedStream}; use crate::id3::v2::{BinaryFrame, FrameFlags, FrameHeader, FrameId}; use crate::macros::try_vec; use std::io::Read; use crate::id3::v2::tag::ATTACHED_PICTURE_ID; use byteorder::{BigEndian, ReadBytesExt}; pub(crate) enum ParsedFrame<'a> { Next(Frame<'a>), Skip { size: u32 }, Eof, } impl<'a> ParsedFrame<'a> { pub(crate) fn read( reader: &mut R, version: Id3v2Version, parse_options: ParseOptions, ) -> Result where R: Read, { let mut size = 0u32; // The header will be upgraded to ID3v2.4 past this point, so they can all be treated the same let parse_header_result = match version { Id3v2Version::V2 => parse_v2_header(reader, &mut size), Id3v2Version::V3 => parse_header(reader, &mut size, false, parse_options), Id3v2Version::V4 => parse_header(reader, &mut size, true, parse_options), }; let (id, mut flags) = match parse_header_result { Ok(None) => { // Stop reading return Ok(Self::Eof); }, Ok(Some(some)) => some, Err(err) => { match parse_options.parsing_mode { ParsingMode::Strict => return Err(err), ParsingMode::BestAttempt | ParsingMode::Relaxed => { // Skip this frame and continue reading // TODO: Log error? return Ok(Self::Skip { size }); }, } }, }; if !parse_options.read_cover_art && id == ATTACHED_PICTURE_ID { return Ok(Self::Skip { size }); } if size == 0 { if parse_options.parsing_mode == ParsingMode::Strict { return Err(Id3v2Error::new(Id3v2ErrorKind::EmptyFrame(id)).into()); } log::debug!("Encountered a zero length frame, skipping"); return Ok(Self::Skip { size }); } // Get the encryption method symbol if let Some(enc) = flags.encryption.as_mut() { log::trace!("Reading encryption method symbol"); if size < 1 { return Err(Id3v2Error::new(Id3v2ErrorKind::BadFrameLength).into()); } *enc = reader.read_u8()?; size -= 1; } // Get the group identifier if let Some(group) = flags.grouping_identity.as_mut() { log::trace!("Reading group identifier"); if size < 1 { return Err(Id3v2Error::new(Id3v2ErrorKind::BadFrameLength).into()); } *group = reader.read_u8()?; size -= 1; } // Get the real data length if flags.data_length_indicator.is_some() || flags.compression { log::trace!("Reading data length indicator"); if size < 4 { return Err(Id3v2Error::new(Id3v2ErrorKind::BadFrameLength).into()); } // For some reason, no one can follow the spec, so while a data length indicator is *written* // the flag **isn't always set** let len = reader.read_u32::()?.unsynch(); flags.data_length_indicator = Some(len); size -= 4; } // Frames must have at least 1 byte, *after* all of the additional data flags can provide if size == 0 { return Err(Id3v2Error::new(Id3v2ErrorKind::BadFrameLength).into()); } // Restrict the reader to the frame content let mut reader = reader.take(u64::from(size)); // It seems like the flags are applied in the order: // // unsynchronization -> compression -> encryption // // Which all have their own needs, so this gets a little messy... match flags { // Possible combinations: // // * unsynchronized + compressed + encrypted // * unsynchronized + compressed // * unsynchronized + encrypted // * unsynchronized FrameFlags { unsynchronisation: true, .. } => { let mut unsynchronized_reader = UnsynchronizedStream::new(reader); if flags.compression { let mut compression_reader = handle_compression(unsynchronized_reader)?; if flags.encryption.is_some() { return handle_encryption(&mut compression_reader, size, id, flags); } return parse_frame( &mut compression_reader, size, id, flags, version, parse_options.parsing_mode, ); } if flags.encryption.is_some() { return handle_encryption(&mut unsynchronized_reader, size, id, flags); } return parse_frame( &mut unsynchronized_reader, size, id, flags, version, parse_options.parsing_mode, ); }, // Possible combinations: // // * compressed + encrypted // * compressed FrameFlags { compression: true, .. } => { let mut compression_reader = handle_compression(reader)?; if flags.encryption.is_some() { return handle_encryption(&mut compression_reader, size, id, flags); } return parse_frame( &mut compression_reader, size, id, flags, version, parse_options.parsing_mode, ); }, // Possible combinations: // // * encrypted FrameFlags { encryption: Some(_), .. } => { return handle_encryption(&mut reader, size, id, flags); }, // Everything else that doesn't have special flags _ => { return parse_frame( &mut reader, size, id, flags, version, parse_options.parsing_mode, ); }, } } } #[cfg(feature = "id3v2_compression_support")] #[allow(clippy::unnecessary_wraps)] fn handle_compression(reader: R) -> Result> { Ok(flate2::read::ZlibDecoder::new(reader)) } #[cfg(not(feature = "id3v2_compression_support"))] #[allow(clippy::unnecessary_wraps)] fn handle_compression(_: R) -> Result { Err(Id3v2Error::new(Id3v2ErrorKind::CompressedFrameEncountered).into()) } fn handle_encryption( reader: &mut R, size: u32, id: FrameId<'static>, flags: FrameFlags, ) -> Result> { if flags.data_length_indicator.is_none() { return Err(Id3v2Error::new(Id3v2ErrorKind::MissingDataLengthIndicator).into()); } let mut content = try_vec![0; size as usize]; reader.read_exact(&mut content)?; let encrypted_frame = Frame::Binary(BinaryFrame { header: FrameHeader::new(id, flags), data: content, }); // Nothing further we can do with encrypted frames Ok(ParsedFrame::Next(encrypted_frame)) } fn parse_frame( reader: &mut R, size: u32, id: FrameId<'static>, flags: FrameFlags, version: Id3v2Version, parse_mode: ParsingMode, ) -> Result> { match parse_content(reader, id, flags, version, parse_mode)? { Some(frame) => Ok(ParsedFrame::Next(frame)), None => Ok(ParsedFrame::Skip { size }), } } lofty-0.21.1/src/id3/v2/header.rs000064400000000000000000000116371046102023000144450ustar 00000000000000use crate::error::{Id3v2Error, Id3v2ErrorKind, Result}; use crate::id3::v2::restrictions::TagRestrictions; use crate::id3::v2::util::synchsafe::SynchsafeInteger; use crate::macros::err; use std::io::Read; use byteorder::{BigEndian, ByteOrder, ReadBytesExt}; /// The ID3v2 version #[derive(PartialEq, Eq, Debug, Clone, Copy)] pub enum Id3v2Version { /// ID3v2.2 V2, /// ID3v2.3 V3, /// ID3v2.4 V4, } /// Flags that apply to the entire tag #[derive(Default, Copy, Clone, Debug, PartialEq, Eq)] #[allow(clippy::struct_excessive_bools)] pub struct Id3v2TagFlags { /// Whether or not all frames are unsynchronised. See [`FrameFlags::unsynchronisation`](crate::id3::v2::FrameFlags::unsynchronisation) pub unsynchronisation: bool, /// Indicates if the tag is in an experimental stage pub experimental: bool, /// Indicates that the tag includes a footer /// /// A footer will be created if the tag is written pub footer: bool, /// Whether or not to include a CRC-32 in the extended header /// /// This is calculated if the tag is written pub crc: bool, /// Restrictions on the tag, written in the extended header /// /// In addition to being setting this flag, all restrictions must be provided. See [`TagRestrictions`] pub restrictions: Option, } impl Id3v2TagFlags { /// Get the **ID3v2.4** byte representation of the flags /// /// NOTE: This does not include the extended header flags pub fn as_id3v24_byte(&self) -> u8 { let mut byte = 0; if self.unsynchronisation { byte |= 0x80; } if self.experimental { byte |= 0x20; } if self.footer { byte |= 0x10; } byte } /// Get the **ID3v2.3** byte representation of the flags /// /// NOTE: This does not include the extended header flags pub fn as_id3v23_byte(&self) -> u8 { let mut byte = 0; if self.experimental { byte |= 0x40; } if self.footer { byte |= 0x10; } byte } } #[derive(Copy, Clone, Debug)] pub(crate) struct Id3v2Header { pub version: Id3v2Version, pub flags: Id3v2TagFlags, /// The size of the tag contents (**DOES NOT INCLUDE THE HEADER/FOOTER**) pub size: u32, pub extended_size: u32, } impl Id3v2Header { pub(crate) fn parse(bytes: &mut R) -> Result where R: Read, { log::debug!("Parsing ID3v2 header"); let mut header = [0; 10]; bytes.read_exact(&mut header)?; if &header[..3] != b"ID3" { err!(FakeTag); } // Version is stored as [major, minor], but here we don't care about minor revisions unless there's an error. let version = match header[3] { 2 => Id3v2Version::V2, 3 => Id3v2Version::V3, 4 => Id3v2Version::V4, major => { return Err( Id3v2Error::new(Id3v2ErrorKind::BadId3v2Version(major, header[4])).into(), ) }, }; let flags = header[5]; // Compression was a flag only used in ID3v2.2 (bit 2). // At the time the ID3v2.2 specification was written, a compression scheme wasn't decided. // The spec recommends just ignoring the tag in this case. if version == Id3v2Version::V2 && flags & 0x40 == 0x40 { return Err(Id3v2Error::new(Id3v2ErrorKind::V2Compression).into()); } let mut flags_parsed = Id3v2TagFlags { unsynchronisation: flags & 0x80 == 0x80, experimental: (version == Id3v2Version::V4 || version == Id3v2Version::V3) && flags & 0x20 == 0x20, footer: (version == Id3v2Version::V4 || version == Id3v2Version::V3) && flags & 0x10 == 0x10, crc: false, // Retrieved later if applicable restrictions: None, // Retrieved later if applicable }; let size = BigEndian::read_u32(&header[6..]).unsynch(); let mut extended_size = 0; let extended_header = (version == Id3v2Version::V4 || version == Id3v2Version::V3) && flags & 0x40 == 0x40; if extended_header { extended_size = bytes.read_u32::()?.unsynch(); if extended_size < 6 { return Err(Id3v2Error::new(Id3v2ErrorKind::BadExtendedHeaderSize).into()); } // Useless byte since there's only 1 byte for flags let _num_flag_bytes = bytes.read_u8()?; let extended_flags = bytes.read_u8()?; // The only flags we care about here are the CRC and restrictions if extended_flags & 0x20 == 0x20 { flags_parsed.crc = true; // We don't care about the existing CRC (5) or its length byte (1) let mut crc = [0; 6]; bytes.read_exact(&mut crc)?; } if extended_flags & 0x10 == 0x10 { // We don't care about the length byte, it is always 1 let _data_length = bytes.read_u8()?; flags_parsed.restrictions = Some(TagRestrictions::from_byte(bytes.read_u8()?)); } } if extended_size > 0 && extended_size >= size { return Err(Id3v2Error::new(Id3v2ErrorKind::BadExtendedHeaderSize).into()); } Ok(Id3v2Header { version, flags: flags_parsed, size, extended_size, }) } /// The total size of the tag, including the header, footer, and extended header pub(crate) fn full_tag_size(&self) -> u32 { self.size + 10 + self.extended_size + if self.flags.footer { 10 } else { 0 } } } lofty-0.21.1/src/id3/v2/items/attached_picture_frame.rs000064400000000000000000000110571046102023000210140ustar 00000000000000use crate::error::{Id3v2Error, Id3v2ErrorKind, Result}; use crate::id3::v2::header::Id3v2Version; use crate::id3::v2::{FrameFlags, FrameHeader, FrameId}; use crate::macros::err; use crate::picture::{MimeType, Picture, PictureType}; use crate::util::text::{encode_text, TextDecodeOptions, TextEncoding}; use std::borrow::Cow; use std::io::{Read, Write as _}; use byteorder::{ReadBytesExt as _, WriteBytesExt as _}; const FRAME_ID: FrameId<'static> = FrameId::Valid(Cow::Borrowed("APIC")); /// An `ID3v2` attached picture frame /// /// This is simply a wrapper around [`Picture`] to include a [`TextEncoding`] #[derive(Clone, Debug, PartialEq, Eq, Hash)] pub struct AttachedPictureFrame<'a> { pub(crate) header: FrameHeader<'a>, /// The encoding of the description pub encoding: TextEncoding, /// The picture itself pub picture: Picture, } impl<'a> AttachedPictureFrame<'a> { /// Create a new [`AttachedPictureFrame`] pub fn new(encoding: TextEncoding, picture: Picture) -> Self { let header = FrameHeader::new(FRAME_ID, FrameFlags::default()); Self { header, encoding, picture, } } /// Get the ID for the frame pub fn id(&self) -> FrameId<'_> { FRAME_ID } /// Get the flags for the frame pub fn flags(&self) -> FrameFlags { self.header.flags } /// Set the flags for the frame pub fn set_flags(&mut self, flags: FrameFlags) { self.header.flags = flags; } /// Get an [`AttachedPictureFrame`] from ID3v2 A/PIC bytes: /// /// NOTE: This expects *only* the frame content /// /// # Errors /// /// * There isn't enough data present /// * Unable to decode any of the text /// /// ID3v2.2: /// /// * The format is not "PNG" or "JPG" pub fn parse(reader: &mut R, frame_flags: FrameFlags, version: Id3v2Version) -> Result where R: Read, { let Some(encoding) = TextEncoding::from_u8(reader.read_u8()?) else { err!(NotAPicture); }; let mime_type; if version == Id3v2Version::V2 { let mut format = [0; 3]; reader.read_exact(&mut format)?; match format { [b'P', b'N', b'G'] => mime_type = Some(MimeType::Png), [b'J', b'P', b'G'] => mime_type = Some(MimeType::Jpeg), _ => { return Err(Id3v2Error::new(Id3v2ErrorKind::BadPictureFormat( String::from_utf8_lossy(&format).into_owned(), )) .into()) }, } } else { let mime_type_str = crate::util::text::decode_text( reader, TextDecodeOptions::new() .encoding(TextEncoding::Latin1) .terminated(true), )? .text_or_none(); mime_type = mime_type_str.map(|mime_type_str| MimeType::from_str(&mime_type_str)); }; let pic_type = PictureType::from_u8(reader.read_u8()?); let description = crate::util::text::decode_text( reader, TextDecodeOptions::new().encoding(encoding).terminated(true), )? .text_or_none() .map(Cow::from); let mut data = Vec::new(); reader.read_to_end(&mut data)?; let picture = Picture { pic_type, mime_type, description, data: Cow::from(data), }; let header = FrameHeader::new(FRAME_ID, frame_flags); Ok(Self { header, encoding, picture, }) } /// Convert an [`AttachedPictureFrame`] to a ID3v2 A/PIC byte Vec /// /// NOTE: This does not include the frame header /// /// # Errors /// /// * Too much data was provided /// /// ID3v2.2: /// /// * The mimetype is not [`MimeType::Png`] or [`MimeType::Jpeg`] pub fn as_bytes(&self, version: Id3v2Version) -> Result> { let mut encoding = self.encoding; if version != Id3v2Version::V4 { encoding = encoding.to_id3v23(); } let mut data = vec![encoding as u8]; let max_size = match version { // ID3v2.2 uses a 24-bit number for sizes Id3v2Version::V2 => 0xFFFF_FF16_u64, _ => u64::from(u32::MAX), }; if version == Id3v2Version::V2 { // ID3v2.2 PIC is pretty limited with formats let format = match self.picture.mime_type { Some(MimeType::Png) => "PNG", Some(MimeType::Jpeg) => "JPG", _ => { let mime_str = self.picture.mime_str(); return Err(Id3v2Error::new(Id3v2ErrorKind::BadPictureFormat( mime_str.to_string(), )) .into()); }, }; data.write_all(format.as_bytes())?; } else { if let Some(mime_type) = &self.picture.mime_type { data.write_all(mime_type.as_str().as_bytes())?; } data.write_u8(0)?; }; data.write_u8(self.picture.pic_type.as_u8())?; match &self.picture.description { Some(description) => data.write_all(&encode_text(description, encoding, true))?, None => data.write_u8(0)?, } data.write_all(&self.picture.data)?; if data.len() as u64 > max_size { err!(TooMuchData); } Ok(data) } } lofty-0.21.1/src/id3/v2/items/audio_text_frame.rs000064400000000000000000000162151046102023000176520ustar 00000000000000use crate::error::{ErrorKind, Id3v2Error, Id3v2ErrorKind, LoftyError, Result}; use crate::id3::v2::{FrameFlags, FrameHeader, FrameId}; use crate::util::text::{decode_text, encode_text, TextDecodeOptions, TextEncoding}; use std::borrow::Cow; use std::hash::{Hash, Hasher}; use byteorder::ReadBytesExt as _; const FRAME_ID: FrameId<'static> = FrameId::Valid(Cow::Borrowed("ATXT")); /// Flags for an ID3v2 audio-text flag #[derive(Copy, Clone, Debug, Ord, PartialOrd, Eq, PartialEq, Hash)] pub struct AudioTextFrameFlags { /// This flag shall be set if the scrambling method defined in [Section 5] has been applied /// to the audio data, or not set if no scrambling has been applied. /// /// [Section 5]: https://mutagen-specs.readthedocs.io/en/latest/id3/id3v2-accessibility-1.0.html#scrambling-scheme-for-non-mpeg-audio-formats pub scrambling: bool, } impl AudioTextFrameFlags { /// Get ID3v2 ATXT frame flags from a byte /// /// The flag byte layout is defined here: pub fn from_u8(byte: u8) -> Self { Self { scrambling: byte & 0x01 > 0, } } /// Convert an [`AudioTextFrameFlags`] to an ATXT frame flag byte /// /// The flag byte layout is defined here: pub fn as_u8(&self) -> u8 { let mut byte = 0_u8; if self.scrambling { byte |= 0x01 } byte } } /// An `ID3v2` audio-text frame #[derive(Clone, Debug, Eq)] pub struct AudioTextFrame<'a> { pub(crate) header: FrameHeader<'a>, /// The encoding of the description pub encoding: TextEncoding, /// The MIME type of the audio data pub mime_type: String, /// Flags for the pub flags: AudioTextFrameFlags, /// The equivalent text for the audio clip /// /// This text must be semantically equivalent to the spoken narrative in the audio clip and /// should match the text and encoding used by another ID3v2 frame in the tag. pub equivalent_text: String, /// The audio clip /// /// The Audio data carries an audio clip which provides the audio description. The encoding /// of the audio data shall match the MIME type field and the data shall be scrambled if /// the scrambling flag is set. /// /// To unscramble the data, see [`scramble()`]. /// /// NOTE: Do not replace this field with the unscrambled data unless the [`AudioTextFrameFlags::scrambling`] flag /// has been unset. Otherwise, this frame will no longer be readable. pub audio_data: Vec, } impl<'a> PartialEq for AudioTextFrame<'a> { fn eq(&self, other: &Self) -> bool { self.equivalent_text == other.equivalent_text } } impl<'a> Hash for AudioTextFrame<'a> { fn hash(&self, state: &mut H) { self.equivalent_text.hash(state); } } impl<'a> AudioTextFrame<'a> { /// Create a new [`AudioTextFrame`] pub fn new( encoding: TextEncoding, mime_type: String, flags: AudioTextFrameFlags, equivalent_text: String, audio_data: Vec, ) -> Self { let header = FrameHeader::new(FRAME_ID, FrameFlags::default()); Self { header, encoding, mime_type, flags, equivalent_text, audio_data, } } /// Get the ID for the frame pub fn id(&self) -> FrameId<'_> { FRAME_ID } /// Get the flags for the frame pub fn flags(&self) -> FrameFlags { self.header.flags } /// Set the flags for the frame pub fn set_flags(&mut self, flags: FrameFlags) { self.header.flags = flags; } /// Get an [`AudioTextFrame`] from ID3v2 ATXT bytes: /// /// NOTE: This expects *only* the frame content /// /// # Errors /// /// * Not enough data /// * Improperly encoded text pub fn parse(bytes: &[u8], frame_flags: FrameFlags) -> Result { if bytes.len() < 4 { return Err(Id3v2Error::new(Id3v2ErrorKind::BadFrameLength).into()); } let content = &mut &bytes[..]; let encoding = TextEncoding::from_u8(content.read_u8()?) .ok_or_else(|| LoftyError::new(ErrorKind::TextDecode("Found invalid encoding")))?; let mime_type = decode_text( content, TextDecodeOptions::new() .encoding(TextEncoding::Latin1) .terminated(true), )? .content; let flags = AudioTextFrameFlags::from_u8(content.read_u8()?); let equivalent_text = decode_text( content, TextDecodeOptions::new().encoding(encoding).terminated(true), )? .content; let header = FrameHeader::new(FRAME_ID, frame_flags); Ok(Self { header, encoding, mime_type, flags, equivalent_text, audio_data: content.to_vec(), }) } /// Convert an [`AudioTextFrame`] to a ID3v2 A/PIC byte Vec /// /// NOTE: This does not include the frame header pub fn as_bytes(&self) -> Vec { let mut content = vec![self.encoding as u8]; content.extend(encode_text( self.mime_type.as_str(), TextEncoding::Latin1, true, )); content.push(self.flags.as_u8()); content.extend(encode_text(&self.equivalent_text, self.encoding, true)); content.extend(&self.audio_data); content } } const SCRAMBLING_TABLE: [u8; 127] = { let mut scrambling_table = [0_u8; 127]; scrambling_table[0] = 0xFE; let mut i = 0; loop { let byte = scrambling_table[i]; let bit7 = (byte >> 7) & 0x01; let bit6 = (byte >> 6) & 0x01; let bit5 = (byte >> 5) & 0x01; let bit4 = (byte >> 4) & 0x01; let bit3 = (byte >> 3) & 0x01; let bit2 = (byte >> 2) & 0x01; let bit1 = (byte >> 1) & 0x01; let bit0 = byte & 0x01; let new_byte = ((bit6 ^ bit5) << 7) + ((bit5 ^ bit4) << 6) + ((bit4 ^ bit3) << 5) + ((bit3 ^ bit2) << 4) + ((bit2 ^ bit1) << 3) + ((bit1 ^ bit0) << 2) + ((bit7 ^ bit5) << 1) + (bit6 ^ bit4); if new_byte == 0xFE { break; } i += 1; scrambling_table[i] = new_byte; } scrambling_table }; /// Scramble/Unscramble the audio clip from an ATXT frame in place /// /// The scrambling scheme is defined here: pub fn scramble(audio_data: &mut [u8]) { let mut idx = 0; for b in audio_data.iter_mut() { *b ^= SCRAMBLING_TABLE[idx]; if idx == 126 { idx = 0; } else { idx += 1; } } } #[cfg(test)] mod tests { use crate::id3::v2::{AudioTextFrame, AudioTextFrameFlags, FrameFlags}; use crate::TextEncoding; fn expected() -> AudioTextFrame<'static> { AudioTextFrame { header: super::FrameHeader::new(super::FRAME_ID, FrameFlags::default()), encoding: TextEncoding::Latin1, mime_type: String::from("audio/mpeg"), flags: AudioTextFrameFlags { scrambling: false }, equivalent_text: String::from("foo bar baz"), audio_data: crate::tag::utils::test_utils::read_path( "tests/files/assets/minimal/full_test.mp3", ), } } #[test] fn atxt_decode() { let expected = expected(); let cont = crate::tag::utils::test_utils::read_path("tests/tags/assets/id3v2/test.atxt"); let parsed_atxt = AudioTextFrame::parse(&cont, FrameFlags::default()).unwrap(); assert_eq!(parsed_atxt, expected); } #[test] fn atxt_encode() { let to_encode = expected(); let encoded = to_encode.as_bytes(); let expected_bytes = crate::tag::utils::test_utils::read_path("tests/tags/assets/id3v2/test.atxt"); assert_eq!(encoded, expected_bytes); } } lofty-0.21.1/src/id3/v2/items/binary_frame.rs000064400000000000000000000025111046102023000167630ustar 00000000000000use crate::error::Result; use crate::id3::v2::{FrameFlags, FrameHeader, FrameId}; use std::io::Read; /// A binary fallback for all unknown `ID3v2` frames #[derive(Clone, Debug, Eq, PartialEq, Hash)] pub struct BinaryFrame<'a> { pub(crate) header: FrameHeader<'a>, /// The binary data pub data: Vec, } impl<'a> BinaryFrame<'a> { /// Create a new [`BinaryFrame`] pub fn new(id: FrameId<'a>, data: Vec) -> Self { let header = FrameHeader::new(id, FrameFlags::default()); Self { header, data } } /// Get the ID for the frame pub fn id(&self) -> &FrameId<'_> { &self.header.id } /// Get the flags for the frame pub fn flags(&self) -> FrameFlags { self.header.flags } /// Set the flags for the frame pub fn set_flags(&mut self, flags: FrameFlags) { self.header.flags = flags; } /// Read a [`BinaryFrame`] /// /// NOTE: This will exhaust the entire reader /// /// # Errors /// /// * Failure to read from `reader` pub fn parse(reader: &mut R, id: FrameId<'a>, frame_flags: FrameFlags) -> Result where R: Read, { let mut data = Vec::new(); reader.read_to_end(&mut data)?; let header = FrameHeader::new(id, frame_flags); Ok(BinaryFrame { header, data }) } /// Convert an [`BinaryFrame`] to a byte vec pub fn as_bytes(&self) -> Vec { let Self { data, .. } = self; data.clone() } } lofty-0.21.1/src/id3/v2/items/encapsulated_object.rs000064400000000000000000000106651046102023000203340ustar 00000000000000use crate::error::{ErrorKind, Id3v2Error, Id3v2ErrorKind, LoftyError, Result}; use crate::id3::v2::{FrameFlags, FrameHeader, FrameId}; use crate::util::text::{decode_text, encode_text, TextDecodeOptions, TextEncoding}; use std::io::{Cursor, Read}; const FRAME_ID: FrameId<'static> = FrameId::Valid(std::borrow::Cow::Borrowed("GEOB")); /// Allows for encapsulation of any file type inside an ID3v2 tag #[derive(PartialEq, Clone, Debug, Eq, Hash)] pub struct GeneralEncapsulatedObject<'a> { pub(crate) header: FrameHeader<'a>, /// The text encoding of `file_name` and `description` pub encoding: TextEncoding, /// The file's mimetype pub mime_type: Option, /// The file's name pub file_name: Option, /// A unique content descriptor pub descriptor: Option, /// The file's content pub data: Vec, } impl<'a> GeneralEncapsulatedObject<'a> { /// Create a new [`GeneralEncapsulatedObject`] pub fn new( encoding: TextEncoding, mime_type: Option, file_name: Option, descriptor: Option, data: Vec, ) -> Self { let header = FrameHeader::new(FRAME_ID, FrameFlags::default()); Self { header, encoding, mime_type, file_name, descriptor, data, } } /// Get the ID for the frame pub fn id(&self) -> FrameId<'_> { FRAME_ID } /// Get the flags for the frame pub fn flags(&self) -> FrameFlags { self.header.flags } /// Set the flags for the frame pub fn set_flags(&mut self, flags: FrameFlags) { self.header.flags = flags; } /// Read a [`GeneralEncapsulatedObject`] from a slice /// /// NOTE: This expects the frame header to have already been skipped /// /// # Errors /// /// This function will return an error if at any point it's unable to parse the data pub fn parse(data: &[u8], frame_flags: FrameFlags) -> Result { if data.len() < 4 { return Err(Id3v2Error::new(Id3v2ErrorKind::BadFrameLength).into()); } let encoding = TextEncoding::from_u8(data[0]) .ok_or_else(|| LoftyError::new(ErrorKind::TextDecode("Found invalid encoding")))?; let mut cursor = Cursor::new(&data[1..]); let mime_type = decode_text( &mut cursor, TextDecodeOptions::new() .encoding(TextEncoding::Latin1) .terminated(true), )?; let text_decode_options = TextDecodeOptions::new().encoding(encoding).terminated(true); let file_name = decode_text(&mut cursor, text_decode_options)?; let descriptor = decode_text(&mut cursor, text_decode_options)?; let mut data = Vec::new(); cursor.read_to_end(&mut data)?; let header = FrameHeader::new(FRAME_ID, frame_flags); Ok(Self { header, encoding, mime_type: mime_type.text_or_none(), file_name: file_name.text_or_none(), descriptor: descriptor.text_or_none(), data, }) } /// Convert a [`GeneralEncapsulatedObject`] into an ID3v2 GEOB frame byte Vec /// /// NOTE: This does not include a frame header pub fn as_bytes(&self) -> Vec { let encoding = self.encoding; let mut bytes = vec![encoding as u8]; if let Some(ref mime_type) = self.mime_type { bytes.extend(mime_type.as_bytes()) } bytes.push(0); let file_name = self.file_name.as_deref(); bytes.extend(&*encode_text(file_name.unwrap_or(""), encoding, true)); let descriptor = self.descriptor.as_deref(); bytes.extend(&*encode_text(descriptor.unwrap_or(""), encoding, true)); bytes.extend(&self.data); bytes } } #[cfg(test)] mod tests { use crate::id3::v2::{FrameFlags, FrameHeader, GeneralEncapsulatedObject}; use crate::util::text::TextEncoding; fn expected() -> GeneralEncapsulatedObject<'static> { GeneralEncapsulatedObject { header: FrameHeader::new(super::FRAME_ID, FrameFlags::default()), encoding: TextEncoding::Latin1, mime_type: Some(String::from("audio/mpeg")), file_name: Some(String::from("a.mp3")), descriptor: Some(String::from("Test Asset")), data: crate::tag::utils::test_utils::read_path( "tests/files/assets/minimal/full_test.mp3", ), } } #[test] fn geob_decode() { let expected = expected(); let cont = crate::tag::utils::test_utils::read_path("tests/tags/assets/id3v2/test.geob"); let parsed_geob = GeneralEncapsulatedObject::parse(&cont, FrameFlags::default()).unwrap(); assert_eq!(parsed_geob, expected); } #[test] fn geob_encode() { let to_encode = expected(); let encoded = to_encode.as_bytes(); let expected_bytes = crate::tag::utils::test_utils::read_path("tests/tags/assets/id3v2/test.geob"); assert_eq!(encoded, expected_bytes); } } lofty-0.21.1/src/id3/v2/items/event_timing_codes_frame.rs000064400000000000000000000205471046102023000213550ustar 00000000000000use crate::error::{Id3v2Error, Id3v2ErrorKind, Result}; use crate::id3::v2::{FrameFlags, FrameHeader, FrameId, TimestampFormat}; use std::borrow::Cow; use std::cmp::Ordering; use std::hash::Hash; use std::io::Read; use byteorder::{BigEndian, ReadBytesExt}; const FRAME_ID: FrameId<'static> = FrameId::Valid(Cow::Borrowed("ETCO")); /// The type of events that can occur in an [`EventTimingCodesFrame`] /// /// This is used in [`Event`]. /// /// Note from [the spec](https://mutagen-specs.readthedocs.io/en/latest/id3/id3v2.4.0-frames.html#event-timing-codes): /// /// >>> Terminating the start events such as “intro start” is OPTIONAL. /// >>> The ‘Not predefined synch’s ($E0-EF) are for user events. /// >>> You might want to synchronise your music to something, /// >>> like setting off an explosion on-stage, activating a screensaver etc. #[repr(u8)] #[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Debug, Hash)] #[allow(missing_docs)] pub enum EventType { Padding = 0x00, EndOfInitialSilence = 0x01, IntroStart = 0x02, MainPartStart = 0x03, OutroStart = 0x04, OutroEnd = 0x05, VerseStart = 0x06, RefrainStart = 0x07, InterludeStart = 0x08, ThemeStart = 0x09, VariationStart = 0x0A, KeyChange = 0x0B, TimeChange = 0x0C, MomentaryUnwantedNoise = 0x0D, SustainedNoise = 0x0E, SustainedNoiseEnd = 0x0F, IntroEnd = 0x10, MainPartEnd = 0x11, VerseEnd = 0x12, RefrainEnd = 0x13, ThemeEnd = 0x14, Profanity = 0x15, ProfanityEnd = 0x16, // User-defined events NotPredefinedSynch0 = 0xE0, NotPredefinedSynch1 = 0xE1, NotPredefinedSynch2 = 0xE2, NotPredefinedSynch3 = 0xE3, NotPredefinedSynch4 = 0xE4, NotPredefinedSynch5 = 0xE5, NotPredefinedSynch6 = 0xE6, NotPredefinedSynch7 = 0xE7, NotPredefinedSynch8 = 0xE8, NotPredefinedSynch9 = 0xE9, NotPredefinedSynchA = 0xEA, NotPredefinedSynchB = 0xEB, NotPredefinedSynchC = 0xEC, NotPredefinedSynchD = 0xED, NotPredefinedSynchE = 0xEE, NotPredefinedSynchF = 0xEF, AudioEnd = 0xFD, AudioFileEnds = 0xFE, /// 0x17..=0xDF and 0xF0..=0xFC Reserved, } impl EventType { /// Get a [`EventType`] from a `u8` /// /// NOTE: 0x17..=0xDF and 0xF0..=0xFC map to [`EventType::Reserved`] /// /// # Examples /// /// ```rust /// use lofty::id3::v2::EventType; /// /// let valid_byte = 1; /// assert_eq!( /// EventType::from_u8(valid_byte), /// EventType::EndOfInitialSilence /// ); /// /// // This is in the undefined range /// let invalid_byte = 0x17; /// assert_eq!(EventType::from_u8(invalid_byte), EventType::Reserved); /// ``` pub fn from_u8(byte: u8) -> Self { match byte { 0x00 => Self::Padding, 0x01 => Self::EndOfInitialSilence, 0x02 => Self::IntroStart, 0x03 => Self::MainPartStart, 0x04 => Self::OutroStart, 0x05 => Self::OutroEnd, 0x06 => Self::VerseStart, 0x07 => Self::RefrainStart, 0x08 => Self::InterludeStart, 0x09 => Self::ThemeStart, 0x0A => Self::VariationStart, 0x0B => Self::KeyChange, 0x0C => Self::TimeChange, 0x0D => Self::MomentaryUnwantedNoise, 0x0E => Self::SustainedNoise, 0x0F => Self::SustainedNoiseEnd, 0x10 => Self::IntroEnd, 0x11 => Self::MainPartEnd, 0x12 => Self::VerseEnd, 0x13 => Self::RefrainEnd, 0x14 => Self::ThemeEnd, 0x15 => Self::Profanity, 0x16 => Self::ProfanityEnd, // User-defined events 0xE0 => Self::NotPredefinedSynch0, 0xE1 => Self::NotPredefinedSynch1, 0xE2 => Self::NotPredefinedSynch2, 0xE3 => Self::NotPredefinedSynch3, 0xE4 => Self::NotPredefinedSynch4, 0xE5 => Self::NotPredefinedSynch5, 0xE6 => Self::NotPredefinedSynch6, 0xE7 => Self::NotPredefinedSynch7, 0xE8 => Self::NotPredefinedSynch8, 0xE9 => Self::NotPredefinedSynch9, 0xEA => Self::NotPredefinedSynchA, 0xEB => Self::NotPredefinedSynchB, 0xEC => Self::NotPredefinedSynchC, 0xED => Self::NotPredefinedSynchD, 0xEE => Self::NotPredefinedSynchE, 0xEF => Self::NotPredefinedSynchF, 0xFD => Self::AudioEnd, 0xFE => Self::AudioFileEnds, // 0x17..=0xDF and 0xF0..=0xFC _ => Self::Reserved, } } } /// An event for an [`EventTimingCodesFrame`] /// /// NOTE: The `Ord` implementation only looks at timestamps, as events must be sorted in chronological /// order. #[derive(Clone, Debug, Eq, PartialEq, Hash)] pub struct Event { /// The event type pub event_type: EventType, /// The timestamp according to the [`TimestampFormat`] pub timestamp: u32, } impl PartialOrd for Event { fn partial_cmp(&self, other: &Self) -> Option { Some(self.cmp(other)) } } impl Ord for Event { fn cmp(&self, other: &Self) -> Ordering { self.timestamp.cmp(&other.timestamp) } } /// An `ID3v2` event timing codes frame /// /// This frame defines a list of different types of events and the timestamps at which they occur. #[derive(Clone, Debug, Eq, PartialEq, Hash)] pub struct EventTimingCodesFrame<'a> { pub(crate) header: FrameHeader<'a>, /// The format of the timestamps pub timestamp_format: TimestampFormat, /// The events /// /// Events are guaranteed to be sorted by their timestamps when read. They can be inserted in /// arbitrary order after the fact, and will be sorted again prior to writing. pub events: Vec, } impl<'a> EventTimingCodesFrame<'a> { /// Create a new [`EventTimingCodesFrame`] pub fn new(timestamp_format: TimestampFormat, events: Vec) -> Self { let header = FrameHeader::new(FRAME_ID, FrameFlags::default()); Self { header, timestamp_format, events, } } /// Get the ID for the frame pub fn id(&self) -> FrameId<'_> { FRAME_ID } /// Get the flags for the frame pub fn flags(&self) -> FrameFlags { self.header.flags } /// Set the flags for the frame pub fn set_flags(&mut self, flags: FrameFlags) { self.header.flags = flags; } /// Read an [`EventTimingCodesFrame`] /// /// NOTE: This expects the frame header to have already been skipped /// /// # Errors /// /// * Invalid timestamp format pub fn parse(reader: &mut R, frame_flags: FrameFlags) -> Result> where R: Read, { let Ok(timestamp_format_byte) = reader.read_u8() else { return Ok(None); }; let timestamp_format = TimestampFormat::from_u8(timestamp_format_byte) .ok_or_else(|| Id3v2Error::new(Id3v2ErrorKind::BadTimestampFormat))?; let mut events = Vec::new(); while let Ok(event_type_byte) = reader.read_u8() { let event_type = EventType::from_u8(event_type_byte); let timestamp = reader.read_u32::()?; events.push(Event { event_type, timestamp, }) } // Order is important, can't use sort_unstable events.sort(); let header = FrameHeader::new(FRAME_ID, frame_flags); Ok(Some(EventTimingCodesFrame { header, timestamp_format, events, })) } /// Convert an [`EventTimingCodesFrame`] to a byte vec /// /// NOTE: This will sort all events according to their timestamps pub fn as_bytes(&self) -> Vec { let mut content = vec![self.timestamp_format as u8]; let mut sorted_events = self.events.iter().collect::>(); sorted_events.sort(); for event in sorted_events { content.push(event.event_type as u8); content.extend(event.timestamp.to_be_bytes()) } content } } #[cfg(test)] mod tests { use crate::id3::v2::{ Event, EventTimingCodesFrame, EventType, FrameFlags, FrameHeader, FrameId, TimestampFormat, }; fn expected() -> EventTimingCodesFrame<'static> { EventTimingCodesFrame { header: FrameHeader { id: FrameId::Valid(std::borrow::Cow::Borrowed("ETCO")), flags: FrameFlags::default(), }, timestamp_format: TimestampFormat::MS, events: vec![ Event { event_type: EventType::IntroStart, timestamp: 1500, }, Event { event_type: EventType::IntroEnd, timestamp: 5000, }, Event { event_type: EventType::MainPartStart, timestamp: 7500, }, Event { event_type: EventType::MainPartEnd, timestamp: 900_000, }, Event { event_type: EventType::AudioFileEnds, timestamp: 750_000_000, }, ], } } #[test] fn etco_decode() { let cont = crate::tag::utils::test_utils::read_path("tests/tags/assets/id3v2/test.etco"); let parsed_etco = EventTimingCodesFrame::parse(&mut &cont[..], FrameFlags::default()) .unwrap() .unwrap(); assert_eq!(parsed_etco, expected()); } #[test] fn etco_encode() { let encoded = expected().as_bytes(); let expected_bytes = crate::tag::utils::test_utils::read_path("tests/tags/assets/id3v2/test.etco"); assert_eq!(encoded, expected_bytes); } } lofty-0.21.1/src/id3/v2/items/extended_text_frame.rs000064400000000000000000000112261046102023000203460ustar 00000000000000use crate::error::{Id3v2Error, Id3v2ErrorKind, LoftyError, Result}; use crate::id3::v2::frame::content::verify_encoding; use crate::id3::v2::header::Id3v2Version; use crate::id3::v2::{FrameFlags, FrameHeader, FrameId}; use crate::macros::err; use crate::util::text::{ decode_text, encode_text, utf16_decode_bytes, TextDecodeOptions, TextEncoding, }; use std::borrow::Cow; use std::hash::{Hash, Hasher}; use std::io::Read; use byteorder::ReadBytesExt; const FRAME_ID: FrameId<'static> = FrameId::Valid(Cow::Borrowed("TXXX")); /// An extended `ID3v2` text frame /// /// This is used in the `TXXX` frame, where the frames /// are told apart by descriptions, rather than their [`FrameID`](crate::id3::v2::FrameId)s. /// This means for each `ExtendedTextFrame` in the tag, the description /// must be unique. #[derive(Clone, Debug, Eq)] pub struct ExtendedTextFrame<'a> { pub(crate) header: FrameHeader<'a>, /// The encoding of the description and comment text pub encoding: TextEncoding, /// Unique content description pub description: String, /// The actual frame content pub content: String, } impl<'a> PartialEq for ExtendedTextFrame<'a> { fn eq(&self, other: &Self) -> bool { self.description == other.description } } impl<'a> Hash for ExtendedTextFrame<'a> { fn hash(&self, state: &mut H) { self.description.hash(state); } } impl<'a> ExtendedTextFrame<'a> { /// Create a new [`ExtendedTextFrame`] pub fn new(encoding: TextEncoding, description: String, content: String) -> Self { let header = FrameHeader::new(FRAME_ID, FrameFlags::default()); Self { header, encoding, description, content, } } /// Get the ID for the frame pub fn id(&self) -> FrameId<'_> { FRAME_ID } /// Get the flags for the frame pub fn flags(&self) -> FrameFlags { self.header.flags } /// Set the flags for the frame pub fn set_flags(&mut self, flags: FrameFlags) { self.header.flags = flags; } /// Read an [`ExtendedTextFrame`] from a slice /// /// NOTE: This expects the frame header to have already been skipped /// /// # Errors /// /// * Unable to decode the text /// /// ID3v2.2: /// /// * The encoding is not [`TextEncoding::Latin1`] or [`TextEncoding::UTF16`] pub fn parse( reader: &mut R, frame_flags: FrameFlags, version: Id3v2Version, ) -> Result> where R: Read, { let Ok(encoding_byte) = reader.read_u8() else { return Ok(None); }; let encoding = verify_encoding(encoding_byte, version)?; let description = decode_text( reader, TextDecodeOptions::new().encoding(encoding).terminated(true), )?; let frame_content; if encoding != TextEncoding::UTF16 { frame_content = decode_text(reader, TextDecodeOptions::new().encoding(encoding))?.content; let header = FrameHeader::new(FRAME_ID, frame_flags); return Ok(Some(ExtendedTextFrame { header, encoding, description: description.content, content: frame_content, })); } // It's possible for the description to be the only string with a BOM 'utf16: { let mut raw_text = Vec::new(); reader.read_to_end(&mut raw_text)?; if raw_text.is_empty() { // Nothing left to do frame_content = String::new(); break 'utf16; } // Reuse the BOM from the description as a fallback if the text // doesn't specify one. let mut bom = description.bom; if raw_text.starts_with(&[0xFF, 0xFE]) || raw_text.starts_with(&[0xFE, 0xFF]) { // The text specifies a BOM bom = [raw_text[0], raw_text[1]]; } let endianness = match bom { [0x00, 0x00] if raw_text.is_empty() => { debug_assert!(description.content.is_empty()); // Empty string frame_content = String::new(); break 'utf16; }, [0x00, 0x00] => { debug_assert!(description.content.is_empty()); err!(TextDecode("UTF-16 string has no BOM")); }, [0xFF, 0xFE] => u16::from_le_bytes, [0xFE, 0xFF] => u16::from_be_bytes, // Handled in description decoding _ => unreachable!(), }; frame_content = utf16_decode_bytes(&raw_text, endianness).map_err(|_| { Into::::into(Id3v2Error::new(Id3v2ErrorKind::BadSyncText)) })?; } let header = FrameHeader::new(FRAME_ID, frame_flags); Ok(Some(ExtendedTextFrame { header, encoding, description: description.content, content: frame_content, })) } /// Convert an [`ExtendedTextFrame`] to a byte vec pub fn as_bytes(&self, is_id3v23: bool) -> Vec { let mut encoding = self.encoding; if is_id3v23 { encoding = encoding.to_id3v23(); } let mut bytes = vec![encoding as u8]; bytes.extend(encode_text(&self.description, encoding, true).iter()); bytes.extend(encode_text(&self.content, encoding, false)); bytes } } lofty-0.21.1/src/id3/v2/items/extended_url_frame.rs000064400000000000000000000061161046102023000201660ustar 00000000000000use crate::error::Result; use crate::id3::v2::frame::content::verify_encoding; use crate::id3::v2::header::Id3v2Version; use crate::id3::v2::{FrameFlags, FrameHeader, FrameId}; use crate::util::text::{decode_text, encode_text, TextDecodeOptions, TextEncoding}; use std::borrow::Cow; use std::hash::{Hash, Hasher}; use std::io::Read; use byteorder::ReadBytesExt; const FRAME_ID: FrameId<'static> = FrameId::Valid(Cow::Borrowed("WXXX")); /// An extended `ID3v2` URL frame /// /// This is used in the `WXXX` frame, where the frames /// are told apart by descriptions, rather than their [`FrameId`]s. /// This means for each `ExtendedUrlFrame` in the tag, the description /// must be unique. #[derive(Clone, Debug, Eq)] pub struct ExtendedUrlFrame<'a> { pub(crate) header: FrameHeader<'a>, /// The encoding of the description and comment text pub encoding: TextEncoding, /// Unique content description pub description: String, /// The actual frame content pub content: String, } impl<'a> PartialEq for ExtendedUrlFrame<'a> { fn eq(&self, other: &Self) -> bool { self.description == other.description } } impl<'a> Hash for ExtendedUrlFrame<'a> { fn hash(&self, state: &mut H) { self.description.hash(state); } } impl<'a> ExtendedUrlFrame<'a> { /// Create a new [`ExtendedUrlFrame`] pub fn new(encoding: TextEncoding, description: String, content: String) -> Self { let header = FrameHeader::new(FRAME_ID, FrameFlags::default()); Self { header, encoding, description, content, } } /// Get the ID for the frame pub fn id(&self) -> FrameId<'_> { FRAME_ID } /// Get the flags for the frame pub fn flags(&self) -> FrameFlags { self.header.flags } /// Set the flags for the frame pub fn set_flags(&mut self, flags: FrameFlags) { self.header.flags = flags; } /// Read an [`ExtendedUrlFrame`] from a slice /// /// NOTE: This expects the frame header to have already been skipped /// /// # Errors /// /// * Unable to decode the text /// /// ID3v2.2: /// /// * The encoding is not [`TextEncoding::Latin1`] or [`TextEncoding::UTF16`] pub fn parse( reader: &mut R, frame_flags: FrameFlags, version: Id3v2Version, ) -> Result> where R: Read, { let Ok(encoding_byte) = reader.read_u8() else { return Ok(None); }; let encoding = verify_encoding(encoding_byte, version)?; let description = decode_text( reader, TextDecodeOptions::new().encoding(encoding).terminated(true), )? .content; let content = decode_text( reader, TextDecodeOptions::new().encoding(TextEncoding::Latin1), )? .content; let header = FrameHeader::new(FRAME_ID, frame_flags); Ok(Some(ExtendedUrlFrame { header, encoding, description, content, })) } /// Convert an [`ExtendedUrlFrame`] to a byte vec pub fn as_bytes(&self, is_id3v23: bool) -> Vec { let mut encoding = self.encoding; if is_id3v23 { encoding = encoding.to_id3v23(); } let mut bytes = vec![encoding as u8]; bytes.extend(encode_text(&self.description, encoding, true).iter()); bytes.extend(encode_text(&self.content, encoding, false)); bytes } } lofty-0.21.1/src/id3/v2/items/key_value_frame.rs000064400000000000000000000061111046102023000174630ustar 00000000000000use crate::error::Result; use crate::id3::v2::frame::content::verify_encoding; use crate::id3::v2::header::Id3v2Version; use crate::id3::v2::{FrameFlags, FrameHeader, FrameId}; use crate::util::text::{decode_text, encode_text, TextDecodeOptions, TextEncoding}; use byteorder::ReadBytesExt; use std::io::Read; /// An `ID3v2` key-value frame #[derive(Clone, Debug, Eq, PartialEq, Hash)] pub struct KeyValueFrame<'a> { pub(crate) header: FrameHeader<'a>, /// The encoding of the text pub encoding: TextEncoding, /// The key value pairs. Keys can be specified multiple times pub key_value_pairs: Vec<(String, String)>, } impl<'a> KeyValueFrame<'a> { /// Create a new [`KeyValueFrame`] pub fn new( id: FrameId<'a>, encoding: TextEncoding, key_value_pairs: Vec<(String, String)>, ) -> Self { let header = FrameHeader::new(id, FrameFlags::default()); Self { header, encoding, key_value_pairs, } } /// Get the ID for the frame pub fn id(&self) -> &FrameId<'_> { &self.header.id } /// Get the flags for the frame pub fn flags(&self) -> FrameFlags { self.header.flags } /// Set the flags for the frame pub fn set_flags(&mut self, flags: FrameFlags) { self.header.flags = flags; } /// Read an [`KeyValueFrame`] from a slice /// /// NOTE: This expects the frame header to have already been skipped /// /// # Errors /// /// * Unable to decode the text /// /// ID3v2.2: /// /// * The encoding is not [`TextEncoding::Latin1`] or [`TextEncoding::UTF16`] pub fn parse( reader: &mut R, id: FrameId<'a>, frame_flags: FrameFlags, version: Id3v2Version, ) -> Result> where R: Read, { let Ok(encoding_byte) = reader.read_u8() else { return Ok(None); }; let encoding = verify_encoding(encoding_byte, version)?; let mut values = Vec::new(); let mut text_decode_options = TextDecodeOptions::new().encoding(encoding).terminated(true); // We have to read the first key/value pair separately because it may be the only string with a BOM let first_key = decode_text(reader, text_decode_options)?; if first_key.bytes_read == 0 { return Ok(None); } if encoding == TextEncoding::UTF16 { text_decode_options = text_decode_options.bom(first_key.bom); } values.push(( first_key.content, decode_text(reader, text_decode_options)?.content, )); loop { let key = decode_text(reader, text_decode_options)?; let value = decode_text(reader, text_decode_options)?; if key.bytes_read == 0 || value.bytes_read == 0 { break; } values.push((key.content, value.content)); } let header = FrameHeader::new(id, frame_flags); Ok(Some(Self { header, encoding, key_value_pairs: values, })) } /// Convert a [`KeyValueFrame`] to a byte vec pub fn as_bytes(&self, is_id3v23: bool) -> Vec { let mut encoding = self.encoding; if is_id3v23 { encoding = encoding.to_id3v23(); } let mut content = vec![encoding as u8]; for (key, value) in &self.key_value_pairs { content.append(&mut encode_text(key, encoding, true)); content.append(&mut encode_text(value, encoding, true)); } content } } lofty-0.21.1/src/id3/v2/items/language_frame.rs000064400000000000000000000163351046102023000172730ustar 00000000000000use crate::error::{Id3v2Error, Id3v2ErrorKind, Result}; use crate::id3::v2::frame::content::verify_encoding; use crate::id3::v2::header::Id3v2Version; use crate::id3::v2::{FrameFlags, FrameHeader, FrameId}; use crate::tag::items::Lang; use crate::util::text::{decode_text, encode_text, TextDecodeOptions, TextEncoding}; use std::borrow::Cow; use std::hash::{Hash, Hasher}; use std::io::Read; use byteorder::ReadBytesExt; // Generic struct for a text frame that has a language // // This exists to deduplicate some code between `CommentFrame` and `UnsynchronizedTextFrame` struct LanguageFrame { pub encoding: TextEncoding, pub language: Lang, pub description: String, pub content: String, } impl LanguageFrame { fn parse(reader: &mut R, version: Id3v2Version) -> Result> where R: Read, { let Ok(encoding_byte) = reader.read_u8() else { return Ok(None); }; let encoding = verify_encoding(encoding_byte, version)?; let mut language = [0; 3]; reader.read_exact(&mut language)?; let description = decode_text( reader, TextDecodeOptions::new().encoding(encoding).terminated(true), )? .content; let content = decode_text(reader, TextDecodeOptions::new().encoding(encoding))?.content; Ok(Some(Self { encoding, language, description, content, })) } fn create_bytes( mut encoding: TextEncoding, language: [u8; 3], description: &str, content: &str, is_id3v23: bool, ) -> Result> { if is_id3v23 { encoding = encoding.to_id3v23(); } let mut bytes = vec![encoding as u8]; if language.len() != 3 || language.iter().any(|c| !c.is_ascii_alphabetic()) { return Err(Id3v2Error::new(Id3v2ErrorKind::InvalidLanguage(language)).into()); } bytes.extend(language); bytes.extend(encode_text(description, encoding, true).iter()); bytes.extend(encode_text(content, encoding, false)); Ok(bytes) } } /// An `ID3v2` comment frame /// /// Similar to `TXXX` and `WXXX` frames, comments are told apart by their descriptions. #[derive(Clone, Debug, Eq)] pub struct CommentFrame<'a> { pub(crate) header: FrameHeader<'a>, /// The encoding of the description and comment text pub encoding: TextEncoding, /// ISO-639-2 language code (3 bytes) pub language: Lang, /// Unique content description pub description: String, /// The actual frame content pub content: String, } impl<'a> PartialEq for CommentFrame<'a> { fn eq(&self, other: &Self) -> bool { self.language == other.language && self.description == other.description } } impl<'a> Hash for CommentFrame<'a> { fn hash(&self, state: &mut H) { self.language.hash(state); self.description.hash(state); } } impl<'a> CommentFrame<'a> { const FRAME_ID: FrameId<'static> = FrameId::Valid(Cow::Borrowed("COMM")); /// Create a new [`CommentFrame`] pub fn new( encoding: TextEncoding, language: Lang, description: String, content: String, ) -> Self { let header = FrameHeader::new(Self::FRAME_ID, FrameFlags::default()); Self { header, encoding, language, description, content, } } /// Get the ID for the frame pub fn id(&self) -> FrameId<'_> { Self::FRAME_ID } /// Get the flags for the frame pub fn flags(&self) -> FrameFlags { self.header.flags } /// Set the flags for the frame pub fn set_flags(&mut self, flags: FrameFlags) { self.header.flags = flags; } /// Read a [`CommentFrame`] from a slice /// /// NOTE: This expects the frame header to have already been skipped /// /// # Errors /// /// * Unable to decode the text /// /// ID3v2.2: /// /// * The encoding is not [`TextEncoding::Latin1`] or [`TextEncoding::UTF16`] pub fn parse( reader: &mut R, frame_flags: FrameFlags, version: Id3v2Version, ) -> Result> where R: Read, { let Some(language_frame) = LanguageFrame::parse(reader, version)? else { return Ok(None); }; let header = FrameHeader::new(Self::FRAME_ID, frame_flags); Ok(Some(Self { header, encoding: language_frame.encoding, language: language_frame.language, description: language_frame.description, content: language_frame.content, })) } /// Convert a [`CommentFrame`] to a byte vec /// /// NOTE: This does not include a frame header /// /// # Errors /// /// * `language` is not exactly 3 bytes /// * `language` contains invalid characters (Only `'a'..='z'` and `'A'..='Z'` allowed) pub fn as_bytes(&self, is_id3v23: bool) -> Result> { LanguageFrame::create_bytes( self.encoding, self.language, &self.description, &self.content, is_id3v23, ) } } /// An `ID3v2` unsynchronized lyrics/text frame /// /// Similar to `TXXX` and `WXXX` frames, USLT frames are told apart by their descriptions. #[derive(Clone, Debug, Eq)] pub struct UnsynchronizedTextFrame<'a> { pub(crate) header: FrameHeader<'a>, /// The encoding of the description and content pub encoding: TextEncoding, /// ISO-639-2 language code (3 bytes) pub language: Lang, /// Unique content description pub description: String, /// The actual frame content pub content: String, } impl<'a> PartialEq for UnsynchronizedTextFrame<'a> { fn eq(&self, other: &Self) -> bool { self.language == other.language && self.description == other.description } } impl<'a> Hash for UnsynchronizedTextFrame<'a> { fn hash(&self, state: &mut H) { self.language.hash(state); self.description.hash(state); } } impl<'a> UnsynchronizedTextFrame<'a> { const FRAME_ID: FrameId<'static> = FrameId::Valid(Cow::Borrowed("USLT")); /// Create a new [`UnsynchronizedTextFrame`] pub fn new( encoding: TextEncoding, language: Lang, description: String, content: String, ) -> Self { let header = FrameHeader::new(Self::FRAME_ID, FrameFlags::default()); Self { header, encoding, language, description, content, } } /// Get the ID for the frame pub fn id(&self) -> FrameId<'_> { Self::FRAME_ID } /// Get the flags for the frame pub fn flags(&self) -> FrameFlags { self.header.flags } /// Set the flags for the frame pub fn set_flags(&mut self, flags: FrameFlags) { self.header.flags = flags; } /// Read a [`UnsynchronizedTextFrame`] from a slice /// /// NOTE: This expects the frame header to have already been skipped /// /// # Errors /// /// * Unable to decode the text /// /// ID3v2.2: /// /// * The encoding is not [`TextEncoding::Latin1`] or [`TextEncoding::UTF16`] pub fn parse( reader: &mut R, frame_flags: FrameFlags, version: Id3v2Version, ) -> Result> where R: Read, { let Some(language_frame) = LanguageFrame::parse(reader, version)? else { return Ok(None); }; let header = FrameHeader::new(Self::FRAME_ID, frame_flags); Ok(Some(Self { header, encoding: language_frame.encoding, language: language_frame.language, description: language_frame.description, content: language_frame.content, })) } /// Convert a [`UnsynchronizedTextFrame`] to a byte vec /// /// NOTE: This does not include a frame header /// /// # Errors /// /// * `language` is not exactly 3 bytes /// * `language` contains invalid characters (Only `'a'..='z'` and `'A'..='Z'` allowed) pub fn as_bytes(&self, is_id3v23: bool) -> Result> { LanguageFrame::create_bytes( self.encoding, self.language, &self.description, &self.content, is_id3v23, ) } } lofty-0.21.1/src/id3/v2/items/mod.rs000064400000000000000000000026511046102023000151110ustar 00000000000000mod attached_picture_frame; mod audio_text_frame; mod binary_frame; mod encapsulated_object; mod event_timing_codes_frame; mod extended_text_frame; mod extended_url_frame; mod key_value_frame; pub(in crate::id3::v2) mod language_frame; mod ownership_frame; mod popularimeter; mod private_frame; mod relative_volume_adjustment_frame; mod sync_text; mod text_information_frame; mod timestamp_frame; mod unique_file_identifier; mod url_link_frame; pub use attached_picture_frame::AttachedPictureFrame; pub use audio_text_frame::{scramble, AudioTextFrame, AudioTextFrameFlags}; pub use binary_frame::BinaryFrame; pub use encapsulated_object::GeneralEncapsulatedObject; pub use event_timing_codes_frame::{Event, EventTimingCodesFrame, EventType}; pub use extended_text_frame::ExtendedTextFrame; pub use extended_url_frame::ExtendedUrlFrame; pub use key_value_frame::KeyValueFrame; pub use language_frame::{CommentFrame, UnsynchronizedTextFrame}; pub use ownership_frame::OwnershipFrame; pub use popularimeter::PopularimeterFrame; pub use private_frame::PrivateFrame; pub use relative_volume_adjustment_frame::{ ChannelInformation, ChannelType, RelativeVolumeAdjustmentFrame, }; pub use sync_text::{SyncTextContentType, SynchronizedTextFrame, TimestampFormat}; pub use text_information_frame::TextInformationFrame; pub use timestamp_frame::TimestampFrame; pub use unique_file_identifier::UniqueFileIdentifierFrame; pub use url_link_frame::UrlLinkFrame; lofty-0.21.1/src/id3/v2/items/ownership_frame.rs000064400000000000000000000110511046102023000175140ustar 00000000000000use crate::error::{ErrorKind, Id3v2Error, Id3v2ErrorKind, LoftyError, Result}; use crate::id3::v2::{FrameFlags, FrameHeader, FrameId}; use crate::util::text::{ decode_text, encode_text, utf8_decode_str, TextDecodeOptions, TextEncoding, }; use std::borrow::Cow; use std::hash::Hash; use std::io::Read; use byteorder::ReadBytesExt; const FRAME_ID: FrameId<'static> = FrameId::Valid(Cow::Borrowed("OWNE")); /// An `ID3v2` ownership frame /// /// This is used to mark a transaction, and is recommended to be used /// in addition to the USER and TOWN frames. #[derive(Clone, Debug, Eq, PartialEq, Hash)] pub struct OwnershipFrame<'a> { pub(crate) header: FrameHeader<'a>, /// The encoding of the seller string pub encoding: TextEncoding, /// The price paid /// /// The first three characters of this field contains the currency used for the transaction, /// encoded according to ISO 4217 alphabetic currency code. Concatenated to this is the actual price paid, /// as a numerical string using ”.” as the decimal separator. pub price_paid: String, /// The date of purchase as an 8 character date string (YYYYMMDD) pub date_of_purchase: String, /// The seller name pub seller: String, } impl<'a> OwnershipFrame<'a> { /// Create a new [`OwnershipFrame`] pub fn new( encoding: TextEncoding, price_paid: String, date_of_purchase: String, seller: String, ) -> Self { let header = FrameHeader::new(FRAME_ID, FrameFlags::default()); Self { header, encoding, price_paid, date_of_purchase, seller, } } /// Get the ID for the frame pub fn id(&self) -> FrameId<'_> { FRAME_ID } /// Get the flags for the frame pub fn flags(&self) -> FrameFlags { self.header.flags } /// Set the flags for the frame pub fn set_flags(&mut self, flags: FrameFlags) { self.header.flags = flags; } /// Read an [`OwnershipFrame`] /// /// NOTE: This expects the frame header to have already been skipped /// /// # Errors /// /// * Invalid text encoding /// * Not enough data pub fn parse(reader: &mut R, frame_flags: FrameFlags) -> Result> where R: Read, { let Ok(encoding_byte) = reader.read_u8() else { return Ok(None); }; let encoding = TextEncoding::from_u8(encoding_byte) .ok_or_else(|| LoftyError::new(ErrorKind::TextDecode("Found invalid encoding")))?; let price_paid = decode_text( reader, TextDecodeOptions::new() .encoding(TextEncoding::Latin1) .terminated(true), )? .content; let mut date_bytes = [0u8; 8]; reader.read_exact(&mut date_bytes)?; let date_of_purchase = utf8_decode_str(&date_bytes)?.to_owned(); let seller = decode_text(reader, TextDecodeOptions::new().encoding(encoding))?.content; let header = FrameHeader::new(FRAME_ID, frame_flags); Ok(Some(OwnershipFrame { header, encoding, price_paid, date_of_purchase, seller, })) } /// Convert an [`OwnershipFrame`] to a byte vec /// /// NOTE: The caller must verify that the `price_paid` field is a valid Latin-1 encoded string /// /// # Errors /// /// * `date_of_purchase` is not at least 8 characters (it will be truncated if greater) pub fn as_bytes(&self, is_id3v23: bool) -> Result> { let mut encoding = self.encoding; if is_id3v23 { encoding = encoding.to_id3v23(); } let mut bytes = vec![encoding as u8]; bytes.extend(encode_text(&self.price_paid, TextEncoding::Latin1, true)); if self.date_of_purchase.len() < 8 { return Err(Id3v2Error::new(Id3v2ErrorKind::BadFrameLength).into()); } bytes.extend(self.date_of_purchase.as_bytes().iter().take(8)); bytes.extend(encode_text(&self.seller, encoding, false)); Ok(bytes) } } #[cfg(test)] mod tests { use crate::id3::v2::{FrameFlags, FrameHeader, FrameId, OwnershipFrame}; use crate::TextEncoding; use std::borrow::Cow; fn expected() -> OwnershipFrame<'static> { OwnershipFrame { header: FrameHeader::new(FrameId::Valid(Cow::Borrowed("OWNE")), FrameFlags::default()), encoding: TextEncoding::Latin1, price_paid: String::from("USD1000"), date_of_purchase: String::from("19840407"), seller: String::from("FooBar"), } } #[test] fn owne_decode() { let cont = crate::tag::utils::test_utils::read_path("tests/tags/assets/id3v2/test.owne"); let parsed_owne = OwnershipFrame::parse(&mut &cont[..], FrameFlags::default()) .unwrap() .unwrap(); assert_eq!(parsed_owne, expected()); } #[test] fn owne_encode() { let encoded = expected().as_bytes(false).unwrap(); let expected_bytes = crate::tag::utils::test_utils::read_path("tests/tags/assets/id3v2/test.owne"); assert_eq!(encoded, expected_bytes); } } lofty-0.21.1/src/id3/v2/items/popularimeter.rs000064400000000000000000000121131046102023000172140ustar 00000000000000use crate::error::Result; use crate::id3::v2::{FrameFlags, FrameHeader, FrameId}; use crate::util::alloc::VecFallibleCapacity; use crate::util::text::{decode_text, encode_text, TextDecodeOptions, TextEncoding}; use std::borrow::Cow; use std::hash::{Hash, Hasher}; use std::io::Read; use byteorder::ReadBytesExt; const FRAME_ID: FrameId<'static> = FrameId::Valid(Cow::Borrowed("POPM")); /// The contents of a popularimeter ("POPM") frame /// /// A tag can contain multiple "POPM" frames, but there must only be /// one with the same email address. #[derive(Clone, Debug, Eq)] pub struct PopularimeterFrame<'a> { pub(crate) header: FrameHeader<'a>, /// An email address of the user performing the rating pub email: String, /// A rating of 1-255, where 1 is the worst and 255 is the best. /// A rating of 0 is unknown. /// /// For mapping this value to a star rating see: pub rating: u8, /// A play counter for the user. It is to be incremented each time the file is played. /// /// This is a `u64` for simplicity. It may change if it becomes an issue. pub counter: u64, } impl<'a> PartialEq for PopularimeterFrame<'a> { fn eq(&self, other: &Self) -> bool { self.email == other.email } } impl<'a> Hash for PopularimeterFrame<'a> { fn hash(&self, state: &mut H) { self.email.hash(state); } } impl<'a> PopularimeterFrame<'a> { /// Create a new [`PopularimeterFrame`] pub fn new(email: String, rating: u8, counter: u64) -> Self { let header = FrameHeader::new(FRAME_ID, FrameFlags::default()); Self { header, email, rating, counter, } } /// Get the ID for the frame pub fn id(&self) -> FrameId<'_> { FRAME_ID } /// Get the flags for the frame pub fn flags(&self) -> FrameFlags { self.header.flags } /// Set the flags for the frame pub fn set_flags(&mut self, flags: FrameFlags) { self.header.flags = flags; } /// Convert ID3v2 POPM frame bytes into a [`PopularimeterFrame`]. /// /// # Errors /// /// * Email is improperly encoded /// * `bytes` doesn't contain enough data pub fn parse(reader: &mut R, frame_flags: FrameFlags) -> Result where R: Read, { let email = decode_text( reader, TextDecodeOptions::new() .encoding(TextEncoding::Latin1) .terminated(true), )?; let rating = reader.read_u8()?; let mut counter_content = Vec::new(); reader.read_to_end(&mut counter_content)?; let counter; let remaining_size = counter_content.len(); if remaining_size > 8 { counter = u64::MAX; } else { let mut counter_bytes = [0; 8]; let counter_start_pos = 8 - remaining_size; counter_bytes[counter_start_pos..].copy_from_slice(&counter_content); counter = u64::from_be_bytes(counter_bytes); } let header = FrameHeader::new(FRAME_ID, frame_flags); Ok(Self { header, email: email.content, rating, counter, }) } /// Convert a [`PopularimeterFrame`] into an ID3v2 POPM frame byte Vec /// /// NOTE: This does not include a frame header /// /// # Errors /// /// * The resulting [`Vec`] exceeds [`GlobalOptions::allocation_limit`](crate::config::GlobalOptions::allocation_limit) pub fn as_bytes(&self) -> Result> { let mut content = Vec::try_with_capacity_stable(self.email.len() + 9)?; content.extend(encode_text(self.email.as_str(), TextEncoding::Latin1, true)); content.push(self.rating); // When the counter reaches all one's, one byte is inserted in front of the counter // thus making the counter eight bits bigger in the same away as the play counter ("PCNT") // // $xx xx xx xx (xx ...) if let Ok(counter) = u32::try_from(self.counter) { content.extend(counter.to_be_bytes()) } else { let counter_bytes = self.counter.to_be_bytes(); let i = counter_bytes.iter().position(|b| *b != 0).unwrap_or(4); content.extend(&counter_bytes[i..]); } Ok(content) } } #[cfg(test)] mod tests { use crate::id3::v2::items::popularimeter::PopularimeterFrame; use crate::id3::v2::{FrameFlags, FrameHeader}; fn test_popm(popm: &PopularimeterFrame<'_>) { let email = popm.email.clone(); let rating = popm.rating; let counter = popm.counter; let popm_bytes = popm.as_bytes().unwrap(); assert_eq!(&popm_bytes[..email.len()], email.as_bytes()); assert_eq!(popm_bytes[email.len()], 0); assert_eq!(popm_bytes[email.len() + 1], rating); let counter_len = if u32::try_from(counter).is_ok() { 4 } else { let counter_bytes = counter.to_be_bytes(); let i = counter_bytes.iter().position(|b| *b != 0).unwrap_or(4); counter_bytes.len() - i }; assert_eq!(popm_bytes[email.len() + 2..].len(), counter_len); } #[test] fn write_popm() { let popm_u32_boundary = PopularimeterFrame { header: FrameHeader::new(super::FRAME_ID, FrameFlags::default()), email: String::from("foo@bar.com"), rating: 255, counter: u64::from(u32::MAX), }; let popm_u40 = PopularimeterFrame { header: FrameHeader::new(super::FRAME_ID, FrameFlags::default()), email: String::from("baz@qux.com"), rating: 196, counter: u64::from(u32::MAX) + 1, }; test_popm(&popm_u32_boundary); test_popm(&popm_u40); } } lofty-0.21.1/src/id3/v2/items/private_frame.rs000064400000000000000000000064711046102023000171620ustar 00000000000000use crate::error::Result; use crate::id3::v2::{FrameFlags, FrameHeader, FrameId}; use crate::util::alloc::VecFallibleCapacity; use crate::util::text::{decode_text, encode_text, TextDecodeOptions, TextEncoding}; use std::borrow::Cow; use std::io::Read; const FRAME_ID: FrameId<'static> = FrameId::Valid(Cow::Borrowed("PRIV")); /// An `ID3v2` private frame /// /// This frame is used to contain information from a software producer that /// its program uses and does not fit into the other frames. #[derive(Clone, Debug, Eq, PartialEq, Hash)] pub struct PrivateFrame<'a> { pub(crate) header: FrameHeader<'a>, /// A URL containing an email address, or a link to a location where an email can be found, /// that belongs to the organisation responsible for the frame pub owner: String, /// Binary data pub private_data: Vec, } impl<'a> PrivateFrame<'a> { /// Create a new [`PrivateFrame`] pub fn new(owner: String, private_data: Vec) -> Self { let header = FrameHeader::new(FRAME_ID, FrameFlags::default()); Self { header, owner, private_data, } } /// Get the ID for the frame pub fn id(&self) -> FrameId<'_> { FRAME_ID } /// Get the flags for the frame pub fn flags(&self) -> FrameFlags { self.header.flags } /// Set the flags for the frame pub fn set_flags(&mut self, flags: FrameFlags) { self.header.flags = flags; } /// Read an [`PrivateFrame`] /// /// NOTE: This expects the frame header to have already been skipped /// /// # Errors /// /// * Failure to read from `reader` pub fn parse(reader: &mut R, frame_flags: FrameFlags) -> Result> where R: Read, { let Ok(owner) = decode_text( reader, TextDecodeOptions::new() .encoding(TextEncoding::Latin1) .terminated(true), ) else { return Ok(None); }; let owner = owner.content; let mut private_data = Vec::new(); reader.read_to_end(&mut private_data)?; let header = FrameHeader::new(FRAME_ID, frame_flags); Ok(Some(PrivateFrame { header, owner, private_data, })) } /// Convert an [`PrivateFrame`] to a byte vec /// /// # Errors /// /// * The resulting [`Vec`] exceeds [`GlobalOptions::allocation_limit`](crate::config::GlobalOptions::allocation_limit) pub fn as_bytes(&self) -> Result> { let Self { owner, private_data, .. } = self; let mut content = Vec::try_with_capacity_stable(owner.len() + private_data.len())?; content.extend(encode_text(owner.as_str(), TextEncoding::Latin1, true)); content.extend_from_slice(private_data); Ok(content) } } #[cfg(test)] mod tests { use crate::id3::v2::{FrameFlags, FrameHeader, PrivateFrame}; fn expected() -> PrivateFrame<'static> { PrivateFrame { header: FrameHeader::new(super::FRAME_ID, FrameFlags::default()), owner: String::from("foo@bar.com"), private_data: String::from("some data").into_bytes(), } } #[test] fn priv_decode() { let cont = crate::tag::utils::test_utils::read_path("tests/tags/assets/id3v2/test.priv"); let parsed_priv = PrivateFrame::parse(&mut &cont[..], FrameFlags::default()) .unwrap() .unwrap(); assert_eq!(parsed_priv, expected()); } #[test] fn priv_encode() { let encoded = expected().as_bytes().unwrap(); let expected_bytes = crate::tag::utils::test_utils::read_path("tests/tags/assets/id3v2/test.priv"); assert_eq!(encoded, expected_bytes); } } lofty-0.21.1/src/id3/v2/items/relative_volume_adjustment_frame.rs000064400000000000000000000215111046102023000231400ustar 00000000000000use crate::config::ParsingMode; use crate::error::{Id3v2Error, Id3v2ErrorKind, Result}; use crate::id3::v2::{FrameFlags, FrameHeader, FrameId}; use crate::macros::try_vec; use crate::util::text::{decode_text, encode_text, TextDecodeOptions, TextEncoding}; use std::borrow::Cow; use std::collections::HashMap; use std::hash::{Hash, Hasher}; use std::io::Read; use byteorder::{BigEndian, ReadBytesExt}; const FRAME_ID: FrameId<'static> = FrameId::Valid(Cow::Borrowed("RVA2")); /// A channel identifier used in the RVA2 frame #[repr(u8)] #[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Debug, Hash)] #[allow(missing_docs)] pub enum ChannelType { Other = 0, MasterVolume = 1, FrontRight = 2, FrontLeft = 3, BackRight = 4, BackLeft = 5, FrontCentre = 6, BackCentre = 7, Subwoofer = 8, } impl ChannelType { /// Get a [`ChannelType`] from a `u8` /// /// # Examples /// /// ```rust /// use lofty::id3::v2::ChannelType; /// /// let valid_byte = 1; /// assert_eq!( /// ChannelType::from_u8(valid_byte), /// Some(ChannelType::MasterVolume) /// ); /// /// // The valid range is 0..=8 /// let invalid_byte = 10; /// assert_eq!(ChannelType::from_u8(invalid_byte), None); /// ``` pub fn from_u8(byte: u8) -> Option { match byte { 0 => Some(Self::Other), 1 => Some(Self::MasterVolume), 2 => Some(Self::FrontRight), 3 => Some(Self::FrontLeft), 4 => Some(Self::BackRight), 5 => Some(Self::BackLeft), 6 => Some(Self::FrontCentre), 7 => Some(Self::BackCentre), 8 => Some(Self::Subwoofer), _ => None, } } } /// Volume adjustment information for a specific channel /// /// This is used in the RVA2 frame through [`RelativeVolumeAdjustmentFrame`] #[derive(Clone, Eq, PartialEq, Debug)] pub struct ChannelInformation { /// The type of channel this describes pub channel_type: ChannelType, /// A fixed point decibel value representing (adjustment*512), giving +/- 64 dB with a precision of 0.001953125 dB. pub volume_adjustment: i16, /// The number of bits the peak volume field occupies, with 0 meaning there is no peak volume. pub bits_representing_peak: u8, /// An optional peak volume pub peak_volume: Option>, } /// An `ID3v2` RVA2 frame /// /// NOTE: The `Eq` and `Hash` implementations depend solely on the `identification` field. #[derive(Clone, Debug, Eq)] pub struct RelativeVolumeAdjustmentFrame<'a> { pub(crate) header: FrameHeader<'a>, /// The identifier used to identify the situation and/or device where this adjustment should apply pub identification: String, /// The information for each channel described in the frame pub channels: HashMap, } impl<'a> PartialEq for RelativeVolumeAdjustmentFrame<'a> { fn eq(&self, other: &Self) -> bool { self.identification == other.identification } } impl<'a> Hash for RelativeVolumeAdjustmentFrame<'a> { fn hash(&self, state: &mut H) { self.identification.hash(state) } } impl<'a> RelativeVolumeAdjustmentFrame<'a> { /// Create a new [`RelativeVolumeAdjustmentFrame`] pub fn new(identification: String, channels: HashMap) -> Self { let header = FrameHeader::new(FRAME_ID, FrameFlags::default()); Self { header, identification, channels, } } /// Get the ID for the frame pub fn id(&self) -> FrameId<'_> { FRAME_ID } /// Get the flags for the frame pub fn flags(&self) -> FrameFlags { self.header.flags } /// Set the flags for the frame pub fn set_flags(&mut self, flags: FrameFlags) { self.header.flags = flags; } /// Read an [`RelativeVolumeAdjustmentFrame`] /// /// NOTE: This expects the frame header to have already been skipped /// /// # Errors /// /// * Bad channel type (See [Id3v2ErrorKind::BadRva2ChannelType]) /// * Not enough data pub fn parse( reader: &mut R, frame_flags: FrameFlags, parse_mode: ParsingMode, ) -> Result> where R: Read, { let identification = decode_text( reader, TextDecodeOptions::new() .encoding(TextEncoding::Latin1) .terminated(true), )? .content; let mut channels = HashMap::new(); while let Ok(channel_type_byte) = reader.read_u8() { let channel_type; match ChannelType::from_u8(channel_type_byte) { Some(channel_ty) => channel_type = channel_ty, None if parse_mode == ParsingMode::BestAttempt => channel_type = ChannelType::Other, _ => return Err(Id3v2Error::new(Id3v2ErrorKind::BadRva2ChannelType).into()), } let volume_adjustment = reader.read_i16::()?; let bits_representing_peak = reader.read_u8()?; let mut peak_volume = None; if bits_representing_peak > 0 { let bytes_representing_peak = (u16::from(bits_representing_peak) + 7) >> 3; let mut peak_volume_bytes = try_vec![0; bytes_representing_peak as usize]; reader.read_exact(&mut peak_volume_bytes)?; peak_volume = Some(peak_volume_bytes); } channels.insert( channel_type, ChannelInformation { channel_type, volume_adjustment, bits_representing_peak, peak_volume, }, ); } let header = FrameHeader::new(FRAME_ID, frame_flags); Ok(Some(Self { header, identification, channels, })) } /// Convert a [`RelativeVolumeAdjustmentFrame`] to a byte vec pub fn as_bytes(&self) -> Vec { let mut content = Vec::new(); content.extend(encode_text( &self.identification, TextEncoding::Latin1, true, )); for (channel_type, info) in &self.channels { let mut bits_representing_peak = info.bits_representing_peak; let expected_peak_byte_length = (u16::from(bits_representing_peak) + 7) >> 3; content.push(*channel_type as u8); content.extend(info.volume_adjustment.to_be_bytes()); if info.peak_volume.is_none() { // Easiest path, no peak content.push(0); continue; } if let Some(peak) = &info.peak_volume { if peak.len() > expected_peak_byte_length as usize { // Recalculate bits representing peak bits_representing_peak = 0; // Max out at 255 bits for b in peak.iter().copied().take(32) { bits_representing_peak += b.leading_ones() as u8; } } content.push(bits_representing_peak); content.extend(peak.iter().take(32)); } } content } } #[cfg(test)] mod tests { use crate::config::ParsingMode; use crate::id3::v2::{ ChannelInformation, ChannelType, FrameFlags, FrameHeader, RelativeVolumeAdjustmentFrame, }; use std::collections::HashMap; use std::io::Read; fn expected() -> RelativeVolumeAdjustmentFrame<'static> { let mut channels = HashMap::new(); channels.insert( ChannelType::MasterVolume, ChannelInformation { channel_type: ChannelType::MasterVolume, volume_adjustment: 15, bits_representing_peak: 4, peak_volume: Some(vec![4]), }, ); channels.insert( ChannelType::FrontLeft, ChannelInformation { channel_type: ChannelType::FrontLeft, volume_adjustment: 21, bits_representing_peak: 0, peak_volume: None, }, ); channels.insert( ChannelType::Subwoofer, ChannelInformation { channel_type: ChannelType::Subwoofer, volume_adjustment: 30, bits_representing_peak: 11, peak_volume: Some(vec![0xFF, 0x07]), }, ); RelativeVolumeAdjustmentFrame { header: FrameHeader::new(super::FRAME_ID, FrameFlags::default()), identification: String::from("Surround sound"), channels, } } #[test] fn rva2_decode() { let cont = crate::tag::utils::test_utils::read_path("tests/tags/assets/id3v2/test.rva2"); let parsed_rva2 = RelativeVolumeAdjustmentFrame::parse( &mut &cont[..], FrameFlags::default(), ParsingMode::Strict, ) .unwrap() .unwrap(); assert_eq!(parsed_rva2, expected()); } #[test] #[allow(unstable_name_collisions)] fn rva2_encode() { let encoded = expected().as_bytes(); let expected_bytes = crate::tag::utils::test_utils::read_path("tests/tags/assets/id3v2/test.rva2"); // We have to check the output in fragments, as the order of channels is not guaranteed. assert_eq!(encoded.len(), expected_bytes.len()); let mut needles = vec![ &[1, 0, 15, 4, 4][..], // Master volume configuration &[8, 0, 30, 11, 255, 7][..], // Front left configuration &[3, 0, 21, 0][..], // Subwoofer configuration ]; let encoded_reader = &mut &encoded[..]; let mut ident = [0; 15]; encoded_reader.read_exact(&mut ident).unwrap(); assert_eq!(ident, b"Surround sound\0"[..]); loop { if needles.is_empty() { break; } let mut remove_idx = None; for (idx, needle) in needles.iter().enumerate() { if encoded_reader.starts_with(needle) { std::io::copy( &mut encoded_reader.take(needle.len() as u64), &mut std::io::sink(), ) .unwrap(); remove_idx = Some(idx); break; } } let Some(remove_idx) = remove_idx else { unreachable!("Unexpected data in RVA2 frame: {:?}", &encoded); }; needles.remove(remove_idx); } assert!(needles.is_empty()); } } lofty-0.21.1/src/id3/v2/items/sync_text.rs000064400000000000000000000217651046102023000163610ustar 00000000000000use crate::error::{ErrorKind, Id3v2Error, Id3v2ErrorKind, LoftyError, Result}; use crate::id3::v2::{FrameFlags, FrameHeader, FrameId}; use crate::macros::err; use crate::util::text::{ decode_text, encode_text, read_to_terminator, utf16_decode_bytes, TextDecodeOptions, TextEncoding, }; use std::borrow::Cow; use std::io::{Cursor, Read, Seek, SeekFrom, Write}; use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt}; const FRAME_ID: FrameId<'static> = FrameId::Valid(Cow::Borrowed("SYLT")); /// The unit used for [`SynchronizedTextFrame`] timestamps #[derive(Copy, Clone, PartialEq, Debug, Eq, Hash)] #[repr(u8)] pub enum TimestampFormat { /// The unit is MPEG frames MPEG = 1, /// The unit is milliseconds MS = 2, } impl TimestampFormat { /// Get a `TimestampFormat` from a u8, must be 1-2 inclusive pub fn from_u8(byte: u8) -> Option { match byte { 1 => Some(Self::MPEG), 2 => Some(Self::MS), _ => None, } } } /// The type of text stored in a [`SynchronizedTextFrame`] #[derive(Copy, Clone, PartialEq, Debug, Eq, Hash)] #[repr(u8)] #[allow(missing_docs)] pub enum SyncTextContentType { Other = 0, Lyrics = 1, TextTranscription = 2, PartName = 3, Events = 4, Chord = 5, Trivia = 6, WebpageURL = 7, ImageURL = 8, } impl SyncTextContentType { /// Get a `SyncTextContentType` from a u8, must be 0-8 inclusive pub fn from_u8(byte: u8) -> Option { match byte { 0 => Some(Self::Other), 1 => Some(Self::Lyrics), 2 => Some(Self::TextTranscription), 3 => Some(Self::PartName), 4 => Some(Self::Events), 5 => Some(Self::Chord), 6 => Some(Self::Trivia), 7 => Some(Self::WebpageURL), 8 => Some(Self::ImageURL), _ => None, } } } /// Represents an ID3v2 synchronized text frame #[derive(Clone, Debug, PartialEq, Eq, Hash)] pub struct SynchronizedTextFrame<'a> { pub(crate) header: FrameHeader<'a>, /// The text encoding (description/text) pub encoding: TextEncoding, /// ISO-639-2 language code (3 bytes) pub language: [u8; 3], /// The format of the timestamps pub timestamp_format: TimestampFormat, /// The type of content stored pub content_type: SyncTextContentType, /// Unique content description pub description: Option, /// Collection of timestamps and text pub content: Vec<(u32, String)>, } impl<'a> SynchronizedTextFrame<'a> { /// Create a new [`SynchronizedTextFrame`] pub fn new( encoding: TextEncoding, language: [u8; 3], timestamp_format: TimestampFormat, content_type: SyncTextContentType, description: Option, content: Vec<(u32, String)>, ) -> Self { let header = FrameHeader::new(FRAME_ID, FrameFlags::default()); Self { header, encoding, language, timestamp_format, content_type, description, content, } } /// Get the ID for the frame pub fn id(&self) -> FrameId<'_> { FRAME_ID } /// Get the flags for the frame pub fn flags(&self) -> FrameFlags { self.header.flags } /// Set the flags for the frame pub fn set_flags(&mut self, flags: FrameFlags) { self.header.flags = flags; } /// Read a [`SynchronizedTextFrame`] from a slice /// /// NOTE: This expects the frame header to have already been skipped /// /// # Errors /// /// This function will return [`BadSyncText`][Id3v2ErrorKind::BadSyncText] if at any point it's unable to parse the data #[allow(clippy::missing_panics_doc)] // Infallible pub fn parse(data: &[u8], frame_flags: FrameFlags) -> Result { if data.len() < 7 { return Err(Id3v2Error::new(Id3v2ErrorKind::BadFrameLength).into()); } let encoding = TextEncoding::from_u8(data[0]) .ok_or_else(|| LoftyError::new(ErrorKind::TextDecode("Found invalid encoding")))?; let language: [u8; 3] = data[1..4].try_into().unwrap(); if language.iter().any(|c| !c.is_ascii_alphabetic()) { return Err(Id3v2Error::new(Id3v2ErrorKind::BadSyncText).into()); } let timestamp_format = TimestampFormat::from_u8(data[4]) .ok_or_else(|| Id3v2Error::new(Id3v2ErrorKind::BadTimestampFormat))?; let content_type = SyncTextContentType::from_u8(data[5]) .ok_or_else(|| Id3v2Error::new(Id3v2ErrorKind::BadSyncText))?; let mut cursor = Cursor::new(&data[6..]); let description = crate::util::text::decode_text( &mut cursor, TextDecodeOptions::new().encoding(encoding).terminated(true), ) .map_err(|_| Id3v2Error::new(Id3v2ErrorKind::BadSyncText))? .text_or_none(); let mut endianness: fn([u8; 2]) -> u16 = u16::from_le_bytes; // It's possible for the description to be the only string with a BOM // To be safe, we change the encoding to the concrete variant determined from the description if encoding == TextEncoding::UTF16 { endianness = match cursor.get_ref()[..=1] { [0xFF, 0xFE] => u16::from_le_bytes, [0xFE, 0xFF] => u16::from_be_bytes, // Since the description was already read, we can assume the BOM was valid _ => unreachable!(), }; } let mut pos = 0; let total = (data.len() - 6) as u64 - cursor.stream_position()?; let mut content = Vec::new(); while pos < total { let text = (|| -> Result { if encoding == TextEncoding::UTF16 { // Check for a BOM let mut bom = [0; 2]; cursor .read_exact(&mut bom) .map_err(|_| Id3v2Error::new(Id3v2ErrorKind::BadSyncText))?; cursor.seek(SeekFrom::Current(-2))?; // Encountered text that doesn't include a BOM if bom != [0xFF, 0xFE] && bom != [0xFE, 0xFF] { let (raw_text, _) = read_to_terminator(&mut cursor, TextEncoding::UTF16); return utf16_decode_bytes(&raw_text, endianness) .map_err(|_| Id3v2Error::new(Id3v2ErrorKind::BadSyncText).into()); } } let decoded_text = decode_text( &mut cursor, TextDecodeOptions::new().encoding(encoding).terminated(true), ) .map_err(|_| Id3v2Error::new(Id3v2ErrorKind::BadSyncText))?; pos += decoded_text.bytes_read as u64; Ok(decoded_text.content) })()?; let time = cursor .read_u32::() .map_err(|_| Id3v2Error::new(Id3v2ErrorKind::BadSyncText))?; pos += 4; content.push((time, text)); } let header = FrameHeader::new(FRAME_ID, frame_flags); Ok(Self { header, encoding, language, timestamp_format, content_type, description, content, }) } /// Convert a [`SynchronizedTextFrame`] to an ID3v2 SYLT frame byte Vec /// /// NOTE: This does not include the frame header /// /// # Errors /// /// * `content`'s length > [`u32::MAX`] /// * `language` is not exactly 3 bytes /// * `language` contains invalid characters (Only `'a'..='z'` and `'A'..='Z'` allowed) pub fn as_bytes(&self) -> Result> { let mut data = vec![self.encoding as u8]; if self.language.len() == 3 && self.language.iter().all(u8::is_ascii_alphabetic) { data.write_all(&self.language)?; data.write_u8(self.timestamp_format as u8)?; data.write_u8(self.content_type as u8)?; if let Some(description) = &self.description { data.write_all(&encode_text(description, self.encoding, true))?; } else { data.write_u8(0)?; } for (time, ref text) in &self.content { data.write_all(&encode_text(text, self.encoding, true))?; data.write_u32::(*time)?; } if data.len() as u64 > u64::from(u32::MAX) { err!(TooMuchData); } return Ok(data); } Err(Id3v2Error::new(Id3v2ErrorKind::BadSyncText).into()) } } #[cfg(test)] mod tests { use crate::id3::v2::{ FrameFlags, FrameHeader, SyncTextContentType, SynchronizedTextFrame, TimestampFormat, }; use crate::util::text::TextEncoding; fn expected(encoding: TextEncoding) -> SynchronizedTextFrame<'static> { SynchronizedTextFrame { header: FrameHeader::new(super::FRAME_ID, FrameFlags::default()), encoding, language: *b"eng", timestamp_format: TimestampFormat::MS, content_type: SyncTextContentType::Lyrics, description: Some(String::from("Test Sync Text")), content: vec![ (0, String::from("\nLofty")), (10000, String::from("\nIs")), (15000, String::from("\nReading")), (30000, String::from("\nThis")), (1_938_000, String::from("\nCorrectly")), ], } } #[test] fn sylt_decode() { let cont = crate::tag::utils::test_utils::read_path("tests/tags/assets/id3v2/test.sylt"); let parsed_sylt = SynchronizedTextFrame::parse(&cont, FrameFlags::default()).unwrap(); assert_eq!(parsed_sylt, expected(TextEncoding::Latin1)); } #[test] fn sylt_encode() { let encoded = expected(TextEncoding::Latin1).as_bytes().unwrap(); let expected_bytes = crate::tag::utils::test_utils::read_path("tests/tags/assets/id3v2/test.sylt"); assert_eq!(encoded, expected_bytes); } #[test] fn sylt_decode_utf16() { let cont = crate::tag::utils::test_utils::read_path("tests/tags/assets/id3v2/test_utf16.sylt"); let parsed_sylt = SynchronizedTextFrame::parse(&cont, FrameFlags::default()).unwrap(); assert_eq!(parsed_sylt, expected(TextEncoding::UTF16)); } #[test] fn sylt_encode_utf_16() { let encoded = expected(TextEncoding::UTF16).as_bytes().unwrap(); let expected_bytes = crate::tag::utils::test_utils::read_path("tests/tags/assets/id3v2/test_utf16.sylt"); assert_eq!(encoded, expected_bytes); } } lofty-0.21.1/src/id3/v2/items/text_information_frame.rs000064400000000000000000000047051046102023000210770ustar 00000000000000use crate::error::Result; use crate::id3::v2::frame::content::verify_encoding; use crate::id3::v2::header::Id3v2Version; use crate::id3::v2::{FrameFlags, FrameHeader, FrameId}; use crate::util::text::{decode_text, encode_text, TextDecodeOptions, TextEncoding}; use byteorder::ReadBytesExt; use std::hash::Hash; use std::io::Read; /// An `ID3v2` text frame #[derive(Clone, Debug, Eq)] pub struct TextInformationFrame<'a> { pub(crate) header: FrameHeader<'a>, /// The encoding of the text pub encoding: TextEncoding, /// The text itself pub value: String, } impl PartialEq for TextInformationFrame<'_> { fn eq(&self, other: &Self) -> bool { self.header.id == other.header.id } } impl Hash for TextInformationFrame<'_> { fn hash(&self, state: &mut H) { self.header.id.hash(state); } } impl<'a> TextInformationFrame<'a> { /// Create a new [`TextInformationFrame`] pub fn new(id: FrameId<'a>, encoding: TextEncoding, value: String) -> Self { let header = FrameHeader::new(id, FrameFlags::default()); Self { header, encoding, value, } } /// Get the ID for the frame pub fn id(&self) -> &FrameId<'_> { &self.header.id } /// Get the flags for the frame pub fn flags(&self) -> FrameFlags { self.header.flags } /// Set the flags for the frame pub fn set_flags(&mut self, flags: FrameFlags) { self.header.flags = flags; } /// Read an [`TextInformationFrame`] from a slice /// /// NOTE: This expects the frame header to have already been skipped /// /// # Errors /// /// * Unable to decode the text /// /// ID3v2.2: /// /// * The encoding is not [`TextEncoding::Latin1`] or [`TextEncoding::UTF16`] pub fn parse( reader: &mut R, id: FrameId<'a>, frame_flags: FrameFlags, version: Id3v2Version, ) -> Result> where R: Read, { let Ok(encoding_byte) = reader.read_u8() else { return Ok(None); }; let encoding = verify_encoding(encoding_byte, version)?; let value = decode_text(reader, TextDecodeOptions::new().encoding(encoding))?.content; let header = FrameHeader::new(id, frame_flags); Ok(Some(TextInformationFrame { header, encoding, value, })) } /// Convert an [`TextInformationFrame`] to a byte vec pub fn as_bytes(&self, is_id3v23: bool) -> Vec { let mut encoding = self.encoding; if is_id3v23 { encoding = encoding.to_id3v23(); } let mut content = encode_text(&self.value, encoding, false); content.insert(0, encoding as u8); content } } lofty-0.21.1/src/id3/v2/items/timestamp_frame.rs000064400000000000000000000057171046102023000175150ustar 00000000000000use crate::config::ParsingMode; use crate::error::{ErrorKind, LoftyError, Result}; use crate::id3::v2::{FrameFlags, FrameHeader, FrameId}; use crate::macros::err; use crate::tag::items::Timestamp; use crate::util::text::{decode_text, encode_text, TextDecodeOptions, TextEncoding}; use std::io::Read; use byteorder::ReadBytesExt; /// An `ID3v2` timestamp frame #[derive(Clone, Debug, Eq, PartialEq, Hash)] #[allow(missing_docs)] pub struct TimestampFrame<'a> { pub(crate) header: FrameHeader<'a>, pub encoding: TextEncoding, pub timestamp: Timestamp, } impl<'a> PartialOrd for TimestampFrame<'a> { fn partial_cmp(&self, other: &Self) -> Option { Some(self.cmp(other)) } } impl<'a> Ord for TimestampFrame<'a> { fn cmp(&self, other: &Self) -> std::cmp::Ordering { self.timestamp.cmp(&other.timestamp) } } impl<'a> TimestampFrame<'a> { /// Create a new [`TimestampFrame`] pub fn new(id: FrameId<'a>, encoding: TextEncoding, timestamp: Timestamp) -> Self { let header = FrameHeader::new(id, FrameFlags::default()); Self { header, encoding, timestamp, } } /// Get the ID for the frame pub fn id(&self) -> &FrameId<'_> { &self.header.id } /// Get the flags for the frame pub fn flags(&self) -> FrameFlags { self.header.flags } /// Set the flags for the frame pub fn set_flags(&mut self, flags: FrameFlags) { self.header.flags = flags; } /// Read a [`TimestampFrame`] /// /// NOTE: This expects the frame header to have already been skipped /// /// # Errors /// /// * Failure to read from `reader` #[allow(clippy::never_loop)] pub fn parse( reader: &mut R, id: FrameId<'a>, frame_flags: FrameFlags, parse_mode: ParsingMode, ) -> Result> where R: Read, { let Ok(encoding_byte) = reader.read_u8() else { return Ok(None); }; let Some(encoding) = TextEncoding::from_u8(encoding_byte) else { return Err(LoftyError::new(ErrorKind::TextDecode( "Found invalid encoding", ))); }; let value = decode_text(reader, TextDecodeOptions::new().encoding(encoding))?.content; if !value.is_ascii() { err!(BadTimestamp("Timestamp contains non-ASCII characters")) } let header = FrameHeader::new(id, frame_flags); let mut frame = TimestampFrame { header, encoding, timestamp: Timestamp::default(), }; let reader = &mut value.as_bytes(); let Some(timestamp) = Timestamp::parse(reader, parse_mode)? else { // Timestamp is empty return Ok(None); }; frame.timestamp = timestamp; Ok(Some(frame)) } /// Convert an [`TimestampFrame`] to a byte vec /// /// # Errors /// /// * The timestamp is invalid /// * Failure to write to the buffer pub fn as_bytes(&self, is_id3v23: bool) -> Result> { let mut encoding = self.encoding; if is_id3v23 { encoding = encoding.to_id3v23(); } self.timestamp.verify()?; let mut encoded_text = encode_text(&self.timestamp.to_string(), encoding, false); encoded_text.insert(0, encoding as u8); Ok(encoded_text) } } lofty-0.21.1/src/id3/v2/items/unique_file_identifier.rs000064400000000000000000000066641046102023000210510ustar 00000000000000use crate::config::ParsingMode; use crate::error::{Id3v2Error, Id3v2ErrorKind, Result}; use crate::id3::v2::{FrameFlags, FrameHeader, FrameId}; use crate::macros::parse_mode_choice; use crate::util::text::{decode_text, encode_text, TextDecodeOptions, TextEncoding}; use std::borrow::Cow; use std::hash::{Hash, Hasher}; use std::io::Read; const FRAME_ID: FrameId<'static> = FrameId::Valid(Cow::Borrowed("UFID")); /// An `ID3v2` unique file identifier frame (UFID). #[derive(Clone, Debug, Eq)] pub struct UniqueFileIdentifierFrame<'a> { pub(crate) header: FrameHeader<'a>, /// The non-empty owner of the identifier. pub owner: String, /// The binary payload with up to 64 bytes of data. pub identifier: Vec, } impl<'a> PartialEq for UniqueFileIdentifierFrame<'a> { fn eq(&self, other: &Self) -> bool { self.owner == other.owner } } impl<'a> Hash for UniqueFileIdentifierFrame<'a> { fn hash(&self, state: &mut H) { self.owner.hash(state); } } impl<'a> UniqueFileIdentifierFrame<'a> { /// Create a new [`UniqueFileIdentifierFrame`] pub fn new(owner: String, identifier: Vec) -> Self { let header = FrameHeader::new(FRAME_ID, FrameFlags::default()); Self { header, owner, identifier, } } /// Get the ID for the frame pub fn id(&self) -> FrameId<'_> { FRAME_ID } /// Get the flags for the frame pub fn flags(&self) -> FrameFlags { self.header.flags } /// Set the flags for the frame pub fn set_flags(&mut self, flags: FrameFlags) { self.header.flags = flags; } /// Decode the frame contents from bytes /// /// # Errors /// /// Owner is missing or improperly encoded pub fn parse( reader: &mut R, frame_flags: FrameFlags, parse_mode: ParsingMode, ) -> Result> where R: Read, { let owner_decode_result = decode_text( reader, TextDecodeOptions::new() .encoding(TextEncoding::Latin1) .terminated(true), )?; let owner; match owner_decode_result.text_or_none() { Some(valid) => owner = valid, None => { parse_mode_choice!( parse_mode, BESTATTEMPT: owner = String::new(), DEFAULT: return Err(Id3v2Error::new(Id3v2ErrorKind::MissingUfidOwner).into()) ); }, } let mut identifier = Vec::new(); reader.read_to_end(&mut identifier)?; let header = FrameHeader::new(FRAME_ID, frame_flags); Ok(Some(Self { header, owner, identifier, })) } /// Encode the frame contents as bytes pub fn as_bytes(&self) -> Vec { let Self { owner, identifier, .. } = self; let mut content = Vec::with_capacity(owner.len() + 1 + identifier.len()); content.extend(encode_text(owner.as_str(), TextEncoding::Latin1, true)); content.extend_from_slice(identifier); content } } #[cfg(test)] mod tests { use crate::id3::v2::{FrameFlags, FrameHeader, FrameId}; use std::borrow::Cow; #[test] fn issue_204_invalid_ufid_parsing_mode_best_attempt() { use crate::config::ParsingMode; use crate::id3::v2::UniqueFileIdentifierFrame; let ufid_no_owner = UniqueFileIdentifierFrame { header: FrameHeader::new(FrameId::Valid(Cow::Borrowed("UFID")), FrameFlags::default()), owner: String::new(), identifier: vec![0], }; let bytes = ufid_no_owner.as_bytes(); assert!(UniqueFileIdentifierFrame::parse( &mut &bytes[..], FrameFlags::default(), ParsingMode::Strict ) .is_err()); assert!(UniqueFileIdentifierFrame::parse( &mut &bytes[..], FrameFlags::default(), ParsingMode::BestAttempt ) .is_ok()); } } lofty-0.21.1/src/id3/v2/items/url_link_frame.rs000064400000000000000000000043031046102023000173170ustar 00000000000000use crate::error::Result; use crate::id3::v2::{FrameFlags, FrameHeader, FrameId}; use crate::util::text::{decode_text, encode_text, TextDecodeOptions, TextEncoding}; use std::hash::Hash; use std::io::Read; /// An `ID3v2` URL frame #[derive(Clone, Debug, Eq)] pub struct UrlLinkFrame<'a> { pub(crate) header: FrameHeader<'a>, pub(crate) content: String, } impl PartialEq for UrlLinkFrame<'_> { fn eq(&self, other: &Self) -> bool { self.header.id == other.header.id } } impl Hash for UrlLinkFrame<'_> { fn hash(&self, state: &mut H) { self.header.id.hash(state); } } impl<'a> UrlLinkFrame<'a> { /// Create a new [`UrlLinkFrame`] pub fn new(id: FrameId<'a>, content: String) -> Self { UrlLinkFrame { header: FrameHeader::new(id, FrameFlags::default()), content, } } /// Get the ID for the frame pub fn id(&self) -> &FrameId<'_> { &self.header.id } /// Get the flags for the frame pub fn flags(&self) -> FrameFlags { self.header.flags } /// Set the flags for the frame pub fn set_flags(&mut self, flags: FrameFlags) { self.header.flags = flags; } /// Read an [`UrlLinkFrame`] from a slice /// /// NOTE: This expects the frame header to have already been skipped /// /// # Errors /// /// * Unable to decode the text as [`TextEncoding::Latin1`] pub fn parse( reader: &mut R, id: FrameId<'a>, frame_flags: FrameFlags, ) -> Result> where R: Read, { let url = decode_text( reader, TextDecodeOptions::new() .encoding(TextEncoding::Latin1) .terminated(true), )?; if url.bytes_read == 0 { return Ok(None); } let header = FrameHeader::new(id, frame_flags); Ok(Some(UrlLinkFrame { header, content: url.content, })) } /// Convert an [`UrlLinkFrame`] to a byte vec pub fn as_bytes(&self) -> Vec { encode_text(&self.content, TextEncoding::Latin1, false) } /// Get the URL of the frame pub fn url(&self) -> &str { &self.content } /// Change the URL of the frame /// /// This will return a `bool` indicating whether or not the URL provided is Latin-1 pub fn set_url(&mut self, url: String) -> bool { if TextEncoding::verify_latin1(&url) { self.content = url; return true; } false } } lofty-0.21.1/src/id3/v2/mod.rs000064400000000000000000000011261046102023000137640ustar 00000000000000//! ID3v2 items and utilities //! //! ## Important notes //! //! See: //! //! * [`Id3v2Tag`] //! * [`Frame`] mod frame; pub(crate) mod header; mod items; pub(crate) mod read; mod restrictions; pub(crate) mod tag; pub mod util; pub(crate) mod write; // Exports pub use header::{Id3v2TagFlags, Id3v2Version}; pub use util::upgrade::{upgrade_v2, upgrade_v3}; pub use tag::Id3v2Tag; pub use items::*; pub use frame::header::{FrameHeader, FrameId}; pub use frame::{Frame, FrameFlags}; pub use restrictions::{ ImageSizeRestrictions, TagRestrictions, TagSizeRestrictions, TextSizeRestrictions, }; lofty-0.21.1/src/id3/v2/read.rs000064400000000000000000000145261046102023000141300ustar 00000000000000use super::frame::read::ParsedFrame; use super::header::Id3v2Header; use super::tag::Id3v2Tag; use crate::config::ParseOptions; use crate::error::{Id3v2Error, Id3v2ErrorKind, Result}; use crate::id3::v2::util::synchsafe::UnsynchronizedStream; use crate::id3::v2::{Frame, FrameId, Id3v2Version, TimestampFrame}; use crate::tag::items::Timestamp; use std::borrow::Cow; use std::io::Read; pub(crate) fn parse_id3v2( bytes: &mut R, header: Id3v2Header, parse_options: ParseOptions, ) -> Result where R: Read, { log::debug!( "Parsing ID3v2 tag, size: {}, version: {:?}", header.size, header.version ); let mut tag_bytes = bytes.take(u64::from(header.size - header.extended_size)); let mut ret; if header.flags.unsynchronisation { // Unsynchronize the entire tag let mut unsynchronized_reader = UnsynchronizedStream::new(tag_bytes); ret = read_all_frames_into_tag(&mut unsynchronized_reader, header, parse_options)?; // Get the `Take` back from the `UnsynchronizedStream` tag_bytes = unsynchronized_reader.into_inner(); } else { ret = read_all_frames_into_tag(&mut tag_bytes, header, parse_options)?; }; // Throw away the rest of the tag (padding, bad frames) std::io::copy(&mut tag_bytes, &mut std::io::sink())?; // Construct TDRC frame from TYER, TDAT, and TIME frames if parse_options.implicit_conversions && header.version == Id3v2Version::V3 { construct_tdrc_from_v3(&mut ret); } Ok(ret) } fn construct_tdrc_from_v3(tag: &mut Id3v2Tag) { const TDRC: FrameId<'_> = FrameId::Valid(Cow::Borrowed("TDRC")); const TDAT: FrameId<'_> = FrameId::Valid(Cow::Borrowed("TDAT")); const TIME: FrameId<'_> = FrameId::Valid(Cow::Borrowed("TIME")); // Our TYER frame gets converted to TDRC earlier let Some(year_frame) = tag.remove(&TDRC).next() else { return; }; let Frame::Timestamp(year_frame) = year_frame else { log::warn!("TYER frame is not a timestamp frame, retaining."); tag.insert(year_frame); return; }; // This is not a TYER frame if year_frame.timestamp.month.is_some() { return; } let date = tag.get_text(&TDAT); let mut date_used = false; let time = tag.get_text(&TIME); let mut time_used = false; let mut tdrc = Timestamp { year: year_frame.timestamp.year, ..Timestamp::default() }; 'build: { if let Some(date) = date { if date.len() != 4 { log::warn!("Invalid TDAT frame, retaining."); break 'build; } let (Ok(day), Ok(month)) = (date[..2].parse::(), date[2..].parse::()) else { log::warn!("Invalid TDAT frame, retaining."); break 'build; }; tdrc.month = Some(month); tdrc.day = Some(day); date_used = true; if let Some(time) = time { if time.len() != 4 { log::warn!("Invalid TIME frame, retaining."); break 'build; } let (Ok(hour), Ok(minute)) = (time[..2].parse::(), time[2..].parse::()) else { log::warn!("Invalid TIME frame, retaining."); break 'build; }; tdrc.hour = Some(hour); tdrc.minute = Some(minute); time_used = true; } } } tag.insert(Frame::Timestamp(TimestampFrame::new( FrameId::Valid(Cow::Borrowed("TDRC")), year_frame.encoding, tdrc, ))); if date_used { let _ = tag.remove(&TDAT); } if time_used { let _ = tag.remove(&TIME); } } fn skip_frame(reader: &mut impl Read, size: u32) -> Result<()> { log::trace!("Skipping frame of size {}", size); let size = u64::from(size); let mut reader = reader.take(size); let skipped = std::io::copy(&mut reader, &mut std::io::sink())?; debug_assert!(skipped <= size); if skipped != size { return Err(Id3v2Error::new(Id3v2ErrorKind::BadFrameLength).into()); } Ok(()) } fn read_all_frames_into_tag( reader: &mut R, header: Id3v2Header, parse_options: ParseOptions, ) -> Result where R: Read, { let mut tag = Id3v2Tag::default(); tag.original_version = header.version; tag.set_flags(header.flags); loop { match ParsedFrame::read(reader, header.version, parse_options)? { ParsedFrame::Next(frame) => { let frame_value_is_empty = frame.is_empty(); if let Some(replaced_frame) = tag.insert(frame) { // Duplicate frames are not allowed. But if this occurs we try // to keep the frame with the non-empty content. Superfluous, // duplicate frames that follow the first frame are often empty. if frame_value_is_empty == Some(true) && replaced_frame.is_empty() == Some(false) { log::warn!( "Restoring non-empty frame with ID \"{id}\" that has been replaced by \ an empty frame with the same ID", id = replaced_frame.id() ); drop(tag.insert(replaced_frame)); } else { log::warn!( "Replaced frame with ID \"{id}\" by a frame with the same ID", id = replaced_frame.id() ); } } }, // No frame content found or ignored due to errors, but we can expect more frames ParsedFrame::Skip { size } => { skip_frame(reader, size)?; }, // No frame content found, and we can expect there are no more frames ParsedFrame::Eof => break, } } Ok(tag) } #[test] fn zero_size_id3v2() { use crate::config::ParsingMode; use crate::id3::v2::header::Id3v2Header; use std::io::Cursor; let mut f = Cursor::new(std::fs::read("tests/tags/assets/id3v2/zero.id3v2").unwrap()); let header = Id3v2Header::parse(&mut f).unwrap(); assert!(parse_id3v2( &mut f, header, ParseOptions::new().parsing_mode(ParsingMode::Strict) ) .is_ok()); } #[test] fn bad_frame_id_relaxed_id3v2() { use crate::config::ParsingMode; use crate::id3::v2::header::Id3v2Header; use crate::prelude::*; use std::io::Cursor; // Contains a frame with a "+" in the ID, which is invalid. // All other frames in the tag are valid, however. let mut f = Cursor::new( std::fs::read("tests/tags/assets/id3v2/bad_frame_otherwise_valid.id3v24").unwrap(), ); let header = Id3v2Header::parse(&mut f).unwrap(); let id3v2 = parse_id3v2( &mut f, header, ParseOptions::new().parsing_mode(ParsingMode::Relaxed), ); assert!(id3v2.is_ok()); let id3v2 = id3v2.unwrap(); // There are 6 valid frames and 1 invalid frame assert_eq!(id3v2.len(), 6); assert_eq!(id3v2.title().as_deref(), Some("Foo title")); assert_eq!(id3v2.artist().as_deref(), Some("Bar artist")); assert_eq!(id3v2.comment().as_deref(), Some("Qux comment")); assert_eq!(id3v2.year(), Some(1984)); assert_eq!(id3v2.track(), Some(1)); assert_eq!(id3v2.genre().as_deref(), Some("Classical")); } lofty-0.21.1/src/id3/v2/restrictions.rs000064400000000000000000000077731046102023000157530ustar 00000000000000/// Restrictions on the tag size #[derive(Default, Clone, Copy, Debug, PartialEq, Eq)] #[allow(non_camel_case_types)] pub enum TagSizeRestrictions { /// No more than 128 frames and 1 MB total tag size #[default] S_128F_1M, /// No more than 64 frames and 128 KB total tag size S_64F_128K, /// No more than 32 frames and 40 KB total tag size S_32F_40K, /// No more than 32 frames and 4 KB total tag size S_32F_4K, } /// Restrictions on text field sizes #[derive(Clone, Copy, Debug, PartialEq, Eq)] #[allow(non_camel_case_types)] pub enum TextSizeRestrictions { /// No longer than 1024 characters C_1024, /// No longer than 128 characters C_128, /// No longer than 30 characters C_30, } /// Restrictions on all image sizes #[derive(Clone, Copy, Debug, PartialEq, Eq)] #[allow(non_camel_case_types)] pub enum ImageSizeRestrictions { /// All images are 256x256 or smaller P_256, /// All images are 64x64 or smaller P_64, /// All images are **exactly** 64x64 P_64_64, } /// Restrictions on the content of an ID3v2 tag #[derive(Default, Clone, Copy, Debug, PartialEq, Eq)] pub struct TagRestrictions { /// Restriction on the size of the tag. See [`TagSizeRestrictions`] pub size: TagSizeRestrictions, /// Text encoding restrictions /// /// `false` - No restrictions /// `true` - Strings are only encoded with [`TextEncoding::Latin1`](crate::TextEncoding::Latin1) or [`TextEncoding::UTF8`](crate::TextEncoding::UTF8) pub text_encoding: bool, /// Restrictions on all text field sizes. See [`TextSizeRestrictions`] pub text_fields_size: Option, /// Image encoding restrictions /// /// `false` - No restrictions /// `true` - Images can only be `PNG` or `JPEG` pub image_encoding: bool, /// Restrictions on all image sizes. See [`ImageSizeRestrictions`] pub image_size: Option, } impl TagRestrictions { /// Read a [`TagRestrictions`] from a byte /// /// NOTE: See section 3.2, item d pub fn from_byte(byte: u8) -> Self { let mut restrictions = TagRestrictions::default(); let restriction_flags = byte; // xx000000 match restriction_flags & 0x0C { 64 => restrictions.size = TagSizeRestrictions::S_64F_128K, 128 => restrictions.size = TagSizeRestrictions::S_32F_40K, 192 => restrictions.size = TagSizeRestrictions::S_32F_4K, _ => {}, // 0, default } // 00x00000 if restriction_flags & 0x20 == 0x20 { restrictions.text_encoding = true } // 000xx000 match restriction_flags & 0x18 { 8 => restrictions.text_fields_size = Some(TextSizeRestrictions::C_1024), 16 => restrictions.text_fields_size = Some(TextSizeRestrictions::C_128), 24 => restrictions.text_fields_size = Some(TextSizeRestrictions::C_30), _ => {}, // 0, default } // 00000x00 if restriction_flags & 0x04 == 0x04 { restrictions.image_encoding = true } // 000000xx match restriction_flags & 0x03 { 1 => restrictions.image_size = Some(ImageSizeRestrictions::P_256), 2 => restrictions.image_size = Some(ImageSizeRestrictions::P_64), 3 => restrictions.image_size = Some(ImageSizeRestrictions::P_64_64), _ => {}, // 0, default } restrictions } /// Convert a [`TagRestrictions`] into a `u8` #[allow(clippy::trivially_copy_pass_by_ref)] pub fn as_bytes(&self) -> u8 { let mut byte = 0; match self.size { TagSizeRestrictions::S_128F_1M => {}, TagSizeRestrictions::S_64F_128K => byte |= 0x40, TagSizeRestrictions::S_32F_40K => byte |= 0x80, TagSizeRestrictions::S_32F_4K => byte |= 0x0C, } if self.text_encoding { byte |= 0x20 } match self.text_fields_size { Some(TextSizeRestrictions::C_1024) => byte |= 0x08, Some(TextSizeRestrictions::C_128) => byte |= 0x10, Some(TextSizeRestrictions::C_30) => byte |= 0x18, _ => {}, } if self.image_encoding { byte |= 0x04 } match self.image_size { Some(ImageSizeRestrictions::P_256) => byte |= 0x01, Some(ImageSizeRestrictions::P_64) => byte |= 0x02, Some(ImageSizeRestrictions::P_64_64) => byte |= 0x03, _ => {}, } byte } } lofty-0.21.1/src/id3/v2/tag/tests.rs000064400000000000000000001131561046102023000151310ustar 00000000000000use crate::config::{ParseOptions, ParsingMode}; use crate::id3::v2::header::Id3v2Header; use crate::id3::v2::items::PopularimeterFrame; use crate::id3::v2::util::pairs::DEFAULT_NUMBER_IN_PAIR; use crate::id3::v2::{ ChannelInformation, ChannelType, RelativeVolumeAdjustmentFrame, TimestampFrame, }; use crate::picture::MimeType; use crate::tag::items::{Timestamp, ENGLISH}; use crate::tag::utils::test_utils::read_path; use super::*; use std::collections::HashMap; const COMMENT_FRAME_ID: &str = "COMM"; fn read_tag(path: &str) -> Id3v2Tag { let tag_bytes = read_path(path); read_tag_with_options( &tag_bytes, ParseOptions::new().parsing_mode(ParsingMode::Strict), ) } fn read_tag_with_options(bytes: &[u8], parse_options: ParseOptions) -> Id3v2Tag { let mut reader = Cursor::new(bytes); let header = Id3v2Header::parse(&mut reader).unwrap(); crate::id3::v2::read::parse_id3v2(&mut reader, header, parse_options).unwrap() } fn dump_and_re_read(tag: &Id3v2Tag, write_options: WriteOptions) -> Id3v2Tag { let mut tag_bytes = Vec::new(); let mut writer = Cursor::new(&mut tag_bytes); tag.dump_to(&mut writer, write_options).unwrap(); read_tag_with_options( &tag_bytes[..], ParseOptions::new().parsing_mode(ParsingMode::Strict), ) } #[test] fn parse_id3v2() { let mut expected_tag = Id3v2Tag::default(); let encoding = TextEncoding::Latin1; expected_tag.insert(Frame::Text(TextInformationFrame::new( FrameId::Valid(Cow::Borrowed("TPE1")), encoding, String::from("Bar artist"), ))); expected_tag.insert(Frame::Text(TextInformationFrame::new( FrameId::Valid(Cow::Borrowed("TIT2")), encoding, String::from("Foo title"), ))); expected_tag.insert(Frame::Text(TextInformationFrame::new( FrameId::Valid(Cow::Borrowed("TALB")), encoding, String::from("Baz album"), ))); expected_tag.insert(Frame::Comment(CommentFrame::new( encoding, *b"eng", EMPTY_CONTENT_DESCRIPTOR, String::from("Qux comment"), ))); expected_tag.insert(Frame::Timestamp(TimestampFrame::new( FrameId::Valid(Cow::Borrowed("TDRC")), encoding, Timestamp { year: 1984, ..Timestamp::default() }, ))); expected_tag.insert(Frame::Text(TextInformationFrame::new( FrameId::Valid(Cow::Borrowed("TRCK")), encoding, String::from("1"), ))); expected_tag.insert(Frame::Text(TextInformationFrame::new( FrameId::Valid(Cow::Borrowed("TCON")), encoding, String::from("Classical"), ))); let parsed_tag = read_tag("tests/tags/assets/id3v2/test.id3v24"); assert_eq!(expected_tag, parsed_tag); } #[test] fn id3v2_re_read() { let parsed_tag = read_tag("tests/tags/assets/id3v2/test.id3v24"); let mut writer = Vec::new(); parsed_tag .dump_to(&mut writer, WriteOptions::default()) .unwrap(); let temp_reader = &mut &*writer; let temp_header = Id3v2Header::parse(temp_reader).unwrap(); let temp_parsed_tag = crate::id3::v2::read::parse_id3v2( temp_reader, temp_header, ParseOptions::new().parsing_mode(ParsingMode::Strict), ) .unwrap(); assert_eq!(parsed_tag, temp_parsed_tag); } #[test] fn id3v2_to_tag() { let id3v2 = read_tag("tests/tags/assets/id3v2/test.id3v24"); let tag: Tag = id3v2.into(); crate::tag::utils::test_utils::verify_tag(&tag, true, true); } #[test] fn fail_write_bad_frame() { let mut tag = Id3v2Tag::default(); tag.insert(Frame::Url(UrlLinkFrame::new( FrameId::Valid(Cow::Borrowed("ABCD")), String::from("FOO URL"), ))); let res = tag.dump_to(&mut Vec::::new(), WriteOptions::default()); assert!(res.is_err()); assert_eq!( res.unwrap_err().to_string(), String::from("ID3v2: Attempted to write an invalid frame. ID: \"ABCD\", Value: \"Url\"") ); } #[test] fn tag_to_id3v2() { fn verify_frame(tag: &Id3v2Tag, id: &str, value: &str) { let frame = tag.get(&FrameId::Valid(Cow::Borrowed(id))); assert!(frame.is_some()); let frame = frame.unwrap(); assert_eq!( frame, &Frame::Text(TextInformationFrame::new( FrameId::Valid(Cow::Borrowed(id)), TextEncoding::UTF8, String::from(value) )), ); } let tag = crate::tag::utils::test_utils::create_tag(TagType::Id3v2); let id3v2_tag: Id3v2Tag = tag.into(); verify_frame(&id3v2_tag, "TIT2", "Foo title"); verify_frame(&id3v2_tag, "TPE1", "Bar artist"); verify_frame(&id3v2_tag, "TALB", "Baz album"); let frame = id3v2_tag .get(&FrameId::Valid(Cow::Borrowed(COMMENT_FRAME_ID))) .unwrap(); assert_eq!( frame, &Frame::Comment(CommentFrame::new( TextEncoding::UTF8, *b"XXX", EMPTY_CONTENT_DESCRIPTOR, String::from("Qux comment"), )) ); verify_frame(&id3v2_tag, "TRCK", "1"); verify_frame(&id3v2_tag, "TCON", "Classical"); } #[allow(clippy::field_reassign_with_default)] fn create_full_test_tag(version: Id3v2Version) -> Id3v2Tag { let mut tag = Id3v2Tag::default(); tag.original_version = version; let encoding = TextEncoding::UTF16; tag.insert(Frame::Text(TextInformationFrame::new( FrameId::Valid(Cow::Borrowed("TIT2")), encoding, String::from("TempleOS Hymn Risen (Remix)"), ))); tag.insert(Frame::Text(TextInformationFrame::new( FrameId::Valid(Cow::Borrowed("TPE1")), encoding, String::from("Dave Eddy"), ))); tag.insert(Frame::Text(TextInformationFrame::new( FrameId::Valid(Cow::Borrowed("TRCK")), encoding, String::from("1"), ))); tag.insert(Frame::Text(TextInformationFrame::new( FrameId::Valid(Cow::Borrowed("TALB")), encoding, String::from("Summer"), ))); tag.insert(Frame::Timestamp(TimestampFrame::new( FrameId::Valid(Cow::Borrowed("TDRC")), encoding, Timestamp { year: 2017, ..Timestamp::default() }, ))); tag.insert(Frame::Text(TextInformationFrame::new( FrameId::Valid(Cow::Borrowed("TCON")), encoding, String::from("Electronic"), ))); tag.insert(Frame::Text(TextInformationFrame::new( FrameId::Valid(Cow::Borrowed("TLEN")), encoding, String::from("213017"), ))); tag.insert(Frame::Picture(AttachedPictureFrame::new( TextEncoding::Latin1, Picture { pic_type: PictureType::CoverFront, mime_type: Some(MimeType::Png), description: None, data: read_path("tests/tags/assets/id3v2/test_full_cover.png").into(), }, ))); tag } #[test] fn id3v24_full() { let tag = create_full_test_tag(Id3v2Version::V4); let parsed_tag = read_tag("tests/tags/assets/id3v2/test_full.id3v24"); assert_eq!(tag, parsed_tag); } #[test] fn id3v23_full() { let mut tag = create_full_test_tag(Id3v2Version::V3); let mut parsed_tag = read_tag("tests/tags/assets/id3v2/test_full.id3v23"); // Tags may change order after being read, due to the TDRC conversion tag.frames.sort_by_key(|frame| frame.id_str().to_string()); parsed_tag .frames .sort_by_key(|frame| frame.id_str().to_string()); assert_eq!(tag, parsed_tag); } #[test] fn id3v22_full() { let tag = create_full_test_tag(Id3v2Version::V2); let parsed_tag = read_tag("tests/tags/assets/id3v2/test_full.id3v22"); assert_eq!(tag, parsed_tag); } #[test] fn id3v24_footer() { let mut tag = create_full_test_tag(Id3v2Version::V4); tag.flags.footer = true; let mut writer = Vec::new(); tag.dump_to(&mut writer, WriteOptions::default()).unwrap(); let mut reader = &mut &writer[..]; let header = Id3v2Header::parse(&mut reader).unwrap(); let _ = crate::id3::v2::read::parse_id3v2( reader, header, ParseOptions::new().parsing_mode(ParsingMode::Strict), ) .unwrap(); assert_eq!(writer[3..10], writer[writer.len() - 7..]) } #[test] fn issue_36() { let picture_data = vec![0; 200]; let picture = Picture::new_unchecked( PictureType::CoverFront, Some(MimeType::Jpeg), Some(String::from("cover")), picture_data, ); let mut tag = Tag::new(TagType::Id3v2); tag.push_picture(picture.clone()); let mut writer = Vec::new(); tag.dump_to(&mut writer, WriteOptions::default()).unwrap(); let mut reader = &mut &writer[..]; let header = Id3v2Header::parse(&mut reader).unwrap(); let tag = crate::id3::v2::read::parse_id3v2( reader, header, ParseOptions::new().parsing_mode(ParsingMode::Strict), ) .unwrap(); assert_eq!(tag.len(), 1); assert_eq!( tag.frames.first(), Some(&Frame::Picture(AttachedPictureFrame::new( TextEncoding::UTF8, picture ))) ); } #[test] fn popm_frame() { let parsed_tag = read_tag("tests/tags/assets/id3v2/test_popm.id3v24"); assert_eq!(parsed_tag.frames.len(), 1); let popm_frame = parsed_tag.frames.first().unwrap(); assert_eq!(popm_frame.id(), &FrameId::Valid(Cow::Borrowed("POPM"))); assert_eq!( popm_frame, &Frame::Popularimeter(PopularimeterFrame::new( String::from("foo@bar.com"), 196, 65535 )) ) } #[test] fn multi_value_frame_to_tag() { let mut tag = Id3v2Tag::default(); tag.set_artist(String::from("foo\0bar\0baz")); let tag: Tag = tag.into(); let collected_artists = tag.get_strings(&ItemKey::TrackArtist).collect::>(); assert_eq!(&collected_artists, &["foo", "bar", "baz"]) } #[test] fn multi_item_tag_to_id3v2() { let mut tag = Tag::new(TagType::Id3v2); tag.push_unchecked(TagItem::new( ItemKey::TrackArtist, ItemValue::Text(String::from("foo")), )); tag.push_unchecked(TagItem::new( ItemKey::TrackArtist, ItemValue::Text(String::from("bar")), )); tag.push_unchecked(TagItem::new( ItemKey::TrackArtist, ItemValue::Text(String::from("baz")), )); let tag: Id3v2Tag = tag.into(); assert_eq!(tag.artist().as_deref(), Some("foo/bar/baz")) } #[test] fn utf16_txxx_with_single_bom() { let _ = read_tag("tests/tags/assets/id3v2/issue_53.id3v24"); } #[test] fn replaygain_tag_conversion() { let mut tag = Id3v2Tag::default(); tag.insert(Frame::UserText(ExtendedTextFrame::new( TextEncoding::UTF8, String::from("REPLAYGAIN_ALBUM_GAIN"), String::from("-10.43 dB"), ))); let tag: Tag = tag.into(); assert_eq!(tag.item_count(), 1); assert_eq!( tag.items[0], TagItem::new( ItemKey::ReplayGainAlbumGain, ItemValue::Text(String::from("-10.43 dB")) ) ); } #[test] fn multi_value_roundtrip() { let mut tag = Tag::new(TagType::Id3v2); // 1st: Multi-valued text frames tag.insert_text(ItemKey::TrackArtist, "TrackArtist 1".to_owned()); tag.push(TagItem::new( ItemKey::TrackArtist, ItemValue::Text("TrackArtist 2".to_owned()), )); tag.insert_text(ItemKey::AlbumArtist, "AlbumArtist 1".to_owned()); tag.push(TagItem::new( ItemKey::AlbumArtist, ItemValue::Text("AlbumArtist 2".to_owned()), )); tag.insert_text(ItemKey::TrackTitle, "TrackTitle 1".to_owned()); tag.push(TagItem::new( ItemKey::TrackTitle, ItemValue::Text("TrackTitle 2".to_owned()), )); tag.insert_text(ItemKey::AlbumTitle, "AlbumTitle 1".to_owned()); tag.push(TagItem::new( ItemKey::AlbumTitle, ItemValue::Text("AlbumTitle 2".to_owned()), )); tag.insert_text(ItemKey::ContentGroup, "ContentGroup 1".to_owned()); tag.push(TagItem::new( ItemKey::ContentGroup, ItemValue::Text("ContentGroup 2".to_owned()), )); tag.insert_text(ItemKey::Genre, "Genre 1".to_owned()); tag.push(TagItem::new( ItemKey::Genre, ItemValue::Text("Genre 2".to_owned()), )); tag.insert_text(ItemKey::Mood, "Mood 1".to_owned()); tag.push(TagItem::new( ItemKey::Mood, ItemValue::Text("Mood 2".to_owned()), )); tag.insert_text(ItemKey::Composer, "Composer 1".to_owned()); tag.push(TagItem::new( ItemKey::Composer, ItemValue::Text("Composer 2".to_owned()), )); tag.insert_text(ItemKey::Conductor, "Conductor 1".to_owned()); tag.push(TagItem::new( ItemKey::Conductor, ItemValue::Text("Conductor 2".to_owned()), )); // 2nd: Multi-valued language frames tag.insert_text(ItemKey::Comment, "Comment 1".to_owned()); tag.push(TagItem::new( ItemKey::Comment, ItemValue::Text("Comment 2".to_owned()), )); assert_eq!(20, tag.len()); let id3v2 = Id3v2Tag::from(tag.clone()); let (split_remainder, split_tag) = id3v2.split_tag(); assert_eq!(0, split_remainder.0.len()); assert_eq!(tag.len(), split_tag.len()); // The ordering of items/frames matters, see above! // TODO: Replace with an unordered comparison. assert_eq!(tag.items, split_tag.items); } #[test] fn comments() { let mut tag = Id3v2Tag::default(); let encoding = TextEncoding::Latin1; let custom_descriptor = "lofty-rs"; assert!(tag.comment().is_none()); // Add an empty comment (which is a valid use case). tag.set_comment(String::new()); assert_eq!(Some(Cow::Borrowed("")), tag.comment()); // Insert a custom comment frame assert!(tag .frames .iter() .find_map(|frame| filter_comment_frame_by_description(frame, custom_descriptor)) .is_none()); tag.insert(Frame::Comment(CommentFrame::new( encoding, *b"eng", custom_descriptor.to_owned(), String::from("Qux comment"), ))); // Verify that the regular comment still exists assert_eq!(Some(Cow::Borrowed("")), tag.comment()); assert_eq!(1, tag.comments().count()); tag.remove_comment(); assert!(tag.comment().is_none()); // Verify that the comment with the custom descriptor still exists assert!(tag .frames .iter() .find_map(|frame| filter_comment_frame_by_description(frame, custom_descriptor)) .is_some()); } #[test] fn txxx_wxxx_tag_conversion() { let txxx_frame = Frame::UserText(ExtendedTextFrame::new( TextEncoding::UTF8, String::from("FOO_TEXT_FRAME"), String::from("foo content"), )); let wxxx_frame = Frame::UserUrl(ExtendedUrlFrame::new( TextEncoding::UTF8, String::from("BAR_URL_FRAME"), String::from("bar url"), )); let mut tag = Id3v2Tag::default(); tag.insert(txxx_frame.clone()); tag.insert(wxxx_frame.clone()); let tag: Tag = tag.into(); assert_eq!(tag.item_count(), 2); let expected_items = [ TagItem::new( ItemKey::Unknown(String::from("FOO_TEXT_FRAME")), ItemValue::Text(String::from("foo content")), ), TagItem::new( ItemKey::Unknown(String::from("BAR_URL_FRAME")), ItemValue::Locator(String::from("bar url")), ), ]; assert!(expected_items .iter() .zip(tag.items()) .all(|(expected, actual)| expected == actual)); let tag: Id3v2Tag = tag.into(); assert_eq!(tag.frames.len(), 2); assert_eq!(&tag.frames, &[txxx_frame, wxxx_frame]) } #[test] fn user_defined_frames_conversion() { let mut id3v2 = Id3v2Tag::default(); id3v2.insert(Frame::UserText(ExtendedTextFrame::new( TextEncoding::UTF8, String::from("FOO_BAR"), String::from("foo content"), ))); let (split_remainder, split_tag) = id3v2.split_tag(); assert_eq!(split_remainder.0.len(), 0); assert_eq!(split_tag.len(), 1); let id3v2 = split_remainder.merge_tag(split_tag); // Verify we properly convert user defined frames between Tag <-> ID3v2Tag round trips assert_eq!( id3v2.frames.first(), Some(&Frame::UserText(ExtendedTextFrame::new( TextEncoding::UTF8, // Not considered by PartialEq! String::from("FOO_BAR"), String::new(), // Not considered by PartialEq! ),)) ); // Verify we properly convert user defined frames when writing a Tag, which has to convert // to the reference types. let (_remainder, tag) = id3v2.clone().split_tag(); assert_eq!(tag.len(), 1); let mut content = Vec::new(); tag.dump_to(&mut content, WriteOptions::default()).unwrap(); assert!(!content.is_empty()); // And verify we can reread the tag let mut reader = std::io::Cursor::new(&content[..]); let header = Id3v2Header::parse(&mut reader).unwrap(); let reparsed = crate::id3::v2::read::parse_id3v2( &mut reader, header, ParseOptions::new().parsing_mode(ParsingMode::Strict), ) .unwrap(); assert_eq!(id3v2, reparsed); } #[test] fn set_track() { let mut id3v2 = Id3v2Tag::default(); let track = 1; id3v2.set_track(track); assert_eq!(id3v2.track().unwrap(), track); assert!(id3v2.track_total().is_none()); } #[test] fn set_track_total() { let mut id3v2 = Id3v2Tag::default(); let track_total = 2; id3v2.set_track_total(track_total); assert_eq!(id3v2.track().unwrap(), DEFAULT_NUMBER_IN_PAIR); assert_eq!(id3v2.track_total().unwrap(), track_total); } #[test] fn set_track_and_track_total() { let mut id3v2 = Id3v2Tag::default(); let track = 1; let track_total = 2; id3v2.set_track(track); id3v2.set_track_total(track_total); assert_eq!(id3v2.track().unwrap(), track); assert_eq!(id3v2.track_total().unwrap(), track_total); } #[test] fn set_track_total_and_track() { let mut id3v2 = Id3v2Tag::default(); let track_total = 2; let track = 1; id3v2.set_track_total(track_total); id3v2.set_track(track); assert_eq!(id3v2.track_total().unwrap(), track_total); assert_eq!(id3v2.track().unwrap(), track); } #[test] fn set_disk() { let mut id3v2 = Id3v2Tag::default(); let disk = 1; id3v2.set_disk(disk); assert_eq!(id3v2.disk().unwrap(), disk); assert!(id3v2.disk_total().is_none()); } #[test] fn set_disk_total() { let mut id3v2 = Id3v2Tag::default(); let disk_total = 2; id3v2.set_disk_total(disk_total); assert_eq!(id3v2.disk().unwrap(), DEFAULT_NUMBER_IN_PAIR); assert_eq!(id3v2.disk_total().unwrap(), disk_total); } #[test] fn set_disk_and_disk_total() { let mut id3v2 = Id3v2Tag::default(); let disk = 1; let disk_total = 2; id3v2.set_disk(disk); id3v2.set_disk_total(disk_total); assert_eq!(id3v2.disk().unwrap(), disk); assert_eq!(id3v2.disk_total().unwrap(), disk_total); } #[test] fn set_disk_total_and_disk() { let mut id3v2 = Id3v2Tag::default(); let disk_total = 2; let disk = 1; id3v2.set_disk_total(disk_total); id3v2.set_disk(disk); assert_eq!(id3v2.disk_total().unwrap(), disk_total); assert_eq!(id3v2.disk().unwrap(), disk); } #[test] fn track_number_tag_to_id3v2() { let track_number = 1; let mut tag = Tag::new(TagType::Id3v2); tag.push(TagItem::new( ItemKey::TrackNumber, ItemValue::Text(track_number.to_string()), )); let tag: Id3v2Tag = tag.into(); assert_eq!(tag.track().unwrap(), track_number); assert!(tag.track_total().is_none()); } #[test] fn track_total_tag_to_id3v2() { let track_total = 2; let mut tag = Tag::new(TagType::Id3v2); tag.push(TagItem::new( ItemKey::TrackTotal, ItemValue::Text(track_total.to_string()), )); let tag: Id3v2Tag = tag.into(); assert_eq!(tag.track().unwrap(), DEFAULT_NUMBER_IN_PAIR); assert_eq!(tag.track_total().unwrap(), track_total); } #[test] fn track_number_and_track_total_tag_to_id3v2() { let track_number = 1; let track_total = 2; let mut tag = Tag::new(TagType::Id3v2); tag.push(TagItem::new( ItemKey::TrackNumber, ItemValue::Text(track_number.to_string()), )); tag.push(TagItem::new( ItemKey::TrackTotal, ItemValue::Text(track_total.to_string()), )); let tag: Id3v2Tag = tag.into(); assert_eq!(tag.track().unwrap(), track_number); assert_eq!(tag.track_total().unwrap(), track_total); } #[test] fn disk_number_tag_to_id3v2() { let disk_number = 1; let mut tag = Tag::new(TagType::Id3v2); tag.push(TagItem::new( ItemKey::DiscNumber, ItemValue::Text(disk_number.to_string()), )); let tag: Id3v2Tag = tag.into(); assert_eq!(tag.disk().unwrap(), disk_number); assert!(tag.disk_total().is_none()); } #[test] fn disk_total_tag_to_id3v2() { let disk_total = 2; let mut tag = Tag::new(TagType::Id3v2); tag.push(TagItem::new( ItemKey::DiscTotal, ItemValue::Text(disk_total.to_string()), )); let tag: Id3v2Tag = tag.into(); assert_eq!(tag.disk().unwrap(), DEFAULT_NUMBER_IN_PAIR); assert_eq!(tag.disk_total().unwrap(), disk_total); } #[test] fn disk_number_and_disk_total_tag_to_id3v2() { let disk_number = 1; let disk_total = 2; let mut tag = Tag::new(TagType::Id3v2); tag.push(TagItem::new( ItemKey::DiscNumber, ItemValue::Text(disk_number.to_string()), )); tag.push(TagItem::new( ItemKey::DiscTotal, ItemValue::Text(disk_total.to_string()), )); let tag: Id3v2Tag = tag.into(); assert_eq!(tag.disk().unwrap(), disk_number); assert_eq!(tag.disk_total().unwrap(), disk_total); } fn create_tag_with_trck_and_tpos_frame(content: &'static str) -> Tag { fn insert_frame(id: &'static str, content: &'static str, tag: &mut Id3v2Tag) { tag.insert(new_text_frame( FrameId::Valid(Cow::Borrowed(id)), content.to_string(), )); } let mut tag = Id3v2Tag::default(); insert_frame("TRCK", content, &mut tag); insert_frame("TPOS", content, &mut tag); tag.into() } #[test] fn valid_trck_and_tpos_frame() { fn assert_valid(content: &'static str, number: Option, total: Option) { let tag = create_tag_with_trck_and_tpos_frame(content); assert_eq!(tag.track(), number); assert_eq!(tag.track_total(), total); assert_eq!(tag.disk(), number); assert_eq!(tag.disk_total(), total); } assert_valid("0", Some(0), None); assert_valid("1", Some(1), None); assert_valid("0/0", Some(0), Some(0)); assert_valid("1/2", Some(1), Some(2)); assert_valid("010/011", Some(10), Some(11)); assert_valid(" 1/2 ", Some(1), Some(2)); assert_valid("1 / 2", Some(1), Some(2)); } #[test] fn invalid_trck_and_tpos_frame() { fn assert_invalid(content: &'static str) { let tag = create_tag_with_trck_and_tpos_frame(content); assert!(tag.track().is_none()); assert!(tag.track_total().is_none()); assert!(tag.disk().is_none()); assert!(tag.disk_total().is_none()); } assert_invalid(""); assert_invalid(" "); assert_invalid("/"); assert_invalid("/1"); assert_invalid("1/"); assert_invalid("a/b"); assert_invalid("1/2/3"); assert_invalid("1//2"); assert_invalid("0x1/0x2"); } #[test] fn ufid_frame_with_musicbrainz_record_id() { let mut id3v2 = Id3v2Tag::default(); let unknown_ufid_frame = UniqueFileIdentifierFrame::new("other".to_owned(), b"0123456789".to_vec()); id3v2.insert(Frame::UniqueFileIdentifier(unknown_ufid_frame.clone())); let musicbrainz_recording_id = b"189002e7-3285-4e2e-92a3-7f6c30d407a2"; let musicbrainz_recording_id_frame = UniqueFileIdentifierFrame::new( MUSICBRAINZ_UFID_OWNER.to_owned(), musicbrainz_recording_id.to_vec(), ); id3v2.insert(Frame::UniqueFileIdentifier( musicbrainz_recording_id_frame.clone(), )); assert_eq!(2, id3v2.len()); let (split_remainder, split_tag) = id3v2.split_tag(); assert_eq!(split_remainder.0.len(), 1); assert_eq!(split_tag.len(), 1); assert_eq!( ItemValue::Text(String::from_utf8(musicbrainz_recording_id.to_vec()).unwrap()), *split_tag .get_items(&ItemKey::MusicBrainzRecordingId) .next() .unwrap() .value() ); let id3v2 = split_remainder.merge_tag(split_tag); assert_eq!(2, id3v2.len()); match &id3v2.frames[..] { [Frame::UniqueFileIdentifier(UniqueFileIdentifierFrame { owner: first_owner, identifier: first_identifier, .. }), Frame::UniqueFileIdentifier(UniqueFileIdentifierFrame { owner: second_owner, identifier: second_identifier, .. })] => { assert_eq!(&unknown_ufid_frame.owner, first_owner); assert_eq!(&unknown_ufid_frame.identifier, first_identifier); assert_eq!(&musicbrainz_recording_id_frame.owner, second_owner); assert_eq!( &musicbrainz_recording_id_frame.identifier, second_identifier ); }, _ => unreachable!(), } } #[test] fn get_set_user_defined_text() { let description = String::from("FOO_BAR"); let content = String::from("Baz!\0Qux!"); let description2 = String::from("FOO_BAR_2"); let content2 = String::new(); let mut id3v2 = Id3v2Tag::default(); let txxx_frame = Frame::UserText(ExtendedTextFrame::new( TextEncoding::UTF8, description.clone(), content.clone(), )); id3v2.insert(txxx_frame.clone()); // Insert another to verify we can search through multiple let txxx_frame2 = Frame::UserText(ExtendedTextFrame::new( TextEncoding::UTF8, description2.clone(), content2.clone(), )); id3v2.insert(txxx_frame2); // We cannot get user defined texts through `get_text` assert!(id3v2 .get_text(&FrameId::Valid(Cow::Borrowed("TXXX"))) .is_none()); assert_eq!(id3v2.get_user_text(description.as_str()), Some(&*content)); // Wipe the tag id3v2.clear(); // Same thing process as above, using simplified setter assert!(id3v2 .insert_user_text(description.clone(), content.clone()) .is_none()); assert!(id3v2 .insert_user_text(description2.clone(), content2.clone()) .is_none()); assert_eq!(id3v2.get_user_text(description.as_str()), Some(&*content)); // Remove one frame assert!(id3v2.remove_user_text(&description).is_some()); assert!(!id3v2.is_empty()); // Now clear the remaining item assert!(id3v2.remove_user_text(&description2).is_some()); assert!(id3v2.is_empty()); } #[test] fn read_multiple_composers_should_not_fail_with_bad_frame_length() { // Issue #255 let tag = read_tag("tests/tags/assets/id3v2/multiple_composers.id3v24"); let mut composers = tag .get_texts(&FrameId::Valid(Cow::Borrowed("TCOM"))) .unwrap(); assert_eq!(composers.next(), Some("A")); assert_eq!(composers.next(), Some("B")); assert_eq!(composers.next(), None) } #[test] fn trim_end_nulls_when_reading_frame_content() { // Issue #273 // Tag written by mid3v2. All frames contain null-terminated UTF-8 text let tag = read_tag("tests/tags/assets/id3v2/trailing_nulls.id3v24"); // Verify that each different frame type no longer has null terminator let artist = tag.get_text(&FrameId::Valid(Cow::Borrowed("TPE1"))); assert_eq!(artist.unwrap(), "Artist"); let writer = tag.get_user_text("Writer"); assert_eq!(writer.unwrap(), "Writer"); let lyrics = &tag.unsync_text().next().unwrap().content; assert_eq!(lyrics, "Lyrics to the song"); let comment = tag.comment().unwrap(); assert_eq!(comment, "Comment"); let url_frame = tag.get(&FrameId::Valid(Cow::Borrowed("WXXX"))).unwrap(); let Frame::UserUrl(url) = &url_frame else { panic!("Expected a UserUrl") }; assert_eq!(url.content, "https://www.myfanpage.com"); } fn id3v2_tag_with_genre(value: &str) -> Id3v2Tag { let mut tag = Id3v2Tag::default(); let frame = new_text_frame(GENRE_ID, String::from(value)); tag.insert(frame); tag } #[test] fn genre_text() { let tag = id3v2_tag_with_genre("Dream Pop"); assert_eq!(tag.genre(), Some(Cow::Borrowed("Dream Pop"))); } #[test] fn genre_id_brackets() { let tag = id3v2_tag_with_genre("(21)"); assert_eq!(tag.genre(), Some(Cow::Borrowed("Ska"))); } #[test] fn genre_id_numeric() { let tag = id3v2_tag_with_genre("21"); assert_eq!(tag.genre(), Some(Cow::Borrowed("Ska"))); } #[test] fn genre_id_multiple_joined() { let tag = id3v2_tag_with_genre("(51)(39)"); assert_eq!( tag.genre(), Some(Cow::Borrowed("Techno-Industrial / Noise")) ); } #[test] fn genres_id_multiple() { let tag = id3v2_tag_with_genre("(51)(39)"); let mut genres = tag.genres().unwrap(); assert_eq!(genres.next(), Some("Techno-Industrial")); assert_eq!(genres.next(), Some("Noise")); assert_eq!(genres.next(), None); } #[test] fn genres_id_multiple_into_tag() { let id3v2 = id3v2_tag_with_genre("(51)(39)"); let tag: Tag = id3v2.into(); let mut genres = tag.get_strings(&ItemKey::Genre); assert_eq!(genres.next(), Some("Techno-Industrial")); assert_eq!(genres.next(), Some("Noise")); assert_eq!(genres.next(), None); } #[test] fn genres_null_separated() { let tag = id3v2_tag_with_genre("Samba-rock\0MPB\0Funk"); let mut genres = tag.genres().unwrap(); assert_eq!(genres.next(), Some("Samba-rock")); assert_eq!(genres.next(), Some("MPB")); assert_eq!(genres.next(), Some("Funk")); assert_eq!(genres.next(), None); } #[test] fn genres_id_textual_refinement() { let tag = id3v2_tag_with_genre("(4)Eurodisco"); let mut genres = tag.genres().unwrap(); assert_eq!(genres.next(), Some("Disco")); assert_eq!(genres.next(), Some("Eurodisco")); assert_eq!(genres.next(), None); } #[test] fn genres_id_bracketed_refinement() { let tag = id3v2_tag_with_genre("(26)(55)((I think...)"); let mut genres = tag.genres().unwrap(); assert_eq!(genres.next(), Some("Ambient")); assert_eq!(genres.next(), Some("Dream")); assert_eq!(genres.next(), Some("(I think...)")); assert_eq!(genres.next(), None); } #[test] fn genres_id_remix_cover() { let tag = id3v2_tag_with_genre("(0)(RX)(CR)"); let mut genres = tag.genres().unwrap(); assert_eq!(genres.next(), Some("Blues")); assert_eq!(genres.next(), Some("Remix")); assert_eq!(genres.next(), Some("Cover")); assert_eq!(genres.next(), None); } #[test] fn tipl_round_trip() { let mut tag = Id3v2Tag::default(); let mut tipl = KeyValueFrame::new( FrameId::Valid(Cow::Borrowed("TIPL")), TextEncoding::UTF8, Vec::new(), ); // Add all supported keys for (_, key) in TIPL_MAPPINGS { tipl.key_value_pairs .push((String::from(*key), String::from("Serial-ATA"))); } // Add one unsupported key tipl.key_value_pairs .push((String::from("Foo"), String::from("Bar"))); tag.insert(Frame::KeyValue(tipl.clone())); let (split_remainder, split_tag) = tag.split_tag(); assert_eq!(split_remainder.0.len(), 1); // "Foo" is not supported assert_eq!(split_tag.len(), TIPL_MAPPINGS.len()); // All supported keys are present for (item_key, _) in TIPL_MAPPINGS { assert_eq!( split_tag .get(item_key) .map(TagItem::value) .and_then(ItemValue::text), Some("Serial-ATA") ); } let mut id3v2 = split_remainder.merge_tag(split_tag); assert_eq!(id3v2.frames.len(), 1); match &mut id3v2.frames[..] { [Frame::KeyValue(tipl2)] => { // Order will not be the same, so we have to sort first tipl.key_value_pairs.sort(); tipl2.key_value_pairs.sort(); assert_eq!(tipl, *tipl2); }, _ => unreachable!(), } } #[test] fn flag_item_conversion() { let mut tag = Tag::new(TagType::Id3v2); tag.insert_text(ItemKey::FlagCompilation, "1".to_owned()); tag.insert_text(ItemKey::FlagPodcast, "0".to_owned()); let tag: Id3v2Tag = tag.into(); assert_eq!( tag.get_text(&FrameId::Valid(Cow::Borrowed("TCMP"))), Some("1") ); assert_eq!( tag.get_text(&FrameId::Valid(Cow::Borrowed("PCST"))), Some("0") ); } #[test] fn itunes_advisory_roundtrip() { use crate::mp4::{AdvisoryRating, Ilst}; let mut tag = Ilst::new(); tag.set_advisory_rating(AdvisoryRating::Explicit); let tag: Tag = tag.into(); let tag: Id3v2Tag = tag.into(); assert_eq!(tag.frames.len(), 1); let frame = tag.get_user_text("ITUNESADVISORY"); assert!(frame.is_some()); assert_eq!(frame.unwrap(), "1"); let tag: Tag = tag.into(); let tag: Ilst = tag.into(); assert_eq!(tag.advisory_rating(), Some(AdvisoryRating::Explicit)); } #[test] fn timestamp_roundtrip() { let mut tag = Id3v2Tag::default(); tag.insert(Frame::Timestamp(TimestampFrame::new( FrameId::Valid(Cow::Borrowed("TDRC")), TextEncoding::UTF8, Timestamp { year: 2024, month: Some(6), day: Some(3), hour: Some(14), minute: Some(8), second: Some(49), }, ))); let tag: Tag = tag.into(); assert_eq!(tag.len(), 1); assert_eq!( tag.get_string(&ItemKey::RecordingDate), Some("2024-06-03T14:08:49") ); let tag: Id3v2Tag = tag.into(); assert_eq!(tag.frames.len(), 1); let frame = tag.frames.first().unwrap(); assert_eq!(frame.id(), &FrameId::Valid(Cow::Borrowed("TDRC"))); match &frame { Frame::Timestamp(frame) => { assert_eq!(frame.timestamp.year, 2024); assert_eq!(frame.timestamp.month, Some(6)); assert_eq!(frame.timestamp.day, Some(3)); assert_eq!(frame.timestamp.hour, Some(14)); assert_eq!(frame.timestamp.minute, Some(8)); assert_eq!(frame.timestamp.second, Some(49)); }, _ => panic!("Expected a TimestampFrame"), } } #[test] fn special_items_roundtrip() { let mut tag = Id3v2Tag::new(); let rva2 = Frame::RelativeVolumeAdjustment(RelativeVolumeAdjustmentFrame::new( String::from("Foo RVA"), HashMap::from([( ChannelType::MasterVolume, ChannelInformation { channel_type: ChannelType::MasterVolume, volume_adjustment: 30, bits_representing_peak: 0, peak_volume: None, }, )]), )); tag.insert(rva2.clone()); tag.set_artist(String::from("Foo Artist")); // Some value that we *can* represent generically let tag: Tag = tag.into(); assert_eq!(tag.len(), 1); assert_eq!(tag.artist().as_deref(), Some("Foo Artist")); let mut tag: Id3v2Tag = tag.into(); assert_eq!(tag.frames.len(), 2); assert_eq!(tag.artist().as_deref(), Some("Foo Artist")); assert_eq!(tag.get(&FrameId::Valid(Cow::Borrowed("RVA2"))), Some(&rva2)); let mut tag_bytes = Vec::new(); tag.dump_to(&mut tag_bytes, WriteOptions::default()) .unwrap(); let mut tag_re_read = read_tag_with_options( &tag_bytes[..], ParseOptions::new().parsing_mode(ParsingMode::Strict), ); // Ensure ordered comparison tag.frames.sort_by_key(|frame| frame.id().to_string()); tag_re_read .frames .sort_by_key(|frame| frame.id().to_string()); assert_eq!(tag, tag_re_read); // Now write from `Tag` let tag: Tag = tag.into(); let mut tag_bytes = Vec::new(); tag.dump_to(&mut tag_bytes, WriteOptions::default()) .unwrap(); let mut generic_tag_re_read = read_tag_with_options( &tag_bytes[..], ParseOptions::new().parsing_mode(ParsingMode::Strict), ); generic_tag_re_read .frames .sort_by_key(|frame| frame.id().to_string()); assert_eq!(tag_re_read, generic_tag_re_read); } #[test] fn preserve_comment_lang_description_on_conversion() { let mut tag = Id3v2Tag::new(); let comment_frame = Frame::Comment(CommentFrame::new( TextEncoding::UTF8, ENGLISH, String::from("Some description"), String::from("Foo comment"), )); tag.insert(comment_frame.clone()); let tag: Tag = tag.into(); assert_eq!(tag.len(), 1); let tag: Id3v2Tag = tag.into(); assert_eq!(tag.len(), 1); let frame = tag.get(&FrameId::Valid(Cow::Borrowed("COMM"))).unwrap(); match frame { Frame::Comment(comm) => { assert_eq!(comm.language, ENGLISH); assert_eq!(comm.description, "Some description"); assert_eq!(comm.content, "Foo comment"); }, _ => panic!("Expected a CommentFrame"), } } // TODO: Remove this once we have a better solution #[test] fn hold_back_4_character_txxx_description() { let mut tag = Id3v2Tag::new(); let _ = tag.insert_user_text(String::from("MODE"), String::from("CBR")); let tag: Tag = tag.into(); assert_eq!(tag.len(), 0); let tag: Id3v2Tag = tag.into(); assert_eq!(tag.len(), 1); } #[test] fn skip_reading_cover_art() { let p = Picture::new_unchecked( PictureType::CoverFront, Some(MimeType::Jpeg), None, std::iter::repeat(0).take(50).collect::>(), ); let mut tag = Tag::new(TagType::Id3v2); tag.push_picture(p); tag.set_artist(String::from("Foo artist")); let mut writer = Vec::new(); tag.dump_to(&mut writer, WriteOptions::new()).unwrap(); let id3v2 = read_tag_with_options(&writer[..], ParseOptions::new().read_cover_art(false)); assert_eq!(id3v2.len(), 1); // Artist, no picture assert!(id3v2.artist().is_some()); } #[test] fn remove_id3v24_frames_on_id3v23_save() { let mut tag = Id3v2Tag::new(); tag.insert(Frame::RelativeVolumeAdjustment( RelativeVolumeAdjustmentFrame::new( String::from("Foo RVA"), HashMap::from([( ChannelType::MasterVolume, ChannelInformation { channel_type: ChannelType::MasterVolume, volume_adjustment: 30, bits_representing_peak: 0, peak_volume: None, }, )]), ), )); let tag_re_read = dump_and_re_read(&tag, WriteOptions::default().use_id3v23(true)); assert_eq!(tag_re_read.frames.len(), 0); } #[test] fn change_text_encoding_on_id3v23_save() { let mut tag = Id3v2Tag::new(); // UTF-16 BE is not supported in ID3v2.3 tag.insert(Frame::Text(TextInformationFrame::new( FrameId::Valid(Cow::from("TFOO")), TextEncoding::UTF16BE, String::from("Foo"), ))); let tag_re_read = dump_and_re_read(&tag, WriteOptions::default().use_id3v23(true)); let frame = tag_re_read .get(&FrameId::Valid(Cow::Borrowed("TFOO"))) .unwrap(); match frame { Frame::Text(frame) => { assert_eq!(frame.encoding, TextEncoding::UTF16); assert_eq!(frame.value, "Foo"); }, _ => panic!("Expected a TextInformationFrame"), } } #[test] fn split_tdor_on_id3v23_save() { let mut tag = Id3v2Tag::new(); // ID3v2.3 ONLY supports the original release year. // This will be written as a TORY frame. Lofty just automatically upgrades it to a TDOR // when reading it back. tag.insert(Frame::Timestamp(TimestampFrame::new( FrameId::Valid(Cow::Borrowed("TDOR")), TextEncoding::UTF8, Timestamp { year: 2024, month: Some(6), day: Some(3), hour: Some(14), minute: Some(8), second: Some(49), }, ))); let tag_re_read = dump_and_re_read(&tag, WriteOptions::default().use_id3v23(true)); let frame = tag_re_read .get(&FrameId::Valid(Cow::Borrowed("TDOR"))) .unwrap(); match frame { Frame::Timestamp(frame) => { assert_eq!(frame.encoding, TextEncoding::UTF16); assert_eq!(frame.timestamp.year, 2024); assert_eq!(frame.timestamp.month, None); assert_eq!(frame.timestamp.day, None); assert_eq!(frame.timestamp.hour, None); assert_eq!(frame.timestamp.minute, None); assert_eq!(frame.timestamp.second, None); }, _ => panic!("Expected a TimestampFrame"), } } #[test] fn split_tdrc_on_id3v23_save() { let mut tag = Id3v2Tag::new(); // TDRC gets split into 3 frames in ID3v2.3: // // TYER: YYYY // TDAT: DDMM // TIME: HHMM tag.insert(Frame::Timestamp(TimestampFrame::new( FrameId::Valid(Cow::Borrowed("TDRC")), TextEncoding::UTF8, Timestamp { year: 2024, month: Some(6), day: Some(3), hour: Some(14), minute: Some(8), second: None, // Seconds are not supported in ID3v2.3 TIME }, ))); let tag_re_read = dump_and_re_read(&tag, WriteOptions::default().use_id3v23(true)); // First, check the default behavior which should return the same TDRC frame let frame = tag_re_read .get(&FrameId::Valid(Cow::Borrowed("TDRC"))) .unwrap(); match frame { Frame::Timestamp(frame) => { assert_eq!(frame.encoding, TextEncoding::UTF16); assert_eq!(frame.timestamp.year, 2024); assert_eq!(frame.timestamp.month, Some(6)); assert_eq!(frame.timestamp.day, Some(3)); assert_eq!(frame.timestamp.hour, Some(14)); assert_eq!(frame.timestamp.minute, Some(8)); }, _ => panic!("Expected a TimestampFrame"), } // Now, re-read with implicit_conversions off, which retains the split frames let mut bytes = Cursor::new(Vec::new()); tag_re_read .dump_to(&mut bytes, WriteOptions::default().use_id3v23(true)) .unwrap(); let tag_re_read = read_tag_with_options( &bytes.into_inner(), ParseOptions::new() .parsing_mode(ParsingMode::Strict) .implicit_conversions(false), ); let year = tag_re_read .get_text(&FrameId::Valid(Cow::Borrowed("TYER"))) .expect("Expected TYER frame"); assert_eq!(year, "2024"); let date = tag_re_read .get_text(&FrameId::Valid(Cow::Borrowed("TDAT"))) .expect("Expected TDAT frame"); assert_eq!(date, "0306"); let time = tag_re_read .get_text(&FrameId::Valid(Cow::Borrowed("TIME"))) .expect("Expected TIME frame"); assert_eq!(time, "1408"); } lofty-0.21.1/src/id3/v2/tag.rs000064400000000000000000001317521046102023000137710ustar 00000000000000#[cfg(test)] mod tests; use super::frame::{Frame, EMPTY_CONTENT_DESCRIPTOR}; use super::header::{Id3v2TagFlags, Id3v2Version}; use crate::config::{global_options, WriteOptions}; use crate::error::{LoftyError, Result}; use crate::id3::v1::GENRES; use crate::id3::v2::frame::{FrameRef, MUSICBRAINZ_UFID_OWNER}; use crate::id3::v2::items::{ AttachedPictureFrame, CommentFrame, ExtendedTextFrame, ExtendedUrlFrame, TextInformationFrame, UniqueFileIdentifierFrame, UnsynchronizedTextFrame, UrlLinkFrame, }; use crate::id3::v2::util::mappings::TIPL_MAPPINGS; use crate::id3::v2::util::pairs::{ format_number_pair, set_number, NUMBER_PAIR_KEYS, NUMBER_PAIR_SEPARATOR, }; use crate::id3::v2::{BinaryFrame, FrameHeader, FrameId, KeyValueFrame, TimestampFrame}; use crate::mp4::AdvisoryRating; use crate::picture::{Picture, PictureType, TOMBSTONE_PICTURE}; use crate::tag::companion_tag::CompanionTag; use crate::tag::items::{Lang, Timestamp, UNKNOWN_LANGUAGE}; use crate::tag::{Accessor, ItemKey, ItemValue, MergeTag, SplitTag, Tag, TagExt, TagItem, TagType}; use crate::util::flag_item; use crate::util::io::{FileLike, Length, Truncate}; use crate::util::text::{decode_text, TextDecodeOptions, TextEncoding}; use std::borrow::Cow; use std::io::{Cursor, Write}; use std::iter::Peekable; use std::ops::Deref; use std::str::FromStr; use lofty_attr::tag; const INVOLVED_PEOPLE_LIST_ID: &str = "TIPL"; const V4_MULTI_VALUE_SEPARATOR: char = '\0'; // Used exclusively for `Accessor` convenience methods fn remove_separators_from_frame_text(value: &str, version: Id3v2Version) -> Cow<'_, str> { if !value.contains(V4_MULTI_VALUE_SEPARATOR) || version != Id3v2Version::V4 { return Cow::Borrowed(value); } return Cow::Owned(value.replace(V4_MULTI_VALUE_SEPARATOR, "/")); } macro_rules! impl_accessor { ($($name:ident => $id:literal;)+) => { paste::paste! { $( fn $name(&self) -> Option> { if let Some(value) = self.get_text(&[<$name:upper _ID>]) { return Some(remove_separators_from_frame_text(value, self.original_version)); } None } fn [](&mut self, value: String) { self.insert(new_text_frame( [<$name:upper _ID>], value, )); } fn [](&mut self) { let _ = self.remove(&[<$name:upper _ID>]); } )+ } } } /// ## [`Accessor`] Methods /// /// As ID3v2.4 allows for multiple values to exist in a single frame, the raw strings, as provided by [`Id3v2Tag::get_text`] /// may contain null separators. /// /// In the [`Accessor`] methods, these values have the separators (`\0`) replaced with `"/"` for convenience. /// /// ## Conversions /// /// ⚠ **Warnings** ⚠ /// /// ### From `Tag` /// /// When converting from a [`Tag`] to an `Id3v2Tag`, some frames may need editing. /// /// * [`ItemKey::Comment`] and [`ItemKey::Lyrics`] - Unlike a normal text frame, these require a language. See [`CommentFrame`] and [`UnsynchronizedTextFrame`] respectively. /// An attempt is made to create this information, but it may be incorrect. /// * `language` - Unknown and set to "XXX" /// * `description` - Left empty, which is invalid if there are more than one of these frames. These frames can only be identified /// by their descriptions, and as such they are expected to be unique for each. /// * [`ItemKey::Unknown("WXXX" | "TXXX")`](ItemKey::Unknown) - These frames are also identified by their descriptions. /// /// ### To `Tag` /// /// * TXXX/WXXX - These frames will be stored as an [`ItemKey`] by their description. Some variants exist for these descriptions, such as the one for `ReplayGain`, /// otherwise [`ItemKey::Unknown`] will be used. /// * Frames that require a language (COMM/USLT) - With ID3v2 being the only format that allows for language-specific items, this information is not retained. /// * POPM - These frames will be stored as a raw [`ItemValue::Binary`] value under the [`ItemKey::Popularimeter`] key. /// /// ## Special Frames /// /// ID3v2 has `GEOB` and `SYLT` frames, which are not parsed by default, instead storing them as [`FrameType::Binary`]. /// They can easily be parsed with [`GeneralEncapsulatedObject::parse`](crate::id3::v2::GeneralEncapsulatedObject::parse) /// and [`SynchronizedText::parse`](crate::id3::v2::SynchronizedTextFrame::parse) respectively, and converted back to binary with /// [`GeneralEncapsulatedObject::as_bytes`](crate::id3::v2::GeneralEncapsulatedObject::as_bytes) and /// [`SynchronizedText::as_bytes`](crate::id3::v2::SynchronizedTextFrame::as_bytes) for writing. #[derive(PartialEq, Eq, Debug, Clone)] #[tag( description = "An `ID3v2` tag", supported_formats(Aac, Aiff, Mpeg, Wav, read_only(Ape, Flac, Mpc)) )] pub struct Id3v2Tag { flags: Id3v2TagFlags, pub(super) original_version: Id3v2Version, pub(crate) frames: Vec>, } impl IntoIterator for Id3v2Tag { type Item = Frame<'static>; type IntoIter = std::vec::IntoIter; fn into_iter(self) -> Self::IntoIter { self.frames.into_iter() } } impl<'a> IntoIterator for &'a Id3v2Tag { type Item = &'a Frame<'static>; type IntoIter = std::slice::Iter<'a, Frame<'static>>; fn into_iter(self) -> Self::IntoIter { self.frames.iter() } } impl Default for Id3v2Tag { fn default() -> Self { Self { flags: Id3v2TagFlags::default(), original_version: Id3v2Version::V4, frames: Vec::new(), } } } impl Id3v2Tag { /// Create a new empty `ID3v2Tag` /// /// # Examples /// /// ```rust /// use lofty::id3::v2::Id3v2Tag; /// use lofty::tag::TagExt; /// /// let id3v2_tag = Id3v2Tag::new(); /// assert!(id3v2_tag.is_empty()); /// ``` pub fn new() -> Self { Self::default() } /// Returns the [`Id3v2TagFlags`] pub fn flags(&self) -> &Id3v2TagFlags { &self.flags } /// Restrict the tag's flags pub fn set_flags(&mut self, flags: Id3v2TagFlags) { self.flags = flags } /// The original version of the tag /// /// This is here, since the tag is upgraded to `ID3v2.4`, but a `v2.2` or `v2.3` /// tag may have been read. pub fn original_version(&self) -> Id3v2Version { self.original_version } } impl Id3v2Tag { /// Gets a [`Frame`] from an id pub fn get(&self, id: &FrameId<'_>) -> Option<&Frame<'static>> { self.frames.iter().find(|f| f.id() == id) } /// Gets the text for a frame /// /// NOTE: If the tag is [`Id3v2Version::V4`], there could be multiple values separated by null characters (`'\0'`). /// Use [`Id3v2Tag::get_texts`] to conveniently split all of the values. /// /// NOTE: This will not work for `TXXX` frames, use [`Id3v2Tag::get_user_text`] for that. /// /// # Examples /// /// ```rust /// use lofty::id3::v2::{FrameId, Id3v2Tag}; /// use lofty::tag::Accessor; /// use std::borrow::Cow; /// /// const TITLE_ID: FrameId<'_> = FrameId::Valid(Cow::Borrowed("TIT2")); /// /// let mut tag = Id3v2Tag::new(); /// /// tag.set_title(String::from("Foo")); /// /// let title = tag.get_text(&TITLE_ID); /// assert_eq!(title, Some("Foo")); /// /// // Now we have a string with multiple values /// tag.set_title(String::from("Foo\0Bar")); /// /// // Null separator is retained! This case is better handled by `get_texts`. /// let title = tag.get_text(&TITLE_ID); /// assert_eq!(title, Some("Foo\0Bar")); /// ``` pub fn get_text(&self, id: &FrameId<'_>) -> Option<&str> { let frame = self.get(id); if let Some(Frame::Text(TextInformationFrame { value, .. })) = frame { return Some(value); } None } /// Gets all of the values for a text frame /// /// NOTE: Multiple values are only supported in ID3v2.4, this will not be /// very useful for ID3v2.2/3 tags. /// /// # Examples /// /// ```rust /// use lofty::id3::v2::{FrameId, Id3v2Tag}; /// use lofty::tag::Accessor; /// use std::borrow::Cow; /// /// const TITLE_ID: FrameId<'_> = FrameId::Valid(Cow::Borrowed("TIT2")); /// /// let mut tag = Id3v2Tag::new(); /// /// tag.set_title(String::from("Foo\0Bar")); /// /// let mut titles = tag.get_texts(&TITLE_ID).expect("Should exist"); /// /// assert_eq!(titles.next(), Some("Foo")); /// assert_eq!(titles.next(), Some("Bar")); /// ``` pub fn get_texts(&self, id: &FrameId<'_>) -> Option> { if let Some(Frame::Text(TextInformationFrame { value, .. })) = self.get(id) { return Some(value.split(V4_MULTI_VALUE_SEPARATOR)); } None } /// Gets the text for a user-defined frame /// /// NOTE: If the tag is [`Id3v2Version::V4`], there could be multiple values separated by null characters (`'\0'`). /// The caller is responsible for splitting these values as necessary. /// /// # Examples /// /// ```rust /// use lofty::id3::v2::Id3v2Tag; /// /// let mut tag = Id3v2Tag::new(); /// /// // Add a new "TXXX" frame identified by "SOME_DESCRIPTION" /// let _ = tag.insert_user_text(String::from("SOME_DESCRIPTION"), String::from("Some value")); /// /// // Now we can get the value back using the description /// let value = tag.get_user_text("SOME_DESCRIPTION"); /// assert_eq!(value, Some("Some value")); /// ``` pub fn get_user_text(&self, description: &str) -> Option<&str> { self.frames .iter() .filter(|frame| frame.id().as_str() == "TXXX") .find_map(|frame| match frame { Frame::UserText(ExtendedTextFrame { description: desc, content, .. }) if desc == description => Some(content.as_str()), _ => None, }) } /// Inserts a new user-defined text frame (`TXXX`) /// /// NOTE: The encoding will be UTF-8 /// /// This will replace any TXXX frame with the same description, see [`Id3v2Tag::insert`]. /// /// # Examples /// /// ```rust /// use lofty::id3::v2::Id3v2Tag; /// use lofty::tag::TagExt; /// /// let mut tag = Id3v2Tag::new(); /// /// assert!(tag.is_empty()); /// /// // Add a new "TXXX" frame identified by "SOME_DESCRIPTION" /// let _ = tag.insert_user_text(String::from("SOME_DESCRIPTION"), String::from("Some value")); /// /// // Now we can get the value back using `get_user_text` /// let value = tag.get_user_text("SOME_DESCRIPTION"); /// assert_eq!(value, Some("Some value")); /// ``` pub fn insert_user_text( &mut self, description: String, content: String, ) -> Option> { self.insert(Frame::UserText(ExtendedTextFrame::new( TextEncoding::UTF8, description, content, ))) } /// Inserts a [`Frame`] /// /// This will replace any frame of the same id (**or description!** See [`ExtendedTextFrame`]) pub fn insert(&mut self, frame: Frame<'static>) -> Option> { // Some frames can only appear once in a tag, handle them separately const ONE_PER_TAG: [&str; 11] = [ "MCDI", "ETCO", "MLLT", "SYTC", "RVRB", "PCNT", "RBUF", "POSS", "OWNE", "SEEK", "ASPI", ]; if ONE_PER_TAG.contains(&frame.id_str()) { let ret = self.remove(frame.id()).next(); self.frames.push(frame); return ret; } let replaced = self .frames .iter() .position(|f| f == &frame) .map(|pos| self.frames.remove(pos)); self.frames.push(frame); replaced } /// Removes a user-defined text frame (`TXXX`) by its description /// /// This will return the matching frame. /// /// # Examples /// /// ```rust /// use lofty::id3::v2::Id3v2Tag; /// use lofty::tag::TagExt; /// /// let mut tag = Id3v2Tag::new(); /// assert!(tag.is_empty()); /// /// // Add a new "TXXX" frame identified by "SOME_DESCRIPTION" /// let _ = tag.insert_user_text(String::from("SOME_DESCRIPTION"), String::from("Some value")); /// assert!(!tag.is_empty()); /// /// // Now we can remove it by its description /// let value = tag.remove_user_text("SOME_DESCRIPTION"); /// assert!(tag.is_empty()); /// ``` pub fn remove_user_text(&mut self, description: &str) -> Option> { self.frames .iter() .position(|frame| { matches!(frame, Frame::UserText(ExtendedTextFrame { description: desc, .. }) if desc == description) }) .map(|pos| self.frames.remove(pos)) } /// Removes a [`Frame`] by id /// /// This will remove any frames with the same ID. To remove `TXXX` frames by their descriptions, /// see [`Id3v2Tag::remove_user_text`]. /// /// # Examples /// /// ```rust /// use lofty::id3::v2::{Frame, FrameFlags, FrameId, Id3v2Tag, TextInformationFrame}; /// use lofty::tag::TagExt; /// use lofty::TextEncoding; /// use std::borrow::Cow; /// /// const MOOD_FRAME_ID: FrameId<'static> = FrameId::Valid(Cow::Borrowed("TMOO")); /// /// # fn main() -> lofty::error::Result<()> { /// let mut tag = Id3v2Tag::new(); /// assert!(tag.is_empty()); /// /// // Add a new "TMOO" frame /// let tmoo_frame = Frame::Text(TextInformationFrame::new( /// MOOD_FRAME_ID, /// TextEncoding::Latin1, /// String::from("Classical"), /// )); /// /// let _ = tag.insert(tmoo_frame.clone()); /// assert!(!tag.is_empty()); /// /// // Now we can remove it by its ID /// let mut values = tag.remove(&MOOD_FRAME_ID); /// /// // We got back exactly what we inserted /// assert_eq!(values.next(), Some(tmoo_frame)); /// assert!(values.next().is_none()); /// drop(values); /// /// // The tag is now empty /// assert!(tag.is_empty()); /// # Ok(()) } /// ``` pub fn remove(&mut self, id: &FrameId<'_>) -> impl Iterator> + '_ { // TODO: drain_filter let mut split_idx = 0_usize; for read_idx in 0..self.frames.len() { if self.frames[read_idx].id() == id { self.frames.swap(split_idx, read_idx); split_idx += 1; } } self.frames.drain(..split_idx) } fn take_first(&mut self, id: &FrameId<'_>) -> Option> { self.frames .iter() .position(|f| f.id() == id) .map(|pos| self.frames.remove(pos)) } /// Retains [`Frame`]s by evaluating the predicate pub fn retain

(&mut self, predicate: P) where P: FnMut(&Frame<'_>) -> bool, { self.frames.retain(predicate) } /// Inserts a [`Picture`] /// /// According to spec, there can only be one picture of type [`PictureType::Icon`] and [`PictureType::OtherIcon`]. /// When attempting to insert these types, if another is found it will be removed and returned. pub fn insert_picture(&mut self, picture: Picture) -> Option> { let ret = if picture.pic_type == PictureType::Icon || picture.pic_type == PictureType::OtherIcon { let mut pos = None; for (i, frame) in self.frames.iter().enumerate() { match frame { Frame::Picture(AttachedPictureFrame { picture: Picture { pic_type, .. }, .. }) if pic_type == &picture.pic_type => { pos = Some(i); break; }, _ => {}, } } pos.map(|p| self.frames.remove(p)) } else { None }; self.frames.push(new_picture_frame(picture)); ret } /// Removes a certain [`PictureType`] pub fn remove_picture_type(&mut self, picture_type: PictureType) { self.frames.retain(|f| { !matches!(f, Frame::Picture(AttachedPictureFrame { picture: Picture { pic_type: p_ty, .. }, .. }) if p_ty == &picture_type) }) } /// Returns all `USLT` frames pub fn unsync_text(&self) -> impl Iterator> + Clone { self.frames.iter().filter_map(|f| match f { Frame::UnsynchronizedText(val) => Some(val), _ => None, }) } /// Returns all `COMM` frames with an empty content descriptor pub fn comments(&self) -> impl Iterator> { self.frames.iter().filter_map(|frame| { filter_comment_frame_by_description(frame, &EMPTY_CONTENT_DESCRIPTOR) }) } fn split_num_pair(&self, id: &FrameId<'_>) -> (Option, Option) { if let Some(Frame::Text(TextInformationFrame { ref value, .. })) = self.get(id) { let mut split = value .split(&[V4_MULTI_VALUE_SEPARATOR, NUMBER_PAIR_SEPARATOR][..]) .flat_map(str::parse::); return (split.next(), split.next()); } (None, None) } fn insert_item(&mut self, item: TagItem) { match item.key() { ItemKey::TrackNumber => set_number(&item, |number| self.set_track(number)), ItemKey::TrackTotal => set_number(&item, |number| self.set_track_total(number)), ItemKey::DiscNumber => set_number(&item, |number| self.set_disk(number)), ItemKey::DiscTotal => set_number(&item, |number| self.set_disk_total(number)), _ => { if let Some(frame) = item.into() { if let Some(replaced) = self.insert(frame) { log::warn!("Replaced frame: {replaced:?}"); } } }, }; } /// Returns all genres contained in a `TCON` frame. /// /// This will translate any numeric genre IDs to their textual equivalent. /// ID3v2.4-style multi-value fields will be split as normal. pub fn genres(&self) -> Option> { if let Some(Frame::Text(TextInformationFrame { ref value, .. })) = self.get(&GENRE_ID) { return Some(GenresIter::new(value, false)); } None } fn insert_number_pair( &mut self, id: FrameId<'static>, number: Option, total: Option, ) { if let Some(content) = format_number_pair(number, total) { self.insert(Frame::text(id.into_inner(), content)); } else { log::warn!("{id} is not set. number: {number:?}, total: {total:?}"); } } } pub(crate) struct GenresIter<'a> { value: &'a str, pos: usize, preserve_indexes: bool, } impl<'a> GenresIter<'a> { pub fn new(value: &'a str, preserve_indexes: bool) -> GenresIter<'_> { GenresIter { value, pos: 0, preserve_indexes, } } } impl<'a> Iterator for GenresIter<'a> { type Item = &'a str; fn next(&mut self) -> Option { if self.pos >= self.value.len() { return None; } let remainder = &self.value[self.pos..]; if let Some(idx) = remainder.find(V4_MULTI_VALUE_SEPARATOR) { let start = self.pos; let end = self.pos + idx; self.pos = end + 1; return Some(parse_genre(&self.value[start..end], self.preserve_indexes)); } if remainder.starts_with('(') && remainder.contains(')') { let start = self.pos + 1; let mut end = self.pos + remainder.find(')').unwrap(); self.pos = end + 1; // handle bracketed refinement e.g. (55)((I think...)" if remainder.starts_with("((") { end += 1; } return Some(parse_genre(&self.value[start..end], self.preserve_indexes)); } self.pos = self.value.len(); Some(parse_genre(remainder, self.preserve_indexes)) } } fn parse_genre(genre: &str, preserve_indexes: bool) -> &str { if genre.len() > 3 { return genre; } if let Ok(id) = genre.parse::() { if id < GENRES.len() && !preserve_indexes { GENRES[id] } else { genre } } else if genre == "RX" { "Remix" } else if genre == "CR" { "Cover" } else { genre } } fn filter_comment_frame_by_description<'a>( frame: &'a Frame<'_>, description: &str, ) -> Option<&'a CommentFrame<'a>> { match &frame { Frame::Comment(comment_frame) => { (comment_frame.description == description).then_some(comment_frame) }, _ => None, } } fn filter_comment_frame_by_description_mut<'a, 'f: 'a>( frame: &'a mut Frame<'f>, description: &str, ) -> Option<&'a mut CommentFrame<'f>> { match frame { Frame::Comment(comment_frame) => { (comment_frame.description == description).then_some(comment_frame) }, _ => None, } } pub(super) fn new_text_frame(id: FrameId<'_>, value: String) -> Frame<'_> { Frame::Text(TextInformationFrame::new(id, TextEncoding::UTF8, value)) } pub(super) fn new_url_frame(id: FrameId<'_>, value: String) -> Frame<'_> { Frame::Url(UrlLinkFrame::new(id, value)) } pub(super) fn new_user_text_frame(description: String, content: String) -> Frame<'static> { Frame::UserText(ExtendedTextFrame::new( TextEncoding::UTF8, description, content, )) } pub(super) fn new_user_url_frame(description: String, content: String) -> Frame<'static> { Frame::UserUrl(ExtendedUrlFrame::new( TextEncoding::UTF8, description, content, )) } pub(super) fn new_comment_frame(content: String) -> Frame<'static> { Frame::Comment(CommentFrame::new( TextEncoding::UTF8, UNKNOWN_LANGUAGE, EMPTY_CONTENT_DESCRIPTOR, content, )) } pub(super) fn new_picture_frame(picture: Picture) -> Frame<'static> { Frame::Picture(AttachedPictureFrame::new(TextEncoding::UTF8, picture)) } pub(super) fn new_key_value_frame( id: FrameId<'_>, key_value_pairs: Vec<(String, String)>, ) -> Frame<'_> { Frame::KeyValue(KeyValueFrame::new(id, TextEncoding::UTF8, key_value_pairs)) } pub(super) fn new_timestamp_frame(id: FrameId<'_>, timestamp: Timestamp) -> Frame<'_> { Frame::Timestamp(TimestampFrame::new(id, TextEncoding::UTF8, timestamp)) } pub(super) fn new_unsync_text_frame(content: String) -> Frame<'static> { Frame::UnsynchronizedText(UnsynchronizedTextFrame::new( TextEncoding::UTF8, UNKNOWN_LANGUAGE, EMPTY_CONTENT_DESCRIPTOR, content, )) } pub(super) fn new_binary_frame(id: FrameId<'_>, data: Vec) -> Frame<'_> { Frame::Binary(BinaryFrame::new(id, data)) } const TITLE_ID: FrameId<'static> = FrameId::Valid(Cow::Borrowed("TIT2")); const ARTIST_ID: FrameId<'static> = FrameId::Valid(Cow::Borrowed("TPE1")); const ALBUM_ID: FrameId<'static> = FrameId::Valid(Cow::Borrowed("TALB")); const GENRE_ID: FrameId<'static> = FrameId::Valid(Cow::Borrowed("TCON")); const TRACK_ID: FrameId<'static> = FrameId::Valid(Cow::Borrowed("TRCK")); const DISC_ID: FrameId<'static> = FrameId::Valid(Cow::Borrowed("TPOS")); const RECORDING_TIME_ID: FrameId<'static> = FrameId::Valid(Cow::Borrowed("TDRC")); pub(super) const ATTACHED_PICTURE_ID: FrameId<'static> = FrameId::Valid(Cow::Borrowed("APIC")); impl Accessor for Id3v2Tag { impl_accessor!( title => "TIT2"; artist => "TPE1"; album => "TALB"; ); fn track(&self) -> Option { self.split_num_pair(&TRACK_ID).0 } fn set_track(&mut self, value: u32) { self.insert_number_pair(TRACK_ID, Some(value), self.track_total()); } fn remove_track(&mut self) { let _ = self.remove(&TRACK_ID); } fn track_total(&self) -> Option { self.split_num_pair(&TRACK_ID).1 } fn set_track_total(&mut self, value: u32) { self.insert_number_pair(TRACK_ID, self.track(), Some(value)); } fn remove_track_total(&mut self) { let existing_track_number = self.track(); let _ = self.remove(&TRACK_ID); if let Some(track) = existing_track_number { self.insert(Frame::text(Cow::Borrowed("TRCK"), track.to_string())); } } fn disk(&self) -> Option { self.split_num_pair(&DISC_ID).0 } fn set_disk(&mut self, value: u32) { self.insert_number_pair(DISC_ID, Some(value), self.disk_total()); } fn remove_disk(&mut self) { let _ = self.remove(&DISC_ID); } fn disk_total(&self) -> Option { self.split_num_pair(&DISC_ID).1 } fn set_disk_total(&mut self, value: u32) { self.insert_number_pair(DISC_ID, self.disk(), Some(value)); } fn remove_disk_total(&mut self) { let existing_track_number = self.track(); let _ = self.remove(&DISC_ID); if let Some(track) = existing_track_number { self.insert(Frame::text(Cow::Borrowed("TPOS"), track.to_string())); } } fn genre(&self) -> Option> { let mut genres = self.genres()?.peekable(); let first = genres.next()?; if genres.peek().is_none() { return Some(Cow::Borrowed(first)); }; let mut joined = String::from(first); for genre in genres { joined.push_str(" / "); joined.push_str(genre); } Some(Cow::Owned(joined)) } fn set_genre(&mut self, value: String) { self.insert(new_text_frame(GENRE_ID, value)); } fn remove_genre(&mut self) { let _ = self.remove(&GENRE_ID); } fn year(&self) -> Option { if let Some(Frame::Timestamp(TimestampFrame { timestamp, .. })) = self.get(&RECORDING_TIME_ID) { return Some(u32::from(timestamp.year)); } None } fn set_year(&mut self, value: u32) { self.insert(Frame::text(Cow::Borrowed("TDRC"), value.to_string())); } fn remove_year(&mut self) { let _ = self.remove(&RECORDING_TIME_ID); } fn comment(&self) -> Option> { self.frames .iter() .find_map(|frame| filter_comment_frame_by_description(frame, &EMPTY_CONTENT_DESCRIPTOR)) .map(|CommentFrame { content, .. }| Cow::Borrowed(content.as_str())) } fn set_comment(&mut self, value: String) { let mut value = Some(value); self.frames.retain_mut(|frame| { let Some(CommentFrame { content, .. }) = filter_comment_frame_by_description_mut(frame, &EMPTY_CONTENT_DESCRIPTOR) else { return true; }; if let Some(value) = value.take() { // Replace value in first comment frame *content = value; true } else { // Remove all subsequent comment frames false } }); if let Some(value) = value { self.frames.push(new_comment_frame(value)); } } fn remove_comment(&mut self) { self.frames.retain(|frame| { filter_comment_frame_by_description(frame, &EMPTY_CONTENT_DESCRIPTOR).is_none() }) } } impl TagExt for Id3v2Tag { type Err = LoftyError; type RefKey<'a> = &'a FrameId<'a>; #[inline] fn tag_type(&self) -> TagType { TagType::Id3v2 } fn len(&self) -> usize { self.frames.len() } fn contains<'a>(&'a self, key: Self::RefKey<'a>) -> bool { self.frames.iter().any(|frame| frame.id() == key) } fn is_empty(&self) -> bool { self.frames.is_empty() } /// Writes the tag to a file /// /// # Errors /// /// * Attempting to write the tag to a format that does not support it /// * Attempting to write an encrypted frame without a valid method symbol or data length indicator /// * Attempting to write an invalid [`FrameId`]/[`Frame`] pairing fn save_to( &self, file: &mut F, write_options: WriteOptions, ) -> std::result::Result<(), Self::Err> where F: FileLike, LoftyError: From<::Error>, LoftyError: From<::Error>, { Id3v2TagRef { flags: self.flags, frames: self.frames.iter().filter_map(Frame::as_opt_ref).peekable(), } .write_to(file, write_options) } /// Dumps the tag to a writer /// /// # Errors /// /// * [`std::io::Error`] /// * [`ErrorKind::TooMuchData`](crate::error::ErrorKind::TooMuchData) fn dump_to( &self, writer: &mut W, write_options: WriteOptions, ) -> std::result::Result<(), Self::Err> { Id3v2TagRef { flags: self.flags, frames: self.frames.iter().filter_map(Frame::as_opt_ref).peekable(), } .dump_to(writer, write_options) } fn clear(&mut self) { self.frames.clear(); } } #[derive(Debug, Clone, Default)] pub struct SplitTagRemainder(Id3v2Tag); impl From for Id3v2Tag { fn from(from: SplitTagRemainder) -> Self { from.0 } } impl Deref for SplitTagRemainder { type Target = Id3v2Tag; fn deref(&self) -> &Self::Target { &self.0 } } fn handle_tag_split(tag: &mut Tag, frame: &mut Frame<'_>) -> bool { /// A frame we are able to split off into the tag const FRAME_CONSUMED: bool = false; /// A frame that must be held back const FRAME_RETAINED: bool = true; fn split_pair( content: &str, tag: &mut Tag, number_key: ItemKey, total_key: ItemKey, ) -> Option<()> { fn parse_number(source: &str) -> Option<&str> { let number = source.trim(); if number.is_empty() { return None; } if str::parse::(number).is_ok() { Some(number) } else { log::warn!("{number:?} could not be parsed as a number."); None } } let mut split = content.splitn(2, &[V4_MULTI_VALUE_SEPARATOR, NUMBER_PAIR_SEPARATOR][..]); let number = parse_number(split.next()?)?; let total = if let Some(total_source) = split.next() { Some(parse_number(total_source)?) } else { None }; debug_assert!(split.next().is_none()); debug_assert!(!number.is_empty()); tag.items.push(TagItem::new( number_key, ItemValue::Text(number.to_string()), )); if let Some(total) = total { debug_assert!(!total.is_empty()); tag.items .push(TagItem::new(total_key, ItemValue::Text(total.to_string()))) } Some(()) } match frame { // The text pairs need some special treatment Frame::Text(TextInformationFrame { header: FrameHeader { id, .. }, value: content, .. }) if id.as_str() == "TRCK" && split_pair(content, tag, ItemKey::TrackNumber, ItemKey::TrackTotal).is_some() => { return FRAME_CONSUMED }, Frame::Text(TextInformationFrame { header: FrameHeader { id, .. }, value: content, .. }) if id.as_str() == "TPOS" && split_pair(content, tag, ItemKey::DiscNumber, ItemKey::DiscTotal).is_some() => { return FRAME_CONSUMED }, Frame::Text(TextInformationFrame { header: FrameHeader { id, .. }, value: content, .. }) if id.as_str() == "MVIN" && split_pair( content, tag, ItemKey::MovementNumber, ItemKey::MovementTotal, ) .is_some() => { return FRAME_CONSUMED }, // TCON needs special treatment to translate genre IDs Frame::Text(TextInformationFrame { header: FrameHeader { id, .. }, value: content, .. }) if id.as_str() == "TCON" => { let genres = GenresIter::new(content, false); for genre in genres { tag.items.push(TagItem::new( ItemKey::Genre, ItemValue::Text(genre.to_string()), )); } return FRAME_CONSUMED; }, // TIPL needs special treatment, as we may not be able to consume all of its items Frame::KeyValue(KeyValueFrame { header: FrameHeader { id, .. }, key_value_pairs, .. }) if id.as_str() == "TIPL" => { key_value_pairs.retain_mut(|(key, value)| { for (item_key, tipl_key) in TIPL_MAPPINGS { if key == *tipl_key { tag.items.push(TagItem::new( item_key.clone(), ItemValue::Text(core::mem::take(value)), )); return false; // This key-value pair is consumed } } true // Keep key-value pair }); !key_value_pairs.is_empty() // Frame is consumed if we consumed all items }, // TODO: HACK!! We are specifically disallowing descriptions with a length of 4. // This is due to use storing 4 character IDs as Frame::Text on tag merge. // Maybe ItemKey could use a "TXXX:" prefix eventually, so we would store // "TXXX:MusicBrainz Album Id" instead of "MusicBrainz Album Id". // Store TXXX/WXXX frames by their descriptions, rather than their IDs Frame::UserText(ExtendedTextFrame { ref description, ref content, .. }) if !description.is_empty() && description.len() != 4 => { let item_key = ItemKey::from_key(TagType::Id3v2, description); for c in content.split(V4_MULTI_VALUE_SEPARATOR) { tag.items.push(TagItem::new( item_key.clone(), ItemValue::Text(c.to_string()), )); } return FRAME_CONSUMED; }, Frame::UserUrl(ExtendedUrlFrame { ref description, ref content, .. }) if !description.is_empty() && description.len() != 4 => { let item_key = ItemKey::from_key(TagType::Id3v2, description); for c in content.split(V4_MULTI_VALUE_SEPARATOR) { tag.items.push(TagItem::new( item_key.clone(), ItemValue::Locator(c.to_string()), )); } return FRAME_CONSUMED; }, Frame::UniqueFileIdentifier(UniqueFileIdentifierFrame { ref owner, ref identifier, .. }) => { if owner != MUSICBRAINZ_UFID_OWNER { // Unsupported owner return FRAME_RETAINED; } let mut identifier = Cursor::new(identifier); let Ok(recording_id) = decode_text( &mut identifier, TextDecodeOptions::new().encoding(TextEncoding::Latin1), ) else { return FRAME_RETAINED; }; tag.items.push(TagItem::new( ItemKey::MusicBrainzRecordingId, ItemValue::Text(recording_id.content), )); return FRAME_CONSUMED; }, // COMM/USLT are identical frames, outside of their ID Frame::Comment(CommentFrame { header: FrameHeader{ id, .. }, content, description, language, .. }) | Frame::UnsynchronizedText(UnsynchronizedTextFrame { header: FrameHeader{ id, .. }, content, description, language, .. }) => { let item_key = ItemKey::from_key(TagType::Id3v2, id.as_str()); for c in content.split(V4_MULTI_VALUE_SEPARATOR) { let mut item = TagItem::new(item_key.clone(), ItemValue::Text(c.to_string())); item.set_lang(*language); if *description != EMPTY_CONTENT_DESCRIPTOR { item.set_description(std::mem::take(description)); } tag.items.push(item); } return FRAME_CONSUMED; }, Frame::Picture(AttachedPictureFrame { ref mut picture, .. }) => { tag.push_picture(std::mem::replace(picture, TOMBSTONE_PICTURE)); return FRAME_CONSUMED; }, Frame::Timestamp(TimestampFrame { header: FrameHeader {id, ..} , timestamp, .. }) => { let item_key = ItemKey::from_key(TagType::Id3v2, id.as_str()); if matches!(item_key, ItemKey::Unknown(_)) { return FRAME_RETAINED; } if timestamp.verify().is_err() { return FRAME_RETAINED; } tag.items.push(TagItem::new( item_key, ItemValue::Text(timestamp.to_string()), )); return FRAME_CONSUMED; }, Frame::Text(TextInformationFrame { header: FrameHeader {id, .. }, value: content, .. }) => { let item_key = ItemKey::from_key(TagType::Id3v2, id.as_str()); for c in content.split(V4_MULTI_VALUE_SEPARATOR) { tag.items.push(TagItem::new( item_key.clone(), ItemValue::Text(c.to_string()), )); } return FRAME_CONSUMED; }, Frame::Url(UrlLinkFrame { header: FrameHeader {id, .. }, ref mut content, .. }) => { let item_key = ItemKey::from_key(TagType::Id3v2, id.as_str()); tag.items.push(TagItem::new( item_key, ItemValue::Locator(std::mem::take(content)), )); return FRAME_CONSUMED; }, Frame::Binary(_) | Frame::UserText(_) | Frame::UserUrl(_) // Bare extended text/URL frames make no sense to support. | Frame::KeyValue(_) | Frame::RelativeVolumeAdjustment(_) | Frame::Ownership(_) | Frame::EventTimingCodes(_) | Frame::Popularimeter(_) | Frame::Private(_) => { return FRAME_RETAINED; // Keep unsupported frame }, } } impl SplitTag for Id3v2Tag { type Remainder = SplitTagRemainder; fn split_tag(mut self) -> (Self::Remainder, Tag) { let mut tag = Tag::new(TagType::Id3v2); self.frames .retain_mut(|frame| handle_tag_split(&mut tag, frame)); (SplitTagRemainder(self), tag) } } impl MergeTag for SplitTagRemainder { type Merged = Id3v2Tag; fn merge_tag(self, mut tag: Tag) -> Id3v2Tag { fn join_text_items<'a>( tag: &mut Tag, keys: impl IntoIterator, ) -> Option { let mut concatenated: Option = None; for key in keys { let mut iter = tag.take_strings(key); let Some(first) = iter.next() else { continue; }; // Use the length of the first string for estimating the capacity // of the concatenated string. let estimated_len_per_item = first.len(); let min_remaining_items = iter.size_hint().0; let concatenated = if let Some(concatenated) = &mut concatenated { concatenated.reserve( (1 + estimated_len_per_item) * (1 + min_remaining_items) + first.len(), ); concatenated.push(V4_MULTI_VALUE_SEPARATOR); concatenated.push_str(&first); concatenated } else { let mut first = first; first.reserve((1 + estimated_len_per_item) * min_remaining_items); concatenated = Some(first); concatenated.as_mut().expect("some") }; iter.for_each(|i| { concatenated.push(V4_MULTI_VALUE_SEPARATOR); concatenated.push_str(&i); }); } concatenated } struct JoinedLanguageItem { lang: Lang, description: String, content: String, } fn join_language_items( tag: &mut Tag, key: &ItemKey, ) -> Option> { let mut items = tag.take(key).collect::>(); if items.is_empty() { return None; } items.sort_by(|a, b| a.lang.cmp(&b.lang).then(a.description.cmp(&b.description))); let TagItem { lang: mut current_lang, description: mut current_description, item_value: ItemValue::Text(mut current_content), .. } = items.remove(0) else { log::warn!("Expected a text item for {key:?}"); return None; }; let mut joined_items = Vec::::new(); for item in items { let TagItem { lang, description, item_value: ItemValue::Text(content), .. } = item else { continue; }; if lang != current_lang || description != current_description { joined_items.push(JoinedLanguageItem { lang: current_lang, description: std::mem::take(&mut current_description), content: std::mem::take(&mut current_content), }); current_lang = lang; current_description = description; current_content = content; } else { current_content.push(V4_MULTI_VALUE_SEPARATOR); current_content.push_str(&content); } } joined_items.push(JoinedLanguageItem { lang: current_lang, description: current_description, content: current_content, }); Some(joined_items.into_iter()) } let Self(mut merged) = self; merged.frames.reserve(tag.item_count() as usize); // Multi-valued text key-to-frame mappings // TODO: Extend this list of item keys as needed or desired for item_key in [ &ItemKey::TrackArtist, &ItemKey::AlbumArtist, &ItemKey::TrackTitle, &ItemKey::AlbumTitle, &ItemKey::SetSubtitle, &ItemKey::TrackSubtitle, &ItemKey::OriginalAlbumTitle, &ItemKey::OriginalArtist, &ItemKey::OriginalLyricist, &ItemKey::ContentGroup, &ItemKey::AppleId3v2ContentGroup, &ItemKey::Genre, &ItemKey::Mood, &ItemKey::Composer, &ItemKey::Conductor, &ItemKey::Writer, &ItemKey::Director, &ItemKey::Lyricist, &ItemKey::MusicianCredits, &ItemKey::InternetRadioStationName, &ItemKey::InternetRadioStationOwner, &ItemKey::Remixer, &ItemKey::Work, &ItemKey::Movement, &ItemKey::FileOwner, &ItemKey::CopyrightMessage, &ItemKey::Language, ] { let frame_id = item_key .map_key(TagType::Id3v2, false) .expect("valid frame id"); if let Some(text) = join_text_items(&mut tag, [item_key]) { let frame = new_text_frame(FrameId::Valid(Cow::Borrowed(frame_id)), text); // Optimization: No duplicate checking according to the preconditions debug_assert!(!merged.frames.contains(&frame)); merged.frames.push(frame); } } // Multi-valued Label/Publisher key-to-frame mapping { let frame_id = ItemKey::Label .map_key(TagType::Id3v2, false) .expect("valid frame id"); debug_assert_eq!( Some(frame_id), ItemKey::Publisher.map_key(TagType::Id3v2, false) ); if let Some(text) = join_text_items(&mut tag, &[ItemKey::Label, ItemKey::Publisher]) { let frame = new_text_frame(FrameId::Valid(Cow::Borrowed(frame_id)), text); // Optimization: No duplicate checking according to the preconditions debug_assert!(!merged.frames.contains(&frame)); merged.frames.push(frame); } } // Comment/Unsync text for key in [ItemKey::Comment, ItemKey::Lyrics] { let Some(items) = join_language_items(&mut tag, &key) else { continue; }; for JoinedLanguageItem { lang, description, content, } in items { let frame = match key { ItemKey::Comment => Frame::Comment(CommentFrame::new( TextEncoding::UTF8, lang, description, content, )), ItemKey::Lyrics => Frame::UnsynchronizedText(UnsynchronizedTextFrame::new( TextEncoding::UTF8, lang, description, content, )), _ => continue, }; // Optimization: No duplicate checking according to the preconditions debug_assert!(!merged.frames.contains(&frame)); merged.frames.push(frame); } } // TIPL key-value mappings 'tipl: { let mut key_value_pairs = Vec::new(); for (item_key, tipl_key) in TIPL_MAPPINGS { for value in tag.take_strings(item_key) { key_value_pairs.push(((*tipl_key).to_string(), value)); } } if key_value_pairs.is_empty() { break 'tipl; } // Check for an existing TIPL frame, and simply extend the existing list // to retain the current `TextEncoding` and `FrameFlags`. let existing_tipl = merged.take_first(&FrameId::Valid(Cow::Borrowed("TIPL"))); if let Some(mut tipl_frame) = existing_tipl { if let Frame::KeyValue(KeyValueFrame { key_value_pairs: ref mut existing, .. }) = &mut tipl_frame { existing.extend(key_value_pairs); } merged.frames.push(tipl_frame); break 'tipl; } merged.frames.push(new_key_value_frame( FrameId::Valid(Cow::Borrowed(INVOLVED_PEOPLE_LIST_ID)), key_value_pairs, )); } // Flag items for item_key in [&ItemKey::FlagCompilation, &ItemKey::FlagPodcast] { let Some(text) = tag.take_strings(item_key).next() else { continue; }; let Some(flag_value) = flag_item(&text) else { continue; }; let frame_id = item_key .map_key(TagType::Id3v2, false) .expect("valid frame id"); merged.frames.push(new_text_frame( FrameId::Valid(Cow::Borrowed(frame_id)), u8::from(flag_value).to_string(), )); } 'rate: { if let Some(advisory_rating) = tag.take_strings(&ItemKey::ParentalAdvisory).next() { let Ok(rating) = advisory_rating.parse::() else { log::warn!( "Parental advisory rating is not a number: {advisory_rating}, discarding" ); break 'rate; }; let Ok(parsed_rating) = AdvisoryRating::try_from(rating) else { log::warn!("Parental advisory rating is out of range: {rating}, discarding"); break 'rate; }; merged.frames.push(new_user_text_frame( "ITUNESADVISORY".to_string(), parsed_rating.as_u8().to_string(), )); } } // Timestamps for item_key in [&ItemKey::RecordingDate, &ItemKey::OriginalReleaseDate] { let Some(text) = tag.take_strings(item_key).next() else { continue; }; let frame_id = item_key .map_key(TagType::Id3v2, false) .expect("valid frame id"); let frame; match Timestamp::from_str(&text) { Ok(timestamp) => { frame = new_timestamp_frame(FrameId::Valid(Cow::Borrowed(frame_id)), timestamp); }, Err(_) => { // We can just preserve it as a text frame frame = new_text_frame(FrameId::Valid(Cow::Borrowed(frame_id)), text); }, } merged.insert(frame); } // Insert all remaining items as single frames and deduplicate as needed for item in tag.items { merged.insert_item(item); } // Insert all pictures as single frames and deduplicate as needed for picture in tag.pictures { let frame = new_picture_frame(picture); if let Some(replaced) = merged.insert(frame) { log::warn!("Replaced picture frame: {replaced:?}"); } } merged } } impl From for Tag { fn from(input: Id3v2Tag) -> Self { let (remainder, mut tag) = input.split_tag(); if unsafe { global_options().preserve_format_specific_items } && remainder.0.len() > 0 { tag.companion_tag = Some(CompanionTag::Id3v2(remainder.0)); } tag } } impl From for Id3v2Tag { fn from(mut input: Tag) -> Self { if unsafe { global_options().preserve_format_specific_items } { if let Some(companion) = input.companion_tag.take().and_then(CompanionTag::id3v2) { return SplitTagRemainder(companion).merge_tag(input); } } SplitTagRemainder::default().merge_tag(input) } } pub(crate) struct Id3v2TagRef<'a, I: Iterator> + 'a> { pub(crate) flags: Id3v2TagFlags, pub(crate) frames: Peekable, } impl<'a> Id3v2TagRef<'a, std::iter::Empty>> { pub(crate) fn empty() -> Self { Self { flags: Id3v2TagFlags::default(), frames: std::iter::empty().peekable(), } } } // Create an iterator of FrameRef from a Tag's items for Id3v2TagRef::new pub(crate) fn tag_frames(tag: &Tag) -> impl Iterator> { #[derive(Clone)] enum CompanionTagIter { Filled(F), Empty(E), } impl<'a, I> Iterator for CompanionTagIter>> where I: Iterator>, { type Item = FrameRef<'a>; fn next(&mut self) -> Option { match self { CompanionTagIter::Filled(iter) => iter.next(), CompanionTagIter::Empty(_) => None, } } } fn create_frameref_for_number_pair<'a>( number: Option<&str>, total: Option<&str>, id: &'a str, ) -> Option> { format_number_pair(number, total) .map(|value| FrameRef(Cow::Owned(Frame::text(Cow::Borrowed(id), value)))) } fn create_framerefs_for_companion_tag( companion: Option<&CompanionTag>, ) -> impl IntoIterator> + Clone { match companion { Some(CompanionTag::Id3v2(companion)) => { CompanionTagIter::Filled(companion.frames.iter().filter_map(Frame::as_opt_ref)) }, _ => CompanionTagIter::Empty(std::iter::empty()), } } let items = tag .items() .filter(|item| !NUMBER_PAIR_KEYS.contains(item.key())) .map(TryInto::>::try_into) .filter_map(Result::ok) .chain(create_frameref_for_number_pair( tag.get_string(&ItemKey::TrackNumber), tag.get_string(&ItemKey::TrackTotal), "TRCK", )) .chain(create_frameref_for_number_pair( tag.get_string(&ItemKey::DiscNumber), tag.get_string(&ItemKey::DiscTotal), "TPOS", )) .chain(create_framerefs_for_companion_tag( tag.companion_tag.as_ref(), )); let pictures = tag.pictures().iter().map(|p| { FrameRef(Cow::Owned(Frame::Picture(AttachedPictureFrame::new( TextEncoding::UTF8, p.clone(), )))) }); items.chain(pictures) } impl<'a, I: Iterator> + 'a> Id3v2TagRef<'a, I> { pub(crate) fn write_to(&mut self, file: &mut F, write_options: WriteOptions) -> Result<()> where F: FileLike, LoftyError: From<::Error>, LoftyError: From<::Error>, { super::write::write_id3v2(file, self, write_options) } pub(crate) fn dump_to( &mut self, writer: &mut W, write_options: WriteOptions, ) -> Result<()> { let temp = super::write::create_tag(self, write_options)?; writer.write_all(&temp)?; Ok(()) } } lofty-0.21.1/src/id3/v2/util/mappings.rs000064400000000000000000000003701046102023000160000ustar 00000000000000use crate::tag::ItemKey; pub(crate) const TIPL_MAPPINGS: &[(ItemKey, &str)] = &[ (ItemKey::Producer, "producer"), (ItemKey::Arranger, "arranger"), (ItemKey::Engineer, "engineer"), (ItemKey::MixDj, "DJ-mix"), (ItemKey::MixEngineer, "mix"), ]; lofty-0.21.1/src/id3/v2/util/mod.rs000064400000000000000000000001761046102023000147450ustar 00000000000000//! Utilities for working with ID3v2 tags pub(crate) mod mappings; pub(crate) mod pairs; pub mod synchsafe; pub mod upgrade; lofty-0.21.1/src/id3/v2/util/pairs.rs000064400000000000000000000042341046102023000153030ustar 00000000000000//! Contains utilities for ID3v2 style number pairs use crate::tag::{ItemKey, TagItem}; use std::fmt::Display; pub(crate) const NUMBER_PAIR_SEPARATOR: char = '/'; // This is used as the default number of track and disk. pub(crate) const DEFAULT_NUMBER_IN_PAIR: u32 = 0; // These keys have the part of the number pair. pub(crate) const NUMBER_PAIR_KEYS: &[ItemKey] = &[ ItemKey::TrackNumber, ItemKey::TrackTotal, ItemKey::DiscNumber, ItemKey::DiscTotal, ]; /// Creates an ID3v2 style number pair pub(crate) fn format_number_pair(number: Option, total: Option) -> Option where N: Display, T: Display, { match (number, total) { (Some(number), None) => Some(number.to_string()), (None, Some(total)) => Some(format!( "{DEFAULT_NUMBER_IN_PAIR}{NUMBER_PAIR_SEPARATOR}{total}" )), (Some(number), Some(total)) => Some(format!("{number}{NUMBER_PAIR_SEPARATOR}{total}")), (None, None) => None, } } /// Attempts to convert a `TagItem` to a number, passing it to `setter` pub(crate) fn set_number(item: &TagItem, mut setter: F) { let text = item.value().text(); let trimmed_text = text.unwrap_or_default().trim(); if trimmed_text.is_empty() { log::warn!("Value does not have text in {:?}", item.key()); return; } match trimmed_text.parse::() { Ok(number) => setter(number), Err(parse_error) => { log::warn!( "\"{}\" cannot be parsed as number in {:?}: {parse_error}", text.unwrap(), item.key() ) }, } } #[cfg(test)] mod tests { use crate::id3::v2::util::pairs::set_number; use crate::tag::{ItemKey, ItemValue, TagItem}; #[test] fn whitespace_in_number() { let item = TagItem::new( ItemKey::TrackNumber, ItemValue::Text(String::from(" 12 ")), ); set_number(&item, |number| assert_eq!(number, 12)); } #[test] fn empty_number_string() { let item = TagItem::new(ItemKey::TrackNumber, ItemValue::Text(String::new())); set_number(&item, |_| unreachable!("Should not be called")); // Also with whitespace only strings let item = TagItem::new( ItemKey::TrackNumber, ItemValue::Text(String::from(" ")), ); set_number(&item, |_| unreachable!("Should not be called")); } } lofty-0.21.1/src/id3/v2/util/synchsafe.rs000064400000000000000000000233451046102023000161540ustar 00000000000000//! Utilities for working with unsynchronized ID3v2 content //! //! See [`FrameFlags::unsynchronisation`](crate::id3::v2::FrameFlags::unsynchronisation) for an explanation. use crate::error::Result; use std::io::Read; /// A reader for unsynchronized content /// /// See [`FrameFlags::unsynchronisation`](crate::id3::v2::FrameFlags::unsynchronisation) for an explanation. /// /// # Examples /// /// ```rust /// use std::io::{Cursor, Read}; /// use lofty::id3::v2::util::synchsafe::UnsynchronizedStream; /// /// fn main() -> lofty::error::Result<()> { /// // The content has two `0xFF 0x00` pairs, which will be removed /// let content = [0xFF, 0x00, 0x1A, 0xFF, 0x00, 0x15]; /// /// let mut unsynchronized_reader = UnsynchronizedStream::new(Cursor::new(content)); /// /// let mut unsynchronized_content = Vec::new(); /// unsynchronized_reader.read_to_end(&mut unsynchronized_content)?; /// /// // All null bytes following `0xFF` have been removed /// assert_eq!(unsynchronized_content, [0xFF, 0x1A, 0xFF, 0x15]); /// # Ok(()) } /// ``` pub struct UnsynchronizedStream { reader: R, // Same buffer size as `BufReader` buf: [u8; 8 * 1024], bytes_available: usize, pos: usize, encountered_ff: bool, } impl UnsynchronizedStream { /// Create a new [`UnsynchronizedStream`] /// /// # Examples /// /// ```rust /// use lofty::id3::v2::util::synchsafe::UnsynchronizedStream; /// use std::io::Cursor; /// /// let reader = Cursor::new([0xFF, 0x00, 0x1A]); /// let unsynchronized_reader = UnsynchronizedStream::new(reader); /// ``` pub fn new(reader: R) -> Self { Self { reader, buf: [0; 8 * 1024], bytes_available: 0, pos: 0, encountered_ff: false, } } /// Extract the reader, discarding the [`UnsynchronizedStream`] /// /// # Examples /// /// ```rust /// use lofty::id3::v2::util::synchsafe::UnsynchronizedStream; /// use std::io::Cursor; /// /// # fn main() -> lofty::error::Result<()> { /// let reader = Cursor::new([0xFF, 0x00, 0x1A]); /// let unsynchronized_reader = UnsynchronizedStream::new(reader); /// /// let reader = unsynchronized_reader.into_inner(); /// # Ok(()) } /// ``` pub fn into_inner(self) -> R { self.reader } } impl Read for UnsynchronizedStream { fn read(&mut self, buf: &mut [u8]) -> std::io::Result { let dest_len = buf.len(); if dest_len == 0 { return Ok(0); } let mut dest_pos = 0; loop { if dest_pos == dest_len { break; } if self.pos >= self.bytes_available { self.bytes_available = self.reader.read(&mut self.buf)?; self.pos = 0; } // Exhausted the reader if self.bytes_available == 0 { break; } if self.encountered_ff { self.encountered_ff = false; // Only skip the next byte if this is valid unsynchronization // Otherwise just continue as normal if self.buf[self.pos] == 0 { self.pos += 1; continue; } } let current_byte = self.buf[self.pos]; buf[dest_pos] = current_byte; dest_pos += 1; self.pos += 1; if current_byte == 0xFF { self.encountered_ff = true; } } Ok(dest_pos) } } /// An integer that can be converted to and from synchsafe variants pub trait SynchsafeInteger: Sized { /// The integer type that this can be widened to for use in [`SynchsafeInteger::widening_synch`] type WideningType; /// Create a synchsafe integer /// /// See [`FrameFlags::unsynchronisation`](crate::id3::v2::FrameFlags::unsynchronisation) for an explanation. /// /// # Errors /// /// `self` doesn't fit in <`INTEGER_TYPE::BITS - size_of::()`> bits /// /// # Examples /// /// ```rust /// use lofty::id3::v2::util::synchsafe::SynchsafeInteger; /// /// # fn main() -> lofty::error::Result<()> { /// // Maximum value we can represent in a synchsafe u32 /// let unsynch_number = 0xFFF_FFFF_u32; /// let synch_number = unsynch_number.synch()?; /// /// // Our synchronized number should be something completely different /// assert_ne!(synch_number, unsynch_number); /// /// // Each byte should have 7 set bits and an MSB of 0 /// assert_eq!(synch_number, 0b01111111_01111111_01111111_01111111_u32); /// # Ok(()) } /// ``` fn synch(self) -> Result; /// Create a synchsafe integer, widening to the next available integer type /// /// See [`FrameFlags::unsynchronisation`](crate::id3::v2::FrameFlags::unsynchronisation) for an explanation. /// /// # Examples /// /// ```rust /// use lofty::id3::v2::util::synchsafe::SynchsafeInteger; /// /// // 0b11111111 /// let large_number = u8::MAX; /// /// // Widened to a u16 /// // 0b00000001_01111111 /// let large_number_synchsafe = large_number.widening_synch(); /// /// // Unsynchronizing the number will get us back to 255 /// assert_eq!(large_number_synchsafe.unsynch(), large_number as u16); /// ``` fn widening_synch(self) -> Self::WideningType; /// Unsynchronise a synchsafe integer /// /// See [`FrameFlags::unsynchronisation`](crate::id3::v2::FrameFlags::unsynchronisation) for an explanation. /// /// # Examples /// /// ```rust /// use lofty::id3::v2::util::synchsafe::SynchsafeInteger; /// /// # fn main() -> lofty::error::Result<()> { /// let unsynch_number = 0xFFF_FFFF_u32; /// let synch_number = unsynch_number.synch()?; /// /// // Our synchronized number should be something completely different /// assert_ne!(synch_number, unsynch_number); /// /// // Now, our re-unsynchronized number should match our original /// let re_unsynch_number = synch_number.unsynch(); /// assert_eq!(re_unsynch_number, unsynch_number); /// # Ok(()) } /// ``` fn unsynch(self) -> Self; } macro_rules! impl_synchsafe { ( $ty:ty, $widening_ty:ty, synch($n:ident) $body:block; widening_synch($w:ident) $widening_body:block; unsynch($u:ident) $unsynch_body:block ) => { #[allow(unused_parens)] impl SynchsafeInteger for $ty { type WideningType = $widening_ty; fn synch(self) -> Result { const MAXIMUM_INTEGER: $ty = { let num_bytes = core::mem::size_of::<$ty>(); // 7 bits are available per byte, shave off 1 bit per byte <$ty>::MAX >> num_bytes }; if self > MAXIMUM_INTEGER { crate::macros::err!(TooMuchData); } let $n = self; Ok($body) } fn widening_synch(self) -> Self::WideningType { let mut $w = <$widening_ty>::MIN; let $n = self; $widening_body; $w } fn unsynch(self) -> Self { let $u = self; $unsynch_body } } }; } impl_synchsafe! { u8, u16, synch(n) { (n & 0x7F) }; widening_synch(w) { w |= u16::from(n & 0x7F); w |= u16::from(n & 0x80) << 1; }; unsynch(u) { (u & 0x7F) } } impl_synchsafe! { u16, u32, synch(n) { (n & 0x7F) | ((n & (0x7F << 7)) << 1) }; widening_synch(w) { w |= u32::from(n & 0x7F); w |= u32::from((n & (0x7F << 7)) << 1); w |= u32::from(n & (0x03 << 14)) << 2; }; unsynch(u) { ((u & 0x7F00) >> 1) | (u & 0x7F) } } impl_synchsafe! { u32, u64, synch(n) { (n & 0x7F) | ((n & (0x7F << 7)) << 1) | ((n & (0x7F << 14)) << 2) | ((n & (0x7F << 21)) << 3) }; widening_synch(w) { w |= u64::from(n & 0x7F); w |= u64::from(n & (0x7F << 7)) << 1; w |= u64::from(n & (0x7F << 14)) << 2; w |= u64::from(n & (0x7F << 21)) << 3; w |= u64::from(n & (0x0F << 28)) << 4; }; unsynch(u) { ((u & 0x7F00_0000) >> 3) | ((u & 0x7F_0000) >> 2) | ((u & 0x7F00) >> 1) | (u & 0x7F) } } #[cfg(test)] mod tests { const UNSYNCHRONIZED_CONTENT: &[u8] = &[0xFF, 0x00, 0x00, 0xFF, 0x12, 0xB0, 0x05, 0xFF, 0x00, 0x00]; const EXPECTED: &[u8] = &[0xFF, 0x00, 0xFF, 0x12, 0xB0, 0x05, 0xFF, 0x00]; #[test] fn unsynchronized_stream() { let reader = Cursor::new(UNSYNCHRONIZED_CONTENT); let mut unsynchronized_reader = UnsynchronizedStream::new(reader); let mut final_content = Vec::new(); unsynchronized_reader .read_to_end(&mut final_content) .unwrap(); assert_eq!(final_content, EXPECTED); } #[test] fn unsynchronized_stream_large() { // Create a buffer >10k to force a buffer reset let reader = Cursor::new(UNSYNCHRONIZED_CONTENT.repeat(1000)); let mut unsynchronized_reader = UnsynchronizedStream::new(reader); let mut final_content = Vec::new(); unsynchronized_reader .read_to_end(&mut final_content) .unwrap(); // UNSYNCHRONIZED_CONTENT * 1000 should equal EXPECTED * 1000 assert_eq!(final_content, EXPECTED.repeat(1000)); } #[test] fn unsynchronized_stream_should_not_replace_unrelated() { const ORIGINAL_CONTENT: &[u8] = &[0xFF, 0x1A, 0xFF, 0xC0, 0x10, 0x01]; let reader = Cursor::new(ORIGINAL_CONTENT); let mut unsynchronized_reader = UnsynchronizedStream::new(reader); let mut final_content = Vec::new(); unsynchronized_reader .read_to_end(&mut final_content) .unwrap(); assert_eq!(final_content, ORIGINAL_CONTENT); } use crate::id3::v2::util::synchsafe::{SynchsafeInteger, UnsynchronizedStream}; use std::io::{Cursor, Read}; macro_rules! synchsafe_integer_tests { ( $($int:ty => { synch: $original:literal, $new:literal; unsynch: $original_unsync:literal, $new_unsynch:literal; widen: $original_widen:literal, $new_widen:literal; });+ ) => { $( paste::paste! { #[test] fn [<$int _synch>]() { assert_eq!($original.synch().unwrap(), $new); } #[test] fn [<$int _unsynch>]() { assert_eq!($original_unsync.unsynch(), $new_unsynch); } #[test] fn [<$int _widen>]() { assert_eq!($original_widen.widening_synch(), $new_widen); } } )+ }; } synchsafe_integer_tests! { u8 => { synch: 0x7F_u8, 0x7F_u8; unsynch: 0x7F_u8, 0x7F_u8; widen: 0xFF_u8, 0x017F_u16; }; u16 => { synch: 0x3FFF_u16, 0x7F7F_u16; unsynch: 0x7F7F_u16, 0x3FFF_u16; widen: 0xFFFF_u16, 0x0003_7F7F_u32; }; u32 => { synch: 0xFFF_FFFF_u32, 0x7F7F_7F7F_u32; unsynch: 0x7F7F_7F7F_u32, 0xFFF_FFFF_u32; widen: 0xFFFF_FFFF_u32, 0x000F_7F7F_7F7F_u64; } } } lofty-0.21.1/src/id3/v2/util/upgrade.rs000064400000000000000000000062111046102023000156110ustar 00000000000000//! Utilities for upgrading old ID3v2 frame IDs use std::collections::HashMap; /// Upgrade an ID3v2.2 key to an ID3v2.4 key /// /// # Examples /// /// ```rust /// use lofty::id3::v2::upgrade_v2; /// /// let old_title = "TT2"; /// let new_title = upgrade_v2(old_title); /// /// assert_eq!(new_title, Some("TIT2")); /// ``` pub fn upgrade_v2(key: &str) -> Option<&'static str> { v2keys().get(key).copied() } /// Upgrade an ID3v2.3 key to an ID3v2.4 key /// /// # Examples /// /// ```rust /// use lofty::id3::v2::upgrade_v3; /// /// let old_involved_people_list = "IPLS"; /// let new_involved_people_list = upgrade_v3(old_involved_people_list); /// /// assert_eq!(new_involved_people_list, Some("TIPL")); /// ``` pub fn upgrade_v3(key: &str) -> Option<&'static str> { v3keys().get(key).copied() } macro_rules! gen_upgrades { (V2 => [$($($v2_key:literal)|* => $id3v24_from_v2:literal),+]; V3 => [$($($v3_key:literal)|* => $id3v24_from_v3:literal),+]) => { use std::sync::OnceLock; fn v2keys() -> &'static HashMap<&'static str, &'static str> { static INSTANCE: OnceLock> = OnceLock::new(); INSTANCE.get_or_init(|| { let mut map = HashMap::new(); $( $( map.insert($v2_key, $id3v24_from_v2); )+ )+ map }) } fn v3keys() -> &'static HashMap<&'static str, &'static str> { static INSTANCE: OnceLock> = OnceLock::new(); INSTANCE.get_or_init(|| { let mut map = HashMap::new(); $( $( map.insert($v3_key, $id3v24_from_v3); )+ )+ map }) } }; } gen_upgrades!( // ID3v2.2 => ID3v2.4 V2 => [ // Standard frames "BUF" => "RBUF", "CNT" => "PCNT", "COM" => "COMM", "CRA" => "AENC", "ETC" => "ETCO", "GEO" => "GEOB", "IPL" => "TIPL", "MCI" => "MCDI", "MLL" => "MLLT", "PIC" => "APIC", "POP" => "POPM", "REV" => "RVRB", "SLT" => "SYLT", "STC" => "SYTC", "TAL" => "TALB", "TBP" => "TBPM", "TCM" => "TCOM", "TCO" => "TCON", "TCP" => "TCMP", "TCR" => "TCOP", "TDY" => "TDLY", "TEN" => "TENC", "TFT" => "TFLT", "TKE" => "TKEY", "TLA" => "TLAN", "TLE" => "TLEN", "TMT" => "TMED", "TOA" => "TOAL", "TOF" => "TOFN", "TOL" => "TOLY", "TOR" => "TDOR", "TOT" => "TOAL", "TP1" => "TPE1", "TP2" => "TPE2", "TP3" => "TPE3", "TP4" => "TPE4", "TPA" => "TPOS", "TPB" => "TPUB", "TRC" => "TSRC", "TRD" => "TDRC", "TRK" => "TRCK", "TS2" => "TSO2", "TSA" => "TSOA", "TSC" => "TSOC", "TSP" => "TSOP", "TSS" => "TSSE", "TST" => "TSOT", "TT1" => "TIT1", "TT2" => "TIT2", "TT3" => "TIT3", "TXT" => "TOLY", "TXX" => "TXXX", "TYE" => "TDRC", "UFI" => "UFID", "ULT" => "USLT", "WAF" => "WOAF", "WAR" => "WOAR", "WAS" => "WOAS", "WCM" => "WCOM", "WCP" => "WCOP", "WPB" => "WPUB", "WXX" => "WXXX", // iTunes non-standard frames // Podcast "PCS" => "PCST", "TCT" => "TCAT", "TDS" => "TDES", "TID" => "TGID", "WFD" => "WFED", // Identifiers "MVI" => "MVIN", "MVN" => "MVNM", "GP1" => "GRP1", "TDR" => "TDRL" ]; // ID3v2.3 => ID3v2.4 V3 => [ // Standard frames "TORY" => "TDOR", "TYER" => "TDRC", "IPLS" => "TIPL" ] ); lofty-0.21.1/src/id3/v2/write/chunk_file.rs000064400000000000000000000040551046102023000164520ustar 00000000000000use crate::config::WriteOptions; use crate::error::{LoftyError, Result}; use crate::iff::chunk::Chunks; use crate::util::io::{FileLike, Length, Truncate}; use std::io::SeekFrom; use byteorder::{ByteOrder, WriteBytesExt}; const CHUNK_NAME_UPPER: [u8; 4] = [b'I', b'D', b'3', b' ']; const CHUNK_NAME_LOWER: [u8; 4] = [b'i', b'd', b'3', b' ']; pub(in crate::id3::v2) fn write_to_chunk_file( file: &mut F, tag: &[u8], write_options: WriteOptions, ) -> Result<()> where F: FileLike, LoftyError: From<::Error>, LoftyError: From<::Error>, B: ByteOrder, { // RIFF....WAVE file.seek(SeekFrom::Current(12))?; let file_len = file.len()?.saturating_sub(12); let mut id3v2_chunk = (None, None); let mut chunks = Chunks::::new(file_len); while chunks.next(file).is_ok() { if chunks.fourcc == CHUNK_NAME_UPPER || chunks.fourcc == CHUNK_NAME_LOWER { id3v2_chunk = (Some(file.stream_position()? - 8), Some(chunks.size)); break; } file.seek(SeekFrom::Current(i64::from(chunks.size)))?; chunks.correct_position(file)?; } if let (Some(chunk_start), Some(mut chunk_size)) = id3v2_chunk { file.rewind()?; // We need to remove the padding byte if it exists if chunk_size % 2 != 0 { chunk_size += 1; } let mut file_bytes = Vec::new(); file.read_to_end(&mut file_bytes)?; file_bytes.splice( chunk_start as usize..(chunk_start + u64::from(chunk_size) + 8) as usize, [], ); file.rewind()?; file.truncate(0)?; file.write_all(&file_bytes)?; } if !tag.is_empty() { file.seek(SeekFrom::End(0))?; if write_options.uppercase_id3v2_chunk { file.write_all(&CHUNK_NAME_UPPER)?; } else { file.write_all(&CHUNK_NAME_LOWER)?; } file.write_u32::(tag.len() as u32)?; file.write_all(tag)?; // It is required an odd length chunk be padded with a 0 // The 0 isn't included in the chunk size, however if tag.len() % 2 != 0 { file.write_u8(0)?; } let total_size = file.stream_position()? - 8; file.seek(SeekFrom::Start(4))?; file.write_u32::(total_size as u32)?; } Ok(()) } lofty-0.21.1/src/id3/v2/write/frame.rs000064400000000000000000000200671046102023000154360ustar 00000000000000use crate::error::{Id3v2Error, Id3v2ErrorKind, Result}; use crate::id3::v2::frame::{FrameFlags, FrameRef}; use crate::id3::v2::util::synchsafe::SynchsafeInteger; use crate::id3::v2::{Frame, FrameId, KeyValueFrame, TextInformationFrame}; use crate::tag::items::Timestamp; use std::io::Write; use crate::id3::v2::tag::GenresIter; use byteorder::{BigEndian, WriteBytesExt}; pub(in crate::id3::v2) fn create_items( writer: &mut W, frames: &mut dyn Iterator>, ) -> Result<()> where W: Write, { let is_id3v23 = false; for frame in frames { verify_frame(&frame)?; let value = frame.as_bytes(is_id3v23)?; write_frame( writer, frame.id().as_str(), frame.flags(), &value, is_id3v23, )?; } Ok(()) } pub(in crate::id3::v2) fn create_items_v3( writer: &mut W, frames: &mut dyn Iterator>, ) -> Result<()> where W: Write, { // These are all frames from ID3v2.4 const FRAMES_TO_DISCARD: &[&str] = &[ "ASPI", "EQU2", "RVA2", "SEEK", "SIGN", "TDEN", "TDRL", "TDTG", "TMOO", "TPRO", "TSOA", "TSOP", "TSOT", "TSST", ]; const IPLS_ID: &str = "IPLS"; let is_id3v23 = true; let mut ipls = None; for mut frame in frames { let id = frame.id_str(); if FRAMES_TO_DISCARD.contains(&id) { log::warn!("Discarding frame: {}, not supported in ID3v2.3", id); continue; } verify_frame(&frame)?; match id { // TORY (Original release year) is the only component of TDOR // that is supported in ID3v2.3 // // TDRC (Recording time) gets split into three frames: TYER, TDAT, and TIME "TDOR" | "TDRC" => { let mut value = frame.0.clone(); let Frame::Timestamp(ref mut f) = value.to_mut() else { log::warn!("Discarding frame: {}, not supported in ID3v2.3", id); continue; }; if f.timestamp.verify().is_err() { log::warn!("Discarding frame: {}, invalid timestamp", id); continue; } if id == "TDOR" { let year = f.timestamp.year; f.timestamp = Timestamp { year, ..Timestamp::default() }; f.header.id = FrameId::Valid("TORY".into()); frame.0 = value; } else { let mut new_frames = Vec::with_capacity(3); let timestamp = f.timestamp; let year = timestamp.year; new_frames.push(Frame::Text(TextInformationFrame::new( FrameId::Valid("TYER".into()), f.encoding.to_id3v23(), year.to_string(), ))); if let (Some(month), Some(day)) = (timestamp.month, timestamp.day) { let date = format!("{:02}{:02}", day, month); new_frames.push(Frame::Text(TextInformationFrame::new( FrameId::Valid("TDAT".into()), f.encoding.to_id3v23(), date, ))); } if let (Some(hour), Some(minute)) = (timestamp.hour, timestamp.minute) { let time = format!("{:02}{:02}", hour, minute); new_frames.push(Frame::Text(TextInformationFrame::new( FrameId::Valid("TIME".into()), f.encoding.to_id3v23(), time, ))); } for mut frame in new_frames { frame.set_flags(f.header.flags); let value = frame.as_bytes(is_id3v23)?; write_frame( writer, frame.id().as_str(), frame.flags(), &value, is_id3v23, )?; } continue; } }, // TCON (Content type) cannot be separated by nulls, so we have to wrap its // components in parentheses "TCON" => { let mut value = frame.0.clone(); let Frame::Text(ref mut f) = value.to_mut() else { log::warn!("Discarding frame: {}, not supported in ID3v2.3", id); continue; }; let mut new_genre_string = String::new(); let genres = GenresIter::new(&f.value, true).collect::>(); for (i, genre) in genres.iter().enumerate() { match *genre { "Remix" => new_genre_string.push_str("(RX)"), "Cover" => new_genre_string.push_str("(CR)"), _ if i == genres.len() - 1 && genre.parse::().is_err() => { new_genre_string.push_str(genre); }, _ => { new_genre_string.push_str(&format!("({genre})")); }, } } f.value = new_genre_string; frame.0 = value; }, // TIPL (Involved people list) and TMCL (Musician credits list) are // both key-value pairs. ID3v2.3 does not distinguish between the two, // so we must merge them into a single IPLS frame. "TIPL" | "TMCL" => { let mut value = frame.0.clone(); let Frame::KeyValue(KeyValueFrame { ref mut key_value_pairs, encoding, .. }) = value.to_mut() else { log::warn!("Discarding frame: {}, not supported in ID3v2.3", id); continue; }; let ipls_frame; match ipls { Some(ref mut frame) => { ipls_frame = frame; }, None => { ipls = Some(TextInformationFrame::new( FrameId::Valid("IPLS".into()), encoding.to_id3v23(), String::new(), )); ipls_frame = ipls.as_mut().unwrap(); }, } for (key, value) in key_value_pairs.drain(..) { if !ipls_frame.value.is_empty() { ipls_frame.value.push('\0'); } ipls_frame.value.push_str(&format!("{}\0{}", key, value)); } continue; }, _ => {}, } let value = frame.as_bytes(is_id3v23)?; write_frame( writer, frame.id().as_str(), frame.flags(), &value, is_id3v23, )?; } if let Some(ipls) = ipls { let frame = Frame::Text(ipls); let value = frame.as_bytes(is_id3v23)?; write_frame(writer, IPLS_ID, frame.flags(), &value, is_id3v23)?; } Ok(()) } fn verify_frame(frame: &FrameRef<'_>) -> Result<()> { match (frame.id().as_str(), &**frame) { ("APIC", Frame::Picture { .. }) | ("USLT", Frame::UnsynchronizedText(_)) | ("COMM", Frame::Comment(_)) | ("TXXX", Frame::UserText(_)) | ("WXXX", Frame::UserUrl(_)) | (_, Frame::Binary(_)) | ("UFID", Frame::UniqueFileIdentifier(_)) | ("POPM", Frame::Popularimeter(_)) | ("TIPL" | "TMCL", Frame::KeyValue { .. }) | ("WFED" | "GRP1" | "MVNM" | "MVIN", Frame::Text { .. }) | ("TDEN" | "TDOR" | "TDRC" | "TDRL" | "TDTG", Frame::Timestamp(_)) | ("RVA2", Frame::RelativeVolumeAdjustment(_)) | ("PRIV", Frame::Private(_)) => Ok(()), (id, Frame::Text { .. }) if id.starts_with('T') => Ok(()), (id, Frame::Url(_)) if id.starts_with('W') => Ok(()), (id, frame_value) => Err(Id3v2Error::new(Id3v2ErrorKind::BadFrame( id.to_string(), frame_value.name(), )) .into()), } } fn write_frame( writer: &mut W, name: &str, flags: FrameFlags, value: &[u8], is_id3v23: bool, ) -> Result<()> where W: Write, { if flags.encryption.is_some() { write_encrypted(writer, name, value, flags, is_id3v23)?; return Ok(()); } let len = value.len() as u32; let is_grouping_identity = flags.grouping_identity.is_some(); write_frame_header( writer, name, if is_grouping_identity { len + 1 } else { len }, flags, is_id3v23, )?; if is_grouping_identity { // Guaranteed to be `Some` at this point. writer.write_u8(flags.grouping_identity.unwrap())?; } writer.write_all(value)?; Ok(()) } fn write_encrypted( writer: &mut W, name: &str, value: &[u8], flags: FrameFlags, is_id3v23: bool, ) -> Result<()> where W: Write, { // Guaranteed to be `Some` at this point. let method_symbol = flags.encryption.unwrap(); if method_symbol > 0x80 { return Err( Id3v2Error::new(Id3v2ErrorKind::InvalidEncryptionMethodSymbol(method_symbol)).into(), ); } if let Some(mut len) = flags.data_length_indicator { if len > 0 { write_frame_header(writer, name, (value.len() + 1) as u32, flags, is_id3v23)?; if !is_id3v23 { len = len.synch()?; } writer.write_u32::(len)?; writer.write_u8(method_symbol)?; writer.write_all(value)?; return Ok(()); } } Err(Id3v2Error::new(Id3v2ErrorKind::MissingDataLengthIndicator).into()) } fn write_frame_header( writer: &mut W, name: &str, mut len: u32, flags: FrameFlags, is_id3v23: bool, ) -> Result<()> where W: Write, { let flags = if is_id3v23 { flags.as_id3v23_bytes() } else { flags.as_id3v24_bytes() }; writer.write_all(name.as_bytes())?; if !is_id3v23 { len = len.synch()?; } writer.write_u32::(len)?; writer.write_u16::(flags)?; Ok(()) } lofty-0.21.1/src/id3/v2/write/mod.rs000064400000000000000000000177551046102023000151350ustar 00000000000000mod chunk_file; mod frame; use super::Id3v2TagFlags; use crate::config::WriteOptions; use crate::error::{LoftyError, Result}; use crate::file::FileType; use crate::id3::v2::frame::FrameRef; use crate::id3::v2::tag::Id3v2TagRef; use crate::id3::v2::util::synchsafe::SynchsafeInteger; use crate::id3::v2::Id3v2Tag; use crate::id3::{find_id3v2, FindId3v2Config}; use crate::macros::{err, try_vec}; use crate::probe::Probe; use crate::util::io::{FileLike, Length, Truncate}; use std::io::{Cursor, Read, Seek, SeekFrom, Write}; use std::ops::Not; use std::sync::OnceLock; use byteorder::{BigEndian, LittleEndian, WriteBytesExt}; // In the very rare chance someone wants to write a CRC in their extended header fn crc_32_table() -> &'static [u32; 256] { static INSTANCE: OnceLock<[u32; 256]> = OnceLock::new(); INSTANCE.get_or_init(|| { let mut crc32_table = [0; 256]; for n in 0..256 { crc32_table[n as usize] = (0..8).fold(n as u32, |acc, _| match acc & 1 { 1 => 0xEDB8_8320 ^ (acc >> 1), _ => acc >> 1, }); } crc32_table }) } #[allow(clippy::shadow_unrelated)] pub(crate) fn write_id3v2<'a, F, I>( file: &mut F, tag: &mut Id3v2TagRef<'a, I>, write_options: WriteOptions, ) -> Result<()> where F: FileLike, LoftyError: From<::Error>, LoftyError: From<::Error>, I: Iterator> + 'a, { let probe = Probe::new(file).guess_file_type()?; let file_type = probe.file_type(); let file = probe.into_inner(); // Unable to determine a format if file_type.is_none() { err!(UnknownFormat); } let file_type = file_type.unwrap(); if !Id3v2Tag::SUPPORTED_FORMATS.contains(&file_type) { err!(UnsupportedTag); } // Attempting to write a non-empty tag to a read only format // An empty tag implies the tag should be stripped. if Id3v2Tag::READ_ONLY_FORMATS.contains(&file_type) && tag.frames.peek().is_some() { err!(UnsupportedTag); } let id3v2 = create_tag(tag, write_options)?; match file_type { // Formats such as WAV and AIFF store the ID3v2 tag in an 'ID3 ' chunk rather than at the beginning of the file FileType::Wav => { tag.flags.footer = false; return chunk_file::write_to_chunk_file::(file, &id3v2, write_options); }, FileType::Aiff => { tag.flags.footer = false; return chunk_file::write_to_chunk_file::(file, &id3v2, write_options); }, _ => {}, } // find_id3v2 will seek us to the end of the tag // TODO: Search through junk find_id3v2(file, FindId3v2Config::NO_READ_TAG)?; let mut file_bytes = Vec::new(); file.read_to_end(&mut file_bytes)?; file_bytes.splice(0..0, id3v2); file.rewind()?; file.truncate(0)?; file.write_all(&file_bytes)?; Ok(()) } pub(super) fn create_tag<'a, I: Iterator> + 'a>( tag: &mut Id3v2TagRef<'a, I>, write_options: WriteOptions, ) -> Result> { let frames = &mut tag.frames; let mut peek = frames.peekable(); // We are stripping the tag if peek.peek().is_none() { return Ok(Vec::new()); } let is_id3v23 = write_options.use_id3v23; if is_id3v23 { log::debug!("Using ID3v2.3"); } let has_footer = tag.flags.footer; let needs_crc = tag.flags.crc; let has_restrictions = tag.flags.restrictions.is_some(); let (mut id3v2, extended_header_len) = create_tag_header(tag.flags, is_id3v23)?; let header_len = id3v2.get_ref().len(); // Write the items if is_id3v23 { frame::create_items_v3(&mut id3v2, &mut peek)?; } else { frame::create_items(&mut id3v2, &mut peek)?; } let mut len = id3v2.get_ref().len() - header_len; // https://mutagen-specs.readthedocs.io/en/latest/id3/id3v2.4.0-structure.html#padding: // // "[A tag] MUST NOT have any padding when a tag footer is added to the tag" let padding_len = write_options.preferred_padding.unwrap_or(0) as usize; if !has_footer { len += padding_len; } // Go back to the start and write the final size id3v2.seek(SeekFrom::Start(6))?; id3v2.write_u32::((extended_header_len + len as u32).synch()?)?; if needs_crc { // The CRC is calculated on all the data between the header and footer #[allow(unused_mut)] // Past the CRC let mut content_start_idx = 22; if has_restrictions { content_start_idx += 3; } // Skip 16 bytes // // Normal ID3v2 header (10) // Extended header (6) id3v2.seek(SeekFrom::Start(16))?; let tag_contents = &id3v2.get_ref()[content_start_idx..]; let encoded_crc = calculate_crc(tag_contents); id3v2.write_u8(5)?; id3v2.write_all(&encoded_crc)?; } if has_footer { log::trace!("Footer requested, not padding tag"); id3v2.seek(SeekFrom::Start(3))?; let mut header_without_identifier = [0; 7]; id3v2.read_exact(&mut header_without_identifier)?; id3v2.seek(SeekFrom::End(0))?; // The footer is the same as the header, but with the identifier reversed id3v2.write_all(b"3DI")?; id3v2.write_all(&header_without_identifier)?; return Ok(id3v2.into_inner()); } if padding_len == 0 { log::trace!("No padding requested, writing tag as-is"); return Ok(id3v2.into_inner()); } log::trace!("Padding tag with {} bytes", padding_len); id3v2.seek(SeekFrom::End(0))?; id3v2.write_all(&try_vec![0; padding_len])?; Ok(id3v2.into_inner()) } fn create_tag_header(flags: Id3v2TagFlags, is_id3v23: bool) -> Result<(Cursor>, u32)> { let mut header = Cursor::new(Vec::new()); header.write_all(b"ID3")?; if is_id3v23 { // Version 3, rev 0 header.write_all(&[3, 0])?; } else { // Version 4, rev 0 header.write_all(&[4, 0])?; } let extended_header = flags.crc || flags.restrictions.is_some(); let tag_flags = if is_id3v23 { flags.as_id3v23_byte() } else { flags.as_id3v24_byte() }; header.write_u8(tag_flags)?; header.write_u32::(0)?; let mut extended_header_size = 0; if extended_header { // Structure of extended header: // // Size (4) // Number of flag bytes (1) (As of ID3v2.4, this will *always* be 1) // Flags (1) // Followed by any extra data (crc or restrictions) // Start with a zeroed header header.write_all(&[0; 6])?; extended_header_size = 6_u32; let mut ext_flags = 0_u8; if flags.crc { ext_flags |= 0x20; extended_header_size += 6; header.write_all(&[0; 6])?; } if let Some(restrictions) = flags.restrictions { ext_flags |= 0x10; extended_header_size += 2; header.write_u8(1)?; header.write_u8(restrictions.as_bytes())?; } header.seek(SeekFrom::Start(10))?; // Seek back and write the actual values header.write_u32::(extended_header_size.synch()?)?; header.write_u8(1)?; header.write_u8(ext_flags)?; header.seek(SeekFrom::End(0))?; } Ok((header, extended_header_size)) } // https://github.com/rstemmer/id3edit/blob/0246f3dc1a7a80a64461eeeb7b9ee88379003eb1/encoding/crc.c#L6:6 fn calculate_crc(content: &[u8]) -> [u8; 5] { let crc: u32 = content .iter() .fold(!0, |crc, octet| { (crc >> 8) ^ crc_32_table()[(((crc & 0xFF) ^ u32::from(*octet)) & 0xFF) as usize] }) .not(); // The CRC-32 is stored as an 35 bit synchsafe integer, leaving the upper // four bits always zeroed. let mut encoded_crc = [0; 5]; let mut b; #[allow(clippy::needless_range_loop)] for i in 0..5 { b = (crc >> ((4 - i) * 7)) as u8; b &= 0x7F; encoded_crc[i] = b; } encoded_crc } #[cfg(test)] mod tests { use crate::config::WriteOptions; use crate::id3::v2::{Id3v2Tag, Id3v2TagFlags}; use crate::prelude::*; #[test] fn id3v2_write_crc32() { let mut tag = Id3v2Tag::default(); tag.set_artist(String::from("Foo artist")); let flags = Id3v2TagFlags { crc: true, ..Id3v2TagFlags::default() }; tag.set_flags(flags); let mut writer = Vec::new(); tag.dump_to(&mut writer, WriteOptions::default()).unwrap(); let crc_content = &writer[16..22]; assert_eq!(crc_content, &[5, 0x06, 0x35, 0x69, 0x7D, 0x14]); // Get rid of the size byte let crc_content = &crc_content[1..]; let mut unsynch_crc = 0; #[allow(clippy::needless_range_loop)] for i in 0..5 { let mut b = crc_content[i]; b &= 0x7F; unsynch_crc |= u32::from(b) << ((4 - i) * 7); } assert_eq!(unsynch_crc, 0x66BA_7E94); } } lofty-0.21.1/src/iff/aiff/mod.rs000064400000000000000000000012671046102023000144350ustar 00000000000000//! AIFF specific items mod properties; mod read; pub(crate) mod tag; use crate::id3::v2::tag::Id3v2Tag; use lofty_attr::LoftyFile; // Exports pub use properties::{AiffCompressionType, AiffProperties}; pub use tag::{AiffTextChunks, Comment}; /// An AIFF file #[derive(LoftyFile)] #[lofty(read_fn = "read::read_from")] #[lofty(internal_write_module_do_not_use_anywhere_else)] pub struct AiffFile { /// Any text chunks included in the file #[lofty(tag_type = "AiffText")] pub(crate) text_chunks_tag: Option, /// An ID3v2 tag #[lofty(tag_type = "Id3v2")] pub(crate) id3v2_tag: Option, /// The file's audio properties pub(crate) properties: AiffProperties, } lofty-0.21.1/src/iff/aiff/properties.rs000064400000000000000000000153341046102023000160520ustar 00000000000000use super::read::CompressionPresent; use crate::error::Result; use crate::macros::{decode_err, try_vec}; use crate::properties::FileProperties; use crate::util::text::utf8_decode; use std::borrow::Cow; use std::io::Read; use std::time::Duration; use crate::io::ReadExt; use byteorder::{BigEndian, ReadBytesExt}; /// The AIFC compression type /// /// This contains a non-exhaustive list of compression types #[allow(non_camel_case_types)] #[derive(Clone, Eq, PartialEq, Default, Debug)] pub enum AiffCompressionType { #[default] /// PCM None, /// 2-to-1 IIGS ACE (Audio Compression / Expansion) ACE2, /// 8-to-3 IIGS ACE (Audio Compression / Expansion) ACE8, /// 3-to-1 Macintosh Audio Compression / Expansion MAC3, /// 6-to-1 Macintosh Audio Compression / Expansion MAC6, /// PCM (byte swapped) sowt, /// IEEE 32-bit float fl32, /// IEEE 64-bit float fl64, /// 8-bit ITU-T G.711 A-law alaw, /// 8-bit ITU-T G.711 µ-law ulaw, /// 8-bit ITU-T G.711 µ-law (64 kb/s) ULAW, /// 8-bit ITU-T G.711 A-law (64 kb/s) ALAW, /// IEEE 32-bit float (From SoundHack & Csound) FL32, /// Catch-all for unknown compression algorithms Other { /// Identifier from the compression algorithm compression_type: [u8; 4], /// Human-readable description of the compression algorithm compression_name: String, }, } impl AiffCompressionType { /// Get the compression name for a compression type /// /// For variants other than [`AiffCompressionType::Other`], this will use statically known names. /// /// # Examples /// /// ```rust /// use lofty::iff::aiff::AiffCompressionType; /// /// let compression_type = AiffCompressionType::alaw; /// assert_eq!(compression_type.compression_name(), "ALaw 2:1"); /// ``` pub fn compression_name(&self) -> Cow<'_, str> { match self { AiffCompressionType::None => Cow::Borrowed("not compressed"), AiffCompressionType::ACE2 => Cow::Borrowed("ACE 2-to-1"), AiffCompressionType::ACE8 => Cow::Borrowed("ACE 8-to-3"), AiffCompressionType::MAC3 => Cow::Borrowed("MACE 3-to-1"), AiffCompressionType::MAC6 => Cow::Borrowed("MACE 6-to-1"), AiffCompressionType::sowt => Cow::Borrowed(""), // Has no compression name AiffCompressionType::fl32 => Cow::Borrowed("32-bit floating point"), AiffCompressionType::fl64 => Cow::Borrowed("64-bit floating point"), AiffCompressionType::alaw => Cow::Borrowed("ALaw 2:1"), AiffCompressionType::ulaw => Cow::Borrowed("µLaw 2:1"), AiffCompressionType::ULAW => Cow::Borrowed("CCITT G.711 u-law"), AiffCompressionType::ALAW => Cow::Borrowed("CCITT G.711 A-law"), AiffCompressionType::FL32 => Cow::Borrowed("Float 32"), AiffCompressionType::Other { compression_name, .. } => Cow::from(compression_name), } } } /// A AIFF file's audio properties #[derive(Debug, PartialEq, Eq, Clone, Default)] #[non_exhaustive] pub struct AiffProperties { pub(crate) duration: Duration, pub(crate) overall_bitrate: u32, pub(crate) audio_bitrate: u32, pub(crate) sample_rate: u32, pub(crate) sample_size: u16, pub(crate) channels: u16, pub(crate) compression_type: Option, } impl From for FileProperties { fn from(value: AiffProperties) -> Self { Self { duration: value.duration, overall_bitrate: Some(value.overall_bitrate), audio_bitrate: Some(value.audio_bitrate), sample_rate: Some(value.sample_rate), bit_depth: Some(value.sample_size as u8), channels: Some(value.channels as u8), channel_mask: None, } } } impl AiffProperties { /// Duration of the audio pub fn duration(&self) -> Duration { self.duration } /// Overall bitrate (kbps) pub fn overall_bitrate(&self) -> u32 { self.overall_bitrate } /// Audio bitrate (kbps) pub fn audio_bitrate(&self) -> u32 { self.audio_bitrate } /// Sample rate (Hz) pub fn sample_rate(&self) -> u32 { self.sample_rate } /// Bits per sample pub fn sample_size(&self) -> u16 { self.sample_size } /// Channel count pub fn channels(&self) -> u16 { self.channels } /// AIFC compression type, if an AIFC file was read pub fn compression_type(&self) -> Option<&AiffCompressionType> { self.compression_type.as_ref() } } pub(super) fn read_properties( comm: &mut &[u8], compression_present: CompressionPresent, stream_len: u32, file_length: u64, ) -> Result { let channels = comm.read_u16::()?; if channels == 0 { decode_err!(@BAIL Aiff, "File contains 0 channels"); } let sample_frames = comm.read_u32::()?; let sample_size = comm.read_u16::()?; let sample_rate_extended = comm.read_f80()?; let sample_rate_64 = sample_rate_extended.as_f64(); if !sample_rate_64.is_finite() || !sample_rate_64.is_sign_positive() { decode_err!(@BAIL Aiff, "Invalid sample rate"); } let sample_rate = sample_rate_64.round() as u32; let (duration, overall_bitrate, audio_bitrate) = if sample_rate > 0 && sample_frames > 0 { let length = (f64::from(sample_frames) * 1000.0) / f64::from(sample_rate); ( Duration::from_millis(length as u64), ((file_length as f64) * 8.0 / length + 0.5) as u32, (f64::from(stream_len) * 8.0 / length + 0.5) as u32, ) } else { (Duration::ZERO, 0, 0) }; let is_compressed = comm.len() >= 5 && compression_present == CompressionPresent::Yes; if !is_compressed { return Ok(AiffProperties { duration, overall_bitrate, audio_bitrate, sample_rate, sample_size, channels, compression_type: None, }); } let mut compression_type = [0u8; 4]; comm.read_exact(&mut compression_type)?; let compression = Some(match &compression_type { b"NONE" => AiffCompressionType::None, b"ACE2" => AiffCompressionType::ACE2, b"ACE8" => AiffCompressionType::ACE8, b"MAC3" => AiffCompressionType::MAC3, b"MAC6" => AiffCompressionType::MAC6, b"sowt" => AiffCompressionType::sowt, b"fl32" => AiffCompressionType::fl32, b"fl64" => AiffCompressionType::fl64, b"alaw" => AiffCompressionType::alaw, b"ulaw" => AiffCompressionType::ulaw, b"ULAW" => AiffCompressionType::ULAW, b"ALAW" => AiffCompressionType::ALAW, b"FL32" => AiffCompressionType::FL32, _ => { log::debug!( "Encountered unknown compression type: {:?}", compression_type ); // We have to read the compression name string let mut compression_name = String::new(); let compression_name_size = comm.read_u8()?; if compression_name_size > 0 { let mut compression_name_bytes = try_vec![0u8; compression_name_size as usize]; comm.read_exact(&mut compression_name_bytes)?; compression_name = utf8_decode(compression_name_bytes)?; } AiffCompressionType::Other { compression_type, compression_name, } }, }); Ok(AiffProperties { duration, overall_bitrate, audio_bitrate, sample_rate, sample_size, channels, compression_type: compression, }) } lofty-0.21.1/src/iff/aiff/read.rs000064400000000000000000000112451046102023000145660ustar 00000000000000use super::properties::AiffProperties; use super::tag::{AiffTextChunks, Comment}; use super::AiffFile; use crate::config::ParseOptions; use crate::error::Result; use crate::id3::v2::tag::Id3v2Tag; use crate::iff::chunk::Chunks; use crate::macros::{decode_err, err}; use std::io::{Read, Seek, SeekFrom}; use byteorder::{BigEndian, ReadBytesExt}; /// Whether we are dealing with an AIFC file #[derive(Copy, Clone, Eq, PartialEq, Debug)] pub(in crate::iff) enum CompressionPresent { Yes, No, } pub(in crate::iff) fn verify_aiff(data: &mut R) -> Result where R: Read + Seek, { let mut id = [0; 12]; data.read_exact(&mut id)?; if &id[..4] != b"FORM" { err!(UnknownFormat); } let compression_present; match &id[8..] { b"AIFF" => compression_present = CompressionPresent::No, b"AIFC" => compression_present = CompressionPresent::Yes, _ => err!(UnknownFormat), } log::debug!( "File verified to be AIFF, compression present: {:?}", compression_present ); Ok(compression_present) } pub(crate) fn read_from(data: &mut R, parse_options: ParseOptions) -> Result where R: Read + Seek, { // TODO: Maybe one day the `Seek` bound can be removed? // let file_size = verify_aiff(data)?; let compression_present = verify_aiff(data)?; let current_pos = data.stream_position()?; let file_len = data.seek(SeekFrom::End(0))?; data.seek(SeekFrom::Start(current_pos))?; let mut comm = None; let mut stream_len = 0; let mut text_chunks = AiffTextChunks::default(); let mut annotations = Vec::new(); let mut comments = Vec::new(); let mut id3v2_tag: Option = None; let mut chunks = Chunks::::new(file_len); while chunks.next(data).is_ok() { match &chunks.fourcc { b"ID3 " | b"id3 " if parse_options.read_tags => { let tag = chunks.id3_chunk(data, parse_options)?; if let Some(existing_tag) = id3v2_tag.as_mut() { log::warn!("Duplicate ID3v2 tag found, appending frames to previous tag"); // https://github.com/Serial-ATA/lofty-rs/issues/87 // Duplicate tags should have their frames appended to the previous for frame in tag.frames { existing_tag.insert(frame); } continue; } id3v2_tag = Some(tag); }, b"COMM" if parse_options.read_properties && comm.is_none() => { if chunks.size < 18 { decode_err!(@BAIL Aiff, "File has an invalid \"COMM\" chunk size (< 18)"); } comm = Some(chunks.content(data)?); chunks.correct_position(data)?; }, b"SSND" if parse_options.read_properties => { stream_len = chunks.size; chunks.skip(data)?; }, b"ANNO" if parse_options.read_tags => { annotations.push(chunks.read_pstring(data, None)?); }, // These four chunks are expected to appear at most once per file, // so there's no need to replace anything we already read b"COMT" if comments.is_empty() && parse_options.read_tags => { if chunks.size < 2 { continue; } let num_comments = data.read_u16::()?; for _ in 0..num_comments { let timestamp = data.read_u32::()?; let marker_id = data.read_u16::()?; let size = data.read_u16::()?; let text = chunks.read_pstring(data, Some(u32::from(size)))?; comments.push(Comment { timestamp, marker_id, text, }) } chunks.correct_position(data)?; }, b"NAME" if text_chunks.name.is_none() && parse_options.read_tags => { text_chunks.name = Some(chunks.read_pstring(data, None)?); }, b"AUTH" if text_chunks.author.is_none() && parse_options.read_tags => { text_chunks.author = Some(chunks.read_pstring(data, None)?); }, b"(c) " if text_chunks.copyright.is_none() && parse_options.read_tags => { text_chunks.copyright = Some(chunks.read_pstring(data, None)?); }, _ => chunks.skip(data)?, } } if !annotations.is_empty() { text_chunks.annotations = Some(annotations); } if !comments.is_empty() { text_chunks.comments = Some(comments); } let properties; if parse_options.read_properties { match comm { Some(comm) => { if stream_len == 0 { decode_err!(@BAIL Aiff, "File does not contain a \"SSND\" chunk"); } properties = super::properties::read_properties( &mut &*comm, compression_present, stream_len, data.stream_position()?, )?; }, None => decode_err!(@BAIL Aiff, "File does not contain a \"COMM\" chunk"), } } else { properties = AiffProperties::default(); }; Ok(AiffFile { properties, text_chunks_tag: match text_chunks { AiffTextChunks { name: None, author: None, copyright: None, annotations: None, comments: None, } => None, _ => Some(text_chunks), }, id3v2_tag, }) } lofty-0.21.1/src/iff/aiff/tag.rs000064400000000000000000000402641046102023000144310ustar 00000000000000use crate::config::WriteOptions; use crate::error::{LoftyError, Result}; use crate::iff::chunk::Chunks; use crate::macros::err; use crate::tag::{Accessor, ItemKey, ItemValue, MergeTag, SplitTag, Tag, TagExt, TagItem, TagType}; use crate::util::io::{FileLike, Length, Truncate}; use std::borrow::Cow; use std::convert::TryFrom; use std::io::{SeekFrom, Write}; use byteorder::BigEndian; use lofty_attr::tag; /// Represents an AIFF `COMT` chunk /// /// This is preferred over the `ANNO` chunk, for its additional information. #[derive(Default, Clone, Debug, PartialEq, Eq)] pub struct Comment { /// The creation time of the comment /// /// The unit is the number of seconds since January 1, 1904. pub timestamp: u32, /// An optional linking to a marker /// /// This is for storing descriptions of markers as a comment. /// An id of 0 means the comment is not linked to a marker, /// otherwise it should be the ID of a marker. pub marker_id: u16, /// The comment itself /// /// The size of the comment is restricted to [`u16::MAX`]. pub text: String, } /// ## Item storage /// /// `AIFF` has a few chunks for storing basic metadata, all of /// which can only appear once in a file, except for annotations. /// /// ## Conversions /// /// ### To `Tag` /// /// All fields can be converted losslessly to a [`TagItem`], with the exception of [`Comment`]s. /// /// * `name` -> [`ItemKey::TrackTitle`] /// * `author` -> [`ItemKey::TrackArtist`] /// * `copyright` -> [`ItemKey::CopyrightMessage`] /// * `annotations` -> [`ItemKey::Comment`] /// /// For the `comments` field, they will be treated as an annotation, having their `timestamp` and `marker_id` /// discarded. /// /// ### From `Tag` /// /// All of the [`ItemKey`]s referenced in the conversion to [`Tag`] will be checked. /// /// Every item with the key [`ItemKey::Comment`] will be stored as an annotation. #[derive(Default, Clone, Debug, PartialEq, Eq)] #[tag(description = "`AIFF` text chunks", supported_formats(Aiff))] pub struct AiffTextChunks { /// The name of the piece pub name: Option, /// The author of the piece pub author: Option, /// A copyright notice consisting of the date followed /// by the copyright owner pub copyright: Option, /// Basic comments /// /// The use of these chunks is discouraged by spec, as the `comments` /// field is more powerful. pub annotations: Option>, /// A more feature-rich comment /// /// These are preferred over `annotations`. See [`Comment`] pub comments: Option>, } impl Accessor for AiffTextChunks { fn artist(&self) -> Option> { self.author.as_deref().map(Cow::Borrowed) } fn set_artist(&mut self, value: String) { self.author = Some(value) } fn remove_artist(&mut self) { self.author = None } fn title(&self) -> Option> { self.name.as_deref().map(Cow::Borrowed) } fn set_title(&mut self, value: String) { self.name = Some(value) } fn remove_title(&mut self) { self.name = None } fn comment(&self) -> Option> { if let Some(ref anno) = self.annotations { if !anno.is_empty() { return anno.first().map(String::as_str).map(Cow::Borrowed); } } if let Some(ref comm) = self.comments { return comm.first().map(|c| c.text.as_str()).map(Cow::Borrowed); } None } fn set_comment(&mut self, value: String) { self.annotations = Some(vec![value]); } fn remove_comment(&mut self) { self.annotations = None; } } impl AiffTextChunks { /// Create a new empty `AIFFTextChunks` /// /// # Examples /// /// ```rust /// use lofty::iff::aiff::AiffTextChunks; /// use lofty::tag::TagExt; /// /// let aiff_tag = AiffTextChunks::new(); /// assert!(aiff_tag.is_empty()); /// ``` pub fn new() -> Self { Self::default() } /// Returns the copyright message pub fn copyright(&self) -> Option<&str> { self.copyright.as_deref() } /// Sets the copyright message pub fn set_copyright(&mut self, value: String) { self.copyright = Some(value) } /// Removes the copyright message pub fn remove_copyright(&mut self) { self.copyright = None } } impl TagExt for AiffTextChunks { type Err = LoftyError; type RefKey<'a> = &'a ItemKey; #[inline] fn tag_type(&self) -> TagType { TagType::AiffText } fn len(&self) -> usize { usize::from(self.name.is_some()) + usize::from(self.author.is_some()) + usize::from(self.copyright.is_some()) + self.annotations.as_ref().map_or(0, Vec::len) + self.comments.as_ref().map_or(0, Vec::len) } fn contains<'a>(&'a self, key: Self::RefKey<'a>) -> bool { match key { ItemKey::TrackTitle => self.name.is_some(), ItemKey::TrackArtist => self.author.is_some(), ItemKey::CopyrightMessage => self.copyright.is_some(), ItemKey::Comment => self.annotations.is_some() || self.comments.is_some(), _ => false, } } fn is_empty(&self) -> bool { matches!( self, AiffTextChunks { name: None, author: None, copyright: None, annotations: None, comments: None } ) } fn save_to( &self, file: &mut F, write_options: WriteOptions, ) -> std::result::Result<(), Self::Err> where F: FileLike, LoftyError: From<::Error>, LoftyError: From<::Error>, { AiffTextChunksRef { name: self.name.as_deref(), author: self.author.as_deref(), copyright: self.copyright.as_deref(), annotations: self.annotations.as_deref(), comments: self.comments.as_deref(), } .write_to(file, write_options) } fn dump_to( &self, writer: &mut W, write_options: WriteOptions, ) -> std::result::Result<(), Self::Err> { AiffTextChunksRef { name: self.name.as_deref(), author: self.author.as_deref(), copyright: self.copyright.as_deref(), annotations: self.annotations.as_deref(), comments: self.comments.as_deref(), } .dump_to(writer, write_options) } fn clear(&mut self) { *self = Self::default(); } } #[derive(Debug, Clone, Default)] pub struct SplitTagRemainder; impl SplitTag for AiffTextChunks { type Remainder = SplitTagRemainder; fn split_tag(self) -> (Self::Remainder, Tag) { (SplitTagRemainder, self.into()) } } impl MergeTag for SplitTagRemainder { type Merged = AiffTextChunks; fn merge_tag(self, tag: Tag) -> Self::Merged { tag.into() } } impl From for Tag { fn from(input: AiffTextChunks) -> Self { let mut tag = Self::new(TagType::AiffText); let push_item = |field: Option, item_key: ItemKey, tag: &mut Tag| { if let Some(text) = field { tag.items .push(TagItem::new(item_key, ItemValue::Text(text))) } }; push_item(input.name, ItemKey::TrackTitle, &mut tag); push_item(input.author, ItemKey::TrackArtist, &mut tag); push_item(input.copyright, ItemKey::CopyrightMessage, &mut tag); if let Some(annotations) = input.annotations { for anno in annotations { tag.items .push(TagItem::new(ItemKey::Comment, ItemValue::Text(anno))); } } if let Some(comments) = input.comments { for comt in comments { tag.items .push(TagItem::new(ItemKey::Comment, ItemValue::Text(comt.text))); } } tag } } impl From for AiffTextChunks { fn from(mut input: Tag) -> Self { let name = input.take_strings(&ItemKey::TrackTitle).next(); let author = input.take_strings(&ItemKey::TrackArtist).next(); let copyright = input.take_strings(&ItemKey::CopyrightMessage).next(); let annotations = input.take_strings(&ItemKey::Comment).collect::>(); Self { name, author, copyright, annotations: (!annotations.is_empty()).then_some(annotations), comments: None, } } } pub(crate) struct AiffTextChunksRef<'a, T, AI> where AI: IntoIterator, { pub name: Option<&'a str>, pub author: Option<&'a str>, pub copyright: Option<&'a str>, pub annotations: Option, pub comments: Option<&'a [Comment]>, } impl<'a, T, AI> AiffTextChunksRef<'a, T, AI> where T: AsRef, AI: IntoIterator, { pub(crate) fn write_to(self, file: &mut F, _write_options: WriteOptions) -> Result<()> where F: FileLike, LoftyError: From<::Error>, LoftyError: From<::Error>, { AiffTextChunksRef::write_to_inner(file, self) } pub(crate) fn dump_to( &mut self, writer: &mut W, _write_options: WriteOptions, ) -> Result<()> { let temp = Self::create_text_chunks(self)?; writer.write_all(&temp)?; Ok(()) } fn create_text_chunks(tag: &mut AiffTextChunksRef<'_, T, AI>) -> Result> { fn write_chunk(writer: &mut Vec, key: &str, value: Option<&str>) { if let Some(val) = value { if let Ok(len) = u32::try_from(val.len()) { writer.extend(key.as_bytes()); writer.extend(len.to_be_bytes()); writer.extend(val.as_bytes()); // AIFF only needs a terminator if the string is on an odd boundary, // unlike RIFF, which makes use of both C-strings and even boundaries if len % 2 != 0 { writer.push(0); } } } } let mut text_chunks = Vec::new(); if let Some(comments) = tag.comments.take() { if !comments.is_empty() { let comment_count = comments.len(); if let Ok(len) = u16::try_from(comment_count) { text_chunks.extend(b"COMT"); text_chunks.extend(len.to_be_bytes()); for comt in comments { text_chunks.extend(comt.timestamp.to_be_bytes()); text_chunks.extend(comt.marker_id.to_be_bytes()); let comt_len = comt.text.len(); if comt_len > u16::MAX as usize { err!(TooMuchData); } text_chunks.extend((comt_len as u16).to_be_bytes()); text_chunks.extend(comt.text.as_bytes()); if comt_len % 2 != 0 { text_chunks.push(0); } } // Get the size of the COMT chunk let comt_len = text_chunks.len() - 4; if let Ok(chunk_len) = u32::try_from(comt_len) { let mut i = 4; // Write the size back for b in chunk_len.to_be_bytes() { text_chunks.insert(i, b); i += 1; } } else { err!(TooMuchData); } if (text_chunks.len() - 4) % 2 != 0 { text_chunks.push(0); } } } } write_chunk(&mut text_chunks, "NAME", tag.name); write_chunk(&mut text_chunks, "AUTH", tag.author); write_chunk(&mut text_chunks, "(c) ", tag.copyright); if let Some(annotations) = tag.annotations.take() { for anno in annotations { write_chunk(&mut text_chunks, "ANNO", Some(anno.as_ref())); } } log::debug!( "Created AIFF text chunks, size: {} bytes", text_chunks.len() ); Ok(text_chunks) } fn write_to_inner(file: &mut F, mut tag: AiffTextChunksRef<'_, T, AI>) -> Result<()> where F: FileLike, LoftyError: From<::Error>, LoftyError: From<::Error>, { super::read::verify_aiff(file)?; let file_len = file.len()?.saturating_sub(12); let text_chunks = Self::create_text_chunks(&mut tag)?; let mut chunks_remove = Vec::new(); let mut chunks = Chunks::::new(file_len); while chunks.next(file).is_ok() { match &chunks.fourcc { b"NAME" | b"AUTH" | b"(c) " | b"ANNO" | b"COMT" => { let start = (file.stream_position()? - 8) as usize; let mut end = start + 8 + chunks.size as usize; if chunks.size % 2 != 0 { end += 1 } chunks_remove.push((start, end)) }, _ => {}, } chunks.skip(file)?; } file.rewind()?; let mut file_bytes = Vec::new(); file.read_to_end(&mut file_bytes)?; if chunks_remove.is_empty() { file.seek(SeekFrom::Start(16))?; let mut size = [0; 4]; file.read_exact(&mut size)?; let comm_end = (20 + u32::from_le_bytes(size)) as usize; file_bytes.splice(comm_end..comm_end, text_chunks); } else { chunks_remove.sort_unstable(); chunks_remove.reverse(); let first = chunks_remove.pop().unwrap(); // Infallible for (s, e) in &chunks_remove { file_bytes.drain(*s..*e); } file_bytes.splice(first.0..first.1, text_chunks); } let total_size = ((file_bytes.len() - 8) as u32).to_be_bytes(); file_bytes.splice(4..8, total_size.to_vec()); file.rewind()?; file.truncate(0)?; file.write_all(&file_bytes)?; Ok(()) } } #[cfg(test)] mod tests { use crate::config::{ParseOptions, WriteOptions}; use crate::iff::aiff::{AiffTextChunks, Comment}; use crate::prelude::*; use crate::tag::{ItemValue, Tag, TagItem, TagType}; use std::io::Cursor; #[test] fn parse_aiff_text() { let expected_tag = AiffTextChunks { name: Some(String::from("Foo title")), author: Some(String::from("Bar artist")), copyright: Some(String::from("Baz copyright")), annotations: Some(vec![ String::from("Qux annotation"), String::from("Quux annotation"), ]), comments: Some(vec![ Comment { timestamp: 1024, marker_id: 0, text: String::from("Quuz comment"), }, Comment { timestamp: 2048, marker_id: 40, text: String::from("Corge comment"), }, ]), }; let tag = crate::tag::utils::test_utils::read_path("tests/tags/assets/test.aiff_text"); let parsed_tag = super::super::read::read_from( &mut Cursor::new(tag), ParseOptions::new().read_properties(false), ) .unwrap() .text_chunks_tag .unwrap(); assert_eq!(expected_tag, parsed_tag); } #[test] fn aiff_text_re_read() { let tag = crate::tag::utils::test_utils::read_path("tests/tags/assets/test.aiff_text"); let parsed_tag = super::super::read::read_from( &mut Cursor::new(tag), ParseOptions::new().read_properties(false), ) .unwrap() .text_chunks_tag .unwrap(); // Create a fake AIFF signature let mut writer = vec![ b'F', b'O', b'R', b'M', 0, 0, 0, 0xC6, b'A', b'I', b'F', b'F', ]; parsed_tag .dump_to(&mut writer, WriteOptions::default()) .unwrap(); let temp_parsed_tag = super::super::read::read_from( &mut Cursor::new(writer), ParseOptions::new().read_properties(false), ) .unwrap() .text_chunks_tag .unwrap(); assert_eq!(parsed_tag, temp_parsed_tag); } #[test] fn aiff_text_to_tag() { let tag_bytes = crate::tag::utils::test_utils::read_path("tests/tags/assets/test.aiff_text"); let aiff_text = super::super::read::read_from( &mut Cursor::new(tag_bytes), ParseOptions::new().read_properties(false), ) .unwrap() .text_chunks_tag .unwrap(); let tag: Tag = aiff_text.into(); assert_eq!(tag.get_string(&ItemKey::TrackTitle), Some("Foo title")); assert_eq!(tag.get_string(&ItemKey::TrackArtist), Some("Bar artist")); assert_eq!( tag.get_string(&ItemKey::CopyrightMessage), Some("Baz copyright") ); let mut comments = tag.get_strings(&ItemKey::Comment); assert_eq!(comments.next(), Some("Qux annotation")); assert_eq!(comments.next(), Some("Quux annotation")); assert_eq!(comments.next(), Some("Quuz comment")); assert_eq!(comments.next(), Some("Corge comment")); assert!(comments.next().is_none()); } #[test] fn tag_to_aiff_text() { let mut tag = Tag::new(TagType::AiffText); tag.insert_text(ItemKey::TrackTitle, String::from("Foo title")); tag.insert_text(ItemKey::TrackArtist, String::from("Bar artist")); tag.insert_text(ItemKey::CopyrightMessage, String::from("Baz copyright")); tag.push_unchecked(TagItem::new( ItemKey::Comment, ItemValue::Text(String::from("Qux annotation")), )); tag.push_unchecked(TagItem::new( ItemKey::Comment, ItemValue::Text(String::from("Quux annotation")), )); let aiff_text: AiffTextChunks = tag.into(); assert_eq!(aiff_text.name, Some(String::from("Foo title"))); assert_eq!(aiff_text.author, Some(String::from("Bar artist"))); assert_eq!(aiff_text.copyright, Some(String::from("Baz copyright"))); assert_eq!( aiff_text.annotations, Some(vec![ String::from("Qux annotation"), String::from("Quux annotation") ]) ); assert!(aiff_text.comments.is_none()); } #[test] fn zero_sized_text_chunks() { let tag_bytes = crate::tag::utils::test_utils::read_path("tests/tags/assets/zero.aiff_text"); let aiff_file = super::super::read::read_from( &mut Cursor::new(tag_bytes), ParseOptions::new().read_properties(false), ) .unwrap(); let aiff_text = aiff_file.text_chunks().unwrap(); assert_eq!(aiff_text.name, Some(String::new())); assert_eq!(aiff_text.author, Some(String::new())); assert_eq!(aiff_text.annotations, Some(vec![String::new()])); assert_eq!(aiff_text.comments, None); // Comments have additional information we need, so we ignore on empty assert_eq!(aiff_text.copyright, Some(String::new())); } } lofty-0.21.1/src/iff/chunk.rs000064400000000000000000000057151046102023000140630ustar 00000000000000use crate::config::ParseOptions; use crate::error::Result; use crate::id3::v2::tag::Id3v2Tag; use crate::macros::{err, try_vec}; use crate::util::text::utf8_decode; use std::io::{Read, Seek, SeekFrom}; use std::marker::PhantomData; use byteorder::{ByteOrder, ReadBytesExt}; pub(crate) struct Chunks where B: ByteOrder, { pub fourcc: [u8; 4], pub size: u32, remaining_size: u64, _phantom: PhantomData, } impl Chunks { #[must_use] pub const fn new(file_size: u64) -> Self { Self { fourcc: [0; 4], size: 0, remaining_size: file_size, _phantom: PhantomData, } } pub fn next(&mut self, data: &mut R) -> Result<()> where R: Read, { data.read_exact(&mut self.fourcc)?; self.size = data.read_u32::()?; self.remaining_size = self.remaining_size.saturating_sub(8); Ok(()) } pub fn read_cstring(&mut self, data: &mut R) -> Result where R: Read + Seek, { let cont = self.content(data)?; self.correct_position(data)?; utf8_decode(cont) } pub fn read_pstring(&mut self, data: &mut R, size: Option) -> Result where R: Read + Seek, { let cont = if let Some(size) = size { self.read(data, u64::from(size))? } else { self.content(data)? }; if cont.len() % 2 != 0 { data.seek(SeekFrom::Current(1))?; } utf8_decode(cont) } pub fn content(&mut self, data: &mut R) -> Result> where R: Read, { self.read(data, u64::from(self.size)) } fn read(&mut self, data: &mut R, size: u64) -> Result> where R: Read, { if size > self.remaining_size { err!(SizeMismatch); } let mut content = try_vec![0; size as usize]; data.read_exact(&mut content)?; self.remaining_size = self.remaining_size.saturating_sub(size); Ok(content) } pub fn id3_chunk(&mut self, data: &mut R, parse_options: ParseOptions) -> Result where R: Read + Seek, { use crate::id3::v2::header::Id3v2Header; use crate::id3::v2::read::parse_id3v2; let content = self.content(data)?; let reader = &mut &*content; let header = Id3v2Header::parse(reader)?; let id3v2 = parse_id3v2(reader, header, parse_options)?; // Skip over the footer if id3v2.flags().footer { data.seek(SeekFrom::Current(10))?; } self.correct_position(data)?; Ok(id3v2) } pub fn skip(&mut self, data: &mut R) -> Result<()> where R: Read + Seek, { data.seek(SeekFrom::Current(i64::from(self.size)))?; self.correct_position(data)?; self.remaining_size = self.remaining_size.saturating_sub(u64::from(self.size)); Ok(()) } pub fn correct_position(&mut self, data: &mut R) -> Result<()> where R: Read + Seek, { // Chunks are expected to start on even boundaries, and are padded // with a 0 if necessary. This is NOT the null terminator of the value, // and it is NOT included in the chunk's size if self.size % 2 != 0 { data.seek(SeekFrom::Current(1))?; self.remaining_size = self.remaining_size.saturating_sub(1); } Ok(()) } } lofty-0.21.1/src/iff/mod.rs000064400000000000000000000001151046102023000135170ustar 00000000000000//! WAV/AIFF specific items pub mod aiff; pub(crate) mod chunk; pub mod wav; lofty-0.21.1/src/iff/wav/mod.rs000064400000000000000000000012221046102023000143140ustar 00000000000000//! WAV specific items mod properties; mod read; pub(crate) mod tag; use crate::id3::v2::tag::Id3v2Tag; use lofty_attr::LoftyFile; // Exports pub use crate::iff::wav::properties::{WavFormat, WavProperties}; pub use tag::RiffInfoList; /// A WAV file #[derive(LoftyFile)] #[lofty(read_fn = "read::read_from")] #[lofty(internal_write_module_do_not_use_anywhere_else)] pub struct WavFile { /// A RIFF INFO LIST #[lofty(tag_type = "RiffInfo")] pub(crate) riff_info_tag: Option, /// An ID3v2 tag #[lofty(tag_type = "Id3v2")] pub(crate) id3v2_tag: Option, /// The file's audio properties pub(crate) properties: WavProperties, } lofty-0.21.1/src/iff/wav/properties.rs000064400000000000000000000145501046102023000157410ustar 00000000000000use crate::error::Result; use crate::macros::decode_err; use crate::properties::{ChannelMask, FileProperties}; use crate::util::math::RoundedDivision; use std::time::Duration; use byteorder::{LittleEndian, ReadBytesExt}; const PCM: u16 = 0x0001; const IEEE_FLOAT: u16 = 0x0003; const EXTENSIBLE: u16 = 0xFFFE; /// A WAV file's format #[allow(missing_docs, non_camel_case_types)] #[derive(Debug, Copy, Clone, PartialEq, Eq)] pub enum WavFormat { PCM, IEEE_FLOAT, Other(u16), } impl Default for WavFormat { fn default() -> Self { Self::Other(0) } } /// A WAV file's audio properties #[derive(Debug, Copy, Clone, PartialEq, Eq, Default)] #[non_exhaustive] pub struct WavProperties { pub(crate) format: WavFormat, pub(crate) duration: Duration, pub(crate) overall_bitrate: u32, pub(crate) audio_bitrate: u32, pub(crate) sample_rate: u32, pub(crate) bit_depth: u8, pub(crate) channels: u8, pub(crate) channel_mask: Option, } impl From for FileProperties { fn from(input: WavProperties) -> Self { let WavProperties { duration, overall_bitrate, audio_bitrate, sample_rate, bit_depth, channels, channel_mask, format: _, } = input; Self { duration, overall_bitrate: Some(overall_bitrate), audio_bitrate: Some(audio_bitrate), sample_rate: Some(sample_rate), bit_depth: Some(bit_depth), channels: Some(channels), channel_mask, } } } impl WavProperties { /// Duration of the audio pub fn duration(&self) -> Duration { self.duration } /// Overall bitrate (kbps) pub fn overall_bitrate(&self) -> u32 { self.overall_bitrate } /// Audio bitrate (kbps) pub fn bitrate(&self) -> u32 { self.audio_bitrate } /// Sample rate (Hz) pub fn sample_rate(&self) -> u32 { self.sample_rate } /// Bits per sample pub fn bit_depth(&self) -> u8 { self.bit_depth } /// Channel count pub fn channels(&self) -> u8 { self.channels } /// Channel mask pub fn channel_mask(&self) -> Option { self.channel_mask } /// WAV format pub fn format(&self) -> &WavFormat { &self.format } } #[derive(Copy, Clone, Debug)] struct ExtensibleFmtChunk { valid_bits_per_sample: u16, channel_mask: ChannelMask, } #[derive(Copy, Clone, Debug)] struct FmtChunk { format_tag: u16, channels: u8, sample_rate: u32, bytes_per_second: u32, block_align: u16, bits_per_sample: u16, extensible_info: Option, } fn read_fmt_chunk(reader: &mut R, len: usize) -> Result where R: ReadBytesExt, { let format_tag = reader.read_u16::()?; let channels = reader.read_u16::()?; let sample_rate = reader.read_u32::()?; let bytes_per_second = reader.read_u32::()?; let block_align = reader.read_u16::()?; let bits_per_sample = reader.read_u16::()?; let mut fmt_chunk = FmtChunk { format_tag, channels: channels as u8, sample_rate, bytes_per_second, block_align, bits_per_sample, extensible_info: None, }; if format_tag == EXTENSIBLE { if len < 40 { decode_err!(@BAIL Wav, "Extensible format identified, invalid \"fmt \" chunk size found (< 40)"); } // cbSize (Size of extra format information) (2) let _cb_size = reader.read_u16::()?; // Valid bits per sample (2) let valid_bits_per_sample = reader.read_u16::()?; // Channel mask (4) let channel_mask = ChannelMask(reader.read_u32::()?); fmt_chunk.format_tag = reader.read_u16::()?; fmt_chunk.extensible_info = Some(ExtensibleFmtChunk { valid_bits_per_sample, channel_mask, }); } Ok(fmt_chunk) } pub(super) fn read_properties( fmt: &mut &[u8], mut total_samples: u32, stream_len: u32, file_length: u64, ) -> Result { if fmt.len() < 16 { decode_err!(@BAIL Wav, "File does not contain a valid \"fmt \" chunk"); } if stream_len == 0 { decode_err!(@BAIL Wav, "File does not contain a \"data\" chunk"); } let FmtChunk { format_tag, channels, sample_rate, bytes_per_second, block_align, bits_per_sample, extensible_info, } = read_fmt_chunk(fmt, fmt.len())?; if channels == 0 { decode_err!(@BAIL Wav, "File contains 0 channels"); } if bits_per_sample % 8 != 0 { decode_err!(@BAIL Wav, "Bits per sample is not a multiple of 8"); } let bytes_per_sample = block_align / u16::from(channels); let bit_depth; match extensible_info { Some(ExtensibleFmtChunk { valid_bits_per_sample, .. }) if valid_bits_per_sample > 0 => bit_depth = valid_bits_per_sample as u8, _ if bits_per_sample > 0 => bit_depth = bits_per_sample as u8, _ => bit_depth = (bytes_per_sample * 8) as u8, }; let channel_mask = extensible_info.map(|info| info.channel_mask); let pcm = format_tag == PCM || format_tag == IEEE_FLOAT; if !pcm && total_samples == 0 { decode_err!(@BAIL Wav, "Non-PCM format identified, no \"fact\" chunk found"); } if bits_per_sample > 0 && (total_samples == 0 || pcm) { total_samples = stream_len / (u32::from(channels) * u32::from(bits_per_sample / 8)); } let mut duration = Duration::ZERO; let mut overall_bitrate = 0; let mut audio_bitrate = 0; if bytes_per_second > 0 { audio_bitrate = (u64::from(bytes_per_second) * 8).div_round(1000) as u32; } if sample_rate > 0 && total_samples > 0 { log::debug!("Calculating duration and bitrate from total samples"); let length = (u64::from(total_samples) * 1000).div_round(u64::from(sample_rate)); duration = Duration::from_millis(length); if length > 0 { overall_bitrate = (file_length * 8).div_round(length) as u32; if audio_bitrate == 0 { log::warn!("Estimating audio bitrate from stream length"); audio_bitrate = (u64::from(stream_len) * 8).div_round(length) as u32; } } } else if stream_len > 0 && bytes_per_second > 0 { log::debug!("Calculating duration and bitrate from stream length/byte rate"); let length = (u64::from(stream_len) * 1000).div_round(u64::from(bytes_per_second)); duration = Duration::from_millis(length); if length > 0 { overall_bitrate = (file_length * 8).div_round(length) as u32; } } else { log::warn!("Unable to calculate duration and bitrate"); }; Ok(WavProperties { format: match format_tag { PCM => WavFormat::PCM, IEEE_FLOAT => WavFormat::IEEE_FLOAT, other => WavFormat::Other(other), }, duration, overall_bitrate, audio_bitrate, sample_rate, bit_depth, channels, channel_mask, }) } lofty-0.21.1/src/iff/wav/read.rs000064400000000000000000000064701046102023000144620ustar 00000000000000use super::properties::WavProperties; use super::tag::RiffInfoList; use super::WavFile; use crate::config::ParseOptions; use crate::error::Result; use crate::id3::v2::tag::Id3v2Tag; use crate::iff::chunk::Chunks; use crate::macros::{decode_err, err}; use std::io::{Read, Seek, SeekFrom}; use byteorder::{LittleEndian, ReadBytesExt}; pub(super) fn verify_wav(data: &mut T) -> Result<()> where T: Read + Seek, { let mut id = [0; 12]; data.read_exact(&mut id)?; if &id[..4] != b"RIFF" { decode_err!(@BAIL Wav, "WAV file doesn't contain a RIFF chunk"); } if &id[8..] != b"WAVE" { decode_err!(@BAIL Wav, "Found RIFF file, format is not WAVE"); } log::debug!("File verified to be WAV"); Ok(()) } pub(super) fn read_from(data: &mut R, parse_options: ParseOptions) -> Result where R: Read + Seek, { verify_wav(data)?; let current_pos = data.stream_position()?; let file_len = data.seek(SeekFrom::End(0))?; data.seek(SeekFrom::Start(current_pos))?; let mut stream_len = 0_u32; let mut total_samples = 0_u32; let mut fmt = Vec::new(); let mut riff_info = RiffInfoList::default(); let mut id3v2_tag: Option = None; let mut chunks = Chunks::::new(file_len); while chunks.next(data).is_ok() { match &chunks.fourcc { b"fmt " if parse_options.read_properties => { if fmt.is_empty() { fmt = chunks.content(data)?; } else { chunks.skip(data)?; } }, b"fact" if parse_options.read_properties => { if total_samples == 0 { total_samples = data.read_u32::()?; } else { data.seek(SeekFrom::Current(4))?; } }, b"data" if parse_options.read_properties => { if stream_len == 0 { stream_len += chunks.size } chunks.skip(data)?; }, b"LIST" => { let mut size = chunks.size; if size < 4 { decode_err!(@BAIL Wav, "Invalid LIST chunk size"); } let mut list_type = [0; 4]; data.read_exact(&mut list_type)?; size -= 4; match &list_type { b"INFO" if parse_options.read_tags => { // TODO: We already get the current position above, just keep it up to date and use it here // to avoid the seeks. let end = data.stream_position()? + u64::from(size); if end > file_len { err!(SizeMismatch); } super::tag::read::parse_riff_info(data, &mut chunks, end, &mut riff_info)?; }, _ => { data.seek(SeekFrom::Current(-4))?; chunks.skip(data)?; }, } }, b"ID3 " | b"id3 " if parse_options.read_tags => { let tag = chunks.id3_chunk(data, parse_options)?; if let Some(existing_tag) = id3v2_tag.as_mut() { log::warn!("Duplicate ID3v2 tag found, appending frames to previous tag"); // https://github.com/Serial-ATA/lofty-rs/issues/87 // Duplicate tags should have their frames appended to the previous for frame in tag.frames { existing_tag.insert(frame); } continue; } id3v2_tag = Some(tag); }, _ => chunks.skip(data)?, } } let properties = if parse_options.read_properties { let file_length = data.stream_position()?; super::properties::read_properties(&mut &*fmt, total_samples, stream_len, file_length)? } else { WavProperties::default() }; Ok(WavFile { properties, riff_info_tag: (!riff_info.items.is_empty()).then_some(riff_info), id3v2_tag, }) } lofty-0.21.1/src/iff/wav/tag/mod.rs000064400000000000000000000242341046102023000150770ustar 00000000000000pub(super) mod read; mod write; use crate::config::WriteOptions; use crate::error::{LoftyError, Result}; use crate::tag::{ try_parse_year, Accessor, ItemKey, ItemValue, MergeTag, SplitTag, Tag, TagExt, TagItem, TagType, }; use crate::util::io::{FileLike, Length, Truncate}; use std::borrow::Cow; use std::io::Write; use lofty_attr::tag; macro_rules! impl_accessor { ($($name:ident => $key:literal;)+) => { paste::paste! { $( fn $name(&self) -> Option> { self.get($key).map(Cow::Borrowed) } fn [](&mut self, value: String) { self.insert(String::from($key), value) } fn [](&mut self) { let _ = self.remove($key); } )+ } } } /// ## Conversions /// /// ### To `Tag` /// /// All items will be converted to a [`TagItem`], with all unknown keys being stored with [`ItemKey::Unknown`]. /// /// ### From `Tag` /// /// When converting a [`TagItem`], two conditions must be met: /// /// * The [`TagItem`] has a value other than [`ItemValue::Binary`](crate::ItemValue::Binary) /// * It has a key that is 4 bytes in length and within the ASCII range #[derive(Default, Debug, PartialEq, Eq, Clone)] #[tag(description = "A RIFF INFO LIST", supported_formats(Wav))] pub struct RiffInfoList { /// A collection of chunk-value pairs pub(crate) items: Vec<(String, String)>, } impl RiffInfoList { /// Create a new empty `RIFFInfoList` /// /// # Examples /// /// ```rust /// use lofty::iff::wav::RiffInfoList; /// use lofty::tag::TagExt; /// /// let riff_info_tag = RiffInfoList::new(); /// assert!(riff_info_tag.is_empty()); /// ``` pub fn new() -> Self { Self::default() } /// Get an item by key pub fn get(&self, key: &str) -> Option<&str> { self.items .iter() .find(|(k, _)| k == key) .map(|(_, v)| v.as_str()) } /// Insert an item /// /// NOTE: This will do nothing if `key` is invalid /// /// This will case-insensitively replace any item with the same key pub fn insert(&mut self, key: String, value: String) { if read::verify_key(key.as_str()) { self.items .iter() .position(|(k, _)| k.eq_ignore_ascii_case(key.as_str())) .map(|p| self.items.remove(p)); self.items.push((key, value)) } } /// Remove an item by key /// /// This will case-insensitively remove an item with the key, returning it /// if it exists. pub fn remove(&mut self, key: &str) -> Option { if let Some((_, value)) = self .items .iter() .position(|(k, _)| k.eq_ignore_ascii_case(key)) .map(|p| self.items.remove(p)) { return Some(value); } None } } impl Accessor for RiffInfoList { impl_accessor!( artist => "IART"; title => "INAM"; album => "IPRD"; genre => "IGNR"; comment => "ICMT"; ); fn track(&self) -> Option { if let Some(item) = self.get("IPRT") { return item.parse::().ok(); } None } fn set_track(&mut self, value: u32) { self.insert(String::from("IPRT"), value.to_string()); } fn remove_track(&mut self) { self.remove("IPRT"); } fn track_total(&self) -> Option { if let Some(item) = self.get("IFRM") { return item.parse::().ok(); } None } fn set_track_total(&mut self, value: u32) { self.insert(String::from("IFRM"), value.to_string()); } fn remove_track_total(&mut self) { self.remove("IFRM"); } fn year(&self) -> Option { if let Some(item) = self.get("ICRD") { return try_parse_year(item); } None } fn set_year(&mut self, value: u32) { self.insert(String::from("ICRD"), value.to_string()); } fn remove_year(&mut self) { let _ = self.remove("ICRD"); } } impl IntoIterator for RiffInfoList { type Item = (String, String); type IntoIter = std::vec::IntoIter; fn into_iter(self) -> Self::IntoIter { self.items.into_iter() } } impl<'a> IntoIterator for &'a RiffInfoList { type Item = &'a (String, String); type IntoIter = std::slice::Iter<'a, (String, String)>; fn into_iter(self) -> Self::IntoIter { self.items.iter() } } impl TagExt for RiffInfoList { type Err = LoftyError; type RefKey<'a> = &'a str; #[inline] fn tag_type(&self) -> TagType { TagType::RiffInfo } fn len(&self) -> usize { self.items.len() } fn contains<'a>(&'a self, key: Self::RefKey<'a>) -> bool { self.items .iter() .any(|(item_key, _)| item_key.eq_ignore_ascii_case(key)) } fn is_empty(&self) -> bool { self.items.is_empty() } fn save_to( &self, file: &mut F, write_options: WriteOptions, ) -> std::result::Result<(), Self::Err> where F: FileLike, LoftyError: From<::Error>, LoftyError: From<::Error>, { RIFFInfoListRef::new(self.items.iter().map(|(k, v)| (k.as_str(), v.as_str()))) .write_to(file, write_options) } fn dump_to( &self, writer: &mut W, write_options: WriteOptions, ) -> std::result::Result<(), Self::Err> { RIFFInfoListRef::new(self.items.iter().map(|(k, v)| (k.as_str(), v.as_str()))) .dump_to(writer, write_options) } fn clear(&mut self) { self.items.clear(); } } #[derive(Debug, Clone, Default)] pub struct SplitTagRemainder; impl SplitTag for RiffInfoList { type Remainder = SplitTagRemainder; fn split_tag(self) -> (Self::Remainder, Tag) { (SplitTagRemainder, self.into()) } } impl MergeTag for SplitTagRemainder { type Merged = RiffInfoList; fn merge_tag(self, tag: Tag) -> Self::Merged { tag.into() } } impl From for Tag { fn from(input: RiffInfoList) -> Self { let mut tag = Self::new(TagType::RiffInfo); for (k, v) in input.items { let item_key = ItemKey::from_key(TagType::RiffInfo, &k); tag.items.push(TagItem::new( item_key, ItemValue::Text(v.trim_matches('\0').to_string()), )); } tag } } impl From for RiffInfoList { fn from(input: Tag) -> Self { let mut riff_info = RiffInfoList::default(); for item in input.items { if let ItemValue::Text(val) | ItemValue::Locator(val) = item.item_value { match item.item_key { ItemKey::Unknown(unknown) => { if read::verify_key(&unknown) { riff_info.items.push((unknown, val)) } }, k => { if let Some(key) = k.map_key(TagType::RiffInfo, false) { riff_info.items.push((key.to_string(), val)) } }, } } } riff_info } } pub(crate) struct RIFFInfoListRef<'a, I> where I: Iterator, { pub(crate) items: I, } impl<'a, I> RIFFInfoListRef<'a, I> where I: Iterator, { pub(crate) fn new(items: I) -> RIFFInfoListRef<'a, I> { RIFFInfoListRef { items } } pub(crate) fn write_to(&mut self, file: &mut F, write_options: WriteOptions) -> Result<()> where F: FileLike, LoftyError: From<::Error>, LoftyError: From<::Error>, { write::write_riff_info(file, self, write_options) } pub(crate) fn dump_to( &mut self, writer: &mut W, _write_options: WriteOptions, ) -> Result<()> { let mut temp = Vec::new(); write::create_riff_info(&mut self.items, &mut temp)?; writer.write_all(&temp)?; Ok(()) } } pub(crate) fn tagitems_into_riff<'a>( items: impl IntoIterator, ) -> impl Iterator { items.into_iter().filter_map(|i| { let item_key = i.key().map_key(TagType::RiffInfo, true); match (item_key, i.value()) { (Some(key), ItemValue::Text(val) | ItemValue::Locator(val)) if read::verify_key(key) => { Some((key, val.as_str())) }, _ => None, } }) } #[cfg(test)] mod tests { use crate::config::WriteOptions; use crate::iff::chunk::Chunks; use crate::iff::wav::RiffInfoList; use crate::prelude::*; use crate::tag::{Tag, TagType}; use byteorder::LittleEndian; use std::io::Cursor; #[test] fn parse_riff_info() { let mut expected_tag = RiffInfoList::default(); expected_tag.insert(String::from("IART"), String::from("Bar artist")); expected_tag.insert(String::from("ICMT"), String::from("Qux comment")); expected_tag.insert(String::from("ICRD"), String::from("1984")); expected_tag.insert(String::from("INAM"), String::from("Foo title")); expected_tag.insert(String::from("IPRD"), String::from("Baz album")); expected_tag.insert(String::from("IPRT"), String::from("1")); let tag = crate::tag::utils::test_utils::read_path("tests/tags/assets/test.riff"); let mut parsed_tag = RiffInfoList::default(); super::read::parse_riff_info( &mut Cursor::new(&tag[..]), &mut Chunks::::new(tag.len() as u64), (tag.len() - 1) as u64, &mut parsed_tag, ) .unwrap(); assert_eq!(expected_tag, parsed_tag); } #[test] fn riff_info_re_read() { let tag = crate::tag::utils::test_utils::read_path("tests/tags/assets/test.riff"); let mut parsed_tag = RiffInfoList::default(); super::read::parse_riff_info( &mut Cursor::new(&tag[..]), &mut Chunks::::new(tag.len() as u64), (tag.len() - 1) as u64, &mut parsed_tag, ) .unwrap(); let mut writer = Vec::new(); parsed_tag .dump_to(&mut writer, WriteOptions::default()) .unwrap(); let mut temp_parsed_tag = RiffInfoList::default(); // Remove the LIST....INFO from the tag super::read::parse_riff_info( &mut Cursor::new(&writer[12..]), &mut Chunks::::new(tag.len() as u64), (tag.len() - 13) as u64, &mut temp_parsed_tag, ) .unwrap(); assert_eq!(parsed_tag, temp_parsed_tag); } #[test] fn riff_info_to_tag() { let tag_bytes = crate::tag::utils::test_utils::read_path("tests/tags/assets/test.riff"); let mut reader = std::io::Cursor::new(&tag_bytes[..]); let mut riff_info = RiffInfoList::default(); super::read::parse_riff_info( &mut reader, &mut Chunks::::new(tag_bytes.len() as u64), (tag_bytes.len() - 1) as u64, &mut riff_info, ) .unwrap(); let tag: Tag = riff_info.into(); crate::tag::utils::test_utils::verify_tag(&tag, true, false); } #[test] fn tag_to_riff_info() { let tag = crate::tag::utils::test_utils::create_tag(TagType::RiffInfo); let riff_info: RiffInfoList = tag.into(); assert_eq!(riff_info.get("INAM"), Some("Foo title")); assert_eq!(riff_info.get("IART"), Some("Bar artist")); assert_eq!(riff_info.get("IPRD"), Some("Baz album")); assert_eq!(riff_info.get("ICMT"), Some("Qux comment")); assert_eq!(riff_info.get("IPRT"), Some("1")); } } lofty-0.21.1/src/iff/wav/tag/read.rs000064400000000000000000000017601046102023000152320ustar 00000000000000use super::RiffInfoList; use crate::error::Result; use crate::iff::chunk::Chunks; use crate::macros::decode_err; use crate::util::text::utf8_decode_str; use std::io::{Read, Seek}; use byteorder::LittleEndian; pub(in crate::iff::wav) fn parse_riff_info( data: &mut R, chunks: &mut Chunks, end: u64, tag: &mut RiffInfoList, ) -> Result<()> where R: Read + Seek, { while data.stream_position()? != end && chunks.next(data).is_ok() { let key_str = utf8_decode_str(&chunks.fourcc) .map_err(|_| decode_err!(Wav, "Non UTF-8 item key found in RIFF INFO"))?; if !verify_key(key_str) { decode_err!(@BAIL Wav, "RIFF INFO item key contains invalid characters"); } tag.items.push(( key_str.to_owned(), chunks .read_cstring(data) .map_err(|_| decode_err!(Wav, "Failed to read RIFF INFO item value"))?, )); } Ok(()) } pub(super) fn verify_key(key: &str) -> bool { key.len() == 4 && key .chars() .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit()) } lofty-0.21.1/src/iff/wav/tag/write.rs000064400000000000000000000063501046102023000154510ustar 00000000000000use super::RIFFInfoListRef; use crate::config::WriteOptions; use crate::error::{LoftyError, Result}; use crate::iff::chunk::Chunks; use crate::iff::wav::read::verify_wav; use crate::macros::err; use crate::util::io::{FileLike, Length, Truncate}; use std::io::{Read, Seek, SeekFrom}; use byteorder::{LittleEndian, WriteBytesExt}; pub(in crate::iff::wav) fn write_riff_info<'a, F, I>( file: &mut F, tag: &mut RIFFInfoListRef<'a, I>, _write_options: WriteOptions, ) -> Result<()> where F: FileLike, LoftyError: From<::Error>, LoftyError: From<::Error>, I: Iterator, { verify_wav(file)?; let file_len = file.len()?.saturating_sub(12); let mut riff_info_bytes = Vec::new(); create_riff_info(&mut tag.items, &mut riff_info_bytes)?; let Some(info_list_size) = find_info_list(file, file_len)? else { // Simply append the info list to the end of the file and update the file size file.seek(SeekFrom::End(0))?; file.write_all(&riff_info_bytes)?; let len = (file.stream_position()? - 8) as u32; file.seek(SeekFrom::Start(4))?; file.write_u32::(len)?; return Ok(()); }; // Replace the existing tag let info_list_start = file.seek(SeekFrom::Current(-12))? as usize; let info_list_end = info_list_start + 8 + info_list_size as usize; file.rewind()?; let mut file_bytes = Vec::new(); file.read_to_end(&mut file_bytes)?; let _ = file_bytes.splice(info_list_start..info_list_end, riff_info_bytes); let total_size = (file_bytes.len() - 8) as u32; let _ = file_bytes.splice(4..8, total_size.to_le_bytes()); file.rewind()?; file.truncate(0)?; file.write_all(&file_bytes)?; Ok(()) } fn find_info_list(data: &mut R, file_size: u64) -> Result> where R: Read + Seek, { let mut info = None; let mut chunks = Chunks::::new(file_size); while chunks.next(data).is_ok() { if &chunks.fourcc == b"LIST" { let mut list_type = [0; 4]; data.read_exact(&mut list_type)?; if &list_type == b"INFO" { log::debug!("Found existing RIFF INFO list, size: {} bytes", chunks.size); info = Some(chunks.size); break; } data.seek(SeekFrom::Current(-8))?; } chunks.skip(data)?; } Ok(info) } pub(super) fn create_riff_info( items: &mut dyn Iterator, bytes: &mut Vec, ) -> Result<()> { let mut items = items.peekable(); if items.peek().is_none() { log::debug!("No items to write, removing RIFF INFO list"); return Ok(()); } bytes.extend(b"LIST"); bytes.extend(b"INFO"); for (k, v) in items { if v.is_empty() { continue; } let val_b = v.as_bytes(); // Account for null terminator let len = val_b.len() + 1; // Each value has to be null terminated and have an even length let terminator: &[u8] = if len % 2 == 0 { &[0] } else { &[0, 0] }; bytes.extend(k.as_bytes()); bytes.extend(&(len as u32).to_le_bytes()); bytes.extend(val_b); bytes.extend(terminator); } let packet_size = Vec::len(bytes) - 4; if packet_size > u32::MAX as usize { err!(TooMuchData); } log::debug!("Created RIFF INFO list, size: {} bytes", packet_size); let size = (packet_size as u32).to_le_bytes(); #[allow(clippy::needless_range_loop)] for i in 0..4 { bytes.insert(i + 4, size[i]); } Ok(()) } lofty-0.21.1/src/lib.rs000064400000000000000000000103001046102023000127370ustar 00000000000000//! [![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/Serial-ATA/lofty-rs/ci.yml?branch=main&logo=github&style=for-the-badge)](https://github.com/Serial-ATA/lofty-rs/actions/workflows/ci.yml) //! [![Downloads](https://img.shields.io/crates/d/lofty?style=for-the-badge&logo=rust)](https://crates.io/crates/lofty) //! [![Version](https://img.shields.io/crates/v/lofty?style=for-the-badge&logo=rust)](https://crates.io/crates/lofty) //! //! Parse, convert, and write metadata to audio formats. //! //! # Supported Formats #![doc = include_str!("../SUPPORTED_FORMATS.md")] //! # Examples //! //! ## Reading a generic file //! //! It isn't always convenient to [use concrete file types](#using-concrete-file-types), which is where [`TaggedFile`](file::TaggedFile) //! comes in. //! //! ### Using a path //! //! ```rust,no_run //! # fn main() -> lofty::error::Result<()> { //! use lofty::probe::Probe; //! use lofty::read_from_path; //! //! // This will guess the format from the extension //! // ("mp3" in this case), but we can guess from the content if we want to. //! let path = "test.mp3"; //! let tagged_file = read_from_path(path)?; //! //! // Let's guess the format from the content just in case. //! // This is not necessary in this case! //! let tagged_file2 = Probe::open(path)?.guess_file_type()?.read()?; //! # Ok(()) //! # } //! ``` //! //! ### Using an existing reader //! //! ```rust,no_run //! # fn main() -> lofty::error::Result<()> { //! use lofty::config::ParseOptions; //! use lofty::read_from; //! use std::fs::File; //! //! // Let's read from an open file //! let path = "test.mp3"; //! let mut file = File::open(path)?; //! //! // Here, we have to guess the file type prior to reading //! let tagged_file = read_from(&mut file)?; //! # Ok(()) //! # } //! ``` //! //! ### Accessing tags //! //! ```rust,no_run //! # fn main() -> lofty::error::Result<()> { //! use lofty::file::TaggedFileExt; //! use lofty::read_from_path; //! //! let path = "test.mp3"; //! let tagged_file = read_from_path(path)?; //! //! // Get the primary tag (ID3v2 in this case) //! let id3v2 = tagged_file.primary_tag(); //! //! // If the primary tag doesn't exist, or the tag types //! // don't matter, the first tag can be retrieved //! let unknown_first_tag = tagged_file.first_tag(); //! # Ok(()) //! # } //! ``` //! //! ## Using concrete file types //! //! ```rust //! # fn main() -> lofty::error::Result<()> { //! use lofty::config::ParseOptions; //! use lofty::file::AudioFile; //! use lofty::mpeg::MpegFile; //! use lofty::tag::TagType; //! use std::fs::File; //! //! # let path = "tests/files/assets/minimal/full_test.mp3"; //! let mut file_content = File::open(path)?; //! //! // We are expecting an MP3 file //! let mp3_file = MpegFile::read_from(&mut file_content, ParseOptions::new())?; //! //! assert_eq!(mp3_file.properties().channels(), 2); //! //! // Here we have a file with multiple tags //! assert!(mp3_file.contains_tag_type(TagType::Id3v2)); //! assert!(mp3_file.contains_tag_type(TagType::Ape)); //! # Ok(()) //! # } //! ``` //! //! # Important format-specific notes //! //! All formats have their own quirks that may produce unexpected results between conversions. //! Be sure to read the module documentation of each format to see important notes and warnings. #![cfg_attr(docsrs, feature(doc_auto_cfg))] #![doc(html_logo_url = "https://raw.githubusercontent.com/Serial-ATA/lofty-rs/main/doc/lofty.svg")] // proc macro hacks extern crate self as lofty; pub(crate) mod _this_is_internal {} pub mod config; pub mod error; pub mod file; pub(crate) mod macros; pub mod picture; pub mod probe; pub mod properties; pub mod resolve; pub mod tag; mod util; pub mod aac; pub mod ape; pub mod flac; pub mod id3; pub mod iff; pub mod mp4; pub mod mpeg; pub mod musepack; pub mod ogg; pub mod wavpack; pub use crate::probe::{read_from, read_from_path}; pub use util::text::TextEncoding; pub use lofty_attr::LoftyFile; pub use util::io; pub mod prelude { //! A prelude for commonly used items in the library. //! //! This module is intended to be wildcard imported. //! //! ```rust //! use lofty::prelude::*; //! ``` pub use crate::file::{AudioFile, TaggedFileExt}; pub use crate::tag::{Accessor, ItemKey, MergeTag, SplitTag, TagExt}; } lofty-0.21.1/src/macros.rs000064400000000000000000000052071046102023000134670ustar 00000000000000macro_rules! try_vec { ($elem:expr; $size:expr) => {{ $crate::util::alloc::fallible_vec_from_element($elem, $size)? }}; } // Shorthand for return Err(LoftyError::new(ErrorKind::Foo)) // // Usage: // - err!(Variant) -> return Err(LoftyError::new(ErrorKind::Variant)) // - err!(Variant(Message)) -> return Err(LoftyError::new(ErrorKind::Variant(Message))) macro_rules! err { ($variant:ident) => { return Err(crate::error::LoftyError::new( crate::error::ErrorKind::$variant, )) }; ($variant:ident($reason:literal)) => { return Err(crate::error::LoftyError::new( crate::error::ErrorKind::$variant($reason), )) }; } // Shorthand for FileDecodingError::new(FileType::Foo, "Message") // // Usage: // // - decode_err!(Variant, Message) // - decode_err!(Message) // // or bail: // // - decode_err!(@BAIL Variant, Message) // - decode_err!(@BAIL Message) macro_rules! decode_err { ($file_ty:ident, $reason:literal) => { Into::::into(crate::error::FileDecodingError::new( crate::file::FileType::$file_ty, $reason, )) }; ($reason:literal) => { Into::::into(crate::error::FileDecodingError::from_description( $reason, )) }; (@BAIL $($file_ty:ident,)? $reason:literal) => { return Err(decode_err!($($file_ty,)? $reason)) }; } // A macro for handling the different `ParsingMode`s // // NOTE: All fields are optional, if `STRICT` or `RELAXED` are missing, it will // fall through to `DEFAULT`. If `DEFAULT` is missing, it will fall through // to an empty block. // // Usage: // // - parse_mode_choice!( // ident_of_parsing_mode, // STRICT: some_expr, // RELAXED: some_expr, // DEFAULT: some_expr, // ) macro_rules! parse_mode_choice { ( $parse_mode:ident, $(STRICT: $strict_handler:expr,)? $(BESTATTEMPT: $best_attempt_handler:expr,)? $(RELAXED: $relaxed_handler:expr,)? DEFAULT: $default:expr ) => { match $parse_mode { $(crate::config::ParsingMode::Strict => { $strict_handler },)? $(crate::config::ParsingMode::BestAttempt => { $best_attempt_handler },)? $(crate::config::ParsingMode::Relaxed => { $relaxed_handler },)? _ => { $default } } }; ( $parse_mode:ident, $(STRICT: $strict_handler:expr,)? $(BESTATTEMPT: $best_attempt_handler:expr,)? $(RELAXED: $relaxed_handler:expr $(,)?)? ) => { match $parse_mode { $(crate::config::ParsingMode::Strict => { $strict_handler },)? $(crate::config::ParsingMode::BestAttempt => { $best_attempt_handler },)? $(crate::config::ParsingMode::Relaxed => { $relaxed_handler },)? #[allow(unreachable_patterns)] _ => {} } }; } pub(crate) use {decode_err, err, parse_mode_choice, try_vec}; lofty-0.21.1/src/mp4/atom_info.rs000064400000000000000000000165351046102023000146640ustar 00000000000000use crate::config::ParsingMode; use crate::error::{ErrorKind, LoftyError, Result}; use crate::macros::{err, try_vec}; use crate::tag::{ItemKey, TagType}; use crate::util::text::utf8_decode; use std::borrow::Cow; use std::io::{Read, Seek, SeekFrom}; use byteorder::{BigEndian, ReadBytesExt}; pub(super) const FOURCC_LEN: u64 = 4; pub(super) const IDENTIFIER_LEN: u64 = 4; pub(super) const ATOM_HEADER_LEN: u64 = FOURCC_LEN + IDENTIFIER_LEN; /// Represents an `MP4` atom identifier #[derive(Eq, PartialEq, Debug, Clone)] pub enum AtomIdent<'a> { /// A four byte identifier /// /// Many FOURCCs start with `0xA9` (©), and should be human-readable. Fourcc([u8; 4]), /// A freeform identifier /// /// # Example /// /// ```text /// ----:com.apple.iTunes:SUBTITLE /// ─┬── ────────┬─────── ───┬──── /// ╰freeform identifier ╰name /// | /// ╰mean /// ``` Freeform { /// A string using a reverse DNS naming convention mean: Cow<'a, str>, /// A string identifying the atom name: Cow<'a, str>, }, } impl<'a> AtomIdent<'a> { /// Obtains a borrowed instance pub fn as_borrowed(&'a self) -> Self { match self { Self::Fourcc(fourcc) => Self::Fourcc(*fourcc), Self::Freeform { mean, name } => Self::Freeform { mean: Cow::Borrowed(mean), name: Cow::Borrowed(name), }, } } /// Obtains an owned instance pub fn into_owned(self) -> AtomIdent<'static> { match self { Self::Fourcc(fourcc) => AtomIdent::Fourcc(fourcc), Self::Freeform { mean, name } => AtomIdent::Freeform { mean: Cow::Owned(mean.into_owned()), name: Cow::Owned(name.into_owned()), }, } } } impl<'a> TryFrom<&'a ItemKey> for AtomIdent<'a> { type Error = LoftyError; fn try_from(value: &'a ItemKey) -> std::result::Result { if let Some(mapped_key) = value.map_key(TagType::Mp4Ilst, true) { if mapped_key.starts_with("----") { let mut split = mapped_key.split(':'); split.next(); let mean = split.next(); let name = split.next(); if let (Some(mean), Some(name)) = (mean, name) { return Ok(AtomIdent::Freeform { mean: Cow::Borrowed(mean), name: Cow::Borrowed(name), }); } } else { let fourcc = mapped_key.chars().map(|c| c as u8).collect::>(); if let Ok(fourcc) = TryInto::<[u8; 4]>::try_into(fourcc) { return Ok(AtomIdent::Fourcc(fourcc)); } } } err!(TextDecode( "ItemKey does not map to a freeform or fourcc identifier" )) } } impl TryFrom for AtomIdent<'static> { type Error = LoftyError; fn try_from(value: ItemKey) -> std::result::Result { let ret: AtomIdent<'_> = (&value).try_into()?; Ok(ret.into_owned()) } } #[derive(Debug)] pub(crate) struct AtomInfo { pub(crate) start: u64, pub(crate) len: u64, pub(crate) extended: bool, pub(crate) ident: AtomIdent<'static>, } // The spec permits any characters to be used in atom identifiers. This doesn't // leave us any room for error detection. // // TagLib has decided on a character set to consider valid, so we will do the same: // fn is_valid_identifier_byte(b: u8) -> bool { (b' '..=b'~').contains(&b) || b == b'\xA9' } impl AtomInfo { pub(crate) fn read( data: &mut R, mut reader_size: u64, parse_mode: ParsingMode, ) -> Result> where R: Read + Seek, { let start = data.stream_position()?; let len_raw = u64::from(data.read_u32::()?); let mut identifier = [0; IDENTIFIER_LEN as usize]; data.read_exact(&mut identifier)?; if !identifier.iter().copied().all(is_valid_identifier_byte) { // The atom identifier contains invalid characters // // Seek to the end, since we can't recover from this data.seek(SeekFrom::End(0))?; match parse_mode { ParsingMode::Strict => { err!(BadAtom("Encountered an atom with invalid characters")); }, ParsingMode::BestAttempt | ParsingMode::Relaxed => { log::warn!("Encountered an atom with invalid characters, stopping"); return Ok(None); }, } } let (len, extended) = match len_raw { // The atom extends to the end of the file 0 => { let pos = data.stream_position()?; let end = data.seek(SeekFrom::End(0))?; data.seek(SeekFrom::Start(pos))?; (end - pos, false) }, // There's an extended length 1 => (data.read_u64::()?, true), _ => (len_raw, false), }; if len < ATOM_HEADER_LEN { // Seek to the end, since we can't recover from this data.seek(SeekFrom::End(0))?; err!(BadAtom("Found an invalid length (< 8)")); } // `len` includes itself and the identifier if (len - ATOM_HEADER_LEN) > reader_size { log::warn!("Encountered an atom with an invalid length, stopping"); if parse_mode == ParsingMode::Relaxed { // Seek to the end, as we cannot gather anything else from the file data.seek(SeekFrom::End(0))?; return Ok(None); } err!(SizeMismatch); } let atom_ident; if identifier == *b"----" { // Encountered a freeform identifier reader_size -= ATOM_HEADER_LEN; if reader_size < ATOM_HEADER_LEN { err!(BadAtom("Found an incomplete freeform identifier")); } atom_ident = parse_freeform(data, len - ATOM_HEADER_LEN, parse_mode)?; } else { atom_ident = AtomIdent::Fourcc(identifier); } Ok(Some(Self { start, len, extended, ident: atom_ident, })) } pub(crate) fn header_size(&self) -> u64 { if !self.extended { return ATOM_HEADER_LEN; } ATOM_HEADER_LEN + 8 } } fn parse_freeform( data: &mut R, atom_len: u64, parse_mode: ParsingMode, ) -> Result> where R: Read + Seek, { // ---- header + mean header + name header = 24 const MINIMUM_FREEFORM_LEN: u64 = ATOM_HEADER_LEN * 3; if atom_len < MINIMUM_FREEFORM_LEN { err!(BadAtom("Found an incomplete freeform identifier")); } let mut atom_len = atom_len; let mean = freeform_chunk(data, b"mean", &mut atom_len, parse_mode)?; let name = freeform_chunk(data, b"name", &mut atom_len, parse_mode)?; Ok(AtomIdent::Freeform { mean: mean.into(), name: name.into(), }) } fn freeform_chunk( data: &mut R, name: &[u8], reader_size: &mut u64, parse_mode: ParsingMode, ) -> Result where R: Read + Seek, { let atom = AtomInfo::read(data, *reader_size, parse_mode)?; match atom { Some(AtomInfo { ident: AtomIdent::Fourcc(ref fourcc), len, .. }) if fourcc == name => { if len < 12 { err!(BadAtom("Found an incomplete freeform identifier chunk")); } if len >= *reader_size { err!(SizeMismatch); } // Version (1) // Flags (3) data.seek(SeekFrom::Current(4))?; // Already read the size (4) + identifier (4) + version/flags (4) let mut content = try_vec![0; (len - 12) as usize]; data.read_exact(&mut content)?; *reader_size -= len; utf8_decode(content).map_err(|_| { LoftyError::new(ErrorKind::BadAtom( "Found a non UTF-8 string while reading freeform identifier", )) }) }, _ => err!(BadAtom( "Found freeform identifier \"----\" with no trailing \"mean\" or \"name\" atoms" )), } } #[cfg(test)] mod tests { use super::*; // Verify that we could create freeform AtomIdent constants #[allow(dead_code)] const FREEFORM_ATOM_IDENT: AtomIdent<'_> = AtomIdent::Freeform { mean: Cow::Borrowed("mean"), name: Cow::Borrowed("name"), }; } lofty-0.21.1/src/mp4/ilst/atom.rs000064400000000000000000000202411046102023000146110ustar 00000000000000use crate::error::Result; use crate::macros::err; use crate::mp4::AtomIdent; use crate::picture::Picture; use std::fmt::{Debug, Formatter}; // Atoms with multiple values aren't all that common, // so there's no need to create a bunch of single-element Vecs #[derive(PartialEq, Clone)] pub(super) enum AtomDataStorage { Single(AtomData), Multiple(Vec), } impl AtomDataStorage { pub(super) fn first_mut(&mut self) -> &mut AtomData { match self { AtomDataStorage::Single(val) => val, AtomDataStorage::Multiple(data) => data.first_mut().expect("not empty"), } } pub(super) fn is_pictures(&self) -> bool { match self { AtomDataStorage::Single(v) => matches!(v, AtomData::Picture(_)), AtomDataStorage::Multiple(v) => v.iter().all(|p| matches!(p, AtomData::Picture(_))), } } } impl Debug for AtomDataStorage { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match &self { AtomDataStorage::Single(v) => write!(f, "{:?}", v), AtomDataStorage::Multiple(v) => f.debug_list().entries(v.iter()).finish(), } } } impl IntoIterator for AtomDataStorage { type Item = AtomData; type IntoIter = std::vec::IntoIter; fn into_iter(self) -> Self::IntoIter { match self { AtomDataStorage::Single(s) => vec![s].into_iter(), AtomDataStorage::Multiple(v) => v.into_iter(), } } } impl<'a> IntoIterator for &'a AtomDataStorage { type Item = &'a AtomData; type IntoIter = AtomDataStorageIter<'a>; fn into_iter(self) -> Self::IntoIter { let cap = match self { AtomDataStorage::Single(_) => 0, AtomDataStorage::Multiple(v) => v.len(), }; Self::IntoIter { storage: Some(self), idx: 0, cap, } } } pub(super) struct AtomDataStorageIter<'a> { storage: Option<&'a AtomDataStorage>, idx: usize, cap: usize, } impl<'a> Iterator for AtomDataStorageIter<'a> { type Item = &'a AtomData; fn next(&mut self) -> Option { match self.storage { Some(AtomDataStorage::Single(data)) => { self.storage = None; Some(data) }, Some(AtomDataStorage::Multiple(data)) => { if self.idx == self.cap { self.storage = None; return None; } let ret = &data[self.idx]; self.idx += 1; Some(ret) }, _ => None, } } } /// Represents an `MP4` atom #[derive(PartialEq, Clone)] pub struct Atom<'a> { pub(crate) ident: AtomIdent<'a>, pub(super) data: AtomDataStorage, } impl<'a> Atom<'a> { /// Create a new [`Atom`] #[must_use] pub const fn new(ident: AtomIdent<'a>, data: AtomData) -> Self { Self { ident, data: AtomDataStorage::Single(data), } } /// Create a new [`Atom`] from a collection of [`AtomData`]s /// /// This will return `None` if `data` is empty, as empty atoms are useless. pub fn from_collection(ident: AtomIdent<'a>, mut data: Vec) -> Option { let data = match data.len() { 0 => return None, 1 => AtomDataStorage::Single(data.swap_remove(0)), _ => AtomDataStorage::Multiple(data), }; Some(Self { ident, data }) } /// Returns the atom's [`AtomIdent`] pub fn ident(&self) -> &AtomIdent<'_> { &self.ident } /// Returns the atom's [`AtomData`] pub fn data(&self) -> impl Iterator { (&self.data).into_iter() } /// Consumes the atom, returning its [`AtomData`] /// /// # Examples /// /// ```rust /// use lofty::mp4::{Atom, AtomData, AtomIdent}; /// /// let atom = Atom::new( /// AtomIdent::Fourcc(*b"\x49ART"), /// AtomData::UTF8(String::from("Foo")), /// ); /// assert_eq!(atom.into_data().count(), 1); /// ``` pub fn into_data(self) -> impl Iterator { self.data.into_iter() } /// Append a value to the atom pub fn push_data(&mut self, data: AtomData) { match self.data { AtomDataStorage::Single(ref s) => { self.data = AtomDataStorage::Multiple(vec![s.clone(), data]) }, AtomDataStorage::Multiple(ref mut m) => m.push(data), } } /// Merge the data of another atom into this one /// /// NOTE: Both atoms must have the same identifier /// /// # Errors /// /// * `self.ident()` != `other.ident()` /// /// # Examples /// /// ```rust /// use lofty::mp4::{Atom, AtomData, AtomIdent}; /// /// # fn main() -> lofty::error::Result<()> { /// // Create an artist atom /// let mut atom = Atom::new( /// AtomIdent::Fourcc(*b"\x49ART"), /// AtomData::UTF8(String::from("foo")), /// ); /// /// // Create a second and merge it into the first /// let atom2 = Atom::new( /// AtomIdent::Fourcc(*b"\x49ART"), /// AtomData::UTF8(String::from("bar")), /// ); /// atom.merge(atom2)?; /// /// // Our first atom now contains the second atom's content /// assert_eq!(atom.data().count(), 2); /// # Ok(()) } /// ``` pub fn merge(&mut self, other: Atom<'_>) -> Result<()> { if self.ident != other.ident { err!(AtomMismatch); } for data in other.data { self.push_data(data) } Ok(()) } // Used internally, has no correctness checks pub(crate) fn unknown_implicit(ident: AtomIdent<'_>, data: Vec) -> Self { Self { ident: ident.into_owned(), data: AtomDataStorage::Single(AtomData::Unknown { code: 0, data }), } } pub(crate) fn text(ident: AtomIdent<'_>, data: String) -> Self { Self { ident: ident.into_owned(), data: AtomDataStorage::Single(AtomData::UTF8(data)), } } pub(crate) fn into_owned(self) -> Atom<'static> { let Self { ident, data } = self; Atom { ident: ident.into_owned(), data, } } } impl Debug for Atom<'_> { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { f.debug_struct("Atom") .field("ident", &self.ident) .field("data", &self.data) .finish() } } /// The data of an atom /// /// NOTES: /// /// * This only covers the most common data types. /// See the list of [well-known data types](https://developer.apple.com/library/archive/documentation/QuickTime/QTFF/Metadata/Metadata.html#//apple_ref/doc/uid/TP40000939-CH1-SW34) /// for codes. /// * There are only two variants for integers, which /// will come from codes `21` and `22`. All other integer /// types will be stored as [`AtomData::Unknown`], refer /// to the link above for codes. #[derive(Debug, PartialEq, Eq, Clone)] pub enum AtomData { /// A UTF-8 encoded string UTF8(String), /// A UTF-16 encoded string UTF16(String), /// A JPEG, PNG, GIF *(Deprecated)*, or BMP image /// /// The type is read from the picture itself Picture(Picture), /// A big endian signed integer (1-4 bytes) /// /// NOTE: /// /// This will shrink the integer when writing /// /// 255 will be written as `[255]` rather than `[0, 0, 0, 255]` /// /// This behavior may be unexpected, use [`AtomData::Unknown`] if unsure SignedInteger(i32), /// A big endian unsigned integer (1-4 bytes) /// /// NOTE: See [`AtomData::SignedInteger`] UnsignedInteger(u32), /// A boolean value /// /// NOTE: This isn't an official data type, but multiple flag atoms exist, /// so this makes them easier to represent. The *real* underlying type /// is `SignedInteger`. Bool(bool), /// Unknown data /// /// Due to the number of possible types, there are many /// **specified** types that are going to fall into this /// variant. Unknown { /// The code, or type of the item code: u32, /// The binary data of the atom data: Vec, }, } /// The parental advisory rating /// /// See also: /// /// #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum AdvisoryRating { /// *Inoffensive*/*None* (0) Inoffensive, /// *Explicit* (1 or 4) /// /// In the past Apple used the value 4 for explicit content /// that has later been replaced by 1. Both values are considered /// as valid when reading but only the newer value 1 is written. Explicit, /// *Clean*/*Edited* (2) Clean, } impl AdvisoryRating { /// Returns the rating as it appears in the `rtng` atom pub fn as_u8(&self) -> u8 { match self { AdvisoryRating::Inoffensive => 0, AdvisoryRating::Explicit => 1, AdvisoryRating::Clean => 2, } } } impl TryFrom for AdvisoryRating { type Error = u8; fn try_from(input: u8) -> std::result::Result { match input { 0 => Ok(Self::Inoffensive), 1 | 4 => Ok(Self::Explicit), 2 => Ok(Self::Clean), value => Err(value), } } } lofty-0.21.1/src/mp4/ilst/constants.rs000064400000000000000000000060671046102023000156770ustar 00000000000000// https://developer.apple.com/library/archive/documentation/QuickTime/QTFF/Metadata/Metadata.html#//apple_ref/doc/uid/TP40000939-CH1-SW35 /// Reserved for use where no type needs to be indicated pub const RESERVED: u32 = 0; /// UTF-8 string without any count or NULL terminator pub const UTF8: u32 = 1; /// A big-endian UTF-16 string pub const UTF16: u32 = 2; /// Deprecated unless it is needed for special Japanese characters pub const S_JIS: u32 = 3; /// The UTF-8 variant storage of a string for sorting only pub const UTF8_SORT: u32 = 4; /// The UTF-16 variant storage of a string for sorting only pub const UTF16_SORT: u32 = 5; /// A JPEG in a JFIF wrapper pub const JPEG: u32 = 13; /// A PNG in a PNG wrapper pub const PNG: u32 = 14; /// A big-endian signed integer in 1,2,3 or 4 bytes pub const BE_SIGNED_INTEGER: u32 = 21; /// A big-endian unsigned integer in 1,2,3 or 4 bytes; size of value determines integer size pub const BE_UNSIGNED_INTEGER: u32 = 22; /// A big-endian 32-bit floating point value (IEEE754) pub const BE_FLOAT32: u32 = 23; /// A big-endian 64-bit floating point value (IEEE754) pub const BE_FLOAT64: u32 = 24; /// Windows bitmap format graphics pub const BMP: u32 = 27; /// A QuickTime metadata atom pub const QUICKTIME_METADATA: u32 = 28; /// An 8-bit signed integer pub const SIGNED_8BIT_INTEGER: u32 = 65; /// A big-endian 16-bit signed integer pub const BE_16BIT_SIGNED_INTEGER: u32 = 66; /// A big-endian 32-bit signed integer pub const BE_32BIT_SIGNED_INTEGER: u32 = 67; /// A block of data representing a two dimensional (2D) point with 32-bit big-endian floating point x and y coordinates. It has the structure: /// /// ```c /// struct { /// BEFloat32 x; /// BEFloat32 y; /// } /// ``` pub const BE_POINT_F32: u32 = 70; /// A block of data representing 2D dimensions with 32-bit big-endian floating point width and height. It has the structure: /// /// ```c /// struct { /// BEFloat32 width; /// BEFloat32 height; /// } /// ``` pub const BE_DIMENSIONS_F32: u32 = 71; /// A block of data representing a 2D rectangle with 32-bit big-endian floating point x and y coordinates and a 32-bit big-endian floating point width and height size. It has the structure: /// /// ```c /// struct { /// BEFloat32 x; /// BEFloat32 y; /// BEFloat32 width; /// BEFloat32 height; /// } /// ``` /// /// or the equivalent structure: /// /// ```c /// struct { /// PointF32 origin; /// DimensionsF32 size; /// } /// ``` pub const BE_RECT_F32: u32 = 72; /// A big-endian 64-bit signed integer pub const BE_64BIT_SIGNED_INTEGER: u32 = 74; /// An 8-bit unsigned integer pub const UNSIGNED_8BIT_INTEGER: u32 = 75; /// A big-endian 16-bit unsigned integer pub const BE_16BIT_UNSIGNED_INTEGER: u32 = 76; /// A big-endian 32-bit unsigned integer pub const BE_32BIT_UNSIGNED_INTEGER: u32 = 77; /// A big-endian 64-bit unsigned integer pub const BE_64BIT_UNSIGNED_INTEGER: u32 = 78; /// A block of data representing a 3x3 transformation matrix. It has the structure: /// /// ```c /// struct { /// BEFloat64 matrix[3][3]; /// } /// ``` pub const AFFINE_TRANSFORM_F64: u32 = 79; lofty-0.21.1/src/mp4/ilst/mod.rs000064400000000000000000001072241046102023000144370ustar 00000000000000pub(super) mod atom; pub(super) mod constants; pub(super) mod read; mod r#ref; pub(crate) mod write; use super::AtomIdent; use crate::config::{global_options, WriteOptions}; use crate::error::LoftyError; use crate::mp4::ilst::atom::AtomDataStorage; use crate::picture::{Picture, PictureType, TOMBSTONE_PICTURE}; use crate::tag::companion_tag::CompanionTag; use crate::tag::{ try_parse_year, Accessor, ItemKey, ItemValue, MergeTag, SplitTag, Tag, TagExt, TagItem, TagType, }; use crate::util::flag_item; use crate::util::io::{FileLike, Length, Truncate}; use atom::{AdvisoryRating, Atom, AtomData}; use std::borrow::Cow; use std::io::Write; use std::ops::Deref; use lofty_attr::tag; const ARTIST: AtomIdent<'_> = AtomIdent::Fourcc(*b"\xa9ART"); const TITLE: AtomIdent<'_> = AtomIdent::Fourcc(*b"\xa9nam"); const ALBUM: AtomIdent<'_> = AtomIdent::Fourcc(*b"\xa9alb"); const GENRE: AtomIdent<'_> = AtomIdent::Fourcc(*b"\xa9gen"); const COMMENT: AtomIdent<'_> = AtomIdent::Fourcc(*b"\xa9cmt"); const ADVISORY_RATING: AtomIdent<'_> = AtomIdent::Fourcc(*b"rtng"); const COVR: AtomIdent<'_> = AtomIdent::Fourcc(*b"covr"); macro_rules! impl_accessor { ($($name:ident => $const:ident;)+) => { paste::paste! { $( fn $name(&self) -> Option> { if let Some(atom) = self.get(&$const) { if let Some(AtomData::UTF8(val) | AtomData::UTF16(val)) = atom.data().next() { return Some(Cow::Borrowed(val)); } } None } fn [](&mut self, value: String) { self.replace_atom(Atom { ident: $const, data: AtomDataStorage::Single(AtomData::UTF8(value)), }) } fn [](&mut self) { let _ = self.remove(&$const); } )+ } } } /// ## Pictures /// /// Unlike other formats, ilst does not store a [`PictureType`]. All pictures will have /// [`PictureType::Other`]. /// /// ## Conversions /// /// ### To `Tag` /// /// When converting to [`Tag`], only atoms with a value of [`AtomData::UTF8`] and [`AtomData::UTF16`], /// with the exception of the `trkn` and `disk` atoms, as well as pictures, will be preserved. /// /// Do note, all pictures will be [`PictureType::Other`](crate::PictureType::Other) /// /// ### From `Tag` /// /// When converting from [`Tag`], only items with a value of [`ItemValue::Text`](crate::ItemValue::Text), as /// well as pictures, will be preserved. /// /// An attempt will be made to create the `TrackNumber/TrackTotal` (trkn) and `DiscNumber/DiscTotal` (disk) pairs. #[derive(Default, PartialEq, Debug, Clone)] #[tag(description = "An MP4 ilst atom", supported_formats(Mp4))] pub struct Ilst { pub(crate) atoms: Vec>, } impl Ilst { /// Create a new empty `Ilst` /// /// # Examples /// /// ```rust /// use lofty::mp4::Ilst; /// use lofty::tag::TagExt; /// /// let ilst_tag = Ilst::new(); /// assert!(ilst_tag.is_empty()); /// ``` pub fn new() -> Self { Self::default() } /// Get an item by its [`AtomIdent`] /// /// # Examples /// /// ```rust /// use lofty::mp4::{AtomIdent, Ilst}; /// use lofty::tag::Accessor; /// /// let mut ilst = Ilst::new(); /// ilst.set_title(String::from("Foo title")); /// /// // Get the title by its FOURCC identifier /// let title = ilst.get(&AtomIdent::Fourcc(*b"\xa9nam")); /// assert!(title.is_some()); /// ``` pub fn get(&self, ident: &AtomIdent<'_>) -> Option<&Atom<'static>> { self.atoms.iter().find(|a| &a.ident == ident) } fn get_mut(&mut self, ident: &AtomIdent<'_>) -> Option<&mut Atom<'static>> { self.atoms.iter_mut().find(|a| &a.ident == ident) } /// Inserts an [`Atom`] /// /// NOTE: Do not use this to replace atoms. This will take the value from the provided atom and /// merge it into an atom of the same type, keeping any existing value(s). To ensure an atom /// is replaced, use [`Ilst::replace_atom`]. /// /// # Examples /// /// ```rust /// use lofty::mp4::{Atom, AtomData, AtomIdent, Ilst}; /// /// const TITLE_IDENTIFIER: AtomIdent = AtomIdent::Fourcc(*b"\xa9nam"); /// /// let mut ilst = Ilst::new(); /// /// // Set the title by manually constructing an `Atom` /// let title_atom = Atom::new(TITLE_IDENTIFIER, AtomData::UTF8(String::from("Foo title"))); /// ilst.insert(title_atom); /// /// // Get the title by its FOURCC identifier /// let title = ilst.get(&TITLE_IDENTIFIER); /// assert!(title.is_some()); /// ``` #[allow(clippy::missing_panics_doc)] // Unwrap on an infallible pub fn insert(&mut self, atom: Atom<'static>) { if atom.ident == COVR && atom.data.is_pictures() { for data in atom.data { match data { AtomData::Picture(p) => self.insert_picture(p), _ => unreachable!(), } } return; } if let Some(existing) = self.get_mut(atom.ident()) { existing.merge(atom).expect( "Somehow the atom merge condition failed, despite the validation beforehand.", ); return; } self.atoms.push(atom); } /// Inserts an [`Atom`], replacing any atom with the same [`AtomIdent`] /// /// # Examples /// /// ```rust /// use lofty::mp4::{Atom, AtomData, AtomIdent, Ilst}; /// use lofty::tag::Accessor; /// /// const TITLE_IDENTIFIER: AtomIdent = AtomIdent::Fourcc(*b"\xa9nam"); /// /// let mut ilst = Ilst::new(); /// /// ilst.set_title(String::from("FooBar")); /// assert_eq!(ilst.title().as_deref(), Some("FooBar")); /// /// // Replace our old title /// ilst.replace_atom(Atom::new( /// TITLE_IDENTIFIER, /// AtomData::UTF8(String::from("BarFoo")), /// )); /// assert_eq!(ilst.title().as_deref(), Some("BarFoo")); /// ``` pub fn replace_atom(&mut self, atom: Atom<'_>) { let _ = self.remove(&atom.ident); self.atoms.push(atom.into_owned()); } /// Remove an atom by its [`AtomIdent`] /// /// # Examples /// /// ```rust /// use lofty::mp4::{Atom, AtomData, AtomIdent, Ilst}; /// use lofty::tag::Accessor; /// /// const TITLE_IDENTIFIER: AtomIdent = AtomIdent::Fourcc(*b"\xa9nam"); /// /// let mut ilst = Ilst::new(); /// ilst.set_title(String::from("Foo title")); /// /// // Get the title by its FOURCC identifier /// let title = ilst.get(&TITLE_IDENTIFIER); /// assert!(title.is_some()); /// /// // Remove the title /// let returned = ilst.remove(&TITLE_IDENTIFIER); /// assert_eq!(returned.count(), 1); /// /// let title = ilst.get(&TITLE_IDENTIFIER); /// assert!(title.is_none()); /// ``` pub fn remove(&mut self, ident: &AtomIdent<'_>) -> impl Iterator> + '_ { // TODO: drain_filter let mut split_idx = 0_usize; for read_idx in 0..self.atoms.len() { if &self.atoms[read_idx].ident == ident { self.atoms.swap(split_idx, read_idx); split_idx += 1; } } self.atoms.drain(..split_idx) } /// Retain atoms based on the predicate /// /// See [`Vec::retain`](std::vec::Vec::retain) pub fn retain(&mut self, f: F) where F: FnMut(&Atom<'_>) -> bool, { self.atoms.retain(f) } /// Returns all pictures, if there are any /// /// # Examples /// /// ```rust /// use lofty::mp4::Ilst; /// use lofty::picture::{MimeType, Picture, PictureType}; /// use lofty::tag::TagExt; /// /// let mut ilst = Ilst::new(); /// /// # let png_data = b"foo".to_vec(); /// // Insert pictures /// ilst.insert_picture(Picture::new_unchecked( /// PictureType::Other, /// Some(MimeType::Png), /// None, /// png_data, /// )); /// /// # let jpeg_data = b"bar".to_vec(); /// ilst.insert_picture(Picture::new_unchecked( /// PictureType::Other, /// Some(MimeType::Jpeg), /// None, /// jpeg_data, /// )); /// /// assert_eq!(ilst.pictures().unwrap().count(), 2); /// ``` pub fn pictures(&self) -> Option> { let covr = self.get(&COVR)?; Some(covr.data().filter_map(|d| { if let AtomData::Picture(pic) = d { Some(pic) } else { None } })) } /// Inserts a picture /// /// NOTE: If a `covr` atom exists in the tag, the picture will be appended to it. /// /// # Examples /// /// ```rust /// use lofty::mp4::Ilst; /// use lofty::picture::{MimeType, Picture, PictureType}; /// use lofty::tag::TagExt; /// /// let mut ilst = Ilst::new(); /// /// # let png_data = b"foo".to_vec(); /// // Insert a single picture /// ilst.insert_picture(Picture::new_unchecked( /// PictureType::Other, /// Some(MimeType::Png), /// None, /// png_data, /// )); /// assert_eq!(ilst.len(), 1); /// /// # let jpeg_data = b"bar".to_vec(); /// // Insert another picture /// ilst.insert_picture(Picture::new_unchecked( /// PictureType::Other, /// Some(MimeType::Jpeg), /// None, /// jpeg_data, /// )); /// /// // The existing `covr` atom is reused /// assert_eq!(ilst.len(), 1); /// assert_eq!(ilst.pictures().unwrap().count(), 2); /// ``` pub fn insert_picture(&mut self, mut picture: Picture) { // This is just for correctness, it doesn't really matter. picture.pic_type = PictureType::Other; let data = AtomData::Picture(picture); let Some(existing_covr) = self.get_mut(&COVR) else { self.atoms.push(Atom { ident: COVR, data: AtomDataStorage::Single(data), }); return; }; existing_covr.push_data(data); } /// Removes all pictures pub fn remove_pictures(&mut self) { self.atoms .retain(|a| !matches!(a.data().next(), Some(AtomData::Picture(_)))) } /// Returns the parental advisory rating according to the `rtng` atom pub fn advisory_rating(&self) -> Option { self.get(&ADVISORY_RATING) .into_iter() .flat_map(Atom::data) .filter_map(|data| match data { AtomData::SignedInteger(si) => u8::try_from(*si).ok(), AtomData::Unknown { data, .. } => data.first().copied(), _ => None, }) .find_map(|rating| AdvisoryRating::try_from(rating).ok()) } /// Sets the advisory rating pub fn set_advisory_rating(&mut self, advisory_rating: AdvisoryRating) { let byte = advisory_rating.as_u8(); self.replace_atom(Atom { ident: ADVISORY_RATING, data: AtomDataStorage::Single(AtomData::SignedInteger(i32::from(byte))), }) } // Extracts a u16 from an integer pair fn extract_number(&self, fourcc: [u8; 4], expected_size: usize) -> Option { if let Some(atom) = self.get(&AtomIdent::Fourcc(fourcc)) { match atom.data().next() { Some(AtomData::Unknown { code: 0, data }) if data.len() >= expected_size => { return Some(u16::from_be_bytes([ data[expected_size - 2], data[expected_size - 1], ])) }, _ => {}, } } None } } impl<'a> IntoIterator for &'a Ilst { type Item = &'a Atom<'static>; type IntoIter = std::slice::Iter<'a, Atom<'static>>; fn into_iter(self) -> Self::IntoIter { self.atoms.iter() } } impl IntoIterator for Ilst { type Item = Atom<'static>; type IntoIter = std::vec::IntoIter; fn into_iter(self) -> Self::IntoIter { self.atoms.into_iter() } } impl Accessor for Ilst { impl_accessor!( artist => ARTIST; title => TITLE; album => ALBUM; genre => GENRE; comment => COMMENT; ); fn track(&self) -> Option { self.extract_number(*b"trkn", 4).map(u32::from) } fn set_track(&mut self, value: u32) { let track = (value as u16).to_be_bytes(); let track_total = (self.track_total().unwrap_or(0) as u16).to_be_bytes(); let data = vec![0, 0, track[0], track[1], track_total[0], track_total[1]]; self.replace_atom(Atom::unknown_implicit(AtomIdent::Fourcc(*b"trkn"), data)); } fn remove_track(&mut self) { let _ = self.remove(&AtomIdent::Fourcc(*b"trkn")); } fn track_total(&self) -> Option { self.extract_number(*b"trkn", 6).map(u32::from) } fn set_track_total(&mut self, value: u32) { let track_total = (value as u16).to_be_bytes(); let track = (self.track().unwrap_or(0) as u16).to_be_bytes(); let data = vec![0, 0, track[0], track[1], track_total[0], track_total[1]]; self.replace_atom(Atom::unknown_implicit(AtomIdent::Fourcc(*b"trkn"), data)); } fn remove_track_total(&mut self) { let track = self.track(); let _ = self.remove(&AtomIdent::Fourcc(*b"trkn")); if let Some(track) = track { let track_bytes = (track as u16).to_be_bytes(); let data = vec![0, 0, track_bytes[0], track_bytes[1], 0, 0]; self.replace_atom(Atom::unknown_implicit(AtomIdent::Fourcc(*b"trkn"), data)); } } fn disk(&self) -> Option { self.extract_number(*b"disk", 4).map(u32::from) } fn set_disk(&mut self, value: u32) { let disk = (value as u16).to_be_bytes(); let disk_total = (self.disk_total().unwrap_or(0) as u16).to_be_bytes(); let data = vec![0, 0, disk[0], disk[1], disk_total[0], disk_total[1]]; self.replace_atom(Atom::unknown_implicit(AtomIdent::Fourcc(*b"disk"), data)); } fn remove_disk(&mut self) { let _ = self.remove(&AtomIdent::Fourcc(*b"disk")); } fn disk_total(&self) -> Option { self.extract_number(*b"disk", 6).map(u32::from) } fn set_disk_total(&mut self, value: u32) { let disk_total = (value as u16).to_be_bytes(); let disk = (self.disk().unwrap_or(0) as u16).to_be_bytes(); let data = vec![0, 0, disk[0], disk[1], disk_total[0], disk_total[1]]; self.replace_atom(Atom::unknown_implicit(AtomIdent::Fourcc(*b"disk"), data)); } fn remove_disk_total(&mut self) { let disk = self.disk(); let _ = self.remove(&AtomIdent::Fourcc(*b"disk")); if let Some(disk) = disk { let disk_bytes = (disk as u16).to_be_bytes(); let data = vec![0, 0, disk_bytes[0], disk_bytes[1], 0, 0]; self.replace_atom(Atom::unknown_implicit(AtomIdent::Fourcc(*b"disk"), data)); } } fn year(&self) -> Option { if let Some(atom) = self.get(&AtomIdent::Fourcc(*b"\xa9day")) { if let Some(AtomData::UTF8(text)) = atom.data().next() { return try_parse_year(text); } } None } fn set_year(&mut self, value: u32) { self.replace_atom(Atom::text( AtomIdent::Fourcc(*b"\xa9day"), value.to_string(), )); } fn remove_year(&mut self) { let _ = self.remove(&AtomIdent::Fourcc(*b"Year")); } } impl TagExt for Ilst { type Err = LoftyError; type RefKey<'a> = &'a AtomIdent<'a>; #[inline] fn tag_type(&self) -> TagType { TagType::Mp4Ilst } fn len(&self) -> usize { self.atoms.len() } fn contains<'a>(&'a self, key: Self::RefKey<'a>) -> bool { self.atoms.iter().any(|atom| &atom.ident == key) } fn is_empty(&self) -> bool { self.atoms.is_empty() } fn save_to( &self, file: &mut F, write_options: WriteOptions, ) -> std::result::Result<(), Self::Err> where F: FileLike, LoftyError: From<::Error>, LoftyError: From<::Error>, { self.as_ref().write_to(file, write_options) } fn dump_to( &self, writer: &mut W, write_options: WriteOptions, ) -> std::result::Result<(), Self::Err> { self.as_ref().dump_to(writer, write_options) } fn clear(&mut self) { self.atoms.clear(); } } #[derive(Debug, Clone, Default)] pub struct SplitTagRemainder(Ilst); impl From for Ilst { fn from(from: SplitTagRemainder) -> Self { from.0 } } impl Deref for SplitTagRemainder { type Target = Ilst; fn deref(&self) -> &Self::Target { &self.0 } } impl SplitTag for Ilst { type Remainder = SplitTagRemainder; fn split_tag(mut self) -> (Self::Remainder, Tag) { let mut tag = Tag::new(TagType::Mp4Ilst); self.atoms.retain_mut(|atom| { let Atom { ident, data } = atom; let value = match data.first_mut() { AtomData::UTF8(text) | AtomData::UTF16(text) => { ItemValue::Text(std::mem::take(text)) }, AtomData::Picture(picture) => { tag.pictures .push(std::mem::replace(picture, TOMBSTONE_PICTURE)); return false; // Atom consumed }, AtomData::Bool(b) => { let text = if *b { "1".to_owned() } else { "0".to_owned() }; ItemValue::Text(text) }, // We have to special case track/disc numbers since they are stored together AtomData::Unknown { code: 0, data } if Vec::len(data) >= 6 => { if let AtomIdent::Fourcc(ref fourcc) = ident { match fourcc { b"trkn" => { let current = u16::from_be_bytes([data[2], data[3]]); let total = u16::from_be_bytes([data[4], data[5]]); if current > 0 { tag.insert_text(ItemKey::TrackNumber, current.to_string()); } if total > 0 { tag.insert_text(ItemKey::TrackTotal, total.to_string()); } return false; // Atom consumed }, b"disk" => { let current = u16::from_be_bytes([data[2], data[3]]); let total = u16::from_be_bytes([data[4], data[5]]); if current > 0 { tag.insert_text(ItemKey::DiscNumber, current.to_string()); } if total > 0 { tag.insert_text(ItemKey::DiscTotal, total.to_string()); } return false; // Atom consumed }, _ => {}, } } return true; // Keep atom }, _ => { return true; // Keep atom }, }; let key = ItemKey::from_key( TagType::Mp4Ilst, &match ident { AtomIdent::Fourcc(fourcc) => { fourcc.iter().map(|b| *b as char).collect::() }, AtomIdent::Freeform { mean, name } => { format!("----:{mean}:{name}") }, }, ); tag.items.push(TagItem::new(key, value)); false // Atom consumed }); if let Some(rating) = self.advisory_rating() { tag.insert_text(ItemKey::ParentalAdvisory, rating.as_u8().to_string()); let _ = self.remove(&ADVISORY_RATING); } (SplitTagRemainder(self), tag) } } impl MergeTag for SplitTagRemainder { type Merged = Ilst; fn merge_tag(self, tag: Tag) -> Self::Merged { fn convert_to_uint(space: &mut Option, cont: &str) { if let Ok(num) = cont.parse::() { *space = Some(num); } } fn create_int_pair(tag: &mut Ilst, ident: [u8; 4], pair: (Option, Option)) { match pair { (None, None) => {}, _ => { let current = pair.0.unwrap_or(0).to_be_bytes(); let total = pair.1.unwrap_or(0).to_be_bytes(); tag.atoms.push(Atom { ident: AtomIdent::Fourcc(ident), data: AtomDataStorage::Single(AtomData::Unknown { code: 0, data: vec![0, 0, current[0], current[1], total[0], total[1], 0, 0], }), }) }, } } let Self(mut merged) = self; // Storage for integer pairs let mut tracks: (Option, Option) = (None, None); let mut discs: (Option, Option) = (None, None); for item in tag.items { let key = item.item_key; if let Ok(ident) = TryInto::>::try_into(&key) { let ItemValue::Text(text) = item.item_value else { continue; }; match key { ItemKey::TrackNumber => convert_to_uint(&mut tracks.0, text.as_str()), ItemKey::TrackTotal => convert_to_uint(&mut tracks.1, text.as_str()), ItemKey::DiscNumber => convert_to_uint(&mut discs.0, text.as_str()), ItemKey::DiscTotal => convert_to_uint(&mut discs.1, text.as_str()), ItemKey::FlagCompilation | ItemKey::FlagPodcast => { let Some(data) = flag_item(text.as_str()) else { continue; }; merged.atoms.push(Atom { ident: ident.into_owned(), data: AtomDataStorage::Single(AtomData::Bool(data)), }) }, ItemKey::ParentalAdvisory => { let Ok(rating) = text.parse::() else { log::warn!( "Parental advisory rating is not a number: {}, discarding", text ); continue; }; let Ok(parsed_rating) = AdvisoryRating::try_from(rating) else { log::warn!( "Parental advisory rating is out of range: {rating}, discarding" ); continue; }; merged.atoms.push(Atom { ident: ident.into_owned(), data: AtomDataStorage::Single(AtomData::SignedInteger(i32::from( parsed_rating.as_u8(), ))), }) }, _ => merged.atoms.push(Atom { ident: ident.into_owned(), data: AtomDataStorage::Single(AtomData::UTF8(text)), }), } } } for mut picture in tag.pictures { // Just for correctness, since we can't actually // assign a picture type in this format picture.pic_type = PictureType::Other; merged.atoms.push(Atom { ident: AtomIdent::Fourcc([b'c', b'o', b'v', b'r']), data: AtomDataStorage::Single(AtomData::Picture(picture)), }) } create_int_pair(&mut merged, *b"trkn", tracks); create_int_pair(&mut merged, *b"disk", discs); merged } } impl From for Tag { fn from(input: Ilst) -> Self { let (remainder, mut tag) = input.split_tag(); if unsafe { global_options().preserve_format_specific_items } && remainder.0.len() > 0 { tag.companion_tag = Some(CompanionTag::Ilst(remainder.0)); } tag } } impl From for Ilst { fn from(mut input: Tag) -> Self { if unsafe { global_options().preserve_format_specific_items } { if let Some(companion) = input.companion_tag.take().and_then(CompanionTag::ilst) { return SplitTagRemainder(companion).merge_tag(input); } } SplitTagRemainder::default().merge_tag(input) } } #[cfg(test)] mod tests { use crate::config::{ParseOptions, ParsingMode, WriteOptions}; use crate::mp4::ilst::atom::AtomDataStorage; use crate::mp4::ilst::TITLE; use crate::mp4::read::AtomReader; use crate::mp4::{AdvisoryRating, Atom, AtomData, AtomIdent, Ilst, Mp4File}; use crate::prelude::*; use crate::tag::utils::test_utils; use crate::tag::utils::test_utils::read_path; use crate::tag::{ItemValue, Tag, TagItem, TagType}; use crate::picture::{MimeType, Picture, PictureType}; use std::io::{Cursor, Read as _, Seek as _, Write as _}; fn read_ilst(path: &str, parse_mode: ParsingMode) -> Ilst { let tag = std::fs::read(path).unwrap(); read_ilst_raw(&tag, parse_mode) } fn read_ilst_raw(bytes: &[u8], parse_mode: ParsingMode) -> Ilst { let options = ParseOptions::new().parsing_mode(parse_mode); read_ilst_with_options(bytes, options) } fn read_ilst_strict(path: &str) -> Ilst { read_ilst(path, ParsingMode::Strict) } fn read_ilst_bestattempt(path: &str) -> Ilst { read_ilst(path, ParsingMode::BestAttempt) } fn read_ilst_with_options(bytes: &[u8], parse_options: ParseOptions) -> Ilst { let len = bytes.len(); let cursor = Cursor::new(bytes); let mut reader = AtomReader::new(cursor, parse_options.parsing_mode).unwrap(); super::read::parse_ilst(&mut reader, parse_options, len as u64).unwrap() } fn verify_atom(ilst: &Ilst, ident: [u8; 4], data: &AtomData) { let atom = ilst.get(&AtomIdent::Fourcc(ident)).unwrap(); assert_eq!(atom.data().next().unwrap(), data); } #[test] fn parse_ilst() { let mut expected_tag = Ilst::default(); // The track number is stored with a code 0, // meaning the there is no need to indicate the type, // which is `u64` in this case expected_tag.insert(Atom::new( AtomIdent::Fourcc(*b"trkn"), AtomData::Unknown { code: 0, data: vec![0, 0, 0, 1, 0, 0, 0, 0], }, )); // Same with disc numbers expected_tag.insert(Atom::new( AtomIdent::Fourcc(*b"disk"), AtomData::Unknown { code: 0, data: vec![0, 0, 0, 1, 0, 2], }, )); expected_tag.insert(Atom::new( AtomIdent::Fourcc(*b"\xa9ART"), AtomData::UTF8(String::from("Bar artist")), )); expected_tag.insert(Atom::new( AtomIdent::Fourcc(*b"\xa9alb"), AtomData::UTF8(String::from("Baz album")), )); expected_tag.insert(Atom::new( AtomIdent::Fourcc(*b"\xa9cmt"), AtomData::UTF8(String::from("Qux comment")), )); expected_tag.insert(Atom::new( AtomIdent::Fourcc(*b"\xa9day"), AtomData::UTF8(String::from("1984")), )); expected_tag.insert(Atom::new( AtomIdent::Fourcc(*b"\xa9gen"), AtomData::UTF8(String::from("Classical")), )); expected_tag.insert(Atom::new( AtomIdent::Fourcc(*b"\xa9nam"), AtomData::UTF8(String::from("Foo title")), )); let tag = crate::tag::utils::test_utils::read_path("tests/tags/assets/ilst/test.ilst"); let len = tag.len(); let cursor = Cursor::new(tag); let mut reader = AtomReader::new(cursor, ParsingMode::Strict).unwrap(); let parsed_tag = super::read::parse_ilst( &mut reader, ParseOptions::new().parsing_mode(ParsingMode::Strict), len as u64, ) .unwrap(); assert_eq!(expected_tag, parsed_tag); } #[test] fn ilst_re_read() { let parsed_tag = read_ilst_strict("tests/tags/assets/ilst/test.ilst"); let mut writer = Vec::new(); parsed_tag .dump_to(&mut writer, WriteOptions::default()) .unwrap(); let cursor = Cursor::new(&writer[8..]); let mut reader = AtomReader::new(cursor, ParsingMode::Strict).unwrap(); // Remove the ilst identifier and size let temp_parsed_tag = super::read::parse_ilst( &mut reader, ParseOptions::new().parsing_mode(ParsingMode::Strict), (writer.len() - 8) as u64, ) .unwrap(); assert_eq!(parsed_tag, temp_parsed_tag); } #[test] fn ilst_to_tag() { let tag = crate::tag::utils::test_utils::read_path("tests/tags/assets/ilst/test.ilst"); let len = tag.len(); let cursor = Cursor::new(tag); let mut reader = AtomReader::new(cursor, ParsingMode::Strict).unwrap(); let ilst = super::read::parse_ilst( &mut reader, ParseOptions::new().parsing_mode(ParsingMode::Strict), len as u64, ) .unwrap(); let tag: Tag = ilst.into(); crate::tag::utils::test_utils::verify_tag(&tag, true, true); assert_eq!(tag.get_string(&ItemKey::DiscNumber), Some("1")); assert_eq!(tag.get_string(&ItemKey::DiscTotal), Some("2")); } #[test] fn tag_to_ilst() { let mut tag = crate::tag::utils::test_utils::create_tag(TagType::Mp4Ilst); tag.insert_text(ItemKey::DiscNumber, String::from("1")); tag.insert_text(ItemKey::DiscTotal, String::from("2")); let ilst: Ilst = tag.into(); verify_atom( &ilst, *b"\xa9nam", &AtomData::UTF8(String::from("Foo title")), ); verify_atom( &ilst, *b"\xa9ART", &AtomData::UTF8(String::from("Bar artist")), ); verify_atom( &ilst, *b"\xa9alb", &AtomData::UTF8(String::from("Baz album")), ); verify_atom( &ilst, *b"\xa9cmt", &AtomData::UTF8(String::from("Qux comment")), ); verify_atom( &ilst, *b"\xa9gen", &AtomData::UTF8(String::from("Classical")), ); verify_atom( &ilst, *b"trkn", &AtomData::Unknown { code: 0, data: vec![0, 0, 0, 1, 0, 0, 0, 0], }, ); verify_atom( &ilst, *b"disk", &AtomData::Unknown { code: 0, data: vec![0, 0, 0, 1, 0, 2, 0, 0], }, ) } #[test] fn issue_34() { let ilst = read_ilst_strict("tests/tags/assets/ilst/issue_34.ilst"); verify_atom( &ilst, *b"\xa9ART", &AtomData::UTF8(String::from("Foo artist")), ); verify_atom( &ilst, *b"plID", &AtomData::Unknown { code: 21, data: 88888_u64.to_be_bytes().to_vec(), }, ) } #[test] fn advisory_rating() { let ilst = read_ilst_strict("tests/tags/assets/ilst/advisory_rating.ilst"); verify_atom( &ilst, *b"\xa9ART", &AtomData::UTF8(String::from("Foo artist")), ); assert_eq!(ilst.advisory_rating(), Some(AdvisoryRating::Explicit)); } #[test] fn trailing_padding() { const ILST_START: usize = 97; const ILST_END: usize = 131; const PADDING_SIZE: usize = 990; let file_bytes = read_path("tests/files/assets/ilst_trailing_padding.m4a"); assert!(Mp4File::read_from( &mut Cursor::new(&file_bytes), ParseOptions::new().read_properties(false) ) .is_ok()); let mut ilst; let old_free_size; { let ilst_bytes = &file_bytes[ILST_START..ILST_END]; old_free_size = u32::from_be_bytes(file_bytes[ILST_END..ILST_END + 4].try_into().unwrap()); assert_eq!(old_free_size, PADDING_SIZE as u32); let cursor = Cursor::new(ilst_bytes); let mut reader = AtomReader::new(cursor, ParsingMode::Strict).unwrap(); ilst = super::read::parse_ilst( &mut reader, ParseOptions::new().parsing_mode(ParsingMode::Strict), ilst_bytes.len() as u64, ) .unwrap(); } let mut file = tempfile::tempfile().unwrap(); file.write_all(&file_bytes).unwrap(); file.rewind().unwrap(); ilst.set_title(String::from("Exactly 21 Characters")); ilst.save_to(&mut file, WriteOptions::default()).unwrap(); // Now verify the free atom file.rewind().unwrap(); let mut file_bytes = Vec::new(); file.read_to_end(&mut file_bytes).unwrap(); // 24 (atom + data) + title string (21) let new_data_size = 24_u32 + 21; let new_ilst_end = ILST_END + new_data_size as usize; let file_atom = &file_bytes[new_ilst_end..new_ilst_end + 8]; match file_atom { [size @ .., b'f', b'r', b'e', b'e'] => assert_eq!( old_free_size - new_data_size, u32::from_be_bytes(size.try_into().unwrap()) ), _ => unreachable!(), } // Verify we can re-read the file file.rewind().unwrap(); assert!(Mp4File::read_from(&mut file, ParseOptions::new().read_properties(false)).is_ok()); } #[test] fn read_non_full_meta_atom() { let file_bytes = read_path("tests/files/assets/non_full_meta_atom.m4a"); let file = Mp4File::read_from( &mut Cursor::new(file_bytes), ParseOptions::new().read_properties(false), ) .unwrap(); assert!(file.ilst_tag.is_some()); } #[test] fn write_non_full_meta_atom() { // This is testing writing to a file with a non-full meta atom // We will *not* write a non-full meta atom let file_bytes = read_path("tests/files/assets/non_full_meta_atom.m4a"); let mut file = tempfile::tempfile().unwrap(); file.write_all(&file_bytes).unwrap(); file.rewind().unwrap(); let mut tag = Ilst::default(); tag.insert(Atom { ident: AtomIdent::Fourcc(*b"\xa9ART"), data: AtomDataStorage::Single(AtomData::UTF8(String::from("Foo artist"))), }); tag.save_to(&mut file, WriteOptions::default()).unwrap(); file.rewind().unwrap(); let mp4_file = Mp4File::read_from(&mut file, ParseOptions::new()).unwrap(); assert!(mp4_file.ilst_tag.is_some()); verify_atom( &mp4_file.ilst_tag.unwrap(), *b"\xa9ART", &AtomData::UTF8(String::from("Foo artist")), ); } #[test] fn multi_value_atom() { let ilst = read_ilst_strict("tests/tags/assets/ilst/multi_value_atom.ilst"); let artist_atom = ilst.get(&AtomIdent::Fourcc(*b"\xa9ART")).unwrap(); assert_eq!( artist_atom.data, AtomDataStorage::Multiple(vec![ AtomData::UTF8(String::from("Foo artist")), AtomData::UTF8(String::from("Bar artist")), ]) ); // Sanity single value atom verify_atom( &ilst, *b"\xa9gen", &AtomData::UTF8(String::from("Classical")), ); } #[test] fn multi_value_roundtrip() { let mut tag = Tag::new(TagType::Mp4Ilst); tag.insert_text(ItemKey::TrackArtist, "TrackArtist 1".to_owned()); tag.push(TagItem::new( ItemKey::TrackArtist, ItemValue::Text("TrackArtist 2".to_owned()), )); tag.insert_text(ItemKey::AlbumArtist, "AlbumArtist 1".to_owned()); tag.push(TagItem::new( ItemKey::AlbumArtist, ItemValue::Text("AlbumArtist 2".to_owned()), )); tag.insert_text(ItemKey::TrackTitle, "TrackTitle 1".to_owned()); tag.push(TagItem::new( ItemKey::TrackTitle, ItemValue::Text("TrackTitle 2".to_owned()), )); tag.insert_text(ItemKey::AlbumTitle, "AlbumTitle 1".to_owned()); tag.push(TagItem::new( ItemKey::AlbumTitle, ItemValue::Text("AlbumTitle 2".to_owned()), )); tag.insert_text(ItemKey::Comment, "Comment 1".to_owned()); tag.push(TagItem::new( ItemKey::Comment, ItemValue::Text("Comment 2".to_owned()), )); tag.insert_text(ItemKey::ContentGroup, "ContentGroup 1".to_owned()); tag.push(TagItem::new( ItemKey::ContentGroup, ItemValue::Text("ContentGroup 2".to_owned()), )); tag.insert_text(ItemKey::Genre, "Genre 1".to_owned()); tag.push(TagItem::new( ItemKey::Genre, ItemValue::Text("Genre 2".to_owned()), )); tag.insert_text(ItemKey::Mood, "Mood 1".to_owned()); tag.push(TagItem::new( ItemKey::Mood, ItemValue::Text("Mood 2".to_owned()), )); tag.insert_text(ItemKey::Composer, "Composer 1".to_owned()); tag.push(TagItem::new( ItemKey::Composer, ItemValue::Text("Composer 2".to_owned()), )); tag.insert_text(ItemKey::Conductor, "Conductor 1".to_owned()); tag.push(TagItem::new( ItemKey::Conductor, ItemValue::Text("Conductor 2".to_owned()), )); assert_eq!(20, tag.len()); let ilst = Ilst::from(tag.clone()); let (split_remainder, split_tag) = ilst.split_tag(); assert_eq!(0, split_remainder.len()); assert_eq!(tag.len(), split_tag.len()); assert_eq!(tag.items, split_tag.items); } #[test] fn zero_sized_ilst() { let file = Mp4File::read_from( &mut Cursor::new(test_utils::read_path("tests/files/assets/zero/zero.ilst")), ParseOptions::new().read_properties(false), ) .unwrap(); assert_eq!(file.ilst(), Some(&Ilst::default())); } #[test] fn merge_insert() { let mut ilst = Ilst::new(); // Insert two titles ilst.set_title(String::from("Foo")); ilst.insert(Atom::new(TITLE, AtomData::UTF8(String::from("Bar")))); // Title should still be the first value, but there should be two total values assert_eq!(ilst.title().as_deref(), Some("Foo")); assert_eq!(ilst.get(&TITLE).unwrap().data().count(), 2); // Meaning we only have 1 atom assert_eq!(ilst.len(), 1); } #[test] fn invalid_atom_type() { let ilst = read_ilst_strict("tests/tags/assets/ilst/invalid_atom_type.ilst"); // The tag contains 3 items, however the last one has an invalid type. We will stop at that point, but retain the // first two items. assert_eq!(ilst.len(), 2); assert_eq!(ilst.track().unwrap(), 1); assert_eq!(ilst.track_total().unwrap(), 0); assert_eq!(ilst.disk().unwrap(), 1); assert_eq!(ilst.disk_total().unwrap(), 2); } #[test] fn invalid_string_encoding() { let ilst = read_ilst_bestattempt("tests/tags/assets/ilst/invalid_string_encoding.ilst"); // The tag has an album string with some unknown encoding, but the rest of the tag // is valid. We should have all items present except the album. assert_eq!(ilst.len(), 3); assert_eq!(ilst.artist().unwrap(), "Foo artist"); assert_eq!(ilst.title().unwrap(), "Bar title"); assert_eq!(ilst.comment().unwrap(), "Baz comment"); assert!(ilst.album().is_none()); } #[test] fn flag_item_conversion() { let mut tag = Tag::new(TagType::Mp4Ilst); tag.insert_text(ItemKey::FlagCompilation, "1".to_owned()); tag.insert_text(ItemKey::FlagPodcast, "0".to_owned()); let ilst: Ilst = tag.into(); assert_eq!( ilst.get(&AtomIdent::Fourcc(*b"cpil")) .unwrap() .data() .next() .unwrap(), &AtomData::Bool(true) ); assert_eq!( ilst.get(&AtomIdent::Fourcc(*b"pcst")) .unwrap() .data() .next() .unwrap(), &AtomData::Bool(false) ); } #[test] fn special_items_roundtrip() { let mut tag = Ilst::new(); let atom = Atom::new( AtomIdent::Fourcc(*b"SMTH"), AtomData::Unknown { code: 0, data: b"Meaningless Data".to_vec(), }, ); tag.insert(atom.clone()); tag.set_artist(String::from("Foo Artist")); // Some value that we *can* represent generically let tag: Tag = tag.into(); assert_eq!(tag.len(), 1); assert_eq!(tag.artist().as_deref(), Some("Foo Artist")); let tag: Ilst = tag.into(); assert_eq!(tag.atoms.len(), 2); assert_eq!(tag.artist().as_deref(), Some("Foo Artist")); assert_eq!(tag.get(&AtomIdent::Fourcc(*b"SMTH")), Some(&atom)); let mut tag_bytes = Vec::new(); tag.dump_to(&mut tag_bytes, WriteOptions::default()) .unwrap(); tag_bytes.drain(..8); // Remove the ilst identifier and size for `read_ilst` let tag_re_read = read_ilst_raw(&tag_bytes[..], ParsingMode::Strict); assert_eq!(tag, tag_re_read); // Now write from `Tag` let tag: Tag = tag.into(); let mut tag_bytes = Vec::new(); tag.dump_to(&mut tag_bytes, WriteOptions::default()) .unwrap(); tag_bytes.drain(..8); // Remove the ilst identifier and size for `read_ilst` let generic_tag_re_read = read_ilst_raw(&tag_bytes[..], ParsingMode::Strict); assert_eq!(tag_re_read, generic_tag_re_read); } #[test] fn skip_reading_cover_art() { let p = Picture::new_unchecked( PictureType::CoverFront, Some(MimeType::Jpeg), None, std::iter::repeat(0).take(50).collect::>(), ); let mut tag = Tag::new(TagType::Mp4Ilst); tag.push_picture(p); tag.set_artist(String::from("Foo artist")); let mut writer = Vec::new(); tag.dump_to(&mut writer, WriteOptions::new()).unwrap(); // Skip `ilst` header let ilst = read_ilst_with_options(&writer[8..], ParseOptions::new().read_cover_art(false)); assert_eq!(ilst.len(), 1); // Artist, no picture assert!(ilst.artist().is_some()); } } lofty-0.21.1/src/mp4/ilst/read.rs000064400000000000000000000225051046102023000145710ustar 00000000000000use super::constants::{ BE_SIGNED_INTEGER, BE_UNSIGNED_INTEGER, BMP, JPEG, PNG, RESERVED, UTF16, UTF8, }; use super::{Atom, AtomData, AtomIdent, Ilst}; use crate::config::{ParseOptions, ParsingMode}; use crate::error::{LoftyError, Result}; use crate::id3::v1::constants::GENRES; use crate::macros::{err, try_vec}; use crate::mp4::atom_info::AtomInfo; use crate::mp4::ilst::atom::AtomDataStorage; use crate::mp4::read::{skip_unneeded, AtomReader}; use crate::picture::{MimeType, Picture, PictureType}; use crate::util::text::{utf16_decode_bytes, utf8_decode}; use std::borrow::Cow; use std::io::{Cursor, Read, Seek, SeekFrom}; pub(in crate::mp4) fn parse_ilst( reader: &mut AtomReader, parse_options: ParseOptions, len: u64, ) -> Result where R: Read + Seek, { let parsing_mode = parse_options.parsing_mode; let mut contents = try_vec![0; len as usize]; reader.read_exact(&mut contents)?; let mut cursor = Cursor::new(contents); let mut ilst_reader = AtomReader::new(&mut cursor, parsing_mode)?; let mut tag = Ilst::default(); while let Ok(Some(atom)) = ilst_reader.next() { if let AtomIdent::Fourcc(ref fourcc) = atom.ident { match fourcc { b"free" | b"skip" => { skip_unneeded(&mut ilst_reader, atom.extended, atom.len)?; continue; }, b"covr" => { if parse_options.read_cover_art { handle_covr(&mut ilst_reader, parsing_mode, &mut tag, &atom)?; } else { skip_unneeded(&mut ilst_reader, atom.extended, atom.len)?; } continue; }, // Upgrade this to a \xa9gen atom b"gnre" => { log::warn!("Encountered outdated 'gnre' atom, attempting to upgrade to '©gen'"); if let Some(atom_data) = parse_data_inner(&mut ilst_reader, parsing_mode, &atom)? { let mut data = Vec::new(); for (_, content) in atom_data { if content.len() >= 2 { let index = content[1] as usize; if index > 0 && index <= GENRES.len() { data.push(AtomData::UTF8(String::from(GENRES[index - 1]))); } } } if !data.is_empty() { let storage = match data.len() { 1 => AtomDataStorage::Single(data.remove(0)), _ => AtomDataStorage::Multiple(data), }; tag.atoms.push(Atom { ident: AtomIdent::Fourcc(*b"\xa9gen"), data: storage, }) } } continue; }, // Special case the "Album ID", as it has the code "BE signed integer" (21), but // must be interpreted as a "BE 64-bit Signed Integer" (74) b"plID" => { if let Some(atom_data) = parse_data_inner(&mut ilst_reader, parsing_mode, &atom)? { let mut data = Vec::new(); for (code, content) in atom_data { if content.len() == 8 { data.push(AtomData::Unknown { code, data: content, }) } } if !data.is_empty() { let storage = match data.len() { 1 => AtomDataStorage::Single(data.remove(0)), _ => AtomDataStorage::Multiple(data), }; tag.atoms.push(Atom { ident: AtomIdent::Fourcc(*b"plID"), data: storage, }) } } continue; }, b"cpil" | b"hdvd" | b"pcst" | b"pgap" | b"shwm" => { if let Some(atom_data) = parse_data_inner(&mut ilst_reader, parsing_mode, &atom)? { if let Some((_, content)) = atom_data.first() { let data = match content[..] { [0, ..] => AtomData::Bool(false), _ => AtomData::Bool(true), }; tag.atoms.push(Atom { ident: AtomIdent::Fourcc(*fourcc), data: AtomDataStorage::Single(data), }) } } continue; }, _ => {}, } } parse_data(&mut ilst_reader, parsing_mode, &mut tag, atom)?; } Ok(tag) } fn parse_data( reader: &mut AtomReader, parsing_mode: ParsingMode, tag: &mut Ilst, atom_info: AtomInfo, ) -> Result<()> where R: Read + Seek, { let handle_error = |err: LoftyError, parsing_mode: ParsingMode| -> Result<()> { match parsing_mode { ParsingMode::Strict => Err(err), ParsingMode::BestAttempt | ParsingMode::Relaxed => { log::warn!("Skipping atom with invalid content: {}", err); Ok(()) }, } }; if let Some(mut atom_data) = parse_data_inner(reader, parsing_mode, &atom_info)? { // Most atoms we encounter are only going to have 1 value, so store them as such if atom_data.len() == 1 { let (flags, content) = atom_data.remove(0); let data = match interpret_atom_content(flags, content) { Ok(data) => data, Err(err) => return handle_error(err, parsing_mode), }; tag.atoms.push(Atom { ident: atom_info.ident, data: AtomDataStorage::Single(data), }); return Ok(()); } let mut data = Vec::new(); for (flags, content) in atom_data { let value = match interpret_atom_content(flags, content) { Ok(data) => data, Err(err) => return handle_error(err, parsing_mode), }; data.push(value); } tag.atoms.push(Atom { ident: atom_info.ident, data: AtomDataStorage::Multiple(data), }); } Ok(()) } const DATA_ATOM_IDENT: AtomIdent<'static> = AtomIdent::Fourcc(*b"data"); fn parse_data_inner( reader: &mut AtomReader, parsing_mode: ParsingMode, atom_info: &AtomInfo, ) -> Result)>>> where R: Read + Seek, { // An atom can contain multiple data atoms let mut ret = Vec::new(); let atom_end = atom_info.start + atom_info.len; let position = reader.stream_position()?; assert!( atom_end >= position, "uncaught size mismatch, reader position: {position} (expected <= {atom_end})", ); let to_read = atom_end - position; let mut pos = 0; while pos < to_read { let Some(next_atom) = reader.next()? else { break; }; if next_atom.len < 16 { log::warn!( "Expected data atom to be at least 16 bytes, got {}. Stopping", next_atom.len ); if parsing_mode == ParsingMode::Strict { err!(BadAtom("Data atom is too small")) } break; } // We don't care about the version let _version = reader.read_u8()?; let mut flags = [0; 3]; reader.read_exact(&mut flags)?; let flags = u32::from_be_bytes([0, flags[0], flags[1], flags[2]]); // We don't care about the locale reader.seek(SeekFrom::Current(4))?; match next_atom.ident { DATA_ATOM_IDENT => { let content_len = (next_atom.len - 16) as usize; if content_len > 0 { let mut content = try_vec![0; content_len]; reader.read_exact(&mut content)?; ret.push((flags, content)); } else { log::warn!("Skipping empty \"data\" atom"); } }, _ => match parsing_mode { ParsingMode::Strict => { err!(BadAtom("Expected atom \"data\" to follow name")) }, ParsingMode::BestAttempt | ParsingMode::Relaxed => { log::warn!( "Skipping unexpected atom {actual_ident:?}, expected {expected_ident:?}", actual_ident = next_atom.ident, expected_ident = DATA_ATOM_IDENT ) }, }, } pos += next_atom.len; } let ret = if ret.is_empty() { None } else { Some(ret) }; Ok(ret) } fn parse_uint(bytes: &[u8]) -> Result { Ok(match bytes.len() { 1 => u32::from(bytes[0]), 2 => u32::from(u16::from_be_bytes([bytes[0], bytes[1]])), 3 => u32::from_be_bytes([0, bytes[0], bytes[1], bytes[2]]), 4 => u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]), _ => err!(BadAtom( "Unexpected atom size for type \"BE unsigned integer\"" )), }) } fn parse_int(bytes: &[u8]) -> Result { Ok(match bytes.len() { 1 => i32::from(bytes[0]), 2 => i32::from(i16::from_be_bytes([bytes[0], bytes[1]])), 3 => i32::from_be_bytes([0, bytes[0], bytes[1], bytes[2]]), 4 => i32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]), _ => err!(BadAtom( "Unexpected atom size for type \"BE signed integer\"" )), }) } fn handle_covr( reader: &mut AtomReader, parsing_mode: ParsingMode, tag: &mut Ilst, atom_info: &AtomInfo, ) -> Result<()> where R: Read + Seek, { if let Some(atom_data) = parse_data_inner(reader, parsing_mode, atom_info)? { let mut data = Vec::new(); let len = atom_data.len(); for (flags, value) in atom_data { let mime_type = match flags { // Type 0 is implicit RESERVED => None, // GIF is deprecated 12 => Some(MimeType::Gif), JPEG => Some(MimeType::Jpeg), PNG => Some(MimeType::Png), BMP => Some(MimeType::Bmp), _ => err!(BadAtom("\"covr\" atom has an unknown type")), }; let picture_data = AtomData::Picture(Picture { pic_type: PictureType::Other, mime_type, description: None, data: Cow::from(value), }); if len == 1 { tag.atoms.push(Atom { ident: AtomIdent::Fourcc(*b"covr"), data: AtomDataStorage::Single(picture_data), }); return Ok(()); } data.push(picture_data); } tag.atoms.push(Atom { ident: AtomIdent::Fourcc(*b"covr"), data: AtomDataStorage::Multiple(data), }); } Ok(()) } fn interpret_atom_content(flags: u32, content: Vec) -> Result { // https://developer.apple.com/library/archive/documentation/QuickTime/QTFF/Metadata/Metadata.html#//apple_ref/doc/uid/TP40000939-CH1-SW35 Ok(match flags { UTF8 => AtomData::UTF8(utf8_decode(content)?), UTF16 => AtomData::UTF16(utf16_decode_bytes(&content, u16::from_be_bytes)?), BE_SIGNED_INTEGER => AtomData::SignedInteger(parse_int(&content)?), BE_UNSIGNED_INTEGER => AtomData::UnsignedInteger(parse_uint(&content)?), code => AtomData::Unknown { code, data: content, }, }) } lofty-0.21.1/src/mp4/ilst/ref.rs000064400000000000000000000025641046102023000144350ustar 00000000000000// ********************* // Reference Conversions // ********************* use crate::config::WriteOptions; use crate::error::{LoftyError, Result}; use crate::mp4::{Atom, AtomData, AtomIdent, Ilst}; use crate::util::io::{FileLike, Length, Truncate}; use std::io::Write; impl Ilst { pub(crate) fn as_ref(&self) -> IlstRef<'_, impl IntoIterator> { IlstRef { atoms: Box::new(self.atoms.iter().map(Atom::as_ref)), } } } pub(crate) struct IlstRef<'a, I> { pub(super) atoms: Box> + 'a>, } impl<'a, I: 'a> IlstRef<'a, I> where I: IntoIterator, { pub(crate) fn write_to(&mut self, file: &mut F, write_options: WriteOptions) -> Result<()> where F: FileLike, LoftyError: From<::Error>, LoftyError: From<::Error>, { super::write::write_to(file, self, write_options) } pub(crate) fn dump_to( &mut self, writer: &mut W, _write_options: WriteOptions, ) -> Result<()> { let temp = super::write::build_ilst(&mut self.atoms)?; writer.write_all(&temp)?; Ok(()) } } impl<'a> Atom<'a> { pub(super) fn as_ref(&self) -> AtomRef<'_, impl IntoIterator> { AtomRef { ident: self.ident.as_borrowed(), data: (&self.data).into_iter(), } } } pub(crate) struct AtomRef<'a, I> { pub(crate) ident: AtomIdent<'a>, pub(crate) data: I, } lofty-0.21.1/src/mp4/ilst/write.rs000064400000000000000000000517131046102023000150130ustar 00000000000000use super::r#ref::IlstRef; use crate::config::{ParseOptions, WriteOptions}; use crate::error::{FileEncodingError, LoftyError, Result}; use crate::file::FileType; use crate::macros::{decode_err, err, try_vec}; use crate::mp4::atom_info::{AtomIdent, AtomInfo, ATOM_HEADER_LEN, FOURCC_LEN}; use crate::mp4::ilst::r#ref::AtomRef; use crate::mp4::read::{atom_tree, meta_is_full, nested_atom, verify_mp4, AtomReader}; use crate::mp4::write::{AtomWriter, AtomWriterCompanion, ContextualAtom}; use crate::mp4::AtomData; use crate::picture::{MimeType, Picture}; use crate::util::alloc::VecFallibleCapacity; use crate::util::io::{FileLike, Length, Truncate}; use std::io::{Cursor, Seek, SeekFrom, Write}; use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt}; // A "full" atom is a traditional length + identifier, followed by a version (1) and flags (3) const FULL_ATOM_SIZE: u64 = ATOM_HEADER_LEN + 4; const HDLR_SIZE: u64 = ATOM_HEADER_LEN + 25; // TODO: We are forcing the use of ParseOptions::DEFAULT_PARSING_MODE. This is not good. It should be caller-specified. pub(crate) fn write_to<'a, F, I>( file: &mut F, tag: &mut IlstRef<'a, I>, write_options: WriteOptions, ) -> Result<()> where F: FileLike, LoftyError: From<::Error>, LoftyError: From<::Error>, I: IntoIterator + 'a, { log::debug!("Attempting to write `ilst` tag to file"); // Create a temporary `AtomReader`, just to verify that this is a valid MP4 file let mut reader = AtomReader::new(file, ParseOptions::DEFAULT_PARSING_MODE)?; verify_mp4(&mut reader)?; // Now we can just read the entire file into memory let file = reader.into_inner(); file.rewind()?; let mut atom_writer = AtomWriter::new_from_file(file, ParseOptions::DEFAULT_PARSING_MODE)?; let Some(moov) = atom_writer.find_contextual_atom(*b"moov") else { return Err(FileEncodingError::new( FileType::Mp4, "Could not find \"moov\" atom in target file", ) .into()); }; let moov_start = moov.info.start; let moov_len = moov.info.len; let moov_extended = moov.info.extended; log::trace!( "Found `moov` atom, offset: {}, size: {}", moov_start, moov_len ); let mut moov_data_start = moov_start + ATOM_HEADER_LEN; if moov_extended { moov_data_start += 8; } let mut write_handle = atom_writer.start_write(); write_handle.seek(SeekFrom::Start(moov_data_start))?; let ilst = build_ilst(&mut tag.atoms)?; let remove_tag = ilst.is_empty(); let udta = nested_atom( &mut write_handle, moov_len, b"udta", ParseOptions::DEFAULT_PARSING_MODE, )?; // Nothing to do if remove_tag && udta.is_none() { return Ok(()); } // Total size of new atoms let mut new_udta_size; // Size of the existing udta atom let mut existing_udta_size = 0; // ilst is nested in udta.meta, so we need to check what atoms actually exist if let Some(udta) = udta { log::trace!( "Found `udta` atom, offset: {}, size: {}", udta.start, udta.len ); existing_udta_size = udta.len; new_udta_size = existing_udta_size; let meta = nested_atom( &mut write_handle, udta.len, b"meta", ParseOptions::DEFAULT_PARSING_MODE, )?; // Nothing to do if remove_tag && meta.is_none() { return Ok(()); } match meta { Some(meta) => { log::trace!( "Found `meta` atom, offset: {}, size: {}", meta.start, meta.len ); // We may encounter a non-full `meta` atom meta_is_full(&mut write_handle)?; drop(write_handle); // We can use the existing `udta` and `meta` atoms save_to_existing( &atom_writer, moov, (meta, udta), &mut new_udta_size, ilst, remove_tag, write_options, )? }, // We have to create the `meta` atom None => { log::trace!("No `meta` atom found, creating one"); drop(write_handle); existing_udta_size = udta.len; // `meta` + `ilst` let capacity = FULL_ATOM_SIZE as usize + ilst.len(); let buf = Vec::with_capacity(capacity); let bytes; { let meta_writer = AtomWriter::new(buf, ParseOptions::DEFAULT_PARSING_MODE); create_meta(&meta_writer, &ilst)?; bytes = meta_writer.into_contents(); } write_handle = atom_writer.start_write(); new_udta_size = udta.len + bytes.len() as u64; write_handle.seek(SeekFrom::Start(udta.start))?; write_handle.write_atom_size(udta.start, new_udta_size, udta.extended)?; // We'll put the new `meta` atom right at the start of `udta` let meta_start_pos = (udta.start + ATOM_HEADER_LEN) as usize; write_handle.splice(meta_start_pos..meta_start_pos, bytes); // TODO: We need to drop the handle at the end of each branch, which is annoying // This whole function needs to be refactored eventually. drop(write_handle); }, } } else { log::trace!("No `udta` atom found, creating one"); // We have to create the `udta` atom let bytes = create_udta(&ilst)?; new_udta_size = bytes.len() as u64; // We'll put the new `udta` atom right at the start of `moov` let udta_pos = (moov_start + ATOM_HEADER_LEN) as usize; write_handle.splice(udta_pos..udta_pos, bytes); drop(write_handle); } let mut write_handle = atom_writer.start_write(); write_handle.seek(SeekFrom::Start(moov_start))?; // Change the size of the moov atom let new_moov_length = (moov_len - existing_udta_size) + new_udta_size; log::trace!( "Updating `moov` atom size, old size: {}, new size: {}", moov_len, new_moov_length ); write_handle.write_atom_size(moov_start, new_moov_length, moov_extended)?; drop(write_handle); atom_writer.save_to(file)?; Ok(()) } // TODO: We are forcing the use of ParseOptions::DEFAULT_PARSING_MODE. This is not good. It should be caller-specified. fn save_to_existing( writer: &AtomWriter, moov: &ContextualAtom, (meta, udta): (AtomInfo, AtomInfo), new_udta_size: &mut u64, ilst: Vec, remove_tag: bool, write_options: WriteOptions, ) -> Result<()> { let mut replacement; let range; let mut write_handle = writer.start_write(); let (ilst_idx, tree) = atom_tree( &mut write_handle, meta.len - ATOM_HEADER_LEN, b"ilst", ParseOptions::DEFAULT_PARSING_MODE, )?; if tree.is_empty() { // Nothing to do if remove_tag { return Ok(()); } let meta_end = (meta.start + meta.len) as usize; replacement = ilst; range = meta_end..meta_end; } else { let existing_ilst = &tree[ilst_idx]; let existing_ilst_size = existing_ilst.len; let mut range_start = existing_ilst.start; let range_end = existing_ilst.start + existing_ilst_size; if remove_tag { // We just need to strip out the `ilst` atom replacement = Vec::new(); range = range_start as usize..range_end as usize; } else { // Check for some padding atoms we can utilize let mut available_space = existing_ilst_size; // Check for one directly before the `ilst` atom if ilst_idx > 0 { let mut i = ilst_idx; while i != 0 { let atom = &tree[i - 1]; if atom.ident != AtomIdent::Fourcc(*b"free") { break; } available_space += atom.len; range_start = atom.start; i -= 1; } log::trace!("Found {} preceding `free` atoms", ilst_idx - i) } // And after if ilst_idx != tree.len() - 1 { let mut i = ilst_idx; while i < tree.len() - 1 { let atom = &tree[i + 1]; if atom.ident != AtomIdent::Fourcc(*b"free") { break; } available_space += atom.len; i += 1; } log::trace!("Found {} succeeding `free` atoms", i - ilst_idx) } let ilst_len = ilst.len() as u64; // Check if we have enough padding to fit the `ilst` atom and a new `free` atom if available_space > ilst_len && (available_space - ilst_len) > 8 { // We have enough space to make use of the padding log::trace!("Found enough padding to fit the tag, file size will not change"); let remaining_space = available_space - ilst_len; if remaining_space > u64::from(u32::MAX) { err!(TooMuchData); } let remaining_space = remaining_space as u32; write_handle.seek(SeekFrom::Start(range_start))?; write_handle.write_all(&ilst)?; // Write the remaining padding write_free_atom(&mut write_handle, remaining_space)?; return Ok(()); } replacement = ilst; range = range_start as usize..range_end as usize; } } drop(write_handle); let mut new_meta_size = (meta.len - range.len() as u64) + replacement.len() as u64; // Pad the `ilst` in the event of a shrink let mut difference = (new_meta_size as i64) - (meta.len as i64); if !replacement.is_empty() && difference != 0 { log::trace!("Tag size changed, attempting to avoid offset update"); let mut ilst_writer = Cursor::new(replacement); let (atom_size_difference, padding_size) = pad_atom(&mut ilst_writer, difference, write_options)?; replacement = ilst_writer.into_inner(); new_meta_size += padding_size; difference = atom_size_difference; } // Update the parent atom sizes if new_meta_size != meta.len { // We need to change the `meta` and `udta` atom sizes let mut write_handle = writer.start_write(); *new_udta_size = (udta.len - meta.len) + new_meta_size; write_handle.seek(SeekFrom::Start(meta.start))?; write_handle.write_atom_size(meta.start, new_meta_size, meta.extended)?; write_handle.seek(SeekFrom::Start(udta.start))?; write_handle.write_atom_size(udta.start, *new_udta_size, udta.extended)?; drop(write_handle); } // Update offset atoms if difference != 0 { let offset = range.start as u64; update_offsets(writer, moov, difference, offset)?; } // Replace the `ilst` atom let mut write_handle = writer.start_write(); write_handle.splice(range, replacement); drop(write_handle); Ok(()) } fn pad_atom( writer: &mut W, mut atom_size_difference: i64, write_options: WriteOptions, ) -> Result<(i64, u64)> where W: Write + Seek, { if atom_size_difference.is_positive() { log::trace!("Atom has grown, cannot avoid offset update"); return Ok((atom_size_difference, 0)); } // When the tag shrinks, we need to try and pad it out to avoid updating // the offsets. writer.seek(SeekFrom::End(0))?; let padding_size: u64; let diff_abs = atom_size_difference.abs(); if diff_abs >= ATOM_HEADER_LEN as i64 { log::trace!( "Avoiding offset update, padding atom with {} bytes", diff_abs ); // If our difference is >= 8, we can make up the difference with // a `free` atom and skip updating the offsets. write_free_atom(writer, diff_abs as u32)?; atom_size_difference = 0; padding_size = diff_abs as u64; return Ok((atom_size_difference, padding_size)); } let Some(preferred_padding) = write_options.preferred_padding else { log::trace!("Cannot avoid offset update, not padding atom"); return Ok((atom_size_difference, 0)); }; log::trace!( "Cannot avoid offset update, padding atom with {} bytes", preferred_padding ); // Otherwise, we'll have to just pad the default amount, // and update the offsets. write_free_atom(writer, preferred_padding)?; atom_size_difference += i64::from(preferred_padding); padding_size = u64::from(preferred_padding); Ok((atom_size_difference, padding_size)) } fn write_free_atom(writer: &mut W, size: u32) -> Result<()> where W: Write, { writer.write_u32::(size)?; writer.write_all(b"free")?; writer.write_all(&try_vec![1; (size - ATOM_HEADER_LEN as u32) as usize])?; Ok(()) } fn update_offsets( writer: &AtomWriter, moov: &ContextualAtom, difference: i64, ilst_offset: u64, ) -> Result<()> { log::debug!("Checking for offset atoms to update"); let mut write_handle = writer.start_write(); // 32-bit offsets for stco in moov.find_all_children(*b"stco", true) { log::trace!("Found `stco` atom"); let stco_start = stco.start; if stco.extended { decode_err!(@BAIL Mp4, "Found an extended `stco` atom"); } write_handle.seek(SeekFrom::Start(stco_start + ATOM_HEADER_LEN + 4))?; let count = write_handle.read_u32::()?; for _ in 0..count { let read_offset = write_handle.read_u32::()?; if u64::from(read_offset) < ilst_offset { continue; } write_handle.seek(SeekFrom::Current(-4))?; write_handle.write_u32::((i64::from(read_offset) + difference) as u32)?; log::trace!( "Updated offset from {} to {}", read_offset, (i64::from(read_offset) + difference) as u32 ); } } // 64-bit offsets for co64 in moov.find_all_children(*b"co64", true) { log::trace!("Found `co64` atom"); let co64_start = co64.start; if !co64.extended { decode_err!(@BAIL Mp4, "Expected `co64` atom to be extended"); } write_handle.seek(SeekFrom::Start(co64_start + ATOM_HEADER_LEN + 8 + 4))?; let count = write_handle.read_u32::()?; for _ in 0..count { let read_offset = write_handle.read_u64::()?; if read_offset < ilst_offset { continue; } write_handle.seek(SeekFrom::Current(-8))?; write_handle.write_u64::((read_offset as i64 + difference) as u64)?; log::trace!( "Updated offset from {} to {}", read_offset, ((read_offset as i64) + difference) as u64 ); } } let Some(moof) = writer.find_contextual_atom(*b"moof") else { return Ok(()); }; log::trace!("Found `moof` atom, checking for `tfhd` atoms to update"); // 64-bit offsets for tfhd in moof.find_all_children(*b"tfhd", true) { log::trace!("Found `tfhd` atom"); let tfhd_start = tfhd.start; if tfhd.extended { decode_err!(@BAIL Mp4, "Found an extended `tfhd` atom"); } // Skip atom header + version (1) write_handle.seek(SeekFrom::Start(tfhd_start + ATOM_HEADER_LEN + 1))?; let flags = write_handle.read_u24::()?; let base_data_offset = (flags & 0b1) != 0; if base_data_offset { let read_offset = write_handle.read_u64::()?; if read_offset < ilst_offset { continue; } write_handle.seek(SeekFrom::Current(-8))?; write_handle.write_u64::((read_offset as i64 + difference) as u64)?; log::trace!( "Updated offset from {} to {}", read_offset, ((read_offset as i64) + difference) as u64 ); } } drop(write_handle); Ok(()) } fn create_udta(ilst: &[u8]) -> Result> { const UDTA_HEADER: [u8; 8] = [0, 0, 0, 0, b'u', b'd', b't', b'a']; // `udta` + `meta` + `hdlr` + `ilst` let capacity = ATOM_HEADER_LEN + FULL_ATOM_SIZE + HDLR_SIZE + ilst.len() as u64; let mut buf = Vec::try_with_capacity_stable(capacity as usize)?; buf.write_all(&UDTA_HEADER)?; let udta_writer = AtomWriter::new(buf, ParseOptions::DEFAULT_PARSING_MODE); let mut write_handle = udta_writer.start_write(); write_handle.seek(SeekFrom::Current(UDTA_HEADER.len() as i64))?; // Skip header drop(write_handle); create_meta(&udta_writer, ilst)?; // `udta` size { let mut write_handle = udta_writer.start_write(); write_handle.rewind()?; write_handle.write_atom_size(0, write_handle.len() as u64, false)?; } Ok(udta_writer.into_contents()) } fn create_meta(writer: &AtomWriter, ilst: &[u8]) -> Result<()> { let mut write_handle = writer.start_write(); let start = write_handle.stream_position()?; // meta atom write_handle.write_all(&[0, 0, 0, 0, b'm', b'e', b't', b'a', 0, 0, 0, 0])?; // hdlr atom write_handle.write_u32::(0)?; write_handle.write_all(b"hdlr")?; write_handle.write_u64::(0)?; write_handle.write_all(b"mdirappl")?; write_handle.write_all(&[0, 0, 0, 0, 0, 0, 0, 0, 0])?; write_handle.seek(SeekFrom::Start(start))?; let meta_size = FULL_ATOM_SIZE + HDLR_SIZE + ilst.len() as u64; write_handle.write_atom_size(start, meta_size, false)?; // Seek to `hdlr` size let hdlr_size_pos = write_handle.seek(SeekFrom::Current(4))?; write_handle.write_atom_size(hdlr_size_pos, HDLR_SIZE, false)?; write_handle.seek(SeekFrom::End(0))?; write_handle.write_all(ilst)?; Ok(()) } pub(super) fn build_ilst<'a, I>(atoms: &mut dyn Iterator>) -> Result> where I: IntoIterator + 'a, { log::debug!("Building `ilst` atom"); let mut peek = atoms.peekable(); if peek.peek().is_none() { return Ok(Vec::new()); } let ilst_header = vec![0, 0, 0, 0, b'i', b'l', b's', b't']; let ilst_writer = AtomWriter::new(ilst_header, ParseOptions::DEFAULT_PARSING_MODE); let mut write_handle = ilst_writer.start_write(); write_handle.seek(SeekFrom::End(0))?; for atom in peek { let start = write_handle.stream_position()?; // Empty size, we get it later write_handle.write_all(&[0; FOURCC_LEN as usize])?; match atom.ident { AtomIdent::Fourcc(ref fourcc) => write_handle.write_all(fourcc)?, AtomIdent::Freeform { mean, name } => write_freeform(&mean, &name, &mut write_handle)?, } write_atom_data(atom.data, &mut write_handle)?; let end = write_handle.stream_position()?; let size = end - start; write_handle.seek(SeekFrom::Start(start))?; write_handle.write_atom_size(start, size, false)?; write_handle.seek(SeekFrom::Start(end))?; } let size = write_handle.len(); write_handle.rewind()?; write_handle.write_atom_size(0, size as u64, false)?; drop(write_handle); log::trace!("Built `ilst` atom, size: {} bytes", size); Ok(ilst_writer.into_contents()) } fn write_freeform(mean: &str, name: &str, writer: &mut W) -> Result<()> where W: Write, { // ---- : ???? : ???? // ---- writer.write_all(b"----")?; // .... MEAN 0000 ???? writer.write_u32::((FULL_ATOM_SIZE + mean.len() as u64) as u32)?; writer.write_all(&[b'm', b'e', b'a', b'n', 0, 0, 0, 0])?; writer.write_all(mean.as_bytes())?; // .... NAME 0000 ???? writer.write_u32::((FULL_ATOM_SIZE + name.len() as u64) as u32)?; writer.write_all(&[b'n', b'a', b'm', b'e', 0, 0, 0, 0])?; writer.write_all(name.as_bytes())?; Ok(()) } fn write_atom_data<'a, I>(data: I, writer: &mut AtomWriterCompanion<'_>) -> Result<()> where I: IntoIterator + 'a, { for value in data { match value { AtomData::UTF8(text) => write_data(1, text.as_bytes(), writer)?, AtomData::UTF16(text) => write_data(2, text.as_bytes(), writer)?, AtomData::Picture(ref pic) => write_picture(pic, writer)?, AtomData::SignedInteger(int) => write_signed_int(*int, writer)?, AtomData::UnsignedInteger(uint) => write_unsigned_int(*uint, writer)?, AtomData::Bool(b) => write_signed_int(i32::from(*b), writer)?, AtomData::Unknown { code, ref data } => write_data(*code, data, writer)?, }; } Ok(()) } fn write_signed_int(int: i32, writer: &mut AtomWriterCompanion<'_>) -> Result<()> { write_int(21, int.to_be_bytes(), 4, writer) } fn bytes_to_occupy_uint(uint: u32) -> usize { if uint == 0 { return 1; } let ret = 4 - (uint.to_le().leading_zeros() >> 3) as usize; if ret == 3 { return 4; } ret } fn write_unsigned_int(uint: u32, writer: &mut AtomWriterCompanion<'_>) -> Result<()> { let bytes_needed = bytes_to_occupy_uint(uint); write_int(22, uint.to_be_bytes(), bytes_needed, writer) } fn write_int( flags: u32, bytes: [u8; 4], bytes_needed: usize, writer: &mut AtomWriterCompanion<'_>, ) -> Result<()> { debug_assert!(bytes_needed != 0); write_data(flags, &bytes[4 - bytes_needed..], writer) } fn write_picture(picture: &Picture, writer: &mut AtomWriterCompanion<'_>) -> Result<()> { match picture.mime_type { // GIF is deprecated Some(MimeType::Gif) => write_data(12, &picture.data, writer), Some(MimeType::Jpeg) => write_data(13, &picture.data, writer), Some(MimeType::Png) => write_data(14, &picture.data, writer), Some(MimeType::Bmp) => write_data(27, &picture.data, writer), // We'll assume implicit (0) was the intended type None => write_data(0, &picture.data, writer), _ => Err(FileEncodingError::new( FileType::Mp4, "Attempted to write an unsupported picture format", ) .into()), } } fn write_data(flags: u32, data: &[u8], writer: &mut AtomWriterCompanion<'_>) -> Result<()> { if flags > 16_777_215 { return Err(FileEncodingError::new( FileType::Mp4, "Attempted to write a code that cannot fit in 24 bits", ) .into()); } // .... DATA (version = 0) (flags) (locale = 0000) (data) let size = FULL_ATOM_SIZE + 4 + data.len() as u64; writer.write_all(&[0, 0, 0, 0, b'd', b'a', b't', b'a'])?; let start = writer.seek(SeekFrom::Current(-8))?; writer.write_atom_size(start, size, false)?; // Version writer.write_u8(0)?; writer.write_uint::(u64::from(flags), 3)?; // Locale writer.write_all(&[0; 4])?; writer.write_all(data)?; Ok(()) } #[cfg(test)] mod tests { use crate::mp4::ilst::write::bytes_to_occupy_uint; macro_rules! int_test { ( func: $fun:expr, $( { input: $input:expr, expected: $expected:expr $(,)? } ),+ $(,)? ) => { $( { let bytes = $fun($input); assert_eq!(&$input.to_be_bytes()[4 - bytes..], &$expected[..]); } )+ } } #[test] fn integer_shrinking_unsigned() { int_test! { func: bytes_to_occupy_uint, { input: 0u32, expected: [0], }, { input: 1u32, expected: [1], }, { input: 32767u32, expected: [127, 255], }, { input: 65535u32, expected: [255, 255], }, { input: 8_388_607_u32, expected: [0, 127, 255, 255], }, { input: 16_777_215_u32, expected: [0, 255, 255, 255], }, { input: u32::MAX, expected: [255, 255, 255, 255], }, } } } lofty-0.21.1/src/mp4/mod.rs000064400000000000000000000032031046102023000134540ustar 00000000000000//! MP4 specific items //! //! ## File notes //! //! The only supported tag format is [`Ilst`]. mod atom_info; pub(crate) mod ilst; mod moov; mod properties; mod read; mod write; use lofty_attr::LoftyFile; // Exports /// This module contains the codes for all of the [Well-known data types] /// /// [Well-known data types]: https://developer.apple.com/library/archive/documentation/QuickTime/QTFF/Metadata/Metadata.html#//apple_ref/doc/uid/TP40000939-CH1-SW34 pub mod constants { pub use super::ilst::constants::*; } pub use crate::mp4::properties::{AudioObjectType, Mp4Codec, Mp4Properties}; pub use atom_info::AtomIdent; pub use ilst::atom::{AdvisoryRating, Atom, AtomData}; pub use ilst::Ilst; pub(crate) use properties::SAMPLE_RATES; /// An MP4 file #[derive(LoftyFile)] #[lofty(read_fn = "read::read_from")] pub struct Mp4File { /// The file format from ftyp's "major brand" (Ex. "M4A ") pub(crate) ftyp: String, #[lofty(tag_type = "Mp4Ilst")] /// The parsed `ilst` (metadata) atom, if it exists pub(crate) ilst_tag: Option, /// The file's audio properties pub(crate) properties: Mp4Properties, } impl Mp4File { /// Returns the file format from ftyp's "major brand" (Ex. "M4A ") /// /// # Examples /// /// ```rust,no_run /// use lofty::config::ParseOptions; /// use lofty::file::AudioFile; /// use lofty::mp4::Mp4File; /// /// # fn main() -> lofty::error::Result<()> { /// # let mut m4a_reader = std::io::Cursor::new(&[]); /// let m4a_file = Mp4File::read_from(&mut m4a_reader, ParseOptions::new())?; /// /// assert_eq!(m4a_file.ftyp(), "M4A "); /// # Ok(()) } /// ``` pub fn ftyp(&self) -> &str { self.ftyp.as_ref() } } lofty-0.21.1/src/mp4/moov.rs000064400000000000000000000063331046102023000136640ustar 00000000000000use super::atom_info::{AtomIdent, AtomInfo}; use super::ilst::read::parse_ilst; use super::ilst::Ilst; use super::read::{meta_is_full, nested_atom, skip_unneeded, AtomReader}; use crate::config::ParseOptions; use crate::error::Result; use crate::macros::decode_err; use std::io::{Read, Seek}; pub(crate) struct Moov { // Represents the trak.mdia atom pub(crate) traks: Vec, // Represents a parsed moov.udta.meta.ilst pub(crate) ilst: Option, } impl Moov { pub(super) fn find(reader: &mut AtomReader) -> Result where R: Read + Seek, { let mut moov = None; while let Ok(Some(atom)) = reader.next() { if atom.ident == AtomIdent::Fourcc(*b"moov") { moov = Some(atom); break; } skip_unneeded(reader, atom.extended, atom.len)?; } moov.ok_or_else(|| decode_err!(Mp4, "No \"moov\" atom found")) } pub(super) fn parse(reader: &mut AtomReader, parse_options: ParseOptions) -> Result where R: Read + Seek, { let mut traks = Vec::new(); let mut ilst = None; while let Ok(Some(atom)) = reader.next() { if let AtomIdent::Fourcc(fourcc) = atom.ident { match &fourcc { b"trak" if parse_options.read_properties => { // All we need from here is trak.mdia if let Some(mdia) = nested_atom(reader, atom.len, b"mdia", parse_options.parsing_mode)? { skip_unneeded(reader, mdia.extended, mdia.len)?; traks.push(mdia); } }, b"udta" if parse_options.read_tags => { let ilst_parsed = ilst_from_udta(reader, parse_options, atom.len - 8)?; if let Some(ilst_parsed) = ilst_parsed { let Some(mut existing_ilst) = ilst else { ilst = Some(ilst_parsed); continue; }; log::warn!("Multiple `ilst` atoms found, combining them"); for atom in ilst_parsed.atoms { existing_ilst.insert(atom); } ilst = Some(existing_ilst); } }, _ => skip_unneeded(reader, atom.extended, atom.len)?, } continue; } skip_unneeded(reader, atom.extended, atom.len)? } Ok(Self { traks, ilst }) } } fn ilst_from_udta( reader: &mut AtomReader, parse_options: ParseOptions, len: u64, ) -> Result> where R: Read + Seek, { let mut read = 8; let mut found_meta = false; let mut meta_atom_size = 0; while read < len { let Some(atom) = reader.next()? else { break; }; if atom.ident == AtomIdent::Fourcc(*b"meta") { found_meta = true; meta_atom_size = atom.len; break; } read += atom.len; skip_unneeded(reader, atom.extended, atom.len)?; } if !found_meta { return Ok(None); } // It's possible for the `meta` atom to be non-full, // so we have to check for that case let full_meta_atom = meta_is_full(reader)?; if full_meta_atom { read = 12; } else { read = 8; } let mut found_ilst = false; let mut ilst_atom_size = 0; while read < meta_atom_size { let Some(atom) = reader.next()? else { break; }; if atom.ident == AtomIdent::Fourcc(*b"ilst") { found_ilst = true; ilst_atom_size = atom.len; break; } read += atom.len; skip_unneeded(reader, atom.extended, atom.len)?; } if found_ilst { return parse_ilst(reader, parse_options, ilst_atom_size - 8).map(Some); } Ok(None) } lofty-0.21.1/src/mp4/properties.rs000064400000000000000000000600761046102023000151040ustar 00000000000000use super::atom_info::{AtomIdent, AtomInfo}; use super::read::{nested_atom, skip_unneeded, AtomReader}; use crate::config::ParsingMode; use crate::error::{LoftyError, Result}; use crate::macros::{decode_err, err, try_vec}; use crate::properties::FileProperties; use crate::util::alloc::VecFallibleCapacity; use crate::util::math::RoundedDivision; use std::io::{Cursor, Read, Seek, SeekFrom}; use std::time::Duration; use byteorder::{BigEndian, ReadBytesExt}; /// An MP4 file's audio codec #[allow(missing_docs)] #[derive(Default, Debug, Copy, Clone, PartialEq, Eq)] #[non_exhaustive] pub enum Mp4Codec { #[default] Unknown, AAC, ALAC, MP3, FLAC, } #[allow(missing_docs)] #[derive(Default, Debug, Copy, Clone, PartialEq, Eq)] #[rustfmt::skip] #[non_exhaustive] pub enum AudioObjectType { // https://en.wikipedia.org/wiki/MPEG-4_Part_3#MPEG-4_Audio_Object_Types #[default] NULL = 0, AacMain = 1, // AAC Main Profile AacLowComplexity = 2, // AAC Low Complexity AacScalableSampleRate = 3, // AAC Scalable Sample Rate AacLongTermPrediction = 4, // AAC Long Term Predictor SpectralBandReplication = 5, // Spectral band Replication AACScalable = 6, // AAC Scalable TwinVQ = 7, // Twin VQ CodeExcitedLinearPrediction = 8, // CELP HarmonicVectorExcitationCoding = 9, // HVXC TextToSpeechtInterface = 12, // TTSI MainSynthetic = 13, // Main Synthetic WavetableSynthesis = 14, // Wavetable Synthesis GeneralMIDI = 15, // General MIDI AlgorithmicSynthesis = 16, // Algorithmic Synthesis ErrorResilientAacLowComplexity = 17, // ER AAC LC ErrorResilientAacLongTermPrediction = 19, // ER AAC LTP ErrorResilientAacScalable = 20, // ER AAC Scalable ErrorResilientAacTwinVQ = 21, // ER AAC TwinVQ ErrorResilientAacBitSlicedArithmeticCoding = 22, // ER Bit Sliced Arithmetic Coding ErrorResilientAacLowDelay = 23, // ER AAC Low Delay ErrorResilientCodeExcitedLinearPrediction = 24, // ER CELP ErrorResilientHarmonicVectorExcitationCoding = 25, // ER HVXC ErrorResilientHarmonicIndividualLinesNoise = 26, // ER HILN ErrorResilientParametric = 27, // ER Parametric SinuSoidalCoding = 28, // SSC ParametricStereo = 29, // PS MpegSurround = 30, // MPEG Surround MpegLayer1 = 32, // MPEG Layer 1 MpegLayer2 = 33, // MPEG Layer 2 MpegLayer3 = 34, // MPEG Layer 3 DirectStreamTransfer = 35, // DST Direct Stream Transfer AudioLosslessCoding = 36, // ALS Audio Lossless Coding ScalableLosslessCoding = 37, // SLC Scalable Lossless Coding ScalableLosslessCodingNoneCore = 38, // SLC non-core ErrorResilientAacEnhancedLowDelay = 39, // ER AAC ELD SymbolicMusicRepresentationSimple = 40, // SMR Simple SymbolicMusicRepresentationMain = 41, // SMR Main UnifiedSpeechAudioCoding = 42, // USAC SpatialAudioObjectCoding = 43, // SAOC LowDelayMpegSurround = 44, // LD MPEG Surround SpatialAudioObjectCodingDialogueEnhancement = 45, // SAOC-DE AudioSync = 46, // Audio Sync } impl TryFrom for AudioObjectType { type Error = LoftyError; #[rustfmt::skip] fn try_from(value: u8) -> std::result::Result { match value { 1 => Ok(Self::AacMain), 2 => Ok(Self::AacLowComplexity), 3 => Ok(Self::AacScalableSampleRate), 4 => Ok(Self::AacLongTermPrediction), 5 => Ok(Self::SpectralBandReplication), 6 => Ok(Self::AACScalable), 7 => Ok(Self::TwinVQ), 8 => Ok(Self::CodeExcitedLinearPrediction), 9 => Ok(Self::HarmonicVectorExcitationCoding), 12 => Ok(Self::TextToSpeechtInterface), 13 => Ok(Self::MainSynthetic), 14 => Ok(Self::WavetableSynthesis), 15 => Ok(Self::GeneralMIDI), 16 => Ok(Self::AlgorithmicSynthesis), 17 => Ok(Self::ErrorResilientAacLowComplexity), 19 => Ok(Self::ErrorResilientAacLongTermPrediction), 20 => Ok(Self::ErrorResilientAacScalable), 21 => Ok(Self::ErrorResilientAacTwinVQ), 22 => Ok(Self::ErrorResilientAacBitSlicedArithmeticCoding), 23 => Ok(Self::ErrorResilientAacLowDelay), 24 => Ok(Self::ErrorResilientCodeExcitedLinearPrediction), 25 => Ok(Self::ErrorResilientHarmonicVectorExcitationCoding), 26 => Ok(Self::ErrorResilientHarmonicIndividualLinesNoise), 27 => Ok(Self::ErrorResilientParametric), 28 => Ok(Self::SinuSoidalCoding), 29 => Ok(Self::ParametricStereo), 30 => Ok(Self::MpegSurround), 32 => Ok(Self::MpegLayer1), 33 => Ok(Self::MpegLayer2), 34 => Ok(Self::MpegLayer3), 35 => Ok(Self::DirectStreamTransfer), 36 => Ok(Self::AudioLosslessCoding), 37 => Ok(Self::ScalableLosslessCoding), 38 => Ok(Self::ScalableLosslessCodingNoneCore), 39 => Ok(Self::ErrorResilientAacEnhancedLowDelay), 40 => Ok(Self::SymbolicMusicRepresentationSimple), 41 => Ok(Self::SymbolicMusicRepresentationMain), 42 => Ok(Self::UnifiedSpeechAudioCoding), 43 => Ok(Self::SpatialAudioObjectCoding), 44 => Ok(Self::LowDelayMpegSurround), 45 => Ok(Self::SpatialAudioObjectCodingDialogueEnhancement), 46 => Ok(Self::AudioSync), _ => decode_err!(@BAIL Mp4, "Encountered an invalid audio object type"), } } } /// An MP4 file's audio properties #[derive(Debug, Clone, PartialEq, Eq, Default)] #[non_exhaustive] pub struct Mp4Properties { pub(crate) codec: Mp4Codec, pub(crate) extended_audio_object_type: Option, pub(crate) duration: Duration, pub(crate) overall_bitrate: u32, pub(crate) audio_bitrate: u32, pub(crate) sample_rate: u32, pub(crate) bit_depth: Option, pub(crate) channels: u8, pub(crate) drm_protected: bool, } impl From for FileProperties { fn from(input: Mp4Properties) -> Self { Self { duration: input.duration, overall_bitrate: Some(input.overall_bitrate), audio_bitrate: Some(input.audio_bitrate), sample_rate: Some(input.sample_rate), bit_depth: input.bit_depth, channels: Some(input.channels), channel_mask: None, } } } impl Mp4Properties { /// Duration of the audio pub fn duration(&self) -> Duration { self.duration } /// Overall bitrate (kbps) pub fn overall_bitrate(&self) -> u32 { self.overall_bitrate } /// Audio bitrate (kbps) pub fn audio_bitrate(&self) -> u32 { self.audio_bitrate } /// Sample rate (Hz) pub fn sample_rate(&self) -> u32 { self.sample_rate } /// Bits per sample pub fn bit_depth(&self) -> Option { self.bit_depth } /// Channel count pub fn channels(&self) -> u8 { self.channels } /// Audio codec pub fn codec(&self) -> &Mp4Codec { &self.codec } /// Extended audio object type /// /// This is only applicable to MP4 files with an Elementary Stream Descriptor. /// See [here](https://wiki.multimedia.cx/index.php?title=MPEG-4_Audio#Audio_Specific_Config) for /// more information. pub fn audio_object_type(&self) -> Option { self.extended_audio_object_type } /// Whether or not the file is DRM protected pub fn is_drm_protected(&self) -> bool { self.drm_protected } } struct TrakChildren { mdhd: AtomInfo, minf: Option, } fn get_trak_children(reader: &mut AtomReader, traks: &[AtomInfo]) -> Result where R: Read + Seek, { let mut audio_track = false; let mut mdhd = None; let mut minf = None; // We have to search through the traks with a mdia atom to find the audio track for mdia in traks { if audio_track { break; } reader.seek(SeekFrom::Start(mdia.start + 8))?; let mut read = 8; while read < mdia.len { let Some(atom) = reader.next()? else { break }; read += atom.len; if let AtomIdent::Fourcc(fourcc) = atom.ident { match &fourcc { b"mdhd" => { skip_unneeded(reader, atom.extended, atom.len)?; mdhd = Some(atom) }, b"hdlr" => { if atom.len < 20 { log::warn!("Incomplete 'hdlr' atom, skipping"); skip_unneeded(reader, atom.extended, atom.len)?; continue; } // The hdlr atom is followed by 8 zeros reader.seek(SeekFrom::Current(8))?; let mut handler_type = [0; 4]; reader.read_exact(&mut handler_type)?; if &handler_type == b"soun" { audio_track = true } skip_unneeded(reader, atom.extended, atom.len - 12)?; }, b"minf" => minf = Some(atom), _ => { skip_unneeded(reader, atom.extended, atom.len)?; }, } continue; } skip_unneeded(reader, atom.extended, atom.len)?; } } if !audio_track { decode_err!(@BAIL Mp4, "File contains no audio tracks"); } let Some(mdhd) = mdhd else { err!(BadAtom("Expected atom \"trak.mdia.mdhd\"")); }; Ok(TrakChildren { mdhd, minf }) } struct Mdhd { timescale: u32, duration: u64, } fn read_mdhd(reader: &mut AtomReader) -> Result where R: Read + Seek, { let version = reader.read_u8()?; let _flags = reader.read_uint(3)?; let (timescale, duration) = if version == 1 { // We don't care about these two values let _creation_time = reader.read_u64()?; let _modification_time = reader.read_u64()?; let timescale = reader.read_u32()?; let duration = reader.read_u64()?; (timescale, duration) } else { let _creation_time = reader.read_u32()?; let _modification_time = reader.read_u32()?; let timescale = reader.read_u32()?; let duration = reader.read_u32()?; (timescale, u64::from(duration)) }; Ok(Mdhd { timescale, duration, }) } // TODO: Estimate duration from stts? // Since this has the number of samples and the duration of each sample, // it would be pretty simple to do, and would help in the case that we have // no timescale available. #[derive(Debug)] struct SttsEntry { _sample_count: u32, sample_duration: u32, } fn read_stts(reader: &mut R) -> Result> where R: Read, { let _version_and_flags = reader.read_uint::(4)?; let entry_count = reader.read_u32::()?; let mut entries = Vec::try_with_capacity_stable(entry_count as usize)?; for _ in 0..entry_count { let sample_count = reader.read_u32::()?; let sample_duration = reader.read_u32::()?; entries.push(SttsEntry { _sample_count: sample_count, sample_duration, }); } Ok(entries) } struct Minf { stsd_data: Vec, stts: Option>, } fn read_minf( reader: &mut AtomReader, len: u64, parse_mode: ParsingMode, ) -> Result> where R: Read + Seek, { let Some(stbl) = nested_atom(reader, len, b"stbl", parse_mode)? else { return Ok(None); }; let mut stsd_data = None; let mut stts = None; let mut read = 8; while read < stbl.len { let Some(atom) = reader.next()? else { break }; read += atom.len; if let AtomIdent::Fourcc(fourcc) = atom.ident { match &fourcc { b"stsd" => { let mut stsd = try_vec![0; (atom.len - 8) as usize]; reader.read_exact(&mut stsd)?; stsd_data = Some(stsd); }, b"stts" => stts = Some(read_stts(reader)?), _ => { skip_unneeded(reader, atom.extended, atom.len)?; }, } continue; } } let Some(stsd_data) = stsd_data else { return Ok(None); }; Ok(Some(Minf { stsd_data, stts })) } fn read_stsd(reader: &mut AtomReader, properties: &mut Mp4Properties) -> Result<()> where R: Read + Seek, { // Skipping 4 bytes // Version (1) // Flags (3) reader.seek(SeekFrom::Current(4))?; let num_sample_entries = reader.read_u32()?; for _ in 0..num_sample_entries { let Some(atom) = reader.next()? else { err!(BadAtom("Expected sample entry atom in `stsd` atom")) }; let AtomIdent::Fourcc(ref fourcc) = atom.ident else { err!(BadAtom("Expected fourcc atom in `stsd` atom")) }; match fourcc { b"mp4a" => mp4a_properties(reader, properties)?, b"alac" => alac_properties(reader, properties)?, b"fLaC" => flac_properties(reader, properties)?, // Maybe do these? // TODO: dops (opus) // TODO: wave (https://developer.apple.com/library/archive/documentation/QuickTime/QTFF/QTFFChap3/qtff3.html#//apple_ref/doc/uid/TP40000939-CH205-134202) // Special case to detect encrypted files b"drms" => { properties.drm_protected = true; skip_unneeded(reader, atom.extended, atom.len)?; continue; }, _ => { log::warn!( "Found unsupported sample entry: {:?}", fourcc.escape_ascii().to_string() ); skip_unneeded(reader, atom.extended, atom.len)?; continue; }, } // We only want to read the properties of the first stream // that we can actually recognize break; } Ok(()) } pub(super) fn read_properties( reader: &mut AtomReader, traks: &[AtomInfo], file_length: u64, parse_mode: ParsingMode, ) -> Result where R: Read + Seek, { // We need the mdhd and minf atoms from the audio track let TrakChildren { mdhd, minf } = get_trak_children(reader, traks)?; reader.seek(SeekFrom::Start(mdhd.start + 8))?; let Mdhd { timescale, duration, } = read_mdhd(reader)?; // We create the properties here, since it is possible the other information isn't available let mut properties = Mp4Properties::default(); if timescale > 0 { let duration_millis = (duration * 1000).div_round(u64::from(timescale)); properties.duration = Duration::from_millis(duration_millis); } // We need an `mdhd` atom at the bare minimum, everything else can be optional. let Some(minf_info) = minf else { return Ok(properties); }; reader.seek(SeekFrom::Start(minf_info.start + 8))?; let Some(Minf { stsd_data, stts }) = read_minf(reader, minf_info.len, parse_mode)? else { return Ok(properties); }; // `stsd` contains the majority of the audio properties let mut cursor = Cursor::new(&*stsd_data); let mut stsd_reader = AtomReader::new(&mut cursor, parse_mode)?; read_stsd(&mut stsd_reader, &mut properties)?; // We do the mdat check up here, so we have access to the entire file if duration > 0 { // TODO: We should keep track of the `mdat` length when first reading the file. // This extra read is unnecessary. let mdat_len = mdat_length(reader)?; if let Some(stts) = stts { let stts_specifies_duration = !(stts.len() == 1 && stts[0].sample_duration == 1); if stts_specifies_duration { // We do a basic audio bitrate calculation below for each stream type. // Up here, we can do a more accurate calculation if the duration is available. let audio_bitrate_bps = (((u128::from(mdat_len) * 8) * u128::from(timescale)) / u128::from(duration)) as u32; // kb/s properties.audio_bitrate = audio_bitrate_bps / 1000; } } // TODO: We need to eventually calculate the duration from the stts atom // if there is no timescale available. let duration_millis = properties.duration.as_millis(); if duration_millis == 0 { log::warn!("Duration is 0, unable to calculate bitrate"); return Ok(properties); } let overall_bitrate = u128::from(file_length * 8) / duration_millis; properties.overall_bitrate = overall_bitrate as u32; if properties.audio_bitrate == 0 { log::warn!("Estimating audio bitrate from 'mdat' size"); properties.audio_bitrate = (u128::from(mdat_length(reader)? * 8) / duration_millis) as u32; } } Ok(properties) } // https://wiki.multimedia.cx/index.php?title=MPEG-4_Audio#Sampling_Frequencies pub(crate) const SAMPLE_RATES: [u32; 15] = [ 96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000, 7350, 0, 0, ]; fn mp4a_properties(stsd: &mut AtomReader, properties: &mut Mp4Properties) -> Result<()> where R: Read + Seek, { const ELEMENTARY_DESCRIPTOR_TAG: u8 = 0x03; const DECODER_CONFIG_TAG: u8 = 0x04; const DECODER_SPECIFIC_DESCRIPTOR_TAG: u8 = 0x05; // Set the codec to AAC, which is a good guess if we fail before reaching the `esds` properties.codec = Mp4Codec::AAC; // Skipping 16 bytes // Reserved (6) // Data reference index (2) // Version (2) // Revision level (2) // Vendor (4) stsd.seek(SeekFrom::Current(16))?; properties.channels = stsd.read_u16()? as u8; // Skipping 4 bytes // Sample size (2) // Compression ID (2) stsd.seek(SeekFrom::Current(4))?; properties.sample_rate = stsd.read_u32()?; stsd.seek(SeekFrom::Current(2))?; // This information is often followed by an esds (elementary stream descriptor) atom containing the bitrate let Ok(Some(esds)) = stsd.next() else { return Ok(()); }; if esds.ident != AtomIdent::Fourcc(*b"esds") { return Ok(()); } // There are 4 bytes we expect to be zeroed out // Version (1) // Flags (3) // // Otherwise, we don't know how to handle it, and can simply bail. if stsd.read_u32()? != 0 { return Ok(()); } let descriptor = Descriptor::read(stsd)?; if descriptor.tag == ELEMENTARY_DESCRIPTOR_TAG { // Skipping 3 bytes // Elementary stream ID (2) // Flags (1) stsd.seek(SeekFrom::Current(3))?; // There is another descriptor embedded in the previous one let descriptor = Descriptor::read(stsd)?; if descriptor.tag == DECODER_CONFIG_TAG { let codec = stsd.read_u8()?; properties.codec = match codec { 0x40 | 0x41 | 0x66 | 0x67 | 0x68 => Mp4Codec::AAC, 0x69 | 0x6B => Mp4Codec::MP3, _ => Mp4Codec::Unknown, }; // Skipping 8 bytes // Stream type (1) // Buffer size (3) // Max bitrate (4) stsd.seek(SeekFrom::Current(8))?; let average_bitrate = stsd.read_u32()?; // Yet another descriptor to check let descriptor = Descriptor::read(stsd)?; if descriptor.tag == DECODER_SPECIFIC_DESCRIPTOR_TAG { // https://wiki.multimedia.cx/index.php?title=MPEG-4_Audio#Audio_Specific_Config // // 5 bits: object type // if (object type == 31) // 6 bits + 32: object type // 4 bits: frequency index // if (frequency index == 15) // 24 bits: frequency // 4 bits: channel configuration let byte_a = stsd.read_u8()?; let byte_b = stsd.read_u8()?; let mut object_type = byte_a >> 3; let mut frequency_index = ((byte_a & 0x07) << 1) | (byte_b >> 7); let mut channel_conf = (byte_b >> 3) & 0x0F; let mut extended_object_type = false; if object_type == 31 { extended_object_type = true; object_type = 32 + ((byte_a & 7) | (byte_b >> 5)); frequency_index = (byte_b >> 1) & 0x0F; } properties.extended_audio_object_type = Some(AudioObjectType::try_from(object_type)?); match frequency_index { // 15 means the sample rate is stored in the next 24 bits 0x0F => { let sample_rate; let explicit_sample_rate = stsd.read_u24::()?; if extended_object_type { sample_rate = explicit_sample_rate >> 1; channel_conf = ((explicit_sample_rate >> 4) & 0x0F) as u8; } else { sample_rate = explicit_sample_rate << 1; let byte_c = stsd.read_u8()?; channel_conf = ((explicit_sample_rate & 0x80) as u8 | (byte_c >> 1)) & 0x0F; } // Just use the sample rate we already read above if this is invalid if sample_rate > 0 { properties.sample_rate = sample_rate; } }, i if i < SAMPLE_RATES.len() as u8 => { properties.sample_rate = SAMPLE_RATES[i as usize]; if extended_object_type { let byte_c = stsd.read_u8()?; channel_conf = (byte_b & 1) | (byte_c & 0xE0); } else { channel_conf = (byte_b >> 3) & 0x0F; } }, // Keep the sample rate we read above _ => {}, } // The channel configuration isn't always set, at least when testing with // the Audio Lossless Coding reference software if channel_conf > 0 { properties.channels = channel_conf; } // We just check for ALS here, might extend it for more codes eventually if object_type == 36 { let mut ident = [0; 5]; stsd.read_exact(&mut ident)?; if &ident == b"\0ALS\0" { properties.sample_rate = stsd.read_u32()?; // Sample count stsd.seek(SeekFrom::Current(4))?; properties.channels = stsd.read_u16()? as u8 + 1; } } } if average_bitrate > 0 || properties.duration.is_zero() { properties.audio_bitrate = average_bitrate / 1000; } } } Ok(()) } fn alac_properties(stsd: &mut AtomReader, properties: &mut Mp4Properties) -> Result<()> where R: Read + Seek, { // With ALAC, we can expect the length to be exactly 88 (80 here since we removed the size and identifier) if stsd.seek(SeekFrom::End(0))? != 80 { return Ok(()); } // Unlike the "mp4a" atom, we cannot read the data that immediately follows it // For ALAC, we have to skip the first "alac" atom entirely, and read the one that // immediately follows it. // // We are skipping over 44 bytes total // stsd information/alac atom header (16, see `read_properties`) // First alac atom's content (28) stsd.seek(SeekFrom::Start(44))?; let Ok(Some(alac)) = stsd.next() else { return Ok(()); }; if alac.ident != AtomIdent::Fourcc(*b"alac") { return Ok(()); } properties.codec = Mp4Codec::ALAC; // Skipping 9 bytes // Version (4) // Samples per frame (4) // Compatible version (1) stsd.seek(SeekFrom::Current(9))?; // Sample size (1) let sample_size = stsd.read_u8()?; properties.bit_depth = Some(sample_size); // Skipping 3 bytes // Rice history mult (1) // Rice initial history (1) // Rice parameter limit (1) stsd.seek(SeekFrom::Current(3))?; properties.channels = stsd.read_u8()?; // Skipping 6 bytes // Max run (2) // Max frame size (4) stsd.seek(SeekFrom::Current(6))?; properties.audio_bitrate = stsd.read_u32()? / 1000; properties.sample_rate = stsd.read_u32()?; Ok(()) } fn flac_properties(stsd: &mut AtomReader, properties: &mut Mp4Properties) -> Result<()> where R: Read + Seek, { properties.codec = Mp4Codec::FLAC; // Skipping 16 bytes // // Reserved (6) // Data reference index (2) // Version (2) // Revision level (2) // Vendor (4) stsd.seek(SeekFrom::Current(16))?; properties.channels = stsd.read_u16()? as u8; properties.bit_depth = Some(stsd.read_u16()? as u8); // Skipping 4 bytes // // Compression ID (2) // Packet size (2) stsd.seek(SeekFrom::Current(4))?; properties.sample_rate = u32::from(stsd.read_u16()?); let _reserved = stsd.read_u16()?; // There should be a dfla atom, but it's not worth erroring if absent. let Some(dfla) = stsd.next()? else { return Ok(()); }; if dfla.ident != AtomIdent::Fourcc(*b"dfLa") { return Ok(()); } // Skipping 4 bytes // // Version (1) // Flags (3) stsd.seek(SeekFrom::Current(4))?; if dfla.len - 12 < 18 { // The atom isn't long enough to hold a STREAMINFO block, also not worth an error. return Ok(()); } let stream_info_block = crate::flac::block::Block::read(stsd, |_| true)?; let flac_properties = crate::flac::properties::read_properties(&mut &stream_info_block.content[..], 0, 0)?; properties.sample_rate = flac_properties.sample_rate; properties.bit_depth = Some(flac_properties.bit_depth); properties.channels = flac_properties.channels; // Bitrate values are calculated later... Ok(()) } // Used to calculate the bitrate, when it isn't readily available to us fn mdat_length(reader: &mut AtomReader) -> Result where R: Read + Seek, { reader.rewind()?; while let Ok(Some(atom)) = reader.next() { if atom.ident == AtomIdent::Fourcc(*b"mdat") { return Ok(atom.len - 8); } skip_unneeded(reader, atom.extended, atom.len)?; } decode_err!(@BAIL Mp4, "Failed to find \"mdat\" atom"); } struct Descriptor { tag: u8, _size: u32, } impl Descriptor { fn read(reader: &mut R) -> Result { let tag = reader.read_u8()?; // https://github.com/FFmpeg/FFmpeg/blob/84f5583078699e96b040f4f41b39720b683326d0/libavformat/isom.c#L283 let mut size: u32 = 0; for _ in 0..4 { let b = reader.read_u8()?; size = (size << 7) | u32::from(b & 0x7F); if b & 0x80 == 0 { break; } } Ok(Descriptor { tag, _size: size }) } } lofty-0.21.1/src/mp4/read.rs000064400000000000000000000167051046102023000136230ustar 00000000000000use super::atom_info::{AtomIdent, AtomInfo}; use super::moov::Moov; use super::properties::Mp4Properties; use super::Mp4File; use crate::config::{ParseOptions, ParsingMode}; use crate::error::{ErrorKind, LoftyError, Result}; use crate::macros::{decode_err, err}; use crate::util::io::SeekStreamLen; use crate::util::text::utf8_decode_str; use std::io::{Read, Seek, SeekFrom}; use byteorder::{BigEndian, ReadBytesExt}; pub(super) struct AtomReader where R: Read + Seek, { reader: R, start: u64, remaining_size: u64, len: u64, parse_mode: ParsingMode, } impl AtomReader where R: Read + Seek, { pub(super) fn new(mut reader: R, parse_mode: ParsingMode) -> Result { let len = reader.stream_len_hack()?; Ok(Self { reader, start: 0, remaining_size: len, len, parse_mode, }) } pub(super) fn reset_bounds(&mut self, start_position: u64, len: u64) { self.start = start_position; self.remaining_size = len; self.len = len; } pub(super) fn read_u8(&mut self) -> std::io::Result { self.remaining_size = self.remaining_size.saturating_sub(1); self.reader.read_u8() } pub(super) fn read_u16(&mut self) -> std::io::Result { self.remaining_size = self.remaining_size.saturating_sub(2); self.reader.read_u16::() } pub(super) fn read_u32(&mut self) -> std::io::Result { self.remaining_size = self.remaining_size.saturating_sub(4); self.reader.read_u32::() } pub(super) fn read_u64(&mut self) -> std::io::Result { self.remaining_size = self.remaining_size.saturating_sub(8); self.reader.read_u64::() } pub(super) fn read_uint(&mut self, size: usize) -> std::io::Result { self.remaining_size = self.remaining_size.saturating_sub(size as u64); self.reader.read_uint::(size) } pub(super) fn next(&mut self) -> Result> { if self.remaining_size == 0 { return Ok(None); } if self.remaining_size < 8 { err!(SizeMismatch); } AtomInfo::read(self, self.remaining_size, self.parse_mode) } pub(super) fn into_inner(self) -> R { self.reader } } impl Seek for AtomReader where R: Read + Seek, { fn seek(&mut self, pos: SeekFrom) -> std::io::Result { match pos { SeekFrom::Start(s) => { if s > self.len { self.remaining_size = 0; let bound_end = self.start + self.len; return self.reader.seek(SeekFrom::Start(bound_end)); } let ret = self.reader.seek(SeekFrom::Start(self.start + s))?; self.remaining_size = self.len.saturating_sub(ret); Ok(ret) }, SeekFrom::End(s) => { if s >= 0 { self.remaining_size = 0; return self.reader.seek(SeekFrom::Start(self.start + self.len)); } let bound_end = self.start + self.len; let relative_seek_count = core::cmp::min(self.len, s.unsigned_abs()); self.reader.seek(SeekFrom::Start( bound_end.saturating_sub(relative_seek_count), )) }, SeekFrom::Current(s) => { if s.is_negative() { self.remaining_size = self.remaining_size.saturating_add(s.unsigned_abs()); } else { self.remaining_size = self.remaining_size.saturating_sub(s as u64); } self.reader.seek(pos) }, } } } impl Read for AtomReader where R: Read + Seek, { fn read(&mut self, buf: &mut [u8]) -> std::io::Result { if self.remaining_size == 0 { return Ok(0); } let r = self.reader.read(buf)?; self.remaining_size = self.remaining_size.saturating_sub(r as u64); Ok(r) } } pub(in crate::mp4) fn verify_mp4(reader: &mut AtomReader) -> Result where R: Read + Seek, { let Some(atom) = reader.next()? else { err!(UnknownFormat); }; if atom.ident != AtomIdent::Fourcc(*b"ftyp") { err!(UnknownFormat); } // size + identifier + major brand // There *should* be more, but this is all we need from it if atom.len < 12 { decode_err!(@BAIL Mp4, "\"ftyp\" atom too short"); } let mut major_brand = [0u8; 4]; reader.read_exact(&mut major_brand)?; reader.seek(SeekFrom::Current((atom.len - 12) as i64))?; let major_brand = utf8_decode_str(&major_brand) .map(ToOwned::to_owned) .map_err(|_| { LoftyError::new(ErrorKind::BadAtom("Unable to parse \"ftyp\"'s major brand")) })?; log::debug!("Verified to be an MP4 file. Major brand: {}", major_brand); Ok(major_brand) } #[allow(unstable_name_collisions)] pub(crate) fn read_from(data: &mut R, parse_options: ParseOptions) -> Result where R: Read + Seek, { let mut reader = AtomReader::new(data, parse_options.parsing_mode)?; let file_length = reader.stream_len_hack()?; let ftyp = verify_mp4(&mut reader)?; // Find the `moov` atom and restrict the reader to its length let moov_info = Moov::find(&mut reader)?; reader.reset_bounds(moov_info.start + 8, moov_info.len - 8); let moov = Moov::parse(&mut reader, parse_options)?; Ok(Mp4File { ftyp, ilst_tag: moov.ilst, properties: if parse_options.read_properties { // Remove the length restriction reader.reset_bounds(0, file_length); super::properties::read_properties( &mut reader, &moov.traks, file_length, parse_options.parsing_mode, )? } else { Mp4Properties::default() }, }) } pub(super) fn skip_unneeded(reader: &mut R, extended: bool, len: u64) -> Result<()> where R: Read + Seek, { log::trace!("Attempting to skip {} bytes", len - 8); if !extended { reader.seek(SeekFrom::Current(i64::from(len as u32) - 8))?; return Ok(()); } let pos = reader.stream_position()?; if let (pos, false) = pos.overflowing_add(len - 8) { reader.seek(SeekFrom::Start(pos))?; } else { err!(TooMuchData); } Ok(()) } pub(super) fn nested_atom( reader: &mut R, mut len: u64, expected: &[u8], parse_mode: ParsingMode, ) -> Result> where R: Read + Seek, { let mut ret = None; while len > 8 { let Some(atom) = AtomInfo::read(reader, len, parse_mode)? else { break; }; match atom.ident { AtomIdent::Fourcc(ref fourcc) if fourcc == expected => { ret = Some(atom); break; }, _ => { skip_unneeded(reader, atom.extended, atom.len)?; len = len.saturating_sub(atom.len); }, } } Ok(ret) } // Creates a tree of nested atoms pub(super) fn atom_tree( reader: &mut R, mut len: u64, up_to: &[u8], parse_mode: ParsingMode, ) -> Result<(usize, Vec)> where R: Read + Seek, { let mut found_idx: usize = 0; let mut buf = Vec::new(); let mut i = 0; while len > 8 { let Some(atom) = AtomInfo::read(reader, len, parse_mode)? else { break; }; skip_unneeded(reader, atom.extended, atom.len)?; len = len.saturating_sub(atom.len); if let AtomIdent::Fourcc(ref fourcc) = atom.ident { i += 1; if fourcc == up_to { found_idx = i; } buf.push(atom); } } found_idx = found_idx.saturating_sub(1); Ok((found_idx, buf)) } pub(super) fn meta_is_full(reader: &mut R) -> Result where R: Read + Seek, { // A full `meta` atom should have the following: // // Version (1) // Flags (3) // // However, it's possible that it is written as a normal atom, // meaning this would be the size of the next atom. let _version_flags = reader.read_u32::()?; // Check if the next four bytes is one of the nested `meta` atoms let mut possible_ident = [0; 4]; reader.read_exact(&mut possible_ident)?; match &possible_ident { b"hdlr" | b"ilst" | b"mhdr" | b"ctry" | b"lang" => { log::warn!("File contains a non-full 'meta' atom"); reader.seek(SeekFrom::Current(-8))?; Ok(false) }, _ => { reader.seek(SeekFrom::Current(-4))?; Ok(true) }, } } lofty-0.21.1/src/mp4/write.rs000064400000000000000000000174161046102023000140420ustar 00000000000000use crate::config::ParsingMode; use crate::error::{LoftyError, Result}; use crate::io::{FileLike, Length, Truncate}; use crate::macros::err; use crate::mp4::atom_info::{AtomIdent, AtomInfo, IDENTIFIER_LEN}; use crate::mp4::read::{meta_is_full, skip_unneeded}; use std::cell::{RefCell, RefMut}; use std::io::{Cursor, Read, Seek, SeekFrom, Write}; use std::ops::RangeBounds; use byteorder::{BigEndian, WriteBytesExt}; /// A wrapper around [`AtomInfo`] that allows us to track all of the children of containers we deem important #[derive(Debug)] pub(super) struct ContextualAtom { pub(crate) info: AtomInfo, pub(crate) children: Vec, } const META_ATOM_IDENT: AtomIdent<'_> = AtomIdent::Fourcc(*b"meta"); #[rustfmt::skip] const IMPORTANT_CONTAINERS: &[[u8; 4]] = &[ *b"moov", *b"udta", *b"moof", *b"trak", *b"mdia", *b"minf", *b"stbl", ]; impl ContextualAtom { pub(super) fn read( reader: &mut R, reader_len: &mut u64, parse_mode: ParsingMode, ) -> Result> where R: Read + Seek, { if *reader_len == 0 { return Ok(None); } let Some(info) = AtomInfo::read(reader, *reader_len, parse_mode)? else { return Ok(None); }; match info.ident { AtomIdent::Fourcc(ident) if IMPORTANT_CONTAINERS.contains(&ident) => {}, _ => { *reader_len = reader_len.saturating_sub(info.len); // We don't care about the atom's contents skip_unneeded(reader, info.extended, info.len)?; return Ok(Some(ContextualAtom { info, children: Vec::new(), })); }, } let mut len = info.len - info.header_size(); let mut children = Vec::new(); // See meta_is_full for details if info.ident == META_ATOM_IDENT && meta_is_full(reader)? { len -= 4; } while let Some(child) = Self::read(reader, &mut len, parse_mode)? { children.push(child); } if len != 0 { // TODO: Print the container ident err!(BadAtom("Unable to read entire container")); } *reader_len = reader_len.saturating_sub(info.len); // reader.seek(SeekFrom::Current(*reader_len as i64))?; // Skip any remaining bytes Ok(Some(ContextualAtom { info, children })) } /// This finds all instances of the `expected` fourcc within the atom's children /// /// If `recurse` is `true`, then this will also search the children's children, and so on. pub(super) fn find_all_children( &self, expected: [u8; 4], recurse: bool, ) -> AtomFindAll> { AtomFindAll { atoms: self.children.iter(), expected_fourcc: expected, recurse, current_container: None, } } } /// This is a simple wrapper around a [`Cursor`] that allows us to store additional atom information /// /// The `atoms` field contains all of the atoms within the file, with containers deemed important (see `IMPORTANT_CONTAINERS`) /// being parsed recursively. We are then able to use this information to find atoms nested deeply within the file. /// /// Atoms that are not "important" containers are simply parsed at the top level, with all children being skipped. pub(super) struct AtomWriter { contents: RefCell>>, atoms: Vec, } impl AtomWriter { /// Create a new [`AtomWriter`] /// /// NOTE: This will not parse `content` for atoms. If you need to do that, use [`AtomWriter::new_from_file`] pub(super) fn new(content: Vec, _parse_mode: ParsingMode) -> Self { Self { contents: RefCell::new(Cursor::new(content)), atoms: Vec::new(), } } /// Create a new [`AtomWriter`] /// /// This will read the entire file into memory, and parse its atoms. pub(super) fn new_from_file(file: &mut F, parse_mode: ParsingMode) -> Result where F: FileLike, LoftyError: From<::Error>, LoftyError: From<::Error>, { let mut contents = Cursor::new(Vec::new()); file.read_to_end(contents.get_mut())?; let mut len = contents.get_ref().len() as u64; let mut atoms = Vec::new(); while let Some(atom) = ContextualAtom::read(&mut contents, &mut len, parse_mode)? { atoms.push(atom); } contents.rewind()?; Ok(Self { contents: RefCell::new(contents), atoms, }) } pub(super) fn find_contextual_atom(&self, fourcc: [u8; 4]) -> Option<&ContextualAtom> { self.atoms .iter() .find(|atom| matches!(atom.info.ident, AtomIdent::Fourcc(ident) if ident == fourcc)) } pub(super) fn into_contents(self) -> Vec { self.contents.into_inner().into_inner() } pub(super) fn start_write(&self) -> AtomWriterCompanion<'_> { AtomWriterCompanion { contents: self.contents.borrow_mut(), } } pub(super) fn save_to(&mut self, file: &mut F) -> Result<()> where F: FileLike, LoftyError: From<::Error>, LoftyError: From<::Error>, { file.rewind()?; file.truncate(0)?; file.write_all(self.contents.borrow().get_ref())?; Ok(()) } } /// The actual handler of the writing operations pub(super) struct AtomWriterCompanion<'a> { contents: RefMut<'a, Cursor>>, } impl AtomWriterCompanion<'_> { /// Insert a byte at the given index /// /// NOTE: This will not affect the position of the inner [`Cursor`] pub(super) fn insert(&mut self, index: usize, byte: u8) { self.contents.get_mut().insert(index, byte); } /// Replace the contents of the given range pub(super) fn splice(&mut self, range: R, replacement: I) where R: RangeBounds, I: IntoIterator, { self.contents.get_mut().splice(range, replacement); } /// Write an atom's size /// /// NOTES: /// * This expects the cursor to be at the start of the atom size /// * This will leave the cursor at the start of the atom's data pub(super) fn write_atom_size(&mut self, start: u64, size: u64, extended: bool) -> Result<()> { if u32::try_from(size).is_ok() { // ???? (identifier) self.write_u32::(size as u32)?; self.seek(SeekFrom::Current(IDENTIFIER_LEN as i64))?; return Ok(()); } // 64-bit extended size // 0001 (identifier) ???????? // Extended size indicator self.write_u32::(1)?; // Skip identifier self.seek(SeekFrom::Current(IDENTIFIER_LEN as i64))?; let extended_size = size.to_be_bytes(); if extended { // Overwrite existing extended size self.write_u64::(size)?; } else { for i in extended_size { self.insert((start + 8 + u64::from(i)) as usize, i); } self.seek(SeekFrom::Current(8))?; } Ok(()) } pub(super) fn len(&self) -> usize { self.contents.get_ref().len() } } impl<'a> Seek for AtomWriterCompanion<'a> { fn seek(&mut self, pos: SeekFrom) -> std::io::Result { self.contents.seek(pos) } } impl<'a> Read for AtomWriterCompanion<'a> { fn read(&mut self, buf: &mut [u8]) -> std::io::Result { self.contents.read(buf) } } impl<'a> Write for AtomWriterCompanion<'a> { fn write(&mut self, buf: &[u8]) -> std::io::Result { self.contents.write(buf) } fn flush(&mut self) -> std::io::Result<()> { self.contents.flush() } } pub struct AtomFindAll { atoms: I, expected_fourcc: [u8; 4], recurse: bool, current_container: Option>>, } impl<'a> Iterator for AtomFindAll> { type Item = &'a AtomInfo; fn next(&mut self) -> Option { if let Some(ref mut container) = self.current_container { match container.next() { Some(next) => { return Some(next); }, None => { self.current_container = None; }, } } loop { let atom = self.atoms.next()?; let AtomIdent::Fourcc(fourcc) = atom.info.ident else { continue; }; if fourcc == self.expected_fourcc { return Some(&atom.info); } if self.recurse { if atom.children.is_empty() { continue; } self.current_container = Some(Box::new( atom.find_all_children(self.expected_fourcc, self.recurse), )); return self.next(); } } } } lofty-0.21.1/src/mpeg/constants.rs000064400000000000000000000022301046102023000151400ustar 00000000000000pub const BITRATES: [[[u32; 16]; 3]; 2] = [ // Version 1 [ // Layer 1 [ 0, 32, 64, 96, 128, 160, 192, 224, 256, 288, 320, 352, 384, 416, 448, 0, ], // Layer 2 [ 0, 32, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384, 0, ], // Layer 3 [ 0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0, ], ], // Version 2/2.5 [ // Layer 1 [ 0, 32, 48, 56, 64, 80, 96, 112, 128, 144, 160, 176, 192, 224, 256, 0, ], // Layer 2 [ 0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0, ], // Layer 3 [ 0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0, ], ], ]; pub const SAMPLE_RATES: [[u32; 3]; 3] = [ [44100, 48000, 32000], // Version 1 [22050, 24000, 16000], // Version 2 [11025, 12000, 8000], // Version 2.5 ]; pub const SAMPLES: [[u16; 2]; 3] = [ // Order: // [Version 1, Version 2/2.5] // Layer 1 // Layer 2 // Layer 3 [384, 384], [1152, 1152], [1152, 576], ]; pub const SIDE_INFORMATION_SIZES: [[u32; 4]; 3] = [ [32, 32, 32, 17], // Version 1 [17, 17, 17, 9], // Version 2 [17, 17, 17, 9], // Version 2.5 ]; pub const PADDING_SIZES: [u8; 3] = [4, 1, 1]; lofty-0.21.1/src/mpeg/header.rs000064400000000000000000000263461046102023000143720ustar 00000000000000use super::constants::{BITRATES, PADDING_SIZES, SAMPLES, SAMPLE_RATES, SIDE_INFORMATION_SIZES}; use crate::error::Result; use crate::macros::decode_err; use std::io::{Read, Seek, SeekFrom}; use byteorder::{BigEndian, ReadBytesExt}; pub(crate) fn verify_frame_sync(frame_sync: [u8; 2]) -> bool { frame_sync[0] == 0xFF && frame_sync[1] >> 5 == 0b111 } // Searches for a frame sync (11 set bits) in the reader. // The search starts at the beginning of the reader and returns the index relative to this beginning. // This will return the first match, if one is found. // // Note that the search searches in 8 bit steps, i.e. the first 8 bits need to be byte aligned. pub(crate) fn search_for_frame_sync(input: &mut R) -> std::io::Result> where R: Read, { let mut iterator = input.bytes(); let mut buffer = [0u8; 2]; // Read the first byte, as each iteration expects that buffer 0 was set from a previous iteration. // This is not the case in the first iteration, which is therefore a special case. if let Some(byte) = iterator.next() { buffer[0] = byte?; } // Create a stream of overlapping 2 byte pairs // // Example: // [0x01, 0x02, 0x03, 0x04] should be analyzed as // [0x01, 0x02], [0x02, 0x03], [0x03, 0x04] for (index, byte) in iterator.enumerate() { buffer[1] = byte?; // Check the two bytes in the buffer if verify_frame_sync(buffer) { return Ok(Some(index as u64)); } // If they do not match, copy the last byte in the buffer to the front for the next iteration buffer[0] = buffer[1]; } Ok(None) } // If we need to find the last frame offset (the file has no Xing/LAME/VBRI header) // // This will search up to 1024 bytes preceding the APE tag/ID3v1/EOF. // Unlike `search_for_frame_sync`, since this has the `Seek` bound, it will seek the reader // back to the start of the header. const REV_FRAME_SEARCH_BOUNDS: u64 = 1024; pub(super) fn rev_search_for_frame_header(input: &mut R, pos: &mut u64) -> Result> where R: Read + Seek, { let search_bounds = std::cmp::min(*pos, REV_FRAME_SEARCH_BOUNDS); *pos -= search_bounds; input.seek(SeekFrom::Start(*pos))?; let mut buf = Vec::with_capacity(search_bounds as usize); input.take(search_bounds).read_to_end(&mut buf)?; let mut frame_sync = [0u8; 2]; for (i, byte) in buf.iter().rev().enumerate() { frame_sync[1] = frame_sync[0]; frame_sync[0] = *byte; if !verify_frame_sync(frame_sync) { continue; } let relative_frame_start = (search_bounds as usize) - (i + 1); if relative_frame_start + 4 > buf.len() { continue; } let header = Header::read(u32::from_be_bytes([ frame_sync[0], frame_sync[1], buf[relative_frame_start + 2], buf[relative_frame_start + 3], ])); // We need to check if the header is actually valid. For // all we know, we could be in some junk (ex. 0xFF_FF_FF_FF). if header.is_none() { continue; } // Seek to the start of the frame sync *pos += relative_frame_start as u64; input.seek(SeekFrom::Start(*pos))?; return Ok(header); } Ok(None) } /// See [`cmp_header()`]. pub(crate) enum HeaderCmpResult { Equal, Undetermined, NotEqual, } // Used to compare the versions, layers, and sample rates of two frame headers. // If they aren't equal, something is broken. pub(super) const HEADER_MASK: u32 = 0xFFFE_0C00; /// Compares the versions, layers, and sample rates of two frame headers. /// /// It is safe to assume that the reader will no longer produce valid headers if [`HeaderCmpResult::Undetermined`] /// is returned. /// /// To compare two already constructed [`Header`]s, use [`Header::cmp()`]. /// /// ## Returns /// /// - [`HeaderCmpResult::Equal`] if the headers are equal. /// - [`HeaderCmpResult::NotEqual`] if the headers are not equal. /// - [`HeaderCmpResult::Undetermined`] if the comparison could not be made (Some IO error occurred). pub(crate) fn cmp_header( reader: &mut R, header_size: u32, first_header_len: u32, first_header_bytes: u32, header_mask: u32, ) -> HeaderCmpResult where R: Read + Seek, { // Read the next header and see if they are the same let res = reader.seek(SeekFrom::Current(i64::from( first_header_len.saturating_sub(header_size), ))); if res.is_err() { return HeaderCmpResult::Undetermined; } let second_header_data = reader.read_u32::(); if second_header_data.is_err() { return HeaderCmpResult::Undetermined; } if reader.seek(SeekFrom::Current(-4)).is_err() { return HeaderCmpResult::Undetermined; } match second_header_data { Ok(second_header_data) if first_header_bytes & header_mask == second_header_data & header_mask => { HeaderCmpResult::Equal }, _ => HeaderCmpResult::NotEqual, } } /// MPEG Audio version #[derive(Default, PartialEq, Eq, Copy, Clone, Debug)] #[allow(missing_docs)] pub enum MpegVersion { #[default] V1, V2, V2_5, /// Exclusive to AAC V4, } /// MPEG layer #[derive(Default, Copy, Clone, Debug, PartialEq, Eq)] #[allow(missing_docs)] pub enum Layer { Layer1 = 1, Layer2 = 2, #[default] Layer3 = 3, } /// Channel mode #[derive(Default, Copy, Clone, PartialEq, Eq, Debug)] #[allow(missing_docs)] pub enum ChannelMode { #[default] Stereo = 0, JointStereo = 1, /// Two independent mono channels DualChannel = 2, SingleChannel = 3, } /// A rarely-used decoder hint that the file must be de-emphasized #[derive(Copy, Clone, PartialEq, Eq, Debug)] #[allow(missing_docs, non_camel_case_types)] pub enum Emphasis { /// 50/15 ms MS5015, Reserved, /// CCIT J.17 CCIT_J17, } #[derive(Copy, Clone, Debug)] pub(crate) struct Header { pub(crate) sample_rate: u32, pub(crate) len: u32, pub(crate) data_start: u32, pub(crate) samples: u16, pub(crate) bitrate: u32, pub(crate) version: MpegVersion, pub(crate) layer: Layer, pub(crate) channel_mode: ChannelMode, pub(crate) mode_extension: Option, pub(crate) copyright: bool, pub(crate) original: bool, pub(crate) emphasis: Option, } impl Header { pub(super) fn read(data: u32) -> Option { let version = match (data >> 19) & 0b11 { 0b00 => MpegVersion::V2_5, 0b10 => MpegVersion::V2, 0b11 => MpegVersion::V1, _ => return None, }; let version_index = if version == MpegVersion::V1 { 0 } else { 1 }; let layer = match (data >> 17) & 0b11 { 0b01 => Layer::Layer3, 0b10 => Layer::Layer2, 0b11 => Layer::Layer1, _ => { log::debug!("MPEG: Frame header uses a reserved layer"); return None; }, }; let mut header = Header { sample_rate: 0, len: 0, data_start: 0, samples: 0, bitrate: 0, version, layer, channel_mode: ChannelMode::default(), mode_extension: None, copyright: false, original: false, emphasis: None, }; let layer_index = (header.layer as usize).saturating_sub(1); let bitrate_index = (data >> 12) & 0xF; header.bitrate = BITRATES[version_index][layer_index][bitrate_index as usize]; if header.bitrate == 0 { return None; } // Sample rate index let sample_rate_index = (data >> 10) & 0b11; header.sample_rate = match sample_rate_index { // This is invalid 0b11 => return None, _ => SAMPLE_RATES[header.version as usize][sample_rate_index as usize], }; let has_padding = ((data >> 9) & 1) == 1; let mut padding = 0; if has_padding { padding = u32::from(PADDING_SIZES[layer_index]); } header.channel_mode = match (data >> 6) & 0b11 { 0b00 => ChannelMode::Stereo, 0b01 => ChannelMode::JointStereo, 0b10 => ChannelMode::DualChannel, 0b11 => ChannelMode::SingleChannel, _ => unreachable!(), }; if let ChannelMode::JointStereo = header.channel_mode { header.mode_extension = Some(((data >> 4) & 3) as u8); } else { header.mode_extension = None; } header.copyright = ((data >> 3) & 1) == 1; header.original = ((data >> 2) & 1) == 1; header.emphasis = match data & 0b11 { 0b00 => None, 0b01 => Some(Emphasis::MS5015), 0b10 => Some(Emphasis::Reserved), 0b11 => Some(Emphasis::CCIT_J17), _ => unreachable!(), }; header.data_start = SIDE_INFORMATION_SIZES[version_index][header.channel_mode as usize] + 4; header.samples = SAMPLES[layer_index][version_index]; header.len = (u32::from(header.samples) * header.bitrate * 125 / header.sample_rate) + padding; Some(header) } /// Equivalent of [`cmp_header()`], but for an already constructed `Header`. pub(super) fn cmp(self, other: &Self) -> bool { self.version == other.version && self.layer == other.layer && self.sample_rate == other.sample_rate } } #[derive(Copy, Clone)] pub(super) enum VbrHeaderType { Xing, Info, Vbri, } #[derive(Copy, Clone)] pub(super) struct VbrHeader { pub ty: VbrHeaderType, pub frames: u32, pub size: u32, } impl VbrHeader { pub(super) fn read(reader: &mut &[u8]) -> Result> { let reader_len = reader.len(); let mut header = [0; 4]; reader.read_exact(&mut header)?; match &header { b"Xing" | b"Info" => { if reader_len < 16 { decode_err!(@BAIL Mpeg, "Xing header has an invalid size (< 16)"); } let mut flags = [0; 4]; reader.read_exact(&mut flags)?; if flags[3] & 0x03 != 0x03 { log::debug!( "MPEG: Xing header doesn't have required flags set (0x0001 and 0x0002)" ); return Ok(None); } let frames = reader.read_u32::()?; let size = reader.read_u32::()?; let ty = match &header { b"Xing" => VbrHeaderType::Xing, b"Info" => VbrHeaderType::Info, _ => unreachable!(), }; Ok(Some(Self { ty, frames, size })) }, b"VBRI" => { if reader_len < 32 { decode_err!(@BAIL Mpeg, "VBRI header has an invalid size (< 32)"); } // Skip 6 bytes // Version ID (2) // Delay float (2) // Quality indicator (2) let _info = reader.read_uint::(6)?; let size = reader.read_u32::()?; let frames = reader.read_u32::()?; Ok(Some(Self { ty: VbrHeaderType::Vbri, frames, size, })) }, _ => Ok(None), } } pub(super) fn is_valid(&self) -> bool { self.frames > 0 && self.size > 0 } } #[cfg(test)] mod tests { use crate::tag::utils::test_utils::read_path; use std::io::{Cursor, Read, Seek, SeekFrom}; #[test] fn search_for_frame_sync() { fn test(data: &[u8], expected_result: Option) { use super::search_for_frame_sync; assert_eq!(search_for_frame_sync(&mut &*data).unwrap(), expected_result); } test(&[0xFF, 0xFB, 0x00], Some(0)); test(&[0x00, 0x00, 0x01, 0xFF, 0xFB], Some(3)); test(&[0x01, 0xFF], None); } #[test] #[rustfmt::skip] fn rev_search_for_frame_header() { fn test(reader: &mut R, expected_reader_position: Option) { // We have to start these at the end to do a reverse search, of course :) let mut pos = reader.seek(SeekFrom::End(0)).unwrap(); let ret = super::rev_search_for_frame_header(reader, &mut pos); if expected_reader_position.is_some() { assert!(ret.is_ok()); assert!(ret.unwrap().is_some()); assert_eq!(Some(pos), expected_reader_position); return; } assert!(ret.unwrap().is_none()); } test(&mut Cursor::new([0xFF, 0xFB, 0x52, 0xC4]), Some(0)); test(&mut Cursor::new([0x00, 0x00, 0x01, 0xFF, 0xFB, 0x52, 0xC4]), Some(3)); test(&mut Cursor::new([0x01, 0xFF]), None); let bytes = read_path("tests/files/assets/rev_frame_sync_search.mp3"); let mut reader = Cursor::new(bytes); test(&mut reader, Some(595)); } } lofty-0.21.1/src/mpeg/mod.rs000064400000000000000000000014611046102023000137100ustar 00000000000000//! MP3 specific items mod constants; pub(crate) mod header; mod properties; mod read; pub use header::{ChannelMode, Emphasis, Layer, MpegVersion}; pub use properties::MpegProperties; use crate::ape::tag::ApeTag; use crate::id3::v1::tag::Id3v1Tag; use crate::id3::v2::tag::Id3v2Tag; use lofty_attr::LoftyFile; /// An MPEG file #[derive(LoftyFile, Default)] #[lofty(read_fn = "read::read_from")] #[lofty(internal_write_module_do_not_use_anywhere_else)] pub struct MpegFile { /// An ID3v2 tag #[lofty(tag_type = "Id3v2")] pub(crate) id3v2_tag: Option, /// An ID3v1 tag #[lofty(tag_type = "Id3v1")] pub(crate) id3v1_tag: Option, /// An APEv1/v2 tag #[lofty(tag_type = "Ape")] pub(crate) ape_tag: Option, /// The file's audio properties pub(crate) properties: MpegProperties, } lofty-0.21.1/src/mpeg/properties.rs000064400000000000000000000140011046102023000153170ustar 00000000000000use super::header::{ChannelMode, Emphasis, Header, Layer, MpegVersion, VbrHeader, VbrHeaderType}; use crate::error::Result; use crate::mpeg::header::rev_search_for_frame_header; use crate::properties::{ChannelMask, FileProperties}; use crate::util::math::RoundedDivision; use std::io::{Read, Seek, SeekFrom}; use std::time::Duration; /// An MPEG file's audio properties #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] #[non_exhaustive] pub struct MpegProperties { pub(crate) version: MpegVersion, pub(crate) layer: Layer, pub(crate) duration: Duration, pub(crate) overall_bitrate: u32, pub(crate) audio_bitrate: u32, pub(crate) sample_rate: u32, pub(crate) channels: u8, pub(crate) channel_mode: ChannelMode, pub(crate) mode_extension: Option, pub(crate) copyright: bool, pub(crate) original: bool, pub(crate) emphasis: Option, } impl From for FileProperties { fn from(input: MpegProperties) -> Self { let MpegProperties { duration, overall_bitrate, audio_bitrate, sample_rate, channels, channel_mode, version: _, layer: _, copyright: _, emphasis: _, mode_extension: _, original: _, } = input; let channel_mask = match channel_mode { ChannelMode::SingleChannel => Some(ChannelMask::mono()), ChannelMode::Stereo | ChannelMode::JointStereo => Some(ChannelMask::stereo()), ChannelMode::DualChannel => None, // Cannot be represented by ChannelMask }; Self { duration, overall_bitrate: Some(overall_bitrate), audio_bitrate: Some(audio_bitrate), sample_rate: Some(sample_rate), bit_depth: None, channels: Some(channels), channel_mask, } } } impl MpegProperties { /// Duration of the audio pub fn duration(&self) -> Duration { self.duration } /// Overall bitrate (kbps) pub fn overall_bitrate(&self) -> u32 { self.overall_bitrate } /// Audio bitrate (kbps) pub fn audio_bitrate(&self) -> u32 { self.audio_bitrate } /// Sample rate (Hz) pub fn sample_rate(&self) -> u32 { self.sample_rate } /// Channel count pub fn channels(&self) -> u8 { self.channels } /// MPEG version pub fn version(&self) -> &MpegVersion { &self.version } /// MPEG layer pub fn layer(&self) -> &Layer { &self.layer } /// MPEG channel mode pub fn channel_mode(&self) -> &ChannelMode { &self.channel_mode } /// A channel mode extension specifically for [`ChannelMode::JointStereo`] pub fn mode_extension(&self) -> Option { self.mode_extension } /// Whether the audio is copyrighted pub fn is_copyright(&self) -> bool { self.copyright } /// Whether the media is original or a copy pub fn is_original(&self) -> bool { self.original } /// See [`Emphasis`] pub fn emphasis(&self) -> Option { self.emphasis } } pub(super) fn read_properties( properties: &mut MpegProperties, reader: &mut R, first_frame: (Header, u64), mut last_frame_offset: u64, vbr_header: Option, file_length: u64, ) -> Result<()> where R: Read + Seek, { let first_frame_header = first_frame.0; let first_frame_offset = first_frame.1; properties.version = first_frame_header.version; properties.layer = first_frame_header.layer; properties.channel_mode = first_frame_header.channel_mode; properties.mode_extension = first_frame_header.mode_extension; properties.copyright = first_frame_header.copyright; properties.original = first_frame_header.original; properties.emphasis = first_frame_header.emphasis; properties.sample_rate = first_frame_header.sample_rate; properties.channels = if first_frame_header.channel_mode == ChannelMode::SingleChannel { 1 } else { 2 }; if let Some(vbr_header) = vbr_header { if first_frame_header.sample_rate > 0 && vbr_header.is_valid() { log::debug!("MPEG: Valid VBR header; using it to calculate duration"); let sample_rate = u64::from(first_frame_header.sample_rate); let samples_per_frame = u64::from(first_frame_header.samples); let total_frames = u64::from(vbr_header.frames); let length = (samples_per_frame * 1000 * total_frames).div_round(sample_rate); properties.duration = Duration::from_millis(length); properties.overall_bitrate = ((file_length * 8) / length) as u32; properties.audio_bitrate = ((u64::from(vbr_header.size) * 8) / length) as u32; return Ok(()); } } // Nothing more we can do if first_frame_header.bitrate == 0 { return Ok(()); } log::warn!("MPEG: Using bitrate to estimate duration"); // http://gabriel.mp3-tech.org/mp3infotag.html: // // "In the Info Tag, the "Xing" identification string (mostly at 0x24) of the header is replaced by "Info" in case of a CBR file." let is_cbr = matches!(vbr_header.map(|h| h.ty), Some(VbrHeaderType::Info)); if is_cbr { log::debug!("MPEG: CBR detected"); properties.audio_bitrate = first_frame_header.bitrate; } // Search for the last frame, starting at the end of the frames reader.seek(SeekFrom::Start(last_frame_offset))?; let mut last_frame = None; let mut pos = last_frame_offset; while pos > 0 { match rev_search_for_frame_header(reader, &mut pos) { // Found a frame header Ok(Some(header)) => { // Move `last_frame_offset` back to the actual position last_frame_offset = pos; if header.cmp(&first_frame_header) { last_frame = Some(header); break; } }, // Encountered some IO error, just break Err(_) => break, // No frame sync found, continue further back in the file _ => {}, } } let Some(last_frame_header) = last_frame else { log::warn!("MPEG: Could not find last frame, properties will be incomplete"); return Ok(()); }; let stream_len = (last_frame_offset + u64::from(last_frame_header.len)) - first_frame_offset; if !is_cbr { log::debug!("MPEG: VBR detected"); // TODO: Actually handle VBR streams, this still assumes CBR properties.audio_bitrate = first_frame_header.bitrate; } let length = (stream_len * 8).div_round(u64::from(properties.audio_bitrate)); if length > 0 { properties.overall_bitrate = ((file_length * 8) / length) as u32; properties.duration = Duration::from_millis(length); } Ok(()) } lofty-0.21.1/src/mpeg/read.rs000064400000000000000000000154531046102023000140520ustar 00000000000000use super::header::{cmp_header, search_for_frame_sync, Header, HeaderCmpResult, VbrHeader}; use super::{MpegFile, MpegProperties}; use crate::ape::header::read_ape_header; use crate::config::{ParseOptions, ParsingMode}; use crate::error::Result; use crate::id3::v2::header::Id3v2Header; use crate::id3::v2::read::parse_id3v2; use crate::id3::{find_id3v1, find_lyrics3v2, FindId3v2Config, ID3FindResults}; use crate::io::SeekStreamLen; use crate::macros::{decode_err, err}; use crate::mpeg::header::HEADER_MASK; use std::io::{Read, Seek, SeekFrom}; use byteorder::{BigEndian, ReadBytesExt}; pub(super) fn read_from(reader: &mut R, parse_options: ParseOptions) -> Result where R: Read + Seek, { let mut file = MpegFile::default(); let mut first_frame_offset = 0; let mut first_frame_header = None; // Skip any invalid padding while reader.read_u8()? == 0 {} reader.seek(SeekFrom::Current(-1))?; let mut header = [0; 4]; while let Ok(()) = reader.read_exact(&mut header) { match header { // [I, D, 3, ver_major, ver_minor, flags, size (4 bytes)] // // Best case scenario, we find an ID3v2 tag at the beginning of the file. // We will check again after finding the frame sync, in case the tag is buried in junk. [b'I', b'D', b'3', ..] => { // Seek back to read the tag in full reader.seek(SeekFrom::Current(-4))?; let header = Id3v2Header::parse(reader)?; let skip_footer = header.flags.footer; if parse_options.read_tags { let id3v2 = parse_id3v2(reader, header, parse_options)?; if let Some(existing_tag) = &mut file.id3v2_tag { // https://github.com/Serial-ATA/lofty-rs/issues/87 // Duplicate tags should have their frames appended to the previous for frame in id3v2.frames { existing_tag.insert(frame); } continue; } file.id3v2_tag = Some(id3v2); } else { reader.seek(SeekFrom::Current(i64::from(header.size)))?; } // Skip over the footer if skip_footer { reader.seek(SeekFrom::Current(10))?; } continue; }, // TODO: APE tags may suffer the same issue as ID3v2 tag described above. // They are not nearly as important to preserve, however. [b'A', b'P', b'E', b'T'] => { log::warn!( "Encountered an APE tag at the beginning of the file, attempting to read" ); let mut header_remaining = [0; 4]; reader.read_exact(&mut header_remaining)?; if &header_remaining == b"AGEX" { let ape_header = read_ape_header(reader, false)?; if parse_options.read_tags { file.ape_tag = Some(crate::ape::tag::read::read_ape_tag_with_header( reader, ape_header, parse_options, )?); } else { reader.seek(SeekFrom::Current(i64::from(ape_header.size)))?; } continue; } err!(FakeTag); }, // Tags might be followed by junk bytes before the first MP3 frame begins _ => { // Seek back the length of the temporary header buffer, to include them // in the frame sync search #[allow(clippy::neg_multiply)] reader.seek(SeekFrom::Current(-1 * header.len() as i64))?; let Some((_first_frame_header, _first_frame_offset)) = find_next_frame(reader)? else { break; }; if file.id3v2_tag.is_none() && parse_options.parsing_mode != ParsingMode::Strict && _first_frame_offset > 0 { reader.seek(SeekFrom::Start(0))?; let search_window_size = std::cmp::min(_first_frame_offset, parse_options.max_junk_bytes as u64); let config = FindId3v2Config { read: parse_options.read_tags, allowed_junk_window: Some(search_window_size), }; if let ID3FindResults(Some(header), Some(id3v2_bytes)) = crate::id3::find_id3v2(reader, config)? { let reader = &mut &*id3v2_bytes; let id3v2 = parse_id3v2(reader, header, parse_options)?; if let Some(existing_tag) = &mut file.id3v2_tag { // https://github.com/Serial-ATA/lofty-rs/issues/87 // Duplicate tags should have their frames appended to the previous for frame in id3v2.frames { existing_tag.insert(frame); } continue; } file.id3v2_tag = Some(id3v2); } } first_frame_offset = _first_frame_offset; first_frame_header = Some(_first_frame_header); break; }, } } #[allow(unused_variables)] let ID3FindResults(header, id3v1) = find_id3v1(reader, parse_options.read_tags)?; if header.is_some() { file.id3v1_tag = id3v1; } let _ = find_lyrics3v2(reader)?; reader.seek(SeekFrom::Current(-32))?; match crate::ape::tag::read::read_ape_tag(reader, true, parse_options)? { (tag, Some(header)) => { file.ape_tag = tag; // Seek back to the start of the tag let pos = reader.stream_position()?; reader.seek(SeekFrom::Start(pos - u64::from(header.size)))?; }, _ => { // Correct the position (APE header - Preamble) reader.seek(SeekFrom::Current(24))?; }, } let last_frame_offset = reader.stream_position()?; file.properties = MpegProperties::default(); if parse_options.read_properties { let Some(first_frame_header) = first_frame_header else { // The search for sync bits was unsuccessful decode_err!(@BAIL Mpeg, "File contains an invalid frame"); }; if first_frame_header.sample_rate == 0 { decode_err!(@BAIL Mpeg, "Sample rate is 0"); } let first_frame_offset = first_frame_offset; // Try to read a Xing header let xing_header_location = first_frame_offset + u64::from(first_frame_header.data_start); reader.seek(SeekFrom::Start(xing_header_location))?; let mut xing_reader = [0; 32]; reader.read_exact(&mut xing_reader)?; let xing_header = VbrHeader::read(&mut &xing_reader[..])?; let file_length = reader.stream_len_hack()?; super::properties::read_properties( &mut file.properties, reader, (first_frame_header, first_frame_offset), last_frame_offset, xing_header, file_length, )?; } Ok(file) } // Searches for the next frame, comparing it to the following one fn find_next_frame(reader: &mut R) -> Result> where R: Read + Seek, { let mut pos = reader.stream_position()?; while let Ok(Some(first_mp3_frame_start_relative)) = search_for_frame_sync(reader) { let first_mp3_frame_start_absolute = pos + first_mp3_frame_start_relative; // Seek back to the start of the frame and read the header reader.seek(SeekFrom::Start(first_mp3_frame_start_absolute))?; let first_header_data = reader.read_u32::()?; if let Some(first_header) = Header::read(first_header_data) { match cmp_header(reader, 4, first_header.len, first_header_data, HEADER_MASK) { HeaderCmpResult::Equal => { return Ok(Some((first_header, first_mp3_frame_start_absolute))) }, HeaderCmpResult::Undetermined => return Ok(None), HeaderCmpResult::NotEqual => {}, } } pos = reader.stream_position()?; } Ok(None) } lofty-0.21.1/src/musepack/constants.rs000064400000000000000000000012041046102023000160200ustar 00000000000000//! MusePack constants // There are only 4 frequencies defined in the spec, but there are 8 possible indices in the header. // // The reference decoder defines the table as: // // static const mpc_int32_t samplefreqs[8] = { 44100, 48000, 37800, 32000 }; // // So it's safe to just fill the rest with zeroes pub(super) const FREQUENCY_TABLE: [u32; 8] = [44100, 48000, 37800, 32000, 0, 0, 0, 0]; // Taken from mpcdec /// This is the gain reference used in old ReplayGain pub const MPC_OLD_GAIN_REF: f32 = 64.82; pub(super) const MPC_DECODER_SYNTH_DELAY: u64 = 481; pub(super) const MPC_FRAME_LENGTH: u64 = 36 * 32; // Samples per mpc frame lofty-0.21.1/src/musepack/mod.rs000064400000000000000000000036641046102023000145770ustar 00000000000000//! Musepack specific items pub mod constants; mod read; pub mod sv4to6; pub mod sv7; pub mod sv8; use crate::ape::tag::ApeTag; use crate::id3::v1::tag::Id3v1Tag; use crate::id3::v2::tag::Id3v2Tag; use crate::properties::FileProperties; use lofty_attr::LoftyFile; /// Audio properties of an MPC file /// /// The information available differs between stream versions #[derive(Debug, Clone, PartialEq)] pub enum MpcProperties { /// MPC stream version 8 properties Sv8(sv8::MpcSv8Properties), /// MPC stream version 7 properties Sv7(sv7::MpcSv7Properties), /// MPC stream version 4-6 properties Sv4to6(sv4to6::MpcSv4to6Properties), } impl Default for MpcProperties { fn default() -> Self { Self::Sv8(sv8::MpcSv8Properties::default()) } } impl From for FileProperties { fn from(input: MpcProperties) -> Self { match input { MpcProperties::Sv8(sv8prop) => sv8prop.into(), MpcProperties::Sv7(sv7prop) => sv7prop.into(), MpcProperties::Sv4to6(sv4to6prop) => sv4to6prop.into(), } } } /// The version of the MPC stream #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum MpcStreamVersion { /// Stream version 8 #[default] Sv8, /// Stream version 7 Sv7, /// Stream version 4 to 6 Sv4to6, } /// An MPC file #[derive(LoftyFile, Default)] #[lofty(read_fn = "read::read_from")] #[lofty(internal_write_module_do_not_use_anywhere_else)] pub struct MpcFile { /// The stream version pub(crate) stream_version: MpcStreamVersion, /// An ID3v2 tag (Not officially supported) #[lofty(tag_type = "Id3v2")] pub(crate) id3v2_tag: Option, /// An ID3v1 tag #[lofty(tag_type = "Id3v1")] pub(crate) id3v1_tag: Option, /// An APEv1/v2 tag #[lofty(tag_type = "Ape")] pub(crate) ape_tag: Option, /// The file's audio properties pub(crate) properties: MpcProperties, } impl MpcFile { /// The version of the MPC stream pub fn stream_version(&self) -> MpcStreamVersion { self.stream_version } } lofty-0.21.1/src/musepack/read.rs000064400000000000000000000072751046102023000147350ustar 00000000000000use super::sv4to6::MpcSv4to6Properties; use super::sv7::MpcSv7Properties; use super::sv8::MpcSv8Properties; use super::{MpcFile, MpcProperties, MpcStreamVersion}; use crate::config::ParseOptions; use crate::error::Result; use crate::id3::v2::read::parse_id3v2; use crate::id3::{find_id3v1, find_id3v2, find_lyrics3v2, FindId3v2Config, ID3FindResults}; use crate::macros::err; use crate::util::io::SeekStreamLen; use std::io::{Read, Seek, SeekFrom}; pub(super) fn read_from(reader: &mut R, parse_options: ParseOptions) -> Result where R: Read + Seek, { log::debug!("Attempting to read MPC file"); // Sv4to6 is the default, as it doesn't have a marker like Sv8's b'MPCK' or Sv7's b'MP+' let mut version = MpcStreamVersion::Sv4to6; let mut file = MpcFile::default(); let mut stream_length = reader.stream_len_hack()?; let find_id3v2_config = if parse_options.read_tags { FindId3v2Config::READ_TAG } else { FindId3v2Config::NO_READ_TAG }; // ID3v2 tags are unsupported in MPC files, but still possible #[allow(unused_variables)] if let ID3FindResults(Some(header), Some(content)) = find_id3v2(reader, find_id3v2_config)? { let reader = &mut &*content; let id3v2 = parse_id3v2(reader, header, parse_options)?; file.id3v2_tag = Some(id3v2); stream_length -= u64::from(header.full_tag_size()); } // Save the current position, so we can go back and read the properties after the tags let pos_past_id3v2 = reader.stream_position()?; #[allow(unused_variables)] let ID3FindResults(header, id3v1) = find_id3v1(reader, parse_options.read_tags)?; if header.is_some() { file.id3v1_tag = id3v1; let Some(new_stream_length) = stream_length.checked_sub(128) else { err!(SizeMismatch); }; stream_length = new_stream_length; } let ID3FindResults(_, lyrics3v2_size) = find_lyrics3v2(reader)?; let Some(new_stream_length) = stream_length.checked_sub(u64::from(lyrics3v2_size)) else { err!(SizeMismatch); }; stream_length = new_stream_length; reader.seek(SeekFrom::Current(-32))?; if let (tag, Some(header)) = crate::ape::tag::read::read_ape_tag(reader, true, parse_options)? { file.ape_tag = tag; // Seek back to the start of the tag let pos = reader.stream_position()?; let tag_size = u64::from(header.size); let Some(tag_start) = pos.checked_sub(tag_size) else { err!(SizeMismatch); }; reader.seek(SeekFrom::Start(tag_start))?; let Some(new_stream_length) = stream_length.checked_sub(tag_size) else { err!(SizeMismatch); }; stream_length = new_stream_length; } // Restore the position of the magic signature reader.seek(SeekFrom::Start(pos_past_id3v2))?; let mut header = [0; 4]; reader.read_exact(&mut header)?; match &header { b"MPCK" => { log::debug!("MPC stream version determined to be 8"); version = MpcStreamVersion::Sv8; }, [b'M', b'P', b'+', ..] => { log::debug!("MPC stream version determined to be 7"); // Seek back the extra byte we read reader.seek(SeekFrom::Current(-1))?; version = MpcStreamVersion::Sv7; }, _ => { log::warn!("MPC stream version could not be determined, assuming 4-6"); // We should be reading into the actual content now, seek back reader.seek(SeekFrom::Current(-4))?; }, } if parse_options.read_properties { match version { MpcStreamVersion::Sv8 => { file.properties = MpcProperties::Sv8(MpcSv8Properties::read(reader, parse_options.parsing_mode)?) }, MpcStreamVersion::Sv7 => { file.properties = MpcProperties::Sv7(MpcSv7Properties::read(reader, stream_length)?) }, MpcStreamVersion::Sv4to6 => { file.properties = MpcProperties::Sv4to6(MpcSv4to6Properties::read( reader, parse_options.parsing_mode, stream_length, )?) }, } } Ok(file) } lofty-0.21.1/src/musepack/sv4to6/mod.rs000064400000000000000000000001261046102023000157320ustar 00000000000000//! Musepack stream versions 4-6 mod properties; // Exports pub use properties::*; lofty-0.21.1/src/musepack/sv4to6/properties.rs000064400000000000000000000102711046102023000173510ustar 00000000000000use crate::config::ParsingMode; use crate::error::Result; use crate::macros::decode_err; use crate::musepack::constants::{MPC_DECODER_SYNTH_DELAY, MPC_FRAME_LENGTH}; use crate::properties::FileProperties; use crate::util::math::RoundedDivision; use std::io::Read; use std::time::Duration; use byteorder::{LittleEndian, ReadBytesExt}; /// MPC stream versions 4-6 audio properties #[derive(Debug, Clone, PartialEq, Default)] pub struct MpcSv4to6Properties { pub(crate) duration: Duration, pub(crate) channels: u8, // NOTE: always 2 pub(crate) sample_rate: u32, // NOTE: always 44100 // Fields actually contained in the header pub(crate) average_bitrate: u32, pub(crate) mid_side_stereo: bool, pub(crate) stream_version: u16, pub(crate) max_band: u8, pub(crate) frame_count: u32, } impl From for FileProperties { fn from(input: MpcSv4to6Properties) -> Self { Self { duration: input.duration, overall_bitrate: Some(input.average_bitrate), audio_bitrate: Some(input.average_bitrate), sample_rate: Some(input.sample_rate), bit_depth: None, channels: Some(input.channels), channel_mask: None, } } } impl MpcSv4to6Properties { /// Duration of the audio pub fn duration(&self) -> Duration { self.duration } /// Channel count pub fn channels(&self) -> u8 { self.channels } /// Sample rate (Hz) pub fn sample_rate(&self) -> u32 { self.sample_rate } /// Average bitrate (kbps) pub fn average_bitrate(&self) -> u32 { self.average_bitrate } /// Whether MidSideStereo is used pub fn mid_side_stereo(&self) -> bool { self.mid_side_stereo } /// The MPC stream version (4-6) pub fn stream_version(&self) -> u16 { self.stream_version } /// Last subband used in the whole file pub fn max_band(&self) -> u8 { self.max_band } /// Total number of audio frames pub fn frame_count(&self) -> u32 { self.frame_count } pub(crate) fn read( reader: &mut R, parse_mode: ParsingMode, stream_length: u64, ) -> Result where R: Read, { let mut header_data = [0u32; 8]; reader.read_u32_into::(&mut header_data)?; let mut properties = Self::default(); properties.average_bitrate = (header_data[0] >> 23) & 0x1FF; let intensity_stereo = (header_data[0] >> 22) & 0x1 == 1; properties.mid_side_stereo = (header_data[0] >> 21) & 0x1 == 1; properties.stream_version = ((header_data[0] >> 11) & 0x03FF) as u16; if !(4..=6).contains(&properties.stream_version) { decode_err!(@BAIL Mpc, "Invalid stream version encountered") } properties.max_band = ((header_data[0] >> 6) & 0x1F) as u8; let block_size = header_data[0] & 0x3F; if properties.stream_version >= 5 { properties.frame_count = header_data[1]; // 32 bit } else { properties.frame_count = header_data[1] >> 16; // 16 bit } if parse_mode == ParsingMode::Strict { if properties.average_bitrate != 0 { decode_err!(@BAIL Mpc, "Encountered CBR stream") } if intensity_stereo { decode_err!(@BAIL Mpc, "Stream uses intensity stereo coding") } if block_size != 1 { decode_err!(@BAIL Mpc, "Stream has an invalid block size (must be 1)") } } if properties.stream_version < 6 { // Versions before 6 had an invalid last frame properties.frame_count = properties.frame_count.saturating_sub(1); } properties.sample_rate = 44100; properties.channels = 2; // Nothing more we can do if properties.frame_count == 0 { return Ok(properties); } let samples = (u64::from(properties.frame_count) * MPC_FRAME_LENGTH) .saturating_sub(MPC_DECODER_SYNTH_DELAY); let length = (samples * 1000).div_round(u64::from(properties.sample_rate)); properties.duration = Duration::from_millis(length); // 576 is a magic number from the reference decoder // // Quote from the reference source (libmpcdec/trunk/src/streaminfo.c:248 @rev 153): // "estimation, exact value needs too much time" let pcm_frames = (MPC_FRAME_LENGTH * u64::from(properties.frame_count)).saturating_sub(576); // Is this accurate? If not, it really doesn't matter. properties.average_bitrate = ((stream_length as f64 * 8.0 * f64::from(properties.sample_rate)) / (pcm_frames as f64) / (MPC_FRAME_LENGTH as f64)) as u32; Ok(properties) } } lofty-0.21.1/src/musepack/sv7/mod.rs000064400000000000000000000001231046102023000153010ustar 00000000000000//! Musepack stream version 7 mod properties; // Exports pub use properties::*; lofty-0.21.1/src/musepack/sv7/properties.rs000064400000000000000000000232621046102023000167270ustar 00000000000000use crate::error::Result; use crate::macros::decode_err; use crate::musepack::constants::{ FREQUENCY_TABLE, MPC_DECODER_SYNTH_DELAY, MPC_FRAME_LENGTH, MPC_OLD_GAIN_REF, }; use crate::properties::FileProperties; use std::io::Read; use std::time::Duration; use byteorder::{LittleEndian, ReadBytesExt}; /// Used profile #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum Profile { /// No profile #[default] None, /// Unstable/Experimental Unstable, /// Profiles 2-4 Unused, /// Below Telephone (q= 0.0) BelowTelephone0, /// Below Telephone (q= 1.0) BelowTelephone1, /// Telephone (q= 2.0) Telephone, /// Thumb (q= 3.0) Thumb, /// Radio (q= 4.0) Radio, /// Standard (q= 5.0) Standard, /// Xtreme (q= 6.0) Xtreme, /// Insane (q= 7.0) Insane, /// BrainDead (q= 8.0) BrainDead, /// Above BrainDead (q= 9.0) AboveBrainDead9, /// Above BrainDead (q= 10.0) AboveBrainDead10, } impl Profile { /// Get a `Profile` from a u8 /// /// The mapping is available here: #[rustfmt::skip] pub fn from_u8(value: u8) -> Option { match value { 0 => Some(Self::None), 1 => Some(Self::Unstable), 2 | 3 | 4 => Some(Self::Unused), 5 => Some(Self::BelowTelephone0), 6 => Some(Self::BelowTelephone1), 7 => Some(Self::Telephone), 8 => Some(Self::Thumb), 9 => Some(Self::Radio), 10 => Some(Self::Standard), 11 => Some(Self::Xtreme), 12 => Some(Self::Insane), 13 => Some(Self::BrainDead), 14 => Some(Self::AboveBrainDead9), 15 => Some(Self::AboveBrainDead10), _ => None, } } } /// Volume description for the start and end of the title #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum Link { /// Title starts or ends with a very low level (no live or classical genre titles) #[default] VeryLowStartOrEnd, /// Title ends loudly LoudEnd, /// Title starts loudly LoudStart, /// Title starts loudly and ends loudly LoudStartAndEnd, } impl Link { /// Get a `Link` from a u8 /// /// The mapping is available here: pub fn from_u8(value: u8) -> Option { match value { 0 => Some(Self::VeryLowStartOrEnd), 1 => Some(Self::LoudEnd), 2 => Some(Self::LoudStart), 3 => Some(Self::LoudStartAndEnd), _ => None, } } } // http://trac.musepack.net/musepack/wiki/SV7Specification /// MPC stream version 7 audio properties #[derive(Debug, Clone, PartialEq, Default)] #[allow(clippy::struct_excessive_bools)] pub struct MpcSv7Properties { pub(crate) duration: Duration, pub(crate) average_bitrate: u32, pub(crate) channels: u8, // NOTE: always 2 // -- Section 1 -- pub(crate) frame_count: u32, // -- Section 2 -- pub(crate) intensity_stereo: bool, pub(crate) mid_side_stereo: bool, pub(crate) max_band: u8, pub(crate) profile: Profile, pub(crate) link: Link, pub(crate) sample_freq: u32, pub(crate) max_level: u16, // -- Section 3 -- pub(crate) title_gain: i16, pub(crate) title_peak: u16, // -- Section 4 -- pub(crate) album_gain: i16, pub(crate) album_peak: u16, // -- Section 5 -- pub(crate) true_gapless: bool, pub(crate) last_frame_length: u16, pub(crate) fast_seeking_safe: bool, // -- Section 6 -- pub(crate) encoder_version: u8, } impl From for FileProperties { fn from(input: MpcSv7Properties) -> Self { Self { duration: input.duration, overall_bitrate: Some(input.average_bitrate), audio_bitrate: Some(input.average_bitrate), sample_rate: Some(input.sample_freq), bit_depth: None, channels: Some(input.channels), channel_mask: None, } } } impl MpcSv7Properties { /// Duration of the audio pub fn duration(&self) -> Duration { self.duration } /// Average bitrate (kbps) pub fn average_bitrate(&self) -> u32 { self.average_bitrate } /// Sample rate (Hz) pub fn sample_rate(&self) -> u32 { self.sample_freq } /// Channel count pub fn channels(&self) -> u8 { self.channels } /// Total number of audio frames pub fn frame_count(&self) -> u32 { self.frame_count } /// Whether intensity stereo coding (IS) is used pub fn intensity_stereo(&self) -> bool { self.intensity_stereo } /// Whether MidSideStereo is used pub fn mid_side_stereo(&self) -> bool { self.mid_side_stereo } /// Last subband used in the whole file pub fn max_band(&self) -> u8 { self.max_band } /// Profile used pub fn profile(&self) -> Profile { self.profile } /// Volume description of the start and end pub fn link(&self) -> Link { self.link } /// Maximum level of the coded PCM input signal pub fn max_level(&self) -> u16 { self.max_level } /// Change in the replay level /// /// The value is a signed 16-bit integer, with the level being attenuated by that many mB pub fn title_gain(&self) -> i16 { self.title_gain } /// Maximum level of the decoded title /// /// * 16422: -6 dB /// * 32767: 0 dB /// * 65379: +6 dB pub fn title_peak(&self) -> u16 { self.title_peak } /// Change in the replay level if the whole CD is supposed to be played with the same level change /// /// The value is a signed 16-bit integer, with the level being attenuated by that many mB pub fn album_gain(&self) -> i16 { self.album_gain } /// Maximum level of the whole decoded CD /// /// * 16422: -6 dB /// * 32767: 0 dB /// * 65379: +6 dB pub fn album_peak(&self) -> u16 { self.album_peak } /// Whether true gapless is used pub fn true_gapless(&self) -> bool { self.true_gapless } /// Used samples of the last frame /// /// * TrueGapless = 0: always 0 /// * TrueGapless = 1: 1...1152 pub fn last_frame_length(&self) -> u16 { self.last_frame_length } /// Whether fast seeking can be used safely pub fn fast_seeking_safe(&self) -> bool { self.fast_seeking_safe } /// Encoder version /// /// * Encoder version * 100 (106 = 1.06) /// * EncoderVersion % 10 == 0 Release (1.0) /// * EncoderVersion % 2 == 0 Beta (1.06) /// * EncoderVersion % 2 == 1 Alpha (1.05a...z) pub fn encoder_version(&self) -> u8 { self.encoder_version } pub(crate) fn read(reader: &mut R, stream_length: u64) -> Result where R: Read, { let version = reader.read_u8()?; if version & 0x0F != 7 { decode_err!(@BAIL Mpc, "Expected stream version 7"); } let mut properties = MpcSv7Properties { channels: 2, // Always 2 channels ..Self::default() }; // TODO: Make a Bitreader, would be nice crate-wide but especially here // The SV7 header is split into 6 32-bit sections // -- Section 1 -- properties.frame_count = reader.read_u32::()?; // -- Section 2 -- let chunk = reader.read_u32::()?; let byte1 = ((chunk & 0xFF00_0000) >> 24) as u8; properties.intensity_stereo = ((byte1 & 0x80) >> 7) == 1; properties.mid_side_stereo = ((byte1 & 0x40) >> 6) == 1; properties.max_band = byte1 & 0x3F; let byte2 = ((chunk & 0xFF_0000) >> 16) as u8; properties.profile = Profile::from_u8((byte2 & 0xF0) >> 4).unwrap(); // Infallible properties.link = Link::from_u8((byte2 & 0x0C) >> 2).unwrap(); // Infallible let sample_freq_index = byte2 & 0x03; properties.sample_freq = FREQUENCY_TABLE[sample_freq_index as usize]; let remaining_bytes = (chunk & 0xFFFF) as u16; properties.max_level = remaining_bytes; // -- Section 3 -- let title_peak = reader.read_u16::()?; let title_gain = reader.read_u16::()?; // -- Section 4 -- let album_peak = reader.read_u16::()?; let album_gain = reader.read_u16::()?; // -- Section 5 -- let chunk = reader.read_u32::()?; properties.true_gapless = (chunk >> 31) == 1; if properties.true_gapless { properties.last_frame_length = ((chunk >> 20) & 0x7FF) as u16; } properties.fast_seeking_safe = (chunk >> 19) & 1 == 1; // NOTE: Rest of the chunk is zeroed and unused // -- Section 6 -- properties.encoder_version = reader.read_u8()?; // -- End of parsing -- // Convert ReplayGain values let set_replay_gain = |gain: u16| -> i16 { if gain == 0 { return 0; } let gain = ((MPC_OLD_GAIN_REF - f32::from(gain) / 100.0) * 256.0 + 0.5) as i16; if !(0..i16::MAX).contains(&gain) { return 0; } gain }; let set_replay_peak = |peak: u16| -> u16 { if peak == 0 { return 0; } ((f64::from(peak).log10() * 20.0 * 256.0) + 0.5) as u16 }; properties.title_gain = set_replay_gain(title_gain); properties.title_peak = set_replay_peak(title_peak); properties.album_gain = set_replay_gain(album_gain); properties.album_peak = set_replay_peak(album_peak); if properties.last_frame_length > MPC_FRAME_LENGTH as u16 { decode_err!(@BAIL Mpc, "Invalid last frame length"); } if properties.sample_freq == 0 { log::warn!("Sample rate is 0, unable to calculate duration and bitrate"); return Ok(properties); } if properties.frame_count == 0 { log::warn!("Frame count is 0, unable to calculate duration and bitrate"); return Ok(properties); } let time_per_frame = (MPC_FRAME_LENGTH as f64) / f64::from(properties.sample_freq); let length = (f64::from(properties.frame_count) * time_per_frame) * 1000.0; properties.duration = Duration::from_millis(length as u64); let total_samples; if properties.true_gapless { total_samples = (u64::from(properties.frame_count) * MPC_FRAME_LENGTH) - (MPC_FRAME_LENGTH - u64::from(properties.last_frame_length)); } else { total_samples = (u64::from(properties.frame_count) * MPC_FRAME_LENGTH) - MPC_DECODER_SYNTH_DELAY; } properties.average_bitrate = ((stream_length * 8 * u64::from(properties.sample_freq)) / (total_samples * 1000)) as u32; Ok(properties) } } lofty-0.21.1/src/musepack/sv8/mod.rs000064400000000000000000000001351046102023000153050ustar 00000000000000//! Musepack stream version 8 mod properties; mod read; // Exports pub use properties::*; lofty-0.21.1/src/musepack/sv8/properties.rs000064400000000000000000000213171046102023000167270ustar 00000000000000use super::read::PacketReader; use crate::config::ParsingMode; use crate::error::Result; use crate::macros::decode_err; use crate::musepack::constants::FREQUENCY_TABLE; use crate::properties::FileProperties; use crate::util::math::RoundedDivision; use std::io::Read; use std::time::Duration; use byteorder::{BigEndian, ReadBytesExt}; /// MPC stream version 8 audio properties #[derive(Debug, Clone, PartialEq, Default)] pub struct MpcSv8Properties { pub(crate) duration: Duration, pub(crate) average_bitrate: u32, /// Mandatory Stream Header packet pub stream_header: StreamHeader, /// Mandatory ReplayGain packet pub replay_gain: ReplayGain, /// Optional encoder information pub encoder_info: Option, } impl From for FileProperties { fn from(input: MpcSv8Properties) -> Self { Self { duration: input.duration, overall_bitrate: Some(input.average_bitrate), audio_bitrate: Some(input.average_bitrate), sample_rate: Some(input.stream_header.sample_rate), bit_depth: None, channels: Some(input.stream_header.channels), channel_mask: None, } } } impl MpcSv8Properties { /// Duration of the audio pub fn duration(&self) -> Duration { self.duration } /// Average bitrate (kbps) pub fn average_bitrate(&self) -> u32 { self.average_bitrate } /// Sample rate (Hz) pub fn sample_rate(&self) -> u32 { self.stream_header.sample_rate } /// Channel count pub fn channels(&self) -> u8 { self.stream_header.channels } /// MusePack stream version pub fn version(&self) -> u8 { self.stream_header.stream_version } pub(crate) fn read(reader: &mut R, parse_mode: ParsingMode) -> Result { super::read::read_from(reader, parse_mode) } } /// Information from a Stream Header packet /// /// This contains the information needed to decode the stream. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub struct StreamHeader { /// CRC 32 of the stream header packet /// /// The CRC used is here: pub crc: u32, /// Bitstream version pub stream_version: u8, /// Number of samples in the stream. 0 = unknown pub sample_count: u64, /// Number of samples to skip at the beginning of the stream pub beginning_silence: u64, /// The sampling frequency /// /// NOTE: This is not the index into the frequency table, this is the mapped value. pub sample_rate: u32, /// Maximum number of bands used in the file pub max_used_bands: u8, /// Number of channels in the stream pub channels: u8, /// Whether Mid Side Stereo is enabled pub ms_used: bool, /// Number of frames per audio packet pub audio_block_frames: u16, } impl StreamHeader { pub(super) fn read(reader: &mut PacketReader) -> Result { // StreamHeader format: // // Field | Size (bits) | Value | Comment // CRC | 32 | | CRC 32 of the block (this field excluded). 0 = invalid // Stream version | 8 | 8 | Bitstream version // Sample count | n*8; 0 < n < 10 | | Number of samples in the stream. 0 = unknown // Beginning silence | n*8; 0 < n < 10 | | Number of samples to skip at the beginning of the stream // Sample frequency | 3 | 0..7 | See table below // Max used bands | 5 | 1..32 | Maximum number of bands used in the file // Channel count | 4 | 1..16 | Number of channels in the stream // MS used | 1 | | True if Mid Side Stereo is enabled // Audio block frames | 3 | 0..7 | Number of frames per audio packet (4value=(1..16384)) let crc = reader.read_u32::()?; let stream_version = reader.read_u8()?; let (sample_count, _) = PacketReader::read_size(reader)?; let (beginning_silence, _) = PacketReader::read_size(reader)?; // Sample rate and max used bands let remaining_flags_byte_1 = reader.read_u8()?; let sample_rate_index = (remaining_flags_byte_1 & 0xE0) >> 5; let sample_rate = FREQUENCY_TABLE[sample_rate_index as usize]; let max_used_bands = (remaining_flags_byte_1 & 0x1F) + 1; // Channel count, MS used, audio block frames let remaining_flags_byte_2 = reader.read_u8()?; let channels = (remaining_flags_byte_2 >> 4) + 1; let ms_used = remaining_flags_byte_2 & 0x08 == 0x08; let audio_block_frames_value = remaining_flags_byte_2 & 0x07; let audio_block_frames = 4u16.pow(u32::from(audio_block_frames_value)); Ok(Self { crc, stream_version, sample_count, beginning_silence, sample_rate, max_used_bands, channels, ms_used, audio_block_frames, }) } } /// Information from a ReplayGain packet /// /// This contains the necessary data needed to apply ReplayGain on the current stream. /// /// The ReplayGain values are stored in dB in Q8.8 format. /// A value of `0` means that this field has not been computed (no gain must be applied in this case). /// /// Examples: /// /// * ReplayGain finds that this title has a loudness of 78.56 dB. It will be encoded as $ 78.56 * 256 ~ 20111 = 0x4E8F $ /// * For 16-bit output (range \[-32767 32768]), the max is 68813 (out of range). It will be encoded as $ 20 * log10(68813) * 256 ~ 24769 = 0x60C1 $ /// * For float output (range \[-1 1]), the max is 0.96. It will be encoded as $ 20 * log10(0.96 * 215) * 256 ~ 23029 = 0x59F5 $ (for peak values it is suggested to round to nearest higher integer) #[derive(Debug, Clone, Copy, PartialEq, Default)] #[allow(missing_docs)] pub struct ReplayGain { /// The replay gain version pub version: u8, /// The loudness calculated for the title, and not the gain that the player must apply pub title_gain: u16, pub title_peak: u16, /// The loudness calculated for the album pub album_gain: u16, pub album_peak: u16, } impl ReplayGain { pub(super) fn read(reader: &mut PacketReader) -> Result { // ReplayGain format: // // Field | Size (bits) | Value | Comment // ReplayGain version | 8 | 1 | The replay gain version // Title gain | 16 | | The loudness calculated for the title, and not the gain that the player must apply // Title peak | 16 | | // Album gain | 16 | | The loudness calculated for the album // Album peak | 16 | | let version = reader.read_u8()?; let title_gain = reader.read_u16::()?; let title_peak = reader.read_u16::()?; let album_gain = reader.read_u16::()?; let album_peak = reader.read_u16::()?; Ok(Self { version, title_gain, title_peak, album_gain, album_peak, }) } } /// Information from an Encoder Info packet #[derive(Debug, Clone, Copy, PartialEq, Default)] #[allow(missing_docs)] pub struct EncoderInfo { /// Quality in 4.3 format pub profile: f32, pub pns_tool: bool, /// Major version pub major: u8, /// Minor version, even numbers for stable version, odd when unstable pub minor: u8, /// Build pub build: u8, } impl EncoderInfo { pub(super) fn read(reader: &mut PacketReader) -> Result { // EncoderInfo format: // // Field | Size (bits) | Value // Profile | 7 | 0..15.875 // PNS tool | 1 | True if enabled // Major | 8 | 1 // Minor | 8 | 17 // Build | 8 | 3 let byte1 = reader.read_u8()?; let profile = f32::from((byte1 & 0xFE) >> 1) / 8.0; let pns_tool = byte1 & 0x01 == 1; let major = reader.read_u8()?; let minor = reader.read_u8()?; let build = reader.read_u8()?; Ok(Self { profile, pns_tool, major, minor, build, }) } } pub(super) fn read( stream_length: u64, stream_header: StreamHeader, replay_gain: ReplayGain, encoder_info: Option, ) -> Result { let mut properties = MpcSv8Properties { duration: Duration::ZERO, average_bitrate: 0, stream_header, replay_gain, encoder_info, }; let sample_count = stream_header.sample_count; let beginning_silence = stream_header.beginning_silence; let sample_rate = stream_header.sample_rate; if beginning_silence > sample_count { decode_err!(@BAIL Mpc, "Beginning silence is greater than the total sample count"); } if sample_rate == 0 { log::warn!("Sample rate is 0, unable to calculate duration and bitrate"); return Ok(properties); } if sample_count == 0 { log::warn!("Sample count is 0, unable to calculate duration and bitrate"); return Ok(properties); } let total_samples = sample_count - beginning_silence; let length = (total_samples * 1000).div_round(u64::from(sample_rate)); properties.duration = Duration::from_millis(length); properties.average_bitrate = ((stream_length * 8 * u64::from(sample_rate)) / (total_samples * 1000)) as u32; Ok(properties) } lofty-0.21.1/src/musepack/sv8/read.rs000064400000000000000000000107551046102023000154520ustar 00000000000000use super::properties::{EncoderInfo, MpcSv8Properties, ReplayGain, StreamHeader}; use crate::config::ParsingMode; use crate::error::{ErrorKind, LoftyError, Result}; use crate::macros::{decode_err, parse_mode_choice}; use std::io::Read; use byteorder::ReadBytesExt; // TODO: Support chapter packets? const STREAM_HEADER_KEY: [u8; 2] = *b"SH"; const REPLAYGAIN_KEY: [u8; 2] = *b"RG"; const ENCODER_INFO_KEY: [u8; 2] = *b"EI"; #[allow(dead_code)] const AUDIO_PACKET_KEY: [u8; 2] = *b"AP"; const STREAM_END_KEY: [u8; 2] = *b"SE"; pub(crate) fn read_from(data: &mut R, parse_mode: ParsingMode) -> Result where R: Read, { let mut packet_reader = PacketReader::new(data); let mut stream_header = None; let mut replay_gain = None; let mut encoder_info = None; let mut stream_length = 0; let mut found_stream_end = false; while let Ok((packet_id, packet_length)) = packet_reader.next() { stream_length += packet_length; match packet_id { STREAM_HEADER_KEY => stream_header = Some(StreamHeader::read(&mut packet_reader)?), REPLAYGAIN_KEY => replay_gain = Some(ReplayGain::read(&mut packet_reader)?), ENCODER_INFO_KEY => encoder_info = Some(EncoderInfo::read(&mut packet_reader)?), STREAM_END_KEY => { found_stream_end = true; break; }, _ => {}, } } // Check mandatory packets let stream_header = match stream_header { Some(stream_header) => stream_header, None => { parse_mode_choice!( parse_mode, STRICT: decode_err!(@BAIL Mpc, "File is missing a Stream Header packet"), DEFAULT: StreamHeader::default() ) }, }; let replay_gain = match replay_gain { Some(replay_gain) => replay_gain, None => { parse_mode_choice!( parse_mode, STRICT: decode_err!(@BAIL Mpc, "File is missing a ReplayGain packet"), DEFAULT: ReplayGain::default() ) }, }; if stream_length == 0 && parse_mode == ParsingMode::Strict { decode_err!(@BAIL Mpc, "File is missing an Audio packet"); } if !found_stream_end && parse_mode == ParsingMode::Strict { decode_err!(@BAIL Mpc, "File is missing a Stream End packet"); } let properties = super::properties::read(stream_length, stream_header, replay_gain, encoder_info)?; Ok(properties) } pub struct PacketReader { reader: R, capacity: u64, } impl PacketReader { fn new(reader: R) -> Self { Self { reader, capacity: 0, } } /// Move the reader to the next packet, returning the next packet key and size fn next(&mut self) -> Result<([u8; 2], u64)> { // Discard the rest of the current packet std::io::copy( &mut self.reader.by_ref().take(self.capacity), &mut std::io::sink(), )?; // Packet format: // // Field | Size (bits) | Value // Key | 16 | "EX" // Size | n*8; 0 < n < 10 | 0x1A // Payload | Size * 8 | "example" let mut key = [0; 2]; self.reader.read_exact(&mut key)?; if !key[0].is_ascii_uppercase() || !key[1].is_ascii_uppercase() { decode_err!(@BAIL Mpc, "Packet key contains characters that are out of the allowed range") } let (packet_size, packet_size_byte_count) = Self::read_size(&mut self.reader)?; // The packet size contains the key (2) and the size (?, variable length <= 9) self.capacity = packet_size.saturating_sub(u64::from(2 + packet_size_byte_count)); Ok((key, self.capacity)) } /// Read the variable-length packet size /// /// This takes a reader since we need to both use it for packet reading *and* setting up the reader itself in `PacketReader::next` pub fn read_size(reader: &mut R) -> Result<(u64, u8)> { let mut current; let mut size = 0u64; // bits, big-endian // 0xxx xxxx - value 0 to 2^7-1 // 1xxx xxxx 0xxx xxxx - value 0 to 2^14-1 // 1xxx xxxx 1xxx xxxx 0xxx xxxx - value 0 to 2^21-1 // 1xxx xxxx 1xxx xxxx 1xxx xxxx 0xxx xxxx - value 0 to 2^28-1 // ... let mut bytes_read = 0; loop { current = reader.read_u8()?; bytes_read += 1; // Sizes cannot go above 9 bytes if bytes_read > 9 { return Err(LoftyError::new(ErrorKind::TooMuchData)); } size = (size << 7) | u64::from(current & 0x7F); if current & 0x80 == 0 { break; } } Ok((size, bytes_read)) } } impl Read for PacketReader { fn read(&mut self, buf: &mut [u8]) -> std::io::Result { let bytes_read = self.reader.by_ref().take(self.capacity).read(buf)?; self.capacity = self.capacity.saturating_sub(bytes_read as u64); Ok(bytes_read) } } lofty-0.21.1/src/ogg/constants.rs000064400000000000000000000010371046102023000147700ustar 00000000000000// https://xiph.org/vorbis/doc/Vorbis_I_spec.html#x1-620004.2.1 pub const VORBIS_IDENT_HEAD: &[u8] = &[1, 118, 111, 114, 98, 105, 115]; pub const VORBIS_COMMENT_HEAD: &[u8] = &[3, 118, 111, 114, 98, 105, 115]; // https://datatracker.ietf.org/doc/pdf/rfc7845.pdf#section-5.1 pub const OPUSTAGS: &[u8] = &[79, 112, 117, 115, 84, 97, 103, 115]; pub const OPUSHEAD: &[u8] = &[79, 112, 117, 115, 72, 101, 97, 100]; // https://www.speex.org/docs/manual/speex-manual/node8.html pub const SPEEXHEADER: &[u8] = &[83, 112, 101, 101, 120, 32, 32, 32]; lofty-0.21.1/src/ogg/mod.rs000064400000000000000000000025521046102023000135360ustar 00000000000000//! Items for OGG container formats //! //! ## File notes //! //! The only supported tag format is [`VorbisComments`] pub(crate) mod constants; pub(crate) mod opus; mod picture_storage; pub(crate) mod read; pub(crate) mod speex; pub(crate) mod tag; pub(crate) mod vorbis; pub(crate) mod write; use crate::error::Result; use crate::macros::decode_err; use std::io::{Read, Seek, SeekFrom}; use ogg_pager::{Page, PageHeader}; // Exports pub use opus::properties::OpusProperties; pub use opus::OpusFile; pub use picture_storage::OggPictureStorage; pub use speex::properties::SpeexProperties; pub use speex::SpeexFile; pub use tag::VorbisComments; pub use vorbis::properties::VorbisProperties; pub use vorbis::VorbisFile; fn verify_signature(content: &[u8], sig: &[u8]) -> Result<()> { let sig_len = sig.len(); if content.len() < sig_len || &content[..sig_len] != sig { decode_err!(@BAIL Vorbis, "File missing magic signature"); } Ok(()) } fn find_last_page(data: &mut R) -> Result where R: Read + Seek, { let mut last_page_header = PageHeader::read(data)?; data.seek(SeekFrom::Current(last_page_header.content_size() as i64))?; while let Ok(header) = PageHeader::read(data) { last_page_header = header; data.seek(SeekFrom::Current(last_page_header.content_size() as i64))?; } data.seek(SeekFrom::Start(last_page_header.start))?; Ok(Page::read(data)?) } lofty-0.21.1/src/ogg/opus/mod.rs000064400000000000000000000023151046102023000145210ustar 00000000000000pub(super) mod properties; use super::find_last_page; use super::tag::VorbisComments; use crate::config::ParseOptions; use crate::error::Result; use crate::ogg::constants::{OPUSHEAD, OPUSTAGS}; use properties::OpusProperties; use std::io::{Read, Seek}; use lofty_attr::LoftyFile; /// An OGG Opus file #[derive(LoftyFile)] #[lofty(read_fn = "Self::read_from")] pub struct OpusFile { /// The vorbis comments contained in the file /// /// NOTE: While a metadata packet is required, it isn't required to actually have any data. #[lofty(tag_type = "VorbisComments")] pub(crate) vorbis_comments_tag: VorbisComments, /// The file's audio properties pub(crate) properties: OpusProperties, } impl OpusFile { fn read_from(reader: &mut R, parse_options: ParseOptions) -> Result where R: Read + Seek, { let file_information = super::read::read_from(reader, OPUSHEAD, OPUSTAGS, 2, parse_options)?; Ok(Self { properties: if parse_options.read_properties { properties::read_properties(reader, &file_information.1, &file_information.2)? } else { OpusProperties::default() }, // A metadata packet is mandatory in Opus vorbis_comments_tag: file_information.0.unwrap_or_default(), }) } } lofty-0.21.1/src/ogg/opus/properties.rs000064400000000000000000000103701046102023000161360ustar 00000000000000use super::find_last_page; use crate::error::Result; use crate::macros::decode_err; use crate::properties::{ChannelMask, FileProperties}; use crate::util::math::RoundedDivision; use std::io::{Read, Seek, SeekFrom}; use std::time::Duration; use byteorder::{LittleEndian, ReadBytesExt}; use ogg_pager::{Packets, PageHeader}; /// An Opus file's audio properties #[derive(Debug, Copy, Clone, PartialEq, Eq, Default)] #[non_exhaustive] pub struct OpusProperties { pub(crate) duration: Duration, pub(crate) overall_bitrate: u32, pub(crate) audio_bitrate: u32, pub(crate) channels: u8, pub(crate) channel_mask: ChannelMask, pub(crate) version: u8, pub(crate) input_sample_rate: u32, } impl From for FileProperties { fn from(input: OpusProperties) -> Self { Self { duration: input.duration, overall_bitrate: Some(input.overall_bitrate), audio_bitrate: Some(input.audio_bitrate), sample_rate: Some(input.input_sample_rate), bit_depth: None, channels: Some(input.channels), channel_mask: if input.channel_mask == ChannelMask(0) { None } else { Some(input.channel_mask) }, } } } impl OpusProperties { /// Duration of the audio pub fn duration(&self) -> Duration { self.duration } /// Overall bitrate (kbps) pub fn overall_bitrate(&self) -> u32 { self.overall_bitrate } /// Audio bitrate (kbps) pub fn audio_bitrate(&self) -> u32 { self.audio_bitrate } /// Channel count pub fn channels(&self) -> u8 { self.channels } /// Channel mask pub fn channel_mask(&self) -> ChannelMask { self.channel_mask } /// Opus version pub fn version(&self) -> u8 { self.version } /// Input sample rate pub fn input_sample_rate(&self) -> u32 { self.input_sample_rate } } pub(in crate::ogg) fn read_properties( data: &mut R, first_page_header: &PageHeader, packets: &Packets, ) -> Result where R: Read + Seek, { let mut properties = OpusProperties::default(); // Safe to unwrap, it is impossible to get this far without // an identification packet. let identification_packet = packets.get(0).unwrap(); // Skip identification header let identification_packet_reader = &mut &identification_packet[8..]; properties.version = identification_packet_reader.read_u8()?; properties.channels = identification_packet_reader.read_u8()?; let pre_skip = identification_packet_reader.read_u16::()?; properties.input_sample_rate = identification_packet_reader.read_u32::()?; let _output_gain = identification_packet_reader.read_u16::()?; let channel_mapping_family = identification_packet_reader.read_u8()?; // https://datatracker.ietf.org/doc/html/rfc7845.html#section-5.1.1 if (channel_mapping_family == 0 && properties.channels > 2) || (channel_mapping_family == 1 && properties.channels > 8) { decode_err!(@BAIL Opus, "Invalid channel count for mapping family"); } properties.channel_mask = ChannelMask::from_opus_channels(properties.channels).expect("Channel count is valid"); let last_page = find_last_page(data); let file_length = data.seek(SeekFrom::End(0))?; if let Ok(last_page) = last_page { let first_page_abgp = first_page_header.abgp; let last_page_abgp = last_page.header().abgp; let total_samples = last_page_abgp .saturating_sub(first_page_abgp) // https://datatracker.ietf.org/doc/html/draft-terriberry-oggopus-01#section-4.1: // // A 'pre-skip' field in the ID header (see Section 5.1) signals the // number of samples which should be skipped (decoded but discarded) .saturating_sub(u64::from(pre_skip)); if total_samples > 0 { // Best case scenario let length = (total_samples * 1000).div_round(48000); // Get the stream length by subtracting the length of the header packets // Safe to unwrap, metadata is checked prior let metadata_packet = packets.get(1).unwrap(); let header_size = identification_packet.len() + metadata_packet.len(); let stream_len = file_length - header_size as u64; properties.duration = Duration::from_millis(length); properties.overall_bitrate = ((file_length * 8) / length) as u32; properties.audio_bitrate = ((stream_len * 8) / length) as u32; } else { log::warn!("Opus: The file contains invalid PCM values, unable to calculate length"); } } Ok(properties) } lofty-0.21.1/src/ogg/picture_storage.rs000064400000000000000000000146131046102023000161570ustar 00000000000000use crate::error::Result; use crate::picture::{Picture, PictureInformation, PictureType}; /// Defines methods for interacting with an item storing OGG pictures /// /// This exists due to *both* [`VorbisComments`](crate::ogg::VorbisComments) and [`FlacFile`](crate::flac::FlacFile) needing to store /// pictures in their own ways. /// /// It cannot be implemented downstream. pub trait OggPictureStorage: private::Sealed { /// Inserts a [`Picture`] /// /// NOTES: /// /// * If `information` is `None`, the [`PictureInformation`] will be inferred using [`PictureInformation::from_picture`]. /// * According to spec, there can only be one picture of type [`PictureType::Icon`] and [`PictureType::OtherIcon`]. /// When attempting to insert these types, if another is found it will be removed and returned. /// /// # Errors /// /// * See [`PictureInformation::from_picture`] fn insert_picture( &mut self, picture: Picture, information: Option, ) -> Result> { let ret = match picture.pic_type { PictureType::Icon | PictureType::OtherIcon => self .pictures() .iter() .position(|(p, _)| p.pic_type == picture.pic_type) .map(|pos| self.remove_picture(pos)), _ => None, }; let info = match information { Some(pic_info) => pic_info, None => PictureInformation::from_picture(&picture)?, }; self.pictures_mut().push((picture, info)); Ok(ret) } /// Removes a certain [`PictureType`] fn remove_picture_type(&mut self, picture_type: PictureType) { self.pictures_mut() .retain(|(pic, _)| pic.pic_type != picture_type); } /// Returns the stored [`Picture`]s as a slice /// /// # Examples /// /// ```rust /// use lofty::ogg::{OggPictureStorage, VorbisComments}; /// /// let mut tag = VorbisComments::default(); /// /// assert!(tag.pictures().is_empty()); /// ``` fn pictures(&self) -> &[(Picture, PictureInformation)]; /// Replaces the picture at the given `index` /// /// NOTE: If `index` is out of bounds, the `picture` will be appended /// to the list. /// /// # Examples /// /// ```rust /// use lofty::ogg::{OggPictureStorage, VorbisComments}; /// use lofty::picture::{MimeType, Picture, PictureInformation, PictureType}; /// /// # fn main() -> lofty::error::Result<()> { /// let mut tag = VorbisComments::default(); /// /// // Add a front cover /// let front_cover = Picture::new_unchecked( /// PictureType::CoverFront, /// Some(MimeType::Png), /// None, /// Vec::new(), /// ); /// let front_cover_info = PictureInformation::default(); /// tag.insert_picture(front_cover, Some(front_cover_info))?; /// /// assert_eq!(tag.pictures().len(), 1); /// assert_eq!(tag.pictures()[0].0.pic_type(), PictureType::CoverFront); /// /// // Replace the front cover with a back cover /// let back_cover = Picture::new_unchecked( /// PictureType::CoverBack, /// Some(MimeType::Png), /// None, /// Vec::new(), /// ); /// let back_cover_info = PictureInformation::default(); /// tag.set_picture(0, back_cover, back_cover_info); /// /// assert_eq!(tag.pictures().len(), 1); /// assert_eq!(tag.pictures()[0].0.pic_type(), PictureType::CoverBack); /// /// // Use an out of bounds index /// let another_picture = /// Picture::new_unchecked(PictureType::Band, Some(MimeType::Png), None, Vec::new()); /// tag.set_picture(100, another_picture, PictureInformation::default()); /// /// assert_eq!(tag.pictures().len(), 2); /// # Ok(()) } /// ``` #[allow(clippy::missing_panics_doc)] fn set_picture(&mut self, index: usize, picture: Picture, info: PictureInformation) { if index >= self.pictures().len() { // Safe to unwrap, since `info` is guaranteed to exist self.insert_picture(picture, Some(info)).unwrap(); } else { self.pictures_mut()[index] = (picture, info); } } /// Removes and returns the picture at the given `index` /// /// # Panics /// /// Panics if `index` is out of bounds. /// /// # Examples /// /// ```rust /// use lofty::ogg::{OggPictureStorage, VorbisComments}; /// use lofty::picture::{MimeType, Picture, PictureInformation, PictureType}; /// /// # fn main() -> lofty::error::Result<()> { /// let front_cover = Picture::new_unchecked( /// PictureType::CoverFront, /// Some(MimeType::Png), /// None, /// Vec::new(), /// ); /// let front_cover_info = PictureInformation::default(); /// /// let mut tag = VorbisComments::default(); /// /// // Add a front cover /// tag.insert_picture(front_cover, Some(front_cover_info))?; /// /// assert_eq!(tag.pictures().len(), 1); /// /// tag.remove_picture(0); /// /// assert_eq!(tag.pictures().len(), 0); /// # Ok(()) } /// ``` fn remove_picture(&mut self, index: usize) -> (Picture, PictureInformation) { self.pictures_mut().remove(index) } /// Removes all pictures and returns them /// /// # Examples /// /// ```rust /// use lofty::ogg::{OggPictureStorage, VorbisComments}; /// use lofty::picture::{MimeType, Picture, PictureInformation, PictureType}; /// /// # fn main() -> lofty::error::Result<()> { /// let mut tag = VorbisComments::default(); /// /// // Add front and back covers /// let front_cover = Picture::new_unchecked( /// PictureType::CoverFront, /// Some(MimeType::Png), /// None, /// Vec::new(), /// ); /// let front_cover_info = PictureInformation::default(); /// tag.insert_picture(front_cover, Some(front_cover_info))?; /// /// let back_cover = Picture::new_unchecked( /// PictureType::CoverBack, /// Some(MimeType::Png), /// None, /// Vec::new(), /// ); /// let back_cover_info = PictureInformation::default(); /// tag.insert_picture(back_cover, Some(front_cover_info))?; /// /// assert_eq!(tag.pictures().len(), 2); /// /// let pictures = tag.remove_pictures(); /// assert_eq!(pictures.len(), 2); /// /// // The tag no longer contains any pictures /// assert_eq!(tag.pictures().len(), 0); /// # Ok(()) } /// ``` fn remove_pictures(&mut self) -> Vec<(Picture, PictureInformation)> { core::mem::take(self.pictures_mut()) } } mod private { use crate::picture::{Picture, PictureInformation}; pub trait Sealed { fn pictures_mut(&mut self) -> &mut Vec<(Picture, PictureInformation)>; } impl Sealed for crate::ogg::tag::VorbisComments { fn pictures_mut(&mut self) -> &mut Vec<(Picture, PictureInformation)> { &mut self.pictures } } impl Sealed for crate::flac::FlacFile { fn pictures_mut(&mut self) -> &mut Vec<(Picture, PictureInformation)> { &mut self.pictures } } } lofty-0.21.1/src/ogg/read.rs000064400000000000000000000145021046102023000136700ustar 00000000000000use super::tag::VorbisComments; use super::verify_signature; use crate::config::{ParseOptions, ParsingMode}; use crate::error::{ErrorKind, LoftyError, Result}; use crate::macros::{decode_err, err, parse_mode_choice}; use crate::picture::{MimeType, Picture, PictureInformation, PictureType}; use crate::util::text::{utf16_decode, utf8_decode, utf8_decode_str}; use std::borrow::Cow; use std::io::{Read, Seek, SeekFrom}; use byteorder::{LittleEndian, ReadBytesExt}; use data_encoding::BASE64; use ogg_pager::{Packets, PageHeader}; pub type OGGTags = (Option, PageHeader, Packets); pub(crate) fn read_comments( data: &mut R, mut len: u64, parse_options: ParseOptions, ) -> Result where R: Read, { use crate::macros::try_vec; let parse_mode = parse_options.parsing_mode; let vendor_len = data.read_u32::()?; if u64::from(vendor_len) > len { err!(SizeMismatch); } let mut vendor_bytes = try_vec![0; vendor_len as usize]; data.read_exact(&mut vendor_bytes)?; len -= u64::from(vendor_len); let vendor; match utf8_decode(vendor_bytes) { Ok(v) => vendor = v, Err(e) => { // The actions following this are not spec-compliant in the slightest, so // we need to short circuit if strict. if parse_mode == ParsingMode::Strict { return Err(e); } log::warn!("Possibly corrupt vendor string, attempting to recover"); // Some vendor strings have invalid mixed UTF-8 and UTF-16 encodings. // This seems to work, while preserving the string opposed to using // the replacement character let LoftyError { kind: ErrorKind::StringFromUtf8(e), } = e else { return Err(e); }; let s = e .as_bytes() .iter() .map(|c| u16::from(*c)) .collect::>(); match utf16_decode(&s) { Ok(v) => { log::warn!("Vendor string recovered as: '{v}'"); vendor = v; }, Err(_) => decode_err!(@BAIL "OGG: File has an invalid vendor string"), } }, }; let number_of_items = data.read_u32::()?; if number_of_items > (len >> 2) as u32 { err!(SizeMismatch); } let mut tag = VorbisComments { vendor, items: Vec::with_capacity(number_of_items as usize), pictures: Vec::new(), }; for _ in 0..number_of_items { let comment_len = data.read_u32::()?; if u64::from(comment_len) > len { err!(SizeMismatch); } let mut comment_bytes = try_vec![0; comment_len as usize]; data.read_exact(&mut comment_bytes)?; len -= u64::from(comment_len); // KEY=VALUE let mut comment_split = comment_bytes.splitn(2, |b| *b == b'='); let Some(key) = comment_split.next() else { continue; }; // Make sure there was a separator present, otherwise just move on let Some(value) = comment_split.next() else { log::warn!("No separator found in field, discarding"); continue; }; match key { k if k.eq_ignore_ascii_case(b"METADATA_BLOCK_PICTURE") => { if !parse_options.read_cover_art { continue; } match Picture::from_flac_bytes(value, true, parse_mode) { Ok(picture) => tag.pictures.push(picture), Err(e) => { if parse_mode == ParsingMode::Strict { return Err(e); } log::warn!("Failed to decode FLAC picture, discarding field"); continue; }, } }, k if k.eq_ignore_ascii_case(b"COVERART") => { if !parse_options.read_cover_art { continue; } // `COVERART` is an old deprecated image storage format. We have to convert it // to a `METADATA_BLOCK_PICTURE` for it to be useful. // // log::warn!( "Found deprecated `COVERART` field, attempting to convert to \ `METADATA_BLOCK_PICTURE`" ); let picture_data = BASE64.decode(value); match picture_data { Ok(picture_data) => { let mime_type = Picture::mimetype_from_bin(&picture_data) .unwrap_or_else(|_| MimeType::Unknown(String::from("image/"))); let picture = Picture { pic_type: PictureType::Other, mime_type: Some(mime_type), description: None, data: Cow::from(picture_data), }; tag.pictures.push((picture, PictureInformation::default())) }, Err(_) => { if parse_mode == ParsingMode::Strict { return Err(LoftyError::new(ErrorKind::NotAPicture)); } log::warn!("Failed to decode FLAC picture, discarding field"); continue; }, } }, // The valid range is 0x20..=0x7D not including 0x3D k if k.iter().all(|c| (b' '..=b'}').contains(c) && *c != b'=') => { // SAFETY: We just verified that all of the bytes fall within the subset of ASCII let key = unsafe { String::from_utf8_unchecked(k.to_vec()) }; match utf8_decode_str(value) { Ok(value) => tag.items.push((key, value.to_owned())), Err(e) => { if parse_mode == ParsingMode::Strict { return Err(e); } log::warn!("Non UTF-8 value found, discarding field {key:?}"); continue; }, } }, _ => { parse_mode_choice!( parse_mode, STRICT: decode_err!(@BAIL "OGG: Vorbis comments contain an invalid key"), // Otherwise discard invalid keys ) }, } } Ok(tag) } pub(crate) fn read_from( data: &mut T, header_sig: &[u8], comment_sig: &[u8], packets_to_read: isize, parse_options: ParseOptions, ) -> Result where T: Read + Seek, { debug_assert!(packets_to_read >= 2); // TODO: Would be nice if we didn't have to read just to seek and reread immediately let start = data.stream_position()?; let first_page_header = PageHeader::read(data)?; data.seek(SeekFrom::Start(start))?; // Read the header packets let packets = Packets::read_count(data, packets_to_read)?; let identification_packet = packets .get(0) .ok_or_else(|| decode_err!("OGG: Expected identification packet"))?; verify_signature(identification_packet, header_sig)?; if !parse_options.read_tags { return Ok((None, first_page_header, packets)); } let mut metadata_packet = packets .get(1) .ok_or_else(|| decode_err!("OGG: Expected comment packet"))?; verify_signature(metadata_packet, comment_sig)?; // Remove the signature from the packet metadata_packet = &metadata_packet[comment_sig.len()..]; let reader = &mut metadata_packet; let tag = read_comments(reader, reader.len() as u64, parse_options)?; Ok((Some(tag), first_page_header, packets)) } lofty-0.21.1/src/ogg/speex/mod.rs000064400000000000000000000022531046102023000146600ustar 00000000000000pub(super) mod properties; use super::tag::VorbisComments; use crate::config::ParseOptions; use crate::error::Result; use crate::ogg::constants::SPEEXHEADER; use properties::SpeexProperties; use std::io::{Read, Seek}; use lofty_attr::LoftyFile; /// An OGG Speex file #[derive(LoftyFile)] #[lofty(read_fn = "Self::read_from")] pub struct SpeexFile { /// The vorbis comments contained in the file /// /// NOTE: While a metadata packet is required, it isn't required to actually have any data. #[lofty(tag_type = "VorbisComments")] pub(crate) vorbis_comments_tag: VorbisComments, /// The file's audio properties pub(crate) properties: SpeexProperties, } impl SpeexFile { fn read_from(reader: &mut R, parse_options: ParseOptions) -> Result where R: Read + Seek, { let file_information = super::read::read_from(reader, SPEEXHEADER, &[], 2, parse_options)?; Ok(Self { properties: if parse_options.read_properties { properties::read_properties(reader, &file_information.1, &file_information.2)? } else { SpeexProperties::default() }, // A metadata packet is mandatory in Speex vorbis_comments_tag: file_information.0.unwrap_or_default(), }) } } lofty-0.21.1/src/ogg/speex/properties.rs000064400000000000000000000122151046102023000162740ustar 00000000000000use crate::error::Result; use crate::macros::decode_err; use crate::ogg::find_last_page; use crate::properties::FileProperties; use crate::util::math::RoundedDivision; use std::io::{Read, Seek, SeekFrom}; use std::time::Duration; use byteorder::{LittleEndian, ReadBytesExt}; use ogg_pager::{Packets, PageHeader}; /// A Speex file's audio properties #[derive(Debug, Copy, Clone, PartialEq, Eq, Default)] #[non_exhaustive] pub struct SpeexProperties { pub(crate) duration: Duration, pub(crate) version: u32, pub(crate) sample_rate: u32, pub(crate) mode: u32, pub(crate) channels: u8, pub(crate) vbr: bool, pub(crate) overall_bitrate: u32, pub(crate) audio_bitrate: u32, pub(crate) nominal_bitrate: i32, } impl From for FileProperties { fn from(input: SpeexProperties) -> Self { Self { duration: input.duration, overall_bitrate: Some(input.overall_bitrate), audio_bitrate: Some(input.audio_bitrate), sample_rate: Some(input.sample_rate), bit_depth: None, channels: Some(input.channels), channel_mask: None, } } } impl SpeexProperties { /// Duration of the audio pub fn duration(&self) -> Duration { self.duration } /// Speex version pub fn version(&self) -> u32 { self.version } /// Sample rate pub fn sample_rate(&self) -> u32 { self.sample_rate } /// Speex encoding mode pub fn mode(&self) -> u32 { self.mode } /// Channel count pub fn channels(&self) -> u8 { self.channels } /// Whether the file makes use of variable bitrate pub fn vbr(&self) -> bool { self.vbr } /// Overall bitrate (kbps) pub fn overall_bitrate(&self) -> u32 { self.overall_bitrate } /// Audio bitrate (kbps) pub fn audio_bitrate(&self) -> u32 { self.audio_bitrate } /// Audio bitrate (bps) pub fn nominal_bitrate(&self) -> i32 { self.nominal_bitrate } } pub(in crate::ogg) fn read_properties( data: &mut R, first_page_header: &PageHeader, packets: &Packets, ) -> Result where R: Read + Seek, { log::debug!("Reading Speex properties"); // Safe to unwrap, it is impossible to get to this point without an // identification header. let identification_packet = packets.get(0).unwrap(); if identification_packet.len() < 80 { decode_err!(@BAIL Speex, "Header packet too small"); } let mut properties = SpeexProperties::default(); // The content we need comes 28 bytes into the packet // // Skipping: // Speex string ("Speex ", 8) // Speex version (20) let identification_packet_reader = &mut &identification_packet[28..]; properties.version = identification_packet_reader.read_u32::()?; if properties.version > 1 { decode_err!(@BAIL Speex, "Unknown Speex stream version"); } // Total size of the speex header let _header_size = identification_packet_reader.read_u32::()?; properties.sample_rate = identification_packet_reader.read_u32::()?; properties.mode = identification_packet_reader.read_u32::()?; // Version ID of the bitstream let _mode_bitstream_version = identification_packet_reader.read_u32::()?; let channels = identification_packet_reader.read_u32::()?; if channels != 1 && channels != 2 { decode_err!(@BAIL Speex, "Found invalid channel count, must be mono or stereo"); } properties.channels = channels as u8; properties.nominal_bitrate = identification_packet_reader.read_i32::()?; // The size of the frames in samples let _frame_size = identification_packet_reader.read_u32::()?; properties.vbr = identification_packet_reader.read_u32::()? == 1; let last_page = find_last_page(data); let file_length = data.seek(SeekFrom::End(0))?; // The stream length is the entire file minus the two mandatory metadata packets let metadata_packets_length = packets.iter().take(2).map(<[u8]>::len).sum::(); let stream_length = file_length.saturating_sub(metadata_packets_length as u64); // This is used for bitrate calculation, it should be the length in // milliseconds, but if we can't determine it then we'll just use 1000. let mut length = 1000; if let Ok(last_page) = last_page { let first_page_abgp = first_page_header.abgp; let last_page_abgp = last_page.header().abgp; if properties.sample_rate > 0 { let total_samples = last_page_abgp.saturating_sub(first_page_abgp); // Best case scenario if total_samples > 0 { length = (total_samples * 1000).div_round(u64::from(properties.sample_rate)); properties.duration = Duration::from_millis(length); } else { log::warn!( "Speex: The file contains invalid PCM values, unable to calculate length" ); } } else { log::warn!("Speex: Sample rate = 0, unable to calculate length"); } } if properties.nominal_bitrate > 0 { properties.overall_bitrate = (file_length.saturating_mul(8) / length) as u32; properties.audio_bitrate = (properties.nominal_bitrate as u64 / 1000) as u32; } else { log::warn!("Nominal bitrate = 0, estimating bitrate from file length"); properties.overall_bitrate = file_length.saturating_mul(8).div_round(length) as u32; properties.audio_bitrate = stream_length.saturating_mul(8).div_round(length) as u32; } Ok(properties) } lofty-0.21.1/src/ogg/tag.rs000064400000000000000000000620321046102023000135310ustar 00000000000000use crate::config::WriteOptions; use crate::error::{LoftyError, Result}; use crate::file::FileType; use crate::macros::err; use crate::ogg::picture_storage::OggPictureStorage; use crate::ogg::write::OGGFormat; use crate::picture::{Picture, PictureInformation}; use crate::probe::Probe; use crate::tag::{ try_parse_year, Accessor, ItemKey, ItemValue, MergeTag, SplitTag, Tag, TagExt, TagItem, TagType, }; use crate::util::flag_item; use crate::util::io::{FileLike, Length, Truncate}; use std::borrow::Cow; use std::io::Write; use std::ops::Deref; use lofty_attr::tag; macro_rules! impl_accessor { ($($name:ident => $key:literal;)+) => { paste::paste! { $( fn $name(&self) -> Option> { self.get($key).map(Cow::Borrowed) } fn [](&mut self, value: String) { self.insert(String::from($key), value) } fn [](&mut self) { let _ = self.remove($key); } )+ } } } /// ## Conversions /// /// ### To `Tag` /// /// All items will be converted to a [`TagItem`], with all unknown keys being stored with [`ItemKey::Unknown`]. /// /// In order to preserve the vendor string, a required part of the OGG formats, it will simply be inserted as /// [`ItemKey::EncoderSoftware`], given an item with this key does not already exist. /// /// ### From `Tag` /// /// If a [`TagItem`] with the key [`ItemKey::EncoderSoftware`] is available, it will be taken and /// used for the vendor string. /// /// [`TagItem`]s with [`ItemKey::Unknown`] will have their keys verified for spec compliance. They must fall within /// ASCII range `0x20` through `0x7D`, excluding `0x3D` ('='). /// /// When converting [Picture]s, they will first be passed through [`PictureInformation::from_picture`]. /// If the information is available, it will be used. Otherwise, the picture will be stored with zeroed out /// [`PictureInformation`]. #[derive(Default, PartialEq, Eq, Debug, Clone)] #[tag( description = "Vorbis comments", supported_formats(Flac, Opus, Speex, Vorbis) )] pub struct VorbisComments { /// An identifier for the encoding software pub(crate) vendor: String, /// A collection of key-value pairs pub(crate) items: Vec<(String, String)>, /// A collection of all pictures pub(crate) pictures: Vec<(Picture, PictureInformation)>, } impl VorbisComments { /// Create a new empty `VorbisComments` /// /// # Examples /// /// ```rust /// use lofty::ogg::VorbisComments; /// use lofty::tag::TagExt; /// /// let vorbis_comments_tag = VorbisComments::new(); /// assert!(vorbis_comments_tag.is_empty()); /// ``` pub fn new() -> Self { Self::default() } /// Returns the vendor string /// /// ```rust /// use lofty::ogg::VorbisComments; /// /// let mut vorbis_comments = VorbisComments::default(); /// assert!(vorbis_comments.vendor().is_empty()); /// /// vorbis_comments.set_vendor(String::from("FooBar")); /// assert_eq!(vorbis_comments.vendor(), "FooBar"); /// ``` pub fn vendor(&self) -> &str { &self.vendor } /// Sets the vendor string /// /// ```rust /// use lofty::ogg::VorbisComments; /// /// let mut vorbis_comments = VorbisComments::default(); /// /// vorbis_comments.set_vendor(String::from("FooBar")); /// assert_eq!(vorbis_comments.vendor(), "FooBar"); /// ``` pub fn set_vendor(&mut self, vendor: String) { self.vendor = vendor } /// Get all items /// /// Returns an [`Iterator`] over the stored key/value pairs. /// /// ```rust /// use lofty::ogg::VorbisComments; /// /// let mut vorbis_comments = VorbisComments::default(); /// /// vorbis_comments.push(String::from("ARTIST"), String::from("Foo artist")); /// vorbis_comments.push(String::from("TITLE"), String::from("Bar title")); /// /// let mut items = vorbis_comments.items(); /// /// assert_eq!(items.next(), Some(("ARTIST", "Foo artist"))); /// assert_eq!(items.next(), Some(("TITLE", "Bar title"))); /// ``` pub fn items(&self) -> impl ExactSizeIterator + Clone { self.items.iter().map(|(k, v)| (k.as_str(), v.as_str())) } /// Consume all items /// /// Returns an [`Iterator`] with the stored key/value pairs. /// /// ```rust /// use lofty::ogg::VorbisComments; /// use lofty::tag::TagExt; /// /// let mut vorbis_comments = VorbisComments::default(); /// /// vorbis_comments.push(String::from("ARTIST"), String::from("Foo artist")); /// vorbis_comments.push(String::from("TITLE"), String::from("Bar title")); /// /// for (key, value) in vorbis_comments.take_items() { /// println!("We took field: {key}={value}"); /// } /// /// // We've taken all the items /// assert!(vorbis_comments.is_empty()); /// ``` pub fn take_items(&mut self) -> impl ExactSizeIterator { let items = std::mem::take(&mut self.items); items.into_iter() } /// Gets the first item with `key` /// /// NOTE: There can be multiple items with the same key, this grabs whichever happens to be the first /// /// # Examples /// /// ```rust /// use lofty::ogg::VorbisComments; /// /// let mut vorbis_comments = VorbisComments::default(); /// /// // Vorbis comments allows multiple fields with the same key, such as artist /// vorbis_comments.push(String::from("ARTIST"), String::from("Foo artist")); /// vorbis_comments.push(String::from("ARTIST"), String::from("Bar artist")); /// vorbis_comments.push(String::from("ARTIST"), String::from("Baz artist")); /// /// let first_artist = vorbis_comments.get("ARTIST").unwrap(); /// assert_eq!(first_artist, "Foo artist"); /// ``` pub fn get(&self, key: &str) -> Option<&str> { if !verify_key(key) { return None; } self.items .iter() .find(|(k, _)| k.eq_ignore_ascii_case(key)) .map(|(_, v)| v.as_str()) } /// Gets all items with the key /// /// # Examples /// /// ```rust /// use lofty::ogg::VorbisComments; /// /// let mut vorbis_comments = VorbisComments::default(); /// /// // Vorbis comments allows multiple fields with the same key, such as artist /// vorbis_comments.push(String::from("ARTIST"), String::from("Foo artist")); /// vorbis_comments.push(String::from("ARTIST"), String::from("Bar artist")); /// vorbis_comments.push(String::from("ARTIST"), String::from("Baz artist")); /// /// let all_artists = vorbis_comments.get_all("ARTIST").collect::>(); /// assert_eq!(all_artists, vec!["Foo artist", "Bar artist", "Baz artist"]); /// ``` pub fn get_all<'a>(&'a self, key: &'a str) -> impl Iterator + Clone + '_ { self.items .iter() .filter_map(move |(k, v)| (k.eq_ignore_ascii_case(key)).then_some(v.as_str())) } /// Inserts an item /// /// This is the same as [`VorbisComments::push`], except it will remove any items with the same key. /// /// NOTE: This will do nothing if the key is invalid. This specification is available [here](https://xiph.org/vorbis/doc/v-comment.html#vectorformat). /// /// # Examples /// /// ```rust /// use lofty::ogg::VorbisComments; /// /// let mut tag = VorbisComments::default(); /// tag.insert(String::from("TITLE"), String::from("Title 1")); /// tag.insert(String::from("TITLE"), String::from("Title 2")); /// /// // We only retain the last title inserted /// let mut titles = tag.get_all("TITLE"); /// assert_eq!(titles.next(), Some("Title 2")); /// assert_eq!(titles.next(), None); /// ``` pub fn insert(&mut self, key: String, value: String) { if !verify_key(&key) { return; } self.items.retain(|(k, _)| !k.eq_ignore_ascii_case(&key)); self.items.push((key, value)) } /// Appends an item /// /// NOTE: This will do nothing if the key is invalid. This specification is available [here](https://xiph.org/vorbis/doc/v-comment.html#vectorformat). /// /// # Examples /// /// ```rust /// use lofty::ogg::VorbisComments; /// /// let mut tag = VorbisComments::default(); /// tag.push(String::from("TITLE"), String::from("Title 1")); /// tag.push(String::from("TITLE"), String::from("Title 2")); /// /// // We retain both titles /// let mut titles = tag.get_all("TITLE"); /// assert_eq!(titles.next(), Some("Title 1")); /// assert_eq!(titles.next(), Some("Title 2")); /// ``` pub fn push(&mut self, key: String, value: String) { if !verify_key(&key) { return; } self.items.push((key, value)) } /// Removes all items with a key, returning an iterator /// /// # Examples /// /// ```rust /// use lofty::ogg::VorbisComments; /// /// let mut tag = VorbisComments::default(); /// tag.push(String::from("TITLE"), String::from("Title 1")); /// tag.push(String::from("TITLE"), String::from("Title 2")); /// /// // Remove both titles /// for title in tag.remove("TITLE") { /// println!("We removed the title: {title}"); /// } /// ``` pub fn remove(&mut self, key: &str) -> impl Iterator + '_ { // TODO: drain_filter let mut split_idx = 0_usize; for read_idx in 0..self.items.len() { if self.items[read_idx].0.eq_ignore_ascii_case(key) { self.items.swap(split_idx, read_idx); split_idx += 1; } } self.items.drain(..split_idx).map(|(_, v)| v) } } // A case-insensitive field name that may consist of ASCII 0x20 through 0x7D, 0x3D ('=') excluded. // ASCII 0x41 through 0x5A inclusive (A-Z) is to be considered equivalent to ASCII 0x61 through 0x7A inclusive (a-z). fn verify_key(key: &str) -> bool { if key.is_empty() { return false; } key.bytes() .all(|byte| (0x20..=0x7D).contains(&byte) && byte != 0x3D) } impl OggPictureStorage for VorbisComments { fn pictures(&self) -> &[(Picture, PictureInformation)] { &self.pictures } } impl Accessor for VorbisComments { impl_accessor!( artist => "ARTIST"; title => "TITLE"; album => "ALBUM"; genre => "GENRE"; comment => "COMMENT"; ); fn track(&self) -> Option { if let Some(item) = self .get("TRACKNUMBER") .map_or_else(|| self.get("TRACKNUM"), Some) { return item.parse::().ok(); } None } fn set_track(&mut self, value: u32) { self.remove_track(); self.insert(String::from("TRACKNUMBER"), value.to_string()); } fn remove_track(&mut self) { let _ = self.remove("TRACKNUMBER"); let _ = self.remove("TRACKNUM"); } fn track_total(&self) -> Option { if let Some(item) = self .get("TRACKTOTAL") .map_or_else(|| self.get("TOTALTRACKS"), Some) { return item.parse::().ok(); } None } fn set_track_total(&mut self, value: u32) { self.insert(String::from("TRACKTOTAL"), value.to_string()); let _ = self.remove("TOTALTRACKS"); } fn remove_track_total(&mut self) { let _ = self.remove("TRACKTOTAL"); let _ = self.remove("TOTALTRACKS"); } fn disk(&self) -> Option { if let Some(item) = self.get("DISCNUMBER") { return item.parse::().ok(); } None } fn set_disk(&mut self, value: u32) { self.insert(String::from("DISCNUMBER"), value.to_string()); } fn remove_disk(&mut self) { let _ = self.remove("DISCNUMBER"); } fn disk_total(&self) -> Option { if let Some(item) = self .get("DISCTOTAL") .map_or_else(|| self.get("TOTALDISCS"), Some) { return item.parse::().ok(); } None } fn set_disk_total(&mut self, value: u32) { self.insert(String::from("DISCTOTAL"), value.to_string()); let _ = self.remove("TOTALDISCS"); } fn remove_disk_total(&mut self) { let _ = self.remove("DISCTOTAL"); let _ = self.remove("TOTALDISCS"); } fn year(&self) -> Option { if let Some(item) = self.get("YEAR").map_or_else(|| self.get("DATE"), Some) { return try_parse_year(item); } None } fn set_year(&mut self, value: u32) { // DATE is the preferred way of storing the year, but it is still possible we will // encounter YEAR self.insert(String::from("DATE"), value.to_string()); let _ = self.remove("YEAR"); } fn remove_year(&mut self) { // DATE is not valid without a year, so we can remove them as well let _ = self.remove("DATE"); let _ = self.remove("YEAR"); } } impl TagExt for VorbisComments { type Err = LoftyError; type RefKey<'a> = &'a str; #[inline] fn tag_type(&self) -> TagType { TagType::VorbisComments } fn len(&self) -> usize { self.items.len() + self.pictures.len() } fn contains<'a>(&'a self, key: Self::RefKey<'a>) -> bool { self.items .iter() .any(|(item_key, _)| item_key.eq_ignore_ascii_case(key)) } fn is_empty(&self) -> bool { self.items.is_empty() && self.pictures.is_empty() } /// Writes the tag to a file /// /// # Errors /// /// * Attempting to write the tag to a format that does not support it /// * The file does not contain valid packets /// * [`PictureInformation::from_picture`] /// * [`std::io::Error`] fn save_to( &self, file: &mut F, write_options: WriteOptions, ) -> std::result::Result<(), Self::Err> where F: FileLike, LoftyError: From<::Error>, LoftyError: From<::Error>, { VorbisCommentsRef { vendor: Cow::from(self.vendor.as_str()), items: self.items.iter().map(|(k, v)| (k.as_str(), v.as_str())), pictures: self.pictures.iter().map(|(p, i)| (p, *i)), } .write_to(file, write_options) } /// Dumps the tag to a writer /// /// This does not include a vendor string, and will thus /// not create a usable file. /// /// # Errors /// /// * [`PictureInformation::from_picture`] /// * [`std::io::Error`] fn dump_to( &self, writer: &mut W, write_options: WriteOptions, ) -> std::result::Result<(), Self::Err> { VorbisCommentsRef { vendor: Cow::from(self.vendor.as_str()), items: self.items.iter().map(|(k, v)| (k.as_str(), v.as_str())), pictures: self.pictures.iter().map(|(p, i)| (p, *i)), } .dump_to(writer, write_options) } fn clear(&mut self) { self.items.clear(); self.pictures.clear(); } } #[derive(Debug, Clone, Default)] pub struct SplitTagRemainder(VorbisComments); impl From for VorbisComments { fn from(from: SplitTagRemainder) -> Self { from.0 } } impl Deref for SplitTagRemainder { type Target = VorbisComments; fn deref(&self) -> &Self::Target { &self.0 } } impl SplitTag for VorbisComments { type Remainder = SplitTagRemainder; fn split_tag(mut self) -> (Self::Remainder, Tag) { let mut tag = Tag::new(TagType::VorbisComments); for (k, v) in std::mem::take(&mut self.items) { tag.items.push(TagItem::new( ItemKey::from_key(TagType::VorbisComments, &k), ItemValue::Text(v), )); } // We need to preserve the vendor string if !tag .items .iter() .any(|i| i.key() == &ItemKey::EncoderSoftware) { tag.items.push(TagItem::new( ItemKey::EncoderSoftware, // Preserve the original vendor by cloning ItemValue::Text(self.vendor.clone()), )); } for (pic, _info) in std::mem::take(&mut self.pictures) { tag.push_picture(pic) } (SplitTagRemainder(self), tag) } } impl MergeTag for SplitTagRemainder { type Merged = VorbisComments; fn merge_tag(self, mut tag: Tag) -> Self::Merged { let Self(mut merged) = self; if let Some(TagItem { item_value: ItemValue::Text(val), .. }) = tag.take(&ItemKey::EncoderSoftware).next() { merged.vendor = val; } for item in tag.items { let item_key = item.item_key; let item_value = item.item_value; // Discard binary items, as they are not allowed in Vorbis comments let (ItemValue::Text(mut val) | ItemValue::Locator(mut val)) = item_value else { continue; }; // Normalize flag items if item_key == ItemKey::FlagCompilation { let Some(flag) = flag_item(&val) else { continue; }; val = u8::from(flag).to_string(); } let key; match item_key { ItemKey::Unknown(unknown) => { if !verify_key(&unknown) { continue; // Bad key, discard the item } key = unknown }, _ => match item_key.map_key(TagType::VorbisComments, false) { Some(mapped_key) => key = mapped_key.to_string(), None => continue, // No mapping exists, discard the item }, } merged.items.push((key, val)); } for picture in tag.pictures { if let Ok(information) = PictureInformation::from_picture(&picture) { merged.pictures.push((picture, information)) } } merged } } impl From for Tag { fn from(input: VorbisComments) -> Self { input.split_tag().1 } } impl From for VorbisComments { fn from(input: Tag) -> Self { SplitTagRemainder::default().merge_tag(input) } } pub(crate) struct VorbisCommentsRef<'a, II, IP> where II: Iterator, IP: Iterator, { pub vendor: Cow<'a, str>, pub items: II, pub pictures: IP, } impl<'a, II, IP> VorbisCommentsRef<'a, II, IP> where II: Iterator, IP: Iterator, { #[allow(clippy::shadow_unrelated)] pub(crate) fn write_to(&mut self, file: &mut F, write_options: WriteOptions) -> Result<()> where F: FileLike, LoftyError: From<::Error>, LoftyError: From<::Error>, { let probe = Probe::new(file).guess_file_type()?; let f_ty = probe.file_type(); let file = probe.into_inner(); let file_type = match f_ty { Some(ft) if VorbisComments::SUPPORTED_FORMATS.contains(&ft) => ft, _ => err!(UnsupportedTag), }; // FLAC has its own special writing needs :) if file_type == FileType::Flac { return crate::flac::write::write_to_inner(file, self, write_options); } let (format, header_packet_count) = OGGFormat::from_filetype(file_type); super::write::write(file, self, format, header_packet_count, write_options) } pub(crate) fn dump_to( &mut self, writer: &mut W, _write_options: WriteOptions, ) -> Result<()> { let metadata_packet = super::write::create_metadata_packet(self, &[], false)?; writer.write_all(&metadata_packet)?; Ok(()) } } pub(crate) fn create_vorbis_comments_ref( tag: &Tag, ) -> ( &str, impl Iterator, impl Iterator, ) { let vendor = tag.get_string(&ItemKey::EncoderSoftware).unwrap_or(""); let items = tag.items.iter().filter_map(|i| match i.value() { ItemValue::Text(val) | ItemValue::Locator(val) => i .key() .map_key(TagType::VorbisComments, true) .map(|key| (key, val.as_str())), _ => None, }); let pictures = tag .pictures .iter() .map(|p| (p, PictureInformation::from_picture(p).unwrap_or_default())); (vendor, items, pictures) } #[cfg(test)] mod tests { use crate::config::{ParseOptions, ParsingMode, WriteOptions}; use crate::ogg::{OggPictureStorage, VorbisComments}; use crate::picture::{MimeType, Picture, PictureType}; use crate::prelude::*; use crate::tag::{ItemValue, Tag, TagItem, TagType}; use std::io::Cursor; fn read_tag(tag: &[u8]) -> VorbisComments { let mut reader = std::io::Cursor::new(tag); crate::ogg::read::read_comments( &mut reader, tag.len() as u64, ParseOptions::new().parsing_mode(ParsingMode::Strict), ) .unwrap() } #[test] fn parse_vorbis_comments() { let mut expected_tag = VorbisComments::default(); expected_tag.set_vendor(String::from("Lavf58.76.100")); expected_tag.push(String::from("ALBUM"), String::from("Baz album")); expected_tag.push(String::from("ARTIST"), String::from("Bar artist")); expected_tag.push(String::from("COMMENT"), String::from("Qux comment")); expected_tag.push(String::from("DATE"), String::from("1984")); expected_tag.push(String::from("GENRE"), String::from("Classical")); expected_tag.push(String::from("TITLE"), String::from("Foo title")); expected_tag.push(String::from("TRACKNUMBER"), String::from("1")); let file_cont = crate::tag::utils::test_utils::read_path("tests/tags/assets/test.vorbis"); let parsed_tag = read_tag(&file_cont); assert_eq!(expected_tag, parsed_tag); } #[test] fn vorbis_comments_re_read() { let file_cont = crate::tag::utils::test_utils::read_path("tests/tags/assets/test.vorbis"); let mut parsed_tag = read_tag(&file_cont); // Create a zero-size vendor for comparison parsed_tag.vendor = String::new(); let mut writer = Vec::new(); parsed_tag .dump_to(&mut writer, WriteOptions::default()) .unwrap(); let temp_parsed_tag = read_tag(&writer); assert_eq!(parsed_tag, temp_parsed_tag); } #[test] fn vorbis_comments_to_tag() { let tag_bytes = std::fs::read("tests/tags/assets/test.vorbis").unwrap(); let vorbis_comments = read_tag(&tag_bytes); let tag: Tag = vorbis_comments.into(); crate::tag::utils::test_utils::verify_tag(&tag, true, true); } #[test] fn tag_to_vorbis_comments() { let tag = crate::tag::utils::test_utils::create_tag(TagType::VorbisComments); let vorbis_comments: VorbisComments = tag.into(); assert_eq!(vorbis_comments.get("TITLE"), Some("Foo title")); assert_eq!(vorbis_comments.get("ARTIST"), Some("Bar artist")); assert_eq!(vorbis_comments.get("ALBUM"), Some("Baz album")); assert_eq!(vorbis_comments.get("COMMENT"), Some("Qux comment")); assert_eq!(vorbis_comments.get("TRACKNUMBER"), Some("1")); assert_eq!(vorbis_comments.get("GENRE"), Some("Classical")); } #[test] fn multi_value_roundtrip() { let mut tag = Tag::new(TagType::VorbisComments); tag.insert_text(ItemKey::TrackArtist, "TrackArtist 1".to_owned()); tag.push(TagItem::new( ItemKey::TrackArtist, ItemValue::Text("TrackArtist 2".to_owned()), )); tag.insert_text(ItemKey::AlbumArtist, "AlbumArtist 1".to_owned()); tag.push(TagItem::new( ItemKey::AlbumArtist, ItemValue::Text("AlbumArtist 2".to_owned()), )); tag.insert_text(ItemKey::TrackTitle, "TrackTitle 1".to_owned()); tag.push(TagItem::new( ItemKey::TrackTitle, ItemValue::Text("TrackTitle 2".to_owned()), )); tag.insert_text(ItemKey::AlbumTitle, "AlbumTitle 1".to_owned()); tag.push(TagItem::new( ItemKey::AlbumTitle, ItemValue::Text("AlbumTitle 2".to_owned()), )); tag.insert_text(ItemKey::Comment, "Comment 1".to_owned()); tag.push(TagItem::new( ItemKey::Comment, ItemValue::Text("Comment 2".to_owned()), )); tag.insert_text(ItemKey::ContentGroup, "ContentGroup 1".to_owned()); tag.push(TagItem::new( ItemKey::ContentGroup, ItemValue::Text("ContentGroup 2".to_owned()), )); tag.insert_text(ItemKey::Genre, "Genre 1".to_owned()); tag.push(TagItem::new( ItemKey::Genre, ItemValue::Text("Genre 2".to_owned()), )); tag.insert_text(ItemKey::Mood, "Mood 1".to_owned()); tag.push(TagItem::new( ItemKey::Mood, ItemValue::Text("Mood 2".to_owned()), )); tag.insert_text(ItemKey::Composer, "Composer 1".to_owned()); tag.push(TagItem::new( ItemKey::Composer, ItemValue::Text("Composer 2".to_owned()), )); tag.insert_text(ItemKey::Conductor, "Conductor 1".to_owned()); tag.push(TagItem::new( ItemKey::Conductor, ItemValue::Text("Conductor 2".to_owned()), )); // Otherwise the following item would be inserted implicitly // during the conversion. tag.insert_text(ItemKey::EncoderSoftware, "EncoderSoftware".to_owned()); assert_eq!(20 + 1, tag.len()); let mut vorbis_comments1 = VorbisComments::from(tag.clone()); let (split_remainder, split_tag) = vorbis_comments1.clone().split_tag(); assert_eq!(0, split_remainder.len()); assert_eq!(tag.len(), split_tag.len()); // Merge back into Vorbis Comments for comparison let mut vorbis_comments2 = split_remainder.merge_tag(split_tag); // Soft before comparison -> unordered comparison vorbis_comments1 .items .sort_by(|(lhs_key, lhs_val), (rhs_key, rhs_val)| { lhs_key.cmp(rhs_key).then_with(|| lhs_val.cmp(rhs_val)) }); vorbis_comments2 .items .sort_by(|(lhs_key, lhs_val), (rhs_key, rhs_val)| { lhs_key.cmp(rhs_key).then_with(|| lhs_val.cmp(rhs_val)) }); assert_eq!(vorbis_comments1.items, vorbis_comments2.items); } #[test] fn zero_sized_vorbis_comments() { let tag_bytes = std::fs::read("tests/tags/assets/zero.vorbis").unwrap(); let _ = read_tag(&tag_bytes); } #[test] fn issue_60() { let tag_bytes = std::fs::read("tests/tags/assets/issue_60.vorbis").unwrap(); let tag = read_tag(&tag_bytes); assert_eq!(tag.pictures().len(), 1); assert!(tag.items.is_empty()); } #[test] fn initial_key_roundtrip() { // Both the primary and alternate key should be mapped to the primary // key if stored again. Note: The outcome is undefined if both the // primary and alternate key would be stored redundantly in VorbisComments! for key in ["INITIALKEY", "KEY"] { let mut vorbis_comments = VorbisComments { items: vec![(key.to_owned(), "Cmaj".to_owned())], ..Default::default() }; let mut tag = Tag::from(vorbis_comments); assert_eq!(Some("Cmaj"), tag.get_string(&ItemKey::InitialKey)); tag.insert_text(ItemKey::InitialKey, "Cmin".to_owned()); vorbis_comments = tag.into(); assert_eq!(Some("Cmin"), vorbis_comments.get("INITIALKEY")); } } #[test] fn skip_reading_cover_art() { let p = Picture::new_unchecked( PictureType::CoverFront, Some(MimeType::Jpeg), None, std::iter::repeat(0).take(50).collect::>(), ); let mut tag = Tag::new(TagType::VorbisComments); tag.push_picture(p); tag.set_artist(String::from("Foo artist")); let mut writer = Vec::new(); tag.dump_to(&mut writer, WriteOptions::new()).unwrap(); let mut reader = Cursor::new(&writer); let tag = crate::ogg::read::read_comments( &mut reader, writer.len() as u64, ParseOptions::new() .parsing_mode(ParsingMode::Strict) .read_cover_art(false), ) .unwrap(); assert_eq!(tag.pictures().len(), 0); // Artist, no picture assert!(tag.artist().is_some()); } } lofty-0.21.1/src/ogg/vorbis/mod.rs000064400000000000000000000024301046102023000150350ustar 00000000000000pub(super) mod properties; use super::find_last_page; use super::tag::VorbisComments; use crate::config::ParseOptions; use crate::error::Result; use crate::ogg::constants::{VORBIS_COMMENT_HEAD, VORBIS_IDENT_HEAD}; use properties::VorbisProperties; use std::io::{Read, Seek}; use lofty_attr::LoftyFile; /// An OGG Vorbis file #[derive(LoftyFile)] #[lofty(read_fn = "Self::read_from")] pub struct VorbisFile { /// The Vorbis Comments contained in the file /// /// NOTE: While a metadata packet is required, it isn't required to actually have any data. #[lofty(tag_type = "VorbisComments")] pub(crate) vorbis_comments_tag: VorbisComments, /// The file's audio properties pub(crate) properties: VorbisProperties, } impl VorbisFile { fn read_from(reader: &mut R, parse_options: ParseOptions) -> Result where R: Read + Seek, { let file_information = super::read::read_from( reader, VORBIS_IDENT_HEAD, VORBIS_COMMENT_HEAD, 3, parse_options, )?; Ok(Self { properties: if parse_options.read_properties { properties::read_properties(reader, &file_information.1, &file_information.2)? } else { VorbisProperties::default() }, // A metadata packet is mandatory in OGG Vorbis vorbis_comments_tag: file_information.0.unwrap_or_default(), }) } } lofty-0.21.1/src/ogg/vorbis/properties.rs000064400000000000000000000075041046102023000164610ustar 00000000000000use super::find_last_page; use crate::error::Result; use crate::properties::FileProperties; use crate::util::math::RoundedDivision; use std::io::{Read, Seek, SeekFrom}; use std::time::Duration; use byteorder::{LittleEndian, ReadBytesExt}; use ogg_pager::{Packets, PageHeader}; /// An OGG Vorbis file's audio properties #[derive(Copy, Clone, Debug, PartialEq, Eq, Default)] #[non_exhaustive] pub struct VorbisProperties { pub(crate) duration: Duration, pub(crate) overall_bitrate: u32, pub(crate) audio_bitrate: u32, pub(crate) sample_rate: u32, pub(crate) channels: u8, pub(crate) version: u32, pub(crate) bitrate_maximum: i32, pub(crate) bitrate_nominal: i32, pub(crate) bitrate_minimum: i32, } impl From for FileProperties { fn from(input: VorbisProperties) -> Self { Self { duration: input.duration, overall_bitrate: Some(input.overall_bitrate), audio_bitrate: Some(input.audio_bitrate), sample_rate: Some(input.sample_rate), bit_depth: None, channels: Some(input.channels), channel_mask: None, } } } impl VorbisProperties { /// Duration of the audio pub fn duration(&self) -> Duration { self.duration } /// Overall bitrate (kbps) pub fn overall_bitrate(&self) -> u32 { self.overall_bitrate } /// Audio bitrate (kbps) pub fn audio_bitrate(&self) -> u32 { self.audio_bitrate } /// Sample rate (Hz) pub fn sample_rate(&self) -> u32 { self.sample_rate } /// Channel count pub fn channels(&self) -> u8 { self.channels } /// Vorbis version pub fn version(&self) -> u32 { self.version } /// Maximum bitrate (bps) pub fn bitrate_max(&self) -> i32 { self.bitrate_maximum } /// Nominal bitrate (bps) pub fn bitrate_nominal(&self) -> i32 { self.bitrate_nominal } /// Minimum bitrate (bps) pub fn bitrate_min(&self) -> i32 { self.bitrate_minimum } } pub(in crate::ogg) fn read_properties( data: &mut R, first_page_header: &PageHeader, packets: &Packets, ) -> Result where R: Read + Seek, { let mut properties = VorbisProperties::default(); // It's impossible to get this far without the identification packet, safe to unwrap let first_packet = packets.get(0).expect("Identification packet expected"); // Skip identification header let first_page_content = &mut &first_packet[7..]; properties.version = first_page_content.read_u32::()?; properties.channels = first_page_content.read_u8()?; properties.sample_rate = first_page_content.read_u32::()?; properties.bitrate_maximum = first_page_content.read_i32::()?; properties.bitrate_nominal = first_page_content.read_i32::()?; properties.bitrate_minimum = first_page_content.read_i32::()?; let last_page = find_last_page(data); let file_length = data.seek(SeekFrom::End(0))?; // This is used for bitrate calculation, it should be the length in // milliseconds, but if we can't determine it then we'll just use 1000. let mut length = 1000; if let Ok(last_page) = last_page { let first_page_abgp = first_page_header.abgp; let last_page_abgp = last_page.header().abgp; if properties.sample_rate > 0 { let total_samples = last_page_abgp.saturating_sub(first_page_abgp) as u128; // Best case scenario if total_samples > 0 { length = (total_samples * 1000).div_round(u128::from(properties.sample_rate)) as u64; properties.duration = Duration::from_millis(length); } else { log::warn!( "Vorbis: The file contains invalid PCM values, unable to calculate length" ); } } else { log::warn!("Vorbis: Sample rate = 0, unable to calculate length"); } } if length > 0 { properties.overall_bitrate = (file_length.saturating_mul(8) / length) as u32; } if properties.bitrate_nominal > 0 { properties.audio_bitrate = (properties.bitrate_nominal as u64 / 1000) as u32; } Ok(properties) } lofty-0.21.1/src/ogg/write.rs000064400000000000000000000150531046102023000141110ustar 00000000000000use super::verify_signature; use crate::config::WriteOptions; use crate::error::{LoftyError, Result}; use crate::file::FileType; use crate::macros::{decode_err, err, try_vec}; use crate::ogg::constants::{OPUSTAGS, VORBIS_COMMENT_HEAD}; use crate::ogg::tag::{create_vorbis_comments_ref, VorbisCommentsRef}; use crate::picture::{Picture, PictureInformation}; use crate::tag::{Tag, TagType}; use crate::util::io::{FileLike, Length, Truncate}; use std::borrow::Cow; use std::io::{Cursor, Read, Seek, SeekFrom, Write}; use byteorder::{LittleEndian, ReadBytesExt, WriteBytesExt}; use ogg_pager::{Packets, Page, PageHeader, CONTAINS_FIRST_PAGE_OF_BITSTREAM}; #[derive(PartialEq, Copy, Clone)] pub(crate) enum OGGFormat { Opus, Vorbis, Speex, } impl OGGFormat { pub(crate) fn comment_signature(self) -> Option<&'static [u8]> { match self { OGGFormat::Opus => Some(OPUSTAGS), OGGFormat::Vorbis => Some(VORBIS_COMMENT_HEAD), OGGFormat::Speex => None, } } pub(super) fn from_filetype(file_type: FileType) -> (Self, isize) { match file_type { FileType::Opus => (OGGFormat::Opus, 2), FileType::Vorbis => (OGGFormat::Vorbis, 3), FileType::Speex => (OGGFormat::Speex, 2), _ => unreachable!("You forgot to add support for FileType::{:?}!", file_type), } } } pub(crate) fn write_to( file: &mut F, tag: &Tag, file_type: FileType, write_options: WriteOptions, ) -> Result<()> where F: FileLike, LoftyError: From<::Error>, LoftyError: From<::Error>, { if tag.tag_type() != TagType::VorbisComments { err!(UnsupportedTag); } let (vendor, items, pictures) = create_vorbis_comments_ref(tag); let mut comments_ref = VorbisCommentsRef { vendor: Cow::from(vendor), items, pictures, }; let (format, header_packet_count) = OGGFormat::from_filetype(file_type); write( file, &mut comments_ref, format, header_packet_count, write_options, ) } pub(super) fn write<'a, F, II, IP>( file: &mut F, tag: &mut VorbisCommentsRef<'a, II, IP>, format: OGGFormat, header_packet_count: isize, _write_options: WriteOptions, ) -> Result<()> where F: FileLike, LoftyError: From<::Error>, LoftyError: From<::Error>, II: Iterator, IP: Iterator, { // TODO: Would be nice if we didn't have to read just to seek and reread immediately // Read the first page header to get the stream serial number let start = file.stream_position()?; let first_page_header = PageHeader::read(file)?; let stream_serial = first_page_header.stream_serial; file.seek(SeekFrom::Start(start))?; let mut packets = Packets::read_count(file, header_packet_count)?; let mut remaining_file_content = Vec::new(); file.read_to_end(&mut remaining_file_content)?; let comment_packet = packets .get(1) .ok_or_else(|| decode_err!("OGG: Expected metadata packet"))?; let comment_signature = format.comment_signature(); if let Some(comment_signature) = comment_signature { verify_signature(comment_packet, comment_signature)?; } let comment_signature = comment_signature.unwrap_or_default(); // Retain the file's vendor string let md_reader = &mut &comment_packet[comment_signature.len()..]; let vendor_len = md_reader.read_u32::()?; let mut vendor = try_vec![0; vendor_len as usize]; md_reader.read_exact(&mut vendor)?; let vendor_str; match String::from_utf8(vendor) { Ok(s) => vendor_str = Cow::Owned(s), Err(_) => { // TODO: Error on strict? log::warn!("OGG vendor string is not valid UTF-8, not re-using"); vendor_str = Cow::Borrowed(""); }, } tag.vendor = vendor_str; let add_framing_bit = format == OGGFormat::Vorbis; let new_metadata_packet = create_metadata_packet(tag, comment_signature, add_framing_bit)?; // Replace the old comment packet packets.set(1, new_metadata_packet); file.rewind()?; file.truncate(0)?; let pages_written = packets.write_to(file, stream_serial, 0, CONTAINS_FIRST_PAGE_OF_BITSTREAM)? as u32; // Correct all remaining page sequence numbers let mut pages_reader = Cursor::new(&remaining_file_content[..]); let mut idx = 0; while let Ok(mut page) = Page::read(&mut pages_reader) { let header = page.header_mut(); header.sequence_number = pages_written + idx; page.gen_crc(); file.write_all(&page.as_bytes())?; idx += 1; } Ok(()) } pub(super) fn create_metadata_packet<'a, II, IP>( tag: &mut VorbisCommentsRef<'a, II, IP>, comment_signature: &[u8], add_framing_bit: bool, ) -> Result> where II: Iterator, IP: Iterator, { let mut new_comment_packet = Cursor::new(Vec::new()); let vendor_bytes = tag.vendor.as_bytes(); new_comment_packet.write_all(comment_signature)?; new_comment_packet.write_u32::(vendor_bytes.len() as u32)?; new_comment_packet.write_all(vendor_bytes)?; // Zero out the item count for later let item_count_pos = new_comment_packet.stream_position()?; new_comment_packet.write_u32::(0)?; let mut count = 0; create_comments(&mut new_comment_packet, &mut count, &mut tag.items)?; create_pictures(&mut new_comment_packet, &mut count, &mut tag.pictures)?; // Seek back and write the item count new_comment_packet.seek(SeekFrom::Start(item_count_pos))?; new_comment_packet.write_u32::(count)?; if add_framing_bit { // OGG Vorbis makes use of a "framing bit" to // separate the header packets // // https://xiph.org/vorbis/doc/Vorbis_I_spec.html#x1-590004 new_comment_packet.get_mut().push(1); } Ok(new_comment_packet.into_inner()) } pub(crate) fn create_comments( packet: &mut impl Write, count: &mut u32, items: &mut dyn Iterator, ) -> Result<()> { for (k, v) in items { if v.is_empty() { continue; } let comment = format!("{k}={v}"); let comment_bytes = comment.as_bytes(); let Ok(bytes_len) = u32::try_from(comment_bytes.len()) else { err!(TooMuchData); }; *count += 1; packet.write_u32::(bytes_len)?; packet.write_all(comment_bytes)?; } Ok(()) } fn create_pictures( packet: &mut impl Write, count: &mut u32, pictures: &mut dyn Iterator, ) -> Result<()> { const PICTURE_KEY: &str = "METADATA_BLOCK_PICTURE="; for (pic, info) in pictures { let picture = pic.as_flac_bytes(info, true); let Ok(bytes_len) = u32::try_from(picture.len() + PICTURE_KEY.len()) else { err!(TooMuchData); }; *count += 1; packet.write_u32::(bytes_len)?; packet.write_all(PICTURE_KEY.as_bytes())?; packet.write_all(&picture)?; } Ok(()) } lofty-0.21.1/src/picture.rs000064400000000000000000000511071046102023000136560ustar 00000000000000//! Format-agnostic picture handling use crate::config::ParsingMode; use crate::error::{ErrorKind, LoftyError, Result}; use crate::macros::err; use crate::util::text::utf8_decode_str; use std::borrow::Cow; use std::fmt::{Debug, Display, Formatter}; use std::io::{Cursor, Read, Seek, SeekFrom}; use byteorder::{BigEndian, ReadBytesExt as _}; use data_encoding::BASE64; /// Common picture item keys for APE pub const APE_PICTURE_TYPES: [&str; 21] = [ "Cover Art (Other)", "Cover Art (Png Icon)", "Cover Art (Icon)", "Cover Art (Front)", "Cover Art (Back)", "Cover Art (Leaflet)", "Cover Art (Media)", "Cover Art (Lead Artist)", "Cover Art (Artist)", "Cover Art (Conductor)", "Cover Art (Band)", "Cover Art (Composer)", "Cover Art (Lyricist)", "Cover Art (Recording Location)", "Cover Art (During Recording)", "Cover Art (During Performance)", "Cover Art (Video Capture)", "Cover Art (Fish)", "Cover Art (Illustration)", "Cover Art (Band Logotype)", "Cover Art (Publisher Logotype)", ]; /// MIME types for pictures. #[derive(Debug, Clone, Eq, PartialEq, Hash)] #[non_exhaustive] pub enum MimeType { /// PNG image Png, /// JPEG image Jpeg, /// TIFF image Tiff, /// BMP image Bmp, /// GIF image Gif, /// Some unknown MIME type Unknown(String), } impl MimeType { /// Get a `MimeType` from a string /// /// # Examples /// /// ```rust /// use lofty::picture::MimeType; /// /// let jpeg_mimetype_str = "image/jpeg"; /// assert_eq!(MimeType::from_str(jpeg_mimetype_str), MimeType::Jpeg); /// ``` #[must_use] #[allow(clippy::should_implement_trait)] // Infallible in contrast to FromStr pub fn from_str(mime_type: &str) -> Self { match &*mime_type.to_lowercase() { "image/jpeg" | "image/jpg" => Self::Jpeg, "image/png" => Self::Png, "image/tiff" => Self::Tiff, "image/bmp" => Self::Bmp, "image/gif" => Self::Gif, _ => Self::Unknown(mime_type.to_owned()), } } /// Get a &str from a `MimeType` /// /// # Examples /// /// ```rust /// use lofty::picture::MimeType; /// /// let jpeg_mimetype = MimeType::Jpeg; /// assert_eq!(jpeg_mimetype.as_str(), "image/jpeg") /// ``` #[must_use] pub fn as_str(&self) -> &str { match self { MimeType::Jpeg => "image/jpeg", MimeType::Png => "image/png", MimeType::Tiff => "image/tiff", MimeType::Bmp => "image/bmp", MimeType::Gif => "image/gif", MimeType::Unknown(unknown) => unknown, } } } impl Display for MimeType { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { f.write_str(self.as_str()) } } /// The picture type, according to ID3v2 APIC #[allow(missing_docs)] #[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)] #[non_exhaustive] pub enum PictureType { Other, Icon, OtherIcon, CoverFront, CoverBack, Leaflet, Media, LeadArtist, Artist, Conductor, Band, Composer, Lyricist, RecordingLocation, DuringRecording, DuringPerformance, ScreenCapture, BrightFish, Illustration, BandLogo, PublisherLogo, Undefined(u8), } impl PictureType { // ID3/OGG specific methods /// Get a `u8` from a `PictureType` according to ID3v2 APIC pub fn as_u8(&self) -> u8 { match self { Self::Other => 0, Self::Icon => 1, Self::OtherIcon => 2, Self::CoverFront => 3, Self::CoverBack => 4, Self::Leaflet => 5, Self::Media => 6, Self::LeadArtist => 7, Self::Artist => 8, Self::Conductor => 9, Self::Band => 10, Self::Composer => 11, Self::Lyricist => 12, Self::RecordingLocation => 13, Self::DuringRecording => 14, Self::DuringPerformance => 15, Self::ScreenCapture => 16, Self::BrightFish => 17, Self::Illustration => 18, Self::BandLogo => 19, Self::PublisherLogo => 20, Self::Undefined(i) => *i, } } /// Get a `PictureType` from a u8 according to ID3v2 APIC pub fn from_u8(byte: u8) -> Self { match byte { 0 => Self::Other, 1 => Self::Icon, 2 => Self::OtherIcon, 3 => Self::CoverFront, 4 => Self::CoverBack, 5 => Self::Leaflet, 6 => Self::Media, 7 => Self::LeadArtist, 8 => Self::Artist, 9 => Self::Conductor, 10 => Self::Band, 11 => Self::Composer, 12 => Self::Lyricist, 13 => Self::RecordingLocation, 14 => Self::DuringRecording, 15 => Self::DuringPerformance, 16 => Self::ScreenCapture, 17 => Self::BrightFish, 18 => Self::Illustration, 19 => Self::BandLogo, 20 => Self::PublisherLogo, i => Self::Undefined(i), } } // APE specific methods /// Get an APE item key from a `PictureType` pub fn as_ape_key(&self) -> Option<&str> { match self { Self::Other => Some("Cover Art (Other)"), Self::Icon => Some("Cover Art (Png Icon)"), Self::OtherIcon => Some("Cover Art (Icon)"), Self::CoverFront => Some("Cover Art (Front)"), Self::CoverBack => Some("Cover Art (Back)"), Self::Leaflet => Some("Cover Art (Leaflet)"), Self::Media => Some("Cover Art (Media)"), Self::LeadArtist => Some("Cover Art (Lead Artist)"), Self::Artist => Some("Cover Art (Artist)"), Self::Conductor => Some("Cover Art (Conductor)"), Self::Band => Some("Cover Art (Band)"), Self::Composer => Some("Cover Art (Composer)"), Self::Lyricist => Some("Cover Art (Lyricist)"), Self::RecordingLocation => Some("Cover Art (Recording Location)"), Self::DuringRecording => Some("Cover Art (During Recording)"), Self::DuringPerformance => Some("Cover Art (During Performance)"), Self::ScreenCapture => Some("Cover Art (Video Capture)"), Self::BrightFish => Some("Cover Art (Fish)"), Self::Illustration => Some("Cover Art (Illustration)"), Self::BandLogo => Some("Cover Art (Band Logotype)"), Self::PublisherLogo => Some("Cover Art (Publisher Logotype)"), Self::Undefined(_) => None, } } /// Get a `PictureType` from an APE item key pub fn from_ape_key(key: &str) -> Self { match key { "Cover Art (Other)" => Self::Other, "Cover Art (Png Icon)" => Self::Icon, "Cover Art (Icon)" => Self::OtherIcon, "Cover Art (Front)" => Self::CoverFront, "Cover Art (Back)" => Self::CoverBack, "Cover Art (Leaflet)" => Self::Leaflet, "Cover Art (Media)" => Self::Media, "Cover Art (Lead Artist)" => Self::LeadArtist, "Cover Art (Artist)" => Self::Artist, "Cover Art (Conductor)" => Self::Conductor, "Cover Art (Band)" => Self::Band, "Cover Art (Composer)" => Self::Composer, "Cover Art (Lyricist)" => Self::Lyricist, "Cover Art (Recording Location)" => Self::RecordingLocation, "Cover Art (During Recording)" => Self::DuringRecording, "Cover Art (During Performance)" => Self::DuringPerformance, "Cover Art (Video Capture)" => Self::ScreenCapture, "Cover Art (Fish)" => Self::BrightFish, "Cover Art (Illustration)" => Self::Illustration, "Cover Art (Band Logotype)" => Self::BandLogo, "Cover Art (Publisher Logotype)" => Self::PublisherLogo, _ => Self::Undefined(0), } } } /// Information about a [`Picture`] /// /// This information is necessary for FLAC's `METADATA_BLOCK_PICTURE`. /// See [`Picture::as_flac_bytes`] for more information. #[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Default)] pub struct PictureInformation { /// The picture's width in pixels pub width: u32, /// The picture's height in pixels pub height: u32, /// The picture's color depth in bits per pixel pub color_depth: u32, /// The number of colors used pub num_colors: u32, } impl PictureInformation { /// Attempt to extract [`PictureInformation`] from a [`Picture`] /// /// NOTE: This only supports PNG and JPEG images. If another image is provided, /// the `PictureInformation` will be zeroed out. /// /// # Errors /// /// * `picture.data` is less than 8 bytes in length /// * See [`PictureInformation::from_png`] and [`PictureInformation::from_jpeg`] pub fn from_picture(picture: &Picture) -> Result { let reader = &mut &*picture.data; if reader.len() < 8 { err!(NotAPicture); } match reader[..4] { [0x89, b'P', b'N', b'G'] => Ok(Self::from_png(reader).unwrap_or_default()), [0xFF, 0xD8, 0xFF, ..] => Ok(Self::from_jpeg(reader).unwrap_or_default()), _ => Ok(Self::default()), } } /// Attempt to extract [`PictureInformation`] from a PNG /// /// # Errors /// /// * `reader` is not a valid PNG pub fn from_png(mut data: &[u8]) -> Result { let reader = &mut data; let mut sig = [0; 8]; reader.read_exact(&mut sig)?; if sig != [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A] { err!(NotAPicture); } let mut ihdr = [0; 8]; reader.read_exact(&mut ihdr)?; // Verify the signature is immediately followed by the IHDR chunk if !ihdr.ends_with(&[0x49, 0x48, 0x44, 0x52]) { err!(NotAPicture); } let width = reader.read_u32::()?; let height = reader.read_u32::()?; let mut color_depth = u32::from(reader.read_u8()?); let color_type = reader.read_u8()?; match color_type { 2 => color_depth *= 3, 4 | 6 => color_depth *= 4, _ => {}, } let mut ret = Self { width, height, color_depth, num_colors: 0, }; // The color type 3 (indexed-color) means there should be // a "PLTE" chunk, whose data can be used in the `num_colors` // field. It isn't really applicable to other color types. if color_type != 3 { return Ok(ret); } let mut reader = Cursor::new(reader); // Skip 7 bytes // Compression method (1) // Filter method (1) // Interlace method (1) // CRC (4) reader.seek(SeekFrom::Current(7))?; let mut chunk_type = [0; 4]; while let (Ok(size), Ok(())) = ( reader.read_u32::(), reader.read_exact(&mut chunk_type), ) { if &chunk_type == b"PLTE" { // The PLTE chunk contains 1-256 3-byte entries ret.num_colors = size / 3; break; } // Skip the chunk's data (size) and CRC (4 bytes) let (content_size, overflowed) = size.overflowing_add(4); if overflowed { break; } reader.seek(SeekFrom::Current(i64::from(content_size)))?; } Ok(ret) } /// Attempt to extract [`PictureInformation`] from a JPEG /// /// # Errors /// /// * `reader` is not a JPEG image /// * `reader` does not contain a `SOFn` frame pub fn from_jpeg(mut data: &[u8]) -> Result { let reader = &mut data; let mut frame_marker = [0; 4]; reader.read_exact(&mut frame_marker)?; if !matches!(frame_marker, [0xFF, 0xD8, 0xFF, ..]) { err!(NotAPicture); } let mut section_len = reader.read_u16::()?; let mut reader = Cursor::new(reader); // The length contains itself, so anything < 2 is invalid let (content_len, overflowed) = section_len.overflowing_sub(2); if overflowed { err!(NotAPicture); } reader.seek(SeekFrom::Current(i64::from(content_len)))?; while let Ok(0xFF) = reader.read_u8() { let marker = reader.read_u8()?; section_len = reader.read_u16::()?; // This marks the SOS (Start of Scan), which is // the end of the header if marker == 0xDA { break; } // We are looking for a frame with a "SOFn" marker, // with `n` either being 0 or 2. Since there isn't a // header like PNG, we actually need to search for this // frame if marker == 0xC0 || marker == 0xC2 { let precision = reader.read_u8()?; let height = u32::from(reader.read_u16::()?); let width = u32::from(reader.read_u16::()?); let components = reader.read_u8()?; return Ok(Self { width, height, color_depth: u32::from(precision * components), num_colors: 0, }); } reader.seek(SeekFrom::Current(i64::from(section_len - 2)))?; } err!(NotAPicture) } } /// Represents a picture. #[derive(Clone, Eq, PartialEq, Hash)] pub struct Picture { /// The picture type according to ID3v2 APIC pub(crate) pic_type: PictureType, /// The picture's mimetype pub(crate) mime_type: Option, /// The picture's description pub(crate) description: Option>, /// The binary data of the picture pub(crate) data: Cow<'static, [u8]>, } impl Debug for Picture { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { f.debug_struct("Picture") .field("pic_type", &self.pic_type) .field("mime_type", &self.mime_type) .field("description", &self.description) .field("data", &format!("<{} bytes>", self.data.len())) .finish() } } impl Picture { /// Create a [`Picture`] from a reader /// /// NOTES: /// /// * This is for reading picture data only, from a [`File`](std::fs::File) for example. /// * `pic_type` will always be [`PictureType::Other`], be sure to change it accordingly if /// writing. /// /// # Errors /// /// * `reader` contains less than 8 bytes /// * `reader` does not contain a supported format. See [`MimeType`] for valid formats pub fn from_reader(reader: &mut R) -> Result where R: Read, { let mut data = Vec::new(); reader.read_to_end(&mut data)?; if data.len() < 8 { err!(NotAPicture); } let mime_type = Self::mimetype_from_bin(&data[..8])?; Ok(Self { pic_type: PictureType::Other, mime_type: Some(mime_type), description: None, data: data.into(), }) } /// Create a new `Picture` /// /// NOTE: This will **not** verify `data`'s signature. /// This should only be used if all data has been verified /// beforehand. pub fn new_unchecked( pic_type: PictureType, mime_type: Option, description: Option, data: Vec, ) -> Self { Self { pic_type, mime_type, description: description.map(Cow::Owned), data: Cow::Owned(data), } } /// Returns the [`PictureType`] pub fn pic_type(&self) -> PictureType { self.pic_type } /// Sets the [`PictureType`] pub fn set_pic_type(&mut self, pic_type: PictureType) { self.pic_type = pic_type } /// Returns the [`MimeType`] /// /// The `mime_type` is determined from the `data`, and /// is immutable. pub fn mime_type(&self) -> Option<&MimeType> { self.mime_type.as_ref() } // Used commonly internally pub(crate) fn mime_str(&self) -> &str { match self.mime_type.as_ref() { Some(mime_type) => mime_type.as_str(), None => "", } } /// Returns the description pub fn description(&self) -> Option<&str> { self.description.as_deref() } /// Sets the description pub fn set_description(&mut self, description: Option) { self.description = description.map(Cow::from); } /// Returns the [`Picture`] data as borrowed bytes. pub fn data(&self) -> &[u8] { &self.data } /// Consumes a [`Picture`], returning the data as [`Vec`] without clones or allocation. pub fn into_data(self) -> Vec { self.data.into_owned() } /// Convert a [`Picture`] to a base64 encoded FLAC `METADATA_BLOCK_PICTURE` String /// /// Use `encode` to convert the picture to a base64 encoded String ([RFC 4648 §4](http://www.faqs.org/rfcs/rfc4648.html)) /// /// NOTES: /// /// * This does not include a key (Vorbis comments) or METADATA_BLOCK_HEADER (FLAC blocks) /// * FLAC blocks have different size requirements than OGG Vorbis/Opus, size is not checked here /// * When writing to Vorbis comments, the data **must** be base64 encoded pub fn as_flac_bytes(&self, picture_information: PictureInformation, encode: bool) -> Vec { let mut data = Vec::::new(); let picture_type = u32::from(self.pic_type.as_u8()).to_be_bytes(); let mime_str = self.mime_str(); let mime_len = mime_str.len() as u32; data.extend(picture_type); data.extend(mime_len.to_be_bytes()); data.extend(mime_str.as_bytes()); if let Some(desc) = &self.description { let desc_len = desc.len() as u32; data.extend(desc_len.to_be_bytes()); data.extend(desc.as_bytes()); } else { data.extend([0; 4]); } data.extend(picture_information.width.to_be_bytes()); data.extend(picture_information.height.to_be_bytes()); data.extend(picture_information.color_depth.to_be_bytes()); data.extend(picture_information.num_colors.to_be_bytes()); let pic_data = &self.data; let pic_data_len = pic_data.len() as u32; data.extend(pic_data_len.to_be_bytes()); data.extend(pic_data.iter()); if encode { BASE64.encode(&data).into_bytes() } else { data } } /// Get a [`Picture`] from FLAC `METADATA_BLOCK_PICTURE` bytes: /// /// NOTE: This takes both the base64 encoded string from Vorbis comments, and /// the raw data from a FLAC block, specified with `encoded`. /// /// # Errors /// /// This function will return [`NotAPicture`][ErrorKind::NotAPicture] if /// at any point it's unable to parse the data pub fn from_flac_bytes( bytes: &[u8], encoded: bool, parse_mode: ParsingMode, ) -> Result<(Self, PictureInformation)> { if encoded { let data = BASE64 .decode(bytes) .map_err(|_| LoftyError::new(ErrorKind::NotAPicture))?; Self::from_flac_bytes_inner(&data, parse_mode) } else { Self::from_flac_bytes_inner(bytes, parse_mode) } } fn from_flac_bytes_inner( content: &[u8], parse_mode: ParsingMode, ) -> Result<(Self, PictureInformation)> { use crate::macros::try_vec; let mut size = content.len(); let mut reader = Cursor::new(content); if size < 32 { err!(NotAPicture); } let pic_ty = reader.read_u32::()?; size -= 4; // ID3v2 APIC uses a single byte for picture type. // Anything greater than that is probably invalid, so // we just stop early if pic_ty > 255 && parse_mode == ParsingMode::Strict { err!(NotAPicture); } let mime_len = reader.read_u32::()? as usize; size -= 4; if mime_len > size { err!(SizeMismatch); } let mime_type_str = utf8_decode_str(&content[8..8 + mime_len])?; size -= mime_len; reader.seek(SeekFrom::Current(mime_len as i64))?; let desc_len = reader.read_u32::()? as usize; size -= 4; let mut description = None; if desc_len > 0 && desc_len < size { let pos = 12 + mime_len; if let Ok(desc) = utf8_decode_str(&content[pos..pos + desc_len]) { description = Some(desc.to_owned().into()); } size -= desc_len; reader.seek(SeekFrom::Current(desc_len as i64))?; } let width = reader.read_u32::()?; let height = reader.read_u32::()?; let color_depth = reader.read_u32::()?; let num_colors = reader.read_u32::()?; let data_len = reader.read_u32::()? as usize; size -= 20; if data_len <= size { let mut data = try_vec![0; data_len]; if let Ok(()) = reader.read_exact(&mut data) { let mime_type; if mime_type_str.is_empty() { mime_type = None; } else { mime_type = Some(MimeType::from_str(mime_type_str)); } return Ok(( Self { pic_type: PictureType::from_u8(pic_ty as u8), mime_type, description, data: Cow::from(data), }, PictureInformation { width, height, color_depth, num_colors, }, )); } } err!(NotAPicture) } /// Convert a [`Picture`] to an APE Cover Art byte vec: /// /// NOTE: This is only the picture data and description, a /// key and terminating null byte will not be prepended. /// To map a [`PictureType`] to an APE key see [`PictureType::as_ape_key`] pub fn as_ape_bytes(&self) -> Vec { let mut data: Vec = Vec::new(); if let Some(desc) = &self.description { data.extend(desc.as_bytes()); } data.push(0); data.extend(self.data.iter()); data } /// Get a [`Picture`] from an APEv2 binary item: /// /// NOTE: This function expects `bytes` to contain *only* the APE item data /// /// # Errors /// /// This function will return [`NotAPicture`](ErrorKind::NotAPicture) /// if at any point it's unable to parse the data pub fn from_ape_bytes(key: &str, bytes: &[u8]) -> Result { if bytes.is_empty() { err!(NotAPicture); } let pic_type = PictureType::from_ape_key(key); let reader = &mut &*bytes; let mut pos = 0; let mut description = None; let mut desc_text = String::new(); while let Ok(ch) = reader.read_u8() { pos += 1; if ch == b'\0' { break; } desc_text.push(char::from(ch)); } if !desc_text.is_empty() { description = Some(Cow::from(desc_text)); } let mime_type = { let mut identifier = [0; 8]; reader.read_exact(&mut identifier)?; Self::mimetype_from_bin(&identifier[..])? }; let data = Cow::from(bytes[pos..].to_vec()); Ok(Picture { pic_type, mime_type: Some(mime_type), description, data, }) } pub(crate) fn mimetype_from_bin(bytes: &[u8]) -> Result { match bytes[..8] { [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A] => Ok(MimeType::Png), [0xFF, 0xD8, ..] => Ok(MimeType::Jpeg), [b'G', b'I', b'F', 0x38, 0x37 | 0x39, b'a', ..] => Ok(MimeType::Gif), [b'B', b'M', ..] => Ok(MimeType::Bmp), [b'I', b'I', b'*', 0x00, ..] | [b'M', b'M', 0x00, b'*', ..] => Ok(MimeType::Tiff), _ => err!(NotAPicture), } } } // A placeholder that is needed during conversions. pub(crate) const TOMBSTONE_PICTURE: Picture = Picture { pic_type: PictureType::Other, mime_type: None, description: None, data: Cow::Owned(Vec::new()), }; lofty-0.21.1/src/probe.rs000064400000000000000000000522111046102023000133070ustar 00000000000000//! Format-agonostic file parsing tools use crate::aac::AacFile; use crate::ape::ApeFile; use crate::config::{global_options, ParseOptions}; use crate::error::Result; use crate::file::{AudioFile, FileType, FileTypeGuessResult, TaggedFile}; use crate::flac::FlacFile; use crate::iff::aiff::AiffFile; use crate::iff::wav::WavFile; use crate::macros::err; use crate::mp4::Mp4File; use crate::mpeg::header::search_for_frame_sync; use crate::mpeg::MpegFile; use crate::musepack::MpcFile; use crate::ogg::opus::OpusFile; use crate::ogg::speex::SpeexFile; use crate::ogg::vorbis::VorbisFile; use crate::resolve::custom_resolvers; use crate::wavpack::WavPackFile; use std::fs::File; use std::io::{BufReader, Cursor, Read, Seek, SeekFrom}; use std::path::Path; /// A format agnostic reader /// /// This provides a way to determine the [`FileType`] of a reader, for when a concrete /// type is not known. /// /// ## Usage /// /// When reading from a path, the [`FileType`] will be inferred from the path, rather than the /// open file. /// /// ```rust,no_run /// # fn main() -> lofty::error::Result<()> { /// use lofty::file::FileType; /// use lofty::probe::Probe; /// /// let probe = Probe::open("path/to/my.mp3")?; /// /// // Inferred from the `mp3` extension /// assert_eq!(probe.file_type(), Some(FileType::Mpeg)); /// # Ok(()) /// # } /// ``` /// /// When a path isn't available, or is unreliable, content-based detection is also possible. /// /// ```rust,no_run /// # fn main() -> lofty::error::Result<()> { /// use lofty::file::FileType; /// use lofty::probe::Probe; /// /// // Our same path probe with a guessed file type /// let probe = Probe::open("path/to/my.mp3")?.guess_file_type()?; /// /// // Inferred from the file's content /// assert_eq!(probe.file_type(), Some(FileType::Mpeg)); /// # Ok(()) /// # } /// ``` /// /// Or with another reader /// /// ```rust /// # fn main() -> lofty::error::Result<()> { /// use lofty::file::FileType; /// use lofty::probe::Probe; /// use std::io::Cursor; /// /// static MAC_HEADER: &[u8; 3] = b"MAC"; /// /// let probe = Probe::new(Cursor::new(MAC_HEADER)).guess_file_type()?; /// /// // Inferred from the MAC header /// assert_eq!(probe.file_type(), Some(FileType::Ape)); /// # Ok(()) /// # } /// ``` pub struct Probe { inner: R, options: Option, f_ty: Option, } impl Probe { /// Create a new `Probe` /// /// Before creating a `Probe`, consider wrapping it in a [`BufReader`] for better /// performance. /// /// # Examples /// /// ```rust /// use lofty::probe::Probe; /// use std::fs::File; /// use std::io::BufReader; /// /// # fn main() -> lofty::error::Result<()> { /// # let path = "tests/files/assets/minimal/full_test.mp3"; /// let file = File::open(path)?; /// let reader = BufReader::new(file); /// /// let probe = Probe::new(reader); /// # Ok(()) } /// ``` #[must_use] pub const fn new(reader: R) -> Self { Self { inner: reader, options: None, f_ty: None, } } /// Create a new `Probe` with a specified [`FileType`] /// /// Before creating a `Probe`, consider wrapping it in a [`BufReader`] for better /// performance. /// /// # Examples /// /// ```rust /// use lofty::file::FileType; /// use lofty::probe::Probe; /// use std::fs::File; /// use std::io::BufReader; /// /// # fn main() -> lofty::error::Result<()> { /// # let my_mp3_path = "tests/files/assets/minimal/full_test.mp3"; /// // We know the file is going to be an MP3, /// // so we can skip the format detection /// let file = File::open(my_mp3_path)?; /// let reader = BufReader::new(file); /// /// let probe = Probe::with_file_type(reader, FileType::Mpeg); /// # Ok(()) } /// ``` pub fn with_file_type(reader: R, file_type: FileType) -> Self { Self { inner: reader, options: None, f_ty: Some(file_type), } } /// Returns the current [`FileType`] /// /// # Examples /// /// ```rust /// use lofty::file::FileType; /// use lofty::probe::Probe; /// /// # fn main() -> lofty::error::Result<()> { /// # let reader = std::io::Cursor::new(&[]); /// let probe = Probe::new(reader); /// /// let file_type = probe.file_type(); /// # Ok(()) } /// ``` pub fn file_type(&self) -> Option { self.f_ty } /// Set the [`FileType`] with which to read the file /// /// # Examples /// /// ```rust /// use lofty::file::FileType; /// use lofty::probe::Probe; /// /// # fn main() -> lofty::error::Result<()> { /// # let reader = std::io::Cursor::new(&[]); /// let mut probe = Probe::new(reader); /// assert_eq!(probe.file_type(), None); /// /// let probe = probe.set_file_type(FileType::Mpeg); /// /// assert_eq!(probe.file_type(), Some(FileType::Mpeg)); /// # Ok(()) } /// ``` pub fn set_file_type(mut self, file_type: FileType) -> Self { self.f_ty = Some(file_type); self } /// Set the [`ParseOptions`] for the Probe /// /// # Examples /// /// ```rust /// use lofty::config::ParseOptions; /// use lofty::probe::Probe; /// /// # fn main() -> lofty::error::Result<()> { /// # let reader = std::io::Cursor::new(&[]); /// // By default, properties will be read. /// // In this example, we want to turn this off. /// let options = ParseOptions::new().read_properties(false); /// /// let probe = Probe::new(reader).options(options); /// # Ok(()) } /// ``` #[must_use] pub fn options(mut self, options: ParseOptions) -> Self { self.options = Some(options); self } /// Extract the reader /// /// # Examples /// /// ```rust /// use lofty::file::FileType; /// use lofty::probe::Probe; /// /// # fn main() -> lofty::error::Result<()> { /// # let reader = std::io::Cursor::new(&[]); /// let probe = Probe::new(reader); /// /// let reader = probe.into_inner(); /// # Ok(()) } /// ``` pub fn into_inner(self) -> R { self.inner } } impl Probe> { /// Opens a file for reading /// /// This will initially guess the [`FileType`] from the path, but /// this can be overwritten with [`Probe::guess_file_type`] or [`Probe::set_file_type`] /// /// # Errors /// /// * `path` does not exist /// /// # Examples /// /// ```rust,no_run /// use lofty::file::FileType; /// use lofty::probe::Probe; /// /// # fn main() -> lofty::error::Result<()> { /// let probe = Probe::open("path/to/my.mp3")?; /// /// // Guessed from the "mp3" extension, see `FileType::from_ext` /// assert_eq!(probe.file_type(), Some(FileType::Mpeg)); /// # Ok(()) } /// ``` pub fn open

(path: P) -> Result where P: AsRef, { let path = path.as_ref(); log::debug!("Probe: Opening `{}` for reading", path.display()); let file_type = FileType::from_path(path); log::debug!("Probe: Guessed file type `{:?}` from extension", file_type); Ok(Self { inner: BufReader::new(File::open(path)?), options: None, f_ty: file_type, }) } } impl Probe { /// Attempts to get the [`FileType`] based on the data in the reader /// /// On success, the file type will be replaced /// /// NOTE: The chance for succeeding is influenced by [`ParseOptions`]. /// Be sure to set it with [`Probe::options()`] prior to calling this method. /// Some files may require more than the default [`ParseOptions::DEFAULT_MAX_JUNK_BYTES`] to be detected successfully. /// /// # Errors /// /// All errors that occur within this function are [`std::io::Error`]. /// If an error does occur, there is likely an issue with the provided /// reader, and the entire `Probe` should be discarded. /// /// # Examples /// /// ```rust /// use lofty::file::FileType; /// use lofty::probe::Probe; /// /// # fn main() -> lofty::error::Result<()> { /// # let path = "tests/files/assets/minimal/full_test.mp3"; /// # let file = std::fs::File::open(path)?; /// # let reader = std::io::BufReader::new(file); /// let probe = Probe::new(reader).guess_file_type()?; /// /// // Determined the file is MP3 from the content /// assert_eq!(probe.file_type(), Some(FileType::Mpeg)); /// # Ok(()) } /// ``` pub fn guess_file_type(mut self) -> std::io::Result { let max_junk_bytes = self .options .map_or(ParseOptions::DEFAULT_MAX_JUNK_BYTES, |options| { options.max_junk_bytes }); let f_ty = self.guess_inner(max_junk_bytes)?; self.f_ty = f_ty.or(self.f_ty); log::debug!("Probe: Guessed file type: {:?}", self.f_ty); Ok(self) } #[allow(clippy::shadow_unrelated)] fn guess_inner(&mut self, max_junk_bytes: usize) -> std::io::Result> { // temporary buffer for storing 36 bytes // (36 is just a guess as to how long the data for estimating the file type might be) let mut buf = [0; 36]; let starting_position = self.inner.stream_position()?; // Read (up to) 36 bytes let buf_len = std::io::copy( &mut self.inner.by_ref().take(buf.len() as u64), &mut Cursor::new(&mut buf[..]), )? as usize; self.inner.seek(SeekFrom::Start(starting_position))?; // Give custom resolvers priority if unsafe { global_options().use_custom_resolvers } { if let Ok(lock) = custom_resolvers().lock() { #[allow(clippy::significant_drop_in_scrutinee)] for (_, resolve) in lock.iter() { if let ret @ Some(_) = resolve.guess(&buf[..buf_len]) { return Ok(ret); } } } } // Guess the file type by using these 36 bytes let Some(file_type_guess) = FileType::from_buffer_inner(&buf[..buf_len]) else { return Ok(None); }; match file_type_guess { // We were able to determine a file type FileTypeGuessResult::Determined(file_ty) => Ok(Some(file_ty)), // The file starts with an ID3v2 tag; this means other data can follow (e.g. APE or MP3 frames) FileTypeGuessResult::MaybePrecededById3(id3_len) => { // `id3_len` is the size of the tag, not including the header (10 bytes) log::debug!("Probe: ID3v2 tag detected, skipping {} bytes", 10 + id3_len); let position_after_id3_block = self .inner .seek(SeekFrom::Current(i64::from(10 + id3_len)))?; // try to guess the file type after the ID3 block by inspecting the first 4 bytes let mut ident = [0; 4]; std::io::copy( &mut self.inner.by_ref().take(ident.len() as u64), &mut Cursor::new(&mut ident[..]), )?; self.inner.seek(SeekFrom::Start(position_after_id3_block))?; let file_type_after_id3_block = match &ident { [b'M', b'A', b'C', ..] => Ok(Some(FileType::Ape)), b"fLaC" => Ok(Some(FileType::Flac)), b"MPCK" | [b'M', b'P', b'+', ..] => Ok(Some(FileType::Mpc)), // Search for a frame sync, which may be preceded by junk _ => self.check_mpeg_or_aac(max_junk_bytes), }; // before returning any result for a file type, seek back to the front self.inner.seek(SeekFrom::Start(starting_position))?; file_type_after_id3_block }, // TODO: Check more than MPEG/AAC FileTypeGuessResult::MaybePrecededByJunk => { log::debug!( "Probe: Possible junk bytes detected, searching up to {} bytes", max_junk_bytes ); let ret = self.check_mpeg_or_aac(max_junk_bytes); // before returning any result for a file type, seek back to the front self.inner.seek(SeekFrom::Start(starting_position))?; ret }, } } /// Searches for an MPEG/AAC frame sync, which may be preceded by junk bytes fn check_mpeg_or_aac(&mut self, max_junk_bytes: usize) -> std::io::Result> { { let mut restricted_reader = self.inner.by_ref().take(max_junk_bytes as u64); if search_for_frame_sync(&mut restricted_reader)?.is_none() { return Ok(None); } } // Seek back to the start of the frame sync to check if we are dealing with // an AAC or MPEG file. See `FileType::quick_type_guess` for explanation. let sync_pos = self.inner.seek(SeekFrom::Current(-2))?; log::debug!("Probe: Found possible frame sync at position {}", sync_pos); let mut buf = [0; 2]; self.inner.read_exact(&mut buf)?; if buf[1] & 0b10000 > 0 && buf[1] & 0b110 == 0 { Ok(Some(FileType::Aac)) } else { Ok(Some(FileType::Mpeg)) } } /// Attempts to extract a [`TaggedFile`] from the reader /// /// If `read_properties` is false, the properties will be zeroed out. /// /// # Errors /// /// * No file type /// - This expects the file type to have been set already, either with /// [`Probe::guess_file_type`] or [`Probe::set_file_type`]. When reading from /// paths, this is not necessary. /// * The reader contains invalid data /// /// # Panics /// /// If an unregistered `FileType` ([`FileType::Custom`]) is encountered. See [`register_custom_resolver`](crate::resolve::register_custom_resolver). /// /// # Examples /// /// ```rust /// use lofty::file::FileType; /// use lofty::probe::Probe; /// /// # fn main() -> lofty::error::Result<()> { /// # let path = "tests/files/assets/minimal/full_test.mp3"; /// # let file = std::fs::File::open(path)?; /// # let reader = std::io::BufReader::new(file); /// let probe = Probe::new(reader).guess_file_type()?; /// /// let parsed_file = probe.read()?; /// # Ok(()) } /// ``` pub fn read(mut self) -> Result { let reader = &mut self.inner; let options = self.options.unwrap_or_default(); if !options.read_tags && !options.read_properties { log::warn!("Skipping both tag and property reading, file will be empty"); } match self.f_ty { Some(f_type) => Ok(match f_type { FileType::Aac => AacFile::read_from(reader, options)?.into(), FileType::Aiff => AiffFile::read_from(reader, options)?.into(), FileType::Ape => ApeFile::read_from(reader, options)?.into(), FileType::Flac => FlacFile::read_from(reader, options)?.into(), FileType::Mpeg => MpegFile::read_from(reader, options)?.into(), FileType::Opus => OpusFile::read_from(reader, options)?.into(), FileType::Vorbis => VorbisFile::read_from(reader, options)?.into(), FileType::Wav => WavFile::read_from(reader, options)?.into(), FileType::Mp4 => Mp4File::read_from(reader, options)?.into(), FileType::Mpc => MpcFile::read_from(reader, options)?.into(), FileType::Speex => SpeexFile::read_from(reader, options)?.into(), FileType::WavPack => WavPackFile::read_from(reader, options)?.into(), FileType::Custom(c) => { if !unsafe { global_options().use_custom_resolvers } { err!(UnknownFormat) } let resolver = crate::resolve::lookup_resolver(c); resolver.read_from(reader, options)? }, }), None => err!(UnknownFormat), } } } /// Read a [`TaggedFile`] from a [File] /// /// # Errors /// /// See: /// /// * [`Probe::guess_file_type`] /// * [`Probe::read`] /// /// # Examples /// /// ```rust /// use lofty::read_from; /// use std::fs::File; /// /// # fn main() -> lofty::error::Result<()> { /// # let path = "tests/files/assets/minimal/full_test.mp3"; /// let mut file = File::open(path)?; /// /// let parsed_file = read_from(&mut file)?; /// # Ok(()) } /// ``` pub fn read_from(file: &mut File) -> Result { Probe::new(BufReader::new(file)).guess_file_type()?.read() } /// Read a [`TaggedFile`] from a path /// /// NOTE: This will determine the [`FileType`] from the extension /// /// # Errors /// /// See: /// /// * [`Probe::open`] /// * [`Probe::read`] /// /// # Examples /// /// ```rust /// use lofty::read_from_path; /// /// # fn main() -> lofty::error::Result<()> { /// # let path = "tests/files/assets/minimal/full_test.mp3"; /// let parsed_file = read_from_path(path)?; /// # Ok(()) } /// ``` pub fn read_from_path

(path: P) -> Result where P: AsRef, { Probe::open(path)?.read() } #[cfg(test)] mod tests { use crate::config::{GlobalOptions, ParseOptions}; use crate::file::FileType; use crate::probe::Probe; use std::fs::File; #[test] fn mp3_id3v2_trailing_junk() { // test data that contains 4 bytes of junk (0x20) between the ID3 portion and the first MP3 frame let data: [&[u8]; 4] = [ // ID3v2.3 header (10 bytes) &[0x49, 0x44, 0x33, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x23], // TALB frame &[ 0x54, 0x41, 0x4C, 0x42, 0x00, 0x00, 0x00, 0x19, 0x00, 0x00, 0x01, 0xFF, 0xFE, 0x61, 0x00, 0x61, 0x00, 0x61, 0x00, 0x61, 0x00, 0x61, 0x00, 0x61, 0x00, 0x61, 0x00, 0x61, 0x00, 0x61, 0x00, 0x61, 0x00, 0x61, 0x00, ], // 4 bytes of junk &[0x20, 0x20, 0x20, 0x20], // start of MP3 frame (not all bytes are shown in this slice) &[ 0xFF, 0xFB, 0x50, 0xC4, 0x00, 0x03, 0xC0, 0x00, 0x01, 0xA4, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00, 0x34, 0x80, 0x00, 0x00, 0x04, ], ]; let data: Vec = data.into_iter().flatten().copied().collect(); let data = std::io::Cursor::new(&data); let probe = Probe::new(data).guess_file_type().unwrap(); assert_eq!(probe.file_type(), Some(FileType::Mpeg)); } #[test] fn parse_options_allocation_limit() { // In this test, we read a partial MP3 file that has an ID3v2 tag containing a frame outside // of the allocation limit. We'll be testing with an encrypted frame, since we immediately read those into memory. use crate::id3::v2::util::synchsafe::SynchsafeInteger; fn create_encrypted_frame(size: usize) -> Vec { // Encryption method (1 byte) + encryption method data length indicator (4 bytes) // This is required and goes before the data. let flag_data = vec![0; 5]; let bytes = vec![0; size]; let frame_length_synch = ((bytes.len() + flag_data.len()) as u32) .synch() .unwrap() .to_be_bytes(); let frame_header = vec![ b'S', b'M', b'T', b'H', frame_length_synch[0], frame_length_synch[1], frame_length_synch[2], frame_length_synch[3], 0x00, 0b0000_0101, // Encrypted, Has data length indicator ]; [frame_header, flag_data, bytes].concat() } fn create_fake_mp3(frame_size: u32) -> Vec { let id3v2_tag_length = (frame_size + 5 + 10).synch().unwrap().to_be_bytes(); [ // ID3v2.4 header (10 bytes) vec![ 0x49, 0x44, 0x33, 0x04, 0x00, 0x00, id3v2_tag_length[0], id3v2_tag_length[1], id3v2_tag_length[2], id3v2_tag_length[3], ], // Random encrypted frame create_encrypted_frame(frame_size as usize), // start of MP3 frame (not all bytes are shown in this slice) vec![ 0xFF, 0xFB, 0x50, 0xC4, 0x00, 0x03, 0xC0, 0x00, 0x01, 0xA4, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00, 0x34, 0x80, 0x00, 0x00, 0x04, ], ] .into_iter() .flatten() .collect::>() } let parse_options = ParseOptions::new().read_properties(false); let mut global_options = GlobalOptions::new().allocation_limit(50); crate::config::apply_global_options(global_options); // An allocation with a size of 40 bytes should be ok let within_limits = create_fake_mp3(40); let probe = Probe::new(std::io::Cursor::new(&within_limits)) .set_file_type(FileType::Mpeg) .options(parse_options); assert!(probe.read().is_ok()); // An allocation with a size of 60 bytes should fail let too_big = create_fake_mp3(60); let probe = Probe::new(std::io::Cursor::new(&too_big)) .set_file_type(FileType::Mpeg) .options(parse_options); assert!(probe.read().is_err()); // Now test the default allocation limit (16MB), which should of course be ok with 60 bytes global_options.allocation_limit = GlobalOptions::DEFAULT_ALLOCATION_LIMIT; crate::config::apply_global_options(global_options); let probe = Probe::new(std::io::Cursor::new(&too_big)) .set_file_type(FileType::Mpeg) .options(parse_options); assert!(probe.read().is_ok()); } fn test_probe(path: &str, expected_file_type_guess: FileType) { test_probe_file(path, expected_file_type_guess); test_probe_path(path, expected_file_type_guess); } // Test from file contents fn test_probe_file(path: &str, expected_file_type_guess: FileType) { let mut f = File::open(path).unwrap(); let probe = Probe::new(&mut f).guess_file_type().unwrap(); assert_eq!(probe.file_type(), Some(expected_file_type_guess)); } // Test from file extension fn test_probe_path(path: &str, expected_file_type_guess: FileType) { let probe = Probe::open(path).unwrap(); assert_eq!(probe.file_type(), Some(expected_file_type_guess)); } #[test] fn probe_aac() { test_probe("tests/files/assets/minimal/untagged.aac", FileType::Aac); } #[test] fn probe_aac_with_id3v2() { test_probe("tests/files/assets/minimal/full_test.aac", FileType::Aac); } #[test] fn probe_aiff() { test_probe("tests/files/assets/minimal/full_test.aiff", FileType::Aiff); } #[test] fn probe_ape_with_id3v2() { test_probe("tests/files/assets/minimal/full_test.ape", FileType::Ape); } #[test] fn probe_flac() { test_probe("tests/files/assets/minimal/full_test.flac", FileType::Flac); } #[test] fn probe_flac_with_id3v2() { test_probe("tests/files/assets/flac_with_id3v2.flac", FileType::Flac); } #[test] fn probe_mp3_with_id3v2() { test_probe("tests/files/assets/minimal/full_test.mp3", FileType::Mpeg); } #[test] fn probe_mp3_with_lots_of_junk() { test_probe("tests/files/assets/junk.mp3", FileType::Mpeg); } #[test] fn probe_vorbis() { test_probe("tests/files/assets/minimal/full_test.ogg", FileType::Vorbis); } #[test] fn probe_opus() { test_probe("tests/files/assets/minimal/full_test.opus", FileType::Opus); } #[test] fn probe_speex() { test_probe("tests/files/assets/minimal/full_test.spx", FileType::Speex); } #[test] fn probe_mp4() { test_probe( "tests/files/assets/minimal/m4a_codec_aac.m4a", FileType::Mp4, ); } #[test] fn probe_wav() { test_probe( "tests/files/assets/minimal/wav_format_pcm.wav", FileType::Wav, ); } } lofty-0.21.1/src/properties/channel_mask.rs000064400000000000000000000106531046102023000170230ustar 00000000000000use std::ops::{BitAnd, BitOr}; macro_rules! define_channels { ([ $( $(#[$meta:meta])? $name:ident => $shift:literal ),+ ]) => { impl ChannelMask { $( $(#[$meta])? #[allow(missing_docs)] pub const $name: Self = Self(1 << $shift); )+ } }; } /// Channel mask /// /// A mask of (at least) 18 bits, one for each channel. /// /// * Standard speaker channels: /// * CAF channel bitmap: /// * WAV default channel ordering: /// * FFmpeg: #[derive(Debug, Clone, Copy, Eq, PartialEq, Default)] #[repr(transparent)] pub struct ChannelMask(pub(crate) u32); define_channels! { [ FRONT_LEFT => 0, FRONT_RIGHT => 1, FRONT_CENTER => 2, LOW_FREQUENCY => 3, BACK_LEFT => 4, BACK_RIGHT => 5, FRONT_LEFT_OF_CENTER => 6, FRONT_RIGHT_OF_CENTER => 7, BACK_CENTER => 8, SIDE_LEFT => 9, SIDE_RIGHT => 10, TOP_CENTER => 11, TOP_FRONT_LEFT => 12, TOP_FRONT_CENTER => 13, TOP_FRONT_RIGHT => 14, TOP_BACK_LEFT => 15, TOP_BACK_CENTER => 16, TOP_BACK_RIGHT => 17 ] } impl ChannelMask { /// A single front center channel #[must_use] pub const fn mono() -> Self { Self::FRONT_CENTER } /// Front left+right channels #[must_use] pub const fn stereo() -> Self { // TODO: #![feature(const_trait_impl)] Self(Self::FRONT_LEFT.0 | Self::FRONT_RIGHT.0) } /// Front left+right+center channels #[must_use] pub const fn linear_surround() -> Self { Self(Self::FRONT_LEFT.0 | Self::FRONT_RIGHT.0 | Self::FRONT_CENTER.0) } /// The bit mask #[must_use] pub const fn bits(self) -> u32 { self.0 } /// Create a channel mask from the number of channels in an Opus file /// /// See for the mapping. pub const fn from_opus_channels(channels: u8) -> Option { match channels { 1 => Some(Self::mono()), 2 => Some(Self::stereo()), 3 => Some(Self::linear_surround()), 4 => Some(Self( Self::FRONT_LEFT.bits() | Self::FRONT_RIGHT.bits() | Self::BACK_LEFT.bits() | Self::BACK_RIGHT.bits(), )), 5 => Some(Self( Self::linear_surround().bits() | Self::BACK_LEFT.bits() | Self::BACK_RIGHT.bits(), )), 6 => Some(Self( Self::linear_surround().bits() | Self::BACK_LEFT.bits() | Self::BACK_RIGHT.bits() | Self::LOW_FREQUENCY.bits(), )), 7 => Some(Self( Self::linear_surround().bits() | Self::SIDE_LEFT.bits() | Self::SIDE_RIGHT.bits() | Self::BACK_CENTER.bits() | Self::LOW_FREQUENCY.bits(), )), 8 => Some(Self( Self::linear_surround().bits() | Self::SIDE_LEFT.bits() | Self::SIDE_RIGHT.bits() | Self::BACK_LEFT.bits() | Self::BACK_RIGHT.bits() | Self::LOW_FREQUENCY.bits(), )), _ => None, } } /// Create a channel mask from the number of channels in an MP4 file /// /// See for the mapping. pub const fn from_mp4_channels(channels: u8) -> Option { match channels { 1 => Some(Self::mono()), 2 => Some(Self::stereo()), 3 => Some(Self::linear_surround()), 4 => Some(Self( Self::FRONT_LEFT.bits() | Self::FRONT_RIGHT.bits() | Self::BACK_LEFT.bits() | Self::BACK_RIGHT.bits(), )), 5 => Some(Self( Self::linear_surround().bits() | Self::BACK_LEFT.bits() | Self::BACK_RIGHT.bits(), )), 6 => Some(Self( Self::linear_surround().bits() | Self::BACK_LEFT.bits() | Self::BACK_RIGHT.bits() | Self::LOW_FREQUENCY.bits(), )), 7 => Some(Self( Self::linear_surround().bits() | Self::SIDE_LEFT.bits() | Self::SIDE_RIGHT.bits() | Self::BACK_LEFT.bits() | Self::BACK_RIGHT.bits() | Self::LOW_FREQUENCY.bits(), )), _ => None, } } } impl BitOr for ChannelMask { type Output = Self; fn bitor(self, rhs: Self) -> Self { Self(self.0 | rhs.0) } } impl BitAnd for ChannelMask { type Output = Self; fn bitand(self, rhs: Self) -> Self { Self(self.0 & rhs.0) } } lofty-0.21.1/src/properties/file_properties.rs000064400000000000000000000041061046102023000175670ustar 00000000000000use super::channel_mask::ChannelMask; use std::time::Duration; /// Various *immutable* audio properties #[derive(Debug, PartialEq, Eq, Clone)] #[non_exhaustive] pub struct FileProperties { pub(crate) duration: Duration, pub(crate) overall_bitrate: Option, pub(crate) audio_bitrate: Option, pub(crate) sample_rate: Option, pub(crate) bit_depth: Option, pub(crate) channels: Option, pub(crate) channel_mask: Option, } impl Default for FileProperties { fn default() -> Self { Self { duration: Duration::ZERO, overall_bitrate: None, audio_bitrate: None, sample_rate: None, bit_depth: None, channels: None, channel_mask: None, } } } impl FileProperties { /// Create a new `FileProperties` #[must_use] pub const fn new( duration: Duration, overall_bitrate: Option, audio_bitrate: Option, sample_rate: Option, bit_depth: Option, channels: Option, channel_mask: Option, ) -> Self { Self { duration, overall_bitrate, audio_bitrate, sample_rate, bit_depth, channels, channel_mask, } } /// Duration of the audio pub fn duration(&self) -> Duration { self.duration } /// Overall bitrate (kbps) pub fn overall_bitrate(&self) -> Option { self.overall_bitrate } /// Audio bitrate (kbps) pub fn audio_bitrate(&self) -> Option { self.audio_bitrate } /// Sample rate (Hz) pub fn sample_rate(&self) -> Option { self.sample_rate } /// Bits per sample (usually 16 or 24 bit) pub fn bit_depth(&self) -> Option { self.bit_depth } /// Channel count pub fn channels(&self) -> Option { self.channels } /// Channel mask pub fn channel_mask(&self) -> Option { self.channel_mask } /// Used for tests #[doc(hidden)] pub fn is_empty(&self) -> bool { matches!( self, Self { duration: Duration::ZERO, overall_bitrate: None | Some(0), audio_bitrate: None | Some(0), sample_rate: None | Some(0), bit_depth: None | Some(0), channels: None | Some(0), channel_mask: None, } ) } } lofty-0.21.1/src/properties/mod.rs000064400000000000000000000006561046102023000151610ustar 00000000000000//! Generic audio properties //! //! Many audio formats have their own custom properties, but there are some properties that are //! common to all audio formats. When using [`TaggedFile`](crate::file::TaggedFile), any custom properties //! will simply be converted to [`FileProperties`]. mod channel_mask; mod file_properties; #[cfg(test)] mod tests; pub use channel_mask::ChannelMask; pub use file_properties::FileProperties; lofty-0.21.1/src/properties/tests.rs000064400000000000000000000241231046102023000155370ustar 00000000000000use crate::aac::{AACProperties, AacFile}; use crate::ape::{ApeFile, ApeProperties}; use crate::config::ParseOptions; use crate::file::AudioFile; use crate::flac::{FlacFile, FlacProperties}; use crate::iff::aiff::{AiffFile, AiffProperties}; use crate::iff::wav::{WavFile, WavFormat, WavProperties}; use crate::mp4::{AudioObjectType, Mp4Codec, Mp4File, Mp4Properties}; use crate::mpeg::{ChannelMode, Layer, MpegFile, MpegProperties, MpegVersion}; use crate::musepack::sv4to6::MpcSv4to6Properties; use crate::musepack::sv7::{Link, MpcSv7Properties, Profile}; use crate::musepack::sv8::{EncoderInfo, MpcSv8Properties, ReplayGain, StreamHeader}; use crate::musepack::{MpcFile, MpcProperties}; use crate::ogg::{ OpusFile, OpusProperties, SpeexFile, SpeexProperties, VorbisFile, VorbisProperties, }; use crate::properties::ChannelMask; use crate::wavpack::{WavPackFile, WavPackProperties}; use std::fs::File; use std::time::Duration; // These values are taken from FFmpeg's ffprobe // There is a chance they will be +/- 1, anything greater (for real world files) // is an issue. const AAC_PROPERTIES: AACProperties = AACProperties { version: MpegVersion::V4, audio_object_type: AudioObjectType::AacLowComplexity, duration: Duration::from_millis(1474), /* TODO: This is ~100ms greater than FFmpeg's report, can we do better? */ overall_bitrate: 117, // 9 less than FFmpeg reports audio_bitrate: 117, // 9 less than FFmpeg reports sample_rate: 48000, channels: 2, channel_mask: Some(ChannelMask::stereo()), copyright: false, original: false, }; const AIFF_PROPERTIES: AiffProperties = AiffProperties { duration: Duration::from_millis(1428), overall_bitrate: 1542, audio_bitrate: 1536, sample_rate: 48000, sample_size: 16, channels: 2, compression_type: None, }; const APE_PROPERTIES: ApeProperties = ApeProperties { version: 3990, duration: Duration::from_millis(1428), overall_bitrate: 361, audio_bitrate: 360, sample_rate: 48000, bit_depth: 16, channels: 2, }; const FLAC_PROPERTIES: FlacProperties = FlacProperties { duration: Duration::from_millis(1428), overall_bitrate: 321, audio_bitrate: 275, sample_rate: 48000, bit_depth: 16, channels: 2, signature: 164_506_065_180_489_231_127_156_351_872_182_799_315, }; const MP1_PROPERTIES: MpegProperties = MpegProperties { version: MpegVersion::V1, layer: Layer::Layer1, channel_mode: ChannelMode::Stereo, mode_extension: None, copyright: false, original: true, duration: Duration::from_millis(588), // FFmpeg reports 576, possibly an issue overall_bitrate: 384, // TODO: FFmpeg reports 392 audio_bitrate: 384, sample_rate: 32000, channels: 2, emphasis: None, }; const MP2_PROPERTIES: MpegProperties = MpegProperties { version: MpegVersion::V1, layer: Layer::Layer2, channel_mode: ChannelMode::Stereo, mode_extension: None, copyright: false, original: true, duration: Duration::from_millis(1440), overall_bitrate: 384, audio_bitrate: 384, sample_rate: 48000, channels: 2, emphasis: None, }; const MP3_PROPERTIES: MpegProperties = MpegProperties { version: MpegVersion::V1, layer: Layer::Layer3, channel_mode: ChannelMode::Stereo, mode_extension: None, copyright: false, original: false, duration: Duration::from_millis(1464), overall_bitrate: 64, audio_bitrate: 62, sample_rate: 48000, channels: 2, emphasis: None, }; const MP4_AAC_PROPERTIES: Mp4Properties = Mp4Properties { codec: Mp4Codec::AAC, extended_audio_object_type: Some(AudioObjectType::AacLowComplexity), duration: Duration::from_millis(1449), overall_bitrate: 135, audio_bitrate: 124, sample_rate: 48000, bit_depth: None, channels: 2, drm_protected: false, }; const MP4_ALAC_PROPERTIES: Mp4Properties = Mp4Properties { codec: Mp4Codec::ALAC, extended_audio_object_type: None, duration: Duration::from_millis(1428), overall_bitrate: 331, audio_bitrate: 326, sample_rate: 48000, bit_depth: Some(16), channels: 2, drm_protected: false, }; const MP4_ALS_PROPERTIES: Mp4Properties = Mp4Properties { codec: Mp4Codec::AAC, extended_audio_object_type: Some(AudioObjectType::AudioLosslessCoding), duration: Duration::from_millis(1429), overall_bitrate: 1083, audio_bitrate: 1078, sample_rate: 48000, bit_depth: None, channels: 2, drm_protected: false, }; const MP4_FLAC_PROPERTIES: Mp4Properties = Mp4Properties { codec: Mp4Codec::FLAC, extended_audio_object_type: None, duration: Duration::from_millis(1428), overall_bitrate: 280, audio_bitrate: 275, sample_rate: 48000, bit_depth: Some(16), channels: 2, drm_protected: false, }; // Properties verified with libmpcdec 1.2.2 const MPC_SV5_PROPERTIES: MpcSv4to6Properties = MpcSv4to6Properties { duration: Duration::from_millis(26347), average_bitrate: 119, channels: 2, frame_count: 1009, mid_side_stereo: true, stream_version: 5, max_band: 31, sample_rate: 44100, }; const MPC_SV7_PROPERTIES: MpcSv7Properties = MpcSv7Properties { duration: Duration::from_millis(1440), average_bitrate: 86, channels: 2, frame_count: 60, intensity_stereo: false, mid_side_stereo: true, max_band: 26, profile: Profile::Standard, link: Link::VeryLowStartOrEnd, sample_freq: 48000, max_level: 0, title_gain: 0, title_peak: 0, album_gain: 0, album_peak: 0, true_gapless: true, last_frame_length: 578, fast_seeking_safe: false, encoder_version: 192, }; const MPC_SV8_PROPERTIES: MpcSv8Properties = MpcSv8Properties { duration: Duration::from_millis(1428), average_bitrate: 82, stream_header: StreamHeader { crc: 4_252_559_415, stream_version: 8, sample_count: 68546, beginning_silence: 0, sample_rate: 48000, max_used_bands: 26, channels: 2, ms_used: true, audio_block_frames: 64, }, replay_gain: ReplayGain { version: 1, title_gain: 16655, title_peak: 21475, album_gain: 16655, album_peak: 21475, }, encoder_info: Some(EncoderInfo { profile: 10.0, pns_tool: false, major: 1, minor: 30, build: 1, }), }; const OPUS_PROPERTIES: OpusProperties = OpusProperties { duration: Duration::from_millis(1428), overall_bitrate: 120, audio_bitrate: 120, channels: 2, channel_mask: ChannelMask::stereo(), version: 1, input_sample_rate: 48000, }; const SPEEX_PROPERTIES: SpeexProperties = SpeexProperties { duration: Duration::from_millis(1469), version: 1, sample_rate: 32000, mode: 2, channels: 2, vbr: false, overall_bitrate: 32, audio_bitrate: 29, nominal_bitrate: 29600, }; const VORBIS_PROPERTIES: VorbisProperties = VorbisProperties { duration: Duration::from_millis(1451), overall_bitrate: 96, audio_bitrate: 112, sample_rate: 48000, channels: 2, version: 0, bitrate_maximum: 0, bitrate_nominal: 112_000, bitrate_minimum: 0, }; const WAV_PROPERTIES: WavProperties = WavProperties { format: WavFormat::PCM, duration: Duration::from_millis(1428), overall_bitrate: 1542, audio_bitrate: 1536, sample_rate: 48000, bit_depth: 16, channels: 2, channel_mask: None, }; const WAVPACK_PROPERTIES: WavPackProperties = WavPackProperties { version: 1040, duration: Duration::from_millis(1428), overall_bitrate: 598, audio_bitrate: 597, sample_rate: 48000, channels: 2, channel_mask: ChannelMask::stereo(), bit_depth: 16, lossless: true, }; fn get_properties(path: &str) -> T::Properties where T: AudioFile, ::Properties: Clone, { let mut f = File::open(path).unwrap(); let audio_file = T::read_from(&mut f, ParseOptions::default()).unwrap(); audio_file.properties().clone() } #[test] fn aac_properties() { assert_eq!( get_properties::("tests/files/assets/minimal/full_test.aac"), AAC_PROPERTIES ); } #[test] fn aiff_properties() { assert_eq!( get_properties::("tests/files/assets/minimal/full_test.aiff"), AIFF_PROPERTIES ); } #[test] fn ape_properties() { assert_eq!( get_properties::("tests/files/assets/minimal/full_test.ape"), APE_PROPERTIES ); } #[test] fn flac_properties() { assert_eq!( get_properties::("tests/files/assets/minimal/full_test.flac"), FLAC_PROPERTIES ) } #[test] fn mp1_properties() { assert_eq!( get_properties::("tests/files/assets/minimal/full_test.mp1"), MP1_PROPERTIES ) } #[test] fn mp2_properties() { assert_eq!( get_properties::("tests/files/assets/minimal/full_test.mp2"), MP2_PROPERTIES ) } #[test] fn mp3_properties() { assert_eq!( get_properties::("tests/files/assets/minimal/full_test.mp3"), MP3_PROPERTIES ) } #[test] fn mp4_aac_properties() { assert_eq!( get_properties::("tests/files/assets/minimal/m4a_codec_aac.m4a"), MP4_AAC_PROPERTIES ) } #[test] fn mp4_alac_properties() { assert_eq!( get_properties::("tests/files/assets/minimal/m4a_codec_alac.m4a"), MP4_ALAC_PROPERTIES ) } #[test] fn mp4_als_properties() { assert_eq!( get_properties::("tests/files/assets/minimal/mp4_codec_als.mp4"), MP4_ALS_PROPERTIES ) } #[test] fn mp4_flac_properties() { assert_eq!( get_properties::("tests/files/assets/minimal/mp4_codec_flac.mp4"), MP4_FLAC_PROPERTIES ) } #[test] fn mpc_sv5_properties() { assert_eq!( get_properties::("tests/files/assets/minimal/mpc_sv5.mpc"), MpcProperties::Sv4to6(MPC_SV5_PROPERTIES) ) } #[test] fn mpc_sv7_properties() { assert_eq!( get_properties::("tests/files/assets/minimal/mpc_sv7.mpc"), MpcProperties::Sv7(MPC_SV7_PROPERTIES) ) } #[test] fn mpc_sv8_properties() { assert_eq!( get_properties::("tests/files/assets/minimal/mpc_sv8.mpc"), MpcProperties::Sv8(MPC_SV8_PROPERTIES) ) } #[test] fn opus_properties() { assert_eq!( get_properties::("tests/files/assets/minimal/full_test.opus"), OPUS_PROPERTIES ) } #[test] fn speex_properties() { assert_eq!( get_properties::("tests/files/assets/minimal/full_test.spx"), SPEEX_PROPERTIES ) } #[test] fn vorbis_properties() { assert_eq!( get_properties::("tests/files/assets/minimal/full_test.ogg"), VORBIS_PROPERTIES ) } #[test] fn wav_properties() { assert_eq!( get_properties::("tests/files/assets/minimal/wav_format_pcm.wav"), WAV_PROPERTIES ) } #[test] fn wavpack_properties() { assert_eq!( get_properties::("tests/files/assets/minimal/full_test.wv"), WAVPACK_PROPERTIES ) } lofty-0.21.1/src/resolve.rs000064400000000000000000000141701046102023000136610ustar 00000000000000//! Tools to create custom file resolvers //! //! For a full example of a custom resolver, see [this](https://github.com/Serial-ATA/lofty-rs/tree/main/examples/custom_resolver). use crate::config::ParseOptions; use crate::error::Result; use crate::file::{AudioFile, FileType, TaggedFile}; use crate::tag::TagType; use std::collections::HashMap; use std::io::{Read, Seek}; use std::marker::PhantomData; use std::sync::{Arc, Mutex, OnceLock}; /// A custom file resolver /// /// This trait allows for the creation of custom [`FileType`]s, that can make use of /// lofty's API. Registering a `FileResolver` ([`register_custom_resolver`]) makes it possible /// to detect and read files using [`Probe`](crate::probe::Probe). pub trait FileResolver: Send + Sync + AudioFile { /// The extension associated with the [`FileType`] without the '.' fn extension() -> Option<&'static str>; /// The primary [`TagType`] for the [`FileType`] fn primary_tag_type() -> TagType; /// The [`FileType`]'s supported [`TagType`]s fn supported_tag_types() -> &'static [TagType]; /// Attempts to guess the [`FileType`] from a portion of the file content /// /// NOTE: This will only provide (up to) the first 36 bytes of the file. /// This number is subject to change in the future, but it will never decrease. /// Such a change will **not** be considered breaking. fn guess(buf: &[u8]) -> Option; } // Just broken out to its own type to make `CUSTOM_RESOLVER`'s type shorter :) type ResolverMap = HashMap<&'static str, &'static dyn ObjectSafeFileResolver>; pub(crate) fn custom_resolvers() -> &'static Arc> { static INSTANCE: OnceLock>> = OnceLock::new(); INSTANCE.get_or_init(Default::default) } pub(crate) fn lookup_resolver(name: &'static str) -> &'static dyn ObjectSafeFileResolver { let res = custom_resolvers().lock().unwrap(); if let Some(resolver) = res.get(name).copied() { return resolver; } panic!( "Encountered an unregistered custom `FileType` named `{}`", name ); } // A `Read + Seek` supertrait for use in [`ObjectSafeFileResolver::read_from`] pub(crate) trait SeekRead: Read + Seek {} impl SeekRead for T {} // `FileResolver` isn't object safe itself, so we need this wrapper trait pub(crate) trait ObjectSafeFileResolver: Send + Sync { fn extension(&self) -> Option<&'static str>; fn primary_tag_type(&self) -> TagType; fn supported_tag_types(&self) -> &'static [TagType]; fn guess(&self, buf: &[u8]) -> Option; // A mask for the `AudioFile::read_from` impl fn read_from( &self, reader: &mut dyn SeekRead, parse_options: ParseOptions, ) -> Result; } // A fake `FileResolver` implementer, so we don't need to construct the type in `register_custom_resolver` pub(crate) struct GhostlyResolver(PhantomData); impl ObjectSafeFileResolver for GhostlyResolver { fn extension(&self) -> Option<&'static str> { T::extension() } fn primary_tag_type(&self) -> TagType { T::primary_tag_type() } fn supported_tag_types(&self) -> &'static [TagType] { T::supported_tag_types() } fn guess(&self, buf: &[u8]) -> Option { T::guess(buf) } fn read_from( &self, reader: &mut dyn SeekRead, parse_options: ParseOptions, ) -> Result { Ok(::read_from(&mut Box::new(reader), parse_options)?.into()) } } /// Register a custom file resolver /// /// Provided a type and a name to associate it with, this will attempt /// to load them into the resolver collection. /// /// Conditions: /// * Both the resolver and name *must* be static. /// * `name` **must** match the name of your custom [`FileType`] variant (case sensitive!) /// /// # Panics /// /// * Attempting to register an existing name or type /// * See [`Mutex::lock`] pub fn register_custom_resolver(name: &'static str) { let mut res = custom_resolvers().lock().unwrap(); assert!( res.iter().all(|(n, _)| *n != name), "Resolver `{}` already exists!", name ); let ghost = GhostlyResolver::(PhantomData); let b: Box = Box::new(ghost); res.insert(name, Box::leak::<'static>(b)); } #[cfg(test)] mod tests { use crate::config::{GlobalOptions, ParseOptions}; use crate::file::{FileType, TaggedFileExt}; use crate::id3::v2::Id3v2Tag; use crate::properties::FileProperties; use crate::resolve::{register_custom_resolver, FileResolver}; use crate::tag::{Accessor, TagType}; use std::fs::File; use std::io::{Read, Seek}; use std::panic; use lofty_attr::LoftyFile; #[derive(LoftyFile, Default)] #[lofty(read_fn = "Self::read")] #[lofty(file_type = "MyFile")] struct MyFile { #[lofty(tag_type = "Id3v2")] id3v2_tag: Option, properties: FileProperties, } impl FileResolver for MyFile { fn extension() -> Option<&'static str> { Some("myfile") } fn primary_tag_type() -> TagType { TagType::Id3v2 } fn supported_tag_types() -> &'static [TagType] { &[TagType::Id3v2] } fn guess(buf: &[u8]) -> Option { if buf.starts_with(b"myfile") { return Some(FileType::Custom("MyFile")); } None } } impl MyFile { #[allow(clippy::unnecessary_wraps)] fn read( _reader: &mut R, _parse_options: ParseOptions, ) -> crate::error::Result { let mut tag = Id3v2Tag::default(); tag.set_artist(String::from("All is well!")); Ok(Self { id3v2_tag: Some(tag), properties: FileProperties::default(), }) } } #[test] fn custom_resolver() { register_custom_resolver::("MyFile"); let global_options = GlobalOptions::new().use_custom_resolvers(true); crate::config::apply_global_options(global_options); let path = "../examples/custom_resolver/test_asset.myfile"; let read = crate::read_from_path(path).unwrap(); assert_eq!(read.file_type(), FileType::Custom("MyFile")); let read_content = crate::read_from(&mut File::open(path).unwrap()).unwrap(); assert_eq!(read_content.file_type(), FileType::Custom("MyFile")); assert!( panic::catch_unwind(|| { register_custom_resolver::("MyFile"); }) .is_err(), "We didn't panic on double register!" ); } } lofty-0.21.1/src/tag/accessor.rs000064400000000000000000000066521046102023000145650ustar 00000000000000use std::borrow::Cow; // This defines the `Accessor` trait, used to define unified getters/setters for commonly // accessed tag values. // // Usage: // // accessor_trait! { // [field_name] // } // // * `field_name` is the name of the method to access the field. If a name consists of multiple segments, // such as `track_number`, they should be separated by spaces like so: [track number]. // // * `type` is the return type for `Accessor::field_name`. By default, this type will also be used // in the setter. // // An owned type can also be specified for the setter: // // accessor_trait! { // field_name // } macro_rules! accessor_trait { ($([$($name:tt)+] < $($ty:ty),+ >),+ $(,)?) => { /// Provides accessors for common items /// /// This attempts to only provide methods for items that all tags have in common, /// but there may be exceptions. pub trait Accessor { $( accessor_trait! { @GETTER [$($name)+] $($ty),+ } accessor_trait! { @SETTER [$($name)+] $($ty),+ } accessor_trait! { @REMOVE [$($name)+] $($ty),+ } )+ } }; (@GETTER [$($name:tt)+] $ty:ty $(, $_ty:tt)?) => { accessor_trait! { @GET_METHOD [$($name)+] Option<$ty> } }; (@SETTER [$($name:tt)+] $_ty:ty, $owned_ty:tt) => { accessor_trait! { @SETTER [$($name)+] $owned_ty } }; (@SETTER [$($name:tt)+] $ty:ty) => { accessor_trait! { @SET_METHOD [$($name)+] $ty } }; (@REMOVE [$($name:tt)+] $_ty:ty, $owned_ty:tt) => { accessor_trait! { @REMOVE [$($name)+] $owned_ty } }; (@REMOVE [$($name:tt)+] $ty:ty) => { accessor_trait! { @REMOVE_METHOD [$($name)+], $ty } }; (@GET_METHOD [$name:tt $($other:tt)*] Option<$ret_ty:ty>) => { paste::paste! { #[doc = "Returns the " $name $(" " $other)*] /// # Example /// /// ```rust /// use lofty::tag::{Tag, Accessor}; /// /// # let tag_type = lofty::tag::TagType::Id3v2; /// let mut tag = Tag::new(tag_type); #[doc = "assert_eq!(tag." $name $(_ $other)* "(), None);"] /// ``` fn [< $name $(_ $other)* >] (&self) -> Option<$ret_ty> { None } } }; (@SET_METHOD [$name:tt $($other:tt)*] $owned_ty:ty) => { paste::paste! { #[doc = "Sets the " $name $(" " $other)*] /// # Example /// /// ```rust,ignore /// use lofty::tag::{Tag, Accessor}; /// /// let mut tag = Tag::new(tag_type); #[doc = "tag.set_" $name $(_ $other)* "(value);"] /// #[doc = "assert_eq!(tag." $name $(_ $other)* "(), Some(value));"] /// ``` fn [< set_ $name $(_ $other)* >] (&mut self , _value: $owned_ty) {} } }; (@REMOVE_METHOD [$name:tt $($other:tt)*], $ty:ty) => { paste::paste! { #[doc = "Removes the " $name $(" " $other)*] /// # Example /// /// ```rust,ignore /// use lofty::tag::{Tag, Accessor}; /// /// let mut tag = Tag::new(tag_type); #[doc = "tag.set_" $name $(_ $other)* "(value);"] /// #[doc = "assert_eq!(tag." $name $(_ $other)* "(), Some(value));"] /// #[doc = "tag.remove_" $name $(_ $other)* "();"] /// #[doc = "assert_eq!(tag." $name $(_ $other)* "(), None);"] /// ``` fn [< remove_ $name $(_ $other)* >] (&mut self) {} } }; } accessor_trait! { [artist], String>, [title ], String>, [album ], String>, [genre ], String>, [track ], [track total], [disk ], [disk total ], [year ], [comment ], String>, } lofty-0.21.1/src/tag/companion_tag.rs000064400000000000000000000006351046102023000155740ustar 00000000000000use crate::id3::v2::Id3v2Tag; use crate::mp4::Ilst; #[derive(Debug, Clone)] pub(crate) enum CompanionTag { Id3v2(Id3v2Tag), Ilst(Ilst), } impl CompanionTag { pub(crate) fn id3v2(self) -> Option { match self { CompanionTag::Id3v2(tag) => Some(tag), _ => None, } } pub(crate) fn ilst(self) -> Option { match self { CompanionTag::Ilst(tag) => Some(tag), _ => None, } } } lofty-0.21.1/src/tag/item.rs000064400000000000000000000743761046102023000137310ustar 00000000000000use crate::tag::items::{Lang, UNKNOWN_LANGUAGE}; use crate::tag::TagType; use std::borrow::Cow; use std::collections::HashMap; macro_rules! first_key { ($key:tt $(| $remaining:expr)*) => { $key }; } pub(crate) use first_key; // This is used to create the key/ItemKey maps // // First comes the name of the map. // Ex: // // APE_MAP; // // This is followed by the key value pairs separated by `=>`, with the key being the // format-specific key and the value being the appropriate ItemKey variant. // Ex. "Artist" => Artist // // Some formats have multiple keys that map to the same ItemKey variant, which can be added with '|'. // The standard key(s) **must** come before any popular non-standard keys. // Keys should appear in order of popularity. macro_rules! gen_map { ($(#[$meta:meta])? $NAME:ident; $($($key:literal)|+ => $variant:ident),+) => { paste::paste! { $(#[$meta])? #[allow(non_camel_case_types)] struct $NAME; $(#[$meta])? impl $NAME { pub(crate) fn get_item_key(&self, key: &str) -> Option { static INSTANCE: std::sync::OnceLock> = std::sync::OnceLock::new(); INSTANCE.get_or_init(|| { let mut map = HashMap::new(); $( $( map.insert($key, ItemKey::$variant); )+ )+ map }).iter().find(|(k, _)| k.eq_ignore_ascii_case(key)).map(|(_, v)| v.clone()) } pub(crate) fn get_key(&self, item_key: &ItemKey) -> Option<&str> { match item_key { $( ItemKey::$variant => Some(first_key!($($key)|*)), )+ _ => None } } } } } } gen_map!( AIFF_TEXT_MAP; "NAME" => TrackTitle, "AUTH" => TrackArtist, "(c) " => CopyrightMessage, "COMM" | "ANNO" => Comment ); gen_map!( APE_MAP; "Album" => AlbumTitle, "DiscSubtitle" => SetSubtitle, "Grouping" => ContentGroup, "Title" => TrackTitle, "Subtitle" => TrackSubtitle, "WORKTITLE" => Work, "MOVEMENTNAME" => Movement, "MOVEMENT" => MovementNumber, "MOVEMENTTOTAL" => MovementTotal, "ALBUMSORT" => AlbumTitleSortOrder, "ALBUMARTISTSORT" => AlbumArtistSortOrder, "TITLESORT" => TrackTitleSortOrder, "ARTISTSORT" => TrackArtistSortOrder, "Album Artist" | "ALBUMARTIST" => AlbumArtist, "Artist" => TrackArtist, "Arranger" => Arranger, "Writer" => Writer, "Composer" => Composer, "Conductor" => Conductor, "Director" => Director, "Engineer" => Engineer, "Lyricist" => Lyricist, "DjMixer" => MixDj, "Mixer" => MixEngineer, "Performer" => Performer, "Producer" => Producer, "Label" => Label, "MixArtist" => Remixer, "Disc" => DiscNumber, "Disc" => DiscTotal, "Track" => TrackNumber, "Track" => TrackTotal, "Year" => Year, "ORIGINALYEAR" => OriginalReleaseDate, "RELEASEDATE" => ReleaseDate, "ISRC" => Isrc, "Barcode" => Barcode, "CatalogNumber" => CatalogNumber, "Compilation" => FlagCompilation, "Media" => OriginalMediaType, "EncodedBy" => EncodedBy, "REPLAYGAIN_ALBUM_GAIN" => ReplayGainAlbumGain, "REPLAYGAIN_ALBUM_PEAK" => ReplayGainAlbumPeak, "REPLAYGAIN_TRACK_GAIN" => ReplayGainTrackGain, "REPLAYGAIN_TRACK_PEAK" => ReplayGainTrackPeak, "Genre" => Genre, "Color" => Color, "Mood" => Mood, "Copyright" => CopyrightMessage, "Comment" => Comment, "language" => Language, "Script" => Script, "Lyrics" => Lyrics, "MUSICBRAINZ_TRACKID" => MusicBrainzRecordingId, "MUSICBRAINZ_RELEASETRACKID" => MusicBrainzTrackId, "MUSICBRAINZ_ALBUMID" => MusicBrainzReleaseId, "MUSICBRAINZ_RELEASEGROUPID" => MusicBrainzReleaseGroupId, "MUSICBRAINZ_ARTISTID" => MusicBrainzArtistId, "MUSICBRAINZ_ALBUMARTISTID" => MusicBrainzReleaseArtistId, "MUSICBRAINZ_WORKID" => MusicBrainzWorkId ); gen_map!( ID3V2_MAP; "TALB" => AlbumTitle, "TSST" => SetSubtitle, "TIT1" => ContentGroup, "GRP1" => AppleId3v2ContentGroup, "TIT2" => TrackTitle, "TIT3" => TrackSubtitle, "TOAL" => OriginalAlbumTitle, "TOPE" => OriginalArtist, "TOLY" => OriginalLyricist, "TSOA" => AlbumTitleSortOrder, "TSO2" => AlbumArtistSortOrder, "TSOT" => TrackTitleSortOrder, "TSOP" => TrackArtistSortOrder, "TSOC" => ComposerSortOrder, "TPE2" => AlbumArtist, "TPE1" => TrackArtist, "TEXT" => Writer, "TCOM" => Composer, "TPE3" => Conductor, "DIRECTOR" => Director, "TEXT" => Lyricist, "TMCL" => MusicianCredits, "TPUB" => Publisher, "TPUB" => Label, "TRSN" => InternetRadioStationName, "TRSO" => InternetRadioStationOwner, "TPE4" => Remixer, "TPOS" => DiscNumber, "TPOS" => DiscTotal, "TRCK" => TrackNumber, "TRCK" => TrackTotal, "POPM" => Popularimeter, "ITUNESADVISORY" => ParentalAdvisory, "TDRC" => RecordingDate, "TDOR" => OriginalReleaseDate, "TSRC" => Isrc, "BARCODE" => Barcode, "CATALOGNUMBER" => CatalogNumber, "WORK" => Work, // ID3v2.4: TXXX:WORK (Apple uses TIT1/ContentGroup, see GRP1/AppleId3v2ContentGroup for disambiguation) "MVNM" => Movement, "MVIN" => MovementNumber, "MVIN" => MovementTotal, "TCMP" => FlagCompilation, "PCST" => FlagPodcast, "TFLT" => FileType, "TOWN" => FileOwner, "TDTG" => TaggingTime, "TLEN" => Length, "TOFN" => OriginalFileName, "TMED" => OriginalMediaType, "TENC" => EncodedBy, "TSSE" => EncoderSoftware, "TSSE" => EncoderSettings, "TDEN" => EncodingTime, "REPLAYGAIN_ALBUM_GAIN" => ReplayGainAlbumGain, "REPLAYGAIN_ALBUM_PEAK" => ReplayGainAlbumPeak, "REPLAYGAIN_TRACK_GAIN" => ReplayGainTrackGain, "REPLAYGAIN_TRACK_PEAK" => ReplayGainTrackPeak, "WOAF" => AudioFileUrl, "WOAS" => AudioSourceUrl, "WCOM" => CommercialInformationUrl, "WCOP" => CopyrightUrl, "WOAR" => TrackArtistUrl, "WORS" => RadioStationUrl, "WPAY" => PaymentUrl, "WPUB" => PublisherUrl, "TCON" => Genre, "TKEY" => InitialKey, "COLOR" => Color, "TMOO" => Mood, "TBPM" => IntegerBpm, "TCOP" => CopyrightMessage, "TDES" => PodcastDescription, "TCAT" => PodcastSeriesCategory, "WFED" => PodcastUrl, "TDRL" => ReleaseDate, "TGID" => PodcastGlobalUniqueId, "TKWD" => PodcastKeywords, "COMM" => Comment, "TLAN" => Language, "USLT" => Lyrics, // Mapping of MusicBrainzRecordingId is implemented as a special case "MusicBrainz Release Track Id" => MusicBrainzTrackId, "MusicBrainz Album Id" => MusicBrainzReleaseId, "MusicBrainz Release Group Id" => MusicBrainzReleaseGroupId, "MusicBrainz Artist Id" => MusicBrainzArtistId, "MusicBrainz Album Artist Id" => MusicBrainzReleaseArtistId, "MusicBrainz Work Id" => MusicBrainzWorkId ); gen_map!( ILST_MAP; "\u{a9}alb" => AlbumTitle, "----:com.apple.iTunes:DISCSUBTITLE" => SetSubtitle, "tvsh" => ShowName, "\u{a9}grp" => ContentGroup, "\u{a9}nam" => TrackTitle, "----:com.apple.iTunes:SUBTITLE" => TrackSubtitle, "\u{a9}wrk" => Work, "\u{a9}mvn" => Movement, "\u{a9}mvi" => MovementNumber, "\u{a9}mvc" => MovementTotal, "soal" => AlbumTitleSortOrder, "soaa" => AlbumArtistSortOrder, "sonm" => TrackTitleSortOrder, "soar" => TrackArtistSortOrder, "sosn" => ShowNameSortOrder, "soco" => ComposerSortOrder, "aART" => AlbumArtist, "\u{a9}ART" => TrackArtist, "\u{a9}wrt" => Composer, "\u{a9}dir" => Director, "----:com.apple.iTunes:CONDUCTOR" => Conductor, "----:com.apple.iTunes:ENGINEER" => Engineer, "----:com.apple.iTunes:LYRICIST" => Lyricist, "----:com.apple.iTunes:DJMIXER" => MixDj, "----:com.apple.iTunes:MIXER" => MixEngineer, "----:com.apple.iTunes:PRODUCER" => Producer, "----:com.apple.iTunes:LABEL" => Label, "----:com.apple.iTunes:REMIXER" => Remixer, "disk" => DiscNumber, "disk" => DiscTotal, "trkn" => TrackNumber, "trkn" => TrackTotal, "rate" => Popularimeter, "rtng" => ParentalAdvisory, "\u{a9}day" => RecordingDate, "----:com.apple.iTunes:ORIGINALDATE" => OriginalReleaseDate, // TagLib v2.0 "----:com.apple.iTunes:RELEASEDATE" => ReleaseDate, "----:com.apple.iTunes:ISRC" => Isrc, "----:com.apple.iTunes:BARCODE" => Barcode, "----:com.apple.iTunes:CATALOGNUMBER" => CatalogNumber, "cpil" => FlagCompilation, "pcst" => FlagPodcast, "----:com.apple.iTunes:MEDIA" => OriginalMediaType, "\u{a9}enc" => EncodedBy, "\u{a9}too" => EncoderSoftware, "\u{a9}gen" => Genre, "----:com.apple.iTunes:COLOR" => Color, "----:com.apple.iTunes:MOOD" => Mood, "tmpo" => IntegerBpm, "----:com.apple.iTunes:BPM" => Bpm, "----:com.apple.iTunes:initialkey" => InitialKey, "----:com.apple.iTunes:replaygain_album_gain" => ReplayGainAlbumGain, "----:com.apple.iTunes:replaygain_album_peak" => ReplayGainAlbumPeak, "----:com.apple.iTunes:replaygain_track_gain" => ReplayGainTrackGain, "----:com.apple.iTunes:replaygain_track_peak" => ReplayGainTrackPeak, "cprt" => CopyrightMessage, "----:com.apple.iTunes:LICENSE" => License, "ldes" => PodcastDescription, "catg" => PodcastSeriesCategory, "purl" => PodcastUrl, "egid" => PodcastGlobalUniqueId, "keyw" => PodcastKeywords, "\u{a9}cmt" => Comment, "desc" => Description, "----:com.apple.iTunes:LANGUAGE" => Language, "----:com.apple.iTunes:SCRIPT" => Script, "\u{a9}lyr" => Lyrics, "xid " => AppleXid, "----:com.apple.iTunes:MusicBrainz Track Id" => MusicBrainzRecordingId, "----:com.apple.iTunes:MusicBrainz Release Track Id" => MusicBrainzTrackId, "----:com.apple.iTunes:MusicBrainz Album Id" => MusicBrainzReleaseId, "----:com.apple.iTunes:MusicBrainz Release Group Id" => MusicBrainzReleaseGroupId, "----:com.apple.iTunes:MusicBrainz Artist Id" => MusicBrainzArtistId, "----:com.apple.iTunes:MusicBrainz Album Artist Id" => MusicBrainzReleaseArtistId, "----:com.apple.iTunes:MusicBrainz Work Id" => MusicBrainzWorkId ); gen_map!( RIFF_INFO_MAP; "IPRD" => AlbumTitle, "INAM" => TrackTitle, "IART" => TrackArtist, "IWRI" => Writer, "IMUS" => Composer, "IPRO" => Producer, "IPRT" | "ITRK" => TrackNumber, "IFRM" => TrackTotal, "IRTD" => Popularimeter, "ICRD" => RecordingDate, "TLEN" => Length, "ISRF" => OriginalMediaType, "ITCH" => EncodedBy, "ISFT" => EncoderSoftware, "IGNR" => Genre, "ICOP" => CopyrightMessage, "ICMT" => Comment, "ILNG" => Language ); gen_map!( VORBIS_MAP; "ALBUM" => AlbumTitle, "DISCSUBTITLE" => SetSubtitle, "GROUPING" => ContentGroup, "TITLE" => TrackTitle, "SUBTITLE" => TrackSubtitle, "WORK" => Work, "MOVEMENTNAME" => Movement, "MOVEMENT" => MovementNumber, "MOVEMENTTOTAL" => MovementTotal, "ALBUMSORT" => AlbumTitleSortOrder, "ALBUMARTISTSORT" => AlbumArtistSortOrder, "TITLESORT" => TrackTitleSortOrder, "ARTISTSORT" => TrackArtistSortOrder, "ALBUMARTIST" => AlbumArtist, "ARTIST" => TrackArtist, "ARRANGER" => Arranger, "AUTHOR" | "WRITER" => Writer, "COMPOSER" => Composer, "CONDUCTOR" => Conductor, "DIRECTOR" => Director, "ENGINEER" => Engineer, "LYRICIST" => Lyricist, "DJMIXER" => MixDj, "MIXER" => MixEngineer, "PERFORMER" => Performer, "PRODUCER" => Producer, "PUBLISHER" => Publisher, "LABEL" | "ORGANIZATION" => Label, "REMIXER" | "MIXARTIST" => Remixer, "DISCNUMBER" => DiscNumber, "DISCTOTAL" | "TOTALDISCS" => DiscTotal, "TRACKNUMBER" => TrackNumber, "TRACKTOTAL" | "TOTALTRACKS" => TrackTotal, "RATING" => Popularimeter, "DATE" => RecordingDate, "YEAR" => Year, "ORIGINALDATE" | "ORIGINALYEAR" => OriginalReleaseDate, "RELEASEDATE" => ReleaseDate, "ISRC" => Isrc, "BARCODE" => Barcode, "CATALOGNUMBER" => CatalogNumber, "COMPILATION" => FlagCompilation, "MEDIA" => OriginalMediaType, "ENCODEDBY" | "ENCODED-BY" | "ENCODED_BY" => EncodedBy, "ENCODER" => EncoderSoftware, "ENCODING" | "ENCODERSETTINGS" => EncoderSettings, "REPLAYGAIN_ALBUM_GAIN" => ReplayGainAlbumGain, "REPLAYGAIN_ALBUM_PEAK" => ReplayGainAlbumPeak, "REPLAYGAIN_TRACK_GAIN" => ReplayGainTrackGain, "REPLAYGAIN_TRACK_PEAK" => ReplayGainTrackPeak, "GENRE" => Genre, "COLOR" => Color, "MOOD" => Mood, "BPM" => Bpm, // MusicBrainz Picard suggests "KEY" (VirtualDJ, Denon Engine DJ), but "INITIALKEY" // seems to be more common (Rekordbox, Serato DJ, Traktor DJ, Mixxx). // // "INITIALKEY" | "KEY" => InitialKey, "COPYRIGHT" => CopyrightMessage, "LICENSE" => License, "COMMENT" => Comment, "LANGUAGE" => Language, "SCRIPT" => Script, "LYRICS" => Lyrics, "MUSICBRAINZ_TRACKID" => MusicBrainzRecordingId, "MUSICBRAINZ_RELEASETRACKID" => MusicBrainzTrackId, "MUSICBRAINZ_ALBUMID" => MusicBrainzReleaseId, "MUSICBRAINZ_RELEASEGROUPID" => MusicBrainzReleaseGroupId, "MUSICBRAINZ_ARTISTID" => MusicBrainzArtistId, "MUSICBRAINZ_ALBUMARTISTID" => MusicBrainzReleaseArtistId, "MUSICBRAINZ_WORKID" => MusicBrainzWorkId ); macro_rules! gen_item_keys { ( MAPS => [ $( $(#[$feat:meta])? [$tag_type:pat, $MAP:ident] ),+ ]; KEYS => [ $( $(#[$variant_meta:meta])* $variant_ident:ident ),+ $(,)? ] ) => { #[derive(PartialEq, Clone, Debug, Eq, Hash)] #[allow(missing_docs)] #[non_exhaustive] /// A generic representation of a tag's key pub enum ItemKey { $( $(#[$variant_meta])* $variant_ident, )+ /// When a key couldn't be mapped to another variant /// /// This **will not** allow writing keys that are out of spec (Eg. ID3v2.4 frame IDs **must** be 4 characters) Unknown(String), } impl ItemKey { /// Map a format specific key to an `ItemKey` /// /// NOTE: If used with ID3v2, this will only check against the ID3v2.4 keys. /// If you wish to use a V2 or V3 key, see [`upgrade_v2`](crate::id3::v2::upgrade_v2) and [`upgrade_v3`](crate::id3::v2::upgrade_v3) pub fn from_key(tag_type: TagType, key: &str) -> Self { match tag_type { $( $(#[$feat])? $tag_type => $MAP.get_item_key(key).unwrap_or_else(|| Self::Unknown(key.to_string())), )+ _ => Self::Unknown(key.to_string()) } } /// Maps the variant to a format-specific key /// /// Use `allow_unknown` to include [`ItemKey::Unknown`]. It is up to the caller /// to determine if the unknown key actually fits the format's specifications. pub fn map_key(&self, tag_type: TagType, allow_unknown: bool) -> Option<&str> { match tag_type { $( $(#[$feat])? $tag_type => if let Some(key) = $MAP.get_key(self) { return Some(key) }, )+ _ => {} } if let ItemKey::Unknown(ref unknown) = self { if allow_unknown { return Some(unknown) } } None } } } } gen_item_keys!( MAPS => [ [TagType::AiffText, AIFF_TEXT_MAP], [TagType::Ape, APE_MAP], [TagType::Id3v2, ID3V2_MAP], [TagType::Mp4Ilst, ILST_MAP], [TagType::RiffInfo, RIFF_INFO_MAP], [TagType::VorbisComments, VORBIS_MAP] ]; KEYS => [ // Titles AlbumTitle, SetSubtitle, ShowName, ContentGroup, TrackTitle, TrackSubtitle, // Original names OriginalAlbumTitle, OriginalArtist, OriginalLyricist, // Sorting AlbumTitleSortOrder, AlbumArtistSortOrder, TrackTitleSortOrder, TrackArtistSortOrder, ShowNameSortOrder, ComposerSortOrder, // People & Organizations AlbumArtist, TrackArtist, Arranger, Writer, Composer, Conductor, Director, Engineer, Lyricist, MixDj, MixEngineer, MusicianCredits, Performer, Producer, Publisher, Label, InternetRadioStationName, InternetRadioStationOwner, Remixer, // Counts & Indexes DiscNumber, DiscTotal, TrackNumber, TrackTotal, Popularimeter, ParentalAdvisory, // Dates /// Recording date /// /// RecordingDate, /// Year Year, /// Release date /// /// The release date of a podcast episode or any other kind of release. /// /// ReleaseDate, /// Original release date/year /// /// /// OriginalReleaseDate, // Identifiers Isrc, Barcode, CatalogNumber, Work, Movement, MovementNumber, MovementTotal, /////////////////////////////////////////////////////////////// // MusicBrainz Identifiers /// MusicBrainz Recording ID /// /// Textual representation of the UUID. /// /// Reference: MusicBrainzRecordingId, /// MusicBrainz Track ID /// /// Textual representation of the UUID. /// /// Reference: MusicBrainzTrackId, /// MusicBrainz Release ID /// /// Textual representation of the UUID. /// /// Reference: MusicBrainzReleaseId, /// MusicBrainz Release Group ID /// /// Textual representation of the UUID. /// /// Reference: MusicBrainzReleaseGroupId, /// MusicBrainz Artist ID /// /// Textual representation of the UUID. /// /// Reference: MusicBrainzArtistId, /// MusicBrainz Release Artist ID /// /// Textual representation of the UUID. /// /// Reference: MusicBrainzReleaseArtistId, /// MusicBrainz Work ID /// /// Textual representation of the UUID. /// /// Reference: MusicBrainzWorkId, /////////////////////////////////////////////////////////////// // Flags FlagCompilation, FlagPodcast, // File Information FileType, FileOwner, TaggingTime, Length, OriginalFileName, OriginalMediaType, // Encoder information EncodedBy, EncoderSoftware, EncoderSettings, EncodingTime, ReplayGainAlbumGain, ReplayGainAlbumPeak, ReplayGainTrackGain, ReplayGainTrackPeak, // URLs AudioFileUrl, AudioSourceUrl, CommercialInformationUrl, CopyrightUrl, TrackArtistUrl, RadioStationUrl, PaymentUrl, PublisherUrl, // Style Genre, InitialKey, Color, Mood, /// Decimal BPM value with arbitrary precision /// /// Only read and written if the tag format supports a field for decimal BPM values /// that are not restricted to integer values. /// /// Not supported by ID3v2 that restricts BPM values to integers in `TBPM`. Bpm, /// Non-fractional BPM value with integer precision /// /// Only read and written if the tag format has a field for integer BPM values, /// e.g. ID3v2 ([`TBPM` frame](https://github.com/id3/ID3v2.4/blob/516075e38ff648a6390e48aff490abed987d3199/id3v2.4.0-frames.txt#L376)) /// and MP4 (`tmpo` integer atom). IntegerBpm, // Legal CopyrightMessage, License, // Podcast PodcastDescription, PodcastSeriesCategory, PodcastUrl, PodcastGlobalUniqueId, PodcastKeywords, // Miscellaneous Comment, Description, Language, Script, Lyrics, // Vendor-specific AppleXid, AppleId3v2ContentGroup, // GRP1 ] ); /// Represents a tag item's value #[derive(Clone, Debug, PartialEq, Eq, Hash)] pub enum ItemValue { /// Any UTF-8 encoded text Text(String), /// Any UTF-8 encoded locator of external information /// /// This is only gets special treatment in `ID3v2` and `APE` tags, being written /// as a normal string in other tags Locator(String), /// Binary information Binary(Vec), } impl ItemValue { /// Returns the value if the variant is `Text` pub fn text(&self) -> Option<&str> { match self { Self::Text(ref text) => Some(text), _ => None, } } /// Returns the value if the variant is `Locator` pub fn locator(&self) -> Option<&str> { match self { Self::Locator(ref locator) => Some(locator), _ => None, } } /// Returns the value if the variant is `Binary` pub fn binary(&self) -> Option<&[u8]> { match self { Self::Binary(ref bin) => Some(bin), _ => None, } } /// Consumes the `ItemValue`, returning a `String` if the variant is `Text` or `Locator` pub fn into_string(self) -> Option { match self { Self::Text(s) | Self::Locator(s) => Some(s), _ => None, } } /// Consumes the `ItemValue`, returning a `Vec` if the variant is `Binary` pub fn into_binary(self) -> Option> { match self { Self::Binary(b) => Some(b), _ => None, } } /// Check for emptiness pub fn is_empty(&self) -> bool { match self { Self::Binary(binary) => binary.is_empty(), Self::Locator(locator) => locator.is_empty(), Self::Text(text) => text.is_empty(), } } } pub(crate) enum ItemValueRef<'a> { Text(Cow<'a, str>), Locator(&'a str), Binary(&'a [u8]), } impl<'a> Into> for &'a ItemValue { fn into(self) -> ItemValueRef<'a> { match self { ItemValue::Text(text) => ItemValueRef::Text(Cow::Borrowed(text)), ItemValue::Locator(locator) => ItemValueRef::Locator(locator), ItemValue::Binary(binary) => ItemValueRef::Binary(binary), } } } /// Represents a tag item (key/value) #[derive(Clone, Debug, PartialEq, Eq, Hash)] pub struct TagItem { pub(crate) lang: Lang, pub(crate) description: String, pub(crate) item_key: ItemKey, pub(crate) item_value: ItemValue, } impl TagItem { /// Create a new [`TagItem`] /// /// NOTES: /// /// * This will check for validity based on the [`TagType`]. /// * If the [`ItemKey`] does not map to a key in the target format, `None` will be returned. /// * This is unnecessary if you plan on using [`Tag::insert`](crate::tag::Tag::insert), as it does validity checks itself. pub fn new_checked( tag_type: TagType, item_key: ItemKey, item_value: ItemValue, ) -> Option { item_key .map_key(tag_type, false) .is_some() .then_some(Self::new(item_key, item_value)) } /// Create a new [`TagItem`] #[must_use] pub const fn new(item_key: ItemKey, item_value: ItemValue) -> Self { Self { lang: UNKNOWN_LANGUAGE, description: String::new(), item_key, item_value, } } /// Set a language for the [`TagItem`] /// /// The default language is empty. /// /// NOTE: This will not be reflected in most tag formats. pub fn set_lang(&mut self, lang: Lang) { self.lang = lang; } /// Returns a reference to the language of the [`TagItem`] /// /// NOTE: This will not be reflected in most tag formats. pub fn lang(&self) -> &Lang { &self.lang } /// Set a description for the [`TagItem`] /// /// The default description is empty. /// /// NOTE: This will not be reflected in most tag formats. pub fn set_description(&mut self, description: String) { self.description = description; } /// Returns a reference to the description of the [`TagItem`] /// /// NOTE: This will not be reflected in most tag formats. pub fn description(&self) -> &str { &self.description } /// Returns a reference to the [`ItemKey`] pub fn key(&self) -> &ItemKey { &self.item_key } /// Consumes the `TagItem`, returning its [`ItemKey`] pub fn into_key(self) -> ItemKey { self.item_key } /// Returns a reference to the [`ItemValue`] pub fn value(&self) -> &ItemValue { &self.item_value } /// Consumes the `TagItem`, returning its [`ItemValue`] pub fn into_value(self) -> ItemValue { self.item_value } /// Consumes the `TagItem`, returning its [`ItemKey`] and [`ItemValue`] pub fn consume(self) -> (ItemKey, ItemValue) { (self.item_key, self.item_value) } pub(crate) fn re_map(&self, tag_type: TagType) -> bool { if tag_type == TagType::Id3v1 { use crate::id3::v1::constants::VALID_ITEMKEYS; return VALID_ITEMKEYS.contains(&self.item_key); } self.item_key.map_key(tag_type, false).is_some() } } lofty-0.21.1/src/tag/items/lang.rs000064400000000000000000000013551046102023000150200ustar 00000000000000/// A three character language code, as specified by [ISO-639-2]. /// /// For now, this is used exclusively in ID3v2. /// /// Excerpt from : /// /// > The three byte language field, present in several frames, is used to describe /// > the language of the frame’s content, according to [ISO-639-2]. /// > The language should be represented in lower case. If the language is not known /// > the string “XXX” should be used. /// /// [ISO-639-2]: https://en.wikipedia.org/wiki/List_of_ISO_639-2_codes. pub type Lang = [u8; 3]; /// English language code pub const ENGLISH: Lang = *b"eng"; /// Unknown/unspecified language pub const UNKNOWN_LANGUAGE: [u8; 3] = *b"XXX"; lofty-0.21.1/src/tag/items/mod.rs000064400000000000000000000001731046102023000146530ustar 00000000000000//! Various generic representations of tag items mod lang; mod timestamp; pub use lang::*; pub use timestamp::Timestamp; lofty-0.21.1/src/tag/items/timestamp.rs000064400000000000000000000212631046102023000161020ustar 00000000000000use crate::config::ParsingMode; use crate::error::{ErrorKind, LoftyError, Result}; use crate::macros::err; use std::fmt::Display; use std::io::Read; use std::str::FromStr; use byteorder::ReadBytesExt; /// A subset of the ISO 8601 timestamp format #[derive(Copy, Clone, Debug, Eq, PartialEq, Hash, Default)] #[allow(missing_docs)] pub struct Timestamp { pub year: u16, pub month: Option, pub day: Option, pub hour: Option, pub minute: Option, pub second: Option, } impl PartialOrd for Timestamp { fn partial_cmp(&self, other: &Self) -> Option { Some(self.cmp(other)) } } impl Ord for Timestamp { fn cmp(&self, other: &Self) -> std::cmp::Ordering { self.year .cmp(&other.year) .then(self.month.cmp(&other.month)) .then(self.day.cmp(&other.day)) .then(self.hour.cmp(&other.hour)) .then(self.minute.cmp(&other.minute)) .then(self.second.cmp(&other.second)) } } impl Display for Timestamp { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{:04}", self.year)?; if let Some(month) = self.month { write!(f, "-{:02}", month)?; if let Some(day) = self.day { write!(f, "-{:02}", day)?; if let Some(hour) = self.hour { write!(f, "T{:02}", hour)?; if let Some(minute) = self.minute { write!(f, ":{:02}", minute)?; if let Some(second) = self.second { write!(f, ":{:02}", second)?; } } } } } Ok(()) } } impl FromStr for Timestamp { type Err = LoftyError; fn from_str(s: &str) -> Result { Timestamp::parse(&mut s.as_bytes(), ParsingMode::BestAttempt)? .ok_or_else(|| LoftyError::new(ErrorKind::BadTimestamp("Timestamp frame is empty"))) } } impl Timestamp { /// The maximum length of a timestamp in bytes pub const MAX_LENGTH: usize = 19; /// Read a [`Timestamp`] /// /// NOTE: This will take [`Self::MAX_LENGTH`] bytes from the reader. Ensure that it only contains the timestamp /// /// # Errors /// /// * Failure to read from `reader` /// * The timestamp is invalid pub fn parse(reader: &mut R, parse_mode: ParsingMode) -> Result> where R: Read, { macro_rules! read_segment { ($expr:expr) => { match $expr { Ok((val, _)) => Some(val as u8), Err(LoftyError { kind: ErrorKind::Io(io), }) if matches!(io.kind(), std::io::ErrorKind::UnexpectedEof) => break, Err(e) => return Err(e.into()), } }; } let mut timestamp = Timestamp::default(); let mut content = Vec::with_capacity(Self::MAX_LENGTH); reader .take(Self::MAX_LENGTH as u64) .read_to_end(&mut content)?; if content.is_empty() { if parse_mode == ParsingMode::Strict { err!(BadTimestamp("Timestamp frame is empty")) } return Ok(None); } let reader = &mut &content[..]; // We need to very that the year is exactly 4 bytes long. This doesn't matter for other segments. let (year, bytes_read) = Self::segment::<4>(reader, None, parse_mode)?; if bytes_read != 4 { err!(BadTimestamp( "Encountered an invalid year length (should be 4 digits)" )) } timestamp.year = year; #[allow(clippy::never_loop)] loop { timestamp.month = read_segment!(Self::segment::<2>(reader, Some(b'-'), parse_mode)); timestamp.day = read_segment!(Self::segment::<2>(reader, Some(b'-'), parse_mode)); timestamp.hour = read_segment!(Self::segment::<2>(reader, Some(b'T'), parse_mode)); timestamp.minute = read_segment!(Self::segment::<2>(reader, Some(b':'), parse_mode)); timestamp.second = read_segment!(Self::segment::<2>(reader, Some(b':'), parse_mode)); break; } Ok(Some(timestamp)) } fn segment( content: &mut &[u8], sep: Option, parse_mode: ParsingMode, ) -> Result<(u16, usize)> { const SEPARATORS: [u8; 3] = [b'-', b'T', b':']; if let Some(sep) = sep { let byte = content.read_u8()?; if byte != sep { err!(BadTimestamp("Expected a separator")) } } if content.len() < SIZE { err!(BadTimestamp("Timestamp segment is too short")) } let mut num = 0; let mut byte_count = 0; for i in content[..SIZE].iter().copied() { // Common spec violation: Timestamps may use spaces instead of zeros, so the month of June // could be written as " 6" rather than "06" for example. if i == b' ' { if parse_mode == ParsingMode::Strict { err!(BadTimestamp("Timestamp contains spaces")) } byte_count += 1; continue; } if !i.is_ascii_digit() { // Another spec violation, timestamps in the wild may not use a zero or a space, so // we would have to treat "06", "6", and " 6" as valid. // // The easiest way to check for a missing digit is to see if we're just eating into // the next segment's separator. if sep.is_some() && SEPARATORS.contains(&i) && parse_mode != ParsingMode::Strict { break; } err!(BadTimestamp( "Timestamp segment contains non-digit characters" )) } num = num * 10 + u16::from(i - b'0'); byte_count += 1; } *content = &content[byte_count..]; Ok((num, byte_count)) } pub(crate) fn verify(&self) -> Result<()> { fn verify_field(field: Option, limit: u8, parent: Option) -> bool { if let Some(field) = field { return parent.is_some() && field <= limit; } return true; // Field does not exist, so it's valid } if self.year > 9999 || !verify_field(self.month, 12, Some(self.year as u8)) || !verify_field(self.day, 31, self.month) || !verify_field(self.hour, 23, self.day) || !verify_field(self.minute, 59, self.hour) || !verify_field(self.second, 59, self.minute) { err!(BadTimestamp( "Timestamp contains segment(s) that exceed their limits" )) } Ok(()) } } #[cfg(test)] mod tests { use crate::config::ParsingMode; use crate::tag::items::timestamp::Timestamp; fn expected() -> Timestamp { // 2024-06-03T14:08:49 Timestamp { year: 2024, month: Some(6), day: Some(3), hour: Some(14), minute: Some(8), second: Some(49), } } #[test] fn timestamp_decode() { let content = "2024-06-03T14:08:49"; let parsed_timestamp = Timestamp::parse(&mut content.as_bytes(), ParsingMode::Strict).unwrap(); assert_eq!(parsed_timestamp, Some(expected())); } #[test] fn timestamp_decode_no_zero() { // Zeroes are not used let content = "2024-6-3T14:8:49"; let parsed_timestamp = Timestamp::parse(&mut content.as_bytes(), ParsingMode::BestAttempt).unwrap(); assert_eq!(parsed_timestamp, Some(expected())); } #[test] fn timestamp_decode_zero_substitution() { // Zeros are replaced by spaces let content = "2024- 6- 3T14: 8:49"; let parsed_timestamp = Timestamp::parse(&mut content.as_bytes(), ParsingMode::BestAttempt).unwrap(); assert_eq!(parsed_timestamp, Some(expected())); } #[test] fn timestamp_encode() { let encoded = expected().to_string(); assert_eq!(encoded, "2024-06-03T14:08:49"); } #[test] fn timestamp_encode_invalid() { let mut timestamp = expected(); // Hour, minute, and second have a dependency on day timestamp.day = None; assert_eq!(timestamp.to_string().len(), 7); } #[test] fn reject_broken_timestamps() { let broken_timestamps: &[&[u8]] = &[ b"2024-", b"2024-06-", b"2024--", b"2024- -", b"2024-06-03T", b"2024:06", b"2024-0-", ]; for timestamp in broken_timestamps { let parsed_timestamp = Timestamp::parse(&mut ×tamp[..], ParsingMode::BestAttempt); assert!(parsed_timestamp.is_err()); } } #[test] fn timestamp_decode_partial() { let partial_timestamps: [(&[u8], Timestamp); 6] = [ ( b"2024", Timestamp { year: 2024, ..Timestamp::default() }, ), ( b"2024-06", Timestamp { year: 2024, month: Some(6), ..Timestamp::default() }, ), ( b"2024-06-03", Timestamp { year: 2024, month: Some(6), day: Some(3), ..Timestamp::default() }, ), ( b"2024-06-03T14", Timestamp { year: 2024, month: Some(6), day: Some(3), hour: Some(14), ..Timestamp::default() }, ), ( b"2024-06-03T14:08", Timestamp { year: 2024, month: Some(6), day: Some(3), hour: Some(14), minute: Some(8), ..Timestamp::default() }, ), (b"2024-06-03T14:08:49", expected()), ]; for (data, expected) in partial_timestamps { let parsed_timestamp = Timestamp::parse(&mut &data[..], ParsingMode::Strict).unwrap(); assert_eq!(parsed_timestamp, Some(expected)); } } #[test] fn empty_timestamp() { let empty_timestamp = Timestamp::parse(&mut "".as_bytes(), ParsingMode::BestAttempt).unwrap(); assert!(empty_timestamp.is_none()); let empty_timestamp_strict = Timestamp::parse(&mut "".as_bytes(), ParsingMode::Strict); assert!(empty_timestamp_strict.is_err()); } } lofty-0.21.1/src/tag/mod.rs000064400000000000000000000555341046102023000135450ustar 00000000000000//! Utilities for generic tag handling mod accessor; pub(crate) mod companion_tag; pub(crate) mod item; pub mod items; mod split_merge_tag; mod tag_ext; mod tag_type; pub(crate) mod utils; use crate::config::WriteOptions; use crate::error::{LoftyError, Result}; use crate::macros::err; use crate::picture::{Picture, PictureType}; use crate::probe::Probe; use crate::util::io::{FileLike, Length, Truncate}; use std::borrow::Cow; use std::io::Write; use std::path::Path; // Exports pub use accessor::Accessor; pub use item::{ItemKey, ItemValue, TagItem}; pub use split_merge_tag::{MergeTag, SplitTag}; pub use tag_ext::TagExt; pub use tag_type::TagType; macro_rules! impl_accessor { ($($item_key:ident => $name:tt),+) => { paste::paste! { $( fn $name(&self) -> Option> { if let Some(ItemValue::Text(txt)) = self.get(&ItemKey::$item_key).map(TagItem::value) { return Some(Cow::Borrowed(txt)) } None } fn [](&mut self, value: String) { self.insert(TagItem::new(ItemKey::$item_key, ItemValue::Text(value))); } fn [](&mut self) { self.retain(|i| i.item_key != ItemKey::$item_key) } )+ } } } /// Represents a parsed tag /// /// This is a tag that is loosely bound to a specific [`TagType`]. /// It is used for conversions and as the return type for [`read_from`](crate::read_from). /// /// Compared to other formats, this gives a much higher-level view of the /// tag items. Rather than storing items according to their format-specific /// keys, [`ItemKey`]s are used. /// /// You can easily remap this to another [`TagType`] with [`Tag::re_map`]. /// /// Any conversion will, of course, be lossy to a varying degree. /// /// ## Usage /// /// Accessing common items /// /// ```rust /// use lofty::tag::{Accessor, Tag, TagType}; /// /// let tag = Tag::new(TagType::Id3v2); /// /// // There are multiple quick getter methods for common items /// /// let title = tag.title(); /// let artist = tag.artist(); /// let album = tag.album(); /// let genre = tag.genre(); /// ``` /// /// Getting an item of a known type /// /// ```rust /// use lofty::tag::{ItemKey, Tag, TagType}; /// /// let tag = Tag::new(TagType::Id3v2); /// /// // If the type of an item is known, there are getter methods /// // to prevent having to match against the value /// /// tag.get_string(&ItemKey::TrackTitle); /// tag.get_binary(&ItemKey::TrackTitle, false); /// ``` /// /// Converting between formats /// /// ```rust /// use lofty::id3::v2::Id3v2Tag; /// use lofty::tag::{Tag, TagType}; /// /// // Converting between formats is as simple as an `into` call. /// // However, such conversions can potentially be *very* lossy. /// /// let tag = Tag::new(TagType::Id3v2); /// let id3v2_tag: Id3v2Tag = tag.into(); /// ``` #[derive(Clone)] pub struct Tag { tag_type: TagType, pub(crate) pictures: Vec, pub(crate) items: Vec, pub(crate) companion_tag: Option, } #[must_use] pub(crate) fn try_parse_year(input: &str) -> Option { let (num_digits, year) = input .chars() .skip_while(|c| c.is_whitespace()) .take_while(char::is_ascii_digit) .take(4) .fold((0usize, 0u32), |(num_digits, year), c| { let decimal_digit = c.to_digit(10).expect("decimal digit"); (num_digits + 1, year * 10 + decimal_digit) }); (num_digits == 4).then_some(year) } impl Accessor for Tag { impl_accessor!( TrackArtist => artist, TrackTitle => title, AlbumTitle => album, Genre => genre, Comment => comment ); fn track(&self) -> Option { self.get_u32_from_string(&ItemKey::TrackNumber) } fn set_track(&mut self, value: u32) { self.insert_text(ItemKey::TrackNumber, value.to_string()); } fn remove_track(&mut self) { self.remove_key(&ItemKey::TrackNumber); } fn track_total(&self) -> Option { self.get_u32_from_string(&ItemKey::TrackTotal) } fn set_track_total(&mut self, value: u32) { self.insert_text(ItemKey::TrackTotal, value.to_string()); } fn remove_track_total(&mut self) { self.remove_key(&ItemKey::TrackTotal); } fn disk(&self) -> Option { self.get_u32_from_string(&ItemKey::DiscNumber) } fn set_disk(&mut self, value: u32) { self.insert_text(ItemKey::DiscNumber, value.to_string()); } fn remove_disk(&mut self) { self.remove_key(&ItemKey::DiscNumber); } fn disk_total(&self) -> Option { self.get_u32_from_string(&ItemKey::DiscTotal) } fn set_disk_total(&mut self, value: u32) { self.insert_text(ItemKey::DiscTotal, value.to_string()); } fn remove_disk_total(&mut self) { self.remove_key(&ItemKey::DiscTotal); } fn year(&self) -> Option { if let Some(item) = self .get_string(&ItemKey::Year) .map_or_else(|| self.get_string(&ItemKey::RecordingDate), Some) { return try_parse_year(item); } None } fn set_year(&mut self, value: u32) { if let Some(item) = self.get_string(&ItemKey::RecordingDate) { if item.len() >= 4 { let (_, remaining) = item.split_at(4); self.insert_text(ItemKey::RecordingDate, format!("{value}{remaining}")); return; } } // Some formats have a dedicated item for `Year`, others just have it as // a part of `RecordingDate` if ItemKey::Year.map_key(self.tag_type, false).is_some() { self.insert_text(ItemKey::Year, value.to_string()); } else { self.insert_text(ItemKey::RecordingDate, value.to_string()); } } fn remove_year(&mut self) { self.remove_key(&ItemKey::Year); self.remove_key(&ItemKey::RecordingDate); } } impl Tag { /// Initialize a new tag with a certain [`TagType`] #[must_use] pub const fn new(tag_type: TagType) -> Self { Self { tag_type, pictures: Vec::new(), items: Vec::new(), companion_tag: None, } } /// Change the [`TagType`], remapping all items /// /// NOTE: If any format-specific items are present, they will be removed. /// See [`GlobalOptions::preserve_format_specific_items`]. /// /// # Examples /// /// ```rust /// use lofty::tag::{Accessor, Tag, TagExt, TagType}; /// /// let mut tag = Tag::new(TagType::Id3v2); /// tag.set_album(String::from("Album")); /// /// // ID3v2 supports the album tag /// assert_eq!(tag.len(), 1); /// /// // But AIFF text chunks do not, the item will be lost /// tag.re_map(TagType::AiffText); /// assert!(tag.is_empty()); /// ``` /// /// [`GlobalOptions::preserve_format_specific_items`]: crate::config::GlobalOptions::preserve_format_specific_items pub fn re_map(&mut self, tag_type: TagType) { if let Some(companion_tag) = self.companion_tag.take() { log::warn!("Discarding format-specific items due to remap"); drop(companion_tag); } self.retain(|i| i.re_map(tag_type)); self.tag_type = tag_type } /// Check if the tag contains any format-specific items /// /// See [`GlobalOptions::preserve_format_specific_items`]. /// /// # Examples /// /// ```rust /// use lofty::tag::{Accessor, Tag, TagExt, TagType}; /// /// let mut tag = Tag::new(TagType::Id3v2); /// tag.set_album(String::from("Album")); /// /// // We cannot create a tag with format-specific items. /// // This must come from a conversion, such as `Id3v2Tag` -> `Tag` /// assert!(!tag.has_format_specific_items()); /// ``` /// /// [`GlobalOptions::preserve_format_specific_items`]: crate::config::GlobalOptions::preserve_format_specific_items pub fn has_format_specific_items(&self) -> bool { self.companion_tag.is_some() } /// Returns the [`TagType`] pub fn tag_type(&self) -> TagType { self.tag_type } /// Returns the number of [`TagItem`]s pub fn item_count(&self) -> u32 { self.items.len() as u32 } /// Returns the number of [`Picture`]s pub fn picture_count(&self) -> u32 { self.pictures.len() as u32 } /// Returns the stored [`TagItem`]s as a slice pub fn items(&self) -> impl Iterator + Clone { self.items.iter() } /// Returns a reference to a [`TagItem`] matching an [`ItemKey`] pub fn get(&self, item_key: &ItemKey) -> Option<&TagItem> { self.items.iter().find(|i| &i.item_key == item_key) } /// Get a string value from an [`ItemKey`] pub fn get_string(&self, item_key: &ItemKey) -> Option<&str> { if let Some(ItemValue::Text(ret)) = self.get(item_key).map(TagItem::value) { return Some(ret); } None } fn get_u32_from_string(&self, key: &ItemKey) -> Option { let i = self.get_string(key)?; i.parse::().ok() } /// Gets a byte slice from an [`ItemKey`] /// /// Use `convert` to convert [`ItemValue::Text`] and [`ItemValue::Locator`] to byte slices pub fn get_binary(&self, item_key: &ItemKey, convert: bool) -> Option<&[u8]> { if let Some(item) = self.get(item_key) { match item.value() { ItemValue::Text(text) | ItemValue::Locator(text) if convert => { return Some(text.as_bytes()) }, ItemValue::Binary(binary) => return Some(binary), _ => {}, } } None } /// Insert a [`TagItem`], replacing any existing one of the same [`ItemKey`] /// /// NOTE: This **will** verify an [`ItemKey`] mapping exists for the target [`TagType`] /// /// This will return `true` if the item was inserted. pub fn insert(&mut self, item: TagItem) -> bool { if item.re_map(self.tag_type) { self.insert_unchecked(item); return true; } false } /// Insert a [`TagItem`], replacing any existing one of the same [`ItemKey`] /// /// Notes: /// /// * This **will not** verify an [`ItemKey`] mapping exists /// * This **will not** allow writing item keys that are out of spec (keys are verified before writing) /// /// This is only necessary if dealing with [`ItemKey::Unknown`]. pub fn insert_unchecked(&mut self, item: TagItem) { self.retain(|i| i.item_key != item.item_key); self.items.push(item); } /// Append a [`TagItem`] to the tag /// /// This will not remove any items of the same [`ItemKey`], unlike [`Tag::insert`] /// /// NOTE: This **will** verify an [`ItemKey`] mapping exists for the target [`TagType`] /// /// Multiple items of the same [`ItemKey`] are not valid in all formats, in which case /// the first available item will be used. /// /// This will return `true` if the item was pushed. pub fn push(&mut self, item: TagItem) -> bool { if item.re_map(self.tag_type) { self.items.push(item); return true; } false } /// Append a [`TagItem`] to the tag /// /// Notes: See [`Tag::insert_unchecked`] pub fn push_unchecked(&mut self, item: TagItem) { self.items.push(item); } /// An alias for [`Tag::insert`] that doesn't require the user to create a [`TagItem`] /// /// NOTE: This will replace any existing item with `item_key`. See [`Tag::insert`] pub fn insert_text(&mut self, item_key: ItemKey, text: String) -> bool { self.insert(TagItem::new(item_key, ItemValue::Text(text))) } /// Removes all items with the specified [`ItemKey`], and returns them /// /// See also: [take_filter()](Self::take_filter) pub fn take(&mut self, key: &ItemKey) -> impl Iterator + '_ { self.take_filter(key, |_| true) } /// Removes selected items with the specified [`ItemKey`], and returns them /// /// Only takes items for which `filter()` returns `true`. All other items are retained. /// /// Returns the selected items in order and preserves the ordering of the remaining items. /// /// # Examples /// /// ``` /// use lofty::tag::{ItemKey, ItemValue, Tag, TagItem, TagType}; /// /// let mut tag = Tag::new(TagType::Id3v2); /// tag.push(TagItem::new( /// ItemKey::Comment, /// ItemValue::Text("comment without description".to_owned()), /// )); /// let mut item = TagItem::new( /// ItemKey::Comment, /// ItemValue::Text("comment with description".to_owned()), /// ); /// item.set_description("description".to_owned()); /// tag.push(item); /// assert_eq!(tag.get_strings(&ItemKey::Comment).count(), 2); /// /// // Extract all comment items with an empty description. /// let comments = tag /// .take_filter(&ItemKey::Comment, |item| item.description().is_empty()) /// .filter_map(|item| item.into_value().into_string()) /// .collect::>(); /// assert_eq!(comments, vec!["comment without description".to_owned()]); /// /// // The comments that didn't match the filter are still present. /// assert_eq!(tag.get_strings(&ItemKey::Comment).count(), 1); /// ``` pub fn take_filter( &mut self, key: &ItemKey, mut filter: impl FnMut(&TagItem) -> bool, ) -> impl Iterator + '_ { // TODO: drain_filter let mut split_idx = 0; for read_idx in 0..self.items.len() { let item = &self.items[read_idx]; if item.key() == key && filter(item) { self.items.swap(split_idx, read_idx); split_idx += 1; } } self.items.drain(..split_idx) } /// Removes all items with the specified [`ItemKey`], and filters them through [`ItemValue::into_string`] pub fn take_strings(&mut self, key: &ItemKey) -> impl Iterator + '_ { self.take(key).filter_map(|i| i.item_value.into_string()) } /// Returns references to all [`TagItem`]s with the specified key pub fn get_items<'a>(&'a self, key: &'a ItemKey) -> impl Iterator + Clone { self.items.iter().filter(move |i| i.key() == key) } /// Returns references to all texts of [`TagItem`]s with the specified key, and [`ItemValue::Text`] pub fn get_strings<'a>(&'a self, key: &'a ItemKey) -> impl Iterator + Clone { self.items.iter().filter_map(move |i| { if i.key() == key { i.value().text() } else { None } }) } /// Returns references to all locators of [`TagItem`]s with the specified key, and [`ItemValue::Locator`] pub fn get_locators<'a>(&'a self, key: &'a ItemKey) -> impl Iterator + Clone { self.items.iter().filter_map(move |i| { if i.key() == key { i.value().locator() } else { None } }) } /// Returns references to all bytes of [`TagItem`]s with the specified key, and [`ItemValue::Binary`] pub fn get_bytes<'a>(&'a self, key: &'a ItemKey) -> impl Iterator + Clone { self.items.iter().filter_map(move |i| { if i.key() == key { i.value().binary() } else { None } }) } /// Remove an item by its key /// /// This will remove all items with this key. pub fn remove_key(&mut self, key: &ItemKey) { self.items.retain(|i| i.key() != key) } /// Retain tag items based on the predicate /// /// See [`Vec::retain`](std::vec::Vec::retain) pub fn retain(&mut self, f: F) where F: FnMut(&TagItem) -> bool, { self.items.retain(f) } /// Remove all items with empty values pub fn remove_empty(&mut self) { self.items.retain(|item| !item.value().is_empty()); } /// Returns the stored [`Picture`]s as a slice pub fn pictures(&self) -> &[Picture] { &self.pictures } /// Returns the first occurrence of the [`PictureType`] pub fn get_picture_type(&self, picture_type: PictureType) -> Option<&Picture> { self.pictures .iter() .find(|picture| picture.pic_type() == picture_type) } /// Pushes a [`Picture`] to the tag pub fn push_picture(&mut self, picture: Picture) { self.pictures.push(picture) } /// Removes all [`Picture`]s of a [`PictureType`] pub fn remove_picture_type(&mut self, picture_type: PictureType) { self.pictures.retain(|p| p.pic_type != picture_type) } /// Replaces the picture at the given `index` /// /// NOTE: If `index` is out of bounds, the `picture` will be appended /// to the list. /// /// # Examples /// /// ```rust /// use lofty::picture::{MimeType, Picture, PictureType}; /// use lofty::tag::{Tag, TagType}; /// /// let mut tag = Tag::new(TagType::Id3v2); /// /// // Add a front cover /// let front_cover = Picture::new_unchecked( /// PictureType::CoverFront, /// Some(MimeType::Png), /// None, /// Vec::new(), /// ); /// tag.push_picture(front_cover); /// /// assert_eq!(tag.pictures().len(), 1); /// assert_eq!(tag.pictures()[0].pic_type(), PictureType::CoverFront); /// /// // Replace the front cover with a back cover /// let back_cover = Picture::new_unchecked( /// PictureType::CoverBack, /// Some(MimeType::Png), /// None, /// Vec::new(), /// ); /// tag.set_picture(0, back_cover); /// /// assert_eq!(tag.pictures().len(), 1); /// assert_eq!(tag.pictures()[0].pic_type(), PictureType::CoverBack); /// /// // Use an out of bounds index /// let another_picture = /// Picture::new_unchecked(PictureType::Band, Some(MimeType::Png), None, Vec::new()); /// tag.set_picture(100, another_picture); /// /// assert_eq!(tag.pictures().len(), 2); /// ``` pub fn set_picture(&mut self, index: usize, picture: Picture) { if index >= self.pictures.len() { self.push_picture(picture); } else { self.pictures[index] = picture; } } /// Removes and returns the picture at the given `index` /// /// # Panics /// /// Panics if `index` is out of bounds. /// /// # Examples /// /// ```rust /// use lofty::picture::{MimeType, Picture, PictureType}; /// use lofty::tag::{Tag, TagType}; /// /// let mut tag = Tag::new(TagType::Id3v2); /// /// let picture = Picture::new_unchecked( /// PictureType::CoverFront, /// Some(MimeType::Png), /// None, /// Vec::new(), /// ); /// /// tag.push_picture(picture); /// /// assert_eq!(tag.pictures().len(), 1); /// /// tag.remove_picture(0); /// /// assert_eq!(tag.pictures().len(), 0); /// ``` pub fn remove_picture(&mut self, index: usize) -> Picture { self.pictures.remove(index) } } impl TagExt for Tag { type Err = LoftyError; type RefKey<'a> = &'a ItemKey; #[inline] fn tag_type(&self) -> TagType { self.tag_type } fn len(&self) -> usize { self.items.len() + self.pictures.len() } fn contains<'a>(&'a self, key: Self::RefKey<'a>) -> bool { self.items.iter().any(|item| item.key() == key) } fn is_empty(&self) -> bool { self.items.is_empty() && self.pictures.is_empty() } /// Save the `Tag` to a [`FileLike`] /// /// # Errors /// /// * A [`FileType`](crate::file::FileType) couldn't be determined from the File /// * Attempting to write a tag to a format that does not support it. See [`FileType::supports_tag_type`](crate::file::FileType::supports_tag_type) fn save_to( &self, file: &mut F, write_options: WriteOptions, ) -> std::result::Result<(), Self::Err> where F: FileLike, LoftyError: From<::Error>, LoftyError: From<::Error>, { let probe = Probe::new(file).guess_file_type()?; match probe.file_type() { Some(file_type) => { if file_type.supports_tag_type(self.tag_type()) { utils::write_tag(self, probe.into_inner(), file_type, write_options) } else { err!(UnsupportedTag); } }, None => err!(UnknownFormat), } } fn dump_to(&self, writer: &mut W, write_options: WriteOptions) -> Result<()> { utils::dump_tag(self, writer, write_options) } /// Remove a tag from a [`Path`] /// /// # Errors /// /// See [`TagType::remove_from`] fn remove_from_path>(&self, path: P) -> std::result::Result<(), Self::Err> { self.tag_type.remove_from_path(path) } /// Remove a tag from a [`FileLike`] /// /// # Errors /// /// See [`TagType::remove_from`] fn remove_from(&self, file: &mut F) -> std::result::Result<(), Self::Err> where F: FileLike, LoftyError: From<::Error>, LoftyError: From<::Error>, { self.tag_type.remove_from(file) } fn clear(&mut self) { self.items.clear(); self.pictures.clear(); } } #[derive(Debug, Clone, Default)] #[allow(missing_docs)] pub struct SplitTagRemainder; impl SplitTag for Tag { type Remainder = SplitTagRemainder; fn split_tag(self) -> (Self::Remainder, Self) { (SplitTagRemainder, self) } } impl MergeTag for SplitTagRemainder { type Merged = Tag; fn merge_tag(self, tag: Tag) -> Self::Merged { tag } } #[cfg(test)] mod tests { use super::try_parse_year; use crate::config::WriteOptions; use crate::picture::{Picture, PictureType}; use crate::prelude::*; use crate::tag::utils::test_utils::read_path; use crate::tag::{Tag, TagType}; use std::io::{Seek, Write}; use std::process::Command; #[test] fn issue_37() { let file_contents = read_path("tests/files/assets/issue_37.ogg"); let mut temp_file = tempfile::NamedTempFile::new().unwrap(); temp_file.write_all(&file_contents).unwrap(); temp_file.rewind().unwrap(); let mut tag = Tag::new(TagType::VorbisComments); let mut picture = Picture::from_reader(&mut &*read_path("tests/files/assets/issue_37.jpg")).unwrap(); picture.set_pic_type(PictureType::CoverFront); tag.push_picture(picture); tag.save_to(temp_file.as_file_mut(), WriteOptions::default()) .unwrap(); let cmd_output = Command::new("ffprobe") .arg(temp_file.path().to_str().unwrap()) .output() .unwrap(); assert!(cmd_output.status.success()); let stderr = String::from_utf8(cmd_output.stderr).unwrap(); assert!(!stderr.contains("CRC mismatch!")); assert!( !stderr.contains("Header processing failed: Invalid data found when processing input") ); } #[test] fn issue_130_huge_picture() { // Verify we have opus-tools available, otherwise skip match Command::new("opusinfo").output() { Err(e) if matches!(e.kind(), std::io::ErrorKind::NotFound) => { eprintln!("Skipping test, `opus-tools` is not installed!"); return; }, Err(e) => panic!("{}", e), _ => {}, } let file_contents = read_path("tests/files/assets/minimal/full_test.opus"); let mut temp_file = tempfile::NamedTempFile::new().unwrap(); temp_file.write_all(&file_contents).unwrap(); temp_file.rewind().unwrap(); let mut tag = Tag::new(TagType::VorbisComments); // 81KB picture, which is big enough to surpass the maximum page size let mut picture = Picture::from_reader(&mut &*read_path("tests/files/assets/issue_37.jpg")).unwrap(); picture.set_pic_type(PictureType::CoverFront); tag.push_picture(picture); tag.save_to(temp_file.as_file_mut(), WriteOptions::default()) .unwrap(); let cmd_output = Command::new("opusinfo") .arg(temp_file.path().to_str().unwrap()) .output() .unwrap(); assert!(cmd_output.status.success()); let stderr = String::from_utf8(cmd_output.stderr).unwrap(); assert!(!stderr.contains("WARNING:")); } #[test] fn should_preserve_empty_title() { let mut tag = Tag::new(TagType::Id3v2); tag.set_title(String::from("Foo title")); assert_eq!(tag.title().as_deref(), Some("Foo title")); tag.set_title(String::new()); assert_eq!(tag.title().as_deref(), Some("")); tag.remove_title(); assert_eq!(tag.title(), None); } #[test] fn try_parse_year_with_leading_trailing_whitespace_and_various_formats() { assert_eq!(Some(1983), try_parse_year("\t 1983\n")); assert_eq!(Some(1983), try_parse_year("1983-1")); assert_eq!(Some(1983), try_parse_year("1983- 1")); assert_eq!(Some(1983), try_parse_year("1983-01")); assert_eq!(Some(1983), try_parse_year("1983-1-2")); assert_eq!(Some(1983), try_parse_year("1983- 1- 2")); assert_eq!(Some(1983), try_parse_year("1983-01-02T10:24:08Z")); assert_eq!(Some(1983), try_parse_year("1983-01-02T10:24:08.001Z")); } #[test] fn should_not_parse_year_from_less_than_4_digits() { assert!(try_parse_year("198").is_none()); assert!(try_parse_year("19").is_none()); assert!(try_parse_year("1").is_none()); } } lofty-0.21.1/src/tag/split_merge_tag.rs000064400000000000000000000064721046102023000161300ustar 00000000000000use super::Tag; /// Split (and merge) tags. /// /// Useful and required for implementing lossless read/modify/write round trips. /// Its counterpart `MergeTag` is used for recombining the results later. /// /// # Example /// /// ```rust,no_run /// use lofty::config::{ParseOptions, WriteOptions}; /// use lofty::mpeg::MpegFile; /// use lofty::prelude::*; /// /// // Read the tag from a file /// # fn main() -> lofty::error::Result<()> { /// # let mut file = std::fs::OpenOptions::new().write(true).open("/path/to/file.mp3")?; /// # let parse_options = ParseOptions::default(); /// let mut mpeg_file = ::read_from(&mut file, parse_options)?; /// let mut id3v2 = mpeg_file /// .id3v2_mut() /// .map(std::mem::take) /// .unwrap_or_default(); /// /// // Split: ID3v2 -> [`lofty::Tag`] /// let (mut remainder, mut tag) = id3v2.split_tag(); /// /// // Modify the metadata in the generic [`lofty::Tag`], independent /// // of the underlying tag and file format. /// tag.insert_text(ItemKey::TrackTitle, "Track Title".to_owned()); /// tag.remove_key(&ItemKey::Composer); /// /// // ID3v2 <- [`lofty::Tag`] /// let id3v2 = remainder.merge_tag(tag); /// /// // Write the changes back into the file /// mpeg_file.set_id3v2(id3v2); /// mpeg_file.save_to(&mut file, WriteOptions::default())?; /// /// # Ok(()) } /// ``` pub trait SplitTag: private::Sealed { /// The remainder of the split operation that is not represented /// in the resulting `Tag`. type Remainder: MergeTag; /// Extract and split generic contents into a [`Tag`]. /// /// Returns the remaining content that cannot be represented in the /// resulting `Tag` in `Self::Remainder`. This is useful if the /// modified [`Tag`] is merged later using [`MergeTag::merge_tag`]. fn split_tag(self) -> (Self::Remainder, Tag); } /// The counterpart of [`SplitTag`]. pub trait MergeTag: private::Sealed { /// The resulting tag. type Merged: SplitTag; /// Merge a generic [`Tag`] back into the remainder of [`SplitTag::split_tag`]. /// /// Restores the original representation merged with the contents of /// `tag` for further processing, e.g. writing back into a file. /// /// Multi-valued items in `tag` with identical keys might get lost /// depending on the support for multi-valued fields in `self`. fn merge_tag(self, tag: Tag) -> Self::Merged; } // https://rust-lang.github.io/api-guidelines/future-proofing.html#c-sealed mod private { use crate::ape::ApeTag; use crate::id3::v1::Id3v1Tag; use crate::id3::v2::Id3v2Tag; use crate::iff::aiff::AiffTextChunks; use crate::iff::wav::RiffInfoList; use crate::ogg::VorbisComments; use crate::tag::Tag; pub trait Sealed {} impl Sealed for AiffTextChunks {} impl Sealed for crate::iff::aiff::tag::SplitTagRemainder {} impl Sealed for ApeTag {} impl Sealed for crate::ape::tag::SplitTagRemainder {} impl Sealed for Id3v1Tag {} impl Sealed for crate::id3::v1::tag::SplitTagRemainder {} impl Sealed for Id3v2Tag {} impl Sealed for crate::id3::v2::tag::SplitTagRemainder {} impl Sealed for crate::mp4::Ilst {} impl Sealed for crate::mp4::ilst::SplitTagRemainder {} impl Sealed for RiffInfoList {} impl Sealed for crate::iff::wav::tag::SplitTagRemainder {} impl Sealed for Tag {} impl Sealed for crate::tag::SplitTagRemainder {} impl Sealed for VorbisComments {} impl Sealed for crate::ogg::tag::SplitTagRemainder {} } lofty-0.21.1/src/tag/tag_ext.rs000064400000000000000000000106141046102023000144070ustar 00000000000000use crate::config::WriteOptions; use crate::error::LoftyError; use crate::io::{FileLike, Length, Truncate}; use crate::tag::{Accessor, Tag, TagType}; use std::path::Path; /// A set of common methods between tags /// /// This provides a set of methods to make interaction with all tags a similar /// experience. /// /// This can be implemented downstream to provide a familiar interface for custom tags. pub trait TagExt: Accessor + Into + Sized + private::Sealed { /// The associated error which can be returned from IO operations type Err: From + From; /// The type of key used in the tag for non-mutating functions type RefKey<'a> where Self: 'a; #[doc(hidden)] fn tag_type(&self) -> TagType; /// Returns the number of items in the tag /// /// This will also include any extras, such as pictures. /// /// # Example /// /// ```rust /// use lofty::tag::{Accessor, ItemKey, Tag, TagExt}; /// # let tag_type = lofty::tag::TagType::Id3v2; /// /// let mut tag = Tag::new(tag_type); /// assert_eq!(tag.len(), 0); /// /// tag.set_artist(String::from("Foo artist")); /// assert_eq!(tag.len(), 1); /// ``` fn len(&self) -> usize; /// Whether the tag contains an item with the key /// /// # Example /// /// ```rust /// use lofty::tag::{Accessor, ItemKey, Tag, TagExt}; /// # let tag_type = lofty::tag::TagType::Id3v2; /// /// let mut tag = Tag::new(tag_type); /// assert!(tag.is_empty()); /// /// tag.set_artist(String::from("Foo artist")); /// assert!(tag.contains(&ItemKey::TrackArtist)); /// ``` fn contains<'a>(&'a self, key: Self::RefKey<'a>) -> bool; /// Whether the tag has any items /// /// # Example /// /// ```rust /// use lofty::tag::{Accessor, Tag, TagExt}; /// # let tag_type = lofty::tag::TagType::Id3v2; /// /// let mut tag = Tag::new(tag_type); /// assert!(tag.is_empty()); /// /// tag.set_artist(String::from("Foo artist")); /// assert!(!tag.is_empty()); /// ``` fn is_empty(&self) -> bool; /// Save the tag to a path /// /// # Errors /// /// * Path doesn't exist /// * Path is not writable /// * See [`TagExt::save_to`] fn save_to_path>( &self, path: P, write_options: WriteOptions, ) -> std::result::Result<(), Self::Err> { self.save_to( &mut std::fs::OpenOptions::new() .read(true) .write(true) .open(path)?, write_options, ) } /// Save the tag to a [`FileLike`] /// /// # Errors /// /// * The file format could not be determined /// * Attempting to write a tag to a format that does not support it. fn save_to( &self, file: &mut F, write_options: WriteOptions, ) -> std::result::Result<(), Self::Err> where F: FileLike, LoftyError: From<::Error>, LoftyError: From<::Error>; #[allow(clippy::missing_errors_doc)] /// Dump the tag to a writer /// /// This will only write the tag, it will not produce a usable file. fn dump_to( &self, writer: &mut W, write_options: WriteOptions, ) -> std::result::Result<(), Self::Err>; /// Remove a tag from a [`Path`] /// /// # Errors /// /// See [`TagExt::remove_from`] fn remove_from_path>(&self, path: P) -> std::result::Result<(), Self::Err> { self.tag_type().remove_from_path(path).map_err(Into::into) } /// Remove a tag from a [`FileLike`] /// /// # Errors /// /// * It is unable to guess the file format /// * The format doesn't support the tag /// * It is unable to write to the file fn remove_from(&self, file: &mut F) -> std::result::Result<(), Self::Err> where F: FileLike, LoftyError: From<::Error>, LoftyError: From<::Error>, { self.tag_type().remove_from(file).map_err(Into::into) } /// Clear the tag, removing all items /// /// NOTE: This will **not** remove any format-specific extras, such as flags fn clear(&mut self); } // https://rust-lang.github.io/api-guidelines/future-proofing.html#c-sealed mod private { use crate::ape::ApeTag; use crate::id3::v1::Id3v1Tag; use crate::id3::v2::Id3v2Tag; use crate::iff::aiff::AiffTextChunks; use crate::iff::wav::RiffInfoList; use crate::mp4::Ilst; use crate::ogg::VorbisComments; use crate::tag::Tag; pub trait Sealed {} impl Sealed for AiffTextChunks {} impl Sealed for ApeTag {} impl Sealed for Id3v1Tag {} impl Sealed for Id3v2Tag {} impl Sealed for Ilst {} impl Sealed for RiffInfoList {} impl Sealed for Tag {} impl Sealed for VorbisComments {} } lofty-0.21.1/src/tag/tag_type.rs000064400000000000000000000040211046102023000145630ustar 00000000000000use super::{utils, Tag}; use crate::config::WriteOptions; use crate::error::LoftyError; use crate::file::FileType; use crate::io::{FileLike, Length, Truncate}; use crate::macros::err; use crate::probe::Probe; use std::fs::OpenOptions; use std::path::Path; /// The tag's format #[derive(Copy, Clone, Debug, PartialEq, Eq)] #[non_exhaustive] pub enum TagType { /// This covers both APEv1 and APEv2 as it doesn't matter much Ape, /// Represents an ID3v1 tag Id3v1, /// This covers all ID3v2 versions since they all get upgraded to ID3v2.4 Id3v2, /// Represents an MP4 ilst atom Mp4Ilst, /// Represents vorbis comments VorbisComments, /// Represents a RIFF INFO LIST RiffInfo, /// Represents AIFF text chunks AiffText, } impl TagType { /// Remove a tag from a [`Path`] /// /// # Errors /// /// See [`TagType::remove_from`] pub fn remove_from_path(&self, path: impl AsRef) -> crate::error::Result<()> { let mut file = OpenOptions::new().read(true).write(true).open(path)?; self.remove_from(&mut file) } #[allow(clippy::shadow_unrelated)] /// Remove a tag from a [`FileLike`] /// /// # Errors /// /// * It is unable to guess the file format /// * The format doesn't support the tag /// * It is unable to write to the file pub fn remove_from(&self, file: &mut F) -> crate::error::Result<()> where F: FileLike, LoftyError: From<::Error>, LoftyError: From<::Error>, { let probe = Probe::new(file).guess_file_type()?; let Some(file_type) = probe.file_type() else { err!(UnknownFormat); }; // TODO: This should not have to be manually updated let special_exceptions = ((file_type == FileType::Ape || file_type == FileType::Mpc || file_type == FileType::Flac) && *self == TagType::Id3v2) || file_type == FileType::Mpc && *self == TagType::Id3v1; if !special_exceptions && !file_type.supports_tag_type(*self) { err!(UnsupportedTag); } let file = probe.into_inner(); utils::write_tag(&Tag::new(*self), file, file_type, WriteOptions::default()) // TODO } } lofty-0.21.1/src/tag/utils.rs000064400000000000000000000107461046102023000141220ustar 00000000000000use crate::config::WriteOptions; use crate::error::{LoftyError, Result}; use crate::file::FileType; use crate::macros::err; use crate::tag::{Tag, TagType}; use crate::util::io::{FileLike, Length, Truncate}; use crate::{aac, ape, flac, iff, mpeg, musepack, wavpack}; use crate::id3::v1::tag::Id3v1TagRef; use crate::id3::v2::tag::Id3v2TagRef; use crate::id3::v2::{self, Id3v2TagFlags}; use crate::mp4::Ilst; use crate::ogg::tag::{create_vorbis_comments_ref, VorbisCommentsRef}; use ape::tag::ApeTagRef; use iff::aiff::tag::AiffTextChunksRef; use iff::wav::tag::RIFFInfoListRef; use std::borrow::Cow; use std::io::Write; #[allow(unreachable_patterns)] pub(crate) fn write_tag( tag: &Tag, file: &mut F, file_type: FileType, write_options: WriteOptions, ) -> Result<()> where F: FileLike, LoftyError: From<::Error>, LoftyError: From<::Error>, { match file_type { FileType::Aac => aac::write::write_to(file, tag, write_options), FileType::Aiff => iff::aiff::write::write_to(file, tag, write_options), FileType::Ape => ape::write::write_to(file, tag, write_options), FileType::Flac => flac::write::write_to(file, tag, write_options), FileType::Opus | FileType::Speex | FileType::Vorbis => { crate::ogg::write::write_to(file, tag, file_type, write_options) }, FileType::Mpc => musepack::write::write_to(file, tag, write_options), FileType::Mpeg => mpeg::write::write_to(file, tag, write_options), FileType::Mp4 => crate::mp4::ilst::write::write_to( file, &mut Into::::into(tag.clone()).as_ref(), write_options, ), FileType::Wav => iff::wav::write::write_to(file, tag, write_options), FileType::WavPack => wavpack::write::write_to(file, tag, write_options), _ => err!(UnsupportedTag), } } #[allow(unreachable_patterns)] pub(crate) fn dump_tag( tag: &Tag, writer: &mut W, write_options: WriteOptions, ) -> Result<()> { match tag.tag_type() { TagType::Ape => ApeTagRef { read_only: false, items: ape::tag::tagitems_into_ape(tag), } .dump_to(writer, write_options), TagType::Id3v1 => Into::>::into(tag).dump_to(writer, write_options), TagType::Id3v2 => Id3v2TagRef { flags: Id3v2TagFlags::default(), frames: v2::tag::tag_frames(tag).peekable(), } .dump_to(writer, write_options), TagType::Mp4Ilst => Into::::into(tag.clone()) .as_ref() .dump_to(writer, write_options), TagType::VorbisComments => { let (vendor, items, pictures) = create_vorbis_comments_ref(tag); VorbisCommentsRef { vendor: Cow::from(vendor), items, pictures, } .dump_to(writer, write_options) }, TagType::RiffInfo => RIFFInfoListRef { items: iff::wav::tag::tagitems_into_riff(tag.items()), } .dump_to(writer, write_options), TagType::AiffText => { use crate::tag::item::ItemKey; AiffTextChunksRef { name: tag.get_string(&ItemKey::TrackTitle), author: tag.get_string(&ItemKey::TrackArtist), copyright: tag.get_string(&ItemKey::CopyrightMessage), annotations: Some(tag.get_strings(&ItemKey::Comment)), comments: None, } } .dump_to(writer, write_options), _ => Ok(()), } } #[cfg(test)] // Used for tag conversion tests pub(crate) mod test_utils { use crate::tag::{ItemKey, Tag, TagType}; use std::fs::File; use std::io::Read; pub(crate) fn create_tag(tag_type: TagType) -> Tag { let mut tag = Tag::new(tag_type); tag.insert_text(ItemKey::TrackTitle, String::from("Foo title")); tag.insert_text(ItemKey::TrackArtist, String::from("Bar artist")); tag.insert_text(ItemKey::AlbumTitle, String::from("Baz album")); tag.insert_text(ItemKey::Comment, String::from("Qux comment")); tag.insert_text(ItemKey::TrackNumber, String::from("1")); tag.insert_text(ItemKey::Genre, String::from("Classical")); tag } pub(crate) fn verify_tag(tag: &Tag, track_number: bool, genre: bool) { assert_eq!(tag.get_string(&ItemKey::TrackTitle), Some("Foo title")); assert_eq!(tag.get_string(&ItemKey::TrackArtist), Some("Bar artist")); assert_eq!(tag.get_string(&ItemKey::AlbumTitle), Some("Baz album")); assert_eq!(tag.get_string(&ItemKey::Comment), Some("Qux comment")); if track_number { assert_eq!(tag.get_string(&ItemKey::TrackNumber), Some("1")); } if genre { assert_eq!(tag.get_string(&ItemKey::Genre), Some("Classical")); } } pub(crate) fn read_path(path: &str) -> Vec { read_file(&mut File::open(path).unwrap()) } pub(crate) fn read_file(file: &mut File) -> Vec { let mut tag = Vec::new(); file.read_to_end(&mut tag).unwrap(); tag } } lofty-0.21.1/src/util/alloc.rs000064400000000000000000000047211046102023000142520ustar 00000000000000use crate::error::Result; use crate::macros::err; use crate::config::global_options; /// Provides the `fallible_repeat` method on `Vec` /// /// It is intended to be used in [`try_vec!`](crate::macros::try_vec). trait VecFallibleRepeat: Sized { fn fallible_repeat(self, element: T, expected_size: usize) -> Result where T: Clone; } impl VecFallibleRepeat for Vec { fn fallible_repeat(mut self, element: T, expected_size: usize) -> Result where T: Clone, { if expected_size == 0 { return Ok(self); } if expected_size > unsafe { global_options().allocation_limit } { err!(TooMuchData); } self.try_reserve(expected_size)?; let ptr = self.as_mut_ptr(); let mut current_length = self.len(); while current_length != expected_size { unsafe { ptr.add(current_length).write(element.clone()); } current_length += 1; } unsafe { self.set_len(current_length); } Ok(self) } } /// **DO NOT USE DIRECTLY** /// /// Creates a `Vec` of the specified length, containing copies of `element`. /// /// This should be used through [`try_vec!`](crate::macros::try_vec) pub(crate) fn fallible_vec_from_element(element: T, expected_size: usize) -> Result> where T: Clone, { Vec::new().fallible_repeat(element, expected_size) } /// Provides the `try_with_capacity` method on `Vec` /// /// This can be used directly. pub(crate) trait VecFallibleCapacity: Sized { /// Same as `Vec::with_capacity`, but takes `GlobalOptions::allocation_limit` into account. /// /// Named `try_with_capacity_stable` to avoid conflicts with the nightly `Vec::try_with_capacity`. fn try_with_capacity_stable(capacity: usize) -> Result; } impl VecFallibleCapacity for Vec { fn try_with_capacity_stable(capacity: usize) -> Result { if capacity > unsafe { global_options().allocation_limit } { err!(TooMuchData); } let mut v = Vec::new(); v.try_reserve(capacity)?; Ok(v) } } #[cfg(test)] mod tests { use crate::util::alloc::fallible_vec_from_element; #[test] fn vec_fallible_repeat() { let u8_vec_len_20 = fallible_vec_from_element(0u8, 20).unwrap(); assert_eq!(u8_vec_len_20.len(), 20); assert!(u8_vec_len_20.iter().all(|e| *e == 0)); let u64_vec_len_89 = fallible_vec_from_element(0u64, 89).unwrap(); assert_eq!(u64_vec_len_89.len(), 89); assert!(u64_vec_len_89.iter().all(|e| *e == 0)); let u8_large_vec = fallible_vec_from_element(0u8, u32::MAX as usize); assert!(u8_large_vec.is_err()); } } lofty-0.21.1/src/util/io.rs000064400000000000000000000223061046102023000135660ustar 00000000000000//! Various traits for reading and writing to file-like objects use crate::error::{LoftyError, Result}; use crate::util::math::F80; use std::collections::VecDeque; use std::fs::File; use std::io::{Cursor, Read, Seek, Write}; // TODO: https://github.com/rust-lang/rust/issues/59359 pub(crate) trait SeekStreamLen: Seek { fn stream_len_hack(&mut self) -> crate::error::Result { use std::io::SeekFrom; let current_pos = self.stream_position()?; let len = self.seek(SeekFrom::End(0))?; self.seek(SeekFrom::Start(current_pos))?; Ok(len) } } impl SeekStreamLen for T where T: Seek {} /// Provides a method to truncate an object to the specified length /// /// This is one component of the [`FileLike`] trait, which is used to provide implementors access to any /// file saving methods such as [`AudioFile::save_to`](crate::file::AudioFile::save_to). /// /// Take great care in implementing this for downstream types, as Lofty will assume that the /// container has the new length specified. If this assumption were to be broken, files **will** become corrupted. /// /// # Examples /// /// ```rust /// use lofty::io::Truncate; /// /// let mut data = vec![1, 2, 3, 4, 5]; /// data.truncate(3); /// /// assert_eq!(data, vec![1, 2, 3]); /// ``` pub trait Truncate { /// The error type of the truncation operation type Error: Into; /// Truncate a storage object to the specified length /// /// # Errors /// /// Errors depend on the object being truncated, which may not always be fallible. fn truncate(&mut self, new_len: u64) -> std::result::Result<(), Self::Error>; } impl Truncate for File { type Error = std::io::Error; fn truncate(&mut self, new_len: u64) -> std::result::Result<(), Self::Error> { self.set_len(new_len) } } impl Truncate for Vec { type Error = std::convert::Infallible; fn truncate(&mut self, new_len: u64) -> std::result::Result<(), Self::Error> { self.truncate(new_len as usize); Ok(()) } } impl Truncate for VecDeque { type Error = std::convert::Infallible; fn truncate(&mut self, new_len: u64) -> std::result::Result<(), Self::Error> { self.truncate(new_len as usize); Ok(()) } } impl Truncate for Cursor where T: Truncate, { type Error = ::Error; fn truncate(&mut self, new_len: u64) -> std::result::Result<(), Self::Error> { self.get_mut().truncate(new_len) } } impl Truncate for Box where T: Truncate, { type Error = ::Error; fn truncate(&mut self, new_len: u64) -> std::result::Result<(), Self::Error> { self.as_mut().truncate(new_len) } } impl Truncate for &mut T where T: Truncate, { type Error = ::Error; fn truncate(&mut self, new_len: u64) -> std::result::Result<(), Self::Error> { (**self).truncate(new_len) } } /// Provides a method to get the length of a storage object /// /// This is one component of the [`FileLike`] trait, which is used to provide implementors access to any /// file saving methods such as [`AudioFile::save_to`](crate::file::AudioFile::save_to). /// /// Take great care in implementing this for downstream types, as Lofty will assume that the /// container has the exact length specified. If this assumption were to be broken, files **may** become corrupted. /// /// # Examples /// /// ```rust /// use lofty::io::Length; /// /// let data = vec![1, 2, 3, 4, 5]; /// assert_eq!(data.len(), 5); /// ``` pub trait Length { /// The error type of the length operation type Error: Into; /// Get the length of a storage object /// /// # Errors /// /// Errors depend on the object being read, which may not always be fallible. fn len(&self) -> std::result::Result; } impl Length for File { type Error = std::io::Error; fn len(&self) -> std::result::Result { self.metadata().map(|m| m.len()) } } impl Length for Vec { type Error = std::convert::Infallible; fn len(&self) -> std::result::Result { Ok(self.len() as u64) } } impl Length for VecDeque { type Error = std::convert::Infallible; fn len(&self) -> std::result::Result { Ok(self.len() as u64) } } impl Length for Cursor where T: Length, { type Error = ::Error; fn len(&self) -> std::result::Result { Length::len(self.get_ref()) } } impl Length for Box where T: Length, { type Error = ::Error; fn len(&self) -> std::result::Result { Length::len(self.as_ref()) } } impl Length for &T where T: Length, { type Error = ::Error; fn len(&self) -> std::result::Result { Length::len(*self) } } impl Length for &mut T where T: Length, { type Error = ::Error; fn len(&self) -> std::result::Result { Length::len(*self) } } /// Provides a set of methods to read and write to a file-like object /// /// This is a combination of the [`Read`], [`Write`], [`Seek`], [`Truncate`], and [`Length`] traits. /// It is used to provide implementors access to any file saving methods such as [`AudioFile::save_to`](crate::file::AudioFile::save_to). /// /// Take great care in implementing this for downstream types, as Lofty will assume that the /// trait implementations are correct. If this assumption were to be broken, files **may** become corrupted. pub trait FileLike: Read + Write + Seek + Truncate + Length where ::Error: Into, ::Error: Into, { } impl FileLike for T where T: Read + Write + Seek + Truncate + Length, ::Error: Into, ::Error: Into, { } pub(crate) trait ReadExt: Read { fn read_f80(&mut self) -> Result; } impl ReadExt for R where R: Read, { fn read_f80(&mut self) -> Result { let mut bytes = [0; 10]; self.read_exact(&mut bytes)?; Ok(F80::from_be_bytes(bytes)) } } #[cfg(test)] mod tests { use crate::config::{ParseOptions, WriteOptions}; use crate::file::AudioFile; use crate::mpeg::MpegFile; use crate::tag::Accessor; use std::io::{Cursor, Read, Seek, Write}; const TEST_ASSET: &str = "tests/files/assets/minimal/full_test.mp3"; fn test_asset_contents() -> Vec { std::fs::read(TEST_ASSET).unwrap() } fn file() -> MpegFile { let file_contents = test_asset_contents(); let mut reader = Cursor::new(file_contents); MpegFile::read_from(&mut reader, ParseOptions::new()).unwrap() } fn alter_tag(file: &mut MpegFile) { let tag = file.id3v2_mut().unwrap(); tag.set_artist(String::from("Bar artist")); } fn revert_tag(file: &mut MpegFile) { let tag = file.id3v2_mut().unwrap(); tag.set_artist(String::from("Foo artist")); } #[test] fn io_save_to_file() { // Read the file and change the artist let mut file = file(); alter_tag(&mut file); let mut temp_file = tempfile::tempfile().unwrap(); let file_content = std::fs::read(TEST_ASSET).unwrap(); temp_file.write_all(&file_content).unwrap(); temp_file.rewind().unwrap(); // Save the new artist file.save_to(&mut temp_file, WriteOptions::new().preferred_padding(0)) .expect("Failed to save to file"); // Read the file again and change the artist back temp_file.rewind().unwrap(); let mut file = MpegFile::read_from(&mut temp_file, ParseOptions::new()).unwrap(); revert_tag(&mut file); temp_file.rewind().unwrap(); file.save_to(&mut temp_file, WriteOptions::new().preferred_padding(0)) .expect("Failed to save to file"); // The contents should be the same as the original file temp_file.rewind().unwrap(); let mut current_file_contents = Vec::new(); temp_file.read_to_end(&mut current_file_contents).unwrap(); assert_eq!(current_file_contents, test_asset_contents()); } #[test] fn io_save_to_vec() { // Same test as above, but using a Cursor> instead of a file let mut file = file(); alter_tag(&mut file); let file_content = std::fs::read(TEST_ASSET).unwrap(); let mut reader = Cursor::new(file_content); file.save_to(&mut reader, WriteOptions::new().preferred_padding(0)) .expect("Failed to save to vec"); reader.rewind().unwrap(); let mut file = MpegFile::read_from(&mut reader, ParseOptions::new()).unwrap(); revert_tag(&mut file); reader.rewind().unwrap(); file.save_to(&mut reader, WriteOptions::new().preferred_padding(0)) .expect("Failed to save to vec"); let current_file_contents = reader.into_inner(); assert_eq!(current_file_contents, test_asset_contents()); } #[test] fn io_save_using_references() { struct File { buf: Vec, } let mut f = File { buf: std::fs::read(TEST_ASSET).unwrap(), }; // Same test as above, but using references instead of owned values let mut file = file(); alter_tag(&mut file); { let mut reader = Cursor::new(&mut f.buf); file.save_to(&mut reader, WriteOptions::new().preferred_padding(0)) .expect("Failed to save to vec"); } { let mut reader = Cursor::new(&f.buf[..]); file = MpegFile::read_from(&mut reader, ParseOptions::new()).unwrap(); revert_tag(&mut file); } { let mut reader = Cursor::new(&mut f.buf); file.save_to(&mut reader, WriteOptions::new().preferred_padding(0)) .expect("Failed to save to vec"); } let current_file_contents = f.buf; assert_eq!(current_file_contents, test_asset_contents()); } } lofty-0.21.1/src/util/math.rs000064400000000000000000000117241046102023000141120ustar 00000000000000/// Perform a rounded division. /// /// This is implemented for all unsigned integers. /// /// NOTE: If the result is less than 1, it will be rounded up to 1. pub(crate) trait RoundedDivision { type Output; fn div_round(self, rhs: Rhs) -> Self::Output; } macro_rules! unsigned_rounded_division { ($($t:ty),*) => { $( impl RoundedDivision for $t { type Output = $t; fn div_round(self, rhs: Self) -> Self::Output { (self + (rhs >> 1)) / rhs } } )* }; } unsigned_rounded_division!(u8, u16, u32, u64, u128, usize); /// An 80-bit extended precision floating-point number. /// /// This is used in AIFF. #[derive(Debug, Eq, PartialEq, Copy, Clone)] pub(crate) struct F80 { signed: bool, // 15-bit exponent with a bias of 16383 exponent: u16, fraction: u64, } impl F80 { /// Create a new `F80` from big-endian bytes. /// /// See [here](https://en.wikipedia.org/wiki/Extended_precision#/media/File:X86_Extended_Floating_Point_Format.svg) for a diagram of the format. pub fn from_be_bytes(bytes: [u8; 10]) -> Self { let signed = bytes[0] & 0x80 != 0; let exponent = (u16::from(bytes[0] & 0x7F) << 8) | u16::from(bytes[1]); let mut fraction_bytes = [0; 8]; fraction_bytes.copy_from_slice(&bytes[2..]); let fraction = u64::from_be_bytes(fraction_bytes); Self { signed, exponent, fraction, } } /// Convert the `F80` to an `f64`. pub fn as_f64(&self) -> f64 { // Apple® Apple Numerics Manual, Second Edition, Table 2-7: // // Biased exponent e Integer i Fraction f Value v Class of v // 0 <= e <= 32766 1 (any) v = (-1)^s * 2^(e-16383) * (1.f) Normalized // 0 <= e <= 32766 0 f != 0 v = (-1)^s * 2^(e-16383) * (0.f) Denormalized // 0 <= e <= 32766 0 f = 0 v = (-1)^s * 0 Zero // e = 32767 (any) f = 0 v = (-1)^s * Infinity Infinity // e = 32767 (any) f != 0 v is a NaN NaN let sign = if self.signed { 1 } else { 0 }; // e = 32767 if self.exponent == 32767 { if self.fraction == 0 { return f64::from_bits((sign << 63) | f64::INFINITY.to_bits()); } return f64::from_bits((sign << 63) | f64::NAN.to_bits()); } // 0 <= e <= 32766, i = 0, f = 0 if self.fraction == 0 { return f64::from_bits(sign << 63); } // 0 <= e <= 32766, 0 <= i <= 1, f >= 0 let fraction = self.fraction & 0x7FFF_FFFF_FFFF_FFFF; let exponent = self.exponent as i16 - 16383 + 1023; let bits = (sign << 63) | ((exponent as u64) << 52) | (fraction >> 11); f64::from_bits(bits) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_div_round() { #[derive(Debug)] struct TestEntry { lhs: u32, rhs: u32, result: u32, } #[rustfmt::skip] let tests = [ TestEntry { lhs: 1, rhs: 1, result: 1 }, TestEntry { lhs: 1, rhs: 2, result: 1 }, TestEntry { lhs: 2, rhs: 2, result: 1 }, TestEntry { lhs: 3, rhs: 2, result: 2 }, TestEntry { lhs: 4, rhs: 2, result: 2 }, TestEntry { lhs: 5, rhs: 2, result: 3 }, // Should be rounded up to 1 TestEntry { lhs: 800, rhs: 1500, result: 1 }, TestEntry { lhs: 1500, rhs: 3000, result: 1 }, // Shouldn't be rounded TestEntry { lhs: 0, rhs: 4000, result: 0 }, TestEntry { lhs: 1500, rhs: 4000, result: 0 }, ]; for test in &tests { let result = test.lhs.div_round(test.rhs); assert_eq!(result, test.result, "{}.div_round({})", test.lhs, test.rhs); } } #[test] fn test_f80() { fn cmp_float_nearly_equal(a: f64, b: f64) -> bool { if a.is_infinite() && b.is_infinite() { return true; } if a.is_nan() && b.is_nan() { return true; } (a - b).abs() < f64::EPSILON } #[derive(Debug)] struct TestEntry { input: [u8; 10], output_f64: f64, } let tests = [ TestEntry { input: [0; 10], output_f64: 0.0, }, TestEntry { input: [0x7F, 0xFF, 0, 0, 0, 0, 0, 0, 0, 0], output_f64: f64::INFINITY, }, TestEntry { input: [0xFF, 0xFF, 0, 0, 0, 0, 0, 0, 0, 0], output_f64: f64::NEG_INFINITY, }, TestEntry { input: [0x7F, 0xFF, 0x80, 0, 0, 0, 0, 0, 0, 0], output_f64: f64::NAN, }, TestEntry { input: [0xFF, 0xFF, 0x80, 0, 0, 0, 0, 0, 0, 0], output_f64: -f64::NAN, }, TestEntry { input: [0x3F, 0xFC, 0x80, 0, 0, 0, 0, 0, 0, 0], output_f64: 0.125, }, TestEntry { input: [0x3F, 0xFF, 0x80, 0, 0, 0, 0, 0, 0, 0], output_f64: 1.0, }, TestEntry { input: [0x40, 0x00, 0x80, 0, 0, 0, 0, 0, 0, 0], output_f64: 2.0, }, TestEntry { input: [0x40, 0x00, 0xC0, 0, 0, 0, 0, 0, 0, 0], output_f64: 3.0, }, TestEntry { input: [0x40, 0x0E, 0xBB, 0x80, 0, 0, 0, 0, 0, 0], output_f64: 48000.0, }, ]; for test in &tests { let f80 = F80::from_be_bytes(test.input); let f64 = f80.as_f64(); assert!( cmp_float_nearly_equal(f64, test.output_f64), "F80::as_f64({f80:?}) == {f64} (expected {})", test.output_f64 ); } } } lofty-0.21.1/src/util/mod.rs000064400000000000000000000003411046102023000137310ustar 00000000000000pub(crate) mod alloc; pub mod io; pub(crate) mod math; pub(crate) mod text; pub(crate) fn flag_item(item: &str) -> Option { match item { "1" | "true" => Some(true), "0" | "false" => Some(false), _ => None, } } lofty-0.21.1/src/util/text.rs000064400000000000000000000232411046102023000141420ustar 00000000000000use crate::error::{ErrorKind, LoftyError, Result}; use crate::macros::err; use std::io::Read; use byteorder::ReadBytesExt; /// The text encoding for use in ID3v2 frames #[derive(Debug, Clone, Eq, PartialEq, Copy, Hash)] #[repr(u8)] pub enum TextEncoding { /// ISO-8859-1 Latin1 = 0, /// UTF-16 with a byte order mark UTF16 = 1, /// UTF-16 big endian UTF16BE = 2, /// UTF-8 UTF8 = 3, } impl TextEncoding { /// Get a `TextEncoding` from a u8, must be 0-3 inclusive pub fn from_u8(byte: u8) -> Option { match byte { 0 => Some(Self::Latin1), 1 => Some(Self::UTF16), 2 => Some(Self::UTF16BE), 3 => Some(Self::UTF8), _ => None, } } pub(crate) fn verify_latin1(text: &str) -> bool { text.chars().all(|c| c as u32 <= 255) } /// ID3v2.4 introduced two new text encodings. /// /// When writing ID3v2.3, we just substitute with UTF-16. pub(crate) fn to_id3v23(self) -> Self { match self { Self::UTF8 | Self::UTF16BE => { log::warn!( "Text encoding {:?} is not supported in ID3v2.3, substituting with UTF-16", self ); Self::UTF16 }, _ => self, } } } #[derive(Eq, PartialEq, Debug)] pub(crate) struct DecodeTextResult { pub(crate) content: String, pub(crate) bytes_read: usize, pub(crate) bom: [u8; 2], } impl DecodeTextResult { pub(crate) fn text_or_none(self) -> Option { if self.content.is_empty() { return None; } Some(self.content) } } const EMPTY_DECODED_TEXT: DecodeTextResult = DecodeTextResult { content: String::new(), bytes_read: 0, bom: [0, 0], }; /// Specify how to decode the provided text /// /// By default, this will: /// /// * Use [`TextEncoding::UTF8`] as the encoding /// * Not expect the text to be null terminated /// * Have no byte order mark #[derive(Copy, Clone, Debug)] pub(crate) struct TextDecodeOptions { pub encoding: TextEncoding, pub terminated: bool, pub bom: [u8; 2], } impl TextDecodeOptions { pub(crate) fn new() -> Self { Self::default() } pub(crate) fn encoding(mut self, encoding: TextEncoding) -> Self { self.encoding = encoding; self } pub(crate) fn terminated(mut self, terminated: bool) -> Self { self.terminated = terminated; self } pub(crate) fn bom(mut self, bom: [u8; 2]) -> Self { self.bom = bom; self } } impl Default for TextDecodeOptions { fn default() -> Self { Self { encoding: TextEncoding::UTF8, terminated: false, bom: [0, 0], } } } pub(crate) fn decode_text(reader: &mut R, options: TextDecodeOptions) -> Result where R: Read, { let raw_bytes; let bytes_read; if options.terminated { let (bytes, terminator_len) = read_to_terminator(reader, options.encoding); if bytes.is_empty() { return Ok(EMPTY_DECODED_TEXT); } bytes_read = bytes.len() + terminator_len; raw_bytes = bytes; } else { let mut bytes = Vec::new(); reader.read_to_end(&mut bytes)?; if bytes.is_empty() { return Ok(EMPTY_DECODED_TEXT); } bytes_read = bytes.len(); raw_bytes = bytes; } let mut bom = [0, 0]; let read_string = match options.encoding { TextEncoding::Latin1 => latin1_decode(&raw_bytes), TextEncoding::UTF16 => { if raw_bytes.len() < 2 { err!(TextDecode("UTF-16 string has an invalid length (< 2)")); } if raw_bytes.len() % 2 != 0 { err!(TextDecode("UTF-16 string has an odd length")); } let bom_to_check; if options.bom == [0, 0] { bom_to_check = [raw_bytes[0], raw_bytes[1]]; } else { bom_to_check = options.bom; } match bom_to_check { [0xFE, 0xFF] => { bom = [0xFE, 0xFF]; utf16_decode_bytes(&raw_bytes[2..], u16::from_be_bytes)? }, [0xFF, 0xFE] => { bom = [0xFF, 0xFE]; utf16_decode_bytes(&raw_bytes[2..], u16::from_le_bytes)? }, _ => err!(TextDecode("UTF-16 string has an invalid byte order mark")), } }, TextEncoding::UTF16BE => utf16_decode_bytes(raw_bytes.as_slice(), u16::from_be_bytes)?, TextEncoding::UTF8 => utf8_decode(raw_bytes) .map_err(|_| LoftyError::new(ErrorKind::TextDecode("Expected a UTF-8 string")))?, }; if read_string.is_empty() { return Ok(EMPTY_DECODED_TEXT); } Ok(DecodeTextResult { content: read_string, bytes_read, bom, }) } pub(crate) fn read_to_terminator(reader: &mut R, encoding: TextEncoding) -> (Vec, usize) where R: Read, { let mut text_bytes = Vec::new(); let mut terminator_len = 0; match encoding { TextEncoding::Latin1 | TextEncoding::UTF8 => { while let Ok(byte) = reader.read_u8() { if byte == 0 { terminator_len = 1; break; } text_bytes.push(byte) } }, TextEncoding::UTF16 | TextEncoding::UTF16BE => { while let (Ok(b1), Ok(b2)) = (reader.read_u8(), reader.read_u8()) { if b1 == 0 && b2 == 0 { terminator_len = 2; break; } text_bytes.push(b1); text_bytes.push(b2) } }, } (text_bytes, terminator_len) } pub(crate) fn latin1_decode(bytes: &[u8]) -> String { let mut text = bytes.iter().map(|c| *c as char).collect::(); trim_end_nulls(&mut text); text } pub(crate) fn utf8_decode(bytes: Vec) -> Result { String::from_utf8(bytes) .map(|mut text| { trim_end_nulls(&mut text); text }) .map_err(Into::into) } pub(crate) fn utf8_decode_str(bytes: &[u8]) -> Result<&str> { std::str::from_utf8(bytes) .map(trim_end_nulls_str) .map_err(Into::into) } pub(crate) fn utf16_decode(words: &[u16]) -> Result { String::from_utf16(words) .map(|mut text| { trim_end_nulls(&mut text); text }) .map_err(|_| LoftyError::new(ErrorKind::TextDecode("Given an invalid UTF-16 string"))) } pub(crate) fn utf16_decode_bytes(bytes: &[u8], endianness: fn([u8; 2]) -> u16) -> Result { if bytes.is_empty() { return Ok(String::new()); } let unverified: Vec = bytes .chunks_exact(2) // In ID3v2, it is possible to have multiple UTF-16 strings separated by null. // This also makes it possible for us to encounter multiple BOMs in a single string. // We must filter them out. .filter_map(|c| match c { [0xFF, 0xFE] | [0xFE, 0xFF] => None, _ => Some(endianness(c.try_into().unwrap())), // Infallible }) .collect(); utf16_decode(&unverified) } pub(crate) fn encode_text(text: &str, text_encoding: TextEncoding, terminated: bool) -> Vec { match text_encoding { TextEncoding::Latin1 => { let mut out = text.chars().map(|c| c as u8).collect::>(); if terminated { out.push(0) } out }, TextEncoding::UTF16 => utf16_encode(text, u16::to_ne_bytes, true, terminated), TextEncoding::UTF16BE => utf16_encode(text, u16::to_be_bytes, false, terminated), TextEncoding::UTF8 => { let mut out = text.as_bytes().to_vec(); if terminated { out.push(0); } out }, } } pub(crate) fn trim_end_nulls(text: &mut String) { if text.ends_with('\0') { let new_len = text.trim_end_matches('\0').len(); text.truncate(new_len); } } pub(crate) fn trim_end_nulls_str(text: &str) -> &str { text.trim_end_matches('\0') } fn utf16_encode( text: &str, endianness: fn(u16) -> [u8; 2], bom: bool, terminated: bool, ) -> Vec { let mut encoded = Vec::::new(); if bom { encoded.extend_from_slice(&endianness(0xFEFF_u16)); } for ch in text.encode_utf16() { encoded.extend_from_slice(&endianness(ch)); } if terminated { encoded.extend_from_slice(&[0, 0]); } encoded } #[cfg(test)] mod tests { use crate::util::text::{TextDecodeOptions, TextEncoding}; use std::io::Cursor; const TEST_STRING: &str = "l\u{00f8}ft\u{00a5}"; #[test] fn text_decode() { // No BOM let utf16_decode = super::utf16_decode_bytes( &[ 0x00, 0x6C, 0x00, 0xF8, 0x00, 0x66, 0x00, 0x74, 0x00, 0xA5, 0x00, 0x00, ], u16::from_be_bytes, ) .unwrap(); assert_eq!(utf16_decode, TEST_STRING.to_string()); // BOM test let be_utf16_decode = super::decode_text( &mut Cursor::new(&[ 0xFE, 0xFF, 0x00, 0x6C, 0x00, 0xF8, 0x00, 0x66, 0x00, 0x74, 0x00, 0xA5, 0x00, 0x00, ]), TextDecodeOptions::new().encoding(TextEncoding::UTF16), ) .unwrap(); let le_utf16_decode = super::decode_text( &mut Cursor::new(&[ 0xFF, 0xFE, 0x6C, 0x00, 0xF8, 0x00, 0x66, 0x00, 0x74, 0x00, 0xA5, 0x00, 0x00, 0x00, ]), TextDecodeOptions::new().encoding(TextEncoding::UTF16), ) .unwrap(); assert_eq!(be_utf16_decode.content, le_utf16_decode.content); assert_eq!(be_utf16_decode.bytes_read, le_utf16_decode.bytes_read); assert_eq!(be_utf16_decode.content, TEST_STRING.to_string()); let utf8_decode = super::decode_text( &mut TEST_STRING.as_bytes(), TextDecodeOptions::new().encoding(TextEncoding::UTF8), ) .unwrap(); assert_eq!(utf8_decode.content, TEST_STRING.to_string()); } #[test] fn text_encode() { // No BOM let utf16_encode = super::utf16_encode(TEST_STRING, u16::to_be_bytes, true, false); assert_eq!( utf16_encode.as_slice(), &[0xFE, 0xFF, 0x00, 0x6C, 0x00, 0xF8, 0x00, 0x66, 0x00, 0x74, 0x00, 0xA5] ); // BOM test let be_utf16_encode = super::encode_text(TEST_STRING, TextEncoding::UTF16BE, false); let le_utf16_encode = super::utf16_encode(TEST_STRING, u16::to_le_bytes, true, false); let be_utf16_encode_bom = super::utf16_encode(TEST_STRING, u16::to_be_bytes, true, false); assert_ne!(be_utf16_encode.as_slice(), le_utf16_encode.as_slice()); // TextEncoding::UTF16BE has no BOM assert_eq!( be_utf16_encode.as_slice(), &[0x00, 0x6C, 0x00, 0xF8, 0x00, 0x66, 0x00, 0x74, 0x00, 0xA5] ); assert_eq!( le_utf16_encode.as_slice(), &[0xFF, 0xFE, 0x6C, 0x00, 0xF8, 0x00, 0x66, 0x00, 0x74, 0x00, 0xA5, 0x00] ); assert_eq!( be_utf16_encode_bom.as_slice(), &[0xFE, 0xFF, 0x00, 0x6C, 0x00, 0xF8, 0x00, 0x66, 0x00, 0x74, 0x00, 0xA5] ); let utf8_encode = super::encode_text(TEST_STRING, TextEncoding::UTF8, false); assert_eq!(utf8_encode.as_slice(), TEST_STRING.as_bytes()); } } lofty-0.21.1/src/wavpack/mod.rs000064400000000000000000000011541046102023000144130ustar 00000000000000//! WavPack specific items mod properties; mod read; use crate::ape::tag::ApeTag; use crate::id3::v1::tag::Id3v1Tag; use lofty_attr::LoftyFile; // Exports pub use properties::WavPackProperties; /// A WavPack file #[derive(LoftyFile, Default)] #[lofty(read_fn = "read::read_from")] #[lofty(internal_write_module_do_not_use_anywhere_else)] pub struct WavPackFile { /// An ID3v1 tag #[lofty(tag_type = "Id3v1")] pub(crate) id3v1_tag: Option, /// An APEv1/v2 tag #[lofty(tag_type = "Ape")] pub(crate) ape_tag: Option, /// The file's audio properties pub(crate) properties: WavPackProperties, } lofty-0.21.1/src/wavpack/properties.rs000064400000000000000000000253221046102023000160330ustar 00000000000000use crate::config::ParsingMode; use crate::error::Result; use crate::macros::{decode_err, err, parse_mode_choice, try_vec}; use crate::properties::{ChannelMask, FileProperties}; use std::io::{Read, Seek, SeekFrom}; use std::time::Duration; use byteorder::{LittleEndian, ReadBytesExt}; /// A WavPack file's audio properties #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] #[non_exhaustive] pub struct WavPackProperties { pub(crate) version: u16, pub(crate) duration: Duration, pub(crate) overall_bitrate: u32, pub(crate) audio_bitrate: u32, pub(crate) sample_rate: u32, pub(crate) channels: u16, pub(crate) channel_mask: ChannelMask, pub(crate) bit_depth: u8, pub(crate) lossless: bool, } impl From for FileProperties { fn from(input: WavPackProperties) -> Self { Self { duration: input.duration, overall_bitrate: Some(input.overall_bitrate), audio_bitrate: Some(input.audio_bitrate), sample_rate: Some(input.sample_rate), bit_depth: Some(input.bit_depth), channels: Some(input.channels as u8), channel_mask: if input.channel_mask == ChannelMask(0) { None } else { Some(input.channel_mask) }, } } } impl WavPackProperties { /// Duration of the audio pub fn duration(&self) -> Duration { self.duration } /// Overall bitrate (kbps) pub fn overall_bitrate(&self) -> u32 { self.overall_bitrate } /// Audio bitrate (kbps) pub fn audio_bitrate(&self) -> u32 { self.audio_bitrate } /// Sample rate (Hz) pub fn sample_rate(&self) -> u32 { self.sample_rate } /// Channel count /// /// This is a `u16` since WavPack supports "unlimited" streams pub fn channels(&self) -> u16 { self.channels } /// Channel mask pub fn channel_mask(&self) -> ChannelMask { self.channel_mask } /// WavPack version pub fn version(&self) -> u16 { self.version } /// Bits per sample pub fn bit_depth(&self) -> u8 { self.bit_depth } /// Whether the audio is lossless pub fn is_lossless(&self) -> bool { self.lossless } } // Thanks MultimediaWiki :) // https://wiki.multimedia.cx/index.php?title=WavPack#Block_structure const BYTES_PER_SAMPLE_MASK: u32 = 3; const BIT_DEPTH_SHL: u32 = 13; const BIT_DEPTH_SHIFT_MASK: u32 = 0x1F << BIT_DEPTH_SHL; const FLAG_INITIAL_BLOCK: u32 = 0x800; const FLAG_FINAL_BLOCK: u32 = 0x1000; const FLAG_MONO: u32 = 0x0004; const FLAG_DSD: u32 = 0x8000_0000; const FLAG_HYBRID_COMPRESSION: u32 = 8; // Hybrid profile (lossy compression) // https://wiki.multimedia.cx/index.php?title=WavPack#Metadata const ID_FLAG_ODD_SIZE: u8 = 0x40; const ID_FLAG_LARGE_SIZE: u8 = 0x80; const ID_MULTICHANNEL: u8 = 0x0D; const ID_NON_STANDARD_SAMPLE_RATE: u8 = 0x27; const ID_DSD: u8 = 0xE; const MIN_STREAM_VERSION: u16 = 0x402; const MAX_STREAM_VERSION: u16 = 0x410; const SAMPLE_RATES: [u32; 16] = [ 6000, 8000, 9600, 11025, 12000, 16000, 22050, 24000, 32000, 44100, 48000, 64000, 88200, 96000, 192_000, 0, ]; #[rustfmt::skip] pub(super) fn read_properties(reader: &mut R, stream_length: u64, parse_mode: ParsingMode) -> Result where R: Read + Seek, { let mut properties = WavPackProperties::default(); let mut offset = 0; let mut total_samples = 0; loop { reader.seek(SeekFrom::Start(offset))?; let block_header; match parse_wv_header(reader) { Ok(header) => block_header = header, Err(e) if parse_mode == ParsingMode::Strict => return Err(e), _ => break, } let flags = block_header.flags; let sample_rate_idx = ((flags >> 23) & 0xF) as usize; properties.sample_rate = SAMPLE_RATES[sample_rate_idx]; // In the case of non-standard sample rates and DSD audio, we need to actually read the // block to get the sample rate if sample_rate_idx == 15 || flags & FLAG_DSD == FLAG_DSD { let mut block_contents = try_vec![0; (block_header.block_size - 24) as usize]; if reader.read_exact(&mut block_contents).is_err() { parse_mode_choice!( parse_mode, STRICT: decode_err!(@BAIL WavPack, "Block size mismatch"), DEFAULT: break ); } if let Err(e) = get_extended_meta_info(parse_mode, &block_contents, &mut properties) { parse_mode_choice!( parse_mode, STRICT: return Err(e), DEFAULT: break ); } // A sample rate index of 15 indicates a custom sample rate, which should have been found // when we just parsed the metadata blocks if sample_rate_idx == 15 && properties.sample_rate == 0 { parse_mode_choice!( parse_mode, STRICT: decode_err!(@BAIL WavPack, "Expected custom sample rate"), DEFAULT: break ) } } if flags & FLAG_INITIAL_BLOCK == FLAG_INITIAL_BLOCK { if block_header.version < MIN_STREAM_VERSION || block_header.version > MAX_STREAM_VERSION { parse_mode_choice!( parse_mode, STRICT: decode_err!(@BAIL WavPack, "Unsupported stream version encountered"), DEFAULT: break ); } total_samples = block_header.total_samples; properties.bit_depth = ((((flags & BYTES_PER_SAMPLE_MASK) + 1) * 8) - ((flags & BIT_DEPTH_SHIFT_MASK) >> BIT_DEPTH_SHL)) as u8; properties.version = block_header.version; properties.lossless = flags & FLAG_HYBRID_COMPRESSION == 0; // https://web.archive.org/web/20150424062034/https://www.wavpack.com/file_format.txt: // // A flag in the header indicates whether the block is the first or the last in the // sequence (for simple mono or stereo files both of these would always be set). // // We already checked if `FLAG_INITIAL_BLOCK` is set if flags & FLAG_FINAL_BLOCK > 0 { let is_mono = flags & FLAG_MONO > 0; properties.channels = if is_mono { 1 } else { 2 }; properties.channel_mask = if is_mono { ChannelMask::mono() } else { ChannelMask::stereo() }; } } // Just skip any block with no samples if block_header.samples == 0 { offset += u64::from(block_header.block_size + 8); continue; } if flags & FLAG_FINAL_BLOCK == FLAG_FINAL_BLOCK { break; } offset += u64::from(block_header.block_size + 8); } // TODO: Support unknown sample counts in WavPack if total_samples == !0 { log::warn!("Unable to calculate duration, unknown sample counts are not yet supported"); return Ok(properties); } if total_samples == 0 || properties.sample_rate == 0 { if parse_mode == ParsingMode::Strict { decode_err!(@BAIL WavPack, "Unable to calculate duration (sample count == 0 || sample rate == 0)") } // We aren't able to determine the duration/bitrate, just early return return Ok(properties); } let length = f64::from(total_samples) * 1000. / f64::from(properties.sample_rate); properties.duration = Duration::from_millis((length + 0.5) as u64); properties.audio_bitrate = (stream_length as f64 * 8. / length + 0.5) as u32; let file_length = reader.seek(SeekFrom::End(0))?; properties.overall_bitrate = (file_length as f64 * 8. / length + 0.5) as u32; Ok(properties) } // According to the spec, the max block size is 1MB const WV_BLOCK_MAX_SIZE: u32 = 1_048_576; #[derive(Debug)] struct WVHeader { version: u16, block_size: u32, total_samples: u32, samples: u32, flags: u32, } // NOTE: Any error here is ignored unless using `ParsingMode::Strict` fn parse_wv_header(reader: &mut R) -> Result where R: Read + Seek, { let mut wv_ident = [0; 4]; reader.read_exact(&mut wv_ident)?; if &wv_ident != b"wvpk" { err!(UnknownFormat); } let block_size = reader.read_u32::()?; if !(24..=WV_BLOCK_MAX_SIZE).contains(&block_size) { decode_err!(@BAIL WavPack, "WavPack block has an invalid size"); } let version = reader.read_u16::()?; // Skip 2 bytes // // Track number (1) // Track sub index (1) reader.seek(SeekFrom::Current(2))?; let total_samples = reader.read_u32::()?; let _block_idx = reader.seek(SeekFrom::Current(4))?; let samples = reader.read_u32::()?; let flags = reader.read_u32::()?; let _crc = reader.seek(SeekFrom::Current(4))?; Ok(WVHeader { version, block_size, total_samples, samples, flags, }) } fn get_extended_meta_info( parse_mode: ParsingMode, block_content: &[u8], properties: &mut WavPackProperties, ) -> Result<()> { let mut index = 0; let block_size = block_content.len(); while index < block_size { let id = block_content[index]; index += 1; let mut size = u32::from(block_content[index]) << 1; index += 1; let is_large = id & ID_FLAG_LARGE_SIZE > 0; if is_large { size += u32::from(block_content[index]) << 9; size += u32::from(block_content[index + 1]) << 17; index += 2; } if id & ID_FLAG_ODD_SIZE > 0 { size -= 1; } match id & 0x3F { ID_NON_STANDARD_SAMPLE_RATE => { properties.sample_rate = (&mut &block_content[index..]).read_u24::()?; }, ID_DSD => { if size <= 1 { decode_err!(@BAIL WavPack, "Encountered an invalid DSD block size"); } let mut rate_multiplier = u32::from(block_content[index]); index += 1; if rate_multiplier > 30 { parse_mode_choice!( parse_mode, STRICT: decode_err!(@BAIL WavPack, "Encountered an invalid sample rate multiplier"), DEFAULT: break ) } rate_multiplier = 1 << rate_multiplier; properties.sample_rate = properties.sample_rate.wrapping_mul(rate_multiplier); // Skip DSD mode index += 1; }, ID_MULTICHANNEL => { if size <= 1 { decode_err!(@BAIL WavPack, "Unable to extract channel information"); } properties.channels = u16::from(block_content[index]); index += 1; let reader = &mut &block_content[index..]; // size - (id length + channel length) let s = size - 2; match s { 0 => { let channel_mask = reader.read_u8()?; properties.channel_mask = ChannelMask(u32::from(channel_mask)); }, 1 => { let channel_mask = reader.read_u16::()?; properties.channel_mask = ChannelMask(u32::from(channel_mask)); }, 2 => { let channel_mask = reader.read_u24::()?; properties.channel_mask = ChannelMask(channel_mask); }, 3 => { let channel_mask = reader.read_u32::()?; properties.channel_mask = ChannelMask(channel_mask); }, 4 => { properties.channels |= u16::from(reader.read_u8()? & 0xF) << 8; properties.channels += 1; let channel_mask = reader.read_u24::()?; properties.channel_mask = ChannelMask(channel_mask); }, 5 => { properties.channels |= u16::from(reader.read_u8()? & 0xF) << 8; properties.channels += 1; let channel_mask = reader.read_u32::()?; properties.channel_mask = ChannelMask(channel_mask); }, _ => decode_err!(@BAIL WavPack, "Encountered invalid channel info size"), } }, _ => { index += size as usize; }, } if id & ID_FLAG_ODD_SIZE > 0 { index += 1; } } Ok(()) } lofty-0.21.1/src/wavpack/read.rs000064400000000000000000000030421046102023000145450ustar 00000000000000use super::properties::WavPackProperties; use super::WavPackFile; use crate::config::ParseOptions; use crate::error::Result; use crate::id3::{find_id3v1, find_lyrics3v2, ID3FindResults}; use std::io::{Read, Seek, SeekFrom}; pub(super) fn read_from(reader: &mut R, parse_options: ParseOptions) -> Result where R: Read + Seek, { let current_pos = reader.stream_position()?; let mut stream_length = reader.seek(SeekFrom::End(0))?; reader.seek(SeekFrom::Start(current_pos))?; let mut id3v1_tag = None; let mut ape_tag = None; let ID3FindResults(id3v1_header, id3v1) = find_id3v1(reader, parse_options.read_tags)?; if id3v1_header.is_some() { stream_length -= 128; id3v1_tag = id3v1; } // Next, check for a Lyrics3v2 tag, and skip over it, as it's no use to us let ID3FindResults(lyrics3_header, lyrics3v2_size) = find_lyrics3v2(reader)?; if lyrics3_header.is_some() { stream_length -= u64::from(lyrics3v2_size); } // Next, search for an APE tag footer // // Starts with ['A', 'P', 'E', 'T', 'A', 'G', 'E', 'X'] // Exactly 32 bytes long // Strongly recommended to be at the end of the file reader.seek(SeekFrom::Current(-32))?; if let (tag, Some(header)) = crate::ape::tag::read::read_ape_tag(reader, true, parse_options)? { stream_length -= u64::from(header.size); ape_tag = tag; } Ok(WavPackFile { id3v1_tag, ape_tag, properties: if parse_options.read_properties { super::properties::read_properties(reader, stream_length, parse_options.parsing_mode)? } else { WavPackProperties::default() }, }) }