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