0.2.64 update
This commit is contained in:
+23
-3
@@ -47,10 +47,10 @@ MEMBY_REMOTE_CONFIG_JSON=
|
||||
# openssl rand -hex 32
|
||||
MEMBY_ADMIN_TOKEN=4fad67d508558efee5cc5ae05694105421d4c79d35ee2333a817b3791235cd8c
|
||||
|
||||
# Public gateway address and a dedicated token used only by the Gitea release workflow.
|
||||
# Generate the token with: openssl rand -hex 32
|
||||
# Public gateway address. Release publishing uses the file-backed secret in
|
||||
# MEMBY_SECRETS_DIR, never a value in this environment file.
|
||||
MEMBY_PUBLIC_URL=https://mserver.sublogue.com
|
||||
MEMBY_RELEASE_PUBLISH_TOKEN=4b0a891382f056d6ab2ddb43ad08fbbadda0a5a7db9371c7d90fd57b38d083d2
|
||||
MEMBY_SECRETS_DIR=/share/Docker/Memby-secrets
|
||||
|
||||
# Library import. Hourly incremental keeps up with episodes added through the day.
|
||||
MEMBY_SYNC_INTERVAL=1h
|
||||
@@ -117,6 +117,26 @@ MEMBY_TRACEARR_API_KEY=trr_pub_WKSdiZFGZ_10d-zgQ2Wx0H4Ym4NAiIeLBSvf8a6rvZ0
|
||||
MEMBY_TRACEARR_SERVER_ID=6964b9ed-3a21-4f51-b94b-cd344ec42c1b
|
||||
MEMBY_TRACEARR_SYNC_INTERVAL=5m
|
||||
MEMBY_TRACEARR_FULL_INTERVAL=24h
|
||||
|
||||
# Credits detection: where an episode's closing credits begin, discovered for the few
|
||||
# episodes the household is actually about to watch. Tracearr is what says which those are,
|
||||
# so this never scans the library — it prepares a sliding window a few episodes ahead of
|
||||
# each viewer and stops. An episode is scanned once per media version and never again.
|
||||
#
|
||||
# It is the only thing in the gateway that reads media bytes, which is why it is off by
|
||||
# default. With ffmpeg absent it still runs, writing markers from where viewers actually
|
||||
# stopped — which on a well-watched show is the better signal anyway.
|
||||
MEMBY_CREDITS_ENABLED=true
|
||||
# Leave blank to find ffmpeg on the path, which is where the image puts it.
|
||||
MEMBY_CREDITS_FFMPEG=
|
||||
# Look-ahead for an ordinary viewer. A binge expands it, a slow viewer contracts it, and
|
||||
# MAX_PREFETCH is the ceiling nothing exceeds however fast somebody watches.
|
||||
MEMBY_CREDITS_PREFETCH_EPISODES=3
|
||||
MEMBY_CREDITS_MAX_PREFETCH=5
|
||||
# Pending candidates. Past this, low-priority speculation is discarded rather than queued:
|
||||
# a backlog of episodes nobody reached has stopped describing demand.
|
||||
MEMBY_CREDITS_QUEUE_LIMIT=20
|
||||
|
||||
MEMBY_FOR_YOU_MIN_REBUILD_AGE=24h
|
||||
MEMBY_FOR_YOU_REFRESH_INTERVAL=24h
|
||||
MEMBY_FOR_YOU_REBUILD_HOUR=4
|
||||
|
||||
@@ -57,6 +57,14 @@ jobs:
|
||||
./gradlew --console=plain testDebugUnitTest assembleRelease \
|
||||
-Pmemby.versionName="$VERSION"
|
||||
|
||||
- name: Verify signed APK
|
||||
shell: bash
|
||||
run: |
|
||||
"$ANDROID_HOME/build-tools/35.0.0/apksigner" verify --verbose --print-certs \
|
||||
app/build/outputs/apk/release/app-release.apk
|
||||
echo "APK_SHA256=$(sha256sum app/build/outputs/apk/release/app-release.apk | awk '{print $1}')" \
|
||||
>> "$GITEA_ENV"
|
||||
|
||||
- name: Publish to Memby gateway
|
||||
shell: bash
|
||||
env:
|
||||
@@ -67,6 +75,7 @@ jobs:
|
||||
-X POST \
|
||||
-H "Authorization: Bearer $RELEASE_TOKEN" \
|
||||
-F "version=$VERSION" \
|
||||
-F "sha256=$APK_SHA256" \
|
||||
-F "notes=$NOTES" \
|
||||
-F "apk=@app/build/outputs/apk/release/app-release.apk;type=application/vnd.android.package-archive" \
|
||||
https://mserver.sublogue.com/admin/api/release
|
||||
|
||||
+12
@@ -23,8 +23,20 @@ local.properties
|
||||
# Release artefacts and signing material. The keystore must never be committed:
|
||||
# whoever holds it can publish an update that installs over Memby.
|
||||
/dist/out/
|
||||
*.apk
|
||||
*.jks
|
||||
*.keystore
|
||||
*.p12
|
||||
*.pfx
|
||||
*.password
|
||||
*.secret
|
||||
/secrets/
|
||||
|
||||
# Local environment variants may contain host paths or release settings. The two
|
||||
# checked-in examples contain names/placeholders only.
|
||||
.env.*
|
||||
!.env.example
|
||||
!builder.env.example
|
||||
|
||||
# The installed APK a deploy script pulls off a television to compare against.
|
||||
/.tmp-memby-installed-base.apk
|
||||
|
||||
+2
-7
@@ -1,10 +1,8 @@
|
||||
## 0.2.64 — 2026-08-14
|
||||
- Added: Movies and TV Shows now have their own curated featured heroes, independently selected and scheduled by the Memby server.
|
||||
- Added: Films and individual episodes can be reported from their detail pages, with permitted viewers able to request a controlled replacement copy.
|
||||
- Improved: Media reports, replacement progress and related actions are visible to operators in the Admin Console and notification system.
|
||||
- Bug fixes & general improvements.
|
||||
|
||||
## 0.2.63 — 2026-08-14
|
||||
- App bug fixes
|
||||
- Bug fixes & general improvements.
|
||||
|
||||
## 0.2.62 — 2026-08-13
|
||||
- Fixed: Playback is back to the way it worked in 0.2.60. The groundwork for Memby playing video itself caused problems, so it has been withdrawn and will return once it is ready.
|
||||
@@ -246,6 +244,3 @@
|
||||
- Added: Personalised "Why you might enjoy it" suggestions on film and show pages.
|
||||
- Improved: Film and series pages now share one layout.
|
||||
- Improved: Home screen rows can change without updating the app.
|
||||
|
||||
## 0.1.42 — 2026-08-14
|
||||
- Improved: Server Logs keeps its headings visible and shows each structured log record’s full details, including gateway version, in an expandable view.
|
||||
|
||||
@@ -80,6 +80,7 @@ docker compose up -d --build # from the repo root; needs .env (see .env.e
|
||||
```powershell
|
||||
.\deploy-server.ps1 # local tree -> 10.0.0.213:/share/Docker/Memby
|
||||
.\deploy-server.ps1 -SourceDirectory C:\src\memby -Destination /share/Docker/Memby-test
|
||||
.\deploy-server.ps1 -SkipAppRelease -SkipBuilder # server/admin-only; reuse Android builder image
|
||||
```
|
||||
|
||||
It tars the local `server/`, `docker-compose.yml` and `.env.example`, and streams them over
|
||||
@@ -87,7 +88,9 @@ one SSH connection (interactive password; stdin carries the
|
||||
archive, so OpenSSH prompts on the tty). The remote half stages into
|
||||
`<destination>.new.$$`, builds, then swaps directories and waits for all three health
|
||||
checks, restoring the previous release if anything fails. The named Postgres volume is
|
||||
preserved — it never runs `compose down -v`.
|
||||
preserved — it never runs `compose down -v`. For a server/admin-only change, combine
|
||||
`-SkipAppRelease -SkipBuilder`: this skips the local APK build and preserves the running
|
||||
Android builder container and image while only the gateway and admin console are replaced.
|
||||
|
||||
**`.env.example` is the configuration.** It holds real values, and every deployment
|
||||
overwrites the NAS's `.env` with the local copy (the old one is kept beside it as
|
||||
@@ -126,13 +129,24 @@ patch` (0.1.53 → 153). Bump both together — the in-app updater compares `ver
|
||||
while Android refuses an APK whose `versionCode` went backwards. `release.ps1 -Version`
|
||||
rewrites both, so prefer it over editing the build file by hand.
|
||||
|
||||
**Releases.** APKs are self-hosted (NAS or any web server), not on a store. `release.ps1`
|
||||
builds a signed APK and assembles `dist/out/` — `index.html` (landing page from
|
||||
`dist/template/`), `latest.json` (the manifest the app polls) and the versioned APK.
|
||||
Release signing reads `memby.keystore` and friends from `local.properties`; with no
|
||||
keystore the build still succeeds but emits an unsigned APK and logs a warning. The key
|
||||
matters more than the code: Android identifies an app by applicationId **plus** signing
|
||||
key, so a changed key forces every user to uninstall and reinstall.
|
||||
**Releases.** APKs are self-hosted (NAS or any web server), not on a store. The preferred
|
||||
The normal NAS path is Admin Console → App updates → **Build latest release**. The isolated
|
||||
service under `builder/` fetches the highest semantic GitHub tag, builds that exact source,
|
||||
verifies the APK and publishes it through the gateway into the shared `memby-releases`
|
||||
volume. An exact tag may be selected as a deliberate fallback, and
|
||||
`docker compose run --rm --build memby-builder release` remains the controller-independent
|
||||
command-line fallback. Its keystore, signing credentials and release-publish token are
|
||||
read-only Compose secrets under `/run/secrets`; the Gradle cache is a named volume. See
|
||||
`builder/README.md`.
|
||||
|
||||
The existing `release.ps1` compatibility path builds a signed APK and assembles
|
||||
`dist/out/` — `index.html` (landing page from `dist/template/`), `latest.json` (the manifest
|
||||
the app polls), the versioned APK and its `.sha256` checksum. Local signing reads
|
||||
`memby.keystore` and friends from `local.properties`; with no keystore the build still
|
||||
succeeds but emits an unsigned APK and logs a warning. The key matters more than the code:
|
||||
Android identifies an app by
|
||||
applicationId **plus** signing key, so a changed key forces every user to uninstall and
|
||||
reinstall.
|
||||
|
||||
Every version bump is also a publish operation: update the version and changelog together,
|
||||
commit the complete release, and push it to GitHub. Never leave a bumped version only in the
|
||||
@@ -611,6 +625,10 @@ pieces make that true and each is easy to undo:
|
||||
`json`; the ring buffer the admin page reads is fed the same records in every format.
|
||||
That ring is restored from a bounded JSONL archive in the persistent `memby-logs`
|
||||
volume, so a deployment replaces the process without erasing the operator's history.
|
||||
History compaction materialises the ordered ring only once per ringful; do not move
|
||||
that copy back onto every append. Cursor reads calculate their ring offset directly.
|
||||
The Admin Console retains and filters the full delivered window but virtualises the
|
||||
display, caches each record's formatted/searchable form and stops polling while hidden.
|
||||
- `internal/api/logcontext.go` carries a **`*requestIdentity` in the request context**.
|
||||
`withLogging` creates it from the route and the client headers; `authed` fills in the
|
||||
viewer and television once the session resolves; both the handler's own events
|
||||
|
||||
@@ -201,7 +201,29 @@ memby.keyPassword=…
|
||||
Without these, `assembleRelease` still builds but the APK is unsigned and will not
|
||||
install. The build prints a warning saying so.
|
||||
|
||||
### Each release
|
||||
### Docker release builder (recommended)
|
||||
|
||||
The backend Compose project includes an isolated `memby-builder` service. It keeps the
|
||||
Android SDK out of the gateway image, fetches the newest semantic GitHub tag, builds with
|
||||
the tagged source's Gradle wrapper, signs with the existing externally mounted keystore,
|
||||
verifies the APK and publishes it into the gateway's existing release volume and update
|
||||
policy. Admin Console → App updates provides a **Build latest release** button, optional
|
||||
exact-tag fallback, required-update confirmation, live status and build output.
|
||||
|
||||
After the one-time secret-file setup in [`builder/README.md`](builder/README.md), use the
|
||||
Admin Console button. The Linux/NAS command-line fallback remains:
|
||||
|
||||
```sh
|
||||
docker compose run --rm --build memby-builder release
|
||||
```
|
||||
|
||||
The signed APK and its checksum are stored in the `memby-releases` volume at
|
||||
`/data/releases/memby-<version>.apk` and
|
||||
`/data/releases/memby-<version>.apk.sha256`. Gradle downloads are retained in the
|
||||
`memby-gradle-cache` volume. The keystore and credentials remain read-only Compose secrets
|
||||
under `/run/secrets` and are never copied into the image or repository.
|
||||
|
||||
### Local PowerShell release (compatibility path)
|
||||
|
||||
```powershell
|
||||
.\release.ps1 -Version 0.1.54 -Notes "Faster home screen" `
|
||||
@@ -209,13 +231,15 @@ install. The build prints a warning saying so.
|
||||
-SourceUrl https://g.sublogue.com/admin/memby
|
||||
```
|
||||
|
||||
That bumps `versionName`/`versionCode`, runs the tests, builds a signed APK, and fills
|
||||
That existing workflow still bumps `versionName`/`versionCode`, runs the tests, builds a
|
||||
signed APK, and fills
|
||||
`dist/out/` with:
|
||||
|
||||
```
|
||||
index.html the page people are sent to
|
||||
latest.json the update manifest the app polls
|
||||
memby-0.1.54.apk the build
|
||||
memby-0.1.54.apk.sha256 the SHA-256 checksum
|
||||
LICENSE GNU GPL v2 terms
|
||||
NOTICE copyright and third-party acknowledgements
|
||||
```
|
||||
|
||||
+12
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
-11
File diff suppressed because one or more lines are too long
+1
-1
@@ -56,4 +56,4 @@ Error generating stack: `+i.message+`
|
||||
* LICENSE.md file in the root directory of this source tree.
|
||||
*
|
||||
* @license MIT
|
||||
*/function fl(){return fl=Object.assign?Object.assign.bind():function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)({}).hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e},fl.apply(null,arguments)}function jc(e,t){if(e==null)return{};var n={};for(var r in e)if({}.hasOwnProperty.call(e,r)){if(t.indexOf(r)!==-1)continue;n[r]=e[r]}return n}function nh(e){return!!(e.metaKey||e.altKey||e.ctrlKey||e.shiftKey)}function rh(e,t){return e.button===0&&(!t||t==="_self")&&!nh(e)}const lh=["onClick","relative","reloadDocument","replace","state","target","to","preventScrollReset","viewTransition"],ih=["aria-current","caseSensitive","className","end","style","to","viewTransition","children"],uh="6";try{window.__reactRouterVersion=uh}catch{}const oh=k.createContext({isTransitioning:!1}),ah="startTransition",ma=lf[ah];function Sh(e){let{basename:t,children:n,future:r,window:l}=e,i=k.useRef();i.current==null&&(i.current=cp({window:l,v5Compat:!0}));let u=i.current,[o,a]=k.useState({action:u.action,location:u.location}),{v7_startTransition:s}=r||{},h=k.useCallback(p=>{s&&ma?ma(()=>a(p)):a(p)},[a,s]);return k.useLayoutEffect(()=>u.listen(h),[u,h]),k.useEffect(()=>bp(r),[r]),k.createElement(th,{basename:t,children:n,location:o.location,navigationType:o.action,navigator:u,future:r})}const sh=typeof window<"u"&&typeof window.document<"u"&&typeof window.document.createElement<"u",ch=/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i,fh=k.forwardRef(function(t,n){let{onClick:r,relative:l,reloadDocument:i,replace:u,state:o,target:a,to:s,preventScrollReset:h,viewTransition:p}=t,m=jc(t,lh),{basename:g}=k.useContext(Je),y,w=!1;if(typeof s=="string"&&ch.test(s)&&(y=s,sh))try{let d=new URL(window.location.href),v=s.startsWith("//")?new URL(d.protocol+s):new URL(s),E=sn(v.pathname,g);v.origin===d.origin&&E!=null?s=E+v.search+v.hash:w=!0}catch{}let C=Fp(s,{relative:l}),f=ph(s,{replace:u,state:o,target:a,preventScrollReset:h,relative:l,viewTransition:p});function c(d){r&&r(d),d.defaultPrevented||f(d)}return k.createElement("a",fl({},m,{href:y||C,onClick:w||i?r:c,ref:n,target:a}))}),kh=k.forwardRef(function(t,n){let{"aria-current":r="page",caseSensitive:l=!1,className:i="",end:u=!1,style:o,to:a,viewTransition:s,children:h}=t,p=jc(t,ih),m=zl(a,{relative:p.relative}),g=mn(),y=k.useContext(Rc),{navigator:w,basename:C}=k.useContext(Je),f=y!=null&&hh(m)&&s===!0,c=w.encodeLocation?w.encodeLocation(m).pathname:m.pathname,d=g.pathname,v=y&&y.navigation&&y.navigation.location?y.navigation.location.pathname:null;l||(d=d.toLowerCase(),v=v?v.toLowerCase():null,c=c.toLowerCase()),v&&C&&(v=sn(v,C)||v);const E=c!=="/"&&c.endsWith("/")?c.length-1:c.length;let _=d===c||!u&&d.startsWith(c)&&d.charAt(E)==="/",N=v!=null&&(v===c||!u&&v.startsWith(c)&&v.charAt(c.length)==="/"),L={isActive:_,isPending:N,isTransitioning:f},$=_?r:void 0,T;typeof i=="function"?T=i(L):T=[i,_?"active":null,N?"pending":null,f?"transitioning":null].filter(Boolean).join(" ");let me=typeof o=="function"?o(L):o;return k.createElement(fh,fl({},p,{"aria-current":$,className:T,ref:n,style:me,to:a,viewTransition:s}),typeof h=="function"?h(L):h)});var tu;(function(e){e.UseScrollRestoration="useScrollRestoration",e.UseSubmit="useSubmit",e.UseSubmitFetcher="useSubmitFetcher",e.UseFetcher="useFetcher",e.useViewTransitionState="useViewTransitionState"})(tu||(tu={}));var va;(function(e){e.UseFetcher="useFetcher",e.UseFetchers="useFetchers",e.UseScrollRestoration="useScrollRestoration"})(va||(va={}));function dh(e){let t=k.useContext(Nl);return t||A(!1),t}function ph(e,t){let{target:n,replace:r,state:l,preventScrollReset:i,relative:u,viewTransition:o}=t===void 0?{}:t,a=Ic(),s=mn(),h=zl(e,{relative:u});return k.useCallback(p=>{if(rh(p,n)){p.preventDefault();let m=r!==void 0?r:cl(s)===cl(h);a(e,{replace:m,state:l,preventScrollReset:i,relative:u,viewTransition:o})}},[s,a,h,r,l,n,e,i,u,o])}function hh(e,t){t===void 0&&(t={});let n=k.useContext(oh);n==null&&A(!1);let{basename:r}=dh(tu.useViewTransitionState),l=zl(e,{relative:t.relative});if(!n.isTransitioning)return!1;let i=sn(n.currentLocation.pathname,r)||n.currentLocation.pathname,u=sn(n.nextLocation.pathname,r)||n.nextLocation.pathname;return bi(l.pathname,u)!=null||bi(l.pathname,i)!=null}export{Sh as B,fh as L,kh as N,gh as O,wh as R,mh as a,mn as b,vh as c,eh as d,yh as e,k as r,Ic as u};
|
||||
*/function fl(){return fl=Object.assign?Object.assign.bind():function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)({}).hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e},fl.apply(null,arguments)}function jc(e,t){if(e==null)return{};var n={};for(var r in e)if({}.hasOwnProperty.call(e,r)){if(t.indexOf(r)!==-1)continue;n[r]=e[r]}return n}function nh(e){return!!(e.metaKey||e.altKey||e.ctrlKey||e.shiftKey)}function rh(e,t){return e.button===0&&(!t||t==="_self")&&!nh(e)}const lh=["onClick","relative","reloadDocument","replace","state","target","to","preventScrollReset","viewTransition"],ih=["aria-current","caseSensitive","className","end","style","to","viewTransition","children"],uh="6";try{window.__reactRouterVersion=uh}catch{}const oh=k.createContext({isTransitioning:!1}),ah="startTransition",ma=lf[ah];function Sh(e){let{basename:t,children:n,future:r,window:l}=e,i=k.useRef();i.current==null&&(i.current=cp({window:l,v5Compat:!0}));let u=i.current,[o,a]=k.useState({action:u.action,location:u.location}),{v7_startTransition:s}=r||{},h=k.useCallback(p=>{s&&ma?ma(()=>a(p)):a(p)},[a,s]);return k.useLayoutEffect(()=>u.listen(h),[u,h]),k.useEffect(()=>bp(r),[r]),k.createElement(th,{basename:t,children:n,location:o.location,navigationType:o.action,navigator:u,future:r})}const sh=typeof window<"u"&&typeof window.document<"u"&&typeof window.document.createElement<"u",ch=/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i,fh=k.forwardRef(function(t,n){let{onClick:r,relative:l,reloadDocument:i,replace:u,state:o,target:a,to:s,preventScrollReset:h,viewTransition:p}=t,m=jc(t,lh),{basename:g}=k.useContext(Je),y,w=!1;if(typeof s=="string"&&ch.test(s)&&(y=s,sh))try{let d=new URL(window.location.href),v=s.startsWith("//")?new URL(d.protocol+s):new URL(s),E=sn(v.pathname,g);v.origin===d.origin&&E!=null?s=E+v.search+v.hash:w=!0}catch{}let C=Fp(s,{relative:l}),f=ph(s,{replace:u,state:o,target:a,preventScrollReset:h,relative:l,viewTransition:p});function c(d){r&&r(d),d.defaultPrevented||f(d)}return k.createElement("a",fl({},m,{href:y||C,onClick:w||i?r:c,ref:n,target:a}))}),kh=k.forwardRef(function(t,n){let{"aria-current":r="page",caseSensitive:l=!1,className:i="",end:u=!1,style:o,to:a,viewTransition:s,children:h}=t,p=jc(t,ih),m=zl(a,{relative:p.relative}),g=mn(),y=k.useContext(Rc),{navigator:w,basename:C}=k.useContext(Je),f=y!=null&&hh(m)&&s===!0,c=w.encodeLocation?w.encodeLocation(m).pathname:m.pathname,d=g.pathname,v=y&&y.navigation&&y.navigation.location?y.navigation.location.pathname:null;l||(d=d.toLowerCase(),v=v?v.toLowerCase():null,c=c.toLowerCase()),v&&C&&(v=sn(v,C)||v);const E=c!=="/"&&c.endsWith("/")?c.length-1:c.length;let _=d===c||!u&&d.startsWith(c)&&d.charAt(E)==="/",N=v!=null&&(v===c||!u&&v.startsWith(c)&&v.charAt(c.length)==="/"),L={isActive:_,isPending:N,isTransitioning:f},$=_?r:void 0,T;typeof i=="function"?T=i(L):T=[i,_?"active":null,N?"pending":null,f?"transitioning":null].filter(Boolean).join(" ");let me=typeof o=="function"?o(L):o;return k.createElement(fh,fl({},p,{"aria-current":$,className:T,ref:n,style:me,to:a,viewTransition:s}),typeof h=="function"?h(L):h)});var tu;(function(e){e.UseScrollRestoration="useScrollRestoration",e.UseSubmit="useSubmit",e.UseSubmitFetcher="useSubmitFetcher",e.UseFetcher="useFetcher",e.useViewTransitionState="useViewTransitionState"})(tu||(tu={}));var va;(function(e){e.UseFetcher="useFetcher",e.UseFetchers="useFetchers",e.UseScrollRestoration="useScrollRestoration"})(va||(va={}));function dh(e){let t=k.useContext(Nl);return t||A(!1),t}function ph(e,t){let{target:n,replace:r,state:l,preventScrollReset:i,relative:u,viewTransition:o}=t===void 0?{}:t,a=Ic(),s=mn(),h=zl(e,{relative:u});return k.useCallback(p=>{if(rh(p,n)){p.preventDefault();let m=r!==void 0?r:cl(s)===cl(h);a(e,{replace:m,state:l,preventScrollReset:i,relative:u,viewTransition:o})}},[s,a,h,r,l,n,e,i,u,o])}function hh(e,t){t===void 0&&(t={});let n=k.useContext(oh);n==null&&A(!1);let{basename:r}=dh(tu.useViewTransitionState),l=zl(e,{relative:t.relative});if(!n.isTransitioning)return!1;let i=sn(n.currentLocation.pathname,r)||n.currentLocation.pathname,u=sn(n.nextLocation.pathname,r)||n.nextLocation.pathname;return bi(l.pathname,u)!=null||bi(l.pathname,i)!=null}export{Sh as B,fh as L,kh as N,gh as O,wh as R,mh as a,mn as b,vh as c,eh as d,yh as e,bi as m,k as r,Ic as u};
|
||||
-1
@@ -1 +0,0 @@
|
||||
import"./router-BwjLFE7Y.js";
|
||||
+1
@@ -0,0 +1 @@
|
||||
import"./router-D9WH5XEU.js";
|
||||
Vendored
+5
-4
@@ -2,19 +2,20 @@
|
||||
<html lang="en-NZ">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<!-- The console is behind an admin token and is nobody's search result. -->
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<meta name="theme-color" content="#010409" />
|
||||
<title>Memby admin</title>
|
||||
<!-- An SVG favicon, inline, because the console must fetch nothing from anywhere. -->
|
||||
<link
|
||||
rel="icon"
|
||||
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ccircle cx='16' cy='16' r='16' fill='%2352b54b'/%3E%3Ctext x='16' y='23' font-family='system-ui,sans-serif' font-size='19' font-weight='800' text-anchor='middle' fill='%2306240a'%3EM%3C/text%3E%3C/svg%3E"
|
||||
/>
|
||||
<script type="module" crossorigin src="/admin/assets/index-Ol_zP3CI.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/admin/assets/router-BwjLFE7Y.js">
|
||||
<link rel="stylesheet" crossorigin href="/admin/assets/index-CXwJRCVF.css">
|
||||
<script type="module" crossorigin src="/admin/assets/index-Ax7UuMTz.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/admin/assets/router-D9WH5XEU.js">
|
||||
<link rel="stylesheet" crossorigin href="/admin/assets/index-DSlxKU2t.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
+2
-1
@@ -2,10 +2,11 @@
|
||||
<html lang="en-NZ">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<!-- The console is behind an admin token and is nobody's search result. -->
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<meta name="theme-color" content="#010409" />
|
||||
<title>Memby admin</title>
|
||||
<!-- An SVG favicon, inline, because the console must fetch nothing from anywhere. -->
|
||||
<link
|
||||
|
||||
@@ -101,6 +101,18 @@ export interface HeroPolicy {
|
||||
primeSubtitle: string;
|
||||
placements?: Record<HeroPlacement, HeroPlacementPolicy>;
|
||||
schedules?: HeroSchedule[] | null;
|
||||
timeZone?: string;
|
||||
}
|
||||
|
||||
export interface ReleaseBuilderStatus {
|
||||
state: 'idle' | 'running' | 'succeeded' | 'failed';
|
||||
tag?: string;
|
||||
mandatory: boolean;
|
||||
startedAt?: string;
|
||||
finishedAt?: string;
|
||||
message?: string;
|
||||
logs: string[] | null;
|
||||
fallback: string;
|
||||
}
|
||||
|
||||
export type HeroPlacement = 'home' | 'movies' | 'tv_shows';
|
||||
@@ -136,9 +148,12 @@ export interface RequestUsage {
|
||||
export interface HeroSchedule {
|
||||
id: string;
|
||||
itemId: string;
|
||||
startAt: string;
|
||||
endAt: string;
|
||||
startAt?: string;
|
||||
endAt?: string;
|
||||
weekdays?: number[];
|
||||
frequency?: 'daily' | 'weekly';
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
priority: number;
|
||||
userId?: string;
|
||||
enabled: boolean;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { NavLink, Outlet, useLocation } from 'react-router-dom';
|
||||
import { matchPath, NavLink, Outlet, useLocation } from 'react-router-dom';
|
||||
import { Icon } from './Icon';
|
||||
import { OmniSearch } from './OmniSearch';
|
||||
import { NotificationBell } from './NotificationBell';
|
||||
@@ -29,15 +29,40 @@ function Rail({ open, onNavigate }: { open: boolean; onNavigate: () => void }) {
|
||||
}
|
||||
});
|
||||
|
||||
const toggle = (id: string) => {
|
||||
setCollapsed((current) => {
|
||||
const next = { ...current, [id]: !current[id] };
|
||||
const storeCollapsed = (next: Record<string, boolean>) => {
|
||||
try {
|
||||
localStorage.setItem('memby-admin-nav', JSON.stringify(next));
|
||||
} catch {
|
||||
// Private-mode storage refusals cost the memory of which groups were open, and
|
||||
// Private-mode storage refusals cost the memory of which group is open, and
|
||||
// nothing else.
|
||||
}
|
||||
};
|
||||
|
||||
// The labelled groups are an accordion. Following a destination opens its group and
|
||||
// folds the one the operator has just left, including navigation from search or Back.
|
||||
useEffect(() => {
|
||||
const activeGroup = nav.find((group) =>
|
||||
group.items.some((item) => matchPath({ path: item.path, end: true }, location.pathname)),
|
||||
);
|
||||
if (!activeGroup) return;
|
||||
setCollapsed((current) => {
|
||||
const next = { ...current };
|
||||
nav.forEach((group) => {
|
||||
if (group.collapsible !== false) next[group.id] = group.id !== activeGroup.id;
|
||||
});
|
||||
storeCollapsed(next);
|
||||
return next;
|
||||
});
|
||||
}, [location.pathname]);
|
||||
|
||||
const toggle = (id: string, defaultCollapsed = false) => {
|
||||
setCollapsed((current) => {
|
||||
const next = { ...current };
|
||||
const collapseSelected = !(current[id] ?? defaultCollapsed);
|
||||
nav.forEach((group) => {
|
||||
if (group.collapsible !== false) next[group.id] = group.id === id ? collapseSelected : true;
|
||||
});
|
||||
storeCollapsed(next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
@@ -47,20 +72,25 @@ function Rail({ open, onNavigate }: { open: boolean; onNavigate: () => void }) {
|
||||
{nav.map((group) => {
|
||||
const items = group.items.filter((item) => !item.hidden);
|
||||
if (items.length === 0) return null;
|
||||
const holdsCurrent = items.some((item) => location.pathname === item.path);
|
||||
const expanded = holdsCurrent || !collapsed[group.id];
|
||||
const holdsCurrent = items.some((item) =>
|
||||
matchPath({ path: item.path, end: true }, location.pathname),
|
||||
);
|
||||
const collapsible = group.collapsible !== false;
|
||||
const expanded = holdsCurrent || !collapsible || !(collapsed[group.id] ?? group.defaultCollapsed ?? false);
|
||||
return (
|
||||
<div className="rail-group" key={group.id}>
|
||||
{group.label ? (
|
||||
{group.label && collapsible ? (
|
||||
<button
|
||||
type="button"
|
||||
className="rail-head"
|
||||
aria-expanded={expanded}
|
||||
onClick={() => toggle(group.id)}
|
||||
onClick={() => toggle(group.id, group.defaultCollapsed)}
|
||||
>
|
||||
{group.label}
|
||||
<Icon name="caret" className="ico caret" />
|
||||
</button>
|
||||
) : group.label ? (
|
||||
<div className="rail-head rail-head-static">{group.label}</div>
|
||||
) : null}
|
||||
{expanded
|
||||
? items.map((item) => (
|
||||
@@ -124,6 +154,23 @@ export function Layout() {
|
||||
// it sits over the page that was just opened.
|
||||
useEffect(() => setRailOpen(false), [location.pathname]);
|
||||
|
||||
// An open tablet drawer is modal navigation: keep the page underneath still and let
|
||||
// Escape close it. The media query decides whether the rail is overlaid; applying this
|
||||
// on desktop is harmless because the desktop rail cannot be opened by a hidden button.
|
||||
useEffect(() => {
|
||||
if (!railOpen) return;
|
||||
const previous = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') setRailOpen(false);
|
||||
};
|
||||
window.addEventListener('keydown', closeOnEscape);
|
||||
return () => {
|
||||
document.body.style.overflow = previous;
|
||||
window.removeEventListener('keydown', closeOnEscape);
|
||||
};
|
||||
}, [railOpen]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<a className="skip" href="#main">
|
||||
@@ -178,6 +225,14 @@ export function Layout() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{railOpen ? (
|
||||
<button
|
||||
type="button"
|
||||
className="rail-scrim"
|
||||
aria-label="Close sections"
|
||||
onClick={() => setRailOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
<Rail open={railOpen} onNavigate={() => setRailOpen(false)} />
|
||||
|
||||
<main className="page" id="main">
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { type ReactNode, useEffect, useId, useRef, useState } from 'react';
|
||||
import { matchPath, useLocation } from 'react-router-dom';
|
||||
import { Glyph, Icon, type IconName } from './Icon';
|
||||
import type { Tone } from '../lib/format';
|
||||
import { allNavItems } from '../nav';
|
||||
|
||||
/* The component vocabulary. Every page is built from what is below and adds nothing of its
|
||||
own: a screen that needs a look it cannot get from here is a missing component, not a
|
||||
@@ -13,20 +15,29 @@ export function PageHead({
|
||||
intro,
|
||||
actions,
|
||||
crumbs,
|
||||
icon,
|
||||
}: {
|
||||
title: string;
|
||||
intro?: string;
|
||||
actions?: ReactNode;
|
||||
crumbs?: ReactNode;
|
||||
icon?: IconName;
|
||||
}) {
|
||||
const location = useLocation();
|
||||
const pageIcon = icon ?? allNavItems.find((item) =>
|
||||
matchPath({ path: item.path, end: true }, location.pathname),
|
||||
)?.icon;
|
||||
return (
|
||||
<header className="page-head">
|
||||
{crumbs ? <nav className="crumbs">{crumbs}</nav> : null}
|
||||
<div className="page-head-row">
|
||||
<div>
|
||||
<div className="page-head-title">
|
||||
{pageIcon ? <span className="page-head-icon" aria-hidden="true"><Icon name={pageIcon} /></span> : null}
|
||||
<div className="page-head-text">
|
||||
<h1>{title}</h1>
|
||||
{intro ? <p>{intro}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
{actions ? <div className="page-head-actions">{actions}</div> : null}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
+95
-84
@@ -9,7 +9,9 @@ import type { IconName } from './components/Icon';
|
||||
* console for any /admin path and the routing is entirely here.
|
||||
*
|
||||
* `hidden` marks a destination that is reachable and titled but not in the rail: a page
|
||||
* about one person or one television belongs to the thing it is about, not to a menu. */
|
||||
* about one person or one television belongs to the thing it is about, not to a menu.
|
||||
* Everyday is deliberately frequency-based; the remaining sections are organised by the
|
||||
* operator's question and begin folded so the common routes never scroll out of reach. */
|
||||
|
||||
export interface NavItem {
|
||||
id: string;
|
||||
@@ -27,12 +29,19 @@ export interface NavItem {
|
||||
export interface NavGroup {
|
||||
id: string;
|
||||
label?: string;
|
||||
/** Less-frequent sections begin folded, but still open whenever they hold the current
|
||||
* page. An operator's explicit choice is remembered and takes precedence. */
|
||||
defaultCollapsed?: boolean;
|
||||
/** A group may opt out of the accordion when its links must remain visible. */
|
||||
collapsible?: boolean;
|
||||
items: NavItem[];
|
||||
}
|
||||
|
||||
export const nav: NavGroup[] = [
|
||||
{
|
||||
id: 'overview',
|
||||
id: 'everyday',
|
||||
label: 'Everyday',
|
||||
defaultCollapsed: false,
|
||||
items: [
|
||||
{
|
||||
id: 'overview',
|
||||
@@ -51,12 +60,6 @@ export const nav: NavGroup[] = [
|
||||
icon: 'bell',
|
||||
badge: 'notifications',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'people',
|
||||
label: 'People',
|
||||
items: [
|
||||
{
|
||||
id: 'accounts',
|
||||
path: '/admin/accounts',
|
||||
@@ -65,12 +68,52 @@ export const nav: NavGroup[] = [
|
||||
intro: 'Who uses Memby, and the devices they are signed in on.',
|
||||
icon: 'people',
|
||||
},
|
||||
{
|
||||
id: 'requests',
|
||||
path: '/admin/requests',
|
||||
label: 'Media requests',
|
||||
title: 'Media requests',
|
||||
intro: 'Who can ask for something the library does not have.',
|
||||
icon: 'inbox',
|
||||
},
|
||||
{
|
||||
id: 'media-reports',
|
||||
path: '/admin/media-reports',
|
||||
label: 'Media reports',
|
||||
title: 'Media reports',
|
||||
intro: 'Problems viewers reported with a film or episode.',
|
||||
icon: 'alert',
|
||||
},
|
||||
{
|
||||
id: 'updates',
|
||||
path: '/admin/updates',
|
||||
label: 'App updates',
|
||||
title: 'App updates',
|
||||
intro: 'Publish an optional or a required client update.',
|
||||
icon: 'upload',
|
||||
},
|
||||
{
|
||||
id: 'logs',
|
||||
path: '/admin/logs',
|
||||
label: 'Server logs',
|
||||
title: 'Server logs',
|
||||
intro: 'Structured gateway events as they happen.',
|
||||
icon: 'list',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'people',
|
||||
label: 'Devices & access',
|
||||
defaultCollapsed: true,
|
||||
items: [
|
||||
{
|
||||
id: 'account',
|
||||
path: '/admin/accounts/:userId',
|
||||
label: 'User',
|
||||
title: 'User',
|
||||
intro: 'Devices, recommendation setup and synced settings for one person.',
|
||||
icon: 'person',
|
||||
hidden: true,
|
||||
},
|
||||
{
|
||||
@@ -79,6 +122,7 @@ export const nav: NavGroup[] = [
|
||||
label: 'Settings history',
|
||||
title: 'Settings history',
|
||||
intro: "Every change to one person's synced settings, and which devices took it.",
|
||||
icon: 'sliders',
|
||||
hidden: true,
|
||||
},
|
||||
{
|
||||
@@ -103,13 +147,15 @@ export const nav: NavGroup[] = [
|
||||
label: 'Device',
|
||||
title: 'Device',
|
||||
intro: 'One television: how often it connects, at what times, and from which addresses.',
|
||||
icon: 'tv',
|
||||
hidden: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'content',
|
||||
label: 'Content',
|
||||
label: 'Content & discovery',
|
||||
defaultCollapsed: true,
|
||||
items: [
|
||||
{
|
||||
id: 'library',
|
||||
@@ -120,27 +166,14 @@ export const nav: NavGroup[] = [
|
||||
icon: 'library',
|
||||
},
|
||||
{
|
||||
id: 'ratings',
|
||||
path: '/admin/ratings',
|
||||
label: 'Movie ratings',
|
||||
title: 'Movie ratings',
|
||||
intro: 'Optional MDBList scores on films and shows.',
|
||||
id: 'hero',
|
||||
path: '/admin/hero',
|
||||
label: 'Home hero',
|
||||
title: 'Home hero',
|
||||
intro:
|
||||
'Choose films or television shows for the launcher spotlight while recent releases fill the remaining places.',
|
||||
icon: 'star',
|
||||
},
|
||||
{
|
||||
id: 'requests',
|
||||
path: '/admin/requests',
|
||||
label: 'Media requests',
|
||||
title: 'Media requests',
|
||||
intro: 'Who can ask for something the library does not have.',
|
||||
icon: 'inbox',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'personalisation',
|
||||
label: 'Personalisation',
|
||||
items: [
|
||||
{
|
||||
id: 'recommendations',
|
||||
path: '/admin/recommendations',
|
||||
@@ -149,6 +182,14 @@ export const nav: NavGroup[] = [
|
||||
intro: 'The prepared pools personalised rows are drawn from.',
|
||||
icon: 'sparkle',
|
||||
},
|
||||
{
|
||||
id: 'ratings',
|
||||
path: '/admin/ratings',
|
||||
label: 'Movie ratings',
|
||||
title: 'Movie ratings',
|
||||
intro: 'Optional MDBList scores on films and shows.',
|
||||
icon: 'star',
|
||||
},
|
||||
{
|
||||
id: 'inspector',
|
||||
path: '/admin/inspector',
|
||||
@@ -161,17 +202,9 @@ export const nav: NavGroup[] = [
|
||||
},
|
||||
{
|
||||
id: 'experience',
|
||||
label: 'Experience',
|
||||
label: 'Viewing experience',
|
||||
defaultCollapsed: true,
|
||||
items: [
|
||||
{
|
||||
id: 'hero',
|
||||
path: '/admin/hero',
|
||||
label: 'Home hero',
|
||||
title: 'Home hero',
|
||||
intro:
|
||||
'Choose films or television shows for the launcher spotlight while recent releases fill the remaining places.',
|
||||
icon: 'star',
|
||||
},
|
||||
{
|
||||
id: 'features',
|
||||
path: '/admin/features',
|
||||
@@ -196,19 +229,12 @@ export const nav: NavGroup[] = [
|
||||
intro: 'Which providers a viewer may fetch a missing subtitle from.',
|
||||
icon: 'captions',
|
||||
},
|
||||
{
|
||||
id: 'updates',
|
||||
path: '/admin/updates',
|
||||
label: 'App updates',
|
||||
title: 'App updates',
|
||||
intro: 'Publish an optional or a required client update.',
|
||||
icon: 'upload',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'operations',
|
||||
label: 'Operations',
|
||||
defaultCollapsed: true,
|
||||
items: [
|
||||
{
|
||||
id: 'tasks',
|
||||
@@ -219,12 +245,12 @@ export const nav: NavGroup[] = [
|
||||
icon: 'clock',
|
||||
},
|
||||
{
|
||||
id: 'integrations',
|
||||
path: '/admin/integrations',
|
||||
label: 'Integrations',
|
||||
title: 'Integrations',
|
||||
intro: 'Send administrative events to Discord and, in time, elsewhere.',
|
||||
icon: 'plug',
|
||||
id: 'imports',
|
||||
path: '/admin/imports',
|
||||
label: 'Imports',
|
||||
title: 'Imports',
|
||||
intro: 'Catalogue synchronisation history.',
|
||||
icon: 'database',
|
||||
},
|
||||
{
|
||||
id: 'maintenance',
|
||||
@@ -235,35 +261,20 @@ export const nav: NavGroup[] = [
|
||||
icon: 'wrench',
|
||||
},
|
||||
{
|
||||
id: 'imports',
|
||||
path: '/admin/imports',
|
||||
label: 'Imports',
|
||||
title: 'Imports',
|
||||
intro: 'Catalogue synchronisation history.',
|
||||
icon: 'database',
|
||||
},
|
||||
{
|
||||
id: 'logs',
|
||||
path: '/admin/logs',
|
||||
label: 'Server logs',
|
||||
title: 'Server logs',
|
||||
intro: 'Structured gateway events as they happen.',
|
||||
icon: 'list',
|
||||
id: 'integrations',
|
||||
path: '/admin/integrations',
|
||||
label: 'Integrations',
|
||||
title: 'Integrations',
|
||||
intro: 'Send administrative events to Discord and, in time, elsewhere.',
|
||||
icon: 'plug',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'reporting',
|
||||
label: 'Reporting',
|
||||
id: 'insights',
|
||||
label: 'Insights',
|
||||
defaultCollapsed: true,
|
||||
items: [
|
||||
{
|
||||
id: 'media-reports',
|
||||
path: '/admin/media-reports',
|
||||
label: 'Media reports',
|
||||
title: 'Media reports',
|
||||
intro: 'Problems viewers reported with a film or episode.',
|
||||
icon: 'inbox',
|
||||
},
|
||||
{
|
||||
id: 'views',
|
||||
path: '/admin/views',
|
||||
@@ -272,6 +283,14 @@ export const nav: NavGroup[] = [
|
||||
intro: 'Home-screen visits, viewers and the times Memby is used.',
|
||||
icon: 'overview',
|
||||
},
|
||||
{
|
||||
id: 'searches',
|
||||
path: '/admin/searches',
|
||||
label: 'Searches',
|
||||
title: 'Searches',
|
||||
intro: 'What the household has been looking for, and what it searched just now.',
|
||||
icon: 'search',
|
||||
},
|
||||
{
|
||||
id: 'journeys',
|
||||
path: '/admin/journeys',
|
||||
@@ -288,14 +307,6 @@ export const nav: NavGroup[] = [
|
||||
intro: 'Impressions, focus, dwell and selections per launcher row.',
|
||||
icon: 'chart',
|
||||
},
|
||||
{
|
||||
id: 'searches',
|
||||
path: '/admin/searches',
|
||||
label: 'Searches',
|
||||
title: 'Searches',
|
||||
intro: 'What the household has been looking for, and what it searched just now.',
|
||||
icon: 'search',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -68,6 +68,17 @@ interface RecommendationState {
|
||||
contentTypes?: string[];
|
||||
}
|
||||
|
||||
interface NotificationPreferences {
|
||||
enabled: boolean;
|
||||
showReturnAlerts: boolean;
|
||||
sonarrAlerts: boolean;
|
||||
radarrAlerts: boolean;
|
||||
updateAlerts: boolean;
|
||||
libraryAlerts: boolean;
|
||||
systemAlerts: boolean;
|
||||
leadDays: number;
|
||||
}
|
||||
|
||||
interface AccountDetail {
|
||||
id: string;
|
||||
username: string;
|
||||
@@ -82,6 +93,7 @@ interface AccountDetail {
|
||||
updatedAt?: string;
|
||||
preferences?: Record<string, unknown>;
|
||||
};
|
||||
notifications: NotificationPreferences;
|
||||
}
|
||||
|
||||
interface AccountsPayload {
|
||||
@@ -124,6 +136,7 @@ export function AccountPage() {
|
||||
saving one does not discard an unsaved edit to the other. */
|
||||
const [prefs, setPrefs] = useState<Record<string, unknown> | null>(null);
|
||||
const [themes, setThemes] = useState<string[] | null>(null);
|
||||
const [notifications, setNotifications] = useState<NotificationPreferences | null>(null);
|
||||
const [pending, setPending] = useState<Pending | null>(null);
|
||||
const [renaming, setRenaming] = useState<{ id: string; name: string } | null>(null);
|
||||
|
||||
@@ -135,6 +148,10 @@ export function AccountPage() {
|
||||
if (prefs === null && account) setPrefs({ ...(account.settings?.preferences ?? {}) });
|
||||
}, [account, prefs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (notifications === null && account) setNotifications({ ...account.notifications });
|
||||
}, [account, notifications]);
|
||||
|
||||
useEffect(() => {
|
||||
if (themes !== null || !account) return;
|
||||
// An empty list from the server means unrestricted, so it draws as every box ticked.
|
||||
@@ -318,6 +335,105 @@ export function AccountPage() {
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Card
|
||||
title="Notifications"
|
||||
intro="Choose what this person sees across every television. Changes apply through the gateway within a few seconds and do not require an app release."
|
||||
icon="bell"
|
||||
tone="note"
|
||||
actions={notifications?.enabled ? <Tag tone="ok">enabled</Tag> : <Tag>muted</Tag>}
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
variant="primary"
|
||||
busy={busy === 'notifications'}
|
||||
onClick={() =>
|
||||
void act(
|
||||
'notifications',
|
||||
() => api.put(`${base}/notifications`, notifications),
|
||||
'Notification settings saved.',
|
||||
() => setNotifications(null),
|
||||
)
|
||||
}
|
||||
>
|
||||
Save notifications
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setNotifications(null);
|
||||
void reload();
|
||||
}}
|
||||
>
|
||||
Discard changes
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{notifications ? (
|
||||
<div className="checks columns">
|
||||
<Toggle
|
||||
label="All notifications"
|
||||
hint="The master switch. Turning this off hides every optional notification below."
|
||||
checked={notifications.enabled}
|
||||
onChange={(enabled) => setNotifications((current) => current && { ...current, enabled })}
|
||||
/>
|
||||
<Toggle
|
||||
label="My Shows return dates"
|
||||
hint="Remind this person when a followed show is about to return."
|
||||
checked={notifications.showReturnAlerts}
|
||||
disabled={!notifications.enabled}
|
||||
onChange={(showReturnAlerts) =>
|
||||
setNotifications((current) => current && { ...current, showReturnAlerts })
|
||||
}
|
||||
/>
|
||||
<Toggle
|
||||
label="Sonarr television alerts"
|
||||
hint="New episodes, additions and cancellation news supplied by Sonarr."
|
||||
checked={notifications.sonarrAlerts}
|
||||
disabled={!notifications.enabled}
|
||||
onChange={(sonarrAlerts) =>
|
||||
setNotifications((current) => current && { ...current, sonarrAlerts })
|
||||
}
|
||||
/>
|
||||
<Toggle
|
||||
label="Radarr film alerts"
|
||||
hint="Notify this person when Radarr imports a new film."
|
||||
checked={notifications.radarrAlerts}
|
||||
disabled={!notifications.enabled}
|
||||
onChange={(radarrAlerts) =>
|
||||
setNotifications((current) => current && { ...current, radarrAlerts })
|
||||
}
|
||||
/>
|
||||
<Toggle
|
||||
label="Optional app updates"
|
||||
hint="Offer new app versions to this person. Mandatory compatibility updates are always enforced."
|
||||
checked={notifications.updateAlerts}
|
||||
disabled={!notifications.enabled}
|
||||
onChange={(updateAlerts) =>
|
||||
setNotifications((current) => current && { ...current, updateAlerts })
|
||||
}
|
||||
/>
|
||||
<Toggle
|
||||
label="Library activity"
|
||||
hint="Show alerts after the Memby library catalogue is refreshed."
|
||||
checked={notifications.libraryAlerts}
|
||||
disabled={!notifications.enabled}
|
||||
onChange={(libraryAlerts) =>
|
||||
setNotifications((current) => current && { ...current, libraryAlerts })
|
||||
}
|
||||
/>
|
||||
<Toggle
|
||||
label="Service status"
|
||||
hint="Memby deployment and Emby outage or recovery notices. Maintenance mode itself still applies."
|
||||
checked={notifications.systemAlerts}
|
||||
disabled={!notifications.enabled}
|
||||
onChange={(systemAlerts) =>
|
||||
setNotifications((current) => current && { ...current, systemAlerts })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="Settings"
|
||||
intro="These live on the server and follow the person, so a change here reaches every television they use — usually within a few seconds, and on the next launch for a set that is switched off."
|
||||
|
||||
+331
-23
@@ -3,7 +3,7 @@ import { api } from '../api/client';
|
||||
import { useAction } from '../lib/hooks';
|
||||
import { useGateway } from '../lib/gateway';
|
||||
import { useToast } from '../lib/toast';
|
||||
import { Banner, Button, Card, Empty, Field, Grid, Loading, PageHead } from '../components/ui';
|
||||
import { Banner, Button, Card, Empty, Field, Grid, Loading, PageHead, Tag, Toggle } from '../components/ui';
|
||||
import type { HeroItem, HeroPlacement, HeroPlacementPolicy, HeroSchedule } from '../api/types';
|
||||
|
||||
const MAX_PINS = 4;
|
||||
@@ -13,6 +13,56 @@ const PLACEMENTS: Array<{ id: HeroPlacement; label: string; type: string }> = [
|
||||
{ id: 'tv_shows', label: 'TV Shows', type: 'television shows' },
|
||||
];
|
||||
const emptyPlacement = (): HeroPlacementPolicy => ({ pinnedItems: [], primeSubtitle: '' });
|
||||
const WEEKDAYS = [
|
||||
{ value: 1, short: 'Mon', label: 'Monday' },
|
||||
{ value: 2, short: 'Tue', label: 'Tuesday' },
|
||||
{ value: 3, short: 'Wed', label: 'Wednesday' },
|
||||
{ value: 4, short: 'Thu', label: 'Thursday' },
|
||||
{ value: 5, short: 'Fri', label: 'Friday' },
|
||||
{ value: 6, short: 'Sat', label: 'Saturday' },
|
||||
{ value: 0, short: 'Sun', label: 'Sunday' },
|
||||
];
|
||||
|
||||
type ScheduleFrequency = 'once' | 'daily' | 'weekly';
|
||||
|
||||
function localDateTime(value: Date): string {
|
||||
const offset = value.getTimezoneOffset() * 60_000;
|
||||
return new Date(value.getTime() - offset).toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
function usableAbsoluteTime(value: string | undefined): boolean {
|
||||
return Boolean(value && Number.isFinite(new Date(value).getTime()) && new Date(value).getFullYear() >= 2000);
|
||||
}
|
||||
|
||||
function scheduleFrequency(schedule: HeroSchedule): ScheduleFrequency {
|
||||
return schedule.frequency ?? 'once';
|
||||
}
|
||||
|
||||
function scheduleSummary(schedule: HeroSchedule): string {
|
||||
if (schedule.frequency === 'daily') {
|
||||
return `Every day · ${schedule.startTime}–${schedule.endTime}`;
|
||||
}
|
||||
if (schedule.frequency === 'weekly') {
|
||||
const selected = WEEKDAYS.filter((day) => (schedule.weekdays ?? []).includes(day.value));
|
||||
const days = selected.length === 7 ? 'Every day' : selected.map((day) => day.short).join(', ');
|
||||
return `${days || 'No days selected'} · ${schedule.startTime}–${schedule.endTime}`;
|
||||
}
|
||||
return `${schedule.startAt ? new Date(schedule.startAt).toLocaleString() : 'Start missing'} → ${schedule.endAt ? new Date(schedule.endAt).toLocaleString() : 'End missing'}`;
|
||||
}
|
||||
|
||||
function newSchedule(item: HeroItem, placement: HeroPlacement): HeroSchedule {
|
||||
const start = new Date();
|
||||
start.setMinutes(Math.ceil(start.getMinutes() / 30) * 30, 0, 0);
|
||||
const end = new Date(start.getTime() + 2 * 60 * 60 * 1000);
|
||||
const validPlacement = placement === 'home' ||
|
||||
(placement === 'movies' && item.type === 'Movie') ||
|
||||
(placement === 'tv_shows' && item.type === 'Series');
|
||||
return {
|
||||
id: crypto.randomUUID(), itemId: item.id,
|
||||
startAt: start.toISOString(), endAt: end.toISOString(),
|
||||
priority: 0, enabled: true, placements: [validPlacement ? placement : 'home'],
|
||||
};
|
||||
}
|
||||
|
||||
export function HeroPage() {
|
||||
const { status, error, loading, reload } = useGateway();
|
||||
@@ -26,6 +76,7 @@ export function HeroPage() {
|
||||
const [queryText, setQueryText] = useState('');
|
||||
const [results, setResults] = useState<HeroItem[] | null>(null);
|
||||
const [schedules, setSchedules] = useState<HeroSchedule[]>([]);
|
||||
const [scheduleDraft, setScheduleDraft] = useState<HeroSchedule | null>(null);
|
||||
|
||||
const policy = status?.heroPolicy;
|
||||
|
||||
@@ -99,9 +150,9 @@ export function HeroPage() {
|
||||
{PLACEMENTS.map((option) => {
|
||||
const configured = placements[option.id];
|
||||
const activeSchedule = schedules
|
||||
.filter((entry) => entry.enabled && (entry.placements ?? ['home']).includes(option.id) && new Date(entry.startAt) <= new Date() && new Date(entry.endAt) > new Date())
|
||||
.filter((entry) => entry.enabled && (entry.placements ?? ['home']).includes(option.id))
|
||||
.sort((left, right) => right.priority - left.priority)[0];
|
||||
const source = (configured.pinnedItems ?? []).length ? 'Manual' : activeSchedule ? 'Scheduled' : 'Automatic';
|
||||
const source = (configured.pinnedItems ?? []).length ? 'Manual' : activeSchedule ? 'Schedule ready' : 'Automatic';
|
||||
const scheduledItem = policy?.items?.find((item) => item.id === activeSchedule?.itemId);
|
||||
const preview = (configured.pinnedItems ?? []).map((item) => item.name).join(', ') || scheduledItem?.name || activeSchedule?.itemId || 'Resolved for each viewer';
|
||||
return <Card key={option.id} title={option.label} intro={`${source} · ${preview}`} tone={option.id === placement ? 'info' : undefined}>
|
||||
@@ -140,20 +191,22 @@ export function HeroPage() {
|
||||
{pins.length === 0 ? (
|
||||
<Empty>No titles are pinned. The hero is entirely release-aware and automatic.</Empty>
|
||||
) : (
|
||||
<div className="chips">
|
||||
<div className="hero-pins">
|
||||
{pins.map((item, index) => (
|
||||
<div className="hero-pin" key={item.id}>
|
||||
<span className="hero-pin-order">{index + 1}</span>
|
||||
<span><b>{item.name}</b><small>{item.type}{item.year ? ` · ${item.year}` : ''}</small></span>
|
||||
<Button size="sm" icon="clock" onClick={() => setScheduleDraft(newSchedule(item, placement))}>
|
||||
Schedule
|
||||
</Button>
|
||||
<Button
|
||||
key={item.id}
|
||||
variant="quiet"
|
||||
size="sm"
|
||||
icon="close"
|
||||
onClick={() => {
|
||||
setCurrent({ pinnedItems: pins.filter((pin) => pin.id !== item.id) });
|
||||
}}
|
||||
>
|
||||
{index + 1}. {item.name}
|
||||
{item.year ? ` (${item.year})` : ''}
|
||||
</Button>
|
||||
title={`Remove ${item.name}`}
|
||||
onClick={() => setCurrent({ pinnedItems: pins.filter((pin) => pin.id !== item.id) })}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -174,18 +227,77 @@ export function HeroPage() {
|
||||
</Field>
|
||||
</Card>
|
||||
|
||||
<Card title="Scheduled heroes" intro="Schedules are resolved by the gateway: manual pins still win, then the highest-priority eligible schedule, then Memby’s automatic hero." icon="clock" tone="info">
|
||||
{schedules.length === 0 ? <Empty>No scheduled heroes yet.</Empty> : (
|
||||
<div className="stack">{schedules.map((schedule) => {
|
||||
const item = [...(policy?.items ?? []), ...pins, ...(results ?? [])].find((candidate) => candidate.id === schedule.itemId);
|
||||
return <div className="row" key={schedule.id}><b>{item?.name ?? schedule.itemId}</b><span className="muted">{new Date(schedule.startAt).toLocaleString()} → {new Date(schedule.endAt).toLocaleString()} · priority {schedule.priority} · {(schedule.placements ?? ['home']).map((value) => PLACEMENTS.find((entry) => entry.id === value)?.label).join(', ')}</span><div className="chips">{PLACEMENTS.map((option) => <Button key={option.id} size="sm" variant={(schedule.placements ?? ['home']).includes(option.id) ? 'primary' : 'quiet'} onClick={() => { setSchedules((all) => all.map((entry) => { if (entry.id !== schedule.id) return entry; const selected = entry.placements ?? ['home']; const next = selected.includes(option.id) ? selected.filter((value) => value !== option.id) : [...selected, option.id]; return { ...entry, placements: next.length ? next : [option.id] }; })); setDirty(true); }}>{option.label}</Button>)}</div><Button size="sm" variant="quiet" onClick={() => { setSchedules((current) => current.filter((entry) => entry.id !== schedule.id)); setDirty(true); }}>Remove</Button></div>;
|
||||
})}</div>
|
||||
<Card
|
||||
title="Hero schedule"
|
||||
intro="The gateway applies these rules in server time. Manual pins win first; otherwise the highest-priority active schedule wins, followed by Memby’s automatic hero."
|
||||
icon="clock"
|
||||
tone="info"
|
||||
actions={<Tag tone="info">{policy?.timeZone || 'server local time'}</Tag>}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="primary" busy={busy === 'save'} onClick={() => void save()}>
|
||||
Save schedule
|
||||
</Button>
|
||||
<span className="hint">Daily and weekly rules repeat until you switch them off.</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{schedules.length === 0 ? (
|
||||
<Empty>No scheduled heroes yet. Use Schedule beside a pinned or searched title.</Empty>
|
||||
) : (
|
||||
<div className="hero-schedule-list">
|
||||
{[...schedules]
|
||||
.sort((left, right) => Number(right.enabled) - Number(left.enabled) || right.priority - left.priority)
|
||||
.map((schedule) => {
|
||||
const item = [...(policy?.items ?? []), ...pins, ...(results ?? [])]
|
||||
.find((candidate) => candidate.id === schedule.itemId);
|
||||
return (
|
||||
<article className="hero-schedule" data-enabled={schedule.enabled || undefined} key={schedule.id}>
|
||||
<div className="hero-schedule-time">
|
||||
<b>{schedule.frequency === 'weekly' ? 'Weekly' : schedule.frequency === 'daily' ? 'Daily' : 'Once'}</b>
|
||||
<span>{schedule.frequency ? schedule.startTime : schedule.startAt ? new Date(schedule.startAt).toLocaleDateString() : '—'}</span>
|
||||
</div>
|
||||
<div className="hero-schedule-main">
|
||||
<div className="hero-schedule-title">
|
||||
<h3>{item?.name ?? schedule.itemId}</h3>
|
||||
<Tag tone={schedule.enabled ? 'ok' : undefined}>{schedule.enabled ? 'enabled' : 'paused'}</Tag>
|
||||
</div>
|
||||
<p>{scheduleSummary(schedule)}</p>
|
||||
<div className="chips">
|
||||
{(schedule.placements ?? ['home']).map((value) => (
|
||||
<span className="chip" key={value}>{PLACEMENTS.find((entry) => entry.id === value)?.label}</span>
|
||||
))}
|
||||
{schedule.priority !== 0 ? <span className="chip">Priority {schedule.priority}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="hero-schedule-actions">
|
||||
<Button size="sm" onClick={() => setScheduleDraft({ ...schedule })}>Edit</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="quiet"
|
||||
onClick={() => {
|
||||
setSchedules((current) => current.map((entry) => entry.id === schedule.id ? { ...entry, enabled: !entry.enabled } : entry));
|
||||
setDirty(true);
|
||||
}}
|
||||
>
|
||||
{schedule.enabled ? 'Pause' : 'Enable'}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="quiet"
|
||||
onClick={() => {
|
||||
setSchedules((current) => current.filter((entry) => entry.id !== schedule.id));
|
||||
setDirty(true);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{pins.length > 0 ? <Button size="sm" icon="plus" onClick={() => {
|
||||
const first = pins[0]; if (!first) return;
|
||||
const start = new Date(); const end = new Date(start.getTime() + 2 * 60 * 60 * 1000);
|
||||
setSchedules((current) => [...current, { id: crypto.randomUUID(), itemId: first.id, startAt: start.toISOString(), endAt: end.toISOString(), priority: 0, enabled: true, placements: [placement] }]); setDirty(true);
|
||||
}}>Schedule first pinned title for two hours</Button> : <p className="hint">Pin or search for a title first, then add it to a schedule.</p>}
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
@@ -217,6 +329,7 @@ export function HeroPage() {
|
||||
<Grid>
|
||||
{results.map((item) => (
|
||||
<Card key={item.id} title={item.name} intro={`${item.type || 'Title'} · ${item.year || 'Year unknown'}`}>
|
||||
<div className="row">
|
||||
<Button
|
||||
size="sm"
|
||||
icon="plus"
|
||||
@@ -225,6 +338,10 @@ export function HeroPage() {
|
||||
>
|
||||
{pins.some((pin) => pin.id === item.id) ? 'Pinned' : 'Add to hero'}
|
||||
</Button>
|
||||
<Button size="sm" icon="clock" onClick={() => setScheduleDraft(newSchedule(item, placement))}>
|
||||
Schedule
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</Grid>
|
||||
@@ -232,6 +349,197 @@ export function HeroPage() {
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
{scheduleDraft ? (
|
||||
<ScheduleEditor
|
||||
schedule={scheduleDraft}
|
||||
item={[...(policy?.items ?? []), ...pins, ...(results ?? [])].find((candidate) => candidate.id === scheduleDraft.itemId)}
|
||||
timeZone={policy?.timeZone || 'server local time'}
|
||||
isNew={!schedules.some((entry) => entry.id === scheduleDraft.id)}
|
||||
onCancel={() => setScheduleDraft(null)}
|
||||
onSave={(next) => {
|
||||
setSchedules((current) => current.some((entry) => entry.id === next.id)
|
||||
? current.map((entry) => entry.id === next.id ? next : entry)
|
||||
: [...current, next]);
|
||||
setScheduleDraft(null);
|
||||
setDirty(true);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ScheduleEditor({
|
||||
schedule,
|
||||
item,
|
||||
timeZone,
|
||||
isNew,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: {
|
||||
schedule: HeroSchedule;
|
||||
item?: HeroItem;
|
||||
timeZone: string;
|
||||
isNew: boolean;
|
||||
onSave: (schedule: HeroSchedule) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState<HeroSchedule>({ ...schedule, weekdays: [...(schedule.weekdays ?? [])] });
|
||||
const frequency = scheduleFrequency(draft);
|
||||
const setFrequency = (next: ScheduleFrequency) => {
|
||||
const now = new Date();
|
||||
const later = new Date(now.getTime() + 2 * 60 * 60 * 1000);
|
||||
setDraft((current) => next === 'once'
|
||||
? {
|
||||
...current,
|
||||
frequency: undefined,
|
||||
startAt: usableAbsoluteTime(current.startAt) ? current.startAt : now.toISOString(),
|
||||
endAt: usableAbsoluteTime(current.endAt) ? current.endAt : later.toISOString(),
|
||||
}
|
||||
: {
|
||||
...current,
|
||||
frequency: next,
|
||||
startTime: current.startTime || '18:00',
|
||||
endTime: current.endTime || '22:00',
|
||||
weekdays: next === 'weekly' ? (current.weekdays?.length ? current.weekdays : [1, 2, 3, 4, 5]) : [],
|
||||
});
|
||||
};
|
||||
const selectedPlacements = draft.placements ?? ['home'];
|
||||
const validPlacement = (value: HeroPlacement) => value === 'home' ||
|
||||
(value === 'movies' && item?.type === 'Movie') ||
|
||||
(value === 'tv_shows' && item?.type === 'Series');
|
||||
const onceValid = Boolean(draft.startAt && draft.endAt && new Date(draft.endAt) > new Date(draft.startAt));
|
||||
const recurringValid = Boolean(draft.startTime && draft.endTime && draft.startTime !== draft.endTime &&
|
||||
(frequency !== 'weekly' || (draft.weekdays ?? []).length > 0));
|
||||
const valid = frequency === 'once' ? onceValid : recurringValid;
|
||||
|
||||
return (
|
||||
<div className="scrim" onPointerDown={(event) => event.target === event.currentTarget && onCancel()}>
|
||||
<div className="dialog hero-schedule-dialog" role="dialog" aria-modal="true" aria-labelledby="hero-schedule-title">
|
||||
<div className="hero-schedule-dialog-head">
|
||||
<span className="hero-schedule-kicker">Hero schedule</span>
|
||||
<h2 id="hero-schedule-title">{item?.name ?? draft.itemId}</h2>
|
||||
<p>Choose exactly when this title can lead the selected sections. Times use {timeZone}.</p>
|
||||
</div>
|
||||
|
||||
<div className="schedule-frequency" role="group" aria-label="Schedule frequency">
|
||||
{(['once', 'daily', 'weekly'] as ScheduleFrequency[]).map((value) => (
|
||||
<button key={value} type="button" aria-pressed={frequency === value} onClick={() => setFrequency(value)}>
|
||||
<b>{value === 'once' ? 'One time' : value === 'daily' ? 'Every day' : 'Weekly'}</b>
|
||||
<span>{value === 'once' ? 'A date range' : value === 'daily' ? 'Same time daily' : 'Choose days'}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{frequency === 'once' ? (
|
||||
<div className="fields">
|
||||
<Field label="Starts">
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={draft.startAt ? localDateTime(new Date(draft.startAt)) : ''}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, startAt: event.target.value ? new Date(event.target.value).toISOString() : undefined }))}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Ends">
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={draft.endAt ? localDateTime(new Date(draft.endAt)) : ''}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, endAt: event.target.value ? new Date(event.target.value).toISOString() : undefined }))}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="fields">
|
||||
<Field label="Starts each time">
|
||||
<input type="time" value={draft.startTime ?? ''} onChange={(event) => setDraft((current) => ({ ...current, startTime: event.target.value }))} />
|
||||
</Field>
|
||||
<Field label="Ends each time" hint="An earlier end time continues into the following day.">
|
||||
<input type="time" value={draft.endTime ?? ''} onChange={(event) => setDraft((current) => ({ ...current, endTime: event.target.value }))} />
|
||||
</Field>
|
||||
</div>
|
||||
{frequency === 'weekly' ? (
|
||||
<div className="schedule-days">
|
||||
<div className="schedule-days-head">
|
||||
<b>Days</b>
|
||||
<div>
|
||||
<button type="button" onClick={() => setDraft((current) => ({ ...current, weekdays: [1, 2, 3, 4, 5] }))}>Weekdays</button>
|
||||
<button type="button" onClick={() => setDraft((current) => ({ ...current, weekdays: [6, 0] }))}>Weekend</button>
|
||||
<button type="button" onClick={() => setDraft((current) => ({ ...current, weekdays: WEEKDAYS.map((day) => day.value) }))}>Every day</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="schedule-day-grid">
|
||||
{WEEKDAYS.map((day) => {
|
||||
const selected = (draft.weekdays ?? []).includes(day.value);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={day.value}
|
||||
aria-pressed={selected}
|
||||
title={day.label}
|
||||
onClick={() => setDraft((current) => ({
|
||||
...current,
|
||||
weekdays: selected
|
||||
? (current.weekdays ?? []).filter((value) => value !== day.value)
|
||||
: [...(current.weekdays ?? []), day.value],
|
||||
}))}
|
||||
>
|
||||
{day.short}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="schedule-options">
|
||||
<div>
|
||||
<span className="schedule-option-label">Show in</span>
|
||||
<div className="schedule-placement-grid">
|
||||
{PLACEMENTS.map((option) => (
|
||||
<Toggle
|
||||
key={option.id}
|
||||
label={option.label}
|
||||
checked={selectedPlacements.includes(option.id)}
|
||||
disabled={!validPlacement(option.id)}
|
||||
onChange={(enabled) => setDraft((current) => {
|
||||
const selected = current.placements ?? ['home'];
|
||||
const next = enabled ? [...selected, option.id] : selected.filter((value) => value !== option.id);
|
||||
return { ...current, placements: next.length ? [...new Set(next)] : selected };
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Field label="Priority" hint="Higher rules win when schedules overlap.">
|
||||
<input
|
||||
type="number"
|
||||
min={-1000}
|
||||
max={1000}
|
||||
step={10}
|
||||
value={draft.priority}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, priority: Number(event.target.value) }))}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Toggle
|
||||
label="Schedule enabled"
|
||||
hint="Pause it without losing its days and times."
|
||||
checked={draft.enabled}
|
||||
onChange={(enabled) => setDraft((current) => ({ ...current, enabled }))}
|
||||
/>
|
||||
|
||||
<div className="dialog-actions">
|
||||
<Button variant="quiet" onClick={onCancel}>Cancel</Button>
|
||||
<Button variant="primary" disabled={!valid} onClick={() => onSave(draft)}>
|
||||
{isNew ? 'Add rule' : 'Save rule'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ export function JourneyViewerPage() {
|
||||
const journeys = sessions.flatMap((session) => splitViewingJourneys(session).map((events, index) => ({ events, key: `${session[0]?.journeyId}:${index}` })));
|
||||
|
||||
return <>
|
||||
<PageHead title={`${username}'s journeys`} intro="Each app session is shown as the viewing journeys it contains: entry, selection, playback outcome." crumbs={<Link className="crumb" to="/admin/journeys">Journeys</Link>} />
|
||||
<PageHead title={`${username}'s journeys`} intro="Each app session is shown as the viewing journeys it contains: entry, selection, playback outcome." icon="journey" crumbs={<Link className="crumb" to="/admin/journeys">Journeys</Link>} />
|
||||
<Banner message={error} />
|
||||
{loading ? <Loading /> : <Card title="Viewing journeys" intro={`${sessions.length} app session${sessions.length === 1 ? '' : 's'} · ${journeys.length} viewing journey${journeys.length === 1 ? '' : 's'} in the last 90 days.`} icon="journey" tone="info">
|
||||
<div className="visits">
|
||||
|
||||
+204
-71
@@ -1,41 +1,114 @@
|
||||
import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Fragment,
|
||||
memo,
|
||||
useCallback,
|
||||
useDeferredValue,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { api } from '../api/client';
|
||||
import { num, when } from '../lib/format';
|
||||
import { Banner, Button, Card, Field, PageHead } from '../components/ui';
|
||||
import { num } from '../lib/format';
|
||||
import { Banner, Button, Card, Field, Note, PageHead } from '../components/ui';
|
||||
import type { LogEvent, LogResponse } from '../api/types';
|
||||
|
||||
/* The live server log.
|
||||
*
|
||||
* The ring buffer is drained in pages until it is caught up, so a console opened *after*
|
||||
* an incident sees what happened rather than only what happens next. Everything the page
|
||||
* has drained is held for filtering and export; only the visible tail is drawn, because
|
||||
* rendering twenty thousand lines during an incident is how a browser tab stops
|
||||
* responding at exactly the wrong moment. */
|
||||
* Network delivery is already cursor based: each server record crosses the wire once.
|
||||
* Rendering is virtualised as well, so retaining and filtering thousands of records does
|
||||
* not mean mounting thousands of details trees. Only the rows around the viewport exist
|
||||
* in the DOM; selecting one opens its complete structured data below the window. */
|
||||
|
||||
const RANKS: Record<string, number> = { DEBUG: 10, INFO: 20, WARN: 30, ERROR: 40 };
|
||||
const RANKS: Record<string, number> = { TRACE: 5, DEBUG: 10, INFO: 20, WARN: 30, ERROR: 40 };
|
||||
const RETAIN = 20_000;
|
||||
const DRAW = 2_500;
|
||||
const POLL_MS = 5_000;
|
||||
const ROW_HEIGHT = 48;
|
||||
const HEADER_HEIGHT = 31;
|
||||
const OVERSCAN = 8;
|
||||
|
||||
/* The same order the server's own console lines use — who and where first, the reason for
|
||||
the line last — so a log read here and a log read over SSH look alike. The gateway
|
||||
version deliberately remains visible: a server-log export is often read away from the
|
||||
console whose top bar would otherwise supply it. */
|
||||
const FIELD_ORDER = ['component', 'user', 'device', 'client', 'protocol', 'method', 'path', 'status', 'duration', 'version', 'gateway_version'];
|
||||
const FIELD_ORDER = [
|
||||
'component',
|
||||
'user',
|
||||
'device',
|
||||
'client',
|
||||
'protocol',
|
||||
'method',
|
||||
'path',
|
||||
'status',
|
||||
'duration',
|
||||
'version',
|
||||
'gateway_version',
|
||||
];
|
||||
const FIELD_RANK = new Map(FIELD_ORDER.map((key, index) => [key, index]));
|
||||
const dateTime = new Intl.DateTimeFormat(undefined, { dateStyle: 'short', timeStyle: 'medium' });
|
||||
|
||||
function orderedFields(attributes: Record<string, unknown>): [string, unknown][] {
|
||||
const rank = (key: string) => {
|
||||
const at = FIELD_ORDER.indexOf(key);
|
||||
if (at >= 0) return at;
|
||||
return key === 'error' ? 1000 : 100;
|
||||
interface CachedEvent {
|
||||
fields: [string, unknown][];
|
||||
summary: string;
|
||||
haystack: string;
|
||||
occurred: string;
|
||||
}
|
||||
|
||||
// API event objects remain stable for their retained lifetime. A WeakMap gives formatting
|
||||
// and search indexing the same lifetime without adding private fields to JSON exports.
|
||||
const eventCache = new WeakMap<LogEvent, CachedEvent>();
|
||||
|
||||
function cached(event: LogEvent): CachedEvent {
|
||||
const existing = eventCache.get(event);
|
||||
if (existing) return existing;
|
||||
const fields = Object.entries(event.attributes ?? {}).sort((left, right) => {
|
||||
const leftRank = FIELD_RANK.get(left[0]) ?? (left[0] === 'error' ? 1000 : 100);
|
||||
const rightRank = FIELD_RANK.get(right[0]) ?? (right[0] === 'error' ? 1000 : 100);
|
||||
return leftRank - rightRank || left[0].localeCompare(right[0]);
|
||||
});
|
||||
const value = {
|
||||
fields,
|
||||
summary: fields.map(([key, fieldValue]) => `${key}=${String(fieldValue)}`).join(' '),
|
||||
haystack: [event.message, ...fields.flat()].join(' ').toLowerCase(),
|
||||
occurred: dateTime.format(new Date(event.occurredAt)),
|
||||
};
|
||||
return Object.entries(attributes).sort((a, b) => rank(a[0]) - rank(b[0]));
|
||||
eventCache.set(event, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
const readableKey = (key: string) => key.replace(/_/g, ' ');
|
||||
|
||||
const haystack = (event: LogEvent) =>
|
||||
[event.message, ...Object.entries(event.attributes ?? {}).flat()].join(' ').toLowerCase();
|
||||
const LogLine = memo(function LogLine({
|
||||
event,
|
||||
index,
|
||||
onInspect,
|
||||
}: {
|
||||
event: LogEvent;
|
||||
index: number;
|
||||
onInspect: (sequence: number) => void;
|
||||
}) {
|
||||
const display = cached(event);
|
||||
return (
|
||||
<div
|
||||
className="logline"
|
||||
data-level={event.level}
|
||||
data-virtual="true"
|
||||
style={{ transform: `translateY(${index * ROW_HEIGHT}px)` }}
|
||||
>
|
||||
<time title={event.occurredAt}>
|
||||
{display.occurred}
|
||||
<small>#{event.sequence}</small>
|
||||
</time>
|
||||
<span className="lvl">{event.level}</span>
|
||||
<span className="msg" title={event.message}>{event.message}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="logattrs-button"
|
||||
title={display.summary || 'No structured details'}
|
||||
onClick={() => onInspect(event.sequence)}
|
||||
>
|
||||
{display.summary || 'View record'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export function LogsPage() {
|
||||
const [records, setRecords] = useState<LogEvent[]>([]);
|
||||
@@ -44,67 +117,114 @@ export function LogsPage() {
|
||||
const [level, setLevel] = useState('INFO');
|
||||
const [search, setSearch] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [viewport, setViewport] = useState({ top: 0, height: 600 });
|
||||
const [selectedSequence, setSelectedSequence] = useState<number | null>(null);
|
||||
|
||||
const deferredSearch = useDeferredValue(search.trim().toLowerCase());
|
||||
const cursor = useRef(0);
|
||||
const fetching = useRef(false);
|
||||
const viewGeneration = useRef(0);
|
||||
const view = useRef<HTMLDivElement>(null);
|
||||
// Whether the reader is at the bottom is decided *before* the new lines are drawn: after
|
||||
// they are, the measurement always says "not at the bottom" and the log would never
|
||||
// follow. Hence the layout effect below rather than a check inside the fetch.
|
||||
const pinned = useRef(true);
|
||||
const scrollFrame = useRef<number | undefined>(undefined);
|
||||
|
||||
const drain = useCallback(async () => {
|
||||
if (paused || fetching.current) return;
|
||||
if (paused || fetching.current || document.hidden) return;
|
||||
fetching.current = true;
|
||||
const generation = viewGeneration.current;
|
||||
const batch: LogEvent[] = [];
|
||||
let droppedInDrain = 0;
|
||||
try {
|
||||
let pages = 0;
|
||||
let page: LogResponse;
|
||||
do {
|
||||
page = await api.get<LogResponse>(`/admin/api/events?after=${cursor.current}&limit=1000`);
|
||||
cursor.current = page.next || cursor.current;
|
||||
if (page.dropped) setDropped((current) => current + page.dropped);
|
||||
const events = page.events ?? [];
|
||||
if (events.length > 0) {
|
||||
setRecords((current) => {
|
||||
const next = [...current, ...events];
|
||||
return next.length > RETAIN ? next.slice(next.length - RETAIN) : next;
|
||||
});
|
||||
}
|
||||
droppedInDrain += page.dropped || 0;
|
||||
batch.push(...(page.events ?? []));
|
||||
pages += 1;
|
||||
} while (page.hasMore && pages < 20);
|
||||
setError('');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : String(cause));
|
||||
} finally {
|
||||
// One React update for a complete catch-up prevents the initial 5,000-record drain
|
||||
// from redrawing the page once per network page.
|
||||
if (batch.length > 0 && generation === viewGeneration.current) {
|
||||
setRecords((current) => {
|
||||
const next = current.concat(batch);
|
||||
return next.length > RETAIN ? next.slice(next.length - RETAIN) : next;
|
||||
});
|
||||
}
|
||||
if (droppedInDrain > 0 && generation === viewGeneration.current) {
|
||||
setDropped((current) => current + droppedInDrain);
|
||||
}
|
||||
fetching.current = false;
|
||||
}
|
||||
}, [paused]);
|
||||
|
||||
useEffect(() => {
|
||||
void drain();
|
||||
if (paused) return;
|
||||
const timer = window.setInterval(() => void drain(), POLL_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
let timer: number | undefined;
|
||||
const schedule = () => {
|
||||
window.clearInterval(timer);
|
||||
timer = document.hidden ? undefined : window.setInterval(() => void drain(), POLL_MS);
|
||||
};
|
||||
const visibilityChanged = () => {
|
||||
schedule();
|
||||
if (!document.hidden) void drain();
|
||||
};
|
||||
void drain();
|
||||
schedule();
|
||||
document.addEventListener('visibilitychange', visibilityChanged);
|
||||
return () => {
|
||||
window.clearInterval(timer);
|
||||
document.removeEventListener('visibilitychange', visibilityChanged);
|
||||
};
|
||||
}, [drain, paused]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const minimum = RANKS[level] ?? 20;
|
||||
const needle = search.trim().toLowerCase();
|
||||
return records.filter(
|
||||
(event) => (RANKS[event.level] ?? 0) >= minimum && (!needle || haystack(event).includes(needle)),
|
||||
(event) =>
|
||||
(RANKS[event.level] ?? 0) >= minimum &&
|
||||
(!deferredSearch || cached(event).haystack.includes(deferredSearch)),
|
||||
);
|
||||
}, [records, level, search]);
|
||||
}, [records, level, deferredSearch]);
|
||||
|
||||
const visible = filtered.slice(-DRAW);
|
||||
const lastSequence = filtered.at(-1)?.sequence ?? 0;
|
||||
const bodyTop = Math.max(0, viewport.top - HEADER_HEIGHT);
|
||||
const count = Math.ceil(viewport.height / ROW_HEIGHT) + OVERSCAN * 2;
|
||||
// A restrictive filter can make the old scroll offset larger than the new body before
|
||||
// the browser dispatches its compensating scroll event. Clamp immediately so that
|
||||
// transition never paints an apparently empty log.
|
||||
const first = Math.min(
|
||||
Math.max(0, Math.floor(bodyTop / ROW_HEIGHT) - OVERSCAN),
|
||||
Math.max(0, filtered.length - count),
|
||||
);
|
||||
const windowed = filtered.slice(first, first + count);
|
||||
const selected = useMemo(
|
||||
() => records.find((event) => event.sequence === selectedSequence),
|
||||
[records, selectedSequence],
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const node = view.current;
|
||||
if (node && pinned.current) node.scrollTop = node.scrollHeight;
|
||||
}, [visible.length]);
|
||||
if (!node || !pinned.current) return;
|
||||
node.scrollTop = node.scrollHeight;
|
||||
setViewport({ top: node.scrollTop, height: node.clientHeight });
|
||||
}, [lastSequence, deferredSearch, level]);
|
||||
|
||||
useEffect(() => () => window.cancelAnimationFrame(scrollFrame.current ?? 0), []);
|
||||
|
||||
const onScroll = () => {
|
||||
const node = view.current;
|
||||
if (node) pinned.current = node.scrollHeight - node.scrollTop - node.clientHeight < 50;
|
||||
if (!node) return;
|
||||
pinned.current = node.scrollHeight - node.scrollTop - node.clientHeight < ROW_HEIGHT;
|
||||
window.cancelAnimationFrame(scrollFrame.current ?? 0);
|
||||
scrollFrame.current = window.requestAnimationFrame(() => {
|
||||
setViewport({ top: node.scrollTop, height: node.clientHeight });
|
||||
});
|
||||
};
|
||||
|
||||
const exportJson = () => {
|
||||
@@ -145,56 +265,69 @@ export function LogsPage() {
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
// A page already in flight may finish after this click. Advancing the
|
||||
// generation makes that batch part of the cleared past, not a flash of
|
||||
// old lines reappearing after the view was emptied.
|
||||
viewGeneration.current += 1;
|
||||
setRecords([]);
|
||||
setDropped(0);
|
||||
setSelectedSequence(null);
|
||||
}}
|
||||
>
|
||||
Clear view
|
||||
</Button>
|
||||
<Button onClick={exportJson} icon="download">
|
||||
Export JSON
|
||||
</Button>
|
||||
<Button onClick={exportJson} icon="download">Export JSON</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="logview" ref={view} onScroll={onScroll} role="log" aria-live="polite">
|
||||
<div className="logview" ref={view} onScroll={onScroll} role="log" aria-label="Server events">
|
||||
<div className="loghead" aria-hidden="true">
|
||||
<span>Time</span>
|
||||
<span>Level</span>
|
||||
<span>Event</span>
|
||||
<span>Details</span>
|
||||
</div>
|
||||
{visible.length === 0 ? (
|
||||
{filtered.length === 0 ? (
|
||||
<p className="empty">{records.length === 0 ? 'Waiting for server events…' : 'No events match this filter.'}</p>
|
||||
) : (
|
||||
visible.map((event, index) => (
|
||||
<div className="logline" key={`${event.occurredAt}:${index}`} data-level={event.level}>
|
||||
<time title={`Log record ${event.sequence}`}>{when(event.occurredAt)}<small>#{event.sequence}</small></time>
|
||||
{/* The level is a class on the line as well as its own column: scrolling a
|
||||
log is looking for the one line that is not INFO, and a coloured word
|
||||
four columns in is easy to scroll past. */}
|
||||
<span className="lvl">{event.level}</span>
|
||||
<span className="msg">{event.message}</span>
|
||||
<span className="attrs">
|
||||
<details className="logdetails">
|
||||
<summary>{orderedFields(event.attributes ?? {}).map(([key, value]) => <span key={key}><b>{key}=</b>{String(value)} </span>)}</summary>
|
||||
<dl>
|
||||
<dt>Log record</dt><dd>{event.sequence}</dd>
|
||||
{orderedFields(event.attributes ?? {}).map(([key, value]) => <Fragment key={key}><dt>{readableKey(key)}</dt><dd>{String(value)}</dd></Fragment>)}
|
||||
</dl>
|
||||
</details>
|
||||
</span>
|
||||
<div className="logbody" style={{ height: `${filtered.length * ROW_HEIGHT}px` }}>
|
||||
{windowed.map((event, offset) => (
|
||||
<LogLine key={event.sequence} event={event} index={first + offset} onInspect={setSelectedSequence} />
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="hint">
|
||||
{num(records.length)} retained · {num(filtered.length)} matching
|
||||
{visible.length < filtered.length ? ` · showing the latest ${num(visible.length)}` : ''}
|
||||
{filtered.length ? ` · ${num(windowed.length)} rows mounted` : ''}
|
||||
{dropped ? ` · ${num(dropped)} overwritten before delivery` : ''}
|
||||
{paused ? ' · paused' : ''}
|
||||
</p>
|
||||
|
||||
{selected ? (
|
||||
<section className="log-inspector" aria-label={`Log record ${selected.sequence}`}>
|
||||
<div className="log-inspector-head">
|
||||
<div>
|
||||
<b>{selected.message}</b>
|
||||
<span>{cached(selected).occurred} · {selected.level} · record #{selected.sequence}</span>
|
||||
</div>
|
||||
<Button size="sm" variant="quiet" onClick={() => setSelectedSequence(null)}>Close</Button>
|
||||
</div>
|
||||
{cached(selected).fields.length ? (
|
||||
<dl>
|
||||
{cached(selected).fields.map(([key, fieldValue]) => (
|
||||
<Fragment key={key}>
|
||||
<dt>{readableKey(key)}</dt>
|
||||
<dd>{String(fieldValue)}</dd>
|
||||
</Fragment>
|
||||
))}
|
||||
</dl>
|
||||
) : (
|
||||
<Note>No structured details were attached to this record.</Note>
|
||||
)}
|
||||
</section>
|
||||
) : null}
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { api } from '../api/client';
|
||||
import type { ReleaseBuilderStatus } from '../api/types';
|
||||
import { useAction } from '../lib/hooks';
|
||||
import { useGateway } from '../lib/gateway';
|
||||
import { useToast } from '../lib/toast';
|
||||
import { Banner, Button, Card, Confirm, Field, Loading, PageHead, Tag, Toggle } from '../components/ui';
|
||||
import { Banner, Button, Card, Confirm, Field, Loading, Note, PageHead, Tag, Toggle } from '../components/ui';
|
||||
|
||||
interface Draft {
|
||||
version: string;
|
||||
@@ -20,6 +21,13 @@ export function UpdatesPage() {
|
||||
const { busy, run } = useAction();
|
||||
const [draft, setDraft] = useState<Draft | null>(null);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [releaseConfirming, setReleaseConfirming] = useState(false);
|
||||
const [releaseTag, setReleaseTag] = useState('');
|
||||
const [releaseNotes, setReleaseNotes] = useState('');
|
||||
const [releaseMandatory, setReleaseMandatory] = useState(false);
|
||||
const [builder, setBuilder] = useState<ReleaseBuilderStatus | null>(null);
|
||||
const [builderError, setBuilderError] = useState('');
|
||||
const completedAt = useRef('');
|
||||
|
||||
const policy = status?.updatePolicy;
|
||||
|
||||
@@ -39,6 +47,32 @@ export function UpdatesPage() {
|
||||
});
|
||||
}, [policy, draft]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const poll = async () => {
|
||||
try {
|
||||
const next = await api.get<ReleaseBuilderStatus>('/admin/api/release-builder');
|
||||
if (!active) return;
|
||||
setBuilder(next);
|
||||
setBuilderError('');
|
||||
if (next.state === 'succeeded' && next.finishedAt && completedAt.current !== next.finishedAt) {
|
||||
completedAt.current = next.finishedAt;
|
||||
setDraft(null);
|
||||
await reload();
|
||||
}
|
||||
} catch (cause) {
|
||||
if (!active) return;
|
||||
setBuilderError(cause instanceof Error ? cause.message : 'The release builder is unavailable.');
|
||||
}
|
||||
};
|
||||
void poll();
|
||||
const timer = window.setInterval(() => void poll(), 3000);
|
||||
return () => {
|
||||
active = false;
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [reload]);
|
||||
|
||||
const save = (enabled: boolean) =>
|
||||
run(enabled ? 'save' : 'off', async () => {
|
||||
if (!draft) return;
|
||||
@@ -66,10 +100,92 @@ export function UpdatesPage() {
|
||||
|
||||
const patch = (next: Partial<Draft>) => setDraft((current) => (current ? { ...current, ...next } : current));
|
||||
|
||||
const startRelease = () =>
|
||||
run('release', async () => {
|
||||
const next = await wrap(
|
||||
() =>
|
||||
api.post<ReleaseBuilderStatus>('/admin/api/release-builder', {
|
||||
tag: releaseTag.trim(),
|
||||
notes: releaseNotes.trim(),
|
||||
mandatory: releaseMandatory,
|
||||
}),
|
||||
'Memby release started.',
|
||||
);
|
||||
if (next) {
|
||||
setBuilder(next);
|
||||
setBuilderError('');
|
||||
setReleaseConfirming(false);
|
||||
}
|
||||
});
|
||||
|
||||
const builderTone =
|
||||
builder?.state === 'succeeded'
|
||||
? 'ok'
|
||||
: builder?.state === 'failed'
|
||||
? 'bad'
|
||||
: builder?.state === 'running'
|
||||
? 'warn'
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title="App updates" intro="Publish an optional or a required client update." />
|
||||
<Banner message={error} />
|
||||
<Banner message={builderError} />
|
||||
|
||||
<Card
|
||||
title="Build and publish"
|
||||
intro="Build the latest tagged Android app in Docker, sign it with Memby's existing certificate, verify it, and publish it to televisions. The builder runs separately from the gateway."
|
||||
icon="download"
|
||||
tone="data"
|
||||
actions={<Tag tone={builderTone}>{builder?.state ?? 'checking'}</Tag>}
|
||||
footer={
|
||||
<Button
|
||||
variant="primary"
|
||||
busy={busy === 'release' || builder?.state === 'running'}
|
||||
disabled={Boolean(builderError)}
|
||||
onClick={() => (releaseMandatory ? setReleaseConfirming(true) : void startRelease())}
|
||||
>
|
||||
{releaseTag.trim() ? `Build ${releaseTag.trim()}` : 'Build latest release'}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="fields">
|
||||
<Field
|
||||
label="GitHub tag override"
|
||||
hint="Leave blank for the latest semantic tag. An exact tag bypasses discovery or repeats that tag; the gateway still refuses downgrades."
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={releaseTag}
|
||||
disabled={builder?.state === 'running'}
|
||||
placeholder="v0.2.64 (blank uses latest)"
|
||||
onChange={(event) => setReleaseTag(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Release notes" hint="Leave blank to use this version's CHANGELOG entry.">
|
||||
<input
|
||||
type="text"
|
||||
value={releaseNotes}
|
||||
disabled={builder?.state === 'running'}
|
||||
placeholder="What's new on the television"
|
||||
onChange={(event) => setReleaseNotes(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Toggle
|
||||
label="Make this update required"
|
||||
hint="Older televisions cannot dismiss the update prompt. You will confirm before the build starts."
|
||||
checked={releaseMandatory}
|
||||
disabled={builder?.state === 'running'}
|
||||
onChange={setReleaseMandatory}
|
||||
/>
|
||||
{builder?.message ? <Note tone={builderTone}>{builder.message}</Note> : null}
|
||||
{builder?.logs?.length ? <pre className="code">{builder.logs.join('\n')}</pre> : null}
|
||||
<Note>
|
||||
Command-line fallback: <code>{builder?.fallback ?? 'docker compose run --rm --build memby-builder release'}</code>
|
||||
</Note>
|
||||
</Card>
|
||||
|
||||
{loading || !draft ? (
|
||||
<Loading rows={1} />
|
||||
@@ -187,6 +303,17 @@ export function UpdatesPage() {
|
||||
onCancel={() => setConfirming(false)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{releaseConfirming ? (
|
||||
<Confirm
|
||||
title="Build a required update?"
|
||||
body="When this signed APK is published, every older television will be blocked until it installs the update."
|
||||
confirmLabel="Build and publish"
|
||||
busy={busy === 'release'}
|
||||
onConfirm={() => void startRelease()}
|
||||
onCancel={() => setReleaseConfirming(false)}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+818
-32
File diff suppressed because it is too large
Load Diff
+15
-4
@@ -67,6 +67,17 @@ val localProperties = Properties().apply {
|
||||
fun localProperty(key: String): String? =
|
||||
localProperties.getProperty(key)?.trim()?.takeIf { it.isNotEmpty() }
|
||||
|
||||
// Compose mounts release credentials as files under /run/secrets. Supporting the
|
||||
// conventional *_FILE form keeps those values out of the container configuration;
|
||||
// the direct environment-variable form remains for the existing PowerShell scripts.
|
||||
fun environmentSecret(key: String): String? {
|
||||
val path = System.getenv("${key}_FILE")?.trim().orEmpty()
|
||||
if (path.isNotEmpty()) {
|
||||
return rootProject.file(path).readText().trim().takeIf { it.isNotEmpty() }
|
||||
}
|
||||
return System.getenv(key)?.trim()?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.ponzischeme89.memby"
|
||||
compileSdk = 35
|
||||
@@ -106,16 +117,16 @@ android {
|
||||
// different app and forces users to uninstall first. Keep the keystore and these
|
||||
// credentials off the repo: set them in local.properties (gitignored) or the
|
||||
// environment. Without them, `assembleRelease` still builds but stays unsigned.
|
||||
val keystorePath = localProperty("memby.keystore") ?: System.getenv("MEMBY_KEYSTORE")
|
||||
val keystorePath = localProperty("memby.keystore") ?: environmentSecret("MEMBY_KEYSTORE")
|
||||
val hasKeystore = !keystorePath.isNullOrBlank() && file(keystorePath).exists()
|
||||
|
||||
signingConfigs {
|
||||
if (hasKeystore) {
|
||||
create("release") {
|
||||
storeFile = file(keystorePath!!)
|
||||
storePassword = localProperty("memby.keystorePassword") ?: System.getenv("MEMBY_KEYSTORE_PASSWORD")
|
||||
keyAlias = localProperty("memby.keyAlias") ?: System.getenv("MEMBY_KEY_ALIAS") ?: "memby"
|
||||
keyPassword = localProperty("memby.keyPassword") ?: System.getenv("MEMBY_KEY_PASSWORD")
|
||||
storePassword = localProperty("memby.keystorePassword") ?: environmentSecret("MEMBY_KEYSTORE_PASSWORD")
|
||||
keyAlias = localProperty("memby.keyAlias") ?: environmentSecret("MEMBY_KEY_ALIAS") ?: "memby"
|
||||
keyPassword = localProperty("memby.keyPassword") ?: environmentSecret("MEMBY_KEY_PASSWORD")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
# Optional release-builder settings. Copy only the names you need into the deployment
|
||||
# .env file. Signing credentials themselves belong in files under MEMBY_SECRETS_DIR.
|
||||
MEMBY_SECRETS_DIR=
|
||||
MEMBY_SOURCE_REPOSITORY=
|
||||
MEMBY_RELEASE_TAG=
|
||||
MEMBY_RELEASE_NOTES=
|
||||
MEMBY_RELEASE_MANDATORY=
|
||||
MEMBY_SKIP_APP_TESTS=
|
||||
MEMBY_SOURCE_URL=
|
||||
MEMBY_BUILDER_MEMORY_LIMIT=
|
||||
@@ -0,0 +1,4 @@
|
||||
**
|
||||
!Dockerfile
|
||||
!release.sh
|
||||
!controller.go
|
||||
@@ -0,0 +1,54 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
FROM golang:1.26-alpine AS controller
|
||||
WORKDIR /src
|
||||
COPY controller.go .
|
||||
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/memby-builder-controller controller.go
|
||||
|
||||
FROM eclipse-temurin:17-jdk-jammy
|
||||
|
||||
ARG ANDROID_COMMAND_LINE_TOOLS_VERSION=15859902
|
||||
ARG ANDROID_COMMAND_LINE_TOOLS_SHA256=4e4c464f145a7512b57d088ac6c278c03c9eea610886b35a5e0804e74eedf583
|
||||
ARG ANDROID_PLATFORM=35
|
||||
ARG ANDROID_BUILD_TOOLS=35.0.0
|
||||
|
||||
ENV ANDROID_HOME=/opt/android-sdk \
|
||||
ANDROID_SDK_ROOT=/opt/android-sdk \
|
||||
GRADLE_USER_HOME=/home/memby/.gradle \
|
||||
PATH=/opt/android-sdk/cmdline-tools/latest/bin:/opt/android-sdk/platform-tools:/opt/android-sdk/build-tools/35.0.0:${PATH}
|
||||
|
||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,target=/var/lib/apt,sharing=locked \
|
||||
apt-get update && \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
ca-certificates curl git unzip && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN mkdir -p "${ANDROID_HOME}/cmdline-tools" /tmp/android-tools && \
|
||||
curl --fail --location --show-error --silent \
|
||||
"https://dl.google.com/android/repository/commandlinetools-linux-${ANDROID_COMMAND_LINE_TOOLS_VERSION}_latest.zip" \
|
||||
--output /tmp/android-tools.zip && \
|
||||
echo "${ANDROID_COMMAND_LINE_TOOLS_SHA256} /tmp/android-tools.zip" | sha256sum --check --strict && \
|
||||
unzip -q /tmp/android-tools.zip -d /tmp/android-tools && \
|
||||
mv /tmp/android-tools/cmdline-tools "${ANDROID_HOME}/cmdline-tools/latest" && \
|
||||
yes | sdkmanager --licenses >/dev/null && \
|
||||
sdkmanager \
|
||||
"platform-tools" \
|
||||
"platforms;android-${ANDROID_PLATFORM}" \
|
||||
"build-tools;${ANDROID_BUILD_TOOLS}" && \
|
||||
rm -rf /tmp/android-tools /tmp/android-tools.zip /root/.android
|
||||
|
||||
# Match the distroless gateway's non-root uid so both services can safely use the
|
||||
# memby-releases volume. The signing material remains a read-only Compose secret.
|
||||
RUN groupadd --gid 65532 memby && \
|
||||
useradd --uid 65532 --gid 65532 --create-home --home-dir /home/memby memby && \
|
||||
mkdir -p /work /data/releases "${GRADLE_USER_HOME}" && \
|
||||
chown -R 65532:65532 /work /data/releases /home/memby
|
||||
|
||||
COPY --chmod=0755 release.sh /usr/local/bin/memby-builder
|
||||
COPY --from=controller --chmod=0755 /out/memby-builder-controller /usr/local/bin/memby-builder-controller
|
||||
|
||||
USER 65532:65532
|
||||
WORKDIR /work
|
||||
ENTRYPOINT ["/usr/local/bin/memby-builder"]
|
||||
CMD ["release"]
|
||||
@@ -0,0 +1,91 @@
|
||||
# Memby Docker release builder
|
||||
|
||||
The `memby-builder` Compose service is an isolated Android toolchain. In the normal stack,
|
||||
its small internal controller waits for the **Build latest release** button on Admin
|
||||
Console → App updates. It then fetches the newest semantic `v*.*.*` tag from GitHub,
|
||||
builds that exact source with its Gradle wrapper, signs it with Memby's existing release
|
||||
identity, verifies it, and publishes it through the running gateway. Its port is exposed
|
||||
only to the Compose network; the browser never receives its address or release token.
|
||||
|
||||
The image pins JDK 17, Android command-line tools 15859902, Android platform 35 and build
|
||||
tools 35.0.0. Android SDK packages stay in the image layer; the `memby-gradle-cache` volume
|
||||
persists Gradle distributions and dependencies between releases.
|
||||
|
||||
## One-time NAS setup
|
||||
|
||||
Set `MEMBY_SECRETS_DIR` in the deployment `.env` to an absolute directory that is outside
|
||||
the directory replaced by `deploy-server.ps1`. The standard NAS value is:
|
||||
|
||||
```dotenv
|
||||
MEMBY_SECRETS_DIR=/share/Docker/Memby-secrets
|
||||
```
|
||||
|
||||
Create that directory with restrictive permissions and place these five files in it:
|
||||
|
||||
```text
|
||||
/share/Docker/Memby-secrets/
|
||||
memby-release.jks
|
||||
memby-keystore-password
|
||||
memby-key-alias
|
||||
memby-key-password
|
||||
memby-release-publish-token
|
||||
```
|
||||
|
||||
- `memby-release.jks` must be the existing Memby release keystore. Do not generate a new
|
||||
key: Android would reject it as an upgrade for every installed television.
|
||||
- The three signing text files contain only their respective existing value, with no
|
||||
`NAME=` prefix.
|
||||
- `memby-release-publish-token` contains the existing gateway release-publish token. Move
|
||||
that value out of `.env`; the gateway and builder now read the same Compose secret.
|
||||
On the first upgraded deployment, `deploy-server.ps1` migrates this value automatically
|
||||
when it is still present in the previous deployed `.env`. It never generates a new one.
|
||||
|
||||
Docker mounts all five files read-only under `/run/secrets`. Their values are never image
|
||||
layers, source files, Compose environment values, Gradle arguments or `docker inspect`
|
||||
output. `deploy-server.ps1` keeps the NAS directory at mode `0700` and its files read-only
|
||||
at `0444`. The file mode is necessary because Compose file secrets are bind mounts and
|
||||
both Memby containers run as non-root; the protected parent directory prevents other NAS
|
||||
accounts from reaching those files.
|
||||
|
||||
## Create a release
|
||||
|
||||
Open Admin Console → App updates and press **Build latest release**. The page shows live
|
||||
status and the retained build output. Leave the tag override blank for the latest GitHub
|
||||
tag, or enter an exact semantic tag for a deliberate recovery build. A required release
|
||||
has an extra confirmation because it blocks older televisions until they update.
|
||||
|
||||
If the console or controller is unavailable, use the command-line fallback from the
|
||||
deployed Memby directory on the NAS:
|
||||
|
||||
```sh
|
||||
docker compose run --rm --build memby-builder release
|
||||
```
|
||||
|
||||
By default the builder queries `https://github.com/ponzischeme89/memby.git` and selects the
|
||||
highest semantic tag. To reproduce a particular tagged release, set an explicit tag for
|
||||
one invocation:
|
||||
|
||||
```sh
|
||||
MEMBY_RELEASE_TAG=v0.2.64 docker compose run --rm --build memby-builder release
|
||||
```
|
||||
|
||||
Optional, non-secret settings are listed with blank values in `builder.env.example`.
|
||||
`MEMBY_RELEASE_MANDATORY=true` makes the published release mandatory; tests run unless
|
||||
`MEMBY_SKIP_APP_TESTS=true` is deliberately set.
|
||||
|
||||
The final files are in the shared `memby-releases` Docker volume under `/data/releases`:
|
||||
|
||||
```text
|
||||
/data/releases/memby-<version>.apk
|
||||
/data/releases/memby-<version>.apk.sha256
|
||||
```
|
||||
|
||||
Publishing through the gateway also updates its existing database-backed version,
|
||||
download URL, release notes, size and SHA-256 metadata atomically. Every run checks the
|
||||
application id and version, runs `apksigner verify --verbose --print-certs`, and compares
|
||||
the APK signer digest with the certificate exported from the mounted keystore before the
|
||||
gateway receives the APK.
|
||||
|
||||
The Windows `release.ps1`, `deploy-tv.ps1`, and the existing app-release option in
|
||||
`deploy-server.ps1` remain available for local workflows and continue to use the same
|
||||
signing identity.
|
||||
@@ -0,0 +1,203 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const maxLogLines = 400
|
||||
|
||||
var tagPattern = regexp.MustCompile(`^v\d+\.\d+\.\d+$`)
|
||||
|
||||
type releaseRequest struct {
|
||||
Tag string `json:"tag"`
|
||||
Notes string `json:"notes"`
|
||||
Mandatory bool `json:"mandatory"`
|
||||
}
|
||||
|
||||
type releaseStatus struct {
|
||||
State string `json:"state"`
|
||||
Tag string `json:"tag,omitempty"`
|
||||
Mandatory bool `json:"mandatory"`
|
||||
StartedAt time.Time `json:"startedAt,omitempty"`
|
||||
FinishedAt time.Time `json:"finishedAt,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Logs []string `json:"logs"`
|
||||
Fallback string `json:"fallback"`
|
||||
}
|
||||
|
||||
type controller struct {
|
||||
mu sync.RWMutex
|
||||
status releaseStatus
|
||||
token []byte
|
||||
}
|
||||
|
||||
func main() {
|
||||
token, err := readSecret("/run/secrets/memby_release_publish_token")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
c := &controller{token: token, status: releaseStatus{
|
||||
State: "idle", Logs: []string{},
|
||||
Fallback: "docker compose run --rm --build memby-builder release",
|
||||
}}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })
|
||||
mux.Handle("GET /v1/status", c.authorise(http.HandlerFunc(c.handleStatus)))
|
||||
mux.Handle("POST /v1/releases", c.authorise(http.HandlerFunc(c.handleRelease)))
|
||||
server := &http.Server{Addr: ":8090", Handler: mux, ReadHeaderTimeout: 5 * time.Second, IdleTimeout: 30 * time.Second}
|
||||
log.Printf("Memby release controller listening on %s", server.Addr)
|
||||
log.Fatal(server.ListenAndServe())
|
||||
}
|
||||
|
||||
func readSecret(path string) ([]byte, error) {
|
||||
value, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("release controller token: %w", err)
|
||||
}
|
||||
value = []byte(strings.TrimSpace(string(value)))
|
||||
if len(value) == 0 {
|
||||
return nil, errors.New("release controller token is empty")
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (c *controller) authorise(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
presented := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
|
||||
if subtle.ConstantTimeCompare([]byte(presented), c.token) != 1 {
|
||||
writeError(w, http.StatusUnauthorized, "invalid release token")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (c *controller) handleStatus(w http.ResponseWriter, _ *http.Request) {
|
||||
c.mu.RLock()
|
||||
status := c.status
|
||||
// Start with a non-nil slice so an idle controller emits `[]`, not `null`. The Admin
|
||||
// Console is still defensive for compatibility with already-deployed controllers.
|
||||
status.Logs = append([]string{}, c.status.Logs...)
|
||||
c.mu.RUnlock()
|
||||
writeJSON(w, http.StatusOK, status)
|
||||
}
|
||||
|
||||
func (c *controller) handleRelease(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 16<<10)
|
||||
var request releaseRequest
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&request); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid release request")
|
||||
return
|
||||
}
|
||||
request.Tag = strings.TrimSpace(request.Tag)
|
||||
request.Notes = strings.TrimSpace(request.Notes)
|
||||
if request.Tag != "" && !tagPattern.MatchString(request.Tag) {
|
||||
writeError(w, http.StatusBadRequest, "tag must be blank or look like v0.2.64")
|
||||
return
|
||||
}
|
||||
if len(request.Notes) > 4000 {
|
||||
writeError(w, http.StatusBadRequest, "release notes are too long")
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
if c.status.State == "running" {
|
||||
c.mu.Unlock()
|
||||
writeError(w, http.StatusConflict, "a Memby release is already running")
|
||||
return
|
||||
}
|
||||
c.status = releaseStatus{
|
||||
State: "running", Tag: request.Tag, Mandatory: request.Mandatory,
|
||||
StartedAt: time.Now().UTC(), Message: "Preparing the Android release builder", Logs: []string{},
|
||||
Fallback: "docker compose run --rm --build memby-builder release",
|
||||
}
|
||||
status := c.status
|
||||
c.mu.Unlock()
|
||||
go c.run(request)
|
||||
writeJSON(w, http.StatusAccepted, status)
|
||||
}
|
||||
|
||||
func (c *controller) run(request releaseRequest) {
|
||||
command := exec.Command("/usr/local/bin/memby-builder", "release")
|
||||
command.Env = append(os.Environ(),
|
||||
"MEMBY_RELEASE_TAG="+request.Tag,
|
||||
"MEMBY_RELEASE_NOTES="+request.Notes,
|
||||
fmt.Sprintf("MEMBY_RELEASE_MANDATORY=%t", request.Mandatory),
|
||||
)
|
||||
stdout, err := command.StdoutPipe()
|
||||
if err != nil {
|
||||
c.finish(err)
|
||||
return
|
||||
}
|
||||
command.Stderr = command.Stdout
|
||||
if err := command.Start(); err != nil {
|
||||
c.finish(err)
|
||||
return
|
||||
}
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
|
||||
for scanner.Scan() {
|
||||
c.appendLog(scanner.Text())
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
c.appendLog("Could not read complete build output: " + err.Error())
|
||||
}
|
||||
close(done)
|
||||
}()
|
||||
err = command.Wait()
|
||||
<-done
|
||||
c.finish(err)
|
||||
}
|
||||
|
||||
func (c *controller) appendLog(line string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
return
|
||||
}
|
||||
c.status.Logs = append(c.status.Logs, line)
|
||||
if len(c.status.Logs) > maxLogLines {
|
||||
c.status.Logs = append([]string(nil), c.status.Logs[len(c.status.Logs)-maxLogLines:]...)
|
||||
}
|
||||
c.status.Message = line
|
||||
}
|
||||
|
||||
func (c *controller) finish(err error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.status.FinishedAt = time.Now().UTC()
|
||||
if err != nil {
|
||||
c.status.State = "failed"
|
||||
c.status.Message = "Release failed: " + err.Error()
|
||||
return
|
||||
}
|
||||
c.status.State = "succeeded"
|
||||
c.status.Message = "Release built, verified and published"
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, value any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, message string) {
|
||||
writeJSON(w, status, map[string]string{"error": message})
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIdleStatusEmitsEmptyLogsArray(t *testing.T) {
|
||||
c := &controller{status: releaseStatus{State: "idle"}}
|
||||
recorder := httptest.NewRecorder()
|
||||
c.handleStatus(recorder, httptest.NewRequest(http.MethodGet, "/v1/status", nil))
|
||||
|
||||
if got := recorder.Body.String(); !strings.Contains(got, `"logs":[]`) {
|
||||
t.Fatalf("idle status must emit an empty logs array: %s", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
readonly KEYSTORE_SECRET="${MEMBY_KEYSTORE_SECRET:-/run/secrets/memby_android_keystore}"
|
||||
readonly STORE_PASSWORD_SECRET="${MEMBY_KEYSTORE_PASSWORD_SECRET:-/run/secrets/memby_android_keystore_password}"
|
||||
readonly KEY_ALIAS_SECRET="${MEMBY_KEY_ALIAS_SECRET:-/run/secrets/memby_android_key_alias}"
|
||||
readonly KEY_PASSWORD_SECRET="${MEMBY_KEY_PASSWORD_SECRET:-/run/secrets/memby_android_key_password}"
|
||||
readonly PUBLISH_TOKEN_SECRET="${MEMBY_RELEASE_PUBLISH_TOKEN_SECRET:-/run/secrets/memby_release_publish_token}"
|
||||
readonly SOURCE_REPOSITORY="${MEMBY_SOURCE_REPOSITORY:-https://github.com/ponzischeme89/memby.git}"
|
||||
readonly PUBLISH_URL="${MEMBY_RELEASE_PUBLISH_URL:-http://server:32768/admin/api/release}"
|
||||
readonly EXPECTED_APPLICATION_ID="com.ponzischeme89.memby"
|
||||
|
||||
log() {
|
||||
printf '[memby-builder] %s\n' "$*"
|
||||
}
|
||||
|
||||
fail() {
|
||||
printf '[memby-builder] ERROR: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
require_secret() {
|
||||
local path="$1"
|
||||
local label="$2"
|
||||
[[ -r "$path" ]] || fail "$label secret is missing or unreadable at $path"
|
||||
[[ -s "$path" ]] || fail "$label secret is empty at $path"
|
||||
}
|
||||
|
||||
semantic_latest_tag() {
|
||||
git ls-remote --tags --refs "$SOURCE_REPOSITORY" 'refs/tags/v[0-9]*' |
|
||||
sed -n 's#^[^[:space:]]\+[[:space:]]\+refs/tags/\(v[0-9]\+\.[0-9]\+\.[0-9]\+\)$#\1#p' |
|
||||
sort -V |
|
||||
tail -n 1
|
||||
}
|
||||
|
||||
release_notes() {
|
||||
local source_dir="$1"
|
||||
local version="$2"
|
||||
local notes_file="$3"
|
||||
|
||||
if [[ -n "${MEMBY_RELEASE_NOTES_FILE:-}" ]]; then
|
||||
[[ -r "$MEMBY_RELEASE_NOTES_FILE" ]] || fail "release notes file is unreadable"
|
||||
cp "$MEMBY_RELEASE_NOTES_FILE" "$notes_file"
|
||||
elif [[ -n "${MEMBY_RELEASE_NOTES:-}" ]]; then
|
||||
printf '%s\n' "$MEMBY_RELEASE_NOTES" > "$notes_file"
|
||||
elif [[ -f "$source_dir/CHANGELOG.md" ]]; then
|
||||
awk -v version="$version" '
|
||||
$0 ~ "^## " version "([[:space:]]|$)" { found=1; next }
|
||||
found && /^## / { exit }
|
||||
found && /^- / { sub(/^- /, ""); print }
|
||||
' "$source_dir/CHANGELOG.md" > "$notes_file"
|
||||
fi
|
||||
|
||||
if [[ ! -s "$notes_file" ]]; then
|
||||
printf 'Memby %s release.\n' "$version" > "$notes_file"
|
||||
fi
|
||||
}
|
||||
|
||||
publish_release() {
|
||||
local apk="$1"
|
||||
local version="$2"
|
||||
local sha256="$3"
|
||||
local notes_file="$4"
|
||||
local mandatory="$5"
|
||||
local token
|
||||
token="$(<"$PUBLISH_TOKEN_SECRET")"
|
||||
|
||||
# Feed the authorisation header through curl's stdin configuration. The token is
|
||||
# never present in the container configuration, process arguments or command log.
|
||||
printf 'header = "Authorization: Bearer %s"\n' "$token" |
|
||||
curl --config - --fail-with-body --show-error --silent \
|
||||
--output /dev/null \
|
||||
--request POST \
|
||||
--form "version=$version" \
|
||||
--form "sha256=$sha256" \
|
||||
--form "mandatory=$mandatory" \
|
||||
--form "notes=<$notes_file" \
|
||||
--form "apk=@$apk;type=application/vnd.android.package-archive" \
|
||||
"$PUBLISH_URL"
|
||||
}
|
||||
|
||||
release() {
|
||||
require_secret "$KEYSTORE_SECRET" 'release keystore'
|
||||
require_secret "$STORE_PASSWORD_SECRET" 'keystore password'
|
||||
require_secret "$KEY_ALIAS_SECRET" 'key alias'
|
||||
require_secret "$KEY_PASSWORD_SECRET" 'key password'
|
||||
require_secret "$PUBLISH_TOKEN_SECRET" 'release publish token'
|
||||
|
||||
local tag="${MEMBY_RELEASE_TAG:-}"
|
||||
if [[ -z "$tag" ]]; then
|
||||
log "Reading the latest GitHub tag from $SOURCE_REPOSITORY"
|
||||
tag="$(semantic_latest_tag)"
|
||||
fi
|
||||
[[ "$tag" =~ ^v([0-9]+\.[0-9]+\.[0-9]+)$ ]] ||
|
||||
fail "release tag must look like v0.2.64 (found '${tag:-none}')"
|
||||
local version="${BASH_REMATCH[1]}"
|
||||
|
||||
local work_dir
|
||||
work_dir="$(mktemp -d /work/memby-release.XXXXXX)"
|
||||
trap 'rm -rf -- "$work_dir"' RETURN
|
||||
local source_dir="$work_dir/source"
|
||||
local notes_file="$work_dir/notes.txt"
|
||||
local certificate_file="$work_dir/release-certificate.der"
|
||||
|
||||
log "Fetching $tag"
|
||||
git init -q "$source_dir"
|
||||
git -C "$source_dir" remote add origin "$SOURCE_REPOSITORY"
|
||||
git -C "$source_dir" fetch --quiet --depth=1 origin "refs/tags/$tag:refs/tags/$tag"
|
||||
git -C "$source_dir" checkout --quiet --detach "refs/tags/$tag"
|
||||
|
||||
export MEMBY_KEYSTORE="$KEYSTORE_SECRET"
|
||||
export MEMBY_KEYSTORE_PASSWORD_FILE="$STORE_PASSWORD_SECRET"
|
||||
export MEMBY_KEY_ALIAS_FILE="$KEY_ALIAS_SECRET"
|
||||
export MEMBY_KEY_PASSWORD_FILE="$KEY_PASSWORD_SECRET"
|
||||
# A fetched tag may predate *_FILE support in build.gradle.kts. Populate the
|
||||
# established secure environment form as a compatibility bridge; these values are
|
||||
# created inside the one-shot build process and never enter Compose or docker inspect.
|
||||
export MEMBY_KEYSTORE_PASSWORD
|
||||
MEMBY_KEYSTORE_PASSWORD="$(<"$STORE_PASSWORD_SECRET")"
|
||||
export MEMBY_KEY_ALIAS
|
||||
MEMBY_KEY_ALIAS="$(<"$KEY_ALIAS_SECRET")"
|
||||
export MEMBY_KEY_PASSWORD
|
||||
MEMBY_KEY_PASSWORD="$(<"$KEY_PASSWORD_SECRET")"
|
||||
|
||||
local source_url="${MEMBY_SOURCE_URL:-${SOURCE_REPOSITORY%.git}/tree/$tag}"
|
||||
local -a gradle_tasks=()
|
||||
if [[ "${MEMBY_SKIP_APP_TESTS:-false}" != 'true' ]]; then
|
||||
gradle_tasks+=(testDebugUnitTest)
|
||||
fi
|
||||
gradle_tasks+=(assembleRelease)
|
||||
|
||||
log "Building Memby $version with JDK 17 and Android API 35"
|
||||
chmod +x "$source_dir/gradlew"
|
||||
"$source_dir/gradlew" --project-dir "$source_dir" --console=plain --no-daemon \
|
||||
"${gradle_tasks[@]}" \
|
||||
"-Pmemby.versionName=$version" \
|
||||
"-Pmemby.sourceUrl=$source_url"
|
||||
|
||||
local apk="$source_dir/app/build/outputs/apk/release/app-release.apk"
|
||||
[[ -f "$apk" ]] || {
|
||||
[[ ! -f "$source_dir/app/build/outputs/apk/release/app-release-unsigned.apk" ]] ||
|
||||
fail 'Gradle produced an unsigned APK; check the mounted signing secrets'
|
||||
fail "signed release APK was not produced at $apk"
|
||||
}
|
||||
|
||||
local package_line application_id built_version
|
||||
package_line="$(aapt dump badging "$apk" | sed -n '1p')"
|
||||
application_id="$(sed -n "s/.*package: name='\([^']*\)'.*/\1/p" <<<"$package_line")"
|
||||
built_version="$(sed -n "s/.*versionName='\([^']*\)'.*/\1/p" <<<"$package_line")"
|
||||
[[ "$application_id" == "$EXPECTED_APPLICATION_ID" ]] ||
|
||||
fail "APK applicationId is $application_id, expected $EXPECTED_APPLICATION_ID"
|
||||
[[ "$built_version" == "$version" ]] ||
|
||||
fail "APK version is $built_version, expected $version"
|
||||
|
||||
log 'Verifying the APK signature'
|
||||
local verification signer_digest keystore_digest
|
||||
if ! verification="$(apksigner verify --verbose --print-certs "$apk" 2>&1)"; then
|
||||
printf '%s\n' "$verification" >&2
|
||||
fail 'apksigner verification failed'
|
||||
fi
|
||||
printf '%s\n' "$verification"
|
||||
|
||||
local key_alias
|
||||
key_alias="$(<"$KEY_ALIAS_SECRET")"
|
||||
keytool -exportcert \
|
||||
-keystore "$KEYSTORE_SECRET" \
|
||||
-alias "$key_alias" \
|
||||
-storepass:file "$STORE_PASSWORD_SECRET" \
|
||||
-file "$certificate_file" >/dev/null
|
||||
keystore_digest="$(sha256sum "$certificate_file" | awk '{print $1}')"
|
||||
signer_digest="$(sed -n 's/^Signer #1 certificate SHA-256 digest: //p' <<<"$verification" | head -n 1 | tr -d ':')"
|
||||
[[ -n "$signer_digest" && "${signer_digest,,}" == "$keystore_digest" ]] ||
|
||||
fail 'APK signer does not match the mounted Memby release certificate'
|
||||
|
||||
local sha256
|
||||
sha256="$(sha256sum "$apk" | awk '{print $1}')"
|
||||
release_notes "$source_dir" "$version" "$notes_file"
|
||||
local mandatory="${MEMBY_RELEASE_MANDATORY:-false}"
|
||||
[[ "$mandatory" == 'true' || "$mandatory" == 'false' ]] ||
|
||||
fail 'MEMBY_RELEASE_MANDATORY must be true or false'
|
||||
|
||||
log "Publishing Memby $version to the gateway"
|
||||
publish_release "$apk" "$version" "$sha256" "$notes_file" "$mandatory"
|
||||
|
||||
local published_apk="/data/releases/memby-$version.apk"
|
||||
[[ -f "$published_apk" ]] || fail "gateway did not publish $published_apk"
|
||||
[[ "$(sha256sum "$published_apk" | awk '{print $1}')" == "$sha256" ]] ||
|
||||
fail 'published APK checksum does not match the verified build'
|
||||
local checksum_file="/data/releases/memby-$version.apk.sha256"
|
||||
local checksum_temp
|
||||
checksum_temp="$(mktemp "/data/releases/.memby-$version.sha256.XXXXXX")"
|
||||
printf '%s %s\n' "$sha256" "memby-$version.apk" > "$checksum_temp"
|
||||
mv -f "$checksum_temp" "$checksum_file"
|
||||
|
||||
log "Release complete: $published_apk"
|
||||
log "SHA-256: $sha256"
|
||||
log "Signing certificate SHA-256: $keystore_digest"
|
||||
}
|
||||
|
||||
case "${1:-release}" in
|
||||
release)
|
||||
release
|
||||
;;
|
||||
serve)
|
||||
exec /usr/local/bin/memby-builder-controller
|
||||
;;
|
||||
*)
|
||||
fail "unknown command '$1' (expected: release or serve)"
|
||||
;;
|
||||
esac
|
||||
+575
-60
@@ -26,6 +26,8 @@ SSH performs the password prompt directly. The password is never read or stored
|
||||
by this script.
|
||||
|
||||
This deploys the current local working tree, including uncommitted server changes.
|
||||
Use -SkipAppRelease -SkipBuilder for an admin/server-only deployment: no APK is built or
|
||||
published, and the running Android builder container and image are left untouched.
|
||||
|
||||
.EXAMPLE
|
||||
.\deploy-server.ps1
|
||||
@@ -41,6 +43,12 @@ This deploys the current local working tree, including uncommitted server change
|
||||
|
||||
.EXAMPLE
|
||||
.\deploy-server.ps1 --Quiet
|
||||
|
||||
.EXAMPLE
|
||||
.\deploy-server.ps1 -EstimateOnly
|
||||
|
||||
.EXAMPLE
|
||||
.\deploy-server.ps1 -SkipAppRelease -SkipBuilder
|
||||
#>
|
||||
|
||||
#Requires -Version 7.2
|
||||
@@ -78,9 +86,13 @@ param(
|
||||
[switch] $SkipAppTests,
|
||||
|
||||
[Parameter()]
|
||||
[switch] $SkipAppRelease
|
||||
[switch] $SkipAppRelease,
|
||||
|
||||
# Preserve the Android builder container and reuse its image. This skips the SDK
|
||||
# image build and leaves the release controller untouched during an admin/server deploy.
|
||||
[Parameter()]
|
||||
[switch] $SkipBuilder,
|
||||
|
||||
,
|
||||
[Parameter()]
|
||||
[Alias('m')]
|
||||
[switch] $MandatoryUpdate,
|
||||
@@ -88,6 +100,13 @@ param(
|
||||
[Parameter()]
|
||||
[switch] $Quiet,
|
||||
|
||||
[Parameter()]
|
||||
[switch] $NoAnimation,
|
||||
|
||||
[Parameter()]
|
||||
[Alias('Preview')]
|
||||
[switch] $EstimateOnly,
|
||||
|
||||
# PowerShell advanced scripts do not bind GNU-style double-dash switches by name.
|
||||
# Accept the two documented flags in either positional order instead.
|
||||
[Parameter(Position = 0)]
|
||||
@@ -112,31 +131,327 @@ if ($mandatoryRelease -and $SkipAppRelease) {
|
||||
}
|
||||
|
||||
$script:CurrentStep = 0
|
||||
$script:TotalSteps = if ($SkipAppRelease) { 5 } else { 6 }
|
||||
$script:LocalStepCount = if ($SkipAppRelease) { 4 } else { 5 }
|
||||
$script:RemoteStepCount = 10
|
||||
$script:TotalSteps = $script:LocalStepCount + $script:RemoteStepCount
|
||||
$script:PhaseOrder = @(
|
||||
'prerequisites',
|
||||
'source',
|
||||
'payload'
|
||||
) + $(if ($SkipAppRelease) { @() } else { @('android-release') }) + @(
|
||||
'packaging',
|
||||
'remote-deployment'
|
||||
)
|
||||
$script:FallbackSeconds = @{
|
||||
'prerequisites' = 3
|
||||
'source' = 2
|
||||
'payload' = 4
|
||||
'android-release' = 230
|
||||
'packaging' = 12
|
||||
'remote-deployment' = 330
|
||||
}
|
||||
$script:PhaseDurations = [ordered]@{}
|
||||
$script:CurrentPhaseKey = $null
|
||||
$script:CurrentPhaseTimer = $null
|
||||
$script:History = @()
|
||||
$script:IsCI = -not [string]::IsNullOrWhiteSpace($env:CI) -and $env:CI -notin @('0', 'false', 'False')
|
||||
$script:UseColour = [string]::IsNullOrWhiteSpace($env:NO_COLOR) -and
|
||||
-not [Console]::IsOutputRedirected -and
|
||||
-not $script:IsCI
|
||||
$script:UseAnimation = -not $NoAnimation -and
|
||||
[Environment]::UserInteractive -and
|
||||
-not [Console]::IsOutputRedirected -and
|
||||
-not $script:IsCI
|
||||
|
||||
function Write-Styled {
|
||||
param(
|
||||
[Parameter(Mandatory)][string] $Message,
|
||||
[Parameter(Mandatory)][ConsoleColor] $Colour,
|
||||
[switch] $NoNewline
|
||||
)
|
||||
$arguments = @{ Object = $Message; NoNewline = $NoNewline }
|
||||
if ($script:UseColour) { $arguments.ForegroundColor = $Colour }
|
||||
Write-Host @arguments
|
||||
}
|
||||
|
||||
function Format-DeploymentDuration {
|
||||
param([Parameter(Mandatory)][double] $Seconds)
|
||||
$seconds = [Math]::Max(0, [Math]::Round($Seconds))
|
||||
if ($seconds -lt 60) { return "${seconds}s" }
|
||||
$span = [TimeSpan]::FromSeconds($seconds)
|
||||
if ($span.TotalHours -ge 1) {
|
||||
return ('{0}h {1}m' -f [Math]::Floor($span.TotalHours), $span.Minutes)
|
||||
}
|
||||
return ('{0}m {1}s' -f $span.Minutes, $span.Seconds)
|
||||
}
|
||||
|
||||
function Get-DeploymentHistoryPath {
|
||||
$root = if ([string]::IsNullOrWhiteSpace($env:LOCALAPPDATA)) {
|
||||
Join-Path ([System.IO.Path]::GetTempPath()) 'Memby'
|
||||
} else {
|
||||
Join-Path $env:LOCALAPPDATA 'Memby'
|
||||
}
|
||||
return Join-Path $root 'deploy-history/deploy-server.json'
|
||||
}
|
||||
|
||||
function Import-DeploymentHistory {
|
||||
$script:HistoryPath = Get-DeploymentHistoryPath
|
||||
if (-not (Test-Path -LiteralPath $script:HistoryPath -PathType Leaf)) { return }
|
||||
try {
|
||||
$document = Get-Content -LiteralPath $script:HistoryPath -Raw | ConvertFrom-Json
|
||||
$script:History = @($document.runs | Where-Object { $_.success -eq $true })
|
||||
}
|
||||
catch {
|
||||
# Estimates are advisory. A truncated or hand-edited history file must never stop
|
||||
# a deployment, and the next successful run will replace it atomically.
|
||||
$script:History = @()
|
||||
}
|
||||
}
|
||||
|
||||
function Get-PhaseHistory {
|
||||
param([Parameter(Mandatory)][string] $Key)
|
||||
$values = foreach ($run in $script:History) {
|
||||
$phasesProperty = $run.PSObject.Properties['phases']
|
||||
if (-not $phasesProperty -or -not $phasesProperty.Value) { continue }
|
||||
$property = $phasesProperty.Value.PSObject.Properties[$Key]
|
||||
if ($property -and [double]$property.Value -gt 0) { [double]$property.Value }
|
||||
}
|
||||
return @($values | Select-Object -Last 20)
|
||||
}
|
||||
|
||||
function Get-PhaseEstimate {
|
||||
param([Parameter(Mandatory)][string] $Key)
|
||||
$historyValues = @(Get-PhaseHistory -Key $Key)
|
||||
if ($historyValues.Count -eq 0) {
|
||||
return [pscustomobject]@{
|
||||
Seconds = [double]$script:FallbackSeconds[$Key]
|
||||
Low = $null; High = $null; Samples = 0; Last = $null
|
||||
}
|
||||
}
|
||||
$last = $historyValues[-1]
|
||||
$values = @($historyValues | Sort-Object)
|
||||
$middle = [Math]::Floor($values.Count / 2)
|
||||
$median = if ($values.Count % 2) { $values[$middle] } else { ($values[$middle - 1] + $values[$middle]) / 2 }
|
||||
return [pscustomobject]@{
|
||||
Seconds = [double]$median
|
||||
Low = [double]$values[[Math]::Floor(($values.Count - 1) * 0.25)]
|
||||
High = [double]$values[[Math]::Ceiling(($values.Count - 1) * 0.75)]
|
||||
Samples = $values.Count
|
||||
Last = [double]$last
|
||||
}
|
||||
}
|
||||
|
||||
function Get-RemainingEstimate {
|
||||
param([Parameter(Mandatory)][string] $CurrentKey)
|
||||
$remaining = 0.0
|
||||
$found = $false
|
||||
foreach ($key in $script:PhaseOrder) {
|
||||
if ($key -eq $CurrentKey) { $found = $true }
|
||||
if (-not $found) { continue }
|
||||
$estimate = Get-PhaseEstimate -Key $key
|
||||
if ($key -eq $CurrentKey -and $script:CurrentPhaseTimer) {
|
||||
$remaining += [Math]::Max(0, $estimate.Seconds - $script:CurrentPhaseTimer.Elapsed.TotalSeconds)
|
||||
} else {
|
||||
$remaining += $estimate.Seconds
|
||||
}
|
||||
}
|
||||
return $remaining
|
||||
}
|
||||
|
||||
function Show-DeploymentProgress {
|
||||
param(
|
||||
[Parameter(Mandatory)][string] $Key,
|
||||
[Parameter(Mandatory)][string] $Status,
|
||||
[int] $CompletedPhases = 0
|
||||
)
|
||||
if (-not $script:UseAnimation) { return }
|
||||
$remaining = Get-RemainingEstimate -CurrentKey $Key
|
||||
$percent = [Math]::Min(99, [Math]::Round(($CompletedPhases / $script:PhaseOrder.Count) * 100))
|
||||
Write-Progress -Id 1 -Activity 'Memby deployment' -Status $Status `
|
||||
-PercentComplete $percent -SecondsRemaining ([Math]::Max(0, [int]$remaining))
|
||||
}
|
||||
|
||||
function Update-DeploymentEta {
|
||||
param([switch] $WriteLine)
|
||||
if (-not $script:CurrentPhaseKey -or -not $script:CurrentPhaseTimer) { return }
|
||||
$phaseIndex = [Array]::IndexOf($script:PhaseOrder, $script:CurrentPhaseKey)
|
||||
$phaseEstimate = Get-PhaseEstimate -Key $script:CurrentPhaseKey
|
||||
$phaseElapsed = $script:CurrentPhaseTimer.Elapsed.TotalSeconds
|
||||
$phaseRemaining = [Math]::Max(0, $phaseEstimate.Seconds - $phaseElapsed)
|
||||
$remaining = Get-RemainingEstimate -CurrentKey $script:CurrentPhaseKey
|
||||
if ($script:UseAnimation) {
|
||||
$withinPhase = if ($phaseEstimate.Seconds -gt 0) {
|
||||
[Math]::Min(0.95, $phaseElapsed / $phaseEstimate.Seconds)
|
||||
} else { 0 }
|
||||
$percent = [Math]::Min(99, [Math]::Round(
|
||||
(($phaseIndex + $withinPhase) / $script:PhaseOrder.Count) * 100
|
||||
))
|
||||
Write-Progress -Id 1 -Activity 'Memby deployment' `
|
||||
-Status ("{0} elapsed in this phase · {1} overall remaining" -f `
|
||||
(Format-DeploymentDuration $phaseElapsed),
|
||||
(Format-DeploymentDuration $remaining)) `
|
||||
-PercentComplete $percent -SecondsRemaining ([int]$remaining)
|
||||
}
|
||||
if ($WriteLine) {
|
||||
$phaseText = if ($phaseRemaining -gt 0) {
|
||||
"about $(Format-DeploymentDuration $phaseRemaining) left in this phase"
|
||||
} else {
|
||||
'running beyond its typical phase time'
|
||||
}
|
||||
Write-Styled -Message (" ◷ {0} elapsed · {1} · about {2} overall, near {3}" -f `
|
||||
(Format-DeploymentDuration $phaseElapsed),
|
||||
$phaseText,
|
||||
(Format-DeploymentDuration $remaining),
|
||||
(Get-Date).AddSeconds($remaining).ToString('h:mm tt')) -Colour Yellow
|
||||
}
|
||||
}
|
||||
|
||||
function Complete-DeploymentPhase {
|
||||
if (-not $script:CurrentPhaseKey -or -not $script:CurrentPhaseTimer) { return }
|
||||
$script:CurrentPhaseTimer.Stop()
|
||||
$script:PhaseDurations[$script:CurrentPhaseKey] = [Math]::Round(
|
||||
$script:CurrentPhaseTimer.Elapsed.TotalSeconds, 2
|
||||
)
|
||||
$script:CurrentPhaseKey = $null
|
||||
$script:CurrentPhaseTimer = $null
|
||||
}
|
||||
|
||||
function Start-DeploymentPhase {
|
||||
param(
|
||||
[Parameter(Mandatory)][string] $Key,
|
||||
[Parameter(Mandatory)][string] $Message,
|
||||
[switch] $RemoteRange
|
||||
)
|
||||
Complete-DeploymentPhase
|
||||
$script:CurrentPhaseKey = $Key
|
||||
$script:CurrentPhaseTimer = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
$phaseIndex = [Array]::IndexOf($script:PhaseOrder, $Key)
|
||||
Show-DeploymentProgress -Key $Key -Status $Message -CompletedPhases ([Math]::Max(0, $phaseIndex))
|
||||
|
||||
Write-Styled -Message '● ' -Colour Magenta -NoNewline
|
||||
if ($RemoteRange) {
|
||||
Write-Styled -Message ("[{0}–{1}/{1}] " -f ($script:LocalStepCount + 1), $script:TotalSteps) -Colour DarkCyan -NoNewline
|
||||
} else {
|
||||
$script:CurrentStep++
|
||||
Write-Styled -Message ("[{0}/{1}] " -f $script:CurrentStep, $script:TotalSteps) -Colour DarkCyan -NoNewline
|
||||
}
|
||||
Write-Styled -Message $Message -Colour Cyan
|
||||
|
||||
$phase = Get-PhaseEstimate -Key $Key
|
||||
$remaining = Get-RemainingEstimate -CurrentKey $Key
|
||||
$readyAt = (Get-Date).AddSeconds($remaining).ToString('h:mm tt')
|
||||
$basis = if ($phase.Samples -eq 0) {
|
||||
'first-run estimate; timings will improve after this deployment'
|
||||
} elseif ($phase.Samples -eq 1) {
|
||||
'based on the previous successful deployment'
|
||||
} else {
|
||||
"median of $($phase.Samples) successful deployments"
|
||||
}
|
||||
Write-Styled -Message (" ◷ About {0} remaining · ready near {1} · {2}" -f `
|
||||
(Format-DeploymentDuration $remaining), $readyAt, $basis) -Colour Yellow
|
||||
if ($phase.Samples -gt 1) {
|
||||
Write-Styled -Message (" Typical phase range {0}–{1}; last run {2}" -f `
|
||||
(Format-DeploymentDuration $phase.Low),
|
||||
(Format-DeploymentDuration $phase.High),
|
||||
(Format-DeploymentDuration $phase.Last)) -Colour Gray
|
||||
}
|
||||
}
|
||||
|
||||
function Save-DeploymentHistory {
|
||||
param([Parameter(Mandatory)][bool] $Success, [Parameter(Mandatory)][double] $DurationSeconds)
|
||||
if (-not $Success) { return }
|
||||
$record = [ordered]@{
|
||||
completedAt = (Get-Date).ToUniversalTime().ToString('o')
|
||||
success = $true
|
||||
durationSeconds = [Math]::Round($DurationSeconds, 2)
|
||||
phases = $script:PhaseDurations
|
||||
appRelease = -not [bool]$SkipAppRelease
|
||||
}
|
||||
$runs = @($script:History) + @([pscustomobject]$record) | Select-Object -Last 30
|
||||
$directory = Split-Path -Parent $script:HistoryPath
|
||||
[void](New-Item -ItemType Directory -Path $directory -Force)
|
||||
$temporary = "$($script:HistoryPath).tmp.$PID"
|
||||
try {
|
||||
@{ schemaVersion = 1; runs = @($runs) } | ConvertTo-Json -Depth 8 |
|
||||
Set-Content -LiteralPath $temporary -Encoding utf8
|
||||
[System.IO.File]::Move($temporary, $script:HistoryPath, $true)
|
||||
}
|
||||
catch {
|
||||
Write-Styled -Message ' ↳ Deployment succeeded, but timing history could not be saved' -Colour Yellow
|
||||
}
|
||||
finally {
|
||||
if (Test-Path -LiteralPath $temporary) { Remove-Item -LiteralPath $temporary -Force }
|
||||
}
|
||||
}
|
||||
|
||||
function Show-DeploymentEstimate {
|
||||
Write-Banner
|
||||
Write-Styled -Message 'Estimated phase timings' -Colour Cyan
|
||||
$labels = @{
|
||||
'prerequisites' = 'Local prerequisites'
|
||||
'source' = 'Source selection'
|
||||
'payload' = 'Compose validation'
|
||||
'android-release' = 'Android build and tests'
|
||||
'packaging' = 'Release packaging'
|
||||
'remote-deployment' = 'Upload, remote build and activation'
|
||||
}
|
||||
foreach ($key in $script:PhaseOrder) {
|
||||
$estimate = Get-PhaseEstimate -Key $key
|
||||
$detail = if ($estimate.Samples -gt 1) {
|
||||
"typical {0}–{1}; {2} samples" -f `
|
||||
(Format-DeploymentDuration $estimate.Low),
|
||||
(Format-DeploymentDuration $estimate.High),
|
||||
$estimate.Samples
|
||||
} elseif ($estimate.Samples -eq 1) {
|
||||
'one successful deployment recorded'
|
||||
} else {
|
||||
'baseline until a successful deployment is recorded'
|
||||
}
|
||||
Write-Styled -Message (" {0,-38} {1,9} {2}" -f `
|
||||
$labels[$key], (Format-DeploymentDuration $estimate.Seconds), $detail) -Colour Gray
|
||||
}
|
||||
Write-Host ''
|
||||
Write-Styled -Message "Timing history: $($script:HistoryPath)" -Colour DarkCyan
|
||||
}
|
||||
|
||||
function Write-Banner {
|
||||
Write-Host ''
|
||||
Write-Host '╭─ Memby deployment' -ForegroundColor Magenta
|
||||
Write-Host "│ Source $SourceDirectory (local working tree)" -ForegroundColor DarkGray
|
||||
Write-Host "│ Target ${RemoteUser}@${RemoteHost}:$Destination" -ForegroundColor DarkGray
|
||||
Write-Styled -Message '╭──────────────────── Memby deployment ────────────────────╮' -Colour Magenta
|
||||
Write-Styled -Message '│ SOURCE ' -Colour DarkCyan -NoNewline
|
||||
Write-Styled -Message "$SourceDirectory (local working tree)" -Colour Gray
|
||||
Write-Styled -Message '│ TARGET ' -Colour DarkCyan -NoNewline
|
||||
Write-Styled -Message "${RemoteUser}@${RemoteHost}:$Destination" -Colour White
|
||||
if ($mandatoryRelease) {
|
||||
Write-Host '│ Update mandatory (viewers cannot skip it)' -ForegroundColor Yellow
|
||||
Write-Styled -Message '│ UPDATE mandatory (viewers cannot skip it)' -Colour Yellow
|
||||
}
|
||||
if ($quietDeployment) {
|
||||
Write-Host '│ Notice quiet (no advance television announcement)' -ForegroundColor DarkGray
|
||||
Write-Styled -Message '│ NOTICE quiet (no advance television announcement)' -Colour Gray
|
||||
}
|
||||
Write-Host '╰─' -ForegroundColor Magenta
|
||||
if ($SkipBuilder) {
|
||||
Write-Styled -Message '│ BUILDER preserve the running Android builder' -Colour Gray
|
||||
}
|
||||
$initialEstimate = ($script:PhaseOrder | ForEach-Object { (Get-PhaseEstimate -Key $_).Seconds } |
|
||||
Measure-Object -Sum).Sum
|
||||
$sampleCounts = @($script:PhaseOrder | ForEach-Object { (Get-PhaseEstimate -Key $_).Samples })
|
||||
$historyRuns = if ($sampleCounts.Count) { ($sampleCounts | Measure-Object -Minimum).Minimum } else { 0 }
|
||||
$estimateBasis = if ($historyRuns -gt 0) { "median history ($historyRuns+ runs)" } else { 'learning baseline' }
|
||||
Write-Styled -Message '│ ETA ' -Colour DarkCyan -NoNewline
|
||||
Write-Styled -Message ("about {0}, near {1} · {2}" -f `
|
||||
(Format-DeploymentDuration $initialEstimate),
|
||||
(Get-Date).AddSeconds($initialEstimate).ToString('h:mm tt'),
|
||||
$estimateBasis) -Colour Yellow
|
||||
Write-Styled -Message '╰───────────────────────────────────────────────────────────╯' -Colour Magenta
|
||||
Write-Host ''
|
||||
}
|
||||
|
||||
function Write-Step {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string] $Message
|
||||
[string] $Message,
|
||||
[Parameter(Mandatory)]
|
||||
[string] $Key
|
||||
)
|
||||
|
||||
$script:CurrentStep++
|
||||
Write-Host ("● [{0}/{1}] {2}" -f $script:CurrentStep, $script:TotalSteps, $Message) -ForegroundColor Cyan
|
||||
Start-DeploymentPhase -Key $Key -Message $Message
|
||||
}
|
||||
|
||||
function Write-Detail {
|
||||
@@ -145,7 +460,7 @@ function Write-Detail {
|
||||
[string] $Message
|
||||
)
|
||||
|
||||
Write-Host " ↳ $Message" -ForegroundColor DarkGray
|
||||
Write-Styled -Message " ↳ $Message" -Colour Gray
|
||||
}
|
||||
|
||||
function Write-Success {
|
||||
@@ -154,7 +469,13 @@ function Write-Success {
|
||||
[string] $Message
|
||||
)
|
||||
|
||||
Write-Host "✓ $Message" -ForegroundColor Green
|
||||
Write-Styled -Message "✓ $Message" -Colour Green
|
||||
Update-DeploymentEta -WriteLine
|
||||
}
|
||||
|
||||
function Write-Failure {
|
||||
param([Parameter(Mandatory)][string] $Message)
|
||||
Write-Styled -Message "✗ $Message" -Colour Red
|
||||
}
|
||||
|
||||
function Get-RequiredCommand {
|
||||
@@ -309,7 +630,8 @@ function New-DeploymentArchive {
|
||||
'--exclude', 'admin-ui/node_modules', '--exclude', 'admin-ui/node_modules/*',
|
||||
'--exclude', 'admin-ui/dist', '--exclude', 'admin-ui/dist/*',
|
||||
'-C', $RepositoryDirectory,
|
||||
'server', 'admin-ui', 'docker-compose.yml', '.env.example'
|
||||
'server', 'admin-ui', 'builder', 'docker-compose.yml', '.env.example',
|
||||
'builder.env.example'
|
||||
)
|
||||
if ($ReleaseDirectory) {
|
||||
$arguments += @(
|
||||
@@ -367,7 +689,40 @@ function Send-ArchiveOverSsh {
|
||||
|
||||
$archiveStream = [System.IO.File]::OpenRead($ArchivePath)
|
||||
try {
|
||||
$archiveStream.CopyTo($process.StandardInput.BaseStream)
|
||||
$totalBytes = $archiveStream.Length
|
||||
$transferred = 0L
|
||||
$buffer = [byte[]]::new(1MB)
|
||||
$transferTimer = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
$lastRefresh = [TimeSpan]::Zero
|
||||
$nextLoggedPercent = 25
|
||||
while (($read = $archiveStream.Read($buffer, 0, $buffer.Length)) -gt 0) {
|
||||
$process.StandardInput.BaseStream.Write($buffer, 0, $read)
|
||||
$transferred += $read
|
||||
if (($transferTimer.Elapsed - $lastRefresh).TotalMilliseconds -lt 250) { continue }
|
||||
$lastRefresh = $transferTimer.Elapsed
|
||||
$percent = if ($totalBytes -gt 0) { [Math]::Min(100, [int](100 * $transferred / $totalBytes)) } else { 0 }
|
||||
$bytesPerSecond = if ($transferTimer.Elapsed.TotalSeconds -gt 0) {
|
||||
$transferred / $transferTimer.Elapsed.TotalSeconds
|
||||
} else { 0 }
|
||||
$secondsLeft = if ($bytesPerSecond -gt 0) {
|
||||
[int](($totalBytes - $transferred) / $bytesPerSecond)
|
||||
} else { -1 }
|
||||
if ($script:UseAnimation) {
|
||||
$rate = if ($bytesPerSecond -gt 0) { '{0:N1} MiB/s' -f ($bytesPerSecond / 1MB) } else { 'measuring speed' }
|
||||
Write-Progress -Id 2 -ParentId 1 -Activity 'Uploading deployment archive' `
|
||||
-Status ("{0}% · {1}" -f $percent, $rate) -PercentComplete $percent `
|
||||
-SecondsRemaining $secondsLeft
|
||||
} elseif ($percent -ge $nextLoggedPercent) {
|
||||
Write-Detail ("Upload {0}% · {1:N1} MiB of {2:N1} MiB" -f `
|
||||
$percent, ($transferred / 1MB), ($totalBytes / 1MB))
|
||||
$nextLoggedPercent += 25
|
||||
}
|
||||
}
|
||||
$process.StandardInput.BaseStream.Flush()
|
||||
if ($script:UseAnimation) { Write-Progress -Id 2 -Activity 'Uploading deployment archive' -Completed }
|
||||
Write-Success ("Archive uploaded in {0} at {1:N1} MiB/s" -f `
|
||||
(Format-DeploymentDuration $transferTimer.Elapsed.TotalSeconds),
|
||||
$(if ($transferTimer.Elapsed.TotalSeconds -gt 0) { ($transferred / 1MB) / $transferTimer.Elapsed.TotalSeconds } else { 0 }))
|
||||
}
|
||||
catch [System.IO.IOException] {
|
||||
# The remote side exited before reading the whole archive — a failed early
|
||||
@@ -398,10 +753,15 @@ function Send-ArchiveOverSsh {
|
||||
}
|
||||
}
|
||||
|
||||
Import-DeploymentHistory
|
||||
if ($EstimateOnly) {
|
||||
Show-DeploymentEstimate
|
||||
return
|
||||
}
|
||||
Write-Banner
|
||||
$deploymentTimer = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
|
||||
Write-Step 'Checking local prerequisites and settings'
|
||||
Write-Step 'Checking local prerequisites and settings' -Key 'prerequisites'
|
||||
Assert-SafeRemoteSettings
|
||||
$script:TarCommand = Get-RequiredCommand -Name 'tar'
|
||||
$script:SshCommand = Get-RequiredCommand -Name 'ssh'
|
||||
@@ -415,12 +775,12 @@ $archivePath = Join-Path $workDirectory 'memby-deployment.tar'
|
||||
try {
|
||||
[void] (New-Item -ItemType Directory -Path $workDirectory)
|
||||
|
||||
Write-Step 'Selecting the local deployment source'
|
||||
Write-Step 'Selecting the local deployment source' -Key 'source'
|
||||
$checkoutDirectory = (Resolve-Path -LiteralPath $SourceDirectory -ErrorAction Stop).Path
|
||||
Write-Success "Using $checkoutDirectory"
|
||||
Write-Host ''
|
||||
|
||||
Write-Step 'Validating the Compose deployment payload'
|
||||
Write-Step 'Validating the Compose deployment payload' -Key 'payload'
|
||||
$requiredPaths = @(
|
||||
(Join-Path $checkoutDirectory 'server'),
|
||||
(Join-Path $checkoutDirectory 'server/Dockerfile'),
|
||||
@@ -429,8 +789,13 @@ try {
|
||||
(Join-Path $checkoutDirectory 'admin-ui/Dockerfile'),
|
||||
(Join-Path $checkoutDirectory 'admin-ui/package.json'),
|
||||
(Join-Path $checkoutDirectory 'admin-ui/src'),
|
||||
(Join-Path $checkoutDirectory 'builder'),
|
||||
(Join-Path $checkoutDirectory 'builder/Dockerfile'),
|
||||
(Join-Path $checkoutDirectory 'builder/release.sh'),
|
||||
(Join-Path $checkoutDirectory 'builder/controller.go'),
|
||||
(Join-Path $checkoutDirectory 'docker-compose.yml'),
|
||||
(Join-Path $checkoutDirectory '.env.example')
|
||||
(Join-Path $checkoutDirectory '.env.example'),
|
||||
(Join-Path $checkoutDirectory 'builder.env.example')
|
||||
)
|
||||
foreach ($requiredPath in $requiredPaths) {
|
||||
if (-not (Test-Path -LiteralPath $requiredPath)) {
|
||||
@@ -439,6 +804,7 @@ try {
|
||||
}
|
||||
Write-Detail 'server/ build context'
|
||||
Write-Detail 'admin-ui/ build context'
|
||||
Write-Detail 'builder/ release toolchain'
|
||||
Write-Detail 'docker-compose.yml'
|
||||
Write-Detail '.env.example'
|
||||
$releaseDirectory = ''
|
||||
@@ -453,18 +819,12 @@ try {
|
||||
throw "Required Android build file is missing: $appPath"
|
||||
}
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace(
|
||||
(Get-DotEnvValue -Path (Join-Path $checkoutDirectory '.env.example') `
|
||||
-Name 'MEMBY_RELEASE_PUBLISH_TOKEN')
|
||||
)) {
|
||||
throw 'MEMBY_RELEASE_PUBLISH_TOKEN is empty in .env.example; a signed APK cannot be published.'
|
||||
}
|
||||
}
|
||||
Write-Success 'Deployment payload is complete'
|
||||
Write-Host ''
|
||||
|
||||
if (-not $SkipAppRelease) {
|
||||
Write-Step 'Building and verifying the signed Android update'
|
||||
Write-Step 'Building and verifying the signed Android update' -Key 'android-release'
|
||||
Import-UserSigningEnvironment
|
||||
$releaseVersion = Get-ConfiguredAppVersion -RepositoryDirectory $checkoutDirectory
|
||||
$sdk = Get-AndroidSdk -RepositoryDirectory $checkoutDirectory
|
||||
@@ -538,7 +898,7 @@ try {
|
||||
Write-Host ''
|
||||
}
|
||||
|
||||
Write-Step 'Packaging the release'
|
||||
Write-Step 'Packaging the release' -Key 'packaging'
|
||||
New-DeploymentArchive -RepositoryDirectory $checkoutDirectory -ArchivePath $archivePath `
|
||||
-ReleaseDirectory $releaseDirectory
|
||||
$archiveSize = (Get-Item -LiteralPath $archivePath).Length
|
||||
@@ -554,28 +914,50 @@ set -eu
|
||||
destination='__DESTINATION__'
|
||||
health_timeout=__HEALTH_TIMEOUT__
|
||||
publish_release=__PUBLISH_RELEASE__
|
||||
skip_builder=__SKIP_BUILDER__
|
||||
mandatory_update=__MANDATORY_UPDATE__
|
||||
quiet_deployment=__QUIET_DEPLOYMENT__
|
||||
colour_output=__COLOUR_OUTPUT__
|
||||
remote_step_offset=__REMOTE_STEP_OFFSET__
|
||||
total_steps=__TOTAL_STEPS__
|
||||
parent=$(dirname "$destination")
|
||||
staging="${destination}.new.$$"
|
||||
backup="${destination}.previous.$$"
|
||||
activated=0
|
||||
previous_stopped=0
|
||||
remote_step=0
|
||||
|
||||
step() {
|
||||
printf '\033[36m ● %s\033[0m\n' "$1"
|
||||
remote_step=$((remote_step + 1))
|
||||
overall_step=$((remote_step_offset + remote_step))
|
||||
if [ "$colour_output" -eq 1 ]; then
|
||||
printf '\033[35m ● \033[36m[%s/%s] %s\033[0m\n' "$overall_step" "$total_steps" "$1"
|
||||
else
|
||||
printf ' * [%s/%s] %s\n' "$overall_step" "$total_steps" "$1"
|
||||
fi
|
||||
}
|
||||
|
||||
detail() {
|
||||
printf '\033[90m ↳ %s\033[0m\n' "$1"
|
||||
if [ "$colour_output" -eq 1 ]; then printf '\033[37m ↳ %s\033[0m\n' "$1"; else printf ' - %s\n' "$1"; fi
|
||||
}
|
||||
|
||||
success() {
|
||||
printf '\033[32m ✓ %s\033[0m\n' "$1"
|
||||
if [ "$colour_output" -eq 1 ]; then printf '\033[32m ✓ %s\033[0m\n' "$1"; else printf ' OK %s\n' "$1"; fi
|
||||
}
|
||||
|
||||
failure() {
|
||||
printf '\033[31m ✗ %s\033[0m\n' "$1" >&2
|
||||
if [ "$colour_output" -eq 1 ]; then printf '\033[31m ✗ %s\033[0m\n' "$1" >&2; else printf ' FAILED %s\n' "$1" >&2; fi
|
||||
}
|
||||
|
||||
start_restored_stack() {
|
||||
if [ "$skip_builder" -eq 1 ]; then
|
||||
# Restore the previous gateway/admin sources without rebuilding the large
|
||||
# Android SDK image or touching the still-running builder, database and cache.
|
||||
docker compose build server memby-admin >/dev/null 2>&1 &&
|
||||
docker compose up -d --no-build --no-deps memby-admin server >/dev/null 2>&1
|
||||
else
|
||||
docker compose up -d --build --remove-orphans >/dev/null 2>&1
|
||||
fi
|
||||
}
|
||||
|
||||
rollback() {
|
||||
@@ -594,7 +976,12 @@ rollback() {
|
||||
detail "Stopping the incomplete application release"
|
||||
(
|
||||
cd "$destination"
|
||||
if [ "$skip_builder" -eq 1 ]; then
|
||||
docker compose stop server memby-admin >/dev/null 2>&1
|
||||
docker compose rm -f server memby-admin >/dev/null 2>&1
|
||||
else
|
||||
docker compose down --remove-orphans >/dev/null 2>&1
|
||||
fi
|
||||
) || true
|
||||
fi
|
||||
|
||||
@@ -604,7 +991,7 @@ rollback() {
|
||||
mv -- "$backup" "$destination"
|
||||
(
|
||||
cd "$destination"
|
||||
docker compose up -d --build --remove-orphans >/dev/null 2>&1
|
||||
start_restored_stack
|
||||
) || true
|
||||
failure "Previous application files were restored"
|
||||
fi
|
||||
@@ -612,7 +999,7 @@ rollback() {
|
||||
detail "Restarting the previous application release"
|
||||
(
|
||||
cd "$destination"
|
||||
docker compose up -d --build --remove-orphans >/dev/null 2>&1
|
||||
start_restored_stack
|
||||
) || true
|
||||
fi
|
||||
|
||||
@@ -645,6 +1032,10 @@ wait_for_service() {
|
||||
|
||||
sleep 2
|
||||
elapsed=$((elapsed + 2))
|
||||
if [ $((elapsed % 10)) -eq 0 ]; then
|
||||
remaining=$((health_timeout - elapsed))
|
||||
detail "$service is still ${state:-starting} (${health:-health pending}) · ${elapsed}s elapsed · up to ${remaining}s remaining"
|
||||
fi
|
||||
done
|
||||
|
||||
failure "$service did not become healthy within ${health_timeout}s"
|
||||
@@ -655,7 +1046,7 @@ wait_for_service() {
|
||||
trap rollback EXIT
|
||||
trap 'exit 130' INT TERM
|
||||
|
||||
step '[remote 1/10] Checking Docker'
|
||||
step 'Checking Docker'
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
failure 'Docker is not installed on the NAS'
|
||||
exit 1
|
||||
@@ -675,7 +1066,7 @@ fi
|
||||
success "$(docker --version)"
|
||||
success "$(docker compose version)"
|
||||
|
||||
step '[remote 2/10] Extracting the release'
|
||||
step 'Extracting the release'
|
||||
# Checked before anything is created: the staging directory, the swap and the backup all
|
||||
# need write access to the parent, and "can't create directory" from BusyBox halfway
|
||||
# through a deployment is a poor way to learn the account cannot write there.
|
||||
@@ -699,26 +1090,37 @@ test -f "$staging/docker-compose.yml"
|
||||
test -f "$staging/server/Dockerfile"
|
||||
test -f "$staging/admin-ui/Dockerfile"
|
||||
test -f "$staging/admin-ui/package.json"
|
||||
test -f "$staging/builder/Dockerfile"
|
||||
test -f "$staging/builder/release.sh"
|
||||
test -f "$staging/builder/controller.go"
|
||||
if [ "$publish_release" -eq 1 ]; then
|
||||
test -f "$staging/release/version.txt"
|
||||
test -f "$staging/release/sha256.txt"
|
||||
fi
|
||||
success 'Release extracted'
|
||||
|
||||
step '[remote 3/10] Installing configuration from .env.example'
|
||||
step 'Installing configuration from .env.example'
|
||||
# The local .env.example is the single source of truth for configuration and carries
|
||||
# real values rather than placeholders. Every deployment overwrites the deployed .env
|
||||
# with it, so neither a Git push nor an SSH edit is needed.
|
||||
previous_password=''
|
||||
previous_admin_token=''
|
||||
previous_release_token=''
|
||||
if [ -f "$destination/.env" ]; then
|
||||
previous_password=$(sed -n 's/^POSTGRES_PASSWORD=//p' "$destination/.env" | head -n 1 | tr -d '\r')
|
||||
# The token of the gateway that is still running, which is the one that can still
|
||||
# tell the televisions anything. It is usually the same as the incoming one, but
|
||||
# reading it from the release being replaced is what makes that not a requirement.
|
||||
previous_admin_token=$(sed -n 's/^MEMBY_ADMIN_TOKEN=//p' "$destination/.env" | head -n 1 | tr -d '\r')
|
||||
# First Docker-builder deployment only: migrate the established publish token out
|
||||
# of the old environment file rather than making the operator rotate it mid-release.
|
||||
previous_release_token=$(sed -n 's/^MEMBY_RELEASE_PUBLISH_TOKEN=//p' "$destination/.env" | head -n 1 | tr -d '\r')
|
||||
# Kept beside the new one purely so a bad edit is recoverable by hand.
|
||||
cp -- "$destination/.env" "$staging/.env.previous"
|
||||
# The migrated release credential belongs only in the external secret file. Do not
|
||||
# perpetuate it in the recovery copy once it has been captured in memory.
|
||||
sed -i '/^MEMBY_RELEASE_PUBLISH_TOKEN=/d' "$staging/.env.previous"
|
||||
chmod 600 "$staging/.env.previous" 2>/dev/null || true
|
||||
detail 'Previous .env saved as .env.previous'
|
||||
fi
|
||||
|
||||
@@ -729,7 +1131,7 @@ new_password=$(sed -n 's/^POSTGRES_PASSWORD=//p' "$staging/.env" | head -n 1 | t
|
||||
admin_token=$(sed -n 's/^MEMBY_ADMIN_TOKEN=//p' "$staging/.env" | head -n 1 | tr -d '\r')
|
||||
emby_url=$(sed -n 's/^MEMBY_EMBY_URL=//p' "$staging/.env" | head -n 1 | tr -d '\r')
|
||||
configured_port=$(sed -n 's/^MEMBY_PORT=//p' "$staging/.env" | head -n 1 | tr -d '\r')
|
||||
release_token=$(sed -n 's/^MEMBY_RELEASE_PUBLISH_TOKEN=//p' "$staging/.env" | head -n 1 | tr -d '\r')
|
||||
secrets_dir=$(sed -n 's/^MEMBY_SECRETS_DIR=//p' "$staging/.env" | head -n 1 | tr -d '\r')
|
||||
if [ -z "$new_password" ]; then
|
||||
failure 'POSTGRES_PASSWORD is empty in .env.example; Compose will refuse to start'
|
||||
exit 1
|
||||
@@ -746,10 +1148,64 @@ if [ "$configured_port" != '32768' ]; then
|
||||
failure "MEMBY_PORT must be 32768 for the mserver.sublogue.com reverse proxy (found: ${configured_port:-unset})"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$publish_release" -eq 1 ] && [ -z "$release_token" ]; then
|
||||
failure 'MEMBY_RELEASE_PUBLISH_TOKEN is empty; the signed APK cannot be published'
|
||||
if [ -z "$secrets_dir" ] || [ "${secrets_dir#/}" = "$secrets_dir" ]; then
|
||||
failure 'MEMBY_SECRETS_DIR must be an absolute NAS path outside the deployment directory'
|
||||
exit 1
|
||||
fi
|
||||
release_token_file="$secrets_dir/memby-release-publish-token"
|
||||
if [ ! -s "$release_token_file" ]; then
|
||||
if [ -n "$previous_release_token" ]; then
|
||||
mkdir -p -- "$secrets_dir"
|
||||
chmod 700 "$secrets_dir" 2>/dev/null || true
|
||||
old_umask=$(umask)
|
||||
umask 077
|
||||
printf '%s\n' "$previous_release_token" > "$release_token_file"
|
||||
umask "$old_umask"
|
||||
success "Migrated the existing release publish token to $release_token_file"
|
||||
else
|
||||
failure "Release publish secret is missing or empty: $release_token_file"
|
||||
detail 'Create it with the existing release token; do not generate a replacement if CI still uses that token'
|
||||
fi
|
||||
fi
|
||||
|
||||
missing_builder_secrets=0
|
||||
check_required_secret() {
|
||||
required_secret="$1"
|
||||
if [ ! -s "$required_secret" ]; then
|
||||
failure "Required secret is missing or empty: $required_secret"
|
||||
missing_builder_secrets=1
|
||||
fi
|
||||
}
|
||||
check_required_secret "$release_token_file"
|
||||
if [ "$skip_builder" -ne 1 ]; then
|
||||
check_required_secret "$secrets_dir/memby-release.jks"
|
||||
check_required_secret "$secrets_dir/memby-keystore-password"
|
||||
check_required_secret "$secrets_dir/memby-key-alias"
|
||||
check_required_secret "$secrets_dir/memby-key-password"
|
||||
fi
|
||||
if [ "$missing_builder_secrets" -ne 0 ]; then
|
||||
if [ "$skip_builder" -eq 1 ]; then
|
||||
detail 'Restore the existing gateway release token at the path above, then rerun this deployment'
|
||||
else
|
||||
detail 'Copy the existing signing identity and its three values to the paths above, then rerun this deployment'
|
||||
detail 'Never create a new keystore: installed Memby clients can upgrade only from the existing certificate'
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
# Docker Compose file-backed secrets are read-only bind mounts on the NAS. The gateway
|
||||
# and builder deliberately run as uid 65532, so files created as the SSH user with 0600
|
||||
# would be present but unreadable in those containers. The 0700 parent prevents every
|
||||
# other NAS account from traversing to them; 0444 makes only the read-only secret mounts
|
||||
# usable by the non-root container processes and also prevents accidental host writes.
|
||||
chmod 700 "$secrets_dir"
|
||||
chmod 444 "$release_token_file"
|
||||
if [ "$skip_builder" -ne 1 ]; then
|
||||
chmod 444 \
|
||||
"$secrets_dir/memby-release.jks" \
|
||||
"$secrets_dir/memby-keystore-password" \
|
||||
"$secrets_dir/memby-key-alias" \
|
||||
"$secrets_dir/memby-key-password"
|
||||
fi
|
||||
success 'Required gateway configuration is present'
|
||||
if [ -n "$previous_password" ] && [ "$previous_password" != "$new_password" ]; then
|
||||
failure 'POSTGRES_PASSWORD differs from the deployed value'
|
||||
@@ -764,7 +1220,7 @@ fi
|
||||
)
|
||||
success 'Compose configuration is valid'
|
||||
|
||||
step '[remote 4/10] Telling the televisions'
|
||||
step 'Telling the televisions'
|
||||
# Announced here rather than at the swap, and the position is the point of it: the
|
||||
# gateway being replaced is still answering, and the build below takes minutes, which is
|
||||
# the window in which every open television polls /v1/status and collects the notice.
|
||||
@@ -793,35 +1249,63 @@ else
|
||||
esac
|
||||
fi
|
||||
|
||||
step '[remote 5/10] Pulling PostgreSQL and Redis'
|
||||
step 'Pulling PostgreSQL and Redis'
|
||||
(
|
||||
cd "$staging"
|
||||
docker compose pull postgres redis
|
||||
)
|
||||
success 'Dependency images are ready'
|
||||
|
||||
step '[remote 6/10] Building the gateway and admin console'
|
||||
if [ "$skip_builder" -eq 1 ]; then
|
||||
step 'Building the gateway and admin console; reusing the Android builder'
|
||||
else
|
||||
step 'Building the gateway, admin console and Android builder'
|
||||
fi
|
||||
(
|
||||
cd "$staging"
|
||||
# `up` reuses an existing image when one is present. Build both local contexts here,
|
||||
# otherwise a new React/nginx console can be packaged and activated while the NAS
|
||||
# continues to serve the previous console image (and its old route configuration).
|
||||
if [ "$skip_builder" -eq 1 ]; then
|
||||
existing_builder=$(docker compose ps -q memby-builder 2>/dev/null || true)
|
||||
if [ -z "$existing_builder" ] ||
|
||||
[ "$(docker inspect --format '{{.State.Status}}' "$existing_builder" 2>/dev/null || true)" != 'running' ]; then
|
||||
failure 'No running memby-builder container is available to preserve'
|
||||
detail 'Run once without -SkipBuilder to install and start the Android builder'
|
||||
exit 1
|
||||
fi
|
||||
docker compose build --pull server memby-admin
|
||||
else
|
||||
docker compose build --pull server memby-admin memby-builder
|
||||
fi
|
||||
)
|
||||
success 'Gateway and admin console images built'
|
||||
if [ "$skip_builder" -eq 1 ]; then
|
||||
success 'Gateway and admin console images built; Android builder container retained'
|
||||
else
|
||||
success 'Gateway, admin console and Android builder images built'
|
||||
fi
|
||||
|
||||
step '[remote 7/10] Activating the release'
|
||||
step 'Activating the release'
|
||||
rm -rf -- "$backup"
|
||||
if [ -e "$destination" ] || [ -L "$destination" ]; then
|
||||
if [ -f "$destination/docker-compose.yml" ]; then
|
||||
# Compose projects created by older releases may use a different project
|
||||
# name. Stop them from their original directory before moving it so their
|
||||
# published ports (especially 32768) are released for the new stack.
|
||||
if [ "$skip_builder" -eq 1 ]; then
|
||||
detail 'Stopping only the gateway and admin console; preserving builder, database and cache'
|
||||
(
|
||||
cd "$destination"
|
||||
docker compose stop server memby-admin
|
||||
docker compose rm -f server memby-admin
|
||||
)
|
||||
else
|
||||
detail 'Stopping the previous Compose application'
|
||||
(
|
||||
cd "$destination"
|
||||
docker compose down --remove-orphans
|
||||
)
|
||||
fi
|
||||
previous_stopped=1
|
||||
success 'Previous Compose application stopped'
|
||||
fi
|
||||
@@ -832,16 +1316,29 @@ mv -- "$staging" "$destination"
|
||||
activated=1
|
||||
success 'Release activated'
|
||||
|
||||
step '[remote 8/10] Starting the Compose stack'
|
||||
step 'Starting the Compose stack'
|
||||
cd "$destination"
|
||||
docker compose up -d --remove-orphans
|
||||
if [ "$skip_builder" -eq 1 ]; then
|
||||
compose_start='docker compose up -d --no-build --no-deps memby-admin server'
|
||||
else
|
||||
compose_start='docker compose up -d --no-build --remove-orphans'
|
||||
fi
|
||||
if ! $compose_start; then
|
||||
failure 'Compose could not start the complete application'
|
||||
detail 'Container state before rollback:'
|
||||
docker compose ps --all || true
|
||||
detail 'Gateway and builder logs before rollback:'
|
||||
docker compose logs --no-color --tail 100 server memby-builder || true
|
||||
exit 1
|
||||
fi
|
||||
success 'Compose start command completed'
|
||||
|
||||
step '[remote 9/10] Waiting for healthy services'
|
||||
step 'Waiting for healthy services'
|
||||
wait_for_service postgres
|
||||
wait_for_service redis
|
||||
wait_for_service memby-admin
|
||||
wait_for_service server
|
||||
wait_for_service memby-builder
|
||||
|
||||
published_address=$(docker compose port server 32768 | head -n 1)
|
||||
actual_port=${published_address##*:}
|
||||
@@ -858,7 +1355,7 @@ if ! docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$serve
|
||||
fi
|
||||
success 'Runtime configuration includes the admin token'
|
||||
|
||||
step '[remote 10/10] Publishing the signed Android update'
|
||||
step 'Publishing the signed Android update'
|
||||
if [ "$publish_release" -eq 1 ]; then
|
||||
release_version=$(tr -d '\r\n' < "$destination/release/version.txt")
|
||||
release_sha256=$(tr -d '\r\n' < "$destination/release/sha256.txt")
|
||||
@@ -877,11 +1374,11 @@ if [ "$publish_release" -eq 1 ]; then
|
||||
fi
|
||||
|
||||
release_response="$destination/release/publish-response.json"
|
||||
release_status=$(curl --show-error --silent \
|
||||
release_token=$(tr -d '\r\n' < "$release_token_file")
|
||||
release_status=$(printf 'header = "Authorization: Bearer %s"\n' "$release_token" | curl --config - --show-error --silent \
|
||||
--output "$release_response" \
|
||||
--write-out '%{http_code}' \
|
||||
-X POST \
|
||||
-H "Authorization: Bearer $release_token" \
|
||||
-F "version=$release_version" \
|
||||
-F "sha256=$release_sha256" \
|
||||
-F "mandatory=$mandatory_update" \
|
||||
@@ -921,6 +1418,10 @@ success 'Memby gateway: https://mserver.sublogue.com'
|
||||
'__PUBLISH_RELEASE__',
|
||||
$(if ($SkipAppRelease) { '0' } else { '1' })
|
||||
)
|
||||
$remoteCommand = $remoteCommand.Replace(
|
||||
'__SKIP_BUILDER__',
|
||||
$(if ($SkipBuilder) { '1' } else { '0' })
|
||||
)
|
||||
$remoteCommand = $remoteCommand.Replace(
|
||||
'__MANDATORY_UPDATE__',
|
||||
$(if ($mandatoryRelease) { '1' } else { '0' })
|
||||
@@ -929,33 +1430,47 @@ success 'Memby gateway: https://mserver.sublogue.com'
|
||||
'__QUIET_DEPLOYMENT__',
|
||||
$(if ($quietDeployment) { '1' } else { '0' })
|
||||
)
|
||||
$remoteCommand = $remoteCommand.Replace(
|
||||
'__COLOUR_OUTPUT__',
|
||||
$(if ($script:UseColour -and -not [Console]::IsOutputRedirected) { '1' } else { '0' })
|
||||
)
|
||||
$remoteCommand = $remoteCommand.Replace('__REMOTE_STEP_OFFSET__', $script:LocalStepCount.ToString())
|
||||
$remoteCommand = $remoteCommand.Replace('__TOTAL_STEPS__', $script:TotalSteps.ToString())
|
||||
# This file is edited on Windows, so the here-string above arrives with whatever line
|
||||
# endings it was saved with. A remote shell reads a trailing carriage return as part
|
||||
# of the token — 'set -eu\r' fails with "illegal option" before anything runs — so the
|
||||
# script is normalised to LF here rather than depending on how it was saved.
|
||||
$remoteCommand = $remoteCommand.Replace("`r`n", "`n").Replace("`r", "`n")
|
||||
|
||||
Write-Step "Deploying to $RemoteHost"
|
||||
Start-DeploymentPhase -Key 'remote-deployment' -Message "Deploying to $RemoteHost" -RemoteRange
|
||||
Write-Detail 'One SSH password prompt will appear'
|
||||
Write-Detail 'Remote build output follows'
|
||||
Write-Detail 'Remote build output follows; its steps continue the overall counter'
|
||||
Write-Host ''
|
||||
Send-ArchiveOverSsh -ArchivePath $archivePath -RemoteCommand $remoteCommand
|
||||
|
||||
Complete-DeploymentPhase
|
||||
$deploymentTimer.Stop()
|
||||
Save-DeploymentHistory -Success $true -DurationSeconds $deploymentTimer.Elapsed.TotalSeconds
|
||||
if ($script:UseAnimation) { Write-Progress -Id 1 -Activity 'Memby deployment' -Completed }
|
||||
Write-Host ''
|
||||
Write-Success ("Deployment complete in {0:mm\:ss}" -f $deploymentTimer.Elapsed)
|
||||
Write-Host ' Memby gateway: https://mserver.sublogue.com' -ForegroundColor White
|
||||
Write-Host " NAS endpoint: http://${RemoteHost}:32768" -ForegroundColor DarkGray
|
||||
Write-Host " Install path: ${RemoteHost}:$Destination" -ForegroundColor DarkGray
|
||||
Write-Styled -Message ' Memby gateway: https://mserver.sublogue.com' -Colour White
|
||||
Write-Styled -Message " NAS endpoint: http://${RemoteHost}:32768" -Colour Gray
|
||||
Write-Styled -Message " Install path: ${RemoteHost}:$Destination" -Colour Gray
|
||||
if (-not $SkipAppRelease) {
|
||||
Write-Host " TV update: Memby $releaseVersion (signed and published)" -ForegroundColor White
|
||||
Write-Styled -Message " TV update: Memby $releaseVersion (signed and published)" -Colour White
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Complete-DeploymentPhase
|
||||
$deploymentTimer.Stop()
|
||||
if ($script:UseAnimation) {
|
||||
Write-Progress -Id 2 -Activity 'Uploading deployment archive' -Completed
|
||||
Write-Progress -Id 1 -Activity 'Memby deployment' -Completed
|
||||
}
|
||||
Write-Host ''
|
||||
Write-Host "✗ Deployment stopped after $($deploymentTimer.Elapsed.ToString('mm\:ss'))" -ForegroundColor Red
|
||||
Write-Host " $($_.Exception.Message)" -ForegroundColor Red
|
||||
Write-Failure "Deployment stopped after $($deploymentTimer.Elapsed.ToString('mm\:ss'))"
|
||||
Write-Styled -Message " $($_.Exception.Message)" -Colour Red
|
||||
throw
|
||||
}
|
||||
finally {
|
||||
|
||||
+67
-1
@@ -48,7 +48,13 @@ services:
|
||||
# rest of the admin API.
|
||||
MEMBY_PUBLIC_URL: "${MEMBY_PUBLIC_URL:-https://mserver.sublogue.com}"
|
||||
MEMBY_RELEASE_DIR: "/data/releases"
|
||||
MEMBY_RELEASE_PUBLISH_TOKEN: "${MEMBY_RELEASE_PUBLISH_TOKEN:-}"
|
||||
# The value is a Compose secret, not a container environment variable. This
|
||||
# keeps the release credential out of `docker inspect` while preserving the
|
||||
# existing CI/backend publish API.
|
||||
MEMBY_RELEASE_PUBLISH_TOKEN_FILE: "/run/secrets/memby_release_publish_token"
|
||||
# The gateway relays authenticated Admin Console actions to this private service.
|
||||
# The builder port is not published and the signing secrets never reach the browser.
|
||||
MEMBY_RELEASE_BUILDER_URL: "http://memby-builder:8090"
|
||||
# Hourly incremental import: enough for episodes landing through the day, and
|
||||
# films appearing weekly ride along.
|
||||
MEMBY_SYNC_INTERVAL: "${MEMBY_SYNC_INTERVAL:-1h}"
|
||||
@@ -79,6 +85,8 @@ services:
|
||||
volumes:
|
||||
- memby-releases:/data/releases
|
||||
- memby-logs:/data/logs
|
||||
secrets:
|
||||
- memby_release_publish_token
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
@@ -97,6 +105,51 @@ services:
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
# An isolated Android toolchain. Its small controller waits for an authenticated
|
||||
# Admin Console request; the same image remains directly runnable as the CLI fallback.
|
||||
# It fetches the newest semantic GitHub tag,
|
||||
# builds with the repository's Gradle wrapper, verifies the existing signing
|
||||
# certificate, and publishes through the gateway's atomic release endpoint.
|
||||
memby-builder:
|
||||
build:
|
||||
context: ./builder
|
||||
args:
|
||||
ANDROID_COMMAND_LINE_TOOLS_VERSION: "15859902"
|
||||
ANDROID_COMMAND_LINE_TOOLS_SHA256: "4e4c464f145a7512b57d088ac6c278c03c9eea610886b35a5e0804e74eedf583"
|
||||
ANDROID_PLATFORM: "35"
|
||||
ANDROID_BUILD_TOOLS: "35.0.0"
|
||||
environment:
|
||||
MEMBY_SOURCE_REPOSITORY: "${MEMBY_SOURCE_REPOSITORY:-https://github.com/ponzischeme89/memby.git}"
|
||||
MEMBY_RELEASE_TAG: "${MEMBY_RELEASE_TAG:-}"
|
||||
MEMBY_RELEASE_NOTES: "${MEMBY_RELEASE_NOTES:-}"
|
||||
MEMBY_RELEASE_MANDATORY: "${MEMBY_RELEASE_MANDATORY:-false}"
|
||||
MEMBY_SKIP_APP_TESTS: "${MEMBY_SKIP_APP_TESTS:-false}"
|
||||
MEMBY_SOURCE_URL: "${MEMBY_SOURCE_URL:-}"
|
||||
MEMBY_RELEASE_PUBLISH_URL: "http://server:32768/admin/api/release"
|
||||
command: ["serve"]
|
||||
restart: unless-stopped
|
||||
expose:
|
||||
- "8090"
|
||||
volumes:
|
||||
- memby-gradle-cache:/home/memby/.gradle
|
||||
- memby-releases:/data/releases
|
||||
secrets:
|
||||
- memby_android_keystore
|
||||
- memby_android_keystore_password
|
||||
- memby_android_key_alias
|
||||
- memby_android_key_password
|
||||
- memby_release_publish_token
|
||||
depends_on:
|
||||
server:
|
||||
condition: service_healthy
|
||||
mem_limit: "${MEMBY_BUILDER_MEMORY_LIMIT:-4g}"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "--fail", "--silent", "http://127.0.0.1:8090/healthz"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 5s
|
||||
|
||||
# The operations console: a React application built at image time and served by nginx.
|
||||
#
|
||||
# Deliberately not published. The household's reverse proxy sends one hostname to the
|
||||
@@ -152,3 +205,16 @@ volumes:
|
||||
memby-postgres:
|
||||
memby-releases:
|
||||
memby-logs:
|
||||
memby-gradle-cache:
|
||||
|
||||
secrets:
|
||||
memby_android_keystore:
|
||||
file: "${MEMBY_SECRETS_DIR:-./secrets}/memby-release.jks"
|
||||
memby_android_keystore_password:
|
||||
file: "${MEMBY_SECRETS_DIR:-./secrets}/memby-keystore-password"
|
||||
memby_android_key_alias:
|
||||
file: "${MEMBY_SECRETS_DIR:-./secrets}/memby-key-alias"
|
||||
memby_android_key_password:
|
||||
file: "${MEMBY_SECRETS_DIR:-./secrets}/memby-key-password"
|
||||
memby_release_publish_token:
|
||||
file: "${MEMBY_SECRETS_DIR:-./secrets}/memby-release-publish-token"
|
||||
|
||||
+32
@@ -8,6 +8,7 @@
|
||||
index.html landing page people are sent to
|
||||
latest.json update manifest the app polls
|
||||
memby-<version>.apk the build itself
|
||||
*.apk.sha256 SHA-256 checksum for the build
|
||||
LICENSE / NOTICE licence terms and acknowledgements
|
||||
|
||||
Copy that folder to whatever the NAS serves, keeping the file names. Old APKs can
|
||||
@@ -85,6 +86,33 @@ if (-not (Test-Path $apk)) {
|
||||
throw "No release APK found at $apk"
|
||||
}
|
||||
|
||||
# A successful Gradle task is not sufficient evidence for a release: verify the
|
||||
# finished archive with the Android SDK tool before it can enter dist/out.
|
||||
$sdk = $env:ANDROID_HOME
|
||||
if (-not $sdk) {
|
||||
$sdkLine = Get-Content -LiteralPath (Join-Path $root 'local.properties') |
|
||||
Where-Object { $_ -match '^sdk\.dir=' } |
|
||||
Select-Object -First 1
|
||||
if ($sdkLine) {
|
||||
$sdk = $sdkLine.Substring($sdkLine.IndexOf('=') + 1).
|
||||
Replace('\:', ':').
|
||||
Replace('\\', '\')
|
||||
}
|
||||
}
|
||||
if (-not $sdk -or -not (Test-Path -LiteralPath $sdk -PathType Container)) {
|
||||
throw 'Android SDK not found; the release signature cannot be verified.'
|
||||
}
|
||||
$buildTools = Get-ChildItem -LiteralPath (Join-Path $sdk 'build-tools') -Directory |
|
||||
Sort-Object { [version]$_.Name } -Descending |
|
||||
Select-Object -First 1
|
||||
if (-not $buildTools) { throw 'Android SDK Build Tools are not installed.' }
|
||||
$apkSigner = Join-Path $buildTools.FullName 'apksigner.bat'
|
||||
if (-not (Test-Path -LiteralPath $apkSigner -PathType Leaf)) {
|
||||
throw "APK signer not found: $apkSigner"
|
||||
}
|
||||
& $apkSigner verify --verbose --print-certs $apk
|
||||
if ($LASTEXITCODE -ne 0) { throw 'APK signature verification failed' }
|
||||
|
||||
# --- assemble the publish folder -------------------------------------------
|
||||
|
||||
$apkName = "memby-$Version.apk"
|
||||
@@ -94,6 +122,9 @@ if (Test-Path $outDir) { Remove-Item $outDir -Recurse -Force }
|
||||
New-Item -ItemType Directory -Force $outDir | Out-Null
|
||||
|
||||
Copy-Item $apk (Join-Path $outDir $apkName)
|
||||
$sha256 = (Get-FileHash -LiteralPath $apk -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
Set-Content -LiteralPath (Join-Path $outDir "$apkName.sha256") `
|
||||
-Value "$sha256 $apkName" -Encoding ascii
|
||||
Copy-Item (Join-Path $root 'LICENSE') (Join-Path $outDir 'LICENSE')
|
||||
$notice = (Get-Content -Raw (Join-Path $root 'NOTICE')).
|
||||
Replace('https://g.sublogue.com/admin/memby', $SourceUrl)
|
||||
@@ -104,6 +135,7 @@ Set-Content -LiteralPath (Join-Path $outDir 'NOTICE') -Value $notice -Encoding u
|
||||
$manifest = [ordered]@{
|
||||
version = $Version
|
||||
apkUrl = $apkName
|
||||
sha256 = $sha256
|
||||
notes = $Notes
|
||||
} | ConvertTo-Json
|
||||
Set-Content -LiteralPath (Join-Path $outDir 'latest.json') -Value $manifest -Encoding utf8
|
||||
|
||||
+21
-3
@@ -11,12 +11,30 @@ COPY . .
|
||||
RUN mkdir -p /out/releases /out/logs
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -buildvcs=false \
|
||||
-ldflags="-s -w" -o /out/memby-server ./cmd/memby-server
|
||||
# The credits bench ships in the image because the number it prints is only meaningful
|
||||
# against the household's own media on its own hardware — a figure measured anywhere else
|
||||
# proves nothing about whether a scan on this NAS is cheap.
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -buildvcs=false \
|
||||
-ldflags="-s -w" -o /out/memby-credits ./cmd/memby-credits
|
||||
|
||||
# distroless/static carries CA certificates, which the gateway needs to reach an
|
||||
# HTTPS Emby server, and runs as a non-root user by default.
|
||||
FROM gcr.io/distroless/static-debian12:nonroot
|
||||
# Alpine rather than distroless/static, and the reason is ffmpeg.
|
||||
#
|
||||
# Credits detection is the only thing in the gateway that reads media bytes, and it does so
|
||||
# through ffmpeg used surgically — seek to the tail, downscale to a thumbnail, one frame every
|
||||
# few seconds, raw grayscale on stdout, nothing written to disk. A static distroless image
|
||||
# cannot carry a decoder, so the choice is this base or no visual detection at all.
|
||||
#
|
||||
# What is preserved from distroless: CA certificates, so an HTTPS Emby is reachable, and a
|
||||
# non-root user. What is given up: about a hundred megabytes, and a shell existing in the
|
||||
# image. The container healthcheck still re-runs the binary with -healthcheck rather than
|
||||
# using curl, so it is unchanged by the move and stays honest if the base ever goes back.
|
||||
FROM alpine:3.21
|
||||
RUN apk add --no-cache ca-certificates ffmpeg \
|
||||
&& addgroup -g 65532 -S nonroot \
|
||||
&& adduser -u 65532 -S -G nonroot nonroot
|
||||
WORKDIR /app
|
||||
COPY --from=build /out/memby-server /app/memby-server
|
||||
COPY --from=build /out/memby-credits /app/memby-credits
|
||||
COPY --from=build --chown=nonroot:nonroot /out/releases /data/releases
|
||||
COPY --from=build --chown=nonroot:nonroot /out/logs /data/logs
|
||||
EXPOSE 8080
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
// Command memby-credits is the credits subsystem's bench, run as
|
||||
//
|
||||
// memby-credits benchmark <emby-item-id>
|
||||
//
|
||||
// It exists because "is this cheap" is not a question a unit test can answer and not one an
|
||||
// opinion should settle. Every claim the package makes — that a scan reads a couple of
|
||||
// minutes rather than a file, that season history narrows the window, that analysis finishes
|
||||
// in seconds on modest hardware — is a number, and this is what prints them against real
|
||||
// media on the NAS.
|
||||
//
|
||||
// The line that matters most is the window's provenance. A run reporting
|
||||
// "generic-tail-window" every time is a run in which demand-driven narrowing is doing
|
||||
// nothing, and the whole design would need revisiting.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"runtime"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/config"
|
||||
"github.com/ponzischeme89/memby/server/internal/credits"
|
||||
"github.com/ponzischeme89/memby/server/internal/emby"
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 3 || os.Args[1] != "benchmark" {
|
||||
fmt.Fprintln(os.Stderr, "usage: memby-credits benchmark <emby-item-id>")
|
||||
os.Exit(2)
|
||||
}
|
||||
if err := run(os.Args[2]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(itemID string) error {
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
embyClient := emby.New(
|
||||
cfg.EmbyURL, cfg.EmbyPublicURL, cfg.ClientName, cfg.GatewayClientName,
|
||||
cfg.UpstreamTimeout,
|
||||
)
|
||||
resolver := credits.NewEmbyResolver(embyClient, emby.Credentials{
|
||||
UserID: cfg.SyncUserID, Token: cfg.SyncAPIKey,
|
||||
DeviceID: "memby-credits-bench", DeviceName: "MbyGateway Credits", Gateway: true,
|
||||
})
|
||||
resolved, err := resolver.Resolve(ctx, itemID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve %s: %w", itemID, err)
|
||||
}
|
||||
if resolved.RuntimeMs <= 0 {
|
||||
return fmt.Errorf("item %s reports no runtime; nothing to scan", itemID)
|
||||
}
|
||||
|
||||
// The database is optional here on purpose. A benchmark that could not run without a
|
||||
// Postgres would be one nobody runs, and the two things it contributes — season history
|
||||
// and behavioural evidence — are exactly the two the report is meant to show the value
|
||||
// of, so their absence is worth being able to measure as the baseline.
|
||||
var (
|
||||
history []credits.Marker
|
||||
runtimeOf func(credits.Marker) int64
|
||||
evidence credits.BehaviourEvidence
|
||||
)
|
||||
if st, err := store.Open(ctx, cfg.DatabaseURL); err == nil {
|
||||
defer st.Close()
|
||||
database := credits.Postgres{Store: st}
|
||||
if resolved.SeriesID != "" && resolved.Season > 0 {
|
||||
if markers, err := database.SeasonMarkers(
|
||||
ctx, resolved.SeriesID, resolved.Season, 6,
|
||||
); err == nil {
|
||||
history = markers
|
||||
episodeRuntime := resolved.RuntimeMs
|
||||
runtimeOf = func(credits.Marker) int64 { return episodeRuntime }
|
||||
}
|
||||
}
|
||||
if stops, err := database.Stops(ctx, itemID); err == nil {
|
||||
evidence = credits.AnalyseStops(stops, resolved.RuntimeMs)
|
||||
}
|
||||
} else {
|
||||
fmt.Println("note: no database; measuring the un-narrowed baseline")
|
||||
}
|
||||
|
||||
window := credits.NarrowWindow(resolved.RuntimeMs, history, runtimeOf, evidence)
|
||||
sampler := &credits.Sampler{Binary: cfg.CreditsFFmpeg}
|
||||
if !sampler.Available() {
|
||||
return fmt.Errorf("ffmpeg not found; set MEMBY_CREDITS_FFMPEG")
|
||||
}
|
||||
|
||||
var before, after runtime.MemStats
|
||||
runtime.GC()
|
||||
runtime.ReadMemStats(&before)
|
||||
|
||||
started := time.Now()
|
||||
detector := &credits.VisualDetector{Sampler: sampler}
|
||||
detection, err := detector.Detect(ctx, credits.MediaInfo{
|
||||
URL: resolved.URL, RuntimeMs: resolved.RuntimeMs, Window: window,
|
||||
})
|
||||
elapsed := time.Since(started)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.ReadMemStats(&after)
|
||||
|
||||
combined, acceptable := credits.Combine(detection, evidence)
|
||||
|
||||
fmt.Printf("Episode runtime: %s\n", clock(resolved.RuntimeMs))
|
||||
if evidence.Found {
|
||||
fmt.Printf("Tracearr cluster: %s (%d viewers, spread %ds)\n",
|
||||
clock(evidence.StartMs), evidence.UserCount, evidence.SpreadMs/1000)
|
||||
}
|
||||
fmt.Printf("Scan region: %s – %s (%s)\n",
|
||||
clock(window.StartMs), clock(window.EndMs), window.Source)
|
||||
fmt.Printf("Season history: %d marker(s)\n", len(history))
|
||||
// Estimated rather than measured: ffmpeg does not report how much of its input it read,
|
||||
// and a proxy to find out would cost more than the number is worth. Derived from the
|
||||
// window as a fraction of the file, which is the ratio the whole design turns on.
|
||||
fmt.Printf("Bytes read: ~%s (estimated, %.1f%% of file)\n",
|
||||
bytesLabel(estimateBytes(resolved.Version.SizeBytes, window, resolved.RuntimeMs)),
|
||||
100*float64(window.DurationMs())/float64(resolved.RuntimeMs))
|
||||
fmt.Printf("Frames sampled: %d\n", detection.FramesSampled)
|
||||
fmt.Printf("Peak memory: ~%s\n", bytesLabel(int64(after.TotalAlloc-before.TotalAlloc)))
|
||||
fmt.Printf("Analysis time: %.1fs\n", elapsed.Seconds())
|
||||
|
||||
if !detection.Found {
|
||||
fmt.Println("Detected marker: none (visual)")
|
||||
} else {
|
||||
fmt.Printf("Detected marker: %s\n", clock(detection.StartMs))
|
||||
fmt.Printf("Confidence: %.2f\n", detection.Confidence)
|
||||
}
|
||||
if acceptable {
|
||||
fmt.Printf("Stored marker would be: %s confidence %.2f method %s\n",
|
||||
clock(combined.StartMs), combined.Confidence, combined.Method)
|
||||
} else {
|
||||
fmt.Println("Stored marker would be: none — below the confidence threshold")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// estimateBytes is the window as a share of the file. Crude, and honest about being crude:
|
||||
// the point of the figure is the order of magnitude, and a scan reading two minutes of a
|
||||
// forty-four minute episode reads about five percent of it whatever the container does.
|
||||
func estimateBytes(sizeBytes int64, window credits.ScanWindow, runtimeMs int64) int64 {
|
||||
if sizeBytes <= 0 || runtimeMs <= 0 {
|
||||
return 0
|
||||
}
|
||||
return int64(float64(sizeBytes) * float64(window.DurationMs()) / float64(runtimeMs))
|
||||
}
|
||||
|
||||
func clock(ms int64) string {
|
||||
total := ms / 1000
|
||||
if hours := total / 3600; hours > 0 {
|
||||
return fmt.Sprintf("%dh %02dm %02ds", hours, (total%3600)/60, total%60)
|
||||
}
|
||||
return fmt.Sprintf("%dm %02ds", total/60, total%60)
|
||||
}
|
||||
|
||||
func bytesLabel(value int64) string {
|
||||
switch {
|
||||
case value <= 0:
|
||||
return "unknown"
|
||||
case value > 1<<20:
|
||||
return fmt.Sprintf("%.1f MB", float64(value)/(1<<20))
|
||||
case value > 1<<10:
|
||||
return fmt.Sprintf("%.1f KB", float64(value)/(1<<10))
|
||||
default:
|
||||
return fmt.Sprintf("%d B", value)
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"github.com/ponzischeme89/memby/server/internal/buildinfo"
|
||||
"github.com/ponzischeme89/memby/server/internal/cache"
|
||||
"github.com/ponzischeme89/memby/server/internal/config"
|
||||
"github.com/ponzischeme89/memby/server/internal/credits"
|
||||
"github.com/ponzischeme89/memby/server/internal/emby"
|
||||
"github.com/ponzischeme89/memby/server/internal/foryou"
|
||||
"github.com/ponzischeme89/memby/server/internal/integrations"
|
||||
@@ -175,6 +176,53 @@ func run(log *slog.Logger, events *logging.Buffer) error {
|
||||
})
|
||||
}
|
||||
|
||||
// Credits discovery. Deliberately built here rather than inside the API server, because
|
||||
// it owns a long-running worker and a queue whose lifetime is the process's — the server
|
||||
// only reads its markers and feeds it the two live signals it already receives.
|
||||
creditsLoad := credits.NewPlaybackLoad()
|
||||
var creditsService *credits.Service
|
||||
if cfg.CreditsEnabled {
|
||||
sampler := &credits.Sampler{Binary: cfg.CreditsFFmpeg}
|
||||
var detector credits.Detector
|
||||
if sampler.Available() {
|
||||
detector = &credits.VisualDetector{Sampler: sampler}
|
||||
} else {
|
||||
// No decoder in the image is a deliberate deployment, not a fault. The subsystem
|
||||
// still runs and still writes markers, on the household's own stop positions —
|
||||
// which cost no media access at all and, on a well-watched show, agree more
|
||||
// closely than any single reading of the picture.
|
||||
log.Warn("credits: ffmpeg not found; running on behavioural evidence only")
|
||||
}
|
||||
database := credits.Postgres{Store: st}
|
||||
creditsConfig := credits.Config{
|
||||
PrefetchEpisodes: cfg.CreditsPrefetchEpisodes,
|
||||
MaxPrefetchEpisodes: cfg.CreditsMaxPrefetch,
|
||||
QueueLimit: cfg.CreditsQueueLimit,
|
||||
StrongWindow: credits.DefaultConfig().StrongWindow,
|
||||
UsefulWindow: credits.DefaultConfig().UsefulWindow,
|
||||
WeakWindow: credits.DefaultConfig().WeakWindow,
|
||||
}
|
||||
creditsService = credits.New(credits.Deps{
|
||||
Repository: database,
|
||||
Resolver: credits.NewEmbyResolver(embyClient, emby.Credentials{
|
||||
UserID: cfg.SyncUserID, Token: cfg.SyncAPIKey,
|
||||
DeviceID: "memby-credits", DeviceName: "MbyGateway Credits",
|
||||
Gateway: true,
|
||||
}),
|
||||
Source: &credits.TracearrSource{DB: database, Cfg: creditsConfig},
|
||||
Detector: detector,
|
||||
Behaviour: database,
|
||||
Load: creditsLoad,
|
||||
Log: log,
|
||||
Config: creditsConfig,
|
||||
})
|
||||
go creditsService.Run(ctx)
|
||||
log.Info("credits detection enabled",
|
||||
"prefetch", creditsConfig.PrefetchEpisodes,
|
||||
"queue_limit", creditsConfig.QueueLimit,
|
||||
"visual", detector != nil)
|
||||
}
|
||||
|
||||
// The administrative event bus, the integration dispatcher that subscribes to it and
|
||||
// the scheduler that publishes into it are built before the server, because the server
|
||||
// takes all three: a handler that could not publish would have to check for nil at
|
||||
@@ -195,6 +243,8 @@ func run(log *slog.Logger, events *logging.Buffer) error {
|
||||
Radarr: radarrClient,
|
||||
Bazarr: bazarrClient,
|
||||
MDBList: mdblistClient,
|
||||
Credits: creditsService,
|
||||
CreditsLoad: creditsLoad,
|
||||
Syncer: syncer,
|
||||
Log: log,
|
||||
Events: events,
|
||||
@@ -207,6 +257,7 @@ func run(log *slog.Logger, events *logging.Buffer) error {
|
||||
// Registration is separate from construction so the task list reads as a declaration
|
||||
// of what the gateway does in the background rather than as more wiring in here.
|
||||
server.RegisterHousekeeping(sched)
|
||||
server.RegisterCreditsTasks(sched)
|
||||
sched.Start(ctx)
|
||||
|
||||
// Installed after the server exists, because both halves of a finished import are
|
||||
@@ -348,5 +399,3 @@ func openStore(ctx context.Context, databaseURL string, log *slog.Logger) (*stor
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ func (s *Server) adminRoutes() http.Handler {
|
||||
mux.Handle("DELETE /admin/api/accounts/{userID}/sessions", s.adminAuth(s.handleAdminDeleteAccount))
|
||||
mux.Handle("PUT /admin/api/accounts/{userID}/preferences", s.adminAuth(s.handleAdminPushPreferences))
|
||||
mux.Handle("DELETE /admin/api/accounts/{userID}/preferences", s.adminAuth(s.handleAdminResetPreferences))
|
||||
mux.Handle("PUT /admin/api/accounts/{userID}/notifications", s.adminAuth(s.handleAdminNotificationPreferences))
|
||||
mux.Handle("GET /admin/api/accounts/{userID}/preferences/history", s.adminAuth(s.handleAdminPreferenceHistory))
|
||||
mux.Handle("POST /admin/api/accounts/{userID}/preferences/revisions/{revision}/restore",
|
||||
s.adminAuth(s.handleAdminRestorePreferences))
|
||||
@@ -58,6 +59,8 @@ func (s *Server) adminRoutes() http.Handler {
|
||||
mux.Handle("POST /admin/api/maintenance", s.adminAuth(s.handleAdminMaintenance))
|
||||
mux.Handle("POST /admin/api/deployment-alert", s.adminAuth(s.handleAdminDeploymentAlert))
|
||||
mux.Handle("POST /admin/api/update-policy", s.adminAuth(s.handleAdminUpdatePolicy))
|
||||
mux.Handle("GET /admin/api/release-builder", s.adminAuth(s.handleAdminReleaseBuilderStatus))
|
||||
mux.Handle("POST /admin/api/release-builder", s.adminAuth(s.handleAdminReleaseBuilderStart))
|
||||
mux.Handle("POST /admin/api/request-policy", s.adminAuth(s.handleAdminRequestPolicy))
|
||||
mux.Handle("GET /admin/api/media-reports", s.adminAuth(s.handleAdminMediaReports))
|
||||
mux.Handle("POST /admin/api/media-reports/{id}/status", s.adminAuth(s.handleAdminMediaReportStatus))
|
||||
|
||||
@@ -44,6 +44,7 @@ type adminMembyAccount struct {
|
||||
// the defaults, and saying so is the difference between "chose this" and "has not
|
||||
// chosen anything".
|
||||
Settings adminAccountSettings `json:"settings"`
|
||||
Notifications store.NotificationPreferences `json:"notifications"`
|
||||
// Themes is the ids this person may choose between, and an empty array means every
|
||||
// selectable theme rather than none — the same permissive reading the store and
|
||||
// themeAllowed take. The console renders that as every box ticked, which is what an
|
||||
@@ -100,6 +101,12 @@ func (s *Server) handleAdminAccounts(w http.ResponseWriter, r *http.Request) {
|
||||
themes = map[string][]string{}
|
||||
}
|
||||
|
||||
notifications, err := s.store.AllNotificationPreferences(r.Context())
|
||||
if err != nil {
|
||||
s.loggerFor(r.Context()).Warn("account notification preferences read failed", "error", err)
|
||||
notifications = map[string]store.NotificationPreferences{}
|
||||
}
|
||||
|
||||
result := make([]adminMembyAccount, 0, len(accounts))
|
||||
for _, account := range accounts {
|
||||
pref := preferences[account.ID]
|
||||
@@ -125,10 +132,15 @@ func (s *Server) handleAdminAccounts(w http.ResponseWriter, r *http.Request) {
|
||||
if !stored.UpdatedAt.IsZero() {
|
||||
accountSettings.UpdatedAt = stored.UpdatedAt
|
||||
}
|
||||
notificationPrefs, savedNotifications := notifications[account.ID]
|
||||
if !savedNotifications {
|
||||
notificationPrefs = store.DefaultNotificationPreferences()
|
||||
}
|
||||
result = append(result, adminMembyAccount{
|
||||
ID: account.ID, Username: account.Username, CreatedAt: account.CreatedAt,
|
||||
LastSeen: account.LastSeen, Devices: account.Devices, Settings: accountSettings,
|
||||
Themes: nonNilStrings(themes[account.ID]),
|
||||
Notifications: notificationPrefs,
|
||||
Recommendations: adminOnboardingPreferences{
|
||||
Completed: pref.Completed, Prompted: pref.Prompted,
|
||||
Updated: len(account.RecommendationPreferences) > 2,
|
||||
@@ -153,6 +165,33 @@ func (s *Server) handleAdminAccounts(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// handleAdminNotificationPreferences changes what one person is told without requiring
|
||||
// a television or an app release. The loaded value is decoded in place so a console from
|
||||
// an older gateway generation cannot accidentally turn off fields it does not know.
|
||||
func (s *Server) handleAdminNotificationPreferences(w http.ResponseWriter, r *http.Request) {
|
||||
userID := strings.TrimSpace(r.PathValue("userID"))
|
||||
if userID == "" {
|
||||
writeError(w, http.StatusBadRequest, "user is required")
|
||||
return
|
||||
}
|
||||
prefs, err := s.notificationPreferencesFor(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not load notification settings")
|
||||
return
|
||||
}
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<10)).Decode(&prefs); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "malformed request body")
|
||||
return
|
||||
}
|
||||
if err := s.saveNotificationPreferences(r.Context(), userID, prefs); err != nil {
|
||||
s.loggerFor(r.Context()).Error("admin notification preferences save failed", "user", userID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "could not save notification settings")
|
||||
return
|
||||
}
|
||||
s.loggerFor(r.Context()).Info("notification settings saved for viewer", "user", userID)
|
||||
writeJSON(w, http.StatusOK, prefs)
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminPromptRecommendations(w http.ResponseWriter, r *http.Request) {
|
||||
userID := strings.TrimSpace(r.PathValue("userID"))
|
||||
if userID == "" {
|
||||
|
||||
@@ -27,6 +27,7 @@ import (
|
||||
"github.com/ponzischeme89/memby/server/internal/bazarr"
|
||||
"github.com/ponzischeme89/memby/server/internal/cache"
|
||||
"github.com/ponzischeme89/memby/server/internal/config"
|
||||
"github.com/ponzischeme89/memby/server/internal/credits"
|
||||
"github.com/ponzischeme89/memby/server/internal/emby"
|
||||
"github.com/ponzischeme89/memby/server/internal/foryou"
|
||||
"github.com/ponzischeme89/memby/server/internal/integrations"
|
||||
@@ -51,6 +52,12 @@ type Server struct {
|
||||
radarr *radarr.Client
|
||||
bazarr *bazarr.Client
|
||||
mdblist *mdblist.Client
|
||||
// credits discovers where an episode's closing credits begin, for the small number of
|
||||
// episodes the household is about to watch. Nil when the subsystem is switched off, and
|
||||
// every call site tolerates that — a missing marker simply means no Skip Credits button,
|
||||
// which is the same state a library with no chapter markers is already in.
|
||||
credits *credits.Service
|
||||
creditsLoad *credits.PlaybackLoad
|
||||
syncer syncerHandle
|
||||
log *slog.Logger
|
||||
events *serverlogging.Buffer
|
||||
@@ -112,6 +119,8 @@ type Deps struct {
|
||||
Radarr *radarr.Client
|
||||
Bazarr *bazarr.Client
|
||||
MDBList *mdblist.Client
|
||||
Credits *credits.Service
|
||||
CreditsLoad *credits.PlaybackLoad
|
||||
Syncer syncerHandle
|
||||
Log *slog.Logger
|
||||
Events *serverlogging.Buffer
|
||||
@@ -133,6 +142,8 @@ func New(cfg config.Config, deps Deps) *Server {
|
||||
radarr: deps.Radarr,
|
||||
bazarr: deps.Bazarr,
|
||||
mdblist: deps.MDBList,
|
||||
credits: deps.Credits,
|
||||
creditsLoad: deps.CreditsLoad,
|
||||
syncer: deps.Syncer,
|
||||
log: deps.Log,
|
||||
events: deps.Events,
|
||||
@@ -271,9 +282,9 @@ func (s *Server) Routes() http.Handler {
|
||||
// process. Keep it outside authentication and maintenance so offline/start-up fallback
|
||||
// never depends on a session being available.
|
||||
mux.HandleFunc("GET /v1/config", s.handleRemoteConfig)
|
||||
// Update policy is app-scoped, not user-scoped. Keep it outside authentication and
|
||||
// maintenance so a fresh install, a signed-out TV, and a retired build can all learn
|
||||
// whether the server requires an update. A valid session enriches only its log context.
|
||||
// Update compatibility is app-scoped; a signed-in viewer may only mute the optional
|
||||
// prompt. Keep this outside authentication and maintenance so a fresh install, a
|
||||
// signed-out TV, and especially a retired build can still learn what it must do.
|
||||
mux.Handle("GET /v1/update", s.identifyOptionalSession(http.HandlerFunc(s.handleUpdate)))
|
||||
// Exact route outside the maintenance gate: signed-in clients poll this lightweight
|
||||
// status even while every normal /v1 operation is deliberately unavailable.
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/scheduler"
|
||||
)
|
||||
|
||||
// The gateway's side of credits discovery: where a discovered marker is read, and where the
|
||||
// two live signals that drive it are picked up.
|
||||
//
|
||||
// Both signals are things the gateway was already being told. Nothing new is reported by a
|
||||
// television and nothing new is written on the playback path — the subsystem is fed entirely
|
||||
// by reports that existed before it did.
|
||||
|
||||
// discoveredCredits reads a stored marker for an item.
|
||||
//
|
||||
// One indexed lookup on the playback path, and it only happens for a title Emby had no
|
||||
// chapter marker for. A failure is silence: this is an optional convenience on a film that is
|
||||
// already playing, and there is nothing a viewer could do about an error.
|
||||
func (s *Server) discoveredCredits(ctx context.Context, itemID string) (int64, bool) {
|
||||
if s.credits == nil {
|
||||
return 0, false
|
||||
}
|
||||
marker, found, err := s.credits.Marker(ctx, itemID)
|
||||
if err != nil {
|
||||
s.loggerFor(ctx).Debug("discovered credits unavailable", "item_id", itemID, "error", err)
|
||||
return 0, false
|
||||
}
|
||||
if !found || marker.CreditsStartMs <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return marker.CreditsStartMs, true
|
||||
}
|
||||
|
||||
// noteCreditsPlayback feeds the two live signals from one playback report.
|
||||
//
|
||||
// The load gauge and the scan trigger are updated from the same call because they are two
|
||||
// readings of one event, and splitting them across call sites is how the two come to
|
||||
// disagree about what is playing.
|
||||
//
|
||||
// A started or progressing playback is the strongest evidence there is that an episode
|
||||
// matters, but it does not scan anything yet: NotePlayback holds the candidate for its
|
||||
// settling delay first, so an episode somebody opened and abandoned costs nothing.
|
||||
func (s *Server) noteCreditsPlayback(ctx context.Context, phase, itemID, sessionKey string) {
|
||||
if itemID == "" {
|
||||
return
|
||||
}
|
||||
switch phase {
|
||||
case "started":
|
||||
s.creditsLoad.Playing(sessionKey)
|
||||
if s.credits != nil && s.featureEnabled(ctx, featureEndCredits) {
|
||||
s.credits.NotePlayback(ctx, itemID)
|
||||
}
|
||||
case "progress":
|
||||
// Progress refreshes the gauge's timestamp. Without it a television that stopped
|
||||
// reporting — a crash, a power cut — would hold the gauge busy for ever and
|
||||
// speculative scanning would never run again.
|
||||
s.creditsLoad.Playing(sessionKey)
|
||||
case "stopped":
|
||||
s.creditsLoad.Stopped(sessionKey)
|
||||
// A playback that ended before its delay elapsed is exactly the case the delay
|
||||
// exists for: somebody looked at the episode and changed their mind, and nothing
|
||||
// should be read from disk on their account.
|
||||
if s.credits != nil {
|
||||
s.credits.AbandonPlayback(itemID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterCreditsTasks declares the demand refresh, so its interval is the operator's to
|
||||
// change and its last run is visible in the console beside every other background job.
|
||||
//
|
||||
// Refreshing on a schedule rather than continuously is the point: viewing behaviour changes
|
||||
// over evenings, not seconds, and polling Tracearr any harder would cost more than the
|
||||
// scanning it directs. Live playback is immediate and does not come through here.
|
||||
func (s *Server) RegisterCreditsTasks(sched *scheduler.Scheduler) {
|
||||
if sched == nil || s.credits == nil {
|
||||
return
|
||||
}
|
||||
sched.Register(scheduler.Task{
|
||||
ID: "credits-candidates",
|
||||
Name: "Credits candidate refresh",
|
||||
Group: "Library",
|
||||
Description: "Rebuilds the credits-detection queue from what the household has " +
|
||||
"recently been watching. Only episodes viewers are about to reach are queued.",
|
||||
Interval: 10 * time.Minute,
|
||||
Timeout: 2 * time.Minute,
|
||||
RunOnStart: true,
|
||||
Run: func(ctx context.Context) (string, error) {
|
||||
if !s.featureEnabled(ctx, featureEndCredits) {
|
||||
return "", nil
|
||||
}
|
||||
return s.credits.Refresh(ctx)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// creditsQueueDetail is the one line the admin console prints about the queue.
|
||||
func (s *Server) creditsQueueDetail() string {
|
||||
if s.credits == nil {
|
||||
return ""
|
||||
}
|
||||
depth := s.credits.QueueDepth()
|
||||
if depth == 0 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%d episode%s awaiting credits detection", depth, plural(depth))
|
||||
}
|
||||
|
||||
// playbackSessionKey identifies one stream for the load gauge.
|
||||
//
|
||||
// The play session where Emby issued one, because that is what distinguishes two
|
||||
// simultaneous plays from one television reconnecting. A device id is the fallback: an older
|
||||
// build sends no play session, and counting every one of those as the same stream would make
|
||||
// a household of old televisions read as permanently idle.
|
||||
func playbackSessionKey(deviceID, playSessionID string) string {
|
||||
if playSessionID != "" {
|
||||
return playSessionID
|
||||
}
|
||||
return deviceID
|
||||
}
|
||||
|
||||
func plural(count int) string {
|
||||
if count == 1 {
|
||||
return ""
|
||||
}
|
||||
return "s"
|
||||
}
|
||||
+47
-14
@@ -759,7 +759,8 @@ func activeHeroScheduleIDs(schedules []store.HeroSchedule, placement, userID str
|
||||
}
|
||||
matched := []active{}
|
||||
for _, schedule := range schedules {
|
||||
if !schedule.Enabled || (schedule.UserID != "" && schedule.UserID != userID) || now.Before(schedule.StartAt) || !now.Before(schedule.EndAt) {
|
||||
if !schedule.Enabled || (schedule.UserID != "" && schedule.UserID != userID) ||
|
||||
!heroScheduleActiveAt(schedule, now, location) {
|
||||
continue
|
||||
}
|
||||
matchesPlacement := false
|
||||
@@ -775,19 +776,6 @@ func activeHeroScheduleIDs(schedules []store.HeroSchedule, placement, userID str
|
||||
if !matchesPlacement {
|
||||
continue
|
||||
}
|
||||
if len(schedule.Weekdays) > 0 {
|
||||
weekday := int(now.In(location).Weekday())
|
||||
found := false
|
||||
for _, day := range schedule.Weekdays {
|
||||
if day == weekday {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
}
|
||||
matched = append(matched, active{schedule.ItemID, schedule.Priority, schedule.StartAt})
|
||||
}
|
||||
sort.SliceStable(matched, func(i, j int) bool {
|
||||
@@ -803,6 +791,51 @@ func activeHeroScheduleIDs(schedules []store.HeroSchedule, placement, userID str
|
||||
return ids
|
||||
}
|
||||
|
||||
func heroScheduleActiveAt(schedule store.HeroSchedule, now time.Time, location *time.Location) bool {
|
||||
if location == nil {
|
||||
location = time.Local
|
||||
}
|
||||
if schedule.Frequency != "daily" && schedule.Frequency != "weekly" {
|
||||
if now.Before(schedule.StartAt) || !now.Before(schedule.EndAt) {
|
||||
return false
|
||||
}
|
||||
return len(schedule.Weekdays) == 0 || heroScheduleHasWeekday(schedule.Weekdays, int(now.In(location).Weekday()))
|
||||
}
|
||||
start, startErr := time.Parse("15:04", schedule.StartTime)
|
||||
end, endErr := time.Parse("15:04", schedule.EndTime)
|
||||
if startErr != nil || endErr != nil {
|
||||
return false
|
||||
}
|
||||
local := now.In(location)
|
||||
minute := local.Hour()*60 + local.Minute()
|
||||
startMinute := start.Hour()*60 + start.Minute()
|
||||
endMinute := end.Hour()*60 + end.Minute()
|
||||
if startMinute == endMinute {
|
||||
return false
|
||||
}
|
||||
effectiveDay := int(local.Weekday())
|
||||
active := minute >= startMinute && minute < endMinute
|
||||
if startMinute > endMinute {
|
||||
active = minute >= startMinute || minute < endMinute
|
||||
if minute < endMinute {
|
||||
effectiveDay = (effectiveDay + 6) % 7
|
||||
}
|
||||
}
|
||||
if !active || schedule.Frequency == "daily" || len(schedule.Weekdays) == 0 {
|
||||
return active
|
||||
}
|
||||
return heroScheduleHasWeekday(schedule.Weekdays, effectiveDay)
|
||||
}
|
||||
|
||||
func heroScheduleHasWeekday(days []int, weekday int) bool {
|
||||
for _, day := range days {
|
||||
if day == weekday {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// pinnedHeroCandidates resolves policy against the imported catalogue. A deleted or
|
||||
// unsupported id quietly drops out, so an old admin choice can never make Home fail.
|
||||
func (s *Server) pinnedHeroCandidates(ctx context.Context, ids []string) []heroCandidate {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
@@ -22,6 +23,7 @@ type heroAdminPolicy struct {
|
||||
PrimeSubtitle string `json:"primeSubtitle"`
|
||||
Placements map[string]heroAdminPlacement `json:"placements"`
|
||||
Schedules []store.HeroSchedule `json:"schedules"`
|
||||
TimeZone string `json:"timeZone"`
|
||||
}
|
||||
|
||||
type heroAdminPlacement struct {
|
||||
@@ -67,6 +69,7 @@ func (s *Server) heroAdminPolicy(ctx context.Context) heroAdminPolicy {
|
||||
Placements: make(map[string]heroAdminPlacement, len(policy.Placements)),
|
||||
Items: []heroAdminItem{},
|
||||
Schedules: policy.Schedules,
|
||||
TimeZone: heroScheduleTimeZone(s.cfg.RadarrLocation),
|
||||
}
|
||||
for _, id := range allIDs {
|
||||
if item, ok := byID[id]; ok {
|
||||
@@ -87,6 +90,13 @@ func (s *Server) heroAdminPolicy(ctx context.Context) heroAdminPolicy {
|
||||
return out
|
||||
}
|
||||
|
||||
func heroScheduleTimeZone(location *time.Location) string {
|
||||
if location == nil {
|
||||
location = time.Local
|
||||
}
|
||||
return location.String()
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminHeroSearch(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := s.store.SearchLibrary(r.Context(), r.URL.Query().Get("q"), 20)
|
||||
if err != nil {
|
||||
@@ -165,6 +175,26 @@ func (s *Server) handleAdminHeroPolicy(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
for _, schedule := range request.Schedules {
|
||||
frequency := strings.ToLower(strings.TrimSpace(schedule.Frequency))
|
||||
if frequency != "" && frequency != "once" && frequency != "daily" && frequency != "weekly" {
|
||||
writeError(w, http.StatusBadRequest, "hero schedule frequency must be once, daily or weekly")
|
||||
return
|
||||
}
|
||||
if frequency == "daily" || frequency == "weekly" {
|
||||
start, startErr := time.Parse("15:04", strings.TrimSpace(schedule.StartTime))
|
||||
end, endErr := time.Parse("15:04", strings.TrimSpace(schedule.EndTime))
|
||||
if startErr != nil || endErr != nil || start.Equal(end) {
|
||||
writeError(w, http.StatusBadRequest, "repeating hero schedules need different start and end times")
|
||||
return
|
||||
}
|
||||
if frequency == "weekly" && len(schedule.Weekdays) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "weekly hero schedules need at least one day")
|
||||
return
|
||||
}
|
||||
} else if !schedule.EndAt.After(schedule.StartAt) {
|
||||
writeError(w, http.StatusBadRequest, "one-time hero schedules need an end after their start")
|
||||
return
|
||||
}
|
||||
item, ok := valid[strings.TrimSpace(schedule.ItemID)]
|
||||
if !ok {
|
||||
writeError(w, http.StatusBadRequest, "every scheduled hero must be a playable library film or series")
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
func TestRecurringHeroScheduleUsesLocalWeekdayAndTime(t *testing.T) {
|
||||
zone := time.FixedZone("NZST", 12*60*60)
|
||||
schedule := store.HeroSchedule{
|
||||
Frequency: "weekly", StartTime: "18:00", EndTime: "22:00",
|
||||
Weekdays: []int{1},
|
||||
}
|
||||
monday := time.Date(2026, 8, 17, 19, 0, 0, 0, zone)
|
||||
if !heroScheduleActiveAt(schedule, monday, zone) {
|
||||
t.Fatal("Monday evening schedule was not active")
|
||||
}
|
||||
if heroScheduleActiveAt(schedule, monday.Add(24*time.Hour), zone) {
|
||||
t.Fatal("Monday schedule remained active on Tuesday")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOvernightHeroScheduleBelongsToStartingDay(t *testing.T) {
|
||||
zone := time.FixedZone("NZST", 12*60*60)
|
||||
schedule := store.HeroSchedule{
|
||||
Frequency: "weekly", StartTime: "22:00", EndTime: "02:00",
|
||||
Weekdays: []int{5}, // Friday night through early Saturday.
|
||||
}
|
||||
fridayNight := time.Date(2026, 8, 21, 23, 0, 0, 0, zone)
|
||||
saturdayEarly := time.Date(2026, 8, 22, 1, 0, 0, 0, zone)
|
||||
if !heroScheduleActiveAt(schedule, fridayNight, zone) || !heroScheduleActiveAt(schedule, saturdayEarly, zone) {
|
||||
t.Fatal("overnight Friday schedule did not span midnight")
|
||||
}
|
||||
if heroScheduleActiveAt(schedule, saturdayEarly.Add(24*time.Hour), zone) {
|
||||
t.Fatal("overnight Friday schedule was active on Sunday morning")
|
||||
}
|
||||
}
|
||||
@@ -161,8 +161,16 @@ func (s *Server) handleIntro(w http.ResponseWriter, r *http.Request, sess store.
|
||||
// optional conveniences on a film that is already playing, and a failure the viewer
|
||||
// cannot act on is not worth a red line in the log for every episode watched.
|
||||
s.loggerFor(ctx).Debug("chapter markers unavailable", "item_id", itemID, "error", err)
|
||||
writeJSON(w, http.StatusOK, introResponse{})
|
||||
return
|
||||
markers = chapterMarkers{}
|
||||
}
|
||||
// A discovered marker fills in where Emby has none, which on 4.10 is nearly everywhere:
|
||||
// a survey of this household's library found no CreditsStart markers at all. Emby still
|
||||
// wins where it has an answer — it is the media server's own reading of its own file, and
|
||||
// this subsystem exists to cover the case where there is nothing to defer to.
|
||||
if credits && !markers.creditsFound {
|
||||
if start, found := s.discoveredCredits(ctx, itemID); found {
|
||||
markers.creditsStart, markers.creditsFound = start, true
|
||||
}
|
||||
}
|
||||
if !markers.introFound && !markers.creditsFound {
|
||||
writeJSON(w, http.StatusOK, introResponse{})
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
// handler has returned by the time the middleware reads this, so no lock is needed.
|
||||
type requestIdentity struct {
|
||||
component string
|
||||
userID string
|
||||
user string
|
||||
device string
|
||||
client string
|
||||
@@ -55,6 +56,7 @@ func identify(ctx context.Context, sess store.Session) {
|
||||
if identity == nil {
|
||||
return
|
||||
}
|
||||
identity.userID = sess.EmbyUserID
|
||||
if sess.Username != "" {
|
||||
identity.user = sess.Username
|
||||
}
|
||||
|
||||
@@ -103,11 +103,20 @@ func (s *Server) handleServiceStatus(w http.ResponseWriter, r *http.Request, ses
|
||||
// Nothing to celebrate while the service is down, and the client is showing the
|
||||
// maintenance screen anyway.
|
||||
if !state.Enabled {
|
||||
if found := mergeAlerts(
|
||||
s.publishedAlerts(r.Context()),
|
||||
s.sonarrAiredAlerts(r.Context()),
|
||||
); len(found) > 0 {
|
||||
alerts = found
|
||||
published, sonarr := s.publishedAlerts(r.Context()), s.sonarrAiredAlerts(r.Context())
|
||||
if len(published) > 0 || len(sonarr) > 0 {
|
||||
prefs, err := s.notificationPreferencesFor(r.Context(), sess.EmbyUserID)
|
||||
if err != nil {
|
||||
s.loggerFor(r.Context()).Warn("notification preferences unavailable", "error", err)
|
||||
alerts = mergeAlerts(published, sonarr)
|
||||
} else {
|
||||
// Filter before trimming so three muted Sonarr stories cannot crowd a
|
||||
// permitted Radarr or service notice out of the small client queue.
|
||||
alerts = mergeAlerts(
|
||||
filterClientAlerts(published, prefs),
|
||||
filterClientAlerts(sonarr, prefs),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
compatible, compatibilityMessage := compatibilityFor(r)
|
||||
|
||||
@@ -155,7 +155,7 @@ func sonarrStatus(series sonarr.Series) string {
|
||||
}
|
||||
|
||||
func (s *Server) handleNotifications(w http.ResponseWriter, r *http.Request, sess store.Session) {
|
||||
prefs, err := s.store.NotificationPreferences(r.Context(), sess.EmbyUserID)
|
||||
prefs, err := s.notificationPreferencesFor(r.Context(), sess.EmbyUserID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not load notification preferences")
|
||||
return
|
||||
@@ -164,12 +164,12 @@ func (s *Server) handleNotifications(w http.ResponseWriter, r *http.Request, ses
|
||||
if !decodeJSON(w, r, &prefs) {
|
||||
return
|
||||
}
|
||||
if err := s.store.SetNotificationPreferences(r.Context(), sess.EmbyUserID, prefs); err != nil {
|
||||
if err := s.saveNotificationPreferences(r.Context(), sess.EmbyUserID, prefs); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not save notification preferences")
|
||||
return
|
||||
}
|
||||
}
|
||||
if prefs.Enabled && prefs.ShowReturnAlerts {
|
||||
if prefs.Enabled && prefs.SonarrAlerts && prefs.ShowReturnAlerts {
|
||||
s.syncReturnNotifications(r, sess, prefs)
|
||||
}
|
||||
notifications, err := s.store.UserNotifications(r.Context(), sess.EmbyUserID)
|
||||
@@ -178,7 +178,7 @@ func (s *Server) handleNotifications(w http.ResponseWriter, r *http.Request, ses
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, notificationsResponse{
|
||||
Notifications: notifications,
|
||||
Notifications: filterStoredNotifications(notifications, prefs),
|
||||
Preferences: prefs,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/cache"
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
const notificationPreferencesTTL = 5 * time.Minute
|
||||
|
||||
func notificationPreferencesKey(userID string) string {
|
||||
return cache.UserKey(userID, "notification-preferences:v2")
|
||||
}
|
||||
|
||||
// notificationPreferencesFor keeps the ten-second status poll off Postgres. Redis also
|
||||
// shares the answer if the gateway is ever run with more than one replica; writes replace
|
||||
// it immediately, while the short TTL still picks up an operator's direct database edit.
|
||||
func (s *Server) notificationPreferencesFor(ctx context.Context, userID string) (store.NotificationPreferences, error) {
|
||||
defaults := store.DefaultNotificationPreferences()
|
||||
if userID == "" || s.store == nil {
|
||||
return defaults, nil
|
||||
}
|
||||
if s.cache != nil {
|
||||
if raw, err := s.cache.Get(ctx, notificationPreferencesKey(userID)); err == nil {
|
||||
prefs := defaults
|
||||
if json.Unmarshal(raw, &prefs) == nil {
|
||||
return prefs, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
prefs, err := s.store.NotificationPreferences(ctx, userID)
|
||||
if err != nil {
|
||||
return defaults, err
|
||||
}
|
||||
s.cacheNotificationPreferences(ctx, userID, prefs)
|
||||
return prefs, nil
|
||||
}
|
||||
|
||||
func (s *Server) cacheNotificationPreferences(ctx context.Context, userID string, prefs store.NotificationPreferences) {
|
||||
if s.cache == nil || userID == "" {
|
||||
return
|
||||
}
|
||||
if raw, err := json.Marshal(prefs); err == nil {
|
||||
_ = s.cache.Set(ctx, notificationPreferencesKey(userID), raw, notificationPreferencesTTL)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) saveNotificationPreferences(ctx context.Context, userID string, prefs store.NotificationPreferences) error {
|
||||
if err := s.store.SetNotificationPreferences(ctx, userID, prefs); err != nil {
|
||||
return err
|
||||
}
|
||||
s.cacheNotificationPreferences(ctx, userID, prefs)
|
||||
return nil
|
||||
}
|
||||
|
||||
func filterClientAlerts(alerts []clientAlert, prefs store.NotificationPreferences) []clientAlert {
|
||||
if !prefs.Enabled {
|
||||
return []clientAlert{}
|
||||
}
|
||||
filtered := make([]clientAlert, 0, len(alerts))
|
||||
for _, alert := range alerts {
|
||||
allowed := true
|
||||
switch alert.Kind {
|
||||
case alertKindSonarrAired:
|
||||
allowed = prefs.SonarrAlerts
|
||||
case alertKindRadarrImport:
|
||||
allowed = prefs.RadarrAlerts
|
||||
case alertKindLibrarySync:
|
||||
allowed = prefs.LibraryAlerts
|
||||
case alertKindServerDown, alertKindServerUp, alertKindDeploying:
|
||||
allowed = prefs.SystemAlerts
|
||||
}
|
||||
if allowed {
|
||||
filtered = append(filtered, alert)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func filterStoredNotifications(notifications []store.UserNotification, prefs store.NotificationPreferences) []store.UserNotification {
|
||||
if !prefs.Enabled {
|
||||
return []store.UserNotification{}
|
||||
}
|
||||
if prefs.SonarrAlerts {
|
||||
return notifications
|
||||
}
|
||||
filtered := make([]store.UserNotification, 0, len(notifications))
|
||||
for _, notification := range notifications {
|
||||
switch notification.Kind {
|
||||
case "show-return", "show-added", "show-cancelled", "auto-follow":
|
||||
continue
|
||||
default:
|
||||
filtered = append(filtered, notification)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/appupdate"
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
func TestFilterClientAlertsByCategory(t *testing.T) {
|
||||
prefs := store.DefaultNotificationPreferences()
|
||||
prefs.SonarrAlerts = false
|
||||
prefs.LibraryAlerts = false
|
||||
alerts := []clientAlert{
|
||||
{ID: "sonarr", Kind: alertKindSonarrAired},
|
||||
{ID: "radarr", Kind: alertKindRadarrImport},
|
||||
{ID: "library", Kind: alertKindLibrarySync},
|
||||
{ID: "system", Kind: alertKindServerDown},
|
||||
}
|
||||
|
||||
filtered := filterClientAlerts(alerts, prefs)
|
||||
if len(filtered) != 2 || filtered[0].ID != "radarr" || filtered[1].ID != "system" {
|
||||
t.Fatalf("filtered alerts = %#v", filtered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterClientAlertsMasterSwitch(t *testing.T) {
|
||||
prefs := store.DefaultNotificationPreferences()
|
||||
prefs.Enabled = false
|
||||
if got := filterClientAlerts([]clientAlert{{ID: "one", Kind: alertKindRadarrImport}}, prefs); len(got) != 0 {
|
||||
t.Fatalf("master switch returned %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterStoredSonarrNotifications(t *testing.T) {
|
||||
prefs := store.DefaultNotificationPreferences()
|
||||
prefs.SonarrAlerts = false
|
||||
notifications := []store.UserNotification{
|
||||
{ID: 1, Kind: "show-return"},
|
||||
{ID: 2, Kind: "future-category"},
|
||||
}
|
||||
filtered := filterStoredNotifications(notifications, prefs)
|
||||
if len(filtered) != 1 || filtered[0].ID != 2 {
|
||||
t.Fatalf("filtered notifications = %#v", filtered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdatePreferenceNeverSuppressesMandatoryUpdate(t *testing.T) {
|
||||
mandatory := appupdate.Decision{Status: appupdate.StatusMandatory, Version: "2.0.0"}
|
||||
if got := updateDecisionForPreferences(mandatory, false); got.Status != appupdate.StatusMandatory {
|
||||
t.Fatalf("mandatory update was suppressed: %#v", got)
|
||||
}
|
||||
optional := appupdate.Decision{Status: appupdate.StatusOptional, Version: "2.0.0"}
|
||||
if got := updateDecisionForPreferences(optional, false); got.Status != appupdate.StatusNone {
|
||||
t.Fatalf("optional update was not suppressed: %#v", got)
|
||||
}
|
||||
}
|
||||
@@ -812,6 +812,12 @@ func (s *Server) handlePlaybackReport(w http.ResponseWriter, r *http.Request, se
|
||||
)
|
||||
}
|
||||
|
||||
// Credits discovery rides the reports that were already being made. The session key is
|
||||
// the play session where the television gave one and the device otherwise, so two
|
||||
// televisions playing the same episode count as two streams rather than one.
|
||||
s.noteCreditsPlayback(r.Context(), phase, report.ItemID,
|
||||
playbackSessionKey(sess.DeviceID, report.PlaySessionID))
|
||||
|
||||
if phase == "stopped" {
|
||||
if err := s.cache.InvalidateUser(r.Context(), sess.EmbyUserID); err != nil {
|
||||
s.loggerFor(r.Context()).Warn("cache invalidation failed", "error", err)
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var releaseBuilderTagPattern = regexp.MustCompile(`^v\d+\.\d+\.\d+$`)
|
||||
|
||||
type releaseBuilderRequest struct {
|
||||
Tag string `json:"tag"`
|
||||
Notes string `json:"notes"`
|
||||
Mandatory bool `json:"mandatory"`
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminReleaseBuilderStatus(w http.ResponseWriter, r *http.Request) {
|
||||
s.relayReleaseBuilder(w, r, http.MethodGet, "/v1/status", nil)
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminReleaseBuilderStart(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 16<<10)
|
||||
var request releaseBuilderRequest
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&request); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid release request")
|
||||
return
|
||||
}
|
||||
request.Tag = strings.TrimSpace(request.Tag)
|
||||
request.Notes = strings.TrimSpace(request.Notes)
|
||||
if request.Tag != "" && !releaseBuilderTagPattern.MatchString(request.Tag) {
|
||||
writeError(w, http.StatusBadRequest, "tag must be blank or look like v0.2.64")
|
||||
return
|
||||
}
|
||||
if len(request.Notes) > 4000 {
|
||||
writeError(w, http.StatusBadRequest, "release notes are too long")
|
||||
return
|
||||
}
|
||||
body, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not prepare release request")
|
||||
return
|
||||
}
|
||||
s.relayReleaseBuilder(w, r, http.MethodPost, "/v1/releases", body)
|
||||
}
|
||||
|
||||
func (s *Server) relayReleaseBuilder(w http.ResponseWriter, incoming *http.Request, method, path string, body []byte) {
|
||||
if s.cfg.ReleaseBuilderURL == "" || s.cfg.ReleasePublishToken == "" {
|
||||
writeError(w, http.StatusServiceUnavailable, "the Docker release builder is not configured")
|
||||
return
|
||||
}
|
||||
request, err := http.NewRequestWithContext(incoming.Context(), method,
|
||||
s.cfg.ReleaseBuilderURL+path, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not prepare builder request")
|
||||
return
|
||||
}
|
||||
request.Header.Set("Authorization", "Bearer "+s.cfg.ReleasePublishToken)
|
||||
if len(body) > 0 {
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "the Docker release builder is not available")
|
||||
return
|
||||
}
|
||||
defer response.Body.Close()
|
||||
payload, err := io.ReadAll(io.LimitReader(response.Body, 1<<20))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, "could not read the Docker release builder response")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(response.StatusCode)
|
||||
_, _ = w.Write(payload)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/config"
|
||||
)
|
||||
|
||||
func TestAdminReleaseBuilderRelaysWithoutExposingToken(t *testing.T) {
|
||||
var receivedAuth string
|
||||
builder := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
receivedAuth = r.Header.Get("Authorization")
|
||||
if r.Method != http.MethodPost || r.URL.Path != "/v1/releases" {
|
||||
t.Fatalf("builder request = %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
if !strings.Contains(string(body), `"tag":"v0.2.64"`) || !strings.Contains(string(body), `"mandatory":true`) {
|
||||
t.Fatalf("builder body = %s", body)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
_, _ = io.WriteString(w, `{"state":"running","logs":[]}`)
|
||||
}))
|
||||
defer builder.Close()
|
||||
|
||||
s := &Server{cfg: config.Config{ReleaseBuilderURL: builder.URL, ReleasePublishToken: "release-secret"}}
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/admin/api/release-builder",
|
||||
strings.NewReader(`{"tag":"v0.2.64","notes":"Living room polish","mandatory":true}`))
|
||||
s.handleAdminReleaseBuilderStart(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusAccepted || receivedAuth != "Bearer release-secret" {
|
||||
t.Fatalf("response/auth = %d/%q", recorder.Code, receivedAuth)
|
||||
}
|
||||
if strings.Contains(recorder.Body.String(), "release-secret") {
|
||||
t.Fatal("release token was exposed to the browser")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminReleaseBuilderValidatesTagBeforeRelay(t *testing.T) {
|
||||
s := &Server{cfg: config.Config{ReleaseBuilderURL: "http://builder", ReleasePublishToken: "secret"}}
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/admin/api/release-builder",
|
||||
strings.NewReader(`{"tag":"latest; rm -rf /"}`))
|
||||
s.handleAdminReleaseBuilderStart(recorder, request)
|
||||
if recorder.Code != http.StatusBadRequest {
|
||||
t.Fatalf("invalid tag status = %d, want 400", recorder.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminReleaseBuilderIsUnavailableWhenUnconfigured(t *testing.T) {
|
||||
s := &Server{}
|
||||
recorder := httptest.NewRecorder()
|
||||
s.handleAdminReleaseBuilderStatus(recorder, httptest.NewRequest(http.MethodGet, "/admin/api/release-builder", nil))
|
||||
if recorder.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("unconfigured builder status = %d, want 503", recorder.Code)
|
||||
}
|
||||
}
|
||||
@@ -92,7 +92,7 @@ func (s *Server) scanSonarrLifecycle(ctx context.Context) error {
|
||||
}
|
||||
preferences[user.ID] = prefs
|
||||
}
|
||||
if preferenceErrors[user.ID] || !prefs.Enabled {
|
||||
if preferenceErrors[user.ID] || !prefs.Enabled || !prefs.SonarrAlerts {
|
||||
continue
|
||||
}
|
||||
eventAt := change.Current.ObservedAt
|
||||
@@ -122,7 +122,7 @@ func (s *Server) scanSonarrLifecycle(ctx context.Context) error {
|
||||
}
|
||||
preferences[user.ID] = prefs
|
||||
}
|
||||
if preferenceErrors[user.ID] || !prefs.Enabled {
|
||||
if preferenceErrors[user.ID] || !prefs.Enabled || !prefs.SonarrAlerts {
|
||||
continue
|
||||
}
|
||||
eventAt := change.Current.ObservedAt
|
||||
|
||||
@@ -148,11 +148,24 @@ func (s *Server) requireSupportedClient(next http.HandlerFunc) http.HandlerFunc
|
||||
|
||||
// handleUpdate answers the client's version check.
|
||||
//
|
||||
// Its own public endpoint rather than a field on /v1/home: update policy belongs to the
|
||||
// app build, not a viewer or login. The verdict comes from memory; when a bearer token is
|
||||
// present the route resolves it only to attribute an offered update to the affected viewer.
|
||||
// Its own public endpoint rather than a field on /v1/home: compatibility belongs to the
|
||||
// app build, not a viewer or login. The verdict comes from memory; a valid bearer token
|
||||
// also lets one viewer mute an optional prompt, but never a mandatory update.
|
||||
func (s *Server) handleUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
decision := s.updateDecision(r)
|
||||
// A signed-in viewer may decline optional update prompts. Mandatory decisions remain
|
||||
// authoritative: this preference is about notifications, not compatibility or the
|
||||
// operator's ability to retire an unsafe build. Signed-out checks have no person to
|
||||
// consult and retain the ordinary app-scoped policy.
|
||||
if decision.Status == appupdate.StatusOptional {
|
||||
if identity := identityFrom(r.Context()); identity != nil && identity.userID != "" {
|
||||
if prefs, err := s.notificationPreferencesFor(r.Context(), identity.userID); err != nil {
|
||||
s.loggerFor(r.Context()).Warn("update notification preferences unavailable", "error", err)
|
||||
} else {
|
||||
decision = updateDecisionForPreferences(decision, prefs.Enabled && prefs.UpdateAlerts)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Only a verdict that asks a television to do something is worth a line. Every TV
|
||||
// checks on every launch, and "nothing to say" logged each time would bury the
|
||||
// launch where an update was actually offered — or forced.
|
||||
@@ -165,3 +178,10 @@ func (s *Server) handleUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
writeJSON(w, http.StatusOK, decision)
|
||||
}
|
||||
|
||||
func updateDecisionForPreferences(decision appupdate.Decision, updateAlerts bool) appupdate.Decision {
|
||||
if decision.Status == appupdate.StatusOptional && !updateAlerts {
|
||||
return appupdate.Decision{Status: appupdate.StatusNone}
|
||||
}
|
||||
return decision
|
||||
}
|
||||
|
||||
@@ -81,6 +81,9 @@ type Config struct {
|
||||
// ReleasePublishToken authorizes the CI-only release upload endpoint. It is separate
|
||||
// from AdminToken so a compromised build runner cannot change maintenance settings.
|
||||
ReleasePublishToken string
|
||||
// ReleaseBuilderURL is the private Compose address of the Android release controller.
|
||||
// It is never given to the browser; the authenticated admin API relays requests to it.
|
||||
ReleaseBuilderURL string
|
||||
|
||||
// SyncInterval is how often the library import runs. Zero disables the schedule.
|
||||
SyncInterval time.Duration
|
||||
@@ -144,6 +147,26 @@ type Config struct {
|
||||
TracearrURL string
|
||||
TracearrAPIKey string
|
||||
TracearrServerID string
|
||||
// Credits detection discovers where an episode's closing credits begin, for the small
|
||||
// number of episodes the household is about to watch. It is demand-driven — Tracearr
|
||||
// says what is worth scanning — so these settings shape how far ahead of a viewer it
|
||||
// prepares, never how much of the library it reads.
|
||||
//
|
||||
// CreditsEnabled is off by default: it is the only thing in the gateway that reads media
|
||||
// bytes, and switching that on is an operator's decision rather than a default.
|
||||
CreditsEnabled bool
|
||||
// CreditsFFmpeg is the decoder. Absent, the subsystem still runs and still writes
|
||||
// markers, on behavioural evidence alone — which on a well-watched show is the better
|
||||
// signal anyway.
|
||||
CreditsFFmpeg string
|
||||
// CreditsPrefetchEpisodes is the look-ahead for an ordinary viewer; velocity moves the
|
||||
// actual depth either side of it, and CreditsMaxPrefetch is the ceiling nothing exceeds.
|
||||
CreditsPrefetchEpisodes int
|
||||
CreditsMaxPrefetch int
|
||||
// CreditsQueueLimit bounds pending candidates. Past it, low-priority speculation is
|
||||
// discarded rather than queued.
|
||||
CreditsQueueLimit int
|
||||
|
||||
// TracearrSyncInterval imports recent changed sessions. FullInterval reconciles
|
||||
// late/out-of-order updates and deletions without needing a source cursor.
|
||||
TracearrSyncInterval time.Duration
|
||||
@@ -160,6 +183,10 @@ func Load() (Config, error) {
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
releasePublishToken, err := secret("MEMBY_RELEASE_PUBLISH_TOKEN")
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
c := Config{
|
||||
ListenAddr: env("MEMBY_LISTEN_ADDR", ":8080"),
|
||||
EmbyURL: strings.TrimRight(os.Getenv("MEMBY_EMBY_URL"), "/"),
|
||||
@@ -185,9 +212,8 @@ func Load() (Config, error) {
|
||||
AdminUIURL: env("MEMBY_ADMIN_UI_URL", "http://memby-admin:80"),
|
||||
PublicURL: strings.TrimRight(strings.TrimSpace(os.Getenv("MEMBY_PUBLIC_URL")), "/"),
|
||||
ReleaseDir: env("MEMBY_RELEASE_DIR", "/data/releases"),
|
||||
ReleasePublishToken: strings.TrimSpace(
|
||||
os.Getenv("MEMBY_RELEASE_PUBLISH_TOKEN"),
|
||||
),
|
||||
ReleasePublishToken: releasePublishToken,
|
||||
ReleaseBuilderURL: strings.TrimRight(strings.TrimSpace(os.Getenv("MEMBY_RELEASE_BUILDER_URL")), "/"),
|
||||
SyncInterval: duration("MEMBY_SYNC_INTERVAL", time.Hour),
|
||||
SyncTimeout: duration("MEMBY_SYNC_TIMEOUT", 30*time.Minute),
|
||||
SyncOnStart: boolean("MEMBY_SYNC_ON_START", false),
|
||||
@@ -209,6 +235,11 @@ func Load() (Config, error) {
|
||||
BazarrAPIKey: strings.TrimSpace(os.Getenv("MEMBY_BAZARR_API_KEY")),
|
||||
BazarrTTL: duration("MEMBY_BAZARR_TTL", 5*time.Minute),
|
||||
BazarrTimeout: duration("MEMBY_BAZARR_TIMEOUT", 45*time.Second),
|
||||
CreditsEnabled: boolean("MEMBY_CREDITS_ENABLED", false),
|
||||
CreditsFFmpeg: strings.TrimSpace(os.Getenv("MEMBY_CREDITS_FFMPEG")),
|
||||
CreditsPrefetchEpisodes: integer("MEMBY_CREDITS_PREFETCH_EPISODES", 3),
|
||||
CreditsMaxPrefetch: integer("MEMBY_CREDITS_MAX_PREFETCH", 5),
|
||||
CreditsQueueLimit: integer("MEMBY_CREDITS_QUEUE_LIMIT", 20),
|
||||
TracearrURL: strings.TrimRight(strings.TrimSpace(os.Getenv("MEMBY_TRACEARR_URL")), "/"),
|
||||
TracearrAPIKey: strings.TrimSpace(os.Getenv("MEMBY_TRACEARR_API_KEY")),
|
||||
TracearrServerID: strings.TrimSpace(os.Getenv("MEMBY_TRACEARR_SERVER_ID")),
|
||||
@@ -268,6 +299,26 @@ func env(key, fallback string) string {
|
||||
return fallback
|
||||
}
|
||||
|
||||
// secret reads a Docker/Kubernetes-style file-backed secret when KEY_FILE is set,
|
||||
// falling back to KEY for existing non-Compose deployments. The file's contents are
|
||||
// never included in an error, and Compose uses only the file form so `docker inspect`
|
||||
// cannot reveal the release-publish credential.
|
||||
func secret(key string) (string, error) {
|
||||
path := strings.TrimSpace(os.Getenv(key + "_FILE"))
|
||||
if path == "" {
|
||||
return strings.TrimSpace(os.Getenv(key)), nil
|
||||
}
|
||||
value, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s_FILE: %w", key, err)
|
||||
}
|
||||
trimmed := strings.TrimSpace(string(value))
|
||||
if trimmed == "" {
|
||||
return "", fmt.Errorf("%s_FILE is empty", key)
|
||||
}
|
||||
return trimmed, nil
|
||||
}
|
||||
|
||||
func boolean(key string, fallback bool) bool {
|
||||
raw := strings.TrimSpace(os.Getenv(key))
|
||||
if raw == "" {
|
||||
|
||||
@@ -1,10 +1,42 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestReleasePublishTokenCanComeFromSecretFile(t *testing.T) {
|
||||
t.Setenv("MEMBY_EMBY_URL", "http://emby")
|
||||
t.Setenv("MEMBY_DATABASE_URL", "postgres://memby")
|
||||
t.Setenv("MEMBY_PUBLIC_URL", "https://memby.example")
|
||||
t.Setenv("MEMBY_RELEASE_PUBLISH_TOKEN", "legacy-environment-value")
|
||||
path := filepath.Join(t.TempDir(), "release-token")
|
||||
if err := os.WriteFile(path, []byte("file-backed-token\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("MEMBY_RELEASE_PUBLISH_TOKEN_FILE", path)
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.ReleasePublishToken != "file-backed-token" {
|
||||
t.Fatalf("release token = %q, want the file-backed value", cfg.ReleasePublishToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfiguredReleaseSecretFileMustBeReadable(t *testing.T) {
|
||||
t.Setenv("MEMBY_EMBY_URL", "http://emby")
|
||||
t.Setenv("MEMBY_DATABASE_URL", "postgres://memby")
|
||||
t.Setenv("MEMBY_RELEASE_PUBLISH_TOKEN_FILE", filepath.Join(t.TempDir(), "missing"))
|
||||
|
||||
if _, err := Load(); err == nil {
|
||||
t.Fatal("expected a missing release token secret to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTracearrURLAndKeyMustBeConfiguredTogether(t *testing.T) {
|
||||
t.Setenv("MEMBY_EMBY_URL", "http://emby")
|
||||
t.Setenv("MEMBY_DATABASE_URL", "postgres://memby")
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
# Credits detection
|
||||
|
||||
Where an episode's closing credits begin, discovered for the small number of episodes a
|
||||
household is actually about to watch.
|
||||
|
||||
This is not a library scanner and the distinction is the whole design. A scanner asks "what
|
||||
is in the library" and answers by reading all of it. This asks "what will somebody press Play
|
||||
on over the next few evenings", which Tracearr already knows, and reads almost nothing:
|
||||
|
||||
```
|
||||
Tracearr demand → priority queue → marker cached? → tiny tail scan → one write → never again
|
||||
```
|
||||
|
||||
## Why it exists
|
||||
|
||||
Emby's `CreditsStart` marker is in its `MarkerType` enumeration and Emby 4.10 does not write
|
||||
it. A survey of this household's 20,000-item library found `Chapter`, `IntroStart` and
|
||||
`IntroEnd` and **no `CreditsStart` at all**. The existing feature's coverage comes entirely
|
||||
from chapters *named* like credits — about 5% of items. This fills in the rest.
|
||||
|
||||
Emby still wins wherever it has an answer. `api/intro.go` reads its chapters first and only
|
||||
falls through to a discovered marker when there is nothing to defer to.
|
||||
|
||||
## What was reused rather than built
|
||||
|
||||
The investigation that preceded this found more already in place than expected, and none of
|
||||
it was duplicated:
|
||||
|
||||
| Needed | Already there |
|
||||
|---|---|
|
||||
| Tracearr ingestion | `internal/tracearr` + the `tracearr_sessions` table, imported by the For You pipeline |
|
||||
| Emby item ids for sessions | `emby_item_id` / `emby_series_id`, backfilled by `UpdateTracearrSessionMapping` |
|
||||
| Stop positions and next-episode transitions | `progress_ms`, `total_duration_ms`, `started_at`/`stopped_at` on the same table |
|
||||
| Episode numbering | `library_items`, via `ParentIndexNumber`/`IndexNumber` in the payload |
|
||||
| Delivery to Android TV | `introResponse.creditsAvailable` / `creditsStartMs` — **no client changes at all** |
|
||||
| Background scheduling | `internal/scheduler`, so the interval is the operator's and the last run is in the console |
|
||||
| Live playback signal | the playback reports televisions already send |
|
||||
|
||||
Two database reads produce the entire candidate queue for the whole household, whatever it
|
||||
is watching. Nothing here is per-candidate and nothing polls Tracearr directly.
|
||||
|
||||
## The files
|
||||
|
||||
```
|
||||
service.go the worker, the cache check, and when scanning is allowed
|
||||
queue.go bounded in-RAM priority queue; nothing about it is persisted
|
||||
candidates.go velocity, look-ahead, decay, multi-user merging — all pure
|
||||
tracearr.go demand → candidates, and the in-memory episode index
|
||||
behaviour.go stop clustering; can write a marker with no media access at all
|
||||
confidence.go signal combination and the rule that stops repeated rewrites
|
||||
window.go where to scan, and how season history narrows it
|
||||
detector.go the two-stage sparse visual scan and its changepoint
|
||||
sampler.go ffmpeg, used surgically
|
||||
fingerprint.go media version identity, so a replaced file invalidates itself
|
||||
resolver.go one Emby request per candidate, cached
|
||||
postgres.go the store adapter
|
||||
load.go whether the server is too busy for speculative work
|
||||
```
|
||||
|
||||
## Two detectors, and the cheap one is often better
|
||||
|
||||
**Behavioural** clustering costs nothing: no file is opened, no decoder runs, no new row is
|
||||
written anywhere. Where three viewers independently stopped an episode within seconds of each
|
||||
other, near the end but not at it, that agreement is evidence no decoder can produce. Two
|
||||
viewers is enough only when both rolled into the next episode, which is unambiguous about why
|
||||
they left.
|
||||
|
||||
**Visual** scanning finds a sustained structural transition — dark, flat, textured with thin
|
||||
text, and staying that way for a minute. Darkness is *multiplied* rather than added into the
|
||||
frame score, which is the one modelling decision worth defending: under a weighted sum a
|
||||
night exterior reaches the credit-like floor on darkness and flatness alone, which is exactly
|
||||
how a final scene comes to be reported as a credits roll. A unit test pins that case.
|
||||
|
||||
The two fail in unrelated ways, so agreement between them is worth far more than either
|
||||
alone — hence a probabilistic union rather than an average. Disagreement beyond 20 seconds
|
||||
costs a 0.3 penalty, which usually means storing nothing. **Prefer no marker to a wrong
|
||||
marker** is the governing rule throughout: a missing Skip Credits button is an absence nobody
|
||||
notices, a button during the final scene is a fault they remember.
|
||||
|
||||
## Cost, and what is still unmeasured
|
||||
|
||||
**Derived arithmetic** (checkable without hardware):
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Generic tail window | `clamp(runtime × 0.20, 5min, 12min)` — 8m50s on a 44-minute episode, **20% of the file** |
|
||||
| Season-narrowed window | ±90s around the expected position — **3 minutes, 6.8% of the file** |
|
||||
| Coarse pass | one 160×90 grey frame every 4s — ~45 frames over a narrowed window |
|
||||
| Fine pass | 750ms over ±30s — ~80 frames |
|
||||
| Frame buffer | 14,400 bytes, allocated once and reused for the whole pass |
|
||||
| Writes, cached marker | **0** |
|
||||
| Writes, successful scan | **1** |
|
||||
|
||||
Against scanning the full library: 20,000 items read end to end versus a queue capped at 20
|
||||
candidates, most of which are rejected by the marker check before any media is touched. Once
|
||||
a household settles, the steady state is one indexed read per candidate and nothing else.
|
||||
|
||||
**Not yet measured, and it needs the NAS.** Bytes actually read, wall-clock analysis time,
|
||||
CPU and peak RSS all depend on the container, the network path to Emby and the media itself,
|
||||
and no figure taken anywhere else would mean anything. The tool is built and ships in the
|
||||
image:
|
||||
|
||||
```
|
||||
docker compose exec server /app/memby-credits benchmark <emby-item-id>
|
||||
```
|
||||
|
||||
It prints the runtime, the scan region **and its provenance**, an estimated byte count,
|
||||
frames sampled, allocation, elapsed time, the detection and what would be stored. The
|
||||
provenance line is the one to read: a run reporting `generic-tail-window` every time is a run
|
||||
in which demand-driven narrowing is doing nothing, and the design would need revisiting.
|
||||
|
||||
## Things to preserve
|
||||
|
||||
- **The queue is deliberately not durable.** Candidate priorities are rebuilt from one
|
||||
Tracearr query on restart, which is cheaper and simpler than a second persistent job
|
||||
scheduler. The database holds markers and nothing else.
|
||||
- **A single worker, and it is not a placeholder for a pool.** Concurrent scans multiply the
|
||||
two costs this exists to minimise on a machine whose real job is streaming video.
|
||||
- **Live playback does not scan immediately.** `livePlaybackDelay` (45s) is what stops a
|
||||
curious button press becoming disk activity; `AbandonPlayback` withdraws it.
|
||||
- **The marker is keyed on a media fingerprint**, so a file Sonarr replaces stops matching
|
||||
with nothing having to notice the swap. A fingerprint too weak to detect a replacement
|
||||
(runtime only) causes the marker to be *withheld* rather than stored un-invalidatable.
|
||||
- **Season history needs two markers, not one.** One is an anecdote and may itself be the
|
||||
mis-detection; narrowing a scan onto it is how one wrong marker propagates through a season.
|
||||
- **`ShouldRewrite` is the only thing between "one write per episode, ever" and a row updated
|
||||
on every playback.** Readings wobble by seconds; a ±12s difference is not news.
|
||||
- **The detector never learns why an episode was chosen.** That boundary is what stops it
|
||||
being tuned to agree with the predictor rather than with the media.
|
||||
@@ -0,0 +1,186 @@
|
||||
package credits
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
// What the household already told us, without anything having to read a media file.
|
||||
//
|
||||
// This is the cheapest evidence in the package and on a well-watched show it is also the
|
||||
// best. Where three people independently stopped an episode within a few seconds of each
|
||||
// other, near the end but not at it, they stopped for a reason, and the reason is that the
|
||||
// story finished and the credits started. Tracearr has recorded every one of those stops
|
||||
// already — so this costs one indexed query, no file access, and no new writes.
|
||||
|
||||
const (
|
||||
// behaviourFloor is how far into the file a stop has to be before it says anything about
|
||||
// credits. The same three-quarters the television's own chapter-name rule uses, and for
|
||||
// the same reason: somebody abandoning an episode twenty minutes in is telling us they
|
||||
// did not like it, not where the credits are.
|
||||
behaviourFloor = 0.75
|
||||
|
||||
// behaviourTailGuard is how close to the end counts as "watched it out". Those sessions
|
||||
// are the majority and they carry no positional information at all — a viewer who sat
|
||||
// through the credits stopped at the end of the file, wherever the credits began.
|
||||
behaviourTailGuard = 15 * time.Second
|
||||
|
||||
// behaviourCluster is how far apart two stops can be and still be the same moment.
|
||||
// Players report progress on a timer, so two people leaving at the same cut are
|
||||
// routinely a few seconds apart in the record.
|
||||
behaviourCluster = 30 * time.Second
|
||||
|
||||
// BehaviourMinUsers is what it takes to write a marker on behaviour alone. Three
|
||||
// independent people agreeing to within half a minute is not a coincidence; two might
|
||||
// be a couple watching one television twice, or one person on two devices.
|
||||
BehaviourMinUsers = 3
|
||||
|
||||
// behaviourNarrowMinUsers is the weaker bar for merely *narrowing* a scan. Being wrong
|
||||
// about where to look costs a wider scan; being wrong about a marker costs somebody the
|
||||
// end of the episode, so the two bars are deliberately different.
|
||||
behaviourNarrowMinUsers = 2
|
||||
)
|
||||
|
||||
// StopEvent is one viewer leaving one episode, reduced to what matters. Built from Tracearr
|
||||
// sessions that are already in Postgres — this subsystem records nothing of its own.
|
||||
type StopEvent struct {
|
||||
UserKey string
|
||||
PositionMs int64
|
||||
RuntimeMs int64
|
||||
// NextEpisode marks a stop that ran straight into the following episode. It is the
|
||||
// strongest form of this signal: somebody who pressed next was unambiguously looking at
|
||||
// credits rather than deciding to go to bed.
|
||||
NextEpisode bool
|
||||
}
|
||||
|
||||
// BehaviourEvidence is what a set of stops came to.
|
||||
type BehaviourEvidence struct {
|
||||
Found bool
|
||||
StartMs int64
|
||||
// UserCount is distinct viewers in the winning cluster, which is the whole strength of
|
||||
// the finding: the same person stopping in the same place four times is one observation.
|
||||
UserCount int
|
||||
// NextEpisodeCount is how many of them rolled into the next episode.
|
||||
NextEpisodeCount int
|
||||
// SpreadMs is how tightly they agreed. A cluster three seconds wide is worth more than
|
||||
// one twenty-eight seconds wide, and confidence reads it.
|
||||
SpreadMs int64
|
||||
}
|
||||
|
||||
// Usable means this is good enough to steer a scan.
|
||||
func (e BehaviourEvidence) Usable() bool {
|
||||
return e.Found && e.UserCount >= behaviourNarrowMinUsers && e.StartMs > 0
|
||||
}
|
||||
|
||||
// StandaloneMarker means this is good enough to *be* a marker, with no media read at all.
|
||||
// The bar is higher than Usable's, and a next-episode transition is what lets a two-viewer
|
||||
// cluster clear it: pressing next is an explicit statement that the episode had ended.
|
||||
func (e BehaviourEvidence) StandaloneMarker() bool {
|
||||
if !e.Found || e.StartMs <= 0 {
|
||||
return false
|
||||
}
|
||||
if e.UserCount >= BehaviourMinUsers {
|
||||
return true
|
||||
}
|
||||
return e.UserCount >= behaviourNarrowMinUsers && e.NextEpisodeCount >= e.UserCount
|
||||
}
|
||||
|
||||
// AnalyseStops finds the moment a household agrees an episode ends.
|
||||
//
|
||||
// A sliding cluster rather than an average of everything: the tail of an episode contains
|
||||
// two quite different populations — people who left at the credits and people who left part
|
||||
// way through them — and averaging the two produces a position that is inside the credits
|
||||
// but later than they began, which is the one failure mode that shows a Skip Credits button
|
||||
// too late to be useful. The tightest agreement is the transition; the stragglers are noise.
|
||||
func AnalyseStops(stops []StopEvent, runtimeMs int64) BehaviourEvidence {
|
||||
if runtimeMs <= 0 || len(stops) == 0 {
|
||||
return BehaviourEvidence{}
|
||||
}
|
||||
floor := int64(float64(runtimeMs) * behaviourFloor)
|
||||
ceiling := runtimeMs - behaviourTailGuard.Milliseconds()
|
||||
if ceiling <= floor {
|
||||
return BehaviourEvidence{}
|
||||
}
|
||||
|
||||
// One observation per viewer: the earliest qualifying stop they made. Somebody who
|
||||
// stopped at the credits, resumed, and stopped again at the very end has told us where
|
||||
// the credits were exactly once.
|
||||
earliest := map[string]StopEvent{}
|
||||
for _, stop := range stops {
|
||||
if stop.PositionMs < floor || stop.PositionMs > ceiling {
|
||||
continue
|
||||
}
|
||||
previous, seen := earliest[stop.UserKey]
|
||||
if !seen || stop.PositionMs < previous.PositionMs {
|
||||
// A next-episode transition is never downgraded by a later plain stop from the
|
||||
// same viewer, but an earlier plain stop still wins on position.
|
||||
stop.NextEpisode = stop.NextEpisode || (seen && previous.NextEpisode &&
|
||||
previous.PositionMs == stop.PositionMs)
|
||||
earliest[stop.UserKey] = stop
|
||||
} else if stop.NextEpisode && previous.PositionMs-stop.PositionMs <= 0 {
|
||||
previous.NextEpisode = true
|
||||
earliest[stop.UserKey] = previous
|
||||
}
|
||||
}
|
||||
if len(earliest) < behaviourNarrowMinUsers {
|
||||
return BehaviourEvidence{}
|
||||
}
|
||||
|
||||
observations := make([]StopEvent, 0, len(earliest))
|
||||
for _, stop := range earliest {
|
||||
observations = append(observations, stop)
|
||||
}
|
||||
sort.Slice(observations, func(a, b int) bool {
|
||||
return observations[a].PositionMs < observations[b].PositionMs
|
||||
})
|
||||
|
||||
// The widest cluster wins, and the earliest of equally wide ones — the credits began at
|
||||
// the first moment the household agreed on, not the last.
|
||||
tolerance := behaviourCluster.Milliseconds()
|
||||
best := BehaviourEvidence{}
|
||||
for start := range observations {
|
||||
end := start
|
||||
for end+1 < len(observations) &&
|
||||
observations[end+1].PositionMs-observations[start].PositionMs <= tolerance {
|
||||
end++
|
||||
}
|
||||
count := end - start + 1
|
||||
if count < behaviourNarrowMinUsers || count < best.UserCount {
|
||||
continue
|
||||
}
|
||||
spread := observations[end].PositionMs - observations[start].PositionMs
|
||||
if count == best.UserCount && spread >= best.SpreadMs {
|
||||
continue
|
||||
}
|
||||
nextCount := 0
|
||||
for _, stop := range observations[start : end+1] {
|
||||
if stop.NextEpisode {
|
||||
nextCount++
|
||||
}
|
||||
}
|
||||
best = BehaviourEvidence{
|
||||
Found: true,
|
||||
StartMs: medianPosition(observations[start : end+1]),
|
||||
UserCount: count,
|
||||
NextEpisodeCount: nextCount,
|
||||
SpreadMs: spread,
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// medianPosition is the middle of a cluster. The median rather than the mean for the reason
|
||||
// the season estimate uses one: a single straggler inside the tolerance must not move the
|
||||
// answer, and with an even count the lower middle is taken because looking slightly early is
|
||||
// the harmless direction to be wrong in.
|
||||
func medianPosition(stops []StopEvent) int64 {
|
||||
if len(stops) == 0 {
|
||||
return 0
|
||||
}
|
||||
positions := make([]int64, 0, len(stops))
|
||||
for _, stop := range stops {
|
||||
positions = append(positions, stop.PositionMs)
|
||||
}
|
||||
sort.Slice(positions, func(a, b int) bool { return positions[a] < positions[b] })
|
||||
return positions[(len(positions)-1)/2]
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package credits
|
||||
|
||||
import "testing"
|
||||
|
||||
// The behavioural detector, which is the half of this subsystem that costs nothing at all.
|
||||
// The tests that matter are the ones about refusing to answer: this can write a marker with
|
||||
// no media ever being opened, so the bar for it doing so has to be real.
|
||||
|
||||
const episodeRuntime = 47*60*1000 + 12*1000 // 47:12, the brief's example
|
||||
|
||||
func stop(user string, minutes, seconds int, next bool) StopEvent {
|
||||
return StopEvent{
|
||||
UserKey: user,
|
||||
PositionMs: int64(minutes*60+seconds) * 1000,
|
||||
RuntimeMs: episodeRuntime,
|
||||
NextEpisode: next,
|
||||
}
|
||||
}
|
||||
|
||||
// The worked example from the brief: three viewers leaving within seconds of each other.
|
||||
func TestClusteredStopsFindTheCredits(t *testing.T) {
|
||||
evidence := AnalyseStops([]StopEvent{
|
||||
stop("paul", 44, 3, true),
|
||||
stop("david", 44, 9, false),
|
||||
stop("matt", 44, 5, true),
|
||||
}, episodeRuntime)
|
||||
|
||||
if !evidence.Found {
|
||||
t.Fatal("three agreeing viewers produced no evidence")
|
||||
}
|
||||
if evidence.UserCount != 3 {
|
||||
t.Fatalf("UserCount = %d, want 3", evidence.UserCount)
|
||||
}
|
||||
// The brief's own answer is "credits ≈ 44:05" — the middle of the cluster.
|
||||
if evidence.StartMs != stop("", 44, 5, false).PositionMs {
|
||||
t.Fatalf("StartMs = %d, want the 44:05 median", evidence.StartMs)
|
||||
}
|
||||
if !evidence.StandaloneMarker() {
|
||||
t.Fatal("three viewers agreeing to within six seconds should be enough on its own")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopsBeforeTheTailAreNotEvidence(t *testing.T) {
|
||||
// Three people abandoning an episode twenty minutes in agree about the episode, not
|
||||
// about where its credits are.
|
||||
evidence := AnalyseStops([]StopEvent{
|
||||
stop("paul", 20, 0, false),
|
||||
stop("david", 20, 4, false),
|
||||
stop("matt", 20, 2, false),
|
||||
}, episodeRuntime)
|
||||
if evidence.Found {
|
||||
t.Fatalf("a cluster below the tail floor was treated as credits: %+v", evidence)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatchingToTheEndSaysNothing(t *testing.T) {
|
||||
// Sitting through the credits stops at the end of the file, wherever they began. These
|
||||
// are the majority of sessions and they carry no positional information.
|
||||
evidence := AnalyseStops([]StopEvent{
|
||||
stop("paul", 47, 12, false),
|
||||
stop("david", 47, 10, false),
|
||||
stop("matt", 47, 11, false),
|
||||
}, episodeRuntime)
|
||||
if evidence.Found {
|
||||
t.Fatalf("stops at the very end were read as a transition: %+v", evidence)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnePersonIsNotAHousehold(t *testing.T) {
|
||||
// The same viewer stopping in the same place repeatedly is one observation, not four.
|
||||
evidence := AnalyseStops([]StopEvent{
|
||||
stop("paul", 44, 3, false),
|
||||
stop("paul", 44, 5, false),
|
||||
stop("paul", 44, 4, false),
|
||||
stop("paul", 44, 6, false),
|
||||
}, episodeRuntime)
|
||||
if evidence.Found {
|
||||
t.Fatalf("one viewer produced evidence: %+v", evidence)
|
||||
}
|
||||
}
|
||||
|
||||
// Two viewers is enough only when both pressed next, which is unambiguous about why they
|
||||
// left. Two plain stops might be one couple, or one person on two devices.
|
||||
func TestTwoViewersNeedNextEpisodeTransitions(t *testing.T) {
|
||||
plain := AnalyseStops([]StopEvent{
|
||||
stop("paul", 44, 3, false),
|
||||
stop("david", 44, 7, false),
|
||||
}, episodeRuntime)
|
||||
if !plain.Usable() {
|
||||
t.Fatal("two agreeing viewers should at least be good enough to narrow a scan")
|
||||
}
|
||||
if plain.StandaloneMarker() {
|
||||
t.Fatal("two plain stops must not be enough to write a marker on their own")
|
||||
}
|
||||
|
||||
advanced := AnalyseStops([]StopEvent{
|
||||
stop("paul", 44, 3, true),
|
||||
stop("david", 44, 7, true),
|
||||
}, episodeRuntime)
|
||||
if !advanced.StandaloneMarker() {
|
||||
t.Fatal("two viewers who both rolled into the next episode should be enough")
|
||||
}
|
||||
}
|
||||
|
||||
// The tail contains two populations: people who left at the credits, and people who left
|
||||
// part way through them. The tightest agreement is the transition.
|
||||
func TestScatteredStragglersDoNotMoveTheAnswer(t *testing.T) {
|
||||
evidence := AnalyseStops([]StopEvent{
|
||||
stop("paul", 44, 3, false),
|
||||
stop("david", 44, 6, false),
|
||||
stop("matt", 44, 4, false),
|
||||
stop("jane", 46, 30, false), // gave up half way through the credits
|
||||
}, episodeRuntime)
|
||||
|
||||
if !evidence.Found {
|
||||
t.Fatal("expected the tight cluster to win")
|
||||
}
|
||||
if evidence.StartMs > stop("", 44, 10, false).PositionMs {
|
||||
t.Fatalf("StartMs = %d; a straggler dragged the answer later", evidence.StartMs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoStopsIsNoEvidence(t *testing.T) {
|
||||
if evidence := AnalyseStops(nil, episodeRuntime); evidence.Found {
|
||||
t.Fatal("no stops produced evidence")
|
||||
}
|
||||
if evidence := AnalyseStops([]StopEvent{stop("paul", 44, 0, false)}, 0); evidence.Found {
|
||||
t.Fatal("a zero runtime produced evidence")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTighterAgreementScoresHigher(t *testing.T) {
|
||||
tight := AnalyseStops([]StopEvent{
|
||||
stop("paul", 44, 3, false),
|
||||
stop("david", 44, 4, false),
|
||||
stop("matt", 44, 5, false),
|
||||
}, episodeRuntime)
|
||||
loose := AnalyseStops([]StopEvent{
|
||||
stop("paul", 44, 0, false),
|
||||
stop("david", 44, 14, false),
|
||||
stop("matt", 44, 28, false),
|
||||
}, episodeRuntime)
|
||||
|
||||
if BehaviourConfidence(tight) <= BehaviourConfidence(loose) {
|
||||
t.Fatalf("tight agreement (%.2f) did not beat loose agreement (%.2f)",
|
||||
BehaviourConfidence(tight), BehaviourConfidence(loose))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBehaviourConfidenceNeverReachesCertainty(t *testing.T) {
|
||||
stops := []StopEvent{}
|
||||
for index := 0; index < 20; index++ {
|
||||
stops = append(stops, stop("viewer"+string(rune('a'+index)), 44, 4, true))
|
||||
}
|
||||
evidence := AnalyseStops(stops, episodeRuntime)
|
||||
if score := BehaviourConfidence(evidence); score > behaviourCeiling {
|
||||
t.Fatalf("confidence %.2f exceeded the behavioural ceiling %.2f",
|
||||
score, behaviourCeiling)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
package credits
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Candidate generation, and all of it pure.
|
||||
//
|
||||
// The rule this file exists to enforce is the one in the brief that matters most: a viewer
|
||||
// watching Blue Bloods is *not* a reason to scan 293 episodes of Blue Bloods. It is a reason
|
||||
// to scan the three they are about to reach. Everything here is arithmetic on where somebody
|
||||
// has got to and how fast they are moving, and the output is a handful of episodes.
|
||||
|
||||
// Config is the tuning. Defaults come from DefaultConfig; an operator changes them through
|
||||
// the environment rather than by editing constants, because the right look-ahead depends on
|
||||
// how a particular household watches television.
|
||||
type Config struct {
|
||||
// PrefetchEpisodes is the look-ahead for an ordinary viewer — the "credits.prefetchEpisodes
|
||||
// = 3" of the brief. Velocity moves the actual depth either side of it.
|
||||
PrefetchEpisodes int
|
||||
// MaxPrefetchEpisodes is the ceiling nothing may exceed, however fast somebody watches.
|
||||
// The objective is useful precomputation, not speculative scanning, and this is the line
|
||||
// between the two.
|
||||
MaxPrefetchEpisodes int
|
||||
|
||||
// The decay thresholds. Demand is evidence with a shelf life: three nights of Blue Bloods
|
||||
// followed by a week of Slow Horses must stop producing Blue Bloods candidates on its own,
|
||||
// without anything having to notice that the household changed its mind.
|
||||
StrongWindow time.Duration
|
||||
UsefulWindow time.Duration
|
||||
WeakWindow time.Duration
|
||||
|
||||
// QueueLimit bounds the queue. Past it, low-priority speculation is discarded rather than
|
||||
// queued — a backlog of candidates for episodes nobody reached is worse than no backlog.
|
||||
QueueLimit int
|
||||
}
|
||||
|
||||
func DefaultConfig() Config {
|
||||
return Config{
|
||||
PrefetchEpisodes: 3,
|
||||
MaxPrefetchEpisodes: 5,
|
||||
StrongWindow: 24 * time.Hour,
|
||||
UsefulWindow: 3 * 24 * time.Hour,
|
||||
WeakWindow: 7 * 24 * time.Hour,
|
||||
QueueLimit: 20,
|
||||
}
|
||||
}
|
||||
|
||||
// Watch is one episode one viewer played, reduced to the four things candidate generation
|
||||
// needs. It is what a Tracearr session becomes on the way in, and keeping it this narrow is
|
||||
// what lets every rule below be tested with a literal.
|
||||
type Watch struct {
|
||||
UserKey string
|
||||
SeriesID string
|
||||
Season int
|
||||
Episode int
|
||||
// WatchedAt is when the session ended, or started where it never ended. Recency is
|
||||
// measured from it and so is velocity.
|
||||
WatchedAt time.Time
|
||||
// Completed distinguishes "finished this episode, will start the next" from "is
|
||||
// part-way through it". The two produce different look-ahead windows, and getting it
|
||||
// wrong costs a scan of an episode somebody is already watching.
|
||||
Completed bool
|
||||
}
|
||||
|
||||
// EpisodeRef is a position in a series resolved to something scannable.
|
||||
type EpisodeRef struct {
|
||||
ItemID string
|
||||
Season int
|
||||
Episode int
|
||||
}
|
||||
|
||||
// EpisodeIndex resolves "the episodes after this one" — across a season boundary, since a
|
||||
// season finale is exactly when somebody is most likely to keep going. Backed by the imported
|
||||
// library, which already holds every episode with its numbering.
|
||||
type EpisodeIndex interface {
|
||||
Following(seriesID string, season, episode, count int) []EpisodeRef
|
||||
}
|
||||
|
||||
// SeriesActivity is one viewer's relationship with one series right now: where they have got
|
||||
// to, when they were last there, and how fast they are moving.
|
||||
type SeriesActivity struct {
|
||||
UserKey string
|
||||
SeriesID string
|
||||
|
||||
Season int
|
||||
Episode int
|
||||
// InProgress means the furthest episode was not finished, so it is itself a candidate
|
||||
// rather than something to look past.
|
||||
InProgress bool
|
||||
|
||||
LastViewed time.Time
|
||||
// EpisodesPerDay over the recent run. See viewingVelocity.
|
||||
EpisodesPerDay float64
|
||||
}
|
||||
|
||||
// Activities groups raw watches into one activity per viewer and series.
|
||||
//
|
||||
// "Where they have got to" is the *furthest* episode watched recently, not the most recent
|
||||
// session: somebody who dips back to rewatch an earlier episode has not un-watched the ones
|
||||
// after it, and predicting from the rewatch would queue episodes they finished a fortnight
|
||||
// ago. Recency still comes from the latest session, because that is what decay measures.
|
||||
func Activities(watches []Watch, now time.Time, cfg Config) []SeriesActivity {
|
||||
type key struct{ user, series string }
|
||||
grouped := map[key][]Watch{}
|
||||
for _, watch := range watches {
|
||||
if strings.TrimSpace(watch.SeriesID) == "" || watch.Season < 0 || watch.Episode <= 0 {
|
||||
continue
|
||||
}
|
||||
// Anything past the discard threshold is not evidence about tonight. Dropping it
|
||||
// here rather than at the end keeps it out of the velocity calculation too, where
|
||||
// a six-month-old session would otherwise drag an active binge down to a crawl.
|
||||
if now.Sub(watch.WatchedAt) > cfg.WeakWindow {
|
||||
continue
|
||||
}
|
||||
group := key{user: watch.UserKey, series: watch.SeriesID}
|
||||
grouped[group] = append(grouped[group], watch)
|
||||
}
|
||||
|
||||
out := make([]SeriesActivity, 0, len(grouped))
|
||||
for group, items := range grouped {
|
||||
activity := SeriesActivity{UserKey: group.user, SeriesID: group.series}
|
||||
for _, watch := range items {
|
||||
if watch.WatchedAt.After(activity.LastViewed) {
|
||||
activity.LastViewed = watch.WatchedAt
|
||||
}
|
||||
if watch.Season > activity.Season ||
|
||||
(watch.Season == activity.Season && watch.Episode > activity.Episode) {
|
||||
activity.Season, activity.Episode = watch.Season, watch.Episode
|
||||
activity.InProgress = !watch.Completed
|
||||
}
|
||||
}
|
||||
activity.EpisodesPerDay = viewingVelocity(items, now)
|
||||
out = append(out, activity)
|
||||
}
|
||||
// Sorted so a queue built from identical input is identical, which is what makes the
|
||||
// scheduler's behaviour reproducible in a test and its log readable in production.
|
||||
sort.Slice(out, func(a, b int) bool {
|
||||
if !out[a].LastViewed.Equal(out[b].LastViewed) {
|
||||
return out[a].LastViewed.After(out[b].LastViewed)
|
||||
}
|
||||
if out[a].SeriesID != out[b].SeriesID {
|
||||
return out[a].SeriesID < out[b].SeriesID
|
||||
}
|
||||
return out[a].UserKey < out[b].UserKey
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// viewingVelocity is episodes per day over the run somebody is actually on.
|
||||
//
|
||||
// Measured across the span from the first watch in the window to now, rather than to the
|
||||
// last watch: a viewer who watched four episodes on Monday and nothing since is not still
|
||||
// watching four a day, and rating them as a binge would queue five episodes for somebody
|
||||
// who has moved on. The span floors at a day, so one evening's four episodes reads as four
|
||||
// a day rather than as an infinite rate.
|
||||
func viewingVelocity(watches []Watch, now time.Time) float64 {
|
||||
if len(watches) == 0 {
|
||||
return 0
|
||||
}
|
||||
// Distinct episodes: a session resumed three times is one episode watched, and counting
|
||||
// the resumes would read an unreliable connection as enthusiasm.
|
||||
type slot struct{ season, episode int }
|
||||
seen := map[slot]bool{}
|
||||
earliest := now
|
||||
for _, watch := range watches {
|
||||
seen[slot{watch.Season, watch.Episode}] = true
|
||||
if watch.WatchedAt.Before(earliest) {
|
||||
earliest = watch.WatchedAt
|
||||
}
|
||||
}
|
||||
days := now.Sub(earliest).Hours() / 24
|
||||
if days < 1 {
|
||||
days = 1
|
||||
}
|
||||
return float64(len(seen)) / days
|
||||
}
|
||||
|
||||
// prefetchDepth turns a rate into a number of episodes to prepare.
|
||||
//
|
||||
// Deliberately a step function rather than anything cleverer. The brief's own guidance is
|
||||
// slow → 1, normal → 2-3, binge → 4-5, and prediction beyond that is not worth the risk of
|
||||
// being confidently wrong about somebody's evening: every episode of depth is a real scan.
|
||||
func prefetchDepth(episodesPerDay float64, cfg Config) int {
|
||||
base := cfg.PrefetchEpisodes
|
||||
if base <= 0 {
|
||||
base = 3
|
||||
}
|
||||
ceiling := cfg.MaxPrefetchEpisodes
|
||||
if ceiling <= 0 {
|
||||
ceiling = 5
|
||||
}
|
||||
|
||||
depth := base
|
||||
switch {
|
||||
case episodesPerDay < 0.5:
|
||||
// One episode every few days. The next one is all that is worth preparing, and
|
||||
// there will be plenty of time to prepare the one after it.
|
||||
depth = 1
|
||||
case episodesPerDay < 1.5:
|
||||
depth = base - 1
|
||||
case episodesPerDay < 3:
|
||||
depth = base
|
||||
case episodesPerDay < 5:
|
||||
depth = base + 1
|
||||
default:
|
||||
depth = base + 2
|
||||
}
|
||||
if depth < 1 {
|
||||
depth = 1
|
||||
}
|
||||
if depth > ceiling {
|
||||
depth = ceiling
|
||||
}
|
||||
return depth
|
||||
}
|
||||
|
||||
// decayWeight is how much a piece of demand still counts for.
|
||||
//
|
||||
// A multiplier rather than a filter because the queue is ordered, not gated: a three-day-old
|
||||
// binge is still worth preparing when the machine is idle, it simply must not outrank
|
||||
// somebody who watched an episode an hour ago.
|
||||
func decayWeight(lastViewed, now time.Time, cfg Config) float64 {
|
||||
age := now.Sub(lastViewed)
|
||||
switch {
|
||||
case age < 0:
|
||||
// A clock disagreement, not a prediction about the future.
|
||||
return 1
|
||||
case age <= cfg.StrongWindow:
|
||||
return 1
|
||||
case age <= cfg.UsefulWindow:
|
||||
return 0.75
|
||||
case age <= cfg.WeakWindow:
|
||||
return 0.4
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// priorityForOffset is the look-ahead hierarchy: how much less an episode is worth for each
|
||||
// step further from where somebody actually is.
|
||||
func priorityForOffset(offset int) (int, string) {
|
||||
switch offset {
|
||||
case 0, 1:
|
||||
return PriorityNext, ReasonNext
|
||||
case 2:
|
||||
return PriorityAhead2, ReasonBinge
|
||||
case 3:
|
||||
return PriorityAhead3, ReasonBinge
|
||||
default:
|
||||
return PrioritySpeculative, ReasonBinge
|
||||
}
|
||||
}
|
||||
|
||||
// BuildCandidates is the whole predictor: activities in, a small deduplicated queue out.
|
||||
//
|
||||
// Three things happen here and they are easy to conflate. Each viewer produces a *window* of
|
||||
// episodes ahead of where they are, sized by how fast they watch. Those windows are then
|
||||
// merged across the household, so an episode two people are approaching is one candidate
|
||||
// rather than two. And the merged candidate is boosted for the agreement, because one scan
|
||||
// now serves both of them.
|
||||
func BuildCandidates(
|
||||
activities []SeriesActivity, index EpisodeIndex, now time.Time, cfg Config,
|
||||
) []Candidate {
|
||||
if index == nil {
|
||||
return nil
|
||||
}
|
||||
merged := map[string]*Candidate{}
|
||||
users := map[string]map[string]bool{}
|
||||
|
||||
for _, activity := range activities {
|
||||
weight := decayWeight(activity.LastViewed, now, cfg)
|
||||
if weight <= 0 {
|
||||
continue
|
||||
}
|
||||
depth := prefetchDepth(activity.EpisodesPerDay, cfg)
|
||||
|
||||
// An unfinished episode is itself the first candidate; a finished one means the
|
||||
// window starts at the episode after it. Asking the index for one extra and dropping
|
||||
// the head is wrong here — the unfinished episode has an item id of its own that the
|
||||
// index would not return as "following".
|
||||
refs := index.Following(activity.SeriesID, activity.Season, activity.Episode, depth)
|
||||
window := make([]EpisodeRef, 0, depth+1)
|
||||
if activity.InProgress {
|
||||
window = append(window, EpisodeRef{
|
||||
ItemID: currentItemID(index, activity),
|
||||
Season: activity.Season,
|
||||
Episode: activity.Episode,
|
||||
})
|
||||
}
|
||||
window = append(window, refs...)
|
||||
|
||||
for offset, ref := range window {
|
||||
if strings.TrimSpace(ref.ItemID) == "" {
|
||||
continue
|
||||
}
|
||||
if offset >= depth+boolToInt(activity.InProgress) {
|
||||
break
|
||||
}
|
||||
base, reason := priorityForOffset(offset)
|
||||
priority := int(float64(base) * weight)
|
||||
|
||||
existing, found := merged[ref.ItemID]
|
||||
if !found {
|
||||
existing = &Candidate{
|
||||
ItemID: ref.ItemID,
|
||||
SeriesID: activity.SeriesID,
|
||||
Season: ref.Season,
|
||||
Episode: ref.Episode,
|
||||
Priority: priority,
|
||||
Reason: reason,
|
||||
LastViewed: activity.LastViewed,
|
||||
}
|
||||
merged[ref.ItemID] = existing
|
||||
users[ref.ItemID] = map[string]bool{}
|
||||
}
|
||||
// The strongest case for an episode wins; a second, weaker viewer never lowers
|
||||
// a candidate somebody else is about to reach.
|
||||
if priority > existing.Priority {
|
||||
existing.Priority, existing.Reason = priority, reason
|
||||
}
|
||||
if activity.LastViewed.After(existing.LastViewed) {
|
||||
existing.LastViewed = activity.LastViewed
|
||||
}
|
||||
users[ref.ItemID][activity.UserKey] = true
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]Candidate, 0, len(merged))
|
||||
for itemID, candidate := range merged {
|
||||
candidate.UserCount = len(users[itemID])
|
||||
if candidate.UserCount > 1 {
|
||||
// Agreement is the cheapest signal there is: the same single scan now serves
|
||||
// more than one person, so it is worth doing sooner.
|
||||
candidate.Priority += MultiUserBonus * (candidate.UserCount - 1)
|
||||
candidate.Reason = ReasonMultiUser
|
||||
}
|
||||
// Nothing predicted may reach live playback's priority. Live playback is a viewer
|
||||
// waiting; everything here is a guess about one.
|
||||
if candidate.Priority > MaxPredictedPriority {
|
||||
candidate.Priority = MaxPredictedPriority
|
||||
}
|
||||
out = append(out, *candidate)
|
||||
}
|
||||
sortCandidates(out)
|
||||
|
||||
if cfg.QueueLimit > 0 && len(out) > cfg.QueueLimit {
|
||||
out = out[:cfg.QueueLimit]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// currentItemID resolves the episode a viewer is part-way through. The index is asked for
|
||||
// the episode at that exact position by requesting the one before it and taking the head,
|
||||
// which keeps EpisodeIndex to a single method.
|
||||
func currentItemID(index EpisodeIndex, activity SeriesActivity) string {
|
||||
refs := index.Following(activity.SeriesID, activity.Season, activity.Episode-1, 1)
|
||||
for _, ref := range refs {
|
||||
if ref.Season == activity.Season && ref.Episode == activity.Episode {
|
||||
return ref.ItemID
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// sortCandidates puts the queue in the order the worker should take it: priority first, then
|
||||
// the most recent demand, then item id so the order is total and a test can assert on it.
|
||||
func sortCandidates(candidates []Candidate) {
|
||||
sort.Slice(candidates, func(a, b int) bool {
|
||||
if candidates[a].Priority != candidates[b].Priority {
|
||||
return candidates[a].Priority > candidates[b].Priority
|
||||
}
|
||||
if !candidates[a].LastViewed.Equal(candidates[b].LastViewed) {
|
||||
return candidates[a].LastViewed.After(candidates[b].LastViewed)
|
||||
}
|
||||
return candidates[a].ItemID < candidates[b].ItemID
|
||||
})
|
||||
}
|
||||
|
||||
func boolToInt(value bool) int {
|
||||
if value {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
package credits
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Candidate generation is the half of this subsystem that decides how much work exists at
|
||||
// all, so these tests are mostly about what it refuses to queue. A predictor that is
|
||||
// enthusiastic is indistinguishable from a library scanner.
|
||||
|
||||
var now = time.Date(2026, 8, 15, 21, 0, 0, 0, time.UTC)
|
||||
|
||||
// blueBloods is the worked example from the brief: a long-running procedural, so a rule that
|
||||
// over-reaches here queues hundreds of episodes.
|
||||
func blueBloods() EpisodeIndex {
|
||||
episodes := []SeriesEpisode{}
|
||||
for season := 1; season <= 8; season++ {
|
||||
for episode := 1; episode <= 22; episode++ {
|
||||
episodes = append(episodes, SeriesEpisode{
|
||||
ItemID: itemID(season, episode),
|
||||
SeriesID: "bb",
|
||||
Season: season,
|
||||
Episode: episode,
|
||||
})
|
||||
}
|
||||
}
|
||||
// One special, which must never be predicted as "next".
|
||||
episodes = append(episodes, SeriesEpisode{
|
||||
ItemID: "bb-s00e01", SeriesID: "bb", Season: 0, Episode: 1,
|
||||
})
|
||||
return NewEpisodeIndex(episodes)
|
||||
}
|
||||
|
||||
func itemID(season, episode int) string {
|
||||
return "bb-s" + twoDigit(season) + "e" + twoDigit(episode)
|
||||
}
|
||||
|
||||
func twoDigit(value int) string {
|
||||
if value < 10 {
|
||||
return "0" + string(rune('0'+value))
|
||||
}
|
||||
return string(rune('0'+value/10)) + string(rune('0'+value%10))
|
||||
}
|
||||
|
||||
func watch(user string, season, episode int, ago time.Duration, completed bool) Watch {
|
||||
return Watch{
|
||||
UserKey: user,
|
||||
SeriesID: "bb",
|
||||
Season: season,
|
||||
Episode: episode,
|
||||
WatchedAt: now.Add(-ago),
|
||||
Completed: completed,
|
||||
}
|
||||
}
|
||||
|
||||
func TestActiveSeriesProducesCandidates(t *testing.T) {
|
||||
watches := []Watch{
|
||||
watch("paul", 6, 5, 72*time.Hour, true),
|
||||
watch("paul", 6, 6, 48*time.Hour, true),
|
||||
watch("paul", 6, 7, 20*time.Minute, true),
|
||||
}
|
||||
candidates := BuildCandidates(
|
||||
Activities(watches, now, DefaultConfig()), blueBloods(), now, DefaultConfig(),
|
||||
)
|
||||
if len(candidates) == 0 {
|
||||
t.Fatal("an actively watched series produced no candidates")
|
||||
}
|
||||
// The brief's own example: having just finished S06E07, the next episodes are what is
|
||||
// worth preparing — and nothing before them.
|
||||
if candidates[0].ItemID != itemID(6, 8) {
|
||||
t.Fatalf("highest-priority candidate = %q, want S06E08", candidates[0].ItemID)
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if candidate.Season == 6 && candidate.Episode <= 7 {
|
||||
t.Fatalf("queued an already-watched episode: S%02dE%02d",
|
||||
candidate.Season, candidate.Episode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The rule the brief calls out as critical: watching a show is not a reason to scan the show.
|
||||
func TestDoesNotQueueTheWholeSeries(t *testing.T) {
|
||||
watches := []Watch{watch("paul", 6, 7, time.Hour, true)}
|
||||
candidates := BuildCandidates(
|
||||
Activities(watches, now, DefaultConfig()), blueBloods(), now, DefaultConfig(),
|
||||
)
|
||||
if len(candidates) > DefaultConfig().MaxPrefetchEpisodes {
|
||||
t.Fatalf("queued %d episodes for one viewer; the ceiling is %d",
|
||||
len(candidates), DefaultConfig().MaxPrefetchEpisodes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOldHistoryProducesNothing(t *testing.T) {
|
||||
watches := []Watch{
|
||||
watch("paul", 6, 7, 180*24*time.Hour, true),
|
||||
watch("paul", 6, 6, 181*24*time.Hour, true),
|
||||
}
|
||||
candidates := BuildCandidates(
|
||||
Activities(watches, now, DefaultConfig()), blueBloods(), now, DefaultConfig(),
|
||||
)
|
||||
if len(candidates) != 0 {
|
||||
t.Fatalf("six-month-old history produced %d candidates; want none", len(candidates))
|
||||
}
|
||||
}
|
||||
|
||||
// Decay is the mechanism that lets a household change its mind without anything noticing.
|
||||
func TestCandidateExpiryDecaysPriority(t *testing.T) {
|
||||
recent := BuildCandidates(
|
||||
Activities([]Watch{watch("paul", 6, 7, time.Hour, true)}, now, DefaultConfig()),
|
||||
blueBloods(), now, DefaultConfig(),
|
||||
)
|
||||
stale := BuildCandidates(
|
||||
Activities([]Watch{watch("paul", 6, 7, 5*24*time.Hour, true)}, now, DefaultConfig()),
|
||||
blueBloods(), now, DefaultConfig(),
|
||||
)
|
||||
if len(recent) == 0 || len(stale) == 0 {
|
||||
t.Fatal("expected candidates in both windows")
|
||||
}
|
||||
if stale[0].Priority >= recent[0].Priority {
|
||||
t.Fatalf("five-day-old demand (%d) did not rank below one-hour-old demand (%d)",
|
||||
stale[0].Priority, recent[0].Priority)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInProgressEpisodeIsItselfACandidate(t *testing.T) {
|
||||
watches := []Watch{watch("paul", 6, 7, 10*time.Minute, false)}
|
||||
candidates := BuildCandidates(
|
||||
Activities(watches, now, DefaultConfig()), blueBloods(), now, DefaultConfig(),
|
||||
)
|
||||
if len(candidates) == 0 || candidates[0].ItemID != itemID(6, 7) {
|
||||
t.Fatalf("a part-watched episode was not the top candidate; got %+v", candidates)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBingeExpandsAndSlowViewingContractsLookAhead(t *testing.T) {
|
||||
// Four episodes in one evening.
|
||||
binge := []Watch{
|
||||
watch("paul", 6, 4, 4*time.Hour, true),
|
||||
watch("paul", 6, 5, 3*time.Hour, true),
|
||||
watch("paul", 6, 6, 2*time.Hour, true),
|
||||
watch("paul", 6, 7, time.Hour, true),
|
||||
}
|
||||
// One episode every few days.
|
||||
slow := []Watch{
|
||||
watch("dave", 6, 6, 6*24*time.Hour, true),
|
||||
watch("dave", 6, 7, 2*24*time.Hour, true),
|
||||
}
|
||||
bingeCount := len(BuildCandidates(
|
||||
Activities(binge, now, DefaultConfig()), blueBloods(), now, DefaultConfig(),
|
||||
))
|
||||
slowCount := len(BuildCandidates(
|
||||
Activities(slow, now, DefaultConfig()), blueBloods(), now, DefaultConfig(),
|
||||
))
|
||||
if bingeCount <= slowCount {
|
||||
t.Fatalf("binge look-ahead (%d) did not exceed slow look-ahead (%d)",
|
||||
bingeCount, slowCount)
|
||||
}
|
||||
if slowCount > 2 {
|
||||
t.Fatalf("slow viewer got %d candidates; one or two is the whole point", slowCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrefetchDepthRespectsCeiling(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
if depth := prefetchDepth(50, cfg); depth > cfg.MaxPrefetchEpisodes {
|
||||
t.Fatalf("an implausible velocity produced depth %d, past the ceiling of %d",
|
||||
depth, cfg.MaxPrefetchEpisodes)
|
||||
}
|
||||
if depth := prefetchDepth(0.01, cfg); depth < 1 {
|
||||
t.Fatalf("depth %d; even the slowest viewer gets the next episode", depth)
|
||||
}
|
||||
}
|
||||
|
||||
// Two viewers approaching the same episode is one scan that serves both, so it should be
|
||||
// done sooner — never twice.
|
||||
func TestMultipleUsersCollapseAndRaisePriority(t *testing.T) {
|
||||
shared := []Watch{
|
||||
watch("paul", 6, 7, time.Hour, true),
|
||||
{UserKey: "dave", SeriesID: "bb", Season: 6, Episode: 7,
|
||||
WatchedAt: now.Add(-2 * time.Hour), Completed: true},
|
||||
}
|
||||
solo := []Watch{watch("paul", 6, 7, time.Hour, true)}
|
||||
|
||||
sharedCandidates := BuildCandidates(
|
||||
Activities(shared, now, DefaultConfig()), blueBloods(), now, DefaultConfig(),
|
||||
)
|
||||
soloCandidates := BuildCandidates(
|
||||
Activities(solo, now, DefaultConfig()), blueBloods(), now, DefaultConfig(),
|
||||
)
|
||||
|
||||
seen := map[string]int{}
|
||||
for _, candidate := range sharedCandidates {
|
||||
seen[candidate.ItemID]++
|
||||
}
|
||||
for id, count := range seen {
|
||||
if count > 1 {
|
||||
t.Fatalf("%s appeared %d times; duplicate candidates must collapse", id, count)
|
||||
}
|
||||
}
|
||||
if sharedCandidates[0].UserCount != 2 {
|
||||
t.Fatalf("UserCount = %d, want 2", sharedCandidates[0].UserCount)
|
||||
}
|
||||
if sharedCandidates[0].Priority <= soloCandidates[0].Priority {
|
||||
t.Fatalf("two interested viewers (%d) did not outrank one (%d)",
|
||||
sharedCandidates[0].Priority, soloCandidates[0].Priority)
|
||||
}
|
||||
if sharedCandidates[0].Reason != ReasonMultiUser {
|
||||
t.Fatalf("reason = %q, want %q", sharedCandidates[0].Reason, ReasonMultiUser)
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing the predictor produces may reach live playback's priority. Somebody watching now
|
||||
// outranks every guess about somebody who might watch later.
|
||||
func TestPredictionNeverOutranksLivePlayback(t *testing.T) {
|
||||
watches := []Watch{}
|
||||
for index := 0; index < 8; index++ {
|
||||
watches = append(watches, Watch{
|
||||
UserKey: "viewer" + twoDigit(index), SeriesID: "bb", Season: 6, Episode: 7,
|
||||
WatchedAt: now.Add(-time.Minute), Completed: true,
|
||||
})
|
||||
}
|
||||
candidates := BuildCandidates(
|
||||
Activities(watches, now, DefaultConfig()), blueBloods(), now, DefaultConfig(),
|
||||
)
|
||||
for _, candidate := range candidates {
|
||||
if candidate.Priority >= PriorityLive {
|
||||
t.Fatalf("predicted candidate reached %d; live playback is %d",
|
||||
candidate.Priority, PriorityLive)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookAheadCrossesSeasonBoundary(t *testing.T) {
|
||||
// A season finale is exactly when somebody carries on.
|
||||
watches := []Watch{
|
||||
watch("paul", 6, 20, 3*time.Hour, true),
|
||||
watch("paul", 6, 21, 2*time.Hour, true),
|
||||
watch("paul", 6, 22, time.Hour, true),
|
||||
}
|
||||
candidates := BuildCandidates(
|
||||
Activities(watches, now, DefaultConfig()), blueBloods(), now, DefaultConfig(),
|
||||
)
|
||||
if len(candidates) == 0 || candidates[0].Season != 7 || candidates[0].Episode != 1 {
|
||||
t.Fatalf("after a finale the next candidate was %+v, want S07E01", candidates)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpecialsAreNeverPredicted(t *testing.T) {
|
||||
watches := []Watch{watch("paul", 6, 22, time.Hour, true)}
|
||||
candidates := BuildCandidates(
|
||||
Activities(watches, now, DefaultConfig()), blueBloods(), now, DefaultConfig(),
|
||||
)
|
||||
for _, candidate := range candidates {
|
||||
if candidate.Season == 0 {
|
||||
t.Fatal("queued a special; nobody follows a finale with a featurette")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rewatching an earlier episode does not un-watch the later ones.
|
||||
func TestProgressIsTheFurthestEpisodeNotTheLatestSession(t *testing.T) {
|
||||
watches := []Watch{
|
||||
watch("paul", 6, 10, 24*time.Hour, true),
|
||||
watch("paul", 6, 2, time.Minute, true),
|
||||
}
|
||||
activities := Activities(watches, now, DefaultConfig())
|
||||
if len(activities) != 1 {
|
||||
t.Fatalf("expected one activity, got %d", len(activities))
|
||||
}
|
||||
if activities[0].Episode != 10 {
|
||||
t.Fatalf("progress = E%02d; a rewatch must not rewind it", activities[0].Episode)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package credits
|
||||
|
||||
import "time"
|
||||
|
||||
// How sure we are, and when that is sure enough to act.
|
||||
//
|
||||
// The governing rule is the brief's: prefer no marker to a wrong marker. A missing Skip
|
||||
// Credits button is an absence nobody notices; a button that appears during the final scene
|
||||
// and throws somebody past the ending is a fault they will remember, and one they cannot
|
||||
// undo without seeking back and finding their place. Every threshold here is set on that
|
||||
// asymmetry rather than on getting the most coverage.
|
||||
|
||||
const (
|
||||
// ConfidenceThreshold is the bar for storing a marker at all. Anything under it is
|
||||
// discarded — not stored with a low score for something later to filter, because a
|
||||
// stored marker is one a future season estimate will narrow a scan onto.
|
||||
ConfidenceThreshold = 0.70
|
||||
|
||||
// agreementTolerance is how far apart two independent findings can be and still be
|
||||
// describing the same transition. A visual detector reads the first frame of the roll;
|
||||
// a viewer presses stop a few seconds into it.
|
||||
agreementTolerance = 20 * time.Second
|
||||
|
||||
// disagreementPenalty is what a contradiction costs. Two signals pointing at different
|
||||
// places are not one strong finding and a weak one — they are evidence that at least one
|
||||
// detector is wrong about this file, and usually the right answer is to store nothing.
|
||||
disagreementPenalty = 0.3
|
||||
|
||||
// combinedCeiling caps agreement. Nothing here observes the credits directly, so a
|
||||
// certainty of 1 would be a claim the method cannot support.
|
||||
combinedCeiling = 0.98
|
||||
|
||||
// behaviourCeiling caps behaviour on its own. A household can agree precisely and still
|
||||
// be agreeing about the moment the last line of dialogue lands rather than the cut.
|
||||
behaviourCeiling = 0.90
|
||||
|
||||
// RewriteTolerance is how much a new reading may differ from a stored one before it is
|
||||
// worth a write. Readings of the same episode wobble by a few seconds; rewriting the row
|
||||
// each time would turn a subsystem whose whole claim is "one write per episode, ever"
|
||||
// into one that writes on every playback.
|
||||
RewriteTolerance = 12 * time.Second
|
||||
|
||||
// rewriteImprovement is how much better the evidence has to be for a rewrite *within*
|
||||
// tolerance to be worth doing. Inside the tolerance the position is not meaningfully
|
||||
// different, so the only reason to write is that the confidence changed enough to matter
|
||||
// to a later season estimate.
|
||||
rewriteImprovement = 0.15
|
||||
)
|
||||
|
||||
// BehaviourConfidence scores a stop cluster.
|
||||
//
|
||||
// Three things move it and they are independent: how many people agreed, how tightly they
|
||||
// agreed, and whether they pressed next rather than simply stopping. A next-episode
|
||||
// transition is the only one of the three that is unambiguous about *why* they left.
|
||||
func BehaviourConfidence(evidence BehaviourEvidence) float64 {
|
||||
if !evidence.Found || evidence.UserCount < behaviourNarrowMinUsers {
|
||||
return 0
|
||||
}
|
||||
score := 0.45
|
||||
// Each viewer past the second is worth less than the one before it: the step from two to
|
||||
// three is the one that rules out a coincidence, and the step from six to seven adds
|
||||
// almost nothing.
|
||||
for extra := 0; extra < evidence.UserCount-behaviourNarrowMinUsers; extra++ {
|
||||
score += 0.12 / float64(extra+1)
|
||||
}
|
||||
if evidence.NextEpisodeCount > 0 {
|
||||
score += 0.10 * float64(evidence.NextEpisodeCount) / float64(evidence.UserCount)
|
||||
}
|
||||
// Tightness, measured against the cluster tolerance: agreeing to within three seconds
|
||||
// earns nearly all of this, agreeing to within the full half-minute earns none of it.
|
||||
if tolerance := behaviourCluster.Milliseconds(); tolerance > 0 {
|
||||
tightness := 1 - float64(evidence.SpreadMs)/float64(tolerance)
|
||||
if tightness > 0 {
|
||||
score += 0.15 * tightness
|
||||
}
|
||||
}
|
||||
if score > behaviourCeiling {
|
||||
score = behaviourCeiling
|
||||
}
|
||||
return score
|
||||
}
|
||||
|
||||
// Combine reconciles a visual detection with behavioural evidence into the marker that will
|
||||
// be stored, or into nothing.
|
||||
//
|
||||
// The interesting case is agreement, and the reason to want it is that the two signals fail
|
||||
// in unrelated ways: a visual detector is fooled by a dark, static final scene, and a stop
|
||||
// cluster is fooled by an episode everybody happened to abandon at the same point. Neither
|
||||
// failure makes the other more likely, so agreement between them is worth far more than
|
||||
// either on its own — which is why the combination is a probabilistic union rather than an
|
||||
// average, and why an average would have been the wrong shape entirely.
|
||||
func Combine(visual Detection, evidence BehaviourEvidence) (Detection, bool) {
|
||||
behaviourScore := BehaviourConfidence(evidence)
|
||||
hasBehaviour := evidence.Found && behaviourScore > 0
|
||||
|
||||
switch {
|
||||
case visual.Found && hasBehaviour:
|
||||
gap := visual.StartMs - evidence.StartMs
|
||||
if gap < 0 {
|
||||
gap = -gap
|
||||
}
|
||||
if gap <= agreementTolerance.Milliseconds() {
|
||||
combined := visual
|
||||
combined.Method = MethodCombined
|
||||
// The earlier of the two. A skip that begins at the first frame of the roll is
|
||||
// correct; one that begins a few seconds in has already shown the viewer the
|
||||
// thing they asked to skip.
|
||||
if evidence.StartMs < combined.StartMs {
|
||||
combined.StartMs = evidence.StartMs
|
||||
}
|
||||
combined.Confidence = union(visual.Confidence, behaviourScore)
|
||||
if combined.Confidence > combinedCeiling {
|
||||
combined.Confidence = combinedCeiling
|
||||
}
|
||||
return combined, combined.Confidence >= ConfidenceThreshold
|
||||
}
|
||||
// They disagree. Take whichever is stronger, pay the penalty, and let the threshold
|
||||
// decide — which, with a penalty this size, usually means storing nothing.
|
||||
stronger := visual
|
||||
stronger.Method = MethodVisual
|
||||
if behaviourScore > visual.Confidence {
|
||||
stronger = Detection{
|
||||
Found: true,
|
||||
StartMs: evidence.StartMs,
|
||||
Confidence: behaviourScore,
|
||||
Method: MethodBehaviour,
|
||||
}
|
||||
}
|
||||
stronger.Confidence -= disagreementPenalty
|
||||
return stronger, stronger.Confidence >= ConfidenceThreshold
|
||||
|
||||
case visual.Found:
|
||||
visual.Method = MethodVisual
|
||||
return visual, visual.Confidence >= ConfidenceThreshold
|
||||
|
||||
case hasBehaviour && evidence.StandaloneMarker():
|
||||
detection := Detection{
|
||||
Found: true,
|
||||
StartMs: evidence.StartMs,
|
||||
Confidence: behaviourScore,
|
||||
Method: MethodBehaviour,
|
||||
}
|
||||
return detection, detection.Confidence >= ConfidenceThreshold
|
||||
|
||||
default:
|
||||
return Detection{}, false
|
||||
}
|
||||
}
|
||||
|
||||
// union combines two independent probabilities: the chance that at least one of them is
|
||||
// right, which is what independence buys.
|
||||
func union(a, b float64) float64 {
|
||||
if a < 0 {
|
||||
a = 0
|
||||
}
|
||||
if b < 0 {
|
||||
b = 0
|
||||
}
|
||||
return 1 - (1-a)*(1-b)
|
||||
}
|
||||
|
||||
// ShouldRewrite decides whether new evidence justifies touching an existing row.
|
||||
//
|
||||
// The default answer is no. Once an episode has a good marker it should never be written
|
||||
// again, and this function is the only thing standing between that promise and a row that is
|
||||
// updated every time somebody watches the episode.
|
||||
func ShouldRewrite(existing Marker, candidate Detection) bool {
|
||||
// An operator's correction is final. Nothing automatic may overwrite a position somebody
|
||||
// set by hand, whatever it thinks it has found.
|
||||
if existing.DetectionMethod == MethodManual {
|
||||
return false
|
||||
}
|
||||
if !candidate.Found || candidate.Confidence < ConfidenceThreshold {
|
||||
return false
|
||||
}
|
||||
gap := candidate.StartMs - existing.CreditsStartMs
|
||||
if gap < 0 {
|
||||
gap = -gap
|
||||
}
|
||||
if gap <= RewriteTolerance.Milliseconds() {
|
||||
// Same position, within the wobble. Only a materially better score is worth a write,
|
||||
// and only because a later season estimate will weigh this row by its confidence.
|
||||
return candidate.Confidence >= existing.Confidence+rewriteImprovement
|
||||
}
|
||||
// A genuinely different position. It has to be better evidence than what is already
|
||||
// there, not merely different — otherwise two detectors that disagree would rewrite the
|
||||
// row past each other for ever.
|
||||
return candidate.Confidence > existing.Confidence
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package credits
|
||||
|
||||
import "testing"
|
||||
|
||||
// Confidence, and the stability rule that decides whether anything is written at all.
|
||||
//
|
||||
// The governing principle under test throughout: prefer no marker to a wrong marker. Almost
|
||||
// every case here is about the combination refusing to answer.
|
||||
|
||||
func visual(startMs int64, confidence float64) Detection {
|
||||
return Detection{Found: true, StartMs: startMs, Confidence: confidence, Method: MethodVisual}
|
||||
}
|
||||
|
||||
func cluster(startMs int64, users int, next bool) BehaviourEvidence {
|
||||
nextCount := 0
|
||||
if next {
|
||||
nextCount = users
|
||||
}
|
||||
return BehaviourEvidence{
|
||||
Found: true, StartMs: startMs, UserCount: users,
|
||||
NextEpisodeCount: nextCount, SpreadMs: 4000,
|
||||
}
|
||||
}
|
||||
|
||||
// The brief's worked example: a visual detector at 44:01 and a Tracearr cluster at 44:05
|
||||
// should produce a marker around 44:03 with very high confidence.
|
||||
func TestAgreeingSignalsStrengthenEachOther(t *testing.T) {
|
||||
detection, ok := Combine(visual(2_641_000, 0.80), cluster(2_645_000, 3, true))
|
||||
if !ok {
|
||||
t.Fatal("two agreeing signals were rejected")
|
||||
}
|
||||
if detection.Method != MethodCombined {
|
||||
t.Fatalf("method = %q, want %q", detection.Method, MethodCombined)
|
||||
}
|
||||
if detection.Confidence <= 0.80 {
|
||||
t.Fatalf("confidence %.2f; agreement must beat either signal alone", detection.Confidence)
|
||||
}
|
||||
// The earlier of the two. A skip that starts at the first frame of the roll is right;
|
||||
// one that starts a few seconds in has already shown what it was asked to skip.
|
||||
if detection.StartMs != 2_641_000 {
|
||||
t.Fatalf("StartMs = %d, want the earlier reading", detection.StartMs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisagreeingSignalsUsuallyProduceNothing(t *testing.T) {
|
||||
// Three minutes apart is not two readings of one transition.
|
||||
_, ok := Combine(visual(2_400_000, 0.80), cluster(2_580_000, 3, true))
|
||||
if ok {
|
||||
t.Fatal("contradicting signals produced a marker; nothing is the right answer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeakVisualAloneIsRejected(t *testing.T) {
|
||||
if _, ok := Combine(visual(2_600_000, 0.55), BehaviourEvidence{}); ok {
|
||||
t.Fatal("a detection below the confidence threshold was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBehaviourAloneCanWriteAMarker(t *testing.T) {
|
||||
// The whole reason the behavioural half is worth having: on a well-watched show it
|
||||
// settles the question with no media access at all.
|
||||
detection, ok := Combine(Detection{}, cluster(2_645_000, 4, true))
|
||||
if !ok {
|
||||
t.Fatal("a four-viewer cluster produced no marker")
|
||||
}
|
||||
if detection.Method != MethodBehaviour {
|
||||
t.Fatalf("method = %q, want %q", detection.Method, MethodBehaviour)
|
||||
}
|
||||
}
|
||||
|
||||
func TestThinBehaviourAloneIsRejected(t *testing.T) {
|
||||
if _, ok := Combine(Detection{}, cluster(2_645_000, 2, false)); ok {
|
||||
t.Fatal("two plain stops wrote a marker with nothing else agreeing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNothingFoundIsNotAMarker(t *testing.T) {
|
||||
if _, ok := Combine(Detection{}, BehaviourEvidence{}); ok {
|
||||
t.Fatal("no evidence at all produced a marker")
|
||||
}
|
||||
}
|
||||
|
||||
// Marker stability: readings of the same episode wobble by a few seconds, and rewriting the
|
||||
// row each time would break the "one write per episode, ever" promise.
|
||||
func TestSmallVariationDoesNotRewrite(t *testing.T) {
|
||||
existing := Marker{CreditsStartMs: 2_644_000, Confidence: 0.90, DetectionMethod: MethodCombined}
|
||||
for _, offset := range []int64{-4000, -1000, 2000, 3000} {
|
||||
if ShouldRewrite(existing, visual(existing.CreditsStartMs+offset, 0.91)) {
|
||||
t.Fatalf("a %dms difference triggered a rewrite", offset)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubstantiallyBetterEvidenceRewrites(t *testing.T) {
|
||||
existing := Marker{CreditsStartMs: 2_644_000, Confidence: 0.71, DetectionMethod: MethodVisual}
|
||||
better := Detection{
|
||||
Found: true, StartMs: 2_644_500, Confidence: 0.95, Method: MethodCombined,
|
||||
}
|
||||
if !ShouldRewrite(existing, better) {
|
||||
t.Fatal("a much stronger reading of the same position did not rewrite")
|
||||
}
|
||||
}
|
||||
|
||||
func TestADifferentPositionNeedsBetterEvidence(t *testing.T) {
|
||||
existing := Marker{CreditsStartMs: 2_644_000, Confidence: 0.90, DetectionMethod: MethodCombined}
|
||||
// A different answer that is no better must not win, or two detectors that disagree
|
||||
// would rewrite the row past each other for ever.
|
||||
if ShouldRewrite(existing, visual(2_500_000, 0.85)) {
|
||||
t.Fatal("a weaker reading at a different position overwrote a strong marker")
|
||||
}
|
||||
if !ShouldRewrite(existing, Detection{
|
||||
Found: true, StartMs: 2_500_000, Confidence: 0.96, Method: MethodCombined,
|
||||
}) {
|
||||
t.Fatal("stronger evidence at a different position should win")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualMarkersAreNeverOverwritten(t *testing.T) {
|
||||
existing := Marker{CreditsStartMs: 2_600_000, Confidence: 0.5, DetectionMethod: MethodManual}
|
||||
if ShouldRewrite(existing, visual(2_400_000, 0.98)) {
|
||||
t.Fatal("an automatic detection overwrote an operator's correction")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBelowThresholdNeverRewrites(t *testing.T) {
|
||||
existing := Marker{CreditsStartMs: 2_644_000, Confidence: 0.72, DetectionMethod: MethodVisual}
|
||||
if ShouldRewrite(existing, visual(2_644_100, 0.50)) {
|
||||
t.Fatal("a sub-threshold detection was written over an accepted marker")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
// Package credits discovers where an episode's closing credits begin, for the small number
|
||||
// of episodes a household is actually about to watch.
|
||||
//
|
||||
// It is deliberately not a library scanner. A scanner asks "what is in the library" and
|
||||
// answers by reading all of it; this asks "what will somebody press Play on in the next few
|
||||
// evenings" and reads almost nothing. Tracearr already records what the house watches and
|
||||
// when, so the question has an answer that costs one indexed query — and the whole design
|
||||
// follows from taking that answer seriously:
|
||||
//
|
||||
// Tracearr demand → priority queue → marker cached? → tiny tail scan → one write → never again
|
||||
//
|
||||
// The two halves are kept apart on purpose. Candidate generation knows about viewers,
|
||||
// series and velocity and nothing about video; the detector is handed a file and a window
|
||||
// and never learns why that episode was chosen. That is what lets the scheduler be tested
|
||||
// with no media and the detector be benchmarked with no database.
|
||||
//
|
||||
// Emby's own chapter markers still win where they exist (`api/intro.go`). This fills in the
|
||||
// rest of a library, which on Emby 4.10 is most of it: a survey of the 20,000-item library
|
||||
// this ships to found no CreditsStart markers at all.
|
||||
package credits
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Detection methods, stored on the marker so a later reading can tell what produced it and
|
||||
// whether new evidence is worth preferring.
|
||||
const (
|
||||
// MethodVisual is a tail scan alone: the picture changed into something structurally
|
||||
// credit-shaped and nothing else agreed or disagreed.
|
||||
MethodVisual = "visual_tail"
|
||||
// MethodBehaviour is viewers alone. Where enough people stopped an episode, or pressed
|
||||
// next, within a few seconds of each other, that agreement is evidence no decoder can
|
||||
// produce — and it costs no media access whatsoever.
|
||||
MethodBehaviour = "behaviour"
|
||||
// MethodCombined is both, agreeing. This is the marker worth trusting.
|
||||
MethodCombined = "combined"
|
||||
// MethodManual is an operator's correction, which nothing automatic may overwrite.
|
||||
MethodManual = "manual"
|
||||
// MethodEmby is a chapter marker Emby detected itself. Recorded for completeness where
|
||||
// this subsystem stores one; the live path reads Emby's chapters directly.
|
||||
MethodEmby = "emby"
|
||||
)
|
||||
|
||||
// Why a candidate was generated. It travels with the candidate purely so a log line and the
|
||||
// benchmark can say whether demand-driven selection is earning its keep — nothing dispatches
|
||||
// on it, and the detector never sees it.
|
||||
const (
|
||||
ReasonLivePlayback = "live-playback"
|
||||
ReasonNext = "tracearr-next"
|
||||
ReasonBinge = "tracearr-binge-prefetch"
|
||||
ReasonMultiUser = "multi-user-demand"
|
||||
)
|
||||
|
||||
// The priority model. Every number the scheduler orders by comes from here rather than
|
||||
// being written into the rule that produced it, so the hierarchy can be read in one place
|
||||
// and changed without hunting through the candidate builder.
|
||||
const (
|
||||
// PriorityLive is playback that has already begun with no marker to offer. It outranks
|
||||
// everything because the viewer is in front of the television now, and the alternative
|
||||
// to answering is a Skip Credits button that never appears.
|
||||
PriorityLive = 1000
|
||||
// PriorityNext is the episode an active viewer is most likely to start next.
|
||||
PriorityNext = 800
|
||||
// PriorityAhead2 and PriorityAhead3 are the tail of the look-ahead window. They decay
|
||||
// fast on purpose: being three episodes wrong about somebody's evening is ordinary.
|
||||
PriorityAhead2 = 500
|
||||
PriorityAhead3 = 300
|
||||
// PrioritySpeculative is the floor — a candidate worth doing if the machine is otherwise
|
||||
// idle and worth discarding the moment the queue is under pressure.
|
||||
PrioritySpeculative = 100
|
||||
|
||||
// MultiUserBonus is added once per additional viewer approaching the same episode, so
|
||||
// two households converging on one episode outrank a single viewer's next episode
|
||||
// without ever overtaking live playback.
|
||||
MultiUserBonus = 100
|
||||
// MaxPredictedPriority caps everything the predictor can produce. Live playback sits
|
||||
// above it by construction, which is the one ordering guarantee worth enforcing rather
|
||||
// than hoping the arithmetic preserves.
|
||||
MaxPredictedPriority = PriorityLive - 1
|
||||
)
|
||||
|
||||
// Candidate is one episode somebody is likely to watch, and the case for scanning it.
|
||||
//
|
||||
// Deliberately small and deliberately transient: candidates live in RAM, are rebuilt from
|
||||
// Tracearr on every cycle, and are never written to the database. A restart rebuilds the
|
||||
// queue from the same query that built it the first time, which is cheaper and far simpler
|
||||
// than keeping a second job scheduler durable.
|
||||
type Candidate struct {
|
||||
ItemID string
|
||||
SeriesID string
|
||||
// Season and Episode are carried for the log line and for season-history narrowing;
|
||||
// nothing routes on them.
|
||||
Season int
|
||||
Episode int
|
||||
|
||||
Priority int
|
||||
Reason string
|
||||
// UserCount is how many distinct viewers this candidate was raised for. It is what
|
||||
// MultiUserBonus is computed from, and it is the honest measure of how much one scan is
|
||||
// worth.
|
||||
UserCount int
|
||||
// LastViewed is the most recent activity that justified this candidate. Decay is
|
||||
// measured from it, so a series somebody abandoned falls out of the queue on its own
|
||||
// rather than needing anything to remember that they did.
|
||||
LastViewed time.Time
|
||||
}
|
||||
|
||||
// MediaInfo is everything the detector is told. No item id, no viewer, no reason — a
|
||||
// detector that knew why an episode was chosen would be one that could be tuned to agree
|
||||
// with the predictor rather than with the media.
|
||||
type MediaInfo struct {
|
||||
// URL is where the bytes are. The gateway has no filesystem access to the media, so in
|
||||
// practice this is Emby's direct stream route and every read of it is a ranged request.
|
||||
URL string
|
||||
// RuntimeMs is the file's duration as Emby records it. The scan window is derived from
|
||||
// it, so a title with no runtime cannot be scanned at all — which is correct: without
|
||||
// one there is no tail to seek to.
|
||||
RuntimeMs int64
|
||||
// Window is where to look. Its provenance rides along because "did season history
|
||||
// actually narrow this" is the question the benchmark exists to answer.
|
||||
Window ScanWindow
|
||||
}
|
||||
|
||||
// Detection is what a detector came to. A detector that found nothing returns Found false
|
||||
// rather than an error: most of a library legitimately has no detectable credits roll, and
|
||||
// treating the common case as a failure would fill the log with news of nothing.
|
||||
type Detection struct {
|
||||
Found bool
|
||||
StartMs int64
|
||||
Confidence float64
|
||||
Method string
|
||||
|
||||
// Window is where the detector was told to look, carried back out so the benchmark can
|
||||
// report whether season history or behaviour actually narrowed anything. Nothing
|
||||
// downstream routes on it.
|
||||
Window ScanWindow
|
||||
|
||||
// The accounting the benchmark prints. Zero is a fine value for any of them.
|
||||
FramesSampled int
|
||||
BytesRead int64
|
||||
Elapsed time.Duration
|
||||
}
|
||||
|
||||
// Marker is what is stored, and it is the only durable output of this whole subsystem.
|
||||
//
|
||||
// Keyed by item *and* fingerprint: if Sonarr replaces the file, the fingerprint moves, the
|
||||
// old row stops matching and the episode becomes a candidate again with no invalidation
|
||||
// pass having to notice.
|
||||
type Marker struct {
|
||||
ItemID string
|
||||
MediaFingerprint string
|
||||
CreditsStartMs int64
|
||||
Confidence float64
|
||||
DetectionMethod string
|
||||
// SeriesID and Season are stored only so a season's markers can be read back as one
|
||||
// indexed query. That read is what narrows the next episode's scan from ten minutes of
|
||||
// file to two, which makes them the cheapest two columns in the schema.
|
||||
SeriesID string
|
||||
Season int
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// Detector turns a file and a window into a position, or into nothing.
|
||||
type Detector interface {
|
||||
Detect(ctx context.Context, media MediaInfo) (Detection, error)
|
||||
}
|
||||
|
||||
// CandidateSource produces the queue's input. Tracearr is the implementation that matters;
|
||||
// live playback pushes candidates in directly rather than through this.
|
||||
type CandidateSource interface {
|
||||
Candidates(ctx context.Context) ([]Candidate, error)
|
||||
}
|
||||
|
||||
// Repository is the marker store. Narrow on purpose — the scheduler must not be able to
|
||||
// write anything else, because "one row per successful scan and nothing else" is the
|
||||
// performance claim this subsystem is making.
|
||||
type Repository interface {
|
||||
// GetMarker answers whether this exact media version has already been decided. It is
|
||||
// the first thing every candidate hits and, once a library has settled, is the only
|
||||
// thing most of them ever hit.
|
||||
GetMarker(ctx context.Context, itemID, fingerprint string) (Marker, bool, error)
|
||||
// SaveMarker upserts. Called at most once per scan, and skipped entirely when
|
||||
// ShouldRewrite says the new evidence is not worth a write.
|
||||
SaveMarker(ctx context.Context, marker Marker) error
|
||||
// SeasonMarkers returns what is already known about a season, newest first. This is the
|
||||
// single most valuable optimisation in the package: it turns a ten-minute tail scan
|
||||
// into a two-minute one.
|
||||
SeasonMarkers(ctx context.Context, seriesID string, season int, limit int) ([]Marker, error)
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
package credits
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The visual detector: two sparse passes and a changepoint.
|
||||
//
|
||||
// What is being looked for is a *sustained structural transition*, not an understanding of
|
||||
// the picture. A credits roll is dark, textured with thin text, and — the part that makes it
|
||||
// findable — it stays that way for a minute or more, where a dark night scene does not. So
|
||||
// the detector does not try to recognise credits at all. It scores every sampled frame for
|
||||
// how credit-like it is, then finds the single point in the window where everything after it
|
||||
// scores markedly higher than everything before it, and refuses to answer when no such point
|
||||
// is clearly better than its neighbours.
|
||||
//
|
||||
// This is deliberately the least clever component in the package. Anything heavier — a model,
|
||||
// OCR, a full OpenCV pipeline — would be a dependency and a hardware requirement out of all
|
||||
// proportion to a feature whose fallback is simply not showing a button.
|
||||
|
||||
const (
|
||||
// sustainSeconds is how long the credit-like state has to persist to count. Shorter than
|
||||
// this and a dark establishing shot at the end of an act qualifies.
|
||||
sustainSeconds = 45
|
||||
|
||||
// leadSeconds is how much ordinary programme has to precede the transition. Without it
|
||||
// the changepoint can sit at the very first sampled frame, which is not evidence of a
|
||||
// transition — it is evidence that the window started too late.
|
||||
leadSeconds = 12
|
||||
|
||||
// creditLikeFloor is how credit-like the tail has to look in absolute terms. The
|
||||
// separation test alone would happily report a transition from "slightly less dark" to
|
||||
// "slightly more dark" in the middle of a night scene.
|
||||
creditLikeFloor = 0.45
|
||||
|
||||
// minSeparation is how much better the tail has to score than the head. This is the
|
||||
// primary guard against answering on noise, and it is set high because the cost of a
|
||||
// wrong marker is somebody losing the end of an episode.
|
||||
minSeparation = 0.18
|
||||
)
|
||||
|
||||
// VisualDetector implements Detector over the ffmpeg sampler.
|
||||
type VisualDetector struct {
|
||||
Sampler *Sampler
|
||||
}
|
||||
|
||||
// Detect runs the coarse pass, then a fine pass around whatever it found.
|
||||
//
|
||||
// The second pass is what makes the answer usable. A frame every four seconds locates the
|
||||
// transition to within four seconds, and a Skip Credits button four seconds early clips the
|
||||
// last line of dialogue. Refining costs a second pass over a single minute of file — about
|
||||
// eighty more tiny frames — which is a good trade for a marker that will be served for the
|
||||
// life of the media version.
|
||||
func (d *VisualDetector) Detect(ctx context.Context, media MediaInfo) (Detection, error) {
|
||||
if d == nil || d.Sampler == nil || !media.Window.Valid() {
|
||||
return Detection{}, nil
|
||||
}
|
||||
started := time.Now()
|
||||
|
||||
from := time.Duration(media.Window.StartMs) * time.Millisecond
|
||||
to := time.Duration(media.Window.EndMs) * time.Millisecond
|
||||
|
||||
coarse, err := d.Sampler.Sample(ctx, media.URL, from, to, coarseInterval)
|
||||
if err != nil {
|
||||
return Detection{}, err
|
||||
}
|
||||
frames := len(coarse)
|
||||
index, separation, found := findTransition(coarse, coarseInterval)
|
||||
if !found {
|
||||
// No transition is a perfectly ordinary answer, and the common one on a film. It is
|
||||
// reported as "nothing found" rather than as an error so it can be cached: without
|
||||
// that, every playback of a credit-less title would re-scan it.
|
||||
return Detection{
|
||||
FramesSampled: frames,
|
||||
Elapsed: time.Since(started),
|
||||
}, nil
|
||||
}
|
||||
startMs := coarse[index].PositionMs
|
||||
|
||||
// Refine, but never let the refinement move the answer outside the span the coarse pass
|
||||
// actually pointed at — a fine pass that disagreed wildly would be finding a different
|
||||
// transition, and the coarse one had the whole window to consider.
|
||||
fineFrom := time.Duration(startMs)*time.Millisecond - fineSpan
|
||||
if fineFrom < from {
|
||||
fineFrom = from
|
||||
}
|
||||
fineTo := time.Duration(startMs)*time.Millisecond + fineSpan
|
||||
if fineTo > to {
|
||||
fineTo = to
|
||||
}
|
||||
if fine, fineErr := d.Sampler.Sample(ctx, media.URL, fineFrom, fineTo, fineInterval); fineErr == nil {
|
||||
frames += len(fine)
|
||||
if refined, refinedSeparation, ok := findTransition(fine, fineInterval); ok {
|
||||
startMs = fine[refined].PositionMs
|
||||
if refinedSeparation > separation {
|
||||
separation = refinedSeparation
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Detection{
|
||||
Found: true,
|
||||
StartMs: startMs,
|
||||
Confidence: visualConfidence(separation),
|
||||
Method: MethodVisual,
|
||||
FramesSampled: frames,
|
||||
Elapsed: time.Since(started),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// creditScore is how credit-like one frame looks, from 0 to 1.
|
||||
//
|
||||
// Three properties, and the way they are combined is the substance of the detector. Darkness
|
||||
// is *multiplied* rather than added, which is what separates credits from the one thing that
|
||||
// most resembles them: a night exterior is dark and flat and, under a weighted sum, scores
|
||||
// most of the way to the floor on those two properties alone — which is exactly how a final
|
||||
// scene comes to be reported as a credits roll. Text is not optional evidence that tops a
|
||||
// dark frame up; it is the thing being detected, and a dark frame without it must score near
|
||||
// zero however dark and however flat it is.
|
||||
func creditScore(frame frameStats) float64 {
|
||||
darkness := frame.DarkFraction
|
||||
|
||||
// Text produces a narrow *band* of edge density: a flat black frame has almost none, and
|
||||
// a detailed photograph has far more than titles do. Scoring the band rather than the
|
||||
// magnitude is what stops a bright, busy scene outscoring the credits.
|
||||
const idealEdges = 0.030
|
||||
text := frame.EdgeDensity / idealEdges
|
||||
if text > 1 {
|
||||
text = 2 - text
|
||||
}
|
||||
text = clamp01(text)
|
||||
|
||||
// Flatness: a credits background is uniform, so nearly all the variance in the frame
|
||||
// comes from the text itself and is small. A supporting property, never a deciding one.
|
||||
flatness := clamp01(1 - frame.Variance/0.04)
|
||||
|
||||
return clamp01(darkness * (0.75*text + 0.25*flatness))
|
||||
}
|
||||
|
||||
// findTransition locates the point where the window stops looking like programme and starts
|
||||
// looking like credits.
|
||||
//
|
||||
// Pure, and the piece worth testing hardest: it is the whole of the visual decision, and
|
||||
// everything above it is plumbing. It returns the index of the first credit-like frame, and
|
||||
// how much better the tail scored than the head — which is what confidence is derived from.
|
||||
func findTransition(frames []frameStats, interval time.Duration) (int, float64, bool) {
|
||||
if interval <= 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
perSecond := float64(time.Second) / float64(interval)
|
||||
lead := int(float64(leadSeconds) * perSecond)
|
||||
if lead < 1 {
|
||||
lead = 1
|
||||
}
|
||||
sustain := int(float64(sustainSeconds) * perSecond)
|
||||
if sustain < 2 {
|
||||
sustain = 2
|
||||
}
|
||||
if len(frames) < lead+sustain {
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
scores := make([]float64, len(frames))
|
||||
for index, frame := range frames {
|
||||
scores[index] = creditScore(frame)
|
||||
}
|
||||
// Prefix sums so every candidate split is evaluated in constant time. The window is only
|
||||
// a few hundred frames, but the quadratic version is the kind of thing that stops being
|
||||
// free the moment somebody widens the window.
|
||||
prefix := make([]float64, len(scores)+1)
|
||||
for index, score := range scores {
|
||||
prefix[index+1] = prefix[index] + score
|
||||
}
|
||||
segmentMean := func(from, to int) float64 {
|
||||
if to <= from {
|
||||
return 0
|
||||
}
|
||||
return (prefix[to] - prefix[from]) / float64(to-from)
|
||||
}
|
||||
|
||||
bestIndex, bestSeparation, found := 0, 0.0, false
|
||||
for split := lead; split+sustain <= len(frames); split++ {
|
||||
head := segmentMean(0, split)
|
||||
tail := segmentMean(split, len(frames))
|
||||
// The sustained window immediately after the split has to qualify on its own, not
|
||||
// merely drag the average of a long tail up. This is what refuses a dark final shot
|
||||
// followed by genuine credits: the split belongs at the credits, not at the shot.
|
||||
if segmentMean(split, split+sustain) < creditLikeFloor {
|
||||
continue
|
||||
}
|
||||
separation := tail - head
|
||||
if separation < minSeparation || separation <= bestSeparation {
|
||||
continue
|
||||
}
|
||||
bestIndex, bestSeparation, found = split, separation, true
|
||||
}
|
||||
return bestIndex, bestSeparation, found
|
||||
}
|
||||
|
||||
// visualConfidence maps separation onto a score.
|
||||
//
|
||||
// Capped below certainty because a single detector agreeing with itself is not corroboration:
|
||||
// the ceiling is what forces a genuinely ambiguous file to wait for behavioural agreement
|
||||
// before a marker is written, rather than being decided by one reading of one window.
|
||||
func visualConfidence(separation float64) float64 {
|
||||
const ceiling = 0.88
|
||||
// minSeparation earns the threshold exactly; twice it earns nearly the ceiling. A
|
||||
// detector that cleared the bar by a hair should produce a marker that a disagreement
|
||||
// can still overturn.
|
||||
score := ConfidenceThreshold +
|
||||
(ceiling-ConfidenceThreshold)*clamp01((separation-minSeparation)/minSeparation)
|
||||
return score
|
||||
}
|
||||
|
||||
func clamp01(value float64) float64 {
|
||||
if value < 0 {
|
||||
return 0
|
||||
}
|
||||
if value > 1 {
|
||||
return 1
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// noopDetector stands in when ffmpeg is absent. It finds nothing, which lets the service run
|
||||
// on behavioural evidence alone rather than needing a second code path for the case.
|
||||
type noopDetector struct{}
|
||||
|
||||
func (noopDetector) Detect(context.Context, MediaInfo) (Detection, error) {
|
||||
return Detection{}, nil
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package credits
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The visual changepoint, tested on synthesised frame statistics rather than on media.
|
||||
//
|
||||
// findTransition is the whole of the visual decision — everything above it is ffmpeg
|
||||
// plumbing — and it is pure, so the cases that matter can be built as literals: a clean
|
||||
// transition, a dark final scene that must not be mistaken for one, and a file with no
|
||||
// credits at all.
|
||||
|
||||
// programmeFrame is an ordinary scene: mid-bright, varied, detailed.
|
||||
func programmeFrame(position time.Duration) frameStats {
|
||||
return frameStats{
|
||||
PositionMs: position.Milliseconds(),
|
||||
Mean: 0.42,
|
||||
Variance: 0.055,
|
||||
DarkFraction: 0.10,
|
||||
EdgeDensity: 0.075,
|
||||
Diff: 0.08,
|
||||
}
|
||||
}
|
||||
|
||||
// creditsFrame is text on black: dark, flat, and textured in the narrow band text produces.
|
||||
func creditsFrame(position time.Duration) frameStats {
|
||||
return frameStats{
|
||||
PositionMs: position.Milliseconds(),
|
||||
Mean: 0.05,
|
||||
Variance: 0.006,
|
||||
DarkFraction: 0.94,
|
||||
EdgeDensity: 0.030,
|
||||
Diff: 0.02,
|
||||
}
|
||||
}
|
||||
|
||||
// darkSceneFrame is the trap: a night exterior is dark and flat but carries none of the fine
|
||||
// detail text does.
|
||||
func darkSceneFrame(position time.Duration) frameStats {
|
||||
return frameStats{
|
||||
PositionMs: position.Milliseconds(),
|
||||
Mean: 0.08,
|
||||
Variance: 0.010,
|
||||
DarkFraction: 0.88,
|
||||
EdgeDensity: 0.004,
|
||||
Diff: 0.05,
|
||||
}
|
||||
}
|
||||
|
||||
func window(start time.Duration, kinds ...func(time.Duration) frameStats) []frameStats {
|
||||
frames := make([]frameStats, 0, len(kinds))
|
||||
for index, build := range kinds {
|
||||
frames = append(frames, build(start+time.Duration(index)*coarseInterval))
|
||||
}
|
||||
return frames
|
||||
}
|
||||
|
||||
func repeatFrames(count int, build func(time.Duration) frameStats) []func(time.Duration) frameStats {
|
||||
out := make([]func(time.Duration) frameStats, 0, count)
|
||||
for index := 0; index < count; index++ {
|
||||
out = append(out, build)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestFindsACleanTransition(t *testing.T) {
|
||||
// Two minutes of programme, then two minutes of credits, at one frame every four seconds.
|
||||
kinds := append(repeatFrames(30, programmeFrame), repeatFrames(30, creditsFrame)...)
|
||||
frames := window(39*time.Minute, kinds...)
|
||||
|
||||
index, separation, found := findTransition(frames, coarseInterval)
|
||||
if !found {
|
||||
t.Fatal("a clean transition was not found")
|
||||
}
|
||||
if index != 30 {
|
||||
t.Fatalf("transition at frame %d, want 30", index)
|
||||
}
|
||||
if separation < minSeparation {
|
||||
t.Fatalf("separation %.3f below the threshold %.3f", separation, minSeparation)
|
||||
}
|
||||
if score := visualConfidence(separation); score < ConfidenceThreshold {
|
||||
t.Fatalf("confidence %.2f below the threshold", score)
|
||||
}
|
||||
}
|
||||
|
||||
// The whole point of not answering on darkness alone.
|
||||
func TestDarkSceneIsNotCredits(t *testing.T) {
|
||||
kinds := append(repeatFrames(30, programmeFrame), repeatFrames(30, darkSceneFrame)...)
|
||||
frames := window(39*time.Minute, kinds...)
|
||||
|
||||
if _, _, found := findTransition(frames, coarseInterval); found {
|
||||
t.Fatal("a dark night scene was reported as credits")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoTransitionInOrdinaryProgramme(t *testing.T) {
|
||||
frames := window(39*time.Minute, repeatFrames(60, programmeFrame)...)
|
||||
if _, _, found := findTransition(frames, coarseInterval); found {
|
||||
t.Fatal("found a transition in a window with no transition in it")
|
||||
}
|
||||
}
|
||||
|
||||
// A window that started too late shows credits from its first frame, which is evidence about
|
||||
// the window rather than about the file.
|
||||
func TestCreditsFromTheFirstFrameAreNotATransition(t *testing.T) {
|
||||
frames := window(41*time.Minute, repeatFrames(50, creditsFrame)...)
|
||||
if _, _, found := findTransition(frames, coarseInterval); found {
|
||||
t.Fatal("an all-credits window was read as a transition")
|
||||
}
|
||||
}
|
||||
|
||||
// A dark beat at the end of an act is not sustained; a credits roll is.
|
||||
func TestBriefDarkBeatIsIgnored(t *testing.T) {
|
||||
kinds := append(repeatFrames(20, programmeFrame), repeatFrames(3, creditsFrame)...)
|
||||
kinds = append(kinds, repeatFrames(30, programmeFrame)...)
|
||||
frames := window(39*time.Minute, kinds...)
|
||||
|
||||
if _, _, found := findTransition(frames, coarseInterval); found {
|
||||
t.Fatal("a three-frame dark beat was reported as credits")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTooFewFramesAnswerNothing(t *testing.T) {
|
||||
frames := window(41*time.Minute, repeatFrames(4, creditsFrame)...)
|
||||
if _, _, found := findTransition(frames, coarseInterval); found {
|
||||
t.Fatal("four frames were enough to claim a transition")
|
||||
}
|
||||
if _, _, found := findTransition(nil, coarseInterval); found {
|
||||
t.Fatal("no frames produced a transition")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreditScoreSeparatesTheThreeCases(t *testing.T) {
|
||||
credits := creditScore(creditsFrame(0))
|
||||
dark := creditScore(darkSceneFrame(0))
|
||||
programme := creditScore(programmeFrame(0))
|
||||
|
||||
if credits <= dark || dark <= programme {
|
||||
t.Fatalf("scores do not separate: credits %.2f, dark scene %.2f, programme %.2f",
|
||||
credits, dark, programme)
|
||||
}
|
||||
if credits < creditLikeFloor {
|
||||
t.Fatalf("a textbook credits frame scored %.2f, below the floor %.2f",
|
||||
credits, creditLikeFloor)
|
||||
}
|
||||
if dark >= creditLikeFloor {
|
||||
t.Fatalf("a dark scene scored %.2f, at or above the credits floor %.2f",
|
||||
dark, creditLikeFloor)
|
||||
}
|
||||
}
|
||||
|
||||
// Confidence from one detector agreeing with itself is not corroboration.
|
||||
func TestVisualConfidenceIsCapped(t *testing.T) {
|
||||
if score := visualConfidence(10); score >= 1 {
|
||||
t.Fatalf("visual confidence reached %.2f", score)
|
||||
}
|
||||
if score := visualConfidence(minSeparation); score < ConfidenceThreshold {
|
||||
t.Fatalf("a detection at the separation threshold scored %.2f, below the bar", score)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package credits
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Identifying a media *version*, not an item.
|
||||
//
|
||||
// An Emby item id survives a file being replaced, which is exactly the case this subsystem
|
||||
// must not get wrong: Sonarr swapping a 720p rip for a 1080p one leaves the id alone, and a
|
||||
// marker measured against the old file would put Skip Credits somewhere arbitrary in the new
|
||||
// one. Keying the marker on item *and* fingerprint means a replaced file simply stops
|
||||
// matching — the episode becomes a candidate again, and nothing had to notice the swap or
|
||||
// run an invalidation pass to make that happen.
|
||||
|
||||
// MediaVersion is what a fingerprint is computed from. Every field is optional except the
|
||||
// item id: a source that can only offer runtime still produces a usable fingerprint, it is
|
||||
// just a coarser one.
|
||||
type MediaVersion struct {
|
||||
ItemID string
|
||||
RuntimeMs int64
|
||||
SizeBytes int64
|
||||
ModifiedAt time.Time
|
||||
// ETag is Emby's own version token where it offers one. It is the strongest field here
|
||||
// because it changes for reasons the other three can miss — a remux to the same length
|
||||
// and near-enough the same size.
|
||||
ETag string
|
||||
}
|
||||
|
||||
// Fingerprint is a short, stable digest of a media version.
|
||||
//
|
||||
// Truncated to sixteen bytes because it is an equality key rather than a security claim: it
|
||||
// is only ever compared against another fingerprint of the same item, and a full digest
|
||||
// would double the width of the busiest index in the schema for no gain.
|
||||
func Fingerprint(version MediaVersion) string {
|
||||
parts := []string{
|
||||
strings.TrimSpace(version.ItemID),
|
||||
strconv.FormatInt(version.RuntimeMs, 10),
|
||||
strconv.FormatInt(version.SizeBytes, 10),
|
||||
strings.TrimSpace(version.ETag),
|
||||
}
|
||||
if !version.ModifiedAt.IsZero() {
|
||||
// Second resolution. Filesystems and Emby disagree about sub-second timestamps often
|
||||
// enough that finer granularity would invalidate markers on files nothing had
|
||||
// touched, which is the expensive direction to be wrong in.
|
||||
parts = append(parts, strconv.FormatInt(version.ModifiedAt.UTC().Unix(), 10))
|
||||
}
|
||||
sum := sha256.Sum256([]byte(strings.Join(parts, "|")))
|
||||
return hex.EncodeToString(sum[:16])
|
||||
}
|
||||
|
||||
// Weak reports a fingerprint built from too little to detect a file swap.
|
||||
//
|
||||
// Runtime alone does not distinguish a re-encode of the same episode, so a marker stored
|
||||
// against a weak fingerprint is one that could survive a replacement it should not have. The
|
||||
// service refuses to store those rather than storing a marker it cannot invalidate — the
|
||||
// alternative is a wrong Skip Credits position that nothing will ever correct.
|
||||
func (v MediaVersion) Weak() bool {
|
||||
return v.SizeBytes <= 0 && strings.TrimSpace(v.ETag) == "" && v.ModifiedAt.IsZero()
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package credits
|
||||
|
||||
import "sync"
|
||||
|
||||
// A minimal single-flight, because four signals can name the same episode.
|
||||
//
|
||||
// Live playback, a predicted next episode, another viewer's prediction and a refresh cycle
|
||||
// all legitimately want the same media version at the same moment, and each of them arriving
|
||||
// as its own scan is the one way this subsystem could become expensive. The queue already
|
||||
// deduplicates by item; this closes the gap for anything that reaches Process directly — a
|
||||
// forced scan from the console, the benchmark, or a live push racing the worker.
|
||||
//
|
||||
// Written rather than taken from golang.org/x/sync so the gateway keeps its two direct
|
||||
// dependencies. It is thirty lines and the semantics are not subtle: the first caller does
|
||||
// the work, everybody else waits and receives the same answer.
|
||||
type flightGroup struct {
|
||||
mu sync.Mutex
|
||||
calls map[string]*flightCall
|
||||
}
|
||||
|
||||
type flightCall struct {
|
||||
done chan struct{}
|
||||
value any
|
||||
err error
|
||||
}
|
||||
|
||||
func newFlightGroup() *flightGroup {
|
||||
return &flightGroup{calls: map[string]*flightCall{}}
|
||||
}
|
||||
|
||||
// Do runs fn unless an identical key is already in flight, in which case it waits for that
|
||||
// one and returns its result.
|
||||
func (g *flightGroup) Do(key string, fn func() (any, error)) (any, error) {
|
||||
g.mu.Lock()
|
||||
if existing, found := g.calls[key]; found {
|
||||
g.mu.Unlock()
|
||||
<-existing.done
|
||||
return existing.value, existing.err
|
||||
}
|
||||
call := &flightCall{done: make(chan struct{})}
|
||||
g.calls[key] = call
|
||||
g.mu.Unlock()
|
||||
|
||||
// The deferred cleanup matters more than it looks: a detector that panics must not leave
|
||||
// a key permanently in flight, which would make that episode unscannable for the life of
|
||||
// the process and give every later caller a channel that never closes.
|
||||
defer func() {
|
||||
g.mu.Lock()
|
||||
delete(g.calls, key)
|
||||
g.mu.Unlock()
|
||||
close(call.done)
|
||||
}()
|
||||
|
||||
call.value, call.err = fn()
|
||||
return call.value, call.err
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package credits
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Whether the server is too busy to be reading media speculatively.
|
||||
//
|
||||
// Deliberately not a telemetry subsystem. The gateway already knows the only thing that
|
||||
// actually matters here — how many televisions are playing something right now — because
|
||||
// every one of them reports playback started, progress and stopped. A scan competes with
|
||||
// those streams for the same disk and the same Emby, so the count is a better predictor of
|
||||
// harm than a CPU figure would be, and it costs nothing to keep.
|
||||
|
||||
const (
|
||||
// busyPlaybacks is how many concurrent streams make speculative scanning unwelcome. Two
|
||||
// televisions is an ordinary evening in a household and the NAS copes; a third is the
|
||||
// point at which adding ranged reads of a fourth file stops being free.
|
||||
busyPlaybacks = 2
|
||||
|
||||
// playbackStale is how long a playback is believed without a progress report. Reports
|
||||
// arrive every ten seconds, so a minute of silence means the television went away
|
||||
// without saying so — a crash, a power cut, a network drop. Without this the gauge would
|
||||
// latch busy for ever on one lost stop report and speculative scanning would never run
|
||||
// again until the container restarted.
|
||||
playbackStale = 90 * time.Second
|
||||
)
|
||||
|
||||
// PlaybackLoad counts what is playing, from the reports televisions already send.
|
||||
type PlaybackLoad struct {
|
||||
mu sync.Mutex
|
||||
active map[string]time.Time
|
||||
}
|
||||
|
||||
func NewPlaybackLoad() *PlaybackLoad {
|
||||
return &PlaybackLoad{active: map[string]time.Time{}}
|
||||
}
|
||||
|
||||
// Playing records that a stream is alive. Called from playback started and from each
|
||||
// progress report, which is what makes the staleness sweep work: a live stream keeps
|
||||
// refreshing its own timestamp.
|
||||
func (l *PlaybackLoad) Playing(sessionKey string) {
|
||||
if l == nil || sessionKey == "" {
|
||||
return
|
||||
}
|
||||
l.mu.Lock()
|
||||
l.active[sessionKey] = time.Now()
|
||||
l.mu.Unlock()
|
||||
}
|
||||
|
||||
// Stopped records that a stream ended.
|
||||
func (l *PlaybackLoad) Stopped(sessionKey string) {
|
||||
if l == nil || sessionKey == "" {
|
||||
return
|
||||
}
|
||||
l.mu.Lock()
|
||||
delete(l.active, sessionKey)
|
||||
l.mu.Unlock()
|
||||
}
|
||||
|
||||
// Busy is the one question the scheduler asks.
|
||||
func (l *PlaybackLoad) Busy(context.Context) bool {
|
||||
return l.Count() >= busyPlaybacks
|
||||
}
|
||||
|
||||
// Count is the live stream count, sweeping anything that stopped reporting. The sweep is
|
||||
// here rather than on a ticker because this is the only reader, and a background goroutine
|
||||
// to expire at most a handful of map entries would cost more than it tidies.
|
||||
func (l *PlaybackLoad) Count() int {
|
||||
if l == nil {
|
||||
return 0
|
||||
}
|
||||
cutoff := time.Now().Add(-playbackStale)
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
for key, seen := range l.active {
|
||||
if seen.Before(cutoff) {
|
||||
delete(l.active, key)
|
||||
}
|
||||
}
|
||||
return len(l.active)
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package credits
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
// The adapter between this package's vocabulary and the store's.
|
||||
//
|
||||
// It exists so every rule above it — candidate generation, decay, clustering, confidence,
|
||||
// the queue — can be tested against literals with no database, which is most of why this
|
||||
// subsystem could be built without a Postgres to hand.
|
||||
|
||||
// Postgres implements Repository, Database and BehaviourSource over the gateway's store.
|
||||
type Postgres struct {
|
||||
Store *store.Store
|
||||
}
|
||||
|
||||
func (p Postgres) GetMarker(
|
||||
ctx context.Context, itemID, fingerprint string,
|
||||
) (Marker, bool, error) {
|
||||
row, found, err := p.Store.CreditsMarker(ctx, itemID, fingerprint)
|
||||
if err != nil || !found {
|
||||
return Marker{}, false, err
|
||||
}
|
||||
return markerFrom(row), true, nil
|
||||
}
|
||||
|
||||
func (p Postgres) SaveMarker(ctx context.Context, marker Marker) error {
|
||||
return p.Store.SaveCreditsMarker(ctx, store.CreditsMarkerRow{
|
||||
ItemID: marker.ItemID,
|
||||
MediaFingerprint: marker.MediaFingerprint,
|
||||
CreditsStartMs: marker.CreditsStartMs,
|
||||
Confidence: marker.Confidence,
|
||||
DetectionMethod: marker.DetectionMethod,
|
||||
SeriesID: marker.SeriesID,
|
||||
Season: marker.Season,
|
||||
})
|
||||
}
|
||||
|
||||
func (p Postgres) SeasonMarkers(
|
||||
ctx context.Context, seriesID string, season, limit int,
|
||||
) ([]Marker, error) {
|
||||
rows, err := p.Store.CreditsSeasonMarkers(ctx, seriesID, season, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]Marker, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, markerFrom(row))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (p Postgres) RecentWatches(
|
||||
ctx context.Context, since time.Time, limit int,
|
||||
) ([]Watch, error) {
|
||||
rows, err := p.Store.CreditsRecentWatches(ctx, since, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]Watch, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, Watch{
|
||||
UserKey: row.UserKey,
|
||||
SeriesID: row.SeriesID,
|
||||
Season: row.Season,
|
||||
Episode: row.Episode,
|
||||
WatchedAt: row.WatchedAt,
|
||||
Completed: row.Completed,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (p Postgres) SeriesEpisodes(
|
||||
ctx context.Context, seriesIDs []string,
|
||||
) ([]SeriesEpisode, error) {
|
||||
rows, err := p.Store.CreditsSeriesEpisodes(ctx, seriesIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]SeriesEpisode, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, SeriesEpisode{
|
||||
ItemID: row.ItemID,
|
||||
SeriesID: row.SeriesID,
|
||||
Season: row.Season,
|
||||
Episode: row.Episode,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (p Postgres) Stops(ctx context.Context, itemID string) ([]StopEvent, error) {
|
||||
rows, err := p.Store.CreditsStops(ctx, itemID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]StopEvent, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, StopEvent{
|
||||
UserKey: row.UserKey,
|
||||
PositionMs: row.PositionMs,
|
||||
RuntimeMs: row.RuntimeMs,
|
||||
NextEpisode: row.NextEpisode,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func markerFrom(row store.CreditsMarkerRow) Marker {
|
||||
return Marker{
|
||||
ItemID: row.ItemID,
|
||||
MediaFingerprint: row.MediaFingerprint,
|
||||
CreditsStartMs: row.CreditsStartMs,
|
||||
Confidence: row.Confidence,
|
||||
DetectionMethod: row.DetectionMethod,
|
||||
SeriesID: row.SeriesID,
|
||||
Season: row.Season,
|
||||
CreatedAt: row.CreatedAt,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package credits
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The queue, which lives entirely in RAM and is deliberately allowed to be lost.
|
||||
//
|
||||
// A durable job queue would need rows written for every state transition, and the whole
|
||||
// point of this subsystem is that the database sees one write per *scan*, not one per
|
||||
// candidate. Nothing here is expensive to rebuild: the priorities came from a single indexed
|
||||
// query against Tracearr sessions, and on restart that query simply runs again. A persistent
|
||||
// scheduler would cost more to maintain than it could ever save.
|
||||
|
||||
// Queue is a bounded priority queue of candidates, deduplicated by item.
|
||||
//
|
||||
// Bounded because saturation is a real state and the right response to it is to throw
|
||||
// speculation away: a queue holding forty episodes nobody has reached is not a queue that
|
||||
// will eventually catch up, it is one that has stopped describing demand.
|
||||
type Queue struct {
|
||||
limit int
|
||||
|
||||
mu sync.Mutex
|
||||
items map[string]Candidate
|
||||
claimed map[string]bool
|
||||
}
|
||||
|
||||
func NewQueue(limit int) *Queue {
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
return &Queue{
|
||||
limit: limit,
|
||||
items: map[string]Candidate{},
|
||||
claimed: map[string]bool{},
|
||||
}
|
||||
}
|
||||
|
||||
// Push offers a candidate. It is merged with anything already queued for the same item — the
|
||||
// strongest case wins — and dropped if the queue is full and it is not strong enough to
|
||||
// displace what is already there.
|
||||
//
|
||||
// Returns whether the candidate is now queued, which is what lets a caller log a discard
|
||||
// rather than believe it queued something.
|
||||
func (q *Queue) Push(candidate Candidate) bool {
|
||||
if candidate.ItemID == "" {
|
||||
return false
|
||||
}
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
|
||||
// Already being scanned. Single-flight lives in the service, but refusing to re-queue
|
||||
// what a worker is holding keeps the queue honest about what is outstanding.
|
||||
if q.claimed[candidate.ItemID] {
|
||||
return false
|
||||
}
|
||||
if existing, found := q.items[candidate.ItemID]; found {
|
||||
if candidate.Priority > existing.Priority {
|
||||
existing.Priority = candidate.Priority
|
||||
existing.Reason = candidate.Reason
|
||||
}
|
||||
if candidate.UserCount > existing.UserCount {
|
||||
existing.UserCount = candidate.UserCount
|
||||
}
|
||||
if candidate.LastViewed.After(existing.LastViewed) {
|
||||
existing.LastViewed = candidate.LastViewed
|
||||
}
|
||||
q.items[candidate.ItemID] = existing
|
||||
return true
|
||||
}
|
||||
|
||||
if len(q.items) >= q.limit {
|
||||
weakestID, weakest := q.weakestLocked()
|
||||
// Ties go to what is already queued. A candidate that has been waiting is one the
|
||||
// worker is closer to reaching, and swapping equals would let a busy refresh cycle
|
||||
// churn the queue without ever finishing anything.
|
||||
if weakestID == "" || candidate.Priority <= weakest.Priority {
|
||||
return false
|
||||
}
|
||||
delete(q.items, weakestID)
|
||||
}
|
||||
q.items[candidate.ItemID] = candidate
|
||||
return true
|
||||
}
|
||||
|
||||
// Replace swaps the speculative contents of the queue for a freshly built set, which is what
|
||||
// a refresh cycle does. Anything a worker has claimed is untouched — cancelling a scan that
|
||||
// is already reading a file to replace it with a marginally better candidate would waste
|
||||
// exactly the disk activity this package exists to avoid.
|
||||
func (q *Queue) Replace(candidates []Candidate) {
|
||||
q.mu.Lock()
|
||||
claimed := q.claimed
|
||||
q.items = map[string]Candidate{}
|
||||
q.mu.Unlock()
|
||||
|
||||
for _, candidate := range candidates {
|
||||
if claimed[candidate.ItemID] {
|
||||
continue
|
||||
}
|
||||
q.Push(candidate)
|
||||
}
|
||||
}
|
||||
|
||||
// Claim takes the highest-priority candidate and marks it in flight. Release must follow.
|
||||
func (q *Queue) Claim() (Candidate, bool) {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
|
||||
best, found := Candidate{}, false
|
||||
for _, candidate := range q.items {
|
||||
if !found || betterCandidate(candidate, best) {
|
||||
best, found = candidate, true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return Candidate{}, false
|
||||
}
|
||||
delete(q.items, best.ItemID)
|
||||
q.claimed[best.ItemID] = true
|
||||
return best, true
|
||||
}
|
||||
|
||||
// Release ends a claim. Called from a defer so a panicking detector cannot wedge an item out
|
||||
// of the queue for the life of the process.
|
||||
func (q *Queue) Release(itemID string) {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
delete(q.claimed, itemID)
|
||||
}
|
||||
|
||||
// Len is the number waiting, not counting anything in flight.
|
||||
func (q *Queue) Len() int {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
return len(q.items)
|
||||
}
|
||||
|
||||
// Snapshot is the queue in worker order, for the log line and the admin console.
|
||||
func (q *Queue) Snapshot() []Candidate {
|
||||
q.mu.Lock()
|
||||
out := make([]Candidate, 0, len(q.items))
|
||||
for _, candidate := range q.items {
|
||||
out = append(out, candidate)
|
||||
}
|
||||
q.mu.Unlock()
|
||||
sortCandidates(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// weakestLocked finds the candidate to discard under saturation. Caller holds the lock.
|
||||
func (q *Queue) weakestLocked() (string, Candidate) {
|
||||
weakestID, weakest, found := "", Candidate{}, false
|
||||
for id, candidate := range q.items {
|
||||
if !found || betterCandidate(weakest, candidate) {
|
||||
weakestID, weakest, found = id, candidate, true
|
||||
}
|
||||
}
|
||||
return weakestID, weakest
|
||||
}
|
||||
|
||||
// betterCandidate is the one ordering rule, shared by Claim and the saturation discard so
|
||||
// the thing taken first and the thing thrown away first can never disagree.
|
||||
func betterCandidate(a, b Candidate) bool {
|
||||
if a.Priority != b.Priority {
|
||||
return a.Priority > b.Priority
|
||||
}
|
||||
if !a.LastViewed.Equal(b.LastViewed) {
|
||||
return a.LastViewed.After(b.LastViewed)
|
||||
}
|
||||
return a.ItemID < b.ItemID
|
||||
}
|
||||
|
||||
// pending is a live-playback candidate waiting out its settling delay.
|
||||
type pending struct {
|
||||
candidate Candidate
|
||||
due time.Time
|
||||
cancel func()
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package credits
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The queue is bounded, and saturation is a real state with a right answer: throw the
|
||||
// speculation away. A queue holding forty episodes nobody has reached has stopped describing
|
||||
// demand.
|
||||
|
||||
func queued(id string, priority int) Candidate {
|
||||
return Candidate{ItemID: id, Priority: priority, LastViewed: now}
|
||||
}
|
||||
|
||||
func TestQueueOrdersByPriority(t *testing.T) {
|
||||
queue := NewQueue(10)
|
||||
queue.Push(queued("ahead3", PriorityAhead3))
|
||||
queue.Push(queued("live", PriorityLive))
|
||||
queue.Push(queued("next", PriorityNext))
|
||||
|
||||
candidate, found := queue.Claim()
|
||||
if !found || candidate.ItemID != "live" {
|
||||
t.Fatalf("claimed %+v, want the live candidate first", candidate)
|
||||
}
|
||||
candidate, _ = queue.Claim()
|
||||
if candidate.ItemID != "next" {
|
||||
t.Fatalf("claimed %q second, want next", candidate.ItemID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaturationDiscardsTheWeakestSpeculation(t *testing.T) {
|
||||
queue := NewQueue(3)
|
||||
queue.Push(queued("spec-a", PrioritySpeculative))
|
||||
queue.Push(queued("spec-b", PrioritySpeculative+1))
|
||||
queue.Push(queued("spec-c", PrioritySpeculative+2))
|
||||
|
||||
if queued := queue.Push(queued("live", PriorityLive)); !queued {
|
||||
t.Fatal("a full queue refused live playback")
|
||||
}
|
||||
if queue.Len() != 3 {
|
||||
t.Fatalf("queue length %d, want the limit of 3", queue.Len())
|
||||
}
|
||||
for _, candidate := range queue.Snapshot() {
|
||||
if candidate.ItemID == "spec-a" {
|
||||
t.Fatal("the weakest candidate survived saturation")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaturationRefusesWeakerWork(t *testing.T) {
|
||||
queue := NewQueue(2)
|
||||
queue.Push(queued("next-a", PriorityNext))
|
||||
queue.Push(queued("next-b", PriorityNext))
|
||||
|
||||
if queue.Push(queued("spec", PrioritySpeculative)) {
|
||||
t.Fatal("a full queue accepted work weaker than everything in it")
|
||||
}
|
||||
if queue.Len() != 2 {
|
||||
t.Fatalf("queue length %d, want 2", queue.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushMergesRatherThanDuplicating(t *testing.T) {
|
||||
queue := NewQueue(10)
|
||||
queue.Push(queued("bb-s06e08", PriorityAhead2))
|
||||
queue.Push(Candidate{
|
||||
ItemID: "bb-s06e08", Priority: PriorityNext, Reason: ReasonMultiUser,
|
||||
UserCount: 2, LastViewed: now,
|
||||
})
|
||||
|
||||
if queue.Len() != 1 {
|
||||
t.Fatalf("queue length %d; the same episode must not be queued twice", queue.Len())
|
||||
}
|
||||
candidate, _ := queue.Claim()
|
||||
if candidate.Priority != PriorityNext {
|
||||
t.Fatalf("priority = %d; the stronger case should have won", candidate.Priority)
|
||||
}
|
||||
if candidate.UserCount != 2 {
|
||||
t.Fatalf("UserCount = %d, want 2", candidate.UserCount)
|
||||
}
|
||||
}
|
||||
|
||||
// A weaker second viewer must never lower a candidate somebody else is about to reach.
|
||||
func TestPushNeverLowersPriority(t *testing.T) {
|
||||
queue := NewQueue(10)
|
||||
queue.Push(queued("bb-s06e08", PriorityNext))
|
||||
queue.Push(queued("bb-s06e08", PrioritySpeculative))
|
||||
|
||||
candidate, _ := queue.Claim()
|
||||
if candidate.Priority != PriorityNext {
|
||||
t.Fatalf("priority = %d, want %d", candidate.Priority, PriorityNext)
|
||||
}
|
||||
}
|
||||
|
||||
// Cancelling a scan that is already reading a file, to replace it with a marginally better
|
||||
// candidate, would waste exactly the disk activity this package exists to avoid.
|
||||
func TestReplaceLeavesClaimedWorkAlone(t *testing.T) {
|
||||
queue := NewQueue(10)
|
||||
queue.Push(queued("in-flight", PriorityNext))
|
||||
claimed, _ := queue.Claim()
|
||||
|
||||
queue.Replace([]Candidate{queued("fresh", PriorityNext), queued(claimed.ItemID, PriorityLive)})
|
||||
|
||||
for _, candidate := range queue.Snapshot() {
|
||||
if candidate.ItemID == claimed.ItemID {
|
||||
t.Fatal("a refresh re-queued an episode already being scanned")
|
||||
}
|
||||
}
|
||||
queue.Release(claimed.ItemID)
|
||||
if queue.Push(queued(claimed.ItemID, PriorityNext)); queue.Len() != 2 {
|
||||
t.Fatalf("queue length %d after release, want 2", queue.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimedItemsAreNotReQueued(t *testing.T) {
|
||||
queue := NewQueue(10)
|
||||
queue.Push(queued("bb-s06e08", PriorityNext))
|
||||
claimed, _ := queue.Claim()
|
||||
|
||||
if queue.Push(queued(claimed.ItemID, PriorityLive)) {
|
||||
t.Fatal("an episode being scanned was queued a second time")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyQueueClaimsNothing(t *testing.T) {
|
||||
if _, found := NewQueue(5).Claim(); found {
|
||||
t.Fatal("an empty queue produced work")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueueOrderIsTotalAndStable(t *testing.T) {
|
||||
queue := NewQueue(10)
|
||||
older := now.Add(-2 * time.Hour)
|
||||
queue.Push(Candidate{ItemID: "b", Priority: PriorityNext, LastViewed: older})
|
||||
queue.Push(Candidate{ItemID: "a", Priority: PriorityNext, LastViewed: now})
|
||||
|
||||
// Equal priority: the more recent demand goes first.
|
||||
candidate, _ := queue.Claim()
|
||||
if candidate.ItemID != "a" {
|
||||
t.Fatalf("claimed %q; more recent demand should win a tie", candidate.ItemID)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package credits
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/emby"
|
||||
)
|
||||
|
||||
// Turning an item id into something scannable.
|
||||
//
|
||||
// One Emby request per candidate, cached, and it answers four questions at once: how long the
|
||||
// file is, what version of it this is, whether Emby has already found the credits itself, and
|
||||
// where the bytes are. Asking them separately would be four round trips on the one path that
|
||||
// runs before every scan — including the scans that are then skipped because the marker
|
||||
// already exists.
|
||||
|
||||
// resolverTTL is how long a resolution is remembered. Short enough that a file replaced this
|
||||
// afternoon is noticed today, long enough that the refresh cycle checking twenty candidates
|
||||
// every ten minutes does not become twenty Emby requests every ten minutes.
|
||||
const resolverTTL = 30 * time.Minute
|
||||
|
||||
// EmbyResolver implements MediaResolver.
|
||||
//
|
||||
// It uses the gateway's own sync credentials rather than a viewer's, because a scan is
|
||||
// something the server does on its own behalf: it must work when nobody is signed in, and a
|
||||
// request made as a viewer would put a playback-adjacent lookup in that person's Emby history.
|
||||
type EmbyResolver struct {
|
||||
Client *emby.Client
|
||||
Credentials emby.Credentials
|
||||
|
||||
mu sync.Mutex
|
||||
cached map[string]cachedResolution
|
||||
}
|
||||
|
||||
type cachedResolution struct {
|
||||
media ResolvedMedia
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
func NewEmbyResolver(client *emby.Client, cred emby.Credentials) *EmbyResolver {
|
||||
return &EmbyResolver{
|
||||
Client: client,
|
||||
Credentials: cred,
|
||||
cached: map[string]cachedResolution{},
|
||||
}
|
||||
}
|
||||
|
||||
// embyResolveFields is everything one request has to bring back.
|
||||
//
|
||||
// Chapters is here for the same reason it is on the live path: if Emby has already detected
|
||||
// the credits, this subsystem must do nothing at all, and finding that out after opening a
|
||||
// decoder would be finding it out too late. MediaSources carries the size and version token
|
||||
// the fingerprint is built from — without them a marker could survive the file it describes.
|
||||
const embyResolveFields = "MediaSources,Chapters,ParentIndexNumber,IndexNumber,SeriesId"
|
||||
|
||||
func (r *EmbyResolver) Resolve(ctx context.Context, itemID string) (ResolvedMedia, error) {
|
||||
if r == nil || r.Client == nil || itemID == "" {
|
||||
return ResolvedMedia{}, nil
|
||||
}
|
||||
r.mu.Lock()
|
||||
entry, found := r.cached[itemID]
|
||||
r.mu.Unlock()
|
||||
if found && time.Now().Before(entry.expires) {
|
||||
return entry.media, nil
|
||||
}
|
||||
|
||||
raw, err := r.Client.Item(ctx, r.Credentials, itemID, embyResolveFields)
|
||||
if err != nil {
|
||||
return ResolvedMedia{}, err
|
||||
}
|
||||
var parsed struct {
|
||||
ID string `json:"Id"`
|
||||
Etag string `json:"Etag"`
|
||||
SeriesID string `json:"SeriesId"`
|
||||
ParentIndexNumber int `json:"ParentIndexNumber"`
|
||||
IndexNumber int `json:"IndexNumber"`
|
||||
RunTimeTicks int64 `json:"RunTimeTicks"`
|
||||
DateModified string `json:"DateModified"`
|
||||
Chapters []struct {
|
||||
StartPositionTicks int64 `json:"StartPositionTicks"`
|
||||
MarkerType string `json:"MarkerType"`
|
||||
Name string `json:"Name"`
|
||||
} `json:"Chapters"`
|
||||
MediaSources []struct {
|
||||
ID string `json:"Id"`
|
||||
Size int64 `json:"Size"`
|
||||
ETag string `json:"ETag"`
|
||||
} `json:"MediaSources"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||
return ResolvedMedia{}, err
|
||||
}
|
||||
|
||||
media := ResolvedMedia{
|
||||
URL: r.Client.InternalStreamURL(r.Credentials, itemID),
|
||||
SeriesID: parsed.SeriesID,
|
||||
Season: parsed.ParentIndexNumber,
|
||||
Episode: parsed.IndexNumber,
|
||||
RuntimeMs: parsed.RunTimeTicks / 10_000,
|
||||
Version: MediaVersion{
|
||||
ItemID: itemID,
|
||||
RuntimeMs: parsed.RunTimeTicks / 10_000,
|
||||
ETag: strings.TrimSpace(parsed.Etag),
|
||||
},
|
||||
}
|
||||
if len(parsed.MediaSources) > 0 {
|
||||
source := parsed.MediaSources[0]
|
||||
media.Version.SizeBytes = source.Size
|
||||
if media.Version.ETag == "" {
|
||||
media.Version.ETag = strings.TrimSpace(source.ETag)
|
||||
}
|
||||
}
|
||||
if parsed.DateModified != "" {
|
||||
if modified, err := time.Parse(time.RFC3339, parsed.DateModified); err == nil {
|
||||
media.Version.ModifiedAt = modified
|
||||
}
|
||||
}
|
||||
// A CreditsStart marker Emby wrote itself. Rare on 4.10 — a survey of this household's
|
||||
// twenty-thousand-item library found none — but where it exists it is authoritative and
|
||||
// free, and nothing here should spend a decoder on a question already answered.
|
||||
for _, chapter := range parsed.Chapters {
|
||||
if strings.EqualFold(chapter.MarkerType, "CreditsStart") && chapter.StartPositionTicks > 0 {
|
||||
media.EmbyCreditsMs = chapter.StartPositionTicks / 10_000
|
||||
}
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
// Bounded rather than unbounded: the working set is the queue plus whatever is playing,
|
||||
// so a cache that grew with the library would be holding resolutions for episodes nobody
|
||||
// has looked at since the container started.
|
||||
if len(r.cached) > 256 {
|
||||
r.cached = map[string]cachedResolution{}
|
||||
}
|
||||
r.cached[itemID] = cachedResolution{media: media, expires: time.Now().Add(resolverTTL)}
|
||||
r.mu.Unlock()
|
||||
|
||||
return media, nil
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
package credits
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FFmpeg, used surgically and never as a transcoder.
|
||||
//
|
||||
// The rules this file exists to keep are the ones that make the difference between reading a
|
||||
// couple of megabytes and reading a whole film: seek before opening the input so the decoder
|
||||
// starts at the credits rather than reading its way there, bound the read with -t, throw away
|
||||
// audio and subtitles, downscale to a thumbnail, drop to a frame every few seconds, and take
|
||||
// the result as raw grayscale on stdout. Nothing is ever written to disk — no JPEGs, no
|
||||
// temporary transcode, no scratch file. The frames exist only as bytes in a buffer that is
|
||||
// reused between passes.
|
||||
//
|
||||
// The gateway has no filesystem access to the media (docker-compose mounts no media share),
|
||||
// so the input is Emby's own stream route over HTTP. That is not a compromise: -ss before -i
|
||||
// makes ffmpeg issue a ranged request, so the bytes that cross the network are the bytes of
|
||||
// the window and not of the file.
|
||||
|
||||
const (
|
||||
// The sampling grid. Small enough that a frame is fourteen kilobytes and the statistics
|
||||
// are computed in a few microseconds, large enough that a credits roll still reads as
|
||||
// structured rather than as noise.
|
||||
sampleWidth = 160
|
||||
sampleHeight = 90
|
||||
frameBytes = sampleWidth * sampleHeight
|
||||
|
||||
// coarseInterval is the first pass: one frame every four seconds, which is enough to
|
||||
// find a transition to within a few seconds while sampling a two-minute window in about
|
||||
// thirty frames.
|
||||
coarseInterval = 4 * time.Second
|
||||
// fineInterval is the second pass, run only over the span the first pass pointed at.
|
||||
fineInterval = 750 * time.Millisecond
|
||||
// fineSpan is how much of the file either side of the coarse estimate the fine pass
|
||||
// covers.
|
||||
fineSpan = 30 * time.Second
|
||||
|
||||
// maxFrames is a hard ceiling on one pass. It bounds memory (frames are held only one at
|
||||
// a time, but the statistics slice is not) and, more importantly, bounds the damage a
|
||||
// mis-computed window can do: without it a bad runtime could turn a tail scan into a
|
||||
// full decode.
|
||||
maxFrames = 400
|
||||
)
|
||||
|
||||
// ErrNoFFmpeg means the binary is absent. Reported distinctly so the service can stand the
|
||||
// visual detector down and run on behaviour alone rather than logging a decoder failure per
|
||||
// candidate for the life of the container.
|
||||
var ErrNoFFmpeg = errors.New("credits: ffmpeg is not available")
|
||||
|
||||
// Sampler decodes a span of a file into frame statistics.
|
||||
type Sampler struct {
|
||||
// Binary is the ffmpeg executable. Configurable because a NAS may carry it somewhere
|
||||
// other than the path.
|
||||
Binary string
|
||||
// Timeout bounds one pass. A decoder that hangs on a malformed file must not hold the
|
||||
// single worker for ever.
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// frameStats is one sampled frame reduced to the handful of cheap properties a credits
|
||||
// transition shows up in. Deliberately not the frame: nothing downstream needs the picture,
|
||||
// and keeping four hundred thumbnails would be most of the package's memory budget.
|
||||
type frameStats struct {
|
||||
PositionMs int64
|
||||
// Mean luminance, 0-1. Credits are dark.
|
||||
Mean float64
|
||||
// Variance of luminance, 0-1 scaled. A credits roll is mostly flat background with thin
|
||||
// text, so its variance is low and, more usefully, *stable*.
|
||||
Variance float64
|
||||
// DarkFraction is the proportion of pixels below the dark threshold.
|
||||
DarkFraction float64
|
||||
// EdgeDensity approximates how much fine detail there is, which is what separates a
|
||||
// credits roll from a dark night scene: text has edges, darkness does not.
|
||||
EdgeDensity float64
|
||||
// Diff is the mean absolute difference from the previous sampled frame. Scrolling text
|
||||
// changes steadily; a held black frame does not.
|
||||
Diff float64
|
||||
}
|
||||
|
||||
// Available reports whether the decoder can be used at all.
|
||||
func (s *Sampler) Available() bool {
|
||||
_, err := exec.LookPath(s.binary())
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (s *Sampler) binary() string {
|
||||
if strings.TrimSpace(s.Binary) != "" {
|
||||
return s.Binary
|
||||
}
|
||||
return "ffmpeg"
|
||||
}
|
||||
|
||||
// Sample decodes one span and returns its frame statistics.
|
||||
//
|
||||
// The byte count it reports is an estimate — ffmpeg does not report how much of its input it
|
||||
// read, and adding a proxy to find out would cost more than the number is worth. It is
|
||||
// derived from the span and the file's bitrate, which is accurate enough for the question it
|
||||
// answers: whether this subsystem is reading a couple of minutes or the whole file.
|
||||
func (s *Sampler) Sample(
|
||||
ctx context.Context, url string, from, to time.Duration, interval time.Duration,
|
||||
) ([]frameStats, error) {
|
||||
if to <= from || url == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if interval <= 0 {
|
||||
interval = coarseInterval
|
||||
}
|
||||
timeout := s.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = 60 * time.Second
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
// -ss ahead of -i is the whole optimisation: it seeks in the container before opening a
|
||||
// decoder, so the input starts near the credits. Behind -i it would decode from zero and
|
||||
// discard, which is the full read this package exists to avoid.
|
||||
args := []string{
|
||||
"-hide_banner", "-loglevel", "error", "-nostdin",
|
||||
"-ss", formatSeconds(from),
|
||||
"-i", url,
|
||||
"-t", formatSeconds(to - from),
|
||||
"-an", "-sn", "-dn",
|
||||
"-vf", fmt.Sprintf("fps=%s,scale=%d:%d,format=gray",
|
||||
formatRate(interval), sampleWidth, sampleHeight),
|
||||
"-frames:v", strconv.Itoa(maxFrames),
|
||||
"-f", "rawvideo", "-pix_fmt", "gray",
|
||||
"pipe:1",
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, s.binary(), args...)
|
||||
// Cancel and WaitDelay together are what stop an orphan. CommandContext's default is to
|
||||
// send Kill and then wait for the pipes to close, which a stuck HTTP read can hold open
|
||||
// indefinitely; WaitDelay puts a bound on that and closes the descriptors itself.
|
||||
cmd.Cancel = func() error { return cmd.Process.Kill() }
|
||||
cmd.WaitDelay = 5 * time.Second
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
if errors.Is(err, exec.ErrNotFound) {
|
||||
return nil, ErrNoFFmpeg
|
||||
}
|
||||
return nil, fmt.Errorf("credits: start ffmpeg: %w", err)
|
||||
}
|
||||
|
||||
stats, readErr := readFrames(stdout, from, interval)
|
||||
// Drain whatever is left so ffmpeg is never blocked writing into a pipe nobody is
|
||||
// reading, which is how a "finished" scan comes to sit in Wait for its full timeout.
|
||||
_, _ = io.Copy(io.Discard, stdout)
|
||||
waitErr := cmd.Wait()
|
||||
|
||||
if readErr != nil {
|
||||
return nil, readErr
|
||||
}
|
||||
if waitErr != nil && len(stats) == 0 {
|
||||
// A pass that produced frames and then failed is a truncated read, not a failure:
|
||||
// the statistics that arrived are still usable. One that produced nothing is a
|
||||
// genuine problem worth reporting with whatever ffmpeg said about it.
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
return nil, fmt.Errorf("credits: ffmpeg: %w: %s",
|
||||
waitErr, strings.TrimSpace(truncate(stderr.String(), 300)))
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// readFrames pulls fixed-size grayscale frames off the pipe and reduces each one as it
|
||||
// arrives. The frame buffer is allocated once and reused, so a four-hundred-frame pass
|
||||
// allocates fourteen kilobytes rather than five and a half megabytes.
|
||||
func readFrames(reader io.Reader, from, interval time.Duration) ([]frameStats, error) {
|
||||
frame := make([]byte, frameBytes)
|
||||
stats := make([]frameStats, 0, 64)
|
||||
var previous []byte
|
||||
previousBuffer := make([]byte, frameBytes)
|
||||
|
||||
for index := 0; index < maxFrames; index++ {
|
||||
if _, err := io.ReadFull(reader, frame); err != nil {
|
||||
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
|
||||
break
|
||||
}
|
||||
return stats, err
|
||||
}
|
||||
position := from + time.Duration(index)*interval
|
||||
stats = append(stats, analyseFrame(frame, previous, position))
|
||||
copy(previousBuffer, frame)
|
||||
previous = previousBuffer
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// analyseFrame reduces one thumbnail to its statistics in a single pass over the pixels,
|
||||
// which at 14,400 bytes is a few microseconds. Nothing here allocates.
|
||||
func analyseFrame(frame, previous []byte, position time.Duration) frameStats {
|
||||
const darkThreshold = 48 // out of 255
|
||||
|
||||
var sum, sumSquares, dark, diff float64
|
||||
for index, pixel := range frame {
|
||||
value := float64(pixel)
|
||||
sum += value
|
||||
sumSquares += value * value
|
||||
if pixel < darkThreshold {
|
||||
dark++
|
||||
}
|
||||
if previous != nil {
|
||||
delta := value - float64(previous[index])
|
||||
if delta < 0 {
|
||||
delta = -delta
|
||||
}
|
||||
diff += delta
|
||||
}
|
||||
}
|
||||
count := float64(len(frame))
|
||||
mean := sum / count
|
||||
variance := sumSquares/count - mean*mean
|
||||
if variance < 0 {
|
||||
variance = 0
|
||||
}
|
||||
|
||||
// Edge density as a horizontal gradient: credits are text, and text on a flat background
|
||||
// is almost entirely horizontal transitions. A proper Sobel would cost a second pass and
|
||||
// a second buffer for a distinction nothing downstream makes.
|
||||
var edges float64
|
||||
for row := 0; row < sampleHeight; row++ {
|
||||
base := row * sampleWidth
|
||||
for column := 1; column < sampleWidth; column++ {
|
||||
delta := float64(frame[base+column]) - float64(frame[base+column-1])
|
||||
if delta < 0 {
|
||||
delta = -delta
|
||||
}
|
||||
if delta > 24 {
|
||||
edges++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return frameStats{
|
||||
PositionMs: position.Milliseconds(),
|
||||
Mean: mean / 255,
|
||||
Variance: variance / (255 * 255),
|
||||
DarkFraction: dark / count,
|
||||
EdgeDensity: edges / count,
|
||||
Diff: diff / count / 255,
|
||||
}
|
||||
}
|
||||
|
||||
// formatSeconds writes a duration the way ffmpeg's -ss wants it, with millisecond precision
|
||||
// and no unit suffix.
|
||||
func formatSeconds(value time.Duration) string {
|
||||
return strconv.FormatFloat(value.Seconds(), 'f', 3, 64)
|
||||
}
|
||||
|
||||
// formatRate turns a sampling interval into an fps filter argument. Expressed as a fraction
|
||||
// rather than a decimal because one frame every four seconds is 1/4 exactly and 0.25 is not,
|
||||
// on a filter that accumulates rounding across a long window.
|
||||
func formatRate(interval time.Duration) string {
|
||||
return fmt.Sprintf("1000/%d", interval.Milliseconds())
|
||||
}
|
||||
|
||||
func truncate(value string, limit int) string {
|
||||
if len(value) <= limit {
|
||||
return value
|
||||
}
|
||||
return value[:limit] + "…"
|
||||
}
|
||||
@@ -0,0 +1,525 @@
|
||||
package credits
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The service: one worker, one queue, and the rules about when it is allowed to run.
|
||||
//
|
||||
// Everything expensive in this package funnels through Process, and the shape of that
|
||||
// function is the performance claim in miniature — resolve, check the marker, and in the
|
||||
// common case stop there having touched no media at all. Only a candidate that survives the
|
||||
// cache check reaches a decoder.
|
||||
|
||||
const (
|
||||
// livePlaybackDelay is how long a playback has to survive before it is worth scanning
|
||||
// for. Somebody browsing the launcher starts and abandons episodes constantly, and
|
||||
// scanning on the Play press would turn every one of those into disk activity. Waiting
|
||||
// costs nothing: the credits are forty minutes away.
|
||||
livePlaybackDelay = 45 * time.Second
|
||||
|
||||
// idlePoll is how often the worker looks for work when the queue is empty. Coarse on
|
||||
// purpose — nothing here is urgent, and a tight loop on an idle NAS is exactly the sort
|
||||
// of background cost this package is supposed not to have.
|
||||
idlePoll = 30 * time.Second
|
||||
|
||||
// busyBackoff is how long the worker stands down when the server is under load.
|
||||
busyBackoff = 2 * time.Minute
|
||||
|
||||
// scanBudget bounds one candidate end to end. Past it the answer is not worth the
|
||||
// resources, and a scan that overruns is far more likely to be a pathological file than
|
||||
// a slow one.
|
||||
scanBudget = 90 * time.Second
|
||||
)
|
||||
|
||||
// ResolvedMedia is everything the service needs to turn a candidate into a scan.
|
||||
type ResolvedMedia struct {
|
||||
Version MediaVersion
|
||||
// URL is where the bytes are — Emby's direct stream route, since the gateway has no
|
||||
// filesystem access to the media.
|
||||
URL string
|
||||
SeriesID string
|
||||
Season int
|
||||
Episode int
|
||||
RuntimeMs int64
|
||||
// EmbyCreditsMs is a chapter marker Emby found itself, if any. When Emby already knows,
|
||||
// this subsystem must do nothing at all.
|
||||
EmbyCreditsMs int64
|
||||
}
|
||||
|
||||
// MediaResolver turns an item id into something scannable. Backed by Emby; kept an interface
|
||||
// so the scheduler, the queue and every rule above them can be tested with no server.
|
||||
type MediaResolver interface {
|
||||
Resolve(ctx context.Context, itemID string) (ResolvedMedia, error)
|
||||
}
|
||||
|
||||
// BehaviourSource reads the stops a household already made. Backed by the tracearr_sessions
|
||||
// table, which is written by the For You import and by nothing here.
|
||||
type BehaviourSource interface {
|
||||
Stops(ctx context.Context, itemID string) ([]StopEvent, error)
|
||||
}
|
||||
|
||||
// LoadGauge answers whether the server is too busy for speculative work. Deliberately a
|
||||
// single boolean: building a telemetry subsystem to answer it would cost more than the
|
||||
// scanning it is meant to defer.
|
||||
type LoadGauge interface {
|
||||
Busy(ctx context.Context) bool
|
||||
}
|
||||
|
||||
// Deps is the service's wiring. Only Repository and MediaResolver are required; a service
|
||||
// with no detector runs on behavioural evidence alone, which is what happens when ffmpeg is
|
||||
// missing from the image.
|
||||
type Deps struct {
|
||||
Repository Repository
|
||||
Resolver MediaResolver
|
||||
Source CandidateSource
|
||||
Detector Detector
|
||||
Behaviour BehaviourSource
|
||||
Load LoadGauge
|
||||
Log *slog.Logger
|
||||
Config Config
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
repo Repository
|
||||
resolver MediaResolver
|
||||
source CandidateSource
|
||||
detector Detector
|
||||
behaviour BehaviourSource
|
||||
load LoadGauge
|
||||
log *slog.Logger
|
||||
cfg Config
|
||||
|
||||
queue *Queue
|
||||
flight *flightGroup
|
||||
|
||||
mu sync.Mutex
|
||||
pending map[string]*pending
|
||||
|
||||
// liveDelay is a field rather than the constant so a test does not have to wait
|
||||
// three quarters of a minute to prove that abandonment cancels a scan.
|
||||
liveDelay time.Duration
|
||||
}
|
||||
|
||||
func New(deps Deps) *Service {
|
||||
cfg := deps.Config
|
||||
if cfg.QueueLimit <= 0 {
|
||||
cfg = DefaultConfig()
|
||||
}
|
||||
detector := deps.Detector
|
||||
if detector == nil {
|
||||
detector = noopDetector{}
|
||||
}
|
||||
log := deps.Log
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
return &Service{
|
||||
repo: deps.Repository,
|
||||
resolver: deps.Resolver,
|
||||
source: deps.Source,
|
||||
detector: detector,
|
||||
behaviour: deps.Behaviour,
|
||||
load: deps.Load,
|
||||
log: log.With("component", "credits"),
|
||||
cfg: cfg,
|
||||
queue: NewQueue(cfg.QueueLimit),
|
||||
flight: newFlightGroup(),
|
||||
pending: map[string]*pending{},
|
||||
liveDelay: livePlaybackDelay,
|
||||
}
|
||||
}
|
||||
|
||||
// Marker is the read path, and the one the API calls. It is a single indexed lookup and it
|
||||
// is what makes the steady state free: once a household's viewing has settled, almost every
|
||||
// call to this subsystem is this function returning a row.
|
||||
func (s *Service) Marker(ctx context.Context, itemID string) (Marker, bool, error) {
|
||||
if s == nil || s.repo == nil || itemID == "" {
|
||||
return Marker{}, false, nil
|
||||
}
|
||||
resolved, err := s.resolver.Resolve(ctx, itemID)
|
||||
if err != nil {
|
||||
return Marker{}, false, err
|
||||
}
|
||||
return s.repo.GetMarker(ctx, itemID, Fingerprint(resolved.Version))
|
||||
}
|
||||
|
||||
// Refresh rebuilds the speculative queue from demand. Registered as a scheduled task, so its
|
||||
// interval is the operator's to change and its last run is visible in the console.
|
||||
//
|
||||
// The returned detail is the scheduler's one-line summary and is empty when nothing changed,
|
||||
// which is what stops a task running every ten minutes announcing itself every ten minutes.
|
||||
func (s *Service) Refresh(ctx context.Context) (string, error) {
|
||||
if s.source == nil {
|
||||
return "", nil
|
||||
}
|
||||
candidates, err := s.source.Candidates(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// The cache check happens here as well as in the worker, and that is the point: an
|
||||
// episode whose marker already exists must never occupy a queue slot, or a household
|
||||
// that has been watching one show for a week ends up with a permanently full queue of
|
||||
// work that will all turn out to be unnecessary.
|
||||
wanted := make([]Candidate, 0, len(candidates))
|
||||
skipped := 0
|
||||
for _, candidate := range candidates {
|
||||
known, err := s.known(ctx, candidate.ItemID)
|
||||
if err != nil {
|
||||
// Trouble reading a marker is not a reason to drop the candidate; the worker
|
||||
// will check again and is the one that can afford to be wrong.
|
||||
s.log.Debug("marker lookup failed", "item_id", candidate.ItemID, "error", err)
|
||||
}
|
||||
if known {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
wanted = append(wanted, candidate)
|
||||
}
|
||||
s.queue.Replace(wanted)
|
||||
|
||||
if len(wanted) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
for _, candidate := range wanted {
|
||||
s.log.Debug("credits_candidate",
|
||||
"item", candidate.ItemID, "reason", candidate.Reason,
|
||||
"priority", candidate.Priority, "users", candidate.UserCount)
|
||||
}
|
||||
return fmt.Sprintf("%d candidate%s queued, %d already known",
|
||||
len(wanted), plural(len(wanted)), skipped), nil
|
||||
}
|
||||
|
||||
// NotePlayback is the live signal, and the strongest one there is: somebody is watching this
|
||||
// episode now, and if there is no marker they will reach the credits without a button.
|
||||
//
|
||||
// It does not scan immediately. A Play press is not yet a viewing — the launcher makes it
|
||||
// trivially easy to start something and change your mind — so the candidate is held for
|
||||
// livePlaybackDelay and raised only if the playback is still going. That is the difference
|
||||
// between a subsystem that reads a file per curious button press and one that reads a file
|
||||
// per episode actually watched.
|
||||
func (s *Service) NotePlayback(ctx context.Context, itemID string) {
|
||||
if s == nil || itemID == "" {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
if _, waiting := s.pending[itemID]; waiting {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
// Detached from the request context deliberately: the HTTP request that reported the
|
||||
// playback is over in milliseconds, and hanging the timer off it would cancel every
|
||||
// delayed scan the instant it returned.
|
||||
timerCtx, cancel := context.WithCancel(context.WithoutCancel(ctx))
|
||||
s.pending[itemID] = &pending{
|
||||
candidate: Candidate{
|
||||
ItemID: itemID,
|
||||
Priority: PriorityLive,
|
||||
Reason: ReasonLivePlayback,
|
||||
UserCount: 1,
|
||||
LastViewed: time.Now().UTC(),
|
||||
},
|
||||
due: time.Now().Add(s.liveDelay),
|
||||
cancel: cancel,
|
||||
}
|
||||
delay := s.liveDelay
|
||||
s.mu.Unlock()
|
||||
|
||||
go func() {
|
||||
defer cancel()
|
||||
select {
|
||||
case <-timerCtx.Done():
|
||||
return
|
||||
case <-time.After(delay):
|
||||
}
|
||||
s.mu.Lock()
|
||||
entry, waiting := s.pending[itemID]
|
||||
delete(s.pending, itemID)
|
||||
s.mu.Unlock()
|
||||
if !waiting {
|
||||
return
|
||||
}
|
||||
// One last cache check before queueing. In the settled case the marker was already
|
||||
// there and this costs one indexed read instead of a queue slot.
|
||||
if known, err := s.known(timerCtx, itemID); err == nil && known {
|
||||
return
|
||||
}
|
||||
if s.queue.Push(entry.candidate) {
|
||||
s.log.Debug("credits_candidate",
|
||||
"item", itemID, "reason", ReasonLivePlayback, "priority", PriorityLive)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// AbandonPlayback withdraws a playback that stopped before its delay elapsed. This is what
|
||||
// makes the delay worth having: without it the timer would fire regardless and the episode
|
||||
// somebody looked at for ten seconds would be scanned anyway.
|
||||
func (s *Service) AbandonPlayback(itemID string) {
|
||||
if s == nil || itemID == "" {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
entry, waiting := s.pending[itemID]
|
||||
delete(s.pending, itemID)
|
||||
s.mu.Unlock()
|
||||
if waiting && entry.cancel != nil {
|
||||
entry.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
// Run is the worker. One goroutine, for the whole gateway, for ever.
|
||||
//
|
||||
// A single worker is not a placeholder for a pool. Concurrent scans would multiply exactly
|
||||
// the two costs this package is built to minimise — disk reads and decoder CPU — on a machine
|
||||
// whose primary job is streaming video to televisions. Depth here buys nothing: the queue is
|
||||
// a handful of episodes, and there are hours before anybody reaches them.
|
||||
func (s *Service) Run(ctx context.Context) {
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
candidate, found := s.queue.Claim()
|
||||
if !found {
|
||||
if !sleep(ctx, idlePoll) {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Live playback goes ahead regardless of load: somebody is waiting, and the scan is
|
||||
// a couple of minutes of ranged reads against a file the server is already serving.
|
||||
// Everything else stands down.
|
||||
if candidate.Priority < PriorityLive && s.busy(ctx) {
|
||||
s.queue.Release(candidate.ItemID)
|
||||
s.queue.Push(candidate)
|
||||
s.log.Debug("credits scan deferred; server busy", "item", candidate.ItemID)
|
||||
if !sleep(ctx, busyBackoff) {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
scanCtx, cancel := context.WithTimeout(ctx, scanBudget)
|
||||
detection, stored, err := s.Process(scanCtx, candidate.ItemID)
|
||||
cancel()
|
||||
s.queue.Release(candidate.ItemID)
|
||||
|
||||
switch {
|
||||
case err != nil && errors.Is(err, context.Canceled):
|
||||
return
|
||||
case err != nil:
|
||||
s.log.Debug("credits scan failed",
|
||||
"item", candidate.ItemID, "reason", candidate.Reason, "error", err)
|
||||
case stored:
|
||||
s.log.Info("credits_detected",
|
||||
"item", candidate.ItemID,
|
||||
"marker_ms", detection.StartMs,
|
||||
"confidence", round2(detection.Confidence),
|
||||
"method", detection.Method,
|
||||
"frames", detection.FramesSampled,
|
||||
"duration_ms", detection.Elapsed.Milliseconds())
|
||||
default:
|
||||
s.log.Debug("credits not detected",
|
||||
"item", candidate.ItemID, "frames", detection.FramesSampled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process is one candidate, start to finish. Single-flighted on the media version, so the
|
||||
// four ways an episode can be asked for — live playback, a predicted next, another viewer's
|
||||
// prediction, a refresh cycle — collapse into one scan.
|
||||
//
|
||||
// Returns whether anything was stored, which is not the same as whether anything was found:
|
||||
// a detection below the confidence threshold, or one too close to an existing marker to be
|
||||
// worth a write, is a successful scan that deliberately produces no database activity.
|
||||
func (s *Service) Process(ctx context.Context, itemID string) (Detection, bool, error) {
|
||||
resolved, err := s.resolver.Resolve(ctx, itemID)
|
||||
if err != nil {
|
||||
return Detection{}, false, err
|
||||
}
|
||||
fingerprint := Fingerprint(resolved.Version)
|
||||
|
||||
result, err := s.flight.Do(itemID+":"+fingerprint, func() (any, error) {
|
||||
detection, stored, err := s.process(ctx, itemID, fingerprint, resolved)
|
||||
return processResult{detection: detection, stored: stored}, err
|
||||
})
|
||||
outcome, _ := result.(processResult)
|
||||
return outcome.detection, outcome.stored, err
|
||||
}
|
||||
|
||||
type processResult struct {
|
||||
detection Detection
|
||||
stored bool
|
||||
}
|
||||
|
||||
func (s *Service) process(
|
||||
ctx context.Context, itemID, fingerprint string, resolved ResolvedMedia,
|
||||
) (Detection, bool, error) {
|
||||
// Emby already knows. Nothing to do, and nothing to store: the live path reads Emby's
|
||||
// chapters directly, so duplicating the answer here would be a row that could only ever
|
||||
// go stale.
|
||||
if resolved.EmbyCreditsMs > 0 {
|
||||
return Detection{Found: true, StartMs: resolved.EmbyCreditsMs, Method: MethodEmby}, false, nil
|
||||
}
|
||||
existing, found, err := s.repo.GetMarker(ctx, itemID, fingerprint)
|
||||
if err != nil {
|
||||
return Detection{}, false, err
|
||||
}
|
||||
// The common path, and the one everything is optimised for: already decided, no media
|
||||
// access, no decoder, no write.
|
||||
if found && existing.Confidence >= ConfidenceThreshold {
|
||||
return Detection{
|
||||
Found: true, StartMs: existing.CreditsStartMs,
|
||||
Confidence: existing.Confidence, Method: existing.DetectionMethod,
|
||||
}, false, nil
|
||||
}
|
||||
if resolved.RuntimeMs <= 0 {
|
||||
return Detection{}, false, nil
|
||||
}
|
||||
|
||||
// Behaviour first, because it is free. On a well-watched show it can settle the question
|
||||
// without any media being opened at all, and where it cannot it still narrows the window
|
||||
// the decoder has to read.
|
||||
evidence := s.evidence(ctx, itemID, resolved.RuntimeMs)
|
||||
|
||||
var visual Detection
|
||||
if !evidence.StandaloneMarker() || !evidence.Usable() {
|
||||
history, runtimes := s.seasonHistory(ctx, resolved)
|
||||
window := NarrowWindow(resolved.RuntimeMs, history, runtimes, evidence)
|
||||
visual, err = s.detector.Detect(ctx, MediaInfo{
|
||||
URL: resolved.URL, RuntimeMs: resolved.RuntimeMs, Window: window,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNoFFmpeg) {
|
||||
// Run on behaviour alone rather than failing. An image without a decoder is
|
||||
// a deliberate deployment, not a fault.
|
||||
s.log.Debug("visual credits detection unavailable")
|
||||
} else {
|
||||
return Detection{}, false, err
|
||||
}
|
||||
}
|
||||
visual.Window = window
|
||||
}
|
||||
|
||||
detection, acceptable := Combine(visual, evidence)
|
||||
if !acceptable {
|
||||
return visual, false, nil
|
||||
}
|
||||
if found && !ShouldRewrite(existing, detection) {
|
||||
// Stability: the same answer, or a marginally different one, is not worth a write.
|
||||
return detection, false, nil
|
||||
}
|
||||
|
||||
// A fingerprint too weak to detect a file replacement is one whose marker could outlive
|
||||
// the file it describes. Better to keep re-deriving it than to store something nothing
|
||||
// will ever invalidate.
|
||||
if resolved.Version.Weak() {
|
||||
s.log.Debug("credits marker withheld; weak fingerprint", "item", itemID)
|
||||
return detection, false, nil
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
marker := Marker{
|
||||
ItemID: itemID,
|
||||
MediaFingerprint: fingerprint,
|
||||
CreditsStartMs: detection.StartMs,
|
||||
Confidence: detection.Confidence,
|
||||
DetectionMethod: detection.Method,
|
||||
SeriesID: resolved.SeriesID,
|
||||
Season: resolved.Season,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := s.repo.SaveMarker(ctx, marker); err != nil {
|
||||
return detection, false, err
|
||||
}
|
||||
return detection, true, nil
|
||||
}
|
||||
|
||||
// evidence reads the household's stops. Failures are swallowed: behaviour is an optimisation
|
||||
// and an enhancement, never a precondition.
|
||||
func (s *Service) evidence(ctx context.Context, itemID string, runtimeMs int64) BehaviourEvidence {
|
||||
if s.behaviour == nil {
|
||||
return BehaviourEvidence{}
|
||||
}
|
||||
stops, err := s.behaviour.Stops(ctx, itemID)
|
||||
if err != nil {
|
||||
s.log.Debug("behavioural evidence unavailable", "item", itemID, "error", err)
|
||||
return BehaviourEvidence{}
|
||||
}
|
||||
return AnalyseStops(stops, runtimeMs)
|
||||
}
|
||||
|
||||
// seasonHistory reads what is already known about this episode's neighbours, and the runtime
|
||||
// of each so the tail offsets can be compared. Failures degrade to a generic tail window.
|
||||
func (s *Service) seasonHistory(
|
||||
ctx context.Context, resolved ResolvedMedia,
|
||||
) ([]Marker, func(Marker) int64) {
|
||||
if resolved.SeriesID == "" || resolved.Season <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
const historyLimit = 6
|
||||
history, err := s.repo.SeasonMarkers(ctx, resolved.SeriesID, resolved.Season, historyLimit)
|
||||
if err != nil || len(history) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
// The neighbours' runtimes come from resolving them, which would be a request each. The
|
||||
// episodes of one season are within a minute or two of each other, so this episode's own
|
||||
// runtime is a good enough stand-in — and being slightly wrong here only widens or
|
||||
// narrows a window that carries a ninety-second margin either way.
|
||||
runtime := resolved.RuntimeMs
|
||||
return history, func(Marker) int64 { return runtime }
|
||||
}
|
||||
|
||||
// known is the cache check in its cheapest form: does a marker exist for the current version
|
||||
// of this item. One resolve, one indexed read.
|
||||
func (s *Service) known(ctx context.Context, itemID string) (bool, error) {
|
||||
resolved, err := s.resolver.Resolve(ctx, itemID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if resolved.EmbyCreditsMs > 0 {
|
||||
return true, nil
|
||||
}
|
||||
marker, found, err := s.repo.GetMarker(ctx, itemID, Fingerprint(resolved.Version))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return found && marker.Confidence >= ConfidenceThreshold, nil
|
||||
}
|
||||
|
||||
func (s *Service) busy(ctx context.Context) bool {
|
||||
return s.load != nil && s.load.Busy(ctx)
|
||||
}
|
||||
|
||||
// QueueDepth is what the console reads.
|
||||
func (s *Service) QueueDepth() int { return s.queue.Len() }
|
||||
|
||||
// Pending is the queue in worker order, for the admin console.
|
||||
func (s *Service) Pending() []Candidate { return s.queue.Snapshot() }
|
||||
|
||||
func sleep(ctx context.Context, duration time.Duration) bool {
|
||||
timer := time.NewTimer(duration)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
case <-timer.C:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func plural(count int) string {
|
||||
if count == 1 {
|
||||
return ""
|
||||
}
|
||||
return "s"
|
||||
}
|
||||
|
||||
func round2(value float64) float64 {
|
||||
return float64(int(value*100+0.5)) / 100
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
package credits
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The service, and the claim it exists to keep: in the settled case a candidate costs one
|
||||
// indexed read and touches no media at all.
|
||||
//
|
||||
// The fake detector counts every call, so "no file access" is an assertion rather than a
|
||||
// hope — which is the only way to test a property whose failure is invisible.
|
||||
|
||||
type fakeRepo struct {
|
||||
mu sync.Mutex
|
||||
markers map[string]Marker
|
||||
season []Marker
|
||||
writes int32
|
||||
reads int32
|
||||
}
|
||||
|
||||
func newFakeRepo() *fakeRepo { return &fakeRepo{markers: map[string]Marker{}} }
|
||||
|
||||
func (r *fakeRepo) key(itemID, fingerprint string) string { return itemID + "|" + fingerprint }
|
||||
|
||||
func (r *fakeRepo) GetMarker(_ context.Context, itemID, fingerprint string) (Marker, bool, error) {
|
||||
atomic.AddInt32(&r.reads, 1)
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
marker, found := r.markers[r.key(itemID, fingerprint)]
|
||||
return marker, found, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) SaveMarker(_ context.Context, marker Marker) error {
|
||||
atomic.AddInt32(&r.writes, 1)
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.markers[r.key(marker.ItemID, marker.MediaFingerprint)] = marker
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) SeasonMarkers(context.Context, string, int, int) ([]Marker, error) {
|
||||
return r.season, nil
|
||||
}
|
||||
|
||||
type fakeDetector struct {
|
||||
calls int32
|
||||
result Detection
|
||||
err error
|
||||
}
|
||||
|
||||
func (d *fakeDetector) Detect(ctx context.Context, _ MediaInfo) (Detection, error) {
|
||||
atomic.AddInt32(&d.calls, 1)
|
||||
if d.err != nil {
|
||||
return Detection{}, d.err
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return Detection{}, ctx.Err()
|
||||
}
|
||||
return d.result, nil
|
||||
}
|
||||
|
||||
type fakeResolver struct {
|
||||
mu sync.Mutex
|
||||
media map[string]ResolvedMedia
|
||||
}
|
||||
|
||||
func (r *fakeResolver) Resolve(_ context.Context, itemID string) (ResolvedMedia, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
media, found := r.media[itemID]
|
||||
if !found {
|
||||
return ResolvedMedia{}, errors.New("unknown item")
|
||||
}
|
||||
return media, nil
|
||||
}
|
||||
|
||||
type fakeBehaviour struct{ stops []StopEvent }
|
||||
|
||||
func (b fakeBehaviour) Stops(context.Context, string) ([]StopEvent, error) { return b.stops, nil }
|
||||
|
||||
func quietLog() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError}))
|
||||
}
|
||||
|
||||
func testMedia() ResolvedMedia {
|
||||
return ResolvedMedia{
|
||||
URL: "http://emby/Videos/bb-s06e08/stream", SeriesID: "bb", Season: 6, Episode: 8,
|
||||
RuntimeMs: bbRuntime,
|
||||
Version: MediaVersion{
|
||||
ItemID: "bb-s06e08", RuntimeMs: bbRuntime, SizeBytes: 1_400_000_000,
|
||||
ETag: "abc123",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newTestService(repo *fakeRepo, detector Detector, media ResolvedMedia) *Service {
|
||||
return New(Deps{
|
||||
Repository: repo,
|
||||
Resolver: &fakeResolver{media: map[string]ResolvedMedia{media.Version.ItemID: media}},
|
||||
Detector: detector,
|
||||
Log: quietLog(),
|
||||
Config: DefaultConfig(),
|
||||
})
|
||||
}
|
||||
|
||||
// The steady state the whole subsystem is optimised for.
|
||||
func TestCachedMarkerPreventsAnyMediaAccess(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
media := testMedia()
|
||||
repo.markers[repo.key(media.Version.ItemID, Fingerprint(media.Version))] = Marker{
|
||||
ItemID: media.Version.ItemID, CreditsStartMs: 2_450_000,
|
||||
Confidence: 0.93, DetectionMethod: MethodCombined,
|
||||
}
|
||||
detector := &fakeDetector{result: visual(2_450_000, 0.9)}
|
||||
service := newTestService(repo, detector, media)
|
||||
|
||||
detection, stored, err := service.Process(context.Background(), media.Version.ItemID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if detector.calls != 0 {
|
||||
t.Fatalf("the detector ran %d times for an episode that was already decided", detector.calls)
|
||||
}
|
||||
if stored {
|
||||
t.Fatal("a cached marker caused a database write")
|
||||
}
|
||||
if atomic.LoadInt32(&repo.writes) != 0 {
|
||||
t.Fatalf("%d writes on the cached path; the promise is zero", repo.writes)
|
||||
}
|
||||
if detection.StartMs != 2_450_000 {
|
||||
t.Fatalf("StartMs = %d, want the stored marker", detection.StartMs)
|
||||
}
|
||||
}
|
||||
|
||||
// A successful scan is one write, and exactly one.
|
||||
func TestSuccessfulScanWritesOnce(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
media := testMedia()
|
||||
detector := &fakeDetector{result: visual(2_450_000, 0.85)}
|
||||
service := newTestService(repo, detector, media)
|
||||
|
||||
if _, stored, err := service.Process(context.Background(), media.Version.ItemID); err != nil || !stored {
|
||||
t.Fatalf("first scan stored = %v, err = %v", stored, err)
|
||||
}
|
||||
if repo.writes != 1 {
|
||||
t.Fatalf("%d writes for one scan, want 1", repo.writes)
|
||||
}
|
||||
// And the second time the episode comes round, nothing at all.
|
||||
if _, stored, err := service.Process(context.Background(), media.Version.ItemID); err != nil || stored {
|
||||
t.Fatalf("second pass stored = %v, err = %v; the answer was already known", stored, err)
|
||||
}
|
||||
if repo.writes != 1 {
|
||||
t.Fatalf("%d writes after re-processing, want 1", repo.writes)
|
||||
}
|
||||
if detector.calls != 1 {
|
||||
t.Fatalf("the detector ran %d times; the second pass should never reach it", detector.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// The reason the marker is keyed on a fingerprint rather than an item id.
|
||||
func TestReplacedFileInvalidatesTheMarker(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
media := testMedia()
|
||||
detector := &fakeDetector{result: visual(2_450_000, 0.85)}
|
||||
service := newTestService(repo, detector, media)
|
||||
|
||||
if _, _, err := service.Process(context.Background(), media.Version.ItemID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Sonarr swaps the file: same episode, same item id, different media.
|
||||
replaced := media
|
||||
replaced.Version.SizeBytes = 2_900_000_000
|
||||
replaced.Version.ETag = "def456"
|
||||
service = newTestService(repo, detector, replaced)
|
||||
|
||||
if _, stored, err := service.Process(context.Background(), replaced.Version.ItemID); err != nil || !stored {
|
||||
t.Fatalf("a replaced file was not rescanned: stored = %v, err = %v", stored, err)
|
||||
}
|
||||
if detector.calls != 2 {
|
||||
t.Fatalf("the detector ran %d times; the replacement should have been scanned", detector.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// A marker that could outlive the file it describes must not be stored.
|
||||
func TestWeakFingerprintWithholdsTheMarker(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
media := testMedia()
|
||||
media.Version = MediaVersion{ItemID: media.Version.ItemID, RuntimeMs: bbRuntime}
|
||||
service := newTestService(repo, &fakeDetector{result: visual(2_450_000, 0.85)}, media)
|
||||
|
||||
if _, stored, err := service.Process(context.Background(), media.Version.ItemID); err != nil || stored {
|
||||
t.Fatalf("stored = %v, err = %v; a runtime-only fingerprint cannot be invalidated",
|
||||
stored, err)
|
||||
}
|
||||
if repo.writes != 0 {
|
||||
t.Fatalf("%d writes for a weak fingerprint, want 0", repo.writes)
|
||||
}
|
||||
}
|
||||
|
||||
// Emby's own answer wins, and costs nothing.
|
||||
func TestEmbyChapterMarkerSkipsTheScanEntirely(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
media := testMedia()
|
||||
media.EmbyCreditsMs = 2_460_000
|
||||
detector := &fakeDetector{result: visual(2_450_000, 0.9)}
|
||||
service := newTestService(repo, detector, media)
|
||||
|
||||
detection, stored, err := service.Process(context.Background(), media.Version.ItemID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if detector.calls != 0 || stored || repo.writes != 0 {
|
||||
t.Fatalf("Emby had already answered but the scan ran anyway "+
|
||||
"(calls=%d stored=%v writes=%d)", detector.calls, stored, repo.writes)
|
||||
}
|
||||
if detection.StartMs != 2_460_000 || detection.Method != MethodEmby {
|
||||
t.Fatalf("detection = %+v, want Emby's own marker", detection)
|
||||
}
|
||||
}
|
||||
|
||||
// A weak reading is a scan that deliberately produces no database activity.
|
||||
func TestSubThresholdDetectionIsNotStored(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
media := testMedia()
|
||||
service := newTestService(repo, &fakeDetector{result: visual(2_450_000, 0.4)}, media)
|
||||
|
||||
if _, stored, err := service.Process(context.Background(), media.Version.ItemID); err != nil || stored {
|
||||
t.Fatalf("stored = %v, err = %v; a doubtful reading must not be written", stored, err)
|
||||
}
|
||||
if repo.writes != 0 {
|
||||
t.Fatalf("%d writes for a rejected detection, want 0", repo.writes)
|
||||
}
|
||||
}
|
||||
|
||||
// No decoder is a deliberate deployment, not a fault: behaviour alone still answers.
|
||||
func TestMissingFFmpegFallsBackToBehaviour(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
media := testMedia()
|
||||
service := New(Deps{
|
||||
Repository: repo,
|
||||
Resolver: &fakeResolver{media: map[string]ResolvedMedia{media.Version.ItemID: media}},
|
||||
Detector: &fakeDetector{err: ErrNoFFmpeg},
|
||||
Behaviour: fakeBehaviour{stops: []StopEvent{
|
||||
{UserKey: "paul", PositionMs: 2_450_000, RuntimeMs: bbRuntime, NextEpisode: true},
|
||||
{UserKey: "david", PositionMs: 2_453_000, RuntimeMs: bbRuntime, NextEpisode: true},
|
||||
{UserKey: "matt", PositionMs: 2_451_000, RuntimeMs: bbRuntime, NextEpisode: true},
|
||||
}},
|
||||
Log: quietLog(),
|
||||
Config: DefaultConfig(),
|
||||
})
|
||||
|
||||
detection, stored, err := service.Process(context.Background(), media.Version.ItemID)
|
||||
if err != nil {
|
||||
t.Fatalf("a missing decoder was reported as an error: %v", err)
|
||||
}
|
||||
if !stored || detection.Method != MethodBehaviour {
|
||||
t.Fatalf("stored = %v, method = %q; behaviour alone should have answered",
|
||||
stored, detection.Method)
|
||||
}
|
||||
}
|
||||
|
||||
// Four signals naming one episode must collapse into one scan.
|
||||
func TestConcurrentRequestsCollapseIntoOneScan(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
media := testMedia()
|
||||
slow := &slowDetector{result: visual(2_450_000, 0.85)}
|
||||
service := newTestService(repo, slow, media)
|
||||
|
||||
var group sync.WaitGroup
|
||||
for index := 0; index < 6; index++ {
|
||||
group.Add(1)
|
||||
go func() {
|
||||
defer group.Done()
|
||||
_, _, _ = service.Process(context.Background(), media.Version.ItemID)
|
||||
}()
|
||||
}
|
||||
group.Wait()
|
||||
|
||||
if calls := atomic.LoadInt32(&slow.calls); calls != 1 {
|
||||
t.Fatalf("six simultaneous requests produced %d scans, want 1", calls)
|
||||
}
|
||||
if repo.writes != 1 {
|
||||
t.Fatalf("%d writes, want 1", repo.writes)
|
||||
}
|
||||
}
|
||||
|
||||
type slowDetector struct {
|
||||
calls int32
|
||||
result Detection
|
||||
}
|
||||
|
||||
func (d *slowDetector) Detect(ctx context.Context, _ MediaInfo) (Detection, error) {
|
||||
atomic.AddInt32(&d.calls, 1)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return Detection{}, ctx.Err()
|
||||
case <-time.After(40 * time.Millisecond):
|
||||
}
|
||||
return d.result, nil
|
||||
}
|
||||
|
||||
func TestCancellationStopsAScan(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
media := testMedia()
|
||||
service := newTestService(repo, &slowDetector{result: visual(2_450_000, 0.9)}, media)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if _, stored, err := service.Process(ctx, media.Version.ItemID); err == nil || stored {
|
||||
t.Fatalf("a cancelled scan returned stored = %v, err = %v", stored, err)
|
||||
}
|
||||
if repo.writes != 0 {
|
||||
t.Fatalf("%d writes after cancellation, want 0", repo.writes)
|
||||
}
|
||||
}
|
||||
|
||||
// The delay is what stops a curious button press becoming disk activity.
|
||||
func TestAbandonedPlaybackIsNeverQueued(t *testing.T) {
|
||||
media := testMedia()
|
||||
service := newTestService(newFakeRepo(), &fakeDetector{}, media)
|
||||
service.liveDelay = 50 * time.Millisecond
|
||||
|
||||
service.NotePlayback(context.Background(), media.Version.ItemID)
|
||||
service.AbandonPlayback(media.Version.ItemID)
|
||||
time.Sleep(120 * time.Millisecond)
|
||||
|
||||
if depth := service.QueueDepth(); depth != 0 {
|
||||
t.Fatalf("queue depth = %d; an abandoned playback was queued anyway", depth)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSustainedPlaybackIsQueuedAtLivePriority(t *testing.T) {
|
||||
media := testMedia()
|
||||
service := newTestService(newFakeRepo(), &fakeDetector{}, media)
|
||||
service.liveDelay = 20 * time.Millisecond
|
||||
|
||||
service.NotePlayback(context.Background(), media.Version.ItemID)
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for service.QueueDepth() == 0 && time.Now().Before(deadline) {
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
|
||||
pending := service.Pending()
|
||||
if len(pending) != 1 {
|
||||
t.Fatalf("queue holds %d candidates, want 1", len(pending))
|
||||
}
|
||||
if pending[0].Priority != PriorityLive || pending[0].Reason != ReasonLivePlayback {
|
||||
t.Fatalf("candidate = %+v, want live playback at priority %d", pending[0], PriorityLive)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package credits
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Tracearr as a candidate source, kept deliberately apart from the detector.
|
||||
//
|
||||
// Everything about *why* an episode is worth scanning lives on this side of the boundary,
|
||||
// and nothing about it crosses. The detector is handed a file and a window; it never learns
|
||||
// that Paul watched four episodes of Blue Bloods this week, and it must not, or it would
|
||||
// become possible to tune the detector to agree with the predictor rather than with the
|
||||
// media.
|
||||
|
||||
// Database is the narrow slice of the store this package reads. Narrow so the whole candidate
|
||||
// pipeline can be exercised against a map in a test, and so it is obvious at a glance that
|
||||
// nothing here writes anything but a marker.
|
||||
type Database interface {
|
||||
RecentWatches(ctx context.Context, since time.Time, limit int) ([]Watch, error)
|
||||
SeriesEpisodes(ctx context.Context, seriesIDs []string) ([]SeriesEpisode, error)
|
||||
}
|
||||
|
||||
// SeriesEpisode is an episode with its series and numbering, which is what an index is built from.
|
||||
type SeriesEpisode struct {
|
||||
ItemID string
|
||||
SeriesID string
|
||||
Season int
|
||||
Episode int
|
||||
}
|
||||
|
||||
// watchLimit bounds the demand query. A household producing more than this many episode
|
||||
// sessions inside the decay window is one whose oldest sessions cannot possibly still be
|
||||
// predictive, so the newest-first ordering makes the cap harmless.
|
||||
const watchLimit = 500
|
||||
|
||||
// TracearrSource builds candidates from what the household has actually been watching.
|
||||
type TracearrSource struct {
|
||||
DB Database
|
||||
Cfg Config
|
||||
}
|
||||
|
||||
// Candidates is the whole predictive pipeline: read recent demand, group it into per-viewer
|
||||
// activities, load the numbering for the few series involved, and project a small window
|
||||
// ahead of each viewer.
|
||||
//
|
||||
// Two database reads for the entire household, whatever it is watching. Nothing here is
|
||||
// per-candidate and nothing is per-episode.
|
||||
func (s *TracearrSource) Candidates(ctx context.Context) ([]Candidate, error) {
|
||||
if s == nil || s.DB == nil {
|
||||
return nil, nil
|
||||
}
|
||||
cfg := s.Cfg
|
||||
if cfg.QueueLimit <= 0 {
|
||||
cfg = DefaultConfig()
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
|
||||
// The decay window is the query's window too. Anything older cannot survive
|
||||
// decayWeight, so fetching it would be reading rows in order to discard them.
|
||||
watches, err := s.DB.RecentWatches(ctx, now.Add(-cfg.WeakWindow), watchLimit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
activities := Activities(watches, now, cfg)
|
||||
if len(activities) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
seriesIDs := make([]string, 0, len(activities))
|
||||
seen := map[string]bool{}
|
||||
for _, activity := range activities {
|
||||
if !seen[activity.SeriesID] {
|
||||
seen[activity.SeriesID] = true
|
||||
seriesIDs = append(seriesIDs, activity.SeriesID)
|
||||
}
|
||||
}
|
||||
episodes, err := s.DB.SeriesEpisodes(ctx, seriesIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return BuildCandidates(activities, NewEpisodeIndex(episodes), now, cfg), nil
|
||||
}
|
||||
|
||||
// episodeIndex is an in-memory ordering of each series' episodes.
|
||||
//
|
||||
// Built once per refresh cycle from one query, so stepping forward — including across a
|
||||
// season boundary, which is exactly where somebody is most likely to keep watching — is
|
||||
// arithmetic rather than a lookup per candidate.
|
||||
type episodeIndex struct {
|
||||
bySeries map[string][]SeriesEpisode
|
||||
}
|
||||
|
||||
// NewEpisodeIndex orders the episodes of each series by season and number.
|
||||
func NewEpisodeIndex(episodes []SeriesEpisode) EpisodeIndex {
|
||||
index := &episodeIndex{bySeries: map[string][]SeriesEpisode{}}
|
||||
for _, episode := range episodes {
|
||||
if episode.ItemID == "" || episode.SeriesID == "" || episode.Episode <= 0 {
|
||||
continue
|
||||
}
|
||||
index.bySeries[episode.SeriesID] = append(index.bySeries[episode.SeriesID], episode)
|
||||
}
|
||||
for _, list := range index.bySeries {
|
||||
sort.Slice(list, func(a, b int) bool {
|
||||
if list[a].Season != list[b].Season {
|
||||
return list[a].Season < list[b].Season
|
||||
}
|
||||
return list[a].Episode < list[b].Episode
|
||||
})
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
// Following returns the next count episodes after a position.
|
||||
//
|
||||
// Specials are skipped. Season 0 is a real season and its episodes are real files, but
|
||||
// nobody finishing S06E07 goes on to a behind-the-scenes featurette, and queueing one would
|
||||
// spend a scan on an episode that will not be watched.
|
||||
func (i *episodeIndex) Following(seriesID string, season, episode, count int) []EpisodeRef {
|
||||
if count <= 0 {
|
||||
return nil
|
||||
}
|
||||
list := i.bySeries[seriesID]
|
||||
out := make([]EpisodeRef, 0, count)
|
||||
for _, candidate := range list {
|
||||
if candidate.Season <= 0 {
|
||||
continue
|
||||
}
|
||||
after := candidate.Season > season ||
|
||||
(candidate.Season == season && candidate.Episode > episode)
|
||||
if !after {
|
||||
continue
|
||||
}
|
||||
out = append(out, EpisodeRef{
|
||||
ItemID: candidate.ItemID,
|
||||
Season: candidate.Season,
|
||||
Episode: candidate.Episode,
|
||||
})
|
||||
if len(out) == count {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package credits
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Where the scan looks, and how it is narrowed.
|
||||
//
|
||||
// This is the file that decides how much of a media file is ever read, so it is where the
|
||||
// performance claim of the whole subsystem is either kept or given away. A generic tail
|
||||
// window is a fallback; the interesting case is the second episode of a season onwards,
|
||||
// where the first one has already told us where this show puts its credits.
|
||||
|
||||
// Provenance of a window, reported by the benchmark so "is demand-driven narrowing actually
|
||||
// saving work" is a question with a printed answer rather than an opinion.
|
||||
const (
|
||||
WindowGeneric = "generic-tail-window"
|
||||
WindowSeason = "season-history"
|
||||
WindowBehaviour = "tracearr-behaviour"
|
||||
)
|
||||
|
||||
const (
|
||||
// The generic tail, when nothing is known: a fifth of the runtime, floored at five
|
||||
// minutes so a short episode still has somewhere to look and capped at twelve so a
|
||||
// three-hour film does not turn into a thirty-six minute scan.
|
||||
genericTailFraction = 0.20
|
||||
genericTailMinimum = 5 * time.Minute
|
||||
genericTailMaximum = 12 * time.Minute
|
||||
|
||||
// The margin either side of an expected position. Wide enough to absorb a cold open
|
||||
// that ran long or a differently-cut episode, narrow enough that the scan is a couple of
|
||||
// minutes rather than ten.
|
||||
narrowMargin = 90 * time.Second
|
||||
|
||||
// seasonSpread is how much disagreement between known episodes is tolerated before the
|
||||
// season stops being evidence. A show that puts its credits at a consistent point is
|
||||
// usable; one whose known markers are three minutes apart is telling us the episodes are
|
||||
// not structurally alike, and narrowing on their average would look in the wrong place.
|
||||
seasonSpread = 2 * time.Minute
|
||||
|
||||
// seasonMinimumSamples is how many known episodes it takes. One is an anecdote — it may
|
||||
// itself be the mis-detection — and narrowing a scan onto a single unconfirmed reading is
|
||||
// how one wrong marker propagates through a whole season.
|
||||
seasonMinimumSamples = 2
|
||||
)
|
||||
|
||||
// ScanWindow is a span of the file, in milliseconds from its start.
|
||||
type ScanWindow struct {
|
||||
StartMs int64
|
||||
EndMs int64
|
||||
Source string
|
||||
}
|
||||
|
||||
func (w ScanWindow) DurationMs() int64 {
|
||||
if w.EndMs <= w.StartMs {
|
||||
return 0
|
||||
}
|
||||
return w.EndMs - w.StartMs
|
||||
}
|
||||
|
||||
func (w ScanWindow) Valid() bool { return w.DurationMs() > 0 }
|
||||
|
||||
// GenericTailWindow is where to look when nothing at all is known about the show.
|
||||
func GenericTailWindow(runtimeMs int64) ScanWindow {
|
||||
if runtimeMs <= 0 {
|
||||
return ScanWindow{}
|
||||
}
|
||||
tail := int64(float64(runtimeMs) * genericTailFraction)
|
||||
if minimum := genericTailMinimum.Milliseconds(); tail < minimum {
|
||||
tail = minimum
|
||||
}
|
||||
if maximum := genericTailMaximum.Milliseconds(); tail > maximum {
|
||||
tail = maximum
|
||||
}
|
||||
start := runtimeMs - tail
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
return ScanWindow{StartMs: start, EndMs: runtimeMs, Source: WindowGeneric}
|
||||
}
|
||||
|
||||
// aroundWindow is a narrow span centred on an expected position, clamped to the file.
|
||||
func aroundWindow(runtimeMs, expectedMs int64, source string) ScanWindow {
|
||||
margin := narrowMargin.Milliseconds()
|
||||
start := expectedMs - margin
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
end := expectedMs + margin
|
||||
if runtimeMs > 0 && end > runtimeMs {
|
||||
end = runtimeMs
|
||||
}
|
||||
if end <= start {
|
||||
return ScanWindow{}
|
||||
}
|
||||
return ScanWindow{StartMs: start, EndMs: end, Source: source}
|
||||
}
|
||||
|
||||
// seasonExpectation is where this show's credits are expected to start, learned from
|
||||
// episodes of the same season that have already been decided.
|
||||
//
|
||||
// Measured as a *tail offset* — how long the credits run for — rather than as an absolute
|
||||
// position, which is the one modelling decision in this file worth defending. Episodes of a
|
||||
// season vary in length by a minute or two; the credits sequence itself does not vary at all,
|
||||
// because it is the same sequence. Averaging absolute positions would smear that variation
|
||||
// into the estimate and force a wider margin to cover it.
|
||||
func seasonExpectation(runtimeMs int64, history []Marker, runtimeOf func(Marker) int64) (int64, bool) {
|
||||
if runtimeMs <= 0 || len(history) < seasonMinimumSamples {
|
||||
return 0, false
|
||||
}
|
||||
offsets := make([]int64, 0, len(history))
|
||||
for _, marker := range history {
|
||||
episodeRuntime := runtimeOf(marker)
|
||||
if episodeRuntime <= 0 || marker.CreditsStartMs <= 0 || marker.CreditsStartMs >= episodeRuntime {
|
||||
continue
|
||||
}
|
||||
// Only markers we would trust ourselves are allowed to steer a scan. A low-confidence
|
||||
// reading is exactly the one that should be re-examined, not the one that decides
|
||||
// where everything else looks.
|
||||
if marker.Confidence < ConfidenceThreshold {
|
||||
continue
|
||||
}
|
||||
offsets = append(offsets, episodeRuntime-marker.CreditsStartMs)
|
||||
}
|
||||
if len(offsets) < seasonMinimumSamples {
|
||||
return 0, false
|
||||
}
|
||||
sort.Slice(offsets, func(a, b int) bool { return offsets[a] < offsets[b] })
|
||||
if offsets[len(offsets)-1]-offsets[0] > seasonSpread.Milliseconds() {
|
||||
return 0, false
|
||||
}
|
||||
// The median, not the mean: one episode with a long "next time on" trailer after the
|
||||
// credits must not drag the estimate, and with two samples the median of the sorted pair
|
||||
// is the lower — the safer end, since looking slightly early costs nothing but looking
|
||||
// late misses the transition entirely.
|
||||
median := offsets[len(offsets)/2]
|
||||
expected := runtimeMs - median
|
||||
if expected <= 0 || expected >= runtimeMs {
|
||||
return 0, false
|
||||
}
|
||||
return expected, true
|
||||
}
|
||||
|
||||
// NarrowWindow decides where to look, preferring the strongest evidence available.
|
||||
//
|
||||
// Order matters and is deliberate. Behaviour first: where enough viewers stopped is a direct
|
||||
// observation of this exact episode, where season history is an inference from its
|
||||
// neighbours. Season history second. The generic tail last, and only because something has
|
||||
// to be.
|
||||
func NarrowWindow(
|
||||
runtimeMs int64,
|
||||
history []Marker,
|
||||
runtimeOf func(Marker) int64,
|
||||
behaviour BehaviourEvidence,
|
||||
) ScanWindow {
|
||||
if runtimeMs <= 0 {
|
||||
return ScanWindow{}
|
||||
}
|
||||
if behaviour.Usable() {
|
||||
if window := aroundWindow(runtimeMs, behaviour.StartMs, WindowBehaviour); window.Valid() {
|
||||
return window
|
||||
}
|
||||
}
|
||||
if runtimeOf != nil {
|
||||
if expected, ok := seasonExpectation(runtimeMs, history, runtimeOf); ok {
|
||||
if window := aroundWindow(runtimeMs, expected, WindowSeason); window.Valid() {
|
||||
return window
|
||||
}
|
||||
}
|
||||
}
|
||||
return GenericTailWindow(runtimeMs)
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package credits
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The scan window is where the performance claim is kept or given away, so these tests are
|
||||
// about size: how much of a file each source of evidence lets us avoid reading.
|
||||
|
||||
const bbRuntime = 44*60*1000 + 12*1000 // 44:12
|
||||
|
||||
func TestGenericTailIsBoundedBothWays(t *testing.T) {
|
||||
// A short episode still gets a usable window.
|
||||
short := GenericTailWindow(18 * 60 * 1000)
|
||||
if short.DurationMs() < genericTailMinimum.Milliseconds() {
|
||||
t.Fatalf("short episode window was %v, below the floor", short.DurationMs())
|
||||
}
|
||||
// A three-hour film does not turn into a thirty-six minute scan.
|
||||
long := GenericTailWindow(3 * 60 * 60 * 1000)
|
||||
if long.DurationMs() > genericTailMaximum.Milliseconds() {
|
||||
t.Fatalf("film window was %v, past the ceiling", long.DurationMs())
|
||||
}
|
||||
if long.EndMs != 3*60*60*1000 {
|
||||
t.Fatal("the window must run to the end of the file")
|
||||
}
|
||||
if long.Source != WindowGeneric {
|
||||
t.Fatalf("source = %q, want %q", long.Source, WindowGeneric)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoRuntimeMeansNoWindow(t *testing.T) {
|
||||
if GenericTailWindow(0).Valid() {
|
||||
t.Fatal("a file with no runtime produced a scannable window")
|
||||
}
|
||||
}
|
||||
|
||||
// The optimisation the brief calls one of the most important: knowing where this show puts
|
||||
// its credits should turn a ten-minute tail into a couple of minutes.
|
||||
func TestSeasonHistoryNarrowsTheScan(t *testing.T) {
|
||||
// Blue Bloods S06E04–06, credits around 40:50 of a ~44 minute episode.
|
||||
history := []Marker{
|
||||
{CreditsStartMs: 40*60*1000 + 51*1000, Confidence: 0.92},
|
||||
{CreditsStartMs: 40*60*1000 + 47*1000, Confidence: 0.90},
|
||||
{CreditsStartMs: 40*60*1000 + 50*1000, Confidence: 0.94},
|
||||
}
|
||||
runtimeOf := func(Marker) int64 { return bbRuntime }
|
||||
|
||||
generic := GenericTailWindow(bbRuntime)
|
||||
narrowed := NarrowWindow(bbRuntime, history, runtimeOf, BehaviourEvidence{})
|
||||
|
||||
if narrowed.Source != WindowSeason {
|
||||
t.Fatalf("source = %q, want %q", narrowed.Source, WindowSeason)
|
||||
}
|
||||
if narrowed.DurationMs() >= generic.DurationMs() {
|
||||
t.Fatalf("season history did not narrow anything: %v vs generic %v",
|
||||
narrowed.DurationMs(), generic.DurationMs())
|
||||
}
|
||||
// The brief's example expects roughly 39:30–42:00 for an expected 40:45.
|
||||
expected := int64(40*60+50) * 1000
|
||||
if narrowed.StartMs > expected || narrowed.EndMs < expected {
|
||||
t.Fatalf("window %v–%v does not contain the expected position %v",
|
||||
narrowed.StartMs, narrowed.EndMs, expected)
|
||||
}
|
||||
}
|
||||
|
||||
// The rule that stops one wrong marker propagating through a whole season.
|
||||
func TestASingleMarkerIsNotEnoughToNarrow(t *testing.T) {
|
||||
history := []Marker{{CreditsStartMs: 40*60*1000 + 51*1000, Confidence: 0.95}}
|
||||
window := NarrowWindow(bbRuntime, history, func(Marker) int64 { return bbRuntime },
|
||||
BehaviourEvidence{})
|
||||
if window.Source != WindowGeneric {
|
||||
t.Fatalf("source = %q; one marker is an anecdote, not a pattern", window.Source)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInconsistentSeasonFallsBackToTheGenericTail(t *testing.T) {
|
||||
// A show whose known markers are minutes apart is telling us its episodes are not
|
||||
// structurally alike, so their average points nowhere useful.
|
||||
history := []Marker{
|
||||
{CreditsStartMs: 36 * 60 * 1000, Confidence: 0.9},
|
||||
{CreditsStartMs: 41 * 60 * 1000, Confidence: 0.9},
|
||||
{CreditsStartMs: 39 * 60 * 1000, Confidence: 0.9},
|
||||
}
|
||||
window := NarrowWindow(bbRuntime, history, func(Marker) int64 { return bbRuntime },
|
||||
BehaviourEvidence{})
|
||||
if window.Source != WindowGeneric {
|
||||
t.Fatalf("source = %q, want the generic fallback", window.Source)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLowConfidenceHistoryDoesNotSteerAScan(t *testing.T) {
|
||||
history := []Marker{
|
||||
{CreditsStartMs: 40*60*1000 + 51*1000, Confidence: 0.4},
|
||||
{CreditsStartMs: 40*60*1000 + 47*1000, Confidence: 0.3},
|
||||
}
|
||||
window := NarrowWindow(bbRuntime, history, func(Marker) int64 { return bbRuntime },
|
||||
BehaviourEvidence{})
|
||||
if window.Source != WindowGeneric {
|
||||
t.Fatalf("source = %q; a doubtful marker must not decide where everything else looks",
|
||||
window.Source)
|
||||
}
|
||||
}
|
||||
|
||||
// Behaviour is a direct observation of this episode, where season history is an inference
|
||||
// from its neighbours, so it wins.
|
||||
func TestBehaviourOutranksSeasonHistory(t *testing.T) {
|
||||
history := []Marker{
|
||||
{CreditsStartMs: 40*60*1000 + 51*1000, Confidence: 0.92},
|
||||
{CreditsStartMs: 40*60*1000 + 47*1000, Confidence: 0.90},
|
||||
}
|
||||
evidence := BehaviourEvidence{
|
||||
Found: true, StartMs: 41*60*1000 + 30*1000, UserCount: 3, SpreadMs: 3000,
|
||||
}
|
||||
window := NarrowWindow(bbRuntime, history, func(Marker) int64 { return bbRuntime }, evidence)
|
||||
if window.Source != WindowBehaviour {
|
||||
t.Fatalf("source = %q, want %q", window.Source, WindowBehaviour)
|
||||
}
|
||||
if window.StartMs > evidence.StartMs || window.EndMs < evidence.StartMs {
|
||||
t.Fatal("the window does not contain the observed cluster")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNarrowedWindowsCarryAMargin(t *testing.T) {
|
||||
evidence := BehaviourEvidence{
|
||||
Found: true, StartMs: 40 * 60 * 1000, UserCount: 3, SpreadMs: 2000,
|
||||
}
|
||||
window := NarrowWindow(bbRuntime, nil, nil, evidence)
|
||||
// Nothing here is precise enough to scan a single instant, and being slightly early is
|
||||
// the harmless direction — but the margin has to exist on both sides.
|
||||
if window.StartMs >= evidence.StartMs || window.EndMs <= evidence.StartMs {
|
||||
t.Fatalf("window %v–%v has no margin around %v",
|
||||
window.StartMs, window.EndMs, evidence.StartMs)
|
||||
}
|
||||
if window.DurationMs() > 2*narrowMargin.Milliseconds()+time.Second.Milliseconds() {
|
||||
t.Fatalf("narrowed window is %v; wider than the margin allows", window.DurationMs())
|
||||
}
|
||||
}
|
||||
@@ -593,6 +593,21 @@ func (c *Client) StreamURL(cred Credentials, itemID string) string {
|
||||
return fmt.Sprintf("%s/Videos/%s/stream?%s", c.publicURL, url.PathEscape(itemID), params.Encode())
|
||||
}
|
||||
|
||||
// InternalStreamURL is the same file, addressed the way the *gateway* reaches Emby rather
|
||||
// than the way a television does.
|
||||
//
|
||||
// It exists for credits detection, which is the only thing that reads media bytes on the
|
||||
// server side. Using StreamURL there would send a ranged read out to the public address and
|
||||
// back in through the reverse proxy — the two containers are on the same network, and a scan
|
||||
// has no business leaving it. Nothing here is ever handed to a client.
|
||||
func (c *Client) InternalStreamURL(cred Credentials, itemID string) string {
|
||||
params := url.Values{}
|
||||
params.Set("static", "true")
|
||||
params.Set("api_key", cred.Token)
|
||||
params.Set("DeviceId", cred.DeviceID)
|
||||
return fmt.Sprintf("%s/Videos/%s/stream?%s", c.baseURL, url.PathEscape(itemID), params.Encode())
|
||||
}
|
||||
|
||||
// DeliveryURL converts a PlaybackInfo URL into a TV-reachable, authenticated URL.
|
||||
func (c *Client) DeliveryURL(cred Credentials, delivery string) string {
|
||||
delivery = strings.TrimSpace(delivery)
|
||||
|
||||
@@ -328,8 +328,10 @@ func (b *Buffer) append(event Event) {
|
||||
b.events = append(b.events, event)
|
||||
}
|
||||
if b.history != nil {
|
||||
ordered := b.orderedEventsLocked()
|
||||
_ = b.history.append(event, ordered)
|
||||
// append asks for the ordered ring only when a compaction is actually due.
|
||||
// Previously this copied every retained event for every log line, even though
|
||||
// 4,999 out of 5,000 appends only write the new JSONL record.
|
||||
_ = b.history.append(event, b)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,11 +343,13 @@ func (b *Buffer) orderedEventsLocked() []Event {
|
||||
return ordered
|
||||
}
|
||||
|
||||
func (h *historyFile) append(event Event, retained []Event) error {
|
||||
// append is called while buffer.mu is held. This lock order is always buffer then history;
|
||||
// compaction may therefore read the already-locked ring without another lock or snapshot.
|
||||
func (h *historyFile) append(event Event, buffer *Buffer) error {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if event.Sequence-h.lastCompacted >= int64(h.capacity) {
|
||||
if err := h.rewriteLocked(retained); err != nil {
|
||||
if err := h.rewriteLocked(buffer.orderedEventsLocked()); err != nil {
|
||||
return err
|
||||
}
|
||||
h.lastCompacted = event.Sequence
|
||||
@@ -419,13 +423,12 @@ func (b *Buffer) Events(after int64, limit int) EventPage {
|
||||
page.Dropped = page.Oldest - after - 1
|
||||
after = page.Oldest - 1
|
||||
}
|
||||
start := len(b.events)
|
||||
for i := range b.events {
|
||||
event := b.events[(b.start+i)%len(b.events)]
|
||||
if event.Sequence > after {
|
||||
start = i
|
||||
break
|
||||
}
|
||||
// Sequences are contiguous, so cursor-to-ring position is arithmetic. The old scan
|
||||
// walked the full ring on every no-news poll—the most common request this endpoint
|
||||
// receives while an operator has the log open.
|
||||
start := 0
|
||||
if after >= page.Oldest {
|
||||
start = int(min(after-page.Oldest+1, int64(len(b.events))))
|
||||
}
|
||||
end := min(start+limit, len(b.events))
|
||||
for i := start; i < end; i++ {
|
||||
|
||||
@@ -2,6 +2,7 @@ package logging
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -138,6 +139,22 @@ func TestBufferedLoggerRetainsStructuredEventsWithCursorPagination(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTailCursorReadDoesNotCopyTheRing(t *testing.T) {
|
||||
logger, buffer := NewBuffered(io.Discard, slog.LevelInfo, 5_000, FormatConsole)
|
||||
for range 5_000 {
|
||||
logger.Info("request complete")
|
||||
}
|
||||
allocations := testing.AllocsPerRun(100, func() {
|
||||
page := buffer.Events(5_000, 1_000)
|
||||
if len(page.Events) != 0 || page.HasMore {
|
||||
t.Fatalf("tail cursor unexpectedly returned events: %+v", page)
|
||||
}
|
||||
})
|
||||
if allocations > 2 {
|
||||
t.Fatalf("tail cursor allocated %.1f objects; the ring may be getting copied", allocations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersistentBufferRestoresTheRetainedTail(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "events.jsonl")
|
||||
var output bytes.Buffer
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// The database half of credits marking.
|
||||
//
|
||||
// Four queries, and the shape of each one is chosen to keep the promise the subsystem makes
|
||||
// about database activity: a settled household reads one indexed row per candidate and
|
||||
// writes nothing at all. Nothing here is written per candidate, per queue transition or per
|
||||
// scan attempt — only a finished marker.
|
||||
|
||||
// CreditsMarkerRow is one stored marker.
|
||||
type CreditsMarkerRow struct {
|
||||
ItemID string
|
||||
MediaFingerprint string
|
||||
CreditsStartMs int64
|
||||
Confidence float64
|
||||
DetectionMethod string
|
||||
SeriesID string
|
||||
Season int
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// CreditsMarker reads the marker for one media version. Absence is an ordinary answer.
|
||||
func (s *Store) CreditsMarker(
|
||||
ctx context.Context, itemID, fingerprint string,
|
||||
) (CreditsMarkerRow, bool, error) {
|
||||
var row CreditsMarkerRow
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT item_id, media_fingerprint, credits_start_ms, confidence, detection_method,
|
||||
series_id, season_number, created_at, updated_at
|
||||
FROM credits_markers
|
||||
WHERE item_id = $1 AND media_fingerprint = $2`, itemID, fingerprint).
|
||||
Scan(&row.ItemID, &row.MediaFingerprint, &row.CreditsStartMs, &row.Confidence,
|
||||
&row.DetectionMethod, &row.SeriesID, &row.Season, &row.CreatedAt, &row.UpdatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return CreditsMarkerRow{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return CreditsMarkerRow{}, false, fmt.Errorf("store: credits marker: %w", err)
|
||||
}
|
||||
return row, true, nil
|
||||
}
|
||||
|
||||
// SaveCreditsMarker upserts one marker. This is the single write the whole subsystem makes,
|
||||
// and the caller has already decided that the new evidence is worth it — the stability rule
|
||||
// lives in the credits package beside the confidence model it depends on, not here.
|
||||
//
|
||||
// created_at is preserved on conflict so a marker's age remains the age of the finding rather
|
||||
// than of the last time something confirmed it.
|
||||
func (s *Store) SaveCreditsMarker(ctx context.Context, row CreditsMarkerRow) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO credits_markers (
|
||||
item_id, media_fingerprint, credits_start_ms, confidence, detection_method,
|
||||
series_id, season_number, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, now(), now())
|
||||
ON CONFLICT (item_id, media_fingerprint) DO UPDATE SET
|
||||
credits_start_ms = EXCLUDED.credits_start_ms,
|
||||
confidence = EXCLUDED.confidence,
|
||||
detection_method = EXCLUDED.detection_method,
|
||||
series_id = EXCLUDED.series_id,
|
||||
season_number = EXCLUDED.season_number,
|
||||
updated_at = now()`,
|
||||
row.ItemID, row.MediaFingerprint, row.CreditsStartMs, row.Confidence,
|
||||
row.DetectionMethod, row.SeriesID, row.Season)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: save credits marker: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreditsSeasonMarkers reads what is already known about a season, best evidence first.
|
||||
//
|
||||
// This is the single most valuable query in the subsystem. Credits within a season begin at
|
||||
// a consistent point, so two decided episodes turn the next one's ten-minute tail scan into a
|
||||
// three-minute one — which is most of the difference between a feature that is affordable on
|
||||
// a NAS and one that is not.
|
||||
func (s *Store) CreditsSeasonMarkers(
|
||||
ctx context.Context, seriesID string, season, limit int,
|
||||
) ([]CreditsMarkerRow, error) {
|
||||
if seriesID == "" || limit <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT item_id, media_fingerprint, credits_start_ms, confidence, detection_method,
|
||||
series_id, season_number, created_at, updated_at
|
||||
FROM credits_markers
|
||||
WHERE series_id = $1 AND season_number = $2
|
||||
ORDER BY confidence DESC, updated_at DESC
|
||||
LIMIT $3`, seriesID, season, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: credits season markers: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]CreditsMarkerRow, 0, limit)
|
||||
for rows.Next() {
|
||||
var row CreditsMarkerRow
|
||||
if err := rows.Scan(&row.ItemID, &row.MediaFingerprint, &row.CreditsStartMs,
|
||||
&row.Confidence, &row.DetectionMethod, &row.SeriesID, &row.Season,
|
||||
&row.CreatedAt, &row.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// CreditsWatchRow is one episode one viewer played, as candidate generation needs it.
|
||||
type CreditsWatchRow struct {
|
||||
UserKey string
|
||||
SeriesID string
|
||||
Season int
|
||||
Episode int
|
||||
WatchedAt time.Time
|
||||
Completed bool
|
||||
}
|
||||
|
||||
// CreditsRecentWatches is the demand signal, and the whole of it: one indexed read of
|
||||
// sessions Tracearr has already imported.
|
||||
//
|
||||
// Nothing is written here and no new ingestion exists — the For You import already maintains
|
||||
// this table and already resolves its rows to Emby ids. That reuse is why demand-driven
|
||||
// candidate generation costs the gateway a single query every ten minutes rather than a
|
||||
// second Tracearr integration.
|
||||
//
|
||||
// Episodes only, and only where the series resolved to something in Emby: a session that
|
||||
// could not be matched cannot produce a scannable candidate, and filtering in SQL keeps the
|
||||
// unmatched majority of an old library out of Go entirely.
|
||||
func (s *Store) CreditsRecentWatches(
|
||||
ctx context.Context, since time.Time, limit int,
|
||||
) ([]CreditsWatchRow, error) {
|
||||
if limit <= 0 {
|
||||
limit = 500
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT
|
||||
coalesce(nullif(tracearr_user_id, ''), lower(username)) AS user_key,
|
||||
emby_series_id,
|
||||
coalesce(season_number, 0),
|
||||
coalesce(episode_number, 0),
|
||||
coalesce(stopped_at, started_at) AS watched_at,
|
||||
watched OR (total_duration_ms > 0
|
||||
AND progress_ms::float8 / total_duration_ms::float8 >= 0.9) AS completed
|
||||
FROM tracearr_sessions
|
||||
WHERE lower(media_type) = 'episode'
|
||||
AND emby_series_id <> ''
|
||||
AND episode_number IS NOT NULL AND episode_number > 0
|
||||
AND coalesce(stopped_at, started_at) >= $1
|
||||
AND coalesce(nullif(tracearr_user_id, ''), lower(username)) <> ''
|
||||
ORDER BY coalesce(stopped_at, started_at) DESC
|
||||
LIMIT $2`, since.UTC(), limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: credits recent watches: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]CreditsWatchRow, 0, 64)
|
||||
for rows.Next() {
|
||||
var row CreditsWatchRow
|
||||
if err := rows.Scan(&row.UserKey, &row.SeriesID, &row.Season,
|
||||
&row.Episode, &row.WatchedAt, &row.Completed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// CreditsStopRow is one viewer leaving one episode.
|
||||
type CreditsStopRow struct {
|
||||
UserKey string
|
||||
PositionMs int64
|
||||
RuntimeMs int64
|
||||
NextEpisode bool
|
||||
}
|
||||
|
||||
// CreditsStops reads where the household stopped one episode.
|
||||
//
|
||||
// The behavioural detector's entire input, and it needs no new table: Tracearr already
|
||||
// records progress and completion per session. NextEpisode is derived rather than stored —
|
||||
// a session for the following episode of the same series starting within a couple of minutes
|
||||
// of this one ending is an auto-advance, which is the strongest form of this signal because
|
||||
// it says the viewer was unambiguously looking at credits rather than deciding to stop.
|
||||
func (s *Store) CreditsStops(ctx context.Context, itemID string) ([]CreditsStopRow, error) {
|
||||
if itemID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
WITH plays AS (
|
||||
SELECT
|
||||
coalesce(nullif(tracearr_user_id, ''), lower(username)) AS user_key,
|
||||
emby_series_id,
|
||||
season_number,
|
||||
episode_number,
|
||||
progress_ms,
|
||||
total_duration_ms,
|
||||
coalesce(stopped_at, started_at) AS ended_at
|
||||
FROM tracearr_sessions
|
||||
WHERE emby_item_id = $1
|
||||
AND progress_ms > 0
|
||||
AND total_duration_ms > 0
|
||||
AND coalesce(nullif(tracearr_user_id, ''), lower(username)) <> ''
|
||||
)
|
||||
SELECT
|
||||
plays.user_key,
|
||||
plays.progress_ms,
|
||||
plays.total_duration_ms,
|
||||
EXISTS (
|
||||
SELECT 1 FROM tracearr_sessions following
|
||||
WHERE following.emby_series_id = plays.emby_series_id
|
||||
AND coalesce(nullif(following.tracearr_user_id, ''), lower(following.username))
|
||||
= plays.user_key
|
||||
AND following.season_number = plays.season_number
|
||||
AND following.episode_number = plays.episode_number + 1
|
||||
AND following.started_at BETWEEN plays.ended_at - interval '30 seconds'
|
||||
AND plays.ended_at + interval '3 minutes'
|
||||
) AS next_episode
|
||||
FROM plays
|
||||
LIMIT 200`, itemID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: credits stops: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]CreditsStopRow, 0, 16)
|
||||
for rows.Next() {
|
||||
var row CreditsStopRow
|
||||
if err := rows.Scan(&row.UserKey, &row.PositionMs, &row.RuntimeMs,
|
||||
&row.NextEpisode); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// CreditsEpisodeRow is one episode's position in its series.
|
||||
type CreditsEpisodeRow struct {
|
||||
ItemID string
|
||||
SeriesID string
|
||||
Season int
|
||||
Episode int
|
||||
}
|
||||
|
||||
// CreditsSeriesEpisodes reads the numbering of every episode of the given series.
|
||||
//
|
||||
// One query for the handful of series a household is currently watching, rather than a
|
||||
// lookup per candidate. The result becomes an in-memory index, so the look-ahead arithmetic
|
||||
// — including stepping across a season boundary, which is exactly when somebody is most
|
||||
// likely to keep going — is pure and testable with no database at all.
|
||||
func (s *Store) CreditsSeriesEpisodes(
|
||||
ctx context.Context, seriesIDs []string,
|
||||
) ([]CreditsEpisodeRow, error) {
|
||||
if len(seriesIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT
|
||||
id,
|
||||
series_id,
|
||||
coalesce((payload->>'ParentIndexNumber')::int, 0),
|
||||
coalesce((payload->>'IndexNumber')::int, 0)
|
||||
FROM library_items
|
||||
WHERE type = 'Episode'
|
||||
AND series_id = ANY($1)
|
||||
AND payload->>'IndexNumber' IS NOT NULL`, seriesIDs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: credits series episodes: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]CreditsEpisodeRow, 0, 128)
|
||||
for rows.Next() {
|
||||
var row CreditsEpisodeRow
|
||||
if err := rows.Scan(&row.ItemID, &row.SeriesID, &row.Season, &row.Episode); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if row.Episode <= 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -18,9 +18,21 @@ type UserShow struct {
|
||||
type NotificationPreferences struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
ShowReturnAlerts bool `json:"showReturnAlerts"`
|
||||
SonarrAlerts bool `json:"sonarrAlerts"`
|
||||
RadarrAlerts bool `json:"radarrAlerts"`
|
||||
UpdateAlerts bool `json:"updateAlerts"`
|
||||
LibraryAlerts bool `json:"libraryAlerts"`
|
||||
SystemAlerts bool `json:"systemAlerts"`
|
||||
LeadDays int `json:"leadDays"`
|
||||
}
|
||||
|
||||
func DefaultNotificationPreferences() NotificationPreferences {
|
||||
return NotificationPreferences{
|
||||
Enabled: true, ShowReturnAlerts: true, SonarrAlerts: true, RadarrAlerts: true,
|
||||
UpdateAlerts: true, LibraryAlerts: true, SystemAlerts: true, LeadDays: 7,
|
||||
}
|
||||
}
|
||||
|
||||
type UserNotification struct {
|
||||
ID int64 `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
@@ -195,11 +207,13 @@ func (s *Store) RecordSonarrSeriesStatuses(
|
||||
}
|
||||
|
||||
func (s *Store) NotificationPreferences(ctx context.Context, userID string) (NotificationPreferences, error) {
|
||||
prefs := NotificationPreferences{Enabled: true, ShowReturnAlerts: true, LeadDays: 7}
|
||||
prefs := DefaultNotificationPreferences()
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT enabled, show_return_alerts, lead_days
|
||||
SELECT enabled, show_return_alerts, sonarr_alerts, radarr_alerts,
|
||||
update_alerts, library_alerts, system_alerts, lead_days
|
||||
FROM user_notification_preferences WHERE emby_user_id = $1`, userID).
|
||||
Scan(&prefs.Enabled, &prefs.ShowReturnAlerts, &prefs.LeadDays)
|
||||
Scan(&prefs.Enabled, &prefs.ShowReturnAlerts, &prefs.SonarrAlerts, &prefs.RadarrAlerts,
|
||||
&prefs.UpdateAlerts, &prefs.LibraryAlerts, &prefs.SystemAlerts, &prefs.LeadDays)
|
||||
if err != nil && !isNoRows(err) {
|
||||
return prefs, fmt.Errorf("store: notification preferences: %w", err)
|
||||
}
|
||||
@@ -217,17 +231,47 @@ func (s *Store) SetNotificationPreferences(
|
||||
}
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO user_notification_preferences
|
||||
(emby_user_id, enabled, show_return_alerts, lead_days)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
(emby_user_id, enabled, show_return_alerts, sonarr_alerts, radarr_alerts,
|
||||
update_alerts, library_alerts, system_alerts, lead_days)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
ON CONFLICT (emby_user_id) DO UPDATE SET
|
||||
enabled = EXCLUDED.enabled,
|
||||
show_return_alerts = EXCLUDED.show_return_alerts,
|
||||
sonarr_alerts = EXCLUDED.sonarr_alerts,
|
||||
radarr_alerts = EXCLUDED.radarr_alerts,
|
||||
update_alerts = EXCLUDED.update_alerts,
|
||||
library_alerts = EXCLUDED.library_alerts,
|
||||
system_alerts = EXCLUDED.system_alerts,
|
||||
lead_days = EXCLUDED.lead_days,
|
||||
updated_at = now()`,
|
||||
userID, prefs.Enabled, prefs.ShowReturnAlerts, prefs.LeadDays)
|
||||
userID, prefs.Enabled, prefs.ShowReturnAlerts, prefs.SonarrAlerts, prefs.RadarrAlerts,
|
||||
prefs.UpdateAlerts, prefs.LibraryAlerts, prefs.SystemAlerts, prefs.LeadDays)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) AllNotificationPreferences(ctx context.Context) (map[string]NotificationPreferences, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT emby_user_id, enabled, show_return_alerts, sonarr_alerts, radarr_alerts,
|
||||
update_alerts, library_alerts, system_alerts, lead_days
|
||||
FROM user_notification_preferences`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list notification preferences: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
result := map[string]NotificationPreferences{}
|
||||
for rows.Next() {
|
||||
prefs := DefaultNotificationPreferences()
|
||||
var userID string
|
||||
if err := rows.Scan(&userID, &prefs.Enabled, &prefs.ShowReturnAlerts, &prefs.SonarrAlerts,
|
||||
&prefs.RadarrAlerts, &prefs.UpdateAlerts, &prefs.LibraryAlerts, &prefs.SystemAlerts,
|
||||
&prefs.LeadDays); err != nil {
|
||||
return nil, fmt.Errorf("store: scan notification preferences: %w", err)
|
||||
}
|
||||
result[userID] = prefs
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) UpsertNotification(
|
||||
ctx context.Context, userID, sourceKey, kind, itemID, title, message string, eventAt *time.Time,
|
||||
) error {
|
||||
|
||||
@@ -217,10 +217,24 @@ CREATE TABLE IF NOT EXISTS user_notification_preferences (
|
||||
emby_user_id TEXT PRIMARY KEY,
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
show_return_alerts BOOLEAN NOT NULL DEFAULT true,
|
||||
sonarr_alerts BOOLEAN NOT NULL DEFAULT true,
|
||||
radarr_alerts BOOLEAN NOT NULL DEFAULT true,
|
||||
update_alerts BOOLEAN NOT NULL DEFAULT true,
|
||||
library_alerts BOOLEAN NOT NULL DEFAULT true,
|
||||
system_alerts BOOLEAN NOT NULL DEFAULT true,
|
||||
lead_days INT NOT NULL DEFAULT 7 CHECK (lead_days BETWEEN 1 AND 30),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- CREATE TABLE IF NOT EXISTS does not add fields to an existing installation. These
|
||||
-- additive, permissive defaults make the feature safe to roll out without changing what
|
||||
-- any current viewer receives.
|
||||
ALTER TABLE user_notification_preferences ADD COLUMN IF NOT EXISTS sonarr_alerts BOOLEAN NOT NULL DEFAULT true;
|
||||
ALTER TABLE user_notification_preferences ADD COLUMN IF NOT EXISTS radarr_alerts BOOLEAN NOT NULL DEFAULT true;
|
||||
ALTER TABLE user_notification_preferences ADD COLUMN IF NOT EXISTS update_alerts BOOLEAN NOT NULL DEFAULT true;
|
||||
ALTER TABLE user_notification_preferences ADD COLUMN IF NOT EXISTS library_alerts BOOLEAN NOT NULL DEFAULT true;
|
||||
ALTER TABLE user_notification_preferences ADD COLUMN IF NOT EXISTS system_alerts BOOLEAN NOT NULL DEFAULT true;
|
||||
|
||||
-- Notifications are materialised so read/dismissed state follows the user to every TV.
|
||||
-- source_key is deterministic, preventing the same return date from being announced
|
||||
-- again whenever the app refreshes.
|
||||
@@ -696,3 +710,33 @@ CREATE TABLE IF NOT EXISTS media_report_actions (
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS media_report_actions_report_idx
|
||||
ON media_report_actions (report_id, created_at ASC);
|
||||
|
||||
-- Where an episode's closing credits begin, for the small number of episodes a household is
|
||||
-- actually about to watch. Written by internal/credits.
|
||||
--
|
||||
-- The primary key is the whole design. Keyed on the media *version* rather than on the item,
|
||||
-- so a file Sonarr replaces stops matching its old marker and becomes a scan candidate again
|
||||
-- with nothing having to notice the swap — no invalidation pass, no staleness check, and no
|
||||
-- possibility of a Skip Credits button positioned against a file that no longer exists.
|
||||
--
|
||||
-- Deliberately the only durable output of that subsystem. Queue state, candidate priorities
|
||||
-- and scan progress are all held in RAM and rebuilt from Tracearr on restart, because a
|
||||
-- persistent job scheduler would cost more writes than the scanning it coordinates.
|
||||
CREATE TABLE IF NOT EXISTS credits_markers (
|
||||
item_id TEXT NOT NULL,
|
||||
media_fingerprint TEXT NOT NULL,
|
||||
credits_start_ms BIGINT NOT NULL,
|
||||
confidence REAL NOT NULL DEFAULT 0,
|
||||
detection_method TEXT NOT NULL DEFAULT '',
|
||||
-- Only so a season can be read back in one query. That read is what narrows the next
|
||||
-- episode's scan from ten minutes of file to two.
|
||||
series_id TEXT NOT NULL DEFAULT '',
|
||||
season_number INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (item_id, media_fingerprint)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS credits_markers_season_idx
|
||||
ON credits_markers (series_id, season_number, confidence DESC)
|
||||
WHERE series_id <> '';
|
||||
|
||||
@@ -100,14 +100,18 @@ type HeroPlacementPolicy struct {
|
||||
PrimeSubtitle string `json:"primeSubtitle"`
|
||||
}
|
||||
|
||||
// HeroSchedule is resolved by the gateway for every home response. Times are UTC RFC3339;
|
||||
// weekdays use the local calendar day (Sunday=0) and an empty list means every day.
|
||||
// HeroSchedule is resolved by the gateway for every home response. An empty Frequency is
|
||||
// the original absolute start/end shape and remains valid. Daily and weekly schedules use
|
||||
// the gateway's local clock; a window such as 22:00–02:00 belongs to the day it starts.
|
||||
type HeroSchedule struct {
|
||||
ID string `json:"id"`
|
||||
ItemID string `json:"itemId"`
|
||||
StartAt time.Time `json:"startAt"`
|
||||
EndAt time.Time `json:"endAt"`
|
||||
Weekdays []int `json:"weekdays,omitempty"`
|
||||
Frequency string `json:"frequency,omitempty"`
|
||||
StartTime string `json:"startTime,omitempty"`
|
||||
EndTime string `json:"endTime,omitempty"`
|
||||
Priority int `json:"priority"`
|
||||
UserID string `json:"userId,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
@@ -205,9 +209,25 @@ func normalizeHeroPolicy(policy HeroPolicy) HeroPolicy {
|
||||
seenSchedules := map[string]bool{}
|
||||
for _, schedule := range policy.Schedules {
|
||||
schedule.ID, schedule.ItemID, schedule.UserID = strings.TrimSpace(schedule.ID), strings.TrimSpace(schedule.ItemID), strings.TrimSpace(schedule.UserID)
|
||||
if schedule.ID == "" || schedule.ItemID == "" || seenSchedules[schedule.ID] || !schedule.EndAt.After(schedule.StartAt) {
|
||||
schedule.Frequency = strings.ToLower(strings.TrimSpace(schedule.Frequency))
|
||||
if schedule.Frequency == "once" {
|
||||
// "once" is explicit in the new console; empty is the compatible legacy form.
|
||||
schedule.Frequency = ""
|
||||
}
|
||||
recurring := schedule.Frequency == "daily" || schedule.Frequency == "weekly"
|
||||
if schedule.ID == "" || schedule.ItemID == "" || seenSchedules[schedule.ID] ||
|
||||
(!recurring && !schedule.EndAt.After(schedule.StartAt)) {
|
||||
continue
|
||||
}
|
||||
if recurring {
|
||||
schedule.StartTime = normaliseHeroClock(schedule.StartTime)
|
||||
schedule.EndTime = normaliseHeroClock(schedule.EndTime)
|
||||
if schedule.StartTime == "" || schedule.EndTime == "" || schedule.StartTime == schedule.EndTime {
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
schedule.StartTime, schedule.EndTime = "", ""
|
||||
}
|
||||
seenSchedules[schedule.ID] = true
|
||||
if schedule.Priority < -1000 {
|
||||
schedule.Priority = -1000
|
||||
@@ -224,6 +244,9 @@ func normalizeHeroPolicy(policy HeroPolicy) HeroPolicy {
|
||||
}
|
||||
}
|
||||
schedule.Weekdays = weekdays
|
||||
if schedule.Frequency == "daily" {
|
||||
schedule.Weekdays = []int{}
|
||||
}
|
||||
placements := make([]string, 0, len(schedule.Placements))
|
||||
seenPlacements := map[string]bool{}
|
||||
for _, placement := range schedule.Placements {
|
||||
@@ -243,6 +266,14 @@ func normalizeHeroPolicy(policy HeroPolicy) HeroPolicy {
|
||||
return policy
|
||||
}
|
||||
|
||||
func normaliseHeroClock(value string) string {
|
||||
parsed, err := time.Parse("15:04", strings.TrimSpace(value))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return parsed.Format("15:04")
|
||||
}
|
||||
|
||||
func (s *Store) HeroPolicy(ctx context.Context) (HeroPolicy, error) {
|
||||
var raw []byte
|
||||
err := s.pool.QueryRow(ctx, `SELECT value FROM app_settings WHERE key = $1`, HeroPolicyKey).Scan(&raw)
|
||||
|
||||
@@ -84,6 +84,22 @@ func TestHeroSchedulesDefaultToHomeAndNormalisePlacementNames(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeroPolicyKeepsRecurringSchedulesWithoutAbsoluteDates(t *testing.T) {
|
||||
got := normalizeHeroPolicy(HeroPolicy{Schedules: []HeroSchedule{
|
||||
{ID: "daily", ItemID: "one", Frequency: "DAILY", StartTime: " 18:00 ", EndTime: "22:30", Enabled: true},
|
||||
{ID: "weekly", ItemID: "two", Frequency: "weekly", StartTime: "22:00", EndTime: "02:00", Weekdays: []int{5, 5, 7}, Enabled: true},
|
||||
}})
|
||||
if len(got.Schedules) != 2 {
|
||||
t.Fatalf("recurring schedules = %+v", got.Schedules)
|
||||
}
|
||||
if got.Schedules[0].Frequency != "daily" || len(got.Schedules[0].Weekdays) != 0 {
|
||||
t.Fatalf("daily schedule = %+v", got.Schedules[0])
|
||||
}
|
||||
if got.Schedules[1].StartTime != "22:00" || len(got.Schedules[1].Weekdays) != 1 || got.Schedules[1].Weekdays[0] != 5 {
|
||||
t.Fatalf("weekly schedule = %+v", got.Schedules[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMDBListSettingsAreOptionalAndNormalizeSources(t *testing.T) {
|
||||
defaults := DefaultMDBListSettings()
|
||||
if defaults.Enabled || defaults.APIKey != "" || len(defaults.Sources) == 0 {
|
||||
|
||||
Reference in New Issue
Block a user