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..220b13a4b 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: 'isSingleFileRelease', + label: () => 'Is Single File Release', + isVisible: true + }, { name: 'releaseGroup', label: () => translate('ReleaseGroup'), @@ -435,6 +440,7 @@ class InteractiveImportModalContent extends Component { allowArtistChange={allowArtistChange} onSelectedChange={this.onSelectedChange} onValidRowChange={this.onValidRowChange} + isSingleFileRelease={item.isSingleFileRelease} /> ); }) diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContentConnector.js b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContentConnector.js index f40da69ee..d05b38e06 100644 --- a/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContentConnector.js +++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContentConnector.js @@ -134,6 +134,7 @@ class InteractiveImportModalContentConnector extends Component { album, albumReleaseId, tracks, + isSingleFileRelease, quality, disableReleaseSwitching } = item; @@ -148,7 +149,7 @@ class InteractiveImportModalContentConnector extends Component { return false; } - if (!tracks || !tracks.length) { + if (!isSingleFileRelease && (!tracks || !tracks.length)) { this.setState({ interactiveImportErrorMessage: 'One or more tracks must be chosen for each selected file' }); return false; } @@ -164,6 +165,7 @@ class InteractiveImportModalContentConnector extends Component { albumId: album.id, albumReleaseId, trackIds: _.map(tracks, 'id'), + isSingleFileRelease: item.isSingleFileRelease, quality, downloadId: this.props.downloadId, disableReleaseSwitching diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.js b/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.js index b914c0996..797ad389e 100644 --- a/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.js +++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.js @@ -64,6 +64,7 @@ class InteractiveImportRow extends Component { artist, album, tracks, + isSingleFileRelease, quality, isSelected, onValidRowChange @@ -82,7 +83,7 @@ class InteractiveImportRow extends Component { const isValid = !!( artist && album && - tracks.length && + (isSingleFileRelease || tracks.length) && quality ); @@ -167,6 +168,7 @@ class InteractiveImportRow extends Component { album, albumReleaseId, tracks, + isSingleFileRelease, quality, releaseGroup, size, @@ -257,7 +259,7 @@ class InteractiveImportRow extends Component { @@ -265,10 +267,20 @@ class InteractiveImportRow extends Component { showTrackNumbersLoading && } { - showTrackNumbersPlaceholder ? : trackNumbers + !isSingleFileRelease && showTrackNumbersPlaceholder ? : trackNumbers } + + + { + isSingleFileRelease ? 'Yes' : 'No' + } + + e.id), + isSingleFileRelease: item.isSingleFileRelease, 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..071fbe05f 100644 --- a/src/Lidarr.Api.V1/ManualImport/ManualImportController.cs +++ b/src/Lidarr.Api.V1/ManualImport/ManualImportController.cs @@ -83,7 +83,8 @@ namespace Lidarr.Api.V1.ManualImport DownloadId = resource.DownloadId, AdditionalFile = resource.AdditionalFile, ReplaceExistingFiles = resource.ReplaceExistingFiles, - DisableReleaseSwitching = resource.DisableReleaseSwitching + DisableReleaseSwitching = resource.DisableReleaseSwitching, + IsSingleFileRelease = resource.IsSingleFileRelease, }); } diff --git a/src/Lidarr.Api.V1/ManualImport/ManualImportResource.cs b/src/Lidarr.Api.V1/ManualImport/ManualImportResource.cs index 4b38b4f7c..5d4f29815 100644 --- a/src/Lidarr.Api.V1/ManualImport/ManualImportResource.cs +++ b/src/Lidarr.Api.V1/ManualImport/ManualImportResource.cs @@ -29,6 +29,7 @@ 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 static class ManualImportResourceMapper @@ -52,6 +53,7 @@ namespace Lidarr.Api.V1.ManualImport Tracks = model.Tracks.ToResource(), Quality = model.Quality, ReleaseGroup = model.ReleaseGroup, + IsSingleFileRelease = model.IsSingleFileRelease, // QualityWeight DownloadId = model.DownloadId, diff --git a/src/Lidarr.Api.V1/ManualImport/ManualImportUpdateResource.cs b/src/Lidarr.Api.V1/ManualImport/ManualImportUpdateResource.cs index 84a513807..bf19a2b1b 100644 --- a/src/Lidarr.Api.V1/ManualImport/ManualImportUpdateResource.cs +++ b/src/Lidarr.Api.V1/ManualImport/ManualImportUpdateResource.cs @@ -21,6 +21,7 @@ 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 IEnumerable Rejections { get; set; } } diff --git a/src/Lidarr.Api.V1/Tracks/TrackResource.cs b/src/Lidarr.Api.V1/Tracks/TrackResource.cs index 47f58811d..59834b273 100644 --- a/src/Lidarr.Api.V1/Tracks/TrackResource.cs +++ b/src/Lidarr.Api.V1/Tracks/TrackResource.cs @@ -26,6 +26,7 @@ namespace Lidarr.Api.V1.Tracks public ArtistResource Artist { get; set; } public Ratings Ratings { get; set; } + public bool IsSingleFileRelease { get; set; } // Hiding this so people don't think its usable (only used to set the initial state) [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] @@ -58,6 +59,7 @@ namespace Lidarr.Api.V1.Tracks MediumNumber = model.MediumNumber, HasFile = model.HasFile, Ratings = model.Ratings, + IsSingleFileRelease = model.IsSingleFileRelease }; } diff --git a/src/NzbDrone.Core/Datastore/Migration/073_add_flac_cue.cs b/src/NzbDrone.Core/Datastore/Migration/073_add_flac_cue.cs new file mode 100644 index 000000000..d4ad6b928 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/073_add_flac_cue.cs @@ -0,0 +1,14 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(073)] + public class add_flac_cue : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Alter.Table("TrackFiles").AddColumn("IsSingleFileRelease").AsBoolean().WithDefaultValue(false); + } + } +} 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/TrackImport/Aggregation/Aggregators/AggregateFilenameInfo.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Aggregation/Aggregators/AggregateFilenameInfo.cs index 28c2c3633..9afb5e927 100644 --- a/src/NzbDrone.Core/MediaFiles/TrackImport/Aggregation/Aggregators/AggregateFilenameInfo.cs +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Aggregation/Aggregators/AggregateFilenameInfo.cs @@ -65,14 +65,25 @@ 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 (tracks.Count == 1 && tracks[0].IsSingleFileRelease) { - foreach (var pattern in Patterns(charSep.Item1, charSep.Item2)) + tracks[0].FileTrackInfo.ArtistTitle = tracks[0].Artist.Name; + tracks[0].FileTrackInfo.AlbumTitle = tracks[0].Album.Title; + + // TODO this is too bold, the release year is not the one from the .cue file + tracks[0].FileTrackInfo.Year = (uint)tracks[0].Album.ReleaseDate.Value.Year; + } + else + { + foreach (var charSep in CharsAndSeps) { - var matches = AllMatches(tracks, pattern); - if (matches != null) + 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..1d3e0e140 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.LocalTracks.Count == 1 && localAlbumRelease.LocalTracks[0].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..a2f68fd15 100644 --- a/src/NzbDrone.Core/MediaFiles/TrackImport/Identification/DistanceCalculator.cs +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Identification/DistanceCalculator.cs @@ -118,13 +118,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.Count == 1 && localTracks[0].IsSingleFileRelease) ? 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,29 +179,36 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Identification } // tracks - foreach (var pair in mapping.Mapping) + if (localTracks.Count == 1 && localTracks[0].IsSingleFileRelease) { - dist.Add("tracks", pair.Value.Item2.NormalizedDistance()); + dist.Add("tracks", 0); } - - Logger.Trace("after trackMapping: {0}", dist.NormalizedDistance()); - - // missing tracks - foreach (var track in mapping.MBExtra.Take(localTracks.Count)) + else { - dist.Add("missing_tracks", 1.0); + foreach (var pair in mapping.Mapping) + { + dist.Add("tracks", pair.Value.Item2.NormalizedDistance()); + } + + Logger.Trace("after trackMapping: {0}", dist.NormalizedDistance()); + + // missing tracks + foreach (var track in mapping.MBExtra.Take(localTracks.Count)) + { + dist.Add("missing_tracks", 1.0); + } + + 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()); } - 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..32cd6f43a 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.LocalTracks.Count == 1 && localAlbumRelease.LocalTracks[0].IsSingleFileRelease) + { + return false; + } + var worstTrackMatchDist = localAlbumRelease.TrackMapping?.Mapping .DefaultIfEmpty() .MaxBy(x => x.Value.Item2.NormalizedDistance()) @@ -335,6 +340,12 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Identification localAlbumRelease.AlbumRelease = release; localAlbumRelease.ExistingTracks = extraTracks; localAlbumRelease.TrackMapping = mapping; + if (localAlbumRelease.LocalTracks.Count == 1 && localAlbumRelease.LocalTracks[0].IsSingleFileRelease) + { + localAlbumRelease.LocalTracks[0].Tracks = release.Tracks; + localAlbumRelease.LocalTracks[0].Tracks.ForEach(x => x.IsSingleFileRelease = true); + } + if (currDistance == 0.0) { break; @@ -348,6 +359,14 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Identification public TrackMapping MapReleaseTracks(List localTracks, List mbTracks) { + var result = new TrackMapping(); + if (localTracks.Count == 1 && localTracks[0].IsSingleFileRelease) + { + result.IsSingleFileRelease = true; + + return result; + } + var distances = new Distance[localTracks.Count, mbTracks.Count]; var costs = new double[localTracks.Count, mbTracks.Count]; @@ -364,7 +383,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])); 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..b047d3770 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; @@ -32,6 +33,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport { public DownloadClientItem DownloadClientItem { get; set; } public ParsedAlbumInfo ParsedAlbumInfo { get; set; } + public bool IsSingleFileRelease { get; set; } } public class ImportDecisionMakerConfig @@ -149,6 +151,12 @@ namespace NzbDrone.Core.MediaFiles.TrackImport var decisions = trackData.Item2; localTracks.ForEach(x => x.ExistingFile = !config.NewDownload); + localTracks.ForEach(x => x.IsSingleFileRelease = itemInfo.IsSingleFileRelease); + if (itemInfo.IsSingleFileRelease) + { + localTracks.ForEach(x => x.Artist = idOverrides.Artist); + localTracks.ForEach(x => x.Album = idOverrides.Album); + } var releases = _identificationService.Identify(localTracks, idOverrides, config); @@ -246,7 +254,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..631318c89 100644 --- a/src/NzbDrone.Core/MediaFiles/TrackImport/Manual/ManualImportFile.cs +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Manual/ManualImportFile.cs @@ -15,6 +15,7 @@ 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 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..879eff917 100644 --- a/src/NzbDrone.Core/MediaFiles/TrackImport/Manual/ManualImportItem.cs +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Manual/ManualImportItem.cs @@ -32,5 +32,6 @@ 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; } } } diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Manual/ManualImportService.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Manual/ManualImportService.cs index 0b8a18f6c..9e6308323 100644 --- a/src/NzbDrone.Core/MediaFiles/TrackImport/Manual/ManualImportService.cs +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Manual/ManualImportService.cs @@ -3,6 +3,8 @@ using System.Collections.Generic; using System.IO; using System.IO.Abstractions; using System.Linq; +using System.Text; +using System.Text.RegularExpressions; using NLog; using NzbDrone.Common; using NzbDrone.Common.Crypto; @@ -132,6 +134,33 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Manual return ProcessFolder(path, downloadId, artist, filter, replaceExistingFiles); } + private static List ReadFieldFromCuesheet(string[] lines, string fieldName) + { + var results = new List(); + var candidates = lines.Where(l => l.StartsWith(fieldName)).ToList(); + foreach (var candidate in candidates) + { + var matches = Regex.Matches(candidate, "\"(.*?)\""); + var result = matches.ToList()[0].Groups[1].Value; + results.Add(result); + } + + return results; + } + + private static string ReadOptionalFieldFromCuesheet(string[] lines, string fieldName) + { + var results = lines.Where(l => l.StartsWith(fieldName)); + if (results.Any()) + { + var matches = Regex.Matches(results.ToList()[0], fieldName + " (.+)"); + var result = matches.ToList()[0].Groups[1].Value; + return result; + } + + return ""; + } + private List ProcessFolder(string folder, string downloadId, Artist artist, FilterFilesType filter, bool replaceExistingFiles) { DownloadClientItem downloadClientItem = null; @@ -149,15 +178,91 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Manual } } - var artistFiles = _diskScanService.GetAudioFiles(folder).ToList(); + var audioFiles = _diskScanService.GetAudioFiles(folder).ToList(); + var results = new List(); + + // Split cue and non-cue files + var cueFiles = audioFiles.Where(x => x.Extension.Equals(".cue")).ToList(); + audioFiles.RemoveAll(l => cueFiles.Contains(l)); + foreach (var cueFile in cueFiles) + { + using (var fs = cueFile.OpenRead()) + { + var bytes = new byte[cueFile.Length]; + var encoding = new UTF8Encoding(true); + string content; + while (fs.Read(bytes, 0, bytes.Length) > 0) + { + content = encoding.GetString(bytes); + var lines = content.Split(new string[] { Environment.NewLine }, StringSplitOptions.None); + + // Single-file cue means it's an unsplit image + var fileNames = ReadFieldFromCuesheet(lines, "FILE"); + if (fileNames.Empty() || fileNames.Count > 1) + { + continue; + } + + var fileName = fileNames[0]; + if (!fileName.Empty()) + { + Artist artistFromCue = null; + var artistNames = ReadFieldFromCuesheet(lines, "PERFORMER"); + if (artistNames.Count > 0) + { + artistFromCue = _parsingService.GetArtist(artistNames[0]); + } + + string albumTitle = null; + var albumTitles = ReadFieldFromCuesheet(lines, "TITLE"); + if (artistNames.Count > 0) + { + albumTitle = albumTitles[0]; + } + + var date = ReadOptionalFieldFromCuesheet(lines, "REM DATE"); + var audioFile = audioFiles.Find(x => x.Name == fileName && x.DirectoryName == cueFile.DirectoryName); + var parsedAlbumInfo = new ParsedAlbumInfo + { + AlbumTitle = albumTitle, + ArtistName = artistFromCue.Name, + ReleaseDate = date, + }; + var albumsFromCue = _parsingService.GetAlbums(parsedAlbumInfo, artistFromCue); + if (albumsFromCue == null || albumsFromCue.Count == 0) + { + continue; + } + + var tempAudioFiles = new List + { + audioFile + }; + + results.AddRange(ProcessFolder(downloadId, artistFromCue, albumsFromCue[0], filter, replaceExistingFiles, downloadClientItem, albumTitle, tempAudioFiles, true)); + audioFiles.Remove(audioFile); + } + } + } + } + + results.AddRange(ProcessFolder(downloadId, artist, null, filter, replaceExistingFiles, downloadClientItem, directoryInfo.Name, audioFiles, false)); + + return results; + } + + private List ProcessFolder(string downloadId, Artist overrideArtist, Album overrideAlbum, FilterFilesType filter, bool replaceExistingFiles, DownloadClientItem downloadClientItem, string albumTitle, List audioFiles, bool isSingleFileRelease) + { var idOverrides = new IdentificationOverrides { - Artist = artist + Artist = overrideArtist, + Album = overrideAlbum }; var itemInfo = new ImportDecisionMakerInfo { DownloadClientItem = downloadClientItem, - ParsedAlbumInfo = Parser.Parser.ParseAlbumTitle(directoryInfo.Name) + ParsedAlbumInfo = Parser.Parser.ParseAlbumTitle(albumTitle), + IsSingleFileRelease = isSingleFileRelease }; var config = new ImportDecisionMakerConfig { @@ -168,10 +273,10 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Manual AddNewArtists = false }; - var decisions = _importDecisionMaker.GetImportDecisions(artistFiles, idOverrides, itemInfo, config); + var decisions = _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 }, @@ -299,6 +404,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Manual item.AdditionalFile = decision.Item.AdditionalFile; item.ReplaceExistingFiles = replaceExistingFiles; item.DisableReleaseSwitching = disableReleaseSwitching; + item.IsSingleFileRelease = decision.Item.IsSingleFileRelease; return item; } @@ -346,9 +452,15 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Manual Quality = file.Quality, Artist = artist, Album = album, - Release = release + Release = release, + IsSingleFileRelease = file.IsSingleFileRelease, }; + 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/CloseAlbumMatchSpecification.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/CloseAlbumMatchSpecification.cs index fda5da995..81743a89b 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.LocalTracks.Count == 1 && item.LocalTracks[0].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..64c88437a 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.LocalTracks.Count == 1 && item.LocalTracks[0].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..9ff31e479 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.LocalTracks.Count == 1 && item.LocalTracks[0].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 c8cb22a64..f3a5b31ab 100644 --- a/src/NzbDrone.Core/Organizer/FileNameBuilder.cs +++ b/src/NzbDrone.Core/Organizer/FileNameBuilder.cs @@ -105,12 +105,15 @@ namespace NzbDrone.Core.Organizer var pattern = namingConfig.StandardTrackFormat; - if (tracks.First().AlbumRelease.Value.Media.Count > 1) + if (!trackFile.IsSingleFileRelease) { - pattern = namingConfig.MultiDiscTrackFormat; - } + if (tracks.First().AlbumRelease.Value.Media.Count > 1) + { + pattern = namingConfig.MultiDiscTrackFormat; + } - tracks = tracks.OrderBy(e => e.AlbumReleaseId).ThenBy(e => e.TrackNumber).ToList(); + tracks = tracks.OrderBy(e => e.AlbumReleaseId).ThenBy(e => e.TrackNumber).ToList(); + } var splitPatterns = pattern.Split(new[] { '\\', '/' }, StringSplitOptions.RemoveEmptyEntries); var components = new List(); @@ -119,15 +122,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) + { + AddMediumTokens(tokenHandlers, tracks.First().AlbumRelease.Value.Media.SingleOrDefault(m => m.Number == tracks.First().MediumNumber)); + AddTrackTokens(tokenHandlers, tracks, artist); + AddTrackTitlePlaceholderTokens(tokenHandlers); + AddTrackFileTokens(tokenHandlers, trackFile); + } + AddQualityTokens(tokenHandlers, artist, trackFile); AddMediaInfoTokens(tokenHandlers, trackFile); AddCustomFormats(tokenHandlers, artist, trackFile, customFormats); @@ -141,9 +152,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..27424e04d 100644 --- a/src/NzbDrone.Core/Parser/Model/LocalAlbumRelease.cs +++ b/src/NzbDrone.Core/Parser/Model/LocalAlbumRelease.cs @@ -73,5 +73,6 @@ namespace NzbDrone.Core.Parser.Model public Dictionary> Mapping { get; set; } public List LocalExtra { get; set; } public List MBExtra { get; set; } + public bool IsSingleFileRelease { get; set; } } } diff --git a/src/NzbDrone.Core/Parser/Model/LocalTrack.cs b/src/NzbDrone.Core/Parser/Model/LocalTrack.cs index 1fc38c6ef..e5b48152e 100644 --- a/src/NzbDrone.Core/Parser/Model/LocalTrack.cs +++ b/src/NzbDrone.Core/Parser/Model/LocalTrack.cs @@ -31,7 +31,7 @@ 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 override string ToString() { return Path;