using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Jellyfin.Plugin.Jellypod.Models; using MediaBrowser.Common.Configuration; using Microsoft.Extensions.Logging; namespace Jellyfin.Plugin.Jellypod.Services; /// /// Service for storing and retrieving podcast data. /// public sealed class PodcastStorageService : IPodcastStorageService, IDisposable { private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNameCaseInsensitive = true }; private readonly ILogger _logger; private readonly IApplicationPaths _applicationPaths; private readonly SemaphoreSlim _dbLock = new(1, 1); private PodcastDatabase? _cache; /// /// Initializes a new instance of the class. /// /// Logger instance. /// Application paths. public PodcastStorageService(ILogger logger, IApplicationPaths applicationPaths) { _logger = logger; _applicationPaths = applicationPaths; _logger.LogInformation("Jellypod database path: {Path}", DatabasePath); _logger.LogInformation("PluginConfigurationsPath: {Path}", applicationPaths.PluginConfigurationsPath); } private string DatabasePath => Path.Combine( _applicationPaths.PluginConfigurationsPath, "Jellypod", "podcasts.json"); /// public async Task> GetAllPodcastsAsync() { var db = await LoadDatabaseAsync().ConfigureAwait(false); return db.Podcasts.ToList(); } /// public async Task GetPodcastAsync(Guid id) { var db = await LoadDatabaseAsync().ConfigureAwait(false); return db.Podcasts.FirstOrDefault(p => p.Id == id); } /// public async Task AddPodcastAsync(Podcast podcast) { await _dbLock.WaitAsync().ConfigureAwait(false); try { var db = await LoadDatabaseInternalAsync().ConfigureAwait(false); db.Podcasts.Add(podcast); await SaveDatabaseInternalAsync(db).ConfigureAwait(false); _logger.LogInformation("Added podcast: {Title}", podcast.Title); } finally { _dbLock.Release(); } } /// public async Task UpdatePodcastAsync(Podcast podcast) { await _dbLock.WaitAsync().ConfigureAwait(false); try { var db = await LoadDatabaseInternalAsync().ConfigureAwait(false); var existing = db.Podcasts.FirstOrDefault(p => p.Id == podcast.Id); if (existing != null) { var index = db.Podcasts.IndexOf(existing); db.Podcasts[index] = podcast; await SaveDatabaseInternalAsync(db).ConfigureAwait(false); _logger.LogDebug("Updated podcast: {Title}", podcast.Title); } } finally { _dbLock.Release(); } } /// public async Task DeletePodcastAsync(Guid id) { await _dbLock.WaitAsync().ConfigureAwait(false); try { var db = await LoadDatabaseInternalAsync().ConfigureAwait(false); var podcast = db.Podcasts.FirstOrDefault(p => p.Id == id); if (podcast != null) { db.Podcasts.Remove(podcast); await SaveDatabaseInternalAsync(db).ConfigureAwait(false); _logger.LogInformation("Deleted podcast: {Title}", podcast.Title); } } finally { _dbLock.Release(); } } /// public string GetEpisodeFilePath(Podcast podcast, Episode episode) { var basePath = GetStoragePath(); var config = Plugin.Instance?.Configuration; var safePodcastTitle = SanitizeFileName(podcast.Title); var safeEpisodeTitle = SanitizeFileName(episode.Title); var extension = GetAudioExtension(episode.AudioUrl); // Format: YYYY-MM-DD - Episode Title.mp3 var datePrefix = episode.PublishedDate.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); var fileName = $"{datePrefix} - {safeEpisodeTitle}{extension}"; if (config?.CreatePodcastFolders == true) { return Path.Combine(basePath, safePodcastTitle, fileName); } return Path.Combine(basePath, $"{safePodcastTitle} - {fileName}"); } /// public string GetStoragePath() { var config = Plugin.Instance?.Configuration; if (!string.IsNullOrEmpty(config?.PodcastStoragePath)) { return config.PodcastStoragePath; } return Path.Combine(_applicationPaths.DataPath, "Podcasts"); } private async Task LoadDatabaseAsync() { await _dbLock.WaitAsync().ConfigureAwait(false); try { return await LoadDatabaseInternalAsync().ConfigureAwait(false); } finally { _dbLock.Release(); } } private async Task LoadDatabaseInternalAsync() { if (_cache != null) { return _cache; } if (!File.Exists(DatabasePath)) { _logger.LogWarning("Database file does not exist at {Path}", DatabasePath); _cache = new PodcastDatabase(); return _cache; } try { _logger.LogInformation("Loading database from {Path}", DatabasePath); var json = await File.ReadAllTextAsync(DatabasePath).ConfigureAwait(false); _logger.LogInformation("Read {Length} characters from database file", json.Length); _cache = JsonSerializer.Deserialize(json, JsonOptions) ?? new PodcastDatabase(); _logger.LogInformation("Loaded {Count} podcasts from database", _cache.Podcasts.Count); return _cache; } catch (Exception ex) { _logger.LogError(ex, "Failed to load podcast database, starting fresh"); _cache = new PodcastDatabase(); return _cache; } } private async Task SaveDatabaseInternalAsync(PodcastDatabase db) { try { var directory = Path.GetDirectoryName(DatabasePath); if (!string.IsNullOrEmpty(directory)) { Directory.CreateDirectory(directory); } db.LastSaved = DateTime.UtcNow; var json = JsonSerializer.Serialize(db, JsonOptions); await File.WriteAllTextAsync(DatabasePath, json).ConfigureAwait(false); _cache = db; } catch (Exception ex) { _logger.LogError(ex, "Failed to save podcast database"); throw; } } private static string SanitizeFileName(string name) { var invalidChars = Path.GetInvalidFileNameChars(); var result = new string(name.Where(c => !invalidChars.Contains(c)).ToArray()); // Limit length if (result.Length > 100) { result = result.Substring(0, 100); } return result.Trim(); } private static string GetAudioExtension(string url) { try { var uri = new Uri(url); var path = uri.AbsolutePath; var extension = Path.GetExtension(path); if (!string.IsNullOrEmpty(extension) && (extension.Equals(".mp3", StringComparison.OrdinalIgnoreCase) || extension.Equals(".m4a", StringComparison.OrdinalIgnoreCase) || extension.Equals(".ogg", StringComparison.OrdinalIgnoreCase) || extension.Equals(".opus", StringComparison.OrdinalIgnoreCase))) { return extension.ToLowerInvariant(); } } catch { // Ignore URL parsing errors } return ".mp3"; } /// public void Dispose() { _dbLock.Dispose(); } }