Compare commits

..
30 Commits
Author SHA1 Message Date
dtourolle 3604e8f7a0 Add unit.tests
🏗️ Build Plugin / build (pull_request) Failing after 9s
🧪 Test Plugin / test (pull_request) Failing after 9s
2025-11-14 22:13:24 +01:00
dtourolle 31b2402a96 more readme refinement
🏗️ Build Plugin / build (push) Successful in 2m22s
🧪 Test Plugin / test (push) Successful in 1m7s
2025-11-14 22:01:17 +01:00
dtourolle 3691b4e30e removed md unrelated to current state 2025-11-14 21:50:58 +01:00
dtourolle 87ccf80813 remove netowkring notes ans script
🏗️ Build Plugin / build (push) Successful in 2m25s
🧪 Test Plugin / test (push) Successful in 1m8s
2025-11-14 21:43:43 +01:00
dtourolle 6ed53054c3 remove changelog
🏗️ Build Plugin / build (push) Successful in 2m18s
🧪 Test Plugin / test (push) Successful in 1m10s
🚀 Release Plugin / build-and-release (push) Successful in 2m16s
2025-11-14 21:39:34 +01:00
dtourolle 1a8b7a33ef more dman ci stuff
🏗️ Build Plugin / build (push) Successful in 2m22s
🧪 Test Plugin / test (push) Successful in 1m8s
🚀 Release Plugin / build-and-release (push) Failing after 2m18s
2025-11-14 21:29:51 +01:00
dtourolle e5f82c53da Update readme (acknowledge goggin/kodi plugin, clearer arch desc, problem with main channels)
🚀 Release Plugin / build-and-release (push) Failing after 2s
2025-11-14 21:25:22 +01:00
dtourolle fddad35cf4 more CI stuff
🏗️ Build Plugin / build (push) Successful in 2m23s
🧪 Test Plugin / test (push) Successful in 1m8s
2025-11-14 21:22:13 +01:00
dtourolle ce6c435a92 Added sports livestreams 2025-11-14 21:20:41 +01:00
dtourolle d830f8ae5f more CI fun
🏗️ Build Plugin / build (push) Failing after 2m19s
🧪 Test Plugin / test (push) Successful in 1m6s
2025-11-14 21:18:14 +01:00
dtourolle def475d0f3 remove debugging scripts
🏗️ Build Plugin / build (push) Failing after 2m16s
🧪 Test Plugin / test (push) Successful in 1m7s
2025-11-14 21:06:53 +01:00
dtourolle 70bcdf7103 more ci fixes
🏗️ Build Plugin / build (push) Failing after 1m15s
🧪 Test Plugin / test (push) Successful in 1m9s
2025-11-14 20:58:50 +01:00
dtourolle c3e3008d57 use pything venv
🏗️ Build Plugin / build (push) Failing after 1m24s
🧪 Test Plugin / test (push) Successful in 1m11s
2025-11-14 20:52:55 +01:00
dtourolle a3fc82f13c set dontet version
🏗️ Build Plugin / build (push) Failing after 1m41s
🧪 Test Plugin / test (push) Successful in 1m8s
2025-11-14 20:45:36 +01:00
dtourolle 24ed1de959 CI fix
🏗️ Build Plugin / build (push) Failing after 9s
🧪 Test Plugin / test (push) Failing after 11s
2025-11-14 20:43:09 +01:00
dtourolle eb6bfad0d2 CI fix
🏗️ Build Plugin / build (push) Failing after 7s
🧪 Test Plugin / test (push) Failing after 8s
2025-11-14 20:34:59 +01:00
dtourolle a8d3968925 gitea ci workflows
🏗️ Build Plugin / build (push) Failing after 1m12s
🧪 Test Plugin / test (push) Failing after 12s
2025-11-14 20:26:52 +01:00
dtourolle ac6a3842dd first commit
🏗️ Build Plugin / call (push) Failing after 0s
📝 Create/Update Release Draft & Release Bump PR / call (push) Failing after 0s
🧪 Test Plugin / call (push) Failing after 0s
🔬 Run CodeQL / call (push) Failing after 0s
2025-11-12 22:05:36 +01:00
Bond-009andGitHub d544b71939 Merge pull request #79 from jakobkukla/patch-1
Fix vscode build task for NET 8
2025-03-11 09:33:59 +01:00
Jakob KuklaandGitHub 12988e127f Fix vscode build task for .NET 8 2025-03-04 00:55:32 +01:00
Anthony LavadoandGitHub e9312af2c2 Merge pull request #72 from jellyfin/renovate/jellyfin.controller-10.x 2024-10-05 11:42:18 -04:00
renovate[bot]andGitHub 4291891dfd Update dependency Jellyfin.Controller to 10.9.11 2024-10-05 15:20:24 +00:00
Anthony LavadoandGitHub e9c9f334cd Merge pull request #74 from Shadowghost/upgrade-template 2024-10-05 11:20:08 -04:00
Shadowghost 3afa1fc407 Upgrade template to 10.9.x and .NET 8 2024-10-05 17:06:23 +02:00
Anthony LavadoandGitHub 2c1143b49f Merge pull request #65 from mrnoname1000/master 2024-10-05 11:01:52 -04:00
Anthony LavadoandGitHub 74da0d2568 Merge pull request #54 from carif/linux 2024-10-05 11:01:16 -04:00
ErikandGitHub 2ddd08221d Updated to specify that you need to place the plugin DLL into a subfolder (#71) 2024-05-19 08:08:00 -06:00
mrnoname1000andGitHub 7fe332f29c README.md: Fix sh UUID generator
And make call to head POSIX-compliant
2023-10-19 23:35:44 +00:00
Patrick Farwick 4de36a8abc Add required extensions to Visual Studio Code 2022-07-07 16:48:24 +02:00
Patrick Farwick 66bac259a8 Add linux support for vscode dev-setup
The ${env:XDG_DATA_HOME} variable is not used, as this variable resolves
to the current working directory within Visual Studio Code's development
container. By manually specifying the file path, it is possible to use
Visual Studio Code's development container.

The "mkdir" command uses the "-p" flag. This flag creates the parent
directories, as well. Should they already exists, the command moves
down to the next folder to create without throwing an error.

The "-r" parameter of the "cp" is required to recursively copy all
files and directories within the "publish" directory.
2022-07-07 16:47:17 +02:00
66 changed files with 7282 additions and 646 deletions
+61
View File
@@ -0,0 +1,61 @@
name: '🏗️ Build Plugin'
on:
push:
branches:
- master
paths-ignore:
- '**/*.md'
pull_request:
branches:
- master
paths-ignore:
- '**/*.md'
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Verify .NET installation
run: dotnet --version
- name: Restore dependencies
run: dotnet restore Jellyfin.Plugin.SRFPlay.sln
- name: Build solution
run: dotnet build Jellyfin.Plugin.SRFPlay.sln --configuration Release --no-restore --no-self-contained
- name: Run tests
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
id: jprm
run: |
# Create artifacts directory for JPRM output
mkdir -p artifacts
# 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)
echo "artifact=${ARTIFACT}" >> $GITHUB_OUTPUT
echo "Found artifact: ${ARTIFACT}"
- name: Upload build artifact
uses: actions/upload-artifact@v3
with:
name: jellyfin-srfplay-plugin
path: ${{ steps.jprm.outputs.artifact }}
retention-days: 30
if-no-files-found: error
+69
View File
@@ -0,0 +1,69 @@
name: '🌙 Nightly API Spec Tests'
on:
schedule:
# Run every night at 2 AM UTC
- cron: '0 2 * * *'
workflow_dispatch: # Allow manual trigger
jobs:
api-spec-validation:
name: Validate SRF API Spec
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Restore dependencies
run: dotnet restore
- name: Build solution
run: dotnet build --no-restore --configuration Release
- name: Run API Spec Tests
id: api_tests
run: |
dotnet test \
--no-build \
--configuration Release \
--filter "Category=APISpec" \
--logger "console;verbosity=detailed" || echo "TESTS_FAILED=true" >> $GITHUB_ENV
- name: Check Test Results
if: always()
run: |
if [ "$TESTS_FAILED" = "true" ]; then
echo "❌ API Spec Tests Failed"
echo ""
echo "This may indicate:"
echo " - SRF Play API has changed"
echo " - API endpoints are experiencing issues"
echo " - Response schemas have been modified"
echo ""
echo "Actions needed:"
echo " 1. Review the test output above"
echo " 2. Check if SRF Play API documentation has been updated"
echo " 3. Update API models if the schema has changed"
echo " 4. Update tests if they need to be adjusted"
echo ""
exit 1
else
echo "✅ All API spec tests passed!"
echo "The SRF Play API is working as expected."
fi
- name: Upload Test Logs
if: failure()
uses: actions/upload-artifact@v4
with:
name: api-test-failure-logs
path: |
**/*.trx
**/TestResults/
retention-days: 30
+128
View File
@@ -0,0 +1,128 @@
name: '🚀 Release Plugin'
on:
push:
tags:
- 'v*.*.*'
workflow_dispatch:
inputs:
version:
description: 'Version to release (e.g., v1.0.0)'
required: true
type: string
jobs:
build-and-release:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Verify .NET installation
run: dotnet --version
- name: Get version
id: get_version
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
VERSION="${{ github.event.inputs.version }}"
else
VERSION="${GITHUB_REF#refs/tags/}"
fi
echo "version=${VERSION}" >> $GITHUB_OUTPUT
echo "version_number=${VERSION#v}" >> $GITHUB_OUTPUT
echo "Building version: ${VERSION}"
- name: Update build.yaml with version
run: |
VERSION="${{ steps.get_version.outputs.version_number }}"
sed -i "s/^version:.*/version: \"${VERSION}\"/" build.yaml
cat build.yaml
- name: Restore dependencies
run: dotnet restore Jellyfin.Plugin.SRFPlay.sln
- name: Build solution
run: dotnet build Jellyfin.Plugin.SRFPlay.sln --configuration Release --no-restore --no-self-contained
- name: Run tests
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
id: jprm
run: |
# Create artifacts directory for JPRM output
mkdir -p artifacts
# 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}")
echo "artifact=${ARTIFACT}" >> $GITHUB_OUTPUT
echo "artifact_name=${ARTIFACT_NAME}" >> $GITHUB_OUTPUT
echo "Found artifact: ${ARTIFACT}"
- name: Create Release
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Get repository information
REPO_OWNER="${{ github.repository_owner }}"
REPO_NAME="${{ github.event.repository.name }}"
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
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 "{
\"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)
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 "✅ Release created successfully!"
echo "View at: ${GITEA_URL}/${REPO_OWNER}/${REPO_NAME}/releases/tag/${{ steps.get_version.outputs.version }}"
+44
View File
@@ -0,0 +1,44 @@
name: '🧪 Test Plugin'
on:
push:
branches:
- master
- develop
paths-ignore:
- '**/*.md'
pull_request:
branches:
- master
- develop
paths-ignore:
- '**/*.md'
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Verify .NET installation
run: dotnet --version
- name: Restore dependencies
run: dotnet restore Jellyfin.Plugin.SRFPlay.sln
- name: Build solution
run: dotnet build Jellyfin.Plugin.SRFPlay.sln --configuration Debug --no-restore --no-self-contained
- name: Run tests
run: dotnet test Jellyfin.Plugin.SRFPlay.sln --no-build --configuration Debug --verbosity normal --logger "trx;LogFileName=test-results.trx"
- name: Upload test results
if: always()
uses: actions/upload-artifact@v3
with:
name: test-results
path: '**/test-results.trx'
retention-days: 7
-18
View File
@@ -1,18 +0,0 @@
name: '🏗️ Build Plugin'
on:
push:
branches:
- master
paths-ignore:
- '**/*.md'
pull_request:
branches:
- master
paths-ignore:
- '**/*.md'
workflow_dispatch:
jobs:
call:
uses: jellyfin/jellyfin-meta-plugins/.github/workflows/build.yaml@master
-20
View File
@@ -1,20 +0,0 @@
name: '📝 Create/Update Release Draft & Release Bump PR'
on:
push:
branches:
- master
paths-ignore:
- build.yaml
workflow_dispatch:
repository_dispatch:
types:
- update-prep-command
jobs:
call:
uses: jellyfin/jellyfin-meta-plugins/.github/workflows/changelog.yaml@master
with:
repository-name: jellyfin/jellyfin-plugin-template
secrets:
token: ${{ secrets.GITHUB_TOKEN }}
-13
View File
@@ -1,13 +0,0 @@
# Allows for the definition of PR and Issue /commands
name: '📟 Slash Command Dispatcher'
on:
issue_comment:
types:
- created
jobs:
call:
uses: jellyfin/jellyfin-meta-plugins/.github/workflows/command-dispatch.yaml@master
secrets:
token: .
-16
View File
@@ -1,16 +0,0 @@
name: '🔀 PR Rebase Command'
on:
repository_dispatch:
types:
- rebase-command
jobs:
call:
uses: jellyfin/jellyfin-meta-plugins/.github/workflows/command-rebase.yaml@master
with:
rebase-head: ${{ github.event.client_payload.pull_request.head.label }}
repository-full-name: ${{ github.event.client_payload.github.payload.repository.full_name }}
comment-id: ${{ github.event.client_payload.github.payload.comment.id }}
secrets:
token: ${{ secrets.GITHUB_TOKEN }}
-18
View File
@@ -1,18 +0,0 @@
name: '🚀 Publish Plugin'
on:
release:
types:
- released
workflow_dispatch:
jobs:
call:
uses: jellyfin/jellyfin-meta-plugins/.github/workflows/publish.yaml@master
with:
version: ${{ github.event.release.tag_name }}
is-unstable: ${{ github.event.release.prerelease }}
secrets:
deploy-host: ${{ secrets.DEPLOY_HOST }}
deploy-user: ${{ secrets.DEPLOY_USER }}
deploy-key: ${{ secrets.DEPLOY_KEY }}
-20
View File
@@ -1,20 +0,0 @@
name: '🔬 Run CodeQL'
on:
push:
branches: [ master ]
paths-ignore:
- '**/*.md'
pull_request:
branches: [ master ]
paths-ignore:
- '**/*.md'
schedule:
- cron: '24 2 * * 4'
workflow_dispatch:
jobs:
call:
uses: jellyfin/jellyfin-meta-plugins/.github/workflows/scan-codeql.yaml@master
with:
repository-name: jellyfin/jellyfin-plugin-template
-12
View File
@@ -1,12 +0,0 @@
name: '🏷️ Sync labels'
on:
schedule:
- cron: '0 0 1 * *'
workflow_dispatch:
jobs:
call:
uses: jellyfin/jellyfin-meta-plugins/.github/workflows/sync-labels.yaml@master
secrets:
token: ${{ secrets.GITHUB_TOKEN }}
-18
View File
@@ -1,18 +0,0 @@
name: '🧪 Test Plugin'
on:
push:
branches:
- master
paths-ignore:
- '**/*.md'
pull_request:
branches:
- master
paths-ignore:
- '**/*.md'
workflow_dispatch:
jobs:
call:
uses: jellyfin/jellyfin-meta-plugins/.github/workflows/test.yaml@master
+11
View File
@@ -0,0 +1,11 @@
{
// See https://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations.
// Extension identifier format: ${publisher}.${name}. Example: vscode.csharp
// List of extensions which should be recommended for users of this workspace.
"recommendations": [
"ms-dotnettools.csharp",
"editorconfig.editorconfig"
],
// List of extensions recommended by VS Code that should not be recommended for users of this workspace.
"unwantedRecommendations": []
}
+1 -1
View File
@@ -7,7 +7,7 @@
"name": "Launch",
"request": "launch",
"preLaunchTask": "build-and-copy",
"program": "${config:jellyfinDir}/bin/Debug/net6.0/jellyfin.dll",
"program": "${config:jellyfinDir}/bin/Debug/net8.0/jellyfin.dll",
"args": [
//"--nowebclient"
"--webdir",
+8 -4
View File
@@ -1,15 +1,19 @@
{
// jellyfinDir : The directory of the cloned jellyfin server project
// This needs to be built once before it can be used
"jellyfinDir" : "${workspaceFolder}/../jellyfin/Jellyfin.Server",
"jellyfinDir": "${workspaceFolder}/../jellyfin/Jellyfin.Server",
// jellyfinWebDir : The directory of the cloned jellyfin-web project
// This needs to be built once before it can be used
"jellyfinWebDir" : "${workspaceFolder}/../jellyfin-web",
"jellyfinWebDir": "${workspaceFolder}/../jellyfin-web",
// jellyfinDataDir : the root data directory for a running jellyfin instance
// This is where jellyfin stores its configs, plugins, metadata etc
// This is platform specific by default, but on Windows defaults to
// ${env:LOCALAPPDATA}/jellyfin
"jellyfinDataDir" : "${env:LOCALAPPDATA}/jellyfin",
// and on Linux, it defaults to
// ${env:XDG_DATA_HOME}/jellyfin
// However ${env:XDG_DATA_HOME} does not work in Visual Studio Code's development container!
"jellyfinWindowsDataDir": "${env:LOCALAPPDATA}/jellyfin",
"jellyfinLinuxDataDir": "$HOME/.local/share/jellyfin",
// The name of the plugin
"pluginName" : "Jellyfin.Plugin.Template",
"pluginName": "Jellyfin.Plugin.Template",
}
+26 -5
View File
@@ -7,7 +7,11 @@
// jellyfin server's plugin directory
"label": "build-and-copy",
"dependsOrder": "sequence",
"dependsOn": ["build", "make-plugin-dir", "copy-dll"]
"dependsOn": [
"build",
"make-plugin-dir",
"copy-dll"
]
},
{
// Build the plugin
@@ -16,6 +20,7 @@
"type": "shell",
"args": [
"publish",
"--configuration=Debug",
"${workspaceFolder}/${config:pluginName}.sln",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
@@ -31,12 +36,20 @@
"label": "make-plugin-dir",
"type": "shell",
"command": "mkdir",
"windows": {
"args": [
"-Force",
"-Path",
"${config:jellyfinDataDir}/plugins/${config:pluginName}/"
"${config:jellyfinWindowsDataDir}/plugins/${config:pluginName}/"
]
},
"linux": {
"args": [
"-p",
"${config:jellyfinLinuxDataDir}/plugins/${config:pluginName}/"
]
}
},
{
// Copy the plugin dll to the jellyfin plugin install path
// This command copies every .dll from the build directory to the plugin dir
@@ -45,11 +58,19 @@
"label": "copy-dll",
"type": "shell",
"command": "cp",
"windows": {
"args": [
"./${config:pluginName}/bin/Debug/net6.0/publish/*",
"${config:jellyfinDataDir}/plugins/${config:pluginName}/"
"./${config:pluginName}/bin/Debug/net8.0/publish/*",
"${config:jellyfinWindowsDataDir}/plugins/${config:pluginName}/"
]
},
"linux": {
"args": [
"-r",
"./${config:pluginName}/bin/Debug/net8.0/publish/*",
"${config:jellyfinLinuxDataDir}/plugins/${config:pluginName}/"
]
}
},
]
}
+254
View File
@@ -0,0 +1,254 @@
# Debugging Guide for SRF Play Plugin
This guide helps you debug issues with the SRF Play plugin, including proxy configuration and content fetching problems.
## Enhanced Logging
The plugin now includes detailed logging to help diagnose issues. After installing the updated version, check your Jellyfin logs for these key messages.
## Key Log Messages to Look For
### 1. Proxy Configuration Status
When the plugin initializes, you'll see:
**Without proxy:**
```
[INF] SRFApiClient initializing without proxy
```
**With proxy:**
```
[INF] SRFApiClient initializing with proxy enabled: http://your-proxy:port
[INF] Proxy configured: http://your-proxy:port (Authentication: True/False)
```
**Proxy configuration errors:**
```
[ERR] Failed to configure proxy: http://your-proxy:port
```
### 2. Media Composition Fetching
For each video URN, you'll see detailed logs:
```
[INF] Fetching media composition for URN: urn:srf:video:12345 from https://il.srgssr.ch/integrationlayer/2.0/mediaComposition/byUrn/urn:srf:video:12345.json
[INF] Media composition response for URN urn:srf:video:12345: StatusCode=OK
[INF] Successfully fetched media composition for URN: urn:srf:video:12345 - Chapters: 1
```
**If there are no chapters:**
```
[WRN] Media composition for URN urn:srf:video:12345 has no chapters
```
**HTTP errors:**
```
[ERR] HTTP error fetching media composition for URN: urn:srf:video:12345 - StatusCode: 404
```
### 3. Stream URL Resolution
For each chapter, you'll see detailed resource processing:
```
[INF] Processing chapter abc123 with 5 resources
[INF] Chapter abc123: Total resources=5, Non-DRM resources=2
[INF] Chapter abc123: HLS resources found=1
```
**DRM-protected content:**
```
[WRN] All resources for chapter abc123 require DRM
[DBG] DRM resource: Protocol=HLS, Streaming=HLS, DRM=WIDEVINE
```
**No HLS streams:**
```
[WRN] No HLS resources found for chapter: abc123
[DBG] Non-HLS resource: Protocol=HTTP, Streaming=PROGRESSIVE, URL=https://...
[INF] Using fallback resource for chapter abc123: https://...
```
## Common Issues and Solutions
### Issue 1: Videos Not Loading (Conversion Failures)
**Symptoms:**
```
Conversion complete: 0 successful, 18 failed, 0 expired, 0 no stream
```
**Debugging steps:**
1. **Check for DRM content:**
- Look for: `All resources for chapter XXX require DRM`
- Solution: This content cannot be played (Widevine DRM protection)
2. **Check for HTTP errors:**
- Look for: `HTTP error fetching media composition` with status codes
- Common codes:
- `403 Forbidden`: Geo-restriction or blocked
- `404 Not Found`: Content removed or URN invalid
- `429 Too Many Requests`: Rate limiting
- Solution: Check if proxy is needed for geo-restrictions
3. **Check for missing chapters:**
- Look for: `Media composition for URN XXX has no chapters`
- Solution: This video metadata is incomplete or invalid
### Issue 2: Proxy Not Being Used
**Symptoms:**
- Plugin says "initializing without proxy" even though configured
- Getting geo-restriction errors (403)
**Debugging steps:**
1. Check the initialization log:
```
[INF] SRFApiClient initializing with proxy enabled: http://...
```
2. If it says "without proxy", check:
- Is "Use Proxy" checkbox enabled in plugin settings?
- Is the Proxy Address field filled in?
- Did you restart Jellyfin after saving?
3. Check for proxy errors:
```
[ERR] Failed to configure proxy: http://...
```
- Verify the proxy URL format (must include `http://` or `socks5://`)
- Test proxy connectivity: `curl --proxy http://proxy:port https://il.srgssr.ch`
### Issue 3: Proxy Connection Failures
**Symptoms:**
```
[ERR] HTTP error fetching media composition for URN: ... - StatusCode: null
```
or timeout errors
**Debugging steps:**
1. **Verify proxy is reachable from Jellyfin server:**
```bash
curl --proxy http://your-proxy:port https://il.srgssr.ch
```
2. **Check proxy authentication:**
- If proxy requires auth, ensure username/password are configured
- Look for: `Proxy configured: ... (Authentication: True)`
3. **Check proxy supports HTTPS:**
- The SRF API uses HTTPS, ensure your proxy can handle it
4. **Test with a simple HTTP proxy:**
```bash
# Test with SSH tunnel as SOCKS5 proxy
ssh -D 1080 -N user@gateway-server
# Then configure: socks5://127.0.0.1:1080
```
### Issue 4: Content Expiration
**Symptoms:**
```
Conversion complete: 0 successful, 0 failed, 10 expired, 0 no stream
```
**Solution:**
- Content has expired and is no longer available
- Check the expiration task runs regularly
- Some content is time-limited by SRF
## Enabling Debug Logs
To see even more detailed logs (like `[DBG]` messages), modify your Jellyfin logging configuration:
1. Go to **Dashboard → Logs**
2. Click **Settings**
3. Set log level to **Debug** for troubleshooting
4. Restart Jellyfin
**Warning:** Debug logging can be very verbose and consume disk space quickly. Only enable temporarily.
## Testing Proxy Configuration
To verify your proxy is working:
### 1. Enable Proxy in Plugin Settings
- Dashboard → Plugins → SRF Play
- Check "Use Proxy"
- Enter your proxy address
- Save and restart Jellyfin
### 2. Check Initialization Logs
Look for:
```
[INF] SRFApiClient initializing with proxy enabled: http://your-proxy:port
[INF] Proxy configured: http://your-proxy:port (Authentication: False)
```
### 3. Trigger Content Refresh
- Dashboard → Scheduled Tasks → "Refresh SRF Play Content"
- Run task manually
- Watch logs for media composition requests
### 4. Verify Requests Go Through Proxy
You can monitor proxy logs (if available) or use network monitoring tools to confirm traffic routes through the proxy.
## Getting Help
When reporting issues, include:
1. **Plugin version:**
- Dashboard → Plugins → SRF Play → Version
2. **Relevant log entries:**
- The initialization message (with/without proxy)
- Any error messages
- The "Conversion complete" summary
- Resource processing details for failed items
3. **Configuration:**
- Business unit selected
- Proxy enabled? (yes/no, don't share credentials)
- Where Jellyfin is running (local network, VPS, etc.)
4. **Network environment:**
- Are you behind a geo-restriction?
- Is the proxy on the same network or remote?
- Any firewalls or network policies?
## Example of Good Debug Output
When everything works correctly with proxy:
```
[INF] SRFApiClient initializing with proxy enabled: http://192.168.1.1:3128
[INF] Proxy configured: http://192.168.1.1:3128 (Authentication: False)
[INF] Fetching media composition for URN: urn:srf:video:12345 from https://il.srgssr.ch/...
[INF] Media composition response for URN urn:srf:video:12345: StatusCode=OK
[INF] Successfully fetched media composition for URN: urn:srf:video:12345 - Chapters: 1
[INF] Processing chapter abc123 with 3 resources
[INF] Chapter abc123: Total resources=3, Non-DRM resources=2
[INF] Chapter abc123: HLS resources found=1
[DBG] Selected stream for chapter abc123: Quality=HD, Protocol=HLS, URL=https://...m3u8
```
## Example of Failed Conversion (DRM Issue)
```
[INF] Fetching media composition for URN: urn:srf:video:67890 from https://il.srgssr.ch/...
[INF] Media composition response for URN urn:srf:video:67890: StatusCode=OK
[INF] Successfully fetched media composition for URN: urn:srf:video:67890 - Chapters: 1
[INF] Processing chapter def456 with 2 resources
[INF] Chapter def456: Total resources=2, Non-DRM resources=0
[WRN] All resources for chapter def456 require DRM
[DBG] DRM resource: Protocol=HLS, Streaming=HLS, DRM=WIDEVINE
```
This means the content requires Widevine DRM and cannot be played by Jellyfin.
@@ -0,0 +1,287 @@
using FluentAssertions;
using Jellyfin.Plugin.SRFPlay.Api;
using Microsoft.Extensions.Logging;
using Xunit;
namespace Jellyfin.Plugin.SRFPlay.Tests.IntegrationTests;
/// <summary>
/// Integration tests to validate SRF API spec compliance.
/// These tests make real API calls to ensure the API is still working as expected.
/// </summary>
[Trait("Category", "Integration")]
[Trait("Category", "APISpec")]
public class SRFApiSpecTests : IDisposable
{
private readonly ILoggerFactory _loggerFactory;
private readonly SRFApiClient _apiClient;
private readonly CancellationToken _cancellationToken = CancellationToken.None;
public SRFApiSpecTests()
{
_loggerFactory = LoggerFactory.Create(builder =>
{
builder.AddConsole();
builder.SetMinimumLevel(LogLevel.Information);
});
_apiClient = new SRFApiClient(_loggerFactory);
}
[Fact]
public async Task GetAllShows_SRF_ReturnsShows()
{
// Act
var shows = await _apiClient.GetAllShowsAsync("srf", _cancellationToken);
// Assert
shows.Should().NotBeNull();
shows.Should().NotBeEmpty();
var firstShow = shows.FirstOrDefault();
firstShow.Should().NotBeNull();
firstShow!.Id.Should().NotBeNullOrEmpty();
firstShow.Title.Should().NotBeNullOrEmpty();
}
[Theory]
[InlineData("srf")]
[InlineData("rts")]
[InlineData("rsi")]
public async Task GetAllShows_MultipleBusinessUnits_ReturnsValidData(string businessUnit)
{
// Act
var shows = await _apiClient.GetAllShowsAsync(businessUnit, _cancellationToken);
// Assert
shows.Should().NotBeNull();
// If shows exist, validate their structure
if (shows != null && shows.Count > 0)
{
var firstShow = shows.FirstOrDefault();
firstShow.Should().NotBeNull();
firstShow!.Id.Should().NotBeNullOrEmpty();
// Validate URN format if present
if (!string.IsNullOrEmpty(firstShow.Urn))
{
firstShow.Urn.Should().Contain($":{businessUnit}:");
}
}
}
[Fact]
public async Task GetVideosForShow_ValidShowId_ReturnsVideos()
{
// Arrange - First get a show
var shows = await _apiClient.GetAllShowsAsync("srf", _cancellationToken);
shows.Should().NotBeNull().And.NotBeEmpty();
var showWithEpisodes = shows!.FirstOrDefault(s => s != null && s.NumberOfEpisodes > 0);
showWithEpisodes.Should().NotBeNull("at least one show should have episodes");
// Act
var videos = await _apiClient.GetVideosForShowAsync("srf", showWithEpisodes!.Id!, _cancellationToken);
// Assert
videos.Should().NotBeNull();
videos.Should().NotBeEmpty();
var firstVideo = videos!.FirstOrDefault();
firstVideo.Should().NotBeNull();
firstVideo!.Id.Should().NotBeNullOrEmpty();
firstVideo.Title.Should().NotBeNullOrEmpty();
firstVideo.Urn.Should().NotBeNullOrEmpty();
firstVideo.Urn.Should().StartWith("urn:srf:video:");
}
[Fact]
public async Task GetMediaCompositionByUrn_ValidVideoUrn_ReturnsMediaComposition()
{
// Arrange - Get a video URN
var shows = await _apiClient.GetAllShowsAsync("srf", _cancellationToken);
shows.Should().NotBeNull().And.NotBeEmpty();
var showWithEpisodes = shows!.FirstOrDefault(s => s != null && s.NumberOfEpisodes > 0);
var videos = await _apiClient.GetVideosForShowAsync("srf", showWithEpisodes!.Id!, _cancellationToken);
videos.Should().NotBeNull().And.NotBeEmpty();
var videoUrn = videos!.First()!.Urn!;
// Act
var mediaComposition = await _apiClient.GetMediaCompositionByUrnAsync(videoUrn, _cancellationToken);
// Assert
mediaComposition.Should().NotBeNull();
mediaComposition!.ChapterList.Should().NotBeNull();
mediaComposition.ChapterList.Should().NotBeEmpty();
var chapter = mediaComposition.ChapterList.First();
chapter.Should().NotBeNull();
chapter.Urn.Should().Be(videoUrn);
chapter.ResourceList.Should().NotBeNull();
}
[Fact]
public async Task GetMediaCompositionByUrn_ValidVideoUrn_HasHLSResources()
{
// Arrange - Get a video URN
var shows = await _apiClient.GetAllShowsAsync("srf", _cancellationToken);
var showWithEpisodes = shows!.FirstOrDefault(s => s != null && s.NumberOfEpisodes > 0);
var videos = await _apiClient.GetVideosForShowAsync("srf", showWithEpisodes!.Id!, _cancellationToken);
var videoUrn = videos!.First()!.Urn!;
// Act
var mediaComposition = await _apiClient.GetMediaCompositionByUrnAsync(videoUrn, _cancellationToken);
// Assert
var chapter = mediaComposition!.ChapterList.First();
var hlsResources = chapter.ResourceList!.Where(r =>
r.Protocol == "HLS" || r.Streaming == "HLS" || r.Url.Contains(".m3u8")).ToList();
hlsResources.Should().NotBeEmpty("video should have HLS streaming resources");
var hlsResource = hlsResources.First();
hlsResource.Url.Should().NotBeNullOrEmpty();
hlsResource.Url.Should().Contain(".m3u8");
}
[Fact]
public async Task VideoResource_Url_IsAccessible()
{
// Arrange - Get a video URN and stream URL
var shows = await _apiClient.GetAllShowsAsync("srf", _cancellationToken);
var showWithEpisodes = shows!.FirstOrDefault(s => s != null && s.NumberOfEpisodes > 0);
var videos = await _apiClient.GetVideosForShowAsync("srf", showWithEpisodes!.Id!, _cancellationToken);
var videoUrn = videos!.First()!.Urn!;
var mediaComposition = await _apiClient.GetMediaCompositionByUrnAsync(videoUrn, _cancellationToken);
var chapter = mediaComposition!.ChapterList.First();
var hlsResource = chapter.ResourceList!.FirstOrDefault(r =>
(r.DrmList == null || r.DrmList.ToString() == "[]") &&
(r.Protocol == "HLS" || r.Url.Contains(".m3u8")));
hlsResource.Should().NotBeNull("at least one non-DRM HLS resource should exist");
// Act - Try to access the URL
using var httpClient = new HttpClient();
var response = await httpClient.GetAsync(hlsResource!.Url, _cancellationToken);
// Assert
response.Should().NotBeNull();
response.IsSuccessStatusCode.Should().BeTrue("stream URL should be accessible");
var content = await response.Content.ReadAsStringAsync(_cancellationToken);
content.Should().Contain("#EXTM3U", "should be a valid M3U8 playlist");
}
[Fact]
public async Task GetAllShows_ResponseStructure_MatchesExpectedSchema()
{
// Act
var shows = await _apiClient.GetAllShowsAsync("srf", _cancellationToken);
// Assert - Validate response structure
shows.Should().NotBeNull();
shows.Should().NotBeEmpty();
var show = shows!.First()!;
// Required fields
show.Id.Should().NotBeNullOrEmpty();
show.Title.Should().NotBeNullOrEmpty();
// Optional but commonly present fields
show.NumberOfEpisodes.Should().BeGreaterThanOrEqualTo(0);
// If URN is present, validate format
if (!string.IsNullOrEmpty(show.Urn))
{
show.Urn.Should().MatchRegex(@"^urn:srf:(show|video):.+$");
}
}
[Fact]
public async Task GetVideosForShow_ResponseStructure_MatchesExpectedSchema()
{
// Arrange
var shows = await _apiClient.GetAllShowsAsync("srf", _cancellationToken);
var showWithEpisodes = shows!.FirstOrDefault(s => s != null && s.NumberOfEpisodes > 0);
// Act
var videos = await _apiClient.GetVideosForShowAsync("srf", showWithEpisodes!.Id!, _cancellationToken);
// Assert - Validate response structure
var video = videos!.First()!;
// Required fields
video.Id.Should().NotBeNullOrEmpty();
video.Title.Should().NotBeNullOrEmpty();
video.Urn.Should().NotBeNullOrEmpty();
video.Urn.Should().MatchRegex(@"^urn:srf:(video|scheduled_livestream:video):.+$");
// Duration should be positive
video.Duration.Should().BeGreaterThan(0);
// Date should be valid
video.Date.Should().BeAfter(DateTime.MinValue);
}
[Fact]
public async Task GetScheduledLivestreams_ReturnsValidData()
{
// Act
var livestreams = await _apiClient.GetScheduledLivestreamsAsync("srf", "SPORT", _cancellationToken);
// Assert
livestreams.Should().NotBeNull();
// If there are livestreams, validate their structure
if (livestreams != null && livestreams.Count > 0)
{
var livestream = livestreams.First();
livestream.Should().NotBeNull();
livestream.Urn.Should().NotBeNullOrEmpty();
livestream.Urn.Should().Contain("scheduled_livestream");
}
}
[Fact]
public async Task ApiEndpoints_AreResponsive()
{
// This test ensures all major endpoints are responsive
var tasks = new List<Task>
{
_apiClient.GetAllShowsAsync("srf", _cancellationToken),
_apiClient.GetScheduledLivestreamsAsync("srf", "SPORT", _cancellationToken)
};
// Act - All API calls should complete without exceptions
var act = async () => await Task.WhenAll(tasks);
// Assert
await act.Should().NotThrowAsync("all API endpoints should be responsive");
}
[Fact]
public async Task ApiPerformance_ReasonableResponseTime()
{
// Arrange
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
// Act
var shows = await _apiClient.GetAllShowsAsync("srf", _cancellationToken);
// Assert
stopwatch.Stop();
stopwatch.Elapsed.Should().BeLessThan(TimeSpan.FromSeconds(30),
"API should respond within 30 seconds");
shows.Should().NotBeEmpty();
}
public void Dispose()
{
_apiClient?.Dispose();
_loggerFactory?.Dispose();
}
}
@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="../Jellyfin.Plugin.SRFPlay/Jellyfin.Plugin.SRFPlay.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="8.0.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="FluentAssertions" Version="6.12.1" />
</ItemGroup>
<PropertyGroup>
<!-- Support both .NET 8 and 9 for flexibility in dev environments -->
<TargetFrameworks>net8.0;net9.0</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
</Project>
+284
View File
@@ -0,0 +1,284 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Api;
using Jellyfin.Plugin.SRFPlay.Api.Models;
using Jellyfin.Plugin.SRFPlay.Configuration;
using Jellyfin.Plugin.SRFPlay.Services;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Tests
{
class Program
{
static async Task Main(string[] args)
{
// Run the new Play v3 API tests
await TestPlayV3Api.RunTests();
Console.WriteLine("\n\n");
Console.WriteLine("=================================================================");
Console.WriteLine("OLD API TESTS (DEPRECATED - Expected to fail)");
Console.WriteLine("=================================================================\n");
Console.WriteLine("=== SRFPlay Plugin Test Suite (Old API) ===\n");
// Setup logging
using var loggerFactory = LoggerFactory.Create(builder =>
{
builder.AddConsole();
builder.SetMinimumLevel(LogLevel.Warning); // Only show warnings and errors
});
var apiClient = new SRFApiClient(loggerFactory);
var streamResolver = new StreamUrlResolver(loggerFactory.CreateLogger<StreamUrlResolver>());
var cancellationToken = CancellationToken.None;
// Test 1: Check API connectivity
Console.WriteLine("[Test 1] API Connectivity Test");
Console.WriteLine("-------------------------------");
try
{
var latestVideos = await apiClient.GetLatestVideosAsync("srf", cancellationToken);
if (latestVideos?.ChapterList != null && latestVideos.ChapterList.Any())
{
Console.WriteLine($"✓ Successfully fetched latest videos: {latestVideos.ChapterList.Count} items");
Console.WriteLine($" First video: {latestVideos.ChapterList[0]?.Title}");
}
else
{
Console.WriteLine("✗ Failed: No videos returned");
}
}
catch (Exception ex)
{
Console.WriteLine($"✗ Error: {ex.Message}");
}
Console.WriteLine();
// Test 2: Check trending videos
Console.WriteLine("[Test 2] Trending Videos Test");
Console.WriteLine("-----------------------------");
try
{
var trendingVideos = await apiClient.GetTrendingVideosAsync("srf", cancellationToken);
if (trendingVideos?.ChapterList != null && trendingVideos.ChapterList.Any())
{
Console.WriteLine($"✓ Successfully fetched trending videos: {trendingVideos.ChapterList.Count} items");
Console.WriteLine($" First video: {trendingVideos.ChapterList[0]?.Title}");
}
else
{
Console.WriteLine("✗ Failed: No videos returned");
}
}
catch (Exception ex)
{
Console.WriteLine($"✗ Error: {ex.Message}");
}
Console.WriteLine();
// Test 3: Get specific video and check stream URL
Console.WriteLine("[Test 3] Stream URL Resolution Test");
Console.WriteLine("-----------------------------------");
try
{
var latestVideos = await apiClient.GetLatestVideosAsync("srf", cancellationToken);
if (latestVideos?.ChapterList != null && latestVideos.ChapterList.Any())
{
var firstChapter = latestVideos.ChapterList[0];
Console.WriteLine($"Testing with: {firstChapter.Title}");
Console.WriteLine($"URN: {firstChapter.Urn}");
// Fetch full media composition for this URN
var mediaComposition = await apiClient.GetMediaCompositionByUrnAsync(firstChapter.Urn, cancellationToken);
if (mediaComposition?.ChapterList != null && mediaComposition.ChapterList.Any())
{
var chapter = mediaComposition.ChapterList[0];
// Check if content is playable
bool hasPlayableContent = streamResolver.HasPlayableContent(chapter);
Console.WriteLine($"Has playable content: {hasPlayableContent}");
// Check if content is expired
bool isExpired = streamResolver.IsContentExpired(chapter);
Console.WriteLine($"Is expired: {isExpired}");
// Try to get stream URL
if (hasPlayableContent && !isExpired)
{
var streamUrl = streamResolver.GetStreamUrl(chapter, Jellyfin.Plugin.SRFPlay.Configuration.QualityPreference.Auto);
if (!string.IsNullOrEmpty(streamUrl))
{
Console.WriteLine($"✓ Stream URL resolved: {streamUrl.Substring(0, Math.Min(80, streamUrl.Length))}...");
// Show available resources
if (chapter.ResourceList != null)
{
Console.WriteLine($" Available resources: {chapter.ResourceList.Count}");
foreach (var resource in chapter.ResourceList.Take(5))
{
var hasDrm = resource.DrmList != null && resource.DrmList.ToString() != "[]";
Console.WriteLine($" - {resource.Quality} ({resource.Protocol}) {(hasDrm ? "[DRM]" : "[No DRM]")}");
}
}
}
else
{
Console.WriteLine("✗ Failed to resolve stream URL");
}
}
else
{
if (!hasPlayableContent)
Console.WriteLine("✗ Content is not playable (likely DRM protected)");
if (isExpired)
Console.WriteLine($"✗ Content is expired (ValidTo: {chapter.ValidTo})");
}
}
else
{
Console.WriteLine("✗ Failed to fetch media composition");
}
}
}
catch (Exception ex)
{
Console.WriteLine($"✗ Error: {ex.Message}");
Console.WriteLine($"Stack trace: {ex.StackTrace}");
}
Console.WriteLine();
// Test 4: Check multiple videos for playability
Console.WriteLine("[Test 4] Multiple Videos Playability Check");
Console.WriteLine("------------------------------------------");
try
{
var latestVideos = await apiClient.GetLatestVideosAsync("srf", cancellationToken);
if (latestVideos?.ChapterList != null)
{
int totalVideos = Math.Min(10, latestVideos.ChapterList.Count);
int playableCount = 0;
int expiredCount = 0;
int drmCount = 0;
Console.WriteLine($"Checking {totalVideos} videos...\n");
for (int i = 0; i < totalVideos; i++)
{
var chapter = latestVideos.ChapterList[i];
var mediaComp = await apiClient.GetMediaCompositionByUrnAsync(chapter.Urn, cancellationToken);
if (mediaComp?.ChapterList != null && mediaComp.ChapterList.Any())
{
var fullChapter = mediaComp.ChapterList[0];
bool hasPlayable = streamResolver.HasPlayableContent(fullChapter);
bool isExpired = streamResolver.IsContentExpired(fullChapter);
string status;
if (isExpired)
{
status = "EXPIRED";
expiredCount++;
}
else if (!hasPlayable)
{
status = "DRM";
drmCount++;
}
else
{
status = "PLAYABLE";
playableCount++;
}
Console.WriteLine($"{i + 1}. [{status}] {fullChapter.Title}");
}
}
Console.WriteLine();
Console.WriteLine($"Summary:");
Console.WriteLine($" Playable: {playableCount}/{totalVideos} ({(playableCount * 100.0 / totalVideos):F1}%)");
Console.WriteLine($" DRM Protected: {drmCount}/{totalVideos} ({(drmCount * 100.0 / totalVideos):F1}%)");
Console.WriteLine($" Expired: {expiredCount}/{totalVideos} ({(expiredCount * 100.0 / totalVideos):F1}%)");
}
}
catch (Exception ex)
{
Console.WriteLine($"✗ Error: {ex.Message}");
}
Console.WriteLine();
// Test 5: Test library content discovery (simulates what would be in the library)
Console.WriteLine("[Test 5] Library Content Discovery Simulation");
Console.WriteLine("----------------------------------------------");
try
{
var latestVideos = await apiClient.GetLatestVideosAsync("srf", cancellationToken);
var trendingVideos = await apiClient.GetTrendingVideosAsync("srf", cancellationToken);
var allUrns = new HashSet<string>();
if (latestVideos?.ChapterList != null)
{
foreach (var chapter in latestVideos.ChapterList)
{
if (!string.IsNullOrEmpty(chapter.Urn))
allUrns.Add(chapter.Urn);
}
}
if (trendingVideos?.ChapterList != null)
{
foreach (var chapter in trendingVideos.ChapterList)
{
if (!string.IsNullOrEmpty(chapter.Urn))
allUrns.Add(chapter.Urn);
}
}
Console.WriteLine($"✓ Latest videos available: {latestVideos?.ChapterList?.Count ?? 0}");
Console.WriteLine($"✓ Trending videos available: {trendingVideos?.ChapterList?.Count ?? 0}");
Console.WriteLine($"✓ Total unique items for library (deduplicated): {allUrns.Count}");
}
catch (Exception ex)
{
Console.WriteLine($"✗ Error: {ex.Message}");
}
Console.WriteLine();
// Test 6: Test different business units
Console.WriteLine("[Test 6] Business Units Test");
Console.WriteLine("----------------------------");
var businessUnits = new[] { "srf", "rts", "rsi", "rtr", "swi" };
foreach (var bu in businessUnits)
{
try
{
var videos = await apiClient.GetLatestVideosAsync(bu, cancellationToken);
if (videos?.ChapterList != null && videos.ChapterList.Any())
{
Console.WriteLine($"✓ {bu.ToUpper()}: {videos.ChapterList.Count} videos available");
}
else
{
Console.WriteLine($"✗ {bu.ToUpper()}: No videos found");
}
}
catch (Exception ex)
{
Console.WriteLine($"✗ {bu.ToUpper()}: Error - {ex.Message}");
}
}
Console.WriteLine();
Console.WriteLine("=== Test Suite Complete ===");
}
}
}
+154
View File
@@ -0,0 +1,154 @@
# SRF Play Plugin Tests
This directory contains the test suite for the Jellyfin SRF Play plugin.
## Test Structure
### Unit Tests (`UnitTests/`)
Fast, isolated tests that verify individual components without external dependencies.
- `StreamUrlResolverTests.cs` - Tests for stream URL resolution logic
- `MetadataCacheTests.cs` - Tests for metadata caching functionality
### Integration Tests (`IntegrationTests/`)
Tests that make real API calls to validate the SRF Play API spec compliance.
- `SRFApiSpecTests.cs` - Validates API endpoints, response schemas, and data integrity
### Legacy Tests
- `Program.cs` - Legacy console test application (kept for manual testing)
- `TestPlayV3Api.cs` - Legacy Play v3 API tests (kept for manual testing)
## Running Tests
### Run All Tests
```bash
dotnet test
```
### Run Only Unit Tests
```bash
dotnet test --filter "Category!=Integration&Category!=APISpec"
```
### Run Only Integration Tests
```bash
dotnet test --filter "Category=Integration"
```
### Run Only API Spec Tests
```bash
dotnet test --filter "Category=APISpec"
```
### Run Tests with Coverage
```bash
dotnet test --collect:"XPlat Code Coverage"
```
### Run Tests with Detailed Output
```bash
dotnet test --logger "console;verbosity=detailed"
```
## Test Categories
Tests are organized using xUnit traits:
- **Unit Tests**: No category (default)
- **Integration Tests**: `[Trait("Category", "Integration")]`
- **API Spec Tests**: `[Trait("Category", "APISpec")]`
## Continuous Integration
### Unit Tests
- Run on every push and pull request
- Workflow: `.github/workflows/unit-tests.yaml`
- Must pass before merging PRs
### API Spec Tests (Nightly)
- Run every night at 2 AM UTC
- Workflow: `.github/workflows/nightly-api-tests.yaml`
- Validates that the SRF Play API is still working as expected
- Creates an issue automatically if tests fail
## Adding New Tests
### Unit Test Example
```csharp
using Xunit;
using FluentAssertions;
public class MyServiceTests
{
[Fact]
public void MyMethod_WithValidInput_ReturnsExpectedResult()
{
// Arrange
var service = new MyService();
// Act
var result = service.MyMethod("test");
// Assert
result.Should().Be("expected");
}
}
```
### Integration Test Example
```csharp
using Xunit;
using FluentAssertions;
[Trait("Category", "Integration")]
public class MyApiTests
{
[Fact]
public async Task ApiCall_ReturnsValidData()
{
// Arrange
var client = new ApiClient();
// Act
var result = await client.GetDataAsync();
// Assert
result.Should().NotBeNull();
}
}
```
## Test Dependencies
- **xUnit** - Test framework
- **FluentAssertions** - Fluent assertion library for readable tests
- **Moq** - Mocking framework for creating test doubles
- **Microsoft.NET.Test.Sdk** - .NET test SDK
## Best Practices
1. **Unit Tests Should Be Fast**: Each test should run in milliseconds
2. **Integration Tests Can Be Slower**: API calls may take seconds
3. **Use Descriptive Names**: Test names should describe what they test
4. **Follow AAA Pattern**: Arrange, Act, Assert
5. **One Assertion Per Test**: Focus each test on a single behavior
6. **Clean Up Resources**: Implement `IDisposable` when needed
7. **Avoid Test Interdependence**: Each test should be independent
## Troubleshooting
### Tests Fail Locally
1. Ensure you have internet connectivity (integration tests need it)
2. Check if the SRF Play API is accessible from your location
3. Verify .NET 8.0 SDK is installed
### API Spec Tests Fail
1. Check if the SRF Play API has changed
2. Review the API documentation
3. Update models and tests if necessary
### Coverage is Low
1. Add tests for uncovered code paths
2. Use `dotnet test --collect:"XPlat Code Coverage"` to generate reports
3. Review `TestResults/` directory for coverage details
@@ -0,0 +1,220 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Api;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Tests
{
public class TestPlayV3Api
{
public static async Task RunTests()
{
Console.WriteLine("=== Play v3 API Test Suite ===\n");
using var loggerFactory = LoggerFactory.Create(builder =>
{
builder.AddConsole();
builder.SetMinimumLevel(LogLevel.Warning);
});
var apiClient = new SRFApiClient(loggerFactory);
var cancellationToken = CancellationToken.None;
// Test 1: Get all shows
Console.WriteLine("[Test 1] Fetch All Shows");
Console.WriteLine("------------------------");
try
{
var shows = await apiClient.GetAllShowsAsync("srf", cancellationToken);
if (shows != null && shows.Any())
{
Console.WriteLine($"✓ Successfully fetched {shows.Count} shows");
Console.WriteLine($" First show: {shows[0]?.Title} ({shows[0]?.NumberOfEpisodes} episodes)");
Console.WriteLine($" Show ID: {shows[0]?.Id}");
Console.WriteLine($" URN: {shows[0]?.Urn}");
}
else
{
Console.WriteLine("✗ Failed: No shows returned");
}
}
catch (Exception ex)
{
Console.WriteLine($"✗ Error: {ex.Message}");
}
Console.WriteLine();
// Test 2: Get videos for a show
Console.WriteLine("[Test 2] Fetch Videos for a Show");
Console.WriteLine("--------------------------------");
try
{
var shows = await apiClient.GetAllShowsAsync("srf", cancellationToken);
if (shows != null && shows.Any())
{
// Find a show with episodes
var showWithEpisodes = shows.FirstOrDefault(s => s.NumberOfEpisodes > 0);
if (showWithEpisodes != null && showWithEpisodes.Id != null)
{
Console.WriteLine($"Testing with show: {showWithEpisodes.Title}");
var videos = await apiClient.GetVideosForShowAsync("srf", showWithEpisodes.Id, cancellationToken);
if (videos != null && videos.Any())
{
Console.WriteLine($"✓ Successfully fetched {videos.Count} videos");
var firstVideo = videos[0];
Console.WriteLine($" First video: {firstVideo?.Title}");
Console.WriteLine($" URN: {firstVideo?.Urn}");
Console.WriteLine($" Duration: {firstVideo?.Duration / 1000}s");
Console.WriteLine($" Date: {firstVideo?.Date}");
Console.WriteLine($" Playable abroad: {firstVideo?.PlayableAbroad}");
}
else
{
Console.WriteLine("✗ Failed: No videos returned");
}
}
else
{
Console.WriteLine("✗ Failed: No shows with episodes found");
}
}
}
catch (Exception ex)
{
Console.WriteLine($"✗ Error: {ex.Message}");
}
Console.WriteLine();
// Test 3: Get video stream URL using Integration Layer
Console.WriteLine("[Test 3] Get Stream URL for Video");
Console.WriteLine("---------------------------------");
try
{
var shows = await apiClient.GetAllShowsAsync("srf", cancellationToken);
if (shows != null && shows.Any())
{
var showWithEpisodes = shows.FirstOrDefault(s => s.NumberOfEpisodes > 0);
if (showWithEpisodes != null && showWithEpisodes.Id != null)
{
var videos = await apiClient.GetVideosForShowAsync("srf", showWithEpisodes.Id, cancellationToken);
if (videos != null && videos.Any())
{
var video = videos[0];
if (video.Urn != null)
{
Console.WriteLine($"Fetching stream for: {video.Title}");
// Use Integration Layer 2.0 to get stream URL
var mediaComposition = await apiClient.GetMediaCompositionByUrnAsync(video.Urn, cancellationToken);
if (mediaComposition?.ChapterList != null && mediaComposition.ChapterList.Any())
{
var chapter = mediaComposition.ChapterList[0];
if (chapter.ResourceList != null && chapter.ResourceList.Any())
{
var hlsResource = chapter.ResourceList.FirstOrDefault(r =>
r.Protocol == "HLS" && (r.DrmList == null || r.DrmList.ToString() == "[]"));
if (hlsResource != null)
{
Console.WriteLine($"✓ Stream URL found:");
Console.WriteLine($" URL: {hlsResource.Url?.Substring(0, Math.Min(80, hlsResource.Url?.Length ?? 0))}...");
Console.WriteLine($" Quality: {hlsResource.Quality}");
Console.WriteLine($" Protocol: {hlsResource.Protocol}");
}
else
{
Console.WriteLine("✗ No HLS stream without DRM found");
}
}
else
{
Console.WriteLine("✗ No resources found");
}
}
else
{
Console.WriteLine("✗ Failed to fetch media composition");
}
}
}
}
}
}
catch (Exception ex)
{
Console.WriteLine($"✗ Error: {ex.Message}");
}
Console.WriteLine();
// Test 4: Check multiple shows
Console.WriteLine("[Test 4] Check Multiple Shows");
Console.WriteLine("----------------------------");
try
{
var shows = await apiClient.GetAllShowsAsync("srf", cancellationToken);
if (shows != null)
{
var showsWithEpisodes = shows.Where(s => s.NumberOfEpisodes > 0).Take(10).ToList();
Console.WriteLine($"Checking {showsWithEpisodes.Count} shows with episodes...\n");
int successCount = 0;
foreach (var show in showsWithEpisodes)
{
if (show.Id != null)
{
var videos = await apiClient.GetVideosForShowAsync("srf", show.Id, cancellationToken);
if (videos != null && videos.Any())
{
Console.WriteLine($"✓ {show.Title}: {videos.Count} videos available");
successCount++;
}
else
{
Console.WriteLine($"✗ {show.Title}: No videos found");
}
}
}
Console.WriteLine();
Console.WriteLine($"Summary: {successCount}/{showsWithEpisodes.Count} shows successfully fetched videos");
}
}
catch (Exception ex)
{
Console.WriteLine($"✗ Error: {ex.Message}");
}
Console.WriteLine();
// Test 5: Test all business units
Console.WriteLine("[Test 5] Test All Business Units");
Console.WriteLine("--------------------------------");
var businessUnits = new[] { "srf", "rts", "rsi", "rtr", "swi" };
foreach (var bu in businessUnits)
{
try
{
var shows = await apiClient.GetAllShowsAsync(bu, cancellationToken);
if (shows != null && shows.Any())
{
Console.WriteLine($"✓ {bu.ToUpper()}: {shows.Count} shows available");
}
else
{
Console.WriteLine($"✗ {bu.ToUpper()}: No shows found");
}
}
catch (Exception ex)
{
Console.WriteLine($"✗ {bu.ToUpper()}: Error - {ex.Message}");
}
}
Console.WriteLine();
Console.WriteLine("=== Test Suite Complete ===");
}
}
}
@@ -0,0 +1,163 @@
using FluentAssertions;
using Jellyfin.Plugin.SRFPlay.Api.Models;
using Jellyfin.Plugin.SRFPlay.Services;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
namespace Jellyfin.Plugin.SRFPlay.Tests.UnitTests;
/// <summary>
/// Unit tests for MetadataCache.
/// </summary>
public class MetadataCacheTests : IDisposable
{
private readonly Mock<ILogger<MetadataCache>> _loggerMock;
private readonly MetadataCache _cache;
public MetadataCacheTests()
{
_loggerMock = new Mock<ILogger<MetadataCache>>();
_cache = new MetadataCache(_loggerMock.Object);
}
[Fact]
public void SetMediaComposition_And_GetMediaComposition_ReturnsCorrectValue()
{
// Arrange
const string urn = "urn:srf:video:test-id";
var mediaComposition = new MediaComposition
{
Episode = new Episode { Id = "test-episode", Title = "Test" }
};
const int cacheDurationMinutes = 10;
// Act
_cache.SetMediaComposition(urn, mediaComposition);
var result = _cache.GetMediaComposition(urn, cacheDurationMinutes);
// Assert
result.Should().NotBeNull();
result.Should().Be(mediaComposition);
}
[Fact]
public void GetMediaComposition_NonExistentKey_ReturnsNull()
{
// Act
var result = _cache.GetMediaComposition("non-existent-urn", 10);
// Assert
result.Should().BeNull();
}
[Fact]
public void GetMediaComposition_ExpiredEntry_ReturnsNull()
{
// Arrange
const string urn = "urn:srf:video:test-id";
var mediaComposition = new MediaComposition
{
Episode = new Episode { Id = "test-episode" }
};
// Set with cache
_cache.SetMediaComposition(urn, mediaComposition);
// Wait a tiny bit to ensure expiration
System.Threading.Thread.Sleep(10);
// Act - Try to get with 0 minute cache duration (immediate expiration)
var result = _cache.GetMediaComposition(urn, 0);
// Assert - Should be null because it's expired
result.Should().BeNull();
}
[Fact]
public void RemoveMediaComposition_ExistingUrn_RemovesValue()
{
// Arrange
const string urn = "urn:srf:video:test-id";
var mediaComposition = new MediaComposition
{
Episode = new Episode { Id = "test-episode" }
};
_cache.SetMediaComposition(urn, mediaComposition);
// Act
_cache.RemoveMediaComposition(urn);
var result = _cache.GetMediaComposition(urn, 10);
// Assert
result.Should().BeNull();
}
[Fact]
public void Clear_RemovesAllValues()
{
// Arrange
var mc1 = new MediaComposition { Episode = new Episode { Id = "episode1" } };
var mc2 = new MediaComposition { Episode = new Episode { Id = "episode2" } };
_cache.SetMediaComposition("urn1", mc1);
_cache.SetMediaComposition("urn2", mc2);
// Act
_cache.Clear();
var result1 = _cache.GetMediaComposition("urn1", 10);
var result2 = _cache.GetMediaComposition("urn2", 10);
// Assert
result1.Should().BeNull();
result2.Should().BeNull();
}
[Fact]
public void GetStatistics_ReturnsCorrectCount()
{
// Arrange
var mc1 = new MediaComposition { Episode = new Episode { Id = "episode1" } };
var mc2 = new MediaComposition { Episode = new Episode { Id = "episode2" } };
_cache.SetMediaComposition("urn1", mc1);
_cache.SetMediaComposition("urn2", mc2);
// Act
var (count, sizeEstimate) = _cache.GetStatistics();
// Assert
count.Should().Be(2);
sizeEstimate.Should().BeGreaterThan(0);
}
[Fact]
public void ConcurrentAccess_DoesNotThrow()
{
// Arrange
var tasks = new List<Task>();
// Act - Perform concurrent operations
for (int i = 0; i < 100; i++)
{
var index = i;
tasks.Add(Task.Run(() =>
{
var mc = new MediaComposition { Episode = new Episode { Id = $"episode-{index}" } };
_cache.SetMediaComposition($"urn-{index}", mc);
_cache.GetMediaComposition($"urn-{index}", 10);
if (index % 2 == 0)
{
_cache.RemoveMediaComposition($"urn-{index}");
}
}));
}
// Assert - Should not throw
var action = async () => await Task.WhenAll(tasks);
action.Should().NotThrowAsync();
}
public void Dispose()
{
_cache?.Dispose();
}
}
@@ -0,0 +1,179 @@
using FluentAssertions;
using Jellyfin.Plugin.SRFPlay.Api.Models;
using Jellyfin.Plugin.SRFPlay.Configuration;
using Jellyfin.Plugin.SRFPlay.Services;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
namespace Jellyfin.Plugin.SRFPlay.Tests.UnitTests;
/// <summary>
/// Unit tests for StreamUrlResolver.
/// </summary>
public class StreamUrlResolverTests : IDisposable
{
private readonly Mock<ILogger<StreamUrlResolver>> _loggerMock;
private readonly StreamUrlResolver _resolver;
public StreamUrlResolverTests()
{
_loggerMock = new Mock<ILogger<StreamUrlResolver>>();
_resolver = new StreamUrlResolver(_loggerMock.Object);
}
[Fact]
public void GetStreamUrl_WithNullChapter_ReturnsNull()
{
// Act
var result = _resolver.GetStreamUrl(null!, QualityPreference.Auto);
// Assert
result.Should().BeNull();
}
[Fact]
public void GetStreamUrl_WithNoResources_ReturnsNull()
{
// Arrange
var chapter = new Chapter
{
Id = "test-id",
ResourceList = new List<Resource>()
};
// Act
var result = _resolver.GetStreamUrl(chapter, QualityPreference.Auto);
// Assert
result.Should().BeNull();
}
[Fact]
public void GetStreamUrl_WithDrmProtectedOnly_ReturnsNull()
{
// Arrange
var chapter = new Chapter
{
Id = "test-id",
ResourceList = new List<Resource>
{
new Resource
{
Url = "https://example.com/stream.m3u8",
Protocol = "HLS",
Quality = "HD",
DrmList = new System.Text.Json.JsonElement() // Non-empty DRM
}
}
};
// Act
var result = _resolver.GetStreamUrl(chapter, QualityPreference.Auto);
// Assert
result.Should().BeNull();
}
[Fact]
public void HasPlayableContent_WithNonDrmHlsStream_ReturnsTrue()
{
// Arrange
var chapter = new Chapter
{
ResourceList = new List<Resource>
{
new Resource
{
Url = "https://example.com/stream.m3u8",
Protocol = "HLS",
Quality = "HD",
DrmList = null
}
}
};
// Act
var result = _resolver.HasPlayableContent(chapter);
// Assert
result.Should().BeTrue();
}
[Fact]
public void HasPlayableContent_WithDrmOnly_ReturnsFalse()
{
// Arrange
var chapter = new Chapter
{
ResourceList = new List<Resource>
{
new Resource
{
Url = "https://example.com/stream.m3u8",
Protocol = "HLS",
DrmList = new System.Text.Json.JsonElement() // Non-empty DRM
}
}
};
// Act
var result = _resolver.HasPlayableContent(chapter);
// Assert
result.Should().BeFalse();
}
[Fact]
public void IsContentExpired_WithFutureValidTo_ReturnsFalse()
{
// Arrange
var chapter = new Chapter
{
ValidTo = DateTime.UtcNow.AddDays(7)
};
// Act
var result = _resolver.IsContentExpired(chapter);
// Assert
result.Should().BeFalse();
}
[Fact]
public void IsContentExpired_WithPastValidTo_ReturnsTrue()
{
// Arrange
var chapter = new Chapter
{
ValidTo = DateTime.UtcNow.AddDays(-1)
};
// Act
var result = _resolver.IsContentExpired(chapter);
// Assert
result.Should().BeTrue();
}
[Fact]
public void IsContentExpired_WithNullValidTo_ReturnsFalse()
{
// Arrange
var chapter = new Chapter
{
ValidTo = null
};
// Act
var result = _resolver.IsContentExpired(chapter);
// Assert
result.Should().BeFalse();
}
public void Dispose()
{
_resolver?.Dispose();
}
}
+42
View File
@@ -0,0 +1,42 @@
Microsoft Visual Studio Solution File, Format Version 12.00
#
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Plugin.SRFPlay", "Jellyfin.Plugin.SRFPlay\Jellyfin.Plugin.SRFPlay.csproj", "{D921B930-CF91-406F-ACBC-08914DCD0D34}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Plugin.SRFPlay.Tests", "Jellyfin.Plugin.SRFPlay.Tests\Jellyfin.Plugin.SRFPlay.Tests.csproj", "{2F8A80BE-D938-42DE-B351-B705D154C1AE}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{D921B930-CF91-406F-ACBC-08914DCD0D34}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D921B930-CF91-406F-ACBC-08914DCD0D34}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D921B930-CF91-406F-ACBC-08914DCD0D34}.Debug|x64.ActiveCfg = Debug|Any CPU
{D921B930-CF91-406F-ACBC-08914DCD0D34}.Debug|x64.Build.0 = Debug|Any CPU
{D921B930-CF91-406F-ACBC-08914DCD0D34}.Debug|x86.ActiveCfg = Debug|Any CPU
{D921B930-CF91-406F-ACBC-08914DCD0D34}.Debug|x86.Build.0 = Debug|Any CPU
{D921B930-CF91-406F-ACBC-08914DCD0D34}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D921B930-CF91-406F-ACBC-08914DCD0D34}.Release|Any CPU.Build.0 = Release|Any CPU
{D921B930-CF91-406F-ACBC-08914DCD0D34}.Release|x64.ActiveCfg = Release|Any CPU
{D921B930-CF91-406F-ACBC-08914DCD0D34}.Release|x64.Build.0 = Release|Any CPU
{D921B930-CF91-406F-ACBC-08914DCD0D34}.Release|x86.ActiveCfg = Release|Any CPU
{D921B930-CF91-406F-ACBC-08914DCD0D34}.Release|x86.Build.0 = Release|Any CPU
{2F8A80BE-D938-42DE-B351-B705D154C1AE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2F8A80BE-D938-42DE-B351-B705D154C1AE}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2F8A80BE-D938-42DE-B351-B705D154C1AE}.Debug|x64.ActiveCfg = Debug|Any CPU
{2F8A80BE-D938-42DE-B351-B705D154C1AE}.Debug|x64.Build.0 = Debug|Any CPU
{2F8A80BE-D938-42DE-B351-B705D154C1AE}.Debug|x86.ActiveCfg = Debug|Any CPU
{2F8A80BE-D938-42DE-B351-B705D154C1AE}.Debug|x86.Build.0 = Debug|Any CPU
{2F8A80BE-D938-42DE-B351-B705D154C1AE}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2F8A80BE-D938-42DE-B351-B705D154C1AE}.Release|Any CPU.Build.0 = Release|Any CPU
{2F8A80BE-D938-42DE-B351-B705D154C1AE}.Release|x64.ActiveCfg = Release|Any CPU
{2F8A80BE-D938-42DE-B351-B705D154C1AE}.Release|x64.Build.0 = Release|Any CPU
{2F8A80BE-D938-42DE-B351-B705D154C1AE}.Release|x86.ActiveCfg = Release|Any CPU
{2F8A80BE-D938-42DE-B351-B705D154C1AE}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal
@@ -0,0 +1,102 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.SRFPlay.Api.Models;
/// <summary>
/// Represents a chapter (video/episode) in the SRF API response.
/// </summary>
public class Chapter
{
/// <summary>
/// Gets or sets the unique identifier.
/// </summary>
[JsonPropertyName("id")]
public string Id { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the URN (Uniform Resource Name).
/// </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 lead/description.
/// </summary>
[JsonPropertyName("lead")]
public string? Lead { get; set; }
/// <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 the duration in milliseconds.
/// </summary>
[JsonPropertyName("duration")]
public long Duration { get; set; }
/// <summary>
/// Gets or sets the publication date.
/// </summary>
[JsonPropertyName("date")]
public DateTime? Date { get; set; }
/// <summary>
/// Gets or sets the valid from date.
/// </summary>
[JsonPropertyName("validFrom")]
public DateTime? ValidFrom { get; set; }
/// <summary>
/// Gets or sets the valid to date (expiration).
/// </summary>
[JsonPropertyName("validTo")]
public DateTime? ValidTo { get; set; }
/// <summary>
/// Gets or sets the list of available resources (streams).
/// </summary>
[JsonPropertyName("resourceList")]
public IReadOnlyList<Resource> ResourceList { get; set; } = new List<Resource>();
/// <summary>
/// Gets or sets the episode number.
/// </summary>
[JsonPropertyName("episodeNumber")]
public int? EpisodeNumber { get; set; }
/// <summary>
/// Gets or sets the season number.
/// </summary>
[JsonPropertyName("seasonNumber")]
public int? SeasonNumber { get; set; }
/// <summary>
/// Gets or sets the media type.
/// </summary>
[JsonPropertyName("mediaType")]
public string? MediaType { get; set; }
/// <summary>
/// Gets or sets the type (e.g., SCHEDULED_LIVESTREAM).
/// </summary>
[JsonPropertyName("type")]
public string? Type { get; set; }
}
@@ -0,0 +1,64 @@
using System;
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.SRFPlay.Api.Models;
/// <summary>
/// Represents an episode in the SRF API response.
/// </summary>
public class Episode
{
/// <summary>
/// Gets or sets the unique identifier.
/// </summary>
[JsonPropertyName("id")]
public string Id { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the URN.
/// </summary>
[JsonPropertyName("urn")]
public string? Urn { get; set; }
/// <summary>
/// Gets or sets the title.
/// </summary>
[JsonPropertyName("title")]
public string Title { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the lead/description.
/// </summary>
[JsonPropertyName("lead")]
public string? Lead { get; set; }
/// <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 the episode number.
/// </summary>
[JsonPropertyName("episodeNumber")]
public int? EpisodeNumber { get; set; }
/// <summary>
/// Gets or sets the season number.
/// </summary>
[JsonPropertyName("seasonNumber")]
public int? SeasonNumber { get; set; }
/// <summary>
/// Gets or sets the publication date.
/// </summary>
[JsonPropertyName("publishedDate")]
public DateTime? PublishedDate { get; set; }
}
@@ -0,0 +1,29 @@
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.SRFPlay.Api.Models;
/// <summary>
/// Represents the root media composition response from SRF API.
/// </summary>
public class MediaComposition
{
/// <summary>
/// Gets or sets the list of chapters (videos/episodes).
/// </summary>
[JsonPropertyName("chapterList")]
public IReadOnlyList<Chapter> ChapterList { get; set; } = new List<Chapter>();
/// <summary>
/// Gets or sets the episode information.
/// </summary>
[JsonPropertyName("episode")]
public Episode? Episode { get; set; }
/// <summary>
/// Gets or sets the show information.
/// </summary>
[JsonPropertyName("show")]
public Show? Show { get; set; }
}
@@ -0,0 +1,26 @@
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.SRFPlay.Api.Models;
/// <summary>
/// Represents the data container in a Play v3 response.
/// </summary>
/// <typeparam name="T">The type of data items.</typeparam>
public class PlayV3DataContainer<T>
{
/// <summary>
/// Gets or sets the list of data items.
/// </summary>
[JsonPropertyName("data")]
[SuppressMessage("Usage", "CA2227:Collection properties should be read only", Justification = "Required for JSON deserialization")]
[SuppressMessage("Design", "CA1002:Do not expose generic lists", Justification = "DTO for JSON deserialization")]
public List<T>? Data { get; set; }
/// <summary>
/// Gets or sets the next page cursor.
/// </summary>
[JsonPropertyName("next")]
public string? Next { get; set; }
}
@@ -0,0 +1,20 @@
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.SRFPlay.Api.Models;
/// <summary>
/// Represents a direct data response (for shows list).
/// </summary>
/// <typeparam name="T">The type of data items.</typeparam>
public class PlayV3DirectResponse<T>
{
/// <summary>
/// Gets or sets the list of data items.
/// </summary>
[JsonPropertyName("data")]
[SuppressMessage("Usage", "CA2227:Collection properties should be read only", Justification = "Required for JSON deserialization")]
[SuppressMessage("Design", "CA1002:Do not expose generic lists", Justification = "DTO for JSON deserialization")]
public List<T>? Data { get; set; }
}
@@ -0,0 +1,16 @@
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.SRFPlay.Api.Models;
/// <summary>
/// Represents a paginated response from the Play v3 API.
/// </summary>
/// <typeparam name="T">The type of data items.</typeparam>
public class PlayV3Response<T>
{
/// <summary>
/// Gets or sets the data container.
/// </summary>
[JsonPropertyName("data")]
public PlayV3DataContainer<T>? Data { get; set; }
}
@@ -0,0 +1,79 @@
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.SRFPlay.Api.Models;
/// <summary>
/// Represents a show from the Play v3 API.
/// </summary>
public class PlayV3Show
{
/// <summary>
/// Gets or sets the show ID.
/// </summary>
[JsonPropertyName("id")]
public string? Id { get; set; }
/// <summary>
/// Gets or sets the show URN.
/// </summary>
[JsonPropertyName("urn")]
public string? Urn { get; set; }
/// <summary>
/// Gets or sets the show title.
/// </summary>
[JsonPropertyName("title")]
public string? Title { get; set; }
/// <summary>
/// Gets or sets the image URL.
/// </summary>
[JsonPropertyName("imageUrl")]
public string? ImageUrl { get; set; }
/// <summary>
/// Gets or sets the transmission type (TV or Radio).
/// </summary>
[JsonPropertyName("transmission")]
public string? Transmission { get; set; }
/// <summary>
/// Gets or sets the vendor/business unit.
/// </summary>
[JsonPropertyName("vendor")]
public string? Vendor { get; set; }
/// <summary>
/// Gets or sets the lead description.
/// </summary>
[JsonPropertyName("lead")]
public string? Lead { get; set; }
/// <summary>
/// Gets or sets the full description.
/// </summary>
[JsonPropertyName("description")]
public string? Description { get; set; }
/// <summary>
/// Gets or sets the poster image URL.
/// </summary>
[JsonPropertyName("posterImageUrl")]
public string? PosterImageUrl { get; set; }
/// <summary>
/// Gets or sets the number of episodes.
/// </summary>
[JsonPropertyName("numberOfEpisodes")]
public int NumberOfEpisodes { get; set; }
/// <summary>
/// Gets or sets the list of topic IDs.
/// </summary>
[JsonPropertyName("topicList")]
[SuppressMessage("Usage", "CA2227:Collection properties should be read only", Justification = "Required for JSON deserialization")]
[SuppressMessage("Design", "CA1002:Do not expose generic lists", Justification = "DTO for JSON deserialization")]
public List<string>? TopicList { get; set; }
}
@@ -0,0 +1,33 @@
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.SRFPlay.Api.Models;
/// <summary>
/// Represents a topic/category from the Play v3 API.
/// </summary>
public class PlayV3Topic
{
/// <summary>
/// Gets or sets the topic ID (UUID).
/// </summary>
[JsonPropertyName("id")]
public string? Id { get; set; }
/// <summary>
/// Gets or sets the topic title.
/// </summary>
[JsonPropertyName("title")]
public string? Title { get; set; }
/// <summary>
/// Gets or sets the topic description/lead.
/// </summary>
[JsonPropertyName("lead")]
public string? Lead { get; set; }
/// <summary>
/// Gets or sets the transmission type (TV or Radio).
/// </summary>
[JsonPropertyName("transmission")]
public string? Transmission { get; set; }
}
@@ -0,0 +1,107 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.SRFPlay.Api.Models.PlayV3;
/// <summary>
/// TV Program entry in the guide.
/// </summary>
public class PlayV3TvProgram
{
/// <summary>
/// Gets or sets the URN identifier.
/// </summary>
[JsonPropertyName("urn")]
public string? Urn { get; set; }
/// <summary>
/// Gets or sets the title.
/// </summary>
[JsonPropertyName("title")]
public string? Title { get; set; }
/// <summary>
/// Gets or sets the lead/description.
/// </summary>
[JsonPropertyName("lead")]
public string? Lead { get; set; }
/// <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 the content type (e.g., SCHEDULED_LIVESTREAM).
/// </summary>
[JsonPropertyName("type")]
public string? Type { get; set; }
/// <summary>
/// Gets or sets the media type.
/// </summary>
[JsonPropertyName("mediaType")]
public string? MediaType { get; set; }
/// <summary>
/// Gets or sets the vendor (e.g., SRF, RTS).
/// </summary>
[JsonPropertyName("vendor")]
public string? Vendor { get; set; }
/// <summary>
/// Gets or sets the start time.
/// </summary>
[JsonPropertyName("date")]
public DateTime? Date { get; set; }
/// <summary>
/// Gets or sets the valid from time (for scheduled livestreams).
/// </summary>
[JsonPropertyName("validFrom")]
public DateTime? ValidFrom { get; set; }
/// <summary>
/// Gets or sets the valid to time (for scheduled livestreams).
/// </summary>
[JsonPropertyName("validTo")]
public DateTime? ValidTo { get; set; }
/// <summary>
/// Gets or sets the duration in milliseconds.
/// </summary>
[JsonPropertyName("duration")]
public long? Duration { get; set; }
/// <summary>
/// Gets or sets the channel ID.
/// </summary>
[JsonPropertyName("channelId")]
public string? ChannelId { get; set; }
/// <summary>
/// Gets or sets the channel title.
/// </summary>
[JsonPropertyName("channelTitle")]
public string? ChannelTitle { get; set; }
/// <summary>
/// Gets or sets whether this is blocked (DRM).
/// </summary>
[JsonPropertyName("blocked")]
public bool? Blocked { get; set; }
/// <summary>
/// Gets or sets whether this is geoblocked.
/// </summary>
[JsonPropertyName("geoblocked")]
public bool? Geoblocked { get; set; }
}
@@ -0,0 +1,16 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.SRFPlay.Api.Models.PlayV3;
/// <summary>
/// TV Program Guide response from Play v3 API.
/// </summary>
public class PlayV3TvProgramGuideResponse
{
/// <summary>
/// Gets the list of TV program entries.
/// </summary>
[JsonPropertyName("data")]
public IReadOnlyList<PlayV3TvProgram>? Data { get; init; }
}
@@ -0,0 +1,116 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.SRFPlay.Api.Models;
/// <summary>
/// Represents a video/episode from the Play v3 API.
/// </summary>
public class PlayV3Video
{
/// <summary>
/// Gets or sets the video ID.
/// </summary>
[JsonPropertyName("id")]
public string? Id { get; set; }
/// <summary>
/// Gets or sets the video URN.
/// </summary>
[JsonPropertyName("urn")]
public string? Urn { get; set; }
/// <summary>
/// Gets or sets the video title.
/// </summary>
[JsonPropertyName("title")]
public string? Title { get; set; }
/// <summary>
/// Gets or sets the episode ID.
/// </summary>
[JsonPropertyName("episodeId")]
public string? EpisodeId { get; set; }
/// <summary>
/// Gets or sets the lead description.
/// </summary>
[JsonPropertyName("lead")]
public string? Lead { get; set; }
/// <summary>
/// Gets or sets the full 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 the duration in milliseconds.
/// </summary>
[JsonPropertyName("duration")]
public long Duration { get; set; }
/// <summary>
/// Gets or sets the publication date.
/// </summary>
[JsonPropertyName("date")]
public DateTime Date { get; set; }
/// <summary>
/// Gets or sets the valid from date.
/// </summary>
[JsonPropertyName("validFrom")]
public DateTime? ValidFrom { get; set; }
/// <summary>
/// Gets or sets the valid to date (expiration).
/// </summary>
[JsonPropertyName("validTo")]
public DateTime? ValidTo { get; set; }
/// <summary>
/// Gets or sets the media type (VIDEO or AUDIO).
/// </summary>
[JsonPropertyName("mediaType")]
public string? MediaType { get; set; }
/// <summary>
/// Gets or sets a value indicating whether subtitles are available.
/// </summary>
[JsonPropertyName("subtitlesAvailable")]
public bool SubtitlesAvailable { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the content is playable abroad.
/// </summary>
[JsonPropertyName("playableAbroad")]
public bool PlayableAbroad { get; set; }
/// <summary>
/// Gets or sets the list of possible block reasons.
/// </summary>
[JsonPropertyName("possibleBlockReasons")]
[SuppressMessage("Usage", "CA2227:Collection properties should be read only", Justification = "Required for JSON deserialization")]
[SuppressMessage("Design", "CA1002:Do not expose generic lists", Justification = "DTO for JSON deserialization")]
public List<string>? PossibleBlockReasons { get; set; }
/// <summary>
/// Gets or sets the content type (EPISODE, CLIP, etc.).
/// </summary>
[JsonPropertyName("type")]
public string? Type { get; set; }
/// <summary>
/// Gets or sets the associated show information.
/// </summary>
[JsonPropertyName("show")]
public PlayV3Show? Show { get; set; }
}
@@ -0,0 +1,51 @@
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.SRFPlay.Api.Models;
/// <summary>
/// Represents a streaming resource (URL) in the SRF API response.
/// </summary>
public class Resource
{
/// <summary>
/// Gets or sets the stream URL.
/// </summary>
[JsonPropertyName("url")]
public string Url { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the streaming protocol (e.g., HLS, DASH).
/// </summary>
[JsonPropertyName("protocol")]
public string? Protocol { get; set; }
/// <summary>
/// Gets or sets the quality level (e.g., HD, SD).
/// </summary>
[JsonPropertyName("quality")]
public string? Quality { get; set; }
/// <summary>
/// Gets or sets the streaming method.
/// </summary>
[JsonPropertyName("streaming")]
public string? Streaming { get; set; }
/// <summary>
/// Gets or sets the MIME type.
/// </summary>
[JsonPropertyName("mimeType")]
public string? MimeType { get; set; }
/// <summary>
/// Gets or sets the encoding.
/// </summary>
[JsonPropertyName("encoding")]
public string? Encoding { get; set; }
/// <summary>
/// Gets or sets whether DRM is required.
/// </summary>
[JsonPropertyName("drmList")]
public object? DrmList { get; set; }
}
@@ -0,0 +1,63 @@
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.SRFPlay.Api.Models;
/// <summary>
/// Represents a show/program in the SRF API response.
/// </summary>
public class Show
{
/// <summary>
/// Gets or sets the unique identifier.
/// </summary>
[JsonPropertyName("id")]
public string Id { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the URN.
/// </summary>
[JsonPropertyName("urn")]
public string? Urn { get; set; }
/// <summary>
/// Gets or sets the title.
/// </summary>
[JsonPropertyName("title")]
public string Title { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the lead/description.
/// </summary>
[JsonPropertyName("lead")]
public string? Lead { get; set; }
/// <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 the banner image URL.
/// </summary>
[JsonPropertyName("bannerImageUrl")]
public string? BannerImageUrl { get; set; }
/// <summary>
/// Gets or sets the vendor (business unit).
/// </summary>
[JsonPropertyName("vendor")]
public string? Vendor { get; set; }
/// <summary>
/// Gets or sets the transmission type.
/// </summary>
[JsonPropertyName("transmission")]
public string? Transmission { get; set; }
}
+560
View File
@@ -0,0 +1,560 @@
using System;
using System.Globalization;
using System.Net;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Api.Models;
using Jellyfin.Plugin.SRFPlay.Api.Models.PlayV3;
using Jellyfin.Plugin.SRFPlay.Configuration;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Api;
/// <summary>
/// HTTP client for interacting with the SRF Integration Layer API.
/// </summary>
public class SRFApiClient : IDisposable
{
private const string BaseUrl = "https://il.srgssr.ch/integrationlayer/2.0";
private const string PlayV3BaseUrlTemplate = "https://www.{0}.ch/play/v3/api/{0}/production/";
private static readonly System.Text.CompositeFormat PlayV3UrlFormat = System.Text.CompositeFormat.Parse(PlayV3BaseUrlTemplate);
private readonly HttpClient _httpClient;
private readonly HttpClient _playV3HttpClient;
private readonly ILogger _logger;
private readonly JsonSerializerOptions _jsonOptions;
private readonly PluginConfiguration _configuration;
/// <summary>
/// Initializes a new instance of the <see cref="SRFApiClient"/> class.
/// </summary>
/// <param name="loggerFactory">The logger factory.</param>
public SRFApiClient(ILoggerFactory loggerFactory)
{
_logger = loggerFactory.CreateLogger<SRFApiClient>();
_configuration = Plugin.Instance?.Configuration ?? new PluginConfiguration();
// Log proxy configuration status
if (_configuration.UseProxy && !string.IsNullOrWhiteSpace(_configuration.ProxyAddress))
{
_logger.LogInformation(
"SRFApiClient initializing with proxy enabled: {ProxyAddress}",
_configuration.ProxyAddress);
}
else
{
_logger.LogInformation("SRFApiClient initializing without proxy");
}
_httpClient = CreateHttpClient(BaseUrl);
_playV3HttpClient = CreateHttpClient(null);
_jsonOptions = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
};
}
/// <summary>
/// Creates an HttpClient with optional proxy configuration.
/// </summary>
/// <param name="baseAddress">The base address for the HTTP client.</param>
/// <returns>Configured HttpClient.</returns>
[System.Diagnostics.CodeAnalysis.SuppressMessage("Security", "CA5399:HttpClient is created without enabling CheckCertificateRevocationList", Justification = "CRL check disabled for proxy compatibility")]
private HttpClient CreateHttpClient(string? baseAddress)
{
HttpClientHandler handler = new HttpClientHandler
{
CheckCertificateRevocationList = false, // Disable CRL check for proxy compatibility
AutomaticDecompression = System.Net.DecompressionMethods.All
};
// Configure proxy if enabled
if (_configuration.UseProxy && !string.IsNullOrWhiteSpace(_configuration.ProxyAddress))
{
try
{
var proxy = new WebProxy(_configuration.ProxyAddress);
// Add credentials if provided
if (!string.IsNullOrWhiteSpace(_configuration.ProxyUsername))
{
proxy.Credentials = new NetworkCredential(
_configuration.ProxyUsername,
_configuration.ProxyPassword);
}
handler.Proxy = proxy;
handler.UseProxy = true;
_logger.LogInformation(
"Proxy configured: {ProxyAddress} (Authentication: {HasAuth})",
_configuration.ProxyAddress,
!string.IsNullOrWhiteSpace(_configuration.ProxyUsername));
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to configure proxy: {ProxyAddress}", _configuration.ProxyAddress);
handler.UseProxy = false;
}
}
var client = new HttpClient(handler)
{
Timeout = TimeSpan.FromSeconds(30),
DefaultRequestVersion = HttpVersion.Version11, // Force HTTP/1.1
DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact // Ensure HTTP/1.1 is used, not upgraded
};
// Add browser headers - required by SRF API
client.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36");
client.DefaultRequestHeaders.Accept.ParseAdd("*/*");
client.DefaultRequestHeaders.AcceptLanguage.ParseAdd("en-US,en;q=0.9");
_logger.LogInformation(
"HttpClient created with HTTP/1.1 and headers - User-Agent: {UserAgent}, Accept: {Accept}, Accept-Language: {AcceptLanguage}",
client.DefaultRequestHeaders.UserAgent.ToString(),
client.DefaultRequestHeaders.Accept.ToString(),
client.DefaultRequestHeaders.AcceptLanguage.ToString());
if (!string.IsNullOrWhiteSpace(baseAddress))
{
client.BaseAddress = new Uri(baseAddress);
}
return client;
}
/// <summary>
/// Gets media composition by URN using curl as a fallback.
/// </summary>
/// <param name="urn">The URN of the content.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The media composition.</returns>
public async Task<MediaComposition?> GetMediaCompositionByUrnAsync(string urn, CancellationToken cancellationToken = default)
{
try
{
var url = $"/mediaComposition/byUrn/{urn}.json";
var fullUrl = $"{BaseUrl}{url}";
_logger.LogInformation("Fetching media composition for URN: {Urn} from {Url}", urn, fullUrl);
var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
// Log response headers to diagnose geo-blocking
var xLocation = response.Headers.Contains("x-location")
? string.Join(", ", response.Headers.GetValues("x-location"))
: "not present";
_logger.LogInformation(
"Media composition response for URN {Urn}: StatusCode={StatusCode}, x-location={XLocation}",
urn,
response.StatusCode,
xLocation);
// If HttpClient fails, try curl as fallback
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("HttpClient failed with {StatusCode}, trying curl fallback", response.StatusCode);
return await FetchWithCurlAsync(fullUrl, cancellationToken).ConfigureAwait(false);
}
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
var result = JsonSerializer.Deserialize<MediaComposition>(content, _jsonOptions);
if (result?.ChapterList != null && result.ChapterList.Count > 0)
{
_logger.LogInformation(
"Successfully fetched media composition for URN: {Urn} - Chapters: {ChapterCount}",
urn,
result.ChapterList.Count);
}
else
{
_logger.LogWarning("Media composition for URN {Urn} has no chapters", urn);
}
return result;
}
catch (HttpRequestException ex)
{
_logger.LogError(
ex,
"HTTP error fetching media composition for URN: {Urn} - StatusCode: {StatusCode}, trying curl fallback",
urn,
ex.StatusCode);
var fullUrl = $"{BaseUrl}/mediaComposition/byUrn/{urn}.json";
return await FetchWithCurlAsync(fullUrl, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching media composition for URN: {Urn}", urn);
return null;
}
}
/// <summary>
/// Fetches content using curl command as a fallback.
/// </summary>
/// <param name="url">The full URL to fetch.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The deserialized media composition.</returns>
private async Task<MediaComposition?> FetchWithCurlAsync(string url, CancellationToken cancellationToken)
{
try
{
_logger.LogInformation("Using curl to fetch: {Url}", url);
var curlArgs = $"-s -H \"User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36\"";
// Add proxy if configured
if (_configuration.UseProxy && !string.IsNullOrWhiteSpace(_configuration.ProxyAddress))
{
curlArgs += $" -x {_configuration.ProxyAddress}";
if (!string.IsNullOrWhiteSpace(_configuration.ProxyUsername))
{
curlArgs += $" -U {_configuration.ProxyUsername}:{_configuration.ProxyPassword}";
}
_logger.LogInformation("curl using proxy: {ProxyAddress}", _configuration.ProxyAddress);
}
curlArgs += $" \"{url}\"";
var processStartInfo = new System.Diagnostics.ProcessStartInfo
{
FileName = "curl",
Arguments = curlArgs,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var process = new System.Diagnostics.Process { StartInfo = processStartInfo };
process.Start();
var output = await process.StandardOutput.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
var error = await process.StandardError.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
if (process.ExitCode != 0)
{
_logger.LogError("curl failed with exit code {ExitCode}: {Error}", process.ExitCode, error);
return null;
}
if (string.IsNullOrWhiteSpace(output))
{
_logger.LogWarning("curl returned empty response");
return null;
}
_logger.LogInformation("curl succeeded, response length: {Length}", output.Length);
var result = JsonSerializer.Deserialize<MediaComposition>(output, _jsonOptions);
if (result?.ChapterList != null && result.ChapterList.Count > 0)
{
_logger.LogInformation("Successfully fetched media composition via curl - Chapters: {ChapterCount}", result.ChapterList.Count);
}
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error using curl to fetch URL: {Url}", url);
return null;
}
}
/// <summary>
/// Gets the latest videos for a business unit.
/// </summary>
/// <param name="businessUnit">The business unit (e.g., srf, rts).</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The media composition containing latest videos.</returns>
public async Task<MediaComposition?> GetLatestVideosAsync(string businessUnit, CancellationToken cancellationToken = default)
{
try
{
var url = $"/video/{businessUnit}/latest.json";
_logger.LogInformation("Fetching latest videos for business unit: {BusinessUnit} from URL: {Url}", businessUnit, url);
var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Latest videos API response: {StatusCode}", response.StatusCode);
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
_logger.LogError("API returned error {StatusCode}: {Error}", response.StatusCode, errorContent);
return null;
}
var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
_logger.LogDebug("Latest videos response length: {Length}", content.Length);
var result = JsonSerializer.Deserialize<MediaComposition>(content, _jsonOptions);
_logger.LogInformation("Successfully fetched latest videos for business unit: {BusinessUnit}", businessUnit);
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching latest videos for business unit: {BusinessUnit}", businessUnit);
return null;
}
}
/// <summary>
/// Gets the trending videos for a business unit.
/// </summary>
/// <param name="businessUnit">The business unit (e.g., srf, rts).</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The media composition containing trending videos.</returns>
public async Task<MediaComposition?> GetTrendingVideosAsync(string businessUnit, CancellationToken cancellationToken = default)
{
try
{
var url = $"/video/{businessUnit}/trending.json";
_logger.LogInformation("Fetching trending videos for business unit: {BusinessUnit} from URL: {Url}", businessUnit, url);
var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Trending videos API response: {StatusCode}", response.StatusCode);
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
_logger.LogError("API returned error {StatusCode}: {Error}", response.StatusCode, errorContent);
return null;
}
var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
_logger.LogDebug("Trending videos response length: {Length}", content.Length);
var result = JsonSerializer.Deserialize<MediaComposition>(content, _jsonOptions);
_logger.LogInformation("Successfully fetched trending videos for business unit: {BusinessUnit}", businessUnit);
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching trending videos for business unit: {BusinessUnit}", businessUnit);
return null;
}
}
/// <summary>
/// Gets raw JSON response from a URL.
/// </summary>
/// <param name="url">The relative URL.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The JSON string.</returns>
public async Task<string?> GetJsonAsync(string url, CancellationToken cancellationToken = default)
{
try
{
_logger.LogDebug("Fetching JSON from URL: {Url}", url);
var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
return content;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching JSON from URL: {Url}", url);
return null;
}
}
/// <summary>
/// Gets all shows from the Play v3 API.
/// </summary>
/// <param name="businessUnit">The business unit (e.g., srf, rts).</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>List of shows.</returns>
public async Task<System.Collections.Generic.List<PlayV3Show>?> GetAllShowsAsync(string businessUnit, CancellationToken cancellationToken = default)
{
try
{
var baseUrl = string.Format(CultureInfo.InvariantCulture, PlayV3UrlFormat, businessUnit);
var url = $"{baseUrl}shows";
_logger.LogInformation("Fetching all shows for business unit: {BusinessUnit} from URL: {Url}", businessUnit, url);
var response = await _playV3HttpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
_logger.LogError("API returned error {StatusCode}: {Error}", response.StatusCode, errorContent);
return null;
}
var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
var result = JsonSerializer.Deserialize<PlayV3DirectResponse<PlayV3Show>>(content, _jsonOptions);
_logger.LogInformation("Successfully fetched {Count} shows for business unit: {BusinessUnit}", result?.Data?.Count ?? 0, businessUnit);
return result?.Data;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching shows for business unit: {BusinessUnit}", businessUnit);
return null;
}
}
/// <summary>
/// Gets all topics from the Play v3 API.
/// </summary>
/// <param name="businessUnit">The business unit (e.g., srf, rts).</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>List of topics.</returns>
public async Task<System.Collections.Generic.List<PlayV3Topic>?> GetAllTopicsAsync(string businessUnit, CancellationToken cancellationToken = default)
{
try
{
var baseUrl = string.Format(CultureInfo.InvariantCulture, PlayV3UrlFormat, businessUnit);
var url = $"{baseUrl}topics";
_logger.LogInformation("Fetching all topics for business unit: {BusinessUnit} from URL: {Url}", businessUnit, url);
var response = await _playV3HttpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
_logger.LogError("API returned error {StatusCode}: {Error}", response.StatusCode, errorContent);
return null;
}
var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
var result = JsonSerializer.Deserialize<PlayV3DirectResponse<PlayV3Topic>>(content, _jsonOptions);
_logger.LogInformation("Successfully fetched {Count} topics for business unit: {BusinessUnit}", result?.Data?.Count ?? 0, businessUnit);
return result?.Data;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching topics for business unit: {BusinessUnit}", businessUnit);
return null;
}
}
/// <summary>
/// Gets videos for a specific show from the Play v3 API.
/// </summary>
/// <param name="businessUnit">The business unit (e.g., srf, rts).</param>
/// <param name="showId">The show ID.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>List of videos.</returns>
public async Task<System.Collections.Generic.List<PlayV3Video>?> GetVideosForShowAsync(string businessUnit, string showId, CancellationToken cancellationToken = default)
{
try
{
var baseUrl = string.Format(CultureInfo.InvariantCulture, PlayV3UrlFormat, businessUnit);
var url = $"{baseUrl}videos-by-show-id?showId={showId}";
_logger.LogDebug("Fetching videos for show {ShowId} from business unit: {BusinessUnit}", showId, businessUnit);
var response = await _playV3HttpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
_logger.LogError("API returned error {StatusCode}: {Error}", response.StatusCode, errorContent);
return null;
}
var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
var result = JsonSerializer.Deserialize<PlayV3Response<PlayV3Video>>(content, _jsonOptions);
_logger.LogDebug("Successfully fetched {Count} videos for show {ShowId}", result?.Data?.Data?.Count ?? 0, showId);
return result?.Data?.Data;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching videos for show {ShowId} from business unit: {BusinessUnit}", showId, businessUnit);
return null;
}
}
/// <summary>
/// Gets scheduled livestreams for sports or news events from the Play v3 API.
/// </summary>
/// <param name="businessUnit">The business unit (e.g., srf, rts).</param>
/// <param name="eventType">The event type (SPORT or NEWS).</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>List of scheduled livestream entries.</returns>
public async Task<System.Collections.Generic.IReadOnlyList<PlayV3TvProgram>?> GetScheduledLivestreamsAsync(
string businessUnit,
string eventType = "SPORT",
CancellationToken cancellationToken = default)
{
try
{
var baseUrl = string.Format(CultureInfo.InvariantCulture, PlayV3UrlFormat, businessUnit);
var url = $"{baseUrl}livestreams?eventType={eventType.ToUpperInvariant()}";
_logger.LogInformation("Fetching scheduled livestreams for eventType={EventType} from business unit: {BusinessUnit}", eventType, businessUnit);
var response = await _playV3HttpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
_logger.LogError("API returned error {StatusCode}: {Error}", response.StatusCode, errorContent);
return null;
}
var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
// The response structure is: { "data": { "scheduledLivestreams": [...] } }
var jsonDoc = JsonSerializer.Deserialize<JsonElement>(content, _jsonOptions);
if (jsonDoc.TryGetProperty("data", out var dataElement) &&
dataElement.TryGetProperty("scheduledLivestreams", out var livestreamsElement))
{
var livestreams = JsonSerializer.Deserialize<System.Collections.Generic.List<PlayV3TvProgram>>(
livestreamsElement.GetRawText(),
_jsonOptions);
_logger.LogInformation("Successfully fetched {Count} scheduled livestreams for eventType={EventType}", livestreams?.Count ?? 0, eventType);
return livestreams;
}
_logger.LogWarning("No scheduledLivestreams found in response");
return null;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching scheduled livestreams for eventType={EventType} from business unit: {BusinessUnit}", eventType, businessUnit);
return null;
}
}
/// <inheritdoc/>
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
/// <summary>
/// Releases the unmanaged resources and optionally releases the managed resources.
/// </summary>
/// <param name="disposing">True to release both managed and unmanaged resources; false to release only unmanaged resources.</param>
protected virtual void Dispose(bool disposing)
{
if (disposing)
{
_httpClient?.Dispose();
_playV3HttpClient?.Dispose();
}
}
}
@@ -0,0 +1,568 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Services;
using MediaBrowser.Controller.Channels;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Channels;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Channels;
/// <summary>
/// SRF Play channel for browsing and playing content.
/// </summary>
public class SRFPlayChannel : IChannel, IHasCacheKey
{
private readonly ILogger<SRFPlayChannel> _logger;
private readonly ILoggerFactory _loggerFactory;
private readonly ContentRefreshService _contentRefreshService;
private readonly StreamUrlResolver _streamResolver;
private readonly CategoryService? _categoryService;
/// <summary>
/// Initializes a new instance of the <see cref="SRFPlayChannel"/> class.
/// </summary>
/// <param name="loggerFactory">The logger factory.</param>
/// <param name="contentRefreshService">The content refresh service.</param>
/// <param name="streamResolver">The stream resolver.</param>
/// <param name="categoryService">The category service (optional).</param>
public SRFPlayChannel(
ILoggerFactory loggerFactory,
ContentRefreshService contentRefreshService,
StreamUrlResolver streamResolver,
CategoryService? categoryService = null)
{
_loggerFactory = loggerFactory;
_logger = loggerFactory.CreateLogger<SRFPlayChannel>();
_contentRefreshService = contentRefreshService;
_streamResolver = streamResolver;
_categoryService = categoryService;
if (_categoryService == null)
{
_logger.LogWarning("CategoryService not available - category folders will be disabled");
}
_logger.LogInformation("=== SRFPlayChannel constructor called! Channel is being instantiated ===");
}
/// <inheritdoc />
public string Name => "SRF Play";
/// <inheritdoc />
public string Description => "Swiss Radio and Television video-on-demand content";
/// <inheritdoc />
public string DataVersion => "1.0";
/// <inheritdoc />
public string HomePageUrl => "https://www.srf.ch/play";
/// <inheritdoc />
public ChannelParentalRating ParentalRating => ChannelParentalRating.GeneralAudience;
/// <inheritdoc />
public InternalChannelFeatures GetChannelFeatures()
{
_logger.LogInformation("=== GetChannelFeatures called for SRF Play channel ===");
return new InternalChannelFeatures
{
ContentTypes = new List<ChannelMediaContentType>
{
ChannelMediaContentType.Episode,
ChannelMediaContentType.Movie
},
MediaTypes = new List<ChannelMediaType>
{
ChannelMediaType.Video
},
SupportsSortOrderToggle = false,
DefaultSortFields = new List<ChannelItemSortField>
{
ChannelItemSortField.DateCreated,
ChannelItemSortField.Name
},
MaxPageSize = 50
};
}
/// <inheritdoc />
public Task<DynamicImageResponse> GetChannelImage(ImageType type, CancellationToken cancellationToken)
{
// Could provide a channel logo here
return Task.FromResult(new DynamicImageResponse
{
HasImage = false
});
}
/// <inheritdoc />
public IEnumerable<ImageType> GetSupportedChannelImages()
{
return new List<ImageType>
{
ImageType.Primary,
ImageType.Thumb
};
}
/// <inheritdoc />
public async Task<ChannelItemResult> GetChannelItems(InternalChannelItemQuery query, CancellationToken cancellationToken)
{
_logger.LogInformation("=== GetChannelItems called! FolderId: {FolderId} ===", query.FolderId);
var items = new List<ChannelItemInfo>();
var config = Plugin.Instance?.Configuration;
try
{
// Root level - show categories
if (string.IsNullOrEmpty(query.FolderId))
{
items.Add(new ChannelItemInfo
{
Id = "latest",
Name = "Latest Videos",
Type = ChannelItemType.Folder,
FolderType = ChannelFolderType.Container,
ImageUrl = null
});
items.Add(new ChannelItemInfo
{
Id = "trending",
Name = "Trending Videos",
Type = ChannelItemType.Folder,
FolderType = ChannelFolderType.Container,
ImageUrl = null
});
items.Add(new ChannelItemInfo
{
Id = "live_sports",
Name = "Live Sports & Events",
Type = ChannelItemType.Folder,
FolderType = ChannelFolderType.Container,
ImageUrl = null
});
// Add category folders if enabled and CategoryService is available
if (config?.EnableCategoryFolders == true && _categoryService != null)
{
try
{
var businessUnit = config.BusinessUnit.ToString().ToLowerInvariant();
var topics = await _categoryService.GetTopicsAsync(businessUnit, cancellationToken).ConfigureAwait(false);
foreach (var topic in topics.Where(t => !string.IsNullOrEmpty(t.Id)))
{
// Filter by enabled topics if configured
if (config.EnabledTopics != null && config.EnabledTopics.Count > 0 && !config.EnabledTopics.Contains(topic.Id!))
{
continue;
}
items.Add(new ChannelItemInfo
{
Id = $"category_{topic.Id}",
Name = topic.Title ?? topic.Id!,
Type = ChannelItemType.Folder,
FolderType = ChannelFolderType.Container,
ImageUrl = null,
Overview = topic.Lead
});
}
_logger.LogInformation("Added {Count} category folders", topics.Count);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load category folders - continuing without categories");
}
}
return new ChannelItemResult
{
Items = items,
TotalRecordCount = items.Count
};
}
// Latest videos
if (query.FolderId == "latest")
{
var urns = await _contentRefreshService.RefreshLatestContentAsync(cancellationToken).ConfigureAwait(false);
items = await ConvertUrnsToChannelItems(urns, cancellationToken).ConfigureAwait(false);
}
// Trending videos
else if (query.FolderId == "trending")
{
var urns = await _contentRefreshService.RefreshTrendingContentAsync(cancellationToken).ConfigureAwait(false);
items = await ConvertUrnsToChannelItems(urns, cancellationToken).ConfigureAwait(false);
}
// Live Sports & Events
else if (query.FolderId == "live_sports")
{
try
{
var businessUnit = config?.BusinessUnit.ToString().ToLowerInvariant() ?? "srf";
using var apiClient = new Api.SRFApiClient(_loggerFactory);
var scheduledLivestreams = await apiClient.GetScheduledLivestreamsAsync(businessUnit, "SPORT", cancellationToken).ConfigureAwait(false);
if (scheduledLivestreams != null)
{
// Filter for upcoming/current events (within next 7 days) that have URNs
var now = DateTime.UtcNow;
var weekFromNow = now.AddDays(7);
var upcomingEvents = scheduledLivestreams
.Where(p => p.Urn != null &&
!string.IsNullOrEmpty(p.Title) &&
p.ValidFrom != null &&
p.ValidFrom.Value.ToUniversalTime() <= weekFromNow)
.OrderBy(p => p.ValidFrom)
.ToList();
_logger.LogInformation("Found {Count} scheduled live sports events", upcomingEvents.Count);
var urns = upcomingEvents.Select(e => e.Urn!).ToList();
items = await ConvertUrnsToChannelItems(urns, cancellationToken).ConfigureAwait(false);
// Enhance items with scheduled time information
foreach (var item in items)
{
var matchingEvent = upcomingEvents.FirstOrDefault(e => item.ProviderIds.ContainsKey("SRF") && item.ProviderIds["SRF"] == e.Urn);
if (matchingEvent?.ValidFrom != null)
{
var eventTime = matchingEvent.ValidFrom.Value;
item.Name = $"[{eventTime:dd.MM HH:mm}] {matchingEvent.Title}";
item.PremiereDate = eventTime;
}
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load live sports events");
}
}
// Category folder - show videos for this category
else if (query.FolderId?.StartsWith("category_", StringComparison.Ordinal) == true)
{
if (_categoryService == null)
{
_logger.LogWarning("CategoryService not available - cannot display category folder");
}
else
{
try
{
var topicId = query.FolderId.Substring("category_".Length);
var businessUnit = config?.BusinessUnit.ToString().ToLowerInvariant() ?? "srf";
var shows = await _categoryService.GetShowsByTopicAsync(topicId, businessUnit, 20, cancellationToken).ConfigureAwait(false);
var urns = new List<string>();
using var apiClient = new Api.SRFApiClient(_loggerFactory);
foreach (var show in shows)
{
if (show.Id == null)
{
continue;
}
try
{
var videos = await apiClient.GetVideosForShowAsync(businessUnit, show.Id, cancellationToken).ConfigureAwait(false);
if (videos != null && videos.Count > 0)
{
_logger.LogDebug("Category {TopicId}, Show {Show} ({ShowId}): Found {Count} videos", topicId, show.Title, show.Id, videos.Count);
// Filter to videos that are actually published and not expired
var now = DateTime.UtcNow;
var availableVideos = videos.Where(v =>
(v.ValidFrom == null || v.ValidFrom.Value.ToUniversalTime() <= now) &&
(v.ValidTo == null || v.ValidTo.Value.ToUniversalTime() > now)).ToList();
_logger.LogDebug("Category {TopicId}, Show {Show}: {AvailableCount} available out of {TotalCount} videos", topicId, show.Title, availableVideos.Count, videos.Count);
if (availableVideos.Count > 0)
{
// Get most recent available video from this show
var latestVideo = availableVideos.OrderByDescending(v => v.Date).FirstOrDefault();
if (latestVideo?.Urn != null)
{
urns.Add(latestVideo.Urn);
_logger.LogInformation(
"Category {TopicId}: Added video from show {Show}: {Title} (URN: {Urn}, Date: {Date}, ValidFrom: {ValidFrom}, ValidTo: {ValidTo})",
topicId,
show.Title,
latestVideo.Title,
latestVideo.Urn,
latestVideo.Date,
latestVideo.ValidFrom,
latestVideo.ValidTo);
}
else
{
_logger.LogWarning("Category {TopicId}, Show {Show}: Latest available video has null URN", topicId, show.Title);
}
}
else
{
_logger.LogDebug("Category {TopicId}, Show {Show}: No available videos (all expired or not yet published)", topicId, show.Title);
}
}
else
{
_logger.LogDebug("Category {TopicId}, Show {Show} ({ShowId}): No videos returned from API", topicId, show.Title, show.Id);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error fetching videos for show {ShowId} in category {TopicId}", show.Id, topicId);
}
if (cancellationToken.IsCancellationRequested)
{
break;
}
}
items = await ConvertUrnsToChannelItems(urns, cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Found {Count} videos for category {TopicId}", items.Count, topicId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load category videos");
}
}
}
_logger.LogInformation("Returning {Count} channel items for folder {FolderId}", items.Count, query.FolderId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting channel items for folder {FolderId}", query.FolderId);
}
return new ChannelItemResult
{
Items = items,
TotalRecordCount = items.Count
};
}
/// <inheritdoc />
public string? GetCacheKey(string? userId)
{
var config = Plugin.Instance?.Configuration;
var enabledTopics = config?.EnabledTopics != null && config.EnabledTopics.Count > 0
? string.Join(",", config.EnabledTopics)
: "all";
var date = DateTime.Now.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
return $"{config?.BusinessUnit}_{config?.EnableLatestContent}_{config?.EnableTrendingContent}_{config?.EnableCategoryFolders}_{enabledTopics}_{date}";
}
private async Task<List<ChannelItemInfo>> ConvertUrnsToChannelItems(List<string> urns, CancellationToken cancellationToken)
{
var items = new List<ChannelItemInfo>();
var config = Plugin.Instance?.Configuration;
if (config == null)
{
_logger.LogWarning("Plugin configuration is null");
return items;
}
_logger.LogInformation("Converting {Count} URNs to channel items", urns.Count);
using var apiClient = new Api.SRFApiClient(_loggerFactory);
int successCount = 0;
int failedCount = 0;
int expiredCount = 0;
int noStreamCount = 0;
foreach (var urn in urns.Take(50)) // Limit to 50 items per request
{
try
{
_logger.LogDebug("Processing URN: {Urn}", urn);
var mediaComposition = await apiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).ConfigureAwait(false);
if (mediaComposition?.ChapterList == null || mediaComposition.ChapterList.Count == 0)
{
_logger.LogWarning("URN {Urn}: No media composition or chapters found", urn);
failedCount++;
continue;
}
var chapter = mediaComposition.ChapterList[0];
// Check if content is expired
if (_streamResolver.IsContentExpired(chapter))
{
_logger.LogDebug("URN {Urn}: Content expired (ValidTo: {ValidTo})", urn, chapter.ValidTo);
expiredCount++;
continue;
}
// Check if content has playable streams
if (!_streamResolver.HasPlayableContent(chapter))
{
_logger.LogWarning("URN {Urn}: No playable content (likely DRM protected)", urn);
noStreamCount++;
continue;
}
// Generate deterministic GUID from URN
var itemId = UrnToGuid(urn);
// Get stream URL and authenticate it
var streamUrl = _streamResolver.GetStreamUrl(chapter, config.QualityPreference);
// For scheduled livestreams that haven't started, streamUrl might be null
var isUpcomingLivestream = chapter.Type == "SCHEDULED_LIVESTREAM" && string.IsNullOrEmpty(streamUrl);
if (!string.IsNullOrEmpty(streamUrl))
{
// Authenticate the stream URL (required for all SRF streams, especially livestreams)
streamUrl = await _streamResolver.GetAuthenticatedStreamUrlAsync(streamUrl, cancellationToken).ConfigureAwait(false);
}
else if (isUpcomingLivestream)
{
// Use a placeholder for upcoming events
streamUrl = "http://placeholder.local/upcoming.m3u8";
_logger.LogInformation(
"URN {Urn}: Upcoming livestream '{Title}' - stream will be available at {ValidFrom}",
urn,
chapter.Title,
chapter.ValidFrom);
}
// Build overview with event time for upcoming livestreams
var overview = chapter.Description ?? chapter.Lead;
if (isUpcomingLivestream && chapter.ValidFrom != null)
{
var eventStart = chapter.ValidFrom.Value.ToString("dd.MM.yyyy HH:mm", CultureInfo.InvariantCulture);
overview = $"[Upcoming Event - Starts at {eventStart}]\n\n{overview}";
}
// Get image URL - prefer chapter image, fall back to show image if available
var imageUrl = chapter.ImageUrl;
if (string.IsNullOrEmpty(imageUrl) && mediaComposition.Show != null)
{
imageUrl = mediaComposition.Show.ImageUrl;
_logger.LogDebug("URN {Urn}: Using show image as fallback", urn);
}
if (string.IsNullOrEmpty(imageUrl))
{
_logger.LogWarning("URN {Urn}: No image URL available for '{Title}'", urn, chapter.Title);
}
// Use ValidFrom for premiere date if this is an upcoming livestream, otherwise use Date
var premiereDate = isUpcomingLivestream ? chapter.ValidFrom?.ToUniversalTime() : chapter.Date?.ToUniversalTime();
var item = new ChannelItemInfo
{
Id = itemId,
Name = chapter.Title,
Overview = overview,
ImageUrl = imageUrl,
Type = ChannelItemType.Media,
ContentType = ChannelMediaContentType.Episode,
MediaType = ChannelMediaType.Video,
DateCreated = chapter.Date?.ToUniversalTime(),
PremiereDate = premiereDate,
ProductionYear = chapter.Date?.Year,
RunTimeTicks = chapter.Duration > 0 ? TimeSpan.FromMilliseconds(chapter.Duration).Ticks : null,
ProviderIds = new Dictionary<string, string>
{
{ "SRF", urn }
},
MediaSources = new List<MediaSourceInfo>
{
new MediaSourceInfo
{
Id = itemId,
Name = chapter.Title,
Path = streamUrl,
Protocol = MediaBrowser.Model.MediaInfo.MediaProtocol.Http,
Container = "m3u8",
SupportsDirectStream = true,
SupportsDirectPlay = true,
SupportsTranscoding = true,
IsRemote = true,
Type = MediaBrowser.Model.Dto.MediaSourceType.Default,
VideoType = VideoType.VideoFile
}
}
};
// Add series info if available
if (mediaComposition.Show != null)
{
item.SeriesName = mediaComposition.Show.Title;
}
items.Add(item);
successCount++;
_logger.LogInformation("URN {Urn}: Successfully converted to channel item - {Title}", urn, chapter.Title);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error converting URN {Urn} to channel item", urn);
failedCount++;
}
}
_logger.LogInformation(
"Conversion complete: {Success} successful, {Failed} failed, {Expired} expired, {NoStream} no stream",
successCount,
failedCount,
expiredCount,
noStreamCount);
return items;
}
/// <summary>
/// Generates a deterministic GUID from a URN.
/// This ensures the same URN always produces the same GUID.
/// MD5 is used for non-cryptographic purposes only (generating IDs).
/// </summary>
/// <param name="urn">The URN to convert.</param>
/// <returns>A deterministic GUID.</returns>
#pragma warning disable CA5351 // MD5 is used for non-cryptographic purposes (ID generation)
private static string UrnToGuid(string urn)
{
// Use MD5 to generate a deterministic hash from the URN
var hash = MD5.HashData(Encoding.UTF8.GetBytes(urn));
// Convert the first 16 bytes to a GUID
var guid = new Guid(hash);
return guid.ToString();
}
#pragma warning restore CA5351
/// <inheritdoc />
public bool IsEnabledFor(string userId)
{
return true;
}
}
@@ -0,0 +1,145 @@
using MediaBrowser.Model.Plugins;
namespace Jellyfin.Plugin.SRFPlay.Configuration;
/// <summary>
/// Business unit options for SRF content.
/// </summary>
public enum BusinessUnit
{
/// <summary>
/// SRF (Swiss Radio and Television - German).
/// </summary>
SRF,
/// <summary>
/// RTS (Radio Télévision Suisse - French).
/// </summary>
RTS,
/// <summary>
/// RSI (Radiotelevisione svizzera - Italian).
/// </summary>
RSI,
/// <summary>
/// RTR (Radiotelevisiun Svizra Rumantscha - Romansh).
/// </summary>
RTR,
/// <summary>
/// SWI (Swiss World International).
/// </summary>
SWI
}
/// <summary>
/// Quality preference for video streams.
/// </summary>
public enum QualityPreference
{
/// <summary>
/// Automatic quality selection.
/// </summary>
Auto,
/// <summary>
/// Standard definition.
/// </summary>
SD,
/// <summary>
/// High definition.
/// </summary>
HD
}
/// <summary>
/// Plugin configuration.
/// </summary>
public class PluginConfiguration : BasePluginConfiguration
{
/// <summary>
/// Initializes a new instance of the <see cref="PluginConfiguration"/> class.
/// </summary>
public PluginConfiguration()
{
// Set default options
BusinessUnit = BusinessUnit.SRF;
QualityPreference = QualityPreference.Auto;
ContentRefreshIntervalHours = 6;
ExpirationCheckIntervalHours = 24;
CacheDurationMinutes = 60;
EnableLatestContent = true;
EnableTrendingContent = true;
EnableCategoryFolders = true;
EnabledTopics = new System.Collections.Generic.List<string>();
}
/// <summary>
/// Gets or sets the business unit to fetch content from.
/// </summary>
public BusinessUnit BusinessUnit { get; set; }
/// <summary>
/// Gets or sets the preferred video quality.
/// </summary>
public QualityPreference QualityPreference { get; set; }
/// <summary>
/// Gets or sets the content refresh interval in hours.
/// </summary>
public int ContentRefreshIntervalHours { get; set; }
/// <summary>
/// Gets or sets the expiration check interval in hours.
/// </summary>
public int ExpirationCheckIntervalHours { get; set; }
/// <summary>
/// Gets or sets the metadata cache duration in minutes.
/// </summary>
public int CacheDurationMinutes { get; set; }
/// <summary>
/// Gets or sets a value indicating whether to enable latest content discovery.
/// </summary>
public bool EnableLatestContent { get; set; }
/// <summary>
/// Gets or sets a value indicating whether to enable trending content discovery.
/// </summary>
public bool EnableTrendingContent { get; set; }
/// <summary>
/// Gets or sets a value indicating whether to use a proxy for API requests.
/// </summary>
public bool UseProxy { get; set; }
/// <summary>
/// Gets or sets the proxy server address (e.g., http://proxy.example.com:8080).
/// </summary>
public string ProxyAddress { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the proxy username (optional).
/// </summary>
public string ProxyUsername { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the proxy password (optional).
/// </summary>
public string ProxyPassword { get; set; } = string.Empty;
/// <summary>
/// Gets or sets a value indicating whether to enable category/topic folders in the channel.
/// </summary>
public bool EnableCategoryFolders { get; set; }
/// <summary>
/// Gets or sets the list of enabled topic IDs. If empty, all topics are shown.
/// </summary>
[System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2227:Collection properties should be read only", Justification = "Required for configuration serialization")]
[System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1002:Do not expose generic lists", Justification = "Configuration DTO")]
public System.Collections.Generic.List<string> EnabledTopics { get; set; }
}
@@ -0,0 +1,143 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>SRF Play</title>
</head>
<body>
<div id="SRFPlayConfigPage" data-role="page" class="page type-interior pluginConfigurationPage" data-require="emby-input,emby-button,emby-select,emby-checkbox">
<div data-role="content">
<div class="content-primary">
<form id="SRFPlayConfigForm">
<div class="selectContainer">
<label class="selectLabel" for="BusinessUnit">Business Unit</label>
<select is="emby-select" id="BusinessUnit" name="BusinessUnit" class="emby-select-withcolor emby-select">
<option id="optSRF" value="SRF">SRF (German)</option>
<option id="optRTS" value="RTS">RTS (French)</option>
<option id="optRSI" value="RSI">RSI (Italian)</option>
<option id="optRTR" value="RTR">RTR (Romansh)</option>
<option id="optSWI" value="SWI">SWI (International)</option>
</select>
<div class="fieldDescription">Select the Swiss broadcasting unit to fetch content from</div>
</div>
<div class="selectContainer">
<label class="selectLabel" for="QualityPreference">Quality Preference</label>
<select is="emby-select" id="QualityPreference" name="QualityPreference" class="emby-select-withcolor emby-select">
<option id="optAuto" value="Auto">Automatic</option>
<option id="optSD" value="SD">Standard Definition</option>
<option id="optHD" value="HD">High Definition</option>
</select>
<div class="fieldDescription">Preferred video quality for playback</div>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="ContentRefreshIntervalHours">Content Refresh Interval (hours)</label>
<input id="ContentRefreshIntervalHours" name="ContentRefreshIntervalHours" type="number" is="emby-input" min="1" max="168" />
<div class="fieldDescription">How often to check for new content (1-168 hours)</div>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="ExpirationCheckIntervalHours">Expiration Check Interval (hours)</label>
<input id="ExpirationCheckIntervalHours" name="ExpirationCheckIntervalHours" type="number" is="emby-input" min="1" max="168" />
<div class="fieldDescription">How often to check for expired content (1-168 hours)</div>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="CacheDurationMinutes">Cache Duration (minutes)</label>
<input id="CacheDurationMinutes" name="CacheDurationMinutes" type="number" is="emby-input" min="5" max="1440" />
<div class="fieldDescription">How long to cache metadata (5-1440 minutes)</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<input id="EnableLatestContent" name="EnableLatestContent" type="checkbox" is="emby-checkbox" />
<span>Enable Latest Content</span>
</label>
<div class="fieldDescription">Automatically discover and add latest videos</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<input id="EnableTrendingContent" name="EnableTrendingContent" type="checkbox" is="emby-checkbox" />
<span>Enable Trending Content</span>
</label>
<div class="fieldDescription">Automatically discover and add trending videos</div>
</div>
<br />
<h2>Proxy Settings</h2>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<input id="UseProxy" name="UseProxy" type="checkbox" is="emby-checkbox" />
<span>Use Proxy</span>
</label>
<div class="fieldDescription">Route all SRF API requests through a proxy server</div>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="ProxyAddress">Proxy Address</label>
<input id="ProxyAddress" name="ProxyAddress" type="text" is="emby-input" />
<div class="fieldDescription">Proxy server address (e.g., http://proxy.example.com:8080 or socks5://proxy.example.com:1080)</div>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="ProxyUsername">Proxy Username (Optional)</label>
<input id="ProxyUsername" name="ProxyUsername" type="text" is="emby-input" autocomplete="off" />
<div class="fieldDescription">Username for proxy authentication (leave empty if not required)</div>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="ProxyPassword">Proxy Password (Optional)</label>
<input id="ProxyPassword" name="ProxyPassword" type="password" is="emby-input" autocomplete="off" />
<div class="fieldDescription">Password for proxy authentication (leave empty if not required)</div>
</div>
<div>
<button is="emby-button" type="submit" class="raised button-submit block emby-button">
<span>Save</span>
</button>
</div>
</form>
</div>
</div>
<script type="text/javascript">
var SRFPlayConfig = {
pluginUniqueId: 'a4b12f86-8c3d-4e9a-b7f2-1d5e6c8a9b4f'
};
document.querySelector('#SRFPlayConfigPage')
.addEventListener('pageshow', function() {
Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(SRFPlayConfig.pluginUniqueId).then(function (config) {
document.querySelector('#BusinessUnit').value = config.BusinessUnit;
document.querySelector('#QualityPreference').value = config.QualityPreference;
document.querySelector('#ContentRefreshIntervalHours').value = config.ContentRefreshIntervalHours;
document.querySelector('#ExpirationCheckIntervalHours').value = config.ExpirationCheckIntervalHours;
document.querySelector('#CacheDurationMinutes').value = config.CacheDurationMinutes;
document.querySelector('#EnableLatestContent').checked = config.EnableLatestContent;
document.querySelector('#EnableTrendingContent').checked = config.EnableTrendingContent;
document.querySelector('#UseProxy').checked = config.UseProxy || false;
document.querySelector('#ProxyAddress').value = config.ProxyAddress || '';
document.querySelector('#ProxyUsername').value = config.ProxyUsername || '';
document.querySelector('#ProxyPassword').value = config.ProxyPassword || '';
Dashboard.hideLoadingMsg();
});
});
document.querySelector('#SRFPlayConfigForm')
.addEventListener('submit', function(e) {
Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(SRFPlayConfig.pluginUniqueId).then(function (config) {
config.BusinessUnit = document.querySelector('#BusinessUnit').value;
config.QualityPreference = document.querySelector('#QualityPreference').value;
config.ContentRefreshIntervalHours = parseInt(document.querySelector('#ContentRefreshIntervalHours').value);
config.ExpirationCheckIntervalHours = parseInt(document.querySelector('#ExpirationCheckIntervalHours').value);
config.CacheDurationMinutes = parseInt(document.querySelector('#CacheDurationMinutes').value);
config.EnableLatestContent = document.querySelector('#EnableLatestContent').checked;
config.EnableTrendingContent = document.querySelector('#EnableTrendingContent').checked;
config.UseProxy = document.querySelector('#UseProxy').checked;
config.ProxyAddress = document.querySelector('#ProxyAddress').value;
config.ProxyUsername = document.querySelector('#ProxyUsername').value;
config.ProxyPassword = document.querySelector('#ProxyPassword').value;
ApiClient.updatePluginConfiguration(SRFPlayConfig.pluginUniqueId, config).then(function (result) {
Dashboard.processPluginConfigurationUpdateResult(result);
});
});
e.preventDefault();
return false;
});
</script>
</div>
</body>
</html>
@@ -1,8 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<RootNamespace>Jellyfin.Plugin.Template</RootNamespace>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>Jellyfin.Plugin.SRFPlay</RootNamespace>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<Nullable>enable</Nullable>
@@ -11,13 +11,14 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Jellyfin.Controller" Version="10.8.13" />
<PackageReference Include="Jellyfin.Model" Version="10.8.13" />
<PackageReference Include="Jellyfin.Controller" Version="10.9.11" />
<PackageReference Include="Jellyfin.Model" Version="10.9.11" />
<PackageReference Include="Socks5" Version="1.1.0" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" />
<PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.507" PrivateAssets="All" />
<PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.556" PrivateAssets="All" />
<PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" />
</ItemGroup>
@@ -1,13 +1,13 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using Jellyfin.Plugin.Template.Configuration;
using Jellyfin.Plugin.SRFPlay.Configuration;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Plugins;
using MediaBrowser.Model.Plugins;
using MediaBrowser.Model.Serialization;
namespace Jellyfin.Plugin.Template;
namespace Jellyfin.Plugin.SRFPlay;
/// <summary>
/// The main plugin.
@@ -26,10 +26,10 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
}
/// <inheritdoc />
public override string Name => "Template";
public override string Name => "SRF Play";
/// <inheritdoc />
public override Guid Id => Guid.Parse("eb5d7894-8eef-4b36-aa6f-5d124e828ce1");
public override Guid Id => Guid.Parse("a4b12f86-8c3d-4e9a-b7f2-1d5e6c8a9b4f");
/// <summary>
/// Gets the current plugin instance.
@@ -39,13 +39,13 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
/// <inheritdoc />
public IEnumerable<PluginPageInfo> GetPages()
{
return new[]
{
return
[
new PluginPageInfo
{
Name = this.Name,
Name = Name,
EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.Configuration.configPage.html", GetType().Namespace)
}
};
];
}
}
@@ -0,0 +1,212 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Api;
using Jellyfin.Plugin.SRFPlay.Services;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Providers;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Providers;
/// <summary>
/// Provides metadata for SRF Play episodes.
/// </summary>
public class SRFEpisodeProvider : IRemoteMetadataProvider<Episode, EpisodeInfo>
{
private readonly ILogger<SRFEpisodeProvider> _logger;
private readonly ILoggerFactory _loggerFactory;
private readonly IHttpClientFactory _httpClientFactory;
private readonly MetadataCache _metadataCache;
/// <summary>
/// Initializes a new instance of the <see cref="SRFEpisodeProvider"/> class.
/// </summary>
/// <param name="loggerFactory">The logger factory.</param>
/// <param name="httpClientFactory">The HTTP client factory.</param>
/// <param name="metadataCache">The metadata cache.</param>
public SRFEpisodeProvider(
ILoggerFactory loggerFactory,
IHttpClientFactory httpClientFactory,
MetadataCache metadataCache)
{
_loggerFactory = loggerFactory;
_logger = loggerFactory.CreateLogger<SRFEpisodeProvider>();
_httpClientFactory = httpClientFactory;
_metadataCache = metadataCache;
}
/// <inheritdoc />
public string Name => "SRF Play";
/// <inheritdoc />
public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(EpisodeInfo searchInfo, CancellationToken cancellationToken)
{
var results = new List<RemoteSearchResult>();
try
{
// Check if we have a URN to search with
if (searchInfo.ProviderIds.TryGetValue("SRF", out var urn) && !string.IsNullOrEmpty(urn))
{
_logger.LogDebug("Searching for episode with URN: {Urn}", urn);
var config = Plugin.Instance?.Configuration;
if (config == null)
{
return results;
}
// Try cache first
var mediaComposition = _metadataCache.GetMediaComposition(urn, config.CacheDurationMinutes);
// If not in cache, fetch from API
if (mediaComposition == null)
{
using var apiClient = new SRFApiClient(_loggerFactory);
mediaComposition = await apiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).ConfigureAwait(false);
if (mediaComposition != null)
{
_metadataCache.SetMediaComposition(urn, mediaComposition);
}
}
if (mediaComposition?.ChapterList != null && mediaComposition.ChapterList.Count > 0)
{
var chapter = mediaComposition.ChapterList[0];
results.Add(new RemoteSearchResult
{
Name = chapter.Title,
Overview = chapter.Description ?? chapter.Lead,
ImageUrl = chapter.ImageUrl,
SearchProviderName = Name,
ProviderIds = new Dictionary<string, string>
{
{ "SRF", chapter.Urn }
}
});
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error searching for episode: {Name}", searchInfo.Name);
}
return results;
}
/// <inheritdoc />
public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, CancellationToken cancellationToken)
{
var result = new MetadataResult<Episode>();
try
{
// Check if we have a URN
if (!info.ProviderIds.TryGetValue("SRF", out var urn) || string.IsNullOrEmpty(urn))
{
_logger.LogDebug("No SRF URN found for episode: {Name}", info.Name);
return result;
}
_logger.LogDebug("Fetching metadata for episode URN: {Urn}", urn);
var config = Plugin.Instance?.Configuration;
if (config == null)
{
return result;
}
// Try cache first
var mediaComposition = _metadataCache.GetMediaComposition(urn, config.CacheDurationMinutes);
// If not in cache, fetch from API
if (mediaComposition == null)
{
using var apiClient = new SRFApiClient(_loggerFactory);
mediaComposition = await apiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).ConfigureAwait(false);
if (mediaComposition != null)
{
_metadataCache.SetMediaComposition(urn, mediaComposition);
}
}
if (mediaComposition?.ChapterList == null || mediaComposition.ChapterList.Count == 0)
{
_logger.LogWarning("No chapter information found for URN: {Urn}", urn);
return result;
}
// Get the first chapter (main video)
var chapter = mediaComposition.ChapterList[0];
result.Item = new Episode
{
Name = chapter.Title,
Overview = chapter.Description ?? chapter.Lead,
ProviderIds = new Dictionary<string, string>
{
{ "SRF", chapter.Urn }
}
};
// Set episode and season numbers if available
if (chapter.EpisodeNumber.HasValue)
{
result.Item.IndexNumber = chapter.EpisodeNumber;
}
if (chapter.SeasonNumber.HasValue)
{
result.Item.ParentIndexNumber = chapter.SeasonNumber;
}
// Set premiere date if available
if (chapter.Date.HasValue)
{
result.Item.PremiereDate = chapter.Date;
}
// Set runtime (convert from milliseconds to ticks)
if (chapter.Duration > 0)
{
result.Item.RunTimeTicks = TimeSpan.FromMilliseconds(chapter.Duration).Ticks;
}
// Set series information if available
if (mediaComposition.Show != null)
{
result.Item.SeriesName = mediaComposition.Show.Title;
// Set series provider ID on the episode
if (!string.IsNullOrEmpty(mediaComposition.Show.Urn))
{
result.Item.SetProviderId("SRF_Series", mediaComposition.Show.Urn);
}
}
result.HasMetadata = true;
_logger.LogDebug("Successfully fetched metadata for episode: {Title}", chapter.Title);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching metadata for episode: {Name}", info.Name);
}
return result;
}
/// <inheritdoc />
public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken)
{
throw new NotImplementedException("Image handling is done by SRFImageProvider");
}
}
@@ -0,0 +1,159 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Api;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Providers;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Providers;
/// <summary>
/// Provides images for SRF Play content.
/// </summary>
public class SRFImageProvider : IRemoteImageProvider, IHasOrder
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<SRFImageProvider> _logger;
private readonly ILoggerFactory _loggerFactory;
/// <summary>
/// Initializes a new instance of the <see cref="SRFImageProvider"/> class.
/// </summary>
/// <param name="httpClientFactory">The HTTP client factory.</param>
/// <param name="loggerFactory">The logger factory.</param>
public SRFImageProvider(IHttpClientFactory httpClientFactory, ILoggerFactory loggerFactory)
{
_httpClientFactory = httpClientFactory;
_loggerFactory = loggerFactory;
_logger = loggerFactory.CreateLogger<SRFImageProvider>();
}
/// <inheritdoc />
public string Name => "SRF Play";
/// <inheritdoc />
public int Order => 0;
/// <inheritdoc />
public bool Supports(BaseItem item)
{
// Support movies and episodes for now
return item is MediaBrowser.Controller.Entities.Movies.Movie ||
item is MediaBrowser.Controller.Entities.TV.Episode ||
item is MediaBrowser.Controller.Entities.TV.Series;
}
/// <inheritdoc />
public IEnumerable<ImageType> GetSupportedImages(BaseItem item)
{
return new List<ImageType>
{
ImageType.Primary,
ImageType.Backdrop,
ImageType.Thumb
};
}
/// <inheritdoc />
public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken)
{
var list = new List<RemoteImageInfo>();
try
{
// Check if item has SRF URN in provider IDs
if (!item.ProviderIds.TryGetValue("SRF", out var urn) || string.IsNullOrEmpty(urn))
{
_logger.LogDebug("No SRF URN found for item: {ItemName}", item.Name);
return list;
}
_logger.LogDebug("Fetching images for SRF URN: {Urn}", urn);
// Fetch media composition to get image URLs
using var apiClient = new SRFApiClient(_loggerFactory);
var mediaComposition = await apiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).ConfigureAwait(false);
if (mediaComposition == null)
{
_logger.LogWarning("Failed to fetch media composition for URN: {Urn}", urn);
return list;
}
// Extract images from chapters
if (mediaComposition.ChapterList != null && mediaComposition.ChapterList.Count > 0)
{
var chapter = mediaComposition.ChapterList[0];
if (!string.IsNullOrEmpty(chapter.ImageUrl))
{
_logger.LogDebug("URN {Urn}: Adding chapter image: {ImageUrl}", urn, chapter.ImageUrl);
list.Add(new RemoteImageInfo
{
Url = chapter.ImageUrl,
Type = ImageType.Primary,
ProviderName = Name
});
list.Add(new RemoteImageInfo
{
Url = chapter.ImageUrl,
Type = ImageType.Thumb,
ProviderName = Name
});
}
else
{
_logger.LogDebug("URN {Urn}: No chapter image available", urn);
}
}
// Extract images from show
if (mediaComposition.Show != null)
{
if (!string.IsNullOrEmpty(mediaComposition.Show.ImageUrl))
{
_logger.LogDebug("URN {Urn}: Adding show image: {ImageUrl}", urn, mediaComposition.Show.ImageUrl);
list.Add(new RemoteImageInfo
{
Url = mediaComposition.Show.ImageUrl,
Type = ImageType.Primary,
ProviderName = Name
});
}
if (!string.IsNullOrEmpty(mediaComposition.Show.BannerImageUrl))
{
_logger.LogDebug("URN {Urn}: Adding show banner: {BannerUrl}", urn, mediaComposition.Show.BannerImageUrl);
list.Add(new RemoteImageInfo
{
Url = mediaComposition.Show.BannerImageUrl,
Type = ImageType.Backdrop,
ProviderName = Name
});
}
}
_logger.LogInformation("URN {Urn}: Found {Count} images for '{ItemName}'", urn, list.Count, item.Name);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching images for item: {ItemName}", item.Name);
}
return list;
}
/// <inheritdoc />
public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken)
{
var httpClient = _httpClientFactory.CreateClient(NamedClient.Default);
return httpClient.GetAsync(new Uri(url), cancellationToken);
}
}
@@ -0,0 +1,250 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Api;
using Jellyfin.Plugin.SRFPlay.Services;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.MediaInfo;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Providers;
/// <summary>
/// Provides media sources (playback URLs) for SRF Play content.
/// </summary>
public class SRFMediaProvider : IMediaSourceProvider
{
private readonly ILogger<SRFMediaProvider> _logger;
private readonly ILoggerFactory _loggerFactory;
private readonly MetadataCache _metadataCache;
private readonly StreamUrlResolver _streamResolver;
/// <summary>
/// Initializes a new instance of the <see cref="SRFMediaProvider"/> class.
/// </summary>
/// <param name="loggerFactory">The logger factory.</param>
/// <param name="metadataCache">The metadata cache.</param>
/// <param name="streamResolver">The stream URL resolver.</param>
public SRFMediaProvider(
ILoggerFactory loggerFactory,
MetadataCache metadataCache,
StreamUrlResolver streamResolver)
{
_loggerFactory = loggerFactory;
_logger = loggerFactory.CreateLogger<SRFMediaProvider>();
_metadataCache = metadataCache;
_streamResolver = streamResolver;
}
/// <summary>
/// Gets the provider name.
/// </summary>
public string Name => "SRF Play";
/// <summary>
/// Gets media sources for the specified item.
/// </summary>
/// <param name="item">The item.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>List of media sources.</returns>
public Task<IEnumerable<MediaSourceInfo>> GetMediaSources(BaseItem item, CancellationToken cancellationToken)
{
var sources = new List<MediaSourceInfo>();
try
{
// Check if this is an SRF item
if (!item.ProviderIds.TryGetValue("SRF", out var urn) || string.IsNullOrEmpty(urn))
{
return Task.FromResult<IEnumerable<MediaSourceInfo>>(sources);
}
_logger.LogDebug("Getting media sources for URN: {Urn}", urn);
var config = Plugin.Instance?.Configuration;
if (config == null)
{
return Task.FromResult<IEnumerable<MediaSourceInfo>>(sources);
}
// For scheduled livestreams, use shorter cache TTL (5 minutes) so they refresh when they go live
// For regular content, use configured cache duration
var cacheDuration = urn.Contains("scheduled_livestream", StringComparison.OrdinalIgnoreCase)
? 5
: config.CacheDurationMinutes;
// Try cache first
var mediaComposition = _metadataCache.GetMediaComposition(urn, cacheDuration);
// If not in cache, fetch from API
if (mediaComposition == null)
{
using var apiClient = new SRFApiClient(_loggerFactory);
mediaComposition = apiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).GetAwaiter().GetResult();
if (mediaComposition != null)
{
_metadataCache.SetMediaComposition(urn, mediaComposition);
}
}
if (mediaComposition?.ChapterList == null || mediaComposition.ChapterList.Count == 0)
{
_logger.LogWarning("No chapters found for URN: {Urn}", urn);
return Task.FromResult<IEnumerable<MediaSourceInfo>>(sources);
}
// Get the first chapter (main video)
var chapter = mediaComposition.ChapterList[0];
// Check if content is expired
if (_streamResolver.IsContentExpired(chapter))
{
_logger.LogWarning("Content expired for URN: {Urn}, ValidTo: {ValidTo}", urn, chapter.ValidTo);
return Task.FromResult<IEnumerable<MediaSourceInfo>>(sources);
}
// Check if content has playable streams
if (!_streamResolver.HasPlayableContent(chapter))
{
_logger.LogWarning("No playable content found for URN: {Urn}", urn);
return Task.FromResult<IEnumerable<MediaSourceInfo>>(sources);
}
// Get stream URL based on quality preference
var streamUrl = _streamResolver.GetStreamUrl(chapter, config.QualityPreference);
// Check if this is an upcoming livestream that hasn't started yet
var isUpcomingLivestream = chapter.Type == "SCHEDULED_LIVESTREAM" && string.IsNullOrEmpty(streamUrl);
if (string.IsNullOrEmpty(streamUrl) && !isUpcomingLivestream)
{
_logger.LogWarning("Could not resolve stream URL for URN: {Urn}", urn);
return Task.FromResult<IEnumerable<MediaSourceInfo>>(sources);
}
// For upcoming livestreams, check if the event has started
if (isUpcomingLivestream)
{
if (chapter.ValidFrom != null)
{
var eventStart = chapter.ValidFrom.Value.ToUniversalTime();
var now = DateTime.UtcNow;
if (eventStart > now)
{
_logger.LogInformation(
"URN {Urn}: Livestream '{Title}' hasn't started yet (starts at {ValidFrom}). User should refresh when live.",
urn,
chapter.Title,
chapter.ValidFrom);
// Return empty sources - event not yet playable
return Task.FromResult<IEnumerable<MediaSourceInfo>>(sources);
}
}
// Event should be live now - re-fetch media composition without cache
using var freshApiClient = new SRFApiClient(_loggerFactory);
var freshMediaComposition = freshApiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).GetAwaiter().GetResult();
if (freshMediaComposition?.ChapterList != null && freshMediaComposition.ChapterList.Count > 0)
{
var freshChapter = freshMediaComposition.ChapterList[0];
streamUrl = _streamResolver.GetStreamUrl(freshChapter, config.QualityPreference);
if (!string.IsNullOrEmpty(streamUrl))
{
// Update cache with fresh data
_metadataCache.SetMediaComposition(urn, freshMediaComposition);
chapter = freshChapter;
_logger.LogInformation("URN {Urn}: Livestream is now live, got fresh stream URL", urn);
}
else
{
_logger.LogWarning("URN {Urn}: Livestream should be live but still no stream URL available", urn);
return Task.FromResult<IEnumerable<MediaSourceInfo>>(sources);
}
}
else
{
_logger.LogWarning("URN {Urn}: Failed to fetch fresh media composition for livestream", urn);
return Task.FromResult<IEnumerable<MediaSourceInfo>>(sources);
}
}
// Authenticate the stream URL (required for all SRF streams, especially livestreams)
if (!string.IsNullOrEmpty(streamUrl))
{
streamUrl = _streamResolver.GetAuthenticatedStreamUrlAsync(streamUrl, cancellationToken).GetAwaiter().GetResult();
_logger.LogDebug("Authenticated stream URL for URN: {Urn}", urn);
}
// Create media source
var mediaSource = new MediaSourceInfo
{
Id = urn,
Name = chapter.Title,
Path = streamUrl,
Protocol = MediaProtocol.Http,
Container = "m3u8",
SupportsDirectStream = true,
SupportsDirectPlay = true,
SupportsTranscoding = true,
IsRemote = true,
Type = MediaSourceType.Default,
RunTimeTicks = chapter.Duration > 0 ? TimeSpan.FromMilliseconds(chapter.Duration).Ticks : null,
VideoType = VideoType.VideoFile,
IsInfiniteStream = false,
RequiresOpening = false,
RequiresClosing = false,
SupportsProbing = true
};
// Add video stream info
mediaSource.MediaStreams = new List<MediaStream>
{
new MediaStream
{
Type = MediaStreamType.Video,
Codec = "h264",
IsInterlaced = false,
IsDefault = true
}
};
sources.Add(mediaSource);
_logger.LogInformation("Resolved stream URL for {Title}: {Url}", chapter.Title, streamUrl);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting media sources for item: {Name}", item.Name);
}
return Task.FromResult<IEnumerable<MediaSourceInfo>>(sources);
}
/// <summary>
/// Gets direct stream provider by unique ID.
/// </summary>
/// <param name="uniqueId">The unique ID.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The direct stream provider.</returns>
public Task<IDirectStreamProvider?> GetDirectStreamProviderByUniqueId(string uniqueId, CancellationToken cancellationToken)
{
// Not needed for HTTP streams
return Task.FromResult<IDirectStreamProvider?>(null);
}
/// <inheritdoc />
public Task<ILiveStream> OpenMediaSource(string openToken, List<ILiveStream> currentLiveStreams, CancellationToken cancellationToken)
{
// Not needed for static HTTP streams
throw new NotImplementedException();
}
}
@@ -0,0 +1,189 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Api;
using Jellyfin.Plugin.SRFPlay.Services;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Providers;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Providers;
/// <summary>
/// Provides metadata for SRF Play series/shows.
/// </summary>
public class SRFSeriesProvider : IRemoteMetadataProvider<Series, SeriesInfo>
{
private readonly ILogger<SRFSeriesProvider> _logger;
private readonly ILoggerFactory _loggerFactory;
private readonly IHttpClientFactory _httpClientFactory;
private readonly MetadataCache _metadataCache;
/// <summary>
/// Initializes a new instance of the <see cref="SRFSeriesProvider"/> class.
/// </summary>
/// <param name="logger">The logger instance.</param>
/// <param name="loggerFactory">The logger factory.</param>
/// <param name="httpClientFactory">The HTTP client factory.</param>
/// <param name="metadataCache">The metadata cache.</param>
public SRFSeriesProvider(
ILogger<SRFSeriesProvider> logger,
ILoggerFactory loggerFactory,
IHttpClientFactory httpClientFactory,
MetadataCache metadataCache)
{
_logger = logger;
_loggerFactory = loggerFactory;
_httpClientFactory = httpClientFactory;
_metadataCache = metadataCache;
}
/// <inheritdoc />
public string Name => "SRF Play";
/// <inheritdoc />
public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(SeriesInfo searchInfo, CancellationToken cancellationToken)
{
var results = new List<RemoteSearchResult>();
try
{
// Check if we have a URN to search with
if (searchInfo.ProviderIds.TryGetValue("SRF", out var urn) && !string.IsNullOrEmpty(urn))
{
_logger.LogDebug("Searching for series with URN: {Urn}", urn);
var config = Plugin.Instance?.Configuration;
if (config == null)
{
return results;
}
// Try cache first
var mediaComposition = _metadataCache.GetMediaComposition(urn, config.CacheDurationMinutes);
// If not in cache, fetch from API
if (mediaComposition == null)
{
using var apiClient = new SRFApiClient(_loggerFactory);
mediaComposition = await apiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).ConfigureAwait(false);
if (mediaComposition != null)
{
_metadataCache.SetMediaComposition(urn, mediaComposition);
}
}
if (mediaComposition?.Show != null)
{
var show = mediaComposition.Show;
results.Add(new RemoteSearchResult
{
Name = show.Title,
Overview = show.Description ?? show.Lead,
ImageUrl = show.ImageUrl,
SearchProviderName = Name,
ProviderIds = new Dictionary<string, string>
{
{ "SRF", show.Urn ?? urn }
}
});
}
}
else if (!string.IsNullOrEmpty(searchInfo.Name))
{
_logger.LogDebug("Name-based search not yet implemented for: {Name}", searchInfo.Name);
// TODO: Implement name-based search when SRF provides search API
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error searching for series: {Name}", searchInfo.Name);
}
return results;
}
/// <inheritdoc />
public async Task<MetadataResult<Series>> GetMetadata(SeriesInfo info, CancellationToken cancellationToken)
{
var result = new MetadataResult<Series>();
try
{
// Check if we have a URN
if (!info.ProviderIds.TryGetValue("SRF", out var urn) || string.IsNullOrEmpty(urn))
{
_logger.LogDebug("No SRF URN found for series: {Name}", info.Name);
return result;
}
_logger.LogDebug("Fetching metadata for series URN: {Urn}", urn);
var config = Plugin.Instance?.Configuration;
if (config == null)
{
return result;
}
// Try cache first
var mediaComposition = _metadataCache.GetMediaComposition(urn, config.CacheDurationMinutes);
// If not in cache, fetch from API
if (mediaComposition == null)
{
using var apiClient = new SRFApiClient(_loggerFactory);
mediaComposition = await apiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).ConfigureAwait(false);
if (mediaComposition != null)
{
_metadataCache.SetMediaComposition(urn, mediaComposition);
}
}
if (mediaComposition?.Show == null)
{
_logger.LogWarning("No show information found for URN: {Urn}", urn);
return result;
}
var show = mediaComposition.Show;
result.Item = new Series
{
Name = show.Title,
Overview = show.Description ?? show.Lead,
ProviderIds = new Dictionary<string, string>
{
{ "SRF", show.Urn ?? urn }
}
};
// Set additional metadata if available
if (!string.IsNullOrEmpty(show.Vendor))
{
result.Item.Studios = new[] { show.Vendor };
}
result.HasMetadata = true;
_logger.LogDebug("Successfully fetched metadata for series: {Title}", show.Title);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching metadata for series: {Name}", info.Name);
}
return result;
}
/// <inheritdoc />
public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken)
{
throw new NotImplementedException("Image handling is done by SRFImageProvider");
}
}
@@ -0,0 +1,108 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Services;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.ScheduledTasks;
/// <summary>
/// Scheduled task for refreshing SRF Play content.
/// </summary>
public class ContentRefreshTask : IScheduledTask
{
private readonly ILogger<ContentRefreshTask> _logger;
private readonly ContentRefreshService _contentRefreshService;
/// <summary>
/// Initializes a new instance of the <see cref="ContentRefreshTask"/> class.
/// </summary>
/// <param name="logger">The logger instance.</param>
/// <param name="contentRefreshService">The content refresh service.</param>
public ContentRefreshTask(
ILogger<ContentRefreshTask> logger,
ContentRefreshService contentRefreshService)
{
_logger = logger;
_contentRefreshService = contentRefreshService;
}
/// <inheritdoc />
public string Name => "Refresh SRF Play Content";
/// <inheritdoc />
public string Description => "Refreshes latest and trending content from SRF Play";
/// <inheritdoc />
public string Category => "SRF Play";
/// <inheritdoc />
public string Key => "SRFPlayContentRefresh";
/// <inheritdoc />
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
{
_logger.LogInformation("Starting SRF Play content refresh task");
progress?.Report(0);
try
{
var config = Plugin.Instance?.Configuration;
if (config == null)
{
_logger.LogWarning("Plugin configuration not available");
return;
}
if (!config.EnableLatestContent && !config.EnableTrendingContent)
{
_logger.LogInformation("Content refresh is disabled in configuration");
progress?.Report(100);
return;
}
// Refresh latest content
if (config.EnableLatestContent)
{
_logger.LogInformation("Refreshing latest content");
progress?.Report(25);
await _contentRefreshService.RefreshLatestContentAsync(cancellationToken).ConfigureAwait(false);
}
// Refresh trending content
if (config.EnableTrendingContent)
{
_logger.LogInformation("Refreshing trending content");
progress?.Report(75);
await _contentRefreshService.RefreshTrendingContentAsync(cancellationToken).ConfigureAwait(false);
}
progress?.Report(100);
_logger.LogInformation("SRF Play content refresh task completed successfully");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during SRF Play content refresh task");
throw;
}
}
/// <inheritdoc />
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
{
var config = Plugin.Instance?.Configuration;
var intervalHours = config?.ContentRefreshIntervalHours ?? 6;
// Run every X hours as configured
return new[]
{
new TaskTriggerInfo
{
Type = TaskTriggerInfo.TriggerInterval,
IntervalTicks = TimeSpan.FromHours(intervalHours).Ticks
}
};
}
}
@@ -0,0 +1,111 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Services;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.ScheduledTasks;
/// <summary>
/// Scheduled task for checking and removing expired SRF Play content.
/// </summary>
public class ExpirationCheckTask : IScheduledTask
{
private readonly ILogger<ExpirationCheckTask> _logger;
private readonly ContentExpirationService _expirationService;
/// <summary>
/// Initializes a new instance of the <see cref="ExpirationCheckTask"/> class.
/// </summary>
/// <param name="logger">The logger instance.</param>
/// <param name="expirationService">The content expiration service.</param>
public ExpirationCheckTask(
ILogger<ExpirationCheckTask> logger,
ContentExpirationService expirationService)
{
_logger = logger;
_expirationService = expirationService;
}
/// <inheritdoc />
public string Name => "Check SRF Play Content Expiration";
/// <inheritdoc />
public string Description => "Checks for expired SRF Play content and removes it from the library";
/// <inheritdoc />
public string Category => "SRF Play";
/// <inheritdoc />
public string Key => "SRFPlayExpirationCheck";
/// <inheritdoc />
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
{
_logger.LogInformation("Starting SRF Play expiration check task");
progress?.Report(0);
try
{
var config = Plugin.Instance?.Configuration;
if (config == null)
{
_logger.LogWarning("Plugin configuration not available");
return;
}
// Get expiration statistics first
progress?.Report(25);
var (total, expired, expiringSoon) = await _expirationService.GetExpirationStatisticsAsync(cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Expiration statistics - Total: {Total}, Expired: {Expired}, Expiring Soon: {ExpiringSoon}",
total,
expired,
expiringSoon);
if (expired == 0)
{
_logger.LogInformation("No expired content found");
progress?.Report(100);
return;
}
// Remove expired content
progress?.Report(50);
var removedCount = await _expirationService.CheckAndRemoveExpiredContentAsync(cancellationToken).ConfigureAwait(false);
progress?.Report(100);
_logger.LogInformation("SRF Play expiration check task completed. Removed {Count} expired items", removedCount);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during SRF Play expiration check task");
throw;
}
}
/// <inheritdoc />
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
{
var config = Plugin.Instance?.Configuration;
var intervalHours = config?.ExpirationCheckIntervalHours ?? 24;
// Run daily at 3 AM or every X hours as configured
return new[]
{
new TaskTriggerInfo
{
Type = TaskTriggerInfo.TriggerDaily,
TimeOfDayTicks = TimeSpan.FromHours(3).Ticks
},
new TaskTriggerInfo
{
Type = TaskTriggerInfo.TriggerInterval,
IntervalTicks = TimeSpan.FromHours(intervalHours).Ticks
}
};
}
}
@@ -0,0 +1,44 @@
using Jellyfin.Plugin.SRFPlay.Channels;
using Jellyfin.Plugin.SRFPlay.Providers;
using Jellyfin.Plugin.SRFPlay.ScheduledTasks;
using Jellyfin.Plugin.SRFPlay.Services;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Channels;
using MediaBrowser.Controller.Plugins;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay;
/// <summary>
/// Service registrator for dependency injection.
/// </summary>
public class ServiceRegistrator : IPluginServiceRegistrator
{
/// <inheritdoc />
public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost)
{
// Register services as singletons
serviceCollection.AddSingleton<MetadataCache>();
serviceCollection.AddSingleton<StreamUrlResolver>();
serviceCollection.AddSingleton<ContentExpirationService>();
serviceCollection.AddSingleton<ContentRefreshService>();
serviceCollection.AddSingleton<CategoryService>();
// Register metadata providers
serviceCollection.AddSingleton<SRFSeriesProvider>();
serviceCollection.AddSingleton<SRFEpisodeProvider>();
serviceCollection.AddSingleton<SRFImageProvider>();
// Register media source provider
serviceCollection.AddSingleton<SRFMediaProvider>();
// Register scheduled tasks
serviceCollection.AddSingleton<IScheduledTask, ContentRefreshTask>();
serviceCollection.AddSingleton<IScheduledTask, ExpirationCheckTask>();
// Register channel - must register as IChannel interface for Jellyfin to discover it
serviceCollection.AddSingleton<IChannel, SRFPlayChannel>();
}
}
@@ -0,0 +1,206 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Api;
using Jellyfin.Plugin.SRFPlay.Api.Models;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Services;
/// <summary>
/// Service for managing topic/category data and filtering.
/// </summary>
public class CategoryService
{
private readonly ILogger _logger;
private readonly ILoggerFactory _loggerFactory;
private readonly TimeSpan _topicsCacheDuration = TimeSpan.FromHours(24);
private Dictionary<string, PlayV3Topic>? _topicsCache;
private DateTime _topicsCacheExpiry = DateTime.MinValue;
/// <summary>
/// Initializes a new instance of the <see cref="CategoryService"/> class.
/// </summary>
/// <param name="loggerFactory">The logger factory.</param>
public CategoryService(ILoggerFactory loggerFactory)
{
_loggerFactory = loggerFactory;
_logger = loggerFactory.CreateLogger<CategoryService>();
}
/// <summary>
/// Gets all topics for a business unit.
/// </summary>
/// <param name="businessUnit">The business unit.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>List of topics.</returns>
public async Task<List<PlayV3Topic>> GetTopicsAsync(string businessUnit, CancellationToken cancellationToken = default)
{
// Return cached topics if still valid
if (_topicsCache != null && DateTime.UtcNow < _topicsCacheExpiry)
{
_logger.LogDebug("Returning cached topics for business unit: {BusinessUnit}", businessUnit);
return _topicsCache.Values.ToList();
}
_logger.LogInformation("Fetching topics for business unit: {BusinessUnit}", businessUnit);
using var apiClient = new SRFApiClient(_loggerFactory);
var topics = await apiClient.GetAllTopicsAsync(businessUnit, cancellationToken).ConfigureAwait(false);
if (topics != null && topics.Count > 0)
{
// Cache topics by ID for quick lookups
_topicsCache = topics
.Where(t => !string.IsNullOrEmpty(t.Id))
.ToDictionary(t => t.Id!, t => t);
_topicsCacheExpiry = DateTime.UtcNow.Add(_topicsCacheDuration);
_logger.LogInformation("Cached {Count} topics for business unit: {BusinessUnit}", _topicsCache.Count, businessUnit);
}
return topics ?? new List<PlayV3Topic>();
}
/// <summary>
/// Gets a topic by ID.
/// </summary>
/// <param name="topicId">The topic ID.</param>
/// <param name="businessUnit">The business unit.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The topic, or null if not found.</returns>
public async Task<PlayV3Topic?> GetTopicByIdAsync(string topicId, string businessUnit, CancellationToken cancellationToken = default)
{
// Ensure topics are loaded
if (_topicsCache == null || DateTime.UtcNow >= _topicsCacheExpiry)
{
await GetTopicsAsync(businessUnit, cancellationToken).ConfigureAwait(false);
}
return _topicsCache?.GetValueOrDefault(topicId);
}
/// <summary>
/// Filters shows by topic ID.
/// </summary>
/// <param name="shows">The shows to filter.</param>
/// <param name="topicId">The topic ID to filter by.</param>
/// <returns>Filtered list of shows.</returns>
public IReadOnlyList<PlayV3Show> FilterShowsByTopic(IReadOnlyList<PlayV3Show> shows, string topicId)
{
if (string.IsNullOrEmpty(topicId))
{
return shows;
}
return shows
.Where(s => s.TopicList != null && s.TopicList.Contains(topicId))
.ToList();
}
/// <summary>
/// Groups shows by their topics.
/// </summary>
/// <param name="shows">The shows to group.</param>
/// <returns>Dictionary mapping topic IDs to shows.</returns>
public IReadOnlyDictionary<string, List<PlayV3Show>> GroupShowsByTopics(IReadOnlyList<PlayV3Show> shows)
{
var groupedShows = new Dictionary<string, List<PlayV3Show>>();
foreach (var show in shows)
{
if (show.TopicList == null || show.TopicList.Count == 0)
{
continue;
}
foreach (var topicId in show.TopicList)
{
if (!groupedShows.TryGetValue(topicId, out var showList))
{
showList = new List<PlayV3Show>();
groupedShows[topicId] = showList;
}
showList.Add(show);
}
}
return groupedShows;
}
/// <summary>
/// Gets shows for a specific topic, sorted by number of episodes.
/// </summary>
/// <param name="topicId">The topic ID.</param>
/// <param name="businessUnit">The business unit.</param>
/// <param name="maxResults">Maximum number of results to return.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>List of shows for the topic.</returns>
public async Task<List<PlayV3Show>> GetShowsByTopicAsync(
string topicId,
string businessUnit,
int maxResults = 50,
CancellationToken cancellationToken = default)
{
using var apiClient = new SRFApiClient(_loggerFactory);
var allShows = await apiClient.GetAllShowsAsync(businessUnit, cancellationToken).ConfigureAwait(false);
if (allShows == null || allShows.Count == 0)
{
_logger.LogWarning("No shows available for business unit: {BusinessUnit}", businessUnit);
return new List<PlayV3Show>();
}
var filteredShows = FilterShowsByTopic(allShows, topicId)
.Where(s => s.NumberOfEpisodes > 0)
.OrderByDescending(s => s.NumberOfEpisodes)
.Take(maxResults)
.ToList();
_logger.LogDebug("Found {Count} shows for topic {TopicId}", filteredShows.Count, topicId);
return filteredShows;
}
/// <summary>
/// Gets video count for each topic.
/// </summary>
/// <param name="shows">The shows to analyze.</param>
/// <returns>Dictionary mapping topic IDs to video counts.</returns>
public IReadOnlyDictionary<string, int> GetVideoCountByTopic(IReadOnlyList<PlayV3Show> shows)
{
var topicCounts = new Dictionary<string, int>();
foreach (var show in shows)
{
if (show.TopicList == null || show.TopicList.Count == 0)
{
continue;
}
foreach (var topicId in show.TopicList)
{
if (!topicCounts.TryGetValue(topicId, out var count))
{
count = 0;
}
topicCounts[topicId] = count + show.NumberOfEpisodes;
}
}
return topicCounts;
}
/// <summary>
/// Clears the topics cache.
/// </summary>
public void ClearCache()
{
_topicsCache = null;
_topicsCacheExpiry = DateTime.MinValue;
_logger.LogInformation("Topics cache cleared");
}
}
@@ -0,0 +1,245 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Api;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.IO;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Services;
/// <summary>
/// Service for managing content expiration.
/// </summary>
public class ContentExpirationService
{
private readonly ILogger<ContentExpirationService> _logger;
private readonly ILoggerFactory _loggerFactory;
private readonly ILibraryManager _libraryManager;
private readonly StreamUrlResolver _streamResolver;
private readonly MetadataCache _metadataCache;
/// <summary>
/// Initializes a new instance of the <see cref="ContentExpirationService"/> class.
/// </summary>
/// <param name="loggerFactory">The logger factory.</param>
/// <param name="libraryManager">The library manager.</param>
/// <param name="streamResolver">The stream URL resolver.</param>
/// <param name="metadataCache">The metadata cache.</param>
public ContentExpirationService(
ILoggerFactory loggerFactory,
ILibraryManager libraryManager,
StreamUrlResolver streamResolver,
MetadataCache metadataCache)
{
_loggerFactory = loggerFactory;
_logger = loggerFactory.CreateLogger<ContentExpirationService>();
_libraryManager = libraryManager;
_streamResolver = streamResolver;
_metadataCache = metadataCache;
}
/// <summary>
/// Checks for expired content and removes it from the library.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The number of items removed.</returns>
public async Task<int> CheckAndRemoveExpiredContentAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Starting content expiration check");
var removedCount = 0;
try
{
// Get all items with SRF provider ID
var query = new InternalItemsQuery
{
HasAnyProviderId = new Dictionary<string, string> { { "SRF", string.Empty } },
IsVirtualItem = false
};
var items = _libraryManager.GetItemList(query);
_logger.LogDebug("Found {Count} SRF items to check for expiration", items.Count);
foreach (var item in items)
{
if (cancellationToken.IsCancellationRequested)
{
break;
}
try
{
if (await IsItemExpiredAsync(item, cancellationToken).ConfigureAwait(false))
{
_logger.LogInformation("Removing expired item: {Name} (URN: {Urn})", item.Name, item.ProviderIds.GetValueOrDefault("SRF"));
// Delete the item from library
_libraryManager.DeleteItem(
item,
new DeleteOptions
{
DeleteFileLocation = false
},
false);
removedCount++;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error checking expiration for item: {Name}", item.Name);
}
}
_logger.LogInformation("Content expiration check completed. Removed {Count} expired items", removedCount);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during content expiration check");
}
return removedCount;
}
/// <summary>
/// Checks if an item is expired.
/// </summary>
/// <param name="item">The item to check.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>True if the item is expired.</returns>
private async Task<bool> IsItemExpiredAsync(BaseItem item, CancellationToken cancellationToken)
{
var urn = item.ProviderIds.GetValueOrDefault("SRF");
if (string.IsNullOrEmpty(urn))
{
return false;
}
var config = Plugin.Instance?.Configuration;
if (config == null)
{
return false;
}
// Try cache first
var mediaComposition = _metadataCache.GetMediaComposition(urn, config.CacheDurationMinutes);
// If not in cache, fetch from API
if (mediaComposition == null)
{
using var apiClient = new SRFApiClient(_loggerFactory);
mediaComposition = await apiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).ConfigureAwait(false);
if (mediaComposition != null)
{
_metadataCache.SetMediaComposition(urn, mediaComposition);
}
}
if (mediaComposition?.ChapterList == null || mediaComposition.ChapterList.Count == 0)
{
// If we can't fetch the content, consider it expired
_logger.LogWarning("Could not fetch media composition for URN: {Urn}, treating as expired", urn);
return true;
}
var chapter = mediaComposition.ChapterList[0];
var isExpired = _streamResolver.IsContentExpired(chapter);
if (isExpired)
{
_logger.LogDebug("Item {Name} is expired (ValidTo: {ValidTo})", item.Name, chapter.ValidTo);
}
return isExpired;
}
/// <summary>
/// Gets statistics about content expiration.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Tuple with total count, expired count, and items expiring soon.</returns>
public async Task<(int Total, int Expired, int ExpiringSoon)> GetExpirationStatisticsAsync(CancellationToken cancellationToken)
{
var total = 0;
var expired = 0;
var expiringSoon = 0;
var soonThreshold = DateTime.UtcNow.AddDays(7); // Items expiring within 7 days
try
{
var query = new InternalItemsQuery
{
HasAnyProviderId = new Dictionary<string, string> { { "SRF", string.Empty } },
IsVirtualItem = false
};
var items = _libraryManager.GetItemList(query);
total = items.Count;
foreach (var item in items)
{
if (cancellationToken.IsCancellationRequested)
{
break;
}
try
{
var urn = item.ProviderIds.GetValueOrDefault("SRF");
if (string.IsNullOrEmpty(urn))
{
continue;
}
var config = Plugin.Instance?.Configuration;
if (config == null)
{
continue;
}
var mediaComposition = _metadataCache.GetMediaComposition(urn, config.CacheDurationMinutes);
if (mediaComposition == null)
{
using var apiClient = new SRFApiClient(_loggerFactory);
mediaComposition = await apiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).ConfigureAwait(false);
if (mediaComposition != null)
{
_metadataCache.SetMediaComposition(urn, mediaComposition);
}
}
if (mediaComposition?.ChapterList != null && mediaComposition.ChapterList.Count > 0)
{
var chapter = mediaComposition.ChapterList[0];
if (_streamResolver.IsContentExpired(chapter))
{
expired++;
}
else if (chapter.ValidTo.HasValue && chapter.ValidTo.Value.ToUniversalTime() <= soonThreshold)
{
expiringSoon++;
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error checking expiration statistics for item: {Name}", item.Name);
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting expiration statistics");
}
return (total, expired, expiringSoon);
}
}
@@ -0,0 +1,306 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Api;
using Jellyfin.Plugin.SRFPlay.Api.Models;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Services;
/// <summary>
/// Service for refreshing content from SRF API.
/// </summary>
public class ContentRefreshService
{
private readonly ILogger<ContentRefreshService> _logger;
private readonly ILoggerFactory _loggerFactory;
private readonly MetadataCache _metadataCache;
/// <summary>
/// Initializes a new instance of the <see cref="ContentRefreshService"/> class.
/// </summary>
/// <param name="loggerFactory">The logger factory.</param>
/// <param name="metadataCache">The metadata cache.</param>
public ContentRefreshService(
ILoggerFactory loggerFactory,
MetadataCache metadataCache)
{
_loggerFactory = loggerFactory;
_logger = loggerFactory.CreateLogger<ContentRefreshService>();
_metadataCache = metadataCache;
}
/// <summary>
/// Refreshes latest content from SRF API using Play v3.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>List of URNs for new content.</returns>
public async Task<List<string>> RefreshLatestContentAsync(CancellationToken cancellationToken)
{
var urns = new List<string>();
try
{
var config = Plugin.Instance?.Configuration;
if (config == null || !config.EnableLatestContent)
{
_logger.LogDebug("Latest content refresh is disabled");
return urns;
}
_logger.LogInformation("Refreshing latest content for business unit: {BusinessUnit}", config.BusinessUnit);
using var apiClient = new SRFApiClient(_loggerFactory);
var businessUnit = config.BusinessUnit.ToString().ToLowerInvariant();
// Get all shows from Play v3 API
var shows = await apiClient.GetAllShowsAsync(businessUnit, cancellationToken).ConfigureAwait(false);
if (shows == null || shows.Count == 0)
{
_logger.LogWarning("No shows found for business unit: {BusinessUnit}", config.BusinessUnit);
return urns;
}
_logger.LogInformation("Found {Count} shows, fetching latest episodes from each", shows.Count);
// Get latest episodes from each show (limit to 20 shows to avoid overwhelming)
var showsToFetch = shows.Where(s => s.NumberOfEpisodes > 0)
.OrderByDescending(s => s.NumberOfEpisodes)
.Take(20)
.ToList();
foreach (var show in showsToFetch)
{
if (show.Id == null)
{
continue;
}
try
{
var videos = await apiClient.GetVideosForShowAsync(businessUnit, show.Id, cancellationToken).ConfigureAwait(false);
if (videos != null && videos.Count > 0)
{
_logger.LogDebug("Show {Show} ({ShowId}): Found {Count} videos", show.Title, show.Id, videos.Count);
// Filter to videos that are actually published (validFrom in the past)
var now = DateTime.UtcNow;
var publishedVideos = videos.Where(v =>
v.ValidFrom == null || v.ValidFrom.Value.ToUniversalTime() <= now).ToList();
_logger.LogDebug("Show {Show}: {PublishedCount} published out of {TotalCount} videos", show.Title, publishedVideos.Count, videos.Count);
if (publishedVideos.Count > 0)
{
// Take only the most recent published video from each show
var latestVideo = publishedVideos.OrderByDescending(v => v.Date).FirstOrDefault();
if (latestVideo?.Urn != null)
{
urns.Add(latestVideo.Urn);
_logger.LogInformation(
"Added latest video from show {Show}: {Title} (URN: {Urn}, Date: {Date}, ValidFrom: {ValidFrom}, ValidTo: {ValidTo})",
show.Title,
latestVideo.Title,
latestVideo.Urn,
latestVideo.Date,
latestVideo.ValidFrom,
latestVideo.ValidTo);
}
else
{
_logger.LogWarning("Show {Show}: Latest video has null URN", show.Title);
}
}
else
{
_logger.LogDebug("Show {Show} has no published videos yet", show.Title);
}
}
else
{
_logger.LogDebug("Show {Show} ({ShowId}): No videos returned from API", show.Title, show.Id);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error fetching videos for show {ShowId}", show.Id);
}
// Respect cancellation
if (cancellationToken.IsCancellationRequested)
{
break;
}
}
_logger.LogInformation("Refreshed {Count} latest content items from {ShowCount} shows", urns.Count, showsToFetch.Count);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error refreshing latest content");
}
return urns;
}
/// <summary>
/// Refreshes trending content from SRF API using Play v3.
/// Gets videos from shows with the most episodes.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>List of URNs for trending content.</returns>
public async Task<List<string>> RefreshTrendingContentAsync(CancellationToken cancellationToken)
{
var urns = new List<string>();
try
{
var config = Plugin.Instance?.Configuration;
if (config == null || !config.EnableTrendingContent)
{
_logger.LogDebug("Trending content refresh is disabled");
return urns;
}
_logger.LogInformation("Refreshing trending content for business unit: {BusinessUnit}", config.BusinessUnit);
using var apiClient = new SRFApiClient(_loggerFactory);
var businessUnit = config.BusinessUnit.ToString().ToLowerInvariant();
// Get all shows from Play v3 API
var shows = await apiClient.GetAllShowsAsync(businessUnit, cancellationToken).ConfigureAwait(false);
if (shows == null || shows.Count == 0)
{
_logger.LogWarning("No shows found for business unit: {BusinessUnit}", config.BusinessUnit);
return urns;
}
_logger.LogInformation("Found {Count} shows, fetching popular content", shows.Count);
// Get videos from popular shows (those with many episodes)
var popularShows = shows.Where(s => s.NumberOfEpisodes > 10)
.OrderByDescending(s => s.NumberOfEpisodes)
.Take(15)
.ToList();
foreach (var show in popularShows)
{
if (show.Id == null)
{
continue;
}
try
{
var videos = await apiClient.GetVideosForShowAsync(businessUnit, show.Id, cancellationToken).ConfigureAwait(false);
if (videos != null && videos.Count > 0)
{
_logger.LogDebug("Show {Show} ({ShowId}): Found {Count} videos for trending", show.Title, show.Id, videos.Count);
// Filter to videos that are actually published (validFrom in the past)
var now = DateTime.UtcNow;
var publishedVideos = videos.Where(v =>
v.ValidFrom == null || v.ValidFrom.Value.ToUniversalTime() <= now).ToList();
_logger.LogDebug("Show {Show}: {PublishedCount} published out of {TotalCount} videos for trending", show.Title, publishedVideos.Count, videos.Count);
if (publishedVideos.Count > 0)
{
// Take 2 recent published videos from each popular show
var recentVideos = publishedVideos.OrderByDescending(v => v.Date).Take(2);
foreach (var video in recentVideos)
{
if (video.Urn != null)
{
urns.Add(video.Urn);
_logger.LogInformation(
"Added trending video from show {Show}: {Title} (URN: {Urn}, Date: {Date}, ValidFrom: {ValidFrom}, ValidTo: {ValidTo})",
show.Title,
video.Title,
video.Urn,
video.Date,
video.ValidFrom,
video.ValidTo);
}
else
{
_logger.LogWarning("Show {Show}: Trending video has null URN - {Title}", show.Title, video.Title);
}
}
}
}
else
{
_logger.LogDebug("Show {Show} ({ShowId}): No videos returned from API for trending", show.Title, show.Id);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error fetching videos for show {ShowId}", show.Id);
}
// Respect cancellation
if (cancellationToken.IsCancellationRequested)
{
break;
}
}
_logger.LogInformation("Refreshed {Count} trending content items from {ShowCount} shows", urns.Count, popularShows.Count);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error refreshing trending content");
}
return urns;
}
/// <summary>
/// Refreshes all content (latest and trending).
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Tuple with counts of latest and trending items.</returns>
public async Task<(int LatestCount, int TrendingCount)> RefreshAllContentAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Starting full content refresh");
var latestUrns = await RefreshLatestContentAsync(cancellationToken).ConfigureAwait(false);
var trendingUrns = await RefreshTrendingContentAsync(cancellationToken).ConfigureAwait(false);
var latestCount = latestUrns.Count;
var trendingCount = trendingUrns.Count;
_logger.LogInformation(
"Content refresh completed. Latest: {LatestCount}, Trending: {TrendingCount}",
latestCount,
trendingCount);
return (latestCount, trendingCount);
}
/// <summary>
/// Gets content recommendations (combines latest and trending).
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>List of recommended URNs.</returns>
public async Task<List<string>> GetRecommendedContentAsync(CancellationToken cancellationToken)
{
var recommendations = new HashSet<string>();
var latestUrns = await RefreshLatestContentAsync(cancellationToken).ConfigureAwait(false);
var trendingUrns = await RefreshTrendingContentAsync(cancellationToken).ConfigureAwait(false);
foreach (var urn in latestUrns.Concat(trendingUrns))
{
recommendations.Add(urn);
}
_logger.LogInformation("Generated {Count} content recommendations", recommendations.Count);
return recommendations.ToList();
}
}
@@ -0,0 +1,236 @@
using System;
using System.Collections.Concurrent;
using System.Threading;
using Jellyfin.Plugin.SRFPlay.Api.Models;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Services;
/// <summary>
/// Service for caching metadata from SRF API.
/// </summary>
public sealed class MetadataCache : IDisposable
{
private readonly ILogger<MetadataCache> _logger;
private readonly ConcurrentDictionary<string, CacheEntry<MediaComposition>> _mediaCompositionCache;
private readonly ReaderWriterLockSlim _lock;
private bool _disposed;
/// <summary>
/// Initializes a new instance of the <see cref="MetadataCache"/> class.
/// </summary>
/// <param name="logger">The logger instance.</param>
public MetadataCache(ILogger<MetadataCache> logger)
{
_logger = logger;
_mediaCompositionCache = new ConcurrentDictionary<string, CacheEntry<MediaComposition>>();
_lock = new ReaderWriterLockSlim();
}
/// <summary>
/// Disposes resources.
/// </summary>
public void Dispose()
{
if (!_disposed)
{
_lock?.Dispose();
_disposed = true;
}
}
/// <summary>
/// Gets cached media composition by URN.
/// </summary>
/// <param name="urn">The URN.</param>
/// <param name="cacheDurationMinutes">The cache duration in minutes.</param>
/// <returns>The cached media composition, or null if not found or expired.</returns>
public MediaComposition? GetMediaComposition(string urn, int cacheDurationMinutes)
{
if (string.IsNullOrEmpty(urn))
{
return null;
}
try
{
_lock.EnterReadLock();
try
{
if (_mediaCompositionCache.TryGetValue(urn, out var entry))
{
if (entry.IsValid(cacheDurationMinutes))
{
_logger.LogDebug("Cache hit for URN: {Urn}", urn);
return entry.Value;
}
_logger.LogDebug("Cache entry expired for URN: {Urn}", urn);
}
}
finally
{
_lock.ExitReadLock();
}
}
catch (ObjectDisposedException)
{
return null;
}
return null;
}
/// <summary>
/// Sets media composition in cache.
/// </summary>
/// <param name="urn">The URN.</param>
/// <param name="mediaComposition">The media composition to cache.</param>
public void SetMediaComposition(string urn, MediaComposition mediaComposition)
{
if (string.IsNullOrEmpty(urn) || mediaComposition == null)
{
return;
}
try
{
_lock.EnterWriteLock();
try
{
var entry = new CacheEntry<MediaComposition>(mediaComposition);
_mediaCompositionCache.AddOrUpdate(urn, entry, (key, oldValue) => entry);
_logger.LogDebug("Cached media composition for URN: {Urn}", urn);
}
finally
{
_lock.ExitWriteLock();
}
}
catch (ObjectDisposedException)
{
// Cache is disposed, ignore
}
}
/// <summary>
/// Removes media composition from cache.
/// </summary>
/// <param name="urn">The URN.</param>
public void RemoveMediaComposition(string urn)
{
if (string.IsNullOrEmpty(urn))
{
return;
}
try
{
_lock.EnterWriteLock();
try
{
if (_mediaCompositionCache.TryRemove(urn, out _))
{
_logger.LogDebug("Removed cached media composition for URN: {Urn}", urn);
}
}
finally
{
_lock.ExitWriteLock();
}
}
catch (ObjectDisposedException)
{
// Cache is disposed, ignore
}
}
/// <summary>
/// Clears all cached data.
/// </summary>
public void Clear()
{
try
{
_lock.EnterWriteLock();
try
{
_mediaCompositionCache.Clear();
_logger.LogInformation("Cleared metadata cache");
}
finally
{
_lock.ExitWriteLock();
}
}
catch (ObjectDisposedException)
{
// Cache is disposed, ignore
}
}
/// <summary>
/// Gets the cache statistics.
/// </summary>
/// <returns>A tuple with cache count and size estimate.</returns>
public (int Count, long SizeEstimate) GetStatistics()
{
try
{
_lock.EnterReadLock();
try
{
var count = _mediaCompositionCache.Count;
// Rough estimate: average 50KB per entry
var sizeEstimate = count * 50L * 1024;
return (count, sizeEstimate);
}
finally
{
_lock.ExitReadLock();
}
}
catch (ObjectDisposedException)
{
return (0, 0);
}
}
/// <summary>
/// Represents a cached entry with timestamp.
/// </summary>
/// <typeparam name="T">The type of cached value.</typeparam>
private sealed class CacheEntry<T>
{
/// <summary>
/// Initializes a new instance of the <see cref="CacheEntry{T}"/> class.
/// </summary>
/// <param name="value">The value to cache.</param>
public CacheEntry(T value)
{
Value = value;
Timestamp = DateTime.UtcNow;
}
/// <summary>
/// Gets the cached value.
/// </summary>
public T Value { get; }
/// <summary>
/// Gets the timestamp when the entry was created.
/// </summary>
public DateTime Timestamp { get; }
/// <summary>
/// Checks if the cache entry is still valid.
/// </summary>
/// <param name="cacheDurationMinutes">The cache duration in minutes.</param>
/// <returns>True if the entry is still valid.</returns>
public bool IsValid(int cacheDurationMinutes)
{
var expirationTime = Timestamp.AddMinutes(cacheDurationMinutes);
return DateTime.UtcNow < expirationTime;
}
}
}
@@ -0,0 +1,305 @@
using System;
using System.Linq;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Api.Models;
using Jellyfin.Plugin.SRFPlay.Configuration;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Services;
/// <summary>
/// Service for resolving stream URLs from media composition resources.
/// </summary>
public class StreamUrlResolver : IDisposable
{
private readonly ILogger<StreamUrlResolver> _logger;
private readonly HttpClient _httpClient;
private bool _disposed;
/// <summary>
/// Initializes a new instance of the <see cref="StreamUrlResolver"/> class.
/// </summary>
/// <param name="logger">The logger instance.</param>
public StreamUrlResolver(ILogger<StreamUrlResolver> logger)
{
_logger = logger;
_httpClient = new HttpClient();
}
/// <summary>
/// Gets the best stream URL from a chapter based on quality preference.
/// </summary>
/// <param name="chapter">The chapter containing resources.</param>
/// <param name="qualityPreference">The quality preference.</param>
/// <returns>The stream URL, or null if no suitable stream found.</returns>
public string? GetStreamUrl(Chapter chapter, QualityPreference qualityPreference)
{
if (chapter?.ResourceList == null || chapter.ResourceList.Count == 0)
{
_logger.LogWarning("No resources found for chapter: {ChapterId}", chapter?.Id);
return null;
}
_logger.LogInformation(
"Processing chapter {ChapterId} with {ResourceCount} resources",
chapter.Id,
chapter.ResourceList.Count);
// Filter out DRM-protected content
var nonDrmResources = chapter.ResourceList
.Where(r => r.DrmList == null || r.DrmList.ToString() == "[]")
.ToList();
_logger.LogInformation(
"Chapter {ChapterId}: Total resources={Total}, Non-DRM resources={NonDrm}",
chapter.Id,
chapter.ResourceList.Count,
nonDrmResources.Count);
if (nonDrmResources.Count == 0)
{
_logger.LogWarning("All resources for chapter {ChapterId} require DRM", chapter.Id);
// Log what DRM types are present
foreach (var resource in chapter.ResourceList)
{
_logger.LogDebug(
"DRM resource: Protocol={Protocol}, Streaming={Streaming}, DRM={Drm}",
resource.Protocol,
resource.Streaming,
resource.DrmList);
}
return null;
}
// Prefer HLS protocol
var hlsResources = nonDrmResources
.Where(r => string.Equals(r.Protocol, "HLS", StringComparison.OrdinalIgnoreCase) ||
string.Equals(r.Streaming, "HLS", StringComparison.OrdinalIgnoreCase) ||
r.Url.Contains(".m3u8", StringComparison.OrdinalIgnoreCase))
.ToList();
_logger.LogInformation(
"Chapter {ChapterId}: HLS resources found={HlsCount}",
chapter.Id,
hlsResources.Count);
if (hlsResources.Count == 0)
{
_logger.LogWarning("No HLS resources found for chapter: {ChapterId}", chapter.Id);
// Log available protocols
foreach (var resource in nonDrmResources)
{
_logger.LogDebug(
"Non-HLS resource: Protocol={Protocol}, Streaming={Streaming}, URL={Url}",
resource.Protocol,
resource.Streaming,
resource.Url);
}
// Fallback to any available non-DRM resource
var fallbackResource = nonDrmResources.FirstOrDefault();
if (fallbackResource != null)
{
_logger.LogInformation(
"Using fallback resource for chapter {ChapterId}: {Url}",
chapter.Id,
fallbackResource.Url);
}
return fallbackResource?.Url;
}
// Select based on quality preference
Resource? selectedResource = qualityPreference switch
{
QualityPreference.HD => SelectHDResource(hlsResources) ?? SelectBestAvailableResource(hlsResources),
QualityPreference.SD => SelectSDResource(hlsResources) ?? SelectBestAvailableResource(hlsResources),
QualityPreference.Auto => SelectBestAvailableResource(hlsResources),
_ => SelectBestAvailableResource(hlsResources)
};
if (selectedResource != null)
{
_logger.LogDebug(
"Selected stream for chapter {ChapterId}: Quality={Quality}, Protocol={Protocol}, URL={Url}",
chapter.Id,
selectedResource.Quality,
selectedResource.Protocol,
selectedResource.Url);
return selectedResource.Url;
}
_logger.LogWarning("Could not select appropriate stream for chapter: {ChapterId}", chapter.Id);
return null;
}
/// <summary>
/// Checks if a chapter has non-DRM playable content.
/// </summary>
/// <param name="chapter">The chapter to check.</param>
/// <returns>True if playable content is available.</returns>
public bool HasPlayableContent(Chapter chapter)
{
// For scheduled livestreams that haven't started yet, resources won't exist
// but we still want to show them. The stream will become available when the event starts.
if (chapter?.Type == "SCHEDULED_LIVESTREAM")
{
var now = DateTime.UtcNow;
var hasStarted = chapter.ValidFrom == null || chapter.ValidFrom.Value.ToUniversalTime() <= now;
if (!hasStarted)
{
_logger.LogInformation(
"Scheduled livestream '{Title}' hasn't started yet (starts at {ValidFrom}), will be playable when live",
chapter.Title,
chapter.ValidFrom);
return true; // Show it, stream will be available when event starts
}
}
if (chapter?.ResourceList == null || chapter.ResourceList.Count == 0)
{
return false;
}
return chapter.ResourceList.Any(r => r.DrmList == null || r.DrmList.ToString() == "[]");
}
/// <summary>
/// Checks if content is expired based on ValidTo date.
/// </summary>
/// <param name="chapter">The chapter to check.</param>
/// <returns>True if the content is expired.</returns>
public bool IsContentExpired(Chapter chapter)
{
if (chapter?.ValidTo == null)
{
return false;
}
return DateTime.UtcNow > chapter.ValidTo.Value.ToUniversalTime();
}
private Resource? SelectHDResource(System.Collections.Generic.List<Resource> resources)
{
return resources.FirstOrDefault(r =>
string.Equals(r.Quality, "HD", StringComparison.OrdinalIgnoreCase) ||
string.Equals(r.Quality, "1080", StringComparison.OrdinalIgnoreCase) ||
string.Equals(r.Quality, "720", StringComparison.OrdinalIgnoreCase));
}
private Resource? SelectSDResource(System.Collections.Generic.List<Resource> resources)
{
return resources.FirstOrDefault(r =>
string.Equals(r.Quality, "SD", StringComparison.OrdinalIgnoreCase) ||
string.Equals(r.Quality, "480", StringComparison.OrdinalIgnoreCase) ||
string.Equals(r.Quality, "360", StringComparison.OrdinalIgnoreCase));
}
private Resource? SelectBestAvailableResource(System.Collections.Generic.List<Resource> resources)
{
// Try HD first
var hdResource = SelectHDResource(resources);
if (hdResource != null)
{
return hdResource;
}
// Fall back to SD
var sdResource = SelectSDResource(resources);
if (sdResource != null)
{
return sdResource;
}
// Return first available
return resources.FirstOrDefault();
}
/// <summary>
/// Authenticates a stream URL by fetching an Akamai token.
/// Based on the Kodi addon implementation.
/// </summary>
/// <param name="streamUrl">The unauthenticated stream URL.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The authenticated stream URL with token.</returns>
public async Task<string> GetAuthenticatedStreamUrlAsync(string streamUrl, CancellationToken cancellationToken = default)
{
try
{
// Parse the stream URL to extract path components
var uri = new Uri(streamUrl);
var pathSegments = uri.AbsolutePath.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (pathSegments.Length < 2)
{
_logger.LogWarning("Stream URL has insufficient path segments for authentication: {Url}", streamUrl);
return streamUrl;
}
// Build ACL path: /{segment1}/{segment2}/*
var aclPath = $"/{pathSegments[0]}/{pathSegments[1]}/*";
var tokenUrl = $"https://tp.srgssr.ch/akahd/token?acl={Uri.EscapeDataString(aclPath)}";
_logger.LogDebug("Fetching auth token from: {TokenUrl}", tokenUrl);
var response = await _httpClient.GetAsync(tokenUrl, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var jsonContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
var tokenResponse = JsonSerializer.Deserialize<JsonElement>(jsonContent);
// Extract authparams from response
if (tokenResponse.TryGetProperty("token", out var token) &&
token.TryGetProperty("authparams", out var authParams))
{
var authParamsValue = authParams.GetString();
if (!string.IsNullOrEmpty(authParamsValue))
{
// Append auth params to original URL
var separator = streamUrl.Contains('?', StringComparison.Ordinal) ? "&" : "?";
var authenticatedUrl = $"{streamUrl}{separator}{authParamsValue}";
_logger.LogInformation("Successfully authenticated stream URL");
return authenticatedUrl;
}
}
_logger.LogWarning("No auth params found in token response, returning original URL");
return streamUrl;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to authenticate stream URL: {Url}", streamUrl);
return streamUrl; // Return original URL as fallback
}
}
/// <inheritdoc/>
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
/// <summary>
/// Releases the unmanaged resources and optionally releases the managed resources.
/// </summary>
/// <param name="disposing">True to release both managed and unmanaged resources; false to release only unmanaged resources.</param>
protected virtual void Dispose(bool disposing)
{
if (!_disposed)
{
if (disposing)
{
_httpClient?.Dispose();
}
_disposed = true;
}
}
}
-15
View File
@@ -1,15 +0,0 @@
Microsoft Visual Studio Solution File, Format Version 12.00
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Plugin.Template", "Jellyfin.Plugin.Template\Jellyfin.Plugin.Template.csproj", "{D921B930-CF91-406F-ACBC-08914DCD0D34}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{D921B930-CF91-406F-ACBC-08914DCD0D34}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D921B930-CF91-406F-ACBC-08914DCD0D34}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D921B930-CF91-406F-ACBC-08914DCD0D34}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D921B930-CF91-406F-ACBC-08914DCD0D34}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal
@@ -1,57 +0,0 @@
using MediaBrowser.Model.Plugins;
namespace Jellyfin.Plugin.Template.Configuration;
/// <summary>
/// The configuration options.
/// </summary>
public enum SomeOptions
{
/// <summary>
/// Option one.
/// </summary>
OneOption,
/// <summary>
/// Second option.
/// </summary>
AnotherOption
}
/// <summary>
/// Plugin configuration.
/// </summary>
public class PluginConfiguration : BasePluginConfiguration
{
/// <summary>
/// Initializes a new instance of the <see cref="PluginConfiguration"/> class.
/// </summary>
public PluginConfiguration()
{
// set default options here
Options = SomeOptions.AnotherOption;
TrueFalseSetting = true;
AnInteger = 2;
AString = "string";
}
/// <summary>
/// Gets or sets a value indicating whether some true or false setting is enabled..
/// </summary>
public bool TrueFalseSetting { get; set; }
/// <summary>
/// Gets or sets an integer setting.
/// </summary>
public int AnInteger { get; set; }
/// <summary>
/// Gets or sets a string setting.
/// </summary>
public string AString { get; set; }
/// <summary>
/// Gets or sets an enum option.
/// </summary>
public SomeOptions Options { get; set; }
}
@@ -1,79 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Template</title>
</head>
<body>
<div id="TemplateConfigPage" data-role="page" class="page type-interior pluginConfigurationPage" data-require="emby-input,emby-button,emby-select,emby-checkbox">
<div data-role="content">
<div class="content-primary">
<form id="TemplateConfigForm">
<div class="selectContainer">
<label class="selectLabel" for="Options">Several Options</label>
<select is="emby-select" id="Options" name="Options" class="emby-select-withcolor emby-select">
<option id="optOneOption" value="OneOption">One Option</option>
<option id="optAnotherOption" value="AnotherOption">Another Option</option>
</select>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="AnInteger">An Integer</label>
<input id="AnInteger" name="AnInteger" type="number" is="emby-input" min="0" />
<div class="fieldDescription">A Description</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<input id="TrueFalseSetting" name="TrueFalseCheckBox" type="checkbox" is="emby-checkbox" />
<span>A Checkbox</span>
</label>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="AString">A String</label>
<input id="AString" name="AString" type="text" is="emby-input" />
<div class="fieldDescription">Another Description</div>
</div>
<div>
<button is="emby-button" type="submit" class="raised button-submit block emby-button">
<span>Save</span>
</button>
</div>
</form>
</div>
</div>
<script type="text/javascript">
var TemplateConfig = {
pluginUniqueId: 'eb5d7894-8eef-4b36-aa6f-5d124e828ce1'
};
document.querySelector('#TemplateConfigPage')
.addEventListener('pageshow', function() {
Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(TemplateConfig.pluginUniqueId).then(function (config) {
document.querySelector('#Options').value = config.Options;
document.querySelector('#AnInteger').value = config.AnInteger;
document.querySelector('#TrueFalseSetting').checked = config.TrueFalseSetting;
document.querySelector('#AString').value = config.AString;
Dashboard.hideLoadingMsg();
});
});
document.querySelector('#TemplateConfigForm')
.addEventListener('submit', function(e) {
Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(TemplateConfig.pluginUniqueId).then(function (config) {
config.Options = document.querySelector('#Options').value;
config.AnInteger = document.querySelector('#AnInteger').value;
config.TrueFalseSetting = document.querySelector('#TrueFalseSetting').checked;
config.AString = document.querySelector('#AString').value;
ApiClient.updatePluginConfiguration(TemplateConfig.pluginUniqueId, config).then(function (result) {
Dashboard.processPluginConfigurationUpdateResult(result);
});
});
e.preventDefault();
return false;
});
</script>
</div>
</body>
</html>
+286 -314
View File
@@ -1,373 +1,345 @@
# So you want to make a Jellyfin plugin
# Jellyfin SRF Play Plugin
Awesome! This guide is for you. Jellyfin plugins are written using the dotnet standard framework. What that means is you can write them in any language that implements the CLI or the DLI and can compile to net6.0. The examples on this page are in C# because that is what most of Jellyfin is written in, but F#, Visual Basic, and IronPython should all be compatible once compiled.
A Jellyfin plugin for accessing SRF Play (Swiss Radio and Television) video-on-demand content.
## 0. Things you need to get started
## Features
- [Dotnet SDK 6.0](https://dotnet.microsoft.com/download)
- Access to SRF Play VOD content (video-on-demand, no DRM-protected content)
- **Live Sports Streaming** - Watch scheduled sports events (skiing, Formula 1, football, tennis, etc.)
- Support for all Swiss broadcasting units (SRF, RTS, RSI, RTR, SWI)
- Automatic content expiration handling
- Latest and trending content discovery
- Quality selection (Auto, SD, HD)
- HLS streaming support with Akamai token authentication
- Proxy support for routing traffic through alternate gateways
- Smart caching with reduced TTL for upcoming livestreams
- An editor of your choice. Some free choices are:
## Screenshots
[Visual Studio Code](https://code.visualstudio.com)
### Channel Menu
![SRF Play Menu](res/menu.png)
[Visual Studio Community Edition](https://visualstudio.microsoft.com/downloads)
The main channel interface showing the content folders.
### Playback
![SRF Play Playback](res/playback.png)
[Mono Develop](https://www.monodevelop.com)
Video playback with HLS streaming support and quality selection.
## 0.5. Quickstarts
## Project Status
We have a number of quickstart options available to speed you along the way.
### ✅ Completed Components
- [Download the Example Plugin Project](https://github.com/jellyfin/jellyfin-plugin-template/tree/master/Jellyfin.Plugin.Template) from this repository, open it in your IDE and go to [step 3](https://github.com/jellyfin/jellyfin-plugin-template#3-customize-plugin-information)
#### Phase 1: Project Setup
- ✅ Renamed from Template to SRFPlay
- ✅ Updated all namespaces and identifiers
- ✅ Configured plugin metadata (ID, name)
- Install our dotnet template by [downloading the dotnet-template/content folder from this repo](https://github.com/jellyfin/jellyfin-plugin-template/tree/master/dotnet-template/content) or off of Nuget (Coming soon)
#### Phase 2: Core API Infrastructure
- ✅ API Models (MediaComposition, Chapter, Resource, Show, Episode)
- ✅ SRF API Client with HTTP client wrapper
- ✅ JSON deserialization support
- ✅ Error handling and logging
```
dotnet new -i /path/to/templatefolder
```
#### Phase 3: Configuration
- ✅ Business unit selection (SRF/RTS/RSI/RTR/SWI)
- ✅ Quality preferences (Auto/SD/HD)
- ✅ Content refresh intervals
- ✅ Expiration check settings
- ✅ Cache duration configuration
- ✅ HTML configuration page
- Run this command then skip to step 4
#### Phase 4: Services
- ✅ Stream URL Resolver
- HLS stream selection
- Quality-based filtering
- DRM content filtering
- Content expiration checking
- Akamai token authentication
- Upcoming livestream detection
- ✅ Metadata Cache Service
- Efficient caching with configurable duration
- Thread-safe with ReaderWriterLockSlim
- IDisposable implementation
- Dynamic TTL for scheduled livestreams
- ✅ Content Expiration Service
- Automatic expiration checking
- Library cleanup of expired content
- Statistics and monitoring
- ✅ Content Refresh Service
- Latest and trending content discovery
- Scheduled sports livestreams
- Automatic cache population
- Recommendations system
```
dotnet new Jellyfin-plugin -name MyPlugin
```
#### Phase 5: Content Providers
- ✅ Series Provider (for show metadata)
- ✅ Episode Provider (for episode metadata)
- ✅ Image Provider (for thumbnails and artwork)
- ✅ Media Provider (for playback URLs and HLS streams)
If you'd rather start from scratch keep going on to step one. This assumes no specific editor or IDE and requires only the command line with dotnet in the path.
#### Phase 6: Scheduled Tasks
- ✅ Content Refresh Task
- Periodic discovery of new content
- Configurable refresh intervals
- ✅ Expiration Check Task
- Automatic cleanup of expired content
- Configurable check intervals
## 1. Initialize Your Project
#### Phase 7: Dependency Injection & Integration
- ✅ Service registration (ServiceRegistrator)
- ✅ Jellyfin provider interfaces implementation
- ✅ Plugin initialization and configuration
Make a new dotnet standard project with the following command, it will make a directory for itself.
#### Phase 8: Live Sports Streaming
- ✅ Play v3 API integration for scheduled livestreams
- ✅ Sports events folder in channel
- ✅ Upcoming event detection and display
- ✅ Akamai token authentication for streams
- ✅ Dynamic cache refresh for live events
- ✅ Event scheduling with ValidFrom/ValidTo handling
- ✅ Automatic stream URL resolution when events go live
```
dotnet new classlib -f net6.0 -n MyJellyfinPlugin
### ✅ Build Status
**Successfully compiling!** All code analysis warnings resolved.
### 🧪 Testing Status
- StreamUrlResolver tests
- MetadataCache tests
- More to be added
- [ ] API spec validation tests (nightly automated runs)
- All business units (SRF, RTS, RSI, RTR, SWI)
- Response schema validation
- Performance monitoring
- [ ] Integration testing with Jellyfin instance
- [ ] End-to-end playback testing for VOD content
- [ ] Live sports streaming validation
**Run tests:**
```bash
# All tests
dotnet test
# Unit tests only
dotnet test --filter "Category!=Integration&Category!=APISpec"
# API spec tests only
dotnet test --filter "Category=APISpec"
# With coverage
dotnet test --collect:"XPlat Code Coverage"
```
Now add the Jellyfin shared libraries.
See [Test Documentation](Jellyfin.Plugin.SRFPlay.Tests/README.md) for more details.
```
dotnet add package Jellyfin.Model
dotnet add package Jellyfin.Controller
### 📝 Next Steps
1. Test live sports streaming when events are scheduled
2. Verify Akamai token authentication
3. Test with different business units (RTS, RSI, RTR)
4. Add more unit tests for remaining services
5. Performance optimization if needed
## API Information
**Base URL:** `https://il.srgssr.ch/integrationlayer/2.0/`
### Key Endpoints
**Integration Layer API v2.0:**
- `GET /mediaComposition/byUrn/{urn}` - Get video metadata and playable URLs
- `GET /video/{businessUnit}/latest` - Get latest videos
- `GET /video/{businessUnit}/trending` - Get trending videos
**Play v3 API:**
- `GET /play/v3/api/{bu}/production/livestreams?eventType=SPORT` - Get scheduled sports livestreams
- `GET /play/v3/api/{bu}/production/shows/{id}` - Get show details
- `GET /play/v3/api/{bu}/production/videos-by-show-id?showId={id}` - Get episodes for a show
**Akamai Token Service:**
- `GET /akahd/token?acl=/{path}/*` - Authenticate stream URLs for playback
### URN Format
- `urn:{bu}:video:{id}` - Video URN (VOD content)
- `urn:{bu}:scheduled_livestream:video:{id}` - Scheduled sports livestream URN (supported)
- `urn:{bu}:video:livestream_{channel}` - Live TV channel URN (not supported due to Widevine DRM)
Examples:
- `urn:srf:video:c4927fcf-4ab4-4bcf-be4e-00e4ee9e6d6b` (VOD)
- `urn:srf:scheduled_livestream:video:7b31c9a6-a96e-4c0e-bfc2-f0d6237f2233` (Sports event)
- `urn:srf:video:c4927fcf-e1a0-0001-7edd-1ef01d441651` (SRF 1 Live - DRM protected)
## Building the Plugin
### Prerequisites
- .NET 8.0 SDK
- Jellyfin 10.9.11 or later
### Build Steps
```bash
cd Jellyfin.Plugin.SRFPlay
dotnet build
```
You have an autogenerated Class1.cs file. You won't be needing this, so go ahead and delete it.
### Output
## 2. Set Up the Basics
The compiled plugin will be in `bin/Debug/net8.0/`
There are a few mandatory classes you'll need for a plugin so we need to make them.
## Installation
### PluginConfiguration
1. Build the plugin (see above)
2. Copy the compiled DLL to your Jellyfin plugins directory
3. Restart Jellyfin
4. Configure the plugin in Jellyfin Dashboard → Plugins → SRF Play
5. The SRF Play channel will appear in Jellyfin with three main folders:
- **Latest Videos** - Recently published content
- **Trending Videos** - Popular content
- **Live Sports & Events** - Scheduled sports livestreams (skiing, F1, football, etc.)
You can call it whatever you'd like really. This class is used to hold settings your plugin might need. We can leave it empty for now. This class should inherit from `MediaBrowser.Model.Plugins.BasePluginConfiguration`
## Configuration
### Plugin
- **Business Unit**: Select the Swiss broadcasting unit (default: SRF)
- **Quality Preference**: Choose video quality (Auto/SD/HD)
- **Content Refresh Interval**: How often to check for new content (1-168 hours)
- **Expiration Check Interval**: How often to check for expired content (1-168 hours)
- **Cache Duration**: How long to cache metadata (5-1440 minutes)
- **Enable Latest Content**: Automatically discover latest videos
- **Enable Trending Content**: Automatically discover trending videos
- **Proxy Settings**: Configure proxy server for routing SRF API traffic (optional)
- **Use Proxy**: Enable/disable proxy usage
- **Proxy Address**: Proxy server URL (e.g., http://proxy.example.com:8080)
- **Proxy Username**: Optional authentication username
- **Proxy Password**: Optional authentication password
This is the main class for your plugin. It will define your name, version and Id. It should inherit from `MediaBrowser.Common.Plugins.BasePlugin<PluginConfiguration>`
For detailed proxy setup instructions, see [PROXY_SETUP_GUIDE.md](PROXY_SETUP_GUIDE.md).
Note: If you called your PluginConfiguration class something different, you need to put that between the <>
## Troubleshooting
### Implement Required Properties
If you encounter issues with the plugin:
- Check [DEBUG_GUIDE.md](DEBUG_GUIDE.md) for detailed logging information
- Enable debug logging in Jellyfin to see detailed request/response information
- Common issues include DRM-protected content and geo-restrictions
The Plugin class needs a few properties implemented before it can work correctly.
## Technical Architecture
It needs an override on ID, an override on Name, and a constructor that follows a specific model. To get started you can use the following section.
### Directory Structure
```c#
public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) : base(applicationPaths, xmlSerializer){}
public override string Name => throw new System.NotImplementedException();
public override Guid Id => Guid.Parse("");
```
Jellyfin.Plugin.SRFPlay/
├── Api/
│ ├── Models/ # API response models
│ │ ├── MediaComposition.cs
│ │ ├── Chapter.cs
│ │ ├── Resource.cs
│ │ ├── Show.cs
│ │ ├── Episode.cs
│ │ └── PlayV3/ # Play v3 API models
│ │ ├── PlayV3TvProgram.cs
│ │ └── PlayV3TvProgramGuideResponse.cs
│ └── SRFApiClient.cs # HTTP client for SRF API
├── Channels/
│ └── SRFPlayChannel.cs # Channel implementation
├── Configuration/
│ ├── PluginConfiguration.cs
│ └── configPage.html
├── Services/
│ ├── StreamUrlResolver.cs # HLS stream resolution & authentication
│ ├── MetadataCache.cs # Caching layer
│ ├── ContentExpirationService.cs # Expiration management
│ ├── ContentRefreshService.cs # Content discovery
│ └── CategoryService.cs # Topic/category management
├── Providers/
│ ├── SRFSeriesProvider.cs # Series metadata
│ ├── SRFEpisodeProvider.cs # Episode metadata
│ ├── SRFImageProvider.cs # Image fetching
│ └── SRFMediaProvider.cs # Playback URLs
├── ScheduledTasks/
│ ├── ContentRefreshTask.cs # Periodic content refresh
│ └── ExpirationCheckTask.cs # Periodic expiration check
├── ServiceRegistrator.cs # DI registration
└── Plugin.cs # Main plugin entry point
```
## 3. Customize Plugin Information
### Key Components
You need to populate some of your plugin's information. Go ahead a put in a string of the Name you've overridden name, and generate a GUID
1. **API Client**: Handles all HTTP requests to SRF Integration Layer and Play v3 API
2. **Channel**: SRF Play channel with Latest, Trending, and Live Sports folders
3. **Stream Resolver**: Extracts and selects optimal HLS streams with Akamai authentication
4. **Configuration**: User-configurable settings via Jellyfin dashboard
5. **Metadata Cache**: Thread-safe caching with dynamic TTL for livestreams
6. **Content Providers**: Jellyfin integration for series, episodes, images, and media sources
7. **Scheduled Tasks**: Automatic content refresh and expiration management
8. **Service Layer**: Content discovery, expiration handling, stream resolution, and category management
- **Windows Users**: you can use the Powershell command `New-Guid`, `[guid]::NewGuid()` or the Visual Studio GUID generator
## Important Notes
- **Linux and OS X Users**: you can use the Powershell Core command `New-Guid` or this command from your shell of choice:
### Content Limitations
```bash
od -x /dev/urandom | head -1 | awk '{OFS="-"; srand($6); sub(/./,"4",$5); sub(/./,substr("89ab",rand()*4,1),$6); print $2$3,$4,$5,$6,$7$8$9}'
```
- **No DRM content**: Only non-DRM protected content is accessible (no Widevine/FairPlay)
- **No live TV channels**: Main channels (SRF 1, SRF zwei, SRF info) use DRM and are not supported
- **Sports livestreams supported**: Scheduled sports events (skiing, F1, football, etc.) work without DRM
- **Content expiration**: SRF content has validity periods, plugin tracks and removes expired content
- **Upcoming events**: Sports events appear before they start but require channel refresh to play once live
- **No subtitles**: Subtitle support not currently implemented
or
### Extensibility
```bash
uuidgen
```
The plugin is designed to support all Swiss broadcasting units:
- **SRF** (Schweizer Radio und Fernsehen - German)
- **RTS** (Radio Télévision Suisse - French)
- **RSI** (Radiotelevisione svizzera - Italian)
- **RTR** (Radiotelevisiun Svizra Rumantscha - Romansh)
- **SWI** (Swiss World International)
- Place that guid inside the `Guid.Parse("")` quotes to define your plugin's ID.
Currently focused on SRF but easily extensible.
## 4. Adding Functionality
## Development
Congratulations, you now have everything you need for a perfectly functional functionless Jellyfin plugin! You can try it out right now if you'd like by compiling it, then placing the dll you generate in the plugins folder under your Jellyfin config directory. If you want to try and hook it up to a debugger make sure you copy the generated PDB file alongside it.
### Current Status
Most people aren't satisfied with just having an entry in a menu for their plugin, most people want to have some functionality, so lets look at how to add it.
All core functionality is implemented and compiling successfully! The plugin includes:
- Complete API integration with SRF Play (Integration Layer v2.0 and Play v3)
- **Live sports streaming** with scheduled event detection
- Channel with Latest, Trending, and Live Sports folders
- Metadata providers for series and episodes
- Image fetching and caching
- HLS stream playback with Akamai token authentication
- Automatic content expiration handling
- Scheduled tasks for content refresh
- Smart caching with dynamic TTL for upcoming livestreams
### 4a. Implement Interfaces
### Testing
If the functionality you are trying to add is functionality related to something that Jellyfin has an interface for you're in luck. Jellyfin uses some automatic discovery and injection to allow any interfaces you implement in your plugin to be available in Jellyfin.
To test the plugin:
Here's some interfaces you could implement for common use cases:
1. Build the plugin: `dotnet build`
2. Copy `bin/Debug/net8.0/Jellyfin.Plugin.SRFPlay.dll` to your Jellyfin plugins directory
3. Restart Jellyfin
4. Configure the plugin in Dashboard → Plugins → SRF Play
5. Add a library with the SRF Play content provider
- **IAuthenticationProvider** - Allows you to add an authentication provider that can authenticate a user based on a name and a password, but that doesn't expect to deal with local users.
- **IBaseItemComparer** - Allows you to add sorting rules for dealing with media that will show up in sort menus
- **IIntroProvider** - Allows you to play a piece of media before another piece of media (i.e. a trailer before a movie, or a network bumper before an episode of a show)
- **IItemResolver** - Allows you to define custom media types
- **ILibraryPostScanTask** - Allows you to define a task that fires after scanning a library
- **IMetadataSaver** - Allows you to define a metadata standard that Jellyfin can use to write metadata
- **IResolverIgnoreRule** - Allows you to define subpaths that are ignored by media resolvers for use with another function (i.e. you wanted to have a theme song for each tv series stored in a subfolder that could be accessed by your plugin for playback in a menu).
- **IScheduledTask** - Allows you to create a scheduled task that will appear in the scheduled task lists on the dashboard.
### Known Limitations
There are loads of other interfaces that can be used, but you'll need to poke around the API to get some info. If you're an expert on a particular interface, you should help [contribute some documentation](https://docs.jellyfin.org/general/contributing/index.html)!
- This is a first version that has not been tested with a live Jellyfin instance yet
- Some edge cases may need handling
- Performance optimization may be needed for large content catalogs
### 4b. Use plugin aimed interfaces to add custom functionality
### Contributing
If your plugin doesn't fit perfectly neatly into a predefined interface, never fear, there are a set of interfaces and classes that allow your plugin to extend Jellyfin any which way you please. Here's a quick overview on how to use them
This plugin is in active development. Contributions welcome!
- **IPluginConfigurationPage** - Allows you to have a plugin config page on the dashboard. If you used one of the quickstart example projects, a premade page with some useful components to work with has been created for you! If not you can check out this guide here for how to whip one up.
## License
- **IServerEntryPoint** - Allows you to run code at server startup that will stay in memory. You can make as many of these as you need and it is wildly useful for loading configs or persisting state. **Be aware that your main plugin class (IBasePlugin) cannot also be a IServerEntryPoint.**
See LICENSE file for details.
- **ControllerBase** - Allows you to define custom REST-API endpoints. This is the default ASP.NET Web-API controller. You can use it exactly as you would in a normal Web-API project. Learn more about it [here](https://docs.microsoft.com/aspnet/core/web-api/?view=aspnetcore-5.0).
## Acknowledgments
Likewise you might need to get data and services from the Jellyfin core, Jellyfin provides a number of interfaces you can add as parameters to your plugin constructor which are then made available in your project (you can see the 2 mandatory ones that are needed by the plugin system in the constructor as is).
This plugin was developed with inspiration from the excellent [Kodi SRG SSR addon](https://github.com/goggle/script.module.srgssr) by [@goggle](https://github.com/goggle). The Kodi addon served as a fantastic reference for understanding the SRG SSR API structure, authentication mechanisms, and handling of scheduled livestreams.
- **IBlurayExaminer** - Allows you to examine blu-ray folders
- **IDtoService** - Allows you to create data transport objects, presumably to send to other plugins or to the core
- **ILibraryManager** - Allows you to directly access the media libraries without hopping through the API
- **ILocalizationManager** - Allows you tap into the main localization engine which governs translations, rating systems, units etc...
- **INetworkManager** - Allows you to get information about the server's networking status
- **IServerApplicationPaths** - Allows you to get the running server's paths
- **IServerConfigurationManager** - Allows you to write or read server configuration data into the application paths
- **ITaskManager** - Allows you to execute and manipulate scheduled tasks
- **IUserManager** - Allows you to retrieve user info and user library related info
- **IXmlSerializer** - Allows you to use the main xml serializer
- **IZipClient** - Allows you to use the core zip client for compressing and decompressing data
## References
## 5. Create a Repository
- [See blog post](https://jellyfin.org/posts/plugin-updates/)
## 6. Set Up Debugging
Debugging can be set up by creating tasks which will be executed when running the plugin project. The specifics on setting up these tasks are not included as they may differ from IDE to IDE. The following list describes the general process:
- Compile the plugin in debug mode.
- Create the plugin directory if it doesn't exist.
- Copy the plugin into your server's plugin directory. The server will then execute it.
- Make sure to set the working directory of the program being debugged to the working directory of the Jellyfin Server.
- Start the server.
Some IDEs like Visual Studio Code may need the following compile flags to compile the plugin:
```shell
dotnet build Your-Plugin.sln /property:GenerateFullPaths=true /consoleloggerparameters:NoSummary
```
These flags generate the full paths for file names and **do not** generate a summary during the build process as this may lead to duplicate errors in the problem panel of your IDE.
### 6.a Set Up Debugging on Visual Studio
Visual Studio allows developers to connect to other processes and debug them, setting breakpoints and inspecting the variables of the program. We can set this up following this steps:
On this section we will explain how to set up our solution to enable debugging before the server starts.
1. Right-click on the solution, And click on Add -> Existing Project...
2. Locate Jellyfin executable in your installation folder and click on 'Open'. It is called `Jellyfin.exe`. Now The solution will have a new "Project" called Jellyfin. This is the executable, not the source code of Jellyfin.
3. Right-click on this new project and click on 'Set up as Startup Project'
4. Right-click on this new project and click on 'Properties'
5. Make sure that the 'Attach' parameter is set to 'No'
From now on, everytime you click on start from Visual Studio, it will start Jellyfin attached to the debugger!
The only thing left to do is to compile the project as it is specified a few lines above and you are done.
### 6.b Automate the Setup on Visual Studio Code
Visual Studio Code allows developers to automate the process of starting all necessary dependencies to start debugging the plugin. This guide assumes the reader is familiar with the [documentation on debugging in Visual Studio Code](https://code.visualstudio.com/docs/editor/debugging) and has read the documentation in this file. It is assumed that the Jellyfin Server has already been compiled once. However, should one desire to automatically compile the server before the start of the debugging session, this can be easily implemented, but is not further discussed here.
A full example, which aims to be portable may be found in this repo's `.vscode` folder.
This example expects you to clone `jellyfin`, `jellyfin-web` and `jellyfin-plugin-template` under the same parent directory, though you can customize this in `settings.json`
1. Create a `settings.json` file inside your `.vscode` folder, to specify common options specific to your local setup.
```jsonc
{
// jellyfinDir : The directory of the cloned jellyfin server project
// This needs to be built once before it can be used
"jellyfinDir" : "${workspaceFolder}/../jellyfin/Jellyfin.Server",
// jellyfinWebDir : The directory of the cloned jellyfin-web project
// This needs to be built once before it can be used
"jellyfinWebDir" : "${workspaceFolder}/../jellyfin-web",
// jellyfinDataDir : the root data directory for a running jellyfin instance
// This is where jellyfin stores its configs, plugins, metadata etc
// This is platform specific by default, but on Windows defaults to
// ${env:LOCALAPPDATA}/jellyfin
"jellyfinDataDir" : "${env:LOCALAPPDATA}/jellyfin",
// The name of the plugin
"pluginName" : "Jellyfin.Plugin.Template",
}
```
1. To automate the launch process, create a new `launch.json` file for C# projects inside the `.vscode` folder. The example below shows only the relevant parts of the file. Adjustments to your specific setup and operating system may be required.
```jsonc
{
// Paths and plugin names are configured in settings.json
"version": "0.2.0",
"configurations": [
{
"type": "coreclr",
"name": "Launch",
"request": "launch",
"preLaunchTask": "build-and-copy",
"program": "${config:jellyfinDir}/bin/Debug/net6.0/jellyfin.dll",
"args": [
//"--nowebclient"
"--webdir",
"${config:jellyfinWebDir}/dist/"
],
"cwd": "${config:jellyfinDir}",
}
]
}
```
The `request` type is specified as `launch`, as this `launch.json` file will start the Jellyfin Server process. The `preLaunchTask` defines a task that will run before the Jellyfin Server starts. More on this later. It is important to set the `program` path to the Jellyin Server program and set the current working directory (`cwd`) to the working directory of the Jellyfin Server.
The `args` option allows to specify arguments to be passed to the server, e.g. whether Jellyfin should start with the web-client or without it.
2. Create a `tasks.json` file inside your `.vscode` folder and specify a `build-and-copy` task that will run in `sequence` order. This tasks depends on multiple other tasks and all of those other tasks can be defined as simple `shell` tasks that run commands like the `cp` command to copy a file. The sequence to run those tasks in is given below. Please note that it might be necessary to adjust the examples for your specific setup and operating system.
The full file is shown here - Specific sections will be discussed in depth
```jsonc
{
// Paths and plugin name are configured in settings.json
"version": "2.0.0",
"tasks": [
{
// A chain task - build the plugin, then copy it to your
// jellyfin server's plugin directory
"label": "build-and-copy",
"dependsOrder": "sequence",
"dependsOn": ["build", "make-plugin-dir", "copy-dll"]
},
{
// Build the plugin
"label": "build",
"command": "dotnet",
"type": "shell",
"args": [
"publish",
"${workspaceFolder}/${config:pluginName}.sln",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"group": "build",
"presentation": {
"reveal": "silent"
},
"problemMatcher": "$msCompile"
},
{
// Ensure the plugin directory exists before trying to use it
"label": "make-plugin-dir",
"type": "shell",
"command": "mkdir",
"args": [
"-Force",
"-Path",
"${config:jellyfinDataDir}/plugins/${config:pluginName}/"
]
},
{
// Copy the plugin dll to the jellyfin plugin install path
// This command copies every .dll from the build directory to the plugin dir
// Usually, you probablly only need ${config:pluginName}.dll
// But some plugins may bundle extra requirements
"label": "copy-dll",
"type": "shell",
"command": "cp",
"args": [
"./${config:pluginName}/bin/Debug/net6.0/publish/*",
"${config:jellyfinDataDir}/plugins/${config:pluginName}/"
]
},
]
}
```
1. The "build-and-copy" task which triggers all of the other tasks
```jsonc
{
// A chain task - build the plugin, then copy it to your
// jellyfin server's plugin directory
"label": "build-and-copy",
"dependsOrder": "sequence",
"dependsOn": ["build", "make-plugin-dir", "copy-dll"]
},
```
2. A build task. This task builds the plugin without generating summary, but with full paths for file names enabled.
```jsonc
{
// Build the plugin
"label": "build",
"command": "dotnet",
"type": "shell",
"args": [
"publish",
"${workspaceFolder}/${config:pluginName}.sln",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"group": "build",
"presentation": {
"reveal": "silent"
},
"problemMatcher": "$msCompile"
},
```
3. A tasks which creates the necessary plugin directory and a sub-folder for the specific plugin. The plugin directory is located below the [data directory](https://jellyfin.org/docs/general/administration/configuration.html) of the Jellyfin Server. As an example, the following path can be used for the bookshelf plugin: `$HOME/.local/share/jellyfin/plugins/Bookshelf/`
```jsonc
{
// Ensure the plugin directory exists before trying to use it
"label": "make-plugin-dir",
"type": "shell",
"command": "mkdir",
"args": [
"-Force",
"-Path",
"${config:jellyfinDataDir}/plugins/${config:pluginName}/"
]
},
```
4. A tasks which copies the plugin dll which has been built in step 2.1. The file is copied into it's specific plugin directory within the server's plugin directory.
```jsonc
{
// Copy the plugin dll to the jellyfin plugin install path
// This command copies every .dll from the build directory to the plugin dir
// Usually, you probablly only need ${config:pluginName}.dll
// But some plugins may bundle extra requirements
"label": "copy-dll",
"type": "shell",
"command": "cp",
"args": [
"./${config:pluginName}/bin/Debug/net6.0/publish/*",
"${config:jellyfinDataDir}/plugins/${config:pluginName}/"
]
},
```
## Licensing
Licensing is a complex topic. This repository features a GPLv3 license template that can be used to provide a good default license for your plugin. You may alter this if you like, but if you do a permissive license must be chosen.
Due to how plugins in Jellyfin work, when your plugin is compiled into a binary, it will link against the various Jellyfin binary NuGet packages. These packages are licensed under the GPLv3. Thus, due to the nature and restrictions of the GPL, the binary plugin you get will also be licensed under the GPLv3.
If you accept the default GPLv3 license from this template, all will be good. However if you choose a different license, please keep this fact in mind, as it might not always be obvious that an, e.g. MIT-licensed plugin would become GPLv3 when compiled.
Please note that this also means making "proprietary", source-unavailable, or otherwise "hidden" plugins for public consumption is not permitted. To build a Jellyfin plugin for distribution to others, it must be under the GPLv3 or a permissive open-source license that can be linked against the GPLv3.
- [SRF Play](https://www.srf.ch/play)
- [Jellyfin Plugin Documentation](https://jellyfin.org/docs/general/server/plugins/)
- [SRG SSR Integration Layer API](https://il.srgssr.ch/)
- [Kodi SRG SSR Addon](https://github.com/goggle/script.module.srgssr) - Reference implementation
+319
View File
@@ -0,0 +1,319 @@
# Testing Guide for SRF Play Plugin
This guide explains how to run tests and set up the nightly API validation for the Jellyfin SRF Play plugin.
## Overview
The plugin now has a comprehensive test suite:
1. **Unit Tests** - Fast, isolated tests for individual components
2. **Integration Tests (API Spec Tests)** - Real API calls to validate the SRF Play API is working correctly
3. **Nightly CI Tests** - Automated nightly runs to detect API changes
## Prerequisites
### Quick Setup
Run the setup script to check your environment:
```bash
./setup-tests.sh
```
### Required Software
**.NET 8.0 SDK** (Required - matches Jellyfin requirements)
```bash
# Arch Linux/CachyOS
sudo pacman -S dotnet-sdk-8.0 aspnet-runtime-8.0
# Or download from:
# https://dotnet.microsoft.com/download/dotnet/8.0
```
**Verify Installation:**
```bash
dotnet --list-runtimes
# Should show:
# Microsoft.NETCore.App 8.x.x
```
**Other Requirements:**
- Internet connection (for integration/API tests)
## Running Tests Locally
### All Tests
```bash
dotnet test
```
### Unit Tests Only
```bash
dotnet test --filter "Category!=Integration&Category!=APISpec"
```
### API Spec Tests Only
```bash
dotnet test --filter "Category=APISpec"
```
### With Code Coverage
```bash
dotnet test --collect:"XPlat Code Coverage"
```
### With Detailed Output
```bash
dotnet test --logger "console;verbosity=detailed"
```
## Test Structure
### Unit Tests ([Jellyfin.Plugin.SRFPlay.Tests/UnitTests/](Jellyfin.Plugin.SRFPlay.Tests/UnitTests/))
- **StreamUrlResolverTests.cs** - Tests stream URL resolution, DRM filtering, expiration checking
- **MetadataCacheTests.cs** - Tests metadata caching, expiration, thread safety
**Characteristics:**
- Fast execution (milliseconds)
- No external dependencies
- Run on every commit/PR
### Integration Tests ([Jellyfin.Plugin.SRFPlay.Tests/IntegrationTests/](Jellyfin.Plugin.SRFPlay.Tests/IntegrationTests/))
- **SRFApiSpecTests.cs** - Validates SRF Play API compliance
- Tests all business units (SRF, RTS, RSI, RTR, SWI)
- Validates response schemas
- Tests API endpoints accessibility
- Validates HLS stream availability
- Performance monitoring
**Characteristics:**
- Slower execution (seconds to minutes)
- Makes real API calls
- Run nightly via CI
## Continuous Integration
### Unit Tests Workflow
**File:** [.github/workflows/unit-tests.yaml](.github/workflows/unit-tests.yaml)
**Triggers:**
- Push to master branch
- Pull requests to master
- Manual trigger
**Features:**
- Runs all unit tests
- Generates code coverage reports
- Posts coverage summary on PRs
- Fails if tests fail
### Nightly API Spec Tests Workflow
**File:** [.github/workflows/nightly-api-tests.yaml](.github/workflows/nightly-api-tests.yaml)
**Schedule:** Every night at 2 AM UTC
**Features:**
- Validates SRF Play API is still working
- Tests all business units
- Validates response schemas
- **Automatically creates a GitHub issue if tests fail**
- Provides detailed test reports
**What happens when tests fail:**
- A GitHub issue is automatically created with:
- Link to the failed workflow run
- Description of what likely changed
- Suggested actions to take
- Labels: `bug`, `api`, `nightly-test-failure`
## Adding New Tests
### Unit Test Example
```csharp
using Xunit;
using FluentAssertions;
namespace Jellyfin.Plugin.SRFPlay.Tests.UnitTests;
public class MyServiceTests
{
[Fact]
public void MyMethod_WithValidInput_ReturnsExpectedResult()
{
// Arrange
var service = new MyService();
// Act
var result = service.MyMethod("test");
// Assert
result.Should().Be("expected");
}
}
```
### Integration Test Example
```csharp
using Xunit;
using FluentAssertions;
namespace Jellyfin.Plugin.SRFPlay.Tests.IntegrationTests;
[Trait("Category", "Integration")]
[Trait("Category", "APISpec")]
public class MyApiTests
{
[Fact]
public async Task ApiCall_ReturnsValidData()
{
// Arrange
var client = new SRFApiClient(loggerFactory);
// Act
var result = await client.GetDataAsync("srf", cancellationToken);
// Assert
result.Should().NotBeNull();
result.Should().NotBeEmpty();
}
}
```
## Test Naming Conventions
Follow the pattern: `MethodName_Scenario_ExpectedBehavior`
**Good Examples:**
- `GetStreamUrl_WithDrmProtectedOnly_ReturnsNull`
- `IsContentExpired_WithPastValidTo_ReturnsTrue`
- `GetAllShows_SRF_ReturnsShows`
## What to Do When Tests Fail
### Unit Tests Fail
1. Check the error message in the test output
2. Review recent code changes
3. Fix the bug or update the test if behavior changed intentionally
4. Run tests locally before pushing
### API Spec Tests Fail (Nightly)
1. **Check the GitHub issue** created automatically
2. **Review the workflow logs** for detailed error messages
3. **Common causes:**
- SRF Play API schema changed
- New authentication requirements
- Endpoints moved or deprecated
- Rate limiting or temporary outages
4. **Actions to take:**
- Update API models in [Jellyfin.Plugin.SRFPlay/Api/Models/](Jellyfin.Plugin.SRFPlay/Api/Models/)
- Update API client in [SRFApiClient.cs](Jellyfin.Plugin.SRFPlay/Api/SRFApiClient.cs)
- Update tests to match new behavior
- Document any breaking changes
### Temporary API Outages
If the API is temporarily down:
1. Monitor the issue - it will auto-close on next successful run
2. No action needed unless failures persist for multiple days
## Code Coverage
Code coverage reports are generated automatically for unit tests in CI.
**View coverage locally:**
```bash
dotnet test --collect:"XPlat Code Coverage"
# Coverage reports will be in TestResults/*/coverage.cobertura.xml
```
**Target:** Aim for >70% coverage for core services
## Performance Benchmarks
API spec tests include performance validation:
- API calls should complete within 30 seconds
- Failures indicate potential performance degradation
## Best Practices
1. **Write tests first** (TDD) when fixing bugs
2. **Keep unit tests fast** - under 100ms per test
3. **Use descriptive test names** that explain what's being tested
4. **One assertion per test** for clear failure messages
5. **Clean up resources** with IDisposable
6. **Mock external dependencies** in unit tests
7. **Use real APIs** only in integration tests
## Troubleshooting
### Tests won't run locally
```bash
# Ensure .NET 8.0 SDK is installed
dotnet --list-sdks
# If not installed, download from:
# https://dotnet.microsoft.com/download/dotnet/8.0
```
### Integration tests fail with network errors
- Check internet connectivity
- Check if SRF Play API is accessible from your location
- Some regions may have geo-restrictions
### Build succeeds but tests won't execute
```bash
# Clean and rebuild
dotnet clean
dotnet build
dotnet test
```
## Legacy Tests
The project still contains legacy console-based tests:
- [Program.cs](Jellyfin.Plugin.SRFPlay.Tests/Program.cs)
- [TestPlayV3Api.cs](Jellyfin.Plugin.SRFPlay.Tests/TestPlayV3Api.cs)
These are kept for manual testing but are not run by CI. To run them:
```bash
cd Jellyfin.Plugin.SRFPlay.Tests
dotnet run
```
## Future Improvements
- [ ] Add more unit tests for remaining services
- [ ] Add tests for scheduled task functionality
- [ ] Add tests for proxy configuration
- [ ] Increase code coverage to >80%
- [ ] Add mutation testing
- [ ] Add performance benchmarks
## Questions?
- Review [Test Documentation](Jellyfin.Plugin.SRFPlay.Tests/README.md)
- Check [GitHub Actions](../../actions) for CI results
- Look at existing tests for examples
## Summary
With this testing infrastructure:
- ✅ **Developers** get immediate feedback on code changes
- ✅ **Maintainers** are automatically notified of API changes
- ✅ **Users** benefit from more reliable plugin
- ✅ **Contributors** have clear examples to follow
+92
View File
@@ -0,0 +1,92 @@
# SRF Play Plugin - Usage Guide
## Finding the Channel in Jellyfin
After installing and enabling the plugin, you can access SRF Play content through the **Channels** feature in Jellyfin.
### Step-by-Step Instructions:
1. **Open Jellyfin** in your web browser
2. **Navigate to Channels**:
- On the Jellyfin home screen, look in the left sidebar menu
- Click on **"Live TV & Channels"** or **"Channels"**
- You should see "SRF Play" listed among your available channels
3. **Browse Content**:
- Click on the "SRF Play" channel
- You'll see two main folders:
- **Latest Videos** - Recently published SRF content
- **Trending Videos** - Popular content from SRF
4. **Watch Videos**:
- Click into either folder to browse videos
- Click on any video to play it
- Videos will stream directly via HLS
### Alternative Navigation:
If you don't see a "Channels" section in the sidebar:
1. Go to **Dashboard** (gear icon or Admin menu)
2. Click on **"Live TV"** in the left menu
3. Look for **"Channels"** section
4. Enable the "SRF Play" channel if it's not already enabled
### Configuration:
To configure the plugin (change business unit, quality, etc.):
1. Go to **Dashboard** → **Plugins**
2. Find **"SRF Play"** in the list
3. Click on it to access settings
4. Configure:
- **Business Unit**: SRF, RTS, RSI, RTR, or SWI
- **Quality Preference**: Auto, SD, or HD
- **Enable Latest Content**: Show latest videos
- **Enable Trending Content**: Show trending videos
### Troubleshooting:
**Channel not appearing?**
- Restart Jellyfin after installing the plugin
- Check Dashboard → Plugins to ensure SRF Play is enabled
- Check the plugin configuration (Dashboard → Plugins → SRF Play)
**No videos showing?**
- Ensure "Enable Latest Content" and/or "Enable Trending Content" are checked in plugin settings
- Check Jellyfin logs for any error messages (Dashboard → Logs)
- Verify your Jellyfin server has internet access to reach `il.srgssr.ch`
**Videos won't play?**
- Check your Jellyfin server can access HLS streams
- Verify firewall settings allow outbound HTTPS connections
- Check the quality setting - try "Auto" if HD/SD isn't working
### What Content is Available?
- **Latest Videos**: The most recently published content from the selected business unit (SRF, RTS, etc.)
- **Trending Videos**: Popular/trending content from the business unit
- All videos are VOD (Video on Demand) - no live TV channels due to DRM restrictions
### Content Expiration:
SRF content has expiration dates. The plugin:
- Automatically filters out expired content
- Runs scheduled tasks to remove expired items from your library
- Checks expiration every 24 hours (configurable)
### Notes:
- Only non-DRM content is accessible (no Widevine protected streams)
- Live TV channels are not available due to DRM
- Content availability depends on the selected business unit and SRF's content policies
- The plugin shows up to 50 videos per category
## Need Help?
If you encounter issues:
1. Check Jellyfin logs (Dashboard → Logs)
2. Verify plugin configuration
3. Ensure your network allows access to `il.srgssr.ch`
4. Report issues on the plugin's GitHub repository
+14 -9
View File
@@ -1,16 +1,21 @@
---
name: "Template"
name: "SRFPlay"
guid: "eb5d7894-8eef-4b36-aa6f-5d124e828ce1"
version: "1.0.0.0"
targetAbi: "10.8.0.0"
framework: "net6.0"
overview: "Short description about your plugin"
targetAbi: "10.9.0.0"
framework: "net8.0"
overview: "SRF Play plugin for Jellyfin"
description: >
This is a longer description that can span more than one
line and include details about your plugin.
category: "General"
Provides access to SRF Play content in Jellyfin, including
live streams and on-demand content from Swiss Radio and Television.
category: "LiveTV"
owner: "jellyfin"
imageUrl: "https://www.srf.ch/static/img/srf-logo.svg"
artifacts:
- "Jellyfin.Plugin.Template.dll"
- "Jellyfin.Plugin.SRFPlay.dll"
build_type: "dotnet"
dotnet_configuration: "Release"
dotnet_framework: "net8.0"
project: "Jellyfin.Plugin.SRFPlay/Jellyfin.Plugin.SRFPlay.csproj"
changelog: >
changelog
Initial release
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Executable
+74
View File
@@ -0,0 +1,74 @@
#!/bin/bash
# Setup script for SRF Play Plugin Tests
# This script helps set up the testing environment
echo "=== SRF Play Plugin - Test Environment Setup ==="
echo
# Check .NET version
echo "Checking .NET installation..."
dotnet --version
echo
# Check SDKs
echo "Installed .NET SDKs:"
dotnet --list-sdks
echo
# Check runtimes
echo "Installed .NET Runtimes:"
dotnet --list-runtimes
echo
# Check if .NET 8 runtime is installed
if dotnet --list-runtimes | grep -q "Microsoft.NETCore.App 8."; then
echo "✓ .NET 8 runtime is installed"
NET8_INSTALLED=true
else
echo "✗ .NET 8 runtime is NOT installed"
NET8_INSTALLED=false
fi
# Check if ASP.NET Core 9 runtime is installed
if dotnet --list-runtimes | grep -q "Microsoft.AspNetCore.App 9."; then
echo "✓ ASP.NET Core 9 runtime is installed"
ASPNET9_INSTALLED=true
else
echo "✗ ASP.NET Core 9 runtime is NOT installed"
ASPNET9_INSTALLED=false
fi
echo
echo "=== Test Execution Options ==="
echo
if [ "$NET8_INSTALLED" = true ]; then
echo "✅ Ready to run tests with .NET 8"
echo " dotnet test"
echo
else
echo "⚠️ .NET 8 runtime required (to match Jellyfin requirements)"
echo
echo "Install .NET 8:"
echo " For Arch Linux/CachyOS:"
echo " sudo pacman -S dotnet-sdk-8.0 aspnet-runtime-8.0"
echo
echo " Or download from:"
echo " https://dotnet.microsoft.com/download/dotnet/8.0"
echo
fi
echo "Option 3: Run tests in GitHub Actions (always works)"
echo " - Tests run automatically on push/PR"
echo " - Nightly API tests run at 2 AM UTC"
echo
echo "=== Quick Test Commands ==="
echo "Build: dotnet build"
echo "All tests: dotnet test"
echo "Unit tests: dotnet test --filter \"Category!=Integration&Category!=APISpec\""
echo "API tests: dotnet test --filter \"Category=APISpec\""
echo
echo "For more information, see TESTING_GUIDE.md"