2019-02-16 14:49:24 +00:00
using System ;
using System.Collections.Generic ;
using System.Linq ;
using Newtonsoft.Json ;
using NLog ;
2019-04-04 08:20:47 +00:00
using NzbDrone.Common ;
2019-02-16 14:49:24 +00:00
using NzbDrone.Common.Extensions ;
2020-02-09 19:15:43 +00:00
using NzbDrone.Common.Instrumentation.Extensions ;
2019-02-16 14:49:24 +00:00
using NzbDrone.Common.Serializer ;
using NzbDrone.Core.Configuration ;
using NzbDrone.Core.MediaFiles.TrackImport.Aggregation ;
using NzbDrone.Core.Music ;
using NzbDrone.Core.Parser ;
using NzbDrone.Core.Parser.Model ;
namespace NzbDrone.Core.MediaFiles.TrackImport.Identification
{
public interface IIdentificationService
{
2020-02-09 19:15:43 +00:00
List < LocalAlbumRelease > Identify ( List < LocalTrack > localTracks , IdentificationOverrides idOverrides , ImportDecisionMakerConfig config ) ;
2019-02-16 14:49:24 +00:00
}
public class IdentificationService : IIdentificationService
{
private readonly ITrackService _trackService ;
private readonly ITrackGroupingService _trackGroupingService ;
private readonly IFingerprintingService _fingerprintingService ;
2019-04-04 08:20:47 +00:00
private readonly IAudioTagService _audioTagService ;
2019-02-16 14:49:24 +00:00
private readonly IAugmentingService _augmentingService ;
2020-02-09 19:15:43 +00:00
private readonly ICandidateService _candidateService ;
2019-02-16 14:49:24 +00:00
private readonly IConfigService _configService ;
private readonly Logger _logger ;
2020-02-09 19:15:43 +00:00
public IdentificationService ( ITrackService trackService ,
2019-02-16 14:49:24 +00:00
ITrackGroupingService trackGroupingService ,
IFingerprintingService fingerprintingService ,
2019-04-04 08:20:47 +00:00
IAudioTagService audioTagService ,
2019-02-16 14:49:24 +00:00
IAugmentingService augmentingService ,
2020-02-09 19:15:43 +00:00
ICandidateService candidateService ,
2019-02-16 14:49:24 +00:00
IConfigService configService ,
Logger logger )
{
_trackService = trackService ;
_trackGroupingService = trackGroupingService ;
_fingerprintingService = fingerprintingService ;
2019-04-04 08:20:47 +00:00
_audioTagService = audioTagService ;
2019-02-16 14:49:24 +00:00
_augmentingService = augmentingService ;
2020-02-09 19:15:43 +00:00
_candidateService = candidateService ;
2019-02-16 14:49:24 +00:00
_configService = configService ;
_logger = logger ;
}
private void LogTestCaseOutput ( List < LocalTrack > localTracks , Artist artist , Album album , AlbumRelease release , bool newDownload , bool singleRelease )
{
2020-01-03 12:49:24 +00:00
var trackData = localTracks . Select ( x = > new BasicLocalTrack
{
Path = x . Path ,
FileTrackInfo = x . FileTrackInfo
} ) ;
var options = new IdTestCase
{
ExpectedMusicBrainzReleaseIds = new List < string > { "expected-id-1" , "expected-id-2" , "..." } ,
LibraryArtists = new List < ArtistTestCase >
{
new ArtistTestCase
{
2019-02-20 01:16:09 +00:00
Artist = artist ? . Metadata . Value . ForeignArtistId ? ? "expected-artist-id (dev: don't forget to add metadata profile)" ,
MetadataProfile = artist ? . MetadataProfile . Value
}
} ,
2019-02-16 14:49:24 +00:00
Artist = artist ? . Metadata . Value . ForeignArtistId ,
Album = album ? . ForeignAlbumId ,
Release = release ? . ForeignReleaseId ,
NewDownload = newDownload ,
SingleRelease = singleRelease ,
Tracks = trackData . ToList ( )
} ;
2020-01-03 12:49:24 +00:00
var serializerSettings = Json . GetSerializerSettings ( ) ;
serializerSettings . Formatting = Formatting . None ;
2019-02-16 14:49:24 +00:00
2020-01-03 12:49:24 +00:00
var output = JsonConvert . SerializeObject ( options , serializerSettings ) ;
2019-02-16 14:49:24 +00:00
_logger . Debug ( $"*** IdentificationService TestCaseGenerator ***\n{output}" ) ;
}
2020-02-09 19:15:43 +00:00
public List < LocalAlbumRelease > GetLocalAlbumReleases ( List < LocalTrack > localTracks , bool singleRelease )
2019-02-16 14:49:24 +00:00
{
var watch = System . Diagnostics . Stopwatch . StartNew ( ) ;
List < LocalAlbumRelease > releases = null ;
if ( singleRelease )
{
2020-01-03 12:49:24 +00:00
releases = new List < LocalAlbumRelease > { new LocalAlbumRelease ( localTracks ) } ;
2019-02-16 14:49:24 +00:00
}
else
{
releases = _trackGroupingService . GroupTracks ( localTracks ) ;
}
_logger . Debug ( $"Sorted {localTracks.Count} tracks into {releases.Count} releases in {watch.ElapsedMilliseconds}ms" ) ;
2020-01-03 12:49:24 +00:00
2019-02-16 14:49:24 +00:00
foreach ( var localRelease in releases )
{
try
{
_augmentingService . Augment ( localRelease ) ;
}
catch ( AugmentingFailedException )
{
_logger . Warn ( $"Augmentation failed for {localRelease}" ) ;
}
2020-02-09 19:15:43 +00:00
}
return releases ;
}
public List < LocalAlbumRelease > Identify ( List < LocalTrack > localTracks , IdentificationOverrides idOverrides , ImportDecisionMakerConfig config )
{
// 1 group localTracks so that we think they represent a single release
// 2 get candidates given specified artist, album and release. Candidates can include extra files already on disk.
// 3 find best candidate
// 4 If best candidate worse than threshold, try fingerprinting
var watch = System . Diagnostics . Stopwatch . StartNew ( ) ;
_logger . Debug ( "Starting track identification" ) ;
var releases = GetLocalAlbumReleases ( localTracks , config . SingleRelease ) ;
2020-01-03 12:49:24 +00:00
2023-05-23 10:52:39 +00:00
var i = 0 ;
2020-02-09 19:15:43 +00:00
foreach ( var localRelease in releases )
{
i + + ;
_logger . ProgressInfo ( $"Identifying album {i}/{releases.Count}" ) ;
IdentifyRelease ( localRelease , idOverrides , config ) ;
2019-02-16 14:49:24 +00:00
}
watch . Stop ( ) ;
_logger . Debug ( $"Track identification for {localTracks.Count} tracks took {watch.ElapsedMilliseconds}ms" ) ;
return releases ;
}
private bool FingerprintingAllowed ( bool newDownload )
{
if ( _configService . AllowFingerprinting = = AllowFingerprinting . Never | |
( _configService . AllowFingerprinting = = AllowFingerprinting . NewFiles & & ! newDownload ) )
{
return false ;
}
return true ;
}
private bool ShouldFingerprint ( LocalAlbumRelease localAlbumRelease )
{
var worstTrackMatchDist = localAlbumRelease . TrackMapping ? . Mapping
2023-04-01 14:57:19 +00:00
. MaxBy ( x = > x . Value . Item2 . NormalizedDistance ( ) )
2019-02-16 14:49:24 +00:00
. Value . Item2 . NormalizedDistance ( ) ? ? 1.0 ;
2020-01-03 12:49:24 +00:00
2019-02-16 14:49:24 +00:00
if ( localAlbumRelease . Distance . NormalizedDistance ( ) > 0.15 | |
localAlbumRelease . TrackMapping . LocalExtra . Any ( ) | |
localAlbumRelease . TrackMapping . MBExtra . Any ( ) | |
worstTrackMatchDist > 0.40 )
{
return true ;
}
return false ;
}
2019-08-01 21:22:28 +00:00
private List < LocalTrack > ToLocalTrack ( IEnumerable < TrackFile > trackfiles , LocalAlbumRelease localRelease )
2019-04-04 08:20:47 +00:00
{
2019-08-01 21:22:28 +00:00
var scanned = trackfiles . Join ( localRelease . LocalTracks , t = > t . Path , l = > l . Path , ( track , localTrack ) = > localTrack ) ;
var toScan = trackfiles . ExceptBy ( t = > t . Path , scanned , s = > s . Path , StringComparer . InvariantCulture ) ;
2020-01-03 12:49:24 +00:00
var localTracks = scanned . Concat ( toScan . Select ( x = > new LocalTrack
{
Path = x . Path ,
Size = x . Size ,
Modified = x . Modified ,
FileTrackInfo = _audioTagService . ReadTags ( x . Path ) ,
ExistingFile = true ,
AdditionalFile = true ,
Quality = x . Quality
} ) )
2020-02-09 19:15:43 +00:00
. ToList ( ) ;
2019-04-04 08:20:47 +00:00
localTracks . ForEach ( x = > _augmentingService . Augment ( x , true ) ) ;
return localTracks ;
}
2020-02-09 19:15:43 +00:00
private void IdentifyRelease ( LocalAlbumRelease localAlbumRelease , IdentificationOverrides idOverrides , ImportDecisionMakerConfig config )
2019-02-16 14:49:24 +00:00
{
var watch = System . Diagnostics . Stopwatch . StartNew ( ) ;
2023-05-23 10:52:39 +00:00
var fingerprinted = false ;
2020-01-03 12:49:24 +00:00
2020-02-09 19:15:43 +00:00
var candidateReleases = _candidateService . GetDbCandidatesFromTags ( localAlbumRelease , idOverrides , config . IncludeExisting ) ;
if ( candidateReleases . Count = = 0 & & config . AddNewArtists )
{
candidateReleases = _candidateService . GetRemoteCandidates ( localAlbumRelease ) ;
}
if ( candidateReleases . Count = = 0 & & FingerprintingAllowed ( config . NewDownload ) )
2019-02-16 14:49:24 +00:00
{
_logger . Debug ( "No candidates found, fingerprinting" ) ;
_fingerprintingService . Lookup ( localAlbumRelease . LocalTracks , 0.5 ) ;
fingerprinted = true ;
2020-02-09 19:15:43 +00:00
candidateReleases = _candidateService . GetDbCandidatesFromFingerprint ( localAlbumRelease , idOverrides , config . IncludeExisting ) ;
if ( candidateReleases . Count = = 0 & & config . AddNewArtists )
{
// Now fingerprints are populated this will return a different answer
candidateReleases = _candidateService . GetRemoteCandidates ( localAlbumRelease ) ;
}
2019-02-16 14:49:24 +00:00
}
if ( candidateReleases . Count = = 0 )
{
// can't find any candidates even after fingerprinting
2020-03-11 21:24:55 +00:00
// populate the overrides and return
foreach ( var localTrack in localAlbumRelease . LocalTracks )
{
localTrack . Release = idOverrides . AlbumRelease ;
localTrack . Album = idOverrides . Album ;
localTrack . Artist = idOverrides . Artist ;
}
2019-02-16 14:49:24 +00:00
return ;
}
_logger . Debug ( $"Got {candidateReleases.Count} candidates for {localAlbumRelease.LocalTracks.Count} tracks in {watch.ElapsedMilliseconds}ms" ) ;
2020-02-09 19:15:43 +00:00
PopulateTracks ( candidateReleases ) ;
2019-04-04 08:20:47 +00:00
// convert all the TrackFiles that represent extra files to List<LocalTrack>
var allLocalTracks = ToLocalTrack ( candidateReleases
. SelectMany ( x = > x . ExistingTracks )
2019-08-01 21:22:28 +00:00
. DistinctBy ( x = > x . Path ) , localAlbumRelease ) ;
2019-02-16 14:49:24 +00:00
2020-02-09 19:15:43 +00:00
_logger . Debug ( $"Retrieved {allLocalTracks.Count} possible tracks in {watch.ElapsedMilliseconds}ms" ) ;
2020-01-03 12:49:24 +00:00
2020-02-09 19:15:43 +00:00
GetBestRelease ( localAlbumRelease , candidateReleases , allLocalTracks ) ;
2019-02-16 14:49:24 +00:00
// If result isn't great and we haven't fingerprinted, try that
// Note that this can improve the match even if we try the same candidates
2020-02-09 19:15:43 +00:00
if ( ! fingerprinted & & FingerprintingAllowed ( config . NewDownload ) & & ShouldFingerprint ( localAlbumRelease ) )
2019-02-16 14:49:24 +00:00
{
_logger . Debug ( $"Match not good enough, fingerprinting" ) ;
_fingerprintingService . Lookup ( localAlbumRelease . LocalTracks , 0.5 ) ;
// Only include extra possible candidates if neither album nor release are specified
// Will generally be specified as part of manual import
2020-02-09 19:15:43 +00:00
if ( idOverrides ? . Album = = null & & idOverrides ? . AlbumRelease = = null )
2019-02-16 14:49:24 +00:00
{
2020-02-09 19:15:43 +00:00
var dbCandidates = _candidateService . GetDbCandidatesFromFingerprint ( localAlbumRelease , idOverrides , config . IncludeExisting ) ;
var remoteCandidates = config . AddNewArtists ? _candidateService . GetRemoteCandidates ( localAlbumRelease ) : new List < CandidateAlbumRelease > ( ) ;
var extraCandidates = dbCandidates . Concat ( remoteCandidates ) ;
2019-04-04 08:20:47 +00:00
var newCandidates = extraCandidates . ExceptBy ( x = > x . AlbumRelease . Id , candidateReleases , y = > y . AlbumRelease . Id , EqualityComparer < int > . Default ) ;
candidateReleases . AddRange ( newCandidates ) ;
2020-02-09 19:15:43 +00:00
PopulateTracks ( candidateReleases ) ;
2019-04-04 08:20:47 +00:00
allLocalTracks . AddRange ( ToLocalTrack ( newCandidates
2020-01-03 12:49:24 +00:00
. SelectMany ( x = > x . ExistingTracks )
2019-04-04 08:20:47 +00:00
. DistinctBy ( x = > x . Path )
2019-08-01 21:22:28 +00:00
. ExceptBy ( x = > x . Path , allLocalTracks , x = > x . Path , PathEqualityComparer . Instance ) ,
localAlbumRelease ) ) ;
2019-02-16 14:49:24 +00:00
}
2019-04-04 08:20:47 +00:00
// fingerprint all the local files in candidates we might be matching against
_fingerprintingService . Lookup ( allLocalTracks , 0.5 ) ;
2020-01-03 12:49:24 +00:00
2020-02-09 19:15:43 +00:00
GetBestRelease ( localAlbumRelease , candidateReleases , allLocalTracks ) ;
2019-02-16 14:49:24 +00:00
}
_logger . Debug ( $"Best release found in {watch.ElapsedMilliseconds}ms" ) ;
localAlbumRelease . PopulateMatch ( ) ;
_logger . Debug ( $"IdentifyRelease done in {watch.ElapsedMilliseconds}ms" ) ;
}
2020-02-09 19:15:43 +00:00
public void PopulateTracks ( List < CandidateAlbumRelease > candidateReleases )
2019-02-16 14:49:24 +00:00
{
var watch = System . Diagnostics . Stopwatch . StartNew ( ) ;
2020-01-03 12:49:24 +00:00
2020-02-09 19:15:43 +00:00
var releasesMissingTracks = candidateReleases . Where ( x = > ! x . AlbumRelease . Tracks . IsLoaded ) ;
var allTracks = _trackService . GetTracksByReleases ( releasesMissingTracks . Select ( x = > x . AlbumRelease . Id ) . ToList ( ) ) ;
2019-02-16 14:49:24 +00:00
2020-02-09 19:15:43 +00:00
_logger . Debug ( $"Retrieved {allTracks.Count} possible tracks in {watch.ElapsedMilliseconds}ms" ) ;
2019-02-16 14:49:24 +00:00
2020-02-09 19:15:43 +00:00
foreach ( var release in releasesMissingTracks )
2019-04-04 08:20:47 +00:00
{
2020-02-09 19:15:43 +00:00
release . AlbumRelease . Tracks = allTracks . Where ( x = > x . AlbumReleaseId = = release . AlbumRelease . Id ) . ToList ( ) ;
2019-04-04 08:20:47 +00:00
}
2019-02-16 14:49:24 +00:00
}
2020-02-09 19:15:43 +00:00
private void GetBestRelease ( LocalAlbumRelease localAlbumRelease , List < CandidateAlbumRelease > candidateReleases , List < LocalTrack > extraTracksOnDisk )
2019-02-16 14:49:24 +00:00
{
var watch = System . Diagnostics . Stopwatch . StartNew ( ) ;
2020-01-03 12:49:24 +00:00
2019-02-16 14:49:24 +00:00
_logger . Debug ( "Matching {0} track files against {1} candidates" , localAlbumRelease . TrackCount , candidateReleases . Count ) ;
_logger . Trace ( "Processing files:\n{0}" , string . Join ( "\n" , localAlbumRelease . LocalTracks . Select ( x = > x . Path ) ) ) ;
2023-05-23 10:52:39 +00:00
var bestDistance = 1.0 ;
2019-02-16 14:49:24 +00:00
2019-04-04 08:20:47 +00:00
foreach ( var candidateRelease in candidateReleases )
2019-02-16 14:49:24 +00:00
{
2019-04-04 08:20:47 +00:00
var release = candidateRelease . AlbumRelease ;
_logger . Debug ( "Trying Release {0} [{1}, {2} tracks, {3} existing]" , release , release . Title , release . TrackCount , candidateRelease . ExistingTracks . Count ) ;
2019-02-16 14:49:24 +00:00
var rwatch = System . Diagnostics . Stopwatch . StartNew ( ) ;
2019-04-04 08:20:47 +00:00
var extraTrackPaths = candidateRelease . ExistingTracks . Select ( x = > x . Path ) . ToList ( ) ;
var extraTracks = extraTracksOnDisk . Where ( x = > extraTrackPaths . Contains ( x . Path ) ) . ToList ( ) ;
var allLocalTracks = localAlbumRelease . LocalTracks . Concat ( extraTracks ) . DistinctBy ( x = > x . Path ) . ToList ( ) ;
2020-01-03 12:49:24 +00:00
2020-02-09 19:15:43 +00:00
var mapping = MapReleaseTracks ( allLocalTracks , release . Tracks . Value ) ;
var distance = DistanceCalculator . AlbumReleaseDistance ( allLocalTracks , release , mapping ) ;
2019-02-16 14:49:24 +00:00
var currDistance = distance . NormalizedDistance ( ) ;
rwatch . Stop ( ) ;
_logger . Debug ( "Release {0} [{1} tracks] has distance {2} vs best distance {3} [{4}ms]" ,
2020-01-03 12:49:24 +00:00
release ,
release . TrackCount ,
currDistance ,
bestDistance ,
rwatch . ElapsedMilliseconds ) ;
2019-02-16 14:49:24 +00:00
if ( currDistance < bestDistance )
{
bestDistance = currDistance ;
localAlbumRelease . Distance = distance ;
localAlbumRelease . AlbumRelease = release ;
2019-04-04 08:20:47 +00:00
localAlbumRelease . ExistingTracks = extraTracks ;
2019-02-16 14:49:24 +00:00
localAlbumRelease . TrackMapping = mapping ;
if ( currDistance = = 0.0 )
{
break ;
}
}
}
watch . Stop ( ) ;
_logger . Debug ( $"Best release: {localAlbumRelease.AlbumRelease} Distance {localAlbumRelease.Distance.NormalizedDistance()} found in {watch.ElapsedMilliseconds}ms" ) ;
}
public TrackMapping MapReleaseTracks ( List < LocalTrack > localTracks , List < Track > mbTracks )
{
var distances = new Distance [ localTracks . Count , mbTracks . Count ] ;
var costs = new double [ localTracks . Count , mbTracks . Count ] ;
2023-05-23 10:52:39 +00:00
for ( var col = 0 ; col < mbTracks . Count ; col + + )
2019-02-16 14:49:24 +00:00
{
2020-02-09 19:15:43 +00:00
var totalTrackNumber = DistanceCalculator . GetTotalTrackNumber ( mbTracks [ col ] , mbTracks ) ;
2023-05-23 10:52:39 +00:00
for ( var row = 0 ; row < localTracks . Count ; row + + )
2019-02-16 14:49:24 +00:00
{
2020-02-09 19:15:43 +00:00
distances [ row , col ] = DistanceCalculator . TrackDistance ( localTracks [ row ] , mbTracks [ col ] , totalTrackNumber , false ) ;
2019-02-16 14:49:24 +00:00
costs [ row , col ] = distances [ row , col ] . NormalizedDistance ( ) ;
}
}
var m = new Munkres ( costs ) ;
m . Run ( ) ;
var result = new TrackMapping ( ) ;
foreach ( var pair in m . Solution )
{
result . Mapping . Add ( localTracks [ pair . Item1 ] , Tuple . Create ( mbTracks [ pair . Item2 ] , distances [ pair . Item1 , pair . Item2 ] ) ) ;
_logger . Trace ( "Mapped {0} to {1}, dist: {2}" , localTracks [ pair . Item1 ] , mbTracks [ pair . Item2 ] , costs [ pair . Item1 , pair . Item2 ] ) ;
}
2020-01-03 12:49:24 +00:00
2019-02-16 14:49:24 +00:00
result . LocalExtra = localTracks . Except ( result . Mapping . Keys ) . ToList ( ) ;
2019-02-20 01:16:09 +00:00
_logger . Trace ( $"Unmapped files:\n{string.Join(" \ n ", result.LocalExtra)}" ) ;
2020-01-03 12:49:24 +00:00
2019-02-16 14:49:24 +00:00
result . MBExtra = mbTracks . Except ( result . Mapping . Values . Select ( x = > x . Item1 ) ) . ToList ( ) ;
2019-02-20 01:16:09 +00:00
_logger . Trace ( $"Missing tracks:\n{string.Join(" \ n ", result.MBExtra)}" ) ;
2019-02-16 14:49:24 +00:00
return result ;
}
}
}