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",
|
||||
"jellyfinLinuxDataDir": "$HOME/.local/share/jellyfin",
|
||||
// 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
|
||||
#
|
||||
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
|
||||
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>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<RootNamespace>Jellyfin.Plugin.Template</RootNamespace>
|
||||
<RootNamespace>Jellyfin.Plugin.JRay</RootNamespace>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<Nullable>enable</Nullable>
|
||||
@ -28,6 +28,8 @@
|
||||
<ItemGroup>
|
||||
<None Remove="Configuration\configPage.html" />
|
||||
<EmbeddedResource Include="Configuration\configPage.html" />
|
||||
<None Remove="Web\jray-overlay.js" />
|
||||
<EmbeddedResource Include="Web\jray-overlay.js" />
|
||||
</ItemGroup>
|
||||
|
||||
</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.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;
|
||||
|
||||
/// <summary>
|
||||
/// The main plugin.
|
||||
/// </summary>
|
||||
public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
||||
{
|
||||
private readonly ILogger<Plugin> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="Plugin"/> class.
|
||||
/// </summary>
|
||||
/// <param name="applicationPaths">Instance of the <see cref="IApplicationPaths"/> 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)
|
||||
{
|
||||
Instance = this;
|
||||
_logger = logger;
|
||||
|
||||
WebClientPatchService.Apply(ApplicationPaths, Configuration.EnableOverlay, _logger);
|
||||
ConfigurationChanged += (_, _) => WebClientPatchService.Apply(ApplicationPaths, Configuration.EnableOverlay, _logger);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Name => "Template";
|
||||
public override string Name => "JRay";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Guid Id => Guid.Parse("eb5d7894-8eef-4b36-aa6f-5d124e828ce1");
|
||||
public override Guid Id => Guid.Parse("96a22d9d-23fd-49bb-8970-5e153817d223");
|
||||
|
||||
/// <summary>
|
||||
/// 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"
|
||||
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
|
||||
|
||||
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