first commit
All checks were successful
🏗️ Build Plugin / build (push) Successful in 1m13s
🧪 Test Plugin / test (push) Successful in 20s
Latest Release / latest-release (push) Successful in 25s

This commit is contained in:
Duncan Tourolle 2026-06-12 18:16:47 +02:00
parent 7a9dbdafcc
commit a38122e993
39 changed files with 1522 additions and 295 deletions

View File

@ -0,0 +1,65 @@
name: '🏗️ Build Plugin'
on:
push:
branches:
- master
paths-ignore:
- '**/*.md'
pull_request:
branches:
- master
paths-ignore:
- '**/*.md'
workflow_dispatch:
jobs:
build:
runs-on: linux/amd64
container:
image: gitea.tourolle.paris/dtourolle/jray-builder:latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
path: build-${{ github.run_id }}
- name: Cache NuGet packages
uses: actions/cache@v3
with:
path: ~/.nuget/packages
key: nuget-${{ hashFiles('**/Jellyfin.Plugin.JRay.csproj') }}
restore-keys: nuget-
- name: Restore dependencies
working-directory: build-${{ github.run_id }}
run: dotnet restore Jellyfin.Plugin.JRay.sln
- name: Build solution
working-directory: build-${{ github.run_id }}
run: dotnet build Jellyfin.Plugin.JRay.sln --configuration Release --no-restore --no-self-contained /m:1
- name: Build Jellyfin Plugin
id: jprm
working-directory: build-${{ github.run_id }}
run: |
mkdir -p artifacts
jprm --verbosity=debug plugin build .
ARTIFACT=$(find . -name "*.zip" -type f -print -quit | sed 's|^\./||')
LATEST="artifacts/jray_latest.zip"
cp "${ARTIFACT}" "${LATEST}"
echo "artifact=${LATEST}" >> $GITHUB_OUTPUT
echo "Found artifact: ${ARTIFACT} -> ${LATEST}"
- name: Upload build artifact
uses: actions/upload-artifact@v3
with:
name: jellyfin-jray-plugin
path: build-${{ github.run_id }}/${{ steps.jprm.outputs.artifact }}
retention-days: 30
if-no-files-found: error
- name: Cleanup
if: always()
run: rm -rf build-${{ github.run_id }}

View File

@ -0,0 +1,166 @@
name: 'Latest Release'
on:
push:
branches:
- master
paths-ignore:
- '**/*.md'
- 'manifest.json'
jobs:
latest-release:
runs-on: linux/amd64
container:
image: gitea.tourolle.paris/dtourolle/jray-builder:latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
path: build-${{ github.run_id }}
- name: Restore dependencies
working-directory: build-${{ github.run_id }}
run: dotnet restore Jellyfin.Plugin.JRay.sln
- name: Build solution
working-directory: build-${{ github.run_id }}
run: dotnet build Jellyfin.Plugin.JRay.sln --configuration Release --no-restore --no-self-contained /m:1
- name: Build Jellyfin Plugin
id: jprm
working-directory: build-${{ github.run_id }}
run: |
mkdir -p artifacts
jprm --verbosity=debug plugin build .
ARTIFACT=$(find . -name "*.zip" -type f -print -quit | sed 's|^\./||')
ARTIFACT_NAME=$(basename "${ARTIFACT}")
echo "artifact=${ARTIFACT}" >> $GITHUB_OUTPUT
echo "artifact_name=${ARTIFACT_NAME}" >> $GITHUB_OUTPUT
echo "Found artifact: ${ARTIFACT}"
- name: Calculate checksum
id: checksum
working-directory: build-${{ github.run_id }}
run: |
CHECKSUM=$(md5sum "${{ steps.jprm.outputs.artifact }}" | awk '{print $1}')
echo "checksum=${CHECKSUM}" >> $GITHUB_OUTPUT
echo "Checksum: ${CHECKSUM}"
- name: Delete existing latest release
working-directory: build-${{ github.run_id }}
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
REPO_OWNER="${{ github.repository_owner }}"
REPO_NAME="${{ github.event.repository.name }}"
GITEA_URL="${{ github.server_url }}"
TAG="latest"
EXISTING=$(curl -s -o /dev/null -w "%{http_code}" \
-H "Authorization: token ${GITEA_TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/tags/${TAG}")
if [ "$EXISTING" = "200" ]; then
RELEASE_ID=$(curl -s \
-H "Authorization: token ${GITEA_TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/tags/${TAG}" | jq -r '.id')
echo "Deleting existing latest release (ID: ${RELEASE_ID})..."
curl -s -X DELETE \
-H "Authorization: token ${GITEA_TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}"
fi
curl -s -X DELETE \
-H "Authorization: token ${GITEA_TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/tags/${TAG}" || true
- name: Create latest release
working-directory: build-${{ github.run_id }}
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
REPO_OWNER="${{ github.repository_owner }}"
REPO_NAME="${{ github.event.repository.name }}"
GITEA_URL="${{ github.server_url }}"
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 "$(jq -n --arg tag "latest" --arg name "Latest Build" --arg body "JRay Jellyfin Plugin latest build from master." '{tag_name: $tag, name: $name, body: $body, target_commitish: "master", draft: false, prerelease: true}')")
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 "Latest release updated successfully!"
- name: Update manifest.json
working-directory: build-${{ github.run_id }}
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
REPO_OWNER="${{ github.repository_owner }}"
REPO_NAME="${{ github.event.repository.name }}"
GITEA_URL="${{ github.server_url }}"
CHECKSUM="${{ steps.checksum.outputs.checksum }}"
ARTIFACT_NAME="${{ steps.jprm.outputs.artifact_name }}"
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
DOWNLOAD_URL="${GITEA_URL}/${REPO_OWNER}/${REPO_NAME}/releases/download/latest/${ARTIFACT_NAME}"
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
git config user.name "Gitea Actions"
git config user.email "actions@gitea.tourolle.paris"
git fetch origin master
git checkout master
# Remove existing "latest" entry if present, then prepend new one
jq --arg url "$DOWNLOAD_URL" 'if .[0].versions[0].changelog == "Latest Build" then .[0].versions = .[0].versions[1:] else . end' manifest.json > manifest.tmp && mv manifest.tmp manifest.json
NEW_VERSION=$(cat <<EOF
{
"version": "0.0.0.0",
"changelog": "Latest Build",
"targetAbi": "10.9.0.0",
"sourceUrl": "${DOWNLOAD_URL}",
"checksum": "${CHECKSUM}",
"timestamp": "${TIMESTAMP}"
}
EOF
)
jq --argjson newver "${NEW_VERSION}" '.[0].versions = [$newver] + .[0].versions' manifest.json > manifest.tmp && mv manifest.tmp manifest.json
git add manifest.json
git commit -m "Update manifest.json for latest build (${SHORT_SHA})" || echo "No changes to commit"
git push origin master
- name: Cleanup
if: always()
run: rm -rf build-${{ github.run_id }}

