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. 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 f716d37..2565bd2 100644 --- a/README.md +++ b/README.md @@ -2,32 +2,26 @@ ## 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`) +> **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) +![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 +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` - > **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. @@ -47,10 +41,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: @@ -66,15 +72,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 @@ -86,8 +101,6 @@ Create the following directories in your docker folder: - ./docker/posterr/custom ```yaml -version: "3.8" - services: posterr: image: binarygeek119/postarr:latest @@ -98,7 +111,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 @@ -114,7 +126,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 | @@ -154,7 +169,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. @@ -164,13 +179,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)** @@ -178,9 +193,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 @@ -192,6 +207,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). 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. + --- ## Possible Uses - Mount a monitor on your wall and showcase your home media setup @@ -213,17 +232,15 @@ 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) + - 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 @@ -252,7 +269,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/holidaysDb.js b/classes/core/holidaysDb.js new file mode 100644 index 0000000..7f78a85 --- /dev/null +++ b/classes/core/holidaysDb.js @@ -0,0 +1,169 @@ +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 '', + 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 '' +); +`; + +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())); +} + +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"); + 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); + ensureExtraColumns(); + 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 || ""), + titleKeywords: String(r.title_keywords || ""), + plotKeywords: String(r.plot_keywords || ""), + matchMode: String(r.match_mode || "or"), + 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, title_keywords, plot_keywords, match_mode, 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 || ""), + String(r.titleKeywords || ""), + String(r.plotKeywords || ""), + String(r.matchMode || "or"), + 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/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..8de2d0a 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,30 @@ 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', + tags_text TEXT NOT NULL DEFAULT '', + 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 +66,28 @@ 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: "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 ''" }, + { 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 +99,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 +140,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 +153,7 @@ async function initPosterMetadataDb() { _sqlDb = new SQL.Database(); } _sqlDb.exec(SCHEMA_SQL); + const addedSchemaColumns = ensurePosterEntriesExtraColumns(); let migratedJson = false; const cntAfterOpen = countRows(); @@ -116,7 +186,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 +228,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 +266,42 @@ 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, 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"); 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, + tagsText: e.tagsText, + 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 +343,30 @@ 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 || "", + tagsText: r.tags_text || "", + 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 +383,30 @@ 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.tagsText || "", + 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 || "", @@ -259,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 = []; @@ -292,6 +484,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 +542,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, 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"); @@ -358,41 +587,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 +770,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, 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"); 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 +830,48 @@ 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", + tagsText: normalizeMetadataTags(card), + 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 +882,49 @@ 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.tagsText !== row.tagsText || + 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 +937,7 @@ function registerFromMediaServerCards(nsCards, odCards, serverKind) { if (needsWrite) { ins.run(entryToParams(row)); changed = true; + written += 1; } } _sqlDb.run("COMMIT"); @@ -560,6 +956,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 +990,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 +1060,39 @@ 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.tags && row.tagsText) card.tags = row.tagsText; + 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 +1122,54 @@ 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.tags = row.tagsText || ""; + 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 +1269,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 +1359,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 +1425,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 +1439,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 +1461,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 +1473,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 +1515,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 +1555,7 @@ function getCacheDashboardStats() { fileCount: imageFiles, totalBytes: imageBytes, byCategory, + kindOrder: IMAGE_KIND_ORDER.slice(), byLibrary: diskLibraries, }, mp3cache: { @@ -985,4 +1576,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..3657018 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,30 @@ 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.homePageCycleDedicatedView = + DEFAULT_SETTINGS.homePageCycleDedicatedView; + this.enableViewHotkeys = DEFAULT_SETTINGS.enableViewHotkeys; this.enableOD = DEFAULT_SETTINGS.enableOD; this.enableSonarr = DEFAULT_SETTINGS.enableSonarr; this.enableRadarr = DEFAULT_SETTINGS.enableRadarr; @@ -84,6 +120,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 +154,10 @@ 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; + this.holidayRules = DEFAULT_SETTINGS.holidayRules; return; } @@ -160,6 +210,68 @@ 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.homePageCycleDedicatedView === undefined) + readSettings.homePageCycleDedicatedView = + DEFAULT_SETTINGS.homePageCycleDedicatedView; + if (readSettings.enableViewHotkeys === undefined) + readSettings.enableViewHotkeys = DEFAULT_SETTINGS.enableViewHotkeys; if(readSettings.enableOD==undefined) readSettings.enableOD = 'true'; if(readSettings.enableSonarr==undefined) readSettings.enableSonarr = 'true'; if(readSettings.enableReadarr==undefined) readSettings.enableReadarr = 'true'; @@ -169,6 +281,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 +330,11 @@ 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.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 = ''; @@ -245,26 +387,32 @@ class Settings { return; } - async UpdateSettings(settings){ - - if(fs.existsSync("config/settings.json")){ - // convert JSON object to string (pretty format) - const data = JSON.stringify(settings, null, 4); - - - // write JSON string to a file - fs.writeFileSync("config/settings.json", data, (err) => { - if (err) { - console.log('Error - writing to settings file',err); - throw err; + async UpdateSettings(settings) { + const settingsPath = "config/settings.json"; + if (!fs.existsSync(settingsPath)) { + return; + } + let disk = {}; + try { + disk = JSON.parse(fs.readFileSync(settingsPath, "utf-8")); + } catch (e) { + disk = {}; + } + const merged = { ...disk }; + if (settings && typeof settings === "object") { + for (const k of Object.keys(settings)) { + const v = settings[k]; + if (v !== undefined) { + merged[k] = v; + } } - }); - } - console.log(`✅ Upgraded settings file + } + fs.writeFileSync(settingsPath, JSON.stringify(merged, null, 4), "utf8"); + console.log(`✅ Upgraded settings file `); return; - } + } /** * @desc Saves settings after changes from settings page @@ -277,8 +425,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 +465,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 +484,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 +514,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 +600,214 @@ 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.homePageCycleDedicatedView !== undefined && + jsonObject.homePageCycleDedicatedView !== null + ) { + const v = String(jsonObject.homePageCycleDedicatedView).trim(); + this.homePageCycleDedicatedView = v === "ads" ? "ads" : "now-showing"; + } else { + this.homePageCycleDedicatedView = + cs.homePageCycleDedicatedView !== undefined + ? cs.homePageCycleDedicatedView === "ads" + ? "ads" + : "now-showing" + : DEFAULT_SETTINGS.homePageCycleDedicatedView; + } + this.enableViewHotkeys = toSettingsBoolStr( + jsonObject.enableViewHotkeys, + cs.enableViewHotkeys ?? DEFAULT_SETTINGS.enableViewHotkeys + ); if (jsonObject.enableOD) this.enableOD = jsonObject.enableOD; else this.enableOD = "false"; if (jsonObject.enableSonarr) this.enableSonarr = jsonObject.enableSonarr; @@ -441,6 +836,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 +1042,33 @@ 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; + } + 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/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..5d66ef7 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,246 @@ 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; +} + +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. @@ -29,6 +278,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 +354,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 +435,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 +474,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 +485,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 +565,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 +656,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 +884,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( @@ -489,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); @@ -663,7 +1232,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 +1536,481 @@ 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.tags = extractEmbyTags(md); + 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 +2024,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` + ); + } + 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); + } + } + 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 (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); + } } - all = Array.from(byId.values()); + 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 +2268,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 +2302,8 @@ class EmbyJellyfinBase { genres, recentlyAdded, contentRating, - syncProgress + syncProgress, + metadataOnlySync ); const od = await util.build_random_od_set( numberOnDemand, @@ -1150,7 +2344,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 +2362,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 +2392,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 +2578,7 @@ class EmbyJellyfinBase { genres, recentlyAdded, contentRatings, + false, false ); if (odRaw !== undefined) { @@ -1219,6 +2589,7 @@ class EmbyJellyfinBase { genres, 0, contentRatings, + false, false ) ); @@ -1229,6 +2600,7 @@ class EmbyJellyfinBase { genres, 0, contentRatings, + false, false ); } @@ -1239,6 +2611,7 @@ class EmbyJellyfinBase { genres, 0, contentRatings, + false, false ); } @@ -1252,7 +2625,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 +2647,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 +2696,9 @@ class EmbyJellyfinBase { const now = new Date(); console.log( now.toLocaleString() + - " [poster sync] " + + " [poster sync]" + + runTag + + " " + this.appName + " — " + odRaw.length + @@ -1303,29 +2708,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 +2759,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 +2804,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..30af55f 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 ( @@ -123,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 || []; @@ -274,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); @@ -447,6 +505,8 @@ class Kodi { "file", "art", "dateadded", + "imdbnumber", + "uniqueid", ]; const lim = limits || { start: 0, end: 20000 }; try { @@ -485,6 +545,8 @@ class Kodi { "art", "studio", "dateadded", + "imdbnumber", + "uniqueid", ]; const lim = limits || { start: 0, end: 20000 }; try { @@ -687,7 +749,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 +866,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 +926,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 +952,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 +1011,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 +1030,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 +1084,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 +1103,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 +1148,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 @@ -969,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; @@ -982,7 +1191,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 +1227,14 @@ class Kodi { ); } } + try { + checkPosterSyncAborted(opts, posterSyncFull, sp); + } catch (e) { + if (e instanceof PosterSyncAbortedError) { + break syncBatches; + } + throw e; + } } } @@ -1031,7 +1248,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..798f995 100644 --- a/classes/mediaservers/plex.js +++ b/classes/mediaservers/plex.js @@ -3,12 +3,95 @@ 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 */ + } +} + +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 @@ -477,9 +560,18 @@ 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); + { + 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 +675,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 +787,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 +848,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 +874,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 +952,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 +979,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 +1039,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 +1066,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 +1173,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 +1196,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 +1209,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 || ""; @@ -1067,9 +1326,18 @@ 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); + { + 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 +1350,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 +1387,15 @@ class Plex { } } + checkPosterSyncAborted(opts, posterSyncFull, sp); + }, undefined); + } catch (e) { + if (e instanceof PosterSyncAbortedError) { + break; + } + throw e; + } } } let now = new Date(); @@ -1132,7 +1408,12 @@ class Plex { odCards.length + " item(s) from (" + onDemandLibraries + - ")" + ")" + + (opts && + typeof opts.posterSyncAbortCheck === "function" && + opts.posterSyncAbortCheck() + ? " (aborted)" + : "") ); } else { console.log( diff --git a/config/ads-view/bg-1776293542894-wr1kfl.jpg b/config/ads-view/bg-1776293542894-wr1kfl.jpg new file mode 100644 index 0000000..54e0115 Binary files /dev/null and b/config/ads-view/bg-1776293542894-wr1kfl.jpg differ diff --git a/config/ads.db b/config/ads.db new file mode 100644 index 0000000..1e0165c Binary files /dev/null and b/config/ads.db differ diff --git a/config/.keep b/config/ads/.keep similarity index 100% rename from config/.keep rename to config/ads/.keep diff --git a/config/cache/.keep b/config/custom-pictures/.keep similarity index 100% rename from config/cache/.keep rename to config/custom-pictures/.keep diff --git a/config/holidays.db b/config/holidays.db new file mode 100644 index 0000000..cacf69a Binary files /dev/null and b/config/holidays.db differ diff --git a/config/now-showing.db b/config/now-showing.db new file mode 100644 index 0000000..90e2f36 Binary files /dev/null and b/config/now-showing.db differ diff --git a/config/posterr-app.lock b/config/posterr-app.lock new file mode 100644 index 0000000..45799e2 --- /dev/null +++ b/config/posterr-app.lock @@ -0,0 +1 @@ +{"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 new file mode 100644 index 0000000..4b2c0e7 Binary files /dev/null and b/config/posterr-poster-metadata.db differ diff --git a/consts.js b/consts.js index 5b08c14..2377296 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,46 @@ 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, + /** When home page cycle runs: embed this dedicated view in an iframe (URL stays on `/`). */ + homePageCycleDedicatedView: "now-showing", + /** Enable global view-switch hotkeys: 1=posters, 2=ads, 3=now-showing. */ + enableViewHotkeys: "true", enableOD: "true", enableSonarr: "true", enableRadarr: "true", @@ -73,6 +115,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 +164,12 @@ 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: "", + /** 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/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..ffdeccb 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,19 @@ 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 holidaysDb = require("./classes/core/holidaysDb"); 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 +83,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 +110,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 +410,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 +423,88 @@ 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); + // 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; + 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 +535,21 @@ if (!fs.existsSync(dir)) { fs.mkdirSync(dir); } -var dir = './public/custom'; +var dir = "./public/custom"; if (!fs.existsSync(dir)) { - fs.mkdirSync(dir); + fs.mkdirSync(dir, { recursive: true }); } - -var dir = './public/custom/pictures'; - -if (!fs.existsSync(dir)) { - fs.mkdirSync(dir); +const customCssPath = path.join(dir, "custom.css"); +if (!fs.existsSync(customCssPath)) { + fs.writeFileSync(customCssPath, ""); } -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 +894,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 +918,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 +953,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; @@ -610,6 +1403,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 @@ -618,26 +1485,46 @@ 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 excludeLibraries; - if(loadedSettings.excludeLibs !== undefined && loadedSettings.excludeLibs !== ""){ - excludeLibraries = loadedSettings.excludeLibs.split(","); - - // trim leading and trailing spaces + 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 !== ""){ + excludeLibraries = loadedSettings.excludeLibs.split(","); + + // trim leading and trailing spaces excludeLibraries = excludeLibraries.map(function (el) { return el.trim(); }); @@ -646,18 +1533,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 +1680,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; + // 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; } - } 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; } 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 +1732,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 +1877,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 +2000,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 +2128,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 +2139,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 +2282,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 +2402,7 @@ async function checkEnabled() { `): ` + isMediaServerEnabled + ` - Now Showing: ` + + Now Playing (media server): ` + isNowShowingEnabled + ` Awtrix: ` + @@ -1492,6 +2516,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 +2529,8 @@ const posterSyncProgressState = { libraries: [], syncScope: "all", syncSingleLibrary: "", + metadataOnlySync: false, + runId: "", serverKind: "", error: "", startedAt: null, @@ -1523,6 +2552,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 +2568,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 +2591,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 +2609,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 +2620,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 +2630,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 +2775,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 +2889,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 +3028,7 @@ async function startup(clearCache) { clearInterval(posterMetadataRefreshClock); picCards = []; + adSlideCards = []; odCards = []; nsCards = []; csCards = []; @@ -1811,6 +3048,11 @@ async function startup(clearCache) { // load settings object loadedSettings = await Promise.resolve(await loadSettings()); await posterMetadata.initPosterMetadataDb(); + await nowShowingDb.initNowShowingDb(); + await adsDb.initAdsDb(); + await ensureHolidaysDbReady(); + holidaysDb.migrateLegacyRulesFromJson(loadedSettings && loadedSettings.holidayRules); + await migrateLegacyGlobalAdsPriceAddOnOnce(); startHttpServerOnce(); if (loadedSettings == 'undefined') { console.load('settings not loaded!!'); @@ -1839,7 +3081,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 +3095,12 @@ 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(); + // Awtrix initialize - if enabled if(isAwtrixEnabled){ var awt = new awtrix(); @@ -1884,8 +3134,6 @@ async function startup(clearCache) { } } - await loadNowScreening(); - syncFullPosterLibraryFromMediaServer().catch(() => {}); // let now = new Date(); @@ -2016,6 +3264,7 @@ async function saveReset(formObject) { csrCards = []; csrCards = []; picCards = []; + adSlideCards = []; cslCards = []; csbCards = []; trivCards = []; @@ -2073,6 +3322,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 +3334,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')); @@ -2090,103 +3345,1232 @@ 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 - // } - // catch (ex) { - // console.log('res.render:' + __dirname); - // res.render(path.join(__dirname, 'myviews', 'index'), { globals: globalPage, hasConfig: setng.GetChanged(), baseUrl: BASEURL, custBrand: globalPage.custBrand, hasArt: globalPage.hasArt, quizTime: globalPage.quizTime }); // index refers to index.ejs - // } -}); +function renderPostersHome(req, res) { + const homeCycleDedicatedView = + loadedSettings && loadedSettings.homePageCycleDedicatedView === "ads" + ? "ads" + : "now-showing"; + const nowShowingListOnHomeEnabled = + loadedSettings && + String(loadedSettings.enableNowShowingListInPoster).toLowerCase() === + "true"; + const nowShowingHomeCycleEnabled = + loadedSettings && + String(loadedSettings.enableNowShowingPageCycle).toLowerCase() === "true" && + (homeCycleDedicatedView === "ads" || nowShowingListOnHomeEnabled); + const viewHotkeysEnabled = + loadedSettings && + String(loadedSettings.enableViewHotkeys).toLowerCase() === "true"; + res.render("posters-home", { + globals: globalPage, + hasConfig: setng.GetChanged(), + baseUrl: BASEURL, + custBrand: globalPage.custBrand, + hasArt: globalPage.hasArt, + quizTime: globalPage.quizTime, + rotate: globalPage.rotate, + nowShowingPageCycleEnabled: nowShowingHomeCycleEnabled ? "true" : "false", + nowShowingPageCycleEveryMins: + loadedSettings && loadedSettings.nowShowingPageCycleEveryMins !== undefined + ? loadedSettings.nowShowingPageCycleEveryMins + : DEFAULT_SETTINGS.nowShowingPageCycleEveryMins, + nowShowingPageCycleStayMins: + loadedSettings && loadedSettings.nowShowingPageCycleStayMins !== undefined + ? loadedSettings.nowShowingPageCycleStayMins + : DEFAULT_SETTINGS.nowShowingPageCycleStayMins, + homePageCycleDedicatedView: homeCycleDedicatedView, + viewHotkeysEnabled: viewHotkeysEnabled ? "true" : "false", + ...newFeaturesBannerViewData(), + }); // index refers to index.ejs +} -app.get(BASEURL + "/getcards", (req, res) => { - res.send({ globalPage: globalPage, baseUrl: BASEURL }); // get generated cards +app.get(BASEURL + "/", (req, res) => { + const homeCycleDedicatedView = + loadedSettings && loadedSettings.homePageCycleDedicatedView === "ads" + ? "ads" + : "now-showing"; + const nowShowingListOnHomeEnabled = + loadedSettings && + String(loadedSettings.enableNowShowingListInPoster).toLowerCase() === + "true"; + const nowShowingHomeCycleEnabled = + loadedSettings && + String(loadedSettings.enableNowShowingPageCycle).toLowerCase() === "true" && + (homeCycleDedicatedView === "ads" || nowShowingListOnHomeEnabled); + 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 + : DEFAULT_SETTINGS.nowShowingPageCycleEveryMins; + const stayMins = + loadedSettings && loadedSettings.nowShowingPageCycleStayMins !== undefined + ? loadedSettings.nowShowingPageCycleStayMins + : DEFAULT_SETTINGS.nowShowingPageCycleStayMins; + res.render("index", { + baseUrl: BASEURL, + nowShowingPageCycleEnabled: nowShowingHomeCycleEnabled ? "true" : "false", + nowShowingPageCycleEveryMins: everyMins, + nowShowingPageCycleStayMins: stayMins, + homePageCycleDedicatedView: homeCycleDedicatedView, + homeNowShowingOnly: homeNowShowingOnly ? "true" : "false", + homeAdsOnly: homeAdsOnly ? "true" : "false", + viewHotkeysEnabled: viewHotkeysEnabled ? "true" : "false", + ...newFeaturesBannerViewData(), + }); }); -// 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 + "/posters", (req, res) => { + renderPostersHome(req, res); }); - -app.get(BASEURL + "/debug", (req, res) => { - res.render("debug", { settings: loadedSettings, version: pjson.version, baseUrl: BASEURL }); +app.get(BASEURL + "/getcards", (req, res) => { + const holidayRules = activeHolidayRulesForToday(); + const postersOnly = + String((req.query && req.query.postersOnly) || "").trim() === "1"; + if (!postersOnly) { + const cards = boostCardsByHolidayRules( + Array.isArray(globalPage && globalPage.cards) ? globalPage.cards : [], + holidayRules + ); + return res.send({ + globalPage: { + ...globalPage, + cards, + }, + baseUrl: BASEURL, + }); // get generated cards + } + const cards = Array.isArray(globalPage && globalPage.cards) + ? globalPage.cards.filter((card) => { + var ct = card && card.cardType; + var name = Array.isArray(ct) ? String(ct[0] || "") : String(ct || ""); + var n = name.toLowerCase(); + return n !== "ad" && n !== "now showing"; + }) + : []; + const boosted = boostCardsByHolidayRules(cards, holidayRules); + return res.send({ + globalPage: { + ...globalPage, + cards: boosted, + }, + 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 }); +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(), + 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, + viewHotkeysEnabled: + loadedSettings && + String(loadedSettings.enableViewHotkeys).toLowerCase() === "true" + ? "true" + : "false", + viewHotkeysContext: cycleEmbed ? "embed-now-showing" : "now-showing-page", + hideSettingsLinks: hideSettingsLinks ? "true" : "false", + ...newFeaturesBannerViewData(), + }); }); -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 + "/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 : ""; + 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(), + titleColour: loadedSettings.titleColour, + custBrand: loadedSettings.custBrand, + adsRotationSeconds, + adsPageStaySeconds, + adsGlobalBackgroundPath, + adsCurrencyCode: normalizeNowShowingCurrencyCode( + (loadedSettings && loadedSettings.adsCurrencyCode) || + DEFAULT_SETTINGS.adsCurrencyCode + ), + viewHotkeysEnabled: + loadedSettings && + String(loadedSettings.enableViewHotkeys).toLowerCase() === "true" + ? "true" + : "false", + viewHotkeysContext: adsCycleEmbed ? "embed-ads" : "ads-page", + hideSettingsLinks: hideSettingsLinks ? "true" : "false", + ...newFeaturesBannerViewData(), }); +}); + +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; } -async function debugMediaServerOd(req, res) { - const label = getMediaServerShortLabel( - loadedSettings && loadedSettings.mediaServerType +function normalizeNowShowingTitle(t) { + return String(t || "") + .toLowerCase() + .replace(/[^a-z0-9\s]/g, " ") + .replace(/\s+/g, " ") + .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 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 ( + String(dt.getMonth() + 1).padStart(2, "0") + + "-" + + String(dt.getDate()).padStart(2, "0") ); - 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); +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)); +} -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 normalizeHolidayMode(raw) { + return String(raw || "").trim().toLowerCase() === "dynamic" + ? "dynamic" + : "fixed"; +} -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 normalizeHolidayMatchMode(raw) { + return String(raw || "").trim().toLowerCase() === "and" ? "and" : "or"; +} -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 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)); + 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(); + 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); + 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, + titleKeywords, + plotKeywords, + matchMode, + }; + } + const start = normalizeHolidayMonthDay(r && r.start); + const end = normalizeHolidayMonthDay(r && r.end); + return { + mode: "fixed", + start, + end, + tag, + titleKeywords, + plotKeywords, + matchMode, + }; + }) + .filter((r) => { + 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); + }); +} + +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 activeHolidayRulesForToday() { + const now = new Date(); + const todayMd = + String(now.getMonth() + 1).padStart(2, "0") + + "-" + + String(now.getDate()).padStart(2, "0"); + const active = []; + 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.push(rule); + } + continue; + } + if (monthDayInHolidayRange(todayMd, rule.start, rule.end)) { + active.push(rule); + } + } + return active; +} + +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 tagHay = ( + 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(); + 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 nowShowingRowMatchesHolidayRule(row, rule) { + if (!row || !rule) return false; + const tagHay = ( + String(row.title || "") + + " " + + String(row.genres || "") + + " " + + String(row.topCast || "") + + " " + + String(row.studio || "") + ) + .toLowerCase() + .trim(); + const titleHay = String(row.title || "").toLowerCase().trim(); + const plotHay = String(row.overview || "").toLowerCase().trim(); + return ruleTextMatch(rule, tagHay, titleHay, plotHay); +} + +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; + for (const rule of activeRules) { + if (cardMatchesHolidayRule(card, rule)) { + matches = true; + break; + } + } + if (matches) boost.push(card); + } + if (!boost.length) return cards; + 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; + for (const rule of activeRules) { + if (nowShowingRowMatchesHolidayRule(row, rule)) { + matches = true; + break; + } + } + if (matches) boost.push({ ...row }); + } + if (!boost.length) return rows; + const out = rows.slice(); + for (let i = 0; i < HOLIDAY_BOOST_COPIES; i++) out.push(...boost); + return out; +} + +function normalizeLibraryNameFor3d(name) { + return String(name || "") + .toLowerCase() + .replace(/\s+/g, " ") + .trim(); +} + +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; +} + +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; +} + +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; +} + +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"; + } +} + +/** 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; +} + +/** 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); + movies = boostNowShowingRowsByHolidayRules( + movies, + activeHolidayRulesForToday() + ); + 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(' '); @@ -2218,9 +4602,10 @@ app.get(BASEURL + "/logon", (req, res) => { }); -function getDirectories(path) { - return fs.readdirSync(path).filter(function (file) { - return fs.statSync(path + '/' + file).isDirectory(); +function getDirectories(dirPath) { + if (!fs.existsSync(dirPath)) return []; + return fs.readdirSync(dirPath).filter(function (file) { + return fs.statSync(path.join(dirPath, file)).isDirectory(); }); } @@ -2295,7 +4680,8 @@ app.post( latestVersion: latestVersion, message: message, updateAvailable: updateAvailable, - cacheClearNotice: null + cacheClearNotice: null, + ...newFeaturesBannerViewData(), }); } } @@ -2304,7 +4690,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 +4707,8 @@ app.get(BASEURL + "/settings", (req, res) => { latestVersion: latestVersion, message: message, updateAvailable: updateAvailable, - cacheClearNotice: cacheClearNotice + cacheClearNotice: cacheClearNotice, + ...newFeaturesBannerViewData(), }); } else { @@ -2336,7 +4723,8 @@ app.get(BASEURL + "/settings", (req, res) => { latestVersion: latestVersion, message: message, updateAvailable: updateAvailable, - cacheClearNotice: cacheClearNotice + cacheClearNotice: cacheClearNotice, + ...newFeaturesBannerViewData(), }); } req.session.errors = null; @@ -2348,6 +4736,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.", @@ -2359,93 +4748,744 @@ app.post(BASEURL + "/settings/clear-poster-cache", async (req, res) => { text: "Could not clear poster cache: " + msg, }; } - res.redirect(302, BASEURL + "/settings"); + 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" }); + } + const st = posterSyncProgressState; + const total = Math.max(0, parseInt(st.total, 10) || 0); + const processed = Math.max(0, parseInt(st.processed, 10) || 0); + const libs = Array.isArray(st.libraries) ? st.libraries : []; + + function fetchRatioFromLibraries(rows) { + if (!rows.length) return 0; + let w = 0; + for (const r of rows) { + const fs = r.fetchStatus; + if (fs === "done" || fs === "skipped") w += 1; + else if (fs === "loading") w += 0.5; + } + return w / rows.length; + } + + let percent = null; + let indeterminate = + st.status === "running" && + st.phase === "starting" && + total === 0 && + libs.length === 0; + + if (st.status === "done") { + percent = 100; + } else if (st.status === "error") { + percent = null; + } else if (st.status === "running") { + if (st.phase === "registering") { + percent = 100; + } else if (st.phase === "fetching") { + 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) { + 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) { + percent = Math.min(100, Math.round((processed / total) * 100)); + } else { + percent = 0; + } + } + + const libraries = libs.map((r) => ({ + name: r.name, + fetchStatus: r.fetchStatus, + itemsFound: r.itemsFound != null ? r.itemsFound : 0, + cacheStatus: r.cacheStatus, + itemsCached: r.itemsCached != null ? r.itemsCached : 0, + cacheTotal: r.cacheTotal != null ? r.cacheTotal : 0, + })); + + res.json({ + status: st.status, + phase: st.phase, + label: st.label, + processed, + total, + percent, + indeterminate, + error: st.error || null, + serverKind: st.serverKind || null, + libraries, + syncScope: st.syncScope || "all", + syncSingleLibrary: st.syncSingleLibrary || "", + metadataOnlySync: st.metadataOnlySync === true, + runId: st.runId || "", + }); +}); + +app.get(BASEURL + "/settings/sync", (req, res) => { + customPicFolders = getDirectories(CUSTOM_PICTURES_ROOT); + const syncNotice = req.session.syncNotice || null; + req.session.syncNotice = null; + res.render("settings-sync", { + 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, + syncNotice: syncNotice, + onDemandLibraryList: getConfiguredOnDemandLibraryNames( + loadedSettings.onDemandLibraries + ), + ...newFeaturesBannerViewData(), + }); +}); + +app.post(BASEURL + "/settings/sync/trigger", (req, res) => { + if (loadedSettings.password !== undefined && !userData.valid) { + return res.redirect(302, BASEURL + "/logon"); + } + if (!isMediaServerEnabled) { + req.session.syncNotice = { + ok: false, + text: "Media server is not configured or cannot be reached. Check Settings → Media server.", + }; + return res.redirect(302, BASEURL + "/settings/sync"); + } + const libs = loadedSettings.onDemandLibraries; + if (!libs || !String(libs).trim()) { + req.session.syncNotice = { + ok: false, + text: "Configure on-demand library names in Settings before running a full sync.", + }; + return res.redirect(302, BASEURL + "/settings/sync"); + } + if (posterSyncProgressState.status === "running") { + req.session.syncNotice = { + ok: false, + text: "A library sync is already running. Wait for it to finish.", + }; + 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 = {}; + if (wantSingle) { + const names = getConfiguredOnDemandLibraryNames(libs); + const resolved = matchConfiguredLibraryName(bodyLib, names); + if (!resolved) { + req.session.syncNotice = { + ok: false, + text: + "That library is not in your configured on-demand list. Check spelling under Settings → Media server.", + }; + return res.redirect(302, BASEURL + "/settings/sync"); + } + syncOptions = { singleLibrary: resolved, metadataOnlySync: metadataOnly }; + } else { + syncOptions = { metadataOnlySync: metadataOnly }; + } + syncFullPosterLibraryFromMediaServer(syncOptions).catch((err) => { + const d = new Date(); + console.log( + d.toLocaleString() + + " Manual poster sync error: " + + (err && err.message ? err.message : err) + ); + }); + 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; + 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"); +}); + +app.post(BASEURL + "/settings/sync/clear-cache", async (req, res) => { + if (loadedSettings.password !== undefined && !userData.valid) { + return res.redirect(302, BASEURL + "/logon"); + } + if (posterSyncProgressState.status === "running") { + req.session.syncNotice = { + ok: false, + text: "Cannot clear sync cache while a library sync is running. Abort or wait for completion first.", + }; + return res.redirect(302, BASEURL + "/settings/sync"); + } + try { + await posterMetadata.clearPosterCacheAndMetadata(); + posterSyncRetry.clearRetryFile(); + req.session.syncNotice = { + ok: true, + text: "Sync cache cleared: removed cached files and reset the poster metadata database.", + }; + } catch (err) { + const msg = err && err.message ? err.message : String(err); + req.session.syncNotice = { + ok: false, + text: "Could not clear sync cache: " + msg, + }; + } + return 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" }); + } + try { + res.json(posterMetadata.getCacheDashboardStats()); + } catch (e) { + res + .status(500) + .json({ error: e && e.message ? e.message : String(e) }); + } +}); + +app.get(BASEURL + "/settings/cache", (req, res) => { + customPicFolders = getDirectories(CUSTOM_PICTURES_ROOT); + res.render("settings-cache", { + 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.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"); + } + function bodyScalar(body, name) { + if (!body || body[name] === undefined || body[name] === null) { + return undefined; + } + const v = body[name]; + if (Array.isArray(v)) { + return v.length ? v[v.length - 1] : undefined; + } + return v; + } + try { + loadedSettings.enableAds = req.body.enableAds ? "true" : "false"; + loadedSettings.adsOnly = req.body.adsOnly ? "true" : "false"; + loadedSettings.adsTitleOutline = req.body.adsTitleOutline + ? "true" + : "false"; + const adsEveryRaw = bodyScalar(req.body, "adsEveryPosters"); + const adsEvery = parseInt(String(adsEveryRaw ?? "").trim(), 10); + loadedSettings.adsEveryPosters = isNaN(adsEvery) ? 0 : Math.max(0, adsEvery); + loadedSettings.adsCurrencyCode = normalizeNowShowingCurrencyCode( + bodyScalar(req.body, "adsCurrencyCode") + ); + const rotRaw = bodyScalar(req.body, "adsRotationSeconds"); + const rotAds = parseInt(String(rotRaw ?? "").trim(), 10); + loadedSettings.adsRotationSeconds = isNaN(rotAds) + ? DEFAULT_SETTINGS.adsRotationSeconds + : Math.min(600, Math.max(3, rotAds)); + const stayRaw = bodyScalar(req.body, "adsPageStaySeconds"); + const stayAds = parseInt(String(stayRaw ?? "").trim(), 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/sync/progress", (req, res) => { +app.get(BASEURL + "/settings/now-showing/search", async (req, res) => { if (loadedSettings.password !== undefined && !userData.valid) { return res.status(401).json({ error: "unauthorized" }); } - const st = posterSyncProgressState; - const total = Math.max(0, parseInt(st.total, 10) || 0); - const processed = Math.max(0, parseInt(st.processed, 10) || 0); - const libs = Array.isArray(st.libraries) ? st.libraries : []; - - function fetchRatioFromLibraries(rows) { - if (!rows.length) return 0; - let w = 0; - for (const r of rows) { - const fs = r.fetchStatus; - if (fs === "done" || fs === "skipped") w += 1; - else if (fs === "loading") w += 0.5; - } - return w / rows.length; + 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) }); } +}); - let percent = null; - let indeterminate = - st.status === "running" && - st.phase === "starting" && - total === 0 && - libs.length === 0; - - if (st.status === "done") { - percent = 100; - } else if (st.status === "error") { - percent = null; - } else if (st.status === "running") { - if (st.phase === "registering") { - percent = 100; - } else if (st.phase === "fetching") { - percent = 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) - ); - } else if (st.phase === "complete") { - percent = 100; - } else if (total > 0) { - percent = Math.min(100, Math.round((processed / total) * 100)); - } else { - percent = 0; - } +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) }); + } +}); - const libraries = libs.map((r) => ({ - name: r.name, - fetchStatus: r.fetchStatus, - itemsFound: r.itemsFound != null ? r.itemsFound : 0, - cacheStatus: r.cacheStatus, - itemsCached: r.itemsCached != null ? r.itemsCached : 0, - cacheTotal: r.cacheTotal != null ? r.cacheTotal : 0, - })); - - res.json({ - status: st.status, - phase: st.phase, - label: st.label, - processed, - total, - percent, - indeterminate, - error: st.error || null, - serverKind: st.serverKind || null, - libraries, - syncScope: st.syncScope || "all", - syncSingleLibrary: st.syncSingleLibrary || "", +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.get(BASEURL + "/settings/sync", (req, res) => { - customPicFolders = getDirectories("./public/custom/pictures"); - const syncNotice = req.session.syncNotice || null; - req.session.syncNotice = null; - res.render("settings-sync", { +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: loadedSettings.password === undefined ? { valid: true } : userData, @@ -2456,96 +5496,389 @@ app.get(BASEURL + "/settings/sync", (req, res) => { latestVersion: latestVersion, message: message, updateAvailable: updateAvailable, - syncNotice: syncNotice, - onDemandLibraryList: getConfiguredOnDemandLibraryNames( - loadedSettings.onDemandLibraries - ), + holidayRules, + holidayNotice, + ...newFeaturesBannerViewData(), }); }); -app.post(BASEURL + "/settings/sync/trigger", (req, res) => { +app.post(BASEURL + "/settings/holidays", async (req, res) => { if (loadedSettings.password !== undefined && !userData.valid) { return res.redirect(302, BASEURL + "/logon"); } - if (!isMediaServerEnabled) { - req.session.syncNotice = { - ok: false, - text: "Media server is not configured or cannot be reached. Check Settings → Media server.", + 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 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 + ? [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, + titleKeywordsRaw.length, + plotKeywordsRaw.length, + matchModesRaw.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 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); + 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 && + 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() && + !String(nthsRaw[i] || "").trim() && + !String(spansRaw[i] || "").trim() + ) { + continue; + } + 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({ + mode: "dynamic", + month, + weekday, + nth, + spanDays, + tag: tag.slice(0, 80), + titleKeywords: titleKeywords.join(", ").slice(0, 240), + plotKeywords: plotKeywords.join(", ").slice(0, 240), + matchMode, + }); + 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), + titleKeywords: titleKeywords.join(", ").slice(0, 240), + plotKeywords: plotKeywords.join(", ").slice(0, 240), + matchMode, + }); + } + holidaysDb.replaceRules(out); + req.session.holidayNotice = { + ok: true, + text: "Holiday date-tag rules saved.", }; - return res.redirect(302, BASEURL + "/settings/sync"); - } - const libs = loadedSettings.onDemandLibraries; - if (!libs || !String(libs).trim()) { - req.session.syncNotice = { + } catch (e) { + req.session.holidayNotice = { ok: false, - text: "Configure on-demand library names in Settings before running a full sync.", + text: "Could not save holiday rules: " + (e && e.message ? e.message : String(e)), }; - return res.redirect(302, BASEURL + "/settings/sync"); } - if (posterSyncProgressState.status === "running") { - req.session.syncNotice = { + 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"); + } + 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: "A library sync is already running. Wait for it to finish.", + text: "Could not save TMDB API key: " + (e && e.message ? e.message : e), }; - return res.redirect(302, BASEURL + "/settings/sync"); } - const bodyLib = req.body && req.body.library; - const wantSingle = - bodyLib != null && String(bodyLib).trim() !== ""; - let syncOptions = {}; - if (wantSingle) { - const names = getConfiguredOnDemandLibraryNames(libs); - const resolved = matchConfiguredLibraryName(bodyLib, names); - if (!resolved) { - req.session.syncNotice = { - ok: false, - text: - "That library is not in your configured on-demand list. Check spelling under Settings → Media server.", - }; - return res.redirect(302, BASEURL + "/settings/sync"); - } - syncOptions = { singleLibrary: resolved }; + 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) }); } - syncFullPosterLibraryFromMediaServer(syncOptions).catch((err) => { - const d = new Date(); - console.log( - d.toLocaleString() + - " Manual poster sync error: " + - (err && err.message ? err.message : err) - ); - }); - res.redirect(302, BASEURL + "/settings/sync"); }); -app.get(BASEURL + "/settings/cache/stats", (req, res) => { +app.post(BASEURL + "/settings/now-showing/delete/:id", (req, res) => { if (loadedSettings.password !== undefined && !userData.valid) { return res.status(401).json({ error: "unauthorized" }); } try { - res.json(posterMetadata.getCacheDashboardStats()); + nowShowingDb.deleteMovie(req.params.id); + res.json({ ok: true }); } catch (e) { - res - .status(500) - .json({ error: e && e.message ? e.message : String(e) }); + res.status(400).json({ error: e && e.message ? e.message : String(e) }); } }); -app.get(BASEURL + "/settings/cache", (req, res) => { - customPicFolders = getDirectories("./public/custom/pictures"); - res.render("settings-cache", { - 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, - }); +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)); + const dedicatedRaw = String(req.body.homePageCycleDedicatedView || "").trim(); + loadedSettings.homePageCycleDedicatedView = + dedicatedRaw === "ads" ? "ads" : "now-showing"; + 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 +6041,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 +6053,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 +6078,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 +6104,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 +6243,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 +6263,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..806806a --- /dev/null +++ b/myviews/ads.ejs @@ -0,0 +1,570 @@ + + + + + + + Posterr - Ads + + + + + + + <%- include('new-features-banner.ejs') %> +
+ +
+
+ + <%- include('posterr-view-hotkeys.ejs', { baseUrl, viewHotkeysContext, viewHotkeysEnabled }) %> + + 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..a519147 100644 --- a/myviews/index.ejs +++ b/myviews/index.ejs @@ -1,3 +1,183 @@ -<%- include('header.ejs'), {baseUrl, globals} %> -<%- include('posters/index.ejs', {globals, hasConfig, baseUrl}) %> -<%- include('footer.ejs') %> + + + + + + + Posterr + + + + +
+ + + +
+ + + 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..1ac0b3b --- /dev/null +++ b/myviews/now-showing.ejs @@ -0,0 +1,1642 @@ + + + + + + + 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

