// 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.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using CommunityToolkit.Diagnostics;
using OwlCore.Events;
using OwlCore.Extensions;
using OwlCore.Provisos;
using StrixMusic.Sdk.AppModels;
using StrixMusic.Sdk.BaseModels;
using StrixMusic.Sdk.CoreModels;
using StrixMusic.Sdk.Extensions;
namespace StrixMusic.Sdk.AdapterModels
{
///
/// Manages the merging of multiple s and presents them as single .
///
/// The collection type that this is part of.
/// The types of items that were merged to form .
/// The type of the item returned from the merged collection.
/// The type of the items returned from the original source collections.
internal sealed class MergedCollectionMap
: IMerged, IMergedMutable, IAsyncInit, IAsyncDisposable
where TCollection : class, ICollectionBase, IMerged
where TCoreCollection : class, ICoreCollection
where TCollectionItem : class, ICollectionItemBase, IMerged
where TCoreCollectionItem : class, ICollectionItemBase, ICoreMember
{
// ReSharper disable StaticMemberInGenericType
private static bool _isInitialized;
private static TaskCompletionSource? _initCompletionSource;
private readonly SemaphoreSlim _disposeSemaphore = new(1, 1);
private readonly TCollection _collection;
private readonly MergedCollectionConfig _config;
///
/// A map where each index contains the representation of an item returned from a source collection, where the value is that source collection.
///
private readonly List _sortedMap = new();
private readonly List _mergedMappedData = new();
private bool _isDisposed;
///
public IReadOnlyList Sources => _collection.Sources;
///
public event EventHandler? SourcesChanged;
///
/// Initializes a new instance of .
///
/// The collection that contains the items
/// Configurable options for this merged collection map.
public MergedCollectionMap(TCollection collection, MergedCollectionConfig config)
{
_collection = collection;
_config = config;
Guard.IsGreaterThan(config.CoreRanking.Count, 0);
AttachEvents();
}
private static async Task InsertItemIntoCollectionAsync(TCoreCollection sourceCollection, T itemToAdd, int originalIndex, CancellationToken cancellationToken)
where T : class, ICollectionItemBase, ICoreMember // https://twitter.com/Arlodottxt/status/1351317100959326213?s=20
{
switch (sourceCollection)
{
case ICorePlayableCollectionGroup playableCollection:
if (await playableCollection.IsAddChildAvailableAsync(originalIndex, cancellationToken))
await playableCollection.AddChildAsync((ICorePlayableCollectionGroup)itemToAdd, originalIndex, cancellationToken);
break;
case ICoreAlbumCollection albumCollection:
if (await albumCollection.IsAddAlbumItemAvailableAsync(originalIndex, cancellationToken))
await albumCollection.AddAlbumItemAsync((ICoreAlbumCollectionItem)itemToAdd, originalIndex, cancellationToken);
break;
case ICoreArtistCollection artistCollection:
if (await artistCollection.IsAddArtistItemAvailableAsync(originalIndex, cancellationToken))
await artistCollection.AddArtistItemAsync((ICoreArtistCollectionItem)itemToAdd, originalIndex, cancellationToken);
break;
case ICorePlaylistCollection playlistCollection:
if (await playlistCollection.IsAddPlaylistItemAvailableAsync(originalIndex, cancellationToken))
await playlistCollection.AddPlaylistItemAsync((ICorePlaylistCollectionItem)itemToAdd, originalIndex, cancellationToken);
break;
case ICoreTrackCollection trackCollection:
if (await trackCollection.IsAddTrackAvailableAsync(originalIndex, cancellationToken))
await trackCollection.AddTrackAsync((ICoreTrack)itemToAdd, originalIndex, cancellationToken);
break;
case ICoreImageCollection imageCollection:
if (await imageCollection.IsAddImageAvailableAsync(originalIndex, cancellationToken))
await imageCollection.AddImageAsync((ICoreImage)itemToAdd, originalIndex, cancellationToken);
break;
case ICoreGenreCollection genreCollection:
if (await genreCollection.IsAddGenreAvailableAsync(originalIndex, cancellationToken))
await genreCollection.AddGenreAsync((ICoreGenre)itemToAdd, originalIndex, cancellationToken);
break;
case ICoreUrlCollection urlCollection:
if (await urlCollection.IsAddUrlAvailableAsync(originalIndex, cancellationToken))
await urlCollection.AddUrlAsync((ICoreUrl)itemToAdd, originalIndex, cancellationToken);
break;
default:
ThrowHelper.ThrowNotSupportedException>($"Couldn't add item to collection. Type {sourceCollection.GetType()} not supported.");
break;
}
}
private static async Task InsertExistingItemAsync(TCollectionItem itemToInsert, MappedData mappedData, CancellationToken cancellationToken)
{
foreach (var source in itemToInsert.Sources)
{
var addedRecord = new Dictionary();
if (mappedData.CollectionItem is null)
continue;
var sourceCollection = mappedData.SourceCollection;
// Make sure the source cores are the same.
if (sourceCollection.SourceCore != source.SourceCore)
continue;
// Only add to this source collection once.
if (addedRecord.ContainsKey(sourceCollection))
continue;
addedRecord.Add(sourceCollection, true);
var originalIndex = mappedData.OriginalIndex;
await InsertItemIntoCollectionAsync(sourceCollection, source, originalIndex, cancellationToken);
}
}
private static async Task InsertNewItemAsync(IEnumerable sourceCollections, IEnumerable sourceCores, IInitialData dataToInsert, int index, CancellationToken cancellationToken = default)
{
// TODO create setting to let user decide default
foreach (var source in sourceCollections)
{
var targetSources = sourceCores;
if (dataToInsert.TargetSourceCores is { Count: > 0 })
{
targetSources = dataToInsert.TargetSourceCores;
}
// Try adding to all by default
foreach (var targetCore in targetSources)
{
if (dataToInsert is InitialPlaylistData playlistData)
{
var coreData = new InitialCorePlaylistData(playlistData, targetCore);
await InsertItemIntoCollectionAsync(source, coreData, index, cancellationToken);
}
}
}
}
///
public async Task InitAsync(CancellationToken cancellationToken = default)
{
if (IsInitialized)
return;
if (_initCompletionSource?.Task.Status is TaskStatus.Running or TaskStatus.WaitingForActivation or TaskStatus.RanToCompletion)
{
await _initCompletionSource.Task;
return;
}
_initCompletionSource = new TaskCompletionSource();
_config.CoreRankingChanged += ConfigOnCoreRankingChanged;
_config.MergedCollectionSortingChanged += ConfigOnMergedCollectionSortingChanged;
Guard.HasSizeGreaterThan(_config.CoreRanking, 0, nameof(_config.CoreRanking));
_initCompletionSource.SetResult(true);
IsInitialized = true;
}
///
public bool IsInitialized
{
get => _isInitialized;
set => _isInitialized = value;
}
private Task TryInitAsync(CancellationToken cancellationToken) => InitAsync(cancellationToken);
///
/// Fires when a source has been added and the merged collection needs to be re-emitted to include the new source.
///
public event CollectionChangedEventHandler? ItemsChanged;
///
/// Fires when the number of items in the merged collection changes, either from a new source being added or from an item getting merged into another.
///
public event EventHandler? ItemsCountChanged;
private void AttachEvents()
{
foreach (var item in Sources)
{
AttachEvents(item);
}
}
private void DetachEvents()
{
foreach (var item in Sources)
{
DetachEvents(item);
}
}
private void AttachEvents(TCoreCollection item)
{
if (typeof(TCoreCollection) == typeof(ICorePlayableCollectionGroup))
{
((ICorePlayableCollectionGroup)item).ChildItemsChanged += MergedCollectionMap_ChildItemsChanged;
((ICorePlayableCollectionGroup)item).ChildrenCountChanged += MergedCollectionMap_CountChanged;
}
else if (typeof(TCoreCollection) == typeof(ICoreAlbumCollection))
{
((ICoreAlbumCollection)item).AlbumItemsCountChanged += MergedCollectionMap_CountChanged;
((ICoreAlbumCollection)item).AlbumItemsChanged += MergedCollectionMap_AlbumItemsChanged;
}
else if (typeof(TCoreCollection) == typeof(ICoreArtistCollection))
{
((ICoreArtistCollection)item).ArtistItemsCountChanged += MergedCollectionMap_CountChanged;
((ICoreArtistCollection)item).ArtistItemsChanged += MergedCollectionMap_ArtistItemsChanged;
}
else if (typeof(TCoreCollection) == typeof(ICorePlaylistCollection))
{
((ICorePlaylistCollection)item).PlaylistItemsCountChanged += MergedCollectionMap_CountChanged;
((ICorePlaylistCollection)item).PlaylistItemsChanged += MergedCollectionMap_PlaylistItemsChanged;
}
else if (typeof(TCoreCollection) == typeof(ICoreTrackCollection))
{
((ICoreTrackCollection)item).TracksCountChanged += MergedCollectionMap_CountChanged;
((ICoreTrackCollection)item).TracksChanged += MergedCollectionMap_TrackItemsChanged;
}
else if (typeof(TCoreCollection) == typeof(ICoreImageCollection))
{
((ICoreImageCollection)item).ImagesCountChanged += MergedCollectionMap_CountChanged;
((ICoreImageCollection)item).ImagesChanged += MergedCollectionMap_ImagesChanged;
}
else if (typeof(TCoreCollection) == typeof(ICoreGenreCollection))
{
((ICoreGenreCollection)item).GenresCountChanged += MergedCollectionMap_CountChanged;
((ICoreGenreCollection)item).GenresChanged += MergedCollectionMap_GenresChanged;
}
else if (typeof(TCoreCollection) == typeof(ICoreUrlCollection))
{
((ICoreUrlCollection)item).UrlsCountChanged += MergedCollectionMap_CountChanged;
((ICoreUrlCollection)item).UrlsChanged += MergedCollectionMap_UrlsChanged;
}
else
{
ThrowHelper.ThrowNotSupportedException>(
$"Couldn't attach events. Type \"{typeof(TCoreCollection)}\" not supported.");
}
}
private void DetachEvents(TCoreCollection item)
{
if (typeof(TCoreCollection) == typeof(ICorePlayableCollectionGroup))
{
((ICorePlayableCollectionGroup)item).ChildItemsChanged -= MergedCollectionMap_ChildItemsChanged;
((ICorePlayableCollectionGroup)item).ChildrenCountChanged -= MergedCollectionMap_CountChanged;
}
else if (typeof(TCoreCollection) == typeof(ICoreAlbumCollection))
{
((ICoreAlbumCollection)item).AlbumItemsCountChanged -= MergedCollectionMap_CountChanged;
((ICoreAlbumCollection)item).AlbumItemsChanged -= MergedCollectionMap_AlbumItemsChanged;
}
else if (typeof(TCoreCollection) == typeof(ICoreArtistCollection))
{
((ICoreArtistCollection)item).ArtistItemsCountChanged -= MergedCollectionMap_CountChanged;
((ICoreArtistCollection)item).ArtistItemsChanged -= MergedCollectionMap_ArtistItemsChanged;
}
else if (typeof(TCoreCollection) == typeof(ICorePlaylistCollection))
{
((ICoreArtistCollection)item).ArtistItemsCountChanged -= MergedCollectionMap_CountChanged;
((ICoreArtistCollection)item).ArtistItemsChanged -= MergedCollectionMap_ArtistItemsChanged;
}
else if (typeof(TCoreCollection) == typeof(ICoreTrackCollection))
{
((ICoreTrackCollection)item).TracksCountChanged -= MergedCollectionMap_CountChanged;
((ICoreTrackCollection)item).TracksChanged -= MergedCollectionMap_TrackItemsChanged;
}
else if (typeof(TCoreCollection) == typeof(ICoreImageCollection))
{
((ICoreImageCollection)item).ImagesCountChanged -= MergedCollectionMap_CountChanged;
((ICoreImageCollection)item).ImagesChanged -= MergedCollectionMap_ImagesChanged;
}
else if (typeof(TCoreCollection) == typeof(ICoreGenreCollection))
{
((ICoreGenreCollection)item).GenresCountChanged -= MergedCollectionMap_CountChanged;
((ICoreGenreCollection)item).GenresChanged -= MergedCollectionMap_GenresChanged;
}
else if (typeof(TCoreCollection) == typeof(ICoreUrlCollection))
{
((ICoreUrlCollection)item).UrlsCountChanged -= MergedCollectionMap_CountChanged;
((ICoreUrlCollection)item).UrlsChanged -= MergedCollectionMap_UrlsChanged;
}
else
{
ThrowHelper.ThrowNotSupportedException>(
"Couldn't detach events. Type not supported.");
}
}
private void MergedCollectionMap_ImagesChanged(object sender, IReadOnlyList> addedItems, IReadOnlyList> removedItems)
{
MergedCollectionMap_ItemsChanged(sender, addedItems, removedItems);
}
private void MergedCollectionMap_GenresChanged(object sender, IReadOnlyList> addedItems, IReadOnlyList> removedItems)
{
MergedCollectionMap_ItemsChanged(sender, addedItems, removedItems);
}
private void MergedCollectionMap_UrlsChanged(object sender, IReadOnlyList> addedItems, IReadOnlyList> removedItems)
{
MergedCollectionMap_ItemsChanged(sender, addedItems, removedItems);
}
private void MergedCollectionMap_TrackItemsChanged(object sender, IReadOnlyList> addedItems, IReadOnlyList> removedItems)
{
MergedCollectionMap_ItemsChanged(sender, addedItems, removedItems);
}
private void MergedCollectionMap_ArtistItemsChanged(object sender, IReadOnlyList> addedItems, IReadOnlyList> removedItems)
{
MergedCollectionMap_ItemsChanged(sender, addedItems, removedItems);
}
private void MergedCollectionMap_AlbumItemsChanged(object sender, IReadOnlyList> addedItems, IReadOnlyList> removedItems)
{
MergedCollectionMap_ItemsChanged(sender, addedItems, removedItems);
}
private void MergedCollectionMap_ChildItemsChanged(object sender, IReadOnlyList> addedItems, IReadOnlyList> removedItems)
{
var changedItemsCount = addedItems.Count + removedItems.Count;
Guard.IsGreaterThan(changedItemsCount, 0, nameof(changedItemsCount));
MergedCollectionMap_ItemsChanged(sender, addedItems, removedItems);
}
private void MergedCollectionMap_PlaylistItemsChanged(object sender, IReadOnlyList> addedItems, IReadOnlyList> removedItems)
{
MergedCollectionMap_ItemsChanged(sender, addedItems, removedItems);
}
private void MergedCollectionMap_ItemsChanged(object sender, IReadOnlyList> addedItems, IReadOnlyList> removedItems)
where T : class, ICollectionItemBase, ICoreMember
{
Guard.IsGreaterThan(addedItems.Count + removedItems.Count, 0, "Total changed items count");
lock (_mergedMappedData)
{
var addedMergedItems = ItemsAdded_CheckAddedItems(addedItems, sender);
var removedMergedItems = ItemsChanged_CheckRemovedItems(removedItems);
ItemsChanged?.Invoke(this, addedMergedItems, removedMergedItems);
ItemsCountChanged?.Invoke(this, _mergedMappedData.Count);
}
}
private List> ItemsAdded_CheckAddedItems(IReadOnlyList> addedItems, object sender)
where T : class, ICollectionItemBase, ICoreMember
{
var added = new List>();
var newItems = new List>();
foreach (var item in addedItems)
{
var newItemsCount = newItems.Count;
if (!(item.Data is TCoreCollectionItem collectionItemData))
return ThrowHelper.ThrowInvalidOperationException>>($"{nameof(item.Data)} couldn't be cast to {nameof(TCoreCollectionItem)}.");
// TODO: Sorting is not handled.
var mappedData = new MappedData(item.Index, (TCoreCollection)sender, collectionItemData);
var mergedImpl = MergeOrAdd(newItems, collectionItemData, _config);
_sortedMap.Add(mappedData);
// If the number of items in this list changes, the item was not merged and should be emitted on the ItemsChanged event.
if (newItemsCount != newItems.Count)
{
_mergedMappedData.Add(new MergedMappedData(mergedImpl, new[] { mappedData }));
var changedItem = new CollectionChangedItem((TCollectionItem)mergedImpl, _mergedMappedData.Count - 1);
Guard.IsGreaterThanOrEqualTo(changedItem.Index, 0);
added.Add(changedItem);
}
}
return added;
}
private List> ItemsChanged_CheckRemovedItems(IReadOnlyList> removedItems)
where T : class, ICollectionItemBase, ICoreMember
{
var removed = new List>();
if (_sortedMap.Count == 0)
return removed;
foreach (var item in removedItems)
{
var mappedData = _sortedMap.FirstOrDefault(x => x.OriginalIndex == item.Index && item.Data.SourceCore == x.SourceCollection.SourceCore);
if (mappedData == null) continue;
foreach (var mergedData in _mergedMappedData)
{
foreach (var mergedSource in mergedData.CollectionItem.Cast>().Sources)
{
if (mappedData.CollectionItem != mergedSource)
continue;
_sortedMap.Remove(mappedData);
mergedData.CollectionItem.RemoveSource(mergedSource);
mergedData.MergedMapData.RemoveAll(x => x.OriginalIndex == item.Index && item.Data.SourceCore == x.SourceCollection.SourceCore);
if (mergedData.CollectionItem.Cast>().Sources.Count == 0)
{
var index = _mergedMappedData.IndexOf(mergedData);
_mergedMappedData.Remove(mergedData);
var changedItem = new CollectionChangedItem((TCollectionItem)mergedData.CollectionItem, index);
Guard.IsGreaterThanOrEqualTo(changedItem.Index, 0);
removed.Add(changedItem);
}
return removed;
}
}
}
return removed;
}
///
/// This is sent from each core.
/// The count would be wrong if we tried to re-emit it as is due to merging.
/// We emit CountChanged (for the MergedCollectionMap) when items are changed.
/// TODO: Maybe we can use it this event verify the size of the collection is correct?
///
private void MergedCollectionMap_CountChanged(object sender, int e)
{
// If we haven't evaluated item count ourselves by merging items together yet
if (_mergedMappedData.Count == 0)
{
// Emit updated max possible item count.
// Needed b/c Merged layer can be constructed before cores are async initialized.
// And some cores need to be async initialized to know the item count.
// Failing to do this can result in consumers of the merged layer thinking a collections has no items (int default 0), and will never even try to get them.
var count = Sources.Aggregate(0, (x, y) => x += y.GetItemsCount());
ItemsCountChanged?.Invoke(this, count);
}
}
///
/// Gets a range of items from the collection, merged and sorted from multiple sources.
///
/// The max number of items to return.
/// Get items starting at this index.
/// A cancellation token that may be used to cancel the ongoing task.
/// The requested range of items, sorted and merged from the sources in the input collection.
public async IAsyncEnumerable GetItemsAsync(int limit, int offset, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await TryInitAsync(cancellationToken);
await foreach (var item in GetItemsByRank(limit, offset, cancellationToken))
yield return item;
}
///
/// Inserts an item into the compatible source collections on the backend.
///
/// The item to insert.
/// The index to place this item at.
/// A cancellation token that may be used to cancel the ongoing task.
/// A representing the asynchronous operation.
public async Task InsertItemAsync(TCollectionItem item, int index, CancellationToken cancellationToken = default)
{
await TryInitAsync(cancellationToken);
Guard.IsNotNull(item, nameof(item));
if (item is IInitialData createdData)
{
await InsertNewItemAsync(Sources, _collection.GetSources().Select(x => x.SourceCore), createdData, index, cancellationToken);
return;
}
// Handle inserting an existing item
await InsertExistingItemAsync(item, _sortedMap[index], cancellationToken);
}
///
/// Inserts an item into the compatible source collections on the backend.
///
/// The index to place this item at.
/// A cancellation token that may be used to cancel the ongoing task.
/// A representing the asynchronous operation.
public async Task RemoveAtAsync(int index, CancellationToken cancellationToken = default)
{
await TryInitAsync(cancellationToken);
// Externally, the app sees non-core items as this internal collection of merged and sorted items and data.
// When they ask for an item at an index, they're asking for an item at that index that could be merged.
// So we go through each of the mapped sources for the item at this index and handle removing from the core side.
var targetItem = _mergedMappedData[index];
foreach (var mappedData in targetItem.MergedMapData)
{
Guard.IsNotNull(mappedData.CollectionItem, nameof(mappedData.CollectionItem));
var sourceCollection = mappedData.SourceCollection;
var source = mappedData.CollectionItem;
var isAvailable = await sourceCollection.IsRemoveAvailable(index, cancellationToken);
if (!isAvailable)
continue;
switch (sourceCollection)
{
case ICorePlayableCollectionGroup playableCollection:
await playableCollection.RemoveChildAsync(mappedData.OriginalIndex, cancellationToken);
break;
case ICoreAlbumCollection albumCollection:
await albumCollection.RemoveAlbumItemAsync(mappedData.OriginalIndex, cancellationToken);
break;
case ICoreArtistCollection artistCollection:
await artistCollection.RemoveArtistItemAsync(mappedData.OriginalIndex, cancellationToken);
break;
case ICorePlaylistCollection playlistCollection:
await playlistCollection.RemovePlaylistItemAsync(mappedData.OriginalIndex, cancellationToken);
break;
case ICoreTrackCollection trackCollection:
await trackCollection.RemoveTrackAsync(mappedData.OriginalIndex, cancellationToken);
break;
case ICoreImageCollection imageCollection:
await imageCollection.RemoveImageAsync(mappedData.OriginalIndex, cancellationToken);
break;
case ICoreGenreCollection genreCollection:
await genreCollection.RemoveGenreAsync(mappedData.OriginalIndex, cancellationToken);
break;
case ICoreUrlCollection urlCollection:
await urlCollection.RemoveUrlAsync(mappedData.OriginalIndex, cancellationToken);
break;
default:
ThrowHelper.ThrowNotSupportedException>("Couldn't create merged item. Type not supported.");
break;
}
}
}
///
/// Checks if adding an item to the sorted map is supported.
///
/// The index to remove.
/// A cancellation token that may be used to cancel the ongoing task.
/// A representing the asynchronous operation. Value indicates support.
public async Task IsAddItemAvailableAsync(int index, CancellationToken cancellationToken = default)
{
await TryInitAsync(cancellationToken);
var sourceResults = await _mergedMappedData[index].MergedMapData
.InParallel(async x => await x.SourceCollection.IsAddAvailable(x.OriginalIndex, cancellationToken));
return sourceResults.Any();
}
///
/// Checks if removing an item from the sorted map is supported.
///
/// The index to remove.
/// A cancellation token that may be used to cancel the ongoing task.
/// A representing the asynchronous operation. Value indicates support.
public async Task IsRemoveItemAvailableAsync(int index, CancellationToken cancellationToken = default)
{
await TryInitAsync(cancellationToken);
var sourceResults = await _mergedMappedData[index].MergedMapData
.InParallel(async x => await x.SourceCollection.IsRemoveAvailable(x.OriginalIndex, cancellationToken));
return sourceResults.Any();
}
private static IMergedMutable MergeOrAdd(List> collection, TCoreCollectionItem itemToMerge, MergedCollectionConfig config)
{
foreach (var item in collection)
{
// ReSharper disable once SuspiciousTypeConversion.Global
if (item.Equals(itemToMerge))
{
item.AddSource(itemToMerge);
return item;
}
}
IMergedMutable? returnData;
// if the collection doesn't contain IMerged at all, create a new Merged
switch (itemToMerge)
{
case ICoreArtist artist:
returnData = (IMergedMutable)new MergedArtist(artist.IntoList(), config);
collection.Add(returnData);
break;
case ICoreAlbum album:
returnData = (IMergedMutable)new MergedAlbum(album.IntoList(), config);
collection.Add(returnData);
break;
case ICorePlaylist playlist:
returnData = (IMergedMutable)new MergedPlaylist(playlist.IntoList(), config);
collection.Add(returnData);
break;
case ICoreTrack track:
returnData = (IMergedMutable)new MergedTrack(track.IntoList(), config);
collection.Add(returnData);
break;
case ICoreDiscoverables discoverables:
returnData = (IMergedMutable)new MergedDiscoverables(discoverables.IntoList(), config);
collection.Add(returnData);
break;
case ICoreLibrary library:
returnData = (IMergedMutable)new MergedLibrary(library.IntoList(), config);
collection.Add(returnData);
break;
case ICoreRecentlyPlayed recentlyPlayed:
returnData = (IMergedMutable)new MergedRecentlyPlayed(recentlyPlayed.IntoList(), config);
collection.Add(returnData);
break;
case ICoreImage coreImage:
returnData = (IMergedMutable)new MergedImage(coreImage.IntoList());
collection.Add(returnData);
break;
case ICoreGenre coreGenre:
returnData = (IMergedMutable)new MergedGenre(coreGenre.IntoList());
collection.Add(returnData);
break;
case ICoreUrl coreUrl:
returnData = (IMergedMutable)new MergedUrl(coreUrl.IntoList());
collection.Add(returnData);
break;
// TODO: Search results post search redo (done, please do this)
// Collections that are returned from other collections, but need their own separate ViewModels.
// Example: an AlbumCollection can return either an Album or another AlbumCollection,
// so we need ViewModels and Merged proxy classes for both.
case ICorePlayableCollectionGroup playableCollection:
returnData = (IMergedMutable)new MergedPlayableCollectionGroup(playableCollection.IntoList(), config);
collection.Add(returnData);
break;
case ICoreAlbumCollection albumCollection:
returnData = (IMergedMutable)new MergedAlbumCollection(albumCollection.IntoList(), config);
collection.Add(returnData);
break;
case ICoreArtistCollection artistCollection:
returnData = (IMergedMutable)new MergedArtistCollection(artistCollection.IntoList(), config);
collection.Add(returnData);
break;
case ICorePlaylistCollection playlistCollection:
returnData = (IMergedMutable)new MergedPlaylistCollection(playlistCollection.IntoList(), config);
collection.Add(returnData);
break;
case ICoreTrackCollection trackCollection:
returnData = (IMergedMutable)new MergedTrackCollection(trackCollection.IntoList(), config);
collection.Add(returnData);
break;
default:
// Replace throw with this when verified that this is fully finished.
// ThrowHelper.ThrowNotSupportedException>("Couldn't create merged item. Type not supported.");
throw new NotImplementedException();
}
return returnData;
}
private async IAsyncEnumerable GetItemsByRank(int limit, int offset, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
Guard.IsGreaterThan(_config.CoreRanking.Count, 0, nameof(_config.CoreRanking.Count));
Guard.IsGreaterThan(limit, 0, nameof(limit));
var mappedData = BuildSortedMapRanked(_sortedMap.Count);
_sortedMap.AddRange(mappedData);
// If the offset exceeds the number of items we have, return nothing.
if (offset >= _sortedMap.Count)
yield break;
// 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 > _sortedMap.Count)
limit = _sortedMap.Count - offset;
// Get all requested items using the sorted map
for (var i = 0; i < limit; i++)
{
var mappedIndex = offset + i;
var currentSource = _sortedMap[mappedIndex];
var itemsCountForSource = currentSource.SourceCollection.GetItemsCount();
var itemLimitForSource = limit;
// Get the max items from each source once per collection.
// If the currentSource and the previous source are the same, skip this iteration.
// Checking if mappedIndex > offset ensures that the request is made at the first mapped item for this source.
if (mappedIndex > offset && currentSource.SourceCollection.SourceCore == _sortedMap[mappedIndex - 1].SourceCollection.SourceCore)
continue;
// do we end up outside the range if we try getting all items from this source?
if (currentSource.OriginalIndex + limit > itemsCountForSource)
{
// If so, reduce limit so it only gets the remaining items in this collection.
itemLimitForSource = itemsCountForSource - currentSource.OriginalIndex;
}
var remainingItemsForSource = await OwlCore.APIs.GetAllItemsAsync(
itemLimitForSource, // Try to get as many items as possible for each page.
currentSource.OriginalIndex,
async currentOffset => await currentSource.SourceCollection.GetItems(itemLimitForSource, currentOffset).ToListAsync(cancellationToken).AsTask());
Guard.IsNotNull(remainingItemsForSource, nameof(remainingItemsForSource));
// Blindly getting as many items as possible will probably cause it to get more than the limit
if (remainingItemsForSource.Count > itemLimitForSource)
{
remainingItemsForSource = remainingItemsForSource.Take(itemLimitForSource).ToList();
}
// For each item that we just retrieved, find the index in the sorted maps and assign the item.
for (var o = 0; o < remainingItemsForSource.Count; o++)
{
var item = remainingItemsForSource[o];
Guard.IsNotNull(item, nameof(item));
_sortedMap[mappedIndex + o].CollectionItem = item;
}
}
lock (_sortedMap)
{
// Initial item count == the item count for all sources combined
// Interacting with _sortedMap is treating as though all items are included but nothing is merged.
var allItemsWithData = MergeMappedData(_sortedMap.Skip(offset).Take(limit).ToArray());
// TODO Re-do of merged collection item handling.
// Since we don't get all items from the API, we don't know which are merged until we get the data, causing the count to be off.
// This problem may require a fundamental re-think of how we handle collection items,
// likely getting and processing the entire collection before emitting any items count
// or something simpler but smarter, like sparsely processing and adjusting the count as you get items.
// Until then, supply the maximum possible count (as if no items are merged).
ItemsCountChanged?.Invoke(this, _sortedMap.Count);
foreach (var item in allItemsWithData.Select(x => (TCollectionItem)x))
yield return item;
}
}
private List> MergeMappedData(IList sortedData)
{
var returnedData = new List>();
var mergedItemMaps = new Dictionary, List>();
foreach (var item in sortedData)
{
if (item.CollectionItem is null)
continue;
var mergedInto = MergeOrAdd(returnedData, item.CollectionItem, _config);
bool exists = mergedItemMaps.TryGetValue(mergedInto, out List mergedMapItems);
mergedMapItems ??= new List();
mergedMapItems.Add(item);
if (!exists)
mergedItemMaps.Add(mergedInto, mergedMapItems);
}
foreach (var item in mergedItemMaps)
_mergedMappedData.Add(new MergedMappedData(item.Key, item.Value));
return returnedData;
}
private List BuildSortedMap() => _config.MergedCollectionSorting switch
{
MergedCollectionSorting.Ranked => BuildSortedMapRanked(),
_ => throw new NotSupportedException($"Merged collection sorting by \"{_config.MergedCollectionSorting}\" not supported.")
};
private List BuildSortedMapRanked(int offset = 0)
{
// Rank the sources by core
var rankedSources = new List();
foreach (var instanceId in _config.CoreRanking)
{
// Find source by instance id.
var source = Sources.FirstOrDefault(x => x.SourceCore.InstanceId == instanceId);
// A core that is in the core ranking might not be part of the sources for this object
if (source is null)
continue;
rankedSources.Add(source);
}
// Create the map for each possible item returned from a source collection.
var itemsMap = new List();
foreach (var source in rankedSources)
{
var itemsCount = source.GetItemsCount();
for (var i = offset; i < itemsCount; i++)
{
itemsMap.Add(new MappedData(i, source));
}
}
return itemsMap;
}
private Task GetSortingMethod()
{
return Task.FromResult(MergedCollectionSorting.Ranked);
//return _settingsService.GetValue(nameof(SettingsKeys.MergedCollectionSorting));
}
private void ConfigOnMergedCollectionSortingChanged(object sender, MergedCollectionSorting e)
{
}
private void ConfigOnCoreRankingChanged(object sender, IReadOnlyList e)
{
Guard.IsGreaterThan(e.Count, 0);
}
private async Task ResetDataRanked()
{
await TryInitAsync(CancellationToken.None);
// TODO: Optimize this (these instruction for ranked sorting only)
// Find where this source lies in the ranking
// If the items have already been requested and another source returned them
// Get all the items from ONLY the new source
// "insert" these and every item that shifted from the insert
// By firing the event with removed, then again with added
var previouslySortedItems = _sortedMap.ToList();
var previousMergedMap = _mergedMappedData.ToList();
_sortedMap.Clear();
for (int i = 0; i < previouslySortedItems.Count; i++)
{
var item = previouslySortedItems[i];
// If the currentSource and the previous source are the same, skip this iteration
// because we get and re-emit the range of items for this source.
if (i > 0 && item.SourceCollection.SourceCore == _sortedMap[i - 1].SourceCollection.SourceCore)
continue;
// The items retrieved will exist in the sorted map.
_ = await GetItemsAsync(item.OriginalIndex, i, CancellationToken.None).ToListAsync();
}
var addedItems = new List>();
// For each item that we just retrieved, find the index in the sorted map and assign the item.
for (var i = 0; i < _mergedMappedData.Count; i++)
{
var addedItem = _mergedMappedData[i];
Guard.IsNotNull(addedItem.CollectionItem, nameof(addedItem.CollectionItem));
var x = new CollectionChangedItem((TCollectionItem)addedItem.CollectionItem, i);
addedItems.Add(x);
}
// logic for removed was copy-pasted and tweaked from the added logic. Not checked or tested.
var removedItems = new List>();
for (var i = 0; i < previousMergedMap.Count; i++)
{
var removedItem = previousMergedMap[i];
Guard.IsNotNull(removedItem.CollectionItem, nameof(removedItem.CollectionItem));
var x = new CollectionChangedItem((TCollectionItem)removedItem.CollectionItem, i);
removedItems.Add(x);
}
if (addedItems.Count > 0 || removedItems.Count > 0)
{
ItemsChanged?.Invoke(this, addedItems, removedItems);
ItemsCountChanged?.Invoke(this, _mergedMappedData.Count);
}
}
///
///
/// Handles the internal merged map when a source is added (when one collection is merged into another).
///
/// When a new source is added, that source could be anywhere in a ranked map, or the data could be scattered or mingled arbitrarily.
/// To keep the collection sorted by the user's preferred method
/// We re-get all the data, which includes rebuilding the collection map
/// Then re-emit ALL data
///
///
public void AddSource(TCoreCollection itemToMerge)
{
// TODO: AddSource and RemoveSource needs to be async.
OwlCore.Flow.Catch(() => ResetDataRanked());
}
///
public void RemoveSource(TCoreCollection itemToRemove)
{
OwlCore.Flow.Catch(() => ResetDataRanked());
}
///
///
/// and have no overlap and never equal each other, this method always returns false.
///
public bool Equals(TCoreCollection other) => false;
private class MappedData
{
public MappedData(int originalIndex, TCoreCollection sourceCollection, TCoreCollectionItem? collectionItem = null)
{
OriginalIndex = originalIndex;
SourceCollection = sourceCollection;
CollectionItem = collectionItem;
}
public int OriginalIndex { get; }
public TCoreCollection SourceCollection { get; }
public TCoreCollectionItem? CollectionItem { get; set; }
}
private class MergedMappedData
{
public MergedMappedData(IMergedMutable collectionItem, IEnumerable mergedMapData)
{
CollectionItem = collectionItem;
MergedMapData = mergedMapData.ToList();
}
public IMergedMutable CollectionItem { get; }
public List MergedMapData { get; }
}
///
public async ValueTask DisposeAsync()
{
if (_isDisposed)
return;
using (await OwlCore.Flow.EasySemaphore(_disposeSemaphore))
{
if (_isDisposed)
return;
DetachEvents();
_mergedMappedData.Clear();
_sortedMap.Clear();
_isDisposed = true;
return;
}
}
}
}