2017-07-27 15:56:41 +08:00
// Copyright (c) 2007-2017 ppy Pty Ltd <contact@ppy.sh>.
// 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 Ionic.Zip ;
using osu.Framework.Audio.Track ;
using osu.Framework.Extensions ;
using osu.Framework.Graphics.Textures ;
using osu.Framework.IO.Stores ;
using osu.Framework.Logging ;
using osu.Framework.Platform ;
using osu.Game.Beatmaps.Formats ;
using osu.Game.Beatmaps.IO ;
using osu.Game.IO ;
using osu.Game.IPC ;
2017-07-28 15:55:58 +08:00
using osu.Game.Overlays.Notifications ;
2017-07-27 15:56:41 +08:00
using osu.Game.Rulesets ;
using SQLite.Net ;
using FileInfo = osu . Game . IO . FileInfo ;
namespace osu.Game.Beatmaps
{
/// <summary>
/// Handles the storage and retrieval of Beatmaps/WorkingBeatmaps.
/// </summary>
public class BeatmapManager
{
/// <summary>
/// Fired when a new <see cref="BeatmapSetInfo"/> becomes available in the database.
/// </summary>
public event Action < BeatmapSetInfo > BeatmapSetAdded ;
/// <summary>
/// Fired when a <see cref="BeatmapSetInfo"/> is removed from the database.
/// </summary>
public event Action < BeatmapSetInfo > BeatmapSetRemoved ;
/// <summary>
/// A default representation of a WorkingBeatmap to use when no beatmap is available.
/// </summary>
public WorkingBeatmap DefaultBeatmap { private get ; set ; }
private readonly Storage storage ;
private readonly FileStore files ;
private readonly RulesetStore rulesets ;
private readonly BeatmapStore beatmaps ;
// ReSharper disable once NotAccessedField.Local (we should keep a reference to this so it is not finalised)
private BeatmapIPCChannel ipc ;
2017-07-31 17:03:55 +08:00
/// <summary>
/// Set an endpoint for notifications to be posted to.
/// </summary>
public Action < Notification > PostNotification { private get ; set ; }
public BeatmapManager ( Storage storage , FileStore files , SQLiteConnection connection , RulesetStore rulesets , IIpcHost importHost = null )
2017-07-27 15:56:41 +08:00
{
beatmaps = new BeatmapStore ( connection ) ;
beatmaps . BeatmapSetAdded + = s = > BeatmapSetAdded ? . Invoke ( s ) ;
beatmaps . BeatmapSetRemoved + = s = > BeatmapSetRemoved ? . Invoke ( s ) ;
this . storage = storage ;
this . files = files ;
this . rulesets = rulesets ;
if ( importHost ! = null )
ipc = new BeatmapIPCChannel ( importHost , this ) ;
}
/// <summary>
2017-07-28 15:55:58 +08:00
/// Import one or more <see cref="BeatmapSetInfo"/> from filesystem <paramref name="paths"/>.
/// This will post a notification tracking import progress.
2017-07-27 15:56:41 +08:00
/// </summary>
2017-07-28 15:55:58 +08:00
/// <param name="paths">One or more beatmap locations on disk.</param>
2017-07-27 15:56:41 +08:00
public void Import ( params string [ ] paths )
{
2017-07-28 15:55:58 +08:00
var notification = new ProgressNotification
{
Text = "Beatmap import is initialising..." ,
Progress = 0 ,
State = ProgressNotificationState . Active ,
} ;
2017-07-31 17:03:55 +08:00
PostNotification ? . Invoke ( notification ) ;
2017-07-28 15:55:58 +08:00
int i = 0 ;
2017-07-27 15:56:41 +08:00
foreach ( string path in paths )
{
2017-07-28 15:55:58 +08:00
if ( notification . State = = ProgressNotificationState . Cancelled )
// user requested abort
return ;
2017-07-27 15:56:41 +08:00
try
{
2017-07-28 15:55:58 +08:00
notification . Text = $"Importing ({i} of {paths.Length})\n{Path.GetFileName(path)}" ;
2017-07-27 15:56:41 +08:00
using ( ArchiveReader reader = getReaderFrom ( path ) )
Import ( reader ) ;
2017-07-28 15:55:58 +08:00
notification . Progress = ( float ) + + i / paths . Length ;
2017-07-27 15:56:41 +08:00
// 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.
// Also, not always a single file, i.e. for LegacyFilesystemReader
// TODO: Add a check to prevent files from storage to be deleted.
try
{
2017-07-28 14:08:56 +08:00
if ( File . Exists ( path ) )
File . Delete ( path ) ;
2017-07-27 15:56:41 +08:00
}
catch ( Exception e )
{
2017-07-28 14:08:56 +08:00
Logger . Error ( e , $@"Could not delete original file after import ({Path.GetFileName(path)})" ) ;
2017-07-27 15:56:41 +08:00
}
}
catch ( Exception e )
{
e = e . InnerException ? ? e ;
Logger . Error ( e , @"Could not import beatmap set" ) ;
}
}
2017-07-28 15:55:58 +08:00
notification . State = ProgressNotificationState . Completed ;
2017-07-27 15:56:41 +08:00
}
2017-07-28 14:36:23 +08:00
private readonly object importLock = new object ( ) ;
2017-07-28 13:42:04 +08:00
2017-07-27 15:56:41 +08:00
/// <summary>
/// Import a beatmap from an <see cref="ArchiveReader"/>.
/// </summary>
/// <param name="archiveReader">The beatmap to be imported.</param>
public BeatmapSetInfo Import ( ArchiveReader archiveReader )
{
2017-07-28 11:46:54 +08:00
// let's only allow one concurrent import at a time for now.
2017-07-28 14:36:23 +08:00
lock ( importLock )
2017-07-28 11:46:54 +08:00
{
BeatmapSetInfo set = importToStorage ( archiveReader ) ;
Import ( set ) ;
return set ;
}
2017-07-27 15:56:41 +08:00
}
/// <summary>
/// Import a beatmap from a <see cref="BeatmapSetInfo"/>.
/// </summary>
/// <param name="beatmapSetInfo">The beatmap to be imported.</param>
public void Import ( BeatmapSetInfo beatmapSetInfo )
{
// If we have an ID then we already exist in the database.
if ( beatmapSetInfo . ID ! = 0 ) return ;
2017-07-28 13:42:04 +08:00
lock ( beatmaps )
beatmaps . Add ( beatmapSetInfo ) ;
2017-07-27 15:56:41 +08:00
}
/// <summary>
/// Delete a beatmap from the manager.
/// Is a no-op for already deleted beatmaps.
/// </summary>
/// <param name="beatmapSet">The beatmap to delete.</param>
public void Delete ( BeatmapSetInfo beatmapSet )
{
2017-07-28 13:42:04 +08:00
lock ( beatmaps )
2017-07-28 11:46:54 +08:00
if ( ! beatmaps . Delete ( beatmapSet ) ) return ;
2017-07-27 15:56:41 +08:00
2017-07-28 13:42:04 +08:00
if ( ! beatmapSet . Protected )
files . Dereference ( beatmapSet . Files ) ;
2017-07-27 15:56:41 +08:00
}
/// <summary>
/// Returns a <see cref="BeatmapSetInfo"/> to a usable state if it has previously been deleted but not yet purged.
/// Is a no-op for already usable beatmaps.
/// </summary>
/// <param name="beatmapSet">The beatmap to restore.</param>
public void Undelete ( BeatmapSetInfo beatmapSet )
{
2017-07-28 13:42:04 +08:00
lock ( beatmaps )
2017-07-28 11:46:54 +08:00
if ( ! beatmaps . Undelete ( beatmapSet ) ) return ;
2017-07-27 15:56:41 +08:00
2017-07-28 13:42:04 +08:00
files . Reference ( beatmapSet . Files ) ;
2017-07-27 15:56:41 +08:00
}
/// <summary>
/// Retrieve a <see cref="WorkingBeatmap"/> instance for the provided <see cref="BeatmapInfo"/>
/// </summary>
/// <param name="beatmapInfo">The beatmap to lookup.</param>
/// <param name="previous">The currently loaded <see cref="WorkingBeatmap"/>. Allows for optimisation where elements are shared with the new beatmap.</param>
/// <returns>A <see cref="WorkingBeatmap"/> instance correlating to the provided <see cref="BeatmapInfo"/>.</returns>
public WorkingBeatmap GetWorkingBeatmap ( BeatmapInfo beatmapInfo , WorkingBeatmap previous = null )
{
2017-07-28 13:42:04 +08:00
if ( beatmapInfo = = null | | beatmapInfo = = DefaultBeatmap ? . BeatmapInfo )
return DefaultBeatmap ;
2017-07-27 15:56:41 +08:00
2017-07-28 13:42:04 +08:00
lock ( beatmaps )
2017-07-28 11:46:54 +08:00
beatmaps . Populate ( beatmapInfo ) ;
2017-07-27 15:56:41 +08:00
2017-07-28 13:42:04 +08:00
if ( beatmapInfo . BeatmapSet = = null )
throw new InvalidOperationException ( $@"Beatmap set {beatmapInfo.BeatmapSetInfoID} is not in the local database." ) ;
2017-07-27 15:56:41 +08:00
2017-07-28 13:42:04 +08:00
if ( beatmapInfo . Metadata = = null )
beatmapInfo . Metadata = beatmapInfo . BeatmapSet . Metadata ;
2017-07-27 15:56:41 +08:00
2017-07-28 13:42:04 +08:00
WorkingBeatmap working = new BeatmapManagerWorkingBeatmap ( files . Store , beatmapInfo ) ;
2017-07-27 15:56:41 +08:00
2017-07-28 13:42:04 +08:00
previous ? . TransferTo ( working ) ;
2017-07-27 15:56:41 +08:00
2017-07-28 13:42:04 +08:00
return working ;
2017-07-27 15:56:41 +08:00
}
/// <summary>
/// Reset the manager to an empty state.
/// </summary>
public void Reset ( )
{
2017-07-28 14:36:23 +08:00
lock ( beatmaps )
beatmaps . Reset ( ) ;
2017-07-27 15:56:41 +08:00
}
/// <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 ( Func < BeatmapSetInfo , bool > query )
{
2017-07-28 14:36:23 +08:00
lock ( beatmaps )
{
BeatmapSetInfo set = beatmaps . Query < BeatmapSetInfo > ( ) . FirstOrDefault ( query ) ;
2017-07-27 15:56:41 +08:00
2017-07-28 14:36:23 +08:00
if ( set ! = null )
beatmaps . Populate ( set ) ;
2017-07-27 15:56:41 +08:00
2017-07-28 14:36:23 +08:00
return set ;
}
2017-07-27 15:56:41 +08:00
}
/// <summary>
/// Perform a lookup query on available <see cref="BeatmapSetInfo"/>s.
/// </summary>
/// <param name="query">The query.</param>
/// <returns>Results from the provided query.</returns>
2017-07-28 14:36:23 +08:00
public List < BeatmapSetInfo > QueryBeatmapSets ( Expression < Func < BeatmapSetInfo , bool > > query )
{
lock ( beatmaps ) return beatmaps . QueryAndPopulate ( query ) ;
}
2017-07-27 15:56:41 +08:00
/// <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 ( Func < BeatmapInfo , bool > query )
{
2017-07-28 14:36:23 +08:00
lock ( beatmaps )
{
BeatmapInfo set = beatmaps . Query < BeatmapInfo > ( ) . FirstOrDefault ( query ) ;
2017-07-27 15:56:41 +08:00
2017-07-28 14:36:23 +08:00
if ( set ! = null )
beatmaps . Populate ( set ) ;
2017-07-27 15:56:41 +08:00
2017-07-28 14:36:23 +08:00
return set ;
}
2017-07-27 15:56:41 +08:00
}
/// <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>
2017-07-28 14:36:23 +08:00
public List < BeatmapInfo > QueryBeatmaps ( Expression < Func < BeatmapInfo , bool > > query )
{
lock ( beatmaps ) return beatmaps . QueryAndPopulate ( query ) ;
}
2017-07-27 15:56:41 +08:00
/// <summary>
/// Creates an <see cref="ArchiveReader"/> from a valid storage path.
/// </summary>
/// <param name="path">A file or folder path resolving the beatmap content.</param>
/// <returns>A reader giving access to the beatmap's content.</returns>
private ArchiveReader getReaderFrom ( string path )
{
if ( ZipFile . IsZipFile ( path ) )
return new OszArchiveReader ( storage . GetStream ( path ) ) ;
else
return new LegacyFilesystemReader ( path ) ;
}
/// <summary>
/// Import a beamap into our local <see cref="FileStore"/> storage.
/// If the beatmap is already imported, the existing instance will be returned.
/// </summary>
/// <param name="reader">The beatmap archive to be read.</param>
/// <returns>The imported beatmap, or an existing instance if it is already present.</returns>
private BeatmapSetInfo importToStorage ( ArchiveReader reader )
{
// for now, concatenate all .osu files in the set to create a unique hash.
MemoryStream hashable = new MemoryStream ( ) ;
foreach ( string file in reader . Filenames . Where ( f = > f . EndsWith ( ".osu" ) ) )
using ( Stream s = reader . GetStream ( file ) )
s . CopyTo ( hashable ) ;
2017-07-27 16:38:40 +08:00
var hash = hashable . ComputeSHA2Hash ( ) ;
2017-07-27 15:56:41 +08:00
// check if this beatmap has already been imported and exit early if so.
2017-08-01 08:38:43 +08:00
BeatmapSetInfo beatmapSet ;
lock ( beatmaps )
beatmapSet = beatmaps . QueryAndPopulate < BeatmapSetInfo > ( b = > b . Hash = = hash ) . FirstOrDefault ( ) ;
2017-07-27 15:56:41 +08:00
if ( beatmapSet ! = null )
{
Undelete ( beatmapSet ) ;
return beatmapSet ;
}
List < FileInfo > fileInfos = new List < FileInfo > ( ) ;
// import files to manager
foreach ( string file in reader . Filenames )
using ( Stream s = reader . GetStream ( file ) )
fileInfos . Add ( files . Add ( s , file ) ) ;
BeatmapMetadata metadata ;
using ( var stream = new StreamReader ( reader . GetStream ( reader . Filenames . First ( f = > f . EndsWith ( ".osu" ) ) ) ) )
metadata = BeatmapDecoder . GetDecoder ( stream ) . Decode ( stream ) . Metadata ;
beatmapSet = new BeatmapSetInfo
{
OnlineBeatmapSetID = metadata . OnlineBeatmapSetID ,
Beatmaps = new List < BeatmapInfo > ( ) ,
Hash = hash ,
Files = fileInfos ,
Metadata = metadata
} ;
var mapNames = reader . Filenames . Where ( f = > f . EndsWith ( ".osu" ) ) ;
foreach ( var name in mapNames )
{
using ( var raw = reader . GetStream ( name ) )
using ( var ms = new MemoryStream ( ) ) //we need a memory stream so we can seek and shit
using ( var sr = new StreamReader ( ms ) )
{
raw . CopyTo ( ms ) ;
ms . Position = 0 ;
var decoder = BeatmapDecoder . GetDecoder ( sr ) ;
Beatmap beatmap = decoder . Decode ( sr ) ;
beatmap . BeatmapInfo . Path = name ;
2017-07-27 16:38:40 +08:00
beatmap . BeatmapInfo . Hash = ms . ComputeSHA2Hash ( ) ;
2017-07-27 15:56:41 +08:00
// TODO: Diff beatmap metadata with set metadata and leave it here if necessary
beatmap . BeatmapInfo . Metadata = null ;
// TODO: this should be done in a better place once we actually need to dynamically update it.
beatmap . BeatmapInfo . Ruleset = rulesets . Query < RulesetInfo > ( ) . FirstOrDefault ( r = > r . ID = = beatmap . BeatmapInfo . RulesetID ) ;
beatmap . BeatmapInfo . StarDifficulty = rulesets . Query < RulesetInfo > ( ) . FirstOrDefault ( r = > r . ID = = beatmap . BeatmapInfo . RulesetID ) ? . CreateInstance ( ) ? . CreateDifficultyCalculator ( beatmap )
. Calculate ( ) ? ? 0 ;
beatmapSet . Beatmaps . Add ( beatmap . BeatmapInfo ) ;
}
}
return beatmapSet ;
}
/// <summary>
/// Returns a list of all usable <see cref="BeatmapSetInfo"/>s.
/// </summary>
/// <param name="populate">Whether returned objects should be pre-populated with all data.</param>
/// <returns>A list of available <see cref="BeatmapSetInfo"/>.</returns>
public List < BeatmapSetInfo > GetAllUsableBeatmapSets ( bool populate = true )
{
2017-07-28 14:36:23 +08:00
lock ( beatmaps )
2017-07-28 11:46:54 +08:00
{
if ( populate )
return beatmaps . QueryAndPopulate < BeatmapSetInfo > ( b = > ! b . DeletePending ) . ToList ( ) ;
else
return beatmaps . Query < BeatmapSetInfo > ( b = > ! b . DeletePending ) . ToList ( ) ;
}
2017-07-27 15:56:41 +08:00
}
protected class BeatmapManagerWorkingBeatmap : WorkingBeatmap
{
private readonly IResourceStore < byte [ ] > store ;
public BeatmapManagerWorkingBeatmap ( IResourceStore < byte [ ] > store , BeatmapInfo beatmapInfo )
: base ( beatmapInfo )
{
this . store = store ;
}
protected override Beatmap GetBeatmap ( )
{
try
{
Beatmap beatmap ;
BeatmapDecoder decoder ;
using ( var stream = new StreamReader ( store . GetStream ( getPathForFile ( BeatmapInfo . Path ) ) ) )
{
decoder = BeatmapDecoder . GetDecoder ( stream ) ;
beatmap = decoder . Decode ( stream ) ;
}
if ( beatmap = = null | | BeatmapSetInfo . StoryboardFile = = null )
return beatmap ;
using ( var stream = new StreamReader ( store . GetStream ( getPathForFile ( BeatmapSetInfo . StoryboardFile ) ) ) )
decoder . Decode ( stream , beatmap ) ;
return beatmap ;
}
catch { return null ; }
}
private string getPathForFile ( string filename ) = > BeatmapSetInfo . Files . First ( f = > f . Filename = = filename ) . StoragePath ;
protected override Texture GetBackground ( )
{
if ( Metadata ? . BackgroundFile = = null )
return null ;
try
{
return new TextureStore ( new RawTextureLoaderStore ( store ) , false ) . Get ( getPathForFile ( Metadata . BackgroundFile ) ) ;
}
catch { return null ; }
}
protected override Track GetTrack ( )
{
try
{
var trackData = store . GetStream ( getPathForFile ( Metadata . AudioFile ) ) ;
return trackData = = null ? null : new TrackBass ( trackData ) ;
}
catch { return new TrackVirtual ( ) ; }
}
}
2017-07-28 11:46:38 +08:00
public void ImportFromStable ( )
{
string stableInstallPath = Path . Combine ( Environment . GetFolderPath ( Environment . SpecialFolder . LocalApplicationData ) , @"osu!" , "Songs" ) ;
if ( ! Directory . Exists ( stableInstallPath ) )
stableInstallPath = Path . Combine ( Environment . GetFolderPath ( Environment . SpecialFolder . UserProfile ) , ".osu" , "Songs" ) ;
if ( ! Directory . Exists ( stableInstallPath ) )
{
Logger . Log ( "Couldn't find an osu!stable installation!" , LoggingTarget . Information , LogLevel . Error ) ;
return ;
}
Import ( Directory . GetDirectories ( stableInstallPath ) ) ;
}
2017-07-31 17:52:59 +08:00
public void DeleteAll ( )
{
var maps = GetAllUsableBeatmapSets ( ) . ToArray ( ) ;
if ( maps . Length = = 0 ) return ;
var notification = new ProgressNotification
{
Progress = 0 ,
State = ProgressNotificationState . Active ,
} ;
PostNotification ? . Invoke ( notification ) ;
int i = 0 ;
foreach ( var b in maps )
{
if ( notification . State = = ProgressNotificationState . Cancelled )
// user requested abort
return ;
notification . Text = $"Deleting ({i} of {maps.Length})" ;
notification . Progress = ( float ) + + i / maps . Length ;
Delete ( b ) ;
}
notification . State = ProgressNotificationState . Completed ;
}
2017-07-27 15:56:41 +08:00
}
}