Skip to content
Draft
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion osu.Game/Database/RealmAccess.cs
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,9 @@ public class RealmAccess : IDisposable
/// 49 2025-06-10 Reset the LegacyOnlineID to -1 for all scores that have it set to 0 (which is semantically the same) for consistency of handling with OnlineID.
/// 50 2025-07-11 Add UserTags to BeatmapMetadata.
/// 51 2025-07-22 Add ScoreInfo.Pauses.
/// 52 2026-07-28 Add RealmOnlineAsset.
/// </summary>
private const int schema_version = 51;
private const int schema_version = 52;

/// <summary>
/// Lock object which is held during <see cref="BlockAllOperations"/> sections, blocking realm retrieval during blocking periods.
Expand Down Expand Up @@ -413,6 +414,12 @@ private void cleanupPendingDeletions(Realm realm)
foreach (var s in pendingDeletePresets)
realm.Remove(s);

var onlineAssetAccessCutoff = DateTimeOffset.Now.AddMonths(-1);
var pendingDeleteOnlineAssets = realm.All<RealmOnlineAsset>().Where(a => a.LastAccessed < onlineAssetAccessCutoff);

foreach (var a in pendingDeleteOnlineAssets)
realm.Remove(a);

transaction.Commit();
}

Expand Down
1 change: 1 addition & 0 deletions osu.Game/Database/RealmObjectExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ private static void applyCommonConfiguration(IMapperConfigurationExpression c)
c.CreateMap<RealmUser, RealmUser>();
c.CreateMap<RealmFile, RealmFile>();
c.CreateMap<RealmNamedFileUsage, RealmNamedFileUsage>();
c.CreateMap<RealmOnlineAsset, RealmOnlineAsset>();
c.CreateMap<SkinInfo, SkinInfo>();
}

Expand Down
87 changes: 87 additions & 0 deletions osu.Game/Graphics/OnlineAssetCachingStore.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// 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.Linq;
using osu.Framework.Graphics.Textures;
using osu.Framework.IO.Stores;
using osu.Framework.Logging;
using osu.Framework.Platform;
using osu.Game.Database;
using osu.Game.Extensions;
using osu.Game.Models;
using osu.Game.Online;
using Realms;

namespace osu.Game.Graphics
{
/// <summary>
/// <para>
/// Store for retrieval and caching of assets (background, avatars, covers) retrieved from the web to disk.
/// </para>
/// <para>
/// This store assumes relies on the uniqueness of the URL of retrieved assets to determine identity.
/// Therefore, this store <b>MUST</b> only be used with URLs that are content-addressed in some way
/// (by containing a content-based hash in the filename, or a cache-busting query string based on time of last update).
/// </para>
/// <para>
/// This store <b>MUST NOT</b> be used with URLs containing naive cache-busting strings (e.g. <c>test.jpg?TIMESTAMP</c>)
/// as it both makes the caching ineffective <b>AND</b> trashes the cache with entries that will never be used again.
/// </para>
/// </summary>
public class OnlineAssetCachingStore
{
private readonly RealmAccess realmAccess;
private readonly OnlineStore onlineStore;
private readonly RealmFileStore fileStore;
private readonly LargeTextureStore largeTextureStore;

public OnlineAssetCachingStore(GameHost host, RealmAccess realmAccess)
{
this.realmAccess = realmAccess;
onlineStore = new TrustedDomainOnlineStore();
fileStore = new RealmFileStore(realmAccess, host.Storage);
largeTextureStore = new LargeTextureStore(host.Renderer, host.CreateTextureLoaderStore(new StorageBackedResourceStore(fileStore.Storage)));
}

public Texture? Get(string url)
{
var existingAsset = realmAccess.Write(r =>
{
var a = r.All<RealmOnlineAsset>().Filter($@"{nameof(RealmOnlineAsset.File)}.{nameof(RealmNamedFileUsage.Filename)} == $0", url).FirstOrDefault();
if (a != null)
a.LastAccessed = DateTimeOffset.Now;
return a?.Detach();
});

if (existingAsset == null)
{
var onlineStream = onlineStore.GetStream(url);

if (onlineStream == null)
return null;

existingAsset = realmAccess.Write(r =>
{
var file = fileStore.Add(onlineStream, r);
var newAsset = new RealmOnlineAsset(file, url);
r.Add(newAsset);
return newAsset.Detach();
});
}
else
{
Logger.Log($"Online asset {url} retrieved from {nameof(OnlineAssetCachingStore)}.", LoggingTarget.Network);
}

string path = existingAsset.File.File.GetStoragePath();
return largeTextureStore.Get(path);
}

public void Dispose()
{
onlineStore.Dispose();
largeTextureStore.Dispose();
}
}
}
41 changes: 41 additions & 0 deletions osu.Game/Models/RealmOnlineAsset.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// 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 JetBrains.Annotations;
using osu.Game.Database;
using Realms;

