-
-
Notifications
You must be signed in to change notification settings - Fork 2.6k
Expand file tree
/
Copy pathBeatmapImporter.cs
More file actions
477 lines (381 loc) · 21.1 KB
/
BeatmapImporter.cs
File metadata and controls
477 lines (381 loc) · 21.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
// 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.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using osu.Framework.Extensions;
using osu.Framework.Extensions.IEnumerableExtensions;
using osu.Framework.Localisation;
using osu.Framework.Logging;
using osu.Framework.Platform;
using osu.Game.Beatmaps.Formats;
using osu.Game.Collections;
using osu.Game.Database;
using osu.Game.Extensions;
using osu.Game.IO;
using osu.Game.IO.Archives;
using osu.Game.Localisation;
using osu.Game.Overlays.Notifications;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Objects.Types;
using Realms;
namespace osu.Game.Beatmaps
{
/// <summary>
/// Handles the storage and retrieval of Beatmaps/WorkingBeatmaps.
/// </summary>
public class BeatmapImporter : RealmArchiveModelImporter<BeatmapSetInfo>
{
public override IEnumerable<string> HandledExtensions => new[] { ".osz", ".olz" };
protected override string[] HashableFileTypes => new[] { ".osu" };
public ProcessBeatmapDelegate? ProcessBeatmap { private get; set; }
public BeatmapImporter(Storage storage, RealmAccess realm)
: base(storage, realm)
{
}
public override async Task<Live<BeatmapSetInfo>?> ImportAsUpdate(ProgressNotification notification, ImportTask importTask, BeatmapSetInfo original)
{
var originalDateAdded = original.DateAdded;
Guid originalId = original.ID;
var imported = await Import(notification, new[] { importTask }).ConfigureAwait(false);
if (!imported.Any())
return null;
Debug.Assert(imported.Count() == 1);
var first = imported.First();
// If there were no changes, ensure we don't accidentally nuke ourselves.
if (first.ID == originalId)
{
first.PerformWrite(s =>
{
// Transfer local values which should be persisted across a beatmap update.
s.DateAdded = originalDateAdded;
// Re-run processing even in this case. We might have outdated metadata.
ProcessBeatmap?.Invoke(s, MetadataLookupScope.OnlineFirst);
});
return first;
}
first.PerformWrite(updated =>
{
try
{
var realm = updated.Realm;
// Re-fetch as we are likely on a different thread.
original = realm!.Find<BeatmapSetInfo>(originalId)!;
// Generally the import process will do this for us if the OnlineIDs match,
// but that isn't a guarantee (ie. if the .osu file doesn't have OnlineIDs populated).
original.DeletePending = true;
// Transfer local values which should be persisted across a beatmap update.
updated.DateAdded = originalDateAdded;
transferCollectionReferences(realm, original, updated);
foreach (var beatmap in original.Beatmaps.ToArray())
{
var updatedBeatmap = updated.Beatmaps.FirstOrDefault(b => b.Hash == beatmap.Hash);
if (updatedBeatmap != null)
{
// If the updated beatmap matches an existing one, transfer any user data across..
if (beatmap.Scores.Any())
{
Logger.Log($"Transferring {beatmap.Scores.Count()} scores for unchanged difficulty \"{beatmap}\"", LoggingTarget.Database);
foreach (var score in beatmap.Scores)
score.BeatmapInfo = updatedBeatmap;
}
// ..then nuke the old beatmap completely.
// this is done instead of a soft deletion to avoid a user potentially creating weird
// interactions, like restoring the outdated beatmap then updating a second time
// (causing user data to be wiped).
original.Beatmaps.Remove(beatmap);
realm.Remove(beatmap.Metadata);
realm.Remove(beatmap);
}
else
{
// If the beatmap differs in the original, leave it in a soft-deleted state but reset online info.
// This caters to the case where a user has made modifications they potentially want to restore,
// but after restoring we want to ensure it can't be used to trigger an update of the beatmap.
beatmap.ResetOnlineInfo();
}
}
// If the original has no beatmaps left, delete the set as well.
if (!original.Beatmaps.Any())
realm.Remove(original);
Logger.Log($"Beatmap \"{updated}\" update completed successfully", LoggingTarget.Database);
}
catch (Exception ex)
{
Logger.Error(ex, $"Failed to update beatmap \"{updated}\"", LoggingTarget.Database);
throw;
}
});
return first;
}
private static void transferCollectionReferences(Realm realm, BeatmapSetInfo original, BeatmapSetInfo updated)
{
// First check if every beatmap in the original set is in any collections.
// In this case, we will assume they also want any newly added difficulties added to the collection.
foreach (var c in realm.All<BeatmapCollection>())
{
if (original.Beatmaps.Select(b => b.MD5Hash).All(c.BeatmapMD5Hashes.Contains))
{
foreach (var b in original.Beatmaps)
c.BeatmapMD5Hashes.Remove(b.MD5Hash);
foreach (var b in updated.Beatmaps)
c.BeatmapMD5Hashes.Add(b.MD5Hash);
}
}
// Handle collections using permissive difficulty name to track difficulties.
foreach (var originalBeatmap in original.Beatmaps)
{
updated.Beatmaps
.FirstOrDefault(b => b.DifficultyName == originalBeatmap.DifficultyName)?
.TransferCollectionReferences(realm, originalBeatmap.MD5Hash);
}
}
protected override bool ShouldDeleteArchive(string path) => HandledExtensions.Contains(Path.GetExtension(path).ToLowerInvariant());
protected override void Populate(BeatmapSetInfo beatmapSet, ArchiveReader? archive, Realm realm, CancellationToken cancellationToken = default)
{
if (archive != null)
beatmapSet.Beatmaps.AddRange(createBeatmapDifficulties(beatmapSet, realm));
beatmapSet.DateAdded = getDateAdded(archive);
foreach (BeatmapInfo b in beatmapSet.Beatmaps)
{
b.BeatmapSet = beatmapSet;
// ensure we aren't trying to add a new ruleset to the database
// this can happen in tests, mostly
if (!b.Ruleset.IsManaged)
b.Ruleset = realm.Find<RulesetInfo>(b.Ruleset.ShortName) ?? throw new ArgumentNullException(nameof(b.Ruleset));
}
validateOnlineIds(beatmapSet, realm);
bool hadOnlineIDs = beatmapSet.Beatmaps.Any(b => b.OnlineID > 0);
// TODO: this may no longer be valid as we aren't doing an online population at this point.
// ensure at least one beatmap was able to retrieve or keep an online ID, else drop the set ID.
if (hadOnlineIDs && !beatmapSet.Beatmaps.Any(b => b.OnlineID > 0))
{
if (beatmapSet.OnlineID > 0)
{
beatmapSet.OnlineID = -1;
LogForModel(beatmapSet, "Disassociating beatmap set ID due to loss of all beatmap IDs");
}
}
}
protected override void PreImport(BeatmapSetInfo beatmapSet, Realm realm)
{
// We are about to import a new beatmap. Before doing so, ensure that no other set shares the online IDs used by the new one.
// Note that this means if the previous beatmap is restored by the user, it will no longer be linked to its online IDs.
// If this is ever an issue, we can consider marking as pending delete but not resetting the IDs (but care will be required for
// beatmaps, which don't have their own `DeletePending` state).
if (beatmapSet.OnlineID > 0)
{
// Required local for iOS. Will cause runtime crash if inlined.
int onlineId = beatmapSet.OnlineID;
// OnlineID should really be unique, but to avoid catastrophic failure let's iterate just to be sure.
foreach (var existingSetWithSameOnlineID in realm.All<BeatmapSetInfo>().Where(b => b.OnlineID == onlineId))
{
existingSetWithSameOnlineID.DeletePending = true;
existingSetWithSameOnlineID.OnlineID = -1;
foreach (var b in existingSetWithSameOnlineID.Beatmaps)
b.ResetOnlineInfo();
LogForModel(beatmapSet, $"Found existing beatmap set with same OnlineID ({beatmapSet.OnlineID}). It will be disassociated and marked for deletion.");
}
}
}
protected override void PostImport(BeatmapSetInfo model, Realm realm, ImportParameters parameters)
{
base.PostImport(model, realm, parameters);
// Scores are stored separately from beatmaps, and persist even when a beatmap is modified or deleted.
// Let's reattach any matching scores that exist in the database, based on hash.
foreach (BeatmapInfo beatmap in model.Beatmaps)
{
beatmap.UpdateLocalScores(realm);
}
ProcessBeatmap?.Invoke(model, parameters.Batch ? MetadataLookupScope.LocalCacheFirst : MetadataLookupScope.OnlineFirst);
}
private void validateOnlineIds(BeatmapSetInfo beatmapSet, Realm realm)
{
var beatmapIds = beatmapSet.Beatmaps.Where(b => b.OnlineID > 0).Select(b => b.OnlineID).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
List<BeatmapInfo> existingBeatmaps = new List<BeatmapInfo>();
foreach (int id in beatmapIds)
existingBeatmaps.AddRange(realm.All<BeatmapInfo>().Where(b => b.OnlineID == id));
if (existingBeatmaps.Any())
{
// 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, realm);
if (existing == null || existingBeatmaps.Any(b => !existing.Beatmaps.Contains(b)))
{
LogForModel(beatmapSet, "Found existing import with online IDs already, resetting...");
resetIds();
}
}
void resetIds() => beatmapSet.Beatmaps.ForEach(b => b.ResetOnlineInfo());
}
protected override bool CanSkipImport(BeatmapSetInfo existing, BeatmapSetInfo import)
{
if (!base.CanSkipImport(existing, import))
return false;
return existing.Beatmaps.Any(b => b.OnlineID > 0);
}
protected override bool CanReuseExisting(BeatmapSetInfo existing, BeatmapSetInfo import)
{
if (!base.CanReuseExisting(existing, import))
return false;
var existingIds = existing.Beatmaps.Select(b => b.OnlineID).Order();
var importIds = import.Beatmaps.Select(b => b.OnlineID).Order();
// force re-import if we are not in a sane state.
return existing.OnlineID == import.OnlineID && existingIds.SequenceEqual(importIds);
}
protected override void UndeleteForReuse(BeatmapSetInfo existing)
{
if (!existing.DeletePending)
return;
base.UndeleteForReuse(existing);
existing.DateAdded = DateTimeOffset.UtcNow;
}
public override string HumanisedModelName => "beatmap";
protected override BeatmapSetInfo? CreateModel(ArchiveReader reader, ImportParameters parameters)
{
// 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)))
{
if (stream.PeekLine() == null)
{
Logger.Log($"No content found in first .osu file of beatmap archive ({reader.Name} / {mapName})", LoggingTarget.Database);
return null;
}
beatmap = Decoder.GetDecoder<Beatmap>(stream).Decode(stream);
}
return new BeatmapSetInfo
{
OnlineID = beatmap.BeatmapInfo.BeatmapSet?.OnlineID ?? -1,
};
}
/// <summary>
/// Determine the date a given beatmapset has been added to the game.
/// For legacy imports, we can use the oldest file write time for any `.osu` file in the directory.
/// For any other import types, use "now".
/// </summary>
private DateTimeOffset getDateAdded(ArchiveReader? reader)
{
DateTimeOffset dateAdded = DateTimeOffset.UtcNow;
if (reader is DirectoryArchiveReader legacyReader)
{
var beatmaps = reader.Filenames.Where(f => f.EndsWith(".osu", StringComparison.OrdinalIgnoreCase));
dateAdded = File.GetLastWriteTimeUtc(legacyReader.GetFullPath(beatmaps.First()));
foreach (string beatmapName in beatmaps)
{
var currentDateAdded = File.GetLastWriteTimeUtc(legacyReader.GetFullPath(beatmapName));
if (currentDateAdded < dateAdded)
dateAdded = currentDateAdded;
}
}
return dateAdded;
}
/// <summary>
/// Create all required <see cref="BeatmapInfo"/>s for the provided archive.
/// </summary>
private List<BeatmapInfo> createBeatmapDifficulties(BeatmapSetInfo beatmapSet, Realm realm)
{
var beatmaps = new List<BeatmapInfo>();
// stable appears to ignore `.osu` files which are not placed at the top level of the beatmap archive.
// the logic that achieves this is very difficult to make sense of, but appears to be located somewhere around
// https://github.com/peppy/osu-stable-reference/blob/67795dba3c308e7d0493b296149dcb073ca47ecb/osu!/GameplayElements/Beatmaps/BeatmapManager.cs#L207-L208
// only testing the `/` path separator character is sufficient as `RealmNamedFileUsage`s are normalised to use the front slash unix path separator convention
foreach (var file in beatmapSet.Files.Where(f => !f.Filename.Contains('/') && f.Filename.EndsWith(@".osu", StringComparison.OrdinalIgnoreCase)))
{
using (var memoryStream = new MemoryStream(Files.Store.Get(file.File.GetStoragePath()))) // we need a memory stream so we can seek
{
IBeatmap decoded;
using (var lineReader = new LineBufferedReader(memoryStream, true))
{
if (lineReader.PeekLine() == null)
{
LogForModel(beatmapSet, $"No content found in beatmap file {file.Filename}.");
continue;
}
decoded = Decoder.GetDecoder<Beatmap>(lineReader).Decode(lineReader);
}
string hash = memoryStream.ComputeSHA2Hash();
if (beatmaps.Any(b => b.Hash == hash))
{
LogForModel(beatmapSet, $"Skipping import of {file.Filename} due to duplicate file content.");
continue;
}
var decodedInfo = decoded.BeatmapInfo;
var decodedDifficulty = decodedInfo.Difficulty;
var ruleset = realm.All<RulesetInfo>().FirstOrDefault(r => r.OnlineID == decodedInfo.Ruleset.OnlineID);
if (ruleset?.Available != true)
{
LogForModel(beatmapSet, $"Skipping import of {file.Filename} due to missing local ruleset {decodedInfo.Ruleset.OnlineID}.");
continue;
}
var difficulty = new BeatmapDifficulty
{
DrainRate = decodedDifficulty.DrainRate,
CircleSize = decodedDifficulty.CircleSize,
OverallDifficulty = decodedDifficulty.OverallDifficulty,
ApproachRate = decodedDifficulty.ApproachRate,
SliderMultiplier = decodedDifficulty.SliderMultiplier,
SliderTickRate = decodedDifficulty.SliderTickRate
};
var metadata = new BeatmapMetadata
{
Title = decoded.Metadata.Title,
TitleUnicode = decoded.Metadata.TitleUnicode,
Artist = decoded.Metadata.Artist,
ArtistUnicode = decoded.Metadata.ArtistUnicode,
Author =
{
OnlineID = decoded.Metadata.Author.OnlineID,
Username = decoded.Metadata.Author.Username
},
Source = decoded.Metadata.Source,
Tags = decoded.Metadata.Tags,
PreviewTime = decoded.Metadata.PreviewTime,
AudioFile = decoded.Metadata.AudioFile,
BackgroundFile = decoded.Metadata.BackgroundFile,
};
var beatmap = new BeatmapInfo(ruleset, difficulty, metadata)
{
Hash = hash,
DifficultyName = decodedInfo.DifficultyName,
OnlineID = decodedInfo.OnlineID,
BeatDivisor = decodedInfo.BeatDivisor,
MD5Hash = memoryStream.ComputeMD5Hash(),
EndTimeObjectCount = decoded.HitObjects.Count(h => h is IHasDuration),
TotalObjectCount = decoded.HitObjects.Count
};
beatmaps.Add(beatmap);
}
}
if (!beatmaps.Any())
throw new ArgumentException("No valid beatmap files found in the beatmap archive.");
return beatmaps;
}
protected override LocalisableString ImportAbortedText => NotificationsStrings.ImportBeatmapsAborted;
protected override LocalisableString ImportStartingText => NotificationsStrings.ImportBeatmapsStarting;
protected override LocalisableString ImportRunningText(int processedCount, int totalCount) => NotificationsStrings.ImportBeatmapsRunning(processedCount, totalCount);
protected override LocalisableString ImportCompletedText(int totalCount) => NotificationsStrings.ImportBeatmapsCompleted(totalCount);
protected override LocalisableString ImportIncompletedText(int processedCount, int totalCount) => NotificationsStrings.ImportBeatmapsIncompleted(processedCount, totalCount);
protected override LocalisableString ImportFailedText => NotificationsStrings.ImportBeatmapsFailed;
protected override LocalisableString ImportPausedText => NotificationsStrings.ImportBeatmapsPaused;
protected override LocalisableString ImportResumingText => NotificationsStrings.ImportBeatmapsResuming;
}
}