mirror of
https://github.com/ppy/osu
synced 2025-02-18 11:26:57 +00:00
Merge branch 'master' into realm-context-factory-safer-blocking
This commit is contained in:
commit
682fe5be78
@ -158,18 +158,47 @@ namespace osu.Game.Tests.Online
|
||||
|
||||
public Task<BeatmapSetInfo> CurrentImportTask { get; private set; }
|
||||
|
||||
protected override ArchiveDownloadRequest<BeatmapSetInfo> CreateDownloadRequest(BeatmapSetInfo set, bool minimiseDownloadSize)
|
||||
=> new TestDownloadRequest(set);
|
||||
|
||||
public TestBeatmapManager(Storage storage, IDatabaseContextFactory contextFactory, RulesetStore rulesets, IAPIProvider api, [NotNull] AudioManager audioManager, IResourceStore<byte[]> resources, GameHost host = null, WorkingBeatmap defaultBeatmap = null, bool performOnlineLookups = false)
|
||||
: base(storage, contextFactory, rulesets, api, audioManager, resources, host, defaultBeatmap, performOnlineLookups)
|
||||
public TestBeatmapManager(Storage storage, IDatabaseContextFactory contextFactory, RulesetStore rulesets, IAPIProvider api, [NotNull] AudioManager audioManager, IResourceStore<byte[]> resources, GameHost host = null, WorkingBeatmap defaultBeatmap = null)
|
||||
: base(storage, contextFactory, rulesets, api, audioManager, resources, host, defaultBeatmap)
|
||||
{
|
||||
}
|
||||
|
||||
public override async Task<BeatmapSetInfo> Import(BeatmapSetInfo item, ArchiveReader archive = null, bool lowPriority = false, CancellationToken cancellationToken = default)
|
||||
protected override BeatmapModelManager CreateBeatmapModelManager(Storage storage, IDatabaseContextFactory contextFactory, RulesetStore rulesets, IAPIProvider api, GameHost host)
|
||||
{
|
||||
await AllowImport.Task.ConfigureAwait(false);
|
||||
return await (CurrentImportTask = base.Import(item, archive, lowPriority, cancellationToken)).ConfigureAwait(false);
|
||||
return new TestBeatmapModelManager(this, storage, contextFactory, rulesets, api, host);
|
||||
}
|
||||
|
||||
protected override BeatmapModelDownloader CreateBeatmapModelDownloader(BeatmapModelManager modelManager, IAPIProvider api, GameHost host)
|
||||
{
|
||||
return new TestBeatmapModelDownloader(modelManager, api, host);
|
||||
}
|
||||
|
||||
internal class TestBeatmapModelDownloader : BeatmapModelDownloader
|
||||
{
|
||||
public TestBeatmapModelDownloader(BeatmapModelManager modelManager, IAPIProvider apiProvider, GameHost gameHost)
|
||||
: base(modelManager, apiProvider, gameHost)
|
||||
{
|
||||
}
|
||||
|
||||
protected override ArchiveDownloadRequest<BeatmapSetInfo> CreateDownloadRequest(BeatmapSetInfo set, bool minimiseDownloadSize)
|
||||
=> new TestDownloadRequest(set);
|
||||
}
|
||||
|
||||
internal class TestBeatmapModelManager : BeatmapModelManager
|
||||
{
|
||||
private readonly TestBeatmapManager testBeatmapManager;
|
||||
|
||||
public TestBeatmapModelManager(TestBeatmapManager testBeatmapManager, Storage storage, IDatabaseContextFactory databaseContextFactory, RulesetStore rulesetStore, IAPIProvider apiProvider, GameHost gameHost)
|
||||
: base(storage, databaseContextFactory, rulesetStore, gameHost)
|
||||
{
|
||||
this.testBeatmapManager = testBeatmapManager;
|
||||
}
|
||||
|
||||
public override async Task<BeatmapSetInfo> Import(BeatmapSetInfo item, ArchiveReader archive = null, bool lowPriority = false, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await testBeatmapManager.AllowImport.Task.ConfigureAwait(false);
|
||||
return await (testBeatmapManager.CurrentImportTask = base.Import(item, archive, lowPriority, cancellationToken)).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -6,111 +6,67 @@ using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Linq.Expressions;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using JetBrains.Annotations;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using osu.Framework.Audio;
|
||||
using osu.Framework.Audio.Track;
|
||||
using osu.Framework.Bindables;
|
||||
using osu.Framework.Extensions;
|
||||
using osu.Framework.Graphics.Textures;
|
||||
using osu.Framework.IO.Stores;
|
||||
using osu.Framework.Lists;
|
||||
using osu.Framework.Logging;
|
||||
using osu.Framework.Platform;
|
||||
using osu.Framework.Statistics;
|
||||
using osu.Framework.Testing;
|
||||
using osu.Game.Beatmaps.Formats;
|
||||
using osu.Game.Database;
|
||||
using osu.Game.IO;
|
||||
using osu.Game.IO.Archives;
|
||||
using osu.Game.Online.API;
|
||||
using osu.Game.Online.API.Requests;
|
||||
using osu.Game.Overlays.Notifications;
|
||||
using osu.Game.Rulesets;
|
||||
using osu.Game.Rulesets.Objects;
|
||||
using osu.Game.Skinning;
|
||||
using osu.Game.Users;
|
||||
using Decoder = osu.Game.Beatmaps.Formats.Decoder;
|
||||
|
||||
namespace osu.Game.Beatmaps
|
||||
{
|
||||
/// <summary>
|
||||
/// Handles the storage and retrieval of Beatmaps/WorkingBeatmaps.
|
||||
/// Handles general operations related to global beatmap management.
|
||||
/// </summary>
|
||||
[ExcludeFromDynamicCompile]
|
||||
public partial class BeatmapManager : DownloadableArchiveModelManager<BeatmapSetInfo, BeatmapSetFileInfo>, IDisposable, IBeatmapResourceProvider
|
||||
public class BeatmapManager : IModelDownloader<BeatmapSetInfo>, IModelManager<BeatmapSetInfo>, IModelFileManager<BeatmapSetInfo, BeatmapSetFileInfo>, ICanAcceptFiles, IWorkingBeatmapCache, IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Fired when a single difficulty has been hidden.
|
||||
/// </summary>
|
||||
public IBindable<WeakReference<BeatmapInfo>> BeatmapHidden => beatmapHidden;
|
||||
private readonly BeatmapModelManager beatmapModelManager;
|
||||
private readonly BeatmapModelDownloader beatmapModelDownloader;
|
||||
|
||||
private readonly Bindable<WeakReference<BeatmapInfo>> beatmapHidden = new Bindable<WeakReference<BeatmapInfo>>();
|
||||
|
||||
/// <summary>
|
||||
/// Fired when a single difficulty has been restored.
|
||||
/// </summary>
|
||||
public IBindable<WeakReference<BeatmapInfo>> BeatmapRestored => beatmapRestored;
|
||||
|
||||
private readonly Bindable<WeakReference<BeatmapInfo>> beatmapRestored = new Bindable<WeakReference<BeatmapInfo>>();
|
||||
|
||||
/// <summary>
|
||||
/// A default representation of a WorkingBeatmap to use when no beatmap is available.
|
||||
/// </summary>
|
||||
public readonly WorkingBeatmap DefaultBeatmap;
|
||||
|
||||
public override IEnumerable<string> HandledExtensions => new[] { ".osz" };
|
||||
|
||||
protected override string[] HashableFileTypes => new[] { ".osu" };
|
||||
|
||||
protected override string ImportFromStablePath => ".";
|
||||
|
||||
protected override Storage PrepareStableStorage(StableStorage stableStorage) => stableStorage.GetSongStorage();
|
||||
|
||||
private readonly RulesetStore rulesets;
|
||||
private readonly BeatmapStore beatmaps;
|
||||
private readonly AudioManager audioManager;
|
||||
private readonly IResourceStore<byte[]> resources;
|
||||
private readonly LargeTextureStore largeTextureStore;
|
||||
private readonly ITrackStore trackStore;
|
||||
|
||||
[CanBeNull]
|
||||
private readonly GameHost host;
|
||||
|
||||
[CanBeNull]
|
||||
private readonly BeatmapOnlineLookupQueue onlineLookupQueue;
|
||||
private readonly WorkingBeatmapCache workingBeatmapCache;
|
||||
private readonly BeatmapOnlineLookupQueue onlineBetamapLookupQueue;
|
||||
|
||||
public BeatmapManager(Storage storage, IDatabaseContextFactory contextFactory, RulesetStore rulesets, IAPIProvider api, [NotNull] AudioManager audioManager, IResourceStore<byte[]> resources, GameHost host = null,
|
||||
WorkingBeatmap defaultBeatmap = null, bool performOnlineLookups = false)
|
||||
: base(storage, contextFactory, api, new BeatmapStore(contextFactory), host)
|
||||
{
|
||||
this.rulesets = rulesets;
|
||||
this.audioManager = audioManager;
|
||||
this.resources = resources;
|
||||
this.host = host;
|
||||
beatmapModelManager = CreateBeatmapModelManager(storage, contextFactory, rulesets, api, host);
|
||||
beatmapModelDownloader = CreateBeatmapModelDownloader(beatmapModelManager, api, host);
|
||||
workingBeatmapCache = CreateWorkingBeatmapCache(audioManager, resources, new FileStore(contextFactory, storage).Store, defaultBeatmap, host);
|
||||
|
||||
DefaultBeatmap = defaultBeatmap;
|
||||
|
||||
beatmaps = (BeatmapStore)ModelStore;
|
||||
beatmaps.BeatmapHidden += b => beatmapHidden.Value = new WeakReference<BeatmapInfo>(b);
|
||||
beatmaps.BeatmapRestored += b => beatmapRestored.Value = new WeakReference<BeatmapInfo>(b);
|
||||
beatmaps.ItemRemoved += removeWorkingCache;
|
||||
beatmaps.ItemUpdated += removeWorkingCache;
|
||||
workingBeatmapCache.BeatmapManager = beatmapModelManager;
|
||||
|
||||
if (performOnlineLookups)
|
||||
onlineLookupQueue = new BeatmapOnlineLookupQueue(api, storage);
|
||||
|
||||
largeTextureStore = new LargeTextureStore(host?.CreateTextureLoaderStore(Files.Store));
|
||||
trackStore = audioManager.GetTrackStore(Files.Store);
|
||||
{
|
||||
onlineBetamapLookupQueue = new BeatmapOnlineLookupQueue(api, storage);
|
||||
beatmapModelManager.OnlineLookupQueue = onlineBetamapLookupQueue;
|
||||
}
|
||||
}
|
||||
|
||||
protected override ArchiveDownloadRequest<BeatmapSetInfo> CreateDownloadRequest(BeatmapSetInfo set, bool minimiseDownloadSize) =>
|
||||
new DownloadBeatmapSetRequest(set, minimiseDownloadSize);
|
||||
protected virtual BeatmapModelDownloader CreateBeatmapModelDownloader(BeatmapModelManager modelManager, IAPIProvider api, GameHost host)
|
||||
{
|
||||
return new BeatmapModelDownloader(modelManager, api, host);
|
||||
}
|
||||
|
||||
protected override bool ShouldDeleteArchive(string path) => Path.GetExtension(path)?.ToLowerInvariant() == ".osz";
|
||||
protected virtual WorkingBeatmapCache CreateWorkingBeatmapCache(AudioManager audioManager, IResourceStore<byte[]> resources, IResourceStore<byte[]> storage, WorkingBeatmap defaultBeatmap, GameHost host) =>
|
||||
new WorkingBeatmapCache(audioManager, resources, storage, defaultBeatmap, host);
|
||||
|
||||
protected virtual BeatmapModelManager CreateBeatmapModelManager(Storage storage, IDatabaseContextFactory contextFactory, RulesetStore rulesets, IAPIProvider api, GameHost host) =>
|
||||
new BeatmapModelManager(storage, contextFactory, rulesets, host);
|
||||
|
||||
/// <summary>
|
||||
/// Create a new <see cref="WorkingBeatmap"/>.
|
||||
/// </summary>
|
||||
public WorkingBeatmap CreateNew(RulesetInfo ruleset, User user)
|
||||
{
|
||||
var metadata = new BeatmapMetadata
|
||||
@ -134,112 +90,21 @@ namespace osu.Game.Beatmaps
|
||||
}
|
||||
};
|
||||
|
||||
var working = Import(set).Result;
|
||||
var working = beatmapModelManager.Import(set).Result;
|
||||
return GetWorkingBeatmap(working.Beatmaps.First());
|
||||
}
|
||||
|
||||
protected override async Task Populate(BeatmapSetInfo beatmapSet, ArchiveReader archive, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (archive != null)
|
||||
beatmapSet.Beatmaps = createBeatmapDifficulties(beatmapSet.Files);
|
||||
|
||||
foreach (BeatmapInfo b in beatmapSet.Beatmaps)
|
||||
{
|
||||
// remove metadata from difficulties where it matches the set
|
||||
if (beatmapSet.Metadata.Equals(b.Metadata))
|
||||
b.Metadata = null;
|
||||
|
||||
b.BeatmapSet = beatmapSet;
|
||||
}
|
||||
|
||||
validateOnlineIds(beatmapSet);
|
||||
|
||||
bool hadOnlineBeatmapIDs = beatmapSet.Beatmaps.Any(b => b.OnlineBeatmapID > 0);
|
||||
|
||||
if (onlineLookupQueue != null)
|
||||
await onlineLookupQueue.UpdateAsync(beatmapSet, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// ensure at least one beatmap was able to retrieve or keep an online ID, else drop the set ID.
|
||||
if (hadOnlineBeatmapIDs && !beatmapSet.Beatmaps.Any(b => b.OnlineBeatmapID > 0))
|
||||
{
|
||||
if (beatmapSet.OnlineBeatmapSetID != null)
|
||||
{
|
||||
beatmapSet.OnlineBeatmapSetID = null;
|
||||
LogForModel(beatmapSet, "Disassociating beatmap set ID due to loss of all beatmap IDs");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected override void PreImport(BeatmapSetInfo beatmapSet)
|
||||
{
|
||||
if (beatmapSet.Beatmaps.Any(b => b.BaseDifficulty == null))
|
||||
throw new InvalidOperationException($"Cannot import {nameof(BeatmapInfo)} with null {nameof(BeatmapInfo.BaseDifficulty)}.");
|
||||
|
||||
// check if a set already exists with the same online id, delete if it does.
|
||||
if (beatmapSet.OnlineBeatmapSetID != null)
|
||||
{
|
||||
var existingOnlineId = beatmaps.ConsumableItems.FirstOrDefault(b => b.OnlineBeatmapSetID == beatmapSet.OnlineBeatmapSetID);
|
||||
|
||||
if (existingOnlineId != null)
|
||||
{
|
||||
Delete(existingOnlineId);
|
||||
|
||||
// in order to avoid a unique key constraint, immediately remove the online ID from the previous set.
|
||||
existingOnlineId.OnlineBeatmapSetID = null;
|
||||
foreach (var b in existingOnlineId.Beatmaps)
|
||||
b.OnlineBeatmapID = null;
|
||||
|
||||
LogForModel(beatmapSet, $"Found existing beatmap set with same OnlineBeatmapSetID ({beatmapSet.OnlineBeatmapSetID}). It has been deleted.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void validateOnlineIds(BeatmapSetInfo beatmapSet)
|
||||
{
|
||||
var beatmapIds = beatmapSet.Beatmaps.Where(b => b.OnlineBeatmapID.HasValue).Select(b => b.OnlineBeatmapID).ToList();
|
||||
|
||||
// ensure all IDs are unique
|
||||
if (beatmapIds.GroupBy(b => b).Any(g => g.Count() > 1))
|
||||
{
|
||||
LogForModel(beatmapSet, "Found non-unique IDs, resetting...");
|
||||
resetIds();
|
||||
return;
|
||||
}
|
||||
|
||||
// find any existing beatmaps in the database that have matching online ids
|
||||
var existingBeatmaps = QueryBeatmaps(b => beatmapIds.Contains(b.OnlineBeatmapID)).ToList();
|
||||
|
||||
if (existingBeatmaps.Count > 0)
|
||||
{
|
||||
// reset the import ids (to force a re-fetch) *unless* they match the candidate CheckForExisting set.
|
||||
// we can ignore the case where the new ids are contained by the CheckForExisting set as it will either be used (import skipped) or deleted.
|
||||
var existing = CheckForExisting(beatmapSet);
|
||||
|
||||
if (existing == null || existingBeatmaps.Any(b => !existing.Beatmaps.Contains(b)))
|
||||
{
|
||||
LogForModel(beatmapSet, "Found existing import with IDs already, resetting...");
|
||||
resetIds();
|
||||
}
|
||||
}
|
||||
|
||||
void resetIds() => beatmapSet.Beatmaps.ForEach(b => b.OnlineBeatmapID = null);
|
||||
}
|
||||
|
||||
protected override bool CheckLocalAvailability(BeatmapSetInfo model, IQueryable<BeatmapSetInfo> items)
|
||||
=> base.CheckLocalAvailability(model, items)
|
||||
|| (model.OnlineBeatmapSetID != null && items.Any(b => b.OnlineBeatmapSetID == model.OnlineBeatmapSetID));
|
||||
#region Delegation to BeatmapModelManager (methods which previously existed locally).
|
||||
|
||||
/// <summary>
|
||||
/// Delete a beatmap difficulty.
|
||||
/// Fired when a single difficulty has been hidden.
|
||||
/// </summary>
|
||||
/// <param name="beatmap">The beatmap difficulty to hide.</param>
|
||||
public void Hide(BeatmapInfo beatmap) => beatmaps.Hide(beatmap);
|
||||
public IBindable<WeakReference<BeatmapInfo>> BeatmapHidden => beatmapModelManager.BeatmapHidden;
|
||||
|
||||
/// <summary>
|
||||
/// Restore a beatmap difficulty.
|
||||
/// Fired when a single difficulty has been restored.
|
||||
/// </summary>
|
||||
/// <param name="beatmap">The beatmap difficulty to restore.</param>
|
||||
public void Restore(BeatmapInfo beatmap) => beatmaps.Restore(beatmap);
|
||||
public IBindable<WeakReference<BeatmapInfo>> BeatmapRestored => beatmapModelManager.BeatmapRestored;
|
||||
|
||||
/// <summary>
|
||||
/// Saves an <see cref="IBeatmap"/> file against a given <see cref="BeatmapInfo"/>.
|
||||
@ -247,109 +112,13 @@ namespace osu.Game.Beatmaps
|
||||
/// <param name="info">The <see cref="BeatmapInfo"/> to save the content against. The file referenced by <see cref="BeatmapInfo.Path"/> will be replaced.</param>
|
||||
/// <param name="beatmapContent">The <see cref="IBeatmap"/> content to write.</param>
|
||||
/// <param name="beatmapSkin">The beatmap <see cref="ISkin"/> content to write, null if to be omitted.</param>
|
||||
public virtual void Save(BeatmapInfo info, IBeatmap beatmapContent, ISkin beatmapSkin = null)
|
||||
{
|
||||
var setInfo = info.BeatmapSet;
|
||||
|
||||
using (var stream = new MemoryStream())
|
||||
{
|
||||
using (var sw = new StreamWriter(stream, Encoding.UTF8, 1024, true))
|
||||
new LegacyBeatmapEncoder(beatmapContent, beatmapSkin).Encode(sw);
|
||||
|
||||
stream.Seek(0, SeekOrigin.Begin);
|
||||
|
||||
using (ContextFactory.GetForWrite())
|
||||
{
|
||||
var beatmapInfo = setInfo.Beatmaps.Single(b => b.ID == info.ID);
|
||||
var metadata = beatmapInfo.Metadata ?? setInfo.Metadata;
|
||||
|
||||
// grab the original file (or create a new one if not found).
|
||||
var fileInfo = setInfo.Files.SingleOrDefault(f => string.Equals(f.Filename, beatmapInfo.Path, StringComparison.OrdinalIgnoreCase)) ?? new BeatmapSetFileInfo();
|
||||
|
||||
// metadata may have changed; update the path with the standard format.
|
||||
beatmapInfo.Path = $"{metadata.Artist} - {metadata.Title} ({metadata.Author}) [{beatmapInfo.Version}].osu";
|
||||
beatmapInfo.MD5Hash = stream.ComputeMD5Hash();
|
||||
|
||||
// update existing or populate new file's filename.
|
||||
fileInfo.Filename = beatmapInfo.Path;
|
||||
|
||||
stream.Seek(0, SeekOrigin.Begin);
|
||||
ReplaceFile(setInfo, fileInfo, stream);
|
||||
}
|
||||
}
|
||||
|
||||
removeWorkingCache(info);
|
||||
}
|
||||
|
||||
private readonly WeakList<BeatmapManagerWorkingBeatmap> workingCache = new WeakList<BeatmapManagerWorkingBeatmap>();
|
||||
|
||||
/// <summary>
|
||||
/// Retrieve a <see cref="WorkingBeatmap"/> instance for the provided <see cref="BeatmapInfo"/>
|
||||
/// </summary>
|
||||
/// <param name="beatmapInfo">The beatmap to lookup.</param>
|
||||
/// <returns>A <see cref="WorkingBeatmap"/> instance correlating to the provided <see cref="BeatmapInfo"/>.</returns>
|
||||
public virtual WorkingBeatmap GetWorkingBeatmap(BeatmapInfo beatmapInfo)
|
||||
{
|
||||
// if there are no files, presume the full beatmap info has not yet been fetched from the database.
|
||||
if (beatmapInfo?.BeatmapSet?.Files.Count == 0)
|
||||
{
|
||||
int lookupId = beatmapInfo.ID;
|
||||
beatmapInfo = QueryBeatmap(b => b.ID == lookupId);
|
||||
}
|
||||
|
||||
if (beatmapInfo?.BeatmapSet == null)
|
||||
return DefaultBeatmap;
|
||||
|
||||
lock (workingCache)
|
||||
{
|
||||
var working = workingCache.FirstOrDefault(w => w.BeatmapInfo?.ID == beatmapInfo.ID);
|
||||
if (working != null)
|
||||
return working;
|
||||
|
||||
beatmapInfo.Metadata ??= beatmapInfo.BeatmapSet.Metadata;
|
||||
|
||||
workingCache.Add(working = new BeatmapManagerWorkingBeatmap(beatmapInfo, this));
|
||||
|
||||
// best effort; may be higher than expected.
|
||||
GlobalStatistics.Get<int>(nameof(Beatmaps), $"Cached {nameof(WorkingBeatmap)}s").Value = workingCache.Count();
|
||||
|
||||
return working;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Perform a lookup query on available <see cref="BeatmapSetInfo"/>s.
|
||||
/// </summary>
|
||||
/// <param name="query">The query.</param>
|
||||
/// <returns>The first result for the provided query, or null if no results were found.</returns>
|
||||
public BeatmapSetInfo QueryBeatmapSet(Expression<Func<BeatmapSetInfo, bool>> query) => beatmaps.ConsumableItems.AsNoTracking().FirstOrDefault(query);
|
||||
|
||||
protected override bool CanSkipImport(BeatmapSetInfo existing, BeatmapSetInfo import)
|
||||
{
|
||||
if (!base.CanSkipImport(existing, import))
|
||||
return false;
|
||||
|
||||
return existing.Beatmaps.Any(b => b.OnlineBeatmapID != null);
|
||||
}
|
||||
|
||||
protected override bool CanReuseExisting(BeatmapSetInfo existing, BeatmapSetInfo import)
|
||||
{
|
||||
if (!base.CanReuseExisting(existing, import))
|
||||
return false;
|
||||
|
||||
var existingIds = existing.Beatmaps.Select(b => b.OnlineBeatmapID).OrderBy(i => i);
|
||||
var importIds = import.Beatmaps.Select(b => b.OnlineBeatmapID).OrderBy(i => i);
|
||||
|
||||
// force re-import if we are not in a sane state.
|
||||
return existing.OnlineBeatmapSetID == import.OnlineBeatmapSetID && existingIds.SequenceEqual(importIds);
|
||||
}
|
||||
public virtual void Save(BeatmapInfo info, IBeatmap beatmapContent, ISkin beatmapSkin = null) => beatmapModelManager.Save(info, beatmapContent, beatmapSkin);
|
||||
|
||||
/// <summary>
|
||||
/// Returns a list of all usable <see cref="BeatmapSetInfo"/>s.
|
||||
/// </summary>
|
||||
/// <returns>A list of available <see cref="BeatmapSetInfo"/>.</returns>
|
||||
public List<BeatmapSetInfo> GetAllUsableBeatmapSets(IncludedDetails includes = IncludedDetails.All, bool includeProtected = false) =>
|
||||
GetAllUsableBeatmapSetsEnumerable(includes, includeProtected).ToList();
|
||||
public List<BeatmapSetInfo> GetAllUsableBeatmapSets(IncludedDetails includes = IncludedDetails.All, bool includeProtected = false) => beatmapModelManager.GetAllUsableBeatmapSets(includes, includeProtected);
|
||||
|
||||
/// <summary>
|
||||
/// Returns a list of all usable <see cref="BeatmapSetInfo"/>s. Note that files are not populated.
|
||||
@ -357,34 +126,7 @@ namespace osu.Game.Beatmaps
|
||||
/// <param name="includes">The level of detail to include in the returned objects.</param>
|
||||
/// <param name="includeProtected">Whether to include protected (system) beatmaps. These should not be included for gameplay playable use cases.</param>
|
||||
/// <returns>A list of available <see cref="BeatmapSetInfo"/>.</returns>
|
||||
public IEnumerable<BeatmapSetInfo> GetAllUsableBeatmapSetsEnumerable(IncludedDetails includes, bool includeProtected = false)
|
||||
{
|
||||
IQueryable<BeatmapSetInfo> queryable;
|
||||
|
||||
switch (includes)
|
||||
{
|
||||
case IncludedDetails.Minimal:
|
||||
queryable = beatmaps.BeatmapSetsOverview;
|
||||
break;
|
||||
|
||||
case IncludedDetails.AllButRuleset:
|
||||
queryable = beatmaps.BeatmapSetsWithoutRuleset;
|
||||
break;
|
||||
|
||||
case IncludedDetails.AllButFiles:
|
||||
queryable = beatmaps.BeatmapSetsWithoutFiles;
|
||||
break;
|
||||
|
||||
default:
|
||||
queryable = beatmaps.ConsumableItems;
|
||||
break;
|
||||
}
|
||||
|
||||
// AsEnumerable used here to avoid applying the WHERE in sql. When done so, ef core 2.x uses an incorrect ORDER BY
|
||||
// clause which causes queries to take 5-10x longer.
|
||||
// TODO: remove if upgrading to EF core 3.x.
|
||||
return queryable.AsEnumerable().Where(s => !s.DeletePending && (includeProtected || !s.Protected));
|
||||
}
|
||||
public IEnumerable<BeatmapSetInfo> GetAllUsableBeatmapSetsEnumerable(IncludedDetails includes, bool includeProtected = false) => beatmapModelManager.GetAllUsableBeatmapSetsEnumerable(includes, includeProtected);
|
||||
|
||||
/// <summary>
|
||||
/// Perform a lookup query on available <see cref="BeatmapSetInfo"/>s.
|
||||
@ -392,207 +134,204 @@ namespace osu.Game.Beatmaps
|
||||
/// <param name="query">The query.</param>
|
||||
/// <param name="includes">The level of detail to include in the returned objects.</param>
|
||||
/// <returns>Results from the provided query.</returns>
|
||||
public IEnumerable<BeatmapSetInfo> QueryBeatmapSets(Expression<Func<BeatmapSetInfo, bool>> query, IncludedDetails includes = IncludedDetails.All)
|
||||
{
|
||||
IQueryable<BeatmapSetInfo> queryable;
|
||||
|
||||
switch (includes)
|
||||
{
|
||||
case IncludedDetails.Minimal:
|
||||
queryable = beatmaps.BeatmapSetsOverview;
|
||||
break;
|
||||
|
||||
case IncludedDetails.AllButRuleset:
|
||||
queryable = beatmaps.BeatmapSetsWithoutRuleset;
|
||||
break;
|
||||
|
||||
case IncludedDetails.AllButFiles:
|
||||
queryable = beatmaps.BeatmapSetsWithoutFiles;
|
||||
break;
|
||||
|
||||
default:
|
||||
queryable = beatmaps.ConsumableItems;
|
||||
break;
|
||||
}
|
||||
|
||||
return queryable.AsNoTracking().Where(query);
|
||||
}
|
||||
public IEnumerable<BeatmapSetInfo> QueryBeatmapSets(Expression<Func<BeatmapSetInfo, bool>> query, IncludedDetails includes = IncludedDetails.All) => beatmapModelManager.QueryBeatmapSets(query, includes);
|
||||
|
||||
/// <summary>
|
||||
/// Perform a lookup query on available <see cref="BeatmapInfo"/>s.
|
||||
/// Perform a lookup query on available <see cref="BeatmapSetInfo"/>s.
|
||||
/// </summary>
|
||||
/// <param name="query">The query.</param>
|
||||
/// <returns>The first result for the provided query, or null if no results were found.</returns>
|
||||
public BeatmapInfo QueryBeatmap(Expression<Func<BeatmapInfo, bool>> query) => beatmaps.Beatmaps.AsNoTracking().FirstOrDefault(query);
|
||||
public BeatmapSetInfo QueryBeatmapSet(Expression<Func<BeatmapSetInfo, bool>> query) => beatmapModelManager.QueryBeatmapSet(query);
|
||||
|
||||
/// <summary>
|
||||
/// Perform a lookup query on available <see cref="BeatmapInfo"/>s.
|
||||
/// </summary>
|
||||
/// <param name="query">The query.</param>
|
||||
/// <returns>Results from the provided query.</returns>
|
||||
public IQueryable<BeatmapInfo> QueryBeatmaps(Expression<Func<BeatmapInfo, bool>> query) => beatmaps.Beatmaps.AsNoTracking().Where(query);
|
||||
public IQueryable<BeatmapInfo> QueryBeatmaps(Expression<Func<BeatmapInfo, bool>> query) => beatmapModelManager.QueryBeatmaps(query);
|
||||
|
||||
protected override string HumanisedModelName => "beatmap";
|
||||
/// <summary>
|
||||
/// Perform a lookup query on available <see cref="BeatmapInfo"/>s.
|
||||
/// </summary>
|
||||
/// <param name="query">The query.</param>
|
||||
/// <returns>The first result for the provided query, or null if no results were found.</returns>
|
||||
public BeatmapInfo QueryBeatmap(Expression<Func<BeatmapInfo, bool>> query) => beatmapModelManager.QueryBeatmap(query);
|
||||
|
||||
protected override BeatmapSetInfo CreateModel(ArchiveReader reader)
|
||||
/// <summary>
|
||||
/// A default representation of a WorkingBeatmap to use when no beatmap is available.
|
||||
/// </summary>
|
||||
public WorkingBeatmap DefaultBeatmap => workingBeatmapCache.DefaultBeatmap;
|
||||
|
||||
/// <summary>
|
||||
/// Fired when a notification should be presented to the user.
|
||||
/// </summary>
|
||||
public Action<Notification> PostNotification
|
||||
{
|
||||
// let's make sure there are actually .osu files to import.
|
||||
string mapName = reader.Filenames.FirstOrDefault(f => f.EndsWith(".osu", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (string.IsNullOrEmpty(mapName))
|
||||
set
|
||||
{
|
||||
Logger.Log($"No beatmap files found in the beatmap archive ({reader.Name}).", LoggingTarget.Database);
|
||||
return null;
|
||||
beatmapModelManager.PostNotification = value;
|
||||
beatmapModelDownloader.PostNotification = value;
|
||||
}
|
||||
|
||||
Beatmap beatmap;
|
||||
using (var stream = new LineBufferedReader(reader.GetStream(mapName)))
|
||||
beatmap = Decoder.GetDecoder<Beatmap>(stream).Decode(stream);
|
||||
|
||||
return new BeatmapSetInfo
|
||||
{
|
||||
OnlineBeatmapSetID = beatmap.BeatmapInfo.BeatmapSet?.OnlineBeatmapSetID,
|
||||
Beatmaps = new List<BeatmapInfo>(),
|
||||
Metadata = beatmap.Metadata,
|
||||
DateAdded = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create all required <see cref="BeatmapInfo"/>s for the provided archive.
|
||||
/// Fired when the user requests to view the resulting import.
|
||||
/// </summary>
|
||||
private List<BeatmapInfo> createBeatmapDifficulties(List<BeatmapSetFileInfo> files)
|
||||
{
|
||||
var beatmapInfos = new List<BeatmapInfo>();
|
||||
public Action<IEnumerable<BeatmapSetInfo>> PresentImport { set => beatmapModelManager.PresentImport = value; }
|
||||
|
||||
foreach (var file in files.Where(f => f.Filename.EndsWith(".osu", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
using (var raw = Files.Store.GetStream(file.FileInfo.StoragePath))
|
||||
using (var ms = new MemoryStream()) // we need a memory stream so we can seek
|
||||
using (var sr = new LineBufferedReader(ms))
|
||||
{
|
||||
raw.CopyTo(ms);
|
||||
ms.Position = 0;
|
||||
/// <summary>
|
||||
/// Delete a beatmap difficulty.
|
||||
/// </summary>
|
||||
/// <param name="beatmap">The beatmap difficulty to hide.</param>
|
||||
public void Hide(BeatmapInfo beatmap) => beatmapModelManager.Hide(beatmap);
|
||||
|
||||
var decoder = Decoder.GetDecoder<Beatmap>(sr);
|
||||
IBeatmap beatmap = decoder.Decode(sr);
|
||||
|
||||
string hash = ms.ComputeSHA2Hash();
|
||||
|
||||
if (beatmapInfos.Any(b => b.Hash == hash))
|
||||
continue;
|
||||
|
||||
beatmap.BeatmapInfo.Path = file.Filename;
|
||||
beatmap.BeatmapInfo.Hash = hash;
|
||||
beatmap.BeatmapInfo.MD5Hash = ms.ComputeMD5Hash();
|
||||
|
||||
var ruleset = rulesets.GetRuleset(beatmap.BeatmapInfo.RulesetID);
|
||||
beatmap.BeatmapInfo.Ruleset = ruleset;
|
||||
|
||||
// TODO: this should be done in a better place once we actually need to dynamically update it.
|
||||
beatmap.BeatmapInfo.StarDifficulty = ruleset?.CreateInstance().CreateDifficultyCalculator(new DummyConversionBeatmap(beatmap)).Calculate().StarRating ?? 0;
|
||||
beatmap.BeatmapInfo.Length = calculateLength(beatmap);
|
||||
beatmap.BeatmapInfo.BPM = 60000 / beatmap.GetMostCommonBeatLength();
|
||||
|
||||
beatmapInfos.Add(beatmap.BeatmapInfo);
|
||||
}
|
||||
}
|
||||
|
||||
return beatmapInfos;
|
||||
}
|
||||
|
||||
private double calculateLength(IBeatmap b)
|
||||
{
|
||||
if (!b.HitObjects.Any())
|
||||
return 0;
|
||||
|
||||
var lastObject = b.HitObjects.Last();
|
||||
|
||||
//TODO: this isn't always correct (consider mania where a non-last object may last for longer than the last in the list).
|
||||
double endTime = lastObject.GetEndTime();
|
||||
double startTime = b.HitObjects.First().StartTime;
|
||||
|
||||
return endTime - startTime;
|
||||
}
|
||||
|
||||
private void removeWorkingCache(BeatmapSetInfo info)
|
||||
{
|
||||
if (info.Beatmaps == null) return;
|
||||
|
||||
foreach (var b in info.Beatmaps)
|
||||
removeWorkingCache(b);
|
||||
}
|
||||
|
||||
private void removeWorkingCache(BeatmapInfo info)
|
||||
{
|
||||
lock (workingCache)
|
||||
{
|
||||
var working = workingCache.FirstOrDefault(w => w.BeatmapInfo?.ID == info.ID);
|
||||
if (working != null)
|
||||
workingCache.Remove(working);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
onlineLookupQueue?.Dispose();
|
||||
}
|
||||
|
||||
#region IResourceStorageProvider
|
||||
|
||||
TextureStore IBeatmapResourceProvider.LargeTextureStore => largeTextureStore;
|
||||
ITrackStore IBeatmapResourceProvider.Tracks => trackStore;
|
||||
AudioManager IStorageResourceProvider.AudioManager => audioManager;
|
||||
IResourceStore<byte[]> IStorageResourceProvider.Files => Files.Store;
|
||||
IResourceStore<byte[]> IStorageResourceProvider.Resources => resources;
|
||||
IResourceStore<TextureUpload> IStorageResourceProvider.CreateTextureLoaderStore(IResourceStore<byte[]> underlyingStore) => host?.CreateTextureLoaderStore(underlyingStore);
|
||||
/// <summary>
|
||||
/// Restore a beatmap difficulty.
|
||||
/// </summary>
|
||||
/// <param name="beatmap">The beatmap difficulty to restore.</param>
|
||||
public void Restore(BeatmapInfo beatmap) => beatmapModelManager.Restore(beatmap);
|
||||
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
/// A dummy WorkingBeatmap for the purpose of retrieving a beatmap for star difficulty calculation.
|
||||
/// </summary>
|
||||
private class DummyConversionBeatmap : WorkingBeatmap
|
||||
#region Implementation of IModelManager<BeatmapSetInfo>
|
||||
|
||||
public bool IsAvailableLocally(BeatmapSetInfo model)
|
||||
{
|
||||
private readonly IBeatmap beatmap;
|
||||
|
||||
public DummyConversionBeatmap(IBeatmap beatmap)
|
||||
: base(beatmap.BeatmapInfo, null)
|
||||
{
|
||||
this.beatmap = beatmap;
|
||||
}
|
||||
|
||||
protected override IBeatmap GetBeatmap() => beatmap;
|
||||
protected override Texture GetBackground() => null;
|
||||
protected override Track GetBeatmapTrack() => null;
|
||||
protected internal override ISkin GetSkin() => null;
|
||||
public override Stream GetStream(string storagePath) => null;
|
||||
return beatmapModelManager.IsAvailableLocally(model);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The level of detail to include in database results.
|
||||
/// </summary>
|
||||
public enum IncludedDetails
|
||||
{
|
||||
/// <summary>
|
||||
/// Only include beatmap difficulties and set level metadata.
|
||||
/// </summary>
|
||||
Minimal,
|
||||
public IBindable<WeakReference<BeatmapSetInfo>> ItemUpdated => beatmapModelManager.ItemUpdated;
|
||||
|
||||
/// <summary>
|
||||
/// Include all difficulties, rulesets, difficulty metadata but no files.
|
||||
/// </summary>
|
||||
AllButFiles,
|
||||
public IBindable<WeakReference<BeatmapSetInfo>> ItemRemoved => beatmapModelManager.ItemRemoved;
|
||||
|
||||
/// <summary>
|
||||
/// Include everything except ruleset. Used for cases where we aren't sure the ruleset is present but still want to consume the beatmap.
|
||||
/// </summary>
|
||||
AllButRuleset,
|
||||
public Task ImportFromStableAsync(StableStorage stableStorage)
|
||||
{
|
||||
return beatmapModelManager.ImportFromStableAsync(stableStorage);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Include everything.
|
||||
/// </summary>
|
||||
All
|
||||
public void Export(BeatmapSetInfo item)
|
||||
{
|
||||
beatmapModelManager.Export(item);
|
||||
}
|
||||
|
||||
public void ExportModelTo(BeatmapSetInfo model, Stream outputStream)
|
||||
{
|
||||
beatmapModelManager.ExportModelTo(model, outputStream);
|
||||
}
|
||||
|
||||
public void Update(BeatmapSetInfo item)
|
||||
{
|
||||
beatmapModelManager.Update(item);
|
||||
}
|
||||
|
||||
public bool Delete(BeatmapSetInfo item)
|
||||
{
|
||||
return beatmapModelManager.Delete(item);
|
||||
}
|
||||
|
||||
public void Delete(List<BeatmapSetInfo> items, bool silent = false)
|
||||
{
|
||||
beatmapModelManager.Delete(items, silent);
|
||||
}
|
||||
|
||||
public void Undelete(List<BeatmapSetInfo> items, bool silent = false)
|
||||
{
|
||||
beatmapModelManager.Undelete(items, silent);
|
||||
}
|
||||
|
||||
public void Undelete(BeatmapSetInfo item)
|
||||
{
|
||||
beatmapModelManager.Undelete(item);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Implementation of IModelDownloader<BeatmapSetInfo>
|
||||
|
||||
public IBindable<WeakReference<ArchiveDownloadRequest<BeatmapSetInfo>>> DownloadBegan => beatmapModelDownloader.DownloadBegan;
|
||||
|
||||
public IBindable<WeakReference<ArchiveDownloadRequest<BeatmapSetInfo>>> DownloadFailed => beatmapModelDownloader.DownloadFailed;
|
||||
|
||||
public bool Download(BeatmapSetInfo model, bool minimiseDownloadSize = false)
|
||||
{
|
||||
return beatmapModelDownloader.Download(model, minimiseDownloadSize);
|
||||
}
|
||||
|
||||
public ArchiveDownloadRequest<BeatmapSetInfo> GetExistingDownload(BeatmapSetInfo model)
|
||||
{
|
||||
return beatmapModelDownloader.GetExistingDownload(model);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Implementation of ICanAcceptFiles
|
||||
|
||||
public Task Import(params string[] paths)
|
||||
{
|
||||
return beatmapModelManager.Import(paths);
|
||||
}
|
||||
|
||||
public Task Import(params ImportTask[] tasks)
|
||||
{
|
||||
return beatmapModelManager.Import(tasks);
|
||||
}
|
||||
|
||||
public Task<IEnumerable<BeatmapSetInfo>> Import(ProgressNotification notification, params ImportTask[] tasks)
|
||||
{
|
||||
return beatmapModelManager.Import(notification, tasks);
|
||||
}
|
||||
|
||||
public Task<BeatmapSetInfo> Import(ImportTask task, bool lowPriority = false, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return beatmapModelManager.Import(task, lowPriority, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<BeatmapSetInfo> Import(ArchiveReader archive, bool lowPriority = false, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return beatmapModelManager.Import(archive, lowPriority, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<BeatmapSetInfo> Import(BeatmapSetInfo item, ArchiveReader archive = null, bool lowPriority = false, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return beatmapModelManager.Import(item, archive, lowPriority, cancellationToken);
|
||||
}
|
||||
|
||||
public IEnumerable<string> HandledExtensions => beatmapModelManager.HandledExtensions;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Implementation of IWorkingBeatmapCache
|
||||
|
||||
public WorkingBeatmap GetWorkingBeatmap(BeatmapInfo importedBeatmap) => workingBeatmapCache.GetWorkingBeatmap(importedBeatmap);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Implementation of IModelFileManager<in BeatmapSetInfo,in BeatmapSetFileInfo>
|
||||
|
||||
public void ReplaceFile(BeatmapSetInfo model, BeatmapSetFileInfo file, Stream contents, string filename = null)
|
||||
{
|
||||
beatmapModelManager.ReplaceFile(model, file, contents, filename);
|
||||
}
|
||||
|
||||
public void DeleteFile(BeatmapSetInfo model, BeatmapSetFileInfo file)
|
||||
{
|
||||
beatmapModelManager.DeleteFile(model, file);
|
||||
}
|
||||
|
||||
public void AddFile(BeatmapSetInfo model, Stream contents, string filename)
|
||||
{
|
||||
beatmapModelManager.AddFile(model, contents, filename);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Implementation of IDisposable
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
onlineBetamapLookupQueue?.Dispose();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
|
@ -1,215 +0,0 @@
|
||||
// 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.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using osu.Framework.Development;
|
||||
using osu.Framework.IO.Network;
|
||||
using osu.Framework.Logging;
|
||||
using osu.Framework.Platform;
|
||||
using osu.Framework.Testing;
|
||||
using osu.Framework.Threading;
|
||||
using osu.Game.Database;
|
||||
using osu.Game.Online.API;
|
||||
using osu.Game.Online.API.Requests;
|
||||
using SharpCompress.Compressors;
|
||||
using SharpCompress.Compressors.BZip2;
|
||||
|
||||
namespace osu.Game.Beatmaps
|
||||
{
|
||||
public partial class BeatmapManager
|
||||
{
|
||||
[ExcludeFromDynamicCompile]
|
||||
private class BeatmapOnlineLookupQueue : IDisposable
|
||||
{
|
||||
private readonly IAPIProvider api;
|
||||
private readonly Storage storage;
|
||||
|
||||
private const int update_queue_request_concurrency = 4;
|
||||
|
||||
private readonly ThreadedTaskScheduler updateScheduler = new ThreadedTaskScheduler(update_queue_request_concurrency, nameof(BeatmapOnlineLookupQueue));
|
||||
|
||||
private FileWebRequest cacheDownloadRequest;
|
||||
|
||||
private const string cache_database_name = "online.db";
|
||||
|
||||
public BeatmapOnlineLookupQueue(IAPIProvider api, Storage storage)
|
||||
{
|
||||
this.api = api;
|
||||
this.storage = storage;
|
||||
|
||||
// avoid downloading / using cache for unit tests.
|
||||
if (!DebugUtils.IsNUnitRunning && !storage.Exists(cache_database_name))
|
||||
prepareLocalCache();
|
||||
}
|
||||
|
||||
public Task UpdateAsync(BeatmapSetInfo beatmapSet, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.WhenAll(beatmapSet.Beatmaps.Select(b => UpdateAsync(beatmapSet, b, cancellationToken)).ToArray());
|
||||
}
|
||||
|
||||
// todo: expose this when we need to do individual difficulty lookups.
|
||||
protected Task UpdateAsync(BeatmapSetInfo beatmapSet, BeatmapInfo beatmap, CancellationToken cancellationToken)
|
||||
=> Task.Factory.StartNew(() => lookup(beatmapSet, beatmap), cancellationToken, TaskCreationOptions.HideScheduler | TaskCreationOptions.RunContinuationsAsynchronously, updateScheduler);
|
||||
|
||||
private void lookup(BeatmapSetInfo set, BeatmapInfo beatmap)
|
||||
{
|
||||
if (checkLocalCache(set, beatmap))
|
||||
return;
|
||||
|
||||
if (api?.State.Value != APIState.Online)
|
||||
return;
|
||||
|
||||
var req = new GetBeatmapRequest(beatmap);
|
||||
|
||||
req.Failure += fail;
|
||||
|
||||
try
|
||||
{
|
||||
// intentionally blocking to limit web request concurrency
|
||||
api.Perform(req);
|
||||
|
||||
var res = req.Result;
|
||||
|
||||
if (res != null)
|
||||
{
|
||||
beatmap.Status = res.Status;
|
||||
beatmap.BeatmapSet.Status = res.BeatmapSet.Status;
|
||||
beatmap.BeatmapSet.OnlineBeatmapSetID = res.OnlineBeatmapSetID;
|
||||
beatmap.OnlineBeatmapID = res.OnlineBeatmapID;
|
||||
|
||||
if (beatmap.Metadata != null)
|
||||
beatmap.Metadata.AuthorID = res.AuthorID;
|
||||
|
||||
if (beatmap.BeatmapSet.Metadata != null)
|
||||
beatmap.BeatmapSet.Metadata.AuthorID = res.AuthorID;
|
||||
|
||||
LogForModel(set, $"Online retrieval mapped {beatmap} to {res.OnlineBeatmapSetID} / {res.OnlineBeatmapID}.");
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
fail(e);
|
||||
}
|
||||
|
||||
void fail(Exception e)
|
||||
{
|
||||
beatmap.OnlineBeatmapID = null;
|
||||
LogForModel(set, $"Online retrieval failed for {beatmap} ({e.Message})");
|
||||
}
|
||||
}
|
||||
|
||||
private void prepareLocalCache()
|
||||
{
|
||||
string cacheFilePath = storage.GetFullPath(cache_database_name);
|
||||
string compressedCacheFilePath = $"{cacheFilePath}.bz2";
|
||||
|
||||
cacheDownloadRequest = new FileWebRequest(compressedCacheFilePath, $"https://assets.ppy.sh/client-resources/{cache_database_name}.bz2?{DateTimeOffset.UtcNow:yyyyMMdd}");
|
||||
|
||||
cacheDownloadRequest.Failed += ex =>
|
||||
{
|
||||
File.Delete(compressedCacheFilePath);
|
||||
File.Delete(cacheFilePath);
|
||||
|
||||
Logger.Log($"{nameof(BeatmapOnlineLookupQueue)}'s online cache download failed: {ex}", LoggingTarget.Database);
|
||||
};
|
||||
|
||||
cacheDownloadRequest.Finished += () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var stream = File.OpenRead(cacheDownloadRequest.Filename))
|
||||
using (var outStream = File.OpenWrite(cacheFilePath))
|
||||
using (var bz2 = new BZip2Stream(stream, CompressionMode.Decompress, false))
|
||||
bz2.CopyTo(outStream);
|
||||
|
||||
// set to null on completion to allow lookups to begin using the new source
|
||||
cacheDownloadRequest = null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Log($"{nameof(BeatmapOnlineLookupQueue)}'s online cache extraction failed: {ex}", LoggingTarget.Database);
|
||||
File.Delete(cacheFilePath);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(compressedCacheFilePath);
|
||||
}
|
||||
};
|
||||
|
||||
cacheDownloadRequest.PerformAsync();
|
||||
}
|
||||
|
||||
private bool checkLocalCache(BeatmapSetInfo set, BeatmapInfo beatmap)
|
||||
{
|
||||
// download is in progress (or was, and failed).
|
||||
if (cacheDownloadRequest != null)
|
||||
return false;
|
||||
|
||||
// database is unavailable.
|
||||
if (!storage.Exists(cache_database_name))
|
||||
return false;
|
||||
|
||||
if (string.IsNullOrEmpty(beatmap.MD5Hash)
|
||||
&& string.IsNullOrEmpty(beatmap.Path)
|
||||
&& beatmap.OnlineBeatmapID == null)
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
using (var db = new SqliteConnection(DatabaseContextFactory.CreateDatabaseConnectionString("online.db", storage)))
|
||||
{
|
||||
db.Open();
|
||||
|
||||
using (var cmd = db.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "SELECT beatmapset_id, beatmap_id, approved, user_id FROM osu_beatmaps WHERE checksum = @MD5Hash OR beatmap_id = @OnlineBeatmapID OR filename = @Path";
|
||||
|
||||
cmd.Parameters.Add(new SqliteParameter("@MD5Hash", beatmap.MD5Hash));
|
||||
cmd.Parameters.Add(new SqliteParameter("@OnlineBeatmapID", beatmap.OnlineBeatmapID ?? (object)DBNull.Value));
|
||||
cmd.Parameters.Add(new SqliteParameter("@Path", beatmap.Path));
|
||||
|
||||
using (var reader = cmd.ExecuteReader())
|
||||
{
|
||||
if (reader.Read())
|
||||
{
|
||||
var status = (BeatmapSetOnlineStatus)reader.GetByte(2);
|
||||
|
||||
beatmap.Status = status;
|
||||
beatmap.BeatmapSet.Status = status;
|
||||
beatmap.BeatmapSet.OnlineBeatmapSetID = reader.GetInt32(0);
|
||||
beatmap.OnlineBeatmapID = reader.GetInt32(1);
|
||||
|
||||
if (beatmap.Metadata != null)
|
||||
beatmap.Metadata.AuthorID = reader.GetInt32(3);
|
||||
|
||||
if (beatmap.BeatmapSet.Metadata != null)
|
||||
beatmap.BeatmapSet.Metadata.AuthorID = reader.GetInt32(3);
|
||||
|
||||
LogForModel(set, $"Cached local retrieval for {beatmap}.");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogForModel(set, $"Cached local retrieval for {beatmap} failed with {ex}.");
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
cacheDownloadRequest?.Dispose();
|
||||
updateScheduler?.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
21
osu.Game/Beatmaps/BeatmapModelDownloader.cs
Normal file
21
osu.Game/Beatmaps/BeatmapModelDownloader.cs
Normal file
@ -0,0 +1,21 @@
|
||||
// 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 osu.Framework.Platform;
|
||||
using osu.Game.Database;
|
||||
using osu.Game.Online.API;
|
||||
using osu.Game.Online.API.Requests;
|
||||
|
||||
namespace osu.Game.Beatmaps
|
||||
{
|
||||
public class BeatmapModelDownloader : ModelDownloader<BeatmapSetInfo>
|
||||
{
|
||||
protected override ArchiveDownloadRequest<BeatmapSetInfo> CreateDownloadRequest(BeatmapSetInfo set, bool minimiseDownloadSize) =>
|
||||
new DownloadBeatmapSetRequest(set, minimiseDownloadSize);
|
||||
|
||||
public BeatmapModelDownloader(BeatmapModelManager beatmapModelManager, IAPIProvider api, GameHost host = null)
|
||||
: base(beatmapModelManager, api, host)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
473
osu.Game/Beatmaps/BeatmapModelManager.cs
Normal file
473
osu.Game/Beatmaps/BeatmapModelManager.cs
Normal file
@ -0,0 +1,473 @@
|
||||
// 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;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Linq.Expressions;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using osu.Framework.Audio.Track;
|
||||
using osu.Framework.Bindables;
|
||||
using osu.Framework.Extensions;
|
||||
using osu.Framework.Graphics.Textures;
|
||||
using osu.Framework.Logging;
|
||||
using osu.Framework.Platform;
|
||||
using osu.Framework.Testing;
|
||||
using osu.Game.Beatmaps.Formats;
|
||||
using osu.Game.Database;
|
||||
using osu.Game.IO;
|
||||
using osu.Game.IO.Archives;
|
||||
using osu.Game.Rulesets;
|
||||
using osu.Game.Rulesets.Objects;
|
||||
using osu.Game.Skinning;
|
||||
using Decoder = osu.Game.Beatmaps.Formats.Decoder;
|
||||
|
||||
namespace osu.Game.Beatmaps
|
||||
{
|
||||
/// <summary>
|
||||
/// Handles ef-core storage of beatmaps.
|
||||
/// </summary>
|
||||
[ExcludeFromDynamicCompile]
|
||||
public class BeatmapModelManager : ArchiveModelManager<BeatmapSetInfo, BeatmapSetFileInfo>
|
||||
{
|
||||
/// <summary>
|
||||
/// Fired when a single difficulty has been hidden.
|
||||
/// </summary>
|
||||
public IBindable<WeakReference<BeatmapInfo>> BeatmapHidden => beatmapHidden;
|
||||
|
||||
private readonly Bindable<WeakReference<BeatmapInfo>> beatmapHidden = new Bindable<WeakReference<BeatmapInfo>>();
|
||||
|
||||
/// <summary>
|
||||
/// Fired when a single difficulty has been restored.
|
||||
/// </summary>
|
||||
public IBindable<WeakReference<BeatmapInfo>> BeatmapRestored => beatmapRestored;
|
||||
|
||||
/// <summary>
|
||||
/// An online lookup queue component which handles populating online beatmap metadata.
|
||||
/// </summary>
|
||||
public BeatmapOnlineLookupQueue OnlineLookupQueue { private get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The game working beatmap cache, used to invalidate entries on changes.
|
||||
/// </summary>
|
||||
public WorkingBeatmapCache WorkingBeatmapCache { private get; set; }
|
||||
|
||||
private readonly Bindable<WeakReference<BeatmapInfo>> beatmapRestored = new Bindable<WeakReference<BeatmapInfo>>();
|
||||
|
||||
public override IEnumerable<string> HandledExtensions => new[] { ".osz" };
|
||||
|
||||
protected override string[] HashableFileTypes => new[] { ".osu" };
|
||||
|
||||
protected override string ImportFromStablePath => ".";
|
||||
|
||||
protected override Storage PrepareStableStorage(StableStorage stableStorage) => stableStorage.GetSongStorage();
|
||||
|
||||
private readonly BeatmapStore beatmaps;
|
||||
private readonly RulesetStore rulesets;
|
||||
|
||||
public BeatmapModelManager(Storage storage, IDatabaseContextFactory contextFactory, RulesetStore rulesets, GameHost host = null)
|
||||
: base(storage, contextFactory, new BeatmapStore(contextFactory), host)
|
||||
{
|
||||
this.rulesets = rulesets;
|
||||
|
||||
beatmaps = (BeatmapStore)ModelStore;
|
||||
beatmaps.BeatmapHidden += b => beatmapHidden.Value = new WeakReference<BeatmapInfo>(b);
|
||||
beatmaps.BeatmapRestored += b => beatmapRestored.Value = new WeakReference<BeatmapInfo>(b);
|
||||
beatmaps.ItemRemoved += b => WorkingBeatmapCache?.Invalidate(b);
|
||||
beatmaps.ItemUpdated += obj => WorkingBeatmapCache?.Invalidate(obj);
|
||||
}
|
||||
|
||||
protected override bool ShouldDeleteArchive(string path) => Path.GetExtension(path)?.ToLowerInvariant() == ".osz";
|
||||
|
||||
protected override async Task Populate(BeatmapSetInfo beatmapSet, ArchiveReader archive, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (archive != null)
|
||||
beatmapSet.Beatmaps = createBeatmapDifficulties(beatmapSet.Files);
|
||||
|
||||
foreach (BeatmapInfo b in beatmapSet.Beatmaps)
|
||||
{
|
||||
// remove metadata from difficulties where it matches the set
|
||||
if (beatmapSet.Metadata.Equals(b.Metadata))
|
||||
b.Metadata = null;
|
||||
|
||||
b.BeatmapSet = beatmapSet;
|
||||
}
|
||||
|
||||
validateOnlineIds(beatmapSet);
|
||||
|
||||
bool hadOnlineBeatmapIDs = beatmapSet.Beatmaps.Any(b => b.OnlineBeatmapID > 0);
|
||||
|
||||
if (OnlineLookupQueue != null)
|
||||
await OnlineLookupQueue.UpdateAsync(beatmapSet, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// ensure at least one beatmap was able to retrieve or keep an online ID, else drop the set ID.
|
||||
if (hadOnlineBeatmapIDs && !beatmapSet.Beatmaps.Any(b => b.OnlineBeatmapID > 0))
|
||||
{
|
||||
if (beatmapSet.OnlineBeatmapSetID != null)
|
||||
{
|
||||
beatmapSet.OnlineBeatmapSetID = null;
|
||||
LogForModel(beatmapSet, "Disassociating beatmap set ID due to loss of all beatmap IDs");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected override void PreImport(BeatmapSetInfo beatmapSet)
|
||||
{
|
||||
if (beatmapSet.Beatmaps.Any(b => b.BaseDifficulty == null))
|
||||
throw new InvalidOperationException($"Cannot import {nameof(BeatmapInfo)} with null {nameof(BeatmapInfo.BaseDifficulty)}.");
|
||||
|
||||
// check if a set already exists with the same online id, delete if it does.
|
||||
if (beatmapSet.OnlineBeatmapSetID != null)
|
||||
{
|
||||
var existingOnlineId = beatmaps.ConsumableItems.FirstOrDefault(b => b.OnlineBeatmapSetID == beatmapSet.OnlineBeatmapSetID);
|
||||
|
||||
if (existingOnlineId != null)
|
||||
{
|
||||
Delete(existingOnlineId);
|
||||
|
||||
// in order to avoid a unique key constraint, immediately remove the online ID from the previous set.
|
||||
existingOnlineId.OnlineBeatmapSetID = null;
|
||||
foreach (var b in existingOnlineId.Beatmaps)
|
||||
b.OnlineBeatmapID = null;
|
||||
|
||||
LogForModel(beatmapSet, $"Found existing beatmap set with same OnlineBeatmapSetID ({beatmapSet.OnlineBeatmapSetID}). It has been deleted.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void validateOnlineIds(BeatmapSetInfo beatmapSet)
|
||||
{
|
||||
var beatmapIds = beatmapSet.Beatmaps.Where(b => b.OnlineBeatmapID.HasValue).Select(b => b.OnlineBeatmapID).ToList();
|
||||
|
||||
// ensure all IDs are unique
|
||||
if (beatmapIds.GroupBy(b => b).Any(g => g.Count() > 1))
|
||||
{
|
||||
LogForModel(beatmapSet, "Found non-unique IDs, resetting...");
|
||||
resetIds();
|
||||
return;
|
||||
}
|
||||
|
||||
// find any existing beatmaps in the database that have matching online ids
|
||||
var existingBeatmaps = QueryBeatmaps(b => beatmapIds.Contains(b.OnlineBeatmapID)).ToList();
|
||||
|
||||
if (existingBeatmaps.Count > 0)
|
||||
{
|
||||
// reset the import ids (to force a re-fetch) *unless* they match the candidate CheckForExisting set.
|
||||
// we can ignore the case where the new ids are contained by the CheckForExisting set as it will either be used (import skipped) or deleted.
|
||||
var existing = CheckForExisting(beatmapSet);
|
||||
|
||||
if (existing == null || existingBeatmaps.Any(b => !existing.Beatmaps.Contains(b)))
|
||||
{
|
||||
LogForModel(beatmapSet, "Found existing import with IDs already, resetting...");
|
||||
resetIds();
|
||||
}
|
||||
}
|
||||
|
||||
void resetIds() => beatmapSet.Beatmaps.ForEach(b => b.OnlineBeatmapID = null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delete a beatmap difficulty.
|
||||
/// </summary>
|
||||
/// <param name="beatmap">The beatmap difficulty to hide.</param>
|
||||
public void Hide(BeatmapInfo beatmap) => beatmaps.Hide(beatmap);
|
||||
|
||||
/// <summary>
|
||||
/// Restore a beatmap difficulty.
|
||||
/// </summary>
|
||||
/// <param name="beatmap">The beatmap difficulty to restore.</param>
|
||||
public void Restore(BeatmapInfo beatmap) => beatmaps.Restore(beatmap);
|
||||
|
||||
/// <summary>
|
||||
/// Saves an <see cref="IBeatmap"/> file against a given <see cref="BeatmapInfo"/>.
|
||||
/// </summary>
|
||||
/// <param name="info">The <see cref="BeatmapInfo"/> to save the content against. The file referenced by <see cref="BeatmapInfo.Path"/> will be replaced.</param>
|
||||
/// <param name="beatmapContent">The <see cref="IBeatmap"/> content to write.</param>
|
||||
/// <param name="beatmapSkin">The beatmap <see cref="ISkin"/> content to write, null if to be omitted.</param>
|
||||
public virtual void Save(BeatmapInfo info, IBeatmap beatmapContent, ISkin beatmapSkin = null)
|
||||
{
|
||||
var setInfo = info.BeatmapSet;
|
||||
|
||||
using (var stream = new MemoryStream())
|
||||
{
|
||||
using (var sw = new StreamWriter(stream, Encoding.UTF8, 1024, true))
|
||||
new LegacyBeatmapEncoder(beatmapContent, beatmapSkin).Encode(sw);
|
||||
|
||||
stream.Seek(0, SeekOrigin.Begin);
|
||||
|
||||
using (ContextFactory.GetForWrite())
|
||||
{
|
||||
var beatmapInfo = setInfo.Beatmaps.Single(b => b.ID == info.ID);
|
||||
var metadata = beatmapInfo.Metadata ?? setInfo.Metadata;
|
||||
|
||||
// grab the original file (or create a new one if not found).
|
||||
var fileInfo = setInfo.Files.SingleOrDefault(f => string.Equals(f.Filename, beatmapInfo.Path, StringComparison.OrdinalIgnoreCase)) ?? new BeatmapSetFileInfo();
|
||||
|
||||
// metadata may have changed; update the path with the standard format.
|
||||
beatmapInfo.Path = $"{metadata.Artist} - {metadata.Title} ({metadata.Author}) [{beatmapInfo.Version}].osu";
|
||||
beatmapInfo.MD5Hash = stream.ComputeMD5Hash();
|
||||
|
||||
// update existing or populate new file's filename.
|
||||
fileInfo.Filename = beatmapInfo.Path;
|
||||
|
||||
stream.Seek(0, SeekOrigin.Begin);
|
||||
ReplaceFile(setInfo, fileInfo, stream);
|
||||
}
|
||||
}
|
||||
|
||||
WorkingBeatmapCache?.Invalidate(info);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Perform a lookup query on available <see cref="BeatmapSetInfo"/>s.
|
||||
/// </summary>
|
||||
/// <param name="query">The query.</param>
|
||||
/// <returns>The first result for the provided query, or null if no results were found.</returns>
|
||||
public BeatmapSetInfo QueryBeatmapSet(Expression<Func<BeatmapSetInfo, bool>> query) => beatmaps.ConsumableItems.AsNoTracking().FirstOrDefault(query);
|
||||
|
||||
protected override bool CanSkipImport(BeatmapSetInfo existing, BeatmapSetInfo import)
|
||||
{
|
||||
if (!base.CanSkipImport(existing, import))
|
||||
return false;
|
||||
|
||||
return existing.Beatmaps.Any(b => b.OnlineBeatmapID != null);
|
||||
}
|
||||
|
||||
protected override bool CanReuseExisting(BeatmapSetInfo existing, BeatmapSetInfo import)
|
||||
{
|
||||
if (!base.CanReuseExisting(existing, import))
|
||||
return false;
|
||||
|
||||
var existingIds = existing.Beatmaps.Select(b => b.OnlineBeatmapID).OrderBy(i => i);
|
||||
var importIds = import.Beatmaps.Select(b => b.OnlineBeatmapID).OrderBy(i => i);
|
||||
|
||||
// force re-import if we are not in a sane state.
|
||||
return existing.OnlineBeatmapSetID == import.OnlineBeatmapSetID && existingIds.SequenceEqual(importIds);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a list of all usable <see cref="BeatmapSetInfo"/>s.
|
||||
/// </summary>
|
||||
/// <returns>A list of available <see cref="BeatmapSetInfo"/>.</returns>
|
||||
public List<BeatmapSetInfo> GetAllUsableBeatmapSets(IncludedDetails includes = IncludedDetails.All, bool includeProtected = false) =>
|
||||
GetAllUsableBeatmapSetsEnumerable(includes, includeProtected).ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Returns a list of all usable <see cref="BeatmapSetInfo"/>s. Note that files are not populated.
|
||||
/// </summary>
|
||||
/// <param name="includes">The level of detail to include in the returned objects.</param>
|
||||
/// <param name="includeProtected">Whether to include protected (system) beatmaps. These should not be included for gameplay playable use cases.</param>
|
||||
/// <returns>A list of available <see cref="BeatmapSetInfo"/>.</returns>
|
||||
public IEnumerable<BeatmapSetInfo> GetAllUsableBeatmapSetsEnumerable(IncludedDetails includes, bool includeProtected = false)
|
||||
{
|
||||
IQueryable<BeatmapSetInfo> queryable;
|
||||
|
||||
switch (includes)
|
||||
{
|
||||
case IncludedDetails.Minimal:
|
||||
queryable = beatmaps.BeatmapSetsOverview;
|
||||
break;
|
||||
|
||||
case IncludedDetails.AllButRuleset:
|
||||
queryable = beatmaps.BeatmapSetsWithoutRuleset;
|
||||
break;
|
||||
|
||||
case IncludedDetails.AllButFiles:
|
||||
queryable = beatmaps.BeatmapSetsWithoutFiles;
|
||||
break;
|
||||
|
||||
default:
|
||||
queryable = beatmaps.ConsumableItems;
|
||||
break;
|
||||
}
|
||||
|
||||
// AsEnumerable used here to avoid applying the WHERE in sql. When done so, ef core 2.x uses an incorrect ORDER BY
|
||||
// clause which causes queries to take 5-10x longer.
|
||||
// TODO: remove if upgrading to EF core 3.x.
|
||||
return queryable.AsEnumerable().Where(s => !s.DeletePending && (includeProtected || !s.Protected));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Perform a lookup query on available <see cref="BeatmapSetInfo"/>s.
|
||||
/// </summary>
|
||||
/// <param name="query">The query.</param>
|
||||
/// <param name="includes">The level of detail to include in the returned objects.</param>
|
||||
/// <returns>Results from the provided query.</returns>
|
||||
public IEnumerable<BeatmapSetInfo> QueryBeatmapSets(Expression<Func<BeatmapSetInfo, bool>> query, IncludedDetails includes = IncludedDetails.All)
|
||||
{
|
||||
IQueryable<BeatmapSetInfo> queryable;
|
||||
|
||||
switch (includes)
|
||||
{
|
||||
case IncludedDetails.Minimal:
|
||||
queryable = beatmaps.BeatmapSetsOverview;
|
||||
break;
|
||||
|
||||
case IncludedDetails.AllButRuleset:
|
||||
queryable = beatmaps.BeatmapSetsWithoutRuleset;
|
||||
break;
|
||||
|
||||
case IncludedDetails.AllButFiles:
|
||||
queryable = beatmaps.BeatmapSetsWithoutFiles;
|
||||
break;
|
||||
|
||||
default:
|
||||
queryable = beatmaps.ConsumableItems;
|
||||
break;
|
||||
}
|
||||
|
||||
return queryable.AsNoTracking().Where(query);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Perform a lookup query on available <see cref="BeatmapInfo"/>s.
|
||||
/// </summary>
|
||||
/// <param name="query">The query.</param>
|
||||
/// <returns>The first result for the provided query, or null if no results were found.</returns>
|
||||
public BeatmapInfo QueryBeatmap(Expression<Func<BeatmapInfo, bool>> query) => beatmaps.Beatmaps.AsNoTracking().FirstOrDefault(query);
|
||||
|
||||
/// <summary>
|
||||
/// Perform a lookup query on available <see cref="BeatmapInfo"/>s.
|
||||
/// </summary>
|
||||
/// <param name="query">The query.</param>
|
||||
/// <returns>Results from the provided query.</returns>
|
||||
public IQueryable<BeatmapInfo> QueryBeatmaps(Expression<Func<BeatmapInfo, bool>> query) => beatmaps.Beatmaps.AsNoTracking().Where(query);
|
||||
|
||||
public override string HumanisedModelName => "beatmap";
|
||||
|
||||
protected override bool CheckLocalAvailability(BeatmapSetInfo model, IQueryable<BeatmapSetInfo> items)
|
||||
=> base.CheckLocalAvailability(model, items)
|
||||
|| (model.OnlineBeatmapSetID != null && items.Any(b => b.OnlineBeatmapSetID == model.OnlineBeatmapSetID));
|
||||
|
||||
protected override BeatmapSetInfo CreateModel(ArchiveReader reader)
|
||||
{
|
||||
// let's make sure there are actually .osu files to import.
|
||||
string mapName = reader.Filenames.FirstOrDefault(f => f.EndsWith(".osu", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (string.IsNullOrEmpty(mapName))
|
||||
{
|
||||
Logger.Log($"No beatmap files found in the beatmap archive ({reader.Name}).", LoggingTarget.Database);
|
||||
return null;
|
||||
}
|
||||
|
||||
Beatmap beatmap;
|
||||
using (var stream = new LineBufferedReader(reader.GetStream(mapName)))
|
||||
beatmap = Decoder.GetDecoder<Beatmap>(stream).Decode(stream);
|
||||
|
||||
return new BeatmapSetInfo
|
||||
{
|
||||
OnlineBeatmapSetID = beatmap.BeatmapInfo.BeatmapSet?.OnlineBeatmapSetID,
|
||||
Beatmaps = new List<BeatmapInfo>(),
|
||||
Metadata = beatmap.Metadata,
|
||||
DateAdded = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create all required <see cref="BeatmapInfo"/>s for the provided archive.
|
||||
/// </summary>
|
||||
private List<BeatmapInfo> createBeatmapDifficulties(List<BeatmapSetFileInfo> files)
|
||||
{
|
||||
var beatmapInfos = new List<BeatmapInfo>();
|
||||
|
||||
foreach (var file in files.Where(f => f.Filename.EndsWith(".osu", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
using (var raw = Files.Store.GetStream(file.FileInfo.StoragePath))
|
||||
using (var ms = new MemoryStream()) // we need a memory stream so we can seek
|
||||
using (var sr = new LineBufferedReader(ms))
|
||||
{
|
||||
raw.CopyTo(ms);
|
||||
ms.Position = 0;
|
||||
|
||||
var decoder = Decoder.GetDecoder<Beatmap>(sr);
|
||||
IBeatmap beatmap = decoder.Decode(sr);
|
||||
|
||||
string hash = ms.ComputeSHA2Hash();
|
||||
|
||||
if (beatmapInfos.Any(b => b.Hash == hash))
|
||||
continue;
|
||||
|
||||
beatmap.BeatmapInfo.Path = file.Filename;
|
||||
beatmap.BeatmapInfo.Hash = hash;
|
||||
beatmap.BeatmapInfo.MD5Hash = ms.ComputeMD5Hash();
|
||||
|
||||
var ruleset = rulesets.GetRuleset(beatmap.BeatmapInfo.RulesetID);
|
||||
beatmap.BeatmapInfo.Ruleset = ruleset;
|
||||
|
||||
// TODO: this should be done in a better place once we actually need to dynamically update it.
|
||||
beatmap.BeatmapInfo.StarDifficulty = ruleset?.CreateInstance().CreateDifficultyCalculator(new DummyConversionBeatmap(beatmap)).Calculate().StarRating ?? 0;
|
||||
beatmap.BeatmapInfo.Length = calculateLength(beatmap);
|
||||
beatmap.BeatmapInfo.BPM = 60000 / beatmap.GetMostCommonBeatLength();
|
||||
|
||||
beatmapInfos.Add(beatmap.BeatmapInfo);
|
||||
}
|
||||
}
|
||||
|
||||
return beatmapInfos;
|
||||
}
|
||||
|
||||
private double calculateLength(IBeatmap b)
|
||||
{
|
||||
if (!b.HitObjects.Any())
|
||||
return 0;
|
||||
|
||||
var lastObject = b.HitObjects.Last();
|
||||
|
||||
//TODO: this isn't always correct (consider mania where a non-last object may last for longer than the last in the list).
|
||||
double endTime = lastObject.GetEndTime();
|
||||
double startTime = b.HitObjects.First().StartTime;
|
||||
|
||||
return endTime - startTime;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A dummy WorkingBeatmap for the purpose of retrieving a beatmap for star difficulty calculation.
|
||||
/// </summary>
|
||||
private class DummyConversionBeatmap : WorkingBeatmap
|
||||
{
|
||||
private readonly IBeatmap beatmap;
|
||||
|
||||
public DummyConversionBeatmap(IBeatmap beatmap)
|
||||
: base(beatmap.BeatmapInfo, null)
|
||||
{
|
||||
this.beatmap = beatmap;
|
||||
}
|
||||
|
||||
protected override IBeatmap GetBeatmap() => beatmap;
|
||||
protected override Texture GetBackground() => null;
|
||||
protected override Track GetBeatmapTrack() => null;
|
||||
protected internal override ISkin GetSkin() => null;
|
||||
public override Stream GetStream(string storagePath) => null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The level of detail to include in database results.
|
||||
/// </summary>
|
||||
public enum IncludedDetails
|
||||
{
|
||||
/// <summary>
|
||||
/// Only include beatmap difficulties and set level metadata.
|
||||
/// </summary>
|
||||
Minimal,
|
||||
|
||||
/// <summary>
|
||||
/// Include all difficulties, rulesets, difficulty metadata but no files.
|
||||
/// </summary>
|
||||
AllButFiles,
|
||||
|
||||
/// <summary>
|
||||
/// Include everything except ruleset. Used for cases where we aren't sure the ruleset is present but still want to consume the beatmap.
|
||||
/// </summary>
|
||||
AllButRuleset,
|
||||
|
||||
/// <summary>
|
||||
/// Include everything.
|
||||
/// </summary>
|
||||
All
|
||||
}
|
||||
}
|
222
osu.Game/Beatmaps/BeatmapOnlineLookupQueue.cs
Normal file
222
osu.Game/Beatmaps/BeatmapOnlineLookupQueue.cs
Normal file
@ -0,0 +1,222 @@
|
||||
// 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.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using osu.Framework.Development;
|
||||
using osu.Framework.IO.Network;
|
||||
using osu.Framework.Logging;
|
||||
using osu.Framework.Platform;
|
||||
using osu.Framework.Testing;
|
||||
using osu.Framework.Threading;
|
||||
using osu.Game.Database;
|
||||
using osu.Game.Online.API;
|
||||
using osu.Game.Online.API.Requests;
|
||||
using SharpCompress.Compressors;
|
||||
using SharpCompress.Compressors.BZip2;
|
||||
|
||||
namespace osu.Game.Beatmaps
|
||||
{
|
||||
/// <summary>
|
||||
/// A component which handles population of online IDs for beatmaps using a two part lookup procedure.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// On creating the component, a copy of a database containing metadata for a large subset of beatmaps (stored to <see cref="cache_database_name"/>) will be downloaded if not already present locally.
|
||||
/// This will always be checked before doing a second online query to get required metadata.
|
||||
/// </remarks>
|
||||
[ExcludeFromDynamicCompile]
|
||||
public class BeatmapOnlineLookupQueue : IDisposable
|
||||
{
|
||||
private readonly IAPIProvider api;
|
||||
private readonly Storage storage;
|
||||
|
||||
private const int update_queue_request_concurrency = 4;
|
||||
|
||||
private readonly ThreadedTaskScheduler updateScheduler = new ThreadedTaskScheduler(update_queue_request_concurrency, nameof(BeatmapOnlineLookupQueue));
|
||||
|
||||
private FileWebRequest cacheDownloadRequest;
|
||||
|
||||
private const string cache_database_name = "online.db";
|
||||
|
||||
public BeatmapOnlineLookupQueue(IAPIProvider api, Storage storage)
|
||||
{
|
||||
this.api = api;
|
||||
this.storage = storage;
|
||||
|
||||
// avoid downloading / using cache for unit tests.
|
||||
if (!DebugUtils.IsNUnitRunning && !storage.Exists(cache_database_name))
|
||||
prepareLocalCache();
|
||||
}
|
||||
|
||||
public Task UpdateAsync(BeatmapSetInfo beatmapSet, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.WhenAll(beatmapSet.Beatmaps.Select(b => UpdateAsync(beatmapSet, b, cancellationToken)).ToArray());
|
||||
}
|
||||
|
||||
// todo: expose this when we need to do individual difficulty lookups.
|
||||
protected Task UpdateAsync(BeatmapSetInfo beatmapSet, BeatmapInfo beatmap, CancellationToken cancellationToken)
|
||||
=> Task.Factory.StartNew(() => lookup(beatmapSet, beatmap), cancellationToken, TaskCreationOptions.HideScheduler | TaskCreationOptions.RunContinuationsAsynchronously, updateScheduler);
|
||||
|
||||
private void lookup(BeatmapSetInfo set, BeatmapInfo beatmap)
|
||||
{
|
||||
if (checkLocalCache(set, beatmap))
|
||||
return;
|
||||
|
||||
if (api?.State.Value != APIState.Online)
|
||||
return;
|
||||
|
||||
var req = new GetBeatmapRequest(beatmap);
|
||||
|
||||
req.Failure += fail;
|
||||
|
||||
try
|
||||
{
|
||||
// intentionally blocking to limit web request concurrency
|
||||
api.Perform(req);
|
||||
|
||||
var res = req.Result;
|
||||
|
||||
if (res != null)
|
||||
{
|
||||
beatmap.Status = res.Status;
|
||||
beatmap.BeatmapSet.Status = res.BeatmapSet.Status;
|
||||
beatmap.BeatmapSet.OnlineBeatmapSetID = res.OnlineBeatmapSetID;
|
||||
beatmap.OnlineBeatmapID = res.OnlineBeatmapID;
|
||||
|
||||
if (beatmap.Metadata != null)
|
||||
beatmap.Metadata.AuthorID = res.AuthorID;
|
||||
|
||||
if (beatmap.BeatmapSet.Metadata != null)
|
||||
beatmap.BeatmapSet.Metadata.AuthorID = res.AuthorID;
|
||||
|
||||
logForModel(set, $"Online retrieval mapped {beatmap} to {res.OnlineBeatmapSetID} / {res.OnlineBeatmapID}.");
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
fail(e);
|
||||
}
|
||||
|
||||
void fail(Exception e)
|
||||
{
|
||||
beatmap.OnlineBeatmapID = null;
|
||||
logForModel(set, $"Online retrieval failed for {beatmap} ({e.Message})");
|
||||
}
|
||||
}
|
||||
|
||||
private void prepareLocalCache()
|
||||
{
|
||||
string cacheFilePath = storage.GetFullPath(cache_database_name);
|
||||
string compressedCacheFilePath = $"{cacheFilePath}.bz2";
|
||||
|
||||
cacheDownloadRequest = new FileWebRequest(compressedCacheFilePath, $"https://assets.ppy.sh/client-resources/{cache_database_name}.bz2?{DateTimeOffset.UtcNow:yyyyMMdd}");
|
||||
|
||||
cacheDownloadRequest.Failed += ex =>
|
||||
{
|
||||
File.Delete(compressedCacheFilePath);
|
||||
File.Delete(cacheFilePath);
|
||||
|
||||
Logger.Log($"{nameof(BeatmapOnlineLookupQueue)}'s online cache download failed: {ex}", LoggingTarget.Database);
|
||||
};
|
||||
|
||||
cacheDownloadRequest.Finished += () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var stream = File.OpenRead(cacheDownloadRequest.Filename))
|
||||
using (var outStream = File.OpenWrite(cacheFilePath))
|
||||
using (var bz2 = new BZip2Stream(stream, CompressionMode.Decompress, false))
|
||||
bz2.CopyTo(outStream);
|
||||
|
||||
// set to null on completion to allow lookups to begin using the new source
|
||||
cacheDownloadRequest = null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Log($"{nameof(BeatmapOnlineLookupQueue)}'s online cache extraction failed: {ex}", LoggingTarget.Database);
|
||||
File.Delete(cacheFilePath);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(compressedCacheFilePath);
|
||||
}
|
||||
};
|
||||
|
||||
cacheDownloadRequest.PerformAsync();
|
||||
}
|
||||
|
||||
private bool checkLocalCache(BeatmapSetInfo set, BeatmapInfo beatmap)
|
||||
{
|
||||
// download is in progress (or was, and failed).
|
||||
if (cacheDownloadRequest != null)
|
||||
return false;
|
||||
|
||||
// database is unavailable.
|
||||
if (!storage.Exists(cache_database_name))
|
||||
return false;
|
||||
|
||||
if (string.IsNullOrEmpty(beatmap.MD5Hash)
|
||||
&& string.IsNullOrEmpty(beatmap.Path)
|
||||
&& beatmap.OnlineBeatmapID == null)
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
using (var db = new SqliteConnection(DatabaseContextFactory.CreateDatabaseConnectionString("online.db", storage)))
|
||||
{
|
||||
db.Open();
|
||||
|
||||
using (var cmd = db.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "SELECT beatmapset_id, beatmap_id, approved, user_id FROM osu_beatmaps WHERE checksum = @MD5Hash OR beatmap_id = @OnlineBeatmapID OR filename = @Path";
|
||||
|
||||
cmd.Parameters.Add(new SqliteParameter("@MD5Hash", beatmap.MD5Hash));
|
||||
cmd.Parameters.Add(new SqliteParameter("@OnlineBeatmapID", beatmap.OnlineBeatmapID ?? (object)DBNull.Value));
|
||||
cmd.Parameters.Add(new SqliteParameter("@Path", beatmap.Path));
|
||||
|
||||
using (var reader = cmd.ExecuteReader())
|
||||
{
|
||||
if (reader.Read())
|
||||
{
|
||||
var status = (BeatmapSetOnlineStatus)reader.GetByte(2);
|
||||
|
||||
beatmap.Status = status;
|
||||
beatmap.BeatmapSet.Status = status;
|
||||
beatmap.BeatmapSet.OnlineBeatmapSetID = reader.GetInt32(0);
|
||||
beatmap.OnlineBeatmapID = reader.GetInt32(1);
|
||||
|
||||
if (beatmap.Metadata != null)
|
||||
beatmap.Metadata.AuthorID = reader.GetInt32(3);
|
||||
|
||||
if (beatmap.BeatmapSet.Metadata != null)
|
||||
beatmap.BeatmapSet.Metadata.AuthorID = reader.GetInt32(3);
|
||||
|
||||
logForModel(set, $"Cached local retrieval for {beatmap}.");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logForModel(set, $"Cached local retrieval for {beatmap} failed with {ex}.");
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private void logForModel(BeatmapSetInfo set, string message) =>
|
||||
ArchiveModelManager<BeatmapSetInfo, BeatmapSetFileInfo>.LogForModel(set, $"[{nameof(BeatmapOnlineLookupQueue)}] {message}");
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
cacheDownloadRequest?.Dispose();
|
||||
updateScheduler?.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
15
osu.Game/Beatmaps/IWorkingBeatmapCache.cs
Normal file
15
osu.Game/Beatmaps/IWorkingBeatmapCache.cs
Normal file
@ -0,0 +1,15 @@
|
||||
// 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.
|
||||
|
||||
namespace osu.Game.Beatmaps
|
||||
{
|
||||
public interface IWorkingBeatmapCache
|
||||
{
|
||||
/// <summary>
|
||||
/// Retrieve a <see cref="WorkingBeatmap"/> instance for the provided <see cref="BeatmapInfo"/>
|
||||
/// </summary>
|
||||
/// <param name="beatmapInfo">The beatmap to lookup.</param>
|
||||
/// <returns>A <see cref="WorkingBeatmap"/> instance correlating to the provided <see cref="BeatmapInfo"/>.</returns>
|
||||
WorkingBeatmap GetWorkingBeatmap(BeatmapInfo beatmapInfo);
|
||||
}
|
||||
}
|
@ -1,12 +1,18 @@
|
||||
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||
// 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.Diagnostics.CodeAnalysis;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using JetBrains.Annotations;
|
||||
using osu.Framework.Audio;
|
||||
using osu.Framework.Audio.Track;
|
||||
using osu.Framework.Graphics.Textures;
|
||||
using osu.Framework.IO.Stores;
|
||||
using osu.Framework.Lists;
|
||||
using osu.Framework.Logging;
|
||||
using osu.Framework.Platform;
|
||||
using osu.Framework.Statistics;
|
||||
using osu.Framework.Testing;
|
||||
using osu.Game.Beatmaps.Formats;
|
||||
using osu.Game.IO;
|
||||
@ -15,8 +21,96 @@ using osu.Game.Storyboards;
|
||||
|
||||
namespace osu.Game.Beatmaps
|
||||
{
|
||||
public partial class BeatmapManager
|
||||
public class WorkingBeatmapCache : IBeatmapResourceProvider, IWorkingBeatmapCache
|
||||
{
|
||||
private readonly WeakList<BeatmapManagerWorkingBeatmap> workingCache = new WeakList<BeatmapManagerWorkingBeatmap>();
|
||||
|
||||
/// <summary>
|
||||
/// A default representation of a WorkingBeatmap to use when no beatmap is available.
|
||||
/// </summary>
|
||||
public readonly WorkingBeatmap DefaultBeatmap;
|
||||
|
||||
public BeatmapModelManager BeatmapManager { private get; set; }
|
||||
|
||||
private readonly AudioManager audioManager;
|
||||
private readonly IResourceStore<byte[]> resources;
|
||||
private readonly LargeTextureStore largeTextureStore;
|
||||
private readonly ITrackStore trackStore;
|
||||
private readonly IResourceStore<byte[]> files;
|
||||
|
||||
[CanBeNull]
|
||||
private readonly GameHost host;
|
||||
|
||||
public WorkingBeatmapCache([NotNull] AudioManager audioManager, IResourceStore<byte[]> resources, IResourceStore<byte[]> files, WorkingBeatmap defaultBeatmap = null, GameHost host = null)
|
||||
{
|
||||
DefaultBeatmap = defaultBeatmap;
|
||||
|
||||
this.audioManager = audioManager;
|
||||
this.resources = resources;
|
||||
this.host = host;
|
||||
this.files = files;
|
||||
largeTextureStore = new LargeTextureStore(host?.CreateTextureLoaderStore(files));
|
||||
trackStore = audioManager.GetTrackStore(files);
|
||||
}
|
||||
|
||||
public void Invalidate(BeatmapSetInfo info)
|
||||
{
|
||||
if (info.Beatmaps == null) return;
|
||||
|
||||
foreach (var b in info.Beatmaps)
|
||||
Invalidate(b);
|
||||
}
|
||||
|
||||
public void Invalidate(BeatmapInfo info)
|
||||
{
|
||||
lock (workingCache)
|
||||
{
|
||||
var working = workingCache.FirstOrDefault(w => w.BeatmapInfo?.ID == info.ID);
|
||||
if (working != null)
|
||||
workingCache.Remove(working);
|
||||
}
|
||||
}
|
||||
|
||||
public virtual WorkingBeatmap GetWorkingBeatmap(BeatmapInfo beatmapInfo)
|
||||
{
|
||||
// if there are no files, presume the full beatmap info has not yet been fetched from the database.
|
||||
if (beatmapInfo?.BeatmapSet?.Files.Count == 0)
|
||||
{
|
||||
int lookupId = beatmapInfo.ID;
|
||||
beatmapInfo = BeatmapManager.QueryBeatmap(b => b.ID == lookupId);
|
||||
}
|
||||
|
||||
if (beatmapInfo?.BeatmapSet == null)
|
||||
return DefaultBeatmap;
|
||||
|
||||
lock (workingCache)
|
||||
{
|
||||
var working = workingCache.FirstOrDefault(w => w.BeatmapInfo?.ID == beatmapInfo.ID);
|
||||
if (working != null)
|
||||
return working;
|
||||
|
||||
beatmapInfo.Metadata ??= beatmapInfo.BeatmapSet.Metadata;
|
||||
|
||||
workingCache.Add(working = new BeatmapManagerWorkingBeatmap(beatmapInfo, this));
|
||||
|
||||
// best effort; may be higher than expected.
|
||||
GlobalStatistics.Get<int>(nameof(Beatmaps), $"Cached {nameof(WorkingBeatmap)}s").Value = workingCache.Count();
|
||||
|
||||
return working;
|
||||
}
|
||||
}
|
||||
|
||||
#region IResourceStorageProvider
|
||||
|
||||
TextureStore IBeatmapResourceProvider.LargeTextureStore => largeTextureStore;
|
||||
ITrackStore IBeatmapResourceProvider.Tracks => trackStore;
|
||||
AudioManager IStorageResourceProvider.AudioManager => audioManager;
|
||||
IResourceStore<byte[]> IStorageResourceProvider.Files => files;
|
||||
IResourceStore<byte[]> IStorageResourceProvider.Resources => resources;
|
||||
IResourceStore<TextureUpload> IStorageResourceProvider.CreateTextureLoaderStore(IResourceStore<byte[]> underlyingStore) => host?.CreateTextureLoaderStore(underlyingStore);
|
||||
|
||||
#endregion
|
||||
|
||||
[ExcludeFromDynamicCompile]
|
||||
private class BeatmapManagerWorkingBeatmap : WorkingBeatmap
|
||||
{
|
@ -14,6 +14,7 @@ using osu.Framework.Graphics;
|
||||
using osu.Framework.Logging;
|
||||
using osu.Framework.Platform;
|
||||
using osu.Game.Beatmaps;
|
||||
using osu.Game.Database;
|
||||
using osu.Game.IO;
|
||||
using osu.Game.IO.Legacy;
|
||||
using osu.Game.Overlays.Notifications;
|
||||
@ -27,7 +28,7 @@ namespace osu.Game.Collections
|
||||
/// 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
|
||||
public class CollectionManager : Component, IPostNotifications
|
||||
{
|
||||
/// <summary>
|
||||
/// Database version in stable-compatible YYYYMMDD format.
|
||||
@ -106,9 +107,6 @@ namespace osu.Game.Collections
|
||||
backgroundSave();
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
/// Set an endpoint for notifications to be posted to.
|
||||
/// </summary>
|
||||
public Action<Notification> PostNotification { protected get; set; }
|
||||
|
||||
/// <summary>
|
||||
|
@ -30,7 +30,7 @@ namespace osu.Game.Database
|
||||
/// </summary>
|
||||
/// <typeparam name="TModel">The model type.</typeparam>
|
||||
/// <typeparam name="TFileModel">The associated file join type.</typeparam>
|
||||
public abstract class ArchiveModelManager<TModel, TFileModel> : ICanAcceptFiles, IModelManager<TModel>
|
||||
public abstract class ArchiveModelManager<TModel, TFileModel> : ICanAcceptFiles, IModelManager<TModel>, IModelFileManager<TModel, TFileModel>, IPresentImports<TModel>
|
||||
where TModel : class, IHasFiles<TFileModel>, IHasPrimaryKey, ISoftDelete
|
||||
where TFileModel : class, INamedFileInfo, new()
|
||||
{
|
||||
@ -57,9 +57,6 @@ namespace osu.Game.Database
|
||||
/// </summary>
|
||||
private static readonly ThreadedTaskScheduler import_scheduler_low_priority = new ThreadedTaskScheduler(import_queue_request_concurrency, nameof(ArchiveModelManager<TModel, TFileModel>));
|
||||
|
||||
/// <summary>
|
||||
/// Set an endpoint for notifications to be posted to.
|
||||
/// </summary>
|
||||
public Action<Notification> PostNotification { protected get; set; }
|
||||
|
||||
/// <summary>
|
||||
@ -135,7 +132,7 @@ namespace osu.Game.Database
|
||||
return Import(notification, tasks);
|
||||
}
|
||||
|
||||
protected async Task<IEnumerable<TModel>> Import(ProgressNotification notification, params ImportTask[] tasks)
|
||||
public async Task<IEnumerable<TModel>> Import(ProgressNotification notification, params ImportTask[] tasks)
|
||||
{
|
||||
if (tasks.Length == 0)
|
||||
{
|
||||
@ -227,7 +224,7 @@ namespace osu.Game.Database
|
||||
/// <param name="lowPriority">Whether this is a low priority import.</param>
|
||||
/// <param name="cancellationToken">An optional cancellation token.</param>
|
||||
/// <returns>The imported model, if successful.</returns>
|
||||
internal async Task<TModel> Import(ImportTask task, bool lowPriority = false, CancellationToken cancellationToken = default)
|
||||
public async Task<TModel> Import(ImportTask task, bool lowPriority = false, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
@ -252,10 +249,7 @@ namespace osu.Game.Database
|
||||
return import;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fired when the user requests to view the resulting import.
|
||||
/// </summary>
|
||||
public Action<IEnumerable<TModel>> PresentImport;
|
||||
public Action<IEnumerable<TModel>> PresentImport { protected get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Silently import an item from an <see cref="ArchiveReader"/>.
|
||||
@ -479,7 +473,7 @@ namespace osu.Game.Database
|
||||
/// </summary>
|
||||
/// <param name="model">The item to export.</param>
|
||||
/// <param name="outputStream">The output stream to export to.</param>
|
||||
protected virtual void ExportModelTo(TModel model, Stream outputStream)
|
||||
public virtual void ExportModelTo(TModel model, Stream outputStream)
|
||||
{
|
||||
using (var archive = ZipArchive.Create())
|
||||
{
|
||||
@ -745,9 +739,6 @@ namespace osu.Game.Database
|
||||
/// <returns>Whether to perform deletion.</returns>
|
||||
protected virtual bool ShouldDeleteArchive(string path) => false;
|
||||
|
||||
/// <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>
|
||||
public Task ImportFromStableAsync(StableStorage stableStorage)
|
||||
{
|
||||
var storage = PrepareStableStorage(stableStorage);
|
||||
@ -805,6 +796,17 @@ namespace osu.Game.Database
|
||||
/// <returns>An existing model which matches the criteria to skip importing, else null.</returns>
|
||||
protected TModel CheckForExisting(TModel model) => model.Hash == null ? null : ModelStore.ConsumableItems.FirstOrDefault(b => b.Hash == model.Hash);
|
||||
|
||||
public bool IsAvailableLocally(TModel model) => CheckLocalAvailability(model, ModelStore.ConsumableItems.Where(m => !m.DeletePending));
|
||||
|
||||
/// <summary>
|
||||
/// Performs implementation specific comparisons to determine whether a given model is present in the local store.
|
||||
/// </summary>
|
||||
/// <param name="model">The <typeparamref name="TModel"/> whose existence needs to be checked.</param>
|
||||
/// <param name="items">The usable items present in the store.</param>
|
||||
/// <returns>Whether the <typeparamref name="TModel"/> exists.</returns>
|
||||
protected virtual bool CheckLocalAvailability(TModel model, IQueryable<TModel> items)
|
||||
=> model.ID > 0 && items.Any(i => i.ID == model.ID && i.Files.Any());
|
||||
|
||||
/// <summary>
|
||||
/// Whether import can be skipped after finding an existing import early in the process.
|
||||
/// Only valid when <see cref="ComputeHash"/> is not overridden.
|
||||
@ -841,7 +843,7 @@ namespace osu.Game.Database
|
||||
|
||||
private DbSet<TModel> queryModel() => ContextFactory.Get().Set<TModel>();
|
||||
|
||||
protected virtual string HumanisedModelName => $"{typeof(TModel).Name.Replace(@"Info", "").ToLower()}";
|
||||
public virtual string HumanisedModelName => $"{typeof(TModel).Name.Replace(@"Info", "").ToLower()}";
|
||||
|
||||
#region Event handling / delaying
|
||||
|
||||
|
@ -11,7 +11,7 @@ namespace osu.Game.Database
|
||||
/// Represents a <see cref="IModelManager{TModel}"/> that can download new models from an external source.
|
||||
/// </summary>
|
||||
/// <typeparam name="TModel">The model type.</typeparam>
|
||||
public interface IModelDownloader<TModel> : IModelManager<TModel>
|
||||
public interface IModelDownloader<TModel> : IPostNotifications
|
||||
where TModel : class
|
||||
{
|
||||
/// <summary>
|
||||
@ -26,13 +26,6 @@ namespace osu.Game.Database
|
||||
/// </summary>
|
||||
IBindable<WeakReference<ArchiveDownloadRequest<TModel>>> DownloadFailed { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a given <typeparamref name="TModel"/> is already available in the local store.
|
||||
/// </summary>
|
||||
/// <param name="model">The <typeparamref name="TModel"/> whose existence needs to be checked.</param>
|
||||
/// <returns>Whether the <typeparamref name="TModel"/> exists.</returns>
|
||||
bool IsAvailableLocally(TModel model);
|
||||
|
||||
/// <summary>
|
||||
/// Begin a download for the requested <typeparamref name="TModel"/>.
|
||||
/// </summary>
|
||||
|
36
osu.Game/Database/IModelFileManager.cs
Normal file
36
osu.Game/Database/IModelFileManager.cs
Normal file
@ -0,0 +1,36 @@
|
||||
// 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.IO;
|
||||
|
||||
namespace osu.Game.Database
|
||||
{
|
||||
public interface IModelFileManager<in TModel, in TFileModel>
|
||||
where TModel : class
|
||||
where TFileModel : class
|
||||
{
|
||||
/// <summary>
|
||||
/// Replace an existing file with a new version.
|
||||
/// </summary>
|
||||
/// <param name="model">The item to operate on.</param>
|
||||
/// <param name="file">The existing file to be replaced.</param>
|
||||
/// <param name="contents">The new file contents.</param>
|
||||
/// <param name="filename">An optional filename for the new file. Will use the previous filename if not specified.</param>
|
||||
void ReplaceFile(TModel model, TFileModel file, Stream contents, string filename = null);
|
||||
|
||||
/// <summary>
|
||||
/// Delete an existing file.
|
||||
/// </summary>
|
||||
/// <param name="model">The item to operate on.</param>
|
||||
/// <param name="file">The existing file to be deleted.</param>
|
||||
void DeleteFile(TModel model, TFileModel file);
|
||||
|
||||
/// <summary>
|
||||
/// Add a new file.
|
||||
/// </summary>
|
||||
/// <param name="model">The item to operate on.</param>
|
||||
/// <param name="contents">The new file contents.</param>
|
||||
/// <param name="filename">The filename for the new file.</param>
|
||||
void AddFile(TModel model, Stream contents, string filename);
|
||||
}
|
||||
}
|
@ -1,8 +1,15 @@
|
||||
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||
// 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;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using osu.Framework.Bindables;
|
||||
using osu.Game.IO;
|
||||
using osu.Game.IO.Archives;
|
||||
using osu.Game.Overlays.Notifications;
|
||||
|
||||
namespace osu.Game.Database
|
||||
{
|
||||
@ -10,7 +17,7 @@ namespace osu.Game.Database
|
||||
/// Represents a model manager that publishes events when <typeparamref name="TModel"/>s are added or removed.
|
||||
/// </summary>
|
||||
/// <typeparam name="TModel">The model type.</typeparam>
|
||||
public interface IModelManager<TModel>
|
||||
public interface IModelManager<TModel> : IPostNotifications
|
||||
where TModel : class
|
||||
{
|
||||
/// <summary>
|
||||
@ -24,5 +31,109 @@ namespace osu.Game.Database
|
||||
/// This is not thread-safe and should be scheduled locally if consumed from a drawable component.
|
||||
/// </summary>
|
||||
IBindable<WeakReference<TModel>> ItemRemoved { get; }
|
||||
|
||||
/// <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>
|
||||
Task ImportFromStableAsync(StableStorage stableStorage);
|
||||
|
||||
/// <summary>
|
||||
/// Exports an item to a legacy (.zip based) package.
|
||||
/// </summary>
|
||||
/// <param name="item">The item to export.</param>
|
||||
void Export(TModel item);
|
||||
|
||||
/// <summary>
|
||||
/// Exports an item to the given output stream.
|
||||
/// </summary>
|
||||
/// <param name="model">The item to export.</param>
|
||||
/// <param name="outputStream">The output stream to export to.</param>
|
||||
void ExportModelTo(TModel model, Stream outputStream);
|
||||
|
||||
/// <summary>
|
||||
/// Perform an update of the specified item.
|
||||
/// TODO: Support file additions/removals.
|
||||
/// </summary>
|
||||
/// <param name="item">The item to update.</param>
|
||||
void Update(TModel item);
|
||||
|
||||
/// <summary>
|
||||
/// Delete an item from the manager.
|
||||
/// Is a no-op for already deleted items.
|
||||
/// </summary>
|
||||
/// <param name="item">The item to delete.</param>
|
||||
/// <returns>false if no operation was performed</returns>
|
||||
bool Delete(TModel item);
|
||||
|
||||
/// <summary>
|
||||
/// Delete multiple items.
|
||||
/// This will post notifications tracking progress.
|
||||
/// </summary>
|
||||
void Delete(List<TModel> items, bool silent = false);
|
||||
|
||||
/// <summary>
|
||||
/// Restore multiple items that were previously deleted.
|
||||
/// This will post notifications tracking progress.
|
||||
/// </summary>
|
||||
void Undelete(List<TModel> items, bool silent = false);
|
||||
|
||||
/// <summary>
|
||||
/// Restore an item that was previously deleted. Is a no-op if the item is not in a deleted state, or has its protected flag set.
|
||||
/// </summary>
|
||||
/// <param name="item">The item to restore</param>
|
||||
void Undelete(TModel item);
|
||||
|
||||
/// <summary>
|
||||
/// Import one or more <typeparamref name="TModel"/> items from filesystem <paramref name="paths"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This will be treated as a low priority import if more than one path is specified; use <see cref="ArchiveModelManager{TModel,TFileModel}.Import(osu.Game.Database.ImportTask[])"/> to always import at standard priority.
|
||||
/// This will post notifications tracking progress.
|
||||
/// </remarks>
|
||||
/// <param name="paths">One or more archive locations on disk.</param>
|
||||
Task Import(params string[] paths);
|
||||
|
||||
Task Import(params ImportTask[] tasks);
|
||||
|
||||
Task<IEnumerable<TModel>> Import(ProgressNotification notification, params ImportTask[] tasks);
|
||||
|
||||
/// <summary>
|
||||
/// Import one <typeparamref name="TModel"/> from the filesystem and delete the file on success.
|
||||
/// Note that this bypasses the UI flow and should only be used for special cases or testing.
|
||||
/// </summary>
|
||||
/// <param name="task">The <see cref="ImportTask"/> containing data about the <typeparamref name="TModel"/> to import.</param>
|
||||
/// <param name="lowPriority">Whether this is a low priority import.</param>
|
||||
/// <param name="cancellationToken">An optional cancellation token.</param>
|
||||
/// <returns>The imported model, if successful.</returns>
|
||||
Task<TModel> Import(ImportTask task, bool lowPriority = false, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Silently import an item from an <see cref="ArchiveReader"/>.
|
||||
/// </summary>
|
||||
/// <param name="archive">The archive to be imported.</param>
|
||||
/// <param name="lowPriority">Whether this is a low priority import.</param>
|
||||
/// <param name="cancellationToken">An optional cancellation token.</param>
|
||||
Task<TModel> Import(ArchiveReader archive, bool lowPriority = false, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Silently import an item from a <typeparamref name="TModel"/>.
|
||||
/// </summary>
|
||||
/// <param name="item">The model to be imported.</param>
|
||||
/// <param name="archive">An optional archive to use for model population.</param>
|
||||
/// <param name="lowPriority">Whether this is a low priority import.</param>
|
||||
/// <param name="cancellationToken">An optional cancellation token.</param>
|
||||
Task<TModel> Import(TModel item, ArchiveReader archive = null, bool lowPriority = false, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a given <typeparamref name="TModel"/> is already available in the local store.
|
||||
/// </summary>
|
||||
/// <param name="model">The <typeparamref name="TModel"/> whose existence needs to be checked.</param>
|
||||
/// <returns>Whether the <typeparamref name="TModel"/> exists.</returns>
|
||||
bool IsAvailableLocally(TModel model);
|
||||
|
||||
/// <summary>
|
||||
/// A user displayable name for the model type associated with this manager.
|
||||
/// </summary>
|
||||
string HumanisedModelName => $"{typeof(TModel).Name.Replace(@"Info", "").ToLower()}";
|
||||
}
|
||||
}
|
||||
|
16
osu.Game/Database/IPostNotifications.cs
Normal file
16
osu.Game/Database/IPostNotifications.cs
Normal file
@ -0,0 +1,16 @@
|
||||
// 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 osu.Game.Overlays.Notifications;
|
||||
|
||||
namespace osu.Game.Database
|
||||
{
|
||||
public interface IPostNotifications
|
||||
{
|
||||
/// <summary>
|
||||
/// And action which will be fired when a notification should be presented to the user.
|
||||
/// </summary>
|
||||
public Action<Notification> PostNotification { set; }
|
||||
}
|
||||
}
|
17
osu.Game/Database/IPresentImports.cs
Normal file
17
osu.Game/Database/IPresentImports.cs
Normal file
@ -0,0 +1,17 @@
|
||||
// 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;
|
||||
|
||||
namespace osu.Game.Database
|
||||
{
|
||||
public interface IPresentImports<TModel>
|
||||
where TModel : class
|
||||
{
|
||||
/// <summary>
|
||||
/// Fired when the user requests to view the resulting import.
|
||||
/// </summary>
|
||||
public Action<IEnumerable<TModel>> PresentImport { set; }
|
||||
}
|
||||
}
|
@ -13,7 +13,7 @@ namespace osu.Game.Database
|
||||
Realm Context { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Create a new realm context for use on an arbitrary thread.
|
||||
/// Create a new realm context for use on the current thread.
|
||||
/// </summary>
|
||||
Realm CreateContext();
|
||||
}
|
||||
|
@ -1,29 +1,24 @@
|
||||
// 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 Humanizer;
|
||||
using osu.Framework.Logging;
|
||||
using osu.Framework.Platform;
|
||||
using osu.Game.Online.API;
|
||||
using osu.Game.Overlays.Notifications;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Humanizer;
|
||||
using osu.Framework.Bindables;
|
||||
using osu.Framework.Logging;
|
||||
using osu.Framework.Platform;
|
||||
using osu.Game.Online.API;
|
||||
using osu.Game.Overlays.Notifications;
|
||||
|
||||
namespace osu.Game.Database
|
||||
{
|
||||
/// <summary>
|
||||
/// An <see cref="ArchiveModelManager{TModel, TFileModel}"/> that has the ability to download models using an <see cref="IAPIProvider"/> and
|
||||
/// import them into the store.
|
||||
/// </summary>
|
||||
/// <typeparam name="TModel">The model type.</typeparam>
|
||||
/// <typeparam name="TFileModel">The associated file join type.</typeparam>
|
||||
public abstract class DownloadableArchiveModelManager<TModel, TFileModel> : ArchiveModelManager<TModel, TFileModel>, IModelDownloader<TModel>
|
||||
where TModel : class, IHasFiles<TFileModel>, IHasPrimaryKey, ISoftDelete, IEquatable<TModel>
|
||||
where TFileModel : class, INamedFileInfo, new()
|
||||
public abstract class ModelDownloader<TModel> : IModelDownloader<TModel>
|
||||
where TModel : class, IHasPrimaryKey, ISoftDelete, IEquatable<TModel>
|
||||
{
|
||||
public Action<Notification> PostNotification { protected get; set; }
|
||||
|
||||
public IBindable<WeakReference<ArchiveDownloadRequest<TModel>>> DownloadBegan => downloadBegan;
|
||||
|
||||
private readonly Bindable<WeakReference<ArchiveDownloadRequest<TModel>>> downloadBegan = new Bindable<WeakReference<ArchiveDownloadRequest<TModel>>>();
|
||||
@ -32,18 +27,15 @@ namespace osu.Game.Database
|
||||
|
||||
private readonly Bindable<WeakReference<ArchiveDownloadRequest<TModel>>> downloadFailed = new Bindable<WeakReference<ArchiveDownloadRequest<TModel>>>();
|
||||
|
||||
private readonly IModelManager<TModel> modelManager;
|
||||
private readonly IAPIProvider api;
|
||||
|
||||
private readonly List<ArchiveDownloadRequest<TModel>> currentDownloads = new List<ArchiveDownloadRequest<TModel>>();
|
||||
|
||||
private readonly MutableDatabaseBackedStoreWithFileIncludes<TModel, TFileModel> modelStore;
|
||||
|
||||
protected DownloadableArchiveModelManager(Storage storage, IDatabaseContextFactory contextFactory, IAPIProvider api, MutableDatabaseBackedStoreWithFileIncludes<TModel, TFileModel> modelStore,
|
||||
IIpcHost importHost = null)
|
||||
: base(storage, contextFactory, modelStore, importHost)
|
||||
protected ModelDownloader(IModelManager<TModel> modelManager, IAPIProvider api, IIpcHost importHost = null)
|
||||
{
|
||||
this.modelManager = modelManager;
|
||||
this.api = api;
|
||||
this.modelStore = modelStore;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -54,12 +46,6 @@ namespace osu.Game.Database
|
||||
/// <returns>The request object.</returns>
|
||||
protected abstract ArchiveDownloadRequest<TModel> CreateDownloadRequest(TModel model, bool minimiseDownloadSize);
|
||||
|
||||
/// <summary>
|
||||
/// Begin a download for the requested <typeparamref name="TModel"/>.
|
||||
/// </summary>
|
||||
/// <param name="model">The <typeparamref name="TModel"/> to be downloaded.</param>
|
||||
/// <param name="minimiseDownloadSize">Whether this download should be optimised for slow connections. Generally means extras are not included in the download bundle.</param>
|
||||
/// <returns>Whether the download was started.</returns>
|
||||
public bool Download(TModel model, bool minimiseDownloadSize = false)
|
||||
{
|
||||
if (!canDownload(model)) return false;
|
||||
@ -82,7 +68,7 @@ namespace osu.Game.Database
|
||||
Task.Factory.StartNew(async () =>
|
||||
{
|
||||
// This gets scheduled back to the update thread, but we want the import to run in the background.
|
||||
var imported = await Import(notification, new ImportTask(filename)).ConfigureAwait(false);
|
||||
var imported = await modelManager.Import(notification, new ImportTask(filename)).ConfigureAwait(false);
|
||||
|
||||
// for now a failed import will be marked as a failed download for simplicity.
|
||||
if (!imported.Any())
|
||||
@ -117,21 +103,10 @@ namespace osu.Game.Database
|
||||
notification.State = ProgressNotificationState.Cancelled;
|
||||
|
||||
if (!(error is OperationCanceledException))
|
||||
Logger.Error(error, $"{HumanisedModelName.Titleize()} download failed!");
|
||||
Logger.Error(error, $"{modelManager.HumanisedModelName.Titleize()} download failed!");
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsAvailableLocally(TModel model) => CheckLocalAvailability(model, modelStore.ConsumableItems.Where(m => !m.DeletePending));
|
||||
|
||||
/// <summary>
|
||||
/// Performs implementation specific comparisons to determine whether a given model is present in the local store.
|
||||
/// </summary>
|
||||
/// <param name="model">The <typeparamref name="TModel"/> whose existence needs to be checked.</param>
|
||||
/// <param name="items">The usable items present in the store.</param>
|
||||
/// <returns>Whether the <typeparamref name="TModel"/> exists.</returns>
|
||||
protected virtual bool CheckLocalAvailability(TModel model, IQueryable<TModel> items)
|
||||
=> model.ID > 0 && items.Any(i => i.ID == model.ID && i.Files.Any());
|
||||
|
||||
public ArchiveDownloadRequest<TModel> GetExistingDownload(TModel model) => currentDownloads.Find(r => r.Model.Equals(model));
|
||||
|
||||
private bool canDownload(TModel model) => GetExistingDownload(model) == null && api != null;
|
@ -16,7 +16,7 @@ namespace osu.Game.Online
|
||||
/// </summary>
|
||||
public abstract class DownloadTrackingComposite<TModel, TModelManager> : CompositeDrawable
|
||||
where TModel : class, IEquatable<TModel>
|
||||
where TModelManager : class, IModelDownloader<TModel>
|
||||
where TModelManager : class, IModelDownloader<TModel>, IModelManager<TModel>
|
||||
{
|
||||
protected readonly Bindable<TModel> Model = new Bindable<TModel>();
|
||||
|
||||
@ -35,7 +35,7 @@ namespace osu.Game.Online
|
||||
Model.Value = model;
|
||||
}
|
||||
|
||||
private IBindable<WeakReference<TModel>> managedUpdated;
|
||||
private IBindable<WeakReference<TModel>> managerUpdated;
|
||||
private IBindable<WeakReference<TModel>> managerRemoved;
|
||||
private IBindable<WeakReference<ArchiveDownloadRequest<TModel>>> managerDownloadBegan;
|
||||
private IBindable<WeakReference<ArchiveDownloadRequest<TModel>>> managerDownloadFailed;
|
||||
@ -60,8 +60,8 @@ namespace osu.Game.Online
|
||||
managerDownloadBegan.BindValueChanged(downloadBegan);
|
||||
managerDownloadFailed = Manager.DownloadFailed.GetBoundCopy();
|
||||
managerDownloadFailed.BindValueChanged(downloadFailed);
|
||||
managedUpdated = Manager.ItemUpdated.GetBoundCopy();
|
||||
managedUpdated.BindValueChanged(itemUpdated);
|
||||
managerUpdated = Manager.ItemUpdated.GetBoundCopy();
|
||||
managerUpdated.BindValueChanged(itemUpdated);
|
||||
managerRemoved = Manager.ItemRemoved.GetBoundCopy();
|
||||
managerRemoved.BindValueChanged(itemRemoved);
|
||||
}
|
||||
@ -77,7 +77,7 @@ namespace osu.Game.Online
|
||||
|
||||
/// <summary>
|
||||
/// Whether the given model is available in the database.
|
||||
/// By default, this calls <see cref="IModelDownloader{TModel}.IsAvailableLocally"/>,
|
||||
/// By default, this calls <see cref="IModelManager{TModel}.IsAvailableLocally"/>,
|
||||
/// but can be overriden to add additional checks for verifying the model in database.
|
||||
/// </summary>
|
||||
protected virtual bool IsModelAvailableLocally() => Manager?.IsAvailableLocally(Model.Value) == true;
|
||||
|
@ -242,7 +242,7 @@ namespace osu.Game
|
||||
|
||||
// ordering is important here to ensure foreign keys rules are not broken in ModelStore.Cleanup()
|
||||
dependencies.Cache(ScoreManager = new ScoreManager(RulesetStore, () => BeatmapManager, Storage, API, contextFactory, Scheduler, Host, () => difficultyCache, LocalConfig));
|
||||
dependencies.Cache(BeatmapManager = new BeatmapManager(Storage, contextFactory, RulesetStore, API, Audio, Resources, Host, defaultBeatmap, true));
|
||||
dependencies.Cache(BeatmapManager = new BeatmapManager(Storage, contextFactory, RulesetStore, API, Audio, Resources, Host, defaultBeatmap, performOnlineLookups: true));
|
||||
|
||||
// this should likely be moved to ArchiveModelManager when another case appears where it is necessary
|
||||
// to have inter-dependent model managers. this could be obtained with an IHasForeign<T> interface to
|
||||
|
@ -9,102 +9,48 @@ using System.Linq.Expressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using JetBrains.Annotations;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using osu.Framework.Bindables;
|
||||
using osu.Framework.Logging;
|
||||
using osu.Framework.Platform;
|
||||
using osu.Framework.Threading;
|
||||
using osu.Game.Beatmaps;
|
||||
using osu.Game.Configuration;
|
||||
using osu.Game.Database;
|
||||
using osu.Game.IO;
|
||||
using osu.Game.IO.Archives;
|
||||
using osu.Game.Online.API;
|
||||
using osu.Game.Online.API.Requests;
|
||||
using osu.Game.Overlays.Notifications;
|
||||
using osu.Game.Rulesets;
|
||||
using osu.Game.Rulesets.Judgements;
|
||||
using osu.Game.Rulesets.Scoring;
|
||||
using osu.Game.Scoring.Legacy;
|
||||
|
||||
namespace osu.Game.Scoring
|
||||
{
|
||||
public class ScoreManager : DownloadableArchiveModelManager<ScoreInfo, ScoreFileInfo>
|
||||
public class ScoreManager : IModelManager<ScoreInfo>, IModelFileManager<ScoreInfo, ScoreFileInfo>, IModelDownloader<ScoreInfo>, ICanAcceptFiles, IPresentImports<ScoreInfo>
|
||||
{
|
||||
public override IEnumerable<string> HandledExtensions => new[] { ".osr" };
|
||||
|
||||
protected override string[] HashableFileTypes => new[] { ".osr" };
|
||||
|
||||
protected override string ImportFromStablePath => Path.Combine("Data", "r");
|
||||
|
||||
private readonly RulesetStore rulesets;
|
||||
private readonly Func<BeatmapManager> beatmaps;
|
||||
private readonly Scheduler scheduler;
|
||||
|
||||
[CanBeNull]
|
||||
private readonly Func<BeatmapDifficultyCache> difficulties;
|
||||
|
||||
[CanBeNull]
|
||||
private readonly OsuConfigManager configManager;
|
||||
private readonly ScoreModelManager scoreModelManager;
|
||||
private readonly ScoreModelDownloader scoreModelDownloader;
|
||||
|
||||
public ScoreManager(RulesetStore rulesets, Func<BeatmapManager> beatmaps, Storage storage, IAPIProvider api, IDatabaseContextFactory contextFactory, Scheduler scheduler,
|
||||
IIpcHost importHost = null, Func<BeatmapDifficultyCache> difficulties = null, OsuConfigManager configManager = null)
|
||||
: base(storage, contextFactory, api, new ScoreStore(contextFactory, storage), importHost)
|
||||
{
|
||||
this.rulesets = rulesets;
|
||||
this.beatmaps = beatmaps;
|
||||
this.scheduler = scheduler;
|
||||
this.difficulties = difficulties;
|
||||
this.configManager = configManager;
|
||||
|
||||
scoreModelManager = new ScoreModelManager(rulesets, beatmaps, storage, contextFactory, importHost);
|
||||
scoreModelDownloader = new ScoreModelDownloader(scoreModelManager, api, importHost);
|
||||
}
|
||||
|
||||
protected override ScoreInfo CreateModel(ArchiveReader archive)
|
||||
{
|
||||
if (archive == null)
|
||||
return null;
|
||||
public Score GetScore(ScoreInfo score) => scoreModelManager.GetScore(score);
|
||||
|
||||
using (var stream = archive.GetStream(archive.Filenames.First(f => f.EndsWith(".osr", StringComparison.OrdinalIgnoreCase))))
|
||||
{
|
||||
try
|
||||
{
|
||||
return new DatabasedLegacyScoreDecoder(rulesets, beatmaps()).Parse(stream).ScoreInfo;
|
||||
}
|
||||
catch (LegacyScoreDecoder.BeatmapNotFoundException e)
|
||||
{
|
||||
Logger.Log(e.Message, LoggingTarget.Information, LogLevel.Error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
public List<ScoreInfo> GetAllUsableScores() => scoreModelManager.GetAllUsableScores();
|
||||
|
||||
protected override Task Populate(ScoreInfo model, ArchiveReader archive, CancellationToken cancellationToken = default)
|
||||
=> Task.CompletedTask;
|
||||
public IEnumerable<ScoreInfo> QueryScores(Expression<Func<ScoreInfo, bool>> query) => scoreModelManager.QueryScores(query);
|
||||
|
||||
protected override void ExportModelTo(ScoreInfo model, Stream outputStream)
|
||||
{
|
||||
var file = model.Files.SingleOrDefault();
|
||||
if (file == null)
|
||||
return;
|
||||
|
||||
using (var inputStream = Files.Storage.GetStream(file.FileInfo.StoragePath))
|
||||
inputStream.CopyTo(outputStream);
|
||||
}
|
||||
|
||||
protected override IEnumerable<string> GetStableImportPaths(Storage storage)
|
||||
=> storage.GetFiles(ImportFromStablePath).Where(p => HandledExtensions.Any(ext => Path.GetExtension(p)?.Equals(ext, StringComparison.OrdinalIgnoreCase) ?? false))
|
||||
.Select(path => storage.GetFullPath(path));
|
||||
|
||||
public Score GetScore(ScoreInfo score) => new LegacyDatabasedScore(score, rulesets, beatmaps(), Files.Store);
|
||||
|
||||
public List<ScoreInfo> GetAllUsableScores() => ModelStore.ConsumableItems.Where(s => !s.DeletePending).ToList();
|
||||
|
||||
public IEnumerable<ScoreInfo> QueryScores(Expression<Func<ScoreInfo, bool>> query) => ModelStore.ConsumableItems.AsNoTracking().Where(query);
|
||||
|
||||
public ScoreInfo Query(Expression<Func<ScoreInfo, bool>> query) => ModelStore.ConsumableItems.AsNoTracking().FirstOrDefault(query);
|
||||
|
||||
protected override ArchiveDownloadRequest<ScoreInfo> CreateDownloadRequest(ScoreInfo score, bool minimiseDownload) => new DownloadReplayRequest(score);
|
||||
|
||||
protected override bool CheckLocalAvailability(ScoreInfo model, IQueryable<ScoreInfo> items)
|
||||
=> base.CheckLocalAvailability(model, items)
|
||||
|| (model.OnlineScoreID != null && items.Any(i => i.OnlineScoreID == model.OnlineScoreID));
|
||||
public ScoreInfo Query(Expression<Func<ScoreInfo, bool>> query) => scoreModelManager.Query(query);
|
||||
|
||||
/// <summary>
|
||||
/// Orders an array of <see cref="ScoreInfo"/>s by total score.
|
||||
@ -281,5 +227,149 @@ namespace osu.Game.Scoring
|
||||
this.totalScore.BindValueChanged(v => Value = v.NewValue.ToString("N0"), true);
|
||||
}
|
||||
}
|
||||
|
||||
#region Implementation of IPostNotifications
|
||||
|
||||
public Action<Notification> PostNotification
|
||||
{
|
||||
set
|
||||
{
|
||||
scoreModelManager.PostNotification = value;
|
||||
scoreModelDownloader.PostNotification = value;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Implementation of IModelManager<ScoreInfo>
|
||||
|
||||
public IBindable<WeakReference<ScoreInfo>> ItemUpdated => scoreModelManager.ItemUpdated;
|
||||
|
||||
public IBindable<WeakReference<ScoreInfo>> ItemRemoved => scoreModelManager.ItemRemoved;
|
||||
|
||||
public Task ImportFromStableAsync(StableStorage stableStorage)
|
||||
{
|
||||
return scoreModelManager.ImportFromStableAsync(stableStorage);
|
||||
}
|
||||
|
||||
public void Export(ScoreInfo item)
|
||||
{
|
||||
scoreModelManager.Export(item);
|
||||
}
|
||||
|
||||
public void ExportModelTo(ScoreInfo model, Stream outputStream)
|
||||
{
|
||||
scoreModelManager.ExportModelTo(model, outputStream);
|
||||
}
|
||||
|
||||
public void Update(ScoreInfo item)
|
||||
{
|
||||
scoreModelManager.Update(item);
|
||||
}
|
||||
|
||||
public bool Delete(ScoreInfo item)
|
||||
{
|
||||
return scoreModelManager.Delete(item);
|
||||
}
|
||||
|
||||
public void Delete(List<ScoreInfo> items, bool silent = false)
|
||||
{
|
||||
scoreModelManager.Delete(items, silent);
|
||||
}
|
||||
|
||||
public void Undelete(List<ScoreInfo> items, bool silent = false)
|
||||
{
|
||||
scoreModelManager.Undelete(items, silent);
|
||||
}
|
||||
|
||||
public void Undelete(ScoreInfo item)
|
||||
{
|
||||
scoreModelManager.Undelete(item);
|
||||
}
|
||||
|
||||
public Task Import(params string[] paths)
|
||||
{
|
||||
return scoreModelManager.Import(paths);
|
||||
}
|
||||
|
||||
public Task Import(params ImportTask[] tasks)
|
||||
{
|
||||
return scoreModelManager.Import(tasks);
|
||||
}
|
||||
|
||||
public IEnumerable<string> HandledExtensions => scoreModelManager.HandledExtensions;
|
||||
|
||||
public Task<IEnumerable<ScoreInfo>> Import(ProgressNotification notification, params ImportTask[] tasks)
|
||||
{
|
||||
return scoreModelManager.Import(notification, tasks);
|
||||
}
|
||||
|
||||
public Task<ScoreInfo> Import(ImportTask task, bool lowPriority = false, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return scoreModelManager.Import(task, lowPriority, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<ScoreInfo> Import(ArchiveReader archive, bool lowPriority = false, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return scoreModelManager.Import(archive, lowPriority, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<ScoreInfo> Import(ScoreInfo item, ArchiveReader archive = null, bool lowPriority = false, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return scoreModelManager.Import(item, archive, lowPriority, cancellationToken);
|
||||
}
|
||||
|
||||
public bool IsAvailableLocally(ScoreInfo model)
|
||||
{
|
||||
return scoreModelManager.IsAvailableLocally(model);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Implementation of IModelFileManager<in ScoreInfo,in ScoreFileInfo>
|
||||
|
||||
public void ReplaceFile(ScoreInfo model, ScoreFileInfo file, Stream contents, string filename = null)
|
||||
{
|
||||
scoreModelManager.ReplaceFile(model, file, contents, filename);
|
||||
}
|
||||
|
||||
public void DeleteFile(ScoreInfo model, ScoreFileInfo file)
|
||||
{
|
||||
scoreModelManager.DeleteFile(model, file);
|
||||
}
|
||||
|
||||
public void AddFile(ScoreInfo model, Stream contents, string filename)
|
||||
{
|
||||
scoreModelManager.AddFile(model, contents, filename);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Implementation of IModelDownloader<ScoreInfo>
|
||||
|
||||
public IBindable<WeakReference<ArchiveDownloadRequest<ScoreInfo>>> DownloadBegan => scoreModelDownloader.DownloadBegan;
|
||||
|
||||
public IBindable<WeakReference<ArchiveDownloadRequest<ScoreInfo>>> DownloadFailed => scoreModelDownloader.DownloadFailed;
|
||||
|
||||
public bool Download(ScoreInfo model, bool minimiseDownloadSize)
|
||||
{
|
||||
return scoreModelDownloader.Download(model, minimiseDownloadSize);
|
||||
}
|
||||
|
||||
public ArchiveDownloadRequest<ScoreInfo> GetExistingDownload(ScoreInfo model)
|
||||
{
|
||||
return scoreModelDownloader.GetExistingDownload(model);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Implementation of IPresentImports<ScoreInfo>
|
||||
|
||||
public Action<IEnumerable<ScoreInfo>> PresentImport
|
||||
{
|
||||
set => scoreModelManager.PresentImport = value;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
|
20
osu.Game/Scoring/ScoreModelDownloader.cs
Normal file
20
osu.Game/Scoring/ScoreModelDownloader.cs
Normal file
@ -0,0 +1,20 @@
|
||||
// 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 osu.Framework.Platform;
|
||||
using osu.Game.Database;
|
||||
using osu.Game.Online.API;
|
||||
using osu.Game.Online.API.Requests;
|
||||
|
||||
namespace osu.Game.Scoring
|
||||
{
|
||||
public class ScoreModelDownloader : ModelDownloader<ScoreInfo>
|
||||
{
|
||||
public ScoreModelDownloader(ScoreModelManager scoreManager, IAPIProvider api, IIpcHost importHost = null)
|
||||
: base(scoreManager, api, importHost)
|
||||
{
|
||||
}
|
||||
|
||||
protected override ArchiveDownloadRequest<ScoreInfo> CreateDownloadRequest(ScoreInfo score, bool minimiseDownload) => new DownloadReplayRequest(score);
|
||||
}
|
||||
}
|
88
osu.Game/Scoring/ScoreModelManager.cs
Normal file
88
osu.Game/Scoring/ScoreModelManager.cs
Normal file
@ -0,0 +1,88 @@
|
||||
// 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;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Linq.Expressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using osu.Framework.Logging;
|
||||
using osu.Framework.Platform;
|
||||
using osu.Game.Beatmaps;
|
||||
using osu.Game.Database;
|
||||
using osu.Game.IO.Archives;
|
||||
using osu.Game.Rulesets;
|
||||
using osu.Game.Scoring.Legacy;
|
||||
|
||||
namespace osu.Game.Scoring
|
||||
{
|
||||
public class ScoreModelManager : ArchiveModelManager<ScoreInfo, ScoreFileInfo>
|
||||
{
|
||||
public override IEnumerable<string> HandledExtensions => new[] { ".osr" };
|
||||
|
||||
protected override string[] HashableFileTypes => new[] { ".osr" };
|
||||
|
||||
protected override string ImportFromStablePath => Path.Combine("Data", "r");
|
||||
|
||||
private readonly RulesetStore rulesets;
|
||||
private readonly Func<BeatmapManager> beatmaps;
|
||||
|
||||
public ScoreModelManager(RulesetStore rulesets, Func<BeatmapManager> beatmaps, Storage storage, IDatabaseContextFactory contextFactory, IIpcHost importHost = null)
|
||||
: base(storage, contextFactory, new ScoreStore(contextFactory, storage), importHost)
|
||||
{
|
||||
this.rulesets = rulesets;
|
||||
this.beatmaps = beatmaps;
|
||||
}
|
||||
|
||||
protected override ScoreInfo CreateModel(ArchiveReader archive)
|
||||
{
|
||||
if (archive == null)
|
||||
return null;
|
||||
|
||||
using (var stream = archive.GetStream(archive.Filenames.First(f => f.EndsWith(".osr", StringComparison.OrdinalIgnoreCase))))
|
||||
{
|
||||
try
|
||||
{
|
||||
return new DatabasedLegacyScoreDecoder(rulesets, beatmaps()).Parse(stream).ScoreInfo;
|
||||
}
|
||||
catch (LegacyScoreDecoder.BeatmapNotFoundException e)
|
||||
{
|
||||
Logger.Log(e.Message, LoggingTarget.Information, LogLevel.Error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public Score GetScore(ScoreInfo score) => new LegacyDatabasedScore(score, rulesets, beatmaps(), Files.Store);
|
||||
|
||||
public List<ScoreInfo> GetAllUsableScores() => ModelStore.ConsumableItems.Where(s => !s.DeletePending).ToList();
|
||||
|
||||
public IEnumerable<ScoreInfo> QueryScores(Expression<Func<ScoreInfo, bool>> query) => ModelStore.ConsumableItems.AsNoTracking().Where(query);
|
||||
|
||||
public ScoreInfo Query(Expression<Func<ScoreInfo, bool>> query) => ModelStore.ConsumableItems.AsNoTracking().FirstOrDefault(query);
|
||||
|
||||
protected override Task Populate(ScoreInfo model, ArchiveReader archive, CancellationToken cancellationToken = default)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
protected override bool CheckLocalAvailability(ScoreInfo model, IQueryable<ScoreInfo> items)
|
||||
=> base.CheckLocalAvailability(model, items)
|
||||
|| (model.OnlineScoreID != null && items.Any(i => i.OnlineScoreID == model.OnlineScoreID));
|
||||
|
||||
public override void ExportModelTo(ScoreInfo model, Stream outputStream)
|
||||
{
|
||||
var file = model.Files.SingleOrDefault();
|
||||
if (file == null)
|
||||
return;
|
||||
|
||||
using (var inputStream = Files.Storage.GetStream(file.FileInfo.StoragePath))
|
||||
inputStream.CopyTo(outputStream);
|
||||
}
|
||||
|
||||
protected override IEnumerable<string> GetStableImportPaths(Storage storage)
|
||||
=> storage.GetFiles(ImportFromStablePath).Where(p => HandledExtensions.Any(ext => Path.GetExtension(p)?.Equals(ext, StringComparison.OrdinalIgnoreCase) ?? false))
|
||||
.Select(path => storage.GetFullPath(path));
|
||||
}
|
||||
}
|
@ -40,7 +40,7 @@ namespace osu.Game.Screens.Ranking
|
||||
}
|
||||
|
||||
[BackgroundDependencyLoader(true)]
|
||||
private void load(OsuGame game, ScoreManager scores)
|
||||
private void load(OsuGame game, ScoreModelDownloader scores)
|
||||
{
|
||||
InternalChild = shakeContainer = new ShakeContainer
|
||||
{
|
||||
|
@ -123,11 +123,40 @@ namespace osu.Game.Tests.Visual
|
||||
this.testBeatmap = testBeatmap;
|
||||
}
|
||||
|
||||
protected override string ComputeHash(BeatmapSetInfo item, ArchiveReader reader = null)
|
||||
=> string.Empty;
|
||||
protected override BeatmapModelManager CreateBeatmapModelManager(Storage storage, IDatabaseContextFactory contextFactory, RulesetStore rulesets, IAPIProvider api, GameHost host)
|
||||
{
|
||||
return new TestBeatmapModelManager(storage, contextFactory, rulesets, api, host);
|
||||
}
|
||||
|
||||
public override WorkingBeatmap GetWorkingBeatmap(BeatmapInfo beatmapInfo)
|
||||
=> testBeatmap;
|
||||
protected override WorkingBeatmapCache CreateWorkingBeatmapCache(AudioManager audioManager, IResourceStore<byte[]> resources, IResourceStore<byte[]> storage, WorkingBeatmap defaultBeatmap, GameHost host)
|
||||
{
|
||||
return new TestWorkingBeatmapCache(this, audioManager, resources, storage, defaultBeatmap, host);
|
||||
}
|
||||
|
||||
private class TestWorkingBeatmapCache : WorkingBeatmapCache
|
||||
{
|
||||
private readonly TestBeatmapManager testBeatmapManager;
|
||||
|
||||
public TestWorkingBeatmapCache(TestBeatmapManager testBeatmapManager, AudioManager audioManager, IResourceStore<byte[]> resourceStore, IResourceStore<byte[]> storage, WorkingBeatmap defaultBeatmap, GameHost gameHost)
|
||||
: base(audioManager, resourceStore, storage, defaultBeatmap, gameHost)
|
||||
{
|
||||
this.testBeatmapManager = testBeatmapManager;
|
||||
}
|
||||
|
||||
public override WorkingBeatmap GetWorkingBeatmap(BeatmapInfo beatmapInfo)
|
||||
=> testBeatmapManager.testBeatmap;
|
||||
}
|
||||
|
||||
internal class TestBeatmapModelManager : BeatmapModelManager
|
||||
{
|
||||
public TestBeatmapModelManager(Storage storage, IDatabaseContextFactory databaseContextFactory, RulesetStore rulesetStore, IAPIProvider apiProvider, GameHost gameHost)
|
||||
: base(storage, databaseContextFactory, rulesetStore, gameHost)
|
||||
{
|
||||
}
|
||||
|
||||
protected override string ComputeHash(BeatmapSetInfo item, ArchiveReader reader = null)
|
||||
=> string.Empty;
|
||||
}
|
||||
|
||||
public override void Save(BeatmapInfo info, IBeatmap beatmapContent, ISkin beatmapSkin = null)
|
||||
{
|
||||
|
Loading…
Reference in New Issue
Block a user