mirror of
https://github.com/rmcrackan/Libation.git
synced 2026-09-12 21:57:19 -04:00
565 lines
20 KiB
C#
565 lines
20 KiB
C#
using ApplicationServices;
|
|
using AudibleUtilities;
|
|
using Dinah.Core.IO;
|
|
using Dinah.Core.Logging;
|
|
using LibationFileManager;
|
|
using Newtonsoft.Json;
|
|
using Newtonsoft.Json.Linq;
|
|
using Serilog;
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Diagnostics;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Reflection;
|
|
using System.Runtime.InteropServices;
|
|
|
|
namespace AppScaffolding;
|
|
|
|
public enum ReleaseIdentifier
|
|
{
|
|
None,
|
|
WindowsClassic = OS.Windows | Variety.Classic | Architecture.X64,
|
|
WindowsAvalonia = OS.Windows | Variety.Chardonnay | Architecture.X64,
|
|
LinuxAvalonia = OS.Linux | Variety.Chardonnay | Architecture.X64,
|
|
MacOSAvalonia = OS.MacOS | Variety.Chardonnay | Architecture.X64,
|
|
LinuxAvalonia_Arm64 = OS.Linux | Variety.Chardonnay | Architecture.Arm64,
|
|
MacOSAvalonia_Arm64 = OS.MacOS | Variety.Chardonnay | Architecture.Arm64,
|
|
WindowsAvalonia_Arm64 = OS.Windows | Variety.Chardonnay | Architecture.Arm64,
|
|
}
|
|
|
|
// I know I'm taking the wine metaphor a bit far by naming this "Variety", but I don't know what else to call it
|
|
[Flags]
|
|
public enum Variety
|
|
{
|
|
None,
|
|
Classic = 0x10000,
|
|
Chardonnay = 0x20000,
|
|
}
|
|
|
|
public static class LibationScaffolding
|
|
{
|
|
public const string RepositoryUrl = "ht" + "tps://github.com/rmcrackan/Libation";
|
|
public const string WebsiteUrl = "ht" + "tps://getlibation.com";
|
|
public const string DonateUrl = WebsiteUrl + "/donate";
|
|
public const string RepositoryLatestUrl = "ht" + "tps://github.com/rmcrackan/Libation/releases/latest";
|
|
/// <summary>Documentation for naming template syntax</summary>
|
|
public const string NamingTemplatesDocUrl = WebsiteUrl + "/docs/features/naming-templates";
|
|
public static ReleaseIdentifier ReleaseIdentifier { get; private set; }
|
|
public static Variety Variety { get; private set; }
|
|
|
|
// AppScaffolding
|
|
private static Assembly? _executingAssembly;
|
|
private static Assembly ExecutingAssembly
|
|
=> _executingAssembly ??= Assembly.GetExecutingAssembly();
|
|
|
|
// LibationWinForms or LibationCli
|
|
private static Assembly? _entryAssembly;
|
|
private static Assembly? EntryAssembly
|
|
=> _entryAssembly ??= Assembly.GetEntryAssembly();
|
|
|
|
// previously: System.Reflection.Assembly.GetExecutingAssembly().GetName().Version;
|
|
private static Version? _buildVersion;
|
|
public static Version? BuildVersion
|
|
=> _buildVersion ??= new[] { ExecutingAssembly.GetName(), EntryAssembly?.GetName() }.Max(a => a?.Version);
|
|
|
|
/// <summary>Run migrations before loading Configuration for the first time. Then load and return Configuration</summary>
|
|
public static Configuration RunPreConfigMigrations()
|
|
{
|
|
// must occur before access to Configuration instance
|
|
// // outdated. kept here as an example of what belongs in this area
|
|
// // Migrations.migrate_to_v5_2_0__pre_config();
|
|
|
|
InstallFolderUnblock.TryUnblockProcessDirectoryIfWindows();
|
|
|
|
Configuration.SetLibationVersion(BuildVersion);
|
|
|
|
//***********************************************//
|
|
// //
|
|
// do not use Configuration before this line //
|
|
// //
|
|
//***********************************************//
|
|
return Configuration.Instance;
|
|
}
|
|
|
|
/// <summary>most migrations go in here</summary>
|
|
public static void RunPostConfigMigrations(Configuration config, bool ephemeralSettings = false)
|
|
{
|
|
if (ephemeralSettings)
|
|
{
|
|
var settings = JObject.Parse(File.ReadAllText(config.LibationFiles.SettingsFilePath));
|
|
config.LoadEphemeralSettings(settings);
|
|
}
|
|
else
|
|
{
|
|
config.LoadPersistentSettings(config.LibationFiles.SettingsFilePath);
|
|
}
|
|
config.ValidateEnumSettings();
|
|
DeleteOpenSqliteFiles(config);
|
|
AudibleApiStorage.EnsureAccountsSettingsFileExists();
|
|
IdentityTokenStorageWiring.Apply(config);
|
|
|
|
//
|
|
// migrations go below here
|
|
//
|
|
|
|
Migrations.migrate_to_v12_0_1(config);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Delete shared memory and write-ahead log SQLite database files which may prevent access to the database.
|
|
/// These file may or may not cause libation to hang on CreateContext,
|
|
/// so try our luck by swallowing any exceptions and continuing.
|
|
/// </summary>
|
|
private static void DeleteOpenSqliteFiles(Configuration config)
|
|
{
|
|
var dbFile = SqliteStorage.DatabasePath;
|
|
var walFile = dbFile + "-wal";
|
|
var shmFile = dbFile + "-shm";
|
|
|
|
// If another Libation process currently has the database open, its WAL/SHM are live state.
|
|
// Deleting them would corrupt that process's database (and ours), so leave everything alone.
|
|
// See issue #1931.
|
|
if (IsFileLockedByAnotherProcess(dbFile))
|
|
{
|
|
Log.Logger.Information("Skipping SQLite WAL/SHM cleanup: the database appears to be open in another process.");
|
|
return;
|
|
}
|
|
|
|
// A non-empty WAL from a previous run that ended abruptly can hold committed-but-uncheckpointed
|
|
// transactions. Deleting it would silently discard that data; instead, leave it in place and let
|
|
// SQLite recover it when the database is next opened. Only remove an already-checkpointed WAL.
|
|
if (File.Exists(walFile))
|
|
{
|
|
if (WalHoldsUnrecoveredTransactions(walFile))
|
|
Log.Logger.Information("Leaving SQLite WAL in place so SQLite can recover it on open: {WalFile}", walFile);
|
|
else
|
|
FileManager.FileUtility.TrySaferDelete(walFile);
|
|
}
|
|
|
|
// The SHM (shared-memory index) is rebuilt from the WAL/database, so it is safe to remove once
|
|
// no other process holds the database.
|
|
if (File.Exists(shmFile))
|
|
FileManager.FileUtility.TrySaferDelete(shmFile);
|
|
}
|
|
|
|
/// <summary>True if <paramref name="path"/> exists and cannot be opened exclusively, i.e. another process holds it.</summary>
|
|
private static bool IsFileLockedByAnotherProcess(string path)
|
|
{
|
|
if (!File.Exists(path))
|
|
return false;
|
|
|
|
try
|
|
{
|
|
using var _ = new FileStream(path, FileMode.Open, FileAccess.ReadWrite, FileShare.None);
|
|
return false;
|
|
}
|
|
catch (IOException)
|
|
{
|
|
return true;
|
|
}
|
|
catch (UnauthorizedAccessException)
|
|
{
|
|
// Can't prove another process holds it, but we also can't safely touch it. Be conservative.
|
|
return true;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// True if the WAL may contain committed transactions SQLite still needs to recover. A WAL longer
|
|
/// than its 32-byte header may hold frames; treat any such file as unrecovered so it is preserved.
|
|
/// </summary>
|
|
private static bool WalHoldsUnrecoveredTransactions(string walFile)
|
|
{
|
|
const long walHeaderSize = 32;
|
|
try
|
|
{
|
|
return new FileInfo(walFile).Length > walHeaderSize;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// If we can't measure it, assume it matters and keep it rather than risk data loss.
|
|
Log.Logger.Warning(ex, "Could not inspect SQLite WAL file; leaving it in place: {WalFile}", walFile);
|
|
return true;
|
|
}
|
|
}
|
|
static bool migrationsRun = false;
|
|
|
|
/// <summary>Initialize logging. Wire-up events. Run after migration</summary>
|
|
public static void RunPostMigrationScaffolding(Variety variety, Configuration config)
|
|
{
|
|
if (System.Threading.Interlocked.CompareExchange(ref migrationsRun, true, false))
|
|
return;
|
|
Variety = Enum.IsDefined(variety) ? variety : Variety.None;
|
|
|
|
var releaseID = (ReleaseIdentifier)((int)variety | (int)Configuration.OS | (int)RuntimeInformation.ProcessArchitecture);
|
|
|
|
ReleaseIdentifier = Enum.IsDefined(releaseID) ? releaseID : ReleaseIdentifier.None;
|
|
|
|
ensureSerilogConfig(config);
|
|
configureLogging(config);
|
|
logStartupState(config);
|
|
|
|
// all else should occur after logging
|
|
|
|
wireUpSystemEvents(config);
|
|
}
|
|
|
|
private static void ensureSerilogConfig(Configuration config)
|
|
=> config.EnsureSerilogConfig();
|
|
|
|
/// <summary>
|
|
/// Hands everything <see cref="StartupLog"/> collected before this point to Serilog, and points it at
|
|
/// Serilog from here on. Startup runs long before logging exists, so without this those diagnostics are
|
|
/// simply dropped. This is also the only place in Libation that maps a startup entry onto a Serilog level.
|
|
/// </summary>
|
|
private static void replayStartupLog()
|
|
=> StartupLog.ReplayTo(entry =>
|
|
{
|
|
var level = entry.Level switch
|
|
{
|
|
StartupLogLevel.Debug => Serilog.Events.LogEventLevel.Debug,
|
|
StartupLogLevel.Warning => Serilog.Events.LogEventLevel.Warning,
|
|
StartupLogLevel.Error => Serilog.Events.LogEventLevel.Error,
|
|
_ => Serilog.Events.LogEventLevel.Information,
|
|
};
|
|
|
|
// The message is already rendered: startup cannot build a Serilog template without
|
|
// referencing Serilog, which is the whole point of StartupLog. See issue #2001.
|
|
Log.Logger.Write(level, entry.Exception, "[startup {StartupTimestamp:HH:mm:ss.fff}] {StartupMessage}", entry.Timestamp, entry.Message);
|
|
});
|
|
|
|
// to restore original: Console.SetOut(origOut);
|
|
private static TextWriter origOut { get; } = Console.Out;
|
|
|
|
private static void configureLogging(Configuration config)
|
|
{
|
|
config.ConfigureLogging();
|
|
replayStartupLog();
|
|
Log.Information(
|
|
"Paths: LibationFiles={LibationFiles} AppsettingsJson={AppsettingsJson} SQLiteDb={SqliteDb}",
|
|
config.LibationFiles.Location,
|
|
config.LibationFiles.AppsettingsJsonFile ?? "(null, LIBATION_FILES_DIR may be set)",
|
|
Path.Combine(config.LibationFiles.Location, "LibationContext.db"));
|
|
DbContexts.TryEmitPendingInitialDatabaseStatistics();
|
|
|
|
// capture most Console.WriteLine() and write to serilog. See below tests for details.
|
|
// Some dependencies print helpful info via Console.WriteLine. We'd like to log it.
|
|
//
|
|
// If Serilog also writes to Console, this might be asking for trouble. ie: infinite loops.
|
|
// To use that way, SerilogTextWriter needs to be more robust and tested. Esp the Write() methods.
|
|
// However, empirical testing so far has shown no issues.
|
|
Console.SetOut(new MultiTextWriter(origOut, new SerilogTextWriter()));
|
|
|
|
#region Console => Serilog tests
|
|
/*
|
|
// all below apply to "Console." and "Console.Out."
|
|
|
|
// captured
|
|
Console.WriteLine("str");
|
|
Console.WriteLine(new { a = "anon" });
|
|
Console.WriteLine("{0}", "format");
|
|
Console.WriteLine("{0}{1}", "zero|", "one");
|
|
Console.WriteLine("{0}{1}{2}", "zero|", "one|", "two");
|
|
Console.WriteLine("{0}", new object[] { "arr" });
|
|
|
|
// not captured
|
|
Console.WriteLine();
|
|
Console.WriteLine(true);
|
|
Console.WriteLine('0');
|
|
Console.WriteLine(1);
|
|
Console.WriteLine(2m);
|
|
Console.WriteLine(3f);
|
|
Console.WriteLine(4d);
|
|
Console.WriteLine(5L);
|
|
Console.WriteLine((uint)6);
|
|
Console.WriteLine((ulong)7);
|
|
|
|
Console.Write("str");
|
|
Console.Write(true);
|
|
Console.Write('0');
|
|
Console.Write(1);
|
|
Console.Write(2m);
|
|
Console.Write(3f);
|
|
Console.Write(4d);
|
|
Console.Write(5L);
|
|
Console.Write((uint)6);
|
|
Console.Write((ulong)7);
|
|
Console.Write(new { a = "anon" });
|
|
Console.Write("{0}", "format");
|
|
Console.Write("{0}{1}", "zero|", "one");
|
|
Console.Write("{0}{1}{2}", "zero|", "one|", "two");
|
|
Console.Write("{0}", new object[] { "arr" });
|
|
*/
|
|
#endregion
|
|
|
|
// .Here() captures debug info via System.Runtime.CompilerServices attributes. Warning: expensive
|
|
//var withLineNumbers_outputTemplate = "[{Timestamp:HH:mm:ss} {Level}] {SourceContext}{NewLine}{Message}{NewLine}in method {MemberName} at {FilePath}:{LineNumber}{NewLine}{Exception}{NewLine}";
|
|
//Log.Logger.Here().Debug("Begin Libation. Debug with line numbers");
|
|
}
|
|
|
|
private static void logStartupState(Configuration config)
|
|
{
|
|
#if DEBUG
|
|
var mode = "Debug";
|
|
#else
|
|
var mode = "Release";
|
|
#endif
|
|
if (Debugger.IsAttached)
|
|
mode += " (Debugger attached)";
|
|
|
|
// begin logging session with a form feed
|
|
Log.Logger.Information("\r\n\f");
|
|
|
|
// -1 means the count could not be taken. Listing a directory no longer throws when it stops being
|
|
// readable partway through, so an incomplete walk has to be asked about rather than caught: a count
|
|
// that silently means "as many as I managed to read" is worse than no count in a bug report.
|
|
static int fileCount(FileManager.LongPath? longPath)
|
|
{
|
|
if (longPath is null)
|
|
return -1;
|
|
|
|
var complete = true;
|
|
try
|
|
{
|
|
var count = FileManager.FileUtility.SaferEnumerateFiles(longPath, onIncomplete: _ => complete = false).Count();
|
|
return complete ? count : -1;
|
|
}
|
|
catch { return -1; }
|
|
}
|
|
|
|
Log.Logger.Information("Begin. {@DebugInfo}", new
|
|
{
|
|
AppName = EntryAssembly?.GetName().Name,
|
|
Version = BuildVersion?.ToString(),
|
|
ReleaseIdentifier,
|
|
Configuration.OS,
|
|
Environment.OSVersion,
|
|
InteropFactory.InteropFunctionsType,
|
|
// A file Windows refused to load, or a half-applied upgrade, is usually explained by one
|
|
// of these three, and none of them is visible anywhere else in a bug report.
|
|
InstallFolder = Configuration.ProcessDirectory,
|
|
ApplicationControl = ApplicationControlPolicy.GetState(),
|
|
InstallFolderCloudSync = CloudSyncedFolders.GetSyncStatus(Configuration.ProcessDirectory),
|
|
Mode = mode,
|
|
LogLevel_Verbose_Enabled = Log.Logger.IsVerboseEnabled(),
|
|
LogLevel_Debug_Enabled = Log.Logger.IsDebugEnabled(),
|
|
LogLevel_Information_Enabled = Log.Logger.IsInformationEnabled(),
|
|
LogLevel_Warning_Enabled = Log.Logger.IsWarningEnabled(),
|
|
LogLevel_Error_Enabled = Log.Logger.IsErrorEnabled(),
|
|
LogLevel_Fatal_Enabled = Log.Logger.IsFatalEnabled(),
|
|
|
|
config.AutoScan,
|
|
// These silently exclude titles from every scan, so a log without them can't explain a missing book
|
|
config.ImportEpisodes,
|
|
config.ImportPlusTitles,
|
|
config.DownloadEpisodes,
|
|
// Off means no startup upgrade prompt, which is otherwise indistinguishable from a broken check
|
|
config.CheckForUpgradesAtStartup,
|
|
config.UseCoverAsFolderIcon,
|
|
config.LibationFiles,
|
|
AudibleFileStorage.BooksDirectory,
|
|
|
|
config.InProgress,
|
|
|
|
AudibleFileStorage.DownloadsInProgressDirectory,
|
|
DownloadsInProgressFiles = fileCount(AudibleFileStorage.DownloadsInProgressDirectory),
|
|
|
|
AudibleFileStorage.DecryptInProgressDirectory,
|
|
DecryptInProgressFiles = fileCount(AudibleFileStorage.DecryptInProgressDirectory),
|
|
|
|
disableIPv6 = AppContext.TryGetSwitch("System.Net.DisableIPv6", out bool disableIPv6Value),
|
|
});
|
|
|
|
if (InteropFactory.InteropFunctionsType is null)
|
|
Serilog.Log.Logger.Warning("WARNING: OSInteropProxy.InteropFunctionsType is null");
|
|
}
|
|
|
|
private static void wireUpSystemEvents(Configuration configuration)
|
|
{
|
|
LibraryCommands.LibrarySizeChanged += (_, libraryBooks)
|
|
=> SearchEngineCommands.OnLibrarySizeChanged(libraryBooks);
|
|
|
|
LibraryCommands.BookUserDefinedItemCommitted += (_, books)
|
|
=> SearchEngineCommands.OnBookUserDefinedItemCommitted(books);
|
|
}
|
|
|
|
public static VersionCheckResult GetLatestRelease()
|
|
{
|
|
var (version, latest, zip, checkSucceeded, definitive) = getLatestRelease(TimeSpan.FromSeconds(10));
|
|
|
|
if (!checkSucceeded || !definitive)
|
|
return new VersionCheckResult(VersionCheckOutcome.UnableToDetermine);
|
|
|
|
if (version is null || latest is null || zip is null)
|
|
return new VersionCheckResult(VersionCheckOutcome.UpToDate);
|
|
|
|
// we have an update
|
|
var zipUrl = zip.BrowserDownloadUrl;
|
|
|
|
Log.Logger.Information("Update available: {@DebugInfo}", new
|
|
{
|
|
latestRelease = version.ToString(),
|
|
latest.HtmlUrl,
|
|
zipUrl
|
|
});
|
|
|
|
return new VersionCheckResult(VersionCheckOutcome.UpdateAvailable, new UpgradeProperties(zipUrl, latest.HtmlUrl, zip.Name, version, latest.Body));
|
|
}
|
|
private static (Version? releaseVersion, Octokit.Release?, Octokit.ReleaseAsset? zip, bool checkSucceeded, bool definitive) getLatestRelease(TimeSpan timeout)
|
|
{
|
|
try
|
|
{
|
|
var task = getLatestReleaseAsync();
|
|
if (task.Wait(timeout))
|
|
return (task.Result.releaseVersion, task.Result.latest, task.Result.zip, true, task.Result.definitive);
|
|
|
|
Log.Logger.Information("Version check timed out");
|
|
}
|
|
catch (AggregateException aggEx)
|
|
{
|
|
var inner = aggEx.InnerException;
|
|
if (inner?.Message?.Contains("API rate limit", StringComparison.OrdinalIgnoreCase) == true)
|
|
Log.Logger.Error(aggEx, "Checking for new version too often");
|
|
else
|
|
Log.Logger.Error(aggEx, "Version check failed. Agg exception");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// different text to make it easier to identify in logs, vs the AggregateException case above
|
|
Log.Logger.Error(ex, "Version check failed. General exception");
|
|
}
|
|
return (null, null, null, false, false);
|
|
}
|
|
private static async System.Threading.Tasks.Task<(Version? releaseVersion, Octokit.Release? latest, Octokit.ReleaseAsset? zip, bool definitive)> getLatestReleaseAsync()
|
|
{
|
|
const string ownerAccount = "rmcrackan";
|
|
const string repoName = "Libation";
|
|
|
|
var gitHubClient = new Octokit.GitHubClient(new Octokit.ProductHeaderValue(repoName));
|
|
|
|
//https://docs.github.com/en/rest/releases/releases?apiVersion=2022-11-28#get-the-latest-release
|
|
var latestRelease = await gitHubClient.Repository.Release.GetLatest(ownerAccount, repoName);
|
|
|
|
//Ensure that latest release is greater than the current version
|
|
var latestVersionString = latestRelease.TagName.Trim('v');
|
|
if (!Version.TryParse(latestVersionString, out var releaseVersion) || releaseVersion <= BuildVersion)
|
|
return (null, null, null, true);
|
|
|
|
//Download the release index
|
|
var bts = await gitHubClient.Repository.Content.GetRawContent(ownerAccount, repoName, ".releaseindex.json");
|
|
var releaseIndex = JObject.Parse(System.Text.Encoding.ASCII.GetString(bts));
|
|
|
|
string? regexPattern;
|
|
string? releaseIdString = null;
|
|
|
|
try
|
|
{
|
|
releaseIdString = InteropFactory.Create().ReleaseIdString;
|
|
regexPattern = releaseIndex.Value<string>(releaseIdString);
|
|
}
|
|
catch
|
|
{
|
|
regexPattern = null;
|
|
}
|
|
if (string.IsNullOrEmpty(regexPattern) && Configuration.IsLinux)
|
|
{
|
|
var baseId = ReleaseIdentifier.ToString();
|
|
regexPattern = releaseIndex.Value<string>($"{baseId}_RPM")
|
|
?? releaseIndex.Value<string>($"{baseId}_DEB");
|
|
releaseIdString ??= $"{baseId}_RPM";
|
|
}
|
|
if (string.IsNullOrEmpty(regexPattern))
|
|
regexPattern = releaseIndex.Value<string>(ReleaseIdentifier.ToString());
|
|
if (string.IsNullOrEmpty(regexPattern))
|
|
{
|
|
Log.Logger.Warning("Release index has no entry for this platform (ReleaseIdentifier: {ReleaseId}, ReleaseIdString: {ReleaseIdString}). Version check inconclusive.", ReleaseIdentifier, releaseIdString);
|
|
return (null, null, null, false);
|
|
}
|
|
|
|
var regex = new System.Text.RegularExpressions.Regex(regexPattern, System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
|
var zip = latestRelease?.Assets?.FirstOrDefault(a => regex.IsMatch(a.Name));
|
|
|
|
if (zip is not null && !string.IsNullOrEmpty(releaseIdString))
|
|
Log.Logger.Information("Update asset matched using {ReleaseIdString}: {AssetName}", releaseIdString, zip.Name);
|
|
|
|
return (releaseVersion, latestRelease, zip, true);
|
|
}
|
|
}
|
|
|
|
internal static class Migrations
|
|
{
|
|
public static void migrate_to_v12_0_1(Configuration config)
|
|
{
|
|
//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.Location, FILENAME_V1);
|
|
var jsonFileV2 = Path.Combine(Configuration.Instance.LibationFiles.Location, 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<string, JArray> cache = [];
|
|
|
|
//Convert to c# objects to speed up searching by ID inside the iterator
|
|
var allItems
|
|
= v1Cache
|
|
.Select(i => new
|
|
{
|
|
Id = i["Id"]?.Value<string>(),
|
|
Path = i["Path"]?["Path"]?.Value<string>()
|
|
}).Where(i => i.Id != null)
|
|
.ToArray();
|
|
|
|
foreach (var id in allItems.Select(i => i.Id).OfType<string>().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 */ }
|
|
}
|
|
}
|
|
}
|