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, absoluteTrackNumber,
title, title,
duration, duration,
isSingleFileRelease,
trackFilePath, trackFilePath,
trackFileSize, trackFileSize,
customFormats, customFormats,
@ -86,7 +87,7 @@ class TrackRow extends Component {
return ( return (
<TableRowCell key={name}> <TableRowCell key={name}>
{ {
trackFilePath isSingleFileRelease ? `${trackFilePath} (Single File)` : trackFilePath
} }
</TableRowCell> </TableRowCell>
); );
@ -203,6 +204,7 @@ TrackRow.propTypes = {
absoluteTrackNumber: PropTypes.number, absoluteTrackNumber: PropTypes.number,
title: PropTypes.string.isRequired, title: PropTypes.string.isRequired,
duration: PropTypes.number.isRequired, duration: PropTypes.number.isRequired,
isSingleFileRelease: PropTypes.bool.isRequired,
isSaving: PropTypes.bool, isSaving: PropTypes.bool,
trackFilePath: PropTypes.string, trackFilePath: PropTypes.string,
trackFileSize: PropTypes.number, trackFileSize: PropTypes.number,

@ -13,7 +13,8 @@ function createMapStateToProps() {
trackFilePath: trackFile ? trackFile.path : null, trackFilePath: trackFile ? trackFile.path : null,
trackFileSize: trackFile ? trackFile.size : null, trackFileSize: trackFile ? trackFile.size : null,
customFormats: trackFile ? trackFile.customFormats : [], 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'), label: () => translate('Tracks'),
isVisible: true isVisible: true
}, },
{
name: 'cueSheetPath',
label: () => 'Cue Sheet Path',
isVisible: true
},
{ {
name: 'releaseGroup', name: 'releaseGroup',
label: () => translate('ReleaseGroup'), label: () => translate('ReleaseGroup'),
@ -435,6 +440,8 @@ class InteractiveImportModalContent extends Component {
allowArtistChange={allowArtistChange} allowArtistChange={allowArtistChange}
onSelectedChange={this.onSelectedChange} onSelectedChange={this.onSelectedChange}
onValidRowChange={this.onValidRowChange} onValidRowChange={this.onValidRowChange}
isSingleFileRelease={item.isSingleFileRelease}
cueSheetPath={item.cueSheetPath}
/> />
); );
}) })

@ -134,6 +134,8 @@ class InteractiveImportModalContentConnector extends Component {
album, album,
albumReleaseId, albumReleaseId,
tracks, tracks,
isSingleFileRelease,
cueSheetPath,
quality, quality,
disableReleaseSwitching disableReleaseSwitching
} = item; } = item;
@ -148,7 +150,7 @@ class InteractiveImportModalContentConnector extends Component {
return false; 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' }); this.setState({ interactiveImportErrorMessage: 'One or more tracks must be chosen for each selected file' });
return false; return false;
} }
@ -164,6 +166,8 @@ class InteractiveImportModalContentConnector extends Component {
albumId: album.id, albumId: album.id,
albumReleaseId, albumReleaseId,
trackIds: _.map(tracks, 'id'), trackIds: _.map(tracks, 'id'),
isSingleFileRelease: item.isSingleFileRelease,
cueSheetPath: item.cueSheetPath,
quality, quality,
downloadId: this.props.downloadId, downloadId: this.props.downloadId,
disableReleaseSwitching disableReleaseSwitching

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

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

@ -83,7 +83,9 @@ namespace Lidarr.Api.V1.ManualImport
DownloadId = resource.DownloadId, DownloadId = resource.DownloadId,
AdditionalFile = resource.AdditionalFile, AdditionalFile = resource.AdditionalFile,
ReplaceExistingFiles = resource.ReplaceExistingFiles, 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 AdditionalFile { get; set; }
public bool ReplaceExistingFiles { get; set; } public bool ReplaceExistingFiles { get; set; }
public bool DisableReleaseSwitching { get; set; } public bool DisableReleaseSwitching { get; set; }
public bool IsSingleFileRelease { get; set; }
public string CueSheetPath { get; set; }
} }
public static class ManualImportResourceMapper public static class ManualImportResourceMapper
@ -52,6 +54,8 @@ namespace Lidarr.Api.V1.ManualImport
Tracks = model.Tracks.ToResource(), Tracks = model.Tracks.ToResource(),
Quality = model.Quality, Quality = model.Quality,
ReleaseGroup = model.ReleaseGroup, ReleaseGroup = model.ReleaseGroup,
IsSingleFileRelease = model.IsSingleFileRelease,
CueSheetPath = model.CueSheetPath,
// QualityWeight // QualityWeight
DownloadId = model.DownloadId, DownloadId = model.DownloadId,

