14 Commits
Author SHA1 Message Date
dtourolleandClaude Opus 5 19d3797a24 Fix release asset upload corrupted by echo of API response
Build Plugin / build (push) Successful in 3m4s
Release Plugin / build-and-release (push) Successful in 2m22s
The v1.1.0 release job created the release then died with a jq parse error
before uploading either package.

The runner executes run: steps with a shell whose echo expands backslash
escapes. Passing the API response through `echo "$BODY" | jq` turned the \n
escapes in the release body field into real newlines, producing invalid JSON.
This only surfaced now because the release body became multi-line; every
earlier release had a single-line body with no \n to expand.

Keep the response in a file and read it with jq directly, so no JSON passes
through echo. Also fall back to the existing release for the tag when creation
returns non-2xx, so a partially completed release can be re-run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 19:16:34 +02:00
dtourolleandClaude Opus 5 71180941e5 Add Jellyfin 12.0 release build alongside 10.11
Build Plugin / build (push) Failing after 1m1s
Release Plugin / build-and-release (push) Failing after 3m22s
Multi-target the plugin against net9.0 (Jellyfin 10.11.x) and net10.0
(Jellyfin 12.0.x), with per-framework Jellyfin.Controller/Model references.

Jellyfin 12 added a controlling-session parameter to
ISessionManager.ReportCapabilities; an empty value skips the AssertCanControl
check, which is what a server-side registration needs.

Each release now ships two packages, jellylms_<version>_jf11.zip and
jellylms_<version>_jf12.zip, with a manifest entry each. Jellyfin filters by
targetAbi, so both can share a version number. Also corrects the manifest
targetAbi for new releases from 10.10.0.0 to 10.11.0.0 - the plugin has
referenced 10.11 packages since 1.0.0.

jprm only reads ./build.yaml, so build-plugin.sh swaps targetAbi/framework per
variant and restores the file afterwards.

