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