Compare commits
No commits in common. "master" and "v1.0.17" have entirely different histories.
@ -15,48 +15,47 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: linux/amd64
|
runs-on: ubuntu-latest
|
||||||
container:
|
|
||||||
image: gitea.tourolle.paris/dtourolle/srfplay-builder:latest
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
path: build-${{ github.run_id }}
|
- name: Verify .NET installation
|
||||||
|
run: dotnet --version
|
||||||
|
|
||||||
- name: Restore dependencies
|
- name: Restore dependencies
|
||||||
working-directory: build-${{ github.run_id }}
|
|
||||||
run: dotnet restore Jellyfin.Plugin.SRFPlay.sln
|
run: dotnet restore Jellyfin.Plugin.SRFPlay.sln
|
||||||
|
|
||||||
- name: Build solution
|
- name: Build solution
|
||||||
working-directory: build-${{ github.run_id }}
|
run: dotnet build Jellyfin.Plugin.SRFPlay.sln --configuration Release --no-restore --no-self-contained
|
||||||
run: dotnet build Jellyfin.Plugin.SRFPlay.sln --configuration Release --no-restore --no-self-contained /m:1
|
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
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: Install JPRM
|
||||||
|
run: |
|
||||||
|
python3 -m venv /tmp/jprm-venv
|
||||||
|
/tmp/jprm-venv/bin/pip install jprm
|
||||||
|
|
||||||
- name: Build Jellyfin Plugin
|
- name: Build Jellyfin Plugin
|
||||||
id: jprm
|
id: jprm
|
||||||
working-directory: build-${{ github.run_id }}
|
|
||||||
run: |
|
run: |
|
||||||
|
# Create artifacts directory for JPRM output
|
||||||
mkdir -p artifacts
|
mkdir -p artifacts
|
||||||
jprm --verbosity=debug plugin build .
|
|
||||||
ARTIFACT=$(find . -name "*.zip" -type f -print -quit | sed 's|^\./||')
|
# Build plugin using JPRM
|
||||||
LATEST="artifacts/srfplay_latest.zip"
|
/tmp/jprm-venv/bin/jprm --verbosity=debug plugin build .
|
||||||
cp "${ARTIFACT}" "${LATEST}"
|
|
||||||
echo "artifact=${LATEST}" >> $GITHUB_OUTPUT
|
# Find the generated zip file
|
||||||
echo "Found artifact: ${ARTIFACT} -> ${LATEST}"
|
ARTIFACT=$(find . -name "*.zip" -type f -print -quit)
|
||||||
|
echo "artifact=${ARTIFACT}" >> $GITHUB_OUTPUT
|
||||||
|
echo "Found artifact: ${ARTIFACT}"
|
||||||
|
|
||||||
- name: Upload build artifact
|
- name: Upload build artifact
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: jellyfin-srfplay-plugin
|
name: jellyfin-srfplay-plugin
|
||||||
path: build-${{ github.run_id }}/${{ steps.jprm.outputs.artifact }}
|
path: ${{ steps.jprm.outputs.artifact }}
|
||||||
retention-days: 30
|
retention-days: 30
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
- name: Cleanup
|
|
||||||
if: always()
|
|
||||||
run: rm -rf build-${{ github.run_id }}
|
|
||||||
|
|||||||
@ -1,170 +0,0 @@
|
|||||||
name: 'Latest Release'
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- master
|
|
||||||
paths-ignore:
|
|
||||||
- '**/*.md'
|
|
||||||
- 'manifest.json'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
latest-release:
|
|
||||||
runs-on: linux/amd64
|
|
||||||
container:
|
|
||||||
image: gitea.tourolle.paris/dtourolle/srfplay-builder:latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
path: build-${{ github.run_id }}
|
|
||||||
|
|
||||||
- name: Restore dependencies
|
|
||||||
working-directory: build-${{ github.run_id }}
|
|
||||||
run: dotnet restore Jellyfin.Plugin.SRFPlay.sln
|
|
||||||
|
|
||||||
- name: Build solution
|
|
||||||
working-directory: build-${{ github.run_id }}
|
|
||||||
run: dotnet build Jellyfin.Plugin.SRFPlay.sln --configuration Release --no-restore --no-self-contained /m:1
|
|
||||||
|
|
||||||
- name: Run tests
|
|
||||||
working-directory: build-${{ github.run_id }}
|
|
||||||
run: dotnet test Jellyfin.Plugin.SRFPlay.sln --no-build --configuration Release --verbosity normal
|
|
||||||
|
|
||||||
- name: Build Jellyfin Plugin
|
|
||||||
id: jprm
|
|
||||||
working-directory: build-${{ github.run_id }}
|
|
||||||
run: |
|
|
||||||
mkdir -p artifacts
|
|
||||||
jprm --verbosity=debug plugin build .
|
|
||||||
ARTIFACT=$(find . -name "*.zip" -type f -print -quit | sed 's|^\./||')
|
|
||||||
ARTIFACT_NAME=$(basename "${ARTIFACT}")
|
|
||||||
echo "artifact=${ARTIFACT}" >> $GITHUB_OUTPUT
|
|
||||||
echo "artifact_name=${ARTIFACT_NAME}" >> $GITHUB_OUTPUT
|
|
||||||
echo "Found artifact: ${ARTIFACT}"
|
|
||||||
|
|
||||||
- name: Calculate checksum
|
|
||||||
id: checksum
|
|
||||||
working-directory: build-${{ github.run_id }}
|
|
||||||
run: |
|
|
||||||
CHECKSUM=$(md5sum "${{ steps.jprm.outputs.artifact }}" | awk '{print $1}')
|
|
||||||
echo "checksum=${CHECKSUM}" >> $GITHUB_OUTPUT
|
|
||||||
echo "Checksum: ${CHECKSUM}"
|
|
||||||
|
|
||||||
- name: Delete existing latest release
|
|
||||||
working-directory: build-${{ github.run_id }}
|
|
||||||
env:
|
|
||||||
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: |
|
|
||||||
REPO_OWNER="${{ github.repository_owner }}"
|
|
||||||
REPO_NAME="${{ github.event.repository.name }}"
|
|
||||||
GITEA_URL="${{ github.server_url }}"
|
|
||||||
TAG="latest"
|
|
||||||
|
|
||||||
EXISTING=$(curl -s -o /dev/null -w "%{http_code}" \
|
|
||||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
|
||||||
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/tags/${TAG}")
|
|
||||||
|
|
||||||
if [ "$EXISTING" = "200" ]; then
|
|
||||||
RELEASE_ID=$(curl -s \
|
|
||||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
|
||||||
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/tags/${TAG}" | jq -r '.id')
|
|
||||||
|
|
||||||
echo "Deleting existing latest release (ID: ${RELEASE_ID})..."
|
|
||||||
curl -s -X DELETE \
|
|
||||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
|
||||||
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
curl -s -X DELETE \
|
|
||||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
|
||||||
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/tags/${TAG}" || true
|
|
||||||
|
|
||||||
- name: Create latest release
|
|
||||||
working-directory: build-${{ github.run_id }}
|
|
||||||
env:
|
|
||||||
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: |
|
|
||||||
REPO_OWNER="${{ github.repository_owner }}"
|
|
||||||
REPO_NAME="${{ github.event.repository.name }}"
|
|
||||||
GITEA_URL="${{ github.server_url }}"
|
|
||||||
|
|
||||||
RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \
|
|
||||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
"${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}')")
|
|
||||||
|
|
||||||
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
|
|
||||||
BODY=$(echo "$RESPONSE" | sed '$d')
|
|
||||||
|
|
||||||
if [ "$HTTP_CODE" -ge 200 ] && [ "$HTTP_CODE" -lt 300 ]; then
|
|
||||||
RELEASE_ID=$(echo "$BODY" | jq -r '.id')
|
|
||||||
echo "Created release with ID: ${RELEASE_ID}"
|
|
||||||
else
|
|
||||||
echo "Failed to create release. HTTP ${HTTP_CODE}"
|
|
||||||
echo "$BODY"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Upload plugin artifact
|
|
||||||
echo "Uploading plugin artifact..."
|
|
||||||
curl -f -X POST \
|
|
||||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
|
||||||
-H "Content-Type: application/zip" \
|
|
||||||
--data-binary "@${{ steps.jprm.outputs.artifact }}" \
|
|
||||||
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}/assets?name=${{ steps.jprm.outputs.artifact_name }}"
|
|
||||||
|
|
||||||
# Upload build.yaml
|
|
||||||
echo "Uploading build.yaml..."
|
|
||||||
curl -f -X POST \
|
|
||||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
|
||||||
-H "Content-Type: application/x-yaml" \
|
|
||||||
--data-binary "@build.yaml" \
|
|
||||||
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}/assets?name=build.yaml"
|
|
||||||
|
|
||||||
echo "Latest release updated successfully!"
|
|
||||||
|
|
||||||
- name: Update manifest.json
|
|
||||||
working-directory: build-${{ github.run_id }}
|
|
||||||
env:
|
|
||||||
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: |
|
|
||||||
REPO_OWNER="${{ github.repository_owner }}"
|
|
||||||
REPO_NAME="${{ github.event.repository.name }}"
|
|
||||||
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")
|
|
||||||
DOWNLOAD_URL="${GITEA_URL}/${REPO_OWNER}/${REPO_NAME}/releases/download/latest/${ARTIFACT_NAME}"
|
|
||||||
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
|
|
||||||
|
|
||||||
git config user.name "Gitea Actions"
|
|
||||||
git config user.email "actions@gitea.tourolle.paris"
|
|
||||||
git fetch origin master
|
|
||||||
git checkout master
|
|
||||||
|
|
||||||
# Remove existing "latest" entry if present, then prepend new one
|
|
||||||
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
|
|
||||||
|
|
||||||
NEW_VERSION=$(cat <<EOF
|
|
||||||
{
|
|
||||||
"version": "0.0.0.0",
|
|
||||||
"changelog": "Latest Build",
|
|
||||||
"targetAbi": "10.9.0.0",
|
|
||||||
"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.json
|
|
||||||
git commit -m "Update manifest.json for latest build (${SHORT_SHA})" || echo "No changes to commit"
|
|
||||||
git push origin master
|
|
||||||
|
|
||||||
- name: Cleanup
|
|
||||||
if: always()
|
|
||||||
run: rm -rf build-${{ github.run_id }}
|
|
||||||
@ -13,15 +13,14 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-release:
|
build-and-release:
|
||||||
runs-on: linux/amd64
|
runs-on: ubuntu-latest
|
||||||
container:
|
|
||||||
image: gitea.tourolle.paris/dtourolle/srfplay-builder:latest
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
path: release-${{ github.run_id }}
|
- name: Verify .NET installation
|
||||||
|
run: dotnet --version
|
||||||
|
|
||||||
- name: Get version
|
- name: Get version
|
||||||
id: get_version
|
id: get_version
|
||||||
@ -36,31 +35,36 @@ jobs:
|
|||||||
echo "Building version: ${VERSION}"
|
echo "Building version: ${VERSION}"
|
||||||
|
|
||||||
- name: Update build.yaml with version
|
- name: Update build.yaml with version
|
||||||
working-directory: release-${{ github.run_id }}
|
|
||||||
run: |
|
run: |
|
||||||
VERSION="${{ steps.get_version.outputs.version_number }}"
|
VERSION="${{ steps.get_version.outputs.version_number }}"
|
||||||
sed -i "s/^version:.*/version: \"${VERSION}\"/" build.yaml
|
sed -i "s/^version:.*/version: \"${VERSION}\"/" build.yaml
|
||||||
cat build.yaml
|
cat build.yaml
|
||||||
|
|
||||||
- name: Restore dependencies
|
- name: Restore dependencies
|
||||||
working-directory: release-${{ github.run_id }}
|
|
||||||
run: dotnet restore Jellyfin.Plugin.SRFPlay.sln
|
run: dotnet restore Jellyfin.Plugin.SRFPlay.sln
|
||||||
|
|
||||||
- name: Build solution
|
- name: Build solution
|
||||||
working-directory: release-${{ github.run_id }}
|
run: dotnet build Jellyfin.Plugin.SRFPlay.sln --configuration Release --no-restore --no-self-contained
|
||||||
run: dotnet build Jellyfin.Plugin.SRFPlay.sln --configuration Release --no-restore --no-self-contained /m:1
|
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
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: Install JPRM
|
||||||
|
run: |
|
||||||
|
python3 -m venv /tmp/jprm-venv
|
||||||
|
/tmp/jprm-venv/bin/pip install jprm
|
||||||
|
|
||||||
- name: Build Jellyfin Plugin
|
- name: Build Jellyfin Plugin
|
||||||
id: jprm
|
id: jprm
|
||||||
working-directory: release-${{ github.run_id }}
|
|
||||||
run: |
|
run: |
|
||||||
|
# Create artifacts directory for JPRM output
|
||||||
mkdir -p artifacts
|
mkdir -p artifacts
|
||||||
jprm --verbosity=debug plugin build ./
|
|
||||||
ARTIFACT=$(find . -name "*.zip" -type f -print -quit | sed 's|^\./||')
|
# Build plugin using JPRM
|
||||||
|
/tmp/jprm-venv/bin/jprm --verbosity=debug plugin build ./
|
||||||
|
|
||||||
|
# Find the generated zip file
|
||||||
|
ARTIFACT=$(find . -name "*.zip" -type f -print -quit)
|
||||||
ARTIFACT_NAME=$(basename "${ARTIFACT}")
|
ARTIFACT_NAME=$(basename "${ARTIFACT}")
|
||||||
echo "artifact=${ARTIFACT}" >> $GITHUB_OUTPUT
|
echo "artifact=${ARTIFACT}" >> $GITHUB_OUTPUT
|
||||||
echo "artifact_name=${ARTIFACT_NAME}" >> $GITHUB_OUTPUT
|
echo "artifact_name=${ARTIFACT_NAME}" >> $GITHUB_OUTPUT
|
||||||
@ -68,14 +72,12 @@ jobs:
|
|||||||
|
|
||||||
- name: Calculate checksum
|
- name: Calculate checksum
|
||||||
id: checksum
|
id: checksum
|
||||||
working-directory: release-${{ github.run_id }}
|
|
||||||
run: |
|
run: |
|
||||||
CHECKSUM=$(md5sum "${{ steps.jprm.outputs.artifact }}" | awk '{print $1}')
|
CHECKSUM=$(md5sum "${{ steps.jprm.outputs.artifact }}" | awk '{print $1}')
|
||||||
echo "checksum=${CHECKSUM}" >> $GITHUB_OUTPUT
|
echo "checksum=${CHECKSUM}" >> $GITHUB_OUTPUT
|
||||||
echo "Checksum: ${CHECKSUM}"
|
echo "Checksum: ${CHECKSUM}"
|
||||||
|
|
||||||
- name: Create Release
|
- name: Create Release
|
||||||
working-directory: release-${{ github.run_id }}
|
|
||||||
env:
|
env:
|
||||||
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
@ -84,13 +86,22 @@ jobs:
|
|||||||
REPO_NAME="${{ github.event.repository.name }}"
|
REPO_NAME="${{ github.event.repository.name }}"
|
||||||
GITEA_URL="${{ github.server_url }}"
|
GITEA_URL="${{ github.server_url }}"
|
||||||
|
|
||||||
|
# Prepare release body
|
||||||
|
RELEASE_BODY="SRFPlay Jellyfin Plugin ${{ steps.get_version.outputs.version }}\n\nSee attached files for plugin installation."
|
||||||
|
RELEASE_BODY_JSON=$(echo -n "${RELEASE_BODY}" | jq -Rs .)
|
||||||
|
|
||||||
# Create release using Gitea API
|
# Create release using Gitea API
|
||||||
VERSION="${{ steps.get_version.outputs.version }}"
|
|
||||||
RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \
|
RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \
|
||||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases" \
|
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases" \
|
||||||
-d "$(jq -n --arg tag "$VERSION" --arg name "Release $VERSION" --arg body "SRFPlay Jellyfin Plugin. See attached files for plugin installation." '{tag_name: $tag, name: $name, body: $body, draft: false, prerelease: false}')")
|
-d "{
|
||||||
|
\"tag_name\": \"${{ steps.get_version.outputs.version }}\",
|
||||||
|
\"name\": \"Release ${{ steps.get_version.outputs.version }}\",
|
||||||
|
\"body\": ${RELEASE_BODY_JSON},
|
||||||
|
\"draft\": false,
|
||||||
|
\"prerelease\": false
|
||||||
|
}")
|
||||||
|
|
||||||
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
|
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
|
||||||
BODY=$(echo "$RESPONSE" | sed '$d')
|
BODY=$(echo "$RESPONSE" | sed '$d')
|
||||||
@ -124,7 +135,6 @@ jobs:
|
|||||||
echo "View at: ${GITEA_URL}/${REPO_OWNER}/${REPO_NAME}/releases/tag/${{ steps.get_version.outputs.version }}"
|
echo "View at: ${GITEA_URL}/${REPO_OWNER}/${REPO_NAME}/releases/tag/${{ steps.get_version.outputs.version }}"
|
||||||
|
|
||||||
- name: Update manifest.json
|
- name: Update manifest.json
|
||||||
working-directory: release-${{ github.run_id }}
|
|
||||||
env:
|
env:
|
||||||
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
@ -158,7 +168,3 @@ jobs:
|
|||||||
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
|
||||||
|
|
||||||
- name: Cleanup
|
|
||||||
if: always()
|
|
||||||
run: rm -rf release-${{ github.run_id }}
|
|
||||||
|
|||||||
@ -17,26 +17,22 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
runs-on: linux/amd64
|
runs-on: ubuntu-latest
|
||||||
container:
|
|
||||||
image: gitea.tourolle.paris/dtourolle/srfplay-builder:latest
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
path: test-${{ github.run_id }}
|
- name: Verify .NET installation
|
||||||
|
run: dotnet --version
|
||||||
|
|
||||||
- name: Restore dependencies
|
- name: Restore dependencies
|
||||||
working-directory: test-${{ github.run_id }}
|
|
||||||
run: dotnet restore Jellyfin.Plugin.SRFPlay.sln
|
run: dotnet restore Jellyfin.Plugin.SRFPlay.sln
|
||||||
|
|
||||||
- name: Build solution
|
- name: Build solution
|
||||||
working-directory: test-${{ github.run_id }}
|
run: dotnet build Jellyfin.Plugin.SRFPlay.sln --configuration Debug --no-restore --no-self-contained
|
||||||
run: dotnet build Jellyfin.Plugin.SRFPlay.sln --configuration Debug --no-restore --no-self-contained /m:1
|
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
working-directory: test-${{ github.run_id }}
|
|
||||||
run: dotnet test Jellyfin.Plugin.SRFPlay.sln --no-build --configuration Debug --verbosity normal --logger "trx;LogFileName=test-results.trx"
|
run: dotnet test Jellyfin.Plugin.SRFPlay.sln --no-build --configuration Debug --verbosity normal --logger "trx;LogFileName=test-results.trx"
|
||||||
|
|
||||||
- name: Upload test results
|
- name: Upload test results
|
||||||
@ -44,9 +40,5 @@ jobs:
|
|||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: test-results
|
name: test-results
|
||||||
path: test-${{ github.run_id }}/**/test-results.trx
|
path: '**/test-results.trx'
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
- name: Cleanup
|
|
||||||
if: always()
|
|
||||||
run: rm -rf test-${{ github.run_id }}
|
|
||||||
|
|||||||
@ -1,19 +0,0 @@
|
|||||||
# SRFPlay Builder Image
|
|
||||||
# Pre-built image with .NET SDK and JPRM for building Jellyfin plugins
|
|
||||||
# Build: docker build -f Dockerfile.builder -t gitea.tourolle.paris/dtourolle/srfplay-builder:latest .
|
|
||||||
# Push: docker push gitea.tourolle.paris/dtourolle/srfplay-builder:latest
|
|
||||||
|
|
||||||
FROM mcr.microsoft.com/dotnet/sdk:8.0
|
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y \
|
|
||||||
python3 \
|
|
||||||
python3-pip \
|
|
||||||
git \
|
|
||||||
jq \
|
|
||||||
nodejs \
|
|
||||||
npm \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
RUN pip install --break-system-packages jprm
|
|
||||||
|
|
||||||
WORKDIR /src
|
|
||||||
@ -1,94 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Text.Json.Serialization;
|
|
||||||
|
|
||||||
namespace Jellyfin.Plugin.SRFPlay.Api.Models;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A scheduled or active recording.
|
|
||||||
/// </summary>
|
|
||||||
public class RecordingEntry
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the unique recording ID.
|
|
||||||
/// </summary>
|
|
||||||
[JsonPropertyName("id")]
|
|
||||||
public string Id { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the SRF URN.
|
|
||||||
/// </summary>
|
|
||||||
[JsonPropertyName("urn")]
|
|
||||||
public string Urn { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the title.
|
|
||||||
/// </summary>
|
|
||||||
[JsonPropertyName("title")]
|
|
||||||
public string Title { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the description.
|
|
||||||
/// </summary>
|
|
||||||
[JsonPropertyName("description")]
|
|
||||||
public string? Description { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the image URL.
|
|
||||||
/// </summary>
|
|
||||||
[JsonPropertyName("imageUrl")]
|
|
||||||
public string? ImageUrl { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets when the livestream starts.
|
|
||||||
/// </summary>
|
|
||||||
[JsonPropertyName("validFrom")]
|
|
||||||
public DateTime? ValidFrom { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets when the livestream ends.
|
|
||||||
/// </summary>
|
|
||||||
[JsonPropertyName("validTo")]
|
|
||||||
public DateTime? ValidTo { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the recording state.
|
|
||||||
/// </summary>
|
|
||||||
[JsonPropertyName("state")]
|
|
||||||
public RecordingState State { get; set; } = RecordingState.Scheduled;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the output file path.
|
|
||||||
/// </summary>
|
|
||||||
[JsonPropertyName("outputPath")]
|
|
||||||
public string? OutputPath { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets when the recording actually started.
|
|
||||||
/// </summary>
|
|
||||||
[JsonPropertyName("recordingStartedAt")]
|
|
||||||
public DateTime? RecordingStartedAt { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets when the recording ended.
|
|
||||||
/// </summary>
|
|
||||||
[JsonPropertyName("recordingEndedAt")]
|
|
||||||
public DateTime? RecordingEndedAt { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the file size in bytes.
|
|
||||||
/// </summary>
|
|
||||||
[JsonPropertyName("fileSizeBytes")]
|
|
||||||
public long? FileSizeBytes { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the error message if recording failed.
|
|
||||||
/// </summary>
|
|
||||||
[JsonPropertyName("errorMessage")]
|
|
||||||
public string? ErrorMessage { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets when this entry was created.
|
|
||||||
/// </summary>
|
|
||||||
[JsonPropertyName("createdAt")]
|
|
||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
|
||||||
}
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
namespace Jellyfin.Plugin.SRFPlay.Api.Models;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// State of a recording.
|
|
||||||
/// </summary>
|
|
||||||
public enum RecordingState
|
|
||||||
{
|
|
||||||
/// <summary>Scheduled for future recording.</summary>
|
|
||||||
Scheduled,
|
|
||||||
|
|
||||||
/// <summary>Waiting for stream to become available.</summary>
|
|
||||||
WaitingForStream,
|
|
||||||
|
|
||||||
/// <summary>Currently recording.</summary>
|
|
||||||
Recording,
|
|
||||||
|
|
||||||
/// <summary>Recording completed successfully.</summary>
|
|
||||||
Completed,
|
|
||||||
|
|
||||||
/// <summary>Recording failed.</summary>
|
|
||||||
Failed,
|
|
||||||
|
|
||||||
/// <summary>Recording was cancelled.</summary>
|
|
||||||
Cancelled
|
|
||||||
}
|
|
||||||
@ -6,7 +6,6 @@ using System.Text;
|
|||||||
using System.Threading;
|
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.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;
|
||||||
@ -16,7 +15,6 @@ using MediaBrowser.Controller.Providers;
|
|||||||
using MediaBrowser.Model.Channels;
|
using MediaBrowser.Model.Channels;
|
||||||
using MediaBrowser.Model.Dto;
|
using MediaBrowser.Model.Dto;
|
||||||
using MediaBrowser.Model.Entities;
|
using MediaBrowser.Model.Entities;
|
||||||
using MediaBrowser.Model.MediaInfo;
|
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace Jellyfin.Plugin.SRFPlay.Channels;
|
namespace Jellyfin.Plugin.SRFPlay.Channels;
|
||||||
@ -32,7 +30,6 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
|
|||||||
private readonly IMediaSourceFactory _mediaSourceFactory;
|
private readonly IMediaSourceFactory _mediaSourceFactory;
|
||||||
private readonly ICategoryService? _categoryService;
|
private readonly ICategoryService? _categoryService;
|
||||||
private readonly ISRFApiClientFactory _apiClientFactory;
|
private readonly ISRFApiClientFactory _apiClientFactory;
|
||||||
private readonly IRecordingService _recordingService;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="SRFPlayChannel"/> class.
|
/// Initializes a new instance of the <see cref="SRFPlayChannel"/> class.
|
||||||
@ -43,15 +40,13 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
|
|||||||
/// <param name="mediaSourceFactory">The media source factory.</param>
|
/// <param name="mediaSourceFactory">The media source factory.</param>
|
||||||
/// <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>
|
|
||||||
public SRFPlayChannel(
|
public SRFPlayChannel(
|
||||||
ILoggerFactory loggerFactory,
|
ILoggerFactory loggerFactory,
|
||||||
IContentRefreshService contentRefreshService,
|
IContentRefreshService contentRefreshService,
|
||||||
IStreamUrlResolver streamResolver,
|
IStreamUrlResolver streamResolver,
|
||||||
IMediaSourceFactory mediaSourceFactory,
|
IMediaSourceFactory mediaSourceFactory,
|
||||||
ICategoryService? categoryService,
|
ICategoryService? categoryService,
|
||||||
ISRFApiClientFactory apiClientFactory,
|
ISRFApiClientFactory apiClientFactory)
|
||||||
IRecordingService recordingService)
|
|
||||||
{
|
{
|
||||||
_logger = loggerFactory.CreateLogger<SRFPlayChannel>();
|
_logger = loggerFactory.CreateLogger<SRFPlayChannel>();
|
||||||
_contentRefreshService = contentRefreshService;
|
_contentRefreshService = contentRefreshService;
|
||||||
@ -59,7 +54,6 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
|
|||||||
_mediaSourceFactory = mediaSourceFactory;
|
_mediaSourceFactory = mediaSourceFactory;
|
||||||
_categoryService = categoryService;
|
_categoryService = categoryService;
|
||||||
_apiClientFactory = apiClientFactory;
|
_apiClientFactory = apiClientFactory;
|
||||||
_recordingService = recordingService;
|
|
||||||
|
|
||||||
if (_categoryService == null)
|
if (_categoryService == null)
|
||||||
{
|
{
|
||||||
@ -176,7 +170,6 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
|
|||||||
"latest" => await GetLatestVideosAsync(cancellationToken).ConfigureAwait(false),
|
"latest" => await GetLatestVideosAsync(cancellationToken).ConfigureAwait(false),
|
||||||
"trending" => await GetTrendingVideosAsync(cancellationToken).ConfigureAwait(false),
|
"trending" => await GetTrendingVideosAsync(cancellationToken).ConfigureAwait(false),
|
||||||
"live_sports" => await GetLiveSportsAsync(cancellationToken).ConfigureAwait(false),
|
"live_sports" => await GetLiveSportsAsync(cancellationToken).ConfigureAwait(false),
|
||||||
"recordings" => GetRecordingItems(),
|
|
||||||
_ when folderId.StartsWith("category_", StringComparison.Ordinal) => await GetCategoryVideosAsync(folderId, cancellationToken).ConfigureAwait(false),
|
_ when folderId.StartsWith("category_", StringComparison.Ordinal) => await GetCategoryVideosAsync(folderId, cancellationToken).ConfigureAwait(false),
|
||||||
_ => new List<ChannelItemInfo>()
|
_ => new List<ChannelItemInfo>()
|
||||||
};
|
};
|
||||||
@ -188,8 +181,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
|
|||||||
{
|
{
|
||||||
CreateFolder("latest", "Latest Videos"),
|
CreateFolder("latest", "Latest Videos"),
|
||||||
CreateFolder("trending", "Trending Videos"),
|
CreateFolder("trending", "Trending Videos"),
|
||||||
CreateFolder("live_sports", "Live Sports & Events"),
|
CreateFolder("live_sports", "Live Sports & Events")
|
||||||
CreateFolder("recordings", "Recordings")
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add category folders if enabled
|
// Add category folders if enabled
|
||||||
@ -304,58 +296,6 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
|
|||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<ChannelItemInfo> GetRecordingItems()
|
|
||||||
{
|
|
||||||
var items = new List<ChannelItemInfo>();
|
|
||||||
var recordings = _recordingService.GetRecordings(RecordingState.Completed);
|
|
||||||
|
|
||||||
foreach (var recording in recordings)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(recording.OutputPath) || !System.IO.File.Exists(recording.OutputPath))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
var fileInfo = new System.IO.FileInfo(recording.OutputPath);
|
|
||||||
var itemId = $"recording_{recording.Id}";
|
|
||||||
|
|
||||||
var mediaSource = new MediaSourceInfo
|
|
||||||
{
|
|
||||||
Id = itemId,
|
|
||||||
Name = recording.Title,
|
|
||||||
Path = recording.OutputPath,
|
|
||||||
Protocol = MediaProtocol.File,
|
|
||||||
Container = "mkv",
|
|
||||||
SupportsDirectPlay = true,
|
|
||||||
SupportsDirectStream = true,
|
|
||||||
SupportsTranscoding = true,
|
|
||||||
IsRemote = false,
|
|
||||||
Size = fileInfo.Length,
|
|
||||||
Type = MediaSourceType.Default
|
|
||||||
};
|
|
||||||
|
|
||||||
var item = new ChannelItemInfo
|
|
||||||
{
|
|
||||||
Id = itemId,
|
|
||||||
Name = recording.Title,
|
|
||||||
Overview = recording.Description,
|
|
||||||
Type = ChannelItemType.Media,
|
|
||||||
ContentType = ChannelMediaContentType.Movie,
|
|
||||||
MediaType = ChannelMediaType.Video,
|
|
||||||
DateCreated = recording.RecordingStartedAt,
|
|
||||||
ImageUrl = !string.IsNullOrEmpty(recording.ImageUrl)
|
|
||||||
? CreateProxiedImageUrl(recording.ImageUrl, _mediaSourceFactory.GetServerBaseUrl())
|
|
||||||
: CreatePlaceholderImageUrl(recording.Title, _mediaSourceFactory.GetServerBaseUrl()),
|
|
||||||
MediaSources = new List<MediaSourceInfo> { mediaSource }
|
|
||||||
};
|
|
||||||
|
|
||||||
items.Add(item);
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogInformation("Returning {Count} completed recordings as channel items", items.Count);
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<List<ChannelItemInfo>> GetCategoryVideosAsync(string folderId, CancellationToken cancellationToken)
|
private async Task<List<ChannelItemInfo>> GetCategoryVideosAsync(string folderId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var items = new List<ChannelItemInfo>();
|
var items = new List<ChannelItemInfo>();
|
||||||
@ -471,8 +411,7 @@ 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;
|
return $"{config?.BusinessUnit}_{config?.EnableLatestContent}_{config?.EnableTrendingContent}_{config?.EnableCategoryFolders}_{enabledTopics}_{timeKey}";
|
||||||
return $"{config?.BusinessUnit}_{config?.EnableLatestContent}_{config?.EnableTrendingContent}_{config?.EnableCategoryFolders}_{enabledTopics}_{timeKey}_rec{recordingCount}";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<List<ChannelItemInfo>> ConvertUrnsToChannelItems(List<string> urns, CancellationToken cancellationToken)
|
private async Task<List<ChannelItemInfo>> ConvertUrnsToChannelItems(List<string> urns, CancellationToken cancellationToken)
|
||||||
|
|||||||
@ -156,9 +156,4 @@ public class PluginConfiguration : BasePluginConfiguration
|
|||||||
/// When enabled, generates custom thumbnails instead of using SRF-provided images.
|
/// When enabled, generates custom thumbnails instead of using SRF-provided images.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool GenerateTitleCards { get; set; }
|
public bool GenerateTitleCards { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the output directory for sport livestream recordings.
|
|
||||||
/// </summary>
|
|
||||||
public string RecordingOutputPath { get; set; } = string.Empty;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -90,13 +90,6 @@
|
|||||||
<div class="fieldDescription">Password for proxy authentication (leave empty if not required)</div>
|
<div class="fieldDescription">Password for proxy authentication (leave empty if not required)</div>
|
||||||
</div>
|
</div>
|
||||||
<br />
|
<br />
|
||||||
<h2>Recording Settings</h2>
|
|
||||||
<div class="inputContainer">
|
|
||||||
<label class="inputLabel inputLabelUnfocused" for="RecordingOutputPath">Recording Output Directory</label>
|
|
||||||
<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>
|
|
||||||
<br />
|
|
||||||
<h2>Network Settings</h2>
|
<h2>Network Settings</h2>
|
||||||
<div class="inputContainer">
|
<div class="inputContainer">
|
||||||
<label class="inputLabel inputLabelUnfocused" for="PublicServerUrl">Public Server URL (Optional)</label>
|
<label class="inputLabel inputLabelUnfocused" for="PublicServerUrl">Public Server URL (Optional)</label>
|
||||||
@ -113,31 +106,6 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<br />
|
|
||||||
<h2>Sport Livestream Recordings</h2>
|
|
||||||
|
|
||||||
<h3>Upcoming Sport Livestreams</h3>
|
|
||||||
<div class="fieldDescription">Select livestreams to record. The recording starts automatically when the stream goes live.</div>
|
|
||||||
<div id="scheduleContainer" style="margin: 10px 0;">
|
|
||||||
<p><em>Loading schedule...</em></p>
|
|
||||||
</div>
|
|
||||||
<button is="emby-button" type="button" class="raised emby-button" onclick="SRFPlayRecordings.loadSchedule()" style="margin-bottom: 20px;">
|
|
||||||
<span>Refresh Schedule</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<h3>Scheduled & Active Recordings</h3>
|
|
||||||
<div id="activeRecordingsContainer" style="margin: 10px 0;">
|
|
||||||
<p><em>Loading...</em></p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3>Completed Recordings</h3>
|
|
||||||
<div id="completedRecordingsContainer" style="margin: 10px 0;">
|
|
||||||
<p><em>Loading...</em></p>
|
|
||||||
</div>
|
|
||||||
<button is="emby-button" type="button" class="raised emby-button" onclick="SRFPlayRecordings.loadRecordings()" style="margin-bottom: 20px;">
|
|
||||||
<span>Refresh Recordings</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
@ -162,12 +130,7 @@
|
|||||||
document.querySelector('#ProxyUsername').value = config.ProxyUsername || '';
|
document.querySelector('#ProxyUsername').value = config.ProxyUsername || '';
|
||||||
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 || '';
|
|
||||||
Dashboard.hideLoadingMsg();
|
Dashboard.hideLoadingMsg();
|
||||||
|
|
||||||
// Load recordings UI
|
|
||||||
SRFPlayRecordings.loadSchedule();
|
|
||||||
SRFPlayRecordings.loadRecordings();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -188,7 +151,6 @@
|
|||||||
config.ProxyUsername = document.querySelector('#ProxyUsername').value;
|
config.ProxyUsername = document.querySelector('#ProxyUsername').value;
|
||||||
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;
|
|
||||||
ApiClient.updatePluginConfiguration(SRFPlayConfig.pluginUniqueId, config).then(function (result) {
|
ApiClient.updatePluginConfiguration(SRFPlayConfig.pluginUniqueId, config).then(function (result) {
|
||||||
Dashboard.processPluginConfigurationUpdateResult(result);
|
Dashboard.processPluginConfigurationUpdateResult(result);
|
||||||
});
|
});
|
||||||
@ -197,205 +159,6 @@
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
|
||||||
var SRFPlayRecordings = {
|
|
||||||
apiBase: ApiClient.serverAddress() + '/Plugins/SRFPlay/Recording',
|
|
||||||
|
|
||||||
getHeaders: function() {
|
|
||||||
return {
|
|
||||||
'X-Emby-Token': ApiClient.accessToken()
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
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><em>Loading schedule...</em></p>';
|
|
||||||
|
|
||||||
fetch(this.apiBase + '/Schedule', { headers: this.getHeaders() })
|
|
||||||
.then(function(r) { return r.json(); })
|
|
||||||
.then(function(programs) {
|
|
||||||
if (!programs || programs.length === 0) {
|
|
||||||
container.innerHTML = '<p>No upcoming sport livestreams found.</p>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var html = '<table style="width:100%; border-collapse: collapse;">';
|
|
||||||
html += '<thead><tr style="text-align:left; border-bottom: 1px solid #444;">';
|
|
||||||
html += '<th style="padding: 8px;">Title</th>';
|
|
||||||
html += '<th style="padding: 8px;">Start</th>';
|
|
||||||
html += '<th style="padding: 8px;">End</th>';
|
|
||||||
html += '<th style="padding: 8px;">Action</th>';
|
|
||||||
html += '</tr></thead><tbody>';
|
|
||||||
|
|
||||||
programs.forEach(function(p) {
|
|
||||||
html += '<tr style="border-bottom: 1px solid #333;">';
|
|
||||||
html += '<td style="padding: 8px;">' + (p.title || 'Unknown') + '</td>';
|
|
||||||
html += '<td style="padding: 8px;">' + SRFPlayRecordings.formatDate(p.validFrom || p.date) + '</td>';
|
|
||||||
html += '<td style="padding: 8px;">' + SRFPlayRecordings.formatDate(p.validTo) + '</td>';
|
|
||||||
html += '<td style="padding: 8px;">';
|
|
||||||
html += '<button is="emby-button" type="button" class="raised emby-button" ';
|
|
||||||
html += 'onclick="SRFPlayRecordings.scheduleRecording(\'' + encodeURIComponent(p.urn) + '\')">';
|
|
||||||
html += '<span>Record</span></button>';
|
|
||||||
html += '</td></tr>';
|
|
||||||
});
|
|
||||||
|
|
||||||
html += '</tbody></table>';
|
|
||||||
container.innerHTML = html;
|
|
||||||
})
|
|
||||||
.catch(function(err) {
|
|
||||||
container.innerHTML = '<p style="color: #f44;">Error loading schedule: ' + err.message + '</p>';
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
scheduleRecording: function(encodedUrn) {
|
|
||||||
fetch(this.apiBase + '/Schedule/' + encodedUrn, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: this.getHeaders()
|
|
||||||
})
|
|
||||||
.then(function(r) {
|
|
||||||
if (r.ok) {
|
|
||||||
Dashboard.alert('Recording scheduled!');
|
|
||||||
SRFPlayRecordings.loadRecordings();
|
|
||||||
} else {
|
|
||||||
Dashboard.alert('Failed to schedule recording');
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(function(err) { Dashboard.alert('Error: ' + err.message); });
|
|
||||||
},
|
|
||||||
|
|
||||||
loadRecordings: function() {
|
|
||||||
this.loadActiveRecordings();
|
|
||||||
this.loadCompletedRecordings();
|
|
||||||
},
|
|
||||||
|
|
||||||
loadActiveRecordings: function() {
|
|
||||||
var container = document.getElementById('activeRecordingsContainer');
|
|
||||||
container.innerHTML = '<p><em>Loading...</em></p>';
|
|
||||||
|
|
||||||
fetch(this.apiBase + '/All', { headers: this.getHeaders() })
|
|
||||||
.then(function(r) { return r.json(); })
|
|
||||||
.then(function(recordings) {
|
|
||||||
var activeStates = ['Scheduled', 'WaitingForStream', 'Recording', 0, 1, 2];
|
|
||||||
var active = recordings.filter(function(r) {
|
|
||||||
return activeStates.indexOf(r.state) !== -1;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (active.length === 0) {
|
|
||||||
container.innerHTML = '<p>No scheduled or active recordings.</p>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var stateLabels = {'Scheduled': 'Scheduled', 'WaitingForStream': 'Waiting', 'Recording': 'Recording', 'Completed': 'Completed', 'Failed': 'Failed', 'Cancelled': 'Cancelled', 0: 'Scheduled', 1: 'Waiting', 2: 'Recording', 3: 'Completed', 4: 'Failed', 5: 'Cancelled'};
|
|
||||||
var stateColors = {'Scheduled': '#2196F3', 'WaitingForStream': '#FF9800', 'Recording': '#4CAF50', 'Failed': '#f44336', 'Cancelled': '#9E9E9E', 0: '#2196F3', 1: '#FF9800', 2: '#4CAF50', 4: '#f44336', 5: '#9E9E9E'};
|
|
||||||
|
|
||||||
var html = '<table style="width:100%; border-collapse: collapse;">';
|
|
||||||
html += '<thead><tr style="text-align:left; border-bottom: 1px solid #444;">';
|
|
||||||
html += '<th style="padding: 8px;">Title</th>';
|
|
||||||
html += '<th style="padding: 8px;">Status</th>';
|
|
||||||
html += '<th style="padding: 8px;">Start</th>';
|
|
||||||
html += '<th style="padding: 8px;">Action</th>';
|
|
||||||
html += '</tr></thead><tbody>';
|
|
||||||
|
|
||||||
active.forEach(function(r) {
|
|
||||||
html += '<tr style="border-bottom: 1px solid #333;">';
|
|
||||||
html += '<td style="padding: 8px;">' + (r.title || 'Unknown') + '</td>';
|
|
||||||
html += '<td style="padding: 8px;"><span style="color:' + (stateColors[r.state] || '#fff') + ';">' + (stateLabels[r.state] || r.state) + '</span></td>';
|
|
||||||
html += '<td style="padding: 8px;">' + SRFPlayRecordings.formatDate(r.validFrom) + '</td>';
|
|
||||||
html += '<td style="padding: 8px;">';
|
|
||||||
if (r.state === 2 || r.state === 'Recording') {
|
|
||||||
html += '<button is="emby-button" type="button" class="raised emby-button" ';
|
|
||||||
html += 'onclick="SRFPlayRecordings.stopRecording(\'' + r.id + '\')"><span>Stop</span></button>';
|
|
||||||
} else {
|
|
||||||
html += '<button is="emby-button" type="button" class="raised emby-button" ';
|
|
||||||
html += 'onclick="SRFPlayRecordings.cancelRecording(\'' + r.id + '\')"><span>Cancel</span></button>';
|
|
||||||
}
|
|
||||||
html += '</td></tr>';
|
|
||||||
});
|
|
||||||
|
|
||||||
html += '</tbody></table>';
|
|
||||||
container.innerHTML = html;
|
|
||||||
})
|
|
||||||
.catch(function(err) {
|
|
||||||
container.innerHTML = '<p style="color: #f44;">Error: ' + err.message + '</p>';
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
loadCompletedRecordings: function() {
|
|
||||||
var container = document.getElementById('completedRecordingsContainer');
|
|
||||||
container.innerHTML = '<p><em>Loading...</em></p>';
|
|
||||||
|
|
||||||
fetch(this.apiBase + '/Completed', { headers: this.getHeaders() })
|
|
||||||
.then(function(r) { return r.json(); })
|
|
||||||
.then(function(recordings) {
|
|
||||||
if (!recordings || recordings.length === 0) {
|
|
||||||
container.innerHTML = '<p>No completed recordings.</p>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var html = '<table style="width:100%; border-collapse: collapse;">';
|
|
||||||
html += '<thead><tr style="text-align:left; border-bottom: 1px solid #444;">';
|
|
||||||
html += '<th style="padding: 8px;">Title</th>';
|
|
||||||
html += '<th style="padding: 8px;">Recorded</th>';
|
|
||||||
html += '<th style="padding: 8px;">Size</th>';
|
|
||||||
html += '<th style="padding: 8px;">File</th>';
|
|
||||||
html += '<th style="padding: 8px;">Action</th>';
|
|
||||||
html += '</tr></thead><tbody>';
|
|
||||||
|
|
||||||
recordings.forEach(function(r) {
|
|
||||||
html += '<tr style="border-bottom: 1px solid #333;">';
|
|
||||||
html += '<td style="padding: 8px;">' + (r.title || 'Unknown') + '</td>';
|
|
||||||
html += '<td style="padding: 8px;">' + SRFPlayRecordings.formatDate(r.recordingStartedAt) + '</td>';
|
|
||||||
html += '<td style="padding: 8px;">' + SRFPlayRecordings.formatSize(r.fileSizeBytes) + '</td>';
|
|
||||||
html += '<td style="padding: 8px; font-size: 0.85em; word-break: break-all;">' + (r.outputPath || '') + '</td>';
|
|
||||||
html += '<td style="padding: 8px;">';
|
|
||||||
html += '<button is="emby-button" type="button" class="raised emby-button" style="background:#f44336;" ';
|
|
||||||
html += 'onclick="SRFPlayRecordings.deleteRecording(\'' + r.id + '\')"><span>Delete</span></button>';
|
|
||||||
html += '</td></tr>';
|
|
||||||
});
|
|
||||||
|
|
||||||
html += '</tbody></table>';
|
|
||||||
container.innerHTML = html;
|
|
||||||
})
|
|
||||||
.catch(function(err) {
|
|
||||||
container.innerHTML = '<p style="color: #f44;">Error: ' + err.message + '</p>';
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
stopRecording: function(id) {
|
|
||||||
fetch(this.apiBase + '/Active/' + id + '/Stop', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: this.getHeaders()
|
|
||||||
}).then(function() { SRFPlayRecordings.loadRecordings(); });
|
|
||||||
},
|
|
||||||
|
|
||||||
cancelRecording: function(id) {
|
|
||||||
fetch(this.apiBase + '/Schedule/' + id, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: this.getHeaders()
|
|
||||||
}).then(function() { SRFPlayRecordings.loadRecordings(); });
|
|
||||||
},
|
|
||||||
|
|
||||||
deleteRecording: function(id) {
|
|
||||||
if (!confirm('Delete this recording and its file?')) return;
|
|
||||||
fetch(this.apiBase + '/Completed/' + id + '?deleteFile=true', {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: this.getHeaders()
|
|
||||||
}).then(function() { SRFPlayRecordings.loadRecordings(); });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@ -1,344 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>SRF Sport Recordings</title>
|
|
||||||
<style>
|
|
||||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
||||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #1a1a2e; color: #e0e0e0; padding: 20px; }
|
|
||||||
h1 { color: #fff; margin-bottom: 8px; font-size: 1.8em; }
|
|
||||||
h2 { color: #ccc; margin: 24px 0 12px; font-size: 1.3em; border-bottom: 1px solid #333; padding-bottom: 6px; }
|
|
||||||
.subtitle { color: #999; margin-bottom: 20px; font-size: 0.9em; }
|
|
||||||
table { width: 100%; border-collapse: collapse; margin: 10px 0 20px; }
|
|
||||||
th { text-align: left; padding: 10px 8px; border-bottom: 2px solid #444; color: #aaa; font-size: 0.85em; text-transform: uppercase; }
|
|
||||||
td { padding: 10px 8px; border-bottom: 1px solid #2a2a3e; }
|
|
||||||
tr:hover { background: #2a2a3e; }
|
|
||||||
.btn { padding: 6px 16px; border: none; border-radius: 4px; cursor: pointer; font-size: 0.85em; color: #fff; transition: opacity 0.2s; }
|
|
||||||
.btn:hover { opacity: 0.85; }
|
|
||||||
.btn-record { background: #4CAF50; }
|
|
||||||
.btn-stop { background: #FF9800; }
|
|
||||||
.btn-cancel { background: #9E9E9E; }
|
|
||||||
.btn-delete { background: #f44336; }
|
|
||||||
.btn-refresh { background: #2196F3; margin-bottom: 16px; }
|
|
||||||
.status { padding: 3px 8px; border-radius: 3px; font-size: 0.8em; font-weight: 600; }
|
|
||||||
.status-scheduled { background: #1565C0; }
|
|
||||||
.status-waiting { background: #E65100; }
|
|
||||||
.status-recording { background: #2E7D32; }
|
|
||||||
.status-failed { background: #C62828; }
|
|
||||||
.msg { padding: 12px; color: #999; font-style: italic; }
|
|
||||||
.error { color: #f44; }
|
|
||||||
.login-form { max-width: 400px; margin: 60px auto; }
|
|
||||||
.login-form input { width: 100%; padding: 10px; margin: 8px 0; background: #2a2a3e; border: 1px solid #444; color: #fff; border-radius: 4px; font-size: 1em; }
|
|
||||||
.login-form .btn { width: 100%; padding: 12px; font-size: 1em; margin-top: 12px; }
|
|
||||||
@media (max-width: 600px) { body { padding: 10px; } td, th { padding: 6px 4px; font-size: 0.85em; } }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="loginSection" style="display:none;">
|
|
||||||
<div class="login-form">
|
|
||||||
<h1>SRF Sport Recordings</h1>
|
|
||||||
<p class="subtitle">Sign in to your Jellyfin server</p>
|
|
||||||
<input type="text" id="loginServer" placeholder="Server URL (e.g. http://192.168.1.50:8096)" />
|
|
||||||
<input type="text" id="loginUser" placeholder="Username" />
|
|
||||||
<input type="password" id="loginPass" placeholder="Password" />
|
|
||||||
<button class="btn btn-record" onclick="SRFRec.login()">Sign In</button>
|
|
||||||
<p id="loginError" class="error" style="margin-top:10px;"></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="mainSection" style="display:none;">
|
|
||||||
<h1>SRF Sport Recordings</h1>
|
|
||||||
<p class="subtitle">Browse upcoming sport livestreams and schedule recordings. <span id="userInfo"></span></p>
|
|
||||||
|
|
||||||
<h2>Upcoming Sport Livestreams</h2>
|
|
||||||
<button class="btn btn-refresh" onclick="SRFRec.loadSchedule()">Refresh Schedule</button>
|
|
||||||
<div id="scheduleContainer"><p class="msg">Loading schedule...</p></div>
|
|
||||||
|
|
||||||
<h2>Scheduled & Active Recordings</h2>
|
|
||||||
<div id="activeRecordingsContainer"><p class="msg">Loading...</p></div>
|
|
||||||
|
|
||||||
<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;
|
|
||||||
} catch(e) { /* fall through to login */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show login form
|
|
||||||
var serverInput = document.getElementById('loginServer');
|
|
||||||
serverInput.value = window.location.origin;
|
|
||||||
document.getElementById('loginSection').style.display = 'block';
|
|
||||||
},
|
|
||||||
|
|
||||||
verifySession: function(session) {
|
|
||||||
var self = this;
|
|
||||||
fetch(this.serverUrl + '/System/Info', {
|
|
||||||
headers: { 'X-Emby-Token': this.token }
|
|
||||||
}).then(function(r) {
|
|
||||||
if (r.ok) {
|
|
||||||
document.getElementById('userInfo').textContent = '(Server: ' + self.serverUrl + ')';
|
|
||||||
self.showMain();
|
|
||||||
} else {
|
|
||||||
localStorage.removeItem('srfRecSession');
|
|
||||||
document.getElementById('loginSection').style.display = 'block';
|
|
||||||
}
|
|
||||||
}).catch(function() {
|
|
||||||
localStorage.removeItem('srfRecSession');
|
|
||||||
document.getElementById('loginSection').style.display = 'block';
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
login: function() {
|
|
||||||
var self = this;
|
|
||||||
var server = document.getElementById('loginServer').value.replace(/\/+$/, '');
|
|
||||||
var user = document.getElementById('loginUser').value;
|
|
||||||
var pass = document.getElementById('loginPass').value;
|
|
||||||
var errorEl = document.getElementById('loginError');
|
|
||||||
errorEl.textContent = '';
|
|
||||||
|
|
||||||
fetch(server + '/Users/AuthenticateByName', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-Emby-Authorization': 'MediaBrowser Client="SRF Recordings", Device="Web", DeviceId="srfrec-' + Date.now() + '", Version="1.0.0"'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ Username: user, Pw: pass })
|
|
||||||
})
|
|
||||||
.then(function(r) {
|
|
||||||
if (!r.ok) throw new Error('Authentication failed');
|
|
||||||
return r.json();
|
|
||||||
})
|
|
||||||
.then(function(data) {
|
|
||||||
self.serverUrl = server;
|
|
||||||
self.token = data.AccessToken;
|
|
||||||
localStorage.setItem('srfRecSession', JSON.stringify({
|
|
||||||
serverUrl: server,
|
|
||||||
token: data.AccessToken,
|
|
||||||
userName: data.User.Name
|
|
||||||
}));
|
|
||||||
document.getElementById('userInfo').textContent = '(Signed in as ' + data.User.Name + ')';
|
|
||||||
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) {
|
|
||||||
container.innerHTML = '<p class="error">Error loading schedule: ' + err.message + '</p>';
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
scheduleRecording: function(encodedUrn) {
|
|
||||||
var self = this;
|
|
||||||
fetch(this.serverUrl + '/Plugins/SRFPlay/Recording/Schedule/' + encodedUrn, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: this.getHeaders()
|
|
||||||
})
|
|
||||||
.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() {
|
|
||||||
this.loadActiveRecordings();
|
|
||||||
this.loadCompletedRecordings();
|
|
||||||
},
|
|
||||||
|
|
||||||
loadActiveRecordings: function() {
|
|
||||||
var container = document.getElementById('activeRecordingsContainer');
|
|
||||||
container.innerHTML = '<p class="msg">Loading...</p>';
|
|
||||||
|
|
||||||
fetch(this.serverUrl + '/Plugins/SRFPlay/Recording/All', { headers: this.getHeaders() })
|
|
||||||
.then(function(r) { return r.json(); })
|
|
||||||
.then(function(recordings) {
|
|
||||||
var activeStates = ['Scheduled', 'WaitingForStream', 'Recording', 0, 1, 2];
|
|
||||||
var active = recordings.filter(function(r) {
|
|
||||||
return activeStates.indexOf(r.state) !== -1;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (active.length === 0) {
|
|
||||||
container.innerHTML = '<p class="msg">No scheduled or active recordings.</p>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 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>';
|
|
||||||
});
|
|
||||||
|
|
||||||
html += '</tbody></table>';
|
|
||||||
container.innerHTML = html;
|
|
||||||
})
|
|
||||||
.catch(function(err) {
|
|
||||||
container.innerHTML = '<p class="error">Error: ' + err.message + '</p>';
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
loadCompletedRecordings: function() {
|
|
||||||
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) {
|
|
||||||
container.innerHTML = '<p class="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(); });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
SRFRec.init();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@ -1,174 +0,0 @@
|
|||||||
using System.IO;
|
|
||||||
using System.Reflection;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Jellyfin.Plugin.SRFPlay.Api.Models;
|
|
||||||
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Http;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
|
|
||||||
namespace Jellyfin.Plugin.SRFPlay.Controllers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Controller for managing sport livestream recordings.
|
|
||||||
/// </summary>
|
|
||||||
[ApiController]
|
|
||||||
[Route("Plugins/SRFPlay/Recording")]
|
|
||||||
[Authorize]
|
|
||||||
public class RecordingController : ControllerBase
|
|
||||||
{
|
|
||||||
private readonly ILogger<RecordingController> _logger;
|
|
||||||
private readonly IRecordingService _recordingService;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Initializes a new instance of the <see cref="RecordingController"/> class.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="logger">The logger.</param>
|
|
||||||
/// <param name="recordingService">The recording service.</param>
|
|
||||||
public RecordingController(
|
|
||||||
ILogger<RecordingController> logger,
|
|
||||||
IRecordingService recordingService)
|
|
||||||
{
|
|
||||||
_logger = logger;
|
|
||||||
_recordingService = recordingService;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Serves the recording manager page accessible to any authenticated user.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>The recording manager HTML page.</returns>
|
|
||||||
[HttpGet("Page")]
|
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
||||||
public IActionResult GetRecordingPage()
|
|
||||||
{
|
|
||||||
var assembly = Assembly.GetExecutingAssembly();
|
|
||||||
var resourceStream = assembly.GetManifestResourceStream("Jellyfin.Plugin.SRFPlay.Configuration.recordingPage.html");
|
|
||||||
|
|
||||||
if (resourceStream == null)
|
|
||||||
{
|
|
||||||
return NotFound("Recording page not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
return File(resourceStream, "text/html");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets upcoming sport livestreams available for recording.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="cancellationToken">The cancellation token.</param>
|
|
||||||
/// <returns>List of upcoming livestreams.</returns>
|
|
||||||
[HttpGet("Schedule")]
|
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
||||||
public async Task<IActionResult> GetSchedule(CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var schedule = await _recordingService.GetUpcomingScheduleAsync(cancellationToken).ConfigureAwait(false);
|
|
||||||
return Ok(schedule);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Schedules a livestream for recording by URN.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="urn">The SRF URN.</param>
|
|
||||||
/// <param name="cancellationToken">The cancellation token.</param>
|
|
||||||
/// <returns>The created recording entry.</returns>
|
|
||||||
[HttpPost("Schedule/{urn}")]
|
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
|
||||||
public async Task<IActionResult> ScheduleRecording(
|
|
||||||
[FromRoute] string urn,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(urn))
|
|
||||||
{
|
|
||||||
return BadRequest("URN is required");
|
|
||||||
}
|
|
||||||
|
|
||||||
// URN comes URL-encoded with colons, decode it
|
|
||||||
urn = System.Net.WebUtility.UrlDecode(urn);
|
|
||||||
|
|
||||||
_logger.LogInformation("Scheduling recording for URN: {Urn}", urn);
|
|
||||||
var entry = await _recordingService.ScheduleRecordingAsync(urn, cancellationToken).ConfigureAwait(false);
|
|
||||||
return Ok(entry);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Cancels a scheduled recording.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="id">The recording ID.</param>
|
|
||||||
/// <returns>OK or NotFound.</returns>
|
|
||||||
[HttpDelete("Schedule/{id}")]
|
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
||||||
public IActionResult CancelRecording([FromRoute] string id)
|
|
||||||
{
|
|
||||||
return _recordingService.CancelRecording(id) ? Ok() : NotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets currently active recordings.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>List of active recordings.</returns>
|
|
||||||
[HttpGet("Active")]
|
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
||||||
public IActionResult GetActiveRecordings()
|
|
||||||
{
|
|
||||||
var active = _recordingService.GetRecordings(RecordingState.Recording);
|
|
||||||
return Ok(active);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Stops an active recording.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="id">The recording ID.</param>
|
|
||||||
/// <returns>OK or NotFound.</returns>
|
|
||||||
[HttpPost("Active/{id}/Stop")]
|
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
||||||
public IActionResult StopRecording([FromRoute] string id)
|
|
||||||
{
|
|
||||||
return _recordingService.StopRecording(id) ? Ok() : NotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets completed recordings.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>List of completed recordings.</returns>
|
|
||||||
[HttpGet("Completed")]
|
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
||||||
public IActionResult GetCompletedRecordings()
|
|
||||||
{
|
|
||||||
var completed = _recordingService.GetRecordings(RecordingState.Completed);
|
|
||||||
return Ok(completed);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets all recordings (all states).
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>List of all recordings.</returns>
|
|
||||||
[HttpGet("All")]
|
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
||||||
public IActionResult GetAllRecordings()
|
|
||||||
{
|
|
||||||
var all = _recordingService.GetRecordings();
|
|
||||||
return Ok(all);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Deletes a completed recording and its file.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="id">The recording ID.</param>
|
|
||||||
/// <param name="deleteFile">Whether to delete the file too.</param>
|
|
||||||
/// <returns>OK or NotFound.</returns>
|
|
||||||
[HttpDelete("Completed/{id}")]
|
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
||||||
public IActionResult DeleteRecording(
|
|
||||||
[FromRoute] string id,
|
|
||||||
[FromQuery] bool deleteFile = true)
|
|
||||||
{
|
|
||||||
return _recordingService.DeleteRecording(id, deleteFile) ? Ok() : NotFound();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -58,28 +58,16 @@ public class StreamProxyController : ControllerBase
|
|||||||
/// Livestreams need frequent manifest refresh, VOD can be cached longer.
|
/// Livestreams need frequent manifest refresh, VOD can be cached longer.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="itemId">The item ID to check.</param>
|
/// <param name="itemId">The item ID to check.</param>
|
||||||
/// <param name="isVariantManifest">Whether this is a variant (sub) manifest with segment lists.</param>
|
private void AddManifestCacheHeaders(string itemId)
|
||||||
private void AddManifestCacheHeaders(string itemId, bool isVariantManifest = false)
|
|
||||||
{
|
{
|
||||||
var metadata = _proxyService.GetStreamMetadata(itemId);
|
var metadata = _proxyService.GetStreamMetadata(itemId);
|
||||||
var isLiveStream = metadata?.IsLiveStream ?? false;
|
var isLiveStream = metadata?.IsLiveStream ?? false;
|
||||||
|
|
||||||
if (isLiveStream)
|
if (isLiveStream)
|
||||||
{
|
{
|
||||||
if (isVariantManifest)
|
// Livestreams need frequent manifest refresh (segments rotate every ~6-10s)
|
||||||
{
|
|
||||||
// Variant manifests contain the segment list which changes every target duration.
|
|
||||||
// Use no-cache to ensure the player always gets the freshest segment list,
|
|
||||||
// preventing requests for segments that have been rotated out of the sliding window.
|
|
||||||
Response.Headers["Cache-Control"] = "no-cache, no-store";
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Master manifest is relatively stable (just lists quality variants)
|
|
||||||
Response.Headers["Cache-Control"] = "max-age=2, must-revalidate";
|
Response.Headers["Cache-Control"] = "max-age=2, must-revalidate";
|
||||||
}
|
_logger.LogDebug("Setting livestream cache headers for {ItemId}", itemId);
|
||||||
|
|
||||||
_logger.LogDebug("Setting livestream cache headers for {ItemId} (variant={IsVariant})", itemId, isVariantManifest);
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@ -227,8 +215,7 @@ public class StreamProxyController : ControllerBase
|
|||||||
var rewrittenContent = _proxyService.RewriteVariantManifestUrls(manifestContent, baseProxyUrl, queryParams);
|
var rewrittenContent = _proxyService.RewriteVariantManifestUrls(manifestContent, baseProxyUrl, queryParams);
|
||||||
|
|
||||||
// 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
|
AddManifestCacheHeaders(actualItemId);
|
||||||
AddManifestCacheHeaders(actualItemId, isVariantManifest: true);
|
|
||||||
|
|
||||||
_logger.LogDebug("Returning variant manifest for item {ItemId} ({Length} bytes)", itemId, rewrittenContent.Length);
|
_logger.LogDebug("Returning variant manifest for item {ItemId} ({Length} bytes)", itemId, rewrittenContent.Length);
|
||||||
return Content(rewrittenContent, "application/vnd.apple.mpegurl; charset=utf-8");
|
return Content(rewrittenContent, "application/vnd.apple.mpegurl; charset=utf-8");
|
||||||
@ -266,12 +253,9 @@ public class StreamProxyController : ControllerBase
|
|||||||
{
|
{
|
||||||
// Pass the original query string to preserve segment-specific parameters (e.g., ?m=timestamp)
|
// Pass the original query string to preserve segment-specific parameters (e.g., ?m=timestamp)
|
||||||
var queryString = Request.QueryString.HasValue ? Request.QueryString.Value : null;
|
var queryString = Request.QueryString.HasValue ? Request.QueryString.Value : null;
|
||||||
|
var segmentData = await _proxyService.GetSegmentAsync(actualItemId, segmentPath, queryString, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
// Use streaming proxy: starts forwarding data to the client before the full segment
|
if (segmentData == null)
|
||||||
// is downloaded from the CDN, reducing time-to-first-byte for live streams
|
|
||||||
using var upstreamResponse = await _proxyService.GetSegmentStreamAsync(actualItemId, segmentPath, queryString, cancellationToken).ConfigureAwait(false);
|
|
||||||
|
|
||||||
if (upstreamResponse == null)
|
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Segment not found - ItemId: {ItemId}, Path: {SegmentPath}", itemId, segmentPath);
|
_logger.LogWarning("Segment not found - ItemId: {ItemId}, Path: {SegmentPath}", itemId, segmentPath);
|
||||||
return NotFound();
|
return NotFound();
|
||||||
@ -279,36 +263,13 @@ public class StreamProxyController : ControllerBase
|
|||||||
|
|
||||||
// Determine content type based on file extension
|
// Determine content type based on file extension
|
||||||
var contentType = MimeTypeHelper.GetSegmentContentType(segmentPath);
|
var contentType = MimeTypeHelper.GetSegmentContentType(segmentPath);
|
||||||
Response.ContentType = contentType;
|
|
||||||
|
|
||||||
if (upstreamResponse.Content.Headers.ContentLength.HasValue)
|
_logger.LogDebug("Returning segment {SegmentPath} ({Length} bytes, {ContentType})", segmentPath, segmentData.Length, contentType);
|
||||||
{
|
return File(segmentData, contentType);
|
||||||
Response.ContentLength = upstreamResponse.Content.Headers.ContentLength.Value;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stream directly from CDN to client without buffering the entire segment in memory
|
|
||||||
await upstreamResponse.Content.CopyToAsync(Response.Body, cancellationToken).ConfigureAwait(false);
|
|
||||||
|
|
||||||
_logger.LogDebug("Streamed segment {SegmentPath} ({ContentType})", segmentPath, contentType);
|
|
||||||
return new EmptyResult();
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException)
|
|
||||||
{
|
|
||||||
// Client disconnected during streaming (e.g., FFmpeg stopped, player seeked).
|
|
||||||
// This is expected behavior, not an error.
|
|
||||||
_logger.LogDebug("Segment streaming canceled - ItemId: {ItemId}, Path: {SegmentPath}", itemId, segmentPath);
|
|
||||||
return new EmptyResult();
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Error proxying segment - ItemId: {ItemId}, Path: {SegmentPath}", itemId, segmentPath);
|
_logger.LogError(ex, "Error proxying segment - ItemId: {ItemId}, Path: {SegmentPath}", itemId, segmentPath);
|
||||||
|
|
||||||
// If we already started streaming data to the client, we can't change the status code
|
|
||||||
if (Response.HasStarted)
|
|
||||||
{
|
|
||||||
return new EmptyResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
return StatusCode(StatusCodes.Status500InternalServerError);
|
return StatusCode(StatusCodes.Status500InternalServerError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,8 +26,6 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<None Remove="Configuration\configPage.html" />
|
<None Remove="Configuration\configPage.html" />
|
||||||
<EmbeddedResource Include="Configuration\configPage.html" />
|
<EmbeddedResource Include="Configuration\configPage.html" />
|
||||||
<None Remove="Configuration\recordingPage.html" />
|
|
||||||
<EmbeddedResource Include="Configuration\recordingPage.html" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@ -45,15 +45,6 @@ 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"
|
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,76 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
|
||||||
using MediaBrowser.Model.Tasks;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
|
|
||||||
namespace Jellyfin.Plugin.SRFPlay.ScheduledTasks;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Scheduled task that checks and manages sport livestream recordings.
|
|
||||||
/// Runs every 2 minutes to start scheduled recordings when streams go live
|
|
||||||
/// and stop recordings when they end.
|
|
||||||
/// </summary>
|
|
||||||
public class RecordingSchedulerTask : IScheduledTask
|
|
||||||
{
|
|
||||||
private readonly ILogger<RecordingSchedulerTask> _logger;
|
|
||||||
private readonly IRecordingService _recordingService;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Initializes a new instance of the <see cref="RecordingSchedulerTask"/> class.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="logger">The logger.</param>
|
|
||||||
/// <param name="recordingService">The recording service.</param>
|
|
||||||
public RecordingSchedulerTask(
|
|
||||||
ILogger<RecordingSchedulerTask> logger,
|
|
||||||
IRecordingService recordingService)
|
|
||||||
{
|
|
||||||
_logger = logger;
|
|
||||||
_recordingService = recordingService;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public string Name => "Process SRF Play Recordings";
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public string Description => "Checks scheduled recordings and starts/stops them as needed";
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public string Category => "SRF Play";
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public string Key => "SRFPlayRecordingScheduler";
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
_logger.LogDebug("Processing SRF Play recordings");
|
|
||||||
progress?.Report(0);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await _recordingService.ProcessRecordingsAsync(cancellationToken).ConfigureAwait(false);
|
|
||||||
progress?.Report(100);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Error processing recordings");
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
|
|
||||||
{
|
|
||||||
return new[]
|
|
||||||
{
|
|
||||||
new TaskTriggerInfo
|
|
||||||
{
|
|
||||||
Type = TaskTriggerInfo.TriggerInterval,
|
|
||||||
IntervalTicks = TimeSpan.FromSeconds(30).Ticks
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -41,13 +41,9 @@ public class ServiceRegistrator : IPluginServiceRegistrator
|
|||||||
// Register media source provider
|
// Register media source provider
|
||||||
serviceCollection.AddSingleton<SRFMediaProvider>();
|
serviceCollection.AddSingleton<SRFMediaProvider>();
|
||||||
|
|
||||||
// Register recording service
|
|
||||||
serviceCollection.AddSingleton<IRecordingService, RecordingService>();
|
|
||||||
|
|
||||||
// Register scheduled tasks
|
// Register scheduled tasks
|
||||||
serviceCollection.AddSingleton<IScheduledTask, ContentRefreshTask>();
|
serviceCollection.AddSingleton<IScheduledTask, ContentRefreshTask>();
|
||||||
serviceCollection.AddSingleton<IScheduledTask, ExpirationCheckTask>();
|
serviceCollection.AddSingleton<IScheduledTask, ExpirationCheckTask>();
|
||||||
serviceCollection.AddSingleton<IScheduledTask, RecordingSchedulerTask>();
|
|
||||||
|
|
||||||
// Register channel - must register as IChannel interface for Jellyfin to discover it
|
// Register channel - must register as IChannel interface for Jellyfin to discover it
|
||||||
serviceCollection.AddSingleton<IChannel, SRFPlayChannel>();
|
serviceCollection.AddSingleton<IChannel, SRFPlayChannel>();
|
||||||
|
|||||||
@ -120,10 +120,9 @@ public class ContentExpirationService : IContentExpirationService
|
|||||||
|
|
||||||
if (mediaComposition?.HasChapters != true)
|
if (mediaComposition?.HasChapters != true)
|
||||||
{
|
{
|
||||||
// Don't treat API failures as expired - the content may still be available
|
// If we can't fetch the content, consider it expired
|
||||||
// and a transient error (network issue, 403, API outage) shouldn't delete library items
|
_logger.LogWarning("Could not fetch media composition for URN: {Urn}, treating as expired", urn);
|
||||||
_logger.LogWarning("Could not fetch media composition for URN: {Urn}, skipping (not treating as expired)", urn);
|
return true;
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var chapter = mediaComposition.ChapterList[0];
|
var chapter = mediaComposition.ChapterList[0];
|
||||||
|
|||||||
@ -1,65 +0,0 @@
|
|||||||
using System.Collections.Generic;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Jellyfin.Plugin.SRFPlay.Api.Models;
|
|
||||||
using Jellyfin.Plugin.SRFPlay.Api.Models.PlayV3;
|
|
||||||
|
|
||||||
namespace Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Service for managing sport livestream recordings.
|
|
||||||
/// </summary>
|
|
||||||
public interface IRecordingService
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Gets upcoming sport livestreams that can be recorded.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="cancellationToken">The cancellation token.</param>
|
|
||||||
/// <returns>List of upcoming sport livestreams.</returns>
|
|
||||||
Task<IReadOnlyList<PlayV3TvProgram>> GetUpcomingScheduleAsync(CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Schedules a livestream for recording.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="urn">The SRF URN to record.</param>
|
|
||||||
/// <param name="cancellationToken">The cancellation token.</param>
|
|
||||||
/// <returns>The created recording entry.</returns>
|
|
||||||
Task<RecordingEntry> ScheduleRecordingAsync(string urn, CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Cancels a scheduled recording.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="recordingId">The recording ID.</param>
|
|
||||||
/// <returns>True if cancelled.</returns>
|
|
||||||
bool CancelRecording(string recordingId);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Stops an active recording.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="recordingId">The recording ID.</param>
|
|
||||||
/// <returns>True if stopped.</returns>
|
|
||||||
bool StopRecording(string recordingId);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets all recordings by state.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="stateFilter">Optional state filter.</param>
|
|
||||||
/// <returns>List of matching recording entries.</returns>
|
|
||||||
IReadOnlyList<RecordingEntry> GetRecordings(RecordingState? stateFilter = null);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Deletes a completed recording (entry and optionally the file).
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="recordingId">The recording ID.</param>
|
|
||||||
/// <param name="deleteFile">Whether to delete the file too.</param>
|
|
||||||
/// <returns>True if deleted.</returns>
|
|
||||||
bool DeleteRecording(string recordingId, bool deleteFile = true);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Checks scheduled recordings and starts/stops them as needed.
|
|
||||||
/// Called periodically by the scheduler task.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="cancellationToken">The cancellation token.</param>
|
|
||||||
/// <returns>A task representing the async operation.</returns>
|
|
||||||
Task ProcessRecordingsAsync(CancellationToken cancellationToken = default);
|
|
||||||
}
|
|
||||||
@ -1,4 +1,3 @@
|
|||||||
using System.Net.Http;
|
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
@ -53,18 +52,6 @@ public interface IStreamProxyService
|
|||||||
/// <returns>The segment content as bytes.</returns>
|
/// <returns>The segment content as bytes.</returns>
|
||||||
Task<byte[]?> GetSegmentAsync(string itemId, string segmentPath, string? queryString = null, CancellationToken cancellationToken = default);
|
Task<byte[]?> GetSegmentAsync(string itemId, string segmentPath, string? queryString = null, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Fetches a segment from the original source as a streaming response.
|
|
||||||
/// Returns the HttpResponseMessage for streaming directly to the client, reducing TTFB.
|
|
||||||
/// The caller is responsible for disposing the response.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="itemId">The item ID.</param>
|
|
||||||
/// <param name="segmentPath">The segment path.</param>
|
|
||||||
/// <param name="queryString">The original query string from the request.</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token.</param>
|
|
||||||
/// <returns>The HTTP response for streaming, or null if not found.</returns>
|
|
||||||
Task<HttpResponseMessage?> GetSegmentStreamAsync(string itemId, string segmentPath, string? queryString = null, CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Rewrites URLs in a variant (sub) manifest to point to the proxy.
|
/// Rewrites URLs in a variant (sub) manifest to point to the proxy.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@ -1,553 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Concurrent;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Diagnostics;
|
|
||||||
using System.Globalization;
|
|
||||||
using System.IO;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Text.Json;
|
|
||||||
using System.Text.RegularExpressions;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Jellyfin.Plugin.SRFPlay.Api;
|
|
||||||
using Jellyfin.Plugin.SRFPlay.Api.Models;
|
|
||||||
using Jellyfin.Plugin.SRFPlay.Api.Models.PlayV3;
|
|
||||||
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
|
||||||
using MediaBrowser.Controller;
|
|
||||||
using MediaBrowser.Controller.MediaEncoding;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
|
|
||||||
namespace Jellyfin.Plugin.SRFPlay.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Service for managing sport livestream recordings using ffmpeg.
|
|
||||||
/// </summary>
|
|
||||||
public class RecordingService : IRecordingService, IDisposable
|
|
||||||
{
|
|
||||||
private readonly ILogger<RecordingService> _logger;
|
|
||||||
private readonly ISRFApiClientFactory _apiClientFactory;
|
|
||||||
private readonly IStreamProxyService _proxyService;
|
|
||||||
private readonly IStreamUrlResolver _streamUrlResolver;
|
|
||||||
private readonly IMediaCompositionFetcher _mediaCompositionFetcher;
|
|
||||||
private readonly IServerApplicationHost _appHost;
|
|
||||||
private readonly IMediaEncoder _mediaEncoder;
|
|
||||||
private readonly ConcurrentDictionary<string, Process> _activeProcesses = new();
|
|
||||||
private static readonly JsonSerializerOptions _jsonOptions = new() { WriteIndented = true };
|
|
||||||
private readonly SemaphoreSlim _persistLock = new(1, 1);
|
|
||||||
private readonly SemaphoreSlim _processLock = new(1, 1);
|
|
||||||
private List<RecordingEntry> _recordings = new();
|
|
||||||
private bool _loaded;
|
|
||||||
private bool _disposed;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Initializes a new instance of the <see cref="RecordingService"/> class.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="logger">The logger.</param>
|
|
||||||
/// <param name="apiClientFactory">The API client factory.</param>
|
|
||||||
/// <param name="proxyService">The stream proxy service.</param>
|
|
||||||
/// <param name="streamUrlResolver">The stream URL resolver.</param>
|
|
||||||
/// <param name="mediaCompositionFetcher">The media composition fetcher.</param>
|
|
||||||
/// <param name="appHost">The application host.</param>
|
|
||||||
/// <param name="mediaEncoder">The media encoder for ffmpeg path.</param>
|
|
||||||
public RecordingService(
|
|
||||||
ILogger<RecordingService> logger,
|
|
||||||
ISRFApiClientFactory apiClientFactory,
|
|
||||||
IStreamProxyService proxyService,
|
|
||||||
IStreamUrlResolver streamUrlResolver,
|
|
||||||
IMediaCompositionFetcher mediaCompositionFetcher,
|
|
||||||
IServerApplicationHost appHost,
|
|
||||||
IMediaEncoder mediaEncoder)
|
|
||||||
{
|
|
||||||
_logger = logger;
|
|
||||||
_apiClientFactory = apiClientFactory;
|
|
||||||
_proxyService = proxyService;
|
|
||||||
_streamUrlResolver = streamUrlResolver;
|
|
||||||
_mediaCompositionFetcher = mediaCompositionFetcher;
|
|
||||||
_appHost = appHost;
|
|
||||||
_mediaEncoder = mediaEncoder;
|
|
||||||
}
|
|
||||||
|
|
||||||
private string GetDataFilePath()
|
|
||||||
{
|
|
||||||
var dataPath = Plugin.Instance?.DataFolderPath ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "jellyfin", "plugins", "SRFPlay");
|
|
||||||
Directory.CreateDirectory(dataPath);
|
|
||||||
return Path.Combine(dataPath, "recordings.json");
|
|
||||||
}
|
|
||||||
|
|
||||||
private string GetRecordingOutputPath()
|
|
||||||
{
|
|
||||||
var config = Plugin.Instance?.Configuration;
|
|
||||||
var path = config?.RecordingOutputPath;
|
|
||||||
if (string.IsNullOrWhiteSpace(path))
|
|
||||||
{
|
|
||||||
path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "SRFRecordings");
|
|
||||||
}
|
|
||||||
|
|
||||||
Directory.CreateDirectory(path);
|
|
||||||
return path;
|
|
||||||
}
|
|
||||||
|
|
||||||
private string GetServerBaseUrl()
|
|
||||||
{
|
|
||||||
var config = Plugin.Instance?.Configuration;
|
|
||||||
if (config != null && !string.IsNullOrWhiteSpace(config.PublicServerUrl))
|
|
||||||
{
|
|
||||||
return config.PublicServerUrl.TrimEnd('/');
|
|
||||||
}
|
|
||||||
|
|
||||||
// For local ffmpeg access, use localhost directly
|
|
||||||
return "http://localhost:8096";
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task LoadRecordingsAsync()
|
|
||||||
{
|
|
||||||
if (_loaded)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var filePath = GetDataFilePath();
|
|
||||||
if (File.Exists(filePath))
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var json = await File.ReadAllTextAsync(filePath).ConfigureAwait(false);
|
|
||||||
_recordings = JsonSerializer.Deserialize<List<RecordingEntry>>(json) ?? new List<RecordingEntry>();
|
|
||||||
_logger.LogInformation("Loaded {Count} recording entries from {Path}", _recordings.Count, filePath);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Failed to load recordings from {Path}", filePath);
|
|
||||||
_recordings = new List<RecordingEntry>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_loaded = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task SaveRecordingsAsync()
|
|
||||||
{
|
|
||||||
await _persistLock.WaitAsync().ConfigureAwait(false);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var filePath = GetDataFilePath();
|
|
||||||
var json = JsonSerializer.Serialize(_recordings, _jsonOptions);
|
|
||||||
await File.WriteAllTextAsync(filePath, json).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Failed to save recordings");
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_persistLock.Release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async Task<IReadOnlyList<PlayV3TvProgram>> GetUpcomingScheduleAsync(CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var config = Plugin.Instance?.Configuration;
|
|
||||||
var businessUnit = (config?.BusinessUnit ?? Configuration.BusinessUnit.SRF).ToString().ToLowerInvariant();
|
|
||||||
|
|
||||||
using var apiClient = _apiClientFactory.CreateClient();
|
|
||||||
var livestreams = await apiClient.GetScheduledLivestreamsAsync(businessUnit, "SPORT", cancellationToken).ConfigureAwait(false);
|
|
||||||
|
|
||||||
if (livestreams == null)
|
|
||||||
{
|
|
||||||
return Array.Empty<PlayV3TvProgram>();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter to only future/current livestreams that aren't blocked
|
|
||||||
return livestreams
|
|
||||||
.Where(ls => ls.Blocked != true && (ls.ValidTo == null || ls.ValidTo.Value.ToUniversalTime() > DateTime.UtcNow))
|
|
||||||
.OrderBy(ls => ls.ValidFrom)
|
|
||||||
.ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async Task<RecordingEntry> ScheduleRecordingAsync(string urn, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
await LoadRecordingsAsync().ConfigureAwait(false);
|
|
||||||
|
|
||||||
// Check if already scheduled
|
|
||||||
var existing = _recordings.FirstOrDefault(r => r.Urn == urn && r.State is RecordingState.Scheduled or RecordingState.WaitingForStream or RecordingState.Recording);
|
|
||||||
if (existing != null)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Recording already exists for URN {Urn} in state {State}", urn, existing.State);
|
|
||||||
return existing;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch metadata for the URN
|
|
||||||
var config = Plugin.Instance?.Configuration;
|
|
||||||
var businessUnit = (config?.BusinessUnit ?? Configuration.BusinessUnit.SRF).ToString().ToLowerInvariant();
|
|
||||||
|
|
||||||
using var apiClient = _apiClientFactory.CreateClient();
|
|
||||||
var livestreams = await apiClient.GetScheduledLivestreamsAsync(businessUnit, "SPORT", cancellationToken).ConfigureAwait(false);
|
|
||||||
var program = livestreams?.FirstOrDefault(ls => ls.Urn == urn);
|
|
||||||
|
|
||||||
var entry = new RecordingEntry
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid().ToString("N"),
|
|
||||||
Urn = urn,
|
|
||||||
Title = program?.Title ?? urn,
|
|
||||||
Description = program?.Lead ?? program?.Description,
|
|
||||||
ImageUrl = program?.ImageUrl,
|
|
||||||
ValidFrom = program?.ValidFrom,
|
|
||||||
ValidTo = program?.ValidTo,
|
|
||||||
State = RecordingState.Scheduled,
|
|
||||||
CreatedAt = DateTime.UtcNow
|
|
||||||
};
|
|
||||||
|
|
||||||
_recordings.Add(entry);
|
|
||||||
await SaveRecordingsAsync().ConfigureAwait(false);
|
|
||||||
|
|
||||||
_logger.LogInformation("Scheduled recording for '{Title}' (URN: {Urn}, starts: {ValidFrom})", entry.Title, urn, entry.ValidFrom);
|
|
||||||
return entry;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public bool CancelRecording(string recordingId)
|
|
||||||
{
|
|
||||||
var entry = _recordings.FirstOrDefault(r => r.Id == recordingId);
|
|
||||||
if (entry == null)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entry.State == RecordingState.Recording)
|
|
||||||
{
|
|
||||||
StopFfmpeg(recordingId);
|
|
||||||
}
|
|
||||||
|
|
||||||
entry.State = RecordingState.Cancelled;
|
|
||||||
entry.RecordingEndedAt = DateTime.UtcNow;
|
|
||||||
_ = SaveRecordingsAsync();
|
|
||||||
|
|
||||||
_logger.LogInformation("Cancelled recording '{Title}' ({Id})", entry.Title, recordingId);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public bool StopRecording(string recordingId)
|
|
||||||
{
|
|
||||||
var entry = _recordings.FirstOrDefault(r => r.Id == recordingId && r.State == RecordingState.Recording);
|
|
||||||
if (entry == null)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
StopFfmpeg(recordingId);
|
|
||||||
|
|
||||||
entry.State = RecordingState.Completed;
|
|
||||||
entry.RecordingEndedAt = DateTime.UtcNow;
|
|
||||||
|
|
||||||
if (entry.OutputPath != null && File.Exists(entry.OutputPath))
|
|
||||||
{
|
|
||||||
entry.FileSizeBytes = new FileInfo(entry.OutputPath).Length;
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = SaveRecordingsAsync();
|
|
||||||
|
|
||||||
_logger.LogInformation("Stopped recording '{Title}' ({Id})", entry.Title, recordingId);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public IReadOnlyList<RecordingEntry> GetRecordings(RecordingState? stateFilter)
|
|
||||||
{
|
|
||||||
// Ensure loaded synchronously for simple reads
|
|
||||||
if (!_loaded)
|
|
||||||
{
|
|
||||||
LoadRecordingsAsync().GetAwaiter().GetResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stateFilter.HasValue)
|
|
||||||
{
|
|
||||||
return _recordings.Where(r => r.State == stateFilter.Value).OrderByDescending(r => r.CreatedAt).ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
return _recordings.OrderByDescending(r => r.CreatedAt).ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public bool DeleteRecording(string recordingId, bool deleteFile)
|
|
||||||
{
|
|
||||||
var entry = _recordings.FirstOrDefault(r => r.Id == recordingId);
|
|
||||||
if (entry == null)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entry.State == RecordingState.Recording)
|
|
||||||
{
|
|
||||||
StopFfmpeg(recordingId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (deleteFile && !string.IsNullOrEmpty(entry.OutputPath) && File.Exists(entry.OutputPath))
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
File.Delete(entry.OutputPath);
|
|
||||||
_logger.LogInformation("Deleted recording file: {Path}", entry.OutputPath);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogWarning(ex, "Failed to delete recording file: {Path}", entry.OutputPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_recordings.Remove(entry);
|
|
||||||
_ = SaveRecordingsAsync();
|
|
||||||
|
|
||||||
_logger.LogInformation("Deleted recording entry '{Title}' ({Id})", entry.Title, recordingId);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async Task ProcessRecordingsAsync(CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// Prevent overlapping scheduler runs from spawning duplicate ffmpeg processes
|
|
||||||
if (!await _processLock.WaitAsync(0, CancellationToken.None).ConfigureAwait(false))
|
|
||||||
{
|
|
||||||
_logger.LogDebug("ProcessRecordingsAsync already running, skipping");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await ProcessRecordingsCoreAsync(cancellationToken).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_processLock.Release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task ProcessRecordingsCoreAsync(CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
await LoadRecordingsAsync().ConfigureAwait(false);
|
|
||||||
|
|
||||||
var now = DateTime.UtcNow;
|
|
||||||
var changed = false;
|
|
||||||
|
|
||||||
foreach (var entry in _recordings.ToList())
|
|
||||||
{
|
|
||||||
// Normalize ValidFrom/ValidTo to UTC for correct comparison
|
|
||||||
var validFromUtc = entry.ValidFrom.HasValue ? entry.ValidFrom.Value.ToUniversalTime() : (DateTime?)null;
|
|
||||||
var validToUtc = entry.ValidTo.HasValue ? entry.ValidTo.Value.ToUniversalTime() : (DateTime?)null;
|
|
||||||
|
|
||||||
switch (entry.State)
|
|
||||||
{
|
|
||||||
case RecordingState.Scheduled:
|
|
||||||
case RecordingState.WaitingForStream:
|
|
||||||
// Check if it's time to start recording
|
|
||||||
if (validFromUtc.HasValue && validFromUtc.Value <= now.AddMinutes(2))
|
|
||||||
{
|
|
||||||
_logger.LogInformation(
|
|
||||||
"Time to start recording '{Title}': ValidFrom={ValidFrom} (UTC: {ValidFromUtc}), Now={Now}",
|
|
||||||
entry.Title,
|
|
||||||
entry.ValidFrom,
|
|
||||||
validFromUtc,
|
|
||||||
now);
|
|
||||||
changed |= await TryStartRecordingAsync(entry, cancellationToken).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
|
||||||
|
|
||||||
case RecordingState.Recording:
|
|
||||||
// Check if recording should stop (ValidTo reached or process died)
|
|
||||||
if (validToUtc.HasValue && validToUtc.Value <= now)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Recording '{Title}' reached ValidTo, stopping", entry.Title);
|
|
||||||
StopFfmpeg(entry.Id);
|
|
||||||
entry.State = RecordingState.Completed;
|
|
||||||
entry.RecordingEndedAt = now;
|
|
||||||
if (entry.OutputPath != null && File.Exists(entry.OutputPath))
|
|
||||||
{
|
|
||||||
entry.FileSizeBytes = new FileInfo(entry.OutputPath).Length;
|
|
||||||
}
|
|
||||||
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
else if (!_activeProcesses.ContainsKey(entry.Id))
|
|
||||||
{
|
|
||||||
// ffmpeg process died unexpectedly — try to restart
|
|
||||||
_logger.LogWarning("ffmpeg process for '{Title}' is no longer running, attempting restart", entry.Title);
|
|
||||||
changed |= await TryStartRecordingAsync(entry, cancellationToken).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (changed)
|
|
||||||
{
|
|
||||||
await SaveRecordingsAsync().ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<bool> TryStartRecordingAsync(RecordingEntry entry, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Fetch the media composition to get the stream URL
|
|
||||||
var mediaComposition = await _mediaCompositionFetcher.GetMediaCompositionAsync(entry.Urn, cacheDurationOverride: 2, cancellationToken: cancellationToken).ConfigureAwait(false);
|
|
||||||
var chapter = mediaComposition?.ChapterList is { Count: > 0 } list ? list[0] : null;
|
|
||||||
|
|
||||||
if (chapter == null)
|
|
||||||
{
|
|
||||||
_logger.LogDebug("No chapter found for '{Title}', stream may not be live yet", entry.Title);
|
|
||||||
entry.State = RecordingState.WaitingForStream;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
var config = Plugin.Instance?.Configuration;
|
|
||||||
var quality = config?.QualityPreference ?? Configuration.QualityPreference.Auto;
|
|
||||||
var streamUrl = _streamUrlResolver.GetStreamUrl(chapter, quality);
|
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(streamUrl))
|
|
||||||
{
|
|
||||||
_logger.LogDebug("No stream URL available for '{Title}', waiting", entry.Title);
|
|
||||||
entry.State = RecordingState.WaitingForStream;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register the stream with the proxy so we can use the proxy URL
|
|
||||||
var itemId = $"rec_{entry.Id}";
|
|
||||||
var isLiveStream = chapter.Type == "SCHEDULED_LIVESTREAM" || entry.Urn.Contains("livestream", StringComparison.OrdinalIgnoreCase);
|
|
||||||
_proxyService.RegisterStreamDeferred(itemId, streamUrl, entry.Urn, isLiveStream);
|
|
||||||
|
|
||||||
// Build proxy URL for ffmpeg (use localhost for local access)
|
|
||||||
var proxyUrl = $"{GetServerBaseUrl()}/Plugins/SRFPlay/Proxy/{itemId}/master.m3u8";
|
|
||||||
|
|
||||||
// Build output file path
|
|
||||||
var safeTitle = SanitizeFileName(entry.Title);
|
|
||||||
var timestamp = DateTime.Now.ToString("yyyy-MM-dd_HHmm", CultureInfo.InvariantCulture);
|
|
||||||
var outputPath = Path.Combine(GetRecordingOutputPath(), $"{safeTitle}_{timestamp}.mkv");
|
|
||||||
entry.OutputPath = outputPath;
|
|
||||||
|
|
||||||
// Start ffmpeg
|
|
||||||
StartFfmpeg(entry.Id, proxyUrl, outputPath);
|
|
||||||
|
|
||||||
entry.State = RecordingState.Recording;
|
|
||||||
entry.RecordingStartedAt = DateTime.UtcNow;
|
|
||||||
|
|
||||||
_logger.LogInformation("Started recording '{Title}' to {OutputPath}", entry.Title, outputPath);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Failed to start recording '{Title}'", entry.Title);
|
|
||||||
entry.State = RecordingState.Failed;
|
|
||||||
entry.ErrorMessage = ex.Message;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void StartFfmpeg(string recordingId, string inputUrl, string outputPath)
|
|
||||||
{
|
|
||||||
var process = new Process
|
|
||||||
{
|
|
||||||
StartInfo = new ProcessStartInfo
|
|
||||||
{
|
|
||||||
FileName = _mediaEncoder.EncoderPath,
|
|
||||||
Arguments = $"-y -i \"{inputUrl}\" -c copy -movflags +faststart \"{outputPath}\"",
|
|
||||||
UseShellExecute = false,
|
|
||||||
RedirectStandardInput = true,
|
|
||||||
RedirectStandardError = true,
|
|
||||||
CreateNoWindow = true
|
|
||||||
},
|
|
||||||
EnableRaisingEvents = true
|
|
||||||
};
|
|
||||||
|
|
||||||
process.ErrorDataReceived += (_, args) =>
|
|
||||||
{
|
|
||||||
if (!string.IsNullOrEmpty(args.Data))
|
|
||||||
{
|
|
||||||
_logger.LogDebug("ffmpeg [{RecordingId}]: {Data}", recordingId, args.Data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
process.Exited += (_, _) =>
|
|
||||||
{
|
|
||||||
_logger.LogInformation("ffmpeg process exited for recording {RecordingId} with code {ExitCode}", recordingId, process.ExitCode);
|
|
||||||
_activeProcesses.TryRemove(recordingId, out _);
|
|
||||||
};
|
|
||||||
|
|
||||||
process.Start();
|
|
||||||
process.BeginErrorReadLine();
|
|
||||||
|
|
||||||
_activeProcesses[recordingId] = process;
|
|
||||||
_logger.LogInformation("Started ffmpeg (PID {Pid}) for recording {RecordingId}: {Args}", process.Id, recordingId, process.StartInfo.Arguments);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void StopFfmpeg(string recordingId)
|
|
||||||
{
|
|
||||||
if (_activeProcesses.TryRemove(recordingId, out var process))
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (!process.HasExited)
|
|
||||||
{
|
|
||||||
// Send 'q' to ffmpeg stdin for graceful shutdown
|
|
||||||
process.StandardInput.Write("q");
|
|
||||||
process.StandardInput.Flush();
|
|
||||||
|
|
||||||
if (!process.WaitForExit(10000))
|
|
||||||
{
|
|
||||||
_logger.LogWarning("ffmpeg did not exit gracefully for {RecordingId}, killing", recordingId);
|
|
||||||
process.Kill(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
process.Dispose();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogWarning(ex, "Error stopping ffmpeg for recording {RecordingId}", recordingId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string SanitizeFileName(string name)
|
|
||||||
{
|
|
||||||
var invalid = Path.GetInvalidFileNameChars();
|
|
||||||
var sanitized = string.Join("_", name.Split(invalid, StringSplitOptions.RemoveEmptyEntries));
|
|
||||||
// Also replace spaces and other problematic chars
|
|
||||||
sanitized = Regex.Replace(sanitized, @"[\s]+", "_");
|
|
||||||
return sanitized.Length > 100 ? sanitized[..100] : sanitized;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
Dispose(true);
|
|
||||||
GC.SuppressFinalize(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Releases resources.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="disposing">True to release managed resources.</param>
|
|
||||||
protected virtual void Dispose(bool disposing)
|
|
||||||
{
|
|
||||||
if (_disposed)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (disposing)
|
|
||||||
{
|
|
||||||
foreach (var kvp in _activeProcesses)
|
|
||||||
{
|
|
||||||
StopFfmpeg(kvp.Key);
|
|
||||||
}
|
|
||||||
|
|
||||||
_persistLock.Dispose();
|
|
||||||
_processLock.Dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
_disposed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -373,15 +373,6 @@ public class StreamProxyService : IStreamProxyService
|
|||||||
return refreshedUrl;
|
return refreshedUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (streamInfo.IsLiveStream)
|
|
||||||
{
|
|
||||||
// For livestreams, keep the mapping and flag for re-authentication
|
|
||||||
// rather than removing it — the next request will trigger a fresh auth
|
|
||||||
_logger.LogWarning("Failed to refresh token for livestream {ItemId}, will re-authenticate on next request", itemId);
|
|
||||||
streamInfo.NeedsAuthentication = true;
|
|
||||||
return streamInfo.AuthenticatedUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogWarning("Failed to refresh token for item {ItemId}, removing mapping", itemId);
|
_logger.LogWarning("Failed to refresh token for item {ItemId}, removing mapping", itemId);
|
||||||
_streamMappings.TryRemove(itemId, out _);
|
_streamMappings.TryRemove(itemId, out _);
|
||||||
return null;
|
return null;
|
||||||
@ -656,21 +647,6 @@ public class StreamProxyService : IStreamProxyService
|
|||||||
// Rewrite the manifest to replace Akamai URLs with proxy URLs
|
// Rewrite the manifest to replace Akamai URLs with proxy URLs
|
||||||
var rewrittenContent = RewriteManifestUrls(manifestContent, authenticatedUrl, baseProxyUrl);
|
var rewrittenContent = RewriteManifestUrls(manifestContent, authenticatedUrl, baseProxyUrl);
|
||||||
|
|
||||||
// For live streams, inject #EXT-X-START to tell the player to start near the live edge
|
|
||||||
// Without this, players may start at the beginning of the sliding window and stutter
|
|
||||||
// as old segments get rotated out by the CDN
|
|
||||||
if (_streamMappings.TryGetValue(itemId, out var streamInfoForManifest) && streamInfoForManifest.IsLiveStream)
|
|
||||||
{
|
|
||||||
if (!rewrittenContent.Contains("#EXT-X-START", StringComparison.Ordinal))
|
|
||||||
{
|
|
||||||
rewrittenContent = rewrittenContent.Replace(
|
|
||||||
"#EXTM3U",
|
|
||||||
"#EXTM3U\n#EXT-X-START:TIME-OFFSET=-6,PRECISE=NO",
|
|
||||||
StringComparison.Ordinal);
|
|
||||||
_logger.LogDebug("Injected #EXT-X-START tag for live stream {ItemId}", itemId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogDebug("Rewritten manifest for item {ItemId} ({Length} bytes):\n{Content}", itemId, rewrittenContent.Length, rewrittenContent);
|
_logger.LogDebug("Rewritten manifest for item {ItemId} ({Length} bytes):\n{Content}", itemId, rewrittenContent.Length, rewrittenContent);
|
||||||
return rewrittenContent;
|
return rewrittenContent;
|
||||||
}
|
}
|
||||||
@ -744,67 +720,6 @@ public class StreamProxyService : IStreamProxyService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Fetches a segment as a streaming response for direct forwarding to the client.
|
|
||||||
/// Uses HttpCompletionOption.ResponseHeadersRead to start streaming before the full
|
|
||||||
/// segment is downloaded, reducing time-to-first-byte for live streams.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="itemId">The item ID.</param>
|
|
||||||
/// <param name="segmentPath">The segment path.</param>
|
|
||||||
/// <param name="queryString">The original query string from the request.</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token.</param>
|
|
||||||
/// <returns>The HTTP response for streaming, or null if not found.</returns>
|
|
||||||
public async Task<HttpResponseMessage?> GetSegmentStreamAsync(
|
|
||||||
string itemId,
|
|
||||||
string segmentPath,
|
|
||||||
string? queryString = null,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var authenticatedUrl = await GetAuthenticatedUrlAsync(itemId, cancellationToken).ConfigureAwait(false);
|
|
||||||
if (authenticatedUrl == null)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var baseUri = new Uri(authenticatedUrl);
|
|
||||||
var baseUrl = $"{baseUri.Scheme}://{baseUri.Host}{string.Join('/', baseUri.AbsolutePath.Split('/')[..^1])}";
|
|
||||||
|
|
||||||
var queryParams = string.Empty;
|
|
||||||
if (!string.IsNullOrEmpty(queryString))
|
|
||||||
{
|
|
||||||
queryParams = queryString.StartsWith('?') ? queryString : $"?{queryString}";
|
|
||||||
}
|
|
||||||
else if (!segmentPath.Contains("hdntl=", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
queryParams = baseUri.Query;
|
|
||||||
}
|
|
||||||
|
|
||||||
var segmentUrl = $"{baseUrl}/{segmentPath}{queryParams}";
|
|
||||||
|
|
||||||
_logger.LogDebug("Streaming segment - SegmentPath: {SegmentPath}, FullUrl: {FullUrl}", segmentPath, segmentUrl);
|
|
||||||
|
|
||||||
var httpClient = _httpClientFactory.CreateClient(NamedClient.Default);
|
|
||||||
var request = new HttpRequestMessage(HttpMethod.Get, segmentUrl);
|
|
||||||
var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
|
|
||||||
|
|
||||||
if (!response.IsSuccessStatusCode)
|
|
||||||
{
|
|
||||||
_logger.LogWarning("Segment stream request failed with {StatusCode} for {SegmentPath}", response.StatusCode, segmentPath);
|
|
||||||
response.Dispose();
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Failed to stream segment {SegmentPath} for item {ItemId}", segmentPath, itemId);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Rewrites URLs in HLS manifest to point to proxy.
|
/// Rewrites URLs in HLS manifest to point to proxy.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -988,9 +903,8 @@ public class StreamProxyService : IStreamProxyService
|
|||||||
_logger.LogDebug("Marking item {ItemId} for cleanup (old registration)", kvp.Key);
|
_logger.LogDebug("Marking item {ItemId} for cleanup (old registration)", kvp.Key);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove if token has expired — but skip livestreams since their CDN tokens
|
// Remove if token has expired
|
||||||
// expire every ~30s and get refreshed on-demand during playback
|
if (kvp.Value.TokenExpiresAt.HasValue && kvp.Value.TokenExpiresAt.Value <= now)
|
||||||
if (kvp.Value.TokenExpiresAt.HasValue && kvp.Value.TokenExpiresAt.Value <= now && !kvp.Value.IsLiveStream)
|
|
||||||
{
|
{
|
||||||
shouldRemove = true;
|
shouldRemove = true;
|
||||||
_logger.LogDebug("Marking item {ItemId} for cleanup (expired token)", kvp.Key);
|
_logger.LogDebug("Marking item {ItemId} for cleanup (expired token)", kvp.Key);
|
||||||
|
|||||||
@ -8,62 +8,6 @@
|
|||||||
"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": "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": "a58587b0c596992eb059885b20246d21",
|
|
||||||
"timestamp": "2026-03-07T17:41:15Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "1.0.27",
|
|
||||||
"changelog": "Release 1.0.27",
|
|
||||||
"targetAbi": "10.9.0.0",
|
|
||||||
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.0.27/srfplay_1.0.27.0.zip",
|
|
||||||
"checksum": "1e15e35452f7b82bf74d8c3560c15949",
|
|
||||||
"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",
|
|
||||||
"changelog": "Release 1.0.25",
|
|
||||||
"targetAbi": "10.9.0.0",
|
|
||||||
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.0.25/srfplay_1.0.25.0.zip",
|
|
||||||
"checksum": "5e4599cfeee7e0845a1be30ec288cc0b",
|
|
||||||
"timestamp": "2026-03-07T15:11:52Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "1.0.24",
|
|
||||||
"changelog": "Release 1.0.24",
|
|
||||||
"targetAbi": "10.9.0.0",
|
|
||||||
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.0.24/srfplay_1.0.24.0.zip",
|
|
||||||
"checksum": "f54dfb8cd9b555471859ffc89c35fb90",
|
|
||||||
"timestamp": "2026-03-07T14:55:13Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "1.0.23",
|
|
||||||
"changelog": "Release 1.0.23",
|
|
||||||
"targetAbi": "10.9.0.0",
|
|
||||||
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.0.23/srfplay_1.0.23.0.zip",
|
|
||||||
"checksum": "cd98644c758c84e2759699ea1da5a716",
|
|
||||||
"timestamp": "2026-02-28T12:13:10Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "1.0.22",
|
|
||||||
"changelog": "Release 1.0.22",
|
|
||||||
"targetAbi": "10.9.0.0",
|
|
||||||
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.0.22/srfplay_1.0.22.0.zip",
|
|
||||||
"checksum": "08177ed8edb4b4cf2441bc808b9860bb",
|
|
||||||
"timestamp": "2026-02-28T11:36:30Z"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"version": "1.0.16",
|
"version": "1.0.16",
|
||||||
"changelog": "Release 1.0.16",
|
"changelog": "Release 1.0.16",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user