Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71a65aab6e | ||
|
|
c6a1fd9f63 | ||
|
|
fb32d5c5e2 | ||
|
|
95ce4edf9b | ||
|
|
536fef85a8 | ||
|
|
3c321b4036 | ||
|
|
6822ea7202 | ||
|
|
8a5223b960 | ||
|
|
db9461cf59 | ||
|
|
b0c067ed41 | ||
|
|
582c94f5f9 | ||
|
|
dc2dd09e49 | ||
|
|
3cbbd20e71 | ||
|
|
d0d5d06a5e | ||
|
|
97b644a3b4 | ||
|
|
699b5432b9 | ||
|
|
7e1973aca7 | ||
|
|
47bf506dc6 |
@@ -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
|
||||
Executable
+62
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build one plugin zip per Jellyfin generation into ./artifacts.
|
||||
#
|
||||
# Jellyfin binds a plugin to the exact API it was compiled against, and 10.11 changed
|
||||
# signatures that still compile but fail at runtime. So each generation gets its own DLL
|
||||
# (see the TargetFrameworks comment in Jellyfin.Plugin.SRFPlay.csproj) and its own
|
||||
# manifest entry. Jellyfin installs the highest version whose targetAbi is <= the server
|
||||
# version, so the flavour code is folded into the version to keep them apart:
|
||||
#
|
||||
# net8.0 -> targetAbi 10.9.0.0 (Jellyfin 10.9 / 10.10) code 9
|
||||
# net9.0 -> targetAbi 10.11.0.0 (Jellyfin 10.11) code 11
|
||||
# net10.0 -> targetAbi 12.0.0.0 (Jellyfin 12) code 12
|
||||
#
|
||||
# Usage: build-plugins.sh <version-format>
|
||||
# <version-format> is a printf format that receives the flavour code, e.g.
|
||||
# "1.2.0.%d" -> 1.2.0.9, 1.2.0.11, 1.2.0.12 (release)
|
||||
# "1.0.20260924.204%02d" -> 1.0.20260924.20409, ...20411, ...20412 (nightly)
|
||||
#
|
||||
# Writes artifacts/builds.tsv with one line per zip: version, targetAbi, zip path, md5.
|
||||
set -euo pipefail
|
||||
|
||||
FORMAT="$1"
|
||||
|
||||
FLAVOURS=(
|
||||
"net8.0 10.9.0.0 9"
|
||||
"net9.0 10.11.0.0 11"
|
||||
"net10.0 12.0.0.0 12"
|
||||
)
|
||||
|
||||
mkdir -p artifacts
|
||||
: > artifacts/builds.tsv
|
||||
|
||||
# jprm has no targetAbi flag and reads it from build.yaml, so rewrite it per flavour.
|
||||
cp build.yaml build.yaml.orig
|
||||
trap 'mv -f build.yaml.orig build.yaml' EXIT
|
||||
|
||||
for flavour in "${FLAVOURS[@]}"; do
|
||||
read -r framework abi code <<< "$flavour"
|
||||
# shellcheck disable=SC2059 # the format is the argument by design
|
||||
version=$(printf "$FORMAT" "$code")
|
||||
|
||||
cp build.yaml.orig build.yaml
|
||||
sed -i \
|
||||
-e "s/^version:.*/version: \"${version}\"/" \
|
||||
-e "s/^targetAbi:.*/targetAbi: \"${abi}\"/" \
|
||||
-e "s/^framework:.*/framework: \"${framework}\"/" \
|
||||
-e "s/^dotnet_framework:.*/dotnet_framework: \"${framework}\"/" \
|
||||
build.yaml
|
||||
|
||||
echo ">> ${version}: ${framework}, targetAbi ${abi}"
|
||||
jprm --verbosity=debug plugin build . --dotnet-framework "${framework}" --version "${version}"
|
||||
|
||||
zip="artifacts/srfplay_${version}.zip"
|
||||
if [ ! -f "${zip}" ]; then
|
||||
echo "jprm did not produce ${zip}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf '%s\t%s\t%s\t%s\n' "${version}" "${abi}" "${zip}" "$(md5sum "${zip}" | awk '{print $1}')" >> artifacts/builds.tsv
|
||||
done
|
||||
|
||||
cat artifacts/builds.tsv
|
||||
@@ -15,7 +15,7 @@ on:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: linux/amd64
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: gitea.tourolle.paris/dtourolle/srfplay-builder:latest
|
||||
|
||||
@@ -51,14 +51,10 @@ jobs:
|
||||
LABEL="master"
|
||||
fi
|
||||
echo "version=${VERSION}" >> $GITHUB_OUTPUT
|
||||
echo "format=${VERSION}%02d" >> $GITHUB_OUTPUT
|
||||
echo "label=${LABEL}" >> $GITHUB_OUTPUT
|
||||
echo "Build version: ${VERSION} (${LABEL})"
|
||||
|
||||
- name: Set build version
|
||||
working-directory: build-${{ github.run_id }}
|
||||
run: |
|
||||
sed -i "s/^version:.*/version: \"${{ steps.version.outputs.version }}\"/" build.yaml
|
||||
|
||||
- name: Build solution
|
||||
working-directory: build-${{ github.run_id }}
|
||||
run: dotnet build Jellyfin.Plugin.SRFPlay.sln --configuration Release --no-restore --no-self-contained /m:1
|
||||
@@ -67,23 +63,16 @@ jobs:
|
||||
working-directory: build-${{ github.run_id }}
|
||||
run: dotnet test Jellyfin.Plugin.SRFPlay.sln --no-build --configuration Release --verbosity normal
|
||||
|
||||
- name: Build Jellyfin Plugin
|
||||
id: jprm
|
||||
- name: Build Jellyfin Plugins
|
||||
working-directory: build-${{ github.run_id }}
|
||||
run: |
|
||||
mkdir -p artifacts
|
||||
jprm --verbosity=debug plugin build .
|
||||
ARTIFACT=$(find . -name "*.zip" -type f -print -quit | sed 's|^\./||')
|
||||
LATEST="artifacts/srfplay_latest.zip"
|
||||
cp "${ARTIFACT}" "${LATEST}"
|
||||
echo "artifact=${LATEST}" >> $GITHUB_OUTPUT
|
||||
echo "Found artifact: ${ARTIFACT} -> ${LATEST}"
|
||||
# One zip per Jellyfin generation (see .gitea/scripts/build-plugins.sh).
|
||||
run: .gitea/scripts/build-plugins.sh "${{ steps.version.outputs.format }}"
|
||||
|
||||
- name: Upload build artifact
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: srfplay-${{ steps.version.outputs.label }}-${{ steps.version.outputs.version }}
|
||||
path: build-${{ github.run_id }}/${{ steps.jprm.outputs.artifact }}
|
||||
path: build-${{ github.run_id }}/artifacts/*.zip
|
||||
retention-days: 30
|
||||
if-no-files-found: error
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ on:
|
||||
|
||||
jobs:
|
||||
nightly-build:
|
||||
runs-on: linux/amd64
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: gitea.tourolle.paris/dtourolle/srfplay-builder:latest
|
||||
|
||||
@@ -27,18 +27,15 @@ jobs:
|
||||
# Date-based nightly version: 1.0.<YYYYMMDD>.<run_number>
|
||||
# The run_number suffix keeps multiple builds on the same day
|
||||
# monotonically increasing so Jellyfin always offers the newest.
|
||||
# Each Jellyfin generation gets its own build, so the last segment
|
||||
# is <run_number><2-digit flavour code>, e.g. run 204 -> 20409/20411/20412
|
||||
# (see .gitea/scripts/build-plugins.sh).
|
||||
DATE=$(date -u +"%Y%m%d")
|
||||
VERSION="1.0.${DATE}.${{ github.run_number }}"
|
||||
echo "version=${VERSION}" >> $GITHUB_OUTPUT
|
||||
echo "format=${VERSION}%02d" >> $GITHUB_OUTPUT
|
||||
echo "Nightly version: ${VERSION}"
|
||||
|
||||
- name: Set build version
|
||||
working-directory: build-${{ github.run_id }}
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
sed -i "s/^version:.*/version: \"${VERSION}\"/" build.yaml
|
||||
cat build.yaml
|
||||
|
||||
- name: Restore dependencies
|
||||
working-directory: build-${{ github.run_id }}
|
||||
run: dotnet restore Jellyfin.Plugin.SRFPlay.sln
|
||||
@@ -51,25 +48,9 @@ jobs:
|
||||
working-directory: build-${{ github.run_id }}
|
||||
run: dotnet test Jellyfin.Plugin.SRFPlay.sln --no-build --configuration Release --verbosity normal
|
||||
|
||||
- name: Build Jellyfin Plugin
|
||||
id: jprm
|
||||
- name: Build Jellyfin Plugins
|
||||
working-directory: build-${{ github.run_id }}
|
||||
run: |
|
||||
mkdir -p artifacts
|
||||
jprm --verbosity=debug plugin build .
|
||||
ARTIFACT=$(find . -name "*.zip" -type f -print -quit | sed 's|^\./||')
|
||||
ARTIFACT_NAME=$(basename "${ARTIFACT}")
|
||||
echo "artifact=${ARTIFACT}" >> $GITHUB_OUTPUT
|
||||
echo "artifact_name=${ARTIFACT_NAME}" >> $GITHUB_OUTPUT
|
||||
echo "Found artifact: ${ARTIFACT}"
|
||||
|
||||
- name: Calculate checksum
|
||||
id: checksum
|
||||
working-directory: build-${{ github.run_id }}
|
||||
run: |
|
||||
CHECKSUM=$(md5sum "${{ steps.jprm.outputs.artifact }}" | awk '{print $1}')
|
||||
echo "checksum=${CHECKSUM}" >> $GITHUB_OUTPUT
|
||||
echo "Checksum: ${CHECKSUM}"
|
||||
run: .gitea/scripts/build-plugins.sh "${{ steps.version.outputs.format }}"
|
||||
|
||||
- name: Delete existing nightly release
|
||||
working-directory: build-${{ github.run_id }}
|
||||
@@ -129,13 +110,15 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Upload plugin artifact
|
||||
echo "Uploading plugin artifact..."
|
||||
curl -f -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/zip" \
|
||||
--data-binary "@${{ steps.jprm.outputs.artifact }}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}/assets?name=${{ steps.jprm.outputs.artifact_name }}"
|
||||
# Upload one plugin zip per Jellyfin generation
|
||||
cut -f3 artifacts/builds.tsv | while read -r ZIP; do
|
||||
echo "Uploading ${ZIP}..."
|
||||
curl -f -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/zip" \
|
||||
--data-binary "@${ZIP}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}/assets?name=$(basename "${ZIP}")"
|
||||
done
|
||||
|
||||
# Upload build.yaml
|
||||
echo "Uploading build.yaml..."
|
||||
@@ -155,37 +138,45 @@ jobs:
|
||||
REPO_OWNER="${{ github.repository_owner }}"
|
||||
REPO_NAME="${{ github.event.repository.name }}"
|
||||
GITEA_URL="${{ github.server_url }}"
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
CHECKSUM="${{ steps.checksum.outputs.checksum }}"
|
||||
ARTIFACT_NAME="${{ steps.jprm.outputs.artifact_name }}"
|
||||
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
DOWNLOAD_URL="${GITEA_URL}/${REPO_OWNER}/${REPO_NAME}/releases/download/latest/${ARTIFACT_NAME}"
|
||||
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
|
||||
# builds.tsv is read before the checkout below replaces the tree.
|
||||
BUILDS=$(cat artifacts/builds.tsv)
|
||||
|
||||
git config user.name "Gitea Actions"
|
||||
git config user.email "actions@gitea.tourolle.paris"
|
||||
git fetch origin master
|
||||
git checkout master
|
||||
# -B against origin/master, not a bare `checkout master`. The bare
|
||||
# form switches to the LOCAL master that actions/checkout left at the
|
||||
# run's own SHA, so if master moved while the build ran -- and this is
|
||||
# a nightly, so it usually has -- the manifest is edited on a stale
|
||||
# base and the push is rejected non-fast-forward. Run 1045 failed
|
||||
# exactly this way, having already printed "Your branch is behind
|
||||
# 'origin/master' by 1 commit" one line before committing anyway.
|
||||
#
|
||||
# Resetting is right rather than merely convenient: this step appends
|
||||
# one entry to the manifest as it stands on master, so the base it
|
||||
# edits must be master, not whatever this build started from.
|
||||
git checkout -B master origin/master
|
||||
|
||||
NEW_VERSION=$(cat <<EOF
|
||||
{
|
||||
"version": "${VERSION}",
|
||||
"changelog": "Nightly build ${VERSION} (${SHORT_SHA})",
|
||||
"targetAbi": "10.9.0.0",
|
||||
"sourceUrl": "${DOWNLOAD_URL}",
|
||||
"checksum": "${CHECKSUM}",
|
||||
"timestamp": "${TIMESTAMP}"
|
||||
}
|
||||
EOF
|
||||
)
|
||||
# One entry per Jellyfin generation; each server installs the newest
|
||||
# entry whose targetAbi it satisfies.
|
||||
NEW_VERSIONS=$(echo "${BUILDS}" | jq -R -s \
|
||||
--arg url "${GITEA_URL}/${REPO_OWNER}/${REPO_NAME}/releases/download/latest" \
|
||||
--arg sha "${SHORT_SHA}" --arg ts "${TIMESTAMP}" \
|
||||
'split("\n") | map(select(length > 0) | split("\t")
|
||||
| {version: .[0], changelog: "Nightly build \(.[0]) (\($sha))", targetAbi: .[1],
|
||||
sourceUrl: "\($url)/\(.[2] | split("/") | last)", checksum: .[3], timestamp: $ts})
|
||||
| reverse')
|
||||
|
||||
# Prepend the new build and keep only the most recent 5 nightlies.
|
||||
jq --argjson newver "${NEW_VERSION}" \
|
||||
'.[0].versions = ([$newver] + .[0].versions)[0:5]' \
|
||||
# Prepend the new builds and keep only the most recent 5 nightlies
|
||||
# (3 entries each, one per Jellyfin generation).
|
||||
jq --argjson newvers "${NEW_VERSIONS}" \
|
||||
'.[0].versions = ($newvers + .[0].versions)[0:15]' \
|
||||
manifest-nightly.json > manifest.tmp && mv manifest.tmp manifest-nightly.json
|
||||
|
||||
git add manifest-nightly.json
|
||||
git commit -m "Nightly: ${VERSION} (${SHORT_SHA})" || echo "No changes to commit"
|
||||
git commit -m "Nightly: ${{ steps.version.outputs.version }} (${SHORT_SHA})" || echo "No changes to commit"
|
||||
git push origin master
|
||||
|
||||
- name: Cleanup
|
||||
|
||||
@@ -13,7 +13,7 @@ on:
|
||||
|
||||
jobs:
|
||||
build-and-release:
|
||||
runs-on: linux/amd64
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: gitea.tourolle.paris/dtourolle/srfplay-builder:latest
|
||||
|
||||
@@ -35,13 +35,6 @@ jobs:
|
||||
echo "version_number=${VERSION#v}" >> $GITHUB_OUTPUT
|
||||
echo "Building version: ${VERSION}"
|
||||
|
||||
- name: Update build.yaml with version
|
||||
working-directory: release-${{ github.run_id }}
|
||||
run: |
|
||||
VERSION="${{ steps.get_version.outputs.version_number }}"
|
||||
sed -i "s/^version:.*/version: \"${VERSION}\"/" build.yaml
|
||||
cat build.yaml
|
||||
|
||||
- name: Cache NuGet packages
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
@@ -61,25 +54,11 @@ jobs:
|
||||
working-directory: release-${{ github.run_id }}
|
||||
run: dotnet test Jellyfin.Plugin.SRFPlay.sln --no-build --configuration Release --verbosity normal
|
||||
|
||||
- name: Build Jellyfin Plugin
|
||||
id: jprm
|
||||
- name: Build Jellyfin Plugins
|
||||
working-directory: release-${{ github.run_id }}
|
||||
run: |
|
||||
mkdir -p artifacts
|
||||
jprm --verbosity=debug plugin build ./
|
||||
ARTIFACT=$(find . -name "*.zip" -type f -print -quit | sed 's|^\./||')
|
||||
ARTIFACT_NAME=$(basename "${ARTIFACT}")
|
||||
echo "artifact=${ARTIFACT}" >> $GITHUB_OUTPUT
|
||||
echo "artifact_name=${ARTIFACT_NAME}" >> $GITHUB_OUTPUT
|
||||
echo "Found artifact: ${ARTIFACT}"
|
||||
|
||||
- name: Calculate checksum
|
||||
id: checksum
|
||||
working-directory: release-${{ github.run_id }}
|
||||
run: |
|
||||
CHECKSUM=$(md5sum "${{ steps.jprm.outputs.artifact }}" | awk '{print $1}')
|
||||
echo "checksum=${CHECKSUM}" >> $GITHUB_OUTPUT
|
||||
echo "Checksum: ${CHECKSUM}"
|
||||
# v1.2.0 -> 1.2.0.9 / 1.2.0.11 / 1.2.0.12, one per Jellyfin generation
|
||||
# (see .gitea/scripts/build-plugins.sh).
|
||||
run: .gitea/scripts/build-plugins.sh "${{ steps.get_version.outputs.version_number }}.%d"
|
||||
|
||||
- name: Create Release
|
||||
working-directory: release-${{ github.run_id }}
|
||||
@@ -111,13 +90,15 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Upload plugin artifact
|
||||
echo "Uploading plugin artifact..."
|
||||
curl -f -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/zip" \
|
||||
--data-binary "@${{ steps.jprm.outputs.artifact }}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}/assets?name=${{ steps.jprm.outputs.artifact_name }}"
|
||||
# Upload one plugin zip per Jellyfin generation
|
||||
cut -f3 artifacts/builds.tsv | while read -r ZIP; do
|
||||
echo "Uploading ${ZIP}..."
|
||||
curl -f -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/zip" \
|
||||
--data-binary "@${ZIP}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}/assets?name=$(basename "${ZIP}")"
|
||||
done
|
||||
|
||||
# Upload build.yaml
|
||||
echo "Uploading build.yaml..."
|
||||
@@ -139,29 +120,28 @@ jobs:
|
||||
REPO_NAME="${{ github.event.repository.name }}"
|
||||
GITEA_URL="${{ github.server_url }}"
|
||||
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")
|
||||
DOWNLOAD_URL="${GITEA_URL}/${REPO_OWNER}/${REPO_NAME}/releases/download/${{ steps.get_version.outputs.version }}/${ARTIFACT_NAME}"
|
||||
# builds.tsv is read before the checkout below replaces the tree.
|
||||
BUILDS=$(cat artifacts/builds.tsv)
|
||||
|
||||
git config user.name "Gitea Actions"
|
||||
git config user.email "actions@gitea.tourolle.paris"
|
||||
git fetch origin master
|
||||
git checkout master
|
||||
# -B against origin/master for the same reason as the nightly: a bare
|
||||
# `checkout master` edits the manifest on a stale base if master moved.
|
||||
git checkout -B master origin/master
|
||||
|
||||
NEW_VERSION=$(cat <<EOF
|
||||
{
|
||||
"version": "${VERSION}",
|
||||
"changelog": "Release ${VERSION}",
|
||||
"targetAbi": "10.9.0.0",
|
||||
"sourceUrl": "${DOWNLOAD_URL}",
|
||||
"checksum": "${CHECKSUM}",
|
||||
"timestamp": "${TIMESTAMP}"
|
||||
}
|
||||
EOF
|
||||
)
|
||||
# One entry per Jellyfin generation; each server installs the newest
|
||||
# entry whose targetAbi it satisfies.
|
||||
NEW_VERSIONS=$(echo "${BUILDS}" | jq -R -s \
|
||||
--arg url "${GITEA_URL}/${REPO_OWNER}/${REPO_NAME}/releases/download/${{ steps.get_version.outputs.version }}" \
|
||||
--arg release "${VERSION}" --arg ts "${TIMESTAMP}" \
|
||||
'split("\n") | map(select(length > 0) | split("\t")
|
||||
| {version: .[0], changelog: "Release \($release)", targetAbi: .[1],
|
||||
sourceUrl: "\($url)/\(.[2] | split("/") | last)", checksum: .[3], timestamp: $ts})
|
||||
| reverse')
|
||||
|
||||
jq --argjson newver "${NEW_VERSION}" '.[0].versions = [$newver] + .[0].versions' manifest.json > manifest.tmp && mv manifest.tmp manifest.json
|
||||
jq --argjson newvers "${NEW_VERSIONS}" '.[0].versions = $newvers + .[0].versions' manifest.json > manifest.tmp && mv manifest.tmp manifest.json
|
||||
git add manifest.json
|
||||
git commit -m "Update manifest.json for version ${VERSION}"
|
||||
git push origin master
|
||||
|
||||
@@ -17,7 +17,7 @@ on:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: linux/amd64
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: gitea.tourolle.paris/dtourolle/srfplay-builder:latest
|
||||
|
||||
|
||||
@@ -4,3 +4,4 @@ obj/
|
||||
.idea/
|
||||
artifacts
|
||||
*.log
|
||||
.test-plugin/
|
||||
|
||||
+6
-3
@@ -1,9 +1,12 @@
|
||||
# SRFPlay Builder Image
|
||||
# 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 .
|
||||
# Push: docker push gitea.tourolle.paris/dtourolle/srfplay-builder:latest
|
||||
# Multi-arch (amd64 + arm64) so runners on ARM hosts such as Oracle Cloud's free
|
||||
# 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:8.0
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
python3 \
|
||||
|
||||
@@ -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
|
||||
@@ -5,13 +5,13 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" Version="8.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="8.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<!-- Mirrors the plugin: jprm publishes the whole solution for one framework at a time. -->
|
||||
<TargetFrameworks>net8.0;net9.0;net10.0</TargetFrameworks>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -81,6 +81,13 @@ public class RecordingEntry
|
||||
[JsonPropertyName("recordingEndedAt")]
|
||||
public DateTime? RecordingEndedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets when the stream disappeared from the API after recording had started.
|
||||
/// Used to detect that a broadcast ended before its scheduled ValidTo.
|
||||
/// </summary>
|
||||
[JsonPropertyName("streamLostAt")]
|
||||
public DateTime? StreamLostAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the file size in bytes.
|
||||
/// </summary>
|
||||
@@ -98,4 +105,10 @@ public class RecordingEntry
|
||||
/// </summary>
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the output file may still grow (recording, or waiting to resume).
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public bool IsInProgress => State is RecordingState.Recording or RecordingState.WaitingForStream;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ namespace Jellyfin.Plugin.SRFPlay.Channels;
|
||||
/// </summary>
|
||||
public abstract class SrgChannelBase : IChannel, IHasCacheKey
|
||||
{
|
||||
private const string InProgressPrefix = "● REC · ";
|
||||
|
||||
private readonly ILogger<SrgChannelBase> _logger;
|
||||
private readonly IContentRefreshService _contentRefreshService;
|
||||
private readonly IStreamUrlResolver _streamResolver;
|
||||
@@ -303,8 +305,8 @@ public abstract class SrgChannelBase : IChannel, IHasCacheKey
|
||||
if (matchingEvent?.ValidFrom != null)
|
||||
{
|
||||
var eventTime = matchingEvent.ValidFrom.Value;
|
||||
item.Name = $"[{eventTime:dd.MM HH:mm}] {matchingEvent.Title}";
|
||||
item.PremiereDate = eventTime;
|
||||
item.Name = $"[{DisplayTime.From(eventTime):dd.MM HH:mm}] {matchingEvent.Title}";
|
||||
item.PremiereDate = eventTime.ToUniversalTime();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -320,15 +322,10 @@ public abstract class SrgChannelBase : IChannel, IHasCacheKey
|
||||
{
|
||||
var items = new List<ChannelItemInfo>();
|
||||
var unit = Unit.ToLowerString();
|
||||
var recordings = _recordingService.GetRecordings(RecordingState.Completed);
|
||||
var serverBaseUrl = _mediaSourceFactory.GetServerBaseUrl();
|
||||
|
||||
foreach (var recording in recordings)
|
||||
foreach (var recording in _recordingService.GetPlayableRecordings())
|
||||
{
|
||||
if (string.IsNullOrEmpty(recording.OutputPath) || !System.IO.File.Exists(recording.OutputPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only show recordings belonging to this unit. Recordings created before the
|
||||
// BusinessUnit tag existed have an empty value and are shown everywhere so they
|
||||
// are not lost.
|
||||
@@ -338,43 +335,72 @@ public abstract class SrgChannelBase : IChannel, IHasCacheKey
|
||||
continue;
|
||||
}
|
||||
|
||||
var fileInfo = new System.IO.FileInfo(recording.OutputPath);
|
||||
var outputPath = recording.OutputPath!;
|
||||
var itemId = $"recording_{recording.Id}";
|
||||
var container = System.IO.Path.GetExtension(outputPath).TrimStart('.').ToLowerInvariant();
|
||||
var name = recording.IsInProgress ? $"{InProgressPrefix}{recording.Title}" : recording.Title;
|
||||
|
||||
var mediaSource = new MediaSourceInfo
|
||||
MediaSourceInfo mediaSource;
|
||||
if (recording.IsInProgress)
|
||||
{
|
||||
Id = itemId,
|
||||
Name = recording.Title,
|
||||
Path = recording.OutputPath,
|
||||
Protocol = MediaProtocol.File,
|
||||
Container = "mkv",
|
||||
SupportsDirectPlay = true,
|
||||
SupportsDirectStream = true,
|
||||
SupportsTranscoding = true,
|
||||
IsRemote = false,
|
||||
Size = fileInfo.Length,
|
||||
Type = MediaSourceType.Default
|
||||
};
|
||||
// Same approach as Jellyfin's own DVR: the file is still growing, so treat it as a live
|
||||
// stream and have ffmpeg read it through an endpoint that follows the file.
|
||||
mediaSource = new MediaSourceInfo
|
||||
{
|
||||
Id = itemId,
|
||||
Name = name,
|
||||
Path = outputPath,
|
||||
Protocol = MediaProtocol.File,
|
||||
EncoderPath = $"{serverBaseUrl}/Plugins/SRFPlay/Recording/{recording.Id}/stream",
|
||||
EncoderProtocol = MediaProtocol.Http,
|
||||
Container = container,
|
||||
SupportsDirectPlay = false,
|
||||
SupportsDirectStream = true,
|
||||
SupportsTranscoding = true,
|
||||
IsInfiniteStream = true,
|
||||
IgnoreDts = true,
|
||||
IgnoreIndex = true,
|
||||
IsRemote = false,
|
||||
Type = MediaSourceType.Default
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
mediaSource = new MediaSourceInfo
|
||||
{
|
||||
Id = itemId,
|
||||
Name = name,
|
||||
Path = outputPath,
|
||||
Protocol = MediaProtocol.File,
|
||||
Container = container,
|
||||
SupportsDirectPlay = true,
|
||||
SupportsDirectStream = true,
|
||||
SupportsTranscoding = true,
|
||||
IsRemote = false,
|
||||
Size = new System.IO.FileInfo(outputPath).Length,
|
||||
Type = MediaSourceType.Default
|
||||
};
|
||||
}
|
||||
|
||||
var item = new ChannelItemInfo
|
||||
{
|
||||
Id = itemId,
|
||||
Name = recording.Title,
|
||||
Name = name,
|
||||
Overview = recording.Description,
|
||||
Type = ChannelItemType.Media,
|
||||
ContentType = ChannelMediaContentType.Movie,
|
||||
MediaType = ChannelMediaType.Video,
|
||||
DateCreated = recording.RecordingStartedAt,
|
||||
ImageUrl = !string.IsNullOrEmpty(recording.ImageUrl)
|
||||
? CreateProxiedImageUrl(recording.ImageUrl, _mediaSourceFactory.GetServerBaseUrl())
|
||||
: CreatePlaceholderImageUrl(recording.Title, _mediaSourceFactory.GetServerBaseUrl()),
|
||||
? CreateProxiedImageUrl(recording.ImageUrl, serverBaseUrl)
|
||||
: CreatePlaceholderImageUrl(recording.Title, serverBaseUrl),
|
||||
MediaSources = new List<MediaSourceInfo> { mediaSource }
|
||||
};
|
||||
|
||||
items.Add(item);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Returning {Count} completed recordings as channel items", items.Count);
|
||||
_logger.LogInformation("Returning {Count} recordings as channel items", items.Count);
|
||||
return items;
|
||||
}
|
||||
|
||||
@@ -492,8 +518,10 @@ public abstract class SrgChannelBase : IChannel, IHasCacheKey
|
||||
var timeBucket = new DateTime(now.Year, now.Month, now.Day, now.Hour, (now.Minute / 15) * 15, 0);
|
||||
var timeKey = timeBucket.ToString("yyyy-MM-dd-HH-mm", CultureInfo.InvariantCulture);
|
||||
|
||||
var recordingCount = _recordingService.GetRecordings(RecordingState.Completed).Count;
|
||||
return $"{Unit}_{config?.EnableLatestContent}_{config?.EnableTrendingContent}_{config?.EnableCategoryFolders}_{enabledTopics}_{timeKey}_rec{recordingCount}";
|
||||
// Include in-progress count so items switch from live to finished sources when a recording ends
|
||||
var recordings = _recordingService.GetPlayableRecordings();
|
||||
var recordingKey = $"{recordings.Count}_{recordings.Count(r => r.IsInProgress)}";
|
||||
return $"{Unit}_{config?.EnableLatestContent}_{config?.EnableTrendingContent}_{config?.EnableCategoryFolders}_{enabledTopics}_{timeKey}_rec{recordingKey}";
|
||||
}
|
||||
|
||||
private async Task<List<ChannelItemInfo>> ConvertUrnsToChannelItems(List<string> urns, CancellationToken cancellationToken)
|
||||
|
||||
@@ -169,6 +169,14 @@ public class PluginConfiguration : BasePluginConfiguration
|
||||
/// </summary>
|
||||
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>
|
||||
/// 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
|
||||
|
||||
@@ -82,6 +82,11 @@
|
||||
<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>
|
||||
<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">
|
||||
<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" />
|
||||
@@ -200,6 +205,7 @@
|
||||
document.querySelector('#ProxyPassword').value = config.ProxyPassword || '';
|
||||
document.querySelector('#PublicServerUrl').value = config.PublicServerUrl || '';
|
||||
document.querySelector('#RecordingOutputPath').value = config.RecordingOutputPath || '';
|
||||
document.querySelector('#DisplayTimeZone').value = config.DisplayTimeZone || '';
|
||||
document.querySelector('#LiveStartSegmentsBack').value = config.LiveStartSegmentsBack != null ? config.LiveStartSegmentsBack : 3;
|
||||
document.querySelector('#CleanUpResumePoints').checked = config.CleanUpResumePoints !== false;
|
||||
document.querySelector('#ClearLiveStreamResumePoints').checked = config.ClearLiveStreamResumePoints !== false;
|
||||
@@ -231,6 +237,7 @@
|
||||
config.ProxyPassword = document.querySelector('#ProxyPassword').value;
|
||||
config.PublicServerUrl = document.querySelector('#PublicServerUrl').value;
|
||||
config.RecordingOutputPath = document.querySelector('#RecordingOutputPath').value;
|
||||
config.DisplayTimeZone = document.querySelector('#DisplayTimeZone').value.trim();
|
||||
config.LiveStartSegmentsBack = parseInt(document.querySelector('#LiveStartSegmentsBack').value) || 0;
|
||||
config.CleanUpResumePoints = document.querySelector('#CleanUpResumePoints').checked;
|
||||
config.ClearLiveStreamResumePoints = document.querySelector('#ClearLiveStreamResumePoints').checked;
|
||||
@@ -257,7 +264,7 @@
|
||||
|
||||
fetch(ApiClient.serverAddress() + '/Plugins/SRFPlay/Maintenance/ResumePoints/Cleanup?clearAll=' + (clearAll ? 'true' : 'false'), {
|
||||
method: 'POST',
|
||||
headers: { 'X-Emby-Token': ApiClient.accessToken() }
|
||||
headers: { 'Authorization': 'MediaBrowser Token="' + ApiClient.accessToken() + '"' }
|
||||
})
|
||||
.then(function(response) {
|
||||
if (!response.ok) {
|
||||
@@ -285,7 +292,7 @@
|
||||
|
||||
getHeaders: function() {
|
||||
return {
|
||||
'X-Emby-Token': ApiClient.accessToken()
|
||||
'Authorization': 'MediaBrowser Token="' + ApiClient.accessToken() + '"'
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -92,7 +92,7 @@
|
||||
verifySession: function(session) {
|
||||
var self = this;
|
||||
fetch(this.serverUrl + '/System/Info', {
|
||||
headers: { 'X-Emby-Token': this.token }
|
||||
headers: { 'Authorization': 'MediaBrowser Token="' + this.token + '"' }
|
||||
}).then(function(r) {
|
||||
if (r.ok) {
|
||||
document.getElementById('srfrecUserInfo').textContent = '(Server: ' + self.serverUrl + ')';
|
||||
@@ -119,7 +119,7 @@
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Emby-Authorization': 'MediaBrowser Client="SRF Recordings", Device="Web", DeviceId="srfrec-' + Date.now() + '", Version="1.0.0"'
|
||||
'Authorization': 'MediaBrowser Client="SRF Recordings", Device="Web", DeviceId="srfrec-' + Date.now() + '", Version="1.0.0"'
|
||||
},
|
||||
body: JSON.stringify({ Username: user, Pw: pass })
|
||||
})
|
||||
@@ -151,7 +151,7 @@
|
||||
},
|
||||
|
||||
getHeaders: function() {
|
||||
return { 'X-Emby-Token': this.token };
|
||||
return { 'Authorization': 'MediaBrowser Token="' + this.token + '"' };
|
||||
},
|
||||
|
||||
formatDate: function(dateStr) {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@@ -19,6 +21,9 @@ namespace Jellyfin.Plugin.SRFPlay.Controllers;
|
||||
[Authorize]
|
||||
public class RecordingController : ControllerBase
|
||||
{
|
||||
/// <summary>How long a followed stream waits at the end of an in-progress file before giving up.</summary>
|
||||
private static readonly TimeSpan FollowIdleTimeout = TimeSpan.FromMinutes(2);
|
||||
|
||||
private readonly ILogger<RecordingController> _logger;
|
||||
private readonly IRecordingService _recordingService;
|
||||
|
||||
@@ -56,6 +61,67 @@ public class RecordingController : ControllerBase
|
||||
return File(resourceStream, "text/html");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Streams a recording's .ts file. While the recording is still in progress this keeps
|
||||
/// following the file as it grows, so it can be watched like a livestream buffer.
|
||||
/// </summary>
|
||||
/// <param name="id">The recording ID.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>The MPEG-TS stream.</returns>
|
||||
[HttpGet("{id}/stream")]
|
||||
[AllowAnonymous] // Fetched server-side by Jellyfin/ffmpeg without a token, like the stream proxy
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetRecordingStream(string id, CancellationToken cancellationToken)
|
||||
{
|
||||
// The path always comes from our own recording list (which only contains existing files),
|
||||
// never from the request
|
||||
var entry = _recordingService.GetPlayableRecordings().FirstOrDefault(r => r.Id == id);
|
||||
if (entry?.OutputPath is not { } path)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
Response.ContentType = "video/mp2t";
|
||||
|
||||
var input = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete, 81920, FileOptions.Asynchronous | FileOptions.SequentialScan);
|
||||
await using (input.ConfigureAwait(false))
|
||||
{
|
||||
var buffer = new byte[81920];
|
||||
var lastDataAt = DateTime.UtcNow;
|
||||
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var read = await input.ReadAsync(buffer, cancellationToken).ConfigureAwait(false);
|
||||
if (read > 0)
|
||||
{
|
||||
await Response.Body.WriteAsync(buffer.AsMemory(0, read), cancellationToken).ConfigureAwait(false);
|
||||
lastDataAt = DateTime.UtcNow;
|
||||
continue;
|
||||
}
|
||||
|
||||
// At the end of the file: keep following while it's still being written. The idle
|
||||
// limit covers an ffmpeg restart but ends the stream if recording has stalled.
|
||||
var stillRecording = _recordingService.GetRecording(id)?.IsInProgress == true;
|
||||
if (!stillRecording || DateTime.UtcNow - lastDataAt > FollowIdleTimeout)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
await Task.Delay(500, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Client went away
|
||||
}
|
||||
}
|
||||
|
||||
return new EmptyResult();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets upcoming sport livestreams available for recording.
|
||||
/// </summary>
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<!--
|
||||
One DLL per Jellyfin generation. The server binds to the Jellyfin API it was built
|
||||
against, and 10.11 changed signatures (User moved, TaskTriggerInfo.Type became an enum,
|
||||
GetItemList return type) that still compile from source but fail at runtime, so a single
|
||||
net8.0 build silently half-works on newer servers. Each TFM pins the oldest package of its
|
||||
line so it loads on every patch release of that line:
|
||||
net8.0 -> Jellyfin 10.9 / 10.10 (targetAbi 10.9.0.0)
|
||||
net9.0 -> Jellyfin 10.11 (targetAbi 10.11.0.0)
|
||||
net10.0 -> Jellyfin 12 (targetAbi 12.0.0.0)
|
||||
-->
|
||||
<TargetFrameworks>net8.0;net9.0;net10.0</TargetFrameworks>
|
||||
<RootNamespace>Jellyfin.Plugin.SRFPlay</RootNamespace>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
@@ -10,9 +20,30 @@
|
||||
<CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PropertyGroup Condition="'$(TargetFramework)' != 'net8.0'">
|
||||
<DefineConstants>$(DefineConstants);JELLYFIN_10_11_OR_GREATER</DefineConstants>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(TargetFramework)' == 'net10.0'">
|
||||
<DefineConstants>$(DefineConstants);JELLYFIN_12_OR_GREATER</DefineConstants>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">
|
||||
<PackageReference Include="Jellyfin.Controller" Version="10.9.11" />
|
||||
<PackageReference Include="Jellyfin.Model" Version="10.9.11" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="'$(TargetFramework)' == 'net9.0'">
|
||||
<PackageReference Include="Jellyfin.Controller" Version="10.11.0" />
|
||||
<PackageReference Include="Jellyfin.Model" Version="10.11.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="'$(TargetFramework)' == 'net10.0'">
|
||||
<PackageReference Include="Jellyfin.Controller" Version="12.0.0" />
|
||||
<PackageReference Include="Jellyfin.Model" Version="12.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Socks5" Version="1.1.0" />
|
||||
<PackageReference Include="SkiaSharp" Version="2.88.8" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -104,11 +104,7 @@ public class ContentRefreshTask : IScheduledTask
|
||||
// Run every X hours as configured
|
||||
return new[]
|
||||
{
|
||||
new TaskTriggerInfo
|
||||
{
|
||||
Type = TaskTriggerInfo.TriggerInterval,
|
||||
IntervalTicks = TimeSpan.FromHours(intervalHours).Ticks
|
||||
}
|
||||
TaskTriggers.Interval(TimeSpan.FromHours(intervalHours))
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
||||
using Jellyfin.Plugin.SRFPlay.Utilities;
|
||||
using MediaBrowser.Model.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -87,16 +88,8 @@ public class ExpirationCheckTask : IScheduledTask
|
||||
// Run daily at 3 AM or every X hours as configured
|
||||
return new[]
|
||||
{
|
||||
new TaskTriggerInfo
|
||||
{
|
||||
Type = TaskTriggerInfo.TriggerDaily,
|
||||
TimeOfDayTicks = TimeSpan.FromHours(3).Ticks
|
||||
},
|
||||
new TaskTriggerInfo
|
||||
{
|
||||
Type = TaskTriggerInfo.TriggerInterval,
|
||||
IntervalTicks = TimeSpan.FromHours(intervalHours).Ticks
|
||||
}
|
||||
TaskTriggers.Daily(TimeSpan.FromHours(3)),
|
||||
TaskTriggers.Interval(TimeSpan.FromHours(intervalHours))
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
||||
using Jellyfin.Plugin.SRFPlay.Utilities;
|
||||
using MediaBrowser.Model.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -66,11 +67,7 @@ public class RecordingSchedulerTask : IScheduledTask
|
||||
{
|
||||
return new[]
|
||||
{
|
||||
new TaskTriggerInfo
|
||||
{
|
||||
Type = TaskTriggerInfo.TriggerInterval,
|
||||
IntervalTicks = TimeSpan.FromSeconds(30).Ticks
|
||||
}
|
||||
TaskTriggers.Interval(TimeSpan.FromSeconds(30))
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
||||
using Jellyfin.Plugin.SRFPlay.Utilities;
|
||||
using MediaBrowser.Model.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -77,11 +78,7 @@ public class ResumeCleanupTask : IScheduledTask
|
||||
// Runs after the 3 AM expiration check so items deleted there are already gone.
|
||||
return new[]
|
||||
{
|
||||
new TaskTriggerInfo
|
||||
{
|
||||
Type = TaskTriggerInfo.TriggerDaily,
|
||||
TimeOfDayTicks = TimeSpan.FromHours(4).Ticks
|
||||
}
|
||||
TaskTriggers.Daily(TimeSpan.FromHours(4))
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,19 @@ public interface IRecordingService
|
||||
/// <returns>List of matching recording entries.</returns>
|
||||
IReadOnlyList<RecordingEntry> GetRecordings(RecordingState? stateFilter = null);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a single recording by ID.
|
||||
/// </summary>
|
||||
/// <param name="recordingId">The recording ID.</param>
|
||||
/// <returns>The recording entry, or null if not found.</returns>
|
||||
RecordingEntry? GetRecording(string recordingId);
|
||||
|
||||
/// <summary>
|
||||
/// Gets recordings that have an output file on disk, finished or still in progress.
|
||||
/// </summary>
|
||||
/// <returns>Playable recordings, newest first.</returns>
|
||||
IReadOnlyList<RecordingEntry> GetPlayableRecordings();
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a completed recording (entry and optionally the file).
|
||||
/// </summary>
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
#if JELLYFIN_10_11_OR_GREATER
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
#else
|
||||
using Jellyfin.Data.Entities;
|
||||
#endif
|
||||
using Jellyfin.Plugin.SRFPlay.Api.Models;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
|
||||
|
||||
@@ -25,6 +25,15 @@ namespace Jellyfin.Plugin.SRFPlay.Services;
|
||||
/// </summary>
|
||||
public class RecordingService : IRecordingService, IDisposable
|
||||
{
|
||||
/// <summary>How long the stream may be missing from the API mid-recording before we treat the broadcast as over.</summary>
|
||||
private static readonly TimeSpan StreamLostGracePeriod = TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>When a recording has no ValidTo, how long after ValidFrom to keep waiting for the stream.</summary>
|
||||
private static readonly TimeSpan NoValidToGiveUpAfter = TimeSpan.FromHours(12);
|
||||
|
||||
/// <summary>Longest the scheduler will block waiting for an imminent DVR window start.</summary>
|
||||
private static readonly TimeSpan MaxInlineStartWait = TimeSpan.FromSeconds(60);
|
||||
|
||||
private readonly ILogger<RecordingService> _logger;
|
||||
private readonly ISRFApiClientFactory _apiClientFactory;
|
||||
private readonly IStreamProxyService _proxyService;
|
||||
@@ -32,7 +41,7 @@ public class RecordingService : IRecordingService, IDisposable
|
||||
private readonly IMediaCompositionFetcher _mediaCompositionFetcher;
|
||||
private readonly IServerApplicationHost _appHost;
|
||||
private readonly IMediaEncoder _mediaEncoder;
|
||||
private readonly ConcurrentDictionary<string, Process> _activeProcesses = new();
|
||||
private readonly ConcurrentDictionary<string, ActiveRecording> _activeProcesses = new();
|
||||
private static readonly JsonSerializerOptions _jsonOptions = new() { WriteIndented = true };
|
||||
private readonly SemaphoreSlim _persistLock = new(1, 1);
|
||||
private readonly SemaphoreSlim _processLock = new(1, 1);
|
||||
@@ -266,15 +275,7 @@ public class RecordingService : IRecordingService, IDisposable
|
||||
}
|
||||
|
||||
StopFfmpeg(recordingId);
|
||||
|
||||
entry.State = RecordingState.Completed;
|
||||
entry.RecordingEndedAt = DateTime.UtcNow;
|
||||
|
||||
if (entry.OutputPath != null && File.Exists(entry.OutputPath))
|
||||
{
|
||||
entry.FileSizeBytes = new FileInfo(entry.OutputPath).Length;
|
||||
}
|
||||
|
||||
CompleteRecording(entry, DateTime.UtcNow);
|
||||
_ = SaveRecordingsAsync();
|
||||
|
||||
_logger.LogInformation("Stopped recording '{Title}' ({Id})", entry.Title, recordingId);
|
||||
@@ -298,6 +299,22 @@ public class RecordingService : IRecordingService, IDisposable
|
||||
return _recordings.OrderByDescending(r => r.CreatedAt).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public RecordingEntry? GetRecording(string recordingId)
|
||||
{
|
||||
return GetRecordings(null).FirstOrDefault(r => r.Id == recordingId);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<RecordingEntry> GetPlayableRecordings()
|
||||
{
|
||||
// Gate on the file rather than the state: an in-progress or failed recording
|
||||
// still has watchable footage.
|
||||
return GetRecordings(null)
|
||||
.Where(r => !string.IsNullOrEmpty(r.OutputPath) && File.Exists(r.OutputPath))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool DeleteRecording(string recordingId, bool deleteFile)
|
||||
{
|
||||
@@ -369,8 +386,26 @@ public class RecordingService : IRecordingService, IDisposable
|
||||
{
|
||||
case RecordingState.Scheduled:
|
||||
case RecordingState.WaitingForStream:
|
||||
// Check if it's time to start recording
|
||||
if (validFromUtc.HasValue && validFromUtc.Value <= now.AddMinutes(2))
|
||||
// Give up once the broadcast window is over, otherwise we'd retry forever
|
||||
var giveUpAt = validToUtc ?? validFromUtc?.Add(NoValidToGiveUpAfter);
|
||||
if (giveUpAt.HasValue && giveUpAt.Value <= now)
|
||||
{
|
||||
if (entry.RecordingStartedAt.HasValue)
|
||||
{
|
||||
_logger.LogInformation("Recording '{Title}' reached end of broadcast window while waiting for stream, completing", entry.Title);
|
||||
CompleteRecording(entry, now);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Stream for '{Title}' never became available (window ended {GiveUpAt}), marking failed", entry.Title, giveUpAt.Value);
|
||||
entry.State = RecordingState.Failed;
|
||||
entry.ErrorMessage = "Stream never became available";
|
||||
entry.RecordingEndedAt = now;
|
||||
}
|
||||
|
||||
changed = true;
|
||||
}
|
||||
else if (validFromUtc.HasValue && validFromUtc.Value <= now.AddMinutes(2))
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Time to start recording '{Title}': ValidFrom={ValidFrom} (UTC: {ValidFromUtc}), Now={Now}",
|
||||
@@ -389,13 +424,7 @@ public class RecordingService : IRecordingService, IDisposable
|
||||
{
|
||||
_logger.LogInformation("Recording '{Title}' reached ValidTo, stopping", entry.Title);
|
||||
StopFfmpeg(entry.Id);
|
||||
entry.State = RecordingState.Completed;
|
||||
entry.RecordingEndedAt = now;
|
||||
if (entry.OutputPath != null && File.Exists(entry.OutputPath))
|
||||
{
|
||||
entry.FileSizeBytes = new FileInfo(entry.OutputPath).Length;
|
||||
}
|
||||
|
||||
CompleteRecording(entry, now);
|
||||
changed = true;
|
||||
}
|
||||
else if (!_activeProcesses.ContainsKey(entry.Id))
|
||||
@@ -426,7 +455,7 @@ public class RecordingService : IRecordingService, IDisposable
|
||||
if (chapter == null)
|
||||
{
|
||||
_logger.LogDebug("No chapter found for '{Title}', stream may not be live yet", entry.Title);
|
||||
entry.State = RecordingState.WaitingForStream;
|
||||
MarkStreamUnavailable(entry);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -437,10 +466,29 @@ public class RecordingService : IRecordingService, IDisposable
|
||||
if (string.IsNullOrEmpty(streamUrl))
|
||||
{
|
||||
_logger.LogDebug("No stream URL available for '{Title}', waiting", entry.Title);
|
||||
entry.State = RecordingState.WaitingForStream;
|
||||
MarkStreamUnavailable(entry);
|
||||
return true;
|
||||
}
|
||||
|
||||
// The CDN rejects a DVR window whose start= is still in the future (400/404).
|
||||
// Wait briefly if it's imminent, otherwise try again on the next scheduler run.
|
||||
var windowStart = GetDvrWindowStart(streamUrl);
|
||||
if (windowStart.HasValue && windowStart.Value > DateTime.UtcNow)
|
||||
{
|
||||
var wait = windowStart.Value - DateTime.UtcNow + TimeSpan.FromSeconds(5);
|
||||
if (wait > MaxInlineStartWait)
|
||||
{
|
||||
_logger.LogInformation("Stream for '{Title}' starts at {Start}, waiting", entry.Title, windowStart.Value);
|
||||
entry.State = RecordingState.WaitingForStream;
|
||||
return true;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Stream for '{Title}' starts in {Seconds:F0}s, delaying ffmpeg start", entry.Title, wait.TotalSeconds);
|
||||
await Task.Delay(wait, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
entry.StreamLostAt = null;
|
||||
|
||||
// Register the stream with the proxy so we can use the proxy URL
|
||||
var itemId = $"rec_{entry.Id}";
|
||||
var isLiveStream = chapter.Type == "SCHEDULED_LIVESTREAM" || UrnHelper.IsLivestreamUrn(entry.Urn);
|
||||
@@ -449,22 +497,26 @@ public class RecordingService : IRecordingService, IDisposable
|
||||
// Build proxy URL for ffmpeg (use localhost for local access)
|
||||
var proxyUrl = $"{GetServerBaseUrl()}/Plugins/SRFPlay/Proxy/{itemId}/master.m3u8";
|
||||
|
||||
// Build output file path
|
||||
var safeTitle = SanitizeFileName(entry.Title);
|
||||
var timestamp = DateTime.Now.ToString("yyyy-MM-dd_HHmm", CultureInfo.InvariantCulture);
|
||||
var outputPath = Path.Combine(GetRecordingOutputPath(), $"{safeTitle}_{timestamp}.mkv");
|
||||
entry.OutputPath = outputPath;
|
||||
// Restarts append to the existing .ts so a recording stays one file
|
||||
var outputPath = entry.OutputPath;
|
||||
if (string.IsNullOrEmpty(outputPath) || !outputPath.EndsWith(".ts", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var safeTitle = SanitizeFileName(entry.Title);
|
||||
var timestamp = DisplayTime.Now().ToString("yyyy-MM-dd_HHmm", CultureInfo.InvariantCulture);
|
||||
outputPath = Path.Combine(GetRecordingOutputPath(), $"{safeTitle}_{timestamp}.ts");
|
||||
entry.OutputPath = outputPath;
|
||||
}
|
||||
|
||||
// Start ffmpeg
|
||||
StartFfmpeg(entry.Id, proxyUrl, outputPath);
|
||||
|
||||
entry.State = RecordingState.Recording;
|
||||
entry.RecordingStartedAt = DateTime.UtcNow;
|
||||
entry.RecordingStartedAt ??= DateTime.UtcNow;
|
||||
|
||||
_logger.LogInformation("Started recording '{Title}' to {OutputPath}", entry.Title, outputPath);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to start recording '{Title}'", entry.Title);
|
||||
entry.State = RecordingState.Failed;
|
||||
@@ -473,6 +525,61 @@ public class RecordingService : IRecordingService, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the API no longer offering a stream. Before recording starts this just means "not live yet";
|
||||
/// once recording has started it means the broadcast ended (possibly before ValidTo).
|
||||
/// </summary>
|
||||
private void MarkStreamUnavailable(RecordingEntry entry)
|
||||
{
|
||||
if (!entry.RecordingStartedAt.HasValue)
|
||||
{
|
||||
entry.State = RecordingState.WaitingForStream;
|
||||
return;
|
||||
}
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
entry.StreamLostAt ??= now;
|
||||
|
||||
if (now - entry.StreamLostAt.Value >= StreamLostGracePeriod)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Stream for '{Title}' has been gone since {LostAt}, broadcast has ended - completing recording",
|
||||
entry.Title,
|
||||
entry.StreamLostAt.Value);
|
||||
StopFfmpeg(entry.Id);
|
||||
CompleteRecording(entry, now);
|
||||
return;
|
||||
}
|
||||
|
||||
entry.State = RecordingState.WaitingForStream;
|
||||
}
|
||||
|
||||
private static void CompleteRecording(RecordingEntry entry, DateTime now)
|
||||
{
|
||||
entry.State = RecordingState.Completed;
|
||||
entry.RecordingEndedAt = now;
|
||||
if (entry.OutputPath != null && File.Exists(entry.OutputPath))
|
||||
{
|
||||
entry.FileSizeBytes = new FileInfo(entry.OutputPath).Length;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the DVR window start (unix seconds in the start= query parameter) from a stream URL.
|
||||
/// </summary>
|
||||
private static DateTime? GetDvrWindowStart(string streamUrl)
|
||||
{
|
||||
if (!Uri.TryCreate(streamUrl, UriKind.Absolute, out var uri))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var start = System.Web.HttpUtility.ParseQueryString(uri.Query)["start"];
|
||||
return long.TryParse(start, NumberStyles.Integer, CultureInfo.InvariantCulture, out var seconds)
|
||||
? DateTimeOffset.FromUnixTimeSeconds(seconds).UtcDateTime
|
||||
: null;
|
||||
}
|
||||
|
||||
private void StartFfmpeg(string recordingId, string inputUrl, string outputPath)
|
||||
{
|
||||
var process = new Process
|
||||
@@ -480,13 +587,16 @@ public class RecordingService : IRecordingService, IDisposable
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = _mediaEncoder.EncoderPath,
|
||||
Arguments = $"-y -i \"{inputUrl}\" -c copy -movflags +faststart \"{outputPath}\"",
|
||||
// MPEG-TS on stdout: it has no header/index to finalise, so the file is playable while it
|
||||
// grows, and we append it ourselves so restarts continue the same file. -copyts keeps the
|
||||
// broadcast timestamps so an appended restart continues the timeline instead of resetting to 0.
|
||||
Arguments = $"-i \"{inputUrl}\" -c copy -copyts -f mpegts pipe:1",
|
||||
UseShellExecute = false,
|
||||
RedirectStandardInput = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true
|
||||
},
|
||||
EnableRaisingEvents = true
|
||||
}
|
||||
};
|
||||
|
||||
process.ErrorDataReceived += (_, args) =>
|
||||
@@ -497,23 +607,49 @@ public class RecordingService : IRecordingService, IDisposable
|
||||
}
|
||||
};
|
||||
|
||||
process.Exited += (_, _) =>
|
||||
{
|
||||
_logger.LogInformation("ffmpeg process exited for recording {RecordingId} with code {ExitCode}", recordingId, process.ExitCode);
|
||||
_activeProcesses.TryRemove(recordingId, out _);
|
||||
};
|
||||
|
||||
process.Start();
|
||||
process.BeginErrorReadLine();
|
||||
|
||||
_activeProcesses[recordingId] = process;
|
||||
_logger.LogInformation("Started ffmpeg (PID {Pid}) for recording {RecordingId}: {Args}", process.Id, recordingId, process.StartInfo.Arguments);
|
||||
var active = new ActiveRecording(process);
|
||||
_activeProcesses[recordingId] = active;
|
||||
active.Pump = PumpToFileAsync(recordingId, active, outputPath);
|
||||
|
||||
_logger.LogInformation("Started ffmpeg (PID {Pid}) for recording {RecordingId} -> {OutputPath}: {Args}", process.Id, recordingId, outputPath, process.StartInfo.Arguments);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Appends ffmpeg's stdout to the recording file. The recording counts as active until
|
||||
/// ffmpeg has exited and all of its output is on disk, so a restart never interleaves writes.
|
||||
/// </summary>
|
||||
private async Task PumpToFileAsync(string recordingId, ActiveRecording active, string outputPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var output = new FileStream(outputPath, FileMode.Append, FileAccess.Write, FileShare.ReadWrite | FileShare.Delete);
|
||||
await using (output.ConfigureAwait(false))
|
||||
{
|
||||
await active.Process.StandardOutput.BaseStream.CopyToAsync(output).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await active.Process.WaitForExitAsync().ConfigureAwait(false);
|
||||
_logger.LogInformation("ffmpeg process exited for recording {RecordingId} with code {ExitCode}", recordingId, active.Process.ExitCode);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error writing recording output for {RecordingId}", recordingId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Only remove our own entry; a newer process may already be registered
|
||||
_activeProcesses.TryRemove(new KeyValuePair<string, ActiveRecording>(recordingId, active));
|
||||
}
|
||||
}
|
||||
|
||||
private void StopFfmpeg(string recordingId)
|
||||
{
|
||||
if (_activeProcesses.TryRemove(recordingId, out var process))
|
||||
if (_activeProcesses.TryRemove(recordingId, out var active))
|
||||
{
|
||||
var process = active.Process;
|
||||
try
|
||||
{
|
||||
if (!process.HasExited)
|
||||
@@ -529,6 +665,8 @@ public class RecordingService : IRecordingService, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
// Let the remaining output reach the file before releasing the process
|
||||
active.Pump?.Wait(TimeSpan.FromSeconds(10));
|
||||
process.Dispose();
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -578,4 +716,19 @@ public class RecordingService : IRecordingService, IDisposable
|
||||
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A running ffmpeg process and the task copying its output to disk.
|
||||
/// </summary>
|
||||
private sealed class ActiveRecording
|
||||
{
|
||||
public ActiveRecording(Process process)
|
||||
{
|
||||
Process = process;
|
||||
}
|
||||
|
||||
public Process Process { get; }
|
||||
|
||||
public Task? Pump { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,11 @@ using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
#if JELLYFIN_10_11_OR_GREATER
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
#else
|
||||
using Jellyfin.Data.Entities;
|
||||
#endif
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Plugin.SRFPlay.Api.Models;
|
||||
using Jellyfin.Plugin.SRFPlay.Configuration;
|
||||
@@ -71,7 +75,11 @@ public class ResumeCleanupService : IResumeCleanupService
|
||||
|
||||
var channelIds = GetPluginChannelIds();
|
||||
|
||||
#if JELLYFIN_12_OR_GREATER
|
||||
foreach (var user in _userManager.GetUsers())
|
||||
#else
|
||||
foreach (var user in _userManager.Users)
|
||||
#endif
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
result.UsersChecked++;
|
||||
|
||||
@@ -132,10 +132,13 @@ public class StreamProxyService : IStreamProxyService
|
||||
!string.IsNullOrEmpty(streamInfo.AuthenticatedUrl));
|
||||
|
||||
// Check for stale alias: only look for fresher stream if current token is EXPIRED or EXPIRING SOON
|
||||
// Don't replace a valid token (>5s left) with a new deferred registration
|
||||
if (!streamInfo.NeedsAuthentication && tokenTimeLeft < 5)
|
||||
// Don't replace a valid token (>5s left) with a new deferred registration.
|
||||
// Livestreams with a URN refresh themselves from the API, so never swap them: an unrelated
|
||||
// registration (e.g. from a library refresh) would point the stream at the wrong CDN path.
|
||||
var selfRefreshing = streamInfo.IsLiveStream && !string.IsNullOrEmpty(streamInfo.Urn);
|
||||
if (!streamInfo.NeedsAuthentication && tokenTimeLeft < 5 && !selfRefreshing)
|
||||
{
|
||||
var freshStream = FindFreshestStream();
|
||||
var freshStream = FindFreshestStream(streamInfo.Urn);
|
||||
if (freshStream != null && freshStream.Value.Value.NeedsAuthentication)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
@@ -595,8 +598,9 @@ public class StreamProxyService : IStreamProxyService
|
||||
/// <summary>
|
||||
/// Finds the freshest (most recently registered) stream that needs authentication or has a valid token.
|
||||
/// </summary>
|
||||
/// <param name="urn">When set, only streams for this URN are considered.</param>
|
||||
/// <returns>The freshest stream entry, or null if none found.</returns>
|
||||
private KeyValuePair<string, StreamInfo>? FindFreshestStream()
|
||||
private KeyValuePair<string, StreamInfo>? FindFreshestStream(string? urn)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
@@ -604,6 +608,11 @@ public class StreamProxyService : IStreamProxyService
|
||||
// or have tokens that aren't expired yet
|
||||
var candidates = _streamMappings.Where(kvp =>
|
||||
{
|
||||
if (!string.IsNullOrEmpty(urn) && !string.Equals(kvp.Value.Urn, urn, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false; // Different content
|
||||
}
|
||||
|
||||
if (kvp.Value.NeedsAuthentication)
|
||||
{
|
||||
return true; // Fresh deferred registration
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using System;
|
||||
using MediaBrowser.Model.Tasks;
|
||||
|
||||
namespace Jellyfin.Plugin.SRFPlay.Utilities;
|
||||
|
||||
/// <summary>
|
||||
/// Builds scheduled task triggers across Jellyfin versions.
|
||||
/// Jellyfin 10.11 replaced the string constants on <see cref="TaskTriggerInfo"/> with the
|
||||
/// <c>TaskTriggerInfoType</c> enum. A task whose GetDefaultTriggers throws is silently given a
|
||||
/// 24h fallback trigger, so this must be built against the matching Jellyfin version.
|
||||
/// </summary>
|
||||
public static class TaskTriggers
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a trigger that fires repeatedly at a fixed interval.
|
||||
/// </summary>
|
||||
/// <param name="interval">The interval between runs.</param>
|
||||
/// <returns>The trigger.</returns>
|
||||
public static TaskTriggerInfo Interval(TimeSpan interval)
|
||||
{
|
||||
return new TaskTriggerInfo
|
||||
{
|
||||
#if JELLYFIN_10_11_OR_GREATER
|
||||
Type = TaskTriggerInfoType.IntervalTrigger,
|
||||
#else
|
||||
Type = TaskTriggerInfo.TriggerInterval,
|
||||
#endif
|
||||
IntervalTicks = interval.Ticks
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a trigger that fires once a day at the given time.
|
||||
/// </summary>
|
||||
/// <param name="timeOfDay">The time of day to run at.</param>
|
||||
/// <returns>The trigger.</returns>
|
||||
public static TaskTriggerInfo Daily(TimeSpan timeOfDay)
|
||||
{
|
||||
return new TaskTriggerInfo
|
||||
{
|
||||
#if JELLYFIN_10_11_OR_GREATER
|
||||
Type = TaskTriggerInfoType.DailyTrigger,
|
||||
#else
|
||||
Type = TaskTriggerInfo.TriggerDaily,
|
||||
#endif
|
||||
TimeOfDayTicks = timeOfDay.Ticks
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -99,9 +99,20 @@ Examples:
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- .NET 8.0 SDK
|
||||
- .NET 10.0 SDK (builds all three targets below)
|
||||
- Jellyfin 10.9.11 or later
|
||||
|
||||
The plugin is built once per Jellyfin generation, because 10.11 changed APIs in ways that
|
||||
compile but fail at runtime. Installing from the repository picks the right one automatically.
|
||||
|
||||
| Jellyfin | Target framework | targetAbi |
|
||||
|---|---|---|
|
||||
| 10.9, 10.10 | net8.0 | 10.9.0.0 |
|
||||
| 10.11 | net9.0 | 10.11.0.0 |
|
||||
| 12 | net10.0 | 12.0.0.0 |
|
||||
|
||||
`.gitea/scripts/build-plugins.sh` builds all three zips the way CI does.
|
||||
|
||||
### Build Steps
|
||||
|
||||
```bash
|
||||
@@ -111,7 +122,7 @@ dotnet build
|
||||
|
||||
### Output
|
||||
|
||||
The compiled plugin will be in `bin/Debug/net8.0/`
|
||||
The compiled plugins will be in `bin/Debug/net8.0/`, `bin/Debug/net9.0/` and `bin/Debug/net10.0/`
|
||||
|
||||
## Manual Installation
|
||||
|
||||
|
||||
@@ -100,6 +100,8 @@
|
||||
<Rule Id="CA1308" Action="None" />
|
||||
<!-- disable warning CA1848: Use the LoggerMessage delegates -->
|
||||
<Rule Id="CA1848" Action="None" />
|
||||
<!-- disable warning CA1873: Avoid potentially expensive logging (new in the .NET 10 analyzers; same family as CA1848) -->
|
||||
<Rule Id="CA1873" Action="None" />
|
||||
<!-- disable warning CA2101: Specify marshaling for P/Invoke string arguments -->
|
||||
<Rule Id="CA2101" Action="None" />
|
||||
<!-- disable warning CA2234: Pass System.Uri objects instead of strings -->
|
||||
|
||||
@@ -8,6 +8,94 @@
|
||||
"category": "Live TV",
|
||||
"imageUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/raw/branch/master/assests/main%20logo.png",
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.0.20260925.21912",
|
||||
"changelog": "Nightly build 1.0.20260925.21912 (95ce4ed)",
|
||||
"targetAbi": "12.0.0.0",
|
||||
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/latest/srfplay_1.0.20260925.21912.zip",
|
||||
"checksum": "2256a19f4089e3958821124cc06952f3",
|
||||
"timestamp": "2026-09-25T23:30:39Z"
|
||||
},
|
||||
{
|
||||
"version": "1.0.20260925.21911",
|
||||
"changelog": "Nightly build 1.0.20260925.21911 (95ce4ed)",
|
||||
"targetAbi": "10.11.0.0",
|
||||
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/latest/srfplay_1.0.20260925.21911.zip",
|
||||
"checksum": "488443d6c721244594be8f1cada52a6f",
|
||||
"timestamp": "2026-09-25T23:30:39Z"
|
||||
},
|
||||
{
|
||||
"version": "1.0.20260925.21909",
|
||||
"changelog": "Nightly build 1.0.20260925.21909 (95ce4ed)",
|
||||
"targetAbi": "10.9.0.0",
|
||||
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/latest/srfplay_1.0.20260925.21909.zip",
|
||||
"checksum": "1b1bcaea6e46b3ecf71912fa6d07d985",
|
||||
"timestamp": "2026-09-25T23:30:39Z"
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"changelog": "Nightly build 1.0.20260908.201 (d0d5d06)",
|
||||
"targetAbi": "10.9.0.0",
|
||||
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/latest/srfplay_1.0.20260908.201.zip",
|
||||
"checksum": "e1c9ed45efbbe7c889f2158fe7c1c537",
|
||||
"timestamp": "2026-09-08T17:28:03Z"
|
||||
},
|
||||
{
|
||||
"version": "1.0.20260903.198",
|
||||
"changelog": "Nightly build 1.0.20260903.198 (699b543)",
|
||||
"targetAbi": "10.9.0.0",
|
||||
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/latest/srfplay_1.0.20260903.198.zip",
|
||||
"checksum": "fcd415e82c95a6fe1e34db1d187a684f",
|
||||
"timestamp": "2026-09-03T19:09:41Z"
|
||||
},
|
||||
{
|
||||
"version": "1.0.20260627.189",
|
||||
"changelog": "Nightly build 1.0.20260627.189 (7c81ef9)",
|
||||
|
||||
@@ -8,6 +8,70 @@
|
||||
"category": "Live TV",
|
||||
"imageUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/raw/branch/master/assests/main%20logo.png",
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.3.0.12",
|
||||
"changelog": "Release 1.3.0",
|
||||
"targetAbi": "12.0.0.0",
|
||||
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.3.0/srfplay_1.3.0.12.zip",
|
||||
"checksum": "117970ad31cb34b6cccaf90262c51b4b",
|
||||
"timestamp": "2026-09-25T23:32:44Z"
|
||||
},
|
||||
{
|
||||
"version": "1.3.0.11",
|
||||
"changelog": "Release 1.3.0",
|
||||
"targetAbi": "10.11.0.0",
|
||||
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.3.0/srfplay_1.3.0.11.zip",
|
||||
"checksum": "2136a102d793b64561656d0c0b04dd6f",
|
||||
"timestamp": "2026-09-25T23:32:44Z"
|
||||
},
|
||||
{
|
||||
"version": "1.3.0.9",
|
||||
"changelog": "Release 1.3.0",
|
||||
"targetAbi": "10.9.0.0",
|
||||
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.3.0/srfplay_1.3.0.9.zip",
|
||||
"checksum": "47008ac9ba658868bb8fc3f56c8c7e02",
|
||||
"timestamp": "2026-09-25T23:32:44Z"
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"changelog": "Release 1.1.2",
|
||||
"targetAbi": "10.9.0.0",
|
||||
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.1.2/srfplay_1.1.2.0.zip",
|
||||
"checksum": "09b96308e9796bf25d681da29d10bfed",
|
||||
"timestamp": "2026-09-08T17:31:00Z"
|
||||
},
|
||||
{
|
||||
"version": "1.1.1",
|
||||
"changelog": "Release 1.1.1",
|
||||
"targetAbi": "10.9.0.0",
|
||||
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.1.1/srfplay_1.1.1.0.zip",
|
||||
"checksum": "4354a495d2d24ab2313c96bb5ea837bb",
|
||||
"timestamp": "2026-08-12T16:59:30Z"
|
||||
},
|
||||
{
|
||||
"version": "1.1.0",
|
||||
"changelog": "Release 1.1.0",
|
||||
|
||||
Executable
+106
@@ -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"
|
||||
Reference in New Issue
Block a user