Compare commits

..
22 Commits
Author SHA1 Message Date
dtourolle c2feaae89c Fix really fix redirect
🏗️ Build Plugin / build (push) Successful in 29s
Latest Release / latest-release (push) Successful in 47s
🧪 Test Plugin / test (push) Successful in 28s
🚀 Release Plugin / build-and-release (push) Successful in 43s
2026-06-27 09:14:07 +02:00
Gitea Actions 35b103c056 Update manifest.json for version 1.0.31 2026-06-27 06:54:02 +00:00
Gitea Actions 7af1d9b8f4 Update manifest.json for latest build (10a76f8) 2026-06-27 06:52:52 +00:00
dtourolle 10a76f8d45 Fix settings no longer redirect.
🏗️ Build Plugin / build (push) Successful in 1m16s
Latest Release / latest-release (push) Successful in 42s
🧪 Test Plugin / test (push) Successful in 27s
🚀 Release Plugin / build-and-release (push) Successful in 42s
2026-06-27 08:50:49 +02:00
Gitea Actions a39a10636b Update manifest.json for version 1.0.30 2026-05-03 16:45:02 +00:00
Gitea Actions 029fb78049 Update manifest.json for latest build (729f82b) 2026-05-03 16:39:53 +00:00
dtourolle 729f82b26d More tizen based changes
🏗️ Build Plugin / build (push) Successful in 56s
Latest Release / latest-release (push) Successful in 2m28s
🧪 Test Plugin / test (push) Successful in 52s
🚀 Release Plugin / build-and-release (push) Successful in 1m1s
2026-05-03 18:36:26 +02:00
Gitea Actions aad0c21d4b Update manifest.json for version 1.0.29 2026-05-03 16:07:24 +00:00
Gitea Actions 7a52b3da3b Update manifest.json for latest build (80598ea) 2026-05-03 16:05:38 +00:00
dtourolle 80598ea8cb Fix CI
🏗️ Build Plugin / build (push) Successful in 2m40s
Latest Release / latest-release (push) Successful in 1m22s
🧪 Test Plugin / test (push) Successful in 46s
🚀 Release Plugin / build-and-release (push) Successful in 1m1s
2026-05-03 17:10:46 +02:00
dtourolle 24f53ef13e Add support for Tizen via transcoding
🏗️ Build Plugin / build (push) Failing after 15m44s
Latest Release / latest-release (push) Has been cancelled
🧪 Test Plugin / test (push) Has been cancelled
🚀 Release Plugin / build-and-release (push) Failing after 3m23s
2026-05-03 10:41:27 +02:00
Gitea Actions 86b22c69d7 Update manifest.json for latest build (8ffa0a0) 2026-03-07 17:41:15 +00:00
dtourolle 8ffa0a0f76 Add control page for downloads
🏗️ Build Plugin / build (push) Successful in 44s
Latest Release / latest-release (push) Successful in 53s
🧪 Test Plugin / test (push) Successful in 36s
2026-03-07 18:39:32 +01:00
Gitea Actions 92f315f6f6 Update manifest.json for version 1.0.27 2026-03-07 16:40:18 +00:00
Gitea Actions aa1ccbe458 Update manifest.json for latest build (702a5ab)
🚀 Release Plugin / build-and-release (push) Successful in 44s
2026-03-07 16:28:38 +00:00
dtourolle 702a5abdc5 fix time zone mix.up
🏗️ Build Plugin / build (push) Successful in 44s
Latest Release / latest-release (push) Successful in 53s
🧪 Test Plugin / test (push) Successful in 36s
2026-03-07 17:26:59 +01:00
Gitea Actions cdab2d76a7 Update manifest.json for latest build (9202ab6) 2026-03-07 15:59:58 +00:00
dtourolle 9202ab62ed fix CI
🏗️ Build Plugin / build (push) Successful in 45s
Latest Release / latest-release (push) Successful in 52s
🧪 Test Plugin / test (push) Successful in 39s
2026-03-07 16:58:13 +01:00
dtourolle 1b23e203ce CI fix
🏗️ Build Plugin / build (push) Successful in 45s
Latest Release / latest-release (push) Failing after 53s
🧪 Test Plugin / test (push) Successful in 36s
2026-03-07 16:39:38 +01:00
dtourolle 611fb52d76 CI fix
🏗️ Build Plugin / build (push) Successful in 44s
Latest Release / latest-release (push) Failing after 52s
🧪 Test Plugin / test (push) Successful in 37s
2026-03-07 16:35:12 +01:00
dtourolle 3336bac3fb latest make jeelyfin release
🏗️ Build Plugin / build (push) Successful in 46s
Latest Release / latest-release (push) Failing after 50s
🧪 Test Plugin / test (push) Successful in 37s
2026-03-07 16:31:53 +01:00
dtourolle 462c8a7c7b latest build pipeline 2026-03-07 16:29:57 +01:00
16 changed files with 734 additions and 42 deletions
+7
View File
@@ -25,6 +25,13 @@ jobs:
with:
path: build-${{ github.run_id }}
- name: Cache NuGet packages
uses: actions/cache@v3
with:
path: ~/.nuget/packages
key: nuget-${{ hashFiles('**/Jellyfin.Plugin.SRFPlay.csproj', '**/Jellyfin.Plugin.SRFPlay.Tests.csproj') }}
restore-keys: nuget-
- name: Restore dependencies
working-directory: build-${{ github.run_id }}
run: dotnet restore Jellyfin.Plugin.SRFPlay.sln
+170
View File
@@ -0,0 +1,170 @@
name: 'Latest Release'
on:
push:
branches:
- master
paths-ignore:
- '**/*.md'
- 'manifest.json'
jobs:
latest-release:
runs-on: linux/amd64
container:
image: gitea.tourolle.paris/dtourolle/srfplay-builder:latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
path: build-${{ github.run_id }}
- name: Restore dependencies
working-directory: build-${{ github.run_id }}
run: dotnet restore Jellyfin.Plugin.SRFPlay.sln
- name: Build solution
working-directory: build-${{ github.run_id }}
run: dotnet build Jellyfin.Plugin.SRFPlay.sln --configuration Release --no-restore --no-self-contained /m:1
- name: Run tests
working-directory: build-${{ github.run_id }}
run: dotnet test Jellyfin.Plugin.SRFPlay.sln --no-build --configuration Release --verbosity normal
- name: Build Jellyfin Plugin
id: jprm
working-directory: build-${{ github.run_id }}
run: |
mkdir -p artifacts
jprm --verbosity=debug plugin build .
ARTIFACT=$(find . -name "*.zip" -type f -print -quit | sed 's|^\./||')
ARTIFACT_NAME=$(basename "${ARTIFACT}")
echo "artifact=${ARTIFACT}" >> $GITHUB_OUTPUT
echo "artifact_name=${ARTIFACT_NAME}" >> $GITHUB_OUTPUT
echo "Found artifact: ${ARTIFACT}"
- name: Calculate checksum
id: checksum
working-directory: build-${{ github.run_id }}
run: |
CHECKSUM=$(md5sum "${{ steps.jprm.outputs.artifact }}" | awk '{print $1}')
echo "checksum=${CHECKSUM}" >> $GITHUB_OUTPUT
echo "Checksum: ${CHECKSUM}"
- name: Delete existing latest release
working-directory: build-${{ github.run_id }}
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
REPO_OWNER="${{ github.repository_owner }}"
REPO_NAME="${{ github.event.repository.name }}"
GITEA_URL="${{ github.server_url }}"
TAG="latest"
EXISTING=$(curl -s -o /dev/null -w "%{http_code}" \
-H "Authorization: token ${GITEA_TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/tags/${TAG}")
if [ "$EXISTING" = "200" ]; then
RELEASE_ID=$(curl -s \
-H "Authorization: token ${GITEA_TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/tags/${TAG}" | jq -r '.id')
echo "Deleting existing latest release (ID: ${RELEASE_ID})..."
curl -s -X DELETE \
-H "Authorization: token ${GITEA_TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}"
fi
curl -s -X DELETE \
-H "Authorization: token ${GITEA_TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/tags/${TAG}" || true
- name: Create latest release
working-directory: build-${{ github.run_id }}
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
REPO_OWNER="${{ github.repository_owner }}"
REPO_NAME="${{ github.event.repository.name }}"
GITEA_URL="${{ github.server_url }}"
RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases" \
-d "$(jq -n --arg tag "latest" --arg name "Latest Build" --arg body "SRFPlay Jellyfin Plugin latest build from master." '{tag_name: $tag, name: $name, body: $body, target_commitish: "master", draft: false, prerelease: true}')")
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
BODY=$(echo "$RESPONSE" | sed '$d')
if [ "$HTTP_CODE" -ge 200 ] && [ "$HTTP_CODE" -lt 300 ]; then
RELEASE_ID=$(echo "$BODY" | jq -r '.id')
echo "Created release with ID: ${RELEASE_ID}"
else
echo "Failed to create release. HTTP ${HTTP_CODE}"
echo "$BODY"
exit 1
fi
# Upload plugin artifact
echo "Uploading plugin artifact..."
curl -f -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/zip" \
--data-binary "@${{ steps.jprm.outputs.artifact }}" \
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}/assets?name=${{ steps.jprm.outputs.artifact_name }}"
# Upload build.yaml
echo "Uploading build.yaml..."
curl -f -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/x-yaml" \
--data-binary "@build.yaml" \
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}/assets?name=build.yaml"
echo "Latest release updated successfully!"
- name: Update manifest.json
working-directory: build-${{ github.run_id }}
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
REPO_OWNER="${{ github.repository_owner }}"
REPO_NAME="${{ github.event.repository.name }}"
GITEA_URL="${{ github.server_url }}"
CHECKSUM="${{ steps.checksum.outputs.checksum }}"
ARTIFACT_NAME="${{ steps.jprm.outputs.artifact_name }}"
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
DOWNLOAD_URL="${GITEA_URL}/${REPO_OWNER}/${REPO_NAME}/releases/download/latest/${ARTIFACT_NAME}"
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
git config user.name "Gitea Actions"
git config user.email "actions@gitea.tourolle.paris"
git fetch origin master
git checkout master
# Remove existing "latest" entry if present, then prepend new one
jq --arg url "$DOWNLOAD_URL" 'if .[0].versions[0].changelog == "Latest Build" then .[0].versions = .[0].versions[1:] else . end' manifest.json > manifest.tmp && mv manifest.tmp manifest.json
NEW_VERSION=$(cat <<EOF
{
"version": "0.0.0.0",
"changelog": "Latest Build",
"targetAbi": "10.9.0.0",
"sourceUrl": "${DOWNLOAD_URL}",
"checksum": "${CHECKSUM}",
"timestamp": "${TIMESTAMP}"
}
EOF
)
jq --argjson newver "${NEW_VERSION}" '.[0].versions = [$newver] + .[0].versions' manifest.json > manifest.tmp && mv manifest.tmp manifest.json
git add manifest.json
git commit -m "Update manifest.json for latest build (${SHORT_SHA})" || echo "No changes to commit"
git push origin master
- name: Cleanup
if: always()
run: rm -rf build-${{ github.run_id }}
+7
View File
@@ -42,6 +42,13 @@ jobs:
sed -i "s/^version:.*/version: \"${VERSION}\"/" build.yaml
cat build.yaml
- name: Cache NuGet packages
uses: actions/cache@v3
with:
path: ~/.nuget/packages
key: nuget-${{ hashFiles('**/Jellyfin.Plugin.SRFPlay.csproj', '**/Jellyfin.Plugin.SRFPlay.Tests.csproj') }}
restore-keys: nuget-
- name: Restore dependencies
working-directory: release-${{ github.run_id }}
run: dotnet restore Jellyfin.Plugin.SRFPlay.sln
+7
View File
@@ -27,6 +27,13 @@ jobs:
with:
path: test-${{ github.run_id }}
- name: Cache NuGet packages
uses: actions/cache@v3
with:
path: ~/.nuget/packages
key: nuget-${{ hashFiles('**/Jellyfin.Plugin.SRFPlay.csproj', '**/Jellyfin.Plugin.SRFPlay.Tests.csproj') }}
restore-keys: nuget-
- name: Restore dependencies
working-directory: test-${{ github.run_id }}
run: dotnet restore Jellyfin.Plugin.SRFPlay.sln
@@ -6,6 +6,7 @@ using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Api;
using Jellyfin.Plugin.SRFPlay.Api.Models;
using Jellyfin.Plugin.SRFPlay.Constants;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using Jellyfin.Plugin.SRFPlay.Utilities;
@@ -15,6 +16,7 @@ using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Channels;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.MediaInfo;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Channels;
@@ -30,6 +32,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
private readonly IMediaSourceFactory _mediaSourceFactory;
private readonly ICategoryService? _categoryService;
private readonly ISRFApiClientFactory _apiClientFactory;
private readonly IRecordingService _recordingService;
/// <summary>
/// Initializes a new instance of the <see cref="SRFPlayChannel"/> class.
@@ -40,13 +43,15 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
/// <param name="mediaSourceFactory">The media source factory.</param>
/// <param name="categoryService">The category service (optional).</param>
/// <param name="apiClientFactory">The API client factory.</param>
/// <param name="recordingService">The recording service.</param>
public SRFPlayChannel(
ILoggerFactory loggerFactory,
IContentRefreshService contentRefreshService,
IStreamUrlResolver streamResolver,
IMediaSourceFactory mediaSourceFactory,
ICategoryService? categoryService,
ISRFApiClientFactory apiClientFactory)
ISRFApiClientFactory apiClientFactory,
IRecordingService recordingService)
{
_logger = loggerFactory.CreateLogger<SRFPlayChannel>();
_contentRefreshService = contentRefreshService;
@@ -54,6 +59,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
_mediaSourceFactory = mediaSourceFactory;
_categoryService = categoryService;
_apiClientFactory = apiClientFactory;
_recordingService = recordingService;
if (_categoryService == null)
{
@@ -170,6 +176,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
"latest" => await GetLatestVideosAsync(cancellationToken).ConfigureAwait(false),
"trending" => await GetTrendingVideosAsync(cancellationToken).ConfigureAwait(false),
"live_sports" => await GetLiveSportsAsync(cancellationToken).ConfigureAwait(false),
"recordings" => GetRecordingItems(),
_ when folderId.StartsWith("category_", StringComparison.Ordinal) => await GetCategoryVideosAsync(folderId, cancellationToken).ConfigureAwait(false),
_ => new List<ChannelItemInfo>()
};
@@ -181,7 +188,8 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
{
CreateFolder("latest", "Latest Videos"),
CreateFolder("trending", "Trending Videos"),
CreateFolder("live_sports", "Live Sports & Events")
CreateFolder("live_sports", "Live Sports & Events"),
CreateFolder("recordings", "Recordings")
};
// Add category folders if enabled
@@ -296,6 +304,58 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
return items;
}
private List<ChannelItemInfo> GetRecordingItems()
{
var items = new List<ChannelItemInfo>();
var recordings = _recordingService.GetRecordings(RecordingState.Completed);
foreach (var recording in recordings)
{
if (string.IsNullOrEmpty(recording.OutputPath) || !System.IO.File.Exists(recording.OutputPath))
{
continue;
}
var fileInfo = new System.IO.FileInfo(recording.OutputPath);
var itemId = $"recording_{recording.Id}";
var mediaSource = new MediaSourceInfo
{
Id = itemId,
Name = recording.Title,
Path = recording.OutputPath,
Protocol = MediaProtocol.File,
Container = "mkv",
SupportsDirectPlay = true,
SupportsDirectStream = true,
SupportsTranscoding = true,
IsRemote = false,
Size = fileInfo.Length,
Type = MediaSourceType.Default
};
var item = new ChannelItemInfo
{
Id = itemId,
Name = recording.Title,
Overview = recording.Description,
Type = ChannelItemType.Media,
ContentType = ChannelMediaContentType.Movie,
MediaType = ChannelMediaType.Video,
DateCreated = recording.RecordingStartedAt,
ImageUrl = !string.IsNullOrEmpty(recording.ImageUrl)
? CreateProxiedImageUrl(recording.ImageUrl, _mediaSourceFactory.GetServerBaseUrl())
: CreatePlaceholderImageUrl(recording.Title, _mediaSourceFactory.GetServerBaseUrl()),
MediaSources = new List<MediaSourceInfo> { mediaSource }
};
items.Add(item);
}
_logger.LogInformation("Returning {Count} completed recordings as channel items", items.Count);
return items;
}
private async Task<List<ChannelItemInfo>> GetCategoryVideosAsync(string folderId, CancellationToken cancellationToken)
{
var items = new List<ChannelItemInfo>();
@@ -411,7 +471,8 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
var timeBucket = new DateTime(now.Year, now.Month, now.Day, now.Hour, (now.Minute / 15) * 15, 0);
var timeKey = timeBucket.ToString("yyyy-MM-dd-HH-mm", CultureInfo.InvariantCulture);
return $"{config?.BusinessUnit}_{config?.EnableLatestContent}_{config?.EnableTrendingContent}_{config?.EnableCategoryFolders}_{enabledTopics}_{timeKey}";
var recordingCount = _recordingService.GetRecordings(RecordingState.Completed).Count;
return $"{config?.BusinessUnit}_{config?.EnableLatestContent}_{config?.EnableTrendingContent}_{config?.EnableCategoryFolders}_{enabledTopics}_{timeKey}_rec{recordingCount}";
}
private async Task<List<ChannelItemInfo>> ConvertUrnsToChannelItems(List<string> urns, CancellationToken cancellationToken)
@@ -553,8 +614,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
ProviderIds = new Dictionary<string, string>
{
{ "SRF", urn }
},
MediaSources = new List<MediaSourceInfo> { mediaSource }
}
};
// Add series info if available
@@ -1,10 +1,3 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>SRF Play</title>
</head>
<body>
<div id="SRFPlayConfigPage" data-role="page" class="page type-interior pluginConfigurationPage" data-require="emby-input,emby-button,emby-select,emby-checkbox">
<div data-role="content">
<div class="content-primary">
@@ -398,5 +391,3 @@
};
</script>
</div>
</body>
</html>
@@ -0,0 +1,346 @@
<div id="SRFPlayRecordingsPage" data-role="page" class="page type-interior pluginConfigurationPage"
data-require="emby-button,emby-input">
<style>
#SRFPlayRecordingsPage .srfrec-wrap { padding: 0 1em; }
#SRFPlayRecordingsPage table { width: 100%; border-collapse: collapse; margin: 10px 0 20px; }
#SRFPlayRecordingsPage th { text-align: left; padding: 10px 8px; border-bottom: 2px solid #444; color: #aaa; font-size: 0.85em; text-transform: uppercase; }
#SRFPlayRecordingsPage td { padding: 10px 8px; border-bottom: 1px solid #2a2a3e; }
#SRFPlayRecordingsPage tr:hover { background: rgba(255,255,255,0.04); }
#SRFPlayRecordingsPage .btn { padding: 6px 16px; border: none; border-radius: 4px; cursor: pointer; font-size: 0.85em; color: #fff; transition: opacity 0.2s; }
#SRFPlayRecordingsPage .btn:hover { opacity: 0.85; }
#SRFPlayRecordingsPage .btn-record { background: #4CAF50; }
#SRFPlayRecordingsPage .btn-stop { background: #FF9800; }
#SRFPlayRecordingsPage .btn-cancel { background: #9E9E9E; }
#SRFPlayRecordingsPage .btn-delete { background: #f44336; }
#SRFPlayRecordingsPage .btn-refresh { background: #2196F3; margin-bottom: 16px; }
#SRFPlayRecordingsPage .status { padding: 3px 8px; border-radius: 3px; font-size: 0.8em; font-weight: 600; color: #fff; }
#SRFPlayRecordingsPage .status-scheduled { background: #1565C0; }
#SRFPlayRecordingsPage .status-waiting { background: #E65100; }
#SRFPlayRecordingsPage .status-recording { background: #2E7D32; }
#SRFPlayRecordingsPage .status-failed { background: #C62828; }
#SRFPlayRecordingsPage .srfrec-msg { padding: 12px; color: #999; font-style: italic; }
#SRFPlayRecordingsPage .srfrec-error { color: #f44; }
#SRFPlayRecordingsPage .login-form { max-width: 400px; margin: 40px auto; }
#SRFPlayRecordingsPage .login-form input { width: 100%; padding: 10px; margin: 8px 0; background: #2a2a3e; border: 1px solid #444; color: #fff; border-radius: 4px; font-size: 1em; }
#SRFPlayRecordingsPage .login-form .btn { width: 100%; padding: 12px; font-size: 1em; margin-top: 12px; }
</style>
<div data-role="content">
<div class="content-primary srfrec-wrap">
<div id="srfrecLoginSection" style="display:none;">
<div class="login-form">
<h1>SRF Sport Recordings</h1>
<p class="fieldDescription">Sign in to your Jellyfin server</p>
<input type="text" id="srfrecLoginServer" placeholder="Server URL (e.g. http://192.168.1.50:8096)" />
<input type="text" id="srfrecLoginUser" placeholder="Username" />
<input type="password" id="srfrecLoginPass" placeholder="Password" />
<button type="button" class="btn btn-record" onclick="SRFRec.login()">Sign In</button>
<p id="srfrecLoginError" class="srfrec-error" style="margin-top:10px;"></p>
</div>
</div>
<div id="srfrecMainSection" style="display:none;">
<h1>SRF Sport Recordings</h1>
<p class="fieldDescription">Browse upcoming sport livestreams and schedule recordings. <span id="srfrecUserInfo"></span></p>
<h2>Upcoming Sport Livestreams</h2>
<button type="button" class="btn btn-refresh" onclick="SRFRec.loadSchedule()">Refresh Schedule</button>
<div id="srfrecScheduleContainer"><p class="srfrec-msg">Loading schedule...</p></div>
<h2>Scheduled &amp; Active Recordings</h2>
<div id="srfrecActiveRecordingsContainer"><p class="srfrec-msg">Loading...</p></div>
<h2>Completed Recordings</h2>
<button type="button" class="btn btn-refresh" onclick="SRFRec.loadRecordings()">Refresh</button>
<div id="srfrecCompletedRecordingsContainer"><p class="srfrec-msg">Loading...</p></div>
</div>
</div>
</div>
<script>
var SRFRec = {
serverUrl: '',
token: '',
init: function() {
// Check if we're inside Jellyfin's web client (ApiClient available)
if (typeof ApiClient !== 'undefined' && ApiClient.accessToken()) {
this.serverUrl = ApiClient.serverAddress();
this.token = ApiClient.accessToken();
this.showMain();
return;
}
// Check localStorage for saved session
var saved = localStorage.getItem('srfRecSession');
if (saved) {
try {
var session = JSON.parse(saved);
this.serverUrl = session.serverUrl;
this.token = session.token;
// Verify token still works
this.verifySession(session);
return;
} catch(e) { /* fall through to login */ }
}
// Show login form
var serverInput = document.getElementById('srfrecLoginServer');
serverInput.value = window.location.origin;
document.getElementById('srfrecLoginSection').style.display = 'block';
},
verifySession: function(session) {
var self = this;
fetch(this.serverUrl + '/System/Info', {
headers: { 'X-Emby-Token': this.token }
}).then(function(r) {
if (r.ok) {
document.getElementById('srfrecUserInfo').textContent = '(Server: ' + self.serverUrl + ')';
self.showMain();
} else {
localStorage.removeItem('srfRecSession');
document.getElementById('srfrecLoginSection').style.display = 'block';
}
}).catch(function() {
localStorage.removeItem('srfRecSession');
document.getElementById('srfrecLoginSection').style.display = 'block';
});
},
login: function() {
var self = this;
var server = document.getElementById('srfrecLoginServer').value.replace(/\/+$/, '');
var user = document.getElementById('srfrecLoginUser').value;
var pass = document.getElementById('srfrecLoginPass').value;
var errorEl = document.getElementById('srfrecLoginError');
errorEl.textContent = '';
fetch(server + '/Users/AuthenticateByName', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Emby-Authorization': 'MediaBrowser Client="SRF Recordings", Device="Web", DeviceId="srfrec-' + Date.now() + '", Version="1.0.0"'
},
body: JSON.stringify({ Username: user, Pw: pass })
})
.then(function(r) {
if (!r.ok) throw new Error('Authentication failed');
return r.json();
})
.then(function(data) {
self.serverUrl = server;
self.token = data.AccessToken;
localStorage.setItem('srfRecSession', JSON.stringify({
serverUrl: server,
token: data.AccessToken,
userName: data.User.Name
}));
document.getElementById('srfrecUserInfo').textContent = '(Signed in as ' + data.User.Name + ')';
self.showMain();
})
.catch(function(err) {
errorEl.textContent = err.message;
});
},
showMain: function() {
document.getElementById('srfrecLoginSection').style.display = 'none';
document.getElementById('srfrecMainSection').style.display = 'block';
this.loadSchedule();
this.loadRecordings();
},
getHeaders: function() {
return { 'X-Emby-Token': this.token };
},
formatDate: function(dateStr) {
if (!dateStr) return 'N/A';
var d = new Date(dateStr);
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
},
formatSize: function(bytes) {
if (!bytes) return '';
if (bytes > 1073741824) return (bytes / 1073741824).toFixed(1) + ' GB';
if (bytes > 1048576) return (bytes / 1048576).toFixed(0) + ' MB';
return (bytes / 1024).toFixed(0) + ' KB';
},
loadSchedule: function() {
var container = document.getElementById('srfrecScheduleContainer');
container.innerHTML = '<p class="srfrec-msg">Loading schedule...</p>';
fetch(this.serverUrl + '/Plugins/SRFPlay/Recording/Schedule', { headers: this.getHeaders() })
.then(function(r) { return r.json(); })
.then(function(programs) {
if (!programs || programs.length === 0) {
container.innerHTML = '<p class="srfrec-msg">No upcoming sport livestreams found.</p>';
return;
}
var html = '<table>';
html += '<thead><tr><th>Title</th><th>Start</th><th>End</th><th>Action</th></tr></thead><tbody>';
programs.forEach(function(p) {
html += '<tr>';
html += '<td>' + (p.title || 'Unknown') + '</td>';
html += '<td>' + SRFRec.formatDate(p.validFrom || p.date) + '</td>';
html += '<td>' + SRFRec.formatDate(p.validTo) + '</td>';
html += '<td><button type="button" class="btn btn-record" onclick="SRFRec.scheduleRecording(\'' + encodeURIComponent(p.urn) + '\')">Record</button></td>';
html += '</tr>';
});
html += '</tbody></table>';
container.innerHTML = html;
})
.catch(function(err) {
container.innerHTML = '<p class="srfrec-error">Error loading schedule: ' + err.message + '</p>';
});
},
scheduleRecording: function(encodedUrn) {
var self = this;
fetch(this.serverUrl + '/Plugins/SRFPlay/Recording/Schedule/' + encodedUrn, {
method: 'POST',
headers: this.getHeaders()
})
.then(function(r) {
if (r.ok) {
alert('Recording scheduled!');
self.loadRecordings();
} else {
alert('Failed to schedule recording');
}
})
.catch(function(err) { alert('Error: ' + err.message); });
},
loadRecordings: function() {
this.loadActiveRecordings();
this.loadCompletedRecordings();
},
loadActiveRecordings: function() {
var container = document.getElementById('srfrecActiveRecordingsContainer');
container.innerHTML = '<p class="srfrec-msg">Loading...</p>';
fetch(this.serverUrl + '/Plugins/SRFPlay/Recording/All', { headers: this.getHeaders() })
.then(function(r) { return r.json(); })
.then(function(recordings) {
var activeStates = ['Scheduled', 'WaitingForStream', 'Recording', 0, 1, 2];
var active = recordings.filter(function(r) {
return activeStates.indexOf(r.state) !== -1;
});
if (active.length === 0) {
container.innerHTML = '<p class="srfrec-msg">No scheduled or active recordings.</p>';
return;
}
var stateMap = {
'Scheduled': {label: 'Scheduled', cls: 'status-scheduled'},
'WaitingForStream': {label: 'Waiting', cls: 'status-waiting'},
'Recording': {label: 'Recording', cls: 'status-recording'},
0: {label: 'Scheduled', cls: 'status-scheduled'},
1: {label: 'Waiting', cls: 'status-waiting'},
2: {label: 'Recording', cls: 'status-recording'}
};
var html = '<table>';
html += '<thead><tr><th>Title</th><th>Status</th><th>Start</th><th>Action</th></tr></thead><tbody>';
active.forEach(function(r) {
var st = stateMap[r.state] || {label: r.state, cls: ''};
html += '<tr>';
html += '<td>' + (r.title || 'Unknown') + '</td>';
html += '<td><span class="status ' + st.cls + '">' + st.label + '</span></td>';
html += '<td>' + SRFRec.formatDate(r.validFrom) + '</td>';
html += '<td>';
if (r.state === 2 || r.state === 'Recording') {
html += '<button type="button" class="btn btn-stop" onclick="SRFRec.stopRecording(\'' + r.id + '\')">Stop</button>';
} else {
html += '<button type="button" class="btn btn-cancel" onclick="SRFRec.cancelRecording(\'' + r.id + '\')">Cancel</button>';
}
html += '</td></tr>';
});
html += '</tbody></table>';
container.innerHTML = html;
})
.catch(function(err) {
container.innerHTML = '<p class="srfrec-error">Error: ' + err.message + '</p>';
});
},
loadCompletedRecordings: function() {
var container = document.getElementById('srfrecCompletedRecordingsContainer');
container.innerHTML = '<p class="srfrec-msg">Loading...</p>';
fetch(this.serverUrl + '/Plugins/SRFPlay/Recording/Completed', { headers: this.getHeaders() })
.then(function(r) { return r.json(); })
.then(function(recordings) {
if (!recordings || recordings.length === 0) {
container.innerHTML = '<p class="srfrec-msg">No completed recordings.</p>';
return;
}
var html = '<table>';
html += '<thead><tr><th>Title</th><th>Recorded</th><th>Size</th><th>Action</th></tr></thead><tbody>';
recordings.forEach(function(r) {
html += '<tr>';
html += '<td>' + (r.title || 'Unknown') + '</td>';
html += '<td>' + SRFRec.formatDate(r.recordingStartedAt) + '</td>';
html += '<td>' + SRFRec.formatSize(r.fileSizeBytes) + '</td>';
html += '<td><button type="button" class="btn btn-delete" onclick="SRFRec.deleteRecording(\'' + r.id + '\')">Delete</button></td>';
html += '</tr>';
});
html += '</tbody></table>';
container.innerHTML = html;
})
.catch(function(err) {
container.innerHTML = '<p class="srfrec-error">Error: ' + err.message + '</p>';
});
},
stopRecording: function(id) {
var self = this;
fetch(this.serverUrl + '/Plugins/SRFPlay/Recording/Active/' + id + '/Stop', {
method: 'POST',
headers: this.getHeaders()
}).then(function() { self.loadRecordings(); });
},
cancelRecording: function(id) {
var self = this;
fetch(this.serverUrl + '/Plugins/SRFPlay/Recording/Schedule/' + id, {
method: 'DELETE',
headers: this.getHeaders()
}).then(function() { self.loadRecordings(); });
},
deleteRecording: function(id) {
if (!confirm('Delete this recording and its file?')) return;
var self = this;
fetch(this.serverUrl + '/Plugins/SRFPlay/Recording/Completed/' + id + '?deleteFile=true', {
method: 'DELETE',
headers: this.getHeaders()
}).then(function() { self.loadRecordings(); });
}
};
(function() {
var page = document.querySelector('#SRFPlayRecordingsPage');
if (page && typeof page.addEventListener === 'function' && typeof ApiClient !== 'undefined') {
// Inside the Jellyfin dashboard: defer to the page lifecycle so we
// never run during the config page's view and never hijack routing.
page.addEventListener('pageshow', function() { SRFRec.init(); });
} else {
// Standalone (e.g. TV/browser): initialise immediately.
SRFRec.init();
}
})();
</script>
</div>
@@ -1,3 +1,5 @@
using System.IO;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Api.Models;
@@ -33,6 +35,27 @@ public class RecordingController : ControllerBase
_recordingService = recordingService;
}
/// <summary>
/// Serves the recording manager page accessible to any authenticated user.
/// </summary>
/// <returns>The recording manager HTML page.</returns>
[HttpGet("Page")]
[AllowAnonymous]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public IActionResult GetRecordingPage()
{
var assembly = Assembly.GetExecutingAssembly();
var resourceStream = assembly.GetManifestResourceStream("Jellyfin.Plugin.SRFPlay.Configuration.recordingPage.html");
if (resourceStream == null)
{
return NotFound("Recording page not found");
}
return File(resourceStream, "text/html");
}
/// <summary>
/// Gets upcoming sport livestreams available for recording.
/// </summary>
@@ -160,7 +160,8 @@ public class StreamProxyController : ControllerBase
AddManifestCacheHeaders(actualItemId);
_logger.LogDebug("Returning master manifest for item {ItemId} ({Length} bytes)", itemId, manifestContent.Length);
return Content(manifestContent, "application/vnd.apple.mpegurl; charset=utf-8");
// Use application/x-mpegURL for broader compatibility (Samsung AVPlay requires it)
return Content(manifestContent, "application/x-mpegURL; charset=utf-8");
}
catch (Exception ex)
{
@@ -231,7 +232,7 @@ public class StreamProxyController : ControllerBase
AddManifestCacheHeaders(actualItemId, isVariantManifest: true);
_logger.LogDebug("Returning variant manifest for item {ItemId} ({Length} bytes)", itemId, rewrittenContent.Length);
return Content(rewrittenContent, "application/vnd.apple.mpegurl; charset=utf-8");
return Content(rewrittenContent, "application/x-mpegURL; charset=utf-8");
}
catch (Exception ex)
{
@@ -26,6 +26,8 @@
<ItemGroup>
<None Remove="Configuration\configPage.html" />
<EmbeddedResource Include="Configuration\configPage.html" />
<None Remove="Configuration\recordingPage.html" />
<EmbeddedResource Include="Configuration\recordingPage.html" />
</ItemGroup>
<ItemGroup>
+6
View File
@@ -46,6 +46,12 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
Name = Name,
EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.Configuration.configPage.html", GetType().Namespace)
}
// Note: the recordings manager is intentionally NOT registered as a plugin
// page. Plugin pages live under the admin Dashboard and are admin-gated, which
// both caused a redirect to the recordings page and made it inaccessible to
// normal users. Recordings are served as a standalone, user-accessible page at
// GET /Plugins/SRFPlay/Recording/Page (see RecordingController).
];
}
}
@@ -62,9 +62,15 @@ public class MediaSourceFactory : IMediaSourceFactory
return Task.FromResult<MediaSourceInfo?>(null);
}
// Detect if this is a live stream
var isLiveStream = chapter.Type == "SCHEDULED_LIVESTREAM" ||
// A scheduled livestream is only treated as live when the broadcast window is open.
// Past replays (ValidTo in the past) and upcoming events (ValidFrom in the future)
// are treated as VOD to avoid IsInfiniteStream/IgnoreDts flags and FFmpeg -re mode.
var isScheduledLivestream = chapter.Type == "SCHEDULED_LIVESTREAM" ||
urn.Contains("livestream", StringComparison.OrdinalIgnoreCase);
var now = DateTime.UtcNow;
var isLiveStream = isScheduledLivestream &&
(chapter.ValidFrom == null || chapter.ValidFrom.Value.ToUniversalTime() <= now) &&
(chapter.ValidTo == null || chapter.ValidTo.Value.ToUniversalTime() > now);
// Register stream with UNAUTHENTICATED URL - proxy will authenticate on-demand
// This avoids wasting 30-second tokens during category browsing
@@ -91,15 +97,15 @@ public class MediaSourceFactory : IMediaSourceFactory
Protocol = MediaProtocol.Http,
// Use "hls" to trigger hls.js player in web client
Container = "hls",
SupportsDirectStream = true,
SupportsDirectStream = false,
SupportsDirectPlay = true,
SupportsTranscoding = false,
SupportsTranscoding = true,
IsRemote = true,
Type = MediaSourceType.Default,
RunTimeTicks = chapter.Duration > 0 ? TimeSpan.FromMilliseconds(chapter.Duration).Ticks : null,
VideoType = VideoType.VideoFile,
IsInfiniteStream = isLiveStream,
// Don't use RequiresOpening - it forces Jellyfin to transcode which breaks token auth
// RequiresOpening = false: proxy handles auth directly, no need for Jellyfin to open the stream
RequiresOpening = false,
RequiresClosing = false,
// Disable probing - we provide stream info directly
@@ -160,7 +160,7 @@ public class RecordingService : IRecordingService, IDisposable
// Filter to only future/current livestreams that aren't blocked
return livestreams
.Where(ls => ls.Blocked != true && (ls.ValidTo == null || ls.ValidTo > DateTime.UtcNow))
.Where(ls => ls.Blocked != true && (ls.ValidTo == null || ls.ValidTo.Value.ToUniversalTime() > DateTime.UtcNow))
.OrderBy(ls => ls.ValidFrom)
.ToList();
}
@@ -308,7 +308,7 @@ public class RecordingService : IRecordingService, IDisposable
public async Task ProcessRecordingsAsync(CancellationToken cancellationToken)
{
// Prevent overlapping scheduler runs from spawning duplicate ffmpeg processes
if (!await _processLock.WaitAsync(0).ConfigureAwait(false))
if (!await _processLock.WaitAsync(0, CancellationToken.None).ConfigureAwait(false))
{
_logger.LogDebug("ProcessRecordingsAsync already running, skipping");
return;
@@ -333,13 +333,23 @@ public class RecordingService : IRecordingService, IDisposable
foreach (var entry in _recordings.ToList())
{
// Normalize ValidFrom/ValidTo to UTC for correct comparison
var validFromUtc = entry.ValidFrom.HasValue ? entry.ValidFrom.Value.ToUniversalTime() : (DateTime?)null;
var validToUtc = entry.ValidTo.HasValue ? entry.ValidTo.Value.ToUniversalTime() : (DateTime?)null;
switch (entry.State)
{
case RecordingState.Scheduled:
case RecordingState.WaitingForStream:
// Check if it's time to start recording
if (entry.ValidFrom.HasValue && entry.ValidFrom.Value <= now.AddMinutes(2))
if (validFromUtc.HasValue && validFromUtc.Value <= now.AddMinutes(2))
{
_logger.LogInformation(
"Time to start recording '{Title}': ValidFrom={ValidFrom} (UTC: {ValidFromUtc}), Now={Now}",
entry.Title,
entry.ValidFrom,
validFromUtc,
now);
changed |= await TryStartRecordingAsync(entry, cancellationToken).ConfigureAwait(false);
}
@@ -347,7 +357,7 @@ public class RecordingService : IRecordingService, IDisposable
case RecordingState.Recording:
// Check if recording should stop (ValidTo reached or process died)
if (entry.ValidTo.HasValue && entry.ValidTo.Value <= now)
if (validToUtc.HasValue && validToUtc.Value <= now)
{
_logger.LogInformation("Recording '{Title}' reached ValidTo, stopping", entry.Title);
StopFfmpeg(entry.Id);
@@ -656,21 +656,6 @@ public class StreamProxyService : IStreamProxyService
// Rewrite the manifest to replace Akamai URLs with proxy URLs
var rewrittenContent = RewriteManifestUrls(manifestContent, authenticatedUrl, baseProxyUrl);
// For live streams, inject #EXT-X-START to tell the player to start near the live edge
// Without this, players may start at the beginning of the sliding window and stutter
// as old segments get rotated out by the CDN
if (_streamMappings.TryGetValue(itemId, out var streamInfoForManifest) && streamInfoForManifest.IsLiveStream)
{
if (!rewrittenContent.Contains("#EXT-X-START", StringComparison.Ordinal))
{
rewrittenContent = rewrittenContent.Replace(
"#EXTM3U",
"#EXTM3U\n#EXT-X-START:TIME-OFFSET=-6,PRECISE=NO",
StringComparison.Ordinal);
_logger.LogDebug("Injected #EXT-X-START tag for live stream {ItemId}", itemId);
}
}
_logger.LogDebug("Rewritten manifest for item {ItemId} ({Length} bytes):\n{Content}", itemId, rewrittenContent.Length, rewrittenContent);
return rewrittenContent;
}
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
</packageSources>
</configuration>
+64
View File
@@ -8,6 +8,70 @@
"category": "Live TV",
"imageUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/raw/branch/master/assests/main%20logo.png",
"versions": [
{
"version": "1.0.31",
"changelog": "Release 1.0.31",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.0.31/srfplay_1.0.31.0.zip",
"checksum": "4e499de15687e6e328ddca41c6192485",
"timestamp": "2026-06-27T06:54:01Z"
},
{
"version": "0.0.0.0",
"changelog": "Latest Build",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/latest/srfplay_1.0.0.0.zip",
"checksum": "1a8b8b018ff6bcdb8da4783b74253fc4",
"timestamp": "2026-06-27T06:52:52Z"
},
{
"version": "1.0.30",
"changelog": "Release 1.0.30",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.0.30/srfplay_1.0.30.0.zip",
"checksum": "06731df9ba3d2dab53885c9c8ac95fa6",
"timestamp": "2026-05-03T16:44:57Z"
},
{
"version": "0.0.0.0",
"changelog": "Latest Build",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/latest/srfplay_1.0.0.0.zip",
"checksum": "834e9f57206eee47ec9607ef6f65d17b",
"timestamp": "2026-05-03T16:39:53Z"
},
{
"version": "1.0.29",
"changelog": "Release 1.0.29",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.0.29/srfplay_1.0.29.0.zip",
"checksum": "fb745388e64299497262d9ad370d8823",
"timestamp": "2026-05-03T16:07:24Z"
},
{
"version": "0.0.0.0",
"changelog": "Latest Build",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/latest/srfplay_1.0.0.0.zip",
"checksum": "7471a63c69deb5b9a31343bb6c49075f",
"timestamp": "2026-05-03T16:05:37Z"
},
{
"version": "1.0.27",
"changelog": "Release 1.0.27",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.0.27/srfplay_1.0.27.0.zip",
"checksum": "1e15e35452f7b82bf74d8c3560c15949",
"timestamp": "2026-03-07T16:40:17Z"
},
{
"version": "0.0.0.0",
"changelog": "Latest Build",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/latest/srfplay_1.0.0.0.zip",
"checksum": "c7e868d23293adcc21d72e735094d9d6",
"timestamp": "2026-03-07T16:28:38Z"
},
{
"version": "1.0.25",
"changelog": "Release 1.0.25",