First POC with podcasts library
This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user