Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d890c11a9b | ||
|
|
c54221fba2 | ||
|
|
221a3f634d | ||
|
|
9ac32e11b5 | ||
|
|
bc24b40bf2 |
@@ -180,6 +180,11 @@ public class JellypodController : ControllerBase
|
|||||||
podcast.MaxEpisodesToKeep = request.MaxEpisodesToKeep.Value;
|
podcast.MaxEpisodesToKeep = request.MaxEpisodesToKeep.Value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (request.MaxEpisodeAgeDays.HasValue)
|
||||||
|
{
|
||||||
|
podcast.MaxEpisodeAgeDays = request.MaxEpisodeAgeDays.Value;
|
||||||
|
}
|
||||||
|
|
||||||
await _storageService.UpdatePodcastAsync(podcast).ConfigureAwait(false);
|
await _storageService.UpdatePodcastAsync(podcast).ConfigureAwait(false);
|
||||||
return Ok(podcast);
|
return Ok(podcast);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,4 +14,9 @@ public class UpdatePodcastRequest
|
|||||||
/// Gets or sets the maximum episodes to keep.
|
/// Gets or sets the maximum episodes to keep.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int? MaxEpisodesToKeep { get; set; }
|
public int? MaxEpisodesToKeep { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the maximum age in days for episodes (0 = use global, -1 = unlimited).
|
||||||
|
/// </summary>
|
||||||
|
public int? MaxEpisodeAgeDays { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -387,10 +387,9 @@ public class JellypodChannel : IChannel, IHasCacheKey, IRequiresMediaInfoCallbac
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public string? GetCacheKey(string? userId)
|
public string? GetCacheKey(string? userId)
|
||||||
{
|
{
|
||||||
// Use 5-minute time buckets for cache key
|
// Include database modification time so cache invalidates when podcasts/episodes change
|
||||||
var now = DateTime.Now;
|
var lastModified = _storageService.LastModified;
|
||||||
var timeBucket = new DateTime(now.Year, now.Month, now.Day, now.Hour, (now.Minute / 5) * 5, 0);
|
return lastModified.ToString("O", CultureInfo.InvariantCulture);
|
||||||
return timeBucket.ToString("yyyy-MM-dd-HH-mm", CultureInfo.InvariantCulture);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|||||||
@@ -17,8 +17,11 @@ public class PluginConfiguration : BasePluginConfiguration
|
|||||||
GlobalAutoDownloadEnabled = true;
|
GlobalAutoDownloadEnabled = true;
|
||||||
MaxConcurrentDownloads = 2;
|
MaxConcurrentDownloads = 2;
|
||||||
MaxEpisodesPerPodcast = 50;
|
MaxEpisodesPerPodcast = 50;
|
||||||
|
MaxEpisodeAgeDays = 0;
|
||||||
CreatePodcastFolders = true;
|
CreatePodcastFolders = true;
|
||||||
DownloadNewEpisodesOnly = true;
|
DownloadNewEpisodesOnly = true;
|
||||||
|
PostDownloadScriptPath = string.Empty;
|
||||||
|
PostDownloadScriptTimeout = 60;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -47,6 +50,12 @@ public class PluginConfiguration : BasePluginConfiguration
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public int MaxEpisodesPerPodcast { get; set; }
|
public int MaxEpisodesPerPodcast { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the maximum age in days for downloaded episodes (0 = unlimited).
|
||||||
|
/// Episodes older than this will be automatically deleted.
|
||||||
|
/// </summary>
|
||||||
|
public int MaxEpisodeAgeDays { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets a value indicating whether to create subfolders for each podcast.
|
/// Gets or sets a value indicating whether to create subfolders for each podcast.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -56,4 +65,15 @@ public class PluginConfiguration : BasePluginConfiguration
|
|||||||
/// Gets or sets a value indicating whether to only download new episodes after subscription.
|
/// Gets or sets a value indicating whether to only download new episodes after subscription.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool DownloadNewEpisodesOnly { get; set; }
|
public bool DownloadNewEpisodesOnly { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the path to post-download processing script.
|
||||||
|
/// Script is called with: script input_file output_file.
|
||||||
|
/// </summary>
|
||||||
|
public string PostDownloadScriptPath { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the timeout in seconds for post-download script execution.
|
||||||
|
/// </summary>
|
||||||
|
public int PostDownloadScriptTimeout { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -164,6 +164,38 @@
|
|||||||
Maximum episodes to keep downloaded per podcast (0 = unlimited)
|
Maximum episodes to keep downloaded per podcast (0 = unlimited)
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="inputContainer">
|
||||||
|
<label class="inputLabel inputLabelUnfocused" for="MaxEpisodeAgeDays">
|
||||||
|
Max Episode Age (days)
|
||||||
|
</label>
|
||||||
|
<input id="MaxEpisodeAgeDays" name="MaxEpisodeAgeDays" type="number" is="emby-input" min="0" />
|
||||||
|
<div class="fieldDescription">
|
||||||
|
Automatically delete episodes downloaded more than this many days ago (0 = unlimited)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Post-Download Processing -->
|
||||||
|
<div class="verticalSection">
|
||||||
|
<h3 class="sectionTitle">Post-Download Processing</h3>
|
||||||
|
<div class="inputContainer">
|
||||||
|
<label class="inputLabel inputLabelUnfocused" for="PostDownloadScriptPath">
|
||||||
|
Post-Download Script Path
|
||||||
|
</label>
|
||||||
|
<input id="PostDownloadScriptPath" name="PostDownloadScriptPath" type="text" is="emby-input" />
|
||||||
|
<div class="fieldDescription">
|
||||||
|
Optional script to process episodes after download. Called as: script input_file output_file
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="inputContainer">
|
||||||
|
<label class="inputLabel inputLabelUnfocused" for="PostDownloadScriptTimeout">
|
||||||
|
Script Timeout (seconds)
|
||||||
|
</label>
|
||||||
|
<input id="PostDownloadScriptTimeout" name="PostDownloadScriptTimeout" type="number" is="emby-input" min="1" max="3600" />
|
||||||
|
<div class="fieldDescription">
|
||||||
|
Maximum time to wait for script completion (default: 60 seconds)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -228,7 +260,10 @@
|
|||||||
document.querySelector('#GlobalAutoDownloadEnabled').checked = config.GlobalAutoDownloadEnabled;
|
document.querySelector('#GlobalAutoDownloadEnabled').checked = config.GlobalAutoDownloadEnabled;
|
||||||
document.querySelector('#MaxConcurrentDownloads').value = config.MaxConcurrentDownloads;
|
document.querySelector('#MaxConcurrentDownloads').value = config.MaxConcurrentDownloads;
|
||||||
document.querySelector('#MaxEpisodesPerPodcast').value = config.MaxEpisodesPerPodcast;
|
document.querySelector('#MaxEpisodesPerPodcast').value = config.MaxEpisodesPerPodcast;
|
||||||
|
document.querySelector('#MaxEpisodeAgeDays').value = config.MaxEpisodeAgeDays;
|
||||||
document.querySelector('#CreatePodcastFolders').checked = config.CreatePodcastFolders;
|
document.querySelector('#CreatePodcastFolders').checked = config.CreatePodcastFolders;
|
||||||
|
document.querySelector('#PostDownloadScriptPath').value = config.PostDownloadScriptPath || '';
|
||||||
|
document.querySelector('#PostDownloadScriptTimeout').value = config.PostDownloadScriptTimeout || 60;
|
||||||
Dashboard.hideLoadingMsg();
|
Dashboard.hideLoadingMsg();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -407,7 +442,10 @@
|
|||||||
config.GlobalAutoDownloadEnabled = document.querySelector('#GlobalAutoDownloadEnabled').checked;
|
config.GlobalAutoDownloadEnabled = document.querySelector('#GlobalAutoDownloadEnabled').checked;
|
||||||
config.MaxConcurrentDownloads = parseInt(document.querySelector('#MaxConcurrentDownloads').value, 10);
|
config.MaxConcurrentDownloads = parseInt(document.querySelector('#MaxConcurrentDownloads').value, 10);
|
||||||
config.MaxEpisodesPerPodcast = parseInt(document.querySelector('#MaxEpisodesPerPodcast').value, 10);
|
config.MaxEpisodesPerPodcast = parseInt(document.querySelector('#MaxEpisodesPerPodcast').value, 10);
|
||||||
|
config.MaxEpisodeAgeDays = parseInt(document.querySelector('#MaxEpisodeAgeDays').value, 10);
|
||||||
config.CreatePodcastFolders = document.querySelector('#CreatePodcastFolders').checked;
|
config.CreatePodcastFolders = document.querySelector('#CreatePodcastFolders').checked;
|
||||||
|
config.PostDownloadScriptPath = document.querySelector('#PostDownloadScriptPath').value;
|
||||||
|
config.PostDownloadScriptTimeout = parseInt(document.querySelector('#PostDownloadScriptTimeout').value, 10);
|
||||||
ApiClient.updatePluginConfiguration(JellypodConfig.pluginUniqueId, config).then(function (result) {
|
ApiClient.updatePluginConfiguration(JellypodConfig.pluginUniqueId, config).then(function (result) {
|
||||||
Dashboard.processPluginConfigurationUpdateResult(result);
|
Dashboard.processPluginConfigurationUpdateResult(result);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -69,6 +69,12 @@ public class Podcast
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public int MaxEpisodesToKeep { get; set; }
|
public int MaxEpisodesToKeep { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the maximum age in days for downloaded episodes.
|
||||||
|
/// 0 = use global setting, -1 = unlimited, greater than 0 = specific days.
|
||||||
|
/// </summary>
|
||||||
|
public int MaxEpisodeAgeDays { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the list of episodes.
|
/// Gets or sets the list of episodes.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Plugin.Jellypod.Models;
|
||||||
using Jellyfin.Plugin.Jellypod.Services;
|
using Jellyfin.Plugin.Jellypod.Services;
|
||||||
using MediaBrowser.Model.Tasks;
|
using MediaBrowser.Model.Tasks;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
@@ -117,7 +118,22 @@ public class PodcastUpdateTask : IScheduledTask
|
|||||||
}
|
}
|
||||||
|
|
||||||
processedCount++;
|
processedCount++;
|
||||||
progress.Report((double)processedCount / totalPodcasts * 100);
|
progress.Report((double)processedCount / totalPodcasts * 90 / 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run cleanup for expired episodes
|
||||||
|
_logger.LogInformation("Starting episode retention cleanup");
|
||||||
|
foreach (var podcast in podcasts)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await CleanupExpiredEpisodesAsync(podcast, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to cleanup expired episodes for: {Title}", podcast.Title);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
progress.Report(100);
|
progress.Report(100);
|
||||||
@@ -139,4 +155,78 @@ public class PodcastUpdateTask : IScheduledTask
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cleans up episodes that exceed the retention policy.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="podcast">The podcast to clean up.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>A task representing the asynchronous operation.</returns>
|
||||||
|
private async Task CleanupExpiredEpisodesAsync(Podcast podcast, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var config = Plugin.Instance?.Configuration;
|
||||||
|
|
||||||
|
// Determine effective max age for this podcast
|
||||||
|
int effectiveMaxAgeDays;
|
||||||
|
if (podcast.MaxEpisodeAgeDays == -1)
|
||||||
|
{
|
||||||
|
// Per-podcast override: unlimited
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
else if (podcast.MaxEpisodeAgeDays > 0)
|
||||||
|
{
|
||||||
|
// Per-podcast specific value
|
||||||
|
effectiveMaxAgeDays = podcast.MaxEpisodeAgeDays;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Use global setting (podcast.MaxEpisodeAgeDays == 0)
|
||||||
|
effectiveMaxAgeDays = config?.MaxEpisodeAgeDays ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 0 means unlimited
|
||||||
|
if (effectiveMaxAgeDays <= 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var cutoffDate = DateTime.UtcNow.AddDays(-effectiveMaxAgeDays);
|
||||||
|
var expiredEpisodes = podcast.Episodes
|
||||||
|
.Where(e => e.Status == EpisodeStatus.Downloaded
|
||||||
|
&& e.DownloadedDate.HasValue
|
||||||
|
&& e.DownloadedDate.Value < cutoffDate)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (expiredEpisodes.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Found {Count} expired episodes for {Podcast} (older than {Days} days)",
|
||||||
|
expiredEpisodes.Count,
|
||||||
|
podcast.Title,
|
||||||
|
effectiveMaxAgeDays);
|
||||||
|
|
||||||
|
foreach (var episode in expiredEpisodes)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _downloadService.DeleteEpisodeFileAsync(episode).ConfigureAwait(false);
|
||||||
|
_logger.LogDebug(
|
||||||
|
"Deleted expired episode: {Title} (downloaded {Date})",
|
||||||
|
episode.Title,
|
||||||
|
episode.DownloadedDate);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to delete expired episode: {Title}", episode.Title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save changes to storage
|
||||||
|
await _storageService.UpdatePodcastAsync(podcast).ConfigureAwait(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,12 @@ namespace Jellyfin.Plugin.Jellypod.Services;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IPodcastStorageService
|
public interface IPodcastStorageService
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the cached last modification time (synchronous, for cache key generation).
|
||||||
|
/// Returns default if database hasn't been loaded yet.
|
||||||
|
/// </summary>
|
||||||
|
DateTime LastModified { get; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets all subscribed podcasts.
|
/// Gets all subscribed podcasts.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -57,4 +63,10 @@ public interface IPodcastStorageService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>The base path for podcast storage.</returns>
|
/// <returns>The base path for podcast storage.</returns>
|
||||||
string GetStoragePath();
|
string GetStoragePath();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the last time the database was modified.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>The last modification time, or null if unknown.</returns>
|
||||||
|
Task<DateTime?> GetLastModifiedAsync();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
|
using System.Diagnostics;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
|
using System.Text;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Jellyfin.Plugin.Jellypod.Models;
|
using Jellyfin.Plugin.Jellypod.Models;
|
||||||
@@ -63,15 +65,27 @@ public sealed class PodcastDownloadService : IPodcastDownloadService, IDisposabl
|
|||||||
IProgress<double>? progress = null,
|
IProgress<double>? progress = null,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var filePath = _storageService.GetEpisodeFilePath(podcast, episode);
|
var finalPath = _storageService.GetEpisodeFilePath(podcast, episode);
|
||||||
var directory = Path.GetDirectoryName(filePath);
|
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
|
try
|
||||||
{
|
{
|
||||||
@@ -91,7 +105,7 @@ public sealed class PodcastDownloadService : IPodcastDownloadService, IDisposabl
|
|||||||
long totalRead;
|
long totalRead;
|
||||||
try
|
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
|
try
|
||||||
{
|
{
|
||||||
var buffer = new byte[81920];
|
var buffer = new byte[81920];
|
||||||
@@ -119,17 +133,51 @@ public sealed class PodcastDownloadService : IPodcastDownloadService, IDisposabl
|
|||||||
await contentStream.DisposeAsync().ConfigureAwait(false);
|
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.Status = EpisodeStatus.Downloaded;
|
||||||
episode.DownloadedDate = DateTime.UtcNow;
|
episode.DownloadedDate = DateTime.UtcNow;
|
||||||
episode.FileSizeBytes = totalRead;
|
episode.FileSizeBytes = finalFileInfo.Length;
|
||||||
|
|
||||||
_logger.LogInformation("Downloaded episode: {Title} ({Size} bytes)", episode.Title, totalRead);
|
|
||||||
|
|
||||||
// Update the podcast in storage
|
// Update the podcast in storage
|
||||||
await _storageService.UpdatePodcastAsync(podcast).ConfigureAwait(false);
|
await _storageService.UpdatePodcastAsync(podcast).ConfigureAwait(false);
|
||||||
|
|
||||||
return filePath;
|
return finalPath;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -137,6 +185,35 @@ public sealed class PodcastDownloadService : IPodcastDownloadService, IDisposabl
|
|||||||
_logger.LogError(ex, "Failed to download episode: {Title}", episode.Title);
|
_logger.LogError(ex, "Failed to download episode: {Title}", episode.Title);
|
||||||
throw;
|
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 />
|
/// <inheritdoc />
|
||||||
@@ -240,6 +317,136 @@ public sealed class PodcastDownloadService : IPodcastDownloadService, IDisposabl
|
|||||||
return result.Length > 100 ? result.Substring(0, 100).Trim() : result.Trim();
|
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 />
|
/// <inheritdoc />
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ public sealed class PodcastStorageService : IPodcastStorageService, IDisposable
|
|||||||
private readonly IApplicationPaths _applicationPaths;
|
private readonly IApplicationPaths _applicationPaths;
|
||||||
private readonly SemaphoreSlim _dbLock = new(1, 1);
|
private readonly SemaphoreSlim _dbLock = new(1, 1);
|
||||||
private PodcastDatabase? _cache;
|
private PodcastDatabase? _cache;
|
||||||
|
private DateTime _lastModified;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="PodcastStorageService"/> class.
|
/// Initializes a new instance of the <see cref="PodcastStorageService"/> class.
|
||||||
@@ -47,6 +48,9 @@ public sealed class PodcastStorageService : IPodcastStorageService, IDisposable
|
|||||||
"Jellypod",
|
"Jellypod",
|
||||||
"podcasts.json");
|
"podcasts.json");
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public DateTime LastModified => _lastModified;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<IReadOnlyList<Podcast>> GetAllPodcastsAsync()
|
public async Task<IReadOnlyList<Podcast>> GetAllPodcastsAsync()
|
||||||
{
|
{
|
||||||
@@ -180,6 +184,7 @@ public sealed class PodcastStorageService : IPodcastStorageService, IDisposable
|
|||||||
{
|
{
|
||||||
_logger.LogWarning("Database file does not exist at {Path}", DatabasePath);
|
_logger.LogWarning("Database file does not exist at {Path}", DatabasePath);
|
||||||
_cache = new PodcastDatabase();
|
_cache = new PodcastDatabase();
|
||||||
|
_lastModified = DateTime.UtcNow;
|
||||||
return _cache;
|
return _cache;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,6 +194,7 @@ public sealed class PodcastStorageService : IPodcastStorageService, IDisposable
|
|||||||
var json = await File.ReadAllTextAsync(DatabasePath).ConfigureAwait(false);
|
var json = await File.ReadAllTextAsync(DatabasePath).ConfigureAwait(false);
|
||||||
_logger.LogInformation("Read {Length} characters from database file", json.Length);
|
_logger.LogInformation("Read {Length} characters from database file", json.Length);
|
||||||
_cache = JsonSerializer.Deserialize<PodcastDatabase>(json, JsonOptions) ?? new PodcastDatabase();
|
_cache = JsonSerializer.Deserialize<PodcastDatabase>(json, JsonOptions) ?? new PodcastDatabase();
|
||||||
|
_lastModified = _cache.LastSaved;
|
||||||
_logger.LogInformation("Loaded {Count} podcasts from database", _cache.Podcasts.Count);
|
_logger.LogInformation("Loaded {Count} podcasts from database", _cache.Podcasts.Count);
|
||||||
return _cache;
|
return _cache;
|
||||||
}
|
}
|
||||||
@@ -196,6 +202,7 @@ public sealed class PodcastStorageService : IPodcastStorageService, IDisposable
|
|||||||
{
|
{
|
||||||
_logger.LogError(ex, "Failed to load podcast database, starting fresh");
|
_logger.LogError(ex, "Failed to load podcast database, starting fresh");
|
||||||
_cache = new PodcastDatabase();
|
_cache = new PodcastDatabase();
|
||||||
|
_lastModified = DateTime.UtcNow;
|
||||||
return _cache;
|
return _cache;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -211,6 +218,7 @@ public sealed class PodcastStorageService : IPodcastStorageService, IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
db.LastSaved = DateTime.UtcNow;
|
db.LastSaved = DateTime.UtcNow;
|
||||||
|
_lastModified = db.LastSaved;
|
||||||
var json = JsonSerializer.Serialize(db, JsonOptions);
|
var json = JsonSerializer.Serialize(db, JsonOptions);
|
||||||
await File.WriteAllTextAsync(DatabasePath, json).ConfigureAwait(false);
|
await File.WriteAllTextAsync(DatabasePath, json).ConfigureAwait(false);
|
||||||
_cache = db;
|
_cache = db;
|
||||||
@@ -261,6 +269,13 @@ public sealed class PodcastStorageService : IPodcastStorageService, IDisposable
|
|||||||
return ".mp3";
|
return ".mp3";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<DateTime?> GetLastModifiedAsync()
|
||||||
|
{
|
||||||
|
var db = await LoadDatabaseAsync().ConfigureAwait(false);
|
||||||
|
return db.LastSaved;
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -15,6 +15,59 @@ https://gitea.tourolle.paris/dtourolle/jellypod/raw/branch/master/manifest.json
|
|||||||
- Browse and subscribe to podcasts
|
- Browse and subscribe to podcasts
|
||||||
- Automatic episode downloads
|
- Automatic episode downloads
|
||||||
- Integration with Jellyfin's library system
|
- Integration with Jellyfin's library system
|
||||||
|
- Post-download script hook for audio processing
|
||||||
|
|
||||||
|
## Post-Download Script Hook
|
||||||
|
|
||||||
|
Jellypod supports running a custom script on each downloaded episode before it's added to your library. This is useful for:
|
||||||
|
|
||||||
|
- Audio normalization (e.g., using ffmpeg-normalize)
|
||||||
|
- Format conversion
|
||||||
|
- Metadata enhancement
|
||||||
|
- Custom processing workflows
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
In the plugin settings (Dashboard → Plugins → Jellypod):
|
||||||
|
|
||||||
|
1. **Post-Download Script Path**: Full path to your script or executable
|
||||||
|
2. **Script Timeout**: Maximum execution time in seconds (default: 60)
|
||||||
|
|
||||||
|
### Script API
|
||||||
|
|
||||||
|
Your script will be called with two arguments:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
script <input_file> <output_file>
|
||||||
|
```
|
||||||
|
|
||||||
|
- `input_file`: Path to the downloaded episode (read-only)
|
||||||
|
- `output_file`: Path where your script should write the processed file
|
||||||
|
|
||||||
|
### Example Scripts
|
||||||
|
|
||||||
|
**Audio normalization (bash + ffmpeg):**
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
INPUT="$1"
|
||||||
|
OUTPUT="$2"
|
||||||
|
ffmpeg-normalize "$INPUT" -o "$OUTPUT" -c:a libmp3lame -b:a 128k
|
||||||
|
```
|
||||||
|
|
||||||
|
**Format conversion (bash + ffmpeg):**
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
INPUT="$1"
|
||||||
|
OUTPUT="$2"
|
||||||
|
ffmpeg -i "$INPUT" -c:a aac -b:a 128k "$OUTPUT"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Behavior
|
||||||
|
|
||||||
|
- If the script succeeds (exit code 0) and creates the output file, the processed file is added to your library
|
||||||
|
- If the script fails, times out, or doesn't create an output file, the original downloaded file is used instead
|
||||||
|
- All script output (stdout/stderr) is logged for debugging
|
||||||
|
- Leave the script path empty to disable post-processing
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
|
|||||||
+18
-1
@@ -7,6 +7,23 @@
|
|||||||
"owner": "dtourolle",
|
"owner": "dtourolle",
|
||||||
"category": "General",
|
"category": "General",
|
||||||
"imageUrl": "https://gitea.tourolle.paris/dtourolle/jellypod/raw/branch/master/Jellyfin.Plugin.Jellypod/Images/channel-icon.jpg",
|
"imageUrl": "https://gitea.tourolle.paris/dtourolle/jellypod/raw/branch/master/Jellyfin.Plugin.Jellypod/Images/channel-icon.jpg",
|
||||||
"versions": []
|
"versions": [
|
||||||
|
{
|
||||||
|
"version": "1.0.1",
|
||||||
|
"changelog": "Release 1.0.1",
|
||||||
|
"targetAbi": "10.9.0.0",
|
||||||
|
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellypod/releases/download/v1.0.1/jellypod_1.0.1.0.zip",
|
||||||
|
"checksum": "8b8cddefe4e6b5c7128e1626a424519b",
|
||||||
|
"timestamp": "2025-12-21T13:07:42Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "1.0.0",
|
||||||
|
"changelog": "Release 1.0.0",
|
||||||
|
"targetAbi": "10.9.0.0",
|
||||||
|
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellypod/releases/download/v1.0.0/jellypod_1.0.0.0.zip",
|
||||||
|
"checksum": "3267fa2bee3661f9a85c959497fe20dd",
|
||||||
|
"timestamp": "2025-12-20T13:00:27Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user