// Copyright (c) Arlo Godfrey. All Rights Reserved.
// Licensed under the GNU Lesser General Public License, Version 3.0 with additional terms.
// See the LICENSE, LICENSE.LESSER and LICENSE.ADDITIONAL files in the project root for more information.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using CommunityToolkit.Diagnostics;
using OwlCore.AbstractStorage;
using OwlCore.AbstractStorage.Scanners;
using OwlCore.AbstractUI.Models;
using OwlCore.Extensions;
using OwlCore.Services;
using StrixMusic.Sdk.AppModels;
using StrixMusic.Sdk.FileMetadata.Repositories;
using StrixMusic.Sdk.FileMetadata.Scanners;
using StrixMusic.Sdk.Services;
namespace StrixMusic.Sdk.FileMetadata
{
///
/// Given an OwlCore.AbstractStorage implementation, this manages scanning and caching all the music metadata from files in folder, including child folders.
///
public sealed class FileMetadataManager : IFileMetadataManager
{
private static string NewGuid() => Guid.NewGuid().ToString();
private readonly IFileScanner _fileScanner;
private readonly AudioMetadataScanner _audioMetadataScanner;
private readonly PlaylistMetadataScanner _playlistMetadataScanner;
private readonly INotificationService? _notificationService;
private readonly IFolderData _rootFolderToScan;
private readonly IFolderData _metadataStorage;
private CancellationTokenSource? _inProgressScanCancellationTokenSource;
private Notification? _filesScannedNotification;
private Notification? _filesFoundNotification;
private AbstractProgressIndicator? _progressUIElement;
private FileScanningType _currentScanningType;
private int _filesFound;
private int _filesProcessed;
///
/// Creates a new instance of .
///
/// The folder where data is scanned.
/// The folder the metadata manager can persist metadata.
/// An optional notification service for notifying the user with dynamic data.
public FileMetadataManager(IFolderData rootFolderToScan, IFolderData metadataStorage, INotificationService? notificationService = null)
{
_notificationService = notificationService;
_fileScanner = new DepthFirstFileScanner(rootFolderToScan);
_audioMetadataScanner = new AudioMetadataScanner(this);
_playlistMetadataScanner = new PlaylistMetadataScanner(this, _audioMetadataScanner, _fileScanner);
Images = new ImageRepository();
Tracks = new TrackRepository();
Albums = new AlbumRepository();
Artists = new ArtistRepository();
Playlists = new PlaylistRepository(_playlistMetadataScanner);
_rootFolderToScan = rootFolderToScan;
_metadataStorage = metadataStorage;
}
///
public async Task InitAsync(CancellationToken cancellationToken = default)
{
Guard.IsFalse(IsInitialized, nameof(IsInitialized));
IsInitialized = true;
Logger.LogInformation($"Setting up repository data location to {_metadataStorage.Path}");
_audioMetadataScanner.CacheFolder = _metadataStorage;
Albums.SetDataFolder(_metadataStorage);
Artists.SetDataFolder(_metadataStorage);
Tracks.SetDataFolder(_metadataStorage);
Playlists.SetDataFolder(_metadataStorage);
Images.SetDataFolder(_metadataStorage);
if (!SkipRepoInit)
{
Logger.LogInformation($"Initializing repositories.");
await Albums.InitAsync(cancellationToken);
await Artists.InitAsync(cancellationToken);
await Tracks.InitAsync(cancellationToken);
await Playlists.InitAsync(cancellationToken);
await Images.InitAsync(cancellationToken);
}
AttachEvents();
Logger.LogInformation($"{nameof(InitAsync)} completed.");
}
///
public event EventHandler? ScanningStarted;
///
public event EventHandler? ScanningCompleted;
private void AttachEvents()
{
_fileScanner.FilesDiscovered += OnFilesDiscovered;
_audioMetadataScanner.FileMetadataAdded += AudioMetadataScanner_FileMetadataAdded;
_fileScanner.FileDiscoveryCompleted += FileScanner_FileScanCompleted;
}
private void DetachEvents()
{
_fileScanner.FilesDiscovered -= OnFilesDiscovered;
_audioMetadataScanner.FileMetadataAdded -= AudioMetadataScanner_FileMetadataAdded;
_fileScanner.FileDiscoveryCompleted -= FileScanner_FileScanCompleted;
}
private async void FileScanner_FileScanCompleted(object sender, IEnumerable e)
{
Logger.LogInformation($"File scan completed");
await RemoveMissingMetadatasAsync(e);
}
private async Task RemoveMissingMetadatasAsync(IEnumerable discoveredFiles)
{
Logger.LogInformation($"Pruning missing metadata.");
var tracks = await Tracks.GetItemsAsync(0, -1);
var removedTracks = tracks.Where(track => discoveredFiles.All(c => c.Path != track.Url)).ToList();
foreach (var removedTrack in removedTracks)
{
await Tracks.RemoveAsync(removedTrack);
if (removedTrack.ArtistIds != null)
{
foreach (var artistId in removedTrack.ArtistIds)
{
var relatedArtist = await Artists.GetByIdAsync(artistId);
if (relatedArtist == null)
continue;
// Do not remove artists if it has more than 1 tracks.
if (relatedArtist.TrackIds?.Count == 1)
await Artists.RemoveAsync(relatedArtist);
}
}
if (removedTrack.AlbumId == null)
return;
var relatedAlbum = await Albums.GetByIdAsync(removedTrack.AlbumId);
if (relatedAlbum != null)
{
// Do not remove album if it has more than 1 tracks.
if (relatedAlbum.TrackIds?.Count == 1)
await Albums.RemoveAsync(relatedAlbum);
}
}
}
private void OnFilesDiscovered(object sender, IEnumerable e)
{
var count = e.Count();
Logger.LogInformation($"{count} files discovered");
FilesFound += count;
}
private async void AudioMetadataScanner_FileMetadataAdded(object sender, IEnumerable e)
{
var fileMetadata = e as Models.FileMetadata[] ?? e.ToArray();
var imageMetadata = fileMetadata.Where(x => x.ImageMetadata != null).SelectMany(x => x.ImageMetadata).ToArray();
var trackMetadata = fileMetadata.Select(x => x.TrackMetadata).PruneNull().ToArray();
var artistMetadata = fileMetadata.Select(x => x.ArtistMetadata).PruneNull().ToArray();
var albumMetadata = fileMetadata.Select(x => x.AlbumMetadata).PruneNull().ToArray();
// Artists and albums reference each other, so update repos in parallel
// and cross your fingers that they internally add all data before emitting changed events
// and that one doesn't finish first.
await Task.WhenAll(Artists.AddOrUpdateAsync(artistMetadata), Albums.AddOrUpdateAsync(albumMetadata));
await Images.AddOrUpdateAsync(imageMetadata);
await Tracks.AddOrUpdateAsync(trackMetadata);
}
///
public bool IsInitialized { get; private set; }
///
/// Gets the number of found files
///
public int FilesFound
{
get => _filesFound;
internal set
{
_filesFound = value;
if (_progressUIElement != null)
_progressUIElement.Maximum = value;
UpdateFilesFoundNotification();
}
}
///
/// Gets the number of processed files.
///
public int FilesProcessed
{
get => _filesProcessed;
internal set
{
_filesProcessed = value;
if (_progressUIElement != null)
_progressUIElement.Value = value;
UpdateFilesScannedNotification();
}
}
///
public IAlbumRepository Albums { get; }
///
public IArtistRepository Artists { get; }
///
public IPlaylistRepository Playlists { get; }
///
public ITrackRepository Tracks { get; }
///
public IImageRepository Images { get; }
///
public bool SkipRepoInit { get; set; }
///
public MetadataScanTypes ScanTypes { get; set; } = MetadataScanTypes.TagLib | MetadataScanTypes.FileProperties;
///
public int DegreesOfParallelism { get; set; } = 2;
///
public async Task ScanAsync(CancellationToken cancellationToken = default)
{
Logger.LogInformation($"Scan started");
if (_inProgressScanCancellationTokenSource is not null)
{
_inProgressScanCancellationTokenSource.Cancel();
_inProgressScanCancellationTokenSource.Dispose();
}
_inProgressScanCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
var currentToken = _inProgressScanCancellationTokenSource.Token;
DismissNotifs();
if (!IsInitialized)
await InitAsync(currentToken);
ScanningStarted?.Invoke(this, EventArgs.Empty);
if (!SkipRepoInit)
{
Logger.LogInformation($"Initializing repositories");
await Albums.InitAsync(currentToken);
await Artists.InitAsync(currentToken);
await Tracks.InitAsync(currentToken);
await Playlists.InitAsync(currentToken);
await Images.InitAsync(currentToken);
}
CancelIfNeeded();
FilesFound = 0;
Logger.LogInformation($"Starting recursive file discovery in {_rootFolderToScan.Path}");
var findingFilesNotif = RaiseFileDiscoveryNotification();
var discoveredFiles = await _fileScanner.ScanFolderAsync(currentToken);
var filesToScan = discoveredFiles as IFileData[] ?? discoveredFiles.ToArray();
findingFilesNotif?.Dismiss();
CancelIfNeeded();
if (filesToScan.Length == 0)
return;
FilesProcessed = 0;
Logger.LogInformation($"Starting metadata scan of audio files");
_currentScanningType = FileScanningType.AudioFiles;
var scanningMusicNotif = RaiseProcessingNotification();
var fileMetadata = await _audioMetadataScanner.ScanMusicFilesAsync(filesToScan, currentToken);
scanningMusicNotif?.Dismiss();
CancelIfNeeded();
Logger.LogInformation($"Starting metadata scan of playlist files");
_currentScanningType = FileScanningType.Playlists;
var scanningPlaylistsNotif = RaiseProcessingNotification();
await _playlistMetadataScanner.ScanPlaylists(filesToScan, fileMetadata, currentToken);
scanningPlaylistsNotif?.Dismiss();
CancelIfNeeded();
ScanningCompleted?.Invoke(this, EventArgs.Empty);
void CancelIfNeeded()
{
if (currentToken.IsCancellationRequested)
{
DismissNotifs();
currentToken.ThrowIfCancellationRequested();
}
}
void DismissNotifs()
{
_filesScannedNotification?.Dismiss();
_filesFoundNotification?.Dismiss();
}
}
private void UpdateFilesScannedNotification()
{
if (_filesScannedNotification is null)
return;
_filesScannedNotification.AbstractUICollection.Subtitle = $"Scanned {FilesProcessed}/{FilesFound} in {_rootFolderToScan.Path}";
}
private void UpdateFilesFoundNotification()
{
if (_filesFoundNotification is null)
return;
_filesFoundNotification.AbstractUICollection.Subtitle = $"Found {FilesFound} in {_rootFolderToScan.Path}";
}
private Notification? RaiseFileDiscoveryNotification()
{
if (_notificationService is null)
return null;
var elementGroup = new AbstractUICollection(NewGuid())
{
Title = "Discovering files",
Subtitle = $"Found {FilesFound} in {_rootFolderToScan.Path}",
};
elementGroup.Add(new AbstractProgressIndicator(NewGuid(), true));
return _filesFoundNotification = _notificationService.RaiseNotification(elementGroup);
}
private Notification? RaiseProcessingNotification()
{
if (_notificationService is null)
return null;
_progressUIElement = new AbstractProgressIndicator(NewGuid(), FilesProcessed, FilesFound);
var scanningTypeStr = _currentScanningType switch
{
FileScanningType.AudioFiles => "music",
FileScanningType.Playlists => "playlists",
_ => ThrowHelper.ThrowArgumentOutOfRangeException(),
};
var elementGroup = new AbstractUICollection(NewGuid())
{
Title = $"Scanning {scanningTypeStr}",
Subtitle = $"Scanned {FilesProcessed}/{FilesFound} in {_rootFolderToScan.Path}",
};
elementGroup.Add(_progressUIElement);
return _filesScannedNotification = _notificationService.RaiseNotification(elementGroup);
}
///
public ValueTask DisposeAsync()
{
Albums.Dispose();
Artists.Dispose();
Playlists.Dispose();
Tracks.Dispose();
Images.Dispose();
_fileScanner.Dispose();
_playlistMetadataScanner.Dispose();
_filesFoundNotification?.Dismiss();
_filesScannedNotification?.Dismiss();
DetachEvents();
return default;
}
}
}