@ -21,7 +21,8 @@ namespace Lidarr.Api.V1.ManualImport
public bool AdditionalFile { get; set; } public bool AdditionalFile { get; set; }
public bool ReplaceExistingFiles { get; set; } public bool ReplaceExistingFiles { get; set; }
public bool DisableReleaseSwitching { get; set; } public bool DisableReleaseSwitching { get; set; }
public bool IsSingleFileRelease { get; set; }
public string CueSheetPath { get; set; }
public IEnumerable<Rejection> Rejections { 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> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Dapper" Version="2.0.123" /> <PackageReference Include="Dapper" Version="2.0.123" />
<PackageReference Include="Diacritics" Version="3.3.18" />
<PackageReference Include="Diacritical.Net" Version="1.0.4" /> <PackageReference Include="Diacritical.Net" Version="1.0.4" />
<PackageReference Include="Polly" Version="8.2.1" /> <PackageReference Include="Polly" Version="8.2.1" />
<PackageReference Include="System.Text.Json" Version="6.0.9" /> <PackageReference Include="System.Text.Json" Version="6.0.9" />
@ -29,6 +30,7 @@
<PackageReference Include="SixLabors.ImageSharp" Version="3.0.1" /> <PackageReference Include="SixLabors.ImageSharp" Version="3.0.1" />
<PackageReference Include="Equ" Version="2.3.0" /> <PackageReference Include="Equ" Version="2.3.0" />
<PackageReference Include="MonoTorrent" Version="2.0.7" /> <PackageReference Include="MonoTorrent" Version="2.0.7" />
<PackageReference Include="UTF.Unknown" Version="2.5.1" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\NzbDrone.Common\Lidarr.Common.csproj" /> <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.Extensions;
using NzbDrone.Common.Instrumentation.Extensions; using NzbDrone.Common.Instrumentation.Extensions;
using NzbDrone.Core.Configuration; using NzbDrone.Core.Configuration;
using NzbDrone.Core.Datastore;
using NzbDrone.Core.MediaFiles.Commands; using NzbDrone.Core.MediaFiles.Commands;
using NzbDrone.Core.MediaFiles.Events; using NzbDrone.Core.MediaFiles.Events;
using NzbDrone.Core.MediaFiles.TrackImport; using NzbDrone.Core.MediaFiles.TrackImport;
using NzbDrone.Core.Messaging.Commands; using NzbDrone.Core.Messaging.Commands;
using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Messaging.Events;
using NzbDrone.Core.Music; using NzbDrone.Core.Music;
using NzbDrone.Core.Parser.Model;
using NzbDrone.Core.RootFolders; using NzbDrone.Core.RootFolders;
namespace NzbDrone.Core.MediaFiles namespace NzbDrone.Core.MediaFiles
@ -41,6 +43,7 @@ namespace NzbDrone.Core.MediaFiles
private readonly IDiskProvider _diskProvider; private readonly IDiskProvider _diskProvider;
private readonly IMediaFileService _mediaFileService; private readonly IMediaFileService _mediaFileService;
private readonly IMakeImportDecision _importDecisionMaker; private readonly IMakeImportDecision _importDecisionMaker;
private readonly ICueSheetService _cueSheetService;
private readonly IImportApprovedTracks _importApprovedTracks; private readonly IImportApprovedTracks _importApprovedTracks;
private readonly IArtistService _artistService; private readonly IArtistService _artistService;
private readonly IMediaFileTableCleanupService _mediaFileTableCleanupService; private readonly IMediaFileTableCleanupService _mediaFileTableCleanupService;
@ -52,6 +55,7 @@ namespace NzbDrone.Core.MediaFiles
IDiskProvider diskProvider, IDiskProvider diskProvider,
IMediaFileService mediaFileService, IMediaFileService mediaFileService,
IMakeImportDecision importDecisionMaker, IMakeImportDecision importDecisionMaker,
ICueSheetService cueSheetService,
IImportApprovedTracks importApprovedTracks, IImportApprovedTracks importApprovedTracks,
IArtistService artistService, IArtistService artistService,
IRootFolderService rootFolderService, IRootFolderService rootFolderService,
@ -63,6 +67,7 @@ namespace NzbDrone.Core.MediaFiles
_diskProvider = diskProvider; _diskProvider = diskProvider;
_mediaFileService = mediaFileService; _mediaFileService = mediaFileService;
_importDecisionMaker = importDecisionMaker; _importDecisionMaker = importDecisionMaker;
_cueSheetService = cueSheetService;
_importApprovedTracks = importApprovedTracks; _importApprovedTracks = importApprovedTracks;
_artistService = artistService; _artistService = artistService;
_mediaFileTableCleanupService = mediaFileTableCleanupService; _mediaFileTableCleanupService = mediaFileTableCleanupService;
@ -83,6 +88,32 @@ namespace NzbDrone.Core.MediaFiles
artistIds = new List<int>(); 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 mediaFileList = new List<IFileInfo>();
var musicFilesStopwatch = Stopwatch.StartNew(); var musicFilesStopwatch = Stopwatch.StartNew();
@ -96,7 +127,7 @@ namespace NzbDrone.Core.MediaFiles
if (rootFolder == null) if (rootFolder == null)
{ {
_logger.Error("Not scanning {0}, it's not a subdirectory of a defined root folder", folder); _logger.Error("Not scanning {0}, it's not a subdirectory of a defined root folder", folder);
return; return mediaFileList;
} }
var folderExists = _diskProvider.FolderExists(folder); var folderExists = _diskProvider.FolderExists(folder);
@ -108,7 +139,7 @@ namespace NzbDrone.Core.MediaFiles
_logger.Warn("Artists' root folder ({0}) doesn't exist.", rootFolder.Path); _logger.Warn("Artists' root folder ({0}) doesn't exist.", rootFolder.Path);
var skippedArtists = _artistService.GetArtists(artistIds); var skippedArtists = _artistService.GetArtists(artistIds);
skippedArtists.ForEach(x => _eventAggregator.PublishEvent(new ArtistScanSkippedEvent(x, ArtistScanSkippedReason.RootFolderDoesNotExist))); skippedArtists.ForEach(x => _eventAggregator.PublishEvent(new ArtistScanSkippedEvent(x, ArtistScanSkippedReason.RootFolderDoesNotExist)));
return; return mediaFileList;
} }
if (_diskProvider.FolderEmpty(rootFolder.Path)) if (_diskProvider.FolderEmpty(rootFolder.Path))
@ -116,7 +147,7 @@ namespace NzbDrone.Core.MediaFiles
_logger.Warn("Artists' root folder ({0}) is empty.", rootFolder.Path); _logger.Warn("Artists' root folder ({0}) is empty.", rootFolder.Path);
var skippedArtists = _artistService.GetArtists(artistIds); var skippedArtists = _artistService.GetArtists(artistIds);
skippedArtists.ForEach(x => _eventAggregator.PublishEvent(new ArtistScanSkippedEvent(x, ArtistScanSkippedReason.RootFolderIsEmpty))); skippedArtists.ForEach(x => _eventAggregator.PublishEvent(new ArtistScanSkippedEvent(x, ArtistScanSkippedReason.RootFolderIsEmpty)));
return; return mediaFileList;
} }
} }
@ -145,20 +176,11 @@ namespace NzbDrone.Core.MediaFiles
musicFilesStopwatch.Stop(); musicFilesStopwatch.Stop();
_logger.Trace("Finished getting track files for:\n{0} [{1}]", folders.ConcatToString("\n"), musicFilesStopwatch.Elapsed); _logger.Trace("Finished getting track files for:\n{0} [{1}]", folders.ConcatToString("\n"), musicFilesStopwatch.Elapsed);
var decisionsStopwatch = Stopwatch.StartNew(); return mediaFileList;
}
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);
private void Import(List<string> folders, List<int> artistIds, List<ImportDecision<LocalTrack>> decisions)
{
var importStopwatch = Stopwatch.StartNew(); var importStopwatch = Stopwatch.StartNew();
_importApprovedTracks.Import(decisions, false); _importApprovedTracks.Import(decisions, false);
@ -177,7 +199,8 @@ namespace NzbDrone.Core.MediaFiles
Modified = decision.Item.Modified, Modified = decision.Item.Modified,
DateAdded = DateTime.UtcNow, DateAdded = DateTime.UtcNow,
Quality = decision.Item.Quality, Quality = decision.Item.Quality,
MediaInfo = decision.Item.FileTrackInfo.MediaInfo MediaInfo = decision.Item.FileTrackInfo.MediaInfo,
IsSingleFileRelease = decision.Item.IsSingleFileRelease,
}) })
.ToList(); .ToList();
_mediaFileService.AddMany(newFiles); _mediaFileService.AddMany(newFiles);

