Build one plugin per Jellyfin generation (10.9, 10.11, 12)
🏗️ Build Plugin / build (push) Successful in 2m27s
Nightly Build / nightly-build (push) Failing after 1m18s
🧪 Test Plugin / test (push) Successful in 43s
🚀 Release Plugin / build-and-release (push) Failing after 1m12s

The single net8.0 build compiled against Jellyfin 10.9.11 still loads on
10.11 and 12, so channels and browsing keep working, but 10.11 changed
APIs in ways that only fail at runtime. A binary audit of the DLL against
the real server assemblies found 9 broken references on both 10.11 and
12 (none on 10.9/10.10):

- TaskTriggerInfo.Type became an enum. GetDefaultTriggers throws, so
  Jellyfin silently gives every SRF task a 24h fallback trigger. The
  recording scheduler therefore ran about once a day and scheduled
  recordings never started.
- ILibraryManager.GetItemList changed its return type: the expiration
  check crashed on every run.
- Jellyfin.Data.Entities.User moved (IUserManager.Users, GetUserData,
  SaveUserData, PlaybackProgressEventArgs.Users): resume cleanup returned
  500 and the playback-stop guard threw on every stop.

Jellyfin 12 also rejects the legacy X-Emby-Token header with an empty
401, which the recordings UI surfaced as "Unexpected end of JSON input".
The pages now send Authorization: MediaBrowser Token="...", verified on
10.9, 10.11 and 12.

The plugin now multi-targets net8.0/net9.0/net10.0 against Jellyfin
10.9.11/10.11.0/12.0.0 (the oldest package of each line, so each DLL
loads on every patch release of it), with JELLYFIN_10_11_OR_GREATER and
JELLYFIN_12_OR_GREATER for the differences. Trigger construction moves
into Utilities/TaskTriggers so the version switch lives in one place.

.gitea/scripts/build-plugins.sh builds one zip per generation. jprm has
no targetAbi flag, so it rewrites build.yaml per flavour and restores
it. Jellyfin installs the highest version whose targetAbi it satisfies,
so the flavour code goes into the last version segment:

  release v1.2.0 -> 1.2.0.9 / 1.2.0.11 / 1.2.0.12
  nightly        -> 1.0.<date>.<run>09 / 11 / 12

The PR, nightly and release workflows use the script and publish one
manifest entry per zip. Verified end to end against a three-entry
manifest: 10.11 installs the .11 build, 12.1 installs the .12 build.
The existing .NET 10 builder image builds all three unchanged.

