diff --git a/osu.Game/Database/RealmAccess.cs b/osu.Game/Database/RealmAccess.cs index 1eca90826a1b..6bcf3b4c6653 100644 --- a/osu.Game/Database/RealmAccess.cs +++ b/osu.Game/Database/RealmAccess.cs @@ -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. /// - private const int schema_version = 51; + private const int schema_version = 52; /// /// Lock object which is held during sections, blocking realm retrieval during blocking periods. @@ -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().Where(a => a.LastAccessed < onlineAssetAccessCutoff); + + foreach (var a in pendingDeleteOnlineAssets) + realm.Remove(a); + transaction.Commit(); } @@ -774,6 +781,8 @@ private Realm getRealmInstance() realmRetrievalLock.Wait(); currentThreadHasRealmRetrievalLock.Value = true; tookSemaphoreLock = true; + + ObjectDisposedException.ThrowIf(isDisposed, this); } else { diff --git a/osu.Game/Database/RealmObjectExtensions.cs b/osu.Game/Database/RealmObjectExtensions.cs index c334f1152dfc..5d4c1ff37805 100644 --- a/osu.Game/Database/RealmObjectExtensions.cs +++ b/osu.Game/Database/RealmObjectExtensions.cs @@ -180,6 +180,7 @@ private static void applyCommonConfiguration(IMapperConfigurationExpression c) c.CreateMap(); c.CreateMap(); c.CreateMap(); + c.CreateMap(); c.CreateMap(); } diff --git a/osu.Game/Graphics/OnlineAssetCachingStore.cs b/osu.Game/Graphics/OnlineAssetCachingStore.cs new file mode 100644 index 000000000000..5b1a9df532e7 --- /dev/null +++ b/osu.Game/Graphics/OnlineAssetCachingStore.cs @@ -0,0 +1,87 @@ +// Copyright (c) ppy Pty Ltd . 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 +{ + /// + /// + /// Store for retrieval and caching of assets (background, avatars, covers) retrieved from the web to disk. + /// + /// + /// This store assumes relies on the uniqueness of the URL of retrieved assets to determine identity. + /// Therefore, this store MUST 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). + /// + /// + /// This store MUST NOT be used with URLs containing naive cache-busting strings (e.g. test.jpg?TIMESTAMP) + /// as it both makes the caching ineffective AND trashes the cache with entries that will never be used again. + /// + /// + 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().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(); + } + } +} diff --git a/osu.Game/Models/RealmOnlineAsset.cs b/osu.Game/Models/RealmOnlineAsset.cs new file mode 100644 index 000000000000..e8a5d1a6eaa5 --- /dev/null +++ b/osu.Game/Models/RealmOnlineAsset.cs @@ -0,0 +1,41 @@ +// Copyright (c) ppy Pty Ltd . 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 +{ + /// + /// 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. + /// + public class RealmOnlineAsset : RealmObject + { + /// + /// Contains information about the original URL of the file and its location on disk. + /// + public RealmNamedFileUsage File { get; set; } = null!; + + /// + /// Contains the last time of access of this asset. + /// + /// + /// Assets that have not been accessed for over a month are purged + /// (). + /// + public DateTimeOffset LastAccessed { get; set; } = DateTimeOffset.Now; + + [UsedImplicitly] + private RealmOnlineAsset() + { + } + + public RealmOnlineAsset(RealmFile localFile, string remoteUrl) + { + File = new RealmNamedFileUsage(localFile, remoteUrl); + } + } +} diff --git a/osu.Game/OsuGameBase.cs b/osu.Game/OsuGameBase.cs index cc4fa5b618ca..0228af495465 100644 --- a/osu.Game/OsuGameBase.cs +++ b/osu.Game/OsuGameBase.cs @@ -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(LocalConfig); diff --git a/osu.Game/Users/Drawables/DrawableAvatar.cs b/osu.Game/Users/Drawables/DrawableAvatar.cs index bd09b9516482..137bff6af242 100644 --- a/osu.Game/Users/Drawables/DrawableAvatar.cs +++ b/osu.Game/Users/Drawables/DrawableAvatar.cs @@ -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 @@ -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"); } diff --git a/osu.Game/Users/Drawables/DrawableTeamFlag.cs b/osu.Game/Users/Drawables/DrawableTeamFlag.cs index 27b5f447a5ac..5db46da7bc67 100644 --- a/osu.Game/Users/Drawables/DrawableTeamFlag.cs +++ b/osu.Game/Users/Drawables/DrawableTeamFlag.cs @@ -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 @@ -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); } } diff --git a/osu.Game/Users/UserCoverBackground.cs b/osu.Game/Users/UserCoverBackground.cs index 4d248d450b81..7b7dc9306ad3 100644 --- a/osu.Game/Users/UserCoverBackground.cs +++ b/osu.Game/Users/UserCoverBackground.cs @@ -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; @@ -51,7 +51,7 @@ public Cover(APIUser? user) } [BackgroundDependencyLoader] - private void load(LargeTextureStore textures) + private void load(OnlineAssetCachingStore textures) { if (user == null) {