@ -27,7 +27,8 @@ namespace NzbDrone.Core.MediaFiles
{ ".ape", Quality.APE }, { ".ape", Quality.APE },
{ ".aif", Quality.Unknown }, { ".aif", Quality.Unknown },
{ ".aiff", 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 QualityModel Quality { get; set; }
public MediaInfoModel MediaInfo { get; set; } public MediaInfoModel MediaInfo { get; set; }
public int AlbumId { get; set; } public int AlbumId { get; set; }
public bool IsSingleFileRelease { get; set; }
// These are queried from the database // These are queried from the database
public LazyLoaded<List<Track>> Tracks { get; set; } public LazyLoaded<List<Track>> Tracks { get; set; }

@ -4,6 +4,7 @@ using System.IO;
using NLog; using NLog;
using NzbDrone.Common.Disk; using NzbDrone.Common.Disk;
using NzbDrone.Common.EnsureThat; using NzbDrone.Common.EnsureThat;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Configuration; using NzbDrone.Core.Configuration;
using NzbDrone.Core.MediaFiles.Events; using NzbDrone.Core.MediaFiles.Events;
using NzbDrone.Core.MediaFiles.TrackImport; using NzbDrone.Core.MediaFiles.TrackImport;
@ -79,6 +80,8 @@ namespace NzbDrone.Core.MediaFiles
EnsureTrackFolder(trackFile, localTrack, filePath); EnsureTrackFolder(trackFile, localTrack, filePath);
TryToCreateCueFile(localTrack, filePath);
_logger.Debug("Moving track file: {0} to {1}", trackFile.Path, filePath); _logger.Debug("Moving track file: {0} to {1}", trackFile.Path, filePath);
return TransferFile(trackFile, localTrack.Artist, localTrack.Tracks, filePath, TransferMode.Move); return TransferFile(trackFile, localTrack.Artist, localTrack.Tracks, filePath, TransferMode.Move);
@ -90,6 +93,8 @@ namespace NzbDrone.Core.MediaFiles
EnsureTrackFolder(trackFile, localTrack, filePath); EnsureTrackFolder(trackFile, localTrack, filePath);
TryToCreateCueFile(localTrack, filePath);
if (_configService.CopyUsingHardlinks) if (_configService.CopyUsingHardlinks)
{ {
_logger.Debug("Attempting to hardlink track file: {0} to {1}", trackFile.Path, filePath); _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); 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) private TrackFile TransferFile(TrackFile trackFile, Artist artist, List<Track> tracks, string destinationFilePath, TransferMode mode)
{ {
Ensure.That(trackFile, () => trackFile).IsNotNull(); Ensure.That(trackFile, () => trackFile).IsNotNull();

@ -65,14 +65,28 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Aggregation.Aggregators
|| tracks.Any(x => x.FileTrackInfo.DiscNumber == 0)) || tracks.Any(x => x.FileTrackInfo.DiscNumber == 0))
{ {
_logger.Debug("Missing data in tags, trying filename augmentation"); _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); tracks[i].FileTrackInfo.ArtistTitle = tracks[i].Artist?.Name;
if (matches != null) 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) 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 // sort candidate releases by closest track count so that we stand a chance of
// getting a perfect match early on // getting a perfect match early on
return GetDbCandidatesByRelease(_releaseService.GetReleasesByAlbum(album.Id) return GetDbCandidatesByRelease(_releaseService.GetReleasesByAlbum(album.Id)

@ -30,6 +30,11 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Identification
localTrack.FileTrackInfo.TrackNumbers[0] != totalTrackNumber; 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) public static int GetTotalTrackNumber(Track track, List<Track> allTracks)
{ {
return track.AbsoluteTrackNumber + allTracks.Count(t => t.MediumNumber < track.MediumNumber); return track.AbsoluteTrackNumber + allTracks.Count(t => t.MediumNumber < track.MediumNumber);
@ -79,6 +84,28 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Identification
return dist; 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) public static Distance AlbumReleaseDistance(List<LocalTrack> localTracks, AlbumRelease release, TrackMapping mapping)
{ {
var dist = new Distance(); var dist = new Distance();
@ -118,13 +145,16 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Identification
{ {
var albumYear = release.Album.Value.ReleaseDate?.Year ?? 0; var albumYear = release.Album.Value.ReleaseDate?.Year ?? 0;
var releaseYear = release.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); dist.Add("year", 0.0);
} }
else else
{ {
var remoteYear = albumYear > 0 ? albumYear : releaseYear; var remoteYear = (albumYear > 0 && isSameWithAlbumYear) ? albumYear : releaseYear;
var diff = Math.Abs(localYear - remoteYear); var diff = Math.Abs(localYear - remoteYear);
var diff_max = Math.Abs(DateTime.Now.Year - remoteYear); var diff_max = Math.Abs(DateTime.Now.Year - remoteYear);
dist.AddRatio("year", diff, diff_max); dist.AddRatio("year", diff, diff_max);
@ -176,12 +206,32 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Identification
} }
// tracks // 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 // missing tracks
foreach (var track in mapping.MBExtra.Take(localTracks.Count)) 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()); 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; return dist;
} }
} }