+
+ +
+ +
+ +
+ +
+ + + <%- include('posterr-view-hotkeys.ejs', { baseUrl, viewHotkeysContext, viewHotkeysEnabled }) %> + + diff --git a/myviews/posterr-view-hotkeys.ejs b/myviews/posterr-view-hotkeys.ejs new file mode 100644 index 0000000..de13d02 --- /dev/null +++ b/myviews/posterr-view-hotkeys.ejs @@ -0,0 +1,54 @@ +<% var _vhkCtx = typeof viewHotkeysContext !== "undefined" ? viewHotkeysContext : "now-showing-page"; %> +<% var _vhkEnabled = typeof viewHotkeysEnabled !== "undefined" ? String(viewHotkeysEnabled) === "true" : true; %> + + diff --git a/myviews/posters-home.ejs b/myviews/posters-home.ejs new file mode 100644 index 0000000..3b72244 --- /dev/null +++ b/myviews/posters-home.ejs @@ -0,0 +1,12 @@ +<%- 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', + viewHotkeysEnabled: typeof viewHotkeysEnabled !== 'undefined' ? viewHotkeysEnabled : 'true' +}) %> +<%- include('footer.ejs') %> diff --git a/myviews/posters/posters.ejs b/myviews/posters/posters.ejs index 2398dfd..21e2266 100644 --- a/myviews/posters/posters.ejs +++ b/myviews/posters/posters.ejs @@ -14,6 +14,39 @@ + + + + + + + + + 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..2b8ea5b 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,18 @@ Sync + + Now Showing + + + Ads + + + Holidays + + + TMDB API + Cache @@ -82,6 +105,10 @@
+
@@ -404,6 +449,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
@@ -597,10 +650,10 @@
- + <%=settings.nowScreening%>"<%}%>> - Alternate text for 'Now Screening' + Alternate text for 'Now Playing'
@@ -731,7 +784,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 +962,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 +994,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 +1465,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,12 +1496,14 @@

