diff --git a/CHANGELOG.md b/CHANGELOG.md index f100ffe..d2dd768 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +## 0.3.0 (BREAKING CHANGES) + +- COMM, APIC, USLT, WXXX tags returns as a map +- WXXX frame returns WURL object +- various fixes +- added USLT tag +- added possibility to pass many COMM, APIC, USLT tags +- APIC processing was refactored +- hex encoder +- unrecognized encoding falls to hex encoder (removed unsupported encoding error) +- unsupported tags like PRIV will be printed just like raw binary data + ## 0.2.3 - Fixed issue [issue #13](https://github.com/NiKoTron/dart-tags/issues/13)) diff --git a/README.md b/README.md index 4a6e657..c101fa5 100644 --- a/README.md +++ b/README.md @@ -16,14 +16,22 @@ project under MIT [license][license] [full changelog][changelog] -## 0.2.3 +## 0.3.0 (BREAKING CHANGES) -- Fixed wrong utf16 decoding. issue [issue #13](https://github.com/NiKoTron/dart-tags/issues/13)) +- COMM, APIC, USLT, WXXX tags returns as a map +- WXXX frame returns WURL object +- various fixes +- added USLT tag +- added possibility to pass many COMM, APIC, USLT tags +- APIC processing was refactored +- hex encoder +- unrecognized encoding falls to hex encoder (removed unsupported encoding error) +- unsupported tags like PRIV will be printed just like raw binary data -## 0.2.2 -- fixed an issue with wrong header calculation thanx [@frankdenouter](https://github.com/frankdenouter) and his [PR](https://github.com/NiKoTron/dart-tags/pull/10) -- added test assets v23 for reader +## 0.2.3 + +- Fixed wrong utf16 decoding. issue [issue #13](https://github.com/NiKoTron/dart-tags/issues/13)) ## Instalation @@ -31,7 +39,7 @@ add dependency in pubsec.yaml ```yaml dependencies: - dart_tags: ^0.2.2 + dart_tags: ^0.3.0 ``` ## Usage @@ -66,7 +74,7 @@ Thanx for contributing [@magodo](https://github.com/magodo), [@frankdenouter](ht Thanx for the [Photo][photo] by [Mink Mingle][mink_mingle] on [Unsplash][unsplash] that we using in unit tests. -[id3org]: http://id3.org/Home +[id3org]: https://id3.org/Home [tracker]: https://github.com/NiKoTron/dart-tags/issues [changelog]: CHANGELOG.md [license]: LICENSE diff --git a/lib/src/convert/hex_encoding.dart b/lib/src/convert/hex_encoding.dart new file mode 100644 index 0000000..1778c51 --- /dev/null +++ b/lib/src/convert/hex_encoding.dart @@ -0,0 +1,23 @@ +import 'dart:convert'; + +import 'package:convert/convert.dart'; + +class HEXEncoding extends Encoding { + @override + Converter, String> get decoder => _HEXDecoder(); + @override + Converter> get encoder => _HEXEnoder(); + + @override + String get name => 'hex'; +} + +class _HEXDecoder extends Converter, String> { + @override + String convert(List input) => hex.encode(input); +} + +class _HEXEnoder extends Converter> { + @override + List convert(String input) => hex.decode(input); +} diff --git a/lib/src/convert/utf16.dart b/lib/src/convert/utf16.dart index 3a2a9cb..db71903 100644 --- a/lib/src/convert/utf16.dart +++ b/lib/src/convert/utf16.dart @@ -1,28 +1,78 @@ import 'dart:convert'; +import 'dart:core'; -import 'package:utf/utf.dart'; +import 'package:utf/utf.dart' as utf; + +abstract class UTF16 extends Encoding { + List get bom; + + static const le = [0xff, 0xfe]; + static const be = [0xfe, 0xff]; + + @override + Converter, String> get decoder; + + @override + Converter> get encoder; + + @override + String get name; // => 'utf16'; +} + +class UTF16LE extends UTF16 { + @override + List get bom => UTF16.le; + + @override + Converter, String> get decoder => _UTF16LEDecoder(); + + @override + Converter> get encoder => _UTF16LEEnoder(); + + @override + String get name => 'utf16le'; +} + +class _UTF16LEDecoder extends Converter, String> { + @override + String convert(List input) { + final decoder = utf.Utf16leBytesToCodeUnitsDecoder(input); + return String.fromCharCodes(decoder.decodeRest()); + } +} + +class _UTF16LEEnoder extends Converter> { + @override + List convert(String input) { + return utf.encodeUtf16le(input, true); + } +} + +class UTF16BE extends UTF16 { + @override + List get bom => UTF16.be; -class UTF16 extends Encoding { @override - Converter, String> get decoder => _UTF16Decoder(); + Converter, String> get decoder => _UTF16BEDecoder(); @override - Converter> get encoder => _UTF16Enoder(); + Converter> get encoder => _UTF16BEEnoder(); @override - String get name => 'utf16'; + String get name => 'utf16be'; } -class _UTF16Decoder extends Converter, String> { +class _UTF16BEDecoder extends Converter, String> { @override String convert(List input) { - return decodeUtf16(input); + final decoder = utf.Utf16beBytesToCodeUnitsDecoder(input); + return String.fromCharCodes(decoder.decodeRest()); } } -class _UTF16Enoder extends Converter> { +class _UTF16BEEnoder extends Converter> { @override List convert(String input) { - return input.runes.toList(); + return utf.encodeUtf16be(input, true); } } diff --git a/lib/src/frames/frame.dart b/lib/src/frames/frame.dart index 1c94969..5fe7443 100644 --- a/lib/src/frames/frame.dart +++ b/lib/src/frames/frame.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:dart_tags/src/frames/id3v2/apic_frame.dart'; import 'package:dart_tags/src/frames/id3v2/comm_frame.dart'; import 'package:dart_tags/src/frames/id3v2/txxx_frame.dart'; +import 'package:dart_tags/src/frames/id3v2/uslt_frame.dart'; import 'package:dart_tags/src/frames/id3v2/wxxx_frame.dart'; import 'package:dart_tags/src/model/consts.dart' as consts; @@ -17,7 +18,7 @@ abstract class Frame { MapEntry decode(List data); } -class FrameFactory { +class FrameFactory { String version; // ignore: avoid_annotating_with_dynamic @@ -36,7 +37,7 @@ class FrameFactory { return FrameFactory._internal('0', (v) => null); } - T getFrame(entry) => _frameGetter(entry); + T getFrame(entry) => _frameGetter(entry); } class FramesID3V24 { @@ -44,7 +45,8 @@ class FramesID3V24 { 'APIC': ApicFrame(), 'TXXX': TXXXFrame(), 'WXXX': WXXXFrame(), - 'COMM': COMMFrame() + 'COMM': COMMFrame(), + 'USLT': USLTFrame(), }; Frame _getFrame(String tag) { diff --git a/lib/src/frames/id3v2/apic_frame.dart b/lib/src/frames/id3v2/apic_frame.dart index 9a681fe..7f833d0 100644 --- a/lib/src/frames/id3v2/apic_frame.dart +++ b/lib/src/frames/id3v2/apic_frame.dart @@ -1,7 +1,10 @@ import 'dart:convert'; -import 'package:dart_tags/src/frames/id3v2/id3v2_frame.dart'; -import 'package:dart_tags/src/model/attached_picture.dart'; +import 'package:dart_tags/src/convert/utf16.dart'; +import 'package:dart_tags/src/utils/image_extractor.dart'; + +import '../../frames/id3v2/id3v2_frame.dart'; +import '../../model/attached_picture.dart'; /* http://id3.org/id3v2.4.0-frames 4.14. Attached picture @@ -53,43 +56,39 @@ import 'package:dart_tags/src/model/attached_picture.dart'; */ class ApicFrame with ID3V2Frame { + final _imageExtractors = { + 'image/jpg': () => JPEGImageExtractor(), + 'image/jpeg': () => JPEGImageExtractor(), + 'image/png': () => PNGImageExtractor(), + }; + @override AttachedPicture decodeBody(List data, Encoding enc) { - final iterator = data.iterator; - var buff = []; - - final attachedPicture = AttachedPicture(); - - var cont = 0; - - while (iterator.moveNext() && cont < 4) { - final crnt = iterator.current; - if (crnt == 0x00 && cont < 3) { - if (cont == 1 && buff.isNotEmpty) { - attachedPicture.imageTypeCode = buff[0]; - cont++; - attachedPicture.description = enc.decode(buff.sublist(1)); - } else { - attachedPicture.mime = enc.decode(buff); - } - buff = []; - cont++; - continue; - } - buff.add(crnt); - } - - attachedPicture.imageData = buff; - - return attachedPicture; + final splitIndex1 = data.indexOf(0x00); + + final mime = latin1.decode(data.sublist(0, splitIndex1)); + final imageType = data[splitIndex1 + 1]; + + final splitIndex2 = enc is UTF16 + ? indexOfSplitPattern( + data.sublist(splitIndex1 + 1), [0x00, 0x00], splitIndex1) + : data.sublist(splitIndex1 + 1).indexOf(0x00) + splitIndex1 + 1; + + final description = enc.decode(data.sublist(splitIndex1 + 2, splitIndex2)); + + final imageData = _imageExtractors.containsKey(mime) + ? _imageExtractors[mime]().parse(data.sublist(splitIndex2)) + : data.sublist(splitIndex2); + + return AttachedPicture(mime, imageType, description, imageData); } @override List encode(AttachedPicture value, [String key]) { - final mimeEncoded = utf8.encode(value.mime); + final mimeEncoded = latin1.encode(value.mime); final descEncoded = utf8.encode(value.description); - return [ + final b = [ ...utf8.encode(frameTag), ...frameSizeInBytes( mimeEncoded.length + descEncoded.length + value.imageData.length + 4), @@ -101,6 +100,7 @@ class ApicFrame with ID3V2Frame { 0x00, ...value.imageData ]; + return b; } @override diff --git a/lib/src/frames/id3v2/comm_frame.dart b/lib/src/frames/id3v2/comm_frame.dart index c183485..2123dfd 100644 --- a/lib/src/frames/id3v2/comm_frame.dart +++ b/lib/src/frames/id3v2/comm_frame.dart @@ -1,7 +1,7 @@ import 'dart:convert'; -import 'package:dart_tags/src/model/comment.dart'; - +import '../../convert/utf16.dart'; +import '../../model/comment.dart'; import 'id3v2_frame.dart'; /* http://id3.org/id3v2.4.0-frames @@ -25,10 +25,16 @@ import 'id3v2_frame.dart'; class COMMFrame extends ID3V2Frame { @override Comment decodeBody(List data, Encoding enc) { - final lang = enc.decode(data.sublist(0, 3)); - final splitIndex = data.indexOf(0x00); - final description = enc.decode(data.sublist(3, splitIndex)); - final body = enc.decode(data.sublist(splitIndex + 1)); + final lang = latin1.decode(data.sublist(0, 3)); + final splitIndex = enc is UTF16 + ? indexOfSplitPattern(data, [0x00, 0x00], 3) + : data.indexOf(0x00); + final description = + splitIndex < 0 ? '' : enc.decode(data.sublist(3, splitIndex)); + final offset = splitIndex + (enc is UTF16 ? 2 : 1); + final bodyBytes = data.sublist(offset); + + final body = enc.decode(bodyBytes); return Comment(lang, description, body); } @@ -38,7 +44,7 @@ class COMMFrame extends ID3V2Frame { final enc = header?.encoding ?? utf8; return [ - ...enc.encode(frameTag), + ...latin1.encode(frameTag), ...frameSizeInBytes(value.lang.length + value.description.length + 1 + diff --git a/lib/src/frames/id3v2/default_frame.dart b/lib/src/frames/id3v2/default_frame.dart index edbd80d..14428fe 100644 --- a/lib/src/frames/id3v2/default_frame.dart +++ b/lib/src/frames/id3v2/default_frame.dart @@ -1,6 +1,6 @@ import 'dart:convert'; -import 'package:dart_tags/src/frames/id3v2/id3v2_frame.dart'; +import '../../frames/id3v2/id3v2_frame.dart'; class DefaultFrame with ID3V2Frame { final String _tag; diff --git a/lib/src/frames/id3v2/id3v2_frame.dart b/lib/src/frames/id3v2/id3v2_frame.dart index ead8dc6..5f3f826 100644 --- a/lib/src/frames/id3v2/id3v2_frame.dart +++ b/lib/src/frames/id3v2/id3v2_frame.dart @@ -1,9 +1,19 @@ import 'dart:convert'; -import 'package:dart_tags/src/convert/utf16.dart'; -import 'package:dart_tags/src/frames/frame.dart'; -import 'package:dart_tags/src/model/consts.dart' as consts; -import 'package:dart_tags/src/model/consts.dart'; +import 'package:collection/collection.dart' as collection; + +import '../../convert/hex_encoding.dart'; +import '../../convert/utf16.dart'; +import '../../frames/frame.dart'; +import '../../model/consts.dart' as consts; +import '../../model/consts.dart'; +import '../../tag_processor.dart'; + +class ID3V2ParsingException extends ParsingException { + static const fram = ''; + + ID3V2ParsingException(String cause) : super(cause); +} abstract class ID3V2Frame implements Frame { // actually 2.2 not supported yet. it should be supported wia mixins @@ -16,30 +26,25 @@ abstract class ID3V2Frame implements Frame { ID3V2FrameHeader _header; ID3V2FrameHeader get header => _header; - List clearFrameData(List bytes) { - if (bytes.length > 3 && bytes[0] == 0xFF && bytes[1] == 0xFE) { - bytes = bytes.sublist(2); - } - return bytes.where((i) => i != 0).toList(); - } - @override MapEntry decode(List data) { final tag = latin1.decode(data.sublist(0, 4)); if (!consts.framesHeaders.keys.contains(tag)) { - //print('$tag unknown tag'); return null; } final size = sizeOf(data.sublist(4, 8)); if (size <= 0) { - //print('frame size should be greater than zero'); return null; } - final encoding = getEncoding(data[headerLength]); - _header = ID3V2FrameHeader(tag, encoding, size); - //print('${data.length}, ${headerLength}, ${_header.length}'); + _header = ID3V2FrameHeader(tag, size, encoding: encoding); + + if (data.length < headerLength + _header?.length) { + _header = + ID3V2FrameHeader(tag, data.length - headerLength, encoding: encoding); + } + final body = data.sublist(headerLength + 1, headerLength + _header?.length); return MapEntry( @@ -51,14 +56,17 @@ abstract class ID3V2Frame implements Frame { @override List encode(T value, [String key]); + /// Returns size of frame in bytes List frameSizeInBytes(int value) { + assert(value <= 16777216); + final block = List(4); - final eightBitMask = 0xff; + final sevenBitMask = 0x7f; - block[0] = (value >> 24) & eightBitMask; - block[1] = (value >> 16) & eightBitMask; - block[2] = (value >> 8) & eightBitMask; - block[3] = (value >> 0) & eightBitMask; + block[0] = (value >> 21) & sevenBitMask; + block[1] = (value >> 14) & sevenBitMask; + block[2] = (value >> 7) & sevenBitMask; + block[3] = (value >> 0) & sevenBitMask; return block; } @@ -78,12 +86,13 @@ abstract class ID3V2Frame implements Frame { bool isTagValid(String tag) => tag.isNotEmpty && consts.framesHeaders.containsKey(tag); + /// Frame lenght represents as sync safe 32bit int int sizeOf(List block) { assert(block.length == 4); - var len = block[0] << 24; - len += block[1] << 16; - len += block[2] << 8; + var len = block[0] << 21; + len += block[1] << 14; + len += block[2] << 7; len += block[3]; return len; @@ -95,10 +104,27 @@ abstract class ID3V2Frame implements Frame { return latin1; case EncodingBytes.utf8: return Utf8Codec(allowMalformed: true); + case consts.EncodingBytes.utf16: + return UTF16LE(); + case consts.EncodingBytes.utf16be: + return UTF16BE(); default: - return UTF16(); + return HEXEncoding(); } } + + int indexOfSplitPattern(List list, List pattern, + [int initialOffset = 0]) { + for (var i = initialOffset ?? 0; + i < list.length - pattern.length; + i += pattern.length) { + final l = list.sublist(i, i + pattern.length); + if (collection.ListEquality().equals(l, pattern)) { + return i; + } + } + return -1; + } } /* http://id3.org/id3v2.4.0-structure @@ -136,15 +162,15 @@ abstract class ID3V2Frame implements Frame { class ID3V2FrameHeader { String tag; - Encoding encoding; int length; + Encoding encoding; + // todo: implement futher int flags; - ID3V2FrameHeader(this.tag, this.encoding, this.length, [this.flags]) { + ID3V2FrameHeader(this.tag, this.length, {this.flags, this.encoding}) { assert(consts.framesHeaders.keys.contains(tag)); - assert(encoding != null); assert(length > 0); } } diff --git a/lib/src/frames/id3v2/txxx_frame.dart b/lib/src/frames/id3v2/txxx_frame.dart index 01c2c8a..d79d6cc 100644 --- a/lib/src/frames/id3v2/txxx_frame.dart +++ b/lib/src/frames/id3v2/txxx_frame.dart @@ -1,5 +1,7 @@ import 'dart:convert'; +import '../../convert/utf16.dart'; + import 'id3v2_frame.dart'; /* http://id3.org/id3v2.4.0-frames @@ -20,42 +22,43 @@ import 'id3v2_frame.dart'; Text encoding $xx Information +4.2.6. User defined text information frame + + This frame is intended for one-string text information concerning the + audio file in a similar way to the other "T"-frames. The frame body + consists of a description of the string, represented as a terminated + string, followed by the actual string. There may be more than one + "TXXX" frame in each tag, but only one with the same description. + +
+ Text encoding $xx + Description $00 (00) + Value + */ class TXXXFrame with ID3V2Frame { @override String get frameTag => 'TXXX'; + String _customTagName; + @override MapEntry decode(List data) { - final encoding = ID3V2Frame.getEncoding(data[ID3V2Frame.headerLength]); - final tag = encoding.decode(data.sublist(0, 4)); - - if (!isTagValid(tag)) { - return null; - } - - assert(tag == frameTag); - - final len = sizeOf(data.sublist(4, 8)); - - final body = data.sublist( - ID3V2Frame.headerLength + 1, ID3V2Frame.headerLength + len); - - final splitIndex = body.indexOf(0x00); - - return MapEntry( - splitIndex == 0 - ? frameTag - : encoding.decode(body.sublist(0, splitIndex)), - decodeBody( - data.sublist(ID3V2Frame.headerLength + 1 + splitIndex + 1, - ID3V2Frame.headerLength + len), - encoding)); + final entry = super.decode(data); + return MapEntry(_customTagName ?? frameTag, entry.value); } @override String decodeBody(List data, Encoding enc) { - return enc.decode(data); + final splitIndex = enc is UTF16 + ? indexOfSplitPattern(data, [0x00, 0x00], 0) + : data.indexOf(0x00); + + _customTagName = enc.decode(data.sublist(0, splitIndex)); + final offset = splitIndex + (enc is UTF16 ? 2 : 1); + + final body = enc.decode(data.sublist(offset)); + return body; } @override diff --git a/lib/src/frames/id3v2/uslt_frame.dart b/lib/src/frames/id3v2/uslt_frame.dart new file mode 100644 index 0000000..7c3b84a --- /dev/null +++ b/lib/src/frames/id3v2/uslt_frame.dart @@ -0,0 +1,64 @@ +import 'dart:convert'; + +import '../../convert/utf16.dart'; + +import '../../model/lyrics.dart'; + +import 'id3v2_frame.dart'; + +/* http://id3.org/id3v2.4.0-frames +4.8. Unsynchronised lyrics/text transcription + + This frame contains the lyrics of the song or a text transcription of + other vocal activities. The head includes an encoding descriptor and + a content descriptor. The body consists of the actual text. The + 'Content descriptor' is a terminated string. If no descriptor is + entered, 'Content descriptor' is $00 (00) only. Newline characters + are allowed in the text. There may be more than one 'Unsynchronised + lyrics/text transcription' frame in each tag, but only one with the + same language and content descriptor. + +
+ Text encoding $xx + Language $xx xx xx + Content descriptor $00 (00) + Lyrics/text +*/ +class USLTFrame with ID3V2Frame { + @override + UnSyncLyric decodeBody(List data, Encoding enc) { + final lang = latin1.decode(data.sublist(0, 3)); + final splitIndex = enc is UTF16 + ? indexOfSplitPattern(data, [0x00, 0x00], 3) + : data.indexOf(0x00); + final description = enc.decode(data.sublist(3, splitIndex)); + final offset = splitIndex + (enc is UTF16 ? 2 : 1); + final bodyBytes = data.sublist(offset); + + final body = enc.decode(bodyBytes); + + return UnSyncLyric(lang, description, body); + } + + @override + List encode(UnSyncLyric value, [String key]) { + final enc = header?.encoding ?? utf8; + + return [ + ...latin1.encode(frameTag), + ...frameSizeInBytes(value.lang.length + + value.description.length + + 1 + + value.lyrics.length + + 1), + ...separatorBytes, + ...latin1.encode(value.lang), + ...enc.encode(value.description), + 0x00, + ...enc.encode(value.lyrics) + ]; + } + + @override + String get frameTag => 'USLT'; +} diff --git a/lib/src/frames/id3v2/wxxx_frame.dart b/lib/src/frames/id3v2/wxxx_frame.dart index af4f0c9..13f5f17 100644 --- a/lib/src/frames/id3v2/wxxx_frame.dart +++ b/lib/src/frames/id3v2/wxxx_frame.dart @@ -1,5 +1,8 @@ import 'dart:convert'; +import 'package:dart_tags/src/convert/utf16.dart'; +import 'package:dart_tags/src/model/wurl.dart'; + import 'id3v2_frame.dart'; /* http://id3.org/id3v2.4.0-frames @@ -17,12 +20,29 @@ import 'id3v2_frame.dart'; Description $00 (00) URL */ -class WXXXFrame with ID3V2Frame { +class WXXXFrame with ID3V2Frame { + @override + WURL decodeBody(List data, Encoding enc) { + final splitIndex = enc is UTF16 + ? indexOfSplitPattern(data, [0x00, 0x00], 0) + : data.indexOf(0x00); + + final description = + splitIndex < 0 ? '' : enc.decode(data.sublist(0, splitIndex)); + final offset = splitIndex + (enc is UTF16 ? 2 : 1); + + final url = latin1.decode(data.sublist(offset)); + return WURL(description, url); + } + @override - List encode(String value, [String key]) { + String get frameTag => 'WXXX'; + + @override + List encode(WURL value, [String key]) { final vBytes = [ - ...utf8.encode('$key${utf8.decode([0x00])}'), - ...latin1.encode(value) + ...utf8.encode('${value.description}${utf8.decode([0x00])}'), + ...latin1.encode(value.url) ]; return [ ...utf8.encode(frameTag), @@ -31,34 +51,4 @@ class WXXXFrame with ID3V2Frame { ...vBytes ]; } - - @override - String decodeBody(List data, Encoding enc) { - return enc.decode(data); - } - - @override - String get frameTag => 'WXXX'; - - @override - MapEntry decode(List data) { - final encoding = ID3V2Frame.getEncoding(data[ID3V2Frame.headerLength]); - final tag = encoding.decode(data.sublist(0, 4)); - - if (!isTagValid(tag)) { - return null; - } - - assert(tag == frameTag); - - final len = sizeOf(data.sublist(4, 8)); - - final body = data.sublist( - ID3V2Frame.headerLength + 1, ID3V2Frame.headerLength + len); - - final splitIndex = body.indexOf(0); - - return MapEntry( - frameTag, decodeBody(data.sublist(splitIndex + 1), latin1)); - } } diff --git a/lib/src/model/attached_picture.dart b/lib/src/model/attached_picture.dart index 9615cfe..5c8fc2e 100644 --- a/lib/src/model/attached_picture.dart +++ b/lib/src/model/attached_picture.dart @@ -1,7 +1,9 @@ import 'dart:convert'; +import 'key_entity.dart'; + /// Class describes attached picture from ID3 v2.x tags -class AttachedPicture { +class AttachedPicture implements KeyEntity { static final _picturesType = const [ 'Other', '32x32 pixels "file icon" (PNG only)', @@ -27,35 +29,39 @@ class AttachedPicture { ]; /// The byte array of image data - List imageData; + final List imageData; /// Returns image data as BASE64 string String get imageData64 => base64.encode(imageData); - /// Write image data from BASE64 string - set imageData64(String imageDataString) => - imageData = base64.decode(imageDataString); + // /// Write image data from BASE64 string + // set imageData64(String imageDataString) => + // imageData = base64.decode(imageDataString); /// The description for artwork ussualy filename - String description; + final String description; /// The image type represents as byte. - int imageTypeCode; + final int imageTypeCode; /// MIME type of image - String mime; + final String mime; /// Returns [String] representation of image type. /// /// eg. 'Band/Orchestra' or 'Cover (front)' etc... - String get imageType { - return _picturesType[imageTypeCode]; - } + String get imageType => _picturesType[imageTypeCode ?? 0x00]; + + AttachedPicture( + this.mime, this.imageTypeCode, this.description, this.imageData); + + AttachedPicture.base64( + this.mime, this.imageTypeCode, this.description, String base64Image) + : imageData = base64.decode(base64Image); @override - String toString() { - return '{mime:$mime, description:$description, bitmap: ${imageData64}}'; - } + String toString() => + '{mime:$mime, description:$description, bitmap: ${imageData64.substring(0, 32)}...}'; @override int get hashCode => super.hashCode; @@ -84,4 +90,7 @@ class AttachedPicture { } return true; } + + @override + String get key => imageType; } diff --git a/lib/src/model/comment.dart b/lib/src/model/comment.dart index 2d0e7d0..cae61a5 100644 --- a/lib/src/model/comment.dart +++ b/lib/src/model/comment.dart @@ -1,5 +1,7 @@ +import 'key_entity.dart'; + /// Class describes comment entity from ID3 v2.x tags -class Comment { +class Comment implements KeyEntity { /// 3 character of language code e.g. "eng" final String lang; @@ -32,4 +34,7 @@ class Comment { } return true; } + + @override + String get key => '$lang:$description'; } diff --git a/lib/src/model/consts.dart b/lib/src/model/consts.dart index 0723e61..051a54d 100644 --- a/lib/src/model/consts.dart +++ b/lib/src/model/consts.dart @@ -345,12 +345,10 @@ class EncodingBytes { // [ISO-8859-1]. Terminated with $00. static const latin1 = 0x00; - // [UTF-16] encoded Unicode [UNICODE] with BOM. All strings in the same frame SHALL have the same byteorder. Terminated with $00 00. (use in future) - // ignore: unused_field + // [UTF-16] encoded Unicode [UNICODE] with BOM. All strings in the same frame SHALL have the same byteorder. Terminated with $00 00. static const utf16 = 0x01; - // [UTF-16] encoded Unicode [UNICODE] without BOM. Terminated with $00 00. (use in future) - // ignore: unused_field + // [UTF-16] encoded Unicode [UNICODE] without BOM. Terminated with $00 00. static const utf16be = 0x02; // [UTF-8] encoded Unicode [UNICODE]. Terminated with $00. diff --git a/lib/src/model/key_entity.dart b/lib/src/model/key_entity.dart new file mode 100644 index 0000000..36868fc --- /dev/null +++ b/lib/src/model/key_entity.dart @@ -0,0 +1,3 @@ +abstract class KeyEntity { + K get key; +} diff --git a/lib/src/model/lyrics.dart b/lib/src/model/lyrics.dart new file mode 100644 index 0000000..bac5f6c --- /dev/null +++ b/lib/src/model/lyrics.dart @@ -0,0 +1,40 @@ +import 'package:dart_tags/src/model/key_entity.dart'; + +/// Class describes unsynchronised lyrics entity from ID3 v2.x tags +class UnSyncLyric implements KeyEntity { + /// 3 character of language code e.g. "eng" + final String lang; + + /// Description for lyrics + final String description; + + /// Lyrics body + final String lyrics; + + UnSyncLyric(this.lang, this.description, this.lyrics); + + @override + String toString() { + return '{language:$lang, description:$description, body: $lyrics'; + } + + @override + int get hashCode => super.hashCode; + + @override + bool operator ==(other) { + if (lang != other.lang) { + return false; + } + if (description != other.description) { + return false; + } + if (lyrics != other.lyrics) { + return false; + } + return true; + } + + @override + String get key => '$lang:$description'; +} diff --git a/lib/src/model/wurl.dart b/lib/src/model/wurl.dart new file mode 100644 index 0000000..095be4f --- /dev/null +++ b/lib/src/model/wurl.dart @@ -0,0 +1,34 @@ +import 'key_entity.dart'; + +/// Class describes url from WXXX tag +class WURL implements KeyEntity { + /// Description for url + final String description; + + /// url + final String url; + + WURL(this.description, this.url); + + @override + String toString() { + return '{description:$description, url: $url'; + } + + @override + int get hashCode => super.hashCode; + + @override + bool operator ==(other) { + if (description != other.description) { + return false; + } + if (url != other.url) { + return false; + } + return true; + } + + @override + String get key => description; +} diff --git a/lib/src/readers/id3v1.dart b/lib/src/readers/id3v1.dart index 44c61db..5ce01a2 100644 --- a/lib/src/readers/id3v1.dart +++ b/lib/src/readers/id3v1.dart @@ -8,30 +8,29 @@ class ID3V1Reader extends Reader { ID3V1Reader() : super('ID3', '1.1'); @override - Future> parseValues(Future> bytes) async { - var sBytes = await bytes; + Future> parseValues(List bytes) async { final tagMap = {}; - if (sBytes.length < 128) { + if (bytes.length < 128) { return tagMap; } - sBytes = sBytes.sublist(sBytes.length - 128); + bytes = bytes.sublist(bytes.length - 128); - if (latin1.decode(sBytes.sublist(0, 3)) == 'TAG') { - tagMap['title'] = latin1.decode(_clearZeros(sBytes.sublist(3, 33))); - tagMap['artist'] = latin1.decode(_clearZeros(sBytes.sublist(33, 63))); - tagMap['album'] = latin1.decode(_clearZeros(sBytes.sublist(63, 93))); - tagMap['year'] = latin1.decode(_clearZeros(sBytes.sublist(93, 97))); + if (latin1.decode(bytes.sublist(0, 3)) == 'TAG') { + tagMap['title'] = latin1.decode(_clearZeros(bytes.sublist(3, 33))); + tagMap['artist'] = latin1.decode(_clearZeros(bytes.sublist(33, 63))); + tagMap['album'] = latin1.decode(_clearZeros(bytes.sublist(63, 93))); + tagMap['year'] = latin1.decode(_clearZeros(bytes.sublist(93, 97))); - final flag = sBytes[125]; + final flag = bytes[125]; if (flag == 0) { - tagMap['comment'] = latin1.decode(_clearZeros(sBytes.sublist(97, 125))); - tagMap['track'] = sBytes[126].toString(); + tagMap['comment'] = latin1.decode(_clearZeros(bytes.sublist(97, 125))); + tagMap['track'] = bytes[126].toString(); } - final id = sBytes[127]; + final id = bytes[127]; tagMap['genre'] = id > consts.id3v1generes.length - 1 || id < 0 ? '' : consts.id3v1generes[id]; diff --git a/lib/src/readers/id3v2.dart b/lib/src/readers/id3v2.dart index 771c8d6..86ed90d 100644 --- a/lib/src/readers/id3v2.dart +++ b/lib/src/readers/id3v2.dart @@ -2,6 +2,8 @@ import 'dart:async'; import 'dart:convert'; import 'package:dart_tags/src/frames/frame.dart'; +import 'package:dart_tags/src/frames/id3v2/id3v2_frame.dart'; +import 'package:dart_tags/src/model/key_entity.dart'; import 'package:dart_tags/src/readers/reader.dart'; class ID3V2Reader extends Reader { @@ -33,20 +35,19 @@ class ID3V2Reader extends Reader { String get version => '$version_o1.$version_o2.$version_o3'; @override - Future> parseValues(Future> bytes) async { - final sBytes = await bytes; + Future> parseValues(List bytes) async { final tags = {}; - if (Utf8Codec(allowMalformed: true).decode(sBytes.sublist(0, 3)) != 'ID3') { + if (Utf8Codec(allowMalformed: true).decode(bytes.sublist(0, 3)) != 'ID3') { return tags; } - version_o2 = sBytes[3]; - version_o3 = sBytes[4]; + version_o2 = bytes[3]; + version_o3 = bytes[4]; - final ff = FrameFactory('ID3', '2.4.0'); + final ff = FrameFactory('ID3', '2.4.0'); - final flags = sBytes[5]; + final flags = bytes[5]; // ignore: unused_local_variable final unsync = flags & 0x80 != 0; @@ -55,38 +56,38 @@ class ID3V2Reader extends Reader { // ignore: unused_local_variable final experimental = flags & 0x20 != 0; - final size = _sizeOf(sBytes.sublist(6, 10)); + final size = _sizeOf(bytes.sublist(6, 10)); var offset = 10; - var end = true; + var contin = true; - while (end) { - final len = _frameSizeOf(sBytes.sublist(offset + 4, offset + 8)); - final fr = sBytes.sublist(offset); + while (contin) { + final fr = bytes.sublist(offset); - final m = ff.getFrame(fr).decode(sBytes.sublist(offset)); + final frame = ff.getFrame(fr); + final m = frame.decode(fr); - tags[m?.key] = m?.value; + if (m?.key != null && m?.value != null) { + if (m?.value is KeyEntity) { + if (tags[m.key] == null) { + tags[m.key] = {m.value.key: m.value}; + } else { + tags[m.key][m.value.key] = m.value; + } + } else { + tags[m.key] = m.value; + } + } - offset = offset + _headerLength + len; - end = offset < size; + offset = offset + _headerLength + (frame?.header?.length ?? 0); + contin = offset < size && (frame?.header?.length ?? 0) != 0; } return tags; } - int _frameSizeOf(List block) { - assert(block.length == 4); - - var len = block[0] << 24; - len += block[1] << 16; - len += block[2] << 8; - len += block[3]; - - return len; - } - + // Sync safe 32bit int int _sizeOf(List block) { assert(block.length == 4); diff --git a/lib/src/readers/reader.dart b/lib/src/readers/reader.dart index e6c4d1b..5e3bad1 100644 --- a/lib/src/readers/reader.dart +++ b/lib/src/readers/reader.dart @@ -8,17 +8,32 @@ abstract class Reader { Reader(this._type, this._version); - Future read(Future> bytes) async { - final tag = Tag() - ..tags = await parseValues(bytes) - ..type = type - ..version = version; + Future read(Future> bytes) { + final c = Completer(); + bytes.then((b) => parseValues(b).then((tags) { + final tag = Tag() + ..tags = tags + ..type = type + ..version = version; + c.complete(tag); + })); + return c.future; + } - return tag; + Future readBytes(List bytes) { + final c = Completer(); + parseValues(bytes).then((tags) { + final tag = Tag() + ..tags = tags + ..type = type + ..version = version; + c.complete(tag); + }); + return c.future; } String get version => _version; String get type => _type; - Future> parseValues(Future> bytes); + Future> parseValues(List bytes); } diff --git a/lib/src/tag_processor.dart b/lib/src/tag_processor.dart index 7b88d43..2a634ce 100644 --- a/lib/src/tag_processor.dart +++ b/lib/src/tag_processor.dart @@ -12,11 +12,14 @@ import 'package:dart_tags/src/writers/writer.dart'; enum TagType { unknown, id3v1, id3v2 } class ParsingException implements Exception { - static const byteDataNull = "Byte data can't be null"; - static const byteArrayNull = "Byte array can't be null"; + static const byteDataNull = 'Byte data can\'t be null'; + static const byteArrayNull = 'Byte array can\'t be null'; - String cause; + final cause; ParsingException(this.cause); + + @override + String toString() => '$runtimeType: $cause \n\t ${super.toString()}'; } class TagProcessor { diff --git a/lib/src/utils/image_extractor.dart b/lib/src/utils/image_extractor.dart new file mode 100644 index 0000000..758e3ab --- /dev/null +++ b/lib/src/utils/image_extractor.dart @@ -0,0 +1,98 @@ +/// Abstraction for image extraction +abstract class ImageExtractor { + List parse(List byteData); +} + +/// Abstract class that utilise SOI (start of image) and EOI (end of image) +/// patterns to determine image data in byte array +abstract class SoiEoiExtractor implements ImageExtractor { + List get soi; + List get eoi; + + @override + List parse(List byteData) { + final iter = byteData.iterator; + + var reachEOI = false; + var reachSOI = false; + + final buff = []; + + while (iter.moveNext() && !reachEOI) { + if (reachSOI) { + final b = iter.current; + buff.add(b); + if (b == eoi[0]) { + final eoiterator = eoi.iterator..moveNext(); + var fail = false; + while (eoiterator.moveNext() && !fail) { + iter.moveNext(); + buff.add(iter.current); + if (eoiterator.current != iter.current) { + fail = true; + } + } + if (!fail) { + reachEOI = true; + break; + } + } + } + + if (!reachSOI && iter.current == soi[0]) { + final soiterator = soi.iterator..moveNext(); + var fail = false; + while (soiterator.moveNext() && !fail) { + iter.moveNext(); + if (soiterator.current != iter.current) { + fail = true; + } + } + if (!fail) { + reachSOI = true; + buff.addAll(soi); + } + } + } + + return buff; + } +} + +/// JPEG extractor realisation of SoiEoiExtractor +class JPEGImageExtractor extends SoiEoiExtractor { + @override + List get soi => [0xFF, 0xD8]; + @override + List get eoi => [0xFF, 0xD9]; +} + +/// PNG extractor realisation of SoiEoiExtractor +class PNGImageExtractor extends SoiEoiExtractor { + @override + List get soi => [ + 0x89, + 0x50, + 0x4E, + 0x47, + 0x0D, + 0x0A, + 0x1A, + 0x0A, + ]; + @override + List get eoi => [ + 0x00, + 0x00, + 0x00, + 0x00, + 0x49, + 0x45, + 0x4E, + 0x44, + 0xAE, + 0x42, + 0x60, + 0x82, + ]; +} diff --git a/lib/src/writers/id3v2.dart b/lib/src/writers/id3v2.dart index 76ee752..d7c4dd9 100644 --- a/lib/src/writers/id3v2.dart +++ b/lib/src/writers/id3v2.dart @@ -14,7 +14,22 @@ class ID3V2Writer extends Writer { final ff = FrameFactory('ID3', '2.4.0'); - tag.tags.forEach((k, v) => tagsF.addAll(ff.getFrame(k)?.encode(v, k))); + tag.tags.forEach((k, v) { + if (k != null) { + final frame = ff.getFrame(k); + if (v is List) { + v.forEach((element) { + tagsF.addAll(frame?.encode(element, k)); + }); + } else if (v is Map) { + v.values.forEach((element) { + tagsF.addAll(frame?.encode(element, k)); + }); + } else { + tagsF.addAll(frame?.encode(v, k)); + } + } + }); final c = Completer>.sync() ..complete([ diff --git a/pubspec.yaml b/pubspec.yaml index c49f16a..f4267cd 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,16 +1,16 @@ name: dart_tags description: The library for work with music tags like ID3. Written on pure Dart. It can be used in flutter, web, and vm projects. -version: 0.2.3 +version: 0.3.0 homepage: https://github.com/NiKoTron/dart-tags -# author[s] is deprecated -# author: NiKoTron environment: sdk: '>=2.3.0 <3.0.0' dependencies: utf: ^0.9.0+5 + collection: ^1.14.13 + convert: ^2.1.1 dev_dependencies: - pedantic: ^1.9.0 - test: ^1.14.2 + pedantic: ^1.9.2 + test: ^1.15.3 diff --git a/test/dart_tags_test.dart b/test/dart_tags_test.dart index db68cb1..0d2c07f 100644 --- a/test/dart_tags_test.dart +++ b/test/dart_tags_test.dart @@ -3,10 +3,12 @@ import 'dart:io'; import 'dart:typed_data'; import 'package:dart_tags/dart_tags.dart'; +import 'package:collection/collection.dart' as collection; import 'package:dart_tags/src/frames/id3v2/comm_frame.dart'; import 'package:dart_tags/src/model/comment.dart'; import 'package:dart_tags/src/readers/id3v1.dart'; import 'package:dart_tags/src/readers/id3v2.dart'; +import 'package:dart_tags/src/utils/image_extractor.dart'; import 'package:dart_tags/src/writers/id3v1.dart'; import 'package:dart_tags/src/writers/id3v2.dart'; import 'package:test/test.dart'; @@ -21,7 +23,7 @@ void main() { setUp(() { file1 = File('test/test_assets/id3v1.mp3'); - file2 = File('test/test_assets/id3v24.mp3'); + file2 = File('test/test_assets/id3v24-lyric.mp3'); file3 = File('test/test_assets/id3v23.mp3'); picture = File('test/test_assets/mink-mingle-109837-unsplash.jpg'); }); @@ -123,27 +125,29 @@ void main() { group('Writer Tests', () { test('generate tag block v2.4', () async { + final com1 = Comment('eng', 'desc_here', 'lol it is a comment'); + final com2 = Comment('eng', 'desc_here_2', 'lol it is a comment'); + final pic = AttachedPicture( + 'image/jpeg', 0x03, 'foo.jpg', picture.readAsBytesSync()); final tag = Tag() ..tags = { 'title': 'foo', 'artist': 'bar', 'album': 'baz', 'year': '2010', - 'comment': Comment('eng', 'desc_here', 'lol it is a comment'), + 'comment': { + com1.key: com1, + com2.key: com2, + }, 'track': '6', 'genre': 'Dream', 'custom': 'Just a tag', - 'picture': AttachedPicture() - ..imageData = picture.readAsBytesSync() - ..imageTypeCode = 0x03 - ..mime = 'image/jpeg' - ..description = 'foo.jpg' + 'picture': {pic.key: pic}, } ..type = 'ID3' ..version = '2.4'; final writer = ID3V2Writer(); - final blocks = writer.write(await file2.readAsBytes(), tag); final r = ID3V2Reader(); @@ -190,13 +194,14 @@ void main() { ..type = 'ID3' ..version = '1.1'; + final com1 = Comment('eng', 'desc', 'lol it is a comment'); final tag2 = Tag() ..tags = { 'title': 'foo', 'artist': 'bar', 'album': 'baz', 'year': '2010', - 'comment': Comment('eng', 'desc', 'lol it is a comment'), + 'comment': {com1.key: com1}, 'track': '6', 'genre': 'Dream', 'Custom': 'Just tag' @@ -346,6 +351,32 @@ void main() { }); }); + group('Image extract', () { + test('jpeg extract', () { + final bytes = file2.readAsBytesSync(); + + final p = JPEGImageExtractor(); + final img = p.parse(bytes); + + assert(collection.ListEquality() + .equals(img.sublist(0, p.soi.length), p.soi)); + assert(collection.ListEquality() + .equals(img.sublist(img.length - p.eoi.length), p.eoi)); + }); + + test('png extract', () { + final bytes = file2.readAsBytesSync(); + + final p = PNGImageExtractor(); + final img = p.parse(bytes); + + assert(collection.ListEquality() + .equals(img.sublist(0, p.soi.length), p.soi)); + assert(collection.ListEquality() + .equals(img.sublist(img.length - p.eoi.length), p.eoi)); + }); + }); + group('Issues test', () { //https://github.com/NiKoTron/dart-tags/issues/4 test('Artist tag restriction on characters [#4]', () async { @@ -383,11 +414,8 @@ void main() { //https://github.com/NiKoTron/dart-tags/issues/3 test('Example for writing APIC tags [#3] ', () async { - final pic1 = AttachedPicture() - ..imageData = picture.readAsBytesSync() - ..imageTypeCode = 0x03 - ..mime = 'image/jpeg' - ..description = 'foo.jpg'; + final pic1 = AttachedPicture( + 'image/jpeg', 0x03, 'foo.jpg', picture.readAsBytesSync()); final tag = Tag() ..tags = {'picture': pic1} @@ -406,7 +434,8 @@ void main() { final r = ID3V2Reader(); final f = await r.read(blocks); - final AttachedPicture pic = f.tags['picture']; + // ignore: avoid_as + final AttachedPicture pic = (f.tags['picture'] as Map).values.first; File('$outputDir/${pic.description}.jpg') ..createSync(recursive: true) @@ -440,6 +469,8 @@ void main() { expect(tags[0].tags.isNotEmpty, true); expect(tags[0].tags.containsKey('artist'), true); expect(tags[0].tags['artist'], expectedArtist); + expect(tags[0].tags.containsKey('TYER'), true); + expect(tags[0].tags['TYER'], '2010'); }); }); } diff --git a/test/test_assets/id3v24-lyric.mp3 b/test/test_assets/id3v24-lyric.mp3 new file mode 100644 index 0000000..01d29c3 Binary files /dev/null and b/test/test_assets/id3v24-lyric.mp3 differ diff --git a/test/test_assets/id3v24.mp3 b/test/test_assets/id3v24.mp3 index 5601624..630883a 100644 Binary files a/test/test_assets/id3v24.mp3 and b/test/test_assets/id3v24.mp3 differ