Compare commits

...
7 Commits
Author SHA1 Message Date
dtourolleandClaude Opus 5.5 95ce4edf9b Show times in a configurable time zone
Nightly Build / nightly-build (push) Successful in 1m32s
🧪 Test Plugin / test (push) Successful in 48s
🚀 Release Plugin / build-and-release (push) Successful in 1m18s
🏗️ Build Plugin / build (push) Successful in 12m28s
SRG sends times with an offset (e.g. 13:55+02:00). They are stored as
DateTime, which deserialisation converts to server-local time, and two
places then wrote that straight into text: the "[dd.MM HH:mm]" prefix
on upcoming livestream names and the timestamp in recording file names
(DateTime.Now). On a server running in UTC both were two hours behind
Swiss time, e.g. a qualifying starting at 13:55 was listed as 11:55.

A new "Display Time Zone" setting (IANA id, e.g. Europe/Zurich) now
decides the zone for both; empty keeps the server's zone, and an
unknown id falls back to it rather than breaking the listing.
Utilities/DisplayTime does the conversion.

Also fixes PremiereDate on upcoming livestreams, which was set to that
server-local value although Jellyfin expects UTC. It was only correct
on UTC servers; it is now converted, like the other channel items.

Verified on simulated UTC and Europe/Paris servers: the same instant
gives 11:55 / 13:55 / 07:55 for server zone / Europe/Zurich /
America/New_York, and PremiereDate is 11:55 UTC on both.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 19:29:05 -04:00
Gitea Actions 536fef85a8 Nightly: 1.0.20260924.216 (3c321b4) 2026-09-24 03:01:30 +00:00
dtourolleandClaude Opus 5.5 3c321b4036 ci: run on any runner, amd64 or arm64
🧪 Test Plugin / test (push) Successful in 13m13s
Nightly Build / nightly-build (push) Successful in 1m35s
🏗️ Build Plugin / build (push) Successful in 12m26s
The builder image is now multi-arch (linux/amd64 + linux/arm64, pushed
as srfplay-builder:latest after building all three plugin zips in each
variant), so jobs no longer need an amd64 host. runs-on moves from
linux/amd64 to ubuntu-latest, which draco-x86, freebox and oracle-a1
all carry, so a job no longer waits behind long jobs on draco while
the ARM runners sit idle.

.gitea/runner/cloud-init.yaml sets up such a runner on an Oracle Cloud
Always Free Ampere VM (Ubuntu 24.04): Docker, act_runner registered
instance-wide with the instance's label set, and a weekly image prune.
The registration token and the optional console password hash are
placeholders here and must be filled in locally before use.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 03:58:55 +02:00
Gitea Actions 6822ea7202 Update manifest.json for version 1.2.1 2026-09-24 01:16:46 +00:00
Gitea Actions 8a5223b960 Nightly: 1.0.20260924.212 (db9461c) 2026-09-24 01:14:34 +00:00
dtourolleandClaude Opus 5.5 db9461cf59 ci: read the zip list with cut, not a bash-only IFS
🏗️ Build Plugin / build (push) Successful in 1m41s
Nightly Build / nightly-build (push) Successful in 1m18s
🧪 Test Plugin / test (push) Successful in 46s
🚀 Release Plugin / build-and-release (push) Successful in 1m26s
The upload loop used IFS=$'\t', but the job steps run under /bin/sh,
which is dash in the builder image. dash does not expand $'\t', so IFS
became the characters $, \ and t and every path was split on "t":
"artifacts/srfplay_1.2.0.9.zip" reached curl as "s/srfplay_...", which
failed with exit 26 after the release had already been created. This
broke both the v1.2.0 release and the nightly.

cut -f3 is plain POSIX. Checked by running the upload and manifest
steps under the builder image's dash with curl stubbed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 02:15:09 +02:00
dtourolleandClaude Opus 5.5 b0c067ed41 Add a Docker test harness for any Jellyfin version
🏗️ Build Plugin / build (push) Successful in 1m15s
Nightly Build / nightly-build (push) Failing after 1m14s
🧪 Test Plugin / test (push) Successful in 40s
test-image.sh publishes the plugin on the host, bakes it into a stock
jellyfin/jellyfin image via Dockerfile.test, and runs it on :8096, with
optional egress through a LAN gateway (DOCKER_GATEWAY, e.g. the Swiss
VPN box) for geo-blocked streams.