View File

@ -0,0 +1,167 @@
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: linux/amd64
container:
image: gitea.tourolle.paris/dtourolle/jray-builder:latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
path: release-${{ github.run_id }}
- 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
working-directory: release-${{ github.run_id }}
run: |
VERSION="${{ steps.get_version.outputs.version_number }}"
sed -i "s/^version:.*/version: \"${VERSION}\"/" build.yaml
cat build.yaml
- name: Cache NuGet packages
uses: actions/cache@v3
with:
path: ~/.nuget/packages
key: nuget-${{ hashFiles('**/Jellyfin.Plugin.JRay.csproj') }}
restore-keys: nuget-
- name: Restore dependencies
working-directory: release-${{ github.run_id }}
run: dotnet restore Jellyfin.Plugin.JRay.sln
- name: Build solution
working-directory: release-${{ github.run_id }}
run: dotnet build Jellyfin.Plugin.JRay.sln --configuration Release --no-restore --no-self-contained /m:1
- name: Build Jellyfin Plugin
id: jprm
working-directory: release-${{ github.run_id }}
run: |
mkdir -p artifacts
jprm --verbosity=debug plugin build ./
ARTIFACT=$(find . -name "*.zip" -type f -print -quit | sed 's|^\./||')
ARTIFACT_NAME=$(basename "${ARTIFACT}")
echo "artifact=${ARTIFACT}" >> $GITHUB_OUTPUT
echo "artifact_name=${ARTIFACT_NAME}" >> $GITHUB_OUTPUT
echo "Found artifact: ${ARTIFACT}"
- 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 "Checksum: ${CHECKSUM}"
- name: Create Release
working-directory: release-${{ github.run_id }}
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 }}"
# Create release using Gitea API
VERSION="${{ steps.get_version.outputs.version }}"
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 "$(jq -n --arg tag "$VERSION" --arg name "Release $VERSION" --arg body "JRay Jellyfin Plugin. See attached files for plugin installation." '{tag_name: $tag, name: $name, body: $body, draft: false, prerelease: false}')")
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
BODY=$(echo "$RESPONSE" | sed '$d')
if [ "$HTTP_CODE" -ge 200 ] && [ "$HTTP_CODE" -lt 300 ]; then
RELEASE_ID=$(echo "$BODY" | jq -r '.id')
echo "Created release with ID: ${RELEASE_ID}"
else
echo "Failed to create release. HTTP ${HTTP_CODE}"
echo "$BODY"
exit 1
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 }}"
- name: Update manifest.json
working-directory: release-${{ github.run_id }}
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
REPO_OWNER="${{ github.repository_owner }}"
REPO_NAME="${{ github.event.repository.name }}"
GITEA_URL="${{ github.server_url }}"
VERSION="${{ steps.get_version.outputs.version_number }}"
CHECKSUM="${{ steps.checksum.outputs.checksum }}"
ARTIFACT_NAME="${{ steps.jprm.outputs.artifact_name }}"
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
DOWNLOAD_URL="${GITEA_URL}/${REPO_OWNER}/${REPO_NAME}/releases/download/${{ steps.get_version.outputs.version }}/${ARTIFACT_NAME}"
git config user.name "Gitea Actions"
git config user.email "actions@gitea.tourolle.paris"
git fetch origin master
git checkout master
NEW_VERSION=$(cat <<EOF
{
"version": "${VERSION}",
"changelog": "Release ${VERSION}",
"targetAbi": "10.9.0.0",
"sourceUrl": "${DOWNLOAD_URL}",
"checksum": "${CHECKSUM}",
"timestamp": "${TIMESTAMP}"
}
EOF
)
jq --argjson newver "${NEW_VERSION}" '.[0].versions = [$newver] + .[0].versions' manifest.json > manifest.tmp && mv manifest.tmp manifest.json
git add manifest.json
git commit -m "Update manifest.json for version ${VERSION}"
git push origin master
- name: Cleanup
if: always()
run: rm -rf release-${{ github.run_id }}

View File

@ -0,0 +1,47 @@
name: '🧪 Test Plugin'
on:
push:
branches:
- master
- develop
paths-ignore:
- '**/*.md'
pull_request:
branches:
- master
- develop
paths-ignore:
- '**/*.md'
workflow_dispatch:
jobs:
test:
runs-on: linux/amd64
container:
image: gitea.tourolle.paris/dtourolle/jray-builder:latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
path: test-${{ github.run_id }}
- name: Cache NuGet packages
uses: actions/cache@v3
with:
path: ~/.nuget/packages
key: nuget-${{ hashFiles('**/Jellyfin.Plugin.JRay.csproj') }}
restore-keys: nuget-
- name: Restore dependencies
working-directory: test-${{ github.run_id }}
run: dotnet restore Jellyfin.Plugin.JRay.sln
- name: Build solution
working-directory: test-${{ github.run_id }}
run: dotnet build Jellyfin.Plugin.JRay.sln --configuration Debug --no-restore --no-self-contained /m:1
- name: Cleanup
if: always()
run: rm -rf test-${{ github.run_id }}

View File

@ -1,6 +0,0 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"github>jellyfin/.github//renovate-presets/default"
]
}

View File

@ -1,18 +0,0 @@
name: '🏗️ Build Plugin'
on:
push:
branches:
- master
paths-ignore:
- '**/*.md'
pull_request:
branches:
- master
paths-ignore:
- '**/*.md'
workflow_dispatch:
jobs:
call:
uses: jellyfin/jellyfin-meta-plugins/.github/workflows/build.yaml@master

View File

@ -1,20 +0,0 @@
name: '📝 Create/Update Release Draft & Release Bump PR'
on:
push:
branches:
- master
paths-ignore:
- build.yaml
workflow_dispatch:
repository_dispatch:
types:
- update-prep-command
jobs:
call:
uses: jellyfin/jellyfin-meta-plugins/.github/workflows/changelog.yaml@master
with:
repository-name: jellyfin/jellyfin-plugin-template
secrets:
token: ${{ secrets.GITHUB_TOKEN }}

View File

@ -1,13 +0,0 @@
# Allows for the definition of PR and Issue /commands
name: '📟 Slash Command Dispatcher'
on:
issue_comment:
types:
- created
jobs:
call:
uses: jellyfin/jellyfin-meta-plugins/.github/workflows/command-dispatch.yaml@master
secrets:
token: .

View File

@ -1,16 +0,0 @@
name: '🔀 PR Rebase Command'
on:
repository_dispatch:
types:
- rebase-command
jobs:
call:
uses: jellyfin/jellyfin-meta-plugins/.github/workflows/command-rebase.yaml@master
with:
rebase-head: ${{ github.event.client_payload.pull_request.head.label }}
repository-full-name: ${{ github.event.client_payload.github.payload.repository.full_name }}
comment-id: ${{ github.event.client_payload.github.payload.comment.id }}
secrets:
token: ${{ secrets.GITHUB_TOKEN }}