namespace osu.Game.Models
{
/// <summary>
/// Describes an online asset (background image, user avatar, cover...) which is persisted to disk
/// to reduce the number of online requests and shorten retrieval time.
/// </summary>
public class RealmOnlineAsset : RealmObject
{
/// <summary>
/// Contains information about the original URL of the file and its location on disk.
/// </summary>
public RealmNamedFileUsage File { get; set; } = null!;

/// <summary>
/// Contains the last time of access of this asset.
/// </summary>
/// <remarks>
/// Assets that have not been accessed for over a month are purged
/// (<see cref="RealmAccess.cleanupPendingDeletions"/>).
/// </remarks>
public DateTimeOffset LastAccessed { get; set; } = DateTimeOffset.Now;

[UsedImplicitly]
private RealmOnlineAsset()
{
}

public RealmOnlineAsset(RealmFile localFile, string remoteUrl)
{
File = new RealmNamedFileUsage(localFile, remoteUrl);
}
}
}
2 changes: 2 additions & 0 deletions osu.Game/OsuGameBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,8 @@ private void load(ReadableKeyCombinationProvider keyCombinationProvider, Framewo
largeStore.AddTextureSource(Host.CreateTextureLoaderStore(CreateOnlineStore()));
dependencies.Cache(largeStore);

dependencies.Cache(new OnlineAssetCachingStore(Host, realm));

dependencies.CacheAs(LocalConfig);
dependencies.CacheAs<IGameplaySettings>(LocalConfig);

Expand Down
5 changes: 3 additions & 2 deletions osu.Game/Users/Drawables/DrawableAvatar.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using osu.Framework.Graphics;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.Textures;
using osu.Game.Graphics;
using osu.Game.Online.API.Requests.Responses;

namespace osu.Game.Users.Drawables
Expand All @@ -31,12 +32,12 @@ public DrawableAvatar(IUser user = null)
}

[BackgroundDependencyLoader]
private void load(LargeTextureStore textures)
private void load(LargeTextureStore textures, OnlineAssetCachingStore onlineTextures)
{
if (user != null && user.OnlineID > 1)
// TODO: The fallback here should not need to exist. Users should be looked up and populated via UserLookupCache or otherwise
// in remaining cases where this is required (chat tabs, local leaderboard), at which point this should be removed.
Texture = textures.Get((user as APIUser)?.AvatarUrl ?? $@"https://a.ppy.sh/{user.OnlineID}");
Texture = onlineTextures.Get((user as APIUser)?.AvatarUrl ?? $@"https://a.ppy.sh/{user.OnlineID}");

Texture ??= textures.Get(@"Online/avatar-guest");
}
Expand Down
6 changes: 3 additions & 3 deletions osu.Game/Users/Drawables/DrawableTeamFlag.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.Textures;
using osu.Game.Graphics;
using osu.Game.Online.API.Requests.Responses;

namespace osu.Game.Users.Drawables
Expand Down Expand Up @@ -44,9 +44,9 @@ public DrawableTeamFlag(APITeam? team)
}

[BackgroundDependencyLoader]
private void load(LargeTextureStore textures)
private void load(OnlineAssetCachingStore textures)
{
if (team != null)
if (team?.FlagUrl != null)
sprite.Texture = textures.Get(team.FlagUrl);
}
}
Expand Down
4 changes: 2 additions & 2 deletions osu.Game/Users/UserCoverBackground.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.Textures;
using osu.Game.Graphics;
using osu.Game.Online.API.Requests.Responses;
using osuTK.Graphics;

Expand Down Expand Up @@ -51,7 +51,7 @@ public Cover(APIUser? user)
}

[BackgroundDependencyLoader]
private void load(LargeTextureStore textures)
private void load(OnlineAssetCachingStore textures)
{
if (user == null)
{
Expand Down
Loading