Add support to read cuesheet file and import a single-file release.

(cherry picked from commit 506e4415d613d3752605131d0f8b63fa448ee696)
This commit is contained in:
zhangdoa 2023-10-01 23:01:43 +02:00
commit 31016bca8a
30 changed files with 311 additions and 56 deletions

View file

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

View file

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

View file

@ -53,6 +53,11 @@ const columns = [
label: () => translate('Tracks'), label: () => translate('Tracks'),
isVisible: true isVisible: true
}, },
{
name: 'isSingleFileRelease',
label: () => 'Is Single File Release',
isVisible: true
},
{ {
name: 'releaseGroup', name: 'releaseGroup',
label: () => translate('ReleaseGroup'), label: () => translate('ReleaseGroup'),
@ -435,6 +440,7 @@ class InteractiveImportModalContent extends Component {
allowArtistChange={allowArtistChange} allowArtistChange={allowArtistChange}
onSelectedChange={this.onSelectedChange} onSelectedChange={this.onSelectedChange}
onValidRowChange={this.onValidRowChange} onValidRowChange={this.onValidRowChange}
isSingleFileRelease={item.isSingleFileRelease}
/> />
); );
}) })

View file

@ -134,6 +134,7 @@ class InteractiveImportModalContentConnector extends Component {
album, album,
albumReleaseId, albumReleaseId,
tracks, tracks,
isSingleFileRelease,
quality, quality,
disableReleaseSwitching disableReleaseSwitching
} = item; } = item;
@ -148,7 +149,7 @@ class InteractiveImportModalContentConnector extends Component {
return false; return false;
} }
if (!tracks || !tracks.length) { if (!isSingleFileRelease && (!tracks || !tracks.length)) {
this.setState({ interactiveImportErrorMessage: 'One or more tracks must be chosen for each selected file' }); this.setState({ interactiveImportErrorMessage: 'One or more tracks must be chosen for each selected file' });
return false; return false;
} }
@ -164,6 +165,7 @@ class InteractiveImportModalContentConnector extends Component {
albumId: album.id, albumId: album.id,
albumReleaseId, albumReleaseId,
trackIds: _.map(tracks, 'id'), trackIds: _.map(tracks, 'id'),
isSingleFileRelease: item.isSingleFileRelease,
quality, quality,
downloadId: this.props.downloadId, downloadId: this.props.downloadId,
disableReleaseSwitching disableReleaseSwitching

View file

@ -64,6 +64,7 @@ class InteractiveImportRow extends Component {
artist, artist,
album, album,
tracks, tracks,
isSingleFileRelease,
quality, quality,
isSelected, isSelected,
onValidRowChange onValidRowChange
@ -82,7 +83,7 @@ class InteractiveImportRow extends Component {
const isValid = !!( const isValid = !!(
artist && artist &&
album && album &&
tracks.length && (isSingleFileRelease || tracks.length) &&
quality quality
); );
@ -167,6 +168,7 @@ class InteractiveImportRow extends Component {
album, album,
albumReleaseId, albumReleaseId,
tracks, tracks,
isSingleFileRelease,
quality, quality,
releaseGroup, releaseGroup,
size, size,
@ -257,7 +259,7 @@ class InteractiveImportRow extends Component {
</TableRowCellButton> </TableRowCellButton>
<TableRowCellButton <TableRowCellButton
isDisabled={!artist || !album} isDisabled={!artist || !album || isSingleFileRelease}
title={artist && album ? translate('ArtistAlbumClickToChangeTrack') : undefined} title={artist && album ? translate('ArtistAlbumClickToChangeTrack') : undefined}
onPress={this.onSelectTrackPress} onPress={this.onSelectTrackPress}
> >
@ -265,10 +267,20 @@ class InteractiveImportRow extends Component {
showTrackNumbersLoading && <LoadingIndicator size={20} className={styles.loading} /> showTrackNumbersLoading && <LoadingIndicator size={20} className={styles.loading} />
} }
{ {
showTrackNumbersPlaceholder ? <InteractiveImportRowCellPlaceholder /> : trackNumbers !isSingleFileRelease && showTrackNumbersPlaceholder ? <InteractiveImportRowCellPlaceholder /> : trackNumbers
} }
</TableRowCellButton> </TableRowCellButton>
<TableRowCell
id={id}
title={'Is Single File Release'}
>
{
isSingleFileRelease ? 'Yes' : 'No'
}
</TableRowCell>
<TableRowCellButton <TableRowCellButton
title={translate('ClickToChangeReleaseGroup')} title={translate('ClickToChangeReleaseGroup')}
onPress={this.onSelectReleaseGroupPress} onPress={this.onSelectReleaseGroupPress}
@ -408,7 +420,8 @@ 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,
releaseGroup: PropTypes.string, releaseGroup: PropTypes.string,
quality: PropTypes.object, quality: PropTypes.object,
size: PropTypes.number.isRequired, size: PropTypes.number.isRequired,

View file

@ -206,6 +206,7 @@ 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,
quality: item.quality, quality: item.quality,
releaseGroup: item.releaseGroup, releaseGroup: item.releaseGroup,
downloadId: item.downloadId, downloadId: item.downloadId,

View file

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

View file

@ -29,6 +29,7 @@ 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 static class ManualImportResourceMapper public static class ManualImportResourceMapper
@ -52,6 +53,7 @@ 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,
// QualityWeight // QualityWeight
DownloadId = model.DownloadId, DownloadId = model.DownloadId,

View file

@ -21,6 +21,7 @@ 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 IEnumerable<Rejection> Rejections { get; set; } public IEnumerable<Rejection> Rejections { get; set; }
} }

View file

@ -26,6 +26,7 @@ namespace Lidarr.Api.V1.Tracks
public ArtistResource Artist { get; set; } public ArtistResource Artist { get; set; }
public Ratings Ratings { get; set; } public Ratings Ratings { get; set; }
public bool IsSingleFileRelease { get; set; }
// Hiding this so people don't think its usable (only used to set the initial state) // Hiding this so people don't think its usable (only used to set the initial state)
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)]
@ -58,6 +59,7 @@ namespace Lidarr.Api.V1.Tracks
MediumNumber = model.MediumNumber, MediumNumber = model.MediumNumber,
HasFile = model.HasFile, HasFile = model.HasFile,
Ratings = model.Ratings, Ratings = model.Ratings,
IsSingleFileRelease = model.IsSingleFileRelease
}; };
} }

