diff --git a/frontend/src/Album/Details/TrackRow.js b/frontend/src/Album/Details/TrackRow.js index 5f60df882..e79672860 100644 --- a/frontend/src/Album/Details/TrackRow.js +++ b/frontend/src/Album/Details/TrackRow.js @@ -28,6 +28,7 @@ class TrackRow extends Component { absoluteTrackNumber, title, duration, + isSingleFileRelease, trackFilePath, trackFileSize, customFormats, @@ -86,7 +87,7 @@ class TrackRow extends Component { return ( { - trackFilePath + isSingleFileRelease ? `${trackFilePath} (Single File)` : trackFilePath } ); @@ -203,6 +204,7 @@ TrackRow.propTypes = { absoluteTrackNumber: PropTypes.number, title: PropTypes.string.isRequired, duration: PropTypes.number.isRequired, + isSingleFileRelease: PropTypes.bool.isRequired, isSaving: PropTypes.bool, trackFilePath: PropTypes.string, trackFileSize: PropTypes.number, diff --git a/frontend/src/Album/Details/TrackRowConnector.js b/frontend/src/Album/Details/TrackRowConnector.js index aee72e39a..37f4fc00a 100644 --- a/frontend/src/Album/Details/TrackRowConnector.js +++ b/frontend/src/Album/Details/TrackRowConnector.js @@ -13,7 +13,8 @@ function createMapStateToProps() { trackFilePath: trackFile ? trackFile.path : null, trackFileSize: trackFile ? trackFile.size : null, customFormats: trackFile ? trackFile.customFormats : [], - customFormatScore: trackFile ? trackFile.customFormatScore : 0 + customFormatScore: trackFile ? trackFile.customFormatScore : 0, + isSingleFileRelease: trackFile ? trackFile.isSingleFileRelease : false }; } ); diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.js b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.js index d1361a785..86fdcc0d1 100644 --- a/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.js +++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.js @@ -53,6 +53,11 @@ const columns = [ label: () => translate('Tracks'), isVisible: true }, + { + name: 'cueSheetPath', + label: () => 'Cue Sheet Path', + isVisible: true + }, { name: 'releaseGroup', label: () => translate('ReleaseGroup'), @@ -435,6 +440,8 @@ class InteractiveImportModalContent extends Component { allowArtistChange={allowArtistChange} onSelectedChange={this.onSelectedChange} onValidRowChange={this.onValidRowChange} + isSingleFileRelease={item.isSingleFileRelease} + cueSheetPath={item.cueSheetPath} /> ); }) diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContentConnector.js b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContentConnector.js index f40da69ee..019d7c212 100644 --- a/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContentConnector.js +++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContentConnector.js @@ -134,6 +134,8 @@ class InteractiveImportModalContentConnector extends Component { album, albumReleaseId, tracks, + isSingleFileRelease, + cueSheetPath, quality, disableReleaseSwitching } = item; @@ -148,7 +150,7 @@ class InteractiveImportModalContentConnector extends Component { return false; } - if (!tracks || !tracks.length) { + if (!(isSingleFileRelease && cueSheetPath) && (!tracks || !tracks.length)) { this.setState({ interactiveImportErrorMessage: 'One or more tracks must be chosen for each selected file' }); return false; } @@ -164,6 +166,8 @@ class InteractiveImportModalContentConnector extends Component { albumId: album.id, albumReleaseId, trackIds: _.map(tracks, 'id'), + isSingleFileRelease: item.isSingleFileRelease, + cueSheetPath: item.cueSheetPath, quality, downloadId: this.props.downloadId, disableReleaseSwitching diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.js b/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.js index b914c0996..14313c768 100644 --- a/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.js +++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.js @@ -167,6 +167,7 @@ class InteractiveImportRow extends Component { album, albumReleaseId, tracks, + cueSheetPath, quality, releaseGroup, size, @@ -267,8 +268,18 @@ class InteractiveImportRow extends Component { { showTrackNumbersPlaceholder ? : trackNumbers } + + + { + cueSheetPath + } + + e.id), + isSingleFileRelease: item.isSingleFileRelease, + cueSheetPath: item.cueSheetPath, quality: item.quality, releaseGroup: item.releaseGroup, downloadId: item.downloadId, diff --git a/src/Lidarr.Api.V1/ManualImport/ManualImportController.cs b/src/Lidarr.Api.V1/ManualImport/ManualImportController.cs index b11c36a91..6b27dbd38 100644 --- a/src/Lidarr.Api.V1/ManualImport/ManualImportController.cs +++ b/src/Lidarr.Api.V1/ManualImport/ManualImportController.cs @@ -83,7 +83,9 @@ namespace Lidarr.Api.V1.ManualImport DownloadId = resource.DownloadId, AdditionalFile = resource.AdditionalFile, ReplaceExistingFiles = resource.ReplaceExistingFiles, - DisableReleaseSwitching = resource.DisableReleaseSwitching + DisableReleaseSwitching = resource.DisableReleaseSwitching, + IsSingleFileRelease = resource.IsSingleFileRelease, + CueSheetPath = resource.CueSheetPath, }); } diff --git a/src/Lidarr.Api.V1/ManualImport/ManualImportResource.cs b/src/Lidarr.Api.V1/ManualImport/ManualImportResource.cs index 4b38b4f7c..a410d2537 100644 --- a/src/Lidarr.Api.V1/ManualImport/ManualImportResource.cs +++ b/src/Lidarr.Api.V1/ManualImport/ManualImportResource.cs @@ -29,6 +29,8 @@ namespace Lidarr.Api.V1.ManualImport public bool AdditionalFile { get; set; } public bool ReplaceExistingFiles { get; set; } public bool DisableReleaseSwitching { get; set; } + public bool IsSingleFileRelease { get; set; } + public string CueSheetPath { get; set; } } public static class ManualImportResourceMapper @@ -52,6 +54,8 @@ namespace Lidarr.Api.V1.ManualImport Tracks = model.Tracks.ToResource(), Quality = model.Quality, ReleaseGroup = model.ReleaseGroup, + IsSingleFileRelease = model.IsSingleFileRelease, + CueSheetPath = model.CueSheetPath, // QualityWeight DownloadId = model.DownloadId, diff --git a/src/Lidarr.Api.V1/ManualImport/ManualImportUpdateResource.cs b/src/Lidarr.Api.V1/ManualImport/ManualImportUpdateResource.cs index 84a513807..4e9ec7c03 100644 --- a/src/Lidarr.Api.V1/ManualImport/ManualImportUpdateResource.cs +++ b/src/Lidarr.Api.V1/ManualImport/ManualImportUpdateResource.cs @@ -21,7 +21,8 @@ namespace Lidarr.Api.V1.ManualImport public bool AdditionalFile { get; set; } public bool ReplaceExistingFiles { get; set; } public bool DisableReleaseSwitching { get; set; } - + public bool IsSingleFileRelease { get; set; } + public string CueSheetPath { get; set; } public IEnumerable Rejections { get; set; } } } diff --git a/src/NzbDrone.Core/Datastore/Migration/078_add_flac_cue.cs b/src/NzbDrone.Core/Datastore/Migration/078_add_flac_cue.cs new file mode 100644 index 000000000..c4cd96d50 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/078_add_flac_cue.cs @@ -0,0 +1,14 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(078)] + public class add_flac_cue : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Alter.Table("TrackFiles").AddColumn("IsSingleFileRelease").AsBoolean().WithDefaultValue(false); + } + } +} diff --git a/src/NzbDrone.Core/Lidarr.Core.csproj b/src/NzbDrone.Core/Lidarr.Core.csproj index 436a2404f..2be5ce39d 100644 --- a/src/NzbDrone.Core/Lidarr.Core.csproj +++ b/src/NzbDrone.Core/Lidarr.Core.csproj @@ -4,6 +4,7 @@ + @@ -29,6 +30,7 @@ + diff --git a/src/NzbDrone.Core/MediaFiles/CueSheet.cs b/src/NzbDrone.Core/MediaFiles/CueSheet.cs new file mode 100644 index 000000000..7ff4ee458 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/CueSheet.cs @@ -0,0 +1,38 @@ +using System.Collections.Generic; +using NzbDrone.Core.Datastore; + +namespace NzbDrone.Core.MediaFiles +{ + public class CueSheet : ModelBase + { + public class IndexEntry + { + public int Key { get; set; } + public string Time { get; set; } + } + + public class TrackEntry + { + public int Number { get; set; } + public string Title { get; set; } + public List Performers { get; set; } = new List(); + public List Indices { get; set; } = new List(); + } + + public class FileEntry + { + public string Name { get; set; } + public IndexEntry Index { get; set; } + public List Tracks { get; set; } = new List(); + } + + public string Path { get; set; } + public bool IsSingleFileRelease { get; set; } + public List Files { get; set; } = new List(); + public string Genre { get; set; } + public string Date { get; set; } + public string DiscID { get; set; } + public string Title { get; set; } + public List Performers { get; set; } = new List(); + } +} diff --git a/src/NzbDrone.Core/MediaFiles/CueSheetService.cs b/src/NzbDrone.Core/MediaFiles/CueSheetService.cs new file mode 100644 index 000000000..0efd07df4 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/CueSheetService.cs @@ -0,0 +1,491 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Abstractions; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using Diacritics.Extensions; +using NLog; +using NzbDrone.Core.MediaFiles.TrackImport; +using NzbDrone.Core.Music; +using NzbDrone.Core.Parser; +using NzbDrone.Core.Parser.Model; +using UtfUnknown; + +namespace NzbDrone.Core.MediaFiles +{ + public class CueSheetInfo + { + public List MusicFiles { get; set; } = new List(); + public IdentificationOverrides IdOverrides { get; set; } + public CueSheet CueSheet { get; set; } + public bool IsForMediaFile(string path) => CueSheet != null && CueSheet.Files.Count > 0 && (Path.GetDirectoryName(path) == Path.GetDirectoryName(CueSheet.Path)) && CueSheet.Files.Any(x => Path.GetFileName(path) == x.Name); + public CueSheet.FileEntry TryToGetFileEntryForMediaFile(string path) + { + if (CueSheet != null && CueSheet.Files.Count > 0) + { + return CueSheet.Files.Find(x => Path.GetFileName(path) == x.Name); + } + + return null; + } + } + + public interface ICueSheetService + { + List> GetImportDecisions(ref List mediaFileList, IdentificationOverrides idOverrides, ImportDecisionMakerInfo itemInfo, ImportDecisionMakerConfig config); + } + + public class CueSheetService : ICueSheetService + { + private readonly IParsingService _parsingService; + private readonly IMakeImportDecision _importDecisionMaker; + private readonly Logger _logger; + + private static string _FileKey = "FILE"; + private static string _TrackKey = "TRACK"; + private static string _IndexKey = "INDEX"; + private static string _GenreKey = "REM GENRE"; + private static string _DateKey = "REM DATE"; + private static string _DiscIdKey = "REM DISCID"; + private static string _PerformerKey = "PERFORMER"; + private static string _TitleKey = "TITLE"; + + public CueSheetService(IParsingService parsingService, + IMakeImportDecision importDecisionMaker, + Logger logger) + { + _parsingService = parsingService; + _importDecisionMaker = importDecisionMaker; + _logger = logger; + } + + private class PunctuationReplacer + { + private readonly Dictionary _replacements = new Dictionary + { + { '‘', '\'' }, { '’', '\'' }, // Single quotes + { '“', '"' }, { '”', '"' }, // Double quotes + { '‹', '<' }, { '›', '>' }, // Angle quotes + { '«', '<' }, { '»', '>' }, // Guillemets + { '–', '-' }, { '—', '-' }, // Dashes + { '…', '.' }, // Ellipsis + { '¡', '!' }, { '¿', '?' }, // Inverted punctuation (Spanish) + }; + + public string ReplacePunctuation(string input) + { + var output = new StringBuilder(input.Length); + + foreach (var c in input) + { + if (_replacements.TryGetValue(c, out var replacement)) + { + output.Append(replacement); + } + else + { + output.Append(c); + } + } + + return output.ToString(); + } + } + + public List> GetImportDecisions(ref List mediaFileList, IdentificationOverrides idOverrides, ImportDecisionMakerInfo itemInfo, ImportDecisionMakerConfig config) + { + var decisions = new List>(); + var cueFiles = mediaFileList.Where(x => x.Extension.Equals(".cue")).ToList(); + if (cueFiles.Count == 0) + { + return decisions; + } + + mediaFileList.RemoveAll(l => cueFiles.Contains(l)); + var cueSheetInfos = new List(); + foreach (var cueFile in cueFiles) + { + var cueSheetInfo = GetCueSheetInfo(cueFile, mediaFileList, itemInfo.DetectCueFileEncoding); + if (idOverrides != null) + { + cueSheetInfo.IdOverrides = idOverrides; + } + + var addedCueSheetInfo = cueSheetInfos.Find(existingCueSheetInfo => existingCueSheetInfo.CueSheet.DiscID == cueSheetInfo.CueSheet.DiscID); + if (addedCueSheetInfo == null) + { + cueSheetInfos.Add(cueSheetInfo); + } + + // If there are multiple cue sheet files for the same disc, then we try to keep the last one or the one with the exact same name as the media file, if there's any + else if (cueSheetInfo.CueSheet.IsSingleFileRelease && addedCueSheetInfo.CueSheet.Files.Count > 0) + { + var mediaFileName = Path.GetFileName(addedCueSheetInfo.CueSheet.Files[0].Name); + var cueSheetFileName = Path.GetFileName(cueFile.Name); + + if (mediaFileName != cueSheetFileName) + { + cueSheetInfos.Remove(addedCueSheetInfo); + cueSheetInfos.Add(cueSheetInfo); + } + } + } + + var cueSheetInfosGroupedByDiscId = cueSheetInfos.GroupBy(x => x.CueSheet.DiscID).ToList(); + foreach (var cueSheetInfoGroup in cueSheetInfosGroupedByDiscId) + { + var audioFilesForCues = new List(); + foreach (var cueSheetInfo in cueSheetInfoGroup) + { + audioFilesForCues.AddRange(cueSheetInfo.MusicFiles); + } + + var itemInfoWithCueSheetInfos = itemInfo; + itemInfoWithCueSheetInfos.CueSheetInfos = cueSheetInfoGroup.ToList(); + decisions.AddRange(_importDecisionMaker.GetImportDecisions(audioFilesForCues, cueSheetInfoGroup.First().IdOverrides, itemInfoWithCueSheetInfos, config)); + + foreach (var cueSheetInfo in cueSheetInfos) + { + if (cueSheetInfo.CueSheet != null) + { + decisions.ForEach(item => + { + if (cueSheetInfo.IsForMediaFile(item.Item.Path)) + { + item.Item.CueSheetPath = cueSheetInfo.CueSheet.Path; + } + }); + } + + mediaFileList.RemoveAll(x => cueSheetInfo.MusicFiles.Contains(x)); + } + } + + decisions.ForEach(decision => + { + if (!decision.Item.IsSingleFileRelease) + { + return; + } + + var cueSheetFindResult = cueSheetInfos.Find(x => x.IsForMediaFile(decision.Item.Path)); + var cueSheet = cueSheetFindResult?.CueSheet; + if (cueSheet == null) + { + return; + } + + if (cueSheet.Files.Count == 0) + { + return; + } + + var tracksFromCueSheet = cueSheet.Files.SelectMany(x => x.Tracks).ToList(); + if (tracksFromCueSheet.Count == 0) + { + return; + } + + if (decision.Item.Release == null) + { + return; + } + + var tracksFromRelease = decision.Item.Release.Tracks.Value; + if (tracksFromRelease.Count == 0) + { + return; + } + + var replacer = new PunctuationReplacer(); + var i = 0; + while (i < tracksFromRelease.Count) + { + var trackFromRelease = tracksFromRelease[i]; + var trackFromReleaseTitle = NormalizeTitle(replacer, trackFromRelease.Title); + + var j = 0; + var anyMatch = false; + while (j < tracksFromCueSheet.Count) + { + var trackFromCueSheet = tracksFromCueSheet[j]; + var trackFromCueSheetTitle = NormalizeTitle(replacer, trackFromCueSheet.Title); + anyMatch = string.Equals(trackFromReleaseTitle, trackFromCueSheetTitle, StringComparison.InvariantCultureIgnoreCase); + + if (anyMatch) + { + decision.Item.Tracks.Add(trackFromRelease); + tracksFromRelease.RemoveAt(i); + tracksFromCueSheet.RemoveAt(j); + + break; + } + else + { + j++; + } + } + + if (!anyMatch) + { + i++; + } + } + }); + + return decisions; + } + + private static string NormalizeTitle(PunctuationReplacer replacer, string title) + { + title.Normalize(NormalizationForm.FormKD); + title = title.RemoveDiacritics(); + title = replacer.ReplacePunctuation(title); + return title; + } + + private CueSheet LoadCueSheet(IFileInfo fileInfo, bool detectCueFileEncoding) + { + using (var fs = fileInfo.OpenRead()) + { + var bytes = new byte[fileInfo.Length]; + while (fs.Read(bytes, 0, bytes.Length) > 0) + { + string content; + if (detectCueFileEncoding) + { + var result = CharsetDetector.DetectFromFile(fileInfo.FullName); // or pass FileInfo + var encoding = result.Detected.Encoding; + _logger.Debug("Detected encoding {0} for {1}", encoding.WebName, fileInfo.FullName); + content = encoding.GetString(bytes); + } + else + { + content = Encoding.UTF8.GetString(bytes); + } + + var lines = content.Split(new string[] { Environment.NewLine }, StringSplitOptions.None); + var cueSheet = ParseLines(lines); + + // Single-file cue means it's an unsplit image, which should be specially treated in the pipeline + cueSheet.IsSingleFileRelease = cueSheet.Files.Count == 1; + cueSheet.Path = fileInfo.FullName; + + return cueSheet; + } + } + + return new CueSheet(); + } + + private string ExtractValue(string line, string keyword) + { + var pattern = keyword + @"\s+(?:(?:\""(.*?)\"")|(.+))"; + var match = Regex.Match(line, pattern); + + if (match.Success) + { + var value = match.Groups[1].Success ? match.Groups[1].Value : match.Groups[2].Value; + return value; + } + + return ""; + } + + private List ExtractPerformers(string line) + { + var delimiters = new char[] { ',', ';' }; + var performers = ExtractValue(line, _PerformerKey); + return performers.Split(delimiters, StringSplitOptions.RemoveEmptyEntries).Select(s => s.Trim()).ToList(); + } + + private bool GetNewLine(ref int index, ref string newLine, string[] lines) + { + if (index < lines.Length) + { + newLine = lines[index]; + index++; + return true; + } + + return false; + } + + private CueSheet ParseLines(string[] lines) + { + var cueSheet = new CueSheet(); + + var i = 0; + string line = null; + + while (GetNewLine(ref i, ref line, lines)) + { + if (line.StartsWith(_FileKey)) + { + line = line.Trim(); + line = line.Substring(_FileKey.Length).Trim(); + var filename = line.Split('"')[1]; + var fileDetails = new CueSheet.FileEntry { Name = filename }; + + if (!GetNewLine(ref i, ref line, lines)) + { + break; + } + + while (line.StartsWith(" ")) + { + line = line.Trim(); + if (line.StartsWith(_TrackKey)) + { + line = line.Substring(_TrackKey.Length).Trim(); + } + + var trackDetails = new CueSheet.TrackEntry(); + var trackInfo = line.Split(' '); + if (trackInfo.Length > 0) + { + if (int.TryParse(trackInfo[0], out var number)) + { + trackDetails.Number = number; + } + } + + if (!GetNewLine(ref i, ref line, lines)) + { + break; + } + + while (line.StartsWith(" ")) + { + line = line.Trim(); + if (line.StartsWith(_IndexKey)) + { + line = line.Substring(_IndexKey.Length).Trim(); + var parts = line.Split(' '); + if (parts.Length > 1) + { + if (int.TryParse(parts[0], out var key)) + { + var value = parts[1].Trim('"'); + trackDetails.Indices.Add(new CueSheet.IndexEntry { Key = key, Time = value }); + } + } + } + else if (line.StartsWith(_TitleKey)) + { + trackDetails.Title = ExtractValue(line, _TitleKey); + } + else if (line.StartsWith(_PerformerKey)) + { + trackDetails.Performers = ExtractPerformers(line); + } + + if (!GetNewLine(ref i, ref line, lines)) + { + break; + } + } + + fileDetails.Tracks.Add(trackDetails); + } + + cueSheet.Files.Add(fileDetails); + } + else if (line.StartsWith(_GenreKey)) + { + cueSheet.Genre = ExtractValue(line, _GenreKey); + } + else if (line.StartsWith(_DateKey)) + { + cueSheet.Date = ExtractValue(line, _DateKey); + } + else if (line.StartsWith(_DiscIdKey)) + { + cueSheet.DiscID = ExtractValue(line, _DiscIdKey); + } + else if (line.StartsWith(_PerformerKey)) + { + cueSheet.Performers = ExtractPerformers(line); + } + else if (line.StartsWith(_TitleKey)) + { + cueSheet.Title = ExtractValue(line, _TitleKey); + } + } + + return cueSheet; + } + + private Artist GetArtist(List performers) + { + if (performers.Count == 1) + { + return _parsingService.GetArtist(performers[0]); + } + else if (performers.Count > 1) + { + return _parsingService.GetArtist("various artists"); + } + + return null; + } + + private CueSheetInfo GetCueSheetInfo(IFileInfo cueFile, List musicFiles, bool detectCueFileEncoding) + { + var cueSheetInfo = new CueSheetInfo(); + var cueSheet = LoadCueSheet(cueFile, detectCueFileEncoding); + if (cueSheet == null) + { + return cueSheetInfo; + } + + cueSheetInfo.CueSheet = cueSheet; + var musicFilesInTheSameDir = musicFiles.Where(musicFile => musicFile.DirectoryName == Path.GetDirectoryName(cueSheetInfo.CueSheet.Path)).ToList(); + cueSheetInfo.MusicFiles = musicFilesInTheSameDir.Where(musicFile => cueSheet.Files.Any(musicFileFromCue => musicFileFromCue.Name == musicFile.Name)).ToList(); + + cueSheetInfo.IdOverrides = new IdentificationOverrides(); + + var artistFromCue = GetArtist(cueSheet.Performers); + + if (artistFromCue == null && cueSheet.Files.Count > 0) + { + foreach (var fileEntry in cueSheet.Files) + { + foreach (var track in fileEntry.Tracks) + { + artistFromCue = GetArtist(track.Performers); + if (artistFromCue != null) + { + break; + } + } + } + } + + // The cue sheet file is too incomplete in this case + if (artistFromCue == null) + { + return cueSheetInfo; + } + + cueSheetInfo.IdOverrides.Artist = artistFromCue; + + var parsedAlbumInfo = new ParsedAlbumInfo + { + AlbumTitle = cueSheet.Title, + ArtistName = artistFromCue.Name, + ReleaseDate = cueSheet.Date, + }; + + var albumsFromCue = _parsingService.GetAlbums(parsedAlbumInfo, artistFromCue); + if (albumsFromCue != null && albumsFromCue.Count > 0) + { + cueSheetInfo.IdOverrides.Album = albumsFromCue[0]; + } + + return cueSheetInfo; + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/DiskScanService.cs b/src/NzbDrone.Core/MediaFiles/DiskScanService.cs index 0c5c4ca05..ecdcfb41e 100644 --- a/src/NzbDrone.Core/MediaFiles/DiskScanService.cs +++ b/src/NzbDrone.Core/MediaFiles/DiskScanService.cs @@ -11,12 +11,14 @@ using NzbDrone.Common.Disk; using NzbDrone.Common.Extensions; using NzbDrone.Common.Instrumentation.Extensions; using NzbDrone.Core.Configuration; +using NzbDrone.Core.Datastore; using NzbDrone.Core.MediaFiles.Commands; using NzbDrone.Core.MediaFiles.Events; using NzbDrone.Core.MediaFiles.TrackImport; using NzbDrone.Core.Messaging.Commands; using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Music; +using NzbDrone.Core.Parser.Model; using NzbDrone.Core.RootFolders; namespace NzbDrone.Core.MediaFiles @@ -41,6 +43,7 @@ namespace NzbDrone.Core.MediaFiles private readonly IDiskProvider _diskProvider; private readonly IMediaFileService _mediaFileService; private readonly IMakeImportDecision _importDecisionMaker; + private readonly ICueSheetService _cueSheetService; private readonly IImportApprovedTracks _importApprovedTracks; private readonly IArtistService _artistService; private readonly IMediaFileTableCleanupService _mediaFileTableCleanupService; @@ -52,6 +55,7 @@ namespace NzbDrone.Core.MediaFiles IDiskProvider diskProvider, IMediaFileService mediaFileService, IMakeImportDecision importDecisionMaker, + ICueSheetService cueSheetService, IImportApprovedTracks importApprovedTracks, IArtistService artistService, IRootFolderService rootFolderService, @@ -63,6 +67,7 @@ namespace NzbDrone.Core.MediaFiles _diskProvider = diskProvider; _mediaFileService = mediaFileService; _importDecisionMaker = importDecisionMaker; + _cueSheetService = cueSheetService; _importApprovedTracks = importApprovedTracks; _artistService = artistService; _mediaFileTableCleanupService = mediaFileTableCleanupService; @@ -83,6 +88,32 @@ namespace NzbDrone.Core.MediaFiles artistIds = new List(); } + var mediaFileList = GetMediaFiles(folders, artistIds); + + var decisionsStopwatch = Stopwatch.StartNew(); + + var itemInfo = new ImportDecisionMakerInfo(); + var config = new ImportDecisionMakerConfig + { + Filter = filter, + IncludeExisting = true, + AddNewArtists = addNewArtists + }; + + var decisions = new List>(); + + itemInfo.DetectCueFileEncoding = false; + decisions.AddRange(_cueSheetService.GetImportDecisions(ref mediaFileList, null, itemInfo, config)); + decisions.AddRange(_importDecisionMaker.GetImportDecisions(mediaFileList, null, itemInfo, config)); + + decisionsStopwatch.Stop(); + _logger.Debug("Import decisions complete [{0}]", decisionsStopwatch.Elapsed); + + Import(folders, artistIds, decisions); + } + + private List GetMediaFiles(List folders, List artistIds) + { var mediaFileList = new List(); var musicFilesStopwatch = Stopwatch.StartNew(); @@ -96,7 +127,7 @@ namespace NzbDrone.Core.MediaFiles if (rootFolder == null) { _logger.Error("Not scanning {0}, it's not a subdirectory of a defined root folder", folder); - return; + return mediaFileList; } var folderExists = _diskProvider.FolderExists(folder); @@ -108,7 +139,7 @@ namespace NzbDrone.Core.MediaFiles _logger.Warn("Artists' root folder ({0}) doesn't exist.", rootFolder.Path); var skippedArtists = _artistService.GetArtists(artistIds); skippedArtists.ForEach(x => _eventAggregator.PublishEvent(new ArtistScanSkippedEvent(x, ArtistScanSkippedReason.RootFolderDoesNotExist))); - return; + return mediaFileList; } if (_diskProvider.FolderEmpty(rootFolder.Path)) @@ -116,7 +147,7 @@ namespace NzbDrone.Core.MediaFiles _logger.Warn("Artists' root folder ({0}) is empty.", rootFolder.Path); var skippedArtists = _artistService.GetArtists(artistIds); skippedArtists.ForEach(x => _eventAggregator.PublishEvent(new ArtistScanSkippedEvent(x, ArtistScanSkippedReason.RootFolderIsEmpty))); - return; + return mediaFileList; } } @@ -145,20 +176,11 @@ namespace NzbDrone.Core.MediaFiles musicFilesStopwatch.Stop(); _logger.Trace("Finished getting track files for:\n{0} [{1}]", folders.ConcatToString("\n"), musicFilesStopwatch.Elapsed); - var decisionsStopwatch = Stopwatch.StartNew(); - - var config = new ImportDecisionMakerConfig - { - Filter = filter, - IncludeExisting = true, - AddNewArtists = addNewArtists - }; - - var decisions = _importDecisionMaker.GetImportDecisions(mediaFileList, null, null, config); - - decisionsStopwatch.Stop(); - _logger.Debug("Import decisions complete [{0}]", decisionsStopwatch.Elapsed); + return mediaFileList; + } + private void Import(List folders, List artistIds, List> decisions) + { var importStopwatch = Stopwatch.StartNew(); _importApprovedTracks.Import(decisions, false); @@ -177,7 +199,8 @@ namespace NzbDrone.Core.MediaFiles Modified = decision.Item.Modified, DateAdded = DateTime.UtcNow, Quality = decision.Item.Quality, - MediaInfo = decision.Item.FileTrackInfo.MediaInfo + MediaInfo = decision.Item.FileTrackInfo.MediaInfo, + IsSingleFileRelease = decision.Item.IsSingleFileRelease, }) .ToList(); _mediaFileService.AddMany(newFiles); diff --git a/src/NzbDrone.Core/MediaFiles/MediaFileExtensions.cs b/src/NzbDrone.Core/MediaFiles/MediaFileExtensions.cs index b87fcc619..8833ba94d 100644 --- a/src/NzbDrone.Core/MediaFiles/MediaFileExtensions.cs +++ b/src/NzbDrone.Core/MediaFiles/MediaFileExtensions.cs @@ -27,7 +27,8 @@ namespace NzbDrone.Core.MediaFiles { ".ape", Quality.APE }, { ".aif", Quality.Unknown }, { ".aiff", Quality.Unknown }, - { ".aifc", Quality.Unknown } + { ".aifc", Quality.Unknown }, + { ".cue", Quality.Unknown } }; } diff --git a/src/NzbDrone.Core/MediaFiles/TrackFile.cs b/src/NzbDrone.Core/MediaFiles/TrackFile.cs index de5d8a805..07eb8a694 100644 --- a/src/NzbDrone.Core/MediaFiles/TrackFile.cs +++ b/src/NzbDrone.Core/MediaFiles/TrackFile.cs @@ -21,6 +21,7 @@ namespace NzbDrone.Core.MediaFiles public QualityModel Quality { get; set; } public MediaInfoModel MediaInfo { get; set; } public int AlbumId { get; set; } + public bool IsSingleFileRelease { get; set; } // These are queried from the database public LazyLoaded> Tracks { get; set; } diff --git a/src/NzbDrone.Core/MediaFiles/TrackFileMovingService.cs b/src/NzbDrone.Core/MediaFiles/TrackFileMovingService.cs index fe48b4d6d..7e24955d1 100644 --- a/src/NzbDrone.Core/MediaFiles/TrackFileMovingService.cs +++ b/src/NzbDrone.Core/MediaFiles/TrackFileMovingService.cs @@ -4,6 +4,7 @@ using System.IO; using NLog; using NzbDrone.Common.Disk; using NzbDrone.Common.EnsureThat; +using NzbDrone.Common.Extensions; using NzbDrone.Core.Configuration; using NzbDrone.Core.MediaFiles.Events; using NzbDrone.Core.MediaFiles.TrackImport; @@ -79,6 +80,8 @@ namespace NzbDrone.Core.MediaFiles EnsureTrackFolder(trackFile, localTrack, filePath); + TryToCreateCueFile(localTrack, filePath); + _logger.Debug("Moving track file: {0} to {1}", trackFile.Path, filePath); return TransferFile(trackFile, localTrack.Artist, localTrack.Tracks, filePath, TransferMode.Move); @@ -90,6 +93,8 @@ namespace NzbDrone.Core.MediaFiles EnsureTrackFolder(trackFile, localTrack, filePath); + TryToCreateCueFile(localTrack, filePath); + if (_configService.CopyUsingHardlinks) { _logger.Debug("Attempting to hardlink track file: {0} to {1}", trackFile.Path, filePath); @@ -100,6 +105,24 @@ namespace NzbDrone.Core.MediaFiles return TransferFile(trackFile, localTrack.Artist, localTrack.Tracks, filePath, TransferMode.Copy); } + private void TryToCreateCueFile(LocalTrack localTrack, string trackFilePath) + { + if (localTrack.IsSingleFileRelease && !localTrack.CueSheetPath.Empty()) + { + var directory = Path.GetDirectoryName(trackFilePath); + var fileName = Path.GetFileNameWithoutExtension(trackFilePath); + var cueSheetPath = Path.Combine(directory, fileName + ".cue"); + _diskTransferService.TransferFile(localTrack.CueSheetPath, cueSheetPath, TransferMode.Copy, true); + var lines = new List(File.ReadAllLines(cueSheetPath)); + var fileLineIndex = lines.FindIndex(line => line.Contains("FILE")); + if (fileLineIndex != -1) + { + lines[fileLineIndex] = "FILE \"" + Path.GetFileName(trackFilePath) + "\" WAVE"; + File.WriteAllLines(cueSheetPath, lines); + } + } + } + private TrackFile TransferFile(TrackFile trackFile, Artist artist, List tracks, string destinationFilePath, TransferMode mode) { Ensure.That(trackFile, () => trackFile).IsNotNull(); diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Aggregation/Aggregators/AggregateFilenameInfo.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Aggregation/Aggregators/AggregateFilenameInfo.cs index 28c2c3633..1b0c96a0a 100644 --- a/src/NzbDrone.Core/MediaFiles/TrackImport/Aggregation/Aggregators/AggregateFilenameInfo.cs +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Aggregation/Aggregators/AggregateFilenameInfo.cs @@ -65,14 +65,28 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Aggregation.Aggregators || tracks.Any(x => x.FileTrackInfo.DiscNumber == 0)) { _logger.Debug("Missing data in tags, trying filename augmentation"); - foreach (var charSep in CharsAndSeps) + if (release.IsSingleFileRelease) { - foreach (var pattern in Patterns(charSep.Item1, charSep.Item2)) + for (var i = 0; i < tracks.Count; ++i) { - var matches = AllMatches(tracks, pattern); - if (matches != null) + tracks[i].FileTrackInfo.ArtistTitle = tracks[i].Artist?.Name; + tracks[i].FileTrackInfo.AlbumTitle = tracks[i].Album?.Title; + + // TODO this is too bold, the release year is not the one from the .cue file + tracks[i].FileTrackInfo.Year = (uint)(tracks[i].Album?.ReleaseDate?.Year ?? 0); + } + } + else + { + foreach (var charSep in CharsAndSeps) + { + foreach (var pattern in Patterns(charSep.Item1, charSep.Item2)) { - ApplyMatches(matches, pattern); + var matches = AllMatches(tracks, pattern); + if (matches != null) + { + ApplyMatches(matches, pattern); + } } } } diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Identification/CandidateService.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Identification/CandidateService.cs index 6fb62bff0..f74508c3a 100644 --- a/src/NzbDrone.Core/MediaFiles/TrackImport/Identification/CandidateService.cs +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Identification/CandidateService.cs @@ -131,6 +131,13 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Identification private List GetDbCandidatesByAlbum(LocalAlbumRelease localAlbumRelease, Album album, bool includeExisting) { + if (localAlbumRelease.IsSingleFileRelease) + { + return GetDbCandidatesByRelease(_releaseService.GetReleasesByAlbum(album.Id) + .OrderBy(x => x.ReleaseDate) + .ToList(), includeExisting); + } + // sort candidate releases by closest track count so that we stand a chance of // getting a perfect match early on return GetDbCandidatesByRelease(_releaseService.GetReleasesByAlbum(album.Id) diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Identification/DistanceCalculator.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Identification/DistanceCalculator.cs index 833ccb12f..d951a3938 100644 --- a/src/NzbDrone.Core/MediaFiles/TrackImport/Identification/DistanceCalculator.cs +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Identification/DistanceCalculator.cs @@ -30,6 +30,11 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Identification localTrack.FileTrackInfo.TrackNumbers[0] != totalTrackNumber; } + private static bool TrackIndexIncorrect(CueSheet.TrackEntry cuesheetTrack, Track mbTrack, int totalTrackNumber) + { + return cuesheetTrack.Number != mbTrack.AbsoluteTrackNumber; + } + public static int GetTotalTrackNumber(Track track, List allTracks) { return track.AbsoluteTrackNumber + allTracks.Count(t => t.MediumNumber < track.MediumNumber); @@ -79,6 +84,28 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Identification return dist; } + public static Distance TrackDistance(CueSheet.TrackEntry cuesheetTrack, Track mbTrack, int totalTrackNumber, bool includeArtist = false) + { + var dist = new Distance(); + + // musicbrainz never has 'featuring' in the track title + // see https://musicbrainz.org/doc/Style/Artist_Credits + dist.AddString("track_title", cuesheetTrack.Title ?? "", mbTrack.Title); + + if (includeArtist && cuesheetTrack.Performers.Count == 1 + && !VariousArtistNames.Any(x => x.Equals(cuesheetTrack.Performers[0], StringComparison.InvariantCultureIgnoreCase))) + { + dist.AddString("track_artist", cuesheetTrack.Performers[0], mbTrack.ArtistMetadata.Value.Name); + } + + if (mbTrack.AbsoluteTrackNumber > 0) + { + dist.AddBool("track_index", TrackIndexIncorrect(cuesheetTrack, mbTrack, totalTrackNumber)); + } + + return dist; + } + public static Distance AlbumReleaseDistance(List localTracks, AlbumRelease release, TrackMapping mapping) { var dist = new Distance(); @@ -118,13 +145,16 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Identification { var albumYear = release.Album.Value.ReleaseDate?.Year ?? 0; var releaseYear = release.ReleaseDate?.Year ?? 0; - if (localYear == albumYear || localYear == releaseYear) + + // The single file version's year is from the album year already, to avoid false positives here we consider it's always different + var isSameWithAlbumYear = localTracks.All(x => x.IsSingleFileRelease == true) ? false : localYear == albumYear; + if (isSameWithAlbumYear || localYear == releaseYear) { dist.Add("year", 0.0); } else { - var remoteYear = albumYear > 0 ? albumYear : releaseYear; + var remoteYear = (albumYear > 0 && isSameWithAlbumYear) ? albumYear : releaseYear; var diff = Math.Abs(localYear - remoteYear); var diff_max = Math.Abs(DateTime.Now.Year - remoteYear); dist.AddRatio("year", diff, diff_max); @@ -176,12 +206,32 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Identification } // tracks - foreach (var pair in mapping.Mapping) + if (mapping.CuesheetTrackMapping.Count != 0) { - dist.Add("tracks", pair.Value.Item2.NormalizedDistance()); + foreach (var pair in mapping.CuesheetTrackMapping) + { + dist.Add("tracks", pair.Value.Item2.NormalizedDistance()); + } + + Logger.Trace("after trackMapping: {0}", dist.NormalizedDistance()); } + else + { + foreach (var pair in mapping.Mapping) + { + dist.Add("tracks", pair.Value.Item2.NormalizedDistance()); + } - Logger.Trace("after trackMapping: {0}", dist.NormalizedDistance()); + Logger.Trace("after trackMapping: {0}", dist.NormalizedDistance()); + + // unmatched tracks + foreach (var track in mapping.LocalExtra.Take(localTracks.Count)) + { + dist.Add("unmatched_tracks", 1.0); + } + + Logger.Trace("after unmatched tracks: {0}", dist.NormalizedDistance()); + } // missing tracks foreach (var track in mapping.MBExtra.Take(localTracks.Count)) @@ -191,14 +241,6 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Identification Logger.Trace("after missing tracks: {0}", dist.NormalizedDistance()); - // unmatched tracks - foreach (var track in mapping.LocalExtra.Take(localTracks.Count)) - { - dist.Add("unmatched_tracks", 1.0); - } - - Logger.Trace("after unmatched tracks: {0}", dist.NormalizedDistance()); - return dist; } } diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Identification/IdentificationService.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Identification/IdentificationService.cs index ccd0b3939..b1b7dff9f 100644 --- a/src/NzbDrone.Core/MediaFiles/TrackImport/Identification/IdentificationService.cs +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Identification/IdentificationService.cs @@ -154,6 +154,11 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Identification private bool ShouldFingerprint(LocalAlbumRelease localAlbumRelease) { + if (localAlbumRelease.IsSingleFileRelease) + { + return false; + } + var worstTrackMatchDist = localAlbumRelease.TrackMapping?.Mapping .DefaultIfEmpty() .MaxBy(x => x.Value.Item2.NormalizedDistance()) @@ -182,7 +187,8 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Identification FileTrackInfo = _audioTagService.ReadTags(x.Path), ExistingFile = true, AdditionalFile = true, - Quality = x.Quality + Quality = x.Quality, + IsSingleFileRelease = x.IsSingleFileRelease, })) .ToList(); @@ -317,7 +323,8 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Identification var extraTracks = extraTracksOnDisk.Where(x => extraTrackPaths.Contains(x.Path)).ToList(); var allLocalTracks = localAlbumRelease.LocalTracks.Concat(extraTracks).DistinctBy(x => x.Path).ToList(); - var mapping = MapReleaseTracks(allLocalTracks, release.Tracks.Value); + var isSingleFileRelease = allLocalTracks.All(x => x.IsSingleFileRelease == true); + var mapping = isSingleFileRelease ? MapSingleFileReleaseTracks(allLocalTracks, release.Tracks.Value) : MapReleaseTracks(allLocalTracks, release.Tracks.Value); var distance = DistanceCalculator.AlbumReleaseDistance(allLocalTracks, release, mapping); var currDistance = distance.NormalizedDistance(); @@ -335,6 +342,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Identification localAlbumRelease.AlbumRelease = release; localAlbumRelease.ExistingTracks = extraTracks; localAlbumRelease.TrackMapping = mapping; + if (currDistance == 0.0) { break; @@ -348,6 +356,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Identification public TrackMapping MapReleaseTracks(List localTracks, List mbTracks) { + var result = new TrackMapping(); var distances = new Distance[localTracks.Count, mbTracks.Count]; var costs = new double[localTracks.Count, mbTracks.Count]; @@ -364,7 +373,6 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Identification var m = new Munkres(costs); m.Run(); - var result = new TrackMapping(); foreach (var pair in m.Solution) { result.Mapping.Add(localTracks[pair.Item1], Tuple.Create(mbTracks[pair.Item2], distances[pair.Item1, pair.Item2])); @@ -379,5 +387,46 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Identification return result; } + + public TrackMapping MapSingleFileReleaseTracks(List localTracks, List mbTracks) + { + var result = new TrackMapping(); + + var cuesheetTracks = new List(); + foreach (var localTrack in localTracks) + { + if (localTrack.CueSheetFileEntry != null) + { + cuesheetTracks.AddRange(localTrack.CueSheetFileEntry.Tracks); + } + } + + var distances = new Distance[cuesheetTracks.Count, mbTracks.Count]; + var costs = new double[cuesheetTracks.Count, mbTracks.Count]; + + for (var col = 0; col < mbTracks.Count; col++) + { + var totalTrackNumber = DistanceCalculator.GetTotalTrackNumber(mbTracks[col], mbTracks); + for (var row = 0; row < cuesheetTracks.Count; row++) + { + distances[row, col] = DistanceCalculator.TrackDistance(cuesheetTracks[row], mbTracks[col], totalTrackNumber, false); + costs[row, col] = distances[row, col].NormalizedDistance(); + } + } + + var m = new Munkres(costs); + m.Run(); + + foreach (var pair in m.Solution) + { + result.CuesheetTrackMapping.Add(cuesheetTracks[pair.Item1], Tuple.Create(mbTracks[pair.Item2], distances[pair.Item1, pair.Item2])); + _logger.Trace("Mapped {0} to {1}, dist: {2}", cuesheetTracks[pair.Item1], mbTracks[pair.Item2], costs[pair.Item1, pair.Item2]); + } + + result.MBExtra = mbTracks.Except(result.CuesheetTrackMapping.Values.Select(x => x.Item1)).ToList(); + _logger.Trace($"Missing tracks:\n{string.Join("\n", result.MBExtra)}"); + + return result; + } } } diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/ImportApprovedTracks.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/ImportApprovedTracks.cs index ba54e10c8..535751ca7 100644 --- a/src/NzbDrone.Core/MediaFiles/TrackImport/ImportApprovedTracks.cs +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/ImportApprovedTracks.cs @@ -194,7 +194,8 @@ namespace NzbDrone.Core.MediaFiles.TrackImport AlbumId = localTrack.Album.Id, Artist = localTrack.Artist, Album = localTrack.Album, - Tracks = localTrack.Tracks + Tracks = localTrack.Tracks, + IsSingleFileRelease = localTrack.IsSingleFileRelease, }; bool copyOnly; diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/ImportDecisionMaker.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/ImportDecisionMaker.cs index aae1a9e6f..9c5fb332e 100644 --- a/src/NzbDrone.Core/MediaFiles/TrackImport/ImportDecisionMaker.cs +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/ImportDecisionMaker.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.IO.Abstractions; using System.Linq; +using DryIoc.ImTools; using NLog; using NzbDrone.Common.Extensions; using NzbDrone.Common.Instrumentation.Extensions; @@ -10,6 +11,7 @@ using NzbDrone.Core.Download; using NzbDrone.Core.MediaFiles.TrackImport.Aggregation; using NzbDrone.Core.MediaFiles.TrackImport.Identification; using NzbDrone.Core.Music; +using NzbDrone.Core.Parser; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Profiles.Qualities; using NzbDrone.Core.RootFolders; @@ -32,6 +34,8 @@ namespace NzbDrone.Core.MediaFiles.TrackImport { public DownloadClientItem DownloadClientItem { get; set; } public ParsedAlbumInfo ParsedAlbumInfo { get; set; } + public List CueSheetInfos { get; set; } = new List(); + public bool DetectCueFileEncoding { get; set; } } public class ImportDecisionMakerConfig @@ -48,6 +52,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport private readonly IEnumerable> _trackSpecifications; private readonly IEnumerable> _albumSpecifications; private readonly IMediaFileService _mediaFileService; + private readonly IParsingService _parsingService; private readonly IAudioTagService _audioTagService; private readonly IAugmentingService _augmentingService; private readonly IIdentificationService _identificationService; @@ -58,6 +63,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport public ImportDecisionMaker(IEnumerable> trackSpecifications, IEnumerable> albumSpecifications, IMediaFileService mediaFileService, + IParsingService parsingService, IAudioTagService audioTagService, IAugmentingService augmentingService, IIdentificationService identificationService, @@ -68,6 +74,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport _trackSpecifications = trackSpecifications; _albumSpecifications = albumSpecifications; _mediaFileService = mediaFileService; + _parsingService = parsingService; _audioTagService = audioTagService; _augmentingService = augmentingService; _identificationService = identificationService; @@ -113,7 +120,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport Size = file.Length, Modified = file.LastWriteTimeUtc, FileTrackInfo = _audioTagService.ReadTags(file.FullName), - AdditionalFile = false + AdditionalFile = false, }; try @@ -149,6 +156,38 @@ namespace NzbDrone.Core.MediaFiles.TrackImport var decisions = trackData.Item2; localTracks.ForEach(x => x.ExistingFile = !config.NewDownload); + if (!itemInfo.CueSheetInfos.Empty()) + { + localTracks.ForEach(localTrack => + { + var cueSheetInfo = itemInfo.CueSheetInfos.Find(x => x.IsForMediaFile(localTrack.Path)); + var cueSheet = cueSheetInfo?.CueSheet; + if (cueSheet != null) + { + localTrack.IsSingleFileRelease = cueSheet.IsSingleFileRelease; + localTrack.CueSheetFileEntry = cueSheetInfo.TryToGetFileEntryForMediaFile(localTrack.Path); + localTrack.Artist = idOverrides.Artist; + localTrack.Album = idOverrides.Album; + } + }); + } + + var localTracksByAlbums = localTracks.GroupBy(x => x.Album); + foreach (var localTracksByAlbum in localTracksByAlbums) + { + if (!localTracksByAlbum.All(x => x.IsSingleFileRelease == true)) + { + continue; + } + + localTracks.ForEach(x => + { + if (x.IsSingleFileRelease && localTracksByAlbum.Contains(x)) + { + x.FileTrackInfo.DiscCount = localTracksByAlbum.Count(); + } + }); + } var releases = _identificationService.Identify(localTracks, idOverrides, config); @@ -246,7 +285,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport { ImportDecision decision = null; - if (localTrack.Tracks.Empty()) + if (!localTrack.IsSingleFileRelease && localTrack.Tracks.Empty()) { decision = localTrack.Album != null ? new ImportDecision(localTrack, new Rejection($"Couldn't parse track from: {localTrack.FileTrackInfo}")) : new ImportDecision(localTrack, new Rejection($"Couldn't parse album from: {localTrack.FileTrackInfo}")); diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Manual/ManualImportFile.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Manual/ManualImportFile.cs index 9faec9a65..598ae785c 100644 --- a/src/NzbDrone.Core/MediaFiles/TrackImport/Manual/ManualImportFile.cs +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Manual/ManualImportFile.cs @@ -15,6 +15,8 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Manual public QualityModel Quality { get; set; } public string DownloadId { get; set; } public bool DisableReleaseSwitching { get; set; } + public bool IsSingleFileRelease { get; set; } + public string CueSheetPath { get; set; } public bool Equals(ManualImportFile other) { diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Manual/ManualImportItem.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Manual/ManualImportItem.cs index b96fbc045..8456dfa5f 100644 --- a/src/NzbDrone.Core/MediaFiles/TrackImport/Manual/ManualImportItem.cs +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Manual/ManualImportItem.cs @@ -32,5 +32,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Manual public bool AdditionalFile { get; set; } public bool ReplaceExistingFiles { get; set; } public bool DisableReleaseSwitching { get; set; } + public bool IsSingleFileRelease { get; set; } + public string CueSheetPath { get; set; } } } diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Manual/ManualImportService.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Manual/ManualImportService.cs index 0b8a18f6c..ce7de7519 100644 --- a/src/NzbDrone.Core/MediaFiles/TrackImport/Manual/ManualImportService.cs +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Manual/ManualImportService.cs @@ -10,6 +10,7 @@ using NzbDrone.Common.Disk; using NzbDrone.Common.Extensions; using NzbDrone.Common.Instrumentation.Extensions; using NzbDrone.Core.CustomFormats; +using NzbDrone.Core.Datastore; using NzbDrone.Core.DecisionEngine; using NzbDrone.Core.Download; using NzbDrone.Core.Download.TrackedDownloads; @@ -36,6 +37,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Manual private readonly IRootFolderService _rootFolderService; private readonly IDiskScanService _diskScanService; private readonly IMakeImportDecision _importDecisionMaker; + private readonly ICueSheetService _cueSheetService; private readonly ICustomFormatCalculationService _formatCalculator; private readonly IArtistService _artistService; private readonly IAlbumService _albumService; @@ -54,6 +56,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Manual IRootFolderService rootFolderService, IDiskScanService diskScanService, IMakeImportDecision importDecisionMaker, + ICueSheetService cueSheetService, ICustomFormatCalculationService formatCalculator, IArtistService artistService, IAlbumService albumService, @@ -72,6 +75,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Manual _rootFolderService = rootFolderService; _diskScanService = diskScanService; _importDecisionMaker = importDecisionMaker; + _cueSheetService = cueSheetService; _formatCalculator = formatCalculator; _artistService = artistService; _albumService = albumService; @@ -149,16 +153,30 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Manual } } - var artistFiles = _diskScanService.GetAudioFiles(folder).ToList(); + var audioFiles = _diskScanService.GetAudioFiles(folder).ToList(); + var results = new List(); + var idOverrides = new IdentificationOverrides { - Artist = artist + Artist = artist, + Album = null }; + + results.AddRange(ProcessFolder(downloadId, idOverrides, filter, replaceExistingFiles, downloadClientItem, directoryInfo.Name, audioFiles)); + + return results; + } + + private List ProcessFolder(string downloadId, IdentificationOverrides idOverrides, FilterFilesType filter, bool replaceExistingFiles, DownloadClientItem downloadClientItem, string albumTitle, List audioFiles, List cueSheetInfos = null) + { + idOverrides ??= new IdentificationOverrides(); var itemInfo = new ImportDecisionMakerInfo { DownloadClientItem = downloadClientItem, - ParsedAlbumInfo = Parser.Parser.ParseAlbumTitle(directoryInfo.Name) + ParsedAlbumInfo = Parser.Parser.ParseAlbumTitle(albumTitle), + DetectCueFileEncoding = true, }; + var config = new ImportDecisionMakerConfig { Filter = filter, @@ -168,20 +186,26 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Manual AddNewArtists = false }; - var decisions = _importDecisionMaker.GetImportDecisions(artistFiles, idOverrides, itemInfo, config); + var decisions = _cueSheetService.GetImportDecisions(ref audioFiles, null, itemInfo, config); + if (!audioFiles.Empty()) + { + decisions.AddRange(_importDecisionMaker.GetImportDecisions(audioFiles, idOverrides, itemInfo, config)); + } // paths will be different for new and old files which is why we need to map separately - var newFiles = artistFiles.Join(decisions, + var newFiles = audioFiles.Join(decisions, f => f.FullName, d => d.Item.Path, (f, d) => new { File = f, Decision = d }, PathEqualityComparer.Instance); - var newItems = newFiles.Select(x => MapItem(x.Decision, downloadId, replaceExistingFiles, false)); + var newItemsList = newFiles.Select(x => MapItem(x.Decision, downloadId, replaceExistingFiles, false)).ToList(); + var existingDecisions = decisions.Except(newFiles.Select(x => x.Decision)); var existingItems = existingDecisions.Select(x => MapItem(x, null, replaceExistingFiles, false)); - return newItems.Concat(existingItems).ToList(); + var itemsList = newItemsList.Concat(existingItems.ToList()).ToList(); + return itemsList; } public List UpdateItems(List items) @@ -198,13 +222,6 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Manual var disableReleaseSwitching = group.First().DisableReleaseSwitching; - var files = group.Select(x => _diskProvider.GetFileInfo(x.Path)).ToList(); - var idOverride = new IdentificationOverrides - { - Artist = group.First().Artist, - Album = group.First().Album, - AlbumRelease = group.First().Release - }; var config = new ImportDecisionMakerConfig { Filter = FilterFilesType.None, @@ -213,55 +230,94 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Manual IncludeExisting = !replaceExistingFiles, AddNewArtists = false }; - var decisions = _importDecisionMaker.GetImportDecisions(files, idOverride, null, config); - var existingItems = group.Join(decisions, - i => i.Path, - d => d.Item.Path, - (i, d) => new { Item = i, Decision = d }, - PathEqualityComparer.Instance); - - foreach (var pair in existingItems) + var audioFiles = new List(); + foreach (var item in group) { - var item = pair.Item; - var decision = pair.Decision; + var file = _diskProvider.GetFileInfo(item.Path); + audioFiles.Add(file); - if (decision.Item.Artist != null) + if (item.CueSheetPath != null) { - item.Artist = decision.Item.Artist; + var cueFile = _diskProvider.GetFileInfo(item.CueSheetPath); + audioFiles.Add(cueFile); } + } - if (decision.Item.Album != null) - { - item.Album = decision.Item.Album; - item.Release = decision.Item.Release; - } + var itemInfo = new ImportDecisionMakerInfo(); + var idOverride = new IdentificationOverrides + { + Artist = group.First().Artist, + Album = group.First().Album, + AlbumRelease = group.First().Release + }; - if (decision.Item.Tracks.Any()) - { - item.Tracks = decision.Item.Tracks; - } + itemInfo.DetectCueFileEncoding = true; + var decisions = _cueSheetService.GetImportDecisions(ref audioFiles, idOverride, itemInfo, config); + if (audioFiles.Count > 0) + { + decisions.AddRange(_importDecisionMaker.GetImportDecisions(audioFiles, idOverride, itemInfo, config)); + } - if (item.Quality?.Quality == Quality.Unknown) - { - item.Quality = decision.Item.Quality; - } + if (decisions.Count > 0) + { + result.AddRange(UpdateItems(group, decisions, replaceExistingFiles, disableReleaseSwitching)); + } + } - if (item.ReleaseGroup.IsNullOrWhiteSpace()) - { - item.ReleaseGroup = decision.Item.ReleaseGroup; - } + return result; + } - item.Rejections = decision.Rejections; - item.Size = decision.Item.Size; + private List UpdateItems(IGrouping group, List> decisions, bool replaceExistingFiles, bool disableReleaseSwitching) + { + var result = new List(); + + var existingItems = group.Join(decisions, + i => i.Path, + d => d.Item.Path, + (i, d) => new { Item = i, Decision = d }, + PathEqualityComparer.Instance); + + foreach (var pair in existingItems) + { + var item = pair.Item; + var decision = pair.Decision; - result.Add(item); + if (decision.Item.Artist != null) + { + item.Artist = decision.Item.Artist; } - var newDecisions = decisions.Except(existingItems.Select(x => x.Decision)); - result.AddRange(newDecisions.Select(x => MapItem(x, null, replaceExistingFiles, disableReleaseSwitching))); + if (decision.Item.Album != null) + { + item.Album = decision.Item.Album; + item.Release = decision.Item.Release; + } + + if (decision.Item.Tracks.Any()) + { + item.Tracks = decision.Item.Tracks; + } + + if (item.Quality?.Quality == Quality.Unknown) + { + item.Quality = decision.Item.Quality; + } + + if (item.ReleaseGroup.IsNullOrWhiteSpace()) + { + item.ReleaseGroup = decision.Item.ReleaseGroup; + } + + item.Rejections = decision.Rejections; + item.Size = decision.Item.Size; + + result.Add(item); } + var newDecisions = decisions.Except(existingItems.Select(x => x.Decision)); + result.AddRange(newDecisions.Select(x => MapItem(x, null, replaceExistingFiles, disableReleaseSwitching))); + return result; } @@ -299,6 +355,8 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Manual item.AdditionalFile = decision.Item.AdditionalFile; item.ReplaceExistingFiles = replaceExistingFiles; item.DisableReleaseSwitching = disableReleaseSwitching; + item.IsSingleFileRelease = decision.Item.IsSingleFileRelease; + item.CueSheetPath = decision.Item.CueSheetPath; return item; } @@ -346,9 +404,16 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Manual Quality = file.Quality, Artist = artist, Album = album, - Release = release + Release = release, + IsSingleFileRelease = file.IsSingleFileRelease, + CueSheetPath = file.CueSheetPath, }; + if (file.IsSingleFileRelease) + { + localTrack.Tracks.ForEach(x => x.IsSingleFileRelease = true); + } + var importDecision = new ImportDecision(localTrack); if (_rootFolderService.GetBestRootFolder(artist.Path) == null) { diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/AlbumUpgradeSpecification.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/AlbumUpgradeSpecification.cs index f6deffbc9..7d5f1ff96 100644 --- a/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/AlbumUpgradeSpecification.cs +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/AlbumUpgradeSpecification.cs @@ -32,7 +32,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Specifications _logger.Debug("Min quality of new files: {0}", newMinQuality); // get minimum quality of existing release - var existingQualities = currentRelease.Tracks.Value.Where(x => x.TrackFileId != 0).Select(x => x.TrackFile.Value.Quality); + var existingQualities = currentRelease.Tracks.Value.Where(x => x.TrackFileId != 0 && x.TrackFile.Value != null).Select(x => x.TrackFile.Value.Quality); if (existingQualities.Any()) { var existingMinQuality = existingQualities.OrderBy(x => x, qualityComparer).First(); diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/CloseAlbumMatchSpecification.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/CloseAlbumMatchSpecification.cs index fda5da995..011f9197c 100644 --- a/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/CloseAlbumMatchSpecification.cs +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/CloseAlbumMatchSpecification.cs @@ -22,11 +22,17 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Specifications { double dist; string reasons; + if (item.IsSingleFileRelease) + { + _logger.Debug($"Accepting single file release {item}: {item.Distance.Reasons}"); + return Decision.Accept(); + } // strict when a new download if (item.NewDownload) { dist = item.Distance.NormalizedDistance(); + reasons = item.Distance.Reasons; if (dist > _albumThreshold) { diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/CloseTrackMatchSpecification.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/CloseTrackMatchSpecification.cs index 11ffe237a..e817d339e 100644 --- a/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/CloseTrackMatchSpecification.cs +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/CloseTrackMatchSpecification.cs @@ -17,6 +17,11 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Specifications public Decision IsSatisfiedBy(LocalTrack item, DownloadClientItem downloadClientItem) { + if (item.IsSingleFileRelease) + { + return Decision.Accept(); + } + var dist = item.Distance.NormalizedDistance(); var reasons = item.Distance.Reasons; diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/MoreTracksSpecification.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/MoreTracksSpecification.cs index 36454d3f2..816d49aa2 100644 --- a/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/MoreTracksSpecification.cs +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/MoreTracksSpecification.cs @@ -17,6 +17,11 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Specifications public Decision IsSatisfiedBy(LocalAlbumRelease item, DownloadClientItem downloadClientItem) { + if (item.IsSingleFileRelease) + { + return Decision.Accept(); + } + var existingRelease = item.AlbumRelease.Album.Value.AlbumReleases.Value.Single(x => x.Monitored); var existingTrackCount = existingRelease.Tracks.Value.Count(x => x.HasFile); if (item.AlbumRelease.Id != existingRelease.Id && diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/NoMissingOrUnmatchedTracksSpecification.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/NoMissingOrUnmatchedTracksSpecification.cs index d54fe89e9..6a614708f 100644 --- a/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/NoMissingOrUnmatchedTracksSpecification.cs +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/NoMissingOrUnmatchedTracksSpecification.cs @@ -16,6 +16,11 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Specifications public Decision IsSatisfiedBy(LocalAlbumRelease item, DownloadClientItem downloadClientItem) { + if (item.IsSingleFileRelease) + { + return Decision.Accept(); + } + if (item.NewDownload && item.TrackMapping.LocalExtra.Count > 0) { _logger.Debug("This release has track files that have not been matched. Skipping {0}", item); diff --git a/src/NzbDrone.Core/Music/Model/Track.cs b/src/NzbDrone.Core/Music/Model/Track.cs index 9c1aed1b1..57ea8f0ca 100644 --- a/src/NzbDrone.Core/Music/Model/Track.cs +++ b/src/NzbDrone.Core/Music/Model/Track.cs @@ -30,6 +30,7 @@ namespace NzbDrone.Core.Music public Ratings Ratings { get; set; } public int MediumNumber { get; set; } public int TrackFileId { get; set; } + public bool IsSingleFileRelease { get; set; } [MemberwiseEqualityIgnore] public bool HasFile => TrackFileId > 0; @@ -73,6 +74,7 @@ namespace NzbDrone.Core.Music Explicit = other.Explicit; Ratings = other.Ratings; MediumNumber = other.MediumNumber; + IsSingleFileRelease = other.IsSingleFileRelease; } public override void UseDbFieldsFrom(Track other) @@ -81,6 +83,7 @@ namespace NzbDrone.Core.Music AlbumReleaseId = other.AlbumReleaseId; ArtistMetadataId = other.ArtistMetadataId; TrackFileId = other.TrackFileId; + IsSingleFileRelease = other.IsSingleFileRelease; } } } diff --git a/src/NzbDrone.Core/Organizer/FileNameBuilder.cs b/src/NzbDrone.Core/Organizer/FileNameBuilder.cs index abfa9399d..166b0da7b 100644 --- a/src/NzbDrone.Core/Organizer/FileNameBuilder.cs +++ b/src/NzbDrone.Core/Organizer/FileNameBuilder.cs @@ -119,15 +119,23 @@ namespace NzbDrone.Core.Organizer { var splitPattern = splitPatterns[i]; var tokenHandlers = new Dictionary>(FileNameBuilderTokenEqualityComparer.Instance); - splitPattern = FormatTrackNumberTokens(splitPattern, "", tracks); - splitPattern = FormatMediumNumberTokens(splitPattern, "", tracks); + if (!trackFile.IsSingleFileRelease) + { + splitPattern = FormatTrackNumberTokens(splitPattern, "", tracks); + } + + splitPattern = FormatMediumNumberTokens(splitPattern, "", tracks); AddArtistTokens(tokenHandlers, artist); AddAlbumTokens(tokenHandlers, album); AddMediumTokens(tokenHandlers, tracks.First().AlbumRelease.Value.Media.SingleOrDefault(m => m.Number == tracks.First().MediumNumber)); - AddTrackTokens(tokenHandlers, tracks, artist); - AddTrackTitlePlaceholderTokens(tokenHandlers); - AddTrackFileTokens(tokenHandlers, trackFile); + if (!trackFile.IsSingleFileRelease) + { + AddTrackTokens(tokenHandlers, tracks, artist); + AddTrackTitlePlaceholderTokens(tokenHandlers); + AddTrackFileTokens(tokenHandlers, trackFile); + } + AddQualityTokens(tokenHandlers, artist, trackFile); AddMediaInfoTokens(tokenHandlers, trackFile); AddCustomFormats(tokenHandlers, artist, trackFile, customFormats); @@ -141,9 +149,12 @@ namespace NzbDrone.Core.Organizer var maxTrackTitleLength = maxPathSegmentLength - GetLengthWithoutTrackTitle(component, namingConfig); - AddTrackTitleTokens(tokenHandlers, tracks, maxTrackTitleLength); - component = ReplaceTokens(component, tokenHandlers, namingConfig).Trim(); + if (!trackFile.IsSingleFileRelease) + { + AddTrackTitleTokens(tokenHandlers, tracks, maxTrackTitleLength); + } + component = ReplaceTokens(component, tokenHandlers, namingConfig).Trim(); component = FileNameCleanupRegex.Replace(component, match => match.Captures[0].Value[0].ToString()); component = TrimSeparatorsRegex.Replace(component, string.Empty); component = component.Replace("{ellipsis}", "..."); diff --git a/src/NzbDrone.Core/Parser/Model/LocalAlbumRelease.cs b/src/NzbDrone.Core/Parser/Model/LocalAlbumRelease.cs index 1cc4b69ae..2ed3aa773 100644 --- a/src/NzbDrone.Core/Parser/Model/LocalAlbumRelease.cs +++ b/src/NzbDrone.Core/Parser/Model/LocalAlbumRelease.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; +using NzbDrone.Core.MediaFiles; using NzbDrone.Core.MediaFiles.TrackImport.Identification; using NzbDrone.Core.Music; @@ -61,6 +62,8 @@ namespace NzbDrone.Core.Parser.Model { return "[" + string.Join(", ", LocalTracks.Select(x => Path.GetDirectoryName(x.Path)).Distinct()) + "]"; } + + public bool IsSingleFileRelease => LocalTracks.All(x => x.IsSingleFileRelease == true); } public class TrackMapping @@ -68,10 +71,12 @@ namespace NzbDrone.Core.Parser.Model public TrackMapping() { Mapping = new Dictionary>(); + CuesheetTrackMapping = new Dictionary>(); } public Dictionary> Mapping { get; set; } public List LocalExtra { get; set; } public List MBExtra { get; set; } + public Dictionary> CuesheetTrackMapping { get; set; } } } diff --git a/src/NzbDrone.Core/Parser/Model/LocalTrack.cs b/src/NzbDrone.Core/Parser/Model/LocalTrack.cs index 1fc38c6ef..9796fe0fb 100644 --- a/src/NzbDrone.Core/Parser/Model/LocalTrack.cs +++ b/src/NzbDrone.Core/Parser/Model/LocalTrack.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using NzbDrone.Core.MediaFiles; using NzbDrone.Core.MediaFiles.TrackImport.Identification; using NzbDrone.Core.Music; using NzbDrone.Core.Qualities; @@ -31,7 +32,9 @@ namespace NzbDrone.Core.Parser.Model public bool SceneSource { get; set; } public string ReleaseGroup { get; set; } public string SceneName { get; set; } - + public bool IsSingleFileRelease { get; set; } + public CueSheet.FileEntry CueSheetFileEntry { get; set; } + public string CueSheetPath { get; set; } public override string ToString() { return Path;