diff --git a/.github/workflows/description_update.yml b/.github/workflows/description_update.yml index b678ca4..5323d5d 100644 --- a/.github/workflows/description_update.yml +++ b/.github/workflows/description_update.yml @@ -1,38 +1,38 @@ -name: Build Docker image and then push to DockerHub -on: - push: - branches: [ master ] - pull_request: - branches: [ master ] - - # Allows you to run this workflow manually from the Actions tab - workflow_dispatch: - -jobs: - build-container: - name: Build container - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v2 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - name: Login to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_HUB_TOKEN }} - - name: Build and push Docker image - uses: docker/build-push-action@v5 - with: - context: . - push: true - tags: | - binarygeek119/posterrx:latest - binarygeek119/posterrx:${{ 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. +name: Build Docker image and then push to DockerHub +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +jobs: + build-container: + name: Build container + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v2 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_HUB_TOKEN }} + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: | + binarygeek119/screenstage:latest + binarygeek119/screenstage:${{ 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/.github/workflows/testing.yml b/.github/workflows/testing.yml index 3a50750..3fc5c54 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -1,33 +1,33 @@ -name: Build Docker image and then push to DockerHub -on: - push: - branches: [ testing, Testing ] - pull_request: - branches: [ testing, Testing ] - - # Allows you to run this workflow manually from the Actions tab - workflow_dispatch: - -jobs: - build-container: - name: Build container - runs-on: ubuntu-latest - steps: - - name: Checkout code - 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 }} - password: ${{ secrets.DOCKER_HUB_TOKEN }} - - name: Build and push Docker image - uses: docker/build-push-action@v5 - with: - context: . - push: ${{ github.event_name != 'pull_request' }} - tags: | - binarygeek119/posterrx:testing - binarygeek119/posterrx:${{ github.run_number }} +name: Build Docker image and then push to DockerHub +on: + push: + branches: [ testing, Testing ] + pull_request: + branches: [ testing, Testing ] + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +jobs: + build-container: + name: Build container + runs-on: ubuntu-latest + steps: + - name: Checkout code + 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 }} + password: ${{ secrets.DOCKER_HUB_TOKEN }} + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + push: ${{ github.event_name != 'pull_request' }} + tags: | + binarygeek119/screenstage:testing + binarygeek119/screenstage:${{ github.run_number }} diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..ca5c350 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24.18.0 diff --git a/Dockerfile b/Dockerfile index 595061f..d5316a7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,7 +6,7 @@ RUN apk add net-tools ENV NODE_ENV=production -# PosterX talks to Plex, Jellyfin, Emby, and Kodi over HTTP(S) from settings (no extra image packages). +# ScreenStage talks to Plex, Jellyfin, Emby, and Kodi over HTTP(S) from settings (no extra image packages). # In Docker, set the media server "host" to a container name on a shared network, or host.docker.internal # (see docker-compose.yml extra_hosts and docker-compose.media-servers.example.yml). # diff --git a/README.md b/README.md index 3b3ef40..eedf216 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,26 @@ -# PosterX +# ScreenStage ## Media display software for Plex, Jellyfin, Emby, Kodi, Sonarr, Radarr, and Readarr. (Fork of [Posterr](https://github.com/petersem/posterr) — theatre-foyer style displays for your library.) > **About this repository** -> **PosterX** 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/posterrX](https://github.com/binarygeek119/posterrX) · **Docker image:** [`binarygeek119/posterrx`](https://hub.docker.com/r/binarygeek119/posterrx) (`:latest` / `:testing` from CI) -> **Sister project:** [binarygeek119/ubuntudisplayos](https://github.com/binarygeek119/ubuntudisplayos) for Ubuntu-based multi-display kiosk hosts that pair well with PosterX. - -![GitHub stars](https://img.shields.io/github/stars/binarygeek119/posterrX?style=flat) -![Fork version](https://img.shields.io/github/package-json/v/binarygeek119/posterrX?label=version&logoColor=blue) -![Last commit](https://img.shields.io/github/last-commit/binarygeek119/posterrX) -![Docker Pulls](https://img.shields.io/docker/pulls/binarygeek119/posterrx) -![Docker image size](https://img.shields.io/docker/image-size/binarygeek119/posterrx/latest?logo=docker) +> **ScreenStage** 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/ScreenStage](https://github.com/binarygeek119/ScreenStage) · **Docker image:** [`binarygeek119/screenstage`](https://hub.docker.com/r/binarygeek119/screenstage) (`:latest` / `:testing` from CI) +> **Sister project:** [binarygeek119/ubuntudisplayos](https://github.com/binarygeek119/ubuntudisplayos) for Ubuntu-based multi-display kiosk hosts that pair well with ScreenStage. + +![GitHub stars](https://img.shields.io/github/stars/binarygeek119/ScreenStage?style=flat) +![Fork version](https://img.shields.io/github/package-json/v/binarygeek119/ScreenStage?label=version&logoColor=blue) +![Last commit](https://img.shields.io/github/last-commit/binarygeek119/ScreenStage) +![Docker Pulls](https://img.shields.io/docker/pulls/binarygeek119/screenstage) +![Docker image size](https://img.shields.io/docker/image-size/binarygeek119/screenstage/latest?logo=docker) ![Platforms](https://img.shields.io/badge/platform-docker-blue) [![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) -**PosterX maintainer Discord:** [https://discord.gg/AEhVjqX4Af](https://discord.gg/AEhVjqX4Af) — updates and limited support for this fork. +**ScreenStage maintainer Discord:** [https://discord.gg/AEhVjqX4Af](https://discord.gg/AEhVjqX4Af) — updates and limited support for this fork. Original Posterr community Discord: [https://discord.gg/TcnEkMEf9J](https://discord.gg/TcnEkMEf9J). -Please do not ask the original Posterr developer for support when using PosterX. -For fork support, use GitHub: [https://github.com/binarygeek119/posterrX](https://github.com/binarygeek119/posterrX). +Please do not ask the original Posterr developer for support when using ScreenStage. +For fork support, use GitHub: [https://github.com/binarygeek119/ScreenStage](https://github.com/binarygeek119/ScreenStage). **Default settings password:** `raidisnotabackup` --- @@ -43,9 +43,9 @@ For fork support, use GitHub: [https://github.com/binarygeek119/posterrX](https: - 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 portrait rotation) - - Post API (at '/api/sleep') to toggle sleep mode. (Pass in header values `'psw: your PosterX password'` and `'sleep: true|false'`) + - Post API (at '/api/sleep') to toggle sleep mode. (Pass in header values `'psw: your ScreenStage password'` and `'sleep: true|false'`) - Get API at the same endpoint will return the sleep status without any parsed parameters. - - Supports `CEC` control of a monitor together with the PosterX sleep timer. + - Supports `CEC` control of a monitor together with the ScreenStage 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) @@ -54,7 +54,7 @@ These are additions on top of upstream behaviour (current fork **`package.json`* | 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** | Identifies this app as **PosterX** (a **binarygeek119** fork of Posterr), **purely AI-modified**, with a link to **[github.com/binarygeek119/posterrX](https://github.com/binarygeek119/posterrX)**. Original author credits and upstream links stay in the same tab. | +| **Settings → About** | Identifies this app as **ScreenStage** (a **binarygeek119** fork of Posterr), **purely AI-modified**, with a link to **[github.com/binarygeek119/ScreenStage](https://github.com/binarygeek119/ScreenStage)**. 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/**`). | @@ -79,7 +79,7 @@ These are additions on top of upstream behaviour (current fork **`package.json`* - **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 PosterX (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). +Community-sourced, open-use custom poster images for ScreenStage (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 @@ -97,21 +97,21 @@ Community-sourced, open-use custom poster images for PosterX (and similar apps) Installation details are as follows: ### Docker Compose (X86, ARM64) Create the following directories in your docker folder: - - ./docker/posterr - - ./docker/posterr/config - - ./docker/posterr/custom + - ./docker/screenstage + - ./docker/screenstage/config + - ./docker/screenstage/custom ```yaml services: - posterr: - image: binarygeek119/posterrx:latest - container_name: posterr + screenstage: + image: binarygeek119/screenstage:latest + container_name: screenstage environment: TZ: Australia/Brisbane BASEPATH: "" volumes: - - ./docker/posterr/config:/usr/src/app/config - - ./docker/posterr/custom:/usr/src/app/public/custom + - ./docker/screenstage/config:/usr/src/app/config + - ./docker/screenstage/custom:/usr/src/app/public/custom ports: - 9876:3000 restart: unless-stopped @@ -126,7 +126,7 @@ The **`config`** volume is required not only for **`settings.json`**, but also f | Path (inside container) | Purpose | |-------------------------|---------| -| `config/settings.json` | PosterX settings | +| `config/settings.json` | ScreenStage settings | | `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 | @@ -135,14 +135,14 @@ The **`config`** volume is required not only for **`settings.json`**, but also f | `config/cache/mp3cache/` | Cached TV/movie theme MP3s | | `config/cache/randomthemes/` | Optional random theme storage | -Mount **one host folder** to **`/usr/src/app/config`** (as in the Compose example). PosterX creates `cache/` and subfolders on first use. If this path is not persisted on the host, **settings, poster sync, and cached artwork are lost** when the container is recreated. +Mount **one host folder** to **`/usr/src/app/config`** (as in the Compose example). ScreenStage creates `cache/` and subfolders on first use. If this path is not persisted on the host, **settings, poster sync, and cached artwork are lost** when the container is recreated. **Older setups** used a separate **`saved`** volume. That layout is deprecated: on the host directory you mount as **`config`**, create **`cache/`** and move in the old **`saved/imagecache`**, **`saved/mp3cache`**, and **`saved/posterr-poster-metadata.db`** (so inside the container they appear as **`config/cache/...`**). #### Media servers (Plex, Jellyfin, Emby, Kodi) in Docker -PosterX only needs **outbound HTTP(S)** to your server — no extra packages in the image. +ScreenStage only needs **outbound HTTP(S)** to your server — no extra packages in the image. -| Where the server runs | What to enter as **host** in PosterX settings | +| Where the server runs | What to enter as **host** in ScreenStage settings | |----------------------|-----------------------------------------------| | **Another container** on the same Compose network | The **service name** (e.g. `jellyfin`, `emby`) and that service’s port (often `8096`). | | **Same machine as Docker, outside containers** (typical Kodi / bare-metal Plex) | `host.docker.internal` (with `extra_hosts` as above on **Linux**; Docker Desktop often works without it). | @@ -150,7 +150,7 @@ PosterX only needs **outbound HTTP(S)** to your server — no extra packages in **Kodi:** set server type to **Kodi**, port to Kodi’s **Web server / JSON-RPC** port (often **8080**), and **Token** only if HTTP auth is enabled in Kodi (otherwise leave blank). -Example **Jellyfin + PosterX** on one stack: see [`docker-compose.media-servers.example.yml`](docker-compose.media-servers.example.yml). Start with: +Example **Jellyfin + ScreenStage** on one stack: see [`docker-compose.media-servers.example.yml`](docker-compose.media-servers.example.yml). Start with: `docker compose -f docker-compose.yml -f docker-compose.media-servers.example.yml up -d` @@ -158,32 +158,32 @@ Then set server type to **Jellyfin**, host **`jellyfin`**, port **8096**, and yo ### Docker CLI (X86, ARM64) Create the following directories in your docker folder: - - ./docker/posterr - - ./docker/posterr/config - - ./docker/posterr/custom + - ./docker/screenstage + - ./docker/screenstage/config + - ./docker/screenstage/custom ``` -docker run -d --name posterr \ +docker run -d --name screenstage \ -p 9876:3000 \ --v ~/docker/posterr/config:/usr/src/app/config \ --v ~/docker/posterr/custom:/usr/src/app/public/custom \ +-v ~/docker/screenstage/config:/usr/src/app/config \ +-v ~/docker/screenstage/custom:/usr/src/app/public/custom \ -e TZ=Australia/Brisbane \ --add-host=host.docker.internal:host-gateway \ --restart=always \ -binarygeek119/posterrx:latest +binarygeek119/screenstage:latest ``` -On **Docker Engine 20.10+**, `--add-host=host.docker.internal:host-gateway` lets PosterX reach Plex/Jellyfin/Emby/Kodi running on the **host** (Linux). Omit if you only use container-to-container names on a custom network. +On **Docker Engine 20.10+**, `--add-host=host.docker.internal:host-gateway` lets ScreenStage reach Plex/Jellyfin/Emby/Kodi running on the **host** (Linux). Omit if you only use container-to-container names on a custom network. #### Details |Option|Details| |--|--| |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|Mount for **custom picture** themes under `public/custom` (omit if you do not use them)| +|/docker/screenstage/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/screenstage/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|`"/your-prefix"` for reverse proxies that serve PosterX under a subpath. Omit or leave empty if unused.| -|extra_hosts `host.docker.internal`|Helps PosterX reach **Jellyfin, Emby, Kodi, or Plex on the Docker host** from inside the container (Linux). Requires Docker Engine **20.10+**. | +|BASEPATH|`"/your-prefix"` for reverse proxies that serve ScreenStage under a subpath. Omit or leave empty if unused.| +|extra_hosts `host.docker.internal`|Helps ScreenStage reach **Jellyfin, Emby, Kodi, or Plex on the Docker host** from inside the container (Linux). Requires Docker Engine **20.10+**. | ### Unraid - Install via **Docker** using the Compose or `docker run` examples above, or a Community Applications template if one matches this image. @@ -194,7 +194,7 @@ On **Docker Engine 20.10+**, `--add-host=host.docker.internal:host-gateway` lets --- ## Updates - - **Settings** may show an **update available** notice when PosterX’s remote version check reports a **newer version** than this install (see startup logs / settings UI). + - **Settings** may show an **update available** notice when ScreenStage’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. @@ -202,7 +202,7 @@ On **Docker Engine 20.10+**, `--add-host=host.docker.internal:host-gateway` lets ## Setup Get to the settings page in a number of ways: - On initial load, you will be prompted. - - Change the URL to _'http://hostIP:9876/settings'_ (where `hostIP` is the IP number of the machine that PosterX is installed on. Change the port number if you set a different value. 3000 is the default for the binary executables) + - Change the URL to _'http://hostIP:9876/settings'_ (where `hostIP` is the IP number of the machine that ScreenStage is installed on. Change the port number if you set a different value. 3000 is the default for the binary executables) - Clicking on the top banner title of any slide. - If on the 'no content' page, then click this text @@ -226,7 +226,7 @@ Get to the settings page in a number of ways: - Low resource usage. Memory: 20-35mb, Diskspace: ~75mb, CPU: < 1% (running on a Synology NAS with a Celeron processor) - Checks for updates in Now Screening / Playing every 10 seconds (Will not display updates until browser refreshed or all slides cycled through) - Browser-based, so can run the app on one machine and a browser on another. - - Browser connectivity checks and auto-reconnect when the PosterX app restarts. (eg During container updates) + - Browser connectivity checks and auto-reconnect when the ScreenStage app restarts. (eg During container updates) - Supports screen resolution heights from 320 pixels to around 3500 pixels. - Supports reverse proxy setup for wildcard dns or alternate base path. - Built-in recovery features should the Poster app, or your media server, go offline. @@ -239,10 +239,10 @@ Should you encounter a problem, the solution may be listed [HERE](https://github --- ## Support - - **PosterX maintainer Discord:** [https://discord.gg/AEhVjqX4Af](https://discord.gg/AEhVjqX4Af) (this fork). + - **ScreenStage maintainer Discord:** [https://discord.gg/AEhVjqX4Af](https://discord.gg/AEhVjqX4Af) (this fork). - Original Posterr community: [https://discord.gg/TcnEkMEf9J](https://discord.gg/TcnEkMEf9J). - - Do not ask the original Posterr developer for support when using PosterX. - - For fork support, use GitHub: [https://github.com/binarygeek119/posterrX](https://github.com/binarygeek119/posterrX). + - Do not ask the original Posterr developer for support when using ScreenStage. + - For fork support, use GitHub: [https://github.com/binarygeek119/ScreenStage](https://github.com/binarygeek119/ScreenStage). --- ### Support my efforts and continued development @@ -256,7 +256,7 @@ Matt Petersen (April 2021) --- ## Technical Details -PosterX uses the following: +ScreenStage uses the following: - Node & Node Express - The awesome [Node-Plex-APi](https://github.com/phillipj/node-plex-api) - Jquery @@ -271,7 +271,7 @@ PosterX uses the following: --- ## Notice -> PosterX 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. +> ScreenStage 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/core/mediaServers.js b/classes/core/mediaServers.js new file mode 100644 index 0000000..c3ce6a1 --- /dev/null +++ b/classes/core/mediaServers.js @@ -0,0 +1,368 @@ +/** + * Multi media-server model: normalize, migrate, cap, and build clients. + * Up to MEDIA_SERVER_MAX_PER_TYPE of each type (plex/jellyfin/emby/kodi). + */ +const crypto = require("crypto"); +const { + getMediaServerClass, + getMediaServerKind, + requiresMediaServerCredential, + getMediaServerShortLabel, +} = require("../mediaservers/mediaServerFactory"); + +const MEDIA_SERVER_TYPES = ["plex", "jellyfin", "emby", "kodi"]; +const MEDIA_SERVER_MAX_PER_TYPE = 10; + +const ARR_SERVER_IDS = { + sonarr: "arr-sonarr", + radarr: "arr-radarr", + lidarr: "arr-lidarr", + readarr: "arr-readarr", + chaptarr: "arr-chaptarr", +}; + +function newMediaServerId() { + if (crypto.randomUUID) return crypto.randomUUID(); + return ( + "ms-" + + Date.now().toString(36) + + "-" + + Math.random().toString(36).slice(2, 10) + ); +} + +function toBoolStr(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"; +} + +function defaultPortForType(type) { + const t = getMediaServerKind(type); + if (t === "jellyfin" || t === "emby") return 8096; + if (t === "kodi") return 8080; + return 32400; +} + +/** + * @param {object} raw + * @returns {object} + */ +function normalizeMediaServerEntry(raw) { + const src = raw && typeof raw === "object" ? raw : {}; + let type = getMediaServerKind(src.type || src.mediaServerType || "plex"); + if (!MEDIA_SERVER_TYPES.includes(type)) type = "plex"; + const portRaw = src.port != null ? src.port : src.plexPort; + let port = parseInt(portRaw, 10); + if (isNaN(port) || port <= 0) port = defaultPortForType(type); + const host = String(src.host != null ? src.host : src.plexIP || "").trim(); + const token = String( + src.token != null ? src.token : src.plexToken != null ? src.plexToken : "" + ).trim(); + const https = toBoolStr( + src.https != null ? src.https : src.plexHTTPS, + "false" + ); + const id = String(src.id || "").trim() || newMediaServerId(); + let name = String(src.name || "").trim(); + if (!name) { + name = + getMediaServerShortLabel(type) + (host ? " @ " + host : " (new)"); + } + return { + id, + type, + name, + https, + host, + port, + token, + enabled: toBoolStr(src.enabled, "true"), + display: toBoolStr(src.display, "true"), + enableNowPlaying: toBoolStr(src.enableNowPlaying, "true"), + enableOnDemand: toBoolStr( + src.enableOnDemand != null ? src.enableOnDemand : src.enableOD, + "true" + ), + enableSync: toBoolStr(src.enableSync, "true"), + libraries: String( + src.libraries != null + ? src.libraries + : src.onDemandLibraries != null + ? src.onDemandLibraries + : "" + ).trim(), + libraries3d: String( + src.libraries3d != null + ? src.libraries3d + : src.onDemand3dLibraries != null + ? src.onDemand3dLibraries + : "" + ).trim(), + }; +} + +/** + * Cap at MEDIA_SERVER_MAX_PER_TYPE per type; drop incomplete empty drafts only when stripEmpty. + * @param {unknown} list + * @param {{ stripEmpty?: boolean }} [opts] + */ +function normalizeMediaServersArray(list, opts) { + const stripEmpty = !!(opts && opts.stripEmpty); + const arr = Array.isArray(list) ? list : []; + const counts = { plex: 0, jellyfin: 0, emby: 0, kodi: 0 }; + const out = []; + const seenIds = new Set(); + for (const raw of arr) { + const entry = normalizeMediaServerEntry(raw); + if (stripEmpty && !entry.host && !entry.token) continue; + if (counts[entry.type] >= MEDIA_SERVER_MAX_PER_TYPE) continue; + let id = entry.id; + while (seenIds.has(id)) id = newMediaServerId(); + entry.id = id; + seenIds.add(id); + counts[entry.type]++; + out.push(entry); + } + return out; +} + +/** + * Build one entry from legacy flat plex* / mediaServerType fields. + * @param {object} settings + */ +function migrateLegacyToMediaServers(settings) { + const s = settings || {}; + if (Array.isArray(s.mediaServers) && s.mediaServers.length > 0) { + return normalizeMediaServersArray(s.mediaServers); + } + const host = String(s.plexIP || "").trim(); + if (!host) return []; + return normalizeMediaServersArray([ + { + id: newMediaServerId(), + type: s.mediaServerType || "plex", + name: "", + https: s.plexHTTPS, + host, + port: s.plexPort, + token: s.plexToken, + enabled: "true", + display: "true", + enableNowPlaying: s.enableNS !== "false" ? "true" : "false", + enableOnDemand: s.enableOD !== "false" ? "true" : "false", + enableSync: "true", + libraries: s.onDemandLibraries || "", + libraries3d: s.onDemand3dLibraries || "", + }, + ]); +} + +/** + * Keep legacy flat fields in sync with the first enabled (or first) server for older code paths. + * @param {object} settings + */ +function syncLegacyFlatFromMediaServers(settings) { + if (!settings || typeof settings !== "object") return settings; + const list = normalizeMediaServersArray(settings.mediaServers || []); + settings.mediaServers = list; + const primary = + list.find((e) => e.enabled === "true" && e.host) || list[0] || null; + if (!primary) { + settings.mediaServerType = "plex"; + settings.plexIP = ""; + settings.plexHTTPS = "false"; + settings.plexPort = 32400; + settings.plexToken = ""; + return settings; + } + settings.mediaServerType = primary.type; + settings.plexIP = primary.host; + settings.plexHTTPS = primary.https; + settings.plexPort = primary.port; + settings.plexToken = primary.token; + return settings; +} + +function isEntryConfigured(entry) { + if (!entry || !entry.host) return false; + if (requiresMediaServerCredential(entry.type) && !entry.token) return false; + return true; +} + +function listConfiguredMediaServers(settings) { + return normalizeMediaServersArray( + (settings && settings.mediaServers) || [] + ).filter((e) => e.enabled === "true" && isEntryConfigured(e)); +} + +function listDisplayMediaServers(settings) { + return listConfiguredMediaServers(settings).filter( + (e) => e.display === "true" + ); +} + +function listNowPlayingMediaServers(settings) { + return listConfiguredMediaServers(settings).filter( + (e) => e.enableNowPlaying === "true" + ); +} + +function listSyncMediaServers(settings) { + return listConfiguredMediaServers(settings).filter( + (e) => e.enableSync === "true" || e.enableOnDemand === "true" + ); +} + +function listDisplayServerIds(settings) { + const ids = listDisplayMediaServers(settings).map((e) => e.id); + const s = settings || {}; + if (s.enableSonarr !== "false" && s.sonarrURL && s.sonarrToken) { + ids.push(ARR_SERVER_IDS.sonarr); + } + if (s.enableRadarr !== "false" && s.radarrURL && s.radarrToken) { + ids.push(ARR_SERVER_IDS.radarr); + } + if (s.enableLidarr !== "false" && s.lidarrURL && s.lidarrToken) { + ids.push(ARR_SERVER_IDS.lidarr); + } + if (s.enableReadarr !== "false" && s.readarrURL && s.readarrToken) { + const kind = + s.bookArrKind === "chaptarr" ? "chaptarr" : "readarr"; + ids.push(ARR_SERVER_IDS[kind] || ARR_SERVER_IDS.readarr); + } + return ids; +} + +function createMediaServerClient(entry) { + const e = normalizeMediaServerEntry(entry); + const Pms = getMediaServerClass(e.type); + return new Pms({ + plexHTTPS: e.https, + plexIP: e.host, + plexPort: e.port, + plexToken: e.token, + }); +} + +function cacheFilePrefix(serverId) { + const id = String(serverId || "") + .trim() + .replace(/[^a-zA-Z0-9_-]/g, ""); + return id || "unknown"; +} + +/** + * Parse indexed form fields mediaServers[i][field] or JSON body.mediaServers. + * @param {object} body + */ +function parseMediaServersFromFormBody(body) { + if (!body || typeof body !== "object") return []; + if (typeof body.mediaServersJson === "string" && body.mediaServersJson.trim()) { + try { + const parsed = JSON.parse(body.mediaServersJson); + return normalizeMediaServersArray(parsed); + } catch (e) { + /* fall through */ + } + } + if (Array.isArray(body.mediaServers)) { + return normalizeMediaServersArray(body.mediaServers); + } + // Indexed: mediaServers[0][host] + const byIndex = {}; + for (const key of Object.keys(body)) { + const m = /^mediaServers\[(\d+)\]\[(\w+)\]$/.exec(key); + if (!m) continue; + const idx = m[1]; + const field = m[2]; + if (!byIndex[idx]) byIndex[idx] = {}; + byIndex[idx][field] = body[key]; + } + const indexes = Object.keys(byIndex) + .map((n) => parseInt(n, 10)) + .filter((n) => !isNaN(n)) + .sort((a, b) => a - b); + return normalizeMediaServersArray(indexes.map((i) => byIndex[String(i)])); +} + +function countByType(list) { + const counts = { plex: 0, jellyfin: 0, emby: 0, kodi: 0 }; + for (const e of list || []) { + const t = getMediaServerKind(e.type); + if (counts[t] != null) counts[t]++; + } + return counts; +} + +function validateMediaServersForSave(list) { + const errors = []; + const normalized = normalizeMediaServersArray(list); + const counts = countByType(normalized); + for (const t of MEDIA_SERVER_TYPES) { + if (counts[t] > MEDIA_SERVER_MAX_PER_TYPE) { + errors.push( + "At most " + + MEDIA_SERVER_MAX_PER_TYPE + + " " + + t + + " servers are allowed." + ); + } + } + for (const e of normalized) { + if (!e.host && !e.token && e.enabled !== "true") continue; + if (e.enabled === "true" || e.host || e.token) { + if (!e.host) { + errors.push( + (e.name || e.type) + ": server address is required when enabled." + ); + } + if (!e.port || isNaN(parseInt(e.port, 10))) { + errors.push((e.name || e.type) + ": port must be a number."); + } + if (requiresMediaServerCredential(e.type) && !e.token) { + errors.push( + (e.name || e.type) + ": token / API key is required for this type." + ); + } + } + } + return { ok: errors.length === 0, errors, mediaServers: normalized }; +} + +module.exports = { + MEDIA_SERVER_TYPES, + MEDIA_SERVER_MAX_PER_TYPE, + ARR_SERVER_IDS, + newMediaServerId, + normalizeMediaServerEntry, + normalizeMediaServersArray, + migrateLegacyToMediaServers, + syncLegacyFlatFromMediaServers, + isEntryConfigured, + listConfiguredMediaServers, + listDisplayMediaServers, + listNowPlayingMediaServers, + listSyncMediaServers, + listDisplayServerIds, + createMediaServerClient, + cacheFilePrefix, + parseMediaServersFromFormBody, + validateMediaServersForSave, + countByType, + getMediaServerKind, + getMediaServerShortLabel, + requiresMediaServerCredential, +}; diff --git a/classes/core/posterMetadataDb.js b/classes/core/posterMetadataDb.js index 6555934..91774c8 100644 --- a/classes/core/posterMetadataDb.js +++ b/classes/core/posterMetadataDb.js @@ -51,6 +51,7 @@ CREATE TABLE IF NOT EXISTS poster_entries ( rating_content TEXT NOT NULL DEFAULT '', summary TEXT NOT NULL DEFAULT '', server_kind TEXT NOT NULL DEFAULT 'plex', + server_id TEXT NOT NULL DEFAULT '', poster_ar TEXT NOT NULL DEFAULT '', dbid TEXT NOT NULL DEFAULT '', api_item_id TEXT NOT NULL DEFAULT '', @@ -63,6 +64,8 @@ CREATE INDEX IF NOT EXISTS idx_poster_server_updated ON poster_entries(server_ki CREATE INDEX IF NOT EXISTS idx_poster_server_api ON poster_entries(server_kind, api_item_id); CREATE INDEX IF NOT EXISTS idx_poster_server_dbid ON poster_entries(server_kind, dbid); `; +// Indexes on server_id are created in ensurePosterEntriesExtraColumns() after +// the column is migrated onto existing DBs (CREATE TABLE IF NOT EXISTS won't add it). /** @type {any} */ let _sqlDb = null; @@ -87,8 +90,16 @@ const EXTRA_SCHEMA_COLUMNS = [ { name: "plot", def: "TEXT NOT NULL DEFAULT ''" }, { name: "rating_score", def: "TEXT NOT NULL DEFAULT ''" }, { name: "rating_content", def: "TEXT NOT NULL DEFAULT ''" }, + { name: "server_id", def: "TEXT NOT NULL DEFAULT ''" }, ]; +const INSERT_COLS = `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, server_id, poster_ar, + dbid, api_item_id, library_kind, library_name, source_url, updated_at`; +const INSERT_PLACEHOLDERS = "?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?"; + function assertDb() { if (!_sqlDb) { throw new Error( @@ -120,6 +131,16 @@ function ensurePosterEntriesExtraColumns() { changed = true; } } + try { + _sqlDb.run( + "CREATE INDEX IF NOT EXISTS idx_poster_server_id_api ON poster_entries(server_id, api_item_id)" + ); + _sqlDb.run( + "CREATE INDEX IF NOT EXISTS idx_poster_server_id_dbid ON poster_entries(server_id, dbid)" + ); + } catch (e) { + /* ignore */ + } return changed; } @@ -252,7 +273,7 @@ function logLegacySavedImageHint() { if (n > 0) return; console.log( new Date().toLocaleString() + - " PosterX: cached images are still under saved/imagecache; move files to config/cache/imagecache (and mp3cache → config/cache/mp3cache), or copy your old saved/ tree into config/cache/." + " ScreenStage: cached images are still under saved/imagecache; move files to config/cache/imagecache (and mp3cache → config/cache/mp3cache), or copy your old saved/ tree into config/cache/." ); } catch (e) { /* ignore */ @@ -264,15 +285,13 @@ function migrateLegacyJson() { const raw = fs.readFileSync(LEGACY_JSON, "utf8"); const parsed = JSON.parse(raw); const entries = parsed && Array.isArray(parsed.entries) ? parsed.entries : []; - const ins = _sqlDb.prepare(` - INSERT OR REPLACE INTO poster_entries ( - cache_file, logo_cache_file, art_cache_file, banner_cache_file, - portrait_actor_cache_file, portrait_actress_cache_file, - portrait_director_cache_file, portrait_author_cache_file, portrait_artist_cache_file, - title, tag_line, year, media_type, 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `); + const ins = _sqlDb.prepare( + "INSERT OR REPLACE INTO poster_entries (" + + INSERT_COLS + + ") VALUES (" + + INSERT_PLACEHOLDERS + + ")" + ); try { _sqlDb.run("BEGIN"); for (const e of entries) { @@ -369,6 +388,7 @@ function rowFromDb(r) { ratingContent: r.rating_content || r.content_rating || "", summary: r.summary || "", serverKind: r.server_kind || "", + serverId: r.server_id || "", posterAR: r.poster_ar || "", dbid: r.dbid || "", apiItemId: r.api_item_id || "", @@ -409,6 +429,7 @@ function entryToParams(e) { e.ratingContent || "", e.summary || "", e.serverKind || "plex", + e.serverId || "", e.posterAR || "", e.dbid || "", e.apiItemId || "", @@ -474,6 +495,20 @@ function selectByServerKind(kind) { return out; } +function selectByServerId(serverId) { + assertDb(); + const sid = String(serverId || "").trim(); + if (!sid) return []; + const out = []; + const s = _sqlDb.prepare("SELECT * FROM poster_entries WHERE server_id = ?"); + s.bind([sid]); + while (s.step()) { + out.push(rowFromDb(s.getAsObject())); + } + s.free(); + return out; +} + function getEntryByCacheFile(cacheFile) { assertDb(); const s = _sqlDb.prepare("SELECT * FROM poster_entries WHERE cache_file = ?"); @@ -484,19 +519,31 @@ function getEntryByCacheFile(cacheFile) { return row; } -function getEntryByServerAndApiItemId(serverKind, apiItemId) { +function getEntryByServerAndApiItemId(serverKind, apiItemId, serverId) { assertDb(); const sk = String(serverKind || "").toLowerCase().trim(); const aid = String(apiItemId || "").trim(); - if (!sk || !aid) return null; - const s = _sqlDb.prepare( + const sid = String(serverId || "").trim(); + if (!aid) return null; + if (sid) { + const s = _sqlDb.prepare( + "SELECT * FROM poster_entries WHERE server_id = ? AND api_item_id = ? LIMIT 1" + ); + s.bind([sid, aid]); + let row = null; + if (s.step()) row = rowFromDb(s.getAsObject()); + s.free(); + return row; + } + if (!sk) return null; + const s2 = _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; + s2.bind([sk, aid]); + let row2 = null; + if (s2.step()) row2 = rowFromDb(s2.getAsObject()); + s2.free(); + return row2; } /** @@ -505,9 +552,10 @@ function getEntryByServerAndApiItemId(serverKind, apiItemId) { * @param {string} serverKind * @param {string} apiItemId * @param {string|number|Date} sourceUpdatedAt + * @param {string} [serverId] */ -function shouldSkipSyncItem(serverKind, apiItemId, sourceUpdatedAt) { - const row = getEntryByServerAndApiItemId(serverKind, apiItemId); +function shouldSkipSyncItem(serverKind, apiItemId, sourceUpdatedAt, serverId) { + const row = getEntryByServerAndApiItemId(serverKind, apiItemId, serverId); if (!row || !row.cacheFile || !fileOk(row.cacheFile)) return false; if (sourceUpdatedAt === undefined || sourceUpdatedAt === null || sourceUpdatedAt === "") return true; @@ -540,15 +588,13 @@ function deleteByCacheFiles(files) { function replaceAllEntries(entries) { assertDb(); - const ins = _sqlDb.prepare(` - INSERT INTO poster_entries ( - cache_file, logo_cache_file, art_cache_file, banner_cache_file, - portrait_actor_cache_file, portrait_actress_cache_file, - portrait_director_cache_file, portrait_author_cache_file, portrait_artist_cache_file, - title, tag_line, year, media_type, 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `); + const ins = _sqlDb.prepare( + "INSERT INTO poster_entries (" + + INSERT_COLS + + ") VALUES (" + + INSERT_PLACEHOLDERS + + ")" + ); try { _sqlDb.run("BEGIN"); _sqlDb.run("DELETE FROM poster_entries"); @@ -694,11 +740,12 @@ function unlinkCacheAndArt(cacheFile) { /** * Remove poster metadata + files when the library item no longer exists on the media server. - * @param {{ currentServerKind: string, isMediaServerEnabled: boolean, maxChecks?: number, minAgeBeforeChangeCheckMins?: number, probeEntryGone: function }} opts + * @param {{ currentServerKind: string, currentServerId?: string, isMediaServerEnabled: boolean, maxChecks?: number, minAgeBeforeChangeCheckMins?: number, probeEntryGone: function }} opts */ async function purgeMissingServerItems(opts) { const { currentServerKind, + currentServerId, isMediaServerEnabled, maxChecks = 35, probeEntryGone, @@ -711,14 +758,17 @@ async function purgeMissingServerItems(opts) { if ( !probeEntryGone || !isMediaServerEnabled || - !currentServerKind || + (!currentServerKind && !currentServerId) || typeof probeEntryGone !== "function" ) { return { removed: 0, checked: 0 }; } assertDb(); - const all = selectByServerKind(currentServerKind).filter( + const pool = currentServerId + ? selectByServerId(currentServerId) + : selectByServerKind(currentServerKind); + const all = pool.filter( (e) => (String(e.apiItemId || "").trim() || String(e.sourceUrl || "").trim()) && nowMs - new Date(e.updatedAt || 0).getTime() >= minAgeMs @@ -764,9 +814,10 @@ async function purgeMissingServerItems(opts) { * Record poster files + metadata from Plex/Jellyfin/Emby/Kodi now-screening and on-demand cards. * @param {object[]} nsCards * @param {object[]} odCards - * @param {string} serverKind plex|jellyfin|emby|kodi + * @param {string} serverKind plex|jellyfin|emby|kodi|sonarr|… + * @param {string} [serverId] stable instance id */ -function registerFromMediaServerCards(nsCards, odCards, serverKind) { +function registerFromMediaServerCards(nsCards, odCards, serverKind, serverId) { const cards = [] .concat(Array.isArray(nsCards) ? nsCards : []) .concat(Array.isArray(odCards) ? odCards : []); @@ -793,16 +844,19 @@ function registerFromMediaServerCards(nsCards, odCards, serverKind) { 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, 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `); + const sid = String( + serverId || + (cards[0] && cards[0].posterServerId) || + "" + ).trim(); + + const ins = _sqlDb.prepare( + "INSERT OR REPLACE INTO poster_entries (" + + INSERT_COLS + + ") VALUES (" + + INSERT_PLACEHOLDERS + + ")" + ); try { _sqlDb.run("BEGIN"); @@ -874,6 +928,10 @@ function registerFromMediaServerCards(nsCards, odCards, serverKind) { ratingContent: String(card.contentRating || "").trim(), summary: String(card.summary || "").slice(0, 2000), serverKind: kind, + serverId: + sid || + String(card.posterServerId || "").trim() || + "", posterAR: String(card.posterAR || "").trim(), dbid: String(card.DBID || "").trim(), apiItemId, @@ -926,6 +984,7 @@ function registerFromMediaServerCards(nsCards, odCards, serverKind) { old.ratingScore !== row.ratingScore || old.ratingContent !== row.ratingContent || old.serverKind !== row.serverKind || + old.serverId !== row.serverId || old.summary !== row.summary || old.posterAR !== row.posterAR || old.dbid !== row.dbid || @@ -969,14 +1028,25 @@ function registerFromMediaServerCards(nsCards, odCards, serverKind) { }; } -function pickRandomEntries(count, serverKindOpt, hideContentRatings) { +function pickRandomEntries(count, serverKindOpt, hideContentRatings, serverIdsOpt) { let valid = selectAllEntries().filter((e) => e.cacheFile && fileOk(e.cacheFile)); if (valid.length === 0) return []; + const wantIds = Array.isArray(serverIdsOpt) + ? serverIdsOpt.map((x) => String(x || "").trim()).filter(Boolean) + : []; + if (wantIds.length > 0) { + const idSet = new Set(wantIds); + const filtered = valid.filter( + (e) => e.serverId && idSet.has(String(e.serverId)) + ); + // Prefer filtered; if empty (legacy rows without server_id), fall through to kind/all + if (filtered.length > 0) valid = filtered; + } const wantKind = serverKindOpt != null && String(serverKindOpt).trim() ? String(serverKindOpt).toLowerCase().trim() : ""; - if (wantKind) { + if (wantKind && wantIds.length === 0) { const filtered = valid.filter( (e) => String(e.serverKind || "").toLowerCase() === wantKind ); @@ -1038,15 +1108,18 @@ function ratingColourForContentRating(contentRating) { * For live on-demand cards: point poster (and fanart when present) at disk cache if the poster DB has a match. * @param {object[]} cards * @param {string} serverKind plex|jellyfin|emby|kodi + * @param {string} [serverId] */ -function applyCachedPostersToMediaCards(cards, serverKind) { +function applyCachedPostersToMediaCards(cards, serverKind, serverId) { if (!Array.isArray(cards) || cards.length === 0) return cards; const kind = String(serverKind || "plex").toLowerCase(); + const sid = String(serverId || "").trim(); assertDb(); const byApiId = new Map(); const byDbId = new Map(); const bySource = new Map(); - for (const e of selectByServerKind(kind)) { + const pool = sid ? selectByServerId(sid) : selectByServerKind(kind); + for (const e of pool) { if (!e.cacheFile || !fileOk(e.cacheFile)) continue; const api = String(e.apiItemId || "").trim(); const dbid = String(e.dbid || "").trim(); @@ -1111,18 +1184,19 @@ function applyCachedPostersToMediaCards(cards, serverKind) { /** * Build on-demand style cards from the poster metadata DB when nothing else is available. * @param {number} count - * @param {string} [serverKind] When set, prefer random picks from this server (plex|jellyfin|emby|kodi). + * @param {string} [serverKind] When set (and no serverIds), prefer random picks from this kind. * @param {string[]} [hideContentRatings] Hide Ratings list (normalized or raw). + * @param {string[]} [serverIds] When set, only include rows for these server_id values. * @returns {MediaCard[]} */ -function buildFallbackMediaCards(count, serverKind, hideContentRatings) { +function buildFallbackMediaCards(count, serverKind, hideContentRatings, serverIds) { const n = typeof count === "number" && count > 0 && Number.isFinite(count) ? Math.floor(count) : DEFAULT_FALLBACK_COUNT; - const rows = pickRandomEntries(n, serverKind, hideContentRatings); + const rows = pickRandomEntries(n, serverKind, hideContentRatings, serverIds); const cards = []; for (const row of rows) { const c = new MediaCard(); @@ -1138,6 +1212,7 @@ function buildFallbackMediaCards(count, serverKind, hideContentRatings) { if (row.posterAR) c.posterAR = row.posterAR; c.DBID = row.dbid || ""; c.posterApiItemId = String(row.apiItemId || "").trim(); + c.posterServerId = String(row.serverId || "").trim(); c.posterLibraryLabel = row.libraryName || ""; if (row.logoCacheFile) { c.posterLogoURL = "/imagecache/" + row.logoCacheFile; @@ -1220,6 +1295,75 @@ async function clearPosterCacheAndMetadata() { ); } +/** + * Delete all poster metadata rows and matching imagecache files for one server_id. + * @param {string} serverId + * @returns {{ removedRows: number }} + */ +function clearCacheForServerId(serverId) { + const sid = String(serverId || "").trim(); + if (!sid) return { removedRows: 0 }; + assertDb(); + const rows = selectByServerId(sid); + const files = new Set(); + for (const r of rows) { + if (r.cacheFile) files.add(r.cacheFile); + if (r.logoCacheFile) files.add(r.logoCacheFile); + if (r.artCacheFile) files.add(r.artCacheFile); + if (r.bannerCacheFile) files.add(r.bannerCacheFile); + if (r.portraitActorCacheFile) files.add(r.portraitActorCacheFile); + if (r.portraitActressCacheFile) files.add(r.portraitActressCacheFile); + if (r.portraitDirectorCacheFile) files.add(r.portraitDirectorCacheFile); + if (r.portraitAuthorCacheFile) files.add(r.portraitAuthorCacheFile); + if (r.portraitArtistCacheFile) files.add(r.portraitArtistCacheFile); + } + // Also unlink files whose basename is prefixed with server id + try { + if (fs.existsSync(IMAGECACHE)) { + const prefix = sid + "-"; + for (const name of fs.readdirSync(IMAGECACHE)) { + if (name.startsWith(prefix) || name.startsWith(sid)) { + files.add(name); + } + } + // *arr synthetic ids use arr-* file names + if (sid.startsWith("arr-")) { + const arrPrefix = sid.replace(/^arr-/, "arr-") + "-"; + // arr-sonarr → files arr-sonarr-*.jpg + const filePrefix = sid + "-"; + for (const name of fs.readdirSync(IMAGECACHE)) { + if (name.startsWith(filePrefix) || name.startsWith(sid + "-")) { + files.add(name); + } + } + void arrPrefix; + } + } + } catch (e) { + /* ignore */ + } + for (const cf of files) { + unlinkCacheAndArt(cf); + } + if (rows.length) { + deleteByCacheFiles(rows.map((r) => r.cacheFile).filter(Boolean)); + } + // Delete any remaining rows by server_id + const del = _sqlDb.prepare("DELETE FROM poster_entries WHERE server_id = ?"); + del.run([sid]); + del.free(); + persistDb(); + console.log( + new Date().toLocaleString() + + " Cleared poster cache for server_id=" + + sid + + " (" + + rows.length + + " rows)" + ); + return { removedRows: rows.length }; +} + /** * Drop orphaned DB rows, then re-download stale poster files when sourceUrl is known * (otherwise remove file + row). Timer interval should match settings.posterCacheRefreshMins. @@ -1582,6 +1726,7 @@ module.exports = { applyCachedPostersToMediaCards, buildFallbackMediaCards, clearPosterCacheAndMetadata, + clearCacheForServerId, runScheduledRefresh, purgeMissingServerItems, getCacheDashboardStats, @@ -1593,4 +1738,5 @@ module.exports = { fileOk, IMAGE_KIND_ORDER, countRows, + selectByServerId, }; diff --git a/classes/core/posterSyncRetry.js b/classes/core/posterSyncRetry.js index 3166806..1db0ad6 100644 --- a/classes/core/posterSyncRetry.js +++ b/classes/core/posterSyncRetry.js @@ -71,14 +71,17 @@ function prioritizeOdRaw(odRaw, retryKeys, serverKind) { return scored.map((s) => s.item); } -function loadRetryKeys(serverKind) { +function loadRetryKeys(serverKind, serverId) { try { if (!fs.existsSync(RETRY_FILE)) return []; const j = JSON.parse(fs.readFileSync(RETRY_FILE, "utf8")); - if ( - !j || + if (!j) return []; + const sid = String(serverId || "").trim(); + if (sid) { + if (String(j.serverId || "").trim() !== sid) return []; + } else if ( String(j.serverKind || "").toLowerCase() !== - String(serverKind || "").toLowerCase() + String(serverKind || "").toLowerCase() ) { return []; } @@ -89,7 +92,7 @@ function loadRetryKeys(serverKind) { } } -function saveRetryKeys(serverKind, keys) { +function saveRetryKeys(serverKind, keys, serverId) { const uniq = []; const seen = new Set(); for (const k of keys || []) { @@ -99,17 +102,15 @@ function saveRetryKeys(serverKind, keys) { uniq.push(s); if (uniq.length >= MAX_KEYS) break; } + const payload = { + serverKind: String(serverKind || "").toLowerCase(), + serverId: String(serverId || "").trim(), + savedAt: new Date().toISOString(), + keys: uniq, + }; try { - fs.mkdirSync(CACHE_ROOT, { recursive: true }); - fs.writeFileSync( - RETRY_FILE, - JSON.stringify({ - serverKind: String(serverKind || "").toLowerCase(), - savedAt: new Date().toISOString(), - keys: uniq, - }), - "utf8" - ); + fs.mkdirSync(path.dirname(RETRY_FILE), { recursive: true }); + fs.writeFileSync(RETRY_FILE, JSON.stringify(payload, null, 2), "utf8"); } catch (e) { /* ignore */ } diff --git a/classes/core/settings.js b/classes/core/settings.js index 3657018..f1a1cf5 100644 --- a/classes/core/settings.js +++ b/classes/core/settings.js @@ -3,6 +3,7 @@ const fsp = require("fs").promises; const DEFAULT_SETTINGS = require("../../consts"); const util = require("../core/utility"); const { requiresMediaServerCredential } = require("../mediaservers/mediaServerFactory"); +const mediaServersUtil = require("./mediaServers"); /** Normalize form/JSON values to "true" | "false" for settings file (checkboxes, toggles). */ function toSettingsBoolStr(value, fallback) { @@ -34,6 +35,9 @@ class Settings { this.plexHTTPS = DEFAULT_SETTINGS.plexHTTPS; this.plexPort = DEFAULT_SETTINGS.plexPort; this.plexToken = DEFAULT_SETTINGS.plexToken; + this.mediaServers = Array.isArray(DEFAULT_SETTINGS.mediaServers) + ? DEFAULT_SETTINGS.mediaServers.slice() + : []; this.onDemandLibraries = DEFAULT_SETTINGS.onDemandLibraries; this.onDemand3dLibraries = DEFAULT_SETTINGS.onDemand3dLibraries; this.numberOnDemand = DEFAULT_SETTINGS.numberOnDemand; @@ -166,28 +170,23 @@ class Settings { * @returns {} true / false if any value is changed */ GetChanged() { - let hasChanged = false; - let SettingChanged; - try { - // only worry about required media server settings (Kodi may omit token if no HTTP auth) - const tokenOk = - !requiresMediaServerCredential(this.mediaServerType) || - (this.plexToken !== undefined && this.plexToken !== ""); - if (this.plexIP !== "" && this.plexPort !== "" && tokenOk) { - hasChanged = true; - throw SettingChanged; - } else { - let now = new Date(); - console.log( - now.toISOString().split("T")[0] + - " INVALID MEDIA SERVER SETTINGS - Please visit setup page to resolve" - ); - } - } catch (e) { - if (e !== SettingChanged) throw e; - } - - return hasChanged; + const list = mediaServersUtil.listConfiguredMediaServers(this); + if (list.length > 0) return true; + // Zero media servers is valid (*arr / cache-only) + const hasArr = + (this.sonarrURL && this.sonarrToken) || + (this.radarrURL && this.radarrToken) || + (this.lidarrURL && this.lidarrToken) || + (this.readarrURL && this.readarrToken); + if (hasArr) return true; + // Cache-only also OK once settings file exists with intentional empty servers + if (Array.isArray(this.mediaServers)) return true; + let now = new Date(); + console.log( + now.toISOString().split("T")[0] + + " No media servers or *arr configured — using cache / other sources" + ); + return true; } /** @@ -339,6 +338,17 @@ class Settings { if(readSettings.lidarrURL==undefined) readSettings.lidarrURL = ''; if(readSettings.lidarrToken==undefined) readSettings.lidarrToken = ''; if(readSettings.lidarrCalDays==undefined) readSettings.lidarrCalDays = 30; + // Multi media-server: migrate legacy flat plex* into mediaServers[] + if (!Array.isArray(readSettings.mediaServers)) { + readSettings.mediaServers = + mediaServersUtil.migrateLegacyToMediaServers(readSettings); + } else { + readSettings.mediaServers = + mediaServersUtil.normalizeMediaServersArray( + readSettings.mediaServers + ); + } + mediaServersUtil.syncLegacyFlatFromMediaServers(readSettings); } catch (ex) { // do nothing if error as it reads ok anyhow let d = new Date(); @@ -347,6 +357,39 @@ class Settings { // populate settings object with settings from json file await Object.assign(this, readSettings); + if (!Array.isArray(this.mediaServers)) { + this.mediaServers = mediaServersUtil.migrateLegacyToMediaServers(this); + } else { + this.mediaServers = mediaServersUtil.normalizeMediaServersArray( + this.mediaServers + ); + } + mediaServersUtil.syncLegacyFlatFromMediaServers(this); + + // persist migrated mediaServers when upgraded from legacy flat fields + try { + if ( + readSettings && + Array.isArray(readSettings.mediaServers) && + fs.existsSync("config/settings.json") + ) { + const disk = JSON.parse( + fs.readFileSync("config/settings.json", "utf-8") + ); + if (!Array.isArray(disk.mediaServers)) { + await this.UpdateSettings({ + mediaServers: this.mediaServers, + mediaServerType: this.mediaServerType, + plexIP: this.plexIP, + plexHTTPS: this.plexHTTPS, + plexPort: this.plexPort, + plexToken: this.plexToken, + }); + } + } + } catch (e) { + /* non-fatal */ + } // ensure settings loaded before returning return new Promise((resolve) => { @@ -449,18 +492,50 @@ class Settings { else this.hideSettingsLinks = "false"; if (jsonObject.theaterRoomMode) this.theaterRoomMode = jsonObject.theaterRoomMode; else this.theaterRoomMode = "false"; - if (jsonObject.mediaServerType) this.mediaServerType = jsonObject.mediaServerType; - else this.mediaServerType = cs.mediaServerType != undefined ? cs.mediaServerType : DEFAULT_SETTINGS.mediaServerType; - if (jsonObject.plexIP) this.plexIP = jsonObject.plexIP; - else this.plexIP = cs.plexIP; - if (jsonObject.plexHTTPSSwitch) this.plexHTTPS = jsonObject.plexHTTPSSwitch; - else this.plexHTTPS = "false"; - if (jsonObject.plexPort) this.plexPort = jsonObject.plexPort; - else this.plexPort = cs.plexPort; - if (jsonObject.plexToken !== undefined && jsonObject.plexToken !== null) { - this.plexToken = jsonObject.plexToken; + if (Object.prototype.hasOwnProperty.call(jsonObject, "mediaServers")) { + this.mediaServers = mediaServersUtil.normalizeMediaServersArray( + jsonObject.mediaServers + ); + mediaServersUtil.syncLegacyFlatFromMediaServers(this); + } else if ( + jsonObject.mediaServerType || + jsonObject.plexIP || + jsonObject.plexPort || + jsonObject.plexToken !== undefined + ) { + // Legacy single-server form fields still accepted + if (jsonObject.mediaServerType) + this.mediaServerType = jsonObject.mediaServerType; + else + this.mediaServerType = + cs.mediaServerType != undefined + ? cs.mediaServerType + : DEFAULT_SETTINGS.mediaServerType; + if (jsonObject.plexIP) this.plexIP = jsonObject.plexIP; + else this.plexIP = cs.plexIP; + if (Object.prototype.hasOwnProperty.call(jsonObject, "plexHTTPSSwitch")) { + this.plexHTTPS = toSettingsBoolStr(jsonObject.plexHTTPSSwitch, "false"); + } else { + this.plexHTTPS = + cs.plexHTTPS !== undefined ? cs.plexHTTPS : DEFAULT_SETTINGS.plexHTTPS; + } + if (jsonObject.plexPort) this.plexPort = jsonObject.plexPort; + else this.plexPort = cs.plexPort; + if (jsonObject.plexToken !== undefined && jsonObject.plexToken !== null) { + this.plexToken = jsonObject.plexToken; + } else { + this.plexToken = cs.plexToken; + } + if (!Array.isArray(this.mediaServers) || this.mediaServers.length === 0) { + this.mediaServers = mediaServersUtil.migrateLegacyToMediaServers(this); + } } else { - this.plexToken = cs.plexToken; + this.mediaServers = mediaServersUtil.normalizeMediaServersArray( + cs.mediaServers != null + ? cs.mediaServers + : mediaServersUtil.migrateLegacyToMediaServers(cs) + ); + mediaServersUtil.syncLegacyFlatFromMediaServers(this); } if (jsonObject.plexLibraries) this.onDemandLibraries = jsonObject.plexLibraries; @@ -1025,8 +1100,17 @@ class Settings { ? cs.posterCacheMinAgeBeforeChangeCheckMins : DEFAULT_SETTINGS.posterCacheMinAgeBeforeChangeCheckMins; } - if (jsonObject.preferCachedPosters) this.preferCachedPosters = jsonObject.preferCachedPosters; - else this.preferCachedPosters = "false"; + if (Object.prototype.hasOwnProperty.call(jsonObject, "preferCachedPosters")) { + this.preferCachedPosters = toSettingsBoolStr( + jsonObject.preferCachedPosters, + "false" + ); + } else { + this.preferCachedPosters = + cs.preferCachedPosters !== undefined + ? cs.preferCachedPosters + : DEFAULT_SETTINGS.preferCachedPosters; + } if ( jsonObject.cachedPosterSlideCount !== undefined && jsonObject.cachedPosterSlideCount !== null && diff --git a/classes/mediaservers/embyJellyfinBase.js b/classes/mediaservers/embyJellyfinBase.js index 3582c66..a52ec7f 100644 --- a/classes/mediaservers/embyJellyfinBase.js +++ b/classes/mediaservers/embyJellyfinBase.js @@ -399,7 +399,7 @@ class EmbyJellyfinBase { _mediaBrowserAuthorizationHeader(key) { const ver = (posterrPackage && posterrPackage.version) || "1.0"; const tok = String(key).replace(/\\/g, "\\\\").replace(/"/g, '\\"'); - return `MediaBrowser Client="PosterX", Device="PosterX", DeviceId="posterx", Version="${ver}", Token="${tok}"`; + return `MediaBrowser Client="ScreenStage", Device="ScreenStage", DeviceId="screenstage", Version="${ver}", Token="${tok}"`; } /** @@ -472,7 +472,7 @@ class EmbyJellyfinBase { : ""; if (!key) { throw new Error( - `${this.appName}: no API key in settings. Create one in the server dashboard (Jellyfin: Dashboard → API Keys) and paste it into PosterX’s server token field.` + `${this.appName}: no API key in settings. Create one in the server dashboard (Jellyfin: Dashboard → API Keys) and paste it into ScreenStage’s server token field.` ); } const params = { ...(options.params || {}) }; @@ -526,9 +526,9 @@ class EmbyJellyfinBase { const status = e.response && e.response.status; if (status === 401) { if (this._usesLegacyEmbyTokenQueryAuth()) { - e.message += ` | ${this.appName}: HTTP 401 — wrong or revoked API key, or the key lacks access. Regenerate the key in the server dashboard and update PosterX. If you use a reverse proxy, ensure it forwards the X-Emby-Token header (PosterX also sends api_key on the query string).`; + e.message += ` | ${this.appName}: HTTP 401 — wrong or revoked API key, or the key lacks access. Regenerate the key in the server dashboard and update ScreenStage. If you use a reverse proxy, ensure it forwards the X-Emby-Token header (ScreenStage also sends api_key on the query string).`; } else { - e.message += ` | Jellyfin: HTTP 401 — invalid or revoked API key (Dashboard → API Keys), or the server blocked the request. PosterX uses Authorization: MediaBrowser (required on Jellyfin 10.11+ when legacy X-Emby-Token is disabled). Ensure your reverse proxy forwards the Authorization header if you use one.`; + e.message += ` | Jellyfin: HTTP 401 — invalid or revoked API key (Dashboard → API Keys), or the server blocked the request. ScreenStage uses Authorization: MediaBrowser (required on Jellyfin 10.11+ when legacy X-Emby-Token is disabled). Ensure your reverse proxy forwards the Authorization header if you use one.`; } } const d = e.response && e.response.data; diff --git a/consts.js b/consts.js index 2377296..81c72c3 100644 --- a/consts.js +++ b/consts.js @@ -6,15 +6,22 @@ const DEFAULT_SETTINGS = { fade : "true", hideSettingsLinks : "false", theaterRoomMode : "false", + /** @deprecated Prefer mediaServers[]; kept for migration / legacy flat sync. */ mediaServerType : "plex", plexIP : "", plexHTTPS : "false", plexPort : 32400, plexToken : "", + /** + * Multi media-server connections (plex/jellyfin/emby/kodi). Up to 10 of each type. + * Empty array = no media servers (*arr / cache only). + */ + mediaServers : [], pinNS : "false", hideUser: "false", + /** @deprecated Prefer per-server mediaServers[].libraries */ onDemandLibraries : "", - /** On-demand library names that should be marked as 3D (comma-separated). */ + /** On-demand library names that should be marked as 3D (comma-separated). @deprecated Prefer per-server libraries3d */ onDemand3dLibraries : "", numberOnDemand : 2, onDemandRefresh : 30, diff --git a/docker-compose.debug.yml b/docker-compose.debug.yml index 72415c4..dea42ba 100644 --- a/docker-compose.debug.yml +++ b/docker-compose.debug.yml @@ -1,24 +1,24 @@ -# Debug compose — same host-gateway helper as docker-compose.yml for media servers on the host. -# Bind-mount config (cache/, ads/, ads-view/, DBs, settings) like production so data survives rebuilds. - -version: "3.8" - -services: - posterr: - image: posterrx - build: - context: . - dockerfile: ./Dockerfile - environment: - NODE_ENV: development - TZ: "Australia/Brisbane" - BASEURL: "" - ports: - - 3000:3000 - - 9229:9229 - volumes: - - ./docker-data/config:/usr/src/app/config - - ./docker-data/custom:/usr/src/app/public/custom - command: ["node", "--inspect=0.0.0.0:9229", "index.js"] - extra_hosts: - - "host.docker.internal:host-gateway" +# Debug compose — same host-gateway helper as docker-compose.yml for media servers on the host. +# Bind-mount config (cache/, ads/, ads-view/, DBs, settings) like production so data survives rebuilds. + +version: "3.8" + +services: + screenstage: + image: screenstage + build: + context: . + dockerfile: ./Dockerfile + environment: + NODE_ENV: development + TZ: "Australia/Brisbane" + BASEURL: "" + ports: + - 3000:3000 + - 9229:9229 + volumes: + - ./docker-data/config:/usr/src/app/config + - ./docker-data/custom:/usr/src/app/public/custom + command: ["node", "--inspect=0.0.0.0:9229", "index.js"] + extra_hosts: + - "host.docker.internal:host-gateway" diff --git a/docker-compose.media-servers.example.yml b/docker-compose.media-servers.example.yml index 0553955..18bf18d 100644 --- a/docker-compose.media-servers.example.yml +++ b/docker-compose.media-servers.example.yml @@ -1,12 +1,12 @@ -# Example: PosterX + Jellyfin on one Docker network. +# Example: ScreenStage + Jellyfin on one Docker network. # Copy to docker-compose.override.yml or merge into your stack, then: # docker compose -f docker-compose.yml -f docker-compose.media-servers.example.yml up -d # -# If you use this file without docker-compose.yml, add volume mounts for PosterX: +# If you use this file without docker-compose.yml, add volume mounts for ScreenStage: # config → /usr/src/app/config # config → /usr/src/app/config (includes cache/, ads/, ads-view/, poster DBs, etc.) # -# PosterX settings: +# ScreenStage settings: # Server type: Jellyfin # Host: jellyfin (service name — NOT localhost) # Port: 8096 @@ -14,16 +14,16 @@ # Libraries: names of your Jellyfin libraries (comma-separated) # # Emby: use image emby/embyserver (or linuxserver/emby), service name e.g. emby, port 8096, API key in Emby. -# Kodi: usually on the Docker host or an HTPC — in PosterX settings use host.docker.internal (with extra_hosts in +# Kodi: usually on the Docker host or an HTPC — in ScreenStage settings use host.docker.internal (with extra_hosts in # docker-compose.yml on Linux), or the host LAN IP. Port: Kodi HTTP control port (often 8080). Token: blank or user:pass. -# (Running Kodi inside Docker is uncommon; if you do, put PosterX on the same network and use the Kodi service name.) +# (Running Kodi inside Docker is uncommon; if you do, put ScreenStage on the same network and use the Kodi service name.) version: "3.8" services: - posterr: + screenstage: networks: - - posterr_media + - screenstage_media jellyfin: image: jellyfin/jellyfin:latest @@ -36,12 +36,12 @@ services: - jellyfin_cache:/cache # - /path/on/host/media:/media:ro networks: - - posterr_media + - screenstage_media volumes: jellyfin_config: jellyfin_cache: networks: - posterr_media: + screenstage_media: driver: bridge diff --git a/docker-compose.yml b/docker-compose.yml index 0596adc..1c0e2d7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,16 +1,16 @@ -# PosterX — requires Docker Engine 20.10+ for host.docker.internal (Linux). -# Jellyfin / Emby / Kodi / Plex are reached over HTTP from PosterX settings (no extra container packages). +# ScreenStage — requires Docker Engine 20.10+ for host.docker.internal (Linux). +# Jellyfin / Emby / Kodi / Plex are reached over HTTP from ScreenStage settings (no extra container packages). # # Volumes: # config/ — settings.json, cache/, ads/, ads-view/, custom-pictures/, SQLite DBs (mount whole config dir). -# Optional: public/custom — legacy uploads; on first run PosterX copies into config/ if those folders are empty. +# Optional: public/custom — legacy uploads; on first run ScreenStage copies into config/ if those folders are empty. version: "3.8" services: - posterr: - image: posterrx - container_name: posterr + screenstage: + image: screenstage + container_name: screenstage build: context: . dockerfile: ./Dockerfile @@ -20,9 +20,9 @@ services: BASEPATH: "" ET: "false" volumes: - - ~/docker/posterr/config:/usr/src/app/config + - ~/docker/screenstage/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 + - ~/docker/screenstage/custom:/usr/src/app/public/custom ports: - 9876:3000 restart: unless-stopped diff --git a/index.js b/index.js index c7de8e6..ecbaad0 100644 --- a/index.js +++ b/index.js @@ -33,6 +33,7 @@ const { getMediaServerKind, requiresMediaServerCredential, } = require("./classes/mediaservers/mediaServerFactory"); +const mediaServersUtil = require("./classes/core/mediaServers"); const posterMetadata = require("./classes/core/posterMetadataDb"); const posterSyncRetry = require("./classes/core/posterSyncRetry"); const nowShowingDb = require("./classes/core/nowShowingDb"); @@ -74,7 +75,7 @@ if(args.length == 2){ } console.log("-------------------------------------------------------"); -console.log(" POSTERX - Your media display"); +console.log(" SCREENSTAGE - Your media display"); console.log(" Developed by Matt Petersen - Brisbane Australia"); console.log(" "); console.log(" Version: " + pjson.version); @@ -423,7 +424,7 @@ if (!fs.existsSync(dir)) { fs.mkdirSync(dir); } -// Prevent multiple PosterX processes from running at the same time. +// Prevent multiple ScreenStage processes from running at the same time. const APP_LOCK_FILE = path.join(CONFIG_ROOT, "posterr-app.lock"); let appLockHeld = false; @@ -471,13 +472,13 @@ function acquireAppLockOrExit() { appLockHeld = true; console.log( new Date().toLocaleString() + - " PosterX: removed stale process lock and continued startup" + " ScreenStage: removed stale process lock and continued startup" ); return; } console.log( new Date().toLocaleString() + - " ✘✘ WARNING ✘✘ - Another PosterX instance is already running (pid " + + " ✘✘ WARNING ✘✘ - Another ScreenStage instance is already running (pid " + lockPid + "). Exiting this process." ); @@ -687,6 +688,9 @@ async function loadReadarrComingSoon() { // call radarr coming soon try { csbCards = await readarr.GetComingSoon(now, ltr, loadedSettings.hasArt); + if (Array.isArray(csbCards) && csbCards.length) { + registerArrCardsIntoPosterCache(csbCards); + } if (isReadarrUnavailable) { console.log( "✅ " + bookAppLabel + " connection restored - default poll timers restored" @@ -742,6 +746,9 @@ async function loadRadarrComingSoon() { // call radarr coming soon try { csrCards = await radarr.GetComingSoon(now, ltr, loadedSettings.genericThemes, loadedSettings.hasArt); + if (Array.isArray(csrCards) && csrCards.length) { + registerArrCardsIntoPosterCache(csrCards); + } if (isRadarrUnavailable) { console.log( "✅ Radarr connection restored - defualt poll timers restored" @@ -789,6 +796,9 @@ async function loadLidarrComingSoon() { try { cslCards = await lidarr.GetComingSoon(now, ltr, loadedSettings.hasArt); + if (Array.isArray(cslCards) && cslCards.length) { + registerArrCardsIntoPosterCache(cslCards); + } if (isLidarrUnavailable) { console.log( "✅ Lidarr connection restored - default poll timers restored" @@ -843,6 +853,9 @@ async function loadSonarrComingSoon() { loadedSettings.playThemes, loadedSettings.hasArt ); + if (Array.isArray(csCards) && csCards.length) { + registerArrCardsIntoPosterCache(csCards); + } if (isSonarrUnavailable) { console.log( @@ -1483,23 +1496,32 @@ function filterCardsByHiddenContentRatings(cards) { ); } -/** Library-style slides: cached poster DB first when enabled; live on-demand only as backup. */ +/** Library slides always come from poster cache (cache-first). Live sources only fill the cache. */ function buildLibrarySlideDeckFromPosterCache() { - // Master ON-DEMAND toggle must gate both live fetches and cache-backed library slides. if (!isOnDemandEnabled) return []; - // Recently-added day filter requires a live media-server query; the poster cache has no added dates. - if (recentlyAddedDaysActive() || !preferCachedPostersEnabled()) { - return filterCardsByHiddenContentRatings(odCards); + const displayIds = mediaServersUtil.listDisplayServerIds(loadedSettings); + // Recently-added day filter still needs live mix when active; otherwise cache-only. + if (recentlyAddedDaysActive()) { + const live = filterCardsByHiddenContentRatings(odCards); + if (live.length > 0) return live; } - const kind = loadedSettings - ? getMediaServerKind(loadedSettings.mediaServerType) - : ""; const cached = posterMetadata.buildFallbackMediaCards( primaryCachedPosterSlideCount(), - kind, - hideContentRatingsFromSettings() + null, + hideContentRatingsFromSettings(), + displayIds.length ? displayIds : null ); if (cached.length > 0) return cached; + // Empty display filter or empty DB: try all cache rows, then any live OD cards as last resort + const anyCached = posterMetadata.buildFallbackMediaCards( + primaryCachedPosterSlideCount(), + null, + hideContentRatingsFromSettings(), + null + ); + if (anyCached.length > 0 && (!displayIds.length || !cachedPosterDbHasRows())) { + return anyCached; + } return filterCardsByHiddenContentRatings(odCards); } @@ -1508,15 +1530,15 @@ function buildLibrarySlideDeckFromPosterCache() { * Now Playing / on-demand network work finishes (first paint on /posters). */ async function warmCachedPosterDeckEarlyIfPossible() { - if (!loadedSettings || !isOnDemandEnabled || !preferCachedPostersEnabled()) return; - if (recentlyAddedDaysActive()) return; + if (!loadedSettings || !isOnDemandEnabled) return; if (!cachedPosterDbHasRows()) return; - const kind = getMediaServerKind(loadedSettings.mediaServerType); + const displayIds = mediaServersUtil.listDisplayServerIds(loadedSettings); const warmCount = Math.min(12, primaryCachedPosterSlideCount()); const cached = posterMetadata.buildFallbackMediaCards( warmCount, - kind, - hideContentRatingsFromSettings() + null, + hideContentRatingsFromSettings(), + displayIds.length ? displayIds : null ); if (!cached.length) return; globalPage.cards = cached.slice(); @@ -1609,20 +1631,10 @@ async function loadNowScreening() { } // stop timers dont run if disabled - if (!isMediaServerEnabled && !skipMediaServerNowPlayingFetch) { + const nsServers = mediaServersUtil.listNowPlayingMediaServers(loadedSettings); + if (!nsServers.length && !skipMediaServerNowPlayingFetch) { nsCards = []; - return nsCards; - } - - 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, - }); + // Still assemble cache library slides below when OD enabled } let excludeLibraries; @@ -1637,20 +1649,71 @@ async function loadNowScreening() { let pollInterval = nsCheckSeconds; - // call now screening method - if (!skipMediaServerNowPlayingFetch) { + // call now screening method — merge sessions from all Enable Now Playing servers + if (!skipMediaServerNowPlayingFetch && nsServers.length) { + try { + const mergedNs = []; + let anyOk = false; + for (const server of nsServers) { + try { + const ms = mediaServersUtil.createMediaServerClient(server); + const part = await ms.GetNowScreening( + loadedSettings.playThemes, + loadedSettings.genericThemes, + loadedSettings.hasArt, + loadedSettings.filterRemote, + loadedSettings.filterLocal, + loadedSettings.filterDevices, + loadedSettings.filterUsers, + loadedSettings.hideUser, + excludeLibraries + ); + if (Array.isArray(part) && part.length) { + for (const c of part) { + if (c && typeof c === "object") { + c.posterServerId = server.id; + } + } + mergedNs.push(...part); + posterMetadata.applyCachedPostersToMediaCards( + part, + server.type, + server.id + ); + } + anyOk = true; + } catch (serverErr) { + let now = new Date(); + console.log( + now.toLocaleString() + + " *Now Playing — " + + (server.name || server.host) + + ": " + + (serverErr && serverErr.message ? serverErr.message : serverErr) + ); + } + } + nsCards = mergedNs; + if (anyOk) { + if (isMediaServerUnavailable) { + console.log( + new Date().toLocaleString() + + " ✔ Media server(s) recovered — Now Playing restored" + ); + } + isMediaServerUnavailable = false; + } else if (nsServers.length) { + isMediaServerUnavailable = true; + } + } catch (err) { + /* handled per-server */ + isMediaServerUnavailable = true; + } + } + + // legacy single-block Awtrix / error handling continues using nsCards + if (!skipMediaServerNowPlayingFetch && nsCards && nsCards.length) { 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(); @@ -1985,30 +2048,47 @@ async function loadNowScreening() { } if (isMediaServerEnabled) { - posterMetadata.registerFromMediaServerCards( - nsCards, - odCards, - getMediaServerKind(loadedSettings.mediaServerType) - ); - posterMetadata - .purgeMissingServerItems({ - currentServerKind: getMediaServerKind(loadedSettings.mediaServerType), - isMediaServerEnabled, - maxChecks: 8, - minAgeBeforeChangeCheckMins: Math.max( - 0, - parseInt(loadedSettings.posterCacheMinAgeBeforeChangeCheckMins, 10) || - 0 - ), - probeEntryGone: probePosterMetadataEntryGone, - }) - .catch(() => {}); + const byServer = new Map(); + for (const c of [].concat(nsCards || []).concat(odCards || [])) { + const sid = c && c.posterServerId ? String(c.posterServerId) : ""; + if (!sid) continue; + if (!byServer.has(sid)) byServer.set(sid, []); + byServer.get(sid).push(c); + } + const servers = mediaServersUtil.listConfiguredMediaServers(loadedSettings); + for (const server of servers) { + const cards = byServer.get(server.id) || []; + if (!cards.length) continue; + posterMetadata.registerFromMediaServerCards( + cards.filter((c) => nsCards && nsCards.includes(c)), + cards.filter((c) => odCards && odCards.includes(c)), + server.type, + server.id + ); + posterMetadata + .purgeMissingServerItems({ + currentServerKind: server.type, + currentServerId: server.id, + isMediaServerEnabled: true, + maxChecks: 8, + minAgeBeforeChangeCheckMins: Math.max( + 0, + parseInt(loadedSettings.posterCacheMinAgeBeforeChangeCheckMins, 10) || + 0 + ), + probeEntryGone: (entry) => + probePosterMetadataEntryGone(entry, server), + }) + .catch(() => {}); + } } if (globalPage.cards.length === 0) { + const displayIds = mediaServersUtil.listDisplayServerIds(loadedSettings); const cached = posterMetadata.buildFallbackMediaCards( posterMetadata.DEFAULT_FALLBACK_COUNT, - getMediaServerKind(loadedSettings.mediaServerType), - hideContentRatingsFromSettings() + null, + hideContentRatingsFromSettings(), + displayIds.length ? displayIds : null ); if (cached.length > 0) { globalPage.cards = cached; @@ -2090,14 +2170,12 @@ async function loadNowScreening() { } /** - * 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. + * On-demand / library slide pool: cache-first. Live GetOnDemand only when + * recently-added days need a live filter; otherwise sync fills the cache. + * @param {number} [numberOnDemandOverride] */ 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 = @@ -2105,46 +2183,64 @@ async function fetchOnDemandCardsFromServer(numberOnDemandOverride) { ? numberOnDemandOverride : loadedSettings.numberOnDemand; - // Prefer-cache skips live GetOnDemand — but recently-added days need a live filter. - if (preferCachedPostersEnabled() && !recentlyAddedDaysActive()) { - const kind = loadedSettings - ? getMediaServerKind(loadedSettings.mediaServerType) - : ""; - const nRaw = - typeof count === "number" ? count : parseInt(String(count), 10); - const n = !isNaN(nRaw) && nRaw > 0 ? nRaw : primaryCachedPosterSlideCount(); + const displayIds = mediaServersUtil.listDisplayServerIds(loadedSettings); + const nRaw = + typeof count === "number" ? count : parseInt(String(count), 10); + const n = !isNaN(nRaw) && nRaw > 0 ? nRaw : primaryCachedPosterSlideCount(); + + // Cache-first display path (always prefer cache when available) + if (!recentlyAddedDaysActive()) { return apply3dLibraryFlagToCards( posterMetadata.buildFallbackMediaCards( n, - kind, - hideContentRatingsFromSettings() + null, + hideContentRatingsFromSettings(), + displayIds.length ? displayIds : null ) ); } let cards = []; - if (isMediaServerEnabled) { - const PmsOd = getMediaServerClass(loadedSettings.mediaServerType); - const ms = new PmsOd({ - plexHTTPS: loadedSettings.plexHTTPS, - plexIP: loadedSettings.plexIP, - plexPort: loadedSettings.plexPort, - plexToken: loadedSettings.plexToken, - }); - cards = await ms.GetOnDemand( - loadedSettings.onDemandLibraries, - count, - loadedSettings.playThemes, - loadedSettings.genericThemes, - loadedSettings.hasArt, - loadedSettings.genres, - loadedSettings.recentlyAddedDays, - loadedSettings.contentRatings, - { - imagePull: buildImagePullOptions(), - tmdbApiKey: loadedSettings.tmdbApiKey || "", + const syncServers = mediaServersUtil.listSyncMediaServers(loadedSettings); + for (const server of syncServers) { + if (!String(server.libraries || "").trim()) continue; + try { + const ms = mediaServersUtil.createMediaServerClient(server); + const part = await ms.GetOnDemand( + server.libraries, + count, + loadedSettings.playThemes, + loadedSettings.genericThemes, + loadedSettings.hasArt, + loadedSettings.genres, + loadedSettings.recentlyAddedDays, + loadedSettings.contentRatings, + { + imagePull: buildImagePullOptions(), + tmdbApiKey: loadedSettings.tmdbApiKey || "", + } + ); + if (Array.isArray(part)) { + for (const c of part) { + if (c) c.posterServerId = server.id; + } + cards = cards.concat(part); + posterMetadata.registerFromMediaServerCards( + [], + part, + server.type, + server.id + ); } - ); + } catch (e) { + console.log( + new Date().toLocaleString() + + " *On-demand — " + + (server.name || server.host) + + ": " + + (e && e.message ? e.message : e) + ); + } } // Recently-added: also pull from *arr (import/added dates outrank cache; merge with server). @@ -2153,17 +2249,97 @@ async function fetchOnDemandCardsFromServer(numberOnDemandOverride) { const arrRa = await fetchArrRecentlyAddedCards(days); if (arrRa.length) { cards = mergeRecentlyAddedServerAndArr(cards, arrRa); - const nRaw = - typeof count === "number" ? count : parseInt(String(count), 10); + registerArrCardsIntoPosterCache(arrRa); if (!isNaN(nRaw) && nRaw > 0 && cards.length > nRaw) { cards = await util.build_random_od_set(nRaw, cards, 0); } } } + if (!cards.length) { + cards = posterMetadata.buildFallbackMediaCards( + n, + null, + hideContentRatingsFromSettings(), + displayIds.length ? displayIds : null + ); + } + return apply3dLibraryFlagToCards(cards); } +function registerArrCardsIntoPosterCache(cards) { + if (!Array.isArray(cards) || !cards.length) return; + const byKind = new Map(); + for (const c of cards) { + const kind = String((c && c.posterLibraryKind) || c._arrKind || "") + .toLowerCase() + .trim(); + let sid = ""; + let sk = ""; + const url = String((c && c.posterURL) || ""); + if (url.includes("arr-sonarr") || kind === "sonarr") { + sk = "sonarr"; + sid = mediaServersUtil.ARR_SERVER_IDS.sonarr; + } else if (url.includes("arr-radarr") || kind === "radarr") { + sk = "radarr"; + sid = mediaServersUtil.ARR_SERVER_IDS.radarr; + } else if (url.includes("arr-lidarr") || kind === "lidarr") { + sk = "lidarr"; + sid = mediaServersUtil.ARR_SERVER_IDS.lidarr; + } else if ( + url.includes("arr-readarr") || + url.includes("arr-chaptarr") || + kind === "readarr" || + kind === "chaptarr" + ) { + sk = + loadedSettings && loadedSettings.bookArrKind === "chaptarr" + ? "chaptarr" + : "readarr"; + sid = + mediaServersUtil.ARR_SERVER_IDS[sk] || + mediaServersUtil.ARR_SERVER_IDS.readarr; + } else { + continue; + } + if (!byKind.has(sid)) byKind.set(sid, { kind: sk, cards: [] }); + c.posterServerId = sid; + byKind.get(sid).cards.push(c); + } + for (const [sid, pack] of byKind) { + posterMetadata.registerFromMediaServerCards([], pack.cards, pack.kind, sid); + } +} + +async function syncArrAppsIntoPosterCache() { + try { + if (isSonarrEnabled || isRadarrEnabled || isLidarrEnabled || isReadarrEnabled) { + const days = Math.max( + parseInt(loadedSettings.sonarrCalDays, 10) || 0, + parseInt(loadedSettings.radarrCalDays, 10) || 0, + 30 + ); + const ra = await fetchArrRecentlyAddedCards(days); + if (ra.length) registerArrCardsIntoPosterCache(ra); + if (Array.isArray(csCards) && csCards.length) + registerArrCardsIntoPosterCache(csCards); + if (Array.isArray(csrCards) && csrCards.length) + registerArrCardsIntoPosterCache(csrCards); + if (Array.isArray(cslCards) && cslCards.length) + registerArrCardsIntoPosterCache(cslCards); + if (Array.isArray(csbCards) && csbCards.length) + registerArrCardsIntoPosterCache(csbCards); + } + } catch (e) { + console.log( + new Date().toLocaleString() + + " *arr cache sync: " + + (e && e.message ? e.message : e) + ); + } +} + /** * Recently imported titles from Radarr/Sonarr (*arr added/import date, not poster-cache time). */ @@ -2236,7 +2412,7 @@ async function ensureOdCardsForNowShowingFill() { loadedSettings.nowShowingFillFromServer === "true" && (Number(loadedSettings.nowShowingFillLibraryMax) || 0) > 0; if (!fillOn) return []; - if (!isMediaServerEnabled && !preferCachedPostersEnabled()) return []; + if (!isMediaServerEnabled && !cachedPosterDbHasRows()) return []; if (posterSyncProgressState.status === "running") { return Array.isArray(odCards) ? odCards : []; } @@ -2440,21 +2616,10 @@ async function checkEnabled() { isSleepEnabled = false; } - // check media server connection fields (Kodi may use empty token if HTTP auth disabled) - const _tokenOk = - !requiresMediaServerCredential(loadedSettings.mediaServerType) || - (loadedSettings.plexToken !== undefined && loadedSettings.plexToken !== ""); - if ( - loadedSettings.plexIP !== undefined && - loadedSettings.plexIP !== "" && - _tokenOk && - loadedSettings.plexPort !== undefined && - loadedSettings.plexPort !== "" - ) { - isMediaServerEnabled = true; - } else { - isMediaServerEnabled = false; - } + // Multi media-server: any configured+enabled entry counts + const configuredServers = + mediaServersUtil.listConfiguredMediaServers(loadedSettings); + isMediaServerEnabled = configuredServers.length > 0; // check Sonarr / Radarr before on-demand (recently-added can use *arr alone) if ( @@ -2479,13 +2644,12 @@ async function checkEnabled() { isRadarrEnabled = false; } - // 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). - // Recently-added days can also run from Radarr/Sonarr imports alone. + // On-demand / library deck: cache-first. Enable when OD not disabled and + // (cache has rows OR sync sources exist OR recently-added *arr). const odNotDisabled = loadedSettings.enableOD !== "false"; - const libsConfigured = - loadedSettings.onDemandLibraries !== undefined && - String(loadedSettings.onDemandLibraries || "").trim().length > 0; + const libsConfigured = mediaServersUtil + .listSyncMediaServers(loadedSettings) + .some((s) => String(s.libraries || "").trim().length > 0); const numConfigured = loadedSettings.numberOnDemand !== undefined; const arrRecentlyAddedOk = recentlyAddedDaysActive() && (isSonarrEnabled || isRadarrEnabled); @@ -2493,12 +2657,13 @@ async function checkEnabled() { isOnDemandEnabled = false; } else if (arrRecentlyAddedOk) { isOnDemandEnabled = true; - } else if (preferCachedPostersEnabled()) { + } else { isOnDemandEnabled = cachedPosterDbHasRows() || - (isMediaServerEnabled && libsConfigured); - } else { - isOnDemandEnabled = isMediaServerEnabled && libsConfigured; + (isMediaServerEnabled && libsConfigured) || + isSonarrEnabled || + isRadarrEnabled || + false; } // check Lidarr @@ -2646,24 +2811,28 @@ async function theaterOff(theater) { console.log(d.toLocaleString() + ` ** Theatre mode deactivated`); } -async function probePosterMetadataEntryGone(entry) { - if (!isMediaServerEnabled || !loadedSettings) return false; +async function probePosterMetadataEntryGone(entry, serverEntry) { + if (!loadedSettings) return false; try { - const Pms = getMediaServerClass(loadedSettings.mediaServerType); - const ms = new Pms({ - plexHTTPS: loadedSettings.plexHTTPS, - plexIP: loadedSettings.plexIP, - plexPort: loadedSettings.plexPort, - plexToken: loadedSettings.plexToken, - }); - if (typeof ms.posterMetadataEntryGone !== "function") return false; + const ms = serverEntry + ? mediaServersUtil.createMediaServerClient(serverEntry) + : newMediaServerClient(); + if (!ms || typeof ms.posterMetadataEntryGone !== "function") return false; return await ms.posterMetadataEntryGone(entry); } catch (e) { return false; } } -function newMediaServerClient() { +function newMediaServerClient(serverEntry) { + if (serverEntry) { + return mediaServersUtil.createMediaServerClient(serverEntry); + } + const list = mediaServersUtil.listConfiguredMediaServers(loadedSettings); + if (list.length > 0) { + return mediaServersUtil.createMediaServerClient(list[0]); + } + // Legacy fallback const Pms = getMediaServerClass(loadedSettings.mediaServerType); return new Pms({ plexHTTPS: loadedSettings.plexHTTPS, @@ -2749,26 +2918,55 @@ async function syncFullPosterLibraryFromMediaServer(options) { if (!syncDebugEnabled) return; console.log(new Date().toLocaleString() + " [poster sync debug] " + msg); }; - if (posterSyncProgressState.status === "running") { + + // Multi-server: when no specific serverEntry, queue each sync-enabled server then *arr + if (!(options && options.serverEntry) && !(options && options._arrOnlyPass)) { + if (posterSyncProgressState.status === "running") { + return; + } + const servers = mediaServersUtil + .listSyncMediaServers(loadedSettings) + .filter((s) => String(s.libraries || "").trim().length > 0); + if (!servers.length) { + await syncArrAppsIntoPosterCache(); + return; + } + for (const server of servers) { + if (posterSyncAbortRequested) break; + await syncFullPosterLibraryFromMediaServer( + Object.assign({}, options || {}, { serverEntry: server }) + ); + } + await syncArrAppsIntoPosterCache(); return; } - if (!loadedSettings || !isMediaServerEnabled || isMediaServerUnavailable) return; - if ( - !loadedSettings.onDemandLibraries || - !String(loadedSettings.onDemandLibraries).trim() - ) { + + if (posterSyncProgressState.status === "running" && !(options && options.serverEntry)) { return; } - const configuredNames = getConfiguredOnDemandLibraryNames( - loadedSettings.onDemandLibraries - ); + const serverEntry = + options && options.serverEntry + ? mediaServersUtil.normalizeMediaServerEntry(options.serverEntry) + : null; + if (!loadedSettings || !serverEntry) { + return; + } + const libraryCsvForServer = String( + (serverEntry && serverEntry.libraries) || + loadedSettings.onDemandLibraries || + "" + ).trim(); + if (!libraryCsvForServer) { + return; + } + const configuredNames = getConfiguredOnDemandLibraryNames(libraryCsvForServer); const singleRaw = options && options.singleLibrary != null ? String(options.singleLibrary).trim() : ""; const metadataOnly = options && options.metadataOnlySync === true; - let libraryCsv = String(loadedSettings.onDemandLibraries).trim(); + let libraryCsv = libraryCsvForServer; if (singleRaw) { const resolved = matchConfiguredLibraryName(singleRaw, configuredNames); if (!resolved) { @@ -2778,7 +2976,7 @@ async function syncFullPosterLibraryFromMediaServer(options) { } let ms; try { - ms = newMediaServerClient(); + ms = newMediaServerClient(serverEntry); } catch (e) { return; } @@ -2789,7 +2987,7 @@ async function syncFullPosterLibraryFromMediaServer(options) { posterSyncProgressState.phase = "starting"; posterSyncProgressState.label = singleRaw ? "Starting (one library)…" - : "Starting…"; + : "Starting " + (serverEntry.name || serverEntry.host) + "…"; posterSyncProgressState.processed = 0; posterSyncProgressState.total = 0; posterSyncProgressState.libraries = []; @@ -2800,9 +2998,9 @@ async function syncFullPosterLibraryFromMediaServer(options) { posterSyncProgressState.error = ""; posterSyncProgressState.startedAt = Date.now(); posterSyncProgressState.finishedAt = null; - posterSyncProgressState.serverKind = getMediaServerKind( - loadedSettings.mediaServerType - ); + posterSyncProgressState.serverKind = serverEntry.type; + posterSyncProgressState.serverId = serverEntry.id; + posterSyncProgressState.serverName = serverEntry.name || serverEntry.host; const syncStarted = Date.now(); const runIdTag = " [run " + posterSyncProgressState.runId + "]"; @@ -2811,6 +3009,8 @@ async function syncFullPosterLibraryFromMediaServer(options) { metadataOnly + ", serverKind=" + posterSyncProgressState.serverKind + + ", serverId=" + + serverEntry.id + ', libraryCsv="' + libraryCsv + '"' @@ -2821,6 +3021,8 @@ async function syncFullPosterLibraryFromMediaServer(options) { runIdTag + " " + posterSyncProgressState.serverKind + + " @" + + (serverEntry.name || serverEntry.host) + ' — libraries: "' + libraryCsv + '"' + @@ -2884,7 +3086,8 @@ async function syncFullPosterLibraryFromMediaServer(options) { : buildPosterSyncImagePullOptions(), tmdbApiKey: loadedSettings.tmdbApiKey || "", retryLibraryKeysFromLastSync: posterSyncRetry.loadRetryKeys( - posterSyncProgressState.serverKind + posterSyncProgressState.serverKind, + posterSyncProgressState.serverId ), posterSyncServerKind: posterSyncProgressState.serverKind, posterSyncAbortCheck: () => posterSyncAbortRequested, @@ -2894,10 +3097,14 @@ async function syncFullPosterLibraryFromMediaServer(options) { .toLowerCase() !== "false", onPosterSyncBatch: async (batchCards, batchMeta) => { if (!Array.isArray(batchCards) || batchCards.length === 0) return; + for (const c of batchCards) { + if (c) c.posterServerId = posterSyncProgressState.serverId; + } const rs = posterMetadata.registerFromMediaServerCards( [], batchCards, - posterSyncProgressState.serverKind + posterSyncProgressState.serverKind, + posterSyncProgressState.serverId ); streamRegisterCalls += 1; streamRegisterWritten += Number((rs && rs.written) || 0); @@ -2987,7 +3194,8 @@ async function syncFullPosterLibraryFromMediaServer(options) { regStats = posterMetadata.registerFromMediaServerCards( [], cards, - posterSyncProgressState.serverKind + posterSyncProgressState.serverKind, + posterSyncProgressState.serverId ); } try { @@ -3022,7 +3230,8 @@ async function syncFullPosterLibraryFromMediaServer(options) { cards, posterSyncProgressState.serverKind, buildImagePullOptions() - ) + ), + posterSyncProgressState.serverId ); posterSyncProgressState.status = "done"; posterSyncProgressState.phase = "complete"; @@ -3366,7 +3575,7 @@ async function startup(clearCache) { console.log(""); } else { - console.log("*** You are running the latest version of PosterX ***"); + console.log("*** You are running the latest version of ScreenStage ***"); console.log(""); } } @@ -4664,7 +4873,7 @@ app.get(BASEURL + "/ads/data", (req, res) => { } }); -// Used by the web client to check connection status to PosterX, and also to determine if there was a cold start that was missed +// Used by the web client to check connection status to ScreenStage, 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 }); @@ -4924,7 +5133,185 @@ 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/media-server"); +}); + +app.post(BASEURL + "/settings/media-server/clear-arr-cache", (req, res) => { + if (loadedSettings.password !== undefined && !userData.valid) { + return res.redirect(302, BASEURL + "/logon"); + } + const kind = String(req.body.arrKind || "").toLowerCase(); + let sid = mediaServersUtil.ARR_SERVER_IDS[kind]; + if (kind === "readarr" && loadedSettings.bookArrKind === "chaptarr") { + sid = mediaServersUtil.ARR_SERVER_IDS.chaptarr; + } + if (!sid) { + req.session.cacheClearNotice = { + ok: false, + text: "Unknown *arr kind.", + }; + return res.redirect(302, BASEURL + "/settings/media-server"); + } + try { + const r = posterMetadata.clearCacheForServerId(sid); + req.session.cacheClearNotice = { + ok: true, + text: "Cleared " + (r.removedRows || 0) + " cached *arr row(s) for " + kind + ".", + }; + } catch (e) { + req.session.cacheClearNotice = { + ok: false, + text: "Could not clear *arr cache: " + (e && e.message ? e.message : e), + }; + } + return res.redirect(302, BASEURL + "/settings/media-server"); +}); + +app.get(BASEURL + "/settings/media-server", (req, res) => { + if (loadedSettings.password !== undefined && !userData.valid) { + return res.redirect(302, BASEURL + "/logon"); + } + customPicFolders = getDirectories(CUSTOM_PICTURES_ROOT); + const cacheClearNotice = req.session.cacheClearNotice || null; + req.session.cacheClearNotice = null; + const notice = req.session.mediaServerNotice || null; + req.session.mediaServerNotice = null; + res.render("settings-media-server", { + 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, + cacheClearNotice: cacheClearNotice, + ...newFeaturesBannerViewData(), + }); +}); + +app.post(BASEURL + "/settings/media-server", async (req, res) => { + if (loadedSettings.password !== undefined && !userData.valid) { + return res.redirect(302, BASEURL + "/logon"); + } + try { + const parsed = mediaServersUtil.parseMediaServersFromFormBody(req.body); + const validated = mediaServersUtil.validateMediaServersForSave(parsed); + if (!validated.ok) { + req.session.mediaServerNotice = { + ok: false, + text: validated.errors.join(" "), + }; + return res.redirect(302, BASEURL + "/settings/media-server"); + } + + const clearRaw = String(req.body.clearCacheServerIds || "").trim(); + const clearIds = clearRaw + ? clearRaw.split(",").map((s) => s.trim()).filter(Boolean) + : []; + for (const sid of clearIds) { + try { + posterMetadata.clearCacheForServerId(sid); + } catch (e) { + console.log( + new Date().toLocaleString() + + " clearCacheForServerId failed: " + + (e && e.message ? e.message : e) + ); + } + } + + loadedSettings.mediaServers = validated.mediaServers; + mediaServersUtil.syncLegacyFlatFromMediaServers(loadedSettings); + + const refreshRaw = req.body.posterCacheRefreshMins; + const refreshN = parseInt(refreshRaw, 10); + loadedSettings.posterCacheRefreshMins = + refreshRaw === undefined || + refreshRaw === null || + refreshRaw === "" || + isNaN(refreshN) + ? 0 + : Math.max(0, refreshN); + + const minAgeRaw = req.body.posterCacheMinAgeBeforeChangeCheckMins; + const minAgeN = parseInt(minAgeRaw, 10); + loadedSettings.posterCacheMinAgeBeforeChangeCheckMins = + minAgeRaw === undefined || + minAgeRaw === null || + minAgeRaw === "" || + isNaN(minAgeN) + ? 0 + : Math.max(0, minAgeN); + + loadedSettings.preferCachedPosters = req.body.preferCachedPosters + ? "true" + : "false"; + + const slideRaw = req.body.cachedPosterSlideCount; + const slideN = parseInt(slideRaw, 10); + loadedSettings.cachedPosterSlideCount = + slideRaw === undefined || + slideRaw === null || + slideRaw === "" || + isNaN(slideN) || + slideN < 1 || + !Number.isFinite(slideN) + ? DEFAULT_SETTINGS.cachedPosterSlideCount + : Math.floor(slideN); + + await setng.UpdateSettings(loadedSettings); + + clearInterval(nowScreeningClock); + clearInterval(onDemandClock); + clearInterval(sonarrClock); + clearInterval(radarrClock); + clearInterval(readarrClock); + clearInterval(lidarrClock); + clearInterval(houseKeepingClock); + clearInterval(picturesClock); + clearInterval(triviaClock); + clearInterval(linksClock); + clearInterval(posterMetadataRefreshClock); + nsCards = []; + odCards = []; + csCards = []; + csrCards = []; + picCards = []; + adSlideCards = []; + cslCards = []; + csbCards = []; + trivCards = []; + linkCards = []; + globalPage.cards = []; + console.log( + "✘✘ WARNING ✘✘ - Restarting after media server settings save. Please wait while current jobs complete" + ); + startup(false); + + const clearedNote = clearIds.length + ? " Cleared cache for " + clearIds.length + " removed server(s)." + : ""; + req.session.mediaServerNotice = { + ok: true, + text: + "Media server settings saved (" + + validated.mediaServers.length + + " server(s)). Services are restarting." + + clearedNote, + }; + } catch (e) { + req.session.mediaServerNotice = { + ok: false, + text: + "Could not save media server settings: " + + (e && e.message ? e.message : e), + }; + } + return res.redirect(302, BASEURL + "/settings/media-server"); }); app.post(BASEURL + "/settings/new-features-acknowledge", (req, res) => { @@ -5060,9 +5447,20 @@ app.get(BASEURL + "/settings/sync", (req, res) => { message: message, updateAvailable: updateAvailable, syncNotice: syncNotice, - onDemandLibraryList: getConfiguredOnDemandLibraryNames( - loadedSettings.onDemandLibraries - ), + onDemandLibraryList: (function () { + const names = []; + const seen = new Set(); + for (const s of mediaServersUtil.listSyncMediaServers(loadedSettings)) { + for (const n of getConfiguredOnDemandLibraryNames(s.libraries)) { + const k = n.toLowerCase(); + if (seen.has(k)) continue; + seen.add(k); + names.push(n); + } + } + return names; + })(), + mediaServerList: mediaServersUtil.listConfiguredMediaServers(loadedSettings), ...newFeaturesBannerViewData(), }); }); @@ -5072,17 +5470,21 @@ app.post(BASEURL + "/settings/sync/trigger", (req, res) => { return res.redirect(302, BASEURL + "/logon"); } if (!isMediaServerEnabled) { + // Still allow *arr-only / cache sync + syncFullPosterLibraryFromMediaServer({}).catch(() => {}); req.session.syncNotice = { - ok: false, - text: "Media server is not configured or cannot be reached. Check Settings → Media server.", + ok: true, + text: "No media servers configured — syncing *arr into poster cache if available.", }; return res.redirect(302, BASEURL + "/settings/sync"); } - const libs = loadedSettings.onDemandLibraries; - if (!libs || !String(libs).trim()) { + const syncServers = mediaServersUtil + .listSyncMediaServers(loadedSettings) + .filter((s) => String(s.libraries || "").trim()); + if (!syncServers.length) { req.session.syncNotice = { ok: false, - text: "Configure on-demand library names in Settings before running a full sync.", + text: "Add library names on each media server (Settings → Media Server) before running a full sync.", }; return res.redirect(302, BASEURL + "/settings/sync"); } @@ -6074,18 +6476,6 @@ app.post( return true; }) .withMessage("'Slide Duration' is required and must be 5 or more"), - check("plexIP").not().isEmpty().withMessage("'Media server address' is required"), - check("plexPort") - .not() - .isEmpty() - .withMessage("'Media server port' is required. (setting default)") - .custom((value) => { - if (parseInt(value) === "NaN") { - throw new Error("'Media server port' must be a number"); - } - // Indicates the success of this synchronous custom validator - return true; - }), check("onDemandRefresh") .not() .isEmpty() @@ -6134,15 +6524,6 @@ app.post( // Indicates the success of this synchronous custom validator return true; }), - check("plexToken").custom((value, { req }) => { - if (!requiresMediaServerCredential(req.body.mediaServerType || "plex")) { - return true; - } - if (value === undefined || value === null || String(value).trim() === "") { - throw new Error("'Media server token / API key' is required"); - } - return true; - }), check("enableSleep") .custom((value, { req }) => { if(value == "true"){ @@ -6208,14 +6589,6 @@ app.post( shuffleSwitch: req.body.shuffleSwitch, hideSettingsLinks: req.body.hideSettingsLinks, theaterRoomMode: req.body.theaterRoomMode, - mediaServerType: req.body.mediaServerType || "plex", - plexToken: - req.body.plexToken !== undefined && req.body.plexToken !== null - ? req.body.plexToken - : "", - plexIP: req.body.plexIP, - 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, @@ -6368,28 +6741,6 @@ app.post( links: req.body.links, rotate: req.body.rotate, excludeLibs: req.body.excludeLibs, - posterCacheRefreshMins: (() => { - const raw = req.body.posterCacheRefreshMins; - if (raw === undefined || raw === null || raw === "") return 0; - const n = parseInt(raw, 10); - return isNaN(n) ? 0 : Math.max(0, n); - })(), - posterCacheMinAgeBeforeChangeCheckMins: (() => { - const raw = req.body.posterCacheMinAgeBeforeChangeCheckMins; - if (raw === undefined || raw === null || raw === "") return 0; - const n = parseInt(raw, 10); - return isNaN(n) ? 0 : Math.max(0, n); - })(), - preferCachedPosters: req.body.preferCachedPosters, - cachedPosterSlideCount: (() => { - const raw = req.body.cachedPosterSlideCount; - if (raw === undefined || raw === null || raw === "") - return DEFAULT_SETTINGS.cachedPosterSlideCount; - const n = parseInt(raw, 10); - return isNaN(n) || n < 1 || !Number.isFinite(n) - ? DEFAULT_SETTINGS.cachedPosterSlideCount - : Math.floor(n); - })(), saved: false }; diff --git a/launch-posterr.ps1 b/launch-posterr.ps1 index 0e38c3b..8ec363f 100644 --- a/launch-posterr.ps1 +++ b/launch-posterr.ps1 @@ -17,5 +17,5 @@ $env:POSTERR_SYNC_DEBUG = "true" $repoRoot = Split-Path -Parent $MyInvocation.MyCommand.Path Set-Location $repoRoot -Write-Host "Starting PosterX from $repoRoot ..." +Write-Host "Starting ScreenStage from $repoRoot ..." node ".\index.js" diff --git a/launch-posterr.sh b/launch-posterr.sh index f3accf0..cbdd375 100644 --- a/launch-posterr.sh +++ b/launch-posterr.sh @@ -10,5 +10,5 @@ export POSTERR_JELLYFIN_LIBRARY_FIRST_PAGE_TIMEOUT_MS="45000" export POSTERR_JELLYFIN_LIBRARY_PAGE_LIMIT="150" export POSTERR_SYNC_DEBUG="true" -echo "Starting PosterX from $SCRIPT_DIR ..." +echo "Starting ScreenStage from $SCRIPT_DIR ..." node "./index.js" diff --git a/myviews/ads.ejs b/myviews/ads.ejs index 03b0e24..fbe6459 100644 --- a/myviews/ads.ejs +++ b/myviews/ads.ejs @@ -4,7 +4,7 @@ - PosterX - Ads + ScreenStage - Ads diff --git a/myviews/debug.ejs b/myviews/debug.ejs index 14b9a97..48d88f8 100644 --- a/myviews/debug.ejs +++ b/myviews/debug.ejs @@ -2,7 +2,7 @@ @@ -22,18 +22,18 @@ - PosterX Debug Page + ScreenStage Debug Page -

