15 Commits
Author SHA1 Message Date
dtourolle f6fa526598 Fix dropdown
Build Plugin / build (push) Successful in 36s
Release Plugin / build-and-release (push) Successful in 43s
2026-06-17 22:23:19 +02:00
Gitea Actions 08f34bfc1e Update manifest.json for v1.0.5 2026-06-17 19:20:00 +00:00
dtourolle 227fcd7fdd Dropdown menu
Build Plugin / build (push) Successful in 36s
Release Plugin / build-and-release (push) Successful in 38s
2026-06-17 20:49:50 +02:00
Gitea Actions 39eab2db69 Update manifest.json for v1.0.4 2026-06-17 18:44:44 +00:00
dtourolle 13c1b8e7d6 Button in menu bar
Build Plugin / build (push) Successful in 1m10s
Release Plugin / build-and-release (push) Successful in 46s
2026-06-15 21:10:05 +02:00
Gitea Actions 1c668a6431 Update manifest.json for v1.0.3 2026-06-14 15:48:18 +00:00
dtourolle 38dc02aea5 fix CI
Build Plugin / build (push) Successful in 37s
Release Plugin / build-and-release (push) Successful in 38s
2026-06-14 17:03:15 +02:00
dtourolle 4d2f7df217 new build system
Build Plugin / build (push) Successful in 49s
Release Plugin / build-and-release (push) Failing after 39s
inject controls in homepage
2026-06-13 23:35:46 +02:00
Gitea Actions df98b2c1f8 Update manifest.json for v1.0.2 2026-01-25 18:35:06 +00:00
dtourolle b85fbc2d90 Track remote volume to prevent unexpcted changes change
Build Plugin / build (push) Successful in 2m54s
Release Plugin / build-and-release (push) Successful in 2m43s
2026-01-25 19:20:35 +01:00
Gitea Actions f5f202794f Update manifest.json for v1.0.1 2025-12-30 13:43:10 +00:00
dtourolle a199fe452c remove redundant restAPI
Build Plugin / build (push) Successful in 2m46s
Release Plugin / build-and-release (push) Successful in 2m44s
playback is controlled by state machine
2025-12-30 14:37:27 +01:00
Gitea Actions 29cd6dfaeb Update manifest.json for v1.0.0 2025-12-20 13:54:14 +00:00
dtourolle 9180da16be Add manifest and repo info for installation by jellyfin webinterface.
Build Plugin / build (push) Successful in 2m13s
Release Plugin / build-and-release (push) Successful in 2m15s
2025-12-20 14:49:43 +01:00
dtourolle d3fbaef417 Allow multiple library folders
Build Plugin / build (push) Successful in 2m21s
2025-12-19 23:29:25 +01:00
19 changed files with 2317 additions and 595 deletions
+26 -19
View File
@@ -15,44 +15,51 @@ on:
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: linux/amd64
container:
image: gitea.tourolle.paris/dtourolle/jellylms-builder:latest
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
path: build-${{ github.run_id }}
- name: Verify .NET installation - name: Cache NuGet packages
run: dotnet --version uses: actions/cache@v3
with:
path: ~/.nuget/packages
key: nuget-${{ hashFiles('**/Jellyfin.Plugin.JellyLMS.csproj') }}
restore-keys: nuget-
- name: Restore dependencies - name: Restore dependencies
working-directory: build-${{ github.run_id }}
run: dotnet restore Jellyfin.Plugin.JellyLMS.sln run: dotnet restore Jellyfin.Plugin.JellyLMS.sln
- name: Build solution - name: Build solution
run: dotnet build Jellyfin.Plugin.JellyLMS.sln --configuration Release --no-restore --no-self-contained working-directory: build-${{ github.run_id }}
run: dotnet build Jellyfin.Plugin.JellyLMS.sln --configuration Release --no-restore --no-self-contained /m:1
- name: Install JPRM
run: |
python3 -m venv /tmp/jprm-venv
/tmp/jprm-venv/bin/pip install jprm
- name: Build Jellyfin Plugin - name: Build Jellyfin Plugin
id: jprm id: jprm
working-directory: build-${{ github.run_id }}
run: | run: |
# Create artifacts directory for JPRM output
mkdir -p artifacts mkdir -p artifacts
jprm --verbosity=debug plugin build .
# Build plugin using JPRM ARTIFACT=$(find . -name "*.zip" -type f -print -quit | sed 's|^\./||')
/tmp/jprm-venv/bin/jprm --verbosity=debug plugin build . LATEST="artifacts/jellylms_latest.zip"
cp "${ARTIFACT}" "${LATEST}"
# Find the generated zip file echo "artifact=${LATEST}" >> $GITHUB_OUTPUT
ARTIFACT=$(find . -name "*.zip" -type f -print -quit) echo "Found artifact: ${ARTIFACT} -> ${LATEST}"
echo "artifact=${ARTIFACT}" >> $GITHUB_OUTPUT
echo "Found artifact: ${ARTIFACT}"
- name: Upload build artifact - name: Upload build artifact
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: with:
name: jellylms-plugin name: jellylms-plugin
path: ${{ steps.jprm.outputs.artifact }} path: build-${{ github.run_id }}/${{ steps.jprm.outputs.artifact }}
retention-days: 30 retention-days: 30
if-no-files-found: error if-no-files-found: error
- name: Cleanup
if: always()
run: rm -rf build-${{ github.run_id }}
+80 -28
View File
@@ -13,14 +13,15 @@ on:
jobs: jobs:
build-and-release: build-and-release:
runs-on: ubuntu-latest runs-on: linux/amd64
container:
image: gitea.tourolle.paris/dtourolle/jellylms-builder:latest
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
- name: Verify .NET installation path: release-${{ github.run_id }}
run: dotnet --version
- name: Get version - name: Get version
id: get_version id: get_version
@@ -35,39 +36,41 @@ jobs:
echo "Building version: ${VERSION}" echo "Building version: ${VERSION}"
- name: Update build.yaml with version - name: Update build.yaml with version
working-directory: release-${{ github.run_id }}
run: | run: |
VERSION="${{ steps.get_version.outputs.version_number }}" VERSION="${{ steps.get_version.outputs.version_number }}"
sed -i "s/^version:.*/version: \"${VERSION}\"/" build.yaml sed -i "s/^version:.*/version: \"${VERSION}\"/" build.yaml
cat 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 - name: Restore dependencies
working-directory: release-${{ github.run_id }}
run: dotnet restore Jellyfin.Plugin.JellyLMS.sln run: dotnet restore Jellyfin.Plugin.JellyLMS.sln
- name: Build solution - name: Build solution
run: dotnet build Jellyfin.Plugin.JellyLMS.sln --configuration Release --no-restore --no-self-contained working-directory: release-${{ github.run_id }}
run: dotnet build Jellyfin.Plugin.JellyLMS.sln --configuration Release --no-restore --no-self-contained /m:1
- name: Install JPRM
run: |
python3 -m venv /tmp/jprm-venv
/tmp/jprm-venv/bin/pip install jprm
- name: Build Jellyfin Plugin - name: Build Jellyfin Plugin
id: jprm id: jprm
working-directory: release-${{ github.run_id }}
run: | run: |
# Create artifacts directory for JPRM output
mkdir -p artifacts mkdir -p artifacts
jprm --verbosity=debug plugin build ./
# Build plugin using JPRM ARTIFACT=$(find . -name "*.zip" -type f -print -quit | sed 's|^\./||')
/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}") ARTIFACT_NAME=$(basename "${ARTIFACT}")
echo "artifact=${ARTIFACT}" >> $GITHUB_OUTPUT echo "artifact=${ARTIFACT}" >> $GITHUB_OUTPUT
echo "artifact_name=${ARTIFACT_NAME}" >> $GITHUB_OUTPUT echo "artifact_name=${ARTIFACT_NAME}" >> $GITHUB_OUTPUT
echo "Found artifact: ${ARTIFACT}" echo "Found artifact: ${ARTIFACT}"
- name: Create Release - name: Create Release
working-directory: release-${{ github.run_id }}
env: env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
@@ -76,22 +79,13 @@ jobs:
REPO_NAME="${{ github.event.repository.name }}" REPO_NAME="${{ github.event.repository.name }}"
GITEA_URL="${{ github.server_url }}" GITEA_URL="${{ github.server_url }}"
# Prepare release body
RELEASE_BODY="JellyLMS Jellyfin Plugin ${{ steps.get_version.outputs.version }}\n\nSee attached files for plugin installation."
RELEASE_BODY_JSON=$(echo -n "${RELEASE_BODY}" | jq -Rs .)
# Create release using Gitea API # Create release using Gitea API
VERSION="${{ steps.get_version.outputs.version }}"
RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \ RESPONSE=$(curl -s -w "\n%{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" \ "${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases" \
-d "{ -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}')")
\"tag_name\": \"${{ steps.get_version.outputs.version }}\",
\"name\": \"Release ${{ steps.get_version.outputs.version }}\",
\"body\": ${RELEASE_BODY_JSON},
\"draft\": false,
\"prerelease\": false
}")
HTTP_CODE=$(echo "$RESPONSE" | tail -n1) HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
BODY=$(echo "$RESPONSE" | sed '$d') BODY=$(echo "$RESPONSE" | sed '$d')
@@ -123,3 +117,61 @@ jobs:
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
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}"
# 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
mv manifest.tmp.json manifest.json
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 add manifest.json
git commit -m "Update manifest.json for ${{ steps.get_version.outputs.version }}"
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:9.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
+206 -111
View File
@@ -1,10 +1,18 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Net.Mime; using System.Net.Mime;
using System.Reflection;
using System.Threading.Tasks; using System.Threading.Tasks;
using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations.Enums;
using Jellyfin.Plugin.JellyLMS.Models; using Jellyfin.Plugin.JellyLMS.Models;
using Jellyfin.Plugin.JellyLMS.Services; using Jellyfin.Plugin.JellyLMS.Services;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Entities;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@@ -22,22 +30,63 @@ public class JellyLmsController : ControllerBase
{ {
private readonly ILmsApiClient _lmsClient; private readonly ILmsApiClient _lmsClient;
private readonly LmsPlayerManager _playerManager; private readonly LmsPlayerManager _playerManager;
private readonly LmsSessionManager _sessionManager; private readonly ILibraryManager _libraryManager;
private readonly IUserManager _userManager;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="JellyLmsController"/> class. /// Initializes a new instance of the <see cref="JellyLmsController"/> class.
/// </summary> /// </summary>
/// <param name="lmsClient">The LMS API client.</param> /// <param name="lmsClient">The LMS API client.</param>
/// <param name="playerManager">The player manager.</param> /// <param name="playerManager">The player manager.</param>
/// <param name="sessionManager">The session manager.</param> /// <param name="libraryManager">The library manager.</param>
/// <param name="userManager">The user manager.</param>
public JellyLmsController( public JellyLmsController(
ILmsApiClient lmsClient, ILmsApiClient lmsClient,
LmsPlayerManager playerManager, LmsPlayerManager playerManager,
LmsSessionManager sessionManager) ILibraryManager libraryManager,
IUserManager userManager)
{ {
_lmsClient = lmsClient; _lmsClient = lmsClient;
_playerManager = playerManager; _playerManager = playerManager;
_sessionManager = sessionManager; _libraryManager = libraryManager;
_userManager = userManager;
}
/// <summary>
/// Determines whether the current user is allowed to use the multi-room remote
/// control features (administrators, or users granted the
/// "Allow remote control of other users" permission).
/// </summary>
/// <returns><c>true</c> if the user may use remote control endpoints.</returns>
private bool HasRemoteControlAccess()
{
var username = User.Identity?.Name;
if (string.IsNullOrEmpty(username))
{
return false;
}
var user = _userManager.GetUserByName(username);
if (user is null)
{
return false;
}
return HasPermission(user, PermissionKind.IsAdministrator)
|| HasPermission(user, PermissionKind.EnableRemoteControlOfOtherUsers);
}
private static bool HasPermission(Jellyfin.Database.Implementations.Entities.User user, PermissionKind kind)
{
foreach (var permission in user.Permissions)
{
if (permission.Kind == kind)
{
return permission.Value;
}
}
return false;
} }
/// <summary> /// <summary>
@@ -59,8 +108,14 @@ public class JellyLmsController : ControllerBase
/// <returns>List of players.</returns> /// <returns>List of players.</returns>
[HttpGet("Players")] [HttpGet("Players")]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<List<LmsPlayer>>> GetPlayers([FromQuery] bool refresh = false) public async Task<ActionResult<List<LmsPlayer>>> GetPlayers([FromQuery] bool refresh = false)
{ {
if (!HasRemoteControlAccess())
{
return Forbid();
}
var players = await _playerManager.GetPlayersAsync(refresh).ConfigureAwait(false); var players = await _playerManager.GetPlayersAsync(refresh).ConfigureAwait(false);
return Ok(players); return Ok(players);
} }
@@ -72,9 +127,15 @@ public class JellyLmsController : ControllerBase
/// <returns>The player details.</returns> /// <returns>The player details.</returns>
[HttpGet("Players/{mac}")] [HttpGet("Players/{mac}")]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<LmsPlayer>> GetPlayer(string mac) public async Task<ActionResult<LmsPlayer>> GetPlayer(string mac)
{ {
if (!HasRemoteControlAccess())
{
return Forbid();
}
var player = await _playerManager.GetPlayerAsync(mac).ConfigureAwait(false); var player = await _playerManager.GetPlayerAsync(mac).ConfigureAwait(false);
if (player == null) if (player == null)
{ {
@@ -92,8 +153,14 @@ public class JellyLmsController : ControllerBase
[HttpPost("Players/{mac}/PowerOn")] [HttpPost("Players/{mac}/PowerOn")]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult> PowerOn(string mac) public async Task<ActionResult> PowerOn(string mac)
{ {
if (!HasRemoteControlAccess())
{
return Forbid();
}
var success = await _lmsClient.PowerOnAsync(mac).ConfigureAwait(false); var success = await _lmsClient.PowerOnAsync(mac).ConfigureAwait(false);
return success ? Ok() : BadRequest("Failed to power on player"); return success ? Ok() : BadRequest("Failed to power on player");
} }
@@ -106,8 +173,14 @@ public class JellyLmsController : ControllerBase
[HttpPost("Players/{mac}/PowerOff")] [HttpPost("Players/{mac}/PowerOff")]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult> PowerOff(string mac) public async Task<ActionResult> PowerOff(string mac)
{ {
if (!HasRemoteControlAccess())
{
return Forbid();
}
var success = await _lmsClient.PowerOffAsync(mac).ConfigureAwait(false); var success = await _lmsClient.PowerOffAsync(mac).ConfigureAwait(false);
return success ? Ok() : BadRequest("Failed to power off player"); return success ? Ok() : BadRequest("Failed to power off player");
} }
@@ -121,8 +194,14 @@ public class JellyLmsController : ControllerBase
[HttpPost("Players/{mac}/Volume")] [HttpPost("Players/{mac}/Volume")]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult> SetVolume(string mac, [FromBody] VolumeRequest request) public async Task<ActionResult> SetVolume(string mac, [FromBody] VolumeRequest request)
{ {
if (!HasRemoteControlAccess())
{
return Forbid();
}
var success = await _lmsClient.SetVolumeAsync(mac, request.Volume).ConfigureAwait(false); var success = await _lmsClient.SetVolumeAsync(mac, request.Volume).ConfigureAwait(false);
return success ? Ok() : BadRequest("Failed to set volume"); return success ? Ok() : BadRequest("Failed to set volume");
} }
@@ -133,8 +212,14 @@ public class JellyLmsController : ControllerBase
/// <returns>List of sync groups.</returns> /// <returns>List of sync groups.</returns>
[HttpGet("SyncGroups")] [HttpGet("SyncGroups")]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<List<SyncGroup>>> GetSyncGroups() public async Task<ActionResult<List<SyncGroup>>> GetSyncGroups()
{ {
if (!HasRemoteControlAccess())
{
return Forbid();
}
var groups = await _playerManager.GetSyncGroupsAsync().ConfigureAwait(false); var groups = await _playerManager.GetSyncGroupsAsync().ConfigureAwait(false);
return Ok(groups); return Ok(groups);
} }
@@ -147,8 +232,14 @@ public class JellyLmsController : ControllerBase
[HttpPost("SyncGroups")] [HttpPost("SyncGroups")]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult> CreateSyncGroup([FromBody] CreateSyncGroupRequest request) public async Task<ActionResult> CreateSyncGroup([FromBody] CreateSyncGroupRequest request)
{ {
if (!HasRemoteControlAccess())
{
return Forbid();
}
var success = await _playerManager.CreateSyncGroupAsync(request.MasterMac, request.SlaveMacs) var success = await _playerManager.CreateSyncGroupAsync(request.MasterMac, request.SlaveMacs)
.ConfigureAwait(false); .ConfigureAwait(false);
return success ? Ok() : BadRequest("Failed to create sync group"); return success ? Ok() : BadRequest("Failed to create sync group");
@@ -162,8 +253,14 @@ public class JellyLmsController : ControllerBase
[HttpDelete("SyncGroups/Players/{mac}")] [HttpDelete("SyncGroups/Players/{mac}")]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult> UnsyncPlayer(string mac) public async Task<ActionResult> UnsyncPlayer(string mac)
{ {
if (!HasRemoteControlAccess())
{
return Forbid();
}
var success = await _playerManager.UnsyncPlayerAsync(mac).ConfigureAwait(false); var success = await _playerManager.UnsyncPlayerAsync(mac).ConfigureAwait(false);
return success ? Ok() : BadRequest("Failed to unsync player"); return success ? Ok() : BadRequest("Failed to unsync player");
} }
@@ -176,115 +273,147 @@ public class JellyLmsController : ControllerBase
[HttpDelete("SyncGroups/{masterMac}")] [HttpDelete("SyncGroups/{masterMac}")]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult> DissolveSyncGroup(string masterMac) public async Task<ActionResult> DissolveSyncGroup(string masterMac)
{ {
if (!HasRemoteControlAccess())
{
return Forbid();
}
var success = await _playerManager.DissolveSyncGroupAsync(masterMac).ConfigureAwait(false); var success = await _playerManager.DissolveSyncGroupAsync(masterMac).ConfigureAwait(false);
return success ? Ok() : BadRequest("Failed to dissolve sync group"); return success ? Ok() : BadRequest("Failed to dissolve sync group");
} }
/// <summary> /// <summary>
/// Gets all active playback sessions. /// 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> /// </summary>
/// <returns>List of active sessions.</returns> /// <returns>200 OK if allowed, otherwise 403 Forbidden.</returns>
[HttpGet("Sessions")] [HttpGet("RemoteControl/Access")]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<List<LmsPlaybackSession>> GetSessions() [ProducesResponseType(StatusCodes.Status403Forbidden)]
public ActionResult CheckRemoteControlAccess()
{ {
return Ok(_sessionManager.GetActiveSessions()); return HasRemoteControlAccess() ? Ok() : Forbid();
} }
/// <summary> /// <summary>
/// Starts playback of a Jellyfin item on LMS players. /// 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> /// </summary>
/// <param name="request">The playback request.</param> /// <returns>The remote control HTML page.</returns>
/// <returns>The created session.</returns> [HttpGet("RemoteControl")]
[HttpPost("Sessions/Play")] [AllowAnonymous]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)] public ActionResult GetRemoteControlPage()
public async Task<ActionResult<LmsPlaybackSession>> StartPlayback([FromBody] StartPlaybackRequest request)
{ {
var session = await _sessionManager.StartPlaybackAsync(request.ItemId, request.PlayerMacs, request.UserId) return ServeEmbeddedResource("Jellyfin.Plugin.JellyLMS.Web.RemoteControl.html", "text/html");
.ConfigureAwait(false);
if (session == null)
{
return BadRequest("Failed to start playback");
}
return Ok(session);
} }
/// <summary> /// <summary>
/// Pauses a playback session. /// Serves the client script that is injected into the Jellyfin web client to add a
/// floating button linking to the remote control page.
/// </summary> /// </summary>
/// <param name="sessionId">The session ID.</param> /// <returns>The client script.</returns>
/// <returns>Success status.</returns> [HttpGet("RemoteControl/ClientScript")]
[HttpPost("Sessions/{sessionId}/Pause")] [AllowAnonymous]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)] public ActionResult GetRemoteControlClientScript()
public async Task<ActionResult> PauseSession(string sessionId)
{ {
var success = await _sessionManager.PauseSessionAsync(sessionId).ConfigureAwait(false); return ServeEmbeddedResource("Jellyfin.Plugin.JellyLMS.Web.remote-button.js", "application/javascript");
return success ? Ok() : NotFound(); }
private FileStreamResult ServeEmbeddedResource(string resourceName, string contentType)
{
var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream(resourceName)
?? throw new InvalidOperationException($"Embedded resource '{resourceName}' not found.");
return File(stream, contentType);
} }
/// <summary> /// <summary>
/// Resumes a paused playback session. /// Discovers file paths used by Jellyfin's music libraries.
/// Helps users configure path mappings for direct file access.
/// </summary> /// </summary>
/// <param name="sessionId">The session ID.</param> /// <returns>Sample file paths from each music library.</returns>
/// <returns>Success status.</returns> [HttpGet("DiscoverPaths")]
[HttpPost("Sessions/{sessionId}/Resume")]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)] public ActionResult<DiscoveredPathsResponse> DiscoverPaths()
public async Task<ActionResult> ResumeSession(string sessionId)
{ {
var success = await _sessionManager.ResumeSessionAsync(sessionId).ConfigureAwait(false); var response = new DiscoveredPathsResponse();
return success ? Ok() : NotFound();
// Get sample audio files from the library
var query = new InternalItemsQuery
{
IncludeItemTypes = [BaseItemKind.Audio],
Limit = 50,
Recursive = true
};
var items = _libraryManager.GetItemsResult(query).Items;
// Extract unique path prefixes
var pathPrefixes = new HashSet<string>();
foreach (var item in items)
{
if (string.IsNullOrEmpty(item.Path))
{
continue;
}
// Add sample paths
if (response.SamplePaths.Count < 5)
{
response.SamplePaths.Add(item.Path);
}
// Try to find common path prefixes
var path = item.Path.Replace('\\', '/');
var parts = path.Split('/');
// Build prefix from first few directory levels
if (parts.Length > 2)
{
// Try different prefix lengths to find common ones
for (var i = 2; i <= Math.Min(4, parts.Length - 1); i++)
{
var prefix = string.Join('/', parts.Take(i));
if (!string.IsNullOrEmpty(prefix) && !prefix.Contains('.', StringComparison.Ordinal))
{
pathPrefixes.Add(prefix);
}
}
}
}
// Sort prefixes by length (shorter = more general)
response.DetectedPrefixes = pathPrefixes
.OrderBy(p => p.Length)
.Take(10)
.ToList();
return Ok(response);
}
} }
/// <summary> /// <summary>
/// Stops a playback session. /// Response containing discovered file paths from Jellyfin libraries.
/// </summary> /// </summary>
/// <param name="sessionId">The session ID.</param> public class DiscoveredPathsResponse
/// <returns>Success status.</returns>
[HttpPost("Sessions/{sessionId}/Stop")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> StopSession(string sessionId)
{ {
var success = await _sessionManager.StopSessionAsync(sessionId).ConfigureAwait(false); /// <summary>
return success ? Ok() : NotFound(); /// Gets or sets sample file paths from the music library.
} /// </summary>
public List<string> SamplePaths { get; set; } = [];
/// <summary> /// <summary>
/// Seeks to a position in the playback session. /// Gets or sets detected common path prefixes.
/// </summary> /// </summary>
/// <param name="sessionId">The session ID.</param> public List<string> DetectedPrefixes { get; set; } = [];
/// <param name="request">The seek request.</param>
/// <returns>Success status.</returns>
[HttpPost("Sessions/{sessionId}/Seek")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> SeekSession(string sessionId, [FromBody] SeekRequest request)
{
var success = await _sessionManager.SeekAsync(sessionId, request.PositionTicks).ConfigureAwait(false);
return success ? Ok() : NotFound();
}
/// <summary>
/// Sets the volume for all players in a session.
/// </summary>
/// <param name="sessionId">The session ID.</param>
/// <param name="request">The volume request.</param>
/// <returns>Success status.</returns>
[HttpPost("Sessions/{sessionId}/Volume")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> SetSessionVolume(string sessionId, [FromBody] VolumeRequest request)
{
var success = await _sessionManager.SetVolumeAsync(sessionId, request.Volume).ConfigureAwait(false);
return success ? Ok() : NotFound();
}
} }
/// <summary> /// <summary>
@@ -316,37 +445,3 @@ public class CreateSyncGroupRequest
[Required] [Required]
public List<string> SlaveMacs { get; set; } = []; public List<string> SlaveMacs { get; set; } = [];
} }
/// <summary>
/// Request to start playback.
/// </summary>
public class StartPlaybackRequest
{
/// <summary>
/// Gets or sets the Jellyfin item ID.
/// </summary>
[Required]
public Guid ItemId { get; set; }
/// <summary>
/// Gets or sets the LMS player MAC addresses.
/// </summary>
[Required]
public List<string> PlayerMacs { get; set; } = [];
/// <summary>
/// Gets or sets the optional user ID.
/// </summary>
public Guid? UserId { get; set; }
}
/// <summary>
/// Request to seek to a position.
/// </summary>
public class SeekRequest
{
/// <summary>
/// Gets or sets the position in ticks.
/// </summary>
public long PositionTicks { get; set; }
}
@@ -1,7 +1,24 @@
using System.Collections.Generic;
using MediaBrowser.Model.Plugins; using MediaBrowser.Model.Plugins;
namespace Jellyfin.Plugin.JellyLMS.Configuration; namespace Jellyfin.Plugin.JellyLMS.Configuration;
/// <summary>
/// Represents a path mapping between Jellyfin and LMS file paths.
/// </summary>
public class PathMapping
{
/// <summary>
/// Gets or sets the path prefix as seen by Jellyfin.
/// </summary>
public string JellyfinPath { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the same path prefix as seen by LMS.
/// </summary>
public string LmsPath { get; set; } = string.Empty;
}
/// <summary> /// <summary>
/// Plugin configuration for JellyLMS. /// Plugin configuration for JellyLMS.
/// </summary> /// </summary>
@@ -19,6 +36,7 @@ public class PluginConfiguration : BasePluginConfiguration
ConnectionTimeoutSeconds = 10; ConnectionTimeoutSeconds = 10;
EnableAutoSync = true; EnableAutoSync = true;
DefaultPlayerMac = string.Empty; DefaultPlayerMac = string.Empty;
EnableHomeScreenButton = true;
} }
/// <summary> /// <summary>
@@ -58,6 +76,12 @@ public class PluginConfiguration : BasePluginConfiguration
/// </summary> /// </summary>
public string DefaultPlayerMac { get; set; } public string DefaultPlayerMac { get; set; }
/// <summary>
/// Gets or sets a value indicating whether a floating "Remote" button linking to the
/// multi-room remote control page should be injected into the Jellyfin web client.
/// </summary>
public bool EnableHomeScreenButton { get; set; }
/// <summary> /// <summary>
/// Gets or sets the Jellyfin API key for authenticating stream requests from LMS. /// Gets or sets the Jellyfin API key for authenticating stream requests from LMS.
/// </summary> /// </summary>
@@ -72,14 +96,66 @@ public class PluginConfiguration : BasePluginConfiguration
/// <summary> /// <summary>
/// Gets or sets the media path prefix as seen by Jellyfin. /// Gets or sets the media path prefix as seen by Jellyfin.
/// Used for path mapping when UseDirectFilePath is enabled. /// Used for path mapping when UseDirectFilePath is enabled.
/// Example: /media/music or C:\Music. /// Deprecated: Use PathMappings instead. Kept for backwards compatibility.
/// </summary> /// </summary>
public string JellyfinMediaPath { get; set; } = string.Empty; public string JellyfinMediaPath { get; set; } = string.Empty;
/// <summary> /// <summary>
/// Gets or sets the media path prefix as seen by LMS. /// Gets or sets the media path prefix as seen by LMS.
/// Used for path mapping when UseDirectFilePath is enabled. /// Used for path mapping when UseDirectFilePath is enabled.
/// Example: /mnt/music or //nas/music. /// Deprecated: Use PathMappings instead. Kept for backwards compatibility.
/// </summary> /// </summary>
public string LmsMediaPath { get; set; } = string.Empty; public string LmsMediaPath { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the list of path mappings between Jellyfin and LMS.
/// Each mapping allows files from different locations to be played via direct file access.
/// </summary>
public List<PathMapping> PathMappings { get; set; } = new();
/// <summary>
/// Gets or sets the timeout in seconds when waiting for LMS to start playback.
/// </summary>
public int LoadingTimeoutSeconds { get; set; } = 5;
/// <summary>
/// Gets or sets the timeout in seconds when waiting for a seek operation to complete.
/// </summary>
public int SeekTimeoutSeconds { get; set; } = 3;
/// <summary>
/// Gets or sets the polling interval in milliseconds during state transitions.
/// </summary>
public int TransitionPollIntervalMs { get; set; } = 300;
/// <summary>
/// Gets or sets the number of automatic retries for transient failures.
/// </summary>
public int MaxAutoRetries { get; set; } = 2;
/// <summary>
/// Gets all effective path mappings, including legacy single mapping if configured.
/// </summary>
/// <returns>Enumerable of all configured path mappings.</returns>
public IEnumerable<PathMapping> GetAllPathMappings()
{
// Return configured list mappings first
foreach (var mapping in PathMappings)
{
if (!string.IsNullOrEmpty(mapping.JellyfinPath) && !string.IsNullOrEmpty(mapping.LmsPath))
{
yield return mapping;
}
}
// Fall back to legacy single mapping for backwards compatibility
if (!string.IsNullOrEmpty(JellyfinMediaPath) && !string.IsNullOrEmpty(LmsMediaPath))
{
yield return new PathMapping
{
JellyfinPath = JellyfinMediaPath,
LmsPath = LmsMediaPath
};
}
}
} }
@@ -154,17 +154,44 @@
</div> </div>
<div id="directPathSettings" style="margin-top: 15px;"> <div id="directPathSettings" style="margin-top: 15px;">
<div class="inputContainer"> <h4 style="margin-bottom: 10px;">Path Mappings</h4>
<label class="inputLabel inputLabelUnfocused" for="JellyfinMediaPath">Jellyfin Media Path</label> <p class="fieldDescription">Map Jellyfin paths to LMS paths. Add multiple mappings if your music and podcasts are in different locations.</p>
<input id="JellyfinMediaPath" name="JellyfinMediaPath" type="text" is="emby-input" placeholder="/media/music" />
<div class="fieldDescription">The path prefix as Jellyfin sees your media library (e.g., /media/music)</div> <div style="margin-bottom: 15px;">
<button is="emby-button" type="button" id="btnDiscoverPaths" class="raised button-alt emby-button">
<span>Discover Jellyfin Paths</span>
</button>
<span id="discoverStatus" style="margin-left: 10px;"></span>
</div> </div>
<div class="inputContainer"> <div id="discoveredPaths" style="display: none; margin-bottom: 15px; padding: 10px; background: rgba(0,0,0,0.2); border-radius: 4px;">
<label class="inputLabel inputLabelUnfocused" for="LmsMediaPath">LMS Media Path</label> <strong>Detected Jellyfin paths:</strong>
<input id="LmsMediaPath" name="LmsMediaPath" type="text" is="emby-input" placeholder="/mnt/music" /> <ul id="detectedPrefixList" style="margin: 5px 0; padding-left: 20px;"></ul>
<div class="fieldDescription">The same location as LMS sees it (e.g., /mnt/music or //nas/music)</div> <div class="fieldDescription">Click a path to use it as the Jellyfin path in a new mapping</div>
</div> </div>
<div id="pathMappingsList">
<!-- Dynamic path mappings will be added here -->
</div>
<div style="margin-top: 10px;">
<button is="emby-button" type="button" id="btnAddMapping" class="raised button-alt emby-button">
<span>+ Add Mapping</span>
</button>
</div>
</div>
</div>
<div class="verticalSection">
<h3>Multi-Room Remote</h3>
<p class="fieldDescription">A standalone remote control page is available at <code>/JellyLms/RemoteControl</code> for any user granted the "Allow remote control of other users" permission (Dashboard &gt; Users).</p>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<input id="EnableHomeScreenButton" name="EnableHomeScreenButton" type="checkbox" is="emby-checkbox" />
<span>Show floating Remote button in web client</span>
</label>
<div class="fieldDescription checkboxFieldDescription">Adds a floating button to the Jellyfin web client that links to the remote control page (for authorized users only). Requires write access to the Jellyfin web root and a page reload to take effect.</div>
</div> </div>
</div> </div>
@@ -433,6 +460,113 @@
}); });
} }
// Path Mapping Functions
function renderPathMappings(mappings) {
var container = document.querySelector('#pathMappingsList');
if (!mappings || mappings.length === 0) {
container.innerHTML = '<p class="fieldDescription">No path mappings configured. Click "Discover Jellyfin Paths" to get started.</p>';
return;
}
var html = '';
mappings.forEach(function(mapping, index) {
html += '<div class="path-mapping-row" style="display: flex; gap: 10px; align-items: flex-end; margin-bottom: 10px; padding: 10px; background: rgba(0,0,0,0.1); border-radius: 4px;">';
html += '<div style="flex: 1;">';
html += '<label class="inputLabel inputLabelUnfocused">Jellyfin Path</label>';
html += '<input type="text" is="emby-input" class="mapping-jellyfin-path" data-index="' + index + '" value="' + (mapping.JellyfinPath || '') + '" placeholder="/media/music" />';
html += '</div>';
html += '<div style="flex: 1;">';
html += '<label class="inputLabel inputLabelUnfocused">LMS Path</label>';
html += '<input type="text" is="emby-input" class="mapping-lms-path" data-index="' + index + '" value="' + (mapping.LmsPath || '') + '" placeholder="/mnt/music" />';
html += '</div>';
html += '<button is="emby-button" type="button" class="raised button-alt emby-button btnRemoveMapping" data-index="' + index + '" style="margin-bottom: 0;">';
html += '<span>Remove</span>';
html += '</button>';
html += '</div>';
});
container.innerHTML = html;
// Add remove handlers
container.querySelectorAll('.btnRemoveMapping').forEach(function(btn) {
btn.addEventListener('click', function() {
var idx = parseInt(this.getAttribute('data-index'));
JellyLmsConfig.pathMappings.splice(idx, 1);
renderPathMappings(JellyLmsConfig.pathMappings);
});
});
// Update stored mappings when inputs change
container.querySelectorAll('.mapping-jellyfin-path, .mapping-lms-path').forEach(function(input) {
input.addEventListener('change', function() {
var idx = parseInt(this.getAttribute('data-index'));
if (this.classList.contains('mapping-jellyfin-path')) {
JellyLmsConfig.pathMappings[idx].JellyfinPath = this.value;
} else {
JellyLmsConfig.pathMappings[idx].LmsPath = this.value;
}
});
});
}
function addPathMapping(jellyfinPath, lmsPath) {
JellyLmsConfig.pathMappings = JellyLmsConfig.pathMappings || [];
JellyLmsConfig.pathMappings.push({
JellyfinPath: jellyfinPath || '',
LmsPath: lmsPath || ''
});
renderPathMappings(JellyLmsConfig.pathMappings);
}
function discoverPaths() {
var statusDiv = document.querySelector('#discoverStatus');
statusDiv.innerHTML = '<span style="color: orange;">Discovering...</span>';
ApiClient.ajax({
url: ApiClient.getUrl('JellyLms/DiscoverPaths'),
type: 'GET',
dataType: 'json'
}).then(function(result) {
statusDiv.innerHTML = '';
var discoveredDiv = document.querySelector('#discoveredPaths');
var prefixList = document.querySelector('#detectedPrefixList');
if (result.DetectedPrefixes && result.DetectedPrefixes.length > 0) {
discoveredDiv.style.display = 'block';
var html = '';
result.DetectedPrefixes.forEach(function(prefix) {
html += '<li><a href="#" class="detected-prefix-link" data-path="' + prefix + '" style="color: #00a4dc;">' + prefix + '</a></li>';
});
prefixList.innerHTML = html;
// Add click handlers to use detected paths
prefixList.querySelectorAll('.detected-prefix-link').forEach(function(link) {
link.addEventListener('click', function(e) {
e.preventDefault();
addPathMapping(this.getAttribute('data-path'), '');
});
});
} else {
discoveredDiv.style.display = 'block';
prefixList.innerHTML = '<li>No audio files found in library</li>';
}
}).catch(function(err) {
statusDiv.innerHTML = '<span style="color: red;">Discovery failed</span>';
console.error('Path discovery failed:', err);
});
}
function getPathMappingsFromUI() {
var mappings = [];
document.querySelectorAll('.path-mapping-row').forEach(function(row) {
var jellyfinPath = row.querySelector('.mapping-jellyfin-path').value;
var lmsPath = row.querySelector('.mapping-lms-path').value;
if (jellyfinPath || lmsPath) {
mappings.push({ JellyfinPath: jellyfinPath, LmsPath: lmsPath });
}
});
return mappings;
}
document.querySelector('#JellyLmsConfigPage') document.querySelector('#JellyLmsConfigPage')
.addEventListener('pageshow', function() { .addEventListener('pageshow', function() {
Dashboard.showLoadingMsg(); Dashboard.showLoadingMsg();
@@ -446,8 +580,19 @@
document.querySelector('#EnableAutoSync').checked = config.EnableAutoSync !== false; document.querySelector('#EnableAutoSync').checked = config.EnableAutoSync !== false;
document.querySelector('#DefaultPlayerMac').value = config.DefaultPlayerMac || ''; document.querySelector('#DefaultPlayerMac').value = config.DefaultPlayerMac || '';
document.querySelector('#UseDirectFilePath').checked = config.UseDirectFilePath || false; document.querySelector('#UseDirectFilePath').checked = config.UseDirectFilePath || false;
document.querySelector('#JellyfinMediaPath').value = config.JellyfinMediaPath || ''; document.querySelector('#EnableHomeScreenButton').checked = config.EnableHomeScreenButton !== false;
document.querySelector('#LmsMediaPath').value = config.LmsMediaPath || '';
// Load path mappings (new list format, with fallback to legacy single mapping)
JellyLmsConfig.pathMappings = config.PathMappings || [];
// If no list mappings but legacy single mapping exists, show it
if (JellyLmsConfig.pathMappings.length === 0 && config.JellyfinMediaPath && config.LmsMediaPath) {
JellyLmsConfig.pathMappings = [{
JellyfinPath: config.JellyfinMediaPath,
LmsPath: config.LmsMediaPath
}];
}
renderPathMappings(JellyLmsConfig.pathMappings);
Dashboard.hideLoadingMsg(); Dashboard.hideLoadingMsg();
loadPlayers(); loadPlayers();
@@ -491,8 +636,11 @@
config.EnableAutoSync = document.querySelector('#EnableAutoSync').checked; config.EnableAutoSync = document.querySelector('#EnableAutoSync').checked;
config.DefaultPlayerMac = document.querySelector('#DefaultPlayerMac').value; config.DefaultPlayerMac = document.querySelector('#DefaultPlayerMac').value;
config.UseDirectFilePath = document.querySelector('#UseDirectFilePath').checked; config.UseDirectFilePath = document.querySelector('#UseDirectFilePath').checked;
config.JellyfinMediaPath = document.querySelector('#JellyfinMediaPath').value; config.EnableHomeScreenButton = document.querySelector('#EnableHomeScreenButton').checked;
config.LmsMediaPath = document.querySelector('#LmsMediaPath').value; // Save path mappings (clear legacy single mapping when using list)
config.PathMappings = getPathMappingsFromUI();
config.JellyfinMediaPath = '';
config.LmsMediaPath = '';
ApiClient.updatePluginConfiguration(JellyLmsConfig.pluginUniqueId, config).then(function (result) { ApiClient.updatePluginConfiguration(JellyLmsConfig.pluginUniqueId, config).then(function (result) {
Dashboard.processPluginConfigurationUpdateResult(result); Dashboard.processPluginConfigurationUpdateResult(result);
}); });
@@ -501,6 +649,16 @@
e.preventDefault(); e.preventDefault();
return false; return false;
}); });
document.querySelector('#btnDiscoverPaths')
.addEventListener('click', function() {
discoverPaths();
});
document.querySelector('#btnAddMapping')
.addEventListener('click', function() {
addPathMapping('', '');
});
</script> </script>
</div> </div>
</body> </body>
@@ -28,6 +28,10 @@
<ItemGroup> <ItemGroup>
<None Remove="Configuration\configPage.html" /> <None Remove="Configuration\configPage.html" />
<EmbeddedResource Include="Configuration\configPage.html" /> <EmbeddedResource Include="Configuration\configPage.html" />
<None Remove="Web\RemoteControl.html" />
<EmbeddedResource Include="Web\RemoteControl.html" />
<None Remove="Web\remote-button.js" />
<EmbeddedResource Include="Web\remote-button.js" />
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -9,9 +9,14 @@ namespace Jellyfin.Plugin.JellyLMS.Models;
public enum PlaybackState public enum PlaybackState
{ {
/// <summary> /// <summary>
/// Playback is stopped. /// Device connected, no media loaded.
/// </summary> /// </summary>
Stopped, Idle,
/// <summary>
/// Play command sent, waiting for LMS to confirm playback started.
/// </summary>
Loading,
/// <summary> /// <summary>
/// Playback is active. /// Playback is active.
@@ -21,7 +26,84 @@ public enum PlaybackState
/// <summary> /// <summary>
/// Playback is paused. /// Playback is paused.
/// </summary> /// </summary>
Paused Paused,
/// <summary>
/// Position change in progress.
/// </summary>
Seeking,
/// <summary>
/// Playback failed with an error.
/// </summary>
Error,
/// <summary>
/// Playback has ended.
/// </summary>
Stopped
}
/// <summary>
/// Types of playback errors.
/// </summary>
public enum PlaybackErrorType
{
/// <summary>
/// No error.
/// </summary>
None,
/// <summary>
/// Operation timed out waiting for LMS response.
/// </summary>
Timeout,
/// <summary>
/// Network error communicating with LMS.
/// </summary>
NetworkError,
/// <summary>
/// LMS returned an error.
/// </summary>
LmsError,
/// <summary>
/// Error with the audio stream from Jellyfin.
/// </summary>
StreamError,
/// <summary>
/// Unknown error.
/// </summary>
Unknown
}
/// <summary>
/// Contains details about a playback error.
/// </summary>
public class PlaybackErrorInfo
{
/// <summary>
/// Gets or sets the type of error.
/// </summary>
public PlaybackErrorType ErrorType { get; set; }
/// <summary>
/// Gets or sets the error message.
/// </summary>
public string Message { get; set; } = string.Empty;
/// <summary>
/// Gets or sets when the error occurred.
/// </summary>
public DateTime OccurredAt { get; set; }
/// <summary>
/// Gets or sets the number of retry attempts made.
/// </summary>
public int RetryCount { get; set; }
} }
/// <summary> /// <summary>
@@ -67,7 +149,12 @@ public class LmsPlaybackSession
/// <summary> /// <summary>
/// Gets or sets the current playback state. /// Gets or sets the current playback state.
/// </summary> /// </summary>
public PlaybackState State { get; set; } = PlaybackState.Stopped; public PlaybackState State { get; set; } = PlaybackState.Idle;
/// <summary>
/// Gets or sets the last error that occurred during playback.
/// </summary>
public PlaybackErrorInfo? LastError { get; set; }
/// <summary> /// <summary>
/// Gets or sets the current playback position in ticks. /// Gets or sets the current playback position in ticks.
+10 -1
View File
@@ -2,10 +2,12 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization; using System.Globalization;
using Jellyfin.Plugin.JellyLMS.Configuration; using Jellyfin.Plugin.JellyLMS.Configuration;
using Jellyfin.Plugin.JellyLMS.Services;
using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Plugins; using MediaBrowser.Common.Plugins;
using MediaBrowser.Model.Plugins; using MediaBrowser.Model.Plugins;
using MediaBrowser.Model.Serialization; using MediaBrowser.Model.Serialization;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.JellyLMS; namespace Jellyfin.Plugin.JellyLMS;
@@ -15,15 +17,22 @@ namespace Jellyfin.Plugin.JellyLMS;
/// </summary> /// </summary>
public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
{ {
private readonly ILogger<Plugin> _logger;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="Plugin"/> class. /// Initializes a new instance of the <see cref="Plugin"/> class.
/// </summary> /// </summary>
/// <param name="applicationPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param> /// <param name="applicationPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param>
/// <param name="xmlSerializer">Instance of the <see cref="IXmlSerializer"/> interface.</param> /// <param name="xmlSerializer">Instance of the <see cref="IXmlSerializer"/> interface.</param>
public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) /// <param name="logger">The logger.</param>
public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer, ILogger<Plugin> logger)
: base(applicationPaths, xmlSerializer) : base(applicationPaths, xmlSerializer)
{ {
Instance = this; Instance = this;
_logger = logger;
WebClientPatchService.Apply(ApplicationPaths, Configuration.EnableHomeScreenButton, _logger);
ConfigurationChanged += (_, _) => WebClientPatchService.Apply(ApplicationPaths, Configuration.EnableHomeScreenButton, _logger);
} }
/// <inheritdoc /> /// <inheritdoc />
@@ -15,8 +15,6 @@ public class PluginServiceRegistrator : IPluginServiceRegistrator
{ {
serviceCollection.AddSingleton<ILmsApiClient, LmsApiClient>(); serviceCollection.AddSingleton<ILmsApiClient, LmsApiClient>();
serviceCollection.AddSingleton<LmsPlayerManager>(); serviceCollection.AddSingleton<LmsPlayerManager>();
serviceCollection.AddSingleton<LmsSessionManager>();
serviceCollection.AddHostedService(sp => sp.GetRequiredService<LmsSessionManager>());
// Device discovery service - registers LMS players as Jellyfin sessions for casting // Device discovery service - registers LMS players as Jellyfin sessions for casting
// Use AddHostedService directly to let DI handle construction // Use AddHostedService directly to let DI handle construction
@@ -1,7 +1,7 @@
using System; using System;
using System.Text.Json;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Jellyfin.Plugin.JellyLMS.Configuration;
using Jellyfin.Plugin.JellyLMS.Models; using Jellyfin.Plugin.JellyLMS.Models;
using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Library;
@@ -23,12 +23,16 @@ public class LmsSessionController : ISessionController, IDisposable
private readonly SessionInfo _session; private readonly SessionInfo _session;
private readonly ISessionManager _sessionManager; private readonly ISessionManager _sessionManager;
private readonly ILibraryManager _libraryManager; private readonly ILibraryManager _libraryManager;
private readonly PlaybackStateMachine _stateMachine;
private readonly LmsStatusPoller _statusPoller;
private readonly CancellationTokenSource _cancellationTokenSource = new();
private Timer? _progressTimer; private Timer? _progressTimer;
private bool _disposed; private bool _disposed;
private BaseItem? _currentItem; private BaseItem? _currentItem;
private Guid[] _playlist = []; private Guid[] _playlist = [];
private int _playlistIndex; private int _playlistIndex;
private long _seekOffsetTicks; // Offset from transcoded stream start position private long _seekOffsetTicks; // Offset from transcoded stream start position
private PlaybackErrorInfo? _lastError;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="LmsSessionController"/> class. /// Initializes a new instance of the <see cref="LmsSessionController"/> class.
@@ -53,22 +57,41 @@ public class LmsSessionController : ISessionController, IDisposable
_session = session; _session = session;
_sessionManager = sessionManager; _sessionManager = sessionManager;
_libraryManager = libraryManager; _libraryManager = libraryManager;
_stateMachine = new PlaybackStateMachine(logger);
_statusPoller = new LmsStatusPoller(lmsClient, logger);
// Start status polling immediately to keep volume in sync
StartProgressTimer();
} }
private static PluginConfiguration Config => Plugin.Instance?.Configuration ?? new PluginConfiguration();
/// <summary> /// <summary>
/// Gets or sets the currently playing item ID. /// Gets or sets the currently playing item ID.
/// </summary> /// </summary>
public Guid? CurrentItemId { get; set; } public Guid? CurrentItemId { get; set; }
/// <summary> /// <summary>
/// Gets or sets a value indicating whether playback is currently active. /// Gets a value indicating whether playback is currently active.
/// </summary> /// </summary>
public bool IsPlaying { get; set; } public bool IsPlaying => _stateMachine.CurrentState == PlaybackState.Playing
|| _stateMachine.CurrentState == PlaybackState.Loading
|| _stateMachine.CurrentState == PlaybackState.Seeking;
/// <summary> /// <summary>
/// Gets or sets a value indicating whether playback is paused. /// Gets a value indicating whether playback is paused.
/// </summary> /// </summary>
public bool IsPaused { get; set; } public bool IsPaused => _stateMachine.CurrentState == PlaybackState.Paused;
/// <summary>
/// Gets the current playback state.
/// </summary>
public PlaybackState State => _stateMachine.CurrentState;
/// <summary>
/// Gets the last error that occurred during playback.
/// </summary>
public PlaybackErrorInfo? LastError => _lastError;
/// <inheritdoc /> /// <inheritdoc />
public bool IsSessionActive => _player.IsConnected; public bool IsSessionActive => _player.IsConnected;
@@ -185,12 +208,51 @@ public class LmsSessionController : ISessionController, IDisposable
useDirectPath, useDirectPath,
streamUrl); streamUrl);
await _lmsClient.PlayUrlAsync(_player.MacAddress, streamUrl).ConfigureAwait(false); // Transition to Loading state before sending command
_stateMachine.TryTransition(PlaybackState.Loading, "Starting playback");
// Send play command with retry logic
var playSuccess = await _statusPoller.ExecuteWithRetryAsync(
async () => await _lmsClient.PlayUrlAsync(_player.MacAddress, streamUrl).ConfigureAwait(false),
Config.MaxAutoRetries,
retry => _logger.LogInformation("Retrying play command (attempt {Retry})", retry),
_cancellationTokenSource.Token).ConfigureAwait(false);
if (!playSuccess)
{
_lastError = new PlaybackErrorInfo
{
ErrorType = PlaybackErrorType.LmsError,
Message = "Failed to send play command to LMS after retries",
OccurredAt = DateTime.UtcNow,
RetryCount = Config.MaxAutoRetries
};
_stateMachine.TryTransition(PlaybackState.Error, "Play command failed");
return;
}
// Wait for LMS to confirm playback started
var loadingTimeout = TimeSpan.FromSeconds(Config.LoadingTimeoutSeconds);
var started = await _statusPoller.WaitForPlaybackStartAsync(
_player.MacAddress,
loadingTimeout,
_cancellationTokenSource.Token).ConfigureAwait(false);
if (!started)
{
_lastError = new PlaybackErrorInfo
{
ErrorType = PlaybackErrorType.Timeout,
Message = $"LMS did not start playing within {loadingTimeout.TotalSeconds}s",
OccurredAt = DateTime.UtcNow
};
_stateMachine.TryTransition(PlaybackState.Error, "Loading timeout");
return;
}
// Track current playback state // Track current playback state
CurrentItemId = itemId; CurrentItemId = itemId;
IsPlaying = true; _stateMachine.TryTransition(PlaybackState.Playing, "LMS confirmed playback");
IsPaused = false;
// Track the seek offset so we report the correct position // Track the seek offset so we report the correct position
// When using direct file paths, LMS handles seeking natively so no offset needed // When using direct file paths, LMS handles seeking natively so no offset needed
@@ -236,7 +298,7 @@ public class LmsSessionController : ISessionController, IDisposable
// Stop any existing timer // Stop any existing timer
_progressTimer?.Dispose(); _progressTimer?.Dispose();
// Report progress every 2 seconds // Poll status every 2 seconds (for progress reporting when playing and volume sync always)
_progressTimer = new Timer( _progressTimer = new Timer(
async _ => await ReportPlaybackProgressAsync().ConfigureAwait(false), async _ => await ReportPlaybackProgressAsync().ConfigureAwait(false),
null, null,
@@ -246,21 +308,33 @@ public class LmsSessionController : ISessionController, IDisposable
private void StopProgressTimer() private void StopProgressTimer()
{ {
_progressTimer?.Dispose(); // Don't actually stop the timer - keep polling for volume updates
_progressTimer = null; // This ensures Jellyfin stays in sync with the device volume
// even when not playing media
} }
private async Task ReportPlaybackProgressAsync() private async Task ReportPlaybackProgressAsync()
{ {
if (!IsPlaying || !CurrentItemId.HasValue) try
{
// Always poll status to keep volume in sync, even when not playing
var status = await _lmsClient.GetPlayerStatusAsync(_player.MacAddress).ConfigureAwait(false);
if (status == null)
{ {
return; return;
} }
try // Update cached volume so Jellyfin stays in sync with device
{ _player.Volume = status.Volume;
var status = await _lmsClient.GetPlayerStatusAsync(_player.MacAddress).ConfigureAwait(false);
if (status == null) // Don't report playback progress during Loading, Seeking, Error, Stopped, or Idle states
var currentState = _stateMachine.CurrentState;
if (currentState == PlaybackState.Loading
|| currentState == PlaybackState.Seeking
|| currentState == PlaybackState.Error
|| currentState == PlaybackState.Stopped
|| currentState == PlaybackState.Idle
|| !CurrentItemId.HasValue)
{ {
return; return;
} }
@@ -269,19 +343,29 @@ public class LmsSessionController : ISessionController, IDisposable
// we're playing a transcoded stream that starts at the seek position. // we're playing a transcoded stream that starts at the seek position.
// Add the seek offset to get the actual track position. // Add the seek offset to get the actual track position.
var positionTicks = (long)(status.Time * TimeSpan.TicksPerSecond) + _seekOffsetTicks; var positionTicks = (long)(status.Time * TimeSpan.TicksPerSecond) + _seekOffsetTicks;
var isPaused = status.Mode == "pause"; var lmsIsPaused = status.Mode == "pause";
// Update our local state from LMS // Sync state machine with LMS state (handles external pause/play)
IsPaused = isPaused; if (lmsIsPaused && currentState == PlaybackState.Playing)
{
_stateMachine.TryTransition(PlaybackState.Paused, "LMS reported pause");
}
else if (!lmsIsPaused && status.Mode == "play" && currentState == PlaybackState.Paused)
{
_stateMachine.TryTransition(PlaybackState.Playing, "LMS reported play");
}
// Check if playback has stopped on LMS side (track ended) // Check if playback has stopped on LMS side (track ended)
// Only advance if we're not paused - LMS can briefly report "stop" during transitions if (status.Mode == "stop" && currentState == PlaybackState.Playing)
if (status.Mode == "stop" && !IsPaused)
{ {
// Double-check by getting status again after a brief delay to avoid false positives // Confirm stop with a quick poll instead of fixed delay
await Task.Delay(500).ConfigureAwait(false); var stillStopped = await _statusPoller.WaitForModeAsync(
var confirmStatus = await _lmsClient.GetPlayerStatusAsync(_player.MacAddress).ConfigureAwait(false); _player.MacAddress,
if (confirmStatus?.Mode != "stop") "stop",
TimeSpan.FromMilliseconds(500),
_cancellationTokenSource.Token).ConfigureAwait(false);
if (!stillStopped)
{ {
_logger.LogDebug("LMS mode changed from stop, ignoring"); _logger.LogDebug("LMS mode changed from stop, ignoring");
return; return;
@@ -311,7 +395,7 @@ public class LmsSessionController : ISessionController, IDisposable
{ {
ItemId = CurrentItemId.Value, ItemId = CurrentItemId.Value,
SessionId = _session.Id, SessionId = _session.Id,
IsPaused = isPaused, IsPaused = _stateMachine.CurrentState == PlaybackState.Paused,
PositionTicks = positionTicks, PositionTicks = positionTicks,
PlayMethod = PlayMethod.DirectStream, PlayMethod = PlayMethod.DirectStream,
CanSeek = true, CanSeek = true,
@@ -347,8 +431,7 @@ public class LmsSessionController : ISessionController, IDisposable
_logger.LogInformation("Reporting playback stopped for item {ItemId}", CurrentItemId.Value); _logger.LogInformation("Reporting playback stopped for item {ItemId}", CurrentItemId.Value);
await _sessionManager.OnPlaybackStopped(stopInfo).ConfigureAwait(false); await _sessionManager.OnPlaybackStopped(stopInfo).ConfigureAwait(false);
IsPlaying = false; _stateMachine.TryTransition(PlaybackState.Stopped, "Playback stopped");
IsPaused = false;
CurrentItemId = null; CurrentItemId = null;
} }
catch (Exception ex) catch (Exception ex)
@@ -382,29 +465,28 @@ public class LmsSessionController : ISessionController, IDisposable
case PlaystateCommand.Pause: case PlaystateCommand.Pause:
var pauseResult = await _lmsClient.PauseAsync(_player.MacAddress).ConfigureAwait(false); var pauseResult = await _lmsClient.PauseAsync(_player.MacAddress).ConfigureAwait(false);
_logger.LogInformation("Pause command result: {Result}", pauseResult); _logger.LogInformation("Pause command result: {Result}", pauseResult);
IsPaused = true; _stateMachine.TryTransition(PlaybackState.Paused, "Pause command");
break; break;
case PlaystateCommand.Unpause: case PlaystateCommand.Unpause:
var playResult = await _lmsClient.PlayAsync(_player.MacAddress).ConfigureAwait(false); var playResult = await _lmsClient.PlayAsync(_player.MacAddress).ConfigureAwait(false);
_logger.LogInformation("Unpause/Play command result: {Result}", playResult); _logger.LogInformation("Unpause/Play command result: {Result}", playResult);
IsPaused = false; _stateMachine.TryTransition(PlaybackState.Playing, "Unpause command");
break; break;
case PlaystateCommand.PlayPause: case PlaystateCommand.PlayPause:
// Toggle play/pause - check current state first // Toggle play/pause based on current state
var currentState = await _lmsClient.GetPlayerStatusAsync(_player.MacAddress).ConfigureAwait(false); if (_stateMachine.CurrentState == PlaybackState.Playing)
if (currentState?.Mode == "play")
{ {
var togglePauseResult = await _lmsClient.PauseAsync(_player.MacAddress).ConfigureAwait(false); var togglePauseResult = await _lmsClient.PauseAsync(_player.MacAddress).ConfigureAwait(false);
_logger.LogInformation("PlayPause toggle (pause) result: {Result}", togglePauseResult); _logger.LogInformation("PlayPause toggle (pause) result: {Result}", togglePauseResult);
IsPaused = true; _stateMachine.TryTransition(PlaybackState.Paused, "PlayPause toggle to pause");
} }
else else
{ {
var togglePlayResult = await _lmsClient.PlayAsync(_player.MacAddress).ConfigureAwait(false); var togglePlayResult = await _lmsClient.PlayAsync(_player.MacAddress).ConfigureAwait(false);
_logger.LogInformation("PlayPause toggle (play) result: {Result}", togglePlayResult); _logger.LogInformation("PlayPause toggle (play) result: {Result}", togglePlayResult);
IsPaused = false; _stateMachine.TryTransition(PlaybackState.Playing, "PlayPause toggle to play");
} }
break; break;
@@ -419,28 +501,48 @@ public class LmsSessionController : ISessionController, IDisposable
if (playstateRequest.SeekPositionTicks.HasValue && CurrentItemId.HasValue) if (playstateRequest.SeekPositionTicks.HasValue && CurrentItemId.HasValue)
{ {
var positionTicks = playstateRequest.SeekPositionTicks.Value; var positionTicks = playstateRequest.SeekPositionTicks.Value;
var positionSeconds = positionTicks / TimeSpan.TicksPerSecond; var positionSeconds = (double)(positionTicks / TimeSpan.TicksPerSecond);
// Transition to Seeking state (state machine remembers previous state)
_stateMachine.TryTransition(PlaybackState.Seeking, "Seek command");
// Check if we're using direct file path mode - if so, LMS can seek natively // Check if we're using direct file path mode - if so, LMS can seek natively
var config = Plugin.Instance?.Configuration; if (CanSeekNatively())
var canSeekNatively = config?.UseDirectFilePath == true
&& !string.IsNullOrEmpty(config.JellyfinMediaPath)
&& !string.IsNullOrEmpty(config.LmsMediaPath)
&& _currentItem?.Path != null
&& _currentItem.Path.StartsWith(config.JellyfinMediaPath, StringComparison.OrdinalIgnoreCase);
if (canSeekNatively)
{ {
// Use native LMS seeking - much smoother! // Use native LMS seeking - much smoother!
_logger.LogInformation("Seeking natively to {Seconds}s using LMS time command", positionSeconds); _logger.LogInformation("Seeking natively to {Seconds}s using LMS time command", positionSeconds);
await _lmsClient.SeekAsync(_player.MacAddress, positionSeconds).ConfigureAwait(false); await _lmsClient.SeekAsync(_player.MacAddress, positionSeconds).ConfigureAwait(false);
// Wait for seek to complete
var seekTimeout = TimeSpan.FromSeconds(Config.SeekTimeoutSeconds);
var seekComplete = await _statusPoller.WaitForSeekCompleteAsync(
_player.MacAddress,
positionSeconds,
toleranceSeconds: 2.0,
seekTimeout,
_cancellationTokenSource.Token).ConfigureAwait(false);
// No seek offset needed - LMS handles position tracking natively // No seek offset needed - LMS handles position tracking natively
_seekOffsetTicks = 0; _seekOffsetTicks = 0;
// Restore previous state
var previousState = _stateMachine.StateBeforeSeek;
if (seekComplete)
{
_stateMachine.TryTransition(previousState, "Seek completed");
}
else
{
_logger.LogWarning("Seek may not have completed within timeout, restoring state anyway");
_stateMachine.TryTransition(previousState, "Seek timeout - restoring state");
}
} }
else else
{ {
// For HTTP streams, LMS can't seek directly - we need to restart with startTimeTicks // For HTTP streams, LMS can't seek directly - we need to restart with startTimeTicks
// Build a new URL with the seek position and restart playback // This is essentially a new playback, so transition to Loading
_stateMachine.TryTransition(PlaybackState.Loading, "HTTP stream seek - restarting");
var streamUrl = BuildStreamUrlWithPosition(CurrentItemId.Value, positionTicks); var streamUrl = BuildStreamUrlWithPosition(CurrentItemId.Value, positionTicks);
_logger.LogInformation( _logger.LogInformation(
"Seeking by restarting stream at position {Seconds}s: {Url}", "Seeking by restarting stream at position {Seconds}s: {Url}",
@@ -449,9 +551,30 @@ public class LmsSessionController : ISessionController, IDisposable
await _lmsClient.PlayUrlAsync(_player.MacAddress, streamUrl).ConfigureAwait(false); await _lmsClient.PlayUrlAsync(_player.MacAddress, streamUrl).ConfigureAwait(false);
// Wait for playback to start
var loadingTimeout = TimeSpan.FromSeconds(Config.LoadingTimeoutSeconds);
var started = await _statusPoller.WaitForPlaybackStartAsync(
_player.MacAddress,
loadingTimeout,
_cancellationTokenSource.Token).ConfigureAwait(false);
// Track the seek offset so we report the correct position // Track the seek offset so we report the correct position
// The transcoded stream starts at 0, but we need to report the actual track position
_seekOffsetTicks = positionTicks; _seekOffsetTicks = positionTicks;
if (started)
{
_stateMachine.TryTransition(PlaybackState.Playing, "HTTP stream seek completed");
}
else
{
_lastError = new PlaybackErrorInfo
{
ErrorType = PlaybackErrorType.Timeout,
Message = "Stream restart after seek failed to start",
OccurredAt = DateTime.UtcNow
};
_stateMachine.TryTransition(PlaybackState.Error, "HTTP stream seek failed");
}
} }
_logger.LogInformation("Seek offset is now {Ticks} ticks ({Seconds}s)", _seekOffsetTicks, _seekOffsetTicks / TimeSpan.TicksPerSecond); _logger.LogInformation("Seek offset is now {Ticks} ticks ({Seconds}s)", _seekOffsetTicks, _seekOffsetTicks / TimeSpan.TicksPerSecond);
@@ -565,24 +688,27 @@ public class LmsSessionController : ISessionController, IDisposable
{ {
var config = Plugin.Instance?.Configuration; var config = Plugin.Instance?.Configuration;
// Check if direct file path mode is enabled and configured // Check if direct file path mode is enabled
if (config?.UseDirectFilePath == true if (config?.UseDirectFilePath == true && _currentItem?.Path != null)
&& !string.IsNullOrEmpty(config.JellyfinMediaPath)
&& !string.IsNullOrEmpty(config.LmsMediaPath)
&& _currentItem?.Path != null)
{ {
var directPath = BuildDirectFilePath(_currentItem.Path, config.JellyfinMediaPath, config.LmsMediaPath); // Try each path mapping until one matches
foreach (var mapping in config.GetAllPathMappings())
{
var directPath = BuildDirectFilePath(_currentItem.Path, mapping.JellyfinPath, mapping.LmsPath);
if (directPath != null) if (directPath != null)
{ {
_logger.LogDebug( _logger.LogDebug(
"Using direct file path: {OriginalPath} -> {MappedPath}", "Using direct file path with mapping '{JellyfinPath}' -> '{LmsPath}': {OriginalPath} -> {MappedPath}",
mapping.JellyfinPath,
mapping.LmsPath,
_currentItem.Path, _currentItem.Path,
directPath); directPath);
return (directPath, true); return (directPath, true);
} }
}
_logger.LogWarning( _logger.LogWarning(
"Direct file path mode enabled but path mapping failed for: {Path}", "Direct file path mode enabled but no path mapping matched for: {Path}",
_currentItem.Path); _currentItem.Path);
} }
@@ -590,6 +716,31 @@ public class LmsSessionController : ISessionController, IDisposable
return (BuildStreamUrlWithPosition(itemId, startPositionTicks), false); return (BuildStreamUrlWithPosition(itemId, startPositionTicks), false);
} }
/// <summary>
/// Checks if the current item can use native LMS seeking (direct file path mode).
/// </summary>
private bool CanSeekNatively()
{
var config = Plugin.Instance?.Configuration;
if (config?.UseDirectFilePath != true || _currentItem?.Path == null)
{
return false;
}
// Check if any mapping matches the current item's path
foreach (var mapping in config.GetAllPathMappings())
{
var normalizedPath = _currentItem.Path.Replace('\\', '/');
var normalizedPrefix = mapping.JellyfinPath.TrimEnd('/', '\\').Replace('\\', '/');
if (normalizedPath.StartsWith(normalizedPrefix, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
/// <summary> /// <summary>
/// Maps a Jellyfin file path to an LMS file path using the configured path prefixes. /// Maps a Jellyfin file path to an LMS file path using the configured path prefixes.
/// </summary> /// </summary>
@@ -665,7 +816,16 @@ public class LmsSessionController : ISessionController, IDisposable
if (disposing) if (disposing)
{ {
StopProgressTimer(); // Cancel any pending operations
_cancellationTokenSource.Cancel();
_cancellationTokenSource.Dispose();
// Actually stop the timer when disposing
_progressTimer?.Dispose();
_progressTimer = null;
// Reset state machine
_stateMachine.Reset();
} }
_disposed = true; _disposed = true;
@@ -1,313 +0,0 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.JellyLMS.Configuration;
using Jellyfin.Plugin.JellyLMS.Models;
using MediaBrowser.Controller.Library;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.JellyLMS.Services;
/// <summary>
/// Manages active playback sessions between Jellyfin and LMS.
/// </summary>
public class LmsSessionManager : IHostedService
{
private readonly ILogger<LmsSessionManager> _logger;
private readonly ILmsApiClient _lmsClient;
private readonly LmsPlayerManager _playerManager;
private readonly ILibraryManager _libraryManager;
private readonly ConcurrentDictionary<string, LmsPlaybackSession> _sessions = new();
/// <summary>
/// Initializes a new instance of the <see cref="LmsSessionManager"/> class.
/// </summary>
/// <param name="logger">The logger instance.</param>
/// <param name="lmsClient">The LMS API client.</param>
/// <param name="playerManager">The player manager.</param>
/// <param name="libraryManager">The Jellyfin library manager.</param>
public LmsSessionManager(
ILogger<LmsSessionManager> logger,
ILmsApiClient lmsClient,
LmsPlayerManager playerManager,
ILibraryManager libraryManager)
{
_logger = logger;
_lmsClient = lmsClient;
_playerManager = playerManager;
_libraryManager = libraryManager;
}
private PluginConfiguration Config => Plugin.Instance?.Configuration ?? new PluginConfiguration();
/// <summary>
/// Gets all active sessions.
/// </summary>
/// <returns>List of active sessions.</returns>
public List<LmsPlaybackSession> GetActiveSessions()
{
return _sessions.Values.Where(s => s.State != PlaybackState.Stopped).ToList();
}
/// <summary>
/// Gets a session by ID.
/// </summary>
/// <param name="sessionId">The session ID.</param>
/// <returns>The session, or null if not found.</returns>
public LmsPlaybackSession? GetSession(string sessionId)
{
return _sessions.GetValueOrDefault(sessionId);
}
/// <summary>
/// Starts playback of a Jellyfin item on LMS players.
/// </summary>
/// <param name="itemId">The Jellyfin item ID.</param>
/// <param name="playerMacs">The LMS player MAC addresses.</param>
/// <param name="userId">Optional user ID.</param>
/// <returns>The created session.</returns>
public async Task<LmsPlaybackSession?> StartPlaybackAsync(
Guid itemId,
IEnumerable<string> playerMacs,
Guid? userId = null)
{
var macList = playerMacs.ToList();
if (macList.Count == 0)
{
_logger.LogWarning("No players specified for playback");
return null;
}
var item = _libraryManager.GetItemById(itemId);
if (item == null)
{
_logger.LogWarning("Item {ItemId} not found", itemId);
return null;
}
// Build the audio stream URL
var streamUrl = BuildStreamUrl(itemId);
var session = new LmsPlaybackSession
{
ItemId = itemId,
ItemName = item.Name,
PlayerMacs = macList,
State = PlaybackState.Playing,
StreamUrl = streamUrl,
UserId = userId,
RuntimeTicks = item.RunTimeTicks ?? 0
};
// If multiple players, sync them first
if (macList.Count > 1)
{
var masterMac = macList[0];
var slaveMacs = macList.Skip(1);
await _playerManager.CreateSyncGroupAsync(masterMac, slaveMacs).ConfigureAwait(false);
}
// Start playback on the first player (others will sync)
var targetMac = macList[0];
var success = await _lmsClient.PlayUrlAsync(targetMac, streamUrl, item.Name).ConfigureAwait(false);
if (!success)
{
_logger.LogError("Failed to start playback on player {Mac}", targetMac);
return null;
}
_sessions[session.SessionId] = session;
_logger.LogInformation(
"Started playback session {SessionId} for {Item} on {Count} players",
session.SessionId,
item.Name,
macList.Count);
return session;
}
/// <summary>
/// Pauses a playback session.
/// </summary>
/// <param name="sessionId">The session ID.</param>
/// <returns>True if successful.</returns>
public async Task<bool> PauseSessionAsync(string sessionId)
{
if (!_sessions.TryGetValue(sessionId, out var session))
{
return false;
}
// Pause the master player (synced players will follow)
var success = await _lmsClient.PauseAsync(session.PlayerMacs[0]).ConfigureAwait(false);
if (success)
{
session.State = PlaybackState.Paused;
}
return success;
}
/// <summary>
/// Resumes a paused playback session.
/// </summary>
/// <param name="sessionId">The session ID.</param>
/// <returns>True if successful.</returns>
public async Task<bool> ResumeSessionAsync(string sessionId)
{
if (!_sessions.TryGetValue(sessionId, out var session))
{
return false;
}
var success = await _lmsClient.PlayAsync(session.PlayerMacs[0]).ConfigureAwait(false);
if (success)
{
session.State = PlaybackState.Playing;
}
return success;
}
/// <summary>
/// Stops a playback session.
/// </summary>
/// <param name="sessionId">The session ID.</param>
/// <returns>True if successful.</returns>
public async Task<bool> StopSessionAsync(string sessionId)
{
if (!_sessions.TryGetValue(sessionId, out var session))
{
return false;
}
var success = await _lmsClient.StopAsync(session.PlayerMacs[0]).ConfigureAwait(false);
if (success)
{
session.State = PlaybackState.Stopped;
// Unsync players if there were multiple
if (session.PlayerMacs.Count > 1)
{
await _playerManager.DissolveSyncGroupAsync(session.PlayerMacs[0]).ConfigureAwait(false);
}
}
// Remove the session
_sessions.TryRemove(sessionId, out _);
return success;
}
/// <summary>
/// Seeks to a position in the playback session.
/// </summary>
/// <param name="sessionId">The session ID.</param>
/// <param name="positionTicks">The position in ticks.</param>
/// <returns>True if successful.</returns>
public async Task<bool> SeekAsync(string sessionId, long positionTicks)
{
if (!_sessions.TryGetValue(sessionId, out var session))
{
return false;
}
var positionSeconds = positionTicks / TimeSpan.TicksPerSecond;
var success = await _lmsClient.SeekAsync(session.PlayerMacs[0], positionSeconds).ConfigureAwait(false);
if (success)
{
session.PositionTicks = positionTicks;
}
return success;
}
/// <summary>
/// Sets the volume for all players in a session.
/// </summary>
/// <param name="sessionId">The session ID.</param>
/// <param name="volume">The volume level (0-100).</param>
/// <returns>True if successful.</returns>
public async Task<bool> SetVolumeAsync(string sessionId, int volume)
{
if (!_sessions.TryGetValue(sessionId, out var session))
{
return false;
}
var success = true;
foreach (var mac in session.PlayerMacs)
{
if (!await _lmsClient.SetVolumeAsync(mac, volume).ConfigureAwait(false))
{
success = false;
}
}
return success;
}
/// <summary>
/// Updates the session state from LMS.
/// </summary>
/// <param name="sessionId">The session ID.</param>
/// <returns>A task representing the operation.</returns>
public async Task RefreshSessionStateAsync(string sessionId)
{
if (!_sessions.TryGetValue(sessionId, out var session))
{
return;
}
var status = await _lmsClient.GetPlayerStatusAsync(session.PlayerMacs[0]).ConfigureAwait(false);
if (status == null)
{
return;
}
session.PositionTicks = (long)(status.Time * TimeSpan.TicksPerSecond);
session.State = status.Mode switch
{
"play" => PlaybackState.Playing,
"pause" => PlaybackState.Paused,
_ => PlaybackState.Stopped
};
}
private string BuildStreamUrl(Guid itemId)
{
var baseUrl = Config.JellyfinServerUrl.TrimEnd('/');
// Direct stream URL - LMS will pull audio from Jellyfin
return $"{baseUrl}/Audio/{itemId}/stream.mp3";
}
/// <inheritdoc />
public Task StartAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("LMS Session Manager started");
return Task.CompletedTask;
}
/// <inheritdoc />
public Task StopAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("LMS Session Manager stopping");
// Stop all active sessions
foreach (var sessionId in _sessions.Keys.ToList())
{
_ = StopSessionAsync(sessionId);
}
return Task.CompletedTask;
}
}
@@ -0,0 +1,228 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.JellyLMS.Configuration;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.JellyLMS.Services;
/// <summary>
/// Polls LMS player status to confirm state transitions.
/// </summary>
public class LmsStatusPoller
{
private readonly ILmsApiClient _lmsClient;
private readonly ILogger _logger;
/// <summary>
/// Initializes a new instance of the <see cref="LmsStatusPoller"/> class.
/// </summary>
/// <param name="lmsClient">The LMS API client.</param>
/// <param name="logger">The logger instance.</param>
public LmsStatusPoller(ILmsApiClient lmsClient, ILogger logger)
{
_lmsClient = lmsClient;
_logger = logger;
}
private static PluginConfiguration Config => Plugin.Instance?.Configuration ?? new PluginConfiguration();
/// <summary>
/// Waits for LMS to report that playback has started (mode="play").
/// </summary>
/// <param name="playerMac">The player's MAC address.</param>
/// <param name="timeout">Maximum time to wait.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if playback started within the timeout.</returns>
public async Task<bool> WaitForPlaybackStartAsync(
string playerMac,
TimeSpan timeout,
CancellationToken cancellationToken = default)
{
return await WaitForModeAsync(playerMac, "play", timeout, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Waits for LMS to report a specific playback mode.
/// </summary>
/// <param name="playerMac">The player's MAC address.</param>
/// <param name="expectedMode">The expected mode ("play", "pause", "stop").</param>
/// <param name="timeout">Maximum time to wait.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if the expected mode was detected within the timeout.</returns>
public async Task<bool> WaitForModeAsync(
string playerMac,
string expectedMode,
TimeSpan timeout,
CancellationToken cancellationToken = default)
{
var pollInterval = TimeSpan.FromMilliseconds(Config.TransitionPollIntervalMs);
var startTime = DateTime.UtcNow;
_logger.LogDebug(
"Waiting for player {Mac} to reach mode '{Mode}' (timeout: {Timeout}s)",
playerMac,
expectedMode,
timeout.TotalSeconds);
while (DateTime.UtcNow - startTime < timeout)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
var status = await _lmsClient.GetPlayerStatusAsync(playerMac).ConfigureAwait(false);
if (status?.Mode == expectedMode)
{
_logger.LogDebug(
"Player {Mac} reached mode '{Mode}' after {Elapsed}ms",
playerMac,
expectedMode,
(DateTime.UtcNow - startTime).TotalMilliseconds);
return true;
}
_logger.LogDebug(
"Player {Mac} current mode: '{CurrentMode}', waiting for '{ExpectedMode}'",
playerMac,
status?.Mode ?? "unknown",
expectedMode);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error polling player {Mac} status", playerMac);
}
await Task.Delay(pollInterval, cancellationToken).ConfigureAwait(false);
}
_logger.LogWarning(
"Timeout waiting for player {Mac} to reach mode '{Mode}' after {Timeout}s",
playerMac,
expectedMode,
timeout.TotalSeconds);
return false;
}
/// <summary>
/// Waits for LMS to report a position within tolerance of the target.
/// Used to confirm seek operations completed.
/// </summary>
/// <param name="playerMac">The player's MAC address.</param>
/// <param name="targetPositionSeconds">The target position in seconds.</param>
/// <param name="toleranceSeconds">Acceptable tolerance (default 2 seconds).</param>
/// <param name="timeout">Maximum time to wait.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if the position was reached within the timeout.</returns>
public async Task<bool> WaitForSeekCompleteAsync(
string playerMac,
double targetPositionSeconds,
double toleranceSeconds,
TimeSpan timeout,
CancellationToken cancellationToken = default)
{
var pollInterval = TimeSpan.FromMilliseconds(Config.TransitionPollIntervalMs);
var startTime = DateTime.UtcNow;
_logger.LogDebug(
"Waiting for player {Mac} to seek to {Target}s (tolerance: {Tolerance}s, timeout: {Timeout}s)",
playerMac,
targetPositionSeconds,
toleranceSeconds,
timeout.TotalSeconds);
while (DateTime.UtcNow - startTime < timeout)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
var status = await _lmsClient.GetPlayerStatusAsync(playerMac).ConfigureAwait(false);
if (status != null)
{
var positionDiff = Math.Abs(status.Time - targetPositionSeconds);
if (positionDiff <= toleranceSeconds)
{
_logger.LogDebug(
"Player {Mac} reached position {Position}s (target: {Target}s) after {Elapsed}ms",
playerMac,
status.Time,
targetPositionSeconds,
(DateTime.UtcNow - startTime).TotalMilliseconds);
return true;
}
_logger.LogDebug(
"Player {Mac} at position {Position}s, waiting for {Target}s (diff: {Diff}s)",
playerMac,
status.Time,
targetPositionSeconds,
positionDiff);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error polling player {Mac} status during seek", playerMac);
}
await Task.Delay(pollInterval, cancellationToken).ConfigureAwait(false);
}
_logger.LogWarning(
"Timeout waiting for player {Mac} to seek to {Target}s after {Timeout}s",
playerMac,
targetPositionSeconds,
timeout.TotalSeconds);
return false;
}
/// <summary>
/// Executes an action with automatic retry on failure.
/// </summary>
/// <param name="action">The async action to execute.</param>
/// <param name="maxRetries">Maximum number of retries.</param>
/// <param name="onRetry">Optional callback when retrying.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if the action succeeded within retry limit.</returns>
public async Task<bool> ExecuteWithRetryAsync(
Func<Task<bool>> action,
int maxRetries,
Action<int>? onRetry = null,
CancellationToken cancellationToken = default)
{
var retryCount = 0;
var baseDelayMs = 500;
while (retryCount <= maxRetries)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
if (await action().ConfigureAwait(false))
{
return true;
}
}
catch (Exception ex) when (retryCount < maxRetries)
{
_logger.LogWarning(ex, "Action failed, will retry ({Retry}/{Max})", retryCount + 1, maxRetries);
}
retryCount++;
if (retryCount <= maxRetries)
{
onRetry?.Invoke(retryCount);
// Exponential backoff: 500ms, 1000ms, 2000ms, etc.
var delayMs = baseDelayMs * (int)Math.Pow(2, retryCount - 1);
_logger.LogDebug("Retrying in {Delay}ms (attempt {Retry}/{Max})", delayMs, retryCount, maxRetries);
await Task.Delay(delayMs, cancellationToken).ConfigureAwait(false);
}
}
return false;
}
}
@@ -0,0 +1,234 @@
using System;
using System.Collections.Generic;
using Jellyfin.Plugin.JellyLMS.Models;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.JellyLMS.Services;
/// <summary>
/// Event args for state transitions.
/// </summary>
public class StateTransitionEventArgs : EventArgs
{
/// <summary>
/// Gets the state before the transition.
/// </summary>
public PlaybackState FromState { get; init; }
/// <summary>
/// Gets the state after the transition.
/// </summary>
public PlaybackState ToState { get; init; }
/// <summary>
/// Gets the reason for the transition.
/// </summary>
public string? Reason { get; init; }
}
/// <summary>
/// Manages playback state transitions with validation.
/// </summary>
public class PlaybackStateMachine
{
private readonly object _lock = new();
private readonly ILogger? _logger;
private PlaybackState _currentState = PlaybackState.Idle;
private PlaybackState _stateBeforeSeek = PlaybackState.Idle;
/// <summary>
/// Valid state transitions. Key is the current state, value is the array of valid next states.
/// </summary>
private static readonly Dictionary<PlaybackState, PlaybackState[]> ValidTransitions = new()
{
[PlaybackState.Idle] = [PlaybackState.Loading, PlaybackState.Stopped],
[PlaybackState.Loading] = [PlaybackState.Playing, PlaybackState.Error, PlaybackState.Stopped],
[PlaybackState.Playing] = [PlaybackState.Paused, PlaybackState.Seeking, PlaybackState.Loading, PlaybackState.Stopped, PlaybackState.Error],
[PlaybackState.Paused] = [PlaybackState.Playing, PlaybackState.Seeking, PlaybackState.Loading, PlaybackState.Stopped, PlaybackState.Error],
[PlaybackState.Seeking] = [PlaybackState.Playing, PlaybackState.Paused, PlaybackState.Error, PlaybackState.Stopped],
[PlaybackState.Error] = [PlaybackState.Loading, PlaybackState.Idle, PlaybackState.Stopped],
[PlaybackState.Stopped] = [PlaybackState.Idle, PlaybackState.Loading]
};
/// <summary>
/// Initializes a new instance of the <see cref="PlaybackStateMachine"/> class.
/// </summary>
/// <param name="logger">Optional logger for state transitions.</param>
public PlaybackStateMachine(ILogger? logger = null)
{
_logger = logger;
}
/// <summary>
/// Fired when the state changes.
/// </summary>
public event EventHandler<StateTransitionEventArgs>? StateChanged;
/// <summary>
/// Gets the current playback state.
/// </summary>
public PlaybackState CurrentState
{
get
{
lock (_lock)
{
return _currentState;
}
}
}
/// <summary>
/// Gets the state before the current seek operation (if in Seeking state).
/// Used to restore the correct state after seeking completes.
/// </summary>
public PlaybackState StateBeforeSeek
{
get
{
lock (_lock)
{
return _stateBeforeSeek;
}
}
}
/// <summary>
/// Gets a value indicating whether playback is active (Playing or Paused).
/// </summary>
public bool IsPlaybackActive
{
get
{
lock (_lock)
{
return _currentState == PlaybackState.Playing
|| _currentState == PlaybackState.Paused
|| _currentState == PlaybackState.Seeking
|| _currentState == PlaybackState.Loading;
}
}
}
/// <summary>
/// Attempts to transition to a new state.
/// </summary>
/// <param name="newState">The target state.</param>
/// <param name="reason">Optional reason for the transition (for logging).</param>
/// <returns>True if the transition was valid and completed.</returns>
public bool TryTransition(PlaybackState newState, string? reason = null)
{
lock (_lock)
{
if (_currentState == newState)
{
return true; // Already in this state
}
if (!IsValidTransition(_currentState, newState))
{
_logger?.LogWarning(
"Invalid state transition attempted: {From} -> {To} (reason: {Reason})",
_currentState,
newState,
reason ?? "none");
return false;
}
// Store state before seek for restoration
if (newState == PlaybackState.Seeking)
{
_stateBeforeSeek = _currentState;
}
var oldState = _currentState;
_currentState = newState;
_logger?.LogInformation(
"State transition: {From} -> {To} (reason: {Reason})",
oldState,
newState,
reason ?? "none");
// Fire event outside the lock to prevent deadlocks
var args = new StateTransitionEventArgs
{
FromState = oldState,
ToState = newState,
Reason = reason
};
try
{
StateChanged?.Invoke(this, args);
}
catch (Exception ex)
{
_logger?.LogError(ex, "Error in StateChanged event handler");
}
return true;
}
}
/// <summary>
/// Forces a state change without validation. Use with caution.
/// Intended for error recovery scenarios.
/// </summary>
/// <param name="newState">The target state.</param>
/// <param name="reason">Reason for the forced transition.</param>
public void ForceState(PlaybackState newState, string reason)
{
lock (_lock)
{
var oldState = _currentState;
_currentState = newState;
_logger?.LogWarning(
"Forced state transition: {From} -> {To} (reason: {Reason})",
oldState,
newState,
reason);
var args = new StateTransitionEventArgs
{
FromState = oldState,
ToState = newState,
Reason = $"FORCED: {reason}"
};
try
{
StateChanged?.Invoke(this, args);
}
catch (Exception ex)
{
_logger?.LogError(ex, "Error in StateChanged event handler");
}
}
}
/// <summary>
/// Resets the state machine to Idle.
/// </summary>
public void Reset()
{
ForceState(PlaybackState.Idle, "Reset");
}
/// <summary>
/// Checks if a transition from one state to another is valid.
/// </summary>
/// <param name="from">The current state.</param>
/// <param name="to">The target state.</param>
/// <returns>True if the transition is valid.</returns>
public static bool IsValidTransition(PlaybackState from, PlaybackState to)
{
if (!ValidTransitions.TryGetValue(from, out var validTargets))
{
return false;
}
return Array.IndexOf(validTargets, to) >= 0;
}
}
@@ -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();
}
})();
+104 -30
View File
@@ -2,6 +2,20 @@
A Jellyfin plugin that bridges audio playback to Logitech Media Server (LMS) for multi-room synchronized playback. A Jellyfin plugin that bridges audio playback to Logitech Media Server (LMS) for multi-room synchronized playback.
## Quick Install
Add the following repository URL to your Jellyfin server to install JellyLMS directly from the plugin catalog:
```
https://gitea.tourolle.paris/dtourolle/jellyLMS/raw/branch/master/manifest.json
```
**Steps:**
1. Go to **Dashboard****Plugins** → **Repositories**
2. Click **Add** and paste the URL above
3. Go to **Catalog** and find "JellyLMS"
4. Click **Install** and restart Jellyfin
## Overview ## Overview
JellyLMS enables Jellyfin to stream audio to LMS, which acts as a multi-room speaker system. The architecture is: JellyLMS enables Jellyfin to stream audio to LMS, which acts as a multi-room speaker system. The architecture is:
@@ -18,14 +32,14 @@ JellyLMS enables Jellyfin to stream audio to LMS, which acts as a multi-room spe
│ │ (Audio) │ │ │ │ │ │ (Zones) │ │ │ │ (Audio) │ │ │ │ │ │ (Zones) │ │
│ └───────────┘ │ │ ┌───────────┐ │ │ └───────────┘ │ │ └───────────┘ │ │ ┌───────────┐ │ │ └───────────┘ │
│ │ │ │ Session │ │ │ │ │ │ │ │ Session │ │ │ │
│ ┌───────────┐ │ │ │ Manager │ │ │ ┌───────────┐ │ │ ┌───────────┐ │ │ │Controller │ │ │ ┌───────────┐ │
│ │ Queue │──┼────────►│ └───────────┘ │────────►│ │ Sync │ │ │ │ Queue │──┼────────►│ │ (State │ │────────►│ │ Sync │ │
│ │ │ │ │ │ │ │ Groups │ │ │ │ │ │ │ │ Machine) │ │ │ │ Groups │ │
│ └───────────┘ │ │ ─────────── │ │ └───────────┘ │ │ └───────────┘ │ │ ─────────── │ │ └───────────┘ │
│ │ │ │ REST API │ │ │ │ │ │ │ │ │ │
│ ┌───────────┐ │ │ │Controller │ │ │ │ │ ┌───────────┐ │ │ ┌───────────┐ │ │ │
│ │ Playback │──┼────────►│ └───────────┘ │ │ │ │ │ Playback │──┼────────►│ │ REST API │ │ │ │
│ │ Controls │ │ │ │ │ │ │ │ Controls │ │ │ └───────────┘ │ │ │
│ └───────────┘ │ └─────────────────┘ └─────────────────┘ │ └───────────┘ │ └─────────────────┘ └─────────────────┘
└─────────────────┘ └─────────────────┘
``` ```
@@ -34,8 +48,9 @@ JellyLMS enables Jellyfin to stream audio to LMS, which acts as a multi-room spe
- **Player Discovery**: Automatically discovers all LMS players/zones - **Player Discovery**: Automatically discovers all LMS players/zones
- **Multi-Room Sync**: Create and manage sync groups for synchronized playback across multiple rooms - **Multi-Room Sync**: Create and manage sync groups for synchronized playback across multiple rooms
- **Playback Control**: Play, pause, stop, seek, and volume control forwarded to LMS - **Playback Control**: Play, pause, stop, seek, and volume control via Jellyfin's "Play On" (cast) interface
- **Stream Bridging**: Generates audio stream URLs from Jellyfin for LMS to consume - **Stream Bridging**: Generates audio stream URLs from Jellyfin for LMS to consume
- **Robust State Machine**: Ensures proper sequencing of playback operations with automatic retry and timeout handling
## Screenshots ## Screenshots
@@ -62,9 +77,62 @@ Create and manage synchronized playback groups for multi-room audio:
## Requirements ## Requirements
- Jellyfin Server 10.10.0 or later - Jellyfin Server 10.10.0 or later
- .NET 8.0 Runtime - .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)
## 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.
### State Machine
The playback controller uses a state machine to ensure proper sequencing of operations:
```
┌────────┐
│ Idle │ (device connected, no media)
└───┬────┘
│ Play command
┌────────┐
┌────►│Loading │◄────┐
│ └───┬────┘ │
│ │ │ Seek (HTTP streaming
│ LMS confirms │ restarts stream)
│ mode="play" │
│ ▼ │
┌───────┐ │ ┌────────┐ │
│ Error │◄────┼─────│Playing │─────┘
└───┬───┘ │ └───┬────┘
│ │ │ Pause
retry │ ▼
│ │ ┌────────┐
└─────────┼─────│ Paused │
│ └───┬────┘
│ │ Seek (native LMS)
│ ▼
│ ┌────────┐
└─────│Seeking │
└────────┘
From any state: Stop → Stopped
```
### How Playback Works
1. **Cast Request**: User selects an LMS player from Jellyfin's "Play On" menu
2. **Loading**: Plugin sends play command to LMS and transitions to Loading state
3. **Confirmation**: Plugin polls LMS until playback is confirmed (mode="play")
4. **Playing**: Playback is active; progress is synced between Jellyfin and LMS
5. **Controls**: Play, pause, seek, and volume commands are forwarded to LMS
### Error Handling
The state machine includes automatic retry with exponential backoff:
- **Timeout errors**: Auto-retry up to 2 times (500ms → 1s delay)
- **Network errors**: Auto-retry up to 2 times
- **LMS errors**: No retry, transition to Error state
## Installation ## Installation
### Manual Installation ### Manual Installation
@@ -87,7 +155,7 @@ cd jellyLMS
dotnet build Jellyfin.Plugin.JellyLMS.sln -c Release dotnet build Jellyfin.Plugin.JellyLMS.sln -c Release
# The DLL will be in: # The DLL will be in:
# Jellyfin.Plugin.JellyLMS/bin/Release/net8.0/ # Jellyfin.Plugin.JellyLMS/bin/Release/net9.0/
``` ```
## Configuration ## Configuration
@@ -102,41 +170,45 @@ dotnet build Jellyfin.Plugin.JellyLMS.sln -c Release
| Connection Timeout | Timeout for LMS API calls (seconds) | `10` | | Connection Timeout | Timeout for LMS API calls (seconds) | `10` |
| Enable Auto Sync | Automatically sync players when creating groups | `true` | | Enable Auto Sync | Automatically sync players when creating groups | `true` |
| Default Player | MAC address of the default player | (none) | | Default Player | MAC address of the default player | (none) |
| Use Direct File Path | Enable direct file access instead of HTTP streaming | `false` |
### Advanced Settings (State Machine)
| Setting | Description | Default |
|---------|-------------|---------|
| Loading Timeout | Max time to wait for LMS to start playback (seconds) | `5` |
| Seek Timeout | Max time to wait for seek to complete (seconds) | `3` |
| Transition Poll Interval | How often to poll LMS during state transitions (ms) | `300` |
| Max Auto Retries | Number of automatic retries for transient failures | `2` |
3. Click "Test Connection" to verify connectivity to LMS 3. Click "Test Connection" to verify connectivity to LMS
4. Use "Discover Players" to see available LMS players 4. Use "Discover Players" to see available LMS players
## API Endpoints ## API Endpoints
The plugin exposes REST API endpoints under `/JellyLms/`: The plugin exposes REST API endpoints under `/JellyLms/` for player and sync group management.
**Note:** Playback control (play, pause, seek, volume) is handled through Jellyfin's native "Play On" (cast) interface, not through REST endpoints.
### Players ### Players
- `GET /JellyLms/Players` - List all LMS players - `GET /JellyLms/Players` - List all LMS players
- `GET /JellyLms/Players/{mac}` - Get specific player details - `GET /JellyLms/Players/{mac}` - Get specific player details
- `POST /JellyLms/Players/Refresh` - Refresh player list from LMS - `POST /JellyLms/Players/{mac}/PowerOn` - Power on a player
- `POST /JellyLms/Players/{mac}/PowerOff` - Power off a player
- `POST /JellyLms/Players/{mac}/Volume` - Set player volume
### Sync Groups ### Sync Groups
- `GET /JellyLms/SyncGroups` - List all sync groups - `GET /JellyLms/SyncGroups` - List all sync groups
- `POST /JellyLms/SyncGroups` - Create a new sync group - `POST /JellyLms/SyncGroups` - Create a new sync group
- `DELETE /JellyLms/SyncGroups/{masterMac}` - Dissolve a sync group - `DELETE /JellyLms/SyncGroups/{masterMac}` - Dissolve a sync group
- `DELETE /JellyLms/SyncGroups/{masterMac}/Players/{slaveMac}` - Remove player from group - `DELETE /JellyLms/SyncGroups/Players/{mac}` - Remove player from its sync group
### Sessions ### Utilities
- `GET /JellyLms/Sessions` - List active playback sessions
- `POST /JellyLms/Sessions` - Start a new playback session
- `POST /JellyLms/Sessions/{id}/Pause` - Pause playback
- `POST /JellyLms/Sessions/{id}/Resume` - Resume playback
- `POST /JellyLms/Sessions/{id}/Stop` - Stop playback
- `POST /JellyLms/Sessions/{id}/Seek` - Seek to position
- `POST /JellyLms/Sessions/{id}/Volume` - Set volume
### Status
- `GET /JellyLms/Status` - Get LMS connection status
- `POST /JellyLms/TestConnection` - Test LMS connectivity - `POST /JellyLms/TestConnection` - Test LMS connectivity
- `GET /JellyLms/DiscoverPaths` - Discover file paths for direct file access configuration
## LMS Setup ## LMS Setup
@@ -204,15 +276,17 @@ Jellyfin.Plugin.JellyLMS/
│ ├── PluginConfiguration.cs # Plugin settings │ ├── PluginConfiguration.cs # Plugin settings
│ └── configPage.html # Dashboard configuration UI │ └── configPage.html # Dashboard configuration UI
├── Api/ ├── Api/
│ └── JellyLmsController.cs # REST API endpoints │ └── JellyLmsController.cs # REST API endpoints (players, sync groups)
├── Services/ ├── Services/
│ ├── ILmsApiClient.cs # LMS API interface │ ├── ILmsApiClient.cs # LMS API interface
│ ├── LmsApiClient.cs # LMS JSON-RPC client │ ├── LmsApiClient.cs # LMS JSON-RPC client
│ ├── LmsPlayerManager.cs # Player discovery & sync │ ├── LmsPlayerManager.cs # Player discovery & sync
── LmsSessionManager.cs # Playback session management ── LmsSessionController.cs # Playback control (ISessionController)
│ ├── PlaybackStateMachine.cs # State machine for playback lifecycle
│ └── LmsStatusPoller.cs # Polls LMS to confirm state transitions
└── Models/ └── Models/
├── LmsPlayer.cs # Player model ├── LmsPlayer.cs # Player model
├── LmsPlaybackSession.cs # Session state model ├── LmsPlaybackSession.cs # Session state (incl. PlaybackState enum)
└── LmsApiModels.cs # JSON-RPC DTOs └── LmsApiModels.cs # JSON-RPC DTOs
``` ```
@@ -223,7 +297,7 @@ Jellyfin.Plugin.JellyLMS/
dotnet build Jellyfin.Plugin.JellyLMS.sln dotnet build Jellyfin.Plugin.JellyLMS.sln
# Copy to Jellyfin plugins directory # Copy to Jellyfin plugins directory
cp Jellyfin.Plugin.JellyLMS/bin/Debug/net8.0/Jellyfin.Plugin.JellyLMS.dll \ cp Jellyfin.Plugin.JellyLMS/bin/Debug/net9.0/Jellyfin.Plugin.JellyLMS.dll \
~/.local/share/jellyfin/plugins/JellyLMS/ ~/.local/share/jellyfin/plugins/JellyLMS/
# Restart Jellyfin to load the plugin # Restart Jellyfin to load the plugin
+60
View File
@@ -0,0 +1,60 @@
[
{
"guid": "a5b8c9d0-1e2f-3a4b-5c6d-7e8f9a0b1c2d",
"name": "JellyLMS",
"description": "Stream Jellyfin audio to Logitech Media Server (LMS) for multi-room playback",
"overview": "Bridges Jellyfin audio playback to LMS for synchronized multi-room playback across Squeezebox players",
"owner": "dtourolle",
"category": "Music",
"versions": [
{
"version": "1.0.5",
"changelog": "Release 1.0.5",
"targetAbi": "10.10.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyLMS/releases/download/v1.0.5/jellylms_1.0.5.0.zip",
"checksum": "13ecfe05b1a0f4f137211d9b6660f66e",
"timestamp": "2026-06-17T19:20:00Z"
},
{
"version": "1.0.4",
"changelog": "Release 1.0.4",
"targetAbi": "10.10.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyLMS/releases/download/v1.0.4/jellylms_1.0.4.0.zip",
"checksum": "6db64bf2ad625c735aff5178e0b53894",
"timestamp": "2026-06-17T18:44:44Z"
},
{
"version": "1.0.3",
"changelog": "Release 1.0.3",
"targetAbi": "10.10.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyLMS/releases/download/v1.0.3/jellylms_1.0.3.0.zip",
"checksum": "c6fa1b9f303babb9664e35cca1180985",
"timestamp": "2026-06-14T15:48:18Z"
},
{
"version": "1.0.2",
"changelog": "Release 1.0.2",
"targetAbi": "10.10.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyLMS/releases/download/v1.0.2/jellylms_1.0.2.0.zip",
"checksum": "43e4fcd6dc67be82a1e9d8816cdf00df",
"timestamp": "2026-01-25T18:35:06Z"
},
{
"version": "1.0.1",
"changelog": "Release 1.0.1",
"targetAbi": "10.10.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyLMS/releases/download/v1.0.1/jellylms_1.0.1.0.zip",
"checksum": "093a1821b86a220cdfad49c3d93345a7",
"timestamp": "2025-12-30T13:43:10Z"
},
{
"version": "1.0.0",
"changelog": "Release 1.0.0",
"targetAbi": "10.10.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyLMS/releases/download/v1.0.0/jellylms_1.0.0.0.zip",
"checksum": "b6194d5ceb5ec0ea711a48f6d34a290d",
"timestamp": "2025-12-20T13:54:14Z"
}
]
}
]