mirror of
https://github.com/SineVector241/VoiceCraft-MCBE_Proximity_Chat.git
synced 2024-11-20 10:27:45 +00:00
2424 lines
113 KiB
C#
2424 lines
113 KiB
C#
using System;
|
||
using System.IO;
|
||
using System.Text;
|
||
using System.Collections.Generic;
|
||
using Commons;
|
||
using ATL.Logging;
|
||
using static ATL.TagData;
|
||
|
||
namespace ATL.AudioData.IO
|
||
{
|
||
/// <summary>
|
||
/// Class for ID3v2.2-2.4 tags manipulation
|
||
///
|
||
/// Implementation notes
|
||
///
|
||
/// 1. Extended header tags
|
||
///
|
||
/// Due to the rarity of ID3v2 tags with extended headers (on my disk and on the web),
|
||
/// implementation of decoding extended header data has been tested on _forged_ files. Implementation might not be 100% real-world proof.
|
||
///
|
||
/// 2. Hierarchical table of contents (CTOC)
|
||
///
|
||
/// ID3v2 chapters specification allows multiple CTOC frames in the tag, in order to describe a multiple-level table of contents.
|
||
/// (see informal standard id3v2-chapters-1.0.html)
|
||
///
|
||
/// This feature is currently not supported. If any CTOC is detected while reading, ATL will "blindly" write a flat CTOC containing
|
||
/// all chapters. Any hierarchical table of contents will be lost while rewriting.
|
||
///
|
||
/// 3. Unsynchronization and Unicode
|
||
///
|
||
/// Little-endian UTF-16 BOM's are caught by the unsynchronization encoding, which "breaks" most tag readers.
|
||
/// Hence unsycnhronization is force-disabled when text encoding is Unicode.
|
||
///
|
||
/// 4. Unsynchronization at frame level
|
||
///
|
||
/// Even though ID3v2.4 allows it, ATL does not support "individual" unsynchronization at frame level
|
||
/// => Either the whole tag (all frames) is unsynchronized, or none is
|
||
///
|
||
/// 5. Prepended tag
|
||
///
|
||
/// Even though specs allow ID3v2.4 tags to be located at the end of the file, I have yet to find a valid sample.
|
||
/// => Prepended tags are not supported until someone asks for it.
|
||
///
|
||
/// 6. Extended support for GEOB fields
|
||
///
|
||
/// Current support for General Encapsulated Object (GEOB) fields are simplified that way :
|
||
/// - MIME-type is always "application/octet-stream" (read time + write time)
|
||
/// - File name is always empty (read time + write time)
|
||
/// - Frame encoding is always Latin-1 (write-time only)
|
||
/// </summary>
|
||
public class ID3v2 : MetaDataIO
|
||
{
|
||
/// <summary>
|
||
/// ID3v2.2
|
||
/// </summary>
|
||
public const byte TAG_VERSION_2_2 = 2;
|
||
|
||
/// <summary>
|
||
/// ID3v2.3
|
||
/// </summary>
|
||
public const byte TAG_VERSION_2_3 = 3;
|
||
|
||
/// <summary>
|
||
/// ID3v2.4
|
||
/// </summary>
|
||
public const byte TAG_VERSION_2_4 = 4;
|
||
|
||
private TagInfo tagHeader;
|
||
|
||
|
||
// Technical 'shortcut' data
|
||
private static readonly byte[] BOM_UTF16_LE = { 0xFF, 0xFE };
|
||
private static readonly byte[] BOM_UTF16_BE = { 0xFE, 0xFF };
|
||
private static readonly byte[] BOM_NONE = Array.Empty<byte>();
|
||
|
||
private static readonly byte[] NULLTERMINATOR = { 0x00 };
|
||
private static readonly byte[] NULLTERMINATOR_2 = { 0x00, 0x00 };
|
||
|
||
// Tag flags
|
||
private const byte FLAG_TAG_UNSYNCHRONIZED = 0b10000000;
|
||
private const byte FLAG_TAG_HAS_EXTENDED_HEADER = 0b01000000;
|
||
private const byte FLAG_TAG_HAS_FOOTER = 0b00010000;
|
||
|
||
// Supported frame flags
|
||
private const short FLAG_FRAME_24_UNSYNCHRONIZED = 0b0000000000000010; // ID3v2.4 only
|
||
private const short FLAG_FRAME_24_HAS_DATA_LENGTH_INDICATOR = 0b0000000000000001; // ID3v2.4 only
|
||
|
||
// Value separators
|
||
private const char VALUE_SEPARATOR_22 = '/';
|
||
private const char VALUE_SEPARATOR_24 = '\0';
|
||
|
||
// ID3v2 tag ID
|
||
private const string ID3V2_ID = "ID3";
|
||
|
||
// Mapping between revisions and allowed encodings
|
||
private static readonly IDictionary<int, ISet<Encoding>> allowedEncodings = new Dictionary<int, ISet<Encoding>>
|
||
{
|
||
{ TAG_VERSION_2_2, new HashSet<Encoding> { Utils.Latin1Encoding, Encoding.Unicode } },
|
||
{ TAG_VERSION_2_3, new HashSet<Encoding> { Utils.Latin1Encoding, Encoding.Unicode } },
|
||
{ TAG_VERSION_2_4, new HashSet<Encoding> { Utils.Latin1Encoding, Encoding.Unicode, Encoding.UTF8, Encoding.BigEndianUnicode } }
|
||
};
|
||
|
||
// List of standard fields
|
||
|
||
#pragma warning disable S125 // Sections of code should not be commented out
|
||
//private static readonly ICollection<string> standardFrames_v22 = new List<string>() { "BUF", "CNT", "COM", "CRA", "CRM", "ETC", "EQU", "GEO", "IPL", "LNK", "MCI", "MLL", "PIC", "POP", "REV", "RVA", "SLT", "STC", "TAL", "TBP", "TCM", "TCO", "TCR", "TDA", "TDY", "TEN", "TFT", "TIM", "TKE", "TLA", "TLE", "TMT", "TOA", "TOF", "TOL", "TOR", "TOT", "TP1", "TP2", "TP3", "TP4", "TPA", "TPB", "TRC", "TRD", "TRK", "TSI", "TSS", "TT1", "TT2", "TT3", "TXT", "TXX", "TYE", "UFI", "ULT", "WAF", "WAR", "WAS", "WCM", "WCP", "WPB", "WXX" };
|
||
#pragma warning restore S125 // Sections of code should not be commented out
|
||
private static readonly ICollection<string> standardFrames_v23 = new HashSet<string> { "AENC", "APIC", "COMM", "COMR", "ENCR", "EQUA", "ETCO", "GEOB", "GRID", "IPLS", "LINK", "MCDI", "MLLT", "OWNE", "PRIV", "PCNT", "POPM", "POSS", "RBUF", "RVAD", "RVRB", "SYLT", "SYTC", "TALB", "TBPM", "TCOM", "TCON", "TCOP", "TDAT", "TDLY", "TENC", "TEXT", "TFLT", "TIME", "TIT1", "TIT2", "TIT3", "TKEY", "TLAN", "TLEN", "TMED", "TOAL", "TOFN", "TOLY", "TOPE", "TORY", "TOWN", "TPE1", "TPE2", "TPE3", "TPE4", "TPOS", "TPUB", "TRCK", "TRDA", "TRSN", "TRSO", "TSIZ", "TSRC", "TSSE", "TYER", "TXXX", "UFID", "USER", "USLT", "WCOM", "WCOP", "WOAF", "WOAR", "WOAS", "WORS", "WPAY", "WPUB", "WXXX", "CHAP", "CTOC" };
|
||
private static readonly ICollection<string> standardFrames_v24 = new HashSet<string> { "AENC", "APIC", "ASPI", "COMM", "COMR", "ENCR", "EQU2", "ETCO", "GEOB", "GRID", "LINK", "MCDI", "MLLT", "OWNE", "PRIV", "PCNT", "POPM", "POSS", "RBUF", "RVA2", "RVRB", "SEEK", "SIGN", "SYLT", "SYTC", "TALB", "TBPM", "TCOM", "TCON", "TCOP", "TDEN", "TDLY", "TDOR", "TDRC", "TDRL", "TDTG", "TENC", "TEXT", "TFLT", "TIPL", "TIT1", "TIT2", "TIT3", "TKEY", "TLAN", "TLEN", "TMCL", "TMED", "TMOO", "TOAL", "TOFN", "TOLY", "TOPE", "TORY", "TOWN", "TPE1", "TPE2", "TPE3", "TPE4", "TPOS", "TPRO", "TPUB", "TRCK", "TRSN", "TRSO", "TSOA", "TSOP", "TSOT", "TSRC", "TSSE", "TSST", "TXXX", "UFID", "USER", "USLT", "WCOM", "WCOP", "WOAF", "WOAR", "WOAS", "WORS", "WPAY", "WPUB", "WXXX", "CHAP", "CTOC",
|
||
"RGAD", "TCMP", "TSO2", "TSOC", "XRVA" // Unoffical fields used by modern apps like itunes & Musicbrainz - see "Unofficial Frames Seen in the Wild" on ID3.org
|
||
};
|
||
|
||
// Field codes that need to be persisted in a COMMENT field
|
||
private static readonly ISet<string> commentsFields = new HashSet<string> { "iTunNORM", "iTunSMPB", "iTunPGAP" };
|
||
|
||
// Fields where text encoding descriptor byte is not required
|
||
private static readonly ISet<string> noTextEncodingFields = new HashSet<string> { "POPM", "WCOM", "WCOP", "WOAF", "WOAR", "WOAS", "WORS", "WPAY", "WPUB" };
|
||
|
||
// Fields which ID3v2.4 size descriptor is known to be misencoded by some implementation
|
||
private static readonly ISet<string> misencodedSizev4Fields = new HashSet<string> { "CTOC" };
|
||
|
||
// Fields allowed to have multiple values according to ID3v2.2-3 specs
|
||
private static readonly ISet<string> multipleValuev23Fields = new HashSet<string> { "TP1", "TCM", "TXT", "TLA", "TOA", "TOL", "TCOM", "TEXT", "TOLY", "TOPE", "TPE1" };
|
||
|
||
// Note on date field identifiers
|
||
//
|
||
// Original release date
|
||
// ID3v2.2 : TOR (year only)
|
||
// ID3v2.3 : TORY (year only)
|
||
// ID3v2.4 : TDOR (timestamp according to spec)
|
||
//
|
||
// Release date
|
||
// ID3v2.2 : no standard
|
||
// ID3v2.3 : no standard
|
||
// ID3v2.4 : TDRL (timestamp according to spec; actual content may vary)
|
||
//
|
||
// Recording date <== de facto standard behind the "date" field on most taggers
|
||
// ID3v2.2 : TYE (year), TDA (day & month - DDMM), TIM (hour & minute - HHMM)
|
||
// ID3v2.3 : TYER (year), TDAT (day & month - DDMM), TIME (hour & minute - HHMM)
|
||
// NB : Some loose implementations actually use TDRC inside ID3v2.3 headers (MediaMonkey, I'm looking at you...)
|
||
// ID3v2.4 : TDRC (timestamp)
|
||
|
||
// Mapping between standard fields and ID3v2.2 identifiers
|
||
private static readonly IDictionary<string, Field> frameMapping_v22 = new Dictionary<string, Field>
|
||
{
|
||
{ "TT1", Field.GENERAL_DESCRIPTION },
|
||
{ "TT2", Field.TITLE },
|
||
{ "TP1", Field.ARTIST },
|
||
{ "TP2", Field.ALBUM_ARTIST }, // De facto standard, regardless of spec
|
||
{ "TP3", Field.CONDUCTOR },
|
||
{ "TOA", Field.ORIGINAL_ARTIST },
|
||
{ "TAL", Field.ALBUM },
|
||
{ "TOT", Field.ORIGINAL_ALBUM },
|
||
{ "TRK", Field.TRACK_NUMBER_TOTAL },
|
||
{ "TPA", Field.DISC_NUMBER_TOTAL },
|
||
{ "TYE", Field.RECORDING_YEAR },
|
||
{ "TDA", Field.RECORDING_DAYMONTH },
|
||
{ "TIM", Field.RECORDING_TIME },
|
||
{ "COM", Field.COMMENT },
|
||
{ "TCM", Field.COMPOSER },
|
||
{ "POP", Field.RATING },
|
||
{ "TCO", Field.GENRE },
|
||
{ "TCR", Field.COPYRIGHT },
|
||
{ "TPB", Field.PUBLISHER },
|
||
{ "TBP", Field.BPM },
|
||
{ "TEN", Field.ENCODED_BY },
|
||
{ "TOR", Field.ORIG_RELEASE_YEAR },
|
||
{ "TSS", Field.ENCODER },
|
||
{ "TLA", Field.LANGUAGE },
|
||
{ "TRC", Field.ISRC },
|
||
{ "WAS", Field.AUDIO_SOURCE_URL },
|
||
{ "TXT", Field.LYRICIST },
|
||
{ "IPL", Field.INVOLVED_PEOPLE }
|
||
};
|
||
|
||
// Mapping between standard fields and ID3v2.3 identifiers
|
||
private static readonly IDictionary<string, Field> frameMapping_v23 = new Dictionary<string, Field>
|
||
{
|
||
{ "TIT2", Field.TITLE },
|
||
{ "TPE1", Field.ARTIST },
|
||
{ "TPE2", Field.ALBUM_ARTIST }, // De facto standard, regardless of spec
|
||
{ "TPE3", Field.CONDUCTOR },
|
||
{ "TOPE", Field.ORIGINAL_ARTIST },
|
||
{ "TALB", Field.ALBUM },
|
||
{ "TOAL", Field.ORIGINAL_ALBUM },
|
||
{ "TRCK", Field.TRACK_NUMBER_TOTAL },
|
||
{ "TPOS", Field.DISC_NUMBER_TOTAL },
|
||
{ "TYER", Field.RECORDING_YEAR },
|
||
{ "TDAT", Field.RECORDING_DAYMONTH },
|
||
{ "TDRC", Field.RECORDING_DATE }, // Not part of ID3v2.3 standard, but sometimes found there anyway (MediaMonkey, I'm looking at you...)
|
||
{ "TIME", Field.RECORDING_TIME },
|
||
{ "COMM", Field.COMMENT },
|
||
{ "TCOM", Field.COMPOSER },
|
||
{ "POPM", Field.RATING },
|
||
{ "TCON", Field.GENRE },
|
||
{ "TCOP", Field.COPYRIGHT },
|
||
{ "TPUB", Field.PUBLISHER },
|
||
{ "CTOC", Field.CHAPTERS_TOC_DESCRIPTION },
|
||
{ "TSOA", Field.SORT_ALBUM },
|
||
{ "TSO2", Field.SORT_ALBUM_ARTIST }, // Not part of ID3v2.3 standard
|
||
{ "TSOP", Field.SORT_ARTIST },
|
||
{ "TSOT", Field.SORT_TITLE },
|
||
{ "TIT1", Field.GROUP },
|
||
{ "MVIN", Field.SERIES_PART}, // Not part of ID3v2.3 standard
|
||
{ "MVNM", Field.SERIES_TITLE }, // Not part of ID3v2.3 standard
|
||
{ "TDES", Field.LONG_DESCRIPTION }, // Not part of ID3v2.3 standard
|
||
{ "TBPM", Field.BPM },
|
||
{ "TENC", Field.ENCODED_BY },
|
||
{ "TORY", Field.ORIG_RELEASE_YEAR},
|
||
{ "TSSE", Field.ENCODER },
|
||
{ "TLAN", Field.LANGUAGE },
|
||
{ "TSRC", Field.ISRC },
|
||
{ "CATALOGNUMBER", Field.CATALOG_NUMBER },
|
||
{ "WOAS", Field.AUDIO_SOURCE_URL },
|
||
{ "TEXT", Field.LYRICIST },
|
||
{ "IPLS", Field.INVOLVED_PEOPLE }
|
||
};
|
||
|
||
// Mapping between standard fields and ID3v2.4 identifiers
|
||
private static readonly IDictionary<string, Field> frameMapping_v24 = new Dictionary<string, Field>
|
||
{
|
||
{ "TIT2", Field.TITLE },
|
||
{ "TPE1", Field.ARTIST },
|
||
{ "TPE2", Field.ALBUM_ARTIST }, // De facto standard, regardless of spec
|
||
{ "TPE3", Field.CONDUCTOR },
|
||
{ "TOPE", Field.ORIGINAL_ARTIST },
|
||
{ "TALB", Field.ALBUM },
|
||
{ "TOAL", Field.ORIGINAL_ALBUM },
|
||
{ "TRCK", Field.TRACK_NUMBER_TOTAL },
|
||
{ "TPOS", Field.DISC_NUMBER_TOTAL },
|
||
{ "TDRC", Field.RECORDING_DATE },
|
||
{ "COMM", Field.COMMENT },
|
||
{ "TCOM", Field.COMPOSER },
|
||
{ "POPM", Field.RATING },
|
||
{ "TCON", Field.GENRE },
|
||
{ "TCOP", Field.COPYRIGHT },
|
||
{ "TPUB", Field.PUBLISHER },
|
||
{ "CTOC", Field.CHAPTERS_TOC_DESCRIPTION },
|
||
{ "TDRL", Field.PUBLISHING_DATE },
|
||
{ "TSOA", Field.SORT_ALBUM },
|
||
{ "TSO2", Field.SORT_ALBUM_ARTIST }, // Not part of ID3v2.4 standard
|
||
{ "TSOP", Field.SORT_ARTIST },
|
||
{ "TSOT", Field.SORT_TITLE },
|
||
{ "TIT1", Field.GROUP },
|
||
{ "MVIN", Field.SERIES_PART}, // Not part of ID3v2.4 standard
|
||
{ "MVNM", Field.SERIES_TITLE }, // Not part of ID3v2.4 standard
|
||
{ "TDES", Field.LONG_DESCRIPTION }, // Not part of ID3v2.4 standard
|
||
{ "TBPM", Field.BPM },
|
||
{ "TENC", Field.ENCODED_BY },
|
||
{ "TDOR", Field.ORIG_RELEASE_DATE },
|
||
{ "TSSE", Field.ENCODER },
|
||
{ "TLAN", Field.LANGUAGE},
|
||
{ "TSRC", Field.ISRC},
|
||
{ "CATALOGNUMBER", Field.CATALOG_NUMBER },
|
||
{ "WOAS", Field.AUDIO_SOURCE_URL},
|
||
{ "TEXT", Field.LYRICIST },
|
||
{ "TIPL", Field.INVOLVED_PEOPLE }
|
||
};
|
||
|
||
// Mapping between ID3v2.2/3 fields and ID3v2.4 fields not included in frameMapping_v2x, and that have changed between versions
|
||
private static readonly IDictionary<string, string> frameMapping_v22_4 = new Dictionary<string, string>
|
||
{
|
||
{ "BUF", "RBUF" },
|
||
{ "CNT", "PCNT" },
|
||
{ "CRA", "AENC" },
|
||
// CRM / Encrypted meta frame field has been droppped
|
||
{ "ETC", "ETCO" },
|
||
{ "EQU", "EQU2" },
|
||
{ "GEO", "GEOB" },
|
||
{ "IPL", "TIPL" },
|
||
{ "LNK", "LINK" },
|
||
{ "MCI", "MCDI" },
|
||
{ "MLL", "MLLT" },
|
||
{ "REV", "RVRB" },
|
||
{ "RVA", "RVA2" },
|
||
{ "SLT", "SYLT" },
|
||
{ "STC", "SYTC" },
|
||
{ "TBP", "TBPM" },
|
||
{ "TDY", "TDLY" },
|
||
{ "TEN", "TENC" },
|
||
{ "TFT", "TFLT" },
|
||
{ "TKE", "TKEY" },
|
||
{ "TLA", "TLAN" },
|
||
{ "TLE", "TLEN" },
|
||
{ "TMT", "TMED" },
|
||
{ "TOF", "TOFN" },
|
||
{ "TOL", "TOLY" },
|
||
{ "TP4", "TPE4" },
|
||
{ "TPA", "TPOS" },
|
||
{ "TRC", "TSRC" },
|
||
//{ "TRD", "" } no direct equivalent
|
||
// TSI / Size field has been dropped
|
||
{ "TSS", "TSSE" },
|
||
{ "TT3", "TIT3" },
|
||
{ "TXT", "TEXT" },
|
||
{ "TXX", "TXXX" },
|
||
{ "UFI", "UFID" },
|
||
{ "ULT", "USLT" },
|
||
{ "WAF", "WOAF" },
|
||
{ "WAR", "WOAR" },
|
||
{ "WAS", "WOAS" },
|
||
{ "WCM", "WCOM" },
|
||
{ "WCP", "WCOP" },
|
||
{ "WPB", "WPUB" },
|
||
{ "WXX", "WXXX" }
|
||
// TYE, TDA and TIM are converted on the fly when writing
|
||
};
|
||
private static readonly IDictionary<string, string> frameMapping_v23_4 = new Dictionary<string, string>
|
||
{
|
||
{ "EQUA", "EQU2" },
|
||
{ "IPLS", "TIPL" },
|
||
{ "RVAD", "RVA2" },
|
||
{ "TORY", "TDOR" } // yyyy is a valid timestamp
|
||
// TYER, TDAT and TIME are converted on the fly when writing
|
||
};
|
||
|
||
// Mapping between ID3v2.2/4 fields and ID3v2.3 fields not included in frameMapping_v2x, and that have changed between versions (populated at runtime)
|
||
private static readonly IDictionary<string, string> frameMapping_v22_3 = new Dictionary<string, string>();
|
||
private static readonly IDictionary<string, string> frameMapping_v24_3 = new Dictionary<string, string>();
|
||
|
||
// Frame header (universal)
|
||
private sealed class FrameHeader
|
||
{
|
||
public string ID; // Frame ID
|
||
public int Size; // Size excluding header
|
||
public ushort Flags; // Flags
|
||
}
|
||
|
||
// ID3v2 header data - for internal use
|
||
private sealed class TagInfo
|
||
{
|
||
// Real structure of ID3v2 header
|
||
public byte[] ID = new byte[3]; // Always "ID3"
|
||
public byte Version; // Version number
|
||
public byte Revision; // Revision number
|
||
public byte Flags; // Flags of tag
|
||
public byte[] Size = new byte[4]; // Tag size excluding header
|
||
// Extended data
|
||
public long FileSize; // File size (bytes)
|
||
public long HeaderEnd; // End position of header
|
||
public long PaddingOffset = -1;
|
||
public long ActualEnd; // End position of entire tag (including all padding bytes)
|
||
|
||
// Extended header flags
|
||
public int ExtendedHeaderSize;
|
||
public int ExtendedFlags;
|
||
public int CRC = -1;
|
||
public int TagRestrictions = -1;
|
||
|
||
|
||
// **** BASE HEADER PROPERTIES ****
|
||
public bool UsesUnsynchronisation => (Flags & FLAG_TAG_UNSYNCHRONIZED) > 0;
|
||
|
||
public bool HasExtendedHeader => (Flags & FLAG_TAG_HAS_EXTENDED_HEADER) > 0 && Version > TAG_VERSION_2_2; // Determinated from flags; indicates if tag has an extended header (ID3v2.3+)
|
||
|
||
private bool HasFooter => (Flags & FLAG_TAG_HAS_FOOTER) > 0; // Determinated from flags; indicates if tag has a footer (ID3v2.4+)
|
||
|
||
public int GetSize(bool includeFooter = true)
|
||
{
|
||
// Get total tag size
|
||
int result = StreamUtils.DecodeSynchSafeInt32(Size) + 10; // 10 being the size of the header
|
||
|
||
if (includeFooter && HasFooter) result += 10; // Indicates the presence of a footer (ID3v2.4+)
|
||
|
||
if (result > FileSize) result = 0;
|
||
|
||
return result;
|
||
}
|
||
public long GetPaddingSize()
|
||
{
|
||
return ActualEnd - PaddingOffset;
|
||
}
|
||
|
||
|
||
// **** EXTENDED HEADER PROPERTIES ****
|
||
#pragma warning disable S2437 // Silly bit operations should not be performed
|
||
public int TagFramesRestriction
|
||
{
|
||
get
|
||
{
|
||
return ((TagRestrictions & 0xC0) >> 6) switch
|
||
{
|
||
0 => 128,
|
||
1 => 64,
|
||
2 => 32,
|
||
3 => 32,
|
||
_ => -1
|
||
};
|
||
}
|
||
}
|
||
public int TagSizeRestrictionKB
|
||
{
|
||
get
|
||
{
|
||
return ((TagRestrictions & 0xC0) >> 6) switch
|
||
{
|
||
0 => 1024,
|
||
1 => 128,
|
||
2 => 40,
|
||
3 => 4,
|
||
_ => -1
|
||
};
|
||
}
|
||
}
|
||
|
||
// public bool HasTextEncodingRestriction => (TagRestrictions & 0x20) >> 5 > 0;
|
||
|
||
public int TextFieldSizeRestriction
|
||
{
|
||
get
|
||
{
|
||
return ((TagRestrictions & 0x18) >> 3) switch
|
||
{
|
||
0 => -1,
|
||
1 => 1024,
|
||
2 => 128,
|
||
3 => 30,
|
||
_ => -1
|
||
};
|
||
}
|
||
}
|
||
public bool HasPictureEncodingRestriction => (TagRestrictions & 0x04) >> 2 > 0;
|
||
|
||
public int PictureSizeRestriction
|
||
{
|
||
get
|
||
{
|
||
return (TagRestrictions & 0x03) switch
|
||
{
|
||
0 => -1, // No restriction
|
||
1 => 256, // 256x256 or less
|
||
2 => 63, // 64x64 or less
|
||
3 => 64, // Exactly 64x64
|
||
_ => -1
|
||
};
|
||
}
|
||
}
|
||
}
|
||
#pragma warning restore S2437 // Silly bit operations should not be performed
|
||
|
||
// Unicode BOM properties
|
||
private sealed class BomProperties
|
||
{
|
||
public bool Found = false; // BOM found
|
||
public int Size = 0; // Size of BOM
|
||
public Encoding Encoding; // Corresponding encoding
|
||
}
|
||
|
||
private sealed class RichStructure
|
||
{
|
||
// Comments and unsynch'ed lyrics
|
||
public string LanguageCode;
|
||
public string ContentDescriptor;
|
||
public int Size;
|
||
// Synch'ed lyrics
|
||
public byte TimestampFormat;
|
||
public byte ContentType;
|
||
}
|
||
|
||
|
||
// --------------- OPTIONAL INFORMATIVE OVERRIDES
|
||
|
||
/// <inheritdoc/>
|
||
public override IList<Format> MetadataFormats
|
||
{
|
||
get
|
||
{
|
||
Format format = new Format(MetaDataIOFactory.GetInstance().getFormatsFromPath("id3v2")[0]);
|
||
format.Name = format.Name + "." + m_tagVersion;
|
||
format.ID += m_tagVersion;
|
||
return new List<Format>(new[] { format });
|
||
}
|
||
}
|
||
|
||
|
||
// --------------- MANDATORY INFORMATIVE OVERRIDES
|
||
|
||
/// <inheritdoc/>
|
||
protected override int getDefaultTagOffset()
|
||
{
|
||
return TO_BOF; // Specs allow the ID3v2 tag to be located at the end of the audio (see <20>5 "Tag location" of specs)
|
||
}
|
||
|
||
/// <inheritdoc/>
|
||
protected override MetaDataIOFactory.TagType getImplementedTagType()
|
||
{
|
||
return MetaDataIOFactory.TagType.ID3V2;
|
||
}
|
||
|
||
/// <inheritdoc/>
|
||
public override byte FieldCodeFixedLength => 0; // Actually 3 or 4 when strictly applying ID3v2.3 / ID3v2.4 specs, but thanks to TXXX fields, any code is supported
|
||
|
||
|
||
// ********************* Auxiliary functions & voids ********************
|
||
|
||
#pragma warning disable S3963 // "static" fields should be initialized inline; removed because this initialization is dynamic and can't be done inline as there already are inline initializers
|
||
static ID3v2()
|
||
{
|
||
foreach (string s in frameMapping_v22_4.Keys)
|
||
{
|
||
frameMapping_v22_3.Add(s, frameMapping_v22_4[s]);
|
||
}
|
||
|
||
foreach (string s in frameMapping_v23_4.Keys)
|
||
{
|
||
frameMapping_v24_3.Add(frameMapping_v23_4[s], s);
|
||
if (frameMapping_v22_3.ContainsKey(frameMapping_v23_4[s])) frameMapping_v22_3[frameMapping_v23_4[s]] = s;
|
||
}
|
||
}
|
||
#pragma warning restore S3963 // "static" fields should be initialized inline
|
||
|
||
/// <summary>
|
||
/// Constructor
|
||
/// </summary>
|
||
public ID3v2()
|
||
{
|
||
ResetData();
|
||
}
|
||
|
||
/// <inheritdoc/>
|
||
protected override Field getFrameMapping(string zone, string ID, byte tagVersion)
|
||
{
|
||
Field supportedMetaId = Field.NO_FIELD;
|
||
|
||
if (ID.Length < 5) ID = ID.ToUpper(); // Preserve the case of non-standard ID3v2 fields -- TODO : use the TagData.Origin property !
|
||
|
||
// Finds the ATL field identifier according to the ID3v2 version
|
||
switch (tagVersion)
|
||
{
|
||
case TAG_VERSION_2_2:
|
||
if (frameMapping_v22.TryGetValue(ID, out var value)) supportedMetaId = value;
|
||
break;
|
||
case TAG_VERSION_2_3:
|
||
if (frameMapping_v23.TryGetValue(ID, out var value1)) supportedMetaId = value1;
|
||
break;
|
||
case TAG_VERSION_2_4:
|
||
if (frameMapping_v24.TryGetValue(ID, out var value2)) supportedMetaId = value2;
|
||
break;
|
||
}
|
||
|
||
return supportedMetaId;
|
||
}
|
||
|
||
/// <inheritdoc/>
|
||
protected override bool canHandleNonStandardField(string code, string value)
|
||
{
|
||
return true; // Will be transformed to a TXXX field
|
||
}
|
||
|
||
internal static bool IsValidHeader(byte[] data)
|
||
{
|
||
if (data.Length < 3) return false;
|
||
return Utils.Latin1Encoding.GetString(data).StartsWith(ID3V2_ID);
|
||
}
|
||
|
||
private static bool readHeader(BufferedBinaryReader SourceFile, TagInfo Tag, long offset)
|
||
{
|
||
// Reads mandatory (base) header
|
||
SourceFile.Seek(offset, SeekOrigin.Begin);
|
||
Tag.ID = SourceFile.ReadBytes(3);
|
||
|
||
if (!IsValidHeader(Tag.ID)) return false;
|
||
|
||
Tag.Version = SourceFile.ReadByte();
|
||
Tag.Revision = SourceFile.ReadByte();
|
||
Tag.Flags = SourceFile.ReadByte();
|
||
|
||
// ID3v2 tag size
|
||
Tag.Size = SourceFile.ReadBytes(4);
|
||
|
||
// Reads optional (extended) header
|
||
if (Tag.HasExtendedHeader)
|
||
{
|
||
Tag.ExtendedHeaderSize = StreamUtils.DecodeSynchSafeInt(SourceFile.ReadBytes(4)); // Extended header size
|
||
SourceFile.Seek(1, SeekOrigin.Current); // Number of flag bytes; always 1 according to spec
|
||
|
||
Tag.ExtendedFlags = SourceFile.ReadByte();
|
||
|
||
if ((Tag.ExtendedFlags & 64) > 0) // Tag is an update
|
||
{
|
||
// This flag is informative and has no corresponding data
|
||
}
|
||
if ((Tag.ExtendedFlags & 32) > 0) // CRC present
|
||
{
|
||
Tag.CRC = StreamUtils.DecodeSynchSafeInt(SourceFile.ReadBytes(5));
|
||
}
|
||
if ((Tag.ExtendedFlags & 16) > 0) // Tag has at least one restriction
|
||
{
|
||
Tag.TagRestrictions = SourceFile.ReadByte();
|
||
}
|
||
}
|
||
Tag.HeaderEnd = SourceFile.Position;
|
||
|
||
// File size
|
||
Tag.FileSize = SourceFile.Length;
|
||
|
||
return true;
|
||
}
|
||
|
||
private static RichStructure readCommentStructure(
|
||
BufferedBinaryReader source,
|
||
int tagVersion,
|
||
int encodingCode,
|
||
Encoding encoding,
|
||
int dataSize)
|
||
{
|
||
RichStructure result = new RichStructure();
|
||
long initialPos = source.Position;
|
||
|
||
// Langage ID
|
||
result.LanguageCode = Utils.Latin1Encoding.GetString(source.ReadBytes(3));
|
||
|
||
// Content description
|
||
Encoding contentDescriptionEncoding = encoding;
|
||
if (tagVersion > TAG_VERSION_2_2 && (1 == encodingCode || 2 == encodingCode))
|
||
{
|
||
BomProperties bom = readBOM(source);
|
||
if (bom.Found) contentDescriptionEncoding = bom.Encoding;
|
||
}
|
||
result.ContentDescriptor = StreamUtils.ReadNullTerminatedString(source, contentDescriptionEncoding);
|
||
result.Size = (int)(source.Position - initialPos);
|
||
|
||
// Ignore malformed comment
|
||
if (result.Size >= dataSize)
|
||
{
|
||
// Get to the physical end of the frame
|
||
source.Position = initialPos + dataSize;
|
||
result.Size = dataSize;
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
private static RichStructure readSynchedLyricsStructure(BufferedBinaryReader source, int tagVersion, int encodingCode, Encoding encoding)
|
||
{
|
||
RichStructure result = new RichStructure();
|
||
long initialPos = source.Position;
|
||
|
||
// Langage ID
|
||
result.LanguageCode = Utils.Latin1Encoding.GetString(source.ReadBytes(3));
|
||
result.TimestampFormat = source.ReadByte();
|
||
result.ContentType = source.ReadByte();
|
||
|
||
// Content description
|
||
Encoding contentDescriptionEncoding = encoding;
|
||
if (tagVersion > TAG_VERSION_2_2 && (1 == encodingCode))
|
||
{
|
||
BomProperties bom = readBOM(source);
|
||
if (bom.Found) contentDescriptionEncoding = bom.Encoding;
|
||
}
|
||
result.ContentDescriptor = StreamUtils.ReadNullTerminatedString(source, contentDescriptionEncoding);
|
||
result.Size = (int)(source.Position - initialPos);
|
||
|
||
return result;
|
||
}
|
||
|
||
private static LyricsInfo.LyricsPhrase readLyricsPhrase(BufferedBinaryReader source, Encoding encoding)
|
||
{
|
||
// Skip the newline char positioned by SYLT Editor
|
||
if (10 != source.ReadByte()) source.Position--;
|
||
|
||
string text = StreamUtils.ReadNullTerminatedString(source, encoding);
|
||
int timestamp = StreamUtils.DecodeBEInt32(source.ReadBytes(4));
|
||
return new LyricsInfo.LyricsPhrase(timestamp, text);
|
||
}
|
||
|
||
private bool readFrame(
|
||
BufferedBinaryReader source,
|
||
TagInfo tag,
|
||
ReadTagParams readTagParams,
|
||
ref IList<MetaFieldInfo> comments,
|
||
bool inChapter = false)
|
||
{
|
||
FrameHeader Frame = new FrameHeader();
|
||
byte encodingCode;
|
||
|
||
long initialTagPos = source.Position;
|
||
|
||
ChapterInfo chapter = null;
|
||
MetaFieldInfo comment = null;
|
||
|
||
bool inLyrics = false;
|
||
|
||
|
||
// Read frame header and check frame ID
|
||
Frame.ID = TAG_VERSION_2_2 == m_tagVersion ? Utils.Latin1Encoding.GetString(source.ReadBytes(3)) : Utils.Latin1Encoding.GetString(source.ReadBytes(4));
|
||
|
||
if (!char.IsLetter(Frame.ID[0]) || !char.IsUpper(Frame.ID[0]))
|
||
{
|
||
// We might be at the beginning of a padding frame
|
||
if (0 == Frame.ID[0] + Frame.ID[1] + Frame.ID[2])
|
||
{
|
||
tag.PaddingOffset = initialTagPos;
|
||
tag.ActualEnd = StreamUtils.TraversePadding(source);
|
||
}
|
||
else // If not, we're in the wrong place
|
||
{
|
||
LogDelegator.GetLogDelegate()(Log.LV_ERROR, "Valid frame not found where expected; parsing interrupted");
|
||
source.Seek(initialTagPos - tag.HeaderEnd + tag.GetSize(false), SeekOrigin.Begin);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
if (tag.ActualEnd > -1) return false;
|
||
|
||
/*
|
||
Frame size measures number of bytes between end of flag and end of payload
|
||
|
||
Frame size encoding conventions
|
||
ID3v2.2 : 3 bytes
|
||
ID3v2.3 : 4 bytes (plain integer)
|
||
ID3v2.4 : synch-safe Int32
|
||
*/
|
||
switch (m_tagVersion)
|
||
{
|
||
case TAG_VERSION_2_2:
|
||
Frame.Size = StreamUtils.DecodeBEInt24(source.ReadBytes(3));
|
||
break;
|
||
case TAG_VERSION_2_3:
|
||
Frame.Size = StreamUtils.DecodeBEInt32(source.ReadBytes(4));
|
||
break;
|
||
case TAG_VERSION_2_4:
|
||
long sizePosition = source.Position;
|
||
byte[] sizeDescriptor = source.ReadBytes(4);
|
||
Frame.Size = StreamUtils.DecodeSynchSafeInt32(sizeDescriptor);
|
||
// Important: Certain implementation of ID3v2.4 still use the ID3v2.3 size descriptor (e.g.FFMpeg 4.3.1 for the CTOC frame).
|
||
// => We have to test reading the frame to "guess" which convention its size descriptor uses
|
||
// when its size is described on more than 1 byte
|
||
//
|
||
// If the size descriptor, read as a plain integer, is larger than the whole tag size, we should keep it as a synch safe int
|
||
if (sizeDescriptor[2] + sizeDescriptor[1] + sizeDescriptor[0] > 0
|
||
&& misencodedSizev4Fields.Contains(Frame.ID)
|
||
&& StreamUtils.DecodeBEInt32(sizeDescriptor) < tag.GetSize(false))
|
||
{
|
||
// Check if the end of the frame is immediately followed by 4 uppercase chars or by padding chars
|
||
// If not, try again by reading frame size as a plain integer
|
||
source.Seek(sizePosition + 6 + Frame.Size, SeekOrigin.Begin);
|
||
string frameId = Utils.Latin1Encoding.GetString(source.ReadBytes(4));
|
||
if (!isUpperAlpha(frameId) && !frameId.Equals("\0\0\0\0")) Frame.Size = StreamUtils.DecodeBEInt32(sizeDescriptor);
|
||
source.Seek(sizePosition + 4, SeekOrigin.Begin);
|
||
}
|
||
break;
|
||
}
|
||
|
||
Frame.Flags = TAG_VERSION_2_2 == m_tagVersion ? (ushort)0 : StreamUtils.DecodeBEUInt16(source.ReadBytes(2));
|
||
|
||
var dataSize = Frame.Size;
|
||
|
||
// Skips data size indicator if signaled by the flag
|
||
if ((Frame.Flags & 1) > 0)
|
||
{
|
||
source.Seek(4, SeekOrigin.Current);
|
||
dataSize -= 4;
|
||
}
|
||
|
||
if (!noTextEncodingFields.Contains(Frame.ID))
|
||
{
|
||
dataSize--; // Minus encoding byte
|
||
encodingCode = source.ReadByte();
|
||
}
|
||
else
|
||
{
|
||
encodingCode = 0; // Latin-1; default according to spec
|
||
}
|
||
var frameEncoding = decodeID3v2CharEncoding(encodingCode);
|
||
|
||
// COMM and USLT/ULT fields contain :
|
||
// a 3-byte langage ID
|
||
// a "short content description", as an encoded null-terminated string
|
||
// the actual data (i.e. comment or lyrics), as an encoded, null-terminated string
|
||
// => lg lg lg (BOM) (encoded description) 00 (00) (BOM) encoded text 00 (00)
|
||
if (Frame.ID.StartsWith("COM") || Frame.ID.StartsWith("USL") || Frame.ID.StartsWith("ULT"))
|
||
{
|
||
RichStructure structure = readCommentStructure(source, m_tagVersion, encodingCode, frameEncoding, dataSize);
|
||
|
||
if (Frame.ID.StartsWith("COM"))
|
||
{
|
||
comments ??= new List<MetaFieldInfo>();
|
||
comment = new MetaFieldInfo(getImplementedTagType(), "")
|
||
{
|
||
Language = structure.LanguageCode,
|
||
NativeFieldCode = structure.ContentDescriptor
|
||
};
|
||
}
|
||
else if (Frame.ID.StartsWith("USL") || Frame.ID.StartsWith("ULT"))
|
||
{
|
||
tagData.Lyrics ??= new LyricsInfo();
|
||
tagData.Lyrics.LanguageCode = structure.LanguageCode;
|
||
tagData.Lyrics.Description = structure.ContentDescriptor;
|
||
inLyrics = true;
|
||
}
|
||
|
||
dataSize -= structure.Size;
|
||
}
|
||
else if (Frame.ID.StartsWith("SYL")) // Synch'ed lyrics
|
||
{
|
||
RichStructure structure = readSynchedLyricsStructure(source, m_tagVersion, encodingCode, frameEncoding);
|
||
tagData.Lyrics ??= new LyricsInfo();
|
||
tagData.Lyrics.LanguageCode = structure.LanguageCode;
|
||
tagData.Lyrics.Description = structure.ContentDescriptor;
|
||
tagData.Lyrics.ContentType = (LyricsInfo.LyricsType)structure.ContentType;
|
||
inLyrics = true;
|
||
|
||
dataSize -= structure.Size;
|
||
}
|
||
|
||
// A $01 "Unicode" encoding flag means the presence of a BOM (Byte Order Mark)
|
||
// NB : Even if it's not part of the spec, BOMs may appear on ID3v2.2 tags
|
||
if (1 == encodingCode)
|
||
{
|
||
BomProperties bom = readBOM(source);
|
||
if (bom.Found)
|
||
{
|
||
frameEncoding = bom.Encoding;
|
||
dataSize -= bom.Size;
|
||
}
|
||
}
|
||
|
||
// If encoding > 3, we might have caught an actual character, which means there is no encoding flag => switch to default encoding
|
||
if (encodingCode > 3)
|
||
{
|
||
frameEncoding = decodeID3v2CharEncoding(0);
|
||
source.Seek(-1, SeekOrigin.Current);
|
||
dataSize++;
|
||
}
|
||
|
||
// General Encapsulated Object
|
||
if (Frame.ID.StartsWith("GEO"))
|
||
{
|
||
long geoStartOffset = source.Position;
|
||
StreamUtils.ReadNullTerminatedString(source, Utils.Latin1Encoding); // Mime-type; unused
|
||
StreamUtils.ReadNullTerminatedString(source, frameEncoding); // File name; unused
|
||
string cDesc = StreamUtils.ReadNullTerminatedString(source, frameEncoding); // Content description
|
||
Frame.ID += "." + cDesc;
|
||
dataSize -= (int)(source.Position - geoStartOffset);
|
||
frameEncoding = Utils.Latin1Encoding; // Decode encapsulated object using Latin-1
|
||
}
|
||
|
||
// General Encapsulated Object
|
||
if (Frame.ID.StartsWith("PRIV"))
|
||
{
|
||
long privStartOffset = source.Position;
|
||
string owner = StreamUtils.ReadNullTerminatedString(source, Utils.Latin1Encoding);
|
||
Frame.ID += "." + owner;
|
||
dataSize -= (int)(source.Position - privStartOffset);
|
||
frameEncoding = Utils.Latin1Encoding; // Decode private data using Latin-1
|
||
}
|
||
|
||
|
||
// == READ ACTUAL FRAME DATA
|
||
var dataPosition = source.Position;
|
||
|
||
if (dataSize >= 0 && dataSize < source.Length)
|
||
{
|
||
if (!("PIC".Equals(Frame.ID) || "APIC".Equals(Frame.ID))) // Not a picture frame
|
||
{
|
||
string strData;
|
||
|
||
// Specific to Popularitymeter : Rating data has to be extracted from the POPM block
|
||
if (Frame.ID.StartsWith("POP"))
|
||
{
|
||
// ID3v2.2 : According to spec (see paragraph3.2), encoding should actually be ISO-8859-1
|
||
// ID3v2.3+ : Spec is unclear whether to read as ISO-8859-1 or not. Practice indicates using this convention is safer.
|
||
strData = readRatingInPopularityMeter(source, Utils.Latin1Encoding).ToString();
|
||
}
|
||
else if (Frame.ID.StartsWith("TXX"))
|
||
{
|
||
// Read frame data and set tag item if frame supported
|
||
byte[] bData = source.ReadBytes(dataSize);
|
||
strData = Utils.StripEndingZeroChars(frameEncoding.GetString(bData));
|
||
|
||
string[] tabS = strData.Split('\0');
|
||
Frame.ID = tabS[0];
|
||
// Handle multiple values (ID3v2.4 only, theoretically)
|
||
if (tabS.Length > 1) strData = string.Join(Settings.InternalValueSeparator + "", tabS, 1, tabS.Length - 1);
|
||
else strData = ""; //if the 2nd part of the array isn't there, value is non-existent (TXXX...KEY\0\0 or TXXX...KEY\0)
|
||
|
||
// If unicode is used, there might be BOMs converted to 'ZERO WIDTH NO-BREAK SPACE' character
|
||
// (pattern : TXXX-stuff-BOM-ID-\0-BOM-VALUE-\0-BOM-VALUE-\0)
|
||
if (1 == encodingCode) strData = strData.Replace(Utils.UNICODE_INVISIBLE_EMPTY, "");
|
||
}
|
||
else if (Frame.ID.StartsWith("CTO")) // Chapters table of contents -> store chapter description
|
||
{
|
||
StreamUtils.ReadNullTerminatedString(source, Utils.Latin1Encoding); // Skip element ID
|
||
source.Seek(1, SeekOrigin.Current); // Skip flags
|
||
int entryCount = source.ReadByte();
|
||
for (int i = 0; i < entryCount; i++) StreamUtils.ReadNullTerminatedString(source, Utils.Latin1Encoding); // Skip chapter element IDs
|
||
// There's an optional header here
|
||
if (source.Position - dataPosition < Frame.Size && "TIT2".Equals(Utils.Latin1Encoding.GetString(source.ReadBytes(4)), StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
source.Seek(6, SeekOrigin.Current); // Skip size and flags
|
||
encodingCode = source.ReadByte();
|
||
Encoding encoding = decodeID3v2CharEncoding(encodingCode);
|
||
if (m_tagVersion > TAG_VERSION_2_2 && (1 == encodingCode || 2 == encodingCode)) readBOM(source);
|
||
strData = StreamUtils.ReadNullTerminatedStringFixed(source, encoding, (int)(dataPosition + Frame.Size - source.Position));
|
||
}
|
||
else
|
||
{
|
||
strData = "";
|
||
}
|
||
}
|
||
else if (Frame.ID.StartsWith("CHA")) // Chapters
|
||
{
|
||
tagData.Chapters ??= new List<ChapterInfo>();
|
||
chapter = new ChapterInfo();
|
||
tagData.Chapters.Add(chapter);
|
||
|
||
long initPos = source.Position;
|
||
chapter.UniqueID = StreamUtils.ReadNullTerminatedString(source, frameEncoding);
|
||
|
||
chapter.StartTime = StreamUtils.DecodeBEUInt32(source.ReadBytes(4));
|
||
chapter.EndTime = StreamUtils.DecodeBEUInt32(source.ReadBytes(4));
|
||
chapter.StartOffset = StreamUtils.DecodeBEUInt32(source.ReadBytes(4));
|
||
chapter.EndOffset = StreamUtils.DecodeBEUInt32(source.ReadBytes(4));
|
||
|
||
chapter.UseOffset = chapter.StartOffset != uint.MaxValue;
|
||
|
||
long remainingData = dataSize - (source.Position - initPos);
|
||
while (remainingData > 0)
|
||
{
|
||
if (!readFrame(source, tag, readTagParams, ref comments, true)) break;
|
||
remainingData = dataSize - (source.Position - initPos);
|
||
} // End chapter frames loop
|
||
|
||
strData = "";
|
||
}
|
||
else if (Frame.ID.StartsWith("SYL")) // Synch'ed lyrics
|
||
{
|
||
long initPos = source.Position;
|
||
long remainingData = dataSize - (source.Position - initPos);
|
||
while (remainingData > 0)
|
||
{
|
||
tagData.Lyrics.SynchronizedLyrics.Add(readLyricsPhrase(source, frameEncoding));
|
||
remainingData = dataSize - (source.Position - initPos);
|
||
}
|
||
strData = "";
|
||
}
|
||
else if (Frame.ID.StartsWith("WXX")) // Custom URL
|
||
{
|
||
// Description encoded with current encoding
|
||
strData = StreamUtils.ReadNullTerminatedString(source, frameEncoding);
|
||
strData += Settings.InternalValueSeparator;
|
||
// URL encoded in ISO-8859-1
|
||
strData += Utils.Latin1Encoding.GetString(source.ReadBytes((int)(dataSize - (source.Position - dataPosition))));
|
||
}
|
||
else
|
||
{
|
||
// Read frame data and set tag item if frame supported
|
||
byte[] bData = source.ReadBytes(dataSize);
|
||
strData = Utils.StripEndingZeroChars(frameEncoding.GetString(bData));
|
||
|
||
// Parse GENRE frame
|
||
if (Frame.ID.StartsWith("TCO")) strData = extractGenreFromID3v2Code(strData);
|
||
// Parse Involved People frame for ID3v2.2-2.3 (IPL/IPLS) where value separator is a \0
|
||
else if (Frame.ID.StartsWith("IPL"))
|
||
{
|
||
string[] parts = strData.Trim().Split(new[] { '\0' }, StringSplitOptions.RemoveEmptyEntries);
|
||
// Individual parts of that field may have a BOM
|
||
if (1 == encodingCode)
|
||
{
|
||
string bomAsStr = frameEncoding.GetString(getBomFromEncoding(frameEncoding));
|
||
for (int i = 0; i < parts.Length; i++) parts[i] = parts[i].Replace(bomAsStr, "");
|
||
}
|
||
strData = string.Join(Settings.InternalValueSeparator + "", parts);
|
||
}
|
||
// Handle multiple values on text information frames
|
||
else if (Frame.ID.StartsWith('T'))
|
||
{
|
||
string[] parts = null;
|
||
if (TAG_VERSION_2_4 == m_tagVersion) // All text information frames may contain multiple values on ID3v2.4
|
||
{
|
||
parts = strData.Trim().Split(new char[] { VALUE_SEPARATOR_24 }, StringSplitOptions.RemoveEmptyEntries);
|
||
}
|
||
else if (multipleValuev23Fields.Contains(Frame.ID)) // Only specific text information frames may contain multiple values on ID3v2.2-3
|
||
{
|
||
parts = strData.Trim().Split(new char[] { VALUE_SEPARATOR_22 }, StringSplitOptions.RemoveEmptyEntries);
|
||
}
|
||
if (parts != null) strData = string.Join(Settings.InternalValueSeparator + "", parts);
|
||
}
|
||
}
|
||
|
||
if (inLyrics)
|
||
{
|
||
if (strData.Length > 0) setMetaField(Field.LYRICS_UNSYNCH, strData);
|
||
}
|
||
else if (null == comment && null == chapter) // We're in a non-Comment, non-Chapter field => directly store value
|
||
{
|
||
if (!inChapter) SetMetaField(Frame.ID, strData, readTagParams.ReadAllMetaFrames, FileStructureHelper.DEFAULT_ZONE_NAME, tag.Version);
|
||
else
|
||
{
|
||
chapter = tagData.Chapters[^1];
|
||
switch (Frame.ID)
|
||
{
|
||
case "TIT2": chapter.Title = strData; break;
|
||
case "TIT3": chapter.Subtitle = strData; break;
|
||
case "WXXX":
|
||
string[] parts = strData.Split(Settings.InternalValueSeparator);
|
||
chapter.Url = new ChapterInfo.UrlInfo(parts[0], parts[1]);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
else if (comment != null) // We're in a Comment field => store value in temp Comment structure
|
||
{
|
||
bool found = false;
|
||
|
||
// Comment of the same field if already exists
|
||
foreach (MetaFieldInfo com in comments)
|
||
{
|
||
if (com.NativeFieldCode.Equals(comment.NativeFieldCode))
|
||
{
|
||
com.Value += Settings.InternalValueSeparator + strData;
|
||
found = true;
|
||
}
|
||
}
|
||
|
||
// Else brand new comment
|
||
if (!found)
|
||
{
|
||
comment.Value = strData;
|
||
comments.Add(comment);
|
||
}
|
||
}
|
||
|
||
if (TAG_VERSION_2_2 == m_tagVersion) source.Seek(dataPosition + dataSize, SeekOrigin.Begin);
|
||
}
|
||
else // Picture frame
|
||
{
|
||
long position = source.Position;
|
||
if (TAG_VERSION_2_2 == m_tagVersion)
|
||
{
|
||
// Image format
|
||
source.Seek(3, SeekOrigin.Current);
|
||
}
|
||
else
|
||
{
|
||
// mime-type always coded in ASCII
|
||
if (1 == encodingCode) source.Seek(-1, SeekOrigin.Current);
|
||
// Mime-type
|
||
StreamUtils.ReadNullTerminatedString(source, Utils.Latin1Encoding);
|
||
}
|
||
|
||
byte picCode = source.ReadByte();
|
||
// TODO factorize : abstract PictureTypeDecoder + unsupported / supported decision in MetaDataIO ?
|
||
PictureInfo.PIC_TYPE picType = DecodeID3v2PictureType(picCode);
|
||
|
||
int picturePosition = 0;
|
||
if (!inChapter)
|
||
{
|
||
if (picType.Equals(PictureInfo.PIC_TYPE.Unsupported))
|
||
{
|
||
picturePosition = takePicturePosition(MetaDataIOFactory.TagType.ID3V2, picCode);
|
||
}
|
||
else
|
||
{
|
||
picturePosition = takePicturePosition(picType);
|
||
}
|
||
}
|
||
|
||
// Image description
|
||
// Description may be coded with another convention
|
||
if (m_tagVersion > TAG_VERSION_2_2 && (1 == encodingCode)) readBOM(source);
|
||
string description = StreamUtils.ReadNullTerminatedString(source, frameEncoding);
|
||
|
||
if (readTagParams.ReadPictures || inChapter)
|
||
{
|
||
int picSize = dataSize - (int)(source.Position - position);
|
||
|
||
byte[] data;
|
||
if (tag.UsesUnsynchronisation)
|
||
{
|
||
data = decodeUnsynchronizedStream(source, picSize);
|
||
}
|
||
else
|
||
{
|
||
using MemoryStream outStream = new MemoryStream(picSize);
|
||
StreamUtils.CopyStream(source, outStream, picSize);
|
||
data = outStream.ToArray();
|
||
}
|
||
PictureInfo picInfo = PictureInfo.fromBinaryData(data, picType, getImplementedTagType(), picCode, picturePosition);
|
||
picInfo.Description = description;
|
||
|
||
if (!inChapter)
|
||
{
|
||
tagData.Pictures.Add(picInfo);
|
||
}
|
||
else
|
||
{
|
||
tagData.Chapters[^1].Picture = picInfo;
|
||
}
|
||
}
|
||
} // Picture frame
|
||
source.Seek(dataPosition + dataSize, SeekOrigin.Begin);
|
||
}
|
||
else // Data size <= 0 or larger than file
|
||
{
|
||
LogDelegator.GetLogDelegate()(Log.LV_WARNING, "Frame " + Frame.ID + " has an invalid size : " + dataSize);
|
||
return false;
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
// Get information from frames (universal)
|
||
private void readFrames(BufferedBinaryReader source, TagInfo tag, long offset, ReadTagParams readTagParams)
|
||
{
|
||
long streamLength = source.Length;
|
||
int tagSize = tag.GetSize(false);
|
||
|
||
tag.PaddingOffset = -1;
|
||
tag.ActualEnd = -1;
|
||
|
||
IList<MetaFieldInfo> comments = new List<MetaFieldInfo>();
|
||
|
||
source.Seek(tag.HeaderEnd, SeekOrigin.Begin);
|
||
long streamPos = source.Position;
|
||
|
||
while (streamPos - offset < tagSize && streamPos < streamLength)
|
||
{
|
||
if (!readFrame(source, tag, readTagParams, ref comments)) break;
|
||
|
||
streamPos = source.Position;
|
||
}
|
||
|
||
/* Store all comments
|
||
*
|
||
* - The only comment with a blank or bland description field is a "classic" Comment
|
||
* - Other comments are treated as additional fields, with their description field as their field code
|
||
*/
|
||
if (comments != null && comments.Count > 0)
|
||
{
|
||
foreach (MetaFieldInfo comm in comments)
|
||
{
|
||
string commentDescription = comm.NativeFieldCode.Trim().Replace(Utils.UNICODE_INVISIBLE_EMPTY, "");
|
||
if (commentDescription.Length > 0 && !commentDescription.Equals("comment", StringComparison.OrdinalIgnoreCase) && !commentDescription.Equals("no description", StringComparison.OrdinalIgnoreCase) && !commentDescription.Equals("description", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
// Processed as an additional field
|
||
SetMetaField(commentDescription, comm.Value, readTagParams.ReadAllMetaFrames, FileStructureHelper.DEFAULT_ZONE_NAME, tag.Version, 0, comm.Language);
|
||
continue;
|
||
}
|
||
|
||
// Processed as a "classic" Comment
|
||
if (m_tagVersion > TAG_VERSION_2_2) SetMetaField("COMM", comm.Value, readTagParams.ReadAllMetaFrames, FileStructureHelper.DEFAULT_ZONE_NAME, tag.Version);
|
||
else SetMetaField("COM", comm.Value, readTagParams.ReadAllMetaFrames, FileStructureHelper.DEFAULT_ZONE_NAME, tag.Version);
|
||
}
|
||
}
|
||
|
||
if (-1 == tag.ActualEnd) // No padding frame has been detected so far
|
||
{
|
||
// Prod to see if there's padding after the end of the tag
|
||
if (streamPos + 4 < source.Length && 0 == source.ReadInt32())
|
||
{
|
||
tag.PaddingOffset = streamPos;
|
||
tag.ActualEnd = StreamUtils.TraversePadding(source);
|
||
}
|
||
else
|
||
{
|
||
tag.ActualEnd = streamPos;
|
||
}
|
||
}
|
||
}
|
||
|
||
// ********************** Public functions & voids **********************
|
||
|
||
/// <inheritdoc/>
|
||
protected override bool read(Stream source, ReadTagParams readTagParams)
|
||
{
|
||
return Read(source, readTagParams.Offset, readTagParams);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Read ID3v2 data
|
||
/// </summary>
|
||
/// <param name="source">Reader object to read ID3v2 data from</param>
|
||
/// <param name="offset">ID3v2 header offset (mostly 0, except for specific audio containers such as AIFF or DSF)</param>
|
||
/// <param name="readTagParams">Reading parameters</param>
|
||
/// <returns>True if the reading operation has succeeded; false if not</returns>
|
||
public bool Read(Stream source, long offset, ReadTagParams readTagParams)
|
||
{
|
||
tagHeader = new TagInfo();
|
||
|
||
BufferedBinaryReader reader = new BufferedBinaryReader(source);
|
||
|
||
// Reset data and load header from file to variable
|
||
ResetData();
|
||
bool result = readHeader(reader, tagHeader, offset);
|
||
|
||
tagData.PaddingSize = tagHeader.GetPaddingSize();
|
||
|
||
// Process data if loaded and header valid
|
||
if (result && IsValidHeader(tagHeader.ID))
|
||
{
|
||
tagExists = true;
|
||
// Fill properties with header data
|
||
m_tagVersion = tagHeader.Version;
|
||
|
||
// Get information from frames if version supported
|
||
if (TAG_VERSION_2_2 <= m_tagVersion && m_tagVersion <= TAG_VERSION_2_4 && tagHeader.GetSize() > 0)
|
||
{
|
||
readFrames(reader, tagHeader, offset, readTagParams);
|
||
structureHelper.AddZone(offset, (int)(tagHeader.ActualEnd - offset));
|
||
}
|
||
else
|
||
{
|
||
if (m_tagVersion < TAG_VERSION_2_2 || m_tagVersion > TAG_VERSION_2_4) LogDelegator.GetLogDelegate()(Log.LV_ERROR, "ID3v2 tag version unknown : " + m_tagVersion + "; parsing interrupted");
|
||
if (0 == Size) LogDelegator.GetLogDelegate()(Log.LV_ERROR, "ID3v2 size is zero; parsing interrupted");
|
||
}
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Writes the given tag into the given Writer using ID3v2.4 conventions
|
||
/// </summary>
|
||
/// <param name="tag">Tag information to be written</param>
|
||
/// <param name="s">Stream to write tag information to</param>
|
||
/// <param name="zone">Name of the zone that is currently being written</param>
|
||
/// <returns>True if writing operation succeeded; false if not</returns>
|
||
protected override int write(TagData tag, Stream s, string zone)
|
||
{
|
||
using BinaryWriter w = new BinaryWriter(s, Encoding.UTF8, true);
|
||
return write(tag, w);
|
||
}
|
||
|
||
private int write(TagData tag, BinaryWriter w)
|
||
{
|
||
if (Settings.ID3v2_tagSubVersion < 3 || Settings.ID3v2_tagSubVersion > 4)
|
||
throw new NotImplementedException("Writing metadata with ID3v2." + Settings.ID3v2_tagSubVersion + " convention is not supported");
|
||
|
||
w.Write(Utils.Latin1Encoding.GetBytes(ID3V2_ID));
|
||
|
||
// Version 2.4.0
|
||
if (3 == Settings.ID3v2_tagSubVersion) w.Write(TAG_VERSION_2_3);
|
||
else if (4 == Settings.ID3v2_tagSubVersion) w.Write(TAG_VERSION_2_4);
|
||
w.Write((byte)0);
|
||
|
||
Encoding tagEncoding = Settings.DefaultTextEncoding;
|
||
|
||
// Fallback to Unicode when target encoding isn't supported
|
||
if (!allowedEncodings[Settings.ID3v2_tagSubVersion].Contains(tagEncoding)) tagEncoding = Encoding.Unicode;
|
||
|
||
// Flags : keep initial flags unless unsynchronization if forced
|
||
if (Settings.ID3v2_forceUnsynchronization) tagHeader.Flags = (byte)(tagHeader.Flags | FLAG_TAG_UNSYNCHRONIZED);
|
||
|
||
// Little-endian UTF-16 BOM's are caught by the unsynchronization encoding, which "breaks" most tag readers
|
||
// Hence unsycnhronization is force-disabled when the encoding is Unicode
|
||
if (tagHeader.UsesUnsynchronisation && tagEncoding.Equals(Encoding.Unicode)) tagHeader.Flags = (byte)(tagHeader.Flags & ~FLAG_TAG_UNSYNCHRONIZED);
|
||
|
||
// NB : Writing ID3v2.4 flags on an ID3v2.3 tag won't have any effect since v2.4 specific bits won't be exploited
|
||
|
||
w.Write(tagHeader.Flags);
|
||
// Keep position in mind to calculate final size and come back here to write it
|
||
var tagSizePos = w.BaseStream.Position;
|
||
w.Write(0); // Tag size placeholder to be rewritten in a few lines
|
||
|
||
writeExtHeader(w);
|
||
long headerEnd = w.BaseStream.Position;
|
||
var result = writeFrames(tag, w, tagEncoding);
|
||
|
||
// PADDING MANAGEMENT
|
||
// TODO - if footer support is added, don't write padding since they are mutually exclusive (see specs)
|
||
long paddingSizeToWrite;
|
||
if (tag.PaddingSize > -1) paddingSizeToWrite = tag.PaddingSize;
|
||
else paddingSizeToWrite = TrackUtils.ComputePaddingSize(
|
||
tagHeader.PaddingOffset, // Initial tag size
|
||
tagHeader.GetPaddingSize(), // Initial padding offset
|
||
tagHeader.PaddingOffset - tagHeader.HeaderEnd, // Initial padding size
|
||
w.BaseStream.Position - headerEnd); // Current tag size
|
||
if (paddingSizeToWrite > 0)
|
||
{
|
||
if (3 == Settings.ID3v2_tagSubVersion) // Write size of padding
|
||
{
|
||
long tmpPos = w.BaseStream.Position;
|
||
w.BaseStream.Seek(headerEnd - 4, SeekOrigin.Begin);
|
||
w.Write(StreamUtils.EncodeBEInt32((int)paddingSizeToWrite));
|
||
w.BaseStream.Seek(tmpPos, SeekOrigin.Begin);
|
||
}
|
||
|
||
for (long l = 0; l < paddingSizeToWrite; l++) w.Write((byte)0);
|
||
}
|
||
|
||
|
||
// Record final(*) size of tag into "tag size" field of header
|
||
// (*) : Spec clearly states that the tag final size is tag size after unsynchronization
|
||
long finalTagPos = w.BaseStream.Position;
|
||
w.BaseStream.Seek(tagSizePos, SeekOrigin.Begin);
|
||
var tagSize = (int)(finalTagPos - tagSizePos - 4);
|
||
w.Write(StreamUtils.EncodeSynchSafeInt32(tagSize)); // Synch-safe int32 since ID3v2.3
|
||
|
||
if (4 == Settings.ID3v2_tagSubVersion && Settings.ID3v2_useExtendedHeaderRestrictions && tagSize / 1024 > tagHeader.TagSizeRestrictionKB)
|
||
{
|
||
LogDelegator.GetLogDelegate()(Log.LV_WARNING, "Tag is too large (" + tagSize / 1024 + "KB) according to ID3v2 restrictions (" + tagHeader.TagSizeRestrictionKB + ") !");
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
|
||
// TODO : Write ID3v2.4 footer
|
||
// if footer support is added, don't write padding since they are mutually exclusive (see specs)
|
||
|
||
private void writeExtHeader(BinaryWriter w)
|
||
{
|
||
// Rewrites extended header as is
|
||
if (!tagHeader.HasExtendedHeader) return;
|
||
|
||
w.Write(StreamUtils.EncodeSynchSafeInt(tagHeader.ExtendedHeaderSize, 4));
|
||
|
||
switch (Settings.ID3v2_tagSubVersion)
|
||
{
|
||
case 4:
|
||
{
|
||
w.Write((byte)1); // Number of flag bytes; always 1 according to spec
|
||
w.Write(tagHeader.ExtendedFlags);
|
||
// A new CRC should be calculated according to actual tag contents instead of rewriting CRC as is -- NB : CRC perimeter definition given by specs is unclear
|
||
if (tagHeader.CRC > 0) w.Write(StreamUtils.EncodeSynchSafeInt(tagHeader.CRC, 5));
|
||
if (tagHeader.TagRestrictions > 0) w.Write(tagHeader.TagRestrictions);
|
||
|
||
/* TODO - to be reimplemented and tested with a proper unit test
|
||
if (4 == Settings.ID3v2_tagSubVersion && Settings.ID3v2_useExtendedHeaderRestrictions && tagHeader.HasTextEncodingRestriction && (!(tagEncoding.BodyName.Equals("iso-8859-1") || tagEncoding.BodyName.Equals("utf-8"))))
|
||
{
|
||
// Force default encoding if encoding restriction is enabled and current encoding is not among authorized types
|
||
tagEncoding = Settings.DefaultTextEncoding;
|
||
}
|
||
*/
|
||
break;
|
||
}
|
||
case 3:
|
||
w.Write(tagHeader.ExtendedFlags);
|
||
w.Write((byte)0); // Always 0 according to spec
|
||
w.Write(0); // Size of padding
|
||
break;
|
||
}
|
||
}
|
||
|
||
private int writeFrames(TagData tag, BinaryWriter w, Encoding tagEncoding)
|
||
{
|
||
int nbFrames = 0;
|
||
|
||
// === ID3v2 FRAMES ===
|
||
IDictionary<Field, string> map = tag.ToMap(true);
|
||
|
||
// 1st pass to gather date information
|
||
|
||
// "Recording date" fields are a bit tricky, since there is no 1-to-1 mapping between ID3v2.2/3 and ID3v2.4
|
||
// ID3v2.2 : TYE (year), TDA (day & month - DDMM), TIM (hour & minute - HHMM)
|
||
// ID3v2.3 : TYER (year), TDAT (day & month - DDMM), TIME (hour & minute - HHMM)
|
||
// ID3v2.4 : TDRC (timestamp)
|
||
string recordingYear = "";
|
||
string recordingDayMonth = "";
|
||
string recordingTime = "";
|
||
string recordingDate = "";
|
||
|
||
foreach (Field frameType in map.Keys)
|
||
{
|
||
if (map[frameType].Length <= 0) continue; // No frame with empty value
|
||
|
||
switch (frameType)
|
||
{
|
||
case Field.RECORDING_YEAR:
|
||
recordingYear = map[frameType];
|
||
break;
|
||
case Field.RECORDING_DAYMONTH:
|
||
recordingDayMonth = map[frameType];
|
||
break;
|
||
case Field.RECORDING_TIME:
|
||
recordingTime = map[frameType];
|
||
break;
|
||
case Field.RECORDING_DATE:
|
||
recordingDate = map[frameType];
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (4 == Settings.ID3v2_tagSubVersion && recordingYear.Length > 0)
|
||
{
|
||
// Make sure we don't erase an existing, same date with less detailed (year only) information
|
||
if (0 == recordingDate.Length || !recordingDate.StartsWith(recordingYear))
|
||
map[Field.RECORDING_DATE] = TrackUtils.FormatISOTimestamp(recordingYear, recordingDayMonth, recordingTime);
|
||
}
|
||
else if (3 == Settings.ID3v2_tagSubVersion && recordingDate.Length > 3 && 0 == recordingYear.Length)
|
||
{
|
||
// Recording date valued for ID3v2.3 (possibly a migration from ID3v2.4 to ID3v2.3)
|
||
map[Field.RECORDING_YEAR] = recordingDate[..4];
|
||
if (recordingDate.Length > 9)
|
||
{
|
||
map[Field.RECORDING_DAYMONTH] = recordingDate.Substring(8, 2) + recordingDate.Substring(5, 2);
|
||
if (recordingDate.Length > 15)
|
||
{
|
||
map[Field.RECORDING_TIME] = recordingDate.Substring(11, 2) + recordingDate.Substring(14, 2);
|
||
}
|
||
}
|
||
map.Remove(Field.RECORDING_DATE);
|
||
}
|
||
|
||
// "Original release date" fields are tricky too for the same reason
|
||
// ID3v2.2 : TOR (year)
|
||
// ID3v2.3 : TORY (year)
|
||
// ID3v2.4 : TDOR (timestamp)
|
||
string origReleaseYear = "";
|
||
string origReleaseDate = "";
|
||
|
||
foreach (Field frameType in map.Keys)
|
||
{
|
||
if (map[frameType].Length <= 0) continue; // No frame with empty value
|
||
|
||
switch (frameType)
|
||
{
|
||
case Field.ORIG_RELEASE_YEAR:
|
||
origReleaseYear = map[frameType];
|
||
break;
|
||
case Field.ORIG_RELEASE_DATE:
|
||
origReleaseDate = map[frameType];
|
||
break;
|
||
}
|
||
}
|
||
if (4 == Settings.ID3v2_tagSubVersion && origReleaseDate.Length > 0)
|
||
{
|
||
// Make sure we don't erase an existing, same date with less detailed (year only) information
|
||
if (0 == origReleaseDate.Length || !origReleaseDate.StartsWith(origReleaseYear))
|
||
map[Field.ORIG_RELEASE_DATE] = TrackUtils.FormatISOTimestamp(origReleaseYear, "0101", "");
|
||
}
|
||
else if (3 == Settings.ID3v2_tagSubVersion && origReleaseDate.Length > 3 && 0 == origReleaseYear.Length)
|
||
{
|
||
// Original release date valued for ID3v2.3 (possibly a migration from ID3v2.4 to ID3v2.3)
|
||
map[Field.ORIG_RELEASE_YEAR] = origReleaseDate[..4];
|
||
map.Remove(Field.ORIG_RELEASE_DATE);
|
||
}
|
||
|
||
|
||
|
||
IDictionary<string, Field> mapping = frameMapping_v24;
|
||
if (3 == Settings.ID3v2_tagSubVersion) mapping = frameMapping_v23;
|
||
// Keep these in memory to prevent setting them twice using AdditionalFields
|
||
var writtenFieldCodes = new HashSet<string>();
|
||
|
||
foreach (Field frameType in map.Keys)
|
||
{
|
||
if (map[frameType].Length <= 0) continue; // No frame with empty value
|
||
foreach (string s in mapping.Keys)
|
||
{
|
||
if (frameType != mapping[s]) continue;
|
||
if (s.Equals("CTOC")) continue; // CTOC (table of contents) is handled by writeChapters
|
||
|
||
string value = formatBeforeWriting(frameType, tag, map);
|
||
writeTextFrame(w, s, value, tagEncoding);
|
||
writtenFieldCodes.Add(s.ToUpper());
|
||
nbFrames++;
|
||
break;
|
||
}
|
||
}
|
||
|
||
// Chapters
|
||
if (Chapters.Count > 0)
|
||
{
|
||
nbFrames += writeChapters(w, Chapters, tagEncoding);
|
||
}
|
||
|
||
// Lyrics
|
||
if (tag.Lyrics != null)
|
||
{
|
||
nbFrames += writeLyrics(w, tag.Lyrics, tagEncoding);
|
||
}
|
||
|
||
// Other textual fields
|
||
foreach (MetaFieldInfo fieldInfo in tag.AdditionalFields)
|
||
{
|
||
if ((fieldInfo.TagType.Equals(MetaDataIOFactory.TagType.ANY) || fieldInfo.TagType.Equals(getImplementedTagType()))
|
||
&& !fieldInfo.MarkedForDeletion
|
||
&& !writtenFieldCodes.Contains(fieldInfo.NativeFieldCode.ToUpper())
|
||
)
|
||
{
|
||
var fieldCode = fieldInfo.NativeFieldCode;
|
||
if (fieldCode.Equals(VorbisTag.VENDOR_METADATA_ID)) continue; // Specific mandatory field exclusive to VorbisComment
|
||
|
||
// We're writing with ID3v2.4 standard. Some standard frame codes have to be converted from ID3v2.2/3 to ID3v4
|
||
if (4 == Settings.ID3v2_tagSubVersion)
|
||
{
|
||
if (TAG_VERSION_2_2 == m_tagVersion)
|
||
{
|
||
if (frameMapping_v22_4.TryGetValue(fieldCode, out var value)) fieldCode = value;
|
||
}
|
||
else if (TAG_VERSION_2_3 == m_tagVersion && frameMapping_v23_4.TryGetValue(fieldCode, out var value))
|
||
{
|
||
fieldCode = value;
|
||
}
|
||
}
|
||
else if (3 == Settings.ID3v2_tagSubVersion)
|
||
{
|
||
if (TAG_VERSION_2_2 == m_tagVersion)
|
||
{
|
||
if (frameMapping_v22_3.TryGetValue(fieldCode, out var value)) fieldCode = value;
|
||
}
|
||
else if (TAG_VERSION_2_4 == m_tagVersion && frameMapping_v24_3.TryGetValue(fieldCode, out var value))
|
||
{
|
||
fieldCode = value;
|
||
}
|
||
}
|
||
|
||
writeTextFrame(w, fieldCode, FormatBeforeWriting(fieldInfo.Value), tagEncoding, fieldInfo.Language);
|
||
nbFrames++;
|
||
}
|
||
}
|
||
|
||
foreach (PictureInfo picInfo in tag.Pictures)
|
||
{
|
||
// Picture has either to be supported, or to come from the right tag standard
|
||
var doWritePicture = !picInfo.PicType.Equals(PictureInfo.PIC_TYPE.Unsupported);
|
||
if (!doWritePicture) doWritePicture = (getImplementedTagType() == picInfo.TagType);
|
||
// It also has not to be marked for deletion
|
||
doWritePicture = doWritePicture && !picInfo.MarkedForDeletion;
|
||
|
||
if (doWritePicture)
|
||
{
|
||
writePictureFrame(w, picInfo.PictureData, picInfo.MimeType, picInfo.PicType.Equals(PictureInfo.PIC_TYPE.Unsupported) ? (byte)picInfo.NativePicCode : EncodeID3v2PictureType(picInfo.PicType), picInfo.Description, tagEncoding);
|
||
nbFrames++;
|
||
}
|
||
}
|
||
|
||
if (4 == Settings.ID3v2_tagSubVersion && Settings.ID3v2_useExtendedHeaderRestrictions && nbFrames > tagHeader.TagFramesRestriction)
|
||
{
|
||
LogDelegator.GetLogDelegate()(Log.LV_WARNING, "Tag has too many frames (" + nbFrames + ") according to ID3v2 restrictions (" + tagHeader.TagFramesRestriction + ") !");
|
||
}
|
||
|
||
return nbFrames;
|
||
}
|
||
|
||
private static void writeFrameHeader(BinaryWriter w, string frameCode, bool useUnsynchronization, bool useDataSize = false)
|
||
{
|
||
w.Write(Utils.Latin1Encoding.GetBytes(frameCode));
|
||
w.Write(0);
|
||
|
||
short flags = 0;
|
||
if (useDataSize) flags |= FLAG_FRAME_24_HAS_DATA_LENGTH_INDICATOR;
|
||
if (useUnsynchronization) flags |= FLAG_FRAME_24_UNSYNCHRONIZED;
|
||
w.Write(StreamUtils.EncodeBEInt16(flags));
|
||
}
|
||
|
||
private int writeChapters(BinaryWriter writer, IList<ChapterInfo> chapters, Encoding tagEncoding)
|
||
{
|
||
int result;
|
||
|
||
if (tagHeader.UsesUnsynchronisation)
|
||
{
|
||
MemoryStream s = new MemoryStream((int)Size);
|
||
using BinaryWriter w = new BinaryWriter(s, tagEncoding);
|
||
result = writeChaptersInternal(writer, w, chapters, tagEncoding, writer.BaseStream.Position);
|
||
s.Seek(0, SeekOrigin.Begin);
|
||
encodeUnsynchronizedStreamTo(s, writer);
|
||
}
|
||
else
|
||
{
|
||
result = writeChaptersInternal(writer, writer, chapters, tagEncoding, 0);
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
private int writeChaptersInternal(BinaryWriter fileWriter, BinaryWriter frameWriter, IList<ChapterInfo> chapters, Encoding tagEncoding, long frameOffset)
|
||
{
|
||
Random randomGenerator = null;
|
||
long frameSizePos, frameDataPos, finalFramePos;
|
||
int result = 0;
|
||
|
||
// Write a "flat" table of contents, if any CTOC is present in tag
|
||
// NB : Hierarchical table of contents is not supported; see implementation notes in the header
|
||
if (Settings.ID3v2_alwaysWriteCTOCFrame && chapters.Count > 0)
|
||
{
|
||
frameSizePos = frameWriter.BaseStream.Position + 4; // Frame size location to be rewritten in a few lines (NB : Always + 4 because all frame codes are 4 chars long)
|
||
writeFrameHeader(frameWriter, "CTOC", tagHeader.UsesUnsynchronisation);
|
||
|
||
// Default unique toc ID
|
||
frameDataPos = frameWriter.BaseStream.Position;
|
||
frameWriter.Write(Utils.Latin1Encoding.GetBytes("toc"));
|
||
frameWriter.Write('\0');
|
||
|
||
// CTOC flags : no parents; chapters are in order
|
||
frameWriter.Write((byte)3);
|
||
|
||
|
||
// Entry count
|
||
frameWriter.Write((byte)chapters.Count);
|
||
|
||
foreach (var c in chapters)
|
||
{
|
||
// Generate a chapter ID if none has been given
|
||
if (0 == c.UniqueID.Length)
|
||
{
|
||
randomGenerator ??= new Random();
|
||
c.UniqueID = randomGenerator.Next().ToString();
|
||
}
|
||
frameWriter.Write(Utils.Latin1Encoding.GetBytes(c.UniqueID));
|
||
frameWriter.Write('\0');
|
||
}
|
||
|
||
// CTOC description
|
||
if (Utils.ProtectValue(ChaptersTableDescription).Length > 0)
|
||
writeTextFrame(frameWriter, "TIT2", ChaptersTableDescription, tagEncoding, "", "", true);
|
||
|
||
// Go back to frame size location to write its actual size
|
||
finalFramePos = frameWriter.BaseStream.Position;
|
||
frameWriter.BaseStream.Seek(frameOffset + frameSizePos, SeekOrigin.Begin);
|
||
int size = (int)(finalFramePos - frameDataPos - frameOffset);
|
||
if (4 == Settings.ID3v2_tagSubVersion) fileWriter.Write(StreamUtils.EncodeSynchSafeInt32(size));
|
||
else if (3 == Settings.ID3v2_tagSubVersion) fileWriter.Write(StreamUtils.EncodeBEInt32(size));
|
||
frameWriter.BaseStream.Seek(finalFramePos, SeekOrigin.Begin);
|
||
|
||
result++;
|
||
}
|
||
|
||
// Write individual chapters
|
||
foreach (ChapterInfo chapter in chapters)
|
||
{
|
||
frameSizePos = frameWriter.BaseStream.Position + 4; // Frame size location to be rewritten in a few lines (NB : Always + 4 because all frame codes are 4 chars long)
|
||
writeFrameHeader(frameWriter, "CHAP", tagHeader.UsesUnsynchronisation);
|
||
|
||
frameDataPos = frameWriter.BaseStream.Position;
|
||
|
||
// Generate a chapter ID if none has been given
|
||
if (0 == chapter.UniqueID.Length)
|
||
{
|
||
if (null == randomGenerator) randomGenerator = new Random();
|
||
chapter.UniqueID = randomGenerator.Next().ToString();
|
||
}
|
||
|
||
frameWriter.Write(Utils.Latin1Encoding.GetBytes(chapter.UniqueID));
|
||
frameWriter.Write('\0');
|
||
|
||
uint startValue = chapter.StartTime;
|
||
uint endValue = chapter.EndTime;
|
||
// As per specs, unused value should be encoded as 0xFF to be ignored
|
||
if (0 == startValue + endValue)
|
||
{
|
||
startValue = uint.MaxValue;
|
||
endValue = uint.MaxValue;
|
||
}
|
||
frameWriter.Write(StreamUtils.EncodeBEUInt32(startValue));
|
||
frameWriter.Write(StreamUtils.EncodeBEUInt32(endValue));
|
||
|
||
startValue = chapter.StartOffset;
|
||
endValue = chapter.EndOffset;
|
||
// As per specs, unused value should be encoded as 0xFF to be ignored
|
||
if (0 == startValue + endValue)
|
||
{
|
||
startValue = uint.MaxValue;
|
||
endValue = uint.MaxValue;
|
||
}
|
||
frameWriter.Write(StreamUtils.EncodeBEUInt32(startValue));
|
||
frameWriter.Write(StreamUtils.EncodeBEUInt32(endValue));
|
||
|
||
if (!string.IsNullOrEmpty(chapter.Title))
|
||
{
|
||
// NB : FFmpeg uses Latin-1
|
||
writeTextFrame(frameWriter, "TIT2", chapter.Title, tagEncoding, "", "", true);
|
||
}
|
||
if (!string.IsNullOrEmpty(chapter.Subtitle))
|
||
{
|
||
writeTextFrame(frameWriter, "TIT3", chapter.Subtitle, tagEncoding, "", "", true);
|
||
}
|
||
if (chapter.Url != null)
|
||
{
|
||
writeTextFrame(frameWriter, "WXXX", chapter.Url.ToString(), tagEncoding, "", "", true);
|
||
}
|
||
if (chapter.Picture != null && chapter.Picture.PictureData != null && chapter.Picture.PictureData.Length > 0)
|
||
{
|
||
writePictureFrame(frameWriter, chapter.Picture.PictureData, chapter.Picture.MimeType, chapter.Picture.PicType.Equals(PictureInfo.PIC_TYPE.Unsupported) ? (byte)chapter.Picture.NativePicCode : EncodeID3v2PictureType(chapter.Picture.PicType), chapter.Picture.Description, tagEncoding, true);
|
||
}
|
||
|
||
// Go back to frame size location to write its actual size
|
||
finalFramePos = frameWriter.BaseStream.Position;
|
||
frameWriter.BaseStream.Seek(frameOffset + frameSizePos, SeekOrigin.Begin);
|
||
int size = (int)(finalFramePos - frameDataPos - frameOffset);
|
||
if (4 == Settings.ID3v2_tagSubVersion) fileWriter.Write(StreamUtils.EncodeSynchSafeInt32(size));
|
||
else if (3 == Settings.ID3v2_tagSubVersion) fileWriter.Write(StreamUtils.EncodeBEInt32(size));
|
||
frameWriter.BaseStream.Seek(finalFramePos, SeekOrigin.Begin);
|
||
}
|
||
|
||
result += chapters.Count;
|
||
return result;
|
||
}
|
||
|
||
private int writeLyrics(BinaryWriter writer, LyricsInfo lyrics, Encoding tagEncoding)
|
||
{
|
||
int result = 0;
|
||
|
||
if (lyrics.UnsynchronizedLyrics.Length > 0)
|
||
{
|
||
writeTextFrame(writer, "USLT", lyrics.UnsynchronizedLyrics, tagEncoding, lyrics.LanguageCode, lyrics.Description);
|
||
result++;
|
||
}
|
||
if (lyrics.SynchronizedLyrics.Count > 0)
|
||
{
|
||
writeSynchedLyrics(writer, lyrics, tagEncoding);
|
||
result++;
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
private void writeSynchedLyrics(BinaryWriter w, LyricsInfo lyrics, Encoding tagEncoding)
|
||
{
|
||
var frameSizePos = w.BaseStream.Position + 4; // Frame size location to be rewritten in a few lines (NB : Always + 4 because all frame codes are 4 chars long)
|
||
writeFrameHeader(w, "SYLT", tagHeader.UsesUnsynchronisation);
|
||
|
||
var frameDataPos = w.BaseStream.Position;
|
||
|
||
// Encoding according to ID3v2 specs
|
||
w.Write(encodeID3v2CharEncoding(tagEncoding));
|
||
|
||
// Language ID (ISO-639-2)
|
||
w.Write(Utils.Latin1Encoding.GetBytes(Utils.BuildStrictLengthString(lyrics.LanguageCode, 3, '\0')));
|
||
|
||
// Timestamp format (ATL : always absolute milliseconds)
|
||
w.Write((byte)2);
|
||
|
||
// Content type
|
||
w.Write((byte)lyrics.ContentType);
|
||
|
||
// Short content description
|
||
w.Write(Utils.Latin1Encoding.GetBytes(lyrics.Description));
|
||
w.Write(getNullTerminatorFromEncoding(tagEncoding));
|
||
|
||
// Lyrics
|
||
foreach (LyricsInfo.LyricsPhrase phrase in lyrics.SynchronizedLyrics)
|
||
{
|
||
w.Write((byte)10); // Emulate SyltEdit's behaviour that seems to be the de facto standard
|
||
w.Write(tagEncoding.GetBytes(phrase.Text));
|
||
w.Write(getNullTerminatorFromEncoding(tagEncoding));
|
||
w.Write(StreamUtils.EncodeBEInt32(phrase.TimestampMs));
|
||
}
|
||
|
||
// Go back to frame size location to write its actual size
|
||
var finalFramePos = w.BaseStream.Position;
|
||
w.BaseStream.Seek(frameSizePos, SeekOrigin.Begin);
|
||
int size = (int)(finalFramePos - frameDataPos);
|
||
if (4 == Settings.ID3v2_tagSubVersion) w.Write(StreamUtils.EncodeSynchSafeInt32(size));
|
||
else if (3 == Settings.ID3v2_tagSubVersion) w.Write(StreamUtils.EncodeBEInt32(size));
|
||
w.BaseStream.Seek(finalFramePos, SeekOrigin.Begin);
|
||
}
|
||
|
||
private void writeTextFrame(
|
||
BinaryWriter writer,
|
||
string frameCode,
|
||
string value,
|
||
Encoding tagEncoding,
|
||
string language = "",
|
||
string description = "",
|
||
bool isInsideUnsynch = false)
|
||
{
|
||
long frameOffset;
|
||
bool isCommentCode = false; // True if we're adding a frame that is only supported through Comment field (see commentsFields list)
|
||
|
||
bool writeValue = true;
|
||
bool isExplicitLatin1Encoding = noTextEncodingFields.Contains(frameCode);
|
||
bool writeTextEncoding = !isExplicitLatin1Encoding;
|
||
bool writeNullTermination = true; // Required by specs; see paragraph 4, concerning $03 encoding
|
||
|
||
ICollection<string> standardFrames = standardFrames_v24;
|
||
if (3 == Settings.ID3v2_tagSubVersion) standardFrames = standardFrames_v23;
|
||
|
||
BinaryWriter w;
|
||
MemoryStream s = null;
|
||
|
||
if (tagHeader.UsesUnsynchronisation && !isInsideUnsynch)
|
||
{
|
||
s = new MemoryStream((int)Size);
|
||
w = new BinaryWriter(s, tagEncoding);
|
||
frameOffset = writer.BaseStream.Position;
|
||
}
|
||
else
|
||
{
|
||
w = writer;
|
||
frameOffset = 0;
|
||
}
|
||
|
||
if (4 == Settings.ID3v2_tagSubVersion && Settings.ID3v2_useExtendedHeaderRestrictions && value.Length > tagHeader.TextFieldSizeRestriction)
|
||
{
|
||
LogDelegator.GetLogDelegate()(Log.LV_INFO, frameCode + " field value (" + value + ") is longer than authorized by ID3v2 restrictions; reducing to " + tagHeader.TextFieldSizeRestriction + " characters");
|
||
|
||
value = value[..tagHeader.TextFieldSizeRestriction];
|
||
}
|
||
|
||
if (frameCode.Length < 5) frameCode = frameCode.ToUpper(); // Only capitalize standard ID3v2 fields -- TODO : Use TagData.Origin property !
|
||
var actualFrameCode = frameCode; // Used for writing TXXX frames
|
||
|
||
// If frame is only supported through Comment field, it has to be added through COMM frame
|
||
if (commentsFields.Contains(frameCode))
|
||
{
|
||
frameCode = "COMM";
|
||
isCommentCode = true;
|
||
}
|
||
// If frame is a General Encapsulated object or a Private Frame,
|
||
// its code has to be broken down ("GEOB.FileName" / "PRIV.Owner")
|
||
else if (frameCode.StartsWith("GEO", StringComparison.OrdinalIgnoreCase)
|
||
|| frameCode.StartsWith("PRIV", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
int dotIdx = frameCode.IndexOf('.');
|
||
if (dotIdx > -1)
|
||
{
|
||
actualFrameCode = frameCode[(dotIdx + 1)..];
|
||
frameCode = frameCode[..dotIdx];
|
||
}
|
||
}
|
||
// If frame is not standard, it has to be added through TXXX frame ("user-defined text information frame")
|
||
else if (!standardFrames.Contains(frameCode))
|
||
{
|
||
frameCode = "TXXX";
|
||
}
|
||
|
||
var frameSizePos = w.BaseStream.Position + 4; // Frame size location to be rewritten in a few lines (NB : Always + 4 because all frame codes are 4 chars long)
|
||
writeFrameHeader(w, frameCode, tagHeader.UsesUnsynchronisation);
|
||
|
||
var frameDataPos = w.BaseStream.Position;
|
||
// Comments frame specifics
|
||
if (frameCode.StartsWith("COM"))
|
||
{
|
||
// Encoding according to ID3v2 specs
|
||
w.Write(encodeID3v2CharEncoding(tagEncoding));
|
||
|
||
// Language ID (ISO-639-2)
|
||
if (language != null) language = Utils.BuildStrictLengthString(language, 3, '\0');
|
||
w.Write(Utils.Latin1Encoding.GetBytes(language));
|
||
|
||
// Short content description
|
||
w.Write(getBomFromEncoding(tagEncoding));
|
||
if (isCommentCode)
|
||
{
|
||
w.Write(Utils.Latin1Encoding.GetBytes(actualFrameCode));
|
||
}
|
||
// NB : ATL doesn't support custom content description yet
|
||
w.Write(getNullTerminatorFromEncoding(tagEncoding));
|
||
|
||
writeTextEncoding = false;
|
||
}
|
||
else if (frameCode.StartsWith("USL")) // Unsynched lyrics frame specifics
|
||
{
|
||
// Encoding according to ID3v2 specs
|
||
w.Write(encodeID3v2CharEncoding(tagEncoding));
|
||
|
||
// Language ID (ISO-639-2)
|
||
if (language != null) language = Utils.BuildStrictLengthString(language, 3, '\0');
|
||
w.Write(Utils.Latin1Encoding.GetBytes(language));
|
||
|
||
// Short content description
|
||
w.Write(Utils.Latin1Encoding.GetBytes(description));
|
||
w.Write(getNullTerminatorFromEncoding(tagEncoding));
|
||
|
||
writeTextEncoding = false;
|
||
}
|
||
else if (frameCode.StartsWith("POP")) // Rating frame specifics
|
||
{
|
||
// User e-mail
|
||
w.Write('\0'); // Empty string, null-terminated; TODO : handle this field dynamically
|
||
|
||
w.Write(byte.Parse(value));
|
||
|
||
// Play count
|
||
w.Write(0); // TODO : handle this field dynamically. Warning : may be longer than 32 bits (see specs)
|
||
|
||
writeValue = false;
|
||
}
|
||
else if (frameCode.StartsWith("TXX")) // User-defined text frame specifics
|
||
{
|
||
if (writeTextEncoding) w.Write(encodeID3v2CharEncoding(tagEncoding));
|
||
w.Write(getBomFromEncoding(tagEncoding));
|
||
w.Write(tagEncoding.GetBytes(actualFrameCode));
|
||
w.Write(getNullTerminatorFromEncoding(tagEncoding));
|
||
value = value.Replace(Settings.DisplayValueSeparator, '\0');
|
||
|
||
writeTextEncoding = false;
|
||
writeNullTermination = true; // Seems to be the de facto standard; however, it isn't written like that in the specs
|
||
}
|
||
else if (frameCode.StartsWith("WXX")) // User-defined URL
|
||
{
|
||
byte[] desc;
|
||
byte[] url;
|
||
// Case 1 : Field read by ATL from a file
|
||
string[] parts = value.Split(Settings.InternalValueSeparator);
|
||
// Case 2 : User-defined value with the ID3v2 specs separator
|
||
// NB : Keeps a remaining null char when the desc is encoded with UTF-16, which is rare
|
||
if (1 == parts.Length) parts = value.Split('\0');
|
||
// Case 3 : User-defined value without separator
|
||
if (1 == parts.Length)
|
||
{
|
||
desc = Array.Empty<byte>();
|
||
url = Utils.Latin1Encoding.GetBytes(parts[0]);
|
||
}
|
||
else
|
||
{
|
||
desc = Utils.Latin1Encoding.GetBytes(parts[0]);
|
||
url = Utils.Latin1Encoding.GetBytes(parts[1]);
|
||
}
|
||
|
||
// Write the field
|
||
w.Write(encodeID3v2CharEncoding(Utils.Latin1Encoding)); // ISO-8859-1 seems to be the de facto norm, although spec allows fancier encodings
|
||
//w.Write(getBomFromEncoding(tagEncoding)); // No BOM for ISO-8859-1
|
||
w.Write(desc);
|
||
w.Write(getNullTerminatorFromEncoding(Utils.Latin1Encoding));
|
||
w.Write(url);
|
||
|
||
writeValue = false;
|
||
}
|
||
else if (frameCode.StartsWith("GEO")) // General encapsulated object
|
||
{
|
||
Encoding geoEncoding = Utils.Latin1Encoding;
|
||
w.Write(encodeID3v2CharEncoding(geoEncoding));
|
||
w.Write(Utils.Latin1Encoding.GetBytes("application/octet-stream\0")); // MIME-type; unsupported for now
|
||
w.Write((byte)0); // File name; unsupported for now
|
||
w.Write(getBomFromEncoding(geoEncoding));
|
||
w.Write(geoEncoding.GetBytes(actualFrameCode + '\0')); // Content description
|
||
w.Write(getBomFromEncoding(geoEncoding));
|
||
|
||
isExplicitLatin1Encoding = true; // Writing something else than Latin-1 is unsupported for now
|
||
writeTextEncoding = false;
|
||
writeNullTermination = true;
|
||
}
|
||
else if (frameCode.StartsWith("PRIV")) // Private frame
|
||
{
|
||
w.Write(Utils.Latin1Encoding.GetBytes(actualFrameCode + '\0')); // Owner
|
||
|
||
isExplicitLatin1Encoding = true; // Writing something else than Latin-1 is unsupported for now
|
||
writeTextEncoding = false;
|
||
writeNullTermination = true;
|
||
}
|
||
else if (value.Contains(Settings.DisplayValueSeparator + ""))
|
||
{
|
||
if (frameCode.StartsWith("TCO")) // Genre
|
||
{
|
||
// Separating values with \0 is actually specific to ID3v2.4 but seems to have become the de facto standard
|
||
// If something ever goes wrong with multiples values in ID3v2.3, remember their spec separates values with ()'s
|
||
value = value.Replace(Settings.DisplayValueSeparator, '\0');
|
||
}
|
||
else if (frameCode.StartsWith("IPL")) // // Involved People frame for ID3v2.2-2.3 (IPL/IPLS)
|
||
{
|
||
// Value separator is a \0 and every value has a BOM
|
||
string[] parts = value.Split(Settings.DisplayValueSeparator, StringSplitOptions.RemoveEmptyEntries);
|
||
string bomAsStr = tagEncoding.GetString(getBomFromEncoding(tagEncoding));
|
||
if (bomAsStr.Length > 0)
|
||
{
|
||
for (int i = 0; i < parts.Length; i++) parts[i] = bomAsStr + parts[i];
|
||
}
|
||
value = string.Join("\0", parts);
|
||
}
|
||
else if (frameCode.StartsWith('T')) // Text information frame
|
||
{
|
||
if (4 == Settings.ID3v2_tagSubVersion) // All text information frames may contain multiple values on ID3v2.4
|
||
{
|
||
value = value.Replace(Settings.DisplayValueSeparator, VALUE_SEPARATOR_24);
|
||
}
|
||
else if (multipleValuev23Fields.Contains(frameCode)) // Only specific text information frames may contain multiple values on ID3v2.2-3
|
||
{
|
||
value = value.Replace(Settings.DisplayValueSeparator, VALUE_SEPARATOR_22);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (writeValue)
|
||
{
|
||
Encoding localEncoding = isExplicitLatin1Encoding ? Utils.Latin1Encoding : tagEncoding;
|
||
if (writeTextEncoding) w.Write(encodeID3v2CharEncoding(localEncoding)); // Encoding according to ID3v2 specs
|
||
w.Write(getBomFromEncoding(localEncoding));
|
||
w.Write(localEncoding.GetBytes(value));
|
||
if (writeNullTermination) w.Write(getNullTerminatorFromEncoding(localEncoding));
|
||
}
|
||
|
||
|
||
if (tagHeader.UsesUnsynchronisation && !isInsideUnsynch)
|
||
{
|
||
s.Seek(0, SeekOrigin.Begin);
|
||
encodeUnsynchronizedStreamTo(s, writer);
|
||
w.Close();
|
||
}
|
||
|
||
// Go back to frame size location to write its actual size
|
||
var finalFramePos = writer.BaseStream.Position;
|
||
writer.BaseStream.Seek(frameOffset + frameSizePos, SeekOrigin.Begin);
|
||
int size = (int)(finalFramePos - frameDataPos - frameOffset);
|
||
if (4 == Settings.ID3v2_tagSubVersion) writer.Write(StreamUtils.EncodeSynchSafeInt32(size));
|
||
else if (3 == Settings.ID3v2_tagSubVersion) writer.Write(StreamUtils.EncodeBEInt32(size));
|
||
writer.BaseStream.Seek(finalFramePos, SeekOrigin.Begin);
|
||
}
|
||
|
||
private void writePictureFrame(BinaryWriter writer, byte[] pictureData, string mimeType, byte pictureTypeCode, string picDescription, Encoding tagEncoding, bool isInsideUnsynch = false)
|
||
{
|
||
// Binary tag writing management
|
||
long frameOffset;
|
||
long dataSizePos = 0;
|
||
// Data length indicator (specific to ID3v2.4)
|
||
bool useDataSize = 4 == Settings.ID3v2_tagSubVersion && Settings.ID3v2_writePictureDataLengthIndicator;
|
||
Encoding usedTagEncoding = Settings.ID3v2_forceAPICEncodingToLatin1 ? Utils.Latin1Encoding : tagEncoding;
|
||
|
||
// Unsynchronization management
|
||
BinaryWriter w;
|
||
MemoryStream s = null;
|
||
|
||
|
||
if (tagHeader.UsesUnsynchronisation && !isInsideUnsynch)
|
||
{
|
||
s = new MemoryStream((int)Size);
|
||
w = new BinaryWriter(s, usedTagEncoding);
|
||
frameOffset = writer.BaseStream.Position;
|
||
}
|
||
else
|
||
{
|
||
w = writer;
|
||
frameOffset = 0;
|
||
}
|
||
|
||
var frameSizePos = w.BaseStream.Position + 4; // Frame size location to be rewritten in a few lines (NB : Always + 4 because all frame codes are 4 chars long)
|
||
writeFrameHeader(w, "APIC", tagHeader.UsesUnsynchronisation, useDataSize);
|
||
|
||
var frameDataPos = w.BaseStream.Position;
|
||
|
||
if (useDataSize)
|
||
{
|
||
dataSizePos = w.BaseStream.Position; // Data length, as indicated by the flag we just set
|
||
w.Write(0);
|
||
}
|
||
|
||
// Beginning of APIC frame data
|
||
w.Write(encodeID3v2CharEncoding(usedTagEncoding));
|
||
|
||
// Application of ID3v2 extended header restrictions
|
||
if (4 == Settings.ID3v2_tagSubVersion && Settings.ID3v2_useExtendedHeaderRestrictions)
|
||
{
|
||
// Force JPEG if encoding restriction is enabled and mime-type is not among authorized types
|
||
// TODO : make target format customizable (JPEG or PNG)
|
||
if (tagHeader.HasPictureEncodingRestriction && (!(mimeType.ToLower().Equals("image/jpeg") || mimeType.ToLower().Equals("image/png"))))
|
||
{
|
||
LogDelegator.GetLogDelegate()(Log.LV_INFO, "Embedded picture format (" + mimeType + ") does not respect ID3v2 restrictions (jpeg or png required)");
|
||
}
|
||
|
||
// Force picture dimensions if a size restriction is enabled
|
||
if (tagHeader.PictureSizeRestriction > 0)
|
||
{
|
||
ImageProperties props = ImageUtils.GetImageProperties(pictureData);
|
||
|
||
if (256 == tagHeader.PictureSizeRestriction && (props.Height > 256 || props.Width > 256)) // 256x256 or less
|
||
{
|
||
LogDelegator.GetLogDelegate()(Log.LV_INFO, "Embedded picture format (" + props.Width + "x" + props.Height + ") does not respect ID3v2 restrictions (256x256 or less)");
|
||
}
|
||
else if (63 == tagHeader.PictureSizeRestriction && (props.Height > 64 || props.Width > 64)) // 64x64 or less
|
||
{
|
||
LogDelegator.GetLogDelegate()(Log.LV_INFO, "Embedded picture format (" + props.Width + "x" + props.Height + ") does not respect ID3v2 restrictions (64x64 or less)");
|
||
}
|
||
else if (64 == tagHeader.PictureSizeRestriction && (props.Height != 64 && props.Width != 64)) // exactly 64x64
|
||
{
|
||
LogDelegator.GetLogDelegate()(Log.LV_INFO, "Embedded picture format (" + props.Width + "x" + props.Height + ") does not respect ID3v2 restrictions (exactly 64x64)");
|
||
}
|
||
}
|
||
}
|
||
|
||
// Null-terminated string = mime-type encoded in ISO-8859-1
|
||
w.Write(Utils.Latin1Encoding.GetBytes(mimeType)); // Force ISO-8859-1 format for mime-type
|
||
w.Write('\0'); // String should be null-terminated
|
||
|
||
w.Write(pictureTypeCode);
|
||
|
||
// Picture description
|
||
w.Write(getBomFromEncoding(usedTagEncoding));
|
||
if (picDescription.Length > 0) w.Write(usedTagEncoding.GetBytes(picDescription));
|
||
w.Write(getNullTerminatorFromEncoding(usedTagEncoding)); // Description should be null-terminated
|
||
|
||
w.Write(pictureData);
|
||
|
||
var finalFramePosRaw = w.BaseStream.Position;
|
||
|
||
if (tagHeader.UsesUnsynchronisation && !isInsideUnsynch)
|
||
{
|
||
s.Seek(0, SeekOrigin.Begin);
|
||
encodeUnsynchronizedStreamTo(s, writer);
|
||
w.Close();
|
||
}
|
||
|
||
// Go back to frame size location to write its actual size
|
||
var finalFramePos = writer.BaseStream.Position;
|
||
|
||
writer.BaseStream.Seek(frameSizePos + frameOffset, SeekOrigin.Begin);
|
||
int size = (int)(finalFramePos - frameDataPos - frameOffset);
|
||
if (4 == Settings.ID3v2_tagSubVersion) writer.Write(StreamUtils.EncodeSynchSafeInt32(size));
|
||
else if (3 == Settings.ID3v2_tagSubVersion) writer.Write(StreamUtils.EncodeBEInt32(size));
|
||
|
||
if (useDataSize) // ID3v2.4 only
|
||
{
|
||
writer.BaseStream.Seek(dataSizePos + frameOffset, SeekOrigin.Begin);
|
||
size = (int)(finalFramePosRaw - frameDataPos - 4); // Data size doesn't take the data size header into account (breaks certain softwares like MusicBee)
|
||
writer.Write(StreamUtils.EncodeSynchSafeInt32(size));
|
||
}
|
||
|
||
writer.BaseStream.Seek(finalFramePos, SeekOrigin.Begin);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// ID3v2-SPECIFIC FEATURES
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// <summary>
|
||
/// Extract genre name from String according to ID3 conventions
|
||
/// </summary>
|
||
/// <param name="iGenre">String representation of genre according to various ID3v1/v2 conventions</param>
|
||
/// <returns>Genre name</returns>
|
||
private static string extractGenreFromID3v2Code(string iGenre)
|
||
{
|
||
if (string.IsNullOrEmpty(iGenre)) return "";
|
||
|
||
ISet<string> genres = new HashSet<string>();
|
||
|
||
// Handle ID3v2.4 separators and ID3v2.2-3 parenthesis
|
||
// If unicode is used, there might be BOMs converted to 'ZERO WIDTH NO-BREAK SPACE' character before each genre
|
||
string[] parts = iGenre.Trim().Replace(Utils.UNICODE_INVISIBLE_EMPTY, "").Split(new char[] { '\0', '(', ')' }, StringSplitOptions.RemoveEmptyEntries);
|
||
foreach (string part in parts)
|
||
{
|
||
// Handle numerical index of the genre, as per ID3v1 convention
|
||
if (Utils.IsNumeric(part, true))
|
||
{
|
||
int.TryParse(part, out var genreIndex);
|
||
if (genreIndex > -1 && genreIndex < ID3v1.MusicGenre.Length)
|
||
{
|
||
genres.Add(ID3v1.MusicGenre[genreIndex]);
|
||
continue;
|
||
}
|
||
}
|
||
genres.Add(part);
|
||
}
|
||
return string.Join(Settings.InternalValueSeparator + "", genres);
|
||
}
|
||
|
||
// Specific to ID3v2 : extract numeric rating from POP/POPM block containing other useless/obsolete data
|
||
private static int readRatingInPopularityMeter(BufferedBinaryReader Source, Encoding encoding)
|
||
{
|
||
// Skip the e-mail, which is a null-terminated string
|
||
StreamUtils.ReadNullTerminatedString(Source, encoding);
|
||
|
||
// Returns the rating, contained in the following byte
|
||
return Source.ReadByte();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Read Unicode BOM and return the corresponding encoding
|
||
/// NB : This implementation _only_ works with UTF-16 BOMs defined in the ID3v2 specs ($FF FE 00 00 or $FE FF 00 00)
|
||
/// </summary>
|
||
/// <param name="source">Source stream</param>
|
||
/// <returns>Properties of the BOM.
|
||
/// If it has been found, stream is positioned on the next byte just after the BOM.
|
||
/// If not, stream is positioned on its initial position before calling readBOM.
|
||
/// </returns>
|
||
private static BomProperties readBOM(Stream source)
|
||
{
|
||
BomProperties result = new BomProperties();
|
||
long initialPos = source.Position;
|
||
result.Size = 1;
|
||
result.Encoding = Encoding.Unicode;
|
||
|
||
byte[] b = new byte[1];
|
||
|
||
source.Read(b, 0, 1);
|
||
bool first = true;
|
||
bool foundFE = false;
|
||
bool foundFF = false;
|
||
|
||
while (0 == b[0] || 0xFF == b[0] || 0xFE == b[0])
|
||
{
|
||
if (result.Size > 3) break; // Useful (unsynchronized) BOM can't be longer than 3 chars => detection failed
|
||
|
||
// All UTF-16 BOMs either start or end with 0xFE or 0xFF
|
||
// => Having them both read means that the entirety of the UTF-16 BOM has been read
|
||
foundFE = foundFE || 0xFE == b[0];
|
||
foundFF = foundFF || 0xFF == b[0];
|
||
if (foundFE && foundFF)
|
||
{
|
||
result.Found = true;
|
||
break;
|
||
}
|
||
|
||
if (first && b[0] > 0)
|
||
{
|
||
// 0xFE first means data is coded in Big Endian
|
||
if (0xFE == b[0]) result.Encoding = Encoding.BigEndianUnicode;
|
||
first = false;
|
||
}
|
||
|
||
source.Read(b, 0, 1);
|
||
result.Size++;
|
||
}
|
||
|
||
if (!result.Found) source.Position = initialPos;
|
||
else result.Size = (int)(source.Position - initialPos);
|
||
|
||
return result;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Returns the ATL picture type corresponding to the given ID3v2 picture code
|
||
/// </summary>
|
||
/// <param name="picCode">ID3v2 picture code</param>
|
||
/// <returns>ATL picture type corresponding to the given ID3v2 picture code; PIC_TYPE.Unsupported by default</returns>
|
||
public static PictureInfo.PIC_TYPE DecodeID3v2PictureType(int picCode)
|
||
{
|
||
return picCode switch
|
||
{
|
||
0 => PictureInfo.PIC_TYPE.Generic, // Spec calls it "Other"
|
||
0x02 => PictureInfo.PIC_TYPE.Icon,
|
||
0x03 => PictureInfo.PIC_TYPE.Front,
|
||
0x04 => PictureInfo.PIC_TYPE.Back,
|
||
0x05 => PictureInfo.PIC_TYPE.Leaflet,
|
||
0x06 => PictureInfo.PIC_TYPE.CD,
|
||
0x07 => PictureInfo.PIC_TYPE.LeadArtist,
|
||
0x08 => PictureInfo.PIC_TYPE.Artist,
|
||
0x09 => PictureInfo.PIC_TYPE.Conductor,
|
||
0x0A => PictureInfo.PIC_TYPE.Band,
|
||
0x0B => PictureInfo.PIC_TYPE.Composer,
|
||
0x0C => PictureInfo.PIC_TYPE.Lyricist,
|
||
0x0D => PictureInfo.PIC_TYPE.RecordingLocation,
|
||
0x0E => PictureInfo.PIC_TYPE.DuringRecording,
|
||
0x0F => PictureInfo.PIC_TYPE.DuringPerformance,
|
||
0x10 => PictureInfo.PIC_TYPE.MovieCapture,
|
||
0x11 => PictureInfo.PIC_TYPE.Fishie,
|
||
0x12 => PictureInfo.PIC_TYPE.Illustration,
|
||
0x13 => PictureInfo.PIC_TYPE.BandLogo,
|
||
0x14 => PictureInfo.PIC_TYPE.PublisherLogo,
|
||
_ => PictureInfo.PIC_TYPE.Unsupported
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// Returns the ID3v2 picture code corresponding to the given ATL picture type
|
||
/// </summary>
|
||
/// <param name="picType">ATL picture type</param>
|
||
/// <returns>ID3v2 picture code corresponding to the given ATL picture type; 0 ("Other") by default</returns>
|
||
public static byte EncodeID3v2PictureType(PictureInfo.PIC_TYPE picType)
|
||
{
|
||
return picType switch
|
||
{
|
||
PictureInfo.PIC_TYPE.Icon => 0x02,
|
||
PictureInfo.PIC_TYPE.Front => 0x03,
|
||
PictureInfo.PIC_TYPE.Back => 0x04,
|
||
PictureInfo.PIC_TYPE.Leaflet => 0x05,
|
||
PictureInfo.PIC_TYPE.CD => 0x06,
|
||
PictureInfo.PIC_TYPE.LeadArtist => 0x07,
|
||
PictureInfo.PIC_TYPE.Artist => 0x08,
|
||
PictureInfo.PIC_TYPE.Conductor => 0x09,
|
||
PictureInfo.PIC_TYPE.Band => 0x0A,
|
||
PictureInfo.PIC_TYPE.Composer => 0x0B,
|
||
PictureInfo.PIC_TYPE.Lyricist => 0x0C,
|
||
PictureInfo.PIC_TYPE.RecordingLocation => 0x0D,
|
||
PictureInfo.PIC_TYPE.DuringRecording => 0x0E,
|
||
PictureInfo.PIC_TYPE.DuringPerformance => 0x0F,
|
||
PictureInfo.PIC_TYPE.MovieCapture => 0x10,
|
||
PictureInfo.PIC_TYPE.Fishie => 0x11,
|
||
PictureInfo.PIC_TYPE.Illustration => 0x12,
|
||
PictureInfo.PIC_TYPE.BandLogo => 0x13,
|
||
PictureInfo.PIC_TYPE.PublisherLogo => 0x14,
|
||
_ => 0
|
||
};
|
||
}
|
||
|
||
// Copies the stream while cleaning abnormalities due to unsynchronization (Cf. paragraph5 of ID3v2.2 specs; paragraph6 of ID3v2.3+ specs)
|
||
// => every "0xff 0x00" becomes "0xff"
|
||
private static byte[] decodeUnsynchronizedStream(BufferedBinaryReader from, int length)
|
||
{
|
||
const int BUFFER_SIZE = 8192;
|
||
|
||
byte[] result = new byte[length];
|
||
|
||
bool foundFF = false;
|
||
|
||
byte[] readBuffer = new byte[BUFFER_SIZE];
|
||
byte[] writeBuffer = new byte[BUFFER_SIZE];
|
||
|
||
int writtenTotal = 0;
|
||
int remainingBytes;
|
||
if (length > 0)
|
||
{
|
||
remainingBytes = (int)Math.Min(length, from.Length - from.Position);
|
||
}
|
||
else
|
||
{
|
||
remainingBytes = (int)(from.Length - from.Position);
|
||
}
|
||
|
||
while (remainingBytes > 0)
|
||
{
|
||
var written = 0;
|
||
var bytesToRead = Math.Min(remainingBytes, BUFFER_SIZE);
|
||
|
||
from.Read(readBuffer, 0, bytesToRead);
|
||
|
||
for (int i = 0; i < bytesToRead; i++)
|
||
{
|
||
if (0xff == readBuffer[i]) foundFF = true;
|
||
else if (0x00 == readBuffer[i] && foundFF)
|
||
{
|
||
foundFF = false;
|
||
continue; // i.e. do not write 0x00 to output stream
|
||
}
|
||
else if (foundFF) foundFF = false;
|
||
|
||
writeBuffer[written++] = readBuffer[i];
|
||
}
|
||
Array.Copy(writeBuffer, 0, result, writtenTotal, written);
|
||
writtenTotal += written;
|
||
|
||
remainingBytes -= bytesToRead;
|
||
}
|
||
|
||
Array.Resize(ref result, writtenTotal);
|
||
|
||
return result;
|
||
}
|
||
|
||
// Copies the stream while unsynchronizing it (Cf. paragraph5 of ID3v2.2 specs; paragraph6 of ID3v2.3+ specs)
|
||
// => every "0xff 0xex" becomes "0xff 0x00 0xex"; every "0xff 0x00" becomes "0xff 0x00 0x00"
|
||
private static void encodeUnsynchronizedStreamTo(Stream from, BinaryWriter to)
|
||
{
|
||
/* TODO PERF : profile I/O speed using
|
||
*
|
||
* BinaryReader.readByte vs. BufferedBinaryReader.readByte vs. Stream.Read
|
||
* BinaryWriter.Write vs. Stream.WriteByte
|
||
*/
|
||
|
||
byte[] data = new byte[2];
|
||
long streamLength = from.Length;
|
||
long position = from.Position;
|
||
|
||
from.Read(data, 0, 1);
|
||
position++;
|
||
|
||
while (position < streamLength)
|
||
{
|
||
from.Read(data, 1, 1);
|
||
position++;
|
||
|
||
to.Write(data[0]);
|
||
if (0xFF == data[0] && (0x00 == data[1] || 0xE0 == (data[1] & 0xE0)))
|
||
{
|
||
to.Write((byte)0);
|
||
}
|
||
data[0] = data[1];
|
||
}
|
||
to.Write(data[0]);
|
||
}
|
||
|
||
/// Returns the .NET Encoding corresponding to the given ID3v2 convention (see below)
|
||
///
|
||
/// Default encoding should be "ISO-8859-1"
|
||
///
|
||
/// Warning : due to approximative implementations, some tags seem to be coded
|
||
/// with the default encoding of the OS they have been tagged on
|
||
///
|
||
/// $00 ISO-8859-1 [ISO-8859-1]. Terminated with $00.
|
||
/// $01 UTF-16 [UTF-16] encoded Unicode [UNICODE], with BOM if version > 2.2
|
||
/// All strings in the same frame SHALL have the same byteorder.
|
||
/// Terminated with $00 00.
|
||
/// $02 UTF-16BE [UTF-16] encoded Unicode [UNICODE] without BOM.
|
||
/// Terminated with $00 00.
|
||
/// $03 UTF-8 [UTF-8] encoded Unicode [UNICODE]. Terminated with $00.
|
||
private static Encoding decodeID3v2CharEncoding(byte encoding)
|
||
{
|
||
return encoding switch
|
||
{
|
||
0 => Utils.Latin1Encoding, // aka ISO Latin-1
|
||
1 => Encoding.Unicode, // UTF-16 with BOM (since ID3v2.2)
|
||
2 => Encoding.BigEndianUnicode, // UTF-16 Big Endian without BOM (since ID3v2.4)
|
||
3 => Encoding.UTF8, // UTF-8 (since ID3v2.4)
|
||
_ => Encoding.Default
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// Returns the ID3v2 encoding byte corresponding to the given .NET Encoding (see above function for the convention)
|
||
/// </summary>
|
||
/// <param name="encoding">.NET Encoding to get the ID3v2 encoding byte for</param>
|
||
/// <returns>ID3v2 encoding byte corresponding to the given Encoding; 0 (ISO-8859-1) by default</returns>
|
||
private static byte encodeID3v2CharEncoding(Encoding encoding)
|
||
{
|
||
if (encoding.Equals(Encoding.Unicode)) return 1;
|
||
if (encoding.Equals(Encoding.BigEndianUnicode)) return 2;
|
||
if (encoding.Equals(Encoding.UTF8)) return 3;
|
||
return 0; // Default = ISO-8859-1 / ISO Latin-1
|
||
}
|
||
|
||
/// <summary>
|
||
/// Get the Byte Order Mark (BOM) corresponding to the given Encoding
|
||
/// </summary>
|
||
/// <param name="encoding">Encoding to get the BOM for</param>
|
||
/// <returns>BOM corresponding to the given Encoding; no BOM (ISO-8859-1) by default</returns>
|
||
private static byte[] getBomFromEncoding(Encoding encoding)
|
||
{
|
||
if (encoding.Equals(Encoding.Unicode)) return BOM_UTF16_LE;
|
||
if (encoding.Equals(Encoding.BigEndianUnicode)) return BOM_UTF16_BE;
|
||
if (encoding.Equals(Encoding.UTF8)) return BOM_NONE;
|
||
return BOM_NONE; // Default = ISO-8859-1 / ISO Latin-1
|
||
}
|
||
|
||
/// <summary>
|
||
/// Get the null terminator corresponding to the given Encoding
|
||
/// </summary>
|
||
/// <param name="encoding">Encoding to get the null terminator for</param>
|
||
/// <returns>Null terminator corresponding to the given Encoding; single byte (ISO-8859-1) by default</returns>
|
||
private static byte[] getNullTerminatorFromEncoding(Encoding encoding)
|
||
{
|
||
if (encoding.Equals(Encoding.Unicode)) return NULLTERMINATOR_2;
|
||
if (encoding.Equals(Encoding.BigEndianUnicode)) return NULLTERMINATOR_2;
|
||
if (encoding.Equals(Encoding.UTF8)) return NULLTERMINATOR;
|
||
return NULLTERMINATOR; // Default = ISO-8859-1 / ISO Latin-1
|
||
}
|
||
|
||
/// <summary>
|
||
/// Indicate if the given string is exclusively composed of upper alphabetic characters
|
||
/// </summary>
|
||
/// <param name="str">String to test</param>
|
||
/// <returns>True if the given string is exclusively composed of upper alphabetic characters; false if not</returns>
|
||
private static bool isUpperAlpha(string str)
|
||
{
|
||
foreach (char c in str)
|
||
{
|
||
if (!char.IsLetterOrDigit(c)) return false;
|
||
if (char.IsLetter(c) && !char.IsUpper(c)) return false;
|
||
}
|
||
return true;
|
||
}
|
||
}
|
||
} |