// 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 track information.
///
public sealed class TrackRepository : ITrackRepository
{
private const string TRACK_DATA_FILENAME = "TrackData.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 .
///
public TrackRepository()
{
_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 work in.
public void SetDataFolder(IFolderData rootFolder)
{
_folderData = rootFolder;
}
///
public Task GetItemCount()
{
return Task.FromResult(_inMemoryMetadata.Count);
}
///
public async Task AddOrUpdateAsync(params TrackMetadata[] trackMetadata)
{
var addedTracks = new List();
var updatedTracks = new List();
// Iterate through FileMetadata and store in memory.
// Updates and additions are tracked separately and emitted as events after all metadata has been processed.
foreach (var item in trackMetadata)
{
Guard.IsNotNullOrWhiteSpace(item.Id, nameof(item.Id));
var trackExists = true;
await _storageMutex.WaitAsync();
var workingMetadata = _inMemoryMetadata.GetOrAdd(item.Id, key =>
{
trackExists = false;
return item;
});
workingMetadata.ArtistIds ??= new HashSet();
workingMetadata.ImageIds ??= new HashSet();
item.ArtistIds ??= new HashSet();
item.ImageIds ??= new HashSet();
Combine(workingMetadata.ArtistIds, item.ArtistIds);
Combine(workingMetadata.ImageIds, item.ImageIds);
_storageMutex.Release();
if (trackExists)
updatedTracks.Add(workingMetadata);
else
addedTracks.Add(workingMetadata);
}
if (addedTracks.Count > 0 || updatedTracks.Count > 0)
{
_ = CommitChangesAsync();
if (addedTracks.Count > 0)
MetadataAdded?.Invoke(this, addedTracks);
if (updatedTracks.Count > 0)
MetadataUpdated?.Invoke(this, updatedTracks);
}
void Combine(HashSet originalData, HashSet newIds)
{
foreach (var newId in newIds.ToArray())
originalData.Add(newId);
}
}
///
public async Task RemoveAsync(TrackMetadata trackMetadata)
{
Guard.IsNotNullOrWhiteSpace(trackMetadata.Id, nameof(trackMetadata.Id));
await _storageMutex.WaitAsync();
var removed = _inMemoryMetadata.TryRemove(trackMetadata.Id, out _);
_storageMutex.Release();
if (removed)
{
await CommitChangesAsync();
MetadataRemoved?.Invoke(this, trackMetadata.IntoList());
}
}
///
public Task GetByIdAsync(string id)
{
_inMemoryMetadata.TryGetValue(id, out var trackMetadata);
return Task.FromResult(trackMetadata);
}
///
public Task> GetItemsAsync(int offset, int limit)
{
var allTracks = _inMemoryMetadata.Values.OrderBy(c => c.TrackNumber).ToList();
if (limit == -1)
return Task.FromResult>(allTracks);
// If the offset exceeds the number of items we have, return nothing.
if (offset >= allTracks.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 > allTracks.Count)
limit = allTracks.Count - offset;
return Task.FromResult>(allTracks.GetRange(offset, limit));
}
///
public async Task> GetTracksByArtistId(string artistId, int offset, int limit)
{
var allTracks = await GetItemsAsync(offset, -1);
var results = new List();
foreach (var item in allTracks)
{
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();
}
///
public async Task> GetTracksByAlbumId(string albumId, int offset, int limit)
{
var results = new List();
var allTracks = await GetItemsAsync(offset, -1);
foreach (var item in allTracks)
{
Guard.IsNotNull(item.AlbumId, nameof(item.AlbumId));
if (item.AlbumId == albumId)
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.Skip(offset).Take(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(TRACK_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(TRACK_DATA_FILENAME, CreationCollisionOption.OpenIfExists);
await fileData.WriteAllBytesAsync(System.Text.Encoding.UTF8.GetBytes(json));
_storageMutex.Release();
}
///
public void Dispose()
{
_initMutex.Dispose();
_storageMutex.Dispose();
}
}
}