using System; using System.Collections.Concurrent; using System.Diagnostics; using System.IO; using System.Linq; using System.Net.Http; using System.Text; 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 finalPath = _storageService.GetEpisodeFilePath(podcast, episode); var finalDirectory = Path.GetDirectoryName(finalPath); if (!string.IsNullOrEmpty(finalDirectory)) { Directory.CreateDirectory(finalDirectory); } // Determine if we should use temp directory for post-processing var config = Plugin.Instance?.Configuration; var usePostProcessing = !string.IsNullOrWhiteSpace(config?.PostDownloadScriptPath); // Use temp directory if post-processing is enabled, otherwise download directly to final location var downloadPath = usePostProcessing ? Path.Combine(Path.GetTempPath(), "jellypod-" + Path.GetRandomFileName() + Path.GetExtension(finalPath)) : finalPath; _logger.LogInformation("Downloading episode: {Title} to {Path}", episode.Title, downloadPath); string? tempInputFile = null; string? tempOutputFile = null; 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(downloadPath, 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); } _logger.LogInformation("Downloaded episode: {Title} ({Size} bytes)", episode.Title, totalRead); // Post-processing if configured string sourceFile = downloadPath; if (usePostProcessing) { tempInputFile = downloadPath; tempOutputFile = Path.Combine( Path.GetTempPath(), "jellypod-output-" + Path.GetRandomFileName() + Path.GetExtension(finalPath)); var scriptTimeout = config?.PostDownloadScriptTimeout ?? 60; var processedFile = await ExecutePostDownloadScriptAsync( tempInputFile, tempOutputFile, scriptTimeout, cancellationToken).ConfigureAwait(false); if (processedFile != null) { _logger.LogInformation("Using processed file from post-download script"); sourceFile = processedFile; } else { _logger.LogInformation("Using original file (script failed, timed out, or not configured)"); sourceFile = tempInputFile; } // Copy the selected file to final destination File.Copy(sourceFile, finalPath, overwrite: true); _logger.LogInformation("Copied episode to final location: {Path}", finalPath); } // Get final file size var finalFileInfo = new FileInfo(finalPath); episode.LocalFilePath = finalPath; episode.Status = EpisodeStatus.Downloaded; episode.DownloadedDate = DateTime.UtcNow; episode.FileSizeBytes = finalFileInfo.Length; // Update the podcast in storage await _storageService.UpdatePodcastAsync(podcast).ConfigureAwait(false); return finalPath; } catch (Exception ex) { episode.Status = EpisodeStatus.Error; _logger.LogError(ex, "Failed to download episode: {Title}", episode.Title); throw; } finally { // Clean up temp files if (tempInputFile != null && File.Exists(tempInputFile)) { try { File.Delete(tempInputFile); _logger.LogDebug("Cleaned up temp input file: {Path}", tempInputFile); } catch (Exception ex) { _logger.LogWarning(ex, "Failed to delete temp input file: {Path}", tempInputFile); } } if (tempOutputFile != null && File.Exists(tempOutputFile)) { try { File.Delete(tempOutputFile); _logger.LogDebug("Cleaned up temp output file: {Path}", tempOutputFile); } catch (Exception ex) { _logger.LogWarning(ex, "Failed to delete temp output file: {Path}", tempOutputFile); } } } } /// 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(); } /// /// Executes the post-download script if configured. /// /// Path to the downloaded file. /// Path where the script should write the processed file. /// Timeout in seconds. /// Cancellation token. /// The output file path if successful, null if failed/timeout. private async Task ExecutePostDownloadScriptAsync( string inputFilePath, string outputFilePath, int timeoutSeconds, CancellationToken cancellationToken) { var config = Plugin.Instance?.Configuration; var scriptPath = config?.PostDownloadScriptPath; if (string.IsNullOrWhiteSpace(scriptPath)) { return null; } if (!File.Exists(scriptPath)) { _logger.LogError("Post-download script not found at path: {ScriptPath}", scriptPath); return null; } try { _logger.LogDebug( "Executing post-download script: {ScriptPath} {InputFile} {OutputFile}", scriptPath, inputFilePath, outputFilePath); var processStartInfo = new ProcessStartInfo { FileName = scriptPath, Arguments = $"\"{inputFilePath}\" \"{outputFilePath}\"", RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true }; using var process = new Process { StartInfo = processStartInfo }; var stdoutBuilder = new StringBuilder(); var stderrBuilder = new StringBuilder(); process.OutputDataReceived += (sender, e) => { if (e.Data != null) { stdoutBuilder.AppendLine(e.Data); } }; process.ErrorDataReceived += (sender, e) => { if (e.Data != null) { stderrBuilder.AppendLine(e.Data); } }; process.Start(); process.BeginOutputReadLine(); process.BeginErrorReadLine(); // Create a timeout cancellation token using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); timeoutCts.CancelAfter(TimeSpan.FromSeconds(timeoutSeconds)); try { await process.WaitForExitAsync(timeoutCts.Token).ConfigureAwait(false); } catch (OperationCanceledException) { if (!process.HasExited) { _logger.LogWarning( "Post-download script timed out after {Timeout} seconds, killing process", timeoutSeconds); process.Kill(entireProcessTree: true); } return null; } var stdout = stdoutBuilder.ToString(); var stderr = stderrBuilder.ToString(); if (!string.IsNullOrWhiteSpace(stdout)) { _logger.LogDebug("Script stdout: {Stdout}", stdout.Trim()); } if (!string.IsNullOrWhiteSpace(stderr)) { _logger.LogDebug("Script stderr: {Stderr}", stderr.Trim()); } if (process.ExitCode != 0) { _logger.LogError( "Post-download script failed with exit code {ExitCode}", process.ExitCode); return null; } if (!File.Exists(outputFilePath)) { _logger.LogWarning( "Post-download script succeeded but output file was not created: {OutputFile}", outputFilePath); return null; } _logger.LogInformation("Post-download script executed successfully"); return outputFilePath; } catch (Exception ex) { _logger.LogError(ex, "Failed to execute post-download script"); return null; } } /// public void Dispose() { _downloadSemaphore.Dispose(); } }