Compare commits

...

8 Commits

Author SHA1 Message Date
d4842c2361 fixing new build system
Some checks failed
🏗️ Build Plugin / build (push) Successful in 48s
🧪 Test Plugin / test (push) Successful in 1m4s
🚀 Release Plugin / build-and-release (push) Failing after 46s
2026-01-11 09:43:51 +01:00
67619a4f56 impoving build system
Some checks failed
🏗️ Build Plugin / build (push) Successful in 57s
🧪 Test Plugin / test (push) Successful in 1m12s
🚀 Release Plugin / build-and-release (push) Failing after 39s
2026-01-11 09:40:18 +01:00
cdf53c5288 Add docker to simplify build
Some checks failed
🧪 Test Plugin / test (push) Successful in 1m10s
🏗️ Build Plugin / build (push) Failing after 20m0s
🚀 Release Plugin / build-and-release (push) Failing after 2m46s
2026-01-10 21:01:28 +01:00
Gitea Actions
6be05158d0 Update manifest.json for version 1.0.4 2026-01-10 18:48:09 +00:00
4f3af0db69 clean up CI job
All checks were successful
🧪 Test Plugin / test (push) Successful in 1m2s
🚀 Release Plugin / build-and-release (push) Successful in 2m6s
🏗️ Build Plugin / build (push) Successful in 1m59s
2026-01-10 19:43:41 +01:00
76714eb0c6 erge branch 'master' of gitea.tourolle.paris:dtourolle/jellypod
Some checks failed
🧪 Test Plugin / test (push) Successful in 1m2s
🚀 Release Plugin / build-and-release (push) Failing after 1m6s
🏗️ Build Plugin / build (push) Failing after 1m11s
2026-01-09 21:55:10 +01:00
f48aa86256 cache podcast images and use them on homepage 2026-01-09 21:54:48 +01:00
Gitea Actions
5a908cbe4d Update manifest.json for version 1.0.2 2025-12-30 15:25:33 +00:00
9 changed files with 313 additions and 39 deletions

View File

@ -15,15 +15,14 @@ on:
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-22.04
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)

View File

@ -13,15 +13,14 @@ on:
jobs: jobs:
build-and-release: build-and-release:
runs-on: ubuntu-latest runs-on: ubuntu-22.04
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)
@ -87,22 +78,20 @@ jobs:
REPO_NAME="${{ github.event.repository.name }}" REPO_NAME="${{ github.event.repository.name }}"
GITEA_URL="${{ github.server_url }}" GITEA_URL="${{ github.server_url }}"
# Prepare release body # Prepare release body and create JSON payload
RELEASE_BODY="Jellypod Jellyfin Plugin ${{ steps.get_version.outputs.version }}\n\nSee attached files for plugin installation.\n\nTo install, add this repository URL to Jellyfin:\n\`${GITEA_URL}/${REPO_OWNER}/${REPO_NAME}/raw/branch/master/manifest.json\`" MANIFEST_URL="${GITEA_URL}/${REPO_OWNER}/${REPO_NAME}/raw/branch/master/manifest.json"
RELEASE_BODY_JSON=$(echo -n "${RELEASE_BODY}" | jq -Rs .)
# Create release using Gitea API # Create release using Gitea API with jq for proper JSON
RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \ RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \ -H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases" \ "${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases" \
-d "{ -d "$(jq -n \
\"tag_name\": \"${{ steps.get_version.outputs.version }}\", --arg tag "${{ steps.get_version.outputs.version }}" \
\"name\": \"Release ${{ steps.get_version.outputs.version }}\", --arg name "Release ${{ steps.get_version.outputs.version }}" \
\"body\": ${RELEASE_BODY_JSON}, --arg body "Jellypod Jellyfin Plugin ${{ steps.get_version.outputs.version }}\n\nSee attached files for plugin installation.\n\nTo install, add this repository URL to Jellyfin:\n${MANIFEST_URL}" \
\"draft\": false, '{tag_name: $tag, name: $name, body: $body, draft: false, prerelease: false}'
\"prerelease\": false )")
}")
HTTP_CODE=$(echo "$RESPONSE" | tail -n1) HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
BODY=$(echo "$RESPONSE" | sed '$d') BODY=$(echo "$RESPONSE" | sed '$d')

16
Dockerfile.build Normal file
View File

@ -0,0 +1,16 @@
FROM mcr.microsoft.com/dotnet/sdk:8.0
# Install Python, Node.js, and tools
RUN apt-get update && apt-get install -y \
python3 \
python3-pip \
jq \
git \
nodejs \
npm \
&& rm -rf /var/lib/apt/lists/*
# Install JPRM
RUN pip install --break-system-packages jprm
WORKDIR /src

View 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('.');
}
}

View File

@ -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);
@ -230,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);
} }

View File

@ -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,
@ -497,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}";
}
} }

View File

@ -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);
} }

View File

@ -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

View File

@ -8,6 +8,22 @@
"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", "version": "1.0.1",
"changelog": "Release 1.0.1", "changelog": "Release 1.0.1",