Compare commits
77
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a2d6a558c | ||
|
|
fb539d6a32 | ||
|
|
6ba5df6be9 | ||
|
|
33209c3b33 | ||
|
|
2177ef9814 | ||
|
|
87bdec280b | ||
|
|
5f6e928409 | ||
|
|
c1b5dea569 | ||
|
|
2879ebf4df | ||
|
|
b0cdcda900 | ||
|
|
5fadf5a9a7 | ||
|
|
a6ae4994e9 | ||
|
|
12af9bddb1 | ||
|
|
92a840cf42 | ||
|
|
9210532cf1 | ||
|
|
66d5faead2 | ||
|
|
65c7a16cee | ||
|
|
46684402e6 | ||
|
|
d75e69249b | ||
|
|
7c76402a4a | ||
|
|
873e531599 | ||
|
|
5c898a98f5 | ||
|
|
c282a98377 | ||
|
|
92dc6d8203 | ||
|
|
d313b68975 | ||
|
|
60434abd01 | ||
|
|
5ace3f4296 | ||
|
|
757aab1943 | ||
|
|
23d8da9ae7 | ||
|
|
2631c93444 | ||
|
|
7f71d3419c | ||
|
|
dbbdd7eb6d | ||
|
|
9146830546 | ||
|
|
0548fe7dec | ||
|
|
198fc4c58d | ||
|
|
ed4cc0990c | ||
|
|
4f9ebe2bce | ||
|
|
14b6c33542 | ||
|
|
a0e7663323 | ||
|
|
0fea57a4f9 | ||
|
|
89c41842a7 | ||
|
|
89a911b9c4 | ||
|
|
b8ac466c90 | ||
|
|
e26f2a2ab1 | ||
|
|
cd0f680981 | ||
|
|
cfe510e15c | ||
|
|
8e86db100a | ||
|
|
d48b515898 | ||
|
|
31b2402a96 | ||
|
|
3691b4e30e | ||
|
|
87ccf80813 | ||
|
|
6ed53054c3 | ||
|
|
1a8b7a33ef | ||
|
|
e5f82c53da | ||
|
|
fddad35cf4 | ||
|
|
ce6c435a92 | ||
|
|
d830f8ae5f | ||
|
|
def475d0f3 | ||
|
|
70bcdf7103 | ||
|
|
c3e3008d57 | ||
|
|
a3fc82f13c | ||
|
|
24ed1de959 | ||
|
|
eb6bfad0d2 | ||
|
|
a8d3968925 | ||
|
|
ac6a3842dd | ||
|
|
d544b71939 | ||
|
|
12988e127f | ||
|
|
e9312af2c2 | ||
|
|
4291891dfd | ||
|
|
e9c9f334cd | ||
|
|
3afa1fc407 | ||
|
|
2c1143b49f | ||
|
|
74da0d2568 | ||
|
|
2ddd08221d | ||
|
|
7fe332f29c | ||
|
|
4de36a8abc | ||
|
|
66bac259a8 |
@@ -0,0 +1,62 @@
|
||||
name: '🏗️ Build Plugin'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths-ignore:
|
||||
- '**/*.md'
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
paths-ignore:
|
||||
- '**/*.md'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: linux/amd64
|
||||
container:
|
||||
image: gitea.tourolle.paris/dtourolle/srfplay-builder:latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
path: build-${{ github.run_id }}
|
||||
|
||||
- name: Restore dependencies
|
||||
working-directory: build-${{ github.run_id }}
|
||||
run: dotnet restore Jellyfin.Plugin.SRFPlay.sln
|
||||
|
||||
- name: Build solution
|
||||
working-directory: build-${{ github.run_id }}
|
||||
run: dotnet build Jellyfin.Plugin.SRFPlay.sln --configuration Release --no-restore --no-self-contained /m:1
|
||||
|
||||
- name: Run tests
|
||||
working-directory: build-${{ github.run_id }}
|
||||
run: dotnet test Jellyfin.Plugin.SRFPlay.sln --no-build --configuration Release --verbosity normal
|
||||
|
||||
- name: Build Jellyfin Plugin
|
||||
id: jprm
|
||||
working-directory: build-${{ github.run_id }}
|
||||
run: |
|
||||
mkdir -p artifacts
|
||||
jprm --verbosity=debug plugin build .
|
||||
ARTIFACT=$(find . -name "*.zip" -type f -print -quit | sed 's|^\./||')
|
||||
LATEST="artifacts/srfplay_latest.zip"
|
||||
cp "${ARTIFACT}" "${LATEST}"
|
||||
echo "artifact=${LATEST}" >> $GITHUB_OUTPUT
|
||||
echo "Found artifact: ${ARTIFACT} -> ${LATEST}"
|
||||
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: jellyfin-srfplay-plugin
|
||||
path: build-${{ github.run_id }}/${{ steps.jprm.outputs.artifact }}
|
||||
retention-days: 30
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
run: rm -rf build-${{ github.run_id }}
|
||||
@@ -0,0 +1,164 @@
|
||||
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: linux/amd64
|
||||
container:
|
||||
image: gitea.tourolle.paris/dtourolle/srfplay-builder:latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
path: release-${{ github.run_id }}
|
||||
|
||||
- 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
|
||||
working-directory: release-${{ github.run_id }}
|
||||
run: |
|
||||
VERSION="${{ steps.get_version.outputs.version_number }}"
|
||||
sed -i "s/^version:.*/version: \"${VERSION}\"/" build.yaml
|
||||
cat build.yaml
|
||||
|
||||
- name: Restore dependencies
|
||||
working-directory: release-${{ github.run_id }}
|
||||
run: dotnet restore Jellyfin.Plugin.SRFPlay.sln
|
||||
|
||||
- name: Build solution
|
||||
working-directory: release-${{ github.run_id }}
|
||||
run: dotnet build Jellyfin.Plugin.SRFPlay.sln --configuration Release --no-restore --no-self-contained /m:1
|
||||
|
||||
- name: Run tests
|
||||
working-directory: release-${{ github.run_id }}
|
||||
run: dotnet test Jellyfin.Plugin.SRFPlay.sln --no-build --configuration Release --verbosity normal
|
||||
|
||||
- name: Build Jellyfin Plugin
|
||||
id: jprm
|
||||
working-directory: release-${{ github.run_id }}
|
||||
run: |
|
||||
mkdir -p artifacts
|
||||
jprm --verbosity=debug plugin build ./
|
||||
ARTIFACT=$(find . -name "*.zip" -type f -print -quit | sed 's|^\./||')
|
||||
ARTIFACT_NAME=$(basename "${ARTIFACT}")
|
||||
echo "artifact=${ARTIFACT}" >> $GITHUB_OUTPUT
|
||||
echo "artifact_name=${ARTIFACT_NAME}" >> $GITHUB_OUTPUT
|
||||
echo "Found artifact: ${ARTIFACT}"
|
||||
|
||||
- name: Calculate checksum
|
||||
id: checksum
|
||||
working-directory: release-${{ github.run_id }}
|
||||
run: |
|
||||
CHECKSUM=$(md5sum "${{ steps.jprm.outputs.artifact }}" | awk '{print $1}')
|
||||
echo "checksum=${CHECKSUM}" >> $GITHUB_OUTPUT
|
||||
echo "Checksum: ${CHECKSUM}"
|
||||
|
||||
- name: Create Release
|
||||
working-directory: release-${{ github.run_id }}
|
||||
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 }}"
|
||||
|
||||
# Create release using Gitea API
|
||||
VERSION="${{ steps.get_version.outputs.version }}"
|
||||
RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases" \
|
||||
-d "$(jq -n --arg tag "$VERSION" --arg name "Release $VERSION" --arg body "SRFPlay Jellyfin Plugin. See attached files for plugin installation." '{tag_name: $tag, name: $name, body: $body, draft: false, prerelease: false}')")
|
||||
|
||||
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 }}"
|
||||
|
||||
- name: Update manifest.json
|
||||
working-directory: release-${{ github.run_id }}
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
REPO_OWNER="${{ github.repository_owner }}"
|
||||
REPO_NAME="${{ github.event.repository.name }}"
|
||||
GITEA_URL="${{ github.server_url }}"
|
||||
VERSION="${{ steps.get_version.outputs.version_number }}"
|
||||
CHECKSUM="${{ steps.checksum.outputs.checksum }}"
|
||||
ARTIFACT_NAME="${{ steps.jprm.outputs.artifact_name }}"
|
||||
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
DOWNLOAD_URL="${GITEA_URL}/${REPO_OWNER}/${REPO_NAME}/releases/download/${{ steps.get_version.outputs.version }}/${ARTIFACT_NAME}"
|
||||
|
||||
git config user.name "Gitea Actions"
|
||||
git config user.email "actions@gitea.tourolle.paris"
|
||||
git fetch origin master
|
||||
git checkout master
|
||||
|
||||
NEW_VERSION=$(cat <<EOF
|
||||
{
|
||||
"version": "${VERSION}",
|
||||
"changelog": "Release ${VERSION}",
|
||||
"targetAbi": "10.9.0.0",
|
||||
"sourceUrl": "${DOWNLOAD_URL}",
|
||||
"checksum": "${CHECKSUM}",
|
||||
"timestamp": "${TIMESTAMP}"
|
||||
}
|
||||
EOF
|
||||
)
|
||||
|
||||
jq --argjson newver "${NEW_VERSION}" '.[0].versions = [$newver] + .[0].versions' manifest.json > manifest.tmp && mv manifest.tmp manifest.json
|
||||
git add manifest.json
|
||||
git commit -m "Update manifest.json for version ${VERSION}"
|
||||
git push origin master
|
||||
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
run: rm -rf release-${{ github.run_id }}
|
||||
@@ -0,0 +1,52 @@
|
||||
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: linux/amd64
|
||||
container:
|
||||
image: gitea.tourolle.paris/dtourolle/srfplay-builder:latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
path: test-${{ github.run_id }}
|
||||
|
||||
- name: Restore dependencies
|
||||
working-directory: test-${{ github.run_id }}
|
||||
run: dotnet restore Jellyfin.Plugin.SRFPlay.sln
|
||||
|
||||
- name: Build solution
|
||||
working-directory: test-${{ github.run_id }}
|
||||
run: dotnet build Jellyfin.Plugin.SRFPlay.sln --configuration Debug --no-restore --no-self-contained /m:1
|
||||
|
||||
- name: Run tests
|
||||
working-directory: test-${{ github.run_id }}
|
||||
run: dotnet test Jellyfin.Plugin.SRFPlay.sln --no-build --configuration Debug --verbosity normal --logger "trx;LogFileName=test-results.trx"
|
||||
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: test-results
|
||||
path: test-${{ github.run_id }}/**/test-results.trx
|
||||
retention-days: 7
|
||||
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
run: rm -rf test-${{ github.run_id }}
|
||||
@@ -3,3 +3,4 @@ obj/
|
||||
.vs/
|
||||
.idea/
|
||||
artifacts
|
||||
*.log
|
||||
|
||||
Vendored
+11
@@ -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": []
|
||||
}
|
||||
Vendored
+1
-1
@@ -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",
|
||||
|
||||
Vendored
+9
-5
@@ -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",
|
||||
}
|
||||
Vendored
+39
-18
@@ -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"
|
||||
@@ -27,29 +32,45 @@
|
||||
"problemMatcher": "$msCompile"
|
||||
},
|
||||
{
|
||||
// Ensure the plugin directory exists before trying to use it
|
||||
"label": "make-plugin-dir",
|
||||
"type": "shell",
|
||||
"command": "mkdir",
|
||||
// Ensure the plugin directory exists before trying to use it
|
||||
"label": "make-plugin-dir",
|
||||
"type": "shell",
|
||||
"command": "mkdir",
|
||||
"windows": {
|
||||
"args": [
|
||||
"-Force",
|
||||
"-Path",
|
||||
"${config:jellyfinDataDir}/plugins/${config:pluginName}/"
|
||||
"-Force",
|
||||
"-Path",
|
||||
"${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
|
||||
// Usually, you probablly only need ${config:pluginName}.dll
|
||||
// But some plugins may bundle extra requirements
|
||||
"label": "copy-dll",
|
||||
"type": "shell",
|
||||
"command": "cp",
|
||||
// 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",
|
||||
"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
@@ -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,19 @@
|
||||
# SRFPlay Builder Image
|
||||
# Pre-built image with .NET SDK and JPRM for building Jellyfin plugins
|
||||
# Build: docker build -f Dockerfile.builder -t gitea.tourolle.paris/dtourolle/srfplay-builder:latest .
|
||||
# Push: docker push gitea.tourolle.paris/dtourolle/srfplay-builder:latest
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:8.0
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
python3 \
|
||||
python3-pip \
|
||||
git \
|
||||
jq \
|
||||
nodejs \
|
||||
npm \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN pip install --break-system-packages jprm
|
||||
|
||||
WORKDIR /src
|
||||
@@ -0,0 +1,19 @@
|
||||
<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" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,293 @@
|
||||
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
|
||||
{
|
||||
/// <summary>
|
||||
/// Simple IHttpClientFactory implementation for tests.
|
||||
/// </summary>
|
||||
internal sealed class TestHttpClientFactory : IHttpClientFactory
|
||||
{
|
||||
public HttpClient CreateClient(string name) => new HttpClient();
|
||||
}
|
||||
|
||||
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 httpClientFactory = new TestHttpClientFactory();
|
||||
var apiClient = new SRFApiClient(loggerFactory);
|
||||
var streamResolver = new StreamUrlResolver(loggerFactory.CreateLogger<StreamUrlResolver>(), httpClientFactory);
|
||||
|
||||
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 ===");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.IsPlayable);
|
||||
|
||||
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,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,13 @@
|
||||
namespace Jellyfin.Plugin.SRFPlay.Api;
|
||||
|
||||
/// <summary>
|
||||
/// Factory interface for creating SRF API clients.
|
||||
/// </summary>
|
||||
public interface ISRFApiClientFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new instance of the SRF API client.
|
||||
/// </summary>
|
||||
/// <returns>A new SRFApiClient instance.</returns>
|
||||
SRFApiClient CreateClient();
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
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")]
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1002:Do not expose generic lists", Justification = "Required for JSON deserialization")]
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA2227:Collection properties should be read only", Justification = "Required for JSON deserialization")]
|
||||
public List<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,34 @@
|
||||
using System.Collections.Generic;
|
||||
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 a value indicating whether this composition has any chapters.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public bool HasChapters => ChapterList != null && ChapterList.Count > 0;
|
||||
|
||||
/// <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,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,94 @@
|
||||
using System;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Jellyfin.Plugin.SRFPlay.Api.Models;
|
||||
|
||||
/// <summary>
|
||||
/// A scheduled or active recording.
|
||||
/// </summary>
|
||||
public class RecordingEntry
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the unique recording ID.
|
||||
/// </summary>
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the SRF URN.
|
||||
/// </summary>
|
||||
[JsonPropertyName("urn")]
|
||||
public string Urn { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the title.
|
||||
/// </summary>
|
||||
[JsonPropertyName("title")]
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the description.
|
||||
/// </summary>
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the image URL.
|
||||
/// </summary>
|
||||
[JsonPropertyName("imageUrl")]
|
||||
public string? ImageUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets when the livestream starts.
|
||||
/// </summary>
|
||||
[JsonPropertyName("validFrom")]
|
||||
public DateTime? ValidFrom { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets when the livestream ends.
|
||||
/// </summary>
|
||||
[JsonPropertyName("validTo")]
|
||||
public DateTime? ValidTo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the recording state.
|
||||
/// </summary>
|
||||
[JsonPropertyName("state")]
|
||||
public RecordingState State { get; set; } = RecordingState.Scheduled;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the output file path.
|
||||
/// </summary>
|
||||
[JsonPropertyName("outputPath")]
|
||||
public string? OutputPath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets when the recording actually started.
|
||||
/// </summary>
|
||||
[JsonPropertyName("recordingStartedAt")]
|
||||
public DateTime? RecordingStartedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets when the recording ended.
|
||||
/// </summary>
|
||||
[JsonPropertyName("recordingEndedAt")]
|
||||
public DateTime? RecordingEndedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the file size in bytes.
|
||||
/// </summary>
|
||||
[JsonPropertyName("fileSizeBytes")]
|
||||
public long? FileSizeBytes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the error message if recording failed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("errorMessage")]
|
||||
public string? ErrorMessage { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets when this entry was created.
|
||||
/// </summary>
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace Jellyfin.Plugin.SRFPlay.Api.Models;
|
||||
|
||||
/// <summary>
|
||||
/// State of a recording.
|
||||
/// </summary>
|
||||
public enum RecordingState
|
||||
{
|
||||
/// <summary>Scheduled for future recording.</summary>
|
||||
Scheduled,
|
||||
|
||||
/// <summary>Waiting for stream to become available.</summary>
|
||||
WaitingForStream,
|
||||
|
||||
/// <summary>Currently recording.</summary>
|
||||
Recording,
|
||||
|
||||
/// <summary>Recording completed successfully.</summary>
|
||||
Completed,
|
||||
|
||||
/// <summary>Recording failed.</summary>
|
||||
Failed,
|
||||
|
||||
/// <summary>Recording was cancelled.</summary>
|
||||
Cancelled
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Jellyfin.Plugin.SRFPlay.Api.Models;
|
||||
|
||||
// NOTE: DrmList is typed as object? because the SRF API returns either null or a JSON array.
|
||||
// IsPlayable checks for both null and empty array ("[]") to determine if content is DRM-free.
|
||||
|
||||
/// <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; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether this resource is playable (not DRM-protected).
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public bool IsPlayable => DrmList == null || DrmList.ToString() == "[]";
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Text.Encodings.Web;
|
||||
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 Jellyfin.Plugin.SRFPlay.Constants;
|
||||
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 static readonly System.Text.CompositeFormat PlayV3UrlFormat = System.Text.CompositeFormat.Parse(ApiEndpoints.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(ApiEndpoints.IntegrationLayerBaseUrl);
|
||||
_playV3HttpClient = CreateHttpClient(null);
|
||||
|
||||
_jsonOptions = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads HTTP response content as UTF-8 string.
|
||||
/// </summary>
|
||||
/// <param name="content">The HTTP content to read.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>The content as a UTF-8 decoded string.</returns>
|
||||
private async Task<string> ReadAsUtf8StringAsync(HttpContent content, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var bytes = await content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
|
||||
return Encoding.UTF8.GetString(bytes);
|
||||
}
|
||||
|
||||
/// <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");
|
||||
client.DefaultRequestHeaders.Add("Accept-Charset", "utf-8");
|
||||
|
||||
_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 fullUrl = $"{ApiEndpoints.IntegrationLayerBaseUrl}/mediaComposition/byUrn/{urn}.json";
|
||||
_logger.LogDebug("Fetching media composition for URN: {Urn} from {Url}", urn, fullUrl);
|
||||
|
||||
// Use curl - HttpClient returns 404 due to server routing/network configuration
|
||||
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,
|
||||
StandardOutputEncoding = Encoding.UTF8
|
||||
};
|
||||
|
||||
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?.HasChapters == true)
|
||||
{
|
||||
_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 Task<MediaComposition?> GetLatestVideosAsync(string businessUnit, CancellationToken cancellationToken = default)
|
||||
=> GetMediaCompositionListAsync(businessUnit, "latest", cancellationToken);
|
||||
|
||||
/// <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 Task<MediaComposition?> GetTrendingVideosAsync(string businessUnit, CancellationToken cancellationToken = default)
|
||||
=> GetMediaCompositionListAsync(businessUnit, "trending", cancellationToken);
|
||||
|
||||
private async Task<MediaComposition?> GetMediaCompositionListAsync(string businessUnit, string endpoint, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var url = $"/video/{businessUnit}/{endpoint}.json";
|
||||
_logger.LogInformation("Fetching {Endpoint} videos for business unit: {BusinessUnit} from URL: {Url}", endpoint, businessUnit, url);
|
||||
|
||||
var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation("{Endpoint} videos API response: {StatusCode}", endpoint, response.StatusCode);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await ReadAsUtf8StringAsync(response.Content, cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogError("API returned error {StatusCode}: {Error}", response.StatusCode, errorContent);
|
||||
return null;
|
||||
}
|
||||
|
||||
var content = await ReadAsUtf8StringAsync(response.Content, cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogDebug("{Endpoint} videos response length: {Length}", endpoint, content.Length);
|
||||
|
||||
var result = JsonSerializer.Deserialize<MediaComposition>(content, _jsonOptions);
|
||||
|
||||
_logger.LogInformation("Successfully fetched {Endpoint} videos for business unit: {BusinessUnit}", endpoint, businessUnit);
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error fetching {Endpoint} videos for business unit: {BusinessUnit}", endpoint, businessUnit);
|
||||
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 Task<System.Collections.Generic.List<PlayV3Show>?> GetAllShowsAsync(string businessUnit, CancellationToken cancellationToken = default)
|
||||
=> GetPlayV3DirectListAsync<PlayV3Show>(businessUnit, "shows", cancellationToken);
|
||||
|
||||
/// <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 Task<System.Collections.Generic.List<PlayV3Topic>?> GetAllTopicsAsync(string businessUnit, CancellationToken cancellationToken = default)
|
||||
=> GetPlayV3DirectListAsync<PlayV3Topic>(businessUnit, "topics", cancellationToken);
|
||||
|
||||
private async Task<System.Collections.Generic.List<T>?> GetPlayV3DirectListAsync<T>(string businessUnit, string endpoint, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var baseUrl = string.Format(CultureInfo.InvariantCulture, PlayV3UrlFormat, businessUnit);
|
||||
var url = $"{baseUrl}{endpoint}";
|
||||
_logger.LogInformation("Fetching all {Endpoint} for business unit: {BusinessUnit} from URL: {Url}", endpoint, businessUnit, url);
|
||||
|
||||
var response = await _playV3HttpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await ReadAsUtf8StringAsync(response.Content, cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogError("API returned error {StatusCode}: {Error}", response.StatusCode, errorContent);
|
||||
return null;
|
||||
}
|
||||
|
||||
var content = await ReadAsUtf8StringAsync(response.Content, cancellationToken).ConfigureAwait(false);
|
||||
var result = JsonSerializer.Deserialize<PlayV3DirectResponse<T>>(content, _jsonOptions);
|
||||
|
||||
_logger.LogInformation("Successfully fetched {Count} {Endpoint} for business unit: {BusinessUnit}", result?.Data?.Count ?? 0, endpoint, businessUnit);
|
||||
return result?.Data;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error fetching {Endpoint} for business unit: {BusinessUnit}", endpoint, 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 ReadAsUtf8StringAsync(response.Content, cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogError("API returned error {StatusCode}: {Error}", response.StatusCode, errorContent);
|
||||
return null;
|
||||
}
|
||||
|
||||
var content = await ReadAsUtf8StringAsync(response.Content, 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 ReadAsUtf8StringAsync(response.Content, cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogError("API returned error {StatusCode}: {Error}", response.StatusCode, errorContent);
|
||||
return null;
|
||||
}
|
||||
|
||||
var content = await ReadAsUtf8StringAsync(response.Content, 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,26 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.SRFPlay.Api;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating SRF API clients.
|
||||
/// </summary>
|
||||
public class SRFApiClientFactory : ISRFApiClientFactory
|
||||
{
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SRFApiClientFactory"/> class.
|
||||
/// </summary>
|
||||
/// <param name="loggerFactory">The logger factory.</param>
|
||||
public SRFApiClientFactory(ILoggerFactory loggerFactory)
|
||||
{
|
||||
_loggerFactory = loggerFactory;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public SRFApiClient CreateClient()
|
||||
{
|
||||
return new SRFApiClient(_loggerFactory);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,627 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.SRFPlay.Api;
|
||||
using Jellyfin.Plugin.SRFPlay.Constants;
|
||||
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
||||
using Jellyfin.Plugin.SRFPlay.Utilities;
|
||||
using MediaBrowser.Controller;
|
||||
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 IContentRefreshService _contentRefreshService;
|
||||
private readonly IStreamUrlResolver _streamResolver;
|
||||
private readonly IMediaSourceFactory _mediaSourceFactory;
|
||||
private readonly ICategoryService? _categoryService;
|
||||
private readonly ISRFApiClientFactory _apiClientFactory;
|
||||
|
||||
/// <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="mediaSourceFactory">The media source factory.</param>
|
||||
/// <param name="categoryService">The category service (optional).</param>
|
||||
/// <param name="apiClientFactory">The API client factory.</param>
|
||||
public SRFPlayChannel(
|
||||
ILoggerFactory loggerFactory,
|
||||
IContentRefreshService contentRefreshService,
|
||||
IStreamUrlResolver streamResolver,
|
||||
IMediaSourceFactory mediaSourceFactory,
|
||||
ICategoryService? categoryService,
|
||||
ISRFApiClientFactory apiClientFactory)
|
||||
{
|
||||
_logger = loggerFactory.CreateLogger<SRFPlayChannel>();
|
||||
_contentRefreshService = contentRefreshService;
|
||||
_streamResolver = streamResolver;
|
||||
_mediaSourceFactory = mediaSourceFactory;
|
||||
_categoryService = categoryService;
|
||||
_apiClientFactory = apiClientFactory;
|
||||
|
||||
if (_categoryService == null)
|
||||
{
|
||||
_logger.LogWarning("CategoryService not available - category folders will be disabled");
|
||||
}
|
||||
|
||||
_logger.LogDebug("SRFPlayChannel initialized");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "SRF Play";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Swiss Radio and Television video-on-demand content";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string DataVersion => "2.0"; // Back to authenticating at channel refresh with auto-refresh for fresh tokens
|
||||
|
||||
/// <inheritdoc />
|
||||
public string HomePageUrl => ApiEndpoints.SrfPlayHomepage;
|
||||
|
||||
/// <inheritdoc />
|
||||
public ChannelParentalRating ParentalRating => ChannelParentalRating.GeneralAudience;
|
||||
|
||||
/// <inheritdoc />
|
||||
public InternalChannelFeatures GetChannelFeatures()
|
||||
{
|
||||
_logger.LogDebug("GetChannelFeatures called");
|
||||
|
||||
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)
|
||||
{
|
||||
var assembly = GetType().Assembly;
|
||||
var resourceStream = assembly.GetManifestResourceStream("Jellyfin.Plugin.SRFPlay.Images.logo.png");
|
||||
|
||||
if (resourceStream == null)
|
||||
{
|
||||
return Task.FromResult(new DynamicImageResponse { HasImage = false });
|
||||
}
|
||||
|
||||
return Task.FromResult(new DynamicImageResponse
|
||||
{
|
||||
HasImage = true,
|
||||
Stream = resourceStream,
|
||||
Format = MediaBrowser.Model.Drawing.ImageFormat.Png
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<ImageType> GetSupportedChannelImages()
|
||||
{
|
||||
return new List<ImageType>
|
||||
{
|
||||
ImageType.Primary,
|
||||
ImageType.Thumb
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ChannelItemResult> GetChannelItems(InternalChannelItemQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogDebug("GetChannelItems called for folder {FolderId}", query.FolderId);
|
||||
|
||||
try
|
||||
{
|
||||
var items = await GetFolderItemsAsync(query.FolderId, cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogDebug("Returning {Count} channel items for folder {FolderId}", items.Count, query.FolderId);
|
||||
|
||||
return new ChannelItemResult
|
||||
{
|
||||
Items = items,
|
||||
TotalRecordCount = items.Count
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting channel items for folder {FolderId}", query.FolderId);
|
||||
return new ChannelItemResult { Items = new List<ChannelItemInfo>(), TotalRecordCount = 0 };
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<List<ChannelItemInfo>> GetFolderItemsAsync(string? folderId, CancellationToken cancellationToken)
|
||||
{
|
||||
// Root level - show folder list
|
||||
if (string.IsNullOrEmpty(folderId))
|
||||
{
|
||||
return await GetRootFoldersAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Handle known folder types
|
||||
return folderId switch
|
||||
{
|
||||
"latest" => await GetLatestVideosAsync(cancellationToken).ConfigureAwait(false),
|
||||
"trending" => await GetTrendingVideosAsync(cancellationToken).ConfigureAwait(false),
|
||||
"live_sports" => await GetLiveSportsAsync(cancellationToken).ConfigureAwait(false),
|
||||
_ when folderId.StartsWith("category_", StringComparison.Ordinal) => await GetCategoryVideosAsync(folderId, cancellationToken).ConfigureAwait(false),
|
||||
_ => new List<ChannelItemInfo>()
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<List<ChannelItemInfo>> GetRootFoldersAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var items = new List<ChannelItemInfo>
|
||||
{
|
||||
CreateFolder("latest", "Latest Videos"),
|
||||
CreateFolder("trending", "Trending Videos"),
|
||||
CreateFolder("live_sports", "Live Sports & Events")
|
||||
};
|
||||
|
||||
// Add category folders if enabled
|
||||
var config = Plugin.Instance?.Configuration;
|
||||
if (config?.EnableCategoryFolders == true && _categoryService != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var businessUnit = config.BusinessUnit.ToLowerString();
|
||||
var topics = await _categoryService.GetTopicsAsync(businessUnit, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
foreach (var topic in topics.Where(t => !string.IsNullOrEmpty(t.Id)))
|
||||
{
|
||||
if (config.EnabledTopics != null && config.EnabledTopics.Count > 0 && !config.EnabledTopics.Contains(topic.Id!))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Generate placeholder image for topic
|
||||
var placeholderUrl = CreatePlaceholderImageUrl(topic.Title ?? topic.Id!, _mediaSourceFactory.GetServerBaseUrl());
|
||||
items.Add(CreateFolder($"category_{topic.Id}", topic.Title ?? topic.Id!, topic.Lead, placeholderUrl));
|
||||
}
|
||||
|
||||
_logger.LogInformation("Added {Count} category folders", topics.Count);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to load category folders - continuing without categories");
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
private static ChannelItemInfo CreateFolder(string id, string name, string? overview = null, string? imageUrl = null)
|
||||
{
|
||||
return new ChannelItemInfo
|
||||
{
|
||||
Id = id,
|
||||
Name = name,
|
||||
Type = ChannelItemType.Folder,
|
||||
FolderType = ChannelFolderType.Container,
|
||||
ImageUrl = imageUrl,
|
||||
Overview = overview
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<List<ChannelItemInfo>> GetLatestVideosAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var urns = await _contentRefreshService.RefreshLatestContentAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await ConvertUrnsToChannelItems(urns, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<List<ChannelItemInfo>> GetTrendingVideosAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var urns = await _contentRefreshService.RefreshTrendingContentAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await ConvertUrnsToChannelItems(urns, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<List<ChannelItemInfo>> GetLiveSportsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var items = new List<ChannelItemInfo>();
|
||||
var config = Plugin.Instance?.Configuration;
|
||||
|
||||
try
|
||||
{
|
||||
var businessUnit = config?.BusinessUnit.ToLowerString() ?? "srf";
|
||||
|
||||
using var apiClient = _apiClientFactory.CreateClient();
|
||||
var scheduledLivestreams = await apiClient.GetScheduledLivestreamsAsync(businessUnit, "SPORT", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (scheduledLivestreams == null)
|
||||
{
|
||||
return items;
|
||||
}
|
||||
|
||||
// Filter for upcoming/current events (within next 7 days)
|
||||
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 &&
|
||||
(p.ValidTo == null || p.ValidTo.Value.ToUniversalTime() > now))
|
||||
.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");
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
private async Task<List<ChannelItemInfo>> GetCategoryVideosAsync(string folderId, CancellationToken cancellationToken)
|
||||
{
|
||||
var items = new List<ChannelItemInfo>();
|
||||
|
||||
if (_categoryService == null)
|
||||
{
|
||||
_logger.LogWarning("CategoryService not available - cannot display category folder");
|
||||
return items;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var config = Plugin.Instance?.Configuration;
|
||||
var topicId = folderId.Substring("category_".Length);
|
||||
var businessUnit = config?.BusinessUnit.ToLowerString() ?? "srf";
|
||||
|
||||
var shows = await _categoryService.GetShowsByTopicAsync(topicId, businessUnit, 20, cancellationToken).ConfigureAwait(false);
|
||||
var urns = new List<string>();
|
||||
|
||||
using var apiClient = _apiClientFactory.CreateClient();
|
||||
|
||||
foreach (var show in shows)
|
||||
{
|
||||
if (show.Id == null || cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var latestUrn = await GetLatestVideoUrnForShowAsync(apiClient, businessUnit, show, topicId, cancellationToken).ConfigureAwait(false);
|
||||
if (latestUrn != null)
|
||||
{
|
||||
urns.Add(latestUrn);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error fetching videos for show {ShowId} in category {TopicId}", show.Id, topicId);
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
private async Task<string?> GetLatestVideoUrnForShowAsync(
|
||||
Api.SRFApiClient apiClient,
|
||||
string businessUnit,
|
||||
Api.Models.PlayV3Show show,
|
||||
string topicId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var videos = await apiClient.GetVideosForShowAsync(businessUnit, show.Id!, cancellationToken).ConfigureAwait(false);
|
||||
if (videos == null || videos.Count == 0)
|
||||
{
|
||||
_logger.LogDebug("Category {TopicId}, Show {Show} ({ShowId}): No videos returned from API", topicId, show.Title, show.Id);
|
||||
return null;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Category {TopicId}, Show {Show} ({ShowId}): Found {Count} videos", topicId, show.Title, show.Id, videos.Count);
|
||||
|
||||
// Filter to available videos
|
||||
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)
|
||||
{
|
||||
_logger.LogDebug("Category {TopicId}, Show {Show}: No available videos (all expired or not yet published)", topicId, show.Title);
|
||||
return null;
|
||||
}
|
||||
|
||||
var latestVideo = availableVideos.OrderByDescending(v => v.Date).FirstOrDefault();
|
||||
if (latestVideo?.Urn == null)
|
||||
{
|
||||
_logger.LogWarning("Category {TopicId}, Show {Show}: Latest available video has null URN", topicId, show.Title);
|
||||
return null;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Category {TopicId}: Added video from show {Show}: {Title} (URN: {Urn}, Date: {Date})",
|
||||
topicId,
|
||||
show.Title,
|
||||
latestVideo.Title,
|
||||
latestVideo.Urn,
|
||||
latestVideo.Date);
|
||||
|
||||
return latestVideo.Urn;
|
||||
}
|
||||
|
||||
/// <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";
|
||||
|
||||
// Use 15-minute time buckets for cache key so live content refreshes frequently
|
||||
// This ensures livestream folders update as programs start/end throughout the day
|
||||
var now = DateTime.Now;
|
||||
var timeBucket = new DateTime(now.Year, now.Month, now.Day, now.Hour, (now.Minute / 15) * 15, 0);
|
||||
var timeKey = timeBucket.ToString("yyyy-MM-dd-HH-mm", CultureInfo.InvariantCulture);
|
||||
|
||||
return $"{config?.BusinessUnit}_{config?.EnableLatestContent}_{config?.EnableTrendingContent}_{config?.EnableCategoryFolders}_{enabledTopics}_{timeKey}";
|
||||
}
|
||||
|
||||
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 = _apiClientFactory.CreateClient();
|
||||
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?.HasChapters != true)
|
||||
{
|
||||
_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 = UrnHelper.ToGuid(urn);
|
||||
|
||||
// Use factory to create MediaSourceInfo (handles stream URL, auth, proxy registration)
|
||||
var mediaSource = await _mediaSourceFactory.CreateMediaSourceAsync(
|
||||
chapter,
|
||||
itemId,
|
||||
urn,
|
||||
config.QualityPreference,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Skip items without a valid media source (no stream URL available)
|
||||
if (mediaSource == null)
|
||||
{
|
||||
if (chapter.Type == "SCHEDULED_LIVESTREAM")
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"URN {Urn}: Skipping upcoming livestream '{Title}' - stream not yet available (starts at {ValidFrom})",
|
||||
urn,
|
||||
chapter.Title,
|
||||
chapter.ValidFrom);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"URN {Urn}: Skipping '{Title}' - no valid stream URL available",
|
||||
urn,
|
||||
chapter.Title);
|
||||
noStreamCount++;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Build overview
|
||||
var overview = chapter.Description ?? chapter.Lead;
|
||||
|
||||
// Determine image URL based on configuration
|
||||
string? imageUrl;
|
||||
var generateTitleCards = config.GenerateTitleCards;
|
||||
|
||||
if (generateTitleCards)
|
||||
{
|
||||
// Generate title card with content name
|
||||
_logger.LogDebug("URN {Urn}: Generating title card for '{Title}'", urn, chapter.Title);
|
||||
imageUrl = CreatePlaceholderImageUrl(chapter.Title ?? "SRF Play", _mediaSourceFactory.GetServerBaseUrl());
|
||||
}
|
||||
else
|
||||
{
|
||||
// Use SRF-provided images - prefer chapter image, fall back to show image
|
||||
var originalImageUrl = chapter.ImageUrl;
|
||||
if (string.IsNullOrEmpty(originalImageUrl) && mediaComposition.Show != null)
|
||||
{
|
||||
originalImageUrl = mediaComposition.Show.ImageUrl;
|
||||
_logger.LogDebug("URN {Urn}: Using show image as fallback", urn);
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(originalImageUrl))
|
||||
{
|
||||
_logger.LogDebug("URN {Urn}: No image URL available for '{Title}', using placeholder", urn, chapter.Title);
|
||||
imageUrl = CreatePlaceholderImageUrl(chapter.Title ?? "SRF Play", _mediaSourceFactory.GetServerBaseUrl());
|
||||
}
|
||||
else
|
||||
{
|
||||
imageUrl = CreateProxiedImageUrl(originalImageUrl, _mediaSourceFactory.GetServerBaseUrl());
|
||||
}
|
||||
}
|
||||
|
||||
// Use ValidFrom for premiere date if this is a scheduled livestream, otherwise use Date
|
||||
var premiereDate = chapter.Type == "SCHEDULED_LIVESTREAM" ? chapter.ValidFrom?.ToUniversalTime() : chapter.Date?.ToUniversalTime();
|
||||
|
||||
// Store authenticated URL - tokens refresh automatically via scheduled channel scans
|
||||
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> { mediaSource }
|
||||
};
|
||||
|
||||
// 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);
|
||||
_logger.LogDebug(
|
||||
"URN {Urn}: MediaSource created via factory - DirectPlay={DirectPlay}, Transcoding={Transcoding}",
|
||||
urn,
|
||||
mediaSource.SupportsDirectPlay,
|
||||
mediaSource.SupportsTranscoding);
|
||||
}
|
||||
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>
|
||||
/// Creates a proxy URL for an image to fix Content-Type headers from SRF CDN.
|
||||
/// </summary>
|
||||
/// <param name="originalUrl">The original SRF image URL.</param>
|
||||
/// <param name="serverUrl">The server base URL.</param>
|
||||
/// <returns>The proxied image URL, or null if original is null.</returns>
|
||||
private static string? CreateProxiedImageUrl(string? originalUrl, string serverUrl)
|
||||
{
|
||||
if (string.IsNullOrEmpty(originalUrl))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Encode the original URL as base64 for safe transport
|
||||
var encodedUrl = Convert.ToBase64String(Encoding.UTF8.GetBytes(originalUrl));
|
||||
return $"{serverUrl}/Plugins/SRFPlay/Proxy/Image?url={encodedUrl}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a placeholder image URL with the given text.
|
||||
/// </summary>
|
||||
/// <param name="text">The text to display on the placeholder.</param>
|
||||
/// <param name="serverUrl">The server base URL.</param>
|
||||
/// <returns>The placeholder image URL.</returns>
|
||||
private static string CreatePlaceholderImageUrl(string text, string serverUrl)
|
||||
{
|
||||
var encodedText = Convert.ToBase64String(Encoding.UTF8.GetBytes(text));
|
||||
return $"{serverUrl}/Plugins/SRFPlay/Proxy/Placeholder?text={encodedText}";
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsEnabledFor(string userId)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
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>();
|
||||
GenerateTitleCards = true;
|
||||
}
|
||||
|
||||
/// <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; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the public/external server URL for remote clients (e.g., https://jellyfin.example.com:8920).
|
||||
/// If not set, the plugin will use Jellyfin's GetSmartApiUrl() which may return local addresses.
|
||||
/// This is important for Android and other remote clients to access streams.
|
||||
/// </summary>
|
||||
public string PublicServerUrl { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to generate title card images with the content title.
|
||||
/// When enabled, generates custom thumbnails instead of using SRF-provided images.
|
||||
/// </summary>
|
||||
public bool GenerateTitleCards { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the output directory for sport livestream recordings.
|
||||
/// </summary>
|
||||
public string RecordingOutputPath { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
<!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>
|
||||
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||
<label class="emby-checkbox-label">
|
||||
<input id="GenerateTitleCards" name="GenerateTitleCards" type="checkbox" is="emby-checkbox" />
|
||||
<span>Generate Title Cards</span>
|
||||
</label>
|
||||
<div class="fieldDescription">Generate custom thumbnails with the content title instead of using SRF-provided images</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>
|
||||
<br />
|
||||
<h2>Recording Settings</h2>
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="RecordingOutputPath">Recording Output Directory</label>
|
||||
<input id="RecordingOutputPath" name="RecordingOutputPath" type="text" is="emby-input" placeholder="e.g., /media/recordings/srf" />
|
||||
<div class="fieldDescription">Directory where sport livestream recordings will be saved (requires ffmpeg)</div>
|
||||
</div>
|
||||
<br />
|
||||
<h2>Network Settings</h2>
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="PublicServerUrl">Public Server URL (Optional)</label>
|
||||
<input id="PublicServerUrl" name="PublicServerUrl" type="text" is="emby-input" placeholder="e.g., https://jellyfin.example.com:8920" />
|
||||
<div class="fieldDescription">
|
||||
The public/external URL for remote clients (Android, iOS, etc.) to access streaming proxy.
|
||||
<br />If not set, the plugin will use Jellyfin's automatic URL detection which may return local addresses.
|
||||
<br /><strong>Important for Android/remote playback!</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<button is="emby-button" type="submit" class="raised button-submit block emby-button">
|
||||
<span>Save</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<br />
|
||||
<h2>Sport Livestream Recordings</h2>
|
||||
|
||||
<h3>Upcoming Sport Livestreams</h3>
|
||||
<div class="fieldDescription">Select livestreams to record. The recording starts automatically when the stream goes live.</div>
|
||||
<div id="scheduleContainer" style="margin: 10px 0;">
|
||||
<p><em>Loading schedule...</em></p>
|
||||
</div>
|
||||
<button is="emby-button" type="button" class="raised emby-button" onclick="SRFPlayRecordings.loadSchedule()" style="margin-bottom: 20px;">
|
||||
<span>Refresh Schedule</span>
|
||||
</button>
|
||||
|
||||
<h3>Scheduled & Active Recordings</h3>
|
||||
<div id="activeRecordingsContainer" style="margin: 10px 0;">
|
||||
<p><em>Loading...</em></p>
|
||||
</div>
|
||||
|
||||
<h3>Completed Recordings</h3>
|
||||
<div id="completedRecordingsContainer" style="margin: 10px 0;">
|
||||
<p><em>Loading...</em></p>
|
||||
</div>
|
||||
<button is="emby-button" type="button" class="raised emby-button" onclick="SRFPlayRecordings.loadRecordings()" style="margin-bottom: 20px;">
|
||||
<span>Refresh Recordings</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<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('#GenerateTitleCards').checked = config.GenerateTitleCards !== false;
|
||||
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 || '';
|
||||
document.querySelector('#PublicServerUrl').value = config.PublicServerUrl || '';
|
||||
document.querySelector('#RecordingOutputPath').value = config.RecordingOutputPath || '';
|
||||
Dashboard.hideLoadingMsg();
|
||||
|
||||
// Load recordings UI
|
||||
SRFPlayRecordings.loadSchedule();
|
||||
SRFPlayRecordings.loadRecordings();
|
||||
});
|
||||
});
|
||||
|
||||
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.GenerateTitleCards = document.querySelector('#GenerateTitleCards').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;
|
||||
config.PublicServerUrl = document.querySelector('#PublicServerUrl').value;
|
||||
config.RecordingOutputPath = document.querySelector('#RecordingOutputPath').value;
|
||||
ApiClient.updatePluginConfiguration(SRFPlayConfig.pluginUniqueId, config).then(function (result) {
|
||||
Dashboard.processPluginConfigurationUpdateResult(result);
|
||||
});
|
||||
});
|
||||
|
||||
e.preventDefault();
|
||||
return false;
|
||||
});
|
||||
|
||||
var SRFPlayRecordings = {
|
||||
apiBase: ApiClient.serverAddress() + '/Plugins/SRFPlay/Recording',
|
||||
|
||||
getHeaders: function() {
|
||||
return {
|
||||
'X-Emby-Token': ApiClient.accessToken()
|
||||
};
|
||||
},
|
||||
|
||||
formatDate: function(dateStr) {
|
||||
if (!dateStr) return 'N/A';
|
||||
var d = new Date(dateStr);
|
||||
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
|
||||
},
|
||||
|
||||
formatSize: function(bytes) {
|
||||
if (!bytes) return '';
|
||||
if (bytes > 1073741824) return (bytes / 1073741824).toFixed(1) + ' GB';
|
||||
if (bytes > 1048576) return (bytes / 1048576).toFixed(0) + ' MB';
|
||||
return (bytes / 1024).toFixed(0) + ' KB';
|
||||
},
|
||||
|
||||
loadSchedule: function() {
|
||||
var container = document.getElementById('scheduleContainer');
|
||||
container.innerHTML = '<p><em>Loading schedule...</em></p>';
|
||||
|
||||
fetch(this.apiBase + '/Schedule', { headers: this.getHeaders() })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(programs) {
|
||||
if (!programs || programs.length === 0) {
|
||||
container.innerHTML = '<p>No upcoming sport livestreams found.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
var html = '<table style="width:100%; border-collapse: collapse;">';
|
||||
html += '<thead><tr style="text-align:left; border-bottom: 1px solid #444;">';
|
||||
html += '<th style="padding: 8px;">Title</th>';
|
||||
html += '<th style="padding: 8px;">Start</th>';
|
||||
html += '<th style="padding: 8px;">End</th>';
|
||||
html += '<th style="padding: 8px;">Action</th>';
|
||||
html += '</tr></thead><tbody>';
|
||||
|
||||
programs.forEach(function(p) {
|
||||
html += '<tr style="border-bottom: 1px solid #333;">';
|
||||
html += '<td style="padding: 8px;">' + (p.title || 'Unknown') + '</td>';
|
||||
html += '<td style="padding: 8px;">' + SRFPlayRecordings.formatDate(p.validFrom || p.date) + '</td>';
|
||||
html += '<td style="padding: 8px;">' + SRFPlayRecordings.formatDate(p.validTo) + '</td>';
|
||||
html += '<td style="padding: 8px;">';
|
||||
html += '<button is="emby-button" type="button" class="raised emby-button" ';
|
||||
html += 'onclick="SRFPlayRecordings.scheduleRecording(\'' + encodeURIComponent(p.urn) + '\')">';
|
||||
html += '<span>Record</span></button>';
|
||||
html += '</td></tr>';
|
||||
});
|
||||
|
||||
html += '</tbody></table>';
|
||||
container.innerHTML = html;
|
||||
})
|
||||
.catch(function(err) {
|
||||
container.innerHTML = '<p style="color: #f44;">Error loading schedule: ' + err.message + '</p>';
|
||||
});
|
||||
},
|
||||
|
||||
scheduleRecording: function(encodedUrn) {
|
||||
fetch(this.apiBase + '/Schedule/' + encodedUrn, {
|
||||
method: 'POST',
|
||||
headers: this.getHeaders()
|
||||
})
|
||||
.then(function(r) {
|
||||
if (r.ok) {
|
||||
Dashboard.alert('Recording scheduled!');
|
||||
SRFPlayRecordings.loadRecordings();
|
||||
} else {
|
||||
Dashboard.alert('Failed to schedule recording');
|
||||
}
|
||||
})
|
||||
.catch(function(err) { Dashboard.alert('Error: ' + err.message); });
|
||||
},
|
||||
|
||||
loadRecordings: function() {
|
||||
this.loadActiveRecordings();
|
||||
this.loadCompletedRecordings();
|
||||
},
|
||||
|
||||
loadActiveRecordings: function() {
|
||||
var container = document.getElementById('activeRecordingsContainer');
|
||||
container.innerHTML = '<p><em>Loading...</em></p>';
|
||||
|
||||
fetch(this.apiBase + '/All', { headers: this.getHeaders() })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(recordings) {
|
||||
var activeStates = ['Scheduled', 'WaitingForStream', 'Recording', 0, 1, 2];
|
||||
var active = recordings.filter(function(r) {
|
||||
return activeStates.indexOf(r.state) !== -1;
|
||||
});
|
||||
|
||||
if (active.length === 0) {
|
||||
container.innerHTML = '<p>No scheduled or active recordings.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
var stateLabels = {'Scheduled': 'Scheduled', 'WaitingForStream': 'Waiting', 'Recording': 'Recording', 'Completed': 'Completed', 'Failed': 'Failed', 'Cancelled': 'Cancelled', 0: 'Scheduled', 1: 'Waiting', 2: 'Recording', 3: 'Completed', 4: 'Failed', 5: 'Cancelled'};
|
||||
var stateColors = {'Scheduled': '#2196F3', 'WaitingForStream': '#FF9800', 'Recording': '#4CAF50', 'Failed': '#f44336', 'Cancelled': '#9E9E9E', 0: '#2196F3', 1: '#FF9800', 2: '#4CAF50', 4: '#f44336', 5: '#9E9E9E'};
|
||||
|
||||
var html = '<table style="width:100%; border-collapse: collapse;">';
|
||||
html += '<thead><tr style="text-align:left; border-bottom: 1px solid #444;">';
|
||||
html += '<th style="padding: 8px;">Title</th>';
|
||||
html += '<th style="padding: 8px;">Status</th>';
|
||||
html += '<th style="padding: 8px;">Start</th>';
|
||||
html += '<th style="padding: 8px;">Action</th>';
|
||||
html += '</tr></thead><tbody>';
|
||||
|
||||
active.forEach(function(r) {
|
||||
html += '<tr style="border-bottom: 1px solid #333;">';
|
||||
html += '<td style="padding: 8px;">' + (r.title || 'Unknown') + '</td>';
|
||||
html += '<td style="padding: 8px;"><span style="color:' + (stateColors[r.state] || '#fff') + ';">' + (stateLabels[r.state] || r.state) + '</span></td>';
|
||||
html += '<td style="padding: 8px;">' + SRFPlayRecordings.formatDate(r.validFrom) + '</td>';
|
||||
html += '<td style="padding: 8px;">';
|
||||
if (r.state === 2 || r.state === 'Recording') {
|
||||
html += '<button is="emby-button" type="button" class="raised emby-button" ';
|
||||
html += 'onclick="SRFPlayRecordings.stopRecording(\'' + r.id + '\')"><span>Stop</span></button>';
|
||||
} else {
|
||||
html += '<button is="emby-button" type="button" class="raised emby-button" ';
|
||||
html += 'onclick="SRFPlayRecordings.cancelRecording(\'' + r.id + '\')"><span>Cancel</span></button>';
|
||||
}
|
||||
html += '</td></tr>';
|
||||
});
|
||||
|
||||
html += '</tbody></table>';
|
||||
container.innerHTML = html;
|
||||
})
|
||||
.catch(function(err) {
|
||||
container.innerHTML = '<p style="color: #f44;">Error: ' + err.message + '</p>';
|
||||
});
|
||||
},
|
||||
|
||||
loadCompletedRecordings: function() {
|
||||
var container = document.getElementById('completedRecordingsContainer');
|
||||
container.innerHTML = '<p><em>Loading...</em></p>';
|
||||
|
||||
fetch(this.apiBase + '/Completed', { headers: this.getHeaders() })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(recordings) {
|
||||
if (!recordings || recordings.length === 0) {
|
||||
container.innerHTML = '<p>No completed recordings.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
var html = '<table style="width:100%; border-collapse: collapse;">';
|
||||
html += '<thead><tr style="text-align:left; border-bottom: 1px solid #444;">';
|
||||
html += '<th style="padding: 8px;">Title</th>';
|
||||
html += '<th style="padding: 8px;">Recorded</th>';
|
||||
html += '<th style="padding: 8px;">Size</th>';
|
||||
html += '<th style="padding: 8px;">File</th>';
|
||||
html += '<th style="padding: 8px;">Action</th>';
|
||||
html += '</tr></thead><tbody>';
|
||||
|
||||
recordings.forEach(function(r) {
|
||||
html += '<tr style="border-bottom: 1px solid #333;">';
|
||||
html += '<td style="padding: 8px;">' + (r.title || 'Unknown') + '</td>';
|
||||
html += '<td style="padding: 8px;">' + SRFPlayRecordings.formatDate(r.recordingStartedAt) + '</td>';
|
||||
html += '<td style="padding: 8px;">' + SRFPlayRecordings.formatSize(r.fileSizeBytes) + '</td>';
|
||||
html += '<td style="padding: 8px; font-size: 0.85em; word-break: break-all;">' + (r.outputPath || '') + '</td>';
|
||||
html += '<td style="padding: 8px;">';
|
||||
html += '<button is="emby-button" type="button" class="raised emby-button" style="background:#f44336;" ';
|
||||
html += 'onclick="SRFPlayRecordings.deleteRecording(\'' + r.id + '\')"><span>Delete</span></button>';
|
||||
html += '</td></tr>';
|
||||
});
|
||||
|
||||
html += '</tbody></table>';
|
||||
container.innerHTML = html;
|
||||
})
|
||||
.catch(function(err) {
|
||||
container.innerHTML = '<p style="color: #f44;">Error: ' + err.message + '</p>';
|
||||
});
|
||||
},
|
||||
|
||||
stopRecording: function(id) {
|
||||
fetch(this.apiBase + '/Active/' + id + '/Stop', {
|
||||
method: 'POST',
|
||||
headers: this.getHeaders()
|
||||
}).then(function() { SRFPlayRecordings.loadRecordings(); });
|
||||
},
|
||||
|
||||
cancelRecording: function(id) {
|
||||
fetch(this.apiBase + '/Schedule/' + id, {
|
||||
method: 'DELETE',
|
||||
headers: this.getHeaders()
|
||||
}).then(function() { SRFPlayRecordings.loadRecordings(); });
|
||||
},
|
||||
|
||||
deleteRecording: function(id) {
|
||||
if (!confirm('Delete this recording and its file?')) return;
|
||||
fetch(this.apiBase + '/Completed/' + id + '?deleteFile=true', {
|
||||
method: 'DELETE',
|
||||
headers: this.getHeaders()
|
||||
}).then(function() { SRFPlayRecordings.loadRecordings(); });
|
||||
}
|
||||
};
|
||||
</script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,41 @@
|
||||
namespace Jellyfin.Plugin.SRFPlay.Constants;
|
||||
|
||||
/// <summary>
|
||||
/// Centralized API endpoints and URL templates used throughout the plugin.
|
||||
/// </summary>
|
||||
public static class ApiEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// SRG SSR Integration Layer API base URL.
|
||||
/// Used for fetching media compositions, video metadata, and stream URLs.
|
||||
/// </summary>
|
||||
public const string IntegrationLayerBaseUrl = "https://il.srgssr.ch/integrationlayer/2.0";
|
||||
|
||||
/// <summary>
|
||||
/// Play V3 API URL template. Format: {0} = business unit (srf, rts, rsi, rtr, swi).
|
||||
/// Used for fetching shows, topics, and latest content.
|
||||
/// </summary>
|
||||
public const string PlayV3BaseUrlTemplate = "https://www.{0}.ch/play/v3/api/{0}/production/";
|
||||
|
||||
/// <summary>
|
||||
/// Akamai token authentication endpoint.
|
||||
/// Used to get hdnts tokens for stream authentication.
|
||||
/// </summary>
|
||||
public const string AkamaiTokenEndpoint = "https://tp.srgssr.ch/akahd/token";
|
||||
|
||||
/// <summary>
|
||||
/// SRF Play homepage URL.
|
||||
/// </summary>
|
||||
public const string SrfPlayHomepage = "https://www.srf.ch/play";
|
||||
|
||||
/// <summary>
|
||||
/// Base proxy route for stream proxying (relative to server root).
|
||||
/// </summary>
|
||||
public const string ProxyBasePath = "/Plugins/SRFPlay/Proxy";
|
||||
|
||||
/// <summary>
|
||||
/// HLS master manifest route template (relative to server root).
|
||||
/// Format: {0} = item ID.
|
||||
/// </summary>
|
||||
public const string ProxyMasterManifestPath = "/Plugins/SRFPlay/Proxy/{0}/master.m3u8";
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.SRFPlay.Api.Models;
|
||||
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.SRFPlay.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Controller for managing sport livestream recordings.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("Plugins/SRFPlay/Recording")]
|
||||
[Authorize]
|
||||
public class RecordingController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<RecordingController> _logger;
|
||||
private readonly IRecordingService _recordingService;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RecordingController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">The logger.</param>
|
||||
/// <param name="recordingService">The recording service.</param>
|
||||
public RecordingController(
|
||||
ILogger<RecordingController> logger,
|
||||
IRecordingService recordingService)
|
||||
{
|
||||
_logger = logger;
|
||||
_recordingService = recordingService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets upcoming sport livestreams available for recording.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>List of upcoming livestreams.</returns>
|
||||
[HttpGet("Schedule")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetSchedule(CancellationToken cancellationToken)
|
||||
{
|
||||
var schedule = await _recordingService.GetUpcomingScheduleAsync(cancellationToken).ConfigureAwait(false);
|
||||
return Ok(schedule);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schedules a livestream for recording by URN.
|
||||
/// </summary>
|
||||
/// <param name="urn">The SRF URN.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>The created recording entry.</returns>
|
||||
[HttpPost("Schedule/{urn}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> ScheduleRecording(
|
||||
[FromRoute] string urn,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrEmpty(urn))
|
||||
{
|
||||
return BadRequest("URN is required");
|
||||
}
|
||||
|
||||
// URN comes URL-encoded with colons, decode it
|
||||
urn = System.Net.WebUtility.UrlDecode(urn);
|
||||
|
||||
_logger.LogInformation("Scheduling recording for URN: {Urn}", urn);
|
||||
var entry = await _recordingService.ScheduleRecordingAsync(urn, cancellationToken).ConfigureAwait(false);
|
||||
return Ok(entry);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cancels a scheduled recording.
|
||||
/// </summary>
|
||||
/// <param name="id">The recording ID.</param>
|
||||
/// <returns>OK or NotFound.</returns>
|
||||
[HttpDelete("Schedule/{id}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public IActionResult CancelRecording([FromRoute] string id)
|
||||
{
|
||||
return _recordingService.CancelRecording(id) ? Ok() : NotFound();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets currently active recordings.
|
||||
/// </summary>
|
||||
/// <returns>List of active recordings.</returns>
|
||||
[HttpGet("Active")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public IActionResult GetActiveRecordings()
|
||||
{
|
||||
var active = _recordingService.GetRecordings(RecordingState.Recording);
|
||||
return Ok(active);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops an active recording.
|
||||
/// </summary>
|
||||
/// <param name="id">The recording ID.</param>
|
||||
/// <returns>OK or NotFound.</returns>
|
||||
[HttpPost("Active/{id}/Stop")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public IActionResult StopRecording([FromRoute] string id)
|
||||
{
|
||||
return _recordingService.StopRecording(id) ? Ok() : NotFound();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets completed recordings.
|
||||
/// </summary>
|
||||
/// <returns>List of completed recordings.</returns>
|
||||
[HttpGet("Completed")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public IActionResult GetCompletedRecordings()
|
||||
{
|
||||
var completed = _recordingService.GetRecordings(RecordingState.Completed);
|
||||
return Ok(completed);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all recordings (all states).
|
||||
/// </summary>
|
||||
/// <returns>List of all recordings.</returns>
|
||||
[HttpGet("All")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public IActionResult GetAllRecordings()
|
||||
{
|
||||
var all = _recordingService.GetRecordings();
|
||||
return Ok(all);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a completed recording and its file.
|
||||
/// </summary>
|
||||
/// <param name="id">The recording ID.</param>
|
||||
/// <param name="deleteFile">Whether to delete the file too.</param>
|
||||
/// <returns>OK or NotFound.</returns>
|
||||
[HttpDelete("Completed/{id}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public IActionResult DeleteRecording(
|
||||
[FromRoute] string id,
|
||||
[FromQuery] bool deleteFile = true)
|
||||
{
|
||||
return _recordingService.DeleteRecording(id, deleteFile) ? Ok() : NotFound();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
||||
using Jellyfin.Plugin.SRFPlay.Utilities;
|
||||
using MediaBrowser.Common.Net;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.SRFPlay.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Controller for proxying SRF Play streams.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("Plugins/SRFPlay/Proxy")]
|
||||
public class StreamProxyController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<StreamProxyController> _logger;
|
||||
private readonly IStreamProxyService _proxyService;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="StreamProxyController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">The logger.</param>
|
||||
/// <param name="proxyService">The proxy service.</param>
|
||||
/// <param name="httpClientFactory">The HTTP client factory.</param>
|
||||
public StreamProxyController(
|
||||
ILogger<StreamProxyController> logger,
|
||||
IStreamProxyService proxyService,
|
||||
IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
_logger = logger;
|
||||
_proxyService = proxyService;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds CORS headers to allow cross-origin requests from hls.js in browsers.
|
||||
/// </summary>
|
||||
private void AddCorsHeaders()
|
||||
{
|
||||
Response.Headers["Access-Control-Allow-Origin"] = "*";
|
||||
Response.Headers["Access-Control-Allow-Methods"] = "GET, HEAD, OPTIONS";
|
||||
Response.Headers["Access-Control-Allow-Headers"] = "Content-Type, Range, Accept, Origin";
|
||||
Response.Headers["Access-Control-Expose-Headers"] = "Content-Length, Content-Range, Accept-Ranges";
|
||||
Response.Headers["Access-Control-Max-Age"] = "86400"; // Cache preflight for 24 hours
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds Cache-Control headers appropriate for the stream type.
|
||||
/// Livestreams need frequent manifest refresh, VOD can be cached longer.
|
||||
/// </summary>
|
||||
/// <param name="itemId">The item ID to check.</param>
|
||||
/// <param name="isVariantManifest">Whether this is a variant (sub) manifest with segment lists.</param>
|
||||
private void AddManifestCacheHeaders(string itemId, bool isVariantManifest = false)
|
||||
{
|
||||
var metadata = _proxyService.GetStreamMetadata(itemId);
|
||||
var isLiveStream = metadata?.IsLiveStream ?? false;
|
||||
|
||||
if (isLiveStream)
|
||||
{
|
||||
if (isVariantManifest)
|
||||
{
|
||||
// Variant manifests contain the segment list which changes every target duration.
|
||||
// Use no-cache to ensure the player always gets the freshest segment list,
|
||||
// preventing requests for segments that have been rotated out of the sliding window.
|
||||
Response.Headers["Cache-Control"] = "no-cache, no-store";
|
||||
}
|
||||
else
|
||||
{
|
||||
// Master manifest is relatively stable (just lists quality variants)
|
||||
Response.Headers["Cache-Control"] = "max-age=2, must-revalidate";
|
||||
}
|
||||
|
||||
_logger.LogDebug("Setting livestream cache headers for {ItemId} (variant={IsVariant})", itemId, isVariantManifest);
|
||||
}
|
||||
else
|
||||
{
|
||||
// VOD manifests are static, can cache longer
|
||||
Response.Headers["Cache-Control"] = "max-age=3600";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles CORS preflight OPTIONS requests for all proxy endpoints.
|
||||
/// </summary>
|
||||
/// <returns>Empty response with CORS headers.</returns>
|
||||
[HttpOptions("{itemId}/master.m3u8")]
|
||||
[HttpOptions("{itemId}/{manifestPath}.m3u8")]
|
||||
[HttpOptions("{itemId}/{*segmentPath}")]
|
||||
[AllowAnonymous]
|
||||
public IActionResult HandleOptions()
|
||||
{
|
||||
AddCorsHeaders();
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Proxies HLS master manifest requests.
|
||||
/// </summary>
|
||||
/// <param name="itemId">The item ID from URL path.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The HLS manifest with rewritten URLs.</returns>
|
||||
[HttpGet("{itemId}/master.m3u8")]
|
||||
[AllowAnonymous] // Allow anonymous since Jellyfin handles auth upstream
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetMasterManifest(
|
||||
[FromRoute] string itemId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
AddCorsHeaders();
|
||||
_logger.LogInformation("Proxy request for master manifest - Path ItemId: {PathItemId}, Query params: {QueryString}", itemId, Request.QueryString);
|
||||
|
||||
// Try to resolve the actual item ID (path ID might be a session ID during transcoding)
|
||||
var actualItemId = ResolveItemId(itemId);
|
||||
|
||||
try
|
||||
{
|
||||
// Get the correct scheme (https if configured, otherwise use request scheme)
|
||||
var scheme = GetProxyScheme();
|
||||
|
||||
// Build the base proxy URL for this item
|
||||
// Preserve query parameters (token or itemId) from the original request
|
||||
string baseProxyUrl;
|
||||
if (Request.Query.TryGetValue("token", out var token) && !string.IsNullOrEmpty(token))
|
||||
{
|
||||
baseProxyUrl = $"{scheme}://{Request.Host}/Plugins/SRFPlay/Proxy/{itemId}?token={token}";
|
||||
_logger.LogDebug("Using token-based proxy URL with token: {Token}", token.ToString());
|
||||
}
|
||||
else if (actualItemId != itemId)
|
||||
{
|
||||
// Legacy: If path ID differs from resolved ID, add itemId query parameter
|
||||
baseProxyUrl = $"{scheme}://{Request.Host}/Plugins/SRFPlay/Proxy/{itemId}?itemId={actualItemId}";
|
||||
_logger.LogInformation("Path itemId {PathId} differs from resolved itemId {ResolvedId}, adding query parameter", itemId, actualItemId);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Simple case: no query parameters needed
|
||||
baseProxyUrl = $"{scheme}://{Request.Host}/Plugins/SRFPlay/Proxy/{itemId}";
|
||||
_logger.LogDebug("Path itemId matches resolved itemId: {ItemId}", itemId);
|
||||
}
|
||||
|
||||
var manifestContent = await _proxyService.GetRewrittenManifestAsync(actualItemId, baseProxyUrl, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (manifestContent == null)
|
||||
{
|
||||
_logger.LogWarning("Manifest not found for path itemId {PathItemId}, resolved itemId {ResolvedItemId} - stream may not be registered", itemId, actualItemId);
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
// Set cache headers based on stream type (live vs VOD)
|
||||
AddManifestCacheHeaders(actualItemId);
|
||||
|
||||
_logger.LogDebug("Returning master manifest for item {ItemId} ({Length} bytes)", itemId, manifestContent.Length);
|
||||
return Content(manifestContent, "application/vnd.apple.mpegurl; charset=utf-8");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error proxying master manifest for item {ItemId}", itemId);
|
||||
return StatusCode(StatusCodes.Status500InternalServerError);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Proxies HLS variant manifest requests.
|
||||
/// </summary>
|
||||
/// <param name="itemId">The item ID.</param>
|
||||
/// <param name="manifestPath">The manifest path (e.g., "index_0_av").</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The HLS manifest with rewritten segment URLs.</returns>
|
||||
[HttpGet("{itemId}/{manifestPath}.m3u8")]
|
||||
[AllowAnonymous]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetVariantManifest(
|
||||
[FromRoute] string itemId,
|
||||
[FromRoute] string manifestPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
AddCorsHeaders();
|
||||
var fullPath = $"{manifestPath}.m3u8";
|
||||
_logger.LogInformation("Proxy request for variant manifest - ItemId: {ItemId}, Path: {Path}", itemId, fullPath);
|
||||
|
||||
// Try to resolve the actual item ID
|
||||
var actualItemId = ResolveItemId(itemId);
|
||||
|
||||
try
|
||||
{
|
||||
// Fetch the variant manifest as a segment
|
||||
var queryString = Request.QueryString.HasValue ? Request.QueryString.Value : null;
|
||||
var manifestData = await _proxyService.GetSegmentAsync(actualItemId, fullPath, queryString, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (manifestData == null)
|
||||
{
|
||||
_logger.LogWarning("Variant manifest not found - ItemId: {ItemId}, Path: {Path}", itemId, fullPath);
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
// Convert to string and rewrite URLs
|
||||
var manifestContent = System.Text.Encoding.UTF8.GetString(manifestData);
|
||||
var scheme = GetProxyScheme();
|
||||
var baseProxyUrl = $"{scheme}://{Request.Host}/Plugins/SRFPlay/Proxy/{itemId}";
|
||||
|
||||
// Extract query parameters from the current request to propagate them
|
||||
string queryParams;
|
||||
if (Request.Query.TryGetValue("token", out var tokenVal) && !string.IsNullOrEmpty(tokenVal))
|
||||
{
|
||||
queryParams = $"?token={tokenVal}";
|
||||
}
|
||||
else if (Request.Query.TryGetValue("itemId", out var itemIdVal) && !string.IsNullOrEmpty(itemIdVal))
|
||||
{
|
||||
queryParams = $"?itemId={itemIdVal}";
|
||||
}
|
||||
else
|
||||
{
|
||||
queryParams = string.Empty;
|
||||
}
|
||||
|
||||
var rewrittenContent = _proxyService.RewriteVariantManifestUrls(manifestContent, baseProxyUrl, queryParams);
|
||||
|
||||
// Set cache headers based on stream type (live vs VOD)
|
||||
// Variant manifests use stricter no-cache for live streams
|
||||
AddManifestCacheHeaders(actualItemId, isVariantManifest: true);
|
||||
|
||||
_logger.LogDebug("Returning variant manifest for item {ItemId} ({Length} bytes)", itemId, rewrittenContent.Length);
|
||||
return Content(rewrittenContent, "application/vnd.apple.mpegurl; charset=utf-8");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error proxying variant manifest - ItemId: {ItemId}, Path: {Path}", itemId, fullPath);
|
||||
return StatusCode(StatusCodes.Status500InternalServerError);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Proxies HLS segment requests (.ts, .mp4, .m4s, .aac files).
|
||||
/// </summary>
|
||||
/// <param name="itemId">The item ID.</param>
|
||||
/// <param name="segmentPath">The segment path.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The segment data.</returns>
|
||||
[HttpGet("{itemId}/{*segmentPath}")]
|
||||
[AllowAnonymous]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetSegment(
|
||||
[FromRoute] string itemId,
|
||||
[FromRoute] string segmentPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
AddCorsHeaders();
|
||||
_logger.LogDebug("Proxy request for segment - ItemId: {ItemId}, Path: {SegmentPath}", itemId, segmentPath);
|
||||
|
||||
// Try to resolve the actual item ID
|
||||
var actualItemId = ResolveItemId(itemId);
|
||||
|
||||
try
|
||||
{
|
||||
// Pass the original query string to preserve segment-specific parameters (e.g., ?m=timestamp)
|
||||
var queryString = Request.QueryString.HasValue ? Request.QueryString.Value : null;
|
||||
|
||||
// Use streaming proxy: starts forwarding data to the client before the full segment
|
||||
// is downloaded from the CDN, reducing time-to-first-byte for live streams
|
||||
using var upstreamResponse = await _proxyService.GetSegmentStreamAsync(actualItemId, segmentPath, queryString, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (upstreamResponse == null)
|
||||
{
|
||||
_logger.LogWarning("Segment not found - ItemId: {ItemId}, Path: {SegmentPath}", itemId, segmentPath);
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
// Determine content type based on file extension
|
||||
var contentType = MimeTypeHelper.GetSegmentContentType(segmentPath);
|
||||
Response.ContentType = contentType;
|
||||
|
||||
if (upstreamResponse.Content.Headers.ContentLength.HasValue)
|
||||
{
|
||||
Response.ContentLength = upstreamResponse.Content.Headers.ContentLength.Value;
|
||||
}
|
||||
|
||||
// Stream directly from CDN to client without buffering the entire segment in memory
|
||||
await upstreamResponse.Content.CopyToAsync(Response.Body, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogDebug("Streamed segment {SegmentPath} ({ContentType})", segmentPath, contentType);
|
||||
return new EmptyResult();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Client disconnected during streaming (e.g., FFmpeg stopped, player seeked).
|
||||
// This is expected behavior, not an error.
|
||||
_logger.LogDebug("Segment streaming canceled - ItemId: {ItemId}, Path: {SegmentPath}", itemId, segmentPath);
|
||||
return new EmptyResult();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error proxying segment - ItemId: {ItemId}, Path: {SegmentPath}", itemId, segmentPath);
|
||||
|
||||
// If we already started streaming data to the client, we can't change the status code
|
||||
if (Response.HasStarted)
|
||||
{
|
||||
return new EmptyResult();
|
||||
}
|
||||
|
||||
return StatusCode(StatusCodes.Status500InternalServerError);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the correct scheme for proxy URLs (https if public URL is configured with https).
|
||||
/// </summary>
|
||||
/// <returns>The scheme to use (http or https).</returns>
|
||||
private string GetProxyScheme()
|
||||
{
|
||||
// Check if PublicServerUrl is configured and uses HTTPS
|
||||
var config = Plugin.Instance?.Configuration;
|
||||
if (config != null && !string.IsNullOrWhiteSpace(config.PublicServerUrl))
|
||||
{
|
||||
if (config.PublicServerUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "https";
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to request scheme, but prefer https if forwarded headers indicate it
|
||||
if (Request.Headers.TryGetValue("X-Forwarded-Proto", out var forwardedProto))
|
||||
{
|
||||
return forwardedProto.ToString().ToLowerInvariant();
|
||||
}
|
||||
|
||||
return Request.Scheme;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the actual item ID from the request.
|
||||
/// </summary>
|
||||
/// <param name="pathItemId">The item ID from the URL path.</param>
|
||||
/// <returns>The resolved item ID.</returns>
|
||||
private string ResolveItemId(string pathItemId)
|
||||
{
|
||||
// Check for token parameter first (preferred method)
|
||||
if (Request.Query.TryGetValue("token", out var token) && !string.IsNullOrEmpty(token))
|
||||
{
|
||||
// Try to resolve the original item ID from the token via the proxy service
|
||||
// We'll need to add a method to StreamProxyService to look up by token
|
||||
_logger.LogInformation("Found token parameter: {Token}, will use path ID {PathItemId} for lookup", token.ToString(), pathItemId);
|
||||
return pathItemId; // Use path ID for now; token prevents Jellyfin from rewriting the URL
|
||||
}
|
||||
|
||||
// Check if there's an itemId query parameter (legacy fallback)
|
||||
if (Request.Query.TryGetValue("itemId", out var queryItemId) && !string.IsNullOrEmpty(queryItemId))
|
||||
{
|
||||
_logger.LogInformation("Using itemId from query parameter: {QueryItemId} (path had: {PathItemId})", queryItemId.ToString(), pathItemId);
|
||||
return queryItemId.ToString();
|
||||
}
|
||||
|
||||
// No query parameters - use path ID as-is (normal case for segments and transcoding sessions)
|
||||
_logger.LogDebug("No query parameters, using path ID as-is: {PathItemId}", pathItemId);
|
||||
return pathItemId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Proxies image requests from SRF CDN, fixing Content-Type headers.
|
||||
/// </summary>
|
||||
/// <param name="url">The original image URL (base64 encoded).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The image data with correct Content-Type.</returns>
|
||||
[HttpGet("Image")]
|
||||
[AllowAnonymous]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetImage(
|
||||
[FromQuery] string url,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrEmpty(url))
|
||||
{
|
||||
return BadRequest("URL parameter is required");
|
||||
}
|
||||
|
||||
string decodedUrl;
|
||||
try
|
||||
{
|
||||
// Decode base64 URL
|
||||
var bytes = Convert.FromBase64String(url);
|
||||
decodedUrl = System.Text.Encoding.UTF8.GetString(bytes);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
_logger.LogWarning("Invalid base64 URL parameter: {Url}", url);
|
||||
return BadRequest("Invalid URL encoding");
|
||||
}
|
||||
|
||||
_logger.LogDebug("Proxying image from: {Url}", decodedUrl);
|
||||
|
||||
try
|
||||
{
|
||||
var httpClient = _httpClientFactory.CreateClient(NamedClient.Default);
|
||||
|
||||
// Create request with proper headers
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, new Uri(decodedUrl));
|
||||
request.Headers.UserAgent.ParseAdd(
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36");
|
||||
request.Headers.Accept.ParseAdd("image/jpeg, image/png, image/webp, image/*;q=0.8");
|
||||
|
||||
var response = await httpClient.SendAsync(
|
||||
request,
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Image fetch failed with status {StatusCode} for {Url}",
|
||||
response.StatusCode,
|
||||
decodedUrl);
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var imageData = await response.Content.ReadAsByteArrayAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// Determine correct content type
|
||||
var contentType = MimeTypeHelper.GetImageContentType(decodedUrl);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Returning proxied image ({Length} bytes, {ContentType})",
|
||||
imageData.Length,
|
||||
contentType);
|
||||
|
||||
return File(imageData, contentType);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error fetching image from {Url}", decodedUrl);
|
||||
return StatusCode(StatusCodes.Status502BadGateway);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a placeholder image with the given text centered.
|
||||
/// </summary>
|
||||
/// <param name="text">The text to display (base64 encoded).</param>
|
||||
/// <returns>A PNG image with the text centered.</returns>
|
||||
[HttpGet("Placeholder")]
|
||||
[AllowAnonymous]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public IActionResult GetPlaceholder([FromQuery] string text)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text))
|
||||
{
|
||||
return BadRequest("Text parameter is required");
|
||||
}
|
||||
|
||||
string decodedText;
|
||||
try
|
||||
{
|
||||
var bytes = Convert.FromBase64String(text);
|
||||
decodedText = System.Text.Encoding.UTF8.GetString(bytes);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
_logger.LogWarning("Invalid base64 text parameter: {Text}", text);
|
||||
return BadRequest("Invalid text encoding");
|
||||
}
|
||||
|
||||
_logger.LogDebug("Generating placeholder image for: {Text}", decodedText);
|
||||
|
||||
var imageStream = PlaceholderImageGenerator.GeneratePlaceholder(decodedText);
|
||||
return File(imageStream, "image/png");
|
||||
}
|
||||
}
|
||||
+12
-5
@@ -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,15 @@
|
||||
</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" />
|
||||
<PackageReference Include="SkiaSharp" Version="2.88.8" />
|
||||
</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>
|
||||
|
||||
@@ -26,4 +28,9 @@
|
||||
<EmbeddedResource Include="Configuration\configPage.html" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Remove="..\assests\main logo.png" />
|
||||
<EmbeddedResource Include="..\assests\main logo.png" LogicalName="Jellyfin.Plugin.SRFPlay.Images.logo.png" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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,166 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
||||
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 IMediaCompositionFetcher _compositionFetcher;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SRFEpisodeProvider"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">The logger.</param>
|
||||
/// <param name="compositionFetcher">The media composition fetcher.</param>
|
||||
public SRFEpisodeProvider(
|
||||
ILogger<SRFEpisodeProvider> logger,
|
||||
IMediaCompositionFetcher compositionFetcher)
|
||||
{
|
||||
_logger = logger;
|
||||
_compositionFetcher = compositionFetcher;
|
||||
}
|
||||
|
||||
/// <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 mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (mediaComposition?.HasChapters == true)
|
||||
{
|
||||
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 mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (mediaComposition?.HasChapters != true)
|
||||
{
|
||||
_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,205 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
||||
using Jellyfin.Plugin.SRFPlay.Utilities;
|
||||
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 IMediaCompositionFetcher _compositionFetcher;
|
||||
|
||||
/// <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>
|
||||
/// <param name="compositionFetcher">The media composition fetcher.</param>
|
||||
public SRFImageProvider(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
ILoggerFactory loggerFactory,
|
||||
IMediaCompositionFetcher compositionFetcher)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_logger = loggerFactory.CreateLogger<SRFImageProvider>();
|
||||
_compositionFetcher = compositionFetcher;
|
||||
}
|
||||
|
||||
/// <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
|
||||
var mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(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.HasChapters)
|
||||
{
|
||||
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 async Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogDebug("Fetching image from URL: {Url}", url);
|
||||
|
||||
var httpClient = _httpClientFactory.CreateClient(NamedClient.Default);
|
||||
|
||||
// Create request with proper headers - SRF CDN requires User-Agent
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, new Uri(url));
|
||||
request.Headers.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");
|
||||
request.Headers.Accept.ParseAdd("image/jpeg, image/png, image/webp, image/*;q=0.8");
|
||||
|
||||
var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var originalContentType = response.Content.Headers.ContentType?.MediaType;
|
||||
_logger.LogDebug(
|
||||
"Image response status: {StatusCode}, Content-Type: {ContentType}, Content-Length: {Length}",
|
||||
response.StatusCode,
|
||||
originalContentType ?? "null",
|
||||
response.Content.Headers.ContentLength);
|
||||
|
||||
// Fix Content-Type if it's not a proper image type - SRF CDN often returns wrong content type
|
||||
// Jellyfin needs correct Content-Type to process images
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var needsContentTypeFix = string.IsNullOrEmpty(originalContentType) ||
|
||||
originalContentType == "binary/octet-stream" ||
|
||||
originalContentType == "application/octet-stream" ||
|
||||
!originalContentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (needsContentTypeFix)
|
||||
{
|
||||
// Determine correct content type from URL extension or default to JPEG
|
||||
var contentType = MimeTypeHelper.GetImageContentType(url);
|
||||
if (!string.IsNullOrEmpty(contentType))
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Fixing Content-Type from '{OriginalType}' to '{NewType}' for {Url}",
|
||||
originalContentType ?? "null",
|
||||
contentType,
|
||||
url);
|
||||
response.Content.Headers.ContentType = new MediaTypeHeaderValue(contentType);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.SRFPlay.Configuration;
|
||||
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Model.Dto;
|
||||
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 IMediaCompositionFetcher _compositionFetcher;
|
||||
private readonly IStreamUrlResolver _streamResolver;
|
||||
private readonly IMediaSourceFactory _mediaSourceFactory;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SRFMediaProvider"/> class.
|
||||
/// </summary>
|
||||
/// <param name="loggerFactory">The logger factory.</param>
|
||||
/// <param name="compositionFetcher">The media composition fetcher.</param>
|
||||
/// <param name="streamResolver">The stream URL resolver.</param>
|
||||
/// <param name="mediaSourceFactory">The media source factory.</param>
|
||||
public SRFMediaProvider(
|
||||
ILoggerFactory loggerFactory,
|
||||
IMediaCompositionFetcher compositionFetcher,
|
||||
IStreamUrlResolver streamResolver,
|
||||
IMediaSourceFactory mediaSourceFactory)
|
||||
{
|
||||
_logger = loggerFactory.CreateLogger<SRFMediaProvider>();
|
||||
_compositionFetcher = compositionFetcher;
|
||||
_streamResolver = streamResolver;
|
||||
_mediaSourceFactory = mediaSourceFactory;
|
||||
}
|
||||
|
||||
/// <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 async 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 sources;
|
||||
}
|
||||
|
||||
_logger.LogDebug("GetMediaSources for URN: {Urn}, Item: {ItemName}", urn, item.Name);
|
||||
|
||||
// For scheduled livestreams, use shorter cache TTL (5 minutes) so they refresh when they go live
|
||||
var cacheDuration = urn.Contains("scheduled_livestream", StringComparison.OrdinalIgnoreCase) ? 5 : (int?)null;
|
||||
|
||||
var mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken, cacheDuration).ConfigureAwait(false);
|
||||
|
||||
if (mediaComposition?.HasChapters != true)
|
||||
{
|
||||
_logger.LogWarning("No chapters found for URN: {Urn}", urn);
|
||||
return 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 sources;
|
||||
}
|
||||
|
||||
// Check if content has playable streams
|
||||
if (!_streamResolver.HasPlayableContent(chapter))
|
||||
{
|
||||
_logger.LogWarning("No playable content found for URN: {Urn}", urn);
|
||||
return sources;
|
||||
}
|
||||
|
||||
// Get quality preference from config
|
||||
var config = Plugin.Instance?.Configuration;
|
||||
var qualityPref = config?.QualityPreference ?? QualityPreference.HD;
|
||||
|
||||
// Use item ID in hex format without dashes
|
||||
var itemIdStr = item.Id.ToString("N");
|
||||
|
||||
// Use factory to create MediaSourceInfo
|
||||
var mediaSource = await _mediaSourceFactory.CreateMediaSourceAsync(
|
||||
chapter,
|
||||
itemIdStr,
|
||||
urn,
|
||||
qualityPref,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// For scheduled livestreams, retry with fresh data if no stream URL
|
||||
if (mediaSource == null && chapter.Type == "SCHEDULED_LIVESTREAM")
|
||||
{
|
||||
_logger.LogDebug("URN {Urn}: Scheduled livestream has no stream URL, fetching fresh data", urn);
|
||||
|
||||
// Force fresh fetch with short cache duration
|
||||
var freshMediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken, 1).ConfigureAwait(false);
|
||||
|
||||
if (freshMediaComposition?.HasChapters == true)
|
||||
{
|
||||
var freshChapter = freshMediaComposition.ChapterList[0];
|
||||
mediaSource = await _mediaSourceFactory.CreateMediaSourceAsync(
|
||||
freshChapter,
|
||||
itemIdStr,
|
||||
urn,
|
||||
qualityPref,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (mediaSource != null)
|
||||
{
|
||||
chapter = freshChapter;
|
||||
_logger.LogInformation("URN {Urn}: Got fresh stream URL for scheduled livestream", urn);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (mediaSource == null)
|
||||
{
|
||||
_logger.LogWarning("Could not resolve stream URL for URN: {Urn}", urn);
|
||||
return sources;
|
||||
}
|
||||
|
||||
sources.Add(mediaSource);
|
||||
_logger.LogDebug(
|
||||
"MediaSource created for {Title} - Id={Id}, DirectPlay={DirectPlay}, Transcoding={Transcoding}",
|
||||
chapter.Title,
|
||||
mediaSource.Id,
|
||||
mediaSource.SupportsDirectPlay,
|
||||
mediaSource.SupportsTranscoding);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting media sources for item: {Name}", item.Name);
|
||||
}
|
||||
|
||||
return 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)
|
||||
{
|
||||
_logger.LogInformation("GetDirectStreamProviderByUniqueId called with uniqueId: {UniqueId}", uniqueId);
|
||||
// Not needed for HTTP streams
|
||||
return Task.FromResult<IDirectStreamProvider?>(null);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ILiveStream> OpenMediaSource(string openToken, List<ILiveStream> currentLiveStreams, CancellationToken cancellationToken)
|
||||
{
|
||||
// Not used - RequiresOpening is false, proxy handles authentication directly
|
||||
_logger.LogWarning("OpenMediaSource unexpectedly called with openToken: {OpenToken}", openToken);
|
||||
throw new NotSupportedException("OpenMediaSource not supported - streams use direct proxy access");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
||||
using MediaBrowser.Controller.Entities.TV;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
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 IMediaCompositionFetcher _compositionFetcher;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SRFSeriesProvider"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">The logger.</param>
|
||||
/// <param name="compositionFetcher">The media composition fetcher.</param>
|
||||
public SRFSeriesProvider(
|
||||
ILogger<SRFSeriesProvider> logger,
|
||||
IMediaCompositionFetcher compositionFetcher)
|
||||
{
|
||||
_logger = logger;
|
||||
_compositionFetcher = compositionFetcher;
|
||||
}
|
||||
|
||||
/// <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 mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
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 mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
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.Interfaces;
|
||||
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 IContentRefreshService _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,
|
||||
IContentRefreshService 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,102 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
||||
using MediaBrowser.Model.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.SRFPlay.ScheduledTasks;
|
||||
|
||||
/// <summary>
|
||||
/// Scheduled task for checking and removing expired SRF Play content.
|
||||
/// </summary>
|
||||
public class ExpirationCheckTask : IScheduledTask
|
||||
{
|
||||
private readonly ILogger<ExpirationCheckTask> _logger;
|
||||
private readonly IContentExpirationService _expirationService;
|
||||
private readonly IStreamProxyService _streamProxyService;
|
||||
|
||||
/// <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>
|
||||
/// <param name="streamProxyService">The stream proxy service.</param>
|
||||
public ExpirationCheckTask(
|
||||
ILogger<ExpirationCheckTask> logger,
|
||||
IContentExpirationService expirationService,
|
||||
IStreamProxyService streamProxyService)
|
||||
{
|
||||
_logger = logger;
|
||||
_expirationService = expirationService;
|
||||
_streamProxyService = streamProxyService;
|
||||
}
|
||||
|
||||
/// <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;
|
||||
}
|
||||
|
||||
// Check and remove expired content
|
||||
progress?.Report(25);
|
||||
var removedCount = await _expirationService.CheckAndRemoveExpiredContentAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Clean up old stream proxy mappings
|
||||
progress?.Report(75);
|
||||
_streamProxyService.CleanupOldMappings();
|
||||
|
||||
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,76 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
||||
using MediaBrowser.Model.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.SRFPlay.ScheduledTasks;
|
||||
|
||||
/// <summary>
|
||||
/// Scheduled task that checks and manages sport livestream recordings.
|
||||
/// Runs every 2 minutes to start scheduled recordings when streams go live
|
||||
/// and stop recordings when they end.
|
||||
/// </summary>
|
||||
public class RecordingSchedulerTask : IScheduledTask
|
||||
{
|
||||
private readonly ILogger<RecordingSchedulerTask> _logger;
|
||||
private readonly IRecordingService _recordingService;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RecordingSchedulerTask"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">The logger.</param>
|
||||
/// <param name="recordingService">The recording service.</param>
|
||||
public RecordingSchedulerTask(
|
||||
ILogger<RecordingSchedulerTask> logger,
|
||||
IRecordingService recordingService)
|
||||
{
|
||||
_logger = logger;
|
||||
_recordingService = recordingService;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "Process SRF Play Recordings";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Checks scheduled recordings and starts/stops them as needed";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Category => "SRF Play";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Key => "SRFPlayRecordingScheduler";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogDebug("Processing SRF Play recordings");
|
||||
progress?.Report(0);
|
||||
|
||||
try
|
||||
{
|
||||
await _recordingService.ProcessRecordingsAsync(cancellationToken).ConfigureAwait(false);
|
||||
progress?.Report(100);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error processing recordings");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
|
||||
{
|
||||
return new[]
|
||||
{
|
||||
new TaskTriggerInfo
|
||||
{
|
||||
Type = TaskTriggerInfo.TriggerInterval,
|
||||
IntervalTicks = TimeSpan.FromSeconds(30).Ticks
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using Jellyfin.Plugin.SRFPlay.Api;
|
||||
using Jellyfin.Plugin.SRFPlay.Channels;
|
||||
using Jellyfin.Plugin.SRFPlay.Providers;
|
||||
using Jellyfin.Plugin.SRFPlay.ScheduledTasks;
|
||||
using Jellyfin.Plugin.SRFPlay.Services;
|
||||
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
||||
using MediaBrowser.Controller;
|
||||
using MediaBrowser.Controller.Channels;
|
||||
using MediaBrowser.Controller.Plugins;
|
||||
using MediaBrowser.Model.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Jellyfin.Plugin.SRFPlay;
|
||||
|
||||
/// <summary>
|
||||
/// Service registrator for dependency injection.
|
||||
/// </summary>
|
||||
public class ServiceRegistrator : IPluginServiceRegistrator
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost)
|
||||
{
|
||||
// Register API client factory
|
||||
serviceCollection.AddSingleton<ISRFApiClientFactory, SRFApiClientFactory>();
|
||||
|
||||
// Register core services with interfaces
|
||||
serviceCollection.AddSingleton<IMetadataCache, MetadataCache>();
|
||||
serviceCollection.AddSingleton<IStreamUrlResolver, StreamUrlResolver>();
|
||||
serviceCollection.AddSingleton<IMediaCompositionFetcher, MediaCompositionFetcher>();
|
||||
serviceCollection.AddSingleton<IStreamProxyService, StreamProxyService>();
|
||||
serviceCollection.AddSingleton<IMediaSourceFactory, MediaSourceFactory>();
|
||||
serviceCollection.AddSingleton<IContentExpirationService, ContentExpirationService>();
|
||||
serviceCollection.AddSingleton<IContentRefreshService, ContentRefreshService>();
|
||||
serviceCollection.AddSingleton<ICategoryService, CategoryService>();
|
||||
|
||||
// Register metadata providers
|
||||
serviceCollection.AddSingleton<SRFSeriesProvider>();
|
||||
serviceCollection.AddSingleton<SRFEpisodeProvider>();
|
||||
serviceCollection.AddSingleton<SRFImageProvider>();
|
||||
|
||||
// Register media source provider
|
||||
serviceCollection.AddSingleton<SRFMediaProvider>();
|
||||
|
||||
// Register recording service
|
||||
serviceCollection.AddSingleton<IRecordingService, RecordingService>();
|
||||
|
||||
// Register scheduled tasks
|
||||
serviceCollection.AddSingleton<IScheduledTask, ContentRefreshTask>();
|
||||
serviceCollection.AddSingleton<IScheduledTask, ExpirationCheckTask>();
|
||||
serviceCollection.AddSingleton<IScheduledTask, RecordingSchedulerTask>();
|
||||
|
||||
// Register channel - must register as IChannel interface for Jellyfin to discover it
|
||||
serviceCollection.AddSingleton<IChannel, SRFPlayChannel>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
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 Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.SRFPlay.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing topic/category data and filtering.
|
||||
/// </summary>
|
||||
public class CategoryService : ICategoryService
|
||||
{
|
||||
private readonly ILogger<CategoryService> _logger;
|
||||
private readonly ISRFApiClientFactory _apiClientFactory;
|
||||
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="logger">The logger.</param>
|
||||
/// <param name="apiClientFactory">The API client factory.</param>
|
||||
public CategoryService(ILogger<CategoryService> logger, ISRFApiClientFactory apiClientFactory)
|
||||
{
|
||||
_logger = logger;
|
||||
_apiClientFactory = apiClientFactory;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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 = _apiClientFactory.CreateClient();
|
||||
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>();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<List<PlayV3Show>> GetShowsByTopicAsync(
|
||||
string topicId,
|
||||
string businessUnit,
|
||||
int maxResults = 50,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var apiClient = _apiClientFactory.CreateClient();
|
||||
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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void ClearCache()
|
||||
{
|
||||
_topicsCache = null;
|
||||
_topicsCacheExpiry = DateTime.MinValue;
|
||||
_logger.LogInformation("Topics cache cleared");
|
||||
}
|
||||
|
||||
/// <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>
|
||||
private static 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.SRFPlay.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing content expiration.
|
||||
/// </summary>
|
||||
public class ContentExpirationService : IContentExpirationService
|
||||
{
|
||||
private readonly ILogger<ContentExpirationService> _logger;
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly IStreamUrlResolver _streamResolver;
|
||||
private readonly IMediaCompositionFetcher _compositionFetcher;
|
||||
|
||||
/// <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="compositionFetcher">The media composition fetcher.</param>
|
||||
public ContentExpirationService(
|
||||
ILoggerFactory loggerFactory,
|
||||
ILibraryManager libraryManager,
|
||||
IStreamUrlResolver streamResolver,
|
||||
IMediaCompositionFetcher compositionFetcher)
|
||||
{
|
||||
_logger = loggerFactory.CreateLogger<ContentExpirationService>();
|
||||
_libraryManager = libraryManager;
|
||||
_streamResolver = streamResolver;
|
||||
_compositionFetcher = compositionFetcher;
|
||||
}
|
||||
|
||||
/// <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 mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (mediaComposition?.HasChapters != true)
|
||||
{
|
||||
// Don't treat API failures as expired - the content may still be available
|
||||
// and a transient error (network issue, 403, API outage) shouldn't delete library items
|
||||
_logger.LogWarning("Could not fetch media composition for URN: {Urn}, skipping (not treating as expired)", urn);
|
||||
return false;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
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.Interfaces;
|
||||
using Jellyfin.Plugin.SRFPlay.Utilities;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.SRFPlay.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for refreshing content from SRF API.
|
||||
/// </summary>
|
||||
public class ContentRefreshService : IContentRefreshService
|
||||
{
|
||||
private readonly ILogger<ContentRefreshService> _logger;
|
||||
private readonly ISRFApiClientFactory _apiClientFactory;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ContentRefreshService"/> class.
|
||||
/// </summary>
|
||||
/// <param name="loggerFactory">The logger factory.</param>
|
||||
/// <param name="apiClientFactory">The API client factory.</param>
|
||||
public ContentRefreshService(
|
||||
ILoggerFactory loggerFactory,
|
||||
ISRFApiClientFactory apiClientFactory)
|
||||
{
|
||||
_logger = loggerFactory.CreateLogger<ContentRefreshService>();
|
||||
_apiClientFactory = apiClientFactory;
|
||||
}
|
||||
|
||||
/// <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 config = Plugin.Instance?.Configuration;
|
||||
if (config == null || !config.EnableLatestContent)
|
||||
{
|
||||
_logger.LogDebug("Latest content refresh is disabled");
|
||||
return new List<string>();
|
||||
}
|
||||
|
||||
return await FetchVideosFromShowsAsync(
|
||||
config.BusinessUnit.ToLowerString(),
|
||||
minEpisodeCount: 0,
|
||||
maxShows: 20,
|
||||
videosPerShow: 1,
|
||||
contentType: "latest",
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <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 config = Plugin.Instance?.Configuration;
|
||||
if (config == null || !config.EnableTrendingContent)
|
||||
{
|
||||
_logger.LogDebug("Trending content refresh is disabled");
|
||||
return new List<string>();
|
||||
}
|
||||
|
||||
return await FetchVideosFromShowsAsync(
|
||||
config.BusinessUnit.ToLowerString(),
|
||||
minEpisodeCount: 10,
|
||||
maxShows: 15,
|
||||
videosPerShow: 2,
|
||||
contentType: "trending",
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches videos from shows based on filter criteria.
|
||||
/// </summary>
|
||||
private async Task<List<string>> FetchVideosFromShowsAsync(
|
||||
string businessUnit,
|
||||
int minEpisodeCount,
|
||||
int maxShows,
|
||||
int videosPerShow,
|
||||
string contentType,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var urns = new List<string>();
|
||||
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Refreshing {ContentType} content for business unit: {BusinessUnit}", contentType, businessUnit);
|
||||
|
||||
using var apiClient = _apiClientFactory.CreateClient();
|
||||
var shows = await apiClient.GetAllShowsAsync(businessUnit, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (shows == null || shows.Count == 0)
|
||||
{
|
||||
_logger.LogWarning("No shows found for business unit: {BusinessUnit}", businessUnit);
|
||||
return urns;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Found {Count} shows, fetching {ContentType} content", shows.Count, contentType);
|
||||
|
||||
var filteredShows = shows
|
||||
.Where(s => s.NumberOfEpisodes > minEpisodeCount)
|
||||
.OrderByDescending(s => s.NumberOfEpisodes)
|
||||
.Take(maxShows)
|
||||
.ToList();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
foreach (var show in filteredShows)
|
||||
{
|
||||
if (show.Id == null || cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var videos = await apiClient.GetVideosForShowAsync(businessUnit, show.Id, cancellationToken).ConfigureAwait(false);
|
||||
if (videos == null || videos.Count == 0)
|
||||
{
|
||||
_logger.LogDebug("Show {Show} ({ShowId}): No videos returned from API", show.Title, show.Id);
|
||||
continue;
|
||||
}
|
||||
|
||||
_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 publishedVideos = videos
|
||||
.Where(v => v.ValidFrom == null || v.ValidFrom.Value.ToUniversalTime() <= now)
|
||||
.OrderByDescending(v => v.Date)
|
||||
.Take(videosPerShow)
|
||||
.ToList();
|
||||
|
||||
foreach (var video in publishedVideos)
|
||||
{
|
||||
if (video.Urn != null)
|
||||
{
|
||||
urns.Add(video.Urn);
|
||||
_logger.LogDebug(
|
||||
"Added {ContentType} video from show {Show}: {Title} (URN: {Urn})",
|
||||
contentType,
|
||||
show.Title,
|
||||
video.Title,
|
||||
video.Urn);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error fetching videos for show {ShowId}", show.Id);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Refreshed {Count} {ContentType} content items from {ShowCount} shows", urns.Count, contentType, filteredShows.Count);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error refreshing {ContentType} content", contentType);
|
||||
}
|
||||
|
||||
return urns;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.SRFPlay.Api.Models;
|
||||
|
||||
namespace Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for managing topic/category data and filtering.
|
||||
/// </summary>
|
||||
public interface ICategoryService
|
||||
{
|
||||
/// <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>
|
||||
Task<List<PlayV3Topic>> GetTopicsAsync(string businessUnit, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <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>
|
||||
Task<PlayV3Topic?> GetTopicByIdAsync(string topicId, string businessUnit, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <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>
|
||||
Task<List<PlayV3Show>> GetShowsByTopicAsync(
|
||||
string topicId,
|
||||
string businessUnit,
|
||||
int maxResults = 50,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Clears the topics cache.
|
||||
/// </summary>
|
||||
void ClearCache();
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for managing content expiration.
|
||||
/// </summary>
|
||||
public interface IContentExpirationService
|
||||
{
|
||||
/// <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>
|
||||
Task<int> CheckAndRemoveExpiredContentAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for refreshing content from SRF API.
|
||||
/// </summary>
|
||||
public interface IContentRefreshService
|
||||
{
|
||||
/// <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>
|
||||
Task<List<string>> RefreshLatestContentAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Refreshes trending content from SRF API using Play v3.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>List of URNs for trending content.</returns>
|
||||
Task<List<string>> RefreshTrendingContentAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.SRFPlay.Api.Models;
|
||||
|
||||
namespace Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for fetching media composition with caching support.
|
||||
/// </summary>
|
||||
public interface IMediaCompositionFetcher
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets media composition by URN, using cache if available.
|
||||
/// </summary>
|
||||
/// <param name="urn">The URN to fetch.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <param name="cacheDurationOverride">Optional override for cache duration (e.g., 5 min for livestreams).</param>
|
||||
/// <returns>The media composition, or null if not found.</returns>
|
||||
Task<MediaComposition?> GetMediaCompositionAsync(
|
||||
string urn,
|
||||
CancellationToken cancellationToken,
|
||||
int? cacheDurationOverride = null);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.SRFPlay.Api.Models;
|
||||
using Jellyfin.Plugin.SRFPlay.Configuration;
|
||||
using MediaBrowser.Model.Dto;
|
||||
using MediaBrowser.Model.Entities;
|
||||
|
||||
namespace Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating MediaSourceInfo objects with consistent configuration.
|
||||
/// </summary>
|
||||
public interface IMediaSourceFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a MediaSourceInfo for a chapter with proper stream authentication and proxy registration.
|
||||
/// </summary>
|
||||
/// <param name="chapter">The chapter containing stream resources.</param>
|
||||
/// <param name="itemId">The unique item ID for proxy registration.</param>
|
||||
/// <param name="urn">The URN of the content.</param>
|
||||
/// <param name="qualityPreference">The preferred quality.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>A configured MediaSourceInfo, or null if stream URL cannot be resolved.</returns>
|
||||
Task<MediaSourceInfo?> CreateMediaSourceAsync(
|
||||
Chapter chapter,
|
||||
string itemId,
|
||||
string urn,
|
||||
QualityPreference qualityPreference,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Builds a proxy URL for an item.
|
||||
/// </summary>
|
||||
/// <param name="itemId">The item ID.</param>
|
||||
/// <returns>The full proxy URL including server address.</returns>
|
||||
string BuildProxyUrl(string itemId);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the server base URL (configured public URL or smart URL).
|
||||
/// </summary>
|
||||
/// <returns>The server base URL without trailing slash.</returns>
|
||||
string GetServerBaseUrl();
|
||||
|
||||
/// <summary>
|
||||
/// Creates MediaStream metadata based on quality preference.
|
||||
/// </summary>
|
||||
/// <param name="quality">The quality preference.</param>
|
||||
/// <returns>List of MediaStream objects for video and audio.</returns>
|
||||
IReadOnlyList<MediaStream> CreateMediaStreams(QualityPreference quality);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using Jellyfin.Plugin.SRFPlay.Api.Models;
|
||||
|
||||
namespace Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for caching metadata from SRF API.
|
||||
/// </summary>
|
||||
public interface IMetadataCache
|
||||
{
|
||||
/// <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>
|
||||
MediaComposition? GetMediaComposition(string urn, int cacheDurationMinutes);
|
||||
|
||||
/// <summary>
|
||||
/// Sets media composition in cache.
|
||||
/// </summary>
|
||||
/// <param name="urn">The URN.</param>
|
||||
/// <param name="mediaComposition">The media composition to cache.</param>
|
||||
void SetMediaComposition(string urn, MediaComposition mediaComposition);
|
||||
|
||||
/// <summary>
|
||||
/// Clears all cached data.
|
||||
/// </summary>
|
||||
void Clear();
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.SRFPlay.Api.Models;
|
||||
using Jellyfin.Plugin.SRFPlay.Api.Models.PlayV3;
|
||||
|
||||
namespace Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing sport livestream recordings.
|
||||
/// </summary>
|
||||
public interface IRecordingService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets upcoming sport livestreams that can be recorded.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>List of upcoming sport livestreams.</returns>
|
||||
Task<IReadOnlyList<PlayV3TvProgram>> GetUpcomingScheduleAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Schedules a livestream for recording.
|
||||
/// </summary>
|
||||
/// <param name="urn">The SRF URN to record.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>The created recording entry.</returns>
|
||||
Task<RecordingEntry> ScheduleRecordingAsync(string urn, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Cancels a scheduled recording.
|
||||
/// </summary>
|
||||
/// <param name="recordingId">The recording ID.</param>
|
||||
/// <returns>True if cancelled.</returns>
|
||||
bool CancelRecording(string recordingId);
|
||||
|
||||
/// <summary>
|
||||
/// Stops an active recording.
|
||||
/// </summary>
|
||||
/// <param name="recordingId">The recording ID.</param>
|
||||
/// <returns>True if stopped.</returns>
|
||||
bool StopRecording(string recordingId);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all recordings by state.
|
||||
/// </summary>
|
||||
/// <param name="stateFilter">Optional state filter.</param>
|
||||
/// <returns>List of matching recording entries.</returns>
|
||||
IReadOnlyList<RecordingEntry> GetRecordings(RecordingState? stateFilter = null);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a completed recording (entry and optionally the file).
|
||||
/// </summary>
|
||||
/// <param name="recordingId">The recording ID.</param>
|
||||
/// <param name="deleteFile">Whether to delete the file too.</param>
|
||||
/// <returns>True if deleted.</returns>
|
||||
bool DeleteRecording(string recordingId, bool deleteFile = true);
|
||||
|
||||
/// <summary>
|
||||
/// Checks scheduled recordings and starts/stops them as needed.
|
||||
/// Called periodically by the scheduler task.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>A task representing the async operation.</returns>
|
||||
Task ProcessRecordingsAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for proxying SRF Play streams and managing authentication.
|
||||
/// </summary>
|
||||
public interface IStreamProxyService
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers a stream for deferred authentication (authenticates on first playback request).
|
||||
/// Use this when browsing to avoid wasting 30-second tokens before the user clicks play.
|
||||
/// </summary>
|
||||
/// <param name="itemId">The item ID.</param>
|
||||
/// <param name="unauthenticatedUrl">The unauthenticated stream URL.</param>
|
||||
/// <param name="urn">The SRF URN for this content.</param>
|
||||
/// <param name="isLiveStream">Whether this is a livestream.</param>
|
||||
void RegisterStreamDeferred(string itemId, string unauthenticatedUrl, string? urn = null, bool isLiveStream = false);
|
||||
|
||||
/// <summary>
|
||||
/// Gets stream metadata for an item (URN and isLiveStream flag).
|
||||
/// </summary>
|
||||
/// <param name="itemId">The item ID.</param>
|
||||
/// <returns>A tuple of (URN, IsLiveStream), or null if not found.</returns>
|
||||
(string? Urn, bool IsLiveStream)? GetStreamMetadata(string itemId);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the authenticated URL for an item.
|
||||
/// </summary>
|
||||
/// <param name="itemId">The item ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The authenticated URL, or null if not found or expired.</returns>
|
||||
Task<string?> GetAuthenticatedUrlAsync(string itemId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Fetches and rewrites an HLS manifest to use proxy URLs.
|
||||
/// </summary>
|
||||
/// <param name="itemId">The item ID.</param>
|
||||
/// <param name="baseProxyUrl">The base proxy URL.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The rewritten manifest content.</returns>
|
||||
Task<string?> GetRewrittenManifestAsync(string itemId, string baseProxyUrl, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Fetches a segment from the original source.
|
||||
/// </summary>
|
||||
/// <param name="itemId">The item ID.</param>
|
||||
/// <param name="segmentPath">The segment path.</param>
|
||||
/// <param name="queryString">The original query string from the request (preserves segment-specific parameters like timestamps).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The segment content as bytes.</returns>
|
||||
Task<byte[]?> GetSegmentAsync(string itemId, string segmentPath, string? queryString = null, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Fetches a segment from the original source as a streaming response.
|
||||
/// Returns the HttpResponseMessage for streaming directly to the client, reducing TTFB.
|
||||
/// The caller is responsible for disposing the response.
|
||||
/// </summary>
|
||||
/// <param name="itemId">The item ID.</param>
|
||||
/// <param name="segmentPath">The segment path.</param>
|
||||
/// <param name="queryString">The original query string from the request.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The HTTP response for streaming, or null if not found.</returns>
|
||||
Task<HttpResponseMessage?> GetSegmentStreamAsync(string itemId, string segmentPath, string? queryString = null, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Rewrites URLs in a variant (sub) manifest to point to the proxy.
|
||||
/// </summary>
|
||||
/// <param name="manifestContent">The variant manifest content.</param>
|
||||
/// <param name="baseProxyUrl">The base proxy URL (without query params).</param>
|
||||
/// <param name="queryParams">Query parameters to append to rewritten URLs (e.g., "?token=abc").</param>
|
||||
/// <returns>The rewritten manifest content.</returns>
|
||||
string RewriteVariantManifestUrls(string manifestContent, string baseProxyUrl, string queryParams);
|
||||
|
||||
/// <summary>
|
||||
/// Cleans up old and expired stream mappings.
|
||||
/// </summary>
|
||||
void CleanupOldMappings();
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.SRFPlay.Api.Models;
|
||||
using Jellyfin.Plugin.SRFPlay.Configuration;
|
||||
|
||||
namespace Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for resolving stream URLs from media composition resources.
|
||||
/// </summary>
|
||||
public interface IStreamUrlResolver
|
||||
{
|
||||
/// <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>
|
||||
string? GetStreamUrl(Chapter chapter, QualityPreference qualityPreference);
|
||||
|
||||
/// <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>
|
||||
bool HasPlayableContent(Chapter chapter);
|
||||
|
||||
/// <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>
|
||||
bool IsContentExpired(Chapter chapter);
|
||||
|
||||
/// <summary>
|
||||
/// Authenticates a stream URL by fetching an Akamai token.
|
||||
/// </summary>
|
||||
/// <param name="streamUrl">The unauthenticated stream URL.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The authenticated stream URL with token.</returns>
|
||||
Task<string> GetAuthenticatedStreamUrlAsync(string streamUrl, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.SRFPlay.Api;
|
||||
using Jellyfin.Plugin.SRFPlay.Api.Models;
|
||||
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.SRFPlay.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for fetching media composition with caching support.
|
||||
/// This consolidates the cache-check → API-fetch → cache-store pattern.
|
||||
/// </summary>
|
||||
public class MediaCompositionFetcher : IMediaCompositionFetcher
|
||||
{
|
||||
private readonly ILogger<MediaCompositionFetcher> _logger;
|
||||
private readonly IMetadataCache _metadataCache;
|
||||
private readonly ISRFApiClientFactory _apiClientFactory;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MediaCompositionFetcher"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">The logger.</param>
|
||||
/// <param name="metadataCache">The metadata cache.</param>
|
||||
/// <param name="apiClientFactory">The API client factory.</param>
|
||||
public MediaCompositionFetcher(
|
||||
ILogger<MediaCompositionFetcher> logger,
|
||||
IMetadataCache metadataCache,
|
||||
ISRFApiClientFactory apiClientFactory)
|
||||
{
|
||||
_logger = logger;
|
||||
_metadataCache = metadataCache;
|
||||
_apiClientFactory = apiClientFactory;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<MediaComposition?> GetMediaCompositionAsync(
|
||||
string urn,
|
||||
CancellationToken cancellationToken,
|
||||
int? cacheDurationOverride = null)
|
||||
{
|
||||
if (string.IsNullOrEmpty(urn))
|
||||
{
|
||||
_logger.LogDebug("GetMediaCompositionAsync called with null/empty URN");
|
||||
return null;
|
||||
}
|
||||
|
||||
var config = Plugin.Instance?.Configuration;
|
||||
if (config == null)
|
||||
{
|
||||
_logger.LogWarning("Plugin configuration is null, cannot fetch media composition");
|
||||
return null;
|
||||
}
|
||||
|
||||
var cacheDuration = cacheDurationOverride ?? config.CacheDurationMinutes;
|
||||
|
||||
// Try cache first
|
||||
var mediaComposition = _metadataCache.GetMediaComposition(urn, cacheDuration);
|
||||
if (mediaComposition != null)
|
||||
{
|
||||
_logger.LogDebug("Cache hit for URN: {Urn}", urn);
|
||||
return mediaComposition;
|
||||
}
|
||||
|
||||
// Fetch from API
|
||||
_logger.LogDebug("Cache miss for URN: {Urn}, fetching from API", urn);
|
||||
using var apiClient = _apiClientFactory.CreateClient();
|
||||
mediaComposition = await apiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (mediaComposition != null)
|
||||
{
|
||||
_metadataCache.SetMediaComposition(urn, mediaComposition);
|
||||
_logger.LogDebug("Cached media composition for URN: {Urn}", urn);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Failed to fetch media composition for URN: {Urn}", urn);
|
||||
}
|
||||
|
||||
return mediaComposition;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.SRFPlay.Api.Models;
|
||||
using Jellyfin.Plugin.SRFPlay.Configuration;
|
||||
using Jellyfin.Plugin.SRFPlay.Constants;
|
||||
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
||||
using MediaBrowser.Controller;
|
||||
using MediaBrowser.Model.Dto;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.MediaInfo;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.SRFPlay.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating MediaSourceInfo objects with consistent configuration.
|
||||
/// Consolidates duplicated logic from SRFMediaProvider and SRFPlayChannel.
|
||||
/// </summary>
|
||||
public class MediaSourceFactory : IMediaSourceFactory
|
||||
{
|
||||
private readonly ILogger<MediaSourceFactory> _logger;
|
||||
private readonly IStreamUrlResolver _streamResolver;
|
||||
private readonly IStreamProxyService _proxyService;
|
||||
private readonly IServerApplicationHost _appHost;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MediaSourceFactory"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">The logger.</param>
|
||||
/// <param name="streamResolver">The stream URL resolver.</param>
|
||||
/// <param name="proxyService">The stream proxy service.</param>
|
||||
/// <param name="appHost">The server application host.</param>
|
||||
public MediaSourceFactory(
|
||||
ILogger<MediaSourceFactory> logger,
|
||||
IStreamUrlResolver streamResolver,
|
||||
IStreamProxyService proxyService,
|
||||
IServerApplicationHost appHost)
|
||||
{
|
||||
_logger = logger;
|
||||
_streamResolver = streamResolver;
|
||||
_proxyService = proxyService;
|
||||
_appHost = appHost;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<MediaSourceInfo?> CreateMediaSourceAsync(
|
||||
Chapter chapter,
|
||||
string itemId,
|
||||
string urn,
|
||||
QualityPreference qualityPreference,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Get stream URL based on quality preference (unauthenticated)
|
||||
var streamUrl = _streamResolver.GetStreamUrl(chapter, qualityPreference);
|
||||
|
||||
if (string.IsNullOrEmpty(streamUrl))
|
||||
{
|
||||
_logger.LogWarning("Could not resolve stream URL for chapter: {ChapterId}", chapter.Id);
|
||||
return Task.FromResult<MediaSourceInfo?>(null);
|
||||
}
|
||||
|
||||
// Detect if this is a live stream
|
||||
var isLiveStream = chapter.Type == "SCHEDULED_LIVESTREAM" ||
|
||||
urn.Contains("livestream", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// Register stream with UNAUTHENTICATED URL - proxy will authenticate on-demand
|
||||
// This avoids wasting 30-second tokens during category browsing
|
||||
_proxyService.RegisterStreamDeferred(itemId, streamUrl, urn, isLiveStream);
|
||||
|
||||
// Build proxy URL
|
||||
var proxyUrl = BuildProxyUrl(itemId);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Created media source for {Title} - ItemId: {ItemId}, IsLiveStream: {IsLiveStream}",
|
||||
chapter.Title,
|
||||
itemId,
|
||||
isLiveStream);
|
||||
|
||||
// Create MediaSourceInfo with codec info so clients know they can direct play
|
||||
// Provide MediaStreams with H.264+AAC so Android TV/ExoPlayer doesn't trigger transcoding
|
||||
var mediaStreams = CreateMediaStreams(qualityPreference);
|
||||
|
||||
var mediaSource = new MediaSourceInfo
|
||||
{
|
||||
Id = itemId,
|
||||
Name = chapter.Title,
|
||||
Path = proxyUrl,
|
||||
Protocol = MediaProtocol.Http,
|
||||
// Use "hls" to trigger hls.js player in web client
|
||||
Container = "hls",
|
||||
SupportsDirectStream = true,
|
||||
SupportsDirectPlay = true,
|
||||
SupportsTranscoding = false,
|
||||
IsRemote = true,
|
||||
Type = MediaSourceType.Default,
|
||||
RunTimeTicks = chapter.Duration > 0 ? TimeSpan.FromMilliseconds(chapter.Duration).Ticks : null,
|
||||
VideoType = VideoType.VideoFile,
|
||||
IsInfiniteStream = isLiveStream,
|
||||
// Don't use RequiresOpening - it forces Jellyfin to transcode which breaks token auth
|
||||
RequiresOpening = false,
|
||||
RequiresClosing = false,
|
||||
// Disable probing - we provide stream info directly
|
||||
SupportsProbing = false,
|
||||
ReadAtNativeFramerate = isLiveStream,
|
||||
// Provide codec info so clients know they can direct play H.264+AAC
|
||||
MediaStreams = mediaStreams.ToList(),
|
||||
AnalyzeDurationMs = isLiveStream ? 1000 : 3000,
|
||||
IgnoreDts = isLiveStream,
|
||||
IgnoreIndex = isLiveStream,
|
||||
};
|
||||
|
||||
return Task.FromResult<MediaSourceInfo?>(mediaSource);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string BuildProxyUrl(string itemId)
|
||||
{
|
||||
return $"{GetServerBaseUrl()}{ApiEndpoints.ProxyMasterManifestPath.Replace("{0}", itemId, StringComparison.Ordinal)}";
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string GetServerBaseUrl()
|
||||
{
|
||||
var config = Plugin.Instance?.Configuration;
|
||||
return config != null && !string.IsNullOrWhiteSpace(config.PublicServerUrl)
|
||||
? config.PublicServerUrl.TrimEnd('/')
|
||||
: _appHost.GetSmartApiUrl(string.Empty);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<MediaStream> CreateMediaStreams(QualityPreference quality)
|
||||
{
|
||||
// Set resolution/bitrate based on quality preference
|
||||
var (width, height, videoBitrate) = quality switch
|
||||
{
|
||||
QualityPreference.SD => (1280, 720, 2500000),
|
||||
QualityPreference.HD => (1920, 1080, 5000000),
|
||||
_ => (1280, 720, 3000000)
|
||||
};
|
||||
|
||||
return new List<MediaStream>
|
||||
{
|
||||
new MediaStream
|
||||
{
|
||||
Type = MediaStreamType.Video,
|
||||
Codec = "h264",
|
||||
Profile = "high",
|
||||
Level = 40,
|
||||
Width = width,
|
||||
Height = height,
|
||||
BitRate = videoBitrate,
|
||||
BitDepth = 8,
|
||||
IsInterlaced = false,
|
||||
IsDefault = true,
|
||||
Index = 0,
|
||||
IsAVC = true,
|
||||
PixelFormat = "yuv420p"
|
||||
},
|
||||
new MediaStream
|
||||
{
|
||||
Type = MediaStreamType.Audio,
|
||||
Codec = "aac",
|
||||
Profile = "LC",
|
||||
Channels = 2,
|
||||
SampleRate = 48000,
|
||||
BitRate = 128000,
|
||||
IsDefault = true,
|
||||
Index = 1
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using Jellyfin.Plugin.SRFPlay.Api.Models;
|
||||
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.SRFPlay.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for caching metadata from SRF API.
|
||||
/// </summary>
|
||||
public sealed class MetadataCache : IMetadataCache
|
||||
{
|
||||
private readonly ILogger<MetadataCache> _logger;
|
||||
private readonly ConcurrentDictionary<string, CacheEntry<MediaComposition>> _mediaCompositionCache;
|
||||
|
||||
/// <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>>();
|
||||
}
|
||||
|
||||
/// <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;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
var entry = new CacheEntry<MediaComposition>(mediaComposition);
|
||||
_mediaCompositionCache.AddOrUpdate(urn, entry, (key, oldValue) => entry);
|
||||
_logger.LogDebug("Cached media composition for URN: {Urn}", urn);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears all cached data.
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
_mediaCompositionCache.Clear();
|
||||
_logger.LogInformation("Cleared metadata cache");
|
||||
}
|
||||
|
||||
/// <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,543 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.SRFPlay.Api;
|
||||
using Jellyfin.Plugin.SRFPlay.Api.Models;
|
||||
using Jellyfin.Plugin.SRFPlay.Api.Models.PlayV3;
|
||||
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
||||
using MediaBrowser.Controller;
|
||||
using MediaBrowser.Controller.MediaEncoding;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.SRFPlay.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing sport livestream recordings using ffmpeg.
|
||||
/// </summary>
|
||||
public class RecordingService : IRecordingService, IDisposable
|
||||
{
|
||||
private readonly ILogger<RecordingService> _logger;
|
||||
private readonly ISRFApiClientFactory _apiClientFactory;
|
||||
private readonly IStreamProxyService _proxyService;
|
||||
private readonly IStreamUrlResolver _streamUrlResolver;
|
||||
private readonly IMediaCompositionFetcher _mediaCompositionFetcher;
|
||||
private readonly IServerApplicationHost _appHost;
|
||||
private readonly IMediaEncoder _mediaEncoder;
|
||||
private readonly ConcurrentDictionary<string, Process> _activeProcesses = new();
|
||||
private static readonly JsonSerializerOptions _jsonOptions = new() { WriteIndented = true };
|
||||
private readonly SemaphoreSlim _persistLock = new(1, 1);
|
||||
private readonly SemaphoreSlim _processLock = new(1, 1);
|
||||
private List<RecordingEntry> _recordings = new();
|
||||
private bool _loaded;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RecordingService"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">The logger.</param>
|
||||
/// <param name="apiClientFactory">The API client factory.</param>
|
||||
/// <param name="proxyService">The stream proxy service.</param>
|
||||
/// <param name="streamUrlResolver">The stream URL resolver.</param>
|
||||
/// <param name="mediaCompositionFetcher">The media composition fetcher.</param>
|
||||
/// <param name="appHost">The application host.</param>
|
||||
/// <param name="mediaEncoder">The media encoder for ffmpeg path.</param>
|
||||
public RecordingService(
|
||||
ILogger<RecordingService> logger,
|
||||
ISRFApiClientFactory apiClientFactory,
|
||||
IStreamProxyService proxyService,
|
||||
IStreamUrlResolver streamUrlResolver,
|
||||
IMediaCompositionFetcher mediaCompositionFetcher,
|
||||
IServerApplicationHost appHost,
|
||||
IMediaEncoder mediaEncoder)
|
||||
{
|
||||
_logger = logger;
|
||||
_apiClientFactory = apiClientFactory;
|
||||
_proxyService = proxyService;
|
||||
_streamUrlResolver = streamUrlResolver;
|
||||
_mediaCompositionFetcher = mediaCompositionFetcher;
|
||||
_appHost = appHost;
|
||||
_mediaEncoder = mediaEncoder;
|
||||
}
|
||||
|
||||
private string GetDataFilePath()
|
||||
{
|
||||
var dataPath = Plugin.Instance?.DataFolderPath ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "jellyfin", "plugins", "SRFPlay");
|
||||
Directory.CreateDirectory(dataPath);
|
||||
return Path.Combine(dataPath, "recordings.json");
|
||||
}
|
||||
|
||||
private string GetRecordingOutputPath()
|
||||
{
|
||||
var config = Plugin.Instance?.Configuration;
|
||||
var path = config?.RecordingOutputPath;
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "SRFRecordings");
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(path);
|
||||
return path;
|
||||
}
|
||||
|
||||
private string GetServerBaseUrl()
|
||||
{
|
||||
var config = Plugin.Instance?.Configuration;
|
||||
if (config != null && !string.IsNullOrWhiteSpace(config.PublicServerUrl))
|
||||
{
|
||||
return config.PublicServerUrl.TrimEnd('/');
|
||||
}
|
||||
|
||||
// For local ffmpeg access, use localhost directly
|
||||
return "http://localhost:8096";
|
||||
}
|
||||
|
||||
private async Task LoadRecordingsAsync()
|
||||
{
|
||||
if (_loaded)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var filePath = GetDataFilePath();
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = await File.ReadAllTextAsync(filePath).ConfigureAwait(false);
|
||||
_recordings = JsonSerializer.Deserialize<List<RecordingEntry>>(json) ?? new List<RecordingEntry>();
|
||||
_logger.LogInformation("Loaded {Count} recording entries from {Path}", _recordings.Count, filePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to load recordings from {Path}", filePath);
|
||||
_recordings = new List<RecordingEntry>();
|
||||
}
|
||||
}
|
||||
|
||||
_loaded = true;
|
||||
}
|
||||
|
||||
private async Task SaveRecordingsAsync()
|
||||
{
|
||||
await _persistLock.WaitAsync().ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var filePath = GetDataFilePath();
|
||||
var json = JsonSerializer.Serialize(_recordings, _jsonOptions);
|
||||
await File.WriteAllTextAsync(filePath, json).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to save recordings");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_persistLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<PlayV3TvProgram>> GetUpcomingScheduleAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var config = Plugin.Instance?.Configuration;
|
||||
var businessUnit = (config?.BusinessUnit ?? Configuration.BusinessUnit.SRF).ToString().ToLowerInvariant();
|
||||
|
||||
using var apiClient = _apiClientFactory.CreateClient();
|
||||
var livestreams = await apiClient.GetScheduledLivestreamsAsync(businessUnit, "SPORT", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (livestreams == null)
|
||||
{
|
||||
return Array.Empty<PlayV3TvProgram>();
|
||||
}
|
||||
|
||||
// Filter to only future/current livestreams that aren't blocked
|
||||
return livestreams
|
||||
.Where(ls => ls.Blocked != true && (ls.ValidTo == null || ls.ValidTo > DateTime.UtcNow))
|
||||
.OrderBy(ls => ls.ValidFrom)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<RecordingEntry> ScheduleRecordingAsync(string urn, CancellationToken cancellationToken)
|
||||
{
|
||||
await LoadRecordingsAsync().ConfigureAwait(false);
|
||||
|
||||
// Check if already scheduled
|
||||
var existing = _recordings.FirstOrDefault(r => r.Urn == urn && r.State is RecordingState.Scheduled or RecordingState.WaitingForStream or RecordingState.Recording);
|
||||
if (existing != null)
|
||||
{
|
||||
_logger.LogInformation("Recording already exists for URN {Urn} in state {State}", urn, existing.State);
|
||||
return existing;
|
||||
}
|
||||
|
||||
// Fetch metadata for the URN
|
||||
var config = Plugin.Instance?.Configuration;
|
||||
var businessUnit = (config?.BusinessUnit ?? Configuration.BusinessUnit.SRF).ToString().ToLowerInvariant();
|
||||
|
||||
using var apiClient = _apiClientFactory.CreateClient();
|
||||
var livestreams = await apiClient.GetScheduledLivestreamsAsync(businessUnit, "SPORT", cancellationToken).ConfigureAwait(false);
|
||||
var program = livestreams?.FirstOrDefault(ls => ls.Urn == urn);
|
||||
|
||||
var entry = new RecordingEntry
|
||||
{
|
||||
Id = Guid.NewGuid().ToString("N"),
|
||||
Urn = urn,
|
||||
Title = program?.Title ?? urn,
|
||||
Description = program?.Lead ?? program?.Description,
|
||||
ImageUrl = program?.ImageUrl,
|
||||
ValidFrom = program?.ValidFrom,
|
||||
ValidTo = program?.ValidTo,
|
||||
State = RecordingState.Scheduled,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
_recordings.Add(entry);
|
||||
await SaveRecordingsAsync().ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation("Scheduled recording for '{Title}' (URN: {Urn}, starts: {ValidFrom})", entry.Title, urn, entry.ValidFrom);
|
||||
return entry;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CancelRecording(string recordingId)
|
||||
{
|
||||
var entry = _recordings.FirstOrDefault(r => r.Id == recordingId);
|
||||
if (entry == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (entry.State == RecordingState.Recording)
|
||||
{
|
||||
StopFfmpeg(recordingId);
|
||||
}
|
||||
|
||||
entry.State = RecordingState.Cancelled;
|
||||
entry.RecordingEndedAt = DateTime.UtcNow;
|
||||
_ = SaveRecordingsAsync();
|
||||
|
||||
_logger.LogInformation("Cancelled recording '{Title}' ({Id})", entry.Title, recordingId);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool StopRecording(string recordingId)
|
||||
{
|
||||
var entry = _recordings.FirstOrDefault(r => r.Id == recordingId && r.State == RecordingState.Recording);
|
||||
if (entry == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
StopFfmpeg(recordingId);
|
||||
|
||||
entry.State = RecordingState.Completed;
|
||||
entry.RecordingEndedAt = DateTime.UtcNow;
|
||||
|
||||
if (entry.OutputPath != null && File.Exists(entry.OutputPath))
|
||||
{
|
||||
entry.FileSizeBytes = new FileInfo(entry.OutputPath).Length;
|
||||
}
|
||||
|
||||
_ = SaveRecordingsAsync();
|
||||
|
||||
_logger.LogInformation("Stopped recording '{Title}' ({Id})", entry.Title, recordingId);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<RecordingEntry> GetRecordings(RecordingState? stateFilter)
|
||||
{
|
||||
// Ensure loaded synchronously for simple reads
|
||||
if (!_loaded)
|
||||
{
|
||||
LoadRecordingsAsync().GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
if (stateFilter.HasValue)
|
||||
{
|
||||
return _recordings.Where(r => r.State == stateFilter.Value).OrderByDescending(r => r.CreatedAt).ToList();
|
||||
}
|
||||
|
||||
return _recordings.OrderByDescending(r => r.CreatedAt).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool DeleteRecording(string recordingId, bool deleteFile)
|
||||
{
|
||||
var entry = _recordings.FirstOrDefault(r => r.Id == recordingId);
|
||||
if (entry == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (entry.State == RecordingState.Recording)
|
||||
{
|
||||
StopFfmpeg(recordingId);
|
||||
}
|
||||
|
||||
if (deleteFile && !string.IsNullOrEmpty(entry.OutputPath) && File.Exists(entry.OutputPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(entry.OutputPath);
|
||||
_logger.LogInformation("Deleted recording file: {Path}", entry.OutputPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to delete recording file: {Path}", entry.OutputPath);
|
||||
}
|
||||
}
|
||||
|
||||
_recordings.Remove(entry);
|
||||
_ = SaveRecordingsAsync();
|
||||
|
||||
_logger.LogInformation("Deleted recording entry '{Title}' ({Id})", entry.Title, recordingId);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task ProcessRecordingsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// Prevent overlapping scheduler runs from spawning duplicate ffmpeg processes
|
||||
if (!await _processLock.WaitAsync(0).ConfigureAwait(false))
|
||||
{
|
||||
_logger.LogDebug("ProcessRecordingsAsync already running, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await ProcessRecordingsCoreAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_processLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProcessRecordingsCoreAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await LoadRecordingsAsync().ConfigureAwait(false);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var changed = false;
|
||||
|
||||
foreach (var entry in _recordings.ToList())
|
||||
{
|
||||
switch (entry.State)
|
||||
{
|
||||
case RecordingState.Scheduled:
|
||||
case RecordingState.WaitingForStream:
|
||||
// Check if it's time to start recording
|
||||
if (entry.ValidFrom.HasValue && entry.ValidFrom.Value <= now.AddMinutes(2))
|
||||
{
|
||||
changed |= await TryStartRecordingAsync(entry, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case RecordingState.Recording:
|
||||
// Check if recording should stop (ValidTo reached or process died)
|
||||
if (entry.ValidTo.HasValue && entry.ValidTo.Value <= now)
|
||||
{
|
||||
_logger.LogInformation("Recording '{Title}' reached ValidTo, stopping", entry.Title);
|
||||
StopFfmpeg(entry.Id);
|
||||
entry.State = RecordingState.Completed;
|
||||
entry.RecordingEndedAt = now;
|
||||
if (entry.OutputPath != null && File.Exists(entry.OutputPath))
|
||||
{
|
||||
entry.FileSizeBytes = new FileInfo(entry.OutputPath).Length;
|
||||
}
|
||||
|
||||
changed = true;
|
||||
}
|
||||
else if (!_activeProcesses.ContainsKey(entry.Id))
|
||||
{
|
||||
// ffmpeg process died unexpectedly — try to restart
|
||||
_logger.LogWarning("ffmpeg process for '{Title}' is no longer running, attempting restart", entry.Title);
|
||||
changed |= await TryStartRecordingAsync(entry, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed)
|
||||
{
|
||||
await SaveRecordingsAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> TryStartRecordingAsync(RecordingEntry entry, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Fetch the media composition to get the stream URL
|
||||
var mediaComposition = await _mediaCompositionFetcher.GetMediaCompositionAsync(entry.Urn, cacheDurationOverride: 2, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
var chapter = mediaComposition?.ChapterList is { Count: > 0 } list ? list[0] : null;
|
||||
|
||||
if (chapter == null)
|
||||
{
|
||||
_logger.LogDebug("No chapter found for '{Title}', stream may not be live yet", entry.Title);
|
||||
entry.State = RecordingState.WaitingForStream;
|
||||
return true;
|
||||
}
|
||||
|
||||
var config = Plugin.Instance?.Configuration;
|
||||
var quality = config?.QualityPreference ?? Configuration.QualityPreference.Auto;
|
||||
var streamUrl = _streamUrlResolver.GetStreamUrl(chapter, quality);
|
||||
|
||||
if (string.IsNullOrEmpty(streamUrl))
|
||||
{
|
||||
_logger.LogDebug("No stream URL available for '{Title}', waiting", entry.Title);
|
||||
entry.State = RecordingState.WaitingForStream;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Register the stream with the proxy so we can use the proxy URL
|
||||
var itemId = $"rec_{entry.Id}";
|
||||
var isLiveStream = chapter.Type == "SCHEDULED_LIVESTREAM" || entry.Urn.Contains("livestream", StringComparison.OrdinalIgnoreCase);
|
||||
_proxyService.RegisterStreamDeferred(itemId, streamUrl, entry.Urn, isLiveStream);
|
||||
|
||||
// Build proxy URL for ffmpeg (use localhost for local access)
|
||||
var proxyUrl = $"{GetServerBaseUrl()}/Plugins/SRFPlay/Proxy/{itemId}/master.m3u8";
|
||||
|
||||
// Build output file path
|
||||
var safeTitle = SanitizeFileName(entry.Title);
|
||||
var timestamp = DateTime.Now.ToString("yyyy-MM-dd_HHmm", CultureInfo.InvariantCulture);
|
||||
var outputPath = Path.Combine(GetRecordingOutputPath(), $"{safeTitle}_{timestamp}.mkv");
|
||||
entry.OutputPath = outputPath;
|
||||
|
||||
// Start ffmpeg
|
||||
StartFfmpeg(entry.Id, proxyUrl, outputPath);
|
||||
|
||||
entry.State = RecordingState.Recording;
|
||||
entry.RecordingStartedAt = DateTime.UtcNow;
|
||||
|
||||
_logger.LogInformation("Started recording '{Title}' to {OutputPath}", entry.Title, outputPath);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to start recording '{Title}'", entry.Title);
|
||||
entry.State = RecordingState.Failed;
|
||||
entry.ErrorMessage = ex.Message;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private void StartFfmpeg(string recordingId, string inputUrl, string outputPath)
|
||||
{
|
||||
var process = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = _mediaEncoder.EncoderPath,
|
||||
Arguments = $"-y -i \"{inputUrl}\" -c copy -movflags +faststart \"{outputPath}\"",
|
||||
UseShellExecute = false,
|
||||
RedirectStandardInput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true
|
||||
},
|
||||
EnableRaisingEvents = true
|
||||
};
|
||||
|
||||
process.ErrorDataReceived += (_, args) =>
|
||||
{
|
||||
if (!string.IsNullOrEmpty(args.Data))
|
||||
{
|
||||
_logger.LogDebug("ffmpeg [{RecordingId}]: {Data}", recordingId, args.Data);
|
||||
}
|
||||
};
|
||||
|
||||
process.Exited += (_, _) =>
|
||||
{
|
||||
_logger.LogInformation("ffmpeg process exited for recording {RecordingId} with code {ExitCode}", recordingId, process.ExitCode);
|
||||
_activeProcesses.TryRemove(recordingId, out _);
|
||||
};
|
||||
|
||||
process.Start();
|
||||
process.BeginErrorReadLine();
|
||||
|
||||
_activeProcesses[recordingId] = process;
|
||||
_logger.LogInformation("Started ffmpeg (PID {Pid}) for recording {RecordingId}: {Args}", process.Id, recordingId, process.StartInfo.Arguments);
|
||||
}
|
||||
|
||||
private void StopFfmpeg(string recordingId)
|
||||
{
|
||||
if (_activeProcesses.TryRemove(recordingId, out var process))
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!process.HasExited)
|
||||
{
|
||||
// Send 'q' to ffmpeg stdin for graceful shutdown
|
||||
process.StandardInput.Write("q");
|
||||
process.StandardInput.Flush();
|
||||
|
||||
if (!process.WaitForExit(10000))
|
||||
{
|
||||
_logger.LogWarning("ffmpeg did not exit gracefully for {RecordingId}, killing", recordingId);
|
||||
process.Kill(true);
|
||||
}
|
||||
}
|
||||
|
||||
process.Dispose();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error stopping ffmpeg for recording {RecordingId}", recordingId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string SanitizeFileName(string name)
|
||||
{
|
||||
var invalid = Path.GetInvalidFileNameChars();
|
||||
var sanitized = string.Join("_", name.Split(invalid, StringSplitOptions.RemoveEmptyEntries));
|
||||
// Also replace spaces and other problematic chars
|
||||
sanitized = Regex.Replace(sanitized, @"[\s]+", "_");
|
||||
return sanitized.Length > 100 ? sanitized[..100] : sanitized;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
Dispose(true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Releases resources.
|
||||
/// </summary>
|
||||
/// <param name="disposing">True to release managed resources.</param>
|
||||
protected virtual void Dispose(bool disposing)
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (disposing)
|
||||
{
|
||||
foreach (var kvp in _activeProcesses)
|
||||
{
|
||||
StopFfmpeg(kvp.Key);
|
||||
}
|
||||
|
||||
_persistLock.Dispose();
|
||||
_processLock.Dispose();
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,306 @@
|
||||
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 Jellyfin.Plugin.SRFPlay.Constants;
|
||||
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
||||
using MediaBrowser.Common.Net;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.SRFPlay.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for resolving stream URLs from media composition resources.
|
||||
/// </summary>
|
||||
public class StreamUrlResolver : IStreamUrlResolver
|
||||
{
|
||||
private readonly ILogger<StreamUrlResolver> _logger;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="StreamUrlResolver"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">The logger instance.</param>
|
||||
/// <param name="httpClientFactory">The HTTP client factory.</param>
|
||||
public StreamUrlResolver(ILogger<StreamUrlResolver> logger, IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
_logger = logger;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
}
|
||||
|
||||
/// <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.IsPlayable)
|
||||
.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);
|
||||
|
||||
// Log all HLS resources with their quality info to help debug quality selection
|
||||
foreach (var resource in hlsResources)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Available HLS resource - Quality={Quality}, Protocol={Protocol}, Streaming={Streaming}, URL={Url}",
|
||||
resource.Quality ?? "NULL",
|
||||
resource.Protocol ?? "NULL",
|
||||
resource.Streaming ?? "NULL",
|
||||
resource.Url);
|
||||
}
|
||||
|
||||
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
|
||||
_logger.LogInformation(
|
||||
"Selecting stream with quality preference: {QualityPreference}",
|
||||
qualityPreference);
|
||||
|
||||
Resource? selectedResource = qualityPreference switch
|
||||
{
|
||||
QualityPreference.HD => SelectHDResource(hlsResources) ?? SelectBestAvailableResource(hlsResources),
|
||||
QualityPreference.SD => SelectSDResource(hlsResources) ?? SelectBestAvailableResource(hlsResources),
|
||||
QualityPreference.Auto => hlsResources.FirstOrDefault(),
|
||||
_ => hlsResources.FirstOrDefault()
|
||||
};
|
||||
|
||||
if (selectedResource != null)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Selected stream for chapter {ChapterId}: Quality={Quality}, Protocol={Protocol}, URL={Url}",
|
||||
chapter.Id,
|
||||
selectedResource.Quality ?? "NULL",
|
||||
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)
|
||||
{
|
||||
if (chapter?.ResourceList == null || chapter.ResourceList.Count == 0)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Chapter {ChapterId}: ResourceList is null or empty. ResourceList count: {Count}, ResourceList type: {Type}",
|
||||
chapter?.Id ?? "null",
|
||||
chapter?.ResourceList?.Count ?? -1,
|
||||
chapter?.ResourceList?.GetType().Name ?? "null");
|
||||
return false;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Chapter {ChapterId}: Found {ResourceCount} resources",
|
||||
chapter.Id,
|
||||
chapter.ResourceList.Count);
|
||||
|
||||
foreach (var resource in chapter.ResourceList)
|
||||
{
|
||||
var urlPreview = resource.Url == null ? "null" : resource.Url.AsSpan(0, Math.Min(60, resource.Url.Length)).ToString() + "...";
|
||||
_logger.LogDebug(
|
||||
"Resource - URL: {Url}, Quality: {Quality}, DrmList: {DrmList}, DrmList type: {DrmListType}",
|
||||
urlPreview,
|
||||
resource.Quality,
|
||||
resource.DrmList?.ToString() ?? "null",
|
||||
resource.DrmList?.GetType().Name ?? "null");
|
||||
}
|
||||
|
||||
var hasPlayable = chapter.ResourceList.Any(r => r.IsPlayable);
|
||||
_logger.LogInformation("Chapter {ChapterId}: Has playable content: {HasPlayable}", chapter.Id, hasPlayable);
|
||||
return hasPlayable;
|
||||
}
|
||||
|
||||
/// <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 = $"{ApiEndpoints.AkamaiTokenEndpoint}?acl={Uri.EscapeDataString(aclPath)}";
|
||||
|
||||
_logger.LogDebug("Fetching auth token from: {TokenUrl}", tokenUrl);
|
||||
|
||||
using var httpClient = _httpClientFactory.CreateClient(NamedClient.Default);
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using Jellyfin.Plugin.SRFPlay.Configuration;
|
||||
|
||||
namespace Jellyfin.Plugin.SRFPlay.Utilities;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for common plugin operations.
|
||||
/// </summary>
|
||||
public static class Extensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts the BusinessUnit enum to its lowercase string representation.
|
||||
/// </summary>
|
||||
/// <param name="businessUnit">The business unit.</param>
|
||||
/// <returns>The lowercase string representation.</returns>
|
||||
public static string ToLowerString(this BusinessUnit businessUnit)
|
||||
{
|
||||
return businessUnit.ToString().ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using System;
|
||||
|
||||
namespace Jellyfin.Plugin.SRFPlay.Utilities;
|
||||
|
||||
/// <summary>
|
||||
/// Helper class for determining MIME content types.
|
||||
/// </summary>
|
||||
public static class MimeTypeHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the MIME content type for an image based on URL or file extension.
|
||||
/// </summary>
|
||||
/// <param name="url">The image URL.</param>
|
||||
/// <returns>The MIME content type, defaulting to "image/jpeg" for SRF images.</returns>
|
||||
public static string GetImageContentType(string? url)
|
||||
{
|
||||
if (string.IsNullOrEmpty(url))
|
||||
{
|
||||
return "image/jpeg";
|
||||
}
|
||||
|
||||
var uri = new Uri(url);
|
||||
var path = uri.AbsolutePath.ToLowerInvariant();
|
||||
|
||||
if (path.EndsWith(".png", StringComparison.Ordinal))
|
||||
{
|
||||
return "image/png";
|
||||
}
|
||||
|
||||
if (path.EndsWith(".gif", StringComparison.Ordinal))
|
||||
{
|
||||
return "image/gif";
|
||||
}
|
||||
|
||||
if (path.EndsWith(".webp", StringComparison.Ordinal))
|
||||
{
|
||||
return "image/webp";
|
||||
}
|
||||
|
||||
if (path.EndsWith(".jpg", StringComparison.Ordinal) || path.EndsWith(".jpeg", StringComparison.Ordinal))
|
||||
{
|
||||
return "image/jpeg";
|
||||
}
|
||||
|
||||
// Default to JPEG for SRF images (most common)
|
||||
return "image/jpeg";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the MIME content type for a media segment based on file extension.
|
||||
/// </summary>
|
||||
/// <param name="path">The segment path or filename.</param>
|
||||
/// <returns>The MIME content type.</returns>
|
||||
public static string GetSegmentContentType(string path)
|
||||
{
|
||||
if (path.EndsWith(".ts", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "video/MP2T";
|
||||
}
|
||||
|
||||
if (path.EndsWith(".mp4", StringComparison.OrdinalIgnoreCase) ||
|
||||
path.EndsWith(".m4s", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "video/mp4";
|
||||
}
|
||||
|
||||
if (path.EndsWith(".aac", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "audio/aac";
|
||||
}
|
||||
|
||||
return "application/octet-stream";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
using System.IO;
|
||||
using SkiaSharp;
|
||||
|
||||
namespace Jellyfin.Plugin.SRFPlay.Utilities;
|
||||
|
||||
/// <summary>
|
||||
/// Generates placeholder images for content without thumbnails.
|
||||
/// </summary>
|
||||
public static class PlaceholderImageGenerator
|
||||
{
|
||||
private const int Width = 640;
|
||||
private const int Height = 360; // 16:9 aspect ratio
|
||||
|
||||
/// <summary>
|
||||
/// Generates a placeholder image with the given text centered.
|
||||
/// </summary>
|
||||
/// <param name="text">The text to display (typically channel/show name).</param>
|
||||
/// <returns>A memory stream containing the PNG image.</returns>
|
||||
public static MemoryStream GeneratePlaceholder(string text)
|
||||
{
|
||||
using var surface = SKSurface.Create(new SKImageInfo(Width, Height));
|
||||
var canvas = surface.Canvas;
|
||||
|
||||
// Dark gradient background
|
||||
using var backgroundPaint = new SKPaint();
|
||||
using var shader = SKShader.CreateLinearGradient(
|
||||
new SKPoint(0, 0),
|
||||
new SKPoint(Width, Height),
|
||||
new[] { new SKColor(45, 45, 48), new SKColor(28, 28, 30) },
|
||||
null,
|
||||
SKShaderTileMode.Clamp);
|
||||
backgroundPaint.Shader = shader;
|
||||
canvas.DrawRect(0, 0, Width, Height, backgroundPaint);
|
||||
|
||||
// Text
|
||||
using var textPaint = new SKPaint
|
||||
{
|
||||
Color = SKColors.White,
|
||||
IsAntialias = true,
|
||||
TextAlign = SKTextAlign.Center,
|
||||
Typeface = SKTypeface.FromFamilyName("Arial", SKFontStyle.Bold)
|
||||
};
|
||||
|
||||
// Calculate font size and wrap text if needed
|
||||
float fontSize = 48;
|
||||
textPaint.TextSize = fontSize;
|
||||
var maxWidth = Width * 0.85f;
|
||||
var maxHeight = Height * 0.8f;
|
||||
|
||||
var lines = WrapText(text, textPaint, maxWidth, maxHeight, ref fontSize);
|
||||
|
||||
// Draw each line centered
|
||||
var lineHeight = fontSize * 1.2f;
|
||||
var totalHeight = lines.Count * lineHeight;
|
||||
var startY = ((Height - totalHeight) / 2) + fontSize;
|
||||
|
||||
foreach (var line in lines)
|
||||
{
|
||||
canvas.DrawText(line, Width / 2, startY, textPaint);
|
||||
startY += lineHeight;
|
||||
}
|
||||
|
||||
// Encode to PNG
|
||||
using var image = surface.Snapshot();
|
||||
using var data = image.Encode(SKEncodedImageFormat.Png, 100);
|
||||
|
||||
var stream = new MemoryStream();
|
||||
data.SaveTo(stream);
|
||||
stream.Position = 0;
|
||||
return stream;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wraps text to fit within the specified width and height constraints.
|
||||
/// </summary>
|
||||
/// <param name="text">The text to wrap.</param>
|
||||
/// <param name="paint">The paint to use for measuring.</param>
|
||||
/// <param name="maxWidth">Maximum width for each line.</param>
|
||||
/// <param name="maxHeight">Maximum total height.</param>
|
||||
/// <param name="fontSize">Font size (will be adjusted if needed).</param>
|
||||
/// <returns>List of text lines.</returns>
|
||||
private static System.Collections.Generic.List<string> WrapText(
|
||||
string text,
|
||||
SKPaint paint,
|
||||
float maxWidth,
|
||||
float maxHeight,
|
||||
ref float fontSize)
|
||||
{
|
||||
const float minFontSize = 20;
|
||||
var words = text.Split(' ');
|
||||
var lines = new System.Collections.Generic.List<string>();
|
||||
|
||||
while (fontSize >= minFontSize)
|
||||
{
|
||||
paint.TextSize = fontSize;
|
||||
lines.Clear();
|
||||
|
||||
var currentLine = string.Empty;
|
||||
foreach (var word in words)
|
||||
{
|
||||
var testLine = string.IsNullOrEmpty(currentLine) ? word : $"{currentLine} {word}";
|
||||
var testWidth = paint.MeasureText(testLine);
|
||||
|
||||
if (testWidth > maxWidth && !string.IsNullOrEmpty(currentLine))
|
||||
{
|
||||
lines.Add(currentLine);
|
||||
currentLine = word;
|
||||
}
|
||||
else
|
||||
{
|
||||
currentLine = testLine;
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(currentLine))
|
||||
{
|
||||
lines.Add(currentLine);
|
||||
}
|
||||
|
||||
// Check if total height fits
|
||||
var lineHeight = fontSize * 1.2f;
|
||||
var totalHeight = lines.Count * lineHeight;
|
||||
|
||||
if (totalHeight <= maxHeight)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
// Reduce font size and try again
|
||||
fontSize -= 2;
|
||||
}
|
||||
|
||||
// If still doesn't fit, just return what we have
|
||||
if (lines.Count == 0)
|
||||
{
|
||||
lines.Add(text);
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using System;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace Jellyfin.Plugin.SRFPlay.Utilities;
|
||||
|
||||
/// <summary>
|
||||
/// Helper class for URN-related operations.
|
||||
/// </summary>
|
||||
public static class UrnHelper
|
||||
{
|
||||
/// <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 stable IDs).
|
||||
/// </summary>
|
||||
/// <param name="urn">The URN to convert.</param>
|
||||
/// <returns>A deterministic GUID string.</returns>
|
||||
#pragma warning disable CA5351 // MD5 is used for non-cryptographic purposes (ID generation)
|
||||
public static string ToGuid(string urn)
|
||||
{
|
||||
var hash = MD5.HashData(Encoding.UTF8.GetBytes(urn));
|
||||
var guid = new Guid(hash);
|
||||
return guid.ToString();
|
||||
}
|
||||
#pragma warning restore CA5351
|
||||
}
|
||||
@@ -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>
|
||||
@@ -1,373 +1,290 @@
|
||||
# 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 and live sports streaming.
|
||||
|
||||
## 0. Things you need to get started
|
||||
## Status
|
||||
|
||||
- [Dotnet SDK 6.0](https://dotnet.microsoft.com/download)
|
||||
**Beta/Alpha** - This plugin has been tested on two Jellyfin instances and is working. Some clients may experience issues with hardware decoding, which appears to be client-specific behavior.
|
||||
|
||||
- An editor of your choice. Some free choices are:
|
||||
## Quick Install
|
||||
|
||||
[Visual Studio Code](https://code.visualstudio.com)
|
||||
|
||||
[Visual Studio Community Edition](https://visualstudio.microsoft.com/downloads)
|
||||
|
||||
[Mono Develop](https://www.monodevelop.com)
|
||||
|
||||
## 0.5. Quickstarts
|
||||
|
||||
We have a number of quickstart options available to speed you along the way.
|
||||
|
||||
- [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)
|
||||
|
||||
- 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)
|
||||
|
||||
```
|
||||
dotnet new -i /path/to/templatefolder
|
||||
```
|
||||
|
||||
- Run this command then skip to step 4
|
||||
|
||||
```
|
||||
dotnet new Jellyfin-plugin -name MyPlugin
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## 1. Initialize Your Project
|
||||
|
||||
Make a new dotnet standard project with the following command, it will make a directory for itself.
|
||||
Add this repository URL in Jellyfin (Dashboard → Plugins → Repositories):
|
||||
|
||||
```
|
||||
dotnet new classlib -f net6.0 -n MyJellyfinPlugin
|
||||
https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/raw/branch/master/manifest.json
|
||||
```
|
||||
|
||||
Now add the Jellyfin shared libraries.
|
||||
Then install "SRF Play" from the plugin catalog.
|
||||
|
||||
```
|
||||
dotnet add package Jellyfin.Model
|
||||
dotnet add package Jellyfin.Controller
|
||||
## Features
|
||||
|
||||
- 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 lets CDN decide, SD prefers 480p/360p, HD prefers 1080p/720p)
|
||||
- HLS streaming support with Akamai token authentication
|
||||
- Proxy support for routing traffic through alternate gateways
|
||||
- Smart caching with reduced TTL for upcoming livestreams
|
||||
|
||||
## Screenshots
|
||||
|
||||
### Channel Menu
|
||||

|
||||
|
||||
The main channel interface showing the content folders.
|
||||
### Playback
|
||||

|
||||
|
||||
Video playback with HLS streaming support and quality selection.
|
||||
|
||||
## Testing
|
||||
|
||||
The plugin includes comprehensive testing:
|
||||
|
||||
- **Unit tests** (xUnit framework) for core services
|
||||
- **API spec validation tests** for all business units (SRF, RTS, RSI, RTR, SWI)
|
||||
- **Integration testing** - Tested on multiple Jellyfin instances with VOD and live sports streaming
|
||||
|
||||
**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"
|
||||
```
|
||||
|
||||
You have an autogenerated Class1.cs file. You won't be needing this, so go ahead and delete it.
|
||||
See [Test Documentation](Jellyfin.Plugin.SRFPlay.Tests/README.md) for more details.
|
||||
|
||||
## 2. Set Up the Basics
|
||||
## API Information
|
||||
|
||||
There are a few mandatory classes you'll need for a plugin so we need to make them.
|
||||
**Base URL:** `https://il.srgssr.ch/integrationlayer/2.0/`
|
||||
|
||||
### PluginConfiguration
|
||||
### Key Endpoints
|
||||
|
||||
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`
|
||||
**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
|
||||
|
||||
### Plugin
|
||||
**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
|
||||
|
||||
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>`
|
||||
**Akamai Token Service:**
|
||||
- `GET /akahd/token?acl=/{path}/*` - Authenticate stream URLs for playback
|
||||
|
||||
Note: If you called your PluginConfiguration class something different, you need to put that between the <>
|
||||
### URN Format
|
||||
|
||||
### Implement Required Properties
|
||||
- `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)
|
||||
|
||||
The Plugin class needs a few properties implemented before it can work correctly.
|
||||
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)
|
||||
|
||||
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.
|
||||
## Building the Plugin
|
||||
|
||||
```c#
|
||||
public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) : base(applicationPaths, xmlSerializer){}
|
||||
public override string Name => throw new System.NotImplementedException();
|
||||
public override Guid Id => Guid.Parse("");
|
||||
### Prerequisites
|
||||
|
||||
- .NET 8.0 SDK
|
||||
- Jellyfin 10.9.11 or later
|
||||
|
||||
### Build Steps
|
||||
|
||||
```bash
|
||||
cd Jellyfin.Plugin.SRFPlay
|
||||
dotnet build
|
||||
```
|
||||
|
||||
## 3. Customize Plugin Information
|
||||
### Output
|
||||
|
||||
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
|
||||
The compiled plugin will be in `bin/Debug/net8.0/`
|
||||
|
||||
- **Windows Users**: you can use the Powershell command `New-Guid`, `[guid]::NewGuid()` or the Visual Studio GUID generator
|
||||
## Manual Installation
|
||||
|
||||
- **Linux and OS X Users**: you can use the Powershell Core command `New-Guid` or this command from your shell of choice:
|
||||
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.)
|
||||
|
||||
```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}'
|
||||
```
|
||||
## Configuration
|
||||
|
||||
or
|
||||
- **Business Unit**: Select the Swiss broadcasting unit (default: SRF)
|
||||
- **Quality Preference**: Choose video quality — Auto (first available, CDN decides), SD, or 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
|
||||
|
||||
```bash
|
||||
uuidgen
|
||||
```
|
||||
For detailed proxy setup instructions, see [PROXY_SETUP_GUIDE.md](PROXY_SETUP_GUIDE.md).
|
||||
|
||||
- Place that guid inside the `Guid.Parse("")` quotes to define your plugin's ID.
|
||||
## Troubleshooting
|
||||
|
||||
## 4. Adding Functionality
|
||||
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
|
||||
|
||||
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.
|
||||
## Technical Architecture
|
||||
|
||||
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.
|
||||
### Directory Structure
|
||||
|
||||
### 4a. Implement Interfaces
|
||||
|
||||
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.
|
||||
|
||||
Here's some interfaces you could implement for common use cases:
|
||||
|
||||
- **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.
|
||||
|
||||
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)!
|
||||
|
||||
### 4b. Use plugin aimed interfaces to add custom functionality
|
||||
|
||||
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
|
||||
|
||||
- **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.
|
||||
|
||||
- **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.**
|
||||
|
||||
- **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).
|
||||
|
||||
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).
|
||||
|
||||
- **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
|
||||
|
||||
## 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
|
||||
```
|
||||
Jellyfin.Plugin.SRFPlay/
|
||||
├── Api/
|
||||
│ ├── Models/ # API response models
|
||||
│ │ ├── MediaComposition.cs # Root composition (HasChapters helper)
|
||||
│ │ ├── Chapter.cs # Video/episode chapter with resources
|
||||
│ │ ├── Resource.cs # Stream URL entry (IsPlayable helper)
|
||||
│ │ ├── Show.cs
|
||||
│ │ ├── Episode.cs
|
||||
│ │ └── PlayV3/ # Play v3 API models
|
||||
│ │ ├── PlayV3Show.cs
|
||||
│ │ ├── PlayV3Topic.cs
|
||||
│ │ ├── PlayV3Video.cs
|
||||
│ │ ├── PlayV3TvProgram.cs
|
||||
│ │ ├── PlayV3Response.cs
|
||||
│ │ ├── PlayV3DirectResponse.cs
|
||||
│ │ └── PlayV3DataContainer.cs
|
||||
│ ├── SRFApiClient.cs # HTTP client for SRF APIs
|
||||
│ ├── ISRFApiClientFactory.cs # Factory interface
|
||||
│ └── SRFApiClientFactory.cs # Factory implementation
|
||||
├── Channels/
|
||||
│ └── SRFPlayChannel.cs # Channel implementation
|
||||
├── Configuration/
|
||||
│ ├── PluginConfiguration.cs
|
||||
│ └── configPage.html
|
||||
├── Constants/
|
||||
│ └── ApiEndpoints.cs # API base URLs and endpoint constants
|
||||
├── Controllers/
|
||||
│ └── StreamProxyController.cs # HLS proxy endpoints (master/variant/segment)
|
||||
├── Services/
|
||||
│ ├── Interfaces/ # Service contracts
|
||||
│ │ ├── IStreamProxyService.cs
|
||||
│ │ ├── IStreamUrlResolver.cs
|
||||
│ │ ├── IMediaCompositionFetcher.cs
|
||||
│ │ ├── IMediaSourceFactory.cs
|
||||
│ │ ├── IMetadataCache.cs
|
||||
│ │ ├── IContentRefreshService.cs
|
||||
│ │ ├── IContentExpirationService.cs
|
||||
│ │ └── ICategoryService.cs
|
||||
│ ├── StreamProxyService.cs # HLS proxy: auth, manifest rewriting, segments
|
||||
│ ├── StreamUrlResolver.cs # Stream selection & Akamai authentication
|
||||
│ ├── MediaCompositionFetcher.cs # Cached API fetcher
|
||||
│ ├── MediaSourceFactory.cs # Jellyfin MediaSourceInfo builder
|
||||
│ ├── MetadataCache.cs # Thread-safe ConcurrentDictionary cache
|
||||
│ ├── ContentExpirationService.cs
|
||||
│ ├── ContentRefreshService.cs
|
||||
│ └── CategoryService.cs
|
||||
├── Providers/
|
||||
│ ├── SRFSeriesProvider.cs # Series metadata
|
||||
│ ├── SRFEpisodeProvider.cs # Episode metadata
|
||||
│ ├── SRFImageProvider.cs # Image fetching
|
||||
│ └── SRFMediaProvider.cs # Playback URLs
|
||||
├── Utilities/
|
||||
│ ├── Extensions.cs # BusinessUnit.ToLowerString() extension
|
||||
│ ├── MimeTypeHelper.cs # Content-type detection
|
||||
│ ├── PlaceholderImageGenerator.cs
|
||||
│ └── UrnHelper.cs # URN parsing utilities
|
||||
├── ScheduledTasks/
|
||||
│ ├── ContentRefreshTask.cs # Periodic content refresh
|
||||
│ └── ExpirationCheckTask.cs # Periodic expiration check
|
||||
├── ServiceRegistrator.cs # DI registration
|
||||
└── Plugin.cs # Main plugin entry point
|
||||
```
|
||||
|
||||
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.
|
||||
### Key Components
|
||||
|
||||
### 6.a Set Up Debugging on Visual Studio
|
||||
1. **API Client** (`SRFApiClient`): HTTP requests to SRF Integration Layer and Play v3 API, with proxy support
|
||||
2. **Channel** (`SRFPlayChannel`): SRF Play channel with Latest, Trending, and Live Sports folders
|
||||
3. **Stream Proxy** (`StreamProxyService` + `StreamProxyController`): HLS proxy that handles Akamai token auth, manifest URL rewriting, deferred authentication, and token refresh for both VOD and livestreams
|
||||
4. **Stream Resolver** (`StreamUrlResolver`): Selects optimal HLS stream by quality preference, filters DRM content
|
||||
5. **Metadata Cache** (`MetadataCache`): Thread-safe `ConcurrentDictionary` cache with dynamic TTL for livestreams
|
||||
6. **Media Composition Fetcher** (`MediaCompositionFetcher`): Cached wrapper around API client for media composition requests
|
||||
7. **Content Providers** (`SRFSeriesProvider`, `SRFEpisodeProvider`, `SRFImageProvider`, `SRFMediaProvider`): Jellyfin integration for series, episodes, images, and media sources
|
||||
8. **Scheduled Tasks**: Automatic content refresh and expiration management
|
||||
9. **Utilities**: Business unit extensions, MIME type helpers, URN parsing, placeholder image generation
|
||||
|
||||
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.
|
||||
## Important Notes
|
||||
|
||||
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'
|
||||
### Content Limitations
|
||||
|
||||
From now on, everytime you click on start from Visual Studio, it will start Jellyfin attached to the debugger!
|
||||
- **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
|
||||
|
||||
The only thing left to do is to compile the project as it is specified a few lines above and you are done.
|
||||
### Extensibility
|
||||
|
||||
### 6.b Automate the Setup on Visual Studio Code
|
||||
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)
|
||||
|
||||
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.
|
||||
Currently focused on SRF but easily extensible.
|
||||
|
||||
A full example, which aims to be portable may be found in this repo's `.vscode` folder.
|
||||
## Development
|
||||
|
||||
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`
|
||||
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
|
||||
|
||||
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",
|
||||
}
|
||||
```
|
||||
### Known Issues
|
||||
|
||||
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.
|
||||
- Some clients may experience issues with hardware decoding (appears to be client-specific)
|
||||
- Some edge cases may need additional handling
|
||||
- Performance optimization may be needed for very large content catalogs
|
||||
|
||||
```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}",
|
||||
}
|
||||
]
|
||||
}
|
||||
### Contributing
|
||||
|
||||
```
|
||||
Contributions welcome!
|
||||
|
||||
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.
|
||||
## License
|
||||
|
||||
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.
|
||||
See LICENSE file for details.
|
||||
|
||||
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}/"
|
||||
]
|
||||
## Acknowledgments
|
||||
|
||||
},
|
||||
]
|
||||
}
|
||||
This plugin was developed partly using [Claude Code](https://docs.anthropic.com/en/docs/claude-code) by Anthropic.
|
||||
|
||||
```
|
||||
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.
|
||||
Inspired by the excellent [Kodi SRG SSR addon](https://github.com/goggle/script.module.srgssr) by [@goggle](https://github.com/goggle), which served as a fantastic reference for understanding the SRG SSR API structure, authentication mechanisms, and handling of scheduled livestreams.
|
||||
|
||||
```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"
|
||||
},
|
||||
```
|
||||
## References
|
||||
|
||||
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
|
||||
|
||||
@@ -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
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
+14
-9
@@ -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
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
[
|
||||
{
|
||||
"guid": "a4b12f86-8c3d-4e9a-b7f2-1d5e6c8a9b4f",
|
||||
"name": "SRF Play",
|
||||
"description": "SRF Play plugin for Jellyfin enables streaming of content from SRF (Swiss Radio and Television). Access live TV channels and on-demand content from Switzerland's German-language public broadcaster directly within Jellyfin.",
|
||||
"overview": "Stream SRF content in Jellyfin",
|
||||
"owner": "dtourolle",
|
||||
"category": "Live TV",
|
||||
"imageUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/raw/branch/master/assests/main%20logo.png",
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.0.25",
|
||||
"changelog": "Release 1.0.25",
|
||||
"targetAbi": "10.9.0.0",
|
||||
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.0.25/srfplay_1.0.25.0.zip",
|
||||
"checksum": "5e4599cfeee7e0845a1be30ec288cc0b",
|
||||
"timestamp": "2026-03-07T15:11:52Z"
|
||||
},
|
||||
{
|
||||
"version": "1.0.24",
|
||||
"changelog": "Release 1.0.24",
|
||||
"targetAbi": "10.9.0.0",
|
||||
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.0.24/srfplay_1.0.24.0.zip",
|
||||
"checksum": "f54dfb8cd9b555471859ffc89c35fb90",
|
||||
"timestamp": "2026-03-07T14:55:13Z"
|
||||
},
|
||||
{
|
||||
"version": "1.0.23",
|
||||
"changelog": "Release 1.0.23",
|
||||
"targetAbi": "10.9.0.0",
|
||||
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.0.23/srfplay_1.0.23.0.zip",
|
||||
"checksum": "cd98644c758c84e2759699ea1da5a716",
|
||||
"timestamp": "2026-02-28T12:13:10Z"
|
||||
},
|
||||
{
|
||||
"version": "1.0.22",
|
||||
"changelog": "Release 1.0.22",
|
||||
"targetAbi": "10.9.0.0",
|
||||
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.0.22/srfplay_1.0.22.0.zip",
|
||||
"checksum": "08177ed8edb4b4cf2441bc808b9860bb",
|
||||
"timestamp": "2026-02-28T11:36:30Z"
|
||||
},
|
||||
{
|
||||
"version": "1.0.16",
|
||||
"changelog": "Release 1.0.16",
|
||||
"targetAbi": "10.9.0.0",
|
||||
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.0.16/srfplay_1.0.16.0.zip",
|
||||
"checksum": "0b3a142dd6cea1f00855bcb11bcd847c",
|
||||
"timestamp": "2026-01-17T20:31:09Z"
|
||||
},
|
||||
{
|
||||
"version": "1.0.15",
|
||||
"changelog": "Release 1.0.15",
|
||||
"targetAbi": "10.9.0.0",
|
||||
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.0.15/srfplay_1.0.15.0.zip",
|
||||
"checksum": "3140ef53a7460d7ce52daf48bbb37e4d",
|
||||
"timestamp": "2026-01-17T09:54:39Z"
|
||||
},
|
||||
{
|
||||
"version": "1.0.14",
|
||||
"changelog": "Release 1.0.14",
|
||||
"targetAbi": "10.9.0.0",
|
||||
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.0.14/srfplay_1.0.14.0.zip",
|
||||
"checksum": "49e6afe16f7abf95c099ecc1d016e725",
|
||||
"timestamp": "2025-12-30T12:38:41Z"
|
||||
},
|
||||
{
|
||||
"version": "1.0.13",
|
||||
"changelog": "Release 1.0.13",
|
||||
"targetAbi": "10.9.0.0",
|
||||
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.0.13/srfplay_1.0.13.0.zip",
|
||||
"checksum": "ac7c1e33c926c21f8e4319da91ef079c",
|
||||
"timestamp": "2025-12-21T13:02:37Z"
|
||||
},
|
||||
{
|
||||
"version": "1.0.12",
|
||||
"changelog": "Release 1.0.12",
|
||||
"targetAbi": "10.9.0.0",
|
||||
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.0.12/srfplay_1.0.12.0.zip",
|
||||
"checksum": "47baa02ade413253db94fe3ba0763f69",
|
||||
"timestamp": "2025-12-20T13:35:38Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 194 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
Reference in New Issue
Block a user