7 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
10 changed files with 455 additions and 107 deletions
+8 -11
View File
@@ -40,23 +40,20 @@ jobs:
working-directory: build-${{ github.run_id }}
run: dotnet build Jellyfin.Plugin.JellyLMS.sln --configuration Release --no-restore --no-self-contained /m:1
- name: Build Jellyfin Plugin
id: jprm
- name: Build Jellyfin plugin packages
working-directory: build-${{ github.run_id }}
run: |
mkdir -p artifacts
jprm --verbosity=debug plugin build .
ARTIFACT=$(find . -name "*.zip" -type f -print -quit | sed 's|^\./||')
LATEST="artifacts/jellylms_latest.zip"
cp "${ARTIFACT}" "${LATEST}"
echo "artifact=${LATEST}" >> $GITHUB_OUTPUT
echo "Found artifact: ${ARTIFACT} -> ${LATEST}"
for VARIANT in jf11 jf12; do
ARTIFACT=$(./build-plugin.sh "${VARIANT}")
cp "${ARTIFACT}" "artifacts/jellylms_latest_${VARIANT}.zip"
echo "Built ${VARIANT}: ${ARTIFACT}"
done
- name: Upload build artifact
- name: Upload build artifacts
uses: actions/upload-artifact@v3
with:
name: jellylms-plugin
path: build-${{ github.run_id }}/${{ steps.jprm.outputs.artifact }}
path: build-${{ github.run_id }}/artifacts/jellylms_latest_*.zip
retention-days: 30
if-no-files-found: error
+70 -66
View File
@@ -35,13 +35,6 @@ jobs:
echo "version_number=${VERSION#v}" >> $GITHUB_OUTPUT
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
uses: actions/cache@v3
with:
@@ -57,17 +50,18 @@ jobs:
working-directory: release-${{ github.run_id }}
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: release-${{ github.run_id }}
run: |
mkdir -p artifacts
jprm --verbosity=debug plugin build ./
ARTIFACT=$(find . -name "*.zip" -type f -print -quit | sed 's|^\./||')
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 }}
@@ -81,51 +75,56 @@ jobs:
# Create release using Gitea API
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 "Content-Type: application/json" \
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/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}')")
"${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..."
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 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 "@${{ steps.jprm.outputs.artifact }}" \
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}/assets?name=${{ steps.jprm.outputs.artifact_name }}"
# 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"
--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
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
working-directory: release-${{ github.run_id }}
run: |
@@ -135,30 +134,35 @@ jobs:
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}"
}
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
# 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
}
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
+1 -1
View File
@@ -3,7 +3,7 @@
# 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:9.0
FROM mcr.microsoft.com/dotnet/sdk:10.0
RUN apt-get update && apt-get install -y \
python3 \
@@ -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" />
@@ -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;
+249 -18
View File
@@ -1,5 +1,6 @@
(function () {
var hasAccess = false;
var panel = null;
function getAuthToken() {
try {
@@ -11,17 +12,244 @@
}
}
function checkAccess() {
function api(path, options) {
var token = getAuthToken();
if (!token) {
return Promise.resolve(false);
var opts = Object.assign({ headers: {} }, options);
if (token) {
opts.headers['X-Emby-Token'] = token;
}
return fetch('/JellyLms/RemoteControl/Access', { headers: { 'X-Emby-Token': token } })
.then(function (resp) { return resp.ok; })
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;
@@ -33,8 +261,9 @@
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 () {
window.location.href = '/JellyLms/RemoteControl';
btn.addEventListener('click', function (e) {
e.stopPropagation();
togglePanel(btn);
});
var castButton = headerRight.querySelector('.headerCastButton');
@@ -46,27 +275,29 @@
}
function tryInject() {
if (!hasAccess) {
return;
if (!hasAccess) { return; }
var headerRight = document.querySelector('.headerRight');
if (headerRight) { addButton(headerRight); }
}
var headerRight = document.querySelector('.headerRight');
if (headerRight) {
addButton(headerRight);
}
}
// ---------- bootstrap ----------
function init() {
checkAccess().then(function (ok) {
hasAccess = ok;
if (!ok) {
return;
}
if (!ok) { return; }
tryInject();
var observer = new MutationObserver(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(); }
});
});
}
+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 -->
+24
View File
@@ -7,6 +7,30 @@
"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",