View file

@ -0,0 +1,14 @@
using FluentMigrator;
using NzbDrone.Core.Datastore.Migration.Framework;
namespace NzbDrone.Core.Datastore.Migration
{
[Migration(073)]
public class add_flac_cue : NzbDroneMigrationBase
{
protected override void MainDbUpgrade()
{
Alter.Table("TrackFiles").AddColumn("IsSingleFileRelease").AsBoolean().WithDefaultValue(false);
}
}
}

View file

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

View file

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

View file

@ -65,14 +65,25 @@ 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 (tracks.Count == 1 && tracks[0].IsSingleFileRelease)
{ {
foreach (var pattern in Patterns(charSep.Item1, charSep.Item2)) tracks[0].FileTrackInfo.ArtistTitle = tracks[0].Artist.Name;
tracks[0].FileTrackInfo.AlbumTitle = tracks[0].Album.Title;
// TODO this is too bold, the release year is not the one from the .cue file
tracks[0].FileTrackInfo.Year = (uint)tracks[0].Album.ReleaseDate.Value.Year;
}
else
{
foreach (var charSep in CharsAndSeps)
{ {
var matches = AllMatches(tracks, pattern); foreach (var pattern in Patterns(charSep.Item1, charSep.Item2))
if (matches != null)
{ {
ApplyMatches(matches, pattern); var matches = AllMatches(tracks, pattern);
if (matches != null)
{
ApplyMatches(matches, pattern);
}
} }
} }
} }

View file