JELLYFIN_TAG (default 12) selects the image and the matching plugin
build: 10.9/10.10 -> net8.0, 10.11 -> net9.0, 12 -> net10.0, with the
generated meta.json targetAbi to match. Images are tagged per version
(srfplay-test:<tag>) so several can coexist.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 01:44:34 +02:00
16 changed files with 333 additions and 13 deletions
+58
View File
@@ -0,0 +1,58 @@
#cloud-config
# Gitea Actions runner on an Oracle Cloud Always Free Ampere (ARM) VM, shared by
# every repo on the instance.
#
# Paste this into "Advanced options -> Management -> Initialization script" when
# creating the instance (image: Canonical Ubuntu 24.04, shape: VM.Standard.A1.Flex).
# Before pasting, replace REGISTRATION_TOKEN below with an instance-wide token:
# Site Administration -> Actions -> Runners -> Create new runner
# (a token from repo Settings would limit the runner to that one repo).
#
# The runner only makes outbound connections (to Gitea and container registries),
# so the VM needs no inbound ports beyond SSH.
#
# Labels follow the instance convention (same set as the freebox ARM runner).
# ubuntu-latest / ubuntu-22.04 are shared with the amd64 runner, so a job asking
# for them may land on either architecture; ask for linux/arm64 or linux/amd64
# when a job needs a specific one. SRF Play jobs run in the multi-arch
# srfplay-builder image, so they work on both.
# Optional: a password for the "ubuntu" user, so you can log in through the Oracle
# serial console ("Launch Cloud Shell connection") without an SSH key. Generate the
# hash locally with: openssl passwd -6
# then uncomment and paste it in. SSH still only accepts keys.
#chpasswd:
# expire: false
# users:
# - name: ubuntu
# password: "$6$...paste the hash here..."
package_update: true
packages:
- docker.io
- docker-compose-v2
write_files:
- path: /opt/act_runner/docker-compose.yml
permissions: "0600"
content: |
services:
runner:
image: gitea/act_runner:latest
restart: unless-stopped
environment:
GITEA_INSTANCE_URL: https://gitea.tourolle.paris
GITEA_RUNNER_REGISTRATION_TOKEN: REGISTRATION_TOKEN
GITEA_RUNNER_NAME: oracle-a1
# Comma-separated with no spaces: label:docker://<default job image>
GITEA_RUNNER_LABELS: "ubuntu-latest:docker://gitea/runner-images:ubuntu-latest,ubuntu-22.04:docker://gitea/runner-images:ubuntu-22.04,linux-arm64:docker://gitea/runner-images:ubuntu-latest,self-hosted:docker://gitea/runner-images:ubuntu-latest,linux/arm64:docker://gitea/runner-images:ubuntu-latest"
volumes:
# Registration is stored here, so the token is only used once.
- ./data:/data
- /var/run/docker.sock:/var/run/docker.sock
runcmd:
- systemctl enable --now docker
- cd /opt/act_runner && docker compose up -d
# Weekly clean-up so pulled images and build caches do not fill the boot volume.
- echo '0 4 * * 0 root docker system prune -af --filter "until=168h" >/dev/null 2>&1' > /etc/cron.d/docker-prune
+1 -1
View File
@@ -15,7 +15,7 @@ on:
jobs: jobs:
build: build:
runs-on: linux/amd64 runs-on: ubuntu-latest
container: container:
image: gitea.tourolle.paris/dtourolle/srfplay-builder:latest image: gitea.tourolle.paris/dtourolle/srfplay-builder:latest
+3 -3
View File
@@ -11,7 +11,7 @@ on:
jobs: jobs:
nightly-build: nightly-build:
runs-on: linux/amd64 runs-on: ubuntu-latest
container: container:
image: gitea.tourolle.paris/dtourolle/srfplay-builder:latest image: gitea.tourolle.paris/dtourolle/srfplay-builder:latest
@@ -111,14 +111,14 @@ jobs:
fi fi
# Upload one plugin zip per Jellyfin generation # Upload one plugin zip per Jellyfin generation
while IFS=$'\t' read -r _ _ ZIP _; do cut -f3 artifacts/builds.tsv | while read -r ZIP; do
echo "Uploading ${ZIP}..." echo "Uploading ${ZIP}..."
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: application/zip" \
--data-binary "@${ZIP}" \ --data-binary "@${ZIP}" \
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}/assets?name=$(basename "${ZIP}")" "${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}/assets?name=$(basename "${ZIP}")"
done < artifacts/builds.tsv done
# Upload build.yaml # Upload build.yaml
echo "Uploading build.yaml..." echo "Uploading build.yaml..."
+3 -3
View File
@@ -13,7 +13,7 @@ on:
jobs: jobs:
build-and-release: build-and-release:
runs-on: linux/amd64 runs-on: ubuntu-latest
container: container:
image: gitea.tourolle.paris/dtourolle/srfplay-builder:latest image: gitea.tourolle.paris/dtourolle/srfplay-builder:latest
@@ -91,14 +91,14 @@ jobs:
fi fi
# Upload one plugin zip per Jellyfin generation # Upload one plugin zip per Jellyfin generation
while IFS=$'\t' read -r _ _ ZIP _; do cut -f3 artifacts/builds.tsv | while read -r ZIP; do
echo "Uploading ${ZIP}..." echo "Uploading ${ZIP}..."
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: application/zip" \
--data-binary "@${ZIP}" \ --data-binary "@${ZIP}" \
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}/assets?name=$(basename "${ZIP}")" "${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}/assets?name=$(basename "${ZIP}")"
done < artifacts/builds.tsv done
# Upload build.yaml # Upload build.yaml
echo "Uploading build.yaml..." echo "Uploading build.yaml..."
+1 -1
View File
@@ -17,7 +17,7 @@ on:
jobs: jobs:
test: test:
runs-on: linux/amd64 runs-on: ubuntu-latest
container: container:
image: gitea.tourolle.paris/dtourolle/srfplay-builder:latest image: gitea.tourolle.paris/dtourolle/srfplay-builder:latest
+1
View File
@@ -4,3 +4,4 @@ obj/
.idea/ .idea/
artifacts artifacts
*.log *.log
.test-plugin/
+5 -2
View File
@@ -1,7 +1,10 @@
# SRFPlay Builder Image # SRFPlay Builder Image
# Pre-built image with .NET SDK and JPRM for building Jellyfin plugins # Pre-built image with .NET SDK and JPRM for building Jellyfin plugins
# Build: docker build -f Dockerfile.builder -t gitea.tourolle.paris/dtourolle/srfplay-builder:latest . # Multi-arch (amd64 + arm64) so runners on ARM hosts such as Oracle Cloud's free
# Push: docker push gitea.tourolle.paris/dtourolle/srfplay-builder:latest # Ampere VMs can use it too; the plugin DLLs it produces are identical either way.
# Build + push (needs a docker-container buildx builder and arm64 binfmt/QEMU):
# docker buildx build -f Dockerfile.builder --platform linux/amd64,linux/arm64 \
# -t gitea.tourolle.paris/dtourolle/srfplay-builder:latest --push .
FROM mcr.microsoft.com/dotnet/sdk:10.0 FROM mcr.microsoft.com/dotnet/sdk:10.0
+23
View File
@@ -0,0 +1,23 @@
# Test image: stock Jellyfin with a prebuilt SRF Play plugin baked in.
#
# This does NOT build the plugin — build it first on the host (fast, warm
# NuGet cache), then this image is just one small COPY on top of stock
# Jellyfin, so it builds in seconds.
#
# ./test-image.sh # builds plugin + image, runs it
#
# or manually:
# dotnet publish Jellyfin.Plugin.SRFPlay/Jellyfin.Plugin.SRFPlay.csproj \
# -c Release -f net10.0 --no-self-contained -o ./.test-plugin/SRFPlay
# docker build -f Dockerfile.test --build-arg JELLYFIN_TAG=12 -t srfplay-test .
# docker run --rm -p 8096:8096 srfplay-test
#
# JELLYFIN_TAG must match the framework the plugin was published for;
# test-image.sh picks both from one setting.
ARG JELLYFIN_TAG=12
FROM jellyfin/jellyfin:${JELLYFIN_TAG}
# .test-plugin/SRFPlay is produced by test-image.sh and contains the plugin
# DLL plus a generated meta.json. Jellyfin loads from <config>/plugins.
COPY .test-plugin/SRFPlay /config/plugins/SRFPlay
@@ -303,8 +303,8 @@ public abstract class SrgChannelBase : IChannel, IHasCacheKey
if (matchingEvent?.ValidFrom != null) if (matchingEvent?.ValidFrom != null)
{ {
var eventTime = matchingEvent.ValidFrom.Value; var eventTime = matchingEvent.ValidFrom.Value;
item.Name = $"[{eventTime:dd.MM HH:mm}] {matchingEvent.Title}"; item.Name = $"[{DisplayTime.From(eventTime):dd.MM HH:mm}] {matchingEvent.Title}";
item.PremiereDate = eventTime; item.PremiereDate = eventTime.ToUniversalTime();
} }
} }
} }
@@ -169,6 +169,14 @@ public class PluginConfiguration : BasePluginConfiguration
/// </summary> /// </summary>
public string RecordingOutputPath { get; set; } = string.Empty; public string RecordingOutputPath { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the time zone used for times the plugin writes as text: the start time in
/// upcoming livestream names and the timestamp in recording file names. An IANA id such as
/// "Europe/Zurich"; empty uses the server's time zone. Dates Jellyfin shows itself (e.g.
/// PremiereDate) are stored as UTC and follow each viewer's own time zone regardless.
/// </summary>
public string DisplayTimeZone { get; set; } = string.Empty;
/// <summary> /// <summary>
/// Gets or sets how many segments back from the live edge livestream playback should start. /// Gets or sets how many segments back from the live edge livestream playback should start.
/// Injected as <c>#EXT-X-START:TIME-OFFSET=-(N * targetDuration)</c> into the live media /// Injected as <c>#EXT-X-START:TIME-OFFSET=-(N * targetDuration)</c> into the live media
@@ -82,6 +82,11 @@
<input id="RecordingOutputPath" name="RecordingOutputPath" type="text" is="emby-input" placeholder="e.g., /media/recordings/srf" /> <input id="RecordingOutputPath" name="RecordingOutputPath" type="text" is="emby-input" placeholder="e.g., /media/recordings/srf" />
<div class="fieldDescription">Directory where sport livestream recordings will be saved (requires ffmpeg)</div> <div class="fieldDescription">Directory where sport livestream recordings will be saved (requires ffmpeg)</div>
</div> </div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="DisplayTimeZone">Display Time Zone</label>
<input id="DisplayTimeZone" name="DisplayTimeZone" type="text" is="emby-input" placeholder="e.g., Europe/Zurich" />
<div class="fieldDescription">Time zone for start times in upcoming livestream names and for recording file names. Leave empty to use the server's time zone.</div>
</div>
<div class="inputContainer"> <div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="LiveStartSegmentsBack">Live Start Offset (segments)</label> <label class="inputLabel inputLabelUnfocused" for="LiveStartSegmentsBack">Live Start Offset (segments)</label>
<input id="LiveStartSegmentsBack" name="LiveStartSegmentsBack" type="number" is="emby-input" min="0" max="20" /> <input id="LiveStartSegmentsBack" name="LiveStartSegmentsBack" type="number" is="emby-input" min="0" max="20" />
@@ -200,6 +205,7 @@
document.querySelector('#ProxyPassword').value = config.ProxyPassword || ''; document.querySelector('#ProxyPassword').value = config.ProxyPassword || '';
document.querySelector('#PublicServerUrl').value = config.PublicServerUrl || ''; document.querySelector('#PublicServerUrl').value = config.PublicServerUrl || '';
document.querySelector('#RecordingOutputPath').value = config.RecordingOutputPath || ''; document.querySelector('#RecordingOutputPath').value = config.RecordingOutputPath || '';
document.querySelector('#DisplayTimeZone').value = config.DisplayTimeZone || '';
document.querySelector('#LiveStartSegmentsBack').value = config.LiveStartSegmentsBack != null ? config.LiveStartSegmentsBack : 3; document.querySelector('#LiveStartSegmentsBack').value = config.LiveStartSegmentsBack != null ? config.LiveStartSegmentsBack : 3;
document.querySelector('#CleanUpResumePoints').checked = config.CleanUpResumePoints !== false; document.querySelector('#CleanUpResumePoints').checked = config.CleanUpResumePoints !== false;
document.querySelector('#ClearLiveStreamResumePoints').checked = config.ClearLiveStreamResumePoints !== false; document.querySelector('#ClearLiveStreamResumePoints').checked = config.ClearLiveStreamResumePoints !== false;
@@ -231,6 +237,7 @@
config.ProxyPassword = document.querySelector('#ProxyPassword').value; config.ProxyPassword = document.querySelector('#ProxyPassword').value;
config.PublicServerUrl = document.querySelector('#PublicServerUrl').value; config.PublicServerUrl = document.querySelector('#PublicServerUrl').value;
config.RecordingOutputPath = document.querySelector('#RecordingOutputPath').value; config.RecordingOutputPath = document.querySelector('#RecordingOutputPath').value;
config.DisplayTimeZone = document.querySelector('#DisplayTimeZone').value.trim();
config.LiveStartSegmentsBack = parseInt(document.querySelector('#LiveStartSegmentsBack').value) || 0; config.LiveStartSegmentsBack = parseInt(document.querySelector('#LiveStartSegmentsBack').value) || 0;
config.CleanUpResumePoints = document.querySelector('#CleanUpResumePoints').checked; config.CleanUpResumePoints = document.querySelector('#CleanUpResumePoints').checked;
config.ClearLiveStreamResumePoints = document.querySelector('#ClearLiveStreamResumePoints').checked; config.ClearLiveStreamResumePoints = document.querySelector('#ClearLiveStreamResumePoints').checked;
@@ -451,7 +451,7 @@ public class RecordingService : IRecordingService, IDisposable
// Build output file path // Build output file path
var safeTitle = SanitizeFileName(entry.Title); var safeTitle = SanitizeFileName(entry.Title);
var timestamp = DateTime.Now.ToString("yyyy-MM-dd_HHmm", CultureInfo.InvariantCulture); var timestamp = DisplayTime.Now().ToString("yyyy-MM-dd_HHmm", CultureInfo.InvariantCulture);
var outputPath = Path.Combine(GetRecordingOutputPath(), $"{safeTitle}_{timestamp}.mkv"); var outputPath = Path.Combine(GetRecordingOutputPath(), $"{safeTitle}_{timestamp}.mkv");
entry.OutputPath = outputPath; entry.OutputPath = outputPath;
@@ -0,0 +1,42 @@
using System;
namespace Jellyfin.Plugin.SRFPlay.Utilities;
/// <summary>
/// Converts times into the time zone the plugin writes into text (item names, file names).
/// SRG sends times with an offset, and DateTime deserialisation turns them into server-local
/// values, so without this the text follows whatever zone the server happens to run in.
/// </summary>
public static class DisplayTime
{
/// <summary>
/// Converts a time to the configured display time zone (the server's if none is set).
/// </summary>
/// <param name="value">The time. Unspecified values are treated as server-local, like deserialised ones.</param>
/// <returns>The same instant expressed in the display time zone.</returns>
public static DateTime From(DateTime value)
{
return TimeZoneInfo.ConvertTimeFromUtc(value.ToUniversalTime(), GetZone());
}
/// <summary>
/// Gets the current time in the display time zone.
/// </summary>
/// <returns>The current time in the display time zone.</returns>
public static DateTime Now()
{
return TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, GetZone());
}
private static TimeZoneInfo GetZone()
{
var id = Plugin.Instance?.Configuration.DisplayTimeZone;
if (string.IsNullOrWhiteSpace(id))
{
return TimeZoneInfo.Local;
}
// An unknown id falls back to the server's zone rather than breaking listings.
return TimeZoneInfo.TryFindSystemTimeZoneById(id.Trim(), out var zone) ? zone : TimeZoneInfo.Local;
}
}
+48
View File
@@ -8,6 +8,54 @@
"category": "Live TV", "category": "Live TV",
"imageUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/raw/branch/master/assests/main%20logo.png", "imageUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/raw/branch/master/assests/main%20logo.png",
"versions": [ "versions": [
{
"version": "1.0.20260924.21612",
"changelog": "Nightly build 1.0.20260924.21612 (3c321b4)",
"targetAbi": "12.0.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/latest/srfplay_1.0.20260924.21612.zip",
"checksum": "146824b350e6f10c0861dea198e5dd51",
"timestamp": "2026-09-24T03:01:30Z"
},
{
"version": "1.0.20260924.21611",
"changelog": "Nightly build 1.0.20260924.21611 (3c321b4)",
"targetAbi": "10.11.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/latest/srfplay_1.0.20260924.21611.zip",
"checksum": "e63045bda702234285ee6d2f62034397",
"timestamp": "2026-09-24T03:01:30Z"
},
{
"version": "1.0.20260924.21609",
"changelog": "Nightly build 1.0.20260924.21609 (3c321b4)",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/latest/srfplay_1.0.20260924.21609.zip",
"checksum": "57592a884c09600d11f82702ac5d1805",
"timestamp": "2026-09-24T03:01:30Z"
},
{
"version": "1.0.20260924.21212",
"changelog": "Nightly build 1.0.20260924.21212 (db9461c)",
"targetAbi": "12.0.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/latest/srfplay_1.0.20260924.21212.zip",
"checksum": "b497b4f8a9c5822909b6df0ed37b8b3d",
"timestamp": "2026-09-24T01:14:34Z"
},
{
"version": "1.0.20260924.21211",
"changelog": "Nightly build 1.0.20260924.21211 (db9461c)",
"targetAbi": "10.11.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/latest/srfplay_1.0.20260924.21211.zip",
"checksum": "e3b98d2db0253dc42c3cc87645bffa0e",
"timestamp": "2026-09-24T01:14:34Z"
},
{
"version": "1.0.20260924.21209",
"changelog": "Nightly build 1.0.20260924.21209 (db9461c)",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/latest/srfplay_1.0.20260924.21209.zip",
"checksum": "1f4b3fe079d32a08968d7dfb99dc813f",
"timestamp": "2026-09-24T01:14:34Z"
},
{ {
"version": "1.0.20260908.201", "version": "1.0.20260908.201",
"changelog": "Nightly build 1.0.20260908.201 (d0d5d06)", "changelog": "Nightly build 1.0.20260908.201 (d0d5d06)",
+24
View File
@@ -8,6 +8,30 @@
"category": "Live TV", "category": "Live TV",
"imageUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/raw/branch/master/assests/main%20logo.png", "imageUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/raw/branch/master/assests/main%20logo.png",
"versions": [ "versions": [
{
"version": "1.2.1.12",
"changelog": "Release 1.2.1",
"targetAbi": "12.0.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.2.1/srfplay_1.2.1.12.zip",
"checksum": "e7fe29977ed387f5f324b58aa40d8ebb",
"timestamp": "2026-09-24T01:16:46Z"
},
{
"version": "1.2.1.11",
"changelog": "Release 1.2.1",
"targetAbi": "10.11.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.2.1/srfplay_1.2.1.11.zip",
"checksum": "f69293b34c2e0e5fc5f2f2117ffb3ded",
"timestamp": "2026-09-24T01:16:46Z"
},
{
"version": "1.2.1.9",
"changelog": "Release 1.2.1",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.2.1/srfplay_1.2.1.9.zip",
"checksum": "f18230d45e510cf60dce3badcd3f29a1",
"timestamp": "2026-09-24T01:16:46Z"
},
{ {
"version": "1.1.2", "version": "1.1.2",
"changelog": "Release 1.1.2", "changelog": "Release 1.1.2",
Executable
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env bash
# Build the SRF Play plugin from the local checkout, bake it into a stock
# Jellyfin image, and run it on http://localhost:8096.
#
# ./test-image.sh build + run
# ./test-image.sh build build image only (no run)
#
# JELLYFIN_TAG picks the Jellyfin image (default 12) and, with it, the matching
# plugin build (see the TargetFrameworks comment in the plugin csproj):
#
# JELLYFIN_TAG=10.11.0 ./test-image.sh
set -euo pipefail
cd "$(dirname "$0")"
JELLYFIN_TAG="${JELLYFIN_TAG:-12}"
PLUGIN_DIR=".test-plugin/SRFPlay"
IMAGE="srfplay-test:${JELLYFIN_TAG}"
case "$JELLYFIN_TAG" in
10.9*|10.10*) FRAMEWORK="net8.0"; ABI="10.9.0.0" ;;
10.11*) FRAMEWORK="net9.0"; ABI="10.11.0.0" ;;
*) FRAMEWORK="net10.0"; ABI="12.0.0.0" ;;
esac
echo ">> Publishing plugin for Jellyfin ${JELLYFIN_TAG} (${FRAMEWORK}, host build, warm cache)..."
rm -rf .test-plugin
dotnet publish Jellyfin.Plugin.SRFPlay/Jellyfin.Plugin.SRFPlay.csproj \
--configuration Release --framework "$FRAMEWORK" --no-self-contained -o /tmp/srfplay-publish >/dev/null
mkdir -p "$PLUGIN_DIR"
cp /tmp/srfplay-publish/Jellyfin.Plugin.SRFPlay.dll "$PLUGIN_DIR/"
# Generate the meta.json Jellyfin needs to load the plugin, from build.yaml.
# targetAbi follows the framework chosen above rather than build.yaml.
GUID=$(grep -E '^guid:' build.yaml | sed -E 's/^guid:[[:space:]]*"?([^"]+)"?.*/\1/')
VERSION=$(grep -E '^version:' build.yaml | sed -E 's/^version:[[:space:]]*"?([^"]+)"?.*/\1/')
cat > "$PLUGIN_DIR/meta.json" <<EOF
{
"guid": "$GUID",
"name": "SRF Play",
"version": "$VERSION",
"targetAbi": "$ABI",
"owner": "dtourolle",
"overview": "SRF Play plugin for Jellyfin (test build)",
"description": "Local test build of the SRF Play plugin.",
"category": "Live TV",
"status": "Active",
"autoUpdate": false
}
EOF
echo ">> Building image $IMAGE (stock Jellyfin + plugin COPY)..."
docker build -f Dockerfile.test --build-arg JELLYFIN_TAG="$JELLYFIN_TAG" -t "$IMAGE" .
if [ "${1:-run}" = "build" ]; then
echo ">> Built $IMAGE. Run with: docker run --rm -p 8096:8096 $IMAGE"
exit 0
fi
# --- Optional: route the container's egress via a LAN gateway ----------------
# Set DOCKER_GATEWAY to a LAN gateway IP (e.g. the Swiss VPN box 192.168.1.52)
# to send this container's outbound traffic through it. Needs sudo once to add
# host policy-route + masquerade; both are removed automatically on exit.
#
# DOCKER_GATEWAY=192.168.1.52 ./test-image.sh
#
# When unset, the container uses Docker's normal default route (your ISP).
TEST_NET="srfplay-testnet"
TEST_SUBNET="172.31.250.0/24"
TEST_TABLE="250" # dedicated rt table for this subnet
# The uplink is the interface that actually reaches the gateway (the LAN NIC,
# e.g. wlan0) — NOT the host default route, which a VPN killswitch may point
# at the tunnel. Derive it from the route to DOCKER_GATEWAY when set.
if [ -n "${DOCKER_GATEWAY:-}" ]; then
UPLINK_IF="$(ip route get "$DOCKER_GATEWAY" 2>/dev/null | grep -oE 'dev [^ ]+' | awk '{print $2}')"
fi
cleanup() {
echo ">> Cleaning up..."
docker rm -f srfplay-test-run >/dev/null 2>&1 || true
if [ -n "${DOCKER_GATEWAY:-}" ]; then
sudo ip rule del from "$TEST_SUBNET" table "$TEST_TABLE" 2>/dev/null || true
sudo ip route flush table "$TEST_TABLE" 2>/dev/null || true
sudo iptables -t nat -D POSTROUTING -s "$TEST_SUBNET" -o "$UPLINK_IF" -j MASQUERADE 2>/dev/null || true
fi
docker network rm "$TEST_NET" >/dev/null 2>&1 || true
}
trap cleanup EXIT INT TERM
docker network rm "$TEST_NET" >/dev/null 2>&1 || true
docker network create --subnet "$TEST_SUBNET" "$TEST_NET" >/dev/null
if [ -n "${DOCKER_GATEWAY:-}" ]; then
echo ">> Routing container egress via gateway $DOCKER_GATEWAY (uplink $UPLINK_IF)..."
echo " (sudo needed once to install host routes; removed on exit)"
sudo ip route replace default via "$DOCKER_GATEWAY" dev "$UPLINK_IF" table "$TEST_TABLE"
sudo ip rule add from "$TEST_SUBNET" table "$TEST_TABLE" 2>/dev/null || true
sudo iptables -t nat -C POSTROUTING -s "$TEST_SUBNET" -o "$UPLINK_IF" -j MASQUERADE 2>/dev/null \
|| sudo iptables -t nat -A POSTROUTING -s "$TEST_SUBNET" -o "$UPLINK_IF" -j MASQUERADE
fi
echo ">> Starting Jellyfin on http://localhost:8096 (Ctrl-C to stop)..."
docker run --rm --name srfplay-test-run --network "$TEST_NET" -p 8096:8096 "$IMAGE"