jellypod/Jellyfin.Plugin.Jellypod/Services/PodcastDownloadService.cs
Duncan Tourolle 9ac32e11b5
All checks were successful
🏗️ Build Plugin / build (push) Successful in 2m4s
🧪 Test Plugin / test (push) Successful in 58s
🚀 Release Plugin / build-and-release (push) Successful in 2m1s
Added a post-download hook for applying processing of downloaded audio before adding to library.
2025-12-21 13:59:42 +01:00

456 lines
16 KiB
C#

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;
/// <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 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);
}
}
}
}
/// <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();
}
/// <summary>
/// Executes the post-download script if configured.
/// </summary>
/// <param name="inputFilePath">Path to the downloaded file.</param>
/// <param name="outputFilePath">Path where the script should write the processed file.</param>
/// <param name="timeoutSeconds">Timeout in seconds.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The output file path if successful, null if failed/timeout.</returns>
private async Task<string?> 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;
}
}
/// <inheritdoc />
public void Dispose()
{
_downloadSemaphore.Dispose();
}
}