Compare commits
2 Commits
2f5a182afd
...
046bf6afe4
| Author | SHA1 | Date | |
|---|---|---|---|
| 046bf6afe4 | |||
| 83741d7980 |
63
.gitea/workflows/build.yaml
Normal file
63
.gitea/workflows/build.yaml
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
name: 'Build Plugin'
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
paths-ignore:
|
||||||
|
- '**/*.md'
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
paths-ignore:
|
||||||
|
- '**/*.md'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup .NET 9
|
||||||
|
uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
dotnet-version: '9.0.x'
|
||||||
|
|
||||||
|
- name: Verify .NET installation
|
||||||
|
run: dotnet --version
|
||||||
|
|
||||||
|
- name: Restore dependencies
|
||||||
|
run: dotnet restore Jellyfin.Plugin.JellyLMS.sln
|
||||||
|
|
||||||
|
- name: Build solution
|
||||||
|
run: dotnet build Jellyfin.Plugin.JellyLMS.sln --configuration Release --no-restore --no-self-contained
|
||||||
|
|
||||||
|
- name: Install JPRM
|
||||||
|
run: |
|
||||||
|
python3 -m venv /tmp/jprm-venv
|
||||||
|
/tmp/jprm-venv/bin/pip install jprm
|
||||||
|
|
||||||
|
- name: Build Jellyfin Plugin
|
||||||
|
id: jprm
|
||||||
|
run: |
|
||||||
|
# Create artifacts directory for JPRM output
|
||||||
|
mkdir -p artifacts
|
||||||
|
|
||||||
|
# Build plugin using JPRM
|
||||||
|
/tmp/jprm-venv/bin/jprm --verbosity=debug plugin build .
|
||||||
|
|
||||||
|
# Find the generated zip file
|
||||||
|
ARTIFACT=$(find . -name "*.zip" -type f -print -quit)
|
||||||
|
echo "artifact=${ARTIFACT}" >> $GITHUB_OUTPUT
|
||||||
|
echo "Found artifact: ${ARTIFACT}"
|
||||||
|
|
||||||
|
- name: Upload build artifact
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: jellylms-plugin
|
||||||
|
path: ${{ steps.jprm.outputs.artifact }}
|
||||||
|
retention-days: 30
|
||||||
|
if-no-files-found: error
|
||||||
130
.gitea/workflows/release.yaml
Normal file
130
.gitea/workflows/release.yaml
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
name: 'Release Plugin'
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*.*.*'
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: 'Version to release (e.g., v1.0.0)'
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup .NET 9
|
||||||
|
uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
dotnet-version: '9.0.x'
|
||||||
|
|
||||||
|
- name: Verify .NET installation
|
||||||
|
run: dotnet --version
|
||||||
|
|
||||||
|
- name: Get version
|
||||||
|
id: get_version
|
||||||
|
run: |
|
||||||
|
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||||
|
VERSION="${{ github.event.inputs.version }}"
|
||||||
|
else
|
||||||
|
VERSION="${GITHUB_REF#refs/tags/}"
|
||||||
|
fi
|
||||||
|
echo "version=${VERSION}" >> $GITHUB_OUTPUT
|
||||||
|
echo "version_number=${VERSION#v}" >> $GITHUB_OUTPUT
|
||||||
|
echo "Building version: ${VERSION}"
|
||||||
|
|
||||||
|
- name: Update build.yaml with version
|
||||||
|
run: |
|
||||||
|
VERSION="${{ steps.get_version.outputs.version_number }}"
|
||||||
|
sed -i "s/^version:.*/version: \"${VERSION}\"/" build.yaml
|
||||||
|
cat build.yaml
|
||||||
|
|
||||||
|
- name: Restore dependencies
|
||||||
|
run: dotnet restore Jellyfin.Plugin.JellyLMS.sln
|
||||||
|
|
||||||
|
- name: Build solution
|
||||||
|
run: dotnet build Jellyfin.Plugin.JellyLMS.sln --configuration Release --no-restore --no-self-contained
|
||||||
|
|
||||||
|
- name: Install JPRM
|
||||||
|
run: |
|
||||||
|
python3 -m venv /tmp/jprm-venv
|
||||||
|
/tmp/jprm-venv/bin/pip install jprm
|
||||||
|
|
||||||
|
- name: Build Jellyfin Plugin
|
||||||
|
id: jprm
|
||||||
|
run: |
|
||||||
|
# Create artifacts directory for JPRM output
|
||||||
|
mkdir -p artifacts
|
||||||
|
|
||||||
|
# Build plugin using JPRM
|
||||||
|
/tmp/jprm-venv/bin/jprm --verbosity=debug plugin build ./
|
||||||
|
|
||||||
|
# Find the generated zip file
|
||||||
|
ARTIFACT=$(find . -name "*.zip" -type f -print -quit)
|
||||||
|
ARTIFACT_NAME=$(basename "${ARTIFACT}")
|
||||||
|
echo "artifact=${ARTIFACT}" >> $GITHUB_OUTPUT
|
||||||
|
echo "artifact_name=${ARTIFACT_NAME}" >> $GITHUB_OUTPUT
|
||||||
|
echo "Found artifact: ${ARTIFACT}"
|
||||||
|
|
||||||
|
- name: Create Release
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
# Get repository information
|
||||||
|
REPO_OWNER="${{ github.repository_owner }}"
|
||||||
|
REPO_NAME="${{ github.event.repository.name }}"
|
||||||
|
GITEA_URL="${{ github.server_url }}"
|
||||||
|
|
||||||
|
# Prepare release body
|
||||||
|
RELEASE_BODY="JellyLMS Jellyfin Plugin ${{ steps.get_version.outputs.version }}\n\nSee attached files for plugin installation."
|
||||||
|
RELEASE_BODY_JSON=$(echo -n "${RELEASE_BODY}" | jq -Rs .)
|
||||||
|
|
||||||
|
# Create release using Gitea API
|
||||||
|
RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases" \
|
||||||
|
-d "{
|
||||||
|
\"tag_name\": \"${{ steps.get_version.outputs.version }}\",
|
||||||
|
\"name\": \"Release ${{ steps.get_version.outputs.version }}\",
|
||||||
|
\"body\": ${RELEASE_BODY_JSON},
|
||||||
|
\"draft\": false,
|
||||||
|
\"prerelease\": false
|
||||||
|
}")
|
||||||
|
|
||||||
|
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
|
||||||
|
BODY=$(echo "$RESPONSE" | sed '$d')
|
||||||
|
|
||||||
|
if [ "$HTTP_CODE" -ge 200 ] && [ "$HTTP_CODE" -lt 300 ]; then
|
||||||
|
RELEASE_ID=$(echo "$BODY" | jq -r '.id')
|
||||||
|
echo "Created release with ID: ${RELEASE_ID}"
|
||||||
|
else
|
||||||
|
echo "Failed to create release. HTTP ${HTTP_CODE}"
|
||||||
|
echo "$BODY"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Upload plugin artifact
|
||||||
|
echo "Uploading plugin artifact..."
|
||||||
|
curl -f -X POST \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
-H "Content-Type: application/zip" \
|
||||||
|
--data-binary "@${{ steps.jprm.outputs.artifact }}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}/assets?name=${{ steps.jprm.outputs.artifact_name }}"
|
||||||
|
|
||||||
|
# Upload build.yaml
|
||||||
|
echo "Uploading build.yaml..."
|
||||||
|
curl -f -X POST \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
-H "Content-Type: application/x-yaml" \
|
||||||
|
--data-binary "@build.yaml" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}/assets?name=build.yaml"
|
||||||
|
|
||||||
|
echo "Release created successfully!"
|
||||||
|
echo "View at: ${GITEA_URL}/${REPO_OWNER}/${REPO_NAME}/releases/tag/${{ steps.get_version.outputs.version }}"
|
||||||
@ -1,9 +1,11 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Jellyfin.Data.Enums;
|
using Jellyfin.Data.Enums;
|
||||||
using Jellyfin.Plugin.JellyLMS.Models;
|
using Jellyfin.Plugin.JellyLMS.Models;
|
||||||
|
using MediaBrowser.Controller.Library;
|
||||||
using MediaBrowser.Controller.Session;
|
using MediaBrowser.Controller.Session;
|
||||||
using MediaBrowser.Model.Session;
|
using MediaBrowser.Model.Session;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
@ -219,6 +221,72 @@ public class LmsDeviceDiscoveryService : IHostedService, IDisposable
|
|||||||
player.MacAddress,
|
player.MacAddress,
|
||||||
session.Id);
|
session.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Report playback progress if the controller has an active item
|
||||||
|
if (controller is LmsSessionController lmsController)
|
||||||
|
{
|
||||||
|
await ReportPlaybackProgressAsync(sessionManager, session, lmsController).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ReportPlaybackProgressAsync(
|
||||||
|
ISessionManager sessionManager,
|
||||||
|
SessionInfo session,
|
||||||
|
LmsSessionController controller)
|
||||||
|
{
|
||||||
|
if (!controller.IsPlaying || !controller.CurrentItemId.HasValue)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Get current playback status from LMS
|
||||||
|
var status = await _lmsClient.GetPlayerStatusAsync(controller.PlayerMac).ConfigureAwait(false);
|
||||||
|
if (status == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the item from Jellyfin library
|
||||||
|
var libraryManager = _serviceProvider.GetService<ILibraryManager>();
|
||||||
|
if (libraryManager == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var item = libraryManager.GetItemById(controller.CurrentItemId.Value);
|
||||||
|
if (item == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate position in ticks
|
||||||
|
var positionTicks = (long)(status.Time * TimeSpan.TicksPerSecond);
|
||||||
|
|
||||||
|
// Determine if paused
|
||||||
|
var isPaused = status.Mode == "pause";
|
||||||
|
|
||||||
|
// Create playback progress info
|
||||||
|
var progressInfo = new PlaybackProgressInfo
|
||||||
|
{
|
||||||
|
ItemId = controller.CurrentItemId.Value,
|
||||||
|
SessionId = session.Id,
|
||||||
|
IsPaused = isPaused,
|
||||||
|
PositionTicks = positionTicks,
|
||||||
|
PlayMethod = PlayMethod.DirectStream,
|
||||||
|
CanSeek = true,
|
||||||
|
IsMuted = status.Volume == 0,
|
||||||
|
VolumeLevel = status.Volume
|
||||||
|
};
|
||||||
|
|
||||||
|
// Report progress to session manager
|
||||||
|
await sessionManager.OnPlaybackProgress(progressInfo).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "Error reporting playback progress for player {Name}", controller.PlayerMac);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|||||||
@ -39,8 +39,23 @@ public class LmsSessionController : ISessionController
|
|||||||
_session = session;
|
_session = session;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the currently playing item ID.
|
||||||
|
/// </summary>
|
||||||
|
public Guid? CurrentItemId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether playback is currently active.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsPlaying { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether playback is paused.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsPaused { get; set; }
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public bool IsSessionActive => _player.IsConnected && _player.IsPoweredOn;
|
public bool IsSessionActive => _player.IsConnected;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public bool SupportsMediaControl => true;
|
public bool SupportsMediaControl => true;
|
||||||
@ -57,10 +72,11 @@ public class LmsSessionController : ISessionController
|
|||||||
T data,
|
T data,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
_logger.LogDebug(
|
_logger.LogInformation(
|
||||||
"LMS Session Controller received message {MessageType} for player {PlayerName}",
|
"LMS Session Controller received message {MessageType} for player {PlayerName} ({Mac})",
|
||||||
name,
|
name,
|
||||||
_player.Name);
|
_player.Name,
|
||||||
|
_player.MacAddress);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@ -118,6 +134,11 @@ public class LmsSessionController : ISessionController
|
|||||||
_logger.LogInformation("Playing stream URL: {Url}", streamUrl);
|
_logger.LogInformation("Playing stream URL: {Url}", streamUrl);
|
||||||
await _lmsClient.PlayUrlAsync(_player.MacAddress, streamUrl).ConfigureAwait(false);
|
await _lmsClient.PlayUrlAsync(_player.MacAddress, streamUrl).ConfigureAwait(false);
|
||||||
|
|
||||||
|
// Track current playback state
|
||||||
|
CurrentItemId = itemId;
|
||||||
|
IsPlaying = true;
|
||||||
|
IsPaused = false;
|
||||||
|
|
||||||
// Seek to start position if specified
|
// Seek to start position if specified
|
||||||
if (playRequest.StartPositionTicks.HasValue && playRequest.StartPositionTicks.Value > 0)
|
if (playRequest.StartPositionTicks.HasValue && playRequest.StartPositionTicks.Value > 0)
|
||||||
{
|
{
|
||||||
@ -135,29 +156,60 @@ public class LmsSessionController : ISessionController
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogDebug(
|
_logger.LogInformation(
|
||||||
"Playstate command {Command} for player {PlayerName}",
|
"Playstate command {Command} for player {PlayerName} ({Mac})",
|
||||||
playstateRequest.Command,
|
playstateRequest.Command,
|
||||||
_player.Name);
|
_player.Name,
|
||||||
|
_player.MacAddress);
|
||||||
|
|
||||||
switch (playstateRequest.Command)
|
switch (playstateRequest.Command)
|
||||||
{
|
{
|
||||||
case PlaystateCommand.Stop:
|
case PlaystateCommand.Stop:
|
||||||
await _lmsClient.StopAsync(_player.MacAddress).ConfigureAwait(false);
|
var stopResult = await _lmsClient.StopAsync(_player.MacAddress).ConfigureAwait(false);
|
||||||
|
_logger.LogInformation("Stop command result: {Result}", stopResult);
|
||||||
|
IsPlaying = false;
|
||||||
|
IsPaused = false;
|
||||||
|
CurrentItemId = null;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case PlaystateCommand.Pause:
|
case PlaystateCommand.Pause:
|
||||||
await _lmsClient.PauseAsync(_player.MacAddress).ConfigureAwait(false);
|
var pauseResult = await _lmsClient.PauseAsync(_player.MacAddress).ConfigureAwait(false);
|
||||||
|
_logger.LogInformation("Pause command result: {Result}", pauseResult);
|
||||||
|
IsPaused = true;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case PlaystateCommand.Unpause:
|
case PlaystateCommand.Unpause:
|
||||||
await _lmsClient.PlayAsync(_player.MacAddress).ConfigureAwait(false);
|
var playResult = await _lmsClient.PlayAsync(_player.MacAddress).ConfigureAwait(false);
|
||||||
|
_logger.LogInformation("Unpause/Play command result: {Result}", playResult);
|
||||||
|
IsPaused = false;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case PlaystateCommand.PlayPause:
|
||||||
|
// Toggle play/pause - check current state first
|
||||||
|
var currentState = await _lmsClient.GetPlayerStatusAsync(_player.MacAddress).ConfigureAwait(false);
|
||||||
|
if (currentState?.Mode == "play")
|
||||||
|
{
|
||||||
|
var togglePauseResult = await _lmsClient.PauseAsync(_player.MacAddress).ConfigureAwait(false);
|
||||||
|
_logger.LogInformation("PlayPause toggle (pause) result: {Result}", togglePauseResult);
|
||||||
|
IsPaused = true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var togglePlayResult = await _lmsClient.PlayAsync(_player.MacAddress).ConfigureAwait(false);
|
||||||
|
_logger.LogInformation("PlayPause toggle (play) result: {Result}", togglePlayResult);
|
||||||
|
IsPaused = false;
|
||||||
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case PlaystateCommand.Seek:
|
case PlaystateCommand.Seek:
|
||||||
if (playstateRequest.SeekPositionTicks.HasValue)
|
if (playstateRequest.SeekPositionTicks.HasValue)
|
||||||
{
|
{
|
||||||
var positionSeconds = playstateRequest.SeekPositionTicks.Value / TimeSpan.TicksPerSecond;
|
var positionSeconds = playstateRequest.SeekPositionTicks.Value / TimeSpan.TicksPerSecond;
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Seeking player {PlayerName} to {Seconds} seconds",
|
||||||
|
_player.Name,
|
||||||
|
positionSeconds);
|
||||||
await _lmsClient.SeekAsync(_player.MacAddress, positionSeconds).ConfigureAwait(false);
|
await _lmsClient.SeekAsync(_player.MacAddress, positionSeconds).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,8 +2,8 @@
|
|||||||
name: "JellyLMS"
|
name: "JellyLMS"
|
||||||
guid: "a5b8c9d0-1e2f-3a4b-5c6d-7e8f9a0b1c2d"
|
guid: "a5b8c9d0-1e2f-3a4b-5c6d-7e8f9a0b1c2d"
|
||||||
version: "1.0.0.0"
|
version: "1.0.0.0"
|
||||||
targetAbi: "10.9.0.0"
|
targetAbi: "10.11.0.0"
|
||||||
framework: "net8.0"
|
framework: "net9.0"
|
||||||
overview: "Bridge plugin to stream Jellyfin audio to Logitech Media Server (LMS) players"
|
overview: "Bridge plugin to stream Jellyfin audio to Logitech Media Server (LMS) players"
|
||||||
description: >
|
description: >
|
||||||
JellyLMS enables Jellyfin to stream audio to LMS (Logitech Media Server) for
|
JellyLMS enables Jellyfin to stream audio to LMS (Logitech Media Server) for
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user