View File

@ -1,18 +0,0 @@
name: '🚀 Publish Plugin'
on:
release:
types:
- released
workflow_dispatch:
jobs:
call:
uses: jellyfin/jellyfin-meta-plugins/.github/workflows/publish.yaml@master
with:
version: ${{ github.event.release.tag_name }}
is-unstable: ${{ github.event.release.prerelease }}
secrets:
deploy-host: ${{ secrets.DEPLOY_HOST }}
deploy-user: ${{ secrets.DEPLOY_USER }}
deploy-key: ${{ secrets.DEPLOY_KEY }}

View File

@ -1,20 +0,0 @@
name: '🔬 Run CodeQL'
on:
push:
branches: [ master ]
paths-ignore:
- '**/*.md'
pull_request:
branches: [ master ]
paths-ignore:
- '**/*.md'
schedule:
- cron: '24 2 * * 4'
workflow_dispatch:
jobs:
call:
uses: jellyfin/jellyfin-meta-plugins/.github/workflows/scan-codeql.yaml@master
with:
repository-name: jellyfin/jellyfin-plugin-template

View File

@ -1,12 +0,0 @@
name: '🏷️ Sync labels'
on:
schedule:
- cron: '0 0 1 * *'
workflow_dispatch:
jobs:
call:
uses: jellyfin/jellyfin-meta-plugins/.github/workflows/sync-labels.yaml@master
secrets:
token: ${{ secrets.GITHUB_TOKEN }}

View File

@ -1,18 +0,0 @@
name: '🧪 Test Plugin'
on:
push:
branches:
- master
paths-ignore:
- '**/*.md'
pull_request:
branches:
- master
paths-ignore:
- '**/*.md'
workflow_dispatch:
jobs:
call:
uses: jellyfin/jellyfin-meta-plugins/.github/workflows/test.yaml@master

View File

@ -15,5 +15,5 @@
"jellyfinWindowsDataDir": "${env:LOCALAPPDATA}/jellyfin", "jellyfinWindowsDataDir": "${env:LOCALAPPDATA}/jellyfin",
"jellyfinLinuxDataDir": "$HOME/.local/share/jellyfin", "jellyfinLinuxDataDir": "$HOME/.local/share/jellyfin",
// The name of the plugin // The name of the plugin
"pluginName": "Jellyfin.Plugin.Template", "pluginName": "Jellyfin.Plugin.JRay",
} }

19
Dockerfile.builder Normal file
View File

@ -0,0 +1,19 @@
# JRay 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/jray-builder:latest .
# Push: docker push gitea.tourolle.paris/dtourolle/jray-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

View File

@ -1,6 +1,6 @@
Microsoft Visual Studio Solution File, Format Version 12.00 Microsoft Visual Studio Solution File, Format Version 12.00
# #
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Plugin.Template", "Jellyfin.Plugin.Template\Jellyfin.Plugin.Template.csproj", "{D921B930-CF91-406F-ACBC-08914DCD0D34}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Plugin.JRay", "Jellyfin.Plugin.JRay\Jellyfin.Plugin.JRay.csproj", "{D921B930-CF91-406F-ACBC-08914DCD0D34}"
EndProject EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution

View File

@ -0,0 +1,40 @@
using MediaBrowser.Model.Plugins;
namespace Jellyfin.Plugin.JRay.Configuration;
/// <summary>
/// Plugin configuration.
/// </summary>
public class PluginConfiguration : BasePluginConfiguration
{
/// <summary>
/// Initializes a new instance of the <see cref="PluginConfiguration"/> class.
/// </summary>
public PluginConfiguration()
{
TruthFileSuffix = ".jray.json";
CacheDurationMinutes = 60;
EnableOverlay = true;
}
/// <summary>
/// Gets or sets the filename suffix used to find a scene-actor-extraction
/// "truth" file for a media item. The plugin looks for a file named
/// "&lt;media file basename&gt;&lt;TruthFileSuffix&gt;" next to the media file,
/// e.g. "Movie.mkv" -&gt; "Movie.jray.json".
/// </summary>
public string TruthFileSuffix { get; set; }
/// <summary>
/// Gets or sets how long (in minutes) a loaded truth file is cached in
/// memory before being re-read from disk.
/// </summary>
public int CacheDurationMinutes { get; set; }
/// <summary>
/// Gets or sets a value indicating whether JRay should inject its
/// pause-overlay script into the web client's index.html. When disabled,
/// any previously injected script is removed.
/// </summary>
public bool EnableOverlay { get; set; }
}

View File

@ -0,0 +1,78 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>JRay</title>
</head>
<body>
<div id="JRayConfigPage" data-role="page" class="page type-interior pluginConfigurationPage" data-require="emby-input,emby-button,emby-select,emby-checkbox">
<div data-role="content">
<div class="content-primary">
<form id="JRayConfigForm">
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="TruthFileSuffix">Truth file suffix</label>
<input id="TruthFileSuffix" name="TruthFileSuffix" type="text" is="emby-input" />
<div class="fieldDescription">
Filename suffix used to find the scene-actor-extraction output for a media
file, e.g. "Movie.mkv" with suffix ".jray.json" looks for "Movie.jray.json"
in the same folder.
</div>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="CacheDurationMinutes">Cache duration (minutes)</label>
<input id="CacheDurationMinutes" name="CacheDurationMinutes" type="number" is="emby-input" min="0" />
<div class="fieldDescription">How long a loaded truth file is cached before being re-read from disk.</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label>
<input id="EnableOverlay" name="EnableOverlay" type="checkbox" is="emby-checkbox" />
<span>Enable pause overlay</span>
</label>
<div class="fieldDescription">
Injects a small script into the web client that shows on-screen actors
when playback is paused. Disabling this removes the injected script.
</div>
</div>
<div>
<button is="emby-button" type="submit" class="raised button-submit block emby-button">
<span>Save</span>
</button>
</div>
</form>
</div>
</div>
<script type="text/javascript">
var JRayConfig = {
pluginUniqueId: '96a22d9d-23fd-49bb-8970-5e153817d223'
};
document.querySelector('#JRayConfigPage')
.addEventListener('pageshow', function() {
Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(JRayConfig.pluginUniqueId).then(function (config) {
document.querySelector('#TruthFileSuffix').value = config.TruthFileSuffix;
document.querySelector('#CacheDurationMinutes').value = config.CacheDurationMinutes;
document.querySelector('#EnableOverlay').checked = config.EnableOverlay;
Dashboard.hideLoadingMsg();
});
});
document.querySelector('#JRayConfigForm')
.addEventListener('submit', function(e) {
Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(JRayConfig.pluginUniqueId).then(function (config) {
config.TruthFileSuffix = document.querySelector('#TruthFileSuffix').value;
config.CacheDurationMinutes = parseInt(document.querySelector('#CacheDurationMinutes').value, 10);
config.EnableOverlay = document.querySelector('#EnableOverlay').checked;
ApiClient.updatePluginConfiguration(JRayConfig.pluginUniqueId, config).then(function (result) {
Dashboard.processPluginConfigurationUpdateResult(result);
});
});
e.preventDefault();
return false;
});
</script>
</div>
</body>
</html>

