mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
docs(docker): restore self-hosted PWA compose flow
* docs(docker): restore self-hosted PWA compose flow * fix(docker): supervise self-hosted runtime processes * fix(docker): exit cleanly on container stop
This commit is contained in:
1 parent
738397c9ed
commit
fd59447064
7 files changed
+422
-51
No files matched your search
@@ -55,7 +55,7 @@ The application is a cross-platform, open-source project built with Electron and
|
||||
- Polish
|
||||
- Custom "User Agent" header configuration for playlists
|
||||
- Light and Dark themes
|
||||
- Docker version available for self-hosting
|
||||
- Docker image available for self-hosting the PWA and web backend together
|
||||
|
||||
## Screenshots:
|
||||
|
||||
@@ -79,6 +79,22 @@ The application is a cross-platform, open-source project built with Electron and
|
||||
|
||||
_Note: First version of the application which was developed as a PWA is available in an extra git branch._
|
||||
|
||||
## Self-hosted PWA
|
||||
|
||||
The Docker setup builds the Angular PWA and the monorepo web backend into one
|
||||
image. The backend handles remote M3U parsing plus Xtream and Stalker proxy
|
||||
requests under `/api`, so a separate `4gray/iptvnator-backend` container is not
|
||||
required for the default self-hosted flow.
|
||||
|
||||
```bash
|
||||
docker compose -f docker/docker-compose.yml up --build -d
|
||||
```
|
||||
|
||||
The application is available at <http://localhost:4333>. See
|
||||
[`docker/docker-compose.yml`](./docker/docker-compose.yml) for the ready-to-run
|
||||
compose file and [`docker/README.md`](./docker/README.md) for environment
|
||||
variables, reverse proxy notes, and build details.
|
||||
|
||||
## Download
|
||||
|
||||
Download the latest version of the application for macOS, Windows, and Linux from the [release page](https://github.com/4gray/iptvnator/releases).
|
||||
|
||||
+25
-17
@@ -1,32 +1,40 @@
|
||||
# Stage 1 - build environment
|
||||
FROM node:22-alpine AS build
|
||||
|
||||
RUN apk add --no-cache python3 make g++ git
|
||||
|
||||
# Create app directory
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
# Swtich to node user
|
||||
#RUN chown node:node ./
|
||||
#USER node
|
||||
|
||||
COPY .npmrc ./
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
COPY patches ./patches
|
||||
|
||||
# Install app dependencies
|
||||
RUN corepack enable && pnpm install --frozen-lockfile
|
||||
RUN corepack enable && pnpm install --frozen-lockfile --ignore-scripts
|
||||
|
||||
# Copy all required files
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
RUN pnpm run build:web
|
||||
RUN pnpm nx build web --configuration=pwa
|
||||
RUN pnpm nx build web-backend
|
||||
|
||||
# Stage 2 - the production environment
|
||||
FROM nginx:stable-alpine
|
||||
FROM node:22-alpine
|
||||
|
||||
# Copy artifacts and nignx.conf
|
||||
COPY --from=build /usr/src/app/dist/browser /usr/share/nginx/html
|
||||
COPY --from=build /usr/src/app/docker/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
RUN apk add --no-cache gettext nginx
|
||||
|
||||
CMD sed -i "s#http://localhost:3333#$BACKEND_URL#g" /usr/share/nginx/html/main.js && nginx -g 'daemon off;'
|
||||
WORKDIR /opt/iptvnator
|
||||
|
||||
ENV PORT=3000
|
||||
ENV BACKEND_URL=/api
|
||||
ENV CLIENT_URL=http://localhost:4333
|
||||
|
||||
COPY --from=build /usr/src/app/dist/apps/web /usr/share/nginx/html
|
||||
COPY --from=build /usr/src/app/dist/apps/web-backend ./web-backend
|
||||
COPY docker/nginx.conf /etc/nginx/http.d/default.conf.template
|
||||
COPY docker/docker-entrypoint.sh /usr/local/bin/iptvnator-entrypoint
|
||||
|
||||
RUN chmod +x /usr/local/bin/iptvnator-entrypoint
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD wget -qO- http://127.0.0.1/api/health >/dev/null || exit 1
|
||||
|
||||
CMD ["iptvnator-entrypoint"]
|
||||
+81
-11
@@ -1,20 +1,90 @@
|
||||
# Self-hosted version of IPTVnator
|
||||
# Self-hosted IPTVnator
|
||||
|
||||
You can deploy and run the PWA version of IPTVnator on your own machine with `docker-compose` using the following command:
|
||||
The self-hosted image contains both pieces required for the browser PWA:
|
||||
|
||||
$ cd docker
|
||||
$ docker-compose up -d
|
||||
- Angular PWA static files served by nginx
|
||||
- The monorepo `web-backend` Express app proxied under `/api`
|
||||
|
||||
This command will launch the frontend and backend applications. By default, the application will be available at: http://localhost:4333/. The ports can be configured in the `docker-compose.yml` file.
|
||||
The historical standalone `4gray/iptvnator-backend` image is no longer needed
|
||||
for the default Docker deployment.
|
||||
|
||||
The web backend proxy accepts only `http` and `https` provider URLs. The PWA first registers provider URLs through `/provider-targets`, then uses the returned `targetId` for playlist, Xtream, and Stalker proxy calls. The backend blocks loopback, private, link-local, and reserved network targets by default to avoid exposing the self-hosted server as a generic internal-network fetcher. If you intentionally need to test against local mock servers or LAN-only IPTV sources, set `IPTVNATOR_PROXY_ALLOW_PRIVATE_NETWORKS=1` on the backend container and avoid exposing that instance to untrusted users.
|
||||
## Run With Docker Compose
|
||||
|
||||
For providers that use private certificate authorities, keep TLS validation enabled and pass the CA bundle to Node with `NODE_EXTRA_CA_CERTS=/path/to/ca.pem`.
|
||||
From the repository root:
|
||||
|
||||
## Build frontend
|
||||
```bash
|
||||
docker compose -f docker/docker-compose.yml up --build -d
|
||||
```
|
||||
|
||||
$ docker build -t 4gray/iptvnator -f docker/Dockerfile .
|
||||
The ready-to-run compose file is [`docker-compose.yml`](./docker-compose.yml).
|
||||
By default the app is available at <http://localhost:4333>. No additional
|
||||
environment variables, backend repository checkout, or separate backend
|
||||
container are required for the default local deployment.
|
||||
|
||||
## Build backend
|
||||
## Build The Image
|
||||
|
||||
You can find the backend app with all instructions in a separate GitHub repository - https://github.com/4gray/iptvnator-backend
|
||||
```bash
|
||||
docker build -t 4gray/iptvnator -f docker/Dockerfile .
|
||||
```
|
||||
|
||||
The image build runs:
|
||||
|
||||
```bash
|
||||
pnpm nx build web --configuration=pwa
|
||||
pnpm nx build web-backend
|
||||
```
|
||||
|
||||
## Runtime Configuration
|
||||
|
||||
The container writes `/usr/share/nginx/html/assets/app-config.js` on startup.
|
||||
That file sets `window.__IPTVNATOR_CONFIG__.BACKEND_URL`, which the PWA reads
|
||||
before it creates `PwaService`.
|
||||
|
||||
These variables are supported by the Docker image. The compose file sets the
|
||||
safe local defaults shown below.
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
| ---------------------------------------- | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `BACKEND_URL` | `/api` | Browser-facing backend URL used by the PWA. Keep `/api` for the bundled nginx proxy. |
|
||||
| `CLIENT_URL` | `http://localhost:4333` | Allowed browser origin for backend CORS. Use the public URL when hosting behind a reverse proxy. Multiple origins can be comma-separated. |
|
||||
| `PORT` | `3000` | Internal Express backend port. nginx proxy config is rendered from the template to match it at startup. |
|
||||
| `IPTVNATOR_PROXY_ALLOW_PRIVATE_NETWORKS` | `0` | Set to `1` or `true` only for trusted local/LAN deployments that intentionally proxy private network IPTV or mock endpoints. |
|
||||
| `NODE_EXTRA_CA_CERTS` | unset | Optional Node.js CA bundle path for providers using private certificate authorities. Mount the CA file into the container and set this path. |
|
||||
|
||||
The web backend proxy accepts only `http` and `https` provider URLs. The PWA
|
||||
first registers provider URLs through `/provider-targets`, then uses the
|
||||
returned `targetId` for playlist, Xtream, and Stalker proxy calls. The backend
|
||||
blocks loopback, private, link-local, and reserved network targets by default so
|
||||
a publicly exposed instance cannot be used as a generic internal-network
|
||||
fetcher. If you enable `IPTVNATOR_PROXY_ALLOW_PRIVATE_NETWORKS=1`, keep the
|
||||
instance restricted to trusted users.
|
||||
|
||||
For providers that use private certificate authorities, keep TLS validation
|
||||
enabled and pass the CA bundle to Node:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
iptvnator:
|
||||
volumes:
|
||||
- ./ca.pem:/etc/ssl/private/provider-ca.pem:ro
|
||||
environment:
|
||||
NODE_EXTRA_CA_CERTS: /etc/ssl/private/provider-ca.pem
|
||||
```
|
||||
|
||||
The entrypoint renders the nginx config from `docker/nginx.conf`, starts the
|
||||
backend, waits for `/health`, and only then starts nginx. The nginx config
|
||||
serves the PWA with SPA fallback, avoids caching `assets/app-config.js`, and
|
||||
proxies `/api/*` to the internal backend. The Dockerfile and compose file both
|
||||
define a health check against `/api/health`. If nginx or the backend exits
|
||||
after startup, the entrypoint exits the container so Docker Compose can apply
|
||||
the `restart: unless-stopped` policy.
|
||||
|
||||
## Local Validation
|
||||
|
||||
```bash
|
||||
pnpm nx test web-backend
|
||||
pnpm nx build web --configuration=pwa --skip-nx-cache
|
||||
pnpm nx build web-backend
|
||||
pnpm nx run web-e2e:e2e -- --project=chromium --grep @self-hosted
|
||||
docker compose -f docker/docker-compose.yml config
|
||||
```
|
||||
+15
-12
@@ -1,18 +1,21 @@
|
||||
---
|
||||
services:
|
||||
backend:
|
||||
image: 4gray/iptvnator-backend:latest
|
||||
ports:
|
||||
- "7333:3000"
|
||||
environment:
|
||||
- CLIENT_URL=http://localhost:4333
|
||||
# this one should match with the address and port in frontend CLIENT_URL env
|
||||
|
||||
frontend:
|
||||
iptvnator:
|
||||
image: 4gray/iptvnator:latest
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "4333:80"
|
||||
environment:
|
||||
- BACKEND_URL=http://localhost:7333
|
||||
# this one should match with the address of the backend service
|
||||
|
||||
BACKEND_URL: /api
|
||||
CLIENT_URL: http://localhost:4333
|
||||
PORT: "3000"
|
||||
IPTVNATOR_PROXY_ALLOW_PRIVATE_NETWORKS: "0"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/api/health >/dev/null || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
@@ -0,0 +1,109 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
export PORT="${PORT:-3000}"
|
||||
export BACKEND_URL="${BACKEND_URL:-/api}"
|
||||
|
||||
envsubst '${PORT}' < /etc/nginx/http.d/default.conf.template > /etc/nginx/http.d/default.conf
|
||||
|
||||
node <<'NODE'
|
||||
const fs = require('node:fs');
|
||||
|
||||
fs.writeFileSync(
|
||||
'/usr/share/nginx/html/assets/app-config.js',
|
||||
`window.__IPTVNATOR_CONFIG__ = ${JSON.stringify(
|
||||
{ BACKEND_URL: process.env.BACKEND_URL || '/api' },
|
||||
null,
|
||||
2
|
||||
)};\n`
|
||||
);
|
||||
NODE
|
||||
|
||||
node /opt/iptvnator/web-backend/main.cjs &
|
||||
BACKEND_PID=$!
|
||||
|
||||
cleanup_done=0
|
||||
cleanup() {
|
||||
if [ "$cleanup_done" -eq 1 ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
cleanup_done=1
|
||||
trap - INT TERM EXIT
|
||||
|
||||
if [ -n "${NGINX_PID:-}" ]; then
|
||||
kill "$NGINX_PID" 2>/dev/null || true
|
||||
wait "$NGINX_PID" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
if [ -n "${BACKEND_PID:-}" ]; then
|
||||
kill "$BACKEND_PID" 2>/dev/null || true
|
||||
wait "$BACKEND_PID" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
handle_signal() {
|
||||
cleanup
|
||||
exit 0
|
||||
}
|
||||
|
||||
process_is_running() {
|
||||
if [ ! -r "/proc/$1/stat" ]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
state="$(awk '{ print $3 }' "/proc/$1/stat" 2>/dev/null || true)"
|
||||
[ -n "$state" ] && [ "$state" != "Z" ]
|
||||
}
|
||||
|
||||
trap handle_signal INT TERM
|
||||
trap cleanup EXIT
|
||||
|
||||
backend_ready=0
|
||||
|
||||
for _ in $(seq 1 30); do
|
||||
if wget -qO- "http://127.0.0.1:${PORT}/health" >/dev/null 2>&1; then
|
||||
backend_ready=1
|
||||
break
|
||||
fi
|
||||
|
||||
if ! process_is_running "$BACKEND_PID"; then
|
||||
set +e
|
||||
wait "$BACKEND_PID"
|
||||
BACKEND_STATUS=$?
|
||||
set -e
|
||||
exit "$BACKEND_STATUS"
|
||||
fi
|
||||
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if [ "$backend_ready" -ne 1 ]; then
|
||||
echo "IPTVnator web backend did not become healthy on port ${PORT}."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
nginx -g 'daemon off;' &
|
||||
NGINX_PID=$!
|
||||
|
||||
while :; do
|
||||
if ! process_is_running "$BACKEND_PID"; then
|
||||
set +e
|
||||
wait "$BACKEND_PID"
|
||||
EXIT_STATUS=$?
|
||||
set -e
|
||||
echo "IPTVnator web backend exited with status ${EXIT_STATUS}."
|
||||
exit "$EXIT_STATUS"
|
||||
fi
|
||||
|
||||
if ! process_is_running "$NGINX_PID"; then
|
||||
set +e
|
||||
wait "$NGINX_PID"
|
||||
EXIT_STATUS=$?
|
||||
set -e
|
||||
echo "nginx exited with status ${EXIT_STATUS}."
|
||||
exit "$EXIT_STATUS"
|
||||
fi
|
||||
|
||||
sleep 1
|
||||
done
|
||||
+25
-10
@@ -1,11 +1,26 @@
|
||||
server {
|
||||
listen 80;
|
||||
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html index.htm;
|
||||
try_files $uri $uri/ /index.html =404;
|
||||
}
|
||||
|
||||
include /etc/nginx/extra-conf.d/*.conf;
|
||||
}
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name localhost;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:${PORT}/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /assets/app-config.js {
|
||||
add_header Cache-Control "no-store";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
# PWA Self-hosted Architecture
|
||||
|
||||
This document describes the browser PWA and self-hosted Docker path.
|
||||
|
||||
## Ownership
|
||||
|
||||
- `apps/web` owns the Angular browser UI and PWA service worker configuration.
|
||||
- `apps/web-backend` owns the browser-only backend proxy for remote playlist,
|
||||
Xtream, and Stalker requests.
|
||||
- `docker/` owns the production self-hosted image that bundles the PWA and
|
||||
`web-backend` into one container.
|
||||
|
||||
The old external `4gray/iptvnator-backend` repository is not required for the
|
||||
default self-hosted deployment. Sync behavior from that repository only when a
|
||||
change intentionally restores or imports missing backend capabilities.
|
||||
|
||||
## Runtime Backend Configuration
|
||||
|
||||
The PWA reads `window.__IPTVNATOR_CONFIG__.BACKEND_URL` through
|
||||
`apps/web/src/app/services/runtime-config.ts`. The static placeholder lives at
|
||||
`apps/web/src/assets/app-config.js` and keeps hosted builds working without
|
||||
Docker-specific values.
|
||||
|
||||
The Docker entrypoint rewrites `assets/app-config.js` at container startup.
|
||||
`ngsw-config.json` explicitly excludes this file from Angular service worker
|
||||
asset hashing so a runtime rewrite does not break cache validation.
|
||||
|
||||
## Service Worker Build
|
||||
|
||||
Use the PWA build configuration for browser deployments:
|
||||
|
||||
```bash
|
||||
pnpm nx build web --configuration=pwa
|
||||
```
|
||||
|
||||
The build must emit these files in `dist/apps/web`:
|
||||
|
||||
- `ngsw-worker.js`
|
||||
- `ngsw.json`
|
||||
- `safety-worker.js`
|
||||
- `worker-basic.min.js`
|
||||
|
||||
`web:serve-static` serves `dist/apps/web` and builds with `web:build:pwa`, so it
|
||||
exercises the same output layout as Docker. If Nx daemon state returns stale
|
||||
service worker outputs while changing build options, run:
|
||||
|
||||
```bash
|
||||
pnpm nx reset
|
||||
pnpm nx build web --configuration=pwa --skip-nx-cache
|
||||
```
|
||||
|
||||
## Web Backend
|
||||
|
||||
The current self-hosted PWA uses these `apps/web-backend` routes:
|
||||
|
||||
- `GET /health`
|
||||
- `GET /config.js`
|
||||
- `POST /provider-targets` with `{ "url": "<provider-url>" }`
|
||||
- `GET /parse?targetId=<id>`
|
||||
- `GET /xtream?targetId=<id>&username=<u>&password=<p>&action=<action>`
|
||||
- `GET /stalker?targetId=<id>&macAddress=<mac>&action=<action>`
|
||||
|
||||
The PWA continues to use `PwaService`; only the backend base URL is resolved at
|
||||
runtime. Electron routes remain owned by the Electron backend and preload
|
||||
bridge.
|
||||
|
||||
EPG/XMLTV is not a supported self-hosted PWA capability yet. Do not document it
|
||||
as part of the Docker user flow or use it as the readiness signal for this path.
|
||||
|
||||
Provider URLs are registered before proxy calls so the proxy endpoints do not
|
||||
accept raw target URLs in query strings. Registration validates the target URL
|
||||
before any outbound request:
|
||||
|
||||
- only `http:` and `https:` provider URLs are accepted
|
||||
- URL credentials are rejected
|
||||
- loopback, private, link-local, and reserved network targets are blocked by
|
||||
default
|
||||
- `IPTVNATOR_PROXY_ALLOW_PRIVATE_NETWORKS=1` explicitly enables trusted
|
||||
local/LAN targets for development, mock servers, or private deployments
|
||||
|
||||
Do not disable TLS certificate validation in the backend proxy. For private
|
||||
certificate authorities, configure Node with `NODE_EXTRA_CA_CERTS`.
|
||||
|
||||
## PWA Portal User Data
|
||||
|
||||
Xtream favorites and recently viewed items use the browser-side
|
||||
`PwaXtreamDataSource` when Electron DB preload APIs are unavailable. The PWA
|
||||
stores this user activity in localStorage:
|
||||
|
||||
- `xtream-favorites`
|
||||
- `xtream-recent-items`
|
||||
|
||||
Entries should include a content snapshot when the item is added. Global
|
||||
collection routes and the dashboard can then restore titles, posters, content
|
||||
type, and category IDs after navigation or a page reload without relying on the
|
||||
Electron SQLite content table.
|
||||
|
||||
Shared collection services must not import `@iptvnator/portal/xtream/data-access`
|
||||
directly. Use `XTREAM_COLLECTION_DATA_SOURCE` from
|
||||
`@iptvnator/portal/shared/util` and bind it to `XTREAM_DATA_SOURCE` at the app
|
||||
provider boundary (`apps/web/src/app/app.config.ts`). This keeps
|
||||
`portal-shared-util` provider-neutral and avoids adding new Nx boundary cycles.
|
||||
|
||||
## Docker Runtime
|
||||
|
||||
The Docker image has two stages:
|
||||
|
||||
1. Build stage installs dependencies and runs `web:pwa` plus `web-backend`.
|
||||
2. Runtime stage uses `node:22-alpine` with nginx installed. nginx serves
|
||||
`dist/apps/web` and proxies `/api/*` to the local Express backend.
|
||||
The entrypoint renders the nginx config from a `${PORT}` template, starts the
|
||||
backend, waits for `/health`, and then starts nginx. If either process exits
|
||||
after startup, the entrypoint exits the container so the compose restart
|
||||
policy can recover the service.
|
||||
|
||||
Default runtime values:
|
||||
|
||||
- `BACKEND_URL=/api`
|
||||
- `CLIENT_URL=http://localhost:4333`
|
||||
- `PORT=3000`
|
||||
- `IPTVNATOR_PROXY_ALLOW_PRIVATE_NETWORKS=0`
|
||||
- `NODE_EXTRA_CA_CERTS` unset
|
||||
|
||||
When hosting behind another domain, set `CLIENT_URL` to the browser origin and
|
||||
keep `BACKEND_URL=/api` unless the reverse proxy exposes the backend elsewhere.
|
||||
Only set `IPTVNATOR_PROXY_ALLOW_PRIVATE_NETWORKS=1` when the self-hosted
|
||||
instance is restricted to trusted users and intentionally needs private network
|
||||
IPTV targets. For providers using private certificate authorities, mount the CA
|
||||
bundle into the container and set `NODE_EXTRA_CA_CERTS` to that mounted path.
|
||||
|
||||
## Validation
|
||||
|
||||
Use the narrow validation ladder for self-hosted changes:
|
||||
|
||||
```bash
|
||||
pnpm nx test web-backend
|
||||
pnpm nx test web --runTestsByPath apps/web/src/app/services/runtime-config.spec.ts
|
||||
pnpm nx build web --configuration=pwa --skip-nx-cache
|
||||
pnpm nx build web-backend
|
||||
pnpm nx run web-e2e:e2e -- --project=chromium --grep @self-hosted
|
||||
docker compose -f docker/docker-compose.yml config
|
||||
```
|
||||
|
||||
Run `docker build -t iptvnator:self-hosted-test -f docker/Dockerfile .` when a
|
||||
Docker daemon is available.
|
||||
|
||||
For manual Docker smoke testing, run the Xtream and Stalker mock servers plus a
|
||||
small M3U fixture, then verify in the browser that M3U, Xtream, and Stalker can
|
||||
add sources, play an item, toggle favorites, populate global favorites,
|
||||
populate recently viewed, and appear on the dashboard rails.
|
||||
Reference in new issue
Block a user