270 lines
8.3 KiB
C#
270 lines
8.3 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Service for storing and retrieving podcast data.
|
|
/// </summary>
|
|
public sealed class PodcastStorageService : IPodcastStorageService, IDisposable
|
|
{
|
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
|
{
|
|
WriteIndented = true,
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
|
PropertyNameCaseInsensitive = true
|
|
};
|
|
|
|
private readonly ILogger<PodcastStorageService> _logger;
|
|
private readonly IApplicationPaths _applicationPaths;
|
|
private readonly SemaphoreSlim _dbLock = new(1, 1);
|
|
private PodcastDatabase? _cache;
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the <see cref="PodcastStorageService"/> class.
|
|
/// </summary>
|
|
/// <param name="logger">Logger instance.</param>
|
|
/// <param name="applicationPaths">Application paths.</param>
|
|
public PodcastStorageService(ILogger<PodcastStorageService> 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");
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IReadOnlyList<Podcast>> GetAllPodcastsAsync()
|
|
{
|
|
var db = await LoadDatabaseAsync().ConfigureAwait(false);
|
|
return db.Podcasts.ToList();
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<Podcast?> GetPodcastAsync(Guid id)
|
|
{
|
|
var db = await LoadDatabaseAsync().ConfigureAwait(false);
|
|
return db.Podcasts.FirstOrDefault(p => p.Id == id);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
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();
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
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();
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
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();
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
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}");
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
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<PodcastDatabase> LoadDatabaseAsync()
|
|
{
|
|
await _dbLock.WaitAsync().ConfigureAwait(false);
|
|
try
|
|
{
|
|
return await LoadDatabaseInternalAsync().ConfigureAwait(false);
|
|
}
|
|
finally
|
|
{
|
|
_dbLock.Release();
|
|
}
|
|
}
|
|
|
|
private async Task<PodcastDatabase> 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<PodcastDatabase>(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";
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public void Dispose()
|
|
{
|
|
_dbLock.Dispose();
|
|
}
|
|
}
|