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