pull/4200/merge
zhangdoa 2 months ago committed by GitHub
commit 0e6cda5134
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -28,6 +28,7 @@ class TrackRow extends Component {
absoluteTrackNumber,
title,
duration,
isSingleFileRelease,
trackFilePath,
trackFileSize,
customFormats,
@ -86,7 +87,7 @@ class TrackRow extends Component {
return (
<TableRowCell key={name}>
{
trackFilePath
isSingleFileRelease ? `${trackFilePath} (Single File)` : trackFilePath
}
</TableRowCell>
);
@ -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,

@ -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
};
}
);

@ -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}
/>
);
})

@ -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

@ -167,6 +167,7 @@ class InteractiveImportRow extends Component {
album,
albumReleaseId,
tracks,
cueSheetPath,
quality,
releaseGroup,
size,
@ -267,8 +268,18 @@ class InteractiveImportRow extends Component {
{
showTrackNumbersPlaceholder ? <InteractiveImportRowCellPlaceholder /> : trackNumbers
}
</TableRowCellButton>
<TableRowCell
id={id}
title={'Cue Sheet Path'}
>
{
cueSheetPath
}
</TableRowCell>
<TableRowCellButton
title={translate('ClickToChangeReleaseGroup')}
onPress={this.onSelectReleaseGroupPress}
@ -408,7 +419,9 @@ InteractiveImportRow.propTypes = {
artist: PropTypes.object,
album: PropTypes.object,
albumReleaseId: PropTypes.number,
tracks: PropTypes.arrayOf(PropTypes.object).isRequired,
tracks: PropTypes.arrayOf(PropTypes.object),
isSingleFileRelease: PropTypes.bool.isRequired,
cueSheetPath: PropTypes.string.isRequired,
releaseGroup: PropTypes.string,
quality: PropTypes.object,
size: PropTypes.number.isRequired,

@ -206,6 +206,8 @@ export const actionHandlers = handleThunks({
albumId: item.album ? item.album.id : undefined,
albumReleaseId: item.albumReleaseId ? item.albumReleaseId : undefined,
trackIds: (item.tracks || []).map((e) => e.id),
isSingleFileRelease: item.isSingleFileRelease,
cueSheetPath: item.cueSheetPath,
quality: item.quality,
releaseGroup: item.releaseGroup,
downloadId: item.downloadId,

@ -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,
});
}

@ -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,

@ -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<Rejection> Rejections { get; set; }
}
}

@ -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);
}
}
}

@ -4,6 +4,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Dapper" Version="2.0.123" />
<PackageReference Include="Diacritics" Version="3.3.18" />
<PackageReference Include="Diacritical.Net" Version="1.0.4" />
<PackageReference Include="Polly" Version="8.2.1" />
<PackageReference Include="System.Text.Json" Version="6.0.9" />
@ -29,6 +30,7 @@
<PackageReference Include="SixLabors.ImageSharp" Version="3.0.1" />
<PackageReference Include="Equ" Version="2.3.0" />
<PackageReference Include="MonoTorrent" Version="2.0.7" />
<PackageReference Include="UTF.Unknown" Version="2.5.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\NzbDrone.Common\Lidarr.Common.csproj" />

@ -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<string> Performers { get; set; } = new List<string>();
public List<IndexEntry> Indices { get; set; } = new List<IndexEntry>();
}
public class FileEntry
{
public string Name { get; set; }
public IndexEntry Index { get; set; }
public List<TrackEntry> Tracks { get; set; } = new List<TrackEntry>();
}
public string Path { get; set; }
public bool IsSingleFileRelease { get; set; }
public List<FileEntry> Files { get; set; } = new List<FileEntry>();
public string Genre { get; set; }
public string Date { get; set; }
public string DiscID { get; set; }
public string Title { get; set; }
public List<string> Performers { get; set; } = new List<string>();
}
}

@ -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<IFileInfo> MusicFiles { get; set; } = new List<IFileInfo>();
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<ImportDecision<LocalTrack>> GetImportDecisions(ref List<IFileInfo> 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<char, char> _replacements = new Dictionary<char, char>
{
{ '', '\'' }, { '', '\'' }, // 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<ImportDecision<LocalTrack>> GetImportDecisions(ref List<IFileInfo> mediaFileList, IdentificationOverrides idOverrides, ImportDecisionMakerInfo itemInfo, ImportDecisionMakerConfig config)
{
var decisions = new List<ImportDecision<LocalTrack>>();
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<CueSheetInfo>();
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<IFileInfo>();
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<string> 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<string> 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<IFileInfo> 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;
}
}
}

