You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
Readarr/src/NzbDrone.Core/MediaFiles/AudioTag.cs

554 lines
23 KiB

using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using NLog;
using NzbDrone.Common.Extensions;
using NzbDrone.Common.Instrumentation;
using NzbDrone.Common.Instrumentation.Extensions;
using NzbDrone.Core.Parser;
using NzbDrone.Core.Parser.Model;
using NzbDrone.Core.Qualities;
using TagLib;
using TagLib.Id3v2;
namespace NzbDrone.Core.MediaFiles
{
public class AudioTag
{
private static readonly Logger Logger = NzbDroneLogger.GetLogger(typeof(AudioTag));
public string Title { get; set; }
public string[] Performers { get; set; }
public string[] BookAuthors { get; set; }
public uint Track { get; set; }
public uint TrackCount { get; set; }
public string Book { get; set; }
public uint Disc { get; set; }
public uint DiscCount { get; set; }
public string Media { get; set; }
public DateTime? Date { get; set; }
public DateTime? OriginalReleaseDate { get; set; }
public uint Year { get; set; }
public uint OriginalYear { get; set; }
public string Publisher { get; set; }
public TimeSpan Duration { get; set; }
public string[] Genres { get; set; }
public string ImageFile { get; set; }
public long ImageSize { get; set; }
public bool IsValid { get; private set; }
public QualityModel Quality { get; set; }
public MediaInfoModel MediaInfo { get; set; }
public AudioTag()
{
IsValid = true;
}
public AudioTag(string path)
{
Read(path);
}
public void Read(string path)
{
Logger.Debug($"Starting tag read for {path}");
IsValid = false;
TagLib.File file = null;
try
{
file = TagLib.File.Create(path);
var tag = file.Tag;
Title = tag.Title ?? tag.TitleSort;
Performers = tag.Performers ?? tag.PerformersSort;
var authors = new List<string>();
if (tag.AlbumArtists?.Any() ?? false)
{
authors.AddRange(tag.AlbumArtists);
}
else if (tag.AlbumArtistsSort?.Any() ?? false)
{
authors.AddRange(tag.AlbumArtistsSort);
}
if (tag.Performers?.Any() ?? false)
{
authors.AddRange(tag.Performers);
}
else if (tag.PerformersSort?.Any() ?? false)
{
authors.AddRange(tag.PerformersSort);
}
BookAuthors = authors.Distinct().ToArray();
Track = tag.Track;
TrackCount = tag.TrackCount;
Book = tag.Album ?? tag.AlbumSort;
Disc = tag.Disc;
DiscCount = tag.DiscCount;
Year = tag.Year;
Publisher = tag.Publisher;
Duration = file.Properties.Duration;
Genres = tag.Genres;
ImageSize = tag.Pictures.FirstOrDefault()?.Data.Count ?? 0;
DateTime tempDate;
// Do the ones that aren't handled by the generic taglib implementation
if (file.TagTypesOnDisk.HasFlag(TagTypes.Id3v2))
{
var id3tag = (TagLib.Id3v2.Tag)file.GetTag(TagTypes.Id3v2);
Media = id3tag.GetTextAsString("TMED");
Date = ReadId3Date(id3tag, "TDRC");
OriginalReleaseDate = ReadId3Date(id3tag, "TDOR");
}
else if (file.TagTypesOnDisk.HasFlag(TagTypes.Xiph))
{
// while publisher is handled by taglib, it seems to be mapped to 'ORGANIZATION' and not 'LABEL' like Picard is
// https://picard.musicbrainz.org/docs/mappings/
var flactag = (TagLib.Ogg.XiphComment)file.GetTag(TagLib.TagTypes.Xiph);
Media = flactag.GetField("MEDIA").ExclusiveOrDefault();
Date = DateTime.TryParse(flactag.GetField("DATE").ExclusiveOrDefault(), out tempDate) ? tempDate : default(DateTime?);
OriginalReleaseDate = DateTime.TryParse(flactag.GetField("ORIGINALDATE").ExclusiveOrDefault(), out tempDate) ? tempDate : default(DateTime?);
Publisher = flactag.GetField("LABEL").ExclusiveOrDefault();
}
else if (file.TagTypesOnDisk.HasFlag(TagTypes.Ape))
{
var apetag = (TagLib.Ape.Tag)file.GetTag(TagTypes.Ape);
Media = apetag.GetItem("Media")?.ToString();
Date = DateTime.TryParse(apetag.GetItem("Year")?.ToString(), out tempDate) ? tempDate : default(DateTime?);
OriginalReleaseDate = DateTime.TryParse(apetag.GetItem("Original Date")?.ToString(), out tempDate) ? tempDate : default(DateTime?);
Publisher = apetag.GetItem("Label")?.ToString();
}
else if (file.TagTypesOnDisk.HasFlag(TagTypes.Asf))
{
var asftag = (TagLib.Asf.Tag)file.GetTag(TagTypes.Asf);
Media = asftag.GetDescriptorString("WM/Media");
Date = DateTime.TryParse(asftag.GetDescriptorString("WM/Year"), out tempDate) ? tempDate : default(DateTime?);
OriginalReleaseDate = DateTime.TryParse(asftag.GetDescriptorString("WM/OriginalReleaseTime"), out tempDate) ? tempDate : default(DateTime?);
Publisher = asftag.GetDescriptorString("WM/Publisher");
}
else if (file.TagTypesOnDisk.HasFlag(TagTypes.Apple))
{
var appletag = (TagLib.Mpeg4.AppleTag)file.GetTag(TagTypes.Apple);
Media = appletag.GetDashBox("com.apple.iTunes", "MEDIA");
Date = DateTime.TryParse(appletag.DataBoxes(FixAppleId("day")).FirstOrDefault()?.Text, out tempDate) ? tempDate : default(DateTime?);
OriginalReleaseDate = DateTime.TryParse(appletag.GetDashBox("com.apple.iTunes", "Original Date"), out tempDate) ? tempDate : default(DateTime?);
}
OriginalYear = OriginalReleaseDate.HasValue ? (uint)OriginalReleaseDate?.Year : 0;
foreach (var codec in file.Properties.Codecs)
{
var acodec = codec as IAudioCodec;
if (acodec != null && (acodec.MediaTypes & MediaTypes.Audio) != MediaTypes.None)
{
var bitrate = acodec.AudioBitrate;
if (bitrate == 0)
{
// Taglib can't read bitrate for Opus.
bitrate = EstimateBitrate(file, path);
}
Logger.Debug("Audio Properties: " + acodec.Description + ", Bitrate: " + bitrate + ", Sample Size: " +
file.Properties.BitsPerSample + ", SampleRate: " + acodec.AudioSampleRate + ", Channels: " + acodec.AudioChannels);
Quality = QualityParser.ParseQuality(file.Name, acodec.Description);
Logger.Debug($"Quality parsed: {Quality}, Source: {Quality.QualityDetectionSource}");
MediaInfo = new MediaInfoModel
{
AudioFormat = acodec.Description,
AudioBitrate = bitrate,
AudioChannels = acodec.AudioChannels,
AudioBits = file.Properties.BitsPerSample,
AudioSampleRate = acodec.AudioSampleRate
};
}
}
IsValid = true;
}
catch (Exception ex)
{
if (ex is CorruptFileException)
{
Logger.Warn(ex, $"Tag reading failed for {path}. File is corrupt");
}
else
{
// Log as error so it goes to sentry with correct fingerprint
Logger.Error(ex, "Tag reading failed for {0}", path);
}
}
finally
{
file?.Dispose();
}
// make sure these are initialized to avoid errors later on
if (Quality == null)
{
Quality = QualityParser.ParseQuality(path);
Logger.Debug($"Unable to parse qulity from tag, Quality parsed from file path: {Quality}, Source: {Quality.QualityDetectionSource}");
}
MediaInfo = MediaInfo ?? new MediaInfoModel();
}
private int EstimateBitrate(TagLib.File file, string path)
{
var bitrate = 0;
try
{
// Taglib File.Length is unreliable so use System.IO
var size = new System.IO.FileInfo(path).Length;
var duration = file.Properties.Duration.TotalSeconds;
bitrate = (int)((size * 8L) / (duration * 1024));
Logger.Trace($"Estimating bitrate. Size: {size} Duration: {duration} Bitrate: {bitrate}");
}
catch
{
}
return bitrate;
}
private DateTime? ReadId3Date(TagLib.Id3v2.Tag tag, string dateTag)
{
var date = tag.GetTextAsString(dateTag);
if (tag.Version == 4)
{
// the unabused TDRC/TDOR tags
return DateTime.TryParse(date, out var result) ? result : default(DateTime?);
}
else if (dateTag == "TDRC")
{
// taglib maps the v3 TYER and TDAT to TDRC but does it incorrectly
return DateTime.TryParseExact(date, "yyyy-dd-MM", CultureInfo.InvariantCulture, DateTimeStyles.None, out var result) ? result : default(DateTime?);
}
else
{
// taglib maps the v3 TORY to TDRC so we just get a year
return int.TryParse(date, out var year) && year >= 1860 && year <= DateTime.UtcNow.Year + 1 ? new DateTime(year, 1, 1) : default(DateTime?);
}
}
private void WriteId3Date(TagLib.Id3v2.Tag tag, string v4field, string v3yyyy, string v3ddmm, DateTime? date)
{
if (tag.Version == 4)
{
tag.SetTextFrame(v3yyyy, default(string));
if (v3ddmm.IsNotNullOrWhiteSpace())
{
tag.SetTextFrame(v3ddmm, default(string));
}
tag.SetTextFrame(v4field, date.HasValue ? date.Value.ToString("yyyy-MM-dd") : null);
}
else
{
tag.SetTextFrame(v4field, default(string));
tag.SetTextFrame(v3yyyy, date.HasValue ? date.Value.ToString("yyyy") : null);
if (v3ddmm.IsNotNullOrWhiteSpace())
{
tag.SetTextFrame(v3ddmm, date.HasValue ? date.Value.ToString("ddMM") : null);
}
}
}
private void WriteId3Tag(TagLib.Id3v2.Tag tag, string id, string value)
{
var frame = UserTextInformationFrame.Get(tag, id, true);
if (value.IsNotNullOrWhiteSpace())
{
frame.Text = value.Split(';');
}
else
{
tag.RemoveFrame(frame);
}
}
private static ReadOnlyByteVector FixAppleId(ByteVector id)
{
if (id.Count == 4)
{
var roid = id as ReadOnlyByteVector;
if (roid != null)
{
return roid;
}
return new ReadOnlyByteVector(id);
}
if (id.Count == 3)
{
return new ReadOnlyByteVector(0xa9, id[0], id[1], id[2]);
}
return null;
}
public void Write(string path)
{
Logger.Debug($"Starting tag write for {path}");
// patch up any null fields to work around TagLib exception for
// WMA with null performers/bookauthors
Performers = Performers ?? new string[0];
BookAuthors = BookAuthors ?? new string[0];
Genres = Genres ?? new string[0];
TagLib.File file = null;
try
{
file = TagLib.File.Create(path);
var tag = file.Tag;
// do the ones with direct support in TagLib
tag.Title = Title;
tag.Performers = Performers;
tag.AlbumArtists = BookAuthors;
tag.Track = Track;
tag.TrackCount = TrackCount;
tag.Album = Book;
tag.Disc = Disc;
tag.DiscCount = DiscCount;
tag.Publisher = Publisher;
tag.Genres = Genres;
if (ImageFile.IsNotNullOrWhiteSpace())
{
tag.Pictures = new IPicture[1] { new Picture(ImageFile) };
}
if (file.TagTypes.HasFlag(TagTypes.Id3v2))
{
var id3tag = (TagLib.Id3v2.Tag)file.GetTag(TagTypes.Id3v2);
id3tag.SetTextFrame("TMED", Media);
WriteId3Date(id3tag, "TDRC", "TYER", "TDAT", Date);
WriteId3Date(id3tag, "TDOR", "TORY", null, OriginalReleaseDate);
}
else if (file.TagTypes.HasFlag(TagTypes.Xiph))
{
// while publisher is handled by taglib, it seems to be mapped to 'ORGANIZATION' and not 'LABEL' like Picard is
// https://picard.musicbrainz.org/docs/mappings/
tag.Publisher = null;
// taglib inserts leading zeros so set manually
tag.Track = 0;
var flactag = (TagLib.Ogg.XiphComment)file.GetTag(TagLib.TagTypes.Xiph);
flactag.SetField("DATE", Date.HasValue ? Date.Value.ToString("yyyy-MM-dd") : null);
flactag.SetField("ORIGINALDATE", OriginalReleaseDate.HasValue ? OriginalReleaseDate.Value.ToString("yyyy-MM-dd") : null);
flactag.SetField("ORIGINALYEAR", OriginalReleaseDate.HasValue ? OriginalReleaseDate.Value.Year.ToString() : null);
flactag.SetField("TRACKTOTAL", TrackCount);
flactag.SetField("TOTALTRACKS", TrackCount);
flactag.SetField("TRACKNUMBER", Track);
flactag.SetField("TOTALDISCS", DiscCount);
flactag.SetField("MEDIA", Media);
flactag.SetField("LABEL", Publisher);
}
else if (file.TagTypes.HasFlag(TagTypes.Ape))
{
var apetag = (TagLib.Ape.Tag)file.GetTag(TagTypes.Ape);
apetag.SetValue("Year", Date.HasValue ? Date.Value.ToString("yyyy-MM-dd") : null);
apetag.SetValue("Original Date", OriginalReleaseDate.HasValue ? OriginalReleaseDate.Value.ToString("yyyy-MM-dd") : null);
apetag.SetValue("Original Year", OriginalReleaseDate.HasValue ? OriginalReleaseDate.Value.Year.ToString() : null);
apetag.SetValue("Media", Media);
apetag.SetValue("Label", Publisher);
}
else if (file.TagTypes.HasFlag(TagTypes.Asf))
{
var asftag = (TagLib.Asf.Tag)file.GetTag(TagTypes.Asf);
asftag.SetDescriptorString(Date.HasValue ? Date.Value.ToString("yyyy-MM-dd") : null, "WM/Year");
asftag.SetDescriptorString(OriginalReleaseDate.HasValue ? OriginalReleaseDate.Value.ToString("yyyy-MM-dd") : null, "WM/OriginalReleaseTime");
asftag.SetDescriptorString(OriginalReleaseDate.HasValue ? OriginalReleaseDate.Value.Year.ToString() : null, "WM/OriginalReleaseYear");
asftag.SetDescriptorString(Media, "WM/Media");
asftag.SetDescriptorString(Publisher, "WM/Publisher");
}
else if (file.TagTypes.HasFlag(TagTypes.Apple))
{
var appletag = (TagLib.Mpeg4.AppleTag)file.GetTag(TagTypes.Apple);
appletag.SetText(FixAppleId("day"), Date.HasValue ? Date.Value.ToString("yyyy-MM-dd") : null);
appletag.SetDashBox("com.apple.iTunes", "Original Date", OriginalReleaseDate.HasValue ? OriginalReleaseDate.Value.ToString("yyyy-MM-dd") : null);
appletag.SetDashBox("com.apple.iTunes", "Original Year", OriginalReleaseDate.HasValue ? OriginalReleaseDate.Value.Year.ToString() : null);
appletag.SetDashBox("com.apple.iTunes", "MEDIA", Media);
}
file.Save();
}
catch (CorruptFileException ex)
{
Logger.Warn(ex, $"Tag writing failed for {path}. File is corrupt");
}
catch (Exception ex)
{
Logger.ForWarnEvent()
.Exception(ex)
.Message($"Tag writing failed for {path}")
.WriteSentryWarn("Tag writing failed")
.Log();
}
finally
{
file?.Dispose();
}
}
public Dictionary<string, Tuple<string, string>> Diff(AudioTag other)
{
var output = new Dictionary<string, Tuple<string, string>>();
if (!IsValid || !other.IsValid)
{
return output;
}
if (Title != other.Title)
{
output.Add("Title", Tuple.Create(Title, other.Title));
}
if (!Performers.SequenceEqual(other.Performers))
{
var oldValue = Performers.Any() ? string.Join(" / ", Performers) : null;
var newValue = other.Performers.Any() ? string.Join(" / ", other.Performers) : null;
output.Add("Author", Tuple.Create(oldValue, newValue));
}
if (Book != other.Book)
{
output.Add("Book", Tuple.Create(Book, other.Book));
}
if (!BookAuthors.SequenceEqual(other.BookAuthors))
{
var oldValue = BookAuthors.Any() ? string.Join(" / ", BookAuthors) : null;
var newValue = other.BookAuthors.Any() ? string.Join(" / ", other.BookAuthors) : null;
output.Add("Book Author", Tuple.Create(oldValue, newValue));
}
if (Track != other.Track)
{
output.Add("Track", Tuple.Create(Track.ToString(), other.Track.ToString()));
}
if (TrackCount != other.TrackCount)
{
output.Add("Track Count", Tuple.Create(TrackCount.ToString(), other.TrackCount.ToString()));
}
if (Disc != other.Disc)
{
output.Add("Disc", Tuple.Create(Disc.ToString(), other.Disc.ToString()));
}
if (DiscCount != other.DiscCount)
{
output.Add("Disc Count", Tuple.Create(DiscCount.ToString(), other.DiscCount.ToString()));
}
if (Media != other.Media)
{
output.Add("Media Format", Tuple.Create(Media, other.Media));
}
if (Date != other.Date)
{
var oldValue = Date.HasValue ? Date.Value.ToString("yyyy-MM-dd") : null;
var newValue = other.Date.HasValue ? other.Date.Value.ToString("yyyy-MM-dd") : null;
output.Add("Date", Tuple.Create(oldValue, newValue));
}
if (OriginalReleaseDate != other.OriginalReleaseDate)
{
// Id3v2.3 tags can only store the year, not the full date
if (OriginalReleaseDate.HasValue &&
OriginalReleaseDate.Value.Month == 1 &&
OriginalReleaseDate.Value.Day == 1)
{
if (OriginalReleaseDate.Value.Year != other.OriginalReleaseDate.Value.Year)
{
output.Add("Original Year", Tuple.Create(OriginalReleaseDate.Value.Year.ToString(), other.OriginalReleaseDate.Value.Year.ToString()));
}
}
else
{
var oldValue = OriginalReleaseDate.HasValue ? OriginalReleaseDate.Value.ToString("yyyy-MM-dd") : null;
var newValue = other.OriginalReleaseDate.HasValue ? other.OriginalReleaseDate.Value.ToString("yyyy-MM-dd") : null;
output.Add("Original Release Date", Tuple.Create(oldValue, newValue));
}
}
if (Publisher != other.Publisher)
{
output.Add("Label", Tuple.Create(Publisher, other.Publisher));
}
if (!Genres.SequenceEqual(other.Genres))
{
output.Add("Genres", Tuple.Create(string.Join(" / ", Genres), string.Join(" / ", other.Genres)));
}
if (ImageSize != other.ImageSize)
{
output.Add("Image Size", Tuple.Create(ImageSize.ToString(), other.ImageSize.ToString()));
}
return output;
}
public static implicit operator ParsedTrackInfo(AudioTag tag)
{
if (!tag.IsValid)
{
return new ParsedTrackInfo
{
Quality = tag.Quality ?? new QualityModel { Quality = NzbDrone.Core.Qualities.Quality.Unknown },
MediaInfo = tag.MediaInfo ?? new MediaInfoModel()
};
}
var authors = tag.BookAuthors.Where(x => x.IsNotNullOrWhiteSpace()).ToList();
if (!authors.Any())
{
authors.AddRange(tag.Performers.Where(x => x.IsNotNullOrWhiteSpace()));
}
return new ParsedTrackInfo
{
BookTitle = tag.Book.IsNotNullOrWhiteSpace() ? tag.Book : tag.Title,
Authors = authors,
DiscNumber = (int)tag.Disc,
DiscCount = (int)tag.DiscCount,
Year = tag.Year,
Label = tag.Publisher,
TrackNumbers = new[] { (int)tag.Track },
Title = tag.Title,
CleanTitle = tag.Title?.CleanTrackTitle(),
Duration = tag.Duration,
Quality = tag.Quality,
MediaInfo = tag.MediaInfo
};
}
}
}