osu/osu.Game/Collections/CollectionManager.cs

312 lines
11 KiB
C#
Raw Normal View History

2020-09-01 08:28:41 +00:00
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
2020-09-08 08:59:38 +00:00
using System.Collections.Specialized;
2020-09-01 08:28:41 +00:00
using System.IO;
2020-09-02 15:08:33 +00:00
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
2020-09-02 15:08:33 +00:00
using osu.Framework;
2020-09-01 10:33:06 +00:00
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Logging;
2020-09-01 08:28:41 +00:00
using osu.Framework.Platform;
using osu.Game.Beatmaps;
using osu.Game.IO.Legacy;
2020-09-08 08:59:38 +00:00
using osu.Game.Overlays.Notifications;
2020-09-01 08:28:41 +00:00
namespace osu.Game.Collections
{
/// <summary>
/// Handles user-defined collections of beatmaps.
/// </summary>
/// <remarks>
/// This is currently reading and writing from the osu-stable file format. This is a temporary arrangement until we refactor the
/// database backing the game. Going forward writing should be done in a similar way to other model stores.
/// </remarks>
public class CollectionManager : Component
2020-09-01 08:28:41 +00:00
{
/// <summary>
2020-09-07 12:08:48 +00:00
/// Database version in stable-compatible YYYYMMDD format.
/// </summary>
private const int database_version = 30000000;
2020-09-01 10:33:06 +00:00
private const string database_name = "collection.db";
2020-09-01 08:28:41 +00:00
2020-09-02 15:08:33 +00:00
public readonly BindableList<BeatmapCollection> Collections = new BindableList<BeatmapCollection>();
public bool SupportsImportFromStable => RuntimeInfo.IsDesktop;
[Resolved]
private GameHost host { get; set; }
2020-09-01 10:33:06 +00:00
[Resolved]
private BeatmapManager beatmaps { get; set; }
2020-09-07 13:47:19 +00:00
private readonly Storage storage;
public CollectionManager(Storage storage)
2020-09-07 13:47:19 +00:00
{
this.storage = storage;
}
2020-09-01 10:33:06 +00:00
[BackgroundDependencyLoader]
private void load()
2020-09-08 08:59:38 +00:00
{
Collections.CollectionChanged += collectionsChanged;
if (storage.Exists(database_name))
{
using (var stream = storage.GetStream(database_name))
importCollections(readCollections(stream));
}
2020-09-08 08:59:38 +00:00
}
private void collectionsChanged(object sender, NotifyCollectionChangedEventArgs e)
{
switch (e.Action)
{
case NotifyCollectionChangedAction.Add:
foreach (var c in e.NewItems.Cast<BeatmapCollection>())
c.Changed += backgroundSave;
break;
case NotifyCollectionChangedAction.Remove:
foreach (var c in e.OldItems.Cast<BeatmapCollection>())
c.Changed -= backgroundSave;
break;
case NotifyCollectionChangedAction.Replace:
foreach (var c in e.OldItems.Cast<BeatmapCollection>())
c.Changed -= backgroundSave;
foreach (var c in e.NewItems.Cast<BeatmapCollection>())
c.Changed += backgroundSave;
break;
}
backgroundSave();
}
/// <summary>
/// Set an endpoint for notifications to be posted to.
/// </summary>
public Action<Notification> PostNotification { protected get; set; }
2020-09-01 08:28:41 +00:00
/// <summary>
/// Set a storage with access to an osu-stable install for import purposes.
/// </summary>
public Func<Storage> GetStableStorage { private get; set; }
/// <summary>
/// This is a temporary method and will likely be replaced by a full-fledged (and more correctly placed) migration process in the future.
/// </summary>
2020-09-02 15:08:33 +00:00
public Task ImportFromStableAsync()
{
var stable = GetStableStorage?.Invoke();
if (stable == null)
{
Logger.Log("No osu!stable installation available!", LoggingTarget.Information, LogLevel.Error);
return Task.CompletedTask;
}
if (!stable.Exists(database_name))
{
// This handles situations like when the user does not have a collections.db file
Logger.Log($"No {database_name} available in osu!stable installation", LoggingTarget.Information, LogLevel.Error);
return Task.CompletedTask;
}
2020-09-07 13:10:12 +00:00
return Task.Run(async () =>
2020-09-02 15:08:33 +00:00
{
2020-09-08 10:14:48 +00:00
using (var stream = stable.GetStream(database_name))
await Import(stream).ConfigureAwait(false);
2020-09-02 15:08:33 +00:00
});
}
public async Task Import(Stream stream)
{
var notification = new ProgressNotification
{
State = ProgressNotificationState.Active,
Text = "Collections import is initialising..."
};
PostNotification?.Invoke(notification);
var collections = readCollections(stream, notification);
await importCollections(collections).ConfigureAwait(false);
notification.CompletionText = $"Imported {collections.Count} collections";
notification.State = ProgressNotificationState.Completed;
}
2020-09-07 13:10:12 +00:00
private Task importCollections(List<BeatmapCollection> newCollections)
2020-09-02 15:08:33 +00:00
{
var tcs = new TaskCompletionSource<bool>();
2020-09-02 15:08:33 +00:00
Schedule(() =>
{
try
2020-09-02 15:08:33 +00:00
{
foreach (var newCol in newCollections)
{
var existing = Collections.FirstOrDefault(c => c.Name.Value == newCol.Name.Value);
if (existing == null)
Collections.Add(existing = new BeatmapCollection { Name = { Value = newCol.Name.Value } });
foreach (var newBeatmap in newCol.Beatmaps)
{
if (!existing.Beatmaps.Contains(newBeatmap))
existing.Beatmaps.Add(newBeatmap);
}
}
tcs.SetResult(true);
2020-09-02 15:08:33 +00:00
}
catch (Exception e)
{
Logger.Error(e, "Failed to import collection.");
tcs.SetException(e);
}
});
return tcs.Task;
2020-09-02 15:08:33 +00:00
}
private List<BeatmapCollection> readCollections(Stream stream, ProgressNotification notification = null)
2020-09-01 08:28:41 +00:00
{
if (notification != null)
{
notification.Text = "Reading collections...";
notification.Progress = 0;
}
2020-09-01 08:28:41 +00:00
var result = new List<BeatmapCollection>();
try
{
using (var sr = new SerializationReader(stream))
{
sr.ReadInt32(); // Version
int collectionCount = sr.ReadInt32();
result.Capacity = collectionCount;
for (int i = 0; i < collectionCount; i++)
{
if (notification?.CancellationToken.IsCancellationRequested == true)
return result;
2020-09-04 19:43:51 +00:00
var collection = new BeatmapCollection { Name = { Value = sr.ReadString() } };
int mapCount = sr.ReadInt32();
for (int j = 0; j < mapCount; j++)
{
if (notification?.CancellationToken.IsCancellationRequested == true)
return result;
string checksum = sr.ReadString();
var beatmap = beatmaps.QueryBeatmap(b => b.MD5Hash == checksum);
if (beatmap != null)
collection.Beatmaps.Add(beatmap);
}
if (notification != null)
{
notification.Text = $"Imported {i + 1} of {collectionCount} collections";
notification.Progress = (float)(i + 1) / collectionCount;
}
result.Add(collection);
}
}
}
catch (Exception e)
2020-09-01 08:28:41 +00:00
{
2020-09-02 14:47:42 +00:00
Logger.Error(e, "Failed to read collection database.");
}
2020-09-01 08:28:41 +00:00
return result;
}
2020-09-01 08:28:41 +00:00
public void DeleteAll()
{
Collections.Clear();
2020-09-19 19:55:52 +00:00
PostNotification?.Invoke(new ProgressCompletionNotification { Text = "Deleted all collections!" });
}
private readonly object saveLock = new object();
private int lastSave;
private int saveFailures;
/// <summary>
/// Perform a save with debounce.
/// </summary>
private void backgroundSave()
{
var current = Interlocked.Increment(ref lastSave);
Task.Delay(100).ContinueWith(task =>
{
if (current != lastSave)
return;
if (!save())
backgroundSave();
});
}
private bool save()
{
lock (saveLock)
{
Interlocked.Increment(ref lastSave);
try
2020-09-01 08:28:41 +00:00
{
// This is NOT thread-safe!!
2020-09-01 08:28:41 +00:00
2020-09-07 13:47:19 +00:00
using (var sw = new SerializationWriter(storage.GetStream(database_name, FileAccess.Write)))
2020-09-01 08:28:41 +00:00
{
sw.Write(database_version);
2020-09-02 15:08:33 +00:00
sw.Write(Collections.Count);
2020-09-01 08:28:41 +00:00
2020-09-02 15:08:33 +00:00
foreach (var c in Collections)
{
2020-09-04 19:43:51 +00:00
sw.Write(c.Name.Value);
sw.Write(c.Beatmaps.Count);
foreach (var b in c.Beatmaps)
sw.Write(b.MD5Hash);
}
2020-09-01 08:28:41 +00:00
}
2020-09-01 10:33:06 +00:00
2020-09-02 14:42:44 +00:00
if (saveFailures < 10)
saveFailures = 0;
return true;
}
catch (Exception e)
{
// Since this code is not thread-safe, we may run into random exceptions (such as collection enumeration or out of range indexing).
2020-09-02 14:42:44 +00:00
// Failures are thus only alerted if they exceed a threshold (once) to indicate "actual" errors having occurred.
if (++saveFailures == 10)
2020-09-02 14:47:42 +00:00
Logger.Error(e, "Failed to save collection database!");
2020-09-01 08:28:41 +00:00
}
return false;
}
2020-09-01 08:28:41 +00:00
}
2020-09-02 14:32:08 +00:00
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
save();
}
2020-09-01 08:28:41 +00:00
}
}