249 lines
8.2 KiB
C#
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();
|
|
}
|
|
}
|