12 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
16 changed files with 1245 additions and 127 deletions
+27 -23
View File
@@ -15,44 +15,48 @@ on:
jobs:
build:
runs-on: ubuntu-latest
runs-on: linux/amd64
container:
image: gitea.tourolle.paris/dtourolle/jellylms-builder:latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
path: build-${{ github.run_id }}
- name: Verify .NET installation
run: dotnet --version
- name: Cache NuGet packages
uses: actions/cache@v3
with:
path: ~/.nuget/packages
key: nuget-${{ hashFiles('**/Jellyfin.Plugin.JellyLMS.csproj') }}
restore-keys: nuget-
- name: Restore dependencies
working-directory: build-${{ github.run_id }}
run: dotnet restore Jellyfin.Plugin.JellyLMS.sln
- 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: |
python3 -m venv /tmp/jprm-venv
/tmp/jprm-venv/bin/pip install jprm
for VARIANT in jf11 jf12; do
ARTIFACT=$(./build-plugin.sh "${VARIANT}")
cp "${ARTIFACT}" "artifacts/jellylms_latest_${VARIANT}.zip"
echo "Built ${VARIANT}: ${ARTIFACT}"
done
- name: Build Jellyfin Plugin
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
- name: Upload build artifacts
uses: actions/upload-artifact@v3
with:
name: jellylms-plugin
path: ${{ steps.jprm.outputs.artifact }}
path: build-${{ github.run_id }}/artifacts/jellylms_latest_*.zip
retention-days: 30
if-no-files-found: error
- name: Cleanup
if: always()
run: rm -rf build-${{ github.run_id }}
+100 -92
View File
@@ -13,14 +13,15 @@ on:
jobs:
build-and-release:
runs-on: ubuntu-latest
runs-on: linux/amd64
container:
image: gitea.tourolle.paris/dtourolle/jellylms-builder:latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Verify .NET installation
run: dotnet --version
with:
path: release-${{ github.run_id }}
- name: Get version
id: get_version
@@ -34,40 +35,36 @@ jobs:
echo "version_number=${VERSION#v}" >> $GITHUB_OUTPUT
echo "Building version: ${VERSION}"
- name: Update build.yaml with version
run: |
VERSION="${{ steps.get_version.outputs.version_number }}"
sed -i "s/^version:.*/version: \"${VERSION}\"/" build.yaml
cat build.yaml
- name: Cache NuGet packages
uses: actions/cache@v3
with:
path: ~/.nuget/packages
key: nuget-${{ hashFiles('**/Jellyfin.Plugin.JellyLMS.csproj') }}
restore-keys: nuget-
- name: Restore dependencies
working-directory: release-${{ github.run_id }}
run: dotnet restore Jellyfin.Plugin.JellyLMS.sln
- 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
run: |
python3 -m venv /tmp/jprm-venv
/tmp/jprm-venv/bin/pip install jprm
- name: Build Jellyfin Plugin
- name: Build Jellyfin plugin packages
id: jprm
working-directory: release-${{ github.run_id }}
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)
ARTIFACT_NAME=$(basename "${ARTIFACT}")
echo "artifact=${ARTIFACT}" >> $GITHUB_OUTPUT
echo "artifact_name=${ARTIFACT_NAME}" >> $GITHUB_OUTPUT
echo "Found artifact: ${ARTIFACT}"
VERSION="${{ steps.get_version.outputs.version_number }}"
for VARIANT in jf11 jf12; do
ARTIFACT=$(./build-plugin.sh "${VARIANT}" "${VERSION}")
echo "${VARIANT}_artifact=${ARTIFACT}" >> $GITHUB_OUTPUT
echo "${VARIANT}_artifact_name=$(basename "${ARTIFACT}")" >> $GITHUB_OUTPUT
echo "${VARIANT}_checksum=$(md5sum "${ARTIFACT}" | awk '{print $1}')" >> $GITHUB_OUTPUT
echo "Built ${VARIANT}: ${ARTIFACT}"
done
- name: Create Release
working-directory: release-${{ github.run_id }}
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
@@ -76,98 +73,109 @@ jobs:
REPO_NAME="${{ github.event.repository.name }}"
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
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 "Content-Type: application/json" \
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases" \
-d "{
\"tag_name\": \"${{ steps.get_version.outputs.version }}\",
\"name\": \"Release ${{ steps.get_version.outputs.version }}\",
\"body\": ${RELEASE_BODY_JSON},
\"draft\": false,
\"prerelease\": false
}")
"${API_URL}/releases" \
-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}')")
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
BODY=$(echo "$RESPONSE" | sed '$d')
if [ "$HTTP_CODE" -ge 200 ] && [ "$HTTP_CODE" -lt 300 ]; then
RELEASE_ID=$(echo "$BODY" | jq -r '.id')
echo "Created release with ID: ${RELEASE_ID}"
else
echo "Failed to create release. HTTP ${HTTP_CODE}"
echo "$BODY"
exit 1
if [ "$HTTP_CODE" -lt 200 ] || [ "$HTTP_CODE" -ge 300 ]; then
echo "Create release returned HTTP ${HTTP_CODE}:"
cat release-response.json
echo "Falling back to an existing release for ${VERSION}..."
curl -sf -o release-response.json \
-H "Authorization: token ${GITEA_TOKEN}" \
"${API_URL}/releases/tags/${VERSION}"
fi
# Upload plugin artifact
echo "Uploading plugin artifact..."
curl -f -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/zip" \
--data-binary "@${{ steps.jprm.outputs.artifact }}" \
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}/assets?name=${{ steps.jprm.outputs.artifact_name }}"
RELEASE_ID=$(jq -r '.id // empty' release-response.json)
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 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"
# 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 \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/zip" \
--data-binary "@${ASSET}" \
"${API_URL}/releases/${RELEASE_ID}/assets?name=$(basename "${ASSET}")"
done
rm -f release-response.json
echo "Release created successfully!"
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
working-directory: release-${{ github.run_id }}
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 }}"
CHECKSUM="${{ steps.checksum.outputs.checksum }}"
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
ARTIFACT_NAME="${{ steps.jprm.outputs.artifact_name }}"
REPO_OWNER="${{ github.repository_owner }}"
REPO_NAME="${{ github.event.repository.name }}"
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
NEW_VERSION=$(cat <<EOF
{
"version": "${VERSION}",
"changelog": "Release ${VERSION}",
"targetAbi": "10.10.0.0",
"sourceUrl": "${DOWNLOAD_URL}",
"checksum": "${CHECKSUM}",
"timestamp": "${TIMESTAMP}"
# One manifest entry per Jellyfin generation. Jellyfin filters the list by
# targetAbi, so both entries can share the same version number.
add_version() {
local abi="$1" artifact_name="$2" checksum="$3"
jq \
--arg version "${VERSION}" \
--arg abi "${abi}" \
--arg url "${RELEASE_URL}/${artifact_name}" \
--arg checksum "${checksum}" \
--arg timestamp "${TIMESTAMP}" \
'.[0].versions = [{
version: $version,
changelog: "Release \($version)",
targetAbi: $abi,
sourceUrl: $url,
checksum: $checksum,
timestamp: $timestamp
}] + .[0].versions' manifest.json > manifest.tmp.json
mv manifest.tmp.json manifest.json
}
EOF
)
# Prepend new version to the versions array in manifest.json
jq --argjson newver "${NEW_VERSION}" '.[0].versions = [$newver] + .[0].versions' manifest.json > manifest.tmp.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:"
cat manifest.json
- name: Commit and push manifest
working-directory: release-${{ github.run_id }}
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
git config user.name "Gitea Actions"
git config user.email "actions@gitea.tourolle.paris"
git add manifest.json
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.Linq;
using System.Net.Mime;
using System.Reflection;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations.Enums;
using Jellyfin.Plugin.JellyLMS.Models;
using Jellyfin.Plugin.JellyLMS.Services;
using MediaBrowser.Controller.Entities;
@@ -29,6 +31,7 @@ public class JellyLmsController : ControllerBase
private readonly ILmsApiClient _lmsClient;
private readonly LmsPlayerManager _playerManager;
private readonly ILibraryManager _libraryManager;
private readonly IUserManager _userManager;
/// <summary>
/// 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="playerManager">The player manager.</param>
/// <param name="libraryManager">The library manager.</param>
/// <param name="userManager">The user manager.</param>
public JellyLmsController(
ILmsApiClient lmsClient,
LmsPlayerManager playerManager,
ILibraryManager libraryManager)
ILibraryManager libraryManager,
IUserManager userManager)
{
_lmsClient = lmsClient;
_playerManager = playerManager;
_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>
@@ -65,8 +108,14 @@ public class JellyLmsController : ControllerBase
/// <returns>List of players.</returns>
[HttpGet("Players")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<List<LmsPlayer>>> GetPlayers([FromQuery] bool refresh = false)
{
if (!HasRemoteControlAccess())
{
return Forbid();
}
var players = await _playerManager.GetPlayersAsync(refresh).ConfigureAwait(false);
return Ok(players);
}
@@ -78,9 +127,15 @@ public class JellyLmsController : ControllerBase
/// <returns>The player details.</returns>
[HttpGet("Players/{mac}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<LmsPlayer>> GetPlayer(string mac)
{
if (!HasRemoteControlAccess())
{
return Forbid();
}
var player = await _playerManager.GetPlayerAsync(mac).ConfigureAwait(false);
if (player == null)
{
@@ -98,8 +153,14 @@ public class JellyLmsController : ControllerBase
[HttpPost("Players/{mac}/PowerOn")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult> PowerOn(string mac)
{
if (!HasRemoteControlAccess())
{
return Forbid();
}
var success = await _lmsClient.PowerOnAsync(mac).ConfigureAwait(false);
return success ? Ok() : BadRequest("Failed to power on player");
}
@@ -112,8 +173,14 @@ public class JellyLmsController : ControllerBase
[HttpPost("Players/{mac}/PowerOff")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult> PowerOff(string mac)
{
if (!HasRemoteControlAccess())
{
return Forbid();
}
var success = await _lmsClient.PowerOffAsync(mac).ConfigureAwait(false);
return success ? Ok() : BadRequest("Failed to power off player");
}
@@ -127,8 +194,14 @@ public class JellyLmsController : ControllerBase
[HttpPost("Players/{mac}/Volume")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult> SetVolume(string mac, [FromBody] VolumeRequest request)
{
if (!HasRemoteControlAccess())
{
return Forbid();
}
var success = await _lmsClient.SetVolumeAsync(mac, request.Volume).ConfigureAwait(false);
return success ? Ok() : BadRequest("Failed to set volume");
}
@@ -139,8 +212,14 @@ public class JellyLmsController : ControllerBase
/// <returns>List of sync groups.</returns>
[HttpGet("SyncGroups")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<List<SyncGroup>>> GetSyncGroups()
{
if (!HasRemoteControlAccess())
{
return Forbid();
}
var groups = await _playerManager.GetSyncGroupsAsync().ConfigureAwait(false);
return Ok(groups);
}
@@ -153,8 +232,14 @@ public class JellyLmsController : ControllerBase
[HttpPost("SyncGroups")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult> CreateSyncGroup([FromBody] CreateSyncGroupRequest request)
{
if (!HasRemoteControlAccess())
{
return Forbid();
}
var success = await _playerManager.CreateSyncGroupAsync(request.MasterMac, request.SlaveMacs)
.ConfigureAwait(false);
return success ? Ok() : BadRequest("Failed to create sync group");
@@ -168,8 +253,14 @@ public class JellyLmsController : ControllerBase
[HttpDelete("SyncGroups/Players/{mac}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult> UnsyncPlayer(string mac)
{
if (!HasRemoteControlAccess())
{
return Forbid();
}
var success = await _playerManager.UnsyncPlayerAsync(mac).ConfigureAwait(false);
return success ? Ok() : BadRequest("Failed to unsync player");
}
@@ -182,12 +273,67 @@ public class JellyLmsController : ControllerBase
[HttpDelete("SyncGroups/{masterMac}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult> DissolveSyncGroup(string masterMac)
{
if (!HasRemoteControlAccess())
{
return Forbid();
}
var success = await _playerManager.DissolveSyncGroupAsync(masterMac).ConfigureAwait(false);
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>
/// Discovers file paths used by Jellyfin's music libraries.
/// Helps users configure path mappings for direct file access.
@@ -36,6 +36,7 @@ public class PluginConfiguration : BasePluginConfiguration
ConnectionTimeoutSeconds = 10;
EnableAutoSync = true;
DefaultPlayerMac = string.Empty;
EnableHomeScreenButton = true;
}
/// <summary>
@@ -75,6 +76,12 @@ public class PluginConfiguration : BasePluginConfiguration
/// </summary>
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>
/// Gets or sets the Jellyfin API key for authenticating stream requests from LMS.
/// </summary>
@@ -182,6 +182,19 @@
</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">
<h3>Player Sync</h3>
<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('#DefaultPlayerMac').value = config.DefaultPlayerMac || '';
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)
JellyLmsConfig.pathMappings = config.PathMappings || [];
@@ -622,6 +636,7 @@
config.EnableAutoSync = document.querySelector('#EnableAutoSync').checked;
config.DefaultPlayerMac = document.querySelector('#DefaultPlayerMac').value;
config.UseDirectFilePath = document.querySelector('#UseDirectFilePath').checked;
config.EnableHomeScreenButton = document.querySelector('#EnableHomeScreenButton').checked;
// Save path mappings (clear legacy single mapping when using list)
config.PathMappings = getPathMappingsFromUI();
config.JellyfinMediaPath = '';
@@ -1,7 +1,13 @@
<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>
<TargetFramework>net9.0</TargetFramework>
<TargetFrameworks>net9.0;net10.0</TargetFrameworks>
<RootNamespace>Jellyfin.Plugin.JellyLMS</RootNamespace>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
@@ -10,8 +16,8 @@
<CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Jellyfin.Controller" Version="10.11.0" >
<ItemGroup Condition="'$(TargetFramework)' == 'net9.0'">
<PackageReference Include="Jellyfin.Controller" Version="10.11.0">
<ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>
<PackageReference Include="Jellyfin.Model" Version="10.11.0">
@@ -19,6 +25,15 @@
</PackageReference>
</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>
<PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" />
<PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.556" PrivateAssets="All" />
@@ -28,6 +43,10 @@
<ItemGroup>
<None Remove="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>
</Project>
+10 -1
View File
@@ -2,10 +2,12 @@ using System;
using System.Collections.Generic;
using System.Globalization;
using Jellyfin.Plugin.JellyLMS.Configuration;
using Jellyfin.Plugin.JellyLMS.Services;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Plugins;
using MediaBrowser.Model.Plugins;
using MediaBrowser.Model.Serialization;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.JellyLMS;
@@ -15,15 +17,22 @@ namespace Jellyfin.Plugin.JellyLMS;
/// </summary>
public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
{
private readonly ILogger<Plugin> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="Plugin"/> class.
/// </summary>
/// <param name="applicationPaths">Instance of the <see cref="IApplicationPaths"/> 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)
{
Instance = this;
_logger = logger;
WebClientPatchService.Apply(ApplicationPaths, Configuration.EnableHomeScreenButton, _logger);
ConfigurationChanged += (_, _) => WebClientPatchService.Apply(ApplicationPaths, Configuration.EnableHomeScreenButton, _logger);
}
/// <inheritdoc />
@@ -211,7 +211,13 @@ public class LmsDeviceDiscoveryService : IHostedService, IDisposable
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);
#endif
// Track this device
_registeredDeviceIds[player.MacAddress] = deviceId;
@@ -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
- Jellyfin Server 10.10.0 or later
- .NET 9.0 Runtime
- Jellyfin Server 10.11.x or 12.0.x
- 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
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
cd jellyLMS
# Build
# Build both target frameworks
dotnet build Jellyfin.Plugin.JellyLMS.sln -c Release
# The DLL will be in:
# Jellyfin.Plugin.JellyLMS/bin/Release/net9.0/
# The DLLs will be in:
# 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
1. Navigate to Jellyfin Dashboard → Plugins → JellyLMS
@@ -293,8 +309,8 @@ Jellyfin.Plugin.JellyLMS/
### Building for Development
```bash
# Build in debug mode
dotnet build Jellyfin.Plugin.JellyLMS.sln
# Build in debug mode (use -f net10.0 for a Jellyfin 12 server)
dotnet build Jellyfin.Plugin.JellyLMS.sln -f net9.0
# Copy to Jellyfin plugins directory
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" />
<!-- disable warning CA1848: Use the LoggerMessage delegates -->
<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 -->
<Rule Id="CA2101" Action="None" />
<!-- disable warning CA2234: Pass System.Uri objects instead of strings -->
+40
View File
@@ -7,6 +7,46 @@
"owner": "dtourolle",
"category": "Music",
"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",