Add SyncPlay stats-for-nerds page, manual sync button, and release pipeline
Build Check / build (push) Successful in 29s
Release Plugin / release (push) Successful in 22s

Phase 0 scaffold plus everything since: quality negotiation, host-relay,
and active drift correction were each built and reverted after real bugs
in live use (see PLAN.md for the full account of each). What remains is
read-only SyncPlay observability (live drift/playback stats page) and a
one-shot manual "sync me to group" action, plus Phase 5 packaging/release
tooling (build.yaml, manifest.json generator, Gitea Actions release
workflow) to distribute it as a proper plugin repository.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-09 17:06:46 +02:00
co-authored by Claude Sonnet 5
parent cf8badcdda
commit b83074d55f
21 changed files with 1435 additions and 21 deletions
+18
View File
@@ -0,0 +1,18 @@
services:
jellyfin-dev:
image: linuxserver/jellyfin:10.11.6
container_name: jellyfinsyncplus-dev
environment:
- PUID=1000
- PGID=1000
- TZ=Etc/UTC
ports:
- "8096:8096"
volumes:
- ./config:/config
- ./plugins/JellyfinSyncPlus_0.1.0.0:/config/data/plugins/JellyfinSyncPlus_0.1.0.0
# Real video content for testing Phase 2 relay. Read-only: recordings here are
# never to be deleted or modified by this project, mounting :ro enforces that
# rather than just relying on discipline.
- /mnt/geforce:/media/geforce:ro
restart: "no"
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
# Extracts the private (non-NuGet) Jellyfin server assemblies our plugin needs
# a compile-time reference to -- see PLAN.md "Known friction / risks" for why.
# Re-run this after the pinned server image (dev/docker-compose.yml) changes.
set -euo pipefail
CONTAINER=jellyfinsyncplus-dev
JELLYFIN_VERSION=10.11.6
DEST="$(dirname "$0")/../lib/jellyfin-${JELLYFIN_VERSION}"
mkdir -p "$DEST"
docker cp "${CONTAINER}:/usr/lib/jellyfin/bin/Emby.Server.Implementations.dll" "$DEST/"
docker cp "${CONTAINER}:/usr/lib/jellyfin/bin/Jellyfin.Api.dll" "$DEST/"
echo "Extracted to $DEST"
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
# Builds, packages, and updates manifest.json for a tagged release. Used by
# .github/workflows/release.yml; safe to run locally too for a dry run.
#
# Required env vars:
# VERSION -- e.g. 0.2.0.0 (must match build.yaml's version)
# SERVER_URL -- e.g. https://gitea.mrcynic.site
# REPO_OWNER -- Gitea org/user the repo lives under
# REPO_NAME -- Gitea repo name
set -euo pipefail
: "${VERSION:?VERSION env var required, e.g. 0.2.0.0}"
: "${SERVER_URL:?SERVER_URL env var required, e.g. https://gitea.mrcynic.site}"
: "${REPO_OWNER:?REPO_OWNER env var required}"
: "${REPO_NAME:?REPO_NAME env var required}"
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
echo "Building Release configuration..."
dotnet build src/JellyfinSyncPlus/JellyfinSyncPlus.csproj -c Release
BUILD_VERSION=$(grep -oP '(?<=^version: ")[^"]*' build.yaml)
if [ "$BUILD_VERSION" != "$VERSION" ]; then
echo "ERROR: build.yaml version ($BUILD_VERSION) does not match requested VERSION ($VERSION)." >&2
echo "Update build.yaml's version field to match the git tag before releasing." >&2
exit 1
fi
mkdir -p release
ZIP_NAME="JellyfinSyncPlus_${VERSION}.zip"
ZIP_PATH="release/${ZIP_NAME}"
rm -f "$ZIP_PATH"
(cd src/JellyfinSyncPlus/bin/Release/net9.0 && zip -j "$ROOT/$ZIP_PATH" JellyfinSyncPlus.dll)
CHECKSUM=$(md5sum "$ZIP_PATH" | awk '{print $1}')
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 "Checksum: $CHECKSUM"
echo "Source URL: $SOURCE_URL"
@@ -0,0 +1,13 @@
{
"category": "General",
"name": "JellyfinSyncPlus",
"description": "Fixes SyncPlay drift via group quality negotiation, host-relay streaming, and active drift correction.",
"overview": "Fixes SyncPlay drift via group quality negotiation, host-relay streaming, and active drift correction.",
"owner": "cynic",
"version": "0.1.0.0",
"status": "Active",
"autoUpdate": false,
"targetAbi": "10.11.6.0",
"timestamp": "2026-07-09T00:00:00.0000000Z",
"guid": "267dbfe9-bb9c-4eeb-97aa-f0449283cfe6"
}
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""Appends (or replaces) this build's version entry in manifest.json, using build.yaml
for the plugin-level metadata (guid, name, description, etc.) and changelog. Run by
dev/package-release.sh; not meant to be invoked directly outside that flow."""
import argparse
import datetime
import json
import re
def parse_build_yaml(path):
# build.yaml is simple enough to not need a real YAML parser dependency.
text = open(path, encoding="utf-8").read()
fields = {}
for key in ("name", "guid", "targetAbi", "framework", "owner", "overview", "category"):
m = re.search(rf'^{key}:\s*"([^"]*)"', text, re.MULTILINE)
if m:
fields[key] = m.group(1)
def block(key):
m = re.search(rf'^{key}:\s*>\s*\n((?: .*\n?)+)', text, re.MULTILINE)
if not m:
return ""
lines = [line[2:].rstrip() for line in m.group(1).splitlines()]
return " ".join(line for line in lines if line).strip()
fields["description"] = block("description")
fields["changelog"] = block("changelog")
return fields
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--build-yaml", required=True)
parser.add_argument("--manifest", required=True)
parser.add_argument("--version", required=True)
parser.add_argument("--checksum", required=True)
parser.add_argument("--source-url", required=True)
args = parser.parse_args()
build = parse_build_yaml(args.build_yaml)
try:
with open(args.manifest, encoding="utf-8") as f:
manifest = json.load(f)
except FileNotFoundError:
manifest = []
entry = next((p for p in manifest if p.get("guid") == build["guid"]), None)
if entry is None:
entry = {
"guid": build["guid"],
"name": build["name"],
"description": build["description"],
"overview": build["overview"],
"owner": build["owner"],
"category": build["category"],
"versions": [],
}
manifest.append(entry)
else:
entry["description"] = build["description"]
entry["overview"] = build["overview"]
entry["owner"] = build["owner"]
entry["category"] = build["category"]
entry["versions"] = [v for v in entry["versions"] if v["version"] != args.version]
entry["versions"].insert(0, {
"version": args.version,
"changelog": build["changelog"],
"targetAbi": build["targetAbi"],
"sourceUrl": args.source_url,
"checksum": args.checksum,
"timestamp": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
})
with open(args.manifest, "w", encoding="utf-8") as f:
json.dump(manifest, f, indent=4)
f.write("\n")
print(f"Updated {args.manifest} with version {args.version}")
if __name__ == "__main__":
main()