// Copyright (c) 2007-2017 ppy Pty Ltd . // Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Linq.Expressions; using System.Security.Cryptography; using osu.Framework.Logging; using osu.Framework.Platform; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Formats; using osu.Game.Beatmaps.IO; using osu.Game.IPC; using SQLite.Net; using SQLiteNetExtensions.Extensions; namespace osu.Game.Database { public class BeatmapDatabase { private SQLiteConnection connection { get; set; } private Storage storage; public event Action BeatmapSetAdded; public event Action BeatmapSetRemoved; private BeatmapImporter ipc; public BeatmapDatabase(Storage storage, GameHost importHost = null) { this.storage = storage; if (importHost != null) ipc = new BeatmapImporter(importHost, this); if (connection == null) { try { connection = prepareConnection(); deletePending(); } catch (Exception e) { Logger.Error(e, @"Failed to initialise the beatmap database! Trying again with a clean database..."); storage.DeleteDatabase(@"beatmaps"); connection = prepareConnection(); } } } private void deletePending() { foreach (var b in Query().Where(b => b.DeletePending)) { try { storage.Delete(b.Path); GetChildren(b, true); foreach (var i in b.Beatmaps) { if (i.Metadata != null) connection.Delete(i.Metadata); if (i.BaseDifficulty != null) connection.Delete(i.BaseDifficulty); connection.Delete(i); } if (b.Metadata != null) connection.Delete(b.Metadata); connection.Delete(b); } catch (Exception e) { Logger.Error(e, $@"Could not delete beatmap {b.ToString()}"); } } //this is required because sqlite migrations don't work, initially inserting nulls into this field. //see https://github.com/praeclarum/sqlite-net/issues/326 connection.Query("UPDATE BeatmapSetInfo SET DeletePending = 0 WHERE DeletePending IS NULL"); } private SQLiteConnection prepareConnection() { var conn = storage.GetDatabase(@"beatmaps"); try { conn.CreateTable(); conn.CreateTable(); conn.CreateTable(); conn.CreateTable(); } catch { conn.Close(); throw; } return conn; } public void Reset() { foreach (var setInfo in Query()) { if (storage.Exists(setInfo.Path)) storage.Delete(setInfo.Path); } connection.DeleteAll(); connection.DeleteAll(); connection.DeleteAll(); connection.DeleteAll(); } public void Import(IEnumerable paths) { Stack sets = new Stack(); foreach (string p in paths) try { BeatmapSetInfo set = getBeatmapSet(p); if (set.ID == 0) sets.Push(set); // We may or may not want to delete the file depending on where it is stored. // e.g. reconstructing/repairing database with beatmaps from default storage. // TODO: Add a check to prevent files from storage to be deleted. try { File.Delete(p); } catch (Exception e) { Logger.Error(e, $@"Could not delete file at {p}"); } } catch (Exception e) { Logger.Error(e, $@"Could not import beatmap set"); } // Batch commit with multiple sets to database Import(sets); } public void Import(string path) { Import(new [] { path }); } /// /// Duplicates content from to storage and returns a representing . /// Returns null if a representation already exists. /// /// Content location /// or null private BeatmapSetInfo getBeatmapSet(string path) { string hash = null; BeatmapMetadata metadata; using (var reader = ArchiveReader.GetReader(storage, path)) metadata = reader.ReadMetadata(); if (File.Exists(path)) // Not always the case, i.e. for LegacyFilesystemReader { using (var md5 = MD5.Create()) using (var input = storage.GetStream(path)) { hash = BitConverter.ToString(md5.ComputeHash(input)).Replace("-", "").ToLowerInvariant(); input.Seek(0, SeekOrigin.Begin); path = Path.Combine(@"beatmaps", hash.Remove(1), hash.Remove(2), hash); if (!storage.Exists(path)) using (var output = storage.GetStream(path, FileAccess.Write)) input.CopyTo(output); } } var existing = connection.Table().FirstOrDefault(b => b.Hash == hash); if (existing != null) { if (existing.DeletePending) { existing.DeletePending = false; Update(existing, false); BeatmapSetAdded?.Invoke(existing); } return existing; } var beatmapSet = new BeatmapSetInfo { OnlineBeatmapSetID = metadata.OnlineBeatmapSetID, Beatmaps = new List(), Path = path, Hash = hash, Metadata = metadata }; using (var reader = ArchiveReader.GetReader(storage, path)) { string[] mapNames = reader.BeatmapFilenames; foreach (var name in mapNames) using (var stream = new StreamReader(reader.GetStream(name))) { var decoder = BeatmapDecoder.GetDecoder(stream); Beatmap beatmap = decoder.Decode(stream); beatmap.BeatmapInfo.Path = name; // TODO: Diff beatmap metadata with set metadata and leave it here if necessary beatmap.BeatmapInfo.Metadata = null; beatmapSet.Beatmaps.Add(beatmap.BeatmapInfo); } beatmapSet.StoryboardFile = reader.StoryboardFilename; } return beatmapSet; } public void Import(IEnumerable beatmapSets) { lock (connection) { connection.BeginTransaction(); foreach (var s in beatmapSets) { connection.InsertWithChildren(s, true); BeatmapSetAdded?.Invoke(s); } connection.Commit(); } } public void Delete(BeatmapSetInfo beatmapSet) { beatmapSet.DeletePending = true; Update(beatmapSet, false); BeatmapSetRemoved?.Invoke(beatmapSet); } public ArchiveReader GetReader(BeatmapSetInfo beatmapSet) { if (string.IsNullOrEmpty(beatmapSet.Path)) return null; return ArchiveReader.GetReader(storage, beatmapSet.Path); } public BeatmapSetInfo GetBeatmapSet(int id) { return Query().FirstOrDefault(s => s.OnlineBeatmapSetID == id); } public WorkingBeatmap GetWorkingBeatmap(BeatmapInfo beatmapInfo, WorkingBeatmap previous = null, bool withStoryboard = false) { var beatmapSetInfo = Query().FirstOrDefault(s => s.ID == beatmapInfo.BeatmapSetInfoID); //we need metadata GetChildren(beatmapSetInfo); if (beatmapSetInfo == null) throw new InvalidOperationException($@"Beatmap set {beatmapInfo.BeatmapSetInfoID} is not in the local database."); if (beatmapInfo.Metadata == null) beatmapInfo.Metadata = beatmapSetInfo.Metadata; WorkingBeatmap working = new DatabaseWorkingBeatmap(this, beatmapInfo, beatmapSetInfo, withStoryboard); previous?.TransferTo(working); return working; } public TableQuery Query() where T : class { return connection.Table(); } public T GetWithChildren(object id) where T : class { return connection.GetWithChildren(id); } public List GetAllWithChildren(Expression> filter = null, bool recursive = true) where T : class { return connection.GetAllWithChildren(filter, recursive); } public T GetChildren(T item, bool recursive = false) { if (item == null) return default(T); connection.GetChildren(item, recursive); return item; } readonly Type[] validTypes = new[] { typeof(BeatmapSetInfo), typeof(BeatmapInfo), typeof(BeatmapMetadata), typeof(BaseDifficulty), }; public void Update(T record, bool cascade = true) where T : class { if (validTypes.All(t => t != typeof(T))) throw new ArgumentException(nameof(T), "Must be a type managed by BeatmapDatabase"); if (cascade) connection.UpdateWithChildren(record); else connection.Update(record); } public bool Exists(BeatmapSetInfo beatmapSet) => storage.Exists(beatmapSet.Path); private class DatabaseWorkingBeatmap : WorkingBeatmap { private readonly BeatmapDatabase database; public DatabaseWorkingBeatmap(BeatmapDatabase database, BeatmapInfo beatmapInfo, BeatmapSetInfo beatmapSetInfo, bool withStoryboard = false) : base(beatmapInfo, beatmapSetInfo, withStoryboard) { this.database = database; } protected override ArchiveReader GetReader() => database?.GetReader(BeatmapSetInfo); } } }