@ -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.LocalTracks.Count == 1 && localAlbumRelease.LocalTracks[0].IsSingleFileRelease)
{
return GetDbCandidatesByRelease(_releaseService.GetReleasesByAlbum(album.Id)
.OrderBy(x => x.ReleaseDate)
.ToList(), includeExisting);
}
// sort candidate releases by closest track count so that we stand a chance of // 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)

View file

@ -118,13 +118,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.Count == 1 && localTracks[0].IsSingleFileRelease) ? 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,29 +179,36 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Identification
} }
// tracks // tracks
foreach (var pair in mapping.Mapping) if (localTracks.Count == 1 && localTracks[0].IsSingleFileRelease)
{ {
dist.Add("tracks", pair.Value.Item2.NormalizedDistance()); dist.Add("tracks", 0);
} }
else
Logger.Trace("after trackMapping: {0}", dist.NormalizedDistance());
// missing tracks
foreach (var track in mapping.MBExtra.Take(localTracks.Count))
{ {
dist.Add("missing_tracks", 1.0); foreach (var pair in mapping.Mapping)
{
dist.Add("tracks", pair.Value.Item2.NormalizedDistance());
}
Logger.Trace("after trackMapping: {0}", dist.NormalizedDistance());
// missing tracks
foreach (var track in mapping.MBExtra.Take(localTracks.Count))
{
dist.Add("missing_tracks", 1.0);
}
Logger.Trace("after missing tracks: {0}", dist.NormalizedDistance());
// unmatched tracks
foreach (var track in mapping.LocalExtra.Take(localTracks.Count))
{
dist.Add("unmatched_tracks", 1.0);
}
Logger.Trace("after unmatched tracks: {0}", dist.NormalizedDistance());
} }
Logger.Trace("after missing tracks: {0}", dist.NormalizedDistance());
// unmatched tracks
foreach (var track in mapping.LocalExtra.Take(localTracks.Count))
{
dist.Add("unmatched_tracks", 1.0);
}
Logger.Trace("after unmatched tracks: {0}", dist.NormalizedDistance());
return dist; return dist;
} }
} }

View file

@ -154,6 +154,11 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Identification
private bool ShouldFingerprint(LocalAlbumRelease localAlbumRelease) private bool ShouldFingerprint(LocalAlbumRelease localAlbumRelease)
{ {
if (localAlbumRelease.LocalTracks.Count == 1 && localAlbumRelease.LocalTracks[0].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())
@ -335,6 +340,12 @@ 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 (localAlbumRelease.LocalTracks.Count == 1 && localAlbumRelease.LocalTracks[0].IsSingleFileRelease)
{
localAlbumRelease.LocalTracks[0].Tracks = release.Tracks;
localAlbumRelease.LocalTracks[0].Tracks.ForEach(x => x.IsSingleFileRelease = true);
}
if (currDistance == 0.0) if (currDistance == 0.0)
{ {
break; break;
@ -348,6 +359,14 @@ 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();
if (localTracks.Count == 1 && localTracks[0].IsSingleFileRelease)
{
result.IsSingleFileRelease = true;
return result;
}
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 +383,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]));

View file

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

View file

@ -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;
@ -32,6 +33,7 @@ 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 bool IsSingleFileRelease { get; set; }
} }
public class ImportDecisionMakerConfig public class ImportDecisionMakerConfig
@ -149,6 +151,12 @@ 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);
localTracks.ForEach(x => x.IsSingleFileRelease = itemInfo.IsSingleFileRelease);
if (itemInfo.IsSingleFileRelease)
{
localTracks.ForEach(x => x.Artist = idOverrides.Artist);
localTracks.ForEach(x => x.Album = idOverrides.Album);
}
var releases = _identificationService.Identify(localTracks, idOverrides, config); var releases = _identificationService.Identify(localTracks, idOverrides, config);
@ -246,7 +254,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}"));

View file

@ -15,6 +15,7 @@ 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 bool Equals(ManualImportFile other) public bool Equals(ManualImportFile other)
{ {

View file

@ -32,5 +32,6 @@ 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; }
} }
} }

View file