@ -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<int>();
}
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<ImportDecision<LocalTrack>>();
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<IFileInfo> GetMediaFiles(List<string> folders, List<int> artistIds)
{
var mediaFileList = new List<IFileInfo>();
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<string> folders, List<int> artistIds, List<ImportDecision<LocalTrack>> 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);

@ -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 }
};
}

@ -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<List<Track>> Tracks { get; set; }

@ -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<string>(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<Track> tracks, string destinationFilePath, TransferMode mode)
{
Ensure.That(trackFile, () => trackFile).IsNotNull();

@ -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);
}
}
}
}

@ -131,6 +131,13 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Identification
private List<CandidateAlbumRelease> 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)

@ -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<Track> 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<LocalTrack> 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;
}
}

@ -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<LocalTrack> localTracks, List<Track> 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<LocalTrack> localTracks, List<Track> mbTracks)
{
var result = new TrackMapping();
var cuesheetTracks = new List<CueSheet.TrackEntry>();
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;
}
}
}

@ -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;

@ -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<CueSheetInfo> CueSheetInfos { get; set; } = new List<CueSheetInfo>();
public bool DetectCueFileEncoding { get; set; }
}
public class ImportDecisionMakerConfig
@ -48,6 +52,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport
private readonly IEnumerable<IImportDecisionEngineSpecification<LocalTrack>> _trackSpecifications;
private readonly IEnumerable<IImportDecisionEngineSpecification<LocalAlbumRelease>> _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<IImportDecisionEngineSpecification<LocalTrack>> trackSpecifications,
IEnumerable<IImportDecisionEngineSpecification<LocalAlbumRelease>> 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<LocalTrack> decision = null;
if (localTrack.Tracks.Empty())
if (!localTrack.IsSingleFileRelease && localTrack.Tracks.Empty())
{
decision = localTrack.Album != null ? new ImportDecision<LocalTrack>(localTrack, new Rejection($"Couldn't parse track from: {localTrack.FileTrackInfo}")) :
new ImportDecision<LocalTrack>(localTrack, new Rejection($"Couldn't parse album from: {localTrack.FileTrackInfo}"));

@ -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)
{

@ -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; }
}
}

@ -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<ManualImportItem>();
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<ManualImportItem> ProcessFolder(string downloadId, IdentificationOverrides idOverrides, FilterFilesType filter, bool replaceExistingFiles, DownloadClientItem downloadClientItem, string albumTitle, List<IFileInfo> audioFiles, List<CueSheetInfo> 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<ManualImportItem> UpdateItems(List<ManualImportItem> 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<IFileInfo>();
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<ManualImportItem> UpdateItems(IGrouping<int?, ManualImportItem> group, List<ImportDecision<LocalTrack>> decisions, bool replaceExistingFiles, bool disableReleaseSwitching)
{
var result = new List<ManualImportItem>();
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>(localTrack);
if (_rootFolderService.GetBestRootFolder(artist.Path) == null)
{

@ -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();

@ -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)
{

@ -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;

@ -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 &&

@ -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);

@ -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;
}
}
}

@ -119,15 +119,23 @@ namespace NzbDrone.Core.Organizer
{
var splitPattern = splitPatterns[i];
var tokenHandlers = new Dictionary<string, Func<TokenMatch, string>>(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}", "...");

@ -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<LocalTrack, Tuple<Track, Distance>>();
CuesheetTrackMapping = new Dictionary<CueSheet.TrackEntry, Tuple<Track, Distance>>();
}
public Dictionary<LocalTrack, Tuple<Track, Distance>> Mapping { get; set; }
public List<LocalTrack> LocalExtra { get; set; }
public List<Track> MBExtra { get; set; }
public Dictionary<CueSheet.TrackEntry, Tuple<Track, Distance>> CuesheetTrackMapping { get; set; }
}
}

@ -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;

Loading…
Cancel
Save