From f4a07337f45f139fc7390041623d9b910d8489aa Mon Sep 17 00:00:00 2001 From: clement Date: Mon, 7 Sep 2026 20:53:10 -0300 Subject: [PATCH 1/2] fix: separate Vorbis album artist metadata --- lib/src/metadata/vorbis_metadata.dart | 3 +++ lib/src/parser.dart | 4 ++++ lib/src/parsers/tags/tag_parser.dart | 8 ++++++++ lib/src/parsers/tags/vorbis_comment.dart | 7 ++++++- lib/src/writers/flac_writer.dart | 1 + test/flac/album_artist.flac | Bin 0 -> 8855 bytes test/flac/flac_parser_test.dart | 13 +++++++++++++ test/flac/vorbis_comment_parser_test.dart | 22 ++++++++++++++++++++++ test/writers/flac_writer_test.dart | 19 +++++++++++++++++++ 9 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 test/flac/album_artist.flac diff --git a/lib/src/metadata/vorbis_metadata.dart b/lib/src/metadata/vorbis_metadata.dart index 52ff3bb..6a3efda 100644 --- a/lib/src/metadata/vorbis_metadata.dart +++ b/lib/src/metadata/vorbis_metadata.dart @@ -14,6 +14,8 @@ class VorbisMetadata extends ParserTag { List trackNumber = []; // TRACKNUMBER /// Main artist values (`ARTIST`). List artist = []; // ARTIST + /// Album-level artist values (`ALBUMARTIST`/`ALBUM_ARTIST`). + List albumArtist = []; // ALBUMARTIST / ALBUM_ARTIST /// Additional performers (`PERFORMER`). List performer = []; // PERFORMER /// Copyright statements (`COPYRIGHT`). @@ -105,6 +107,7 @@ class VorbisMetadata extends ParserTag { ' album: $album,\n' ' trackNumber: $trackNumber,\n' ' artist: $artist,\n' + ' albumArtist: $albumArtist,\n' ' performer: $performer,\n' ' copyright: $copyright,\n' ' license: $license,\n' diff --git a/lib/src/parser.dart b/lib/src/parser.dart index c4469f6..9dc547e 100644 --- a/lib/src/parser.dart +++ b/lib/src/parser.dart @@ -26,6 +26,7 @@ AudioMetadata readMetadata(File track, {bool getImage = false}) { final newMetadata = AudioMetadata( file: track, album: apeMetadata.album, + albumArtist: apeMetadata.albumArtist, artist: apeMetadata.artist, bitrate: apeMetadata.bitrate, discNumber: apeMetadata.discNumber, @@ -84,6 +85,7 @@ AudioMetadata readMetadata(File track, {bool getImage = false}) { final newMetadata = AudioMetadata( file: track, album: vorbisMetadata.album.firstOrNull, + albumArtist: vorbisMetadata.albumArtist.firstOrNull, artist: vorbisMetadata.artist.firstOrNull, bitrate: vorbisMetadata.bitrate, discNumber: vorbisMetadata.discNumber, @@ -109,6 +111,7 @@ AudioMetadata readMetadata(File track, {bool getImage = false}) { final newMetadata = AudioMetadata( file: track, album: vorbisMetadata.album.firstOrNull, + albumArtist: vorbisMetadata.albumArtist.firstOrNull, artist: vorbisMetadata.artist.firstOrNull, bitrate: vorbisMetadata.bitrate, discNumber: vorbisMetadata.discNumber, @@ -164,6 +167,7 @@ AudioMetadata readMetadata(File track, {bool getImage = false}) { final newMetadata = AudioMetadata( file: track, album: oggMetadata.album.firstOrNull, + albumArtist: oggMetadata.albumArtist.firstOrNull, artist: oggMetadata.artist.firstOrNull, bitrate: oggMetadata.bitrate, discNumber: oggMetadata.discNumber, diff --git a/lib/src/parsers/tags/tag_parser.dart b/lib/src/parsers/tags/tag_parser.dart index 9ba839b..b11581f 100644 --- a/lib/src/parsers/tags/tag_parser.dart +++ b/lib/src/parsers/tags/tag_parser.dart @@ -36,6 +36,12 @@ class AudioMetadata { /// The name of the album String? album; + /// The main artist credited for the whole album. + /// + /// This is distinct from [artist], which identifies the artist credited on + /// the individual track. It is useful for compilations and guest features. + String? albumArtist; + /// The year of when the album/track has been released DateTime? year; @@ -92,6 +98,7 @@ class AudioMetadata { AudioMetadata({ this.album, + this.albumArtist, this.year, this.language, this.artist, @@ -115,6 +122,7 @@ class AudioMetadata { String toString() { return 'AudioMetadata(\n' ' album: $album,\n' + ' albumArtist: $albumArtist,\n' ' year: $year,\n' ' language: $language,\n' ' artist: $artist,\n' diff --git a/lib/src/parsers/tags/vorbis_comment.dart b/lib/src/parsers/tags/vorbis_comment.dart index dbd1dff..56b79c2 100644 --- a/lib/src/parsers/tags/vorbis_comment.dart +++ b/lib/src/parsers/tags/vorbis_comment.dart @@ -70,9 +70,14 @@ void parseVorbisComment( metadata.trackNumber.add(int.parse(value)); } break; - case 'ARTIST' || "ALBUMARTIST": + case 'ARTIST': metadata.artist.add(value); break; + case 'ALBUMARTIST' || 'ALBUM_ARTIST': + // ALBUMARTIST is a common Vorbis comment convention. Some taggers, + // including FFmpeg, use the equivalent ALBUM_ARTIST spelling. + metadata.albumArtist.add(value); + break; case 'PERFORMER': metadata.performer.add(value); break; diff --git a/lib/src/writers/flac_writer.dart b/lib/src/writers/flac_writer.dart index ad3054f..ba72263 100644 --- a/lib/src/writers/flac_writer.dart +++ b/lib/src/writers/flac_writer.dart @@ -130,6 +130,7 @@ class FlacWriter extends BaseMetadataWriter { writeComment( "TRACKNUMBER", metadata.trackNumber.map((e) => e.toString()).toList()); writeComment("ARTIST", metadata.artist); + writeComment("ALBUMARTIST", metadata.albumArtist); writeComment("PERFORMER", metadata.performer); writeComment("COPYRIGHT", metadata.copyright); writeComment("LICENSE", metadata.license); diff --git a/test/flac/album_artist.flac b/test/flac/album_artist.flac new file mode 100644 index 0000000000000000000000000000000000000000..cce612a42490a6d281d12ec5d164d86057bc41ce GIT binary patch literal 8855 zcmeI&-Ahw(9LMorZQ9ygHFY|dwKbQuR-^TJp)>5k2Uvu8qzHnRZ7_O*?Kn_+(2Jyt z$U)vzgqV1dg%m~)1S;N0P+knW2rGzn5s^?BvI}EF%x8uNEJ8)ZZ4*rjuiHUohTPggN0zDzLxo9G^d^ZQD zhYz+LU0SnzzaHiKhGDs+fE17dQa}nw0VyB_q<|EV0#ZNWm@=_|Q13ogb<1CIivZKUxxXum=hEP$Ox zvM>+6zeTRffrEcY(+9Azm0Xz7DeZ4p{S-Ea?YN+#pMPz|42j-V2VW$+AAs>>$f;g0VBCV;Jn7 zCMzC-KPO1%WANmDa{Uu<&oQ#{HTcX!ZWsqYpCUKD1mhXf^%N`^A>CHc|D4=p1{>dz z9s@Xkn5^Qtj)7XzYXhAv directory.deleteSync(recursive: true)); + final target = File('${directory.path}/track.flac') + ..writeAsBytesSync(File('test/flac/album_artist.flac').readAsBytesSync()); + + updateMetadata(target, (metadata) { + final vorbisMetadata = metadata as VorbisMetadata; + vorbisMetadata.albumArtist + ..clear() + ..add('Updated album artist'); + }); + + final result = readMetadata(target, getImage: false); + + expect(result.artist, equals('Track artist')); + expect(result.albumArtist, equals('Updated album artist')); + }); } From 4150255e55707034abf80542d7b23324f726a8c5 Mon Sep 17 00:00:00 2001 From: clement Date: Mon, 7 Sep 2026 21:02:06 -0300 Subject: [PATCH 2/2] format --- lib/src/parsers/tags/id3v2.dart | 1230 +++++++++++++++---------------- 1 file changed, 615 insertions(+), 615 deletions(-) diff --git a/lib/src/parsers/tags/id3v2.dart b/lib/src/parsers/tags/id3v2.dart index 128b95e..ed4fc67 100644 --- a/lib/src/parsers/tags/id3v2.dart +++ b/lib/src/parsers/tags/id3v2.dart @@ -31,219 +31,219 @@ // - Malformed or truncated data is handled by the existing buffer/parser // error path; the parser never leaves the cursor inside a frame payload. -import 'dart:convert'; -import 'dart:io'; -import 'dart:typed_data'; -import 'package:audio_metadata_reader/src/constants/id3_genres.dart'; -import 'package:audio_metadata_reader/src/metadata/base.dart'; -import 'package:audio_metadata_reader/src/utils/bit_manipulator.dart'; -import 'package:audio_metadata_reader/src/utils/buffer.dart'; -import 'package:charset/charset.dart'; -import 'tag_parser.dart'; - -const utf8Decoder = Utf8Decoder(); -const utf16Decoder = Utf16Decoder(); -const latin1Decoder = Latin1Decoder(); - -class ID3v3Frame { - final String id; - final int size; - final Uint8List flags; - - ID3v3Frame(this.id, this.size, this.flags); -} - -/// -/// Metadata frame defined in the ID3 tag -/// -String getTextFromFrame(Uint8List information) { - final encoding = information.first; - - switch (encoding) { - case 0: - final nullCharacterPosition = information.indexOf(0, 1); - return latin1Decoder.convert( - information, - 1, - (nullCharacterPosition >= 0) - ? nullCharacterPosition - : information.length); - - case 1: - int nullCharacterPosition = -1; - for (int i = 1; i + 1 < information.length; i += 2) { - if (information[i] == 0 && information[i + 1] == 0) { - nullCharacterPosition = i; - break; - } - } - return utf16Decoder.decodeUtf16Le(information.sublist( - 1, - (nullCharacterPosition >= 0) - ? nullCharacterPosition - : information.length)); - - case 2: - int nullCharacterPosition = 1; - while (nullCharacterPosition < information.length) { - if (information[nullCharacterPosition] == 0) { - if (information[nullCharacterPosition + 1] == 0) break; - } - nullCharacterPosition++; - } - return utf16Decoder - .decodeUtf16Le(information.sublist(1, nullCharacterPosition)); - - case 3: - final nullCharacterPosition = information.indexOf(0, 1); - return utf8Decoder.convert( - information, - 1, - (nullCharacterPosition >= 0) - ? nullCharacterPosition - : information.length); - } - - return ""; -} - -/// Custom metadata frame -/// Can be used my MusicBrainz for instance -class TXXXFrame { - late final int encoding; - late final String description; - late final String information; - - TXXXFrame(Uint8List information) { - int offset = 0; - encoding = information[offset++]; - - final descriptionData = []; - bool isUTF16 = encoding == 1 || encoding == 2; - - if (isUTF16) { - while (!(information[offset] == 0 && information[offset + 1] == 0)) { - descriptionData.add(information[offset]); - descriptionData.add(information[offset + 1]); - offset += 2; - } - - // we pass the final zeros - while (information[offset] == 0) { - offset++; - } - } else { - while (information[offset] != 0) { - descriptionData.add(information[offset++]); - } - } - - int lastCharPosition = information.length - 1; - - // can be 00 for utf16 or 0 for other - bool hasTerminalEmptyCharacter = information[lastCharPosition] == 0 && - information[lastCharPosition - 1] == 0; - - // we need to remove the empty character at the end - // it's a single or double zero - final length = information.length - - offset - - (hasTerminalEmptyCharacter - ? isUTF16 - ? 2 - : 1 - : 0); - final rest = information.buffer.asUint8List(offset, length); - - switch (encoding) { - case 0: - description = latin1Decoder.convert(descriptionData); - - this.information = latin1Decoder.convert(rest); - break; - case 1: - description = utf16Decoder.decodeUtf16Le(descriptionData); - - this.information = utf16Decoder.decodeUtf16Le(rest); - break; - case 2: - description = utf16Decoder.decodeUtf16Be(descriptionData); - - this.information = utf16Decoder.decodeUtf16Be(rest); - break; - case 3: - description = utf8Decoder.convert(descriptionData); - - this.information = utf8Decoder.convert(rest); - break; - } - } -} - -/// -/// Parser for the ID3 tags -/// -/// -/// https://teslabs.com/openplayer/docs/docs/specs/id3v2.3.0%20-%20ID3.org.pdf -/// -class ID3v2Parser extends TagParser { - final Mp3Metadata metadata = Mp3Metadata(); - late final Buffer buffer; - - static final _discRegex = RegExp(r"(\d+)/(\d+)"); - static final _trackRegex = RegExp(r"(\d+)/(\d+)"); - - ID3v2Parser({bool fetchImage = false}) : super(fetchImage: fetchImage); - - @override - Mp3Metadata parse(RandomAccessFile reader) { - buffer = Buffer(randomAccessFile: reader); - - final headerBytes = buffer.read(10); - final majorVersion = headerBytes[3]; - - if (majorVersion == 1) { - return metadata; - } - +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; +import 'package:audio_metadata_reader/src/constants/id3_genres.dart'; +import 'package:audio_metadata_reader/src/metadata/base.dart'; +import 'package:audio_metadata_reader/src/utils/bit_manipulator.dart'; +import 'package:audio_metadata_reader/src/utils/buffer.dart'; +import 'package:charset/charset.dart'; +import 'tag_parser.dart'; + +const utf8Decoder = Utf8Decoder(); +const utf16Decoder = Utf16Decoder(); +const latin1Decoder = Latin1Decoder(); + +class ID3v3Frame { + final String id; + final int size; + final Uint8List flags; + + ID3v3Frame(this.id, this.size, this.flags); +} + +/// +/// Metadata frame defined in the ID3 tag +/// +String getTextFromFrame(Uint8List information) { + final encoding = information.first; + + switch (encoding) { + case 0: + final nullCharacterPosition = information.indexOf(0, 1); + return latin1Decoder.convert( + information, + 1, + (nullCharacterPosition >= 0) + ? nullCharacterPosition + : information.length); + + case 1: + int nullCharacterPosition = -1; + for (int i = 1; i + 1 < information.length; i += 2) { + if (information[i] == 0 && information[i + 1] == 0) { + nullCharacterPosition = i; + break; + } + } + return utf16Decoder.decodeUtf16Le(information.sublist( + 1, + (nullCharacterPosition >= 0) + ? nullCharacterPosition + : information.length)); + + case 2: + int nullCharacterPosition = 1; + while (nullCharacterPosition < information.length) { + if (information[nullCharacterPosition] == 0) { + if (information[nullCharacterPosition + 1] == 0) break; + } + nullCharacterPosition++; + } + return utf16Decoder + .decodeUtf16Le(information.sublist(1, nullCharacterPosition)); + + case 3: + final nullCharacterPosition = information.indexOf(0, 1); + return utf8Decoder.convert( + information, + 1, + (nullCharacterPosition >= 0) + ? nullCharacterPosition + : information.length); + } + + return ""; +} + +/// Custom metadata frame +/// Can be used my MusicBrainz for instance +class TXXXFrame { + late final int encoding; + late final String description; + late final String information; + + TXXXFrame(Uint8List information) { + int offset = 0; + encoding = information[offset++]; + + final descriptionData = []; + bool isUTF16 = encoding == 1 || encoding == 2; + + if (isUTF16) { + while (!(information[offset] == 0 && information[offset + 1] == 0)) { + descriptionData.add(information[offset]); + descriptionData.add(information[offset + 1]); + offset += 2; + } + + // we pass the final zeros + while (information[offset] == 0) { + offset++; + } + } else { + while (information[offset] != 0) { + descriptionData.add(information[offset++]); + } + } + + int lastCharPosition = information.length - 1; + + // can be 00 for utf16 or 0 for other + bool hasTerminalEmptyCharacter = information[lastCharPosition] == 0 && + information[lastCharPosition - 1] == 0; + + // we need to remove the empty character at the end + // it's a single or double zero + final length = information.length - + offset - + (hasTerminalEmptyCharacter + ? isUTF16 + ? 2 + : 1 + : 0); + final rest = information.buffer.asUint8List(offset, length); + + switch (encoding) { + case 0: + description = latin1Decoder.convert(descriptionData); + + this.information = latin1Decoder.convert(rest); + break; + case 1: + description = utf16Decoder.decodeUtf16Le(descriptionData); + + this.information = utf16Decoder.decodeUtf16Le(rest); + break; + case 2: + description = utf16Decoder.decodeUtf16Be(descriptionData); + + this.information = utf16Decoder.decodeUtf16Be(rest); + break; + case 3: + description = utf8Decoder.convert(descriptionData); + + this.information = utf8Decoder.convert(rest); + break; + } + } +} + +/// +/// Parser for the ID3 tags +/// +/// +/// https://teslabs.com/openplayer/docs/docs/specs/id3v2.3.0%20-%20ID3.org.pdf +/// +class ID3v2Parser extends TagParser { + final Mp3Metadata metadata = Mp3Metadata(); + late final Buffer buffer; + + static final _discRegex = RegExp(r"(\d+)/(\d+)"); + static final _trackRegex = RegExp(r"(\d+)/(\d+)"); + + ID3v2Parser({bool fetchImage = false}) : super(fetchImage: fetchImage); + + @override + Mp3Metadata parse(RandomAccessFile reader) { + buffer = Buffer(randomAccessFile: reader); + + final headerBytes = buffer.read(10); + final majorVersion = headerBytes[3]; + + if (majorVersion == 1) { + return metadata; + } + final Uint8List sizeBytes = headerBytes.sublist(6); - - // size of the ID3 tag minus id3 header size - int size = (sizeBytes[3] & 0x7F) | - ((sizeBytes[2] & 0x7F) << 7) | - ((sizeBytes[1] & 0x7F) << 14) | - ((sizeBytes[0] & 0x7F) << 21); - - int offset = 10; - - // extended header - // useless - // - // > The extended header contains information - // > that is not vital to the correct - // > parsing of the tag information - if (getBit(headerBytes[5], 6) == 1) { - final extendedHeaderSize = getUint32(buffer.read(4)); - buffer.skip(extendedHeaderSize - 4); - } - - while (offset < size) { - final frame = getFrame(reader, isV4: majorVersion == 4); - - if (frame == null) { - break; - } - - // 10 -> frame header - offset = offset + 10 + frame.size; - processFrame(frame.id, frame.size); - } - - return metadata; - } - - /// Process a frame. - /// + + // size of the ID3 tag minus id3 header size + int size = (sizeBytes[3] & 0x7F) | + ((sizeBytes[2] & 0x7F) << 7) | + ((sizeBytes[1] & 0x7F) << 14) | + ((sizeBytes[0] & 0x7F) << 21); + + int offset = 10; + + // extended header + // useless + // + // > The extended header contains information + // > that is not vital to the correct + // > parsing of the tag information + if (getBit(headerBytes[5], 6) == 1) { + final extendedHeaderSize = getUint32(buffer.read(4)); + buffer.skip(extendedHeaderSize - 4); + } + + while (offset < size) { + final frame = getFrame(reader, isV4: majorVersion == 4); + + if (frame == null) { + break; + } + + // 10 -> frame header + offset = offset + 10 + frame.size; + processFrame(frame.id, frame.size); + } + + return metadata; + } + + /// Process a frame. + /// /// Unknown frames are retained because ID3 allows application-specific and /// newer frame identifiers that this parser does not know yet. /// @@ -269,409 +269,409 @@ class ID3v2Parser extends TagParser { /// /// If the frame ID is not defined in the ID3 specification, its complete /// payload is preserved in `customMetadata` under the frame ID. - void processFrame(String frameId, int size) { - // why do we duplicate the content in every block? - // it's because the biggest thing to get in the cover - // sometimes, we don't want to read so we have to read the content - // at the very last time - - final handlers = switch (frameId) { - "APIC" => () { - if (fetchImage) { - final content = buffer.read(size); - final picture = getPicture(content); - metadata.pictures.add(picture); - } else { - buffer.skip(size); - } - }, - "TALB" => () { - final content = buffer.read(size); - metadata.album = getTextFromFrame(content); - }, - "TBPM" => () { - final content = buffer.read(size); - metadata.bpm = getTextFromFrame(content); - }, - "TCOP" => () { - final content = buffer.read(size); - metadata.copyrightMessage = getTextFromFrame(content); - }, - "TCON" => () { - final content = buffer.read(size); - metadata.contentType = getTextFromFrame(content); - final regex = RegExp(r"(\d+).*"); - final containRegex = RegExp(r";|/|\||,"); - - if (metadata.contentType!.contains(containRegex)) { - metadata.genres.addAll(metadata.contentType! - .split(containRegex) - .map((e) => e.trim()) - .toList()); - } else if (regex.hasMatch(metadata.contentType!)) { - metadata.genres.add(id3Genres[ - regex.allMatches(metadata.contentType!).first.group(0)!] ?? - ""); - } else if (metadata.contentType!.isNotEmpty) { - metadata.genres.add(metadata.contentType!); - } - }, - "TCOM" => () { - final content = buffer.read(size); - metadata.composer = getTextFromFrame(content); - }, - "TDAT" => () { - final content = buffer.read(size); - metadata.date = getTextFromFrame(content); - }, - "TDLY" => () { - final content = buffer.read(size); - metadata.playlistDelay = getTextFromFrame(content); - }, - "TENC" => () { - final content = buffer.read(size); - metadata.encodedBy = getTextFromFrame(content); - }, - "TFLT" => () { - final content = buffer.read(size); - metadata.fileType = getTextFromFrame(content); - }, - "TIME" => () { - final content = buffer.read(size); - metadata.time = getTextFromFrame(content); - }, - "TIT1" => () { - final content = buffer.read(size); - metadata.contentGroupDescription = getTextFromFrame(content); - }, - "TIT2" => () { - final content = buffer.read(size); - metadata.songName = getTextFromFrame(content); - }, - "TIT3" => () { - final content = buffer.read(size); - metadata.subtitle = getTextFromFrame(content); - }, - "TKEY" => () { - final content = buffer.read(size); - metadata.initialKey = getTextFromFrame(content); - }, - "TLAN" => () { - final content = buffer.read(size); - metadata.languages = getTextFromFrame(content); - }, - "TLEN" => () { - final content = buffer.read(size); - final time = int.tryParse(getTextFromFrame(content)); - - if (time != null) { - if ((time / 1000) < 1) { - metadata.duration = Duration(seconds: time); - } else { - metadata.duration = Duration(milliseconds: time); - } - } - }, - "TMED" => () { - final content = buffer.read(size); - metadata.mediatype = getTextFromFrame(content); - }, - "TOAL" => () { - final content = buffer.read(size); - metadata.originalAlbum = getTextFromFrame(content); - }, - "TOFN" => () { - final content = buffer.read(size); - metadata.originalFilename = getTextFromFrame(content); - }, - "TOLY" => () { - final content = buffer.read(size); - metadata.originalTextWriter = getTextFromFrame(content); - }, - "TOPE" => () { - final content = buffer.read(size); - metadata.originalArtist = getTextFromFrame(content); - }, - "TORY" => () { - final content = buffer.read(size); - metadata.originalReleaseYear = _parseYear(getTextFromFrame(content)); - }, - "TOWN" => () { - final content = buffer.read(size); - metadata.fileOwner = getTextFromFrame(content); - }, - "TDRC" => () { - final content = buffer.read(size); - metadata.year = _parseYear(getTextFromFrame(content)); - }, - "TYER" => () { - final content = buffer.read(size); - metadata.year = _parseYear(getTextFromFrame(content)); - }, - "TRDA" => () { - final content = buffer.read(size); - metadata.year = _parseYear(getTextFromFrame(content)); - }, - "TPE1" => () { - final content = buffer.read(size); - metadata.leadPerformer = getTextFromFrame(content); - }, - "TPE2" => () { - final content = buffer.read(size); - metadata.bandOrOrchestra = getTextFromFrame(content); - }, - "TPE3" => () { - final content = buffer.read(size); - metadata.conductor = getTextFromFrame(content); - }, - "TPE4" => () { - final content = buffer.read(size); - metadata.interpreted = getTextFromFrame(content); - }, - "TEXT" => () { - final content = buffer.read(size); - metadata.textWriter = getTextFromFrame(content); - }, - "TPOS" || "TPA" => () { - final content = buffer.read(size); - final value = getTextFromFrame(content); - metadata.partOfSet = value; - - final match = _discRegex.firstMatch(value); - - if (match != null) { - metadata.discNumber = int.parse(match.group(1)!); - metadata.totalDics = int.parse(match.group(2)!); - } else { - metadata.discNumber = int.tryParse(value); - } - }, - "TPUB" => () { - final content = buffer.read(size); - metadata.publisher = getTextFromFrame(content); - }, - "TRCK" => () { - final content = buffer.read(size); - final trackInfo = getTextFromFrame(content); - - if (trackInfo.isEmpty) { - return; - } - - final match = _trackRegex.firstMatch(trackInfo); - - if (match != null) { - metadata.trackNumber = int.parse(match.group(1)!); - metadata.trackTotal = int.parse(match.group(2)!); - } else { - // ID3 text frames come from external taggers. A malformed value - // such as "Enero" must not abort parsing the whole audio file; - // it simply does not provide a usable track number. - metadata.trackNumber = int.tryParse(trackInfo); - } - }, - "TRSN" => () { - final content = buffer.read(size); - metadata.internetRadioStationName = getTextFromFrame(content); - }, - "TRSO" => () { - final content = buffer.read(size); - metadata.internetRadioStationOwner = getTextFromFrame(content); - }, - "TSIZ" => () { - final content = buffer.read(size); - metadata.size = getTextFromFrame(content); - }, - "TSRC" => () { - final content = buffer.read(size); - metadata.isrc = getTextFromFrame(content); - }, - "TXXX" => () { - final content = buffer.read(size); - final frame = TXXXFrame(content); - metadata.customMetadata[frame.description] = frame.information; - }, - "USLT" => () { - final content = buffer.read(size); - metadata.lyric = getUnsynchronisedLyric(content); - }, - "TSSE" => () { - final content = buffer.read(size); - metadata.encoderSoftware = getTextFromFrame(content); - }, - _ => () { + void processFrame(String frameId, int size) { + // why do we duplicate the content in every block? + // it's because the biggest thing to get in the cover + // sometimes, we don't want to read so we have to read the content + // at the very last time + + final handlers = switch (frameId) { + "APIC" => () { + if (fetchImage) { + final content = buffer.read(size); + final picture = getPicture(content); + metadata.pictures.add(picture); + } else { + buffer.skip(size); + } + }, + "TALB" => () { + final content = buffer.read(size); + metadata.album = getTextFromFrame(content); + }, + "TBPM" => () { + final content = buffer.read(size); + metadata.bpm = getTextFromFrame(content); + }, + "TCOP" => () { + final content = buffer.read(size); + metadata.copyrightMessage = getTextFromFrame(content); + }, + "TCON" => () { + final content = buffer.read(size); + metadata.contentType = getTextFromFrame(content); + final regex = RegExp(r"(\d+).*"); + final containRegex = RegExp(r";|/|\||,"); + + if (metadata.contentType!.contains(containRegex)) { + metadata.genres.addAll(metadata.contentType! + .split(containRegex) + .map((e) => e.trim()) + .toList()); + } else if (regex.hasMatch(metadata.contentType!)) { + metadata.genres.add(id3Genres[ + regex.allMatches(metadata.contentType!).first.group(0)!] ?? + ""); + } else if (metadata.contentType!.isNotEmpty) { + metadata.genres.add(metadata.contentType!); + } + }, + "TCOM" => () { + final content = buffer.read(size); + metadata.composer = getTextFromFrame(content); + }, + "TDAT" => () { + final content = buffer.read(size); + metadata.date = getTextFromFrame(content); + }, + "TDLY" => () { + final content = buffer.read(size); + metadata.playlistDelay = getTextFromFrame(content); + }, + "TENC" => () { + final content = buffer.read(size); + metadata.encodedBy = getTextFromFrame(content); + }, + "TFLT" => () { + final content = buffer.read(size); + metadata.fileType = getTextFromFrame(content); + }, + "TIME" => () { + final content = buffer.read(size); + metadata.time = getTextFromFrame(content); + }, + "TIT1" => () { + final content = buffer.read(size); + metadata.contentGroupDescription = getTextFromFrame(content); + }, + "TIT2" => () { + final content = buffer.read(size); + metadata.songName = getTextFromFrame(content); + }, + "TIT3" => () { + final content = buffer.read(size); + metadata.subtitle = getTextFromFrame(content); + }, + "TKEY" => () { + final content = buffer.read(size); + metadata.initialKey = getTextFromFrame(content); + }, + "TLAN" => () { + final content = buffer.read(size); + metadata.languages = getTextFromFrame(content); + }, + "TLEN" => () { + final content = buffer.read(size); + final time = int.tryParse(getTextFromFrame(content)); + + if (time != null) { + if ((time / 1000) < 1) { + metadata.duration = Duration(seconds: time); + } else { + metadata.duration = Duration(milliseconds: time); + } + } + }, + "TMED" => () { + final content = buffer.read(size); + metadata.mediatype = getTextFromFrame(content); + }, + "TOAL" => () { + final content = buffer.read(size); + metadata.originalAlbum = getTextFromFrame(content); + }, + "TOFN" => () { + final content = buffer.read(size); + metadata.originalFilename = getTextFromFrame(content); + }, + "TOLY" => () { + final content = buffer.read(size); + metadata.originalTextWriter = getTextFromFrame(content); + }, + "TOPE" => () { + final content = buffer.read(size); + metadata.originalArtist = getTextFromFrame(content); + }, + "TORY" => () { + final content = buffer.read(size); + metadata.originalReleaseYear = _parseYear(getTextFromFrame(content)); + }, + "TOWN" => () { + final content = buffer.read(size); + metadata.fileOwner = getTextFromFrame(content); + }, + "TDRC" => () { + final content = buffer.read(size); + metadata.year = _parseYear(getTextFromFrame(content)); + }, + "TYER" => () { + final content = buffer.read(size); + metadata.year = _parseYear(getTextFromFrame(content)); + }, + "TRDA" => () { + final content = buffer.read(size); + metadata.year = _parseYear(getTextFromFrame(content)); + }, + "TPE1" => () { + final content = buffer.read(size); + metadata.leadPerformer = getTextFromFrame(content); + }, + "TPE2" => () { + final content = buffer.read(size); + metadata.bandOrOrchestra = getTextFromFrame(content); + }, + "TPE3" => () { + final content = buffer.read(size); + metadata.conductor = getTextFromFrame(content); + }, + "TPE4" => () { + final content = buffer.read(size); + metadata.interpreted = getTextFromFrame(content); + }, + "TEXT" => () { + final content = buffer.read(size); + metadata.textWriter = getTextFromFrame(content); + }, + "TPOS" || "TPA" => () { + final content = buffer.read(size); + final value = getTextFromFrame(content); + metadata.partOfSet = value; + + final match = _discRegex.firstMatch(value); + + if (match != null) { + metadata.discNumber = int.parse(match.group(1)!); + metadata.totalDics = int.parse(match.group(2)!); + } else { + metadata.discNumber = int.tryParse(value); + } + }, + "TPUB" => () { + final content = buffer.read(size); + metadata.publisher = getTextFromFrame(content); + }, + "TRCK" => () { + final content = buffer.read(size); + final trackInfo = getTextFromFrame(content); + + if (trackInfo.isEmpty) { + return; + } + + final match = _trackRegex.firstMatch(trackInfo); + + if (match != null) { + metadata.trackNumber = int.parse(match.group(1)!); + metadata.trackTotal = int.parse(match.group(2)!); + } else { + // ID3 text frames come from external taggers. A malformed value + // such as "Enero" must not abort parsing the whole audio file; + // it simply does not provide a usable track number. + metadata.trackNumber = int.tryParse(trackInfo); + } + }, + "TRSN" => () { + final content = buffer.read(size); + metadata.internetRadioStationName = getTextFromFrame(content); + }, + "TRSO" => () { + final content = buffer.read(size); + metadata.internetRadioStationOwner = getTextFromFrame(content); + }, + "TSIZ" => () { + final content = buffer.read(size); + metadata.size = getTextFromFrame(content); + }, + "TSRC" => () { + final content = buffer.read(size); + metadata.isrc = getTextFromFrame(content); + }, + "TXXX" => () { + final content = buffer.read(size); + final frame = TXXXFrame(content); + metadata.customMetadata[frame.description] = frame.information; + }, + "USLT" => () { + final content = buffer.read(size); + metadata.lyric = getUnsynchronisedLyric(content); + }, + "TSSE" => () { + final content = buffer.read(size); + metadata.encoderSoftware = getTextFromFrame(content); + }, + _ => () { final Uint8List content = buffer.read(size); metadata.customMetadata[frameId] = _getUnknownFrameValue(frameId, content); - } - }; - - handlers.call(); - } - - ID3v3Frame? getFrame( - RandomAccessFile reader, { - required bool isV4, - }) { - final headerBytes = buffer.read(10); - - if (headerBytes.every((element) => element == 0)) return null; - - int size; - - // the id3 v4 ignore the first bit of every byte from the size - if (isV4) { - size = (headerBytes[7] & 0xFF) | - ((headerBytes[6] & 0xFF) << 7) | - ((headerBytes[5] & 0xFF) << 14) | - ((headerBytes[4] & 0xFF) << 21); - } else { - size = headerBytes[7] + - (headerBytes[6] << 8) + - (headerBytes[5] << 16) + - (headerBytes[4] << 24); - } - - final flags = headerBytes.sublist(8); - final id = String.fromCharCodes(headerBytes.sublist(0, 4)); - - return ID3v3Frame( - id, - size, - flags, - ); - } - - Picture getPicture(Uint8List content) { - int offset = 0; - - final reader = ByteData.sublistView(content); - final encoding = reader.getUint8(offset++); - - final mimetype = [reader.getUint8(offset++)]; - - while (mimetype.last != 0) { - mimetype.add(reader.getUint8(offset)); - offset++; - } - mimetype.removeLast(); - - final pictureType = reader.getUint8(offset); - - offset++; - - if (encoding == 1) { - // APIC encoding 1 uses UTF-16 with a BOM. Its description is terminated - // by a UTF-16 NUL code unit (`00 00`), not by the first zero byte. A - // single-byte scan would stop in the middle of every UTF-16 character - // and make the returned image include the rest of the description. - // - // Layout at this point: - // FF FE UTF-16LE BOM - // XX 00 ... description code units - // 00 00 description terminator - while (offset + 1 < reader.lengthInBytes) { - final first = reader.getUint8(offset++); - final second = reader.getUint8(offset++); - if (first == 0 && second == 0) { - break; - } - } - } else { - final description = [reader.getInt8(offset++)]; - - while (description.last != 0) { - description.add(reader.getInt8(offset++)); - } - } - - if (encoding == 1 || encoding == 2) { - while (reader.getInt8(offset) == 0) { - offset++; - } - } - - return Picture( - reader.buffer.asUint8List(offset), - String.fromCharCodes(mimetype), - getPictureTypeEnum(pictureType), - ); - } - - String getUnsynchronisedLyric(Uint8List content) { - int offset = 1; - - final reader = ByteData.sublistView(content); - final encoding = reader.getInt8(0); - - // skip language - offset += 3; - - final description = [reader.getInt8(offset)]; - - while (description.last != 0) { - description.add(reader.getInt8(offset)); - offset++; - } - - if (encoding == 1 || encoding == 2) { - while (offset < reader.lengthInBytes && reader.getInt8(offset) == 0) { - offset++; - } - } - - final rest = reader.buffer.asUint8List(offset); - - switch (encoding) { - case 0: - final nullCharacterPosition = rest.indexOf(0, 1); - final informationBytes = rest.sublist( - 1, (nullCharacterPosition >= 0) ? nullCharacterPosition : null); - return latin1Decoder.convert(informationBytes); - case 1: - if (encoding == 1 || encoding == 2) { - // Check if rest length is sufficient and properly handle ending - if (rest.length >= 2 && - rest[rest.length - 1] == 0 && - rest[rest.length - 2] == 0) { - return utf16Decoder.decodeUtf16Le(rest, 0, rest.length - 2); - } - return utf16Decoder.decodeUtf16Le(rest); - } - return utf16Decoder.decodeUtf16Le(rest); - case 2: - int nullCharacterPosition = 1; - bool zeroFound = false; - while (nullCharacterPosition < rest.length) { - if (rest[nullCharacterPosition] == 0) { - if (zeroFound) { - break; - } - zeroFound = true; - } - nullCharacterPosition++; - } - - final informationBytes = rest.sublist( - 1, (nullCharacterPosition >= 0) ? nullCharacterPosition : null); - return utf16Decoder.decodeUtf16Le(informationBytes); - case 3: - final nullCharacterPosition = rest.indexOf(0, 1); - final informationBytes = rest.sublist( - 1, (nullCharacterPosition >= 0) ? nullCharacterPosition : null); - return utf8Decoder.convert(informationBytes); - } - - return ""; - } - - int? _parseYear(String year) { - if (year.contains("-")) { - return int.tryParse(year.split("-").first); - } else if (year.contains("/")) { - return int.tryParse(year.split("/").first); - } else { - return int.tryParse(year); - } - } -} + } + }; + + handlers.call(); + } + + ID3v3Frame? getFrame( + RandomAccessFile reader, { + required bool isV4, + }) { + final headerBytes = buffer.read(10); + + if (headerBytes.every((element) => element == 0)) return null; + + int size; + + // the id3 v4 ignore the first bit of every byte from the size + if (isV4) { + size = (headerBytes[7] & 0xFF) | + ((headerBytes[6] & 0xFF) << 7) | + ((headerBytes[5] & 0xFF) << 14) | + ((headerBytes[4] & 0xFF) << 21); + } else { + size = headerBytes[7] + + (headerBytes[6] << 8) + + (headerBytes[5] << 16) + + (headerBytes[4] << 24); + } + + final flags = headerBytes.sublist(8); + final id = String.fromCharCodes(headerBytes.sublist(0, 4)); + + return ID3v3Frame( + id, + size, + flags, + ); + } + + Picture getPicture(Uint8List content) { + int offset = 0; + + final reader = ByteData.sublistView(content); + final encoding = reader.getUint8(offset++); + + final mimetype = [reader.getUint8(offset++)]; + + while (mimetype.last != 0) { + mimetype.add(reader.getUint8(offset)); + offset++; + } + mimetype.removeLast(); + + final pictureType = reader.getUint8(offset); + + offset++; + + if (encoding == 1) { + // APIC encoding 1 uses UTF-16 with a BOM. Its description is terminated + // by a UTF-16 NUL code unit (`00 00`), not by the first zero byte. A + // single-byte scan would stop in the middle of every UTF-16 character + // and make the returned image include the rest of the description. + // + // Layout at this point: + // FF FE UTF-16LE BOM + // XX 00 ... description code units + // 00 00 description terminator + while (offset + 1 < reader.lengthInBytes) { + final first = reader.getUint8(offset++); + final second = reader.getUint8(offset++); + if (first == 0 && second == 0) { + break; + } + } + } else { + final description = [reader.getInt8(offset++)]; + + while (description.last != 0) { + description.add(reader.getInt8(offset++)); + } + } + + if (encoding == 1 || encoding == 2) { + while (reader.getInt8(offset) == 0) { + offset++; + } + } + + return Picture( + reader.buffer.asUint8List(offset), + String.fromCharCodes(mimetype), + getPictureTypeEnum(pictureType), + ); + } + + String getUnsynchronisedLyric(Uint8List content) { + int offset = 1; + + final reader = ByteData.sublistView(content); + final encoding = reader.getInt8(0); + + // skip language + offset += 3; + + final description = [reader.getInt8(offset)]; + + while (description.last != 0) { + description.add(reader.getInt8(offset)); + offset++; + } + + if (encoding == 1 || encoding == 2) { + while (offset < reader.lengthInBytes && reader.getInt8(offset) == 0) { + offset++; + } + } + + final rest = reader.buffer.asUint8List(offset); + + switch (encoding) { + case 0: + final nullCharacterPosition = rest.indexOf(0, 1); + final informationBytes = rest.sublist( + 1, (nullCharacterPosition >= 0) ? nullCharacterPosition : null); + return latin1Decoder.convert(informationBytes); + case 1: + if (encoding == 1 || encoding == 2) { + // Check if rest length is sufficient and properly handle ending + if (rest.length >= 2 && + rest[rest.length - 1] == 0 && + rest[rest.length - 2] == 0) { + return utf16Decoder.decodeUtf16Le(rest, 0, rest.length - 2); + } + return utf16Decoder.decodeUtf16Le(rest); + } + return utf16Decoder.decodeUtf16Le(rest); + case 2: + int nullCharacterPosition = 1; + bool zeroFound = false; + while (nullCharacterPosition < rest.length) { + if (rest[nullCharacterPosition] == 0) { + if (zeroFound) { + break; + } + zeroFound = true; + } + nullCharacterPosition++; + } + + final informationBytes = rest.sublist( + 1, (nullCharacterPosition >= 0) ? nullCharacterPosition : null); + return utf16Decoder.decodeUtf16Le(informationBytes); + case 3: + final nullCharacterPosition = rest.indexOf(0, 1); + final informationBytes = rest.sublist( + 1, (nullCharacterPosition >= 0) ? nullCharacterPosition : null); + return utf8Decoder.convert(informationBytes); + } + + return ""; + } + + int? _parseYear(String year) { + if (year.contains("-")) { + return int.tryParse(year.split("-").first); + } else if (year.contains("/")) { + return int.tryParse(year.split("/").first); + } else { + return int.tryParse(year); + } + } +}