From a38122e993ce48b894054b49b22d290a24ccef3f Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Fri, 12 Jun 2026 18:16:47 +0200 Subject: [PATCH] first commit --- .gitea/workflows/build.yaml | 65 +++++++ .gitea/workflows/latest.yaml | 166 +++++++++++++++++ .gitea/workflows/release.yaml | 167 ++++++++++++++++++ .gitea/workflows/test.yaml | 47 +++++ .github/renovate.json | 6 - .github/workflows/build.yaml | 18 -- .github/workflows/changelog.yaml | 20 --- .github/workflows/command-dispatch.yaml | 13 -- .github/workflows/command-rebase.yaml | 16 -- .github/workflows/publish.yaml | 18 -- .github/workflows/scan-codeql.yaml | 20 --- .github/workflows/sync-labels.yaml | 12 -- .github/workflows/test.yaml | 18 -- .vscode/settings.json | 2 +- Dockerfile.builder | 19 ++ ...n.Template.sln => Jellyfin.Plugin.JRay.sln | 2 +- .../Configuration/PluginConfiguration.cs | 40 +++++ .../Configuration/configPage.html | 78 ++++++++ .../Controllers/ActorsController.cs | 87 +++++++++ .../Controllers/TruthController.cs | 76 ++++++++ .../Controllers/WebController.cs | 35 ++++ .../Jellyfin.Plugin.JRay.csproj | 4 +- Jellyfin.Plugin.JRay/Models/ActorAtTime.cs | 33 ++++ Jellyfin.Plugin.JRay/Models/JRayContext.cs | 18 ++ Jellyfin.Plugin.JRay/Models/TruthActor.cs | 41 +++++ Jellyfin.Plugin.JRay/Models/TruthFile.cs | 41 +++++ .../Plugin.cs | 19 +- Jellyfin.Plugin.JRay/ServiceRegistrator.cs | 20 +++ .../Services/Interfaces/IManagedTruthStore.cs | 38 ++++ .../Services/Interfaces/ITruthDataService.cs | 27 +++ .../Services/ManagedTruthStore.cs | 90 ++++++++++ .../Services/TruthDataService.cs | 99 +++++++++++ .../Services/WebClientPatchService.cs | 72 ++++++++ Jellyfin.Plugin.JRay/Web/jray-overlay.js | 109 ++++++++++++ .../Configuration/PluginConfiguration.cs | 57 ------ .../Configuration/configPage.html | 79 --------- SPEC.md | 108 +++++++++++ build.yaml | 26 +-- manifest.json | 11 ++ 39 files changed, 1522 insertions(+), 295 deletions(-) create mode 100644 .gitea/workflows/build.yaml create mode 100644 .gitea/workflows/latest.yaml create mode 100644 .gitea/workflows/release.yaml create mode 100644 .gitea/workflows/test.yaml delete mode 100644 .github/renovate.json delete mode 100644 .github/workflows/build.yaml delete mode 100644 .github/workflows/changelog.yaml delete mode 100644 .github/workflows/command-dispatch.yaml delete mode 100644 .github/workflows/command-rebase.yaml delete mode 100644 .github/workflows/publish.yaml delete mode 100644 .github/workflows/scan-codeql.yaml delete mode 100644 .github/workflows/sync-labels.yaml delete mode 100644 .github/workflows/test.yaml create mode 100644 Dockerfile.builder rename Jellyfin.Plugin.Template.sln => Jellyfin.Plugin.JRay.sln (92%) create mode 100644 Jellyfin.Plugin.JRay/Configuration/PluginConfiguration.cs create mode 100644 Jellyfin.Plugin.JRay/Configuration/configPage.html create mode 100644 Jellyfin.Plugin.JRay/Controllers/ActorsController.cs create mode 100644 Jellyfin.Plugin.JRay/Controllers/TruthController.cs create mode 100644 Jellyfin.Plugin.JRay/Controllers/WebController.cs rename Jellyfin.Plugin.Template/Jellyfin.Plugin.Template.csproj => Jellyfin.Plugin.JRay/Jellyfin.Plugin.JRay.csproj (88%) create mode 100644 Jellyfin.Plugin.JRay/Models/ActorAtTime.cs create mode 100644 Jellyfin.Plugin.JRay/Models/JRayContext.cs create mode 100644 Jellyfin.Plugin.JRay/Models/TruthActor.cs create mode 100644 Jellyfin.Plugin.JRay/Models/TruthFile.cs rename {Jellyfin.Plugin.Template => Jellyfin.Plugin.JRay}/Plugin.cs (67%) create mode 100644 Jellyfin.Plugin.JRay/ServiceRegistrator.cs create mode 100644 Jellyfin.Plugin.JRay/Services/Interfaces/IManagedTruthStore.cs create mode 100644 Jellyfin.Plugin.JRay/Services/Interfaces/ITruthDataService.cs create mode 100644 Jellyfin.Plugin.JRay/Services/ManagedTruthStore.cs create mode 100644 Jellyfin.Plugin.JRay/Services/TruthDataService.cs create mode 100644 Jellyfin.Plugin.JRay/Services/WebClientPatchService.cs create mode 100644 Jellyfin.Plugin.JRay/Web/jray-overlay.js delete mode 100644 Jellyfin.Plugin.Template/Configuration/PluginConfiguration.cs delete mode 100644 Jellyfin.Plugin.Template/Configuration/configPage.html create mode 100644 SPEC.md create mode 100644 manifest.json 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 + + +
+
+
+
+
+ + +
+ 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. +
+
+
+ + +
How long a loaded truth file is cached before being re-read from disk.
+
+
+ +
+ Injects a small script into the web client that shows on-screen actors + when playback is paused. Disabling this removes the injected script. +
+
+
+ +
+
+
+
+ +
+ + 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"; + + /// + /// Ensures the web client's index.html either has or does not have the + /// JRay overlay script injected, matching . + /// + /// The Jellyfin application paths. + /// Whether the overlay script should be present. + /// The logger. + 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, "", 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)..]; + } +} diff --git a/Jellyfin.Plugin.JRay/Web/jray-overlay.js b/Jellyfin.Plugin.JRay/Web/jray-overlay.js new file mode 100644 index 0000000..ba7b7f8 --- /dev/null +++ b/Jellyfin.Plugin.JRay/Web/jray-overlay.js @@ -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); +})(); diff --git a/Jellyfin.Plugin.Template/Configuration/PluginConfiguration.cs b/Jellyfin.Plugin.Template/Configuration/PluginConfiguration.cs deleted file mode 100644 index 564e6bf..0000000 --- a/Jellyfin.Plugin.Template/Configuration/PluginConfiguration.cs +++ /dev/null @@ -1,57 +0,0 @@ -using MediaBrowser.Model.Plugins; - -namespace Jellyfin.Plugin.Template.Configuration; - -/// -/// The configuration options. -/// -public enum SomeOptions -{ - /// - /// Option one. - /// - OneOption, - - /// - /// Second option. - /// - AnotherOption -} - -/// -/// Plugin configuration. -/// -public class PluginConfiguration : BasePluginConfiguration -{ - /// - /// Initializes a new instance of the class. - /// - public PluginConfiguration() - { - // set default options here - Options = SomeOptions.AnotherOption; - TrueFalseSetting = true; - AnInteger = 2; - AString = "string"; - } - - /// - /// Gets or sets a value indicating whether some true or false setting is enabled.. - /// - public bool TrueFalseSetting { get; set; } - - /// - /// Gets or sets an integer setting. - /// - public int AnInteger { get; set; } - - /// - /// Gets or sets a string setting. - /// - public string AString { get; set; } - - /// - /// Gets or sets an enum option. - /// - public SomeOptions Options { get; set; } -} diff --git a/Jellyfin.Plugin.Template/Configuration/configPage.html b/Jellyfin.Plugin.Template/Configuration/configPage.html deleted file mode 100644 index 23f024e..0000000 --- a/Jellyfin.Plugin.Template/Configuration/configPage.html +++ /dev/null @@ -1,79 +0,0 @@ - - - - - Template - - -
-
-
-
-
- - -
-
- - -
A Description
-
-
- -
-
- - -
Another Description
-
-
- -
-
-
-
- -
- - diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..d7c6294 --- /dev/null +++ b/SPEC.md @@ -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 +`` into the web +client's `index.html` on startup (idempotent, marked with +``). 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. diff --git a/build.yaml b/build.yaml index 168daa7..18652e8 100644 --- a/build.yaml +++ b/build.yaml @@ -1,16 +1,22 @@ --- -name: "Template" -guid: "eb5d7894-8eef-4b36-aa6f-5d124e828ce1" -version: "1.0.0.0" +name: "JRay" +guid: "96a22d9d-23fd-49bb-8970-5e153817d223" +version: "0.0.0.0" targetAbi: "10.9.0.0" -framework: "net8.0" -overview: "Short description about your plugin" +framework: "net9.0" +overview: "Shows which actors are on screen at any point in a movie" description: > - This is a longer description that can span more than one - line and include details about your plugin. + 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 building + an actor-overlay (Jellyfin "X-Ray") style feature. category: "General" -owner: "jellyfin" +owner: "dtourolle" 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 + Initial scaffold diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..92bc1a7 --- /dev/null +++ b/manifest.json @@ -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": [] + } +]