From 38bf669db5a5b96b2e383246c13c02cc229df283 Mon Sep 17 00:00:00 2001 From: binarygeek119 <72405030+binarygeek119@users.noreply.github.com> Date: Mon, 13 Apr 2026 21:37:13 -0500 Subject: [PATCH 01/24] Add badges for Docker pulls and GitHub stars --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index f716d37..de0e1ca 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ https://hub.docker.com/r/binarygeek119/postarr binarygeek119/postarr:latest + ![Docker Pulls](https://img.shields.io/docker/pulls/petersem/posterr) ![Docker Image Size (tag)](https://img.shields.io/docker/image-size/petersem/posterr/latest?logo=docker) ![GitHub Stars](https://img.shields.io/github/stars/petersem/posterr?style=flat) From 0a816192c2708ef604018a44bdf2657f0fd260db Mon Sep 17 00:00:00 2001 From: binarygeek119 Date: Wed, 15 Apr 2026 18:55:41 -0500 Subject: [PATCH 02/24] feat: ads in poster deck, dedicated /ads with page-stay redirect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ads DB, /ads slideshow, adsPageStaySeconds (return to home posters), and Settings → Ads. Include Now Showing DB/views, poster sync retry/abort, TMDB backdrop fallback, new-features banner, Docker/config path updates, launch scripts, and README fork documentation. Local config databases and uploads remain untracked. Made-with: Cursor --- .gitignore | 7 + Dockerfile | 5 +- README.md | 97 +- classes/cards/CardType.js | 6 +- classes/cards/MediaCard.js | 222 +- classes/core/adsDb.js | 431 +++ classes/core/appPaths.js | 22 +- classes/core/cache.js | 14 +- classes/core/globalPage.js | 60 +- classes/core/nowShowingDb.js | 930 +++++++ classes/core/posterMetadataDb.js | 648 ++++- classes/core/posterSyncAbort.js | 39 + classes/core/posterSyncRetry.js | 200 ++ classes/core/settings.js | 566 +++- classes/core/tmdbBackdropFallback.js | 290 ++ classes/core/utility.js | 59 +- classes/custom/pictures.js | 28 +- classes/mediaservers/embyJellyfinBase.js | 1839 ++++++++++--- classes/mediaservers/kodi.js | 197 +- classes/mediaservers/plex.js | 263 +- config/.keep | 0 config/cache/.keep | 0 consts.js | 61 +- docker-compose.debug.yml | 2 +- docker-compose.media-servers.example.yml | 2 +- docker-compose.yml | 7 +- index.js | 3171 ++++++++++++++++++++-- launch-posterr.bat | 7 + launch-posterr.ps1 | 21 + launch-posterr.sh | 14 + myviews/ads.ejs | 529 ++++ myviews/header.ejs | 1 + myviews/index.ejs | 11 +- myviews/new-features-banner.ejs | 5 + myviews/now-showing.ejs | 1546 +++++++++++ myviews/posters/posters.ejs | 9 + myviews/settings-ads.ejs | 427 +++ myviews/settings-cache.ejs | 189 +- myviews/settings-now-showing.ejs | 329 +++ myviews/settings-sync.ejs | 123 +- myviews/settings-tmdb.ejs | 79 + myviews/settings.ejs | 192 +- package-lock.json | 100 +- package.json | 3 +- public/css/styles.css | 119 + test/posterMetadataDb.init.test.js | 45 +- 46 files changed, 12018 insertions(+), 897 deletions(-) create mode 100644 classes/core/adsDb.js create mode 100644 classes/core/nowShowingDb.js create mode 100644 classes/core/posterSyncAbort.js create mode 100644 classes/core/posterSyncRetry.js create mode 100644 classes/core/tmdbBackdropFallback.js delete mode 100644 config/.keep delete mode 100644 config/cache/.keep create mode 100644 launch-posterr.bat create mode 100644 launch-posterr.ps1 create mode 100644 launch-posterr.sh create mode 100644 myviews/ads.ejs create mode 100644 myviews/new-features-banner.ejs create mode 100644 myviews/now-showing.ejs create mode 100644 myviews/settings-ads.ejs create mode 100644 myviews/settings-now-showing.ejs create mode 100644 myviews/settings-tmdb.ejs diff --git a/.gitignore b/.gitignore index 859c0a2..6cf1f91 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,10 @@ !/public/custom/pictures/default !/public/custom/pictures/readme.txt !/public/custom/custom.css + +# Ads + custom pictures live under config/ (served at /custom/ads and /custom/pictures) +/config/ads/** +!/config/ads/.keep +/config/custom-pictures/** +!/config/custom-pictures/.keep +!/config/custom-pictures/default/.keep diff --git a/Dockerfile b/Dockerfile index 8da96f3..671fa95 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,8 +11,7 @@ ENV NODE_ENV=production # (see docker-compose.yml extra_hosts and docker-compose.media-servers.example.yml). # # Persist these on the host (see docker-compose.yml): -# /usr/src/app/config — settings -# /usr/src/app/config — settings + cache/ (poster DB, imagecache, mp3cache) +# /usr/src/app/config — settings.json, cache/, ads/, ads-view/, custom-pictures/, *.db, etc. WORKDIR /usr/src/app COPY ["package.json", "package-lock.json*", "npm-shrinkwrap.json*", "./"] @@ -20,7 +19,7 @@ RUN npm install --production --silent && mv node_modules ../ COPY . . -RUN mkdir -p config/cache/imagecache config/cache/mp3cache config/cache/randomthemes public/custom/pictures +RUN mkdir -p config/cache/imagecache config/cache/mp3cache config/cache/randomthemes config/ads config/ads-view config/custom-pictures/default public/custom EXPOSE 3000 HEALTHCHECK --interval=10s --timeout=5s --retries=3 --start-period=15s CMD node healthcheck.js > /dev/null || exit 1 diff --git a/README.md b/README.md index de0e1ca..077569c 100644 --- a/README.md +++ b/README.md @@ -2,33 +2,22 @@ ## Media display software for Plex, Jellyfin, Emby, Kodi, Sonarr, Radarr, and Readarr. (Just like the display screens in movie theatre foyers) > **About this repository** -> This is an **AI-assisted, modified** version of [Posterr](https://github.com/petersem/posterr). The **original developer(s) and the upstream project did the real work**—design, architecture, and years of maintenance. This tree is intentionally a **test / sandbox app** for another developer to practice extending Posterr-style features and to learn how to do that **the right way** (clear changes, tests, and eventual contribution back to the real project if desired). For production use, prefer **[upstream Posterr](https://github.com/petersem/posterr)**. - -my modded docker image -https://hub.docker.com/r/binarygeek119/postarr -binarygeek119/postarr:latest - - - -![Docker Pulls](https://img.shields.io/docker/pulls/petersem/posterr) -![Docker Image Size (tag)](https://img.shields.io/docker/image-size/petersem/posterr/latest?logo=docker) -![GitHub Stars](https://img.shields.io/github/stars/petersem/posterr?style=flat) -![Version](https://img.shields.io/github/package-json/v/petersem/posterr?logoColor=blue) -![GitHub last commit](https://img.shields.io/github/last-commit/petersem/posterr) +> This is an **AI-assisted fork** of [Posterr](https://github.com/petersem/posterr) by **binarygeek119**. Upstream owns the original design and core product; this repo adds the features in **New in this fork** below. +> **Source:** [github.com/binarygeek119/posterr](https://github.com/binarygeek119/posterr) · **Docker image:** [`binarygeek119/postarr`](https://hub.docker.com/r/binarygeek119/postarr) (`:latest`) + +![GitHub stars](https://img.shields.io/github/stars/binarygeek119/posterr?style=flat) +![Fork version](https://img.shields.io/github/package-json/v/binarygeek119/posterr?label=version&logoColor=blue) +![Last commit](https://img.shields.io/github/last-commit/binarygeek119/posterr) +![Docker Pulls](https://img.shields.io/docker/pulls/binarygeek119/postarr) +![Docker image size](https://img.shields.io/docker/image-size/binarygeek119/postarr/latest?logo=docker) ![Platforms](https://img.shields.io/badge/platform-docker-blue) -[![User Guide](https://img.shields.io/badge/user_guide-wiki-informational?logo=github)](https://github.com/petersem/posterr/wiki/Posterr-Configuration) +[![Upstream wiki](https://img.shields.io/badge/upstream-wiki-informational?logo=github)](https://github.com/petersem/posterr/wiki/Posterr-Configuration) ![Slides](https://github.com/petersem/posterr/blob/master/doco/posterr.jpg?raw=true) ![Awtrix](https://github.com/petersem/posterr/blob/master/doco/awtrix.gif?raw=true) -- Check [Here](https://github.com/petersem/posterr/wiki/Latest-changes) for the latest updates -- Visit the [wiki](https://github.com/petersem/posterr/wiki/Known-Issues) for more information on known issues. -- Visit the [Discord Group](https://discord.gg/TcnEkMEf9J) for discussions and limited support. -- **The default password is:** raidisnotabackup +**Documentation and community (upstream):** [Latest changes](https://github.com/petersem/posterr/wiki/Latest-changes) · [Known issues](https://github.com/petersem/posterr/wiki/Known-Issues) · [Discord](https://discord.gg/TcnEkMEf9J) (limited support). **Default settings password:** `raidisnotabackup` - > **IMPORTANT NOTE ON UPGRADES** - > - There are rare times that you will need to update settings. Check [here](https://github.com/petersem/posterr/wiki/Latest-changes) for detailed notes on each updated. - --- ## Features - Displays movies, shows, music poster for what's currently playing. @@ -48,10 +37,22 @@ binarygeek119/postarr:latest - Trivia Quiz (multiple selectable topics) - Support LED Matrix displays running Awtrix software - Display custom web pages as slides (if web page compatible) - **EXPERIMENTAL!** - - Rotate display -90° (for running on display devices, like Firesticks, which do not support portait rotation) + - Rotate display -90° (for running on display devices, like Firesticks, which do not support portrait rotation) - Post API (at '/api/sleep') to toggle sleep mode. (Pass in header values `'psw: your Posterr password'` and `'sleep: true|false'`) - Get API at the same endpoint will return the sleep status without any parsed parameters. - - Supports `CEC` control of monitor inconjunction with the Posterr `Sleep Timer`. + - Supports `CEC` control of a monitor together with the Posterr sleep timer. + - **TMDB “Now Showing”** list with dedicated **`/now-showing`** view, optional main-poster slides, showtimes, and library fill-in — details under **Display controls (this modified branch)** below. + +### New in this fork (recent) +These are additions on top of upstream behaviour (current fork **`package.json`** version is **2.0.0**). + +| Area | What changed | +|------|----------------| +| **Release notice** | After an upgrade, a **red banner** on the **home poster view**, **`/now-showing`**, and **settings** pages reminds you that new features shipped. It clears after you open **Settings** and click **Acknowledge there are new features**. Your choice is stored in `settings.json` (`newFeaturesAcknowledgedVersion`); the banner comes back when the app **version string** in `package.json` changes again (any **X.Y.Z** bump). | +| **Settings → About** | Shows that this build is a **binarygeek119** fork, **purely AI-modified**, with a link to **[github.com/binarygeek119/posterr](https://github.com/binarygeek119/posterr)**. Original author credits and upstream links stay in the same tab. | +| **Settings navigation** | **Sync**, **Cache**, **Now Showing**, and **TMDB API** pages use the **same sidebar and mobile icons** as each other, including **Debug** and **About**, so entries no longer disappear when you switch pages. | +| **Ads (main deck + `/settings/ads`)** | When **Enabled**, ad slides rotate with the home posters (**every *n* posters**, optional **only show ads**). Upload images, prices, and optional per-slide backgrounds; metadata in **`config/ads.db`**, files under **`config/ads`**. | +| **Dedicated `/ads` view** | Full-screen ad slideshow: **seconds per ad** advances each slide; **seconds on full `/ads` page before returning home** is separate (`0` = manual leave; otherwise **30–86400** seconds then redirect to **`/`**). Optional backdrop from **`config/ads-view`** (`**/custom/ads-view/**`). | ### Display controls (this modified branch) - Per-media poster toggles: @@ -67,15 +68,24 @@ binarygeek119/postarr:latest - Display artist portrait - Featured portrait modes can render a person/artist image as the main poster, show their name in the top banner, and show up to 5 related credits in the bottom line when metadata is available. - Now Screening/Now Playing support includes music and audiobooks (in addition to movies/TV) for supported servers. + - **Dedicated Now Showing view (`/now-showing`):** TMDB-backed list with **auto showtimes** spaced by **feature runtime + 10 minutes** between each listed time (manual times unchanged when you choose “manual” per title). Optional **library fillers** sample the same on-demand title pool as poster slides; fillers are excluded when they match a curated title (by normalized name). + - **Settings → Ads:** enable ads, spacing (**every *n* posters**), **only show ads**, currency, title outline on `/ads`, **seconds per ad** on `/ads` (slide rotation), **seconds on the full `/ads` page before returning home** (separate timer; returns to home posters at **`/`** when non-zero). Manage slides and optional per-slide backgrounds on **`/settings/ads`**; open the dedicated view at **`/ads`**. + - **Settings layout:** **Now Playing (media server)** and **Now Showing (main poster)** sections are ordered with **Now Playing first** in the accordion and related settings shortcuts. + - **Poster / library sync:** manual **full sync** and **abort** from **Settings → Sync**; optional **About sync** modal. Jellyfin/Emby library paging: optional env **`POSTERR_JELLYFIN_LIBRARY_PAGE_LIMIT`** (integer **50–500**, default **300**) for items per request during large library walks. + +### Free community custom posters +Community-sourced, open-use custom poster images for Posterr (and similar apps) live in **[binarygeek119/open-custom-posters](https://github.com/binarygeek119/open-custom-posters)**. Use them in your **`public/custom`** / Docker **`custom`** picture themes; open an issue to request more, or submit a pull request with your art in a folder named for your GitHub username. Discord: [open-custom-posters](https://discord.gg/AEhVjqX4Af). + --- ## Prerequisites ### Mandatory - Plex, Jellyfin, Emby, or Kodi (settings → server type; Kodi needs HTTP JSON-RPC enabled) ### Optional - - Sonarr v3.0 - - Radarr v4.3 - - Readarr v0.1.3 + - Sonarr + - Radarr + - Readarr (or Chaptarr-compatible book stack) + - **TMDB API key** (for Now Showing movie search, list, and artwork) — set in **Settings → TMDB API** or **`TMDB_API_KEY`** environment variable --- ## Installation @@ -87,8 +97,6 @@ Create the following directories in your docker folder: - ./docker/posterr/custom ```yaml -version: "3.8" - services: posterr: image: binarygeek119/postarr:latest @@ -99,7 +107,6 @@ services: volumes: - ./docker/posterr/config:/usr/src/app/config - ./docker/posterr/custom:/usr/src/app/public/custom - - ./docker/posterr/saved:/usr/src/app/saved ports: - 9876:3000 restart: unless-stopped @@ -115,7 +122,10 @@ The **`config`** volume is required not only for **`settings.json`**, but also f | Path (inside container) | Purpose | |-------------------------|---------| | `config/settings.json` | Posterr settings | -| `config/cache/posterr-poster-metadata.db` | Poster / library sync metadata (SQLite) | +| `config/posterr-poster-metadata.db` | Poster / library sync metadata (SQLite) | +| `config/now-showing.db` | TMDB **Now Showing** movie list (dedicated `/now-showing` screen and optional main-poster slides) | +| `config/ads.db` | **Ads** slide list (titles, prices, paths) for **`/ads`** and the main poster deck | +| `config/ads/` · `config/ads-view/` | Uploaded **ad images** and optional **full-page `/ads` backdrop** (served as **`/custom/ads/`** and **`/custom/ads-view/`**) | | `config/cache/imagecache/` | Downloaded posters, fan art, and related images | | `config/cache/mp3cache/` | Cached TV/movie theme MP3s | | `config/cache/randomthemes/` | Optional random theme storage | @@ -155,7 +165,7 @@ docker run -d --name posterr \ -e TZ=Australia/Brisbane \ --add-host=host.docker.internal:host-gateway \ --restart=always \ -petersem/posterr +binarygeek119/postarr:latest ``` On **Docker Engine 20.10+**, `--add-host=host.docker.internal:host-gateway` lets Posterr reach Plex/Jellyfin/Emby/Kodi running on the **host** (Linux). Omit if you only use container-to-container names on a custom network. @@ -165,13 +175,13 @@ On **Docker Engine 20.10+**, `--add-host=host.docker.internal:host-gateway` lets |--|--| |TZ|Your local timezone. Go to [wikipedia](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) and use the `TZ Database Name` value.| |/docker/posterr/config → `/usr/src/app/config`|**Required.** Holds `settings.json` **and** the **`cache/`** subtree (poster SQLite DB, `imagecache/`, `mp3cache/`). Use a persistent host directory so nothing is lost on container recreate.| -|/docker/posterr/custom|This is required for custom pictures (and other custom media in the future)| +|/docker/posterr/custom|Mount for **custom picture** themes under `public/custom` (omit if you do not use them)| |Ports|Change first part to a different port if needed. e.g. 9876:3000| -|BASEPATH|_"/path"_ Use this for reverse proxy setups which require a base path value. **This line can be left out, or value left blank** if you dont use alternate paths. | +|BASEPATH|`"/your-prefix"` for reverse proxies that serve Posterr under a subpath. Omit or leave empty if unused.| |extra_hosts `host.docker.internal`|Helps Posterr reach **Jellyfin, Emby, Kodi, or Plex on the Docker host** from inside the container (Linux). Requires Docker Engine **20.10+**. | ### Unraid - - Use the Posterr template in community apps. (Being replaced with new version) + - Install via **Docker** using the Compose or `docker run` examples above, or a Community Applications template if one matches this image. --- ## CEC Control script installation **(rPi only)** @@ -179,9 +189,9 @@ On **Docker Engine 20.10+**, `--add-host=host.docker.internal:host-gateway` lets --- ## Updates - - From v1.10.1, there will be a notice at the top of the settings screen informing you if you are running an old version. - - Use containrr/watchtower to auto-update Posterr in Docker environments - - Update in the usual way for Unraid + - **Settings** may show an **update available** notice when Posterr’s remote version check reports a **newer version** than this install (see startup logs / settings UI). + - **This fork:** after upgrading to a new **app version**, a **red “new features”** banner appears until **Settings → Acknowledge there are new features** (see **New in this fork**). + - Optional: [Watchtower](https://containrrr.dev/watchtower/) or your stack’s policy for container updates. --- ## Setup @@ -193,6 +203,10 @@ Get to the settings page in a number of ways: *The default password is:* **raidisnotabackup** +**Now Showing (this fork):** configure the TMDB movie list at **`/settings/now-showing`** (also linked from **Settings**). The public-style schedule view is at **`/now-showing`** (prefix with `BASEPATH` if you use a reverse-proxy base path). Set a TMDB API key in **Settings → TMDB API** or the **`TMDB_API_KEY`** environment variable. + +**Ads (this fork):** configure slides and timings at **`/settings/ads`**. The dedicated slideshow is at **`/ads`**; use **seconds per ad** for each slide and **seconds on full `/ads` page before returning home** to auto-navigate back to the home poster view when you want a timed lobby loop. + --- ## Possible Uses - Mount a monitor on your wall and showcase your home media setup @@ -214,17 +228,14 @@ Get to the settings page in a number of ways: > Please see the [Posterr Wiki](https://github.com/petersem/posterr/wiki/Posterr-Configuration) for more information. ---- -## Star History -[![Star History Chart](https://api.star-history.com/svg?repos=petersem/posterr,petersem/monocker,petersem/dockerholics,petersem/exportarr&type=Date)](https://star-history.com/#petersem/posterr&petersem/monocker&petersem/dockerholics&petersem/exportarr&Date) --- ## Troubleshooting Should you encounter a problem, the solution may be listed [HERE](https://github.com/petersem/posterr/wiki/Troubleshooting). --- ## Support - - There is no _'official'_ support for this product, however should you encounter issues, raise an issue on the github page. - - Limited support in [Discord](https://discord.gg/TcnEkMEf9J) + - **This fork:** open an issue on [binarygeek119/posterr](https://github.com/binarygeek119/posterr/issues). + - **Upstream Posterr:** [Discord](https://discord.gg/TcnEkMEf9J) (limited); issues on [petersem/posterr](https://github.com/petersem/posterr/issues) for vanilla Posterr. --- ### Support my efforts and continued development @@ -253,7 +264,7 @@ Posterr uses the following: --- ## Notice -> Posterr is dependant on third party applications and services. Some features may fail temporarily or permenantly if the dependancies are unavailable, or become incompatible for any technical or legal reason. This software comes with no warranty or guarantee of any kind. Images and themes that you download through Posterr may be copyrighted, and are the property of the respective copyright holders. +> Posterr depends on third-party applications and services. Some features may fail temporarily or permanently if those dependencies are unavailable or become incompatible. This software comes with no warranty. Images and themes you download may be copyrighted by their respective owners. --- ## License diff --git a/classes/cards/CardType.js b/classes/cards/CardType.js index 4afee3a..9ce77cc 100644 --- a/classes/cards/CardType.js +++ b/classes/cards/CardType.js @@ -8,13 +8,17 @@ class CardType { NowScreening: ["Now Screening", ""], OnDemand: ["On-demand", ""], ComingSoon: ["Coming Soon", ""], + /** TMDB scheduled list shown on the main poster carousel */ + NowShowingList: ["Now Showing", ""], Playing: ["Playing", ""], IFrame: ["", ""], Picture: ["Picture", ""], EBook: ["E-Book Release", ""], Trivia: ["Trivia Question", ""], RecentlyAdded: ["Recently Added", ""], - WebURL: ["WebURL", ""] + WebURL: ["WebURL", ""], + /** Promotional slide from ADS settings (main poster rotation + ads-only mode). */ + Ad: ["Ad", ""], }; } diff --git a/classes/cards/MediaCard.js b/classes/cards/MediaCard.js index a541431..c3288a4 100644 --- a/classes/cards/MediaCard.js +++ b/classes/cards/MediaCard.js @@ -21,7 +21,11 @@ class MediaCard { this.posterLibraryKind = ""; /** Plex/Jellyfin/Emby/Kodi on-demand library display name (for cache stats) */ this.posterLibraryLabel = ""; + /** True when this title comes from a library configured as 3D. */ + this.is3D = false; this.posterArtURL = ""; + /** Cached title logo / clearlogo (PNG under /imagecache/*-logo.png) when sync pulls it */ + this.posterLogoURL = ""; this.posterAR = ""; this.contentRating = ""; this.ratingColour = ""; @@ -61,6 +65,9 @@ class MediaCard { this.youtubeKey = ""; /** Comma-separated principal cast; shown when settings.showCast is true */ this.cast = ""; + /** First two billed names for compact on-demand pills (optional; falls back to splitting cast) */ + this.actor1 = ""; + this.actor2 = ""; /** Comma-separated directors; shown when settings.showDirectors is true */ this.directors = ""; /** Comma-separated authors; shown when settings.showAuthors is true */ @@ -83,6 +90,8 @@ class MediaCard { this.featuredDirectorCredits = []; this.featuredAuthorCredits = []; this.featuredArtistCredits = []; + /** Rich HTML for Ad card price/add-on badges in the footer strip */ + this.adPricingHtml = ""; } /** @@ -119,11 +128,17 @@ class MediaCard { let pauseMessage = ""; // set header/footer hidden values - // Keep music/book metadata visible in footer for on-demand cards. + // Keep metadata footer visible for on-demand music/books (legacy) and for on-demand + // movies/shows/episodes so rating, year, genre, studio, etc. pills can appear. const isMusicCard = this.mediaType === "album" || this.mediaType === "track"; const isBookCard = this.mediaType === "ebook" || this.mediaType === "audiobook"; - const keepMetaFooter = isMusicCard || isBookCard; + const isVideoOnDemand = + this.cardType[0] === "On-demand" && + (this.mediaType === "movie" || + this.mediaType === "episode" || + this.mediaType === "show"); + const keepMetaFooter = isMusicCard || isBookCard || isVideoOnDemand; if (hideTitle == "true" && this.cardType[0] == "On-demand" && !keepMetaFooter) { hiddenTitle = "hidden"; } @@ -144,6 +159,14 @@ class MediaCard { } } + if (this.cardType[0] == "Ad") { + if (hasArt == "true" && this.posterArtURL !== "") { + fullScreen = "fullscreenCustom"; + } else { + fullScreen = "fullscreen"; + } + } + if(this.cardType[0] == "Picture"){ pauseMessage = `
@@ -156,6 +179,10 @@ class MediaCard { // get custom card title let cardCustomTitle = this.cardType[1] !== "" ? this.cardType[1] : this.cardType[0]; + if (this.cardType[0] == "Ad") { + const t = String(this.title || "").trim(); + cardCustomTitle = t ? util.escapeHtml(t) : "Ad"; + } var decRemainingTime = this.runDuration - this.runProgress; var et = new Date(); @@ -217,6 +244,12 @@ class MediaCard { let yearPill = ""; let pagePill = ""; let endTimePill = ""; + let threeDPill = ""; + let genrePill = ""; + let libraryPill = ""; + let episodePill = ""; + let leadCastPill1 = ""; + let leadCastPill2 = ""; let castPill = ""; let directorPill = ""; let authorPill = ""; @@ -352,6 +385,8 @@ class MediaCard { } } + const isAdCard = this.cardType[0] == "Ad"; + const portraitStrip = (() => { const parts = []; const add = (on, url, cls) => { @@ -383,12 +418,78 @@ class MediaCard { ""; } + const genreStr = (() => { + const g = this.genre; + if (g == null) return ""; + if (Array.isArray(g)) { + return g + .map((x) => String(x || "").trim()) + .filter(Boolean) + .slice(0, 4) + .join(" · "); + } + const s = String(g).trim(); + if (!s) return ""; + return s + .split(/[,|]/) + .map((x) => x.trim()) + .filter(Boolean) + .slice(0, 4) + .join(" · "); + })(); + if (!(await util.isEmpty(genreStr))) { + genrePill = + "" + + util.escapeHtml(genreStr) + + ""; + } + + if (!(await util.isEmpty(this.posterLibraryLabel))) { + const is3dTitle = + this.is3D === true || String(this.is3D || "").toLowerCase() === "true"; + const libTrim = String(this.posterLibraryLabel).trim(); + // On-demand 3D: only the "3D" badge, not the library (e.g. "3D Movies"). + // On-demand non-3D movie: hide generic library names like "Movies" / "Films" (no extra pill). + const genericMovieLibraryPill = + this.mediaType === "movie" && + /^(movies?|films?)$/i.test(libTrim); + const suppressLibraryPill = + (isVideoOnDemand && is3dTitle) || + (isVideoOnDemand && !is3dTitle && genericMovieLibraryPill); + if (!suppressLibraryPill) { + libraryPill = + "" + + util.escapeHtml(libTrim.slice(0, 48)) + + ""; + } + } + + if ( + !(await util.isEmpty(this.episodeName)) && + this.mediaType === "episode" + ) { + episodePill = + "" + + util.escapeHtml(this.episodeName) + + ""; + } + + if (this.is3D === true || String(this.is3D || "").toLowerCase() === "true") { + threeDPill = "3D"; + } + if (!(await util.isEmpty(this.contentRating))) { + const ratingColourClass = this.ratingColour || "badge-dark"; + const crRaw = String(this.contentRating).trim(); + const crLabel = + isVideoOnDemand && crRaw && !/^(nr|unrated)$/i.test(crRaw) + ? "Rated " + util.escapeHtml(crRaw) + : util.escapeHtml(crRaw); contentRatingPill = "" + - this.contentRating + + crLabel + ""; } @@ -460,8 +561,15 @@ class MediaCard { } if (!(await util.isEmpty(this.rating))) { - ratingPill = - " " + this.rating + ""; + if (isVideoOnDemand) { + ratingPill = + "Audience " + + util.escapeHtml(String(this.rating).trim()) + + ""; + } else { + ratingPill = + " " + this.rating + ""; + } } if(this.cardType[0] == "Now Screening" || this.cardType[0] == "Playing") { @@ -469,7 +577,36 @@ class MediaCard { "End: " + endTime + ""; } - if (showCast === "true" && !(await util.isEmpty(this.cast))) { + if (isVideoOnDemand) { + let n1 = String(this.actor1 || "").trim(); + let n2 = String(this.actor2 || "").trim(); + if (!n1 || !n2) { + const parts = String(this.cast || "") + .split(",") + .map((x) => x.trim()) + .filter(Boolean); + if (!n1) n1 = parts[0] || ""; + if (!n2) n2 = parts[1] || ""; + } + if (n1) { + leadCastPill1 = + "" + + util.escapeHtml(n1) + + ""; + } + if (n2) { + leadCastPill2 = + "" + + util.escapeHtml(n2) + + ""; + } + } + + if ( + showCast === "true" && + !(await util.isEmpty(this.cast)) && + !isVideoOnDemand + ) { castPill = "Cast: " + util.escapeHtml(this.cast) + @@ -500,6 +637,59 @@ class MediaCard { ""; } + let tagDetailsHtml = ""; + if (isAdCard) { + tagDetailsHtml = this.adPricingHtml || ""; + } else if (isVideoOnDemand) { + tagDetailsHtml = + contentRatingPill + + ratingPill + + studioPill + + runTimePill + + leadCastPill1 + + leadCastPill2 + + genrePill + + yearPill + + threeDPill + + libraryPill + + resCodecPill + + networkPill + + audioCodecPill + + pagePill + + userPill + + devicePill + + ipPill + + episodePill + + endTimePill + + castPill + + directorPill + + authorPill + + albumArtistPill; + } else { + tagDetailsHtml = + contentRatingPill + + resCodecPill + + networkPill + + studioPill + + libraryPill + + audioCodecPill + + runTimePill + + pagePill + + ratingPill + + userPill + + devicePill + + ipPill + + yearPill + + genrePill + + threeDPill + + episodePill + + endTimePill + + castPill + + directorPill + + authorPill + + albumArtistPill; + } + // render data into html this.rendered = ` @@ -568,23 +758,7 @@ class MediaCard { displayedTagLine + `
` + - contentRatingPill + - resCodecPill + - networkPill + - studioPill + - audioCodecPill + - runTimePill + - pagePill + - ratingPill + - userPill + - devicePill + - ipPill + - yearPill + - endTimePill + - castPill + - directorPill + - authorPill + - albumArtistPill + + tagDetailsHtml + `
diff --git a/classes/core/adsDb.js b/classes/core/adsDb.js new file mode 100644 index 0000000..57d034f --- /dev/null +++ b/classes/core/adsDb.js @@ -0,0 +1,431 @@ +const fs = require("fs"); +const path = require("path"); +const { CONFIG_ROOT } = require("./appPaths"); + +const DB_FILE = path.join(CONFIG_ROOT, "ads.db"); + +/** + * Future ADS system DB. + * Stores metadata/config only; image/media bytes remain on disk cache paths. + */ +const SCHEMA_SQL = ` +CREATE TABLE IF NOT EXISTS ads_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT DEFAULT '', + media_path TEXT DEFAULT '', + background_media_path TEXT DEFAULT '', + price_addon REAL DEFAULT 0, + enabled INTEGER DEFAULT 1, + created_at TEXT DEFAULT '', + updated_at TEXT DEFAULT '' +); +CREATE TABLE IF NOT EXISTS ads_item_prices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ad_id INTEGER NOT NULL, + line_title TEXT DEFAULT '', + amount REAL, + sort_order INTEGER DEFAULT 0, + created_at TEXT DEFAULT '', + updated_at TEXT DEFAULT '', + FOREIGN KEY(ad_id) REFERENCES ads_items(id) ON DELETE CASCADE +); +CREATE TABLE IF NOT EXISTS ads_item_addons ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ad_id INTEGER NOT NULL, + line_title TEXT DEFAULT '', + amount REAL, + sort_order INTEGER DEFAULT 0, + created_at TEXT DEFAULT '', + updated_at TEXT DEFAULT '', + FOREIGN KEY(ad_id) REFERENCES ads_items(id) ON DELETE CASCADE +); +CREATE TABLE IF NOT EXISTS ads_settings ( + key TEXT PRIMARY KEY NOT NULL, + value TEXT DEFAULT '' +); +`; + +/** @type {any} */ +let _db = null; + +function assertDb() { + if (!_db) throw new Error("ADS DB not initialized"); +} + +function persistDb() { + assertDb(); + fs.mkdirSync(path.dirname(DB_FILE), { recursive: true }); + fs.writeFileSync(DB_FILE, Buffer.from(_db.export())); +} + +function ensureAdsSchemaColumns() { + assertDb(); + const existing = new Set(); + let changed = false; + const s = _db.prepare("PRAGMA table_info(ads_items)"); + while (s.step()) { + const row = s.getAsObject(); + existing.add(String(row.name || "").toLowerCase()); + } + s.free(); + if (!existing.has("background_media_path")) { + _db.run("ALTER TABLE ads_items ADD COLUMN background_media_path TEXT DEFAULT ''"); + changed = true; + } + if (!existing.has("price_addon")) { + _db.run("ALTER TABLE ads_items ADD COLUMN price_addon REAL DEFAULT 0"); + changed = true; + } + migrateLegacyPriceAddonColumnToAddonsTable(); + return changed; +} + +function migrateLegacyPriceAddonColumnToAddonsTable() { + assertDb(); + let migrated = false; + const now = new Date().toISOString(); + const sel = _db.prepare( + "SELECT id, price_addon FROM ads_items WHERE IFNULL(price_addon,0) > 0" + ); + while (sel.step()) { + const r = sel.getAsObject(); + const id = Number(r.id); + const amt = parseMoneyAmount(r.price_addon); + if (amt == null || amt <= 0) continue; + const chk = _db.prepare( + "SELECT COUNT(*) AS c FROM ads_item_addons WHERE ad_id=? LIMIT 1" + ); + chk.bind([id]); + chk.step(); + const cnt = Number(chk.getAsObject().c) || 0; + chk.free(); + if (cnt > 0) continue; + _db.run( + "INSERT INTO ads_item_addons (ad_id, line_title, amount, sort_order, created_at, updated_at) VALUES (?,?,?,?,?,?)", + [id, "Add-on", amt, 0, now, now] + ); + _db.run("UPDATE ads_items SET price_addon=0, updated_at=? WHERE id=?", [ + now, + id, + ]); + migrated = true; + } + sel.free(); + if (migrated) persistDb(); +} + +function parseMoneyAmount(value) { + if (value === undefined || value === null || value === "") return null; + const n = Number(value); + if (!Number.isFinite(n) || n < 0) return null; + return Math.round(n * 100) / 100; +} + +function rowToAdItem(r) { + return { + id: Number(r.id), + title: String(r.title || ""), + mediaPath: String(r.media_path || ""), + backgroundMediaPath: String(r.background_media_path || ""), + enabled: Number(r.enabled) === 1, + createdAt: String(r.created_at || ""), + updatedAt: String(r.updated_at || ""), + prices: [], + addons: [], + }; +} + +function sanitizeAddonLines(lines) { + if (!Array.isArray(lines)) return []; + return lines + .map((line, index) => { + const title = String(line && line.title != null ? line.title : "").trim(); + const amount = parseMoneyAmount(line && line.amount); + if (amount == null) return null; + return { + title, + amount, + sortOrder: Number.isFinite(Number(line && line.sortOrder)) + ? Math.max(0, parseInt(line.sortOrder, 10)) + : index, + }; + }) + .filter(Boolean); +} + +function sanitizePriceLines(lines) { + if (!Array.isArray(lines)) return []; + return lines + .map((line, index) => { + const title = String(line && line.title != null ? line.title : "").trim(); + const amount = parseMoneyAmount(line && line.amount); + if (!title && amount == null) return null; + return { + title, + amount, + sortOrder: Number.isFinite(Number(line && line.sortOrder)) + ? Math.max(0, parseInt(line.sortOrder, 10)) + : index, + }; + }) + .filter(Boolean); +} + +function listAds() { + assertDb(); + const out = []; + const s = _db.prepare("SELECT * FROM ads_items ORDER BY id ASC"); + while (s.step()) out.push(rowToAdItem(s.getAsObject())); + s.free(); + if (!out.length) return out; + const priceStmt = _db.prepare( + "SELECT id, ad_id, line_title, amount, sort_order FROM ads_item_prices ORDER BY ad_id ASC, sort_order ASC, id ASC" + ); + const byAdId = new Map(out.map((ad) => [ad.id, ad])); + while (priceStmt.step()) { + const row = priceStmt.getAsObject(); + const adId = Number(row.ad_id); + const ad = byAdId.get(adId); + if (!ad) continue; + ad.prices.push({ + id: Number(row.id), + title: String(row.line_title || ""), + amount: parseMoneyAmount(row.amount), + sortOrder: Number(row.sort_order) || 0, + }); + } + priceStmt.free(); + const addonStmt = _db.prepare( + "SELECT id, ad_id, line_title, amount, sort_order FROM ads_item_addons ORDER BY ad_id ASC, sort_order ASC, id ASC" + ); + while (addonStmt.step()) { + const row = addonStmt.getAsObject(); + const adId = Number(row.ad_id); + const ad = byAdId.get(adId); + if (!ad) continue; + ad.addons.push({ + id: Number(row.id), + title: String(row.line_title || ""), + amount: parseMoneyAmount(row.amount), + sortOrder: Number(row.sort_order) || 0, + }); + } + addonStmt.free(); + return out; +} + +function getAdById(id) { + assertDb(); + const nid = parseInt(id, 10); + if (!nid) return null; + const s = _db.prepare("SELECT * FROM ads_items WHERE id=? LIMIT 1"); + s.bind([nid]); + let row = null; + if (s.step()) row = rowToAdItem(s.getAsObject()); + s.free(); + if (!row) return null; + const ps = _db.prepare( + "SELECT id, line_title, amount, sort_order FROM ads_item_prices WHERE ad_id=? ORDER BY sort_order ASC, id ASC" + ); + ps.bind([nid]); + while (ps.step()) { + const p = ps.getAsObject(); + row.prices.push({ + id: Number(p.id), + title: String(p.line_title || ""), + amount: parseMoneyAmount(p.amount), + sortOrder: Number(p.sort_order) || 0, + }); + } + ps.free(); + const as = _db.prepare( + "SELECT id, line_title, amount, sort_order FROM ads_item_addons WHERE ad_id=? ORDER BY sort_order ASC, id ASC" + ); + as.bind([nid]); + while (as.step()) { + const a = as.getAsObject(); + row.addons.push({ + id: Number(a.id), + title: String(a.line_title || ""), + amount: parseMoneyAmount(a.amount), + sortOrder: Number(a.sort_order) || 0, + }); + } + as.free(); + return row; +} + +function createAd(input) { + assertDb(); + const now = new Date().toISOString(); + const title = String((input && input.title) || "").trim(); + const mediaPath = String((input && input.mediaPath) || "").trim(); + const backgroundMediaPath = String( + (input && input.backgroundMediaPath) || "" + ).trim(); + const enabled = input && input.enabled === false ? 0 : 1; + _db.run( + "INSERT INTO ads_items (title, media_path, background_media_path, price_addon, enabled, created_at, updated_at) VALUES (?,?,?,?,?,?,?)", + [title, mediaPath, backgroundMediaPath, 0, enabled, now, now] + ); + const rs = _db.exec("SELECT last_insert_rowid() AS id"); + const id = + rs && + rs[0] && + rs[0].values && + rs[0].values[0] && + parseInt(rs[0].values[0][0], 10); + persistDb(); + return id || 0; +} + +function updateAd(id, input) { + assertDb(); + const nid = parseInt(id, 10); + if (!nid) throw new Error("Invalid ad id"); + const current = getAdById(nid); + if (!current) throw new Error("Ad not found"); + const title = + input && Object.prototype.hasOwnProperty.call(input, "title") + ? String(input.title || "").trim() + : current.title; + const mediaPath = + input && Object.prototype.hasOwnProperty.call(input, "mediaPath") + ? String(input.mediaPath || "").trim() + : current.mediaPath; + const backgroundMediaPath = + input && Object.prototype.hasOwnProperty.call(input, "backgroundMediaPath") + ? String(input.backgroundMediaPath || "").trim() + : current.backgroundMediaPath; + const enabled = + input && Object.prototype.hasOwnProperty.call(input, "enabled") + ? input.enabled === false + ? 0 + : 1 + : current.enabled + ? 1 + : 0; + _db.run( + "UPDATE ads_items SET title=?, media_path=?, background_media_path=?, enabled=?, updated_at=? WHERE id=?", + [ + title, + mediaPath, + backgroundMediaPath, + enabled, + new Date().toISOString(), + nid, + ] + ); + persistDb(); +} + +function replaceAdPrices(id, lines) { + assertDb(); + const nid = parseInt(id, 10); + if (!nid) throw new Error("Invalid ad id"); + const clean = sanitizePriceLines(lines); + _db.run("DELETE FROM ads_item_prices WHERE ad_id=?", [nid]); + const now = new Date().toISOString(); + for (let i = 0; i < clean.length; i++) { + const line = clean[i]; + _db.run( + "INSERT INTO ads_item_prices (ad_id, line_title, amount, sort_order, created_at, updated_at) VALUES (?,?,?,?,?,?)", + [nid, line.title, line.amount, line.sortOrder != null ? line.sortOrder : i, now, now] + ); + } + _db.run("UPDATE ads_items SET updated_at=? WHERE id=?", [now, nid]); + persistDb(); +} + +function replaceAdAddons(id, lines) { + assertDb(); + const nid = parseInt(id, 10); + if (!nid) throw new Error("Invalid ad id"); + const clean = sanitizeAddonLines(lines); + _db.run("DELETE FROM ads_item_addons WHERE ad_id=?", [nid]); + const now = new Date().toISOString(); + for (let i = 0; i < clean.length; i++) { + const line = clean[i]; + _db.run( + "INSERT INTO ads_item_addons (ad_id, line_title, amount, sort_order, created_at, updated_at) VALUES (?,?,?,?,?,?)", + [nid, line.title, line.amount, line.sortOrder != null ? line.sortOrder : i, now, now] + ); + } + _db.run("UPDATE ads_items SET updated_at=? WHERE id=?", [now, nid]); + persistDb(); +} + +function deleteAd(id) { + assertDb(); + const nid = parseInt(id, 10); + if (!nid) return; + _db.run("DELETE FROM ads_item_addons WHERE ad_id=?", [nid]); + _db.run("DELETE FROM ads_item_prices WHERE ad_id=?", [nid]); + _db.run("DELETE FROM ads_items WHERE id=?", [nid]); + persistDb(); +} + +function getSetting(key, fallbackValue) { + assertDb(); + const k = String(key || "").trim(); + if (!k) return fallbackValue; + const s = _db.prepare("SELECT value FROM ads_settings WHERE key=? LIMIT 1"); + s.bind([k]); + let value = fallbackValue; + if (s.step()) { + const row = s.getAsObject(); + value = row && row.value != null ? String(row.value) : fallbackValue; + } + s.free(); + return value; +} + +function setSetting(key, value) { + assertDb(); + const k = String(key || "").trim(); + if (!k) return; + _db.run( + "INSERT INTO ads_settings (key, value) VALUES (?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value", + [k, String(value == null ? "" : value)] + ); + persistDb(); +} + +async function initAdsDb() { + if (_db) return; + const initSqlJs = require("sql.js"); + const wasmPath = path.join( + path.dirname(require.resolve("sql.js/package.json")), + "dist", + "sql-wasm.wasm" + ); + const SQL = await initSqlJs({ locateFile: () => wasmPath }); + fs.mkdirSync(path.dirname(DB_FILE), { recursive: true }); + if (fs.existsSync(DB_FILE)) { + _db = new SQL.Database(fs.readFileSync(DB_FILE)); + } else { + _db = new SQL.Database(); + } + _db.exec(SCHEMA_SQL); + _db.run("PRAGMA foreign_keys = ON"); + const schemaChanged = ensureAdsSchemaColumns(); + if (!fs.existsSync(DB_FILE)) { + persistDb(); + } else if (schemaChanged) { + persistDb(); + } +} + +module.exports = { + DB_FILE, + initAdsDb, + listAds, + getAdById, + createAd, + updateAd, + replaceAdPrices, + replaceAdAddons, + deleteAd, + getSetting, + setSetting, +}; diff --git a/classes/core/appPaths.js b/classes/core/appPaths.js index b463e41..8afb871 100644 --- a/classes/core/appPaths.js +++ b/classes/core/appPaths.js @@ -1,12 +1,13 @@ const path = require("path"); const ROOT = process.cwd(); +const CONFIG_ROOT = path.join(ROOT, "config"); /** * Poster DB (SQLite), imagecache, mp3cache, randomthemes — under config/cache * (replaces the former top-level saved/ directory). */ -const CACHE_ROOT = path.join(ROOT, "config", "cache"); +const CACHE_ROOT = path.join(CONFIG_ROOT, "cache"); const IMAGE_CACHE_DIR = path.join(CACHE_ROOT, "imagecache"); const MP3_CACHE_DIR = path.join(CACHE_ROOT, "mp3cache"); const RANDOM_THEMES_DIR = path.join(CACHE_ROOT, "randomthemes"); @@ -14,11 +15,30 @@ const RANDOM_THEMES_DIR = path.join(CACHE_ROOT, "randomthemes"); /** Previous layout for one-time data migration */ const LEGACY_SAVED_ROOT = path.join(ROOT, "saved"); +/** + * Ad slide images. Canonical: config/ads (served at /custom/ads). + * Legacy public/custom/ads is cleared on startup after a one-time move into config/ads. + */ +const ADS_MEDIA_DIR = path.join(CONFIG_ROOT, "ads"); +/** + * Dedicated /ads page backdrop only. Canonical: config/ads-view (served at /custom/ads-view). + */ +const ADS_VIEW_BG_DIR = path.join(CONFIG_ROOT, "ads-view"); +/** + * Custom picture themes. Canonical: config/custom-pictures (served at /custom/pictures). + * Legacy public/custom/pictures is cleared after a one-time move into config/custom-pictures. + */ +const CUSTOM_PICTURES_DIR = path.join(CONFIG_ROOT, "custom-pictures"); + module.exports = { ROOT, + CONFIG_ROOT, CACHE_ROOT, IMAGE_CACHE_DIR, MP3_CACHE_DIR, RANDOM_THEMES_DIR, LEGACY_SAVED_ROOT, + ADS_MEDIA_DIR, + ADS_VIEW_BG_DIR, + CUSTOM_PICTURES_DIR, }; diff --git a/classes/core/cache.js b/classes/core/cache.js index fe115a3..b4634c9 100644 --- a/classes/core/cache.js +++ b/classes/core/cache.js @@ -35,6 +35,10 @@ class Cache { * @param {string} savePath absolute or relative path */ static downloadImageForce(url, savePath, options) { + const cleanUrl = String(url || "").trim(); + if (!cleanUrl || cleanUrl.includes("undefined") || cleanUrl.includes("null")) { + return Promise.resolve(false); + } const headers = options && options.headers; return new Promise((resolve, reject) => { try { @@ -43,7 +47,7 @@ class Cache { /* ignore */ } const ws = fs.createWriteStream(savePath, { autoClose: true }); - const req = headers ? request({ url, headers }) : request(url); + const req = headers ? request({ url: cleanUrl, headers }) : request(cleanUrl); req.on("error", (err) => { try { ws.destroy(); @@ -91,6 +95,10 @@ class Cache { * @returns {Promise} */ static download(url, savePath, options) { + const cleanUrl = String(url || "").trim(); + if (!cleanUrl || cleanUrl.includes("undefined") || cleanUrl.includes("null")) { + return Promise.resolve(false); + } if (fs.existsSync(savePath)) { return Promise.resolve(true); } @@ -102,7 +110,9 @@ class Cache { const headers = options && options.headers; return new Promise((resolve) => { const ws = fs.createWriteStream(savePath, { autoClose: true }); - const req = headers ? request({ url, headers }) : request(url); + const req = headers + ? request({ url: cleanUrl, headers }) + : request(cleanUrl); req.on("error", (err) => { try { ws.destroy(); diff --git a/classes/core/globalPage.js b/classes/core/globalPage.js index 5617933..f8bc99e 100644 --- a/classes/core/globalPage.js +++ b/classes/core/globalPage.js @@ -56,38 +56,34 @@ class globalPage { displayPosterArtist ) { if (this.cards.length != 0) { - let webID = 0; - // move through cards and update ID's and active, then render - await this.cards.reduce(async (memo, card) => { - await memo; - webID++; - card.ID = webID; - // set first card to be active for web carousel - if (card.ID == 1) { - card.active = "active"; - } else { - card.active = ""; - } - // console.log(card); - await card.Render( - hasArt, - baseUrl, - hideTitle, - hideFooter, - showCast, - showDirectors, - showAuthors, - showAlbumArtist, - displayPosterAlbum, - displayPosterVideo, - displayPosterBooks, - displayPosterActor, - displayPosterActress, - displayPosterDirector, - displayPosterAuthor, - displayPosterArtist - ); - }, undefined); + for (let i = 0; i < this.cards.length; i++) { + const card = this.cards[i]; + card.ID = i + 1; + card.active = i === 0 ? "active" : ""; + } + // Parallel render: each card builds its own HTML; order is already fixed by ID. + await Promise.all( + this.cards.map((card) => + card.Render( + hasArt, + baseUrl, + hideTitle, + hideFooter, + showCast, + showDirectors, + showAuthors, + showAlbumArtist, + displayPosterAlbum, + displayPosterVideo, + displayPosterBooks, + displayPosterActor, + displayPosterActress, + displayPosterDirector, + displayPosterAuthor, + displayPosterArtist + ) + ) + ); } return; } diff --git a/classes/core/nowShowingDb.js b/classes/core/nowShowingDb.js new file mode 100644 index 0000000..96b072b --- /dev/null +++ b/classes/core/nowShowingDb.js @@ -0,0 +1,930 @@ +const fs = require("fs"); +const path = require("path"); +const axios = require("axios"); +const Cache = require("./cache"); +const { CACHE_ROOT, CONFIG_ROOT } = require("./appPaths"); + +const DB_FILE = path.join(CONFIG_ROOT, "now-showing.db"); +const LEGACY_CACHE_DB_FILE = path.join(CACHE_ROOT, "now-showing.db"); +const TMDB_BASE = "https://api.themoviedb.org/3"; +const TMDB_IMAGE = "https://image.tmdb.org/t/p"; + +/** logo_url / banner_url store local /imagecache/... paths (image bytes stay on disk, never in DB). */ +const SCHEMA_SQL = ` +CREATE TABLE IF NOT EXISTS now_showing_movies ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tmdb_id INTEGER UNIQUE, + title TEXT NOT NULL, + year TEXT DEFAULT '', + logo_url TEXT DEFAULT '', + banner_url TEXT DEFAULT '', + overview TEXT DEFAULT '', + rating TEXT DEFAULT '', + plot TEXT DEFAULT '', + rating_score TEXT DEFAULT '', + rating_content TEXT DEFAULT '', + studio TEXT DEFAULT '', + top_cast TEXT DEFAULT '', + actor_1 TEXT DEFAULT '', + actor_2 TEXT DEFAULT '', + runtime_mins INTEGER DEFAULT 120, + content_rating TEXT DEFAULT '', + genres TEXT DEFAULT '', + showtime_mode TEXT DEFAULT 'auto', + manual_times TEXT DEFAULT '[]', + auto_showings INTEGER DEFAULT 4, + auto_seed_start TEXT DEFAULT '', + created_at TEXT DEFAULT '', + updated_at TEXT DEFAULT '' +); +`; +const EXTRA_SCHEMA_COLUMNS = [ + { name: "logo_cache_file", def: "TEXT DEFAULT ''" }, + { name: "banner_cache_file", def: "TEXT DEFAULT ''" }, + { name: "auto_schedule_day", def: "TEXT DEFAULT ''" }, + { name: "studio", def: "TEXT DEFAULT ''" }, + { name: "top_cast", def: "TEXT DEFAULT ''" }, + { name: "actor_1", def: "TEXT DEFAULT ''" }, + { name: "actor_2", def: "TEXT DEFAULT ''" }, + { name: "plot", def: "TEXT DEFAULT ''" }, + { name: "rating_score", def: "TEXT DEFAULT ''" }, + { name: "rating_content", def: "TEXT DEFAULT ''" }, + { + name: "auto_generated_showtimes_json", + def: "TEXT DEFAULT '[]'", + }, + { name: "price_amount", def: "REAL" }, + { name: "price_auto_generated", def: "INTEGER DEFAULT 0" }, +]; + +let _db = null; + +function assertDb() { + if (!_db) throw new Error("Now Showing DB not initialized"); +} + +function persistDb() { + assertDb(); + fs.mkdirSync(path.dirname(DB_FILE), { recursive: true }); + fs.writeFileSync(DB_FILE, Buffer.from(_db.export())); +} + +function ensureNowShowingExtraColumns() { + assertDb(); + const existing = new Set(); + let changed = false; + const s = _db.prepare("PRAGMA table_info(now_showing_movies)"); + while (s.step()) { + const row = s.getAsObject(); + existing.add(String(row.name || "").toLowerCase()); + } + s.free(); + for (const c of EXTRA_SCHEMA_COLUMNS) { + if (!existing.has(c.name.toLowerCase())) { + _db.run(`ALTER TABLE now_showing_movies ADD COLUMN ${c.name} ${c.def}`); + changed = true; + } + } + return changed; +} + +function parseJsonSafe(v, fallback) { + try { + return JSON.parse(v); + } catch (e) { + return fallback; + } +} + +function parsePriceAmount(v) { + if (v === undefined || v === null || v === "") return null; + const n = Number(v); + if (!Number.isFinite(n) || n < 0) return null; + return Math.round(n * 100) / 100; +} + +function clampPriceBound(v, fallback) { + const n = Number(v); + if (!Number.isFinite(n) || n < 0) return fallback; + return Math.round(n * 100) / 100; +} + +function randomPriceInRange(minRaw, maxRaw) { + const min = clampPriceBound(minRaw, 5); + const max = clampPriceBound(maxRaw, 20); + const lo = Math.min(min, max); + const hi = Math.max(min, max); + if (hi === lo) return lo; + return Math.round((lo + Math.random() * (hi - lo)) * 100) / 100; +} + +function parseReleaseYear(yearRaw) { + const y = parseInt(String(yearRaw == null ? "" : yearRaw).trim(), 10); + const current = new Date().getFullYear(); + if (!Number.isFinite(y) || y < 1888 || y > current + 1) return null; + return y; +} + +function autoPriceMultiplierForYear(yearRaw) { + const y = parseReleaseYear(yearRaw); + if (y == null) return 1; + const age = Math.max(0, new Date().getFullYear() - y); + if (age >= 60) return 0.8; // classic pricing + if (age >= 20) return 0.2; + if (age >= 10) return 0.5; + if (age >= 5) return 0.8; + return 1; +} + +function autoPriceFromAge(minRaw, maxRaw, yearRaw) { + const base = randomPriceInRange(minRaw, maxRaw); + const mult = autoPriceMultiplierForYear(yearRaw); + return Math.round(base * mult * 100) / 100; +} + +function nowShowingImageCacheFileNames(tmdbId) { + const tid = String(parseInt(tmdbId, 10) || 0); + return { + banner: `nowshowinglist-${tid}.jpg`, + logo: `nowshowinglist-${tid}-logo.png`, + }; +} + +function isLocalImageCachePath(v) { + const s = String(v || "").trim(); + return s.startsWith("/imagecache/"); +} + +async function toLocalNowShowingAssetPath(tmdbId, sourceUrl, kind) { + const src = String(sourceUrl || "").trim(); + if (!src) return ""; + if (isLocalImageCachePath(src)) return src; + if (!/^https?:\/\//i.test(src)) return ""; + const names = nowShowingImageCacheFileNames(tmdbId); + const fileName = kind === "logo" ? names.logo : names.banner; + try { + const ok = await Cache.CacheImage(src, fileName); + if (ok) return "/imagecache/" + fileName; + } catch (e) { + /* ignore */ + } + return ""; +} + +function rowFromDb(r) { + const logoCache = r.logo_cache_file || ""; + const bannerCache = r.banner_cache_file || ""; + const logoLegacy = r.logo_url || ""; + const bannerLegacy = r.banner_url || ""; + return { + id: Number(r.id), + tmdbId: r.tmdb_id != null ? Number(r.tmdb_id) : null, + title: r.title || "", + year: r.year || "", + logoUrl: logoCache || logoLegacy || "", + bannerUrl: bannerCache || bannerLegacy || "", + logoCacheFile: logoCache || "", + bannerCacheFile: bannerCache || "", + overview: r.overview || "", + plot: r.plot || r.overview || "", + rating: r.rating_score || r.rating || "", + ratingScore: r.rating_score || r.rating || "", + ratingContent: r.rating_content || r.content_rating || "", + studio: r.studio || "", + topCast: r.top_cast || "", + actor1: r.actor_1 || "", + actor2: r.actor_2 || "", + runtimeMins: Number(r.runtime_mins) || 120, + contentRating: r.rating_content || r.content_rating || "", + genres: r.genres || "", + showtimeMode: r.showtime_mode || "auto", + manualTimes: parseJsonSafe(r.manual_times || "[]", []), + autoShowings: Math.max(1, Math.min(6, Number(r.auto_showings) || 6)), + autoSeedStart: r.auto_seed_start || "", + autoScheduleDay: r.auto_schedule_day || "", + autoGeneratedShowtimes: parseJsonSafe( + r.auto_generated_showtimes_json || "[]", + [] + ), + priceAmount: parsePriceAmount(r.price_amount), + priceAutoGenerated: + Number(r.price_auto_generated) === 1 || String(r.price_auto_generated) === "1", + createdAt: r.created_at || "", + updatedAt: r.updated_at || "", + }; +} + +function allRows() { + assertDb(); + const out = []; + const s = _db.prepare("SELECT * FROM now_showing_movies ORDER BY title ASC"); + while (s.step()) out.push(rowFromDb(s.getAsObject())); + s.free(); + return out; +} + +/** Local calendar date key YYYY-MM-DD (server local timezone). */ +function localDateKey(d) { + const x = d instanceof Date ? d : new Date(d); + if (isNaN(x.getTime())) return ""; + const y = x.getFullYear(); + const m = String(x.getMonth() + 1).padStart(2, "0"); + const day = String(x.getDate()).padStart(2, "0"); + return `${y}-${m}-${day}`; +} + +function startOfLocalDay(d) { + const x = d instanceof Date ? new Date(d.getTime()) : new Date(d); + x.setHours(0, 0, 0, 0); + return x; +} + +/** First instant of the next local calendar day (exclusive upper bound for “this day”). */ +function endOfLocalDayExclusive(d) { + const s = startOfLocalDay(d); + s.setDate(s.getDate() + 1); + return s; +} + +/** Stable 0–89 minute offset after local midnight from title (per-title stagger). */ +function dayStaggerMinutes(title) { + let h = 0; + const s = String(title || ""); + for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0; + return h % 90; +} + +function firstAutoSlotForLocalDay(title, refNow) { + const ref = refNow instanceof Date ? refNow : new Date(refNow); + const dayStart = startOfLocalDay(ref); + return new Date(dayStart.getTime() + dayStaggerMinutes(title) * 60000); +} + +/** + * @param {string} [seedIso] First showtime of the day (local). + * @param {Date} [dayEndExclusive] If set, omit slots at or after this instant (next midnight). + */ +function generateShowtimes(seedIso, count, runtimeMins, dayEndExclusive) { + const seed = seedIso ? new Date(seedIso) : new Date(); + const rtRaw = Number(runtimeMins); + const rt = rtRaw > 0 && !isNaN(rtRaw) ? rtRaw : 120; + /** Minutes from one listed showtime start to the next (feature + turnaround). */ + const spacingMins = rt + 10; + const out = []; + let t = seed.getTime(); + const c = Number(count); + const n = Number.isFinite(c) + ? Math.max(1, Math.min(8, Math.floor(c))) + : 6; + const endMs = + dayEndExclusive instanceof Date && !isNaN(dayEndExclusive.getTime()) + ? dayEndExclusive.getTime() + : null; + for (let i = 0; i < n; i++) { + if (endMs != null && t >= endMs) break; + out.push(new Date(t)); + t += spacingMins * 60000; + } + return out; +} + +function formatTimes(dates) { + return dates.map((d) => + d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }) + ); +} + +/** + * Parse a manual showtime string (e.g. "2:30 PM", "14:30") as today in refNow's calendar day. + */ +function parseManualShowtimeToDate(str, refNow) { + const s = String(str || "").trim(); + if (!s) return null; + const ref = refNow instanceof Date ? refNow : new Date(refNow); + const y = ref.getFullYear(); + const mo = ref.getMonth(); + const da = ref.getDate(); + const pad = (n) => String(n).padStart(2, "0"); + const isoDate = `${y}-${pad(mo + 1)}-${pad(da)}`; + let t = Date.parse(`${isoDate} ${s}`); + if (!isNaN(t)) return new Date(t); + t = Date.parse(`${isoDate}T${s}`); + if (!isNaN(t)) return new Date(t); + t = Date.parse(`${mo + 1}/${da}/${y} ${s}`); + if (!isNaN(t)) return new Date(t); + const long = ref.toLocaleString("en-US", { + month: "long", + day: "numeric", + year: "numeric", + }); + t = Date.parse(`${long} ${s}`); + if (!isNaN(t)) return new Date(t); + const plain = Date.parse(s); + if (!isNaN(plain)) { + const d = new Date(plain); + if (!isNaN(d.getTime())) return d; + } + return null; +} + +async function initNowShowingDb() { + if (_db) return; + const initSqlJs = require("sql.js"); + const wasmPath = path.join( + path.dirname(require.resolve("sql.js/package.json")), + "dist", + "sql-wasm.wasm" + ); + const SQL = await initSqlJs({ locateFile: () => wasmPath }); + fs.mkdirSync(path.dirname(DB_FILE), { recursive: true }); + let loadedFromLegacyCacheDb = false; + if (fs.existsSync(DB_FILE)) { + _db = new SQL.Database(fs.readFileSync(DB_FILE)); + } else if (fs.existsSync(LEGACY_CACHE_DB_FILE)) { + _db = new SQL.Database(fs.readFileSync(LEGACY_CACHE_DB_FILE)); + loadedFromLegacyCacheDb = true; + } else { + _db = new SQL.Database(); + } + _db.exec(SCHEMA_SQL); + const schemaChanged = ensureNowShowingExtraColumns(); + if (!fs.existsSync(DB_FILE) || loadedFromLegacyCacheDb) persistDb(); + if (loadedFromLegacyCacheDb) { + try { + const bak = LEGACY_CACHE_DB_FILE + ".migrated.bak"; + if (fs.existsSync(bak)) fs.unlinkSync(bak); + fs.renameSync(LEGACY_CACHE_DB_FILE, bak); + console.log( + new Date().toLocaleString() + + " Now Showing DB: migrated from config/cache → " + + DB_FILE + + "; backup at " + + bak + ); + } catch (e) { + console.log( + new Date().toLocaleString() + + " Now Showing DB: could not rename legacy config/cache DB — " + + (e && e.message ? e.message : e) + ); + } + } + if (schemaChanged && fs.existsSync(DB_FILE) && !loadedFromLegacyCacheDb) { + persistDb(); + console.log( + new Date().toLocaleString() + + " Now Showing DB: schema upgraded with cache path columns" + ); + } +} + +function tmdbApiKey(overrideKey) { + const k = String(overrideKey || "").trim(); + if (k) return k; + return String(process.env.TMDB_API_KEY || "").trim(); +} + +async function searchTmdbMovies(query, overrideKey) { + const key = tmdbApiKey(overrideKey); + if (!key) throw new Error("TMDB_API_KEY is missing"); + const q = String(query || "").trim(); + if (!q) return []; + const r = await axios.get(`${TMDB_BASE}/search/movie`, { + params: { api_key: key, query: q, include_adult: false, page: 1 }, + timeout: 20000, + }); + const items = (r.data && r.data.results) || []; + return items.slice(0, 20).map((m) => ({ + tmdbId: m.id, + title: m.title || m.name || "", + year: m.release_date ? String(m.release_date).slice(0, 4) : "", + rating: + m.vote_average != null && !isNaN(m.vote_average) + ? Math.round(Number(m.vote_average) * 10) + "%" + : "", + overview: m.overview || "", + bannerUrl: m.backdrop_path ? `${TMDB_IMAGE}/w780${m.backdrop_path}` : "", + posterUrl: m.poster_path ? `${TMDB_IMAGE}/w342${m.poster_path}` : "", + })); +} + +async function getTmdbMovieDetails(tmdbId, overrideKey) { + const key = tmdbApiKey(overrideKey); + if (!key) throw new Error("TMDB_API_KEY is missing"); + const id = parseInt(tmdbId, 10); + if (!id) throw new Error("Invalid TMDB id"); + const [details, images, rel, credits] = await Promise.all([ + axios.get(`${TMDB_BASE}/movie/${id}`, { + params: { api_key: key }, + timeout: 20000, + }), + axios.get(`${TMDB_BASE}/movie/${id}/images`, { + params: { api_key: key, include_image_language: "en,null" }, + timeout: 20000, + }), + axios.get(`${TMDB_BASE}/movie/${id}/release_dates`, { + params: { api_key: key }, + timeout: 20000, + }), + axios.get(`${TMDB_BASE}/movie/${id}/credits`, { + params: { api_key: key }, + timeout: 20000, + }), + ]); + const d = details.data || {}; + const logos = (images.data && images.data.logos) || []; + const logo = logos.length ? logos[0].file_path : ""; + let cert = ""; + const rs = (rel.data && rel.data.results) || []; + const us = rs.find((x) => x.iso_3166_1 === "US"); + if (us && Array.isArray(us.release_dates)) { + const it = us.release_dates.find((x) => x.certification); + cert = (it && it.certification) || ""; + } + const cast = (credits.data && credits.data.cast) || []; + const actor1 = String((cast[0] && cast[0].name) || "").trim(); + const actor2 = String((cast[1] && cast[1].name) || "").trim(); + const topCast = cast + .slice(0, 2) + .map((x) => String((x && x.name) || "").trim()) + .filter(Boolean) + .join(", "); + const companies = Array.isArray(d.production_companies) + ? d.production_companies + : []; + const studio = companies.length + ? String((companies[0] && companies[0].name) || "").trim() + : ""; + return { + tmdbId: id, + title: d.title || "", + year: d.release_date ? String(d.release_date).slice(0, 4) : "", + logoUrl: logo ? `${TMDB_IMAGE}/w500${logo}` : "", + bannerUrl: d.backdrop_path ? `${TMDB_IMAGE}/w1280${d.backdrop_path}` : "", + overview: d.overview || "", + plot: d.overview || "", + studio: studio || "", + topCast: topCast || "", + actor1: actor1 || "", + actor2: actor2 || "", + rating: + d.vote_average != null && !isNaN(d.vote_average) + ? Math.round(Number(d.vote_average) * 10) + "%" + : "", + runtimeMins: Number(d.runtime) || 120, + ratingScore: + d.vote_average != null && !isNaN(d.vote_average) + ? Math.round(Number(d.vote_average) * 10) + "%" + : "", + contentRating: cert || "", + ratingContent: cert || "", + genres: Array.isArray(d.genres) + ? d.genres.map((g) => g.name).filter(Boolean).join(", ") + : "", + }; +} + +async function upsertMovieFromTmdb(tmdbId, options, overrideKey) { + assertDb(); + const meta = await getTmdbMovieDetails(tmdbId, overrideKey); + const localBanner = await toLocalNowShowingAssetPath( + meta.tmdbId, + meta.bannerUrl, + "banner" + ); + const localLogo = await toLocalNowShowingAssetPath( + meta.tmdbId, + meta.logoUrl, + "logo" + ); + const now = new Date().toISOString(); + const mode = + options && options.showtimeMode === "manual" ? "manual" : "auto"; + const showings = Math.max( + 1, + Math.min(6, parseInt(options && options.autoShowings, 10) || 6) + ); + const manual = Array.isArray(options && options.manualTimes) + ? options.manualTimes.slice(0, 6) + : []; + const ticketPrice = parsePriceAmount(options && options.priceAmount); + const isAutoPrice = options && options.priceAutoGenerated ? 1 : 0; + const refNow = new Date(); + const scheduleDay = mode === "manual" ? "" : localDateKey(refNow); + const seed = + mode === "manual" + ? "" + : firstAutoSlotForLocalDay(meta.title, refNow).toISOString(); + const emptySlotsJson = "[]"; + _db.run( + `INSERT INTO now_showing_movies + (tmdb_id,title,year,logo_url,banner_url,logo_cache_file,banner_cache_file,overview,rating,plot,rating_score,rating_content,studio,top_cast,actor_1,actor_2,runtime_mins,content_rating,genres,showtime_mode,manual_times,auto_showings,auto_seed_start,auto_schedule_day,auto_generated_showtimes_json,price_amount,price_auto_generated,created_at,updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ON CONFLICT(tmdb_id) DO UPDATE SET + title=excluded.title, year=excluded.year, logo_url=excluded.logo_url, banner_url=excluded.banner_url, + logo_cache_file=excluded.logo_cache_file, banner_cache_file=excluded.banner_cache_file, overview=excluded.overview, + rating=excluded.rating, plot=excluded.plot, rating_score=excluded.rating_score, rating_content=excluded.rating_content, + studio=excluded.studio, top_cast=excluded.top_cast, actor_1=excluded.actor_1, actor_2=excluded.actor_2, runtime_mins=excluded.runtime_mins, content_rating=excluded.content_rating, genres=excluded.genres, + showtime_mode=excluded.showtime_mode, manual_times=excluded.manual_times, auto_showings=excluded.auto_showings, + auto_seed_start=excluded.auto_seed_start, auto_schedule_day=excluded.auto_schedule_day, + auto_generated_showtimes_json=excluded.auto_generated_showtimes_json, price_amount=excluded.price_amount, + price_auto_generated=excluded.price_auto_generated, updated_at=excluded.updated_at`, + [ + meta.tmdbId, + meta.title, + meta.year, + localLogo || meta.logoUrl, + localBanner || meta.bannerUrl, + localLogo, + localBanner, + meta.overview, + meta.rating, + meta.plot || meta.overview || "", + meta.ratingScore || meta.rating || "", + meta.ratingContent || meta.contentRating || "", + meta.studio, + meta.topCast, + meta.actor1, + meta.actor2, + meta.runtimeMins, + meta.contentRating, + meta.genres, + mode, + JSON.stringify(manual), + showings, + seed, + scheduleDay, + emptySlotsJson, + ticketPrice, + isAutoPrice, + now, + now, + ] + ); + persistDb(); +} + +function updateMovieConfig(id, options) { + assertDb(); + const nid = parseInt(id, 10); + if (!nid) throw new Error("Invalid id"); + const mode = + options && options.showtimeMode === "manual" ? "manual" : "auto"; + const showings = Math.max( + 1, + Math.min(6, parseInt(options && options.autoShowings, 10) || 6) + ); + const manual = Array.isArray(options && options.manualTimes) + ? options.manualTimes.slice(0, 6) + : []; + const hasPriceField = + options && Object.prototype.hasOwnProperty.call(options, "priceAmount"); + const ticketPrice = hasPriceField ? parsePriceAmount(options.priceAmount) : null; + const isAutoPrice = options && options.priceAutoGenerated ? 1 : 0; + const sqlParts = [ + "showtime_mode=?", + "manual_times=?", + "auto_showings=?", + "auto_schedule_day=''", + "auto_seed_start=''", + "auto_generated_showtimes_json='[]'", + ]; + const params = [mode, JSON.stringify(manual), showings]; + if (hasPriceField) { + sqlParts.push("price_amount=?"); + sqlParts.push("price_auto_generated=?"); + params.push(ticketPrice); + params.push(isAutoPrice); + } + sqlParts.push("updated_at=?"); + params.push(new Date().toISOString()); + params.push(nid); + _db.run( + `UPDATE now_showing_movies + SET ${sqlParts.join(", ")} + WHERE id=?`, + params + ); + persistDb(); +} + +function deleteMovie(id) { + assertDb(); + const nid = parseInt(id, 10); + if (!nid) return; + _db.run("DELETE FROM now_showing_movies WHERE id=?", [nid]); + persistDb(); +} + +function listMoviesForSettings() { + return allRows(); +} + +/** + * @param {{ showtimeSlotCount?: number }} [options] When set (1–8), use this many + * showtimes for every row (auto + manual display). Otherwise auto uses each row’s + * autoShowings and manual use up to 6 labels (per row); API may request up to 8 slots including buffer. + */ +function listMoviesForScreen(options) { + assertDb(); + const opt = options && typeof options === "object" ? options : {}; + const autoPriceEnabled = !!opt.autoPriceEnabled; + const autoPriceMin = clampPriceBound(opt.autoPriceMin, 5); + const autoPriceMax = clampPriceBound(opt.autoPriceMax, 20); + let slotCap = null; + if ( + opt.showtimeSlotCount !== undefined && + opt.showtimeSlotCount !== null && + opt.showtimeSlotCount !== "" + ) { + const raw = parseInt(String(opt.showtimeSlotCount).trim(), 10); + if (!isNaN(raw)) slotCap = Math.max(1, Math.min(8, raw)); + } + const slotCountForRow = (rowAuto) => { + const a = Math.max(1, Math.min(6, Number(rowAuto) || 6)); + return slotCap != null ? slotCap : a; + }; + const rows = allRows(); + let changed = false; + const now = new Date(); + const out = rows.map((r) => { + let nextPriceAmount = parsePriceAmount(r.priceAmount); + let nextPriceAutoGenerated = r.priceAutoGenerated ? 1 : 0; + if (autoPriceEnabled && nextPriceAmount == null) { + nextPriceAmount = autoPriceFromAge(autoPriceMin, autoPriceMax, r.year); + nextPriceAutoGenerated = 1; + _db.run( + "UPDATE now_showing_movies SET price_amount=?, price_auto_generated=?, updated_at=? WHERE id=?", + [nextPriceAmount, nextPriceAutoGenerated, now.toISOString(), r.id] + ); + changed = true; + } + if (r.showtimeMode === "manual") { + const maxManual = slotCap != null ? slotCap : 6; + const labels = (r.manualTimes || []).slice(0, maxManual); + const showtimeStartsIso = labels.map((lbl) => { + const dt = parseManualShowtimeToDate(lbl, now); + return dt ? dt.toISOString() : null; + }); + return { + ...r, + priceAmount: nextPriceAmount, + priceAutoGenerated: nextPriceAutoGenerated === 1, + showtimes: labels, + showtimeStartsIso, + }; + } + const nSlots = slotCountForRow(r.autoShowings); + const todayKey = localDateKey(now); + const dayEndEx = endOfLocalDayExclusive(now); + const firstOfDay = firstAutoSlotForLocalDay(r.title, now); + let scheduleDay = String(r.autoScheduleDay || "").trim(); + let seed = String(r.autoSeedStart || "").trim(); + let needSave = false; + + if (scheduleDay !== todayKey) { + seed = firstOfDay.toISOString(); + scheduleDay = todayKey; + needSave = true; + } else if (!seed) { + seed = firstOfDay.toISOString(); + needSave = true; + } else { + const seedDate = new Date(seed); + if (isNaN(seedDate.getTime()) || localDateKey(seedDate) !== todayKey) { + seed = firstOfDay.toISOString(); + needSave = true; + } + } + + let slots = generateShowtimes(seed, nSlots, r.runtimeMins, dayEndEx); + if (!slots.length) { + seed = firstOfDay.toISOString(); + scheduleDay = todayKey; + slots = generateShowtimes(seed, nSlots, r.runtimeMins, dayEndEx); + needSave = true; + } + + const slotsIso = slots.map((d) => d.toISOString()); + const slotsJson = JSON.stringify(slotsIso); + const prevStored = Array.isArray(r.autoGeneratedShowtimes) + ? r.autoGeneratedShowtimes + : []; + const prevJson = JSON.stringify(prevStored.map((x) => String(x))); + const jsonDirty = + prevJson !== slotsJson || + (r.autoScheduleDay || "") !== scheduleDay || + String(r.autoSeedStart || "").trim() !== String(seed).trim(); + + if (needSave || jsonDirty) { + _db.run( + "UPDATE now_showing_movies SET auto_seed_start=?, auto_schedule_day=?, auto_generated_showtimes_json=?, updated_at=? WHERE id=?", + [seed, scheduleDay, slotsJson, now.toISOString(), r.id] + ); + changed = true; + } + + return { + ...r, + priceAmount: nextPriceAmount, + priceAutoGenerated: nextPriceAutoGenerated === 1, + autoScheduleDay: scheduleDay, + autoSeedStart: seed, + autoGeneratedShowtimes: slotsIso, + showtimes: formatTimes(slots), + showtimeStartsIso: slotsIso, + }; + }); + if (changed) persistDb(); + return out; +} + +/** + * First logo from TMDB movie images (same rules as getTmdbMovieDetails). + */ +async function fetchTmdbLogoUrl(tmdbId, overrideKey) { + const key = tmdbApiKey(overrideKey); + if (!key) return ""; + const id = parseInt(tmdbId, 10); + if (!id) return ""; + try { + const r = await axios.get(`${TMDB_BASE}/movie/${id}/images`, { + params: { api_key: key, include_image_language: "en,null" }, + timeout: 20000, + }); + const logos = (r.data && r.data.logos) || []; + const path = logos.length ? logos[0].file_path : ""; + return path ? `${TMDB_IMAGE}/w500${path}` : ""; + } catch (e) { + return ""; + } +} + +async function backfillRemoteAssetsToLocalPaths(rows) { + if (!Array.isArray(rows) || !rows.length) return; + let touched = false; + const nowIso = new Date().toISOString(); + for (const row of rows) { + if (!row || !row.id || !row.tmdbId) continue; + const nextBanner = await toLocalNowShowingAssetPath( + row.tmdbId, + row.bannerUrl, + "banner" + ); + const nextLogo = await toLocalNowShowingAssetPath( + row.tmdbId, + row.logoUrl, + "logo" + ); + const curBanner = String(row.bannerUrl || "").trim(); + const curLogo = String(row.logoUrl || "").trim(); + if ( + (nextBanner && nextBanner !== curBanner) || + (nextLogo && nextLogo !== curLogo) + ) { + _db.run( + `UPDATE now_showing_movies + SET banner_url=?, logo_url=?, banner_cache_file=?, logo_cache_file=?, updated_at=? + WHERE id=?`, + [ + nextBanner || curBanner, + nextLogo || curLogo, + nextBanner || row.bannerCacheFile || "", + nextLogo || row.logoCacheFile || "", + nowIso, + row.id, + ] + ); + row.bannerUrl = nextBanner || curBanner; + row.logoUrl = nextLogo || curLogo; + row.bannerCacheFile = nextBanner || row.bannerCacheFile || ""; + row.logoCacheFile = nextLogo || row.logoCacheFile || ""; + touched = true; + } + } + if (touched) persistDb(); +} + +/** + * Curated rows missing logo_url: fetch from TMDB, UPDATE sqlite, patch row.logoUrl in place. + * @param {object[]} rows from listMoviesForScreen() + */ +async function backfillMissingLogosForNowShowingRows(rows, overrideKey) { + if (!Array.isArray(rows) || !rows.length) return; + if (!tmdbApiKey(overrideKey)) return; + let touched = false; + for (const row of rows) { + if (String(row.logoUrl || "").trim()) continue; + const tid = row.tmdbId != null ? parseInt(row.tmdbId, 10) : 0; + if (!tid || !row.id) continue; + const logoUrlRemote = await fetchTmdbLogoUrl(tid, overrideKey); + if (!logoUrlRemote) continue; + const logoUrl = await toLocalNowShowingAssetPath(tid, logoUrlRemote, "logo"); + if (!logoUrl) continue; + assertDb(); + _db.run( + `UPDATE now_showing_movies + SET logo_url=?, logo_cache_file=?, updated_at=? WHERE id=?`, + [logoUrl, logoUrl, new Date().toISOString(), row.id] + ); + row.logoUrl = logoUrl; + row.logoCacheFile = logoUrl; + touched = true; + } + if (touched) persistDb(); +} + +/** + * Curated DB rows missing banner and/or logo: one TMDB movie fetch, cache images, UPDATE sqlite. + * Rows without tmdbId or id are skipped (library fillers, etc.). + * @param {object[]} rows from listMoviesForScreen() + */ +async function hydrateMissingBannerLogoFromTmdb(rows, overrideKey) { + if (!Array.isArray(rows) || !rows.length) return; + if (!tmdbApiKey(overrideKey)) return; + let touched = false; + const nowIso = new Date().toISOString(); + for (const row of rows) { + if (!row || !row.id) continue; + const tid = row.tmdbId != null ? parseInt(row.tmdbId, 10) : 0; + if (!tid) continue; + let nextBanner = String(row.bannerUrl || "").trim(); + let nextLogo = String(row.logoUrl || "").trim(); + if (nextBanner && nextLogo) continue; + + let meta; + try { + meta = await getTmdbMovieDetails(tid, overrideKey); + } catch (e) { + continue; + } + + let nextBannerCache = String(row.bannerCacheFile || "").trim(); + let nextLogoCache = String(row.logoCacheFile || "").trim(); + let changed = false; + + if (!nextBanner && String(meta.bannerUrl || "").trim()) { + const loc = await toLocalNowShowingAssetPath(tid, meta.bannerUrl, "banner"); + nextBanner = loc || String(meta.bannerUrl || "").trim(); + nextBannerCache = loc || nextBannerCache; + changed = true; + } + if (!nextLogo && String(meta.logoUrl || "").trim()) { + const loc = await toLocalNowShowingAssetPath(tid, meta.logoUrl, "logo"); + nextLogo = loc || String(meta.logoUrl || "").trim(); + nextLogoCache = loc || nextLogoCache; + changed = true; + } + if (!changed) continue; + assertDb(); + _db.run( + `UPDATE now_showing_movies + SET banner_url=?, logo_url=?, banner_cache_file=?, logo_cache_file=?, updated_at=? + WHERE id=?`, + [ + nextBanner, + nextLogo, + nextBannerCache, + nextLogoCache, + nowIso, + row.id, + ] + ); + row.bannerUrl = nextBanner; + row.logoUrl = nextLogo; + row.bannerCacheFile = nextBannerCache; + row.logoCacheFile = nextLogoCache; + touched = true; + } + if (touched) persistDb(); +} + +/** Clears persisted auto grids so the next screen build uses today’s midnight-based schedule. */ +function regenerateAllAutoShowtimes() { + assertDb(); + const iso = new Date().toISOString(); + _db.run( + `UPDATE now_showing_movies + SET auto_schedule_day='', auto_seed_start='', auto_generated_showtimes_json='[]', updated_at=? + WHERE showtime_mode IS NULL + OR TRIM(COALESCE(showtime_mode,''))='' + OR LOWER(TRIM(showtime_mode))='auto'`, + [iso] + ); + persistDb(); +} + +module.exports = { + initNowShowingDb, + listMoviesForSettings, + listMoviesForScreen, + searchTmdbMovies, + upsertMovieFromTmdb, + updateMovieConfig, + deleteMovie, + regenerateAllAutoShowtimes, + backfillRemoteAssetsToLocalPaths, + backfillMissingLogosForNowShowingRows, + hydrateMissingBannerLogoFromTmdb, +}; + diff --git a/classes/core/posterMetadataDb.js b/classes/core/posterMetadataDb.js index 15e000f..74c07e6 100644 --- a/classes/core/posterMetadataDb.js +++ b/classes/core/posterMetadataDb.js @@ -3,15 +3,19 @@ const path = require("path"); const Cache = require("./cache"); const MediaCard = require("../cards/MediaCard"); const CardType = require("../cards/CardType"); -const { CACHE_ROOT, LEGACY_SAVED_ROOT } = require("./appPaths"); +const { CACHE_ROOT, CONFIG_ROOT, LEGACY_SAVED_ROOT } = require("./appPaths"); -/** SQLite poster metadata (replaces legacy JSON). */ -const SQLITE_DB = path.join(CACHE_ROOT, "posterr-poster-metadata.db"); +/** SQLite poster metadata (moved from config/cache to config root). */ +const SQLITE_DB = path.join(CONFIG_ROOT, "posterr-poster-metadata.db"); +/** Previous path under config/cache; auto-migrated to config root. */ +const LEGACY_CACHE_SQLITE = path.join(CACHE_ROOT, "posterr-poster-metadata.db"); /** Legacy JSON under former saved/; migrated once into SQLite then renamed. */ const LEGACY_JSON = path.join(LEGACY_SAVED_ROOT, "posterr-poster-metadata.json"); /** Legacy SQLite under former saved/; migrated into config/cache on first start. */ const LEGACY_SQLITE = path.join(LEGACY_SAVED_ROOT, "posterr-poster-metadata.db"); const IMAGECACHE = path.join(CACHE_ROOT, "imagecache"); +/** Pre–config/cache layout; still checked so poster DB rows match files users never moved. */ +const LEGACY_IMAGECACHE = path.join(LEGACY_SAVED_ROOT, "imagecache"); const MP3CACHE = path.join(CACHE_ROOT, "mp3cache"); /** Upper bound on poster metadata rows (full-library sync can exceed the old 2500 cap). */ const MAX_ENTRIES = 100000; @@ -21,10 +25,29 @@ const DEFAULT_FALLBACK_COUNT = 24; const SCHEMA_SQL = ` CREATE TABLE IF NOT EXISTS poster_entries ( cache_file TEXT PRIMARY KEY NOT NULL, + logo_cache_file TEXT NOT NULL DEFAULT '', + art_cache_file TEXT NOT NULL DEFAULT '', + banner_cache_file TEXT NOT NULL DEFAULT '', + portrait_actor_cache_file TEXT NOT NULL DEFAULT '', + portrait_actress_cache_file TEXT NOT NULL DEFAULT '', + portrait_director_cache_file TEXT NOT NULL DEFAULT '', + portrait_author_cache_file TEXT NOT NULL DEFAULT '', + portrait_artist_cache_file TEXT NOT NULL DEFAULT '', title TEXT NOT NULL DEFAULT '', tag_line TEXT NOT NULL DEFAULT '', year TEXT NOT NULL DEFAULT '', media_type TEXT NOT NULL DEFAULT 'movie', + genres TEXT NOT NULL DEFAULT '', + top_cast TEXT NOT NULL DEFAULT '', + actor_1 TEXT NOT NULL DEFAULT '', + actor_2 TEXT NOT NULL DEFAULT '', + studio TEXT NOT NULL DEFAULT '', + runtime_mins INTEGER NOT NULL DEFAULT 0, + rating TEXT NOT NULL DEFAULT '', + content_rating TEXT NOT NULL DEFAULT '', + plot TEXT NOT NULL DEFAULT '', + rating_score TEXT NOT NULL DEFAULT '', + rating_content TEXT NOT NULL DEFAULT '', summary TEXT NOT NULL DEFAULT '', server_kind TEXT NOT NULL DEFAULT 'plex', poster_ar TEXT NOT NULL DEFAULT '', @@ -42,6 +65,27 @@ CREATE INDEX IF NOT EXISTS idx_poster_server_dbid ON poster_entries(server_kind, /** @type {any} */ let _sqlDb = null; +const EXTRA_SCHEMA_COLUMNS = [ + { name: "logo_cache_file", def: "TEXT NOT NULL DEFAULT ''" }, + { name: "art_cache_file", def: "TEXT NOT NULL DEFAULT ''" }, + { name: "banner_cache_file", def: "TEXT NOT NULL DEFAULT ''" }, + { name: "portrait_actor_cache_file", def: "TEXT NOT NULL DEFAULT ''" }, + { name: "portrait_actress_cache_file", def: "TEXT NOT NULL DEFAULT ''" }, + { name: "portrait_director_cache_file", def: "TEXT NOT NULL DEFAULT ''" }, + { name: "portrait_author_cache_file", def: "TEXT NOT NULL DEFAULT ''" }, + { name: "portrait_artist_cache_file", def: "TEXT NOT NULL DEFAULT ''" }, + { name: "genres", def: "TEXT NOT NULL DEFAULT ''" }, + { name: "top_cast", def: "TEXT NOT NULL DEFAULT ''" }, + { name: "actor_1", def: "TEXT NOT NULL DEFAULT ''" }, + { name: "actor_2", def: "TEXT NOT NULL DEFAULT ''" }, + { name: "studio", def: "TEXT NOT NULL DEFAULT ''" }, + { name: "runtime_mins", def: "INTEGER NOT NULL DEFAULT 0" }, + { name: "rating", def: "TEXT NOT NULL DEFAULT ''" }, + { name: "content_rating", def: "TEXT NOT NULL DEFAULT ''" }, + { name: "plot", def: "TEXT NOT NULL DEFAULT ''" }, + { name: "rating_score", def: "TEXT NOT NULL DEFAULT ''" }, + { name: "rating_content", def: "TEXT NOT NULL DEFAULT ''" }, +]; function assertDb() { if (!_sqlDb) { @@ -53,11 +97,30 @@ function assertDb() { function persistDb() { assertDb(); - fs.mkdirSync(CACHE_ROOT, { recursive: true }); + fs.mkdirSync(path.dirname(SQLITE_DB), { recursive: true }); const data = _sqlDb.export(); fs.writeFileSync(SQLITE_DB, Buffer.from(data)); } +function ensurePosterEntriesExtraColumns() { + assertDb(); + const existing = new Set(); + let changed = false; + const s = _sqlDb.prepare("PRAGMA table_info(poster_entries)"); + while (s.step()) { + const row = s.getAsObject(); + existing.add(String(row.name || "").toLowerCase()); + } + s.free(); + for (const c of EXTRA_SCHEMA_COLUMNS) { + if (!existing.has(c.name.toLowerCase())) { + _sqlDb.run(`ALTER TABLE poster_entries ADD COLUMN ${c.name} ${c.def}`); + changed = true; + } + } + return changed; +} + /** * Load sql.js WASM and open (or create) the poster SQLite file. * Call once from application startup before any other poster DB access. @@ -75,8 +138,12 @@ async function initPosterMetadataDb() { const hadNewSqlite = fs.existsSync(SQLITE_DB); let loadedFromLegacySqlite = false; + let loadedFromLegacyCacheSqlite = false; if (hadNewSqlite) { _sqlDb = new SQL.Database(fs.readFileSync(SQLITE_DB)); + } else if (fs.existsSync(LEGACY_CACHE_SQLITE)) { + _sqlDb = new SQL.Database(fs.readFileSync(LEGACY_CACHE_SQLITE)); + loadedFromLegacyCacheSqlite = true; } else if (fs.existsSync(LEGACY_SQLITE)) { _sqlDb = new SQL.Database(fs.readFileSync(LEGACY_SQLITE)); loadedFromLegacySqlite = true; @@ -84,6 +151,7 @@ async function initPosterMetadataDb() { _sqlDb = new SQL.Database(); } _sqlDb.exec(SCHEMA_SQL); + const addedSchemaColumns = ensurePosterEntriesExtraColumns(); let migratedJson = false; const cntAfterOpen = countRows(); @@ -116,7 +184,27 @@ async function initPosterMetadataDb() { } } - if (loadedFromLegacySqlite) { + if (loadedFromLegacyCacheSqlite) { + persistDb(); + try { + const lbak = LEGACY_CACHE_SQLITE + ".migrated.bak"; + if (fs.existsSync(lbak)) fs.unlinkSync(lbak); + fs.renameSync(LEGACY_CACHE_SQLITE, lbak); + console.log( + new Date().toLocaleString() + + " Poster metadata: migrated SQLite DB from config/cache → " + + SQLITE_DB + + "; backup at " + + lbak + ); + } catch (e) { + console.log( + new Date().toLocaleString() + + " Poster metadata: could not rename legacy config/cache DB — " + + (e && e.message ? e.message : e) + ); + } + } else if (loadedFromLegacySqlite) { persistDb(); try { const lbak = LEGACY_SQLITE + ".migrated.bak"; @@ -138,6 +226,13 @@ async function initPosterMetadataDb() { } } else if (!hadNewSqlite && !migratedJson) { persistDb(); + } else if (addedSchemaColumns) { + // Existing DB upgraded with new columns; persist ALTER TABLE changes immediately. + persistDb(); + console.log( + new Date().toLocaleString() + + " Poster metadata: schema upgraded with additional metadata/cache columns" + ); } logLegacySavedImageHint(); @@ -169,19 +264,41 @@ function migrateLegacyJson() { const entries = parsed && Array.isArray(parsed.entries) ? parsed.entries : []; const ins = _sqlDb.prepare(` INSERT OR REPLACE INTO poster_entries ( - cache_file, title, tag_line, year, media_type, summary, server_kind, poster_ar, + cache_file, logo_cache_file, art_cache_file, banner_cache_file, + portrait_actor_cache_file, portrait_actress_cache_file, + portrait_director_cache_file, portrait_author_cache_file, portrait_artist_cache_file, + title, tag_line, year, media_type, genres, top_cast, actor_1, actor_2, studio, runtime_mins, rating, content_rating, plot, rating_score, rating_content, summary, server_kind, poster_ar, dbid, api_item_id, library_kind, library_name, source_url, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); try { _sqlDb.run("BEGIN"); for (const e of entries) { const row = entryToParams({ cacheFile: e.cacheFile, + logoCacheFile: e.logoCacheFile, + artCacheFile: e.artCacheFile, + bannerCacheFile: e.bannerCacheFile, + portraitActorCacheFile: e.portraitActorCacheFile, + portraitActressCacheFile: e.portraitActressCacheFile, + portraitDirectorCacheFile: e.portraitDirectorCacheFile, + portraitAuthorCacheFile: e.portraitAuthorCacheFile, + portraitArtistCacheFile: e.portraitArtistCacheFile, title: e.title, tagLine: e.tagLine, year: e.year, mediaType: e.mediaType, + genres: e.genres, + topCast: e.topCast, + actor1: e.actor1, + actor2: e.actor2, + studio: e.studio, + runtimeMins: e.runtimeMins, + rating: e.rating, + contentRating: e.contentRating, + plot: e.plot, + ratingScore: e.ratingScore, + ratingContent: e.ratingContent, summary: e.summary, serverKind: e.serverKind, posterAR: e.posterAR, @@ -223,10 +340,29 @@ function rowFromDb(r) { if (!r) return null; return { cacheFile: r.cache_file, + logoCacheFile: r.logo_cache_file || "", + artCacheFile: r.art_cache_file || "", + bannerCacheFile: r.banner_cache_file || "", + portraitActorCacheFile: r.portrait_actor_cache_file || "", + portraitActressCacheFile: r.portrait_actress_cache_file || "", + portraitDirectorCacheFile: r.portrait_director_cache_file || "", + portraitAuthorCacheFile: r.portrait_author_cache_file || "", + portraitArtistCacheFile: r.portrait_artist_cache_file || "", title: r.title || "", tagLine: r.tag_line || "", year: r.year || "", mediaType: r.media_type || "", + genres: r.genres || "", + topCast: r.top_cast || "", + actor1: r.actor_1 || "", + actor2: r.actor_2 || "", + studio: r.studio || "", + runtimeMins: Number(r.runtime_mins) || 0, + rating: r.rating || "", + contentRating: r.content_rating || "", + plot: r.plot || r.summary || "", + ratingScore: r.rating_score || r.rating || "", + ratingContent: r.rating_content || r.content_rating || "", summary: r.summary || "", serverKind: r.server_kind || "", posterAR: r.poster_ar || "", @@ -243,10 +379,29 @@ function rowFromDb(r) { function entryToParams(e) { return [ e.cacheFile, + e.logoCacheFile || "", + e.artCacheFile || "", + e.bannerCacheFile || "", + e.portraitActorCacheFile || "", + e.portraitActressCacheFile || "", + e.portraitDirectorCacheFile || "", + e.portraitAuthorCacheFile || "", + e.portraitArtistCacheFile || "", e.title || "", e.tagLine || "", e.year || "", e.mediaType || "movie", + e.genres || "", + e.topCast || "", + e.actor1 || "", + e.actor2 || "", + e.studio || "", + Number.isFinite(Number(e.runtimeMins)) ? Math.max(0, Math.round(Number(e.runtimeMins))) : 0, + e.rating || "", + e.contentRating || "", + e.plot || "", + e.ratingScore || "", + e.ratingContent || "", e.summary || "", e.serverKind || "plex", e.posterAR || "", @@ -292,6 +447,40 @@ function getEntryByCacheFile(cacheFile) { return row; } +function getEntryByServerAndApiItemId(serverKind, apiItemId) { + assertDb(); + const sk = String(serverKind || "").toLowerCase().trim(); + const aid = String(apiItemId || "").trim(); + if (!sk || !aid) return null; + const s = _sqlDb.prepare( + "SELECT * FROM poster_entries WHERE server_kind = ? AND api_item_id = ? LIMIT 1" + ); + s.bind([sk, aid]); + let row = null; + if (s.step()) row = rowFromDb(s.getAsObject()); + s.free(); + return row; +} + +/** + * Full sync optimization: skip expensive metadata/image pull when a valid cached + * poster row already exists and source item was not updated. + * @param {string} serverKind + * @param {string} apiItemId + * @param {string|number|Date} sourceUpdatedAt + */ +function shouldSkipSyncItem(serverKind, apiItemId, sourceUpdatedAt) { + const row = getEntryByServerAndApiItemId(serverKind, apiItemId); + if (!row || !row.cacheFile || !fileOk(row.cacheFile)) return false; + if (sourceUpdatedAt === undefined || sourceUpdatedAt === null || sourceUpdatedAt === "") + return true; + const srcTs = new Date(sourceUpdatedAt).getTime(); + const rowTs = new Date(row.updatedAt || 0).getTime(); + if (!Number.isFinite(srcTs) || srcTs <= 0) return true; + if (!Number.isFinite(rowTs) || rowTs <= 0) return false; + return srcTs <= rowTs; +} + function deleteByCacheFiles(files) { if (!files.length) return; assertDb(); @@ -316,9 +505,12 @@ function replaceAllEntries(entries) { assertDb(); const ins = _sqlDb.prepare(` INSERT INTO poster_entries ( - cache_file, title, tag_line, year, media_type, summary, server_kind, poster_ar, + cache_file, logo_cache_file, art_cache_file, banner_cache_file, + portrait_actor_cache_file, portrait_actress_cache_file, + portrait_director_cache_file, portrait_author_cache_file, portrait_artist_cache_file, + title, tag_line, year, media_type, genres, top_cast, actor_1, actor_2, studio, runtime_mins, rating, content_rating, plot, rating_score, rating_content, summary, server_kind, poster_ar, dbid, api_item_id, library_kind, library_name, source_url, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); try { _sqlDb.run("BEGIN"); @@ -358,41 +550,109 @@ function enforceMaxEntries() { */ function normalizeCacheFile(posterURL) { if (!posterURL || typeof posterURL !== "string") return null; - const idx = posterURL.indexOf("/imagecache/"); + const idx = posterURL.toLowerCase().indexOf("/imagecache/"); if (idx === -1) return null; let rest = posterURL.slice(idx + "/imagecache/".length); if (rest.includes("?")) rest = rest.split("?")[0]; - const base = path.basename(rest); + const base = path.basename(rest.replace(/\\/g, "/")); if (!base || /[\\/]/.test(base) || base.includes("..")) return null; return base; } +/** + * Normalize a poster_entries.cache_file value to a single safe basename (flat imagecache layout). + * Handles full URLs, Windows paths, and plain basenames. + * @param {string} cacheFile + * @returns {string|null} + */ +function cacheBasenameFromStoredValue(cacheFile) { + if (!cacheFile || typeof cacheFile !== "string") return null; + const s = cacheFile.trim().replace(/\\/g, "/"); + if (!s) return null; + const idx = s.toLowerCase().indexOf("/imagecache/"); + let rest = idx === -1 ? s : s.slice(idx + "/imagecache/".length); + if (rest.includes("?")) rest = rest.split("?")[0]; + const base = path.basename(rest); + if (!base || base === "." || base === ".." || base.includes("..")) return null; + return base; +} + function fileOk(cacheFile) { - const fp = path.join(IMAGECACHE, cacheFile); + const base = cacheBasenameFromStoredValue(cacheFile); + if (!base) return false; + const dirs = [IMAGECACHE]; try { - const st = fs.statSync(fp); - return st.isFile() && st.size >= MIN_FILE_BYTES; + if (path.resolve(LEGACY_IMAGECACHE) !== path.resolve(IMAGECACHE)) { + dirs.push(LEGACY_IMAGECACHE); + } } catch (e) { - return false; + /* ignore */ } + for (const dir of dirs) { + const fp = path.join(dir, base); + try { + const st = fs.statSync(fp); + if (st.isFile() && st.size >= MIN_FILE_BYTES) return true; + } catch (e) { + /* try next dir */ + } + } + return false; +} + +/** Cached wide background: fanart (`-art.jpg`) first, else Plex/JF banner (`-banner.jpg`). */ +function resolveBackdropCacheFile(posterCacheFile) { + const m = String(posterCacheFile || "").match(/^(.+)\.(jpe?g)$/i); + if (!m) return ""; + const base = m[1]; + const artFn = `${base}-art.jpg`; + if (fileOk(artFn)) return artFn; + const bnFn = `${base}-banner.jpg`; + if (fileOk(bnFn)) return bnFn; + return ""; +} + +/** Cached banner only (`-banner.jpg`). */ +function resolveBannerCacheFile(posterCacheFile) { + const m = String(posterCacheFile || "").match(/^(.+)\.(jpe?g)$/i); + if (!m) return ""; + const bnFn = `${m[1]}-banner.jpg`; + if (fileOk(bnFn)) return bnFn; + return ""; } function unlinkCacheAndArt(cacheFile) { - const fp = path.join(IMAGECACHE, cacheFile); + const base = cacheBasenameFromStoredValue(cacheFile) || String(cacheFile || "").trim(); + if (!base) return; + const dirs = [IMAGECACHE]; try { - if (fs.existsSync(fp)) fs.unlinkSync(fp); + if (path.resolve(LEGACY_IMAGECACHE) !== path.resolve(IMAGECACHE)) { + dirs.push(LEGACY_IMAGECACHE); + } } catch (e) { /* ignore */ } - const m = String(cacheFile).match(/^(.+)\.jpg$/i); - if (m) { + for (const dir of dirs) { + const fp = path.join(dir, base); try { - const art = path.join(IMAGECACHE, m[1] + "-art.jpg"); - if (fs.existsSync(art)) fs.unlinkSync(art); + if (fs.existsSync(fp)) fs.unlinkSync(fp); } catch (e) { /* ignore */ } } + const m = String(base).match(/^(.+)\.jpg$/i); + if (m) { + for (const suf of ["-art", "-banner"]) { + for (const dir of dirs) { + try { + const side = path.join(dir, m[1] + suf + ".jpg"); + if (fs.existsSync(side)) fs.unlinkSync(side); + } catch (e) { + /* ignore */ + } + } + } + } } /** @@ -473,33 +733,57 @@ function registerFromMediaServerCards(nsCards, odCards, serverKind) { const cards = [] .concat(Array.isArray(nsCards) ? nsCards : []) .concat(Array.isArray(odCards) ? odCards : []); - if (cards.length === 0) return; + if (cards.length === 0) { + return { + totalCards: 0, + posterUrlPresent: 0, + normalizedCacheFile: 0, + fileOk: 0, + titlePresent: 0, + written: 0, + rowCountBefore: countRows(), + rowCountAfter: countRows(), + }; + } assertDb(); let changed = false; + let written = 0; + let posterUrlPresent = 0; + let normalizedCacheFile = 0; + let fileOkCount = 0; + let titlePresent = 0; + const rowCountBefore = countRows(); const now = new Date().toISOString(); const kind = String(serverKind || "plex").toLowerCase(); const ins = _sqlDb.prepare(` INSERT OR REPLACE INTO poster_entries ( - cache_file, title, tag_line, year, media_type, summary, server_kind, poster_ar, + cache_file, logo_cache_file, art_cache_file, banner_cache_file, + portrait_actor_cache_file, portrait_actress_cache_file, + portrait_director_cache_file, portrait_author_cache_file, portrait_artist_cache_file, + title, tag_line, year, media_type, genres, top_cast, actor_1, actor_2, studio, runtime_mins, rating, content_rating, plot, rating_score, rating_content, summary, server_kind, poster_ar, dbid, api_item_id, library_kind, library_name, source_url, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); try { _sqlDb.run("BEGIN"); for (const card of cards) { + if (card && String(card.posterURL || "").trim()) posterUrlPresent += 1; const cacheFile = normalizeCacheFile(card.posterURL); - if (!cacheFile || !fileOk(cacheFile)) continue; + if (!cacheFile) continue; + normalizedCacheFile += 1; + if (fileOk(cacheFile)) fileOkCount += 1; const title = String(card.title || "").trim(); if (!title) continue; + titlePresent += 1; let sourceUrl = String(card.posterDownloadURL || "").trim(); let apiItemId = String(card.posterApiItemId || "").trim(); let libraryKind = String(card.posterLibraryKind || "").trim(); - const libraryName = String(card.posterLibraryLabel || "").trim(); + let libraryName = String(card.posterLibraryLabel || "").trim(); const old = getEntryByCacheFile(cacheFile); if (!sourceUrl && old && old.sourceUrl) sourceUrl = old.sourceUrl; @@ -509,10 +793,47 @@ function registerFromMediaServerCards(nsCards, odCards, serverKind) { const row = { cacheFile, + logoCacheFile: normalizeCacheFile(card.posterLogoURL) || "", + artCacheFile: normalizeCacheFile(card.posterArtURL) || "", + bannerCacheFile: + normalizeCacheFile(card.posterBannerURL) || resolveBannerCacheFile(cacheFile), + portraitActorCacheFile: normalizeCacheFile(card.portraitActorURL) || "", + portraitActressCacheFile: + normalizeCacheFile(card.portraitActressURL) || "", + portraitDirectorCacheFile: + normalizeCacheFile(card.portraitDirectorURL) || "", + portraitAuthorCacheFile: + normalizeCacheFile(card.portraitAuthorURL) || "", + portraitArtistCacheFile: + normalizeCacheFile(card.portraitArtistURL) || "", title, tagLine: String(card.tagLine || "").trim(), year: String(card.year || "").trim(), mediaType: String(card.mediaType || "movie").trim() || "movie", + genres: Array.isArray(card.genre) + ? card.genre.join(", ") + : String(card.genre || "").trim(), + topCast: String(card.cast || "") + .split(",") + .map((x) => x.trim()) + .filter(Boolean) + .slice(0, 2) + .join(", "), + actor1: String(card.cast || "") + .split(",") + .map((x) => x.trim()) + .filter(Boolean)[0] || "", + actor2: String(card.cast || "") + .split(",") + .map((x) => x.trim()) + .filter(Boolean)[1] || "", + studio: String(card.studio || card.network || "").trim(), + runtimeMins: Math.max(0, parseInt(card.runTime, 10) || 0), + rating: String(card.rating || "").trim(), + contentRating: String(card.contentRating || "").trim(), + plot: String(card.summary || "").slice(0, 2000), + ratingScore: String(card.rating || "").trim(), + ratingContent: String(card.contentRating || "").trim(), summary: String(card.summary || "").slice(0, 2000), serverKind: kind, posterAR: String(card.posterAR || "").trim(), @@ -523,13 +844,48 @@ function registerFromMediaServerCards(nsCards, odCards, serverKind) { sourceUrl, updatedAt: now, }; + const incomingTagLine = String(card.tagLine || "").trim(); + const oldTagLine = old ? String(old.tagLine || "").trim() : ""; + const oldTitle = old ? String(old.title || "").trim() : ""; + const looksLikeFallbackTitle = + incomingTagLine && + row.title && + incomingTagLine.toLowerCase() === row.title.toLowerCase(); + const oldLooksRealTagline = + oldTagLine && + (!oldTitle || oldTagLine.toLowerCase() !== oldTitle.toLowerCase()); + if (!incomingTagLine && oldTagLine) { + row.tagLine = oldTagLine; + } else if (looksLikeFallbackTitle && oldLooksRealTagline) { + // Keep a previously synced real tagline when current sync only has title fallback. + row.tagLine = oldTagLine; + } const needsWrite = !old || old.title !== row.title || + old.logoCacheFile !== row.logoCacheFile || + old.artCacheFile !== row.artCacheFile || + old.bannerCacheFile !== row.bannerCacheFile || + old.portraitActorCacheFile !== row.portraitActorCacheFile || + old.portraitActressCacheFile !== row.portraitActressCacheFile || + old.portraitDirectorCacheFile !== row.portraitDirectorCacheFile || + old.portraitAuthorCacheFile !== row.portraitAuthorCacheFile || + old.portraitArtistCacheFile !== row.portraitArtistCacheFile || old.tagLine !== row.tagLine || old.year !== row.year || old.mediaType !== row.mediaType || + old.genres !== row.genres || + old.topCast !== row.topCast || + old.actor1 !== row.actor1 || + old.actor2 !== row.actor2 || + old.studio !== row.studio || + Number(old.runtimeMins || 0) !== Number(row.runtimeMins || 0) || + old.rating !== row.rating || + old.contentRating !== row.contentRating || + old.plot !== row.plot || + old.ratingScore !== row.ratingScore || + old.ratingContent !== row.ratingContent || old.serverKind !== row.serverKind || old.summary !== row.summary || old.posterAR !== row.posterAR || @@ -542,6 +898,7 @@ function registerFromMediaServerCards(nsCards, odCards, serverKind) { if (needsWrite) { ins.run(entryToParams(row)); changed = true; + written += 1; } } _sqlDb.run("COMMIT"); @@ -560,6 +917,17 @@ function registerFromMediaServerCards(nsCards, odCards, serverKind) { enforceMaxEntries(); persistDb(); } + const rowCountAfter = countRows(); + return { + totalCards: cards.length, + posterUrlPresent, + normalizedCacheFile, + fileOk: fileOkCount, + titlePresent, + written, + rowCountBefore, + rowCountAfter, + }; } function pickRandomEntries(count, serverKindOpt) { @@ -583,6 +951,40 @@ function pickRandomEntries(count, serverKindOpt) { return shuffled.slice(0, Math.min(count, shuffled.length)); } +/** Match EmbyJellyfinBase.ratingColour / MediaCard content-rating pills for DB-built OD cards. */ +function ratingColourForContentRating(contentRating) { + const cr = (contentRating || "NR").toLowerCase(); + let ratingColour = "badge-dark"; + switch (cr) { + case "nr": + case "unrated": + ratingColour = "badge-dark"; + break; + case "g": + case "tv-g": + case "tv-y": + ratingColour = "badge-success"; + break; + case "pg": + case "tv-pg": + case "tv-y7": + ratingColour = "badge-info"; + break; + case "pg-13": + case "tv-14": + ratingColour = "badge-warning"; + break; + case "tv-ma": + case "r": + case "nc-17": + ratingColour = "badge-danger"; + break; + default: + ratingColour = "badge-dark"; + } + return ratingColour; +} + /** * For live on-demand cards: point poster (and fanart when present) at disk cache if the poster DB has a match. * @param {object[]} cards @@ -619,10 +1021,38 @@ function applyCachedPostersToMediaCards(cards, serverKind) { } if (!row) continue; card.posterURL = "/imagecache/" + row.cacheFile; - const m = String(row.cacheFile).match(/^(.+)\.(jpe?g)$/i); - if (m) { - const artFn = `${m[1]}-art.jpg`; - if (fileOk(artFn)) card.posterArtURL = "/imagecache/" + artFn; + const artFn = resolveBackdropCacheFile(row.cacheFile); + if (artFn) card.posterArtURL = "/imagecache/" + artFn; + if (!card.genre || (Array.isArray(card.genre) && card.genre.length === 0)) { + const g = row.genres != null ? String(row.genres).trim() : ""; + if (g) card.genre = g; + } + if (!card.studio && row.studio) card.studio = row.studio; + if ( + (!card.runTime || card.runTime === "" || card.runTime === 0) && + row.runtimeMins > 0 + ) { + card.runTime = String(row.runtimeMins); + } + if (!card.rating && row.rating) card.rating = row.rating; + if (!card.contentRating && row.contentRating) { + card.contentRating = row.contentRating; + card.ratingColour = ratingColourForContentRating(row.contentRating); + } + if (!card.cast && row.topCast) card.cast = String(row.topCast).trim(); + if (!card.cast) { + const a = [row.actor1, row.actor2].filter((x) => x && String(x).trim()); + if (a.length) card.cast = a.join(", "); + } + if (!card.actor1 && row.actor1) card.actor1 = row.actor1; + if (!card.actor2 && row.actor2) card.actor2 = row.actor2; + if (!String(card.actor1 || "").trim() || !String(card.actor2 || "").trim()) { + const cp = String(card.cast || "") + .split(",") + .map((x) => x.trim()) + .filter(Boolean); + if (!String(card.actor1 || "").trim()) card.actor1 = cp[0] || ""; + if (!String(card.actor2 || "").trim()) card.actor2 = cp[1] || ""; } } return cards; @@ -652,9 +1082,53 @@ function buildFallbackMediaCards(count, serverKind) { c.year = row.year || ""; c.summary = row.summary || ""; c.posterURL = "/imagecache/" + row.cacheFile; - c.posterArtURL = ""; + const artFn = resolveBackdropCacheFile(row.cacheFile); + c.posterArtURL = artFn ? "/imagecache/" + artFn : ""; if (row.posterAR) c.posterAR = row.posterAR; c.DBID = row.dbid || ""; + c.posterApiItemId = String(row.apiItemId || "").trim(); + c.posterLibraryLabel = row.libraryName || ""; + if (row.logoCacheFile) { + c.posterLogoURL = "/imagecache/" + row.logoCacheFile; + } + c.genre = row.genres != null ? String(row.genres).trim() : ""; + c.studio = row.studio || ""; + if (row.runtimeMins > 0) c.runTime = String(row.runtimeMins); + c.rating = row.rating || ""; + c.contentRating = row.contentRating || ""; + c.ratingColour = ratingColourForContentRating(c.contentRating || "NR"); + const castBits = [row.actor1, row.actor2].filter( + (x) => x && String(x).trim() + ); + c.cast = + row.topCast && String(row.topCast).trim() + ? String(row.topCast).trim() + : castBits.join(", "); + c.actor1 = String(row.actor1 || "").trim(); + c.actor2 = String(row.actor2 || "").trim(); + if (!c.actor1 || !c.actor2) { + const cp = String(c.cast || "") + .split(",") + .map((x) => x.trim()) + .filter(Boolean); + if (!c.actor1) c.actor1 = cp[0] || ""; + if (!c.actor2) c.actor2 = cp[1] || ""; + } + if (row.portraitActorCacheFile) { + c.portraitActorURL = "/imagecache/" + row.portraitActorCacheFile; + } + if (row.portraitActressCacheFile) { + c.portraitActressURL = "/imagecache/" + row.portraitActressCacheFile; + } + if (row.portraitDirectorCacheFile) { + c.portraitDirectorURL = "/imagecache/" + row.portraitDirectorCacheFile; + } + if (row.portraitAuthorCacheFile) { + c.portraitAuthorURL = "/imagecache/" + row.portraitAuthorCacheFile; + } + if (row.portraitArtistCacheFile) { + c.portraitArtistURL = "/imagecache/" + row.portraitArtistCacheFile; + } c.theme = ""; cards.push(c); } @@ -754,7 +1228,9 @@ async function runScheduledRefresh(opts) { } const url = String(entry.sourceUrl || "").trim(); - const fp = path.join(IMAGECACHE, entry.cacheFile); + const cacheBase = + cacheBasenameFromStoredValue(entry.cacheFile) || entry.cacheFile; + const fp = path.join(IMAGECACHE, cacheBase); if (url) { try { const imgHdr = opts && opts.imageDownloadHeaders; @@ -842,6 +1318,60 @@ function libLabel(name) { return s || "(unknown library)"; } +/** Display / sort order for Settings → Cache image breakdown. */ +const IMAGE_KIND_ORDER = [ + "people", + "album", + "book", + "audiobook", + "poster", + "logo", + "banner", + "background", + "other", +]; + +function emptyKindCounts() { + /** @type {Record} */ + const o = {}; + for (const k of IMAGE_KIND_ORDER) o[k] = 0; + return o; +} + +/** + * Classify a cached image filename into a dashboard bucket. + * @param {string} fname + * @param {Set} primarySet + * @param {Map} posterFileToMediaType cache_file -> media_type + */ +function classifyImageKind(fname, primarySet, posterFileToMediaType) { + if (!fname || typeof fname !== "string") return "other"; + if (!/\.(jpe?g|png)$/i.test(fname)) return "other"; + if (/-logo\.(png|jpe?g)$/i.test(fname)) return "logo"; + if (/\.png$/i.test(fname)) return "other"; + if (/-banner\.jpg$/i.test(fname)) return "banner"; + if (/-(actor|actress|director|author|artist)\.jpg$/i.test(fname)) + return "people"; + if (/-art\.jpg$/i.test(fname)) return "background"; + if (primarySet.has(fname)) { + const mt = String(posterFileToMediaType.get(fname) || "movie") + .toLowerCase() + .trim(); + if (mt === "album") return "album"; + if (mt === "ebook") return "book"; + if (mt === "audiobook") return "audiobook"; + return "poster"; + } + if ( + /\.jpe?g$/i.test(fname) && + !/-(art|banner)\.jpg$/i.test(fname) && + !/-(actor|actress|director|author|artist)\.jpg$/i.test(fname) + ) { + return "poster"; + } + return "other"; +} + /** * Stats for Settings → Cache (poster DB, image files by type/library, mp3 themes). * @returns {object} @@ -854,6 +1384,7 @@ function getCacheDashboardStats() { let rowsMissing = 0; const fileToLibrary = new Map(); + const posterFileToMediaType = new Map(); for (const e of entries) { const lib = libLabel(e.libraryName); if (!posterByLib[lib]) { @@ -867,6 +1398,9 @@ function getCacheDashboardStats() { } posterByLib[lib].total += 1; const mt = String(e.mediaType || "other").toLowerCase() || "other"; + if (e.cacheFile) { + posterFileToMediaType.set(e.cacheFile, mt); + } if (e.cacheFile && fileOk(e.cacheFile)) { rowsValid += 1; posterByLib[lib].valid += 1; @@ -886,6 +1420,11 @@ function getCacheDashboardStats() { const base = artM[1] + ".jpg"; if (fileToLibrary.has(base)) return fileToLibrary.get(base); } + const bannerM = fname.match(/^(.+)-banner\.jpg$/i); + if (bannerM) { + const base = bannerM[1] + ".jpg"; + if (fileToLibrary.has(base)) return fileToLibrary.get(base); + } const portM = fname.match( /^(.+)-(actor|actress|director|author|artist)\.jpg$/i ); @@ -893,21 +1432,37 @@ function getCacheDashboardStats() { const base = portM[1] + ".jpg"; if (fileToLibrary.has(base)) return fileToLibrary.get(base); } + const logoM = fname.match(/^(.+)-logo\.(png|jpe?g)$/i); + if (logoM) { + const base = logoM[1] + ".jpg"; + if (fileToLibrary.has(base)) return fileToLibrary.get(base); + } return "(unassigned)"; } - const byCategory = { primary: 0, fanart: 0, portrait: 0, other: 0 }; + const byCategory = emptyKindCounts(); const diskByLib = {}; let imageFiles = 0; let imageBytes = 0; - const portraitRe = /-(actor|actress|director|author|artist)\.jpg$/i; const primarySet = new Set(entries.map((e) => e.cacheFile).filter(Boolean)); if (fs.existsSync(IMAGECACHE)) { try { - for (const fname of fs.readdirSync(IMAGECACHE)) { - if (!/\.(jpe?g)$/i.test(fname)) continue; + const names = fs.readdirSync(IMAGECACHE).filter((fname) => + /\.(jpe?g|png)$/i.test(fname) + ); + names.sort((a, b) => { + const ka = classifyImageKind(a, primarySet, posterFileToMediaType); + const kb = classifyImageKind(b, primarySet, posterFileToMediaType); + const ia = IMAGE_KIND_ORDER.indexOf(ka); + const ib = IMAGE_KIND_ORDER.indexOf(kb); + const oa = ia === -1 ? 99 : ia; + const ob = ib === -1 ? 99 : ib; + if (oa !== ob) return oa - ob; + return a.localeCompare(b, undefined, { sensitivity: "base" }); + }); + for (const fname of names) { const fp = path.join(IMAGECACHE, fname); let st; try { @@ -919,24 +1474,18 @@ function getCacheDashboardStats() { imageFiles += 1; imageBytes += st.size; - let cat = "other"; - if (primarySet.has(fname)) cat = "primary"; - else if (/-art\.jpg$/i.test(fname)) cat = "fanart"; - else if (portraitRe.test(fname)) cat = "portrait"; - byCategory[cat] += 1; + const kind = classifyImageKind(fname, primarySet, posterFileToMediaType); + byCategory[kind] += 1; const lib = libraryForImageFile(fname); if (!diskByLib[lib]) { diskByLib[lib] = { name: lib, - primary: 0, - fanart: 0, - portrait: 0, - other: 0, + ...emptyKindCounts(), bytes: 0, }; } - diskByLib[lib][cat] += 1; + diskByLib[lib][kind] += 1; diskByLib[lib].bytes += st.size; } } catch (e) { @@ -965,6 +1514,7 @@ function getCacheDashboardStats() { fileCount: imageFiles, totalBytes: imageBytes, byCategory, + kindOrder: IMAGE_KIND_ORDER.slice(), byLibrary: diskLibraries, }, mp3cache: { @@ -985,4 +1535,10 @@ module.exports = { getCacheDashboardStats, DEFAULT_FALLBACK_COUNT, normalizeCacheFile, + getEntryByServerAndApiItemId, + shouldSkipSyncItem, + cacheBasenameFromStoredValue, + fileOk, + IMAGE_KIND_ORDER, + countRows, }; diff --git a/classes/core/posterSyncAbort.js b/classes/core/posterSyncAbort.js new file mode 100644 index 0000000..f58f304 --- /dev/null +++ b/classes/core/posterSyncAbort.js @@ -0,0 +1,39 @@ +/** Thrown when the user aborts a full poster sync; caught inside GetOnDemand to return partial cards. */ +class PosterSyncAbortedError extends Error { + constructor() { + super("Poster sync aborted by user"); + this.name = "PosterSyncAbortedError"; + this.code = "POSTERR_POSTER_SYNC_ABORTED"; + } +} + +/** + * @param {object} opts - GetOnDemand options + * @param {boolean} posterSyncFull + * @param {object|null} syncProgress - posterSyncProgressState slice on `sp` + */ +function checkPosterSyncAborted(opts, posterSyncFull, syncProgress) { + if ( + !posterSyncFull || + typeof opts.posterSyncAbortCheck !== "function" || + !opts.posterSyncAbortCheck() + ) { + return; + } + if (syncProgress) { + syncProgress.label = "Sync aborted — saving partial results…"; + } + const n = + syncProgress && syncProgress.processed != null + ? syncProgress.processed + : 0; + console.log( + new Date().toLocaleString() + + " [poster sync] aborted by user — " + + n + + " item(s) cached so far" + ); + throw new PosterSyncAbortedError(); +} + +module.exports = { PosterSyncAbortedError, checkPosterSyncAborted }; diff --git a/classes/core/posterSyncRetry.js b/classes/core/posterSyncRetry.js new file mode 100644 index 0000000..3166806 --- /dev/null +++ b/classes/core/posterSyncRetry.js @@ -0,0 +1,200 @@ +const fs = require("fs"); +const path = require("path"); +const { CACHE_ROOT } = require("./appPaths"); +const posterMetadataDb = require("./posterMetadataDb"); + +const RETRY_FILE = path.join(CACHE_ROOT, "poster-sync-retry.json"); +const MAX_KEYS = 80000; + +function plexRawKey(md) { + if (!md || md.ratingKey == null) return ""; + return String(md.ratingKey); +} + +function jfRawKey(md) { + if (!md || md.Id == null) return ""; + return String(md.Id); +} + +function kodiRawKey(md) { + if (!md) return ""; + if (md._kodiKind === "show" && md.tvshowid != null) + return "s:" + String(md.tvshowid); + if (md._kodiKind === "movie" && md.movieid != null) + return "m:" + String(md.movieid); + return ""; +} + +/** + * Stable id for retry file + prioritization (must match raw row key helpers). + * @param {object} card + * @param {string} serverKind + */ +function keyFromCard(card, serverKind) { + const k = String((card && card.posterApiItemId) || "").trim(); + if (!k) return ""; + const sk = String(serverKind || "").toLowerCase(); + if (sk === "kodi") { + const mt = String((card && card.mediaType) || "").toLowerCase(); + if (mt === "show") return "s:" + k; + if (mt === "movie") return "m:" + k; + } + return k; +} + +/** + * Move items whose keys were saved after the previous sync to the front of the queue. + * @param {object[]} odRaw + * @param {string[]} retryKeys + * @param {string} serverKind + */ +function prioritizeOdRaw(odRaw, retryKeys, serverKind) { + if (!odRaw || !odRaw.length || !retryKeys || !retryKeys.length) return odRaw; + const sk = String(serverKind || "").toLowerCase(); + let getKey; + if (sk === "plex") getKey = plexRawKey; + else if (sk === "jellyfin" || sk === "emby") getKey = jfRawKey; + else if (sk === "kodi") getKey = kodiRawKey; + else getKey = () => ""; + + const order = new Map(); + for (let i = 0; i < retryKeys.length; i++) { + const k = String(retryKeys[i] || "").trim(); + if (k && !order.has(k)) order.set(k, i); + } + const scored = odRaw.map((item, idx) => { + const k = getKey(item); + const pri = k && order.has(k) ? order.get(k) : 1e9; + return { item, idx, pri }; + }); + scored.sort((a, b) => a.pri - b.pri || a.idx - b.idx); + return scored.map((s) => s.item); +} + +function loadRetryKeys(serverKind) { + try { + if (!fs.existsSync(RETRY_FILE)) return []; + const j = JSON.parse(fs.readFileSync(RETRY_FILE, "utf8")); + if ( + !j || + String(j.serverKind || "").toLowerCase() !== + String(serverKind || "").toLowerCase() + ) { + return []; + } + const keys = j.keys; + return Array.isArray(keys) ? keys.map(String).filter(Boolean) : []; + } catch (e) { + return []; + } +} + +function saveRetryKeys(serverKind, keys) { + const uniq = []; + const seen = new Set(); + for (const k of keys || []) { + const s = String(k || "").trim(); + if (!s || seen.has(s)) continue; + seen.add(s); + uniq.push(s); + if (uniq.length >= MAX_KEYS) break; + } + try { + fs.mkdirSync(CACHE_ROOT, { recursive: true }); + fs.writeFileSync( + RETRY_FILE, + JSON.stringify({ + serverKind: String(serverKind || "").toLowerCase(), + savedAt: new Date().toISOString(), + keys: uniq, + }), + "utf8" + ); + } catch (e) { + /* ignore */ + } +} + +function wantPosterForCard(card, imagePull) { + const mt = String((card && card.mediaType) || "").toLowerCase(); + if (mt === "album") return imagePull.albumPoster !== false; + return imagePull.videoPoster !== false; +} + +function posterGap(card, imagePull) { + if (!wantPosterForCard(card, imagePull)) return false; + const dl = String((card && card.posterDownloadURL) || "").trim(); + const url = String((card && card.posterURL) || ""); + if (url.includes("no-poster") || url.includes("no-cover")) { + return !!dl; + } + const fn = posterMetadataDb.normalizeCacheFile(card.posterURL); + if (!fn) return !!dl; + return !posterMetadataDb.fileOk(fn); +} + +function backdropGap(card, imagePull) { + if (imagePull.background === false) return false; + const artUrl = String((card && card.posterArtURL) || "").trim(); + if (!artUrl || !artUrl.includes("/imagecache/")) return false; + const af = posterMetadataDb.normalizeCacheFile(artUrl); + return !!(af && !posterMetadataDb.fileOk(af)); +} + +function logoGap(card, imagePull) { + if (imagePull.logo === false) return false; + const u = String((card && card.posterLogoURL) || "").trim(); + if (!u || !u.includes("/imagecache/")) return false; + const lf = posterMetadataDb.normalizeCacheFile(u); + return !!(lf && !posterMetadataDb.fileOk(lf)); +} + +function cardNeedsRetry(card, imagePull) { + return ( + posterGap(card, imagePull) || + backdropGap(card, imagePull) || + logoGap(card, imagePull) + ); +} + +/** + * Keys to prioritize on the *next* full sync (missing cache files / failed downloads). + * @param {object[]} cards + * @param {string} serverKind + * @param {object} imagePull + * @returns {string[]} + */ +function collectRetryKeysFromCards(cards, serverKind, imagePull) { + const pull = imagePull || {}; + const out = []; + const seen = new Set(); + if (!Array.isArray(cards)) return out; + for (const c of cards) { + if (!c || !cardNeedsRetry(c, pull)) continue; + const k = keyFromCard(c, serverKind); + if (!k || seen.has(k)) continue; + seen.add(k); + out.push(k); + } + return out; +} + +function clearRetryFile() { + try { + if (fs.existsSync(RETRY_FILE)) fs.unlinkSync(RETRY_FILE); + } catch (e) { + /* ignore */ + } +} + +module.exports = { + loadRetryKeys, + saveRetryKeys, + prioritizeOdRaw, + collectRetryKeysFromCards, + keyFromCard, + plexRawKey, + jfRawKey, + kodiRawKey, + clearRetryFile, +}; diff --git a/classes/core/settings.js b/classes/core/settings.js index e146344..be1f5e2 100644 --- a/classes/core/settings.js +++ b/classes/core/settings.js @@ -4,6 +4,17 @@ const DEFAULT_SETTINGS = require("../../consts"); const util = require("../core/utility"); const { requiresMediaServerCredential } = require("../mediaservers/mediaServerFactory"); +/** Normalize form/JSON values to "true" | "false" for settings file (checkboxes, toggles). */ +function toSettingsBoolStr(value, fallback) { + if (value === undefined || value === null || value === "") { + if (fallback === undefined || fallback === null) return "false"; + return fallback === true || fallback === "true" ? "true" : "false"; + } + if (value === true || value === "true" || value === "on" || value === 1 || value === "1") + return "true"; + return "false"; +} + /** * @desc settings object is used to get and set all settings for poster * @returns {} settings @@ -24,6 +35,7 @@ class Settings { this.plexPort = DEFAULT_SETTINGS.plexPort; this.plexToken = DEFAULT_SETTINGS.plexToken; this.onDemandLibraries = DEFAULT_SETTINGS.onDemandLibraries; + this.onDemand3dLibraries = DEFAULT_SETTINGS.onDemand3dLibraries; this.numberOnDemand = DEFAULT_SETTINGS.numberOnDemand; this.onDemandRefresh = DEFAULT_SETTINGS.onDemandRefresh; this.sonarrURL = DEFAULT_SETTINGS.sonarrURL; @@ -70,6 +82,27 @@ class Settings { this.footColour = DEFAULT_SETTINGS.footColour; this.bgColour = DEFAULT_SETTINGS.bgColour; this.enableNS = DEFAULT_SETTINGS.enableNS; + this.nowPlayingEveryPosters = DEFAULT_SETTINGS.nowPlayingEveryPosters; + this.enableNowShowingListInPoster = DEFAULT_SETTINGS.enableNowShowingListInPoster; + this.nowShowingListEveryMins = DEFAULT_SETTINGS.nowShowingListEveryMins; + this.nowShowingListOnly = DEFAULT_SETTINGS.nowShowingListOnly; + this.nowShowingListBanner = DEFAULT_SETTINGS.nowShowingListBanner; + this.nowShowingFillFromServer = DEFAULT_SETTINGS.nowShowingFillFromServer; + this.nowShowingFillLibraryMax = DEFAULT_SETTINGS.nowShowingFillLibraryMax; + this.nowShowingCuratedWeight = DEFAULT_SETTINGS.nowShowingCuratedWeight; + this.nowShowingShowtimeCount = DEFAULT_SETTINGS.nowShowingShowtimeCount; + this.nowShowingShowPrices = DEFAULT_SETTINGS.nowShowingShowPrices; + this.nowShowingAutoPriceEnabled = + DEFAULT_SETTINGS.nowShowingAutoPriceEnabled; + this.nowShowingAutoPriceMin = DEFAULT_SETTINGS.nowShowingAutoPriceMin; + this.nowShowingAutoPriceMax = DEFAULT_SETTINGS.nowShowingAutoPriceMax; + this.nowShowing3dPriceExtra = DEFAULT_SETTINGS.nowShowing3dPriceExtra; + this.nowShowingCurrencyCode = DEFAULT_SETTINGS.nowShowingCurrencyCode; + this.enableNowShowingPageCycle = DEFAULT_SETTINGS.enableNowShowingPageCycle; + this.nowShowingPageCycleEveryMins = + DEFAULT_SETTINGS.nowShowingPageCycleEveryMins; + this.nowShowingPageCycleStayMins = + DEFAULT_SETTINGS.nowShowingPageCycleStayMins; this.enableOD = DEFAULT_SETTINGS.enableOD; this.enableSonarr = DEFAULT_SETTINGS.enableSonarr; this.enableRadarr = DEFAULT_SETTINGS.enableRadarr; @@ -84,6 +117,16 @@ class Settings { this.enableCustomPictures = DEFAULT_SETTINGS.enableCustomPictures; this.enableCustomPictureThemes = DEFAULT_SETTINGS.enableCustomPictureThemes; this.customPictureTheme = DEFAULT_SETTINGS.customPictureTheme; + this.customPictureEveryPosters = DEFAULT_SETTINGS.customPictureEveryPosters; + this.enableAds = DEFAULT_SETTINGS.enableAds; + this.adsOnly = DEFAULT_SETTINGS.adsOnly; + this.adsTheme = DEFAULT_SETTINGS.adsTheme; + this.adsEveryPosters = DEFAULT_SETTINGS.adsEveryPosters; + this.adsCurrencyCode = DEFAULT_SETTINGS.adsCurrencyCode; + this.adsTitleOutline = DEFAULT_SETTINGS.adsTitleOutline; + this.adsRotationSeconds = DEFAULT_SETTINGS.adsRotationSeconds; + this.adsPageStaySeconds = DEFAULT_SETTINGS.adsPageStaySeconds; + this.adsGlobalBackgroundPath = DEFAULT_SETTINGS.adsGlobalBackgroundPath; this.serverID = DEFAULT_SETTINGS.serverID; this.sleepStart = DEFAULT_SETTINGS.sleepStart; this.sleepEnd = DEFAULT_SETTINGS.sleepEnd; @@ -108,6 +151,9 @@ class Settings { DEFAULT_SETTINGS.posterCacheMinAgeBeforeChangeCheckMins; this.preferCachedPosters = DEFAULT_SETTINGS.preferCachedPosters; this.cachedPosterSlideCount = DEFAULT_SETTINGS.cachedPosterSlideCount; + this.tmdbApiKey = DEFAULT_SETTINGS.tmdbApiKey; + this.newFeaturesAcknowledgedVersion = + DEFAULT_SETTINGS.newFeaturesAcknowledgedVersion; return; } @@ -160,6 +206,63 @@ class Settings { // if needed settings values missing, then add them to the object, pending a future save. This is for settings file upgrades of exisitng installs. (when default state should be true) if(readSettings.enableNS==undefined) readSettings.enableNS = 'true'; + if (readSettings.onDemand3dLibraries === undefined) + readSettings.onDemand3dLibraries = DEFAULT_SETTINGS.onDemand3dLibraries; + if (readSettings.nowPlayingEveryPosters === undefined) { + const legacy = readSettings.nowShowingEveryPosters; + readSettings.nowPlayingEveryPosters = + legacy !== undefined && legacy !== null && legacy !== "" + ? Math.max(0, parseInt(legacy, 10) || 0) + : 0; + } + if (readSettings.enableNowShowingListInPoster === undefined) + readSettings.enableNowShowingListInPoster = "false"; + if (readSettings.nowShowingListEveryMins === undefined) { + const legacy = readSettings.nowShowingListEveryPosters; + readSettings.nowShowingListEveryMins = + legacy !== undefined && legacy !== null && legacy !== "" + ? Math.max(0, parseInt(legacy, 10) || 0) + : DEFAULT_SETTINGS.nowShowingListEveryMins; + } + if (readSettings.nowShowingListOnly === undefined) + readSettings.nowShowingListOnly = "false"; + if (readSettings.nowShowingListBanner === undefined) + readSettings.nowShowingListBanner = ""; + if (readSettings.nowShowingFillFromServer === undefined) + readSettings.nowShowingFillFromServer = "false"; + if (readSettings.nowShowingFillLibraryMax === undefined) + readSettings.nowShowingFillLibraryMax = 12; + if (readSettings.nowShowingCuratedWeight === undefined) + readSettings.nowShowingCuratedWeight = 4; + if (readSettings.nowShowingShowtimeCount === undefined) + readSettings.nowShowingShowtimeCount = + DEFAULT_SETTINGS.nowShowingShowtimeCount; + if (readSettings.nowShowingShowPrices === undefined) + readSettings.nowShowingShowPrices = DEFAULT_SETTINGS.nowShowingShowPrices; + if (readSettings.nowShowingAutoPriceEnabled === undefined) + readSettings.nowShowingAutoPriceEnabled = + DEFAULT_SETTINGS.nowShowingAutoPriceEnabled; + if (readSettings.nowShowingAutoPriceMin === undefined) + readSettings.nowShowingAutoPriceMin = + DEFAULT_SETTINGS.nowShowingAutoPriceMin; + if (readSettings.nowShowingAutoPriceMax === undefined) + readSettings.nowShowingAutoPriceMax = + DEFAULT_SETTINGS.nowShowingAutoPriceMax; + if (readSettings.nowShowing3dPriceExtra === undefined) + readSettings.nowShowing3dPriceExtra = + DEFAULT_SETTINGS.nowShowing3dPriceExtra; + if (readSettings.nowShowingCurrencyCode === undefined) + readSettings.nowShowingCurrencyCode = + DEFAULT_SETTINGS.nowShowingCurrencyCode; + if (readSettings.enableNowShowingPageCycle === undefined) + readSettings.enableNowShowingPageCycle = + DEFAULT_SETTINGS.enableNowShowingPageCycle; + if (readSettings.nowShowingPageCycleEveryMins === undefined) + readSettings.nowShowingPageCycleEveryMins = + DEFAULT_SETTINGS.nowShowingPageCycleEveryMins; + if (readSettings.nowShowingPageCycleStayMins === undefined) + readSettings.nowShowingPageCycleStayMins = + DEFAULT_SETTINGS.nowShowingPageCycleStayMins; if(readSettings.enableOD==undefined) readSettings.enableOD = 'true'; if(readSettings.enableSonarr==undefined) readSettings.enableSonarr = 'true'; if(readSettings.enableReadarr==undefined) readSettings.enableReadarr = 'true'; @@ -169,6 +272,31 @@ class Settings { if(readSettings.filterLocal==undefined) readSettings.filterLocal = 'true'; if(readSettings.enableCustomPictures==undefined) readSettings.enableCustomPictures = 'false'; if(readSettings.customPictureTheme==undefined) readSettings.customPictureTheme = 'default'; + if(readSettings.customPictureEveryPosters==undefined) readSettings.customPictureEveryPosters = 0; + if (readSettings.enableAds === undefined) readSettings.enableAds = "false"; + if (readSettings.adsOnly === undefined) readSettings.adsOnly = "false"; + if (readSettings.adsTheme === undefined) readSettings.adsTheme = "default"; + if (readSettings.adsEveryPosters === undefined) readSettings.adsEveryPosters = 0; + if (readSettings.adsCurrencyCode === undefined) + readSettings.adsCurrencyCode = DEFAULT_SETTINGS.adsCurrencyCode; + if (readSettings.adsTitleOutline === undefined) + readSettings.adsTitleOutline = DEFAULT_SETTINGS.adsTitleOutline; + if (readSettings.adsRotationSeconds === undefined) + readSettings.adsRotationSeconds = DEFAULT_SETTINGS.adsRotationSeconds; + if (readSettings.adsPageStaySeconds === undefined) + readSettings.adsPageStaySeconds = DEFAULT_SETTINGS.adsPageStaySeconds; + if (readSettings.adsGlobalBackgroundPath === undefined) + readSettings.adsGlobalBackgroundPath = + DEFAULT_SETTINGS.adsGlobalBackgroundPath; + else if ( + typeof readSettings.adsGlobalBackgroundPath === "string" && + readSettings.adsGlobalBackgroundPath.trim() !== "" && + !readSettings.adsGlobalBackgroundPath + .trim() + .startsWith("/custom/ads-view/") + ) { + readSettings.adsGlobalBackgroundPath = ""; + } if(readSettings.enableSleep==undefined) readSettings.enableSleep = 'false'; if(readSettings.enableTrivia==undefined) readSettings.enableTrivia = 'false'; if(readSettings.enableLinks==undefined) readSettings.enableLinks = 'false'; @@ -193,6 +321,9 @@ class Settings { if(readSettings.posterCacheMinAgeBeforeChangeCheckMins === undefined) readSettings.posterCacheMinAgeBeforeChangeCheckMins = 0; if(readSettings.preferCachedPosters === undefined) readSettings.preferCachedPosters = 'true'; if(readSettings.cachedPosterSlideCount === undefined) readSettings.cachedPosterSlideCount = 48; + if(readSettings.tmdbApiKey === undefined) readSettings.tmdbApiKey = ""; + if (readSettings.newFeaturesAcknowledgedVersion === undefined) + readSettings.newFeaturesAcknowledgedVersion = ""; if(readSettings.enableLidarr==undefined) readSettings.enableLidarr = 'true'; if(readSettings.lidarrURL==undefined) readSettings.lidarrURL = ''; if(readSettings.lidarrToken==undefined) readSettings.lidarrToken = ''; @@ -277,8 +408,15 @@ class Settings { throw error("JSON object not passed"); } - // load existing values - const cs = this.GetSettings(); + let cs = {}; + try { + if (fs.existsSync("config/settings.json")) { + cs = JSON.parse(fs.readFileSync("config/settings.json", "utf-8")); + } + } catch (e) { + cs = {}; + } + // set passed in values from object. if value not passed, then use current settings if (jsonObject.password) this.password = jsonObject.password; else this.password = cs.password; @@ -310,6 +448,13 @@ class Settings { if (jsonObject.plexLibraries) this.onDemandLibraries = jsonObject.plexLibraries; else this.onDemandLibraries = cs.onDemandLibraries; + if (jsonObject.onDemand3dLibraries !== undefined && jsonObject.onDemand3dLibraries !== null) + this.onDemand3dLibraries = String(jsonObject.onDemand3dLibraries); + else + this.onDemand3dLibraries = + cs.onDemand3dLibraries !== undefined + ? cs.onDemand3dLibraries + : DEFAULT_SETTINGS.onDemand3dLibraries; if (jsonObject.numberOnDemand || jsonObject.numberOnDemand==0) this.numberOnDemand = jsonObject.numberOnDemand; else this.numberOnDemand = cs.numberOnDemand; @@ -322,9 +467,10 @@ class Settings { else this.sonarrToken = cs.sonarrToken; if (jsonObject.sonarrDays) this.sonarrCalDays = jsonObject.sonarrDays; else this.sonarrCalDays = cs.sonarrCalDays; - if (jsonObject.premiereSwitch) - this.sonarrPremieres = jsonObject.premiereSwitch; - else this.sonarrPremieres = cs.sonarrPremieres; + this.sonarrPremieres = toSettingsBoolStr( + jsonObject.premiereSwitch, + cs.sonarrPremieres ?? DEFAULT_SETTINGS.sonarrPremieres + ); if (jsonObject.radarrUrl) this.radarrURL = jsonObject.radarrUrl; else this.radarrURL = cs.radarrURL; if (jsonObject.radarrToken) this.radarrToken = jsonObject.radarrToken; @@ -351,30 +497,54 @@ class Settings { : DEFAULT_SETTINGS.bookArrKind; if (jsonObject.artSwitch) this.hasArt = jsonObject.artSwitch; else this.hasArt = cs.hasArt; - if (jsonObject.showCast) this.showCast = jsonObject.showCast; - else this.showCast = cs.showCast; - if (jsonObject.showDirectors) this.showDirectors = jsonObject.showDirectors; - else this.showDirectors = cs.showDirectors; - if (jsonObject.showAuthors) this.showAuthors = jsonObject.showAuthors; - else this.showAuthors = cs.showAuthors; - if (jsonObject.showAlbumArtist) this.showAlbumArtist = jsonObject.showAlbumArtist; - else this.showAlbumArtist = cs.showAlbumArtist; - if (jsonObject.displayPosterAlbum) this.displayPosterAlbum = jsonObject.displayPosterAlbum; - else this.displayPosterAlbum = cs.displayPosterAlbum !== undefined ? cs.displayPosterAlbum : DEFAULT_SETTINGS.displayPosterAlbum; - if (jsonObject.displayPosterVideo) this.displayPosterVideo = jsonObject.displayPosterVideo; - else this.displayPosterVideo = cs.displayPosterVideo !== undefined ? cs.displayPosterVideo : DEFAULT_SETTINGS.displayPosterVideo; - if (jsonObject.displayPosterBooks) this.displayPosterBooks = jsonObject.displayPosterBooks; - else this.displayPosterBooks = cs.displayPosterBooks !== undefined ? cs.displayPosterBooks : DEFAULT_SETTINGS.displayPosterBooks; - if (jsonObject.displayPosterActor) this.displayPosterActor = jsonObject.displayPosterActor; - else this.displayPosterActor = cs.displayPosterActor !== undefined ? cs.displayPosterActor : DEFAULT_SETTINGS.displayPosterActor; - if (jsonObject.displayPosterActress) this.displayPosterActress = jsonObject.displayPosterActress; - else this.displayPosterActress = cs.displayPosterActress !== undefined ? cs.displayPosterActress : DEFAULT_SETTINGS.displayPosterActress; - if (jsonObject.displayPosterDirector) this.displayPosterDirector = jsonObject.displayPosterDirector; - else this.displayPosterDirector = cs.displayPosterDirector !== undefined ? cs.displayPosterDirector : DEFAULT_SETTINGS.displayPosterDirector; - if (jsonObject.displayPosterAuthor) this.displayPosterAuthor = jsonObject.displayPosterAuthor; - else this.displayPosterAuthor = cs.displayPosterAuthor !== undefined ? cs.displayPosterAuthor : DEFAULT_SETTINGS.displayPosterAuthor; - if (jsonObject.displayPosterArtist) this.displayPosterArtist = jsonObject.displayPosterArtist; - else this.displayPosterArtist = cs.displayPosterArtist !== undefined ? cs.displayPosterArtist : DEFAULT_SETTINGS.displayPosterArtist; + this.showCast = toSettingsBoolStr( + jsonObject.showCast, + cs.showCast ?? DEFAULT_SETTINGS.showCast + ); + this.showDirectors = toSettingsBoolStr( + jsonObject.showDirectors, + cs.showDirectors ?? DEFAULT_SETTINGS.showDirectors + ); + this.showAuthors = toSettingsBoolStr( + jsonObject.showAuthors, + cs.showAuthors ?? DEFAULT_SETTINGS.showAuthors + ); + this.showAlbumArtist = toSettingsBoolStr( + jsonObject.showAlbumArtist, + cs.showAlbumArtist ?? DEFAULT_SETTINGS.showAlbumArtist + ); + this.displayPosterAlbum = toSettingsBoolStr( + jsonObject.displayPosterAlbum, + cs.displayPosterAlbum ?? DEFAULT_SETTINGS.displayPosterAlbum + ); + this.displayPosterVideo = toSettingsBoolStr( + jsonObject.displayPosterVideo, + cs.displayPosterVideo ?? DEFAULT_SETTINGS.displayPosterVideo + ); + this.displayPosterBooks = toSettingsBoolStr( + jsonObject.displayPosterBooks, + cs.displayPosterBooks ?? DEFAULT_SETTINGS.displayPosterBooks + ); + this.displayPosterActor = toSettingsBoolStr( + jsonObject.displayPosterActor, + cs.displayPosterActor ?? DEFAULT_SETTINGS.displayPosterActor + ); + this.displayPosterActress = toSettingsBoolStr( + jsonObject.displayPosterActress, + cs.displayPosterActress ?? DEFAULT_SETTINGS.displayPosterActress + ); + this.displayPosterDirector = toSettingsBoolStr( + jsonObject.displayPosterDirector, + cs.displayPosterDirector ?? DEFAULT_SETTINGS.displayPosterDirector + ); + this.displayPosterAuthor = toSettingsBoolStr( + jsonObject.displayPosterAuthor, + cs.displayPosterAuthor ?? DEFAULT_SETTINGS.displayPosterAuthor + ); + this.displayPosterArtist = toSettingsBoolStr( + jsonObject.displayPosterArtist, + cs.displayPosterArtist ?? DEFAULT_SETTINGS.displayPosterArtist + ); if (jsonObject.shuffleSwitch) this.shuffleSlides = jsonObject.shuffleSwitch; else this.shuffleSlides = cs.shuffleSlides; if (jsonObject.genres) this.genres = jsonObject.genres; @@ -413,6 +583,196 @@ class Settings { else this.bgColour = cs.bgColour; if (jsonObject.enableNS) this.enableNS = jsonObject.enableNS; else this.enableNS = "false"; + if ( + jsonObject.nowPlayingEveryPosters !== undefined && + jsonObject.nowPlayingEveryPosters !== null && + jsonObject.nowPlayingEveryPosters !== "" + ) { + const n = parseInt(jsonObject.nowPlayingEveryPosters, 10); + this.nowPlayingEveryPosters = isNaN(n) ? 0 : Math.max(0, n); + } else { + this.nowPlayingEveryPosters = + cs.nowPlayingEveryPosters !== undefined + ? cs.nowPlayingEveryPosters + : DEFAULT_SETTINGS.nowPlayingEveryPosters; + } + this.enableNowShowingListInPoster = toSettingsBoolStr( + jsonObject.enableNowShowingListInPoster, + cs.enableNowShowingListInPoster ?? DEFAULT_SETTINGS.enableNowShowingListInPoster + ); + if ( + jsonObject.nowShowingListEveryMins !== undefined && + jsonObject.nowShowingListEveryMins !== null && + jsonObject.nowShowingListEveryMins !== "" + ) { + const n = parseInt(jsonObject.nowShowingListEveryMins, 10); + this.nowShowingListEveryMins = isNaN(n) ? 0 : Math.max(0, n); + } else { + this.nowShowingListEveryMins = + cs.nowShowingListEveryMins !== undefined + ? cs.nowShowingListEveryMins + : cs.nowShowingListEveryPosters !== undefined + ? cs.nowShowingListEveryPosters + : DEFAULT_SETTINGS.nowShowingListEveryMins; + } + this.nowShowingListOnly = toSettingsBoolStr( + jsonObject.nowShowingListOnly, + cs.nowShowingListOnly ?? DEFAULT_SETTINGS.nowShowingListOnly + ); + if (jsonObject.nowShowingListBanner !== undefined && jsonObject.nowShowingListBanner !== null) { + this.nowShowingListBanner = String(jsonObject.nowShowingListBanner); + } else { + this.nowShowingListBanner = + cs.nowShowingListBanner !== undefined + ? cs.nowShowingListBanner + : DEFAULT_SETTINGS.nowShowingListBanner; + } + this.nowShowingFillFromServer = toSettingsBoolStr( + jsonObject.nowShowingFillFromServer, + cs.nowShowingFillFromServer ?? DEFAULT_SETTINGS.nowShowingFillFromServer + ); + if ( + jsonObject.nowShowingFillLibraryMax !== undefined && + jsonObject.nowShowingFillLibraryMax !== null && + jsonObject.nowShowingFillLibraryMax !== "" + ) { + const n = parseInt(jsonObject.nowShowingFillLibraryMax, 10); + this.nowShowingFillLibraryMax = isNaN(n) ? 0 : Math.max(0, Math.min(48, n)); + } else { + this.nowShowingFillLibraryMax = + cs.nowShowingFillLibraryMax !== undefined + ? cs.nowShowingFillLibraryMax + : DEFAULT_SETTINGS.nowShowingFillLibraryMax; + } + if ( + jsonObject.nowShowingCuratedWeight !== undefined && + jsonObject.nowShowingCuratedWeight !== null && + jsonObject.nowShowingCuratedWeight !== "" + ) { + const n = parseInt(jsonObject.nowShowingCuratedWeight, 10); + this.nowShowingCuratedWeight = isNaN(n) + ? DEFAULT_SETTINGS.nowShowingCuratedWeight + : Math.max(1, Math.min(20, n)); + } else { + this.nowShowingCuratedWeight = + cs.nowShowingCuratedWeight !== undefined + ? cs.nowShowingCuratedWeight + : DEFAULT_SETTINGS.nowShowingCuratedWeight; + } + if ( + jsonObject.nowShowingShowtimeCount !== undefined && + jsonObject.nowShowingShowtimeCount !== null && + jsonObject.nowShowingShowtimeCount !== "" + ) { + const n = parseInt(jsonObject.nowShowingShowtimeCount, 10); + this.nowShowingShowtimeCount = isNaN(n) + ? DEFAULT_SETTINGS.nowShowingShowtimeCount + : Math.max(1, Math.min(6, n)); + } else { + this.nowShowingShowtimeCount = + cs.nowShowingShowtimeCount !== undefined + ? cs.nowShowingShowtimeCount + : DEFAULT_SETTINGS.nowShowingShowtimeCount; + } + this.nowShowingShowPrices = toSettingsBoolStr( + jsonObject.nowShowingShowPrices, + cs.nowShowingShowPrices ?? DEFAULT_SETTINGS.nowShowingShowPrices + ); + this.nowShowingAutoPriceEnabled = toSettingsBoolStr( + jsonObject.nowShowingAutoPriceEnabled, + cs.nowShowingAutoPriceEnabled ?? DEFAULT_SETTINGS.nowShowingAutoPriceEnabled + ); + if ( + jsonObject.nowShowingAutoPriceMin !== undefined && + jsonObject.nowShowingAutoPriceMin !== null && + jsonObject.nowShowingAutoPriceMin !== "" + ) { + const n = parseFloat(jsonObject.nowShowingAutoPriceMin); + this.nowShowingAutoPriceMin = isNaN(n) ? DEFAULT_SETTINGS.nowShowingAutoPriceMin : Math.max(0, n); + } else { + this.nowShowingAutoPriceMin = + cs.nowShowingAutoPriceMin !== undefined + ? cs.nowShowingAutoPriceMin + : DEFAULT_SETTINGS.nowShowingAutoPriceMin; + } + if ( + jsonObject.nowShowingAutoPriceMax !== undefined && + jsonObject.nowShowingAutoPriceMax !== null && + jsonObject.nowShowingAutoPriceMax !== "" + ) { + const n = parseFloat(jsonObject.nowShowingAutoPriceMax); + this.nowShowingAutoPriceMax = isNaN(n) ? DEFAULT_SETTINGS.nowShowingAutoPriceMax : Math.max(0, n); + } else { + this.nowShowingAutoPriceMax = + cs.nowShowingAutoPriceMax !== undefined + ? cs.nowShowingAutoPriceMax + : DEFAULT_SETTINGS.nowShowingAutoPriceMax; + } + if ( + jsonObject.nowShowing3dPriceExtra !== undefined && + jsonObject.nowShowing3dPriceExtra !== null && + jsonObject.nowShowing3dPriceExtra !== "" + ) { + const n = parseFloat(jsonObject.nowShowing3dPriceExtra); + this.nowShowing3dPriceExtra = isNaN(n) + ? DEFAULT_SETTINGS.nowShowing3dPriceExtra + : Math.max(0, n); + } else { + this.nowShowing3dPriceExtra = + cs.nowShowing3dPriceExtra !== undefined + ? cs.nowShowing3dPriceExtra + : DEFAULT_SETTINGS.nowShowing3dPriceExtra; + } + if ( + jsonObject.nowShowingCurrencyCode !== undefined && + jsonObject.nowShowingCurrencyCode !== null && + jsonObject.nowShowingCurrencyCode !== "" + ) { + const code = String(jsonObject.nowShowingCurrencyCode).trim().toUpperCase(); + const allowed = ["USD", "EUR", "GBP", "CAD", "AUD", "NZD", "JPY"]; + this.nowShowingCurrencyCode = allowed.includes(code) + ? code + : DEFAULT_SETTINGS.nowShowingCurrencyCode; + } else { + this.nowShowingCurrencyCode = + cs.nowShowingCurrencyCode !== undefined + ? cs.nowShowingCurrencyCode + : DEFAULT_SETTINGS.nowShowingCurrencyCode; + } + this.enableNowShowingPageCycle = toSettingsBoolStr( + jsonObject.enableNowShowingPageCycle, + cs.enableNowShowingPageCycle ?? DEFAULT_SETTINGS.enableNowShowingPageCycle + ); + if ( + jsonObject.nowShowingPageCycleEveryMins !== undefined && + jsonObject.nowShowingPageCycleEveryMins !== null && + jsonObject.nowShowingPageCycleEveryMins !== "" + ) { + const n = parseInt(jsonObject.nowShowingPageCycleEveryMins, 10); + this.nowShowingPageCycleEveryMins = isNaN(n) + ? DEFAULT_SETTINGS.nowShowingPageCycleEveryMins + : Math.max(1, Math.min(1440, n)); + } else { + this.nowShowingPageCycleEveryMins = + cs.nowShowingPageCycleEveryMins !== undefined + ? cs.nowShowingPageCycleEveryMins + : DEFAULT_SETTINGS.nowShowingPageCycleEveryMins; + } + if ( + jsonObject.nowShowingPageCycleStayMins !== undefined && + jsonObject.nowShowingPageCycleStayMins !== null && + jsonObject.nowShowingPageCycleStayMins !== "" + ) { + const n = parseInt(jsonObject.nowShowingPageCycleStayMins, 10); + this.nowShowingPageCycleStayMins = isNaN(n) + ? DEFAULT_SETTINGS.nowShowingPageCycleStayMins + : Math.max(1, Math.min(120, n)); + } else { + this.nowShowingPageCycleStayMins = + cs.nowShowingPageCycleStayMins !== undefined + ? cs.nowShowingPageCycleStayMins + : DEFAULT_SETTINGS.nowShowingPageCycleStayMins; + } if (jsonObject.enableOD) this.enableOD = jsonObject.enableOD; else this.enableOD = "false"; if (jsonObject.enableSonarr) this.enableSonarr = jsonObject.enableSonarr; @@ -441,6 +801,135 @@ class Settings { else this.enableCustomPictureThemes = cs.enableCustomPictureThemes; if (jsonObject.customPictureTheme) this.customPictureTheme = jsonObject.customPictureTheme; else this.customPictureTheme = cs.customPictureTheme; + if ( + jsonObject.customPictureEveryPosters !== undefined && + jsonObject.customPictureEveryPosters !== null && + jsonObject.customPictureEveryPosters !== "" + ) { + const n = parseInt(jsonObject.customPictureEveryPosters, 10); + this.customPictureEveryPosters = isNaN(n) ? 0 : Math.max(0, n); + } else { + this.customPictureEveryPosters = + cs.customPictureEveryPosters !== undefined + ? cs.customPictureEveryPosters + : DEFAULT_SETTINGS.customPictureEveryPosters; + } + if (jsonObject.enableAds) this.enableAds = jsonObject.enableAds; + else + this.enableAds = + cs.enableAds !== undefined ? cs.enableAds : DEFAULT_SETTINGS.enableAds; + if ( + jsonObject.adsOnly !== undefined && + jsonObject.adsOnly !== null && + jsonObject.adsOnly !== "" + ) { + const s = String(jsonObject.adsOnly).toLowerCase().trim(); + this.adsOnly = + s === "true" || s === "on" || s === "1" ? "true" : "false"; + } else { + this.adsOnly = + cs.adsOnly !== undefined ? cs.adsOnly : DEFAULT_SETTINGS.adsOnly; + } + if (jsonObject.adsTheme) this.adsTheme = String(jsonObject.adsTheme); + else + this.adsTheme = + cs.adsTheme !== undefined ? cs.adsTheme : DEFAULT_SETTINGS.adsTheme; + if ( + jsonObject.adsEveryPosters !== undefined && + jsonObject.adsEveryPosters !== null && + jsonObject.adsEveryPosters !== "" + ) { + const n = parseInt(jsonObject.adsEveryPosters, 10); + this.adsEveryPosters = isNaN(n) ? 0 : Math.max(0, n); + } else { + this.adsEveryPosters = + cs.adsEveryPosters !== undefined + ? cs.adsEveryPosters + : DEFAULT_SETTINGS.adsEveryPosters; + } + if ( + jsonObject.adsCurrencyCode !== undefined && + jsonObject.adsCurrencyCode !== null && + jsonObject.adsCurrencyCode !== "" + ) { + const code = String(jsonObject.adsCurrencyCode).trim().toUpperCase(); + const allowed = ["USD", "EUR", "GBP", "CAD", "AUD", "NZD", "JPY"]; + this.adsCurrencyCode = allowed.includes(code) + ? code + : DEFAULT_SETTINGS.adsCurrencyCode; + } else { + this.adsCurrencyCode = + cs.adsCurrencyCode !== undefined + ? cs.adsCurrencyCode + : DEFAULT_SETTINGS.adsCurrencyCode; + } + if ( + jsonObject.adsTitleOutline !== undefined && + jsonObject.adsTitleOutline !== null && + jsonObject.adsTitleOutline !== "" + ) { + const s = String(jsonObject.adsTitleOutline).toLowerCase().trim(); + this.adsTitleOutline = + s === "true" || s === "on" || s === "1" ? "true" : "false"; + } else { + this.adsTitleOutline = + cs.adsTitleOutline !== undefined + ? cs.adsTitleOutline + : DEFAULT_SETTINGS.adsTitleOutline; + } + if ( + jsonObject.adsRotationSeconds !== undefined && + jsonObject.adsRotationSeconds !== null && + jsonObject.adsRotationSeconds !== "" + ) { + const n = parseInt(jsonObject.adsRotationSeconds, 10); + this.adsRotationSeconds = isNaN(n) + ? DEFAULT_SETTINGS.adsRotationSeconds + : Math.min(600, Math.max(3, n)); + } else { + this.adsRotationSeconds = + cs.adsRotationSeconds !== undefined + ? cs.adsRotationSeconds + : DEFAULT_SETTINGS.adsRotationSeconds; + } + if ( + jsonObject.adsPageStaySeconds !== undefined && + jsonObject.adsPageStaySeconds !== null && + jsonObject.adsPageStaySeconds !== "" + ) { + const n = parseInt(jsonObject.adsPageStaySeconds, 10); + if (isNaN(n) || n <= 0) { + this.adsPageStaySeconds = 0; + } else { + this.adsPageStaySeconds = Math.min(86400, Math.max(30, n)); + } + } else { + this.adsPageStaySeconds = + cs.adsPageStaySeconds !== undefined + ? cs.adsPageStaySeconds + : DEFAULT_SETTINGS.adsPageStaySeconds; + } + if ( + jsonObject.adsGlobalBackgroundPath !== undefined && + jsonObject.adsGlobalBackgroundPath !== null + ) { + const raw = String(jsonObject.adsGlobalBackgroundPath).trim(); + if (raw === "") { + this.adsGlobalBackgroundPath = ""; + } else if (raw.startsWith("/custom/ads-view/")) { + this.adsGlobalBackgroundPath = raw; + } else { + this.adsGlobalBackgroundPath = + cs.adsGlobalBackgroundPath !== undefined + ? cs.adsGlobalBackgroundPath + : DEFAULT_SETTINGS.adsGlobalBackgroundPath; + } + } else { + this.adsGlobalBackgroundPath = + cs.adsGlobalBackgroundPath !== undefined + ? cs.adsGlobalBackgroundPath + : DEFAULT_SETTINGS.adsGlobalBackgroundPath; + } if (jsonObject.serverID) this.serverID = jsonObject.serverID; else this.serverID = cs.serverID; if (jsonObject.sleepStart) this.sleepStart = jsonObject.sleepStart; @@ -518,6 +1007,25 @@ class Settings { ? cs.cachedPosterSlideCount : DEFAULT_SETTINGS.cachedPosterSlideCount; } + if (jsonObject.tmdbApiKey !== undefined && jsonObject.tmdbApiKey !== null) { + this.tmdbApiKey = String(jsonObject.tmdbApiKey); + } else { + this.tmdbApiKey = + cs.tmdbApiKey !== undefined ? cs.tmdbApiKey : DEFAULT_SETTINGS.tmdbApiKey; + } + if ( + jsonObject.newFeaturesAcknowledgedVersion !== undefined && + jsonObject.newFeaturesAcknowledgedVersion !== null + ) { + this.newFeaturesAcknowledgedVersion = String( + jsonObject.newFeaturesAcknowledgedVersion + ); + } else { + this.newFeaturesAcknowledgedVersion = + cs.newFeaturesAcknowledgedVersion !== undefined + ? cs.newFeaturesAcknowledgedVersion + : DEFAULT_SETTINGS.newFeaturesAcknowledgedVersion; + } // convert JSON object to string (pretty format) const data = JSON.stringify(this, null, 4); diff --git a/classes/core/tmdbBackdropFallback.js b/classes/core/tmdbBackdropFallback.js new file mode 100644 index 0000000..cdfa35d --- /dev/null +++ b/classes/core/tmdbBackdropFallback.js @@ -0,0 +1,290 @@ +const axios = require("axios"); + +const TMDB_BASE = "https://api.themoviedb.org/3"; +const TMDB_IMG_W1280 = "https://image.tmdb.org/t/p/w1280"; + +/** In-process cache to limit duplicate TMDB calls during large syncs. */ +const backdropUrlCache = new Map(); + +function cacheGet(key) { + return backdropUrlCache.has(key) ? backdropUrlCache.get(key) : undefined; +} + +function cacheSet(key, val) { + if (backdropUrlCache.size > 6000) backdropUrlCache.clear(); + backdropUrlCache.set(key, val); +} + +async function fetchJson(url, params) { + const r = await axios.get(url, { params, timeout: 20000 }); + return r.data; +} + +function backdropPathToUrl(p) { + if (!p || typeof p !== "string") return null; + return TMDB_IMG_W1280 + p; +} + +/** + * Parse Plex agent guids (md.guid and md.Guid[]) for TMDB / IMDb / TVDB ids. + * @param {object} md Plex metadata object + */ +function collectPlexExternalIds(md) { + const out = {}; + const list = []; + if (md && md.Guid && Array.isArray(md.Guid)) { + for (const g of md.Guid) { + const id = g && (g.id || g.Id); + if (id) list.push(String(id)); + } + } + if (md && md.guid) list.push(String(md.guid)); + for (const s of list) { + const str = String(s || ""); + let m = str.match(/themoviedb:\/\/(\d+)/i); + if (m) out.tmdbId = parseInt(m[1], 10); + m = str.match(/imdb:\/\/(tt\d+)/i); + if (m) out.imdbId = m[1]; + m = str.match(/imdb:\/\/(\d+)/i); + if (m && !out.imdbId) out.imdbId = "tt" + m[1]; + m = str.match(/tvdb:\/\/(\d+)/i); + if (m) out.tvdbId = m[1]; + } + return out; +} + +/** + * Jellyfin / Emby ProviderIds. + * @param {object} md Item from API + */ +function collectJellyfinProviderIds(md) { + if (!md) return {}; + const p = md.ProviderIds || md.providerIds || {}; + const tRaw = + p.Tmdb || + p.TMDB || + p.tmdb || + p.TmdbId || + p.TmdbID || + ""; + const tmdbId = + tRaw !== "" && tRaw != null ? parseInt(String(tRaw).replace(/\D/g, ""), 10) : NaN; + const imdbRaw = p.Imdb || p.IMDB || p.imdb || ""; + const tvdbRaw = p.Tvdb || p.TVDB || p.tvdb || ""; + return { + tmdbId: !isNaN(tmdbId) && tmdbId > 0 ? tmdbId : undefined, + imdbId: imdbRaw ? String(imdbRaw).trim() : undefined, + tvdbId: tvdbRaw != null && tvdbRaw !== "" ? String(tvdbRaw).trim() : undefined, + }; +} + +/** + * Kodi movie / TV show item (imdbnumber, uniqueid map). + * @param {object} md + */ +function collectKodiExternalIds(md) { + if (!md) return {}; + const out = {}; + if (md.imdbnumber) { + const im = String(md.imdbnumber).trim(); + out.imdbId = im.startsWith("tt") ? im : "tt" + im.replace(/^tt?/i, ""); + } + const u = md.uniqueid; + if (u && typeof u === "object") { + if (u.tmdb && !out.tmdbId) { + const t = parseInt(String(u.tmdb), 10); + if (!isNaN(t) && t > 0) out.tmdbId = t; + } + if (u.imdb) out.imdbId = String(u.imdb).trim(); + if (u.tvdb) out.tvdbId = String(u.tvdb).trim(); + } + return out; +} + +async function getDetailsBackdrop(apiKey, tmdbNumericId, isTv) { + const key = `${isTv ? "tv" : "m"}:${tmdbNumericId}`; + const cached = cacheGet(key); + if (cached !== undefined) return cached; + const path = isTv ? `/tv/${tmdbNumericId}` : `/movie/${tmdbNumericId}`; + try { + const d = await fetchJson(`${TMDB_BASE}${path}`, { api_key: apiKey }); + const url = backdropPathToUrl(d.backdrop_path); + cacheSet(key, url); + return url; + } catch (e) { + cacheSet(key, null); + return null; + } +} + +async function findByImdb(apiKey, imdbId) { + const raw = String(imdbId || "").trim(); + if (!raw) return null; + const tt = raw.startsWith("tt") ? raw : "tt" + raw.replace(/^tt?/i, ""); + const cacheKey = `find:imdb:${tt}`; + const cached = cacheGet(cacheKey); + if (cached !== undefined) return cached; + try { + const d = await fetchJson( + `${TMDB_BASE}/find/${encodeURIComponent(tt)}`, + { + api_key: apiKey, + external_source: "imdb_id", + } + ); + const mv = d.movie_results && d.movie_results[0]; + const tv = d.tv_results && d.tv_results[0]; + let url = mv && backdropPathToUrl(mv.backdrop_path); + if (!url && tv) url = backdropPathToUrl(tv.backdrop_path); + cacheSet(cacheKey, url || null); + return url || null; + } catch (e) { + cacheSet(cacheKey, null); + return null; + } +} + +async function findByTvdb(apiKey, tvdbId) { + const id = String(tvdbId || "").trim(); + if (!id) return null; + const cacheKey = `find:tvdb:${id}`; + const cached = cacheGet(cacheKey); + if (cached !== undefined) return cached; + try { + const d = await fetchJson(`${TMDB_BASE}/find/${encodeURIComponent(id)}`, { + api_key: apiKey, + external_source: "tvdb_id", + }); + const tv = d.tv_results && d.tv_results[0]; + const url = tv && backdropPathToUrl(tv.backdrop_path); + cacheSet(cacheKey, url || null); + return url || null; + } catch (e) { + cacheSet(cacheKey, null); + return null; + } +} + +async function searchByTitle(apiKey, title, yearStr, isTv) { + const q = String(title || "").trim(); + if (!q) return null; + const cacheKey = `search:${isTv ? "tv" : "mv"}:${q}|${yearStr || ""}`; + const cached = cacheGet(cacheKey); + if (cached !== undefined) return cached; + const path = isTv ? "/search/tv" : "/search/movie"; + const params = { + api_key: apiKey, + query: q, + include_adult: false, + page: 1, + }; + const y = parseInt(String(yearStr || "").slice(0, 4), 10); + if (!isNaN(y) && y > 1800) { + if (isTv) params.first_air_date_year = y; + else params.year = y; + } + try { + const d = await fetchJson(`${TMDB_BASE}${path}`, params); + const results = (d && d.results) || []; + for (const item of results.slice(0, 6)) { + const u = backdropPathToUrl(item.backdrop_path); + if (u) { + cacheSet(cacheKey, u); + return u; + } + } + const first = results[0]; + if (first && first.id) { + const u = await getDetailsBackdrop(apiKey, first.id, isTv); + cacheSet(cacheKey, u || null); + return u; + } + cacheSet(cacheKey, null); + return null; + } catch (e) { + cacheSet(cacheKey, null); + return null; + } +} + +/** + * Resolve a TMDB backdrop (wide) image URL for movies or TV using ids or title search. + * Skips music-only types. + * + * @param {{ apiKey: string, mediaType: string, title?: string, year?: string|number, tmdbId?: number, imdbId?: string, tvdbId?: string|number }} ctx + * @returns {Promise} HTTPS image URL or null + */ +async function resolveTmdbBackdropImageUrl(ctx) { + const apiKey = String(ctx.apiKey || "").trim(); + if (!apiKey) return null; + const mt = String(ctx.mediaType || "").toLowerCase(); + if (mt === "album" || mt === "track" || mt === "artist") return null; + const isTv = mt === "show" || mt === "series" || mt === "episode"; + + const tmdbRaw = ctx.tmdbId; + const tmdbNum = + tmdbRaw != null && tmdbRaw !== "" + ? parseInt(String(tmdbRaw).replace(/\D/g, ""), 10) + : NaN; + if (!isNaN(tmdbNum) && tmdbNum > 0) { + const u = await getDetailsBackdrop(apiKey, tmdbNum, isTv); + if (u) return u; + } + + if (ctx.imdbId) { + const u = await findByImdb(apiKey, ctx.imdbId); + if (u) return u; + } + + if (isTv && ctx.tvdbId) { + const u = await findByTvdb(apiKey, ctx.tvdbId); + if (u) return u; + } + + const year = + ctx.year != null && ctx.year !== "" + ? String(ctx.year).slice(0, 4) + : ""; + return searchByTitle(apiKey, ctx.title, year, isTv); +} + +/** + * When the media server did not supply a banner, fetch a TMDB backdrop into imagecache. + * + * @param {{ tmdbApiKey: string, pullBackground: boolean, serverBannerOk: boolean, mediaType: string, title?: string, year?: string|number, tmdbId?: number, imdbId?: string, tvdbId?: string|number, bannerFileName: string, medCard: object, cacheImage: function }} p + */ +async function cacheTmdbBannerIfNeeded(p) { + const key = String(p.tmdbApiKey || process.env.TMDB_API_KEY || "").trim(); + if (!p.pullBackground || !key || p.serverBannerOk) return; + const mt = String(p.mediaType || "").toLowerCase(); + if (mt === "album") return; + const bn = String(p.bannerFileName || "").trim(); + if (!bn || bn.includes("..") || /[\\/]/.test(bn)) return; + + const url = await resolveTmdbBackdropImageUrl({ + apiKey: key, + mediaType: mt, + title: p.title, + year: p.year, + tmdbId: p.tmdbId, + imdbId: p.imdbId, + tvdbId: p.tvdbId, + }); + if (!url) return; + try { + await p.cacheImage(url, bn); + if (!p.medCard.posterArtURL) { + p.medCard.posterArtURL = "/imagecache/" + bn; + } + } catch (e) { + /* optional */ + } +} + +module.exports = { + collectPlexExternalIds, + collectJellyfinProviderIds, + collectKodiExternalIds, + resolveTmdbBackdropImageUrl, + cacheTmdbBannerIfNeeded, +}; diff --git a/classes/core/utility.js b/classes/core/utility.js index 6812dab..1130a88 100644 --- a/classes/core/utility.js +++ b/classes/core/utility.js @@ -131,38 +131,39 @@ class utility { if (options && options.includeAll === true) { return Array.isArray(mediaCards) ? mediaCards.slice() : []; } - let onDemandCards = []; - let libTooSmall = false; - if(recentlyAdded > 0) { + if (recentlyAdded > 0) { return mediaCards; } - else{ - for await (let i of Array(numberOnDemand).keys()) { - let odc; - odc = await this.random_item(mediaCards); - let tryCount = 0; - // try at least five times to get unique random titles. If not, then ommit - while(onDemandCards.includes(odc) && tryCount < 5){ - //console.log('Dupe found:' + odc.title); - tryCount++; - odc = await this.random_item(mediaCards); - } - // finally, if card still a duplicate, then ommit from display - if(!onDemandCards.includes(odc)){ - onDemandCards.push(odc); - } - else{ - libTooSmall = true; - } - } - // display a warning if 'number to display' was too large for library size. - if(libTooSmall && mediaCards.length !== 0){ - let d = new Date(); - console.log(d.toLocaleString() + " ✘✘ WARNING ✘✘ On-demand library too small to get consistent unique titles. Requested titles reduced. (Reduce the 'number to display')"); - } - return onDemandCards; + const pool = Array.isArray(mediaCards) ? mediaCards.slice() : []; + if (pool.length === 0) { + return []; } - + const requested = Math.max(0, Math.floor(Number(numberOnDemand))); + const want = Math.min(requested, pool.length); + if (want === 0) { + return []; + } + // Fisher–Yates shuffle, then take first `want` — always unique (reference equality per slot). + for (let i = pool.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + const t = pool[i]; + pool[i] = pool[j]; + pool[j] = t; + } + if (requested > pool.length && pool.length > 0) { + const d = new Date(); + console.log( + d.toLocaleString() + + " *On-demand — requested " + + requested + + " unique title(s) but only " + + pool.length + + " match the current library/filters; showing " + + pool.length + + ". (Lower “number to display” or widen libraries/filters.)" + ); + } + return pool.slice(0, want); } } diff --git a/classes/custom/pictures.js b/classes/custom/pictures.js index 4c7b7ec..eae1dfb 100644 --- a/classes/custom/pictures.js +++ b/classes/custom/pictures.js @@ -4,6 +4,7 @@ const path = require('path'); const fs = require('fs'); const util = require('util'); const fsPromises = fs.promises; +const { CUSTOM_PICTURES_DIR } = require("../core/appPaths"); /** * @desc Used to get a list of custom pictures @@ -11,11 +12,25 @@ const fsPromises = fs.promises; class Pictures { constructor() { } + normalizeThemeFolders(themeValue) { + if (Array.isArray(themeValue)) { + return themeValue + .map((v) => String(v || "").trim()) + .filter(Boolean); + } + const raw = String(themeValue == null ? "" : themeValue).trim(); + if (!raw) return ["default"]; + return raw + .split(",") + .map((v) => String(v || "").trim()) + .filter(Boolean); + } + async GetFiles(directoryPath) { let pictures = []; //passsing directoryPath and callback function - return await fsPromises.readdir('public/custom/pictures/' + directoryPath).then( + return await fsPromises.readdir(path.join(CUSTOM_PICTURES_DIR, directoryPath)).then( function (result) { // get suitable posters let posters = []; @@ -90,7 +105,16 @@ class Pictures { * @desc Custom picture slide array */ async GetPictures(theme, hasThemes, hasArt) { - const pics = await this.GetFiles(theme); + const folders = this.normalizeThemeFolders(theme); + let pics = []; + for (const folder of folders) { + try { + const next = await this.GetFiles(folder); + if (Array.isArray(next) && next.length) pics = pics.concat(next); + } catch (e) { + // ignore bad/missing folders; other selected folders can still load + } + } let picCards = []; // Example format needed for pictures object diff --git a/classes/mediaservers/embyJellyfinBase.js b/classes/mediaservers/embyJellyfinBase.js index 93c338d..d3218f2 100644 --- a/classes/mediaservers/embyJellyfinBase.js +++ b/classes/mediaservers/embyJellyfinBase.js @@ -1,9 +1,19 @@ const axios = require("axios"); +const fs = require("fs"); +const path = require("path"); const mediaCard = require("./../cards/MediaCard"); const cType = require("./../cards/CardType"); const util = require("./../core/utility"); const core = require("./../core/cache"); +const { IMAGE_CACHE_DIR } = require("./../core/appPaths"); +const tmdbBackdropFallback = require("./../core/tmdbBackdropFallback"); const posterSyncLib = require("./../core/posterSyncProgress"); +const posterSyncRetry = require("./../core/posterSyncRetry"); +const posterMetadataDb = require("./../core/posterMetadataDb"); +const { + PosterSyncAbortedError, + checkPosterSyncAborted, +} = require("./../core/posterSyncAbort"); const { CardTypeEnum } = require("./../cards/CardType"); const posterrPackage = require("../../package.json"); @@ -12,7 +22,200 @@ const posterrPackage = require("../../package.json"); * libraries, slow storage, or remote Jellyfin; poster sync uses this per page. */ const LIBRARY_ITEMS_PAGE_TIMEOUT_MS = 600000; -const POSTER_SYNC_BATCH_SIZE = 100; + +/** + * How many items each library page requests. Fewer HTTP round trips = faster listing on LAN. + * If Jellyfin times out or returns errors, set env POSTERR_JELLYFIN_LIBRARY_PAGE_LIMIT lower (min 50). + * @see process.env.POSTERR_JELLYFIN_LIBRARY_PAGE_LIMIT — integer 50–500, default 300 + */ +const LIBRARY_ITEMS_PAGE_LIMIT = (() => { + const raw = parseInt(process.env.POSTERR_JELLYFIN_LIBRARY_PAGE_LIMIT, 10); + if (Number.isFinite(raw) && raw >= 50 && raw <= 500) return raw; + return 300; +})(); +const LIBRARY_ITEMS_FIRST_PAGE_LIMIT = (() => { + const raw = parseInt( + process.env.POSTERR_JELLYFIN_LIBRARY_FIRST_PAGE_LIMIT, + 10 + ); + if (Number.isFinite(raw) && raw >= 50 && raw <= 500) return raw; + return 120; +})(); +const LIBRARY_ITEMS_FIRST_PAGE_FALLBACK_LIMIT = (() => { + const raw = parseInt( + process.env.POSTERR_JELLYFIN_LIBRARY_FIRST_PAGE_FALLBACK_LIMIT, + 10 + ); + if (Number.isFinite(raw) && raw >= 10 && raw <= 500) return raw; + return 30; +})(); +const LIBRARY_ITEMS_FIRST_PAGE_FALLBACK_LIMIT_METADATA_ONLY = (() => { + const raw = parseInt( + process.env.POSTERR_JELLYFIN_LIBRARY_FIRST_PAGE_FALLBACK_LIMIT_METADATA_ONLY, + 10 + ); + if (Number.isFinite(raw) && raw >= 10 && raw <= 500) return raw; + return 30; +})(); +const LIBRARY_ITEMS_FIRST_PAGE_TIMEOUT_MS = (() => { + const raw = parseInt( + process.env.POSTERR_JELLYFIN_LIBRARY_FIRST_PAGE_TIMEOUT_MS, + 10 + ); + if (Number.isFinite(raw) && raw >= 10000 && raw <= 600000) return raw; + return 90000; +})(); +/** GET /Sessions (Now Playing / Now Screening) — default 60s axios is often too low on busy Jellyfin. */ +const SESSIONS_API_TIMEOUT_MS = (() => { + const raw = parseInt(process.env.POSTERR_JELLYFIN_SESSIONS_TIMEOUT_MS, 10); + if (Number.isFinite(raw) && raw >= 30000 && raw <= 600000) return raw; + return 180000; +})(); +const SESSIONS_API_MAX_RETRIES = (() => { + const raw = parseInt(process.env.POSTERR_JELLYFIN_SESSIONS_MAX_RETRIES, 10); + if (Number.isFinite(raw) && raw >= 0 && raw <= 5) return raw; + return 2; +})(); +/** + * Pagination pages after the first (GET /Users/{id}/Items). Defaults to the long library timeout; + * optional env can shorten per-request wait and rely on retries when Jellyfin intermittently stalls. + */ +const LIBRARY_ITEMS_CONTINUATION_PAGE_TIMEOUT_MS = (() => { + const raw = parseInt( + process.env.POSTERR_JELLYFIN_LIBRARY_CONTINUATION_TIMEOUT_MS, + 10 + ); + if (Number.isFinite(raw) && raw >= 60000 && raw <= 900000) return raw; + return LIBRARY_ITEMS_PAGE_TIMEOUT_MS; +})(); +const LIBRARY_ITEMS_CONTINUATION_MAX_RETRIES = (() => { + const raw = parseInt( + process.env.POSTERR_JELLYFIN_LIBRARY_CONTINUATION_MAX_RETRIES, + 10 + ); + if (Number.isFinite(raw) && raw >= 0 && raw <= 8) return raw; + return 3; +})(); +const METADATA_ONLY_MAX_ITEMS_PER_LIBRARY = (() => { + const raw = parseInt( + process.env.POSTERR_JELLYFIN_METADATA_ONLY_MAX_ITEMS_PER_LIBRARY, + 10 + ); + if (Number.isFinite(raw) && raw >= 0) return raw; + return 0; +})(); +const FULL_SYNC_ENRICH_DETAILS = (() => { + const raw = String( + process.env.POSTERR_JELLYFIN_FULL_SYNC_ENRICH_DETAILS || "" + ).trim().toLowerCase(); + return raw === "true" || raw === "1" || raw === "yes" || raw === "on"; +})(); +const LIBRARY_ITEMS_PAGE_LIMIT_METADATA_ONLY = (() => { + const raw = parseInt( + process.env.POSTERR_JELLYFIN_LIBRARY_PAGE_LIMIT_METADATA_ONLY, + 10 + ); + if (Number.isFinite(raw) && raw >= 50 && raw <= 500) return raw; + return 120; +})(); + +/** + * Jellyfin poster sync worker size. Each batch processes image pulls and then triggers + * metadata registration callback (when enabled), so this also controls metadata flush cadence. + */ +const POSTER_SYNC_BATCH_SIZE = 10; + +/** + * Full poster sync: fetch this many Items per HTTP page, cache them fully, flush DB, then next page. + * @see process.env.POSTERR_POSTER_SYNC_STREAM_CHUNK_SIZE — integer 25–500, default 100 + */ +const POSTER_SYNC_STREAM_CHUNK_SIZE = (() => { + const raw = parseInt(process.env.POSTERR_POSTER_SYNC_STREAM_CHUNK_SIZE, 10); + if (Number.isFinite(raw) && raw >= 25 && raw <= 500) return raw; + return 100; +})(); + +/** GET /Users/{id}/Items page 1: extra axios retries before outer “smaller limit” fallback (0–3). */ +const LIBRARY_ITEMS_FIRST_PAGE_HTTP_RETRIES = (() => { + const raw = parseInt( + process.env.POSTERR_JELLYFIN_FIRST_PAGE_HTTP_RETRIES, + 10 + ); + if (Number.isFinite(raw) && raw >= 0 && raw <= 3) return raw; + return 1; +})(); + +function _axiosErrCode(e) { + if (!e || typeof e !== "object") return ""; + if (e.code != null) return String(e.code); + const c = e.cause; + if (c && c.code != null) return String(c.code); + return ""; +} + +const RETRYABLE_AXIOS_NETWORK_CODES = new Set([ + "ETIMEDOUT", + "ECONNRESET", + "ECONNREFUSED", + "ENOTFOUND", + "EAI_AGAIN", + "EPIPE", +]); + +function _axiosErrorIsRetriableTimeout(e) { + if (!e || typeof e !== "object") return false; + if (e.code === "ECONNABORTED") return true; + return String(e.message || "").toLowerCase().includes("timeout"); +} + +function _axiosErrorIsRetriableNetwork(e) { + const code = _axiosErrCode(e); + if (RETRYABLE_AXIOS_NETWORK_CODES.has(code)) return true; + const msg = String((e && e.message) || "").toLowerCase(); + return ( + msg.includes("etimedout") || + msg.includes("econnrefused") || + msg.includes("econnreset") || + msg.includes("enotfound") + ); +} + +/** + * Same filtering as the legacy “load entire library then filter” path, applied to one page of Items. + * @param {object[]} items + * @param {string[]} genres + * @param {number} recentlyAdded + * @param {string[]} contentRatings + */ +function filterMediaItemsForOdemandFilters(items, genres, recentlyAdded, contentRatings) { + if (!items || !items.length) return []; + let all = items.slice(); + if (recentlyAdded > 0) { + const from = new Date(); + from.setDate(from.getDate() - recentlyAdded); + from.setHours(0, 0, 0, 0); + all = all.filter((m) => m.DateCreated && new Date(m.DateCreated) >= from); + } else { + if (genres && genres.length > 0) { + all = all.filter((m) => { + const itemGenres = m.Genres || []; + return genres.some((val) => { + const valLower = (val || "").toLowerCase(); + return itemGenres.some((g) => + (g || "").toLowerCase().includes(valLower) + ); + }); + }); + } + if (contentRatings && contentRatings.length > 0) { + all = all.filter((m) => { + const cr = ((m.OfficialRating || "") + "").toLowerCase(); + return !contentRatings.some((r) => r.toLowerCase() === cr); + }); + } + } + return all; +} /** * Shared Emby/Jellyfin REST client. @@ -29,6 +232,7 @@ class EmbyJellyfinBase { this.port = String(plexPort == null ? "" : plexPort).trim(); this.apiKey = typeof plexToken === "string" ? plexToken.trim() : plexToken; this._userId = null; + this._libraryFirstPageLimitHint = null; } /** @returns {"Jellyfin"|"Emby"} — overridden by plugin subclasses */ @@ -104,6 +308,49 @@ class EmbyJellyfinBase { return core.CacheImage(url, fileName, h ? { headers: h } : undefined); } + /** + * Try several image URLs (e.g. Jellyfin /Logo/0 vs /Logo); unlink stale attempts so a bad response + * does not block the next URL. @returns {Promise} + */ + async _cacheFirstImageUrl(urls, fileName) { + const list = (urls || []).filter(Boolean); + if (list.length === 0) return false; + const savePath = path.join(IMAGE_CACHE_DIR, fileName); + const h = this.jellyfinImageAuthHeaders(); + const opts = h ? { headers: h } : undefined; + for (const u of list) { + try { + if (fs.existsSync(savePath)) fs.unlinkSync(savePath); + } catch (e) { + /* ignore */ + } + const ok = await core.CacheImage(u, fileName, opts); + if (ok) return true; + } + return false; + } + + /** Candidate GET URLs for a Logo image (Jellyfin: indexed + unindexed paths; PNG first for clearlogo/logo.png). */ + logoImageFetchUrls(itemId, tag) { + const id = itemId != null ? String(itemId) : ""; + if (!id) return []; + if (this.appName === "Jellyfin") { + const base = this.baseUrl(); + const qPng = EmbyJellyfinBase._jellyfinLogoQueryString(tag); + const qTagOnly = + tag != null && String(tag).length > 0 + ? `?tag=${encodeURIComponent(String(tag))}` + : ""; + return [ + `${base}/Items/${id}/Images/Logo/0${qPng}`, + `${base}/Items/${id}/Images/Logo${qPng}`, + `${base}/Items/${id}/Images/Logo/0${qTagOnly}`, + `${base}/Items/${id}/Images/Logo${qTagOnly}`, + ]; + } + return [this.logoImageUrl(id, tag)]; + } + async apiGet(path, options = {}) { const key = this.apiKey != null && this.apiKey !== "" @@ -142,11 +389,17 @@ class EmbyJellyfinBase { }); return res.data; } catch (e) { - const isTimeout = - e.code === "ECONNABORTED" || - String(e.message || "").toLowerCase().includes("timeout"); - if (isTimeout && attempt < maxRetries) { + const canRetry = + attempt < maxRetries && + (_axiosErrorIsRetriableTimeout(e) || + _axiosErrorIsRetriableNetwork(e)); + if (canRetry) { attempt++; + const backoffMs = Math.min( + 12000, + 500 + attempt * 750 + (attempt > 2 ? 1500 : 0) + ); + await new Promise((resolve) => setTimeout(resolve, backoffMs)); continue; } const status = e.response && e.response.status; @@ -175,7 +428,10 @@ class EmbyJellyfinBase { async getUserId() { if (this._userId) return this._userId; try { - const me = await this.apiGet("/Users/Me"); + const me = await this.apiGet("/Users/Me", { + timeoutMs: LIBRARY_ITEMS_PAGE_TIMEOUT_MS, + maxRetries: 1, + }); if (me && me.Id) { this._userId = me.Id; return this._userId; @@ -183,7 +439,10 @@ class EmbyJellyfinBase { } catch (e) { /* fall through */ } - const list = await this.apiGet("/Users"); + const list = await this.apiGet("/Users", { + timeoutMs: LIBRARY_ITEMS_PAGE_TIMEOUT_MS, + maxRetries: 2, + }); const users = Array.isArray(list) ? list : []; if (users.length === 0) { throw new Error( @@ -260,6 +519,34 @@ class EmbyJellyfinBase { return `${this.baseUrl()}/Items/${itemId}/Images/Backdrop/${index}?${this._imageUrlApiQuery()}`; } + bannerImageUrl(itemId, tag) { + if (this.appName === "Jellyfin") { + const q = tag ? `?tag=${encodeURIComponent(tag)}` : ""; + return `${this.baseUrl()}/Items/${itemId}/Images/Banner/0${q}`; + } + const t = tag ? `&tag=${encodeURIComponent(tag)}` : ""; + return `${this.baseUrl()}/Items/${itemId}/Images/Banner/0?${this._imageUrlApiQuery()}${t}`; + } + + logoImageUrl(itemId, tag) { + if (this.appName === "Jellyfin") { + // Local fanart often uses logo.png / clearlogo.png; ask for PNG so Jellyfin does not fall back to opaque JPEG. + const qs = EmbyJellyfinBase._jellyfinLogoQueryString(tag); + return `${this.baseUrl()}/Items/${itemId}/Images/Logo/0${qs}`; + } + const t = tag ? `&tag=${encodeURIComponent(tag)}` : ""; + return `${this.baseUrl()}/Items/${itemId}/Images/Logo?${this._imageUrlApiQuery()}${t}`; + } + + /** Query string for Jellyfin Logo image (transparency: logo.png / clearlogo.png in library folders). */ + static _jellyfinLogoQueryString(tag) { + const parts = ["format=Png"]; + if (tag != null && String(tag).length > 0) { + parts.push("tag=" + encodeURIComponent(String(tag))); + } + return "?" + parts.join("&"); + } + /** * Cache a primary image using multiple Jellyfin id/tag candidates. * Returns web path or empty string when all candidates fail. @@ -323,6 +610,170 @@ class EmbyJellyfinBase { return p !== undefined && p !== null && String(p).length > 0; } + /** True if Jellyfin/Emby item has at least one backdrop tag/path. */ + static hasBackdropImage(item) { + if (!item) return false; + const bt = item.BackdropImageTags || item.backdropImageTags; + if (Array.isArray(bt) && bt.length > 0) return true; + if (item.ImageTags && item.ImageTags.Backdrop) return true; + if (item.imageTags && item.imageTags.backdrop) return true; + if (item.art && item.art.fanart) return true; + if (item.fanart) return true; + return false; + } + + /** True if Jellyfin/Emby item has a banner image (separate from backdrop). */ + static hasBannerImage(item) { + if (!item) return false; + const bt = item.BannerImageTags || item.bannerImageTags; + if (Array.isArray(bt) && bt.length > 0) return true; + if (item.ImageTags && item.ImageTags.Banner) return true; + if (item.imageTags && item.imageTags.banner) return true; + return false; + } + + static bannerImageTag(item) { + if (!item) return null; + const tags = item.ImageTags || item.imageTags; + if (!tags || typeof tags !== "object") return null; + const b = tags.Banner ?? tags.banner; + return b !== undefined && b !== null && String(b).length > 0 + ? String(b) + : null; + } + + /** Jellyfin serializes ImageTags keys as enum names, but some clients/servers use numeric strings (Logo = 4). */ + static _jellyfinLogoEnumKey() { + return "4"; + } + + static _logoTagFromBlurHashes(item) { + const bh = item.ImageBlurHashes || item.imageBlurHashes; + if (!bh || typeof bh !== "object") return null; + const logoMap = bh.Logo ?? bh.logo; + if (!logoMap || typeof logoMap !== "object") return null; + const keys = Object.keys(logoMap); + for (const k of keys) { + if (k != null && String(k).length > 0) return String(k); + } + return null; + } + + static _ownLogoTagFromImageTags(item) { + const tags = item.ImageTags || item.imageTags; + if (!tags || typeof tags !== "object") return null; + const L = tags.Logo ?? tags.logo; + if (L !== undefined && L !== null && String(L).length > 0) { + return String(L); + } + const n = tags[EmbyJellyfinBase._jellyfinLogoEnumKey()]; + if (n !== undefined && n !== null && String(n).length > 0) { + return String(n); + } + return null; + } + + /** + * Resolves which item id + cache tag to use for Logo (own ImageTags, blur-hash-only hint, or parent logo). + * @returns {{ itemId: string, tag: string } | null} + */ + static logoImageRef(item) { + if (!item) return null; + const ownId = item.Id ?? item.id; + if (ownId == null || String(ownId).length === 0) return null; + let tag = + EmbyJellyfinBase._ownLogoTagFromImageTags(item) || + EmbyJellyfinBase._logoTagFromBlurHashes(item); + if (tag) { + return { itemId: String(ownId), tag }; + } + const pid = item.ParentLogoItemId ?? item.parentLogoItemId; + const ptag = item.ParentLogoImageTag ?? item.parentLogoImageTag; + if ( + pid != null && + String(pid).length > 0 && + ptag != null && + String(ptag).length > 0 + ) { + return { itemId: String(pid), tag: String(ptag) }; + } + return null; + } + + static hasLogoImage(item) { + return EmbyJellyfinBase.logoImageRef(item) != null; + } + + static logoImageTag(item) { + const ref = EmbyJellyfinBase.logoImageRef(item); + return ref ? ref.tag : null; + } + + /** + * Ref used when caching logos. Jellyfin list items often lack ImageTags.Logo / blur hashes even when + * folder art (logo.png, clearlogo.png) was picked up — during full sync, still hit /Images/Logo (no tag). + * @returns {{ itemId: string, tag: string|null } | null} + */ + logoImageRefForCache(item, posterSyncFull) { + const ref = EmbyJellyfinBase.logoImageRef(item); + if (ref) return ref; + if ( + !posterSyncFull || + (this.appName !== "Jellyfin" && this.appName !== "Emby") + ) { + return null; + } + const type = item.Type || item.type; + if (type !== "Series" && type !== "Movie" && type !== "MusicAlbum") { + return null; + } + const ownId = item.Id ?? item.id; + if (ownId == null || String(ownId).length === 0) return null; + return { itemId: String(ownId), tag: null }; + } + + /** + * Full poster sync: fetch each Series/Movie DTO from the server so Overview, People, ImageTags, etc. + * match the item detail (recursive library pages are slimmer than GET …/Items/{id}). + */ + async enrichItemForPosterSync( + listRow, + posterSyncFull, + metadataOnlySync, + enrichDetails + ) { + if (metadataOnlySync || !enrichDetails) { + return listRow; + } + const type = listRow.Type || listRow.type; + if ( + !posterSyncFull || + (type !== "Series" && type !== "Movie") || + listRow.Id == null + ) { + return listRow; + } + try { + const userId = await this.getUserId(); + const detail = await this.apiGet( + `/Users/${userId}/Items/${encodeURIComponent(listRow.Id)}`, + { + timeoutMs: 45000, + maxRetries: 1, + // List pages omit Taglines unless Fields asks for it; same for some detail defaults. + params: { Fields: "Taglines,Overview,Genres,People" }, + } + ); + if (!detail || !detail.Id) return listRow; + const merged = { ...listRow, ...detail }; + merged._jfLibraryName = listRow._jfLibraryName; + merged.ctype = listRow.ctype; + return merged; + } catch (e) { + return listRow; + } + } + /** Match Jellyfin Client / DeviceName against comma-separated filter (substring, case-insensitive). */ static sessionDeviceMatchesFilter(session, playerDeviceLabel, wantedDevices) { if (!wantedDevices || wantedDevices.length === 0 || !wantedDevices[0]) return true; @@ -387,8 +838,79 @@ class EmbyJellyfinBase { return ratingColour; } + /** + * Pick a display tagline from Jellyfin/Emby BaseItemDto-shaped JSON. + * Jellyfin omits the Taglines field on GET /Users/{id}/Items list rows unless Fields includes + * ItemFields.Taglines (see Items API). Without it, Taglines is empty and we fall back to + * overview/title. When Taglines is present, use the first entry that is not a junk duplicate of + * title / title+year / title+premiere date; otherwise short/overview, then genres. + * @param {object} item BaseItemDto-shaped object (PascalCase and camelCase tolerated) + * @returns {string} + */ + static pickBaseItemTagline(item) { + if (!item || typeof item !== "object") return ""; + const name = String(item.Name || item.name || "").trim(); + const yearRaw = item.ProductionYear ?? item.productionYear; + const year = + yearRaw != null && yearRaw !== "" ? String(yearRaw).trim() : ""; + const lines = item.Taglines || item.taglines; + + const isJunkTagline = (s) => { + if (!s) return true; + if (!name) return false; + const sl = s.toLowerCase(); + const nl = name.toLowerCase(); + if (sl === nl) return true; + if (year && sl === `${nl} (${year})`) return true; + if (year && sl === `${nl} ${year}`) return true; + const prem = item.PremiereDate || item.premiereDate; + if (prem && typeof prem === "string") { + const d = prem.slice(0, 10); + if (d && /\d{4}-\d{2}-\d{2}/.test(d) && sl.startsWith(nl) && sl.includes(d)) { + return true; + } + } + return false; + }; + + let fromTags = ""; + if (Array.isArray(lines)) { + const candidates = lines + .map((x) => (x == null ? "" : String(x).trim())) + .filter(Boolean); + const good = candidates.find((s) => !isJunkTagline(s)); + if (good) return good; + fromTags = candidates[0] || ""; + } + + if (fromTags && !isJunkTagline(fromTags)) return fromTags; + + const shortO = String(item.ShortOverview || item.shortOverview || "").trim(); + if (shortO) return shortO.length > 280 ? shortO.slice(0, 277) + "…" : shortO; + + const ov = String(item.Overview || item.overview || "").trim(); + if (ov) { + const para = ov.split(/\n+/)[0].trim(); + let bit = (para.split(/(?<=[.!?])\s+/)[0] || para).trim(); + if (!bit) bit = para; + if (bit.length > 280) bit = bit.slice(0, 277) + "…"; + return bit; + } + + const genres = item.Genres || item.genres; + if (Array.isArray(genres) && genres.length) { + const g = genres.filter(Boolean).slice(0, 3).join(" · "); + if (g) return g; + } + + return ""; + } + async GetNowScreeningRawData() { - return this.apiGet("/Sessions"); + return this.apiGet("/Sessions", { + timeoutMs: SESSIONS_API_TIMEOUT_MS, + maxRetries: SESSIONS_API_MAX_RETRIES, + }); } async GetNowScreening( @@ -663,7 +1185,29 @@ class EmbyJellyfinBase { if (ms0 && ms0.TranscodingUrl) transcode = "transcode"; } else if (typeLc === "movie") { medCard.title = item.Name || ""; - medCard.tagLine = await util.emptyIfNull(item.Taglines && item.Taglines[0]); + let tagSrc = item; + const sessTl = item.Taglines || item.taglines; + const hasTl = + Array.isArray(sessTl) && + sessTl.some((x) => x != null && String(x).trim() !== ""); + if (!hasTl && (item.Id || item.id)) { + try { + const userId = await this.getUserId(); + const det = await this.apiGet( + `/Users/${userId}/Items/${encodeURIComponent(item.Id || item.id)}`, + { + timeoutMs: 8000, + maxRetries: 0, + params: { Fields: "Taglines,Overview" }, + } + ); + if (det && det.Id) tagSrc = { ...item, ...det }; + } catch (e) { + /* keep session payload */ + } + } + medCard.tagLine = EmbyJellyfinBase.pickBaseItemTagline(tagSrc); + if (!medCard.tagLine) medCard.tagLine = medCard.title || ""; medCard.mediaType = "movie"; medCard.DBID = String(mediaId); @@ -945,13 +1489,480 @@ class EmbyJellyfinBase { return keys; } + async _posterSyncProcessRawMdList(rawList, ctx) { + const { + posterSyncFull, + metadataOnlySync, + enrichDetails, + opts, + sp, + runTag, + retrySet, + pullBackground, + pullLogo, + pullVideoPoster, + pullAlbumPoster, + odCards, + onPosterSyncBatch, + batchMeta, + getProgressTotal, + imagePull, + } = ctx; + const completedBatchCards = []; + for (const rawMd of rawList) { + if (posterSyncFull) { + const rawApiId = String((rawMd && rawMd.Id) || "").trim(); + const sourceUpdatedAt = + (rawMd && rawMd.DateLastSaved) || + (rawMd && rawMd.DateModified) || + (rawMd && rawMd.DateCreated) || + ""; + const mustRetry = !!(retrySet && rawApiId && retrySet.has(rawApiId)); + if ( + rawApiId && + !mustRetry && + posterMetadataDb.shouldSkipSyncItem( + this.appName.toLowerCase(), + rawApiId, + sourceUpdatedAt + ) + ) { + if (sp) { + sp.processed = Math.min(sp.total || 0, (sp.processed || 0) + 1); + } + continue; + } + } + const md = await this.enrichItemForPosterSync( + rawMd, + posterSyncFull, + metadataOnlySync, + enrichDetails + ); + const medCard = new mediaCard(); + medCard.posterLibraryLabel = String(md._jfLibraryName || "").trim(); + const type = md.Type; + const probeVideoArt = posterSyncFull && pullBackground; + + if (type === "Series") { + medCard.tagLine = EmbyJellyfinBase.pickBaseItemTagline(md); + const mediaId = (md.Id || "").replace(/[^a-zA-Z0-9]/g, ""); + medCard.DBID = mediaId; + medCard.theme = ""; + if (await util.isEmpty(md.CommunityRating)) { + medCard.rating = ""; + } else { + medCard.rating = Math.round(md.CommunityRating * 10) + "%"; + } + const fileName = `${mediaId}.jpg`; + const primaryTag = md.ImageTags && md.ImageTags.Primary; + const seriesPosterUrl = this.primaryImageUrl(md.Id, primaryTag); + medCard.posterDownloadURL = seriesPosterUrl; + if (pullVideoPoster) { + let posterOk = false; + if (EmbyJellyfinBase.hasPrimaryImage(md)) { + posterOk = await this._cacheImageFromServer(seriesPosterUrl, fileName); + } else if (posterSyncFull) { + const fallbackUrl = this.primaryImageUrl(md.Id, null); + posterOk = await this._cacheImageFromServer(fallbackUrl, fileName); + if (posterOk) medCard.posterDownloadURL = fallbackUrl; + } + medCard.posterURL = posterOk + ? "/imagecache/" + fileName + : "/images/no-poster-available.png"; + } else if (metadataOnlySync && seriesPosterUrl) { + medCard.posterURL = "/imagecache/" + fileName; + } else { + medCard.posterURL = "/images/no-poster-available.png"; + } + { + const logoRef = this.logoImageRefForCache(md, posterSyncFull); + if (pullLogo && logoRef) { + const logoName = `${mediaId}-logo.png`; + try { + const ok = await this._cacheFirstImageUrl( + this.logoImageFetchUrls(logoRef.itemId, logoRef.tag), + logoName + ); + if (ok) medCard.posterLogoURL = "/imagecache/" + logoName; + } catch (e) { + /* optional */ + } + } + } + let jfBannerSeriesOk = false; + if ( + pullBackground && + (EmbyJellyfinBase.hasBackdropImage(md) || probeVideoArt) + ) { + const artName = `${mediaId}-art.jpg`; + try { + const ok = await this._cacheImageFromServer( + this.backdropImageUrl(md.Id, 0), + artName + ); + if (ok) medCard.posterArtURL = "/imagecache/" + artName; + } catch (e) { + /* optional */ + } + } + if ( + pullBackground && + (EmbyJellyfinBase.hasBannerImage(md) || probeVideoArt) + ) { + const bnName = `${mediaId}-banner.jpg`; + try { + const ok = await this._cacheImageFromServer( + this.bannerImageUrl(md.Id, EmbyJellyfinBase.bannerImageTag(md)), + bnName + ); + if (ok) { + jfBannerSeriesOk = true; + if (!medCard.posterArtURL) { + medCard.posterArtURL = "/imagecache/" + bnName; + } + } + } catch (e) { + /* optional */ + } + } + { + const jfYear = + md.ProductionYear || + (md.PremiereDate ? String(md.PremiereDate).slice(0, 4) : ""); + await tmdbBackdropFallback.cacheTmdbBannerIfNeeded({ + tmdbApiKey: opts && opts.tmdbApiKey, + pullBackground, + serverBannerOk: jfBannerSeriesOk, + mediaType: "show", + title: md.Name, + year: jfYear, + ...tmdbBackdropFallback.collectJellyfinProviderIds(md), + bannerFileName: `${mediaId}-banner.jpg`, + medCard, + cacheImage: (u, f) => core.CacheImage(u, f), + }); + } + medCard.posterAR = 1.47; + medCard.runTime = md.RunTimeTicks + ? Math.round(md.RunTimeTicks / 6000000000) + : 0; + medCard.title = md.Name || ""; + medCard.mediaType = "show"; + } else if (type === "Movie") { + const movieFileName = `${md.Id}.jpg`.replace(/[^a-zA-Z0-9._-]/g, "_"); + const primaryTag = md.ImageTags && md.ImageTags.Primary; + const moviePosterUrl = this.primaryImageUrl(md.Id, primaryTag); + medCard.posterDownloadURL = moviePosterUrl; + if (pullVideoPoster) { + let posterOk = false; + if (EmbyJellyfinBase.hasPrimaryImage(md)) { + posterOk = await this._cacheImageFromServer( + moviePosterUrl, + movieFileName + ); + } else if (posterSyncFull) { + const fallbackUrl = this.primaryImageUrl(md.Id, null); + posterOk = await this._cacheImageFromServer( + fallbackUrl, + movieFileName + ); + if (posterOk) medCard.posterDownloadURL = fallbackUrl; + } + medCard.posterURL = posterOk + ? "/imagecache/" + movieFileName + : "/images/no-poster-available.png"; + } else if (metadataOnlySync && moviePosterUrl) { + medCard.posterURL = "/imagecache/" + movieFileName; + } else { + medCard.posterURL = "/images/no-poster-available.png"; + } + { + const logoRef = this.logoImageRefForCache(md, posterSyncFull); + if (pullLogo && logoRef) { + const logoMovie = movieFileName.replace(/\.jpe?g$/i, "") + "-logo.png"; + try { + const ok = await this._cacheFirstImageUrl( + this.logoImageFetchUrls(logoRef.itemId, logoRef.tag), + logoMovie + ); + if (ok) medCard.posterLogoURL = "/imagecache/" + logoMovie; + } catch (e) { + /* optional */ + } + } + } + let jfBannerMovieOk = false; + if ( + pullBackground && + (EmbyJellyfinBase.hasBackdropImage(md) || probeVideoArt) + ) { + const artName = `${md.Id}-art.jpg`.replace(/[^a-zA-Z0-9._-]/g, "_"); + try { + const ok = await this._cacheImageFromServer( + this.backdropImageUrl(md.Id, 0), + artName + ); + if (ok) medCard.posterArtURL = "/imagecache/" + artName; + } catch (e) { + /* optional */ + } + } + if ( + pullBackground && + (EmbyJellyfinBase.hasBannerImage(md) || probeVideoArt) + ) { + const bnName = `${md.Id}-banner.jpg`.replace(/[^a-zA-Z0-9._-]/g, "_"); + try { + const ok = await this._cacheImageFromServer( + this.bannerImageUrl(md.Id, EmbyJellyfinBase.bannerImageTag(md)), + bnName + ); + if (ok) { + jfBannerMovieOk = true; + if (!medCard.posterArtURL) { + medCard.posterArtURL = "/imagecache/" + bnName; + } + } + } catch (e) { + /* optional */ + } + } + { + const jfYear = + md.ProductionYear || + (md.PremiereDate ? String(md.PremiereDate).slice(0, 4) : ""); + const bnMovie = `${md.Id}-banner.jpg`.replace(/[^a-zA-Z0-9._-]/g, "_"); + await tmdbBackdropFallback.cacheTmdbBannerIfNeeded({ + tmdbApiKey: opts && opts.tmdbApiKey, + pullBackground, + serverBannerOk: jfBannerMovieOk, + mediaType: "movie", + title: md.Name, + year: jfYear, + ...tmdbBackdropFallback.collectJellyfinProviderIds(md), + bannerFileName: bnMovie, + medCard, + cacheImage: (u, f) => core.CacheImage(u, f), + }); + } + medCard.posterAR = 1.47; + medCard.theme = ""; + medCard.title = md.Name || ""; + medCard.runTime = md.RunTimeTicks + ? Math.round(md.RunTimeTicks / 6000000000) + : 0; + medCard.resCodec = ""; + medCard.audioCodec = ""; + medCard.tagLine = EmbyJellyfinBase.pickBaseItemTagline(md); + if (await util.isEmpty(md.CommunityRating)) { + medCard.rating = ""; + } else { + medCard.rating = Math.round(md.CommunityRating * 10) + "%"; + } + medCard.mediaType = "movie"; + } else if (type === "MusicAlbum") { + const albumFileName = `${md.Id}.jpg`.replace(/[^a-zA-Z0-9._-]/g, "_"); + medCard.DBID = (md.Id || "").replace(/[^a-zA-Z0-9]/g, "") || String(md.Id || ""); + const albumTag = + (md.ImageTags && md.ImageTags.Primary) || + (md.imageTags && md.imageTags.primary) || + md.PrimaryImageTag || + md.primaryImageTag || + ""; + if (pullAlbumPoster && EmbyJellyfinBase.hasPrimaryImage(md)) { + const albumPoster = await this.cachePrimaryImageAny( + [ + { id: md.Id, tag: albumTag }, + { id: md.Id, tag: null }, + { + id: + md.AlbumArtists && + md.AlbumArtists[0] && + (md.AlbumArtists[0].Id || md.AlbumArtists[0].id), + tag: null, + }, + ], + albumFileName, + medCard + ); + medCard.posterURL = albumPoster || "/images/no-poster-available.png"; + } else if (metadataOnlySync) { + medCard.posterURL = "/imagecache/" + albumFileName; + } else { + medCard.posterURL = "/images/no-poster-available.png"; + } + if (pullBackground && EmbyJellyfinBase.hasBackdropImage(md)) { + const artName = `${md.Id}-art.jpg`.replace(/[^a-zA-Z0-9._-]/g, "_"); + try { + await this._cacheImageFromServer( + this.backdropImageUrl(md.Id, 0), + artName + ); + medCard.posterArtURL = "/imagecache/" + artName; + } catch (e) { + /* optional */ + } + } + if (pullBackground && EmbyJellyfinBase.hasBannerImage(md)) { + const bnName = `${md.Id}-banner.jpg`.replace(/[^a-zA-Z0-9._-]/g, "_"); + try { + await this._cacheImageFromServer( + this.bannerImageUrl(md.Id, EmbyJellyfinBase.bannerImageTag(md)), + bnName + ); + if (!medCard.posterArtURL) { + medCard.posterArtURL = "/imagecache/" + bnName; + } + } catch (e) { + /* optional */ + } + } + { + const logoRef = this.logoImageRefForCache(md, posterSyncFull); + if (pullLogo && logoRef) { + const logoAlb = `${md.Id}-logo.png`.replace(/[^a-zA-Z0-9._-]/g, "_"); + try { + const ok = await this._cacheFirstImageUrl( + this.logoImageFetchUrls(logoRef.itemId, logoRef.tag), + logoAlb + ); + if (ok) medCard.posterLogoURL = "/imagecache/" + logoAlb; + } catch (e) { + /* optional */ + } + } + } + medCard.posterAR = 1; + medCard.theme = ""; + medCard.title = md.Name || ""; + const albumArtist = + (md.AlbumArtist && String(md.AlbumArtist).trim()) || + (md.AlbumArtists && + md.AlbumArtists[0] && + (md.AlbumArtists[0].Name || md.AlbumArtists[0].name)) || + ""; + medCard.tagLine = albumArtist + ? `${albumArtist} — ${medCard.title}` + : medCard.title; + medCard.albumArtist = albumArtist; + medCard.runTime = md.RunTimeTicks + ? Math.round(md.RunTimeTicks / 6000000000) + : 0; + medCard.resCodec = ""; + medCard.audioCodec = ""; + if (await util.isEmpty(md.CommunityRating)) { + medCard.rating = ""; + } else { + medCard.rating = Math.round(md.CommunityRating * 10) + "%"; + } + medCard.mediaType = "album"; + } else { + continue; + } + + if (!(await util.isEmpty(md.Studios && md.Studios[0] && md.Studios[0].Name))) { + medCard.studio = md.Studios[0].Name; + } + + if (medCard.tagLine === "") medCard.tagLine = medCard.title; + + let contentRating = "NR"; + if (!(await util.isEmpty(md.OfficialRating))) { + contentRating = md.OfficialRating; + } + medCard.contentRating = contentRating; + medCard.ratingColour = EmbyJellyfinBase.ratingColour(contentRating); + + medCard.year = md.ProductionYear; + medCard.genre = await util.emptyIfNull(md.Genres); + medCard.summary = md.Overview || ""; + medCard.cast = util.formatCastFromEmbyPeople(md.People); + medCard.directors = util.formatDirectorsFromEmbyPeople(md.People); + { + const cp = String(medCard.cast || "") + .split(",") + .map((x) => x.trim()) + .filter(Boolean); + medCard.actor1 = cp[0] || ""; + medCard.actor2 = cp[1] || ""; + } + medCard.cardType = md.ctype; + + const odPortraitKey = String(medCard.DBID || md.Id || "x").replace( + /[^a-zA-Z0-9._-]/g, + "_" + ); + await this.cacheItemPersonPortraits( + medCard, + md, + odPortraitKey, + imagePull + ); + + if (md.Id) medCard.posterApiItemId = String(md.Id); + + odCards.push(medCard); + completedBatchCards.push(medCard); + if (sp) { + sp.processed = Math.min(sp.total || 0, (sp.processed || 0) + 1); + } + if (posterSyncFull && sp && sp.libraries) { + const lr = posterSyncLib.findLibraryRow( + sp.libraries, + medCard.posterLibraryLabel + ); + if (lr && lr.cacheStatus !== "skipped") { + lr.cacheStatus = "running"; + lr.itemsCached = (lr.itemsCached || 0) + 1; + if (lr.cacheTotal > 0 && lr.itemsCached >= lr.cacheTotal) { + lr.cacheStatus = "done"; + } + } + } + if (posterSyncFull && sp) { + const n = odCards.length; + const total = sp.total || getProgressTotal(); + const step = Math.max(25, Math.min(500, Math.floor(total / 15) || 1)); + if (n === 1 || n >= total || n % step === 0) { + const t = medCard.mediaType || String(type || "").toLowerCase() || "?"; + const title = String(medCard.title || "").slice(0, 72); + console.log( + new Date().toLocaleString() + + " [poster sync]" + + runTag + + " " + + n + + "/" + + total + + " " + + t + + ' — "' + + title + + '"' + ); + } + } + checkPosterSyncAborted(opts, posterSyncFull, sp); + } + if (onPosterSyncBatch && completedBatchCards.length > 0) { + await onPosterSyncBatch(completedBatchCards, batchMeta); + } + } + async GetAllMediaForLibrary( libEntry, genres, recentlyAdded, contentRatings, - syncProgress + syncProgress, + metadataOnlySync, + streamOptions = null ) { + const runTag = + syncProgress && syncProgress.runId + ? ` [run ${syncProgress.runId}]` + : ""; let fetchRow = null; if (syncProgress && syncProgress.libraries) { fetchRow = posterSyncLib.findLibraryRow( @@ -965,110 +1976,239 @@ class EmbyJellyfinBase { if (syncProgress) { console.log( new Date().toLocaleString() + - ` [poster sync] ${this.appName} — user id + item list for library “${libEntry.name || libEntry.id}”…` + ` [poster sync]${runTag} ${this.appName} — user id + item list for library “${libEntry.name || libEntry.id}”…` ); } const userId = await this.getUserId(); const includeTypes = this.includeItemTypesForCollection(libEntry.collectionType); - // Omit Fields: Jellyfin 10.9+ rejects legacy ItemFields names with HTTP 400; default DTO is enough for on-demand cards. + // Jellyfin omits Taglines (and often a useful Overview) on /Items unless Fields includes them. + // Use current ItemFields names only (Jellyfin 10.9+ rejects legacy enum strings with HTTP 400). const baseParams = { ParentId: libEntry.id, Recursive: true, IncludeItemTypes: EmbyJellyfinBase.splitCsvTypes(includeTypes), - Fields: "People", }; + // Cast (People) + marketing taglines + plot text; same field set for full and metadata-only sync. + baseParams.Fields = "People,Taglines,Overview,Genres"; + + const streaming = + streamOptions && typeof streamOptions.onPage === "function"; + const streamPageSize = streaming + ? Number.isFinite(streamOptions.pageSize) + ? Math.max(25, Math.min(500, Number(streamOptions.pageSize))) + : POSTER_SYNC_STREAM_CHUNK_SIZE + : POSTER_SYNC_STREAM_CHUNK_SIZE; + let metadataEmittedThisLib = 0; + let serverRowsSeen = 0; let all = []; let start = 0; - // 100 keeps payload sizes moderate on large Jellyfin libraries. - const limit = 100; + const limit = metadataOnlySync + ? LIBRARY_ITEMS_PAGE_LIMIT_METADATA_ONLY + : LIBRARY_ITEMS_PAGE_LIMIT; + let firstPageLimit = Math.min( + limit, + this._libraryFirstPageLimitHint != null + ? this._libraryFirstPageLimitHint + : LIBRARY_ITEMS_FIRST_PAGE_LIMIT + ); + const firstPageFallbackFloor = metadataOnlySync + ? Math.min(limit, LIBRARY_ITEMS_FIRST_PAGE_FALLBACK_LIMIT_METADATA_ONLY) + : Math.min(limit, LIBRARY_ITEMS_FIRST_PAGE_FALLBACK_LIMIT); + let firstPageFallbackAttempts = 0; /** Avoid infinite loop if StartIndex is ignored or broken (would return full limit forever). */ const maxPages = 20000; let page = 0; while (true) { page++; + const pageLimit = streaming + ? page === 1 + ? Math.min(firstPageLimit, streamPageSize) + : streamPageSize + : page === 1 + ? firstPageLimit + : limit; + const pageTimeoutMs = + page === 1 + ? LIBRARY_ITEMS_FIRST_PAGE_TIMEOUT_MS + : LIBRARY_ITEMS_CONTINUATION_PAGE_TIMEOUT_MS; + const pageMaxRetries = + page === 1 + ? LIBRARY_ITEMS_FIRST_PAGE_HTTP_RETRIES + : LIBRARY_ITEMS_CONTINUATION_MAX_RETRIES; if (page > maxPages) { console.log( new Date().toLocaleString() + - ` [poster sync] ✘✘ WARNING ✘✘ — pagination stopped after ${maxPages} pages (~${all.length} items) in “${libEntry.name}” to avoid a runaway loop. Check Jellyfin / network.` + ` [poster sync] ✘✘ WARNING ✘✘ — pagination stopped after ${maxPages} pages (~${ + streaming ? serverRowsSeen : all.length + } items) in “${libEntry.name}” to avoid a runaway loop. Check Jellyfin / network.` ); break; } if (syncProgress) { - syncProgress.label = `Fetching “${libEntry.name}”… ${all.length} items so far`; + syncProgress.label = `Fetching “${libEntry.name}”… ${ + streaming ? serverRowsSeen : all.length + } items so far`; if (page === 1 || page % 15 === 1) { console.log( new Date().toLocaleString() + - ` [poster sync] “${libEntry.name}” — GET /Users/…/Items StartIndex=${start} Limit=${limit} (slow servers: up to ${Math.round( - LIBRARY_ITEMS_PAGE_TIMEOUT_MS / 60000 - )} min per page)…` + ` [poster sync]${runTag} “${libEntry.name}” — GET /Users/${userId}/Items StartIndex=${start} Limit=${pageLimit} (client timeout ${Math.round( + pageTimeoutMs / 60000 + )} min per request; first page can look idle until Jellyfin responds)…` ); } } - const chunk = await this.apiGet(`/Users/${userId}/Items`, { - timeoutMs: LIBRARY_ITEMS_PAGE_TIMEOUT_MS, - maxRetries: 2, - params: { ...baseParams, StartIndex: start, Limit: limit }, - }); - const items = chunk.Items || []; - all = all.concat(items); - if (syncProgress) { - const logOften = page <= 3 || page % 10 === 0 || items.length < limit; - if (logOften) { + let chunk; + try { + chunk = await this.apiGet(`/Users/${userId}/Items`, { + timeoutMs: pageTimeoutMs, + maxRetries: pageMaxRetries, + params: { ...baseParams, StartIndex: start, Limit: pageLimit }, + }); + } catch (e) { + const isFirstPageFallbackCandidate = + page === 1 && + firstPageFallbackAttempts < 2 && + pageLimit > firstPageFallbackFloor; + const status = e && e.response ? Number(e.response.status) : 0; + const msg = String((e && e.message) || "").toLowerCase(); + const isRetriableFirstPageError = + e && + (e.code === "ECONNABORTED" || + msg.includes("timeout") || + status >= 500); + if (isFirstPageFallbackCandidate && isRetriableFirstPageError) { + const halved = Math.floor(pageLimit / 2); + firstPageLimit = Math.max( + 1, + Math.min(pageLimit - 1, Math.max(firstPageFallbackFloor, halved)) + ); + // Persist a lower first-page hint for later libraries in this sync run. + this._libraryFirstPageLimitHint = this._libraryFirstPageLimitHint == null + ? firstPageLimit + : Math.min(this._libraryFirstPageLimitHint, firstPageLimit); + firstPageFallbackAttempts += 1; console.log( new Date().toLocaleString() + - ` [poster sync] “${libEntry.name}” — page ${page}, +${items.length} rows, total ${all.length}` + ` [poster sync]${runTag} ${this.appName} — “${libEntry.name || libEntry.id}” first page retry ${firstPageFallbackAttempts}/2 with smaller limit (${firstPageLimit}) after ${e.code || status || "request error"}` ); + continue; } + throw e; } - if (items.length < limit) break; - start += limit; - } - - if (recentlyAdded > 0) { - const from = new Date(); - from.setDate(from.getDate() - recentlyAdded); - from.setHours(0, 0, 0, 0); - all = all.filter((m) => m.DateCreated && new Date(m.DateCreated) >= from); - } else { - if (genres && genres.length > 0) { - const mapGenre = (arr, gs) => - gs.reduce((acc, val) => { - const valLower = (val || "").toLowerCase(); - const libMatches = arr.filter( - (m) => - m.Genres && - m.Genres.some((g) => - (g || "").toLowerCase().includes(valLower) - ) + const items = chunk.Items || []; + if (streaming) { + serverRowsSeen += items.length; + let toSend = filterMediaItemsForOdemandFilters( + items, + genres, + recentlyAdded, + contentRatings + ); + for (const m of toSend) { + m._jfLibraryName = libEntry.name; + } + if ( + metadataOnlySync && + METADATA_ONLY_MAX_ITEMS_PER_LIBRARY > 0 && + metadataEmittedThisLib >= METADATA_ONLY_MAX_ITEMS_PER_LIBRARY + ) { + if (syncProgress) { + console.log( + new Date().toLocaleString() + + ` [poster sync]${runTag} ${this.appName} — “${libEntry.name}” metadata-only cap reached (${METADATA_ONLY_MAX_ITEMS_PER_LIBRARY}); stopping early` ); - if (libMatches.length > 0) return acc.concat(libMatches); - return acc; - }, []); - const matched = mapGenre(all, genres); - const byId = new Map(); - for (const m of matched) { - if (m.Id) byId.set(m.Id, m); + } + break; + } + if ( + metadataOnlySync && + METADATA_ONLY_MAX_ITEMS_PER_LIBRARY > 0 && + toSend.length > 0 + ) { + const room = METADATA_ONLY_MAX_ITEMS_PER_LIBRARY - metadataEmittedThisLib; + if (room <= 0) { + break; + } + if (toSend.length > room) { + toSend = toSend.slice(0, room); + } + } + if (page === 1 && fetchRow && fetchRow.fetchStatus !== "skipped") { + const trc = chunk.TotalRecordCount; + if (Number.isFinite(trc) && trc >= 0) { + fetchRow.itemsFound = trc; + } + } + if (page === 1 && typeof streamOptions.onLibraryTotal === "function") { + const trc = chunk.TotalRecordCount; + if (Number.isFinite(trc) && trc >= 0) { + streamOptions.onLibraryTotal(libEntry.name, trc); + } } - all = Array.from(byId.values()); + if (toSend.length > 0) { + await streamOptions.onPage(toSend); + metadataEmittedThisLib += toSend.length; + } + if (syncProgress) { + const logOften = page <= 3 || page % 10 === 0 || items.length < pageLimit; + if (logOften) { + console.log( + new Date().toLocaleString() + + ` [poster sync]${runTag} “${libEntry.name}” — page ${page}, +${items.length} rows from API, ${serverRowsSeen} cumulative (streamed fetch)` + ); + } + } + if (items.length < pageLimit) { + break; + } + start += items.length; + continue; } - if (contentRatings && contentRatings.length > 0) { - const exclude = new Set(); - for (const m of all) { - const cr = (m.OfficialRating || "").toLowerCase(); - if (contentRatings.some((r) => r.toLowerCase() === cr)) { - exclude.add(m); - } + all = all.concat(items); + if ( + metadataOnlySync && + METADATA_ONLY_MAX_ITEMS_PER_LIBRARY > 0 && + all.length >= METADATA_ONLY_MAX_ITEMS_PER_LIBRARY + ) { + all = all.slice(0, METADATA_ONLY_MAX_ITEMS_PER_LIBRARY); + if (syncProgress) { + console.log( + new Date().toLocaleString() + + ` [poster sync]${runTag} ${this.appName} — “${libEntry.name}” metadata-only cap reached (${METADATA_ONLY_MAX_ITEMS_PER_LIBRARY}); stopping early` + ); + } + break; + } + if (syncProgress) { + const logOften = page <= 3 || page % 10 === 0 || items.length < pageLimit; + if (logOften) { + console.log( + new Date().toLocaleString() + + ` [poster sync]${runTag} “${libEntry.name}” — page ${page}, +${items.length} rows, total ${all.length}` + ); } - all = all.filter((m) => !exclude.has(m)); } + if (items.length < pageLimit) break; + start += pageLimit; + } + + if (!streaming) { + all = filterMediaItemsForOdemandFilters( + all, + genres, + recentlyAdded, + contentRatings + ); } if (fetchRow && fetchRow.fetchStatus !== "skipped") { fetchRow.fetchStatus = "done"; - fetchRow.itemsFound = all.length; + if (!streaming) { + fetchRow.itemsFound = all.length; + } } return all; } @@ -1080,21 +2220,26 @@ class EmbyJellyfinBase { recentlyAdded, contentRating, fullLibraryForPosterSync, - syncProgress + syncProgress, + metadataOnlySync = false ) { + const runTag = + syncProgress && syncProgress.runId + ? ` [run ${syncProgress.runId}]` + : ""; let odSet = []; try { if (syncProgress) { console.log( new Date().toLocaleString() + - ` [poster sync] ${this.appName} — GET /Library/MediaFolders (match configured libraries)…` + ` [poster sync]${runTag} ${this.appName} — GET /Library/MediaFolders (match configured libraries)…` ); } const libEntries = await this.GetLibraryKeys(onDemandLibraries); if (syncProgress) { console.log( new Date().toLocaleString() + - ` [poster sync] ${this.appName} — ${libEntries.length} library folder(s) matched; loading items…` + ` [poster sync]${runTag} ${this.appName} — ${libEntries.length} library folder(s) matched; loading items…` ); syncProgress.libraries = posterSyncLib.buildLibraryProgressRows( onDemandLibraries, @@ -1109,7 +2254,8 @@ class EmbyJellyfinBase { genres, recentlyAdded, contentRating, - syncProgress + syncProgress, + metadataOnlySync ); const od = await util.build_random_od_set( numberOnDemand, @@ -1150,7 +2296,15 @@ class EmbyJellyfinBase { opts ) { const posterSyncFull = opts && opts.posterSyncFullLibrary === true; + const metadataOnlySync = posterSyncFull && opts && opts.metadataOnlySync === true; + const enrichDetails = + posterSyncFull && + !metadataOnlySync && + (opts && opts.posterSyncEnrichDetails !== undefined + ? opts.posterSyncEnrichDetails === true + : FULL_SYNC_ENRICH_DETAILS); const sp = opts && opts.syncProgress; + const runTag = sp && sp.runId ? ` [run ${sp.runId}]` : ""; const imagePull = (opts && opts.imagePull) || {}; if (posterSyncFull && sp) { sp.phase = "fetching"; @@ -1160,11 +2314,13 @@ class EmbyJellyfinBase { } const effHasArt = posterSyncFull ? "true" : hasArt; const pullBackground = effHasArt === "true" && imagePull.background !== false; + const pullLogo = effHasArt === "true" && imagePull.logo !== false; const pullVideoPoster = imagePull.videoPoster !== false; const pullAlbumPoster = imagePull.albumPoster !== false; let odCards = []; let odRaw; + let posterStreamUsed = false; if (genres != undefined) { genres = genres .replace(/, /g, ",") @@ -1188,20 +2344,185 @@ class EmbyJellyfinBase { const now = new Date(); console.log( now.toLocaleString() + - " [poster sync] " + + " [poster sync]" + + runTag + + " " + this.appName + " — fetching full library list…" ); + if (!enrichDetails) { + console.log( + now.toLocaleString() + + " [poster sync]" + + runTag + + " " + + this.appName + + " — full sync detail enrich is OFF (faster mode)" + ); + } + } + const onPosterSyncBatchEarly = + opts && typeof opts.onPosterSyncBatch === "function" + ? opts.onPosterSyncBatch + : null; + const usePosterStreamChunking = + !!onPosterSyncBatchEarly && + opts.posterSyncStreamChunks !== false && + !( + opts.retryLibraryKeysFromLastSync && + opts.retryLibraryKeysFromLastSync.length + ); + if (usePosterStreamChunking) { + try { + posterStreamUsed = true; + if (sp) { + const nowS = new Date(); + console.log( + nowS.toLocaleString() + + " [poster sync]" + + runTag + + " " + + this.appName + + " — streaming library fetch (" + + POSTER_SYNC_STREAM_CHUNK_SIZE + + " Items per HTTP page; DB write after each chunk; already-synced rows skipped)…" + ); + } + odRaw = []; + let streamDbFlushSeq = 0; + let plannedGrandTotal = 0; + const libEntries = await this.GetLibraryKeys(onDemandLibraries); + if (sp) { + sp.libraries = posterSyncLib.buildLibraryProgressRows( + onDemandLibraries, + libEntries, + (e) => e.name, + (e) => e.name + ); + sp.phase = "caching"; + sp.label = metadataOnlySync + ? "Syncing metadata…" + : "Caching posters and images…"; + } + const streamRetrySet = + opts && Array.isArray(opts.retryLibraryKeysFromLastSync) + ? new Set( + opts.retryLibraryKeysFromLastSync.map((k) => String(k)) + ) + : null; + for (const entry of libEntries) { + await this.GetAllMediaForLibrary( + entry, + genres, + 0, + contentRatings, + sp, + metadataOnlySync, + { + pageSize: POSTER_SYNC_STREAM_CHUNK_SIZE, + onLibraryTotal: (libName, trc) => { + const lr = posterSyncLib.findLibraryRow(sp.libraries, libName); + if (lr && Number.isFinite(trc) && trc >= 0) { + lr.cacheTotal = trc; + lr.itemsCached = 0; + lr.cacheStatus = + lr.fetchStatus === "skipped" + ? "skipped" + : trc > 0 + ? "pending" + : "done"; + plannedGrandTotal += trc; + if (sp) { + sp.total = plannedGrandTotal; + } + } + }, + onPage: async (pageItems) => { + streamDbFlushSeq += 1; + if (sp) { + const batchPrefix = metadataOnlySync + ? "Syncing metadata… chunk " + : "Caching posters and images… chunk "; + sp.label = + batchPrefix + + streamDbFlushSeq + + " (" + + pageItems.length + + " titles)"; + console.log( + new Date().toLocaleString() + + " [poster sync]" + + runTag + + " " + + this.appName + + " — stream chunk " + + streamDbFlushSeq + + " (" + + pageItems.length + + " items)" + ); + } + await this._posterSyncProcessRawMdList(pageItems, { + posterSyncFull, + metadataOnlySync, + enrichDetails, + opts, + sp, + runTag, + retrySet: streamRetrySet, + pullBackground, + pullLogo, + pullVideoPoster, + pullAlbumPoster, + odCards, + onPosterSyncBatch: onPosterSyncBatchEarly, + batchMeta: { + batchIndex: streamDbFlushSeq, + totalBatches: null, + }, + getProgressTotal: () => + Math.max((sp && sp.total) || 0, odCards.length || 0), + imagePull, + }); + }, + } + ); + } + odRaw = odCards; + if (sp) { + const nowE = new Date(); + console.log( + nowE.toLocaleString() + + " [poster sync]" + + runTag + + " " + + this.appName + + " — streaming pass done — " + + (plannedGrandTotal || odCards.length) + + " item(s) reported by Jellyfin; " + + odCards.length + + " title(s) processed this run (" + + onDemandLibraries + + ")" + ); + } + } catch (e) { + if (!(e instanceof PosterSyncAbortedError)) { + throw e; + } + } + } else { + odRaw = await this.GetOnDemandRawData( + onDemandLibraries, + numberOnDemand, + genres, + 0, + contentRatings, + true, + sp, + metadataOnlySync + ); } - odRaw = await this.GetOnDemandRawData( - onDemandLibraries, - numberOnDemand, - genres, - 0, - contentRatings, - true, - sp - ); } else if (recentlyAdded > 0) { odRaw = await this.GetOnDemandRawData( onDemandLibraries, @@ -1209,6 +2530,7 @@ class EmbyJellyfinBase { genres, recentlyAdded, contentRatings, + false, false ); if (odRaw !== undefined) { @@ -1219,6 +2541,7 @@ class EmbyJellyfinBase { genres, 0, contentRatings, + false, false ) ); @@ -1229,6 +2552,7 @@ class EmbyJellyfinBase { genres, 0, contentRatings, + false, false ); } @@ -1239,6 +2563,7 @@ class EmbyJellyfinBase { genres, 0, contentRatings, + false, false ); } @@ -1252,7 +2577,7 @@ class EmbyJellyfinBase { odRaw = []; } - if (!odRaw || odRaw.length === 0) { + if ((!odRaw || odRaw.length === 0) && !posterStreamUsed) { if (posterSyncFull && sp) { sp.total = 0; sp.processed = 0; @@ -1274,10 +2599,40 @@ class EmbyJellyfinBase { return odCards; } - if (posterSyncFull && sp) { + if ( + !posterStreamUsed && + posterSyncFull && + opts && + Array.isArray(opts.retryLibraryKeysFromLastSync) && + opts.retryLibraryKeysFromLastSync.length && + odRaw && + odRaw.length > 0 + ) { + const kind = String(opts.posterSyncServerKind || "jellyfin") + .toLowerCase() + .trim(); + odRaw = posterSyncRetry.prioritizeOdRaw( + odRaw, + opts.retryLibraryKeysFromLastSync, + kind + ); + const nowJ = new Date(); + console.log( + nowJ.toLocaleString() + + " [poster sync] " + + this.appName + + " — prioritizing " + + opts.retryLibraryKeysFromLastSync.length + + " id(s) from last sync (missing images/metadata)" + ); + } + + if (posterSyncFull && sp && !posterStreamUsed) { sp.total = odRaw.length; sp.phase = "caching"; - sp.label = "Caching posters and images…"; + sp.label = metadataOnlySync + ? "Syncing metadata…" + : "Caching posters and images…"; const counts = posterSyncLib.countItemsByLibraryFields(odRaw, [ "_jfLibraryName", ]); @@ -1293,7 +2648,9 @@ class EmbyJellyfinBase { const now = new Date(); console.log( now.toLocaleString() + - " [poster sync] " + + " [poster sync]" + + runTag + + " " + this.appName + " — " + odRaw.length + @@ -1303,29 +2660,47 @@ class EmbyJellyfinBase { ); } - const odBatches = posterSyncFull - ? Array.from( - { length: Math.ceil(odRaw.length / POSTER_SYNC_BATCH_SIZE) }, - (_, i) => - odRaw.slice( - i * POSTER_SYNC_BATCH_SIZE, - (i + 1) * POSTER_SYNC_BATCH_SIZE + const odBatches = + posterSyncFull && posterStreamUsed + ? [] + : posterSyncFull + ? Array.from( + { length: Math.ceil(odRaw.length / POSTER_SYNC_BATCH_SIZE) }, + (_, i) => + odRaw.slice( + i * POSTER_SYNC_BATCH_SIZE, + (i + 1) * POSTER_SYNC_BATCH_SIZE + ) ) - ) - : [odRaw]; + : [odRaw]; const totalBatches = odBatches.length; - - for (let batchIdx = 0; batchIdx < totalBatches; batchIdx++) { + const retrySet = + posterSyncFull && + opts && + Array.isArray(opts.retryLibraryKeysFromLastSync) + ? new Set(opts.retryLibraryKeysFromLastSync.map((k) => String(k))) + : null; + const onPosterSyncBatch = + posterSyncFull && opts && typeof opts.onPosterSyncBatch === "function" + ? opts.onPosterSyncBatch + : null; + + syncBatches: for (let batchIdx = 0; batchIdx < totalBatches; batchIdx++) { const batch = odBatches[batchIdx]; if (posterSyncFull && sp) { + const batchPrefix = metadataOnlySync + ? "Syncing metadata… batch " + : "Caching posters and images… batch "; sp.label = - "Caching posters and images… batch " + + batchPrefix + (batchIdx + 1) + "/" + totalBatches; console.log( new Date().toLocaleString() + - " [poster sync] " + + " [poster sync]" + + runTag + + " " + this.appName + " — processing batch " + (batchIdx + 1) + @@ -1336,231 +2711,42 @@ class EmbyJellyfinBase { " items)" ); } - for (const md of batch) { - const medCard = new mediaCard(); - medCard.posterLibraryLabel = String(md._jfLibraryName || "").trim(); - const type = md.Type; - - if (type === "Series") { - medCard.tagLine = md.Name || ""; - const mediaId = (md.Id || "").replace(/[^a-zA-Z0-9]/g, ""); - medCard.DBID = mediaId; - medCard.theme = ""; - if (await util.isEmpty(md.CommunityRating)) { - medCard.rating = ""; - } else { - medCard.rating = Math.round(md.CommunityRating * 10) + "%"; - } - const fileName = `${mediaId}.jpg`; - const seriesPosterUrl = this.primaryImageUrl( - md.Id, - md.ImageTags && md.ImageTags.Primary - ); - medCard.posterDownloadURL = seriesPosterUrl; - if (pullVideoPoster) { - await this._cacheImageFromServer(seriesPosterUrl, fileName); - medCard.posterURL = "/imagecache/" + fileName; - } else { - medCard.posterURL = "/images/no-poster-available.png"; - } - if (pullBackground) { - const artName = `${mediaId}-art.jpg`; - try { - await this._cacheImageFromServer( - this.backdropImageUrl(md.Id, 0), - artName - ); - medCard.posterArtURL = "/imagecache/" + artName; - } catch (e) { - /* optional */ - } - } - medCard.posterAR = 1.47; - medCard.runTime = md.RunTimeTicks - ? Math.round(md.RunTimeTicks / 6000000000) - : 0; - medCard.title = md.Name || ""; - medCard.mediaType = "show"; - } else if (type === "Movie") { - const movieFileName = `${md.Id}.jpg`.replace(/[^a-zA-Z0-9._-]/g, "_"); - const moviePosterUrl = this.primaryImageUrl( - md.Id, - md.ImageTags && md.ImageTags.Primary - ); - medCard.posterDownloadURL = moviePosterUrl; - if (pullVideoPoster) { - await this._cacheImageFromServer(moviePosterUrl, movieFileName); - medCard.posterURL = "/imagecache/" + movieFileName; - } else { - medCard.posterURL = "/images/no-poster-available.png"; - } - if (pullBackground) { - const artName = `${md.Id}-art.jpg`.replace(/[^a-zA-Z0-9._-]/g, "_"); - try { - await this._cacheImageFromServer( - this.backdropImageUrl(md.Id, 0), - artName - ); - medCard.posterArtURL = "/imagecache/" + artName; - } catch (e) { - /* optional */ - } - } - medCard.posterAR = 1.47; - medCard.theme = ""; - medCard.title = md.Name || ""; - medCard.runTime = md.RunTimeTicks - ? Math.round(md.RunTimeTicks / 6000000000) - : 0; - medCard.resCodec = ""; - medCard.audioCodec = ""; - medCard.tagLine = await util.emptyIfNull(md.Taglines && md.Taglines[0]); - if (await util.isEmpty(md.CommunityRating)) { - medCard.rating = ""; - } else { - medCard.rating = Math.round(md.CommunityRating * 10) + "%"; - } - medCard.mediaType = "movie"; - } else if (type === "MusicAlbum") { - const albumFileName = `${md.Id}.jpg`.replace(/[^a-zA-Z0-9._-]/g, "_"); - medCard.DBID = (md.Id || "").replace(/[^a-zA-Z0-9]/g, "") || String(md.Id || ""); - const albumTag = - (md.ImageTags && md.ImageTags.Primary) || - (md.imageTags && md.imageTags.primary) || - md.PrimaryImageTag || - md.primaryImageTag || - ""; - if (pullAlbumPoster) { - const albumPoster = await this.cachePrimaryImageAny( - [ - { id: md.Id, tag: albumTag }, - { id: md.Id, tag: null }, - { - id: - md.AlbumArtists && - md.AlbumArtists[0] && - (md.AlbumArtists[0].Id || md.AlbumArtists[0].id), - tag: null, - }, - ], - albumFileName, - medCard - ); - medCard.posterURL = albumPoster || "/images/no-poster-available.png"; - } else { - medCard.posterURL = "/images/no-poster-available.png"; - } - if (pullBackground) { - const artName = `${md.Id}-art.jpg`.replace(/[^a-zA-Z0-9._-]/g, "_"); - try { - await this._cacheImageFromServer( - this.backdropImageUrl(md.Id, 0), - artName - ); - medCard.posterArtURL = "/imagecache/" + artName; - } catch (e) { - /* optional */ - } - } - medCard.posterAR = 1; - medCard.theme = ""; - medCard.title = md.Name || ""; - const albumArtist = - (md.AlbumArtist && String(md.AlbumArtist).trim()) || - (md.AlbumArtists && - md.AlbumArtists[0] && - (md.AlbumArtists[0].Name || md.AlbumArtists[0].name)) || - ""; - medCard.tagLine = albumArtist - ? `${albumArtist} — ${medCard.title}` - : medCard.title; - medCard.albumArtist = albumArtist; - medCard.runTime = md.RunTimeTicks - ? Math.round(md.RunTimeTicks / 6000000000) - : 0; - medCard.resCodec = ""; - medCard.audioCodec = ""; - if (await util.isEmpty(md.CommunityRating)) { - medCard.rating = ""; - } else { - medCard.rating = Math.round(md.CommunityRating * 10) + "%"; - } - medCard.mediaType = "album"; - } else { - continue; - } - - if (!(await util.isEmpty(md.Studios && md.Studios[0] && md.Studios[0].Name))) { - medCard.studio = md.Studios[0].Name; - } - - if (medCard.tagLine === "") medCard.tagLine = medCard.title; - - let contentRating = "NR"; - if (!(await util.isEmpty(md.OfficialRating))) { - contentRating = md.OfficialRating; - } - medCard.contentRating = contentRating; - medCard.ratingColour = EmbyJellyfinBase.ratingColour(contentRating); - - medCard.year = md.ProductionYear; - medCard.genre = await util.emptyIfNull(md.Genres); - medCard.summary = md.Overview || ""; - medCard.cast = util.formatCastFromEmbyPeople(md.People); - medCard.directors = util.formatDirectorsFromEmbyPeople(md.People); - medCard.cardType = md.ctype; - - const odPortraitKey = String(medCard.DBID || md.Id || "x").replace( - /[^a-zA-Z0-9._-]/g, - "_" - ); - await this.cacheItemPersonPortraits( - medCard, - md, - odPortraitKey, - imagePull - ); - - if (md.Id) medCard.posterApiItemId = String(md.Id); - - odCards.push(medCard); - if (sp) { - sp.processed = odCards.length; - } - if (posterSyncFull && sp && sp.libraries) { - const lr = posterSyncLib.findLibraryRow( - sp.libraries, - medCard.posterLibraryLabel - ); - if (lr && lr.cacheStatus !== "skipped") { - lr.cacheStatus = "running"; - lr.itemsCached = (lr.itemsCached || 0) + 1; - if (lr.cacheTotal > 0 && lr.itemsCached >= lr.cacheTotal) { - lr.cacheStatus = "done"; - } + try { + checkPosterSyncAborted(opts, posterSyncFull, sp); + } catch (e) { + if (e instanceof PosterSyncAbortedError) { + break syncBatches; } + throw e; } - if (posterSyncFull && sp) { - const n = odCards.length; - const total = sp.total || odRaw.length; - const step = Math.max(25, Math.min(500, Math.floor(total / 15) || 1)); - if (n === 1 || n >= total || n % step === 0) { - const t = medCard.mediaType || String(type || "").toLowerCase() || "?"; - const title = String(medCard.title || "").slice(0, 72); - console.log( - new Date().toLocaleString() + - " [poster sync] " + - n + - "/" + - total + - " " + - t + - ' — "' + - title + - '"' - ); + try { + await this._posterSyncProcessRawMdList(batch, { + posterSyncFull, + metadataOnlySync, + enrichDetails, + opts, + sp, + runTag, + retrySet, + pullBackground, + pullLogo, + pullVideoPoster, + pullAlbumPoster, + odCards, + onPosterSyncBatch, + batchMeta: { + batchIndex: batchIdx + 1, + totalBatches, + }, + getProgressTotal: () => + Math.max((sp && sp.total) || 0, (odRaw && odRaw.length) || 0), + imagePull, + }); + } catch (e) { + if (e instanceof PosterSyncAbortedError) { + break syncBatches; } - } + throw e; } } @@ -1570,13 +2756,20 @@ class EmbyJellyfinBase { } else if (posterSyncFull) { console.log( now.toLocaleString() + - " [poster sync] " + + " [poster sync]" + + runTag + + " " + this.appName + " — finished caching " + odCards.length + " item(s) from (" + onDemandLibraries + - ")" + ")" + + (opts && + typeof opts.posterSyncAbortCheck === "function" && + opts.posterSyncAbortCheck() + ? " (aborted)" + : "") ); } else { console.log( diff --git a/classes/mediaservers/kodi.js b/classes/mediaservers/kodi.js index 22ab492..e7ce066 100644 --- a/classes/mediaservers/kodi.js +++ b/classes/mediaservers/kodi.js @@ -4,7 +4,14 @@ const mediaCard = require("./../cards/MediaCard"); const cType = require("./../cards/CardType"); const util = require("./../core/utility"); const core = require("./../core/cache"); +const tmdbBackdropFallback = require("./../core/tmdbBackdropFallback"); const posterSyncLib = require("./../core/posterSyncProgress"); +const posterSyncRetry = require("./../core/posterSyncRetry"); +const posterMetadataDb = require("./../core/posterMetadataDb"); +const { + PosterSyncAbortedError, + checkPosterSyncAborted, +} = require("./../core/posterSyncAbort"); const { CardTypeEnum } = require("./../cards/CardType"); const EmbyJellyfinBase = require("./embyJellyfinBase"); const POSTER_SYNC_BATCH_SIZE = 100; @@ -104,6 +111,23 @@ class Kodi { return it.art.fanart || it.fanart || null; } + bannerFromItem(it) { + if (!it || !it.art) return null; + return it.art.banner || it.banner || null; + } + + /** Kodi clearlogo / clearart (title treatment) for cache as *-logo.png */ + clearlogoFromItem(it) { + if (!it || !it.art) return null; + return ( + it.art.clearlogo || + it.clearlogo || + it.art.clearart || + it.clearart || + null + ); + } + static timeToSeconds(t) { if (!t || typeof t !== "object") return 0; return ( @@ -447,6 +471,8 @@ class Kodi { "file", "art", "dateadded", + "imdbnumber", + "uniqueid", ]; const lim = limits || { start: 0, end: 20000 }; try { @@ -485,6 +511,8 @@ class Kodi { "art", "studio", "dateadded", + "imdbnumber", + "uniqueid", ]; const lim = limits || { start: 0, end: 20000 }; try { @@ -687,7 +715,9 @@ class Kodi { sp.total = 0; } const effHasArt = posterSyncFull ? "true" : hasArt; + const metadataOnlySync = posterSyncFull && opts && opts.metadataOnlySync === true; const pullBackground = effHasArt === "true" && imagePull.background !== false; + const pullLogo = effHasArt === "true" && imagePull.logo !== false; const pullVideoPoster = imagePull.videoPoster !== false; let odCards = []; @@ -802,6 +832,28 @@ class Kodi { return odCards; } + if ( + posterSyncFull && + opts && + Array.isArray(opts.retryLibraryKeysFromLastSync) && + opts.retryLibraryKeysFromLastSync.length && + odRaw && + odRaw.length > 0 + ) { + odRaw = posterSyncRetry.prioritizeOdRaw( + odRaw, + opts.retryLibraryKeysFromLastSync, + "kodi" + ); + const nowK = new Date(); + console.log( + nowK.toLocaleString() + + " [poster sync] Kodi — prioritizing " + + opts.retryLibraryKeysFromLastSync.length + + " id(s) from last sync (missing images/metadata)" + ); + } + if (posterSyncFull && sp) { sp.total = odRaw.length; sp.phase = "caching"; @@ -840,8 +892,14 @@ class Kodi { ) : [odRaw]; const totalBatches = odBatches.length; + const retrySet = + posterSyncFull && + opts && + Array.isArray(opts.retryLibraryKeysFromLastSync) + ? new Set(opts.retryLibraryKeysFromLastSync.map((k) => String(k))) + : null; - for (let batchIdx = 0; batchIdx < totalBatches; batchIdx++) { + syncBatches: for (let batchIdx = 0; batchIdx < totalBatches; batchIdx++) { const batch = odBatches[batchIdx]; if (posterSyncFull && sp) { sp.label = @@ -860,13 +918,45 @@ class Kodi { " items)" ); } + try { + checkPosterSyncAborted(opts, posterSyncFull, sp); + } catch (e) { + if (e instanceof PosterSyncAbortedError) { + break syncBatches; + } + throw e; + } for (const md of batch) { + if (posterSyncFull) { + const rawApiId = + md && md._kodiKind === "show" && md.tvshowid != null + ? "s:" + String(md.tvshowid) + : md && md._kodiKind === "movie" && md.movieid != null + ? "m:" + String(md.movieid) + : ""; + const sourceUpdatedAt = + (md && md.lastmodified) || + (md && md.dateadded) || + (md && md.dateAdded) || + ""; + const mustRetry = !!(retrySet && rawApiId && retrySet.has(rawApiId)); + if ( + rawApiId && + !mustRetry && + posterMetadataDb.shouldSkipSyncItem("kodi", rawApiId, sourceUpdatedAt) + ) { + if (sp) { + sp.processed = Math.min(sp.total || 0, (sp.processed || 0) + 1); + } + continue; + } + } const medCard = new mediaCard(); medCard.posterLibraryLabel = String(md._kodiLabel || "").trim(); const kind = md._kodiKind; if (kind === "show") { - medCard.tagLine = md.title || ""; + medCard.tagLine = await util.emptyIfNull(md.tagline || md.plotoutline); const id = md.tvshowid != null ? md.tvshowid : md.title; const mediaId = String(id).replace(/[^a-zA-Z0-9]/g, ""); medCard.DBID = mediaId; @@ -887,10 +977,15 @@ class Kodi { await core.CacheImage(url, fileName); } medCard.posterURL = "/imagecache/" + fileName; + } else if (metadataOnlySync && posterVfs) { + const url = this.vfsImageUrl(posterVfs); + if (url) medCard.posterDownloadURL = url; + medCard.posterURL = "/imagecache/" + fileName; } else if (!pullVideoPoster) { medCard.posterURL = "/images/no-poster-available.png"; } if (pullBackground) { + let kodiShowBannerOk = false; const fan = this.fanartFromItem(md); if (fan) { const artName = `kodi-show-${mediaId}-art.jpg`; @@ -901,6 +996,43 @@ class Kodi { /* optional */ } } + const ban = this.bannerFromItem(md); + if (ban) { + const bnName = `kodi-show-${mediaId}-banner.jpg`; + try { + await core.CacheImage(this.vfsImageUrl(ban), bnName); + kodiShowBannerOk = true; + if (!medCard.posterArtURL) { + medCard.posterArtURL = "/imagecache/" + bnName; + } + } catch (e) { + /* optional */ + } + } + await tmdbBackdropFallback.cacheTmdbBannerIfNeeded({ + tmdbApiKey: opts && opts.tmdbApiKey, + pullBackground, + serverBannerOk: kodiShowBannerOk, + mediaType: "show", + title: md.title, + year: md.year, + ...tmdbBackdropFallback.collectKodiExternalIds(md), + bannerFileName: `kodi-show-${mediaId}-banner.jpg`, + medCard, + cacheImage: (u, f) => core.CacheImage(u, f), + }); + } + if (pullLogo) { + const cl = this.clearlogoFromItem(md); + if (cl) { + const logoName = `kodi-show-${mediaId}-logo.png`; + try { + await core.CacheImage(this.vfsImageUrl(cl), logoName); + medCard.posterLogoURL = "/imagecache/" + logoName; + } catch (e) { + /* optional */ + } + } } medCard.posterAR = 1.47; medCard.runTime = @@ -918,10 +1050,15 @@ class Kodi { await core.CacheImage(url, movieFileName); } medCard.posterURL = "/imagecache/" + movieFileName; + } else if (metadataOnlySync && posterVfs) { + const url = this.vfsImageUrl(posterVfs); + if (url) medCard.posterDownloadURL = url; + medCard.posterURL = "/imagecache/" + movieFileName; } else if (!pullVideoPoster) { medCard.posterURL = "/images/no-poster-available.png"; } if (pullBackground) { + let kodiMovieBannerOk = false; const fan = this.fanartFromItem(md); if (fan) { const artName = movieFileName.replace(/\.jpg$/, "-art.jpg"); @@ -932,6 +1069,43 @@ class Kodi { /* optional */ } } + const ban = this.bannerFromItem(md); + const movieBnName = movieFileName.replace(/\.jpg$/, "-banner.jpg"); + if (ban) { + try { + await core.CacheImage(this.vfsImageUrl(ban), movieBnName); + kodiMovieBannerOk = true; + if (!medCard.posterArtURL) { + medCard.posterArtURL = "/imagecache/" + movieBnName; + } + } catch (e) { + /* optional */ + } + } + await tmdbBackdropFallback.cacheTmdbBannerIfNeeded({ + tmdbApiKey: opts && opts.tmdbApiKey, + pullBackground, + serverBannerOk: kodiMovieBannerOk, + mediaType: "movie", + title: md.title, + year: md.year, + ...tmdbBackdropFallback.collectKodiExternalIds(md), + bannerFileName: movieBnName, + medCard, + cacheImage: (u, f) => core.CacheImage(u, f), + }); + } + if (pullLogo) { + const cl = this.clearlogoFromItem(md); + if (cl) { + const logoName = movieFileName.replace(/\.jpg$/i, "") + "-logo.png"; + try { + await core.CacheImage(this.vfsImageUrl(cl), logoName); + medCard.posterLogoURL = "/imagecache/" + logoName; + } catch (e) { + /* optional */ + } + } } medCard.posterAR = 1.47; medCard.theme = ""; @@ -940,7 +1114,7 @@ class Kodi { md.runtime != null ? Math.round(md.runtime / 60) : 0; medCard.resCodec = ""; medCard.audioCodec = ""; - medCard.tagLine = medCard.title; + medCard.tagLine = await util.emptyIfNull(md.tagline || md.plotoutline); const ratingVal = parseFloat(md.rating); medCard.rating = !isNaN(ratingVal) && ratingVal > 0 @@ -982,7 +1156,7 @@ class Kodi { odCards.push(medCard); if (sp) { - sp.processed = odCards.length; + sp.processed = Math.min(sp.total || 0, (sp.processed || 0) + 1); } if (posterSyncFull && sp && sp.libraries) { const lr = posterSyncLib.findLibraryRow( @@ -1018,6 +1192,14 @@ class Kodi { ); } } + try { + checkPosterSyncAborted(opts, posterSyncFull, sp); + } catch (e) { + if (e instanceof PosterSyncAbortedError) { + break syncBatches; + } + throw e; + } } } @@ -1031,7 +1213,12 @@ class Kodi { odCards.length + " item(s) from (" + onDemandLibraries + - ")" + ")" + + (opts && + typeof opts.posterSyncAbortCheck === "function" && + opts.posterSyncAbortCheck() + ? " (aborted)" + : "") ); } else { console.log( diff --git a/classes/mediaservers/plex.js b/classes/mediaservers/plex.js index abf3d35..d8ce002 100644 --- a/classes/mediaservers/plex.js +++ b/classes/mediaservers/plex.js @@ -3,12 +3,59 @@ const mediaCard = require("./../cards/MediaCard"); const cType = require("./../cards/CardType"); const util = require("./../core/utility"); const core = require("./../core/cache"); +const tmdbBackdropFallback = require("./../core/tmdbBackdropFallback"); const posterSyncLib = require("./../core/posterSyncProgress"); +const posterSyncRetry = require("./../core/posterSyncRetry"); +const posterMetadataDb = require("./../core/posterMetadataDb"); +const { + PosterSyncAbortedError, + checkPosterSyncAborted, +} = require("./../core/posterSyncAbort"); const { CardTypeEnum } = require("./../cards/CardType"); const { error } = require("jquery"); const POSTER_SYNC_BATCH_SIZE = 100; // const sizeOf = require("image-size"); +/** Plex clear logo path from metadata field or Image[] (provider clearLogo). */ +function plexClearLogoPathFromMetadata(md) { + if (!md) return ""; + const d = md.clearLogo || md.clearlogo; + if (d && String(d).trim()) return String(d).trim(); + const arr = md.Image || md.image; + if (!Array.isArray(arr)) return ""; + for (const im of arr) { + if (!im) continue; + const typ = String(im.type || im.Type || "").toLowerCase(); + if (typ !== "clearlogo") continue; + const u = im.url || im.URL || im.path || im.Path || ""; + if (u) return String(u).trim(); + } + return ""; +} + +/** + * Download Plex clear logo into imagecache as `{idForFile}-logo.png`. + */ +async function plexCacheClearLogo(core, self, md, idForFile, medCard) { + const rel = plexClearLogoPathFromMetadata(md); + if (!rel || !idForFile) return; + const logoFile = `${idForFile}-logo.png`; + try { + let fetchUrl; + if (/^https?:\/\//i.test(rel)) { + fetchUrl = rel; + } else { + const pre = self.https ? "https://" : "http://"; + const sep = rel.includes("?") ? "&" : "?"; + fetchUrl = `${pre}${self.plexIP}:${self.plexPort}${rel}${sep}X-Plex-Token=${self.plexToken}`; + } + await core.CacheImage(fetchUrl, logoFile); + medCard.posterLogoURL = "/imagecache/" + logoFile; + } catch (e) { + /* optional */ + } +} + /** * @desc Used to communicate with Plex * @param {string} HTTPS - set this to true if Plex only allows secure connections @@ -480,6 +527,14 @@ class Plex { medCard.summary = md.summary; medCard.cast = util.formatCastFromPlexRole(md.Role); medCard.directors = util.formatDirectorsFromPlexDirector(md.Director); + { + const cp = String(medCard.cast || "") + .split(",") + .map((x) => x.trim()) + .filter(Boolean); + medCard.actor1 = cp[0] || ""; + medCard.actor2 = cp[1] || ""; + } if (md.type === "track") { medCard.albumArtist = (md.grandparentTitle || "").trim(); } @@ -583,7 +638,9 @@ class Plex { const effPlayThemes = posterSyncFull ? "false" : playThemes; const effPlayGenenericThemes = posterSyncFull ? "false" : playGenenericThemes; const effHasArt = posterSyncFull ? "true" : hasArt; + const metadataOnlySync = posterSyncFull && opts && opts.metadataOnlySync === true; const pullBackground = effHasArt == "true" && imagePull.background !== false; + const pullLogo = effHasArt == "true" && imagePull.logo !== false; const pullVideoPoster = imagePull.videoPoster !== false; const pullAlbumPoster = imagePull.albumPoster !== false; @@ -693,6 +750,28 @@ class Plex { } } + if ( + posterSyncFull && + opts && + Array.isArray(opts.retryLibraryKeysFromLastSync) && + opts.retryLibraryKeysFromLastSync.length && + odRaw && + odRaw.length > 0 + ) { + odRaw = posterSyncRetry.prioritizeOdRaw( + odRaw, + opts.retryLibraryKeysFromLastSync, + "plex" + ); + const nowP = new Date(); + console.log( + nowP.toLocaleString() + + " [poster sync] Plex — prioritizing " + + opts.retryLibraryKeysFromLastSync.length + + " id(s) from last sync (missing images/metadata)" + ); + } + // reutrn an empty array if no results if (odRaw.length !== null && odRaw.length !== 0 && odRaw !== ",") { if (posterSyncFull && sp) { @@ -732,6 +811,12 @@ class Plex { ) : [odRaw]; const totalBatches = odBatches.length; + const retrySet = + posterSyncFull && + opts && + Array.isArray(opts.retryLibraryKeysFromLastSync) + ? new Set(opts.retryLibraryKeysFromLastSync.map((k) => String(k))) + : null; // move through results and populate media cards for (let batchIdx = 0; batchIdx < totalBatches; batchIdx++) { const batch = odBatches[batchIdx]; @@ -752,13 +837,34 @@ class Plex { " items)" ); } + try { + checkPosterSyncAborted(opts, posterSyncFull, sp); await batch.reduce(async (memo, md) => { await memo; + if (posterSyncFull) { + const rawApiId = String((md && md.ratingKey) || "").trim(); + const sourceUpdatedAt = + (md && md.updatedAt) || + (md && md.updated_at) || + (md && md.addedAt) || + ""; + const mustRetry = !!(retrySet && rawApiId && retrySet.has(rawApiId)); + if ( + rawApiId && + !mustRetry && + posterMetadataDb.shouldSkipSyncItem("plex", rawApiId, sourceUpdatedAt) + ) { + if (sp) { + sp.processed = Math.min(sp.total || 0, (sp.processed || 0) + 1); + } + return; + } + } const medCard = new mediaCard(); // modify inputs, based upon tv episode or movie result structure switch (md.type) { case "show": - medCard.tagLine = md.title; + medCard.tagLine = await util.emptyIfNull(md.tagline); let result = md.guid.split("/"); // Use TVDB ID if available, otherwise use GUID @@ -809,16 +915,19 @@ class Plex { "?X-Plex-Token=" + this.plexToken; - if (pullVideoPoster) { + if (pullVideoPoster && md.thumb) { medCard.posterDownloadURL = url; await core.CacheImage(url, fileName); medCard.posterURL = "/imagecache/" + fileName; + } else if (metadataOnlySync && md.thumb) { + medCard.posterDownloadURL = url; + medCard.posterURL = "/imagecache/" + fileName; } else { medCard.posterURL = "/images/no-poster-available.png"; } - //download poster art - // check art exists + //download poster art + optional Plex banner (wide) + let showHasBackdrop = false; if (md.art !== undefined && pullBackground) { fileName = mediaId + "-art.jpg"; if (this.https) prefix = "https://"; @@ -833,6 +942,44 @@ class Plex { await core.CacheImage(url, fileName); medCard.posterArtURL = "/imagecache/" + fileName; + showHasBackdrop = true; + } + let showServerBannerOk = false; + if (pullBackground && md.banner) { + fileName = mediaId + "-banner.jpg"; + if (this.https) prefix = "https://"; + url = + prefix + + this.plexIP + + ":" + + this.plexPort + + md.banner + + "?X-Plex-Token=" + + this.plexToken; + try { + await core.CacheImage(url, fileName); + showServerBannerOk = true; + if (!showHasBackdrop) { + medCard.posterArtURL = "/imagecache/" + fileName; + } + } catch (e) { + /* optional */ + } + } + await tmdbBackdropFallback.cacheTmdbBannerIfNeeded({ + tmdbApiKey: opts && opts.tmdbApiKey, + pullBackground, + serverBannerOk: showServerBannerOk, + mediaType: "show", + title: md.title || md.grandparentTitle, + year: md.year, + ...tmdbBackdropFallback.collectPlexExternalIds(md), + bannerFileName: mediaId + "-banner.jpg", + medCard, + cacheImage: (u, f) => core.CacheImage(u, f), + }); + if (pullLogo) { + await plexCacheClearLogo(core, this, md, mediaId, medCard); } medCard.posterAR = 1.47; @@ -855,16 +1002,19 @@ class Plex { md.thumb + "?X-Plex-Token=" + this.plexToken; - if (pullVideoPoster) { + if (pullVideoPoster && md.thumb) { medCard.posterDownloadURL = movieUrl; await core.CacheImage(movieUrl, movieFileName); medCard.posterURL = "/imagecache/" + movieFileName; + } else if (metadataOnlySync && md.thumb) { + medCard.posterDownloadURL = movieUrl; + medCard.posterURL = "/imagecache/" + movieFileName; } else { medCard.posterURL = "/images/no-poster-available.png"; } - //download poster - // check art exists + //download poster art + optional Plex banner (wide) + let movieHasBackdrop = false; if (md.art !== undefined && pullBackground) { movieFileName = md.ratingKey + "-art.jpg"; if (this.https) moviePlexPrefix = "https://"; @@ -879,6 +1029,44 @@ class Plex { await core.CacheImage(movieUrl, movieFileName); medCard.posterArtURL = "/imagecache/" + movieFileName; + movieHasBackdrop = true; + } + let movieServerBannerOk = false; + if (pullBackground && md.banner) { + movieFileName = md.ratingKey + "-banner.jpg"; + if (this.https) moviePlexPrefix = "https://"; + movieUrl = + moviePlexPrefix + + this.plexIP + + ":" + + this.plexPort + + md.banner + + "?X-Plex-Token=" + + this.plexToken; + try { + await core.CacheImage(movieUrl, movieFileName); + movieServerBannerOk = true; + if (!movieHasBackdrop) { + medCard.posterArtURL = "/imagecache/" + movieFileName; + } + } catch (e) { + /* optional */ + } + } + await tmdbBackdropFallback.cacheTmdbBannerIfNeeded({ + tmdbApiKey: opts && opts.tmdbApiKey, + pullBackground, + serverBannerOk: movieServerBannerOk, + mediaType: "movie", + title: md.title, + year: md.year, + ...tmdbBackdropFallback.collectPlexExternalIds(md), + bannerFileName: md.ratingKey + "-banner.jpg", + medCard, + cacheImage: (u, f) => core.CacheImage(u, f), + }); + if (pullLogo) { + await plexCacheClearLogo(core, this, md, String(md.ratingKey), medCard); } // play movie theme or add generic random theme if applicable @@ -948,13 +1136,17 @@ class Plex { md.thumb + "?X-Plex-Token=" + this.plexToken; - if (pullAlbumPoster) { + if (pullAlbumPoster && md.thumb) { medCard.posterDownloadURL = albImgUrl; await core.CacheImage(albImgUrl, albPoster); medCard.posterURL = "/imagecache/" + albPoster; + } else if (metadataOnlySync && md.thumb) { + medCard.posterDownloadURL = albImgUrl; + medCard.posterURL = "/imagecache/" + albPoster; } else { medCard.posterURL = "/images/no-poster-available.png"; } + let albHasBackdrop = false; if (md.art !== undefined && pullBackground) { let albArtFile = md.ratingKey + "-art.jpg"; let albArtUrl = @@ -967,6 +1159,7 @@ class Plex { this.plexToken; await core.CacheImage(albArtUrl, albArtFile); medCard.posterArtURL = "/imagecache/" + albArtFile; + albHasBackdrop = true; } else if (md.grandparentArt !== undefined && pullBackground) { let albArtFile = md.ratingKey + "-art.jpg"; let albArtUrl = @@ -979,6 +1172,35 @@ class Plex { this.plexToken; await core.CacheImage(albArtUrl, albArtFile); medCard.posterArtURL = "/imagecache/" + albArtFile; + albHasBackdrop = true; + } + if (pullBackground && md.banner) { + let albBnFile = md.ratingKey + "-banner.jpg"; + let albBnUrl = + albPre + + this.plexIP + + ":" + + this.plexPort + + md.banner + + "?X-Plex-Token=" + + this.plexToken; + try { + await core.CacheImage(albBnUrl, albBnFile); + if (!albHasBackdrop) { + medCard.posterArtURL = "/imagecache/" + albBnFile; + } + } catch (e) { + /* optional */ + } + } + if (pullLogo) { + await plexCacheClearLogo( + core, + this, + md, + String(md.ratingKey != null ? md.ratingKey : ""), + medCard + ); } medCard.posterAR = 1; medCard.title = md.title || ""; @@ -1070,6 +1292,14 @@ class Plex { medCard.summary = md.summary; medCard.cast = util.formatCastFromPlexRole(md.Role); medCard.directors = util.formatDirectorsFromPlexDirector(md.Director); + { + const cp = String(medCard.cast || "") + .split(",") + .map((x) => x.trim()) + .filter(Boolean); + medCard.actor1 = cp[0] || ""; + medCard.actor2 = cp[1] || ""; + } await this.populatePlexPersonPosters(medCard, md, imagePull); // calculate for recently added (if set) @@ -1082,7 +1312,7 @@ class Plex { // add media card to array odCards.push(medCard); if (sp) { - sp.processed = odCards.length; + sp.processed = Math.min(sp.total || 0, (sp.processed || 0) + 1); } if (posterSyncFull && sp && sp.libraries) { const lr = posterSyncLib.findLibraryRow( @@ -1119,7 +1349,15 @@ class Plex { } } + checkPosterSyncAborted(opts, posterSyncFull, sp); + }, undefined); + } catch (e) { + if (e instanceof PosterSyncAbortedError) { + break; + } + throw e; + } } } let now = new Date(); @@ -1132,7 +1370,12 @@ class Plex { odCards.length + " item(s) from (" + onDemandLibraries + - ")" + ")" + + (opts && + typeof opts.posterSyncAbortCheck === "function" && + opts.posterSyncAbortCheck() + ? " (aborted)" + : "") ); } else { console.log( diff --git a/config/.keep b/config/.keep deleted file mode 100644 index e69de29..0000000 diff --git a/config/cache/.keep b/config/cache/.keep deleted file mode 100644 index e69de29..0000000 diff --git a/consts.js b/consts.js index 5b08c14..a8642ab 100644 --- a/consts.js +++ b/consts.js @@ -14,6 +14,8 @@ const DEFAULT_SETTINGS = { pinNS : "false", hideUser: "false", onDemandLibraries : "", + /** On-demand library names that should be marked as 3D (comma-separated). */ + onDemand3dLibraries : "", numberOnDemand : 2, onDemandRefresh : 30, sonarrURL : "", @@ -59,6 +61,42 @@ const DEFAULT_SETTINGS = { footColour: "#FAFAD2", bgColour: "#000000", enableNS: "true", + /** Insert Now Playing (media server) slides after this many other poster slides; 0 = off */ + nowPlayingEveryPosters: 0, + /** Include TMDB Now Showing list on the main poster carousel */ + enableNowShowingListInPoster: "false", + /** Insert one TMDB Now Showing slide every N minutes on the main poster carousel; 0 = off (when mixed with other slides). */ + nowShowingListEveryMins: 0, + /** When on (and list on home is on), main poster shows only TMDB Now Showing slides when the list is non-empty */ + nowShowingListOnly: "false", + /** Optional banner title override for TMDB Now Showing slides on the main poster */ + nowShowingListBanner: "", + /** Dedicated /now-showing view: add random titles from on-demand library pool (global odCards) */ + nowShowingFillFromServer: "false", + /** Max extra library titles to mix into /now-showing per data refresh (0–48) */ + nowShowingFillLibraryMax: 12, + /** How many times more often curated (saved) titles appear vs library fillers in the rotating list (1–20) */ + nowShowingCuratedWeight: 4, + /** Max future showtimes per title on /now-showing and TMDB list slides (1–6) */ + nowShowingShowtimeCount: 6, + /** Show ticket prices on /now-showing cards when available */ + nowShowingShowPrices: "false", + /** Auto-generate prices for titles without a saved price */ + nowShowingAutoPriceEnabled: "false", + /** Minimum auto-generated ticket price */ + nowShowingAutoPriceMin: 8, + /** Maximum auto-generated ticket price */ + nowShowingAutoPriceMax: 18, + /** Extra amount added to displayed prices for titles marked as 3D. */ + nowShowing3dPriceExtra: 0, + /** Currency code used for now-showing ticket price display */ + nowShowingCurrencyCode: "USD", + /** Auto-switch from home slideshow to dedicated /now-showing page on a timer. */ + enableNowShowingPageCycle: "false", + /** Minutes to stay on home before switching to /now-showing (1–1440). */ + nowShowingPageCycleEveryMins: 30, + /** Minutes to stay on /now-showing before returning home (1–120). */ + nowShowingPageCycleStayMins: 5, enableOD: "true", enableSonarr: "true", enableRadarr: "true", @@ -73,6 +111,24 @@ const DEFAULT_SETTINGS = { enableCustomPictures: "false", enableCustomPictureThemes: "false", customPictureTheme: "default", + customPictureEveryPosters: 0, + /** Placeholder: same shape as custom pictures; slideshow wiring added later */ + enableAds: "false", + adsOnly: "false", + adsTheme: "default", + adsEveryPosters: 0, + adsCurrencyCode: "USD", + /** When "true", /ads shows a dark stroke around the ad title for contrast. */ + adsTitleOutline: "false", + /** Seconds each ad stays visible on /ads before advancing (3–600). */ + adsRotationSeconds: 10, + /** + * Seconds the dedicated /ads page stays open before redirecting to home posters (/). + * 0 = stay until manual navigation. When > 0, clamped 30–86400 on save and in the UI. + */ + adsPageStaySeconds: 0, + /** URL path under /custom/ads-view/… for the full-page backdrop on /ads only; empty = none. */ + adsGlobalBackgroundPath: "", serverID: "", sleepStart: "00:00", sleepEnd: "07:00", @@ -104,7 +160,10 @@ const DEFAULT_SETTINGS = { /** When true, slideshow library slides come from poster cache/DB first; live on-demand from the media server is only a backup and still fills the cache. */ preferCachedPosters: "true", /** How many cached poster slides to build when preferCachedPosters is on (actual deck is at most entries in the poster DB). */ - cachedPosterSlideCount: 48 + cachedPosterSlideCount: 48, + tmdbApiKey: "", + /** Last package.json version the admin acknowledged (hides “new features” banner when it matches current version) */ + newFeaturesAcknowledgedVersion: "" }; module.exports = DEFAULT_SETTINGS; \ No newline at end of file diff --git a/docker-compose.debug.yml b/docker-compose.debug.yml index 95fe199..b7b35d1 100644 --- a/docker-compose.debug.yml +++ b/docker-compose.debug.yml @@ -1,5 +1,5 @@ # Debug compose — same host-gateway helper as docker-compose.yml for media servers on the host. -# Bind-mount config (includes cache/) like production so poster sync and settings survive container rebuilds. +# Bind-mount config (cache/, ads/, ads-view/, DBs, settings) like production so data survives rebuilds. version: "3.8" diff --git a/docker-compose.media-servers.example.yml b/docker-compose.media-servers.example.yml index 0c0fee3..eace2de 100644 --- a/docker-compose.media-servers.example.yml +++ b/docker-compose.media-servers.example.yml @@ -4,7 +4,7 @@ # # If you use this file without docker-compose.yml, add volume mounts for Posterr: # config → /usr/src/app/config -# config → /usr/src/app/config (includes cache/: poster DB, imagecache, mp3cache) +# config → /usr/src/app/config (includes cache/, ads/, ads-view/, poster DBs, etc.) # # Posterr settings: # Server type: Jellyfin diff --git a/docker-compose.yml b/docker-compose.yml index 8787a05..6c6e735 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,8 +2,8 @@ # Jellyfin / Emby / Kodi / Plex are reached over HTTP from Posterr settings (no extra container packages). # # Volumes: -# config/ — settings.json and other config (required to keep setup across restarts). -# config/ — settings.json + cache/ (poster DB, imagecache, mp3cache). Mount the whole config dir. +# config/ — settings.json, cache/, ads/, ads-view/, custom-pictures/, SQLite DBs (mount whole config dir). +# Optional: public/custom — legacy uploads; on first run Posterr copies into config/ if those folders are empty. version: "3.8" @@ -20,8 +20,9 @@ services: BASEPATH: "" ET: "false" volumes: - - ~/docker/posterr/custom:/usr/src/app/public/custom - ~/docker/posterr/config:/usr/src/app/config + # Legacy only — omit if unused; one-time migration copies into config/ when config ads/pictures are empty + - ~/docker/posterr/custom:/usr/src/app/public/custom ports: - 9876:3000 restart: unless-stopped diff --git a/index.js b/index.js index 5d7f1fc..4898c7f 100644 --- a/index.js +++ b/index.js @@ -1,6 +1,7 @@ const express = require("express"); const path = require("path"); const app = express(); +const multer = require("multer"); const cors = require("cors"); const cookieParser = require("cookie-parser"); const session = require("express-session"); @@ -33,11 +34,18 @@ const { requiresMediaServerCredential, } = require("./classes/mediaservers/mediaServerFactory"); const posterMetadata = require("./classes/core/posterMetadataDb"); +const posterSyncRetry = require("./classes/core/posterSyncRetry"); +const nowShowingDb = require("./classes/core/nowShowingDb"); +const adsDb = require("./classes/core/adsDb"); const { + CONFIG_ROOT, CACHE_ROOT, IMAGE_CACHE_DIR, MP3_CACHE_DIR, RANDOM_THEMES_DIR, + ADS_MEDIA_DIR, + ADS_VIEW_BG_DIR, + CUSTOM_PICTURES_DIR, } = require("./classes/core/appPaths"); // just in case someone puts in a / for the basepath value @@ -74,11 +82,15 @@ console.log("-------------------------------------------------------"); // global variables let odCards = []; let nsCards = []; +/** TMDB Now Showing list slides for the main poster carousel (when enabled) */ +let tmdbNowShowingPosterCards = []; let csCards = []; let csrCards = []; let cslCards = []; let rtCards = []; let picCards = []; +/** Enabled ad slides for the main poster deck (from ads DB). */ +let adSlideCards = []; let csbCards = []; let trivCards = []; let linkCards = []; @@ -97,6 +109,258 @@ let linksClock; let setng = new settings(); let loadedSettings; let httpServerStarted = false; +const CUSTOM_PICTURES_ROOT = CUSTOM_PICTURES_DIR; +const ADS_MEDIA_ROOT = ADS_MEDIA_DIR; +const ADS_VIEW_BG_ROOT = ADS_VIEW_BG_DIR; +const customPicturesUpload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: 25 * 1024 * 1024 }, +}); +const adsMediaUpload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: 30 * 1024 * 1024 }, +}); +const adsMediaUploadAny = adsMediaUpload.any(); +const adsViewBgUpload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: 25 * 1024 * 1024 }, +}); + +function unlinkAdsGlobalBackgroundFileIfSafe(urlPath) { + if (!urlPath || typeof urlPath !== "string") return; + const u = urlPath.trim(); + if (!u.startsWith("/custom/ads-view/")) return; + const base = path.basename(u); + if (!base || base.includes("..")) return; + const diskPath = path.resolve(ADS_VIEW_BG_ROOT, base); + const rootResolved = path.resolve(ADS_VIEW_BG_ROOT); + if ( + !diskPath.startsWith(rootResolved + path.sep) && + diskPath !== rootResolved + ) { + return; + } + try { + if (fs.existsSync(diskPath)) fs.unlinkSync(diskPath); + } catch (e) { + /* ignore */ + } +} + +function sanitizeCustomPicturesFolderName(value) { + const trimmed = String(value == null ? "" : value).trim(); + if (!trimmed) return ""; + const normalized = trimmed + .replace(/[\\\/]/g, "") + .replace(/[^\w.\- ]/g, "") + .trim(); + if (!normalized || normalized === "." || normalized === "..") return ""; + return normalized; +} + +function normalizeCustomPictureThemeSelection(value) { + const raw = Array.isArray(value) + ? value + : String(value == null ? "" : value) + .split(","); + const cleaned = raw + .map((v) => sanitizeCustomPicturesFolderName(v)) + .filter(Boolean); + if (!cleaned.length) return DEFAULT_SETTINGS.customPictureTheme; + return Array.from(new Set(cleaned)).join(","); +} + +function resolveCustomPicturesTargetDirectory(themeFolder, newFolderName) { + const createFolder = sanitizeCustomPicturesFolderName(newFolderName); + const selectedFolder = sanitizeCustomPicturesFolderName(themeFolder); + const finalFolder = createFolder || selectedFolder || "default"; + const fullPath = path.resolve(CUSTOM_PICTURES_ROOT, finalFolder); + if (!fullPath.startsWith(CUSTOM_PICTURES_ROOT)) { + throw new Error("Invalid custom pictures folder"); + } + return { finalFolder, fullPath }; +} + +function copyDirContentsRecursive(src, dest) { + fs.mkdirSync(dest, { recursive: true }); + for (const name of fs.readdirSync(src)) { + const from = path.join(src, name); + const to = path.join(dest, name); + const st = fs.statSync(from); + if (st.isDirectory()) { + copyDirContentsRecursive(from, to); + } else if (st.isFile()) { + fs.copyFileSync(from, to); + } + } +} + +function treeHasPictureMedia(dir) { + if (!fs.existsSync(dir)) return false; + for (const name of fs.readdirSync(dir)) { + const p = path.join(dir, name); + let st; + try { + st = fs.statSync(p); + } catch (e) { + continue; + } + if (st.isFile() && /\.(jpe?g|png|gif|webp)$/i.test(name)) return true; + if (st.isDirectory() && treeHasPictureMedia(p)) return true; + } + return false; +} + +/** True if a directory has at least one slide image (ignores .keep, readme, etc.). */ +function flatDirHasAdImageMedia(dir) { + if (!fs.existsSync(dir)) return false; + for (const name of fs.readdirSync(dir)) { + if (/^(?:\.keep|readme\.txt|\.ds_store)$/i.test(name)) continue; + const p = path.join(dir, name); + let st; + try { + st = fs.statSync(p); + } catch (e) { + continue; + } + if (st.isFile() && /\.(jpe?g|png|gif|webp)$/i.test(name)) return true; + } + return false; +} + +/** + * Move legacy public/custom/pictures and public/custom/ads into config/custom-pictures + * and config/ads when the config side has no image media yet (repo .keep files do not count). + * After a successful copy, legacy folders are removed and empty placeholders are recreated. + */ +function migrateLegacyPublicCustomMediaToConfig() { + try { + const legacyPics = path.resolve(process.cwd(), "public", "custom", "pictures"); + const legacyAds = path.resolve(process.cwd(), "public", "custom", "ads"); + if ( + treeHasPictureMedia(legacyPics) && + !treeHasPictureMedia(CUSTOM_PICTURES_ROOT) + ) { + copyDirContentsRecursive(legacyPics, CUSTOM_PICTURES_ROOT); + fs.rmSync(legacyPics, { recursive: true, force: true }); + fs.mkdirSync(legacyPics, { recursive: true }); + fs.mkdirSync(path.join(legacyPics, "default"), { recursive: true }); + const now = new Date(); + console.log( + now.toLocaleString() + + " Custom pictures: moved public/custom/pictures → config/custom-pictures" + ); + } + if ( + flatDirHasAdImageMedia(legacyAds) && + !flatDirHasAdImageMedia(ADS_MEDIA_ROOT) + ) { + copyDirContentsRecursive(legacyAds, ADS_MEDIA_ROOT); + fs.rmSync(legacyAds, { recursive: true, force: true }); + fs.mkdirSync(legacyAds, { recursive: true }); + const now = new Date(); + console.log( + now.toLocaleString() + + " Ads media: moved public/custom/ads → config/ads" + ); + } + } catch (e) { + const now = new Date(); + console.log( + now.toLocaleString() + + " Legacy public/custom → config media migration skipped: " + + (e && e.message ? e.message : e) + ); + } +} + +function sanitizeUploadBaseName(value) { + const base = String(value == null ? "" : value) + .replace(/[^\w.\- ]/g, "") + .trim(); + return base || ""; +} + +function parseAdPriceLinesJson(value) { + const raw = String(value == null ? "" : value).trim(); + if (!raw) return []; + let parsed = []; + try { + parsed = JSON.parse(raw); + } catch (e) { + throw new Error("Price lines JSON is invalid"); + } + if (!Array.isArray(parsed)) return []; + return parsed + .map((row, idx) => { + const title = String((row && row.title) || "").trim(); + const amount = parseMoneyAmount(row && row.amount); + if (!title && amount == null) return null; + return { + title, + amount, + sortOrder: idx, + }; + }) + .filter(Boolean); +} + +function parseAdAddonLinesJson(value) { + const raw = String(value == null ? "" : value).trim(); + if (!raw) return []; + let parsed = []; + try { + parsed = JSON.parse(raw); + } catch (e) { + throw new Error("Add-on lines JSON is invalid"); + } + if (!Array.isArray(parsed)) return []; + return parsed + .map((row, idx) => { + const title = String((row && row.title) || "").trim(); + const amount = parseMoneyAmount(row && row.amount); + if (amount == null) return null; + return { + title, + amount, + sortOrder: idx, + }; + }) + .filter(Boolean); +} + +function firstUploadedFile(req, fieldName) { + if (!req || !Array.isArray(req.files)) return null; + for (const f of req.files) { + if (f && f.fieldname === fieldName) return f; + } + return null; +} + +function validateAdsUploadFields(req) { + const allowed = new Set(["adMediaFile", "adBackgroundFile"]); + const seen = new Set(); + const files = Array.isArray(req && req.files) ? req.files : []; + for (const f of files) { + const n = String((f && f.fieldname) || ""); + if (!allowed.has(n)) { + throw new Error("Unexpected upload field: " + (n || "(unknown)")); + } + seen.add(n); + } + return seen; +} + +function newFeaturesBannerViewData() { + const appVersion = String(pjson.version || "").trim(); + const ack = String( + (loadedSettings && loadedSettings.newFeaturesAcknowledgedVersion) || "" + ).trim(); + return { + showNewFeaturesBanner: !!loadedSettings && ack !== appVersion, + appVersion, + }; +} //let endPoint = "https://logz-dev.nesretep.net/pstr"; let endPoint = "https://logz.nesretep.net/pstr"; let nsCheckSeconds = 10000; // how often now screening checks are performed. (not available in setup screen as running too often can cause network issues) @@ -145,9 +409,9 @@ let excludeLibs = ""; // create working folders if they do not exist // needed for package binaries var fs = require('fs'); -const { CardTypeEnum } = require("./classes/cards/CardType"); const { titleColour, enableSleep, sleepStart, sleepEnd, numberOnDemand } = require("./consts"); const CardType = require("./classes/cards/CardType"); +const CardTypeEnum = CardType.CardTypeEnum; const MediaCard = require("./classes/cards/MediaCard"); const Links = require("./classes/custom/links"); const { now } = require("jquery"); @@ -158,6 +422,86 @@ if (!fs.existsSync(dir)) { fs.mkdirSync(dir); } +// Prevent multiple Posterr processes from running at the same time. +const APP_LOCK_FILE = path.join(CONFIG_ROOT, "posterr-app.lock"); +let appLockHeld = false; + +function processExists(pid) { + if (!Number.isFinite(pid) || pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch (e) { + return false; + } +} + +function releaseAppLock() { + if (!appLockHeld) return; + try { + if (fs.existsSync(APP_LOCK_FILE)) fs.unlinkSync(APP_LOCK_FILE); + } catch (e) { + /* ignore */ + } + appLockHeld = false; +} + +function acquireAppLockOrExit() { + const payload = JSON.stringify( + { pid: process.pid, startedAt: new Date().toISOString() }, + null, + 0 + ); + try { + fs.writeFileSync(APP_LOCK_FILE, payload, { flag: "wx" }); + appLockHeld = true; + return; + } catch (e) { + try { + if (fs.existsSync(APP_LOCK_FILE)) { + const raw = fs.readFileSync(APP_LOCK_FILE, "utf8"); + const lock = JSON.parse(raw || "{}"); + const lockPid = parseInt(lock.pid, 10); + if (!processExists(lockPid)) { + fs.unlinkSync(APP_LOCK_FILE); + fs.writeFileSync(APP_LOCK_FILE, payload, { flag: "wx" }); + appLockHeld = true; + console.log( + new Date().toLocaleString() + + " Posterr: removed stale process lock and continued startup" + ); + return; + } + console.log( + new Date().toLocaleString() + + " ✘✘ WARNING ✘✘ - Another Posterr instance is already running (pid " + + lockPid + + "). Exiting this process." + ); + process.exit(1); + } + } catch (e2) { + console.log( + new Date().toLocaleString() + + " ✘✘ WARNING ✘✘ - Could not acquire app lock: " + + (e2 && e2.message ? e2.message : e2) + ); + process.exit(1); + } + } +} + +acquireAppLockOrExit(); +process.on("exit", releaseAppLock); +process.on("SIGINT", () => { + releaseAppLock(); + process.exit(130); +}); +process.on("SIGTERM", () => { + releaseAppLock(); + process.exit(143); +}); + var dir = CACHE_ROOT; if (!fs.existsSync(dir)) { @@ -188,23 +532,17 @@ if (!fs.existsSync(dir)) { fs.mkdirSync(dir); } -var dir = './public/custom'; - -if (!fs.existsSync(dir)) { - fs.mkdirSync(dir); -} - -var dir = './public/custom/pictures'; +var dir = "./public/custom"; if (!fs.existsSync(dir)) { - fs.mkdirSync(dir); + fs.mkdirSync(dir, { recursive: true }); } -var dir = './public/custom/pictures/default'; - -if (!fs.existsSync(dir)) { - fs.mkdirSync(dir); -} +fs.mkdirSync(CUSTOM_PICTURES_ROOT, { recursive: true }); +fs.mkdirSync(path.join(CUSTOM_PICTURES_ROOT, "default"), { recursive: true }); +fs.mkdirSync(ADS_MEDIA_ROOT, { recursive: true }); +fs.mkdirSync(ADS_VIEW_BG_ROOT, { recursive: true }); +migrateLegacyPublicCustomMediaToConfig(); function checkTime(i) { try{ @@ -549,6 +887,15 @@ function preferCachedPostersEnabled() { return s !== "false" && s !== "0" && s !== "off" && s !== "no"; } +/** True when poster metadata DB has at least one row (sync may have run while server was up). */ +function cachedPosterDbHasRows() { + try { + return posterMetadata.countRows() > 0; + } catch (e) { + return false; + } +} + function primaryCachedPosterSlideCount() { const raw = loadedSettings && loadedSettings.cachedPosterSlideCount; const parsed = parseInt(raw, 10); @@ -564,8 +911,11 @@ function settingEnabled(val, defaultValue) { } function buildImagePullOptions() { + const hasArtOn = settingEnabled(loadedSettings && loadedSettings.hasArt, false); return { - background: settingEnabled(loadedSettings && loadedSettings.hasArt, false), + background: hasArtOn, + /** Title logos / clear logos (same toggle as fanart for sync scope) */ + logo: hasArtOn, videoPoster: settingEnabled( loadedSettings && loadedSettings.displayPosterVideo, true @@ -596,6 +946,442 @@ function buildImagePullOptions() { }; } +/** + * Poster metadata sync must always pull primary posters so rows can be registered + * in poster metadata DB, even when UI display toggles hide some poster categories. + */ +function buildPosterSyncImagePullOptions() { + const base = buildImagePullOptions(); + return { + ...base, + videoPoster: true, + albumPoster: true, + bookPoster: true, + }; +} + +function customPictureEveryPosters() { + const raw = loadedSettings && loadedSettings.customPictureEveryPosters; + const n = parseInt(raw, 10); + return isNaN(n) ? 0 : Math.max(0, n); +} + +function applyCustomPictureSpacing(cards) { + const every = customPictureEveryPosters(); + if (every <= 0 || !isPicturesEnabled || !Array.isArray(cards) || !cards.length) { + return cards; + } + if (!Array.isArray(picCards) || picCards.length === 0) return cards; + const picSet = new Set(picCards); + const pictures = cards.filter((c) => picSet.has(c)); + const nonPictures = cards.filter((c) => !picSet.has(c)); + if (!pictures.length || !nonPictures.length) return cards; + + const out = []; + let shown = 0; + let picIdx = 0; + for (const card of nonPictures) { + out.push(card); + shown++; + if (shown >= every && picIdx < pictures.length) { + out.push(pictures[picIdx++]); + shown = 0; + } + } + while (picIdx < pictures.length) out.push(pictures[picIdx++]); + return out; +} + +function nowPlayingEveryPosters() { + const raw = loadedSettings && loadedSettings.nowPlayingEveryPosters; + const n = parseInt(raw, 10); + return isNaN(n) ? 0 : Math.max(0, n); +} + +function nowShowingListEveryMinsVal() { + const raw = loadedSettings && loadedSettings.nowShowingListEveryMins; + const n = parseInt(raw, 10); + return isNaN(n) ? 0 : Math.max(0, n); +} + +function nowShowingListEverySlidesVal() { + const mins = nowShowingListEveryMinsVal(); + if (mins <= 0) return 0; + const slideSecs = Math.max( + 1, + parseInt( + (loadedSettings && loadedSettings.slideDuration) || DEFAULT_SETTINGS.slideDuration, + 10 + ) || DEFAULT_SETTINGS.slideDuration + ); + return Math.max(1, Math.round((mins * 60) / slideSecs)); +} + +/** Interleave Now Playing (media server) slides after every N other slides */ +function applyNowPlayingSpacing(cards) { + const every = nowPlayingEveryPosters(); + if (every <= 0 || !isNowShowingEnabled || !Array.isArray(cards) || !cards.length) { + return cards; + } + if (!Array.isArray(nsCards) || nsCards.length === 0) return cards; + const nsSet = new Set(nsCards); + const nsSlides = cards.filter((c) => nsSet.has(c)); + const otherSlides = cards.filter((c) => !nsSet.has(c)); + if (!nsSlides.length || !otherSlides.length) return cards; + + const out = []; + let shown = 0; + let nsIdx = 0; + for (const card of otherSlides) { + out.push(card); + shown++; + if (shown >= every && nsIdx < nsSlides.length) { + out.push(nsSlides[nsIdx++]); + shown = 0; + } + } + while (nsIdx < nsSlides.length) out.push(nsSlides[nsIdx++]); + return out; +} + +/** Interleave TMDB Now Showing list slides after every N other slides */ +function applyNowShowingListSpacing(cards) { + const every = nowShowingListEverySlidesVal(); + if ( + every <= 0 || + !loadedSettings || + loadedSettings.enableNowShowingListInPoster !== "true" || + loadedSettings.nowShowingListOnly === "true" || + !Array.isArray(cards) || + !cards.length + ) { + return cards; + } + if (!Array.isArray(tmdbNowShowingPosterCards) || tmdbNowShowingPosterCards.length === 0) { + return cards; + } + const tmdbSet = new Set(tmdbNowShowingPosterCards); + const tmdbSlides = cards.filter((c) => tmdbSet.has(c)); + const otherSlides = cards.filter((c) => !tmdbSet.has(c)); + if (!tmdbSlides.length || !otherSlides.length) return cards; + + const out = []; + let shown = 0; + let tIdx = 0; + for (const card of otherSlides) { + out.push(card); + shown++; + if (shown >= every && tIdx < tmdbSlides.length) { + out.push(tmdbSlides[tIdx++]); + shown = 0; + } + } + while (tIdx < tmdbSlides.length) out.push(tmdbSlides[tIdx++]); + return out; +} + +function formatAdCardFooterBadgesHtml(ad, currencySymbol) { + const sym = String(currencySymbol != null ? currencySymbol : "$"); + const esc = (v) => + util.escapeHtml(v == null ? "" : String(v)); + const addonTitleOutline = + loadedSettings && + String(loadedSettings.adsTitleOutline || "").toLowerCase() === "true"; + const prices = Array.isArray(ad && ad.prices) ? ad.prices : []; + const addons = Array.isArray(ad && ad.addons) ? ad.addons : []; + const validPrices = prices.filter( + (p) => p && p.amount != null && !isNaN(Number(p.amount)) + ); + const validAddons = addons.filter( + (a) => a && a.amount != null && !isNaN(Number(a.amount)) + ); + const priceParts = []; + validPrices.forEach((p, pi) => { + const base = Number(p.amount); + const t = String(p.title || "").trim(); + const colorClass = "ad-price-pill--c" + String((pi % 6) + 1); + const valueInner = + "" + + esc(sym) + + esc(base.toFixed(2)) + + ""; + priceParts.push( + "" + + (t ? "" + esc(t) + "" : "") + + "" + + valueInner + + "" + ); + }); + const addonParts = []; + validAddons.forEach((a, ai) => { + const base = Number(a.amount); + const t = String(a.title || "").trim(); + const colorClass = + "ad-price-pill--c" + String(((validPrices.length + ai) % 6) + 1); + const valueInner = + "+" + + esc(sym) + + esc(base.toFixed(2)) + + ""; + addonParts.push( + "" + + (t ? "" + esc(t) + "" : "") + + "" + + valueInner + + "" + ); + }); + if (!priceParts.length && !addonParts.length) return ""; + if (addonParts.length) { + const rowClass = + "ad-poster-pricing-row" + + (priceParts.length ? "" : " ad-poster-pricing-row--addons-only"); + return ( + "
" + + (priceParts.length + ? "" + + priceParts.join("") + + "" + : "") + + "
" + + "Addons" + + "" + + addonParts.join("") + + "
" + ); + } + return ( + "" + priceParts.join("") + "" + ); +} + +function buildAdSlideMediaCard(ad, currencySymbol) { + const mediaPath = String((ad && ad.mediaPath) || "").trim(); + if (!mediaPath) return null; + const medCard = new MediaCard(); + medCard.cardType = [...CardTypeEnum.Ad]; + medCard.mediaType = "ad"; + medCard.title = String((ad && ad.title) || "").trim(); + medCard.posterURL = mediaPath; + const bg = String((ad && ad.backgroundMediaPath) || "").trim(); + medCard.posterArtURL = bg; + medCard.DBID = "ad-" + (ad && ad.id != null ? ad.id : "x"); + medCard.theme = ""; + medCard.tagLine = ""; + medCard.adPricingHtml = formatAdCardFooterBadgesHtml(ad, currencySymbol); + medCard.posterAR = ""; + medCard.genre = []; + return medCard; +} + +function rebuildAdSlideCardsFromDb() { + adSlideCards = []; + if (!loadedSettings) return; + let list = []; + try { + list = adsDb.listAds(); + } catch (e) { + return; + } + const sym = currencySymbolForCode( + normalizeNowShowingCurrencyCode( + loadedSettings.adsCurrencyCode || DEFAULT_SETTINGS.adsCurrencyCode + ) + ); + for (const ad of list) { + if (!ad || ad.enabled === false) continue; + const m = buildAdSlideMediaCard(ad, sym); + if (m) adSlideCards.push(m); + } +} + +function adsEveryPostersVal() { + const raw = loadedSettings && loadedSettings.adsEveryPosters; + const n = parseInt(raw, 10); + return isNaN(n) ? 0 : Math.max(0, n); +} + +/** Interleave ad slides after every N non-ad slides when adsEveryPosters > 0. */ +function applyAdsSpacing(cards) { + const every = adsEveryPostersVal(); + if ( + !loadedSettings || + loadedSettings.enableAds !== "true" || + every <= 0 || + !Array.isArray(cards) || + !cards.length + ) { + return cards; + } + if (!Array.isArray(adSlideCards) || adSlideCards.length === 0) { + return cards; + } + const adSet = new Set(adSlideCards); + const adSlides = cards.filter((c) => adSet.has(c)); + const otherSlides = cards.filter((c) => !adSet.has(c)); + if (!adSlides.length || !otherSlides.length) return cards; + + const out = []; + let shown = 0; + let adIdx = 0; + for (const card of otherSlides) { + out.push(card); + shown++; + if (shown >= every && adIdx < adSlides.length) { + out.push(adSlides[adIdx++]); + shown = 0; + } + } + while (adIdx < adSlides.length) out.push(adSlides[adIdx++]); + return out; +} + +/** + * /now-showing: require both backdrop and logo after TMDB hydrate + cache pass. + * Library fillers still need both URLs (no TMDB id → not hydrated here). + */ +function nowShowingRowHasBannerAndLogo(m) { + const b = String(m.bannerUrl || "").trim(); + const l = String(m.logoUrl || "").trim(); + return b.length > 0 && l.length > 0; +} + +/** Same basenames as buildTmdbNowShowingListCards — one file per TMDB id (or sqlite id fallback). */ +function nowShowingImageCacheFileNames(m) { + const tid = + m.tmdbId != null && m.tmdbId !== "" + ? String(m.tmdbId) + : m.id != null && m.id !== "" + ? String(m.id) + : "x"; + return { + banner: `nowshowinglist-${tid}.jpg`, + logo: `nowshowinglist-${tid}-logo.png`, + }; +} + +function nowShowingAssetLooksCachedOrLocal(u) { + return /(^|\/|\\)imagecache\//i.test(String(u || "")); +} + +/** + * Poster deck stores TMDB URLs in now-showing.db (metadata only — not image bytes). + * For /now-showing/data, download remote banner/logo into config/cache/imagecache and + * return same-origin URLs so the browser loads from disk cache like other posters. + */ +async function ensureNowShowingMoviesImageCacheForResponse(movies, baseUrl) { + if (!Array.isArray(movies) || !movies.length) return; + const root = String(baseUrl || ""); + const ic = root + "/imagecache/"; + for (const m of movies) { + const { banner: bFn, logo: lFn } = nowShowingImageCacheFileNames(m); + const bSrc = String(m.bannerUrl || "").trim(); + const lSrc = String(m.logoUrl || "").trim(); + if (bSrc && !nowShowingAssetLooksCachedOrLocal(bSrc) && /^https?:\/\//i.test(bSrc)) { + try { + if (await core.CacheImage(bSrc, bFn)) m.bannerUrl = ic + bFn; + } catch (e) { + /* keep remote URL */ + } + } else if (bSrc.startsWith("/imagecache/")) { + m.bannerUrl = root + bSrc; + } + if (lSrc && !nowShowingAssetLooksCachedOrLocal(lSrc) && /^https?:\/\//i.test(lSrc)) { + try { + if (await core.CacheImage(lSrc, lFn)) m.logoUrl = ic + lFn; + } catch (e) { + /* keep remote URL */ + } + } else if (lSrc.startsWith("/imagecache/")) { + m.logoUrl = root + lSrc; + } + } +} + +async function buildTmdbNowShowingListCards() { + const out = []; + if (!loadedSettings || loadedSettings.enableNowShowingListInPoster !== "true") { + return out; + } + CardTypeEnum.NowShowingList[1] = String(loadedSettings.nowShowingListBanner || ""); + let movies = []; + try { + const nFuture = nowShowingShowtimeDisplayCount(); + const nsSlots = Math.min(10, nFuture + 2); + movies = + nowShowingDb.listMoviesForScreen({ showtimeSlotCount: nsSlots }) || []; + await nowShowingDb.backfillRemoteAssetsToLocalPaths(movies); + await nowShowingDb.hydrateMissingBannerLogoFromTmdb( + movies, + loadedSettings && loadedSettings.tmdbApiKey + ); + await nowShowingDb.backfillRemoteAssetsToLocalPaths(movies); + } catch (e) { + return out; + } + const hasArt = loadedSettings.hasArt === "true"; + for (let i = 0; i < movies.length; i++) { + const r = movies[i]; + if (!nowShowingRowHasBannerAndLogo(r)) continue; + const medCard = new MediaCard(); + medCard.cardType = [...CardTypeEnum.NowShowingList]; + medCard.title = r.title || ""; + medCard.year = r.year || ""; + medCard.mediaType = "movie"; + medCard.rating = r.rating || ""; + medCard.contentRating = r.contentRating || ""; + medCard.summary = r.overview || ""; + const nSt = Math.min(8, nowShowingShowtimeDisplayCount() + 2); + const timesArr = Array.isArray(r.showtimes) + ? r.showtimes.filter(Boolean).slice(0, nSt) + : []; + const times = timesArr.join(" · "); + medCard.tagLine = times || medCard.title; + const genreStr = r.genres || ""; + medCard.genre = genreStr + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + medCard.studio = ""; + medCard.theme = ""; + const tid = r.tmdbId != null ? String(r.tmdbId) : String(r.id); + const fileBanner = `nowshowinglist-${tid}.jpg`; + const bannerUrl = r.bannerUrl || ""; + if (bannerUrl) { + try { + await core.CacheImage(bannerUrl, fileBanner); + medCard.posterURL = "/imagecache/" + fileBanner; + } catch (e) { + medCard.posterURL = "/images/no-poster-available.png"; + } + } else { + medCard.posterURL = "/images/no-poster-available.png"; + } + if (hasArt && r.logoUrl) { + const fileLogo = `nowshowinglist-${tid}-logo.png`; + try { + await core.CacheImage(r.logoUrl, fileLogo); + medCard.posterArtURL = "/imagecache/" + fileLogo; + } catch (e) { + medCard.posterArtURL = ""; + } + } + medCard.posterAR = 1.47; + medCard.DBID = "nowshowinglist-" + (r.id != null ? r.id : tid); + out.push(medCard); + } + return out; +} + /** Library-style slides: cached poster DB first when enabled; live on-demand only as backup. */ function buildLibrarySlideDeckFromPosterCache() { if (!preferCachedPostersEnabled()) return odCards; @@ -618,20 +1404,40 @@ async function loadNowScreening() { // stop the clock clearInterval(nowScreeningClock); + rebuildAdSlideCardsFromDb(); + + const wantListOnlyHomePoster = + loadedSettings && + loadedSettings.nowShowingListOnly === "true" && + loadedSettings.enableNowShowingListInPoster === "true"; + + let skipMediaServerNowPlayingFetch = false; + if (wantListOnlyHomePoster) { + tmdbNowShowingPosterCards = await buildTmdbNowShowingListCards(); + if (tmdbNowShowingPosterCards.length > 0) { + skipMediaServerNowPlayingFetch = true; + nsCards = []; + } + } else { + tmdbNowShowingPosterCards = []; + } // stop timers dont run if disabled - if (!isMediaServerEnabled) { + if (!isMediaServerEnabled && !skipMediaServerNowPlayingFetch) { nsCards = []; return nsCards; } - const Pms = getMediaServerClass(loadedSettings.mediaServerType); - let ms = new Pms({ - plexHTTPS: loadedSettings.plexHTTPS, - plexIP: loadedSettings.plexIP, - plexPort: loadedSettings.plexPort, - plexToken: loadedSettings.plexToken, - }); + let ms = null; + if (!skipMediaServerNowPlayingFetch) { + const Pms = getMediaServerClass(loadedSettings.mediaServerType); + ms = new Pms({ + plexHTTPS: loadedSettings.plexHTTPS, + plexIP: loadedSettings.plexIP, + plexPort: loadedSettings.plexPort, + plexToken: loadedSettings.plexToken, + }); + } let excludeLibraries; if(loadedSettings.excludeLibs !== undefined && loadedSettings.excludeLibs !== ""){ @@ -646,18 +1452,19 @@ async function loadNowScreening() { let pollInterval = nsCheckSeconds; // call now screening method - try { - nsCards = await ms.GetNowScreening( - loadedSettings.playThemes, - loadedSettings.genericThemes, - loadedSettings.hasArt, - loadedSettings.filterRemote, - loadedSettings.filterLocal, - loadedSettings.filterDevices, - loadedSettings.filterUsers, - loadedSettings.hideUser, - excludeLibraries - ); + if (!skipMediaServerNowPlayingFetch) { + try { + nsCards = await ms.GetNowScreening( + loadedSettings.playThemes, + loadedSettings.genericThemes, + loadedSettings.hasArt, + loadedSettings.filterRemote, + loadedSettings.filterLocal, + loadedSettings.filterDevices, + loadedSettings.filterUsers, + loadedSettings.hideUser, + excludeLibraries + ); // Send to Awtrix, if enabled if(isAwtrixEnabled){ var awt = new awtrix(); @@ -792,27 +1599,51 @@ async function loadNowScreening() { } } - // restore defaults if media server is available again after an error - if (isMediaServerUnavailable) { - console.log("✅ Media server connection restored - default poll timers restored"); - isMediaServerUnavailable = false; - } - } catch (err) { - let now = new Date(); - console.log(now.toLocaleString() + " *Now Showing. - Get full data: " + dumpError(err)); - pollInterval = nsCheckSeconds + 60000; - console.log( - "✘✘ WARNING ✘✘ - Next Now Screening query will be delayed by 1 minute:", - "(" + pollInterval / 1000 + " seconds)" - ); - isMediaServerUnavailable = true; + // restore defaults if media server is available again after an error + if (isMediaServerUnavailable) { + console.log("✅ Media server connection restored - default poll timers restored"); + isMediaServerUnavailable = false; + } + } catch (err) { + let now = new Date(); + console.log(now.toLocaleString() + " *Now Playing. - Get full data: " + dumpError(err)); + pollInterval = nsCheckSeconds + 60000; + console.log( + "✘✘ WARNING ✘✘ - Next Now Screening query will be delayed by 1 minute:", + "(" + pollInterval / 1000 + " seconds)" + ); + isMediaServerUnavailable = true; + } } const librarySlideCards = buildLibrarySlideDeckFromPosterCache(); + const nowShowingListOnlyOn = + wantListOnlyHomePoster && tmdbNowShowingPosterCards.length > 0; + const adsOnlyOn = + loadedSettings && + loadedSettings.enableAds === "true" && + loadedSettings.adsOnly === "true" && + Array.isArray(adSlideCards) && + adSlideCards.length > 0; + // Concatenate cards for all objects load now showing and on-demand cards, else just on-demand (if present) // TODO - move this into its own function! let mCards = []; + + if (adsOnlyOn) { + mCards = adSlideCards.slice(); + if (loadedSettings.shuffleSlides !== undefined && loadedSettings.shuffleSlides == "true") { + mCards = mCards.sort(() => Math.random() - 0.5); + } + globalPage.cards = mCards; + } else if (nowShowingListOnlyOn) { + mCards = tmdbNowShowingPosterCards.slice(); + if (loadedSettings.shuffleSlides !== undefined && loadedSettings.shuffleSlides == "true") { + mCards = mCards.sort(() => Math.random() - 0.5); + } + globalPage.cards = mCards; + } else { // is now screening false, then clear array // If sessions were detected, keep them even when the enable flag is in an // unexpected format (e.g. boolean/on/1) to avoid blank screens. @@ -820,7 +1651,7 @@ async function loadNowScreening() { let now = new Date(); console.log( now.toLocaleString() + - " *Now Scrn. - Detected active sessions while Now Showing appears disabled; keeping cards for display" + " *Now Scrn. - Detected active sessions while Now Playing appears disabled; keeping cards for display" ); } @@ -965,6 +1796,25 @@ async function loadNowScreening() { // globalPage.cards = mCards; } + // TMDB Now Showing list is dedicated to /now-showing and is intentionally + // excluded from the homepage poster deck. + } + + if (!nowShowingListOnlyOn && !adsOnlyOn) { + if ( + loadedSettings && + loadedSettings.enableAds === "true" && + Array.isArray(adSlideCards) && + adSlideCards.length + ) { + globalPage.cards = globalPage.cards.concat(adSlideCards); + } + globalPage.cards = applyNowPlayingSpacing(globalPage.cards); + globalPage.cards = applyNowShowingListSpacing(globalPage.cards); + globalPage.cards = applyCustomPictureSpacing(globalPage.cards); + globalPage.cards = applyAdsSpacing(globalPage.cards); + } + if (isMediaServerEnabled) { posterMetadata.registerFromMediaServerCards( nsCards, @@ -1069,11 +1919,126 @@ async function loadNowScreening() { return nsCards; } +/** + * On-demand slide pool: when "Prefer cached poster library" is on (default), reads from the poster metadata DB + * only (filled by sync) — no live GetOnDemand. Otherwise pulls from the media server and optionally rewrites URLs + * from the poster DB when that same setting is on (legacy path is server-only when prefer-cache is off). + * @param {number} [numberOnDemandOverride] — if set, used instead of settings.numberOnDemand so fill can work when OD count is 0. + */ +async function fetchOnDemandCardsFromServer(numberOnDemandOverride) { + if (posterSyncProgressState.status === "running") { + // Avoid concurrent Jellyfin library scans while a full poster sync is in progress. + return Array.isArray(odCards) ? odCards : []; + } + const count = + numberOnDemandOverride != null && numberOnDemandOverride !== "" + ? numberOnDemandOverride + : loadedSettings.numberOnDemand; + + if (preferCachedPostersEnabled()) { + const kind = loadedSettings + ? getMediaServerKind(loadedSettings.mediaServerType) + : ""; + const nRaw = + typeof count === "number" ? count : parseInt(String(count), 10); + const n = !isNaN(nRaw) && nRaw > 0 ? nRaw : primaryCachedPosterSlideCount(); + return apply3dLibraryFlagToCards( + posterMetadata.buildFallbackMediaCards(n, kind) + ); + } + + const PmsOd = getMediaServerClass(loadedSettings.mediaServerType); + const ms = new PmsOd({ + plexHTTPS: loadedSettings.plexHTTPS, + plexIP: loadedSettings.plexIP, + plexPort: loadedSettings.plexPort, + plexToken: loadedSettings.plexToken, + }); + const cards = await ms.GetOnDemand( + loadedSettings.onDemandLibraries, + count, + loadedSettings.playThemes, + loadedSettings.genericThemes, + loadedSettings.hasArt, + loadedSettings.genres, + loadedSettings.recentlyAddedDays, + loadedSettings.contentRatings, + { + imagePull: buildImagePullOptions(), + tmdbApiKey: loadedSettings.tmdbApiKey || "", + } + ); + return apply3dLibraryFlagToCards(cards); +} + +/** + * Resolves the MediaCard pool used for "Fill with random library titles" on /now-showing. + * Refetches from the media server when odCards is empty or has no movie/show/episode rows + * (e.g. on-demand pool is only music, or numberOnDemand is tiny and only albums were picked). + * @returns {Promise} + */ +async function ensureOdCardsForNowShowingFill() { + const fillOn = + loadedSettings && + loadedSettings.nowShowingFillFromServer === "true" && + (Number(loadedSettings.nowShowingFillLibraryMax) || 0) > 0; + if (!fillOn) return []; + if (!isMediaServerEnabled && !preferCachedPostersEnabled()) return []; + if (posterSyncProgressState.status === "running") { + return Array.isArray(odCards) ? odCards : []; + } + + const nOdRaw = parseInt(loadedSettings.numberOnDemand, 10); + const nOd = isNaN(nOdRaw) ? 0 : Math.max(0, nOdRaw); + const nFill = Math.min( + 48, + parseInt(loadedSettings.nowShowingFillLibraryMax, 10) || 0 + ); + const pullCount = Math.max(nOd, nFill, 24); + + let pool = Array.isArray(odCards) ? odCards : []; + if (pool.length > 0 && odCardsHaveNowShowingFillCandidates(pool)) { + return pool; + } + + try { + const fresh = await fetchOnDemandCardsFromServer(pullCount); + if (!Array.isArray(fresh) || !fresh.length) { + return pool; + } + const useFresh = + !pool.length || !odCardsHaveNowShowingFillCandidates(pool); + if (useFresh) { + if (!Array.isArray(odCards) || !odCards.length) { + odCards = fresh; + } + return fresh; + } + return pool; + } catch (e) { + const d = new Date(); + console.log( + d.toLocaleString() + + " *Now-showing library fill: could not build filler pool — " + + (e && e.message ? e.message : e) + ); + return pool; + } +} + /** * @desc Wrapper function to call on-demand method * @returns {Promise} mediaCards array - results of on-demand search */ async function loadOnDemand() { + if (posterSyncProgressState.status === "running") { + const odCheckMinutes = Number(loadedSettings.onDemandRefresh); + const nextMs = isNaN(odCheckMinutes) + ? 30 * 60 * 1000 + : Math.max(10, odCheckMinutes) * 60000; + onDemandClock = setInterval(loadOnDemand, nextMs); + return odCards; + } // stop the clock clearInterval(onDemandClock); @@ -1082,9 +2047,9 @@ async function loadOnDemand() { return odCards; } - // changing timings if media server unavailable or NS not working + // Changing timings if media server unavailable (live OD only; cache-backed OD ignores this). let odCheckMinutes = loadedSettings.onDemandRefresh; - if (isMediaServerUnavailable) { + if (!preferCachedPostersEnabled() && isMediaServerUnavailable) { odCheckMinutes = 1; console.log("✘✘ WARNING ✘✘ - Next on-demand query will run in 1 minute."); // restart interval timer @@ -1093,34 +2058,8 @@ async function loadOnDemand() { return odCards; } - const PmsOd = getMediaServerClass(loadedSettings.mediaServerType); - let ms = new PmsOd({ - plexHTTPS: loadedSettings.plexHTTPS, - plexIP: loadedSettings.plexIP, - plexPort: loadedSettings.plexPort, - plexToken: loadedSettings.plexToken, - }); - try { - odCards = await ms.GetOnDemand( - loadedSettings.onDemandLibraries, - loadedSettings.numberOnDemand, - loadedSettings.playThemes, - loadedSettings.genericThemes, - loadedSettings.hasArt, - loadedSettings.genres, - loadedSettings.recentlyAddedDays, - loadedSettings.contentRatings, - { - imagePull: buildImagePullOptions(), - } - ); - if (preferCachedPostersEnabled()) { - posterMetadata.applyCachedPostersToMediaCards( - odCards, - getMediaServerKind(loadedSettings.mediaServerType) - ); - } + odCards = await fetchOnDemandCardsFromServer(); } catch (err) { let d = new Date(); console.log(d.toLocaleString() + " *On-demand - Get full data: " + err); @@ -1262,17 +2201,21 @@ async function checkEnabled() { isMediaServerEnabled = false; } - // check on-demand - if (loadedSettings.onDemandLibraries !== undefined && - isMediaServerEnabled && - loadedSettings.numberOnDemand !== undefined && - //loadedSettings.numberOnDemand !== 0 && - loadedSettings.enableOD !== 'false' - ) { - isOnDemandEnabled = true; - } - else{ + // On-demand: live library fetches need a configured media server; cache-backed slides can run + // offline when "Prefer cached poster library" is on and the poster DB has rows (filled by sync). + const odNotDisabled = loadedSettings.enableOD !== "false"; + const libsConfigured = + loadedSettings.onDemandLibraries !== undefined && + String(loadedSettings.onDemandLibraries || "").trim().length > 0; + const numConfigured = loadedSettings.numberOnDemand !== undefined; + if (!odNotDisabled || !numConfigured) { isOnDemandEnabled = false; + } else if (preferCachedPostersEnabled()) { + isOnDemandEnabled = + cachedPosterDbHasRows() || + (isMediaServerEnabled && libsConfigured); + } else { + isOnDemandEnabled = isMediaServerEnabled && libsConfigured; } // check Sonarr @@ -1378,7 +2321,7 @@ async function checkEnabled() { `): ` + isMediaServerEnabled + ` - Now Showing: ` + + Now Playing (media server): ` + isNowShowingEnabled + ` Awtrix: ` + @@ -1492,6 +2435,9 @@ function matchConfiguredLibraryName(requested, configuredNames) { return null; } +/** Set true when POST /settings/sync/abort runs during a running sync (cooperative cancel between items). */ +let posterSyncAbortRequested = false; + /** Live state for full-library poster sync (UI polls GET /settings/sync/progress). */ const posterSyncProgressState = { status: "idle", @@ -1502,6 +2448,8 @@ const posterSyncProgressState = { libraries: [], syncScope: "all", syncSingleLibrary: "", + metadataOnlySync: false, + runId: "", serverKind: "", error: "", startedAt: null, @@ -1523,6 +2471,8 @@ function schedulePosterSyncIdleReset(delayMs) { posterSyncProgressState.libraries = []; posterSyncProgressState.syncScope = "all"; posterSyncProgressState.syncSingleLibrary = ""; + posterSyncProgressState.metadataOnlySync = false; + posterSyncProgressState.runId = ""; posterSyncProgressState.error = ""; posterSyncProgressState.serverKind = ""; posterSyncProgressState.startedAt = null; @@ -1537,6 +2487,12 @@ function schedulePosterSyncIdleReset(delayMs) { * @param {{ singleLibrary?: string }} [options] If singleLibrary is set, only that library (must match a configured on-demand name, case-insensitive) is synced. */ async function syncFullPosterLibraryFromMediaServer(options) { + const syncDebugEnabled = + String(process.env.POSTERR_SYNC_DEBUG || "").trim().toLowerCase() === "true"; + const syncDebugLog = (msg) => { + if (!syncDebugEnabled) return; + console.log(new Date().toLocaleString() + " [poster sync debug] " + msg); + }; if (posterSyncProgressState.status === "running") { return; } @@ -1554,6 +2510,8 @@ async function syncFullPosterLibraryFromMediaServer(options) { options && options.singleLibrary != null ? String(options.singleLibrary).trim() : ""; + const metadataOnly = + options && options.metadataOnlySync === true; let libraryCsv = String(loadedSettings.onDemandLibraries).trim(); if (singleRaw) { const resolved = matchConfiguredLibraryName(singleRaw, configuredNames); @@ -1570,6 +2528,7 @@ async function syncFullPosterLibraryFromMediaServer(options) { } if (!ms || typeof ms.GetOnDemand !== "function") return; + posterSyncAbortRequested = false; posterSyncProgressState.status = "running"; posterSyncProgressState.phase = "starting"; posterSyncProgressState.label = singleRaw @@ -1580,6 +2539,8 @@ async function syncFullPosterLibraryFromMediaServer(options) { posterSyncProgressState.libraries = []; posterSyncProgressState.syncScope = singleRaw ? "single" : "all"; posterSyncProgressState.syncSingleLibrary = singleRaw ? libraryCsv : ""; + posterSyncProgressState.metadataOnlySync = metadataOnly; + posterSyncProgressState.runId = Math.random().toString(36).slice(2, 8); posterSyncProgressState.error = ""; posterSyncProgressState.startedAt = Date.now(); posterSyncProgressState.finishedAt = null; @@ -1588,32 +2549,144 @@ async function syncFullPosterLibraryFromMediaServer(options) { ); const syncStarted = Date.now(); + const runIdTag = " [run " + posterSyncProgressState.runId + "]"; + syncDebugLog( + "config: metadataOnly=" + + metadataOnly + + ", serverKind=" + + posterSyncProgressState.serverKind + + ', libraryCsv="' + + libraryCsv + + '"' + ); console.log( new Date().toLocaleString() + " [poster sync] start — " + + runIdTag + + " " + posterSyncProgressState.serverKind + ' — libraries: "' + libraryCsv + '"' + - (singleRaw ? " (single library)" : "") + (singleRaw ? " (single library)" : "") + + (metadataOnly ? " [metadata-only]" : "") ); try { - const cards = await ms.GetOnDemand( - libraryCsv, - loadedSettings.numberOnDemand, - loadedSettings.playThemes, - loadedSettings.genericThemes, - loadedSettings.hasArt, - loadedSettings.genres, - loadedSettings.recentlyAddedDays, - loadedSettings.contentRatings, - { - posterSyncFullLibrary: true, - syncProgress: posterSyncProgressState, - imagePull: buildImagePullOptions(), - } + let streamRegisterCalls = 0; + let streamRegisterWritten = 0; + let streamRegisterTotalCards = 0; + let streamRowCountBefore = null; + let streamRowCountAfter = null; + const selectedLibraries = String(libraryCsv) + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + const syncTargets = selectedLibraries.length > 0 ? selectedLibraries : [libraryCsv]; + syncDebugLog( + "targets: " + + syncTargets.length + + " library/libraries -> " + + syncTargets.join(" | ") ); + let cards = []; + for (let i = 0; i < syncTargets.length; i++) { + const oneLibrary = syncTargets[i]; + const chunkStarted = Date.now(); + syncDebugLog( + "chunk " + (i + 1) + "/" + syncTargets.length + ' start: "' + oneLibrary + '"' + ); + if (posterSyncProgressState.status === "running" && syncTargets.length > 1) { + posterSyncProgressState.label = + 'Scanning library ' + (i + 1) + "/" + syncTargets.length + ': "' + oneLibrary + '"'; + } + const libCards = await ms.GetOnDemand( + oneLibrary, + loadedSettings.numberOnDemand, + loadedSettings.playThemes, + loadedSettings.genericThemes, + loadedSettings.hasArt, + loadedSettings.genres, + loadedSettings.recentlyAddedDays, + loadedSettings.contentRatings, + { + posterSyncFullLibrary: true, + metadataOnlySync: metadataOnly, + syncProgress: posterSyncProgressState, + imagePull: metadataOnly + ? { + background: false, + logo: false, + videoPoster: false, + albumPoster: false, + bookPoster: false, + castPortrait: false, + directorPortrait: false, + authorPortrait: false, + artistPortrait: false, + } + : buildPosterSyncImagePullOptions(), + tmdbApiKey: loadedSettings.tmdbApiKey || "", + retryLibraryKeysFromLastSync: posterSyncRetry.loadRetryKeys( + posterSyncProgressState.serverKind + ), + posterSyncServerKind: posterSyncProgressState.serverKind, + posterSyncAbortCheck: () => posterSyncAbortRequested, + posterSyncStreamChunks: + String(process.env.POSTERR_POSTER_SYNC_STREAM || "") + .trim() + .toLowerCase() !== "false", + onPosterSyncBatch: async (batchCards, batchMeta) => { + if (!Array.isArray(batchCards) || batchCards.length === 0) return; + const rs = posterMetadata.registerFromMediaServerCards( + [], + batchCards, + posterSyncProgressState.serverKind + ); + streamRegisterCalls += 1; + streamRegisterWritten += Number((rs && rs.written) || 0); + streamRegisterTotalCards += Number((rs && rs.totalCards) || 0); + if (streamRowCountBefore === null && rs) { + streamRowCountBefore = Number(rs.rowCountBefore || 0); + } + if (rs) { + streamRowCountAfter = Number(rs.rowCountAfter || 0); + } + if (posterSyncProgressState.status === "running") { + if (metadataOnly) { + posterSyncProgressState.phase = "caching"; + posterSyncProgressState.label = + "Syncing metadata… batch " + + Number((batchMeta && batchMeta.batchIndex) || streamRegisterCalls); + } else { + posterSyncProgressState.phase = "registering"; + posterSyncProgressState.label = + "Saving poster metadata… batch " + + Number((batchMeta && batchMeta.batchIndex) || streamRegisterCalls); + } + } + }, + } + ); + if (Array.isArray(libCards) && libCards.length > 0) { + cards = cards.concat(libCards); + } + syncDebugLog( + "chunk " + + (i + 1) + + "/" + + syncTargets.length + + ' done: "' + + oneLibrary + + '" cards=' + + (Array.isArray(libCards) ? libCards.length : 0) + + ", elapsed=" + + Math.max(1, Math.round((Date.now() - chunkStarted) / 1000)) + + "s" + ); + } + const abortedAfterWork = posterSyncAbortRequested; + posterSyncAbortRequested = false; posterSyncProgressState.phase = "registering"; posterSyncProgressState.label = "Saving poster metadata…"; posterSyncProgressState.processed = cards.length; @@ -1621,34 +2694,111 @@ async function syncFullPosterLibraryFromMediaServer(options) { posterSyncProgressState.total, cards.length ); - console.log( - new Date().toLocaleString() + - " [poster sync] caching done — " + - cards.length + - " title(s) in " + - Math.max(1, Math.round((Date.now() - syncStarted) / 1000)) + - "s — saving metadata DB…" - ); - posterMetadata.registerFromMediaServerCards( - [], - cards, - posterSyncProgressState.serverKind + const elapsedSec = Math.max(1, Math.round((Date.now() - syncStarted) / 1000)); + let regStats; + if (streamRegisterCalls > 0) { + console.log( + new Date().toLocaleString() + + " [poster sync] caching done — " + + runIdTag + + " " + + cards.length + + " title(s) in " + + elapsedSec + + "s — metadata DB already updated in " + + streamRegisterCalls + + " batch(es)" + ); + regStats = { + totalCards: streamRegisterTotalCards, + written: streamRegisterWritten, + rowCountBefore: + streamRowCountBefore == null ? posterMetadata.countRows() : streamRowCountBefore, + rowCountAfter: + streamRowCountAfter == null ? posterMetadata.countRows() : streamRowCountAfter, + }; + } else { + console.log( + new Date().toLocaleString() + + " [poster sync] caching done — " + + runIdTag + + " " + + cards.length + + " title(s) in " + + elapsedSec + + "s — saving metadata DB…" + ); + regStats = posterMetadata.registerFromMediaServerCards( + [], + cards, + posterSyncProgressState.serverKind + ); + } + try { + const rs = regStats || {}; + console.log( + new Date().toLocaleString() + + " [poster sync debug]" + + runIdTag + + " cards=" + + Number(rs.totalCards || 0) + + " posterURL=" + + Number(rs.posterUrlPresent || 0) + + " normalized=" + + Number(rs.normalizedCacheFile || 0) + + " fileOk=" + + Number(rs.fileOk || 0) + + " titleOk=" + + Number(rs.titlePresent || 0) + + " written=" + + Number(rs.written || 0) + + " rows " + + Number(rs.rowCountBefore || 0) + + "->" + + Number(rs.rowCountAfter || 0) + ); + } catch (e) { + /* ignore debug log errors */ + } + posterSyncRetry.saveRetryKeys( + posterSyncProgressState.serverKind, + posterSyncRetry.collectRetryKeysFromCards( + cards, + posterSyncProgressState.serverKind, + buildImagePullOptions() + ) ); posterSyncProgressState.status = "done"; posterSyncProgressState.phase = "complete"; - posterSyncProgressState.label = - "Complete — " + cards.length + " title(s) cached"; + posterSyncProgressState.label = abortedAfterWork + ? "Stopped — partial sync (" + cards.length + " title(s))" + : "Complete — " + cards.length + " title(s) " + (metadataOnly ? "metadata synced" : "cached"); posterSyncProgressState.finishedAt = Date.now(); console.log( new Date().toLocaleString() + - " [poster sync] complete — " + + " [poster sync] " + + runIdTag + + " " + + (abortedAfterWork ? "stopped (aborted) — " : "complete — ") + cards.length + " title(s) registered (" + Math.round((Date.now() - syncStarted) / 1000) + "s total)" ); + syncDebugLog( + "summary: cards=" + + cards.length + + ", streamedBatches=" + + streamRegisterCalls + + ", streamedWritten=" + + streamRegisterWritten + + ", elapsed=" + + Math.round((Date.now() - syncStarted) / 1000) + + "s" + ); schedulePosterSyncIdleReset(12000); } catch (e) { + posterSyncAbortRequested = false; posterSyncProgressState.status = "error"; posterSyncProgressState.phase = "error"; posterSyncProgressState.error = e && e.message ? e.message : String(e); @@ -1658,8 +2808,13 @@ async function syncFullPosterLibraryFromMediaServer(options) { console.log( d.toLocaleString() + " [poster sync] failed — " + + runIdTag + + " " + posterSyncProgressState.error ); + syncDebugLog( + "error: " + (e && e.stack ? String(e.stack).slice(0, 1200) : String(e)) + ); schedulePosterSyncIdleReset(20000); } } @@ -1792,6 +2947,7 @@ async function startup(clearCache) { clearInterval(posterMetadataRefreshClock); picCards = []; + adSlideCards = []; odCards = []; nsCards = []; csCards = []; @@ -1811,6 +2967,9 @@ async function startup(clearCache) { // load settings object loadedSettings = await Promise.resolve(await loadSettings()); await posterMetadata.initPosterMetadataDb(); + await nowShowingDb.initNowShowingDb(); + await adsDb.initAdsDb(); + await migrateLegacyGlobalAdsPriceAddOnOnce(); startHttpServerOnce(); if (loadedSettings == 'undefined') { console.load('settings not loaded!!'); @@ -1839,7 +2998,9 @@ async function startup(clearCache) { CardTypeEnum.Picture[1] = loadedSettings.picture !== undefined ? loadedSettings.picture : ""; CardTypeEnum.EBook[1] = loadedSettings.ebook !== undefined ? loadedSettings.ebook : ""; CardTypeEnum.Trivia[1] = loadedSettings.trivia !== undefined ? loadedSettings.trivia : ""; - CardTypeEnum.WebURL[1] = loadedSettings.links !== undefined ? loadedSettings.links : ""; + CardTypeEnum.WebURL[1] = loadedSettings.links !== undefined ? loadedSettings.links : ""; + CardTypeEnum.NowShowingList[1] = + loadedSettings.nowShowingListBanner !== undefined ? loadedSettings.nowShowingListBanner : ""; // initial load of card providers if (isSonarrEnabled) await loadSonarrComingSoon(); @@ -1851,6 +3012,9 @@ async function startup(clearCache) { if (isTriviaEnabled) await loadTrivia(); if (isLinksEnabled) await loadLinks(); + // Build homepage deck before optional Awtrix (network) work so /getcards can serve cached posters sooner. + await loadNowScreening(); + // Awtrix initialize - if enabled if(isAwtrixEnabled){ var awt = new awtrix(); @@ -1884,8 +3048,6 @@ async function startup(clearCache) { } } - await loadNowScreening(); - syncFullPosterLibraryFromMediaServer().catch(() => {}); // let now = new Date(); @@ -2016,6 +3178,7 @@ async function saveReset(formObject) { csrCards = []; csrCards = []; picCards = []; + adSlideCards = []; cslCards = []; csbCards = []; trivCards = []; @@ -2073,6 +3236,9 @@ app.use( if (BASEURL == "") { // console.log(__dirname); // console.log(process.cwd()); + app.use("/custom/ads", express.static(ADS_MEDIA_ROOT)); + app.use("/custom/ads-view", express.static(ADS_VIEW_BG_ROOT)); + app.use("/custom/pictures", express.static(CUSTOM_PICTURES_ROOT)); app.use(express.static(path.join(__dirname, "public"))); app.use(express.static(CACHE_ROOT)); app.use(express.static(path.join(process.cwd(), "public"))); @@ -2082,6 +3248,9 @@ if (BASEURL == "") { // app.use("/js",express.static(path.join(__dirname, "node_modules/jquery/dist"))); } else { + app.use(BASEURL + "/custom/ads", express.static(ADS_MEDIA_ROOT)); + app.use(BASEURL + "/custom/ads-view", express.static(ADS_VIEW_BG_ROOT)); + app.use(BASEURL + "/custom/pictures", express.static(CUSTOM_PICTURES_ROOT)); app.use(BASEURL, express.static(__dirname + '/public')); app.use(BASEURL, express.static(CACHE_ROOT)); app.use(BASEURL, express.static(process.cwd() + '/public')); @@ -2092,7 +3261,28 @@ else { // set routes app.get(BASEURL + "/", (req, res) => { // try { - res.render("index", { globals: globalPage, hasConfig: setng.GetChanged(), baseUrl: BASEURL, custBrand: globalPage.custBrand, hasArt: globalPage.hasArt, quizTime: globalPage.quizTime, rotate: globalPage.rotate }); // index refers to index.ejs + res.render("index", { + globals: globalPage, + hasConfig: setng.GetChanged(), + baseUrl: BASEURL, + custBrand: globalPage.custBrand, + hasArt: globalPage.hasArt, + quizTime: globalPage.quizTime, + rotate: globalPage.rotate, + nowShowingPageCycleEnabled: + loadedSettings && loadedSettings.enableNowShowingPageCycle === "true" + ? "true" + : "false", + nowShowingPageCycleEveryMins: + loadedSettings && loadedSettings.nowShowingPageCycleEveryMins !== undefined + ? loadedSettings.nowShowingPageCycleEveryMins + : DEFAULT_SETTINGS.nowShowingPageCycleEveryMins, + nowShowingPageCycleStayMins: + loadedSettings && loadedSettings.nowShowingPageCycleStayMins !== undefined + ? loadedSettings.nowShowingPageCycleStayMins + : DEFAULT_SETTINGS.nowShowingPageCycleStayMins, + ...newFeaturesBannerViewData(), + }); // index refers to index.ejs // } // catch (ex) { // console.log('res.render:' + __dirname); @@ -2104,124 +3294,810 @@ app.get(BASEURL + "/getcards", (req, res) => { res.send({ globalPage: globalPage, baseUrl: BASEURL }); // get generated cards }); -// Used by the web client to check connection status to Posterr, and also to determine if there was a cold start that was missed - -app.get(BASEURL + "/conncheck", (req, res) => { - res.send({ "status": cold_start_time, "sleep": sleep }); -}); - - -app.get(BASEURL + "/debug", (req, res) => { - res.render("debug", { settings: loadedSettings, version: pjson.version, baseUrl: BASEURL }); -}); - -app.get(BASEURL + "/debug/ping", (req, res) => { - console.log(' '); - console.log('** PING TESTS **'); - console.log('-------------------------------------------------------'); - let test = new health(loadedSettings); - test.TestPing(); - res.render("debug", { settings: loadedSettings, version: pjson.version, baseUrl: BASEURL }); -}); - -async function debugMediaServerNs(req, res) { - const label = getMediaServerShortLabel( - loadedSettings && loadedSettings.mediaServerType - ); - console.log(" "); - console.log("** " + label.toUpperCase() + " 'NOW SCREENING' CHECK **"); - console.log("-------------------------------------------------------"); - const test = new health(loadedSettings); - await test.PlexNSCheck(); - res.render("debug", { - settings: loadedSettings, - version: pjson.version, +app.get(BASEURL + "/now-showing", (req, res) => { + res.render("now-showing", { baseUrl: BASEURL, + hasConfig: setng.GetChanged(), + titleColour: loadedSettings.titleColour, + custBrand: loadedSettings.custBrand, + settingsVersion: nowShowingSettingsVersionToken(), + nowShowingPageCycleEnabled: + loadedSettings && loadedSettings.enableNowShowingPageCycle === "true" + ? "true" + : "false", + nowShowingPageCycleStayMins: + loadedSettings && loadedSettings.nowShowingPageCycleStayMins !== undefined + ? loadedSettings.nowShowingPageCycleStayMins + : DEFAULT_SETTINGS.nowShowingPageCycleStayMins, + ...newFeaturesBannerViewData(), }); -} +}); -async function debugMediaServerOd(req, res) { - const label = getMediaServerShortLabel( - loadedSettings && loadedSettings.mediaServerType - ); - console.log(" "); - console.log("** " + label.toUpperCase() + " 'ON-DEMAND' CHECK **"); - console.log("-------------------------------------------------------"); - const test = new health(loadedSettings); - await test.PlexODCheck(); - res.render("debug", { - settings: loadedSettings, - version: pjson.version, +app.get(BASEURL + "/ads", (req, res) => { + const rotRaw = + loadedSettings && loadedSettings.adsRotationSeconds !== undefined + ? loadedSettings.adsRotationSeconds + : DEFAULT_SETTINGS.adsRotationSeconds; + const rotN = parseInt(rotRaw, 10); + const adsRotationSeconds = isNaN(rotN) + ? DEFAULT_SETTINGS.adsRotationSeconds + : Math.min(600, Math.max(3, rotN)); + const stayRaw = + loadedSettings && loadedSettings.adsPageStaySeconds !== undefined + ? loadedSettings.adsPageStaySeconds + : DEFAULT_SETTINGS.adsPageStaySeconds; + const stayN = parseInt(stayRaw, 10); + const adsPageStaySeconds = + isNaN(stayN) || stayN <= 0 + ? 0 + : Math.min(86400, Math.max(30, stayN)); + const adsGlobalBgRaw = + loadedSettings && loadedSettings.adsGlobalBackgroundPath != null + ? String(loadedSettings.adsGlobalBackgroundPath).trim() + : ""; + const adsGlobalBackgroundPath = + adsGlobalBgRaw.startsWith("/custom/ads-view/") ? adsGlobalBgRaw : ""; + res.render("ads", { baseUrl: BASEURL, + hasConfig: setng.GetChanged(), + titleColour: loadedSettings.titleColour, + custBrand: loadedSettings.custBrand, + adsRotationSeconds, + adsPageStaySeconds, + adsGlobalBackgroundPath, + adsCurrencyCode: normalizeNowShowingCurrencyCode( + (loadedSettings && loadedSettings.adsCurrencyCode) || + DEFAULT_SETTINGS.adsCurrencyCode + ), + ...newFeaturesBannerViewData(), }); -} +}); -app.get(BASEURL + "/debug/medians", debugMediaServerNs); -app.get(BASEURL + "/debug/mediaod", debugMediaServerOd); -app.get(BASEURL + "/debug/plexns", debugMediaServerNs); -app.get(BASEURL + "/debug/plexod", debugMediaServerOd); +const NOW_SHOWING_OD_TYPES = new Set([ + "movie", + "show", + "series", + "episode", +]); + +/** True if the on-demand card list can supply /now-showing library fillers (movie/TV types with a title). */ +function odCardsHaveNowShowingFillCandidates(arr) { + if (!Array.isArray(arr) || !arr.length) return false; + for (const c of arr) { + const mt = String(c.mediaType || "").toLowerCase(); + if (!NOW_SHOWING_OD_TYPES.has(mt)) continue; + const t = normalizeNowShowingTitle( + (c && (c.title || c.tagLine)) || "" + ); + if (t) return true; + } + return false; +} -app.get(BASEURL + "/debug/sonarr", (req, res) => { - console.log(' '); - console.log("** SONARR CHECK ** (titles in next 5 days)"); - console.log('-------------------------------------------------------'); - let test = new health(loadedSettings); - test.SonarrCheck(); - res.render("debug", { settings: loadedSettings, version: pjson.version, baseUrl: BASEURL }); -}); +function normalizeNowShowingTitle(t) { + return String(t || "") + .toLowerCase() + .replace(/[^a-z0-9\s]/g, " ") + .replace(/\s+/g, " ") + .trim(); +} -app.get(BASEURL + "/debug/radarr", (req, res) => { - console.log(' '); - console.log("** RADARR CHECK ** (Any releases in next 30 days)"); - console.log('-------------------------------------------------------'); - let test = new health(loadedSettings); - test.RadarrCheck(); - res.render("debug", { settings: loadedSettings, version: pjson.version, baseUrl: BASEURL }); -}); +function normalizeLibraryNameFor3d(name) { + return String(name || "") + .toLowerCase() + .replace(/\s+/g, " ") + .trim(); +} -app.get(BASEURL + "/debug/lidarr", (req, res) => { - console.log(' '); - console.log("** LIDARR CHECK ** (Album calendar, next 30 days)"); - console.log('-------------------------------------------------------'); - let test = new health(loadedSettings); - test.LidarrCheck(); - res.render("debug", { settings: loadedSettings, version: pjson.version, baseUrl: BASEURL }); -}); +function onDemand3dLibrarySet() { + const raw = + loadedSettings && loadedSettings.onDemand3dLibraries != null + ? loadedSettings.onDemand3dLibraries + : ""; + const set = new Set(); + String(raw || "") + .split(",") + .map((x) => normalizeLibraryNameFor3d(x)) + .filter(Boolean) + .forEach((x) => set.add(x)); + return set; +} -app.get(BASEURL + "/debug/readarr", (req, res) => { - console.log(' '); - console.log("** READARR CHECK ** (Any releases in next 90 days)"); - console.log('-------------------------------------------------------'); - let test = new health(loadedSettings); - test.ReadarrCheck(); - res.render("debug", { settings: loadedSettings, version: pjson.version, baseUrl: BASEURL }); -}); +function apply3dLibraryFlagToCards(cards) { + if (!Array.isArray(cards) || cards.length === 0) return cards; + const libs = onDemand3dLibrarySet(); + if (libs.size === 0) { + for (const c of cards) { + if (!c || typeof c !== "object") continue; + c.is3D = false; + } + return cards; + } + for (const c of cards) { + if (!c || typeof c !== "object") continue; + const lib = normalizeLibraryNameFor3d(c.posterLibraryLabel); + c.is3D = lib !== "" && libs.has(lib); + } + return cards; +} -app.get(BASEURL + "/debug/trivia", (req, res) => { - console.log(' '); - console.log("** Open Trvia DB CHECK ** (Get 5 questions)"); - console.log('-------------------------------------------------------'); - let test = new health(loadedSettings); - test.TriviaCheck(); - res.render("debug", { settings: loadedSettings, version: pjson.version, baseUrl: BASEURL }); -}); +function nowShowingAssetUrlWithBase(u, baseUrl) { + const s = String(u || "").trim(); + if (!s) return ""; + if (/^https?:\/\//i.test(s)) return s; + if (s.startsWith("/")) return (baseUrl || "") + s; + return s; +} -// password for settings section -let userData = { valid: false, expires: 10 }; +function nowShowingSettingsVersionToken() { + try { + const p = path.join(__dirname, "config", "settings.json"); + if (!fs.existsSync(p)) return "0"; + const st = fs.statSync(p); + return String(Math.floor(Number(st.mtimeMs) || 0)); + } catch (e) { + return "0"; + } +} -// settings page -app.get(BASEURL + "/logon", (req, res) => { - res.render("logon", { - success: req.session.success, baseUrl: BASEURL - }); - req.session.errors = null; -}); +/** Read authoritative value from disk (fixes stale `loadedSettings` after editing JSON or partial saves). */ +function readNowShowingShowtimeCountFromSettingsFile() { + try { + const p = path.join(__dirname, "config", "settings.json"); + if (!fs.existsSync(p)) return null; + const j = JSON.parse(fs.readFileSync(p, "utf8")); + const raw = j && j.nowShowingShowtimeCount; + if (raw === undefined || raw === null || raw === "") return null; + const n = + typeof raw === "number" && !isNaN(raw) + ? raw + : parseInt(String(raw).trim(), 10); + if (isNaN(n)) return null; + return Math.max(1, Math.min(6, n)); + } catch (e) { + return null; + } +} +/** Clamped 1–6 from settings; drives /now-showing, fillers, and TMDB list slides. */ +function nowShowingShowtimeDisplayCount() { + const fromFile = readNowShowingShowtimeCountFromSettingsFile(); + const raw = loadedSettings && loadedSettings.nowShowingShowtimeCount; + const fromMem = + typeof raw === "number" && !isNaN(raw) + ? raw + : parseInt(String(raw == null ? "" : raw).trim(), 10); + const fallback = Number(DEFAULT_SETTINGS.nowShowingShowtimeCount) || 6; + const n = + fromFile != null ? fromFile : !isNaN(fromMem) ? fromMem : fallback; + const clamped = Math.max(1, Math.min(6, n)); + if (loadedSettings && loadedSettings.nowShowingShowtimeCount !== clamped) { + loadedSettings.nowShowingShowtimeCount = clamped; + } + return clamped; +} -function getDirectories(path) { - return fs.readdirSync(path).filter(function (file) { - return fs.statSync(path + '/' + file).isDirectory(); - }); +/** Futures + 2 buffer slots (last played + current) for /now-showing payload; capped at 8. */ +function nowShowingTotalSlotsForSchedulePayload() { + return Math.min(8, nowShowingShowtimeDisplayCount() + 2); +} + +function sliceNowShowingMovieShowtimesForDisplay(m, count) { + const c = Math.max(1, Math.min(10, count)); + const stFull = Array.isArray(m.showtimes) ? m.showtimes : []; + const isoFull = Array.isArray(m.showtimeStartsIso) ? m.showtimeStartsIso : []; + const len = Math.min(stFull.length, isoFull.length); + if (len <= 0) { + return { ...m, showtimes: [], showtimeStartsIso: [] }; + } + if (len <= c) { + return { + ...m, + showtimes: stFull.slice(0, len), + showtimeStartsIso: isoFull.slice(0, len), + }; + } + const runMs = + Math.max(1, Math.min(600, Number(m.runtimeMins) || 120)) * 60000; + const nowMs = Date.now(); + const toMs = (i) => { + const ms = Date.parse(String(isoFull[i])); + return Number.isNaN(ms) ? null : ms; + }; + let currentIdx = -1; + for (let i = 0; i < len; i++) { + const ms = toMs(i); + if (ms == null) continue; + if (nowMs >= ms && nowMs < ms + runMs) { + currentIdx = i; + break; + } + } + let start = 0; + if (currentIdx >= 0) { + start = Math.max(0, currentIdx - 1); + start = Math.max(start, currentIdx - c + 1); + start = Math.min(start, len - c); + } else { + let lastPast = -1; + for (let i = 0; i < len; i++) { + const ms = toMs(i); + if (ms == null) continue; + if (ms + runMs <= nowMs) lastPast = i; + } + if (lastPast >= 0) { + start = Math.max(0, Math.min(lastPast, len - c)); + } else { + start = 0; + } + } + return { + ...m, + showtimes: stFull.slice(start, start + c), + showtimeStartsIso: isoFull.slice(start, start + c), + }; +} + +function nowShowingFillerShowtimesWithIso(seedTitle, count, runtimeMins) { + const rtRaw = Number(runtimeMins); + const rt = rtRaw > 0 && !isNaN(rtRaw) ? rtRaw : 120; + const spacingMins = rt + 10; + const c = Number(count); + const n = Number.isFinite(c) + ? Math.max(1, Math.min(10, Math.floor(c))) + : 4; + const now = new Date(); + const t = new Date(now); + t.setMinutes(0, 0, 0); + t.setHours(t.getHours() + 1); + const salt = (String(seedTitle || "").length % 4) * 10; + t.setMinutes(t.getMinutes() + salt); + const showtimes = []; + const showtimeStartsIso = []; + let ms = t.getTime(); + for (let i = 0; i < n; i++) { + const slot = new Date(ms); + showtimes.push( + slot.toLocaleTimeString([], { + hour: "numeric", + minute: "2-digit", + }) + ); + showtimeStartsIso.push(slot.toISOString()); + ms += spacingMins * 60000; + } + return { showtimes, showtimeStartsIso }; +} + +function parseMoneyAmount(value) { + if (value === undefined || value === null || value === "") return null; + const raw = String(value).trim(); + if (!raw) return null; + const normalized = raw + .replace(/,/g, "") + .replace(/[^\d.-]/g, ""); + const n = Number(normalized); + if (!Number.isFinite(n) || n < 0) return null; + return Math.round(n * 100) / 100; +} + +/** One-time: legacy global adsPriceAddOn in settings → each ad row, then clear global. */ +async function migrateLegacyGlobalAdsPriceAddOnOnce() { + try { + if (!loadedSettings) return; + const g = parseMoneyAmount(loadedSettings.adsPriceAddOn); + if (g == null || g <= 0) return; + for (const ad of adsDb.listAds()) { + const addons = Array.isArray(ad.addons) ? ad.addons : []; + if (addons.length) continue; + adsDb.replaceAdAddons(ad.id, [ + { title: "Add-on", amount: g, sortOrder: 0 }, + ]); + } + loadedSettings.adsPriceAddOn = 0; + await setng.UpdateSettings(loadedSettings); + const now = new Date(); + console.log( + now.toLocaleString() + + " Ads: migrated global price add-on onto each ad; global setting cleared." + ); + } catch (e) { + const now = new Date(); + console.log( + now.toLocaleString() + + " Ads price add-on migration skipped: " + + (e && e.message ? e.message : e) + ); + } +} + +function randomMoneyAmountInRange(minRaw, maxRaw) { + const min = parseMoneyAmount(minRaw); + const max = parseMoneyAmount(maxRaw); + const lo = min == null ? 5 : min; + const hi = max == null ? 20 : max; + const start = Math.min(lo, hi); + const end = Math.max(lo, hi); + if (start === end) return start; + return Math.round((start + Math.random() * (end - start)) * 100) / 100; +} + +function parseReleaseYear(value) { + const y = parseInt(String(value == null ? "" : value).trim(), 10); + const current = new Date().getFullYear(); + if (!Number.isFinite(y) || y < 1888 || y > current + 1) return null; + return y; +} + +function autoPriceMultiplierForYear(value) { + const y = parseReleaseYear(value); + if (y == null) return 1; + const age = Math.max(0, new Date().getFullYear() - y); + if (age >= 60) return 0.8; // classic pricing + if (age >= 20) return 0.2; + if (age >= 10) return 0.5; + if (age >= 5) return 0.8; + return 1; +} + +function autoPriceFromAge(minRaw, maxRaw, yearValue) { + const base = randomMoneyAmountInRange(minRaw, maxRaw); + const mult = autoPriceMultiplierForYear(yearValue); + return Math.round(base * mult * 100) / 100; +} + +function normalizeNowShowingCurrencyCode(value) { + const code = String(value || "").trim().toUpperCase(); + const allowed = new Set(["USD", "EUR", "GBP", "CAD", "AUD", "NZD", "JPY"]); + if (allowed.has(code)) return code; + return DEFAULT_SETTINGS.nowShowingCurrencyCode; +} + +function currencySymbolForCode(code) { + const cc = normalizeNowShowingCurrencyCode(code); + const map = { + USD: "$", + EUR: "€", + GBP: "£", + CAD: "C$", + AUD: "A$", + NZD: "NZ$", + JPY: "¥", + }; + return map[cc] || "$"; +} + +function odCardToNowShowingFiller(card, baseUrl, options) { + const serverKind = getMediaServerKind( + loadedSettings && loadedSettings.mediaServerType + ); + const apiItemId = String((card && card.posterApiItemId) || "").trim(); + const dbMeta = + apiItemId && serverKind + ? posterMetadata.getEntryByServerAndApiItemId(serverKind, apiItemId) + : null; + + const dbLogo = dbMeta && dbMeta.logoCacheFile + ? "/imagecache/" + dbMeta.logoCacheFile + : ""; + const dbArt = dbMeta && dbMeta.artCacheFile + ? "/imagecache/" + dbMeta.artCacheFile + : ""; + const dbBanner = dbMeta && dbMeta.bannerCacheFile + ? "/imagecache/" + dbMeta.bannerCacheFile + : ""; + const dbPoster = dbMeta && dbMeta.cacheFile + ? "/imagecache/" + dbMeta.cacheFile + : ""; + + const art = String(card.posterArtURL || "").trim(); + const post = String(card.posterURL || "").trim(); + const logoCached = String(card.posterLogoURL || "").trim(); + const bannerSrc = dbBanner || dbArt || dbPoster || art || post; + const logoSrc = dbLogo || dbPoster || logoCached || post || art; + const genres = Array.isArray(card.genre) + ? card.genre.join(", ") + : String(card.genre || ""); + const runRaw = parseInt(card.runTime, 10); + const runMins = runRaw > 0 && !isNaN(runRaw) ? runRaw : 120; + const nFuture = nowShowingShowtimeDisplayCount(); + const nSlots = Math.min(8, nFuture + 2); + const titleForSeed = + (dbMeta && dbMeta.title) || card.title || card.tagLine || "Untitled"; + const st = nowShowingFillerShowtimesWithIso(titleForSeed, nSlots, runMins); + const opt = options && typeof options === "object" ? options : {}; + const autoPriceEnabled = opt.autoPriceEnabled === true; + const releaseYear = + (dbMeta && dbMeta.year != null ? String(dbMeta.year) : "") || + (card.year != null ? String(card.year) : ""); + const fillerPrice = autoPriceEnabled + ? autoPriceFromAge(opt.autoPriceMin, opt.autoPriceMax, releaseYear) + : null; + return { + id: null, + tmdbId: null, + title: (dbMeta && dbMeta.title) || card.title || card.tagLine || "Untitled", + year: + (dbMeta && dbMeta.year != null ? String(dbMeta.year) : "") || + (card.year != null ? String(card.year) : ""), + logoUrl: logoSrc ? nowShowingAssetUrlWithBase(logoSrc, baseUrl) : "", + bannerUrl: bannerSrc ? nowShowingAssetUrlWithBase(bannerSrc, baseUrl) : "", + overview: String((dbMeta && dbMeta.summary) || card.summary || "").trim(), + rating: card.rating || "", + runtimeMins: runMins, + contentRating: card.contentRating || "", + genres, + showtimeMode: "auto", + manualTimes: [], + autoShowings: nFuture, + autoSeedStart: "", + priceAmount: fillerPrice, + priceAutoGenerated: autoPriceEnabled && fillerPrice != null, + is3D: card && card.is3D === true, + isCurated: false, + showtimes: st.showtimes, + showtimeStartsIso: st.showtimeStartsIso, + }; +} + +function nowPlayingNormalizedTitlesFromNsCards() { + const set = new Set(); + if (!Array.isArray(nsCards)) return set; + for (const c of nsCards) { + if (!c) continue; + const nt = normalizeNowShowingTitle(c.title); + if (nt) set.add(nt); + } + return set; +} + +function sampleNowShowingFillersFromOd(cards, curatedTitleSet, maxCount) { + if (!maxCount || !cards || !cards.length) return []; + const pool = cards.filter((c) => { + const mt = String(c.mediaType || "").toLowerCase(); + if (!NOW_SHOWING_OD_TYPES.has(mt)) return false; + const nt = normalizeNowShowingTitle( + (c && (c.title || c.tagLine)) || "" + ); + if (!nt) return false; + if (curatedTitleSet.has(nt)) return false; + return true; + }); + const shuffled = pool.slice(); + for (let i = shuffled.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; + } + const out = []; + const seen = new Set(); + for (const c of shuffled) { + const key = `${normalizeNowShowingTitle( + (c && (c.title || c.tagLine)) || "" + )}\0${String(c.DBID || c.posterApiItemId || "")}`; + if (seen.has(key)) continue; + seen.add(key); + out.push(c); + if (out.length >= maxCount) break; + } + return out; +} + +app.get(BASEURL + "/now-showing/data", async (req, res) => { + try { + res.set( + "Cache-Control", + "no-store, no-cache, must-revalidate, proxy-revalidate" + ); + res.set("Pragma", "no-cache"); + res.set("Expires", "0"); + const nFuture = nowShowingShowtimeDisplayCount(); + const nSlots = nowShowingTotalSlotsForSchedulePayload(); + const autoPriceEnabled = + loadedSettings && + loadedSettings.nowShowingAutoPriceEnabled === "true"; + const autoPriceMin = + loadedSettings && + loadedSettings.nowShowingAutoPriceMin !== undefined + ? loadedSettings.nowShowingAutoPriceMin + : DEFAULT_SETTINGS.nowShowingAutoPriceMin; + const autoPriceMax = + loadedSettings && + loadedSettings.nowShowingAutoPriceMax !== undefined + ? loadedSettings.nowShowingAutoPriceMax + : DEFAULT_SETTINGS.nowShowingAutoPriceMax; + const showPrices = + loadedSettings && loadedSettings.nowShowingShowPrices === "true"; + const extra3dRaw = + loadedSettings && loadedSettings.nowShowing3dPriceExtra !== undefined + ? loadedSettings.nowShowing3dPriceExtra + : DEFAULT_SETTINGS.nowShowing3dPriceExtra; + const nowShowing3dPriceExtra = parseMoneyAmount(extra3dRaw) || 0; + const currencyCode = normalizeNowShowingCurrencyCode( + loadedSettings && loadedSettings.nowShowingCurrencyCode + ); + const currencySymbol = currencySymbolForCode(currencyCode); + const curatedRows = nowShowingDb.listMoviesForScreen({ + showtimeSlotCount: nSlots, + autoPriceEnabled, + autoPriceMin, + autoPriceMax, + }); + await nowShowingDb.backfillRemoteAssetsToLocalPaths(curatedRows); + await nowShowingDb.hydrateMissingBannerLogoFromTmdb( + curatedRows, + loadedSettings && loadedSettings.tmdbApiKey + ); + await nowShowingDb.backfillRemoteAssetsToLocalPaths(curatedRows); + const curated = curatedRows + .map((r) => ({ ...r, isCurated: true })) + .filter(nowShowingRowHasBannerAndLogo); + const curatedSet = new Set( + curated.map((r) => normalizeNowShowingTitle(r.title)) + ); + let movies = curated.slice(); + const fillOn = + loadedSettings.nowShowingFillFromServer === "true" && + (Number(loadedSettings.nowShowingFillLibraryMax) || 0) > 0; + if (fillOn) { + const fillPool = await ensureOdCardsForNowShowingFill(); + const maxN = Math.min( + 48, + Math.max(0, parseInt(loadedSettings.nowShowingFillLibraryMax, 10) || 0) + ); + let picked = sampleNowShowingFillersFromOd(fillPool, curatedSet, maxN); + if (!picked.length && maxN > 0) { + const kind = getMediaServerKind(loadedSettings.mediaServerType); + const fb = posterMetadata.buildFallbackMediaCards( + Math.max(maxN * 2, 24), + kind + ); + picked = sampleNowShowingFillersFromOd(fb, curatedSet, maxN); + } + movies = curated.concat( + picked.map((c) => + odCardToNowShowingFiller(c, BASEURL, { + autoPriceEnabled, + autoPriceMin, + autoPriceMax, + }) + ) + ); + } + movies = movies.filter(nowShowingRowHasBannerAndLogo); + await ensureNowShowingMoviesImageCacheForResponse(movies, BASEURL); + let curatedWeight = parseInt(loadedSettings.nowShowingCuratedWeight, 10); + if (isNaN(curatedWeight)) + curatedWeight = DEFAULT_SETTINGS.nowShowingCuratedWeight; + curatedWeight = Math.max(1, Math.min(20, curatedWeight)); + const playingSet = nowPlayingNormalizedTitlesFromNsCards(); + const moviesOut = movies.map((m) => { + const base = sliceNowShowingMovieShowtimesForDisplay(m, nSlots); + let nextPrice = base.priceAmount; + let next3dPrice = null; + if (base.is3D === true && nextPrice != null && nowShowing3dPriceExtra > 0) { + next3dPrice = + Math.round((Number(nextPrice) + nowShowing3dPriceExtra) * 100) / 100; + } + return { + ...base, + priceAmount: nextPrice, + price3dAmount: next3dPrice, + isNowPlaying: playingSet.has(normalizeNowShowingTitle(m.title)), + }; + }); + res.json({ + movies: moviesOut, + showPrices, + currencyCode, + currencySymbol, + curatedWeight, + showtimeDisplayCount: nFuture, + settingsVersion: nowShowingSettingsVersionToken(), + }); + } catch (e) { + res.status(500).json({ error: e && e.message ? e.message : String(e) }); + } +}); + +app.get(BASEURL + "/ads/data", (req, res) => { + try { + res.set( + "Cache-Control", + "no-store, no-cache, must-revalidate, proxy-revalidate" + ); + res.set("Pragma", "no-cache"); + res.set("Expires", "0"); + const adsCurrencyCode = normalizeNowShowingCurrencyCode( + (loadedSettings && loadedSettings.adsCurrencyCode) || + DEFAULT_SETTINGS.adsCurrencyCode + ); + const items = adsDb + .listAds() + .filter((ad) => String(ad.mediaPath || "").trim()) + .map((ad) => ({ + id: ad.id, + title: String(ad.title || "").trim(), + mediaPath: String(ad.mediaPath || "").trim(), + backgroundMediaPath: String(ad.backgroundMediaPath || "").trim(), + addons: Array.isArray(ad.addons) + ? ad.addons.map((a) => ({ + title: String(a.title || "").trim(), + amount: + a.amount !== undefined && a.amount !== null + ? Number(a.amount) + : null, + })) + : [], + prices: Array.isArray(ad.prices) + ? ad.prices.map((p) => ({ + title: String(p.title || "").trim(), + amount: + p.amount !== undefined && p.amount !== null + ? Number(p.amount) + : null, + })) + : [], + })); + const titleOutline = + loadedSettings && String(loadedSettings.adsTitleOutline).toLowerCase() === + "true"; + const rotRaw = + loadedSettings && loadedSettings.adsRotationSeconds !== undefined + ? loadedSettings.adsRotationSeconds + : DEFAULT_SETTINGS.adsRotationSeconds; + const rotN = parseInt(rotRaw, 10); + const rotationSeconds = isNaN(rotN) + ? DEFAULT_SETTINGS.adsRotationSeconds + : Math.min(600, Math.max(3, rotN)); + const stayRaw = + loadedSettings && loadedSettings.adsPageStaySeconds !== undefined + ? loadedSettings.adsPageStaySeconds + : DEFAULT_SETTINGS.adsPageStaySeconds; + const stayN = parseInt(stayRaw, 10); + const pageStaySeconds = + isNaN(stayN) || stayN <= 0 + ? 0 + : Math.min(86400, Math.max(30, stayN)); + const gRaw = + loadedSettings && loadedSettings.adsGlobalBackgroundPath != null + ? String(loadedSettings.adsGlobalBackgroundPath).trim() + : ""; + const globalBackgroundPath = gRaw.startsWith("/custom/ads-view/") + ? gRaw + : ""; + res.json({ + items, + currencyCode: adsCurrencyCode, + currencySymbol: currencySymbolForCode(adsCurrencyCode), + titleOutline, + rotationSeconds, + pageStaySeconds, + globalBackgroundPath, + }); + } catch (e) { + res.status(500).json({ error: e && e.message ? e.message : String(e) }); + } +}); + +// Used by the web client to check connection status to Posterr, and also to determine if there was a cold start that was missed + +app.get(BASEURL + "/conncheck", (req, res) => { + res.send({ "status": cold_start_time, "sleep": sleep }); +}); + + +app.get(BASEURL + "/debug", (req, res) => { + res.render("debug", { settings: loadedSettings, version: pjson.version, baseUrl: BASEURL }); +}); + +app.get(BASEURL + "/debug/ping", (req, res) => { + console.log(' '); + console.log('** PING TESTS **'); + console.log('-------------------------------------------------------'); + let test = new health(loadedSettings); + test.TestPing(); + res.render("debug", { settings: loadedSettings, version: pjson.version, baseUrl: BASEURL }); +}); + +async function debugMediaServerNs(req, res) { + const label = getMediaServerShortLabel( + loadedSettings && loadedSettings.mediaServerType + ); + console.log(" "); + console.log("** " + label.toUpperCase() + " 'NOW SCREENING' CHECK **"); + console.log("-------------------------------------------------------"); + const test = new health(loadedSettings); + await test.PlexNSCheck(); + res.render("debug", { + settings: loadedSettings, + version: pjson.version, + baseUrl: BASEURL, + }); +} + +async function debugMediaServerOd(req, res) { + const label = getMediaServerShortLabel( + loadedSettings && loadedSettings.mediaServerType + ); + console.log(" "); + console.log("** " + label.toUpperCase() + " 'ON-DEMAND' CHECK **"); + console.log("-------------------------------------------------------"); + const test = new health(loadedSettings); + await test.PlexODCheck(); + res.render("debug", { + settings: loadedSettings, + version: pjson.version, + baseUrl: BASEURL, + }); +} + +app.get(BASEURL + "/debug/medians", debugMediaServerNs); +app.get(BASEURL + "/debug/mediaod", debugMediaServerOd); +app.get(BASEURL + "/debug/plexns", debugMediaServerNs); +app.get(BASEURL + "/debug/plexod", debugMediaServerOd); + +app.get(BASEURL + "/debug/sonarr", (req, res) => { + console.log(' '); + console.log("** SONARR CHECK ** (titles in next 5 days)"); + console.log('-------------------------------------------------------'); + let test = new health(loadedSettings); + test.SonarrCheck(); + res.render("debug", { settings: loadedSettings, version: pjson.version, baseUrl: BASEURL }); +}); + +app.get(BASEURL + "/debug/radarr", (req, res) => { + console.log(' '); + console.log("** RADARR CHECK ** (Any releases in next 30 days)"); + console.log('-------------------------------------------------------'); + let test = new health(loadedSettings); + test.RadarrCheck(); + res.render("debug", { settings: loadedSettings, version: pjson.version, baseUrl: BASEURL }); +}); + +app.get(BASEURL + "/debug/lidarr", (req, res) => { + console.log(' '); + console.log("** LIDARR CHECK ** (Album calendar, next 30 days)"); + console.log('-------------------------------------------------------'); + let test = new health(loadedSettings); + test.LidarrCheck(); + res.render("debug", { settings: loadedSettings, version: pjson.version, baseUrl: BASEURL }); +}); + +app.get(BASEURL + "/debug/readarr", (req, res) => { + console.log(' '); + console.log("** READARR CHECK ** (Any releases in next 90 days)"); + console.log('-------------------------------------------------------'); + let test = new health(loadedSettings); + test.ReadarrCheck(); + res.render("debug", { settings: loadedSettings, version: pjson.version, baseUrl: BASEURL }); +}); + +app.get(BASEURL + "/debug/trivia", (req, res) => { + console.log(' '); + console.log("** Open Trvia DB CHECK ** (Get 5 questions)"); + console.log('-------------------------------------------------------'); + let test = new health(loadedSettings); + test.TriviaCheck(); + res.render("debug", { settings: loadedSettings, version: pjson.version, baseUrl: BASEURL }); +}); + +// password for settings section +let userData = { valid: false, expires: 10 }; + +// settings page +app.get(BASEURL + "/logon", (req, res) => { + res.render("logon", { + success: req.session.success, baseUrl: BASEURL + }); + req.session.errors = null; +}); + + +function getDirectories(dirPath) { + if (!fs.existsSync(dirPath)) return []; + return fs.readdirSync(dirPath).filter(function (file) { + return fs.statSync(path.join(dirPath, file)).isDirectory(); + }); } app.get('/api/sleep', (req, res) => { @@ -2295,7 +4171,8 @@ app.post( latestVersion: latestVersion, message: message, updateAvailable: updateAvailable, - cacheClearNotice: null + cacheClearNotice: null, + ...newFeaturesBannerViewData(), }); } } @@ -2304,7 +4181,7 @@ app.post( // settings page app.get(BASEURL + "/settings", (req, res) => { // load pic folders - customPicFolders = getDirectories('./public/custom/pictures'); + customPicFolders = getDirectories(CUSTOM_PICTURES_ROOT); const cacheClearNotice = req.session.cacheClearNotice || null; req.session.cacheClearNotice = null; @@ -2321,7 +4198,8 @@ app.get(BASEURL + "/settings", (req, res) => { latestVersion: latestVersion, message: message, updateAvailable: updateAvailable, - cacheClearNotice: cacheClearNotice + cacheClearNotice: cacheClearNotice, + ...newFeaturesBannerViewData(), }); } else { @@ -2336,7 +4214,8 @@ app.get(BASEURL + "/settings", (req, res) => { latestVersion: latestVersion, message: message, updateAvailable: updateAvailable, - cacheClearNotice: cacheClearNotice + cacheClearNotice: cacheClearNotice, + ...newFeaturesBannerViewData(), }); } req.session.errors = null; @@ -2348,6 +4227,7 @@ app.post(BASEURL + "/settings/clear-poster-cache", async (req, res) => { } try { await posterMetadata.clearPosterCacheAndMetadata(); + posterSyncRetry.clearRetryFile(); req.session.cacheClearNotice = { ok: true, text: "Poster image cache and metadata database were cleared.", @@ -2362,6 +4242,36 @@ app.post(BASEURL + "/settings/clear-poster-cache", async (req, res) => { res.redirect(302, BASEURL + "/settings"); }); +app.post(BASEURL + "/settings/new-features-acknowledge", (req, res) => { + if (loadedSettings.password !== undefined && !userData.valid) { + return res.redirect(302, BASEURL + "/logon"); + } + const ver = String(pjson.version || "").trim(); + const settingsPath = path.join(__dirname, "config", "settings.json"); + try { + let data = {}; + try { + if (fs.existsSync(settingsPath)) { + data = JSON.parse(fs.readFileSync(settingsPath, "utf8")); + } else { + data = JSON.parse(JSON.stringify(loadedSettings || {})); + } + } catch (e) { + data = JSON.parse(JSON.stringify(loadedSettings || {})); + } + data.newFeaturesAcknowledgedVersion = ver; + fs.writeFileSync(settingsPath, JSON.stringify(data, null, 4), "utf8"); + loadedSettings.newFeaturesAcknowledgedVersion = ver; + setng.newFeaturesAcknowledgedVersion = ver; + } catch (e) { + let d = new Date(); + console.log( + d.toLocaleString() + " new-features-acknowledge failed: " + (e && e.message ? e.message : e) + ); + } + res.redirect(302, BASEURL + "/settings"); +}); + app.get(BASEURL + "/settings/sync/progress", (req, res) => { if (loadedSettings.password !== undefined && !userData.valid) { return res.status(401).json({ error: "unauthorized" }); @@ -2397,16 +4307,22 @@ app.get(BASEURL + "/settings/sync/progress", (req, res) => { if (st.phase === "registering") { percent = 100; } else if (st.phase === "fetching") { - percent = Math.round(50 * fetchRatioFromLibraries(libs)); + percent = st.metadataOnlySync + ? Math.round(10 * fetchRatioFromLibraries(libs)) + : Math.round(50 * fetchRatioFromLibraries(libs)); if (libs.length === 0) { indeterminate = true; percent = null; } } else if (st.phase === "caching" && total > 0) { - percent = Math.min( - 100, - Math.round(50 + (processed / total) * 50) - ); + if (st.metadataOnlySync) { + percent = Math.min(100, Math.round((processed / total) * 100)); + } else { + percent = Math.min( + 100, + Math.round(50 + (processed / total) * 50) + ); + } } else if (st.phase === "complete") { percent = 100; } else if (total > 0) { @@ -2438,11 +4354,13 @@ app.get(BASEURL + "/settings/sync/progress", (req, res) => { libraries, syncScope: st.syncScope || "all", syncSingleLibrary: st.syncSingleLibrary || "", + metadataOnlySync: st.metadataOnlySync === true, + runId: st.runId || "", }); }); app.get(BASEURL + "/settings/sync", (req, res) => { - customPicFolders = getDirectories("./public/custom/pictures"); + customPicFolders = getDirectories(CUSTOM_PICTURES_ROOT); const syncNotice = req.session.syncNotice || null; req.session.syncNotice = null; res.render("settings-sync", { @@ -2460,6 +4378,7 @@ app.get(BASEURL + "/settings/sync", (req, res) => { onDemandLibraryList: getConfiguredOnDemandLibraryNames( loadedSettings.onDemandLibraries ), + ...newFeaturesBannerViewData(), }); }); @@ -2490,6 +4409,9 @@ app.post(BASEURL + "/settings/sync/trigger", (req, res) => { return res.redirect(302, BASEURL + "/settings/sync"); } const bodyLib = req.body && req.body.library; + const modeRaw = req.body && req.body.syncMode; + const metadataOnly = + modeRaw != null && String(modeRaw).trim().toLowerCase() === "metadata"; const wantSingle = bodyLib != null && String(bodyLib).trim() !== ""; let syncOptions = {}; @@ -2504,7 +4426,9 @@ app.post(BASEURL + "/settings/sync/trigger", (req, res) => { }; return res.redirect(302, BASEURL + "/settings/sync"); } - syncOptions = { singleLibrary: resolved }; + syncOptions = { singleLibrary: resolved, metadataOnlySync: metadataOnly }; + } else { + syncOptions = { metadataOnlySync: metadataOnly }; } syncFullPosterLibraryFromMediaServer(syncOptions).catch((err) => { const d = new Date(); @@ -2517,6 +4441,16 @@ app.post(BASEURL + "/settings/sync/trigger", (req, res) => { res.redirect(302, BASEURL + "/settings/sync"); }); +app.post(BASEURL + "/settings/sync/abort", (req, res) => { + if (loadedSettings.password !== undefined && !userData.valid) { + return res.redirect(302, BASEURL + "/logon"); + } + if (posterSyncProgressState.status === "running") { + posterSyncAbortRequested = true; + } + res.redirect(302, BASEURL + "/settings/sync"); +}); + app.get(BASEURL + "/settings/cache/stats", (req, res) => { if (loadedSettings.password !== undefined && !userData.valid) { return res.status(401).json({ error: "unauthorized" }); @@ -2531,7 +4465,7 @@ app.get(BASEURL + "/settings/cache/stats", (req, res) => { }); app.get(BASEURL + "/settings/cache", (req, res) => { - customPicFolders = getDirectories("./public/custom/pictures"); + customPicFolders = getDirectories(CUSTOM_PICTURES_ROOT); res.render("settings-cache", { success: req.session.success, user: @@ -2543,9 +4477,665 @@ app.get(BASEURL + "/settings/cache", (req, res) => { latestVersion: latestVersion, message: message, updateAvailable: updateAvailable, + ...newFeaturesBannerViewData(), + }); +}); + +app.get(BASEURL + "/settings/now-showing-slides", (req, res) => { + if (loadedSettings.password !== undefined && !userData.valid) { + return res.redirect(302, BASEURL + "/logon"); + } + return res.redirect(302, BASEURL + "/settings/now-showing"); +}); + +app.get(BASEURL + "/settings/now-showing", (req, res) => { + if (loadedSettings.password !== undefined && !userData.valid) { + return res.redirect(302, BASEURL + "/logon"); + } + customPicFolders = getDirectories(CUSTOM_PICTURES_ROOT); + const notice = req.session.nowShowingNotice || null; + req.session.nowShowingNotice = null; + res.render("settings-now-showing", { + success: req.session.success, + user: + loadedSettings.password === undefined ? { valid: true } : userData, + settings: loadedSettings, + version: pjson.version, + baseUrl: BASEURL, + customPicFolders: customPicFolders, + latestVersion: latestVersion, + message: message, + updateAvailable: updateAvailable, + notice: notice, + ...newFeaturesBannerViewData(), + }); +}); + +app.get(BASEURL + "/settings/ads", (req, res) => { + if (loadedSettings.password !== undefined && !userData.valid) { + return res.redirect(302, BASEURL + "/logon"); + } + customPicFolders = getDirectories(CUSTOM_PICTURES_ROOT); + const notice = req.session.adsNotice || null; + req.session.adsNotice = null; + let adsItems = []; + try { + adsItems = adsDb.listAds(); + } catch (e) { + adsItems = []; + } + const adsCurrencyCode = normalizeNowShowingCurrencyCode( + loadedSettings.adsCurrencyCode || DEFAULT_SETTINGS.adsCurrencyCode + ); + const adsCurrencySymbol = currencySymbolForCode(adsCurrencyCode); + res.render("settings-ads", { + success: req.session.success, + user: + loadedSettings.password === undefined ? { valid: true } : userData, + settings: loadedSettings, + version: pjson.version, + baseUrl: BASEURL, + customPicFolders: customPicFolders, + latestVersion: latestVersion, + message: message, + updateAvailable: updateAvailable, + notice: notice, + adsItems, + adsCurrencyCode, + adsCurrencySymbol, + ...newFeaturesBannerViewData(), + }); +}); + +app.post(BASEURL + "/settings/ads", async (req, res) => { + if (loadedSettings.password !== undefined && !userData.valid) { + return res.redirect(302, BASEURL + "/logon"); + } + try { + loadedSettings.enableAds = req.body.enableAds ? "true" : "false"; + loadedSettings.adsOnly = req.body.adsOnly ? "true" : "false"; + loadedSettings.adsTitleOutline = req.body.adsTitleOutline + ? "true" + : "false"; + const adsEvery = parseInt(req.body.adsEveryPosters, 10); + loadedSettings.adsEveryPosters = isNaN(adsEvery) ? 0 : Math.max(0, adsEvery); + loadedSettings.adsCurrencyCode = normalizeNowShowingCurrencyCode( + req.body.adsCurrencyCode + ); + const rotAds = parseInt(req.body.adsRotationSeconds, 10); + loadedSettings.adsRotationSeconds = isNaN(rotAds) + ? DEFAULT_SETTINGS.adsRotationSeconds + : Math.min(600, Math.max(3, rotAds)); + const stayRaw = req.body.adsPageStaySeconds; + const stayAds = parseInt(stayRaw, 10); + loadedSettings.adsPageStaySeconds = + stayRaw === undefined || + stayRaw === null || + String(stayRaw).trim() === "" || + isNaN(stayAds) || + stayAds <= 0 + ? 0 + : Math.min(86400, Math.max(30, stayAds)); + await setng.UpdateSettings(loadedSettings); + req.session.adsNotice = { ok: true, text: "ADS settings saved." }; + } catch (e) { + req.session.adsNotice = { + ok: false, + text: "Could not save ADS settings: " + (e && e.message ? e.message : e), + }; + } + return res.redirect(302, BASEURL + "/settings/ads"); +}); + +app.post( + BASEURL + "/settings/ads/global-background", + adsViewBgUpload.single("adsGlobalBgFile"), + async (req, res) => { + if (loadedSettings.password !== undefined && !userData.valid) { + return res.redirect(302, BASEURL + "/logon"); + } + try { + if (!req.file || !req.file.buffer) { + req.session.adsNotice = { + ok: false, + text: "Choose an image file for the dedicated Ads page background.", + }; + return res.redirect(302, BASEURL + "/settings/ads"); + } + const ext = path + .extname(String(req.file.originalname || "")) + .toLowerCase(); + if (![".jpg", ".jpeg", ".png", ".gif", ".webp"].includes(ext)) { + req.session.adsNotice = { + ok: false, + text: "Only JPG, PNG, GIF, and WEBP files are supported.", + }; + return res.redirect(302, BASEURL + "/settings/ads"); + } + fs.mkdirSync(ADS_VIEW_BG_ROOT, { recursive: true }); + unlinkAdsGlobalBackgroundFileIfSafe(loadedSettings.adsGlobalBackgroundPath); + const unique = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const fileName = `bg-${unique}${ext}`; + const diskPath = path.resolve(ADS_VIEW_BG_ROOT, fileName); + if (!diskPath.startsWith(path.resolve(ADS_VIEW_BG_ROOT))) { + throw new Error("Invalid ads-view path"); + } + await fs.promises.writeFile(diskPath, req.file.buffer); + loadedSettings.adsGlobalBackgroundPath = `/custom/ads-view/${fileName}`; + await setng.UpdateSettings(loadedSettings); + req.session.adsNotice = { + ok: true, + text: "Dedicated Ads page background updated.", + }; + } catch (e) { + req.session.adsNotice = { + ok: false, + text: + "Could not save Ads page background: " + + (e && e.message ? e.message : e), + }; + } + return res.redirect(302, BASEURL + "/settings/ads"); + } +); + +app.post(BASEURL + "/settings/ads/global-background/clear", async (req, res) => { + if (loadedSettings.password !== undefined && !userData.valid) { + return res.redirect(302, BASEURL + "/logon"); + } + try { + unlinkAdsGlobalBackgroundFileIfSafe(loadedSettings.adsGlobalBackgroundPath); + loadedSettings.adsGlobalBackgroundPath = ""; + await setng.UpdateSettings(loadedSettings); + req.session.adsNotice = { + ok: true, + text: "Dedicated Ads page background removed.", + }; + } catch (e) { + req.session.adsNotice = { + ok: false, + text: + "Could not clear Ads page background: " + + (e && e.message ? e.message : e), + }; + } + return res.redirect(302, BASEURL + "/settings/ads"); +}); + +app.get(BASEURL + "/settings/ads/list", (req, res) => { + if (loadedSettings.password !== undefined && !userData.valid) { + return res.status(401).json({ error: "unauthorized" }); + } + try { + const adsCurrencyCode = normalizeNowShowingCurrencyCode( + loadedSettings.adsCurrencyCode || DEFAULT_SETTINGS.adsCurrencyCode + ); + const titleOutline = + loadedSettings && String(loadedSettings.adsTitleOutline).toLowerCase() === + "true"; + const listRotRaw = + loadedSettings && loadedSettings.adsRotationSeconds !== undefined + ? loadedSettings.adsRotationSeconds + : DEFAULT_SETTINGS.adsRotationSeconds; + const listRotN = parseInt(listRotRaw, 10); + const rotationSeconds = isNaN(listRotN) + ? DEFAULT_SETTINGS.adsRotationSeconds + : Math.min(600, Math.max(3, listRotN)); + res.json({ + items: adsDb.listAds(), + adsCurrencyCode, + adsCurrencySymbol: currencySymbolForCode(adsCurrencyCode), + titleOutline, + rotationSeconds, + }); + } catch (e) { + res.status(500).json({ error: e && e.message ? e.message : String(e) }); + } +}); + +app.post( + BASEURL + "/settings/ads/item", + adsMediaUploadAny, + async (req, res) => { + if (loadedSettings.password !== undefined && !userData.valid) { + return res.status(401).json({ error: "unauthorized" }); + } + try { + validateAdsUploadFields(req); + const mainFile = firstUploadedFile(req, "adMediaFile"); + if (!mainFile) throw new Error("Select an ad image to upload"); + const ext = path.extname(String(mainFile.originalname || "")).toLowerCase(); + if (![".jpg", ".jpeg", ".png", ".gif", ".webp"].includes(ext)) { + throw new Error("Only JPG, PNG, GIF, and WEBP files are supported"); + } + const title = String((req.body && req.body.title) || "").trim(); + const nameBase = sanitizeUploadBaseName( + title || path.parse(String(mainFile.originalname || "")).name + ); + if (!nameBase) throw new Error("Ad title or filename is invalid"); + fs.mkdirSync(ADS_MEDIA_ROOT, { recursive: true }); + const unique = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const fileName = `${nameBase}-${unique}${ext}`; + const diskPath = path.resolve(ADS_MEDIA_ROOT, fileName); + if (!diskPath.startsWith(ADS_MEDIA_ROOT)) throw new Error("Invalid ad path"); + await fs.promises.writeFile(diskPath, mainFile.buffer); + const mediaPath = `/custom/ads/${fileName}`; + let backgroundMediaPath = ""; + const bgFile = firstUploadedFile(req, "adBackgroundFile"); + if (bgFile) { + const bgExt = path.extname(String(bgFile.originalname || "")).toLowerCase(); + if (![".jpg", ".jpeg", ".png", ".gif", ".webp"].includes(bgExt)) { + throw new Error("Background must be JPG, PNG, GIF, or WEBP"); + } + const bgBase = sanitizeUploadBaseName( + "bg-" + (title || path.parse(String(bgFile.originalname || "")).name) + ); + const bgFileName = `${bgBase}-${unique}${bgExt}`; + const bgDiskPath = path.resolve(ADS_MEDIA_ROOT, bgFileName); + if (!bgDiskPath.startsWith(ADS_MEDIA_ROOT)) throw new Error("Invalid ad background path"); + await fs.promises.writeFile(bgDiskPath, bgFile.buffer); + backgroundMediaPath = `/custom/ads/${bgFileName}`; + } + const adId = adsDb.createAd({ + title: title || nameBase, + mediaPath, + enabled: true, + }); + if (backgroundMediaPath) { + adsDb.updateAd(adId, { backgroundMediaPath }); + } + const prices = parseAdPriceLinesJson(req.body && req.body.pricesJson); + adsDb.replaceAdPrices(adId, prices); + const addons = parseAdAddonLinesJson(req.body && req.body.addonsJson); + adsDb.replaceAdAddons(adId, addons); + res.json({ ok: true, item: adsDb.getAdById(adId) }); + } catch (e) { + res.status(400).json({ error: e && e.message ? e.message : String(e) }); + } + } +); + +app.post( + BASEURL + "/settings/ads/item/:id", + adsMediaUploadAny, + async (req, res) => { + if (loadedSettings.password !== undefined && !userData.valid) { + return res.status(401).json({ error: "unauthorized" }); + } + try { + validateAdsUploadFields(req); + const id = parseInt(req.params.id, 10); + if (!id) throw new Error("Invalid ad id"); + const current = adsDb.getAdById(id); + if (!current) throw new Error("Ad not found"); + let mediaPath = current.mediaPath; + let backgroundMediaPath = current.backgroundMediaPath; + const mainFile = firstUploadedFile(req, "adMediaFile"); + if (mainFile) { + const ext = path.extname(String(mainFile.originalname || "")).toLowerCase(); + if (![".jpg", ".jpeg", ".png", ".gif", ".webp"].includes(ext)) { + throw new Error("Only JPG, PNG, GIF, and WEBP files are supported"); + } + const title = String((req.body && req.body.title) || current.title || "").trim(); + const nameBase = sanitizeUploadBaseName( + title || path.parse(String(mainFile.originalname || "")).name + ); + if (!nameBase) throw new Error("Ad title or filename is invalid"); + fs.mkdirSync(ADS_MEDIA_ROOT, { recursive: true }); + const unique = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const fileName = `${nameBase}-${unique}${ext}`; + const diskPath = path.resolve(ADS_MEDIA_ROOT, fileName); + if (!diskPath.startsWith(ADS_MEDIA_ROOT)) throw new Error("Invalid ad path"); + await fs.promises.writeFile(diskPath, mainFile.buffer); + mediaPath = `/custom/ads/${fileName}`; + } + const bgFile = firstUploadedFile(req, "adBackgroundFile"); + const title = String((req.body && req.body.title) || "").trim(); + if (bgFile) { + const bgExt = path.extname(String(bgFile.originalname || "")).toLowerCase(); + if (![".jpg", ".jpeg", ".png", ".gif", ".webp"].includes(bgExt)) { + throw new Error("Background must be JPG, PNG, GIF, or WEBP"); + } + const bgBase = sanitizeUploadBaseName( + "bg-" + (title || current.title || path.parse(String(bgFile.originalname || "")).name) + ); + const uniqueBg = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const bgFileName = `${bgBase}-${uniqueBg}${bgExt}`; + const bgDiskPath = path.resolve(ADS_MEDIA_ROOT, bgFileName); + if (!bgDiskPath.startsWith(ADS_MEDIA_ROOT)) throw new Error("Invalid ad background path"); + await fs.promises.writeFile(bgDiskPath, bgFile.buffer); + backgroundMediaPath = `/custom/ads/${bgFileName}`; + } + adsDb.updateAd(id, { + title, + mediaPath, + backgroundMediaPath, + enabled: true, + }); + const prices = parseAdPriceLinesJson(req.body && req.body.pricesJson); + adsDb.replaceAdPrices(id, prices); + const addons = parseAdAddonLinesJson(req.body && req.body.addonsJson); + adsDb.replaceAdAddons(id, addons); + res.json({ ok: true, item: adsDb.getAdById(id) }); + } catch (e) { + res.status(400).json({ error: e && e.message ? e.message : String(e) }); + } + } +); + +app.post(BASEURL + "/settings/ads/item/:id/delete", (req, res) => { + if (loadedSettings.password !== undefined && !userData.valid) { + return res.status(401).json({ error: "unauthorized" }); + } + try { + const id = parseInt(req.params.id, 10); + if (!id) throw new Error("Invalid ad id"); + adsDb.deleteAd(id); + res.json({ ok: true }); + } catch (e) { + res.status(400).json({ error: e && e.message ? e.message : String(e) }); + } +}); + +app.get(BASEURL + "/settings/now-showing/list", (req, res) => { + if (loadedSettings.password !== undefined && !userData.valid) { + return res.status(401).json({ error: "unauthorized" }); + } + try { + res.json({ movies: nowShowingDb.listMoviesForSettings() }); + } catch (e) { + res.status(500).json({ error: e && e.message ? e.message : String(e) }); + } +}); + +app.get(BASEURL + "/settings/now-showing/search", async (req, res) => { + if (loadedSettings.password !== undefined && !userData.valid) { + return res.status(401).json({ error: "unauthorized" }); + } + try { + const q = req.query && req.query.q; + const results = await nowShowingDb.searchTmdbMovies( + q, + loadedSettings && loadedSettings.tmdbApiKey + ); + res.json({ results }); + } catch (e) { + res.status(400).json({ error: e && e.message ? e.message : String(e) }); + } +}); + +app.post(BASEURL + "/settings/now-showing/add", async (req, res) => { + if (loadedSettings.password !== undefined && !userData.valid) { + return res.status(401).json({ error: "unauthorized" }); + } + try { + const manualTimes = String(req.body.manualTimes || "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + .slice(0, 6); + await nowShowingDb.upsertMovieFromTmdb( + req.body.tmdbId, + { + showtimeMode: req.body.showtimeMode === "manual" ? "manual" : "auto", + autoShowings: req.body.autoShowings, + manualTimes: manualTimes, + priceAmount: req.body.priceAmount, + priceAutoGenerated: false, + }, + loadedSettings && loadedSettings.tmdbApiKey + ); + res.json({ ok: true }); + } catch (e) { + res.status(400).json({ error: e && e.message ? e.message : String(e) }); + } +}); + +app.get(BASEURL + "/settings/tmdb-api", (req, res) => { + if (loadedSettings.password !== undefined && !userData.valid) { + return res.redirect(302, BASEURL + "/logon"); + } + customPicFolders = getDirectories(CUSTOM_PICTURES_ROOT); + res.render("settings-tmdb", { + success: req.session.success, + user: + loadedSettings.password === undefined ? { valid: true } : userData, + settings: loadedSettings, + version: pjson.version, + baseUrl: BASEURL, + customPicFolders: customPicFolders, + latestVersion: latestVersion, + message: message, + updateAvailable: updateAvailable, + notice: req.session.tmdbNotice || null, + ...newFeaturesBannerViewData(), }); + req.session.tmdbNotice = null; +}); + +app.post(BASEURL + "/settings/tmdb-api", async (req, res) => { + if (loadedSettings.password !== undefined && !userData.valid) { + return res.redirect(302, BASEURL + "/logon"); + } + try { + loadedSettings.tmdbApiKey = String(req.body.tmdbApiKey || "").trim(); + await setng.UpdateSettings(loadedSettings); + req.session.tmdbNotice = { ok: true, text: "TMDB API key saved." }; + } catch (e) { + req.session.tmdbNotice = { + ok: false, + text: "Could not save TMDB API key: " + (e && e.message ? e.message : e), + }; + } + return res.redirect(302, BASEURL + "/settings/tmdb-api"); }); +app.post(BASEURL + "/settings/now-showing/update/:id", (req, res) => { + if (loadedSettings.password !== undefined && !userData.valid) { + return res.status(401).json({ error: "unauthorized" }); + } + try { + const manualTimes = String(req.body.manualTimes || "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + .slice(0, 6); + nowShowingDb.updateMovieConfig(req.params.id, { + showtimeMode: req.body.showtimeMode === "manual" ? "manual" : "auto", + autoShowings: req.body.autoShowings, + manualTimes: manualTimes, + priceAmount: req.body.priceAmount, + priceAutoGenerated: false, + }); + res.json({ ok: true }); + } catch (e) { + res.status(400).json({ error: e && e.message ? e.message : String(e) }); + } +}); + +app.post(BASEURL + "/settings/now-showing/delete/:id", (req, res) => { + if (loadedSettings.password !== undefined && !userData.valid) { + return res.status(401).json({ error: "unauthorized" }); + } + try { + nowShowingDb.deleteMovie(req.params.id); + res.json({ ok: true }); + } catch (e) { + res.status(400).json({ error: e && e.message ? e.message : String(e) }); + } +}); + +app.post(BASEURL + "/settings/now-showing/screen", async (req, res) => { + if (loadedSettings.password !== undefined && !userData.valid) { + return res.redirect(302, BASEURL + "/logon"); + } + try { + const listInPosterRaw = req.body.enableNowShowingListInPoster; + loadedSettings.enableNowShowingListInPoster = + listInPosterRaw === "on" || + listInPosterRaw === "true" || + listInPosterRaw === true || + listInPosterRaw === 1 || + listInPosterRaw === "1" + ? "true" + : "false"; + const listOnlyRaw = req.body.nowShowingListOnly; + loadedSettings.nowShowingListOnly = + listOnlyRaw === "on" || + listOnlyRaw === "true" || + listOnlyRaw === true || + listOnlyRaw === 1 || + listOnlyRaw === "1" + ? "true" + : "false"; + const fillRaw = req.body.nowShowingFillFromServer; + loadedSettings.nowShowingFillFromServer = + fillRaw === "on" || fillRaw === "true" || fillRaw === true || fillRaw === 1 || fillRaw === "1" + ? "true" + : "false"; + const maxN = parseInt(req.body.nowShowingFillLibraryMax, 10); + loadedSettings.nowShowingFillLibraryMax = isNaN(maxN) + ? DEFAULT_SETTINGS.nowShowingFillLibraryMax + : Math.max(0, Math.min(48, maxN)); + const w = parseInt(req.body.nowShowingCuratedWeight, 10); + loadedSettings.nowShowingCuratedWeight = isNaN(w) + ? DEFAULT_SETTINGS.nowShowingCuratedWeight + : Math.max(1, Math.min(20, w)); + const stc = parseInt(req.body.nowShowingShowtimeCount, 10); + loadedSettings.nowShowingShowtimeCount = isNaN(stc) + ? DEFAULT_SETTINGS.nowShowingShowtimeCount + : Math.max(1, Math.min(6, stc)); + const showPricesRaw = req.body.nowShowingShowPrices; + loadedSettings.nowShowingShowPrices = + showPricesRaw === "on" || + showPricesRaw === "true" || + showPricesRaw === true || + showPricesRaw === 1 || + showPricesRaw === "1" + ? "true" + : "false"; + const autoPriceRaw = req.body.nowShowingAutoPriceEnabled; + loadedSettings.nowShowingAutoPriceEnabled = + autoPriceRaw === "on" || + autoPriceRaw === "true" || + autoPriceRaw === true || + autoPriceRaw === 1 || + autoPriceRaw === "1" + ? "true" + : "false"; + const pMin = parseMoneyAmount(req.body.nowShowingAutoPriceMin); + const pMax = parseMoneyAmount(req.body.nowShowingAutoPriceMax); + const p3dExtra = parseMoneyAmount(req.body.nowShowing3dPriceExtra); + loadedSettings.nowShowingAutoPriceMin = + pMin == null ? DEFAULT_SETTINGS.nowShowingAutoPriceMin : pMin; + loadedSettings.nowShowingAutoPriceMax = + pMax == null ? DEFAULT_SETTINGS.nowShowingAutoPriceMax : pMax; + loadedSettings.nowShowing3dPriceExtra = + p3dExtra == null ? DEFAULT_SETTINGS.nowShowing3dPriceExtra : p3dExtra; + loadedSettings.nowShowingCurrencyCode = normalizeNowShowingCurrencyCode( + req.body.nowShowingCurrencyCode + ); + const cycleOnRaw = req.body.enableNowShowingPageCycle; + loadedSettings.enableNowShowingPageCycle = + cycleOnRaw === "on" || + cycleOnRaw === "true" || + cycleOnRaw === true || + cycleOnRaw === 1 || + cycleOnRaw === "1" + ? "true" + : "false"; + const every = parseInt(req.body.nowShowingPageCycleEveryMins, 10); + loadedSettings.nowShowingPageCycleEveryMins = isNaN(every) + ? DEFAULT_SETTINGS.nowShowingPageCycleEveryMins + : Math.max(1, Math.min(1440, every)); + const stay = parseInt(req.body.nowShowingPageCycleStayMins, 10); + loadedSettings.nowShowingPageCycleStayMins = isNaN(stay) + ? DEFAULT_SETTINGS.nowShowingPageCycleStayMins + : Math.max(1, Math.min(120, stay)); + await setng.UpdateSettings(loadedSettings); + req.session.nowShowingNotice = { + ok: true, + text: "Now Showing options saved (home poster and dedicated screen).", + }; + } catch (e) { + req.session.nowShowingNotice = { + ok: false, + text: + "Could not save screen options: " + + (e && e.message ? e.message : String(e)), + }; + } + return res.redirect(302, BASEURL + "/settings/now-showing"); +}); + +app.post(BASEURL + "/settings/now-showing/regenerate-showtimes", (req, res) => { + if (loadedSettings.password !== undefined && !userData.valid) { + return res.redirect(302, BASEURL + "/logon"); + } + try { + nowShowingDb.regenerateAllAutoShowtimes(); + req.session.nowShowingNotice = { + ok: true, + text: "Auto showtime schedules cleared. The next visit to /now-showing will rebuild today’s grid from local midnight.", + }; + } catch (e) { + req.session.nowShowingNotice = { + ok: false, + text: + "Could not regenerate showtimes: " + + (e && e.message ? e.message : String(e)), + }; + } + return res.redirect(302, BASEURL + "/settings/now-showing"); +}); + +app.post( + BASEURL + "/settings/custom-pictures/upload", + customPicturesUpload.single("customPictureUpload"), + async (req, res) => { + if (loadedSettings.password !== undefined && !userData.valid) { + return res.redirect(302, BASEURL + "/logon"); + } + try { + if (!req.file) { + throw new Error("Choose an image file to upload"); + } + const originalName = String(req.file.originalname || "").trim(); + const ext = path.extname(originalName).toLowerCase(); + if (![".jpg", ".jpeg", ".png", ".gif", ".webp"].includes(ext)) { + throw new Error("Only JPG, PNG, GIF, and WEBP files are supported"); + } + const parsedName = path.parse(originalName); + const safeBaseName = String(parsedName.name || "") + .replace(/[^\w.\- ]/g, "") + .trim(); + if (!safeBaseName) { + throw new Error("Upload filename is invalid"); + } + + const { fullPath } = resolveCustomPicturesTargetDirectory( + req.body.customPictureTheme, + req.body.customPictureNewFolder + ); + fs.mkdirSync(fullPath, { recursive: true }); + const finalPath = path.resolve(fullPath, safeBaseName + ext); + if (!finalPath.startsWith(fullPath)) { + throw new Error("Invalid upload target"); + } + await fs.promises.writeFile(finalPath, req.file.buffer); + customPicFolders = getDirectories(CUSTOM_PICTURES_ROOT); + req.session.success = true; + req.session.errors = []; + } catch (e) { + req.session.success = false; + req.session.errors = [ + { msg: e && e.message ? e.message : "Could not upload picture" }, + ]; + } + return res.redirect(302, BASEURL + "/settings"); + } +); + app.post( BASEURL + "/settings", [ @@ -2708,6 +5298,7 @@ app.post( plexHTTPSSwitch: req.body.plexHTTPSSwitch, plexPort: req.body.plexPort ? parseInt(req.body.plexPort) : DEFAULT_SETTINGS.plexPort, plexLibraries: req.body.plexLibraries, + onDemand3dLibraries: req.body.onDemand3dLibraries, pinNSSwitch: req.body.pinNSSwitch, hideUser: req.body.hideUser, numberOnDemand: !isNaN(parseInt(req.body.numberOnDemand)) ? parseInt(req.body.numberOnDemand) : DEFAULT_SETTINGS.numberOnDemand, @@ -2719,7 +5310,7 @@ app.post( sonarrUrl: req.body.sonarrUrl, sonarrToken: req.body.sonarrToken, sonarrDays: req.body.sonarrDays ? parseInt(req.body.sonarrDays) : DEFAULT_SETTINGS.sonarrCalDays, - premiereSwitch: req.body.premiereSwitch, + premiereSwitch: req.body.premiereSwitch ? "true" : "false", radarrUrl: req.body.radarrUrl, radarrToken: req.body.radarrToken, radarrDays: req.body.radarrDays ? parseInt(req.body.radarrDays) : DEFAULT_SETTINGS.radarrCalDays, @@ -2744,6 +5335,21 @@ app.post( footColour: req.body.footColour ? req.body.footColour : DEFAULT_SETTINGS.footColour, bgColour: req.body.bgColour ? req.body.bgColour : DEFAULT_SETTINGS.bgColour, enableNS: req.body.enableNS, + nowPlayingEveryPosters: (() => { + const raw = req.body.nowPlayingEveryPosters; + if (raw === undefined || raw === null || raw === "") return 0; + const n = parseInt(raw, 10); + return isNaN(n) ? 0 : Math.max(0, n); + })(), + enableNowShowingListInPoster: req.body.enableNowShowingListInPoster ? "true" : "false", + nowShowingListOnly: req.body.nowShowingListOnly ? "true" : "false", + nowShowingListEveryMins: (() => { + const raw = req.body.nowShowingListEveryMins; + if (raw === undefined || raw === null || raw === "") return 0; + const n = parseInt(raw, 10); + return isNaN(n) ? 0 : Math.max(0, n); + })(), + nowShowingListBanner: String(req.body.nowShowingListBanner || "").trim(), enableOD: req.body.enableOD, enableSonarr: req.body.enableSonarr, enableRadarr: req.body.enableRadarr, @@ -2755,22 +5361,77 @@ app.post( filterUsers: req.body.filterUsers, odHideTitle: req.body.odHideTitle, odHideFooter: req.body.odHideFooter, - showCast: req.body.showCast, - showDirectors: req.body.showDirectors, - showAuthors: req.body.showAuthors, - showAlbumArtist: req.body.showAlbumArtist, - displayPosterAlbum: req.body.displayPosterAlbum, - displayPosterVideo: req.body.displayPosterVideo, - displayPosterBooks: req.body.displayPosterBooks, + showCast: req.body.showCast ? "true" : "false", + showDirectors: req.body.showDirectors ? "true" : "false", + showAuthors: req.body.showAuthors ? "true" : "false", + showAlbumArtist: req.body.showAlbumArtist ? "true" : "false", + displayPosterAlbum: req.body.displayPosterAlbum ? "true" : "false", + displayPosterVideo: req.body.displayPosterVideo ? "true" : "false", + displayPosterBooks: req.body.displayPosterBooks ? "true" : "false", displayPosterCast: req.body.displayPosterCast, displayPosterActor: req.body.displayPosterCast ? "true" : "false", displayPosterActress: req.body.displayPosterCast ? "true" : "false", - displayPosterDirector: req.body.displayPosterDirector, - displayPosterAuthor: req.body.displayPosterAuthor, - displayPosterArtist: req.body.displayPosterArtist, + displayPosterDirector: req.body.displayPosterDirector ? "true" : "false", + displayPosterAuthor: req.body.displayPosterAuthor ? "true" : "false", + displayPosterArtist: req.body.displayPosterArtist ? "true" : "false", enableCustomPictures: req.body.enableCustomPictures, enableCustomPictureThemes: req.body.enableCustomPictureThemes, - customPictureTheme: req.body.customPictureTheme ? req.body.customPictureTheme : DEFAULT_SETTINGS.customPictureTheme, + customPictureTheme: normalizeCustomPictureThemeSelection( + req.body.customPictureTheme + ), + customPictureEveryPosters: (() => { + const raw = req.body.customPictureEveryPosters; + if (raw === undefined || raw === null || raw === "") return 0; + const n = parseInt(raw, 10); + return isNaN(n) ? 0 : Math.max(0, n); + })(), + enableAds: req.body.enableAds, + adsOnly: + req.body.adsOnly !== undefined + ? req.body.adsOnly + : loadedSettings.adsOnly, + adsTitleOutline: + req.body.adsTitleOutline !== undefined + ? req.body.adsTitleOutline + : loadedSettings.adsTitleOutline, + adsTheme: req.body.adsTheme ? req.body.adsTheme : DEFAULT_SETTINGS.adsTheme, + adsEveryPosters: (() => { + const raw = req.body.adsEveryPosters; + if (raw === undefined || raw === null || raw === "") return 0; + const n = parseInt(raw, 10); + return isNaN(n) ? 0 : Math.max(0, n); + })(), + adsRotationSeconds: (() => { + const raw = req.body.adsRotationSeconds; + if (raw === undefined || raw === null || raw === "") { + return loadedSettings.adsRotationSeconds !== undefined && + loadedSettings.adsRotationSeconds !== null + ? loadedSettings.adsRotationSeconds + : DEFAULT_SETTINGS.adsRotationSeconds; + } + const n = parseInt(raw, 10); + return isNaN(n) + ? DEFAULT_SETTINGS.adsRotationSeconds + : Math.min(600, Math.max(3, n)); + })(), + adsPageStaySeconds: (() => { + const raw = req.body.adsPageStaySeconds; + if (raw === undefined || raw === null || String(raw).trim() === "") { + return loadedSettings.adsPageStaySeconds !== undefined && + loadedSettings.adsPageStaySeconds !== null + ? loadedSettings.adsPageStaySeconds + : DEFAULT_SETTINGS.adsPageStaySeconds; + } + const n = parseInt(raw, 10); + if (isNaN(n) || n <= 0) return 0; + return Math.min(86400, Math.max(30, n)); + })(), + adsGlobalBackgroundPath: + req.body.adsGlobalBackgroundPath !== undefined + ? String(req.body.adsGlobalBackgroundPath || "").trim() + : loadedSettings.adsGlobalBackgroundPath !== undefined + ? loadedSettings.adsGlobalBackgroundPath + : "", customPicFolders: customPicFolders, serverID: loadedSettings.serverID, updateAvailable: updateAvailable, @@ -2839,12 +5500,14 @@ app.post( errors: req.session.errors, user: { valid: true }, formData: form, + settings: loadedSettings, version: pjson.version, baseUrl: BASEURL, customPicFolders: customPicFolders, latestVersion: latestVersion, message: message, - cacheClearNotice: null + cacheClearNotice: null, + ...newFeaturesBannerViewData(), }); } else { // save settings @@ -2857,12 +5520,14 @@ app.post( version: pjson.version, user: { valid: true }, formData: form, + settings: loadedSettings, baseUrl: BASEURL, customPicFolders: customPicFolders, latestVersion: latestVersion, message: message, updateAvailable: updateAvailable, - cacheClearNotice: null + cacheClearNotice: null, + ...newFeaturesBannerViewData(), }); } } diff --git a/launch-posterr.bat b/launch-posterr.bat new file mode 100644 index 0000000..a82a8ac --- /dev/null +++ b/launch-posterr.bat @@ -0,0 +1,7 @@ +@echo off +setlocal + +set "SCRIPT_DIR=%~dp0" +powershell -ExecutionPolicy Bypass -File "%SCRIPT_DIR%launch-posterr.ps1" + +endlocal diff --git a/launch-posterr.ps1 b/launch-posterr.ps1 new file mode 100644 index 0000000..80d804c --- /dev/null +++ b/launch-posterr.ps1 @@ -0,0 +1,21 @@ +$ErrorActionPreference = "Stop" + +Write-Host "Configuring persistent Jellyfin sync environment variables..." +setx POSTERR_JELLYFIN_LIBRARY_FIRST_PAGE_LIMIT "60" | Out-Null +setx POSTERR_JELLYFIN_LIBRARY_FIRST_PAGE_FALLBACK_LIMIT "20" | Out-Null +setx POSTERR_JELLYFIN_LIBRARY_FIRST_PAGE_TIMEOUT_MS "45000" | Out-Null +setx POSTERR_JELLYFIN_LIBRARY_PAGE_LIMIT "150" | Out-Null +setx POSTERR_SYNC_DEBUG "true" | Out-Null + +# Also set current-process values so this launch uses them immediately. +$env:POSTERR_JELLYFIN_LIBRARY_FIRST_PAGE_LIMIT = "60" +$env:POSTERR_JELLYFIN_LIBRARY_FIRST_PAGE_FALLBACK_LIMIT = "20" +$env:POSTERR_JELLYFIN_LIBRARY_FIRST_PAGE_TIMEOUT_MS = "45000" +$env:POSTERR_JELLYFIN_LIBRARY_PAGE_LIMIT = "150" +$env:POSTERR_SYNC_DEBUG = "true" + +$repoRoot = Split-Path -Parent $MyInvocation.MyCommand.Path +Set-Location $repoRoot + +Write-Host "Starting Posterr from $repoRoot ..." +node ".\index.js" diff --git a/launch-posterr.sh b/launch-posterr.sh new file mode 100644 index 0000000..e083a62 --- /dev/null +++ b/launch-posterr.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env sh +set -eu + +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +cd "$SCRIPT_DIR" + +export POSTERR_JELLYFIN_LIBRARY_FIRST_PAGE_LIMIT="60" +export POSTERR_JELLYFIN_LIBRARY_FIRST_PAGE_FALLBACK_LIMIT="20" +export POSTERR_JELLYFIN_LIBRARY_FIRST_PAGE_TIMEOUT_MS="45000" +export POSTERR_JELLYFIN_LIBRARY_PAGE_LIMIT="150" +export POSTERR_SYNC_DEBUG="true" + +echo "Starting Posterr from $SCRIPT_DIR ..." +node "./index.js" diff --git a/myviews/ads.ejs b/myviews/ads.ejs new file mode 100644 index 0000000..8079435 --- /dev/null +++ b/myviews/ads.ejs @@ -0,0 +1,529 @@ + + + + + + + Posterr - Ads + + + + + + + <%- include('new-features-banner.ejs') %> +
+

+
+
+ + + diff --git a/myviews/header.ejs b/myviews/header.ejs index f1d6df8..fc5e239 100644 --- a/myviews/header.ejs +++ b/myviews/header.ejs @@ -52,3 +52,4 @@ +<%- include('new-features-banner.ejs') %> diff --git a/myviews/index.ejs b/myviews/index.ejs index 938944a..7e14856 100644 --- a/myviews/index.ejs +++ b/myviews/index.ejs @@ -1,3 +1,10 @@ -<%- include('header.ejs'), {baseUrl, globals} %> -<%- include('posters/index.ejs', {globals, hasConfig, baseUrl}) %> +<%- include('header.ejs', { baseUrl, globals, showNewFeaturesBanner, appVersion }) %> +<%- include('posters/index.ejs', { + globals, + hasConfig, + baseUrl, + nowShowingPageCycleEnabled: typeof nowShowingPageCycleEnabled !== 'undefined' ? nowShowingPageCycleEnabled : 'false', + nowShowingPageCycleEveryMins: typeof nowShowingPageCycleEveryMins !== 'undefined' ? nowShowingPageCycleEveryMins : 30, + nowShowingPageCycleStayMins: typeof nowShowingPageCycleStayMins !== 'undefined' ? nowShowingPageCycleStayMins : 5 +}) %> <%- include('footer.ejs') %> diff --git a/myviews/new-features-banner.ejs b/myviews/new-features-banner.ejs new file mode 100644 index 0000000..ee6a719 --- /dev/null +++ b/myviews/new-features-banner.ejs @@ -0,0 +1,5 @@ +<% if (typeof showNewFeaturesBanner !== 'undefined' && showNewFeaturesBanner) { %> +
+ New features have been added (v<%= typeof appVersion !== 'undefined' ? appVersion : '' %>). Open Settings and click “Acknowledge there are new features” to dismiss until the next version. +
+<% } %> diff --git a/myviews/now-showing.ejs b/myviews/now-showing.ejs new file mode 100644 index 0000000..0bc2bb7 --- /dev/null +++ b/myviews/now-showing.ejs @@ -0,0 +1,1546 @@ + + + + + + + Posterr - Now Showing + + + + + + <% if (typeof custBrand !== "undefined" && custBrand != null && String(custBrand).trim() !== "") { %> + + <% } %> + <% if (typeof titleColour !== "undefined" && titleColour != null && String(titleColour).trim() !== "") { %> + + <% } %> + + + <%- include('new-features-banner.ejs') %> +
+ +

Now Showing

+
+ +
+ +
+ +
+ +
+ + + + diff --git a/myviews/posters/posters.ejs b/myviews/posters/posters.ejs index 2398dfd..5fc9649 100644 --- a/myviews/posters/posters.ejs +++ b/myviews/posters/posters.ejs @@ -122,6 +122,7 @@ function startInactivityCheck(element, delay, callbackInactivity, callbackActive var connCheckClock; var connOk = true; var sleeping = false; + var nowShowingCycleTimer = null; var counter; var curPlayID = 1; var pauseDetect = false; @@ -397,6 +398,14 @@ function startInactivityCheck(element, delay, callbackInactivity, callbackActive // initial load of cards and connection check timer every 5 seconds setInterval(connCheck,5000); getCards(); + var nsCycleEnabled = <%- JSON.stringify(String(typeof nowShowingPageCycleEnabled !== "undefined" ? nowShowingPageCycleEnabled : "false")) %> === "true"; + var nsCycleEveryMins = parseInt(<%- JSON.stringify(String(typeof nowShowingPageCycleEveryMins !== "undefined" ? nowShowingPageCycleEveryMins : "30")) %>, 10); + if (nsCycleEnabled && Number.isFinite(nsCycleEveryMins) && nsCycleEveryMins > 0) { + var ms = nsCycleEveryMins * 60 * 1000; + nowShowingCycleTimer = setTimeout(function () { + window.location.href = "<%=baseUrl%>/now-showing"; + }, ms); + } }); async function sleep(ms) { diff --git a/myviews/settings-ads.ejs b/myviews/settings-ads.ejs new file mode 100644 index 0000000..2fe303c --- /dev/null +++ b/myviews/settings-ads.ejs @@ -0,0 +1,427 @@ + + + + + + + + + + + + + + Posterr - ADS Settings + + + + <%if(!user.valid){%><%}%> + <%- include('new-features-banner.ejs') %> +
+ +
+
+ +
+
+
+
+ +
+
+ <% if (typeof notice !== "undefined" && notice && notice.text) { %> +
"><%= notice.text %>
+ <% } %> +

ADS settings

+

Dedicated ADS configuration page. Upload and manage ad slides, including custom price lines.

+

Open dedicated Ads view

+
+
+
+ > + + Turns ad slides on/off in the main rotation. +
+
+
+
+ > + + When enabled, the home poster rotation shows only ad slides (if any ads exist). +
+
+
+
+ > + + Adds a dark stroke around the ad name on the dedicated /ads view so it stays readable on busy images. +
+
+
+ + + 0 disables spacing. Example: 5 means one ad appears after every 5 poster slides. +
+
+ + <% var _adsRot = (settings.adsRotationSeconds !== undefined && settings.adsRotationSeconds !== null) ? parseInt(settings.adsRotationSeconds, 10) : 10; if (isNaN(_adsRot)) _adsRot = 10; _adsRot = Math.min(600, Math.max(3, _adsRot)); %> + + How long each slide stays on the dedicated Ads page before advancing (3–600 seconds). Independent of the main poster slide duration. +
+
+ + <% var _adsPageStay = (settings.adsPageStaySeconds !== undefined && settings.adsPageStaySeconds !== null) ? parseInt(settings.adsPageStaySeconds, 10) : 0; if (isNaN(_adsPageStay)) _adsPageStay = 0; if (_adsPageStay > 0) _adsPageStay = Math.min(86400, Math.max(30, _adsPageStay)); %> + + 0 leaves the Ads page open until you navigate away. When set, the browser returns to the home posters (/) after this many seconds in total on /ads (30–86400 when non-zero). Separate from “seconds per ad,” which only advances the slideshow between slides. +
+
+ + + Used globally for all ad price rows. +
+ +
+
+
Dedicated /ads background
+

One full-page backdrop behind all slides on the dedicated Ads view only. Files are stored under config/ads-view and served at /custom/ads-view/.

+ <% var _adsGlobalBg = (settings.adsGlobalBackgroundPath && String(settings.adsGlobalBackgroundPath).trim().startsWith("/custom/ads-view/")) ? String(settings.adsGlobalBackgroundPath).trim() : ""; %> + <% if (_adsGlobalBg) { %> +
+ Current + <%= _adsGlobalBg %> + +
+ <% } %> +
+
+ + +
+ +
+ <% if (_adsGlobalBg) { %> +
+ +
+ <% } %> +
+
Add new ad
+
+
+ + +
+
+ + +
+
+ + +
+
+ Price lines + +
+
+
+ Price add-ons + +
+ Each add-on has a label and amount. On /ads and the main poster deck, add-ons use the same pill style as price lines (with a leading + on the amount). +
+ +
+
Saved ads
+
+ <% if (Array.isArray(adsItems) && adsItems.length) { %> + <% adsItems.forEach(function (ad) { %> +
+
+ + +
+
+ + + <% if (ad.mediaPath) { %> + Current file: <%= ad.mediaPath %> + <% } %> +
+
+ + + <% if (ad.backgroundMediaPath) { %> + Current background: <%= ad.backgroundMediaPath %> + <% } %> +
+
+ Price lines + +
+
+
+ Price add-ons + +
+
+
+ + +
+ + +
+ <% }); %> + <% } else { %> +

No ads saved yet.

+ <% } %> +
+
+
+
+
+
+ + + diff --git a/myviews/settings-cache.ejs b/myviews/settings-cache.ejs index fbdea6e..90fba6f 100644 --- a/myviews/settings-cache.ejs +++ b/myviews/settings-cache.ejs @@ -20,12 +20,42 @@ @media (min-width: 768px) { .cache-chart-wrap.wide { height: 320px; } } + /* Keep cache dashboard readable when a dark theme is active. */ + @media (prefers-color-scheme: dark) { + .card, + .card-body, + #cacheKindTable, + #posterDbTable, + #cacheStatusLine, + #cacheGenerated { + color: #f1f3f5; + } + .small.text-muted, + .text-muted { + color: #cfd4da !important; + } + #cacheKindTable.table-striped tbody tr:nth-of-type(odd), + #posterDbTable.table-striped tbody tr:nth-of-type(odd) { + background-color: rgba(255, 255, 255, 0.06); + } + #cacheSummary .bg-light { + background-color: rgba(255, 255, 255, 0.1) !important; + border-color: rgba(255, 255, 255, 0.2) !important; + } + #cacheKindTable td, + #cacheKindTable th, + #posterDbTable td, + #posterDbTable th { + border-color: rgba(255, 255, 255, 0.15); + } + } <%if(!user.valid){%><%}%> + <%- include('new-features-banner.ejs') %>
+ <% if (typeof showNewFeaturesBanner !== 'undefined' && showNewFeaturesBanner && user && user.valid) { %> +
+
+

Posterr v<%= typeof appVersion !== 'undefined' ? appVersion : '' %> includes changes you have not acknowledged yet. The red banner appears on the main display and other pages until you confirm below.

+
+ +
+
+
+ <% } %> <%if(typeof formData !== 'undefined' && formData.saved == true) {%><%}%> @@ -65,6 +76,15 @@ Sync + + Now Showing + + + Ads + + + TMDB API + Cache @@ -82,6 +102,10 @@
+
@@ -597,10 +636,10 @@
- + <%=settings.nowScreening%>"<%}%>> - Alternate text for 'Now Screening' + Alternate text for 'Now Playing'
@@ -731,7 +770,7 @@ value="<% if(typeof formData !== 'undefined' && errors && formData.cachedPosterSlideCount !== undefined){%><%=formData.cachedPosterSlideCount%><%}else{%><%=(settings.cachedPosterSlideCount != null ? settings.cachedPosterSlideCount : 48)%><%}%>"> How many random cached titles to include per refresh when “Prefer cached poster library” is enabled. There is no fixed upper limit; you cannot get more distinct slides than you have poster rows on disk.
-

Clear removes all files in config/cache/imagecache and the poster metadata database (config/cache/posterr-poster-metadata.db). Sonarr/Radarr/Lidarr/Readarr cached art in that folder is removed too.

+

Clear removes all files in config/cache/imagecache and the poster metadata database (config/posterr-poster-metadata.db). Sonarr/Radarr/Lidarr/Readarr cached art in that folder is removed too.

+
+
+ + + 0 disables spacing. Example: 5 means one custom picture appears after every 5 poster slides.
+

- - -
+
+ + + 0 disables spacing. Example: 5 means one Now Playing (media server) slide appears after every 5 other poster slides. Configure all TMDB Now Showing options in Now Showing settings. +
+
<%=formData.pinNSSwitch ? 'checked' : '' %><%}else{%><%=(settings.pinNS == 'true') ? 'checked' : '' %><%}%>> - + If something is playing, then hide on-demand and coming soon
@@ -851,7 +948,7 @@ <%=formData.enableAwtrix ? 'checked' : '' %><%}else{%><%=(settings.enableAwtrix == 'true') ? 'checked' : '' %><%}%>> (Awtrix docs) - Enable integration of Now Screening titles with devices running Awtrix. + Enable integration of Now Playing titles with devices running Awtrix. @@ -883,8 +980,7 @@ -
- +
+
+ + <%=settings.onDemand3dLibraries%>"<%}%>> + Comma-separated on-demand library names that should show a 3D pill on On-demand and /now-showing cards. +
<%=formData.premiereSwitch ? 'checked' : '' %> <%}else{%><%=(settings.sonarrPremieres == 'true') ? 'checked' : '' %><%}%>> + value="true" <% if(typeof formData !== 'undefined' && errors){%><%=(formData.premiereSwitch == 'true' || formData.premiereSwitch === true) ? 'checked' : '' %> <%}else{%><%=(settings.sonarrPremieres == 'true') ? 'checked' : '' %><%}%>> Only show season premieres.
@@ -1349,6 +1451,11 @@
ABOUT POSTERR
+
+

This build is a modified version of Posterr by binarygeek119.

+

GitHub — binarygeek119/posterr

+

This fork is purely AI-modified.

+

<%="Version: " + version%>

Support my efforts and continued development.
Buy me a coffee to say @@ -1375,7 +1482,8 @@

+ - <%- include('posterr-view-hotkeys.ejs', { baseUrl, viewHotkeysContext }) %> + <%- include('posterr-view-hotkeys.ejs', { baseUrl, viewHotkeysContext, viewHotkeysEnabled }) %> diff --git a/myviews/index.ejs b/myviews/index.ejs index 6ef63fd..e2fb3e4 100644 --- a/myviews/index.ejs +++ b/myviews/index.ejs @@ -1,11 +1,164 @@ -<%- include('header.ejs', { baseUrl, globals, showNewFeaturesBanner, appVersion }) %> -<%- include('posters/index.ejs', { - globals, - hasConfig, - baseUrl, - nowShowingPageCycleEnabled: typeof nowShowingPageCycleEnabled !== 'undefined' ? nowShowingPageCycleEnabled : 'false', - nowShowingPageCycleEveryMins: typeof nowShowingPageCycleEveryMins !== 'undefined' ? nowShowingPageCycleEveryMins : 30, - nowShowingPageCycleStayMins: typeof nowShowingPageCycleStayMins !== 'undefined' ? nowShowingPageCycleStayMins : 5, - homePageCycleDedicatedView: typeof homePageCycleDedicatedView !== 'undefined' ? homePageCycleDedicatedView : 'now-showing' -}) %> -<%- include('footer.ejs') %> + + + + + + + Posterr + + + + +
+ + + +
+ + + diff --git a/myviews/now-showing.ejs b/myviews/now-showing.ejs index 8e47201..27ed08e 100644 --- a/myviews/now-showing.ejs +++ b/myviews/now-showing.ejs @@ -597,7 +597,7 @@ ); } catch (e) {} } else { - window.location.href = <%- JSON.stringify(baseUrl + "/") %>; + window.location.href = <%- JSON.stringify(baseUrl + "/posters") %>; } }, nsCycleStayMins * 60 * 1000); } @@ -1553,6 +1553,6 @@ setInterval(loadMovies, 15000); })(); - <%- include('posterr-view-hotkeys.ejs', { baseUrl, viewHotkeysContext }) %> + <%- include('posterr-view-hotkeys.ejs', { baseUrl, viewHotkeysContext, viewHotkeysEnabled }) %> diff --git a/myviews/posterr-view-hotkeys.ejs b/myviews/posterr-view-hotkeys.ejs index 92fb0b5..de13d02 100644 --- a/myviews/posterr-view-hotkeys.ejs +++ b/myviews/posterr-view-hotkeys.ejs @@ -1,7 +1,9 @@ <% var _vhkCtx = typeof viewHotkeysContext !== "undefined" ? viewHotkeysContext : "now-showing-page"; %> +<% var _vhkEnabled = typeof viewHotkeysEnabled !== "undefined" ? String(viewHotkeysEnabled) === "true" : true; %> <%}%> -
@@ -103,6 +99,9 @@
+ <% if (typeof syncNotice !== "undefined" && syncNotice && syncNotice.text) { %> +
" role="alert"><%= syncNotice.text %>
+ <% } %>

Poster & library sync

+
+
+ +
+ Removes files in config/cache/imagecache and resets config/posterr-poster-metadata.db. +
<% var libList = typeof onDemandLibraryList !== 'undefined' && onDemandLibraryList ? onDemandLibraryList : []; %> <% if (libList.length > 1) { %> @@ -238,6 +243,7 @@ var serverEl = document.getElementById("syncServerType"); var startBtn = document.getElementById("syncStartBtn"); var abortBtn = document.getElementById("syncAbortBtn"); + var clearBtn = document.getElementById("syncClearCacheBtn"); var libWrap = document.getElementById("syncLibrariesWrap"); var libList = document.getElementById("syncLibrariesList"); @@ -342,12 +348,14 @@ renderLibraries([], false); startBtn.disabled = false; if (abortBtn) abortBtn.disabled = true; + if (clearBtn) clearBtn.disabled = false; setLibButtonsDisabled(false); setTimeout(poll, pollMs * 2); return; } startBtn.disabled = data.status === "running"; if (abortBtn) abortBtn.disabled = data.status !== "running"; + if (clearBtn) clearBtn.disabled = data.status === "running"; setLibButtonsDisabled(data.status === "running"); if (data.indeterminate) { diff --git a/myviews/settings.ejs b/myviews/settings.ejs index 2c0ea9c..45c5d36 100644 --- a/myviews/settings.ejs +++ b/myviews/settings.ejs @@ -443,6 +443,14 @@ Rotate screen orientation -90 degrees (ideal for Amazon Firestick devices)
+
+
+ <%=formData.enableViewHotkeys ? 'checked' : '' %><%}else{%><%=(settings.enableViewHotkeys == 'true') ? 'checked' : '' %><%}%>> + + Global shortcuts: 1 = posters, 2 = ads, 3 = now showing (home hub and dedicated views). +
+
Colours
From d6f65bc62b578bd7623004ec2212878cf13c417c Mon Sep 17 00:00:00 2001 From: binarygeek119 Date: Wed, 15 Apr 2026 22:18:43 -0500 Subject: [PATCH 07/24] Fix Docker testing workflow branch triggers Made-with: Cursor --- .github/workflows/testing.yml | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 9249f76..b60d51e 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -1,9 +1,9 @@ name: Build Docker image and then push to DockerHub on: push: - branches: [ testing ] + branches: [ testing, Testing ] pull_request: - branches: [ testing ] + branches: [ testing, Testing ] # Allows you to run this workflow manually from the Actions tab workflow_dispatch: @@ -14,10 +14,11 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Login to Docker Hub + if: github.event_name != 'pull_request' uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_USERNAME }} @@ -26,13 +27,7 @@ jobs: uses: docker/build-push-action@v5 with: context: . - push: true + push: ${{ github.event_name != 'pull_request' }} tags: | binarygeek119/posterr:testing binarygeek119/posterr:${{ github.run_number }} - - name: Run a one-line script - run: echo Hello, world! - - name: Run a multi-line script - run: | - echo Add other actions to build, - echo test, and deploy your project. From a7cc1b804a5e0e468f95741740e3637a61cc1540 Mon Sep 17 00:00:00 2001 From: binarygeek119 Date: Wed, 15 Apr 2026 22:25:37 -0500 Subject: [PATCH 08/24] Update runtime app lock metadata Made-with: Cursor --- config/posterr-app.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/posterr-app.lock b/config/posterr-app.lock index 2095d3a..e8ad2d5 100644 --- a/config/posterr-app.lock +++ b/config/posterr-app.lock @@ -1 +1 @@ -{"pid":9748,"startedAt":"2026-04-15T23:47:50.438Z"} \ No newline at end of file +{"pid":1224,"startedAt":"2026-04-16T03:25:26.471Z"} \ No newline at end of file From 968af7cd88253f54f1b66e46454591d2a9f85bf1 Mon Sep 17 00:00:00 2001 From: binarygeek119 Date: Wed, 15 Apr 2026 23:37:52 -0500 Subject: [PATCH 09/24] Home hub: lazy-load dedicated views, posters-only on startup Made-with: Cursor --- myviews/index.ejs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/myviews/index.ejs b/myviews/index.ejs index e2fb3e4..6ae2d46 100644 --- a/myviews/index.ejs +++ b/myviews/index.ejs @@ -78,6 +78,15 @@ function activateView(view) { activeView = view === "now-showing" || view === "ads" ? view : "posters"; + if (activeView === "now-showing") { + ensureSrc(nowShowingFrame, nowShowingUrl); + } else if (activeView === "ads") { + ensureSrc(adsFrame, adsUrl); + } else { + /* Posters-only startup: unload dedicated views so nothing from /ads or /now-showing runs in the background. */ + if (nowShowingFrame) nowShowingFrame.setAttribute("src", "about:blank"); + if (adsFrame) adsFrame.setAttribute("src", "about:blank"); + } postersFrame.classList.toggle("is-active", activeView === "posters"); nowShowingFrame.classList.toggle("is-active", activeView === "now-showing"); adsFrame.classList.toggle("is-active", activeView === "ads"); @@ -123,8 +132,8 @@ var nowShowingUrl = baseUrl + "/now-showing?cycleEmbed=1"; var adsUrl = baseUrl + "/ads?cycleEmbed=1&cycleStayMins=" + encodeURIComponent(String(Number.isFinite(nsCycleStayMins) && nsCycleStayMins > 0 ? nsCycleStayMins : 5)); ensureSrc(postersFrame, postersUrl); - ensureSrc(nowShowingFrame, nowShowingUrl); - ensureSrc(adsFrame, adsUrl); + if (nowShowingFrame) nowShowingFrame.setAttribute("src", "about:blank"); + if (adsFrame) adsFrame.setAttribute("src", "about:blank"); activateView("posters"); scheduleCycleOpen(); From 99777d22aea9747cab578b9390b352fc343c3321 Mon Sep 17 00:00:00 2001 From: binarygeek119 Date: Wed, 15 Apr 2026 23:52:31 -0500 Subject: [PATCH 10/24] Warm-render cached poster deck for faster /posters first paint Made-with: Cursor --- index.js | 77 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/index.js b/index.js index f7dec83..45315fe 100644 --- a/index.js +++ b/index.js @@ -1396,6 +1396,80 @@ function buildLibrarySlideDeckFromPosterCache() { return odCards; } +/** + * Render cached poster-library slides immediately so /getcards can respond before + * Now Playing / on-demand network work finishes (first paint on /posters). + */ +async function warmCachedPosterDeckEarlyIfPossible() { + if (!loadedSettings || !preferCachedPostersEnabled()) return; + if (!cachedPosterDbHasRows()) return; + const kind = getMediaServerKind(loadedSettings.mediaServerType); + const warmCount = Math.min(12, primaryCachedPosterSlideCount()); + const cached = posterMetadata.buildFallbackMediaCards(warmCount, kind); + if (!cached.length) return; + globalPage.cards = cached.slice(); + try { + await globalPage.OrderAndRenderCards( + BASEURL, + loadedSettings.hasArt, + loadedSettings.odHideTitle, + loadedSettings.odHideFooter, + loadedSettings.showCast !== undefined ? loadedSettings.showCast : "false", + loadedSettings.showDirectors !== undefined + ? loadedSettings.showDirectors + : "false", + loadedSettings.showAuthors !== undefined ? loadedSettings.showAuthors : "false", + loadedSettings.showAlbumArtist !== undefined + ? loadedSettings.showAlbumArtist + : "false", + loadedSettings.displayPosterAlbum !== undefined + ? loadedSettings.displayPosterAlbum + : "true", + loadedSettings.displayPosterVideo !== undefined + ? loadedSettings.displayPosterVideo + : "true", + loadedSettings.displayPosterBooks !== undefined + ? loadedSettings.displayPosterBooks + : "true", + loadedSettings.displayPosterActor !== undefined + ? loadedSettings.displayPosterActor + : "false", + loadedSettings.displayPosterActress !== undefined + ? loadedSettings.displayPosterActress + : "false", + loadedSettings.displayPosterDirector !== undefined + ? loadedSettings.displayPosterDirector + : "false", + loadedSettings.displayPosterAuthor !== undefined + ? loadedSettings.displayPosterAuthor + : "false", + loadedSettings.displayPosterArtist !== undefined + ? loadedSettings.displayPosterArtist + : "false" + ); + globalPage.slideDuration = loadedSettings.slideDuration * 1000; + globalPage.playThemes = loadedSettings.playThemes; + globalPage.playGenericThemes = loadedSettings.genericThemes; + globalPage.fadeTransition = + loadedSettings.fade == "true" ? "carousel-fade" : ""; + globalPage.custBrand = loadedSettings.custBrand; + globalPage.titleColour = loadedSettings.titleColour; + globalPage.footColour = loadedSettings.footColour; + globalPage.bgColour = loadedSettings.bgColour; + globalPage.hasArt = loadedSettings.hasArt; + globalPage.quizTime = + loadedSettings.triviaTimer !== undefined ? loadedSettings.triviaTimer : 15; + globalPage.hideSettingsLinks = + loadedSettings.hideSettingsLinks !== undefined + ? loadedSettings.hideSettingsLinks + : "false"; + globalPage.rotate = + loadedSettings.rotate !== undefined ? loadedSettings.rotate : "false"; + } catch (e) { + /* non-fatal: full deck build in loadNowScreening will retry */ + } +} + /** * @desc Wrapper function to call now screening method. * @returns {Promise} mediaCards array - results of now screening search @@ -3012,6 +3086,9 @@ async function startup(clearCache) { if (isTriviaEnabled) await loadTrivia(); if (isLinksEnabled) await loadLinks(); + // First paint: serve cached poster-library HTML before Now Playing blocks on the media server. + await warmCachedPosterDeckEarlyIfPossible(); + // Build homepage deck before optional Awtrix (network) work so /getcards can serve cached posters sooner. await loadNowScreening(); From d89c2ce383aaeb04f45a35bcc4cebecc0d5dc4d8 Mon Sep 17 00:00:00 2001 From: binarygeek119 Date: Thu, 16 Apr 2026 00:11:10 -0500 Subject: [PATCH 11/24] Improve homepage resiliency after long idle periods in Docker. Add timeout and retry handling for poster card fetches so transient network stalls do not leave the home screen black, and update fork support messaging in README and settings. Made-with: Cursor --- README.md | 10 ++++--- myviews/posters/posters.ejs | 52 ++++++++++++++++++++++++++++++++++--- myviews/settings.ejs | 7 ++--- 3 files changed, 60 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 077569c..e10cd97 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,10 @@ ![Slides](https://github.com/petersem/posterr/blob/master/doco/posterr.jpg?raw=true) ![Awtrix](https://github.com/petersem/posterr/blob/master/doco/awtrix.gif?raw=true) -**Documentation and community (upstream):** [Latest changes](https://github.com/petersem/posterr/wiki/Latest-changes) · [Known issues](https://github.com/petersem/posterr/wiki/Known-Issues) · [Discord](https://discord.gg/TcnEkMEf9J) (limited support). **Default settings password:** `raidisnotabackup` +Join the Posterr Discord channel to get updates and limited support: [https://discord.gg/TcnEkMEf9J](https://discord.gg/TcnEkMEf9J). +Please do not ask the original developer for support when using this fork version of Posterr. +For fork support, use GitHub: [https://github.com/binarygeek119/posterr](https://github.com/binarygeek119/posterr). +**Default settings password:** `raidisnotabackup` --- ## Features @@ -234,8 +237,9 @@ Should you encounter a problem, the solution may be listed [HERE](https://github --- ## Support - - **This fork:** open an issue on [binarygeek119/posterr](https://github.com/binarygeek119/posterr/issues). - - **Upstream Posterr:** [Discord](https://discord.gg/TcnEkMEf9J) (limited); issues on [petersem/posterr](https://github.com/petersem/posterr/issues) for vanilla Posterr. + - Join the Posterr Discord channel to get updates and limited support: [https://discord.gg/TcnEkMEf9J](https://discord.gg/TcnEkMEf9J). + - Do not ask the original developer for support when using this fork version of Posterr. + - For fork support, use GitHub: [https://github.com/binarygeek119/posterr](https://github.com/binarygeek119/posterr). --- ### Support my efforts and continued development diff --git a/myviews/posters/posters.ejs b/myviews/posters/posters.ejs index bae4b51..4c55582 100644 --- a/myviews/posters/posters.ejs +++ b/myviews/posters/posters.ejs @@ -155,6 +155,8 @@ function startInactivityCheck(element, delay, callbackInactivity, callbackActive var connCheckClock; var connOk = true; var sleeping = false; + var getCardsRetryTimer = null; + var getCardsFailCount = 0; var nowShowingCycleTimer = null; var counter; var curPlayID = 1; @@ -177,6 +179,15 @@ function startInactivityCheck(element, delay, callbackInactivity, callbackActive $("body").css("background-color","black"); } + function queueGetCardsRetry(delayMs) { + if (getCardsRetryTimer) return; + var wait = Math.max(1000, parseInt(delayMs, 10) || 2000); + getCardsRetryTimer = setTimeout(function () { + getCardsRetryTimer = null; + getCards(); + }, wait); + } + function shutdown(){ // shutdown slides, audio, and then hide screen if(sleeping !== true){ @@ -205,6 +216,7 @@ function startInactivityCheck(element, delay, callbackInactivity, callbackActive const xhr = new XMLHttpRequest(); xhr.open('GET', "<%=baseUrl%>/conncheck"); + xhr.timeout = 5000; // set response format xhr.responseType = 'json'; xhr.send(); @@ -213,12 +225,26 @@ function startInactivityCheck(element, delay, callbackInactivity, callbackActive console.log('Conection failed. Trying to restore connection.') connOk = false; noConnection(); + queueGetCardsRetry(2000); + } + xhr.ontimeout = () => { + console.log('Connection check timed out. Trying to restore connection.') + connOk = false; + noConnection(); + queueGetCardsRetry(2000); } xhr.onload = () => { // check that there hasn't been rapid a cold restart of posterr that this connection check missed const res = xhr.response; - if(res==null && sleeping !== true) noConnection(); + if(!res){ + if (sleeping !== true) { + connOk = false; + noConnection(); + queueGetCardsRetry(2000); + } + return; + } let serverDate = new Date(res.status); // enable overlay if sleep signalled if(res.sleep=="true") { @@ -268,16 +294,35 @@ function startInactivityCheck(element, delay, callbackInactivity, callbackActive //console.log('getting cards'); const xhr = new XMLHttpRequest(); xhr.open('GET', getCardsPath); + xhr.timeout = 10000; // set response format xhr.responseType = 'json'; xhr.send(); + xhr.onerror = () => { + getCardsFailCount++; + noConnection(); + queueGetCardsRetry(Math.min(10000, 1500 * Math.max(1, getCardsFailCount))); + }; + xhr.ontimeout = () => { + getCardsFailCount++; + noConnection(); + queueGetCardsRetry(Math.min(10000, 1500 * Math.max(1, getCardsFailCount))); + }; + xhr.onload = () => { // get JSON response // console.log('got response'); const posterr = xhr.response; - if((!posterr == null || posterr.globalPage.cards.length !== 0) && sleeping !== "true") { + if ( + posterr && + posterr.globalPage && + Array.isArray(posterr.globalPage.cards) && + posterr.globalPage.cards.length !== 0 && + sleeping !== true + ) { + getCardsFailCount = 0; let cardHtml = ""; posterr.globalPage.cards.forEach(card => { cardHtml += card.rendered; @@ -292,7 +337,8 @@ function startInactivityCheck(element, delay, callbackInactivity, callbackActive } } else { - location.reload(); + getCardsFailCount++; + queueGetCardsRetry(Math.min(10000, 1500 * Math.max(1, getCardsFailCount))); } } } diff --git a/myviews/settings.ejs b/myviews/settings.ejs index 45c5d36..8abe3f2 100644 --- a/myviews/settings.ejs +++ b/myviews/settings.ejs @@ -1490,13 +1490,14 @@

From 1ec8a438fdffc6f92b1c4c60093af1902b0c1e16 Mon Sep 17 00:00:00 2001 From: binarygeek119 Date: Thu, 16 Apr 2026 00:28:35 -0500 Subject: [PATCH 12/24] Fix hub-embedded posters startup crash. Define getCardsPath at script scope so initial card loading works in /posters?hubEmbed=1, preventing black-screen startup and restoring hotkeys on first load. Made-with: Cursor --- myviews/posters/posters.ejs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/myviews/posters/posters.ejs b/myviews/posters/posters.ejs index 4c55582..6b4786b 100644 --- a/myviews/posters/posters.ejs +++ b/myviews/posters/posters.ejs @@ -155,6 +155,7 @@ function startInactivityCheck(element, delay, callbackInactivity, callbackActive var connCheckClock; var connOk = true; var sleeping = false; + var getCardsPath = "<%=baseUrl%>/getcards"; var getCardsRetryTimer = null; var getCardsFailCount = 0; var nowShowingCycleTimer = null; @@ -477,7 +478,7 @@ function startInactivityCheck(element, delay, callbackInactivity, callbackActive var postersQs = new URLSearchParams(window.location.search || ""); var inHubEmbed = postersQs.get("hubEmbed") === "1"; var isStandalonePosters = !inHubEmbed; - var getCardsPath = "<%=baseUrl%>/getcards" + (inHubEmbed ? "?postersOnly=1" : ""); + getCardsPath = "<%=baseUrl%>/getcards" + (inHubEmbed ? "?postersOnly=1" : ""); var viewHotkeysEnabled = <%- JSON.stringify(String(typeof viewHotkeysEnabled !== "undefined" ? viewHotkeysEnabled : "true")) %> === "true"; // initial load of cards and connection check timer every 5 seconds setInterval(connCheck,5000); From 238b2a9e9c34b2e3446b10678930d29983db91b3 Mon Sep 17 00:00:00 2001 From: binarygeek119 Date: Thu, 16 Apr 2026 00:38:23 -0500 Subject: [PATCH 13/24] Harden Docker homepage startup against missing custom CSS and stale path state. Auto-create public/custom/custom.css on startup so mounted Docker custom folders do not return HTML for CSS requests, and add a safe getCardsPath resolver in posters view to prevent hub-embed startup crashes. Made-with: Cursor --- index.js | 4 ++++ myviews/posters/posters.ejs | 11 +++++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/index.js b/index.js index 45315fe..4b838c1 100644 --- a/index.js +++ b/index.js @@ -537,6 +537,10 @@ var dir = "./public/custom"; if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } +const customCssPath = path.join(dir, "custom.css"); +if (!fs.existsSync(customCssPath)) { + fs.writeFileSync(customCssPath, ""); +} fs.mkdirSync(CUSTOM_PICTURES_ROOT, { recursive: true }); fs.mkdirSync(path.join(CUSTOM_PICTURES_ROOT, "default"), { recursive: true }); diff --git a/myviews/posters/posters.ejs b/myviews/posters/posters.ejs index 6b4786b..21e2266 100644 --- a/myviews/posters/posters.ejs +++ b/myviews/posters/posters.ejs @@ -158,6 +158,13 @@ function startInactivityCheck(element, delay, callbackInactivity, callbackActive var getCardsPath = "<%=baseUrl%>/getcards"; var getCardsRetryTimer = null; var getCardsFailCount = 0; + function resolveGetCardsPath() { + if (typeof getCardsPath === "string" && getCardsPath.trim() !== "") { + return getCardsPath; + } + return "<%=baseUrl%>/getcards"; + } + var nowShowingCycleTimer = null; var counter; var curPlayID = 1; @@ -294,7 +301,7 @@ function startInactivityCheck(element, delay, callbackInactivity, callbackActive function getCards(){ //console.log('getting cards'); const xhr = new XMLHttpRequest(); - xhr.open('GET', getCardsPath); + xhr.open('GET', resolveGetCardsPath()); xhr.timeout = 10000; // set response format @@ -348,7 +355,7 @@ function startInactivityCheck(element, delay, callbackInactivity, callbackActive function updateFirstCard(){ //console.log('getting cards'); const xhr = new XMLHttpRequest(); - xhr.open('GET', getCardsPath); + xhr.open('GET', resolveGetCardsPath()); // set response format xhr.responseType = 'json'; From 4b60b5a6fe1190d028a6e7df08ba4af3624c0fb5 Mon Sep 17 00:00:00 2001 From: binarygeek119 Date: Thu, 16 Apr 2026 00:49:41 -0500 Subject: [PATCH 14/24] Honor home-only view priorities at startup. On /, start in now-showing when Only Now Showing slides is enabled, otherwise start in ads when Ads only mode is enabled, and keep posters-first behavior for all other cases. Made-with: Cursor --- index.js | 9 +++++++++ myviews/index.ejs | 14 ++++++++++++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/index.js b/index.js index 4b838c1..4067e71 100644 --- a/index.js +++ b/index.js @@ -3395,6 +3395,13 @@ app.get(BASEURL + "/", (req, res) => { const viewHotkeysEnabled = loadedSettings && String(loadedSettings.enableViewHotkeys).toLowerCase() === "true"; + const homeNowShowingOnly = + loadedSettings && + String(loadedSettings.nowShowingListOnly).toLowerCase() === "true"; + const homeAdsOnly = + loadedSettings && + String(loadedSettings.enableAds).toLowerCase() === "true" && + String(loadedSettings.adsOnly).toLowerCase() === "true"; const everyMins = loadedSettings && loadedSettings.nowShowingPageCycleEveryMins !== undefined ? loadedSettings.nowShowingPageCycleEveryMins @@ -3409,6 +3416,8 @@ app.get(BASEURL + "/", (req, res) => { nowShowingPageCycleEveryMins: everyMins, nowShowingPageCycleStayMins: stayMins, homePageCycleDedicatedView: homeCycleDedicatedView, + homeNowShowingOnly: homeNowShowingOnly ? "true" : "false", + homeAdsOnly: homeAdsOnly ? "true" : "false", viewHotkeysEnabled: viewHotkeysEnabled ? "true" : "false", ...newFeaturesBannerViewData(), }); diff --git a/myviews/index.ejs b/myviews/index.ejs index 6ae2d46..a519147 100644 --- a/myviews/index.ejs +++ b/myviews/index.ejs @@ -56,6 +56,8 @@ var nsCycleEveryMins = parseInt(<%- JSON.stringify(String(typeof nowShowingPageCycleEveryMins !== "undefined" ? nowShowingPageCycleEveryMins : "30")) %>, 10); var nsCycleStayMins = parseInt(<%- JSON.stringify(String(typeof nowShowingPageCycleStayMins !== "undefined" ? nowShowingPageCycleStayMins : "5")) %>, 10); var defaultDedicated = <%- JSON.stringify(String(typeof homePageCycleDedicatedView !== "undefined" ? homePageCycleDedicatedView : "now-showing")) %>; + var homeNowShowingOnly = <%- JSON.stringify(String(typeof homeNowShowingOnly !== "undefined" ? homeNowShowingOnly : "false")) %> === "true"; + var homeAdsOnly = <%- JSON.stringify(String(typeof homeAdsOnly !== "undefined" ? homeAdsOnly : "false")) %> === "true"; var HOME_CYCLE_EMBED_MSG = "posterr-home-cycle-embed-close"; var VIEW_SWITCH_MSG = "posterr-view-switch"; @@ -134,8 +136,16 @@ ensureSrc(postersFrame, postersUrl); if (nowShowingFrame) nowShowingFrame.setAttribute("src", "about:blank"); if (adsFrame) adsFrame.setAttribute("src", "about:blank"); - activateView("posters"); - scheduleCycleOpen(); + if (homeNowShowingOnly) { + activateView("now-showing"); + clearCycleTimer(); + } else if (homeAdsOnly) { + activateView("ads"); + clearCycleTimer(); + } else { + activateView("posters"); + scheduleCycleOpen(); + } window.addEventListener("message", function (ev) { if (ev.origin !== window.location.origin) return; From 1795f369a8372b3a2187e289e2f355caff4710b2 Mon Sep 17 00:00:00 2001 From: binarygeek119 Date: Thu, 16 Apr 2026 01:10:05 -0500 Subject: [PATCH 15/24] Add holiday settings page and harden fullscreen display behavior. This wires a new Holidays settings route into the navigation and updates fullscreen now-showing/ads pages so kiosk playback can auto-scroll while still allowing settings-link suppression when requested. Made-with: Cursor --- config/now-showing.db | Bin 28672 -> 28672 bytes config/posterr-app.lock | 2 +- index.js | 28 +++++++++++++ myviews/ads.ejs | 18 +++++++++ myviews/now-showing.ejs | 22 ++++++++++- myviews/settings-ads.ejs | 2 + myviews/settings-cache.ejs | 4 ++ myviews/settings-holidays.ejs | 66 +++++++++++++++++++++++++++++++ myviews/settings-now-showing.ejs | 2 + myviews/settings-sync.ejs | 6 +++ myviews/settings-tmdb.ejs | 2 + myviews/settings.ejs | 6 +++ 12 files changed, 156 insertions(+), 2 deletions(-) create mode 100644 myviews/settings-holidays.ejs diff --git a/config/now-showing.db b/config/now-showing.db index 09eb18edd4a43ca00d278df8bd02638987d71d99..90e2f3611d026155ee64cd2f06f04781ec3e1cc2 100644 GIT binary patch delta 1149 zcmZ`&L2DC16lQ6NRuDsC2~Bb_M`_~BWOue1YgJ5yF50b-2280j7dIwgdhs9>OphLv z>N|L;f`~sr7B7NO@#LYXSm;Htf?!Uf|G@WVHx|w2vdq3W?|t94yQ^#bDS;}Hc$f6Xy0nbtk@T)SJk}G+^ge)+#!10b$!UXOf$Ep+6 z41VXTnM1P1g|axs<9N0dgAI2IllOyUS@GGR$bVOTHGzpc3sHzUn7deb2i9ku2&GB{tI!p3Y?v5-8p zTmoNrGcjy$!tYL+aHJ~HUCiuI-XFMMWBBtv8HJXGdx;_}r6v>qAzE2gEsDUi}Pk2FECt)bXYgXDqOyA402!X#2j!;5RlNGhjS%UL0mT`{+Lwz?!F(x0N6}0~55+*y*iD&A)is zc?&)^wev_VaNaMpe;A}cu6{7I=tK{_-HyXnyEt}mdOIjGq1%en5pjQZ^xPbM4+2O8 z!#FYR2eHs$|8_U2rMHyef%LbcSq!@7*bUId_>yTHTa|`Bw|WG*-qVn@fma;ufDGRt gZDScWtLbT36CBr2F;--ZHS(+HPQF=tzIWuzAM?R8TL1t6 delta 1147 zcmZ`(L2DCH5N2tJwonX-B{a#!9HoixZT9Up&x(qP&_%ly(ts&8=HjLam|i>x1=FJk zr8);MRS@w9$l^s1DxN$P6$LMX6$En<{Rhsxx3So4F3a1Qo%z1`zIofX@jROma(FX3@6G4S1Sbgx{6PlvvH1%(M&4DsWL_wlpo^-f^rt zQO)3Up_(}&YTT59Q#_7mAsK9WGnl-mlV!>0by4uP`f>sjw-=)jbFgr+@X#Fr6;e1M zD0d0;lB+;@2ucJ>(kh4FJa#ig!;uzj^^ivJ$$dqj_m`qU2TOKxWK0IY!frhVJN3ETfuket zDxtAC4y`F&ZYVWPr1ae%=+i}=w7sn1xHwQ%~bVqVJBJQt_nwz8V zI)Fqlj1$v-5DOI!ZgrDNdLd1&rN0@?V$ik5Zh$JrdradvvNZI$)g#Ewo`R$eyy7Yc k?C=KCHkM(#nx4h~2*)*)j1?JUjr_H9Ctq(o`!aRrFZOIQoB#j- diff --git a/config/posterr-app.lock b/config/posterr-app.lock index e8ad2d5..bd51486 100644 --- a/config/posterr-app.lock +++ b/config/posterr-app.lock @@ -1 +1 @@ -{"pid":1224,"startedAt":"2026-04-16T03:25:26.471Z"} \ No newline at end of file +{"pid":13276,"startedAt":"2026-04-16T06:01:08.382Z"} \ No newline at end of file diff --git a/index.js b/index.js index 4067e71..7d9d8be 100644 --- a/index.js +++ b/index.js @@ -3453,6 +3453,9 @@ app.get(BASEURL + "/getcards", (req, res) => { app.get(BASEURL + "/now-showing", (req, res) => { const cycleEmbed = String((req.query && req.query.cycleEmbed) || "").trim() === "1"; + const hideSettingsLinks = + loadedSettings && + String(loadedSettings.hideSettingsLinks).toLowerCase() === "true"; res.render("now-showing", { baseUrl: BASEURL, hasConfig: setng.GetChanged(), @@ -3473,6 +3476,7 @@ app.get(BASEURL + "/now-showing", (req, res) => { ? "true" : "false", viewHotkeysContext: cycleEmbed ? "embed-now-showing" : "now-showing-page", + hideSettingsLinks: hideSettingsLinks ? "true" : "false", ...newFeaturesBannerViewData(), }); }); @@ -3503,6 +3507,9 @@ app.get(BASEURL + "/ads", (req, res) => { adsGlobalBgRaw.startsWith("/custom/ads-view/") ? adsGlobalBgRaw : ""; const adsCycleEmbed = String((req.query && req.query.cycleEmbed) || "").trim() === "1"; + const hideSettingsLinks = + loadedSettings && + String(loadedSettings.hideSettingsLinks).toLowerCase() === "true"; res.render("ads", { baseUrl: BASEURL, hasConfig: setng.GetChanged(), @@ -3521,6 +3528,7 @@ app.get(BASEURL + "/ads", (req, res) => { ? "true" : "false", viewHotkeysContext: adsCycleEmbed ? "embed-ads" : "ads-page", + hideSettingsLinks: hideSettingsLinks ? "true" : "false", ...newFeaturesBannerViewData(), }); }); @@ -5125,6 +5133,26 @@ app.get(BASEURL + "/settings/tmdb-api", (req, res) => { req.session.tmdbNotice = null; }); +app.get(BASEURL + "/settings/holidays", (req, res) => { + if (loadedSettings.password !== undefined && !userData.valid) { + return res.redirect(302, BASEURL + "/logon"); + } + customPicFolders = getDirectories(CUSTOM_PICTURES_ROOT); + res.render("settings-holidays", { + success: req.session.success, + user: + loadedSettings.password === undefined ? { valid: true } : userData, + settings: loadedSettings, + version: pjson.version, + baseUrl: BASEURL, + customPicFolders: customPicFolders, + latestVersion: latestVersion, + message: message, + updateAvailable: updateAvailable, + ...newFeaturesBannerViewData(), + }); +}); + app.post(BASEURL + "/settings/tmdb-api", async (req, res) => { if (loadedSettings.password !== undefined && !userData.valid) { return res.redirect(302, BASEURL + "/logon"); diff --git a/myviews/ads.ejs b/myviews/ads.ejs index b3c878c..806806a 100644 --- a/myviews/ads.ejs +++ b/myviews/ads.ejs @@ -239,8 +239,26 @@ var n = parseInt(adsUrlQs.get("cycleStayMins") || "0", 10); return Number.isFinite(n) && n > 0 ? n : 0; })(); + var settingsLinksHidden = <%- JSON.stringify(String(typeof hideSettingsLinks !== "undefined" ? hideSettingsLinks : "false")) %> === "true"; var adsEmbedReturnTimer = null; + if (!settingsLinksHidden) { + document.addEventListener("click", function (ev) { + if (!ev || ev.defaultPrevented) return; + var t = ev.target; + if (t && t.closest && t.closest("a, button, input, select, textarea, label, [role='button']")) return; + try { + if (window.top && window.top !== window) { + window.top.location.href = <%- JSON.stringify(baseUrl + "/settings") %>; + } else { + window.location.href = <%- JSON.stringify(baseUrl + "/settings") %>; + } + } catch (e) { + window.location.href = <%- JSON.stringify(baseUrl + "/settings") %>; + } + }, true); + } + function clampPageStay(raw) { if (raw === undefined || raw === null || raw === "") return 0; var n = parseInt(raw, 10); diff --git a/myviews/now-showing.ejs b/myviews/now-showing.ejs index 27ed08e..af77757 100644 --- a/myviews/now-showing.ejs +++ b/myviews/now-showing.ejs @@ -139,6 +139,8 @@ height: 100%; overflow-y: auto; scroll-behavior: auto; + -webkit-overflow-scrolling: touch; + touch-action: pan-y; padding: 0.75rem 0 2rem; position: relative; z-index: 1; @@ -585,8 +587,25 @@ (function () { var nsCycleEnabled = <%- JSON.stringify(String(typeof nowShowingPageCycleEnabled !== "undefined" ? nowShowingPageCycleEnabled : "false")) %> === "true"; var nsCycleStayMins = parseInt(<%- JSON.stringify(String(typeof nowShowingPageCycleStayMins !== "undefined" ? nowShowingPageCycleStayMins : "5")) %>, 10); + var settingsLinksHidden = <%- JSON.stringify(String(typeof hideSettingsLinks !== "undefined" ? hideSettingsLinks : "false")) %> === "true"; var nsUrlQs = new URLSearchParams(window.location.search || ""); var nsCycleEmbed = nsUrlQs.get("cycleEmbed") === "1"; + if (!settingsLinksHidden) { + document.addEventListener("click", function (ev) { + if (!ev || ev.defaultPrevented) return; + var t = ev.target; + if (t && t.closest && t.closest("a, button, input, select, textarea, label, [role='button']")) return; + try { + if (window.top && window.top !== window) { + window.top.location.href = <%- JSON.stringify(baseUrl + "/settings") %>; + } else { + window.location.href = <%- JSON.stringify(baseUrl + "/settings") %>; + } + } catch (e) { + window.location.href = <%- JSON.stringify(baseUrl + "/settings") %>; + } + }, true); + } if (nsCycleEnabled && Number.isFinite(nsCycleStayMins) && nsCycleStayMins > 0) { setTimeout(function () { if (nsCycleEmbed && window.parent && window.parent !== window) { @@ -620,6 +639,7 @@ var reduceMotion = typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches; + var disableAutoScroll = nsUrlQs.get("autoscroll") === "0"; /** @type {IntersectionObserver[]} */ var tickerObservers = []; var scheduleHighlightTimer = null; @@ -1419,7 +1439,7 @@ function startAutoScroll() { if (autoScrollTimer) clearInterval(autoScrollTimer); - if (reduceMotion) return; + if (disableAutoScroll) return; autoScrollTimer = setInterval(function () { var h = segmentHeightPx; if (h < 8) return; diff --git a/myviews/settings-ads.ejs b/myviews/settings-ads.ejs index 2fe303c..56c02d4 100644 --- a/myviews/settings-ads.ejs +++ b/myviews/settings-ads.ejs @@ -33,6 +33,7 @@ Sync Now Showing Ads + Holidays TMDB API Cache Debug @@ -49,6 +50,7 @@ + diff --git a/myviews/settings-cache.ejs b/myviews/settings-cache.ejs index 90fba6f..2b8ea5b 100644 --- a/myviews/settings-cache.ejs +++ b/myviews/settings-cache.ejs @@ -82,6 +82,9 @@ Ads + + Holidays + TMDB API @@ -112,6 +115,7 @@ + diff --git a/myviews/settings-holidays.ejs b/myviews/settings-holidays.ejs new file mode 100644 index 0000000..e91bcfa --- /dev/null +++ b/myviews/settings-holidays.ejs @@ -0,0 +1,66 @@ + + + + + + + Posterr - Holidays Settings + + + + + + + <%if(!user.valid){%><%}%> + <%- include('new-features-banner.ejs') %> +
+ +
+
+ +
+
+
+
+ +
+
+

Holidays

+

Holiday settings page is ready. Configuration controls can be added here next.

+
+
+
+
+
+ + diff --git a/myviews/settings-now-showing.ejs b/myviews/settings-now-showing.ejs index c78ebec..017da71 100644 --- a/myviews/settings-now-showing.ejs +++ b/myviews/settings-now-showing.ejs @@ -33,6 +33,7 @@ Sync Now Showing Ads + Holidays TMDB API Cache Debug @@ -54,6 +55,7 @@ + diff --git a/myviews/settings-sync.ejs b/myviews/settings-sync.ejs index a655868..e381ecc 100644 --- a/myviews/settings-sync.ejs +++ b/myviews/settings-sync.ejs @@ -44,6 +44,9 @@ Ads + + Holidays + TMDB API @@ -84,6 +87,9 @@ + diff --git a/myviews/settings-tmdb.ejs b/myviews/settings-tmdb.ejs index 50c226f..31a47df 100644 --- a/myviews/settings-tmdb.ejs +++ b/myviews/settings-tmdb.ejs @@ -32,6 +32,7 @@ Sync Now Showing Ads + Holidays TMDB API Cache Debug @@ -52,6 +53,7 @@ + diff --git a/myviews/settings.ejs b/myviews/settings.ejs index 8abe3f2..eafbd66 100644 --- a/myviews/settings.ejs +++ b/myviews/settings.ejs @@ -82,6 +82,9 @@ Ads + + Holidays + TMDB API @@ -127,6 +130,9 @@ + From 23891fb9fd21e9f734e3f6c63ba92b1df3af86fe Mon Sep 17 00:00:00 2001 From: binarygeek119 Date: Thu, 16 Apr 2026 01:53:57 -0500 Subject: [PATCH 16/24] Add holidays database rules and optimize low-power display behavior. This moves holiday rule persistence into a dedicated holidays.db with dynamic date support and metadata-tag matching, and improves now-showing/sync responsiveness on low-resource devices with better abort feedback and lightweight rendering safeguards. Made-with: Cursor --- classes/core/holidaysDb.js | 134 ++++++++ classes/core/posterMetadataDb.js | 53 +++- classes/core/settings.js | 11 + classes/mediaservers/embyJellyfinBase.js | 48 +++ classes/mediaservers/kodi.js | 35 +++ classes/mediaservers/plex.js | 38 +++ config/holidays.db | Bin 0 -> 12288 bytes config/posterr-app.lock | 2 +- config/posterr-poster-metadata.db | Bin 1429504 -> 1748992 bytes consts.js | 4 +- index.js | 385 ++++++++++++++++++++++- myviews/now-showing.ejs | 52 ++- myviews/settings-holidays.ejs | 179 ++++++++++- myviews/settings-sync.ejs | 15 +- 14 files changed, 937 insertions(+), 19 deletions(-) create mode 100644 classes/core/holidaysDb.js create mode 100644 config/holidays.db diff --git a/classes/core/holidaysDb.js b/classes/core/holidaysDb.js new file mode 100644 index 0000000..3fe0ef4 --- /dev/null +++ b/classes/core/holidaysDb.js @@ -0,0 +1,134 @@ +const fs = require("fs"); +const path = require("path"); +const { CONFIG_ROOT } = require("./appPaths"); + +const DB_FILE = path.join(CONFIG_ROOT, "holidays.db"); + +const SCHEMA_SQL = ` +CREATE TABLE IF NOT EXISTS holiday_rules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + mode TEXT NOT NULL DEFAULT 'fixed', + start_md TEXT NOT NULL DEFAULT '', + end_md TEXT NOT NULL DEFAULT '', + month_num INTEGER NOT NULL DEFAULT 11, + weekday_num INTEGER NOT NULL DEFAULT 4, + nth_value TEXT NOT NULL DEFAULT '4', + span_days INTEGER NOT NULL DEFAULT 0, + tag_text TEXT NOT NULL DEFAULT '', + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT '', + updated_at TEXT NOT NULL DEFAULT '' +); +`; + +let _db = null; + +function assertDb() { + if (!_db) throw new Error("Holidays DB not initialized"); +} + +function persistDb() { + assertDb(); + fs.mkdirSync(path.dirname(DB_FILE), { recursive: true }); + fs.writeFileSync(DB_FILE, Buffer.from(_db.export())); +} + +async function initHolidaysDb() { + if (_db) return; + const initSqlJs = require("sql.js"); + const wasmPath = path.join( + path.dirname(require.resolve("sql.js/package.json")), + "dist", + "sql-wasm.wasm" + ); + const SQL = await initSqlJs({ locateFile: () => wasmPath }); + fs.mkdirSync(path.dirname(DB_FILE), { recursive: true }); + if (fs.existsSync(DB_FILE)) _db = new SQL.Database(fs.readFileSync(DB_FILE)); + else _db = new SQL.Database(); + _db.exec(SCHEMA_SQL); + if (!fs.existsSync(DB_FILE)) persistDb(); +} + +function listRules() { + assertDb(); + const out = []; + const s = _db.prepare("SELECT * FROM holiday_rules ORDER BY sort_order ASC, id ASC"); + while (s.step()) { + const r = s.getAsObject(); + out.push({ + id: Number(r.id), + mode: String(r.mode || "fixed"), + start: String(r.start_md || ""), + end: String(r.end_md || ""), + month: Number(r.month_num) || 11, + weekday: Number(r.weekday_num) || 4, + nth: String(r.nth_value || "4"), + spanDays: Number(r.span_days) || 0, + tag: String(r.tag_text || ""), + sortOrder: Number(r.sort_order) || 0, + }); + } + s.free(); + return out; +} + +function replaceRules(rules) { + assertDb(); + const list = Array.isArray(rules) ? rules : []; + const now = new Date().toISOString(); + const ins = _db.prepare( + "INSERT INTO holiday_rules (mode, start_md, end_md, month_num, weekday_num, nth_value, span_days, tag_text, sort_order, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" + ); + try { + _db.run("BEGIN"); + _db.run("DELETE FROM holiday_rules"); + for (let i = 0; i < list.length; i++) { + const r = list[i] || {}; + ins.run([ + String(r.mode || "fixed"), + String(r.start || ""), + String(r.end || ""), + Number(r.month) || 11, + Number(r.weekday) || 4, + String(r.nth || "4"), + Number(r.spanDays) || 0, + String(r.tag || ""), + i, + now, + now, + ]); + } + _db.run("COMMIT"); + } catch (e) { + try { + _db.run("ROLLBACK"); + } catch (_e) {} + throw e; + } finally { + ins.free(); + } + persistDb(); +} + +function migrateLegacyRulesFromJson(raw) { + assertDb(); + if (listRules().length > 0) return false; + let parsed = []; + try { + parsed = JSON.parse(String(raw || "[]")); + } catch (e) { + parsed = []; + } + if (!Array.isArray(parsed) || !parsed.length) return false; + replaceRules(parsed); + return true; +} + +module.exports = { + DB_FILE, + initHolidaysDb, + listRules, + replaceRules, + migrateLegacyRulesFromJson, +}; + diff --git a/classes/core/posterMetadataDb.js b/classes/core/posterMetadataDb.js index 74c07e6..8de2d0a 100644 --- a/classes/core/posterMetadataDb.js +++ b/classes/core/posterMetadataDb.js @@ -37,6 +37,7 @@ CREATE TABLE IF NOT EXISTS poster_entries ( tag_line TEXT NOT NULL DEFAULT '', year TEXT NOT NULL DEFAULT '', media_type TEXT NOT NULL DEFAULT 'movie', + tags_text TEXT NOT NULL DEFAULT '', genres TEXT NOT NULL DEFAULT '', top_cast TEXT NOT NULL DEFAULT '', actor_1 TEXT NOT NULL DEFAULT '', @@ -75,6 +76,7 @@ const EXTRA_SCHEMA_COLUMNS = [ { name: "portrait_author_cache_file", def: "TEXT NOT NULL DEFAULT ''" }, { name: "portrait_artist_cache_file", def: "TEXT NOT NULL DEFAULT ''" }, { name: "genres", def: "TEXT NOT NULL DEFAULT ''" }, + { name: "tags_text", def: "TEXT NOT NULL DEFAULT ''" }, { name: "top_cast", def: "TEXT NOT NULL DEFAULT ''" }, { name: "actor_1", def: "TEXT NOT NULL DEFAULT ''" }, { name: "actor_2", def: "TEXT NOT NULL DEFAULT ''" }, @@ -267,9 +269,9 @@ function migrateLegacyJson() { cache_file, logo_cache_file, art_cache_file, banner_cache_file, portrait_actor_cache_file, portrait_actress_cache_file, portrait_director_cache_file, portrait_author_cache_file, portrait_artist_cache_file, - title, tag_line, year, media_type, genres, top_cast, actor_1, actor_2, studio, runtime_mins, rating, content_rating, plot, rating_score, rating_content, summary, server_kind, poster_ar, + title, tag_line, year, media_type, tags_text, genres, top_cast, actor_1, actor_2, studio, runtime_mins, rating, content_rating, plot, rating_score, rating_content, summary, server_kind, poster_ar, dbid, api_item_id, library_kind, library_name, source_url, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); try { _sqlDb.run("BEGIN"); @@ -288,6 +290,7 @@ function migrateLegacyJson() { tagLine: e.tagLine, year: e.year, mediaType: e.mediaType, + tagsText: e.tagsText, genres: e.genres, topCast: e.topCast, actor1: e.actor1, @@ -352,6 +355,7 @@ function rowFromDb(r) { tagLine: r.tag_line || "", year: r.year || "", mediaType: r.media_type || "", + tagsText: r.tags_text || "", genres: r.genres || "", topCast: r.top_cast || "", actor1: r.actor_1 || "", @@ -391,6 +395,7 @@ function entryToParams(e) { e.tagLine || "", e.year || "", e.mediaType || "movie", + e.tagsText || "", e.genres || "", e.topCast || "", e.actor1 || "", @@ -414,6 +419,38 @@ function entryToParams(e) { ]; } +function normalizeMetadataTags(card) { + if (!card || typeof card !== "object") return ""; + const out = []; + const add = (v) => { + if (v == null) return; + if (Array.isArray(v)) { + for (const x of v) add(x); + return; + } + const s = String(v).trim(); + if (!s) return; + out.push(s); + }; + add(card.tags); + add(card.keywords); + add(card.labels); + add(card.genre); + add(card.contentRating); + add(card.studio); + add(card.network); + add(card.cast); + const uniq = []; + const seen = new Set(); + for (const s of out) { + const lc = s.toLowerCase(); + if (seen.has(lc)) continue; + seen.add(lc); + uniq.push(s); + } + return uniq.join(", ").slice(0, 400); +} + function selectAllEntries() { assertDb(); const out = []; @@ -508,9 +545,9 @@ function replaceAllEntries(entries) { cache_file, logo_cache_file, art_cache_file, banner_cache_file, portrait_actor_cache_file, portrait_actress_cache_file, portrait_director_cache_file, portrait_author_cache_file, portrait_artist_cache_file, - title, tag_line, year, media_type, genres, top_cast, actor_1, actor_2, studio, runtime_mins, rating, content_rating, plot, rating_score, rating_content, summary, server_kind, poster_ar, + title, tag_line, year, media_type, tags_text, genres, top_cast, actor_1, actor_2, studio, runtime_mins, rating, content_rating, plot, rating_score, rating_content, summary, server_kind, poster_ar, dbid, api_item_id, library_kind, library_name, source_url, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); try { _sqlDb.run("BEGIN"); @@ -762,9 +799,9 @@ function registerFromMediaServerCards(nsCards, odCards, serverKind) { cache_file, logo_cache_file, art_cache_file, banner_cache_file, portrait_actor_cache_file, portrait_actress_cache_file, portrait_director_cache_file, portrait_author_cache_file, portrait_artist_cache_file, - title, tag_line, year, media_type, genres, top_cast, actor_1, actor_2, studio, runtime_mins, rating, content_rating, plot, rating_score, rating_content, summary, server_kind, poster_ar, + title, tag_line, year, media_type, tags_text, genres, top_cast, actor_1, actor_2, studio, runtime_mins, rating, content_rating, plot, rating_score, rating_content, summary, server_kind, poster_ar, dbid, api_item_id, library_kind, library_name, source_url, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); try { @@ -810,6 +847,7 @@ function registerFromMediaServerCards(nsCards, odCards, serverKind) { tagLine: String(card.tagLine || "").trim(), year: String(card.year || "").trim(), mediaType: String(card.mediaType || "movie").trim() || "movie", + tagsText: normalizeMetadataTags(card), genres: Array.isArray(card.genre) ? card.genre.join(", ") : String(card.genre || "").trim(), @@ -875,6 +913,7 @@ function registerFromMediaServerCards(nsCards, odCards, serverKind) { old.tagLine !== row.tagLine || old.year !== row.year || old.mediaType !== row.mediaType || + old.tagsText !== row.tagsText || old.genres !== row.genres || old.topCast !== row.topCast || old.actor1 !== row.actor1 || @@ -1027,6 +1066,7 @@ function applyCachedPostersToMediaCards(cards, serverKind) { const g = row.genres != null ? String(row.genres).trim() : ""; if (g) card.genre = g; } + if (!card.tags && row.tagsText) card.tags = row.tagsText; if (!card.studio && row.studio) card.studio = row.studio; if ( (!card.runTime || card.runTime === "" || card.runTime === 0) && @@ -1092,6 +1132,7 @@ function buildFallbackMediaCards(count, serverKind) { c.posterLogoURL = "/imagecache/" + row.logoCacheFile; } c.genre = row.genres != null ? String(row.genres).trim() : ""; + c.tags = row.tagsText || ""; c.studio = row.studio || ""; if (row.runtimeMins > 0) c.runTime = String(row.runtimeMins); c.rating = row.rating || ""; diff --git a/classes/core/settings.js b/classes/core/settings.js index 029e3f1..3657018 100644 --- a/classes/core/settings.js +++ b/classes/core/settings.js @@ -157,6 +157,7 @@ class Settings { this.tmdbApiKey = DEFAULT_SETTINGS.tmdbApiKey; this.newFeaturesAcknowledgedVersion = DEFAULT_SETTINGS.newFeaturesAcknowledgedVersion; + this.holidayRules = DEFAULT_SETTINGS.holidayRules; return; } @@ -332,6 +333,8 @@ class Settings { if(readSettings.tmdbApiKey === undefined) readSettings.tmdbApiKey = ""; if (readSettings.newFeaturesAcknowledgedVersion === undefined) readSettings.newFeaturesAcknowledgedVersion = ""; + if (readSettings.holidayRules === undefined) + readSettings.holidayRules = DEFAULT_SETTINGS.holidayRules; if(readSettings.enableLidarr==undefined) readSettings.enableLidarr = 'true'; if(readSettings.lidarrURL==undefined) readSettings.lidarrURL = ''; if(readSettings.lidarrToken==undefined) readSettings.lidarrToken = ''; @@ -1058,6 +1061,14 @@ class Settings { ? cs.newFeaturesAcknowledgedVersion : DEFAULT_SETTINGS.newFeaturesAcknowledgedVersion; } + if (jsonObject.holidayRules !== undefined && jsonObject.holidayRules !== null) { + this.holidayRules = String(jsonObject.holidayRules); + } else { + this.holidayRules = + cs.holidayRules !== undefined + ? cs.holidayRules + : DEFAULT_SETTINGS.holidayRules; + } // convert JSON object to string (pretty format) const data = JSON.stringify(this, null, 4); diff --git a/classes/mediaservers/embyJellyfinBase.js b/classes/mediaservers/embyJellyfinBase.js index d3218f2..5d66ef7 100644 --- a/classes/mediaservers/embyJellyfinBase.js +++ b/classes/mediaservers/embyJellyfinBase.js @@ -217,6 +217,52 @@ function filterMediaItemsForOdemandFilters(items, genres, recentlyAdded, content return all; } +function extractEmbyTags(item) { + if (!item || typeof item !== "object") return ""; + const out = []; + const add = (v) => { + if (!v) return; + if (Array.isArray(v)) { + for (const it of v) add(it); + return; + } + if (typeof v === "object") { + add( + v.Name || + v.name || + v.Tag || + v.tag || + v.Title || + v.title || + v.DisplayName || + v.displayName || + "" + ); + return; + } + const s = String(v).trim(); + if (!s) return; + out.push(s); + }; + add(item.Tags); + add(item.tags); + add(item.TagItems); + add(item.tagItems); + add(item.Genres); + add(item.genres); + add(item.Studios); + add(item.studios); + const uniq = []; + const seen = new Set(); + for (const s of out) { + const lc = s.toLowerCase(); + if (seen.has(lc)) continue; + seen.add(lc); + uniq.push(s); + } + return uniq.join(", "); +} + /** * Shared Emby/Jellyfin REST client. * Emby: X-Emby-Token + api_key query (legacy, still widely used). @@ -1011,6 +1057,7 @@ class EmbyJellyfinBase { medCard.playerLocal = localPlayer; medCard.genre = await util.emptyIfNull(item.Genres); + medCard.tags = extractEmbyTags(item); medCard.summary = item.Overview || ""; medCard.cast = util.formatCastFromEmbyPeople(item.People); medCard.directors = util.formatDirectorsFromEmbyPeople(item.People); @@ -1876,6 +1923,7 @@ class EmbyJellyfinBase { medCard.year = md.ProductionYear; medCard.genre = await util.emptyIfNull(md.Genres); + medCard.tags = extractEmbyTags(md); medCard.summary = md.Overview || ""; medCard.cast = util.formatCastFromEmbyPeople(md.People); medCard.directors = util.formatDirectorsFromEmbyPeople(md.People); diff --git a/classes/mediaservers/kodi.js b/classes/mediaservers/kodi.js index e7ce066..30af55f 100644 --- a/classes/mediaservers/kodi.js +++ b/classes/mediaservers/kodi.js @@ -147,6 +147,39 @@ class Kodi { .filter(Boolean); } + static extractKodiTags(item) { + if (!item || typeof item !== "object") return ""; + const out = []; + const add = (v) => { + if (!v) return; + if (Array.isArray(v)) { + for (const it of v) add(it); + return; + } + if (typeof v === "object") { + add(v.label || v.Label || v.name || v.Name || ""); + return; + } + const s = String(v).trim(); + if (!s) return; + out.push(s); + }; + add(item.tag); + add(item.tags); + add(item.genre); + add(item.studio); + add(item.mpaa); + const uniq = []; + const seen = new Set(); + for (const s of out) { + const lc = s.toLowerCase(); + if (seen.has(lc)) continue; + seen.add(lc); + uniq.push(s); + } + return uniq.join(", "); + } + async GetVideoSources() { const r = await this.rpc("Files.GetSources", { media: "video" }); return r.sources || []; @@ -298,6 +331,7 @@ class Kodi { medCard.contentRating = contentRating; medCard.ratingColour = EmbyJellyfinBase.ratingColour(contentRating); medCard.genre = await util.emptyIfNull(Kodi.genresToArray(item.genre)); + medCard.tags = Kodi.extractKodiTags(item); medCard.summary = item.plot || ""; const ratingVal = parseFloat(item.rating); @@ -1143,6 +1177,7 @@ class Kodi { medCard.year = md.year; medCard.genre = await util.emptyIfNull(Kodi.genresToArray(md.genre)); + medCard.tags = Kodi.extractKodiTags(md); medCard.summary = md.plot || ""; medCard.cardType = md.ctype; diff --git a/classes/mediaservers/plex.js b/classes/mediaservers/plex.js index d8ce002..798f995 100644 --- a/classes/mediaservers/plex.js +++ b/classes/mediaservers/plex.js @@ -56,6 +56,42 @@ async function plexCacheClearLogo(core, self, md, idForFile, medCard) { } } +function extractPlexTags(md) { + if (!md || typeof md !== "object") return ""; + const out = []; + const add = (v) => { + if (!v) return; + if (Array.isArray(v)) { + for (const it of v) add(it); + return; + } + if (typeof v === "object") { + add(v.tag || v.Tag || v.title || v.Title || v.label || v.Label || ""); + return; + } + const s = String(v).trim(); + if (!s) return; + out.push(s); + }; + add(md.Tag); + add(md.tag); + add(md.Label); + add(md.label); + add(md.Collection); + add(md.collection); + add(md.Genre); + add(md.genre); + const uniq = []; + const seen = new Set(); + for (const s of out) { + const lc = s.toLowerCase(); + if (seen.has(lc)) continue; + seen.add(lc); + uniq.push(s); + } + return uniq.join(", "); +} + /** * @desc Used to communicate with Plex * @param {string} HTTPS - set this to true if Plex only allows secure connections @@ -524,6 +560,7 @@ class Plex { //medCard.year = md.year; medCard.genre = await util.emptyIfNull(md.Genre); + medCard.tags = extractPlexTags(md); medCard.summary = md.summary; medCard.cast = util.formatCastFromPlexRole(md.Role); medCard.directors = util.formatDirectorsFromPlexDirector(md.Director); @@ -1289,6 +1326,7 @@ class Plex { medCard.year = md.year; medCard.genre = await util.emptyIfNull(md.Genre); + medCard.tags = extractPlexTags(md); medCard.summary = md.summary; medCard.cast = util.formatCastFromPlexRole(md.Role); medCard.directors = util.formatDirectorsFromPlexDirector(md.Director); diff --git a/config/holidays.db b/config/holidays.db new file mode 100644 index 0000000000000000000000000000000000000000..cacf69aa925610e859fb1c5facbefeca5379b56e GIT binary patch literal 12288 zcmeI&PjAyO7zS`VonQ=A-7VbkEt|GZY5M0dX%g8ms3Pk|Nu`xbWEyXcXp${)#<(LP zapgPk&A9F};B+cdhq7Lfc6qG2t=lKB^ZstG`r*A7Dap>$EEbBj$tIzcykd+HV(3}Z zb1|rXv$k01*K)mT7-aAIM_s3k`cG2-t)DO;009U<00Izz00bZa0SG`~A#iFuF?V+8 zScy+FnP1KH`7FrgRUwnf{blWF$X%Z^-#zp=yO-TcL@ampmD}H5(5I$l(eLTJ<}{r} zq4*kPg|5G}FqY~qeZ{sm7>h#IANc$&AF|V7|JWUl**iXF?#Mss5A?OieBkfuT%3lI z`TWdhgA+YRp2uGEH}1&unROoB$j~ZNxe}QQ;;)=u(fv%WW?`)Ull(-0#yipH!nx|SL%|e;2s5Z%@ zP%;ekfxW_besjfTxGJ`>U7pQp;}_8n3wZVsi*U00Izz00bZa0SG_<0uX=z1pb@ACaqb|mj2f92S>TxJ^%m! literal 0 HcmV?d00001 diff --git a/config/posterr-app.lock b/config/posterr-app.lock index bd51486..45799e2 100644 --- a/config/posterr-app.lock +++ b/config/posterr-app.lock @@ -1 +1 @@ -{"pid":13276,"startedAt":"2026-04-16T06:01:08.382Z"} \ No newline at end of file +{"pid":30364,"startedAt":"2026-04-16T06:51:02.704Z"} \ No newline at end of file diff --git a/config/posterr-poster-metadata.db b/config/posterr-poster-metadata.db index e539108485d27aad08d913325314d08b5d0b34a4..856cd0926f24cdcab3209ac5fe8104832015f51f 100644 GIT binary patch delta 170295 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 delta 71973 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 diff --git a/consts.js b/consts.js index 0976349..2377296 100644 --- a/consts.js +++ b/consts.js @@ -167,7 +167,9 @@ const DEFAULT_SETTINGS = { cachedPosterSlideCount: 48, tmdbApiKey: "", /** Last package.json version the admin acknowledged (hides “new features” banner when it matches current version) */ - newFeaturesAcknowledgedVersion: "" + newFeaturesAcknowledgedVersion: "", + /** Seasonal date ranges + tag match rules used to boost matching media in home posters and /now-showing. */ + holidayRules: "[]" }; module.exports = DEFAULT_SETTINGS; \ No newline at end of file diff --git a/index.js b/index.js index 7d9d8be..bc68616 100644 --- a/index.js +++ b/index.js @@ -37,6 +37,7 @@ const posterMetadata = require("./classes/core/posterMetadataDb"); const posterSyncRetry = require("./classes/core/posterSyncRetry"); const nowShowingDb = require("./classes/core/nowShowingDb"); const adsDb = require("./classes/core/adsDb"); +const holidaysDb = require("./classes/core/holidaysDb"); const { CONFIG_ROOT, CACHE_ROOT, @@ -3047,6 +3048,8 @@ async function startup(clearCache) { await posterMetadata.initPosterMetadataDb(); await nowShowingDb.initNowShowingDb(); await adsDb.initAdsDb(); + await ensureHolidaysDbReady(); + holidaysDb.migrateLegacyRulesFromJson(loadedSettings && loadedSettings.holidayRules); await migrateLegacyGlobalAdsPriceAddOnOnce(); startHttpServerOnce(); if (loadedSettings == 'undefined') { @@ -3428,10 +3431,21 @@ app.get(BASEURL + "/posters", (req, res) => { }); app.get(BASEURL + "/getcards", (req, res) => { + const holidayTags = activeHolidayTagsForToday(); const postersOnly = String((req.query && req.query.postersOnly) || "").trim() === "1"; if (!postersOnly) { - return res.send({ globalPage: globalPage, baseUrl: BASEURL }); // get generated cards + const cards = boostCardsByHolidayTags( + Array.isArray(globalPage && globalPage.cards) ? globalPage.cards : [], + holidayTags + ); + return res.send({ + globalPage: { + ...globalPage, + cards, + }, + baseUrl: BASEURL, + }); // get generated cards } const cards = Array.isArray(globalPage && globalPage.cards) ? globalPage.cards.filter((card) => { @@ -3441,10 +3455,11 @@ app.get(BASEURL + "/getcards", (req, res) => { return n !== "ad" && n !== "now showing"; }) : []; + const boosted = boostCardsByHolidayTags(cards, holidayTags); return res.send({ globalPage: { ...globalPage, - cards, + cards: boosted, }, baseUrl: BASEURL, }); @@ -3562,6 +3577,232 @@ function normalizeNowShowingTitle(t) { .trim(); } +let holidaysDbInitPromise = null; +async function ensureHolidaysDbReady() { + if (!holidaysDbInitPromise) { + holidaysDbInitPromise = holidaysDb.initHolidaysDb().catch((e) => { + holidaysDbInitPromise = null; + throw e; + }); + } + await holidaysDbInitPromise; +} + +function normalizeHolidayMonthDay(raw) { + const s = String(raw || "").trim(); + if (!s) return ""; + const m = s.match(/^(\d{1,2})-(\d{1,2})$/); + if (m) { + const mo = parseInt(m[1], 10); + const da = parseInt(m[2], 10); + if (mo >= 1 && mo <= 12 && da >= 1 && da <= 31) { + return String(mo).padStart(2, "0") + "-" + String(da).padStart(2, "0"); + } + } + const dt = new Date(s); + if (isNaN(dt.getTime())) return ""; + return ( + String(dt.getMonth() + 1).padStart(2, "0") + + "-" + + String(dt.getDate()).padStart(2, "0") + ); +} + +function normalizeHolidayInt(raw, min, max, fallback) { + const n = parseInt(String(raw == null ? "" : raw).trim(), 10); + if (isNaN(n)) return fallback; + return Math.max(min, Math.min(max, n)); +} + +function normalizeHolidayMode(raw) { + return String(raw || "").trim().toLowerCase() === "dynamic" + ? "dynamic" + : "fixed"; +} + +function computeNthWeekdayOfMonth(year, monthOneBased, weekday, nth) { + if (!Number.isFinite(year)) return null; + const month = Math.max(1, Math.min(12, monthOneBased)); + const wd = Math.max(0, Math.min(6, weekday)); + const nthNorm = String(nth || "").toLowerCase() === "last" ? "last" : String(nth); + if (nthNorm === "last") { + const last = new Date(year, month, 0); + const d = new Date(last.getTime()); + while (d.getDay() !== wd) d.setDate(d.getDate() - 1); + return d; + } + const n = normalizeHolidayInt(nthNorm, 1, 5, 1); + const first = new Date(year, month - 1, 1); + const delta = (wd - first.getDay() + 7) % 7; + const day = 1 + delta + (n - 1) * 7; + const candidate = new Date(year, month - 1, day); + if (candidate.getMonth() !== month - 1) return null; + return candidate; +} + +function readHolidayRulesFromSettings() { + let parsed = []; + try { + parsed = holidaysDb.listRules(); + } catch (e) { + parsed = []; + } + if (!Array.isArray(parsed)) return []; + return parsed + .map((r) => { + const mode = normalizeHolidayMode(r && r.mode); + const tag = String((r && r.tag) || "").trim().toLowerCase(); + if (mode === "dynamic") { + const month = normalizeHolidayInt(r && r.month, 1, 12, 11); + const weekday = normalizeHolidayInt(r && r.weekday, 0, 6, 4); + const nthRaw = String((r && r.nth) || "").trim().toLowerCase(); + const nth = + nthRaw === "last" + ? "last" + : String(normalizeHolidayInt(nthRaw || "1", 1, 5, 1)); + const spanDays = normalizeHolidayInt(r && r.spanDays, 0, 31, 0); + return { mode, month, weekday, nth, spanDays, tag }; + } + const start = normalizeHolidayMonthDay(r && r.start); + const end = normalizeHolidayMonthDay(r && r.end); + return { mode: "fixed", start, end, tag }; + }) + .filter((r) => { + if (!r.tag) return false; + if (r.mode === "dynamic") return true; + return !!(r.start && r.end); + }); +} + +function monthDayInHolidayRange(todayMd, startMd, endMd) { + if (!todayMd || !startMd || !endMd) return false; + if (startMd <= endMd) return todayMd >= startMd && todayMd <= endMd; + return todayMd >= startMd || todayMd <= endMd; +} + +function activeHolidayTagsForToday() { + const now = new Date(); + const todayMd = + String(now.getMonth() + 1).padStart(2, "0") + + "-" + + String(now.getDate()).padStart(2, "0"); + const active = new Set(); + for (const rule of readHolidayRulesFromSettings()) { + if (rule.mode === "dynamic") { + const base = computeNthWeekdayOfMonth( + now.getFullYear(), + rule.month, + rule.weekday, + rule.nth + ); + if (!base) continue; + const end = new Date(base.getTime()); + end.setDate(end.getDate() + (rule.spanDays || 0)); + const startMd = + String(base.getMonth() + 1).padStart(2, "0") + + "-" + + String(base.getDate()).padStart(2, "0"); + const endMd = + String(end.getMonth() + 1).padStart(2, "0") + + "-" + + String(end.getDate()).padStart(2, "0"); + if (monthDayInHolidayRange(todayMd, startMd, endMd)) { + active.add(rule.tag); + } + continue; + } + if (monthDayInHolidayRange(todayMd, rule.start, rule.end)) { + active.add(rule.tag); + } + } + return active; +} + +function cardMatchesHolidayTag(card, tagLc) { + if (!card || !tagLc) return false; + const genreText = Array.isArray(card.genre) + ? card.genre.join(" ") + : String(card.genre || ""); + const hay = ( + String(card.title || "") + + " " + + String(card.tagLine || "") + + " " + + String(card.summary || "") + + " " + + String(card.studio || "") + + " " + + String(card.cast || "") + + " " + + String(card.directors || "") + + " " + + String(card.authors || "") + + " " + + String(card.albumArtist || "") + + " " + + String(card.tags || "") + + " " + + genreText + ) + .toLowerCase() + .trim(); + return hay.includes(tagLc); +} + +function nowShowingRowMatchesHolidayTag(row, tagLc) { + if (!row || !tagLc) return false; + const hay = ( + String(row.title || "") + + " " + + String(row.overview || "") + + " " + + String(row.genres || "") + + " " + + String(row.topCast || "") + + " " + + String(row.studio || "") + ) + .toLowerCase() + .trim(); + return hay.includes(tagLc); +} + +function boostCardsByHolidayTags(cards, activeTags) { + if (!Array.isArray(cards) || !cards.length) return cards; + if (!(activeTags instanceof Set) || activeTags.size === 0) return cards; + const boost = []; + for (const card of cards) { + let matches = false; + for (const tag of activeTags) { + if (cardMatchesHolidayTag(card, tag)) { + matches = true; + break; + } + } + if (matches) boost.push(card); + } + if (!boost.length) return cards; + return cards.concat(boost, boost); +} + +function boostNowShowingRowsByHolidayTags(rows, activeTags) { + if (!Array.isArray(rows) || !rows.length) return rows; + if (!(activeTags instanceof Set) || activeTags.size === 0) return rows; + const boost = []; + for (const row of rows) { + let matches = false; + for (const tag of activeTags) { + if (nowShowingRowMatchesHolidayTag(row, tag)) { + matches = true; + break; + } + } + if (matches) boost.push({ ...row }); + } + if (!boost.length) return rows; + return rows.concat(boost, boost); +} + function normalizeLibraryNameFor3d(name) { return String(name || "") .toLowerCase() @@ -4044,6 +4285,7 @@ app.get(BASEURL + "/now-showing/data", async (req, res) => { ); } movies = movies.filter(nowShowingRowHasBannerAndLogo); + movies = boostNowShowingRowsByHolidayTags(movies, activeHolidayTagsForToday()); await ensureNowShowingMoviesImageCacheForResponse(movies, BASEURL); let curatedWeight = parseInt(loadedSettings.nowShowingCuratedWeight, 10); if (isNaN(curatedWeight)) @@ -4627,6 +4869,15 @@ app.post(BASEURL + "/settings/sync/abort", (req, res) => { } if (posterSyncProgressState.status === "running") { posterSyncAbortRequested = true; + req.session.syncNotice = { + ok: true, + text: "Abort requested. Current library item finishes, then sync will stop.", + }; + } else { + req.session.syncNotice = { + ok: false, + text: "No sync is currently running.", + }; } res.redirect(302, BASEURL + "/settings/sync"); }); @@ -5133,11 +5384,20 @@ app.get(BASEURL + "/settings/tmdb-api", (req, res) => { req.session.tmdbNotice = null; }); -app.get(BASEURL + "/settings/holidays", (req, res) => { +app.get(BASEURL + "/settings/holidays", async (req, res) => { if (loadedSettings.password !== undefined && !userData.valid) { return res.redirect(302, BASEURL + "/logon"); } customPicFolders = getDirectories(CUSTOM_PICTURES_ROOT); + let holidayRules = []; + try { + await ensureHolidaysDbReady(); + holidayRules = holidaysDb.listRules(); + } catch (e) { + holidayRules = []; + } + const holidayNotice = req.session.holidayNotice || null; + req.session.holidayNotice = null; res.render("settings-holidays", { success: req.session.success, user: @@ -5149,10 +5409,129 @@ app.get(BASEURL + "/settings/holidays", (req, res) => { latestVersion: latestVersion, message: message, updateAvailable: updateAvailable, + holidayRules, + holidayNotice, ...newFeaturesBannerViewData(), }); }); +app.post(BASEURL + "/settings/holidays", async (req, res) => { + if (loadedSettings.password !== undefined && !userData.valid) { + return res.redirect(302, BASEURL + "/logon"); + } + try { + await ensureHolidaysDbReady(); + const startsRaw = Array.isArray(req.body.holidayStart) + ? req.body.holidayStart + : req.body.holidayStart != null + ? [req.body.holidayStart] + : []; + const endsRaw = Array.isArray(req.body.holidayEnd) + ? req.body.holidayEnd + : req.body.holidayEnd != null + ? [req.body.holidayEnd] + : []; + const tagsRaw = Array.isArray(req.body.holidayTag) + ? req.body.holidayTag + : req.body.holidayTag != null + ? [req.body.holidayTag] + : []; + const modesRaw = Array.isArray(req.body.holidayMode) + ? req.body.holidayMode + : req.body.holidayMode != null + ? [req.body.holidayMode] + : []; + const monthsRaw = Array.isArray(req.body.holidayMonth) + ? req.body.holidayMonth + : req.body.holidayMonth != null + ? [req.body.holidayMonth] + : []; + const weekdaysRaw = Array.isArray(req.body.holidayWeekday) + ? req.body.holidayWeekday + : req.body.holidayWeekday != null + ? [req.body.holidayWeekday] + : []; + const nthsRaw = Array.isArray(req.body.holidayNth) + ? req.body.holidayNth + : req.body.holidayNth != null + ? [req.body.holidayNth] + : []; + const spansRaw = Array.isArray(req.body.holidaySpanDays) + ? req.body.holidaySpanDays + : req.body.holidaySpanDays != null + ? [req.body.holidaySpanDays] + : []; + const maxLen = Math.max( + startsRaw.length, + endsRaw.length, + tagsRaw.length, + modesRaw.length, + monthsRaw.length, + weekdaysRaw.length, + nthsRaw.length, + spansRaw.length + ); + const out = []; + for (let i = 0; i < maxLen; i++) { + const mode = normalizeHolidayMode(modesRaw[i]); + const tag = String(tagsRaw[i] || "").trim(); + const start = normalizeHolidayMonthDay(startsRaw[i]); + const end = normalizeHolidayMonthDay(endsRaw[i]); + const month = normalizeHolidayInt(monthsRaw[i], 1, 12, 11); + const weekday = normalizeHolidayInt(weekdaysRaw[i], 0, 6, 4); + const nthRaw = String(nthsRaw[i] || "").trim().toLowerCase(); + const nth = nthRaw === "last" ? "last" : String(normalizeHolidayInt(nthRaw || "1", 1, 5, 1)); + const spanDays = normalizeHolidayInt(spansRaw[i], 0, 31, 0); + if ( + !tag && + !start && + !end && + !String(modesRaw[i] || "").trim() && + !String(monthsRaw[i] || "").trim() && + !String(weekdaysRaw[i] || "").trim() && + !String(nthsRaw[i] || "").trim() && + !String(spansRaw[i] || "").trim() + ) { + continue; + } + if (!tag) { + throw new Error("Each holiday row needs a tag."); + } + if (mode === "dynamic") { + out.push({ + mode: "dynamic", + month, + weekday, + nth, + spanDays, + tag: tag.slice(0, 80), + }); + continue; + } + if (!start || !end) { + throw new Error("Fixed date rows need start date and end date."); + } + out.push({ + mode: "fixed", + start, + end, + tag: tag.slice(0, 80), + }); + } + holidaysDb.replaceRules(out); + req.session.holidayNotice = { + ok: true, + text: "Holiday date-tag rules saved.", + }; + } catch (e) { + req.session.holidayNotice = { + ok: false, + text: "Could not save holiday rules: " + (e && e.message ? e.message : String(e)), + }; + } + return res.redirect(302, BASEURL + "/settings/holidays"); +}); + app.post(BASEURL + "/settings/tmdb-api", async (req, res) => { if (loadedSettings.password !== undefined && !userData.valid) { return res.redirect(302, BASEURL + "/logon"); diff --git a/myviews/now-showing.ejs b/myviews/now-showing.ejs index af77757..5ad13e0 100644 --- a/myviews/now-showing.ejs +++ b/myviews/now-showing.ejs @@ -35,6 +35,29 @@ min-height: 100vh; min-height: 100dvh; } + body.now-showing-page.now-showing-low-power .topbar { + backdrop-filter: none; + background: rgba(15, 17, 21, 0.98); + } + body.now-showing-page.now-showing-low-power .movie-card, + body.now-showing-page.now-showing-low-power .movie-card--now-playing, + body.now-showing-page.now-showing-low-power .movie-card--schedule-active { + box-shadow: none; + } + body.now-showing-page.now-showing-low-power .movie-banner { + opacity: 0.62; + transform: none; + } + body.now-showing-page.now-showing-low-power .movie-overlay { + background: rgba(0, 0, 0, 0.52); + } + body.now-showing-page.now-showing-low-power .now-showing-top-fade, + body.now-showing-page.now-showing-low-power .now-showing-bottom-fade { + display: none; + } + body.now-showing-page.now-showing-low-power .movie-logo img { + filter: none; + } .topbar { position: relative; z-index: 12; @@ -590,6 +613,19 @@ var settingsLinksHidden = <%- JSON.stringify(String(typeof hideSettingsLinks !== "undefined" ? hideSettingsLinks : "false")) %> === "true"; var nsUrlQs = new URLSearchParams(window.location.search || ""); var nsCycleEmbed = nsUrlQs.get("cycleEmbed") === "1"; + var lowPowerRequested = nsUrlQs.get("lowPower") === "1"; + var lowPowerDetected = false; + try { + var mem = Number(navigator && navigator.deviceMemory); + var cpu = Number(navigator && navigator.hardwareConcurrency); + lowPowerDetected = + (Number.isFinite(mem) && mem > 0 && mem <= 1.5) || + (Number.isFinite(cpu) && cpu > 0 && cpu <= 4); + } catch (e) {} + var lowPowerMode = lowPowerRequested || lowPowerDetected; + if (lowPowerMode && document.body) { + document.body.classList.add("now-showing-low-power"); + } if (!settingsLinksHidden) { document.addEventListener("click", function (ev) { if (!ev || ev.defaultPrevented) return; @@ -624,6 +660,7 @@ var fallbackBanner = <%- JSON.stringify(baseUrl + "/images/no-poster-available.png") %>; var settingsVersion = <%- JSON.stringify(String(typeof settingsVersion !== "undefined" ? settingsVersion : "0")) %>; var MAX_LOOP_CARDS = 100; + var maxLoopCards = lowPowerMode ? 48 : MAX_LOOP_CARDS; var curatedWeight = 4; var sourceMovies = []; var listEl = document.getElementById("movieList"); @@ -639,6 +676,7 @@ var reduceMotion = typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches; + if (lowPowerMode) reduceMotion = true; var disableAutoScroll = nsUrlQs.get("autoscroll") === "0"; /** @type {IntersectionObserver[]} */ var tickerObservers = []; @@ -1298,7 +1336,7 @@ if (loopWrap && !loopWrap.hasAttribute("hidden")) { updateShowtimeScheduleHighlights(loopWrap); } - }, 1000); + }, lowPowerMode ? 3000 : 1000); } function stopScheduleHighlightTimer() { @@ -1440,15 +1478,17 @@ function startAutoScroll() { if (autoScrollTimer) clearInterval(autoScrollTimer); if (disableAutoScroll) return; + var scrollStep = lowPowerMode ? 2 : 1; + var scrollTickMs = lowPowerMode ? 60 : 35; autoScrollTimer = setInterval(function () { var h = segmentHeightPx; if (h < 8) return; if (listEl.scrollHeight <= listEl.clientHeight) return; - listEl.scrollTop += 1; + listEl.scrollTop += scrollStep; if (listEl.scrollTop >= h - 0.5) { listEl.scrollTop -= h; } - }, 35); + }, scrollTickMs); } function renderEmpty(msg) { @@ -1528,8 +1568,8 @@ } var pool = weightedShufflePool(sourceMovies, curatedWeight); - if (pool.length > MAX_LOOP_CARDS) { - pool = pool.slice(0, MAX_LOOP_CARDS); + if (pool.length > maxLoopCards) { + pool = pool.slice(0, maxLoopCards); } listEl.scrollTop = 0; @@ -1570,7 +1610,7 @@ setInterval(tickTopClock, 1000); loadMovies(); - setInterval(loadMovies, 15000); + setInterval(loadMovies, lowPowerMode ? 30000 : 15000); })(); <%- include('posterr-view-hotkeys.ejs', { baseUrl, viewHotkeysContext, viewHotkeysEnabled }) %> diff --git a/myviews/settings-holidays.ejs b/myviews/settings-holidays.ejs index e91bcfa..cf67aca 100644 --- a/myviews/settings-holidays.ejs +++ b/myviews/settings-holidays.ejs @@ -56,11 +56,188 @@

Holidays

-

Holiday settings page is ready. Configuration controls can be added here next.

+ <% if (typeof holidayNotice !== "undefined" && holidayNotice && holidayNotice.text) { %> +
"><%= holidayNotice.text %>
+ <% } %> + <% var _holidayRules = Array.isArray(holidayRules) ? holidayRules : []; %> +
+

Add seasonal rules and match tags. When a rule is active, media that matches the tag appears more often on Home and /now-showing.

+ Supports fixed ranges (month/day only) and dynamic dates (example: 4th Thursday in November for Thanksgiving). +
+ <% if (_holidayRules.length) { %> + <% _holidayRules.forEach(function (r) { %> + <% var _mode = String(r && r.mode || "fixed").toLowerCase() === "dynamic" ? "dynamic" : "fixed"; %> +
+
+
+ + +
+
+ + "> +
+
+ + "> +
+
+ + +
+
+ + <% var _wd = Number(r.weekday || 4); %> + +
+
+ + <% var _nth = String(r.nth || "4"); %> + +
+
+ + +
+
+ + " maxlength="80" placeholder="christmas, halloween, easter"> +
+
+ +
+
+
+ <% }) %> + <% } %> +
+
+ + +
+
+ diff --git a/myviews/settings-sync.ejs b/myviews/settings-sync.ejs index e381ecc..095e2cc 100644 --- a/myviews/settings-sync.ejs +++ b/myviews/settings-sync.ejs @@ -151,7 +151,7 @@
- +
@@ -250,6 +250,8 @@ var startBtn = document.getElementById("syncStartBtn"); var abortBtn = document.getElementById("syncAbortBtn"); var clearBtn = document.getElementById("syncClearCacheBtn"); + var triggerForm = document.getElementById("syncTriggerForm"); + var abortForm = document.getElementById("syncAbortForm"); var libWrap = document.getElementById("syncLibrariesWrap"); var libList = document.getElementById("syncLibrariesList"); @@ -424,6 +426,17 @@ }); } + if (triggerForm && abortBtn) { + triggerForm.addEventListener("submit", function () { + abortBtn.disabled = false; + }); + } + if (abortForm && detail) { + abortForm.addEventListener("submit", function () { + detail.textContent = "Abort requested. Waiting for current item to finish..."; + }); + } + setTimeout(poll, 300); })(); From 9822611b3da5cad56c47695b92bd8d35b9e28cdc Mon Sep 17 00:00:00 2001 From: binarygeek119 Date: Thu, 16 Apr 2026 03:08:56 -0500 Subject: [PATCH 17/24] Update poster metadata database snapshot. This captures the latest synced poster metadata state after recent holiday/tag ingestion changes. Made-with: Cursor --- config/posterr-poster-metadata.db | Bin 1748992 -> 1748992 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/config/posterr-poster-metadata.db b/config/posterr-poster-metadata.db index 856cd0926f24cdcab3209ac5fe8104832015f51f..4b2c0e7237ddaf6ac4c7d772a54e63ea414d83a4 100644 GIT binary patch delta 82888 zcmb@v34Baf^f*3mnKy6Vd-G=AWD*g?AR;2NSb|uRklG2VYM0n!kEKNsLe*9hBXdw~ zEv>3GmKs${EJaHRRa>oHXmvpsRqOxU`(`qee&_r9{D1$?pU>QT&$;KGd+vRAJ@?$3 znKwtwyg8zvlBJ%(z-%%Y3=idoHm)rhe z^2FuhM7fK6TG}edNl&F+(pqVQiBhCw zm%=5%{8%!We>Z<;{>gm7eAWD=`GEPTd53wud9!(?d9it!Ip6#m{?A9~D8~OT37E1a za+`xW?85xE%Er^;L~;4f1@;w2Ms!7mr-$BM(f~#8v zalIElmNP9F;<8oD$$NEWsCk}2SP+e+dS#{Ayc)xy81A|v7u{|vrlXs=Vu*QH-G zC=b(gc{fQh2zm1cAkb!cGP;eIXP}$BytWi&5atm~z2#PP>nyK~ZqjmAYKwRo!*yXe zy74dx-4w_~Hx~M$TN~1)EQ9b?Ck$7In&{Sq`sh}J3esf6SI2Oqdj`5rkkGB{ZZ}?9 zg%Lag72U6*Tgg2HU5C3Vx}olN=!UrCrM(7WZWFAryn?#a60Y%TY8^o@o%?3`dILg;;jOZ%au{T8o_MPFdCpF-MBJxq4KJtuDWZ zgl0s1?mA!TI;;HU+EqD?w}fNVqsq=O zORPaDP%X&@p#V!|xKKEPgkAAfMlz3fuBU6wU*_q+RgIRnrH29{bpxeViyZ%{l)<4&BH4|=`NGaHKRp@E3LWBNV?H* z$Q>wGv^PpgYLHUDNqmz=4SLIYh|i*U4e`k-^&7`Gtker7?Ols0wIp->ER)vV-N4Get zUBQBF(y$Y@$H@r$!}b<6TVW5V(3x zAlWcRP+Hmu6_Wsm?$~Ll2Q=*!n&5iTxgpE6g}fw7f@?_TOE56SUR97kLedIwXOLDm zLjL^ApRu(X1nXjSnbFo=*59nht!u1Tc7}C%&6=l>0jHJ9SFx}ohKw3DWoXVA1&0DA zx*STHjfs1eLy5DHs#ZCaxfoF~Jms8Bf&Sm6 z70hzIll2Wh+rA(aoSi}{dzkOT3K-9OZe}@SRQ}+9r~z6I7YPIEhZ;R=9l~USN^OK-tqP;plyZV9<7#A5@6}DuDnvAn6FR1Cw>$Ad83*$ME zVLZ=xZhag!hk>m37?bDwl}r_*=W!w9;M^sl!cJl4^8q4jm~9Zp7-nPnUKf`PE9zeB z6^jKhIexMX;0P_J6{n(LX) z3vT0NzAx8;bsC-E%Aug7If10w6W+;_VGoMuE1|@B4SGG{ll7E|O>h{APj1qL zWD;I9oTPMrCzCu5KINT|{FFZugfgONSYRFr&-f_#;2G}~rySFiqM?*DYmS3*eU4m} z7yOUaXd%my_AVMiNcDfOzYc4L@)b~!%)fmlVE*PC0y?Mur%3bpqQNnwGS}IJ>g??4 zpg064{Ad$udirRBjkEqIJ19wz!lB?(#soLlSxj&`L6YFzZCn^U)dWe6bG9ElIA@5n z_1Fm$hm42fuel0&ujY&zHDt6iBYWuBA#u)D!*gCAGI7W#=Sw4U#tt1j9x>VDM~$7} zOdm6N{E?3*INKcAIeN&{IA@0(Op`Ny$e@XYsD1Y6ArqY4vd2sqI|jM595Q|kX45Tu z&~O|H5YYvBj(4^hGJeR!DRIsoIV6>{)!0!vgOowTbH?UJ77e1+d24%U6|PQm&2^>} z7asGtFT7RKFeR}`eEo(E$oF&>4WzW*_EmR2=Y*;Axs#e+7XhTZ(-^*hf7x)v0BV62 z2aQX(L>OGkEP}beGgjBsj!stk3aXuDerA=qpqyj+vdR}Q;vBP(m3zZK6#flf`I@QA zO3z`=*N9hIfp#8Y`3|H}e=Lk8zOo43qy8EA>OA4|5!Cqxefc$*{|!lZ8Nx0Q{5vp_ z`mN#I1(I$hFc(RFb73*{XG5h+B)`Ydp8CJRluIQ3HxMq9e49f<;w!6RF#1*{KAx}A zscgW7?U6R8RmSNf0O@)#84G$99?*o#!Fh!+I0u>3zXdahFWWp@t}tyF))EE}za@w; zsCktT4u$SlNeMoOTUSYm`anXGiyQ}q_fS~?BGzauPb08rlv$EeT1-^7>K zLDcueKL#(OZtV9{E^wqO25FG+a!x* zD7{U-#~g^iL*QZ1kNN{((H(-X3wQ4@39PI@rJqQauRwd^E0VF9rsec57 zpGmw28dCog7)<@O;QE<-KRY<@lG@>6#$7`99JWyZ8eF}LwIyf3@n29rxdF`nl@x7E z`Jz2&pgCE&mgm;5OgNS*3ZDGNjC8fZ7Az;hyx*Cht+KUzimoq5I6bTHF@w`a^e`MX zST9&NTl1|wt?{a;UQvtHxoTggM6D ziEJ!wN+n5b3~3T~(HE3I>GE-G(@=c{to#No>JhxYyv?(OdY7`7Dc!xF9bUR~u&*AQ z0OKz+Rq4`Mi3eMKOenuX30LoxoY22Mo5Z{c1@+l~1Z6J87uazC-`;?YCEfa319qAq z_ofk9oA@X}>?%$+)P^OatWGnTNmv?1K)Rlmo>TZx2_NN7CM;S(Tr$b?4#gMV)pX(H zu`myQL(w^8d zIyPow1*Nt4H;Q!wu~aOc5)n`@`bs#7WW0G55{^(=d)aQLM~CYE#1Ks+GqanXZn&LJ&~jt1PEUx>nG<^4chz_D3j=`pqz3=_`sD zFdZwge={~;ki#gV-!tECXDEed)w4xGgXU~^LHV2_ZWY?X-Aha&Y;DfQldwHbdqgN}DWdB=pKw-BNqe?4ooQ?x+4W?m+0&6taQziuLs05c;x!{H zVk_;0tyKDjBF_BmYsLd8%;cJ#aH$iUPv(ifo!L@WISuid1WJI`(5=5ho6zx?m~f0xK`@BjSYbvm)4DVnd+99J@MHU$*~SxZA~ z#sj8!W(%YrG*vh5$KYH&xDSH^^x$V0Y^(?OVo=kApPJ$gclF>N3?89D&SQ#)2dd_v zthwC?tuc8a<0nYgStr|t5yjh0IvIR91jmG%pPEd@Zw&^nJ|U^3!&eYi2wVeu1^XjS z3=0nGngFQIYD=NSs7cICNK&+DW*7BfK()|XkR4N@G8D_2%yiHr!}Z9GlGX`so3$7) z_tk2_m#?5RvlvtQyZh!x;KWNeqPSF;##xlV*dnD?{`UYMHv4@FRs1 zrB5{TFZCe1XLx0}za2NuZwvTM{u(7m!|u^?EttFA6a)FLkTA&FfedF2^3oYQ`|B?b zYvM=9-mBNt0liXVY4y=hSQ~@mbZiU;+v!2nUt#@G0#vJE{m~%N&z{$VQ5f8!2Wwz( zt{x=n+5vj78U`EdK_><^J&1ZmtUt;u5`#x*kgMXWKcbD}PSb*phrS47ij*N&#qFm*axOC-9~uH454h?vPXwp%Y-cUTu%2V0w4!_<4~33UxL zE;jXyN^00WF*&6k>csfW4;V|T ztTXL5EjA4>C7KxF96JiemvD8gJ-Av-F;jzm#JF`EQeFTlCy8h> z07(@NP z;N7n{+@lSE*wZ8)px0@xIqZ1FQI#yHJQNuVw@(w<;~?V<$}Bf2FLQcZi_#< zvz0D3N*~~Aq*+TI7fbqvExi{mmI_+(Jz425xZ9G)eU!d#$!%}Pr5MmuGaX!%VD3)C z+1P~j^=TI!#ulP45$l63gXE+y-)0^2(kc4E!aKEmbSx~+;A44X149gqU1@iUWQRt1 z5lH8Kn8IC22a11*#|ros7pDiCsQThm-_8+`=8Qe1|4V!f=_%V^Le8d_k@)@*)E&Ke ziTAFB38=H4MOoiC;MssLw*s%y~4Prjda4})_iYnJu;oy z26vrrwBhUUVhse}uVjxhax^^&3i2Wv&V8$bo|n|pvcOOa#)gMDO;|`8NdR4te<+;Q znUC_UYRjKzU~wnhx+-r`bU~>#3J$dAV+E;{BGx#BD8Uj*!^K#)wB$I1wCI4cT0$s3 zJ;%4?W++UC2AY@hmmc2$H(@tB@Vub>K@m3^`nKy2z}69~5C@qQ^abTQjqh5+S4&kW z{GgGq%KwDQ*sS1YN4`7h!O5L4G4|jr^aXjGT8w)xEKX-uNr3AVw}djVg!ncNA#kNL zN%IJb(HG=hG=5F4uLbPH87>+QEw)F&rOtdD*`7QFl0U>`@;e~2ZU}BkpYQI%Gpli4 zG*}Gx4eD--TgjJ4NQ!vDv{IO69Ll%lBABO!`?ym(E#`|wlZkfG)nW+I>y~_t8-X5I z)t$N?GO2z_y(aOElN;;G>({9s(yP2qy@tx`UDwoL%Inyo*C^@fLObEQ`U@=zoHx|7 zUasX;;*$coR%?ibxza%WJP4`1Xd)%enn1a}@fEht*2(JrAfypR69T!?D%#j6DA&eC z<0)z9J5+s7IuNW*Q612^2|6vBsGkMlU#}=v=bxgI0w$zjU3`$DUe?v&Nr46IT{Mo6 z{zo0|Zl-$m`Td%y4FMgmHoRO~N6}cCOVeme4r81^tX{~VkeP@S5C4Ku=#D} zYh4K)m}r-gnVBb^F)ZfS8?1j=e?Udt3F}7dJJvDDpU0oE?obZb3p4XeXyR3EB$ z)Jy6Y>Rxq|x=MXpouQ6Zv(&C?OSQhSnX#@h(r7gr_y_!V{MYAJv04ox?w6c9Ww36Gp#c%Grehg%{0u^)6~wCVya`RY_bT? zg?qwH;jC~N&z3$AiiEks>%wrMm(X5lD#Qs@1l9P$c;9%-c+PmlxXbvV(T%gqWMd9) zCOfE6YN#q${;~XIxor8;@|k5bu8bC2@+{*l*_LjWR+a{qnwBt&NqMCFtmIu$PAdD9 zEy{b!5@n{6s|-|LRx)tU8m&}NMEPI&uKcZh>i^&W{CfZYJb#dc#52DCY_g7S_sfWBfQNI-$QVLy&s#5PMySA&H+1M&}xFh77iM0C_ORo*9H3nnB7SdleXl%yCQFaV&;+kH*NU zqmlOGk?2fAhe?7*!!hdQFhpz_W>?{t!6;W_!tgg_18)vQrx}qea;#lt#aggul)Z|r zX1VSpM#1JBE0cVY;nap3=wySBBIt%yDiW9Df_IfPK;<4i_&-zU2#>k4E(qGu#iN3X19F2T={h zwD*Ikiej4jK~zUEaeh!iu!5j`%@TSaFs7AP(lJ^)F+EimAY+hRQ2!YI$YR48j3z4LJw2H6AB|fVRftL=e!{ zh$<_ft?@V!1hh3CCj#2m^pT#&iNN2~xH0^{P<@pBb>K!}Cp(JqmW_I3_(%0h=31p9{L(6AYMo^3$+1pVRPVHkB~ z7?SmZ@L@Re{5BAK`02se*PmkNW=%z~m7u@H;UvLiDVJP$Q6BuZJa`4(er&6gCu}c| zUQ!;MQXb4I4~CWljsKPhe=84eEf21wVA;@WEP`zhZIzoFN0cWUQXWJ-p~32JFL4%J zHZZ>SQQg(Af2E4{ayU&=S;H>A#9;Xo=G|9!LFWSFTo4P4aZ$gQW6hU|JBI(YECxC} zRa4>BKUE9dEHrk6zn`c@1Z)qd$O!)baw51n5Wjoyug01z(DV=W5JZl_FZ(HJKo7Bj zFqa{YB_~}tRIW* z#!W{49CU0)%gRG+%futFktA73CX%0qV?$g*W{`i$cz=*It%-wB6ot$nlL?Cs#IX|R zyD^yTLn7$BF?LlGca-P59ZV=9vkkTl#q8VQJj85*%7ZZ6hoo_i!4NYB8}nLtV>EV` zzBs2bt686$l?G$- zp+x*j#A!itnG459*;8Okw8IAGXh#S5s-^>H;hGLBs;-qNM-}+3h688J>KH0YaNxnV zwOl4{!L&FG_lv`@39@1x?OAMk-BA@b#yW83eFB$jJBGoD z+L*^i;w&Q0r5MLsP*UAdR}+6M$6ovaRPyz|Aa!sH%s3r0H=qil9M{Sd=3{@@1zSeh zD?;KZdrsL1RRy+1Ir_kmD9omI6c$w`q3fiKO=>uL!)w(sG%MOsb@yr`YpZTX<=S=w zvt8U|J!@U7Yj4>W>TTAldenJDgA2}LV?)zWb0hHzy9)Xi8`C^mF{?4e8byT;hqNbSxW?L+G9j*;5In91AXNvJl{2;jd z389yu;%A-u8sN^h&4>Zbw!kKoo)H(1UU7jZ}@JX5jDYsA#J~LuOQ><$Pn*7fb5^{Cu}EDwh0G}wOQ#) zn0ElnFIR(%gCy(5o(Ttygk!-bmA zaX85|lP1{S(Wgt(PmZ@`qh-r)!%;{DdrpopgKQg7rVD|oL| zm~BkP;mBRClzR-Y%iM8LE2$VK`Z z`h0^4rKj-IH;9+VLxl^3dmWf`fx!LY1N5!(TjfP(D&wJ#{1>nnkytK;tc!%WAuPE_ zq__o@FA=x{bhsoW@M;|#t!7*jVqq#lD;aR{5=piZjTwc7RynmCZ&&UfC)~R%yv-`9 zXq-q09dMQU?I7h_0=I_c-wJz8ikT*at(UMXNasDXuL?aG7=E2p$YXeV4YRg};ptPt zb)g9~LA2HKA1k@suZdk<4(z#3$d};=z;Qb)k_KrmjK#*s?^KEsvBMnJuP!!q>(%{6a2=ZO+ssbCb-7`ASi?uAy8< z)sF8kKt=Db3pkIih)oQzc+HO~%A%yUYg8xv{vsr8zli(VONw_ZlHwaRsZaFRkwpV2 zsn6yXR=v@C+~NO9*unZ8TSnat)nwFbG{lQ6I`sSGl|LraE1xrg~N5<#LWL>P-o6 z)+Fj**O_T}Zu6>G&DCpKv6pTAlm-pslarE2O(YieBGfMDbVA&9hSOC%{o5dPj-sBF zt5^MeXhxJPInS$7P7Guy7QIRt(nAE-tRYU<=^@@DgmQFYMLj4(ExMv^_MG&Z(-kw! zXK)_K^5vpeC~1ijQVFsOR3l_uFh#js!ykL?p1n5z#6)`cO_P{~Cx1*cW~krp)5;UO zPs;+eFCI72V@BmWugzzQvWHlF%EJ|#d|TcpFJ<;ZS{mCH7Nv>tcrG#%nx%`=1nCGJ zdS7t9)2VwDuJ#L>3yLkp&4N;oBDyBX&X2CN6miuN16Ny#cvN(iM7T4NFg8QXXDvq9 znt^!fCQU%j3k2x_4Ua3u>l3jri5mo2pTWnr!HMY6OPESNgr{$WTQ5rKeBbKyeA!y; z&alcwaJI!5c@&N5de`LqXiQroz%n%K5~r~W!0+uyQr#Xb_Z6Q3Rz;d%)`!@Nhqf2d z(6AdpxYA>zz}`XZ1NpCpRMn5iDp1N2Bd*YPcMuoAx`82)dQ3EpNt|TY58UKpSlm(U z2{$MEGSRPel^qx0-5r|Z*EQa&V9G8$6cZERW@U#rvs_AW5U&;JrP z1VOr^)3otry8=zekcsWRP~C>mV#8xIlIRUeNG=>ud8HpI{9qlf6~?_G@DP_tYFl4G zrO)D=p2wM@sf$_p&d`~eWkB-TP|4sPI$B^FDmt`(cP?41$U9!9N2oOB!BA-)_BUsCuE z{7c~#5VKCiX($)wt|Repz$e6)euN*^iMXo%7S4Pi;ws$+3qK_BRblUk2;*|`we=!S zDgDY#DL58m(@XqF#FKbjKz>A0Y=HveE8oJwk1!|cXQ;A)a99YJH;|lu2k~RXpap*hKi;g%z6!ehox#CX%~g&StScxiuFs1>=g( zsg^g-C#yi5gEB$eLfE6$WeZ7u15Rxr;zYo_t;COnjBO$sU$Es1mRnOnyMq(uk<~`x zq1~Bnq_ls`Ef#kdoB7ptli{I3y=5sj|1BQoDzjF@L+sgjUnjJvGaXrQK=+O;mkZS> z7dL4y+^?k=!}R+^`YkyeEX_roDEZb34lj8t7p^h)xYV8hAzfI#s3W~(G;5jK2KK*U z3HKxwsnvYfiwH-+>qXVT$x9k6`O0L5aVykNDA;S`p?HN_BIYL)b)cEu-0mComajzF z1`R&uYC6W|9iq$b@OAF?R->Z!G|Q*&sBJvUR;k-q?`0;M+e|AH0f`6mhSS zZ|9+#V%+)=JItM23HJkSC_(mPJWN-HL;85>F)Itue1e2SOBRhg5QoR)3nw74l0*^C zX}%-+O?u*fc07rHAFLClvw~8c;!3BWaf~oYLc1V4WKEK~^ClLpJs$iU5@78?AbpUf zn-JG5uf4By*pv90^e)TFCO9)$%46Pz%qdc5UJk|W(V8hzO;|il!tGHqygLQulupqs zOCICyu*FpAB2UfJ$89fXo=!mBO64%5zJaNg4uTGP2Ll+Lpbdq2QjaVXi_hs@gkVWzR zDZXo-Z%C!%Sl}5zgu07g%#Ch923vO3G@XyhKx_} zq`F)iy4Ao7B{&`1qL4tY38}=FZo(MqKY(|mWE|~&hF&#g983?v?V5y9FHdN+{2FF7 z3f`_Iw36(q1Sj(;%LxE-)r*kqv3WsZ|A<}c5!cVXkv)wg^cFFZWN#|6yK#JfR za|6xC%&W}ftryH~VcKdXJj_%}cqV);d@S@pLlxF|$+*?J**FI_uU2wcWj|C{qu}tp z57MdsDU3v4PLnoQV;z92uQeD8fhX4H%DoW#k) zq3cJ=G*(gJ%ts2|w27fKU2k|xD$js58wlNQIJrUT&Py4X!EcppdIlU+{g^N|(nJNL zt=>JyGg$Sp@-auQj^%E|)y=4l%5+wG1ix$~)Vdlj=@Q;NYQ9N{DHCI8bm<7 z7F+6-5rMQhT2kF98;BMz#tyASGL7eXDYjlI{Nh`=PK!>B-7@khO(m zf1E~?mSV)#ElQ$mR$WAD6glexBIC9y@q)!d5x3+}oQ$u~aMxda^XXX}nx8^KTZ}W3 z<6R6_L51$~tqOS^13{XMN9z&8Ip{hER}S5_Dg9yBRt1?oqRdDFeZ}zo?qY?RX;OO` zGRbx0@KU*R7j+&NZAoRSvHzF__5{0v9btJFuY@VqweoVa%QA+TmMh(fs?zX>eB88H z&e7GBsH8*%rD=e;!L*+!D8(sad$E$~*TVEF`oNB*zRKdI@U3UbWOWv}H)bgQk zvE$WcGFA7*i@=1@jPi`InsLm#g4dG?Q3-1NNaFplCvn$nCJ#oR?z|8R>% zx~F_Ec99~v?<|eD!KSCC?qan0FZqDiN z)a`FmctQ4R|JUPjKo28ICFutzg{8h>U?db9)yJ$Q6gCTlgFe!pJ&NPT%&iI!&jljx zGniq*LdrF0zPU0=8YQBVTu~^ZnkUrhsccr`7+$W3qf%XbB7lzVx;zBbN0;v?dEo*y z7CI=Y{g_(dj4?Sm9CabVirFjrQAqA(-;B{0REvuG+*@!!RzK#l4KIWB30BJjMNQCe zFlI0DRf?bs*UC3UJb`RXD=7a`{EcPg-sL8=(0CuVxAvwnqSXo8fj%YV*=8Z~)2CWAhtRJMJp@160n9q-dQxU)}o^OQ6V*I>lD6`vB&)xn3V*lGYedxIp3 za&t6MRvP6`ZUIADRa*pcwg;h4bhCtB*R{g4A-M8sKh)~gww4no+|3Y5@4Fq^aHKqA zxB1L+0|l4d21+@F>b$q-AY~JWts#uevCa%4f}<#pima<~q1p(vqIZw5`fP&(ZM;Vm z&4@-BVE9ODHLMbHIo$Jjq&0-`vJIq-DVk2%dR6aa9);JEY+luSIn(j*qGX#SX;Zas1&sK!$GkLwh9yIa}U{ziIf}kH7wS9`X|Y>K8S( zHo~X642DDaJB*kPMboS`di6rcjlVDPw-kR9@b^3ZR^smqq(XZ#wCgu8 zTS}E3@^Y!0Inwk4+d?Vz(-a_2g2AE)ts?ZVtO?MrvbG~gqUvsiCQ7_O zWlF-A$lzcVOoWd25T;yrPjt(K-a9a);58SBsH$BI!nvheqMS>P(f*$L5pGn4P}^@o zNW-m4vj6|ioEa9gnNWOth=srNXnT16aR$5gcW zE@71aq(XUNvLlf1g$UdJ+WON-0mK&Nlz}rO8`O!$qv$0wXPL8x>4xbL?yMN;8Brgf z=)f6)+2CIijFNs^&eUD)(V*?y7nT^{W)bOapE^HSg(TIa1LbNl3ky zXPnison%xG%2ae$37{lbh~T{%K`9g6l_@1XoPz^jhTvgPloo8lP~DZNpx&wO{$9Km zG$UI3BuKl6b4O58)GL#z4(%y$$x=)6jnx4kB0tt$k&=4v?rf`#qV}w_Jf4w9aa4cdmC?h2ID`-su4I@mkL#A)Hg*%m}#n>&osd+)O_@yPmUUClRV1q$qR zhX%6lL@pb}CTPcla1C=iC}~;un57}{Lrn|Td?Vch}}*b zaomQMeuG5+y;4bKvvO}4TdcnjrTMWxw zgWhSj*63qvy-YVfu2z|7r*s{*rrVmsTWxKqC7K4^JKAtdFc_M)L|^&=Mzply>c0W} zLwxy^XHF{{>I{{a;b{gEC>+#z$%Z?m3NWAgDRAc{!r&}~wI&R%z)QqeCd0(mHoQdp zwEP}p)-`g^wJR`fkcraBGoXzPH%XT}*>LCaE^KH^lJ0?v)DHon9f4DzA@yItVB*VX zz}1e3upJ(>v!R;uXSmv)$SOik2NM4&ET{exI8JBg74s*m;mcDZGGxmHyipGaO3+Eo(1C_Qf0C1knTz)_>Sr*ymN}LvEndsJ3JrR<2|ibEW4Bx%K~f=G5Sh7 z7a!cDn{WAyr=*Jwt&QZZWNGa-qpm{jX2YY6+H|R%_nA)%(U!tw(WxiITc+J?xQ|#* z@!C?{rzE~?!+nar1uD+Mv)+t5Vp1}u2lwqB6+r%i4xC(4&2(6_s78Y>TkE9eVy z3(BS1+vItYbQ*SMnw+ry61 zrYlW0$KE=vWuo4{fG#)q0Td zG8!fi-qDYR-8-wFEgoK!`ijz<61?%7Z|^u7uJ^-lq)exH=IhWX*xH}efu7^dG59f_ z_qWNAW_REb$z+HhV50_gu~0m~RtL5Zup#=5-B~uw64#)Ih*52t7@nxu%Ds;@7*E#r zD=Caix0N70M0QxNlODpPlDDy-+f3VNy^$8OUTJ0h+%546E>^6wY&+mqOH#2mD(mkL z@F5~=z74NpcY>z*Sc?|@!{1BB7C1kjw1k%^Wwm@?>6=h^^F~~6m}Zj?xqu?_7I?ph z#SGcAv3V*D;l^y_DF034&n`oaK;1buw7hFZ5o^X(aKffJ*vJ(ld^?9wPNEToQbqkB z&OAqofTTBV>jY&6jp@7s_5F|E#Aa;?hZ%E8zfft!+g*GbfXdM2EgL>xqo07~PSUWy zG=tM`kp?o3(w>d*8u(c%QMlxU&yL{|beu=NV;73wzR34|TSDY~(%;&{s`*4ry`kkU z_oX`u_vhPUc?odKUl>vL!Vw9w%6J&P03($>7+KQ0oR*S+dglnGIhOv5N1y6KQbl?< zE|`vWwumCaC`AXiFVD}xw#e2SLJm11bw%J;uy_&XgJWqdXopY%bafH=dOy;14>pmp zp%V??zkpq^_uIA$f~@xv4N%3{W3lZ5D}Mslm^Tr}D{qn>u>{O*GaovGvbl zx`6S%Z92@kZ)@Qxxo=w{W5#Sd&&yA3TR9WE+|ZiIGQhZkkT8k+hb!ei=JL5dMWPnl zkvo)Bf%s0$d<}D_RYapZv6-}`wPPi(3f<}P?4?R(T)3$D(msbxy?y ze2ztrfcl(K~RXN{RfQG|xO~rQ< z!v<qoKW1TO{V&-SPU`QNJg?rai9l!a4-q6RPgIl+?R~Cg#ZSTF0QIiaU;ydUp`1 zE!ilz(kbW?-tMjwD1a>!xi-iQy2w=Bv4H}Rm!_ddQqVQL=&nsjy_=2ndPupuUeLue z?~b8NyOhw!T4&)~il8(IN*d*^rHiVov&dUxFtZ^xVswJ3v+V9@onEiog~`bJQgYCu z+1)h*Nr}M)G;17`G}0YKNz0bn2~ALr51MGcX8;10+Ng`DL5a&2@$aV~v7>2F&JK5V zO6=8rJ!^(SWS->1U~x0;;~+Z($z6@myLHuCVRIzE-dy{n{{P&t;)rK4k>6i&R!PFpe?qr=_-)Vj|12c)p3%VzaayO);UR7#(I+ET` z)6N7Dy`H-PCAI1AEs&@3bcN5tmRdDHXHSqsVea}gQCUOD&G0F$m<&`}p%Y9Q66LNJ zDAuhEEb+LP@Qa}ntV@KulL)<6Z`-wvFOQq8@wGK{HU$wa#+|5_M>ktO)CTjoH;@g3 zj%|YuJ`Q&RrS~>ta*PO@+i3?I1Z|h9VTM7yiyFPH@_K2ixtSO#{9(MxALDkj>zSnn z(8l2+sb`uNE#}+Yx(S$fpS>m@84u16y3e+`sRr;@jj^oO^Cc6+-!BaoCl%?NGh2PKVh zx1yw8H8!&lNoStc&?0X^(54vSZmHAj%4xD()0<-IA}tiq*&O71r?}H~dGzPA$({*5 zdt0kXoP9y)W87(hLY-ZVLPahKs;}YQsg%?^*ss2fi@9C7K?nOtcMD2dHq7oHk9?&G zn)li1a>|`BcXLWy)_cgKsCr2YCt~kGCs^;XyPMHu-j769!f<<%_G!carKL6_Bj^R? zfV^2+Z0;#03b&2t`J>!ic0AMB(5x^$9FKHX=mWlYlFt1C$GLqo9X=gz*&ipsBOT`o zeYp3knR0(7*W%l=JUH_mK7~*qy=?^@?tN`KeKsy&7teP1)J%b-DJyghqW1;#yg8^r z{A-TmmiOxgk}4~74WhUCC(K2z)8;yS8pJ?Sbp_QRg12nsiV<9SD`?qTub?VK@IJ=! zU_O$j%y&EpB0$s%szU_t$eO(XWxc<^5kVZ^5Edv!y%oA9(L208Z-i@`(Tg0_h~pbs z1L-4H=;}nTzL-4L3#Z<8_-5-sQgwx{PV^R#*qK1%B|!`5SfQ&Ey@MQiJr_#4ID98W z<+mQ*$k zY*|{Rc17kCvn*a^c8XiLJK_v66O9QirU$0OCYNcjsiBdPuA2nmBDcrzNbO}^%hh3@ zvR{!m^;8f0o=}M$D|MBsoBy;tfr(jKtiq=A=gh}gJHL*fs6Ul=Axq0-mCaBqTSH~n zC(t8XOW;{M8W+yb)?(pJL|c{P<<)atmzkU_Ck?1&$B1 z4$_)|Is^?Dl(rBySVKkGS1@rfGLx^BXNDRcG>JP>9+}l0H`^};kWxR zG*mdg0sDtwPKph$@FGw7J|1b3oPUKW)c+ASQ2!=er2Za{FpNsH6*7mD1hrwyaFT$9 ztHZU0sE<;18RB4c4sIQCa==~h+SE7ZvK7!==6f7f&|IpKHq&Ash ztr?!(qqH!_HKH??szvz%ke#F;^8^VmMr*a<)iIg|FOStw4LBWdLlYTBgNOLauW*~f zo!}Z#56bDdz(X^Kv05Dqv5WNEG!mw;dMwt;#VoC=RoPKKPgLe$buZgJPUGzY!)Mzd?qJZxvcs)kqw%(W+ZrToutxLsslau019x}I)qALxy3RM;m#oD@ zHFSIfZeSwC{W4AD9qP8N!{I*agP=3L>h4BKy?r+6LlkAkhe5g8+`7G%J^+TDJ6G%%2VI5 zq|PFO&eA@q^grfX@*GGPri%fUD89cA(G13yGRfEoH|T~|6?+lWq^2->DH$rd!Aa^{ zptg(j+I)D6_|iY{ql@&I+u&G+zD0uVg<5mSTc&kp>YXuHy(y{X0P-}{s#Ct0=G-Wk_b0t#B-@ulYq#U1t{0?E64JpJ| z#y}4Bi(&aYC}NeRxTSdb4pLea=)Q`KynmPH%~Zh)ES8F}V-;Z+4z_m*yZVs&u7>yI zH^7s35iebaUhko=O!Ro(BNEH?;OzT^a5Ho-)bIh%0vw#r7idYY8=WJg<*CG)N;RN` z%TJc8$|+nA%{31dyO~mi>c+~b<#@{cKy6$m!>ZNzC$I7&+(T%0@(u#&SA5;|%w=4M zqT^qOdjGx@u8$nG%9g->QinS$P}KAZxQ9sG9#m9^dq5y* zwH;V0_l}^XHn;aSr+&D*X(tNsXlGE;%Ic{T+uIUb$4G%>OK3f zat+r*Cw6OJ1u0f#cb`D6_dF;-vpqq%D(>DoseWeN?^7h*_-W9AMRfNfq`uMWGo%zh z3p!dk+<|vG;lXEQwA!bA_P=R3_8HVumi0(Vt);og z>3KY%T9Fq&>7*54;m_FEC;uEUp#|nK_P=?MzhVK`?*`37a*rY8Ue!+lsu*C@uR&XJ zquMBdO74HdfvHQFJMzTdLd{fB)aLJkMbJ35K~%ca7p{NY*Sb!u7tMWJ(*C0wI`E9 zuJWpV@sKoLzsieG`;KQ))p}691PA5OL3gfI11#chL@Bs@0MI8DEA8xpI zSi@H#^miE!^fE=kfFnpDe?}?D7^a_AWK+20f$vOc4BR-P;rUN7#m{;f?I!CU)$kBW zf$c|0gnmLrW)sT5qTkIuxC(2h_U9<198OabwUwSjeFD3-;z8f0&$St3s;P5K>qe%U zJoE)+BBj@!;sxS=j%j$XQV-4?$DCySoUyPJBN7=i57ka!a(ODOIYH!TN|PO$iIsEj z1j%}!Cq!C{{`W4n$neNNLaZQw4)LFIZ}3SG$+6N1!1tCQWh44;}glxR5PAI zF-ObYJtH73lS2P;q-)JPPWbFg4NsnX!7pDDp{nVbJ@nP)NJu?N@cOB9S0~!x7oOD4 z3G#=OFtZ4>q6CdRA5pw+-qEOM%iw=FPjs@uqO&UH(081e<8MjmiY$PSj?h;q+UCs0gsUD=0^c`M1HP4+*2s2 zSLc22F_M-(LG9_-$0+oG$J&=c_@}ui(?n_dlrZZlCMx;V&cpd9L5KJD?$;@OWsWXA z9y~?Sk<R5*ma2wFAG+>Icd4%)lXJg(frKcsl< zR=`ffU-|}a{aIu0zU$2QzWKAu_qgvu$G`6iOr$?>>`kQoh|(4Rpxr1lO)4YZeG@mL zl@%t^fbX)rrZmPzwojaCY9U1M54pAMKqe9!S#@Z6U%M*ir-5#2=&e*6u}Yj+#cqen zRqcKYurl8H%|N#|3@G&b49-R4s@PGN&DhTdsn{-{8yk9$E!O;tt&`?)g|+_}gdWfP zX~lYVdfP9c0*CUS#@qe2Zho22I5Lo){E;ZQWVHMK9%Eof!TW8Lbafqig7Qp2(c7Es zKL?SbG3dtP-e;my%}9Dhw0|FjGy^!w^=4&iuZe$#Dhh^6_FsaKCIU-Hy*+P>j7%TP zcE7Qozf>u}P^Mn>BYnwznk8t3wE#n4sXkMYt7r{cstk8tpr}h8U^BXMU%OYf8Y~ll z>`p996lp|wX-n>mvoCMDBzrj+4f8nHlt}p@7d>y`PD(!&UqIG@1vdUKGkF3*A2tV1IQN2 z_WK7rBXNUFyWiT+&)EUGwVzK>(h5J?@0#7G2@d2N0lG1?PyJ#?uH8fIers7j*BI~` zK#T3!F+zKp#hCCnbI?nsj*yJxTMXR&~mPtJ-#WrzaZ@Y%bpOsIscS4Pt2&uO}+^T|Hu>;?0Ck+RB(%^05nSD?OsZUT!1@wak-{Dzb zB%RpT?mImQY>5)kg9h)GB^w_dfD8TXJ`H~$WjN4t2A@8uKk|$pU_Vmhe|&=cX;6h+ z0nz`>sxYg9?+>_^remU#R(1z;YH4o>&0E@oZ3-%be)Qn2lFU{p)6rJ{OF{Kh2X8@d zWe}lW3R=*3&<`B+mz5y3HIgoBZND63NOgjK;NYDu4&rk$ptKD-{M$i4aPX>JliDHa z%Hvv1uqNoIP65CViPM3#`Sgb*MQ zL_|b35!qCNh>FM}ihw{MDheusC^Zv7Q4tXlhzmhP7DYrrAd0An?4qIq`KpL4^4zNG zrEaM2AMbhJ_c`bHJe)&MKDY0!s;;iBZ{2%qZASPjuS*?BPkMUXQNLn!R`^XPm?OdA zQw&dU7Jkf`KF4;geTXeEGk7u7#6HCHma-j!*WDEC3}NQCko25z?VG_VGO)(`F}O-z z7dY&QIbI7i3Z|&H*w+PjsqgyFDIxWkFT?+tw~hLkKkj?mH{H8H&Gaiyd#FPB+dD!@ z_zN6gdOiMoeZ9R^N=MkG_=(KT310wXF82c18JL?JZbIhdhC$Iao&1y=UY``Kh1FTb zbv+qL!BkA_R0?Lk!5YNg3M9d{E{FcArMvF=O;@?Q4irZU=Rz4xlv4kLjN{J${Hfv226iG8X$&r zohp64`h?qn>C2@V(~RfaG7Z*DaFMNv+_Q) zCi+U}vzCNggBfNv@R7^g$1DB9^$1*y&0ukANhg`CBzjFhdo!f(>MyJ5fcmEGOZ!RG$8NdEUmeH3u&`Gc7wzJ6$#bX(#fLju_WT$ z87m~eY>zc1JGaH|b@)L|^LZ?t%-9yYk(~TImTbSD1h&N{lKXeW>XP^uu?}SWj#x8t zd{=B#@+4Hez{7BaS8Na*{uMjsO6q94LQa1c>qc(e6MLWR%hsBarnDeWLgC4%xZd6#UD)gc=`i)H%)&Zf4>jx#p0GDG{%&HfVq zV*iImo%?_Ge;4Qyx+ydzbUd^#^l4~q=#8YwNy{LrbXC$Z#~?7MZSQF2s11?PuGr5$ zaPNq_iiQur7Zuz81*uJAGoyk<|2O5++}*(WxNQ=7q`sEc9R3_;_7aLxZL-K zQ$7Bs?C<1;T7G@kNFffo?>H)kzlA#uWJWdAu5rO%_Y%@jd(`F52U?v*T3V9(QBn2; zxxSIs!R6irw_j|b)g((AX?0!hi!dL3(n!m9c_!EflPit1>vKIDMY)qW&ucO40?ZxA z!r~M^D_5Jry+esz~n96z}4|`2Iou@5H+}^tpdi|FtBoelDu3B-tQgTmn{Riiz2O!8(-hi{Iu-u9xf#4)6^>ja zGn#1+x`Msnuk+2|E*y)NMEW&{cNaVXf6Z;K-CEBf=7A3i@Rx*c0ya;!*4;S;{Z%C3k~H12MjKSbnt-W**{eO3Jw^r%)@t| z!La0MjGw~r2@Gdp_&A1-VfZM9r5NgGAQ`9aD39RVQ!t#2As*$Ghw z5@iCy$^#hQhT%{QhhR7u!$BAh#PC)I&Er0l0r+-X4D&E-gJEk7TVdD|!(0)Tj8Sq# zJVt4OVK#=%F>HomQw*D6cnyYG6qcA{ztR}r&cv`0h7B=nfMItP6AQMHW|Z%XbR zqtq2Jz&aS##xNbjS{SBbSQEok3~K-^F$w}1$M9`@-iV^%yAce-7^YyDjG=JaLkS#_ zq2S{l6g*%mDi&5S3}EQT(1)QHLr=(6V!jC|ha2B^Vd%urfuS8k8-jr=7+MRN5_(2R z;4;2_3B!vRUcm4l49{cuH-_gh{3}#qHAN`LpZNA482$&tDh$tJcm~7M82)a+5_Za5 zz&Znl)yJ>KZGQI@hQDC=GloB5h!;+QAMxE2B^V#a@EC?iF+76dVGIvp_ydLqb*LY= z75EhMyovLoeR3GuE_3xdqXc{uthjVLuG}V%P`6-Wc}6@FoVWdAKLO zeItfFFuVc7?igN=VK)q~6CupQT}3=bxfa7N7#3mJ8N*H(7Gl^D!wwYYTk~*xe7gX{ zb{OW9X****$kRLEEBxH&vB9LxXYf~_9q^^R=yUjHj(#4Sguk9A(a-N?`FzhJhnnr} zfDk&1z>VQSUkA@6_X*c_=Mu*Zh`j?5daQq?nWTv6OLRA^suw{sRJh38Ds5BHGCw;% zKaU3LuMe@9sgg(6488q4X#0S*2Ki!fc&)Dc7Wva5x-8|_RWWssLDEfL4jY~iA}fSH zVXXRA4?UfS9GW5BKS?Fb6GT@U=9B_w|5BNLr&J>ySfctS8Z~tw8y}VKN_8OZ8LO~! zT=}@6ZtC@TIFrCdwo%ub6fi-3=bHu_Cg08q*QXcRZdr`9WBk@G;FD0Q{3oURbpeu3 znRQiQi`kI&(%Dk|UYN9H$?AIttLH$Xm*z-ItdTs%s@Dn)-A9H!Ev=O_!d>F@t;N1` zjT$-pOgIWBRD`8QorF8b>FKlMq4S;%f7tS>%^kLh3xTLYe}YN+9Hqd2!MD)+n5V$4 zI=4Aqwa>GW;&IU-;1x!=J)>^Yyys&mar!6bW~kVXLSzVgI9<<~yA@dX_J$*ZOShVv zfg(THP%?gfduINpz<=mdb9+W)?LwGQBhlj?9o`0SqyJ~-KDNl(gm9}y-PnZs){_(4 zO>L;i+Kq5Kd)>;7#uX(ac7%;+N+N3rRDiTQ>CZOR2Ya%Yc81^APk9zuhY@CH&v(tH zO*8h)Qa(v(STXdAu-7i>tsMwAxEH#>u;XB?5ZDtoY#_xfdJ=AG&*N7SGs+>lU|3m2N$F5iG}hJW`3{h zmaATe${e{Yyjr&(5I9-WvuD~uD}!^?i4ec9wLj#2$MZa#N8Zob&5>u%q^G~Q=;<$U zeFu@7z0w#9WYab$N2`L z-uUU!8RTsLh+zpJvNnnH4Me@!9la$2-rEBrhSiJ6nil6a5k~Fa3aReCHDcH;h^zzS z%p3wv`C&2bHa`VcM1_MQS0wMOew^Dw=o19pD&z0WJ|t3;z{S`)5YrFGxm|=lbnT_h zdg#!IF?fosS#fR^p*u&W+zzGQa9hN%Ul3W7Aw~@CEPAzNPXU*#ygQ`TRwvG_B1|ie z@kLN^my04b=tVz-Cs$0oPn;V^=r%(%mIxVeZRBs=%RyibI5b-y+i81X4DqcAH;g5@ z)Z^as-q)4R-bcJy>a$=>S_&t8bXgN_Y#&8NtqEt;QGRmVZ9DCMz`w{b-<=aIhGW9_ zB@Kk52GRmul~H8Hn(%0kx|kX7)yssyC7sK|U?#{c3m42h2exwMVK5K7kI9V>9EX z#_JppQ8ohmk=B|K!g)^xO>`z>%f18wCwr_5F9(MYMp&~D)-kCc&{`{HguY)H4|tMc z)0oerOkxCTTkKHoCTBmOWv(J))HHz23LYyP;n$<`BGI)If3{7`e)VuGWDmNf!Z;gYV@1k_ z@OeFQ>HL{SWYct5@m(bKj2d&(IFAq!bz?Y_DpoI&n^NH=zqK(uNROLi2nKm%87j!0 z3cPD>+X%%9@>f8`c;Ja*`qH#f6It`%kV`sR=q9SQzcwbD#rwhy2wV(1RV<`?uke7a zF20M=RGgujuDp-l+$ULpD*e&kzVb$usYJ9Hu3eMMi9 zBprb1?y&>X6-fc%?kk49-uK1>c03qvMBrk$eT${eBHWQhA8uRx0O>FN!CaJ!tj!2_ zWYHgx4he9AoAd$e5bnrg4!1K$K>u60CH(n+nWk-r=rn!1T3<=_|K@wgJJmDP-OZKf zOmhTmhoDi-Bk$}DH}jQdk%!nz)QdYG3?|6=H0kW1k_Q>9ZtDZ5=`|!1(<6rYmH1?- zWCCT?KR52E4V?RGM+|E~ky9lPh>4ya1(SMVU1_3WGM=&Ob6#^h6m^O{VtinTsiu>0 zJk_UEy2(!?T9HGJi0mV(CE*?>`hGb*D2(iNM`C2SOUj=Ir+x^n{th>NHh07@KNd@o zLAZ~J;mKYAeGgc}WQ;drSiOq;A%fgYrum>KJABefJ)bZ)6QY~4(vw(7lR(5UWffEH z0o#$Zz`D`&JOx<$1Yv3Mg%UBWzeN5%ggKiKeV$GUasG=X{8L0=4Y)VjZm^xVL2$&2 z5O4gv<7LNXR}JUSa28Omw~eP(=t=wI!Lg38eSp2OcZ923u(R_6^_cpuXL9JI{XO*- z?-n)F^^j|+bBN~~*H>-`@~+(H{>`yl=@2*^SmT=(YU2MZ&=BmRwmHVxkCQP+!b-4* zZ>6(YXps9^M^REGnRO(*+o8fX|50i=R7@VHzsf=K9{eV$!?VF@*wiJEnJZ|#^)E>H z7-iZ-is-L8m5gJ*qshyRUPX4Z-{s^o`~8wMJ5KqoAw%e|GKtJ#zZ1v@?DuZ+^YJja z5FR4kPEbBCc?f=!)WtY+I*>Y)ljJLUQ#p=rmaE|L+TzFXGf7Y>VDM#?877+yyPItM z5t0r%$?xe48Wi4>^1aN)XtuoEP)ibjg)rcaYGpVTh{uGc6q{`W393;=h6g)Jqi? zehn|R2a_h)ZnKI;r%#t_SJDKs=lAdj_Fk2t4R$l|0qppb+gECcwF`iL^5p5^k2Q+oiAR24?uJn?(%k zcrnp*!rel2l~3>HFr;j4F3mrcFt-rBq(`$M|Lhi0PL)jMLm>YIThk3Z-p?jWzpJ{D zM;NRAIc*O^s=dMy!!1QDYJD<=vFaZytCoSC_o;|<x8=Go;EET0jq699o58N=B$b|HXW65mz7>n18e4r~= zgy-X5xC3mV{h$b31yRc8_$T?gcthaL^QCj0W0IYuY*yQnfyt47eWi8c%!ZqobMoG< zMro3+gYO`?$i9O*#JMfEpfX6g-VmJIEs{y#BAe7=aczt_QJLt+1f+;4sznm!9=%R-*O`qBClsewk&HI=dLCC z+&b?j<1xRxDN+xj&_)bfG~pGLhT`0}gllXTth@>I0llS@U~-&$mFS*nmLdII zRpZ>D#8|u3r%k-Rv^S*2nL~-*#q9a8C|lV;+Qocv=1-#QBftO^!hbiC>LW7ax;qIw z+ZAkjpw1r5jC`TH`UsD|HX~wBc6tA{btdkd$o(!Fx3*$pPNbFF6XeckRb6S6(hKaX zh#tnwKP7%8SK372%ZGh9^o_GG zFxAg6_0?wOg(I3@rhLj1JbhS8dXzue-$~zqb5ML2n7D*&sXmRe1SjNf#%S34F`$PC;K(;@_r+WT$|K>X4TRL#B57^Sw1ibM!pj2Ky>m>Oqp8$=&aUbzoS9UE;{o5FL^VPMlXgpj>zwcvmG^} z+mVlb3!d=&3Mp%PoNuY=>$_s=Qrdr|Ii-lHhU0uoO<$^vf1B3Z+vW@}vNnwKO*DP? zj|ROaomWGF;UX(=a-46X>9Zw`%1lnoG-pdO>Et-uLNmH#-%T(89=X=#B0V0J_RG9D z-$>InLA@V0+U%akA`J*!*6VFn$Yj}!Nbvk$a2EX^UkXm})lZ$yjwW{fln_Yxk9=<>3YfRQZEjRz<{p@N(<;yv zwO(y*K#I&^asy-58^+upK;i%IJae~HWNkvaGge(0W`>9-!=IDxQ2WUBlwY3!lAebW zA6Q^|)rl#GNjI?&xpb$i^$XJSCX?$pD-Y5_qfe9gLh1IShp?f@=t(p)siQvbu_fJR8KE7_= zY>(zT;oRj|XMf2yYkrYN@|Q$<`AW4o-;mSiwN-1O1fvg1J5emo_u|a9Km*#7-9JPO zCsUy_lqSd79-PtRc0UP2M8RQcXVc=WI}p7H^vp%l;uv@{Wt2yZaJXX9&EmXM=vu}r z_>QUJ6Io+%-YLu?zw$05e)?T$k+nGQ7J50pe;-%}yf2+XJaOJFjInYpux?l@ z9i`Mb?-csHI)5FoYU`vG5ssrSVTUy;nfQVEr64|7ew=p+eN9A9SKxbW|E>oixX2bp zYMk{8V~RiXA+V0vU{3L3`gP*GW0(`u_>Z968$U8Xj78?uIO`flBi;y{*_$Gt=z&xO zPTn-f(5AVMMW37NpXj^Y+aF?Pr@I^weQt$q3@j;o_)7ERTvM$--~68-%|ky)mxl#$ zuAkNyo_l^Vs$%{xkwyeAhEiK72rQDgrrK;6drv_Qf1Z*yj98rOs!is3Jz>Zm`rChD z=4J{eGd;MDZ2euzoEGPX3i{VS{aPW-&PZo}Pn_E*=uM8Ez(9IdN}HS(=Qav@L!Qyi z*N`m#QraZ6IJZ&IS?6zni7~KG+UB)5w^7hp%fE&KTlrs2)F#-0o0+ zT=+=@Ld-owp7{j$l>HSOK8d8+UG6jBS9@SHnVkPqkQ?@C*36j6~ zyDhXnSO$?Lh6i%|$q-3>pS#R84%|orw(T%ZJxAW#8_|5Fg>k+uqOa2S{0CZO*FU76 zzRlu%M?{~y?)Vc}_x&lIyIRKij)<;nvO~}z((9bLFC(hDM` zdNJami~M=U*1;zV+@(&|AFN#T#SuQQ79{Z4-?Wi)`~AcCi^c$j5rJ;s~W$X zvFcs)%0)U>U6Rgu>G6@Am2Yg_b{SY#TsGBoVyfBkyBMp!Y)zg9!S~XyM2rwHB5P`V z1ZCAbe(}FBnq(YK9y4AcM9xOe?%r8mkxsI@!?`|O@Tc2UJJVGNDcG6@^w)8mDZySc1mB~H4sw`$GL)A zU%Yn;1M7QXGt{xjs>Qj2Tc0-0MnF@bX=VUrku^E4tF`$OyLl8?r$o)b*O-;>^6Hbz zvoT;j7?ZMk;(V7^pE#>(0Bd2Y8FE@oH7(9|d5K z*O)@#q#NR?^5TQwzS$Z;(WtbS52&@>h8?C znoKnjh_k(3UEf5b!jThIk@uVPolxPa0?v-M-i{i!;8TtwI0x*B&~2esA%F02@O|g{ z7t*u_HMRuC2CfU#@>lsk1OMC!j!pg>{B?bQz0hB4ReK&i3f_I2yOndK+wZ91I_!Gd zHQbf&3OP?W?xRPQj<=H<4h_s(J|jgA?UKv277TTYoLVDpcXG4_jgpqmG?Itg>BcMS zX@+Ukd2RyJnc-J*!H!3DX-zzVmA_0k&|>;{c$$`iVa z?DcA(XPw9B^i)DluSt6OD1ASpAD*KhWA8r@NWg=FZCfL<%LfUn8e^c=9sHS&FORsAK;FlRHw?P;m2(aa&r_-~W6!BChP`sdCqlP4+ zXqOyHf0Cj?ZNtuRd-(OJw=n+GZQ)~gwyJ?)7C)k`avPio{R0qSBbEe%+M}fOt(3Yv z$zDvP^QRDa@Vg+0C|@uFoxeQlX!iHsI%yddw}v#Z8B_U5OKWyPOI((u4Q!}B!#AQp zpSLPFMeVBO2QvMt_pIj~m{1>cj&=;T=fH&eBw3NHL9C|CgpZA~`g~UR2Ix}H-=G;9 zG$A}X#NqccR(&X(OM`={H=0AC$f_kgj8*SazxSlQucz6iMAmS^%~72PJ=sN86==quo0xpBIN$fx4ZaSy zhmthwp#7t(ngv$fBiKe#=eh6sFD}6uG)_R$S4mrw-e*w*o~-U7OysZ=iEyB9IMfZ;zFp2v{+ zR0@v6*r`(a3v>U8;U5@cpGu_)-^D(ais(~G9fz?~rD8f&GOtPHH=GvssZ>tkyT4%g zGloB5coLxKIQ%2NeFDSd7#_nA`&25}r&7T_mC7MYmN^b%r%L6Zb(^{se~;k-48Ozh zTMV&JrGkAbm9MKg4)4RlUtx%SDwRrn7yDEy*r!s#K9!2)Q^_2Mm0dV3-Kp}f(Yyuh zz;{2#a65*dVYm&$Pchtz9fy@q@a>N=+=Ag|3^!r85yOu#++aZNIIMhVVE)AGG5i3- zbr@D)xE90rF?9B!&Ml*jp0fR-_oJ(IIOI|x8KBYIfidwxD3PB zFV6~-9 z$^VaQn|e6o3=HcsXwAQM@a@_drejzO!!!(QVwj3y4H1@%`G2*x35#isQByEX#t;nC z#5GljLTmn2gZQ?Jp@N|D9ES5ToQL7F7|sYz;VI8&v0b!pWyzHy4zFvE$~76TYt@Pr4;oL^%LAHQnyZ~XR?Ds9fZw-i{V8n z@^?sZ*GR##ZtOrqiJ3D{%Or3yv>2jbIH5aA@*{M|4T3T~KS;{zNN`6<{kWX_sYdzI zV9nU67gN;|$)au~r2=*rdk&RqT+{@&J=V7jif)4^U3;6UZxB<>OK^K*efOg1RWKdY z?$CbF6#^n_L4sQt8_r)vkm{dZwJ^AJY7z2lk!I`-i$Z-8+|pS07qMRpb#b<!^Sq18<-OB5E zQDUTZckO+B4?*Bx+gWoZXEAqzgTa;HOg|#f)8EkN@$7JKb**zQgkaT$u*U`eN{7$R zBukcn6=9z~+G!u0QOOM&^o;097+mwc_Y-(9ItgxnXjb<3Ibba0 zdskZ7%@W)KQQwfPd=JV!>AnBb7@7Sc(c8%35X5Z1@V@rhjePe&ctk!Dwxq_JE7WmH zo`0L~F>gaps_O#Sz?IsE+FJ5fXkB3oGmo`&UFEy!aoB@vJxgjdSwG%}cM4u9L!N+L zjO9;ASv$vDGgkfRh&@jNYx?ZSuUG31!MUatWz`#V@f_e>HOF+W5wwh@Y4MgUQN2~n z>jLH#t)G_KclhGDEYZTdSZLnykZ9(3sj0%ycn(YS?0DuflJr2NVZ~z)M4U#92C)Ez z@fM6%-)}xT0g~PP5bWIceK4|3@@S*u*))1O$$ltOkDh7uojs!h_@lD4^Wx1}+WO$y z;$cX8#sgp%yZPaWhrIT%bZa;--b@raGRY8XG&!Y& zL`-?{RAZ;Q_EdOYxU4U}dhbf@Z@bZ6&z1yH%!6a>_29(QL7^tWi@{yN#mc?GPQhgL zsQQljXy9$Nk#bJ?Oo`k3ChNBGeXy=0aFl$L9|Qw~f5@ubAVeU4h>U6pSh<-b=x^W$vYp|j ze^JEM1VPSBfLro#kPC;f`L4$<{H#7{{ zy4gaf;W)t!%Bz8={iA(7yjdO(cob{}^Vo^DZlyzK=aS72hU@rBnv&YuUI zp83-COZ^1zH~LDA9?e8XKPOGJPJ;Iqb9^4V0Mb3NKzdYhodoMDmQU8<=YhHZ3;%_g z_ZBnd-V1?w>q04Wdg2<^wR9uyO>i&?X&pEBn8X?jC$bo;z8%�T1_5Lb^JPB^onU z)BJ6I8KmoZQOca2$P|k(kDkLkYLWC%>`~k=_;#_mb0wBGlxQfjZa4@9 z=GgK@ z(T7lqzy@hM>zd&9^!gDexgP=R^p7;zy0m42o6_rBcxN{n>x_1rU>^@I#y*}M&>UA z*8FACH+wQ1?0bK zh4#H~}A6-Q#THNVT1Vrj|*%Z`M|UBW8kI zWt#o?`w#@?$V!UJmdl|8x69Of#lUVfRL~oIG6%9ag@OLSzn&6h|`bX{Wr=b7^XQZsI#7!(!-4!im3$Py9EdA_i zmgvb?^=AL<$H1ERvG$D~&p;?^z?*F=u&3LUd;Dc?)iu{X&i5b3Xy-}SF-NL@oO06r zo_(w5fb$+l7kheOw6ei}+B4L*-8I5JCy;7C?N4_V_`3x*2Nt@ADbG1IMRPu<6xzE5 zPI=aNe2&e5F0N!xOUEf^p_=SIWG^G5_i8?mf2(7mJC97=tNq8Jd`HGrQbQOUvk(~u zUg_kwO6^OB>Z#cLrM3$eH1)~Z16o$a+I`v-h&7bM_|7y?xgky9b24;4wG-M(dVdEY zz*p_pX1LS>$m-~$zKn`~-)IvdSioUw+j5K?`hnWEaFZL_o;>PTEH(P6$INonZDj62 z%Da}VgI|xjl-)0$LwOgmzp0@IGk8%SXPBBja9qMLHA&-mBEzN;v`4*_5vWbQL%p61 zgTgqJ9b^tIl=3;dN6!-C)-*3NY##07QR0knZZmn8Wh7%{=wKHCVTkl_clX0A|6I7yq1nJgpMR=}AAX249iW4lDm^1B`byqp;2 z_t4wZ2`#rR7dnUNaC+Kf#xjW8m+A)Px)r6t2FfIDGJ~`$W_DLN} z>G#&NwSe_tt*ButBC_Tsn8_!B?{y>P7PWzWYVGJzNy=Rk-0oAKq1POS3Wume8k|8G zH4IRNjt5pj-0oBV#vghBY^}OEqDH_Nk+nyHyE^FMOzv<(DIlCl4RY2QO(Q=zqgy4* zfI128^kB{q7hNFM-7TFVS|+%&gFd?#_5>R#7BcCKCz?cl@I+;^yCcD!9(3JG&UG-% zb@oMNYQ9i{yFBPK(2;B5gZO!W)G$XCA2vC`9Uk;?cDw?mJ{yS2M(?ZycX-h4jgKmD z!t4MwD$}S063pR2H)|{jLZZ8Z(f6BPrI-i=KC$I`U$=Q*_wGsB@BPQuAgMI?ROtJp zfh2ZTYwc2Ef!|24vs!9JN{+X-cR|LyI@~1D^ceK0`K%Mp?`T-tu`^*89GTIO)H|>B z_XN|}{j<}-_U*;<8iW&@#t2mT$%mnNr0zd-RM^N9`kEU1{X-d_WQ2O7lAwEEAyxlq zQyuDTIB%3PujcnQCFw&!IH?7g5>j2Go0pLO=~16zY`N2DWU{uT^F?isL+x0Ra!H#6 zW9{SQsms)ua3D{sjWMk7BY#VZz~1rJf2lp-2*z;cE9k0k{R<@v>|liC-o`UGg|2)J zp{Ihnsr{jow75bG$&dQo(j>JZS#d=h45!H6V+*Yac2mQFkNqQIxwGE=psS@bWM5~y zhpwz?2OrNTQ>%j?ZWGmYkFT_eHX?A@E$NW`662U4Xm*VLTX#HYqSM++yL(1rEMtcz zYOoKR4(uEAqecuRadaq5jA87XJFtO-8ljSA1yN%`F0yt>+|O9YjbyB2+XL%{_E95r zr^s5E7>!cx04=(I$EacdC$f%A+{ak^6tYxlScHlrh0zp4Wh3$qOpIc@5beZxw#pPJ zMaJD87pWhP8XF6uOwR=O01yV&7MfA-RS~FnxX9`~C&9e{VBj$_D~}q2Go#TO1TKco zN6c|p;%-(-Fyk`>e|-xo;HA`PDsk0_8lNSiOuqzo4iH{43sF~=8oiv$JGa12zvfgM zgwk0N9IeIzJN(c2x_K_Rm$?Qy9rmfV!uds@QYc6;<3p0%z-<(xcq~+wI#%``9(5$9 zGS%7Oq9|3Jy<7zE;kTk_Q`vjym*7_ULTSfe3rTOhHfs3A3#BdXk>Hm3u%tCgLL*_2 zIoI)vVeKl)^ht0NefY36WXiii7XNmOUX*l_?g?(DpWL0bq88mD<)z)FRoy+o&GdEF z-)}J9X>pHe0|J*_lJr|AxWPWG;*H|0xzP};x-nXlz~y6!VE4pBtTE`h6~pP`e7j-h zO^_R0WV!WAJjjHE`YrBl-!}hsGd(z zYpCQSH@aL4TSv3tf2BUP2)WSK=XW{iS+pb7jKDhoC>ZaSx%;~uq-RF-$%dB%P zS^bPEtHop>wyW_PE7Lss8_7=dv?g#dw%5eM^(aeYf|J#!<7V#qv)%g?G_ti%NvDpkWnQtsSDrD_ANx$S z9)XK-8o5yP(vfA{;N6%+o~4t>T=^u@zl>YF!vfcMFXhEhD<|egBc$E%=+BZ`>{iB2 z-y!O`AyYEKDA?&cqxA_~3^x?9V76(s=vMO2j41hTNs&ye-(9ey!Uz79>#+6*Jo6CZjGkfgZ_l* z9ty)uM#zn~4=BwMtWEk3j6&!>g{&GFy`M}T6s_Y>o+B5AL?J|AA(o)$AA6M714qF# zb@8BR6OtGNqRMWvo&82hqrrd!=Scs-(VhI)gg{*deHoz0bS=vmsn$vtf78tJ6(AU1$x&E?P*Ue@MD%kFp5k6h`SQ zap)3dacPh1Sr%sO!qRZwYoG(YqYPGsaFMMFTbHFU8DSwXeg$-+su$@R;6>>gplew& zlhMcEq?e45qhv7*X>gGZX#>iVc;Q~+>*AN8SDk$+YL`tN{mMc-1M3rkhC(KXW=1d9 zziRbZno|~}?EGJ+PC-%>iN;{fm+~k)$jgtygTTem-V43AbZD8%@_`R{W5}s|9LnW- zJZgs67x_n(DU4s3Zi{9?9;aqSThohdrX5fgU^2t_bJIx76=R-+=hj{Kg!BWx1GeVg zRyJ=d?`H2AUmX}xUQL>i)YUsbDN54EMLRqEzKYw&MRUCl(67yZn0CRf6`LQ9rh?;^ zColv0)85VCM!pLivq3M$`cZH)^rI1zqt7@L8~Fo%gQ1_q{&f0F&_TXECHk~Ook-e0 zLVIWqNwD9~$={FA9`PN?oJxP=-tI{ikO&S)ejCM%{Hgh zsp)$;aDU+1Ku!M{|EK;J{o{Q-{nz_z`~L8elo^3J6&X3+5LFg5CiRy``@4c|;p2D- zg!N%z%6XtTdZq+-4m?1XERTY6u$&wYhO$VlH>1-X>M!KQH=|QMijRG0Z-Uc6xTf#} zhG_sxE_{*Pzak2Ow^JD1jBMyo6|#liP@AwD8)r#kIJ>I zG?EWbbSL=;=nnNKlCl!+D?F_HQ5Ss1KeIC0-=pwHsC)-B72da_v&o6KqxC)NSe8|v zEj4&D{!t`*H7&wJj9&Z(_+d6%MTgNtoB%p?kGhxr{d8^++`Lys=a8eJ5G1vUQCGi4 zOMNr_d%^gJAvXOFT<}&fU)j4l>hdT{8G&lN`4jD0wkFycBCWdYp|%CNrj3MJK*kSO zY6cOFl$SVaBv-Q*P)SO;H?10D--%MjzNyt2i=J`R(3gx>-mO{QxIC?7X+%4tOppYtEyM_8B^3`Q%+wks*vJ=)f>`p z6475Jv{Mq=FS0CBiWS_66;kw(qz#^w2^XKQ{%9v8`5u>vRt~P-m5xe8kH|!eE=!u= zA&K-4652rt?R!}+m7|nuef>L$=(kH_$!sb|<2j5J|A#!DtY*qLnSIP^zofE>wK8Z=}k7RVOOWs@{H{H_Ks3E|8^G?5o}| z=F8Yp>{n%4biY}9RvvR@!j%WAx1gsb#d}IZn& z-Ogxq*=RLCGPBiH)mPQ1`h+?}kj!bB-72@$V1-n@AXJ}09VFu%|B$f$FgR}&N@&mq z$fT?8GAhtu>n{<#Swib4q4kwzQ4}-=KqHIZ6472V(W*8^{TZU9ILoL%gLI=zvN$fO zzZ)d^c9+num(aRNbGfH_?RAxiUMrz>k_-oluDjd$Kj5H)Cpf~Y|w z#rapYpvorITTpvRYL@o4b%B|hpuH8ShW1t<3EEqMnxboO1(Kk>wWzHn8 z*YH~pBB-n^qNuX6s5K?;z|v1!d^IF|F$vAmPg}C4e%c~MWbaSVPg|TRl1!2%G)q5i z$%dr4==y1kO_j3g`e}=e2>NM@>X($oC!tyTX-n24%~;n@TWl^#a!#43pr5v+?GkBA zKi#UJWjnAX&mSBx_0tyVUo#VnMhyM5MY8nM7VV-;RM1abod1~RvuKunI;Wsbes)e? zo56vI75 zZJ(KV5(wGOC(X{4nM^}pzft6s9xI+n{rp6z*ps6hI zBQG~s_xMUXzR;DMwSf6QFU`Vi$RekO+K660u9i>fbuaMH8PrOV^Ka_|{O$Xy9ZB!L zYLx8Es=2tjP~#VP5Dn_;!-ZbDre;GDecc^huu=O!e}BT0SKRehY?0;-_OktGKWig9 z=DX`ul+JV4v6Ho%W8H~wp1WI#7_#f{kP*SlFtalWG;or75yJ}@{)6Fp46U?TQs?x$ zdVoCjFUY&bAWuDk zZy(3-7=}kNJc8k23=d)W1A-;$L4?)sF+70bcNl();WrrW$M9MHs$_ zVHt)A6S4q#YTU$Za3&Zo#P9_SpT}?khR2HugW+rp zpEMv3P^&&+U>+cE7KV>w_!x$dVpxjdOblmWSW<#9j^wCL!*{1*_y~qmFr19xBn%(c zp)vnX#J3;9@IeeGVE6!r<1rkE;aChy#$bFuhNCgO55rLy-izTq7~YNHNDj^U_bz;U z1crBFI2^-b42NNO2ZpyJSfbvBusRgOAs7zEa1e$AF}xMS0T|xGpf&&Y$G30Bupfqf zG3ftYtwu{|3`6Okqt7Q!%W8VGP44h8l(u48wT-4W{7R$rvVK7{V}!p^Bk` zVZelJ{tfy~Oy}RA4?{189t_S28Gv5al#wt5A_e=)p_;iYAJV;KvF zDZ3pb?jrRnQ)`)80ZaGrKUGCh{nI*N+Mig8h5#6tg=>&u{Zf-Fe*7|Zz86$=!rk4X z;zZYon06=vpJ|68(1>n_B9f>b3e|#{pZlQyG0p7+>UQ%r2%MI=oxr(GQbN<*PT+%l zuF~V^=5`_%nA>HN7Nb*pSAb zrLMA*`0&)5DyEN4eZu*KJGn-MJ2|zyGXb%|6_duKJ_!o%eyN#uP%*+!#gF|`(}NZE zKB-RTFkwdKP+u}0H`k&O!#K(!y(nb^LnwXM#o{zAc?6PS$zze8H|kEvc7Zex-IB)= zwXAF`+EbF`xE%^r&040(MA&4tsMaQ{MFaJ!xmRaVtxZ;o25M%$$!d`vG>c==CYVoW z(I6AP$!d|tOGGVe5{u8WCb4K^q*>_JBo-T~)U8P@(tR>X!J5S4w5&-inq^I5(JX5c zi#Af4i*8L~v5k;a!JQJCWldtq7EAanYZ8mkvL>-;w@ZtsTa#F9#IPo@NJC_jf;EZ7 zX<3t4G$L4&SX9fJ#G(z5=A&DaSZw_zm2k6!W?7S1vVCP)7}g{fn`KR6(R#^54QmpM z&9o-5NH@wP1#1$E)3PS9XqGjJH6YBAJg#LCXUQ5CaTeuKX<2lOIE!tjB!d|;QNd=+ zlAbQ(G|WjXHp`sEqCFxN1#=R*(`B()<}?a`Kx-vGL+_e&#Wlm$s7RjaoTVv*WQ3TK&;SbYCe9Rb&lOSKQHun)Ux@-}zBgjv+w z{Ss*3n^kM|5Mya6u$jAG0?pk05@^KQ{Sv6=?w3F_cfSOhx%(y1jNLDhWSBOiBAfP? z7S&u@io$|vv&CsHDFse*Nh#3GC8a6iJ2$hxNRs2Zu%4B5MhT2ZzPB$?Q=UZKF)o@Zhl6pj~m(Hi2Y#a0n#9gTtbJ zAjx)}gjOM;t(6u}_u#Oycu&S=cyL&3q6ddXEtllGMwX!A!C{H6lCc>c92VP38Jpq3 zVX?s)#9Wi;m09lD)@0&)VVTY!;iya`+xR@Nf%0%>}-|=raigrNNQ&!3Ji)NVsSv1oGNFWI&Kmyh9$hSx}C3&PuXf-6X zn1p6oJX(2}7LOJw!bGd}cERG&;!Kfbk}RQF7LS%}NScdo@o2HBQa0V<(P9%V9xbY0 zQWl?tW?4L1vL0#1y2Yc#=8`1ml!^XdI$WY=q?#6wW(BH$UR{CeA6HkP3RZ4c&o^q7 zjMK1qv@)?Q9xd8wnW$m$XtDh!DW7HWXz`gAkJcaofny9md5e!(JXWg}%hXR0J}DD6 zJkhQ6PDsjk+!VD2Jz}_OTco3=s6{hP{eGIzHd++<+_dWPhRljrUoKsa@Rb5qG zRWtkgxY^go6<2k|aUA~+$8q=ZuU<)=W_;YPto81Ebt<1`>m7#7@s9f1UTwSMb;oQ+ zBkhrPK-;2i(AH=xv|??MHdmXaJ+DpHCTOFzEN!sXPwTC9)!J*VwNx!dOVr}D7%f^0 z)0~>3arTGyU+s77-`Q{4FWb-C%j_rZ$L)vg`|Z2!TkV_drS{kC%j`?+3+#o__L=tS zc1`uF5$Z^FrSn%;j3d((?MmDe;rW^0vnXtp%vbTnMW;tTSXv)b-fLMSbO$b*=s3+O zi<5jWM8}H0rL%mmM^6$*E=uuvDv!+{yeJ;CGiXsYbe~=nj&A=&8oK=!@#ywl_&mCO z7EVGpW8t&t_F6au-JT0G(e1IYGrHXuwnn$x!iMN}UYLMxr-fC~?YPiDxBWsJy6qOs zL^o~0WOUmu7=v!>1;fy7mA{}bdMy@oLAUvWw&*rn&;;Go1$EJFx*!JK#tS0QZM49S zZo~Pr&`p{@72O8&$Dvzqeipik^9P_?cYY6a>&$P5Zo>Sg=*G`aM7QRAFS@bwqtNxv zccNQkzJP8_Q8$vmYDE*#ty(k^-O5EzqZ?h+6Wz$7_UJ|wHA6SN2uqX?D~d%|D5}J9 zT#LdE=&A)zq5DUX+jpc&ns{g45T8)BL2X-sgw(I+MWZ{WL`C-1&>^ z9X(r@-P2qzxiXw5T(M4YcX3zN8@o5|sqZa!!WVx;)YQZz6c|!h>@PrGH;-3O8khT= zcp24xYSLP`yc(fN#MBnJy3JWf5MzArG)NNk65m%1nt<_oWDJcWlV?~T7P}(qXd-@)x42te7dd z(>~ zF?39JG*G0*7#<4WUR6+Fo8E+L)Bx4;j9M8RJa-6YG`RSep z==+|{=mZ(VB5Nq(Cm5{~6toO$P(`YB@_XzMIK{mLU4EkbBlmjuUiT&UPkW*=r@QkN zB=a3~sk*D8EbQkn=3s^-VHe2VV@Y?BG>9RIqfnQJA=z9=s?CtpuU)nWoFb=kjug9d zjXv1(1$~xuPAVS`+o8 zt-H@C{?DF2a{uI^^z+L1q5qaj^Tonv>Otuekw{69o++n08$XjBDiEZ2%>rrLM>{0_ zf6l?2*ygoJ4r1lPnFUYPf{dpnuTZZIeDZ}YjyRUGq|mhe{TH?`q53PLM;u+y8?N5O z63n^~EI|^olptl$60CZ`_5qcW#uTL0^5xBge>BeLG8|bT8)M2U)pv;ep7LlY}PFAS13o>`Il^8{qw;Vjx4Z4 zX({Tz+BQ56w(pg#stGh@_k3kL?EXis+C5v!vWvD=oW8&nWdmcj+S8w#897$*d5bTo zi)IwNYLm*JSKEml?x}FAVgjjR|K8fp(;{3H3@6+?p@zeeRko^_3_cLLUC+5y%qe#I za%a_8k?fS={rb*0->fq>&wd18?sSh8^fwRof2D%g* zHDFX3|FTc=J~ zdx+r`q%FW-AjW;Lg!;u$^&&BT2D(#!4$Qbn_zyw(k|d%#G$p>Y9Y#^V75LD1iyl@v zsrCYQ8t&6bo4|XC2wjH^>feP~#22gkw_W19@PZr%zkfvtwIKd7i8vMdT_#yL2RAO0 zERBVXO7^5rN+l=##hnoMAL5^br_gtcH`tus z%XD~Q$A9?Yg1Eu&{+g&1UxR_)VDhu(Vg9ii+Nc_Obu(zgUpgJIO4 z2rqw2=q=&)w|uf7R)(tI5tAdKJMpDkFoXJ+VGH#y!UgJ=gYrG$pMa*+{{Ti&e<%39 zCl9R#cyE*934>X;F`n#%ZN!&;fXlbBxWqp2(GQqDF&*aoh;;Eq*iHQ={u@8?(U_5j z@YhfLcql95lVSNC{<2%F#^%1yQ&qhF(!2bqcH^Grj&ttw?yc@Z_ds`&ThcG-JM|)c zD1St6q^taG*HzbE*CJPzE7j$6-gNHgS2&kAM>$*CCp&KO?HxX$t?;U2tRu}4sr{fG z)j*r5b>O4z_v|0q*9ZsgUAg1xZ`>XAQ}ubsNfF}tdH!W7LL#j^M;Z!s6gi5H2uH+3 znxjpbr+AVQ=~d{+_b+JM2;)h2DLwCQa8&v&#SJd;fq2*9U?U;f_iIuu@+3TsQAQj| zib{k> z>ISYZ96ch%$ueb=;EGft)(7gR5<97JCg$%9-Xu9IwEq z$by&_#J*3f!qxTdtQ9X&aXne}!h{w=iU~_wV7V8=T@qC|Lu2e%?2J={Iga;H>`r_@ z?AjW|3(vL0^ob8?EPh%rL*3zMOOk-?l%Dq`CeJf2LQIu6+CLfT*W`Xi5d4CSv|9qk3JHKssSDPhD~9Uon5p->TP zQNsE!!vnLDsT3D9^29-tHbOr|I!6gNN<7gkmfBR<-bP3wUC`Aw_$06k`hjL(D2>!- zIu`ukw!+(H0X1A6;e|SBB;&(P{x!_t%rv1$S;{@lr{No7wkKL=n*yY?6GkRTUrIZq zdD0N6p=9IJ#J%DoF-uGpox)AtC+ruV6|ar>N#cii{&^ z;i(F|pDrXruXqFNa5FI43w;H#1uSWgf?{X!Di&CJS67^DGj(7PuD2)kVeM{kE{E1J;r&ge#THB0la}bpnt3i4Y!WIyyA$DD+jNSE=}#IW8}})iGFm z3N8jqd>VaZBcNa{zOv`k6smBW+ zb`iEJ;teWSqDo#^*_AxihLpg+hlC^VN)p`bD$FANb8a_ME+0^C-ei0u9O@Qg7EowNeSW5;>y)Bo*U=K98U$er%4qW_|w(%;0X_|xv)V!8OXI7b{R zHWhW@Tj7YXN|-G4Xo-E#WBxq9gI~aB|3Cc~-n9Pr9Z1<6nqBiEls^osPG{PJ=w%q` zuLxuguLPJe471=DI>9%YM9bi4YvihI zCJmFCH*YZ*GfgPEg(6GWz$+P1?v&J)DJd-*H%)5Nyy;+m2#HvkwQ*W$%EkC@q(t-B zeV7X&3gj<#+Ia!WwLsDdCZii#90BJ2d4Uo)nVAgJSnna3maWO8&!n=;Iy2>bd|D;A zmoF&pirFexXxqhA52T2sSGVMSF!uw^153LVvMmS;tzZs~?P4@Qd6lS22F_*}Dvm(O zDo!ACMqZSeE=MR`7R8FNM7_Z+^OdK@%FQv9Lqfhs&1y>sY9a{KKeK3jjOz62f%;)G zOK1wim{M7yo#9J;4O_&@DHwWzhLSPV4?{I23?xxf0}QDo~MXO(B4lCw-!$5_DQ5x)t8*vUA<2lFAtRFS!pHx$<#=@*zqUVVi4x zAOR5=q6y%`eK(rN!q%IZKt4OT#{}AxhR?rA z%Z4dQO&Y?krA+tXHl@v8Ko0?IrT2fc^d*8QhL~g`N9zz36KF-P24*g9;ES+3ZOPdB z8qpTnxLLEo{9vS8%EL71A!;f|GF4)ehB5o#pv2~_yb4NZ3tF5s*ZN;rF60PZ1TX(5 zf0loTpUV&9Q+YR>o-I^|ulL%b*cNk`nqX;%DD59@rQnySN}hkV6(G;(zp3}1b^H%% zL9MVVtfBY*hw=UYgXaGqw0-|8k@qA2M}yw$JK8W_eolM=3;0G#4cNU@i2AQqZ>nQ^ z#CN54xGwZrDP|7=MoCnq8viVW`w3F@HWiDGjm#zEcD2Ji{1iA^H!J~mC0D8hl#oXW ze6HzDGQAu#R>2hPX-F(6Zy6nkKi-Tn&h*Bb-Wbz+&h$o84+_MfjZr538PgkSdfBFz zWqKn_Z#ebf{_VQ*Xo;K+N0(3cOxSvUX%Jll0ULVuTG`$Sd z>rFk#em-cpr%CT&dfmyRHP_Jvv0DgWqsv~vG|w&}rXY3>0TNSX-nJb>OhN1r0vI!W zA~Og`XPANaS8zCy7GjbSaz#3c!RCyb)*+@Kwh94^S@Cu?TX_qXxyxo9B{T~$1u@kM znuY-C{71jlm|>D?YZ((YWSHa&M+K9nu3}`B6pAQ_4MG58l|N*9zj_RFSH8s_iA+9| zn3=VFBLPpW=k=muVPCPxr?|zZxxU;9jyvHlcW>Z+1y7ly1zA|D2a}J8b=`G@Klz<} zE#aiFT9_cTDM_zPmv($zde($rkBAN2s`8I@iq$xi$vi3!7gPuAJSx8C+uk#lkM*7F ziOV8xSba=PmWTwLJ|-seXQ1{6A})fx3IotrxWm}VCi#wbNrZWXF8vOBKM;qy#U-qf z*y@p9LB?0yaf~EB1G&VPp1``}A};P6hQ|~)f%+#1KNp4&Uvk2t6XJJ*xC*9zNN^*V za13)G*JPDvFAB%yx^&<_B9RV4%11=01WV9YBsWd`y^q9!g7hPF|CsPN*z&P>K#(~Y z^$C&L1-?&2T=hwWStpS$0<`=T={SNNOZ}H%CGn-@@DcS}!|#;72I5bVcwXp7{b4ZY zlsFN39us2(@hEscBSJEy5nu2@4)vG8+RsE>f|>w{r-|?zFzB?{2JW5`YY379{FK-X zZk@)I;>@(;8O*ZSnawgzQN4mT6Bb}(ICDnCm9f%&WulOqaFgrBcjm-b#W7-AF+#X4 z95L6M?60X)$!e21!G^?kQiEQ#Y=821#9NLCsin5Uw#lArn`x`7JWCukJ!{sJgw5&<{r6rCrj<)Xe_Ri|h+63_+Y-lI_u1LG+oNMP) zoHrj!mvG*E81AG?I0HOL8D;lzGS<4igzGaLC8Q6+31e}4X`mo1gWK&TTp*f28Bh4I zz?Ae2if2cNaf-bk4fi=L2UaLLQ%q`rIbBT$Pe)?SoW>S>C- zxy+IE$cPifC6LsaD1OHF6h^u`1=rAtVsqJzK$zKC!li>;*h=C_&N^a$-Zyi+7an$& z28(4VG`kBfoW0mZst0Gf5LK(xUPvu2bH~Y3X#(@+S2B~LwWoNU9-Q_{&Lv!JXxB&f z+R!Rx7M~Km{*_&&i#*H@mYw~A$wHNIsJoP?s18b4AECr4viZ4s>59lWL9PjHdte&m zSi<*R?`hhV-84FB+UCb`f#UQgk+}ZULy{DkKH=rXr~;e1rzgqj?=()v?ckclj}-3= z_rxh;qCS*Mgi~E{VURC`YFKOFdQYhzX^yGABwYB2hAi|I@g>u6IIfHocgKdlyBDcw zt>G%=uA$t#8XhxQqL<=qECIOQ0h1*Tb0=~AfD{Q$H-8IpBBY!N)G6+gsbFcC7iRZH z^%r|f^V9dvFnq*E_GL@&3Gq+E!j(T%)jhJfyd`$3dUlHm`q;q+Q_EId$gB^jqcSW_%5}+FOkyj`foZ zf##>5YR!3E-oLlNDO+Z)948I%w>oMQg*^$kGX%MC%-l@+3C$n3&?)c`*@RCeGH$7G z^xSslC%KpdVR3+t)eM2=mz_77@E=eKqARB2b6jatT+c(tDny_ubfhVN;-5*Iqs~I*2Mm)v{2e7ZI*|~ZDfzUKt885P}~Zq>{Z@WRw(n8 z8_HSbiLJhEknOtKQmv;}Q=e0psn^sp^{BerUdKKhwi;cRc49=;-EX>8R(Z=5RS%I_o*BIbDvw9d{kq9p#Q=jy;Yy96nsWf9$;D zyyh%(9(C?^Zgehl&T~$8KIeSemE#)W%G9Iu4Z2UCtDkbe?q2Gi?ap(LbU)?p=x*x% z*nMC8Li|wli(AB1V*VoWMR9^SOzbVDi78?%7RCRs|MUM}n)73N|JQ$#hyTukDEumX zBb*mL!d2V1gx7?{!c1YJFkHwG+6fJXnnJiB@eg#eGa){(pMrwm-U{v^3;*;z=<_&V ze1@TlBUzZu!Yme!VBv5U4rAd^77n2>KbSg^-9RD!W2eMGR`_Wa4v@TDgNmsUca7l+ z$;%h+kvVt*SROs)S7}Txo^*Il?k4&Jlc! zE`B}BJ;=gs4CMY{=|fv9ODcs!Ok}<}ftHGL#x}+hoBN013yiRE-u5^5d`0o_t!&FV zx3ACBRq~M@(#fA-rM^P0r4-rB@a@%Z)>8bImA%10z5_TNRh2D_4}7P!r+*)Dp{zc|$NCw>FAweHD^# zc8F;#w=IPgFB7p1%WloWRxE7E!WJx~-E_!sGuKO8(~1Yv98v8Gl;6ySEwBQnBGVB- ztj^9X?8L&3dOWwPLQ4vw#T6(S(MuI53DKkq)IdU*?OQu6Iv)d_D(Hz!s_G)IZUwIn z!>f(FQ2N-?ib<5|VY_U&9mf-P``Vp+Ci%>&wDEEX-tK z1`B($@E>`ggltrI1`%tbKl%7`N^){=x(|NH8eW1M zz4jlCssg=p!y3W8V!0nIxTl|r(336yy}F?bu3z9#vD^f{E0H_q3*9Ym1b!l8gMUvb zvW_J7vs|{@WgSqEE7s+4&Fu z8O!}tY-yFvAI7pzi$|>^E^`7Dod5h|cEAt9$d+N)Kvvb$$h8c^%2@6hZre#K*x*wL88jvJ+LR+t?EO9spJu+Ysy z-2(HmKwK7#(8)pv3pEznS*Wtm#zKXKGQ#{&*C(+f1&N(aNCGR&vyfw;_=JUjv+!{U zHX>ufM{K`z$u zF$+Ip;fE|d!NTJ#{J;XS-w=;kFv6oOJi@}mEM$iR;`=Q3APfJ^LNZpc^c&)PESnvr zh<=v4kA?5Da4!q@uyA(>(n;v95GMV`P8RN9;dT~oW8qd7zQe+|S@@PN5Q~dI z@)P_zG)CVoewxIbek=K+vKN;6Wjrah9PW~sxJ@!GJpF?*yrbKmUOICT^X76%r20i`~*_)?Dtdc$QOG z2{#YQxEW?Lw0mDZpoq;$q!owcM7e-$>VaX0P$dt84xxEfh6RT(!(tM2JWMju*+1p5 zT#_lO+-kqgqdd%TsioW??g!2-k&{l~$x?b4tL=IAF81o`FX|`GCF*pwCrrpv`tUyg z!7PR6%?%SrvvIe9Xzn&BPQ=4Ij*$xP)c*~Zj#OsinL=dRN|61Gf?EI%z+&_j z>3jN({(dU%Ci(IiB}ox7&9D5L76%PSDYFD|CF~l7F@=ScQP4{E!jn-7Zo0S+$DLFaH-?yPX+MGcIr8G2=GG9p zwE}iz<0-12#$YO?pP>3!WtLe+>&7baa&7lO6xf+l#VeD&B>8Y>tb&_m%u^zLc3^6Y z$6?}R4>i4WijH60$0_|3@d+iAzZKm2G7#F1SMVfCZ%Qwj;q=13@g$%7C}Dj9mV`Ee zWNSJlZ_=O8 zw_L$C^FHjP{u^)&eYaF9e#0dN*ROIQcBJYRqF)F7EQwqIZK(euOgO7xD;*0b&nj(U zX}N;C#I{1+bIM}3_zr9C?k0~{vcDpofp5<#@4@NMaTA18TYU=~GyjEB7tgaj{v36w zIt=+jSu6-Q;JYu-mY5Hf&J!EWVcL0u$HN=cUk2xhFYzE;K)!ed4|5ZKdsun_<;6Qt z?IOVgp~pof&^OJxs3gjL^@X@AuutbixNwmO`tfKiNpDXky}rEtUby?E@`@n!!eg%_ zVk}&y{s3tC6~Rxz%C8jcXQS}cD_T-(-Ne3`;;=Z5iFe%nslW?tYQk<%g(khR<9fzNl@>Ig!23#I06TOG)LL5T8W zgvT}*IWRg3j?5H1{y7J11(CX-RO4*Nm1>UR_Mh!PlUL5$yu4sg;fo0{bi5}X`mDn< zd>hAmTKIo2c0MimuU2yx@zR{QVvoOUruz+EEpA@yCWpm+i?4Tq?8}OEV-10HW6hq^ z*I!jCj*7?miodOAkyTP!eotlLGe(d##0%)DC&QxrF(-wHNni1Gh zRfXc%AF!#a?bWS{)Q%D+;YP^Km)fiN;%N$(JE+)A-zJO|85l9Kqgp7);jq0U(zzc@ zjekm}Mxlh_+%ho^5<96|6w%x#HmM8tMGrcmq<9*)%n^;|?mX{0x7UBNv)Ydrq;J66 z6*=Mzs)OvU+MLssBw{_DJyT~2(wlInn~EnzZ$fl;q9TDtEO-+)gpKa5;<3jOgy2h0 zjDxTq>JT`buGUh-M>N(E8Q;wNdZ>#ar-xcok>V(|q+^)5-%xxXH-@PLp--|S&?Ppe z5%Qj3$CuJel@!6;t@q=4HLyeR7;a)ylObzjMBo@(3o1djNC|i_>1r>Mm%x!v+;XJ| zF_gb9CTMaK9%4fUOM9uc1SubO_Qt%43*Zv^ieR3ey}uD(2e~gQ67E{VnKoBDt~cM81Fcl8Ch&bBDL1kTv@cbMh6!y1oxg@#hzoEufSr?ir6fJZ91f8@ zz@Me+abM?NIDVK4*Vd~I2*Cr@HmFJPKJo>r1x&#EO-NQ&6Cby9pQJbj{-k&l)Y+(F zXOj;_8wq~_yi0sRhOalOI8^!$&b*=G-rN{i{3hYohXZdS#u35vO)B;weHWP^hP)vO3dpNuqEeV-WeGAdB629C*EG-1}Eu`Z>VDwum4wrHu^KBw` z9P-~*v0EDj&UXm^4;c3j(enUSy+i2RAz>>?`FfbURUHnVZA54&q@nK?Te_!MJ9F}4 z=VKvf8xi;kPHiIzs0|CY6TcpG+<{lX$R5(&z{}g!x^BhCW-E3)9?CtlgJkPT(N1;W zPPeTf|L-f6p9IMC=^Bjq*6xAFtCjlF!Y+#k z!Ow5oFj^%jA8UC1E#I-FNRYWo)8I_9%?6kH*$ucShz{_cL<_I)uvOicr3s$=tK3uE zeO}qluM*OQO8igmtzwDzoIcd?t=PtqCWZ^&J0c~wyHMUMFOswH5*Vj+lkO!R>ogqi zOV6lpsxPX2$RT*=5`CxkgR_-()Zx=sYB_q5)={fsUu*w}pTynQFWINqYl+{ef9j3+ zs_Gf>->$1_Z2{)wYW46^DcGK?EmtJ7<0?qNvCzakEm09W(T1=y(ZDgMxh43xvCR|>8>M-iw0ajDb1t5u;TWwg{F^fNQ>`ym3bxR# z;GU|LD^fD0pLq){DAP3De_jK!rfL1)@-$Q@{mUkBeIaAD8pr<&Ql@Klc$*)+wSpi< zz?tW@eEvRU%+Pwny%`u?r3aFI1%ZvTw#UwAaX7gCONTjrG}}Fa(DnuG0)#sab2#qUT>ze$8ups=p(Xl?G>^v3_#(K= z_<1+(;9hYnFn%?+8Rs<%z9P)gT*?XTF)q&}Wfc$3mx#J4zJdmckcDAInm|=PnLrLG zo8#DF&S2qVPe|g3f6+MC->l??`m;0#_-1O@Ie$c{y7%YBT8_WLuap}j_&#tkKFci#)2=gsgVV8+bi7Ok zufp(`c^wXXhdh8Qv5tchX`HaOrX!KR3BT7QS2;Cxcf?^;-CB;icy&{JEeB5e-ULsa z1G|JqkVbqwc$q_dp#;{(Ik4IP4uj$y*pHlsTk%Mjp7Td0IHseK8SqMNRDdt6=j&hu zDFNiVM1UMWA_9+~cOo$m2QBIm1MkC_dWda*!&CL~R+Y154jdCc_9-|`y2BSjFaKw^?(u}!*7yB(jWQyus^$uS&X<+M*4mgK-b zsXyB%6?Dd_LTR$2p^K%rI~L*f|DEhmOT#|k)ER1bSB_fM_M`26+Y;LdP*YsJySmH8 z1Xmg#EBt{66hFpO&f{Eb@z}o8oS5r_+>!b%XFoEI=YDp4M8@&v7#>IPWc0p3-C~a3 zgO3NwQe2k>8E?L6=)(M6gDccO3C>0?%y$@!YlL*+2mh)@t}eV=ILM~>?%Oy96gT>f zCN6T9$7ZP46r+o+VKDKzxv;RQ3(Ngwh)N~&&;6ZJUA6I!vzK|V+x`Ztt6>yQS~JQW zICt+4JIWnxq1su;PQ=~td6Yhm>%gnxKAUU zhFL99Nct4EwZz=u=5t3Yf`>s%>f2x}@x`xU2`UrBY4A}iqP!~fYfa+sh25=5d^`wF z{k7m}L+~0%qy7rGT8_0scEDPS@53qTkA%Odp9zU=UCEGx0E*hWJTS4DtA-%X1%F$j z<|y21>l$u0iLPm`g|Gn0@OPTa=9jNKo}@VyaU-Ya*jK2cvfS_SItSTc-M_+B=)XQ{ zE>wzl>hc_SF>VOYz8xL|+J-2+qT-lg9l;W4ZVgY~5cQ)evanwa4Q776ZYc*Q{M=BTCe9DQki z93-bl#L)YVE^dyBgRJurR?7s2u9k}35)}{V9HU9tm*V7eZ9)mxhx>qxoSbc%wQ(He zmvbLD#=6ft(u4!r5B&GqQ6bzd@#9^K$UQB5I-cu)POBm|7e|uYTI}8M#Z?1ll{%xN zmA{qGmA94I$^fN44%{xv+x4CDTzN2TEp?71PruT7yopdc3F+u7lG)`IydFF`ag_FW zC9ec8YS;^h*E<8JP!6|t1xA~jq4fqLy_UwV-X=(21I3iS!d8bYLu``FRRsYKY;ZnQ zWb@3#jo(xp&~J3|irAmZ-ybD=SG?&=g1fnz4Vu2`><5z%8#T;pNv6Q|H_?y&*xhg%tQhwZNK4jtN6*j$&z-PnO z9jH!LX#A_&oj9A^PSWum_UT)o&s-e1^xfec4ryzGN2{*WsQgJB0L=b`EV^9Vx6{eb zCU=68Tf0Ilr}{13kK@@YW%wGw!-m;Vnj4||Di!CMQ-h?uh!hy9>-ZkJ>PyW?hV6L~ zIBlN;<0eO750wS0h|isak15^-e-K}~1PN1!|Czu4lnAn5nF5cdA^~qHsW*)Gf_suQ=v0Snso5IMEvWJ_EJP^nEqk}PKpM?T1tE#PQ4U?0{{f@^{j|wL5hLM z{D@@VI($h_$&V=UOYK~LA66!FUGZ|L<(i&4)TKMVQ{S-n~Z&|VX- z-Ypew@kfMLg)#EZ<*(^86=?^3+V|eUiEnFO$3a;nOyG5_fQKpLZ*tGiAzsJD(?QTy z(37E|(_v0h?Wr`z`dv7ZJ|O6i6v>>GCXdAh%gdsU7s8km-|`N@(_oQQ;^@=ic-U=+ zv&nx|I@0IB09nVi+krG{Nm+1(?|IlM>kkFF7Hm~W@>WwmsqgSm(F@7bpJl@gi7ABP zJ6ePbv#Nexkt)-OvHnP}znWdI&qJPu^1Rnk8C!74B0PT-I)+Ce9M<$<{F~sd6KJEh zhR)I4n}9VWPO;T@e$D+E)Go>WUCntMjDIc!ZlpZq(BBf9W5aU)g@v%lsVB>Hyj>xo z*iN{b;~FR7nJU6)~^BH9Tp@74|az2uhNWMUpb8F43Fu}(?iGfj(imn2k2O(2n<<$<)PFR z&^cvVEgU&dGjENovl53kw1h~=VwEze)!l#LnOQ8MFn%Ot7KGRG_gds^#jhxKN}Hv2 zdZtI=+Hj6vwOnb2Se4&}m)AykE7z3^bBl2meusw+ZtD8Vm>VAvh0>Om?2H0*_Ryt4 z8(#f5x)={7u3sXWUj&+! z=IOqgpNipalZ&Sm+vsJNaZhPoVEqh6gg{f|y%$~8{BxetV(}0rx$*M$>-I?Ky+F~( z(9VGCPir^TIb5+qFRmn)#?pb^?SYsKa^sgJ8T2AqTD*8)>=inmQx~YkGEKzeL70e= zp-dtOq>1pi8?5aS9F}1w6u9}ykm46LJm*^32G{mgHV8DJ`!H?vzEa(m)i4p?pqudT zBfJflTxyM1gL9AYuO9xrjkGc7zmI>zF`SEkqv7|#{a40ZZRaxChz9-6|QQIEc-+Cbz2j8 zH}(g;<%ON+b%=)xm+-c%*spL=v-($+$H)tF=5g zrqdRsK-%+$W@|ogCg}uWe{($+mbM7ykc2E&D1%t>KWwgt({s0G3jJ48JTLH=(RtZs zMu$#P;s711G=XOJUTqc39=X_N#dTeZP{pB~KHYiS(bs-TZLVyP!^ELvMQ;u-&2Rkj z0I*ea#0v$HfMe+1aAm0dBDDR$mg^tt)3-P`OS9d@5vr8K>5E)~V}sUGJ#QPQkX1}0 zMS3C^jhHt%9_Am%0WN)Ees;s5uC(o~(}DXK&}%+tQG8j!Xb&oI+okAy6ROd0f1 zzBbPNll4)KDvC01U_LS)&!?kf6(i6r-RtAsM?+%xPAQ74c&DUK#8}iS30bUC2DSQd zf;;dcBe!lyg;_;`H(B;&M?jW@EV7hAWutT4^i3A5_@j!#%@pTN3fd(hi!5bO*`!I< zWUGZki^6DWl|_1L0GY`U$0|yonPa`7{+^j*TA8IHgGyd+sMlE0NRPumiyT3wn75IB zBUDgQ;p8HZξ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 Date: Thu, 16 Apr 2026 21:13:55 -0500 Subject: [PATCH 18/24] Fix stale app lock detection on container restarts. Treat a lock file with the current PID as stale so Docker restarts with pid 1 do not trigger false duplicate-instance exits. Made-with: Cursor --- index.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/index.js b/index.js index bc68616..29f6ed9 100644 --- a/index.js +++ b/index.js @@ -463,7 +463,9 @@ function acquireAppLockOrExit() { const raw = fs.readFileSync(APP_LOCK_FILE, "utf8"); const lock = JSON.parse(raw || "{}"); const lockPid = parseInt(lock.pid, 10); - if (!processExists(lockPid)) { + // In containers PID 1 is frequently reused across restarts. If a prior + // run left a stale lock with our current PID, treat it as stale. + if (lockPid === process.pid || !processExists(lockPid)) { fs.unlinkSync(APP_LOCK_FILE); fs.writeFileSync(APP_LOCK_FILE, payload, { flag: "wx" }); appLockHeld = true; From 1e234e19c7daa2da89edae22b80e0bd01cbdd7c5 Mon Sep 17 00:00:00 2001 From: binarygeek119 Date: Fri, 17 Apr 2026 00:48:15 -0500 Subject: [PATCH 19/24] Add sister project link for Ubuntu Display OS. Document ubuntudisplayos as the companion Ubuntu kiosk host project that pairs with Posterr deployments. Made-with: Cursor --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index e10cd97..6221678 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ > **About this repository** > This is an **AI-assisted fork** of [Posterr](https://github.com/petersem/posterr) by **binarygeek119**. Upstream owns the original design and core product; this repo adds the features in **New in this fork** below. > **Source:** [github.com/binarygeek119/posterr](https://github.com/binarygeek119/posterr) · **Docker image:** [`binarygeek119/postarr`](https://hub.docker.com/r/binarygeek119/postarr) (`:latest`) +> **Sister project:** [binarygeek119/ubuntudisplayos](https://github.com/binarygeek119/ubuntudisplayos) for Ubuntu-based multi-display kiosk hosts that pair well with Posterr. ![GitHub stars](https://img.shields.io/github/stars/binarygeek119/posterr?style=flat) ![Fork version](https://img.shields.io/github/package-json/v/binarygeek119/posterr?label=version&logoColor=blue) From cf5add786691f5463ea5f758cec76605856ebb5f Mon Sep 17 00:00:00 2001 From: binarygeek119 Date: Fri, 17 Apr 2026 06:20:11 -0500 Subject: [PATCH 20/24] Refine now-showing low-power detection for kiosk devices. Switch auto low-power mode to a maintainable device hint list (seeded for Raspberry Pi 1-3 class hosts), keep manual lowPower URL support documented, and preserve ticker animation unless reduced motion is explicitly requested by the browser. Made-with: Cursor --- README.md | 2 +- myviews/now-showing.ejs | 36 ++++++++++++++++++++++++++++++------ 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 6221678..2565bd2 100644 --- a/README.md +++ b/README.md @@ -207,7 +207,7 @@ Get to the settings page in a number of ways: *The default password is:* **raidisnotabackup** -**Now Showing (this fork):** configure the TMDB movie list at **`/settings/now-showing`** (also linked from **Settings**). The public-style schedule view is at **`/now-showing`** (prefix with `BASEPATH` if you use a reverse-proxy base path). Set a TMDB API key in **Settings → TMDB API** or the **`TMDB_API_KEY`** environment variable. +**Now Showing (this fork):** configure the TMDB movie list at **`/settings/now-showing`** (also linked from **Settings**). The public-style schedule view is at **`/now-showing`** (prefix with `BASEPATH` if you use a reverse-proxy base path). For reduced animation/scroll load on low-powered devices, use **`/now-showing?lowPower=1`**. Set a TMDB API key in **Settings → TMDB API** or the **`TMDB_API_KEY`** environment variable. **Ads (this fork):** configure slides and timings at **`/settings/ads`**. The dedicated slideshow is at **`/ads`**; use **seconds per ad** for each slide and **seconds on full `/ads` page before returning home** to auto-navigate back to the home poster view when you want a timed lobby loop. diff --git a/myviews/now-showing.ejs b/myviews/now-showing.ejs index 5ad13e0..1ac0b3b 100644 --- a/myviews/now-showing.ejs +++ b/myviews/now-showing.ejs @@ -615,12 +615,37 @@ var nsCycleEmbed = nsUrlQs.get("cycleEmbed") === "1"; var lowPowerRequested = nsUrlQs.get("lowPower") === "1"; var lowPowerDetected = false; + /** + * Auto low-power target device hints. + * Add additional lowercase tokens here as needed. + */ + var LOW_POWER_DEVICE_HINTS = [ + "raspberry pi 1", + "raspberry pi 2", + "raspberry pi 3", + "raspbian", + "armv6", + "armv7", + ]; try { - var mem = Number(navigator && navigator.deviceMemory); - var cpu = Number(navigator && navigator.hardwareConcurrency); - lowPowerDetected = - (Number.isFinite(mem) && mem > 0 && mem <= 1.5) || - (Number.isFinite(cpu) && cpu > 0 && cpu <= 4); + var nav = navigator || {}; + var ua = String(nav.userAgent || "").toLowerCase(); + var platform = String(nav.platform || "").toLowerCase(); + var vendor = String(nav.vendor || "").toLowerCase(); + var uaData = nav.userAgentData || {}; + var uaBrands = ""; + if (uaData && Array.isArray(uaData.brands)) { + uaBrands = uaData.brands + .map(function (b) { + return String((b && b.brand) || ""); + }) + .join(" ") + .toLowerCase(); + } + var deviceFingerprint = [ua, platform, vendor, uaBrands].join(" "); + lowPowerDetected = LOW_POWER_DEVICE_HINTS.some(function (hint) { + return deviceFingerprint.indexOf(hint) !== -1; + }); } catch (e) {} var lowPowerMode = lowPowerRequested || lowPowerDetected; if (lowPowerMode && document.body) { @@ -676,7 +701,6 @@ var reduceMotion = typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches; - if (lowPowerMode) reduceMotion = true; var disableAutoScroll = nsUrlQs.get("autoscroll") === "0"; /** @type {IntersectionObserver[]} */ var tickerObservers = []; From 48be18bdf22ec5e106c71da9b5f4a594b1b00118 Mon Sep 17 00:00:00 2001 From: binarygeek119 Date: Fri, 17 Apr 2026 07:14:19 -0500 Subject: [PATCH 21/24] Use month-day inputs for holiday fixed date rules. Replace year-bound date pickers with MM-DD fields so fixed holiday ranges save reliably without requiring a year. Made-with: Cursor --- myviews/settings-holidays.ejs | 32 ++++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/myviews/settings-holidays.ejs b/myviews/settings-holidays.ejs index cf67aca..69009c8 100644 --- a/myviews/settings-holidays.ejs +++ b/myviews/settings-holidays.ejs @@ -77,12 +77,28 @@
- - "> + + " + placeholder="12-20" + inputmode="numeric" + pattern="^(0?[1-9]|1[0-2])-(0?[1-9]|[12][0-9]|3[01])$" + >
- - "> + + " + placeholder="12-31" + inputmode="numeric" + pattern="^(0?[1-9]|1[0-2])-(0?[1-9]|[12][0-9]|3[01])$" + >
@@ -161,12 +177,12 @@ '' + '
' + '
' + - '' + - '' + + '' + + '' + '
' + '
' + - '' + - '' + + '' + + '' + '
' + '
' + '' + From f4a82bd4db86190edd49210e18ba63ebae33aa8e Mon Sep 17 00:00:00 2001 From: binarygeek119 <72405030+binarygeek119@users.noreply.github.com> Date: Fri, 17 Apr 2026 07:26:27 -0500 Subject: [PATCH 22/24] Restore calendar pickers for holiday date rules. Keep fixed holiday ranges month/day based while using date inputs in the UI, with clear labels that the selected year is ignored. Made-with: Cursor --- myviews/settings-holidays.ejs | 32 ++++++++------------------------ 1 file changed, 8 insertions(+), 24 deletions(-) diff --git a/myviews/settings-holidays.ejs b/myviews/settings-holidays.ejs index 69009c8..2b3a50b 100644 --- a/myviews/settings-holidays.ejs +++ b/myviews/settings-holidays.ejs @@ -77,28 +77,12 @@
- - " - placeholder="12-20" - inputmode="numeric" - pattern="^(0?[1-9]|1[0-2])-(0?[1-9]|[12][0-9]|3[01])$" - > + +
- - " - placeholder="12-31" - inputmode="numeric" - pattern="^(0?[1-9]|1[0-2])-(0?[1-9]|[12][0-9]|3[01])$" - > + +
@@ -177,12 +161,12 @@ '' + '
' + '
' + - '' + - '' + + '' + + '' + '
' + '
' + - '' + - '' + + '' + + '' + '
' + '
' + '' + From 0c7773c673b0857b24da6256460800c97f37dce7 Mon Sep 17 00:00:00 2001 From: binarygeek119 <72405030+binarygeek119@users.noreply.github.com> Date: Fri, 17 Apr 2026 08:03:31 -0500 Subject: [PATCH 23/24] Add holiday title keyword matching with AND/OR rule mode. Extend holiday rules to support title and plot keyword matching in addition to tag text, persist the new fields, and apply OR/AND matching across Home and Now Showing boosts. Made-with: Cursor --- classes/core/holidaysDb.js | 37 +++++++- index.js | 169 +++++++++++++++++++++++++++------- myviews/settings-holidays.ejs | 33 ++++++- 3 files changed, 205 insertions(+), 34 deletions(-) diff --git a/classes/core/holidaysDb.js b/classes/core/holidaysDb.js index 3fe0ef4..7f78a85 100644 --- a/classes/core/holidaysDb.js +++ b/classes/core/holidaysDb.js @@ -15,6 +15,9 @@ CREATE TABLE IF NOT EXISTS holiday_rules ( nth_value TEXT NOT NULL DEFAULT '4', span_days INTEGER NOT NULL DEFAULT 0, tag_text TEXT NOT NULL DEFAULT '', + title_keywords TEXT NOT NULL DEFAULT '', + plot_keywords TEXT NOT NULL DEFAULT '', + match_mode TEXT NOT NULL DEFAULT 'or', sort_order INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT '', updated_at TEXT NOT NULL DEFAULT '' @@ -33,6 +36,31 @@ function persistDb() { fs.writeFileSync(DB_FILE, Buffer.from(_db.export())); } +function ensureExtraColumns() { + assertDb(); + const cols = new Set(); + const s = _db.prepare("PRAGMA table_info(holiday_rules)"); + while (s.step()) { + const r = s.getAsObject(); + cols.add(String(r.name || "").toLowerCase()); + } + s.free(); + let changed = false; + if (!cols.has("plot_keywords")) { + _db.run("ALTER TABLE holiday_rules ADD COLUMN plot_keywords TEXT NOT NULL DEFAULT ''"); + changed = true; + } + if (!cols.has("title_keywords")) { + _db.run("ALTER TABLE holiday_rules ADD COLUMN title_keywords TEXT NOT NULL DEFAULT ''"); + changed = true; + } + if (!cols.has("match_mode")) { + _db.run("ALTER TABLE holiday_rules ADD COLUMN match_mode TEXT NOT NULL DEFAULT 'or'"); + changed = true; + } + if (changed) persistDb(); +} + async function initHolidaysDb() { if (_db) return; const initSqlJs = require("sql.js"); @@ -46,6 +74,7 @@ async function initHolidaysDb() { if (fs.existsSync(DB_FILE)) _db = new SQL.Database(fs.readFileSync(DB_FILE)); else _db = new SQL.Database(); _db.exec(SCHEMA_SQL); + ensureExtraColumns(); if (!fs.existsSync(DB_FILE)) persistDb(); } @@ -65,6 +94,9 @@ function listRules() { nth: String(r.nth_value || "4"), spanDays: Number(r.span_days) || 0, tag: String(r.tag_text || ""), + titleKeywords: String(r.title_keywords || ""), + plotKeywords: String(r.plot_keywords || ""), + matchMode: String(r.match_mode || "or"), sortOrder: Number(r.sort_order) || 0, }); } @@ -77,7 +109,7 @@ function replaceRules(rules) { const list = Array.isArray(rules) ? rules : []; const now = new Date().toISOString(); const ins = _db.prepare( - "INSERT INTO holiday_rules (mode, start_md, end_md, month_num, weekday_num, nth_value, span_days, tag_text, sort_order, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" + "INSERT INTO holiday_rules (mode, start_md, end_md, month_num, weekday_num, nth_value, span_days, tag_text, title_keywords, plot_keywords, match_mode, sort_order, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" ); try { _db.run("BEGIN"); @@ -93,6 +125,9 @@ function replaceRules(rules) { String(r.nth || "4"), Number(r.spanDays) || 0, String(r.tag || ""), + String(r.titleKeywords || ""), + String(r.plotKeywords || ""), + String(r.matchMode || "or"), i, now, now, diff --git a/index.js b/index.js index 29f6ed9..d858c83 100644 --- a/index.js +++ b/index.js @@ -3433,13 +3433,13 @@ app.get(BASEURL + "/posters", (req, res) => { }); app.get(BASEURL + "/getcards", (req, res) => { - const holidayTags = activeHolidayTagsForToday(); + const holidayRules = activeHolidayRulesForToday(); const postersOnly = String((req.query && req.query.postersOnly) || "").trim() === "1"; if (!postersOnly) { - const cards = boostCardsByHolidayTags( + const cards = boostCardsByHolidayRules( Array.isArray(globalPage && globalPage.cards) ? globalPage.cards : [], - holidayTags + holidayRules ); return res.send({ globalPage: { @@ -3457,7 +3457,7 @@ app.get(BASEURL + "/getcards", (req, res) => { return n !== "ad" && n !== "now showing"; }) : []; - const boosted = boostCardsByHolidayTags(cards, holidayTags); + const boosted = boostCardsByHolidayRules(cards, holidayRules); return res.send({ globalPage: { ...globalPage, @@ -3622,6 +3622,18 @@ function normalizeHolidayMode(raw) { : "fixed"; } +function normalizeHolidayMatchMode(raw) { + return String(raw || "").trim().toLowerCase() === "and" ? "and" : "or"; +} + +function normalizeHolidayPlotKeywords(raw) { + return String(raw || "") + .split(",") + .map((s) => s.trim().toLowerCase()) + .filter(Boolean) + .slice(0, 20); +} + function computeNthWeekdayOfMonth(year, monthOneBased, weekday, nth) { if (!Number.isFinite(year)) return null; const month = Math.max(1, Math.min(12, monthOneBased)); @@ -3654,6 +3666,9 @@ function readHolidayRulesFromSettings() { .map((r) => { const mode = normalizeHolidayMode(r && r.mode); const tag = String((r && r.tag) || "").trim().toLowerCase(); + const titleKeywords = normalizeHolidayPlotKeywords(r && r.titleKeywords); + const plotKeywords = normalizeHolidayPlotKeywords(r && r.plotKeywords); + const matchMode = normalizeHolidayMatchMode(r && r.matchMode); if (mode === "dynamic") { const month = normalizeHolidayInt(r && r.month, 1, 12, 11); const weekday = normalizeHolidayInt(r && r.weekday, 0, 6, 4); @@ -3663,14 +3678,37 @@ function readHolidayRulesFromSettings() { ? "last" : String(normalizeHolidayInt(nthRaw || "1", 1, 5, 1)); const spanDays = normalizeHolidayInt(r && r.spanDays, 0, 31, 0); - return { mode, month, weekday, nth, spanDays, tag }; + return { + mode, + month, + weekday, + nth, + spanDays, + tag, + titleKeywords, + plotKeywords, + matchMode, + }; } const start = normalizeHolidayMonthDay(r && r.start); const end = normalizeHolidayMonthDay(r && r.end); - return { mode: "fixed", start, end, tag }; + return { + mode: "fixed", + start, + end, + tag, + titleKeywords, + plotKeywords, + matchMode, + }; }) .filter((r) => { - if (!r.tag) return false; + if ( + !r.tag && + (!Array.isArray(r.titleKeywords) || r.titleKeywords.length === 0) && + (!Array.isArray(r.plotKeywords) || r.plotKeywords.length === 0) + ) + return false; if (r.mode === "dynamic") return true; return !!(r.start && r.end); }); @@ -3682,13 +3720,13 @@ function monthDayInHolidayRange(todayMd, startMd, endMd) { return todayMd >= startMd || todayMd <= endMd; } -function activeHolidayTagsForToday() { +function activeHolidayRulesForToday() { const now = new Date(); const todayMd = String(now.getMonth() + 1).padStart(2, "0") + "-" + String(now.getDate()).padStart(2, "0"); - const active = new Set(); + const active = []; for (const rule of readHolidayRulesFromSettings()) { if (rule.mode === "dynamic") { const base = computeNthWeekdayOfMonth( @@ -3709,23 +3747,43 @@ function activeHolidayTagsForToday() { "-" + String(end.getDate()).padStart(2, "0"); if (monthDayInHolidayRange(todayMd, startMd, endMd)) { - active.add(rule.tag); + active.push(rule); } continue; } if (monthDayInHolidayRange(todayMd, rule.start, rule.end)) { - active.add(rule.tag); + active.push(rule); } } return active; } -function cardMatchesHolidayTag(card, tagLc) { - if (!card || !tagLc) return false; +function ruleTextMatch(rule, tagHay, titleHay, plotHay) { + if (!rule) return false; + const needsTag = !!rule.tag; + const needsTitle = + Array.isArray(rule.titleKeywords) && rule.titleKeywords.length > 0; + const needsPlot = + Array.isArray(rule.plotKeywords) && rule.plotKeywords.length > 0; + if (!needsTag && !needsTitle && !needsPlot) return false; + const tagMatch = !needsTag || String(tagHay || "").includes(rule.tag); + const titleMatch = + !needsTitle || + rule.titleKeywords.some((kw) => String(titleHay || "").includes(kw)); + const plotMatch = + !needsPlot || + rule.plotKeywords.some((kw) => String(plotHay || "").includes(kw)); + return rule.matchMode === "and" + ? tagMatch && titleMatch && plotMatch + : tagMatch || titleMatch || plotMatch; +} + +function cardMatchesHolidayRule(card, rule) { + if (!card || !rule) return false; const genreText = Array.isArray(card.genre) ? card.genre.join(" ") : String(card.genre || ""); - const hay = ( + const tagHay = ( String(card.title || "") + " " + String(card.tagLine || "") + @@ -3748,16 +3806,24 @@ function cardMatchesHolidayTag(card, tagLc) { ) .toLowerCase() .trim(); - return hay.includes(tagLc); + const titleHay = ( + String(card.title || "") + " " + String(card.tagLine || "") + ) + .toLowerCase() + .trim(); + const plotHay = ( + String(card.summary || "") + " " + String(card.plot || "") + ) + .toLowerCase() + .trim(); + return ruleTextMatch(rule, tagHay, titleHay, plotHay); } -function nowShowingRowMatchesHolidayTag(row, tagLc) { - if (!row || !tagLc) return false; - const hay = ( +function nowShowingRowMatchesHolidayRule(row, rule) { + if (!row || !rule) return false; + const tagHay = ( String(row.title || "") + " " + - String(row.overview || "") + - " " + String(row.genres || "") + " " + String(row.topCast || "") + @@ -3766,17 +3832,19 @@ function nowShowingRowMatchesHolidayTag(row, tagLc) { ) .toLowerCase() .trim(); - return hay.includes(tagLc); + const titleHay = String(row.title || "").toLowerCase().trim(); + const plotHay = String(row.overview || "").toLowerCase().trim(); + return ruleTextMatch(rule, tagHay, titleHay, plotHay); } -function boostCardsByHolidayTags(cards, activeTags) { +function boostCardsByHolidayRules(cards, activeRules) { if (!Array.isArray(cards) || !cards.length) return cards; - if (!(activeTags instanceof Set) || activeTags.size === 0) return cards; + if (!Array.isArray(activeRules) || activeRules.length === 0) return cards; const boost = []; for (const card of cards) { let matches = false; - for (const tag of activeTags) { - if (cardMatchesHolidayTag(card, tag)) { + for (const rule of activeRules) { + if (cardMatchesHolidayRule(card, rule)) { matches = true; break; } @@ -3787,14 +3855,14 @@ function boostCardsByHolidayTags(cards, activeTags) { return cards.concat(boost, boost); } -function boostNowShowingRowsByHolidayTags(rows, activeTags) { +function boostNowShowingRowsByHolidayRules(rows, activeRules) { if (!Array.isArray(rows) || !rows.length) return rows; - if (!(activeTags instanceof Set) || activeTags.size === 0) return rows; + if (!Array.isArray(activeRules) || activeRules.length === 0) return rows; const boost = []; for (const row of rows) { let matches = false; - for (const tag of activeTags) { - if (nowShowingRowMatchesHolidayTag(row, tag)) { + for (const rule of activeRules) { + if (nowShowingRowMatchesHolidayRule(row, rule)) { matches = true; break; } @@ -4287,7 +4355,10 @@ app.get(BASEURL + "/now-showing/data", async (req, res) => { ); } movies = movies.filter(nowShowingRowHasBannerAndLogo); - movies = boostNowShowingRowsByHolidayTags(movies, activeHolidayTagsForToday()); + movies = boostNowShowingRowsByHolidayRules( + movies, + activeHolidayRulesForToday() + ); await ensureNowShowingMoviesImageCacheForResponse(movies, BASEURL); let curatedWeight = parseInt(loadedSettings.nowShowingCuratedWeight, 10); if (isNaN(curatedWeight)) @@ -5438,6 +5509,21 @@ app.post(BASEURL + "/settings/holidays", async (req, res) => { : req.body.holidayTag != null ? [req.body.holidayTag] : []; + const titleKeywordsRaw = Array.isArray(req.body.holidayTitleKeywords) + ? req.body.holidayTitleKeywords + : req.body.holidayTitleKeywords != null + ? [req.body.holidayTitleKeywords] + : []; + const plotKeywordsRaw = Array.isArray(req.body.holidayPlotKeywords) + ? req.body.holidayPlotKeywords + : req.body.holidayPlotKeywords != null + ? [req.body.holidayPlotKeywords] + : []; + const matchModesRaw = Array.isArray(req.body.holidayMatchMode) + ? req.body.holidayMatchMode + : req.body.holidayMatchMode != null + ? [req.body.holidayMatchMode] + : []; const modesRaw = Array.isArray(req.body.holidayMode) ? req.body.holidayMode : req.body.holidayMode != null @@ -5467,6 +5553,9 @@ app.post(BASEURL + "/settings/holidays", async (req, res) => { startsRaw.length, endsRaw.length, tagsRaw.length, + titleKeywordsRaw.length, + plotKeywordsRaw.length, + matchModesRaw.length, modesRaw.length, monthsRaw.length, weekdaysRaw.length, @@ -5477,6 +5566,9 @@ app.post(BASEURL + "/settings/holidays", async (req, res) => { for (let i = 0; i < maxLen; i++) { const mode = normalizeHolidayMode(modesRaw[i]); const tag = String(tagsRaw[i] || "").trim(); + const titleKeywords = normalizeHolidayPlotKeywords(titleKeywordsRaw[i]); + const plotKeywords = normalizeHolidayPlotKeywords(plotKeywordsRaw[i]); + const matchMode = normalizeHolidayMatchMode(matchModesRaw[i]); const start = normalizeHolidayMonthDay(startsRaw[i]); const end = normalizeHolidayMonthDay(endsRaw[i]); const month = normalizeHolidayInt(monthsRaw[i], 1, 12, 11); @@ -5486,8 +5578,13 @@ app.post(BASEURL + "/settings/holidays", async (req, res) => { const spanDays = normalizeHolidayInt(spansRaw[i], 0, 31, 0); if ( !tag && + titleKeywords.length === 0 && + plotKeywords.length === 0 && !start && !end && + !String(titleKeywordsRaw[i] || "").trim() && + !String(plotKeywordsRaw[i] || "").trim() && + !String(matchModesRaw[i] || "").trim() && !String(modesRaw[i] || "").trim() && !String(monthsRaw[i] || "").trim() && !String(weekdaysRaw[i] || "").trim() && @@ -5496,8 +5593,10 @@ app.post(BASEURL + "/settings/holidays", async (req, res) => { ) { continue; } - if (!tag) { - throw new Error("Each holiday row needs a tag."); + if (!tag && titleKeywords.length === 0 && plotKeywords.length === 0) { + throw new Error( + "Each holiday row needs a tag, title keywords, plot keywords, or a combination." + ); } if (mode === "dynamic") { out.push({ @@ -5507,6 +5606,9 @@ app.post(BASEURL + "/settings/holidays", async (req, res) => { nth, spanDays, tag: tag.slice(0, 80), + titleKeywords: titleKeywords.join(", ").slice(0, 240), + plotKeywords: plotKeywords.join(", ").slice(0, 240), + matchMode, }); continue; } @@ -5518,6 +5620,9 @@ app.post(BASEURL + "/settings/holidays", async (req, res) => { start, end, tag: tag.slice(0, 80), + titleKeywords: titleKeywords.join(", ").slice(0, 240), + plotKeywords: plotKeywords.join(", ").slice(0, 240), + matchMode, }); } holidaysDb.replaceRules(out); diff --git a/myviews/settings-holidays.ejs b/myviews/settings-holidays.ejs index 2b3a50b..b125016 100644 --- a/myviews/settings-holidays.ejs +++ b/myviews/settings-holidays.ejs @@ -61,7 +61,7 @@ <% } %> <% var _holidayRules = Array.isArray(holidayRules) ? holidayRules : []; %>
-

Add seasonal rules and match tags. When a rule is active, media that matches the tag appears more often on Home and /now-showing.

+

Add seasonal rules and match by tag, title keywords, and/or plot keywords. When a rule is active, media that matches the rule appears more often on Home and /now-showing.

Supports fixed ranges (month/day only) and dynamic dates (example: 4th Thursday in November for Thanksgiving).
<% if (_holidayRules.length) { %> @@ -125,6 +125,22 @@ " maxlength="80" placeholder="christmas, halloween, easter">
+
+ + " maxlength="240" placeholder="christmas, santa, reindeer"> +
+
+ + " maxlength="240" placeholder="christmas, xmas, noel"> +
+
+ <% var _matchMode = String(r.matchMode || "or").toLowerCase() === "and" ? "and" : "or"; %> + + +
@@ -195,6 +211,21 @@ '' + '' + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
' + '
' + '' + '
' + From b8bcbacac0223fab2a22be67661c6d4918c9d9b7 Mon Sep 17 00:00:00 2001 From: binarygeek119 <72405030+binarygeek119@users.noreply.github.com> Date: Fri, 17 Apr 2026 08:16:08 -0500 Subject: [PATCH 24/24] Harden holiday date saves and increase active-rule weighting. Parse YYYY-MM-DD holiday picker values without timezone shifts and raise holiday boost copies so matching titles appear more often during active date windows. Made-with: Cursor --- index.js | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/index.js b/index.js index d858c83..ffdeccb 100644 --- a/index.js +++ b/index.js @@ -3601,6 +3601,14 @@ function normalizeHolidayMonthDay(raw) { return String(mo).padStart(2, "0") + "-" + String(da).padStart(2, "0"); } } + const ymd = s.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/); + if (ymd) { + const mo = parseInt(ymd[2], 10); + const da = parseInt(ymd[3], 10); + if (mo >= 1 && mo <= 12 && da >= 1 && da <= 31) { + return String(mo).padStart(2, "0") + "-" + String(da).padStart(2, "0"); + } + } const dt = new Date(s); if (isNaN(dt.getTime())) return ""; return ( @@ -3840,6 +3848,7 @@ function nowShowingRowMatchesHolidayRule(row, rule) { function boostCardsByHolidayRules(cards, activeRules) { if (!Array.isArray(cards) || !cards.length) return cards; if (!Array.isArray(activeRules) || activeRules.length === 0) return cards; + const HOLIDAY_BOOST_COPIES = 4; const boost = []; for (const card of cards) { let matches = false; @@ -3852,12 +3861,15 @@ function boostCardsByHolidayRules(cards, activeRules) { if (matches) boost.push(card); } if (!boost.length) return cards; - return cards.concat(boost, boost); + const out = cards.slice(); + for (let i = 0; i < HOLIDAY_BOOST_COPIES; i++) out.push(...boost); + return out; } function boostNowShowingRowsByHolidayRules(rows, activeRules) { if (!Array.isArray(rows) || !rows.length) return rows; if (!Array.isArray(activeRules) || activeRules.length === 0) return rows; + const HOLIDAY_BOOST_COPIES = 4; const boost = []; for (const row of rows) { let matches = false; @@ -3870,7 +3882,9 @@ function boostNowShowingRowsByHolidayRules(rows, activeRules) { if (matches) boost.push({ ...row }); } if (!boost.length) return rows; - return rows.concat(boost, boost); + const out = rows.slice(); + for (let i = 0; i < HOLIDAY_BOOST_COPIES; i++) out.push(...boost); + return out; } function normalizeLibraryNameFor3d(name) {