Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 45dc748a84 | |||
| b5a0bd79a3 | |||
| fda59741b5 | |||
| 55c94f7d84 | |||
|
|
ceeef92150 |
@@ -33,10 +33,29 @@ jobs:
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
|
echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
# Annotated tag message becomes the changelog; build.yaml is the fallback.
|
# checkout leaves behind a lightweight ref pointing at the commit, and
|
||||||
|
# %(contents) on one of those yields the commit message. Re-fetch the
|
||||||
|
# real object so an annotated tag's message is what we read.
|
||||||
|
git fetch --force origin \
|
||||||
|
"refs/tags/${GITHUB_REF_NAME}:refs/tags/${GITHUB_REF_NAME}" || true
|
||||||
|
|
||||||
|
CHANGELOG=""
|
||||||
|
if [ "$(git cat-file -t "refs/tags/${GITHUB_REF_NAME}" 2>/dev/null || true)" = "tag" ]; then
|
||||||
|
# subject+body rather than %(contents) so a signature is left out.
|
||||||
|
CHANGELOG="$(git for-each-ref \
|
||||||
|
--format='%(contents:subject)%0a%0a%(contents:body)' \
|
||||||
|
"refs/tags/${GITHUB_REF_NAME}")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Lightweight tag, or an annotated tag with an empty message.
|
||||||
|
if [ -z "$(printf '%s' "$CHANGELOG" | tr -d '[:space:]')" ]; then
|
||||||
|
echo "no annotated tag message; falling back to build.yaml"
|
||||||
|
CHANGELOG="$(python -c "import yaml; print(yaml.safe_load(open('build.yaml')).get('changelog','').strip())")"
|
||||||
|
fi
|
||||||
|
|
||||||
{
|
{
|
||||||
echo "changelog<<CHANGELOG_EOF"
|
echo "changelog<<CHANGELOG_EOF"
|
||||||
git tag -l --format='%(contents)' "$GITHUB_REF_NAME"
|
printf '%s\n' "$CHANGELOG"
|
||||||
echo "CHANGELOG_EOF"
|
echo "CHANGELOG_EOF"
|
||||||
} >> "$GITHUB_OUTPUT"
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,22 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Security.Claims;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using IdentityModel.OidcClient;
|
using IdentityModel.OidcClient;
|
||||||
using Jellyfin.Data;
|
using Jellyfin.Data;
|
||||||
|
using Jellyfin.Database.Implementations.Entities;
|
||||||
using Jellyfin.Database.Implementations.Enums;
|
using Jellyfin.Database.Implementations.Enums;
|
||||||
using Jellyfin.Plugin.OidcAuth.Configuration;
|
using Jellyfin.Plugin.OidcAuth.Configuration;
|
||||||
|
using MediaBrowser.Common.Net;
|
||||||
using MediaBrowser.Controller.Authentication;
|
using MediaBrowser.Controller.Authentication;
|
||||||
|
using MediaBrowser.Controller.Configuration;
|
||||||
using MediaBrowser.Controller.Library;
|
using MediaBrowser.Controller.Library;
|
||||||
|
using MediaBrowser.Controller.Providers;
|
||||||
using MediaBrowser.Controller.Session;
|
using MediaBrowser.Controller.Session;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@@ -26,8 +34,24 @@ public class OidcAuthController : ControllerBase
|
|||||||
private static readonly TimeSpan StateLifetime = TimeSpan.FromMinutes(15);
|
private static readonly TimeSpan StateLifetime = TimeSpan.FromMinutes(15);
|
||||||
private static readonly ConcurrentDictionary<string, FlowState> States = new();
|
private static readonly ConcurrentDictionary<string, FlowState> States = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Profile pictures larger than this are ignored rather than written to disk.
|
||||||
|
/// </summary>
|
||||||
|
private const long MaxProfilePictureBytes = 5 * 1024 * 1024;
|
||||||
|
|
||||||
|
private static readonly Dictionary<string, string> ProfilePictureExtensions = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["image/jpeg"] = ".jpg",
|
||||||
|
["image/png"] = ".png",
|
||||||
|
["image/gif"] = ".gif",
|
||||||
|
["image/webp"] = ".webp"
|
||||||
|
};
|
||||||
|
|
||||||
private readonly IUserManager _userManager;
|
private readonly IUserManager _userManager;
|
||||||
private readonly ISessionManager _sessionManager;
|
private readonly ISessionManager _sessionManager;
|
||||||
|
private readonly IProviderManager _providerManager;
|
||||||
|
private readonly IServerConfigurationManager _serverConfigurationManager;
|
||||||
|
private readonly IHttpClientFactory _httpClientFactory;
|
||||||
private readonly ILogger<OidcAuthController> _logger;
|
private readonly ILogger<OidcAuthController> _logger;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -35,11 +59,23 @@ public class OidcAuthController : ControllerBase
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
|
/// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
|
||||||
/// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param>
|
/// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param>
|
||||||
|
/// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
|
||||||
|
/// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
|
||||||
|
/// <param name="httpClientFactory">Instance of the <see cref="IHttpClientFactory"/> interface.</param>
|
||||||
/// <param name="logger">Instance of the <see cref="ILogger{OidcAuthController}"/> interface.</param>
|
/// <param name="logger">Instance of the <see cref="ILogger{OidcAuthController}"/> interface.</param>
|
||||||
public OidcAuthController(IUserManager userManager, ISessionManager sessionManager, ILogger<OidcAuthController> logger)
|
public OidcAuthController(
|
||||||
|
IUserManager userManager,
|
||||||
|
ISessionManager sessionManager,
|
||||||
|
IProviderManager providerManager,
|
||||||
|
IServerConfigurationManager serverConfigurationManager,
|
||||||
|
IHttpClientFactory httpClientFactory,
|
||||||
|
ILogger<OidcAuthController> logger)
|
||||||
{
|
{
|
||||||
_userManager = userManager;
|
_userManager = userManager;
|
||||||
_sessionManager = sessionManager;
|
_sessionManager = sessionManager;
|
||||||
|
_providerManager = providerManager;
|
||||||
|
_serverConfigurationManager = serverConfigurationManager;
|
||||||
|
_httpClientFactory = httpClientFactory;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,6 +191,19 @@ public class OidcAuthController : ControllerBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Config.SyncProfilePicture)
|
||||||
|
{
|
||||||
|
// A broken or unreachable picture must never keep the user out.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await SyncProfilePictureAsync(user, result.User).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Could not sync the profile picture for {Username}", username);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await _userManager.UpdateUserAsync(user).ConfigureAwait(false);
|
await _userManager.UpdateUserAsync(user).ConfigureAwait(false);
|
||||||
|
|
||||||
flow.Username = username;
|
flow.Username = username;
|
||||||
@@ -203,6 +252,96 @@ public class OidcAuthController : ControllerBase
|
|||||||
return Ok(authResult);
|
return Ok(authResult);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Copies the provider's profile picture onto the Jellyfin user. The claim holds a
|
||||||
|
/// URL (Pocket ID points at /api/users/{id}/profile-picture.png), so it is fetched
|
||||||
|
/// anonymously — the access token is deliberately not sent to an address that
|
||||||
|
/// ultimately comes from a token payload.
|
||||||
|
/// </summary>
|
||||||
|
private async Task SyncProfilePictureAsync(User user, ClaimsPrincipal principal)
|
||||||
|
{
|
||||||
|
var claim = principal.FindFirst(Config.PictureClaim)?.Value;
|
||||||
|
if (string.IsNullOrWhiteSpace(claim))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Uri.TryCreate(claim, UriKind.Absolute, out var uri)
|
||||||
|
|| (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Ignoring profile picture for {Username}: '{Claim}' is not an http(s) URL", user.Username, claim);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var client = _httpClientFactory.CreateClient(NamedClient.Default);
|
||||||
|
using var response = await client.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Ignoring profile picture for {Username}: {Uri} returned {Status}",
|
||||||
|
user.Username,
|
||||||
|
uri,
|
||||||
|
(int)response.StatusCode);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var mimeType = response.Content.Headers.ContentType?.MediaType;
|
||||||
|
if (mimeType is null || !ProfilePictureExtensions.TryGetValue(mimeType, out var extension))
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Ignoring profile picture for {Username}: unsupported content type '{MimeType}'",
|
||||||
|
user.Username,
|
||||||
|
mimeType);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.Content.Headers.ContentLength > MaxProfilePictureBytes)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Ignoring profile picture for {Username}: {Length} bytes exceeds the {Limit} byte limit",
|
||||||
|
user.Username,
|
||||||
|
response.Content.Headers.ContentLength,
|
||||||
|
MaxProfilePictureBytes);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var image = await response.Content.ReadAsByteArrayAsync().ConfigureAwait(false);
|
||||||
|
if (image.Length == 0 || image.Length > MaxProfilePictureBytes)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Ignoring profile picture for {Username}: {Length} bytes", user.Username, image.Length);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var path = Path.Combine(
|
||||||
|
_serverConfigurationManager.ApplicationPaths.UserConfigurationDirectoryPath,
|
||||||
|
user.Username,
|
||||||
|
"profile" + extension);
|
||||||
|
|
||||||
|
// Rewriting an identical image on every login would churn the image cache.
|
||||||
|
if (user.ProfileImage is not null
|
||||||
|
&& string.Equals(user.ProfileImage.Path, path, StringComparison.Ordinal)
|
||||||
|
&& System.IO.File.Exists(path))
|
||||||
|
{
|
||||||
|
var current = await System.IO.File.ReadAllBytesAsync(path).ConfigureAwait(false);
|
||||||
|
if (image.AsSpan().SequenceEqual(current))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.ProfileImage is not null)
|
||||||
|
{
|
||||||
|
await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
user.ProfileImage = new ImageInfo(path);
|
||||||
|
|
||||||
|
using var stream = new MemoryStream(image, false);
|
||||||
|
await _providerManager.SaveImage(stream, mimeType, path).ConfigureAwait(false);
|
||||||
|
|
||||||
|
_logger.LogInformation("Updated the profile picture for {Username} from {Uri}", user.Username, uri);
|
||||||
|
}
|
||||||
|
|
||||||
private OidcClient CreateClient()
|
private OidcClient CreateClient()
|
||||||
{
|
{
|
||||||
var options = new OidcClientOptions
|
var options = new OidcClientOptions
|
||||||
|
|||||||
@@ -38,6 +38,17 @@ public class PluginConfiguration : BasePluginConfiguration
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public string RoleClaim { get; set; } = "groups";
|
public string RoleClaim { get; set; } = "groups";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the claim holding a URL to the user's profile picture.
|
||||||
|
/// </summary>
|
||||||
|
public string PictureClaim { get; set; } = "picture";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether the Jellyfin profile image is updated
|
||||||
|
/// from the picture claim on every OIDC login.
|
||||||
|
/// </summary>
|
||||||
|
public bool SyncProfilePicture { get; set; } = true;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets a comma separated list of role/group values that grant Jellyfin
|
/// Gets or sets a comma separated list of role/group values that grant Jellyfin
|
||||||
/// administrator rights. When empty, admin status is never modified by the plugin.
|
/// administrator rights. When empty, admin status is never modified by the plugin.
|
||||||
|
|||||||
@@ -41,6 +41,11 @@
|
|||||||
<div class="fieldDescription">Claim containing groups/roles. Default: groups.</div>
|
<div class="fieldDescription">Claim containing groups/roles. Default: groups.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="inputContainer">
|
||||||
|
<input is="emby-input" id="PictureClaim" type="text" label="Picture claim" />
|
||||||
|
<div class="fieldDescription">Claim holding a URL to the user's avatar. Default: picture.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="inputContainer">
|
<div class="inputContainer">
|
||||||
<input is="emby-input" id="AllowedRoles" type="text" label="Allowed roles" />
|
<input is="emby-input" id="AllowedRoles" type="text" label="Allowed roles" />
|
||||||
<div class="fieldDescription">Comma separated. Only users with one of these roles may log in. Empty = everyone.</div>
|
<div class="fieldDescription">Comma separated. Only users with one of these roles may log in. Empty = everyone.</div>
|
||||||
@@ -58,6 +63,13 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||||
|
<label class="emby-checkbox-label">
|
||||||
|
<input is="emby-checkbox" id="SyncProfilePicture" type="checkbox" />
|
||||||
|
<span>Sync the profile picture from the provider on every login (overwrites a picture set in Jellyfin)</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="checkboxContainer checkboxContainer-withDescription">
|
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||||
<label class="emby-checkbox-label">
|
<label class="emby-checkbox-label">
|
||||||
<input is="emby-checkbox" id="CreateUsersIfMissing" type="checkbox" />
|
<input is="emby-checkbox" id="CreateUsersIfMissing" type="checkbox" />
|
||||||
@@ -96,8 +108,8 @@
|
|||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
(function () {
|
(function () {
|
||||||
var pluginId = '96badb44-9940-4ff0-befc-5f987159152c';
|
var pluginId = '96badb44-9940-4ff0-befc-5f987159152c';
|
||||||
var textFields = ['OidIssuer', 'OidClientId', 'OidClientSecret', 'OidScopes', 'UsernameClaim', 'RoleClaim', 'AllowedRoles', 'AdminRoles'];
|
var textFields = ['OidIssuer', 'OidClientId', 'OidClientSecret', 'OidScopes', 'UsernameClaim', 'RoleClaim', 'PictureClaim', 'AllowedRoles', 'AdminRoles'];
|
||||||
var boolFields = ['RevokeAdminWithoutRole', 'CreateUsersIfMissing', 'SetRandomPasswordOnCreate', 'DisableEndpointValidation'];
|
var boolFields = ['RevokeAdminWithoutRole', 'SyncProfilePicture', 'CreateUsersIfMissing', 'SetRandomPasswordOnCreate', 'DisableEndpointValidation'];
|
||||||
|
|
||||||
document.querySelector('#OidcAuthConfigPage').addEventListener('pageshow', function () {
|
document.querySelector('#OidcAuthConfigPage').addEventListener('pageshow', function () {
|
||||||
Dashboard.showLoadingMsg();
|
Dashboard.showLoadingMsg();
|
||||||
|
|||||||
@@ -5,9 +5,9 @@
|
|||||||
<RootNamespace>Jellyfin.Plugin.OidcAuth</RootNamespace>
|
<RootNamespace>Jellyfin.Plugin.OidcAuth</RootNamespace>
|
||||||
<!-- Kept in sync with build.yaml by scripts/build_plugin.py; the release
|
<!-- Kept in sync with build.yaml by scripts/build_plugin.py; the release
|
||||||
workflow overrides these with the version from the git tag. -->
|
workflow overrides these with the version from the git tag. -->
|
||||||
<Version>1.0.3.0</Version>
|
<Version>1.0.4.0</Version>
|
||||||
<AssemblyVersion>1.0.3.0</AssemblyVersion>
|
<AssemblyVersion>1.0.4.0</AssemblyVersion>
|
||||||
<FileVersion>1.0.3.0</FileVersion>
|
<FileVersion>1.0.4.0</FileVersion>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>disable</ImplicitUsings>
|
<ImplicitUsings>disable</ImplicitUsings>
|
||||||
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
|
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
|
||||||
|
|||||||
41
README.md
41
README.md
@@ -89,13 +89,48 @@ Then in Jellyfin: Dashboard → Plugins → OIDC Auth:
|
|||||||
|
|
||||||
## Login button on the web login page
|
## Login button on the web login page
|
||||||
|
|
||||||
Jellyfin does not let plugins modify the login page. Add a button via
|
Jellyfin does not let plugins modify the login page. The login disclaimer is the
|
||||||
Dashboard → General → Branding → Login disclaimer:
|
only injection point, and it needs a little CSS to look like a real button.
|
||||||
|
|
||||||
|
Dashboard → General → Branding → **Login disclaimer**:
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<a is="emby-linkbutton" class="raised block emby-button" href="/OidcAuth/login">Sign in with SSO</a>
|
<a href="/OidcAuth/login" class="raised cancel block emby-button"><span>Sign in with SSO</span></a>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`cancel` gives the grey look of the Quick Connect button; swap it for
|
||||||
|
`button-submit` to match the blue Sign In button. Do not copy `btnQuick` off the
|
||||||
|
Quick Connect button — that class is what the login page binds Quick Connect to,
|
||||||
|
not styling.
|
||||||
|
|
||||||
|
Dashboard → General → Branding → **Custom CSS**:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.disclaimerContainer {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.raised.emby-button {
|
||||||
|
padding: 0.9em 1em;
|
||||||
|
color: inherit !important;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Both pieces are needed. Jellyfin renders the disclaimer through markdown-it and
|
||||||
|
then DOMPurify, which strips the `is="emby-linkbutton"` attribute that would
|
||||||
|
normally turn the anchor into a button component — so the styling has to come
|
||||||
|
from the classes instead. `.disclaimerContainer` is not a block by default,
|
||||||
|
which is what otherwise leaves the link shrink-wrapped and centred instead of
|
||||||
|
matching the width of the buttons above it.
|
||||||
|
|
||||||
|
It has to be an anchor. Copying the markup of a real login button does not work:
|
||||||
|
`<button>` cannot navigate without an `href`, and DOMPurify removes `onclick`
|
||||||
|
along with `is`, so the result renders correctly and does nothing when clicked.
|
||||||
|
|
||||||
|
Styling via the theme's own classes rather than inline styles keeps the button
|
||||||
|
correct when the theme changes, since `.raised` is defined per theme.
|
||||||
|
|
||||||
## TV and native clients
|
## TV and native clients
|
||||||
|
|
||||||
The plugin does not change or disable Jellyfin's normal password login, so TV apps
|
The plugin does not change or disable Jellyfin's normal password login, so TV apps
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: "OIDC Auth"
|
name: "OIDC Auth"
|
||||||
guid: "96badb44-9940-4ff0-befc-5f987159152c"
|
guid: "96badb44-9940-4ff0-befc-5f987159152c"
|
||||||
version: "1.0.3.0"
|
version: "1.0.4.0"
|
||||||
targetAbi: "10.11.0.0"
|
targetAbi: "10.11.0.0"
|
||||||
framework: "net9.0"
|
framework: "net9.0"
|
||||||
owner: "pascallinxweiler"
|
owner: "pascallinxweiler"
|
||||||
|
|||||||
BIN
dist/oidc-auth_1.0.4.0.zip
vendored
Normal file
BIN
dist/oidc-auth_1.0.4.0.zip
vendored
Normal file
Binary file not shown.
@@ -7,6 +7,14 @@
|
|||||||
"owner": "pascallinxweiler",
|
"owner": "pascallinxweiler",
|
||||||
"category": "Authentication",
|
"category": "Authentication",
|
||||||
"versions": [
|
"versions": [
|
||||||
|
{
|
||||||
|
"version": "1.0.4.0",
|
||||||
|
"changelog": "fix: make the release workflow build and package a real Jellyfin plugin\n\nThe release workflow was copied from another project and could not succeed on\nany tag: it installed the .NET 8 SDK for a net9.0 project, published a\nJellyfinSso.csproj that does not exist, called a missing update_manifest.py,\nand zipped the entire publish tree instead of the three DLLs declared in\nbuild.yaml.\n\nReplace it with a tag-driven release:\n\n- Build with the .NET 9 SDK, injecting the tag version via -p:Version,\n -p:AssemblyVersion and -p:FileVersion so the assembly no longer carries a\n hardcoded 1.0.3.0.\n- Package via scripts/build_plugin.py, which emits the JPRM layout Jellyfin\n expects: the build.yaml artifacts plus a generated meta.json. The previous\n zips shipped without meta.json, which only worked for repository installs\n because Jellyfin synthesises one from the folder name.\n- Update manifest.json via scripts/update_manifest.py, keeping build.yaml as\n the single source of truth for plugin metadata.\n- Commit the zip and manifest to main and attach the zip to the Gitea release.\n Pushing uses the injected token, falling back to a RELEASE_TOKEN secret when\n the built-in one lacks write access.\n\nAlso fix the published download URLs. manifest.json and the README pointed at\npascallinxweiler/jellyfinplugin, which 404s; the repository is jellyfinsso, so\nthe plugin repository could not be added to Jellyfin and no version could be\ndownloaded. The workflow now derives the URL from GITHUB_REPOSITORY.\n\nAdd a build workflow so main and pull requests are compiled and packaged.\n\nCo-Authored-By: Claude <noreply@anthropic.com>",
|
||||||
|
"targetAbi": "10.11.0.0",
|
||||||
|
"sourceUrl": "https://git.linxweiler.xyz/pascallinxweiler/jellyfinsso/raw/branch/main/dist/oidc-auth_1.0.4.0.zip",
|
||||||
|
"checksum": "afcf05fb5d8737273fdc9ca2195853d6",
|
||||||
|
"timestamp": "2026-07-29T13:26:22Z"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "1.0.3.0",
|
"version": "1.0.3.0",
|
||||||
"changelog": "Admin role mapping now only grants admin by default. New opt-in setting 'Also revoke admin when the admin role is missing' restores the old revoking behavior.",
|
"changelog": "Admin role mapping now only grants admin by default. New opt-in setting 'Also revoke admin when the admin role is missing' restores the old revoking behavior.",
|
||||||
|
|||||||
Reference in New Issue
Block a user