PosterX Debug Page

+

ScreenStage Debug Page


-

Use this page to help debug any connectivity issues with PosterX

+

Use this page to help debug any connectivity issues with ScreenStage

Support (maintainer): Discord — discord.gg/AEhVjqX4Af


Software Version

-

PosterX Verson: <%=version%>

+

ScreenStage Verson: <%=version%>


Tests

Open server logs for output. Media server tests match your Server type (Plex, Jellyfin, Emby, or Kodi). On-demand test lists libraries/sources, then up to 5 titles from the first — same idea as the legacy Plex check.

diff --git a/myviews/header.ejs b/myviews/header.ejs index b8121a7..cf27029 100644 --- a/myviews/header.ejs +++ b/myviews/header.ejs @@ -2,7 +2,7 @@ @@ -48,7 +48,7 @@ <%}%> - PosterX + ScreenStage diff --git a/myviews/index.ejs b/myviews/index.ejs index 478e3fb..823d4cb 100644 --- a/myviews/index.ejs +++ b/myviews/index.ejs @@ -4,7 +4,7 @@ - PosterX + ScreenStage + + + <%if(!user.valid){%><%}%> + <%- include('new-features-banner.ejs') %> +
+ +
+ +
+
+ +
+ +
+
+

Media Servers

+

Add up to 10 of each type (Plex, Jellyfin, Emby, Kodi). Zero servers is fine — posters come from the cache (*arr and sync fill it). Enabled allows sync/Now Playing; Display includes that server’s cached posters in the slideshow.

+ <% if (notice && notice.text) { %> +
<%= notice.text %>
+ <% } %> + <% if (typeof cacheClearNotice !== 'undefined' && cacheClearNotice && cacheClearNotice.text) { %> +
<%= cacheClearNotice.text %>
+ <% } %> + +
+ + +
+ + +
+
Poster cache (global)
+

Sync fills the cache; the slideshow reads from it. Clearing all cache is under Settings → Cache / Clear below.

+
+ + +
+
+ + +
+
+
+ > + +
+
+
+ + +
+

Clear all removes every cached image and the poster metadata database.

+ + +
+

Clear only *arr-sourced cache rows/images:

+ + + + +
+ +
+ +
+
+
+
+
+
+ + + + diff --git a/myviews/settings-now-showing.ejs b/myviews/settings-now-showing.ejs index cbac267..8d1f193 100644 --- a/myviews/settings-now-showing.ejs +++ b/myviews/settings-now-showing.ejs @@ -11,7 +11,7 @@ - PosterX — Now Showing Settings + ScreenStage — Now Showing Settings @@ -20,7 +20,7 @@