Compare commits
No commits in common. "master" and "v1.0.31" have entirely different histories.
@ -36,29 +36,6 @@ jobs:
|
|||||||
working-directory: build-${{ github.run_id }}
|
working-directory: build-${{ github.run_id }}
|
||||||
run: dotnet restore Jellyfin.Plugin.SRFPlay.sln
|
run: dotnet restore Jellyfin.Plugin.SRFPlay.sln
|
||||||
|
|
||||||
- name: Compute build version
|
|
||||||
id: version
|
|
||||||
run: |
|
|
||||||
# For PRs, stamp a distinct version so a side-loaded build is
|
|
||||||
# identifiable in Jellyfin: 1.0.<YYYYMMDD>-pr<N>.<run_number>.
|
|
||||||
# For plain master pushes, keep a date-based dev version.
|
|
||||||
DATE=$(date -u +"%Y%m%d")
|
|
||||||
if [ -n "${{ github.event.pull_request.number }}" ]; then
|
|
||||||
VERSION="1.0.${DATE}.${{ github.run_number }}"
|
|
||||||
LABEL="pr${{ github.event.pull_request.number }}"
|
|
||||||
else
|
|
||||||
VERSION="1.0.${DATE}.${{ github.run_number }}"
|
|
||||||
LABEL="master"
|
|
||||||
fi
|
|
||||||
echo "version=${VERSION}" >> $GITHUB_OUTPUT
|
|
||||||
echo "label=${LABEL}" >> $GITHUB_OUTPUT
|
|
||||||
echo "Build version: ${VERSION} (${LABEL})"
|
|
||||||
|
|
||||||
- name: Set build version
|
|
||||||
working-directory: build-${{ github.run_id }}
|
|
||||||
run: |
|
|
||||||
sed -i "s/^version:.*/version: \"${{ steps.version.outputs.version }}\"/" build.yaml
|
|
||||||
|
|
||||||
- name: Build solution
|
- name: Build solution
|
||||||
working-directory: build-${{ github.run_id }}
|
working-directory: build-${{ github.run_id }}
|
||||||
run: dotnet build Jellyfin.Plugin.SRFPlay.sln --configuration Release --no-restore --no-self-contained /m:1
|
run: dotnet build Jellyfin.Plugin.SRFPlay.sln --configuration Release --no-restore --no-self-contained /m:1
|
||||||
@ -82,7 +59,7 @@ jobs:
|
|||||||
- name: Upload build artifact
|
- name: Upload build artifact
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: srfplay-${{ steps.version.outputs.label }}-${{ steps.version.outputs.version }}
|
name: jellyfin-srfplay-plugin
|
||||||
path: build-${{ github.run_id }}/${{ steps.jprm.outputs.artifact }}
|
path: build-${{ github.run_id }}/${{ steps.jprm.outputs.artifact }}
|
||||||
retention-days: 30
|
retention-days: 30
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
name: 'Nightly Build'
|
name: 'Latest Release'
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@ -7,10 +7,9 @@ on:
|
|||||||
paths-ignore:
|
paths-ignore:
|
||||||
- '**/*.md'
|
- '**/*.md'
|
||||||
- 'manifest.json'
|
- 'manifest.json'
|
||||||
- 'manifest-nightly.json'
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
nightly-build:
|
latest-release:
|
||||||
runs-on: linux/amd64
|
runs-on: linux/amd64
|
||||||
container:
|
container:
|
||||||
image: gitea.tourolle.paris/dtourolle/srfplay-builder:latest
|
image: gitea.tourolle.paris/dtourolle/srfplay-builder:latest
|
||||||
@ -21,24 +20,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
path: build-${{ github.run_id }}
|
path: build-${{ github.run_id }}
|
||||||
|
|
||||||
- name: Compute nightly version
|
|
||||||
id: version
|
|
||||||
run: |
|
|
||||||
# Date-based nightly version: 1.0.<YYYYMMDD>.<run_number>
|
|
||||||
# The run_number suffix keeps multiple builds on the same day
|
|
||||||
# monotonically increasing so Jellyfin always offers the newest.
|
|
||||||
DATE=$(date -u +"%Y%m%d")
|
|
||||||
VERSION="1.0.${DATE}.${{ github.run_number }}"
|
|
||||||
echo "version=${VERSION}" >> $GITHUB_OUTPUT
|
|
||||||
echo "Nightly version: ${VERSION}"
|
|
||||||
|
|
||||||
- name: Set build version
|
|
||||||
working-directory: build-${{ github.run_id }}
|
|
||||||
run: |
|
|
||||||
VERSION="${{ steps.version.outputs.version }}"
|
|
||||||
sed -i "s/^version:.*/version: \"${VERSION}\"/" build.yaml
|
|
||||||
cat build.yaml
|
|
||||||
|
|
||||||
- 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
|
||||||
@ -71,7 +52,7 @@ jobs:
|
|||||||
echo "checksum=${CHECKSUM}" >> $GITHUB_OUTPUT
|
echo "checksum=${CHECKSUM}" >> $GITHUB_OUTPUT
|
||||||
echo "Checksum: ${CHECKSUM}"
|
echo "Checksum: ${CHECKSUM}"
|
||||||
|
|
||||||
- name: Delete existing nightly release
|
- name: Delete existing latest release
|
||||||
working-directory: build-${{ github.run_id }}
|
working-directory: build-${{ github.run_id }}
|
||||||
env:
|
env:
|
||||||
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@ -90,7 +71,7 @@ jobs:
|
|||||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/tags/${TAG}" | jq -r '.id')
|
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/tags/${TAG}" | jq -r '.id')
|
||||||
|
|
||||||
echo "Deleting existing nightly release (ID: ${RELEASE_ID})..."
|
echo "Deleting existing latest release (ID: ${RELEASE_ID})..."
|
||||||
curl -s -X DELETE \
|
curl -s -X DELETE \
|
||||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}"
|
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}"
|
||||||
@ -100,7 +81,7 @@ jobs:
|
|||||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/tags/${TAG}" || true
|
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/tags/${TAG}" || true
|
||||||
|
|
||||||
- name: Create nightly release
|
- name: Create latest release
|
||||||
working-directory: build-${{ github.run_id }}
|
working-directory: build-${{ github.run_id }}
|
||||||
env:
|
env:
|
||||||
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@ -108,14 +89,12 @@ jobs:
|
|||||||
REPO_OWNER="${{ github.repository_owner }}"
|
REPO_OWNER="${{ github.repository_owner }}"
|
||||||
REPO_NAME="${{ github.event.repository.name }}"
|
REPO_NAME="${{ github.event.repository.name }}"
|
||||||
GITEA_URL="${{ github.server_url }}"
|
GITEA_URL="${{ github.server_url }}"
|
||||||
VERSION="${{ steps.version.outputs.version }}"
|
|
||||||
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
|
|
||||||
|
|
||||||
RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \
|
RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \
|
||||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases" \
|
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases" \
|
||||||
-d "$(jq -n --arg tag "latest" --arg name "Nightly Build ${VERSION}" --arg body "SRFPlay Jellyfin Plugin nightly build ${VERSION} from master (${SHORT_SHA})." '{tag_name: $tag, name: $name, body: $body, target_commitish: "master", draft: false, prerelease: true}')")
|
-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)
|
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
|
||||||
BODY=$(echo "$RESPONSE" | sed '$d')
|
BODY=$(echo "$RESPONSE" | sed '$d')
|
||||||
@ -145,9 +124,9 @@ jobs:
|
|||||||
--data-binary "@build.yaml" \
|
--data-binary "@build.yaml" \
|
||||||
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}/assets?name=build.yaml"
|
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}/assets?name=build.yaml"
|
||||||
|
|
||||||
echo "Nightly release updated successfully!"
|
echo "Latest release updated successfully!"
|
||||||
|
|
||||||
- name: Update manifest-nightly.json
|
- name: Update manifest.json
|
||||||
working-directory: build-${{ github.run_id }}
|
working-directory: build-${{ github.run_id }}
|
||||||
env:
|
env:
|
||||||
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@ -155,7 +134,6 @@ jobs:
|
|||||||
REPO_OWNER="${{ github.repository_owner }}"
|
REPO_OWNER="${{ github.repository_owner }}"
|
||||||
REPO_NAME="${{ github.event.repository.name }}"
|
REPO_NAME="${{ github.event.repository.name }}"
|
||||||
GITEA_URL="${{ github.server_url }}"
|
GITEA_URL="${{ github.server_url }}"
|
||||||
VERSION="${{ steps.version.outputs.version }}"
|
|
||||||
CHECKSUM="${{ steps.checksum.outputs.checksum }}"
|
CHECKSUM="${{ steps.checksum.outputs.checksum }}"
|
||||||
ARTIFACT_NAME="${{ steps.jprm.outputs.artifact_name }}"
|
ARTIFACT_NAME="${{ steps.jprm.outputs.artifact_name }}"
|
||||||
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||||
@ -167,10 +145,13 @@ jobs:
|
|||||||
git fetch origin master
|
git fetch origin master
|
||||||
git checkout 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
|
NEW_VERSION=$(cat <<EOF
|
||||||
{
|
{
|
||||||
"version": "${VERSION}",
|
"version": "0.0.0.0",
|
||||||
"changelog": "Nightly build ${VERSION} (${SHORT_SHA})",
|
"changelog": "Latest Build",
|
||||||
"targetAbi": "10.9.0.0",
|
"targetAbi": "10.9.0.0",
|
||||||
"sourceUrl": "${DOWNLOAD_URL}",
|
"sourceUrl": "${DOWNLOAD_URL}",
|
||||||
"checksum": "${CHECKSUM}",
|
"checksum": "${CHECKSUM}",
|
||||||
@ -179,13 +160,9 @@ jobs:
|
|||||||
EOF
|
EOF
|
||||||
)
|
)
|
||||||
|
|
||||||
# Prepend the new build and keep only the most recent 5 nightlies.
|
jq --argjson newver "${NEW_VERSION}" '.[0].versions = [$newver] + .[0].versions' manifest.json > manifest.tmp && mv manifest.tmp manifest.json
|
||||||
jq --argjson newver "${NEW_VERSION}" \
|
git add manifest.json
|
||||||
'.[0].versions = ([$newver] + .[0].versions)[0:5]' \
|
git commit -m "Update manifest.json for latest build (${SHORT_SHA})" || echo "No changes to commit"
|
||||||
manifest-nightly.json > manifest.tmp && mv manifest.tmp manifest-nightly.json
|
|
||||||
|
|
||||||
git add manifest-nightly.json
|
|
||||||
git commit -m "Nightly: ${VERSION} (${SHORT_SHA})" || echo "No changes to commit"
|
|
||||||
git push origin master
|
git push origin master
|
||||||
|
|
||||||
- name: Cleanup
|
- name: Cleanup
|
||||||
|
|||||||
@ -1,17 +0,0 @@
|
|||||||
<Project>
|
|
||||||
<!--
|
|
||||||
The Jellyfin meta build workflow rewrites Directory.Build.props, stamping Version,
|
|
||||||
AssemblyVersion and FileVersion all to a date-based value (e.g. 1.0.20260627.182).
|
|
||||||
The build segment (20260627) exceeds the 16-bit limit (0-65535) that AssemblyVersion
|
|
||||||
and FileVersion require, which fails the compile (CS7034/CS7035).
|
|
||||||
|
|
||||||
Directory.Build.targets is imported AFTER the project (and after Directory.Build.props),
|
|
||||||
and the workflow does not touch it, so we force valid assembly/file versions here. The
|
|
||||||
package/plugin manifest Version stays as injected; only the .NET assembly identity is
|
|
||||||
normalised, which is fine because Jellyfin identifies plugins by GUID + manifest version.
|
|
||||||
-->
|
|
||||||
<PropertyGroup>
|
|
||||||
<AssemblyVersion>1.0.0.0</AssemblyVersion>
|
|
||||||
<FileVersion>1.0.0.0</FileVersion>
|
|
||||||
</PropertyGroup>
|
|
||||||
</Project>
|
|
||||||
@ -20,13 +20,6 @@ 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>
|
||||||
|
|||||||
@ -20,6 +20,7 @@ namespace Jellyfin.Plugin.SRFPlay.Api;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class SRFApiClient : IDisposable
|
public class SRFApiClient : IDisposable
|
||||||
{
|
{
|
||||||
|
private static readonly System.Text.CompositeFormat PlayV3UrlFormat = System.Text.CompositeFormat.Parse(ApiEndpoints.PlayV3BaseUrlTemplate);
|
||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
private readonly HttpClient _playV3HttpClient;
|
private readonly HttpClient _playV3HttpClient;
|
||||||
private readonly ILogger _logger;
|
private readonly ILogger _logger;
|
||||||
@ -57,21 +58,6 @@ public class SRFApiClient : IDisposable
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Builds the Play v3 API base URL for a business unit. The host is normally
|
|
||||||
/// www.<unit>.ch, but SWI's site lives at www.swissinfo.ch (www.swi.ch is an
|
|
||||||
/// unrelated welding institute), while the API path segment stays the unit code.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="businessUnit">The lowercase business unit (e.g. "srf", "swi").</param>
|
|
||||||
/// <returns>The Play v3 production base URL ending in a slash.</returns>
|
|
||||||
private static string BuildPlayV3BaseUrl(string businessUnit)
|
|
||||||
{
|
|
||||||
var host = string.Equals(businessUnit, "swi", StringComparison.OrdinalIgnoreCase)
|
|
||||||
? "www.swissinfo.ch"
|
|
||||||
: $"www.{businessUnit}.ch";
|
|
||||||
return $"https://{host}/play/v3/api/{businessUnit}/production/";
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Reads HTTP response content as UTF-8 string.
|
/// Reads HTTP response content as UTF-8 string.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -329,7 +315,7 @@ public class SRFApiClient : IDisposable
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var baseUrl = BuildPlayV3BaseUrl(businessUnit);
|
var baseUrl = string.Format(CultureInfo.InvariantCulture, PlayV3UrlFormat, businessUnit);
|
||||||
var url = $"{baseUrl}{endpoint}";
|
var url = $"{baseUrl}{endpoint}";
|
||||||
_logger.LogInformation("Fetching all {Endpoint} for business unit: {BusinessUnit} from URL: {Url}", endpoint, businessUnit, url);
|
_logger.LogInformation("Fetching all {Endpoint} for business unit: {BusinessUnit} from URL: {Url}", endpoint, businessUnit, url);
|
||||||
|
|
||||||
@ -366,7 +352,7 @@ public class SRFApiClient : IDisposable
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var baseUrl = BuildPlayV3BaseUrl(businessUnit);
|
var baseUrl = string.Format(CultureInfo.InvariantCulture, PlayV3UrlFormat, businessUnit);
|
||||||
var url = $"{baseUrl}videos-by-show-id?showId={showId}";
|
var url = $"{baseUrl}videos-by-show-id?showId={showId}";
|
||||||
_logger.LogDebug("Fetching videos for show {ShowId} from business unit: {BusinessUnit}", showId, businessUnit);
|
_logger.LogDebug("Fetching videos for show {ShowId} from business unit: {BusinessUnit}", showId, businessUnit);
|
||||||
|
|
||||||
@ -406,7 +392,7 @@ public class SRFApiClient : IDisposable
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var baseUrl = BuildPlayV3BaseUrl(businessUnit);
|
var baseUrl = string.Format(CultureInfo.InvariantCulture, PlayV3UrlFormat, businessUnit);
|
||||||
var url = $"{baseUrl}livestreams?eventType={eventType.ToUpperInvariant()}";
|
var url = $"{baseUrl}livestreams?eventType={eventType.ToUpperInvariant()}";
|
||||||
_logger.LogInformation("Fetching scheduled livestreams for eventType={EventType} from business unit: {BusinessUnit}", eventType, businessUnit);
|
_logger.LogInformation("Fetching scheduled livestreams for eventType={EventType} from business unit: {BusinessUnit}", eventType, businessUnit);
|
||||||
|
|
||||||
|
|||||||
@ -1,43 +0,0 @@
|
|||||||
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";
|
|
||||||
}
|
|
||||||
@ -1,43 +0,0 @@
|
|||||||
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";
|
|
||||||
}
|
|
||||||
@ -1,43 +0,0 @@
|
|||||||
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";
|
|
||||||
}
|
|
||||||
@ -7,7 +7,6 @@ using System.Threading;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Jellyfin.Plugin.SRFPlay.Api;
|
using Jellyfin.Plugin.SRFPlay.Api;
|
||||||
using Jellyfin.Plugin.SRFPlay.Api.Models;
|
using Jellyfin.Plugin.SRFPlay.Api.Models;
|
||||||
using Jellyfin.Plugin.SRFPlay.Configuration;
|
|
||||||
using Jellyfin.Plugin.SRFPlay.Constants;
|
using Jellyfin.Plugin.SRFPlay.Constants;
|
||||||
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
||||||
using Jellyfin.Plugin.SRFPlay.Utilities;
|
using Jellyfin.Plugin.SRFPlay.Utilities;
|
||||||
@ -23,12 +22,11 @@ using Microsoft.Extensions.Logging;
|
|||||||
namespace Jellyfin.Plugin.SRFPlay.Channels;
|
namespace Jellyfin.Plugin.SRFPlay.Channels;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Base channel for browsing and playing content from a single SRG SSR business unit.
|
/// SRF Play channel for browsing and playing content.
|
||||||
/// One concrete subclass exists per unit (SRF, RTS, RSI, RTR, SWI), each shown as its own tile.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public abstract class SrgChannelBase : IChannel, IHasCacheKey
|
public class SRFPlayChannel : IChannel, IHasCacheKey
|
||||||
{
|
{
|
||||||
private readonly ILogger<SrgChannelBase> _logger;
|
private readonly ILogger<SRFPlayChannel> _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;
|
||||||
@ -37,7 +35,7 @@ public abstract class SrgChannelBase : IChannel, IHasCacheKey
|
|||||||
private readonly IRecordingService _recordingService;
|
private readonly IRecordingService _recordingService;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="SrgChannelBase"/> class.
|
/// Initializes a new instance of the <see cref="SRFPlayChannel"/> 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>
|
||||||
@ -46,7 +44,7 @@ public abstract class SrgChannelBase : IChannel, IHasCacheKey
|
|||||||
/// <param name="categoryService">The category service (optional).</param>
|
/// <param name="categoryService">The category service (optional).</param>
|
||||||
/// <param name="apiClientFactory">The API client factory.</param>
|
/// <param name="apiClientFactory">The API client factory.</param>
|
||||||
/// <param name="recordingService">The recording service.</param>
|
/// <param name="recordingService">The recording service.</param>
|
||||||
protected SrgChannelBase(
|
public SRFPlayChannel(
|
||||||
ILoggerFactory loggerFactory,
|
ILoggerFactory loggerFactory,
|
||||||
IContentRefreshService contentRefreshService,
|
IContentRefreshService contentRefreshService,
|
||||||
IStreamUrlResolver streamResolver,
|
IStreamUrlResolver streamResolver,
|
||||||
@ -55,7 +53,7 @@ public abstract class SrgChannelBase : IChannel, IHasCacheKey
|
|||||||
ISRFApiClientFactory apiClientFactory,
|
ISRFApiClientFactory apiClientFactory,
|
||||||
IRecordingService recordingService)
|
IRecordingService recordingService)
|
||||||
{
|
{
|
||||||
_logger = loggerFactory.CreateLogger<SrgChannelBase>();
|
_logger = loggerFactory.CreateLogger<SRFPlayChannel>();
|
||||||
_contentRefreshService = contentRefreshService;
|
_contentRefreshService = contentRefreshService;
|
||||||
_streamResolver = streamResolver;
|
_streamResolver = streamResolver;
|
||||||
_mediaSourceFactory = mediaSourceFactory;
|
_mediaSourceFactory = mediaSourceFactory;
|
||||||
@ -68,24 +66,14 @@ public abstract class SrgChannelBase : IChannel, IHasCacheKey
|
|||||||
_logger.LogWarning("CategoryService not available - category folders will be disabled");
|
_logger.LogWarning("CategoryService not available - category folders will be disabled");
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogDebug("SrgChannelBase initialized");
|
_logger.LogDebug("SRFPlayChannel initialized");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// Gets the business unit this channel serves.
|
public string Name => "SRF Play";
|
||||||
/// </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 Name => $"{Unit} Play";
|
public string Description => "Swiss Radio and Television video-on-demand content";
|
||||||
|
|
||||||
/// <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
|
||||||
@ -126,8 +114,7 @@ public abstract class SrgChannelBase : 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(LogoResourceName)
|
var resourceStream = assembly.GetManifestResourceStream("Jellyfin.Plugin.SRFPlay.Images.logo.png");
|
||||||
?? assembly.GetManifestResourceStream("Jellyfin.Plugin.SRFPlay.Images.logo.png");
|
|
||||||
|
|
||||||
if (resourceStream == null)
|
if (resourceStream == null)
|
||||||
{
|
{
|
||||||
@ -211,7 +198,7 @@ public abstract class SrgChannelBase : IChannel, IHasCacheKey
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var businessUnit = Unit.ToLowerString();
|
var businessUnit = config.BusinessUnit.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)))
|
||||||
@ -252,23 +239,24 @@ public abstract class SrgChannelBase : IChannel, IHasCacheKey
|
|||||||
|
|
||||||
private async Task<List<ChannelItemInfo>> GetLatestVideosAsync(CancellationToken cancellationToken)
|
private async Task<List<ChannelItemInfo>> GetLatestVideosAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var urns = await _contentRefreshService.RefreshLatestContentAsync(Unit.ToLowerString(), cancellationToken).ConfigureAwait(false);
|
var urns = await _contentRefreshService.RefreshLatestContentAsync(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(Unit.ToLowerString(), cancellationToken).ConfigureAwait(false);
|
var urns = await _contentRefreshService.RefreshTrendingContentAsync(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 = Unit.ToLowerString();
|
var businessUnit = config?.BusinessUnit.ToLowerString() ?? "srf";
|
||||||
|
|
||||||
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);
|
||||||
@ -319,7 +307,6 @@ public abstract class SrgChannelBase : IChannel, IHasCacheKey
|
|||||||
private List<ChannelItemInfo> GetRecordingItems()
|
private List<ChannelItemInfo> GetRecordingItems()
|
||||||
{
|
{
|
||||||
var items = new List<ChannelItemInfo>();
|
var items = new List<ChannelItemInfo>();
|
||||||
var unit = Unit.ToLowerString();
|
|
||||||
var recordings = _recordingService.GetRecordings(RecordingState.Completed);
|
var recordings = _recordingService.GetRecordings(RecordingState.Completed);
|
||||||
|
|
||||||
foreach (var recording in recordings)
|
foreach (var recording in recordings)
|
||||||
@ -329,15 +316,6 @@ public abstract class SrgChannelBase : IChannel, IHasCacheKey
|
|||||||
continue;
|
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 fileInfo = new System.IO.FileInfo(recording.OutputPath);
|
||||||
var itemId = $"recording_{recording.Id}";
|
var itemId = $"recording_{recording.Id}";
|
||||||
|
|
||||||
@ -390,8 +368,9 @@ public abstract class SrgChannelBase : IChannel, IHasCacheKey
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
var config = Plugin.Instance?.Configuration;
|
||||||
var topicId = folderId.Substring("category_".Length);
|
var topicId = folderId.Substring("category_".Length);
|
||||||
var businessUnit = Unit.ToLowerString();
|
var businessUnit = config?.BusinessUnit.ToLowerString() ?? "srf";
|
||||||
|
|
||||||
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>();
|
||||||
@ -493,7 +472,7 @@ public abstract class SrgChannelBase : IChannel, IHasCacheKey
|
|||||||
var timeKey = timeBucket.ToString("yyyy-MM-dd-HH-mm", CultureInfo.InvariantCulture);
|
var timeKey = timeBucket.ToString("yyyy-MM-dd-HH-mm", CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
var recordingCount = _recordingService.GetRecordings(RecordingState.Completed).Count;
|
var recordingCount = _recordingService.GetRecordings(RecordingState.Completed).Count;
|
||||||
return $"{Unit}_{config?.EnableLatestContent}_{config?.EnableTrendingContent}_{config?.EnableCategoryFolders}_{enabledTopics}_{timeKey}_rec{recordingCount}";
|
return $"{config?.BusinessUnit}_{config?.EnableLatestContent}_{config?.EnableTrendingContent}_{config?.EnableCategoryFolders}_{enabledTopics}_{timeKey}_rec{recordingCount}";
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<List<ChannelItemInfo>> ConvertUrnsToChannelItems(List<string> urns, CancellationToken cancellationToken)
|
private async Task<List<ChannelItemInfo>> ConvertUrnsToChannelItems(List<string> urns, CancellationToken cancellationToken)
|
||||||
@ -1,43 +0,0 @@
|
|||||||
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";
|
|
||||||
}
|
|
||||||
@ -1,43 +0,0 @@
|
|||||||
using Jellyfin.Plugin.SRFPlay.Api;
|
|
||||||
using Jellyfin.Plugin.SRFPlay.Configuration;
|
|
||||||
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
|
|
||||||
namespace Jellyfin.Plugin.SRFPlay.Channels;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// SWI swissinfo.ch — Swiss international channel.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class SwiChannel : SrgChannelBase
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Initializes a new instance of the <see cref="SwiChannel"/> class.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="loggerFactory">The logger factory.</param>
|
|
||||||
/// <param name="contentRefreshService">The content refresh service.</param>
|
|
||||||
/// <param name="streamResolver">The stream resolver.</param>
|
|
||||||
/// <param name="mediaSourceFactory">The media source factory.</param>
|
|
||||||
/// <param name="categoryService">The category service.</param>
|
|
||||||
/// <param name="apiClientFactory">The API client factory.</param>
|
|
||||||
/// <param name="recordingService">The recording service.</param>
|
|
||||||
public SwiChannel(
|
|
||||||
ILoggerFactory loggerFactory,
|
|
||||||
IContentRefreshService contentRefreshService,
|
|
||||||
IStreamUrlResolver streamResolver,
|
|
||||||
IMediaSourceFactory mediaSourceFactory,
|
|
||||||
ICategoryService? categoryService,
|
|
||||||
ISRFApiClientFactory apiClientFactory,
|
|
||||||
IRecordingService recordingService)
|
|
||||||
: base(loggerFactory, contentRefreshService, streamResolver, mediaSourceFactory, categoryService, apiClientFactory, recordingService)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override BusinessUnit Unit => BusinessUnit.SWI;
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override string LogoResourceName => "Jellyfin.Plugin.SRFPlay.Images.swi-logo.png";
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public override string Description => "SWI swissinfo.ch — Swiss international video-on-demand content";
|
|
||||||
}
|
|
||||||
@ -75,12 +75,10 @@ public class PluginConfiguration : BasePluginConfiguration
|
|||||||
EnableCategoryFolders = true;
|
EnableCategoryFolders = true;
|
||||||
EnabledTopics = new System.Collections.Generic.List<string>();
|
EnabledTopics = new System.Collections.Generic.List<string>();
|
||||||
GenerateTitleCards = true;
|
GenerateTitleCards = true;
|
||||||
LiveStartSegmentsBack = 3;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the legacy single business unit. Retained only for backwards compatibility
|
/// Gets or sets the business unit to fetch content from.
|
||||||
/// with older configs; every unit now has its own always-on channel.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public BusinessUnit BusinessUnit { get; set; }
|
public BusinessUnit BusinessUnit { get; set; }
|
||||||
|
|
||||||
@ -163,14 +161,4 @@ public class PluginConfiguration : BasePluginConfiguration
|
|||||||
/// Gets or sets the output directory for sport livestream recordings.
|
/// Gets or sets the output directory for sport livestream recordings.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string RecordingOutputPath { get; set; } = string.Empty;
|
public string RecordingOutputPath { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets how many segments back from the live edge livestream playback should start.
|
|
||||||
/// Injected as <c>#EXT-X-START:TIME-OFFSET=-(N * targetDuration)</c> into the live media
|
|
||||||
/// playlist. This keeps the start point out of the volatile live edge, which on Android TV
|
|
||||||
/// (ExoPlayer) otherwise causes stalling/jumping until a manual skip. RFC 8216 requires the
|
|
||||||
/// offset to stay at least 3 target durations from the edge, so values below 3 are clamped.
|
|
||||||
/// Set to 0 to disable injection entirely.
|
|
||||||
/// </summary>
|
|
||||||
public int LiveStartSegmentsBack { get; set; }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,9 +2,16 @@
|
|||||||
<div data-role="content">
|
<div data-role="content">
|
||||||
<div class="content-primary">
|
<div class="content-primary">
|
||||||
<form id="SRFPlayConfigForm">
|
<form id="SRFPlayConfigForm">
|
||||||
<div class="checkboxContainer checkboxContainer-withDescription">
|
<div class="selectContainer">
|
||||||
<h3>Channels</h3>
|
<label class="selectLabel" for="BusinessUnit">Business Unit</label>
|
||||||
<div class="fieldDescription">Every Swiss broadcaster has its own channel tile: SRF (German), RTS (French), RSI (Italian), RTR (Romansh) and SWI (swissinfo.ch). They are always available — pin the ones you use from the Jellyfin home screen.</div>
|
<select is="emby-select" id="BusinessUnit" name="BusinessUnit" class="emby-select-withcolor emby-select">
|
||||||
|
<option id="optSRF" value="SRF">SRF (German)</option>
|
||||||
|
<option id="optRTS" value="RTS">RTS (French)</option>
|
||||||
|
<option id="optRSI" value="RSI">RSI (Italian)</option>
|
||||||
|
<option id="optRTR" value="RTR">RTR (Romansh)</option>
|
||||||
|
<option id="optSWI" value="SWI">SWI (International)</option>
|
||||||
|
</select>
|
||||||
|
<div class="fieldDescription">Select the Swiss broadcasting unit to fetch content from</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="selectContainer">
|
<div class="selectContainer">
|
||||||
<label class="selectLabel" for="QualityPreference">Quality Preference</label>
|
<label class="selectLabel" for="QualityPreference">Quality Preference</label>
|
||||||
@ -82,11 +89,6 @@
|
|||||||
<input id="RecordingOutputPath" name="RecordingOutputPath" type="text" is="emby-input" placeholder="e.g., /media/recordings/srf" />
|
<input id="RecordingOutputPath" name="RecordingOutputPath" type="text" is="emby-input" placeholder="e.g., /media/recordings/srf" />
|
||||||
<div class="fieldDescription">Directory where sport livestream recordings will be saved (requires ffmpeg)</div>
|
<div class="fieldDescription">Directory where sport livestream recordings will be saved (requires ffmpeg)</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="inputContainer">
|
|
||||||
<label class="inputLabel inputLabelUnfocused" for="LiveStartSegmentsBack">Live Start Offset (segments)</label>
|
|
||||||
<input id="LiveStartSegmentsBack" name="LiveStartSegmentsBack" type="number" is="emby-input" min="0" max="20" />
|
|
||||||
<div class="fieldDescription">How many segments back from the live edge livestreams start playing. Fixes jumpy/stalling playback at the start on Android TV. Minimum 3 (RFC requirement); 0 disables it. Default 3.</div>
|
|
||||||
</div>
|
|
||||||
<br />
|
<br />
|
||||||
<h2>Network Settings</h2>
|
<h2>Network Settings</h2>
|
||||||
<div class="inputContainer">
|
<div class="inputContainer">
|
||||||
@ -140,6 +142,7 @@
|
|||||||
.addEventListener('pageshow', function() {
|
.addEventListener('pageshow', function() {
|
||||||
Dashboard.showLoadingMsg();
|
Dashboard.showLoadingMsg();
|
||||||
ApiClient.getPluginConfiguration(SRFPlayConfig.pluginUniqueId).then(function (config) {
|
ApiClient.getPluginConfiguration(SRFPlayConfig.pluginUniqueId).then(function (config) {
|
||||||
|
document.querySelector('#BusinessUnit').value = config.BusinessUnit;
|
||||||
document.querySelector('#QualityPreference').value = config.QualityPreference;
|
document.querySelector('#QualityPreference').value = config.QualityPreference;
|
||||||
document.querySelector('#ContentRefreshIntervalHours').value = config.ContentRefreshIntervalHours;
|
document.querySelector('#ContentRefreshIntervalHours').value = config.ContentRefreshIntervalHours;
|
||||||
document.querySelector('#ExpirationCheckIntervalHours').value = config.ExpirationCheckIntervalHours;
|
document.querySelector('#ExpirationCheckIntervalHours').value = config.ExpirationCheckIntervalHours;
|
||||||
@ -153,7 +156,6 @@
|
|||||||
document.querySelector('#ProxyPassword').value = config.ProxyPassword || '';
|
document.querySelector('#ProxyPassword').value = config.ProxyPassword || '';
|
||||||
document.querySelector('#PublicServerUrl').value = config.PublicServerUrl || '';
|
document.querySelector('#PublicServerUrl').value = config.PublicServerUrl || '';
|
||||||
document.querySelector('#RecordingOutputPath').value = config.RecordingOutputPath || '';
|
document.querySelector('#RecordingOutputPath').value = config.RecordingOutputPath || '';
|
||||||
document.querySelector('#LiveStartSegmentsBack').value = config.LiveStartSegmentsBack != null ? config.LiveStartSegmentsBack : 3;
|
|
||||||
Dashboard.hideLoadingMsg();
|
Dashboard.hideLoadingMsg();
|
||||||
|
|
||||||
// Load recordings UI
|
// Load recordings UI
|
||||||
@ -166,6 +168,7 @@
|
|||||||
.addEventListener('submit', function(e) {
|
.addEventListener('submit', function(e) {
|
||||||
Dashboard.showLoadingMsg();
|
Dashboard.showLoadingMsg();
|
||||||
ApiClient.getPluginConfiguration(SRFPlayConfig.pluginUniqueId).then(function (config) {
|
ApiClient.getPluginConfiguration(SRFPlayConfig.pluginUniqueId).then(function (config) {
|
||||||
|
config.BusinessUnit = document.querySelector('#BusinessUnit').value;
|
||||||
config.QualityPreference = document.querySelector('#QualityPreference').value;
|
config.QualityPreference = document.querySelector('#QualityPreference').value;
|
||||||
config.ContentRefreshIntervalHours = parseInt(document.querySelector('#ContentRefreshIntervalHours').value);
|
config.ContentRefreshIntervalHours = parseInt(document.querySelector('#ContentRefreshIntervalHours').value);
|
||||||
config.ExpirationCheckIntervalHours = parseInt(document.querySelector('#ExpirationCheckIntervalHours').value);
|
config.ExpirationCheckIntervalHours = parseInt(document.querySelector('#ExpirationCheckIntervalHours').value);
|
||||||
@ -179,7 +182,6 @@
|
|||||||
config.ProxyPassword = document.querySelector('#ProxyPassword').value;
|
config.ProxyPassword = document.querySelector('#ProxyPassword').value;
|
||||||
config.PublicServerUrl = document.querySelector('#PublicServerUrl').value;
|
config.PublicServerUrl = document.querySelector('#PublicServerUrl').value;
|
||||||
config.RecordingOutputPath = document.querySelector('#RecordingOutputPath').value;
|
config.RecordingOutputPath = document.querySelector('#RecordingOutputPath').value;
|
||||||
config.LiveStartSegmentsBack = parseInt(document.querySelector('#LiveStartSegmentsBack').value) || 0;
|
|
||||||
ApiClient.updatePluginConfiguration(SRFPlayConfig.pluginUniqueId, config).then(function (result) {
|
ApiClient.updatePluginConfiguration(SRFPlayConfig.pluginUniqueId, config).then(function (result) {
|
||||||
Dashboard.processPluginConfigurationUpdateResult(result);
|
Dashboard.processPluginConfigurationUpdateResult(result);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -40,7 +40,6 @@ public class RecordingController : ControllerBase
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>The recording manager HTML page.</returns>
|
/// <returns>The recording manager HTML page.</returns>
|
||||||
[HttpGet("Page")]
|
[HttpGet("Page")]
|
||||||
[AllowAnonymous]
|
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
public IActionResult GetRecordingPage()
|
public IActionResult GetRecordingPage()
|
||||||
|
|||||||
@ -225,8 +225,7 @@ public class StreamProxyController : ControllerBase
|
|||||||
queryParams = string.Empty;
|
queryParams = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
var isLiveStream = _proxyService.GetStreamMetadata(actualItemId)?.IsLiveStream ?? false;
|
var rewrittenContent = _proxyService.RewriteVariantManifestUrls(manifestContent, baseProxyUrl, queryParams);
|
||||||
var rewrittenContent = _proxyService.RewriteVariantManifestUrls(manifestContent, baseProxyUrl, queryParams, isLiveStream);
|
|
||||||
|
|
||||||
// Set cache headers based on stream type (live vs VOD)
|
// Set cache headers based on stream type (live vs VOD)
|
||||||
// Variant manifests use stricter no-cache for live streams
|
// Variant manifests use stricter no-cache for live streams
|
||||||
|
|||||||
@ -33,11 +33,6 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<None Remove="..\assests\main logo.png" />
|
<None Remove="..\assests\main logo.png" />
|
||||||
<EmbeddedResource Include="..\assests\main logo.png" LogicalName="Jellyfin.Plugin.SRFPlay.Images.logo.png" />
|
<EmbeddedResource Include="..\assests\main logo.png" LogicalName="Jellyfin.Plugin.SRFPlay.Images.logo.png" />
|
||||||
<EmbeddedResource Include="..\assests\units\srf-logo.png" LogicalName="Jellyfin.Plugin.SRFPlay.Images.srf-logo.png" />
|
|
||||||
<EmbeddedResource Include="..\assests\units\rts-logo.png" LogicalName="Jellyfin.Plugin.SRFPlay.Images.rts-logo.png" />
|
|
||||||
<EmbeddedResource Include="..\assests\units\rsi-logo.png" LogicalName="Jellyfin.Plugin.SRFPlay.Images.rsi-logo.png" />
|
|
||||||
<EmbeddedResource Include="..\assests\units\rtr-logo.png" LogicalName="Jellyfin.Plugin.SRFPlay.Images.rtr-logo.png" />
|
|
||||||
<EmbeddedResource Include="..\assests\units\swi-logo.png" LogicalName="Jellyfin.Plugin.SRFPlay.Images.swi-logo.png" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@ -45,13 +45,16 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
|||||||
{
|
{
|
||||||
Name = Name,
|
Name = Name,
|
||||||
EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.Configuration.configPage.html", GetType().Namespace)
|
EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.Configuration.configPage.html", GetType().Namespace)
|
||||||
|
},
|
||||||
|
new PluginPageInfo
|
||||||
|
{
|
||||||
|
Name = "SRF Play Recordings",
|
||||||
|
DisplayName = "SRF Sport Recordings",
|
||||||
|
EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.Configuration.recordingPage.html", GetType().Namespace),
|
||||||
|
EnableInMainMenu = true,
|
||||||
|
MenuSection = "Live TV",
|
||||||
|
MenuIcon = "fiber_smart_record"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: the recordings manager is intentionally NOT registered as a plugin
|
|
||||||
// page. Plugin pages live under the admin Dashboard and are admin-gated, which
|
|
||||||
// both caused a redirect to the recordings page and made it inaccessible to
|
|
||||||
// normal users. Recordings are served as a standalone, user-accessible page at
|
|
||||||
// GET /Plugins/SRFPlay/Recording/Page (see RecordingController).
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,7 +3,6 @@ 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;
|
||||||
|
|
||||||
@ -64,25 +63,20 @@ public class ContentRefreshTask : IScheduledTask
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh content for every business unit (one channel per unit).
|
// Refresh latest content
|
||||||
var units = System.Enum.GetValues<Configuration.BusinessUnit>();
|
if (config.EnableLatestContent)
|
||||||
foreach (var unit in units)
|
|
||||||
{
|
{
|
||||||
var businessUnit = unit.ToLowerString();
|
_logger.LogInformation("Refreshing latest content");
|
||||||
|
progress?.Report(25);
|
||||||
|
await _contentRefreshService.RefreshLatestContentAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
if (config.EnableLatestContent)
|
// Refresh trending content
|
||||||
{
|
if (config.EnableTrendingContent)
|
||||||
_logger.LogInformation("Refreshing latest content for {BusinessUnit}", businessUnit);
|
{
|
||||||
progress?.Report(25);
|
_logger.LogInformation("Refreshing trending content");
|
||||||
await _contentRefreshService.RefreshLatestContentAsync(businessUnit, cancellationToken).ConfigureAwait(false);
|
progress?.Report(75);
|
||||||
}
|
await _contentRefreshService.RefreshTrendingContentAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
if (config.EnableTrendingContent)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Refreshing trending content for {BusinessUnit}", businessUnit);
|
|
||||||
progress?.Report(75);
|
|
||||||
await _contentRefreshService.RefreshTrendingContentAsync(businessUnit, cancellationToken).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
progress?.Report(100);
|
progress?.Report(100);
|
||||||
|
|||||||
@ -49,13 +49,7 @@ public class ServiceRegistrator : IPluginServiceRegistrator
|
|||||||
serviceCollection.AddSingleton<IScheduledTask, ExpirationCheckTask>();
|
serviceCollection.AddSingleton<IScheduledTask, ExpirationCheckTask>();
|
||||||
serviceCollection.AddSingleton<IScheduledTask, RecordingSchedulerTask>();
|
serviceCollection.AddSingleton<IScheduledTask, RecordingSchedulerTask>();
|
||||||
|
|
||||||
// Register one channel (tile) per SRG business unit. Each must be registered as IChannel
|
// Register channel - must register as IChannel interface for Jellyfin to discover it
|
||||||
// for Jellyfin to discover it. A unit that is not in EnabledBusinessUnits returns no
|
serviceCollection.AddSingleton<IChannel, SRFPlayChannel>();
|
||||||
// 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,10 +18,8 @@ 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;
|
||||||
// Cache topics per business unit. A single shared cache would return one unit's
|
private DateTime _topicsCacheExpiry = DateTime.MinValue;
|
||||||
// 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.
|
||||||
@ -37,11 +35,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 for this business unit if still valid
|
// Return cached topics if still valid
|
||||||
if (_topicsCacheByUnit.TryGetValue(businessUnit, out var cached) && DateTime.UtcNow < cached.Expiry)
|
if (_topicsCache != null && DateTime.UtcNow < _topicsCacheExpiry)
|
||||||
{
|
{
|
||||||
_logger.LogDebug("Returning cached topics for business unit: {BusinessUnit}", businessUnit);
|
_logger.LogDebug("Returning cached topics for business unit: {BusinessUnit}", businessUnit);
|
||||||
return cached.Topics.Values.ToList();
|
return _topicsCache.Values.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("Fetching topics for business unit: {BusinessUnit}", businessUnit);
|
_logger.LogInformation("Fetching topics for business unit: {BusinessUnit}", businessUnit);
|
||||||
@ -50,13 +48,13 @@ public class CategoryService : ICategoryService
|
|||||||
|
|
||||||
if (topics != null && topics.Count > 0)
|
if (topics != null && topics.Count > 0)
|
||||||
{
|
{
|
||||||
// Cache topics by ID for quick lookups, scoped to this business unit
|
// Cache topics by ID for quick lookups
|
||||||
var byId = topics
|
_topicsCache = 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);
|
||||||
_topicsCacheByUnit[businessUnit] = (byId, DateTime.UtcNow.Add(_topicsCacheDuration));
|
_topicsCacheExpiry = DateTime.UtcNow.Add(_topicsCacheDuration);
|
||||||
|
|
||||||
_logger.LogInformation("Cached {Count} topics for business unit: {BusinessUnit}", byId.Count, businessUnit);
|
_logger.LogInformation("Cached {Count} topics for business unit: {BusinessUnit}", _topicsCache.Count, businessUnit);
|
||||||
}
|
}
|
||||||
|
|
||||||
return topics ?? new List<PlayV3Topic>();
|
return topics ?? new List<PlayV3Topic>();
|
||||||
@ -65,14 +63,13 @@ 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 for this business unit
|
// Ensure topics are loaded
|
||||||
if (!_topicsCacheByUnit.TryGetValue(businessUnit, out var cached) || DateTime.UtcNow >= cached.Expiry)
|
if (_topicsCache == null || DateTime.UtcNow >= _topicsCacheExpiry)
|
||||||
{
|
{
|
||||||
await GetTopicsAsync(businessUnit, cancellationToken).ConfigureAwait(false);
|
await GetTopicsAsync(businessUnit, cancellationToken).ConfigureAwait(false);
|
||||||
_topicsCacheByUnit.TryGetValue(businessUnit, out cached);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return cached.Topics?.GetValueOrDefault(topicId);
|
return _topicsCache?.GetValueOrDefault(topicId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@ -104,7 +101,8 @@ public class CategoryService : ICategoryService
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void ClearCache()
|
public void ClearCache()
|
||||||
{
|
{
|
||||||
_topicsCacheByUnit.Clear();
|
_topicsCache = null;
|
||||||
|
_topicsCacheExpiry = DateTime.MinValue;
|
||||||
_logger.LogInformation("Topics cache cleared");
|
_logger.LogInformation("Topics cache cleared");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -34,10 +34,9 @@ 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(string businessUnit, CancellationToken cancellationToken)
|
public async Task<List<string>> RefreshLatestContentAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var config = Plugin.Instance?.Configuration;
|
var config = Plugin.Instance?.Configuration;
|
||||||
if (config == null || !config.EnableLatestContent)
|
if (config == null || !config.EnableLatestContent)
|
||||||
@ -47,7 +46,7 @@ public class ContentRefreshService : IContentRefreshService
|
|||||||
}
|
}
|
||||||
|
|
||||||
return await FetchVideosFromShowsAsync(
|
return await FetchVideosFromShowsAsync(
|
||||||
businessUnit,
|
config.BusinessUnit.ToLowerString(),
|
||||||
minEpisodeCount: 0,
|
minEpisodeCount: 0,
|
||||||
maxShows: 20,
|
maxShows: 20,
|
||||||
videosPerShow: 1,
|
videosPerShow: 1,
|
||||||
@ -59,10 +58,9 @@ 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(string businessUnit, CancellationToken cancellationToken)
|
public async Task<List<string>> RefreshTrendingContentAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var config = Plugin.Instance?.Configuration;
|
var config = Plugin.Instance?.Configuration;
|
||||||
if (config == null || !config.EnableTrendingContent)
|
if (config == null || !config.EnableTrendingContent)
|
||||||
@ -72,7 +70,7 @@ public class ContentRefreshService : IContentRefreshService
|
|||||||
}
|
}
|
||||||
|
|
||||||
return await FetchVideosFromShowsAsync(
|
return await FetchVideosFromShowsAsync(
|
||||||
businessUnit,
|
config.BusinessUnit.ToLowerString(),
|
||||||
minEpisodeCount: 10,
|
minEpisodeCount: 10,
|
||||||
maxShows: 15,
|
maxShows: 15,
|
||||||
videosPerShow: 2,
|
videosPerShow: 2,
|
||||||
|
|||||||
@ -12,16 +12,14 @@ 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(string businessUnit, CancellationToken cancellationToken);
|
Task<List<string>> RefreshLatestContentAsync(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(string businessUnit, CancellationToken cancellationToken);
|
Task<List<string>> RefreshTrendingContentAsync(CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -71,9 +71,8 @@ public interface IStreamProxyService
|
|||||||
/// <param name="manifestContent">The variant manifest content.</param>
|
/// <param name="manifestContent">The variant manifest content.</param>
|
||||||
/// <param name="baseProxyUrl">The base proxy URL (without query params).</param>
|
/// <param name="baseProxyUrl">The base proxy URL (without query params).</param>
|
||||||
/// <param name="queryParams">Query parameters to append to rewritten URLs (e.g., "?token=abc").</param>
|
/// <param name="queryParams">Query parameters to append to rewritten URLs (e.g., "?token=abc").</param>
|
||||||
/// <param name="isLiveStream">Whether this is a livestream; if so an EXT-X-START offset is injected.</param>
|
|
||||||
/// <returns>The rewritten manifest content.</returns>
|
/// <returns>The rewritten manifest content.</returns>
|
||||||
string RewriteVariantManifestUrls(string manifestContent, string baseProxyUrl, string queryParams, bool isLiveStream = false);
|
string RewriteVariantManifestUrls(string manifestContent, string baseProxyUrl, string queryParams);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Cleans up old and expired stream mappings.
|
/// Cleans up old and expired stream mappings.
|
||||||
|
|||||||
@ -147,25 +147,19 @@ public class RecordingService : IRecordingService, IDisposable
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<IReadOnlyList<PlayV3TvProgram>> GetUpcomingScheduleAsync(CancellationToken cancellationToken)
|
public async Task<IReadOnlyList<PlayV3TvProgram>> GetUpcomingScheduleAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var units = System.Enum.GetValues<Configuration.BusinessUnit>();
|
var config = Plugin.Instance?.Configuration;
|
||||||
|
var businessUnit = (config?.BusinessUnit ?? Configuration.BusinessUnit.SRF).ToString().ToLowerInvariant();
|
||||||
|
|
||||||
using var apiClient = _apiClientFactory.CreateClient();
|
using var apiClient = _apiClientFactory.CreateClient();
|
||||||
|
var livestreams = await apiClient.GetScheduledLivestreamsAsync(businessUnit, "SPORT", cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
// Aggregate sport livestreams across every business unit so the recordings
|
if (livestreams == null)
|
||||||
// page shows events from all languages.
|
|
||||||
var all = new List<PlayV3TvProgram>();
|
|
||||||
foreach (var unit in units)
|
|
||||||
{
|
{
|
||||||
var businessUnit = unit.ToString().ToLowerInvariant();
|
return Array.Empty<PlayV3TvProgram>();
|
||||||
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 all
|
return livestreams
|
||||||
.Where(ls => ls.Blocked != true && (ls.ValidTo == null || ls.ValidTo.Value.ToUniversalTime() > DateTime.UtcNow))
|
.Where(ls => ls.Blocked != true && (ls.ValidTo == null || ls.ValidTo.Value.ToUniversalTime() > DateTime.UtcNow))
|
||||||
.OrderBy(ls => ls.ValidFrom)
|
.OrderBy(ls => ls.ValidFrom)
|
||||||
.ToList();
|
.ToList();
|
||||||
@ -184,11 +178,9 @@ public class RecordingService : IRecordingService, IDisposable
|
|||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch metadata for the URN. The unit is encoded in the URN itself, so use that to
|
// Fetch metadata for the URN
|
||||||
// query the right schedule regardless of which units are enabled.
|
|
||||||
var config = Plugin.Instance?.Configuration;
|
var config = Plugin.Instance?.Configuration;
|
||||||
var businessUnit = ParseBusinessUnitFromUrn(urn)
|
var businessUnit = (config?.BusinessUnit ?? Configuration.BusinessUnit.SRF).ToString().ToLowerInvariant();
|
||||||
?? (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);
|
||||||
@ -198,7 +190,6 @@ 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,
|
||||||
@ -215,24 +206,6 @@ 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)
|
||||||
{
|
{
|
||||||
|
|||||||
@ -900,15 +900,9 @@ public class StreamProxyService : IStreamProxyService
|
|||||||
/// <param name="manifestContent">The variant manifest content.</param>
|
/// <param name="manifestContent">The variant manifest content.</param>
|
||||||
/// <param name="baseProxyUrl">The base proxy URL (without query params).</param>
|
/// <param name="baseProxyUrl">The base proxy URL (without query params).</param>
|
||||||
/// <param name="queryParams">Query parameters to append to rewritten URLs (e.g., "?token=abc").</param>
|
/// <param name="queryParams">Query parameters to append to rewritten URLs (e.g., "?token=abc").</param>
|
||||||
/// <param name="isLiveStream">Whether this is a livestream; if so an EXT-X-START offset is injected.</param>
|
|
||||||
/// <returns>The rewritten manifest content.</returns>
|
/// <returns>The rewritten manifest content.</returns>
|
||||||
public string RewriteVariantManifestUrls(string manifestContent, string baseProxyUrl, string queryParams, bool isLiveStream = false)
|
public string RewriteVariantManifestUrls(string manifestContent, string baseProxyUrl, string queryParams)
|
||||||
{
|
{
|
||||||
if (isLiveStream)
|
|
||||||
{
|
|
||||||
manifestContent = InjectLiveStartOffset(manifestContent);
|
|
||||||
}
|
|
||||||
|
|
||||||
string RewriteUrl(string url)
|
string RewriteUrl(string url)
|
||||||
{
|
{
|
||||||
if (url.Contains("://", StringComparison.Ordinal))
|
if (url.Contains("://", StringComparison.Ordinal))
|
||||||
@ -959,82 +953,6 @@ public class StreamProxyService : IStreamProxyService
|
|||||||
return result.ToString();
|
return result.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Injects an <c>#EXT-X-START:TIME-OFFSET</c> tag into a live media playlist so that players
|
|
||||||
/// (notably ExoPlayer on Android TV) begin a few segments back from the volatile live edge
|
|
||||||
/// instead of at the edge itself, which otherwise causes stalling/jumping until a manual skip.
|
|
||||||
/// The offset is sized relative to the playlist's own <c>EXT-X-TARGETDURATION</c> to stay
|
|
||||||
/// RFC 8216 compliant (≥ 3 target durations from the edge for live playlists).
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="manifestContent">The media playlist content.</param>
|
|
||||||
/// <returns>The playlist with the tag injected, or unchanged if it is not applicable.</returns>
|
|
||||||
private string InjectLiveStartOffset(string manifestContent)
|
|
||||||
{
|
|
||||||
var segmentsBack = Plugin.Instance?.Configuration?.LiveStartSegmentsBack ?? 3;
|
|
||||||
if (segmentsBack <= 0)
|
|
||||||
{
|
|
||||||
return manifestContent; // Disabled by config.
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only a media playlist (has segments, no variant list) should carry EXT-X-START.
|
|
||||||
// A master/multivariant playlist or a VOD playlist (#EXT-X-ENDLIST) is left untouched.
|
|
||||||
if (!manifestContent.Contains("#EXTINF", StringComparison.Ordinal)
|
|
||||||
|| manifestContent.Contains("#EXT-X-STREAM-INF", StringComparison.Ordinal)
|
|
||||||
|| manifestContent.Contains("#EXT-X-ENDLIST", StringComparison.Ordinal))
|
|
||||||
{
|
|
||||||
return manifestContent;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Don't add a second tag if the source already specified a start point.
|
|
||||||
if (manifestContent.Contains("#EXT-X-START", StringComparison.Ordinal))
|
|
||||||
{
|
|
||||||
_logger.LogDebug("Live playlist already contains #EXT-X-START; leaving as-is");
|
|
||||||
return manifestContent;
|
|
||||||
}
|
|
||||||
|
|
||||||
var targetDurationMatch = Regex.Match(manifestContent, @"#EXT-X-TARGETDURATION:(\d+(?:\.\d+)?)");
|
|
||||||
if (!targetDurationMatch.Success
|
|
||||||
|| !double.TryParse(
|
|
||||||
targetDurationMatch.Groups[1].Value,
|
|
||||||
System.Globalization.NumberStyles.Float,
|
|
||||||
System.Globalization.CultureInfo.InvariantCulture,
|
|
||||||
out var targetDuration)
|
|
||||||
|| targetDuration <= 0)
|
|
||||||
{
|
|
||||||
_logger.LogDebug("Could not read EXT-X-TARGETDURATION; skipping EXT-X-START injection");
|
|
||||||
return manifestContent;
|
|
||||||
}
|
|
||||||
|
|
||||||
// RFC 8216: TIME-OFFSET SHOULD NOT be within 3 target durations of the live edge.
|
|
||||||
var clampedSegments = Math.Max(3, segmentsBack);
|
|
||||||
var offsetSeconds = clampedSegments * targetDuration;
|
|
||||||
var startTag = string.Format(
|
|
||||||
System.Globalization.CultureInfo.InvariantCulture,
|
|
||||||
"#EXT-X-START:TIME-OFFSET=-{0:0.###},PRECISE=YES",
|
|
||||||
offsetSeconds);
|
|
||||||
|
|
||||||
_logger.LogInformation(
|
|
||||||
"Injecting {StartTag} into live playlist (targetDuration={TargetDuration}s, segmentsBack={SegmentsBack})",
|
|
||||||
startTag,
|
|
||||||
targetDuration,
|
|
||||||
clampedSegments);
|
|
||||||
|
|
||||||
// Insert after the #EXTM3U header so it sits with the other top-level tags.
|
|
||||||
var headerIndex = manifestContent.IndexOf("#EXTM3U", StringComparison.Ordinal);
|
|
||||||
if (headerIndex < 0)
|
|
||||||
{
|
|
||||||
return startTag + "\n" + manifestContent;
|
|
||||||
}
|
|
||||||
|
|
||||||
var lineEnd = manifestContent.IndexOf('\n', headerIndex);
|
|
||||||
if (lineEnd < 0)
|
|
||||||
{
|
|
||||||
return manifestContent + "\n" + startTag;
|
|
||||||
}
|
|
||||||
|
|
||||||
return manifestContent[..(lineEnd + 1)] + startTag + "\n" + manifestContent[(lineEnd + 1)..];
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Cleans up old and expired stream mappings.
|
/// Cleans up old and expired stream mappings.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@ -270,12 +270,7 @@ The plugin includes:
|
|||||||
|
|
||||||
### Contributing
|
### Contributing
|
||||||
|
|
||||||
Contributions welcome! This project is hosted on a self-hosted [Gitea](https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay) instance.
|
Contributions welcome!
|
||||||
|
|
||||||
**You don't need a separate account** — you can sign in with your existing GitHub account. On the [sign-in page](https://gitea.tourolle.paris/user/login), choose **"Sign in with GitHub"** to register and log in via GitHub OAuth. Once signed in, you can:
|
|
||||||
|
|
||||||
- **Raise issues** — report bugs or request features on the [issue tracker](https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/issues)
|
|
||||||
- **Contribute code** — fork the repository, push a branch, and open a pull request
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 24 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 26 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 19 KiB |
@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: "SRFPlay"
|
name: "SRFPlay"
|
||||||
guid: "a4b12f86-8c3d-4e9a-b7f2-1d5e6c8a9b4f"
|
guid: "eb5d7894-8eef-4b36-aa6f-5d124e828ce1"
|
||||||
version: "1.0.0.0"
|
version: "1.0.0.0"
|
||||||
targetAbi: "10.9.0.0"
|
targetAbi: "10.9.0.0"
|
||||||
framework: "net8.0"
|
framework: "net8.0"
|
||||||
|
|||||||
@ -1,29 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"guid": "a4b12f86-8c3d-4e9a-b7f2-1d5e6c8a9b4f",
|
|
||||||
"name": "SRF Play (Nightly)",
|
|
||||||
"description": "NIGHTLY/UNSTABLE builds of the SRF Play plugin for Jellyfin. Built automatically from the master branch on every push. Expect bugs. For stable releases use the regular SRF Play repository instead.",
|
|
||||||
"overview": "Nightly builds of SRF Play for Jellyfin",
|
|
||||||
"owner": "dtourolle",
|
|
||||||
"category": "Live TV",
|
|
||||||
"imageUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/raw/branch/master/assests/main%20logo.png",
|
|
||||||
"versions": [
|
|
||||||
{
|
|
||||||
"version": "1.0.20260627.189",
|
|
||||||
"changelog": "Nightly build 1.0.20260627.189 (7c81ef9)",
|
|
||||||
"targetAbi": "10.9.0.0",
|
|
||||||
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/latest/srfplay_1.0.20260627.189.zip",
|
|
||||||
"checksum": "553ac3f806bce24e5aae3b2c8d17395b",
|
|
||||||
"timestamp": "2026-06-27T13:46:17Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "1.0.20260627.185",
|
|
||||||
"changelog": "Nightly build 1.0.20260627.185 (1101385)",
|
|
||||||
"targetAbi": "10.9.0.0",
|
|
||||||
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/latest/srfplay_1.0.20260627.185.zip",
|
|
||||||
"checksum": "577af725ca202bec555bf51905b75913",
|
|
||||||
"timestamp": "2026-06-27T09:30:57Z"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@ -8,38 +8,6 @@
|
|||||||
"category": "Live TV",
|
"category": "Live TV",
|
||||||
"imageUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/raw/branch/master/assests/main%20logo.png",
|
"imageUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/raw/branch/master/assests/main%20logo.png",
|
||||||
"versions": [
|
"versions": [
|
||||||
{
|
|
||||||
"version": "1.1.0",
|
|
||||||
"changelog": "Release 1.1.0",
|
|
||||||
"targetAbi": "10.9.0.0",
|
|
||||||
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.1.0/srfplay_1.1.0.0.zip",
|
|
||||||
"checksum": "2094b00b29f79e7625fcd3ebf046674f",
|
|
||||||
"timestamp": "2026-06-27T09:33:04Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "0.0.0.0",
|
|
||||||
"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": "2ed3281990b56fbfb5f75a1ee82a2f55",
|
|
||||||
"timestamp": "2026-06-27T09:01:57Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "1.0.32",
|
|
||||||
"changelog": "Release 1.0.32",
|
|
||||||
"targetAbi": "10.9.0.0",
|
|
||||||
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.0.32/srfplay_1.0.32.0.zip",
|
|
||||||
"checksum": "82f53848f2e2b15a35ea731ee69ee402",
|
|
||||||
"timestamp": "2026-06-27T07:16:38Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "1.0.31",
|
|
||||||
"changelog": "Release 1.0.31",
|
|
||||||
"targetAbi": "10.9.0.0",
|
|
||||||
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.0.31/srfplay_1.0.31.0.zip",
|
|
||||||
"checksum": "4e499de15687e6e328ddca41c6192485",
|
|
||||||
"timestamp": "2026-06-27T06:54:01Z"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"version": "1.0.30",
|
"version": "1.0.30",
|
||||||
"changelog": "Release 1.0.30",
|
"changelog": "Release 1.0.30",
|
||||||
@ -48,6 +16,14 @@
|
|||||||
"checksum": "06731df9ba3d2dab53885c9c8ac95fa6",
|
"checksum": "06731df9ba3d2dab53885c9c8ac95fa6",
|
||||||
"timestamp": "2026-05-03T16:44:57Z"
|
"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",
|
"version": "1.0.29",
|
||||||
"changelog": "Release 1.0.29",
|
"changelog": "Release 1.0.29",
|
||||||
@ -56,6 +32,14 @@
|
|||||||
"checksum": "fb745388e64299497262d9ad370d8823",
|
"checksum": "fb745388e64299497262d9ad370d8823",
|
||||||
"timestamp": "2026-05-03T16:07:24Z"
|
"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",
|
"version": "1.0.27",
|
||||||
"changelog": "Release 1.0.27",
|
"changelog": "Release 1.0.27",
|
||||||
@ -64,6 +48,14 @@
|
|||||||
"checksum": "1e15e35452f7b82bf74d8c3560c15949",
|
"checksum": "1e15e35452f7b82bf74d8c3560c15949",
|
||||||
"timestamp": "2026-03-07T16:40:17Z"
|
"timestamp": "2026-03-07T16:40:17Z"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"version": "0.0.0.0",
|
||||||
|
"changelog": "Latest Build",
|
||||||
|
"targetAbi": "10.9.0.0",
|
||||||
|
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/latest/srfplay_1.0.0.0.zip",
|
||||||
|
"checksum": "c7e868d23293adcc21d72e735094d9d6",
|
||||||
|
"timestamp": "2026-03-07T16:28:38Z"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "1.0.25",
|
"version": "1.0.25",
|
||||||
"changelog": "Release 1.0.25",
|
"changelog": "Release 1.0.25",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user