Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cdf53c5288 | |||
|
|
6be05158d0 | ||
| 4f3af0db69 | |||
| 76714eb0c6 | |||
| f48aa86256 | |||
|
|
5a908cbe4d | ||
| d890c11a9b | |||
| c54221fba2 | |||
|
|
221a3f634d |
@ -16,14 +16,13 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: gitea.tourolle.paris/dtourolle/jellypod-builder:latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Verify .NET installation
|
|
||||||
run: dotnet --version
|
|
||||||
|
|
||||||
- name: Restore dependencies
|
- name: Restore dependencies
|
||||||
run: dotnet restore Jellyfin.Plugin.Jellypod.sln
|
run: dotnet restore Jellyfin.Plugin.Jellypod.sln
|
||||||
|
|
||||||
@ -33,19 +32,11 @@ jobs:
|
|||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: dotnet test Jellyfin.Plugin.Jellypod.sln --no-build --configuration Release --verbosity normal
|
run: dotnet test Jellyfin.Plugin.Jellypod.sln --no-build --configuration Release --verbosity normal
|
||||||
|
|
||||||
- name: Install JPRM
|
|
||||||
run: |
|
|
||||||
python3 -m venv /tmp/jprm-venv
|
|
||||||
/tmp/jprm-venv/bin/pip install jprm
|
|
||||||
|
|
||||||
- name: Build Jellyfin Plugin
|
- name: Build Jellyfin Plugin
|
||||||
id: jprm
|
id: jprm
|
||||||
run: |
|
run: |
|
||||||
# Create artifacts directory for JPRM output
|
|
||||||
mkdir -p artifacts
|
mkdir -p artifacts
|
||||||
|
jprm --verbosity=debug plugin build .
|
||||||
# Build plugin using JPRM
|
|
||||||
/tmp/jprm-venv/bin/jprm --verbosity=debug plugin build .
|
|
||||||
|
|
||||||
# Find the generated zip file
|
# Find the generated zip file
|
||||||
ARTIFACT=$(find . -name "*.zip" -type f -print -quit)
|
ARTIFACT=$(find . -name "*.zip" -type f -print -quit)
|
||||||
|
|||||||
@ -14,14 +14,13 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
build-and-release:
|
build-and-release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: gitea.tourolle.paris/dtourolle/jellypod-builder:latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Verify .NET installation
|
|
||||||
run: dotnet --version
|
|
||||||
|
|
||||||
- name: Get version
|
- name: Get version
|
||||||
id: get_version
|
id: get_version
|
||||||
run: |
|
run: |
|
||||||
@ -49,19 +48,11 @@ jobs:
|
|||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: dotnet test Jellyfin.Plugin.Jellypod.sln --no-build --configuration Release --verbosity normal
|
run: dotnet test Jellyfin.Plugin.Jellypod.sln --no-build --configuration Release --verbosity normal
|
||||||
|
|
||||||
- name: Install JPRM
|
|
||||||
run: |
|
|
||||||
python3 -m venv /tmp/jprm-venv
|
|
||||||
/tmp/jprm-venv/bin/pip install jprm
|
|
||||||
|
|
||||||
- name: Build Jellyfin Plugin
|
- name: Build Jellyfin Plugin
|
||||||
id: jprm
|
id: jprm
|
||||||
run: |
|
run: |
|
||||||
# Create artifacts directory for JPRM output
|
|
||||||
mkdir -p artifacts
|
mkdir -p artifacts
|
||||||
|
jprm --verbosity=debug plugin build ./
|
||||||
# Build plugin using JPRM
|
|
||||||
/tmp/jprm-venv/bin/jprm --verbosity=debug plugin build ./
|
|
||||||
|
|
||||||
# Find the generated zip file
|
# Find the generated zip file
|
||||||
ARTIFACT=$(find . -name "*.zip" -type f -print -quit)
|
ARTIFACT=$(find . -name "*.zip" -type f -print -quit)
|
||||||
|
|||||||
13
Dockerfile.build
Normal file
13
Dockerfile.build
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
FROM mcr.microsoft.com/dotnet/sdk:8.0
|
||||||
|
|
||||||
|
# Install Python and pip for JPRM
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
python3 \
|
||||||
|
python3-pip \
|
||||||
|
jq \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install JPRM
|
||||||
|
RUN pip install --break-system-packages jprm
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
189
Jellyfin.Plugin.Jellypod/Api/ImageController.cs
Normal file
189
Jellyfin.Plugin.Jellypod/Api/ImageController.cs
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
using System;
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Plugin.Jellypod.Services;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.Jellypod.Api;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Controller for serving podcast and episode images.
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Route("Jellypod/Image")]
|
||||||
|
public class ImageController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly ILogger<ImageController> _logger;
|
||||||
|
private readonly IPodcastStorageService _storageService;
|
||||||
|
private readonly IHttpClientFactory _httpClientFactory;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="ImageController"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="logger">Logger instance.</param>
|
||||||
|
/// <param name="storageService">Storage service instance.</param>
|
||||||
|
/// <param name="httpClientFactory">HTTP client factory.</param>
|
||||||
|
public ImageController(
|
||||||
|
ILogger<ImageController> logger,
|
||||||
|
IPodcastStorageService storageService,
|
||||||
|
IHttpClientFactory httpClientFactory)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_storageService = storageService;
|
||||||
|
_httpClientFactory = httpClientFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the image for a podcast.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="podcastId">The podcast ID.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>The image file.</returns>
|
||||||
|
[HttpGet("podcast/{podcastId}")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
[SuppressMessage("Microsoft.Security", "CA3003:ReviewCodeForFilePathInjectionVulnerabilities", Justification = "Path is constructed from validated GUID and sanitized podcast title")]
|
||||||
|
public async Task<IActionResult> GetPodcastImage(
|
||||||
|
[FromRoute] Guid podcastId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var podcast = await _storageService.GetPodcastAsync(podcastId).ConfigureAwait(false);
|
||||||
|
if (podcast == null)
|
||||||
|
{
|
||||||
|
return NotFound("Podcast not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to serve local cached image first
|
||||||
|
var config = Plugin.Instance?.Configuration;
|
||||||
|
if (config?.CreatePodcastFolders == true)
|
||||||
|
{
|
||||||
|
var basePath = _storageService.GetStoragePath();
|
||||||
|
var podcastFolder = Path.Combine(basePath, SanitizeFileName(podcast.Title));
|
||||||
|
var artworkPath = Path.Combine(podcastFolder, "folder.jpg");
|
||||||
|
|
||||||
|
if (System.IO.File.Exists(artworkPath))
|
||||||
|
{
|
||||||
|
var fileStream = System.IO.File.OpenRead(artworkPath);
|
||||||
|
return File(fileStream, "image/jpeg");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to proxying the external URL
|
||||||
|
if (!string.IsNullOrEmpty(podcast.ImageUrl))
|
||||||
|
{
|
||||||
|
return await ProxyImageAsync(podcast.ImageUrl, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NotFound("No image available");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error serving podcast image for {PodcastId}", podcastId);
|
||||||
|
return StatusCode(500, "Failed to serve podcast image");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the image for an episode.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="podcastId">The podcast ID.</param>
|
||||||
|
/// <param name="episodeId">The episode ID.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>The image file.</returns>
|
||||||
|
[HttpGet("episode/{podcastId}/{episodeId}")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
[SuppressMessage("Microsoft.Security", "CA3003:ReviewCodeForFilePathInjectionVulnerabilities", Justification = "Path is constructed from validated GUIDs and sanitized podcast title")]
|
||||||
|
public async Task<IActionResult> GetEpisodeImage(
|
||||||
|
[FromRoute] Guid podcastId,
|
||||||
|
[FromRoute] Guid episodeId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var podcast = await _storageService.GetPodcastAsync(podcastId).ConfigureAwait(false);
|
||||||
|
if (podcast == null)
|
||||||
|
{
|
||||||
|
return NotFound("Podcast not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
var episode = podcast.Episodes.FirstOrDefault(e => e.Id == episodeId);
|
||||||
|
if (episode == null)
|
||||||
|
{
|
||||||
|
return NotFound("Episode not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to serve local cached episode image first
|
||||||
|
var config = Plugin.Instance?.Configuration;
|
||||||
|
if (config?.CreatePodcastFolders == true)
|
||||||
|
{
|
||||||
|
var basePath = _storageService.GetStoragePath();
|
||||||
|
var podcastFolder = Path.Combine(basePath, SanitizeFileName(podcast.Title));
|
||||||
|
var episodeFileName = $"{SanitizeFileName(episode.Id.ToString())}.jpg";
|
||||||
|
var artworkPath = Path.Combine(podcastFolder, episodeFileName);
|
||||||
|
|
||||||
|
if (System.IO.File.Exists(artworkPath))
|
||||||
|
{
|
||||||
|
var fileStream = System.IO.File.OpenRead(artworkPath);
|
||||||
|
return File(fileStream, "image/jpeg");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to episode ImageUrl if available
|
||||||
|
if (!string.IsNullOrEmpty(episode.ImageUrl))
|
||||||
|
{
|
||||||
|
return await ProxyImageAsync(episode.ImageUrl, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final fallback to podcast image
|
||||||
|
if (!string.IsNullOrEmpty(podcast.ImageUrl))
|
||||||
|
{
|
||||||
|
return await ProxyImageAsync(podcast.ImageUrl, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NotFound("No image available");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error serving episode image for {EpisodeId}", episodeId);
|
||||||
|
return StatusCode(500, "Failed to serve episode image");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IActionResult> ProxyImageAsync(string imageUrl, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var client = _httpClientFactory.CreateClient("Jellypod");
|
||||||
|
var response = await client.GetAsync(imageUrl, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Failed to fetch image: {StatusCode} from {Url}", response.StatusCode, imageUrl);
|
||||||
|
return StatusCode((int)response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
var contentType = response.Content.Headers.ContentType?.MediaType ?? "image/jpeg";
|
||||||
|
var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return File(stream, contentType);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error proxying image from {Url}", imageUrl);
|
||||||
|
return StatusCode(500, "Failed to proxy image");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string SanitizeFileName(string name)
|
||||||
|
{
|
||||||
|
var invalidChars = Path.GetInvalidFileNameChars();
|
||||||
|
return string.Join("_", name.Split(invalidChars, StringSplitOptions.RemoveEmptyEntries)).TrimEnd('.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -119,6 +119,12 @@ public class JellypodController : ControllerBase
|
|||||||
// Download podcast artwork
|
// Download podcast artwork
|
||||||
await _downloadService.DownloadPodcastArtworkAsync(podcast).ConfigureAwait(false);
|
await _downloadService.DownloadPodcastArtworkAsync(podcast).ConfigureAwait(false);
|
||||||
|
|
||||||
|
// Download episode artwork for all initial episodes
|
||||||
|
foreach (var episode in podcast.Episodes)
|
||||||
|
{
|
||||||
|
await _downloadService.DownloadEpisodeArtworkAsync(podcast, episode).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
_logger.LogInformation("Added podcast: {Title} ({Url})", podcast.Title, podcast.FeedUrl);
|
_logger.LogInformation("Added podcast: {Title} ({Url})", podcast.Title, podcast.FeedUrl);
|
||||||
|
|
||||||
return CreatedAtAction(nameof(GetPodcast), new { id = podcast.Id }, podcast);
|
return CreatedAtAction(nameof(GetPodcast), new { id = podcast.Id }, podcast);
|
||||||
@ -180,6 +186,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);
|
||||||
}
|
}
|
||||||
@ -225,6 +236,12 @@ public class JellypodController : ControllerBase
|
|||||||
podcast.LastUpdated = DateTime.UtcNow;
|
podcast.LastUpdated = DateTime.UtcNow;
|
||||||
await _storageService.UpdatePodcastAsync(podcast).ConfigureAwait(false);
|
await _storageService.UpdatePodcastAsync(podcast).ConfigureAwait(false);
|
||||||
|
|
||||||
|
// Download artwork for new episodes
|
||||||
|
foreach (var episode in newEpisodes)
|
||||||
|
{
|
||||||
|
await _downloadService.DownloadEpisodeArtworkAsync(podcast, episode).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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -189,7 +189,7 @@ public class JellypodChannel : IChannel, IHasCacheKey, IRequiresMediaInfoCallbac
|
|||||||
Id = podcast.Id.ToString("N"),
|
Id = podcast.Id.ToString("N"),
|
||||||
Name = podcast.Title,
|
Name = podcast.Title,
|
||||||
Overview = podcast.Description,
|
Overview = podcast.Description,
|
||||||
ImageUrl = podcast.ImageUrl,
|
ImageUrl = GetPodcastImageUrl(podcast.Id),
|
||||||
Type = ChannelItemType.Folder,
|
Type = ChannelItemType.Folder,
|
||||||
FolderType = ChannelFolderType.Container,
|
FolderType = ChannelFolderType.Container,
|
||||||
DateCreated = podcast.DateAdded,
|
DateCreated = podcast.DateAdded,
|
||||||
@ -257,7 +257,7 @@ public class JellypodChannel : IChannel, IHasCacheKey, IRequiresMediaInfoCallbac
|
|||||||
Id = episode.Id.ToString("N"),
|
Id = episode.Id.ToString("N"),
|
||||||
Name = episodeName,
|
Name = episodeName,
|
||||||
Overview = overview,
|
Overview = overview,
|
||||||
ImageUrl = episode.ImageUrl ?? podcast.ImageUrl,
|
ImageUrl = GetEpisodeImageUrl(podcast.Id, episode.Id),
|
||||||
Type = ChannelItemType.Media,
|
Type = ChannelItemType.Media,
|
||||||
ContentType = ChannelMediaContentType.Podcast,
|
ContentType = ChannelMediaContentType.Podcast,
|
||||||
MediaType = ChannelMediaType.Audio,
|
MediaType = ChannelMediaType.Audio,
|
||||||
@ -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 />
|
||||||
@ -498,4 +497,16 @@ public class JellypodChannel : IChannel, IHasCacheKey, IRequiresMediaInfoCallbac
|
|||||||
ReadAtNativeFramerate = false
|
ReadAtNativeFramerate = false
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private string GetPodcastImageUrl(Guid podcastId)
|
||||||
|
{
|
||||||
|
var localAddress = _appHost.GetApiUrlForLocalAccess();
|
||||||
|
return $"{localAddress}/Jellypod/Image/podcast/{podcastId:N}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetEpisodeImageUrl(Guid podcastId, Guid episodeId)
|
||||||
|
{
|
||||||
|
var localAddress = _appHost.GetApiUrlForLocalAccess();
|
||||||
|
return $"{localAddress}/Jellypod/Image/episode/{podcastId:N}/{episodeId:N}";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,6 +17,7 @@ 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;
|
PostDownloadScriptPath = string.Empty;
|
||||||
@ -49,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>
|
||||||
|
|||||||
@ -164,6 +164,15 @@
|
|||||||
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>
|
</div>
|
||||||
|
|
||||||
<!-- Post-Download Processing -->
|
<!-- Post-Download Processing -->
|
||||||
@ -251,6 +260,7 @@
|
|||||||
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('#PostDownloadScriptPath').value = config.PostDownloadScriptPath || '';
|
||||||
document.querySelector('#PostDownloadScriptTimeout').value = config.PostDownloadScriptTimeout || 60;
|
document.querySelector('#PostDownloadScriptTimeout').value = config.PostDownloadScriptTimeout || 60;
|
||||||
@ -432,6 +442,7 @@
|
|||||||
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.PostDownloadScriptPath = document.querySelector('#PostDownloadScriptPath').value;
|
||||||
config.PostDownloadScriptTimeout = parseInt(document.querySelector('#PostDownloadScriptTimeout').value, 10);
|
config.PostDownloadScriptTimeout = parseInt(document.querySelector('#PostDownloadScriptTimeout').value, 10);
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -46,4 +46,13 @@ public interface IPodcastDownloadService
|
|||||||
/// <param name="cancellationToken">Cancellation token.</param>
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
/// <returns>Task representing the download operation.</returns>
|
/// <returns>Task representing the download operation.</returns>
|
||||||
Task DownloadPodcastArtworkAsync(Podcast podcast, CancellationToken cancellationToken = default);
|
Task DownloadPodcastArtworkAsync(Podcast podcast, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Downloads episode artwork.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="podcast">The podcast.</param>
|
||||||
|
/// <param name="episode">The episode.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>Task representing the download operation.</returns>
|
||||||
|
Task DownloadEpisodeArtworkAsync(Podcast podcast, Episode episode, CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -283,6 +283,46 @@ public sealed class PodcastDownloadService : IPodcastDownloadService, IDisposabl
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task DownloadEpisodeArtworkAsync(Podcast podcast, Episode episode, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(episode.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 episodeFileName = $"{SanitizeFileName(episode.Id.ToString())}.jpg";
|
||||||
|
var artworkPath = Path.Combine(podcastFolder, episodeFileName);
|
||||||
|
|
||||||
|
if (File.Exists(artworkPath))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(podcastFolder);
|
||||||
|
|
||||||
|
var httpClient = _httpClientFactory.CreateClient("Jellypod");
|
||||||
|
var imageBytes = await httpClient.GetByteArrayAsync(episode.ImageUrl, cancellationToken).ConfigureAwait(false);
|
||||||
|
await File.WriteAllBytesAsync(artworkPath, imageBytes, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
_logger.LogDebug("Downloaded artwork for episode: {Title}", episode.Title);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "Failed to download artwork for episode: {Title}", episode.Title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task ProcessQueueAsync()
|
private async Task ProcessQueueAsync()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
@ -8,6 +8,30 @@
|
|||||||
"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.4",
|
||||||
|
"changelog": "Release 1.0.4",
|
||||||
|
"targetAbi": "10.9.0.0",
|
||||||
|
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellypod/releases/download/v1.0.4/jellypod_1.0.4.0.zip",
|
||||||
|
"checksum": "0e0c134e6584581a8498cb17dfda334b",
|
||||||
|
"timestamp": "2026-01-10T18:48:08Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "1.0.2",
|
||||||
|
"changelog": "Release 1.0.2",
|
||||||
|
"targetAbi": "10.9.0.0",
|
||||||
|
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellypod/releases/download/v1.0.2/jellypod_1.0.2.0.zip",
|
||||||
|
"checksum": "874f6b76c8cf4bac6495fe946224096a",
|
||||||
|
"timestamp": "2025-12-30T15:25:33Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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",
|
"version": "1.0.0",
|
||||||
"changelog": "Release 1.0.0",
|
"changelog": "Release 1.0.0",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user