9 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
10 changed files with 497 additions and 112 deletions
+8 -11
View File
@@ -40,23 +40,20 @@ jobs:
working-directory: build-${{ github.run_id }} working-directory: build-${{ github.run_id }}
run: dotnet build Jellyfin.Plugin.JellyLMS.sln --configuration Release --no-restore --no-self-contained /m:1 run: dotnet build Jellyfin.Plugin.JellyLMS.sln --configuration Release --no-restore --no-self-contained /m:1
- name: Build Jellyfin Plugin - name: Build Jellyfin plugin packages
id: jprm
working-directory: build-${{ github.run_id }} working-directory: build-${{ github.run_id }}
run: | run: |
mkdir -p artifacts for VARIANT in jf11 jf12; do
jprm --verbosity=debug plugin build . ARTIFACT=$(./build-plugin.sh "${VARIANT}")
ARTIFACT=$(find . -name "*.zip" -type f -print -quit | sed 's|^\./||') cp "${ARTIFACT}" "artifacts/jellylms_latest_${VARIANT}.zip"
LATEST="artifacts/jellylms_latest.zip" echo "Built ${VARIANT}: ${ARTIFACT}"
cp "${ARTIFACT}" "${LATEST}" done
echo "artifact=${LATEST}" >> $GITHUB_OUTPUT
echo "Found artifact: ${ARTIFACT} -> ${LATEST}"
- name: Upload build artifact - name: Upload build artifacts
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: with:
name: jellylms-plugin name: jellylms-plugin
path: build-${{ github.run_id }}/${{ 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
+70 -66
View File
@@ -35,13 +35,6 @@ 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
working-directory: release-${{ github.run_id }}
run: |
VERSION="${{ steps.get_version.outputs.version_number }}"
sed -i "s/^version:.*/version: \"${VERSION}\"/" build.yaml
cat build.yaml
- name: Cache NuGet packages - name: Cache NuGet packages
uses: actions/cache@v3 uses: actions/cache@v3
with: with:
@@ -57,17 +50,18 @@ jobs:
working-directory: release-${{ github.run_id }} working-directory: release-${{ github.run_id }}
run: dotnet build Jellyfin.Plugin.JellyLMS.sln --configuration Release --no-restore --no-self-contained /m:1 run: dotnet build Jellyfin.Plugin.JellyLMS.sln --configuration Release --no-restore --no-self-contained /m:1
- name: Build Jellyfin Plugin - name: Build Jellyfin plugin packages
id: jprm id: jprm
working-directory: release-${{ github.run_id }} working-directory: release-${{ github.run_id }}
run: | run: |
mkdir -p artifacts VERSION="${{ steps.get_version.outputs.version_number }}"
jprm --verbosity=debug plugin build ./ for VARIANT in jf11 jf12; do
ARTIFACT=$(find . -name "*.zip" -type f -print -quit | sed 's|^\./||') ARTIFACT=$(./build-plugin.sh "${VARIANT}" "${VERSION}")
ARTIFACT_NAME=$(basename "${ARTIFACT}") echo "${VARIANT}_artifact=${ARTIFACT}" >> $GITHUB_OUTPUT
echo "artifact=${ARTIFACT}" >> $GITHUB_OUTPUT echo "${VARIANT}_artifact_name=$(basename "${ARTIFACT}")" >> $GITHUB_OUTPUT
echo "artifact_name=${ARTIFACT_NAME}" >> $GITHUB_OUTPUT echo "${VARIANT}_checksum=$(md5sum "${ARTIFACT}" | awk '{print $1}')" >> $GITHUB_OUTPUT
echo "Found artifact: ${ARTIFACT}" echo "Built ${VARIANT}: ${ARTIFACT}"
done
- name: Create Release - name: Create Release
working-directory: release-${{ github.run_id }} working-directory: release-${{ github.run_id }}
@@ -81,51 +75,56 @@ jobs:
# Create release using Gitea API # Create release using Gitea API
VERSION="${{ steps.get_version.outputs.version }}" VERSION="${{ steps.get_version.outputs.version }}"
RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \ 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 "$(jq -n --arg tag "$VERSION" --arg name "Release $VERSION" --arg body "JellyLMS Jellyfin Plugin ${VERSION}. See attached files for plugin installation." '{tag_name: $tag, name: $name, body: $body, draft: false, prerelease: false}')") -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) 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
working-directory: release-${{ github.run_id }}
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 }} working-directory: release-${{ github.run_id }}
run: | run: |
@@ -135,30 +134,35 @@ jobs:
git checkout 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
+1 -1
View File
@@ -3,7 +3,7 @@
# Build: docker build -f Dockerfile.builder -t gitea.tourolle.paris/dtourolle/jellylms-builder:latest . # Build: docker build -f Dockerfile.builder -t gitea.tourolle.paris/dtourolle/jellylms-builder:latest .
# Push: docker push gitea.tourolle.paris/dtourolle/jellylms-builder:latest # Push: docker push gitea.tourolle.paris/dtourolle/jellylms-builder:latest
FROM mcr.microsoft.com/dotnet/sdk:9.0 FROM mcr.microsoft.com/dotnet/sdk:10.0
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y \
python3 \ python3 \
@@ -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,8 +16,8 @@
<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>
<PackageReference Include="Jellyfin.Model" Version="10.11.0"> <PackageReference Include="Jellyfin.Model" Version="10.11.0">
@@ -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" />
@@ -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;
+283 -23
View File
@@ -1,4 +1,7 @@
(function () { (function () {
var hasAccess = false;
var panel = null;
function getAuthToken() { function getAuthToken() {
try { try {
var creds = JSON.parse(localStorage.getItem('jellyfin_credentials')); var creds = JSON.parse(localStorage.getItem('jellyfin_credentials'));
@@ -9,36 +12,293 @@
} }
} }
function addButton() { function api(path, options) {
if (document.getElementById('jellylms-remote-btn')) { 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; return;
} }
var btn = document.createElement('a'); panel = createPanel(btn);
btn.id = 'jellylms-remote-btn'; document.body.appendChild(panel);
btn.href = '/JellyLms/RemoteControl'; refresh(panel);
btn.title = 'Multi-room Remote';
btn.textContent = '🔊';
btn.style.cssText = 'position:fixed;bottom:20px;right:20px;width:48px;height:48px;' +
'border-radius:50%;background:#00a4dc;color:#fff;display:flex;' +
'align-items:center;justify-content:center;font-size:22px;' +
'text-decoration:none;z-index:99999;box-shadow:0 2px 8px rgba(0,0,0,0.5);';
document.body.appendChild(btn);
} }
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() { function init() {
var token = getAuthToken(); checkAccess().then(function (ok) {
if (!token) { hasAccess = ok;
return; if (!ok) { return; }
}
fetch('/JellyLms/RemoteControl/Access', { headers: { 'X-Emby-Token': token } }) tryInject();
.then(function (resp) {
if (resp.ok) { var observer = new MutationObserver(function () {
addButton(); if (panel && !document.body.contains(panel)) { panel = null; }
} tryInject();
}) });
.catch(function () {}); observer.observe(document.body, { childList: true, subtree: true });
document.addEventListener('click', function (e) {
if (panel && !panel.contains(e.target)) { closePanel(); }
});
});
} }
if (document.readyState === 'loading') { if (document.readyState === 'loading') {
+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 -->
+32
View File
@@ -7,6 +7,38 @@
"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", "version": "1.0.2",
"changelog": "Release 1.0.2", "changelog": "Release 1.0.2",