Compare commits
43
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71a65aab6e | ||
|
|
c6a1fd9f63 | ||
|
|
fb32d5c5e2 | ||
|
|
95ce4edf9b | ||
|
|
536fef85a8 | ||
|
|
3c321b4036 | ||
|
|
6822ea7202 | ||
|
|
8a5223b960 | ||
|
|
db9461cf59 | ||
|
|
b0c067ed41 | ||
|
|
582c94f5f9 | ||
|
|
dc2dd09e49 | ||
|
|
3cbbd20e71 | ||
|
|
d0d5d06a5e | ||
|
|
97b644a3b4 | ||
|
|
699b5432b9 | ||
|
|
7e1973aca7 | ||
|
|
47bf506dc6 | ||
|
|
390146e8d4 | ||
|
|
a9db0aa09c | ||
|
|
7c81ef94ca | ||
|
|
f715130302 | ||
|
|
affc87bd38 | ||
|
|
1101385107 | ||
|
|
4fc79f39f7 | ||
|
|
5875f81b9b | ||
|
|
cedef6d6aa | ||
|
|
4b5d7e2a7f | ||
|
|
8281960f0b | ||
|
|
7a719ee4ac | ||
|
|
5667c2e12f | ||
|
|
fef1bd6b17 | ||
|
|
763217c43d | ||
|
|
c2feaae89c | ||
|
|
35b103c056 | ||
|
|
7af1d9b8f4 | ||
|
|
10a76f8d45 | ||
|
|
a39a10636b | ||
|
|
029fb78049 | ||
|
|
729f82b26d | ||
|
|
aad0c21d4b | ||
|
|
7a52b3da3b | ||
|
|
80598ea8cb |
@@ -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
|
||||||
+33
-14
@@ -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
|
||||||
|
|
||||||
@@ -25,10 +25,36 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
path: build-${{ github.run_id }}
|
path: build-${{ github.run_id }}
|
||||||
|
|
||||||
|
- name: Cache NuGet packages
|
||||||
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: ~/.nuget/packages
|
||||||
|
key: nuget-${{ hashFiles('**/Jellyfin.Plugin.SRFPlay.csproj', '**/Jellyfin.Plugin.SRFPlay.Tests.csproj') }}
|
||||||
|
restore-keys: nuget-
|
||||||
|
|
||||||
- name: Restore dependencies
|
- name: Restore dependencies
|
||||||
working-directory: build-${{ github.run_id }}
|
working-directory: build-${{ github.run_id }}
|
||||||
run: dotnet restore Jellyfin.Plugin.SRFPlay.sln
|
run: dotnet restore Jellyfin.Plugin.SRFPlay.sln
|
||||||
|
|
||||||
|
- name: Compute build version
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
# For PRs, stamp a distinct version so a side-loaded build is
|
||||||
|
# identifiable in Jellyfin: 1.0.<YYYYMMDD>-pr<N>.<run_number>.
|
||||||
|
# For plain master pushes, keep a date-based dev version.
|
||||||
|
DATE=$(date -u +"%Y%m%d")
|
||||||
|
if [ -n "${{ github.event.pull_request.number }}" ]; then
|
||||||
|
VERSION="1.0.${DATE}.${{ github.run_number }}"
|
||||||
|
LABEL="pr${{ github.event.pull_request.number }}"
|
||||||
|
else
|
||||||
|
VERSION="1.0.${DATE}.${{ github.run_number }}"
|
||||||
|
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: Build solution
|
- name: Build solution
|
||||||
working-directory: build-${{ github.run_id }}
|
working-directory: build-${{ github.run_id }}
|
||||||
run: dotnet build Jellyfin.Plugin.SRFPlay.sln --configuration Release --no-restore --no-self-contained /m:1
|
run: dotnet build Jellyfin.Plugin.SRFPlay.sln --configuration Release --no-restore --no-self-contained /m:1
|
||||||
@@ -37,23 +63,16 @@ jobs:
|
|||||||
working-directory: build-${{ github.run_id }}
|
working-directory: build-${{ github.run_id }}
|
||||||
run: dotnet test Jellyfin.Plugin.SRFPlay.sln --no-build --configuration Release --verbosity normal
|
run: dotnet test Jellyfin.Plugin.SRFPlay.sln --no-build --configuration Release --verbosity normal
|
||||||
|
|
||||||
- name: Build Jellyfin Plugin
|
- name: Build Jellyfin Plugins
|
||||||
id: jprm
|
|
||||||
working-directory: build-${{ github.run_id }}
|
working-directory: build-${{ github.run_id }}
|
||||||
run: |
|
# One zip per Jellyfin generation (see .gitea/scripts/build-plugins.sh).
|
||||||
mkdir -p artifacts
|
run: .gitea/scripts/build-plugins.sh "${{ steps.version.outputs.format }}"
|
||||||
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}"
|
|
||||||
|
|
||||||
- name: Upload build artifact
|
- name: Upload build artifacts
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: jellyfin-srfplay-plugin
|
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
|
retention-days: 30
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
name: 'Latest Release'
|
name: 'Nightly Build'
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -7,10 +7,11 @@ on:
|
|||||||
paths-ignore:
|
paths-ignore:
|
||||||
- '**/*.md'
|
- '**/*.md'
|
||||||
- 'manifest.json'
|
- 'manifest.json'
|
||||||
|
- 'manifest-nightly.json'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
latest-release:
|
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
|
||||||
|
|
||||||
@@ -20,6 +21,21 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
path: build-${{ github.run_id }}
|
path: build-${{ github.run_id }}
|
||||||
|
|
||||||
|
- name: Compute nightly version
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
# 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: Restore dependencies
|
- name: Restore dependencies
|
||||||
working-directory: build-${{ github.run_id }}
|
working-directory: build-${{ github.run_id }}
|
||||||
run: dotnet restore Jellyfin.Plugin.SRFPlay.sln
|
run: dotnet restore Jellyfin.Plugin.SRFPlay.sln
|
||||||
@@ -32,27 +48,11 @@ jobs:
|
|||||||
working-directory: build-${{ github.run_id }}
|
working-directory: build-${{ github.run_id }}
|
||||||
run: dotnet test Jellyfin.Plugin.SRFPlay.sln --no-build --configuration Release --verbosity normal
|
run: dotnet test Jellyfin.Plugin.SRFPlay.sln --no-build --configuration Release --verbosity normal
|
||||||
|
|
||||||
- name: Build Jellyfin Plugin
|
- name: Build Jellyfin Plugins
|
||||||
id: jprm
|
|
||||||
working-directory: build-${{ github.run_id }}
|
working-directory: build-${{ github.run_id }}
|
||||||
run: |
|
run: .gitea/scripts/build-plugins.sh "${{ steps.version.outputs.format }}"
|
||||||
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
|
- name: Delete existing nightly release
|
||||||
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}"
|
|
||||||
|
|
||||||
- name: Delete existing latest release
|
|
||||||
working-directory: build-${{ github.run_id }}
|
working-directory: build-${{ github.run_id }}
|
||||||
env:
|
env:
|
||||||
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -71,7 +71,7 @@ jobs:
|
|||||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/tags/${TAG}" | jq -r '.id')
|
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/tags/${TAG}" | jq -r '.id')
|
||||||
|
|
||||||
echo "Deleting existing latest release (ID: ${RELEASE_ID})..."
|
echo "Deleting existing nightly release (ID: ${RELEASE_ID})..."
|
||||||
curl -s -X DELETE \
|
curl -s -X DELETE \
|
||||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}"
|
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}"
|
||||||
@@ -81,7 +81,7 @@ jobs:
|
|||||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/tags/${TAG}" || true
|
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/tags/${TAG}" || true
|
||||||
|
|
||||||
- name: Create latest release
|
- name: Create nightly release
|
||||||
working-directory: build-${{ github.run_id }}
|
working-directory: build-${{ github.run_id }}
|
||||||
env:
|
env:
|
||||||
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -89,12 +89,14 @@ jobs:
|
|||||||
REPO_OWNER="${{ github.repository_owner }}"
|
REPO_OWNER="${{ github.repository_owner }}"
|
||||||
REPO_NAME="${{ github.event.repository.name }}"
|
REPO_NAME="${{ github.event.repository.name }}"
|
||||||
GITEA_URL="${{ github.server_url }}"
|
GITEA_URL="${{ github.server_url }}"
|
||||||
|
VERSION="${{ steps.version.outputs.version }}"
|
||||||
|
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
|
||||||
|
|
||||||
RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \
|
RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \
|
||||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases" \
|
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases" \
|
||||||
-d "$(jq -n --arg tag "latest" --arg name "Latest Build" --arg body "SRFPlay Jellyfin Plugin latest build from master." '{tag_name: $tag, name: $name, body: $body, target_commitish: "master", draft: false, prerelease: true}')")
|
-d "$(jq -n --arg tag "latest" --arg name "Nightly Build ${VERSION}" --arg body "SRFPlay Jellyfin Plugin nightly build ${VERSION} from master (${SHORT_SHA})." '{tag_name: $tag, name: $name, body: $body, target_commitish: "master", draft: false, prerelease: true}')")
|
||||||
|
|
||||||
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
|
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
|
||||||
BODY=$(echo "$RESPONSE" | sed '$d')
|
BODY=$(echo "$RESPONSE" | sed '$d')
|
||||||
@@ -108,13 +110,15 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Upload plugin artifact
|
# Upload one plugin zip per Jellyfin generation
|
||||||
echo "Uploading plugin artifact..."
|
cut -f3 artifacts/builds.tsv | while read -r ZIP; do
|
||||||
curl -f -X POST \
|
echo "Uploading ${ZIP}..."
|
||||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
curl -f -X POST \
|
||||||
-H "Content-Type: application/zip" \
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
--data-binary "@${{ steps.jprm.outputs.artifact }}" \
|
-H "Content-Type: application/zip" \
|
||||||
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}/assets?name=${{ steps.jprm.outputs.artifact_name }}"
|
--data-binary "@${ZIP}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}/assets?name=$(basename "${ZIP}")"
|
||||||
|
done
|
||||||
|
|
||||||
# Upload build.yaml
|
# Upload build.yaml
|
||||||
echo "Uploading build.yaml..."
|
echo "Uploading build.yaml..."
|
||||||
@@ -124,9 +128,9 @@ jobs:
|
|||||||
--data-binary "@build.yaml" \
|
--data-binary "@build.yaml" \
|
||||||
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}/assets?name=build.yaml"
|
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}/assets?name=build.yaml"
|
||||||
|
|
||||||
echo "Latest release updated successfully!"
|
echo "Nightly release updated successfully!"
|
||||||
|
|
||||||
- name: Update manifest.json
|
- name: Update manifest-nightly.json
|
||||||
working-directory: build-${{ github.run_id }}
|
working-directory: build-${{ github.run_id }}
|
||||||
env:
|
env:
|
||||||
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -134,35 +138,45 @@ jobs:
|
|||||||
REPO_OWNER="${{ github.repository_owner }}"
|
REPO_OWNER="${{ github.repository_owner }}"
|
||||||
REPO_NAME="${{ github.event.repository.name }}"
|
REPO_NAME="${{ github.event.repository.name }}"
|
||||||
GITEA_URL="${{ github.server_url }}"
|
GITEA_URL="${{ github.server_url }}"
|
||||||
CHECKSUM="${{ steps.checksum.outputs.checksum }}"
|
|
||||||
ARTIFACT_NAME="${{ steps.jprm.outputs.artifact_name }}"
|
|
||||||
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||||
DOWNLOAD_URL="${GITEA_URL}/${REPO_OWNER}/${REPO_NAME}/releases/download/latest/${ARTIFACT_NAME}"
|
|
||||||
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
|
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.name "Gitea Actions"
|
||||||
git config user.email "actions@gitea.tourolle.paris"
|
git config user.email "actions@gitea.tourolle.paris"
|
||||||
git fetch origin master
|
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
|
||||||
|
|
||||||
# Remove existing "latest" entry if present, then prepend new one
|
# One entry per Jellyfin generation; each server installs the newest
|
||||||
jq --arg url "$DOWNLOAD_URL" 'if .[0].versions[0].changelog == "Latest Build" then .[0].versions = .[0].versions[1:] else . end' manifest.json > manifest.tmp && mv manifest.tmp manifest.json
|
# 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')
|
||||||
|
|
||||||
NEW_VERSION=$(cat <<EOF
|
# Prepend the new builds and keep only the most recent 5 nightlies
|
||||||
{
|
# (3 entries each, one per Jellyfin generation).
|
||||||
"version": "0.0.0.0",
|
jq --argjson newvers "${NEW_VERSIONS}" \
|
||||||
"changelog": "Latest Build",
|
'.[0].versions = ($newvers + .[0].versions)[0:15]' \
|
||||||
"targetAbi": "10.9.0.0",
|
manifest-nightly.json > manifest.tmp && mv manifest.tmp manifest-nightly.json
|
||||||
"sourceUrl": "${DOWNLOAD_URL}",
|
|
||||||
"checksum": "${CHECKSUM}",
|
|
||||||
"timestamp": "${TIMESTAMP}"
|
|
||||||
}
|
|
||||||
EOF
|
|
||||||
)
|
|
||||||
|
|
||||||
jq --argjson newver "${NEW_VERSION}" '.[0].versions = [$newver] + .[0].versions' manifest.json > manifest.tmp && mv manifest.tmp manifest.json
|
git add manifest-nightly.json
|
||||||
git add manifest.json
|
git commit -m "Nightly: ${{ steps.version.outputs.version }} (${SHORT_SHA})" || echo "No changes to commit"
|
||||||
git commit -m "Update manifest.json for latest build (${SHORT_SHA})" || echo "No changes to commit"
|
|
||||||
git push origin master
|
git push origin master
|
||||||
|
|
||||||
- name: Cleanup
|
- name: Cleanup
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -35,12 +35,12 @@ jobs:
|
|||||||
echo "version_number=${VERSION#v}" >> $GITHUB_OUTPUT
|
echo "version_number=${VERSION#v}" >> $GITHUB_OUTPUT
|
||||||
echo "Building version: ${VERSION}"
|
echo "Building version: ${VERSION}"
|
||||||
|
|
||||||
- name: Update build.yaml with version
|
- name: Cache NuGet packages
|
||||||
working-directory: release-${{ github.run_id }}
|
uses: actions/cache@v3
|
||||||
run: |
|
with:
|
||||||
VERSION="${{ steps.get_version.outputs.version_number }}"
|
path: ~/.nuget/packages
|
||||||
sed -i "s/^version:.*/version: \"${VERSION}\"/" build.yaml
|
key: nuget-${{ hashFiles('**/Jellyfin.Plugin.SRFPlay.csproj', '**/Jellyfin.Plugin.SRFPlay.Tests.csproj') }}
|
||||||
cat build.yaml
|
restore-keys: nuget-
|
||||||
|
|
||||||
- name: Restore dependencies
|
- name: Restore dependencies
|
||||||
working-directory: release-${{ github.run_id }}
|
working-directory: release-${{ github.run_id }}
|
||||||
@@ -54,25 +54,11 @@ jobs:
|
|||||||
working-directory: release-${{ github.run_id }}
|
working-directory: release-${{ github.run_id }}
|
||||||
run: dotnet test Jellyfin.Plugin.SRFPlay.sln --no-build --configuration Release --verbosity normal
|
run: dotnet test Jellyfin.Plugin.SRFPlay.sln --no-build --configuration Release --verbosity normal
|
||||||
|
|
||||||
- name: Build Jellyfin Plugin
|
- name: Build Jellyfin Plugins
|
||||||
id: jprm
|
|
||||||
working-directory: release-${{ github.run_id }}
|
working-directory: release-${{ github.run_id }}
|
||||||
run: |
|
# v1.2.0 -> 1.2.0.9 / 1.2.0.11 / 1.2.0.12, one per Jellyfin generation
|
||||||
mkdir -p artifacts
|
# (see .gitea/scripts/build-plugins.sh).
|
||||||
jprm --verbosity=debug plugin build ./
|
run: .gitea/scripts/build-plugins.sh "${{ steps.get_version.outputs.version_number }}.%d"
|
||||||
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}"
|
|
||||||
|
|
||||||
- name: Create Release
|
- name: Create Release
|
||||||
working-directory: release-${{ github.run_id }}
|
working-directory: release-${{ github.run_id }}
|
||||||
@@ -104,13 +90,15 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Upload plugin artifact
|
# Upload one plugin zip per Jellyfin generation
|
||||||
echo "Uploading plugin artifact..."
|
cut -f3 artifacts/builds.tsv | while read -r ZIP; do
|
||||||
curl -f -X POST \
|
echo "Uploading ${ZIP}..."
|
||||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
curl -f -X POST \
|
||||||
-H "Content-Type: application/zip" \
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
--data-binary "@${{ steps.jprm.outputs.artifact }}" \
|
-H "Content-Type: application/zip" \
|
||||||
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}/assets?name=${{ steps.jprm.outputs.artifact_name }}"
|
--data-binary "@${ZIP}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}/assets?name=$(basename "${ZIP}")"
|
||||||
|
done
|
||||||
|
|
||||||
# Upload build.yaml
|
# Upload build.yaml
|
||||||
echo "Uploading build.yaml..."
|
echo "Uploading build.yaml..."
|
||||||
@@ -132,29 +120,28 @@ jobs:
|
|||||||
REPO_NAME="${{ github.event.repository.name }}"
|
REPO_NAME="${{ github.event.repository.name }}"
|
||||||
GITEA_URL="${{ github.server_url }}"
|
GITEA_URL="${{ github.server_url }}"
|
||||||
VERSION="${{ steps.get_version.outputs.version_number }}"
|
VERSION="${{ steps.get_version.outputs.version_number }}"
|
||||||
CHECKSUM="${{ steps.checksum.outputs.checksum }}"
|
|
||||||
ARTIFACT_NAME="${{ steps.jprm.outputs.artifact_name }}"
|
|
||||||
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||||
DOWNLOAD_URL="${GITEA_URL}/${REPO_OWNER}/${REPO_NAME}/releases/download/${{ steps.get_version.outputs.version }}/${ARTIFACT_NAME}"
|
# 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.name "Gitea Actions"
|
||||||
git config user.email "actions@gitea.tourolle.paris"
|
git config user.email "actions@gitea.tourolle.paris"
|
||||||
git fetch origin master
|
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
|
# One entry per Jellyfin generation; each server installs the newest
|
||||||
{
|
# entry whose targetAbi it satisfies.
|
||||||
"version": "${VERSION}",
|
NEW_VERSIONS=$(echo "${BUILDS}" | jq -R -s \
|
||||||
"changelog": "Release ${VERSION}",
|
--arg url "${GITEA_URL}/${REPO_OWNER}/${REPO_NAME}/releases/download/${{ steps.get_version.outputs.version }}" \
|
||||||
"targetAbi": "10.9.0.0",
|
--arg release "${VERSION}" --arg ts "${TIMESTAMP}" \
|
||||||
"sourceUrl": "${DOWNLOAD_URL}",
|
'split("\n") | map(select(length > 0) | split("\t")
|
||||||
"checksum": "${CHECKSUM}",
|
| {version: .[0], changelog: "Release \($release)", targetAbi: .[1],
|
||||||
"timestamp": "${TIMESTAMP}"
|
sourceUrl: "\($url)/\(.[2] | split("/") | last)", checksum: .[3], timestamp: $ts})
|
||||||
}
|
| reverse')
|
||||||
EOF
|
|
||||||
)
|
|
||||||
|
|
||||||
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 add manifest.json
|
||||||
git commit -m "Update manifest.json for version ${VERSION}"
|
git commit -m "Update manifest.json for version ${VERSION}"
|
||||||
git push origin master
|
git push origin master
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -27,6 +27,13 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
path: test-${{ github.run_id }}
|
path: test-${{ github.run_id }}
|
||||||
|
|
||||||
|
- name: Cache NuGet packages
|
||||||
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: ~/.nuget/packages
|
||||||
|
key: nuget-${{ hashFiles('**/Jellyfin.Plugin.SRFPlay.csproj', '**/Jellyfin.Plugin.SRFPlay.Tests.csproj') }}
|
||||||
|
restore-keys: nuget-
|
||||||
|
|
||||||
- name: Restore dependencies
|
- name: Restore dependencies
|
||||||
working-directory: test-${{ github.run_id }}
|
working-directory: test-${{ github.run_id }}
|
||||||
run: dotnet restore Jellyfin.Plugin.SRFPlay.sln
|
run: dotnet restore Jellyfin.Plugin.SRFPlay.sln
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ obj/
|
|||||||
.idea/
|
.idea/
|
||||||
artifacts
|
artifacts
|
||||||
*.log
|
*.log
|
||||||
|
.test-plugin/
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<Project>
|
||||||
|
<!--
|
||||||
|
The Jellyfin meta build workflow rewrites Directory.Build.props, stamping Version,
|
||||||
|
AssemblyVersion and FileVersion all to a date-based value (e.g. 1.0.20260627.182).
|
||||||
|
The build segment (20260627) exceeds the 16-bit limit (0-65535) that AssemblyVersion
|
||||||
|
and FileVersion require, which fails the compile (CS7034/CS7035).
|
||||||
|
|
||||||
|
Directory.Build.targets is imported AFTER the project (and after Directory.Build.props),
|
||||||
|
and the workflow does not touch it, so we force valid assembly/file versions here. The
|
||||||
|
package/plugin manifest Version stays as injected; only the .NET assembly identity is
|
||||||
|
normalised, which is fine because Jellyfin identifies plugins by GUID + manifest version.
|
||||||
|
-->
|
||||||
|
<PropertyGroup>
|
||||||
|
<AssemblyVersion>1.0.0.0</AssemblyVersion>
|
||||||
|
<FileVersion>1.0.0.0</FileVersion>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
+6
-3
@@ -1,9 +1,12 @@
|
|||||||
# 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:8.0
|
FROM mcr.microsoft.com/dotnet/sdk:10.0
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y \
|
RUN apt-get update && apt-get install -y \
|
||||||
python3 \
|
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>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.Extensions.Logging" Version="8.0.1" />
|
|
||||||
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="8.0.1" />
|
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="8.0.1" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<OutputType>Exe</OutputType>
|
<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>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|||||||
@@ -20,6 +20,13 @@ public class RecordingEntry
|
|||||||
[JsonPropertyName("urn")]
|
[JsonPropertyName("urn")]
|
||||||
public string Urn { get; set; } = string.Empty;
|
public string Urn { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the business unit this recording belongs to (e.g. "srf", "rts").
|
||||||
|
/// Used to show recordings under the matching unit's channel.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("businessUnit")]
|
||||||
|
public string BusinessUnit { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the title.
|
/// Gets or sets the title.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -74,6 +81,13 @@ public class RecordingEntry
|
|||||||
[JsonPropertyName("recordingEndedAt")]
|
[JsonPropertyName("recordingEndedAt")]
|
||||||
public DateTime? RecordingEndedAt { get; set; }
|
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>
|
/// <summary>
|
||||||
/// Gets or sets the file size in bytes.
|
/// Gets or sets the file size in bytes.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -91,4 +105,10 @@ public class RecordingEntry
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
[JsonPropertyName("createdAt")]
|
[JsonPropertyName("createdAt")]
|
||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.SRFPlay.Api.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Outcome of a resume point ("Continue Watching") cleanup run.
|
||||||
|
/// </summary>
|
||||||
|
public class ResumeCleanupResult
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the number of users whose resume list was inspected.
|
||||||
|
/// </summary>
|
||||||
|
public int UsersChecked { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the number of plugin-owned resume points that were inspected.
|
||||||
|
/// </summary>
|
||||||
|
public int Inspected { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the number of resume points that were cleared.
|
||||||
|
/// </summary>
|
||||||
|
public int Cleared { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the number of cleared resume points grouped by the rule that matched.
|
||||||
|
/// </summary>
|
||||||
|
public Dictionary<string, int> ClearedByReason { get; } = new Dictionary<string, int>();
|
||||||
|
}
|
||||||
@@ -20,7 +20,6 @@ namespace Jellyfin.Plugin.SRFPlay.Api;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class SRFApiClient : IDisposable
|
public class SRFApiClient : IDisposable
|
||||||
{
|
{
|
||||||
private static readonly System.Text.CompositeFormat PlayV3UrlFormat = System.Text.CompositeFormat.Parse(ApiEndpoints.PlayV3BaseUrlTemplate);
|
|
||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
private readonly HttpClient _playV3HttpClient;
|
private readonly HttpClient _playV3HttpClient;
|
||||||
private readonly ILogger _logger;
|
private readonly ILogger _logger;
|
||||||
@@ -58,6 +57,21 @@ public class SRFApiClient : IDisposable
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the Play v3 API base URL for a business unit. The host is normally
|
||||||
|
/// www.<unit>.ch, but SWI's site lives at www.swissinfo.ch (www.swi.ch is an
|
||||||
|
/// unrelated welding institute), while the API path segment stays the unit code.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="businessUnit">The lowercase business unit (e.g. "srf", "swi").</param>
|
||||||
|
/// <returns>The Play v3 production base URL ending in a slash.</returns>
|
||||||
|
private static string BuildPlayV3BaseUrl(string businessUnit)
|
||||||
|
{
|
||||||
|
var host = string.Equals(businessUnit, "swi", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? "www.swissinfo.ch"
|
||||||
|
: $"www.{businessUnit}.ch";
|
||||||
|
return $"https://{host}/play/v3/api/{businessUnit}/production/";
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Reads HTTP response content as UTF-8 string.
|
/// Reads HTTP response content as UTF-8 string.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -315,7 +329,7 @@ public class SRFApiClient : IDisposable
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var baseUrl = string.Format(CultureInfo.InvariantCulture, PlayV3UrlFormat, businessUnit);
|
var baseUrl = BuildPlayV3BaseUrl(businessUnit);
|
||||||
var url = $"{baseUrl}{endpoint}";
|
var url = $"{baseUrl}{endpoint}";
|
||||||
_logger.LogInformation("Fetching all {Endpoint} for business unit: {BusinessUnit} from URL: {Url}", endpoint, businessUnit, url);
|
_logger.LogInformation("Fetching all {Endpoint} for business unit: {BusinessUnit} from URL: {Url}", endpoint, businessUnit, url);
|
||||||
|
|
||||||
@@ -352,7 +366,7 @@ public class SRFApiClient : IDisposable
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var baseUrl = string.Format(CultureInfo.InvariantCulture, PlayV3UrlFormat, businessUnit);
|
var baseUrl = BuildPlayV3BaseUrl(businessUnit);
|
||||||
var url = $"{baseUrl}videos-by-show-id?showId={showId}";
|
var url = $"{baseUrl}videos-by-show-id?showId={showId}";
|
||||||
_logger.LogDebug("Fetching videos for show {ShowId} from business unit: {BusinessUnit}", showId, businessUnit);
|
_logger.LogDebug("Fetching videos for show {ShowId} from business unit: {BusinessUnit}", showId, businessUnit);
|
||||||
|
|
||||||
@@ -392,7 +406,7 @@ public class SRFApiClient : IDisposable
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var baseUrl = string.Format(CultureInfo.InvariantCulture, PlayV3UrlFormat, businessUnit);
|
var baseUrl = BuildPlayV3BaseUrl(businessUnit);
|
||||||
var url = $"{baseUrl}livestreams?eventType={eventType.ToUpperInvariant()}";
|
var url = $"{baseUrl}livestreams?eventType={eventType.ToUpperInvariant()}";
|
||||||
_logger.LogInformation("Fetching scheduled livestreams for eventType={EventType} from business unit: {BusinessUnit}", eventType, businessUnit);
|
_logger.LogInformation("Fetching scheduled livestreams for eventType={EventType} from business unit: {BusinessUnit}", eventType, businessUnit);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
using Jellyfin.Plugin.SRFPlay.Api;
|
||||||
|
using Jellyfin.Plugin.SRFPlay.Configuration;
|
||||||
|
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.SRFPlay.Channels;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Radiotelevisione svizzera — Swiss Italian channel.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class RsiChannel : SrgChannelBase
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="RsiChannel"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="loggerFactory">The logger factory.</param>
|
||||||
|
/// <param name="contentRefreshService">The content refresh service.</param>
|
||||||
|
/// <param name="streamResolver">The stream resolver.</param>
|
||||||
|
/// <param name="mediaSourceFactory">The media source factory.</param>
|
||||||
|
/// <param name="categoryService">The category service.</param>
|
||||||
|
/// <param name="apiClientFactory">The API client factory.</param>
|
||||||
|
/// <param name="recordingService">The recording service.</param>
|
||||||
|
public RsiChannel(
|
||||||
|
ILoggerFactory loggerFactory,
|
||||||
|
IContentRefreshService contentRefreshService,
|
||||||
|
IStreamUrlResolver streamResolver,
|
||||||
|
IMediaSourceFactory mediaSourceFactory,
|
||||||
|
ICategoryService? categoryService,
|
||||||
|
ISRFApiClientFactory apiClientFactory,
|
||||||
|
IRecordingService recordingService)
|
||||||
|
: base(loggerFactory, contentRefreshService, streamResolver, mediaSourceFactory, categoryService, apiClientFactory, recordingService)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override BusinessUnit Unit => BusinessUnit.RSI;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override string LogoResourceName => "Jellyfin.Plugin.SRFPlay.Images.rsi-logo.png";
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override string Description => "Radiotelevisione svizzera — Swiss Italian video-on-demand content";
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
using Jellyfin.Plugin.SRFPlay.Api;
|
||||||
|
using Jellyfin.Plugin.SRFPlay.Configuration;
|
||||||
|
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.SRFPlay.Channels;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Radiotelevisiun Svizra Rumantscha — Swiss Romansh channel.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class RtrChannel : SrgChannelBase
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="RtrChannel"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="loggerFactory">The logger factory.</param>
|
||||||
|
/// <param name="contentRefreshService">The content refresh service.</param>
|
||||||
|
/// <param name="streamResolver">The stream resolver.</param>
|
||||||
|
/// <param name="mediaSourceFactory">The media source factory.</param>
|
||||||
|
/// <param name="categoryService">The category service.</param>
|
||||||
|
/// <param name="apiClientFactory">The API client factory.</param>
|
||||||
|
/// <param name="recordingService">The recording service.</param>
|
||||||
|
public RtrChannel(
|
||||||
|
ILoggerFactory loggerFactory,
|
||||||
|
IContentRefreshService contentRefreshService,
|
||||||
|
IStreamUrlResolver streamResolver,
|
||||||
|
IMediaSourceFactory mediaSourceFactory,
|
||||||
|
ICategoryService? categoryService,
|
||||||
|
ISRFApiClientFactory apiClientFactory,
|
||||||
|
IRecordingService recordingService)
|
||||||
|
: base(loggerFactory, contentRefreshService, streamResolver, mediaSourceFactory, categoryService, apiClientFactory, recordingService)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override BusinessUnit Unit => BusinessUnit.RTR;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override string LogoResourceName => "Jellyfin.Plugin.SRFPlay.Images.rtr-logo.png";
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override string Description => "Radiotelevisiun Svizra Rumantscha — Swiss Romansh video-on-demand content";
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
using Jellyfin.Plugin.SRFPlay.Api;
|
||||||
|
using Jellyfin.Plugin.SRFPlay.Configuration;
|
||||||
|
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.SRFPlay.Channels;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Radio Télévision Suisse — Swiss French channel.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class RtsChannel : SrgChannelBase
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="RtsChannel"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="loggerFactory">The logger factory.</param>
|
||||||
|
/// <param name="contentRefreshService">The content refresh service.</param>
|
||||||
|
/// <param name="streamResolver">The stream resolver.</param>
|
||||||
|
/// <param name="mediaSourceFactory">The media source factory.</param>
|
||||||
|
/// <param name="categoryService">The category service.</param>
|
||||||
|
/// <param name="apiClientFactory">The API client factory.</param>
|
||||||
|
/// <param name="recordingService">The recording service.</param>
|
||||||
|
public RtsChannel(
|
||||||
|
ILoggerFactory loggerFactory,
|
||||||
|
IContentRefreshService contentRefreshService,
|
||||||
|
IStreamUrlResolver streamResolver,
|
||||||
|
IMediaSourceFactory mediaSourceFactory,
|
||||||
|
ICategoryService? categoryService,
|
||||||
|
ISRFApiClientFactory apiClientFactory,
|
||||||
|
IRecordingService recordingService)
|
||||||
|
: base(loggerFactory, contentRefreshService, streamResolver, mediaSourceFactory, categoryService, apiClientFactory, recordingService)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override BusinessUnit Unit => BusinessUnit.RTS;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override string LogoResourceName => "Jellyfin.Plugin.SRFPlay.Images.rts-logo.png";
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override string Description => "Radio Télévision Suisse — Swiss French video-on-demand content";
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
using Jellyfin.Plugin.SRFPlay.Api;
|
||||||
|
using Jellyfin.Plugin.SRFPlay.Configuration;
|
||||||
|
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.SRFPlay.Channels;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Schweizer Radio und Fernsehen — Swiss German channel.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SrfChannel : SrgChannelBase
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="SrfChannel"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="loggerFactory">The logger factory.</param>
|
||||||
|
/// <param name="contentRefreshService">The content refresh service.</param>
|
||||||
|
/// <param name="streamResolver">The stream resolver.</param>
|
||||||
|
/// <param name="mediaSourceFactory">The media source factory.</param>
|
||||||
|
/// <param name="categoryService">The category service.</param>
|
||||||
|
/// <param name="apiClientFactory">The API client factory.</param>
|
||||||
|
/// <param name="recordingService">The recording service.</param>
|
||||||
|
public SrfChannel(
|
||||||
|
ILoggerFactory loggerFactory,
|
||||||
|
IContentRefreshService contentRefreshService,
|
||||||
|
IStreamUrlResolver streamResolver,
|
||||||
|
IMediaSourceFactory mediaSourceFactory,
|
||||||
|
ICategoryService? categoryService,
|
||||||
|
ISRFApiClientFactory apiClientFactory,
|
||||||
|
IRecordingService recordingService)
|
||||||
|
: base(loggerFactory, contentRefreshService, streamResolver, mediaSourceFactory, categoryService, apiClientFactory, recordingService)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override BusinessUnit Unit => BusinessUnit.SRF;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override string LogoResourceName => "Jellyfin.Plugin.SRFPlay.Images.srf-logo.png";
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override string Description => "Schweizer Radio und Fernsehen — Swiss German video-on-demand content";
|
||||||
|
}
|
||||||
+93
-45
@@ -7,6 +7,7 @@ using System.Threading;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Jellyfin.Plugin.SRFPlay.Api;
|
using Jellyfin.Plugin.SRFPlay.Api;
|
||||||
using Jellyfin.Plugin.SRFPlay.Api.Models;
|
using Jellyfin.Plugin.SRFPlay.Api.Models;
|
||||||
|
using Jellyfin.Plugin.SRFPlay.Configuration;
|
||||||
using Jellyfin.Plugin.SRFPlay.Constants;
|
using Jellyfin.Plugin.SRFPlay.Constants;
|
||||||
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
||||||
using Jellyfin.Plugin.SRFPlay.Utilities;
|
using Jellyfin.Plugin.SRFPlay.Utilities;
|
||||||
@@ -22,11 +23,14 @@ using Microsoft.Extensions.Logging;
|
|||||||
namespace Jellyfin.Plugin.SRFPlay.Channels;
|
namespace Jellyfin.Plugin.SRFPlay.Channels;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// SRF Play channel for browsing and playing content.
|
/// Base channel for browsing and playing content from a single SRG SSR business unit.
|
||||||
|
/// One concrete subclass exists per unit (SRF, RTS, RSI, RTR, SWI), each shown as its own tile.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class SRFPlayChannel : IChannel, IHasCacheKey
|
public abstract class SrgChannelBase : IChannel, IHasCacheKey
|
||||||
{
|
{
|
||||||
private readonly ILogger<SRFPlayChannel> _logger;
|
private const string InProgressPrefix = "● REC · ";
|
||||||
|
|
||||||
|
private readonly ILogger<SrgChannelBase> _logger;
|
||||||
private readonly IContentRefreshService _contentRefreshService;
|
private readonly IContentRefreshService _contentRefreshService;
|
||||||
private readonly IStreamUrlResolver _streamResolver;
|
private readonly IStreamUrlResolver _streamResolver;
|
||||||
private readonly IMediaSourceFactory _mediaSourceFactory;
|
private readonly IMediaSourceFactory _mediaSourceFactory;
|
||||||
@@ -35,7 +39,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
|
|||||||
private readonly IRecordingService _recordingService;
|
private readonly IRecordingService _recordingService;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="SRFPlayChannel"/> class.
|
/// Initializes a new instance of the <see cref="SrgChannelBase"/> class.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="loggerFactory">The logger factory.</param>
|
/// <param name="loggerFactory">The logger factory.</param>
|
||||||
/// <param name="contentRefreshService">The content refresh service.</param>
|
/// <param name="contentRefreshService">The content refresh service.</param>
|
||||||
@@ -44,7 +48,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
|
|||||||
/// <param name="categoryService">The category service (optional).</param>
|
/// <param name="categoryService">The category service (optional).</param>
|
||||||
/// <param name="apiClientFactory">The API client factory.</param>
|
/// <param name="apiClientFactory">The API client factory.</param>
|
||||||
/// <param name="recordingService">The recording service.</param>
|
/// <param name="recordingService">The recording service.</param>
|
||||||
public SRFPlayChannel(
|
protected SrgChannelBase(
|
||||||
ILoggerFactory loggerFactory,
|
ILoggerFactory loggerFactory,
|
||||||
IContentRefreshService contentRefreshService,
|
IContentRefreshService contentRefreshService,
|
||||||
IStreamUrlResolver streamResolver,
|
IStreamUrlResolver streamResolver,
|
||||||
@@ -53,7 +57,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
|
|||||||
ISRFApiClientFactory apiClientFactory,
|
ISRFApiClientFactory apiClientFactory,
|
||||||
IRecordingService recordingService)
|
IRecordingService recordingService)
|
||||||
{
|
{
|
||||||
_logger = loggerFactory.CreateLogger<SRFPlayChannel>();
|
_logger = loggerFactory.CreateLogger<SrgChannelBase>();
|
||||||
_contentRefreshService = contentRefreshService;
|
_contentRefreshService = contentRefreshService;
|
||||||
_streamResolver = streamResolver;
|
_streamResolver = streamResolver;
|
||||||
_mediaSourceFactory = mediaSourceFactory;
|
_mediaSourceFactory = mediaSourceFactory;
|
||||||
@@ -66,14 +70,24 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
|
|||||||
_logger.LogWarning("CategoryService not available - category folders will be disabled");
|
_logger.LogWarning("CategoryService not available - category folders will be disabled");
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogDebug("SRFPlayChannel initialized");
|
_logger.LogDebug("SrgChannelBase initialized");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <summary>
|
||||||
public string Name => "SRF Play";
|
/// Gets the business unit this channel serves.
|
||||||
|
/// </summary>
|
||||||
|
protected abstract BusinessUnit Unit { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the embedded resource name of this unit's logo PNG.
|
||||||
|
/// </summary>
|
||||||
|
protected abstract string LogoResourceName { get; }
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public string Description => "Swiss Radio and Television video-on-demand content";
|
public string Name => $"{Unit} Play";
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public abstract string Description { get; }
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public string DataVersion => "2.0"; // Back to authenticating at channel refresh with auto-refresh for fresh tokens
|
public string DataVersion => "2.0"; // Back to authenticating at channel refresh with auto-refresh for fresh tokens
|
||||||
@@ -114,7 +128,8 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
|
|||||||
public Task<DynamicImageResponse> GetChannelImage(ImageType type, CancellationToken cancellationToken)
|
public Task<DynamicImageResponse> GetChannelImage(ImageType type, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var assembly = GetType().Assembly;
|
var assembly = GetType().Assembly;
|
||||||
var resourceStream = assembly.GetManifestResourceStream("Jellyfin.Plugin.SRFPlay.Images.logo.png");
|
var resourceStream = assembly.GetManifestResourceStream(LogoResourceName)
|
||||||
|
?? assembly.GetManifestResourceStream("Jellyfin.Plugin.SRFPlay.Images.logo.png");
|
||||||
|
|
||||||
if (resourceStream == null)
|
if (resourceStream == null)
|
||||||
{
|
{
|
||||||
@@ -198,7 +213,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var businessUnit = config.BusinessUnit.ToLowerString();
|
var businessUnit = Unit.ToLowerString();
|
||||||
var topics = await _categoryService.GetTopicsAsync(businessUnit, cancellationToken).ConfigureAwait(false);
|
var topics = await _categoryService.GetTopicsAsync(businessUnit, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
foreach (var topic in topics.Where(t => !string.IsNullOrEmpty(t.Id)))
|
foreach (var topic in topics.Where(t => !string.IsNullOrEmpty(t.Id)))
|
||||||
@@ -239,24 +254,23 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
|
|||||||
|
|
||||||
private async Task<List<ChannelItemInfo>> GetLatestVideosAsync(CancellationToken cancellationToken)
|
private async Task<List<ChannelItemInfo>> GetLatestVideosAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var urns = await _contentRefreshService.RefreshLatestContentAsync(cancellationToken).ConfigureAwait(false);
|
var urns = await _contentRefreshService.RefreshLatestContentAsync(Unit.ToLowerString(), cancellationToken).ConfigureAwait(false);
|
||||||
return await ConvertUrnsToChannelItems(urns, cancellationToken).ConfigureAwait(false);
|
return await ConvertUrnsToChannelItems(urns, cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<List<ChannelItemInfo>> GetTrendingVideosAsync(CancellationToken cancellationToken)
|
private async Task<List<ChannelItemInfo>> GetTrendingVideosAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var urns = await _contentRefreshService.RefreshTrendingContentAsync(cancellationToken).ConfigureAwait(false);
|
var urns = await _contentRefreshService.RefreshTrendingContentAsync(Unit.ToLowerString(), cancellationToken).ConfigureAwait(false);
|
||||||
return await ConvertUrnsToChannelItems(urns, cancellationToken).ConfigureAwait(false);
|
return await ConvertUrnsToChannelItems(urns, cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<List<ChannelItemInfo>> GetLiveSportsAsync(CancellationToken cancellationToken)
|
private async Task<List<ChannelItemInfo>> GetLiveSportsAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var items = new List<ChannelItemInfo>();
|
var items = new List<ChannelItemInfo>();
|
||||||
var config = Plugin.Instance?.Configuration;
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var businessUnit = config?.BusinessUnit.ToLowerString() ?? "srf";
|
var businessUnit = Unit.ToLowerString();
|
||||||
|
|
||||||
using var apiClient = _apiClientFactory.CreateClient();
|
using var apiClient = _apiClientFactory.CreateClient();
|
||||||
var scheduledLivestreams = await apiClient.GetScheduledLivestreamsAsync(businessUnit, "SPORT", cancellationToken).ConfigureAwait(false);
|
var scheduledLivestreams = await apiClient.GetScheduledLivestreamsAsync(businessUnit, "SPORT", cancellationToken).ConfigureAwait(false);
|
||||||
@@ -291,8 +305,8 @@ public class SRFPlayChannel : 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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -307,52 +321,86 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
|
|||||||
private List<ChannelItemInfo> GetRecordingItems()
|
private List<ChannelItemInfo> GetRecordingItems()
|
||||||
{
|
{
|
||||||
var items = new List<ChannelItemInfo>();
|
var items = new List<ChannelItemInfo>();
|
||||||
var recordings = _recordingService.GetRecordings(RecordingState.Completed);
|
var unit = Unit.ToLowerString();
|
||||||
|
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))
|
// 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.
|
||||||
|
if (!string.IsNullOrEmpty(recording.BusinessUnit) &&
|
||||||
|
!string.Equals(recording.BusinessUnit, unit, StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
var fileInfo = new System.IO.FileInfo(recording.OutputPath);
|
var outputPath = recording.OutputPath!;
|
||||||
var itemId = $"recording_{recording.Id}";
|
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,
|
// Same approach as Jellyfin's own DVR: the file is still growing, so treat it as a live
|
||||||
Name = recording.Title,
|
// stream and have ffmpeg read it through an endpoint that follows the file.
|
||||||
Path = recording.OutputPath,
|
mediaSource = new MediaSourceInfo
|
||||||
Protocol = MediaProtocol.File,
|
{
|
||||||
Container = "mkv",
|
Id = itemId,
|
||||||
SupportsDirectPlay = true,
|
Name = name,
|
||||||
SupportsDirectStream = true,
|
Path = outputPath,
|
||||||
SupportsTranscoding = true,
|
Protocol = MediaProtocol.File,
|
||||||
IsRemote = false,
|
EncoderPath = $"{serverBaseUrl}/Plugins/SRFPlay/Recording/{recording.Id}/stream",
|
||||||
Size = fileInfo.Length,
|
EncoderProtocol = MediaProtocol.Http,
|
||||||
Type = MediaSourceType.Default
|
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
|
var item = new ChannelItemInfo
|
||||||
{
|
{
|
||||||
Id = itemId,
|
Id = itemId,
|
||||||
Name = recording.Title,
|
Name = name,
|
||||||
Overview = recording.Description,
|
Overview = recording.Description,
|
||||||
Type = ChannelItemType.Media,
|
Type = ChannelItemType.Media,
|
||||||
ContentType = ChannelMediaContentType.Movie,
|
ContentType = ChannelMediaContentType.Movie,
|
||||||
MediaType = ChannelMediaType.Video,
|
MediaType = ChannelMediaType.Video,
|
||||||
DateCreated = recording.RecordingStartedAt,
|
DateCreated = recording.RecordingStartedAt,
|
||||||
ImageUrl = !string.IsNullOrEmpty(recording.ImageUrl)
|
ImageUrl = !string.IsNullOrEmpty(recording.ImageUrl)
|
||||||
? CreateProxiedImageUrl(recording.ImageUrl, _mediaSourceFactory.GetServerBaseUrl())
|
? CreateProxiedImageUrl(recording.ImageUrl, serverBaseUrl)
|
||||||
: CreatePlaceholderImageUrl(recording.Title, _mediaSourceFactory.GetServerBaseUrl()),
|
: CreatePlaceholderImageUrl(recording.Title, serverBaseUrl),
|
||||||
MediaSources = new List<MediaSourceInfo> { mediaSource }
|
MediaSources = new List<MediaSourceInfo> { mediaSource }
|
||||||
};
|
};
|
||||||
|
|
||||||
items.Add(item);
|
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;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -368,9 +416,8 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var config = Plugin.Instance?.Configuration;
|
|
||||||
var topicId = folderId.Substring("category_".Length);
|
var topicId = folderId.Substring("category_".Length);
|
||||||
var businessUnit = config?.BusinessUnit.ToLowerString() ?? "srf";
|
var businessUnit = Unit.ToLowerString();
|
||||||
|
|
||||||
var shows = await _categoryService.GetShowsByTopicAsync(topicId, businessUnit, 20, cancellationToken).ConfigureAwait(false);
|
var shows = await _categoryService.GetShowsByTopicAsync(topicId, businessUnit, 20, cancellationToken).ConfigureAwait(false);
|
||||||
var urns = new List<string>();
|
var urns = new List<string>();
|
||||||
@@ -471,8 +518,10 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
|
|||||||
var timeBucket = new DateTime(now.Year, now.Month, now.Day, now.Hour, (now.Minute / 15) * 15, 0);
|
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 timeKey = timeBucket.ToString("yyyy-MM-dd-HH-mm", CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
var recordingCount = _recordingService.GetRecordings(RecordingState.Completed).Count;
|
// Include in-progress count so items switch from live to finished sources when a recording ends
|
||||||
return $"{config?.BusinessUnit}_{config?.EnableLatestContent}_{config?.EnableTrendingContent}_{config?.EnableCategoryFolders}_{enabledTopics}_{timeKey}_rec{recordingCount}";
|
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)
|
private async Task<List<ChannelItemInfo>> ConvertUrnsToChannelItems(List<string> urns, CancellationToken cancellationToken)
|
||||||
@@ -614,8 +663,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
|
|||||||
ProviderIds = new Dictionary<string, string>
|
ProviderIds = new Dictionary<string, string>
|
||||||
{
|
{
|
||||||
{ "SRF", urn }
|
{ "SRF", urn }
|
||||||
},
|
}
|
||||||
MediaSources = new List<MediaSourceInfo> { mediaSource }
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add series info if available
|
// Add series info if available
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
using Jellyfin.Plugin.SRFPlay.Api;
|
||||||
|
using Jellyfin.Plugin.SRFPlay.Configuration;
|
||||||
|
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.SRFPlay.Channels;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SWI swissinfo.ch — Swiss international channel.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SwiChannel : SrgChannelBase
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="SwiChannel"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="loggerFactory">The logger factory.</param>
|
||||||
|
/// <param name="contentRefreshService">The content refresh service.</param>
|
||||||
|
/// <param name="streamResolver">The stream resolver.</param>
|
||||||
|
/// <param name="mediaSourceFactory">The media source factory.</param>
|
||||||
|
/// <param name="categoryService">The category service.</param>
|
||||||
|
/// <param name="apiClientFactory">The API client factory.</param>
|
||||||
|
/// <param name="recordingService">The recording service.</param>
|
||||||
|
public SwiChannel(
|
||||||
|
ILoggerFactory loggerFactory,
|
||||||
|
IContentRefreshService contentRefreshService,
|
||||||
|
IStreamUrlResolver streamResolver,
|
||||||
|
IMediaSourceFactory mediaSourceFactory,
|
||||||
|
ICategoryService? categoryService,
|
||||||
|
ISRFApiClientFactory apiClientFactory,
|
||||||
|
IRecordingService recordingService)
|
||||||
|
: base(loggerFactory, contentRefreshService, streamResolver, mediaSourceFactory, categoryService, apiClientFactory, recordingService)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override BusinessUnit Unit => BusinessUnit.SWI;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override string LogoResourceName => "Jellyfin.Plugin.SRFPlay.Images.swi-logo.png";
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override string Description => "SWI swissinfo.ch — Swiss international video-on-demand content";
|
||||||
|
}
|
||||||
@@ -75,10 +75,17 @@ public class PluginConfiguration : BasePluginConfiguration
|
|||||||
EnableCategoryFolders = true;
|
EnableCategoryFolders = true;
|
||||||
EnabledTopics = new System.Collections.Generic.List<string>();
|
EnabledTopics = new System.Collections.Generic.List<string>();
|
||||||
GenerateTitleCards = true;
|
GenerateTitleCards = true;
|
||||||
|
LiveStartSegmentsBack = 3;
|
||||||
|
CleanUpResumePoints = true;
|
||||||
|
ClearLiveStreamResumePoints = true;
|
||||||
|
ResumePointMaxAgeDays = 30;
|
||||||
|
ResumePointMinPositionSeconds = 60;
|
||||||
|
ResumePointCompletedPercent = 92;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the business unit to fetch content from.
|
/// Gets or sets the legacy single business unit. Retained only for backwards compatibility
|
||||||
|
/// with older configs; every unit now has its own always-on channel.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public BusinessUnit BusinessUnit { get; set; }
|
public BusinessUnit BusinessUnit { get; set; }
|
||||||
|
|
||||||
@@ -161,4 +168,55 @@ public class PluginConfiguration : BasePluginConfiguration
|
|||||||
/// Gets or sets the output directory for sport livestream recordings.
|
/// Gets or sets the output directory for sport livestream recordings.
|
||||||
/// </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>
|
||||||
|
/// 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
|
||||||
|
/// playlist. This keeps the start point out of the volatile live edge, which on Android TV
|
||||||
|
/// (ExoPlayer) otherwise causes stalling/jumping until a manual skip. RFC 8216 requires the
|
||||||
|
/// offset to stay at least 3 target durations from the edge, so values below 3 are clamped.
|
||||||
|
/// Set to 0 to disable injection entirely.
|
||||||
|
/// </summary>
|
||||||
|
public int LiveStartSegmentsBack { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether the "Clean Up SRF Play Continue Watching" task
|
||||||
|
/// removes stale resume points. When false the task inspects nothing and clears nothing.
|
||||||
|
/// </summary>
|
||||||
|
public bool CleanUpResumePoints { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether resume points for livestreams are cleared.
|
||||||
|
/// A livestream has no meaningful resume position, so stopping one otherwise leaves it
|
||||||
|
/// pinned in "Continue Watching" forever. When enabled the position is also cleared
|
||||||
|
/// immediately on playback stop, not just by the scheduled task.
|
||||||
|
/// </summary>
|
||||||
|
public bool ClearLiveStreamResumePoints { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the age in days after which an untouched resume point is cleared.
|
||||||
|
/// Measured from the last time the item was played. Set to 0 to disable the age rule.
|
||||||
|
/// </summary>
|
||||||
|
public int ResumePointMaxAgeDays { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the minimum resume position in seconds. Anything at or below this is
|
||||||
|
/// treated as an accidental start rather than something worth resuming.
|
||||||
|
/// Set to 0 to disable the rule.
|
||||||
|
/// </summary>
|
||||||
|
public int ResumePointMinPositionSeconds { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the percentage of runtime at or beyond which playback counts as finished.
|
||||||
|
/// Only applied to items with a known runtime. Set to 0 to disable the rule.
|
||||||
|
/// </summary>
|
||||||
|
public int ResumePointCompletedPercent { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,10 @@
|
|||||||
<!DOCTYPE html>
|
<div id="SRFPlayConfigPage" data-role="page" class="page type-interior pluginConfigurationPage" data-require="emby-input,emby-button,emby-select,emby-checkbox">
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<title>SRF Play</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="SRFPlayConfigPage" data-role="page" class="page type-interior pluginConfigurationPage" data-require="emby-input,emby-button,emby-select,emby-checkbox">
|
|
||||||
<div data-role="content">
|
<div data-role="content">
|
||||||
<div class="content-primary">
|
<div class="content-primary">
|
||||||
<form id="SRFPlayConfigForm">
|
<form id="SRFPlayConfigForm">
|
||||||
<div class="selectContainer">
|
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||||
<label class="selectLabel" for="BusinessUnit">Business Unit</label>
|
<h3>Channels</h3>
|
||||||
<select is="emby-select" id="BusinessUnit" name="BusinessUnit" class="emby-select-withcolor emby-select">
|
<div class="fieldDescription">Every Swiss broadcaster has its own channel tile: SRF (German), RTS (French), RSI (Italian), RTR (Romansh) and SWI (swissinfo.ch). They are always available — pin the ones you use from the Jellyfin home screen.</div>
|
||||||
<option id="optSRF" value="SRF">SRF (German)</option>
|
|
||||||
<option id="optRTS" value="RTS">RTS (French)</option>
|
|
||||||
<option id="optRSI" value="RSI">RSI (Italian)</option>
|
|
||||||
<option id="optRTR" value="RTR">RTR (Romansh)</option>
|
|
||||||
<option id="optSWI" value="SWI">SWI (International)</option>
|
|
||||||
</select>
|
|
||||||
<div class="fieldDescription">Select the Swiss broadcasting unit to fetch content from</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="selectContainer">
|
<div class="selectContainer">
|
||||||
<label class="selectLabel" for="QualityPreference">Quality Preference</label>
|
<label class="selectLabel" for="QualityPreference">Quality Preference</label>
|
||||||
@@ -96,6 +82,52 @@
|
|||||||
<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">
|
||||||
|
<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" />
|
||||||
|
<div class="fieldDescription">How many segments back from the live edge livestreams start playing. Fixes jumpy/stalling playback at the start on Android TV. Minimum 3 (RFC requirement); 0 disables it. Default 3.</div>
|
||||||
|
</div>
|
||||||
|
<br />
|
||||||
|
<h2>Continue Watching Cleanup</h2>
|
||||||
|
<div class="fieldDescription" style="margin-bottom: 1em;">
|
||||||
|
Livestreams and abandoned playback otherwise stay pinned in every user's
|
||||||
|
"Continue Watching" row forever. Clearing a resume point only resets the
|
||||||
|
playback position — nothing is deleted and the item stays unwatched.
|
||||||
|
</div>
|
||||||
|
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||||
|
<label class="emby-checkbox-label">
|
||||||
|
<input id="CleanUpResumePoints" name="CleanUpResumePoints" type="checkbox" is="emby-checkbox" />
|
||||||
|
<span>Clean up stale resume points</span>
|
||||||
|
</label>
|
||||||
|
<div class="fieldDescription checkboxFieldDescription">Runs daily at 4 AM (Scheduled Tasks → "Clean Up SRF Play Continue Watching")</div>
|
||||||
|
</div>
|
||||||
|
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||||
|
<label class="emby-checkbox-label">
|
||||||
|
<input id="ClearLiveStreamResumePoints" name="ClearLiveStreamResumePoints" type="checkbox" is="emby-checkbox" />
|
||||||
|
<span>Never keep a resume point for livestreams</span>
|
||||||
|
</label>
|
||||||
|
<div class="fieldDescription checkboxFieldDescription">Clears the position as soon as a livestream stops playing, rather than waiting for the daily task</div>
|
||||||
|
</div>
|
||||||
|
<div class="inputContainer">
|
||||||
|
<label class="inputLabel inputLabelUnfocused" for="ResumePointMaxAgeDays">Maximum Resume Point Age (days)</label>
|
||||||
|
<input id="ResumePointMaxAgeDays" name="ResumePointMaxAgeDays" type="number" is="emby-input" min="0" max="3650" />
|
||||||
|
<div class="fieldDescription">Clear resume points not touched for this many days. 0 disables the rule. Default 30.</div>
|
||||||
|
</div>
|
||||||
|
<div class="inputContainer">
|
||||||
|
<label class="inputLabel inputLabelUnfocused" for="ResumePointMinPositionSeconds">Minimum Resume Position (seconds)</label>
|
||||||
|
<input id="ResumePointMinPositionSeconds" name="ResumePointMinPositionSeconds" type="number" is="emby-input" min="0" max="3600" />
|
||||||
|
<div class="fieldDescription">Positions at or below this are treated as an accidental start and cleared. 0 disables the rule. Default 60.</div>
|
||||||
|
</div>
|
||||||
|
<div class="inputContainer">
|
||||||
|
<label class="inputLabel inputLabelUnfocused" for="ResumePointCompletedPercent">Finished Threshold (%)</label>
|
||||||
|
<input id="ResumePointCompletedPercent" name="ResumePointCompletedPercent" type="number" is="emby-input" min="0" max="100" />
|
||||||
|
<div class="fieldDescription">Playback at or beyond this share of the runtime counts as finished. Only applies when the runtime is known. 0 disables the rule. Default 92.</div>
|
||||||
|
</div>
|
||||||
<br />
|
<br />
|
||||||
<h2>Network Settings</h2>
|
<h2>Network Settings</h2>
|
||||||
<div class="inputContainer">
|
<div class="inputContainer">
|
||||||
@@ -114,6 +146,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<br />
|
||||||
|
<h2>Continue Watching Maintenance</h2>
|
||||||
|
<div class="fieldDescription">Run the cleanup right now instead of waiting for the daily task. Only SRF Play items are affected.</div>
|
||||||
|
<div id="resumeCleanupResult" style="margin: 10px 0;"></div>
|
||||||
|
<button is="emby-button" type="button" class="raised emby-button" onclick="SRFPlayMaintenance.cleanupResumePoints(false)" style="margin-bottom: 20px;">
|
||||||
|
<span>Clean Up Stale Entries Now</span>
|
||||||
|
</button>
|
||||||
|
<button is="emby-button" type="button" class="raised emby-button" onclick="SRFPlayMaintenance.cleanupResumePoints(true)" style="margin-bottom: 20px;">
|
||||||
|
<span>Clear All SRF Play Entries</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<br />
|
<br />
|
||||||
<h2>Sport Livestream Recordings</h2>
|
<h2>Sport Livestream Recordings</h2>
|
||||||
|
|
||||||
@@ -149,7 +192,6 @@
|
|||||||
.addEventListener('pageshow', function() {
|
.addEventListener('pageshow', function() {
|
||||||
Dashboard.showLoadingMsg();
|
Dashboard.showLoadingMsg();
|
||||||
ApiClient.getPluginConfiguration(SRFPlayConfig.pluginUniqueId).then(function (config) {
|
ApiClient.getPluginConfiguration(SRFPlayConfig.pluginUniqueId).then(function (config) {
|
||||||
document.querySelector('#BusinessUnit').value = config.BusinessUnit;
|
|
||||||
document.querySelector('#QualityPreference').value = config.QualityPreference;
|
document.querySelector('#QualityPreference').value = config.QualityPreference;
|
||||||
document.querySelector('#ContentRefreshIntervalHours').value = config.ContentRefreshIntervalHours;
|
document.querySelector('#ContentRefreshIntervalHours').value = config.ContentRefreshIntervalHours;
|
||||||
document.querySelector('#ExpirationCheckIntervalHours').value = config.ExpirationCheckIntervalHours;
|
document.querySelector('#ExpirationCheckIntervalHours').value = config.ExpirationCheckIntervalHours;
|
||||||
@@ -163,6 +205,13 @@
|
|||||||
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('#CleanUpResumePoints').checked = config.CleanUpResumePoints !== false;
|
||||||
|
document.querySelector('#ClearLiveStreamResumePoints').checked = config.ClearLiveStreamResumePoints !== false;
|
||||||
|
document.querySelector('#ResumePointMaxAgeDays').value = config.ResumePointMaxAgeDays != null ? config.ResumePointMaxAgeDays : 30;
|
||||||
|
document.querySelector('#ResumePointMinPositionSeconds').value = config.ResumePointMinPositionSeconds != null ? config.ResumePointMinPositionSeconds : 60;
|
||||||
|
document.querySelector('#ResumePointCompletedPercent').value = config.ResumePointCompletedPercent != null ? config.ResumePointCompletedPercent : 92;
|
||||||
Dashboard.hideLoadingMsg();
|
Dashboard.hideLoadingMsg();
|
||||||
|
|
||||||
// Load recordings UI
|
// Load recordings UI
|
||||||
@@ -175,7 +224,6 @@
|
|||||||
.addEventListener('submit', function(e) {
|
.addEventListener('submit', function(e) {
|
||||||
Dashboard.showLoadingMsg();
|
Dashboard.showLoadingMsg();
|
||||||
ApiClient.getPluginConfiguration(SRFPlayConfig.pluginUniqueId).then(function (config) {
|
ApiClient.getPluginConfiguration(SRFPlayConfig.pluginUniqueId).then(function (config) {
|
||||||
config.BusinessUnit = document.querySelector('#BusinessUnit').value;
|
|
||||||
config.QualityPreference = document.querySelector('#QualityPreference').value;
|
config.QualityPreference = document.querySelector('#QualityPreference').value;
|
||||||
config.ContentRefreshIntervalHours = parseInt(document.querySelector('#ContentRefreshIntervalHours').value);
|
config.ContentRefreshIntervalHours = parseInt(document.querySelector('#ContentRefreshIntervalHours').value);
|
||||||
config.ExpirationCheckIntervalHours = parseInt(document.querySelector('#ExpirationCheckIntervalHours').value);
|
config.ExpirationCheckIntervalHours = parseInt(document.querySelector('#ExpirationCheckIntervalHours').value);
|
||||||
@@ -189,6 +237,13 @@
|
|||||||
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.CleanUpResumePoints = document.querySelector('#CleanUpResumePoints').checked;
|
||||||
|
config.ClearLiveStreamResumePoints = document.querySelector('#ClearLiveStreamResumePoints').checked;
|
||||||
|
config.ResumePointMaxAgeDays = parseInt(document.querySelector('#ResumePointMaxAgeDays').value) || 0;
|
||||||
|
config.ResumePointMinPositionSeconds = parseInt(document.querySelector('#ResumePointMinPositionSeconds').value) || 0;
|
||||||
|
config.ResumePointCompletedPercent = parseInt(document.querySelector('#ResumePointCompletedPercent').value) || 0;
|
||||||
ApiClient.updatePluginConfiguration(SRFPlayConfig.pluginUniqueId, config).then(function (result) {
|
ApiClient.updatePluginConfiguration(SRFPlayConfig.pluginUniqueId, config).then(function (result) {
|
||||||
Dashboard.processPluginConfigurationUpdateResult(result);
|
Dashboard.processPluginConfigurationUpdateResult(result);
|
||||||
});
|
});
|
||||||
@@ -198,12 +253,46 @@
|
|||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
var SRFPlayMaintenance = {
|
||||||
|
cleanupResumePoints: function(clearAll) {
|
||||||
|
if (clearAll && !confirm('Clear the playback position of every SRF Play item for all users? Nothing is deleted and items stay unwatched.')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var target = document.querySelector('#resumeCleanupResult');
|
||||||
|
target.innerHTML = '<p><em>Cleaning up...</em></p>';
|
||||||
|
|
||||||
|
fetch(ApiClient.serverAddress() + '/Plugins/SRFPlay/Maintenance/ResumePoints/Cleanup?clearAll=' + (clearAll ? 'true' : 'false'), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': 'MediaBrowser Token="' + ApiClient.accessToken() + '"' }
|
||||||
|
})
|
||||||
|
.then(function(response) {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('HTTP ' + response.status);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then(function(result) {
|
||||||
|
var byReason = result.clearedByReason || {};
|
||||||
|
var reasons = Object.keys(byReason)
|
||||||
|
.map(function(key) { return key + ': ' + byReason[key]; })
|
||||||
|
.join(', ');
|
||||||
|
target.innerHTML = '<p>Cleared <strong>' + result.cleared + '</strong> of ' + result.inspected +
|
||||||
|
' SRF Play resume point(s) across ' + result.usersChecked + ' user(s).' +
|
||||||
|
(reasons ? ' (' + reasons + ')' : '') + '</p>';
|
||||||
|
})
|
||||||
|
.catch(function(error) {
|
||||||
|
target.innerHTML = '<p style="color: #d44;">Cleanup failed: ' + error.message + '</p>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
var SRFPlayRecordings = {
|
var SRFPlayRecordings = {
|
||||||
apiBase: ApiClient.serverAddress() + '/Plugins/SRFPlay/Recording',
|
apiBase: ApiClient.serverAddress() + '/Plugins/SRFPlay/Recording',
|
||||||
|
|
||||||
getHeaders: function() {
|
getHeaders: function() {
|
||||||
return {
|
return {
|
||||||
'X-Emby-Token': ApiClient.accessToken()
|
'Authorization': 'MediaBrowser Token="' + ApiClient.accessToken() + '"'
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -398,5 +487,3 @@
|
|||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|||||||
@@ -1,344 +1,346 @@
|
|||||||
<!DOCTYPE html>
|
<div id="SRFPlayRecordingsPage" data-role="page" class="page type-interior pluginConfigurationPage"
|
||||||
<html lang="en">
|
data-require="emby-button,emby-input">
|
||||||
<head>
|
<style>
|
||||||
<meta charset="utf-8">
|
#SRFPlayRecordingsPage .srfrec-wrap { padding: 0 1em; }
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
#SRFPlayRecordingsPage table { width: 100%; border-collapse: collapse; margin: 10px 0 20px; }
|
||||||
<title>SRF Sport Recordings</title>
|
#SRFPlayRecordingsPage th { text-align: left; padding: 10px 8px; border-bottom: 2px solid #444; color: #aaa; font-size: 0.85em; text-transform: uppercase; }
|
||||||
<style>
|
#SRFPlayRecordingsPage td { padding: 10px 8px; border-bottom: 1px solid #2a2a3e; }
|
||||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
#SRFPlayRecordingsPage tr:hover { background: rgba(255,255,255,0.04); }
|
||||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #1a1a2e; color: #e0e0e0; padding: 20px; }
|
#SRFPlayRecordingsPage .btn { padding: 6px 16px; border: none; border-radius: 4px; cursor: pointer; font-size: 0.85em; color: #fff; transition: opacity 0.2s; }
|
||||||
h1 { color: #fff; margin-bottom: 8px; font-size: 1.8em; }
|
#SRFPlayRecordingsPage .btn:hover { opacity: 0.85; }
|
||||||
h2 { color: #ccc; margin: 24px 0 12px; font-size: 1.3em; border-bottom: 1px solid #333; padding-bottom: 6px; }
|
#SRFPlayRecordingsPage .btn-record { background: #4CAF50; }
|
||||||
.subtitle { color: #999; margin-bottom: 20px; font-size: 0.9em; }
|
#SRFPlayRecordingsPage .btn-stop { background: #FF9800; }
|
||||||
table { width: 100%; border-collapse: collapse; margin: 10px 0 20px; }
|
#SRFPlayRecordingsPage .btn-cancel { background: #9E9E9E; }
|
||||||
th { text-align: left; padding: 10px 8px; border-bottom: 2px solid #444; color: #aaa; font-size: 0.85em; text-transform: uppercase; }
|
#SRFPlayRecordingsPage .btn-delete { background: #f44336; }
|
||||||
td { padding: 10px 8px; border-bottom: 1px solid #2a2a3e; }
|
#SRFPlayRecordingsPage .btn-refresh { background: #2196F3; margin-bottom: 16px; }
|
||||||
tr:hover { background: #2a2a3e; }
|
#SRFPlayRecordingsPage .status { padding: 3px 8px; border-radius: 3px; font-size: 0.8em; font-weight: 600; color: #fff; }
|
||||||
.btn { padding: 6px 16px; border: none; border-radius: 4px; cursor: pointer; font-size: 0.85em; color: #fff; transition: opacity 0.2s; }
|
#SRFPlayRecordingsPage .status-scheduled { background: #1565C0; }
|
||||||
.btn:hover { opacity: 0.85; }
|
#SRFPlayRecordingsPage .status-waiting { background: #E65100; }
|
||||||
.btn-record { background: #4CAF50; }
|
#SRFPlayRecordingsPage .status-recording { background: #2E7D32; }
|
||||||
.btn-stop { background: #FF9800; }
|
#SRFPlayRecordingsPage .status-failed { background: #C62828; }
|
||||||
.btn-cancel { background: #9E9E9E; }
|
#SRFPlayRecordingsPage .srfrec-msg { padding: 12px; color: #999; font-style: italic; }
|
||||||
.btn-delete { background: #f44336; }
|
#SRFPlayRecordingsPage .srfrec-error { color: #f44; }
|
||||||
.btn-refresh { background: #2196F3; margin-bottom: 16px; }
|
#SRFPlayRecordingsPage .login-form { max-width: 400px; margin: 40px auto; }
|
||||||
.status { padding: 3px 8px; border-radius: 3px; font-size: 0.8em; font-weight: 600; }
|
#SRFPlayRecordingsPage .login-form input { width: 100%; padding: 10px; margin: 8px 0; background: #2a2a3e; border: 1px solid #444; color: #fff; border-radius: 4px; font-size: 1em; }
|
||||||
.status-scheduled { background: #1565C0; }
|
#SRFPlayRecordingsPage .login-form .btn { width: 100%; padding: 12px; font-size: 1em; margin-top: 12px; }
|
||||||
.status-waiting { background: #E65100; }
|
</style>
|
||||||
.status-recording { background: #2E7D32; }
|
<div data-role="content">
|
||||||
.status-failed { background: #C62828; }
|
<div class="content-primary srfrec-wrap">
|
||||||
.msg { padding: 12px; color: #999; font-style: italic; }
|
<div id="srfrecLoginSection" style="display:none;">
|
||||||
.error { color: #f44; }
|
<div class="login-form">
|
||||||
.login-form { max-width: 400px; margin: 60px auto; }
|
<h1>SRF Sport Recordings</h1>
|
||||||
.login-form input { width: 100%; padding: 10px; margin: 8px 0; background: #2a2a3e; border: 1px solid #444; color: #fff; border-radius: 4px; font-size: 1em; }
|
<p class="fieldDescription">Sign in to your Jellyfin server</p>
|
||||||
.login-form .btn { width: 100%; padding: 12px; font-size: 1em; margin-top: 12px; }
|
<input type="text" id="srfrecLoginServer" placeholder="Server URL (e.g. http://192.168.1.50:8096)" />
|
||||||
@media (max-width: 600px) { body { padding: 10px; } td, th { padding: 6px 4px; font-size: 0.85em; } }
|
<input type="text" id="srfrecLoginUser" placeholder="Username" />
|
||||||
</style>
|
<input type="password" id="srfrecLoginPass" placeholder="Password" />
|
||||||
</head>
|
<button type="button" class="btn btn-record" onclick="SRFRec.login()">Sign In</button>
|
||||||
<body>
|
<p id="srfrecLoginError" class="srfrec-error" style="margin-top:10px;"></p>
|
||||||
<div id="loginSection" style="display:none;">
|
</div>
|
||||||
<div class="login-form">
|
</div>
|
||||||
<h1>SRF Sport Recordings</h1>
|
|
||||||
<p class="subtitle">Sign in to your Jellyfin server</p>
|
<div id="srfrecMainSection" style="display:none;">
|
||||||
<input type="text" id="loginServer" placeholder="Server URL (e.g. http://192.168.1.50:8096)" />
|
<h1>SRF Sport Recordings</h1>
|
||||||
<input type="text" id="loginUser" placeholder="Username" />
|
<p class="fieldDescription">Browse upcoming sport livestreams and schedule recordings. <span id="srfrecUserInfo"></span></p>
|
||||||
<input type="password" id="loginPass" placeholder="Password" />
|
|
||||||
<button class="btn btn-record" onclick="SRFRec.login()">Sign In</button>
|
<h2>Upcoming Sport Livestreams</h2>
|
||||||
<p id="loginError" class="error" style="margin-top:10px;"></p>
|
<button type="button" class="btn btn-refresh" onclick="SRFRec.loadSchedule()">Refresh Schedule</button>
|
||||||
|
<div id="srfrecScheduleContainer"><p class="srfrec-msg">Loading schedule...</p></div>
|
||||||
|
|
||||||
|
<h2>Scheduled & Active Recordings</h2>
|
||||||
|
<div id="srfrecActiveRecordingsContainer"><p class="srfrec-msg">Loading...</p></div>
|
||||||
|
|
||||||
|
<h2>Completed Recordings</h2>
|
||||||
|
<button type="button" class="btn btn-refresh" onclick="SRFRec.loadRecordings()">Refresh</button>
|
||||||
|
<div id="srfrecCompletedRecordingsContainer"><p class="srfrec-msg">Loading...</p></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="mainSection" style="display:none;">
|
<script>
|
||||||
<h1>SRF Sport Recordings</h1>
|
var SRFRec = {
|
||||||
<p class="subtitle">Browse upcoming sport livestreams and schedule recordings. <span id="userInfo"></span></p>
|
serverUrl: '',
|
||||||
|
token: '',
|
||||||
|
|
||||||
<h2>Upcoming Sport Livestreams</h2>
|
init: function() {
|
||||||
<button class="btn btn-refresh" onclick="SRFRec.loadSchedule()">Refresh Schedule</button>
|
// Check if we're inside Jellyfin's web client (ApiClient available)
|
||||||
<div id="scheduleContainer"><p class="msg">Loading schedule...</p></div>
|
if (typeof ApiClient !== 'undefined' && ApiClient.accessToken()) {
|
||||||
|
this.serverUrl = ApiClient.serverAddress();
|
||||||
<h2>Scheduled & Active Recordings</h2>
|
this.token = ApiClient.accessToken();
|
||||||
<div id="activeRecordingsContainer"><p class="msg">Loading...</p></div>
|
this.showMain();
|
||||||
|
|
||||||
<h2>Completed Recordings</h2>
|
|
||||||
<button class="btn btn-refresh" onclick="SRFRec.loadRecordings()">Refresh</button>
|
|
||||||
<div id="completedRecordingsContainer"><p class="msg">Loading...</p></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
var SRFRec = {
|
|
||||||
serverUrl: '',
|
|
||||||
token: '',
|
|
||||||
|
|
||||||
init: function() {
|
|
||||||
// Check if we're inside Jellyfin's web client (ApiClient available)
|
|
||||||
if (typeof ApiClient !== 'undefined' && ApiClient.accessToken()) {
|
|
||||||
this.serverUrl = ApiClient.serverAddress();
|
|
||||||
this.token = ApiClient.accessToken();
|
|
||||||
this.showMain();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check localStorage for saved session
|
|
||||||
var saved = localStorage.getItem('srfRecSession');
|
|
||||||
if (saved) {
|
|
||||||
try {
|
|
||||||
var session = JSON.parse(saved);
|
|
||||||
this.serverUrl = session.serverUrl;
|
|
||||||
this.token = session.token;
|
|
||||||
// Verify token still works
|
|
||||||
this.verifySession(session);
|
|
||||||
return;
|
return;
|
||||||
} catch(e) { /* fall through to login */ }
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Show login form
|
// Check localStorage for saved session
|
||||||
var serverInput = document.getElementById('loginServer');
|
var saved = localStorage.getItem('srfRecSession');
|
||||||
serverInput.value = window.location.origin;
|
if (saved) {
|
||||||
document.getElementById('loginSection').style.display = 'block';
|
try {
|
||||||
},
|
var session = JSON.parse(saved);
|
||||||
|
this.serverUrl = session.serverUrl;
|
||||||
|
this.token = session.token;
|
||||||
|
// Verify token still works
|
||||||
|
this.verifySession(session);
|
||||||
|
return;
|
||||||
|
} catch(e) { /* fall through to login */ }
|
||||||
|
}
|
||||||
|
|
||||||
verifySession: function(session) {
|
// Show login form
|
||||||
var self = this;
|
var serverInput = document.getElementById('srfrecLoginServer');
|
||||||
fetch(this.serverUrl + '/System/Info', {
|
serverInput.value = window.location.origin;
|
||||||
headers: { 'X-Emby-Token': this.token }
|
document.getElementById('srfrecLoginSection').style.display = 'block';
|
||||||
}).then(function(r) {
|
},
|
||||||
if (r.ok) {
|
|
||||||
document.getElementById('userInfo').textContent = '(Server: ' + self.serverUrl + ')';
|
verifySession: function(session) {
|
||||||
self.showMain();
|
var self = this;
|
||||||
} else {
|
fetch(this.serverUrl + '/System/Info', {
|
||||||
|
headers: { 'Authorization': 'MediaBrowser Token="' + this.token + '"' }
|
||||||
|
}).then(function(r) {
|
||||||
|
if (r.ok) {
|
||||||
|
document.getElementById('srfrecUserInfo').textContent = '(Server: ' + self.serverUrl + ')';
|
||||||
|
self.showMain();
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem('srfRecSession');
|
||||||
|
document.getElementById('srfrecLoginSection').style.display = 'block';
|
||||||
|
}
|
||||||
|
}).catch(function() {
|
||||||
localStorage.removeItem('srfRecSession');
|
localStorage.removeItem('srfRecSession');
|
||||||
document.getElementById('loginSection').style.display = 'block';
|
document.getElementById('srfrecLoginSection').style.display = 'block';
|
||||||
}
|
});
|
||||||
}).catch(function() {
|
},
|
||||||
localStorage.removeItem('srfRecSession');
|
|
||||||
document.getElementById('loginSection').style.display = 'block';
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
login: function() {
|
login: function() {
|
||||||
var self = this;
|
var self = this;
|
||||||
var server = document.getElementById('loginServer').value.replace(/\/+$/, '');
|
var server = document.getElementById('srfrecLoginServer').value.replace(/\/+$/, '');
|
||||||
var user = document.getElementById('loginUser').value;
|
var user = document.getElementById('srfrecLoginUser').value;
|
||||||
var pass = document.getElementById('loginPass').value;
|
var pass = document.getElementById('srfrecLoginPass').value;
|
||||||
var errorEl = document.getElementById('loginError');
|
var errorEl = document.getElementById('srfrecLoginError');
|
||||||
errorEl.textContent = '';
|
errorEl.textContent = '';
|
||||||
|
|
||||||
fetch(server + '/Users/AuthenticateByName', {
|
fetch(server + '/Users/AuthenticateByName', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'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 })
|
body: JSON.stringify({ Username: user, Pw: pass })
|
||||||
})
|
})
|
||||||
.then(function(r) {
|
.then(function(r) {
|
||||||
if (!r.ok) throw new Error('Authentication failed');
|
if (!r.ok) throw new Error('Authentication failed');
|
||||||
return r.json();
|
return r.json();
|
||||||
})
|
})
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
self.serverUrl = server;
|
self.serverUrl = server;
|
||||||
self.token = data.AccessToken;
|
self.token = data.AccessToken;
|
||||||
localStorage.setItem('srfRecSession', JSON.stringify({
|
localStorage.setItem('srfRecSession', JSON.stringify({
|
||||||
serverUrl: server,
|
serverUrl: server,
|
||||||
token: data.AccessToken,
|
token: data.AccessToken,
|
||||||
userName: data.User.Name
|
userName: data.User.Name
|
||||||
}));
|
}));
|
||||||
document.getElementById('userInfo').textContent = '(Signed in as ' + data.User.Name + ')';
|
document.getElementById('srfrecUserInfo').textContent = '(Signed in as ' + data.User.Name + ')';
|
||||||
self.showMain();
|
self.showMain();
|
||||||
})
|
|
||||||
.catch(function(err) {
|
|
||||||
errorEl.textContent = err.message;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
showMain: function() {
|
|
||||||
document.getElementById('loginSection').style.display = 'none';
|
|
||||||
document.getElementById('mainSection').style.display = 'block';
|
|
||||||
this.loadSchedule();
|
|
||||||
this.loadRecordings();
|
|
||||||
},
|
|
||||||
|
|
||||||
getHeaders: function() {
|
|
||||||
return { 'X-Emby-Token': this.token };
|
|
||||||
},
|
|
||||||
|
|
||||||
formatDate: function(dateStr) {
|
|
||||||
if (!dateStr) return 'N/A';
|
|
||||||
var d = new Date(dateStr);
|
|
||||||
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
|
|
||||||
},
|
|
||||||
|
|
||||||
formatSize: function(bytes) {
|
|
||||||
if (!bytes) return '';
|
|
||||||
if (bytes > 1073741824) return (bytes / 1073741824).toFixed(1) + ' GB';
|
|
||||||
if (bytes > 1048576) return (bytes / 1048576).toFixed(0) + ' MB';
|
|
||||||
return (bytes / 1024).toFixed(0) + ' KB';
|
|
||||||
},
|
|
||||||
|
|
||||||
loadSchedule: function() {
|
|
||||||
var container = document.getElementById('scheduleContainer');
|
|
||||||
container.innerHTML = '<p class="msg">Loading schedule...</p>';
|
|
||||||
|
|
||||||
fetch(this.serverUrl + '/Plugins/SRFPlay/Recording/Schedule', { headers: this.getHeaders() })
|
|
||||||
.then(function(r) { return r.json(); })
|
|
||||||
.then(function(programs) {
|
|
||||||
if (!programs || programs.length === 0) {
|
|
||||||
container.innerHTML = '<p class="msg">No upcoming sport livestreams found.</p>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var html = '<table>';
|
|
||||||
html += '<thead><tr><th>Title</th><th>Start</th><th>End</th><th>Action</th></tr></thead><tbody>';
|
|
||||||
|
|
||||||
programs.forEach(function(p) {
|
|
||||||
html += '<tr>';
|
|
||||||
html += '<td>' + (p.title || 'Unknown') + '</td>';
|
|
||||||
html += '<td>' + SRFRec.formatDate(p.validFrom || p.date) + '</td>';
|
|
||||||
html += '<td>' + SRFRec.formatDate(p.validTo) + '</td>';
|
|
||||||
html += '<td><button class="btn btn-record" onclick="SRFRec.scheduleRecording(\'' + encodeURIComponent(p.urn) + '\')">Record</button></td>';
|
|
||||||
html += '</tr>';
|
|
||||||
});
|
|
||||||
|
|
||||||
html += '</tbody></table>';
|
|
||||||
container.innerHTML = html;
|
|
||||||
})
|
})
|
||||||
.catch(function(err) {
|
.catch(function(err) {
|
||||||
container.innerHTML = '<p class="error">Error loading schedule: ' + err.message + '</p>';
|
errorEl.textContent = err.message;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
scheduleRecording: function(encodedUrn) {
|
showMain: function() {
|
||||||
var self = this;
|
document.getElementById('srfrecLoginSection').style.display = 'none';
|
||||||
fetch(this.serverUrl + '/Plugins/SRFPlay/Recording/Schedule/' + encodedUrn, {
|
document.getElementById('srfrecMainSection').style.display = 'block';
|
||||||
method: 'POST',
|
this.loadSchedule();
|
||||||
headers: this.getHeaders()
|
this.loadRecordings();
|
||||||
})
|
},
|
||||||
.then(function(r) {
|
|
||||||
if (r.ok) {
|
|
||||||
alert('Recording scheduled!');
|
|
||||||
self.loadRecordings();
|
|
||||||
} else {
|
|
||||||
alert('Failed to schedule recording');
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(function(err) { alert('Error: ' + err.message); });
|
|
||||||
},
|
|
||||||
|
|
||||||
loadRecordings: function() {
|
getHeaders: function() {
|
||||||
this.loadActiveRecordings();
|
return { 'Authorization': 'MediaBrowser Token="' + this.token + '"' };
|
||||||
this.loadCompletedRecordings();
|
},
|
||||||
},
|
|
||||||
|
|
||||||
loadActiveRecordings: function() {
|
formatDate: function(dateStr) {
|
||||||
var container = document.getElementById('activeRecordingsContainer');
|
if (!dateStr) return 'N/A';
|
||||||
container.innerHTML = '<p class="msg">Loading...</p>';
|
var d = new Date(dateStr);
|
||||||
|
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
|
||||||
|
},
|
||||||
|
|
||||||
fetch(this.serverUrl + '/Plugins/SRFPlay/Recording/All', { headers: this.getHeaders() })
|
formatSize: function(bytes) {
|
||||||
.then(function(r) { return r.json(); })
|
if (!bytes) return '';
|
||||||
.then(function(recordings) {
|
if (bytes > 1073741824) return (bytes / 1073741824).toFixed(1) + ' GB';
|
||||||
var activeStates = ['Scheduled', 'WaitingForStream', 'Recording', 0, 1, 2];
|
if (bytes > 1048576) return (bytes / 1048576).toFixed(0) + ' MB';
|
||||||
var active = recordings.filter(function(r) {
|
return (bytes / 1024).toFixed(0) + ' KB';
|
||||||
return activeStates.indexOf(r.state) !== -1;
|
},
|
||||||
});
|
|
||||||
|
|
||||||
if (active.length === 0) {
|
loadSchedule: function() {
|
||||||
container.innerHTML = '<p class="msg">No scheduled or active recordings.</p>';
|
var container = document.getElementById('srfrecScheduleContainer');
|
||||||
return;
|
container.innerHTML = '<p class="srfrec-msg">Loading schedule...</p>';
|
||||||
}
|
|
||||||
|
|
||||||
var stateMap = {
|
fetch(this.serverUrl + '/Plugins/SRFPlay/Recording/Schedule', { headers: this.getHeaders() })
|
||||||
'Scheduled': {label: 'Scheduled', cls: 'status-scheduled'},
|
.then(function(r) { return r.json(); })
|
||||||
'WaitingForStream': {label: 'Waiting', cls: 'status-waiting'},
|
.then(function(programs) {
|
||||||
'Recording': {label: 'Recording', cls: 'status-recording'},
|
if (!programs || programs.length === 0) {
|
||||||
0: {label: 'Scheduled', cls: 'status-scheduled'},
|
container.innerHTML = '<p class="srfrec-msg">No upcoming sport livestreams found.</p>';
|
||||||
1: {label: 'Waiting', cls: 'status-waiting'},
|
return;
|
||||||
2: {label: 'Recording', cls: 'status-recording'}
|
|
||||||
};
|
|
||||||
|
|
||||||
var html = '<table>';
|
|
||||||
html += '<thead><tr><th>Title</th><th>Status</th><th>Start</th><th>Action</th></tr></thead><tbody>';
|
|
||||||
|
|
||||||
active.forEach(function(r) {
|
|
||||||
var st = stateMap[r.state] || {label: r.state, cls: ''};
|
|
||||||
html += '<tr>';
|
|
||||||
html += '<td>' + (r.title || 'Unknown') + '</td>';
|
|
||||||
html += '<td><span class="status ' + st.cls + '">' + st.label + '</span></td>';
|
|
||||||
html += '<td>' + SRFRec.formatDate(r.validFrom) + '</td>';
|
|
||||||
html += '<td>';
|
|
||||||
if (r.state === 2 || r.state === 'Recording') {
|
|
||||||
html += '<button class="btn btn-stop" onclick="SRFRec.stopRecording(\'' + r.id + '\')">Stop</button>';
|
|
||||||
} else {
|
|
||||||
html += '<button class="btn btn-cancel" onclick="SRFRec.cancelRecording(\'' + r.id + '\')">Cancel</button>';
|
|
||||||
}
|
}
|
||||||
html += '</td></tr>';
|
|
||||||
|
var html = '<table>';
|
||||||
|
html += '<thead><tr><th>Title</th><th>Start</th><th>End</th><th>Action</th></tr></thead><tbody>';
|
||||||
|
|
||||||
|
programs.forEach(function(p) {
|
||||||
|
html += '<tr>';
|
||||||
|
html += '<td>' + (p.title || 'Unknown') + '</td>';
|
||||||
|
html += '<td>' + SRFRec.formatDate(p.validFrom || p.date) + '</td>';
|
||||||
|
html += '<td>' + SRFRec.formatDate(p.validTo) + '</td>';
|
||||||
|
html += '<td><button type="button" class="btn btn-record" onclick="SRFRec.scheduleRecording(\'' + encodeURIComponent(p.urn) + '\')">Record</button></td>';
|
||||||
|
html += '</tr>';
|
||||||
|
});
|
||||||
|
|
||||||
|
html += '</tbody></table>';
|
||||||
|
container.innerHTML = html;
|
||||||
|
})
|
||||||
|
.catch(function(err) {
|
||||||
|
container.innerHTML = '<p class="srfrec-error">Error loading schedule: ' + err.message + '</p>';
|
||||||
});
|
});
|
||||||
|
},
|
||||||
|
|
||||||
html += '</tbody></table>';
|
scheduleRecording: function(encodedUrn) {
|
||||||
container.innerHTML = html;
|
var self = this;
|
||||||
|
fetch(this.serverUrl + '/Plugins/SRFPlay/Recording/Schedule/' + encodedUrn, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: this.getHeaders()
|
||||||
})
|
})
|
||||||
.catch(function(err) {
|
.then(function(r) {
|
||||||
container.innerHTML = '<p class="error">Error: ' + err.message + '</p>';
|
if (r.ok) {
|
||||||
});
|
alert('Recording scheduled!');
|
||||||
},
|
self.loadRecordings();
|
||||||
|
} else {
|
||||||
loadCompletedRecordings: function() {
|
alert('Failed to schedule recording');
|
||||||
var container = document.getElementById('completedRecordingsContainer');
|
|
||||||
container.innerHTML = '<p class="msg">Loading...</p>';
|
|
||||||
|
|
||||||
fetch(this.serverUrl + '/Plugins/SRFPlay/Recording/Completed', { headers: this.getHeaders() })
|
|
||||||
.then(function(r) { return r.json(); })
|
|
||||||
.then(function(recordings) {
|
|
||||||
if (!recordings || recordings.length === 0) {
|
|
||||||
container.innerHTML = '<p class="msg">No completed recordings.</p>';
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var html = '<table>';
|
|
||||||
html += '<thead><tr><th>Title</th><th>Recorded</th><th>Size</th><th>Action</th></tr></thead><tbody>';
|
|
||||||
|
|
||||||
recordings.forEach(function(r) {
|
|
||||||
html += '<tr>';
|
|
||||||
html += '<td>' + (r.title || 'Unknown') + '</td>';
|
|
||||||
html += '<td>' + SRFRec.formatDate(r.recordingStartedAt) + '</td>';
|
|
||||||
html += '<td>' + SRFRec.formatSize(r.fileSizeBytes) + '</td>';
|
|
||||||
html += '<td><button class="btn btn-delete" onclick="SRFRec.deleteRecording(\'' + r.id + '\')">Delete</button></td>';
|
|
||||||
html += '</tr>';
|
|
||||||
});
|
|
||||||
|
|
||||||
html += '</tbody></table>';
|
|
||||||
container.innerHTML = html;
|
|
||||||
})
|
})
|
||||||
.catch(function(err) {
|
.catch(function(err) { alert('Error: ' + err.message); });
|
||||||
container.innerHTML = '<p class="error">Error: ' + err.message + '</p>';
|
},
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
stopRecording: function(id) {
|
loadRecordings: function() {
|
||||||
var self = this;
|
this.loadActiveRecordings();
|
||||||
fetch(this.serverUrl + '/Plugins/SRFPlay/Recording/Active/' + id + '/Stop', {
|
this.loadCompletedRecordings();
|
||||||
method: 'POST',
|
},
|
||||||
headers: this.getHeaders()
|
|
||||||
}).then(function() { self.loadRecordings(); });
|
|
||||||
},
|
|
||||||
|
|
||||||
cancelRecording: function(id) {
|
loadActiveRecordings: function() {
|
||||||
var self = this;
|
var container = document.getElementById('srfrecActiveRecordingsContainer');
|
||||||
fetch(this.serverUrl + '/Plugins/SRFPlay/Recording/Schedule/' + id, {
|
container.innerHTML = '<p class="srfrec-msg">Loading...</p>';
|
||||||
method: 'DELETE',
|
|
||||||
headers: this.getHeaders()
|
|
||||||
}).then(function() { self.loadRecordings(); });
|
|
||||||
},
|
|
||||||
|
|
||||||
deleteRecording: function(id) {
|
fetch(this.serverUrl + '/Plugins/SRFPlay/Recording/All', { headers: this.getHeaders() })
|
||||||
if (!confirm('Delete this recording and its file?')) return;
|
.then(function(r) { return r.json(); })
|
||||||
var self = this;
|
.then(function(recordings) {
|
||||||
fetch(this.serverUrl + '/Plugins/SRFPlay/Recording/Completed/' + id + '?deleteFile=true', {
|
var activeStates = ['Scheduled', 'WaitingForStream', 'Recording', 0, 1, 2];
|
||||||
method: 'DELETE',
|
var active = recordings.filter(function(r) {
|
||||||
headers: this.getHeaders()
|
return activeStates.indexOf(r.state) !== -1;
|
||||||
}).then(function() { self.loadRecordings(); });
|
});
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
SRFRec.init();
|
if (active.length === 0) {
|
||||||
</script>
|
container.innerHTML = '<p class="srfrec-msg">No scheduled or active recordings.</p>';
|
||||||
</body>
|
return;
|
||||||
</html>
|
}
|
||||||
|
|
||||||
|
var stateMap = {
|
||||||
|
'Scheduled': {label: 'Scheduled', cls: 'status-scheduled'},
|
||||||
|
'WaitingForStream': {label: 'Waiting', cls: 'status-waiting'},
|
||||||
|
'Recording': {label: 'Recording', cls: 'status-recording'},
|
||||||
|
0: {label: 'Scheduled', cls: 'status-scheduled'},
|
||||||
|
1: {label: 'Waiting', cls: 'status-waiting'},
|
||||||
|
2: {label: 'Recording', cls: 'status-recording'}
|
||||||
|
};
|
||||||
|
|
||||||
|
var html = '<table>';
|
||||||
|
html += '<thead><tr><th>Title</th><th>Status</th><th>Start</th><th>Action</th></tr></thead><tbody>';
|
||||||
|
|
||||||
|
active.forEach(function(r) {
|
||||||
|
var st = stateMap[r.state] || {label: r.state, cls: ''};
|
||||||
|
html += '<tr>';
|
||||||
|
html += '<td>' + (r.title || 'Unknown') + '</td>';
|
||||||
|
html += '<td><span class="status ' + st.cls + '">' + st.label + '</span></td>';
|
||||||
|
html += '<td>' + SRFRec.formatDate(r.validFrom) + '</td>';
|
||||||
|
html += '<td>';
|
||||||
|
if (r.state === 2 || r.state === 'Recording') {
|
||||||
|
html += '<button type="button" class="btn btn-stop" onclick="SRFRec.stopRecording(\'' + r.id + '\')">Stop</button>';
|
||||||
|
} else {
|
||||||
|
html += '<button type="button" class="btn btn-cancel" onclick="SRFRec.cancelRecording(\'' + r.id + '\')">Cancel</button>';
|
||||||
|
}
|
||||||
|
html += '</td></tr>';
|
||||||
|
});
|
||||||
|
|
||||||
|
html += '</tbody></table>';
|
||||||
|
container.innerHTML = html;
|
||||||
|
})
|
||||||
|
.catch(function(err) {
|
||||||
|
container.innerHTML = '<p class="srfrec-error">Error: ' + err.message + '</p>';
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
loadCompletedRecordings: function() {
|
||||||
|
var container = document.getElementById('srfrecCompletedRecordingsContainer');
|
||||||
|
container.innerHTML = '<p class="srfrec-msg">Loading...</p>';
|
||||||
|
|
||||||
|
fetch(this.serverUrl + '/Plugins/SRFPlay/Recording/Completed', { headers: this.getHeaders() })
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(recordings) {
|
||||||
|
if (!recordings || recordings.length === 0) {
|
||||||
|
container.innerHTML = '<p class="srfrec-msg">No completed recordings.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var html = '<table>';
|
||||||
|
html += '<thead><tr><th>Title</th><th>Recorded</th><th>Size</th><th>Action</th></tr></thead><tbody>';
|
||||||
|
|
||||||
|
recordings.forEach(function(r) {
|
||||||
|
html += '<tr>';
|
||||||
|
html += '<td>' + (r.title || 'Unknown') + '</td>';
|
||||||
|
html += '<td>' + SRFRec.formatDate(r.recordingStartedAt) + '</td>';
|
||||||
|
html += '<td>' + SRFRec.formatSize(r.fileSizeBytes) + '</td>';
|
||||||
|
html += '<td><button type="button" class="btn btn-delete" onclick="SRFRec.deleteRecording(\'' + r.id + '\')">Delete</button></td>';
|
||||||
|
html += '</tr>';
|
||||||
|
});
|
||||||
|
|
||||||
|
html += '</tbody></table>';
|
||||||
|
container.innerHTML = html;
|
||||||
|
})
|
||||||
|
.catch(function(err) {
|
||||||
|
container.innerHTML = '<p class="srfrec-error">Error: ' + err.message + '</p>';
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
stopRecording: function(id) {
|
||||||
|
var self = this;
|
||||||
|
fetch(this.serverUrl + '/Plugins/SRFPlay/Recording/Active/' + id + '/Stop', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: this.getHeaders()
|
||||||
|
}).then(function() { self.loadRecordings(); });
|
||||||
|
},
|
||||||
|
|
||||||
|
cancelRecording: function(id) {
|
||||||
|
var self = this;
|
||||||
|
fetch(this.serverUrl + '/Plugins/SRFPlay/Recording/Schedule/' + id, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: this.getHeaders()
|
||||||
|
}).then(function() { self.loadRecordings(); });
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteRecording: function(id) {
|
||||||
|
if (!confirm('Delete this recording and its file?')) return;
|
||||||
|
var self = this;
|
||||||
|
fetch(this.serverUrl + '/Plugins/SRFPlay/Recording/Completed/' + id + '?deleteFile=true', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: this.getHeaders()
|
||||||
|
}).then(function() { self.loadRecordings(); });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
var page = document.querySelector('#SRFPlayRecordingsPage');
|
||||||
|
if (page && typeof page.addEventListener === 'function' && typeof ApiClient !== 'undefined') {
|
||||||
|
// Inside the Jellyfin dashboard: defer to the page lifecycle so we
|
||||||
|
// never run during the config page's view and never hijack routing.
|
||||||
|
page.addEventListener('pageshow', function() { SRFRec.init(); });
|
||||||
|
} else {
|
||||||
|
// Standalone (e.g. TV/browser): initialise immediately.
|
||||||
|
SRFRec.init();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
using System.Threading;
|
||||||
|
using Jellyfin.Plugin.SRFPlay.Api.Models;
|
||||||
|
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
||||||
|
using MediaBrowser.Common.Api;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.SRFPlay.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Administrative maintenance actions for the SRF Play plugin.
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Route("Plugins/SRFPlay/Maintenance")]
|
||||||
|
[Authorize(Policy = Policies.RequiresElevation)]
|
||||||
|
public class MaintenanceController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly ILogger<MaintenanceController> _logger;
|
||||||
|
private readonly IResumeCleanupService _resumeCleanupService;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="MaintenanceController"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="logger">The logger.</param>
|
||||||
|
/// <param name="resumeCleanupService">The resume cleanup service.</param>
|
||||||
|
public MaintenanceController(
|
||||||
|
ILogger<MaintenanceController> logger,
|
||||||
|
IResumeCleanupService resumeCleanupService)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_resumeCleanupService = resumeCleanupService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Clears stale SRF Play resume points from every user's "Continue Watching" row.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="clearAll">
|
||||||
|
/// When true, clears every SRF Play resume point instead of only the stale ones.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="cancellationToken">The cancellation token.</param>
|
||||||
|
/// <returns>A summary of what was inspected and cleared.</returns>
|
||||||
|
[HttpPost("ResumePoints/Cleanup")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
public ActionResult<ResumeCleanupResult> CleanupResumePoints(
|
||||||
|
[FromQuery] bool clearAll,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Manual Continue Watching cleanup requested (clearAll: {ClearAll})", clearAll);
|
||||||
|
return Ok(_resumeCleanupService.Cleanup(clearAll, cancellationToken));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
|
using System;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
@@ -19,6 +21,9 @@ namespace Jellyfin.Plugin.SRFPlay.Controllers;
|
|||||||
[Authorize]
|
[Authorize]
|
||||||
public class RecordingController : ControllerBase
|
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 ILogger<RecordingController> _logger;
|
||||||
private readonly IRecordingService _recordingService;
|
private readonly IRecordingService _recordingService;
|
||||||
|
|
||||||
@@ -40,6 +45,7 @@ public class RecordingController : ControllerBase
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>The recording manager HTML page.</returns>
|
/// <returns>The recording manager HTML page.</returns>
|
||||||
[HttpGet("Page")]
|
[HttpGet("Page")]
|
||||||
|
[AllowAnonymous]
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
public IActionResult GetRecordingPage()
|
public IActionResult GetRecordingPage()
|
||||||
@@ -55,6 +61,67 @@ public class RecordingController : ControllerBase
|
|||||||
return File(resourceStream, "text/html");
|
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>
|
/// <summary>
|
||||||
/// Gets upcoming sport livestreams available for recording.
|
/// Gets upcoming sport livestreams available for recording.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -225,7 +225,8 @@ public class StreamProxyController : ControllerBase
|
|||||||
queryParams = string.Empty;
|
queryParams = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
var rewrittenContent = _proxyService.RewriteVariantManifestUrls(manifestContent, baseProxyUrl, queryParams);
|
var isLiveStream = _proxyService.GetStreamMetadata(actualItemId)?.IsLiveStream ?? false;
|
||||||
|
var rewrittenContent = _proxyService.RewriteVariantManifestUrls(manifestContent, baseProxyUrl, queryParams, isLiveStream);
|
||||||
|
|
||||||
// Set cache headers based on stream type (live vs VOD)
|
// Set cache headers based on stream type (live vs VOD)
|
||||||
// Variant manifests use stricter no-cache for live streams
|
// Variant manifests use stricter no-cache for live streams
|
||||||
|
|||||||
@@ -1,7 +1,17 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<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>
|
<RootNamespace>Jellyfin.Plugin.SRFPlay</RootNamespace>
|
||||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
@@ -10,9 +20,30 @@
|
|||||||
<CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet>
|
<CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet>
|
||||||
</PropertyGroup>
|
</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.Controller" Version="10.9.11" />
|
||||||
<PackageReference Include="Jellyfin.Model" 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="Socks5" Version="1.1.0" />
|
||||||
<PackageReference Include="SkiaSharp" Version="2.88.8" />
|
<PackageReference Include="SkiaSharp" Version="2.88.8" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
@@ -33,6 +64,11 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<None Remove="..\assests\main logo.png" />
|
<None Remove="..\assests\main logo.png" />
|
||||||
<EmbeddedResource Include="..\assests\main logo.png" LogicalName="Jellyfin.Plugin.SRFPlay.Images.logo.png" />
|
<EmbeddedResource Include="..\assests\main logo.png" LogicalName="Jellyfin.Plugin.SRFPlay.Images.logo.png" />
|
||||||
|
<EmbeddedResource Include="..\assests\units\srf-logo.png" LogicalName="Jellyfin.Plugin.SRFPlay.Images.srf-logo.png" />
|
||||||
|
<EmbeddedResource Include="..\assests\units\rts-logo.png" LogicalName="Jellyfin.Plugin.SRFPlay.Images.rts-logo.png" />
|
||||||
|
<EmbeddedResource Include="..\assests\units\rsi-logo.png" LogicalName="Jellyfin.Plugin.SRFPlay.Images.rsi-logo.png" />
|
||||||
|
<EmbeddedResource Include="..\assests\units\rtr-logo.png" LogicalName="Jellyfin.Plugin.SRFPlay.Images.rtr-logo.png" />
|
||||||
|
<EmbeddedResource Include="..\assests\units\swi-logo.png" LogicalName="Jellyfin.Plugin.SRFPlay.Images.swi-logo.png" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -45,16 +45,13 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
|||||||
{
|
{
|
||||||
Name = Name,
|
Name = Name,
|
||||||
EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.Configuration.configPage.html", GetType().Namespace)
|
EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.Configuration.configPage.html", GetType().Namespace)
|
||||||
},
|
|
||||||
new PluginPageInfo
|
|
||||||
{
|
|
||||||
Name = "SRF Play Recordings",
|
|
||||||
DisplayName = "SRF Sport Recordings",
|
|
||||||
EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.Configuration.recordingPage.html", GetType().Namespace),
|
|
||||||
EnableInMainMenu = true,
|
|
||||||
MenuSection = "Live TV",
|
|
||||||
MenuIcon = "fiber_smart_record"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Note: the recordings manager is intentionally NOT registered as a plugin
|
||||||
|
// page. Plugin pages live under the admin Dashboard and are admin-gated, which
|
||||||
|
// both caused a redirect to the recordings page and made it inaccessible to
|
||||||
|
// normal users. Recordings are served as a standalone, user-accessible page at
|
||||||
|
// GET /Plugins/SRFPlay/Recording/Page (see RecordingController).
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
|||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
||||||
|
using Jellyfin.Plugin.SRFPlay.Utilities;
|
||||||
using MediaBrowser.Model.Tasks;
|
using MediaBrowser.Model.Tasks;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
@@ -63,20 +64,25 @@ public class ContentRefreshTask : IScheduledTask
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh latest content
|
// Refresh content for every business unit (one channel per unit).
|
||||||
if (config.EnableLatestContent)
|
var units = System.Enum.GetValues<Configuration.BusinessUnit>();
|
||||||
|
foreach (var unit in units)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Refreshing latest content");
|
var businessUnit = unit.ToLowerString();
|
||||||
progress?.Report(25);
|
|
||||||
await _contentRefreshService.RefreshLatestContentAsync(cancellationToken).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refresh trending content
|
if (config.EnableLatestContent)
|
||||||
if (config.EnableTrendingContent)
|
{
|
||||||
{
|
_logger.LogInformation("Refreshing latest content for {BusinessUnit}", businessUnit);
|
||||||
_logger.LogInformation("Refreshing trending content");
|
progress?.Report(25);
|
||||||
progress?.Report(75);
|
await _contentRefreshService.RefreshLatestContentAsync(businessUnit, cancellationToken).ConfigureAwait(false);
|
||||||
await _contentRefreshService.RefreshTrendingContentAsync(cancellationToken).ConfigureAwait(false);
|
}
|
||||||
|
|
||||||
|
if (config.EnableTrendingContent)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Refreshing trending content for {BusinessUnit}", businessUnit);
|
||||||
|
progress?.Report(75);
|
||||||
|
await _contentRefreshService.RefreshTrendingContentAsync(businessUnit, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
progress?.Report(100);
|
progress?.Report(100);
|
||||||
@@ -98,11 +104,7 @@ public class ContentRefreshTask : IScheduledTask
|
|||||||
// Run every X hours as configured
|
// Run every X hours as configured
|
||||||
return new[]
|
return new[]
|
||||||
{
|
{
|
||||||
new TaskTriggerInfo
|
TaskTriggers.Interval(TimeSpan.FromHours(intervalHours))
|
||||||
{
|
|
||||||
Type = TaskTriggerInfo.TriggerInterval,
|
|
||||||
IntervalTicks = TimeSpan.FromHours(intervalHours).Ticks
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
|||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
||||||
|
using Jellyfin.Plugin.SRFPlay.Utilities;
|
||||||
using MediaBrowser.Model.Tasks;
|
using MediaBrowser.Model.Tasks;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
@@ -87,16 +88,8 @@ public class ExpirationCheckTask : IScheduledTask
|
|||||||
// Run daily at 3 AM or every X hours as configured
|
// Run daily at 3 AM or every X hours as configured
|
||||||
return new[]
|
return new[]
|
||||||
{
|
{
|
||||||
new TaskTriggerInfo
|
TaskTriggers.Daily(TimeSpan.FromHours(3)),
|
||||||
{
|
TaskTriggers.Interval(TimeSpan.FromHours(intervalHours))
|
||||||
Type = TaskTriggerInfo.TriggerDaily,
|
|
||||||
TimeOfDayTicks = TimeSpan.FromHours(3).Ticks
|
|
||||||
},
|
|
||||||
new TaskTriggerInfo
|
|
||||||
{
|
|
||||||
Type = TaskTriggerInfo.TriggerInterval,
|
|
||||||
IntervalTicks = TimeSpan.FromHours(intervalHours).Ticks
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
|||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
||||||
|
using Jellyfin.Plugin.SRFPlay.Utilities;
|
||||||
using MediaBrowser.Model.Tasks;
|
using MediaBrowser.Model.Tasks;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
@@ -66,11 +67,7 @@ public class RecordingSchedulerTask : IScheduledTask
|
|||||||
{
|
{
|
||||||
return new[]
|
return new[]
|
||||||
{
|
{
|
||||||
new TaskTriggerInfo
|
TaskTriggers.Interval(TimeSpan.FromSeconds(30))
|
||||||
{
|
|
||||||
Type = TaskTriggerInfo.TriggerInterval,
|
|
||||||
IntervalTicks = TimeSpan.FromSeconds(30).Ticks
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
using System;
|
||||||
|
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;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.SRFPlay.ScheduledTasks;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Scheduled task that clears stale SRF Play resume points from "Continue Watching".
|
||||||
|
/// </summary>
|
||||||
|
public class ResumeCleanupTask : IScheduledTask
|
||||||
|
{
|
||||||
|
private readonly ILogger<ResumeCleanupTask> _logger;
|
||||||
|
private readonly IResumeCleanupService _resumeCleanupService;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="ResumeCleanupTask"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="logger">The logger.</param>
|
||||||
|
/// <param name="resumeCleanupService">The resume cleanup service.</param>
|
||||||
|
public ResumeCleanupTask(
|
||||||
|
ILogger<ResumeCleanupTask> logger,
|
||||||
|
IResumeCleanupService resumeCleanupService)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_resumeCleanupService = resumeCleanupService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string Name => "Clean Up SRF Play Continue Watching";
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string Description => "Removes stale SRF Play resume points - ended livestreams, abandoned playback and finished programmes - from every user's Continue Watching row";
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string Category => "SRF Play";
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string Key => "SRFPlayResumeCleanup";
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Starting SRF Play Continue Watching cleanup");
|
||||||
|
progress?.Report(0);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = _resumeCleanupService.Cleanup(false, cancellationToken);
|
||||||
|
|
||||||
|
progress?.Report(100);
|
||||||
|
_logger.LogInformation(
|
||||||
|
"SRF Play Continue Watching cleanup completed. Cleared {Cleared} of {Inspected} resume points",
|
||||||
|
result.Cleared,
|
||||||
|
result.Inspected);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("SRF Play Continue Watching cleanup was cancelled");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error during SRF Play Continue Watching cleanup");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
|
||||||
|
{
|
||||||
|
// Runs after the 3 AM expiration check so items deleted there are already gone.
|
||||||
|
return new[]
|
||||||
|
{
|
||||||
|
TaskTriggers.Daily(TimeSpan.FromHours(4))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,6 +32,11 @@ public class ServiceRegistrator : IPluginServiceRegistrator
|
|||||||
serviceCollection.AddSingleton<IContentExpirationService, ContentExpirationService>();
|
serviceCollection.AddSingleton<IContentExpirationService, ContentExpirationService>();
|
||||||
serviceCollection.AddSingleton<IContentRefreshService, ContentRefreshService>();
|
serviceCollection.AddSingleton<IContentRefreshService, ContentRefreshService>();
|
||||||
serviceCollection.AddSingleton<ICategoryService, CategoryService>();
|
serviceCollection.AddSingleton<ICategoryService, CategoryService>();
|
||||||
|
serviceCollection.AddSingleton<IResumeCleanupService, ResumeCleanupService>();
|
||||||
|
|
||||||
|
// Clears livestream resume points the moment playback stops, so ended broadcasts never
|
||||||
|
// linger in "Continue Watching" waiting for the nightly cleanup task.
|
||||||
|
serviceCollection.AddHostedService<PlaybackResumeGuard>();
|
||||||
|
|
||||||
// Register metadata providers
|
// Register metadata providers
|
||||||
serviceCollection.AddSingleton<SRFSeriesProvider>();
|
serviceCollection.AddSingleton<SRFSeriesProvider>();
|
||||||
@@ -48,8 +53,15 @@ public class ServiceRegistrator : IPluginServiceRegistrator
|
|||||||
serviceCollection.AddSingleton<IScheduledTask, ContentRefreshTask>();
|
serviceCollection.AddSingleton<IScheduledTask, ContentRefreshTask>();
|
||||||
serviceCollection.AddSingleton<IScheduledTask, ExpirationCheckTask>();
|
serviceCollection.AddSingleton<IScheduledTask, ExpirationCheckTask>();
|
||||||
serviceCollection.AddSingleton<IScheduledTask, RecordingSchedulerTask>();
|
serviceCollection.AddSingleton<IScheduledTask, RecordingSchedulerTask>();
|
||||||
|
serviceCollection.AddSingleton<IScheduledTask, ResumeCleanupTask>();
|
||||||
|
|
||||||
// Register channel - must register as IChannel interface for Jellyfin to discover it
|
// Register one channel (tile) per SRG business unit. Each must be registered as IChannel
|
||||||
serviceCollection.AddSingleton<IChannel, SRFPlayChannel>();
|
// for Jellyfin to discover it. A unit that is not in EnabledBusinessUnits returns no
|
||||||
|
// content (its tile appears empty) - see SrgChannelBase.
|
||||||
|
serviceCollection.AddSingleton<IChannel, SrfChannel>();
|
||||||
|
serviceCollection.AddSingleton<IChannel, RtsChannel>();
|
||||||
|
serviceCollection.AddSingleton<IChannel, RsiChannel>();
|
||||||
|
serviceCollection.AddSingleton<IChannel, RtrChannel>();
|
||||||
|
serviceCollection.AddSingleton<IChannel, SwiChannel>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,10 @@ public class CategoryService : ICategoryService
|
|||||||
private readonly ILogger<CategoryService> _logger;
|
private readonly ILogger<CategoryService> _logger;
|
||||||
private readonly ISRFApiClientFactory _apiClientFactory;
|
private readonly ISRFApiClientFactory _apiClientFactory;
|
||||||
private readonly TimeSpan _topicsCacheDuration = TimeSpan.FromHours(24);
|
private readonly TimeSpan _topicsCacheDuration = TimeSpan.FromHours(24);
|
||||||
private Dictionary<string, PlayV3Topic>? _topicsCache;
|
|
||||||
private DateTime _topicsCacheExpiry = DateTime.MinValue;
|
// Cache topics per business unit. A single shared cache would return one unit's
|
||||||
|
// topics (e.g. German SRF) even after the user switched to another (e.g. French RTS).
|
||||||
|
private readonly Dictionary<string, (Dictionary<string, PlayV3Topic> Topics, DateTime Expiry)> _topicsCacheByUnit = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="CategoryService"/> class.
|
/// Initializes a new instance of the <see cref="CategoryService"/> class.
|
||||||
@@ -35,11 +37,11 @@ public class CategoryService : ICategoryService
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<List<PlayV3Topic>> GetTopicsAsync(string businessUnit, CancellationToken cancellationToken = default)
|
public async Task<List<PlayV3Topic>> GetTopicsAsync(string businessUnit, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
// Return cached topics if still valid
|
// Return cached topics for this business unit if still valid
|
||||||
if (_topicsCache != null && DateTime.UtcNow < _topicsCacheExpiry)
|
if (_topicsCacheByUnit.TryGetValue(businessUnit, out var cached) && DateTime.UtcNow < cached.Expiry)
|
||||||
{
|
{
|
||||||
_logger.LogDebug("Returning cached topics for business unit: {BusinessUnit}", businessUnit);
|
_logger.LogDebug("Returning cached topics for business unit: {BusinessUnit}", businessUnit);
|
||||||
return _topicsCache.Values.ToList();
|
return cached.Topics.Values.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("Fetching topics for business unit: {BusinessUnit}", businessUnit);
|
_logger.LogInformation("Fetching topics for business unit: {BusinessUnit}", businessUnit);
|
||||||
@@ -48,13 +50,13 @@ public class CategoryService : ICategoryService
|
|||||||
|
|
||||||
if (topics != null && topics.Count > 0)
|
if (topics != null && topics.Count > 0)
|
||||||
{
|
{
|
||||||
// Cache topics by ID for quick lookups
|
// Cache topics by ID for quick lookups, scoped to this business unit
|
||||||
_topicsCache = topics
|
var byId = topics
|
||||||
.Where(t => !string.IsNullOrEmpty(t.Id))
|
.Where(t => !string.IsNullOrEmpty(t.Id))
|
||||||
.ToDictionary(t => t.Id!, t => t);
|
.ToDictionary(t => t.Id!, t => t);
|
||||||
_topicsCacheExpiry = DateTime.UtcNow.Add(_topicsCacheDuration);
|
_topicsCacheByUnit[businessUnit] = (byId, DateTime.UtcNow.Add(_topicsCacheDuration));
|
||||||
|
|
||||||
_logger.LogInformation("Cached {Count} topics for business unit: {BusinessUnit}", _topicsCache.Count, businessUnit);
|
_logger.LogInformation("Cached {Count} topics for business unit: {BusinessUnit}", byId.Count, businessUnit);
|
||||||
}
|
}
|
||||||
|
|
||||||
return topics ?? new List<PlayV3Topic>();
|
return topics ?? new List<PlayV3Topic>();
|
||||||
@@ -63,13 +65,14 @@ public class CategoryService : ICategoryService
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<PlayV3Topic?> GetTopicByIdAsync(string topicId, string businessUnit, CancellationToken cancellationToken = default)
|
public async Task<PlayV3Topic?> GetTopicByIdAsync(string topicId, string businessUnit, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
// Ensure topics are loaded
|
// Ensure topics are loaded for this business unit
|
||||||
if (_topicsCache == null || DateTime.UtcNow >= _topicsCacheExpiry)
|
if (!_topicsCacheByUnit.TryGetValue(businessUnit, out var cached) || DateTime.UtcNow >= cached.Expiry)
|
||||||
{
|
{
|
||||||
await GetTopicsAsync(businessUnit, cancellationToken).ConfigureAwait(false);
|
await GetTopicsAsync(businessUnit, cancellationToken).ConfigureAwait(false);
|
||||||
|
_topicsCacheByUnit.TryGetValue(businessUnit, out cached);
|
||||||
}
|
}
|
||||||
|
|
||||||
return _topicsCache?.GetValueOrDefault(topicId);
|
return cached.Topics?.GetValueOrDefault(topicId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -101,8 +104,7 @@ public class CategoryService : ICategoryService
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void ClearCache()
|
public void ClearCache()
|
||||||
{
|
{
|
||||||
_topicsCache = null;
|
_topicsCacheByUnit.Clear();
|
||||||
_topicsCacheExpiry = DateTime.MinValue;
|
|
||||||
_logger.LogInformation("Topics cache cleared");
|
_logger.LogInformation("Topics cache cleared");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,9 +34,10 @@ public class ContentRefreshService : IContentRefreshService
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Refreshes latest content from SRF API using Play v3.
|
/// Refreshes latest content from SRF API using Play v3.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="businessUnit">The business unit to fetch content for (e.g. "srf", "rts").</param>
|
||||||
/// <param name="cancellationToken">The cancellation token.</param>
|
/// <param name="cancellationToken">The cancellation token.</param>
|
||||||
/// <returns>List of URNs for new content.</returns>
|
/// <returns>List of URNs for new content.</returns>
|
||||||
public async Task<List<string>> RefreshLatestContentAsync(CancellationToken cancellationToken)
|
public async Task<List<string>> RefreshLatestContentAsync(string businessUnit, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var config = Plugin.Instance?.Configuration;
|
var config = Plugin.Instance?.Configuration;
|
||||||
if (config == null || !config.EnableLatestContent)
|
if (config == null || !config.EnableLatestContent)
|
||||||
@@ -46,7 +47,7 @@ public class ContentRefreshService : IContentRefreshService
|
|||||||
}
|
}
|
||||||
|
|
||||||
return await FetchVideosFromShowsAsync(
|
return await FetchVideosFromShowsAsync(
|
||||||
config.BusinessUnit.ToLowerString(),
|
businessUnit,
|
||||||
minEpisodeCount: 0,
|
minEpisodeCount: 0,
|
||||||
maxShows: 20,
|
maxShows: 20,
|
||||||
videosPerShow: 1,
|
videosPerShow: 1,
|
||||||
@@ -58,9 +59,10 @@ public class ContentRefreshService : IContentRefreshService
|
|||||||
/// Refreshes trending content from SRF API using Play v3.
|
/// Refreshes trending content from SRF API using Play v3.
|
||||||
/// Gets videos from shows with the most episodes.
|
/// Gets videos from shows with the most episodes.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="businessUnit">The business unit to fetch content for (e.g. "srf", "rts").</param>
|
||||||
/// <param name="cancellationToken">The cancellation token.</param>
|
/// <param name="cancellationToken">The cancellation token.</param>
|
||||||
/// <returns>List of URNs for trending content.</returns>
|
/// <returns>List of URNs for trending content.</returns>
|
||||||
public async Task<List<string>> RefreshTrendingContentAsync(CancellationToken cancellationToken)
|
public async Task<List<string>> RefreshTrendingContentAsync(string businessUnit, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var config = Plugin.Instance?.Configuration;
|
var config = Plugin.Instance?.Configuration;
|
||||||
if (config == null || !config.EnableTrendingContent)
|
if (config == null || !config.EnableTrendingContent)
|
||||||
@@ -70,7 +72,7 @@ public class ContentRefreshService : IContentRefreshService
|
|||||||
}
|
}
|
||||||
|
|
||||||
return await FetchVideosFromShowsAsync(
|
return await FetchVideosFromShowsAsync(
|
||||||
config.BusinessUnit.ToLowerString(),
|
businessUnit,
|
||||||
minEpisodeCount: 10,
|
minEpisodeCount: 10,
|
||||||
maxShows: 15,
|
maxShows: 15,
|
||||||
videosPerShow: 2,
|
videosPerShow: 2,
|
||||||
|
|||||||
@@ -12,14 +12,16 @@ public interface IContentRefreshService
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Refreshes latest content from SRF API using Play v3.
|
/// Refreshes latest content from SRF API using Play v3.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="businessUnit">The business unit to fetch content for (e.g. "srf", "rts").</param>
|
||||||
/// <param name="cancellationToken">The cancellation token.</param>
|
/// <param name="cancellationToken">The cancellation token.</param>
|
||||||
/// <returns>List of URNs for new content.</returns>
|
/// <returns>List of URNs for new content.</returns>
|
||||||
Task<List<string>> RefreshLatestContentAsync(CancellationToken cancellationToken);
|
Task<List<string>> RefreshLatestContentAsync(string businessUnit, CancellationToken cancellationToken);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Refreshes trending content from SRF API using Play v3.
|
/// Refreshes trending content from SRF API using Play v3.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="businessUnit">The business unit to fetch content for (e.g. "srf", "rts").</param>
|
||||||
/// <param name="cancellationToken">The cancellation token.</param>
|
/// <param name="cancellationToken">The cancellation token.</param>
|
||||||
/// <returns>List of URNs for trending content.</returns>
|
/// <returns>List of URNs for trending content.</returns>
|
||||||
Task<List<string>> RefreshTrendingContentAsync(CancellationToken cancellationToken);
|
Task<List<string>> RefreshTrendingContentAsync(string businessUnit, CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,19 @@ public interface IRecordingService
|
|||||||
/// <returns>List of matching recording entries.</returns>
|
/// <returns>List of matching recording entries.</returns>
|
||||||
IReadOnlyList<RecordingEntry> GetRecordings(RecordingState? stateFilter = null);
|
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>
|
/// <summary>
|
||||||
/// Deletes a completed recording (entry and optionally the file).
|
/// Deletes a completed recording (entry and optionally the file).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes stale plugin-owned resume points so ended livestreams and abandoned playback
|
||||||
|
/// stop accumulating in every user's "Continue Watching" row.
|
||||||
|
/// </summary>
|
||||||
|
public interface IResumeCleanupService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Scans every user's resumable items and clears the resume point of those owned by this
|
||||||
|
/// plugin that match a staleness rule. Items belonging to other plugins or to the regular
|
||||||
|
/// library are never touched.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="clearAll">
|
||||||
|
/// When true every plugin-owned resume point is cleared regardless of the staleness rules.
|
||||||
|
/// Used by the "Clear all" maintenance action.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="cancellationToken">The cancellation token.</param>
|
||||||
|
/// <returns>A summary of what was inspected and cleared.</returns>
|
||||||
|
ResumeCleanupResult Cleanup(bool clearAll, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Clears the resume point of a single item for the given users.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="item">The item whose resume point should be reset.</param>
|
||||||
|
/// <param name="users">The users to clear it for.</param>
|
||||||
|
/// <param name="reason">A short reason, logged for traceability.</param>
|
||||||
|
/// <returns>The number of resume points actually cleared.</returns>
|
||||||
|
int ClearResumePoint(BaseItem item, IEnumerable<User> users, string reason);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determines whether an item belongs to this plugin (an SRG channel item or a recording).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="item">The item to test.</param>
|
||||||
|
/// <returns>True if the item is owned by this plugin.</returns>
|
||||||
|
bool IsPluginItem(BaseItem item);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Clears the resume point if the item is one of this plugin's livestreams and livestream
|
||||||
|
/// cleanup is enabled. Called right after playback stops so an ended broadcast never reaches
|
||||||
|
/// "Continue Watching" in the first place, instead of waiting for the nightly task.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="item">The item that just stopped playing.</param>
|
||||||
|
/// <param name="users">The users the playback was attributed to.</param>
|
||||||
|
/// <returns>The number of resume points cleared.</returns>
|
||||||
|
int ClearLiveStreamResumePoint(BaseItem item, IEnumerable<User> users);
|
||||||
|
}
|
||||||
@@ -71,8 +71,9 @@ public interface IStreamProxyService
|
|||||||
/// <param name="manifestContent">The variant manifest content.</param>
|
/// <param name="manifestContent">The variant manifest content.</param>
|
||||||
/// <param name="baseProxyUrl">The base proxy URL (without query params).</param>
|
/// <param name="baseProxyUrl">The base proxy URL (without query params).</param>
|
||||||
/// <param name="queryParams">Query parameters to append to rewritten URLs (e.g., "?token=abc").</param>
|
/// <param name="queryParams">Query parameters to append to rewritten URLs (e.g., "?token=abc").</param>
|
||||||
|
/// <param name="isLiveStream">Whether this is a livestream; if so an EXT-X-START offset is injected.</param>
|
||||||
/// <returns>The rewritten manifest content.</returns>
|
/// <returns>The rewritten manifest content.</returns>
|
||||||
string RewriteVariantManifestUrls(string manifestContent, string baseProxyUrl, string queryParams);
|
string RewriteVariantManifestUrls(string manifestContent, string baseProxyUrl, string queryParams, bool isLiveStream = false);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Cleans up old and expired stream mappings.
|
/// Cleans up old and expired stream mappings.
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ using Jellyfin.Plugin.SRFPlay.Api.Models;
|
|||||||
using Jellyfin.Plugin.SRFPlay.Configuration;
|
using Jellyfin.Plugin.SRFPlay.Configuration;
|
||||||
using Jellyfin.Plugin.SRFPlay.Constants;
|
using Jellyfin.Plugin.SRFPlay.Constants;
|
||||||
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
||||||
|
using Jellyfin.Plugin.SRFPlay.Utilities;
|
||||||
using MediaBrowser.Controller;
|
using MediaBrowser.Controller;
|
||||||
using MediaBrowser.Model.Dto;
|
using MediaBrowser.Model.Dto;
|
||||||
using MediaBrowser.Model.Entities;
|
using MediaBrowser.Model.Entities;
|
||||||
@@ -62,9 +63,15 @@ public class MediaSourceFactory : IMediaSourceFactory
|
|||||||
return Task.FromResult<MediaSourceInfo?>(null);
|
return Task.FromResult<MediaSourceInfo?>(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect if this is a live stream
|
// A scheduled livestream is only treated as live when the broadcast window is open.
|
||||||
var isLiveStream = chapter.Type == "SCHEDULED_LIVESTREAM" ||
|
// Past replays (ValidTo in the past) and upcoming events (ValidFrom in the future)
|
||||||
urn.Contains("livestream", StringComparison.OrdinalIgnoreCase);
|
// are treated as VOD to avoid IsInfiniteStream/IgnoreDts flags and FFmpeg -re mode.
|
||||||
|
var isScheduledLivestream = chapter.Type == "SCHEDULED_LIVESTREAM" ||
|
||||||
|
UrnHelper.IsLivestreamUrn(urn);
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var isLiveStream = isScheduledLivestream &&
|
||||||
|
(chapter.ValidFrom == null || chapter.ValidFrom.Value.ToUniversalTime() <= now) &&
|
||||||
|
(chapter.ValidTo == null || chapter.ValidTo.Value.ToUniversalTime() > now);
|
||||||
|
|
||||||
// Register stream with UNAUTHENTICATED URL - proxy will authenticate on-demand
|
// Register stream with UNAUTHENTICATED URL - proxy will authenticate on-demand
|
||||||
// This avoids wasting 30-second tokens during category browsing
|
// This avoids wasting 30-second tokens during category browsing
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
||||||
|
using MediaBrowser.Controller.Library;
|
||||||
|
using MediaBrowser.Controller.Session;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.SRFPlay.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Watches for livestream playback ending and immediately drops the resume point Jellyfin
|
||||||
|
/// just saved for it. Without this, every livestream a user stops watching sticks in
|
||||||
|
/// "Continue Watching" until the nightly cleanup task runs.
|
||||||
|
/// </summary>
|
||||||
|
public class PlaybackResumeGuard : IHostedService
|
||||||
|
{
|
||||||
|
private readonly ILogger<PlaybackResumeGuard> _logger;
|
||||||
|
private readonly ISessionManager _sessionManager;
|
||||||
|
private readonly IResumeCleanupService _resumeCleanupService;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="PlaybackResumeGuard"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="logger">The logger.</param>
|
||||||
|
/// <param name="sessionManager">The session manager raising playback events.</param>
|
||||||
|
/// <param name="resumeCleanupService">The resume cleanup service.</param>
|
||||||
|
public PlaybackResumeGuard(
|
||||||
|
ILogger<PlaybackResumeGuard> logger,
|
||||||
|
ISessionManager sessionManager,
|
||||||
|
IResumeCleanupService resumeCleanupService)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_sessionManager = sessionManager;
|
||||||
|
_resumeCleanupService = resumeCleanupService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task StartAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_sessionManager.PlaybackStopped += OnPlaybackStopped;
|
||||||
|
_logger.LogDebug("PlaybackResumeGuard attached to session manager");
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task StopAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_sessionManager.PlaybackStopped -= OnPlaybackStopped;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPlaybackStopped(object? sender, PlaybackStopEventArgs e)
|
||||||
|
{
|
||||||
|
// Jellyfin saves the resume point before raising this event, so clearing it here wins.
|
||||||
|
if (e?.Item == null || e.Users == null || e.Users.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var cleared = _resumeCleanupService.ClearLiveStreamResumePoint(e.Item, e.Users);
|
||||||
|
if (cleared > 0)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(
|
||||||
|
"Dropped {Count} resume point(s) for finished livestream '{Name}'",
|
||||||
|
cleared,
|
||||||
|
e.Item.Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Never let a cleanup failure escape into Jellyfin's playback event pipeline.
|
||||||
|
_logger.LogError(ex, "Error clearing livestream resume point for '{Name}'", e.Item.Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ using Jellyfin.Plugin.SRFPlay.Api;
|
|||||||
using Jellyfin.Plugin.SRFPlay.Api.Models;
|
using Jellyfin.Plugin.SRFPlay.Api.Models;
|
||||||
using Jellyfin.Plugin.SRFPlay.Api.Models.PlayV3;
|
using Jellyfin.Plugin.SRFPlay.Api.Models.PlayV3;
|
||||||
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
||||||
|
using Jellyfin.Plugin.SRFPlay.Utilities;
|
||||||
using MediaBrowser.Controller;
|
using MediaBrowser.Controller;
|
||||||
using MediaBrowser.Controller.MediaEncoding;
|
using MediaBrowser.Controller.MediaEncoding;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
@@ -24,6 +25,15 @@ namespace Jellyfin.Plugin.SRFPlay.Services;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class RecordingService : IRecordingService, IDisposable
|
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 ILogger<RecordingService> _logger;
|
||||||
private readonly ISRFApiClientFactory _apiClientFactory;
|
private readonly ISRFApiClientFactory _apiClientFactory;
|
||||||
private readonly IStreamProxyService _proxyService;
|
private readonly IStreamProxyService _proxyService;
|
||||||
@@ -31,7 +41,7 @@ public class RecordingService : IRecordingService, IDisposable
|
|||||||
private readonly IMediaCompositionFetcher _mediaCompositionFetcher;
|
private readonly IMediaCompositionFetcher _mediaCompositionFetcher;
|
||||||
private readonly IServerApplicationHost _appHost;
|
private readonly IServerApplicationHost _appHost;
|
||||||
private readonly IMediaEncoder _mediaEncoder;
|
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 static readonly JsonSerializerOptions _jsonOptions = new() { WriteIndented = true };
|
||||||
private readonly SemaphoreSlim _persistLock = new(1, 1);
|
private readonly SemaphoreSlim _persistLock = new(1, 1);
|
||||||
private readonly SemaphoreSlim _processLock = new(1, 1);
|
private readonly SemaphoreSlim _processLock = new(1, 1);
|
||||||
@@ -147,19 +157,25 @@ public class RecordingService : IRecordingService, IDisposable
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<IReadOnlyList<PlayV3TvProgram>> GetUpcomingScheduleAsync(CancellationToken cancellationToken)
|
public async Task<IReadOnlyList<PlayV3TvProgram>> GetUpcomingScheduleAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var config = Plugin.Instance?.Configuration;
|
var units = System.Enum.GetValues<Configuration.BusinessUnit>();
|
||||||
var businessUnit = (config?.BusinessUnit ?? Configuration.BusinessUnit.SRF).ToString().ToLowerInvariant();
|
|
||||||
|
|
||||||
using var apiClient = _apiClientFactory.CreateClient();
|
using var apiClient = _apiClientFactory.CreateClient();
|
||||||
var livestreams = await apiClient.GetScheduledLivestreamsAsync(businessUnit, "SPORT", cancellationToken).ConfigureAwait(false);
|
|
||||||
|
|
||||||
if (livestreams == null)
|
// Aggregate sport livestreams across every business unit so the recordings
|
||||||
|
// page shows events from all languages.
|
||||||
|
var all = new List<PlayV3TvProgram>();
|
||||||
|
foreach (var unit in units)
|
||||||
{
|
{
|
||||||
return Array.Empty<PlayV3TvProgram>();
|
var businessUnit = unit.ToString().ToLowerInvariant();
|
||||||
|
var livestreams = await apiClient.GetScheduledLivestreamsAsync(businessUnit, "SPORT", cancellationToken).ConfigureAwait(false);
|
||||||
|
if (livestreams != null)
|
||||||
|
{
|
||||||
|
all.AddRange(livestreams);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter to only future/current livestreams that aren't blocked
|
// Filter to only future/current livestreams that aren't blocked
|
||||||
return livestreams
|
return all
|
||||||
.Where(ls => ls.Blocked != true && (ls.ValidTo == null || ls.ValidTo.Value.ToUniversalTime() > DateTime.UtcNow))
|
.Where(ls => ls.Blocked != true && (ls.ValidTo == null || ls.ValidTo.Value.ToUniversalTime() > DateTime.UtcNow))
|
||||||
.OrderBy(ls => ls.ValidFrom)
|
.OrderBy(ls => ls.ValidFrom)
|
||||||
.ToList();
|
.ToList();
|
||||||
@@ -178,9 +194,11 @@ public class RecordingService : IRecordingService, IDisposable
|
|||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch metadata for the URN
|
// Fetch metadata for the URN. The unit is encoded in the URN itself, so use that to
|
||||||
|
// query the right schedule regardless of which units are enabled.
|
||||||
var config = Plugin.Instance?.Configuration;
|
var config = Plugin.Instance?.Configuration;
|
||||||
var businessUnit = (config?.BusinessUnit ?? Configuration.BusinessUnit.SRF).ToString().ToLowerInvariant();
|
var businessUnit = ParseBusinessUnitFromUrn(urn)
|
||||||
|
?? (config?.BusinessUnit ?? Configuration.BusinessUnit.SRF).ToString().ToLowerInvariant();
|
||||||
|
|
||||||
using var apiClient = _apiClientFactory.CreateClient();
|
using var apiClient = _apiClientFactory.CreateClient();
|
||||||
var livestreams = await apiClient.GetScheduledLivestreamsAsync(businessUnit, "SPORT", cancellationToken).ConfigureAwait(false);
|
var livestreams = await apiClient.GetScheduledLivestreamsAsync(businessUnit, "SPORT", cancellationToken).ConfigureAwait(false);
|
||||||
@@ -190,6 +208,7 @@ public class RecordingService : IRecordingService, IDisposable
|
|||||||
{
|
{
|
||||||
Id = Guid.NewGuid().ToString("N"),
|
Id = Guid.NewGuid().ToString("N"),
|
||||||
Urn = urn,
|
Urn = urn,
|
||||||
|
BusinessUnit = ParseBusinessUnitFromUrn(urn) ?? businessUnit,
|
||||||
Title = program?.Title ?? urn,
|
Title = program?.Title ?? urn,
|
||||||
Description = program?.Lead ?? program?.Description,
|
Description = program?.Lead ?? program?.Description,
|
||||||
ImageUrl = program?.ImageUrl,
|
ImageUrl = program?.ImageUrl,
|
||||||
@@ -206,6 +225,24 @@ public class RecordingService : IRecordingService, IDisposable
|
|||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extracts the business unit from an SRF URN of the form "urn:<bu>:<type>:...".
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="urn">The URN.</param>
|
||||||
|
/// <returns>The lowercase business unit, or null if it cannot be determined.</returns>
|
||||||
|
private static string? ParseBusinessUnitFromUrn(string urn)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(urn))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var parts = urn.Split(':');
|
||||||
|
return parts.Length >= 2 && !string.IsNullOrEmpty(parts[1])
|
||||||
|
? parts[1].ToLowerInvariant()
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public bool CancelRecording(string recordingId)
|
public bool CancelRecording(string recordingId)
|
||||||
{
|
{
|
||||||
@@ -238,15 +275,7 @@ public class RecordingService : IRecordingService, IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
StopFfmpeg(recordingId);
|
StopFfmpeg(recordingId);
|
||||||
|
CompleteRecording(entry, DateTime.UtcNow);
|
||||||
entry.State = RecordingState.Completed;
|
|
||||||
entry.RecordingEndedAt = DateTime.UtcNow;
|
|
||||||
|
|
||||||
if (entry.OutputPath != null && File.Exists(entry.OutputPath))
|
|
||||||
{
|
|
||||||
entry.FileSizeBytes = new FileInfo(entry.OutputPath).Length;
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = SaveRecordingsAsync();
|
_ = SaveRecordingsAsync();
|
||||||
|
|
||||||
_logger.LogInformation("Stopped recording '{Title}' ({Id})", entry.Title, recordingId);
|
_logger.LogInformation("Stopped recording '{Title}' ({Id})", entry.Title, recordingId);
|
||||||
@@ -270,6 +299,22 @@ public class RecordingService : IRecordingService, IDisposable
|
|||||||
return _recordings.OrderByDescending(r => r.CreatedAt).ToList();
|
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 />
|
/// <inheritdoc />
|
||||||
public bool DeleteRecording(string recordingId, bool deleteFile)
|
public bool DeleteRecording(string recordingId, bool deleteFile)
|
||||||
{
|
{
|
||||||
@@ -341,8 +386,26 @@ public class RecordingService : IRecordingService, IDisposable
|
|||||||
{
|
{
|
||||||
case RecordingState.Scheduled:
|
case RecordingState.Scheduled:
|
||||||
case RecordingState.WaitingForStream:
|
case RecordingState.WaitingForStream:
|
||||||
// Check if it's time to start recording
|
// Give up once the broadcast window is over, otherwise we'd retry forever
|
||||||
if (validFromUtc.HasValue && validFromUtc.Value <= now.AddMinutes(2))
|
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(
|
_logger.LogInformation(
|
||||||
"Time to start recording '{Title}': ValidFrom={ValidFrom} (UTC: {ValidFromUtc}), Now={Now}",
|
"Time to start recording '{Title}': ValidFrom={ValidFrom} (UTC: {ValidFromUtc}), Now={Now}",
|
||||||
@@ -361,13 +424,7 @@ public class RecordingService : IRecordingService, IDisposable
|
|||||||
{
|
{
|
||||||
_logger.LogInformation("Recording '{Title}' reached ValidTo, stopping", entry.Title);
|
_logger.LogInformation("Recording '{Title}' reached ValidTo, stopping", entry.Title);
|
||||||
StopFfmpeg(entry.Id);
|
StopFfmpeg(entry.Id);
|
||||||
entry.State = RecordingState.Completed;
|
CompleteRecording(entry, now);
|
||||||
entry.RecordingEndedAt = now;
|
|
||||||
if (entry.OutputPath != null && File.Exists(entry.OutputPath))
|
|
||||||
{
|
|
||||||
entry.FileSizeBytes = new FileInfo(entry.OutputPath).Length;
|
|
||||||
}
|
|
||||||
|
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
else if (!_activeProcesses.ContainsKey(entry.Id))
|
else if (!_activeProcesses.ContainsKey(entry.Id))
|
||||||
@@ -398,7 +455,7 @@ public class RecordingService : IRecordingService, IDisposable
|
|||||||
if (chapter == null)
|
if (chapter == null)
|
||||||
{
|
{
|
||||||
_logger.LogDebug("No chapter found for '{Title}', stream may not be live yet", entry.Title);
|
_logger.LogDebug("No chapter found for '{Title}', stream may not be live yet", entry.Title);
|
||||||
entry.State = RecordingState.WaitingForStream;
|
MarkStreamUnavailable(entry);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -409,34 +466,57 @@ public class RecordingService : IRecordingService, IDisposable
|
|||||||
if (string.IsNullOrEmpty(streamUrl))
|
if (string.IsNullOrEmpty(streamUrl))
|
||||||
{
|
{
|
||||||
_logger.LogDebug("No stream URL available for '{Title}', waiting", entry.Title);
|
_logger.LogDebug("No stream URL available for '{Title}', waiting", entry.Title);
|
||||||
entry.State = RecordingState.WaitingForStream;
|
MarkStreamUnavailable(entry);
|
||||||
return true;
|
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
|
// Register the stream with the proxy so we can use the proxy URL
|
||||||
var itemId = $"rec_{entry.Id}";
|
var itemId = $"rec_{entry.Id}";
|
||||||
var isLiveStream = chapter.Type == "SCHEDULED_LIVESTREAM" || entry.Urn.Contains("livestream", StringComparison.OrdinalIgnoreCase);
|
var isLiveStream = chapter.Type == "SCHEDULED_LIVESTREAM" || UrnHelper.IsLivestreamUrn(entry.Urn);
|
||||||
_proxyService.RegisterStreamDeferred(itemId, streamUrl, entry.Urn, isLiveStream);
|
_proxyService.RegisterStreamDeferred(itemId, streamUrl, entry.Urn, isLiveStream);
|
||||||
|
|
||||||
// Build proxy URL for ffmpeg (use localhost for local access)
|
// Build proxy URL for ffmpeg (use localhost for local access)
|
||||||
var proxyUrl = $"{GetServerBaseUrl()}/Plugins/SRFPlay/Proxy/{itemId}/master.m3u8";
|
var proxyUrl = $"{GetServerBaseUrl()}/Plugins/SRFPlay/Proxy/{itemId}/master.m3u8";
|
||||||
|
|
||||||
// Build output file path
|
// Restarts append to the existing .ts so a recording stays one file
|
||||||
var safeTitle = SanitizeFileName(entry.Title);
|
var outputPath = entry.OutputPath;
|
||||||
var timestamp = DateTime.Now.ToString("yyyy-MM-dd_HHmm", CultureInfo.InvariantCulture);
|
if (string.IsNullOrEmpty(outputPath) || !outputPath.EndsWith(".ts", StringComparison.OrdinalIgnoreCase))
|
||||||
var outputPath = Path.Combine(GetRecordingOutputPath(), $"{safeTitle}_{timestamp}.mkv");
|
{
|
||||||
entry.OutputPath = outputPath;
|
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
|
// Start ffmpeg
|
||||||
StartFfmpeg(entry.Id, proxyUrl, outputPath);
|
StartFfmpeg(entry.Id, proxyUrl, outputPath);
|
||||||
|
|
||||||
entry.State = RecordingState.Recording;
|
entry.State = RecordingState.Recording;
|
||||||
entry.RecordingStartedAt = DateTime.UtcNow;
|
entry.RecordingStartedAt ??= DateTime.UtcNow;
|
||||||
|
|
||||||
_logger.LogInformation("Started recording '{Title}' to {OutputPath}", entry.Title, outputPath);
|
_logger.LogInformation("Started recording '{Title}' to {OutputPath}", entry.Title, outputPath);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Failed to start recording '{Title}'", entry.Title);
|
_logger.LogError(ex, "Failed to start recording '{Title}'", entry.Title);
|
||||||
entry.State = RecordingState.Failed;
|
entry.State = RecordingState.Failed;
|
||||||
@@ -445,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)
|
private void StartFfmpeg(string recordingId, string inputUrl, string outputPath)
|
||||||
{
|
{
|
||||||
var process = new Process
|
var process = new Process
|
||||||
@@ -452,13 +587,16 @@ public class RecordingService : IRecordingService, IDisposable
|
|||||||
StartInfo = new ProcessStartInfo
|
StartInfo = new ProcessStartInfo
|
||||||
{
|
{
|
||||||
FileName = _mediaEncoder.EncoderPath,
|
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,
|
UseShellExecute = false,
|
||||||
RedirectStandardInput = true,
|
RedirectStandardInput = true,
|
||||||
|
RedirectStandardOutput = true,
|
||||||
RedirectStandardError = true,
|
RedirectStandardError = true,
|
||||||
CreateNoWindow = true
|
CreateNoWindow = true
|
||||||
},
|
}
|
||||||
EnableRaisingEvents = true
|
|
||||||
};
|
};
|
||||||
|
|
||||||
process.ErrorDataReceived += (_, args) =>
|
process.ErrorDataReceived += (_, args) =>
|
||||||
@@ -469,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.Start();
|
||||||
process.BeginErrorReadLine();
|
process.BeginErrorReadLine();
|
||||||
|
|
||||||
_activeProcesses[recordingId] = process;
|
var active = new ActiveRecording(process);
|
||||||
_logger.LogInformation("Started ffmpeg (PID {Pid}) for recording {RecordingId}: {Args}", process.Id, recordingId, process.StartInfo.Arguments);
|
_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)
|
private void StopFfmpeg(string recordingId)
|
||||||
{
|
{
|
||||||
if (_activeProcesses.TryRemove(recordingId, out var process))
|
if (_activeProcesses.TryRemove(recordingId, out var active))
|
||||||
{
|
{
|
||||||
|
var process = active.Process;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (!process.HasExited)
|
if (!process.HasExited)
|
||||||
@@ -501,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();
|
process.Dispose();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -550,4 +716,19 @@ public class RecordingService : IRecordingService, IDisposable
|
|||||||
|
|
||||||
_disposed = true;
|
_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; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,310 @@
|
|||||||
|
using System;
|
||||||
|
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;
|
||||||
|
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
||||||
|
using Jellyfin.Plugin.SRFPlay.Utilities;
|
||||||
|
using MediaBrowser.Controller.Dto;
|
||||||
|
using MediaBrowser.Controller.Entities;
|
||||||
|
using MediaBrowser.Controller.Library;
|
||||||
|
using MediaBrowser.Model.Entities;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.SRFPlay.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes stale plugin-owned resume points from every user's "Continue Watching" row.
|
||||||
|
/// </summary>
|
||||||
|
public class ResumeCleanupService : IResumeCleanupService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Provider ID key stamped onto every item built from the SRG API.
|
||||||
|
/// </summary>
|
||||||
|
private const string SrfProviderId = "SRF";
|
||||||
|
|
||||||
|
private readonly ILogger<ResumeCleanupService> _logger;
|
||||||
|
private readonly ILibraryManager _libraryManager;
|
||||||
|
private readonly IUserManager _userManager;
|
||||||
|
private readonly IUserDataManager _userDataManager;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="ResumeCleanupService"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="loggerFactory">The logger factory.</param>
|
||||||
|
/// <param name="libraryManager">The library manager.</param>
|
||||||
|
/// <param name="userManager">The user manager.</param>
|
||||||
|
/// <param name="userDataManager">The user data manager.</param>
|
||||||
|
public ResumeCleanupService(
|
||||||
|
ILoggerFactory loggerFactory,
|
||||||
|
ILibraryManager libraryManager,
|
||||||
|
IUserManager userManager,
|
||||||
|
IUserDataManager userDataManager)
|
||||||
|
{
|
||||||
|
_logger = loggerFactory.CreateLogger<ResumeCleanupService>();
|
||||||
|
_libraryManager = libraryManager;
|
||||||
|
_userManager = userManager;
|
||||||
|
_userDataManager = userDataManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public ResumeCleanupResult Cleanup(bool clearAll, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = new ResumeCleanupResult();
|
||||||
|
var config = Plugin.Instance?.Configuration;
|
||||||
|
|
||||||
|
if (config == null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Plugin configuration not available - skipping resume point cleanup");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!clearAll && !config.CleanUpResumePoints)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Resume point cleanup is disabled in configuration");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
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++;
|
||||||
|
|
||||||
|
var query = new InternalItemsQuery(user)
|
||||||
|
{
|
||||||
|
IsResumable = true,
|
||||||
|
Recursive = true,
|
||||||
|
EnableTotalRecordCount = false,
|
||||||
|
DtoOptions = new DtoOptions(false)
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var item in _libraryManager.GetItemList(query))
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
if (!IsPluginItem(item, channelIds))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Inspected++;
|
||||||
|
|
||||||
|
var userData = _userDataManager.GetUserData(user, item);
|
||||||
|
if (userData == null || userData.PlaybackPositionTicks <= 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var reason = clearAll ? "clear all" : GetStaleReason(item, userData, config);
|
||||||
|
if (reason == null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ClearResumePoint(item, user, userData, reason))
|
||||||
|
{
|
||||||
|
result.Cleared++;
|
||||||
|
result.ClearedByReason.TryGetValue(reason, out var count);
|
||||||
|
result.ClearedByReason[reason] = count + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Resume point cleanup finished: {Cleared} of {Inspected} SRF Play resume points cleared across {Users} user(s)",
|
||||||
|
result.Cleared,
|
||||||
|
result.Inspected,
|
||||||
|
result.UsersChecked);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public int ClearResumePoint(BaseItem item, IEnumerable<User> users, string reason)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(item);
|
||||||
|
ArgumentNullException.ThrowIfNull(users);
|
||||||
|
|
||||||
|
var cleared = 0;
|
||||||
|
|
||||||
|
foreach (var user in users)
|
||||||
|
{
|
||||||
|
var userData = _userDataManager.GetUserData(user, item);
|
||||||
|
if (userData == null || userData.PlaybackPositionTicks <= 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ClearResumePoint(item, user, userData, reason))
|
||||||
|
{
|
||||||
|
cleared++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cleared;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool IsPluginItem(BaseItem item)
|
||||||
|
{
|
||||||
|
return IsPluginItem(item, GetPluginChannelIds());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public int ClearLiveStreamResumePoint(BaseItem item, IEnumerable<User> users)
|
||||||
|
{
|
||||||
|
if (item == null || Plugin.Instance?.Configuration.ClearLiveStreamResumePoints != true)
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only the URN heuristic here - the item is already known, and re-querying the channel
|
||||||
|
// list on every playback stop would be wasteful.
|
||||||
|
if (!UrnHelper.IsLivestreamUrn(item.ProviderIds.GetValueOrDefault(SrfProviderId)))
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ClearResumePoint(item, users, "livestream stopped");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determines whether an item belongs to one of this plugin's channels.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="item">The item to test.</param>
|
||||||
|
/// <param name="channelIds">The set of channel IDs owned by this plugin.</param>
|
||||||
|
/// <returns>True if the item is owned by this plugin.</returns>
|
||||||
|
private static bool IsPluginItem(BaseItem item, HashSet<Guid> channelIds)
|
||||||
|
{
|
||||||
|
if (item == null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// API-sourced content carries the URN as a provider ID. Recordings do not, so fall back
|
||||||
|
// to the owning channel, which also covers any item shape added later.
|
||||||
|
return item.ProviderIds.ContainsKey(SrfProviderId)
|
||||||
|
|| (!item.ChannelId.Equals(Guid.Empty) && channelIds.Contains(item.ChannelId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves the IDs of the channel entries created by this plugin, one per business unit.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>The set of channel IDs, empty if the channels have not been scanned yet.</returns>
|
||||||
|
private HashSet<Guid> GetPluginChannelIds()
|
||||||
|
{
|
||||||
|
// SrgChannelBase names every channel "{unit} Play".
|
||||||
|
var names = Enum.GetNames<BusinessUnit>()
|
||||||
|
.Select(unit => unit + " Play")
|
||||||
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
var channels = _libraryManager.GetItemList(new InternalItemsQuery
|
||||||
|
{
|
||||||
|
IncludeItemTypes = new[] { BaseItemKind.Channel },
|
||||||
|
EnableTotalRecordCount = false,
|
||||||
|
DtoOptions = new DtoOptions(false)
|
||||||
|
});
|
||||||
|
|
||||||
|
return channels
|
||||||
|
.Where(channel => channel.Name != null && names.Contains(channel.Name))
|
||||||
|
.Select(channel => channel.Id)
|
||||||
|
.ToHashSet();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Decides whether a resume point is stale, and why.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="item">The item the resume point belongs to.</param>
|
||||||
|
/// <param name="userData">The user data holding the resume position.</param>
|
||||||
|
/// <param name="config">The plugin configuration supplying the thresholds.</param>
|
||||||
|
/// <returns>A short reason string, or null when the resume point should be kept.</returns>
|
||||||
|
private static string? GetStaleReason(BaseItem item, UserItemData userData, PluginConfiguration config)
|
||||||
|
{
|
||||||
|
// A livestream has no meaningful position to return to: by the time the user comes back
|
||||||
|
// the broadcast has moved on or ended. These are the entries that pile up the fastest.
|
||||||
|
if (config.ClearLiveStreamResumePoints
|
||||||
|
&& UrnHelper.IsLivestreamUrn(item.ProviderIds.GetValueOrDefault(SrfProviderId)))
|
||||||
|
{
|
||||||
|
return "livestream";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.ResumePointMaxAgeDays > 0
|
||||||
|
&& userData.LastPlayedDate.HasValue
|
||||||
|
&& userData.LastPlayedDate.Value.ToUniversalTime() < DateTime.UtcNow.AddDays(-config.ResumePointMaxAgeDays))
|
||||||
|
{
|
||||||
|
return "older than " + config.ResumePointMaxAgeDays.ToString(CultureInfo.InvariantCulture) + " days";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.ResumePointMinPositionSeconds > 0
|
||||||
|
&& userData.PlaybackPositionTicks <= TimeSpan.FromSeconds(config.ResumePointMinPositionSeconds).Ticks)
|
||||||
|
{
|
||||||
|
return "barely started";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runtime is unknown for live and for some API items; without it there is nothing to
|
||||||
|
// compare the position against, so leave the resume point alone.
|
||||||
|
if (config.ResumePointCompletedPercent > 0
|
||||||
|
&& item.RunTimeTicks.HasValue
|
||||||
|
&& item.RunTimeTicks.Value > 0)
|
||||||
|
{
|
||||||
|
var watchedPercent = userData.PlaybackPositionTicks * 100.0 / item.RunTimeTicks.Value;
|
||||||
|
if (watchedPercent >= config.ResumePointCompletedPercent)
|
||||||
|
{
|
||||||
|
return "watched to the end";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Zeroes the resume position, which removes the item from "Continue Watching" while
|
||||||
|
/// leaving it unwatched so it can still be played again from the start.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="item">The item to clear.</param>
|
||||||
|
/// <param name="user">The user to clear it for.</param>
|
||||||
|
/// <param name="userData">The user data to update.</param>
|
||||||
|
/// <param name="reason">A short reason, logged for traceability.</param>
|
||||||
|
/// <returns>True if the resume point was cleared.</returns>
|
||||||
|
private bool ClearResumePoint(BaseItem item, User user, UserItemData userData, string reason)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
userData.PlaybackPositionTicks = 0;
|
||||||
|
|
||||||
|
_userDataManager.SaveUserData(
|
||||||
|
user,
|
||||||
|
item,
|
||||||
|
userData,
|
||||||
|
UserDataSaveReason.UpdateUserData,
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Cleared resume point for '{Name}' (user {User}, reason: {Reason})",
|
||||||
|
item.Name,
|
||||||
|
user.Username,
|
||||||
|
reason);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to clear resume point for '{Name}'", item.Name);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -132,10 +132,13 @@ public class StreamProxyService : IStreamProxyService
|
|||||||
!string.IsNullOrEmpty(streamInfo.AuthenticatedUrl));
|
!string.IsNullOrEmpty(streamInfo.AuthenticatedUrl));
|
||||||
|
|
||||||
// Check for stale alias: only look for fresher stream if current token is EXPIRED or EXPIRING SOON
|
// 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
|
// Don't replace a valid token (>5s left) with a new deferred registration.
|
||||||
if (!streamInfo.NeedsAuthentication && tokenTimeLeft < 5)
|
// 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)
|
if (freshStream != null && freshStream.Value.Value.NeedsAuthentication)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(
|
_logger.LogWarning(
|
||||||
@@ -595,8 +598,9 @@ public class StreamProxyService : IStreamProxyService
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Finds the freshest (most recently registered) stream that needs authentication or has a valid token.
|
/// Finds the freshest (most recently registered) stream that needs authentication or has a valid token.
|
||||||
/// </summary>
|
/// </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>
|
/// <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;
|
var now = DateTime.UtcNow;
|
||||||
|
|
||||||
@@ -604,6 +608,11 @@ public class StreamProxyService : IStreamProxyService
|
|||||||
// or have tokens that aren't expired yet
|
// or have tokens that aren't expired yet
|
||||||
var candidates = _streamMappings.Where(kvp =>
|
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)
|
if (kvp.Value.NeedsAuthentication)
|
||||||
{
|
{
|
||||||
return true; // Fresh deferred registration
|
return true; // Fresh deferred registration
|
||||||
@@ -900,9 +909,15 @@ public class StreamProxyService : IStreamProxyService
|
|||||||
/// <param name="manifestContent">The variant manifest content.</param>
|
/// <param name="manifestContent">The variant manifest content.</param>
|
||||||
/// <param name="baseProxyUrl">The base proxy URL (without query params).</param>
|
/// <param name="baseProxyUrl">The base proxy URL (without query params).</param>
|
||||||
/// <param name="queryParams">Query parameters to append to rewritten URLs (e.g., "?token=abc").</param>
|
/// <param name="queryParams">Query parameters to append to rewritten URLs (e.g., "?token=abc").</param>
|
||||||
|
/// <param name="isLiveStream">Whether this is a livestream; if so an EXT-X-START offset is injected.</param>
|
||||||
/// <returns>The rewritten manifest content.</returns>
|
/// <returns>The rewritten manifest content.</returns>
|
||||||
public string RewriteVariantManifestUrls(string manifestContent, string baseProxyUrl, string queryParams)
|
public string RewriteVariantManifestUrls(string manifestContent, string baseProxyUrl, string queryParams, bool isLiveStream = false)
|
||||||
{
|
{
|
||||||
|
if (isLiveStream)
|
||||||
|
{
|
||||||
|
manifestContent = InjectLiveStartOffset(manifestContent);
|
||||||
|
}
|
||||||
|
|
||||||
string RewriteUrl(string url)
|
string RewriteUrl(string url)
|
||||||
{
|
{
|
||||||
if (url.Contains("://", StringComparison.Ordinal))
|
if (url.Contains("://", StringComparison.Ordinal))
|
||||||
@@ -953,6 +968,82 @@ public class StreamProxyService : IStreamProxyService
|
|||||||
return result.ToString();
|
return result.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Injects an <c>#EXT-X-START:TIME-OFFSET</c> tag into a live media playlist so that players
|
||||||
|
/// (notably ExoPlayer on Android TV) begin a few segments back from the volatile live edge
|
||||||
|
/// instead of at the edge itself, which otherwise causes stalling/jumping until a manual skip.
|
||||||
|
/// The offset is sized relative to the playlist's own <c>EXT-X-TARGETDURATION</c> to stay
|
||||||
|
/// RFC 8216 compliant (≥ 3 target durations from the edge for live playlists).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="manifestContent">The media playlist content.</param>
|
||||||
|
/// <returns>The playlist with the tag injected, or unchanged if it is not applicable.</returns>
|
||||||
|
private string InjectLiveStartOffset(string manifestContent)
|
||||||
|
{
|
||||||
|
var segmentsBack = Plugin.Instance?.Configuration?.LiveStartSegmentsBack ?? 3;
|
||||||
|
if (segmentsBack <= 0)
|
||||||
|
{
|
||||||
|
return manifestContent; // Disabled by config.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only a media playlist (has segments, no variant list) should carry EXT-X-START.
|
||||||
|
// A master/multivariant playlist or a VOD playlist (#EXT-X-ENDLIST) is left untouched.
|
||||||
|
if (!manifestContent.Contains("#EXTINF", StringComparison.Ordinal)
|
||||||
|
|| manifestContent.Contains("#EXT-X-STREAM-INF", StringComparison.Ordinal)
|
||||||
|
|| manifestContent.Contains("#EXT-X-ENDLIST", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return manifestContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't add a second tag if the source already specified a start point.
|
||||||
|
if (manifestContent.Contains("#EXT-X-START", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Live playlist already contains #EXT-X-START; leaving as-is");
|
||||||
|
return manifestContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
var targetDurationMatch = Regex.Match(manifestContent, @"#EXT-X-TARGETDURATION:(\d+(?:\.\d+)?)");
|
||||||
|
if (!targetDurationMatch.Success
|
||||||
|
|| !double.TryParse(
|
||||||
|
targetDurationMatch.Groups[1].Value,
|
||||||
|
System.Globalization.NumberStyles.Float,
|
||||||
|
System.Globalization.CultureInfo.InvariantCulture,
|
||||||
|
out var targetDuration)
|
||||||
|
|| targetDuration <= 0)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Could not read EXT-X-TARGETDURATION; skipping EXT-X-START injection");
|
||||||
|
return manifestContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
// RFC 8216: TIME-OFFSET SHOULD NOT be within 3 target durations of the live edge.
|
||||||
|
var clampedSegments = Math.Max(3, segmentsBack);
|
||||||
|
var offsetSeconds = clampedSegments * targetDuration;
|
||||||
|
var startTag = string.Format(
|
||||||
|
System.Globalization.CultureInfo.InvariantCulture,
|
||||||
|
"#EXT-X-START:TIME-OFFSET=-{0:0.###},PRECISE=YES",
|
||||||
|
offsetSeconds);
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Injecting {StartTag} into live playlist (targetDuration={TargetDuration}s, segmentsBack={SegmentsBack})",
|
||||||
|
startTag,
|
||||||
|
targetDuration,
|
||||||
|
clampedSegments);
|
||||||
|
|
||||||
|
// Insert after the #EXTM3U header so it sits with the other top-level tags.
|
||||||
|
var headerIndex = manifestContent.IndexOf("#EXTM3U", StringComparison.Ordinal);
|
||||||
|
if (headerIndex < 0)
|
||||||
|
{
|
||||||
|
return startTag + "\n" + manifestContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
var lineEnd = manifestContent.IndexOf('\n', headerIndex);
|
||||||
|
if (lineEnd < 0)
|
||||||
|
{
|
||||||
|
return manifestContent + "\n" + startTag;
|
||||||
|
}
|
||||||
|
|
||||||
|
return manifestContent[..(lineEnd + 1)] + startTag + "\n" + manifestContent[(lineEnd + 1)..];
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Cleans up old and expired stream mappings.
|
/// Cleans up old and expired stream mappings.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,4 +24,17 @@ public static class UrnHelper
|
|||||||
return guid.ToString();
|
return guid.ToString();
|
||||||
}
|
}
|
||||||
#pragma warning restore CA5351
|
#pragma warning restore CA5351
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determines whether a URN refers to a livestream (scheduled or continuous).
|
||||||
|
/// This is a URN-only heuristic that needs no API call, so it stays usable long after
|
||||||
|
/// the broadcast has ended and the media composition is no longer worth fetching.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="urn">The URN to inspect. May be null or empty.</param>
|
||||||
|
/// <returns>True if the URN identifies livestream content.</returns>
|
||||||
|
public static bool IsLivestreamUrn(string? urn)
|
||||||
|
{
|
||||||
|
return !string.IsNullOrEmpty(urn)
|
||||||
|
&& urn.Contains("livestream", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<configuration>
|
||||||
|
<packageSources>
|
||||||
|
<clear />
|
||||||
|
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
|
||||||
|
</packageSources>
|
||||||
|
</configuration>
|
||||||
@@ -22,6 +22,7 @@ Then install "SRF Play" from the plugin catalog.
|
|||||||
- **Live Sports Streaming** - Watch scheduled sports events (skiing, Formula 1, football, tennis, etc.)
|
- **Live Sports Streaming** - Watch scheduled sports events (skiing, Formula 1, football, tennis, etc.)
|
||||||
- Support for all Swiss broadcasting units (SRF, RTS, RSI, RTR, SWI)
|
- Support for all Swiss broadcasting units (SRF, RTS, RSI, RTR, SWI)
|
||||||
- Automatic content expiration handling
|
- Automatic content expiration handling
|
||||||
|
- Continue Watching cleanup — ended livestreams and abandoned playback don't pile up in the resume row
|
||||||
- Latest and trending content discovery
|
- Latest and trending content discovery
|
||||||
- Quality selection (Auto lets CDN decide, SD prefers 480p/360p, HD prefers 1080p/720p)
|
- Quality selection (Auto lets CDN decide, SD prefers 480p/360p, HD prefers 1080p/720p)
|
||||||
- HLS streaming support with Akamai token authentication
|
- HLS streaming support with Akamai token authentication
|
||||||
@@ -98,9 +99,20 @@ Examples:
|
|||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- .NET 8.0 SDK
|
- .NET 10.0 SDK (builds all three targets below)
|
||||||
- Jellyfin 10.9.11 or later
|
- 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
|
### Build Steps
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -110,7 +122,7 @@ dotnet build
|
|||||||
|
|
||||||
### Output
|
### 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
|
## Manual Installation
|
||||||
|
|
||||||
@@ -137,6 +149,16 @@ The compiled plugin will be in `bin/Debug/net8.0/`
|
|||||||
- **Proxy Address**: Proxy server URL (e.g., http://proxy.example.com:8080)
|
- **Proxy Address**: Proxy server URL (e.g., http://proxy.example.com:8080)
|
||||||
- **Proxy Username**: Optional authentication username
|
- **Proxy Username**: Optional authentication username
|
||||||
- **Proxy Password**: Optional authentication password
|
- **Proxy Password**: Optional authentication password
|
||||||
|
- **Continue Watching Cleanup**: Stops stale SRF Play entries accumulating in the resume row
|
||||||
|
- **Clean up stale resume points**: Enables the daily "Clean Up SRF Play Continue Watching" task (4 AM)
|
||||||
|
- **Never keep a resume point for livestreams**: Clears the position the moment a livestream stops
|
||||||
|
- **Maximum Resume Point Age**: Clear entries untouched for this many days (default 30, 0 disables)
|
||||||
|
- **Minimum Resume Position**: Clear entries at or below this position (default 60s, 0 disables)
|
||||||
|
- **Finished Threshold**: Clear entries at or beyond this share of the runtime (default 92%, 0 disables)
|
||||||
|
|
||||||
|
Clearing a resume point only resets the playback position. Nothing is deleted, the item stays
|
||||||
|
unwatched, and only SRF Play items are ever touched. The plugin config page also has
|
||||||
|
**Clean Up Stale Entries Now** and **Clear All SRF Play Entries** buttons for running it on demand.
|
||||||
|
|
||||||
For detailed proxy setup instructions, see [PROXY_SETUP_GUIDE.md](PROXY_SETUP_GUIDE.md).
|
For detailed proxy setup instructions, see [PROXY_SETUP_GUIDE.md](PROXY_SETUP_GUIDE.md).
|
||||||
|
|
||||||
@@ -270,7 +292,12 @@ The plugin includes:
|
|||||||
|
|
||||||
### Contributing
|
### Contributing
|
||||||
|
|
||||||
Contributions welcome!
|
Contributions welcome! This project is hosted on a self-hosted [Gitea](https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay) instance.
|
||||||
|
|
||||||
|
**You don't need a separate account** — you can sign in with your existing GitHub account. On the [sign-in page](https://gitea.tourolle.paris/user/login), choose **"Sign in with GitHub"** to register and log in via GitHub OAuth. Once signed in, you can:
|
||||||
|
|
||||||
|
- **Raise issues** — report bugs or request features on the [issue tracker](https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/issues)
|
||||||
|
- **Contribute code** — fork the repository, push a branch, and open a pull request
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: "SRFPlay"
|
name: "SRFPlay"
|
||||||
guid: "eb5d7894-8eef-4b36-aa6f-5d124e828ce1"
|
guid: "a4b12f86-8c3d-4e9a-b7f2-1d5e6c8a9b4f"
|
||||||
version: "1.0.0.0"
|
version: "1.0.0.0"
|
||||||
targetAbi: "10.9.0.0"
|
targetAbi: "10.9.0.0"
|
||||||
framework: "net8.0"
|
framework: "net8.0"
|
||||||
|
|||||||
@@ -100,6 +100,8 @@
|
|||||||
<Rule Id="CA1308" Action="None" />
|
<Rule Id="CA1308" Action="None" />
|
||||||
<!-- disable warning CA1848: Use the LoggerMessage delegates -->
|
<!-- disable warning CA1848: Use the LoggerMessage delegates -->
|
||||||
<Rule Id="CA1848" Action="None" />
|
<Rule Id="CA1848" Action="None" />
|
||||||
|
<!-- disable warning CA1873: Avoid potentially expensive logging (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 -->
|
<!-- disable warning CA2101: Specify marshaling for P/Invoke string arguments -->
|
||||||
<Rule Id="CA2101" Action="None" />
|
<Rule Id="CA2101" Action="None" />
|
||||||
<!-- disable warning CA2234: Pass System.Uri objects instead of strings -->
|
<!-- disable warning CA2234: Pass System.Uri objects instead of strings -->
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"guid": "a4b12f86-8c3d-4e9a-b7f2-1d5e6c8a9b4f",
|
||||||
|
"name": "SRF Play (Nightly)",
|
||||||
|
"description": "NIGHTLY/UNSTABLE builds of the SRF Play plugin for Jellyfin. Built automatically from the master branch on every push. Expect bugs. For stable releases use the regular SRF Play repository instead.",
|
||||||
|
"overview": "Nightly builds of SRF Play for Jellyfin",
|
||||||
|
"owner": "dtourolle",
|
||||||
|
"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)",
|
||||||
|
"targetAbi": "10.9.0.0",
|
||||||
|
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/latest/srfplay_1.0.20260627.189.zip",
|
||||||
|
"checksum": "553ac3f806bce24e5aae3b2c8d17395b",
|
||||||
|
"timestamp": "2026-06-27T13:46:17Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "1.0.20260627.185",
|
||||||
|
"changelog": "Nightly build 1.0.20260627.185 (1101385)",
|
||||||
|
"targetAbi": "10.9.0.0",
|
||||||
|
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/latest/srfplay_1.0.20260627.185.zip",
|
||||||
|
"checksum": "577af725ca202bec555bf51905b75913",
|
||||||
|
"timestamp": "2026-06-27T09:30:57Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
+106
-10
@@ -8,13 +8,117 @@
|
|||||||
"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.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",
|
||||||
|
"targetAbi": "10.9.0.0",
|
||||||
|
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.1.0/srfplay_1.1.0.0.zip",
|
||||||
|
"checksum": "2094b00b29f79e7625fcd3ebf046674f",
|
||||||
|
"timestamp": "2026-06-27T09:33:04Z"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.0.0.0",
|
"version": "0.0.0.0",
|
||||||
"changelog": "Latest Build",
|
"changelog": "Latest Build",
|
||||||
"targetAbi": "10.9.0.0",
|
"targetAbi": "10.9.0.0",
|
||||||
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/latest/srfplay_1.0.0.0.zip",
|
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/latest/srfplay_1.0.0.0.zip",
|
||||||
"checksum": "a58587b0c596992eb059885b20246d21",
|
"checksum": "2ed3281990b56fbfb5f75a1ee82a2f55",
|
||||||
"timestamp": "2026-03-07T17:41:15Z"
|
"timestamp": "2026-06-27T09:01:57Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "1.0.32",
|
||||||
|
"changelog": "Release 1.0.32",
|
||||||
|
"targetAbi": "10.9.0.0",
|
||||||
|
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.0.32/srfplay_1.0.32.0.zip",
|
||||||
|
"checksum": "82f53848f2e2b15a35ea731ee69ee402",
|
||||||
|
"timestamp": "2026-06-27T07:16:38Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "1.0.31",
|
||||||
|
"changelog": "Release 1.0.31",
|
||||||
|
"targetAbi": "10.9.0.0",
|
||||||
|
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.0.31/srfplay_1.0.31.0.zip",
|
||||||
|
"checksum": "4e499de15687e6e328ddca41c6192485",
|
||||||
|
"timestamp": "2026-06-27T06:54:01Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "1.0.30",
|
||||||
|
"changelog": "Release 1.0.30",
|
||||||
|
"targetAbi": "10.9.0.0",
|
||||||
|
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.0.30/srfplay_1.0.30.0.zip",
|
||||||
|
"checksum": "06731df9ba3d2dab53885c9c8ac95fa6",
|
||||||
|
"timestamp": "2026-05-03T16:44:57Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "1.0.29",
|
||||||
|
"changelog": "Release 1.0.29",
|
||||||
|
"targetAbi": "10.9.0.0",
|
||||||
|
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.0.29/srfplay_1.0.29.0.zip",
|
||||||
|
"checksum": "fb745388e64299497262d9ad370d8823",
|
||||||
|
"timestamp": "2026-05-03T16:07:24Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"version": "1.0.27",
|
"version": "1.0.27",
|
||||||
@@ -24,14 +128,6 @@
|
|||||||
"checksum": "1e15e35452f7b82bf74d8c3560c15949",
|
"checksum": "1e15e35452f7b82bf74d8c3560c15949",
|
||||||
"timestamp": "2026-03-07T16:40:17Z"
|
"timestamp": "2026-03-07T16:40:17Z"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"version": "0.0.0.0",
|
|
||||||
"changelog": "Latest Build",
|
|
||||||
"targetAbi": "10.9.0.0",
|
|
||||||
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/latest/srfplay_1.0.0.0.zip",
|
|
||||||
"checksum": "c7e868d23293adcc21d72e735094d9d6",
|
|
||||||
"timestamp": "2026-03-07T16:28:38Z"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"version": "1.0.25",
|
"version": "1.0.25",
|
||||||
"changelog": "Release 1.0.25",
|
"changelog": "Release 1.0.25",
|
||||||
|
|||||||
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