@ -154,6 +154,11 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Identification
private bool ShouldFingerprint(LocalAlbumRelease localAlbumRelease) private bool ShouldFingerprint(LocalAlbumRelease localAlbumRelease)
{ {
if (localAlbumRelease.IsSingleFileRelease)
{
return false;
}
var worstTrackMatchDist = localAlbumRelease.TrackMapping?.Mapping var worstTrackMatchDist = localAlbumRelease.TrackMapping?.Mapping
.DefaultIfEmpty() .DefaultIfEmpty()
.MaxBy(x => x.Value.Item2.NormalizedDistance()) .MaxBy(x => x.Value.Item2.NormalizedDistance())
@ -182,7 +187,8 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Identification
FileTrackInfo = _audioTagService.ReadTags(x.Path), FileTrackInfo = _audioTagService.ReadTags(x.Path),
ExistingFile = true, ExistingFile = true,
AdditionalFile = true, AdditionalFile = true,
Quality = x.Quality Quality = x.Quality,
IsSingleFileRelease = x.IsSingleFileRelease,
})) }))
.ToList(); .ToList();
@ -317,7 +323,8 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Identification
var extraTracks = extraTracksOnDisk.Where(x => extraTrackPaths.Contains(x.Path)).ToList(); var extraTracks = extraTracksOnDisk.Where(x => extraTrackPaths.Contains(x.Path)).ToList();
var allLocalTracks = localAlbumRelease.LocalTracks.Concat(extraTracks).DistinctBy(x => 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 distance = DistanceCalculator.AlbumReleaseDistance(allLocalTracks, release, mapping);
var currDistance = distance.NormalizedDistance(); var currDistance = distance.NormalizedDistance();
@ -335,6 +342,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Identification
localAlbumRelease.AlbumRelease = release; localAlbumRelease.AlbumRelease = release;
localAlbumRelease.ExistingTracks = extraTracks; localAlbumRelease.ExistingTracks = extraTracks;
localAlbumRelease.TrackMapping = mapping; localAlbumRelease.TrackMapping = mapping;
if (currDistance == 0.0) if (currDistance == 0.0)
{ {
break; break;
@ -348,6 +356,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Identification
public TrackMapping MapReleaseTracks(List<LocalTrack> localTracks, List<Track> mbTracks) public TrackMapping MapReleaseTracks(List<LocalTrack> localTracks, List<Track> mbTracks)
{ {
var result = new TrackMapping();
var distances = new Distance[localTracks.Count, mbTracks.Count]; var distances = new Distance[localTracks.Count, mbTracks.Count];
var costs = new double[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); var m = new Munkres(costs);
m.Run(); m.Run();
var result = new TrackMapping();
foreach (var pair in m.Solution) foreach (var pair in m.Solution)
{ {
result.Mapping.Add(localTracks[pair.Item1], Tuple.Create(mbTracks[pair.Item2], distances[pair.Item1, pair.Item2])); 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; 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, AlbumId = localTrack.Album.Id,
Artist = localTrack.Artist, Artist = localTrack.Artist,
Album = localTrack.Album, Album = localTrack.Album,
Tracks = localTrack.Tracks Tracks = localTrack.Tracks,
IsSingleFileRelease = localTrack.IsSingleFileRelease,
}; };
bool copyOnly; bool copyOnly;

@ -2,6 +2,7 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO.Abstractions; using System.IO.Abstractions;
using System.Linq; using System.Linq;
using DryIoc.ImTools;
using NLog; using NLog;
using NzbDrone.Common.Extensions; using NzbDrone.Common.Extensions;
using NzbDrone.Common.Instrumentation.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.Aggregation;
using NzbDrone.Core.MediaFiles.TrackImport.Identification; using NzbDrone.Core.MediaFiles.TrackImport.Identification;
using NzbDrone.Core.Music; using NzbDrone.Core.Music;
using NzbDrone.Core.Parser;
using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Parser.Model;
using NzbDrone.Core.Profiles.Qualities; using NzbDrone.Core.Profiles.Qualities;
using NzbDrone.Core.RootFolders; using NzbDrone.Core.RootFolders;
@ -32,6 +34,8 @@ namespace NzbDrone.Core.MediaFiles.TrackImport
{ {
public DownloadClientItem DownloadClientItem { get; set; } public DownloadClientItem DownloadClientItem { get; set; }
public ParsedAlbumInfo ParsedAlbumInfo { get; set; } public ParsedAlbumInfo ParsedAlbumInfo { get; set; }
public List<CueSheetInfo> CueSheetInfos { get; set; } = new List<CueSheetInfo>();
public bool DetectCueFileEncoding { get; set; }
} }
public class ImportDecisionMakerConfig public class ImportDecisionMakerConfig
@ -48,6 +52,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport
private readonly IEnumerable<IImportDecisionEngineSpecification<LocalTrack>> _trackSpecifications; private readonly IEnumerable<IImportDecisionEngineSpecification<LocalTrack>> _trackSpecifications;
private readonly IEnumerable<IImportDecisionEngineSpecification<LocalAlbumRelease>> _albumSpecifications; private readonly IEnumerable<IImportDecisionEngineSpecification<LocalAlbumRelease>> _albumSpecifications;
private readonly IMediaFileService _mediaFileService; private readonly IMediaFileService _mediaFileService;
private readonly IParsingService _parsingService;
private readonly IAudioTagService _audioTagService; private readonly IAudioTagService _audioTagService;
private readonly IAugmentingService _augmentingService; private readonly IAugmentingService _augmentingService;
private readonly IIdentificationService _identificationService; private readonly IIdentificationService _identificationService;
@ -58,6 +63,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport
public ImportDecisionMaker(IEnumerable<IImportDecisionEngineSpecification<LocalTrack>> trackSpecifications, public ImportDecisionMaker(IEnumerable<IImportDecisionEngineSpecification<LocalTrack>> trackSpecifications,
IEnumerable<IImportDecisionEngineSpecification<LocalAlbumRelease>> albumSpecifications, IEnumerable<IImportDecisionEngineSpecification<LocalAlbumRelease>> albumSpecifications,
IMediaFileService mediaFileService, IMediaFileService mediaFileService,
IParsingService parsingService,
IAudioTagService audioTagService, IAudioTagService audioTagService,
IAugmentingService augmentingService, IAugmentingService augmentingService,
IIdentificationService identificationService, IIdentificationService identificationService,
@ -68,6 +74,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport
_trackSpecifications = trackSpecifications; _trackSpecifications = trackSpecifications;
_albumSpecifications = albumSpecifications; _albumSpecifications = albumSpecifications;
_mediaFileService = mediaFileService; _mediaFileService = mediaFileService;
_parsingService = parsingService;
_audioTagService = audioTagService; _audioTagService = audioTagService;
_augmentingService = augmentingService; _augmentingService = augmentingService;
_identificationService = identificationService; _identificationService = identificationService;
@ -113,7 +120,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport
Size = file.Length, Size = file.Length,
Modified = file.LastWriteTimeUtc, Modified = file.LastWriteTimeUtc,
FileTrackInfo = _audioTagService.ReadTags(file.FullName), FileTrackInfo = _audioTagService.ReadTags(file.FullName),
AdditionalFile = false AdditionalFile = false,
}; };
try try
@ -149,6 +156,38 @@ namespace NzbDrone.Core.MediaFiles.TrackImport
var decisions = trackData.Item2; var decisions = trackData.Item2;
localTracks.ForEach(x => x.ExistingFile = !config.NewDownload); 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); var releases = _identificationService.Identify(localTracks, idOverrides, config);
@ -246,7 +285,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport
{ {
ImportDecision<LocalTrack> decision = null; 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}")) : 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}")); 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 QualityModel Quality { get; set; }
public string DownloadId { get; set; } public string DownloadId { get; set; }
public bool DisableReleaseSwitching { get; set; } public bool DisableReleaseSwitching { get; set; }
public bool IsSingleFileRelease { get; set; }
public string CueSheetPath { get; set; }
public bool Equals(ManualImportFile other) public bool Equals(ManualImportFile other)
{ {

@ -32,5 +32,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Manual
public bool AdditionalFile { get; set; } public bool AdditionalFile { get; set; }
public bool ReplaceExistingFiles { get; set; } public bool ReplaceExistingFiles { get; set; }
public bool DisableReleaseSwitching { 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.Extensions;
using NzbDrone.Common.Instrumentation.Extensions; using NzbDrone.Common.Instrumentation.Extensions;
using NzbDrone.Core.CustomFormats; using NzbDrone.Core.CustomFormats;
using NzbDrone.Core.Datastore;
using NzbDrone.Core.DecisionEngine; using NzbDrone.Core.DecisionEngine;
using NzbDrone.Core.Download; using NzbDrone.Core.Download;
using NzbDrone.Core.Download.TrackedDownloads; using NzbDrone.Core.Download.TrackedDownloads;
@ -36,6 +37,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Manual
private readonly IRootFolderService _rootFolderService; private readonly IRootFolderService _rootFolderService;
private readonly IDiskScanService _diskScanService; private readonly IDiskScanService _diskScanService;
private readonly IMakeImportDecision _importDecisionMaker; private readonly IMakeImportDecision _importDecisionMaker;
private readonly ICueSheetService _cueSheetService;
private readonly ICustomFormatCalculationService _formatCalculator; private readonly ICustomFormatCalculationService _formatCalculator;
private readonly IArtistService _artistService; private readonly IArtistService _artistService;
private readonly IAlbumService _albumService; private readonly IAlbumService _albumService;
@ -54,6 +56,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Manual
IRootFolderService rootFolderService, IRootFolderService rootFolderService,
IDiskScanService diskScanService, IDiskScanService diskScanService,
IMakeImportDecision importDecisionMaker, IMakeImportDecision importDecisionMaker,
ICueSheetService cueSheetService,
ICustomFormatCalculationService formatCalculator, ICustomFormatCalculationService formatCalculator,
IArtistService artistService, IArtistService artistService,
IAlbumService albumService, IAlbumService albumService,
@ -72,6 +75,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Manual
_rootFolderService = rootFolderService; _rootFolderService = rootFolderService;
_diskScanService = diskScanService; _diskScanService = diskScanService;
_importDecisionMaker = importDecisionMaker; _importDecisionMaker = importDecisionMaker;
_cueSheetService = cueSheetService;
_formatCalculator = formatCalculator; _formatCalculator = formatCalculator;
_artistService = artistService; _artistService = artistService;
_albumService = albumService; _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 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 var itemInfo = new ImportDecisionMakerInfo
{ {
DownloadClientItem = downloadClientItem, DownloadClientItem = downloadClientItem,
ParsedAlbumInfo = Parser.Parser.ParseAlbumTitle(directoryInfo.Name) ParsedAlbumInfo = Parser.Parser.ParseAlbumTitle(albumTitle),
DetectCueFileEncoding = true,
}; };
var config = new ImportDecisionMakerConfig var config = new ImportDecisionMakerConfig
{ {
Filter = filter, Filter = filter,
@ -168,20 +186,26 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Manual
AddNewArtists = false 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 // 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, f => f.FullName,
d => d.Item.Path, d => d.Item.Path,
(f, d) => new { File = f, Decision = d }, (f, d) => new { File = f, Decision = d },
PathEqualityComparer.Instance); 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 existingDecisions = decisions.Except(newFiles.Select(x => x.Decision));
var existingItems = existingDecisions.Select(x => MapItem(x, null, replaceExistingFiles, false)); 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) public List<ManualImportItem> UpdateItems(List<ManualImportItem> items)
@ -198,13 +222,6 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Manual
var disableReleaseSwitching = group.First().DisableReleaseSwitching; 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 var config = new ImportDecisionMakerConfig
{ {
Filter = FilterFilesType.None, Filter = FilterFilesType.None,
@ -213,55 +230,94 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Manual
IncludeExisting = !replaceExistingFiles, IncludeExisting = !replaceExistingFiles,
AddNewArtists = false AddNewArtists = false
}; };
var decisions = _importDecisionMaker.GetImportDecisions(files, idOverride, null, config);
var existingItems = group.Join(decisions, var audioFiles = new List<IFileInfo>();
i => i.Path, foreach (var item in group)
d => d.Item.Path,
(i, d) => new { Item = i, Decision = d },
PathEqualityComparer.Instance);
foreach (var pair in existingItems)
{ {
var item = pair.Item; var file = _diskProvider.GetFileInfo(item.Path);
var decision = pair.Decision; 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) var itemInfo = new ImportDecisionMakerInfo();
{ var idOverride = new IdentificationOverrides
item.Album = decision.Item.Album; {
item.Release = decision.Item.Release; Artist = group.First().Artist,
} Album = group.First().Album,
AlbumRelease = group.First().Release
};
if (decision.Item.Tracks.Any()) itemInfo.DetectCueFileEncoding = true;
{ var decisions = _cueSheetService.GetImportDecisions(ref audioFiles, idOverride, itemInfo, config);
item.Tracks = decision.Item.Tracks; if (audioFiles.Count > 0)
} {
decisions.AddRange(_importDecisionMaker.GetImportDecisions(audioFiles, idOverride, itemInfo, config));
}
if (item.Quality?.Quality == Quality.Unknown) if (decisions.Count > 0)
{ {
item.Quality = decision.Item.Quality; result.AddRange(UpdateItems(group, decisions, replaceExistingFiles, disableReleaseSwitching));
} }
}
if (item.ReleaseGroup.IsNullOrWhiteSpace()) return result;
{ }
item.ReleaseGroup = decision.Item.ReleaseGroup;
}
item.Rejections = decision.Rejections; private List<ManualImportItem> UpdateItems(IGrouping<int?, ManualImportItem> group, List<ImportDecision<LocalTrack>> decisions, bool replaceExistingFiles, bool disableReleaseSwitching)
item.Size = decision.Item.Size; {
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)); if (decision.Item.Album != null)
result.AddRange(newDecisions.Select(x => MapItem(x, null, replaceExistingFiles, disableReleaseSwitching))); {
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; return result;
} }
@ -299,6 +355,8 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Manual
item.AdditionalFile = decision.Item.AdditionalFile; item.AdditionalFile = decision.Item.AdditionalFile;
item.ReplaceExistingFiles = replaceExistingFiles; item.ReplaceExistingFiles = replaceExistingFiles;
item.DisableReleaseSwitching = disableReleaseSwitching; item.DisableReleaseSwitching = disableReleaseSwitching;
item.IsSingleFileRelease = decision.Item.IsSingleFileRelease;
item.CueSheetPath = decision.Item.CueSheetPath;
return item; return item;
} }
@ -346,9 +404,16 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Manual
Quality = file.Quality, Quality = file.Quality,
Artist = artist, Artist = artist,
Album = album, 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); var importDecision = new ImportDecision<LocalTrack>(localTrack);
if (_rootFolderService.GetBestRootFolder(artist.Path) == null) 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); _logger.Debug("Min quality of new files: {0}", newMinQuality);
// get minimum quality of existing release // 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()) if (existingQualities.Any())
{ {
var existingMinQuality = existingQualities.OrderBy(x => x, qualityComparer).First(); var existingMinQuality = existingQualities.OrderBy(x => x, qualityComparer).First();

@ -22,11 +22,17 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Specifications
{ {
double dist; double dist;
string reasons; string reasons;
if (item.IsSingleFileRelease)
{
_logger.Debug($"Accepting single file release {item}: {item.Distance.Reasons}");
return Decision.Accept();
}
// strict when a new download // strict when a new download
if (item.NewDownload) if (item.NewDownload)
{ {
dist = item.Distance.NormalizedDistance(); dist = item.Distance.NormalizedDistance();
reasons = item.Distance.Reasons; reasons = item.Distance.Reasons;
if (dist > _albumThreshold) if (dist > _albumThreshold)
{ {

@ -17,6 +17,11 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Specifications
public Decision IsSatisfiedBy(LocalTrack item, DownloadClientItem downloadClientItem) public Decision IsSatisfiedBy(LocalTrack item, DownloadClientItem downloadClientItem)
{ {
if (item.IsSingleFileRelease)
{
return Decision.Accept();
}
var dist = item.Distance.NormalizedDistance(); var dist = item.Distance.NormalizedDistance();
var reasons = item.Distance.Reasons; var reasons = item.Distance.Reasons;

@ -17,6 +17,11 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Specifications
public Decision IsSatisfiedBy(LocalAlbumRelease item, DownloadClientItem downloadClientItem) 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 existingRelease = item.AlbumRelease.Album.Value.AlbumReleases.Value.Single(x => x.Monitored);
var existingTrackCount = existingRelease.Tracks.Value.Count(x => x.HasFile); var existingTrackCount = existingRelease.Tracks.Value.Count(x => x.HasFile);
if (item.AlbumRelease.Id != existingRelease.Id && if (item.AlbumRelease.Id != existingRelease.Id &&

@ -16,6 +16,11 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Specifications
public Decision IsSatisfiedBy(LocalAlbumRelease item, DownloadClientItem downloadClientItem) public Decision IsSatisfiedBy(LocalAlbumRelease item, DownloadClientItem downloadClientItem)
{ {
if (item.IsSingleFileRelease)
{
return Decision.Accept();
}
if (item.NewDownload && item.TrackMapping.LocalExtra.Count > 0) if (item.NewDownload && item.TrackMapping.LocalExtra.Count > 0)
{ {
_logger.Debug("This release has track files that have not been matched. Skipping {0}", item); _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 Ratings Ratings { get; set; }
public int MediumNumber { get; set; } public int MediumNumber { get; set; }
public int TrackFileId { get; set; } public int TrackFileId { get; set; }
public bool IsSingleFileRelease { get; set; }
[MemberwiseEqualityIgnore] [MemberwiseEqualityIgnore]
public bool HasFile => TrackFileId > 0; public bool HasFile => TrackFileId > 0;
@ -73,6 +74,7 @@ namespace NzbDrone.Core.Music
Explicit = other.Explicit; Explicit = other.Explicit;
Ratings = other.Ratings; Ratings = other.Ratings;
MediumNumber = other.MediumNumber; MediumNumber = other.MediumNumber;
IsSingleFileRelease = other.IsSingleFileRelease;
} }
public override void UseDbFieldsFrom(Track other) public override void UseDbFieldsFrom(Track other)
@ -81,6 +83,7 @@ namespace NzbDrone.Core.Music
AlbumReleaseId = other.AlbumReleaseId; AlbumReleaseId = other.AlbumReleaseId;
ArtistMetadataId = other.ArtistMetadataId; ArtistMetadataId = other.ArtistMetadataId;
TrackFileId = other.TrackFileId; TrackFileId = other.TrackFileId;
IsSingleFileRelease = other.IsSingleFileRelease;
} }
} }
} }

@ -119,15 +119,23 @@ namespace NzbDrone.Core.Organizer
{ {
var splitPattern = splitPatterns[i]; var splitPattern = splitPatterns[i];
var tokenHandlers = new Dictionary<string, Func<TokenMatch, string>>(FileNameBuilderTokenEqualityComparer.Instance); 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); AddArtistTokens(tokenHandlers, artist);
AddAlbumTokens(tokenHandlers, album); AddAlbumTokens(tokenHandlers, album);
AddMediumTokens(tokenHandlers, tracks.First().AlbumRelease.Value.Media.SingleOrDefault(m => m.Number == tracks.First().MediumNumber)); AddMediumTokens(tokenHandlers, tracks.First().AlbumRelease.Value.Media.SingleOrDefault(m => m.Number == tracks.First().MediumNumber));
AddTrackTokens(tokenHandlers, tracks, artist); if (!trackFile.IsSingleFileRelease)
AddTrackTitlePlaceholderTokens(tokenHandlers); {
AddTrackFileTokens(tokenHandlers, trackFile); AddTrackTokens(tokenHandlers, tracks, artist);
AddTrackTitlePlaceholderTokens(tokenHandlers);
AddTrackFileTokens(tokenHandlers, trackFile);
}
AddQualityTokens(tokenHandlers, artist, trackFile); AddQualityTokens(tokenHandlers, artist, trackFile);
AddMediaInfoTokens(tokenHandlers, trackFile); AddMediaInfoTokens(tokenHandlers, trackFile);
AddCustomFormats(tokenHandlers, artist, trackFile, customFormats); AddCustomFormats(tokenHandlers, artist, trackFile, customFormats);
@ -141,9 +149,12 @@ namespace NzbDrone.Core.Organizer
var maxTrackTitleLength = maxPathSegmentLength - GetLengthWithoutTrackTitle(component, namingConfig); var maxTrackTitleLength = maxPathSegmentLength - GetLengthWithoutTrackTitle(component, namingConfig);
AddTrackTitleTokens(tokenHandlers, tracks, maxTrackTitleLength); if (!trackFile.IsSingleFileRelease)
component = ReplaceTokens(component, tokenHandlers, namingConfig).Trim(); {
AddTrackTitleTokens(tokenHandlers, tracks, maxTrackTitleLength);
}
component = ReplaceTokens(component, tokenHandlers, namingConfig).Trim();
component = FileNameCleanupRegex.Replace(component, match => match.Captures[0].Value[0].ToString()); component = FileNameCleanupRegex.Replace(component, match => match.Captures[0].Value[0].ToString());
component = TrimSeparatorsRegex.Replace(component, string.Empty); component = TrimSeparatorsRegex.Replace(component, string.Empty);
component = component.Replace("{ellipsis}", "..."); component = component.Replace("{ellipsis}", "...");

@ -2,6 +2,7 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using NzbDrone.Core.MediaFiles;
using NzbDrone.Core.MediaFiles.TrackImport.Identification; using NzbDrone.Core.MediaFiles.TrackImport.Identification;
using NzbDrone.Core.Music; using NzbDrone.Core.Music;
@ -61,6 +62,8 @@ namespace NzbDrone.Core.Parser.Model
{ {
return "[" + string.Join(", ", LocalTracks.Select(x => Path.GetDirectoryName(x.Path)).Distinct()) + "]"; return "[" + string.Join(", ", LocalTracks.Select(x => Path.GetDirectoryName(x.Path)).Distinct()) + "]";
} }
public bool IsSingleFileRelease => LocalTracks.All(x => x.IsSingleFileRelease == true);
} }
public class TrackMapping public class TrackMapping
@ -68,10 +71,12 @@ namespace NzbDrone.Core.Parser.Model
public TrackMapping() public TrackMapping()
{ {
Mapping = new Dictionary<LocalTrack, Tuple<Track, Distance>>(); 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 Dictionary<LocalTrack, Tuple<Track, Distance>> Mapping { get; set; }
public List<LocalTrack> LocalExtra { get; set; } public List<LocalTrack> LocalExtra { get; set; }
public List<Track> MBExtra { 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;
using System.Collections.Generic; using System.Collections.Generic;
using NzbDrone.Core.MediaFiles;
using NzbDrone.Core.MediaFiles.TrackImport.Identification; using NzbDrone.Core.MediaFiles.TrackImport.Identification;
using NzbDrone.Core.Music; using NzbDrone.Core.Music;
using NzbDrone.Core.Qualities; using NzbDrone.Core.Qualities;
@ -31,7 +32,9 @@ namespace NzbDrone.Core.Parser.Model
public bool SceneSource { get; set; } public bool SceneSource { get; set; }
public string ReleaseGroup { get; set; } public string ReleaseGroup { get; set; }
public string SceneName { 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() public override string ToString()
{ {
return Path; return Path;

Loading…
Cancel
Save