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();
}
}