first commit
This commit is contained in:
parent
7a9dbdafcc
commit
a38122e993
65
.gitea/workflows/build.yaml
Normal file
65
.gitea/workflows/build.yaml
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
name: '🏗️ Build Plugin'
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
paths-ignore:
|
||||||
|
- '**/*.md'
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
paths-ignore:
|
||||||
|
- '**/*.md'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: linux/amd64
|
||||||
|
container:
|
||||||
|
image: gitea.tourolle.paris/dtourolle/jray-builder:latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
path: build-${{ github.run_id }}
|
||||||
|
|
||||||
|
- name: Cache NuGet packages
|
||||||
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: ~/.nuget/packages
|
||||||
|
key: nuget-${{ hashFiles('**/Jellyfin.Plugin.JRay.csproj') }}
|
||||||
|
restore-keys: nuget-
|
||||||
|
|
||||||
|
- name: Restore dependencies
|
||||||
|
working-directory: build-${{ github.run_id }}
|
||||||
|
run: dotnet restore Jellyfin.Plugin.JRay.sln
|
||||||
|
|
||||||
|
- name: Build solution
|
||||||
|
working-directory: build-${{ github.run_id }}
|
||||||
|
run: dotnet build Jellyfin.Plugin.JRay.sln --configuration Release --no-restore --no-self-contained /m:1
|
||||||
|
|
||||||
|
- name: Build Jellyfin Plugin
|
||||||
|
id: jprm
|
||||||
|
working-directory: build-${{ github.run_id }}
|
||||||
|
run: |
|
||||||
|
mkdir -p artifacts
|
||||||
|
jprm --verbosity=debug plugin build .
|
||||||
|
ARTIFACT=$(find . -name "*.zip" -type f -print -quit | sed 's|^\./||')
|
||||||
|
LATEST="artifacts/jray_latest.zip"
|
||||||
|
cp "${ARTIFACT}" "${LATEST}"
|
||||||
|
echo "artifact=${LATEST}" >> $GITHUB_OUTPUT
|
||||||
|
echo "Found artifact: ${ARTIFACT} -> ${LATEST}"
|
||||||
|
|
||||||
|
- name: Upload build artifact
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: jellyfin-jray-plugin
|
||||||
|
path: build-${{ github.run_id }}/${{ steps.jprm.outputs.artifact }}
|
||||||
|
retention-days: 30
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
- name: Cleanup
|
||||||
|
if: always()
|
||||||
|
run: rm -rf build-${{ github.run_id }}
|
||||||
166
.gitea/workflows/latest.yaml
Normal file
166
.gitea/workflows/latest.yaml
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
name: 'Latest Release'
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
paths-ignore:
|
||||||
|
- '**/*.md'
|
||||||
|
- 'manifest.json'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
latest-release:
|
||||||
|
runs-on: linux/amd64
|
||||||
|
container:
|
||||||
|
image: gitea.tourolle.paris/dtourolle/jray-builder:latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
path: build-${{ github.run_id }}
|
||||||
|
|
||||||
|
- name: Restore dependencies
|
||||||
|
working-directory: build-${{ github.run_id }}
|
||||||
|
run: dotnet restore Jellyfin.Plugin.JRay.sln
|
||||||
|
|
||||||
|
- name: Build solution
|
||||||
|
working-directory: build-${{ github.run_id }}
|
||||||
|
run: dotnet build Jellyfin.Plugin.JRay.sln --configuration Release --no-restore --no-self-contained /m:1
|
||||||
|
|
||||||
|
- name: Build Jellyfin Plugin
|
||||||
|
id: jprm
|
||||||
|
working-directory: build-${{ github.run_id }}
|
||||||
|
run: |
|
||||||
|
mkdir -p artifacts
|
||||||
|
jprm --verbosity=debug plugin build .
|
||||||
|
ARTIFACT=$(find . -name "*.zip" -type f -print -quit | sed 's|^\./||')
|
||||||
|
ARTIFACT_NAME=$(basename "${ARTIFACT}")
|
||||||
|
echo "artifact=${ARTIFACT}" >> $GITHUB_OUTPUT
|
||||||
|
echo "artifact_name=${ARTIFACT_NAME}" >> $GITHUB_OUTPUT
|
||||||
|
echo "Found artifact: ${ARTIFACT}"
|
||||||
|
|
||||||
|
- name: Calculate checksum
|
||||||
|
id: checksum
|
||||||
|
working-directory: build-${{ github.run_id }}
|
||||||
|
run: |
|
||||||
|
CHECKSUM=$(md5sum "${{ steps.jprm.outputs.artifact }}" | awk '{print $1}')
|
||||||
|
echo "checksum=${CHECKSUM}" >> $GITHUB_OUTPUT
|
||||||
|
echo "Checksum: ${CHECKSUM}"
|
||||||
|
|
||||||
|
- name: Delete existing latest release
|
||||||
|
working-directory: build-${{ github.run_id }}
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
REPO_OWNER="${{ github.repository_owner }}"
|
||||||
|
REPO_NAME="${{ github.event.repository.name }}"
|
||||||
|
GITEA_URL="${{ github.server_url }}"
|
||||||
|
TAG="latest"
|
||||||
|
|
||||||
|
EXISTING=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/tags/${TAG}")
|
||||||
|
|
||||||
|
if [ "$EXISTING" = "200" ]; then
|
||||||
|
RELEASE_ID=$(curl -s \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/tags/${TAG}" | jq -r '.id')
|
||||||
|
|
||||||
|
echo "Deleting existing latest release (ID: ${RELEASE_ID})..."
|
||||||
|
curl -s -X DELETE \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
curl -s -X DELETE \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/tags/${TAG}" || true
|
||||||
|
|
||||||
|
- name: Create latest release
|
||||||
|
working-directory: build-${{ github.run_id }}
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
REPO_OWNER="${{ github.repository_owner }}"
|
||||||
|
REPO_NAME="${{ github.event.repository.name }}"
|
||||||
|
GITEA_URL="${{ github.server_url }}"
|
||||||
|
|
||||||
|
RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases" \
|
||||||
|
-d "$(jq -n --arg tag "latest" --arg name "Latest Build" --arg body "JRay Jellyfin Plugin latest build from master." '{tag_name: $tag, name: $name, body: $body, target_commitish: "master", draft: false, prerelease: true}')")
|
||||||
|
|
||||||
|
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
|
||||||
|
BODY=$(echo "$RESPONSE" | sed '$d')
|
||||||
|
|
||||||
|
if [ "$HTTP_CODE" -ge 200 ] && [ "$HTTP_CODE" -lt 300 ]; then
|
||||||
|
RELEASE_ID=$(echo "$BODY" | jq -r '.id')
|
||||||
|
echo "Created release with ID: ${RELEASE_ID}"
|
||||||
|
else
|
||||||
|
echo "Failed to create release. HTTP ${HTTP_CODE}"
|
||||||
|
echo "$BODY"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Upload plugin artifact
|
||||||
|
echo "Uploading plugin artifact..."
|
||||||
|
curl -f -X POST \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
-H "Content-Type: application/zip" \
|
||||||
|
--data-binary "@${{ steps.jprm.outputs.artifact }}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}/assets?name=${{ steps.jprm.outputs.artifact_name }}"
|
||||||
|
|
||||||
|
# Upload build.yaml
|
||||||
|
echo "Uploading build.yaml..."
|
||||||
|
curl -f -X POST \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
-H "Content-Type: application/x-yaml" \
|
||||||
|
--data-binary "@build.yaml" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}/assets?name=build.yaml"
|
||||||
|
|
||||||
|
echo "Latest release updated successfully!"
|
||||||
|
|
||||||
|
- name: Update manifest.json
|
||||||
|
working-directory: build-${{ github.run_id }}
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
REPO_OWNER="${{ github.repository_owner }}"
|
||||||
|
REPO_NAME="${{ github.event.repository.name }}"
|
||||||
|
GITEA_URL="${{ github.server_url }}"
|
||||||
|
CHECKSUM="${{ steps.checksum.outputs.checksum }}"
|
||||||
|
ARTIFACT_NAME="${{ steps.jprm.outputs.artifact_name }}"
|
||||||
|
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
DOWNLOAD_URL="${GITEA_URL}/${REPO_OWNER}/${REPO_NAME}/releases/download/latest/${ARTIFACT_NAME}"
|
||||||
|
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
|
||||||
|
|
||||||
|
git config user.name "Gitea Actions"
|
||||||
|
git config user.email "actions@gitea.tourolle.paris"
|
||||||
|
git fetch origin master
|
||||||
|
git checkout master
|
||||||
|
|
||||||
|
# Remove existing "latest" entry if present, then prepend new one
|
||||||
|
jq --arg url "$DOWNLOAD_URL" 'if .[0].versions[0].changelog == "Latest Build" then .[0].versions = .[0].versions[1:] else . end' manifest.json > manifest.tmp && mv manifest.tmp manifest.json
|
||||||
|
|
||||||
|
NEW_VERSION=$(cat <<EOF
|
||||||
|
{
|
||||||
|
"version": "0.0.0.0",
|
||||||
|
"changelog": "Latest Build",
|
||||||
|
"targetAbi": "10.9.0.0",
|
||||||
|
"sourceUrl": "${DOWNLOAD_URL}",
|
||||||
|
"checksum": "${CHECKSUM}",
|
||||||
|
"timestamp": "${TIMESTAMP}"
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
)
|
||||||
|
|
||||||
|
jq --argjson newver "${NEW_VERSION}" '.[0].versions = [$newver] + .[0].versions' manifest.json > manifest.tmp && mv manifest.tmp manifest.json
|
||||||
|
git add manifest.json
|
||||||
|
git commit -m "Update manifest.json for latest build (${SHORT_SHA})" || echo "No changes to commit"
|
||||||
|
git push origin master
|
||||||
|
|
||||||
|
- name: Cleanup
|
||||||
|
if: always()
|
||||||
|
run: rm -rf build-${{ github.run_id }}
|
||||||
167
.gitea/workflows/release.yaml
Normal file
167
.gitea/workflows/release.yaml
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
name: '🚀 Release Plugin'
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*.*.*'
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: 'Version to release (e.g., v1.0.0)'
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-release:
|
||||||
|
runs-on: linux/amd64
|
||||||
|
container:
|
||||||
|
image: gitea.tourolle.paris/dtourolle/jray-builder:latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
path: release-${{ github.run_id }}
|
||||||
|
|
||||||
|
- name: Get version
|
||||||
|
id: get_version
|
||||||
|
run: |
|
||||||
|
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||||
|
VERSION="${{ github.event.inputs.version }}"
|
||||||
|
else
|
||||||
|
VERSION="${GITHUB_REF#refs/tags/}"
|
||||||
|
fi
|
||||||
|
echo "version=${VERSION}" >> $GITHUB_OUTPUT
|
||||||
|
echo "version_number=${VERSION#v}" >> $GITHUB_OUTPUT
|
||||||
|
echo "Building version: ${VERSION}"
|
||||||
|
|
||||||
|
- name: Update build.yaml with version
|
||||||
|
working-directory: release-${{ github.run_id }}
|
||||||
|
run: |
|
||||||
|
VERSION="${{ steps.get_version.outputs.version_number }}"
|
||||||
|
sed -i "s/^version:.*/version: \"${VERSION}\"/" build.yaml
|
||||||
|
cat build.yaml
|
||||||
|
|
||||||
|
- name: Cache NuGet packages
|
||||||
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: ~/.nuget/packages
|
||||||
|
key: nuget-${{ hashFiles('**/Jellyfin.Plugin.JRay.csproj') }}
|
||||||
|
restore-keys: nuget-
|
||||||
|
|
||||||
|
- name: Restore dependencies
|
||||||
|
working-directory: release-${{ github.run_id }}
|
||||||
|
run: dotnet restore Jellyfin.Plugin.JRay.sln
|
||||||
|
|
||||||
|
- name: Build solution
|
||||||
|
working-directory: release-${{ github.run_id }}
|
||||||
|
run: dotnet build Jellyfin.Plugin.JRay.sln --configuration Release --no-restore --no-self-contained /m:1
|
||||||
|
|
||||||
|
- name: Build Jellyfin Plugin
|
||||||
|
id: jprm
|
||||||
|
working-directory: release-${{ github.run_id }}
|
||||||
|
run: |
|
||||||
|
mkdir -p artifacts
|
||||||
|
jprm --verbosity=debug plugin build ./
|
||||||
|
ARTIFACT=$(find . -name "*.zip" -type f -print -quit | sed 's|^\./||')
|
||||||
|
ARTIFACT_NAME=$(basename "${ARTIFACT}")
|
||||||
|
echo "artifact=${ARTIFACT}" >> $GITHUB_OUTPUT
|
||||||
|
echo "artifact_name=${ARTIFACT_NAME}" >> $GITHUB_OUTPUT
|
||||||
|
echo "Found artifact: ${ARTIFACT}"
|
||||||
|
|
||||||
|
- name: Calculate checksum
|
||||||
|
id: checksum
|
||||||
|
working-directory: release-${{ github.run_id }}
|
||||||
|
run: |
|
||||||
|
CHECKSUM=$(md5sum "${{ steps.jprm.outputs.artifact }}" | awk '{print $1}')
|
||||||
|
echo "checksum=${CHECKSUM}" >> $GITHUB_OUTPUT
|
||||||
|
echo "Checksum: ${CHECKSUM}"
|
||||||
|
|
||||||
|
- name: Create Release
|
||||||
|
working-directory: release-${{ github.run_id }}
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
# Get repository information
|
||||||
|
REPO_OWNER="${{ github.repository_owner }}"
|
||||||
|
REPO_NAME="${{ github.event.repository.name }}"
|
||||||
|
GITEA_URL="${{ github.server_url }}"
|
||||||
|
|
||||||
|
# Create release using Gitea API
|
||||||
|
VERSION="${{ steps.get_version.outputs.version }}"
|
||||||
|
RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases" \
|
||||||
|
-d "$(jq -n --arg tag "$VERSION" --arg name "Release $VERSION" --arg body "JRay Jellyfin Plugin. See attached files for plugin installation." '{tag_name: $tag, name: $name, body: $body, draft: false, prerelease: false}')")
|
||||||
|
|
||||||
|
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
|
||||||
|
BODY=$(echo "$RESPONSE" | sed '$d')
|
||||||
|
|
||||||
|
if [ "$HTTP_CODE" -ge 200 ] && [ "$HTTP_CODE" -lt 300 ]; then
|
||||||
|
RELEASE_ID=$(echo "$BODY" | jq -r '.id')
|
||||||
|
echo "Created release with ID: ${RELEASE_ID}"
|
||||||
|
else
|
||||||
|
echo "Failed to create release. HTTP ${HTTP_CODE}"
|
||||||
|
echo "$BODY"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Upload plugin artifact
|
||||||
|
echo "Uploading plugin artifact..."
|
||||||
|
curl -f -X POST \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
-H "Content-Type: application/zip" \
|
||||||
|
--data-binary "@${{ steps.jprm.outputs.artifact }}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}/assets?name=${{ steps.jprm.outputs.artifact_name }}"
|
||||||
|
|
||||||
|
# Upload build.yaml
|
||||||
|
echo "Uploading build.yaml..."
|
||||||
|
curl -f -X POST \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
-H "Content-Type: application/x-yaml" \
|
||||||
|
--data-binary "@build.yaml" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}/assets?name=build.yaml"
|
||||||
|
|
||||||
|
echo "✅ Release created successfully!"
|
||||||
|
echo "View at: ${GITEA_URL}/${REPO_OWNER}/${REPO_NAME}/releases/tag/${{ steps.get_version.outputs.version }}"
|
||||||
|
|
||||||
|
- name: Update manifest.json
|
||||||
|
working-directory: release-${{ github.run_id }}
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
REPO_OWNER="${{ github.repository_owner }}"
|
||||||
|
REPO_NAME="${{ github.event.repository.name }}"
|
||||||
|
GITEA_URL="${{ github.server_url }}"
|
||||||
|
VERSION="${{ steps.get_version.outputs.version_number }}"
|
||||||
|
CHECKSUM="${{ steps.checksum.outputs.checksum }}"
|
||||||
|
ARTIFACT_NAME="${{ steps.jprm.outputs.artifact_name }}"
|
||||||
|
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
DOWNLOAD_URL="${GITEA_URL}/${REPO_OWNER}/${REPO_NAME}/releases/download/${{ steps.get_version.outputs.version }}/${ARTIFACT_NAME}"
|
||||||
|
|
||||||
|
git config user.name "Gitea Actions"
|
||||||
|
git config user.email "actions@gitea.tourolle.paris"
|
||||||
|
git fetch origin master
|
||||||
|
git checkout master
|
||||||
|
|
||||||
|
NEW_VERSION=$(cat <<EOF
|
||||||
|
{
|
||||||
|
"version": "${VERSION}",
|
||||||
|
"changelog": "Release ${VERSION}",
|
||||||
|
"targetAbi": "10.9.0.0",
|
||||||
|
"sourceUrl": "${DOWNLOAD_URL}",
|
||||||
|
"checksum": "${CHECKSUM}",
|
||||||
|
"timestamp": "${TIMESTAMP}"
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
)
|
||||||
|
|
||||||
|
jq --argjson newver "${NEW_VERSION}" '.[0].versions = [$newver] + .[0].versions' manifest.json > manifest.tmp && mv manifest.tmp manifest.json
|
||||||
|
git add manifest.json
|
||||||
|
git commit -m "Update manifest.json for version ${VERSION}"
|
||||||
|
git push origin master
|
||||||
|
|
||||||
|
- name: Cleanup
|
||||||
|
if: always()
|
||||||
|
run: rm -rf release-${{ github.run_id }}
|
||||||
47
.gitea/workflows/test.yaml
Normal file
47
.gitea/workflows/test.yaml
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
name: '🧪 Test Plugin'
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
- develop
|
||||||
|
paths-ignore:
|
||||||
|
- '**/*.md'
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
- develop
|
||||||
|
paths-ignore:
|
||||||
|
- '**/*.md'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: linux/amd64
|
||||||
|
container:
|
||||||
|
image: gitea.tourolle.paris/dtourolle/jray-builder:latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
path: test-${{ github.run_id }}
|
||||||
|
|
||||||
|
- name: Cache NuGet packages
|
||||||
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: ~/.nuget/packages
|
||||||
|
key: nuget-${{ hashFiles('**/Jellyfin.Plugin.JRay.csproj') }}
|
||||||
|
restore-keys: nuget-
|
||||||
|
|
||||||
|
- name: Restore dependencies
|
||||||
|
working-directory: test-${{ github.run_id }}
|
||||||
|
run: dotnet restore Jellyfin.Plugin.JRay.sln
|
||||||
|
|
||||||
|
- name: Build solution
|
||||||
|
working-directory: test-${{ github.run_id }}
|
||||||
|
run: dotnet build Jellyfin.Plugin.JRay.sln --configuration Debug --no-restore --no-self-contained /m:1
|
||||||
|
|
||||||
|
- name: Cleanup
|
||||||
|
if: always()
|
||||||
|
run: rm -rf test-${{ github.run_id }}
|
||||||
6
.github/renovate.json
vendored
6
.github/renovate.json
vendored
@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
|
||||||
"extends": [
|
|
||||||
"github>jellyfin/.github//renovate-presets/default"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
18
.github/workflows/build.yaml
vendored
18
.github/workflows/build.yaml
vendored
@ -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
|
|
||||||
20
.github/workflows/changelog.yaml
vendored
20
.github/workflows/changelog.yaml
vendored
@ -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 }}
|
|
||||||
13
.github/workflows/command-dispatch.yaml
vendored
13
.github/workflows/command-dispatch.yaml
vendored
@ -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: .
|
|
||||||
16
.github/workflows/command-rebase.yaml
vendored
16
.github/workflows/command-rebase.yaml
vendored
@ -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 }}
|
|
||||||
18
.github/workflows/publish.yaml
vendored
18
.github/workflows/publish.yaml
vendored
@ -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 }}
|
|
||||||
20
.github/workflows/scan-codeql.yaml
vendored
20
.github/workflows/scan-codeql.yaml
vendored
@ -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
|
|
||||||
12
.github/workflows/sync-labels.yaml
vendored
12
.github/workflows/sync-labels.yaml
vendored
@ -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 }}
|
|
||||||
18
.github/workflows/test.yaml
vendored
18
.github/workflows/test.yaml
vendored
@ -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
|
|
||||||
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@ -15,5 +15,5 @@
|
|||||||
"jellyfinWindowsDataDir": "${env:LOCALAPPDATA}/jellyfin",
|
"jellyfinWindowsDataDir": "${env:LOCALAPPDATA}/jellyfin",
|
||||||
"jellyfinLinuxDataDir": "$HOME/.local/share/jellyfin",
|
"jellyfinLinuxDataDir": "$HOME/.local/share/jellyfin",
|
||||||
// The name of the plugin
|
// The name of the plugin
|
||||||
"pluginName": "Jellyfin.Plugin.Template",
|
"pluginName": "Jellyfin.Plugin.JRay",
|
||||||
}
|
}
|
||||||
19
Dockerfile.builder
Normal file
19
Dockerfile.builder
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
# JRay Builder Image
|
||||||
|
# Pre-built image with .NET SDK and JPRM for building Jellyfin plugins
|
||||||
|
# Build: docker build -f Dockerfile.builder -t gitea.tourolle.paris/dtourolle/jray-builder:latest .
|
||||||
|
# Push: docker push gitea.tourolle.paris/dtourolle/jray-builder:latest
|
||||||
|
|
||||||
|
FROM mcr.microsoft.com/dotnet/sdk:9.0
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
python3 \
|
||||||
|
python3-pip \
|
||||||
|
git \
|
||||||
|
jq \
|
||||||
|
nodejs \
|
||||||
|
npm \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN pip install --break-system-packages jprm
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
@ -1,6 +1,6 @@
|
|||||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
#
|
#
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Plugin.Template", "Jellyfin.Plugin.Template\Jellyfin.Plugin.Template.csproj", "{D921B930-CF91-406F-ACBC-08914DCD0D34}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Plugin.JRay", "Jellyfin.Plugin.JRay\Jellyfin.Plugin.JRay.csproj", "{D921B930-CF91-406F-ACBC-08914DCD0D34}"
|
||||||
EndProject
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
40
Jellyfin.Plugin.JRay/Configuration/PluginConfiguration.cs
Normal file
40
Jellyfin.Plugin.JRay/Configuration/PluginConfiguration.cs
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
using MediaBrowser.Model.Plugins;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.JRay.Configuration;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Plugin configuration.
|
||||||
|
/// </summary>
|
||||||
|
public class PluginConfiguration : BasePluginConfiguration
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="PluginConfiguration"/> class.
|
||||||
|
/// </summary>
|
||||||
|
public PluginConfiguration()
|
||||||
|
{
|
||||||
|
TruthFileSuffix = ".jray.json";
|
||||||
|
CacheDurationMinutes = 60;
|
||||||
|
EnableOverlay = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the filename suffix used to find a scene-actor-extraction
|
||||||
|
/// "truth" file for a media item. The plugin looks for a file named
|
||||||
|
/// "<media file basename><TruthFileSuffix>" next to the media file,
|
||||||
|
/// e.g. "Movie.mkv" -> "Movie.jray.json".
|
||||||
|
/// </summary>
|
||||||
|
public string TruthFileSuffix { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets how long (in minutes) a loaded truth file is cached in
|
||||||
|
/// memory before being re-read from disk.
|
||||||
|
/// </summary>
|
||||||
|
public int CacheDurationMinutes { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether JRay should inject its
|
||||||
|
/// pause-overlay script into the web client's index.html. When disabled,
|
||||||
|
/// any previously injected script is removed.
|
||||||
|
/// </summary>
|
||||||
|
public bool EnableOverlay { get; set; }
|
||||||
|
}
|
||||||
78
Jellyfin.Plugin.JRay/Configuration/configPage.html
Normal file
78
Jellyfin.Plugin.JRay/Configuration/configPage.html
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>JRay</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="JRayConfigPage" data-role="page" class="page type-interior pluginConfigurationPage" data-require="emby-input,emby-button,emby-select,emby-checkbox">
|
||||||
|
<div data-role="content">
|
||||||
|
<div class="content-primary">
|
||||||
|
<form id="JRayConfigForm">
|
||||||
|
<div class="inputContainer">
|
||||||
|
<label class="inputLabel inputLabelUnfocused" for="TruthFileSuffix">Truth file suffix</label>
|
||||||
|
<input id="TruthFileSuffix" name="TruthFileSuffix" type="text" is="emby-input" />
|
||||||
|
<div class="fieldDescription">
|
||||||
|
Filename suffix used to find the scene-actor-extraction output for a media
|
||||||
|
file, e.g. "Movie.mkv" with suffix ".jray.json" looks for "Movie.jray.json"
|
||||||
|
in the same folder.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="inputContainer">
|
||||||
|
<label class="inputLabel inputLabelUnfocused" for="CacheDurationMinutes">Cache duration (minutes)</label>
|
||||||
|
<input id="CacheDurationMinutes" name="CacheDurationMinutes" type="number" is="emby-input" min="0" />
|
||||||
|
<div class="fieldDescription">How long a loaded truth file is cached before being re-read from disk.</div>
|
||||||
|
</div>
|
||||||
|
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||||
|
<label>
|
||||||
|
<input id="EnableOverlay" name="EnableOverlay" type="checkbox" is="emby-checkbox" />
|
||||||
|
<span>Enable pause overlay</span>
|
||||||
|
</label>
|
||||||
|
<div class="fieldDescription">
|
||||||
|
Injects a small script into the web client that shows on-screen actors
|
||||||
|
when playback is paused. Disabling this removes the injected script.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button is="emby-button" type="submit" class="raised button-submit block emby-button">
|
||||||
|
<span>Save</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script type="text/javascript">
|
||||||
|
var JRayConfig = {
|
||||||
|
pluginUniqueId: '96a22d9d-23fd-49bb-8970-5e153817d223'
|
||||||
|
};
|
||||||
|
|
||||||
|
document.querySelector('#JRayConfigPage')
|
||||||
|
.addEventListener('pageshow', function() {
|
||||||
|
Dashboard.showLoadingMsg();
|
||||||
|
ApiClient.getPluginConfiguration(JRayConfig.pluginUniqueId).then(function (config) {
|
||||||
|
document.querySelector('#TruthFileSuffix').value = config.TruthFileSuffix;
|
||||||
|
document.querySelector('#CacheDurationMinutes').value = config.CacheDurationMinutes;
|
||||||
|
document.querySelector('#EnableOverlay').checked = config.EnableOverlay;
|
||||||
|
Dashboard.hideLoadingMsg();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelector('#JRayConfigForm')
|
||||||
|
.addEventListener('submit', function(e) {
|
||||||
|
Dashboard.showLoadingMsg();
|
||||||
|
ApiClient.getPluginConfiguration(JRayConfig.pluginUniqueId).then(function (config) {
|
||||||
|
config.TruthFileSuffix = document.querySelector('#TruthFileSuffix').value;
|
||||||
|
config.CacheDurationMinutes = parseInt(document.querySelector('#CacheDurationMinutes').value, 10);
|
||||||
|
config.EnableOverlay = document.querySelector('#EnableOverlay').checked;
|
||||||
|
ApiClient.updatePluginConfiguration(JRayConfig.pluginUniqueId, config).then(function (result) {
|
||||||
|
Dashboard.processPluginConfigurationUpdateResult(result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
87
Jellyfin.Plugin.JRay/Controllers/ActorsController.cs
Normal file
87
Jellyfin.Plugin.JRay/Controllers/ActorsController.cs
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Plugin.JRay.Models;
|
||||||
|
using Jellyfin.Plugin.JRay.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.JRay.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exposes scene-actor-extraction "truth" data: which actors are on screen
|
||||||
|
/// at a given timestamp in a movie.
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Route("Plugins/JRay/Items/{itemId}")]
|
||||||
|
[Authorize]
|
||||||
|
public class ActorsController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly ITruthDataService _truthDataService;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="ActorsController"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="truthDataService">The truth data service.</param>
|
||||||
|
public ActorsController(ITruthDataService truthDataService)
|
||||||
|
{
|
||||||
|
_truthDataService = truthDataService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the full actor timeline (every actor with their on-screen scene windows) for a movie.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="itemId">The Jellyfin item id.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>The truth file contents, or 404 if no truth data exists for this item.</returns>
|
||||||
|
[HttpGet("Timeline")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<TruthFile>> GetTimeline(Guid itemId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var truth = await _truthDataService.GetTruthAsync(itemId, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (truth is null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(truth);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the JRay context (currently: on-screen actors) at a given timestamp.
|
||||||
|
/// This is an extensible envelope — future fields (locations, trivia, etc.)
|
||||||
|
/// will be added here without changing the route.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="itemId">The Jellyfin item id.</param>
|
||||||
|
/// <param name="t">The timestamp, in seconds from the start of the movie.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>The JRay context at <paramref name="t"/>, or 404 if no truth data exists for this item.</returns>
|
||||||
|
[HttpGet("jray")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<JRayContext>> GetContext(Guid itemId, [FromQuery] double t, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var truth = await _truthDataService.GetTruthAsync(itemId, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (truth is null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var context = new JRayContext();
|
||||||
|
foreach (var actor in truth.Actors.Where(actor => actor.Scenes.Any(scene => scene.Length == 2 && scene[0] <= t && t <= scene[1])))
|
||||||
|
{
|
||||||
|
context.Actors.Add(new ActorAtTime
|
||||||
|
{
|
||||||
|
Name = actor.Name,
|
||||||
|
ImdbId = actor.ImdbId,
|
||||||
|
TmdbId = actor.TmdbId,
|
||||||
|
JellyfinId = actor.JellyfinId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
76
Jellyfin.Plugin.JRay/Controllers/TruthController.cs
Normal file
76
Jellyfin.Plugin.JRay/Controllers/TruthController.cs
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Plugin.JRay.Models;
|
||||||
|
using Jellyfin.Plugin.JRay.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.JRay.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Accepts scene-actor-extraction "truth" data pushed directly by a remote
|
||||||
|
/// extraction worker, for servers that cannot run the extraction pipeline
|
||||||
|
/// locally. See SPEC.md.
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Route("Plugins/JRay/Items/{itemId}/Truth")]
|
||||||
|
[Authorize(Roles = "Administrator")]
|
||||||
|
public class TruthController : ControllerBase
|
||||||
|
{
|
||||||
|
private const int SupportedSchemaVersion = 1;
|
||||||
|
|
||||||
|
private readonly IManagedTruthStore _managedTruthStore;
|
||||||
|
private readonly ITruthDataService _truthDataService;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="TruthController"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="managedTruthStore">The managed truth store.</param>
|
||||||
|
/// <param name="truthDataService">The truth data service.</param>
|
||||||
|
public TruthController(IManagedTruthStore managedTruthStore, ITruthDataService truthDataService)
|
||||||
|
{
|
||||||
|
_managedTruthStore = managedTruthStore;
|
||||||
|
_truthDataService = truthDataService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Uploads (creates or replaces) the truth data for an item.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="itemId">The Jellyfin item id.</param>
|
||||||
|
/// <param name="truth">The truth file contents.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>204 on success, or 400 if the schema version is unsupported.</returns>
|
||||||
|
[HttpPut]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
public async Task<IActionResult> PutTruth(Guid itemId, [FromBody] TruthFile truth, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (truth.SchemaVersion != SupportedSchemaVersion)
|
||||||
|
{
|
||||||
|
return BadRequest($"Unsupported schema_version {truth.SchemaVersion}; expected {SupportedSchemaVersion}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await _managedTruthStore.SaveAsync(itemId, truth, cancellationToken).ConfigureAwait(false);
|
||||||
|
_truthDataService.Invalidate(itemId);
|
||||||
|
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes any managed truth data for an item. The item falls back to
|
||||||
|
/// its sidecar truth file (if any) on subsequent reads.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="itemId">The Jellyfin item id.</param>
|
||||||
|
/// <returns>204, whether or not managed data existed.</returns>
|
||||||
|
[HttpDelete]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
public IActionResult DeleteTruth(Guid itemId)
|
||||||
|
{
|
||||||
|
_managedTruthStore.Delete(itemId);
|
||||||
|
_truthDataService.Invalidate(itemId);
|
||||||
|
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
35
Jellyfin.Plugin.JRay/Controllers/WebController.cs
Normal file
35
Jellyfin.Plugin.JRay/Controllers/WebController.cs
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Reflection;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.JRay.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serves static client-side assets for JRay, e.g. the pause-overlay script
|
||||||
|
/// injected into the web client.
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Route("Plugins/JRay")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
public class WebController : ControllerBase
|
||||||
|
{
|
||||||
|
private const string OverlayScriptResource = "Jellyfin.Plugin.JRay.Web.jray-overlay.js";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the pause-overlay client script.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>The JavaScript source for the pause overlay.</returns>
|
||||||
|
[HttpGet("ClientScript")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
public IActionResult GetClientScript()
|
||||||
|
{
|
||||||
|
var assembly = Assembly.GetExecutingAssembly();
|
||||||
|
var stream = assembly.GetManifestResourceStream(OverlayScriptResource)
|
||||||
|
?? throw new InvalidOperationException($"Embedded resource '{OverlayScriptResource}' not found.");
|
||||||
|
|
||||||
|
return File(stream, "application/javascript");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net9.0</TargetFramework>
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
<RootNamespace>Jellyfin.Plugin.Template</RootNamespace>
|
<RootNamespace>Jellyfin.Plugin.JRay</RootNamespace>
|
||||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
@ -28,6 +28,8 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<None Remove="Configuration\configPage.html" />
|
<None Remove="Configuration\configPage.html" />
|
||||||
<EmbeddedResource Include="Configuration\configPage.html" />
|
<EmbeddedResource Include="Configuration\configPage.html" />
|
||||||
|
<None Remove="Web\jray-overlay.js" />
|
||||||
|
<EmbeddedResource Include="Web\jray-overlay.js" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
33
Jellyfin.Plugin.JRay/Models/ActorAtTime.cs
Normal file
33
Jellyfin.Plugin.JRay/Models/ActorAtTime.cs
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.JRay.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An actor visible on screen at a queried timestamp.
|
||||||
|
/// </summary>
|
||||||
|
public class ActorAtTime
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the actor's display name.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("name")]
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the actor's IMDB person id, or "" if unresolved.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("imdb_id")]
|
||||||
|
public string ImdbId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the actor's TMDB person id, or "" if unresolved.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("tmdb_id")]
|
||||||
|
public string TmdbId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the actor's Jellyfin Person item GUID, or "" if unresolved.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("jellyfin_id")]
|
||||||
|
public string JellyfinId { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
18
Jellyfin.Plugin.JRay/Models/JRayContext.cs
Normal file
18
Jellyfin.Plugin.JRay/Models/JRayContext.cs
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.JRay.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extensible "what's happening at time t" context for a media item.
|
||||||
|
/// Returned by the <c>jray?t=</c> endpoint; new fields (e.g. locations,
|
||||||
|
/// trivia) can be added here without changing the route.
|
||||||
|
/// </summary>
|
||||||
|
public class JRayContext
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the list of actors visible on screen at the queried timestamp.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("actors")]
|
||||||
|
public Collection<ActorAtTime> Actors { get; } = new();
|
||||||
|
}
|
||||||
41
Jellyfin.Plugin.JRay/Models/TruthActor.cs
Normal file
41
Jellyfin.Plugin.JRay/Models/TruthActor.cs
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.JRay.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One actor entry in a <see cref="TruthFile"/>, with the time windows during
|
||||||
|
/// which they are visible on screen.
|
||||||
|
/// </summary>
|
||||||
|
public class TruthActor
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the actor's display name.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("name")]
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the actor's IMDB person id (e.g. "nm0000158"), or "" if unresolved.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("imdb_id")]
|
||||||
|
public string ImdbId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the actor's TMDB person id, or "" if unresolved.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("tmdb_id")]
|
||||||
|
public string TmdbId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the actor's Jellyfin Person item GUID, or "" if unresolved.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("jellyfin_id")]
|
||||||
|
public string JellyfinId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the list of [start_sec, end_sec] windows during which the actor is on screen.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("scenes")]
|
||||||
|
public Collection<double[]> Scenes { get; } = new();
|
||||||
|
}
|
||||||
41
Jellyfin.Plugin.JRay/Models/TruthFile.cs
Normal file
41
Jellyfin.Plugin.JRay/Models/TruthFile.cs
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.JRay.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Root object of a scene-actor-extraction "truth" file
|
||||||
|
/// (schema_version 1, minimal verbosity). See SPEC.md.
|
||||||
|
/// </summary>
|
||||||
|
public class TruthFile
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the schema version of this file.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("schema_version")]
|
||||||
|
public int SchemaVersion { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the source media path at extraction time (informational).
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("movie")]
|
||||||
|
public string Movie { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the sampling rate (frames per second) used during extraction.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("sample_fps")]
|
||||||
|
public double SampleFps { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the gap (seconds) below which consecutive detections were merged into one scene.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("anneal_sec")]
|
||||||
|
public double AnnealSec { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the list of actors detected in the film, each with their on-screen scene windows.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("actors")]
|
||||||
|
public Collection<TruthActor> Actors { get; } = new();
|
||||||
|
}
|
||||||
@ -1,35 +1,44 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using Jellyfin.Plugin.Template.Configuration;
|
using Jellyfin.Plugin.JRay.Configuration;
|
||||||
|
using Jellyfin.Plugin.JRay.Services;
|
||||||
using MediaBrowser.Common.Configuration;
|
using MediaBrowser.Common.Configuration;
|
||||||
using MediaBrowser.Common.Plugins;
|
using MediaBrowser.Common.Plugins;
|
||||||
using MediaBrowser.Model.Plugins;
|
using MediaBrowser.Model.Plugins;
|
||||||
using MediaBrowser.Model.Serialization;
|
using MediaBrowser.Model.Serialization;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace Jellyfin.Plugin.Template;
|
namespace Jellyfin.Plugin.JRay;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The main plugin.
|
/// The main plugin.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
||||||
{
|
{
|
||||||
|
private readonly ILogger<Plugin> _logger;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="Plugin"/> class.
|
/// Initializes a new instance of the <see cref="Plugin"/> class.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="applicationPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param>
|
/// <param name="applicationPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param>
|
||||||
/// <param name="xmlSerializer">Instance of the <see cref="IXmlSerializer"/> interface.</param>
|
/// <param name="xmlSerializer">Instance of the <see cref="IXmlSerializer"/> interface.</param>
|
||||||
public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer)
|
/// <param name="logger">The logger.</param>
|
||||||
|
public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer, ILogger<Plugin> logger)
|
||||||
: base(applicationPaths, xmlSerializer)
|
: base(applicationPaths, xmlSerializer)
|
||||||
{
|
{
|
||||||
Instance = this;
|
Instance = this;
|
||||||
|
_logger = logger;
|
||||||
|
|
||||||
|
WebClientPatchService.Apply(ApplicationPaths, Configuration.EnableOverlay, _logger);
|
||||||
|
ConfigurationChanged += (_, _) => WebClientPatchService.Apply(ApplicationPaths, Configuration.EnableOverlay, _logger);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public override string Name => "Template";
|
public override string Name => "JRay";
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public override Guid Id => Guid.Parse("eb5d7894-8eef-4b36-aa6f-5d124e828ce1");
|
public override Guid Id => Guid.Parse("96a22d9d-23fd-49bb-8970-5e153817d223");
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the current plugin instance.
|
/// Gets the current plugin instance.
|
||||||
20
Jellyfin.Plugin.JRay/ServiceRegistrator.cs
Normal file
20
Jellyfin.Plugin.JRay/ServiceRegistrator.cs
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
using Jellyfin.Plugin.JRay.Services;
|
||||||
|
using Jellyfin.Plugin.JRay.Services.Interfaces;
|
||||||
|
using MediaBrowser.Controller;
|
||||||
|
using MediaBrowser.Controller.Plugins;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.JRay;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service registrator for dependency injection.
|
||||||
|
/// </summary>
|
||||||
|
public class ServiceRegistrator : IPluginServiceRegistrator
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost)
|
||||||
|
{
|
||||||
|
serviceCollection.AddSingleton<IManagedTruthStore, ManagedTruthStore>();
|
||||||
|
serviceCollection.AddSingleton<ITruthDataService, TruthDataService>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,38 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Plugin.JRay.Models;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.JRay.Services.Interfaces;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stores and retrieves truth files that were pushed to JRay directly
|
||||||
|
/// (e.g. by a remote extraction worker), independent of any sidecar file
|
||||||
|
/// on the media filesystem.
|
||||||
|
/// </summary>
|
||||||
|
public interface IManagedTruthStore
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Loads the managed truth file for the given item, if one was uploaded.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="itemId">The Jellyfin library item id.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>The parsed truth file, or null if none has been uploaded for this item.</returns>
|
||||||
|
Task<TruthFile?> LoadAsync(Guid itemId, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Saves (creates or replaces) the managed truth file for the given item.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="itemId">The Jellyfin library item id.</param>
|
||||||
|
/// <param name="truth">The truth file contents to persist.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>A task that completes when the file has been written.</returns>
|
||||||
|
Task SaveAsync(Guid itemId, TruthFile truth, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes the managed truth file for the given item, if one exists.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="itemId">The Jellyfin library item id.</param>
|
||||||
|
/// <returns><c>true</c> if a file was deleted; <c>false</c> if none existed.</returns>
|
||||||
|
bool Delete(Guid itemId);
|
||||||
|
}
|
||||||
@ -0,0 +1,27 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Plugin.JRay.Models;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.JRay.Services.Interfaces;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loads and caches scene-actor-extraction "truth" files for library items.
|
||||||
|
/// </summary>
|
||||||
|
public interface ITruthDataService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the truth file for the given library item, if one exists.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="itemId">The Jellyfin library item id.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>The parsed truth file, or null if no truth file exists for this item.</returns>
|
||||||
|
Task<TruthFile?> GetTruthAsync(Guid itemId, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes any cached truth file for the given item, so the next
|
||||||
|
/// <see cref="GetTruthAsync"/> call re-reads from the managed store or sidecar file.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="itemId">The Jellyfin library item id.</param>
|
||||||
|
void Invalidate(Guid itemId);
|
||||||
|
}
|
||||||
90
Jellyfin.Plugin.JRay/Services/ManagedTruthStore.cs
Normal file
90
Jellyfin.Plugin.JRay/Services/ManagedTruthStore.cs
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Plugin.JRay.Models;
|
||||||
|
using Jellyfin.Plugin.JRay.Services.Interfaces;
|
||||||
|
using MediaBrowser.Common.Configuration;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.JRay.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stores truth files pushed directly to JRay (e.g. by a remote extraction
|
||||||
|
/// worker) under the plugin's configuration directory, keyed by item id.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ManagedTruthStore : IManagedTruthStore
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||||
|
|
||||||
|
private readonly IApplicationPaths _applicationPaths;
|
||||||
|
private readonly ILogger<ManagedTruthStore> _logger;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="ManagedTruthStore"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="applicationPaths">The Jellyfin application paths.</param>
|
||||||
|
/// <param name="logger">The logger.</param>
|
||||||
|
public ManagedTruthStore(IApplicationPaths applicationPaths, ILogger<ManagedTruthStore> logger)
|
||||||
|
{
|
||||||
|
_applicationPaths = applicationPaths;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<TruthFile?> LoadAsync(Guid itemId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var path = GetPath(itemId);
|
||||||
|
if (!File.Exists(path))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var stream = File.OpenRead(path);
|
||||||
|
return await JsonSerializer.DeserializeAsync<TruthFile>(stream, JsonOptions, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or JsonException)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "JRay: failed to read managed truth file {Path}", path);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task SaveAsync(Guid itemId, TruthFile truth, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var path = GetPath(itemId);
|
||||||
|
var directory = Path.GetDirectoryName(path) ?? throw new InvalidOperationException("Managed truth path has no directory.");
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
|
||||||
|
var tempPath = path + ".tmp";
|
||||||
|
using (var stream = File.Create(tempPath))
|
||||||
|
{
|
||||||
|
await JsonSerializer.SerializeAsync(stream, truth, JsonOptions, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
File.Move(tempPath, path, overwrite: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool Delete(Guid itemId)
|
||||||
|
{
|
||||||
|
var path = GetPath(itemId);
|
||||||
|
if (!File.Exists(path))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
File.Delete(path);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetPath(Guid itemId)
|
||||||
|
{
|
||||||
|
return Path.Combine(_applicationPaths.PluginConfigurationsPath, "JRay", "truth", itemId.ToString("D") + ".json");
|
||||||
|
}
|
||||||
|
}
|
||||||
99
Jellyfin.Plugin.JRay/Services/TruthDataService.cs
Normal file
99
Jellyfin.Plugin.JRay/Services/TruthDataService.cs
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Plugin.JRay.Models;
|
||||||
|
using Jellyfin.Plugin.JRay.Services.Interfaces;
|
||||||
|
using MediaBrowser.Controller.Library;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.JRay.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loads scene-actor-extraction truth files from disk, alongside each media
|
||||||
|
/// item's source file, and caches the parsed result for a configurable
|
||||||
|
/// duration.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class TruthDataService : ITruthDataService
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||||
|
|
||||||
|
private readonly ILibraryManager _libraryManager;
|
||||||
|
private readonly IManagedTruthStore _managedTruthStore;
|
||||||
|
private readonly ILogger<TruthDataService> _logger;
|
||||||
|
private readonly ConcurrentDictionary<Guid, CacheEntry> _cache = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="TruthDataService"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="libraryManager">The Jellyfin library manager.</param>
|
||||||
|
/// <param name="managedTruthStore">The managed truth store.</param>
|
||||||
|
/// <param name="logger">The logger.</param>
|
||||||
|
public TruthDataService(ILibraryManager libraryManager, IManagedTruthStore managedTruthStore, ILogger<TruthDataService> logger)
|
||||||
|
{
|
||||||
|
_libraryManager = libraryManager;
|
||||||
|
_managedTruthStore = managedTruthStore;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<TruthFile?> GetTruthAsync(Guid itemId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var cacheDuration = TimeSpan.FromMinutes(Math.Max(0, Plugin.Instance?.Configuration.CacheDurationMinutes ?? 0));
|
||||||
|
|
||||||
|
if (_cache.TryGetValue(itemId, out var cached) && DateTime.UtcNow - cached.LoadedAt < cacheDuration)
|
||||||
|
{
|
||||||
|
return cached.Truth;
|
||||||
|
}
|
||||||
|
|
||||||
|
var managed = await _managedTruthStore.LoadAsync(itemId, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (managed is not null)
|
||||||
|
{
|
||||||
|
_cache[itemId] = new CacheEntry(managed, DateTime.UtcNow);
|
||||||
|
return managed;
|
||||||
|
}
|
||||||
|
|
||||||
|
var item = _libraryManager.GetItemById(itemId);
|
||||||
|
if (item is null || string.IsNullOrEmpty(item.Path))
|
||||||
|
{
|
||||||
|
_logger.LogDebug("JRay: item {ItemId} not found or has no path", itemId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var suffix = Plugin.Instance?.Configuration.TruthFileSuffix ?? ".jray.json";
|
||||||
|
var truthPath = Path.Combine(
|
||||||
|
Path.GetDirectoryName(item.Path) ?? string.Empty,
|
||||||
|
Path.GetFileNameWithoutExtension(item.Path) + suffix);
|
||||||
|
|
||||||
|
if (!File.Exists(truthPath))
|
||||||
|
{
|
||||||
|
_logger.LogDebug("JRay: no truth file at {TruthPath}", truthPath);
|
||||||
|
_cache[itemId] = new CacheEntry(null, DateTime.UtcNow);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var stream = File.OpenRead(truthPath);
|
||||||
|
var truth = await JsonSerializer.DeserializeAsync<TruthFile>(stream, JsonOptions, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
_cache[itemId] = new CacheEntry(truth, DateTime.UtcNow);
|
||||||
|
return truth;
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or JsonException)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "JRay: failed to read truth file {TruthPath}", truthPath);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Invalidate(Guid itemId)
|
||||||
|
{
|
||||||
|
_cache.TryRemove(itemId, out _);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record CacheEntry(TruthFile? Truth, DateTime LoadedAt);
|
||||||
|
}
|
||||||
72
Jellyfin.Plugin.JRay/Services/WebClientPatchService.cs
Normal file
72
Jellyfin.Plugin.JRay/Services/WebClientPatchService.cs
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using MediaBrowser.Common.Configuration;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.JRay.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Injects (or removes) a script tag in the web client's <c>index.html</c>
|
||||||
|
/// that loads JRay's pause-overlay script. This follows the pattern used by
|
||||||
|
/// other Jellyfin plugins (e.g. Intro Skipper) since there is no official
|
||||||
|
/// plugin hook for player-overlay UI.
|
||||||
|
/// </summary>
|
||||||
|
public static class WebClientPatchService
|
||||||
|
{
|
||||||
|
private const string Marker = "<!-- jray-overlay -->";
|
||||||
|
private const string ScriptTag = "<script defer src=\"/Plugins/JRay/ClientScript\"></script>";
|
||||||
|
private const string Injected = ScriptTag + Marker + "\n</body>";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ensures the web client's index.html either has or does not have the
|
||||||
|
/// JRay overlay script injected, matching <paramref name="enableOverlay"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="applicationPaths">The Jellyfin application paths.</param>
|
||||||
|
/// <param name="enableOverlay">Whether the overlay script should be present.</param>
|
||||||
|
/// <param name="logger">The logger.</param>
|
||||||
|
public static void Apply(IApplicationPaths applicationPaths, bool enableOverlay, ILogger logger)
|
||||||
|
{
|
||||||
|
var indexPath = Path.Combine(applicationPaths.WebPath, "index.html");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!File.Exists(indexPath))
|
||||||
|
{
|
||||||
|
logger.LogDebug("JRay: web client index.html not found at {Path}", indexPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var html = File.ReadAllText(indexPath);
|
||||||
|
var hasMarker = html.Contains(Marker, StringComparison.Ordinal);
|
||||||
|
|
||||||
|
if (enableOverlay && !hasMarker)
|
||||||
|
{
|
||||||
|
var patched = ReplaceLast(html, "</body>", Injected);
|
||||||
|
File.WriteAllText(indexPath, patched);
|
||||||
|
logger.LogInformation("JRay: injected pause-overlay script into {Path}", indexPath);
|
||||||
|
}
|
||||||
|
else if (!enableOverlay && hasMarker)
|
||||||
|
{
|
||||||
|
var patched = html.Replace(ScriptTag + Marker + "\n", string.Empty, StringComparison.Ordinal)
|
||||||
|
.Replace(ScriptTag + Marker, string.Empty, StringComparison.Ordinal);
|
||||||
|
File.WriteAllText(indexPath, patched);
|
||||||
|
logger.LogInformation("JRay: removed pause-overlay script from {Path}", indexPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "JRay: failed to patch web client index.html at {Path}", indexPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ReplaceLast(string source, string find, string replace)
|
||||||
|
{
|
||||||
|
var index = source.LastIndexOf(find, StringComparison.Ordinal);
|
||||||
|
if (index < 0)
|
||||||
|
{
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
return source[..index] + replace + source[(index + find.Length)..];
|
||||||
|
}
|
||||||
|
}
|
||||||
109
Jellyfin.Plugin.JRay/Web/jray-overlay.js
Normal file
109
Jellyfin.Plugin.JRay/Web/jray-overlay.js
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var POLL_INTERVAL_MS = 1000;
|
||||||
|
var overlayEl = null;
|
||||||
|
|
||||||
|
function getItemIdFromHash() {
|
||||||
|
var match = window.location.hash.match(/[?&]id=([0-9a-fA-F-]+)/);
|
||||||
|
return match ? match[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeOverlay() {
|
||||||
|
if (overlayEl && overlayEl.parentNode) {
|
||||||
|
overlayEl.parentNode.removeChild(overlayEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
overlayEl = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showOverlay(video, actors) {
|
||||||
|
removeOverlay();
|
||||||
|
|
||||||
|
if (!actors || actors.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var container = video.parentElement;
|
||||||
|
if (!container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
overlayEl = document.createElement('div');
|
||||||
|
overlayEl.className = 'jrayOverlay';
|
||||||
|
overlayEl.style.position = 'absolute';
|
||||||
|
overlayEl.style.bottom = '10%';
|
||||||
|
overlayEl.style.left = '2%';
|
||||||
|
overlayEl.style.zIndex = '9999';
|
||||||
|
overlayEl.style.display = 'flex';
|
||||||
|
overlayEl.style.flexWrap = 'wrap';
|
||||||
|
overlayEl.style.gap = '12px';
|
||||||
|
overlayEl.style.pointerEvents = 'none';
|
||||||
|
|
||||||
|
actors.forEach(function (actor) {
|
||||||
|
var card = document.createElement('div');
|
||||||
|
card.style.background = 'rgba(0, 0, 0, 0.7)';
|
||||||
|
card.style.color = '#fff';
|
||||||
|
card.style.padding = '6px 12px';
|
||||||
|
card.style.borderRadius = '4px';
|
||||||
|
card.style.fontSize = '14px';
|
||||||
|
card.textContent = actor.name;
|
||||||
|
overlayEl.appendChild(card);
|
||||||
|
});
|
||||||
|
|
||||||
|
container.appendChild(overlayEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchContext(itemId, t) {
|
||||||
|
if (!window.ApiClient) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var url = window.ApiClient.getUrl('Plugins/JRay/Items/' + itemId + '/jray', { t: t });
|
||||||
|
window.ApiClient.ajax({ url: url, type: 'GET', dataType: 'json' }).then(function (context) {
|
||||||
|
var video = document.querySelector('video');
|
||||||
|
if (!video || !video.paused) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showOverlay(video, context.actors);
|
||||||
|
}, function () {
|
||||||
|
// No truth data (404) or request error - fail silently, never break playback.
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPause(event) {
|
||||||
|
var video = event.target;
|
||||||
|
var itemId = getItemIdFromHash();
|
||||||
|
if (!itemId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchContext(itemId, video.currentTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPlay() {
|
||||||
|
removeOverlay();
|
||||||
|
}
|
||||||
|
|
||||||
|
function attach(video) {
|
||||||
|
if (video.dataset.jrayAttached) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
video.dataset.jrayAttached = 'true';
|
||||||
|
video.addEventListener('pause', onPause);
|
||||||
|
video.addEventListener('play', onPlay);
|
||||||
|
video.addEventListener('playing', onPlay);
|
||||||
|
video.addEventListener('seeking', removeOverlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
setInterval(function () {
|
||||||
|
var video = document.querySelector('video');
|
||||||
|
if (video) {
|
||||||
|
attach(video);
|
||||||
|
} else {
|
||||||
|
removeOverlay();
|
||||||
|
}
|
||||||
|
}, POLL_INTERVAL_MS);
|
||||||
|
})();
|
||||||
@ -1,57 +0,0 @@
|
|||||||
using MediaBrowser.Model.Plugins;
|
|
||||||
|
|
||||||
namespace Jellyfin.Plugin.Template.Configuration;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The configuration options.
|
|
||||||
/// </summary>
|
|
||||||
public enum SomeOptions
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Option one.
|
|
||||||
/// </summary>
|
|
||||||
OneOption,
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Second option.
|
|
||||||
/// </summary>
|
|
||||||
AnotherOption
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Plugin configuration.
|
|
||||||
/// </summary>
|
|
||||||
public class PluginConfiguration : BasePluginConfiguration
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Initializes a new instance of the <see cref="PluginConfiguration"/> class.
|
|
||||||
/// </summary>
|
|
||||||
public PluginConfiguration()
|
|
||||||
{
|
|
||||||
// set default options here
|
|
||||||
Options = SomeOptions.AnotherOption;
|
|
||||||
TrueFalseSetting = true;
|
|
||||||
AnInteger = 2;
|
|
||||||
AString = "string";
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets a value indicating whether some true or false setting is enabled..
|
|
||||||
/// </summary>
|
|
||||||
public bool TrueFalseSetting { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets an integer setting.
|
|
||||||
/// </summary>
|
|
||||||
public int AnInteger { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets a string setting.
|
|
||||||
/// </summary>
|
|
||||||
public string AString { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets an enum option.
|
|
||||||
/// </summary>
|
|
||||||
public SomeOptions Options { get; set; }
|
|
||||||
}
|
|
||||||
@ -1,79 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<title>Template</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="TemplateConfigPage" data-role="page" class="page type-interior pluginConfigurationPage" data-require="emby-input,emby-button,emby-select,emby-checkbox">
|
|
||||||
<div data-role="content">
|
|
||||||
<div class="content-primary">
|
|
||||||
<form id="TemplateConfigForm">
|
|
||||||
<div class="selectContainer">
|
|
||||||
<label class="selectLabel" for="Options">Several Options</label>
|
|
||||||
<select is="emby-select" id="Options" name="Options" class="emby-select-withcolor emby-select">
|
|
||||||
<option id="optOneOption" value="OneOption">One Option</option>
|
|
||||||
<option id="optAnotherOption" value="AnotherOption">Another Option</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="inputContainer">
|
|
||||||
<label class="inputLabel inputLabelUnfocused" for="AnInteger">An Integer</label>
|
|
||||||
<input id="AnInteger" name="AnInteger" type="number" is="emby-input" min="0" />
|
|
||||||
<div class="fieldDescription">A Description</div>
|
|
||||||
</div>
|
|
||||||
<div class="checkboxContainer checkboxContainer-withDescription">
|
|
||||||
<label class="emby-checkbox-label">
|
|
||||||
<input id="TrueFalseSetting" name="TrueFalseCheckBox" type="checkbox" is="emby-checkbox" />
|
|
||||||
<span>A Checkbox</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="inputContainer">
|
|
||||||
<label class="inputLabel inputLabelUnfocused" for="AString">A String</label>
|
|
||||||
<input id="AString" name="AString" type="text" is="emby-input" />
|
|
||||||
<div class="fieldDescription">Another Description</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<button is="emby-button" type="submit" class="raised button-submit block emby-button">
|
|
||||||
<span>Save</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<script type="text/javascript">
|
|
||||||
var TemplateConfig = {
|
|
||||||
pluginUniqueId: 'eb5d7894-8eef-4b36-aa6f-5d124e828ce1'
|
|
||||||
};
|
|
||||||
|
|
||||||
document.querySelector('#TemplateConfigPage')
|
|
||||||
.addEventListener('pageshow', function() {
|
|
||||||
Dashboard.showLoadingMsg();
|
|
||||||
ApiClient.getPluginConfiguration(TemplateConfig.pluginUniqueId).then(function (config) {
|
|
||||||
document.querySelector('#Options').value = config.Options;
|
|
||||||
document.querySelector('#AnInteger').value = config.AnInteger;
|
|
||||||
document.querySelector('#TrueFalseSetting').checked = config.TrueFalseSetting;
|
|
||||||
document.querySelector('#AString').value = config.AString;
|
|
||||||
Dashboard.hideLoadingMsg();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
document.querySelector('#TemplateConfigForm')
|
|
||||||
.addEventListener('submit', function(e) {
|
|
||||||
Dashboard.showLoadingMsg();
|
|
||||||
ApiClient.getPluginConfiguration(TemplateConfig.pluginUniqueId).then(function (config) {
|
|
||||||
config.Options = document.querySelector('#Options').value;
|
|
||||||
config.AnInteger = document.querySelector('#AnInteger').value;
|
|
||||||
config.TrueFalseSetting = document.querySelector('#TrueFalseSetting').checked;
|
|
||||||
config.AString = document.querySelector('#AString').value;
|
|
||||||
ApiClient.updatePluginConfiguration(TemplateConfig.pluginUniqueId, config).then(function (result) {
|
|
||||||
Dashboard.processPluginConfigurationUpdateResult(result);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
e.preventDefault();
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
108
SPEC.md
Normal file
108
SPEC.md
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
# JRay truth file format
|
||||||
|
|
||||||
|
JRay reads "truth" files produced offline by the
|
||||||
|
[scene-actor-extraction](https://github.com/dtourolle/scene-actor-extraction)
|
||||||
|
pipeline (`result_sink_node`, `Verbosity::minimal`, `schema_version: 1`).
|
||||||
|
|
||||||
|
## File location
|
||||||
|
|
||||||
|
For a media file `Movie.mkv`, the pipeline writes a sibling file
|
||||||
|
`Movie.jray.json` (suffix configurable in the plugin settings, default
|
||||||
|
`.jray.json`). The plugin resolves this path from the Jellyfin item's media
|
||||||
|
source path by stripping the extension and appending the suffix.
|
||||||
|
|
||||||
|
## JSON schema (schema_version 1, minimal verbosity)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"movie": "/path/to/Movie.mkv",
|
||||||
|
"sample_fps": 1,
|
||||||
|
"anneal_sec": 2,
|
||||||
|
"actors": [
|
||||||
|
{
|
||||||
|
"name": "Tom Hanks",
|
||||||
|
"imdb_id": "nm0000158",
|
||||||
|
"tmdb_id": "31",
|
||||||
|
"jellyfin_id": "abc123-guid",
|
||||||
|
"scenes": [[12.0, 45.0], [102.5, 150.0]]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `schema_version`: integer, bump on breaking changes. JRay should refuse (or
|
||||||
|
warn) on a version it doesn't understand.
|
||||||
|
- `movie`: absolute path to the source media file at extraction time (informational only).
|
||||||
|
- `sample_fps`: frames-per-second the pipeline sampled at.
|
||||||
|
- `anneal_sec`: gap (in seconds) below which consecutive detections of the
|
||||||
|
same actor were merged into a single scene window.
|
||||||
|
- `actors[]`: one entry per actor detected anywhere in the film.
|
||||||
|
- `name`: display name from the gallery.
|
||||||
|
- `imdb_id` / `tmdb_id` / `jellyfin_id`: identity keys, each `""` if not
|
||||||
|
resolved. JRay should prefer `jellyfin_id` (a Jellyfin Person item GUID)
|
||||||
|
when non-empty, and otherwise resolve `imdb_id`/`tmdb_id` against the
|
||||||
|
item's People `ProviderIds`.
|
||||||
|
- `scenes`: list of `[start_sec, end_sec]` windows (inclusive) during which
|
||||||
|
the actor is on screen.
|
||||||
|
|
||||||
|
## Querying "who's on screen at time t"
|
||||||
|
|
||||||
|
For a given timestamp `t` (seconds), an actor is visible if any of their
|
||||||
|
`scenes` windows satisfies `start <= t <= end`.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
### `GET /Plugins/JRay/Items/{itemId}/Timeline`
|
||||||
|
|
||||||
|
Returns the full truth file (schema above) for an item, or `404` if no truth
|
||||||
|
data exists (neither a managed upload nor a sidecar file).
|
||||||
|
|
||||||
|
### `GET /Plugins/JRay/Items/{itemId}/jray?t={seconds}`
|
||||||
|
|
||||||
|
Returns an extensible "context at time t" envelope, or `404` if no truth
|
||||||
|
data exists for the item:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"actors": [
|
||||||
|
{ "name": "Tom Hanks", "imdb_id": "nm0000158", "tmdb_id": "31", "jellyfin_id": "abc123-guid" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Future fields (e.g. `locations`, `trivia`) will be added to this object
|
||||||
|
without changing the route, so clients should ignore unknown keys.
|
||||||
|
|
||||||
|
### `PUT /Plugins/JRay/Items/{itemId}/Truth`
|
||||||
|
|
||||||
|
For servers that cannot run the extraction pipeline locally, a remote worker
|
||||||
|
may push truth data directly. Requires an administrator API key. Body is a
|
||||||
|
truth file (schema above). Returns `204` on success, or `400` if
|
||||||
|
`schema_version` is not `1`.
|
||||||
|
|
||||||
|
This "managed" truth data takes precedence over any sidecar
|
||||||
|
`Movie.jray.json` file for the same item, and is stored independently of the
|
||||||
|
media library filesystem.
|
||||||
|
|
||||||
|
### `DELETE /Plugins/JRay/Items/{itemId}/Truth`
|
||||||
|
|
||||||
|
Removes managed truth data for an item (idempotent, always returns `204`).
|
||||||
|
The item falls back to its sidecar truth file, if any, on subsequent reads.
|
||||||
|
Requires an administrator API key.
|
||||||
|
|
||||||
|
### `GET /Plugins/JRay/ClientScript`
|
||||||
|
|
||||||
|
Serves the pause-overlay script that JRay injects into the web client's
|
||||||
|
`index.html` (see below). Anonymous access.
|
||||||
|
|
||||||
|
## Web client pause overlay
|
||||||
|
|
||||||
|
Since Jellyfin has no plugin hook for player UI, JRay injects
|
||||||
|
`<script defer src="/Plugins/JRay/ClientScript"></script>` into the web
|
||||||
|
client's `index.html` on startup (idempotent, marked with
|
||||||
|
`<!-- jray-overlay -->`). The injected script listens for the video player's
|
||||||
|
pause event, calls `jray?t=` for the current item and timestamp, and renders
|
||||||
|
a small overlay listing on-screen actors. This can be disabled via the
|
||||||
|
plugin's "Enable pause overlay" setting, which also removes the injected
|
||||||
|
script.
|
||||||
26
build.yaml
26
build.yaml
@ -1,16 +1,22 @@
|
|||||||
---
|
---
|
||||||
name: "Template"
|
name: "JRay"
|
||||||
guid: "eb5d7894-8eef-4b36-aa6f-5d124e828ce1"
|
guid: "96a22d9d-23fd-49bb-8970-5e153817d223"
|
||||||
version: "1.0.0.0"
|
version: "0.0.0.0"
|
||||||
targetAbi: "10.9.0.0"
|
targetAbi: "10.9.0.0"
|
||||||
framework: "net8.0"
|
framework: "net9.0"
|
||||||
overview: "Short description about your plugin"
|
overview: "Shows which actors are on screen at any point in a movie"
|
||||||
description: >
|
description: >
|
||||||
This is a longer description that can span more than one
|
JRay reads "truth" files produced offline by the scene-actor-extraction
|
||||||
line and include details about your plugin.
|
pipeline (face detection + recognition) and exposes an API to query which
|
||||||
|
actors are visible on screen at a given timestamp in a movie, for building
|
||||||
|
an actor-overlay (Jellyfin "X-Ray") style feature.
|
||||||
category: "General"
|
category: "General"
|
||||||
owner: "jellyfin"
|
owner: "dtourolle"
|
||||||
artifacts:
|
artifacts:
|
||||||
- "Jellyfin.Plugin.Template.dll"
|
- "Jellyfin.Plugin.JRay.dll"
|
||||||
|
build_type: "dotnet"
|
||||||
|
dotnet_configuration: "Release"
|
||||||
|
dotnet_framework: "net9.0"
|
||||||
|
project: "Jellyfin.Plugin.JRay/Jellyfin.Plugin.JRay.csproj"
|
||||||
changelog: >
|
changelog: >
|
||||||
changelog
|
Initial scaffold
|
||||||
|
|||||||
11
manifest.json
Normal file
11
manifest.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"guid": "96a22d9d-23fd-49bb-8970-5e153817d223",
|
||||||
|
"name": "JRay",
|
||||||
|
"description": "JRay reads \"truth\" files produced offline by the scene-actor-extraction pipeline (face detection + recognition) and exposes an API to query which actors are visible on screen at a given timestamp in a movie, for an actor-overlay (Jellyfin \"X-Ray\") style feature.",
|
||||||
|
"overview": "Shows which actors are on screen at any point in a movie",
|
||||||
|
"owner": "dtourolle",
|
||||||
|
"category": "General",
|
||||||
|
"versions": []
|
||||||
|
}
|
||||||
|
]
|
||||||
Loading…
x
Reference in New Issue
Block a user