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; /// /// Service for downloading podcast episodes. /// public sealed class PodcastDownloadService : IPodcastDownloadService, IDisposable { private readonly ILogger _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; /// /// Initializes a new instance of the class. /// /// Logger instance. /// HTTP client factory. /// Storage service. public PodcastDownloadService( ILogger logger, IHttpClientFactory httpClientFactory, IPodcastStorageService storageService) { _logger = logger; _httpClientFactory = httpClientFactory; _storageService = storageService; var maxConcurrent = Plugin.Instance?.Configuration?.MaxConcurrentDownloads ?? 2; _downloadSemaphore = new SemaphoreSlim(maxConcurrent, maxConcurrent); } /// 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; } /// public async Task DownloadEpisodeAsync( Podcast podcast, Episode episode, IProgress? 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; } } /// 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; } /// 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(); } /// public void Dispose() { _downloadSemaphore.Dispose(); } }