Compare commits
13
Commits
5a908cbe4d
...
v1.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c67005bd8f | ||
|
|
361028574e | ||
|
|
c81845c338 | ||
|
|
0348cb3d09 | ||
|
|
a3ba704882 | ||
|
|
77bbc87a1a | ||
|
|
d4842c2361 | ||
|
|
67619a4f56 | ||
|
|
cdf53c5288 | ||
|
|
6be05158d0 | ||
|
|
4f3af0db69 | ||
|
|
76714eb0c6 | ||
|
|
f48aa86256 |
+41
-20
@@ -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,29 +32,51 @@ 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
|
- name: Build plugin for Jellyfin 10.9 (net8.0)
|
||||||
|
id: jprm_jf109
|
||||||
run: |
|
run: |
|
||||||
python3 -m venv /tmp/jprm-venv
|
mkdir -p ./artifacts/jf10.9
|
||||||
/tmp/jprm-venv/bin/pip install jprm
|
jprm --verbosity=debug plugin build . --output ./artifacts/jf10.9
|
||||||
|
|
||||||
- name: Build Jellyfin Plugin
|
ARTIFACT=$(find ./artifacts/jf10.9 -name "*.zip" -type f -print -quit)
|
||||||
id: jprm
|
RENAMED="./artifacts/jf10.9/$(basename "${ARTIFACT}" .zip)_jf10.9.zip"
|
||||||
|
mv "${ARTIFACT}" "${RENAMED}"
|
||||||
|
echo "artifact=${RENAMED}" >> $GITHUB_OUTPUT
|
||||||
|
echo "Found artifact: ${RENAMED}"
|
||||||
|
|
||||||
|
- name: Retarget build config at Jellyfin 12.0 (net10.0)
|
||||||
run: |
|
run: |
|
||||||
# Create artifacts directory for JPRM output
|
sed -i \
|
||||||
mkdir -p artifacts
|
-e 's/^targetAbi:.*/targetAbi: "12.0.0.0"/' \
|
||||||
|
-e 's/^framework:.*/framework: "net10.0"/' \
|
||||||
|
-e 's/^dotnet_framework:.*/dotnet_framework: "net10.0"/' \
|
||||||
|
build.yaml
|
||||||
|
cat build.yaml
|
||||||
|
|
||||||
# Build plugin using JPRM
|
- name: Build plugin for Jellyfin 12.0 (net10.0)
|
||||||
/tmp/jprm-venv/bin/jprm --verbosity=debug plugin build .
|
id: jprm_jf12
|
||||||
|
run: |
|
||||||
|
mkdir -p ./artifacts/jf12
|
||||||
|
jprm --verbosity=debug plugin build . --output ./artifacts/jf12
|
||||||
|
|
||||||
# Find the generated zip file
|
ARTIFACT=$(find ./artifacts/jf12 -name "*.zip" -type f -print -quit)
|
||||||
ARTIFACT=$(find . -name "*.zip" -type f -print -quit)
|
RENAMED="./artifacts/jf12/$(basename "${ARTIFACT}" .zip)_jf12.zip"
|
||||||
echo "artifact=${ARTIFACT}" >> $GITHUB_OUTPUT
|
mv "${ARTIFACT}" "${RENAMED}"
|
||||||
echo "Found artifact: ${ARTIFACT}"
|
echo "artifact=${RENAMED}" >> $GITHUB_OUTPUT
|
||||||
|
echo "Found artifact: ${RENAMED}"
|
||||||
|
|
||||||
- name: Upload build artifact
|
- name: Upload build artifact (Jellyfin 10.9)
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: jellypod-plugin
|
name: jellypod-plugin-jf10.9
|
||||||
path: ${{ steps.jprm.outputs.artifact }}
|
path: ${{ steps.jprm_jf109.outputs.artifact }}
|
||||||
|
retention-days: 30
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
- name: Upload build artifact (Jellyfin 12.0)
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: jellypod-plugin-jf12
|
||||||
|
path: ${{ steps.jprm_jf12.outputs.artifact }}
|
||||||
retention-days: 30
|
retention-days: 30
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|||||||
@@ -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: |
|
||||||
@@ -40,6 +39,17 @@ jobs:
|
|||||||
sed -i "s/^version:.*/version: \"${VERSION}\"/" build.yaml
|
sed -i "s/^version:.*/version: \"${VERSION}\"/" build.yaml
|
||||||
cat build.yaml
|
cat build.yaml
|
||||||
|
|
||||||
|
- name: Generate Jellyfin 12.0 build config
|
||||||
|
run: |
|
||||||
|
# build.yaml is the Jellyfin 10.9 (net8.0) config; derive the
|
||||||
|
# Jellyfin 12.0 (net10.0) one from it so the two cannot drift apart.
|
||||||
|
sed \
|
||||||
|
-e 's/^targetAbi:.*/targetAbi: "12.0.0.0"/' \
|
||||||
|
-e 's/^framework:.*/framework: "net10.0"/' \
|
||||||
|
-e 's/^dotnet_framework:.*/dotnet_framework: "net10.0"/' \
|
||||||
|
build.yaml > build.jf12.yaml
|
||||||
|
cat build.jf12.yaml
|
||||||
|
|
||||||
- name: Restore dependencies
|
- name: Restore dependencies
|
||||||
run: dotnet restore Jellyfin.Plugin.Jellypod.sln
|
run: dotnet restore Jellyfin.Plugin.Jellypod.sln
|
||||||
|
|
||||||
@@ -49,33 +59,39 @@ 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
|
- name: Build plugin for Jellyfin 10.9 (net8.0)
|
||||||
|
id: jprm_jf109
|
||||||
run: |
|
run: |
|
||||||
python3 -m venv /tmp/jprm-venv
|
mkdir -p ./artifacts/jf10.9
|
||||||
/tmp/jprm-venv/bin/pip install jprm
|
jprm --verbosity=debug plugin build . --output ./artifacts/jf10.9
|
||||||
|
|
||||||
- name: Build Jellyfin Plugin
|
ARTIFACT=$(find ./artifacts/jf10.9 -name "*.zip" -type f -print -quit)
|
||||||
id: jprm
|
RENAMED="./artifacts/jf10.9/$(basename "${ARTIFACT}" .zip)_jf10.9.zip"
|
||||||
|
mv "${ARTIFACT}" "${RENAMED}"
|
||||||
|
echo "artifact=${RENAMED}" >> $GITHUB_OUTPUT
|
||||||
|
echo "artifact_name=$(basename "${RENAMED}")" >> $GITHUB_OUTPUT
|
||||||
|
echo "checksum=$(md5sum "${RENAMED}" | awk '{print $1}')" >> $GITHUB_OUTPUT
|
||||||
|
echo "Found artifact: ${RENAMED}"
|
||||||
|
|
||||||
|
- name: Build plugin for Jellyfin 12.0 (net10.0)
|
||||||
|
id: jprm_jf12
|
||||||
run: |
|
run: |
|
||||||
# Create artifacts directory for JPRM output
|
# jprm always reads build.yaml, so swap the 12.0 config in for this build.
|
||||||
mkdir -p artifacts
|
cp build.yaml build.jf10.9.yaml
|
||||||
|
cp build.jf12.yaml build.yaml
|
||||||
|
|
||||||
# Build plugin using JPRM
|
mkdir -p ./artifacts/jf12
|
||||||
/tmp/jprm-venv/bin/jprm --verbosity=debug plugin build ./
|
jprm --verbosity=debug plugin build . --output ./artifacts/jf12
|
||||||
|
|
||||||
# Find the generated zip file
|
mv build.jf10.9.yaml build.yaml
|
||||||
ARTIFACT=$(find . -name "*.zip" -type f -print -quit)
|
|
||||||
ARTIFACT_NAME=$(basename "${ARTIFACT}")
|
|
||||||
echo "artifact=${ARTIFACT}" >> $GITHUB_OUTPUT
|
|
||||||
echo "artifact_name=${ARTIFACT_NAME}" >> $GITHUB_OUTPUT
|
|
||||||
echo "Found artifact: ${ARTIFACT}"
|
|
||||||
|
|
||||||
- name: Calculate checksum
|
ARTIFACT=$(find ./artifacts/jf12 -name "*.zip" -type f -print -quit)
|
||||||
id: checksum
|
RENAMED="./artifacts/jf12/$(basename "${ARTIFACT}" .zip)_jf12.zip"
|
||||||
run: |
|
mv "${ARTIFACT}" "${RENAMED}"
|
||||||
CHECKSUM=$(md5sum "${{ steps.jprm.outputs.artifact }}" | awk '{print $1}')
|
echo "artifact=${RENAMED}" >> $GITHUB_OUTPUT
|
||||||
echo "checksum=${CHECKSUM}" >> $GITHUB_OUTPUT
|
echo "artifact_name=$(basename "${RENAMED}")" >> $GITHUB_OUTPUT
|
||||||
echo "MD5 Checksum: ${CHECKSUM}"
|
echo "checksum=$(md5sum "${RENAMED}" | awk '{print $1}')" >> $GITHUB_OUTPUT
|
||||||
|
echo "Found artifact: ${RENAMED}"
|
||||||
|
|
||||||
- name: Create Release
|
- name: Create Release
|
||||||
id: create_release
|
id: create_release
|
||||||
@@ -87,22 +103,13 @@ 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
|
|
||||||
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\`"
|
|
||||||
RELEASE_BODY_JSON=$(echo -n "${RELEASE_BODY}" | jq -Rs .)
|
|
||||||
|
|
||||||
# Create release using Gitea API
|
# Create release using Gitea API
|
||||||
|
VERSION="${{ steps.get_version.outputs.version }}"
|
||||||
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 --arg tag "$VERSION" --arg name "Release $VERSION" --arg body "Jellypod Jellyfin Plugin. Install the _jf10.9 build on Jellyfin 10.9.x and the _jf12 build on Jellyfin 12.0 or newer." '{tag_name: $tag, name: $name, body: $body, draft: false, prerelease: false}')")
|
||||||
\"tag_name\": \"${{ steps.get_version.outputs.version }}\",
|
|
||||||
\"name\": \"Release ${{ steps.get_version.outputs.version }}\",
|
|
||||||
\"body\": ${RELEASE_BODY_JSON},
|
|
||||||
\"draft\": 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')
|
||||||
@@ -117,21 +124,19 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Upload plugin artifact
|
upload_asset() {
|
||||||
echo "Uploading plugin artifact..."
|
echo "Uploading $2 ..."
|
||||||
curl -f -X POST \
|
curl -f -X POST \
|
||||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
-H "Content-Type: application/zip" \
|
-H "Content-Type: $3" \
|
||||||
--data-binary "@${{ steps.jprm.outputs.artifact }}" \
|
--data-binary "@$1" \
|
||||||
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}/assets?name=${{ steps.jprm.outputs.artifact_name }}"
|
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}/assets?name=$2"
|
||||||
|
}
|
||||||
|
|
||||||
# Upload build.yaml
|
upload_asset "${{ steps.jprm_jf109.outputs.artifact }}" "${{ steps.jprm_jf109.outputs.artifact_name }}" "application/zip"
|
||||||
echo "Uploading build.yaml..."
|
upload_asset "${{ steps.jprm_jf12.outputs.artifact }}" "${{ steps.jprm_jf12.outputs.artifact_name }}" "application/zip"
|
||||||
curl -f -X POST \
|
upload_asset build.yaml build.yaml "application/x-yaml"
|
||||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
upload_asset build.jf12.yaml build.jf12.yaml "application/x-yaml"
|
||||||
-H "Content-Type: application/x-yaml" \
|
|
||||||
--data-binary "@build.yaml" \
|
|
||||||
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}/assets?name=build.yaml"
|
|
||||||
|
|
||||||
echo "Release created successfully!"
|
echo "Release created successfully!"
|
||||||
echo "View at: ${GITEA_URL}/${REPO_OWNER}/${REPO_NAME}/releases/tag/${{ steps.get_version.outputs.version }}"
|
echo "View at: ${GITEA_URL}/${REPO_OWNER}/${REPO_NAME}/releases/tag/${{ steps.get_version.outputs.version }}"
|
||||||
@@ -144,10 +149,8 @@ jobs:
|
|||||||
REPO_NAME="${{ github.event.repository.name }}"
|
REPO_NAME="${{ github.event.repository.name }}"
|
||||||
GITEA_URL="${{ github.server_url }}"
|
GITEA_URL="${{ github.server_url }}"
|
||||||
VERSION="${{ steps.get_version.outputs.version_number }}"
|
VERSION="${{ steps.get_version.outputs.version_number }}"
|
||||||
CHECKSUM="${{ steps.checksum.outputs.checksum }}"
|
|
||||||
ARTIFACT_NAME="${{ steps.jprm.outputs.artifact_name }}"
|
|
||||||
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||||
DOWNLOAD_URL="${GITEA_URL}/${REPO_OWNER}/${REPO_NAME}/releases/download/${{ steps.get_version.outputs.version }}/${ARTIFACT_NAME}"
|
RELEASE_URL="${GITEA_URL}/${REPO_OWNER}/${REPO_NAME}/releases/download/${{ steps.get_version.outputs.version }}"
|
||||||
|
|
||||||
# Configure git
|
# Configure git
|
||||||
git config user.name "Gitea Actions"
|
git config user.name "Gitea Actions"
|
||||||
@@ -157,21 +160,24 @@ jobs:
|
|||||||
git fetch origin master
|
git fetch origin master
|
||||||
git checkout master
|
git checkout master
|
||||||
|
|
||||||
# Create new version entry
|
# Prepend both ABI entries for this version. Jellyfin keeps only the
|
||||||
NEW_VERSION=$(cat <<EOF
|
# entries whose targetAbi is <= the server version and then takes the
|
||||||
{
|
# first of the highest version, so the 12.0 entry must come first: a
|
||||||
"version": "${VERSION}",
|
# 12.0 server sees both and picks it, a 10.9 server never sees it.
|
||||||
"changelog": "Release ${VERSION}",
|
jq --arg ver "${VERSION}" \
|
||||||
"targetAbi": "10.9.0.0",
|
--arg changelog "Release ${VERSION}" \
|
||||||
"sourceUrl": "${DOWNLOAD_URL}",
|
--arg abi12 "12.0.0.0" \
|
||||||
"checksum": "${CHECKSUM}",
|
--arg url12 "${RELEASE_URL}/${{ steps.jprm_jf12.outputs.artifact_name }}" \
|
||||||
"timestamp": "${TIMESTAMP}"
|
--arg checksum12 "${{ steps.jprm_jf12.outputs.checksum }}" \
|
||||||
}
|
--arg abi109 "10.9.0.0" \
|
||||||
EOF
|
--arg url109 "${RELEASE_URL}/${{ steps.jprm_jf109.outputs.artifact_name }}" \
|
||||||
)
|
--arg checksum109 "${{ steps.jprm_jf109.outputs.checksum }}" \
|
||||||
|
--arg ts "${TIMESTAMP}" \
|
||||||
# Update manifest.json - prepend new version to versions array
|
'.[0].versions = [
|
||||||
jq --argjson newver "${NEW_VERSION}" '.[0].versions = [$newver] + .[0].versions' manifest.json > manifest.tmp && mv manifest.tmp manifest.json
|
{version: $ver, changelog: $changelog, targetAbi: $abi12, sourceUrl: $url12, checksum: $checksum12, timestamp: $ts},
|
||||||
|
{version: $ver, changelog: $changelog, targetAbi: $abi109, sourceUrl: $url109, checksum: $checksum109, timestamp: $ts}
|
||||||
|
] + .[0].versions' \
|
||||||
|
manifest.json > manifest.tmp && mv manifest.tmp manifest.json
|
||||||
|
|
||||||
# Commit and push
|
# Commit and push
|
||||||
git add manifest.json
|
git add manifest.json
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
name: '🧪 Test Plugin'
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- master
|
|
||||||
- develop
|
|
||||||
paths-ignore:
|
|
||||||
- '**/*.md'
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- master
|
|
||||||
- develop
|
|
||||||
paths-ignore:
|
|
||||||
- '**/*.md'
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Verify .NET installation
|
|
||||||
run: dotnet --version
|
|
||||||
|
|
||||||
- name: Restore dependencies
|
|
||||||
run: dotnet restore Jellyfin.Plugin.Jellypod.sln
|
|
||||||
|
|
||||||
- name: Build solution
|
|
||||||
run: dotnet build Jellyfin.Plugin.Jellypod.sln --configuration Debug --no-restore --no-self-contained
|
|
||||||
|
|
||||||
- name: Run tests
|
|
||||||
run: dotnet test Jellyfin.Plugin.Jellypod.sln --no-build --configuration Debug --verbosity normal --logger "trx;LogFileName=test-results.trx"
|
|
||||||
|
|
||||||
- name: Upload test results
|
|
||||||
if: always()
|
|
||||||
uses: actions/upload-artifact@v3
|
|
||||||
with:
|
|
||||||
name: test-results
|
|
||||||
path: '**/test-results.trx'
|
|
||||||
retention-days: 7
|
|
||||||
@@ -3,3 +3,4 @@ obj/
|
|||||||
.vs/
|
.vs/
|
||||||
.idea/
|
.idea/
|
||||||
artifacts
|
artifacts
|
||||||
|
build.jf12.yaml
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
FROM mcr.microsoft.com/dotnet/sdk:10.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/*
|
||||||
|
|
||||||
|
# The .NET 10 SDK builds the net10.0 (Jellyfin 12.0) target; the .NET 8 SDK is
|
||||||
|
# needed alongside it for the net8.0 (Jellyfin 10.9) target.
|
||||||
|
RUN curl -sSL https://dot.net/v1/dotnet-install.sh -o /tmp/dotnet-install.sh \
|
||||||
|
&& bash /tmp/dotnet-install.sh --channel 8.0 --install-dir /usr/share/dotnet --no-path \
|
||||||
|
&& rm /tmp/dotnet-install.sh
|
||||||
|
|
||||||
|
# Install JPRM
|
||||||
|
RUN pip install --break-system-packages jprm
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFrameworks>net8.0;net10.0</TargetFrameworks>
|
||||||
<RootNamespace>Jellyfin.Plugin.Jellypod</RootNamespace>
|
<RootNamespace>Jellyfin.Plugin.Jellypod</RootNamespace>
|
||||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
@@ -11,7 +11,8 @@
|
|||||||
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
|
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<!-- Jellyfin 10.9 (net8.0) -->
|
||||||
|
<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">
|
||||||
<PackageReference Include="Jellyfin.Controller" Version="10.9.11">
|
<PackageReference Include="Jellyfin.Controller" Version="10.9.11">
|
||||||
<ExcludeAssets>runtime</ExcludeAssets>
|
<ExcludeAssets>runtime</ExcludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
@@ -22,6 +23,18 @@
|
|||||||
<PackageReference Include="System.ServiceModel.Syndication" Version="8.0.0" />
|
<PackageReference Include="System.ServiceModel.Syndication" Version="8.0.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!-- Jellyfin 12.0 (net10.0) -->
|
||||||
|
<ItemGroup Condition="'$(TargetFramework)' == 'net10.0'">
|
||||||
|
<PackageReference Include="Jellyfin.Controller" Version="12.0.0">
|
||||||
|
<ExcludeAssets>runtime</ExcludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="Jellyfin.Model" Version="12.0.0">
|
||||||
|
<ExcludeAssets>runtime</ExcludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.11" />
|
||||||
|
<PackageReference Include="System.ServiceModel.Syndication" Version="10.0.11" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" />
|
<PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" />
|
||||||
<PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.556" PrivateAssets="All" />
|
<PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.556" PrivateAssets="All" />
|
||||||
|
|||||||
@@ -150,7 +150,11 @@ public class PodcastUpdateTask : IScheduledTask
|
|||||||
{
|
{
|
||||||
new TaskTriggerInfo
|
new TaskTriggerInfo
|
||||||
{
|
{
|
||||||
|
#if NET10_0_OR_GREATER
|
||||||
|
Type = TaskTriggerInfoType.IntervalTrigger,
|
||||||
|
#else
|
||||||
Type = TaskTriggerInfo.TriggerInterval,
|
Type = TaskTriggerInfo.TriggerInterval,
|
||||||
|
#endif
|
||||||
IntervalTicks = TimeSpan.FromHours(intervalHours).Ticks
|
IntervalTicks = TimeSpan.FromHours(intervalHours).Ticks
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -10,6 +10,19 @@ Add this repository URL in Jellyfin (Dashboard → Plugins → Repositories):
|
|||||||
https://gitea.tourolle.paris/dtourolle/jellypod/raw/branch/master/manifest.json
|
https://gitea.tourolle.paris/dtourolle/jellypod/raw/branch/master/manifest.json
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Supported Jellyfin Versions
|
||||||
|
|
||||||
|
Jellypod is released as two builds from the same source. The repository manifest
|
||||||
|
lists both, and Jellyfin picks the right one for your server automatically:
|
||||||
|
|
||||||
|
| Jellyfin server | Build | Target framework |
|
||||||
|
| --------------- | -------------- | ---------------- |
|
||||||
|
| 10.9.x | `*_jf10.9.zip` | .NET 8 |
|
||||||
|
| 12.0 and newer | `*_jf12.zip` | .NET 10 |
|
||||||
|
|
||||||
|
If you install manually, download the build matching your server — a build
|
||||||
|
installed on the wrong server will fail to load.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Browse and subscribe to podcasts
|
- Browse and subscribe to podcasts
|
||||||
@@ -92,7 +105,9 @@ ffmpeg -i "$INPUT" -c:a aac -b:a 128k "$OUTPUT"
|
|||||||
|
|
||||||
### Manual Installation
|
### Manual Installation
|
||||||
|
|
||||||
1. Download the latest release from [Releases](https://gitea.tourolle.paris/dtourolle/jellypod/releases)
|
1. Download the latest release from [Releases](https://gitea.tourolle.paris/dtourolle/jellypod/releases),
|
||||||
|
picking `jellypod_<version>_jf10.9.zip` for Jellyfin 10.9.x or
|
||||||
|
`jellypod_<version>_jf12.zip` for Jellyfin 12.0 and newer
|
||||||
2. Extract the contents to your Jellyfin plugins directory:
|
2. Extract the contents to your Jellyfin plugins directory:
|
||||||
- **Linux**: `~/.local/share/jellyfin/plugins/Jellypod/`
|
- **Linux**: `~/.local/share/jellyfin/plugins/Jellypod/`
|
||||||
- **Windows**: `%LOCALAPPDATA%\jellyfin\plugins\Jellypod\`
|
- **Windows**: `%LOCALAPPDATA%\jellyfin\plugins\Jellypod\`
|
||||||
@@ -103,15 +118,32 @@ ffmpeg -i "$INPUT" -c:a aac -b:a 128k "$OUTPUT"
|
|||||||
|
|
||||||
#### Requirements
|
#### Requirements
|
||||||
|
|
||||||
- [.NET SDK 8.0](https://dotnet.microsoft.com/en-us/download/dotnet/8.0)
|
- [.NET SDK 8.0](https://dotnet.microsoft.com/en-us/download/dotnet/8.0) for the Jellyfin 10.9 build
|
||||||
|
- [.NET SDK 10.0](https://dotnet.microsoft.com/en-us/download/dotnet/10.0) for the Jellyfin 12.0 build
|
||||||
|
|
||||||
#### Build
|
#### Build
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
dotnet build
|
dotnet build # both targets
|
||||||
|
dotnet build -f net8.0 # Jellyfin 10.9 only
|
||||||
|
dotnet build -f net10.0 # Jellyfin 12.0 only
|
||||||
```
|
```
|
||||||
|
|
||||||
The plugin DLL will be in `Jellyfin.Plugin.Jellypod/bin/Debug/net8.0/`.
|
The plugin DLL will be in `Jellyfin.Plugin.Jellypod/bin/Debug/net8.0/` or
|
||||||
|
`Jellyfin.Plugin.Jellypod/bin/Debug/net10.0/`.
|
||||||
|
|
||||||
|
#### Packaging
|
||||||
|
|
||||||
|
`build.yaml` is the [jprm](https://github.com/oddstr13/jellyfin-plugin-repository-manager)
|
||||||
|
config for the Jellyfin 10.9 build. The Jellyfin 12.0 config is derived from it
|
||||||
|
at release time, so the two cannot drift apart:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sed -e 's/^targetAbi:.*/targetAbi: "12.0.0.0"/' \
|
||||||
|
-e 's/^framework:.*/framework: "net10.0"/' \
|
||||||
|
-e 's/^dotnet_framework:.*/dotnet_framework: "net10.0"/' \
|
||||||
|
build.yaml > build.jf12.yaml
|
||||||
|
```
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
|
|||||||
@@ -100,6 +100,8 @@
|
|||||||
<Rule Id="CA1308" Action="None" />
|
<Rule Id="CA1308" Action="None" />
|
||||||
<!-- disable warning CA1848: Use the LoggerMessage delegates -->
|
<!-- disable warning CA1848: Use the LoggerMessage delegates -->
|
||||||
<Rule Id="CA1848" Action="None" />
|
<Rule Id="CA1848" Action="None" />
|
||||||
|
<!-- disable warning CA1873: Avoid potentially expensive logging -->
|
||||||
|
<Rule Id="CA1873" Action="None" />
|
||||||
<!-- disable warning CA2101: Specify marshaling for P/Invoke string arguments -->
|
<!-- disable warning CA2101: Specify marshaling for P/Invoke string arguments -->
|
||||||
<Rule Id="CA2101" Action="None" />
|
<Rule Id="CA2101" Action="None" />
|
||||||
<!-- disable warning CA2234: Pass System.Uri objects instead of strings -->
|
<!-- disable warning CA2234: Pass System.Uri objects instead of strings -->
|
||||||
|
|||||||
@@ -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.10",
|
||||||
|
"changelog": "Release 1.0.10",
|
||||||
|
"targetAbi": "10.9.0.0",
|
||||||
|
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellypod/releases/download/v1.0.10/jellypod_1.0.10.0.zip",
|
||||||
|
"checksum": "37509ee316e5731b7dcfef394099ac53",
|
||||||
|
"timestamp": "2026-01-11T09:06:37Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "1.0.9",
|
||||||
|
"changelog": "Release 1.0.9",
|
||||||
|
"targetAbi": "10.9.0.0",
|
||||||
|
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellypod/releases/download/v1.0.9/jellypod_1.0.9.0.zip",
|
||||||
|
"checksum": "f1f4ade3fc5483118d1171e636b67007",
|
||||||
|
"timestamp": "2026-01-11T09:02:21Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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",
|
"version": "1.0.2",
|
||||||
"changelog": "Release 1.0.2",
|
"changelog": "Release 1.0.2",
|
||||||
|
|||||||
Reference in New Issue
Block a user