The builder image moves to the .NET 10 SDK, which targets both frameworks.
CA1873 (new in that SDK) is disabled alongside CA1848, same rationale.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 18:48:16 +02:00
Gitea Actions e69f57d7ee Update manifest.json for v1.0.6 2026-06-17 20:24:41 +00:00
dtourolle f6fa526598 Fix dropdown
Build Plugin / build (push) Successful in 36s
Release Plugin / build-and-release (push) Successful in 43s
2026-06-17 22:23:19 +02:00
Gitea Actions 08f34bfc1e Update manifest.json for v1.0.5 2026-06-17 19:20:00 +00:00
dtourolle 227fcd7fdd Dropdown menu
Build Plugin / build (push) Successful in 36s
Release Plugin / build-and-release (push) Successful in 38s
2026-06-17 20:49:50 +02:00
Gitea Actions 39eab2db69 Update manifest.json for v1.0.4 2026-06-17 18:44:44 +00:00
dtourolle 13c1b8e7d6 Button in menu bar
Build Plugin / build (push) Successful in 1m10s
Release Plugin / build-and-release (push) Successful in 46s
2026-06-15 21:10:05 +02:00
Gitea Actions 1c668a6431 Update manifest.json for v1.0.3 2026-06-14 15:48:18 +00:00
dtourolle 38dc02aea5 fix CI
Build Plugin / build (push) Successful in 37s
Release Plugin / build-and-release (push) Successful in 38s
2026-06-14 17:03:15 +02:00
dtourolle 4d2f7df217 new build system
Build Plugin / build (push) Successful in 49s
Release Plugin / build-and-release (push) Failing after 39s
inject controls in homepage
2026-06-13 23:35:46 +02:00
Gitea Actions df98b2c1f8 Update manifest.json for v1.0.2 2026-01-25 18:35:06 +00:00
dtourolle b85fbc2d90 Track remote volume to prevent unexpcted changes change
Build Plugin / build (push) Successful in 2m54s
Release Plugin / build-and-release (push) Successful in 2m43s
2026-01-25 19:20:35 +01:00
Gitea Actions f5f202794f Update manifest.json for v1.0.1 2025-12-30 13:43:10 +00:00
17 changed files with 1279 additions and 143 deletions
+27 -23
View File
@@ -15,44 +15,48 @@ on:
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: linux/amd64
container:
image: gitea.tourolle.paris/dtourolle/jellylms-builder:latest
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
path: build-${{ github.run_id }}
- name: Verify .NET installation - name: Cache NuGet packages
run: dotnet --version uses: actions/cache@v3
with:
path: ~/.nuget/packages
key: nuget-${{ hashFiles('**/Jellyfin.Plugin.JellyLMS.csproj') }}
restore-keys: nuget-
- name: Restore dependencies - name: Restore dependencies
working-directory: build-${{ github.run_id }}
run: dotnet restore Jellyfin.Plugin.JellyLMS.sln run: dotnet restore Jellyfin.Plugin.JellyLMS.sln
- name: Build solution - name: Build solution
run: dotnet build Jellyfin.Plugin.JellyLMS.sln --configuration Release --no-restore --no-self-contained working-directory: build-${{ github.run_id }}
run: dotnet build Jellyfin.Plugin.JellyLMS.sln --configuration Release --no-restore --no-self-contained /m:1
- name: Install JPRM - name: Build Jellyfin plugin packages
working-directory: build-${{ github.run_id }}
run: | run: |
python3 -m venv /tmp/jprm-venv for VARIANT in jf11 jf12; do
/tmp/jprm-venv/bin/pip install jprm ARTIFACT=$(./build-plugin.sh "${VARIANT}")
cp "${ARTIFACT}" "artifacts/jellylms_latest_${VARIANT}.zip"
echo "Built ${VARIANT}: ${ARTIFACT}"
done
- name: Build Jellyfin Plugin - name: Upload build artifacts
id: jprm
run: |
# Create artifacts directory for JPRM output
mkdir -p artifacts
# Build plugin using JPRM
/tmp/jprm-venv/bin/jprm --verbosity=debug plugin build .
# Find the generated zip file
ARTIFACT=$(find . -name "*.zip" -type f -print -quit)
echo "artifact=${ARTIFACT}" >> $GITHUB_OUTPUT
echo "Found artifact: ${ARTIFACT}"
- name: Upload build artifact
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: with:
name: jellylms-plugin name: jellylms-plugin
path: ${{ steps.jprm.outputs.artifact }} path: build-${{ github.run_id }}/artifacts/jellylms_latest_*.zip
retention-days: 30 retention-days: 30
if-no-files-found: error if-no-files-found: error
- name: Cleanup
if: always()
run: rm -rf build-${{ github.run_id }}
+99 -91
View File
@@ -13,14 +13,15 @@ on:
jobs: jobs:
build-and-release: build-and-release:
runs-on: ubuntu-latest runs-on: linux/amd64
container:
image: gitea.tourolle.paris/dtourolle/jellylms-builder:latest
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
- name: Verify .NET installation path: release-${{ github.run_id }}
run: dotnet --version
- name: Get version - name: Get version
id: get_version id: get_version
@@ -34,40 +35,36 @@ jobs:
echo "version_number=${VERSION#v}" >> $GITHUB_OUTPUT echo "version_number=${VERSION#v}" >> $GITHUB_OUTPUT
echo "Building version: ${VERSION}" echo "Building version: ${VERSION}"
- name: Update build.yaml with version - name: Cache NuGet packages
run: | uses: actions/cache@v3
VERSION="${{ steps.get_version.outputs.version_number }}" with:
sed -i "s/^version:.*/version: \"${VERSION}\"/" build.yaml path: ~/.nuget/packages
cat build.yaml key: nuget-${{ hashFiles('**/Jellyfin.Plugin.JellyLMS.csproj') }}
restore-keys: nuget-
- name: Restore dependencies - name: Restore dependencies
working-directory: release-${{ github.run_id }}
run: dotnet restore Jellyfin.Plugin.JellyLMS.sln run: dotnet restore Jellyfin.Plugin.JellyLMS.sln
- name: Build solution - name: Build solution
run: dotnet build Jellyfin.Plugin.JellyLMS.sln --configuration Release --no-restore --no-self-contained working-directory: release-${{ github.run_id }}
run: dotnet build Jellyfin.Plugin.JellyLMS.sln --configuration Release --no-restore --no-self-contained /m:1
- name: Install JPRM - name: Build Jellyfin plugin packages
run: |
python3 -m venv /tmp/jprm-venv
/tmp/jprm-venv/bin/pip install jprm
- name: Build Jellyfin Plugin
id: jprm id: jprm
working-directory: release-${{ github.run_id }}
run: | run: |
# Create artifacts directory for JPRM output VERSION="${{ steps.get_version.outputs.version_number }}"
mkdir -p artifacts for VARIANT in jf11 jf12; do
ARTIFACT=$(./build-plugin.sh "${VARIANT}" "${VERSION}")
# Build plugin using JPRM echo "${VARIANT}_artifact=${ARTIFACT}" >> $GITHUB_OUTPUT
/tmp/jprm-venv/bin/jprm --verbosity=debug plugin build ./ echo "${VARIANT}_artifact_name=$(basename "${ARTIFACT}")" >> $GITHUB_OUTPUT
echo "${VARIANT}_checksum=$(md5sum "${ARTIFACT}" | awk '{print $1}')" >> $GITHUB_OUTPUT
# Find the generated zip file echo "Built ${VARIANT}: ${ARTIFACT}"
ARTIFACT=$(find . -name "*.zip" -type f -print -quit) done
ARTIFACT_NAME=$(basename "${ARTIFACT}")
echo "artifact=${ARTIFACT}" >> $GITHUB_OUTPUT
echo "artifact_name=${ARTIFACT_NAME}" >> $GITHUB_OUTPUT
echo "Found artifact: ${ARTIFACT}"
- name: Create Release - name: Create Release
working-directory: release-${{ github.run_id }}
env: env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
@@ -76,98 +73,109 @@ jobs:
REPO_NAME="${{ github.event.repository.name }}" REPO_NAME="${{ github.event.repository.name }}"
GITEA_URL="${{ github.server_url }}" GITEA_URL="${{ github.server_url }}"
# Prepare release body
RELEASE_BODY="JellyLMS Jellyfin Plugin ${{ steps.get_version.outputs.version }}\n\nSee attached files for plugin installation."
RELEASE_BODY_JSON=$(echo -n "${RELEASE_BODY}" | jq -Rs .)
# Create release using Gitea API # Create release using Gitea API
RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \ VERSION="${{ steps.get_version.outputs.version }}"
RELEASE_BODY=$(printf '%s\n' \
"JellyLMS Jellyfin Plugin ${VERSION}." \
"" \
"Pick the package matching your server:" \
"" \
"- \`${{ steps.jprm.outputs.jf11_artifact_name }}\` - Jellyfin 10.11.x" \
"- \`${{ steps.jprm.outputs.jf12_artifact_name }}\` - Jellyfin 12.0.x")
API_URL="${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}"
# Keep the API response in a file: it contains \n escapes, and `echo` in
# this shell expands those into real newlines, which corrupts the JSON.
HTTP_CODE=$(curl -s -o release-response.json -w "%{http_code}" -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \ -H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases" \ "${API_URL}/releases" \
-d "{ -d "$(jq -n --arg tag "$VERSION" --arg name "Release $VERSION" --arg body "$RELEASE_BODY" '{tag_name: $tag, name: $name, body: $body, draft: false, prerelease: false}')")
\"tag_name\": \"${{ steps.get_version.outputs.version }}\",
\"name\": \"Release ${{ steps.get_version.outputs.version }}\",
\"body\": ${RELEASE_BODY_JSON},
\"draft\": false,
\"prerelease\": false
}")
HTTP_CODE=$(echo "$RESPONSE" | tail -n1) if [ "$HTTP_CODE" -lt 200 ] || [ "$HTTP_CODE" -ge 300 ]; then
BODY=$(echo "$RESPONSE" | sed '$d') echo "Create release returned HTTP ${HTTP_CODE}:"
cat release-response.json
if [ "$HTTP_CODE" -ge 200 ] && [ "$HTTP_CODE" -lt 300 ]; then echo "Falling back to an existing release for ${VERSION}..."
RELEASE_ID=$(echo "$BODY" | jq -r '.id') curl -sf -o release-response.json \
echo "Created release with ID: ${RELEASE_ID}" -H "Authorization: token ${GITEA_TOKEN}" \
else "${API_URL}/releases/tags/${VERSION}"
echo "Failed to create release. HTTP ${HTTP_CODE}"
echo "$BODY"
exit 1
fi fi
# Upload plugin artifact RELEASE_ID=$(jq -r '.id // empty' release-response.json)
echo "Uploading plugin artifact..." if [ -z "${RELEASE_ID}" ]; then
echo "Could not determine release ID from:"
cat release-response.json
exit 1
fi
echo "Using release ID: ${RELEASE_ID}"
# Upload plugin artifacts (one per supported Jellyfin generation)
for ASSET in \
"${{ steps.jprm.outputs.jf11_artifact }}" \
"${{ steps.jprm.outputs.jf12_artifact }}"; do
echo "Uploading $(basename "${ASSET}")..."
curl -f -X POST \ curl -f -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \ -H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/zip" \ -H "Content-Type: application/zip" \
--data-binary "@${{ steps.jprm.outputs.artifact }}" \ --data-binary "@${ASSET}" \
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}/assets?name=${{ steps.jprm.outputs.artifact_name }}" "${API_URL}/releases/${RELEASE_ID}/assets?name=$(basename "${ASSET}")"
done
# 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"
rm -f release-response.json
echo "Release created successfully!" echo "Release created successfully!"
echo "View at: ${GITEA_URL}/${REPO_OWNER}/${REPO_NAME}/releases/tag/${{ steps.get_version.outputs.version }}" echo "View at: ${GITEA_URL}/${REPO_OWNER}/${REPO_NAME}/releases/tag/${{ steps.get_version.outputs.version }}"
- name: Calculate checksum
id: checksum
run: |
CHECKSUM=$(md5sum "${{ steps.jprm.outputs.artifact }}" | awk '{print $1}')
echo "checksum=${CHECKSUM}" >> $GITHUB_OUTPUT
echo "MD5 checksum: ${CHECKSUM}"
- name: Update manifest.json - name: Update manifest.json
working-directory: release-${{ github.run_id }}
run: | run: |
git config user.name "Gitea Actions"
git config user.email "actions@gitea.tourolle.paris"
git fetch origin master
git checkout master
VERSION="${{ steps.get_version.outputs.version_number }}" VERSION="${{ steps.get_version.outputs.version_number }}"
CHECKSUM="${{ steps.checksum.outputs.checksum }}"
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ") TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
ARTIFACT_NAME="${{ steps.jprm.outputs.artifact_name }}"
REPO_OWNER="${{ github.repository_owner }}" REPO_OWNER="${{ github.repository_owner }}"
REPO_NAME="${{ github.event.repository.name }}" REPO_NAME="${{ github.event.repository.name }}"
GITEA_URL="${{ github.server_url }}" GITEA_URL="${{ github.server_url }}"
DOWNLOAD_URL="${GITEA_URL}/${REPO_OWNER}/${REPO_NAME}/releases/download/${{ steps.get_version.outputs.version }}/${ARTIFACT_NAME}" RELEASE_URL="${GITEA_URL}/${REPO_OWNER}/${REPO_NAME}/releases/download/${{ steps.get_version.outputs.version }}"
# Create the new version entry # One manifest entry per Jellyfin generation. Jellyfin filters the list by
NEW_VERSION=$(cat <<EOF # targetAbi, so both entries can share the same version number.
{ add_version() {
"version": "${VERSION}", local abi="$1" artifact_name="$2" checksum="$3"
"changelog": "Release ${VERSION}", jq \
"targetAbi": "10.10.0.0", --arg version "${VERSION}" \
"sourceUrl": "${DOWNLOAD_URL}", --arg abi "${abi}" \
"checksum": "${CHECKSUM}", --arg url "${RELEASE_URL}/${artifact_name}" \
"timestamp": "${TIMESTAMP}" --arg checksum "${checksum}" \
} --arg timestamp "${TIMESTAMP}" \
EOF '.[0].versions = [{
) version: $version,
changelog: "Release \($version)",
# Prepend new version to the versions array in manifest.json targetAbi: $abi,
jq --argjson newver "${NEW_VERSION}" '.[0].versions = [$newver] + .[0].versions' manifest.json > manifest.tmp.json sourceUrl: $url,
checksum: $checksum,
timestamp: $timestamp
}] + .[0].versions' manifest.json > manifest.tmp.json
mv manifest.tmp.json manifest.json mv manifest.tmp.json manifest.json
}
add_version "10.11.0.0" "${{ steps.jprm.outputs.jf11_artifact_name }}" "${{ steps.jprm.outputs.jf11_checksum }}"
add_version "12.0.0.0" "${{ steps.jprm.outputs.jf12_artifact_name }}" "${{ steps.jprm.outputs.jf12_checksum }}"
echo "Updated manifest.json:" echo "Updated manifest.json:"
cat manifest.json cat manifest.json
- name: Commit and push manifest - name: Commit and push manifest
working-directory: release-${{ github.run_id }}
env: env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
git config user.name "Gitea Actions"
git config user.email "actions@gitea.tourolle.paris"
git add manifest.json git add manifest.json
git commit -m "Update manifest.json for ${{ steps.get_version.outputs.version }}" git commit -m "Update manifest.json for ${{ steps.get_version.outputs.version }}"
git push origin HEAD:master git push origin master
- name: Cleanup
if: always()
run: rm -rf release-${{ github.run_id }}
+19
View File
@@ -0,0 +1,19 @@
# JellyLMS Builder Image
# Pre-built image with .NET SDK and JPRM for building Jellyfin plugins
# Build: docker build -f Dockerfile.builder -t gitea.tourolle.paris/dtourolle/jellylms-builder:latest .
# Push: docker push gitea.tourolle.paris/dtourolle/jellylms-builder:latest
FROM mcr.microsoft.com/dotnet/sdk:10.0
RUN apt-get update && apt-get install -y \
python3 \
python3-pip \
git \
jq \
nodejs \
npm \
&& rm -rf /var/lib/apt/lists/*
RUN pip install --break-system-packages jprm
WORKDIR /src
@@ -3,8 +3,10 @@ using System.Collections.Generic;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Linq; using System.Linq;
using System.Net.Mime; using System.Net.Mime;
using System.Reflection;
using System.Threading.Tasks; using System.Threading.Tasks;
using Jellyfin.Data.Enums; using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations.Enums;
using Jellyfin.Plugin.JellyLMS.Models; using Jellyfin.Plugin.JellyLMS.Models;
using Jellyfin.Plugin.JellyLMS.Services; using Jellyfin.Plugin.JellyLMS.Services;
using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities;
@@ -29,6 +31,7 @@ public class JellyLmsController : ControllerBase
private readonly ILmsApiClient _lmsClient; private readonly ILmsApiClient _lmsClient;
private readonly LmsPlayerManager _playerManager; private readonly LmsPlayerManager _playerManager;
private readonly ILibraryManager _libraryManager; private readonly ILibraryManager _libraryManager;
private readonly IUserManager _userManager;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="JellyLmsController"/> class. /// Initializes a new instance of the <see cref="JellyLmsController"/> class.
@@ -36,14 +39,54 @@ public class JellyLmsController : ControllerBase
/// <param name="lmsClient">The LMS API client.</param> /// <param name="lmsClient">The LMS API client.</param>
/// <param name="playerManager">The player manager.</param> /// <param name="playerManager">The player manager.</param>
/// <param name="libraryManager">The library manager.</param> /// <param name="libraryManager">The library manager.</param>
/// <param name="userManager">The user manager.</param>
public JellyLmsController( public JellyLmsController(
ILmsApiClient lmsClient, ILmsApiClient lmsClient,
LmsPlayerManager playerManager, LmsPlayerManager playerManager,
ILibraryManager libraryManager) ILibraryManager libraryManager,
IUserManager userManager)
{ {
_lmsClient = lmsClient; _lmsClient = lmsClient;
_playerManager = playerManager; _playerManager = playerManager;
_libraryManager = libraryManager; _libraryManager = libraryManager;
_userManager = userManager;
}
/// <summary>
/// Determines whether the current user is allowed to use the multi-room remote
/// control features (administrators, or users granted the
/// "Allow remote control of other users" permission).
/// </summary>
/// <returns><c>true</c> if the user may use remote control endpoints.</returns>
private bool HasRemoteControlAccess()
{
var username = User.Identity?.Name;
if (string.IsNullOrEmpty(username))
{
return false;
}
var user = _userManager.GetUserByName(username);
if (user is null)
{
return false;
}
return HasPermission(user, PermissionKind.IsAdministrator)
|| HasPermission(user, PermissionKind.EnableRemoteControlOfOtherUsers);
}
private static bool HasPermission(Jellyfin.Database.Implementations.Entities.User user, PermissionKind kind)
{
foreach (var permission in user.Permissions)
{
if (permission.Kind == kind)
{
return permission.Value;
}
}
return false;
} }
/// <summary> /// <summary>
@@ -65,8 +108,14 @@ public class JellyLmsController : ControllerBase
/// <returns>List of players.</returns> /// <returns>List of players.</returns>
[HttpGet("Players")] [HttpGet("Players")]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<List<LmsPlayer>>> GetPlayers([FromQuery] bool refresh = false) public async Task<ActionResult<List<LmsPlayer>>> GetPlayers([FromQuery] bool refresh = false)
{ {
if (!HasRemoteControlAccess())
{
return Forbid();
}
var players = await _playerManager.GetPlayersAsync(refresh).ConfigureAwait(false); var players = await _playerManager.GetPlayersAsync(refresh).ConfigureAwait(false);
return Ok(players); return Ok(players);
} }
@@ -78,9 +127,15 @@ public class JellyLmsController : ControllerBase
/// <returns>The player details.</returns> /// <returns>The player details.</returns>
[HttpGet("Players/{mac}")] [HttpGet("Players/{mac}")]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<LmsPlayer>> GetPlayer(string mac) public async Task<ActionResult<LmsPlayer>> GetPlayer(string mac)
{ {
if (!HasRemoteControlAccess())
{
return Forbid();
}
var player = await _playerManager.GetPlayerAsync(mac).ConfigureAwait(false); var player = await _playerManager.GetPlayerAsync(mac).ConfigureAwait(false);
if (player == null) if (player == null)
{ {
@@ -98,8 +153,14 @@ public class JellyLmsController : ControllerBase
[HttpPost("Players/{mac}/PowerOn")] [HttpPost("Players/{mac}/PowerOn")]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult> PowerOn(string mac) public async Task<ActionResult> PowerOn(string mac)
{ {
if (!HasRemoteControlAccess())
{
return Forbid();
}
var success = await _lmsClient.PowerOnAsync(mac).ConfigureAwait(false); var success = await _lmsClient.PowerOnAsync(mac).ConfigureAwait(false);
return success ? Ok() : BadRequest("Failed to power on player"); return success ? Ok() : BadRequest("Failed to power on player");
} }
@@ -112,8 +173,14 @@ public class JellyLmsController : ControllerBase
[HttpPost("Players/{mac}/PowerOff")] [HttpPost("Players/{mac}/PowerOff")]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult> PowerOff(string mac) public async Task<ActionResult> PowerOff(string mac)
{ {
if (!HasRemoteControlAccess())
{
return Forbid();
}
var success = await _lmsClient.PowerOffAsync(mac).ConfigureAwait(false); var success = await _lmsClient.PowerOffAsync(mac).ConfigureAwait(false);
return success ? Ok() : BadRequest("Failed to power off player"); return success ? Ok() : BadRequest("Failed to power off player");
} }
@@ -127,8 +194,14 @@ public class JellyLmsController : ControllerBase
[HttpPost("Players/{mac}/Volume")] [HttpPost("Players/{mac}/Volume")]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult> SetVolume(string mac, [FromBody] VolumeRequest request) public async Task<ActionResult> SetVolume(string mac, [FromBody] VolumeRequest request)
{ {
if (!HasRemoteControlAccess())
{
return Forbid();
}
var success = await _lmsClient.SetVolumeAsync(mac, request.Volume).ConfigureAwait(false); var success = await _lmsClient.SetVolumeAsync(mac, request.Volume).ConfigureAwait(false);
return success ? Ok() : BadRequest("Failed to set volume"); return success ? Ok() : BadRequest("Failed to set volume");
} }
@@ -139,8 +212,14 @@ public class JellyLmsController : ControllerBase
/// <returns>List of sync groups.</returns> /// <returns>List of sync groups.</returns>
[HttpGet("SyncGroups")] [HttpGet("SyncGroups")]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<List<SyncGroup>>> GetSyncGroups() public async Task<ActionResult<List<SyncGroup>>> GetSyncGroups()
{ {
if (!HasRemoteControlAccess())
{
return Forbid();
}
var groups = await _playerManager.GetSyncGroupsAsync().ConfigureAwait(false); var groups = await _playerManager.GetSyncGroupsAsync().ConfigureAwait(false);
return Ok(groups); return Ok(groups);
} }
@@ -153,8 +232,14 @@ public class JellyLmsController : ControllerBase
[HttpPost("SyncGroups")] [HttpPost("SyncGroups")]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult> CreateSyncGroup([FromBody] CreateSyncGroupRequest request) public async Task<ActionResult> CreateSyncGroup([FromBody] CreateSyncGroupRequest request)
{ {
if (!HasRemoteControlAccess())
{
return Forbid();
}
var success = await _playerManager.CreateSyncGroupAsync(request.MasterMac, request.SlaveMacs) var success = await _playerManager.CreateSyncGroupAsync(request.MasterMac, request.SlaveMacs)
.ConfigureAwait(false); .ConfigureAwait(false);
return success ? Ok() : BadRequest("Failed to create sync group"); return success ? Ok() : BadRequest("Failed to create sync group");
@@ -168,8 +253,14 @@ public class JellyLmsController : ControllerBase
[HttpDelete("SyncGroups/Players/{mac}")] [HttpDelete("SyncGroups/Players/{mac}")]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult> UnsyncPlayer(string mac) public async Task<ActionResult> UnsyncPlayer(string mac)
{ {
if (!HasRemoteControlAccess())
{
return Forbid();
}
var success = await _playerManager.UnsyncPlayerAsync(mac).ConfigureAwait(false); var success = await _playerManager.UnsyncPlayerAsync(mac).ConfigureAwait(false);
return success ? Ok() : BadRequest("Failed to unsync player"); return success ? Ok() : BadRequest("Failed to unsync player");
} }
@@ -182,12 +273,67 @@ public class JellyLmsController : ControllerBase
[HttpDelete("SyncGroups/{masterMac}")] [HttpDelete("SyncGroups/{masterMac}")]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult> DissolveSyncGroup(string masterMac) public async Task<ActionResult> DissolveSyncGroup(string masterMac)
{ {
if (!HasRemoteControlAccess())
{
return Forbid();
}
var success = await _playerManager.DissolveSyncGroupAsync(masterMac).ConfigureAwait(false); var success = await _playerManager.DissolveSyncGroupAsync(masterMac).ConfigureAwait(false);
return success ? Ok() : BadRequest("Failed to dissolve sync group"); return success ? Ok() : BadRequest("Failed to dissolve sync group");
} }
/// <summary>
/// Checks whether the current user is allowed to use the multi-room remote control.
/// Used by the remote control page and the injected web client button to decide
/// whether to show themselves.
/// </summary>
/// <returns>200 OK if allowed, otherwise 403 Forbidden.</returns>
[HttpGet("RemoteControl/Access")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public ActionResult CheckRemoteControlAccess()
{
return HasRemoteControlAccess() ? Ok() : Forbid();
}
/// <summary>
/// Serves the standalone multi-room remote control page. The page itself contains
/// no sensitive data; it authenticates API calls using the Jellyfin access token
/// stored by the web client, so it is reachable without a prior Jellyfin auth header.
/// </summary>
/// <returns>The remote control HTML page.</returns>
[HttpGet("RemoteControl")]
[AllowAnonymous]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult GetRemoteControlPage()
{
return ServeEmbeddedResource("Jellyfin.Plugin.JellyLMS.Web.RemoteControl.html", "text/html");
}
/// <summary>
/// Serves the client script that is injected into the Jellyfin web client to add a
/// floating button linking to the remote control page.
/// </summary>
/// <returns>The client script.</returns>
[HttpGet("RemoteControl/ClientScript")]
[AllowAnonymous]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult GetRemoteControlClientScript()
{
return ServeEmbeddedResource("Jellyfin.Plugin.JellyLMS.Web.remote-button.js", "application/javascript");
}
private FileStreamResult ServeEmbeddedResource(string resourceName, string contentType)
{
var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream(resourceName)
?? throw new InvalidOperationException($"Embedded resource '{resourceName}' not found.");
return File(stream, contentType);
}
/// <summary> /// <summary>
/// Discovers file paths used by Jellyfin's music libraries. /// Discovers file paths used by Jellyfin's music libraries.
/// Helps users configure path mappings for direct file access. /// Helps users configure path mappings for direct file access.
@@ -36,6 +36,7 @@ public class PluginConfiguration : BasePluginConfiguration
ConnectionTimeoutSeconds = 10; ConnectionTimeoutSeconds = 10;
EnableAutoSync = true; EnableAutoSync = true;
DefaultPlayerMac = string.Empty; DefaultPlayerMac = string.Empty;
EnableHomeScreenButton = true;
} }
/// <summary> /// <summary>
@@ -75,6 +76,12 @@ public class PluginConfiguration : BasePluginConfiguration
/// </summary> /// </summary>
public string DefaultPlayerMac { get; set; } public string DefaultPlayerMac { get; set; }
/// <summary>
/// Gets or sets a value indicating whether a floating "Remote" button linking to the
/// multi-room remote control page should be injected into the Jellyfin web client.
/// </summary>
public bool EnableHomeScreenButton { get; set; }
/// <summary> /// <summary>
/// Gets or sets the Jellyfin API key for authenticating stream requests from LMS. /// Gets or sets the Jellyfin API key for authenticating stream requests from LMS.
/// </summary> /// </summary>
@@ -182,6 +182,19 @@
</div> </div>
</div> </div>
<div class="verticalSection">
<h3>Multi-Room Remote</h3>
<p class="fieldDescription">A standalone remote control page is available at <code>/JellyLms/RemoteControl</code> for any user granted the "Allow remote control of other users" permission (Dashboard &gt; Users).</p>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<input id="EnableHomeScreenButton" name="EnableHomeScreenButton" type="checkbox" is="emby-checkbox" />
<span>Show floating Remote button in web client</span>
</label>
<div class="fieldDescription checkboxFieldDescription">Adds a floating button to the Jellyfin web client that links to the remote control page (for authorized users only). Requires write access to the Jellyfin web root and a page reload to take effect.</div>
</div>
</div>
<div class="verticalSection"> <div class="verticalSection">
<h3>Player Sync</h3> <h3>Player Sync</h3>
<p class="fieldDescription">Select players to sync together for multi-room audio. Synced players play in perfect sync.</p> <p class="fieldDescription">Select players to sync together for multi-room audio. Synced players play in perfect sync.</p>
@@ -567,6 +580,7 @@
document.querySelector('#EnableAutoSync').checked = config.EnableAutoSync !== false; document.querySelector('#EnableAutoSync').checked = config.EnableAutoSync !== false;
document.querySelector('#DefaultPlayerMac').value = config.DefaultPlayerMac || ''; document.querySelector('#DefaultPlayerMac').value = config.DefaultPlayerMac || '';
document.querySelector('#UseDirectFilePath').checked = config.UseDirectFilePath || false; document.querySelector('#UseDirectFilePath').checked = config.UseDirectFilePath || false;
document.querySelector('#EnableHomeScreenButton').checked = config.EnableHomeScreenButton !== false;
// Load path mappings (new list format, with fallback to legacy single mapping) // Load path mappings (new list format, with fallback to legacy single mapping)
JellyLmsConfig.pathMappings = config.PathMappings || []; JellyLmsConfig.pathMappings = config.PathMappings || [];
@@ -622,6 +636,7 @@
config.EnableAutoSync = document.querySelector('#EnableAutoSync').checked; config.EnableAutoSync = document.querySelector('#EnableAutoSync').checked;
config.DefaultPlayerMac = document.querySelector('#DefaultPlayerMac').value; config.DefaultPlayerMac = document.querySelector('#DefaultPlayerMac').value;
config.UseDirectFilePath = document.querySelector('#UseDirectFilePath').checked; config.UseDirectFilePath = document.querySelector('#UseDirectFilePath').checked;
config.EnableHomeScreenButton = document.querySelector('#EnableHomeScreenButton').checked;
// Save path mappings (clear legacy single mapping when using list) // Save path mappings (clear legacy single mapping when using list)
config.PathMappings = getPathMappingsFromUI(); config.PathMappings = getPathMappingsFromUI();
config.JellyfinMediaPath = ''; config.JellyfinMediaPath = '';
@@ -1,7 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<!--
Multi-targeted against the two supported Jellyfin server generations:
net9.0 -> Jellyfin 10.11.x (targetAbi 10.11.0.0)
net10.0 -> Jellyfin 12.0.x (targetAbi 12.0.0.0)
jprm builds one framework at a time (see build.yaml / build.jf12.yaml).
-->
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFrameworks>net9.0;net10.0</TargetFrameworks>
<RootNamespace>Jellyfin.Plugin.JellyLMS</RootNamespace> <RootNamespace>Jellyfin.Plugin.JellyLMS</RootNamespace>
<GenerateDocumentationFile>true</GenerateDocumentationFile> <GenerateDocumentationFile>true</GenerateDocumentationFile>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors> <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
@@ -10,7 +16,7 @@
<CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet> <CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup Condition="'$(TargetFramework)' == 'net9.0'">
<PackageReference Include="Jellyfin.Controller" Version="10.11.0"> <PackageReference Include="Jellyfin.Controller" Version="10.11.0">
<ExcludeAssets>runtime</ExcludeAssets> <ExcludeAssets>runtime</ExcludeAssets>
</PackageReference> </PackageReference>
@@ -19,6 +25,15 @@
</PackageReference> </PackageReference>
</ItemGroup> </ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net10.0'">
<PackageReference Include="Jellyfin.Controller" Version="12.0.0">
<ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>
<PackageReference Include="Jellyfin.Model" Version="12.0.0">
<ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" />
<PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.556" PrivateAssets="All" /> <PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.556" PrivateAssets="All" />
@@ -28,6 +43,10 @@
<ItemGroup> <ItemGroup>
<None Remove="Configuration\configPage.html" /> <None Remove="Configuration\configPage.html" />
<EmbeddedResource Include="Configuration\configPage.html" /> <EmbeddedResource Include="Configuration\configPage.html" />
<None Remove="Web\RemoteControl.html" />
<EmbeddedResource Include="Web\RemoteControl.html" />
<None Remove="Web\remote-button.js" />
<EmbeddedResource Include="Web\remote-button.js" />
</ItemGroup> </ItemGroup>
</Project> </Project>
+10 -1
View File
@@ -2,10 +2,12 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization; using System.Globalization;
using Jellyfin.Plugin.JellyLMS.Configuration; using Jellyfin.Plugin.JellyLMS.Configuration;
using Jellyfin.Plugin.JellyLMS.Services;
using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Plugins; using MediaBrowser.Common.Plugins;
using MediaBrowser.Model.Plugins; using MediaBrowser.Model.Plugins;
using MediaBrowser.Model.Serialization; using MediaBrowser.Model.Serialization;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.JellyLMS; namespace Jellyfin.Plugin.JellyLMS;
@@ -15,15 +17,22 @@ namespace Jellyfin.Plugin.JellyLMS;
/// </summary> /// </summary>
public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
{ {
private readonly ILogger<Plugin> _logger;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="Plugin"/> class. /// Initializes a new instance of the <see cref="Plugin"/> class.
/// </summary> /// </summary>
/// <param name="applicationPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param> /// <param name="applicationPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param>
/// <param name="xmlSerializer">Instance of the <see cref="IXmlSerializer"/> interface.</param> /// <param name="xmlSerializer">Instance of the <see cref="IXmlSerializer"/> interface.</param>
public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) /// <param name="logger">The logger.</param>
public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer, ILogger<Plugin> logger)
: base(applicationPaths, xmlSerializer) : base(applicationPaths, xmlSerializer)
{ {
Instance = this; Instance = this;
_logger = logger;
WebClientPatchService.Apply(ApplicationPaths, Configuration.EnableHomeScreenButton, _logger);
ConfigurationChanged += (_, _) => WebClientPatchService.Apply(ApplicationPaths, Configuration.EnableHomeScreenButton, _logger);
} }
/// <inheritdoc /> /// <inheritdoc />
@@ -211,7 +211,13 @@ public class LmsDeviceDiscoveryService : IHostedService, IDisposable
SupportsPersistentIdentifier = true SupportsPersistentIdentifier = true
}; };
#if NET10_0_OR_GREATER
// Jellyfin 12 added a controlling session parameter; an empty value skips the
// "can control" assertion, which is what we want for a server-side registration.
sessionManager.ReportCapabilities(string.Empty, session.Id, capabilities);
#else
sessionManager.ReportCapabilities(session.Id, capabilities); sessionManager.ReportCapabilities(session.Id, capabilities);
#endif
// Track this device // Track this device
_registeredDeviceIds[player.MacAddress] = deviceId; _registeredDeviceIds[player.MacAddress] = deviceId;
@@ -59,6 +59,9 @@ public class LmsSessionController : ISessionController, IDisposable
_libraryManager = libraryManager; _libraryManager = libraryManager;
_stateMachine = new PlaybackStateMachine(logger); _stateMachine = new PlaybackStateMachine(logger);
_statusPoller = new LmsStatusPoller(lmsClient, logger); _statusPoller = new LmsStatusPoller(lmsClient, logger);
// Start status polling immediately to keep volume in sync
StartProgressTimer();
} }
private static PluginConfiguration Config => Plugin.Instance?.Configuration ?? new PluginConfiguration(); private static PluginConfiguration Config => Plugin.Instance?.Configuration ?? new PluginConfiguration();
@@ -295,7 +298,7 @@ public class LmsSessionController : ISessionController, IDisposable
// Stop any existing timer // Stop any existing timer
_progressTimer?.Dispose(); _progressTimer?.Dispose();
// Report progress every 2 seconds // Poll status every 2 seconds (for progress reporting when playing and volume sync always)
_progressTimer = new Timer( _progressTimer = new Timer(
async _ => await ReportPlaybackProgressAsync().ConfigureAwait(false), async _ => await ReportPlaybackProgressAsync().ConfigureAwait(false),
null, null,
@@ -305,13 +308,26 @@ public class LmsSessionController : ISessionController, IDisposable
private void StopProgressTimer() private void StopProgressTimer()
{ {
_progressTimer?.Dispose(); // Don't actually stop the timer - keep polling for volume updates
_progressTimer = null; // This ensures Jellyfin stays in sync with the device volume
// even when not playing media
} }
private async Task ReportPlaybackProgressAsync() private async Task ReportPlaybackProgressAsync()
{ {
// Don't report during Loading, Seeking, or Error states try
{
// Always poll status to keep volume in sync, even when not playing
var status = await _lmsClient.GetPlayerStatusAsync(_player.MacAddress).ConfigureAwait(false);
if (status == null)
{
return;
}
// Update cached volume so Jellyfin stays in sync with device
_player.Volume = status.Volume;
// Don't report playback progress during Loading, Seeking, Error, Stopped, or Idle states
var currentState = _stateMachine.CurrentState; var currentState = _stateMachine.CurrentState;
if (currentState == PlaybackState.Loading if (currentState == PlaybackState.Loading
|| currentState == PlaybackState.Seeking || currentState == PlaybackState.Seeking
@@ -323,14 +339,6 @@ public class LmsSessionController : ISessionController, IDisposable
return; return;
} }
try
{
var status = await _lmsClient.GetPlayerStatusAsync(_player.MacAddress).ConfigureAwait(false);
if (status == null)
{
return;
}
// LMS reports time relative to the current stream, but after seeking // LMS reports time relative to the current stream, but after seeking
// we're playing a transcoded stream that starts at the seek position. // we're playing a transcoded stream that starts at the seek position.
// Add the seek offset to get the actual track position. // Add the seek offset to get the actual track position.
@@ -812,7 +820,9 @@ public class LmsSessionController : ISessionController, IDisposable
_cancellationTokenSource.Cancel(); _cancellationTokenSource.Cancel();
_cancellationTokenSource.Dispose(); _cancellationTokenSource.Dispose();
StopProgressTimer(); // Actually stop the timer when disposing
_progressTimer?.Dispose();
_progressTimer = null;
// Reset state machine // Reset state machine
_stateMachine.Reset(); _stateMachine.Reset();
@@ -0,0 +1,72 @@
using System;
using System.IO;
using MediaBrowser.Common.Configuration;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.JellyLMS.Services;
/// <summary>
/// Injects (or removes) a script tag in the web client's <c>index.html</c>
/// that adds a floating button linking to the JellyLMS remote control page.
/// This follows the pattern used by other Jellyfin plugins (e.g. Intro Skipper)
/// since there is no official plugin hook for adding buttons to the web client.
/// </summary>
public static class WebClientPatchService
{
private const string Marker = "<!-- jellylms-remote-button -->";
private const string ScriptTag = "<script defer src=\"/JellyLms/RemoteControl/ClientScript\"></script>";
private const string Injected = ScriptTag + Marker + "\n</body>";
/// <summary>
/// Ensures the web client's index.html either has or does not have the
/// JellyLMS remote button script injected, matching <paramref name="enableButton"/>.
/// </summary>
/// <param name="applicationPaths">The Jellyfin application paths.</param>
/// <param name="enableButton">Whether the remote button script should be present.</param>
/// <param name="logger">The logger.</param>
public static void Apply(IApplicationPaths applicationPaths, bool enableButton, ILogger logger)
{
var indexPath = Path.Combine(applicationPaths.WebPath, "index.html");
try
{
if (!File.Exists(indexPath))
{
logger.LogDebug("JellyLMS: web client index.html not found at {Path}", indexPath);
return;
}
var html = File.ReadAllText(indexPath);
var hasMarker = html.Contains(Marker, StringComparison.Ordinal);
if (enableButton && !hasMarker)
{
var patched = ReplaceLast(html, "</body>", Injected);
File.WriteAllText(indexPath, patched);
logger.LogInformation("JellyLMS: injected remote control button into {Path}", indexPath);
}
else if (!enableButton && hasMarker)
{
var patched = html.Replace(ScriptTag + Marker + "\n", string.Empty, StringComparison.Ordinal)
.Replace(ScriptTag + Marker, string.Empty, StringComparison.Ordinal);
File.WriteAllText(indexPath, patched);
logger.LogInformation("JellyLMS: removed remote control button from {Path}", indexPath);
}
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
logger.LogWarning(ex, "JellyLMS: failed to patch web client index.html at {Path}", indexPath);
}
}
private static string ReplaceLast(string source, string find, string replace)
{
var index = source.LastIndexOf(find, StringComparison.Ordinal);
if (index < 0)
{
return source;
}
return source[..index] + replace + source[(index + find.Length)..];
}
}
@@ -0,0 +1,393 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>JellyLMS Remote</title>
<style>
:root {
color-scheme: dark;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: -apple-system, "Helvetica Neue", Helvetica, Arial, sans-serif;
background: #101010;
color: #fff;
padding: 16px;
padding-bottom: 60px;
}
h1 {
font-size: 1.4em;
font-weight: 500;
margin: 8px 0 16px;
}
h2 {
font-size: 1.05em;
font-weight: 500;
color: #ccc;
margin: 24px 0 8px;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.card {
background: #1c1c1c;
border-radius: 10px;
padding: 14px;
margin-bottom: 10px;
}
.player-row {
display: flex;
align-items: center;
gap: 12px;
}
.player-info {
flex: 1;
min-width: 0;
}
.player-name {
font-weight: 500;
font-size: 1.05em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.player-status {
display: flex;
align-items: center;
gap: 6px;
color: #888;
font-size: 0.85em;
margin-top: 2px;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
}
.status-dot.on { background: #52b54b; }
.status-dot.standby { background: #f9a825; }
.status-dot.off { background: #f44336; }
.power-btn {
border: none;
border-radius: 50%;
width: 44px;
height: 44px;
font-size: 1.2em;
background: #2a2a2a;
color: #aaa;
cursor: pointer;
flex-shrink: 0;
}
.power-btn.on {
background: #00a4dc;
color: #fff;
}
.volume-row {
display: flex;
align-items: center;
gap: 10px;
margin-top: 10px;
}
.volume-row input[type="range"] {
flex: 1;
}
.volume-value {
width: 2.5em;
text-align: right;
color: #ccc;
font-size: 0.9em;
}
.sync-group-players {
color: #ccc;
flex: 1;
}
.sync-checkbox-row {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 0;
}
button.action {
background: #00a4dc;
color: #fff;
border: none;
border-radius: 6px;
padding: 8px 14px;
font-size: 0.95em;
cursor: pointer;
}
button.action:disabled {
background: #333;
color: #777;
cursor: default;
}
button.action.alt {
background: #333;
color: #ccc;
}
.empty, .message {
color: #888;
padding: 8px 0;
}
.message.error { color: #f44336; }
.message a { color: #00a4dc; }
#refreshBtn {
position: fixed;
bottom: 16px;
right: 16px;
}
</style>
</head>
<body>
<h1>🔊 JellyLMS Remote</h1>
<div id="app">
<p class="message">Loading…</p>
</div>
<script>
var API_BASE = '/JellyLms';
var state = { players: [], syncGroups: [] };
function getAuthToken() {
try {
var creds = JSON.parse(localStorage.getItem('jellyfin_credentials'));
var server = creds && creds.Servers && creds.Servers[0];
return (server && server.AccessToken) || null;
} catch (e) {
return null;
}
}
function api(path, options) {
options = options || {};
var headers = options.headers || {};
var token = getAuthToken();
if (token) {
headers['X-Emby-Token'] = token;
}
if (options.body) {
headers['Content-Type'] = 'application/json';
}
return fetch(API_BASE + path, {
method: options.method || 'GET',
headers: headers,
body: options.body
}).then(function (resp) {
if (!resp.ok) {
var err = new Error('Request failed: ' + resp.status);
err.status = resp.status;
throw err;
}
if (resp.status === 204) {
return null;
}
var contentType = resp.headers.get('content-type') || '';
return contentType.indexOf('application/json') !== -1 ? resp.json() : null;
});
}
function renderMessage(text, isError) {
document.getElementById('app').innerHTML =
'<p class="message' + (isError ? ' error' : '') + '">' + text + '</p>';
}
function getStatusClass(player) {
if (!player.IsConnected) return 'off';
return player.IsPoweredOn ? 'on' : 'standby';
}
function getStatusText(player) {
if (!player.IsConnected) return 'Disconnected';
return player.IsPoweredOn ? 'Playing' : 'Standby';
}
function syncedMacs() {
var macs = new Set();
state.syncGroups.forEach(function (group) {
macs.add(group.MasterMac);
group.SlaveMacs.forEach(function (mac) { macs.add(mac); });
});
return macs;
}
function findPlayer(mac) {
return state.players.find(function (p) { return p.MacAddress === mac; });
}
function render() {
var app = document.getElementById('app');
var html = '';
html += '<h2>Players</h2>';
if (state.players.length === 0) {
html += '<p class="empty">No players found.</p>';
} else {
state.players.forEach(function (player) {
var mac = player.MacAddress;
html += '<div class="card">';
html += '<div class="player-row">';
html += '<div class="player-info">';
html += '<div class="player-name">' + player.Name + '</div>';
html += '<div class="player-status"><span class="status-dot ' + getStatusClass(player) + '"></span>' +
'<span>' + getStatusText(player) + '</span></div>';
html += '</div>';
html += '<button class="power-btn' + (player.IsPoweredOn ? ' on' : '') + '" data-action="power" data-mac="' + mac + '" data-on="' + player.IsPoweredOn + '" title="Power"></button>';
html += '</div>';
html += '<div class="volume-row">';
html += '<span>🔈</span>';
html += '<input type="range" min="0" max="100" value="' + player.Volume + '" data-action="volume" data-mac="' + mac + '">';
html += '<span class="volume-value">' + player.Volume + '</span>';
html += '</div>';
html += '</div>';
});
}
html += '<h2>Multi-Room Sync</h2>';
if (state.syncGroups.length > 0) {
state.syncGroups.forEach(function (group) {
var names = [];
var master = findPlayer(group.MasterMac);
if (master) names.push(master.Name);
group.SlaveMacs.forEach(function (mac) {
var p = findPlayer(mac);
if (p) names.push(p.Name);
});
html += '<div class="card">';
html += '<div class="player-row">';
html += '<span class="sync-group-players">' + names.join(' + ') + '</span>';
html += '<button class="action alt" data-action="unsync" data-master="' + group.MasterMac + '">Unsync</button>';
html += '</div>';
html += '</div>';
});
}
var unsynced = state.players.filter(function (p) { return !syncedMacs().has(p.MacAddress); });
if (unsynced.length > 1) {
html += '<div class="card">';
html += '<p class="empty" style="margin-top:0;">Select players to sync together:</p>';
unsynced.forEach(function (player) {
html += '<label class="sync-checkbox-row">';
html += '<input type="checkbox" data-action="sync-select" data-mac="' + player.MacAddress + '">';
html += '<span>' + player.Name + '</span>';
html += '</label>';
});
html += '<div style="margin-top:10px;">';
html += '<button class="action" id="syncSelectedBtn" disabled>Sync Selected</button>';
html += '</div>';
html += '</div>';
} else if (state.syncGroups.length === 0) {
html += '<p class="empty">No players synced yet.</p>';
}
app.innerHTML = html;
attachHandlers();
}
function attachHandlers() {
document.querySelectorAll('[data-action="power"]').forEach(function (btn) {
btn.addEventListener('click', function () {
var mac = btn.getAttribute('data-mac');
var isOn = btn.getAttribute('data-on') === 'true';
var endpoint = isOn ? '/Players/' + encodeURIComponent(mac) + '/PowerOff' : '/Players/' + encodeURIComponent(mac) + '/PowerOn';
btn.disabled = true;
api(endpoint, { method: 'POST' }).then(loadPlayers).catch(function () {
btn.disabled = false;
});
});
});
document.querySelectorAll('[data-action="volume"]').forEach(function (input) {
input.addEventListener('change', function () {
var mac = input.getAttribute('data-mac');
var volume = parseInt(input.value, 10);
input.nextElementSibling.textContent = volume;
api('/Players/' + encodeURIComponent(mac) + '/Volume', {
method: 'POST',
body: JSON.stringify({ Volume: volume })
}).catch(function () {});
});
input.addEventListener('input', function () {
input.nextElementSibling.textContent = input.value;
});
});
document.querySelectorAll('[data-action="unsync"]').forEach(function (btn) {
btn.addEventListener('click', function () {
var masterMac = btn.getAttribute('data-master');
btn.disabled = true;
api('/SyncGroups/' + encodeURIComponent(masterMac), { method: 'DELETE' })
.then(loadAll)
.catch(function () { btn.disabled = false; });
});
});
var syncBtn = document.getElementById('syncSelectedBtn');
if (syncBtn) {
var checkboxes = document.querySelectorAll('[data-action="sync-select"]');
var updateSyncBtn = function () {
var checked = Array.from(checkboxes).filter(function (cb) { return cb.checked; });
syncBtn.disabled = checked.length < 2;
};
checkboxes.forEach(function (cb) { cb.addEventListener('change', updateSyncBtn); });
syncBtn.addEventListener('click', function () {
var macs = Array.from(checkboxes).filter(function (cb) { return cb.checked; })
.map(function (cb) { return cb.getAttribute('data-mac'); });
if (macs.length < 2) return;
syncBtn.disabled = true;
api('/SyncGroups', {
method: 'POST',
body: JSON.stringify({ MasterMac: macs[0], SlaveMacs: macs.slice(1) })
}).then(loadAll).catch(function () { syncBtn.disabled = false; });
});
}
}
function loadPlayers() {
return api('/Players?refresh=true').then(function (players) {
state.players = players || [];
render();
});
}
function loadSyncGroups() {
return api('/SyncGroups').then(function (groups) {
state.syncGroups = groups || [];
});
}
function loadAll() {
return Promise.all([loadPlayers(), loadSyncGroups()]).then(render);
}
function init() {
if (!getAuthToken()) {
renderMessage('Please <a href="/web/">log in to Jellyfin</a> first, then reload this page.', true);
return;
}
api('/RemoteControl/Access').then(function () {
loadAll().catch(function () {
renderMessage('Failed to load players. Check the JellyLMS plugin configuration.', true);
});
}).catch(function (err) {
if (err.status === 403) {
renderMessage('Your account does not have permission to use the multi-room remote. Ask an admin to grant "Allow remote control of other users".', true);
} else {
renderMessage('Could not reach JellyLMS. <a href="/web/">Return to Jellyfin</a>.', true);
}
});
}
init();
</script>
</body>
</html>
@@ -0,0 +1,309 @@
(function () {
var hasAccess = false;
var panel = null;
function getAuthToken() {
try {
var creds = JSON.parse(localStorage.getItem('jellyfin_credentials'));
var server = creds && creds.Servers && creds.Servers[0];
return (server && server.AccessToken) || null;
} catch (e) {
return null;
}
}
function api(path, options) {
var token = getAuthToken();
var opts = Object.assign({ headers: {} }, options);
if (token) {
opts.headers['X-Emby-Token'] = token;
}
if (opts.body && typeof opts.body === 'object') {
opts.body = JSON.stringify(opts.body);
opts.headers['Content-Type'] = 'application/json';
}
return fetch('/JellyLms' + path, opts);
}
function checkAccess() {
return api('/RemoteControl/Access')
.then(function (r) { return r.ok; })
.catch(function () { return false; });
}
// ---------- panel ----------
function createPanel(anchorBtn) {
var rect = anchorBtn.getBoundingClientRect();
var p = document.createElement('div');
p.id = 'jellylms-panel';
p.style.cssText = [
'position:fixed',
'top:' + (rect.bottom + 4) + 'px',
'right:' + (window.innerWidth - rect.right) + 'px',
'width:300px',
'max-height:70vh',
'overflow-y:auto',
'background:#1c1c1c',
'border:1px solid #333',
'border-radius:4px',
'box-shadow:0 4px 24px rgba(0,0,0,.7)',
'z-index:999999',
'font-family:inherit',
'font-size:14px',
'color:#ddd',
'padding:12px',
].join(';');
return p;
}
function el(tag, css, html) {
var e = document.createElement(tag);
if (css) { e.style.cssText = css; }
if (html != null) { e.innerHTML = html; }
return e;
}
function renderError(p, msg) {
p.innerHTML = '<div style="color:#f44;padding:8px">' + msg + '</div>';
}
function renderPlayers(p, players) {
p.innerHTML = '';
// ---- Players ----
p.appendChild(el('div',
'font-size:11px;text-transform:uppercase;letter-spacing:.08em;color:#888;margin-bottom:8px',
'Players'));
players.forEach(function (player) {
// name row: status dot + name + power button
var row = el('div', 'display:flex;align-items:center;gap:8px;margin-bottom:6px');
row.appendChild(el('span',
'width:8px;height:8px;border-radius:50%;flex-shrink:0;background:' +
(player.isConnected ? '#4caf50' : '#555')));
var label = player.name || player.macAddress;
row.appendChild(el('span',
'flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap',
label));
var pwrBtn = el('button',
'background:none;border:1px solid #555;border-radius:3px;color:#ddd;' +
'padding:2px 7px;cursor:pointer;font-size:12px;flex-shrink:0',
player.isPoweredOn ? 'Off' : 'On');
pwrBtn.title = player.isPoweredOn ? 'Power off' : 'Power on';
pwrBtn.addEventListener('click', function () {
var endpoint = player.isPoweredOn
? '/Players/' + player.macAddress + '/PowerOff'
: '/Players/' + player.macAddress + '/PowerOn';
api(endpoint, { method: 'POST' }).then(function () { refresh(p); });
});
row.appendChild(pwrBtn);
p.appendChild(row);
// volume row — show when powered on
if (player.isPoweredOn) {
var volRow = el('div',
'display:flex;align-items:center;gap:8px;margin-bottom:8px;padding-left:16px');
volRow.appendChild(el('span',
'color:#888;font-size:16px;font-family:"Material Icons";line-height:1',
'volume_up'));
var slider = document.createElement('input');
slider.type = 'range';
slider.min = 0;
slider.max = 100;
slider.value = player.volume;
slider.style.cssText = 'flex:1;accent-color:#00a4dc';
slider.addEventListener('change', function () {
api('/Players/' + player.macAddress + '/Volume', {
method: 'POST',
body: { volume: parseInt(slider.value, 10) }
});
});
volRow.appendChild(slider);
var volVal = el('span', 'color:#888;font-size:12px;width:28px;text-align:right',
player.volume + '%');
slider.addEventListener('input', function () { volVal.textContent = slider.value + '%'; });
volRow.appendChild(volVal);
p.appendChild(volRow);
}
});
// ---- Sync Groups ----
var synced = {};
players.forEach(function (pl) {
if (pl.syncMaster) { synced[pl.macAddress] = true; }
if (pl.syncSlaves && pl.syncSlaves.length) {
synced[pl.macAddress] = true;
pl.syncSlaves.forEach(function (m) { synced[m] = true; });
}
});
var masters = players.filter(function (pl) {
return pl.syncSlaves && pl.syncSlaves.length > 0;
});
if (masters.length > 0) {
p.appendChild(el('div', 'border-top:1px solid #333;margin:8px 0'));
p.appendChild(el('div',
'font-size:11px;text-transform:uppercase;letter-spacing:.08em;color:#888;margin-bottom:8px',
'Sync Groups'));
masters.forEach(function (master) {
var slaveNames = master.syncSlaves.map(function (mac) {
var found = players.find(function (pl) { return pl.macAddress === mac; });
return found ? (found.name || mac) : mac;
});
var label = (master.name || master.macAddress) + ' + ' + slaveNames.join(', ');
var grow = el('div', 'display:flex;align-items:center;gap:8px;margin-bottom:8px');
grow.appendChild(el('span',
'flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px',
label));
var dissolveBtn = el('button',
'background:none;border:1px solid #555;border-radius:3px;color:#f88;' +
'padding:2px 7px;cursor:pointer;font-size:12px;flex-shrink:0',
'Unsync');
dissolveBtn.addEventListener('click', function () {
api('/SyncGroups/' + master.macAddress, { method: 'DELETE' })
.then(function () { refresh(p); });
});
grow.appendChild(dissolveBtn);
p.appendChild(grow);
});
}
// ---- Create Sync Group ----
var unsynced = players.filter(function (pl) { return !synced[pl.macAddress]; });
if (unsynced.length >= 2) {
p.appendChild(el('div', 'border-top:1px solid #333;margin:8px 0'));
p.appendChild(el('div',
'font-size:11px;text-transform:uppercase;letter-spacing:.08em;color:#888;margin-bottom:8px',
'Create Sync Group'));
var checkboxes = [];
unsynced.forEach(function (pl) {
var crow = el('div', 'display:flex;align-items:center;gap:8px;margin-bottom:6px');
var cb = document.createElement('input');
cb.type = 'checkbox';
cb.style.accentColor = '#00a4dc';
cb.dataset.mac = pl.macAddress;
checkboxes.push(cb);
crow.appendChild(cb);
crow.appendChild(el('span', 'flex:1', pl.name || pl.macAddress));
p.appendChild(crow);
});
var syncBtn = el('button',
'margin-top:6px;width:100%;background:#00a4dc;border:none;border-radius:3px;' +
'color:#fff;padding:5px 0;cursor:pointer;font-size:13px',
'Sync Selected');
syncBtn.addEventListener('click', function () {
var selected = checkboxes.filter(function (cb) { return cb.checked; });
if (selected.length < 2) { return; }
var macs = selected.map(function (cb) { return cb.dataset.mac; });
api('/SyncGroups', {
method: 'POST',
body: { masterMac: macs[0], slaveMacs: macs.slice(1) }
}).then(function () { refresh(p); });
});
p.appendChild(syncBtn);
}
}
function refresh(p) {
p.innerHTML = '<div style="color:#888;padding:8px">Loading…</div>';
api('/Players')
.then(function (r) { return r.json(); })
.then(function (players) { renderPlayers(p, players); })
.catch(function () { renderError(p, 'Failed to load players.'); });
}
function togglePanel(btn) {
if (panel) {
panel.remove();
panel = null;
return;
}
panel = createPanel(btn);
document.body.appendChild(panel);
refresh(panel);
}
function closePanel() {
if (panel) {
panel.remove();
panel = null;
}
}
// ---------- header button ----------
function addButton(headerRight) {
if (headerRight.querySelector('.headerLmsRemoteButton')) {
return;
}
var btn = document.createElement('button');
btn.setAttribute('is', 'paper-icon-button-light');
btn.setAttribute('type', 'button');
btn.className = 'headerLmsRemoteButton headerButton headerButtonRight paper-icon-button-light';
btn.title = 'Multi-room Remote';
btn.innerHTML = '<span class="material-icons speaker_group" aria-hidden="true"></span>';
btn.addEventListener('click', function (e) {
e.stopPropagation();
togglePanel(btn);
});
var castButton = headerRight.querySelector('.headerCastButton');
if (castButton) {
headerRight.insertBefore(btn, castButton);
} else {
headerRight.appendChild(btn);
}
}
function tryInject() {
if (!hasAccess) { return; }
var headerRight = document.querySelector('.headerRight');
if (headerRight) { addButton(headerRight); }
}
// ---------- bootstrap ----------
function init() {
checkAccess().then(function (ok) {
hasAccess = ok;
if (!ok) { return; }
tryInject();
var observer = new MutationObserver(function () {
if (panel && !document.body.contains(panel)) { panel = null; }
tryInject();
});
observer.observe(document.body, { childList: true, subtree: true });
document.addEventListener('click', function (e) {
if (panel && !panel.contains(e.target)) { closePanel(); }
});
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
+23 -7
View File
@@ -76,10 +76,19 @@ Create and manage synchronized playback groups for multi-room audio:
## Requirements ## Requirements
- Jellyfin Server 10.10.0 or later - Jellyfin Server 10.11.x or 12.0.x
- .NET 9.0 Runtime
- Logitech Media Server (LMS) with JSON-RPC API enabled (default on port 9000) - Logitech Media Server (LMS) with JSON-RPC API enabled (default on port 9000)
Each release ships two packages, one per Jellyfin generation:
| Package | Jellyfin | targetAbi | Runtime |
| --- | --- | --- | --- |
| `jellylms_<version>_jf11.zip` | 10.11.x | `10.11.0.0` | .NET 9.0 |
| `jellylms_<version>_jf12.zip` | 12.0.x | `12.0.0.0` | .NET 10.0 |
If you install through the plugin repository, Jellyfin picks the matching
package automatically. For manual installs, download the one for your server.
## Playback Architecture ## Playback Architecture
JellyLMS uses Jellyfin's native "Play On" (cast) interface to control LMS players. When you select an LMS player from Jellyfin's cast menu, playback is managed through a robust state machine that ensures reliable operation. JellyLMS uses Jellyfin's native "Play On" (cast) interface to control LMS players. When you select an LMS player from Jellyfin's cast menu, playback is managed through a robust state machine that ensures reliable operation.
@@ -151,13 +160,20 @@ The state machine includes automatic retry with exponential backoff:
git clone https://gitea.tourolle.paris/dtourolle/jellyLMS.git git clone https://gitea.tourolle.paris/dtourolle/jellyLMS.git
cd jellyLMS cd jellyLMS
# Build # Build both target frameworks
dotnet build Jellyfin.Plugin.JellyLMS.sln -c Release dotnet build Jellyfin.Plugin.JellyLMS.sln -c Release
# The DLL will be in: # The DLLs will be in:
# Jellyfin.Plugin.JellyLMS/bin/Release/net9.0/ # Jellyfin.Plugin.JellyLMS/bin/Release/net9.0/ (Jellyfin 10.11.x)
# Jellyfin.Plugin.JellyLMS/bin/Release/net10.0/ (Jellyfin 12.0.x)
# Or produce installable plugin packages (requires jprm)
./build-plugin.sh jf11
./build-plugin.sh jf12
``` ```
Building requires the .NET 10 SDK, which can target both `net9.0` and `net10.0`.
## Configuration ## Configuration
1. Navigate to Jellyfin Dashboard → Plugins → JellyLMS 1. Navigate to Jellyfin Dashboard → Plugins → JellyLMS
@@ -293,8 +309,8 @@ Jellyfin.Plugin.JellyLMS/
### Building for Development ### Building for Development
```bash ```bash
# Build in debug mode # Build in debug mode (use -f net10.0 for a Jellyfin 12 server)
dotnet build Jellyfin.Plugin.JellyLMS.sln dotnet build Jellyfin.Plugin.JellyLMS.sln -f net9.0
# Copy to Jellyfin plugins directory # Copy to Jellyfin plugins directory
cp Jellyfin.Plugin.JellyLMS/bin/Debug/net9.0/Jellyfin.Plugin.JellyLMS.dll \ cp Jellyfin.Plugin.JellyLMS/bin/Debug/net9.0/Jellyfin.Plugin.JellyLMS.dll \
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env bash
#
# Package the JellyLMS plugin for one Jellyfin server generation.
#
# ./build-plugin.sh jf11 [version] -> Jellyfin 10.11.x, net9.0
# ./build-plugin.sh jf12 [version] -> Jellyfin 12.0.x, net10.0
#
# jprm always reads ./build.yaml, so this temporarily rewrites the targetAbi and
# framework fields for the requested variant and restores the file afterwards.
# The resulting zip is written to artifacts/ with a variant suffix, and its path
# is printed on stdout.
set -euo pipefail
VARIANT="${1:-}"
VERSION="${2:-}"
case "${VARIANT}" in
jf11) TARGET_ABI="10.11.0.0"; FRAMEWORK="net9.0" ;;
jf12) TARGET_ABI="12.0.0.0"; FRAMEWORK="net10.0" ;;
*) echo "usage: $0 <jf11|jf12> [version]" >&2; exit 1 ;;
esac
cd "$(dirname "$0")"
if [ -z "${VERSION}" ]; then
VERSION=$(sed -n 's/^version:[[:space:]]*"\{0,1\}\([^"]*\)"\{0,1\}[[:space:]]*$/\1/p' build.yaml)
fi
# jprm normalises the version to four components for the artifact name.
FULL_VERSION="${VERSION}"
while [ "$(printf '%s' "${FULL_VERSION}" | tr -cd '.' | wc -c)" -lt 3 ]; do
FULL_VERSION="${FULL_VERSION}.0"
done
BACKUP=$(mktemp)
cp build.yaml "${BACKUP}"
trap 'cp "${BACKUP}" build.yaml; rm -f "${BACKUP}"' EXIT
sed -i "s/^version:.*/version: \"${VERSION}\"/" build.yaml
sed -i "s/^targetAbi:.*/targetAbi: \"${TARGET_ABI}\"/" build.yaml
sed -i "s/^framework:.*/framework: \"${FRAMEWORK}\"/" build.yaml
echo "Building ${VARIANT}: targetAbi=${TARGET_ABI} framework=${FRAMEWORK} version=${VERSION}" >&2
mkdir -p "artifacts/${VARIANT}"
jprm --verbosity=debug plugin build ./ --output "artifacts/${VARIANT}" >&2
SRC="artifacts/${VARIANT}/jellylms_${FULL_VERSION}.zip"
DEST="artifacts/jellylms_${FULL_VERSION}_${VARIANT}.zip"
mv "${SRC}" "${DEST}"
rm -rf "artifacts/${VARIANT}"
echo "${DEST}"
+2
View File
@@ -100,6 +100,8 @@
<Rule Id="CA1308" Action="None" /> <Rule Id="CA1308" Action="None" />
<!-- disable warning CA1848: Use the LoggerMessage delegates --> <!-- disable warning CA1848: Use the LoggerMessage delegates -->
<Rule Id="CA1848" Action="None" /> <Rule Id="CA1848" Action="None" />
<!-- disable warning CA1873: Evaluation of logging argument may be expensive (same rationale as CA1848) -->
<Rule Id="CA1873" Action="None" />
<!-- disable warning CA2101: Specify marshaling for P/Invoke string arguments --> <!-- disable warning CA2101: Specify marshaling for P/Invoke string arguments -->
<Rule Id="CA2101" Action="None" /> <Rule Id="CA2101" Action="None" />
<!-- disable warning CA2234: Pass System.Uri objects instead of strings --> <!-- disable warning CA2234: Pass System.Uri objects instead of strings -->
+48
View File
@@ -7,6 +7,54 @@
"owner": "dtourolle", "owner": "dtourolle",
"category": "Music", "category": "Music",
"versions": [ "versions": [
{
"version": "1.0.6",
"changelog": "Release 1.0.6",
"targetAbi": "10.10.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyLMS/releases/download/v1.0.6/jellylms_1.0.6.0.zip",
"checksum": "026fae22c793a5e71fb322811d964dfd",
"timestamp": "2026-06-17T20:24:41Z"
},
{
"version": "1.0.5",
"changelog": "Release 1.0.5",
"targetAbi": "10.10.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyLMS/releases/download/v1.0.5/jellylms_1.0.5.0.zip",
"checksum": "13ecfe05b1a0f4f137211d9b6660f66e",
"timestamp": "2026-06-17T19:20:00Z"
},
{
"version": "1.0.4",
"changelog": "Release 1.0.4",
"targetAbi": "10.10.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyLMS/releases/download/v1.0.4/jellylms_1.0.4.0.zip",
"checksum": "6db64bf2ad625c735aff5178e0b53894",
"timestamp": "2026-06-17T18:44:44Z"
},
{
"version": "1.0.3",
"changelog": "Release 1.0.3",
"targetAbi": "10.10.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyLMS/releases/download/v1.0.3/jellylms_1.0.3.0.zip",
"checksum": "c6fa1b9f303babb9664e35cca1180985",
"timestamp": "2026-06-14T15:48:18Z"
},
{
"version": "1.0.2",
"changelog": "Release 1.0.2",
"targetAbi": "10.10.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyLMS/releases/download/v1.0.2/jellylms_1.0.2.0.zip",
"checksum": "43e4fcd6dc67be82a1e9d8816cdf00df",
"timestamp": "2026-01-25T18:35:06Z"
},
{
"version": "1.0.1",
"changelog": "Release 1.0.1",
"targetAbi": "10.10.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyLMS/releases/download/v1.0.1/jellylms_1.0.1.0.zip",
"checksum": "093a1821b86a220cdfad49c3d93345a7",
"timestamp": "2025-12-30T13:43:10Z"
},
{ {
"version": "1.0.0", "version": "1.0.0",
"changelog": "Release 1.0.0", "changelog": "Release 1.0.0",