// 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.Concurrent; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using CommunityToolkit.Diagnostics; using Newtonsoft.Json; using OwlCore; using OwlCore.AbstractStorage; using OwlCore.Extensions; using StrixMusic.Sdk.FileMetadata.Models; namespace StrixMusic.Sdk.FileMetadata.Repositories { /// /// The service that helps in interacting with album information. /// public sealed class AlbumRepository : IAlbumRepository { private const string ALBUM_DATA_FILENAME = "AlbumData.bin"; private readonly ConcurrentDictionary _inMemoryMetadata; private readonly SemaphoreSlim _storageMutex; private readonly SemaphoreSlim _initMutex; private readonly string _debouncerId; private IFolderData? _folderData; /// /// Creates a new instance of . /// internal AlbumRepository() { _inMemoryMetadata = new ConcurrentDictionary(); _storageMutex = new SemaphoreSlim(1, 1); _initMutex = new SemaphoreSlim(1, 1); _debouncerId = Guid.NewGuid().ToString(); } /// public async Task InitAsync(CancellationToken cancellationToken = default) { using var initMutexReleaseRegistration = cancellationToken.Register(() => _initMutex.Release()); await _initMutex.WaitAsync(cancellationToken); if (IsInitialized) { _initMutex.Release(); return; } await LoadDataFromDisk(cancellationToken); IsInitialized = true; _initMutex.Release(); } /// public event EventHandler>? MetadataUpdated; /// public event EventHandler>? MetadataAdded; /// public event EventHandler>? MetadataRemoved; /// public bool IsInitialized { get; private set; } /// /// Sets the root folder to operate in when saving data. /// /// The root folder to save data in. public void SetDataFolder(IFolderData rootFolder) { _folderData = rootFolder; } /// public Task GetItemCount() { return Task.FromResult(_inMemoryMetadata.Count); } /// public async Task AddOrUpdateAsync(params AlbumMetadata[] metadata) { var addedAlbums = new List(); var updatedAlbums = new List(); foreach (var item in metadata) { Guard.IsNotNull(item.Id, nameof(item.Id)); var albumExists = true; await _storageMutex.WaitAsync(); var workingMetadata = _inMemoryMetadata.GetOrAdd(item.Id, key => { albumExists = false; return item; }); _storageMutex.Release(); workingMetadata.ArtistIds ??= new HashSet(); workingMetadata.TrackIds ??= new HashSet(); workingMetadata.ImageIds ??= new HashSet(); item.ArtistIds ??= new HashSet(); item.TrackIds ??= new HashSet(); item.ImageIds ??= new HashSet(); item.ArtistIds ??= new HashSet(); item.TrackIds ??= new HashSet(); item.ImageIds ??= new HashSet(); Combine(workingMetadata.ArtistIds, item.ArtistIds); Combine(workingMetadata.TrackIds, item.TrackIds); Combine(workingMetadata.ImageIds, item.ImageIds); if (albumExists) updatedAlbums.Add(workingMetadata); else addedAlbums.Add(workingMetadata); } if (updatedAlbums.Count > 0) MetadataUpdated?.Invoke(this, updatedAlbums); if (addedAlbums.Count > 0) MetadataAdded?.Invoke(this, addedAlbums); _ = CommitChangesAsync(); void Combine(HashSet originalData, HashSet newIds) { foreach (var newId in newIds.ToArray()) originalData.Add(newId); } } /// public async Task RemoveAsync(AlbumMetadata metadata) { Guard.IsNotNullOrWhiteSpace(metadata.Id, nameof(metadata.Id)); await _storageMutex.WaitAsync(); var removed = _inMemoryMetadata.TryRemove(metadata.Id, out _); _storageMutex.Release(); if (removed) { _ = CommitChangesAsync(); MetadataRemoved?.Invoke(this, metadata.IntoList()); } } /// public Task GetByIdAsync(string id) { _inMemoryMetadata.TryGetValue(id, out var metadata); return Task.FromResult(metadata); } /// public Task> GetItemsAsync(int offset, int limit) { var allAlbums = _inMemoryMetadata.Values.ToList(); if (limit == -1) return Task.FromResult>(allAlbums); // If the offset exceeds the number of items we have, return nothing. if (offset >= allAlbums.Count) return Task.FromResult>(new List()); // If the total number of requested items exceeds the number of items we have, adjust the limit so it won't go out of range. if (offset + limit > allAlbums.Count) limit = allAlbums.Count - offset; return Task.FromResult>(allAlbums.GetRange(offset, limit)); } /// public async Task> GetAlbumsByArtistId(string artistId, int offset, int limit) { var allArtists = await GetItemsAsync(offset, -1); var results = new List(); foreach (var item in allArtists) { Guard.IsNotNull(item.ArtistIds, nameof(item.ArtistIds)); Guard.IsGreaterThan(item.ArtistIds.Count, 0, nameof(item.ArtistIds.Count)); if (item.ArtistIds.Contains(artistId)) results.Add(item); } // If the offset exceeds the number of items we have, return nothing. if (offset >= results.Count) return new List(); // If the total number of requested items exceeds the number of items we have, adjust the limit so it won't go out of range. if (offset + limit > results.Count) limit = results.Count - offset; return results.GetRange(offset, limit).ToList(); } /// /// Gets the existing repo data stored on disk. /// /// The collection. private async Task LoadDataFromDisk(CancellationToken cancellationToken) { Guard.IsEmpty((ICollection>)_inMemoryMetadata, nameof(_inMemoryMetadata)); Guard.IsNotNull(_folderData, nameof(_folderData)); var fileData = await _folderData.CreateFileAsync(ALBUM_DATA_FILENAME, CreationCollisionOption.OpenIfExists); Guard.IsNotNull(fileData, nameof(fileData)); using var stream = await fileData.GetStreamAsync(FileAccessMode.ReadWrite); cancellationToken.ThrowIfCancellationRequested(); var bytes = await stream.ToBytesAsync(); cancellationToken.ThrowIfCancellationRequested(); if (bytes.Length == 0) return; var str = System.Text.Encoding.UTF8.GetString(bytes); var data = JsonConvert.DeserializeObject>(str); cancellationToken.ThrowIfCancellationRequested(); using var mutexReleaseOnCancelRegistration = cancellationToken.Register(() => _storageMutex.Release()); await _storageMutex.WaitAsync(cancellationToken); cancellationToken.ThrowIfCancellationRequested(); foreach (var item in data ?? Enumerable.Empty()) { Guard.IsNotNullOrWhiteSpace(item?.Id, nameof(item.Id)); if (!_inMemoryMetadata.TryAdd(item.Id, item)) ThrowHelper.ThrowInvalidOperationException($"Item already added to {nameof(_inMemoryMetadata)}"); } _storageMutex.Release(); } private async Task CommitChangesAsync() { if (!await Flow.Debounce(_debouncerId, TimeSpan.FromSeconds(5)) || _inMemoryMetadata.IsEmpty) return; await _storageMutex.WaitAsync(); Guard.IsNotNull(_folderData, nameof(_folderData)); var json = JsonConvert.SerializeObject(_inMemoryMetadata.Values.DistinctBy(x => x.Id).ToList()); var fileData = await _folderData.CreateFileAsync(ALBUM_DATA_FILENAME, CreationCollisionOption.OpenIfExists); await fileData.WriteAllBytesAsync(System.Text.Encoding.UTF8.GetBytes(json)); _storageMutex.Release(); } /// public void Dispose() { _initMutex.Dispose(); _storageMutex.Dispose(); } } }