View File

@ -0,0 +1,87 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.JRay.Models;
using Jellyfin.Plugin.JRay.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace Jellyfin.Plugin.JRay.Controllers;
/// <summary>
/// Exposes scene-actor-extraction "truth" data: which actors are on screen
/// at a given timestamp in a movie.
/// </summary>
[ApiController]
[Route("Plugins/JRay/Items/{itemId}")]
[Authorize]
public class ActorsController : ControllerBase
{
private readonly ITruthDataService _truthDataService;
/// <summary>
/// Initializes a new instance of the <see cref="ActorsController"/> class.
/// </summary>
/// <param name="truthDataService">The truth data service.</param>
public ActorsController(ITruthDataService truthDataService)
{
_truthDataService = truthDataService;
}
/// <summary>
/// Gets the full actor timeline (every actor with their on-screen scene windows) for a movie.
/// </summary>
/// <param name="itemId">The Jellyfin item id.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The truth file contents, or 404 if no truth data exists for this item.</returns>
[HttpGet("Timeline")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<TruthFile>> GetTimeline(Guid itemId, CancellationToken cancellationToken)
{
var truth = await _truthDataService.GetTruthAsync(itemId, cancellationToken).ConfigureAwait(false);
if (truth is null)
{
return NotFound();
}
return Ok(truth);
}
/// <summary>
/// Gets the JRay context (currently: on-screen actors) at a given timestamp.
/// This is an extensible envelope — future fields (locations, trivia, etc.)
/// will be added here without changing the route.
/// </summary>
/// <param name="itemId">The Jellyfin item id.</param>
/// <param name="t">The timestamp, in seconds from the start of the movie.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The JRay context at <paramref name="t"/>, or 404 if no truth data exists for this item.</returns>
[HttpGet("jray")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<JRayContext>> GetContext(Guid itemId, [FromQuery] double t, CancellationToken cancellationToken)
{
var truth = await _truthDataService.GetTruthAsync(itemId, cancellationToken).ConfigureAwait(false);
if (truth is null)
{
return NotFound();
}
var context = new JRayContext();
foreach (var actor in truth.Actors.Where(actor => actor.Scenes.Any(scene => scene.Length == 2 && scene[0] <= t && t <= scene[1])))
{
context.Actors.Add(new ActorAtTime
{
Name = actor.Name,
ImdbId = actor.ImdbId,
TmdbId = actor.TmdbId,
JellyfinId = actor.JellyfinId
});
}
return Ok(context);
}
}

View File

@ -0,0 +1,76 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.JRay.Models;
using Jellyfin.Plugin.JRay.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace Jellyfin.Plugin.JRay.Controllers;
/// <summary>
/// Accepts scene-actor-extraction "truth" data pushed directly by a remote
/// extraction worker, for servers that cannot run the extraction pipeline
/// locally. See SPEC.md.
/// </summary>
[ApiController]
[Route("Plugins/JRay/Items/{itemId}/Truth")]
[Authorize(Roles = "Administrator")]
public class TruthController : ControllerBase
{
private const int SupportedSchemaVersion = 1;
private readonly IManagedTruthStore _managedTruthStore;
private readonly ITruthDataService _truthDataService;
/// <summary>
/// Initializes a new instance of the <see cref="TruthController"/> class.
/// </summary>
/// <param name="managedTruthStore">The managed truth store.</param>
/// <param name="truthDataService">The truth data service.</param>
public TruthController(IManagedTruthStore managedTruthStore, ITruthDataService truthDataService)
{
_managedTruthStore = managedTruthStore;
_truthDataService = truthDataService;
}
/// <summary>
/// Uploads (creates or replaces) the truth data for an item.
/// </summary>
/// <param name="itemId">The Jellyfin item id.</param>
/// <param name="truth">The truth file contents.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>204 on success, or 400 if the schema version is unsupported.</returns>
[HttpPut]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> PutTruth(Guid itemId, [FromBody] TruthFile truth, CancellationToken cancellationToken)
{
if (truth.SchemaVersion != SupportedSchemaVersion)
{
return BadRequest($"Unsupported schema_version {truth.SchemaVersion}; expected {SupportedSchemaVersion}.");
}
await _managedTruthStore.SaveAsync(itemId, truth, cancellationToken).ConfigureAwait(false);
_truthDataService.Invalidate(itemId);
return NoContent();
}
/// <summary>
/// Removes any managed truth data for an item. The item falls back to
/// its sidecar truth file (if any) on subsequent reads.
/// </summary>
/// <param name="itemId">The Jellyfin item id.</param>
/// <returns>204, whether or not managed data existed.</returns>
[HttpDelete]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public IActionResult DeleteTruth(Guid itemId)
{
_managedTruthStore.Delete(itemId);
_truthDataService.Invalidate(itemId);
return NoContent();
}
}

View File

@ -0,0 +1,35 @@
using System;
using System.IO;
using System.Reflection;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace Jellyfin.Plugin.JRay.Controllers;
/// <summary>
/// Serves static client-side assets for JRay, e.g. the pause-overlay script
/// injected into the web client.
/// </summary>
[ApiController]
[Route("Plugins/JRay")]
[AllowAnonymous]
public class WebController : ControllerBase
{
private const string OverlayScriptResource = "Jellyfin.Plugin.JRay.Web.jray-overlay.js";
/// <summary>
/// Gets the pause-overlay client script.
/// </summary>
/// <returns>The JavaScript source for the pause overlay.</returns>
[HttpGet("ClientScript")]
[ProducesResponseType(StatusCodes.Status200OK)]
public IActionResult GetClientScript()
{
var assembly = Assembly.GetExecutingAssembly();
var stream = assembly.GetManifestResourceStream(OverlayScriptResource)
?? throw new InvalidOperationException($"Embedded resource '{OverlayScriptResource}' not found.");
return File(stream, "application/javascript");
}
}

View File

@ -2,7 +2,7 @@
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net9.0</TargetFramework>
<RootNamespace>Jellyfin.Plugin.Template</RootNamespace> <RootNamespace>Jellyfin.Plugin.JRay</RootNamespace>
<GenerateDocumentationFile>true</GenerateDocumentationFile> <GenerateDocumentationFile>true</GenerateDocumentationFile>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors> <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
@ -28,6 +28,8 @@
<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\jray-overlay.js" />
<EmbeddedResource Include="Web\jray-overlay.js" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@ -0,0 +1,33 @@
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.JRay.Models;
/// <summary>
/// An actor visible on screen at a queried timestamp.
/// </summary>
public class ActorAtTime
{
/// <summary>
/// Gets or sets the actor's display name.
/// </summary>
[JsonPropertyName("name")]
public string Name { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the actor's IMDB person id, or "" if unresolved.
/// </summary>
[JsonPropertyName("imdb_id")]
public string ImdbId { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the actor's TMDB person id, or "" if unresolved.
/// </summary>
[JsonPropertyName("tmdb_id")]
public string TmdbId { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the actor's Jellyfin Person item GUID, or "" if unresolved.
/// </summary>
[JsonPropertyName("jellyfin_id")]
public string JellyfinId { get; set; } = string.Empty;
}

View File

@ -0,0 +1,18 @@
using System.Collections.ObjectModel;
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.JRay.Models;
/// <summary>
/// Extensible "what's happening at time t" context for a media item.
/// Returned by the <c>jray?t=</c> endpoint; new fields (e.g. locations,
/// trivia) can be added here without changing the route.
/// </summary>
public class JRayContext
{
/// <summary>
/// Gets the list of actors visible on screen at the queried timestamp.
/// </summary>
[JsonPropertyName("actors")]
public Collection<ActorAtTime> Actors { get; } = new();
}

View File

@ -0,0 +1,41 @@
using System.Collections.ObjectModel;
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.JRay.Models;
/// <summary>
/// One actor entry in a <see cref="TruthFile"/>, with the time windows during
/// which they are visible on screen.
/// </summary>
public class TruthActor
{
/// <summary>
/// Gets or sets the actor's display name.
/// </summary>
[JsonPropertyName("name")]
public string Name { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the actor's IMDB person id (e.g. "nm0000158"), or "" if unresolved.
/// </summary>
[JsonPropertyName("imdb_id")]
public string ImdbId { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the actor's TMDB person id, or "" if unresolved.
/// </summary>
[JsonPropertyName("tmdb_id")]
public string TmdbId { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the actor's Jellyfin Person item GUID, or "" if unresolved.
/// </summary>
[JsonPropertyName("jellyfin_id")]
public string JellyfinId { get; set; } = string.Empty;
/// <summary>
/// Gets the list of [start_sec, end_sec] windows during which the actor is on screen.
/// </summary>
[JsonPropertyName("scenes")]
public Collection<double[]> Scenes { get; } = new();
}

View File

@ -0,0 +1,41 @@
using System.Collections.ObjectModel;
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.JRay.Models;
/// <summary>
/// Root object of a scene-actor-extraction "truth" file
/// (schema_version 1, minimal verbosity). See SPEC.md.
/// </summary>
public class TruthFile
{
/// <summary>
/// Gets or sets the schema version of this file.
/// </summary>
[JsonPropertyName("schema_version")]
public int SchemaVersion { get; set; }
/// <summary>
/// Gets or sets the source media path at extraction time (informational).
/// </summary>
[JsonPropertyName("movie")]
public string Movie { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the sampling rate (frames per second) used during extraction.
/// </summary>
[JsonPropertyName("sample_fps")]
public double SampleFps { get; set; }
/// <summary>
/// Gets or sets the gap (seconds) below which consecutive detections were merged into one scene.
/// </summary>
[JsonPropertyName("anneal_sec")]
public double AnnealSec { get; set; }
/// <summary>
/// Gets the list of actors detected in the film, each with their on-screen scene windows.
/// </summary>
[JsonPropertyName("actors")]
public Collection<TruthActor> Actors { get; } = new();
}

View File

@ -1,35 +1,44 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization; using System.Globalization;
using Jellyfin.Plugin.Template.Configuration; using Jellyfin.Plugin.JRay.Configuration;
using Jellyfin.Plugin.JRay.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.Template; namespace Jellyfin.Plugin.JRay;
/// <summary> /// <summary>
/// The main plugin. /// The main plugin.
/// </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.EnableOverlay, _logger);
ConfigurationChanged += (_, _) => WebClientPatchService.Apply(ApplicationPaths, Configuration.EnableOverlay, _logger);
} }
/// <inheritdoc /> /// <inheritdoc />
public override string Name => "Template"; public override string Name => "JRay";
/// <inheritdoc /> /// <inheritdoc />
public override Guid Id => Guid.Parse("eb5d7894-8eef-4b36-aa6f-5d124e828ce1"); public override Guid Id => Guid.Parse("96a22d9d-23fd-49bb-8970-5e153817d223");
/// <summary> /// <summary>
/// Gets the current plugin instance. /// Gets the current plugin instance.

View File

@ -0,0 +1,20 @@
using Jellyfin.Plugin.JRay.Services;
using Jellyfin.Plugin.JRay.Services.Interfaces;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Plugins;
using Microsoft.Extensions.DependencyInjection;
namespace Jellyfin.Plugin.JRay;
/// <summary>
/// Service registrator for dependency injection.
/// </summary>
public class ServiceRegistrator : IPluginServiceRegistrator
{
/// <inheritdoc />
public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost)
{
serviceCollection.AddSingleton<IManagedTruthStore, ManagedTruthStore>();
serviceCollection.AddSingleton<ITruthDataService, TruthDataService>();
}
}

View File

@ -0,0 +1,38 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.JRay.Models;
namespace Jellyfin.Plugin.JRay.Services.Interfaces;
/// <summary>
/// Stores and retrieves truth files that were pushed to JRay directly
/// (e.g. by a remote extraction worker), independent of any sidecar file
/// on the media filesystem.
/// </summary>
public interface IManagedTruthStore
{
/// <summary>
/// Loads the managed truth file for the given item, if one was uploaded.
/// </summary>
/// <param name="itemId">The Jellyfin library item id.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The parsed truth file, or null if none has been uploaded for this item.</returns>
Task<TruthFile?> LoadAsync(Guid itemId, CancellationToken cancellationToken);
/// <summary>
/// Saves (creates or replaces) the managed truth file for the given item.
/// </summary>
/// <param name="itemId">The Jellyfin library item id.</param>
/// <param name="truth">The truth file contents to persist.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task that completes when the file has been written.</returns>
Task SaveAsync(Guid itemId, TruthFile truth, CancellationToken cancellationToken);
/// <summary>
/// Deletes the managed truth file for the given item, if one exists.
/// </summary>
/// <param name="itemId">The Jellyfin library item id.</param>
/// <returns><c>true</c> if a file was deleted; <c>false</c> if none existed.</returns>
bool Delete(Guid itemId);
}

View File

@ -0,0 +1,27 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.JRay.Models;
namespace Jellyfin.Plugin.JRay.Services.Interfaces;
/// <summary>
/// Loads and caches scene-actor-extraction "truth" files for library items.
/// </summary>
public interface ITruthDataService
{
/// <summary>
/// Gets the truth file for the given library item, if one exists.
/// </summary>
/// <param name="itemId">The Jellyfin library item id.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The parsed truth file, or null if no truth file exists for this item.</returns>
Task<TruthFile?> GetTruthAsync(Guid itemId, CancellationToken cancellationToken);
/// <summary>
/// Removes any cached truth file for the given item, so the next
/// <see cref="GetTruthAsync"/> call re-reads from the managed store or sidecar file.
/// </summary>
/// <param name="itemId">The Jellyfin library item id.</param>
void Invalidate(Guid itemId);
}

View File

@ -0,0 +1,90 @@
using System;
using System.IO;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.JRay.Models;
using Jellyfin.Plugin.JRay.Services.Interfaces;
using MediaBrowser.Common.Configuration;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.JRay.Services;
/// <summary>
/// Stores truth files pushed directly to JRay (e.g. by a remote extraction
/// worker) under the plugin's configuration directory, keyed by item id.
/// </summary>
public sealed class ManagedTruthStore : IManagedTruthStore
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private readonly IApplicationPaths _applicationPaths;
private readonly ILogger<ManagedTruthStore> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="ManagedTruthStore"/> class.
/// </summary>
/// <param name="applicationPaths">The Jellyfin application paths.</param>
/// <param name="logger">The logger.</param>
public ManagedTruthStore(IApplicationPaths applicationPaths, ILogger<ManagedTruthStore> logger)
{
_applicationPaths = applicationPaths;
_logger = logger;
}
/// <inheritdoc />
public async Task<TruthFile?> LoadAsync(Guid itemId, CancellationToken cancellationToken)
{
var path = GetPath(itemId);
if (!File.Exists(path))
{
return null;
}
try
{
using var stream = File.OpenRead(path);
return await JsonSerializer.DeserializeAsync<TruthFile>(stream, JsonOptions, cancellationToken)
.ConfigureAwait(false);
}
catch (Exception ex) when (ex is IOException or JsonException)
{
_logger.LogWarning(ex, "JRay: failed to read managed truth file {Path}", path);
return null;
}
}
/// <inheritdoc />
public async Task SaveAsync(Guid itemId, TruthFile truth, CancellationToken cancellationToken)
{
var path = GetPath(itemId);
var directory = Path.GetDirectoryName(path) ?? throw new InvalidOperationException("Managed truth path has no directory.");
Directory.CreateDirectory(directory);
var tempPath = path + ".tmp";
using (var stream = File.Create(tempPath))
{
await JsonSerializer.SerializeAsync(stream, truth, JsonOptions, cancellationToken).ConfigureAwait(false);
}
File.Move(tempPath, path, overwrite: true);
}
/// <inheritdoc />
public bool Delete(Guid itemId)
{
var path = GetPath(itemId);
if (!File.Exists(path))
{
return false;
}
File.Delete(path);
return true;
}
private string GetPath(Guid itemId)
{
return Path.Combine(_applicationPaths.PluginConfigurationsPath, "JRay", "truth", itemId.ToString("D") + ".json");
}
}

View File

@ -0,0 +1,99 @@
using System;
using System.Collections.Concurrent;
using System.IO;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.JRay.Models;
using Jellyfin.Plugin.JRay.Services.Interfaces;
using MediaBrowser.Controller.Library;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.JRay.Services;
/// <summary>
/// Loads scene-actor-extraction truth files from disk, alongside each media
/// item's source file, and caches the parsed result for a configurable
/// duration.
/// </summary>
public sealed class TruthDataService : ITruthDataService
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private readonly ILibraryManager _libraryManager;
private readonly IManagedTruthStore _managedTruthStore;
private readonly ILogger<TruthDataService> _logger;
private readonly ConcurrentDictionary<Guid, CacheEntry> _cache = new();
/// <summary>
/// Initializes a new instance of the <see cref="TruthDataService"/> class.
/// </summary>
/// <param name="libraryManager">The Jellyfin library manager.</param>
/// <param name="managedTruthStore">The managed truth store.</param>
/// <param name="logger">The logger.</param>
public TruthDataService(ILibraryManager libraryManager, IManagedTruthStore managedTruthStore, ILogger<TruthDataService> logger)
{
_libraryManager = libraryManager;
_managedTruthStore = managedTruthStore;
_logger = logger;
}
/// <inheritdoc />
public async Task<TruthFile?> GetTruthAsync(Guid itemId, CancellationToken cancellationToken)
{
var cacheDuration = TimeSpan.FromMinutes(Math.Max(0, Plugin.Instance?.Configuration.CacheDurationMinutes ?? 0));
if (_cache.TryGetValue(itemId, out var cached) && DateTime.UtcNow - cached.LoadedAt < cacheDuration)
{
return cached.Truth;
}
var managed = await _managedTruthStore.LoadAsync(itemId, cancellationToken).ConfigureAwait(false);
if (managed is not null)
{
_cache[itemId] = new CacheEntry(managed, DateTime.UtcNow);
return managed;
}
var item = _libraryManager.GetItemById(itemId);
if (item is null || string.IsNullOrEmpty(item.Path))
{
_logger.LogDebug("JRay: item {ItemId} not found or has no path", itemId);
return null;
}
var suffix = Plugin.Instance?.Configuration.TruthFileSuffix ?? ".jray.json";
var truthPath = Path.Combine(
Path.GetDirectoryName(item.Path) ?? string.Empty,
Path.GetFileNameWithoutExtension(item.Path) + suffix);
if (!File.Exists(truthPath))
{
_logger.LogDebug("JRay: no truth file at {TruthPath}", truthPath);
_cache[itemId] = new CacheEntry(null, DateTime.UtcNow);
return null;
}
try
{
using var stream = File.OpenRead(truthPath);
var truth = await JsonSerializer.DeserializeAsync<TruthFile>(stream, JsonOptions, cancellationToken)
.ConfigureAwait(false);
_cache[itemId] = new CacheEntry(truth, DateTime.UtcNow);
return truth;
}
catch (Exception ex) when (ex is IOException or JsonException)
{
_logger.LogWarning(ex, "JRay: failed to read truth file {TruthPath}", truthPath);
return null;
}
}
/// <inheritdoc />
public void Invalidate(Guid itemId)
{
_cache.TryRemove(itemId, out _);
}
private sealed record CacheEntry(TruthFile? Truth, DateTime LoadedAt);
}

View File

@ -0,0 +1,72 @@
using System;
using System.IO;
using MediaBrowser.Common.Configuration;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.JRay.Services;
/// <summary>
/// Injects (or removes) a script tag in the web client's <c>index.html</c>
/// that loads JRay's pause-overlay script. This follows the pattern used by
/// other Jellyfin plugins (e.g. Intro Skipper) since there is no official
/// plugin hook for player-overlay UI.
/// </summary>
public static class WebClientPatchService
{
private const string Marker = "<!-- jray-overlay -->";
private const string ScriptTag = "<script defer src=\"/Plugins/JRay/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
/// JRay overlay script injected, matching <paramref name="enableOverlay"/>.
/// </summary>
/// <param name="applicationPaths">The Jellyfin application paths.</param>
/// <param name="enableOverlay">Whether the overlay script should be present.</param>
/// <param name="logger">The logger.</param>
public static void Apply(IApplicationPaths applicationPaths, bool enableOverlay, ILogger logger)
{
var indexPath = Path.Combine(applicationPaths.WebPath, "index.html");
try
{
if (!File.Exists(indexPath))
{
logger.LogDebug("JRay: web client index.html not found at {Path}", indexPath);
return;
}
var html = File.ReadAllText(indexPath);
var hasMarker = html.Contains(Marker, StringComparison.Ordinal);
if (enableOverlay && !hasMarker)
{
var patched = ReplaceLast(html, "</body>", Injected);
File.WriteAllText(indexPath, patched);
logger.LogInformation("JRay: injected pause-overlay script into {Path}", indexPath);
}
else if (!enableOverlay && 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("JRay: removed pause-overlay script from {Path}", indexPath);
}
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
logger.LogWarning(ex, "JRay: 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)..];
}
}

View File

@ -0,0 +1,109 @@
(function () {
'use strict';
var POLL_INTERVAL_MS = 1000;
var overlayEl = null;
function getItemIdFromHash() {
var match = window.location.hash.match(/[?&]id=([0-9a-fA-F-]+)/);
return match ? match[1] : null;
}
function removeOverlay() {
if (overlayEl && overlayEl.parentNode) {
overlayEl.parentNode.removeChild(overlayEl);
}
overlayEl = null;
}
function showOverlay(video, actors) {
removeOverlay();
if (!actors || actors.length === 0) {
return;
}
var container = video.parentElement;
if (!container) {
return;
}
overlayEl = document.createElement('div');
overlayEl.className = 'jrayOverlay';
overlayEl.style.position = 'absolute';
overlayEl.style.bottom = '10%';
overlayEl.style.left = '2%';
overlayEl.style.zIndex = '9999';
overlayEl.style.display = 'flex';
overlayEl.style.flexWrap = 'wrap';
overlayEl.style.gap = '12px';
overlayEl.style.pointerEvents = 'none';
actors.forEach(function (actor) {
var card = document.createElement('div');
card.style.background = 'rgba(0, 0, 0, 0.7)';
card.style.color = '#fff';
card.style.padding = '6px 12px';
card.style.borderRadius = '4px';
card.style.fontSize = '14px';
card.textContent = actor.name;
overlayEl.appendChild(card);
});
container.appendChild(overlayEl);
}
function fetchContext(itemId, t) {
if (!window.ApiClient) {
return;
}
var url = window.ApiClient.getUrl('Plugins/JRay/Items/' + itemId + '/jray', { t: t });
window.ApiClient.ajax({ url: url, type: 'GET', dataType: 'json' }).then(function (context) {
var video = document.querySelector('video');
if (!video || !video.paused) {
return;
}
showOverlay(video, context.actors);
}, function () {
// No truth data (404) or request error - fail silently, never break playback.
});
}
function onPause(event) {
var video = event.target;
var itemId = getItemIdFromHash();
if (!itemId) {
return;
}
fetchContext(itemId, video.currentTime);
}
function onPlay() {
removeOverlay();
}
function attach(video) {
if (video.dataset.jrayAttached) {
return;
}
video.dataset.jrayAttached = 'true';
video.addEventListener('pause', onPause);
video.addEventListener('play', onPlay);
video.addEventListener('playing', onPlay);
video.addEventListener('seeking', removeOverlay);
}
setInterval(function () {
var video = document.querySelector('video');
if (video) {
attach(video);
} else {
removeOverlay();
}
}, POLL_INTERVAL_MS);
})();

View File

@ -1,57 +0,0 @@
using MediaBrowser.Model.Plugins;
namespace Jellyfin.Plugin.Template.Configuration;
/// <summary>
/// The configuration options.
/// </summary>
public enum SomeOptions
{
/// <summary>
/// Option one.
/// </summary>
OneOption,
/// <summary>
/// Second option.
/// </summary>
AnotherOption
}
/// <summary>
/// Plugin configuration.
/// </summary>
public class PluginConfiguration : BasePluginConfiguration
{
/// <summary>
/// Initializes a new instance of the <see cref="PluginConfiguration"/> class.
/// </summary>
public PluginConfiguration()
{
// set default options here
Options = SomeOptions.AnotherOption;
TrueFalseSetting = true;
AnInteger = 2;
AString = "string";
}
/// <summary>
/// Gets or sets a value indicating whether some true or false setting is enabled..
/// </summary>
public bool TrueFalseSetting { get; set; }
/// <summary>
/// Gets or sets an integer setting.
/// </summary>
public int AnInteger { get; set; }
/// <summary>
/// Gets or sets a string setting.
/// </summary>
public string AString { get; set; }
/// <summary>
/// Gets or sets an enum option.
/// </summary>
public SomeOptions Options { get; set; }
}

View File

@ -1,79 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Template</title>
</head>
<body>
<div id="TemplateConfigPage" data-role="page" class="page type-interior pluginConfigurationPage" data-require="emby-input,emby-button,emby-select,emby-checkbox">
<div data-role="content">
<div class="content-primary">
<form id="TemplateConfigForm">
<div class="selectContainer">
<label class="selectLabel" for="Options">Several Options</label>
<select is="emby-select" id="Options" name="Options" class="emby-select-withcolor emby-select">
<option id="optOneOption" value="OneOption">One Option</option>
<option id="optAnotherOption" value="AnotherOption">Another Option</option>
</select>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="AnInteger">An Integer</label>
<input id="AnInteger" name="AnInteger" type="number" is="emby-input" min="0" />
<div class="fieldDescription">A Description</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<input id="TrueFalseSetting" name="TrueFalseCheckBox" type="checkbox" is="emby-checkbox" />
<span>A Checkbox</span>
</label>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="AString">A String</label>
<input id="AString" name="AString" type="text" is="emby-input" />
<div class="fieldDescription">Another Description</div>
</div>
<div>
<button is="emby-button" type="submit" class="raised button-submit block emby-button">
<span>Save</span>
</button>
</div>
</form>
</div>
</div>
<script type="text/javascript">
var TemplateConfig = {
pluginUniqueId: 'eb5d7894-8eef-4b36-aa6f-5d124e828ce1'
};
document.querySelector('#TemplateConfigPage')
.addEventListener('pageshow', function() {
Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(TemplateConfig.pluginUniqueId).then(function (config) {
document.querySelector('#Options').value = config.Options;
document.querySelector('#AnInteger').value = config.AnInteger;
document.querySelector('#TrueFalseSetting').checked = config.TrueFalseSetting;
document.querySelector('#AString').value = config.AString;
Dashboard.hideLoadingMsg();
});
});
document.querySelector('#TemplateConfigForm')
.addEventListener('submit', function(e) {
Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(TemplateConfig.pluginUniqueId).then(function (config) {
config.Options = document.querySelector('#Options').value;
config.AnInteger = document.querySelector('#AnInteger').value;
config.TrueFalseSetting = document.querySelector('#TrueFalseSetting').checked;
config.AString = document.querySelector('#AString').value;
ApiClient.updatePluginConfiguration(TemplateConfig.pluginUniqueId, config).then(function (result) {
Dashboard.processPluginConfigurationUpdateResult(result);
});
});
e.preventDefault();
return false;
});
</script>
</div>
</body>
</html>

108
SPEC.md Normal file
View File

@ -0,0 +1,108 @@
# JRay truth file format
JRay reads "truth" files produced offline by the
[scene-actor-extraction](https://github.com/dtourolle/scene-actor-extraction)
pipeline (`result_sink_node`, `Verbosity::minimal`, `schema_version: 1`).
## File location
For a media file `Movie.mkv`, the pipeline writes a sibling file
`Movie.jray.json` (suffix configurable in the plugin settings, default
`.jray.json`). The plugin resolves this path from the Jellyfin item's media
source path by stripping the extension and appending the suffix.
## JSON schema (schema_version 1, minimal verbosity)
```json
{
"schema_version": 1,
"movie": "/path/to/Movie.mkv",
"sample_fps": 1,
"anneal_sec": 2,
"actors": [
{
"name": "Tom Hanks",
"imdb_id": "nm0000158",
"tmdb_id": "31",
"jellyfin_id": "abc123-guid",
"scenes": [[12.0, 45.0], [102.5, 150.0]]
}
]
}
```
- `schema_version`: integer, bump on breaking changes. JRay should refuse (or
warn) on a version it doesn't understand.
- `movie`: absolute path to the source media file at extraction time (informational only).
- `sample_fps`: frames-per-second the pipeline sampled at.
- `anneal_sec`: gap (in seconds) below which consecutive detections of the
same actor were merged into a single scene window.
- `actors[]`: one entry per actor detected anywhere in the film.
- `name`: display name from the gallery.
- `imdb_id` / `tmdb_id` / `jellyfin_id`: identity keys, each `""` if not
resolved. JRay should prefer `jellyfin_id` (a Jellyfin Person item GUID)
when non-empty, and otherwise resolve `imdb_id`/`tmdb_id` against the
item's People `ProviderIds`.
- `scenes`: list of `[start_sec, end_sec]` windows (inclusive) during which
the actor is on screen.
## Querying "who's on screen at time t"
For a given timestamp `t` (seconds), an actor is visible if any of their
`scenes` windows satisfies `start <= t <= end`.
## API
### `GET /Plugins/JRay/Items/{itemId}/Timeline`
Returns the full truth file (schema above) for an item, or `404` if no truth
data exists (neither a managed upload nor a sidecar file).
### `GET /Plugins/JRay/Items/{itemId}/jray?t={seconds}`
Returns an extensible "context at time t" envelope, or `404` if no truth
data exists for the item:
```json
{
"actors": [
{ "name": "Tom Hanks", "imdb_id": "nm0000158", "tmdb_id": "31", "jellyfin_id": "abc123-guid" }
]
}
```
Future fields (e.g. `locations`, `trivia`) will be added to this object
without changing the route, so clients should ignore unknown keys.
### `PUT /Plugins/JRay/Items/{itemId}/Truth`
For servers that cannot run the extraction pipeline locally, a remote worker
may push truth data directly. Requires an administrator API key. Body is a
truth file (schema above). Returns `204` on success, or `400` if
`schema_version` is not `1`.
This "managed" truth data takes precedence over any sidecar
`Movie.jray.json` file for the same item, and is stored independently of the
media library filesystem.
### `DELETE /Plugins/JRay/Items/{itemId}/Truth`
Removes managed truth data for an item (idempotent, always returns `204`).
The item falls back to its sidecar truth file, if any, on subsequent reads.
Requires an administrator API key.
### `GET /Plugins/JRay/ClientScript`
Serves the pause-overlay script that JRay injects into the web client's
`index.html` (see below). Anonymous access.
## Web client pause overlay
Since Jellyfin has no plugin hook for player UI, JRay injects
`<script defer src="/Plugins/JRay/ClientScript"></script>` into the web
client's `index.html` on startup (idempotent, marked with
`<!-- jray-overlay -->`). The injected script listens for the video player's
pause event, calls `jray?t=` for the current item and timestamp, and renders
a small overlay listing on-screen actors. This can be disabled via the
plugin's "Enable pause overlay" setting, which also removes the injected
script.

View File

@ -1,16 +1,22 @@
--- ---
name: "Template" name: "JRay"
guid: "eb5d7894-8eef-4b36-aa6f-5d124e828ce1" guid: "96a22d9d-23fd-49bb-8970-5e153817d223"
version: "1.0.0.0" version: "0.0.0.0"
targetAbi: "10.9.0.0" targetAbi: "10.9.0.0"
framework: "net8.0" framework: "net9.0"
overview: "Short description about your plugin" overview: "Shows which actors are on screen at any point in a movie"
description: > description: >
This is a longer description that can span more than one JRay reads "truth" files produced offline by the scene-actor-extraction
line and include details about your plugin. pipeline (face detection + recognition) and exposes an API to query which
actors are visible on screen at a given timestamp in a movie, for building
an actor-overlay (Jellyfin "X-Ray") style feature.
category: "General" category: "General"
owner: "jellyfin" owner: "dtourolle"
artifacts: artifacts:
- "Jellyfin.Plugin.Template.dll" - "Jellyfin.Plugin.JRay.dll"
build_type: "dotnet"
dotnet_configuration: "Release"
dotnet_framework: "net9.0"
project: "Jellyfin.Plugin.JRay/Jellyfin.Plugin.JRay.csproj"
changelog: > changelog: >
changelog Initial scaffold

11
manifest.json Normal file
View File

@ -0,0 +1,11 @@
[
{
"guid": "96a22d9d-23fd-49bb-8970-5e153817d223",
"name": "JRay",
"description": "JRay reads \"truth\" files produced offline by the scene-actor-extraction pipeline (face detection + recognition) and exposes an API to query which actors are visible on screen at a given timestamp in a movie, for an actor-overlay (Jellyfin \"X-Ray\") style feature.",
"overview": "Shows which actors are on screen at any point in a movie",
"owner": "dtourolle",
"category": "General",
"versions": []
}
]