Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5015ffe2b4 | ||
|
|
697b934108 | ||
|
|
64a30bfa64 | ||
|
|
268a6ed2d0 | ||
|
|
8dadef4184 | ||
|
|
7245bcf1ef |
@@ -1,14 +1,5 @@
|
|||||||
name: Release Plugin
|
name: Release Plugin
|
||||||
|
|
||||||
# Releases are published to a *separate public* distribution repo, not this one.
|
|
||||||
# Gitea has no way to make individual release assets public while the source repo
|
|
||||||
# stays private -- repo visibility is all-or-nothing, confirmed against Gitea's own
|
|
||||||
# docs/issue tracker -- so the actual plugin zip + manifest.json live in
|
|
||||||
# PUBLIC_REPO_NAME below, kept in sync by this workflow. This (private source) repo
|
|
||||||
# never needs to be public for Jellyfin installs to work.
|
|
||||||
env:
|
|
||||||
PUBLIC_REPO_NAME: JellyfinSyncPlus-repo
|
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
@@ -32,6 +23,7 @@ jobs:
|
|||||||
id: meta
|
id: meta
|
||||||
run: |
|
run: |
|
||||||
echo REPO_OWNER=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $1}') >> $GITHUB_OUTPUT
|
echo REPO_OWNER=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $1}') >> $GITHUB_OUTPUT
|
||||||
|
echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}') >> $GITHUB_OUTPUT
|
||||||
echo VERSION=$(echo ${GITHUB_REF_NAME} | sed 's/^v//') >> $GITHUB_OUTPUT
|
echo VERSION=$(echo ${GITHUB_REF_NAME} | sed 's/^v//') >> $GITHUB_OUTPUT
|
||||||
cat $GITHUB_OUTPUT
|
cat $GITHUB_OUTPUT
|
||||||
|
|
||||||
@@ -46,40 +38,53 @@ jobs:
|
|||||||
docker cp jf-extract:/usr/lib/jellyfin/bin/Emby.Server.Implementations.dll lib/jellyfin-10.11.6/
|
docker cp jf-extract:/usr/lib/jellyfin/bin/Emby.Server.Implementations.dll lib/jellyfin-10.11.6/
|
||||||
docker rm jf-extract
|
docker rm jf-extract
|
||||||
|
|
||||||
- name: Build and package
|
- name: Build, package, update manifest
|
||||||
id: package
|
|
||||||
env:
|
env:
|
||||||
VERSION: ${{ steps.meta.outputs.VERSION }}
|
VERSION: ${{ steps.meta.outputs.VERSION }}
|
||||||
SERVER_URL: ${{ github.server_url }}
|
SERVER_URL: ${{ github.server_url }}
|
||||||
PUBLIC_REPO_OWNER: ${{ steps.meta.outputs.REPO_OWNER }}
|
REPO_OWNER: ${{ steps.meta.outputs.REPO_OWNER }}
|
||||||
PUBLIC_REPO_NAME: ${{ env.PUBLIC_REPO_NAME }}
|
REPO_NAME: ${{ steps.meta.outputs.REPO_NAME }}
|
||||||
run: dev/package-release.sh
|
run: dev/package-release.sh
|
||||||
|
|
||||||
- name: Create release and upload zip on the public repo
|
- name: Create Gitea release and upload zip
|
||||||
env:
|
env:
|
||||||
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
SERVER_URL: ${{ github.server_url }}
|
SERVER_URL: ${{ github.server_url }}
|
||||||
PUBLIC_REPO_OWNER: ${{ steps.meta.outputs.REPO_OWNER }}
|
REPO_OWNER: ${{ steps.meta.outputs.REPO_OWNER }}
|
||||||
|
REPO_NAME: ${{ steps.meta.outputs.REPO_NAME }}
|
||||||
VERSION: ${{ steps.meta.outputs.VERSION }}
|
VERSION: ${{ steps.meta.outputs.VERSION }}
|
||||||
TAG: ${{ github.ref_name }}
|
TAG: ${{ github.ref_name }}
|
||||||
ZIP_PATH: ${{ steps.package.outputs.ZIP_PATH }}
|
|
||||||
run: |
|
run: |
|
||||||
RELEASE_ID=$(curl -sf -X POST \
|
RELEASE_ID=$(curl -sf -X POST \
|
||||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\",\"body\":\"See build.yaml changelog / manifest.json for this version's notes.\"}" \
|
-d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\",\"body\":\"See build.yaml changelog / manifest.json for this version's notes.\"}" \
|
||||||
"${SERVER_URL}/api/v1/repos/${PUBLIC_REPO_OWNER}/${PUBLIC_REPO_NAME}/releases" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
|
"${SERVER_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
|
||||||
curl -sf -X POST \
|
curl -sf -X POST \
|
||||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
-F "attachment=@${ZIP_PATH}" \
|
-F "attachment=@release/JellyfinSyncPlus_${VERSION}.zip" \
|
||||||
"${SERVER_URL}/api/v1/repos/${PUBLIC_REPO_OWNER}/${PUBLIC_REPO_NAME}/releases/${RELEASE_ID}/assets?name=JellyfinSyncPlus_${VERSION}.zip"
|
"${SERVER_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}/assets?name=JellyfinSyncPlus_${VERSION}.zip"
|
||||||
|
|
||||||
- name: Publish manifest.json to the public repo
|
# manifest.json has to live at a stable URL on a normal branch (master) --
|
||||||
|
# that's the one URL admins add to Jellyfin once, and every future release
|
||||||
|
# just appends to it. The tag checkout above is detached HEAD, so switch to
|
||||||
|
# master to commit, carrying the just-updated manifest.json across.
|
||||||
|
- name: Commit updated manifest to master
|
||||||
env:
|
env:
|
||||||
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
SERVER_URL: ${{ github.server_url }}
|
SERVER_URL: ${{ github.server_url }}
|
||||||
PUBLIC_REPO_OWNER: ${{ steps.meta.outputs.REPO_OWNER }}
|
REPO_OWNER: ${{ steps.meta.outputs.REPO_OWNER }}
|
||||||
|
REPO_NAME: ${{ steps.meta.outputs.REPO_NAME }}
|
||||||
VERSION: ${{ steps.meta.outputs.VERSION }}
|
VERSION: ${{ steps.meta.outputs.VERSION }}
|
||||||
CHECKSUM: ${{ steps.package.outputs.CHECKSUM }}
|
run: |
|
||||||
SOURCE_URL: ${{ steps.package.outputs.SOURCE_URL }}
|
cp manifest.json /tmp/manifest.json
|
||||||
run: dev/publish-manifest.sh
|
git fetch origin master
|
||||||
|
git checkout master
|
||||||
|
cp /tmp/manifest.json manifest.json
|
||||||
|
git config user.name "gitea-actions"
|
||||||
|
git config user.email "actions@gitea.local"
|
||||||
|
git add manifest.json
|
||||||
|
git diff --cached --quiet && echo "No manifest changes to commit" && exit 0
|
||||||
|
git commit -m "Release ${VERSION}"
|
||||||
|
HOST=$(echo "${SERVER_URL}" | sed 's#https\?://##')
|
||||||
|
git push "https://gitea-actions:${GITEA_TOKEN}@${HOST}/${REPO_OWNER}/${REPO_NAME}.git" HEAD:master
|
||||||
|
|||||||
@@ -153,51 +153,34 @@ Two different loops -- do not conflate them:
|
|||||||
versioned zip + `manifest.json`, host via Gitea, add the repo URL in
|
versioned zip + `manifest.json`, host via Gitea, add the repo URL in
|
||||||
Jellyfin's dashboard. Mirrors MR-Discord's tag -> Gitea Actions -> build
|
Jellyfin's dashboard. Mirrors MR-Discord's tag -> Gitea Actions -> build
|
||||||
pipeline, just producing a plugin zip instead of a Docker image.
|
pipeline, just producing a plugin zip instead of a Docker image.
|
||||||
- **Source repo (`JellyfinSyncPlus`) stays private; a separate public repo
|
- **Repo (`JellyfinSyncPlus`) itself is public** -- briefly tried a
|
||||||
(`JellyfinSyncPlus-repo`) holds only `manifest.json` and release zips.**
|
separate private-source/public-distribution repo split (Gitea has no
|
||||||
Confirmed via Gitea's own docs/issue tracker: there's no way to make
|
way to make individual release assets public on a private repo; repo
|
||||||
individual release assets public while the repo itself stays private --
|
visibility is all-or-nothing there), but decided to just make this repo
|
||||||
repo visibility is all-or-nothing, covering raw files, releases, and API
|
public instead rather than maintain two repos. Manifest + releases live
|
||||||
access alike. So the two are fully decoupled: CI builds from the private
|
directly on this repo.
|
||||||
source repo (has the checkout + build context) but publishes the
|
|
||||||
release and updates the manifest on the separate public repo, using the
|
|
||||||
same `RELEASE_TOKEN` (Gitea personal access tokens are user-scoped, not
|
|
||||||
repo-scoped, so one token works against both repos as long as the
|
|
||||||
owning account has write access to each).
|
|
||||||
- `dev/package-release.sh` -- builds Release config, zips the DLL,
|
- `dev/package-release.sh` -- builds Release config, zips the DLL,
|
||||||
computes its MD5 checksum, derives the release download URL (pointing
|
computes its MD5 checksum, updates `manifest.json` (schema-verified
|
||||||
at the *public* repo). Live-tested locally twice (once before, once
|
against a real published Jellyfin plugin repo's actual file). Live-
|
||||||
after the public/private split) -- produces a real, valid zip + a
|
tested locally.
|
||||||
`manifest.json` schema-verified against a real published Jellyfin
|
- `dev/update_manifest.py` -- the manifest merge logic package-release.sh
|
||||||
plugin repo's actual file, not just assumed from memory.
|
calls; reads plugin metadata/changelog from `build.yaml`.
|
||||||
- `dev/publish-manifest.sh` -- fetches the public repo's current
|
|
||||||
`manifest.json` via Gitea's Contents API (or starts fresh on the very
|
|
||||||
first release), merges in this version via `dev/update_manifest.py`,
|
|
||||||
and pushes it back via the same API. No git clone of the public repo
|
|
||||||
needed.
|
|
||||||
- `.github/workflows/release.yml` -- on a `v*` tag push: builds
|
- `.github/workflows/release.yml` -- on a `v*` tag push: builds
|
||||||
(including `dev/extract-private-refs.sh`'s CI equivalent, pulling the
|
(including pulling the two private SDK assemblies straight from the
|
||||||
two private SDK assemblies straight from the pinned
|
pinned `linuxserver/jellyfin:10.11.6` image via `docker cp`, mirroring
|
||||||
`linuxserver/jellyfin:10.11.6` image via `docker cp`), packages,
|
`dev/extract-private-refs.sh` for CI), packages, creates a Gitea
|
||||||
creates a Gitea release + uploads the zip on the *public* repo, then
|
release with the zip attached, commits the updated `manifest.json`
|
||||||
publishes the manifest there too.
|
back to `master`.
|
||||||
- `.github/workflows/build.yml` -- plain build-check on every push/PR to
|
- `.github/workflows/build.yml` -- plain build-check on every push/PR to
|
||||||
`master`, mirroring MR-Discord's `build.yml` pattern.
|
`master`, mirroring MR-Discord's `build.yml` pattern.
|
||||||
- **Live-verified 2026-07-09, partially**: tagged and pushed `v0.2.0.0`
|
- **Live-verified 2026-07-09**: tagged and pushed `v0.2.0.0` -- confirmed
|
||||||
against the *first* version of this pipeline (before the public/private
|
the workflow ran, created a real Gitea release with the zip attached,
|
||||||
split existed) -- confirmed the workflow actually ran, created a real
|
and committed a real `manifest.json` back to `master`. Manifest URL:
|
||||||
Gitea release with the zip attached, and committed a real
|
`https://gitea.mrcynic.site/seer/JellyfinSyncPlus/raw/branch/master/manifest.json`.
|
||||||
`manifest.json`. That run published to the private source repo itself,
|
- Once installed, Jellyfin has no hot-reload -- same as local dev, a
|
||||||
which is exactly the mistake the public/private split above exists to
|
server restart is required. On the real k8s deployment that's the
|
||||||
fix; the updated, split version hasn't had a live tag-triggered CI run
|
"confirm with the user first" milestone action from the "Dev / test /
|
||||||
yet as of this note -- the local packaging half is tested, the
|
iterate workflow" section above, not a routine step.
|
||||||
`publish-manifest.sh` Contents API calls are syntax/logic-checked but
|
|
||||||
not yet exercised against a real Gitea instance.
|
|
||||||
- Once a plugin is actually installed from this repo, Jellyfin has no
|
|
||||||
hot-reload -- same as local dev, a server restart is required. On the
|
|
||||||
real k8s deployment that's the "confirm with the user first" milestone
|
|
||||||
action from the "Dev / test / iterate workflow" section above, not a
|
|
||||||
routine step.
|
|
||||||
|
|
||||||
## Known friction / risks (flagged going in, not discovered later)
|
## Known friction / risks (flagged going in, not discovered later)
|
||||||
|
|
||||||
@@ -378,6 +361,48 @@ modes the reverted features did.
|
|||||||
real drifted second device (only one browser session available this
|
real drifted second device (only one browser session available this
|
||||||
round, same recurring constraint as everything else that needs two real
|
round, same recurring constraint as everything else that needs two real
|
||||||
logins).
|
logins).
|
||||||
|
- **In-player overlay added 2026-07-09.** User-requested (correctly pushing
|
||||||
|
back on an earlier "not possible without patching jellyfin-web" claim --
|
||||||
|
plugins like InPlayerEpisodePreview prove the pattern exists): the stats
|
||||||
|
panel + sync button now also appear *inside the video player page*, not
|
||||||
|
just on the standalone stats page.
|
||||||
|
- Mechanism: an `IStartupFilter` registered from plugin DI adds
|
||||||
|
`SyncPlusIndexInjectionMiddleware` to the front of the pipeline, which
|
||||||
|
intercepts responses for `/web/` and `/web/index.html` and injects a
|
||||||
|
`<script src="/SyncPlus/Stats/Client.js" defer>` tag before `</body>`
|
||||||
|
**in the served response only** -- deliberately NOT the
|
||||||
|
write-to-index.html-on-disk approach other plugins default to, because
|
||||||
|
in the linuxserver image (dev and the real k8s deployment alike)
|
||||||
|
index.html is root-owned while Jellyfin runs unprivileged, so disk
|
||||||
|
writes fail with permission errors there (the known issue
|
||||||
|
InPlayerEpisodePreview's README warns about). Response interception has
|
||||||
|
no permission problem and leaves nothing behind on uninstall. The
|
||||||
|
middleware also strips `Accept-Encoding` on those requests (so
|
||||||
|
downstream compression can't garble the `</body>` marker) and drops
|
||||||
|
`ETag`/`Last-Modified` on modified responses (so browsers can't
|
||||||
|
cache-revalidate back to an uninjected copy).
|
||||||
|
- `GET /SyncPlus/Stats/Client.js` serves the overlay script
|
||||||
|
(`[AllowAnonymous]` -- script tags fetch without auth; the script itself
|
||||||
|
only calls auth-gated endpoints via `window.ApiClient.accessToken()`,
|
||||||
|
available because the script runs inside the real SPA).
|
||||||
|
- `GET /SyncPlus/Stats/Mine` -- new non-admin endpoint returning only the
|
||||||
|
*caller's own group's* stats (regular viewers aren't admins, and the
|
||||||
|
admin-gated all-groups endpoint would leak other users' sessions).
|
||||||
|
Returns `{"Group": null}`-shaped response when not in a group (nulls
|
||||||
|
are omitted by Jellyfin's JSON serializer, so the body is literally
|
||||||
|
`{}` -- the overlay JS handles `undefined` fine).
|
||||||
|
- Overlay design: deliberately does NOT hook into jellyfin-web's OSD
|
||||||
|
button DOM (minified class names churn across releases) -- it floats
|
||||||
|
its own fixed-position "SyncPlus" pill + panel, shown only while a
|
||||||
|
`<video>` element exists on the page. Survives web client updates at
|
||||||
|
the cost of not looking native.
|
||||||
|
- **Server-side verified 2026-07-09**: script tag confirmed present in
|
||||||
|
served `/web/index.html` and `/web/` (including with
|
||||||
|
`Accept-Encoding: gzip, br` requests), `Client.js` serves as
|
||||||
|
`application/javascript` and passes `node --check`, `Mine` returns 200
|
||||||
|
with token / 401 without. **Browser-side behavior (overlay appearing
|
||||||
|
during playback, panel polling, sync button) handed to the user to
|
||||||
|
test** -- not yet verified as of this note.
|
||||||
|
|
||||||
## Open questions (revisit before/at the relevant phase, not now)
|
## Open questions (revisit before/at the relevant phase, not now)
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
name: "JellyfinSyncPlus"
|
name: "JellyfinSyncPlus"
|
||||||
guid: "267dbfe9-bb9c-4eeb-97aa-f0449283cfe6"
|
guid: "267dbfe9-bb9c-4eeb-97aa-f0449283cfe6"
|
||||||
version: "0.2.0.0"
|
version: "0.3.0.0"
|
||||||
targetAbi: "10.11.6.0"
|
targetAbi: "10.11.6.0"
|
||||||
framework: "net9.0"
|
framework: "net9.0"
|
||||||
owner: "cynic"
|
owner: "cynic"
|
||||||
@@ -16,6 +16,6 @@ category: "General"
|
|||||||
artifacts:
|
artifacts:
|
||||||
- "JellyfinSyncPlus.dll"
|
- "JellyfinSyncPlus.dll"
|
||||||
changelog: >
|
changelog: >
|
||||||
0.2.0.0: SyncPlay stats-for-nerds page and manual one-shot "sync me to
|
0.3.0.0: SyncPlay stats-for-nerds page and manual one-shot "sync me to
|
||||||
group" button. No automatic playback intervention.
|
group" button. No automatic playback intervention.
|
||||||
0.1.0.0: Phase 0 scaffold. Trivial plugin, no functionality yet.
|
0.1.0.0: Phase 0 scaffold. Trivial plugin, no functionality yet.
|
||||||
|
|||||||
+17
-22
@@ -1,21 +1,18 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Builds and packages a tagged release. Used by .github/workflows/release.yml; safe to
|
# Builds, packages, and updates manifest.json for a tagged release. Used by
|
||||||
# run locally too for a dry run. Does NOT touch manifest.json -- that's published
|
# .github/workflows/release.yml; safe to run locally too for a dry run.
|
||||||
# separately to the public distribution repo by dev/publish-manifest.sh, since the zip
|
|
||||||
# this produces gets uploaded there too (release assets on a private repo can't be made
|
|
||||||
# public on their own in Gitea; see PLAN.md "Release distribution").
|
|
||||||
#
|
#
|
||||||
# Required env vars:
|
# Required env vars:
|
||||||
# VERSION -- e.g. 0.2.0.0 (must match build.yaml's version)
|
# VERSION -- e.g. 0.3.0.0 (must match build.yaml's version)
|
||||||
# SERVER_URL -- e.g. https://gitea.mrcynic.site
|
# SERVER_URL -- e.g. https://gitea.mrcynic.site
|
||||||
# PUBLIC_REPO_OWNER -- Gitea org/user the *public* distribution repo lives under
|
# REPO_OWNER -- Gitea org/user the repo lives under
|
||||||
# PUBLIC_REPO_NAME -- the public distribution repo's name (holds releases + manifest.json)
|
# REPO_NAME -- Gitea repo name
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
: "${VERSION:?VERSION env var required, e.g. 0.2.0.0}"
|
: "${VERSION:?VERSION env var required, e.g. 0.3.0.0}"
|
||||||
: "${SERVER_URL:?SERVER_URL env var required, e.g. https://gitea.mrcynic.site}"
|
: "${SERVER_URL:?SERVER_URL env var required, e.g. https://gitea.mrcynic.site}"
|
||||||
: "${PUBLIC_REPO_OWNER:?PUBLIC_REPO_OWNER env var required}"
|
: "${REPO_OWNER:?REPO_OWNER env var required}"
|
||||||
: "${PUBLIC_REPO_NAME:?PUBLIC_REPO_NAME env var required}"
|
: "${REPO_NAME:?REPO_NAME env var required}"
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
@@ -37,17 +34,15 @@ rm -f "$ZIP_PATH"
|
|||||||
(cd src/JellyfinSyncPlus/bin/Release/net9.0 && zip -j "$ROOT/$ZIP_PATH" JellyfinSyncPlus.dll)
|
(cd src/JellyfinSyncPlus/bin/Release/net9.0 && zip -j "$ROOT/$ZIP_PATH" JellyfinSyncPlus.dll)
|
||||||
|
|
||||||
CHECKSUM=$(md5sum "$ZIP_PATH" | awk '{print $1}')
|
CHECKSUM=$(md5sum "$ZIP_PATH" | awk '{print $1}')
|
||||||
SOURCE_URL="${SERVER_URL}/${PUBLIC_REPO_OWNER}/${PUBLIC_REPO_NAME}/releases/download/v${VERSION}/${ZIP_NAME}"
|
SOURCE_URL="${SERVER_URL}/${REPO_OWNER}/${REPO_NAME}/releases/download/v${VERSION}/${ZIP_NAME}"
|
||||||
|
|
||||||
|
python3 "$ROOT/dev/update_manifest.py" \
|
||||||
|
--build-yaml "$ROOT/build.yaml" \
|
||||||
|
--manifest "$ROOT/manifest.json" \
|
||||||
|
--version "$VERSION" \
|
||||||
|
--checksum "$CHECKSUM" \
|
||||||
|
--source-url "$SOURCE_URL"
|
||||||
|
|
||||||
echo "Packaged $ZIP_PATH"
|
echo "Packaged $ZIP_PATH"
|
||||||
echo "Checksum: $CHECKSUM"
|
echo "Checksum: $CHECKSUM"
|
||||||
echo "Source URL: $SOURCE_URL"
|
echo "Source URL: $SOURCE_URL"
|
||||||
|
|
||||||
# For the next workflow step to pick up without re-deriving them (no-op outside CI).
|
|
||||||
if [ -n "${GITHUB_OUTPUT:-}" ]; then
|
|
||||||
{
|
|
||||||
echo "CHECKSUM=${CHECKSUM}"
|
|
||||||
echo "SOURCE_URL=${SOURCE_URL}"
|
|
||||||
echo "ZIP_PATH=${ZIP_PATH}"
|
|
||||||
} >> "$GITHUB_OUTPUT"
|
|
||||||
fi
|
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Fetches the current manifest.json from the public distribution repo (or starts fresh
|
|
||||||
# if this is the first release), merges in this build's version entry, and pushes it
|
|
||||||
# back -- all via Gitea's Contents API, so this never needs a git clone of the public
|
|
||||||
# repo. Keeps the private source repo and the public distribution repo fully decoupled.
|
|
||||||
#
|
|
||||||
# Required env vars:
|
|
||||||
# GITEA_TOKEN, SERVER_URL, PUBLIC_REPO_OWNER, PUBLIC_REPO_NAME, VERSION, CHECKSUM, SOURCE_URL
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
: "${GITEA_TOKEN:?}"
|
|
||||||
: "${SERVER_URL:?}"
|
|
||||||
: "${PUBLIC_REPO_OWNER:?}"
|
|
||||||
: "${PUBLIC_REPO_NAME:?}"
|
|
||||||
: "${VERSION:?}"
|
|
||||||
: "${CHECKSUM:?}"
|
|
||||||
: "${SOURCE_URL:?}"
|
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
||||||
API="${SERVER_URL}/api/v1/repos/${PUBLIC_REPO_OWNER}/${PUBLIC_REPO_NAME}/contents/manifest.json"
|
|
||||||
WORKDIR=$(mktemp -d)
|
|
||||||
trap 'rm -rf "$WORKDIR"' EXIT
|
|
||||||
|
|
||||||
HTTP_CODE=$(curl -s -o "$WORKDIR/existing.json" -w "%{http_code}" \
|
|
||||||
-H "Authorization: token ${GITEA_TOKEN}" "${API}?ref=master")
|
|
||||||
|
|
||||||
if [ "$HTTP_CODE" = "200" ]; then
|
|
||||||
SHA=$(python3 -c 'import json; print(json.load(open("'"$WORKDIR"'/existing.json"))["sha"])')
|
|
||||||
python3 -c 'import json,base64
|
|
||||||
d = json.load(open("'"$WORKDIR"'/existing.json"))
|
|
||||||
open("'"$WORKDIR"'/manifest.json", "wb").write(base64.b64decode(d["content"]))'
|
|
||||||
echo "Fetched existing manifest.json (sha ${SHA})"
|
|
||||||
else
|
|
||||||
SHA=""
|
|
||||||
echo "[]" > "$WORKDIR/manifest.json"
|
|
||||||
echo "No existing manifest.json in ${PUBLIC_REPO_OWNER}/${PUBLIC_REPO_NAME} -- starting fresh (HTTP $HTTP_CODE)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
python3 "$ROOT/dev/update_manifest.py" \
|
|
||||||
--build-yaml "$ROOT/build.yaml" \
|
|
||||||
--manifest "$WORKDIR/manifest.json" \
|
|
||||||
--version "$VERSION" \
|
|
||||||
--checksum "$CHECKSUM" \
|
|
||||||
--source-url "$SOURCE_URL"
|
|
||||||
|
|
||||||
CONTENT_B64=$(base64 -w0 "$WORKDIR/manifest.json")
|
|
||||||
|
|
||||||
python3 -c 'import json,sys
|
|
||||||
body = {"content": sys.argv[1], "message": "Release " + sys.argv[2], "branch": "master"}
|
|
||||||
if sys.argv[3]:
|
|
||||||
body["sha"] = sys.argv[3]
|
|
||||||
json.dump(body, open(sys.argv[4], "w"))' "$CONTENT_B64" "$VERSION" "$SHA" "$WORKDIR/body.json"
|
|
||||||
|
|
||||||
METHOD="POST"
|
|
||||||
if [ -n "$SHA" ]; then
|
|
||||||
METHOD="PUT"
|
|
||||||
fi
|
|
||||||
|
|
||||||
curl -sf -X "$METHOD" \
|
|
||||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "@$WORKDIR/body.json" \
|
|
||||||
"$API" > /dev/null
|
|
||||||
|
|
||||||
echo "Published manifest.json version ${VERSION} to ${PUBLIC_REPO_OWNER}/${PUBLIC_REPO_NAME}"
|
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"guid": "267dbfe9-bb9c-4eeb-97aa-f0449283cfe6",
|
||||||
|
"name": "JellyfinSyncPlus",
|
||||||
|
"description": "Read-only live view of every active SyncPlay group's members -- drift, position, play method, bitrate, and codec info -- plus a manual \"sync me to group\" button for one-shot self-correction. Does not touch playback automatically: earlier attempts at automatic quality-forcing and automatic drift correction both caused real problems in live use and were reverted (see PLAN.md). Reachable via Dashboard -> Plugins -> JellyfinSyncPlus.",
|
||||||
|
"overview": "SyncPlay stats for nerds: live drift/playback view plus a manual one-shot sync button.",
|
||||||
|
"owner": "cynic",
|
||||||
|
"category": "General",
|
||||||
|
"versions": [
|
||||||
|
{
|
||||||
|
"version": "0.3.0.0",
|
||||||
|
"changelog": "0.3.0.0: SyncPlay stats-for-nerds page and manual one-shot \"sync me to group\" button. No automatic playback intervention. 0.1.0.0: Phase 0 scaffold. Trivial plugin, no functionality yet.",
|
||||||
|
"targetAbi": "10.11.6.0",
|
||||||
|
"sourceUrl": "https://gitea.mrcynic.site/seer/JellyfinSyncPlus/releases/download/v0.3.0.0/JellyfinSyncPlus_0.3.0.0.zip",
|
||||||
|
"checksum": "34e384fc5914e2c459f52a9a7bfd91a5",
|
||||||
|
"timestamp": "2026-07-09T15:11:18Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -2,6 +2,7 @@ using Jellyfin.Plugin.SyncPlus.SyncPlay;
|
|||||||
using MediaBrowser.Controller;
|
using MediaBrowser.Controller;
|
||||||
using MediaBrowser.Controller.Plugins;
|
using MediaBrowser.Controller.Plugins;
|
||||||
using MediaBrowser.Controller.SyncPlay;
|
using MediaBrowser.Controller.SyncPlay;
|
||||||
|
using Microsoft.AspNetCore.Hosting;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using ConcreteSyncPlayManager = Emby.Server.Implementations.SyncPlay.SyncPlayManager;
|
using ConcreteSyncPlayManager = Emby.Server.Implementations.SyncPlay.SyncPlayManager;
|
||||||
|
|
||||||
@@ -20,5 +21,10 @@ public class PluginServiceRegistrator : IPluginServiceRegistrator
|
|||||||
// RegisterServices runs after core's, so this registration wins for resolution.
|
// RegisterServices runs after core's, so this registration wins for resolution.
|
||||||
serviceCollection.AddSingleton<ConcreteSyncPlayManager>();
|
serviceCollection.AddSingleton<ConcreteSyncPlayManager>();
|
||||||
serviceCollection.AddSingleton<ISyncPlayManager, SyncPlusSyncPlayManager>();
|
serviceCollection.AddSingleton<ISyncPlayManager, SyncPlusSyncPlayManager>();
|
||||||
|
|
||||||
|
// Injects the in-player overlay script tag into served index.html -- see
|
||||||
|
// SyncPlusIndexInjection.cs for why this is a startup filter and why it
|
||||||
|
// intercepts responses instead of writing to the file on disk.
|
||||||
|
serviceCollection.AddSingleton<IStartupFilter, SyncPlusIndexInjectionStartupFilter>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.AspNetCore.Hosting;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.SyncPlus.SyncPlay;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers <see cref="SyncPlusIndexInjectionMiddleware"/> at the front of the request
|
||||||
|
/// pipeline. An <see cref="IStartupFilter"/> registered in DI is the one seam a plugin
|
||||||
|
/// has for adding middleware -- plugins can't touch Jellyfin's own <c>Startup.Configure</c>,
|
||||||
|
/// but startup filters are resolved from the final service provider when the pipeline is
|
||||||
|
/// built, and plugin <c>RegisterServices</c> runs early enough to land there. Same
|
||||||
|
/// technique the "File Transformation" plugin ecosystem uses.
|
||||||
|
/// </summary>
|
||||||
|
public class SyncPlusIndexInjectionStartupFilter : IStartupFilter
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next)
|
||||||
|
=> app =>
|
||||||
|
{
|
||||||
|
app.UseMiddleware<SyncPlusIndexInjectionMiddleware>();
|
||||||
|
next(app);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Injects a <c><script></c> tag for the plugin's in-player overlay
|
||||||
|
/// (<see cref="SyncPlusStatsController.GetClientScript"/>) into the *served*
|
||||||
|
/// <c>index.html</c> response, on the fly. Deliberately does not write to the file on
|
||||||
|
/// disk: in the linuxserver image (dev and the real k8s deployment alike) index.html is
|
||||||
|
/// owned by root while Jellyfin runs as an unprivileged user, so the disk-write approach
|
||||||
|
/// other plugins default to (e.g. InPlayerEpisodePreview) fails with permission errors
|
||||||
|
/// there. Intercepting the response instead has no permission problem and leaves nothing
|
||||||
|
/// behind if the plugin is removed.
|
||||||
|
/// </summary>
|
||||||
|
public class SyncPlusIndexInjectionMiddleware
|
||||||
|
{
|
||||||
|
private const string ScriptTag = "<script src=\"/SyncPlus/Stats/Client.js\" defer></script>";
|
||||||
|
|
||||||
|
private readonly RequestDelegate _next;
|
||||||
|
private readonly ILogger<SyncPlusIndexInjectionMiddleware> _logger;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="SyncPlusIndexInjectionMiddleware"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="next">The next middleware in the pipeline.</param>
|
||||||
|
/// <param name="logger">Instance of the <see cref="ILogger{SyncPlusIndexInjectionMiddleware}"/> interface.</param>
|
||||||
|
public SyncPlusIndexInjectionMiddleware(RequestDelegate next, ILogger<SyncPlusIndexInjectionMiddleware> logger)
|
||||||
|
{
|
||||||
|
_next = next;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Intercepts index.html responses and injects the overlay script tag.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="context">The HTTP context.</param>
|
||||||
|
/// <returns>A task.</returns>
|
||||||
|
public async Task InvokeAsync(HttpContext context)
|
||||||
|
{
|
||||||
|
var path = context.Request.Path.Value ?? string.Empty;
|
||||||
|
var isIndex = path.Equals("/web/", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| path.Equals("/web/index.html", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
if (!isIndex)
|
||||||
|
{
|
||||||
|
await _next(context).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Downstream middleware would otherwise gzip/brotli the body before it gets back
|
||||||
|
// to us, making the </body> marker unfindable. Dropping Accept-Encoding for this
|
||||||
|
// one request keeps the body plain; index.html is ~5KB, the loss is irrelevant.
|
||||||
|
context.Request.Headers.Remove("Accept-Encoding");
|
||||||
|
|
||||||
|
var originalBody = context.Response.Body;
|
||||||
|
using var buffer = new MemoryStream();
|
||||||
|
context.Response.Body = buffer;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _next(context).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var isHtml = context.Response.ContentType?.Contains("text/html", StringComparison.OrdinalIgnoreCase) ?? false;
|
||||||
|
if (context.Response.StatusCode == StatusCodes.Status200OK && isHtml)
|
||||||
|
{
|
||||||
|
var html = Encoding.UTF8.GetString(buffer.ToArray());
|
||||||
|
var injected = html.Replace("</body>", ScriptTag + "</body>", StringComparison.OrdinalIgnoreCase);
|
||||||
|
var bytes = Encoding.UTF8.GetBytes(injected);
|
||||||
|
|
||||||
|
if (injected.Length == html.Length)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("SyncPlus: served index.html contains no </body> tag, overlay script not injected");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The stale validators describe the unmodified file; leaving them would let
|
||||||
|
// a browser cache-revalidate its way back to an uninjected copy.
|
||||||
|
context.Response.Headers.Remove("ETag");
|
||||||
|
context.Response.Headers.Remove("Last-Modified");
|
||||||
|
context.Response.ContentLength = bytes.Length;
|
||||||
|
await originalBody.WriteAsync(bytes).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
buffer.Position = 0;
|
||||||
|
await buffer.CopyToAsync(originalBody).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
context.Response.Body = originalBody;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -65,6 +65,22 @@ public class SyncPlusStatsController : ControllerBase
|
|||||||
return Content(StatsPageHtml, "text/html");
|
return Content(StatsPageHtml, "text/html");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serves the in-player overlay script. A script tag pointing here gets injected
|
||||||
|
/// into the served <c>index.html</c> by <see cref="SyncPlusIndexInjectionMiddleware"/>,
|
||||||
|
/// so this runs inside the real jellyfin-web SPA with access to <c>window.ApiClient</c>
|
||||||
|
/// -- same reason the standalone stats page can read the login token. Anonymous
|
||||||
|
/// because script tags are fetched without auth headers; the script itself only
|
||||||
|
/// calls auth-gated endpoints.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>The client-side overlay script.</returns>
|
||||||
|
[HttpGet("Client.js")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
public ContentResult GetClientScript()
|
||||||
|
{
|
||||||
|
return Content(ClientScriptJs, "application/javascript");
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets a live snapshot of every active SyncPlay group's members and their drift
|
/// Gets a live snapshot of every active SyncPlay group's members and their drift
|
||||||
/// relative to whoever in the group is furthest behind.
|
/// relative to whoever in the group is furthest behind.
|
||||||
@@ -74,76 +90,109 @@ public class SyncPlusStatsController : ControllerBase
|
|||||||
[Authorize(Policy = Policies.RequiresElevation)]
|
[Authorize(Policy = Policies.RequiresElevation)]
|
||||||
public ActionResult<IReadOnlyList<GroupStatsDto>> GetStats()
|
public ActionResult<IReadOnlyList<GroupStatsDto>> GetStats()
|
||||||
{
|
{
|
||||||
var sessionsByDevice = _sessionManager.Sessions
|
var sessionsByDevice = GetSessionsByDevice();
|
||||||
.Where(s => !string.IsNullOrEmpty(s.DeviceId))
|
|
||||||
.GroupBy(s => s.DeviceId)
|
|
||||||
.ToDictionary(g => g.Key, g => g.First());
|
|
||||||
|
|
||||||
var result = new List<GroupStatsDto>();
|
var result = new List<GroupStatsDto>();
|
||||||
foreach (var (groupId, deviceIds) in _membershipStore.GetAllGroups())
|
foreach (var (groupId, deviceIds) in _membershipStore.GetAllGroups())
|
||||||
{
|
{
|
||||||
var members = new List<MemberStatsDto>();
|
result.Add(BuildGroupStats(groupId, deviceIds, sessionsByDevice));
|
||||||
long? laggardTicks = null;
|
|
||||||
|
|
||||||
foreach (var deviceId in deviceIds)
|
|
||||||
{
|
|
||||||
if (!sessionsByDevice.TryGetValue(deviceId, out var session))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
var positionTicks = session.PlayState?.PositionTicks;
|
|
||||||
if (positionTicks is not null && !(session.PlayState?.IsPaused ?? false))
|
|
||||||
{
|
|
||||||
laggardTicks = laggardTicks is null ? positionTicks : Math.Min(laggardTicks.Value, positionTicks.Value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var deviceId in deviceIds)
|
|
||||||
{
|
|
||||||
if (!sessionsByDevice.TryGetValue(deviceId, out var session))
|
|
||||||
{
|
|
||||||
members.Add(new MemberStatsDto { DeviceId = deviceId, Connected = false });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
var positionTicks = session.PlayState?.PositionTicks;
|
|
||||||
var transcoding = session.TranscodingInfo;
|
|
||||||
|
|
||||||
members.Add(new MemberStatsDto
|
|
||||||
{
|
|
||||||
DeviceId = deviceId,
|
|
||||||
Connected = true,
|
|
||||||
DeviceName = session.DeviceName,
|
|
||||||
UserName = session.UserName,
|
|
||||||
NowPlaying = session.NowPlayingItem?.Name,
|
|
||||||
PositionTicks = positionTicks,
|
|
||||||
PositionFormatted = positionTicks is null ? null : TimeSpan.FromTicks(positionTicks.Value).ToString(@"hh\:mm\:ss"),
|
|
||||||
DriftMs = positionTicks is null || laggardTicks is null
|
|
||||||
? null
|
|
||||||
: (positionTicks.Value - laggardTicks.Value) / TimeSpan.TicksPerMillisecond,
|
|
||||||
IsPaused = session.PlayState?.IsPaused,
|
|
||||||
PlayMethod = session.PlayState?.PlayMethod?.ToString(),
|
|
||||||
Bitrate = transcoding?.Bitrate,
|
|
||||||
VideoCodec = transcoding?.VideoCodec,
|
|
||||||
AudioCodec = transcoding?.AudioCodec,
|
|
||||||
Container = transcoding?.Container,
|
|
||||||
IsVideoDirect = transcoding is null ? null : transcoding.IsVideoDirect,
|
|
||||||
IsAudioDirect = transcoding is null ? null : transcoding.IsAudioDirect,
|
|
||||||
TranscodeReasons = transcoding is null ? null : transcoding.TranscodeReasons.ToString()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
result.Add(new GroupStatsDto
|
|
||||||
{
|
|
||||||
GroupId = groupId,
|
|
||||||
Members = members
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the live stats for the calling device's own SyncPlay group only. Unlike
|
||||||
|
/// <see cref="GetStats"/> this is open to any authenticated user, because it only
|
||||||
|
/// ever exposes the group the caller is themselves a member of -- it's what the
|
||||||
|
/// in-player overlay (served by <see cref="GetClientScript"/>) polls, and regular
|
||||||
|
/// viewers aren't admins.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>The caller's group stats, with <c>Group</c> null when not in a group.</returns>
|
||||||
|
[HttpGet("Mine")]
|
||||||
|
[Authorize]
|
||||||
|
public ActionResult<MyGroupStatsDto> GetMyStats()
|
||||||
|
{
|
||||||
|
var deviceId = User.Claims.FirstOrDefault(c => c.Type == DeviceIdClaimType)?.Value;
|
||||||
|
var groupId = string.IsNullOrEmpty(deviceId) ? null : _membershipStore.GetGroupForDevice(deviceId);
|
||||||
|
if (groupId is null || !_membershipStore.GetAllGroups().TryGetValue(groupId.Value, out var deviceIds))
|
||||||
|
{
|
||||||
|
return new MyGroupStatsDto { Group = null };
|
||||||
|
}
|
||||||
|
|
||||||
|
return new MyGroupStatsDto { Group = BuildGroupStats(groupId.Value, deviceIds, GetSessionsByDevice()) };
|
||||||
|
}
|
||||||
|
|
||||||
|
private Dictionary<string, SessionInfo> GetSessionsByDevice()
|
||||||
|
=> _sessionManager.Sessions
|
||||||
|
.Where(s => !string.IsNullOrEmpty(s.DeviceId))
|
||||||
|
.GroupBy(s => s.DeviceId)
|
||||||
|
.ToDictionary(g => g.Key, g => g.First());
|
||||||
|
|
||||||
|
private static GroupStatsDto BuildGroupStats(
|
||||||
|
Guid groupId,
|
||||||
|
IReadOnlyCollection<string> deviceIds,
|
||||||
|
Dictionary<string, SessionInfo> sessionsByDevice)
|
||||||
|
{
|
||||||
|
var members = new List<MemberStatsDto>();
|
||||||
|
long? laggardTicks = null;
|
||||||
|
|
||||||
|
foreach (var deviceId in deviceIds)
|
||||||
|
{
|
||||||
|
if (!sessionsByDevice.TryGetValue(deviceId, out var session))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var positionTicks = session.PlayState?.PositionTicks;
|
||||||
|
if (positionTicks is not null && !(session.PlayState?.IsPaused ?? false))
|
||||||
|
{
|
||||||
|
laggardTicks = laggardTicks is null ? positionTicks : Math.Min(laggardTicks.Value, positionTicks.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var deviceId in deviceIds)
|
||||||
|
{
|
||||||
|
if (!sessionsByDevice.TryGetValue(deviceId, out var session))
|
||||||
|
{
|
||||||
|
members.Add(new MemberStatsDto { DeviceId = deviceId, Connected = false });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var positionTicks = session.PlayState?.PositionTicks;
|
||||||
|
var transcoding = session.TranscodingInfo;
|
||||||
|
|
||||||
|
members.Add(new MemberStatsDto
|
||||||
|
{
|
||||||
|
DeviceId = deviceId,
|
||||||
|
Connected = true,
|
||||||
|
DeviceName = session.DeviceName,
|
||||||
|
UserName = session.UserName,
|
||||||
|
NowPlaying = session.NowPlayingItem?.Name,
|
||||||
|
PositionTicks = positionTicks,
|
||||||
|
PositionFormatted = positionTicks is null ? null : TimeSpan.FromTicks(positionTicks.Value).ToString(@"hh\:mm\:ss"),
|
||||||
|
DriftMs = positionTicks is null || laggardTicks is null
|
||||||
|
? null
|
||||||
|
: (positionTicks.Value - laggardTicks.Value) / TimeSpan.TicksPerMillisecond,
|
||||||
|
IsPaused = session.PlayState?.IsPaused,
|
||||||
|
PlayMethod = session.PlayState?.PlayMethod?.ToString(),
|
||||||
|
Bitrate = transcoding?.Bitrate,
|
||||||
|
VideoCodec = transcoding?.VideoCodec,
|
||||||
|
AudioCodec = transcoding?.AudioCodec,
|
||||||
|
Container = transcoding?.Container,
|
||||||
|
IsVideoDirect = transcoding is null ? null : transcoding.IsVideoDirect,
|
||||||
|
IsAudioDirect = transcoding is null ? null : transcoding.IsAudioDirect,
|
||||||
|
TranscodeReasons = transcoding is null ? null : transcoding.TranscodeReasons.ToString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new GroupStatsDto
|
||||||
|
{
|
||||||
|
GroupId = groupId,
|
||||||
|
Members = members
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// One-shot: seeks the calling device's own session to match whoever in its SyncPlay
|
/// One-shot: seeks the calling device's own session to match whoever in its SyncPlay
|
||||||
/// group is furthest behind (never the other direction -- if the caller is themselves
|
/// group is furthest behind (never the other direction -- if the caller is themselves
|
||||||
@@ -385,6 +434,164 @@ public class SyncPlusStatsController : ControllerBase
|
|||||||
</html>
|
</html>
|
||||||
""";
|
""";
|
||||||
|
|
||||||
|
// The in-player UI. Entry point is jellyfin-web's own player settings (cog) menu:
|
||||||
|
// we watch for the action sheet it opens and append a single native-looking menu
|
||||||
|
// item ("SyncPlay stats") that opens our own floating panel holding both the drift
|
||||||
|
// readout and the sync button. Just one item on purpose -- the sheet is measured and
|
||||||
|
// positioned before our async injection runs, so every added item risks pushing it
|
||||||
|
// off the bottom of the screen (which is exactly what happened with two). Depends on
|
||||||
|
// a small, verified slice of the minified client's DOM (.btnVideoOsdSettings,
|
||||||
|
// .actionSheet, .actionSheetScroller, .actionSheetMenuItem -- all confirmed present
|
||||||
|
// in the bundled 10.11.6 client, including the sheet's own delegated click handler
|
||||||
|
// that auto-closes it on any .actionSheetMenuItem click). If a future jellyfin-web
|
||||||
|
// rename breaks these, the failure mode is just "the item doesn't appear" -- nothing
|
||||||
|
// errors, and the standalone stats page still works.
|
||||||
|
private const string ClientScriptJs = """
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
if (window.__syncPlusInjected) { return; }
|
||||||
|
window.__syncPlusInjected = true;
|
||||||
|
|
||||||
|
function token() {
|
||||||
|
try { return window.ApiClient ? window.ApiClient.accessToken() : null; } catch (e) { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
var panel = document.createElement('div');
|
||||||
|
panel.id = 'syncPlusPanel';
|
||||||
|
panel.style.cssText = 'position:fixed;top:70px;left:12px;z-index:99999;display:none;font-family:sans-serif;font-size:13px;color:#ddd;background:rgba(0,0,0,.8);border:1px solid rgba(255,255,255,.15);border-radius:6px;padding:10px 12px;min-width:260px;';
|
||||||
|
panel.innerHTML =
|
||||||
|
'<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">' +
|
||||||
|
'<span style="color:#999;">SyncPlay stats</span>' +
|
||||||
|
'<button id="syncPlusClose" style="background:none;border:none;color:#999;cursor:pointer;font-size:14px;padding:0 2px;">✕</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div id="syncPlusRows">Not in a SyncPlay group.</div>' +
|
||||||
|
'<button id="syncPlusSyncBtn" style="margin-top:10px;background:#00a4dc;color:#fff;border:none;border-radius:3px;padding:6px 12px;cursor:pointer;font-size:12px;">Sync me to group</button>' +
|
||||||
|
'<div id="syncPlusResult" style="margin-top:6px;color:#999;font-size:12px;min-height:1em;"></div>';
|
||||||
|
document.body.appendChild(panel);
|
||||||
|
|
||||||
|
var rows = panel.querySelector('#syncPlusRows');
|
||||||
|
var result = panel.querySelector('#syncPlusResult');
|
||||||
|
var pollHandle = null;
|
||||||
|
|
||||||
|
function closePanel() {
|
||||||
|
panel.style.display = 'none';
|
||||||
|
if (pollHandle) { clearInterval(pollHandle); pollHandle = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
panel.querySelector('#syncPlusClose').addEventListener('click', closePanel);
|
||||||
|
|
||||||
|
function driftColor(ms) {
|
||||||
|
if (ms === null || ms === undefined) { return '#999'; }
|
||||||
|
if (ms >= 3000) { return '#ff6b6b'; }
|
||||||
|
if (ms >= 1000) { return '#ffb86b'; }
|
||||||
|
return '#6bd68b';
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(group) {
|
||||||
|
if (!group) {
|
||||||
|
rows.textContent = 'Not in a SyncPlay group.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var html = '';
|
||||||
|
(group.Members || []).forEach(function (m) {
|
||||||
|
if (!m.Connected) { return; }
|
||||||
|
var drift = m.DriftMs === null || m.DriftMs === undefined ? '-' : m.DriftMs + ' ms';
|
||||||
|
html += '<div style="display:flex;justify-content:space-between;gap:12px;padding:2px 0;">' +
|
||||||
|
'<span>' + (m.UserName || '?') + ' (' + (m.PlayMethod || '-') + (m.IsPaused ? ', paused' : '') + ')</span>' +
|
||||||
|
'<span style="color:' + driftColor(m.DriftMs) + ';">' + drift + '</span></div>';
|
||||||
|
});
|
||||||
|
rows.innerHTML = html || 'No connected members.';
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
var t = token();
|
||||||
|
if (!t) { return; }
|
||||||
|
fetch('/SyncPlus/Stats/Mine', { headers: { 'X-Emby-Token': t } })
|
||||||
|
.then(function (r) { if (!r.ok) { throw new Error('HTTP ' + r.status); } return r.json(); })
|
||||||
|
.then(function (d) { render(d.Group); })
|
||||||
|
.catch(function (e) { rows.textContent = 'Stats error: ' + e.message; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePanel() {
|
||||||
|
if (panel.style.display !== 'none') { closePanel(); return; }
|
||||||
|
result.textContent = '';
|
||||||
|
panel.style.display = 'block';
|
||||||
|
refresh();
|
||||||
|
pollHandle = setInterval(refresh, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
panel.querySelector('#syncPlusSyncBtn').addEventListener('click', function () {
|
||||||
|
var t = token();
|
||||||
|
if (!t) { result.textContent = 'Not logged in.'; return; }
|
||||||
|
result.textContent = 'Syncing...';
|
||||||
|
fetch('/SyncPlus/Stats/Sync', { method: 'POST', headers: { 'X-Emby-Token': t } })
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (d) { result.textContent = d.Message || 'Done.'; })
|
||||||
|
.catch(function (e) { result.textContent = 'Error: ' + e.message; });
|
||||||
|
});
|
||||||
|
|
||||||
|
// The cog menu is an action sheet built fresh on every open, so watch for it appearing
|
||||||
|
// shortly after a click on the OSD settings button and append a single item. Just one
|
||||||
|
// item (both stats + the sync button live in our own panel it opens) keeps the sheet
|
||||||
|
// from growing tall enough to overflow off-screen -- jellyfin measures and positions
|
||||||
|
// the sheet before our async injection runs, so every added item risks pushing content
|
||||||
|
// past the viewport bottom. The sheet's own delegated click handler closes it on any
|
||||||
|
// .actionSheetMenuItem click, so our item gets native close behavior for free.
|
||||||
|
var lastCogClick = 0;
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
if (e.target && e.target.closest && e.target.closest('.btnVideoOsdSettings')) {
|
||||||
|
lastCogClick = Date.now();
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
function makeItem(id, text, handler) {
|
||||||
|
var btn = document.createElement('button');
|
||||||
|
btn.setAttribute('is', 'emby-button');
|
||||||
|
btn.setAttribute('type', 'button');
|
||||||
|
btn.setAttribute('data-id', id);
|
||||||
|
btn.className = 'listItem listItem-button actionSheetMenuItem';
|
||||||
|
btn.innerHTML = '<div class="listItemBody actionsheetListItemBody"><div class="listItemBodyText actionSheetItemText">' + text + '</div></div>';
|
||||||
|
btn.addEventListener('click', handler);
|
||||||
|
return btn;
|
||||||
|
}
|
||||||
|
|
||||||
|
new MutationObserver(function (mutations) {
|
||||||
|
if (Date.now() - lastCogClick > 1500) { return; }
|
||||||
|
for (var i = 0; i < mutations.length; i++) {
|
||||||
|
var added = mutations[i].addedNodes;
|
||||||
|
for (var j = 0; j < added.length; j++) {
|
||||||
|
var node = added[j];
|
||||||
|
if (!(node instanceof HTMLElement)) { continue; }
|
||||||
|
var sheet = node.classList && node.classList.contains('actionSheet') ? node : node.querySelector && node.querySelector('.actionSheet');
|
||||||
|
if (!sheet || sheet.querySelector('[data-id="syncplus-stats"]')) { continue; }
|
||||||
|
var scroller = sheet.querySelector('.actionSheetScroller');
|
||||||
|
if (!scroller) { continue; }
|
||||||
|
scroller.appendChild(makeItem('syncplus-stats', 'SyncPlay stats', togglePanel));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).observe(document.body, { childList: true, subtree: true });
|
||||||
|
|
||||||
|
// Close the panel when playback ends -- the cog (and the group context) is gone.
|
||||||
|
setInterval(function () {
|
||||||
|
if (!document.querySelector('video') && panel.style.display !== 'none') {
|
||||||
|
closePanel();
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
})();
|
||||||
|
""";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Result of <see cref="GetMyStats"/>.
|
||||||
|
/// </summary>
|
||||||
|
public class MyGroupStatsDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the caller's group stats, or null when the caller isn't in a
|
||||||
|
/// SyncPlay group.
|
||||||
|
/// </summary>
|
||||||
|
public GroupStatsDto? Group { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Result of a <see cref="SyncMe"/> call.
|
/// Result of a <see cref="SyncMe"/> call.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
Reference in New Issue
Block a user