jellypod/Jellyfin.Plugin.Jellypod/Services/PodcastDownloadService.cs
Duncan Tourolle 4679b77d1a
Some checks failed
🏗️ Build Plugin / call (push) Failing after 0s
📝 Create/Update Release Draft & Release Bump PR / call (push) Failing after 0s
🔬 Run CodeQL / call (push) Failing after 0s
🧪 Test Plugin / call (push) Failing after 0s
First POC with podcasts library
2025-12-13 23:57:58 +01:00

249 lines
8.2 KiB
C#

using System;
using System.Collections.Concurrent;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.Jellypod.Models;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.Jellypod.Services;
/// <summary>
/// Service for downloading podcast episodes.
/// </summary>
public sealed class PodcastDownloadService : IPodcastDownloadService, IDisposable
{
private readonly ILogger<PodcastDownloadService> _logger;
private readonly IHttpClientFactory _httpClientFactory;
private readonly IPodcastStorageService _storageService;
private readonly ConcurrentQueue<(Podcast Podcast, Episode Episode)> _downloadQueue = new();
private readonly SemaphoreSlim _downloadSemaphore;
private int _isProcessing;
/// <summary>
/// Initializes a new instance of the <see cref="PodcastDownloadService"/> class.
/// </summary>
/// <param name="logger">Logger instance.</param>
/// <param name="httpClientFactory">HTTP client factory.</param>
/// <param name="storageService">Storage service.</param>
public PodcastDownloadService(
ILogger<PodcastDownloadService> logger,
IHttpClientFactory httpClientFactory,
IPodcastStorageService storageService)
{
_logger = logger;
_httpClientFactory = httpClientFactory;
_storageService = storageService;
var maxConcurrent = Plugin.Instance?.Configuration?.MaxConcurrentDownloads ?? 2;
_downloadSemaphore = new SemaphoreSlim(maxConcurrent, maxConcurrent);
}
/// <inheritdoc />
public Task QueueDownloadAsync(Podcast podcast, Episode episode)
{
_downloadQueue.Enqueue((podcast, episode));
_logger.LogDebug("Queued download: {PodcastTitle} - {EpisodeTitle}", podcast.Title, episode.Title);
// Start processing if not already running
if (Interlocked.CompareExchange(ref _isProcessing, 1, 0) == 0)
{
_ = ProcessQueueAsync();
}
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task<string> DownloadEpisodeAsync(
Podcast podcast,
Episode episode,
IProgress<double>? progress = null,
CancellationToken cancellationToken = default)
{
var filePath = _storageService.GetEpisodeFilePath(podcast, episode);
var directory = Path.GetDirectoryName(filePath);
if (!string.IsNullOrEmpty(directory))
{
Directory.CreateDirectory(directory);
}
_logger.LogInformation("Downloading episode: {Title} to {Path}", episode.Title, filePath);
try
{
episode.Status = EpisodeStatus.Downloading;
var httpClient = _httpClientFactory.CreateClient("Jellypod");
using var response = await httpClient.GetAsync(
episode.AudioUrl,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var totalBytes = response.Content.Headers.ContentLength ?? -1;
var contentStream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
long totalRead;
try
{
var fileStream = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None, 81920, true);
try
{
var buffer = new byte[81920];
totalRead = 0L;
int bytesRead;
while ((bytesRead = await contentStream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false)) > 0)
{
await fileStream.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken).ConfigureAwait(false);
totalRead += bytesRead;
if (totalBytes > 0)
{
progress?.Report((double)totalRead / totalBytes * 100);
}
}
}
finally
{
await fileStream.DisposeAsync().ConfigureAwait(false);
}
}
finally
{
await contentStream.DisposeAsync().ConfigureAwait(false);
}
episode.LocalFilePath = filePath;
episode.Status = EpisodeStatus.Downloaded;
episode.DownloadedDate = DateTime.UtcNow;
episode.FileSizeBytes = totalRead;
_logger.LogInformation("Downloaded episode: {Title} ({Size} bytes)", episode.Title, totalRead);
// Update the podcast in storage
await _storageService.UpdatePodcastAsync(podcast).ConfigureAwait(false);
return filePath;
}
catch (Exception ex)
{
episode.Status = EpisodeStatus.Error;
_logger.LogError(ex, "Failed to download episode: {Title}", episode.Title);
throw;
}
}
/// <inheritdoc />
public Task DeleteEpisodeFileAsync(Episode episode)
{
if (string.IsNullOrEmpty(episode.LocalFilePath))
{
return Task.CompletedTask;
}
try
{
if (File.Exists(episode.LocalFilePath))
{
File.Delete(episode.LocalFilePath);
_logger.LogInformation("Deleted episode file: {Path}", episode.LocalFilePath);
}
episode.LocalFilePath = null;
episode.Status = EpisodeStatus.Available;
episode.DownloadedDate = null;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to delete episode file: {Path}", episode.LocalFilePath);
}
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task DownloadPodcastArtworkAsync(Podcast podcast, CancellationToken cancellationToken = default)
{
if (string.IsNullOrEmpty(podcast.ImageUrl))
{
return;
}
var config = Plugin.Instance?.Configuration;
if (config?.CreatePodcastFolders != true)
{
return;
}
var basePath = _storageService.GetStoragePath();
var podcastFolder = Path.Combine(basePath, SanitizeFileName(podcast.Title));
var artworkPath = Path.Combine(podcastFolder, "folder.jpg");
if (File.Exists(artworkPath))
{
return;
}
try
{
Directory.CreateDirectory(podcastFolder);
var httpClient = _httpClientFactory.CreateClient("Jellypod");
var imageBytes = await httpClient.GetByteArrayAsync(podcast.ImageUrl, cancellationToken).ConfigureAwait(false);
await File.WriteAllBytesAsync(artworkPath, imageBytes, cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Downloaded artwork for podcast: {Title}", podcast.Title);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to download artwork for podcast: {Title}", podcast.Title);
}
}
private async Task ProcessQueueAsync()
{
try
{
while (_downloadQueue.TryDequeue(out var item))
{
await _downloadSemaphore.WaitAsync().ConfigureAwait(false);
try
{
await DownloadEpisodeAsync(item.Podcast, item.Episode).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to process queued download: {Title}", item.Episode.Title);
}
finally
{
_downloadSemaphore.Release();
}
}
}
finally
{
Interlocked.Exchange(ref _isProcessing, 0);
}
}
private static string SanitizeFileName(string name)
{
var invalidChars = Path.GetInvalidFileNameChars();
var result = new string(name.Where(c => !invalidChars.Contains(c)).ToArray());
return result.Length > 100 ? result.Substring(0, 100).Trim() : result.Trim();
}
/// <inheritdoc />
public void Dispose()
{
_downloadSemaphore.Dispose();
}
}