diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml
new file mode 100644
index 0000000..f13f685
--- /dev/null
+++ b/.gitea/workflows/build.yaml
@@ -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 }}
diff --git a/.gitea/workflows/latest.yaml b/.gitea/workflows/latest.yaml
new file mode 100644
index 0000000..91e4809
--- /dev/null
+++ b/.gitea/workflows/latest.yaml
@@ -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 < 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 }}
diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml
new file mode 100644
index 0000000..f145b75
--- /dev/null
+++ b/.gitea/workflows/release.yaml
@@ -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 < 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 }}
diff --git a/.gitea/workflows/test.yaml b/.gitea/workflows/test.yaml
new file mode 100644
index 0000000..c74fcb0
--- /dev/null
+++ b/.gitea/workflows/test.yaml
@@ -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 }}
diff --git a/.github/renovate.json b/.github/renovate.json
deleted file mode 100644
index 2f561e6..0000000
--- a/.github/renovate.json
+++ /dev/null
@@ -1,6 +0,0 @@
-{
- "$schema": "https://docs.renovatebot.com/renovate-schema.json",
- "extends": [
- "github>jellyfin/.github//renovate-presets/default"
- ]
-}
\ No newline at end of file
diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml
deleted file mode 100644
index f290747..0000000
--- a/.github/workflows/build.yaml
+++ /dev/null
@@ -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
diff --git a/.github/workflows/changelog.yaml b/.github/workflows/changelog.yaml
deleted file mode 100644
index 5b3c3be..0000000
--- a/.github/workflows/changelog.yaml
+++ /dev/null
@@ -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 }}
diff --git a/.github/workflows/command-dispatch.yaml b/.github/workflows/command-dispatch.yaml
deleted file mode 100644
index 1b5e4ee..0000000
--- a/.github/workflows/command-dispatch.yaml
+++ /dev/null
@@ -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: .
diff --git a/.github/workflows/command-rebase.yaml b/.github/workflows/command-rebase.yaml
deleted file mode 100644
index 7847e20..0000000
--- a/.github/workflows/command-rebase.yaml
+++ /dev/null
@@ -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 }}
diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml
deleted file mode 100644
index 80483cf..0000000
--- a/.github/workflows/publish.yaml
+++ /dev/null
@@ -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 }}
diff --git a/.github/workflows/scan-codeql.yaml b/.github/workflows/scan-codeql.yaml
deleted file mode 100644
index ca8b0b0..0000000
--- a/.github/workflows/scan-codeql.yaml
+++ /dev/null
@@ -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
diff --git a/.github/workflows/sync-labels.yaml b/.github/workflows/sync-labels.yaml
deleted file mode 100644
index 5e06ae4..0000000
--- a/.github/workflows/sync-labels.yaml
+++ /dev/null
@@ -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 }}
diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml
deleted file mode 100644
index d90b14d..0000000
--- a/.github/workflows/test.yaml
+++ /dev/null
@@ -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
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 7fa6075..d4e00ad 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -15,5 +15,5 @@
"jellyfinWindowsDataDir": "${env:LOCALAPPDATA}/jellyfin",
"jellyfinLinuxDataDir": "$HOME/.local/share/jellyfin",
// The name of the plugin
- "pluginName": "Jellyfin.Plugin.Template",
+ "pluginName": "Jellyfin.Plugin.JRay",
}
\ No newline at end of file
diff --git a/Dockerfile.builder b/Dockerfile.builder
new file mode 100644
index 0000000..bd51cf0
--- /dev/null
+++ b/Dockerfile.builder
@@ -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
diff --git a/Jellyfin.Plugin.Template.sln b/Jellyfin.Plugin.JRay.sln
similarity index 92%
rename from Jellyfin.Plugin.Template.sln
rename to Jellyfin.Plugin.JRay.sln
index 7c9b9ee..f24635f 100644
--- a/Jellyfin.Plugin.Template.sln
+++ b/Jellyfin.Plugin.JRay.sln
@@ -1,6 +1,6 @@
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
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
diff --git a/Jellyfin.Plugin.JRay/Configuration/PluginConfiguration.cs b/Jellyfin.Plugin.JRay/Configuration/PluginConfiguration.cs
new file mode 100644
index 0000000..1b424dc
--- /dev/null
+++ b/Jellyfin.Plugin.JRay/Configuration/PluginConfiguration.cs
@@ -0,0 +1,40 @@
+using MediaBrowser.Model.Plugins;
+
+namespace Jellyfin.Plugin.JRay.Configuration;
+
+///
+/// Plugin configuration.
+///
+public class PluginConfiguration : BasePluginConfiguration
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public PluginConfiguration()
+ {
+ TruthFileSuffix = ".jray.json";
+ CacheDurationMinutes = 60;
+ EnableOverlay = true;
+ }
+
+ ///
+ /// 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
+ /// "<media file basename><TruthFileSuffix>" next to the media file,
+ /// e.g. "Movie.mkv" -> "Movie.jray.json".
+ ///
+ public string TruthFileSuffix { get; set; }
+
+ ///
+ /// Gets or sets how long (in minutes) a loaded truth file is cached in
+ /// memory before being re-read from disk.
+ ///
+ public int CacheDurationMinutes { get; set; }
+
+ ///
+ /// 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.
+ ///
+ public bool EnableOverlay { get; set; }
+}
diff --git a/Jellyfin.Plugin.JRay/Configuration/configPage.html b/Jellyfin.Plugin.JRay/Configuration/configPage.html
new file mode 100644
index 0000000..c471ce5
--- /dev/null
+++ b/Jellyfin.Plugin.JRay/Configuration/configPage.html
@@ -0,0 +1,78 @@
+
+
+
+
+ JRay
+
+
+
+
+
diff --git a/Jellyfin.Plugin.JRay/Controllers/ActorsController.cs b/Jellyfin.Plugin.JRay/Controllers/ActorsController.cs
new file mode 100644
index 0000000..a9befd6
--- /dev/null
+++ b/Jellyfin.Plugin.JRay/Controllers/ActorsController.cs
@@ -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;
+
+///
+/// Exposes scene-actor-extraction "truth" data: which actors are on screen
+/// at a given timestamp in a movie.
+///
+[ApiController]
+[Route("Plugins/JRay/Items/{itemId}")]
+[Authorize]
+public class ActorsController : ControllerBase
+{
+ private readonly ITruthDataService _truthDataService;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The truth data service.
+ public ActorsController(ITruthDataService truthDataService)
+ {
+ _truthDataService = truthDataService;
+ }
+
+ ///
+ /// Gets the full actor timeline (every actor with their on-screen scene windows) for a movie.
+ ///
+ /// The Jellyfin item id.
+ /// Cancellation token.
+ /// The truth file contents, or 404 if no truth data exists for this item.
+ [HttpGet("Timeline")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task> GetTimeline(Guid itemId, CancellationToken cancellationToken)
+ {
+ var truth = await _truthDataService.GetTruthAsync(itemId, cancellationToken).ConfigureAwait(false);
+ if (truth is null)
+ {
+ return NotFound();
+ }
+
+ return Ok(truth);
+ }
+
+ ///
+ /// 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.
+ ///
+ /// The Jellyfin item id.
+ /// The timestamp, in seconds from the start of the movie.
+ /// Cancellation token.
+ /// The JRay context at , or 404 if no truth data exists for this item.
+ [HttpGet("jray")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task> 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);
+ }
+}
diff --git a/Jellyfin.Plugin.JRay/Controllers/TruthController.cs b/Jellyfin.Plugin.JRay/Controllers/TruthController.cs
new file mode 100644
index 0000000..0e589ed
--- /dev/null
+++ b/Jellyfin.Plugin.JRay/Controllers/TruthController.cs
@@ -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;
+
+///
+/// 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.
+///
+[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;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The managed truth store.
+ /// The truth data service.
+ public TruthController(IManagedTruthStore managedTruthStore, ITruthDataService truthDataService)
+ {
+ _managedTruthStore = managedTruthStore;
+ _truthDataService = truthDataService;
+ }
+
+ ///
+ /// Uploads (creates or replaces) the truth data for an item.
+ ///
+ /// The Jellyfin item id.
+ /// The truth file contents.
+ /// Cancellation token.
+ /// 204 on success, or 400 if the schema version is unsupported.
+ [HttpPut]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ public async Task 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();
+ }
+
+ ///
+ /// Removes any managed truth data for an item. The item falls back to
+ /// its sidecar truth file (if any) on subsequent reads.
+ ///
+ /// The Jellyfin item id.
+ /// 204, whether or not managed data existed.
+ [HttpDelete]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public IActionResult DeleteTruth(Guid itemId)
+ {
+ _managedTruthStore.Delete(itemId);
+ _truthDataService.Invalidate(itemId);
+
+ return NoContent();
+ }
+}
diff --git a/Jellyfin.Plugin.JRay/Controllers/WebController.cs b/Jellyfin.Plugin.JRay/Controllers/WebController.cs
new file mode 100644
index 0000000..6fe874f
--- /dev/null
+++ b/Jellyfin.Plugin.JRay/Controllers/WebController.cs
@@ -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;
+
+///
+/// Serves static client-side assets for JRay, e.g. the pause-overlay script
+/// injected into the web client.
+///
+[ApiController]
+[Route("Plugins/JRay")]
+[AllowAnonymous]
+public class WebController : ControllerBase
+{
+ private const string OverlayScriptResource = "Jellyfin.Plugin.JRay.Web.jray-overlay.js";
+
+ ///
+ /// Gets the pause-overlay client script.
+ ///
+ /// The JavaScript source for the pause overlay.
+ [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");
+ }
+}
diff --git a/Jellyfin.Plugin.Template/Jellyfin.Plugin.Template.csproj b/Jellyfin.Plugin.JRay/Jellyfin.Plugin.JRay.csproj
similarity index 88%
rename from Jellyfin.Plugin.Template/Jellyfin.Plugin.Template.csproj
rename to Jellyfin.Plugin.JRay/Jellyfin.Plugin.JRay.csproj
index fd1cdb1..ec6cbbc 100644
--- a/Jellyfin.Plugin.Template/Jellyfin.Plugin.Template.csproj
+++ b/Jellyfin.Plugin.JRay/Jellyfin.Plugin.JRay.csproj
@@ -2,7 +2,7 @@
net9.0
- Jellyfin.Plugin.Template
+ Jellyfin.Plugin.JRay
true
true
enable
@@ -28,6 +28,8 @@
+
+
diff --git a/Jellyfin.Plugin.JRay/Models/ActorAtTime.cs b/Jellyfin.Plugin.JRay/Models/ActorAtTime.cs
new file mode 100644
index 0000000..c017442
--- /dev/null
+++ b/Jellyfin.Plugin.JRay/Models/ActorAtTime.cs
@@ -0,0 +1,33 @@
+using System.Text.Json.Serialization;
+
+namespace Jellyfin.Plugin.JRay.Models;
+
+///
+/// An actor visible on screen at a queried timestamp.
+///
+public class ActorAtTime
+{
+ ///
+ /// Gets or sets the actor's display name.
+ ///
+ [JsonPropertyName("name")]
+ public string Name { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the actor's IMDB person id, or "" if unresolved.
+ ///
+ [JsonPropertyName("imdb_id")]
+ public string ImdbId { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the actor's TMDB person id, or "" if unresolved.
+ ///
+ [JsonPropertyName("tmdb_id")]
+ public string TmdbId { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the actor's Jellyfin Person item GUID, or "" if unresolved.
+ ///
+ [JsonPropertyName("jellyfin_id")]
+ public string JellyfinId { get; set; } = string.Empty;
+}
diff --git a/Jellyfin.Plugin.JRay/Models/JRayContext.cs b/Jellyfin.Plugin.JRay/Models/JRayContext.cs
new file mode 100644
index 0000000..ff198f1
--- /dev/null
+++ b/Jellyfin.Plugin.JRay/Models/JRayContext.cs
@@ -0,0 +1,18 @@
+using System.Collections.ObjectModel;
+using System.Text.Json.Serialization;
+
+namespace Jellyfin.Plugin.JRay.Models;
+
+///
+/// Extensible "what's happening at time t" context for a media item.
+/// Returned by the jray?t= endpoint; new fields (e.g. locations,
+/// trivia) can be added here without changing the route.
+///
+public class JRayContext
+{
+ ///
+ /// Gets the list of actors visible on screen at the queried timestamp.
+ ///
+ [JsonPropertyName("actors")]
+ public Collection Actors { get; } = new();
+}
diff --git a/Jellyfin.Plugin.JRay/Models/TruthActor.cs b/Jellyfin.Plugin.JRay/Models/TruthActor.cs
new file mode 100644
index 0000000..2732a65
--- /dev/null
+++ b/Jellyfin.Plugin.JRay/Models/TruthActor.cs
@@ -0,0 +1,41 @@
+using System.Collections.ObjectModel;
+using System.Text.Json.Serialization;
+
+namespace Jellyfin.Plugin.JRay.Models;
+
+///
+/// One actor entry in a , with the time windows during
+/// which they are visible on screen.
+///
+public class TruthActor
+{
+ ///
+ /// Gets or sets the actor's display name.
+ ///
+ [JsonPropertyName("name")]
+ public string Name { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the actor's IMDB person id (e.g. "nm0000158"), or "" if unresolved.
+ ///
+ [JsonPropertyName("imdb_id")]
+ public string ImdbId { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the actor's TMDB person id, or "" if unresolved.
+ ///
+ [JsonPropertyName("tmdb_id")]
+ public string TmdbId { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the actor's Jellyfin Person item GUID, or "" if unresolved.
+ ///
+ [JsonPropertyName("jellyfin_id")]
+ public string JellyfinId { get; set; } = string.Empty;
+
+ ///
+ /// Gets the list of [start_sec, end_sec] windows during which the actor is on screen.
+ ///
+ [JsonPropertyName("scenes")]
+ public Collection Scenes { get; } = new();
+}
diff --git a/Jellyfin.Plugin.JRay/Models/TruthFile.cs b/Jellyfin.Plugin.JRay/Models/TruthFile.cs
new file mode 100644
index 0000000..5d8c79e
--- /dev/null
+++ b/Jellyfin.Plugin.JRay/Models/TruthFile.cs
@@ -0,0 +1,41 @@
+using System.Collections.ObjectModel;
+using System.Text.Json.Serialization;
+
+namespace Jellyfin.Plugin.JRay.Models;
+
+///
+/// Root object of a scene-actor-extraction "truth" file
+/// (schema_version 1, minimal verbosity). See SPEC.md.
+///
+public class TruthFile
+{
+ ///
+ /// Gets or sets the schema version of this file.
+ ///
+ [JsonPropertyName("schema_version")]
+ public int SchemaVersion { get; set; }
+
+ ///
+ /// Gets or sets the source media path at extraction time (informational).
+ ///
+ [JsonPropertyName("movie")]
+ public string Movie { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the sampling rate (frames per second) used during extraction.
+ ///
+ [JsonPropertyName("sample_fps")]
+ public double SampleFps { get; set; }
+
+ ///
+ /// Gets or sets the gap (seconds) below which consecutive detections were merged into one scene.
+ ///
+ [JsonPropertyName("anneal_sec")]
+ public double AnnealSec { get; set; }
+
+ ///
+ /// Gets the list of actors detected in the film, each with their on-screen scene windows.
+ ///
+ [JsonPropertyName("actors")]
+ public Collection Actors { get; } = new();
+}
diff --git a/Jellyfin.Plugin.Template/Plugin.cs b/Jellyfin.Plugin.JRay/Plugin.cs
similarity index 67%
rename from Jellyfin.Plugin.Template/Plugin.cs
rename to Jellyfin.Plugin.JRay/Plugin.cs
index 445c2bc..10e92be 100644
--- a/Jellyfin.Plugin.Template/Plugin.cs
+++ b/Jellyfin.Plugin.JRay/Plugin.cs
@@ -1,35 +1,44 @@
using System;
using System.Collections.Generic;
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.Plugins;
using MediaBrowser.Model.Plugins;
using MediaBrowser.Model.Serialization;
+using Microsoft.Extensions.Logging;
-namespace Jellyfin.Plugin.Template;
+namespace Jellyfin.Plugin.JRay;
///
/// The main plugin.
///
public class Plugin : BasePlugin, IHasWebPages
{
+ private readonly ILogger _logger;
+
///
/// Initializes a new instance of the class.
///
/// Instance of the interface.
/// Instance of the interface.
- public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer)
+ /// The logger.
+ public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer, ILogger logger)
: base(applicationPaths, xmlSerializer)
{
Instance = this;
+ _logger = logger;
+
+ WebClientPatchService.Apply(ApplicationPaths, Configuration.EnableOverlay, _logger);
+ ConfigurationChanged += (_, _) => WebClientPatchService.Apply(ApplicationPaths, Configuration.EnableOverlay, _logger);
}
///
- public override string Name => "Template";
+ public override string Name => "JRay";
///
- public override Guid Id => Guid.Parse("eb5d7894-8eef-4b36-aa6f-5d124e828ce1");
+ public override Guid Id => Guid.Parse("96a22d9d-23fd-49bb-8970-5e153817d223");
///
/// Gets the current plugin instance.
diff --git a/Jellyfin.Plugin.JRay/ServiceRegistrator.cs b/Jellyfin.Plugin.JRay/ServiceRegistrator.cs
new file mode 100644
index 0000000..191c104
--- /dev/null
+++ b/Jellyfin.Plugin.JRay/ServiceRegistrator.cs
@@ -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;
+
+///
+/// Service registrator for dependency injection.
+///
+public class ServiceRegistrator : IPluginServiceRegistrator
+{
+ ///
+ public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost)
+ {
+ serviceCollection.AddSingleton();
+ serviceCollection.AddSingleton();
+ }
+}
diff --git a/Jellyfin.Plugin.JRay/Services/Interfaces/IManagedTruthStore.cs b/Jellyfin.Plugin.JRay/Services/Interfaces/IManagedTruthStore.cs
new file mode 100644
index 0000000..1bd303d
--- /dev/null
+++ b/Jellyfin.Plugin.JRay/Services/Interfaces/IManagedTruthStore.cs
@@ -0,0 +1,38 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Plugin.JRay.Models;
+
+namespace Jellyfin.Plugin.JRay.Services.Interfaces;
+
+///
+/// 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.
+///
+public interface IManagedTruthStore
+{
+ ///
+ /// Loads the managed truth file for the given item, if one was uploaded.
+ ///
+ /// The Jellyfin library item id.
+ /// Cancellation token.
+ /// The parsed truth file, or null if none has been uploaded for this item.
+ Task LoadAsync(Guid itemId, CancellationToken cancellationToken);
+
+ ///
+ /// Saves (creates or replaces) the managed truth file for the given item.
+ ///
+ /// The Jellyfin library item id.
+ /// The truth file contents to persist.
+ /// Cancellation token.
+ /// A task that completes when the file has been written.
+ Task SaveAsync(Guid itemId, TruthFile truth, CancellationToken cancellationToken);
+
+ ///
+ /// Deletes the managed truth file for the given item, if one exists.
+ ///
+ /// The Jellyfin library item id.
+ /// true if a file was deleted; false if none existed.
+ bool Delete(Guid itemId);
+}
diff --git a/Jellyfin.Plugin.JRay/Services/Interfaces/ITruthDataService.cs b/Jellyfin.Plugin.JRay/Services/Interfaces/ITruthDataService.cs
new file mode 100644
index 0000000..fc27433
--- /dev/null
+++ b/Jellyfin.Plugin.JRay/Services/Interfaces/ITruthDataService.cs
@@ -0,0 +1,27 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Plugin.JRay.Models;
+
+namespace Jellyfin.Plugin.JRay.Services.Interfaces;
+
+///
+/// Loads and caches scene-actor-extraction "truth" files for library items.
+///
+public interface ITruthDataService
+{
+ ///
+ /// Gets the truth file for the given library item, if one exists.
+ ///
+ /// The Jellyfin library item id.
+ /// Cancellation token.
+ /// The parsed truth file, or null if no truth file exists for this item.
+ Task GetTruthAsync(Guid itemId, CancellationToken cancellationToken);
+
+ ///
+ /// Removes any cached truth file for the given item, so the next
+ /// call re-reads from the managed store or sidecar file.
+ ///
+ /// The Jellyfin library item id.
+ void Invalidate(Guid itemId);
+}
diff --git a/Jellyfin.Plugin.JRay/Services/ManagedTruthStore.cs b/Jellyfin.Plugin.JRay/Services/ManagedTruthStore.cs
new file mode 100644
index 0000000..3351580
--- /dev/null
+++ b/Jellyfin.Plugin.JRay/Services/ManagedTruthStore.cs
@@ -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;
+
+///
+/// Stores truth files pushed directly to JRay (e.g. by a remote extraction
+/// worker) under the plugin's configuration directory, keyed by item id.
+///
+public sealed class ManagedTruthStore : IManagedTruthStore
+{
+ private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
+
+ private readonly IApplicationPaths _applicationPaths;
+ private readonly ILogger _logger;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The Jellyfin application paths.
+ /// The logger.
+ public ManagedTruthStore(IApplicationPaths applicationPaths, ILogger logger)
+ {
+ _applicationPaths = applicationPaths;
+ _logger = logger;
+ }
+
+ ///
+ public async Task 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(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;
+ }
+ }
+
+ ///
+ 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);
+ }
+
+ ///
+ 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");
+ }
+}
diff --git a/Jellyfin.Plugin.JRay/Services/TruthDataService.cs b/Jellyfin.Plugin.JRay/Services/TruthDataService.cs
new file mode 100644
index 0000000..bfe40c5
--- /dev/null
+++ b/Jellyfin.Plugin.JRay/Services/TruthDataService.cs
@@ -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;
+
+///
+/// Loads scene-actor-extraction truth files from disk, alongside each media
+/// item's source file, and caches the parsed result for a configurable
+/// duration.
+///
+public sealed class TruthDataService : ITruthDataService
+{
+ private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
+
+ private readonly ILibraryManager _libraryManager;
+ private readonly IManagedTruthStore _managedTruthStore;
+ private readonly ILogger _logger;
+ private readonly ConcurrentDictionary _cache = new();
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The Jellyfin library manager.
+ /// The managed truth store.
+ /// The logger.
+ public TruthDataService(ILibraryManager libraryManager, IManagedTruthStore managedTruthStore, ILogger logger)
+ {
+ _libraryManager = libraryManager;
+ _managedTruthStore = managedTruthStore;
+ _logger = logger;
+ }
+
+ ///
+ public async Task 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(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;
+ }
+ }
+
+ ///
+ public void Invalidate(Guid itemId)
+ {
+ _cache.TryRemove(itemId, out _);
+ }
+
+ private sealed record CacheEntry(TruthFile? Truth, DateTime LoadedAt);
+}
diff --git a/Jellyfin.Plugin.JRay/Services/WebClientPatchService.cs b/Jellyfin.Plugin.JRay/Services/WebClientPatchService.cs
new file mode 100644
index 0000000..37d8327
--- /dev/null
+++ b/Jellyfin.Plugin.JRay/Services/WebClientPatchService.cs
@@ -0,0 +1,72 @@
+using System;
+using System.IO;
+using MediaBrowser.Common.Configuration;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Plugin.JRay.Services;
+
+///
+/// Injects (or removes) a script tag in the web client's index.html
+/// 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.
+///
+public static class WebClientPatchService
+{
+ private const string Marker = "";
+ private const string ScriptTag = "";
+ private const string Injected = ScriptTag + Marker + "\n
-
-