@ -3,6 +3,8 @@ using System.Collections.Generic;
using System.IO; using System.IO;
using System.IO.Abstractions; using System.IO.Abstractions;
using System.Linq; using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using NLog; using NLog;
using NzbDrone.Common; using NzbDrone.Common;
using NzbDrone.Common.Crypto; using NzbDrone.Common.Crypto;
@ -132,6 +134,33 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Manual
return ProcessFolder(path, downloadId, artist, filter, replaceExistingFiles); return ProcessFolder(path, downloadId, artist, filter, replaceExistingFiles);
} }
private static List<string> ReadFieldFromCuesheet(string[] lines, string fieldName)
{
var results = new List<string>();
var candidates = lines.Where(l => l.StartsWith(fieldName)).ToList();
foreach (var candidate in candidates)
{
var matches = Regex.Matches(candidate, "\"(.*?)\"");
var result = matches.ToList()[0].Groups[1].Value;
results.Add(result);
}
return results;
}
private static string ReadOptionalFieldFromCuesheet(string[] lines, string fieldName)
{
var results = lines.Where(l => l.StartsWith(fieldName));
if (results.Any())
{
var matches = Regex.Matches(results.ToList()[0], fieldName + " (.+)");
var result = matches.ToList()[0].Groups[1].Value;
return result;
}
return "";
}
private List<ManualImportItem> ProcessFolder(string folder, string downloadId, Artist artist, FilterFilesType filter, bool replaceExistingFiles) private List<ManualImportItem> ProcessFolder(string folder, string downloadId, Artist artist, FilterFilesType filter, bool replaceExistingFiles)
{ {
DownloadClientItem downloadClientItem = null; DownloadClientItem downloadClientItem = null;
@ -149,15 +178,91 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Manual
} }
} }
var artistFiles = _diskScanService.GetAudioFiles(folder).ToList(); var audioFiles = _diskScanService.GetAudioFiles(folder).ToList();
var results = new List<ManualImportItem>();
// Split cue and non-cue files
var cueFiles = audioFiles.Where(x => x.Extension.Equals(".cue")).ToList();
audioFiles.RemoveAll(l => cueFiles.Contains(l));
foreach (var cueFile in cueFiles)
{
using (var fs = cueFile.OpenRead())
{
var bytes = new byte[cueFile.Length];
var encoding = new UTF8Encoding(true);
string content;
while (fs.Read(bytes, 0, bytes.Length) > 0)
{
content = encoding.GetString(bytes);
var lines = content.Split(new string[] { Environment.NewLine }, StringSplitOptions.None);
// Single-file cue means it's an unsplit image
var fileNames = ReadFieldFromCuesheet(lines, "FILE");
if (fileNames.Empty() || fileNames.Count > 1)
{
continue;
}
var fileName = fileNames[0];
if (!fileName.Empty())
{
Artist artistFromCue = null;
var artistNames = ReadFieldFromCuesheet(lines, "PERFORMER");
if (artistNames.Count > 0)
{
artistFromCue = _parsingService.GetArtist(artistNames[0]);
}
string albumTitle = null;
var albumTitles = ReadFieldFromCuesheet(lines, "TITLE");
if (artistNames.Count > 0)
{
albumTitle = albumTitles[0];
}
var date = ReadOptionalFieldFromCuesheet(lines, "REM DATE");
var audioFile = audioFiles.Find(x => x.Name == fileName && x.DirectoryName == cueFile.DirectoryName);
var parsedAlbumInfo = new ParsedAlbumInfo
{
AlbumTitle = albumTitle,
ArtistName = artistFromCue.Name,
ReleaseDate = date,
};
var albumsFromCue = _parsingService.GetAlbums(parsedAlbumInfo, artistFromCue);
if (albumsFromCue == null || albumsFromCue.Count == 0)
{
continue;
}
var tempAudioFiles = new List<IFileInfo>
{
audioFile
};
results.AddRange(ProcessFolder(downloadId, artistFromCue, albumsFromCue[0], filter, replaceExistingFiles, downloadClientItem, albumTitle, tempAudioFiles, true));
audioFiles.Remove(audioFile);
}
}
}
}
results.AddRange(ProcessFolder(downloadId, artist, null, filter, replaceExistingFiles, downloadClientItem, directoryInfo.Name, audioFiles, false));
return results;
}
private List<ManualImportItem> ProcessFolder(string downloadId, Artist overrideArtist, Album overrideAlbum, FilterFilesType filter, bool replaceExistingFiles, DownloadClientItem downloadClientItem, string albumTitle, List<IFileInfo> audioFiles, bool isSingleFileRelease)
{
var idOverrides = new IdentificationOverrides var idOverrides = new IdentificationOverrides
{ {
Artist = artist Artist = overrideArtist,
Album = overrideAlbum
}; };
var itemInfo = new ImportDecisionMakerInfo var itemInfo = new ImportDecisionMakerInfo
{ {
DownloadClientItem = downloadClientItem, DownloadClientItem = downloadClientItem,
ParsedAlbumInfo = Parser.Parser.ParseAlbumTitle(directoryInfo.Name) ParsedAlbumInfo = Parser.Parser.ParseAlbumTitle(albumTitle),
IsSingleFileRelease = isSingleFileRelease
}; };
var config = new ImportDecisionMakerConfig var config = new ImportDecisionMakerConfig
{ {
@ -168,10 +273,10 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Manual
AddNewArtists = false AddNewArtists = false
}; };
var decisions = _importDecisionMaker.GetImportDecisions(artistFiles, idOverrides, itemInfo, config); var decisions = _importDecisionMaker.GetImportDecisions(audioFiles, idOverrides, itemInfo, config);
// paths will be different for new and old files which is why we need to map separately // 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 },
@ -299,6 +404,7 @@ 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;
return item; return item;
} }
@ -346,9 +452,15 @@ 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,
}; };
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)
{ {

View file

@ -22,11 +22,17 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Specifications
{ {
double dist; double dist;
string reasons; string reasons;
if (item.LocalTracks.Count == 1 && item.LocalTracks[0].IsSingleFileRelease)
{
_logger.Debug($"Accepting single file release {item}: {item.Distance.Reasons}");
return Decision.Accept();
}
// strict when a new download // 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)
{ {

View file

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

View file

@ -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.LocalTracks.Count == 1 && item.LocalTracks[0].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 &&

View file

@ -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.LocalTracks.Count == 1 && item.LocalTracks[0].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);

View file

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

View file

@ -105,12 +105,15 @@ namespace NzbDrone.Core.Organizer
var pattern = namingConfig.StandardTrackFormat; var pattern = namingConfig.StandardTrackFormat;
if (tracks.First().AlbumRelease.Value.Media.Count > 1) if (!trackFile.IsSingleFileRelease)
{ {
pattern = namingConfig.MultiDiscTrackFormat; if (tracks.First().AlbumRelease.Value.Media.Count > 1)
} {
pattern = namingConfig.MultiDiscTrackFormat;
}
tracks = tracks.OrderBy(e => e.AlbumReleaseId).ThenBy(e => e.TrackNumber).ToList(); tracks = tracks.OrderBy(e => e.AlbumReleaseId).ThenBy(e => e.TrackNumber).ToList();
}
var splitPatterns = pattern.Split(new[] { '\\', '/' }, StringSplitOptions.RemoveEmptyEntries); var splitPatterns = pattern.Split(new[] { '\\', '/' }, StringSplitOptions.RemoveEmptyEntries);
var components = new List<string>(); var components = new List<string>();
@ -119,15 +122,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)); if (!trackFile.IsSingleFileRelease)
AddTrackTokens(tokenHandlers, tracks, artist); {
AddTrackTitlePlaceholderTokens(tokenHandlers); AddMediumTokens(tokenHandlers, tracks.First().AlbumRelease.Value.Media.SingleOrDefault(m => m.Number == tracks.First().MediumNumber));
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 +152,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}", "...");

View file

@ -73,5 +73,6 @@ namespace NzbDrone.Core.Parser.Model
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 bool IsSingleFileRelease { get; set; }
} }
} }

View file

@ -31,7 +31,7 @@ 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 override string ToString() public override string ToString()
{ {
return Path; return Path;