using System; using System.IO; using System.Reflection; using System.Text.Json; using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; using Jellyfin.Plugin.Lyrics.Models; using MediaBrowser.Common.Configuration; using Microsoft.Extensions.Logging; namespace Jellyfin.Plugin.Lyrics; /// /// Persists retry and cursor state for the lyric download task. /// public class RetryStateStore { private static readonly JsonSerializerOptions SerializerOptions = new() { WriteIndented = false, Converters = { new JsonStringEnumConverter() } }; private readonly ILogger _logger; private readonly string _stateFilePath; /// /// Initializes a new instance of the class. /// /// Application paths. /// Logger. public RetryStateStore(IApplicationPaths applicationPaths, ILogger logger) { _logger = logger; var stateDir = Path.Combine(ResolveBasePath(applicationPaths), "plugins", "lyrics"); Directory.CreateDirectory(stateDir); _stateFilePath = Path.Combine(stateDir, "retry-state.json"); } /// /// Loads state from disk. /// /// Cancellation token. /// Loaded state or empty default state. public async Task LoadAsync(CancellationToken cancellationToken) { try { if (!File.Exists(_stateFilePath)) { return new LyricsRetryState(); } using var stream = File.OpenRead(_stateFilePath); var state = await JsonSerializer.DeserializeAsync(stream, SerializerOptions, cancellationToken).ConfigureAwait(false); return state ?? new LyricsRetryState(); } catch (OperationCanceledException) { throw; } catch (Exception ex) { _logger.LogWarning(ex, "Failed to load lyrics retry state from {Path}. Using empty state.", _stateFilePath); return new LyricsRetryState(); } } /// /// Saves state to disk. /// /// State to persist. /// Cancellation token. /// Task completion. public async Task SaveAsync(LyricsRetryState state, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(state); try { var tempPath = _stateFilePath + ".tmp"; using (var stream = File.Create(tempPath)) { await JsonSerializer.SerializeAsync(stream, state, SerializerOptions, cancellationToken).ConfigureAwait(false); await stream.FlushAsync(cancellationToken).ConfigureAwait(false); } File.Move(tempPath, _stateFilePath, true); } catch (OperationCanceledException) { throw; } catch (Exception ex) { _logger.LogWarning(ex, "Failed to save lyrics retry state to {Path}.", _stateFilePath); } } private static string ResolveBasePath(IApplicationPaths applicationPaths) { var candidateProperties = new[] { "ProgramDataPath", "DataPath", "CachePath" }; var pathsType = applicationPaths.GetType(); foreach (var propertyName in candidateProperties) { if (TryGetPathValue(pathsType, applicationPaths, propertyName) is string runtimePath) { return runtimePath; } if (TryGetPathValue(typeof(IApplicationPaths), applicationPaths, propertyName) is string interfacePath) { return interfacePath; } } throw new InvalidOperationException("Unable to resolve application data path for retry-state persistence."); } private static string? TryGetPathValue(Type sourceType, object instance, string propertyName) { var property = sourceType.GetProperty(propertyName, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); if (property?.GetValue(instance) is string value && !string.IsNullOrWhiteSpace(value)) { return value; } return null; } }