Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
390146e8d4 | ||
|
|
a9db0aa09c | ||
|
|
7c81ef94ca | ||
|
|
f715130302 | ||
|
|
affc87bd38 | ||
|
|
1101385107 | ||
|
|
4fc79f39f7 | ||
|
|
5875f81b9b | ||
|
|
cedef6d6aa | ||
|
|
4b5d7e2a7f | ||
|
|
8281960f0b |
@@ -36,6 +36,29 @@ 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
|
||||||
@@ -59,7 +82,7 @@ jobs:
|
|||||||
- name: Upload build artifact
|
- name: Upload build artifact
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: jellyfin-srfplay-plugin
|
name: srfplay-${{ steps.version.outputs.label }}-${{ steps.version.outputs.version }}
|
||||||
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: 'Latest Release'
|
name: 'Nightly Build'
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -7,9 +7,10 @@ on:
|
|||||||
paths-ignore:
|
paths-ignore:
|
||||||
- '**/*.md'
|
- '**/*.md'
|
||||||
- 'manifest.json'
|
- 'manifest.json'
|
||||||
|
- 'manifest-nightly.json'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
latest-release:
|
nightly-build:
|
||||||
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
|
||||||
@@ -20,6 +21,24 @@ 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
|
||||||
@@ -52,7 +71,7 @@ jobs:
|
|||||||
echo "checksum=${CHECKSUM}" >> $GITHUB_OUTPUT
|
echo "checksum=${CHECKSUM}" >> $GITHUB_OUTPUT
|
||||||
echo "Checksum: ${CHECKSUM}"
|
echo "Checksum: ${CHECKSUM}"
|
||||||
|
|
||||||
- name: Delete existing latest release
|
- name: Delete existing nightly 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 }}
|
||||||
@@ -71,7 +90,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 latest release (ID: ${RELEASE_ID})..."
|
echo "Deleting existing nightly 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}"
|
||||||
@@ -81,7 +100,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 latest release
|
- name: Create nightly 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 }}
|
||||||
@@ -89,12 +108,14 @@ 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 "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}')")
|
-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}')")
|
||||||
|
|
||||||
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
|
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
|
||||||
BODY=$(echo "$RESPONSE" | sed '$d')
|
BODY=$(echo "$RESPONSE" | sed '$d')
|
||||||
@@ -124,9 +145,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 "Latest release updated successfully!"
|
echo "Nightly release updated successfully!"
|
||||||
|
|
||||||
- name: Update manifest.json
|
- name: Update manifest-nightly.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 }}
|
||||||
@@ -134,6 +155,7 @@ 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")
|
||||||
@@ -145,13 +167,10 @@ 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": "0.0.0.0",
|
"version": "${VERSION}",
|
||||||
"changelog": "Latest Build",
|
"changelog": "Nightly build ${VERSION} (${SHORT_SHA})",
|
||||||
"targetAbi": "10.9.0.0",
|
"targetAbi": "10.9.0.0",
|
||||||
"sourceUrl": "${DOWNLOAD_URL}",
|
"sourceUrl": "${DOWNLOAD_URL}",
|
||||||
"checksum": "${CHECKSUM}",
|
"checksum": "${CHECKSUM}",
|
||||||
@@ -160,9 +179,13 @@ jobs:
|
|||||||
EOF
|
EOF
|
||||||
)
|
)
|
||||||
|
|
||||||
jq --argjson newver "${NEW_VERSION}" '.[0].versions = [$newver] + .[0].versions' manifest.json > manifest.tmp && mv manifest.tmp manifest.json
|
# Prepend the new build and keep only the most recent 5 nightlies.
|
||||||
git add manifest.json
|
jq --argjson newver "${NEW_VERSION}" \
|
||||||
git commit -m "Update manifest.json for latest build (${SHORT_SHA})" || echo "No changes to commit"
|
'.[0].versions = ([$newver] + .[0].versions)[0:5]' \
|
||||||
|
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
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.SRFPlay.Api.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Outcome of a resume point ("Continue Watching") cleanup run.
|
||||||
|
/// </summary>
|
||||||
|
public class ResumeCleanupResult
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the number of users whose resume list was inspected.
|
||||||
|
/// </summary>
|
||||||
|
public int UsersChecked { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the number of plugin-owned resume points that were inspected.
|
||||||
|
/// </summary>
|
||||||
|
public int Inspected { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the number of resume points that were cleared.
|
||||||
|
/// </summary>
|
||||||
|
public int Cleared { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the number of cleared resume points grouped by the rule that matched.
|
||||||
|
/// </summary>
|
||||||
|
public Dictionary<string, int> ClearedByReason { get; } = new Dictionary<string, int>();
|
||||||
|
}
|
||||||
@@ -20,7 +20,6 @@ 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;
|
||||||
@@ -58,6 +57,21 @@ 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>
|
||||||
@@ -315,7 +329,7 @@ public class SRFApiClient : IDisposable
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var baseUrl = string.Format(CultureInfo.InvariantCulture, PlayV3UrlFormat, businessUnit);
|
var baseUrl = BuildPlayV3BaseUrl(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);
|
||||||
|
|
||||||
@@ -352,7 +366,7 @@ public class SRFApiClient : IDisposable
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var baseUrl = string.Format(CultureInfo.InvariantCulture, PlayV3UrlFormat, businessUnit);
|
var baseUrl = BuildPlayV3BaseUrl(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);
|
||||||
|
|
||||||
@@ -392,7 +406,7 @@ public class SRFApiClient : IDisposable
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var baseUrl = string.Format(CultureInfo.InvariantCulture, PlayV3UrlFormat, businessUnit);
|
var baseUrl = BuildPlayV3BaseUrl(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);
|
||||||
|
|
||||||
|
|||||||
@@ -175,21 +175,8 @@ public abstract class SrgChannelBase : 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))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -66,7 +66,6 @@ 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;
|
||||||
@@ -76,23 +75,20 @@ 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;
|
||||||
|
CleanUpResumePoints = true;
|
||||||
|
ClearLiveStreamResumePoints = true;
|
||||||
|
ResumePointMaxAgeDays = 30;
|
||||||
|
ResumePointMinPositionSeconds = 60;
|
||||||
|
ResumePointCompletedPercent = 92;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the legacy single business unit. Retained for backwards compatibility and
|
/// Gets or sets the legacy single business unit. Retained only for backwards compatibility
|
||||||
/// migration into <see cref="EnabledBusinessUnits"/>; new code should use the list instead.
|
/// with older configs; every unit now has its own always-on channel.
|
||||||
/// </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>
|
||||||
@@ -174,18 +170,45 @@ public class PluginConfiguration : BasePluginConfiguration
|
|||||||
public string RecordingOutputPath { get; set; } = string.Empty;
|
public string RecordingOutputPath { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Resolves the effective set of enabled business units, migrating from the legacy single
|
/// Gets or sets how many segments back from the live edge livestream playback should start.
|
||||||
/// <see cref="BusinessUnit"/> value when the list has not been populated yet.
|
/// 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>
|
/// </summary>
|
||||||
/// <returns>The business units that should have channels.</returns>
|
public int LiveStartSegmentsBack { get; set; }
|
||||||
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.
|
/// <summary>
|
||||||
return new System.Collections.Generic.List<BusinessUnit> { BusinessUnit };
|
/// Gets or sets a value indicating whether the "Clean Up SRF Play Continue Watching" task
|
||||||
}
|
/// removes stale resume points. When false the task inspects nothing and clears nothing.
|
||||||
|
/// </summary>
|
||||||
|
public bool CleanUpResumePoints { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether resume points for livestreams are cleared.
|
||||||
|
/// A livestream has no meaningful resume position, so stopping one otherwise leaves it
|
||||||
|
/// pinned in "Continue Watching" forever. When enabled the position is also cleared
|
||||||
|
/// immediately on playback stop, not just by the scheduled task.
|
||||||
|
/// </summary>
|
||||||
|
public bool ClearLiveStreamResumePoints { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the age in days after which an untouched resume point is cleared.
|
||||||
|
/// Measured from the last time the item was played. Set to 0 to disable the age rule.
|
||||||
|
/// </summary>
|
||||||
|
public int ResumePointMaxAgeDays { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the minimum resume position in seconds. Anything at or below this is
|
||||||
|
/// treated as an accidental start rather than something worth resuming.
|
||||||
|
/// Set to 0 to disable the rule.
|
||||||
|
/// </summary>
|
||||||
|
public int ResumePointMinPositionSeconds { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the percentage of runtime at or beyond which playback counts as finished.
|
||||||
|
/// Only applied to items with a known runtime. Set to 0 to disable the rule.
|
||||||
|
/// </summary>
|
||||||
|
public int ResumePointCompletedPercent { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,28 +3,8 @@
|
|||||||
<div class="content-primary">
|
<div class="content-primary">
|
||||||
<form id="SRFPlayConfigForm">
|
<form id="SRFPlayConfigForm">
|
||||||
<div class="checkboxContainer checkboxContainer-withDescription">
|
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||||
<h3>Channels (Business Units)</h3>
|
<h3>Channels</h3>
|
||||||
<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>
|
<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>
|
||||||
<label class="emby-checkbox-label">
|
|
||||||
<input id="buSRF" type="checkbox" is="emby-checkbox" data-bu="SRF" />
|
|
||||||
<span>SRF — Schweizer Radio und Fernsehen (German)</span>
|
|
||||||
</label>
|
|
||||||
<label class="emby-checkbox-label">
|
|
||||||
<input id="buRTS" type="checkbox" is="emby-checkbox" data-bu="RTS" />
|
|
||||||
<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>
|
||||||
@@ -102,6 +82,47 @@
|
|||||||
<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 />
|
||||||
|
<h2>Continue Watching Cleanup</h2>
|
||||||
|
<div class="fieldDescription" style="margin-bottom: 1em;">
|
||||||
|
Livestreams and abandoned playback otherwise stay pinned in every user's
|
||||||
|
"Continue Watching" row forever. Clearing a resume point only resets the
|
||||||
|
playback position — nothing is deleted and the item stays unwatched.
|
||||||
|
</div>
|
||||||
|
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||||
|
<label class="emby-checkbox-label">
|
||||||
|
<input id="CleanUpResumePoints" name="CleanUpResumePoints" type="checkbox" is="emby-checkbox" />
|
||||||
|
<span>Clean up stale resume points</span>
|
||||||
|
</label>
|
||||||
|
<div class="fieldDescription checkboxFieldDescription">Runs daily at 4 AM (Scheduled Tasks → "Clean Up SRF Play Continue Watching")</div>
|
||||||
|
</div>
|
||||||
|
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||||
|
<label class="emby-checkbox-label">
|
||||||
|
<input id="ClearLiveStreamResumePoints" name="ClearLiveStreamResumePoints" type="checkbox" is="emby-checkbox" />
|
||||||
|
<span>Never keep a resume point for livestreams</span>
|
||||||
|
</label>
|
||||||
|
<div class="fieldDescription checkboxFieldDescription">Clears the position as soon as a livestream stops playing, rather than waiting for the daily task</div>
|
||||||
|
</div>
|
||||||
|
<div class="inputContainer">
|
||||||
|
<label class="inputLabel inputLabelUnfocused" for="ResumePointMaxAgeDays">Maximum Resume Point Age (days)</label>
|
||||||
|
<input id="ResumePointMaxAgeDays" name="ResumePointMaxAgeDays" type="number" is="emby-input" min="0" max="3650" />
|
||||||
|
<div class="fieldDescription">Clear resume points not touched for this many days. 0 disables the rule. Default 30.</div>
|
||||||
|
</div>
|
||||||
|
<div class="inputContainer">
|
||||||
|
<label class="inputLabel inputLabelUnfocused" for="ResumePointMinPositionSeconds">Minimum Resume Position (seconds)</label>
|
||||||
|
<input id="ResumePointMinPositionSeconds" name="ResumePointMinPositionSeconds" type="number" is="emby-input" min="0" max="3600" />
|
||||||
|
<div class="fieldDescription">Positions at or below this are treated as an accidental start and cleared. 0 disables the rule. Default 60.</div>
|
||||||
|
</div>
|
||||||
|
<div class="inputContainer">
|
||||||
|
<label class="inputLabel inputLabelUnfocused" for="ResumePointCompletedPercent">Finished Threshold (%)</label>
|
||||||
|
<input id="ResumePointCompletedPercent" name="ResumePointCompletedPercent" type="number" is="emby-input" min="0" max="100" />
|
||||||
|
<div class="fieldDescription">Playback at or beyond this share of the runtime counts as finished. Only applies when the runtime is known. 0 disables the rule. Default 92.</div>
|
||||||
|
</div>
|
||||||
<br />
|
<br />
|
||||||
<h2>Network Settings</h2>
|
<h2>Network Settings</h2>
|
||||||
<div class="inputContainer">
|
<div class="inputContainer">
|
||||||
@@ -120,6 +141,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<br />
|
||||||
|
<h2>Continue Watching Maintenance</h2>
|
||||||
|
<div class="fieldDescription">Run the cleanup right now instead of waiting for the daily task. Only SRF Play items are affected.</div>
|
||||||
|
<div id="resumeCleanupResult" style="margin: 10px 0;"></div>
|
||||||
|
<button is="emby-button" type="button" class="raised emby-button" onclick="SRFPlayMaintenance.cleanupResumePoints(false)" style="margin-bottom: 20px;">
|
||||||
|
<span>Clean Up Stale Entries Now</span>
|
||||||
|
</button>
|
||||||
|
<button is="emby-button" type="button" class="raised emby-button" onclick="SRFPlayMaintenance.cleanupResumePoints(true)" style="margin-bottom: 20px;">
|
||||||
|
<span>Clear All SRF Play Entries</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<br />
|
<br />
|
||||||
<h2>Sport Livestream Recordings</h2>
|
<h2>Sport Livestream Recordings</h2>
|
||||||
|
|
||||||
@@ -155,12 +187,6 @@
|
|||||||
.addEventListener('pageshow', function() {
|
.addEventListener('pageshow', function() {
|
||||||
Dashboard.showLoadingMsg();
|
Dashboard.showLoadingMsg();
|
||||||
ApiClient.getPluginConfiguration(SRFPlayConfig.pluginUniqueId).then(function (config) {
|
ApiClient.getPluginConfiguration(SRFPlayConfig.pluginUniqueId).then(function (config) {
|
||||||
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;
|
||||||
@@ -174,6 +200,12 @@
|
|||||||
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;
|
||||||
|
document.querySelector('#CleanUpResumePoints').checked = config.CleanUpResumePoints !== false;
|
||||||
|
document.querySelector('#ClearLiveStreamResumePoints').checked = config.ClearLiveStreamResumePoints !== false;
|
||||||
|
document.querySelector('#ResumePointMaxAgeDays').value = config.ResumePointMaxAgeDays != null ? config.ResumePointMaxAgeDays : 30;
|
||||||
|
document.querySelector('#ResumePointMinPositionSeconds').value = config.ResumePointMinPositionSeconds != null ? config.ResumePointMinPositionSeconds : 60;
|
||||||
|
document.querySelector('#ResumePointCompletedPercent').value = config.ResumePointCompletedPercent != null ? config.ResumePointCompletedPercent : 92;
|
||||||
Dashboard.hideLoadingMsg();
|
Dashboard.hideLoadingMsg();
|
||||||
|
|
||||||
// Load recordings UI
|
// Load recordings UI
|
||||||
@@ -186,11 +218,6 @@
|
|||||||
.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.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);
|
||||||
@@ -204,6 +231,12 @@
|
|||||||
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;
|
||||||
|
config.CleanUpResumePoints = document.querySelector('#CleanUpResumePoints').checked;
|
||||||
|
config.ClearLiveStreamResumePoints = document.querySelector('#ClearLiveStreamResumePoints').checked;
|
||||||
|
config.ResumePointMaxAgeDays = parseInt(document.querySelector('#ResumePointMaxAgeDays').value) || 0;
|
||||||
|
config.ResumePointMinPositionSeconds = parseInt(document.querySelector('#ResumePointMinPositionSeconds').value) || 0;
|
||||||
|
config.ResumePointCompletedPercent = parseInt(document.querySelector('#ResumePointCompletedPercent').value) || 0;
|
||||||
ApiClient.updatePluginConfiguration(SRFPlayConfig.pluginUniqueId, config).then(function (result) {
|
ApiClient.updatePluginConfiguration(SRFPlayConfig.pluginUniqueId, config).then(function (result) {
|
||||||
Dashboard.processPluginConfigurationUpdateResult(result);
|
Dashboard.processPluginConfigurationUpdateResult(result);
|
||||||
});
|
});
|
||||||
@@ -213,6 +246,40 @@
|
|||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
var SRFPlayMaintenance = {
|
||||||
|
cleanupResumePoints: function(clearAll) {
|
||||||
|
if (clearAll && !confirm('Clear the playback position of every SRF Play item for all users? Nothing is deleted and items stay unwatched.')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var target = document.querySelector('#resumeCleanupResult');
|
||||||
|
target.innerHTML = '<p><em>Cleaning up...</em></p>';
|
||||||
|
|
||||||
|
fetch(ApiClient.serverAddress() + '/Plugins/SRFPlay/Maintenance/ResumePoints/Cleanup?clearAll=' + (clearAll ? 'true' : 'false'), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-Emby-Token': ApiClient.accessToken() }
|
||||||
|
})
|
||||||
|
.then(function(response) {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('HTTP ' + response.status);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then(function(result) {
|
||||||
|
var byReason = result.clearedByReason || {};
|
||||||
|
var reasons = Object.keys(byReason)
|
||||||
|
.map(function(key) { return key + ': ' + byReason[key]; })
|
||||||
|
.join(', ');
|
||||||
|
target.innerHTML = '<p>Cleared <strong>' + result.cleared + '</strong> of ' + result.inspected +
|
||||||
|
' SRF Play resume point(s) across ' + result.usersChecked + ' user(s).' +
|
||||||
|
(reasons ? ' (' + reasons + ')' : '') + '</p>';
|
||||||
|
})
|
||||||
|
.catch(function(error) {
|
||||||
|
target.innerHTML = '<p style="color: #d44;">Cleanup failed: ' + error.message + '</p>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
var SRFPlayRecordings = {
|
var SRFPlayRecordings = {
|
||||||
apiBase: ApiClient.serverAddress() + '/Plugins/SRFPlay/Recording',
|
apiBase: ApiClient.serverAddress() + '/Plugins/SRFPlay/Recording',
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
using System.Threading;
|
||||||
|
using Jellyfin.Plugin.SRFPlay.Api.Models;
|
||||||
|
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
||||||
|
using MediaBrowser.Common.Api;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.SRFPlay.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Administrative maintenance actions for the SRF Play plugin.
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Route("Plugins/SRFPlay/Maintenance")]
|
||||||
|
[Authorize(Policy = Policies.RequiresElevation)]
|
||||||
|
public class MaintenanceController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly ILogger<MaintenanceController> _logger;
|
||||||
|
private readonly IResumeCleanupService _resumeCleanupService;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="MaintenanceController"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="logger">The logger.</param>
|
||||||
|
/// <param name="resumeCleanupService">The resume cleanup service.</param>
|
||||||
|
public MaintenanceController(
|
||||||
|
ILogger<MaintenanceController> logger,
|
||||||
|
IResumeCleanupService resumeCleanupService)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_resumeCleanupService = resumeCleanupService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Clears stale SRF Play resume points from every user's "Continue Watching" row.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="clearAll">
|
||||||
|
/// When true, clears every SRF Play resume point instead of only the stale ones.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="cancellationToken">The cancellation token.</param>
|
||||||
|
/// <returns>A summary of what was inspected and cleared.</returns>
|
||||||
|
[HttpPost("ResumePoints/Cleanup")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
public ActionResult<ResumeCleanupResult> CleanupResumePoints(
|
||||||
|
[FromQuery] bool clearAll,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Manual Continue Watching cleanup requested (clearAll: {ClearAll})", clearAll);
|
||||||
|
return Ok(_resumeCleanupService.Cleanup(clearAll, cancellationToken));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -225,7 +225,8 @@ public class StreamProxyController : ControllerBase
|
|||||||
queryParams = string.Empty;
|
queryParams = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
var rewrittenContent = _proxyService.RewriteVariantManifestUrls(manifestContent, baseProxyUrl, queryParams);
|
var isLiveStream = _proxyService.GetStreamMetadata(actualItemId)?.IsLiveStream ?? false;
|
||||||
|
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
|
||||||
|
|||||||
@@ -64,8 +64,8 @@ public class ContentRefreshTask : IScheduledTask
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh content for every enabled business unit (one channel per unit).
|
// Refresh content for every business unit (one channel per unit).
|
||||||
var units = config.ResolveEnabledUnits();
|
var units = System.Enum.GetValues<Configuration.BusinessUnit>();
|
||||||
foreach (var unit in units)
|
foreach (var unit in units)
|
||||||
{
|
{
|
||||||
var businessUnit = unit.ToLowerString();
|
var businessUnit = unit.ToLowerString();
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
||||||
|
using MediaBrowser.Model.Tasks;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.SRFPlay.ScheduledTasks;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Scheduled task that clears stale SRF Play resume points from "Continue Watching".
|
||||||
|
/// </summary>
|
||||||
|
public class ResumeCleanupTask : IScheduledTask
|
||||||
|
{
|
||||||
|
private readonly ILogger<ResumeCleanupTask> _logger;
|
||||||
|
private readonly IResumeCleanupService _resumeCleanupService;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="ResumeCleanupTask"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="logger">The logger.</param>
|
||||||
|
/// <param name="resumeCleanupService">The resume cleanup service.</param>
|
||||||
|
public ResumeCleanupTask(
|
||||||
|
ILogger<ResumeCleanupTask> logger,
|
||||||
|
IResumeCleanupService resumeCleanupService)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_resumeCleanupService = resumeCleanupService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string Name => "Clean Up SRF Play Continue Watching";
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string Description => "Removes stale SRF Play resume points - ended livestreams, abandoned playback and finished programmes - from every user's Continue Watching row";
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string Category => "SRF Play";
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string Key => "SRFPlayResumeCleanup";
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Starting SRF Play Continue Watching cleanup");
|
||||||
|
progress?.Report(0);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = _resumeCleanupService.Cleanup(false, cancellationToken);
|
||||||
|
|
||||||
|
progress?.Report(100);
|
||||||
|
_logger.LogInformation(
|
||||||
|
"SRF Play Continue Watching cleanup completed. Cleared {Cleared} of {Inspected} resume points",
|
||||||
|
result.Cleared,
|
||||||
|
result.Inspected);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("SRF Play Continue Watching cleanup was cancelled");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error during SRF Play Continue Watching cleanup");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
|
||||||
|
{
|
||||||
|
// Runs after the 3 AM expiration check so items deleted there are already gone.
|
||||||
|
return new[]
|
||||||
|
{
|
||||||
|
new TaskTriggerInfo
|
||||||
|
{
|
||||||
|
Type = TaskTriggerInfo.TriggerDaily,
|
||||||
|
TimeOfDayTicks = TimeSpan.FromHours(4).Ticks
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,6 +32,11 @@ public class ServiceRegistrator : IPluginServiceRegistrator
|
|||||||
serviceCollection.AddSingleton<IContentExpirationService, ContentExpirationService>();
|
serviceCollection.AddSingleton<IContentExpirationService, ContentExpirationService>();
|
||||||
serviceCollection.AddSingleton<IContentRefreshService, ContentRefreshService>();
|
serviceCollection.AddSingleton<IContentRefreshService, ContentRefreshService>();
|
||||||
serviceCollection.AddSingleton<ICategoryService, CategoryService>();
|
serviceCollection.AddSingleton<ICategoryService, CategoryService>();
|
||||||
|
serviceCollection.AddSingleton<IResumeCleanupService, ResumeCleanupService>();
|
||||||
|
|
||||||
|
// Clears livestream resume points the moment playback stops, so ended broadcasts never
|
||||||
|
// linger in "Continue Watching" waiting for the nightly cleanup task.
|
||||||
|
serviceCollection.AddHostedService<PlaybackResumeGuard>();
|
||||||
|
|
||||||
// Register metadata providers
|
// Register metadata providers
|
||||||
serviceCollection.AddSingleton<SRFSeriesProvider>();
|
serviceCollection.AddSingleton<SRFSeriesProvider>();
|
||||||
@@ -48,6 +53,7 @@ public class ServiceRegistrator : IPluginServiceRegistrator
|
|||||||
serviceCollection.AddSingleton<IScheduledTask, ContentRefreshTask>();
|
serviceCollection.AddSingleton<IScheduledTask, ContentRefreshTask>();
|
||||||
serviceCollection.AddSingleton<IScheduledTask, ExpirationCheckTask>();
|
serviceCollection.AddSingleton<IScheduledTask, ExpirationCheckTask>();
|
||||||
serviceCollection.AddSingleton<IScheduledTask, RecordingSchedulerTask>();
|
serviceCollection.AddSingleton<IScheduledTask, RecordingSchedulerTask>();
|
||||||
|
serviceCollection.AddSingleton<IScheduledTask, ResumeCleanupTask>();
|
||||||
|
|
||||||
// Register one channel (tile) per SRG business unit. Each must be registered as IChannel
|
// Register one channel (tile) per SRG business unit. Each must be registered as IChannel
|
||||||
// for Jellyfin to discover it. A unit that is not in EnabledBusinessUnits returns no
|
// for Jellyfin to discover it. A unit that is not in EnabledBusinessUnits returns no
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using Jellyfin.Data.Entities;
|
||||||
|
using Jellyfin.Plugin.SRFPlay.Api.Models;
|
||||||
|
using MediaBrowser.Controller.Entities;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes stale plugin-owned resume points so ended livestreams and abandoned playback
|
||||||
|
/// stop accumulating in every user's "Continue Watching" row.
|
||||||
|
/// </summary>
|
||||||
|
public interface IResumeCleanupService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Scans every user's resumable items and clears the resume point of those owned by this
|
||||||
|
/// plugin that match a staleness rule. Items belonging to other plugins or to the regular
|
||||||
|
/// library are never touched.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="clearAll">
|
||||||
|
/// When true every plugin-owned resume point is cleared regardless of the staleness rules.
|
||||||
|
/// Used by the "Clear all" maintenance action.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="cancellationToken">The cancellation token.</param>
|
||||||
|
/// <returns>A summary of what was inspected and cleared.</returns>
|
||||||
|
ResumeCleanupResult Cleanup(bool clearAll, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Clears the resume point of a single item for the given users.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="item">The item whose resume point should be reset.</param>
|
||||||
|
/// <param name="users">The users to clear it for.</param>
|
||||||
|
/// <param name="reason">A short reason, logged for traceability.</param>
|
||||||
|
/// <returns>The number of resume points actually cleared.</returns>
|
||||||
|
int ClearResumePoint(BaseItem item, IEnumerable<User> users, string reason);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determines whether an item belongs to this plugin (an SRG channel item or a recording).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="item">The item to test.</param>
|
||||||
|
/// <returns>True if the item is owned by this plugin.</returns>
|
||||||
|
bool IsPluginItem(BaseItem item);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Clears the resume point if the item is one of this plugin's livestreams and livestream
|
||||||
|
/// cleanup is enabled. Called right after playback stops so an ended broadcast never reaches
|
||||||
|
/// "Continue Watching" in the first place, instead of waiting for the nightly task.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="item">The item that just stopped playing.</param>
|
||||||
|
/// <param name="users">The users the playback was attributed to.</param>
|
||||||
|
/// <returns>The number of resume points cleared.</returns>
|
||||||
|
int ClearLiveStreamResumePoint(BaseItem item, IEnumerable<User> users);
|
||||||
|
}
|
||||||
@@ -71,8 +71,9 @@ 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);
|
string RewriteVariantManifestUrls(string manifestContent, string baseProxyUrl, string queryParams, bool isLiveStream = false);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Cleans up old and expired stream mappings.
|
/// Cleans up old and expired stream mappings.
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ using Jellyfin.Plugin.SRFPlay.Api.Models;
|
|||||||
using Jellyfin.Plugin.SRFPlay.Configuration;
|
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 MediaBrowser.Controller;
|
using MediaBrowser.Controller;
|
||||||
using MediaBrowser.Model.Dto;
|
using MediaBrowser.Model.Dto;
|
||||||
using MediaBrowser.Model.Entities;
|
using MediaBrowser.Model.Entities;
|
||||||
@@ -66,7 +67,7 @@ public class MediaSourceFactory : IMediaSourceFactory
|
|||||||
// Past replays (ValidTo in the past) and upcoming events (ValidFrom in the future)
|
// 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.
|
// are treated as VOD to avoid IsInfiniteStream/IgnoreDts flags and FFmpeg -re mode.
|
||||||
var isScheduledLivestream = chapter.Type == "SCHEDULED_LIVESTREAM" ||
|
var isScheduledLivestream = chapter.Type == "SCHEDULED_LIVESTREAM" ||
|
||||||
urn.Contains("livestream", StringComparison.OrdinalIgnoreCase);
|
UrnHelper.IsLivestreamUrn(urn);
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
var isLiveStream = isScheduledLivestream &&
|
var isLiveStream = isScheduledLivestream &&
|
||||||
(chapter.ValidFrom == null || chapter.ValidFrom.Value.ToUniversalTime() <= now) &&
|
(chapter.ValidFrom == null || chapter.ValidFrom.Value.ToUniversalTime() <= now) &&
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
||||||
|
using MediaBrowser.Controller.Library;
|
||||||
|
using MediaBrowser.Controller.Session;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.SRFPlay.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Watches for livestream playback ending and immediately drops the resume point Jellyfin
|
||||||
|
/// just saved for it. Without this, every livestream a user stops watching sticks in
|
||||||
|
/// "Continue Watching" until the nightly cleanup task runs.
|
||||||
|
/// </summary>
|
||||||
|
public class PlaybackResumeGuard : IHostedService
|
||||||
|
{
|
||||||
|
private readonly ILogger<PlaybackResumeGuard> _logger;
|
||||||
|
private readonly ISessionManager _sessionManager;
|
||||||
|
private readonly IResumeCleanupService _resumeCleanupService;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="PlaybackResumeGuard"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="logger">The logger.</param>
|
||||||
|
/// <param name="sessionManager">The session manager raising playback events.</param>
|
||||||
|
/// <param name="resumeCleanupService">The resume cleanup service.</param>
|
||||||
|
public PlaybackResumeGuard(
|
||||||
|
ILogger<PlaybackResumeGuard> logger,
|
||||||
|
ISessionManager sessionManager,
|
||||||
|
IResumeCleanupService resumeCleanupService)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_sessionManager = sessionManager;
|
||||||
|
_resumeCleanupService = resumeCleanupService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task StartAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_sessionManager.PlaybackStopped += OnPlaybackStopped;
|
||||||
|
_logger.LogDebug("PlaybackResumeGuard attached to session manager");
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task StopAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_sessionManager.PlaybackStopped -= OnPlaybackStopped;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPlaybackStopped(object? sender, PlaybackStopEventArgs e)
|
||||||
|
{
|
||||||
|
// Jellyfin saves the resume point before raising this event, so clearing it here wins.
|
||||||
|
if (e?.Item == null || e.Users == null || e.Users.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var cleared = _resumeCleanupService.ClearLiveStreamResumePoint(e.Item, e.Users);
|
||||||
|
if (cleared > 0)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(
|
||||||
|
"Dropped {Count} resume point(s) for finished livestream '{Name}'",
|
||||||
|
cleared,
|
||||||
|
e.Item.Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Never let a cleanup failure escape into Jellyfin's playback event pipeline.
|
||||||
|
_logger.LogError(ex, "Error clearing livestream resume point for '{Name}'", e.Item.Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ using Jellyfin.Plugin.SRFPlay.Api;
|
|||||||
using Jellyfin.Plugin.SRFPlay.Api.Models;
|
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 Jellyfin.Plugin.SRFPlay.Utilities;
|
||||||
using MediaBrowser.Controller;
|
using MediaBrowser.Controller;
|
||||||
using MediaBrowser.Controller.MediaEncoding;
|
using MediaBrowser.Controller.MediaEncoding;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
@@ -147,13 +148,12 @@ public class RecordingService : IRecordingService, IDisposable
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<IReadOnlyList<PlayV3TvProgram>> GetUpcomingScheduleAsync(CancellationToken cancellationToken)
|
public async Task<IReadOnlyList<PlayV3TvProgram>> GetUpcomingScheduleAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var config = Plugin.Instance?.Configuration;
|
var units = System.Enum.GetValues<Configuration.BusinessUnit>();
|
||||||
var units = config?.ResolveEnabledUnits() ?? new[] { Configuration.BusinessUnit.SRF };
|
|
||||||
|
|
||||||
using var apiClient = _apiClientFactory.CreateClient();
|
using var apiClient = _apiClientFactory.CreateClient();
|
||||||
|
|
||||||
// Aggregate sport livestreams across every enabled business unit so the recordings
|
// Aggregate sport livestreams across every business unit so the recordings
|
||||||
// page shows events from all enabled languages.
|
// page shows events from all languages.
|
||||||
var all = new List<PlayV3TvProgram>();
|
var all = new List<PlayV3TvProgram>();
|
||||||
foreach (var unit in units)
|
foreach (var unit in units)
|
||||||
{
|
{
|
||||||
@@ -443,7 +443,7 @@ public class RecordingService : IRecordingService, IDisposable
|
|||||||
|
|
||||||
// Register the stream with the proxy so we can use the proxy URL
|
// Register the stream with the proxy so we can use the proxy URL
|
||||||
var itemId = $"rec_{entry.Id}";
|
var itemId = $"rec_{entry.Id}";
|
||||||
var isLiveStream = chapter.Type == "SCHEDULED_LIVESTREAM" || entry.Urn.Contains("livestream", StringComparison.OrdinalIgnoreCase);
|
var isLiveStream = chapter.Type == "SCHEDULED_LIVESTREAM" || UrnHelper.IsLivestreamUrn(entry.Urn);
|
||||||
_proxyService.RegisterStreamDeferred(itemId, streamUrl, entry.Urn, isLiveStream);
|
_proxyService.RegisterStreamDeferred(itemId, streamUrl, entry.Urn, isLiveStream);
|
||||||
|
|
||||||
// Build proxy URL for ffmpeg (use localhost for local access)
|
// Build proxy URL for ffmpeg (use localhost for local access)
|
||||||
|
|||||||
@@ -0,0 +1,302 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using Jellyfin.Data.Entities;
|
||||||
|
using Jellyfin.Data.Enums;
|
||||||
|
using Jellyfin.Plugin.SRFPlay.Api.Models;
|
||||||
|
using Jellyfin.Plugin.SRFPlay.Configuration;
|
||||||
|
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
||||||
|
using Jellyfin.Plugin.SRFPlay.Utilities;
|
||||||
|
using MediaBrowser.Controller.Dto;
|
||||||
|
using MediaBrowser.Controller.Entities;
|
||||||
|
using MediaBrowser.Controller.Library;
|
||||||
|
using MediaBrowser.Model.Entities;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.SRFPlay.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes stale plugin-owned resume points from every user's "Continue Watching" row.
|
||||||
|
/// </summary>
|
||||||
|
public class ResumeCleanupService : IResumeCleanupService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Provider ID key stamped onto every item built from the SRG API.
|
||||||
|
/// </summary>
|
||||||
|
private const string SrfProviderId = "SRF";
|
||||||
|
|
||||||
|
private readonly ILogger<ResumeCleanupService> _logger;
|
||||||
|
private readonly ILibraryManager _libraryManager;
|
||||||
|
private readonly IUserManager _userManager;
|
||||||
|
private readonly IUserDataManager _userDataManager;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="ResumeCleanupService"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="loggerFactory">The logger factory.</param>
|
||||||
|
/// <param name="libraryManager">The library manager.</param>
|
||||||
|
/// <param name="userManager">The user manager.</param>
|
||||||
|
/// <param name="userDataManager">The user data manager.</param>
|
||||||
|
public ResumeCleanupService(
|
||||||
|
ILoggerFactory loggerFactory,
|
||||||
|
ILibraryManager libraryManager,
|
||||||
|
IUserManager userManager,
|
||||||
|
IUserDataManager userDataManager)
|
||||||
|
{
|
||||||
|
_logger = loggerFactory.CreateLogger<ResumeCleanupService>();
|
||||||
|
_libraryManager = libraryManager;
|
||||||
|
_userManager = userManager;
|
||||||
|
_userDataManager = userDataManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public ResumeCleanupResult Cleanup(bool clearAll, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = new ResumeCleanupResult();
|
||||||
|
var config = Plugin.Instance?.Configuration;
|
||||||
|
|
||||||
|
if (config == null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Plugin configuration not available - skipping resume point cleanup");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!clearAll && !config.CleanUpResumePoints)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Resume point cleanup is disabled in configuration");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
var channelIds = GetPluginChannelIds();
|
||||||
|
|
||||||
|
foreach (var user in _userManager.Users)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
result.UsersChecked++;
|
||||||
|
|
||||||
|
var query = new InternalItemsQuery(user)
|
||||||
|
{
|
||||||
|
IsResumable = true,
|
||||||
|
Recursive = true,
|
||||||
|
EnableTotalRecordCount = false,
|
||||||
|
DtoOptions = new DtoOptions(false)
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var item in _libraryManager.GetItemList(query))
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
if (!IsPluginItem(item, channelIds))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Inspected++;
|
||||||
|
|
||||||
|
var userData = _userDataManager.GetUserData(user, item);
|
||||||
|
if (userData == null || userData.PlaybackPositionTicks <= 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var reason = clearAll ? "clear all" : GetStaleReason(item, userData, config);
|
||||||
|
if (reason == null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ClearResumePoint(item, user, userData, reason))
|
||||||
|
{
|
||||||
|
result.Cleared++;
|
||||||
|
result.ClearedByReason.TryGetValue(reason, out var count);
|
||||||
|
result.ClearedByReason[reason] = count + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Resume point cleanup finished: {Cleared} of {Inspected} SRF Play resume points cleared across {Users} user(s)",
|
||||||
|
result.Cleared,
|
||||||
|
result.Inspected,
|
||||||
|
result.UsersChecked);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public int ClearResumePoint(BaseItem item, IEnumerable<User> users, string reason)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(item);
|
||||||
|
ArgumentNullException.ThrowIfNull(users);
|
||||||
|
|
||||||
|
var cleared = 0;
|
||||||
|
|
||||||
|
foreach (var user in users)
|
||||||
|
{
|
||||||
|
var userData = _userDataManager.GetUserData(user, item);
|
||||||
|
if (userData == null || userData.PlaybackPositionTicks <= 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ClearResumePoint(item, user, userData, reason))
|
||||||
|
{
|
||||||
|
cleared++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cleared;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool IsPluginItem(BaseItem item)
|
||||||
|
{
|
||||||
|
return IsPluginItem(item, GetPluginChannelIds());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public int ClearLiveStreamResumePoint(BaseItem item, IEnumerable<User> users)
|
||||||
|
{
|
||||||
|
if (item == null || Plugin.Instance?.Configuration.ClearLiveStreamResumePoints != true)
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only the URN heuristic here - the item is already known, and re-querying the channel
|
||||||
|
// list on every playback stop would be wasteful.
|
||||||
|
if (!UrnHelper.IsLivestreamUrn(item.ProviderIds.GetValueOrDefault(SrfProviderId)))
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ClearResumePoint(item, users, "livestream stopped");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determines whether an item belongs to one of this plugin's channels.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="item">The item to test.</param>
|
||||||
|
/// <param name="channelIds">The set of channel IDs owned by this plugin.</param>
|
||||||
|
/// <returns>True if the item is owned by this plugin.</returns>
|
||||||
|
private static bool IsPluginItem(BaseItem item, HashSet<Guid> channelIds)
|
||||||
|
{
|
||||||
|
if (item == null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// API-sourced content carries the URN as a provider ID. Recordings do not, so fall back
|
||||||
|
// to the owning channel, which also covers any item shape added later.
|
||||||
|
return item.ProviderIds.ContainsKey(SrfProviderId)
|
||||||
|
|| (!item.ChannelId.Equals(Guid.Empty) && channelIds.Contains(item.ChannelId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves the IDs of the channel entries created by this plugin, one per business unit.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>The set of channel IDs, empty if the channels have not been scanned yet.</returns>
|
||||||
|
private HashSet<Guid> GetPluginChannelIds()
|
||||||
|
{
|
||||||
|
// SrgChannelBase names every channel "{unit} Play".
|
||||||
|
var names = Enum.GetNames<BusinessUnit>()
|
||||||
|
.Select(unit => unit + " Play")
|
||||||
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
var channels = _libraryManager.GetItemList(new InternalItemsQuery
|
||||||
|
{
|
||||||
|
IncludeItemTypes = new[] { BaseItemKind.Channel },
|
||||||
|
EnableTotalRecordCount = false,
|
||||||
|
DtoOptions = new DtoOptions(false)
|
||||||
|
});
|
||||||
|
|
||||||
|
return channels
|
||||||
|
.Where(channel => channel.Name != null && names.Contains(channel.Name))
|
||||||
|
.Select(channel => channel.Id)
|
||||||
|
.ToHashSet();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Decides whether a resume point is stale, and why.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="item">The item the resume point belongs to.</param>
|
||||||
|
/// <param name="userData">The user data holding the resume position.</param>
|
||||||
|
/// <param name="config">The plugin configuration supplying the thresholds.</param>
|
||||||
|
/// <returns>A short reason string, or null when the resume point should be kept.</returns>
|
||||||
|
private static string? GetStaleReason(BaseItem item, UserItemData userData, PluginConfiguration config)
|
||||||
|
{
|
||||||
|
// A livestream has no meaningful position to return to: by the time the user comes back
|
||||||
|
// the broadcast has moved on or ended. These are the entries that pile up the fastest.
|
||||||
|
if (config.ClearLiveStreamResumePoints
|
||||||
|
&& UrnHelper.IsLivestreamUrn(item.ProviderIds.GetValueOrDefault(SrfProviderId)))
|
||||||
|
{
|
||||||
|
return "livestream";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.ResumePointMaxAgeDays > 0
|
||||||
|
&& userData.LastPlayedDate.HasValue
|
||||||
|
&& userData.LastPlayedDate.Value.ToUniversalTime() < DateTime.UtcNow.AddDays(-config.ResumePointMaxAgeDays))
|
||||||
|
{
|
||||||
|
return "older than " + config.ResumePointMaxAgeDays.ToString(CultureInfo.InvariantCulture) + " days";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.ResumePointMinPositionSeconds > 0
|
||||||
|
&& userData.PlaybackPositionTicks <= TimeSpan.FromSeconds(config.ResumePointMinPositionSeconds).Ticks)
|
||||||
|
{
|
||||||
|
return "barely started";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runtime is unknown for live and for some API items; without it there is nothing to
|
||||||
|
// compare the position against, so leave the resume point alone.
|
||||||
|
if (config.ResumePointCompletedPercent > 0
|
||||||
|
&& item.RunTimeTicks.HasValue
|
||||||
|
&& item.RunTimeTicks.Value > 0)
|
||||||
|
{
|
||||||
|
var watchedPercent = userData.PlaybackPositionTicks * 100.0 / item.RunTimeTicks.Value;
|
||||||
|
if (watchedPercent >= config.ResumePointCompletedPercent)
|
||||||
|
{
|
||||||
|
return "watched to the end";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Zeroes the resume position, which removes the item from "Continue Watching" while
|
||||||
|
/// leaving it unwatched so it can still be played again from the start.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="item">The item to clear.</param>
|
||||||
|
/// <param name="user">The user to clear it for.</param>
|
||||||
|
/// <param name="userData">The user data to update.</param>
|
||||||
|
/// <param name="reason">A short reason, logged for traceability.</param>
|
||||||
|
/// <returns>True if the resume point was cleared.</returns>
|
||||||
|
private bool ClearResumePoint(BaseItem item, User user, UserItemData userData, string reason)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
userData.PlaybackPositionTicks = 0;
|
||||||
|
|
||||||
|
_userDataManager.SaveUserData(
|
||||||
|
user,
|
||||||
|
item,
|
||||||
|
userData,
|
||||||
|
UserDataSaveReason.UpdateUserData,
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Cleared resume point for '{Name}' (user {User}, reason: {Reason})",
|
||||||
|
item.Name,
|
||||||
|
user.Username,
|
||||||
|
reason);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to clear resume point for '{Name}'", item.Name);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -900,9 +900,15 @@ 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)
|
public string RewriteVariantManifestUrls(string manifestContent, string baseProxyUrl, string queryParams, bool isLiveStream = false)
|
||||||
{
|
{
|
||||||
|
if (isLiveStream)
|
||||||
|
{
|
||||||
|
manifestContent = InjectLiveStartOffset(manifestContent);
|
||||||
|
}
|
||||||
|
|
||||||
string RewriteUrl(string url)
|
string RewriteUrl(string url)
|
||||||
{
|
{
|
||||||
if (url.Contains("://", StringComparison.Ordinal))
|
if (url.Contains("://", StringComparison.Ordinal))
|
||||||
@@ -953,6 +959,82 @@ 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>
|
||||||
|
|||||||
@@ -24,4 +24,17 @@ public static class UrnHelper
|
|||||||
return guid.ToString();
|
return guid.ToString();
|
||||||
}
|
}
|
||||||
#pragma warning restore CA5351
|
#pragma warning restore CA5351
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determines whether a URN refers to a livestream (scheduled or continuous).
|
||||||
|
/// This is a URN-only heuristic that needs no API call, so it stays usable long after
|
||||||
|
/// the broadcast has ended and the media composition is no longer worth fetching.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="urn">The URN to inspect. May be null or empty.</param>
|
||||||
|
/// <returns>True if the URN identifies livestream content.</returns>
|
||||||
|
public static bool IsLivestreamUrn(string? urn)
|
||||||
|
{
|
||||||
|
return !string.IsNullOrEmpty(urn)
|
||||||
|
&& urn.Contains("livestream", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ Then install "SRF Play" from the plugin catalog.
|
|||||||
- **Live Sports Streaming** - Watch scheduled sports events (skiing, Formula 1, football, tennis, etc.)
|
- **Live Sports Streaming** - Watch scheduled sports events (skiing, Formula 1, football, tennis, etc.)
|
||||||
- Support for all Swiss broadcasting units (SRF, RTS, RSI, RTR, SWI)
|
- Support for all Swiss broadcasting units (SRF, RTS, RSI, RTR, SWI)
|
||||||
- Automatic content expiration handling
|
- Automatic content expiration handling
|
||||||
|
- Continue Watching cleanup — ended livestreams and abandoned playback don't pile up in the resume row
|
||||||
- Latest and trending content discovery
|
- Latest and trending content discovery
|
||||||
- Quality selection (Auto lets CDN decide, SD prefers 480p/360p, HD prefers 1080p/720p)
|
- Quality selection (Auto lets CDN decide, SD prefers 480p/360p, HD prefers 1080p/720p)
|
||||||
- HLS streaming support with Akamai token authentication
|
- HLS streaming support with Akamai token authentication
|
||||||
@@ -137,6 +138,16 @@ The compiled plugin will be in `bin/Debug/net8.0/`
|
|||||||
- **Proxy Address**: Proxy server URL (e.g., http://proxy.example.com:8080)
|
- **Proxy Address**: Proxy server URL (e.g., http://proxy.example.com:8080)
|
||||||
- **Proxy Username**: Optional authentication username
|
- **Proxy Username**: Optional authentication username
|
||||||
- **Proxy Password**: Optional authentication password
|
- **Proxy Password**: Optional authentication password
|
||||||
|
- **Continue Watching Cleanup**: Stops stale SRF Play entries accumulating in the resume row
|
||||||
|
- **Clean up stale resume points**: Enables the daily "Clean Up SRF Play Continue Watching" task (4 AM)
|
||||||
|
- **Never keep a resume point for livestreams**: Clears the position the moment a livestream stops
|
||||||
|
- **Maximum Resume Point Age**: Clear entries untouched for this many days (default 30, 0 disables)
|
||||||
|
- **Minimum Resume Position**: Clear entries at or below this position (default 60s, 0 disables)
|
||||||
|
- **Finished Threshold**: Clear entries at or beyond this share of the runtime (default 92%, 0 disables)
|
||||||
|
|
||||||
|
Clearing a resume point only resets the playback position. Nothing is deleted, the item stays
|
||||||
|
unwatched, and only SRF Play items are ever touched. The plugin config page also has
|
||||||
|
**Clean Up Stale Entries Now** and **Clear All SRF Play Entries** buttons for running it on demand.
|
||||||
|
|
||||||
For detailed proxy setup instructions, see [PROXY_SETUP_GUIDE.md](PROXY_SETUP_GUIDE.md).
|
For detailed proxy setup instructions, see [PROXY_SETUP_GUIDE.md](PROXY_SETUP_GUIDE.md).
|
||||||
|
|
||||||
@@ -270,7 +281,12 @@ The plugin includes:
|
|||||||
|
|
||||||
### Contributing
|
### Contributing
|
||||||
|
|
||||||
Contributions welcome!
|
Contributions welcome! This project is hosted on a self-hosted [Gitea](https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay) instance.
|
||||||
|
|
||||||
|
**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
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: "SRFPlay"
|
name: "SRFPlay"
|
||||||
guid: "eb5d7894-8eef-4b36-aa6f-5d124e828ce1"
|
guid: "a4b12f86-8c3d-4e9a-b7f2-1d5e6c8a9b4f"
|
||||||
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"
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
+15
-39
@@ -9,20 +9,28 @@
|
|||||||
"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",
|
"version": "1.1.0",
|
||||||
"changelog": "Release 1.0.32",
|
"changelog": "Release 1.1.0",
|
||||||
"targetAbi": "10.9.0.0",
|
"targetAbi": "10.9.0.0",
|
||||||
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.0.32/srfplay_1.0.32.0.zip",
|
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.1.0/srfplay_1.1.0.0.zip",
|
||||||
"checksum": "82f53848f2e2b15a35ea731ee69ee402",
|
"checksum": "2094b00b29f79e7625fcd3ebf046674f",
|
||||||
"timestamp": "2026-06-27T07:16:38Z"
|
"timestamp": "2026-06-27T09:33:04Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"version": "0.0.0.0",
|
"version": "0.0.0.0",
|
||||||
"changelog": "Latest Build",
|
"changelog": "Latest Build",
|
||||||
"targetAbi": "10.9.0.0",
|
"targetAbi": "10.9.0.0",
|
||||||
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/latest/srfplay_1.0.0.0.zip",
|
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/latest/srfplay_1.0.0.0.zip",
|
||||||
"checksum": "a3848128ff37d68f97493c33538a4d25",
|
"checksum": "2ed3281990b56fbfb5f75a1ee82a2f55",
|
||||||
"timestamp": "2026-06-27T07:15:26Z"
|
"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",
|
"version": "1.0.31",
|
||||||
@@ -32,14 +40,6 @@
|
|||||||
"checksum": "4e499de15687e6e328ddca41c6192485",
|
"checksum": "4e499de15687e6e328ddca41c6192485",
|
||||||
"timestamp": "2026-06-27T06:54:01Z"
|
"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",
|
"version": "1.0.30",
|
||||||
"changelog": "Release 1.0.30",
|
"changelog": "Release 1.0.30",
|
||||||
@@ -48,14 +48,6 @@
|
|||||||
"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",
|
||||||
@@ -64,14 +56,6 @@
|
|||||||
"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",
|
||||||
@@ -80,14 +64,6 @@
|
|||||||
"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",
|
||||||
|
|||||||
Reference in New Issue
Block a user