Added a post-download hook for applying processing of downloaded audio before adding to library.
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
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;
|
||||
@@ -63,15 +65,27 @@ public sealed class PodcastDownloadService : IPodcastDownloadService, IDisposabl
|
||||
IProgress<double>? progress = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filePath = _storageService.GetEpisodeFilePath(podcast, episode);
|
||||
var directory = Path.GetDirectoryName(filePath);
|
||||
var finalPath = _storageService.GetEpisodeFilePath(podcast, episode);
|
||||
var finalDirectory = Path.GetDirectoryName(finalPath);
|
||||
|
||||
if (!string.IsNullOrEmpty(directory))
|
||||
if (!string.IsNullOrEmpty(finalDirectory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
Directory.CreateDirectory(finalDirectory);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Downloading episode: {Title} to {Path}", episode.Title, filePath);
|
||||
// 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
|
||||
{
|
||||
@@ -91,7 +105,7 @@ public sealed class PodcastDownloadService : IPodcastDownloadService, IDisposabl
|
||||
long totalRead;
|
||||
try
|
||||
{
|
||||
var fileStream = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None, 81920, true);
|
||||
var fileStream = new FileStream(downloadPath, FileMode.Create, FileAccess.Write, FileShare.None, 81920, true);
|
||||
try
|
||||
{
|
||||
var buffer = new byte[81920];
|
||||
@@ -119,17 +133,51 @@ public sealed class PodcastDownloadService : IPodcastDownloadService, IDisposabl
|
||||
await contentStream.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
episode.LocalFilePath = filePath;
|
||||
_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 = totalRead;
|
||||
|
||||
_logger.LogInformation("Downloaded episode: {Title} ({Size} bytes)", episode.Title, totalRead);
|
||||
episode.FileSizeBytes = finalFileInfo.Length;
|
||||
|
||||
// Update the podcast in storage
|
||||
await _storageService.UpdatePodcastAsync(podcast).ConfigureAwait(false);
|
||||
|
||||
return filePath;
|
||||
return finalPath;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -137,6 +185,35 @@ public sealed class PodcastDownloadService : IPodcastDownloadService, IDisposabl
|
||||
_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 />
|
||||
@@ -240,6 +317,136 @@ public sealed class PodcastDownloadService : IPodcastDownloadService, IDisposabl
|
||||
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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user