Supporting changes: the test project multi-targets too, because jprm
publishes the whole solution for one framework at a time, and it drops
its Microsoft.Extensions.Logging 8.0.1 pin, which is a NU1605 downgrade
under the newer Jellyfin packages. CA1873 (new in the .NET 10 analyzers)
is silenced beside CA1848. The release manifest step checks out
origin/master like the nightly does since 7e1973a.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-24 01:42:38 +02:00
co-authored by Claude Opus 5.5
parent dc2dd09e49
commit 582c94f5f9
17 changed files with 251 additions and 152 deletions
@@ -257,7 +257,7 @@
fetch(ApiClient.serverAddress() + '/Plugins/SRFPlay/Maintenance/ResumePoints/Cleanup?clearAll=' + (clearAll ? 'true' : 'false'), {
method: 'POST',
headers: { 'X-Emby-Token': ApiClient.accessToken() }
headers: { 'Authorization': 'MediaBrowser Token="' + ApiClient.accessToken() + '"' }
})
.then(function(response) {
if (!response.ok) {
@@ -285,7 +285,7 @@
getHeaders: function() {
return {
'X-Emby-Token': ApiClient.accessToken()
'Authorization': 'MediaBrowser Token="' + ApiClient.accessToken() + '"'
};
},
@@ -92,7 +92,7 @@
verifySession: function(session) {
var self = this;
fetch(this.serverUrl + '/System/Info', {
headers: { 'X-Emby-Token': this.token }
headers: { 'Authorization': 'MediaBrowser Token="' + this.token + '"' }
}).then(function(r) {
if (r.ok) {
document.getElementById('srfrecUserInfo').textContent = '(Server: ' + self.serverUrl + ')';
@@ -119,7 +119,7 @@
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Emby-Authorization': 'MediaBrowser Client="SRF Recordings", Device="Web", DeviceId="srfrec-' + Date.now() + '", Version="1.0.0"'
'Authorization': 'MediaBrowser Client="SRF Recordings", Device="Web", DeviceId="srfrec-' + Date.now() + '", Version="1.0.0"'
},
body: JSON.stringify({ Username: user, Pw: pass })
})
@@ -151,7 +151,7 @@
},
getHeaders: function() {
return { 'X-Emby-Token': this.token };
return { 'Authorization': 'MediaBrowser Token="' + this.token + '"' };
},
formatDate: function(dateStr) {
@@ -1,7 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<!--
One DLL per Jellyfin generation. The server binds to the Jellyfin API it was built
against, and 10.11 changed signatures (User moved, TaskTriggerInfo.Type became an enum,
GetItemList return type) that still compile from source but fail at runtime, so a single
net8.0 build silently half-works on newer servers. Each TFM pins the oldest package of its
line so it loads on every patch release of that line:
net8.0 -> Jellyfin 10.9 / 10.10 (targetAbi 10.9.0.0)
net9.0 -> Jellyfin 10.11 (targetAbi 10.11.0.0)
net10.0 -> Jellyfin 12 (targetAbi 12.0.0.0)
-->
<TargetFrameworks>net8.0;net9.0;net10.0</TargetFrameworks>
<RootNamespace>Jellyfin.Plugin.SRFPlay</RootNamespace>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
@@ -10,9 +20,30 @@
<CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet>
</PropertyGroup>
<ItemGroup>
<PropertyGroup Condition="'$(TargetFramework)' != 'net8.0'">
<DefineConstants>$(DefineConstants);JELLYFIN_10_11_OR_GREATER</DefineConstants>
</PropertyGroup>
<PropertyGroup Condition="'$(TargetFramework)' == 'net10.0'">
<DefineConstants>$(DefineConstants);JELLYFIN_12_OR_GREATER</DefineConstants>
</PropertyGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">
<PackageReference Include="Jellyfin.Controller" Version="10.9.11" />
<PackageReference Include="Jellyfin.Model" Version="10.9.11" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net9.0'">
<PackageReference Include="Jellyfin.Controller" Version="10.11.0" />
<PackageReference Include="Jellyfin.Model" Version="10.11.0" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net10.0'">
<PackageReference Include="Jellyfin.Controller" Version="12.0.0" />
<PackageReference Include="Jellyfin.Model" Version="12.0.0" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Socks5" Version="1.1.0" />
<PackageReference Include="SkiaSharp" Version="2.88.8" />
</ItemGroup>
@@ -104,11 +104,7 @@ public class ContentRefreshTask : IScheduledTask
// Run every X hours as configured
return new[]
{
new TaskTriggerInfo
{
Type = TaskTriggerInfo.TriggerInterval,
IntervalTicks = TimeSpan.FromHours(intervalHours).Ticks
}
TaskTriggers.Interval(TimeSpan.FromHours(intervalHours))
};
}
}
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using Jellyfin.Plugin.SRFPlay.Utilities;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging;
@@ -87,16 +88,8 @@ public class ExpirationCheckTask : IScheduledTask
// Run daily at 3 AM or every X hours as configured
return new[]
{
new TaskTriggerInfo
{
Type = TaskTriggerInfo.TriggerDaily,
TimeOfDayTicks = TimeSpan.FromHours(3).Ticks
},
new TaskTriggerInfo
{
Type = TaskTriggerInfo.TriggerInterval,
IntervalTicks = TimeSpan.FromHours(intervalHours).Ticks
}
TaskTriggers.Daily(TimeSpan.FromHours(3)),
TaskTriggers.Interval(TimeSpan.FromHours(intervalHours))
};
}
}
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using Jellyfin.Plugin.SRFPlay.Utilities;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging;
@@ -66,11 +67,7 @@ public class RecordingSchedulerTask : IScheduledTask
{
return new[]
{
new TaskTriggerInfo
{
Type = TaskTriggerInfo.TriggerInterval,
IntervalTicks = TimeSpan.FromSeconds(30).Ticks
}
TaskTriggers.Interval(TimeSpan.FromSeconds(30))
};
}
}
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using Jellyfin.Plugin.SRFPlay.Utilities;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging;
@@ -77,11 +78,7 @@ public class ResumeCleanupTask : IScheduledTask
// 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
}
TaskTriggers.Daily(TimeSpan.FromHours(4))
};
}
}
@@ -1,6 +1,10 @@
using System.Collections.Generic;
using System.Threading;
#if JELLYFIN_10_11_OR_GREATER
using Jellyfin.Database.Implementations.Entities;
#else
using Jellyfin.Data.Entities;
#endif
using Jellyfin.Plugin.SRFPlay.Api.Models;
using MediaBrowser.Controller.Entities;
@@ -3,7 +3,11 @@ using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading;
#if JELLYFIN_10_11_OR_GREATER
using Jellyfin.Database.Implementations.Entities;
#else
using Jellyfin.Data.Entities;
#endif
using Jellyfin.Data.Enums;
using Jellyfin.Plugin.SRFPlay.Api.Models;
using Jellyfin.Plugin.SRFPlay.Configuration;
@@ -71,7 +75,11 @@ public class ResumeCleanupService : IResumeCleanupService
var channelIds = GetPluginChannelIds();
#if JELLYFIN_12_OR_GREATER
foreach (var user in _userManager.GetUsers())
#else
foreach (var user in _userManager.Users)
#endif
{
cancellationToken.ThrowIfCancellationRequested();
result.UsersChecked++;
@@ -0,0 +1,49 @@
using System;
using MediaBrowser.Model.Tasks;
namespace Jellyfin.Plugin.SRFPlay.Utilities;
/// <summary>
/// Builds scheduled task triggers across Jellyfin versions.
/// Jellyfin 10.11 replaced the string constants on <see cref="TaskTriggerInfo"/> with the
/// <c>TaskTriggerInfoType</c> enum. A task whose GetDefaultTriggers throws is silently given a
/// 24h fallback trigger, so this must be built against the matching Jellyfin version.
/// </summary>
public static class TaskTriggers
{
/// <summary>
/// Creates a trigger that fires repeatedly at a fixed interval.
/// </summary>
/// <param name="interval">The interval between runs.</param>
/// <returns>The trigger.</returns>
public static TaskTriggerInfo Interval(TimeSpan interval)
{
return new TaskTriggerInfo
{
#if JELLYFIN_10_11_OR_GREATER
Type = TaskTriggerInfoType.IntervalTrigger,
#else
Type = TaskTriggerInfo.TriggerInterval,
#endif
IntervalTicks = interval.Ticks
};
}
/// <summary>
/// Creates a trigger that fires once a day at the given time.
/// </summary>
/// <param name="timeOfDay">The time of day to run at.</param>
/// <returns>The trigger.</returns>
public static TaskTriggerInfo Daily(TimeSpan timeOfDay)
{
return new TaskTriggerInfo
{
#if JELLYFIN_10_11_OR_GREATER
Type = TaskTriggerInfoType.DailyTrigger,
#else
Type = TaskTriggerInfo.TriggerDaily,
#endif
TimeOfDayTicks = timeOfDay.Ticks
};
}
}