diff --git a/Source/AppScaffolding/LibationScaffolding.cs b/Source/AppScaffolding/LibationScaffolding.cs index 9a0b385d..57b86ec8 100644 --- a/Source/AppScaffolding/LibationScaffolding.cs +++ b/Source/AppScaffolding/LibationScaffolding.cs @@ -90,6 +90,7 @@ namespace AppScaffolding Migrations.migrate_to_v6_6_9(config); Migrations.migrate_to_v11_5_0(config); Migrations.migrate_to_v11_6_5(config); + Migrations.migrate_to_v12_0_1(config); } /// Initialize logging. Wire-up events. Run after migration @@ -417,6 +418,82 @@ namespace AppScaffolding public List Filters { get; set; } = new(); } + + public static void migrate_to_v12_0_1(Configuration config) + { +#nullable enable + //Migrate from version 1 file cache to the dictionary-based version 2 cache + const string FILENAME_V1 = "FileLocations.json"; + const string FILENAME_V2 = "FileLocationsV2.json"; + + var jsonFileV1 = Path.Combine(Configuration.Instance.LibationFiles, FILENAME_V1); + var jsonFileV2 = Path.Combine(Configuration.Instance.LibationFiles, FILENAME_V2); + + if (!File.Exists(jsonFileV2) && File.Exists(jsonFileV1)) + { + try + { + //FilePathCache loads the cache in its static constructor, + //so perform migration without using FilePathCache.CacheEntry + if (JArray.Parse(File.ReadAllText(jsonFileV1)) is not JArray v1Cache || v1Cache.Count == 0) + return; + + Dictionary cache = new(); + + //Convert to c# objects to speed up searching by ID inside the iterator + var allItems + = v1Cache + .Select(i => new + { + Id = i["Id"]?.Value(), + Path = i["Path"]?["Path"]?.Value() + }).Where(i => i.Id != null) + .ToArray(); + + foreach (var id in allItems.Select(i => i.Id).OfType().Distinct()) + { + //Use this opportunity to purge non-existent files and re-classify file types + //(due to *.aax files previously not being classified as FileType.AAXC) + var items = allItems + .Where(i => i.Id == id && File.Exists(i.Path)) + .Select(i => new JObject + { + { "Id", i.Id }, + { "FileType", (int)FileTypes.GetFileTypeFromPath(i.Path) }, + { "Path", new JObject{ { "Path", i.Path } } } + }) + .ToArray(); + + if (items.Length == 0) + continue; + + cache[id] = new JArray(items); + } + + var cacheJson = new JObject { { "Dictionary", JObject.FromObject(cache) } }; + var cacheFileText = cacheJson.ToString(Formatting.Indented); + + void migrate() + { + File.WriteAllText(jsonFileV2, cacheFileText); + File.Delete(jsonFileV1); + } + + try { migrate(); } + catch (IOException) + { + try { migrate(); } + catch (IOException) + { + migrate(); + } + } + } + catch { /* eat */ } + } +#nullable restore + } + public static void migrate_to_v11_6_5(Configuration config) { //Settings migration for unsupported sample rates (#1116) diff --git a/Source/LibationFileManager/FilePathCache.cs b/Source/LibationFileManager/FilePathCache.cs index 21aee8f6..96724d4a 100644 --- a/Source/LibationFileManager/FilePathCache.cs +++ b/Source/LibationFileManager/FilePathCache.cs @@ -1,8 +1,8 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; -using Dinah.Core.Collections.Immutable; using FileManager; using Newtonsoft.Json; @@ -13,78 +13,96 @@ namespace LibationFileManager { public record CacheEntry(string Id, FileType FileType, LongPath Path); - private const string FILENAME = "FileLocations.json"; + private const string FILENAME_V2 = "FileLocationsV2.json"; public static event EventHandler? Inserted; public static event EventHandler? Removed; - private static Cache cache { get; } = new Cache(); + private static LongPath jsonFileV2 => Path.Combine(Configuration.Instance.LibationFiles, FILENAME_V2); - private static LongPath jsonFile => Path.Combine(Configuration.Instance.LibationFiles, FILENAME); + private static readonly FileCacheV2 Cache = new(); - static FilePathCache() - { + static FilePathCache() + { // load json into memory. if file doesn't exist, nothing to do. save() will create if needed - if (!File.Exists(jsonFile)) + if (!File.Exists(jsonFileV2)) return; - try - { - var list = JsonConvert.DeserializeObject>(File.ReadAllText(jsonFile)); - if (list is null) - throw new NullReferenceException("File exists but deserialize is null. This will never happen when file is healthy."); - - cache = new Cache(list); - } - catch (Exception ex) - { - Serilog.Log.Logger.Error(ex, "Error deserializing file. Wrong format. Possibly corrupt. Deleting file. {@DebugInfo}", new { jsonFile }); - lock (locker) - File.Delete(jsonFile); - return; - } - } - - public static bool Exists(string id, FileType type) => GetFirstPath(id, type) is not null; - - public static List<(FileType fileType, LongPath path)> GetFiles(string id) - => getEntries(entry => entry.Id == id) - .Select(entry => (entry.FileType, entry.Path)) - .ToList(); - - public static LongPath? GetFirstPath(string id, FileType type) - => getEntries(entry => entry.Id == id && entry.FileType == type) - ?.FirstOrDefault() - ?.Path; - - private static IEnumerable getEntries(Func predicate) - { - var entries = cache.Where(predicate).ToList(); - if (entries is null || !entries.Any()) - return Enumerable.Empty(); - - remove(entries.Where(e => !File.Exists(e.Path)).ToList()); - - return cache.Where(predicate).ToList(); - } - - private static void remove(List entries) - { - if (entries is null) - return; - - lock (locker) + try { - foreach (var entry in entries) - { - cache.Remove(entry); - Removed?.Invoke(null, entry); - } - save(); + Cache = JsonConvert.DeserializeObject>(File.ReadAllText(jsonFileV2)) + ?? throw new NullReferenceException("File exists but deserialize is null. This will never happen when file is healthy."); + } + catch (Exception ex) + { + Serilog.Log.Logger.Error(ex, "Error deserializing file. Wrong format. Possibly corrupt. Deleting file. {@DebugInfo}", new { jsonFileV2 }); + lock (locker) + File.Delete(jsonFileV2); + return; } } - public static void Insert(string id, string path) + public static bool Exists(string id, FileType type) => GetFirstPath(id, type) is not null; + + public static List<(FileType fileType, LongPath path)> GetFiles(string id) + { + var matchingFiles = Cache.GetIdEntries(id); + + bool cacheChanged = false; + + //Verify all entries exist + for (int i = 0; i < matchingFiles.Count; i++) + { + if (!File.Exists(matchingFiles[i].Path)) + { + matchingFiles.RemoveAt(i); + cacheChanged |= Remove(matchingFiles[i]); + } + } + if (cacheChanged) + save(); + + return matchingFiles.Select(e => (e.FileType, e.Path)).ToList(); + } + + public static LongPath? GetFirstPath(string id, FileType type) + { + var matchingFiles = Cache.GetIdEntries(id).Where(e => e.FileType == type).ToList(); + + bool cacheChanged = false; + try + { + //Verify entries exist, but return first matching 'type' + for (int i = 0; i < matchingFiles.Count; i++) + { + if (File.Exists(matchingFiles[i].Path)) + return matchingFiles[i].Path; + else + { + matchingFiles.RemoveAt(i); + cacheChanged |= Remove(matchingFiles[i]); + } + } + return null; + } + finally + { + if (cacheChanged) + save(); + } + } + + private static bool Remove(CacheEntry entry) + { + if (Cache.Remove(entry.Id, entry)) + { + Removed?.Invoke(null, entry); + return true; + } + return false; + } + + public static void Insert(string id, string path) { var type = FileTypes.GetFileTypeFromPath(path); Insert(new CacheEntry(id, type, path)); @@ -92,7 +110,7 @@ namespace LibationFileManager public static void Insert(CacheEntry entry) { - cache.Add(entry); + Cache.Add(entry.Id, entry); Inserted?.Invoke(null, entry); save(); } @@ -102,7 +120,7 @@ namespace LibationFileManager private static void save() { // create json if not exists - static void resave() => File.WriteAllText(jsonFile, JsonConvert.SerializeObject(cache.ToList(), Formatting.Indented)); + static void resave() => File.WriteAllText(jsonFileV2, JsonConvert.SerializeObject(Cache, Formatting.Indented)); lock (locker) { @@ -112,11 +130,41 @@ namespace LibationFileManager try { resave(); } catch (IOException ex) { - Serilog.Log.Logger.Error(ex, $"Error saving {FILENAME}"); + Serilog.Log.Logger.Error(ex, $"Error saving {FILENAME_V2}"); throw; } } } - } - } + } + + private class FileCacheV2 + { + [JsonProperty] + private readonly ConcurrentDictionary> Dictionary = new(); + + public List GetIdEntries(string id) + { + static List empty() => new(); + + return Dictionary.TryGetValue(id, out var entries) ? entries.ToList() : empty(); + } + + public void Add(string id, TEntry entry) + { + Dictionary.AddOrUpdate(id, [entry], (id, entries) => { entries.Add(entry); return entries; }); + } + + public void AddRange(string id, IEnumerable entries) + { + Dictionary.AddOrUpdate(id, entries.ToList(), (id, entries) => + { + entries.AddRange(entries); + return entries; + }); + } + + public bool Remove(string id, TEntry entry) + => Dictionary.TryGetValue(id, out List? entries) && entries.Remove(entry); + } + } } diff --git a/Source/LibationFileManager/FileTypes.cs b/Source/LibationFileManager/FileTypes.cs index 91c2fd25..591525b9 100644 --- a/Source/LibationFileManager/FileTypes.cs +++ b/Source/LibationFileManager/FileTypes.cs @@ -11,6 +11,7 @@ namespace LibationFileManager { private static Dictionary dic => new() { + ["aax"] = FileType.AAXC, ["aaxc"] = FileType.AAXC, ["cue"] = FileType.Cue, ["pdf"] = FileType.PDF,