diff --git a/package-lock.json b/package-lock.json index dbc8c10..2656604 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "posterr", - "version": "1.21.11", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "posterr", - "version": "1.21.11", + "version": "2.0.0", "license": "MIT", "dependencies": { "@jaames/iro": "^5.5.2", @@ -25,6 +25,7 @@ "image-size": "^0.9.7", "jquery": "^3.6.0", "memorystore": "^1.6.6", + "multer": "^2.1.1", "ping": "^0.4.1", "plex-api": "^5.3.1", "sleep-promise": "^9.1.0", @@ -1422,6 +1423,12 @@ "node": ">= 8" } }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -1785,9 +1792,19 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, "license": "MIT" }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -2161,6 +2178,21 @@ "dev": true, "license": "MIT" }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, "node_modules/config-chain": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", @@ -4895,6 +4927,25 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/multer": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz", + "integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -5596,6 +5647,20 @@ "dev": true, "license": "MIT" }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -6113,6 +6178,23 @@ "node": ">=0.10.0" } }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", @@ -6480,6 +6562,12 @@ "node": ">= 0.6" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, "node_modules/uc.micro": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", @@ -6591,6 +6679,12 @@ "punycode": "^2.1.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", diff --git a/package.json b/package.json index 81583da..913f385 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "posterr", - "version": "1.21.11", + "version": "2.0.0", "description": "A digital display for your media", "main": "index.js", "bin": "index.js", @@ -51,6 +51,7 @@ "image-size": "^0.9.7", "jquery": "^3.6.0", "memorystore": "^1.6.6", + "multer": "^2.1.1", "ping": "^0.4.1", "plex-api": "^5.3.1", "sleep-promise": "^9.1.0", diff --git a/public/css/styles.css b/public/css/styles.css index 8d716ed..8fdba99 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -925,3 +925,122 @@ a.alert-link { transform: translateX(-50%); } +/* Ad slides on main poster: same pill look as dedicated /ads page */ +.bottomBanner .tagDetails .ad-poster-pricing-row { + display: flex; + flex-direction: row; + align-items: flex-end; + justify-content: space-between; + flex-wrap: wrap; + gap: 0.35rem 0.75rem; + width: 100%; +} +.bottomBanner .tagDetails .ad-poster-pricing-row--addons-only { + justify-content: flex-end; +} +.bottomBanner .tagDetails .ad-poster-addon-section { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0.2rem; + flex: 0 1 auto; + max-width: min(46%, 14rem); + text-align: right; +} +/* Mirror main slide title (.bannerBigText.Ad); undo absolute centering for footer strip */ +.bottomBanner .tagDetails .ad-poster-addon-heading-mirror.bannerBigText { + position: static; + transform: none; + left: auto; + top: auto; + display: block; + width: 100%; + white-space: normal; + text-align: right; + font-size: clamp(0.55rem, 1.65vmin, 0.95rem); + line-height: 1.15; + margin: 0 0 0.12rem 0; +} +.bottomBanner .tagDetails .ad-poster-addon-heading--outline { + paint-order: stroke fill; + -webkit-text-stroke: 1.5px rgba(0, 0, 0, 0.9); + text-shadow: + 0 0 2px rgba(0, 0, 0, 1), + -1px -1px 0 rgba(0, 0, 0, 0.85), + 1px -1px 0 rgba(0, 0, 0, 0.85), + -1px 1px 0 rgba(0, 0, 0, 0.85), + 1px 1px 0 rgba(0, 0, 0, 0.85); +} +.bottomBanner .tagDetails .ad-poster-pills { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.35rem; + text-shadow: none; +} +.bottomBanner .tagDetails .ad-poster-pills--prices { + justify-content: flex-start; + flex: 1 1 auto; + min-width: 0; +} +.bottomBanner .tagDetails .ad-poster-pills--addons { + justify-content: flex-end; +} +.bottomBanner .tagDetails .ad-price-pill { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.22rem 0.55rem; + border-radius: 999px; + background: rgba(13, 110, 253, 0.35); + border: 1px solid rgba(13, 110, 253, 0.65); + font-size: clamp(0.65rem, 1.6vmin, 0.95rem); + font-weight: 600; + color: #fff; + line-height: 1.2; +} +.bottomBanner .tagDetails .ad-price-pill--c1 { + background: rgba(13, 110, 253, 0.38); + border-color: rgba(13, 110, 253, 0.72); +} +.bottomBanner .tagDetails .ad-price-pill--c2 { + background: rgba(40, 167, 69, 0.36); + border-color: rgba(40, 167, 69, 0.68); +} +.bottomBanner .tagDetails .ad-price-pill--c3 { + background: rgba(255, 193, 7, 0.4); + border-color: rgba(255, 193, 7, 0.75); + color: #1a1a1a; +} +.bottomBanner .tagDetails .ad-price-pill--c4 { + background: rgba(220, 53, 69, 0.36); + border-color: rgba(220, 53, 69, 0.7); +} +.bottomBanner .tagDetails .ad-price-pill--c5 { + background: rgba(111, 66, 193, 0.38); + border-color: rgba(111, 66, 193, 0.72); +} +.bottomBanner .tagDetails .ad-price-pill--c6 { + background: rgba(23, 162, 184, 0.36); + border-color: rgba(23, 162, 184, 0.7); +} +.bottomBanner .tagDetails .ad-price-pill__title { + opacity: 0.95; + font-weight: 600; +} +.bottomBanner .tagDetails .ad-price-pill__value { + font-weight: 700; + display: inline-flex; + align-items: baseline; + flex-wrap: wrap; + gap: 0.12em 0.28em; +} +.bottomBanner .tagDetails .ad-price-pill__plus { + opacity: 0.9; + margin-right: 0.06em; + font-weight: 800; +} +.bottomBanner .tagDetails .ad-price-pill__base { + font-weight: 700; +} + diff --git a/test/posterMetadataDb.init.test.js b/test/posterMetadataDb.init.test.js index 3f70a32..c124b18 100644 --- a/test/posterMetadataDb.init.test.js +++ b/test/posterMetadataDb.init.test.js @@ -25,7 +25,7 @@ describe("posterMetadataDb SQLite", () => { test("init creates db file and returns empty stats", async () => { const posterMetadata = require("../classes/core/posterMetadataDb"); await posterMetadata.initPosterMetadataDb(); - const dbPath = path.join(tmp, "config", "cache", "posterr-poster-metadata.db"); + const dbPath = path.join(tmp, "config", "posterr-poster-metadata.db"); expect(fs.existsSync(dbPath)).toBe(true); const stats = posterMetadata.getCacheDashboardStats(); expect(stats.posterDb.rowCount).toBe(0); @@ -74,4 +74,47 @@ describe("posterMetadataDb SQLite", () => { ) ).toBe(true); }); + + test("poster row counts as valid when file exists only under saved/imagecache", async () => { + const legacyDir = path.join(tmp, "saved"); + fs.mkdirSync(legacyDir, { recursive: true }); + const legacyDoc = { + v: 1, + entries: [ + { + cacheFile: "legacy-only.jpg", + title: "Legacy Path Movie", + tagLine: "", + year: "2021", + mediaType: "movie", + summary: "", + serverKind: "plex", + posterAR: "", + dbid: "y", + apiItemId: "2", + libraryKind: "", + libraryName: "Movies", + sourceUrl: "http://example/p2.jpg", + updatedAt: new Date().toISOString(), + }, + ], + }; + fs.writeFileSync( + path.join(legacyDir, "posterr-poster-metadata.json"), + JSON.stringify(legacyDoc), + "utf8" + ); + fs.mkdirSync(path.join(legacyDir, "imagecache"), { recursive: true }); + fs.writeFileSync( + path.join(legacyDir, "imagecache", "legacy-only.jpg"), + Buffer.alloc(400, 2) + ); + + const posterMetadata = require("../classes/core/posterMetadataDb"); + await posterMetadata.initPosterMetadataDb(); + const stats = posterMetadata.getCacheDashboardStats(); + expect(stats.posterDb.rowCount).toBe(1); + expect(stats.posterDb.rowsWithValidFile).toBe(1); + expect(stats.posterDb.rowsMissingFile).toBe(0); + }); });