Compare commits

..
13 Commits
Author SHA1 Message Date
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
dtourolle 0a2d6a558c no dupplicate recordings
🏗️ Build Plugin / build (push) Failing after 28s
🧪 Test Plugin / test (push) Failing after 39s
🚀 Release Plugin / build-and-release (push) Failing after 38s
2026-03-07 16:25:01 +01:00
dtourolle fb539d6a32 Scheduling fixes 2026-03-07 16:24:29 +01:00
Gitea Actions 6ba5df6be9 Update manifest.json for version 1.0.25 2026-03-07 15:11:52 +00:00
dtourolle 33209c3b33 Fix bug in recoridng page
🏗️ Build Plugin / build (push) Successful in 43s
🧪 Test Plugin / test (push) Successful in 36s
🚀 Release Plugin / build-and-release (push) Successful in 52s
2026-03-07 16:09:37 +01:00
Gitea Actions 2177ef9814 Update manifest.json for version 1.0.24 2026-03-07 14:55:13 +00:00
5 changed files with 241 additions and 10 deletions
+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 }}
@@ -288,8 +288,9 @@
fetch(this.apiBase + '/All', { headers: this.getHeaders() }) fetch(this.apiBase + '/All', { headers: this.getHeaders() })
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
.then(function(recordings) { .then(function(recordings) {
var activeStates = ['Scheduled', 'WaitingForStream', 'Recording', 0, 1, 2];
var active = recordings.filter(function(r) { var active = recordings.filter(function(r) {
return r.state === 0 || r.state === 1 || r.state === 2; return activeStates.indexOf(r.state) !== -1;
}); });
if (active.length === 0) { if (active.length === 0) {
@@ -297,8 +298,8 @@
return; return;
} }
var stateLabels = {0: 'Scheduled', 1: 'Waiting', 2: 'Recording', 3: 'Completed', 4: 'Failed', 5: 'Cancelled'}; var stateLabels = {'Scheduled': 'Scheduled', 'WaitingForStream': 'Waiting', 'Recording': 'Recording', 'Completed': 'Completed', 'Failed': 'Failed', 'Cancelled': 'Cancelled', 0: 'Scheduled', 1: 'Waiting', 2: 'Recording', 3: 'Completed', 4: 'Failed', 5: 'Cancelled'};
var stateColors = {0: '#2196F3', 1: '#FF9800', 2: '#4CAF50', 4: '#f44336', 5: '#9E9E9E'}; var stateColors = {'Scheduled': '#2196F3', 'WaitingForStream': '#FF9800', 'Recording': '#4CAF50', 'Failed': '#f44336', 'Cancelled': '#9E9E9E', 0: '#2196F3', 1: '#FF9800', 2: '#4CAF50', 4: '#f44336', 5: '#9E9E9E'};
var html = '<table style="width:100%; border-collapse: collapse;">'; var html = '<table style="width:100%; border-collapse: collapse;">';
html += '<thead><tr style="text-align:left; border-bottom: 1px solid #444;">'; html += '<thead><tr style="text-align:left; border-bottom: 1px solid #444;">';
@@ -314,7 +315,7 @@
html += '<td style="padding: 8px;"><span style="color:' + (stateColors[r.state] || '#fff') + ';">' + (stateLabels[r.state] || r.state) + '</span></td>'; html += '<td style="padding: 8px;"><span style="color:' + (stateColors[r.state] || '#fff') + ';">' + (stateLabels[r.state] || r.state) + '</span></td>';
html += '<td style="padding: 8px;">' + SRFPlayRecordings.formatDate(r.validFrom) + '</td>'; html += '<td style="padding: 8px;">' + SRFPlayRecordings.formatDate(r.validFrom) + '</td>';
html += '<td style="padding: 8px;">'; html += '<td style="padding: 8px;">';
if (r.state === 2) { if (r.state === 2 || r.state === 'Recording') {
html += '<button is="emby-button" type="button" class="raised emby-button" '; html += '<button is="emby-button" type="button" class="raised emby-button" ';
html += 'onclick="SRFPlayRecordings.stopRecording(\'' + r.id + '\')"><span>Stop</span></button>'; html += 'onclick="SRFPlayRecordings.stopRecording(\'' + r.id + '\')"><span>Stop</span></button>';
} else { } else {
@@ -69,7 +69,7 @@ public class RecordingSchedulerTask : IScheduledTask
new TaskTriggerInfo new TaskTriggerInfo
{ {
Type = TaskTriggerInfo.TriggerInterval, Type = TaskTriggerInfo.TriggerInterval,
IntervalTicks = TimeSpan.FromMinutes(2).Ticks IntervalTicks = TimeSpan.FromSeconds(30).Ticks
} }
}; };
} }
@@ -14,6 +14,7 @@ using Jellyfin.Plugin.SRFPlay.Api.Models;
using Jellyfin.Plugin.SRFPlay.Api.Models.PlayV3; using Jellyfin.Plugin.SRFPlay.Api.Models.PlayV3;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces; using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using MediaBrowser.Controller; using MediaBrowser.Controller;
using MediaBrowser.Controller.MediaEncoding;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Services; namespace Jellyfin.Plugin.SRFPlay.Services;
@@ -29,9 +30,11 @@ public class RecordingService : IRecordingService, IDisposable
private readonly IStreamUrlResolver _streamUrlResolver; private readonly IStreamUrlResolver _streamUrlResolver;
private readonly IMediaCompositionFetcher _mediaCompositionFetcher; private readonly IMediaCompositionFetcher _mediaCompositionFetcher;
private readonly IServerApplicationHost _appHost; private readonly IServerApplicationHost _appHost;
private readonly IMediaEncoder _mediaEncoder;
private readonly ConcurrentDictionary<string, Process> _activeProcesses = new(); private readonly ConcurrentDictionary<string, Process> _activeProcesses = new();
private static readonly JsonSerializerOptions _jsonOptions = new() { WriteIndented = true }; private static readonly JsonSerializerOptions _jsonOptions = new() { WriteIndented = true };
private readonly SemaphoreSlim _persistLock = new(1, 1); private readonly SemaphoreSlim _persistLock = new(1, 1);
private readonly SemaphoreSlim _processLock = new(1, 1);
private List<RecordingEntry> _recordings = new(); private List<RecordingEntry> _recordings = new();
private bool _loaded; private bool _loaded;
private bool _disposed; private bool _disposed;
@@ -45,13 +48,15 @@ public class RecordingService : IRecordingService, IDisposable
/// <param name="streamUrlResolver">The stream URL resolver.</param> /// <param name="streamUrlResolver">The stream URL resolver.</param>
/// <param name="mediaCompositionFetcher">The media composition fetcher.</param> /// <param name="mediaCompositionFetcher">The media composition fetcher.</param>
/// <param name="appHost">The application host.</param> /// <param name="appHost">The application host.</param>
/// <param name="mediaEncoder">The media encoder for ffmpeg path.</param>
public RecordingService( public RecordingService(
ILogger<RecordingService> logger, ILogger<RecordingService> logger,
ISRFApiClientFactory apiClientFactory, ISRFApiClientFactory apiClientFactory,
IStreamProxyService proxyService, IStreamProxyService proxyService,
IStreamUrlResolver streamUrlResolver, IStreamUrlResolver streamUrlResolver,
IMediaCompositionFetcher mediaCompositionFetcher, IMediaCompositionFetcher mediaCompositionFetcher,
IServerApplicationHost appHost) IServerApplicationHost appHost,
IMediaEncoder mediaEncoder)
{ {
_logger = logger; _logger = logger;
_apiClientFactory = apiClientFactory; _apiClientFactory = apiClientFactory;
@@ -59,6 +64,7 @@ public class RecordingService : IRecordingService, IDisposable
_streamUrlResolver = streamUrlResolver; _streamUrlResolver = streamUrlResolver;
_mediaCompositionFetcher = mediaCompositionFetcher; _mediaCompositionFetcher = mediaCompositionFetcher;
_appHost = appHost; _appHost = appHost;
_mediaEncoder = mediaEncoder;
} }
private string GetDataFilePath() private string GetDataFilePath()
@@ -154,7 +160,7 @@ public class RecordingService : IRecordingService, IDisposable
// Filter to only future/current livestreams that aren't blocked // Filter to only future/current livestreams that aren't blocked
return livestreams 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) .OrderBy(ls => ls.ValidFrom)
.ToList(); .ToList();
} }
@@ -300,6 +306,25 @@ public class RecordingService : IRecordingService, IDisposable
/// <inheritdoc /> /// <inheritdoc />
public async Task ProcessRecordingsAsync(CancellationToken cancellationToken) public async Task ProcessRecordingsAsync(CancellationToken cancellationToken)
{
// Prevent overlapping scheduler runs from spawning duplicate ffmpeg processes
if (!await _processLock.WaitAsync(0, CancellationToken.None).ConfigureAwait(false))
{
_logger.LogDebug("ProcessRecordingsAsync already running, skipping");
return;
}
try
{
await ProcessRecordingsCoreAsync(cancellationToken).ConfigureAwait(false);
}
finally
{
_processLock.Release();
}
}
private async Task ProcessRecordingsCoreAsync(CancellationToken cancellationToken)
{ {
await LoadRecordingsAsync().ConfigureAwait(false); await LoadRecordingsAsync().ConfigureAwait(false);
@@ -308,13 +333,23 @@ public class RecordingService : IRecordingService, IDisposable
foreach (var entry in _recordings.ToList()) foreach (var entry in _recordings.ToList())
{ {
// Normalize ValidFrom/ValidTo to UTC for correct comparison
var validFromUtc = entry.ValidFrom.HasValue ? entry.ValidFrom.Value.ToUniversalTime() : (DateTime?)null;
var validToUtc = entry.ValidTo.HasValue ? entry.ValidTo.Value.ToUniversalTime() : (DateTime?)null;
switch (entry.State) switch (entry.State)
{ {
case RecordingState.Scheduled: case RecordingState.Scheduled:
case RecordingState.WaitingForStream: case RecordingState.WaitingForStream:
// Check if it's time to start recording // Check if it's time to start recording
if (entry.ValidFrom.HasValue && entry.ValidFrom.Value <= now.AddMinutes(2)) if (validFromUtc.HasValue && validFromUtc.Value <= now.AddMinutes(2))
{ {
_logger.LogInformation(
"Time to start recording '{Title}': ValidFrom={ValidFrom} (UTC: {ValidFromUtc}), Now={Now}",
entry.Title,
entry.ValidFrom,
validFromUtc,
now);
changed |= await TryStartRecordingAsync(entry, cancellationToken).ConfigureAwait(false); changed |= await TryStartRecordingAsync(entry, cancellationToken).ConfigureAwait(false);
} }
@@ -322,7 +357,7 @@ public class RecordingService : IRecordingService, IDisposable
case RecordingState.Recording: case RecordingState.Recording:
// Check if recording should stop (ValidTo reached or process died) // Check if recording should stop (ValidTo reached or process died)
if (entry.ValidTo.HasValue && entry.ValidTo.Value <= now) if (validToUtc.HasValue && validToUtc.Value <= now)
{ {
_logger.LogInformation("Recording '{Title}' reached ValidTo, stopping", entry.Title); _logger.LogInformation("Recording '{Title}' reached ValidTo, stopping", entry.Title);
StopFfmpeg(entry.Id); StopFfmpeg(entry.Id);
@@ -416,7 +451,7 @@ public class RecordingService : IRecordingService, IDisposable
{ {
StartInfo = new ProcessStartInfo StartInfo = new ProcessStartInfo
{ {
FileName = "ffmpeg", FileName = _mediaEncoder.EncoderPath,
Arguments = $"-y -i \"{inputUrl}\" -c copy -movflags +faststart \"{outputPath}\"", Arguments = $"-y -i \"{inputUrl}\" -c copy -movflags +faststart \"{outputPath}\"",
UseShellExecute = false, UseShellExecute = false,
RedirectStandardInput = true, RedirectStandardInput = true,
@@ -510,6 +545,7 @@ public class RecordingService : IRecordingService, IDisposable
} }
_persistLock.Dispose(); _persistLock.Dispose();
_processLock.Dispose();
} }
_disposed = true; _disposed = true;
+24
View File
@@ -8,6 +8,30 @@
"category": "Live TV", "category": "Live TV",
"imageUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/raw/branch/master/assests/main%20logo.png", "imageUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/raw/branch/master/assests/main%20logo.png",
"versions": [ "versions": [
{
"version": "0.0.0.0",
"changelog": "Latest Build",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/latest/srfplay_1.0.0.0.zip",
"checksum": "c7e868d23293adcc21d72e735094d9d6",
"timestamp": "2026-03-07T16:28:38Z"
},
{
"version": "1.0.25",
"changelog": "Release 1.0.25",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.0.25/srfplay_1.0.25.0.zip",
"checksum": "5e4599cfeee7e0845a1be30ec288cc0b",
"timestamp": "2026-03-07T15:11:52Z"
},
{
"version": "1.0.24",
"changelog": "Release 1.0.24",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.0.24/srfplay_1.0.24.0.zip",
"checksum": "f54dfb8cd9b555471859ffc89c35fb90",
"timestamp": "2026-03-07T14:55:13Z"
},
{ {
"version": "1.0.23", "version": "1.0.23",
"changelog": "Release 1.0.23", "changelog": "Release 1.0.23",