Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a719ee4ac | ||
|
|
5667c2e12f | ||
|
|
fef1bd6b17 | ||
|
|
763217c43d | ||
|
|
c2feaae89c | ||
|
|
35b103c056 | ||
|
|
7af1d9b8f4 | ||
|
|
10a76f8d45 | ||
|
|
a39a10636b | ||
|
|
029fb78049 | ||
|
|
729f82b26d | ||
|
|
aad0c21d4b | ||
|
|
7a52b3da3b | ||
|
|
80598ea8cb | ||
|
|
24f53ef13e | ||
|
|
86b22c69d7 | ||
|
|
8ffa0a0f76 | ||
|
|
92f315f6f6 | ||
|
|
aa1ccbe458 | ||
|
|
702a5abdc5 | ||
|
|
cdab2d76a7 | ||
|
|
9202ab62ed | ||
|
|
1b23e203ce | ||
|
|
611fb52d76 | ||
|
|
3336bac3fb | ||
|
|
462c8a7c7b | ||
|
|
0a2d6a558c | ||
|
|
fb539d6a32 | ||
|
|
6ba5df6be9 |
@@ -25,6 +25,13 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
path: build-${{ github.run_id }}
|
path: build-${{ github.run_id }}
|
||||||
|
|
||||||
|
- name: Cache NuGet packages
|
||||||
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: ~/.nuget/packages
|
||||||
|
key: nuget-${{ hashFiles('**/Jellyfin.Plugin.SRFPlay.csproj', '**/Jellyfin.Plugin.SRFPlay.Tests.csproj') }}
|
||||||
|
restore-keys: nuget-
|
||||||
|
|
||||||
- name: Restore dependencies
|
- name: Restore dependencies
|
||||||
working-directory: build-${{ github.run_id }}
|
working-directory: build-${{ github.run_id }}
|
||||||
run: dotnet restore Jellyfin.Plugin.SRFPlay.sln
|
run: dotnet restore Jellyfin.Plugin.SRFPlay.sln
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
name: 'Latest Release'
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
paths-ignore:
|
||||||
|
- '**/*.md'
|
||||||
|
- 'manifest.json'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
latest-release:
|
||||||
|
runs-on: linux/amd64
|
||||||
|
container:
|
||||||
|
image: gitea.tourolle.paris/dtourolle/srfplay-builder:latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
path: build-${{ github.run_id }}
|
||||||
|
|
||||||
|
- name: Restore dependencies
|
||||||
|
working-directory: build-${{ github.run_id }}
|
||||||
|
run: dotnet restore Jellyfin.Plugin.SRFPlay.sln
|
||||||
|
|
||||||
|
- name: Build solution
|
||||||
|
working-directory: build-${{ github.run_id }}
|
||||||
|
run: dotnet build Jellyfin.Plugin.SRFPlay.sln --configuration Release --no-restore --no-self-contained /m:1
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
working-directory: build-${{ github.run_id }}
|
||||||
|
run: dotnet test Jellyfin.Plugin.SRFPlay.sln --no-build --configuration Release --verbosity normal
|
||||||
|
|
||||||
|
- name: Build Jellyfin Plugin
|
||||||
|
id: jprm
|
||||||
|
working-directory: build-${{ github.run_id }}
|
||||||
|
run: |
|
||||||
|
mkdir -p artifacts
|
||||||
|
jprm --verbosity=debug plugin build .
|
||||||
|
ARTIFACT=$(find . -name "*.zip" -type f -print -quit | sed 's|^\./||')
|
||||||
|
ARTIFACT_NAME=$(basename "${ARTIFACT}")
|
||||||
|
echo "artifact=${ARTIFACT}" >> $GITHUB_OUTPUT
|
||||||
|
echo "artifact_name=${ARTIFACT_NAME}" >> $GITHUB_OUTPUT
|
||||||
|
echo "Found artifact: ${ARTIFACT}"
|
||||||
|
|
||||||
|
- name: Calculate checksum
|
||||||
|
id: checksum
|
||||||
|
working-directory: build-${{ github.run_id }}
|
||||||
|
run: |
|
||||||
|
CHECKSUM=$(md5sum "${{ steps.jprm.outputs.artifact }}" | awk '{print $1}')
|
||||||
|
echo "checksum=${CHECKSUM}" >> $GITHUB_OUTPUT
|
||||||
|
echo "Checksum: ${CHECKSUM}"
|
||||||
|
|
||||||
|
- name: Delete existing latest release
|
||||||
|
working-directory: build-${{ github.run_id }}
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
REPO_OWNER="${{ github.repository_owner }}"
|
||||||
|
REPO_NAME="${{ github.event.repository.name }}"
|
||||||
|
GITEA_URL="${{ github.server_url }}"
|
||||||
|
TAG="latest"
|
||||||
|
|
||||||
|
EXISTING=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/tags/${TAG}")
|
||||||
|
|
||||||
|
if [ "$EXISTING" = "200" ]; then
|
||||||
|
RELEASE_ID=$(curl -s \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/tags/${TAG}" | jq -r '.id')
|
||||||
|
|
||||||
|
echo "Deleting existing latest release (ID: ${RELEASE_ID})..."
|
||||||
|
curl -s -X DELETE \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
curl -s -X DELETE \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/tags/${TAG}" || true
|
||||||
|
|
||||||
|
- name: Create latest release
|
||||||
|
working-directory: build-${{ github.run_id }}
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
REPO_OWNER="${{ github.repository_owner }}"
|
||||||
|
REPO_NAME="${{ github.event.repository.name }}"
|
||||||
|
GITEA_URL="${{ github.server_url }}"
|
||||||
|
|
||||||
|
RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases" \
|
||||||
|
-d "$(jq -n --arg tag "latest" --arg name "Latest Build" --arg body "SRFPlay Jellyfin Plugin latest build from master." '{tag_name: $tag, name: $name, body: $body, target_commitish: "master", draft: false, prerelease: true}')")
|
||||||
|
|
||||||
|
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
|
||||||
|
BODY=$(echo "$RESPONSE" | sed '$d')
|
||||||
|
|
||||||
|
if [ "$HTTP_CODE" -ge 200 ] && [ "$HTTP_CODE" -lt 300 ]; then
|
||||||
|
RELEASE_ID=$(echo "$BODY" | jq -r '.id')
|
||||||
|
echo "Created release with ID: ${RELEASE_ID}"
|
||||||
|
else
|
||||||
|
echo "Failed to create release. HTTP ${HTTP_CODE}"
|
||||||
|
echo "$BODY"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Upload plugin artifact
|
||||||
|
echo "Uploading plugin artifact..."
|
||||||
|
curl -f -X POST \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
-H "Content-Type: application/zip" \
|
||||||
|
--data-binary "@${{ steps.jprm.outputs.artifact }}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}/assets?name=${{ steps.jprm.outputs.artifact_name }}"
|
||||||
|
|
||||||
|
# Upload build.yaml
|
||||||
|
echo "Uploading build.yaml..."
|
||||||
|
curl -f -X POST \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
-H "Content-Type: application/x-yaml" \
|
||||||
|
--data-binary "@build.yaml" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}/assets?name=build.yaml"
|
||||||
|
|
||||||
|
echo "Latest release updated successfully!"
|
||||||
|
|
||||||
|
- name: Update manifest.json
|
||||||
|
working-directory: build-${{ github.run_id }}
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
REPO_OWNER="${{ github.repository_owner }}"
|
||||||
|
REPO_NAME="${{ github.event.repository.name }}"
|
||||||
|
GITEA_URL="${{ github.server_url }}"
|
||||||
|
CHECKSUM="${{ steps.checksum.outputs.checksum }}"
|
||||||
|
ARTIFACT_NAME="${{ steps.jprm.outputs.artifact_name }}"
|
||||||
|
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
DOWNLOAD_URL="${GITEA_URL}/${REPO_OWNER}/${REPO_NAME}/releases/download/latest/${ARTIFACT_NAME}"
|
||||||
|
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
|
||||||
|
|
||||||
|
git config user.name "Gitea Actions"
|
||||||
|
git config user.email "actions@gitea.tourolle.paris"
|
||||||
|
git fetch origin master
|
||||||
|
git checkout master
|
||||||
|
|
||||||
|
# Remove existing "latest" entry if present, then prepend new one
|
||||||
|
jq --arg url "$DOWNLOAD_URL" 'if .[0].versions[0].changelog == "Latest Build" then .[0].versions = .[0].versions[1:] else . end' manifest.json > manifest.tmp && mv manifest.tmp manifest.json
|
||||||
|
|
||||||
|
NEW_VERSION=$(cat <<EOF
|
||||||
|
{
|
||||||
|
"version": "0.0.0.0",
|
||||||
|
"changelog": "Latest Build",
|
||||||
|
"targetAbi": "10.9.0.0",
|
||||||
|
"sourceUrl": "${DOWNLOAD_URL}",
|
||||||
|
"checksum": "${CHECKSUM}",
|
||||||
|
"timestamp": "${TIMESTAMP}"
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
)
|
||||||
|
|
||||||
|
jq --argjson newver "${NEW_VERSION}" '.[0].versions = [$newver] + .[0].versions' manifest.json > manifest.tmp && mv manifest.tmp manifest.json
|
||||||
|
git add manifest.json
|
||||||
|
git commit -m "Update manifest.json for latest build (${SHORT_SHA})" || echo "No changes to commit"
|
||||||
|
git push origin master
|
||||||
|
|
||||||
|
- name: Cleanup
|
||||||
|
if: always()
|
||||||
|
run: rm -rf build-${{ github.run_id }}
|
||||||
@@ -42,6 +42,13 @@ jobs:
|
|||||||
sed -i "s/^version:.*/version: \"${VERSION}\"/" build.yaml
|
sed -i "s/^version:.*/version: \"${VERSION}\"/" build.yaml
|
||||||
cat build.yaml
|
cat build.yaml
|
||||||
|
|
||||||
|
- name: Cache NuGet packages
|
||||||
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: ~/.nuget/packages
|
||||||
|
key: nuget-${{ hashFiles('**/Jellyfin.Plugin.SRFPlay.csproj', '**/Jellyfin.Plugin.SRFPlay.Tests.csproj') }}
|
||||||
|
restore-keys: nuget-
|
||||||
|
|
||||||
- name: Restore dependencies
|
- name: Restore dependencies
|
||||||
working-directory: release-${{ github.run_id }}
|
working-directory: release-${{ github.run_id }}
|
||||||
run: dotnet restore Jellyfin.Plugin.SRFPlay.sln
|
run: dotnet restore Jellyfin.Plugin.SRFPlay.sln
|
||||||
|
|||||||
@@ -27,6 +27,13 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
path: test-${{ github.run_id }}
|
path: test-${{ github.run_id }}
|
||||||
|
|
||||||
|
- name: Cache NuGet packages
|
||||||
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: ~/.nuget/packages
|
||||||
|
key: nuget-${{ hashFiles('**/Jellyfin.Plugin.SRFPlay.csproj', '**/Jellyfin.Plugin.SRFPlay.Tests.csproj') }}
|
||||||
|
restore-keys: nuget-
|
||||||
|
|
||||||
- name: Restore dependencies
|
- name: Restore dependencies
|
||||||
working-directory: test-${{ github.run_id }}
|
working-directory: test-${{ github.run_id }}
|
||||||
run: dotnet restore Jellyfin.Plugin.SRFPlay.sln
|
run: dotnet restore Jellyfin.Plugin.SRFPlay.sln
|
||||||
|
|||||||
@@ -20,6 +20,13 @@ public class RecordingEntry
|
|||||||
[JsonPropertyName("urn")]
|
[JsonPropertyName("urn")]
|
||||||
public string Urn { get; set; } = string.Empty;
|
public string Urn { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the business unit this recording belongs to (e.g. "srf", "rts").
|
||||||
|
/// Used to show recordings under the matching unit's channel.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("businessUnit")]
|
||||||
|
public string BusinessUnit { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the title.
|
/// Gets or sets the title.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
using Jellyfin.Plugin.SRFPlay.Api;
|
||||||
|
using Jellyfin.Plugin.SRFPlay.Configuration;
|
||||||
|
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.SRFPlay.Channels;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Radiotelevisione svizzera — Swiss Italian channel.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class RsiChannel : SrgChannelBase
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="RsiChannel"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="loggerFactory">The logger factory.</param>
|
||||||
|
/// <param name="contentRefreshService">The content refresh service.</param>
|
||||||
|
/// <param name="streamResolver">The stream resolver.</param>
|
||||||
|
/// <param name="mediaSourceFactory">The media source factory.</param>
|
||||||
|
/// <param name="categoryService">The category service.</param>
|
||||||
|
/// <param name="apiClientFactory">The API client factory.</param>
|
||||||
|
/// <param name="recordingService">The recording service.</param>
|
||||||
|
public RsiChannel(
|
||||||
|
ILoggerFactory loggerFactory,
|
||||||
|
IContentRefreshService contentRefreshService,
|
||||||
|
IStreamUrlResolver streamResolver,
|
||||||
|
IMediaSourceFactory mediaSourceFactory,
|
||||||
|
ICategoryService? categoryService,
|
||||||
|
ISRFApiClientFactory apiClientFactory,
|
||||||
|
IRecordingService recordingService)
|
||||||
|
: base(loggerFactory, contentRefreshService, streamResolver, mediaSourceFactory, categoryService, apiClientFactory, recordingService)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override BusinessUnit Unit => BusinessUnit.RSI;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override string LogoResourceName => "Jellyfin.Plugin.SRFPlay.Images.rsi-logo.png";
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override string Description => "Radiotelevisione svizzera — Swiss Italian video-on-demand content";
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
using Jellyfin.Plugin.SRFPlay.Api;
|
||||||
|
using Jellyfin.Plugin.SRFPlay.Configuration;
|
||||||
|
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.SRFPlay.Channels;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Radiotelevisiun Svizra Rumantscha — Swiss Romansh channel.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class RtrChannel : SrgChannelBase
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="RtrChannel"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="loggerFactory">The logger factory.</param>
|
||||||
|
/// <param name="contentRefreshService">The content refresh service.</param>
|
||||||
|
/// <param name="streamResolver">The stream resolver.</param>
|
||||||
|
/// <param name="mediaSourceFactory">The media source factory.</param>
|
||||||
|
/// <param name="categoryService">The category service.</param>
|
||||||
|
/// <param name="apiClientFactory">The API client factory.</param>
|
||||||
|
/// <param name="recordingService">The recording service.</param>
|
||||||
|
public RtrChannel(
|
||||||
|
ILoggerFactory loggerFactory,
|
||||||
|
IContentRefreshService contentRefreshService,
|
||||||
|
IStreamUrlResolver streamResolver,
|
||||||
|
IMediaSourceFactory mediaSourceFactory,
|
||||||
|
ICategoryService? categoryService,
|
||||||
|
ISRFApiClientFactory apiClientFactory,
|
||||||
|
IRecordingService recordingService)
|
||||||
|
: base(loggerFactory, contentRefreshService, streamResolver, mediaSourceFactory, categoryService, apiClientFactory, recordingService)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override BusinessUnit Unit => BusinessUnit.RTR;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override string LogoResourceName => "Jellyfin.Plugin.SRFPlay.Images.rtr-logo.png";
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override string Description => "Radiotelevisiun Svizra Rumantscha — Swiss Romansh video-on-demand content";
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
using Jellyfin.Plugin.SRFPlay.Api;
|
||||||
|
using Jellyfin.Plugin.SRFPlay.Configuration;
|
||||||
|
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.SRFPlay.Channels;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Radio Télévision Suisse — Swiss French channel.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class RtsChannel : SrgChannelBase
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="RtsChannel"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="loggerFactory">The logger factory.</param>
|
||||||
|
/// <param name="contentRefreshService">The content refresh service.</param>
|
||||||
|
/// <param name="streamResolver">The stream resolver.</param>
|
||||||
|
/// <param name="mediaSourceFactory">The media source factory.</param>
|
||||||
|
/// <param name="categoryService">The category service.</param>
|
||||||
|
/// <param name="apiClientFactory">The API client factory.</param>
|
||||||
|
/// <param name="recordingService">The recording service.</param>
|
||||||
|
public RtsChannel(
|
||||||
|
ILoggerFactory loggerFactory,
|
||||||
|
IContentRefreshService contentRefreshService,
|
||||||
|
IStreamUrlResolver streamResolver,
|
||||||
|
IMediaSourceFactory mediaSourceFactory,
|
||||||
|
ICategoryService? categoryService,
|
||||||
|
ISRFApiClientFactory apiClientFactory,
|
||||||
|
IRecordingService recordingService)
|
||||||
|
: base(loggerFactory, contentRefreshService, streamResolver, mediaSourceFactory, categoryService, apiClientFactory, recordingService)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override BusinessUnit Unit => BusinessUnit.RTS;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override string LogoResourceName => "Jellyfin.Plugin.SRFPlay.Images.rts-logo.png";
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override string Description => "Radio Télévision Suisse — Swiss French video-on-demand content";
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
using Jellyfin.Plugin.SRFPlay.Api;
|
||||||
|
using Jellyfin.Plugin.SRFPlay.Configuration;
|
||||||
|
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.SRFPlay.Channels;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Schweizer Radio und Fernsehen — Swiss German channel.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SrfChannel : SrgChannelBase
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="SrfChannel"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="loggerFactory">The logger factory.</param>
|
||||||
|
/// <param name="contentRefreshService">The content refresh service.</param>
|
||||||
|
/// <param name="streamResolver">The stream resolver.</param>
|
||||||
|
/// <param name="mediaSourceFactory">The media source factory.</param>
|
||||||
|
/// <param name="categoryService">The category service.</param>
|
||||||
|
/// <param name="apiClientFactory">The API client factory.</param>
|
||||||
|
/// <param name="recordingService">The recording service.</param>
|
||||||
|
public SrfChannel(
|
||||||
|
ILoggerFactory loggerFactory,
|
||||||
|
IContentRefreshService contentRefreshService,
|
||||||
|
IStreamUrlResolver streamResolver,
|
||||||
|
IMediaSourceFactory mediaSourceFactory,
|
||||||
|
ICategoryService? categoryService,
|
||||||
|
ISRFApiClientFactory apiClientFactory,
|
||||||
|
IRecordingService recordingService)
|
||||||
|
: base(loggerFactory, contentRefreshService, streamResolver, mediaSourceFactory, categoryService, apiClientFactory, recordingService)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override BusinessUnit Unit => BusinessUnit.SRF;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override string LogoResourceName => "Jellyfin.Plugin.SRFPlay.Images.srf-logo.png";
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override string Description => "Schweizer Radio und Fernsehen — Swiss German video-on-demand content";
|
||||||
|
}
|
||||||
+117
-23
@@ -6,6 +6,8 @@ using System.Text;
|
|||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Jellyfin.Plugin.SRFPlay.Api;
|
using Jellyfin.Plugin.SRFPlay.Api;
|
||||||
|
using Jellyfin.Plugin.SRFPlay.Api.Models;
|
||||||
|
using Jellyfin.Plugin.SRFPlay.Configuration;
|
||||||
using Jellyfin.Plugin.SRFPlay.Constants;
|
using Jellyfin.Plugin.SRFPlay.Constants;
|
||||||
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
||||||
using Jellyfin.Plugin.SRFPlay.Utilities;
|
using Jellyfin.Plugin.SRFPlay.Utilities;
|
||||||
@@ -15,24 +17,27 @@ using MediaBrowser.Controller.Providers;
|
|||||||
using MediaBrowser.Model.Channels;
|
using MediaBrowser.Model.Channels;
|
||||||
using MediaBrowser.Model.Dto;
|
using MediaBrowser.Model.Dto;
|
||||||
using MediaBrowser.Model.Entities;
|
using MediaBrowser.Model.Entities;
|
||||||
|
using MediaBrowser.Model.MediaInfo;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace Jellyfin.Plugin.SRFPlay.Channels;
|
namespace Jellyfin.Plugin.SRFPlay.Channels;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// SRF Play channel for browsing and playing content.
|
/// Base channel for browsing and playing content from a single SRG SSR business unit.
|
||||||
|
/// One concrete subclass exists per unit (SRF, RTS, RSI, RTR, SWI), each shown as its own tile.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class SRFPlayChannel : IChannel, IHasCacheKey
|
public abstract class SrgChannelBase : IChannel, IHasCacheKey
|
||||||
{
|
{
|
||||||
private readonly ILogger<SRFPlayChannel> _logger;
|
private readonly ILogger<SrgChannelBase> _logger;
|
||||||
private readonly IContentRefreshService _contentRefreshService;
|
private readonly IContentRefreshService _contentRefreshService;
|
||||||
private readonly IStreamUrlResolver _streamResolver;
|
private readonly IStreamUrlResolver _streamResolver;
|
||||||
private readonly IMediaSourceFactory _mediaSourceFactory;
|
private readonly IMediaSourceFactory _mediaSourceFactory;
|
||||||
private readonly ICategoryService? _categoryService;
|
private readonly ICategoryService? _categoryService;
|
||||||
private readonly ISRFApiClientFactory _apiClientFactory;
|
private readonly ISRFApiClientFactory _apiClientFactory;
|
||||||
|
private readonly IRecordingService _recordingService;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="SRFPlayChannel"/> class.
|
/// Initializes a new instance of the <see cref="SrgChannelBase"/> class.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="loggerFactory">The logger factory.</param>
|
/// <param name="loggerFactory">The logger factory.</param>
|
||||||
/// <param name="contentRefreshService">The content refresh service.</param>
|
/// <param name="contentRefreshService">The content refresh service.</param>
|
||||||
@@ -40,34 +45,47 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
|
|||||||
/// <param name="mediaSourceFactory">The media source factory.</param>
|
/// <param name="mediaSourceFactory">The media source factory.</param>
|
||||||
/// <param name="categoryService">The category service (optional).</param>
|
/// <param name="categoryService">The category service (optional).</param>
|
||||||
/// <param name="apiClientFactory">The API client factory.</param>
|
/// <param name="apiClientFactory">The API client factory.</param>
|
||||||
public SRFPlayChannel(
|
/// <param name="recordingService">The recording service.</param>
|
||||||
|
protected SrgChannelBase(
|
||||||
ILoggerFactory loggerFactory,
|
ILoggerFactory loggerFactory,
|
||||||
IContentRefreshService contentRefreshService,
|
IContentRefreshService contentRefreshService,
|
||||||
IStreamUrlResolver streamResolver,
|
IStreamUrlResolver streamResolver,
|
||||||
IMediaSourceFactory mediaSourceFactory,
|
IMediaSourceFactory mediaSourceFactory,
|
||||||
ICategoryService? categoryService,
|
ICategoryService? categoryService,
|
||||||
ISRFApiClientFactory apiClientFactory)
|
ISRFApiClientFactory apiClientFactory,
|
||||||
|
IRecordingService recordingService)
|
||||||
{
|
{
|
||||||
_logger = loggerFactory.CreateLogger<SRFPlayChannel>();
|
_logger = loggerFactory.CreateLogger<SrgChannelBase>();
|
||||||
_contentRefreshService = contentRefreshService;
|
_contentRefreshService = contentRefreshService;
|
||||||
_streamResolver = streamResolver;
|
_streamResolver = streamResolver;
|
||||||
_mediaSourceFactory = mediaSourceFactory;
|
_mediaSourceFactory = mediaSourceFactory;
|
||||||
_categoryService = categoryService;
|
_categoryService = categoryService;
|
||||||
_apiClientFactory = apiClientFactory;
|
_apiClientFactory = apiClientFactory;
|
||||||
|
_recordingService = recordingService;
|
||||||
|
|
||||||
if (_categoryService == null)
|
if (_categoryService == null)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("CategoryService not available - category folders will be disabled");
|
_logger.LogWarning("CategoryService not available - category folders will be disabled");
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogDebug("SRFPlayChannel initialized");
|
_logger.LogDebug("SrgChannelBase initialized");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <summary>
|
||||||
public string Name => "SRF Play";
|
/// Gets the business unit this channel serves.
|
||||||
|
/// </summary>
|
||||||
|
protected abstract BusinessUnit Unit { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the embedded resource name of this unit's logo PNG.
|
||||||
|
/// </summary>
|
||||||
|
protected abstract string LogoResourceName { get; }
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public string Description => "Swiss Radio and Television video-on-demand content";
|
public string Name => $"{Unit} Play";
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public abstract string Description { get; }
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public string DataVersion => "2.0"; // Back to authenticating at channel refresh with auto-refresh for fresh tokens
|
public string DataVersion => "2.0"; // Back to authenticating at channel refresh with auto-refresh for fresh tokens
|
||||||
@@ -108,7 +126,8 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
|
|||||||
public Task<DynamicImageResponse> GetChannelImage(ImageType type, CancellationToken cancellationToken)
|
public Task<DynamicImageResponse> GetChannelImage(ImageType type, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var assembly = GetType().Assembly;
|
var assembly = GetType().Assembly;
|
||||||
var resourceStream = assembly.GetManifestResourceStream("Jellyfin.Plugin.SRFPlay.Images.logo.png");
|
var resourceStream = assembly.GetManifestResourceStream(LogoResourceName)
|
||||||
|
?? assembly.GetManifestResourceStream("Jellyfin.Plugin.SRFPlay.Images.logo.png");
|
||||||
|
|
||||||
if (resourceStream == null)
|
if (resourceStream == null)
|
||||||
{
|
{
|
||||||
@@ -156,8 +175,21 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private bool IsUnitEnabled()
|
||||||
|
{
|
||||||
|
var config = Plugin.Instance?.Configuration;
|
||||||
|
return config == null || config.ResolveEnabledUnits().Contains(Unit);
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<List<ChannelItemInfo>> GetFolderItemsAsync(string? folderId, CancellationToken cancellationToken)
|
private async Task<List<ChannelItemInfo>> GetFolderItemsAsync(string? folderId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
// If this unit is not enabled in configuration, show nothing (empty tile).
|
||||||
|
if (!IsUnitEnabled())
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Business unit {Unit} is not enabled - returning no items", Unit);
|
||||||
|
return new List<ChannelItemInfo>();
|
||||||
|
}
|
||||||
|
|
||||||
// Root level - show folder list
|
// Root level - show folder list
|
||||||
if (string.IsNullOrEmpty(folderId))
|
if (string.IsNullOrEmpty(folderId))
|
||||||
{
|
{
|
||||||
@@ -170,6 +202,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
|
|||||||
"latest" => await GetLatestVideosAsync(cancellationToken).ConfigureAwait(false),
|
"latest" => await GetLatestVideosAsync(cancellationToken).ConfigureAwait(false),
|
||||||
"trending" => await GetTrendingVideosAsync(cancellationToken).ConfigureAwait(false),
|
"trending" => await GetTrendingVideosAsync(cancellationToken).ConfigureAwait(false),
|
||||||
"live_sports" => await GetLiveSportsAsync(cancellationToken).ConfigureAwait(false),
|
"live_sports" => await GetLiveSportsAsync(cancellationToken).ConfigureAwait(false),
|
||||||
|
"recordings" => GetRecordingItems(),
|
||||||
_ when folderId.StartsWith("category_", StringComparison.Ordinal) => await GetCategoryVideosAsync(folderId, cancellationToken).ConfigureAwait(false),
|
_ when folderId.StartsWith("category_", StringComparison.Ordinal) => await GetCategoryVideosAsync(folderId, cancellationToken).ConfigureAwait(false),
|
||||||
_ => new List<ChannelItemInfo>()
|
_ => new List<ChannelItemInfo>()
|
||||||
};
|
};
|
||||||
@@ -181,7 +214,8 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
|
|||||||
{
|
{
|
||||||
CreateFolder("latest", "Latest Videos"),
|
CreateFolder("latest", "Latest Videos"),
|
||||||
CreateFolder("trending", "Trending Videos"),
|
CreateFolder("trending", "Trending Videos"),
|
||||||
CreateFolder("live_sports", "Live Sports & Events")
|
CreateFolder("live_sports", "Live Sports & Events"),
|
||||||
|
CreateFolder("recordings", "Recordings")
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add category folders if enabled
|
// Add category folders if enabled
|
||||||
@@ -190,7 +224,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var businessUnit = config.BusinessUnit.ToLowerString();
|
var businessUnit = Unit.ToLowerString();
|
||||||
var topics = await _categoryService.GetTopicsAsync(businessUnit, cancellationToken).ConfigureAwait(false);
|
var topics = await _categoryService.GetTopicsAsync(businessUnit, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
foreach (var topic in topics.Where(t => !string.IsNullOrEmpty(t.Id)))
|
foreach (var topic in topics.Where(t => !string.IsNullOrEmpty(t.Id)))
|
||||||
@@ -231,24 +265,23 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
|
|||||||
|
|
||||||
private async Task<List<ChannelItemInfo>> GetLatestVideosAsync(CancellationToken cancellationToken)
|
private async Task<List<ChannelItemInfo>> GetLatestVideosAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var urns = await _contentRefreshService.RefreshLatestContentAsync(cancellationToken).ConfigureAwait(false);
|
var urns = await _contentRefreshService.RefreshLatestContentAsync(Unit.ToLowerString(), cancellationToken).ConfigureAwait(false);
|
||||||
return await ConvertUrnsToChannelItems(urns, cancellationToken).ConfigureAwait(false);
|
return await ConvertUrnsToChannelItems(urns, cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<List<ChannelItemInfo>> GetTrendingVideosAsync(CancellationToken cancellationToken)
|
private async Task<List<ChannelItemInfo>> GetTrendingVideosAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var urns = await _contentRefreshService.RefreshTrendingContentAsync(cancellationToken).ConfigureAwait(false);
|
var urns = await _contentRefreshService.RefreshTrendingContentAsync(Unit.ToLowerString(), cancellationToken).ConfigureAwait(false);
|
||||||
return await ConvertUrnsToChannelItems(urns, cancellationToken).ConfigureAwait(false);
|
return await ConvertUrnsToChannelItems(urns, cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<List<ChannelItemInfo>> GetLiveSportsAsync(CancellationToken cancellationToken)
|
private async Task<List<ChannelItemInfo>> GetLiveSportsAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var items = new List<ChannelItemInfo>();
|
var items = new List<ChannelItemInfo>();
|
||||||
var config = Plugin.Instance?.Configuration;
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var businessUnit = config?.BusinessUnit.ToLowerString() ?? "srf";
|
var businessUnit = Unit.ToLowerString();
|
||||||
|
|
||||||
using var apiClient = _apiClientFactory.CreateClient();
|
using var apiClient = _apiClientFactory.CreateClient();
|
||||||
var scheduledLivestreams = await apiClient.GetScheduledLivestreamsAsync(businessUnit, "SPORT", cancellationToken).ConfigureAwait(false);
|
var scheduledLivestreams = await apiClient.GetScheduledLivestreamsAsync(businessUnit, "SPORT", cancellationToken).ConfigureAwait(false);
|
||||||
@@ -296,6 +329,68 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
|
|||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private List<ChannelItemInfo> GetRecordingItems()
|
||||||
|
{
|
||||||
|
var items = new List<ChannelItemInfo>();
|
||||||
|
var unit = Unit.ToLowerString();
|
||||||
|
var recordings = _recordingService.GetRecordings(RecordingState.Completed);
|
||||||
|
|
||||||
|
foreach (var recording in recordings)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(recording.OutputPath) || !System.IO.File.Exists(recording.OutputPath))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only show recordings belonging to this unit. Recordings created before the
|
||||||
|
// BusinessUnit tag existed have an empty value and are shown everywhere so they
|
||||||
|
// are not lost.
|
||||||
|
if (!string.IsNullOrEmpty(recording.BusinessUnit) &&
|
||||||
|
!string.Equals(recording.BusinessUnit, unit, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var fileInfo = new System.IO.FileInfo(recording.OutputPath);
|
||||||
|
var itemId = $"recording_{recording.Id}";
|
||||||
|
|
||||||
|
var mediaSource = new MediaSourceInfo
|
||||||
|
{
|
||||||
|
Id = itemId,
|
||||||
|
Name = recording.Title,
|
||||||
|
Path = recording.OutputPath,
|
||||||
|
Protocol = MediaProtocol.File,
|
||||||
|
Container = "mkv",
|
||||||
|
SupportsDirectPlay = true,
|
||||||
|
SupportsDirectStream = true,
|
||||||
|
SupportsTranscoding = true,
|
||||||
|
IsRemote = false,
|
||||||
|
Size = fileInfo.Length,
|
||||||
|
Type = MediaSourceType.Default
|
||||||
|
};
|
||||||
|
|
||||||
|
var item = new ChannelItemInfo
|
||||||
|
{
|
||||||
|
Id = itemId,
|
||||||
|
Name = recording.Title,
|
||||||
|
Overview = recording.Description,
|
||||||
|
Type = ChannelItemType.Media,
|
||||||
|
ContentType = ChannelMediaContentType.Movie,
|
||||||
|
MediaType = ChannelMediaType.Video,
|
||||||
|
DateCreated = recording.RecordingStartedAt,
|
||||||
|
ImageUrl = !string.IsNullOrEmpty(recording.ImageUrl)
|
||||||
|
? CreateProxiedImageUrl(recording.ImageUrl, _mediaSourceFactory.GetServerBaseUrl())
|
||||||
|
: CreatePlaceholderImageUrl(recording.Title, _mediaSourceFactory.GetServerBaseUrl()),
|
||||||
|
MediaSources = new List<MediaSourceInfo> { mediaSource }
|
||||||
|
};
|
||||||
|
|
||||||
|
items.Add(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Returning {Count} completed recordings as channel items", items.Count);
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<List<ChannelItemInfo>> GetCategoryVideosAsync(string folderId, CancellationToken cancellationToken)
|
private async Task<List<ChannelItemInfo>> GetCategoryVideosAsync(string folderId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var items = new List<ChannelItemInfo>();
|
var items = new List<ChannelItemInfo>();
|
||||||
@@ -308,9 +403,8 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var config = Plugin.Instance?.Configuration;
|
|
||||||
var topicId = folderId.Substring("category_".Length);
|
var topicId = folderId.Substring("category_".Length);
|
||||||
var businessUnit = config?.BusinessUnit.ToLowerString() ?? "srf";
|
var businessUnit = Unit.ToLowerString();
|
||||||
|
|
||||||
var shows = await _categoryService.GetShowsByTopicAsync(topicId, businessUnit, 20, cancellationToken).ConfigureAwait(false);
|
var shows = await _categoryService.GetShowsByTopicAsync(topicId, businessUnit, 20, cancellationToken).ConfigureAwait(false);
|
||||||
var urns = new List<string>();
|
var urns = new List<string>();
|
||||||
@@ -411,7 +505,8 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
|
|||||||
var timeBucket = new DateTime(now.Year, now.Month, now.Day, now.Hour, (now.Minute / 15) * 15, 0);
|
var timeBucket = new DateTime(now.Year, now.Month, now.Day, now.Hour, (now.Minute / 15) * 15, 0);
|
||||||
var timeKey = timeBucket.ToString("yyyy-MM-dd-HH-mm", CultureInfo.InvariantCulture);
|
var timeKey = timeBucket.ToString("yyyy-MM-dd-HH-mm", CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
return $"{config?.BusinessUnit}_{config?.EnableLatestContent}_{config?.EnableTrendingContent}_{config?.EnableCategoryFolders}_{enabledTopics}_{timeKey}";
|
var recordingCount = _recordingService.GetRecordings(RecordingState.Completed).Count;
|
||||||
|
return $"{Unit}_{config?.EnableLatestContent}_{config?.EnableTrendingContent}_{config?.EnableCategoryFolders}_{enabledTopics}_{timeKey}_rec{recordingCount}";
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<List<ChannelItemInfo>> ConvertUrnsToChannelItems(List<string> urns, CancellationToken cancellationToken)
|
private async Task<List<ChannelItemInfo>> ConvertUrnsToChannelItems(List<string> urns, CancellationToken cancellationToken)
|
||||||
@@ -553,8 +648,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
|
|||||||
ProviderIds = new Dictionary<string, string>
|
ProviderIds = new Dictionary<string, string>
|
||||||
{
|
{
|
||||||
{ "SRF", urn }
|
{ "SRF", urn }
|
||||||
},
|
}
|
||||||
MediaSources = new List<MediaSourceInfo> { mediaSource }
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add series info if available
|
// Add series info if available
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
using Jellyfin.Plugin.SRFPlay.Api;
|
||||||
|
using Jellyfin.Plugin.SRFPlay.Configuration;
|
||||||
|
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.SRFPlay.Channels;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SWI swissinfo.ch — Swiss international channel.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SwiChannel : SrgChannelBase
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="SwiChannel"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="loggerFactory">The logger factory.</param>
|
||||||
|
/// <param name="contentRefreshService">The content refresh service.</param>
|
||||||
|
/// <param name="streamResolver">The stream resolver.</param>
|
||||||
|
/// <param name="mediaSourceFactory">The media source factory.</param>
|
||||||
|
/// <param name="categoryService">The category service.</param>
|
||||||
|
/// <param name="apiClientFactory">The API client factory.</param>
|
||||||
|
/// <param name="recordingService">The recording service.</param>
|
||||||
|
public SwiChannel(
|
||||||
|
ILoggerFactory loggerFactory,
|
||||||
|
IContentRefreshService contentRefreshService,
|
||||||
|
IStreamUrlResolver streamResolver,
|
||||||
|
IMediaSourceFactory mediaSourceFactory,
|
||||||
|
ICategoryService? categoryService,
|
||||||
|
ISRFApiClientFactory apiClientFactory,
|
||||||
|
IRecordingService recordingService)
|
||||||
|
: base(loggerFactory, contentRefreshService, streamResolver, mediaSourceFactory, categoryService, apiClientFactory, recordingService)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override BusinessUnit Unit => BusinessUnit.SWI;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override string LogoResourceName => "Jellyfin.Plugin.SRFPlay.Images.swi-logo.png";
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override string Description => "SWI swissinfo.ch — Swiss international video-on-demand content";
|
||||||
|
}
|
||||||
@@ -66,6 +66,7 @@ public class PluginConfiguration : BasePluginConfiguration
|
|||||||
{
|
{
|
||||||
// Set default options
|
// Set default options
|
||||||
BusinessUnit = BusinessUnit.SRF;
|
BusinessUnit = BusinessUnit.SRF;
|
||||||
|
EnabledBusinessUnits = new System.Collections.Generic.List<BusinessUnit> { BusinessUnit.SRF };
|
||||||
QualityPreference = QualityPreference.Auto;
|
QualityPreference = QualityPreference.Auto;
|
||||||
ContentRefreshIntervalHours = 6;
|
ContentRefreshIntervalHours = 6;
|
||||||
ExpirationCheckIntervalHours = 24;
|
ExpirationCheckIntervalHours = 24;
|
||||||
@@ -78,10 +79,20 @@ public class PluginConfiguration : BasePluginConfiguration
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the business unit to fetch content from.
|
/// Gets or sets the legacy single business unit. Retained for backwards compatibility and
|
||||||
|
/// migration into <see cref="EnabledBusinessUnits"/>; new code should use the list instead.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public BusinessUnit BusinessUnit { get; set; }
|
public BusinessUnit BusinessUnit { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the list of business units to expose as channels. One channel tile is shown
|
||||||
|
/// per enabled unit, so polylingual households can browse e.g. SRF (German) and RTS (French)
|
||||||
|
/// at the same time.
|
||||||
|
/// </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<BusinessUnit> EnabledBusinessUnits { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the preferred video quality.
|
/// Gets or sets the preferred video quality.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -161,4 +172,20 @@ public class PluginConfiguration : BasePluginConfiguration
|
|||||||
/// Gets or sets the output directory for sport livestream recordings.
|
/// Gets or sets the output directory for sport livestream recordings.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string RecordingOutputPath { get; set; } = string.Empty;
|
public string RecordingOutputPath { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves the effective set of enabled business units, migrating from the legacy single
|
||||||
|
/// <see cref="BusinessUnit"/> value when the list has not been populated yet.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>The business units that should have channels.</returns>
|
||||||
|
public System.Collections.Generic.IReadOnlyList<BusinessUnit> ResolveEnabledUnits()
|
||||||
|
{
|
||||||
|
if (EnabledBusinessUnits != null && EnabledBusinessUnits.Count > 0)
|
||||||
|
{
|
||||||
|
return EnabledBusinessUnits;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy installs only had a single BusinessUnit; honour it so they keep working.
|
||||||
|
return new System.Collections.Generic.List<BusinessUnit> { BusinessUnit };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,30 @@
|
|||||||
<!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 id="SRFPlayConfigPage" data-role="page" class="page type-interior pluginConfigurationPage" data-require="emby-input,emby-button,emby-select,emby-checkbox">
|
||||||
<div data-role="content">
|
<div data-role="content">
|
||||||
<div class="content-primary">
|
<div class="content-primary">
|
||||||
<form id="SRFPlayConfigForm">
|
<form id="SRFPlayConfigForm">
|
||||||
<div class="selectContainer">
|
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||||
<label class="selectLabel" for="BusinessUnit">Business Unit</label>
|
<h3>Channels (Business Units)</h3>
|
||||||
<select is="emby-select" id="BusinessUnit" name="BusinessUnit" class="emby-select-withcolor emby-select">
|
<div class="fieldDescription" style="margin-bottom: 0.5em;">Enable a channel tile for each Swiss broadcaster you want. A separate tile appears per enabled unit, so polylingual households can browse e.g. SRF (German) and RTS (French) at the same time.</div>
|
||||||
<option id="optSRF" value="SRF">SRF (German)</option>
|
<label class="emby-checkbox-label">
|
||||||
<option id="optRTS" value="RTS">RTS (French)</option>
|
<input id="buSRF" type="checkbox" is="emby-checkbox" data-bu="SRF" />
|
||||||
<option id="optRSI" value="RSI">RSI (Italian)</option>
|
<span>SRF — Schweizer Radio und Fernsehen (German)</span>
|
||||||
<option id="optRTR" value="RTR">RTR (Romansh)</option>
|
</label>
|
||||||
<option id="optSWI" value="SWI">SWI (International)</option>
|
<label class="emby-checkbox-label">
|
||||||
</select>
|
<input id="buRTS" type="checkbox" is="emby-checkbox" data-bu="RTS" />
|
||||||
<div class="fieldDescription">Select the Swiss broadcasting unit to fetch content from</div>
|
<span>RTS — Radio Télévision Suisse (French)</span>
|
||||||
|
</label>
|
||||||
|
<label class="emby-checkbox-label">
|
||||||
|
<input id="buRSI" type="checkbox" is="emby-checkbox" data-bu="RSI" />
|
||||||
|
<span>RSI — Radiotelevisione svizzera (Italian)</span>
|
||||||
|
</label>
|
||||||
|
<label class="emby-checkbox-label">
|
||||||
|
<input id="buRTR" type="checkbox" is="emby-checkbox" data-bu="RTR" />
|
||||||
|
<span>RTR — Radiotelevisiun Svizra Rumantscha (Romansh)</span>
|
||||||
|
</label>
|
||||||
|
<label class="emby-checkbox-label">
|
||||||
|
<input id="buSWI" type="checkbox" is="emby-checkbox" data-bu="SWI" />
|
||||||
|
<span>SWI — swissinfo.ch (International)</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="selectContainer">
|
<div class="selectContainer">
|
||||||
<label class="selectLabel" for="QualityPreference">Quality Preference</label>
|
<label class="selectLabel" for="QualityPreference">Quality Preference</label>
|
||||||
@@ -149,7 +155,12 @@
|
|||||||
.addEventListener('pageshow', function() {
|
.addEventListener('pageshow', function() {
|
||||||
Dashboard.showLoadingMsg();
|
Dashboard.showLoadingMsg();
|
||||||
ApiClient.getPluginConfiguration(SRFPlayConfig.pluginUniqueId).then(function (config) {
|
ApiClient.getPluginConfiguration(SRFPlayConfig.pluginUniqueId).then(function (config) {
|
||||||
document.querySelector('#BusinessUnit').value = config.BusinessUnit;
|
var enabled = (config.EnabledBusinessUnits && config.EnabledBusinessUnits.length)
|
||||||
|
? config.EnabledBusinessUnits
|
||||||
|
: [config.BusinessUnit]; // migrate legacy single value
|
||||||
|
['SRF','RTS','RSI','RTR','SWI'].forEach(function(bu) {
|
||||||
|
document.querySelector('#bu' + bu).checked = enabled.indexOf(bu) !== -1;
|
||||||
|
});
|
||||||
document.querySelector('#QualityPreference').value = config.QualityPreference;
|
document.querySelector('#QualityPreference').value = config.QualityPreference;
|
||||||
document.querySelector('#ContentRefreshIntervalHours').value = config.ContentRefreshIntervalHours;
|
document.querySelector('#ContentRefreshIntervalHours').value = config.ContentRefreshIntervalHours;
|
||||||
document.querySelector('#ExpirationCheckIntervalHours').value = config.ExpirationCheckIntervalHours;
|
document.querySelector('#ExpirationCheckIntervalHours').value = config.ExpirationCheckIntervalHours;
|
||||||
@@ -175,7 +186,11 @@
|
|||||||
.addEventListener('submit', function(e) {
|
.addEventListener('submit', function(e) {
|
||||||
Dashboard.showLoadingMsg();
|
Dashboard.showLoadingMsg();
|
||||||
ApiClient.getPluginConfiguration(SRFPlayConfig.pluginUniqueId).then(function (config) {
|
ApiClient.getPluginConfiguration(SRFPlayConfig.pluginUniqueId).then(function (config) {
|
||||||
config.BusinessUnit = document.querySelector('#BusinessUnit').value;
|
config.EnabledBusinessUnits = ['SRF','RTS','RSI','RTR','SWI'].filter(function(bu) {
|
||||||
|
return document.querySelector('#bu' + bu).checked;
|
||||||
|
});
|
||||||
|
// Keep legacy field in sync with the first enabled unit for backwards compat.
|
||||||
|
config.BusinessUnit = config.EnabledBusinessUnits[0] || 'SRF';
|
||||||
config.QualityPreference = document.querySelector('#QualityPreference').value;
|
config.QualityPreference = document.querySelector('#QualityPreference').value;
|
||||||
config.ContentRefreshIntervalHours = parseInt(document.querySelector('#ContentRefreshIntervalHours').value);
|
config.ContentRefreshIntervalHours = parseInt(document.querySelector('#ContentRefreshIntervalHours').value);
|
||||||
config.ExpirationCheckIntervalHours = parseInt(document.querySelector('#ExpirationCheckIntervalHours').value);
|
config.ExpirationCheckIntervalHours = parseInt(document.querySelector('#ExpirationCheckIntervalHours').value);
|
||||||
@@ -398,5 +413,3 @@
|
|||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|||||||
@@ -0,0 +1,346 @@
|
|||||||
|
<div id="SRFPlayRecordingsPage" data-role="page" class="page type-interior pluginConfigurationPage"
|
||||||
|
data-require="emby-button,emby-input">
|
||||||
|
<style>
|
||||||
|
#SRFPlayRecordingsPage .srfrec-wrap { padding: 0 1em; }
|
||||||
|
#SRFPlayRecordingsPage table { width: 100%; border-collapse: collapse; margin: 10px 0 20px; }
|
||||||
|
#SRFPlayRecordingsPage th { text-align: left; padding: 10px 8px; border-bottom: 2px solid #444; color: #aaa; font-size: 0.85em; text-transform: uppercase; }
|
||||||
|
#SRFPlayRecordingsPage td { padding: 10px 8px; border-bottom: 1px solid #2a2a3e; }
|
||||||
|
#SRFPlayRecordingsPage tr:hover { background: rgba(255,255,255,0.04); }
|
||||||
|
#SRFPlayRecordingsPage .btn { padding: 6px 16px; border: none; border-radius: 4px; cursor: pointer; font-size: 0.85em; color: #fff; transition: opacity 0.2s; }
|
||||||
|
#SRFPlayRecordingsPage .btn:hover { opacity: 0.85; }
|
||||||
|
#SRFPlayRecordingsPage .btn-record { background: #4CAF50; }
|
||||||
|
#SRFPlayRecordingsPage .btn-stop { background: #FF9800; }
|
||||||
|
#SRFPlayRecordingsPage .btn-cancel { background: #9E9E9E; }
|
||||||
|
#SRFPlayRecordingsPage .btn-delete { background: #f44336; }
|
||||||
|
#SRFPlayRecordingsPage .btn-refresh { background: #2196F3; margin-bottom: 16px; }
|
||||||
|
#SRFPlayRecordingsPage .status { padding: 3px 8px; border-radius: 3px; font-size: 0.8em; font-weight: 600; color: #fff; }
|
||||||
|
#SRFPlayRecordingsPage .status-scheduled { background: #1565C0; }
|
||||||
|
#SRFPlayRecordingsPage .status-waiting { background: #E65100; }
|
||||||
|
#SRFPlayRecordingsPage .status-recording { background: #2E7D32; }
|
||||||
|
#SRFPlayRecordingsPage .status-failed { background: #C62828; }
|
||||||
|
#SRFPlayRecordingsPage .srfrec-msg { padding: 12px; color: #999; font-style: italic; }
|
||||||
|
#SRFPlayRecordingsPage .srfrec-error { color: #f44; }
|
||||||
|
#SRFPlayRecordingsPage .login-form { max-width: 400px; margin: 40px auto; }
|
||||||
|
#SRFPlayRecordingsPage .login-form input { width: 100%; padding: 10px; margin: 8px 0; background: #2a2a3e; border: 1px solid #444; color: #fff; border-radius: 4px; font-size: 1em; }
|
||||||
|
#SRFPlayRecordingsPage .login-form .btn { width: 100%; padding: 12px; font-size: 1em; margin-top: 12px; }
|
||||||
|
</style>
|
||||||
|
<div data-role="content">
|
||||||
|
<div class="content-primary srfrec-wrap">
|
||||||
|
<div id="srfrecLoginSection" style="display:none;">
|
||||||
|
<div class="login-form">
|
||||||
|
<h1>SRF Sport Recordings</h1>
|
||||||
|
<p class="fieldDescription">Sign in to your Jellyfin server</p>
|
||||||
|
<input type="text" id="srfrecLoginServer" placeholder="Server URL (e.g. http://192.168.1.50:8096)" />
|
||||||
|
<input type="text" id="srfrecLoginUser" placeholder="Username" />
|
||||||
|
<input type="password" id="srfrecLoginPass" placeholder="Password" />
|
||||||
|
<button type="button" class="btn btn-record" onclick="SRFRec.login()">Sign In</button>
|
||||||
|
<p id="srfrecLoginError" class="srfrec-error" style="margin-top:10px;"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="srfrecMainSection" style="display:none;">
|
||||||
|
<h1>SRF Sport Recordings</h1>
|
||||||
|
<p class="fieldDescription">Browse upcoming sport livestreams and schedule recordings. <span id="srfrecUserInfo"></span></p>
|
||||||
|
|
||||||
|
<h2>Upcoming Sport Livestreams</h2>
|
||||||
|
<button type="button" class="btn btn-refresh" onclick="SRFRec.loadSchedule()">Refresh Schedule</button>
|
||||||
|
<div id="srfrecScheduleContainer"><p class="srfrec-msg">Loading schedule...</p></div>
|
||||||
|
|
||||||
|
<h2>Scheduled & Active Recordings</h2>
|
||||||
|
<div id="srfrecActiveRecordingsContainer"><p class="srfrec-msg">Loading...</p></div>
|
||||||
|
|
||||||
|
<h2>Completed Recordings</h2>
|
||||||
|
<button type="button" class="btn btn-refresh" onclick="SRFRec.loadRecordings()">Refresh</button>
|
||||||
|
<div id="srfrecCompletedRecordingsContainer"><p class="srfrec-msg">Loading...</p></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
var SRFRec = {
|
||||||
|
serverUrl: '',
|
||||||
|
token: '',
|
||||||
|
|
||||||
|
init: function() {
|
||||||
|
// Check if we're inside Jellyfin's web client (ApiClient available)
|
||||||
|
if (typeof ApiClient !== 'undefined' && ApiClient.accessToken()) {
|
||||||
|
this.serverUrl = ApiClient.serverAddress();
|
||||||
|
this.token = ApiClient.accessToken();
|
||||||
|
this.showMain();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check localStorage for saved session
|
||||||
|
var saved = localStorage.getItem('srfRecSession');
|
||||||
|
if (saved) {
|
||||||
|
try {
|
||||||
|
var session = JSON.parse(saved);
|
||||||
|
this.serverUrl = session.serverUrl;
|
||||||
|
this.token = session.token;
|
||||||
|
// Verify token still works
|
||||||
|
this.verifySession(session);
|
||||||
|
return;
|
||||||
|
} catch(e) { /* fall through to login */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show login form
|
||||||
|
var serverInput = document.getElementById('srfrecLoginServer');
|
||||||
|
serverInput.value = window.location.origin;
|
||||||
|
document.getElementById('srfrecLoginSection').style.display = 'block';
|
||||||
|
},
|
||||||
|
|
||||||
|
verifySession: function(session) {
|
||||||
|
var self = this;
|
||||||
|
fetch(this.serverUrl + '/System/Info', {
|
||||||
|
headers: { 'X-Emby-Token': this.token }
|
||||||
|
}).then(function(r) {
|
||||||
|
if (r.ok) {
|
||||||
|
document.getElementById('srfrecUserInfo').textContent = '(Server: ' + self.serverUrl + ')';
|
||||||
|
self.showMain();
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem('srfRecSession');
|
||||||
|
document.getElementById('srfrecLoginSection').style.display = 'block';
|
||||||
|
}
|
||||||
|
}).catch(function() {
|
||||||
|
localStorage.removeItem('srfRecSession');
|
||||||
|
document.getElementById('srfrecLoginSection').style.display = 'block';
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
login: function() {
|
||||||
|
var self = this;
|
||||||
|
var server = document.getElementById('srfrecLoginServer').value.replace(/\/+$/, '');
|
||||||
|
var user = document.getElementById('srfrecLoginUser').value;
|
||||||
|
var pass = document.getElementById('srfrecLoginPass').value;
|
||||||
|
var errorEl = document.getElementById('srfrecLoginError');
|
||||||
|
errorEl.textContent = '';
|
||||||
|
|
||||||
|
fetch(server + '/Users/AuthenticateByName', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Emby-Authorization': 'MediaBrowser Client="SRF Recordings", Device="Web", DeviceId="srfrec-' + Date.now() + '", Version="1.0.0"'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ Username: user, Pw: pass })
|
||||||
|
})
|
||||||
|
.then(function(r) {
|
||||||
|
if (!r.ok) throw new Error('Authentication failed');
|
||||||
|
return r.json();
|
||||||
|
})
|
||||||
|
.then(function(data) {
|
||||||
|
self.serverUrl = server;
|
||||||
|
self.token = data.AccessToken;
|
||||||
|
localStorage.setItem('srfRecSession', JSON.stringify({
|
||||||
|
serverUrl: server,
|
||||||
|
token: data.AccessToken,
|
||||||
|
userName: data.User.Name
|
||||||
|
}));
|
||||||
|
document.getElementById('srfrecUserInfo').textContent = '(Signed in as ' + data.User.Name + ')';
|
||||||
|
self.showMain();
|
||||||
|
})
|
||||||
|
.catch(function(err) {
|
||||||
|
errorEl.textContent = err.message;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
showMain: function() {
|
||||||
|
document.getElementById('srfrecLoginSection').style.display = 'none';
|
||||||
|
document.getElementById('srfrecMainSection').style.display = 'block';
|
||||||
|
this.loadSchedule();
|
||||||
|
this.loadRecordings();
|
||||||
|
},
|
||||||
|
|
||||||
|
getHeaders: function() {
|
||||||
|
return { 'X-Emby-Token': this.token };
|
||||||
|
},
|
||||||
|
|
||||||
|
formatDate: function(dateStr) {
|
||||||
|
if (!dateStr) return 'N/A';
|
||||||
|
var d = new Date(dateStr);
|
||||||
|
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
|
||||||
|
},
|
||||||
|
|
||||||
|
formatSize: function(bytes) {
|
||||||
|
if (!bytes) return '';
|
||||||
|
if (bytes > 1073741824) return (bytes / 1073741824).toFixed(1) + ' GB';
|
||||||
|
if (bytes > 1048576) return (bytes / 1048576).toFixed(0) + ' MB';
|
||||||
|
return (bytes / 1024).toFixed(0) + ' KB';
|
||||||
|
},
|
||||||
|
|
||||||
|
loadSchedule: function() {
|
||||||
|
var container = document.getElementById('srfrecScheduleContainer');
|
||||||
|
container.innerHTML = '<p class="srfrec-msg">Loading schedule...</p>';
|
||||||
|
|
||||||
|
fetch(this.serverUrl + '/Plugins/SRFPlay/Recording/Schedule', { headers: this.getHeaders() })
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(programs) {
|
||||||
|
if (!programs || programs.length === 0) {
|
||||||
|
container.innerHTML = '<p class="srfrec-msg">No upcoming sport livestreams found.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var html = '<table>';
|
||||||
|
html += '<thead><tr><th>Title</th><th>Start</th><th>End</th><th>Action</th></tr></thead><tbody>';
|
||||||
|
|
||||||
|
programs.forEach(function(p) {
|
||||||
|
html += '<tr>';
|
||||||
|
html += '<td>' + (p.title || 'Unknown') + '</td>';
|
||||||
|
html += '<td>' + SRFRec.formatDate(p.validFrom || p.date) + '</td>';
|
||||||
|
html += '<td>' + SRFRec.formatDate(p.validTo) + '</td>';
|
||||||
|
html += '<td><button type="button" class="btn btn-record" onclick="SRFRec.scheduleRecording(\'' + encodeURIComponent(p.urn) + '\')">Record</button></td>';
|
||||||
|
html += '</tr>';
|
||||||
|
});
|
||||||
|
|
||||||
|
html += '</tbody></table>';
|
||||||
|
container.innerHTML = html;
|
||||||
|
})
|
||||||
|
.catch(function(err) {
|
||||||
|
container.innerHTML = '<p class="srfrec-error">Error loading schedule: ' + err.message + '</p>';
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
scheduleRecording: function(encodedUrn) {
|
||||||
|
var self = this;
|
||||||
|
fetch(this.serverUrl + '/Plugins/SRFPlay/Recording/Schedule/' + encodedUrn, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: this.getHeaders()
|
||||||
|
})
|
||||||
|
.then(function(r) {
|
||||||
|
if (r.ok) {
|
||||||
|
alert('Recording scheduled!');
|
||||||
|
self.loadRecordings();
|
||||||
|
} else {
|
||||||
|
alert('Failed to schedule recording');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function(err) { alert('Error: ' + err.message); });
|
||||||
|
},
|
||||||
|
|
||||||
|
loadRecordings: function() {
|
||||||
|
this.loadActiveRecordings();
|
||||||
|
this.loadCompletedRecordings();
|
||||||
|
},
|
||||||
|
|
||||||
|
loadActiveRecordings: function() {
|
||||||
|
var container = document.getElementById('srfrecActiveRecordingsContainer');
|
||||||
|
container.innerHTML = '<p class="srfrec-msg">Loading...</p>';
|
||||||
|
|
||||||
|
fetch(this.serverUrl + '/Plugins/SRFPlay/Recording/All', { headers: this.getHeaders() })
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(recordings) {
|
||||||
|
var activeStates = ['Scheduled', 'WaitingForStream', 'Recording', 0, 1, 2];
|
||||||
|
var active = recordings.filter(function(r) {
|
||||||
|
return activeStates.indexOf(r.state) !== -1;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (active.length === 0) {
|
||||||
|
container.innerHTML = '<p class="srfrec-msg">No scheduled or active recordings.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var stateMap = {
|
||||||
|
'Scheduled': {label: 'Scheduled', cls: 'status-scheduled'},
|
||||||
|
'WaitingForStream': {label: 'Waiting', cls: 'status-waiting'},
|
||||||
|
'Recording': {label: 'Recording', cls: 'status-recording'},
|
||||||
|
0: {label: 'Scheduled', cls: 'status-scheduled'},
|
||||||
|
1: {label: 'Waiting', cls: 'status-waiting'},
|
||||||
|
2: {label: 'Recording', cls: 'status-recording'}
|
||||||
|
};
|
||||||
|
|
||||||
|
var html = '<table>';
|
||||||
|
html += '<thead><tr><th>Title</th><th>Status</th><th>Start</th><th>Action</th></tr></thead><tbody>';
|
||||||
|
|
||||||
|
active.forEach(function(r) {
|
||||||
|
var st = stateMap[r.state] || {label: r.state, cls: ''};
|
||||||
|
html += '<tr>';
|
||||||
|
html += '<td>' + (r.title || 'Unknown') + '</td>';
|
||||||
|
html += '<td><span class="status ' + st.cls + '">' + st.label + '</span></td>';
|
||||||
|
html += '<td>' + SRFRec.formatDate(r.validFrom) + '</td>';
|
||||||
|
html += '<td>';
|
||||||
|
if (r.state === 2 || r.state === 'Recording') {
|
||||||
|
html += '<button type="button" class="btn btn-stop" onclick="SRFRec.stopRecording(\'' + r.id + '\')">Stop</button>';
|
||||||
|
} else {
|
||||||
|
html += '<button type="button" class="btn btn-cancel" onclick="SRFRec.cancelRecording(\'' + r.id + '\')">Cancel</button>';
|
||||||
|
}
|
||||||
|
html += '</td></tr>';
|
||||||
|
});
|
||||||
|
|
||||||
|
html += '</tbody></table>';
|
||||||
|
container.innerHTML = html;
|
||||||
|
})
|
||||||
|
.catch(function(err) {
|
||||||
|
container.innerHTML = '<p class="srfrec-error">Error: ' + err.message + '</p>';
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
loadCompletedRecordings: function() {
|
||||||
|
var container = document.getElementById('srfrecCompletedRecordingsContainer');
|
||||||
|
container.innerHTML = '<p class="srfrec-msg">Loading...</p>';
|
||||||
|
|
||||||
|
fetch(this.serverUrl + '/Plugins/SRFPlay/Recording/Completed', { headers: this.getHeaders() })
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(recordings) {
|
||||||
|
if (!recordings || recordings.length === 0) {
|
||||||
|
container.innerHTML = '<p class="srfrec-msg">No completed recordings.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var html = '<table>';
|
||||||
|
html += '<thead><tr><th>Title</th><th>Recorded</th><th>Size</th><th>Action</th></tr></thead><tbody>';
|
||||||
|
|
||||||
|
recordings.forEach(function(r) {
|
||||||
|
html += '<tr>';
|
||||||
|
html += '<td>' + (r.title || 'Unknown') + '</td>';
|
||||||
|
html += '<td>' + SRFRec.formatDate(r.recordingStartedAt) + '</td>';
|
||||||
|
html += '<td>' + SRFRec.formatSize(r.fileSizeBytes) + '</td>';
|
||||||
|
html += '<td><button type="button" class="btn btn-delete" onclick="SRFRec.deleteRecording(\'' + r.id + '\')">Delete</button></td>';
|
||||||
|
html += '</tr>';
|
||||||
|
});
|
||||||
|
|
||||||
|
html += '</tbody></table>';
|
||||||
|
container.innerHTML = html;
|
||||||
|
})
|
||||||
|
.catch(function(err) {
|
||||||
|
container.innerHTML = '<p class="srfrec-error">Error: ' + err.message + '</p>';
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
stopRecording: function(id) {
|
||||||
|
var self = this;
|
||||||
|
fetch(this.serverUrl + '/Plugins/SRFPlay/Recording/Active/' + id + '/Stop', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: this.getHeaders()
|
||||||
|
}).then(function() { self.loadRecordings(); });
|
||||||
|
},
|
||||||
|
|
||||||
|
cancelRecording: function(id) {
|
||||||
|
var self = this;
|
||||||
|
fetch(this.serverUrl + '/Plugins/SRFPlay/Recording/Schedule/' + id, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: this.getHeaders()
|
||||||
|
}).then(function() { self.loadRecordings(); });
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteRecording: function(id) {
|
||||||
|
if (!confirm('Delete this recording and its file?')) return;
|
||||||
|
var self = this;
|
||||||
|
fetch(this.serverUrl + '/Plugins/SRFPlay/Recording/Completed/' + id + '?deleteFile=true', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: this.getHeaders()
|
||||||
|
}).then(function() { self.loadRecordings(); });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
var page = document.querySelector('#SRFPlayRecordingsPage');
|
||||||
|
if (page && typeof page.addEventListener === 'function' && typeof ApiClient !== 'undefined') {
|
||||||
|
// Inside the Jellyfin dashboard: defer to the page lifecycle so we
|
||||||
|
// never run during the config page's view and never hijack routing.
|
||||||
|
page.addEventListener('pageshow', function() { SRFRec.init(); });
|
||||||
|
} else {
|
||||||
|
// Standalone (e.g. TV/browser): initialise immediately.
|
||||||
|
SRFRec.init();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</div>
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using System.IO;
|
||||||
|
using System.Reflection;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Jellyfin.Plugin.SRFPlay.Api.Models;
|
using Jellyfin.Plugin.SRFPlay.Api.Models;
|
||||||
@@ -33,6 +35,27 @@ public class RecordingController : ControllerBase
|
|||||||
_recordingService = recordingService;
|
_recordingService = recordingService;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serves the recording manager page accessible to any authenticated user.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>The recording manager HTML page.</returns>
|
||||||
|
[HttpGet("Page")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public IActionResult GetRecordingPage()
|
||||||
|
{
|
||||||
|
var assembly = Assembly.GetExecutingAssembly();
|
||||||
|
var resourceStream = assembly.GetManifestResourceStream("Jellyfin.Plugin.SRFPlay.Configuration.recordingPage.html");
|
||||||
|
|
||||||
|
if (resourceStream == null)
|
||||||
|
{
|
||||||
|
return NotFound("Recording page not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
return File(resourceStream, "text/html");
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets upcoming sport livestreams available for recording.
|
/// Gets upcoming sport livestreams available for recording.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -160,7 +160,8 @@ public class StreamProxyController : ControllerBase
|
|||||||
AddManifestCacheHeaders(actualItemId);
|
AddManifestCacheHeaders(actualItemId);
|
||||||
|
|
||||||
_logger.LogDebug("Returning master manifest for item {ItemId} ({Length} bytes)", itemId, manifestContent.Length);
|
_logger.LogDebug("Returning master manifest for item {ItemId} ({Length} bytes)", itemId, manifestContent.Length);
|
||||||
return Content(manifestContent, "application/vnd.apple.mpegurl; charset=utf-8");
|
// Use application/x-mpegURL for broader compatibility (Samsung AVPlay requires it)
|
||||||
|
return Content(manifestContent, "application/x-mpegURL; charset=utf-8");
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -231,7 +232,7 @@ public class StreamProxyController : ControllerBase
|
|||||||
AddManifestCacheHeaders(actualItemId, isVariantManifest: true);
|
AddManifestCacheHeaders(actualItemId, isVariantManifest: true);
|
||||||
|
|
||||||
_logger.LogDebug("Returning variant manifest for item {ItemId} ({Length} bytes)", itemId, rewrittenContent.Length);
|
_logger.LogDebug("Returning variant manifest for item {ItemId} ({Length} bytes)", itemId, rewrittenContent.Length);
|
||||||
return Content(rewrittenContent, "application/vnd.apple.mpegurl; charset=utf-8");
|
return Content(rewrittenContent, "application/x-mpegURL; charset=utf-8");
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -26,11 +26,18 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<None Remove="Configuration\configPage.html" />
|
<None Remove="Configuration\configPage.html" />
|
||||||
<EmbeddedResource Include="Configuration\configPage.html" />
|
<EmbeddedResource Include="Configuration\configPage.html" />
|
||||||
|
<None Remove="Configuration\recordingPage.html" />
|
||||||
|
<EmbeddedResource Include="Configuration\recordingPage.html" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<None Remove="..\assests\main logo.png" />
|
<None Remove="..\assests\main logo.png" />
|
||||||
<EmbeddedResource Include="..\assests\main logo.png" LogicalName="Jellyfin.Plugin.SRFPlay.Images.logo.png" />
|
<EmbeddedResource Include="..\assests\main logo.png" LogicalName="Jellyfin.Plugin.SRFPlay.Images.logo.png" />
|
||||||
|
<EmbeddedResource Include="..\assests\units\srf-logo.png" LogicalName="Jellyfin.Plugin.SRFPlay.Images.srf-logo.png" />
|
||||||
|
<EmbeddedResource Include="..\assests\units\rts-logo.png" LogicalName="Jellyfin.Plugin.SRFPlay.Images.rts-logo.png" />
|
||||||
|
<EmbeddedResource Include="..\assests\units\rsi-logo.png" LogicalName="Jellyfin.Plugin.SRFPlay.Images.rsi-logo.png" />
|
||||||
|
<EmbeddedResource Include="..\assests\units\rtr-logo.png" LogicalName="Jellyfin.Plugin.SRFPlay.Images.rtr-logo.png" />
|
||||||
|
<EmbeddedResource Include="..\assests\units\swi-logo.png" LogicalName="Jellyfin.Plugin.SRFPlay.Images.swi-logo.png" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -46,6 +46,12 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
|||||||
Name = Name,
|
Name = Name,
|
||||||
EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.Configuration.configPage.html", GetType().Namespace)
|
EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.Configuration.configPage.html", GetType().Namespace)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Note: the recordings manager is intentionally NOT registered as a plugin
|
||||||
|
// page. Plugin pages live under the admin Dashboard and are admin-gated, which
|
||||||
|
// both caused a redirect to the recordings page and made it inaccessible to
|
||||||
|
// normal users. Recordings are served as a standalone, user-accessible page at
|
||||||
|
// GET /Plugins/SRFPlay/Recording/Page (see RecordingController).
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
|||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
||||||
|
using Jellyfin.Plugin.SRFPlay.Utilities;
|
||||||
using MediaBrowser.Model.Tasks;
|
using MediaBrowser.Model.Tasks;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
@@ -63,20 +64,25 @@ public class ContentRefreshTask : IScheduledTask
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh latest content
|
// Refresh content for every enabled business unit (one channel per unit).
|
||||||
|
var units = config.ResolveEnabledUnits();
|
||||||
|
foreach (var unit in units)
|
||||||
|
{
|
||||||
|
var businessUnit = unit.ToLowerString();
|
||||||
|
|
||||||
if (config.EnableLatestContent)
|
if (config.EnableLatestContent)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Refreshing latest content");
|
_logger.LogInformation("Refreshing latest content for {BusinessUnit}", businessUnit);
|
||||||
progress?.Report(25);
|
progress?.Report(25);
|
||||||
await _contentRefreshService.RefreshLatestContentAsync(cancellationToken).ConfigureAwait(false);
|
await _contentRefreshService.RefreshLatestContentAsync(businessUnit, cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh trending content
|
|
||||||
if (config.EnableTrendingContent)
|
if (config.EnableTrendingContent)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Refreshing trending content");
|
_logger.LogInformation("Refreshing trending content for {BusinessUnit}", businessUnit);
|
||||||
progress?.Report(75);
|
progress?.Report(75);
|
||||||
await _contentRefreshService.RefreshTrendingContentAsync(cancellationToken).ConfigureAwait(false);
|
await _contentRefreshService.RefreshTrendingContentAsync(businessUnit, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
progress?.Report(100);
|
progress?.Report(100);
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ public class RecordingSchedulerTask : IScheduledTask
|
|||||||
new TaskTriggerInfo
|
new TaskTriggerInfo
|
||||||
{
|
{
|
||||||
Type = TaskTriggerInfo.TriggerInterval,
|
Type = TaskTriggerInfo.TriggerInterval,
|
||||||
IntervalTicks = TimeSpan.FromMinutes(2).Ticks
|
IntervalTicks = TimeSpan.FromSeconds(30).Ticks
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,13 @@ public class ServiceRegistrator : IPluginServiceRegistrator
|
|||||||
serviceCollection.AddSingleton<IScheduledTask, ExpirationCheckTask>();
|
serviceCollection.AddSingleton<IScheduledTask, ExpirationCheckTask>();
|
||||||
serviceCollection.AddSingleton<IScheduledTask, RecordingSchedulerTask>();
|
serviceCollection.AddSingleton<IScheduledTask, RecordingSchedulerTask>();
|
||||||
|
|
||||||
// Register channel - must register as IChannel interface for Jellyfin to discover it
|
// Register one channel (tile) per SRG business unit. Each must be registered as IChannel
|
||||||
serviceCollection.AddSingleton<IChannel, SRFPlayChannel>();
|
// for Jellyfin to discover it. A unit that is not in EnabledBusinessUnits returns no
|
||||||
|
// content (its tile appears empty) - see SrgChannelBase.
|
||||||
|
serviceCollection.AddSingleton<IChannel, SrfChannel>();
|
||||||
|
serviceCollection.AddSingleton<IChannel, RtsChannel>();
|
||||||
|
serviceCollection.AddSingleton<IChannel, RsiChannel>();
|
||||||
|
serviceCollection.AddSingleton<IChannel, RtrChannel>();
|
||||||
|
serviceCollection.AddSingleton<IChannel, SwiChannel>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,10 @@ public class CategoryService : ICategoryService
|
|||||||
private readonly ILogger<CategoryService> _logger;
|
private readonly ILogger<CategoryService> _logger;
|
||||||
private readonly ISRFApiClientFactory _apiClientFactory;
|
private readonly ISRFApiClientFactory _apiClientFactory;
|
||||||
private readonly TimeSpan _topicsCacheDuration = TimeSpan.FromHours(24);
|
private readonly TimeSpan _topicsCacheDuration = TimeSpan.FromHours(24);
|
||||||
private Dictionary<string, PlayV3Topic>? _topicsCache;
|
|
||||||
private DateTime _topicsCacheExpiry = DateTime.MinValue;
|
// Cache topics per business unit. A single shared cache would return one unit's
|
||||||
|
// topics (e.g. German SRF) even after the user switched to another (e.g. French RTS).
|
||||||
|
private readonly Dictionary<string, (Dictionary<string, PlayV3Topic> Topics, DateTime Expiry)> _topicsCacheByUnit = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="CategoryService"/> class.
|
/// Initializes a new instance of the <see cref="CategoryService"/> class.
|
||||||
@@ -35,11 +37,11 @@ public class CategoryService : ICategoryService
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<List<PlayV3Topic>> GetTopicsAsync(string businessUnit, CancellationToken cancellationToken = default)
|
public async Task<List<PlayV3Topic>> GetTopicsAsync(string businessUnit, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
// Return cached topics if still valid
|
// Return cached topics for this business unit if still valid
|
||||||
if (_topicsCache != null && DateTime.UtcNow < _topicsCacheExpiry)
|
if (_topicsCacheByUnit.TryGetValue(businessUnit, out var cached) && DateTime.UtcNow < cached.Expiry)
|
||||||
{
|
{
|
||||||
_logger.LogDebug("Returning cached topics for business unit: {BusinessUnit}", businessUnit);
|
_logger.LogDebug("Returning cached topics for business unit: {BusinessUnit}", businessUnit);
|
||||||
return _topicsCache.Values.ToList();
|
return cached.Topics.Values.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("Fetching topics for business unit: {BusinessUnit}", businessUnit);
|
_logger.LogInformation("Fetching topics for business unit: {BusinessUnit}", businessUnit);
|
||||||
@@ -48,13 +50,13 @@ public class CategoryService : ICategoryService
|
|||||||
|
|
||||||
if (topics != null && topics.Count > 0)
|
if (topics != null && topics.Count > 0)
|
||||||
{
|
{
|
||||||
// Cache topics by ID for quick lookups
|
// Cache topics by ID for quick lookups, scoped to this business unit
|
||||||
_topicsCache = topics
|
var byId = topics
|
||||||
.Where(t => !string.IsNullOrEmpty(t.Id))
|
.Where(t => !string.IsNullOrEmpty(t.Id))
|
||||||
.ToDictionary(t => t.Id!, t => t);
|
.ToDictionary(t => t.Id!, t => t);
|
||||||
_topicsCacheExpiry = DateTime.UtcNow.Add(_topicsCacheDuration);
|
_topicsCacheByUnit[businessUnit] = (byId, DateTime.UtcNow.Add(_topicsCacheDuration));
|
||||||
|
|
||||||
_logger.LogInformation("Cached {Count} topics for business unit: {BusinessUnit}", _topicsCache.Count, businessUnit);
|
_logger.LogInformation("Cached {Count} topics for business unit: {BusinessUnit}", byId.Count, businessUnit);
|
||||||
}
|
}
|
||||||
|
|
||||||
return topics ?? new List<PlayV3Topic>();
|
return topics ?? new List<PlayV3Topic>();
|
||||||
@@ -63,13 +65,14 @@ public class CategoryService : ICategoryService
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<PlayV3Topic?> GetTopicByIdAsync(string topicId, string businessUnit, CancellationToken cancellationToken = default)
|
public async Task<PlayV3Topic?> GetTopicByIdAsync(string topicId, string businessUnit, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
// Ensure topics are loaded
|
// Ensure topics are loaded for this business unit
|
||||||
if (_topicsCache == null || DateTime.UtcNow >= _topicsCacheExpiry)
|
if (!_topicsCacheByUnit.TryGetValue(businessUnit, out var cached) || DateTime.UtcNow >= cached.Expiry)
|
||||||
{
|
{
|
||||||
await GetTopicsAsync(businessUnit, cancellationToken).ConfigureAwait(false);
|
await GetTopicsAsync(businessUnit, cancellationToken).ConfigureAwait(false);
|
||||||
|
_topicsCacheByUnit.TryGetValue(businessUnit, out cached);
|
||||||
}
|
}
|
||||||
|
|
||||||
return _topicsCache?.GetValueOrDefault(topicId);
|
return cached.Topics?.GetValueOrDefault(topicId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -101,8 +104,7 @@ public class CategoryService : ICategoryService
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void ClearCache()
|
public void ClearCache()
|
||||||
{
|
{
|
||||||
_topicsCache = null;
|
_topicsCacheByUnit.Clear();
|
||||||
_topicsCacheExpiry = DateTime.MinValue;
|
|
||||||
_logger.LogInformation("Topics cache cleared");
|
_logger.LogInformation("Topics cache cleared");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,9 +34,10 @@ public class ContentRefreshService : IContentRefreshService
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Refreshes latest content from SRF API using Play v3.
|
/// Refreshes latest content from SRF API using Play v3.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="businessUnit">The business unit to fetch content for (e.g. "srf", "rts").</param>
|
||||||
/// <param name="cancellationToken">The cancellation token.</param>
|
/// <param name="cancellationToken">The cancellation token.</param>
|
||||||
/// <returns>List of URNs for new content.</returns>
|
/// <returns>List of URNs for new content.</returns>
|
||||||
public async Task<List<string>> RefreshLatestContentAsync(CancellationToken cancellationToken)
|
public async Task<List<string>> RefreshLatestContentAsync(string businessUnit, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var config = Plugin.Instance?.Configuration;
|
var config = Plugin.Instance?.Configuration;
|
||||||
if (config == null || !config.EnableLatestContent)
|
if (config == null || !config.EnableLatestContent)
|
||||||
@@ -46,7 +47,7 @@ public class ContentRefreshService : IContentRefreshService
|
|||||||
}
|
}
|
||||||
|
|
||||||
return await FetchVideosFromShowsAsync(
|
return await FetchVideosFromShowsAsync(
|
||||||
config.BusinessUnit.ToLowerString(),
|
businessUnit,
|
||||||
minEpisodeCount: 0,
|
minEpisodeCount: 0,
|
||||||
maxShows: 20,
|
maxShows: 20,
|
||||||
videosPerShow: 1,
|
videosPerShow: 1,
|
||||||
@@ -58,9 +59,10 @@ public class ContentRefreshService : IContentRefreshService
|
|||||||
/// Refreshes trending content from SRF API using Play v3.
|
/// Refreshes trending content from SRF API using Play v3.
|
||||||
/// Gets videos from shows with the most episodes.
|
/// Gets videos from shows with the most episodes.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="businessUnit">The business unit to fetch content for (e.g. "srf", "rts").</param>
|
||||||
/// <param name="cancellationToken">The cancellation token.</param>
|
/// <param name="cancellationToken">The cancellation token.</param>
|
||||||
/// <returns>List of URNs for trending content.</returns>
|
/// <returns>List of URNs for trending content.</returns>
|
||||||
public async Task<List<string>> RefreshTrendingContentAsync(CancellationToken cancellationToken)
|
public async Task<List<string>> RefreshTrendingContentAsync(string businessUnit, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var config = Plugin.Instance?.Configuration;
|
var config = Plugin.Instance?.Configuration;
|
||||||
if (config == null || !config.EnableTrendingContent)
|
if (config == null || !config.EnableTrendingContent)
|
||||||
@@ -70,7 +72,7 @@ public class ContentRefreshService : IContentRefreshService
|
|||||||
}
|
}
|
||||||
|
|
||||||
return await FetchVideosFromShowsAsync(
|
return await FetchVideosFromShowsAsync(
|
||||||
config.BusinessUnit.ToLowerString(),
|
businessUnit,
|
||||||
minEpisodeCount: 10,
|
minEpisodeCount: 10,
|
||||||
maxShows: 15,
|
maxShows: 15,
|
||||||
videosPerShow: 2,
|
videosPerShow: 2,
|
||||||
|
|||||||
@@ -12,14 +12,16 @@ public interface IContentRefreshService
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Refreshes latest content from SRF API using Play v3.
|
/// Refreshes latest content from SRF API using Play v3.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="businessUnit">The business unit to fetch content for (e.g. "srf", "rts").</param>
|
||||||
/// <param name="cancellationToken">The cancellation token.</param>
|
/// <param name="cancellationToken">The cancellation token.</param>
|
||||||
/// <returns>List of URNs for new content.</returns>
|
/// <returns>List of URNs for new content.</returns>
|
||||||
Task<List<string>> RefreshLatestContentAsync(CancellationToken cancellationToken);
|
Task<List<string>> RefreshLatestContentAsync(string businessUnit, CancellationToken cancellationToken);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Refreshes trending content from SRF API using Play v3.
|
/// Refreshes trending content from SRF API using Play v3.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="businessUnit">The business unit to fetch content for (e.g. "srf", "rts").</param>
|
||||||
/// <param name="cancellationToken">The cancellation token.</param>
|
/// <param name="cancellationToken">The cancellation token.</param>
|
||||||
/// <returns>List of URNs for trending content.</returns>
|
/// <returns>List of URNs for trending content.</returns>
|
||||||
Task<List<string>> RefreshTrendingContentAsync(CancellationToken cancellationToken);
|
Task<List<string>> RefreshTrendingContentAsync(string businessUnit, CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,9 +62,15 @@ public class MediaSourceFactory : IMediaSourceFactory
|
|||||||
return Task.FromResult<MediaSourceInfo?>(null);
|
return Task.FromResult<MediaSourceInfo?>(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect if this is a live stream
|
// A scheduled livestream is only treated as live when the broadcast window is open.
|
||||||
var isLiveStream = chapter.Type == "SCHEDULED_LIVESTREAM" ||
|
// Past replays (ValidTo in the past) and upcoming events (ValidFrom in the future)
|
||||||
|
// are treated as VOD to avoid IsInfiniteStream/IgnoreDts flags and FFmpeg -re mode.
|
||||||
|
var isScheduledLivestream = chapter.Type == "SCHEDULED_LIVESTREAM" ||
|
||||||
urn.Contains("livestream", StringComparison.OrdinalIgnoreCase);
|
urn.Contains("livestream", StringComparison.OrdinalIgnoreCase);
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var isLiveStream = isScheduledLivestream &&
|
||||||
|
(chapter.ValidFrom == null || chapter.ValidFrom.Value.ToUniversalTime() <= now) &&
|
||||||
|
(chapter.ValidTo == null || chapter.ValidTo.Value.ToUniversalTime() > now);
|
||||||
|
|
||||||
// Register stream with UNAUTHENTICATED URL - proxy will authenticate on-demand
|
// Register stream with UNAUTHENTICATED URL - proxy will authenticate on-demand
|
||||||
// This avoids wasting 30-second tokens during category browsing
|
// This avoids wasting 30-second tokens during category browsing
|
||||||
@@ -91,15 +97,15 @@ public class MediaSourceFactory : IMediaSourceFactory
|
|||||||
Protocol = MediaProtocol.Http,
|
Protocol = MediaProtocol.Http,
|
||||||
// Use "hls" to trigger hls.js player in web client
|
// Use "hls" to trigger hls.js player in web client
|
||||||
Container = "hls",
|
Container = "hls",
|
||||||
SupportsDirectStream = true,
|
SupportsDirectStream = false,
|
||||||
SupportsDirectPlay = true,
|
SupportsDirectPlay = true,
|
||||||
SupportsTranscoding = false,
|
SupportsTranscoding = true,
|
||||||
IsRemote = true,
|
IsRemote = true,
|
||||||
Type = MediaSourceType.Default,
|
Type = MediaSourceType.Default,
|
||||||
RunTimeTicks = chapter.Duration > 0 ? TimeSpan.FromMilliseconds(chapter.Duration).Ticks : null,
|
RunTimeTicks = chapter.Duration > 0 ? TimeSpan.FromMilliseconds(chapter.Duration).Ticks : null,
|
||||||
VideoType = VideoType.VideoFile,
|
VideoType = VideoType.VideoFile,
|
||||||
IsInfiniteStream = isLiveStream,
|
IsInfiniteStream = isLiveStream,
|
||||||
// Don't use RequiresOpening - it forces Jellyfin to transcode which breaks token auth
|
// RequiresOpening = false: proxy handles auth directly, no need for Jellyfin to open the stream
|
||||||
RequiresOpening = false,
|
RequiresOpening = false,
|
||||||
RequiresClosing = false,
|
RequiresClosing = false,
|
||||||
// Disable probing - we provide stream info directly
|
// Disable probing - we provide stream info directly
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ using Jellyfin.Plugin.SRFPlay.Api.Models;
|
|||||||
using Jellyfin.Plugin.SRFPlay.Api.Models.PlayV3;
|
using Jellyfin.Plugin.SRFPlay.Api.Models.PlayV3;
|
||||||
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
||||||
using MediaBrowser.Controller;
|
using MediaBrowser.Controller;
|
||||||
|
using MediaBrowser.Controller.MediaEncoding;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace Jellyfin.Plugin.SRFPlay.Services;
|
namespace Jellyfin.Plugin.SRFPlay.Services;
|
||||||
@@ -29,9 +30,11 @@ public class RecordingService : IRecordingService, IDisposable
|
|||||||
private readonly IStreamUrlResolver _streamUrlResolver;
|
private readonly IStreamUrlResolver _streamUrlResolver;
|
||||||
private readonly IMediaCompositionFetcher _mediaCompositionFetcher;
|
private readonly IMediaCompositionFetcher _mediaCompositionFetcher;
|
||||||
private readonly IServerApplicationHost _appHost;
|
private readonly IServerApplicationHost _appHost;
|
||||||
|
private readonly IMediaEncoder _mediaEncoder;
|
||||||
private readonly ConcurrentDictionary<string, Process> _activeProcesses = new();
|
private readonly ConcurrentDictionary<string, Process> _activeProcesses = new();
|
||||||
private static readonly JsonSerializerOptions _jsonOptions = new() { WriteIndented = true };
|
private static readonly JsonSerializerOptions _jsonOptions = new() { WriteIndented = true };
|
||||||
private readonly SemaphoreSlim _persistLock = new(1, 1);
|
private readonly SemaphoreSlim _persistLock = new(1, 1);
|
||||||
|
private readonly SemaphoreSlim _processLock = new(1, 1);
|
||||||
private List<RecordingEntry> _recordings = new();
|
private List<RecordingEntry> _recordings = new();
|
||||||
private bool _loaded;
|
private bool _loaded;
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
@@ -45,13 +48,15 @@ public class RecordingService : IRecordingService, IDisposable
|
|||||||
/// <param name="streamUrlResolver">The stream URL resolver.</param>
|
/// <param name="streamUrlResolver">The stream URL resolver.</param>
|
||||||
/// <param name="mediaCompositionFetcher">The media composition fetcher.</param>
|
/// <param name="mediaCompositionFetcher">The media composition fetcher.</param>
|
||||||
/// <param name="appHost">The application host.</param>
|
/// <param name="appHost">The application host.</param>
|
||||||
|
/// <param name="mediaEncoder">The media encoder for ffmpeg path.</param>
|
||||||
public RecordingService(
|
public RecordingService(
|
||||||
ILogger<RecordingService> logger,
|
ILogger<RecordingService> logger,
|
||||||
ISRFApiClientFactory apiClientFactory,
|
ISRFApiClientFactory apiClientFactory,
|
||||||
IStreamProxyService proxyService,
|
IStreamProxyService proxyService,
|
||||||
IStreamUrlResolver streamUrlResolver,
|
IStreamUrlResolver streamUrlResolver,
|
||||||
IMediaCompositionFetcher mediaCompositionFetcher,
|
IMediaCompositionFetcher mediaCompositionFetcher,
|
||||||
IServerApplicationHost appHost)
|
IServerApplicationHost appHost,
|
||||||
|
IMediaEncoder mediaEncoder)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_apiClientFactory = apiClientFactory;
|
_apiClientFactory = apiClientFactory;
|
||||||
@@ -59,6 +64,7 @@ public class RecordingService : IRecordingService, IDisposable
|
|||||||
_streamUrlResolver = streamUrlResolver;
|
_streamUrlResolver = streamUrlResolver;
|
||||||
_mediaCompositionFetcher = mediaCompositionFetcher;
|
_mediaCompositionFetcher = mediaCompositionFetcher;
|
||||||
_appHost = appHost;
|
_appHost = appHost;
|
||||||
|
_mediaEncoder = mediaEncoder;
|
||||||
}
|
}
|
||||||
|
|
||||||
private string GetDataFilePath()
|
private string GetDataFilePath()
|
||||||
@@ -142,19 +148,26 @@ public class RecordingService : IRecordingService, IDisposable
|
|||||||
public async Task<IReadOnlyList<PlayV3TvProgram>> GetUpcomingScheduleAsync(CancellationToken cancellationToken)
|
public async Task<IReadOnlyList<PlayV3TvProgram>> GetUpcomingScheduleAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var config = Plugin.Instance?.Configuration;
|
var config = Plugin.Instance?.Configuration;
|
||||||
var businessUnit = (config?.BusinessUnit ?? Configuration.BusinessUnit.SRF).ToString().ToLowerInvariant();
|
var units = config?.ResolveEnabledUnits() ?? new[] { Configuration.BusinessUnit.SRF };
|
||||||
|
|
||||||
using var apiClient = _apiClientFactory.CreateClient();
|
using var apiClient = _apiClientFactory.CreateClient();
|
||||||
var livestreams = await apiClient.GetScheduledLivestreamsAsync(businessUnit, "SPORT", cancellationToken).ConfigureAwait(false);
|
|
||||||
|
|
||||||
if (livestreams == null)
|
// Aggregate sport livestreams across every enabled business unit so the recordings
|
||||||
|
// page shows events from all enabled languages.
|
||||||
|
var all = new List<PlayV3TvProgram>();
|
||||||
|
foreach (var unit in units)
|
||||||
{
|
{
|
||||||
return Array.Empty<PlayV3TvProgram>();
|
var businessUnit = unit.ToString().ToLowerInvariant();
|
||||||
|
var livestreams = await apiClient.GetScheduledLivestreamsAsync(businessUnit, "SPORT", cancellationToken).ConfigureAwait(false);
|
||||||
|
if (livestreams != null)
|
||||||
|
{
|
||||||
|
all.AddRange(livestreams);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter to only future/current livestreams that aren't blocked
|
// Filter to only future/current livestreams that aren't blocked
|
||||||
return livestreams
|
return all
|
||||||
.Where(ls => ls.Blocked != true && (ls.ValidTo == null || ls.ValidTo > DateTime.UtcNow))
|
.Where(ls => ls.Blocked != true && (ls.ValidTo == null || ls.ValidTo.Value.ToUniversalTime() > DateTime.UtcNow))
|
||||||
.OrderBy(ls => ls.ValidFrom)
|
.OrderBy(ls => ls.ValidFrom)
|
||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
@@ -172,9 +185,11 @@ public class RecordingService : IRecordingService, IDisposable
|
|||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch metadata for the URN
|
// Fetch metadata for the URN. The unit is encoded in the URN itself, so use that to
|
||||||
|
// query the right schedule regardless of which units are enabled.
|
||||||
var config = Plugin.Instance?.Configuration;
|
var config = Plugin.Instance?.Configuration;
|
||||||
var businessUnit = (config?.BusinessUnit ?? Configuration.BusinessUnit.SRF).ToString().ToLowerInvariant();
|
var businessUnit = ParseBusinessUnitFromUrn(urn)
|
||||||
|
?? (config?.BusinessUnit ?? Configuration.BusinessUnit.SRF).ToString().ToLowerInvariant();
|
||||||
|
|
||||||
using var apiClient = _apiClientFactory.CreateClient();
|
using var apiClient = _apiClientFactory.CreateClient();
|
||||||
var livestreams = await apiClient.GetScheduledLivestreamsAsync(businessUnit, "SPORT", cancellationToken).ConfigureAwait(false);
|
var livestreams = await apiClient.GetScheduledLivestreamsAsync(businessUnit, "SPORT", cancellationToken).ConfigureAwait(false);
|
||||||
@@ -184,6 +199,7 @@ public class RecordingService : IRecordingService, IDisposable
|
|||||||
{
|
{
|
||||||
Id = Guid.NewGuid().ToString("N"),
|
Id = Guid.NewGuid().ToString("N"),
|
||||||
Urn = urn,
|
Urn = urn,
|
||||||
|
BusinessUnit = ParseBusinessUnitFromUrn(urn) ?? businessUnit,
|
||||||
Title = program?.Title ?? urn,
|
Title = program?.Title ?? urn,
|
||||||
Description = program?.Lead ?? program?.Description,
|
Description = program?.Lead ?? program?.Description,
|
||||||
ImageUrl = program?.ImageUrl,
|
ImageUrl = program?.ImageUrl,
|
||||||
@@ -200,6 +216,24 @@ public class RecordingService : IRecordingService, IDisposable
|
|||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extracts the business unit from an SRF URN of the form "urn:<bu>:<type>:...".
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="urn">The URN.</param>
|
||||||
|
/// <returns>The lowercase business unit, or null if it cannot be determined.</returns>
|
||||||
|
private static string? ParseBusinessUnitFromUrn(string urn)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(urn))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var parts = urn.Split(':');
|
||||||
|
return parts.Length >= 2 && !string.IsNullOrEmpty(parts[1])
|
||||||
|
? parts[1].ToLowerInvariant()
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public bool CancelRecording(string recordingId)
|
public bool CancelRecording(string recordingId)
|
||||||
{
|
{
|
||||||
@@ -300,6 +334,25 @@ public class RecordingService : IRecordingService, IDisposable
|
|||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task ProcessRecordingsAsync(CancellationToken cancellationToken)
|
public async Task ProcessRecordingsAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// Prevent overlapping scheduler runs from spawning duplicate ffmpeg processes
|
||||||
|
if (!await _processLock.WaitAsync(0, CancellationToken.None).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
_logger.LogDebug("ProcessRecordingsAsync already running, skipping");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await ProcessRecordingsCoreAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_processLock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ProcessRecordingsCoreAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await LoadRecordingsAsync().ConfigureAwait(false);
|
await LoadRecordingsAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
@@ -308,13 +361,23 @@ public class RecordingService : IRecordingService, IDisposable
|
|||||||
|
|
||||||
foreach (var entry in _recordings.ToList())
|
foreach (var entry in _recordings.ToList())
|
||||||
{
|
{
|
||||||
|
// Normalize ValidFrom/ValidTo to UTC for correct comparison
|
||||||
|
var validFromUtc = entry.ValidFrom.HasValue ? entry.ValidFrom.Value.ToUniversalTime() : (DateTime?)null;
|
||||||
|
var validToUtc = entry.ValidTo.HasValue ? entry.ValidTo.Value.ToUniversalTime() : (DateTime?)null;
|
||||||
|
|
||||||
switch (entry.State)
|
switch (entry.State)
|
||||||
{
|
{
|
||||||
case RecordingState.Scheduled:
|
case RecordingState.Scheduled:
|
||||||
case RecordingState.WaitingForStream:
|
case RecordingState.WaitingForStream:
|
||||||
// Check if it's time to start recording
|
// Check if it's time to start recording
|
||||||
if (entry.ValidFrom.HasValue && entry.ValidFrom.Value <= now.AddMinutes(2))
|
if (validFromUtc.HasValue && validFromUtc.Value <= now.AddMinutes(2))
|
||||||
{
|
{
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Time to start recording '{Title}': ValidFrom={ValidFrom} (UTC: {ValidFromUtc}), Now={Now}",
|
||||||
|
entry.Title,
|
||||||
|
entry.ValidFrom,
|
||||||
|
validFromUtc,
|
||||||
|
now);
|
||||||
changed |= await TryStartRecordingAsync(entry, cancellationToken).ConfigureAwait(false);
|
changed |= await TryStartRecordingAsync(entry, cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,7 +385,7 @@ public class RecordingService : IRecordingService, IDisposable
|
|||||||
|
|
||||||
case RecordingState.Recording:
|
case RecordingState.Recording:
|
||||||
// Check if recording should stop (ValidTo reached or process died)
|
// Check if recording should stop (ValidTo reached or process died)
|
||||||
if (entry.ValidTo.HasValue && entry.ValidTo.Value <= now)
|
if (validToUtc.HasValue && validToUtc.Value <= now)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Recording '{Title}' reached ValidTo, stopping", entry.Title);
|
_logger.LogInformation("Recording '{Title}' reached ValidTo, stopping", entry.Title);
|
||||||
StopFfmpeg(entry.Id);
|
StopFfmpeg(entry.Id);
|
||||||
@@ -416,7 +479,7 @@ public class RecordingService : IRecordingService, IDisposable
|
|||||||
{
|
{
|
||||||
StartInfo = new ProcessStartInfo
|
StartInfo = new ProcessStartInfo
|
||||||
{
|
{
|
||||||
FileName = "ffmpeg",
|
FileName = _mediaEncoder.EncoderPath,
|
||||||
Arguments = $"-y -i \"{inputUrl}\" -c copy -movflags +faststart \"{outputPath}\"",
|
Arguments = $"-y -i \"{inputUrl}\" -c copy -movflags +faststart \"{outputPath}\"",
|
||||||
UseShellExecute = false,
|
UseShellExecute = false,
|
||||||
RedirectStandardInput = true,
|
RedirectStandardInput = true,
|
||||||
@@ -510,6 +573,7 @@ public class RecordingService : IRecordingService, IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
_persistLock.Dispose();
|
_persistLock.Dispose();
|
||||||
|
_processLock.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
_disposed = true;
|
_disposed = true;
|
||||||
|
|||||||
@@ -656,21 +656,6 @@ public class StreamProxyService : IStreamProxyService
|
|||||||
// Rewrite the manifest to replace Akamai URLs with proxy URLs
|
// Rewrite the manifest to replace Akamai URLs with proxy URLs
|
||||||
var rewrittenContent = RewriteManifestUrls(manifestContent, authenticatedUrl, baseProxyUrl);
|
var rewrittenContent = RewriteManifestUrls(manifestContent, authenticatedUrl, baseProxyUrl);
|
||||||
|
|
||||||
// For live streams, inject #EXT-X-START to tell the player to start near the live edge
|
|
||||||
// Without this, players may start at the beginning of the sliding window and stutter
|
|
||||||
// as old segments get rotated out by the CDN
|
|
||||||
if (_streamMappings.TryGetValue(itemId, out var streamInfoForManifest) && streamInfoForManifest.IsLiveStream)
|
|
||||||
{
|
|
||||||
if (!rewrittenContent.Contains("#EXT-X-START", StringComparison.Ordinal))
|
|
||||||
{
|
|
||||||
rewrittenContent = rewrittenContent.Replace(
|
|
||||||
"#EXTM3U",
|
|
||||||
"#EXTM3U\n#EXT-X-START:TIME-OFFSET=-6,PRECISE=NO",
|
|
||||||
StringComparison.Ordinal);
|
|
||||||
_logger.LogDebug("Injected #EXT-X-START tag for live stream {ItemId}", itemId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogDebug("Rewritten manifest for item {ItemId} ({Length} bytes):\n{Content}", itemId, rewrittenContent.Length, rewrittenContent);
|
_logger.LogDebug("Rewritten manifest for item {ItemId} ({Length} bytes):\n{Content}", itemId, rewrittenContent.Length, rewrittenContent);
|
||||||
return rewrittenContent;
|
return rewrittenContent;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<configuration>
|
||||||
|
<packageSources>
|
||||||
|
<clear />
|
||||||
|
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
|
||||||
|
</packageSources>
|
||||||
|
</configuration>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
@@ -8,6 +8,94 @@
|
|||||||
"category": "Live TV",
|
"category": "Live TV",
|
||||||
"imageUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/raw/branch/master/assests/main%20logo.png",
|
"imageUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/raw/branch/master/assests/main%20logo.png",
|
||||||
"versions": [
|
"versions": [
|
||||||
|
{
|
||||||
|
"version": "1.0.32",
|
||||||
|
"changelog": "Release 1.0.32",
|
||||||
|
"targetAbi": "10.9.0.0",
|
||||||
|
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.0.32/srfplay_1.0.32.0.zip",
|
||||||
|
"checksum": "82f53848f2e2b15a35ea731ee69ee402",
|
||||||
|
"timestamp": "2026-06-27T07:16:38Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.0.0.0",
|
||||||
|
"changelog": "Latest Build",
|
||||||
|
"targetAbi": "10.9.0.0",
|
||||||
|
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/latest/srfplay_1.0.0.0.zip",
|
||||||
|
"checksum": "a3848128ff37d68f97493c33538a4d25",
|
||||||
|
"timestamp": "2026-06-27T07:15:26Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "1.0.31",
|
||||||
|
"changelog": "Release 1.0.31",
|
||||||
|
"targetAbi": "10.9.0.0",
|
||||||
|
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.0.31/srfplay_1.0.31.0.zip",
|
||||||
|
"checksum": "4e499de15687e6e328ddca41c6192485",
|
||||||
|
"timestamp": "2026-06-27T06:54:01Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.0.0.0",
|
||||||
|
"changelog": "Latest Build",
|
||||||
|
"targetAbi": "10.9.0.0",
|
||||||
|
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/latest/srfplay_1.0.0.0.zip",
|
||||||
|
"checksum": "1a8b8b018ff6bcdb8da4783b74253fc4",
|
||||||
|
"timestamp": "2026-06-27T06:52:52Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "1.0.30",
|
||||||
|
"changelog": "Release 1.0.30",
|
||||||
|
"targetAbi": "10.9.0.0",
|
||||||
|
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.0.30/srfplay_1.0.30.0.zip",
|
||||||
|
"checksum": "06731df9ba3d2dab53885c9c8ac95fa6",
|
||||||
|
"timestamp": "2026-05-03T16:44:57Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.0.0.0",
|
||||||
|
"changelog": "Latest Build",
|
||||||
|
"targetAbi": "10.9.0.0",
|
||||||
|
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/latest/srfplay_1.0.0.0.zip",
|
||||||
|
"checksum": "834e9f57206eee47ec9607ef6f65d17b",
|
||||||
|
"timestamp": "2026-05-03T16:39:53Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "1.0.29",
|
||||||
|
"changelog": "Release 1.0.29",
|
||||||
|
"targetAbi": "10.9.0.0",
|
||||||
|
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.0.29/srfplay_1.0.29.0.zip",
|
||||||
|
"checksum": "fb745388e64299497262d9ad370d8823",
|
||||||
|
"timestamp": "2026-05-03T16:07:24Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.0.0.0",
|
||||||
|
"changelog": "Latest Build",
|
||||||
|
"targetAbi": "10.9.0.0",
|
||||||
|
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/latest/srfplay_1.0.0.0.zip",
|
||||||
|
"checksum": "7471a63c69deb5b9a31343bb6c49075f",
|
||||||
|
"timestamp": "2026-05-03T16:05:37Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "1.0.27",
|
||||||
|
"changelog": "Release 1.0.27",
|
||||||
|
"targetAbi": "10.9.0.0",
|
||||||
|
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.0.27/srfplay_1.0.27.0.zip",
|
||||||
|
"checksum": "1e15e35452f7b82bf74d8c3560c15949",
|
||||||
|
"timestamp": "2026-03-07T16:40:17Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.0.0.0",
|
||||||
|
"changelog": "Latest Build",
|
||||||
|
"targetAbi": "10.9.0.0",
|
||||||
|
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/latest/srfplay_1.0.0.0.zip",
|
||||||
|
"checksum": "c7e868d23293adcc21d72e735094d9d6",
|
||||||
|
"timestamp": "2026-03-07T16:28:38Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "1.0.25",
|
||||||
|
"changelog": "Release 1.0.25",
|
||||||
|
"targetAbi": "10.9.0.0",
|
||||||
|
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.0.25/srfplay_1.0.25.0.zip",
|
||||||
|
"checksum": "5e4599cfeee7e0845a1be30ec288cc0b",
|
||||||
|
"timestamp": "2026-03-07T15:11:52Z"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "1.0.24",
|
"version": "1.0.24",
|
||||||
"changelog": "Release 1.0.24",
|
"changelog": "Release 1.0.24",
|
||||||
|
|||||||
Reference in New Issue
Block a user