Merge feature/hw-transcoding into main

This commit is contained in:
Trevor Mears
2026-01-18 20:07:37 -08:00
43 changed files with 4445 additions and 431 deletions
+1
View File
@@ -8,4 +8,5 @@ coverage/
.DS_Store
dist/
release/
transcode-cache/
+39 -6
View File
@@ -1,8 +1,41 @@
FROM node:20-alpine
# NodeCast TV Docker Image
#
# Uses system FFmpeg with hardware acceleration drivers installed.
#
# Hardware acceleration:
# - VAAPI (Intel/AMD): Mount /dev/dri and add video/render groups
# - NVIDIA NVENC: Requires nvidia-container-toolkit on host + --gpus flag
# - Intel QSV: Mount /dev/dri
#
# Build: docker compose build
# Run with VAAPI: docker run --device /dev/dri:/dev/dri --group-add video ...
# Run with NVENC: docker run --gpus all ...
# Install build dependencies for better-sqlite3 and FFmpeg for streaming
# Using system FFmpeg instead of ffmpeg-static for better Docker DNS compatibility
RUN apk add --no-cache python3 make g++ ffmpeg
FROM node:20-slim
# Install FFmpeg, build dependencies, and hardware acceleration drivers
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
# FFmpeg with full codec support
ffmpeg \
# Build dependencies for better-sqlite3
python3 \
make \
g++ \
# VAAPI runtime libraries (AMD/Intel)
libva2 \
libva-drm2 \
libvdpau1 \
mesa-va-drivers \
# Intel VAAPI/QSV drivers
intel-media-va-driver \
i965-va-driver \
# VA-API utilities for debugging
vainfo \
&& rm -rf /var/lib/apt/lists/*
# Verify FFmpeg installed and show version
RUN ffmpeg -version && ffmpeg -encoders 2>/dev/null | grep -E "vaapi|nvenc|qsv|libx264" | head -10
WORKDIR /app
@@ -15,8 +48,8 @@ RUN npm ci --only=production
# Copy application files
COPY . .
# Create data directory
RUN mkdir -p /app/data
# Create data and cache directories
RUN mkdir -p /app/data /app/transcode-cache && chmod 777 /app/transcode-cache
# Expose port
EXPOSE 3000
+111 -112
View File
@@ -13,22 +13,26 @@ nodecast-tv is a modern, web-based IPTV player featuring Live TV, EPG, Movies (V
- **🎬 VOD Support**: Dedicated sections for Movies and TV Series with rich metadata, posters, and seasonal episode lists.
- **❤️ Favorites System**: Unified favorites for channels, movies, and series with instant synchronization.
- **🔐 Authentication**: User login system with admin and viewer roles ([details](https://github.com/technomancer702/nodecast-tv/pull/23)).
- **🆔 OIDC SSO**: Support for Single Sign-On via OIDC providers (Authentik, Keycloak, etc.).
- **⚡ High Performance**: Optimized for large playlists (7000+ channels) using virtual scrolling and batch rendering.
- **⚙️ Management**:
- Support for Xtream Codes and M3U playlists.
- Manage hidden content categories.
- Playback preferences (volume memory, auto-play).
- **🔊 Audio Transcoding**: Optional FFmpeg-based audio transcoding for Dolby/AC3/EAC3 compatibility.
- **📦 Stream Remux**: Lightweight FFmpeg remux for raw MPEG-TS streams from IPTV middleware.
- **🎛️ Hardware Transcoding**: GPU-accelerated transcoding with NVIDIA NVENC, AMD AMF, Intel QuickSync, and VAAPI support.
- **🔊 Smart Audio**: Configurable 5.1→Stereo downmix presets (ITU, Night Mode, Cinematic) with automatic passthrough for compatible sources.
- **📦 Stream Processing**: Auto-detection of stream codecs with smart remux/transcode decisions.
- **🐳 Docker Ready**: Easy deployment containerization.
## Screenshots
<div align="center">
<img src="public/img/screenshots/screenshot-2.png" width="45%" alt="Screenshot 2" />
<img src="public/img/screenshots/screenshot-1.png" width="45%" alt="Screenshot 1" />
<img src="public/img/screenshots/screenshot-3.png" width="45%" alt="Screenshot 3" />
<img src="public/img/screenshots/screenshot-4.png" width="45%" alt="Screenshot 4" />
<img src="public/img/screenshots/screenshot-dashboard.png" width="45%" alt="Dashboard" />
<img src="public/img/screenshots/screenshot-1.png" width="45%" alt="Live TV" />
<img src="public/img/screenshots/screenshot-2.png" width="45%" alt="TV Guide" />
<img src="public/img/screenshots/screenshot-3.png" width="45%" alt="Movies" />
<img src="public/img/screenshots/screenshot-4.png" width="45%" alt="Series" />
<img src="public/img/screenshots/screenshot-settings.png" width="45%" alt="Settings" />
</div>
## Getting Started
@@ -86,6 +90,49 @@ You can run nodecast-tv easily using Docker.
The application will be available at `http://localhost:3000`.
### Hardware Acceleration Setup
To enable hardware transcoding (NVENC, QSV, VAAPI), you must expose your host's GPU to the container.
**1. Intel (QSV) & AMD (VAAPI)**
Update your `docker-compose.yml` to map the DRI devices and add necessary groups (often required for permission):
```yaml
devices:
- /dev/dri:/dev/dri # Required for VAAPI/QuickSync/AMF (Linux)
# group_add: # Optional: Needed mainly if you run as non-root
# - "video" # Run on host: getent group video
# - "render" # Run on host: getent group render
```
**2. NVIDIA (NVENC)**
Ensure you have the [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/install-guide.html) installed on your host, then update your `docker-compose.yml`:
```yaml
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
```
**Verify:**
After restarting the container, go to **Settings -> Transcoding**. The **Hardware Detection** status should list your GPU (e.g., "NVIDIA GPU Detected" or "VAAPI Available").
### SSO / OIDC Setup
Enable Single Sign-On (SSO) with your preferred OIDC provider (Authentik, Keycloak, etc.) by configuring these variables in your `.env` file or Docker environment:
```env
OIDC_ISSUER_URL=https://your-idp.com/application/o/nodecast/
OIDC_CLIENT_ID=your_client_id
OIDC_CLIENT_SECRET=your_client_secret
OIDC_CALLBACK_URL=http://localhost:3000/api/auth/oidc/callback # Adjust for your domain
```
**Note:** New users signing in via SSO are automatically assigned the **Viewer** role. You must manually promote them to Admin if desired.
### Usage
1. Go to **Settings** -> **Content Sources**.
@@ -94,42 +141,24 @@ The application will be available at `http://localhost:3000`.
4. Navigate to **Live TV**, **Movies**, or **Series** to browse your content.
## Browser Codec Support
## Browser Codec Support & Transcoding
nodecast-tv is a web-based application, which means **video decoding is handled by your browser**, not by the server. The server simply proxies the stream data - it does not transcode or re-encode video.
nodecast-tv is a web-based application. By default, **video decoding is handled by your browser**. However, the built-in **smart transcoding system** automatically converts incompatible media (e.g., HEVC video, Dolby audio) into browser-friendly formats using FFmpeg.
This means codec support depends entirely on what your browser can decode natively:
**Codec Compatibility Table:**
| Codec | Chrome | Firefox | Safari | Edge |
|-------|--------|---------|--------|------|
| **H.264 (AVC)** | ✅ | ✅ | ✅ | ✅ |
| **H.265 (HEVC)** | ❌¹ | ❌ | ✅ | ⚠️² |
| **VP9** | ✅ | ✅ | ⚠️³ | ✅ |
| **AV1** | ✅ | ✅ | ❌ | ✅ |
| **H.265 (HEVC)** | Auto-Transcode | Auto-Transcode | ✅ | ⚠️ |
| **AV1** | ✅ | ✅ | Auto-Transcode | ✅ |
| **AAC Audio** | ✅ | ✅ | ✅ | ✅ |
| **AC3/EAC3 (Dolby)** | ❌ | ❌ | ✅ | ❌ |
| **MP3 Audio** | ✅ | ✅ | ✅ | ✅ |
| **AC3/EAC3 (Dolby)** | Auto-Transcode | Auto-Transcode | ✅ | Auto-Transcode |
**Notes:**
1. Chrome may support HEVC on macOS with hardware decoder
2. Edge requires the paid "HEVC Video Extensions" from Microsoft Store ($0.99)
3. Safari VP9 support varies by device/version
> **⚠️ Note:** Edge requires the [HEVC Video Extensions](https://apps.microsoft.com/store/detail/hevc-video-extensions/9NMZLZ57R3T7) from the Microsoft Store to play H.265 (HEVC) natively.
> **ℹ️ Note:** Safari plays AV1 natively on supported hardware (iPhone 15 Pro, M3 Macs). On older devices, Auto-Transcode handles it.
**If a stream doesn't play:**
- The stream codec may not be supported by your browser
- Try a different browser (Safari for HEVC/Dolby, Chrome/Edge for VP9/AV1)
- Check if your IPTV provider offers alternative stream formats
- For Dolby audio issues, enable **"Force Audio Transcode"** in Settings → Streaming
### Audio Transcoding
For streams with Dolby Digital (AC3/EAC3) audio that browsers can't decode natively:
1. Install FFmpeg support: `npm install ffmpeg-static` (included as optional dependency)
2. Enable **"Force Audio Transcode"** in Settings → Streaming
3. Audio will be transcoded to AAC while video passes through unchanged
**Note:** For ad-stitched live streams (like Pluto TV), transcoding may struggle to keep up. These streams typically use AAC audio already, so the built-in HLS discontinuity handling manages audio transitions without transcoding.
## Supported Stream Types
@@ -140,18 +169,45 @@ nodecast-tv is optimized for **HLS (HTTP Live Streaming)**.
- **⚠️ High Latency/P2P**: For sources like Acestream, prefer HLS output (`.m3u8`) over raw TS streams to avoid timeouts during buffering.
- **❌ RTMP/RTSP**: Not supported natively by browsers.
## Transcoding Settings
All transcoding and stream processing settings are found in **Settings → Transcoding**.
## Streaming Settings
### Hardware Encoder
All streaming settings are found in **Settings → Player → Streaming**.
| Setting | Options | Description |
|---------|---------|-------------|
| **Hardware Encoder** | Auto, NVENC, AMF, QSV, VAAPI, Software | GPU-accelerated encoding. Auto detects best available. |
| **Max Resolution** | 4K, 1080p, 720p, 480p | Limit output resolution (lower = faster). |
| **Quality Preset** | High, Medium, Low | Encoding quality/speed tradeoff. |
| **Audio Mix Preset** | Auto, ITU, Night Mode, Cinematic, Passthrough | 5.1→Stereo downmix mode (see below). |
### Audio Mix Presets
| Preset | Description |
|--------|-------------|
| **Auto (Smart)** | Copies stereo AAC as-is, uses ITU downmix for 5.1+ |
| **ITU-R BS.775** | Industry-standard balanced downmix |
| **Night Mode** | Boosted dialogue, reduced bass for quiet viewing |
| **Cinematic** | Wide soundstage, immersive surround feel |
| **Passthrough** | No processing (may cause errors on 5.1/Dolby sources) |
### Stream Processing
| Setting | What It Does | When to Enable |
|---------|--------------|----------------|
| **Force Backend Proxy** | Routes streams through the nodecast-tv server, adding proper CORS headers | When streams fail with "Access-Control-Allow-Origin" errors, or when using IPTV middleware |
| **Force Audio Transcode** | Transcodes audio to AAC using FFmpeg (video passes through unchanged) | When you have video but no audio (Dolby/AC3/EAC3 streams) |
| **Force Remux** | Remuxes MPEG-TS to MP4 container using FFmpeg (no re-encoding, very lightweight) | When using raw `.ts` streams from m3u-editor, dispatcharr, or similar middleware |
| **Stream Output Format** | Controls whether Xtream API requests use HLS (.m3u8) or TS format | Try TS if you experience buffering issues with HLS |
| **Auto Transcode (Smart)** | Probes streams and only transcodes/remuxes when needed | Recommended for most users (default ON) |
| **Force Audio Transcode** | Transcodes audio to AAC (video passes through) | When you have video but no audio (Dolby/AC3/EAC3) |
| **Force Video Transcode** | Full transcode of both audio and video | For HEVC/VP9 sources on unsupported browsers |
| **Force Remux** | Remuxes MPEG-TS to MP4 (no re-encoding) | For raw `.ts` streams from middleware |
| **Stream Output Format** | HLS or TS for Xtream API requests | Try TS if HLS causes buffering |
### Network
| Setting | What It Does | When to Enable |
|---------|--------------|----------------|
| **Force Backend Proxy** | Routes streams through the server for CORS headers | When streams fail with CORS errors, or using middleware |
## Troubleshooting
@@ -159,23 +215,22 @@ All streaming settings are found in **Settings → Player → Streaming**.
| Symptom | Likely Cause | Solution |
|---------|--------------|----------|
| Black screen, `Access-Control-Allow-Origin` error | CORS blocked | Enable **"Force Backend Proxy"** in Settings → Streaming |
| Black screen with `MEDIA_ERR_DECODE` or `fragParsingError` | Unsupported codec (likely HEVC) | Try a different browser (see Codec Support table) |
| Loading forever (no error) | Decoder hung on unsupported codec | Try Safari or Edge; stream likely uses HEVC |
| Black screen, `Access-Control-Allow-Origin` error | CORS blocked | Enable **"Force Backend Proxy"** in Settings → Transcoding |
| Black screen with `MEDIA_ERR_DECODE` | Unsupported codec (HEVC/VP9) | Ensure **"Auto Transcode"** is enabled |
| Loading forever (no error) | Browser decoder stuck | Enable **"Force Video Transcode"** (overrides Auto detection) |
### No Audio (Video Plays Fine)
| Symptom | Likely Cause | Solution |
|---------|--------------|----------|
| No audio at all | Dolby/AC3/EAC3 audio | Enable **"Force Audio Transcode"** in Settings → Streaming |
| No audio on some channels | Codec mismatch | Try Safari (best Dolby support) or enable transcoding |
| No audio at all | Dolby/AC3/EAC3 audio | Enable **"Force Audio Transcode"** (overrides Auto detection) |
| Audio out of sync | Stream encoding issue | Try changing stream format to TS in Settings |
### Buffering Issues
| Symptom | Likely Cause | Solution |
|---------|--------------|----------|
| Constant buffering | Slow network or provider | Try TS format instead of HLS in Settings |
| Constant buffering | Slow network or weak GPU | 1. Lower **Max Resolution** (e.g. to 720p)<br>2. Try **TS** format instead of HLS |
### HTTPS / Reverse Proxy Issues
@@ -183,7 +238,7 @@ If you're running nodecast-tv behind a reverse proxy (Nginx, Caddy, Traefik) wit
| Symptom | Likely Cause | Solution |
|---------|--------------|----------|
| Streams fail with `fragLoadError` | Mixed content (HTTPS page loading HTTP streams) | Enable **"Force Backend Proxy"** in Settings → Streaming |
| Streams fail with `fragLoadError` | Mixed content (HTTPS page loading HTTP streams) | Enable **"Force Backend Proxy"** in Settings → Transcoding |
| Streams work on HTTP but not HTTPS | Reverse proxy not passing headers correctly | Ensure `X-Forwarded-Proto` header is set (see examples below) |
**Caddy example:**
@@ -214,77 +269,21 @@ location / {
}
```
### IPTV Middleware (m3u-editor, dispatcharr, Threadfin, etc.)
### IPTV Middleware (m3u-editor, dispatcharr, Threadfin, xTeVe)
If you manage your streams with middleware tools, you may encounter CORS issues or raw MPEG-TS streams that browsers can't play directly.
If you're using IPTV middleware like **m3u-editor**, **dispatcharr**, **Threadfin**, or **xTeVe** to manage your streams, you may need to adjust nodecast-tv settings for optimal playback. These tools typically use passthrough mode, which preserves original codecs (like HEVC/Dolby) that most browsers cannot decode natively, and may also trigger CORS restrictions.
**Recommended Setup:**
1. **Force Backend Proxy:** Enable this in **Settings → Transcoding → Network**. This routes middleware streams through NodeCast TV, bypassing CORS restrictions.
2. **Auto Transcode:** Keep this enabled (default). It will automatically detect if the middleware stream (e.g., MPEG-TS) needs to be remuxed or transcoded for the browser.
**Output Format Compatibility:**
| Middleware Output | Works? | Notes |
|-------------------|--------|-------|
| **HLS (`.m3u8`)** | ✅ Yes | Best option. NodeCast plays HLS natively. |
| **Raw TS (`.ts`)** | ⚠️ Maybe | Enable **"Force Remux"**. May fail if middleware proxies the stream. |
| **Proxied TS** | ❌ No | If middleware proxies/rewrites the stream, NodeCast's FFmpeg can't read it. Use HLS output instead. |
> **Why Proxied TS Fails:** When middleware is set to "Proxy" mode, it wraps the stream in its own layer. NodeCast's Remux/FFmpeg can't parse this format (error: `Invalid data found when processing input`). Configure your middleware to output **HLS (m3u8)** instead.
**Recommended Settings in nodecast-tv:**
| Setting | Location | When to Enable |
|---------|----------|----------------|
| **Force Backend Proxy** | Settings → Player → Streaming | Always recommended when using middleware |
| **Force Remux** | Settings → Player → Streaming | For raw `.ts` streams (lightweight, no re-encoding) |
| **Force Audio Transcode** | Settings → Player → Streaming | If you have no audio (Dolby/AC3/EAC3 streams) |
---
#### m3u-editor (sparkison/m3u-editor)
m3u-editor includes an internal proxy that remuxes streams to MPEG-TS.
**Setup:**
1. In m3u-editor, configure your playlist and enable the proxy if needed
2. In nodecast-tv, enable **"Force Remux"** in Settings → Streaming (for raw .ts streams)
3. If audio doesn't play, enable **"Force Audio Transcode"** instead
**Note:** m3u-editor's proxy preserves original codecs. If your source has HEVC or Dolby, you'll need transcoding or a compatible browser (Safari).
---
#### dispatcharr
dispatcharr uses FFmpeg stream profiles to process streams. By default it outputs MPEG-TS with passthrough codecs.
**Setup:**
1. In dispatcharr, streams are proxied by default via stream profiles
2. In nodecast-tv, enable **"Force Remux"** in Settings → Streaming (for raw .ts streams)
3. If audio doesn't play, enable **"Force Audio Transcode"** instead
**Custom dispatcharr profile for browser compatibility:**
If you want dispatcharr to transcode audio for you instead of nodecast-tv:
```
-user_agent {userAgent} -i {streamUrl} -c:v copy -c:a aac -f mpegts pipe:1
```
This keeps video passthrough but converts audio to AAC.
---
#### Threadfin / xTeVe
These HDHomeRun emulators work similarly to other middleware.
**Setup:**
1. Add your Threadfin/xTeVe M3U URL as an M3U source in nodecast-tv
2. Enable **"Force Remux"** in Settings → Streaming (for raw .ts streams)
3. If needed, enable **"Force Audio Transcode"** instead for Dolby audio
There is rarely a need to configure specific "Force Remux" settings manually anymore; the system detects stream types automatically.
### TVHeadend
If you're using TVHeadend as your source, you may need to configure a few settings for streams to play correctly in nodecast-tv:
**Option 1: Enable Force Backend Proxy (Easiest)**
- In nodecast-tv, go to **Settings → Player → Streaming**
- In nodecast-tv, go to **Settings → Transcoding → Network**
- Enable **"Force Backend Proxy"**
- This routes streams through the server, bypassing browser CORS restrictions
@@ -299,10 +298,10 @@ If you're using TVHeadend as your source, you may need to configure a few settin
### Acestream / P2P Streaming
If you are using `acestream-docker-home` or similar tools, you **MUST** use the HLS output format.
If you are using `acestream-docker-home` or similar tools, it is **recommended** to use the HLS output format to reduce server load, though NodeCast TV can remux raw streams if needed.
- **Don't use**: `http://proxy:6878/ace/getstream?id=...` (This is a raw MPEG-TS stream)
- **Do use**: `http://proxy:6878/ace/manifest.m3u8?id=...` (This wraps the stream in a browser-friendly HLS playlist)
- **Recommended:** `http://proxy:6878/ace/manifest.m3u8?id=...` (HLS Playlist - Direct Play)
- **Supported:** `http://proxy:6878/ace/getstream?id=...` (MPEG-TS - Requires Server Remuxing)
## Technology Stack
@@ -310,7 +309,7 @@ If you are using `acestream-docker-home` or similar tools, you **MUST** use the
- **Frontend**: Vanilla JavaScript (ES6+), CSS3
- **Database**: SQLite (via better-sqlite3) for high-performance data storage
- **Streaming**: HLS.js for stream playback
- **Audio Transcoding**: FFmpeg (optional, via ffmpeg-static)
- **Transcoding**: FFmpeg (integrated for hardware/software transcoding)
## Project Structure
+39 -2
View File
@@ -1,23 +1,25 @@
{
"name": "nodecast-tv",
"version": "1.5.0",
"version": "2.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nodecast-tv",
"version": "1.0.0",
"version": "2.0.0",
"license": "GPL-3.0-only",
"dependencies": {
"@ffprobe-installer/ffprobe": "^2.1.2",
"bcryptjs": "^3.0.3",
"better-sqlite3": "^12.5.0",
"dotenv": "^17.2.3",
"express": "^4.18.2",
"express-session": "^1.18.2",
"jsonwebtoken": "^9.0.3",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
"passport-openidconnect": "^0.1.2",
"sax": "^1.4.3",
"xml2js": "^0.6.2"
},
@@ -525,6 +527,18 @@
"node": ">=8"
}
},
"node_modules/dotenv": {
"version": "17.2.3",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
"integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -1196,6 +1210,12 @@
"node": ">=10"
}
},
"node_modules/oauth": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.2.tgz",
"integrity": "sha512-JtFnB+8nxDEXgNyniwz573xxbKSOu3R8D40xQKqcjwJ2CDkYqUDI53o6IuzDJBx60Z8VKCm271+t8iFjakrl8Q==",
"license": "MIT"
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -1292,6 +1312,23 @@
"node": ">= 0.4.0"
}
},
"node_modules/passport-openidconnect": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/passport-openidconnect/-/passport-openidconnect-0.1.2.tgz",
"integrity": "sha512-JX3rTyW+KFZ/E9OF/IpXJPbyLO9vGzcmXB5FgSP2jfL3LGKJPdV7zUE8rWeKeeI/iueQggOeFa3onrCmhxXZTg==",
"license": "MIT",
"dependencies": {
"oauth": "0.10.x",
"passport-strategy": "1.x.x"
},
"engines": {
"node": ">= 0.6.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/jaredhanson"
}
},
"node_modules/passport-strategy": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz",
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "nodecast-tv",
"version": "1.5.1",
"version": "2.0.0",
"description": "Modern IPTV Player",
"license": "GPL-3.0-only",
"main": "server/index.js",
@@ -12,12 +12,14 @@
"@ffprobe-installer/ffprobe": "^2.1.2",
"bcryptjs": "^3.0.3",
"better-sqlite3": "^12.5.0",
"dotenv": "^17.2.3",
"express": "^4.18.2",
"express-session": "^1.18.2",
"jsonwebtoken": "^9.0.3",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
"passport-openidconnect": "^0.1.2",
"sax": "^1.4.3",
"xml2js": "^0.6.2"
},
+719 -9
View File
@@ -980,7 +980,8 @@ video::cue {
.epg-grid {
height: calc(100vh - var(--navbar-height) - 60px);
overflow: auto;
overflow: hidden;
overflow-y: auto;
padding: var(--space-md);
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
@@ -988,10 +989,22 @@ video::cue {
.epg-time-header {
display: flex;
position: sticky;
top: 0;
background: var(--color-bg-secondary);
z-index: 10;
margin-left: var(--epg-sidebar-width);
}
.epg-header-corner {
width: var(--epg-sidebar-width);
flex-shrink: 0;
background: var(--color-bg-secondary);
border-right: 1px solid var(--color-border);
}
.epg-time-slots {
display: flex;
flex: 1;
}
.epg-time-slot {
@@ -1103,7 +1116,7 @@ video::cue {
bottom: 0;
width: 2px;
background: var(--color-error);
z-index: 15;
z-index: 2;
}
/* =====================================================
@@ -1853,6 +1866,21 @@ kbd {
animation: spin 0.8s linear infinite;
}
.loading-state {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-sm);
padding: var(--space-xl);
color: var(--color-text-muted);
width: 100%;
}
.horizontal-scroll .empty-state {
padding: var(--space-xl);
min-height: 150px;
}
@keyframes spin {
to {
transform: rotate(360deg);
@@ -1887,6 +1915,7 @@ kbd {
--sidebar-width: 320px;
--space-lg: 16px;
--space-xl: 24px;
--epg-sidebar-width: 180px;
}
/* Navigation - Hide text labels, show only icons */
@@ -1900,7 +1929,8 @@ kbd {
gap: 0;
}
.navbar-menu span:not(.nav-icon) {
/* Hide nav link text on mobile, but keep Now Playing visible */
.navbar-menu .nav-link span:not(.nav-icon) {
display: none;
}
@@ -2039,11 +2069,7 @@ kbd {
}
.epg-channel-info {
width: 180px;
}
.epg-time-slot {
min-width: 160px;
width: var(--epg-sidebar-width);
}
/* Movies & Series */
@@ -3314,6 +3340,24 @@ kbd {
gap: var(--space-md);
padding: var(--space-lg);
padding-top: calc(var(--space-lg) + var(--safe-area-inset-top));
pointer-events: none;
z-index: 20;
width: 100%;
box-sizing: border-box;
}
.watch-top-bar>* {
pointer-events: auto;
}
/* Live TV Header: Left Title & Right Status */
.watch-top-bar.live-header {
justify-content: space-between;
}
/* VOD Header: Spaced (Back Btn - Title - Status) */
.watch-top-bar.vod-header {
justify-content: space-between;
}
.watch-back-btn {
@@ -4026,3 +4070,669 @@ kbd {
height: 18px;
flex-shrink: 0;
}
/* Hardware Info Grid (Transcoding Tab) */
.hw-info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: var(--space-sm);
}
.hw-info-item {
display: flex;
flex-direction: column;
gap: var(--space-xs);
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-sm);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
}
.hw-info-item.hw-available {
border-color: var(--color-success);
background: rgba(16, 185, 129, 0.1);
}
.hw-info-item.hw-unavailable {
opacity: 0.6;
}
.hw-badge {
font-weight: 600;
font-size: 0.875rem;
}
.hw-info-item.hw-available .hw-badge {
color: var(--color-success);
}
.hw-info-item.hw-unavailable .hw-badge {
color: var(--color-text-muted);
}
.hw-name {
font-size: 0.75rem;
color: var(--color-text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Transcode Status Indicator */
.transcode-status {
padding: 4px 8px;
border-radius: 4px;
font-size: 0.8rem;
font-weight: 600;
background: rgba(0, 0, 0, 0.5);
color: white;
display: inline-flex;
align-items: center;
backdrop-filter: blur(4px);
transition: opacity 0.3s ease;
}
.transcode-status.transcoding {
background: rgba(255, 68, 68, 0.8);
/* Red for Transcoding */
box-shadow: 0 0 10px rgba(255, 68, 68, 0.3);
}
.transcode-status.remuxing {
background: rgba(255, 153, 0, 0.8);
/* Orange for Remux */
}
.transcode-status.direct {
background: rgba(76, 175, 80, 0.8);
/* Green for Direct */
}
.transcode-status.hidden {
opacity: 0;
pointer-events: none;
}
/* Badge Container for right alignment */
.player-badges {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
/* Fallback for cases without flex-grow middle element */
}
/* Quality Badge */
.quality-badge {
display: inline-block;
background: rgba(158, 158, 158, 0.8);
color: white;
padding: 4px 10px;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
}
.quality-badge.hidden {
display: none;
}
/* Watch Header Bar (Title + Transcode Status) */
/* Moved to original definition around line 3311 */
.watch-title-overlay {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: white;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 60%;
}
/* =====================================================
Edit User Modal
===================================================== */
.modal-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(4px);
z-index: 10000;
align-items: center;
justify-content: center;
}
.modal-overlay.active {
display: flex;
}
.modal-overlay .modal {
display: block;
position: relative;
inset: auto;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
width: 90%;
max-width: 500px;
max-height: 90vh;
overflow: hidden;
box-shadow: var(--shadow-lg);
animation: modalFadeIn 0.2s ease;
}
@keyframes modalFadeIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
.modal-overlay .modal-header {
padding: var(--space-md);
border-bottom: 1px solid var(--color-border);
display: flex;
align-items: center;
justify-content: space-between;
}
.modal-overlay .modal-title {
font-size: 1.25rem;
font-weight: 600;
color: var(--color-text-primary);
margin: 0;
}
.modal-overlay .modal-close {
background: none;
border: none;
color: var(--color-text-muted);
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
padding: 0;
transition: color 0.2s;
}
.modal-overlay .modal-close:hover {
color: var(--color-text-primary);
}
.modal-overlay .modal-body {
padding: var(--space-lg);
}
.modal-overlay .modal-footer {
padding: var(--space-md);
border-top: 1px solid var(--color-border);
display: flex;
justify-content: flex-end;
gap: var(--space-sm);
background: var(--color-bg-tertiary);
}
/* Modal Form Styles */
.modal-overlay .modal-form-group {
margin-bottom: var(--space-md);
}
.modal-overlay .modal-form-group label {
display: block;
margin-bottom: var(--space-xs);
color: var(--color-text-secondary);
font-size: 0.875rem;
}
.modal-overlay .modal-form-group .form-input {
width: 100%;
padding: var(--space-sm);
background: var(--color-bg-primary);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-text-primary);
}
.modal-overlay .modal-form-group .form-input:focus {
outline: none;
border-color: var(--color-accent);
}
.modal-overlay .modal-form-group .form-input:disabled {
opacity: 0.6;
cursor: not-allowed;
background: var(--color-bg-tertiary);
}
/* =====================================================
User Management Badges
===================================================== */
.user-badge {
display: inline-flex;
align-items: center;
padding: 2px 10px;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
border-radius: 12px;
white-space: nowrap;
}
.user-badge-sso {
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
color: #fff;
box-shadow: 0 2px 4px rgba(99, 102, 241, 0.3);
}
.user-badge-local {
background: rgba(255, 255, 255, 0.08);
color: var(--color-text-secondary);
border: 1px solid var(--color-border);
}
.user-badge-admin {
background: linear-gradient(135deg, #f59e0b 0%, #f97316 100%);
color: #000;
box-shadow: 0 2px 4px rgba(245, 158, 11, 0.3);
}
.user-badge-viewer {
background: rgba(255, 255, 255, 0.05);
color: var(--color-text-muted);
border: 1px solid rgba(255, 255, 255, 0.1);
}
/* =====================================================
Now Playing Indicator (Header)
===================================================== */
.now-playing-indicator {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 14px;
margin-right: var(--space-md);
background: linear-gradient(135deg, var(--color-accent) 0%, #8b5cf6 100%);
border-radius: var(--radius-full);
color: white;
text-decoration: none;
font-size: 13px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 200px;
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.4);
transition: transform var(--transition-fast), box-shadow var(--transition-fast);
animation: now-playing-pulse 2s ease-in-out infinite;
}
.now-playing-indicator:hover {
transform: scale(1.05);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.5);
}
.now-playing-indicator.hidden {
display: none;
}
.now-playing-icon {
font-size: 10px;
}
.now-playing-text {
overflow: hidden;
text-overflow: ellipsis;
}
@keyframes now-playing-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.85;
}
}
/* Mobile responsive - show in dropdown menu */
@media (max-width: 768px) {
.now-playing-indicator {
width: calc(100% - 32px);
max-width: none;
margin: 0 16px var(--space-sm) 16px;
padding: 10px 16px;
font-size: 13px;
border-radius: var(--radius-md);
justify-content: center;
}
}
/* =====================================================
Home Dashboard Styles
===================================================== */
#page-home {
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.dashboard-content {
padding: 24px;
max-width: 1400px;
margin: 0 auto;
}
.dashboard-section {
margin-bottom: 40px;
}
.dashboard-section.hidden {
display: none;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.section-header h2 {
font-family: 'Outfit', sans-serif;
font-size: 1.5rem;
color: var(--color-text-bright);
}
/* Scroll Wrapper with Navigation Arrows */
.scroll-wrapper {
position: relative;
}
.scroll-arrow {
position: absolute;
top: 50%;
transform: translateY(-50%);
z-index: 10;
width: 40px;
height: 40px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.7);
border: 1px solid rgba(255, 255, 255, 0.1);
color: white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all var(--transition-fast);
backdrop-filter: blur(8px);
}
.scroll-arrow:hover {
background: rgba(0, 0, 0, 0.9);
border-color: var(--color-accent);
transform: translateY(-50%) scale(1.1);
}
.scroll-arrow svg {
width: 24px;
height: 24px;
}
.scroll-arrow.scroll-left {
left: -8px;
}
.scroll-arrow.scroll-right {
right: -8px;
}
.scroll-arrow.hidden {
opacity: 0;
pointer-events: none;
}
/* Hide arrows on touch devices (they can swipe) */
@media (hover: none) {
.scroll-arrow {
display: none;
}
}
.horizontal-scroll {
display: flex;
gap: 20px;
overflow-x: auto;
padding-bottom: 12px;
scroll-snap-type: x mandatory;
-webkit-overflow-scrolling: touch;
}
/* Hide scrollbar by default, show on hover */
.horizontal-scroll::-webkit-scrollbar {
height: 6px;
}
.horizontal-scroll::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 3px;
}
.horizontal-scroll:hover::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
}
.dashboard-card {
flex: 0 0 calc(50% - 10px);
/* 2 per view on small screens */
max-width: 240px;
background: var(--color-surface);
border-radius: var(--radius-lg);
overflow: hidden;
cursor: pointer;
transition: transform var(--transition-fast), box-shadow var(--transition-fast);
scroll-snap-align: start;
border: 1px solid rgba(255, 255, 255, 0.05);
}
/* 4 per view on medium screens */
@media (min-width: 768px) {
.dashboard-card {
flex: 0 0 calc(25% - 15px);
}
}
/* 6 per view on large screens */
@media (min-width: 1200px) {
.dashboard-card {
flex: 0 0 calc(16.66% - 17px);
}
}
.dashboard-card:hover {
transform: translateY(-5px);
box-shadow: var(--shadow-lg);
border-color: var(--color-accent-dim);
}
.card-image {
position: relative;
aspect-ratio: 2/3;
overflow: hidden;
}
.card-image img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.5s ease;
}
.dashboard-card:hover .card-image img {
transform: scale(1.05);
}
.progress-bar-container {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 4px;
background: rgba(0, 0, 0, 0.5);
}
.progress-bar {
height: 100%;
background: var(--color-accent);
box-shadow: 0 0 10px var(--color-accent);
}
.play-icon-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.4);
opacity: 0;
transition: opacity var(--transition-fast);
}
.play-icon-overlay svg {
width: 48px;
height: 48px;
color: white;
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.5));
}
.dashboard-card:hover .play-icon-overlay {
opacity: 1;
}
.card-info {
padding: 12px;
}
.card-title {
font-weight: 600;
font-size: 0.95rem;
color: var(--color-text-bright);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 4px;
}
.card-subtitle {
font-size: 0.8rem;
color: var(--color-text-muted);
}
.loading-state,
.empty-state {
padding: 40px;
text-align: center;
color: var(--color-text-muted);
width: 100%;
background: rgba(255, 255, 255, 0.02);
border-radius: var(--radius-lg);
border: 1px dashed rgba(255, 255, 255, 0.1);
}
/* =====================================================
Channel Tile Styles (Favorite Channels)
===================================================== */
.channel-tiles {
gap: 16px;
}
.channel-tile {
flex: 0 0 100px;
display: flex;
flex-direction: column;
align-items: center;
cursor: pointer;
padding: 12px 8px;
border-radius: var(--radius-md);
transition: transform var(--transition-fast), background var(--transition-fast);
scroll-snap-align: start;
}
.channel-tile:hover {
transform: translateY(-4px);
background: rgba(255, 255, 255, 0.05);
}
.tile-logo {
width: 64px;
height: 64px;
border-radius: 50%;
overflow: hidden;
background: var(--color-surface);
border: 2px solid rgba(255, 255, 255, 0.1);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 8px;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.channel-tile:hover .tile-logo {
border-color: var(--color-accent);
box-shadow: 0 0 16px rgba(var(--color-accent-rgb), 0.3);
}
.tile-logo img {
width: 100%;
height: 100%;
object-fit: cover;
}
.tile-name {
font-size: 0.75rem;
font-weight: 500;
color: var(--color-text-secondary);
text-align: center;
max-width: 90px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.channel-tile:hover .tile-name {
color: var(--color-text-bright);
}
/* Responsive: smaller tiles on mobile */
@media (max-width: 480px) {
.channel-tile {
flex: 0 0 80px;
padding: 8px 4px;
}
.tile-logo {
width: 52px;
height: 52px;
}
.tile-name {
font-size: 0.7rem;
max-width: 70px;
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 259 B

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 177 KiB

After

Width:  |  Height:  |  Size: 514 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 438 KiB

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 799 KiB

After

Width:  |  Height:  |  Size: 879 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 899 KiB

After

Width:  |  Height:  |  Size: 895 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 483 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

+235 -32
View File
@@ -8,7 +8,7 @@
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="mobile-web-app-capable" content="yes">
<title>NodeCast TV | IPTV Player</title>
<title>nodecast-tv | IPTV Player</title>
<meta name="description" content="Stream live TV, movies, and series with EPG support">
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="stylesheet" href="css/main.css">
@@ -39,7 +39,19 @@
<span></span>
</button>
<div class="navbar-menu" id="navbar-menu">
<!-- Now Playing indicator for movies/series -->
<a href="#" class="now-playing-indicator hidden" id="now-playing-indicator" data-page="watch">
<span class="now-playing-icon">▶</span>
<span class="now-playing-text" id="now-playing-text">Now Playing</span>
</a>
<a href="#" class="nav-link active" data-page="home">
<span class="nav-icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"
class="icon">
<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z" />
</svg></span>
<span>Home</span>
</a>
<a href="#" class="nav-link" data-page="live">
<span class="nav-icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"
class="icon">
<path
@@ -83,8 +95,14 @@
<!-- Main Content Area -->
<main class="main-content">
<!-- Home Page -->
<!-- Home (Dashboard) Page -->
<div id="page-home" class="page active">
<!-- Content will be rendered by HomePage.js -->
<div class="loading-state">Loading Dashboard...</div>
</div>
<!-- Live TV Page (formerly Home) -->
<div id="page-live" class="page">
<div class="home-layout">
<!-- Channel Toggle Button (Mobile) -->
<button class="channel-toggle-btn" id="channel-toggle-btn">
@@ -148,8 +166,12 @@
<!-- Custom Controls Overlay -->
<div class="watch-overlay hidden" id="player-controls-overlay">
<!-- Top Bar -->
<div class="watch-top-bar">
<div class="watch-top-bar live-header">
<h2 class="watch-title-overlay" id="player-channel-name"></h2>
<div class="player-badges">
<div class="transcode-status hidden" id="player-transcode-status">Direct</div>
<div class="quality-badge hidden" id="player-quality-badge"></div>
</div>
</div>
@@ -338,6 +360,7 @@
</button>
</div>
</div>
<div class="series-content">
<div id="series-grid" class="series-grid">
<div class="empty-state">
@@ -368,6 +391,7 @@
<div class="tabs">
<button class="tab active" data-tab="sources">Sources</button>
<button class="tab" data-tab="player">Player</button>
<button class="tab" data-tab="transcode">Transcoding</button>
<button class="tab" data-tab="content">Manage Content</button>
<button class="tab" data-tab="users" id="users-tab" style="display:none;">Users</button>
</div>
@@ -484,10 +508,90 @@
</div>
<div class="settings-section">
<h3>Streaming</h3>
<h3>Keyboard Shortcuts</h3>
<div class="shortcuts-grid">
<div class="shortcut"><kbd>↑</kbd> / <kbd>↓</kbd> Channel up/down (or volume)</div>
<div class="shortcut"><kbd>←</kbd> / <kbd>→</kbd> Volume down/up (when arrows = channels)</div>
<div class="shortcut"><kbd>Space</kbd> / <kbd>K</kbd> Play/Pause</div>
<div class="shortcut"><kbd>F</kbd> Fullscreen</div>
<div class="shortcut"><kbd>M</kbd> Mute/Unmute</div>
<div class="shortcut"><kbd>I</kbd> Toggle info overlay</div>
<div class="shortcut"><kbd>PgUp</kbd> / <kbd>PgDn</kbd> Channel up/down</div>
</div>
</div>
</div>
<!-- Transcoding Tab -->
<div id="tab-transcode" class="tab-content">
<div class="settings-section">
<h3>Detected Hardware</h3>
<div id="hw-info-container" class="hw-info-container">
<p class="hint">Loading hardware information...</p>
</div>
</div>
<div class="settings-section">
<h3>Encoder Settings</h3>
<div class="setting-item">
<div class="setting-info" style="flex: 1;">
<span class="setting-label">Hardware Encoder</span>
<span class="setting-hint">Select which GPU encoder to use for transcoding. Auto will use the best
available.</span>
</div>
<select id="setting-hw-encoder" class="source-select" style="width: auto; min-width: 200px;">
<option value="auto">Auto (Recommended)</option>
<option value="nvenc">NVIDIA NVENC</option>
<option value="amf">AMD AMF</option>
<option value="qsv">Intel QuickSync</option>
<option value="vaapi">VAAPI (Linux)</option>
<option value="software">Software (CPU)</option>
</select>
</div>
<div class="setting-item">
<div class="setting-info" style="flex: 1;">
<span class="setting-label">Max Resolution</span>
<span class="setting-hint">Limit transcoded output resolution. Lower = faster, more
compatible.</span>
</div>
<select id="setting-max-resolution" class="source-select" style="width: auto; min-width: 200px;">
<option value="4k">4K (2160p)</option>
<option value="1080p" selected>1080p (Full HD)</option>
<option value="720p">720p (HD)</option>
<option value="480p">480p (SD)</option>
</select>
</div>
<div class="setting-item">
<div class="setting-info" style="flex: 1;">
<span class="setting-label">Quality Preset</span>
<span class="setting-hint">Higher quality uses more bandwidth and CPU/GPU.</span>
</div>
<select id="setting-quality" class="source-select" style="width: auto; min-width: 200px;">
<option value="high">High</option>
<option value="medium" selected>Medium</option>
<option value="low">Low (Fastest)</option>
</select>
</div>
<div class="setting-item">
<div class="setting-info" style="flex: 1;">
<span class="setting-label">Audio Mix Preset</span>
<span class="setting-hint">How multi-channel audio (5.1/7.1) is mixed down to stereo for browser
playback.</span>
</div>
<select id="setting-audio-mix" class="source-select" style="width: auto; min-width: 200px;">
<option value="auto">Auto (Smart Copy/ITU)</option>
<option value="itu">ITU-R BS.775 (Balanced)</option>
<option value="night">Night Mode (Dialogue Boost)</option>
<option value="cinematic">Cinematic (Wide)</option>
<option value="passthrough">Passthrough (Copy)</option>
</select>
</div>
</div>
<div class="settings-section">
<h3>Stream Processing</h3>
<div class="setting-item">
<label class="setting-toggle">
<input type="checkbox" id="setting-auto-transcode">
<input type="checkbox" id="setting-auto-transcode-tc">
<span class="toggle-slider"></span>
</label>
<div class="setting-info">
@@ -498,18 +602,7 @@
</div>
<div class="setting-item">
<label class="setting-toggle">
<input type="checkbox" id="setting-force-proxy">
<span class="toggle-slider"></span>
</label>
<div class="setting-info">
<span class="setting-label">Force Backend Proxy</span>
<span class="setting-hint">Route all streams through the proxy server. Enable if streams don't play
directly.</span>
</div>
</div>
<div class="setting-item">
<label class="setting-toggle">
<input type="checkbox" id="setting-force-transcode">
<input type="checkbox" id="setting-force-transcode-tc">
<span class="toggle-slider"></span>
</label>
<div class="setting-info">
@@ -520,13 +613,25 @@
</div>
<div class="setting-item">
<label class="setting-toggle">
<input type="checkbox" id="setting-force-remux">
<input type="checkbox" id="setting-force-video-transcode-tc">
<span class="toggle-slider"></span>
</label>
<div class="setting-info">
<span class="setting-label">Force Video Transcode</span>
<span class="setting-hint">Force full video transcoding (burn-in subtitles, incompatible video).
Uses
HW encoder.</span>
</div>
</div>
<div class="setting-item">
<label class="setting-toggle">
<input type="checkbox" id="setting-force-remux-tc">
<span class="toggle-slider"></span>
</label>
<div class="setting-info">
<span class="setting-label">Force Remux</span>
<span class="setting-hint">Remux streams to MP4 container. Enable for raw .ts streams
from IPTV middleware like m3u-editor or dispatcharr.</span>
<span class="setting-hint">Remux streams to MP4 container. Enable for raw .ts streams from IPTV
middleware like m3u-editor or dispatcharr.</span>
</div>
</div>
<div class="setting-item">
@@ -535,7 +640,7 @@
<span class="setting-hint">Container format for Xtream streams. Try TS if you experience buffering
issues.</span>
</div>
<select id="setting-stream-format" class="source-select" style="width: auto; min-width: 200px;">
<select id="setting-stream-format-tc" class="source-select" style="width: auto; min-width: 200px;">
<option value="m3u8">HLS (m3u8) - Recommended</option>
<option value="ts">MPEG-TS (ts) - Better buffer</option>
</select>
@@ -543,15 +648,42 @@
</div>
<div class="settings-section">
<h3>Keyboard Shortcuts</h3>
<div class="shortcuts-grid">
<div class="shortcut"><kbd>↑</kbd> / <kbd>↓</kbd> Channel up/down (or volume)</div>
<div class="shortcut"><kbd>←</kbd> / <kbd>→</kbd> Volume down/up (when arrows = channels)</div>
<div class="shortcut"><kbd>Space</kbd> / <kbd>K</kbd> Play/Pause</div>
<div class="shortcut"><kbd>F</kbd> Fullscreen</div>
<div class="shortcut"><kbd>M</kbd> Mute/Unmute</div>
<div class="shortcut"><kbd>I</kbd> Toggle info overlay</div>
<div class="shortcut"><kbd>PgUp</kbd> / <kbd>PgDn</kbd> Channel up/down</div>
<h3>HTTP Settings</h3>
<div class="setting-item">
<div class="setting-info" style="flex: 1;">
<span class="setting-label">User-Agent</span>
<span class="setting-hint">HTTP User-Agent for stream requests. Try VLC if your provider blocks
Chrome.</span>
</div>
<select id="setting-user-agent-tc" class="source-select" style="width: auto; min-width: 200px;">
<option value="chrome">Chrome (Default)</option>
<option value="vlc">VLC</option>
<option value="tivimate">TiviMate</option>
<option value="custom">Custom...</option>
</select>
</div>
<div class="setting-item" id="custom-user-agent-container-tc" style="display: none;">
<div class="setting-info" style="flex: 1;">
<span class="setting-label">Custom User-Agent</span>
<span class="setting-hint">Enter your own User-Agent string.</span>
</div>
<input type="text" id="setting-user-agent-custom-tc" class="search-input" style="width: 300px;"
placeholder="Custom User-Agent string...">
</div>
</div>
<div class="settings-section">
<h3>Network</h3>
<div class="setting-item">
<label class="setting-toggle">
<input type="checkbox" id="setting-force-proxy-tc">
<span class="toggle-slider"></span>
</label>
<div class="setting-info">
<span class="setting-label">Force Backend Proxy</span>
<span class="setting-hint">Route all streams through the server to bypass CORS restrictions. Enable if
streams don't play directly.</span>
</div>
</div>
</div>
</div>
@@ -600,6 +732,7 @@
<thead>
<tr>
<th>Username</th>
<th>Email</th>
<th>Role</th>
<th>Created</th>
<th>Actions</th>
@@ -648,7 +781,7 @@
<!-- Player Controls Overlay -->
<div class="watch-overlay" id="watch-overlay">
<!-- Top Bar: Back + Title -->
<div class="watch-top-bar">
<div class="watch-top-bar vod-header">
<button class="watch-back-btn" id="watch-back-btn" title="Back">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon">
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
@@ -658,6 +791,10 @@
<h3 class="watch-title" id="watch-title"></h3>
<p class="watch-subtitle" id="watch-subtitle"></p>
</div>
<div class="player-badges">
<div class="transcode-status hidden" id="watch-transcode-status">Direct</div>
<div class="quality-badge hidden" id="watch-quality-badge"></div>
</div>
</div>
<!-- Center: Large Play Button -->
@@ -865,6 +1002,57 @@
</svg> Hide</button>
</div>
<!-- Edit User Modal -->
<div class="modal-overlay" id="edit-user-modal">
<div class="modal">
<div class="modal-header">
<h3 class="modal-title">Edit User</h3>
<button class="modal-close" id="edit-user-close">&times;</button>
</div>
<div class="modal-body">
<form id="edit-user-form">
<input type="hidden" id="edit-user-id">
<div class="modal-form-group">
<label for="edit-username">Username</label>
<input type="text" id="edit-username" name="username" class="form-input" required>
</div>
<div class="modal-form-group">
<label for="edit-email">Email</label>
<input type="text" id="edit-email" class="form-input" readonly disabled placeholder="No email associated">
</div>
<div class="modal-form-group">
<label for="edit-role">Role</label>
<select id="edit-role" name="role" class="form-input" required>
<option value="viewer">Viewer</option>
<option value="admin">Admin</option>
</select>
</div>
<div class="modal-form-group">
<label for="edit-password">New Password</label>
<input type="password" id="edit-password" name="password" class="form-input"
placeholder="Leave blank to keep current">
<p class="hint" id="edit-password-hint" style="margin-top: 4px;">Optional. Leave blank to keep unchanged.
</p>
</div>
<div class="modal-form-group hidden" id="oidc-info-group">
<label>OIDC Provider ID</label>
<code id="edit-oidc-id"
style="font-size: 0.8rem; color: var(--color-text-muted); padding: 4px; background: rgba(0,0,0,0.3); border-radius: 4px; display: block;"></code>
</div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-ghost" id="edit-user-cancel">Cancel</button>
<button class="btn btn-primary" id="edit-user-save">Save Changes</button>
</div>
</div>
</div>
<!-- JavaScript -->
<script src="/js/icons.js"></script>
<script src="/js/api.js?v=2"></script>
@@ -872,13 +1060,28 @@
<script src="/js/components/ChannelList.js?v=3"></script>
<script src="/js/components/SourceManager.js?v=2"></script>
<script src="/js/components/EpgGuide.js?v=2"></script>
<script src="/js/pages/Home.js?v=3"></script>
<script src="/js/pages/HomePage.js?v=2"></script>
<script src="/js/pages/LivePage.js?v=2"></script>
<script src="/js/pages/Guide.js?v=2"></script>
<script src="/js/pages/MoviesPage.js?v=4"></script>
<script src="/js/pages/SeriesPage.js?v=1"></script>
<script src="/js/pages/Settings.js?v=2"></script>
<script src="/js/pages/WatchPage.js?v=1"></script>
<script src="/js/app.js?v=4"></script>
<script>
// Check for SSO token in URL
(function () {
const urlParams = new URLSearchParams(window.location.search);
const token = urlParams.get('token');
if (token) {
console.log('SSO Login successful, saving token...');
localStorage.setItem('authToken', token);
// Clean URL without reloading
const newUrl = window.location.pathname;
window.history.replaceState({}, document.title, newUrl);
}
})();
</script>
</body>
</html>
+22 -2
View File
@@ -16,6 +16,7 @@ class App {
// Initialize page controllers
this.pages.home = new HomePage(this);
this.pages.live = new LivePage(this);
this.pages.guide = new GuidePage(this);
this.pages.movies = new MoviesPage(this);
this.pages.series = new SeriesPage(this);
@@ -113,6 +114,15 @@ class App {
});
});
// Now Playing indicator
const nowPlayingBtn = document.getElementById('now-playing-indicator');
if (nowPlayingBtn) {
nowPlayingBtn.addEventListener('click', (e) => {
e.preventDefault();
this.navigateTo('watch');
});
}
// Toggle groups button
document.getElementById('toggle-groups').addEventListener('click', () => {
this.channelList.toggleAllGroups();
@@ -137,9 +147,19 @@ class App {
this.navigateTo(page, false); // false = don't add to history
});
// Initialize home page
// Initialize home page first (it's needed for channel list)
await this.pages.home.init();
this.navigateTo('home', true); // true = replace history (don't add)
// Preload EPG data in background (non-blocking)
// This ensures EPG info is available on Live TV page without visiting Guide first
this.epgGuide.loadEpg().catch(err => {
console.warn('Background EPG load failed:', err.message);
});
// Navigate to the page from URL hash, or default to home
const hash = window.location.hash.slice(1); // Remove #
const initialPage = hash && this.pages[hash] ? hash : 'home';
this.navigateTo(initialPage, true); // true = replace history (don't add)
console.log('NodeCast TV initialized');
}
+40 -9
View File
@@ -497,6 +497,7 @@ class ChannelList {
const isFavorite = this.isFavorite(channel.sourceId, channel.id);
const renderId = this.renderedChannels[renderIndex]?._renderId || '';
const renderGroup = this.renderedChannels[renderIndex]?._renderGroup || groupName;
renderIndex++;
html += `
@@ -506,7 +507,8 @@ class ChannelList {
data-source-type="${channel.sourceType}"
data-stream-id="${channel.streamId || ''}"
data-url="${channel.url || ''}"
data-render-id="${renderId}">
data-render-id="${renderId}"
data-render-group="${renderGroup}">
<img class="channel-logo" src="${this.getProxiedImageUrl(channel.tvgLogo)}"
alt="" onerror="this.onerror=null;this.src='/img/placeholder.png'">
<div class="channel-info">
@@ -604,13 +606,22 @@ class ChannelList {
const isActive = this.currentChannel?.id === channel.id;
const isFavorite = this.isFavorite(channel.sourceId, channel.id);
// Find the matching rendered channel to get its unique IDs
const renderedChannel = this.renderedChannels.find(rc =>
rc.id === channel.id && rc.sourceId === channel.sourceId && rc._renderGroup === groupName
);
const renderId = renderedChannel?._renderId || '';
const renderGroup = renderedChannel?._renderGroup || groupName;
html += `
<div class="channel-item ${isActive ? 'active' : ''} ${channelHidden ? 'hidden' : ''}"
data-channel-id="${channel.id}"
data-source-id="${channel.sourceId}"
data-source-type="${channel.sourceType}"
data-stream-id="${channel.streamId || ''}"
data-url="${channel.url || ''}">
data-url="${channel.url || ''}"
data-render-id="${renderId}"
data-render-group="${renderGroup}">
<img class="channel-logo" src="${this.getProxiedImageUrl(channel.tvgLogo)}"
alt="" onerror="this.onerror=null;this.src='/img/placeholder.png'">
<div class="channel-info">
@@ -1079,6 +1090,7 @@ class ChannelList {
this.currentChannel = channel;
this.currentRenderId = dataset.renderId; // Track which visual instance is active
this.currentRenderGroup = dataset.renderGroup; // Track which group the selection came from
// Update active state in DOM
this.container.querySelectorAll('.channel-item.active').forEach(el => {
@@ -1359,11 +1371,20 @@ class ChannelList {
currentIndex = this.renderedChannels.findIndex(c => c._renderId === this.currentRenderId);
}
// Fallback: Find first matching channel ID (if render ID lost or invalid)
// Fallback: Find matching channel ID, prioritizing same render group
if (currentIndex === -1) {
currentIndex = this.renderedChannels.findIndex(c =>
c.id === this.currentChannel.id && c.sourceId === this.currentChannel.sourceId
);
// First try to find in same group (for Favorites containing duplicates)
if (this.currentRenderGroup) {
currentIndex = this.renderedChannels.findIndex(c =>
c.id === this.currentChannel.id && c.sourceId === this.currentChannel.sourceId && c._renderGroup === this.currentRenderGroup
);
}
// Final fallback: any matching channel
if (currentIndex === -1) {
currentIndex = this.renderedChannels.findIndex(c =>
c.id === this.currentChannel.id && c.sourceId === this.currentChannel.sourceId
);
}
}
if (currentIndex === -1) return;
@@ -1393,10 +1414,20 @@ class ChannelList {
currentIndex = this.renderedChannels.findIndex(c => c._renderId === this.currentRenderId);
}
// Fallback: Find matching channel ID, prioritizing same render group
if (currentIndex === -1) {
currentIndex = this.renderedChannels.findIndex(c =>
c.id === this.currentChannel.id && c.sourceId === this.currentChannel.sourceId
);
// First try to find in same group (for Favorites containing duplicates)
if (this.currentRenderGroup) {
currentIndex = this.renderedChannels.findIndex(c =>
c.id === this.currentChannel.id && c.sourceId === this.currentChannel.sourceId && c._renderGroup === this.currentRenderGroup
);
}
// Final fallback: any matching channel
if (currentIndex === -1) {
currentIndex = this.renderedChannels.findIndex(c =>
c.id === this.currentChannel.id && c.sourceId === this.currentChannel.sourceId
);
}
}
if (currentIndex === -1) return;
+55 -22
View File
@@ -429,17 +429,20 @@ class EpgGuide {
// Calculate total height for virtual scrolling
const totalHeight = this.filteredChannels.length * this.rowHeight;
// Build HTML structure with virtual scroll container
// Build HTML structure - header is INSIDE scroll container for natural sync
this.container.innerHTML = `
<div class="epg-container" style="position: relative;">
<div class="epg-time-header">
${timeSlots.map(slot => `
<div class="epg-time-slot" style="width: ${30 * this.pixelsPerMinute}px;">
${slot.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
<div class="epg-scroll-container" style="overflow: auto; max-height: calc(100vh - 200px);">
<div class="epg-time-header">
<div class="epg-header-corner"></div>
<div class="epg-time-slots">
${timeSlots.map(slot => `
<div class="epg-time-slot" style="width: ${30 * this.pixelsPerMinute}px;">
${slot.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</div>
`).join('')}
</div>
`).join('')}
</div>
<div class="epg-scroll-container" style="overflow-y: auto; max-height: calc(100vh - 200px);">
</div>
<div class="epg-spacer" style="height: ${totalHeight}px; position: relative;">
<div class="epg-channel-rows" style="position: absolute; top: 0; left: 0; right: 0;"></div>
</div>
@@ -461,16 +464,46 @@ class EpgGuide {
if (this._scrollHandler) {
this.scrollContainer?.removeEventListener('scroll', this._scrollHandler);
}
if (this._hScrollHandler) {
this.scrollContainer?.removeEventListener('scroll', this._hScrollHandler);
}
// Set up scroll handler for virtual scrolling
// Get reference to time slots container for horizontal scroll sync
this.timeSlotsContainer = this.container.querySelector('.epg-time-slots');
// Set up scroll handler for virtual scrolling (debounced for performance)
this._scrollHandler = this.debounce(() => this.updateVisibleRows(), 16); // ~60fps
this.scrollContainer.addEventListener('scroll', this._scrollHandler);
// No need for horizontal scroll sync anymore - header is inside scroll container
// Initial render of visible rows
this.updateVisibleRows();
// Add now indicator
// Sync header corner width with actual sidebar width (handles CSS overrides)
this.syncHeaderCornerWidth();
// Add now indicator and set up periodic refresh
this.updateNowIndicator();
// Clear any existing interval
if (this._nowIndicatorInterval) {
clearInterval(this._nowIndicatorInterval);
}
// Update indicator every 60 seconds
this._nowIndicatorInterval = setInterval(() => this.updateNowIndicator(), 60000);
}
/**
* Sync header corner width with actual sidebar width
* This handles cases where CSS variables are overridden on mobile
*/
syncHeaderCornerWidth() {
const sidebar = this.container.querySelector('.epg-channel-info');
const headerCorner = this.container.querySelector('.epg-header-corner');
if (sidebar && headerCorner) {
const actualWidth = sidebar.offsetWidth;
headerCorner.style.width = `${actualWidth}px`;
}
}
/**
@@ -824,29 +857,29 @@ class EpgGuide {
updateNowIndicator() {
const now = new Date();
const container = this.container.querySelector('.epg-container');
if (!container) return;
// Place indicator inside the spacer so it scrolls with content
const spacer = this.container.querySelector('.epg-spacer');
if (!spacer) return;
// Remove existing indicator
const existing = container.querySelector('.epg-now-line');
const existing = spacer.querySelector('.epg-now-line');
if (existing) existing.remove();
// Calculate position
const startTime = new Date();
startTime.setHours(startTime.getHours() + this.timeOffset);
startTime.setMinutes(0, 0, 0);
// Calculate position relative to EPG start time
const minutesFromStart = (now - this.startTime) / 60000;
if (minutesFromStart < 0 || minutesFromStart > 1440) return; // Not in visible 24h range
const minutesFromStart = (now - startTime) / 60000;
if (minutesFromStart < 0 || minutesFromStart > 240) return; // Not in visible range
// Get sidebar width to offset the indicator
const sidebar = this.container.querySelector('.epg-channel-info');
const sidebarWidth = sidebar ? sidebar.offsetWidth : 150;
// Get current sidebar width
const sidebarWidth = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--epg-sidebar-width')) || 150;
// Position is sidebar + time offset
const leftPos = sidebarWidth + (minutesFromStart * this.pixelsPerMinute);
const indicator = document.createElement('div');
indicator.className = 'epg-now-line';
indicator.style.left = `${leftPos}px`;
container.appendChild(indicator);
spacer.appendChild(indicator);
}
/**
+236 -22
View File
@@ -522,6 +522,17 @@ class VideoPlayer {
// Setup custom video controls
this.initCustomControls();
// Detect video resolution when metadata loads (works for all streams)
this.video.addEventListener('loadedmetadata', () => {
if (this.video.videoHeight > 0) {
this.currentStreamInfo = {
width: this.video.videoWidth,
height: this.video.videoHeight
};
this.updateQualityBadge();
}
});
// Initialize HLS.js if supported
if (Hls.isSupported()) {
this.hls = new Hls(this.getHlsConfig());
@@ -707,6 +718,44 @@ class VideoPlayer {
this.nowPlaying.classList.add('hidden');
}
/**
* Start a HLS transcode session
*/
async startTranscodeSession(url, options = {}) {
try {
console.log('[Player] Starting HLS transcode session...', options);
const res = await fetch('/api/transcode/session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url, ...options })
});
if (!res.ok) throw new Error('Failed to start session');
const session = await res.json();
this.currentSessionId = session.sessionId;
return session.playlistUrl;
} catch (err) {
console.error('[Player] Session start failed:', err);
// Fallback to direct transcode if session fails
return `/api/transcode?url=${encodeURIComponent(url)}`;
}
}
/**
* Stop and cleanup current transcode session
*/
async stopTranscodeSession() {
if (this.currentSessionId) {
console.log('[Player] Stopping transcode session:', this.currentSessionId);
try {
// Fire and forget cleanup
fetch(`/api/transcode/${this.currentSessionId}`, { method: 'DELETE' });
} catch (err) {
console.error('Failed to stop session:', err);
}
this.currentSessionId = null;
}
}
/**
* Play a channel
*/
@@ -714,8 +763,12 @@ class VideoPlayer {
this.currentChannel = channel;
try {
// Stop any WatchPage playback (movies/series) before starting Live TV
window.app?.pages?.watch?.stop?.();
// Stop current playback
this.stop();
this.updateTranscodeStatus('hidden');
// Hide "select a channel" overlay
this.overlay.classList.add('hidden');
@@ -733,7 +786,11 @@ class VideoPlayer {
try {
const probeRes = await fetch(`/api/probe?url=${encodeURIComponent(streamUrl)}`);
const info = await probeRes.json();
console.log(`[Player] Probe result: video=${info.video}, audio=${info.audio}, compatible=${info.compatible}`);
console.log(`[Player] Probe result: video=${info.video}, audio=${info.audio}, ${info.width}x${info.height}, compatible=${info.compatible}`);
// Store probe result for quality badge display
this.currentStreamInfo = info;
this.updateQualityBadge();
// Handle subtitles from probe result
// Clear existing remote tracks (from previous streams)
@@ -758,14 +815,25 @@ class VideoPlayer {
}
if (info.needsTranscode) {
// Incompatible audio (AC3/EAC3/DTS) - use transcode
console.log('[Player] Auto: Using transcode (incompatible audio)');
const transcodeUrl = `/api/transcode?url=${encodeURIComponent(streamUrl)}`;
this.currentUrl = transcodeUrl;
this.video.src = transcodeUrl;
this.video.play().catch(e => {
if (e.name !== 'AbortError') console.log('[Player] Autoplay prevented:', e);
// Incompatible audio (AC3/EAC3/DTS) - use transcode session
console.log('[Player] Auto: Using HLS transcode session');
// Heuristic: If video is h264, it's likely compatible, so only copy video (audio transcode only)
// If video is hevc/other, we need full transcode
const videoMode = (info.video && info.video.includes('h264')) ? 'copy' : 'encode';
const statusText = videoMode === 'copy' ? 'Transcoding (Audio)' : 'Transcoding (Video)';
this.updateTranscodeStatus('transcoding', statusText);
const playlistUrl = await this.startTranscodeSession(streamUrl, {
videoMode,
videoCodec: info.video,
audioCodec: info.audio,
audioChannels: info.audioChannels
});
this.currentUrl = playlistUrl; // Update currentUrl for HLS reload
this.playHls(playlistUrl);
this.updateNowPlaying(channel);
this.showNowPlayingOverlay();
this.fetchEpgData(channel);
@@ -774,6 +842,7 @@ class VideoPlayer {
} else if (info.needsRemux) {
// Raw .ts container - use remux
console.log('[Player] Auto: Using remux (.ts container)');
this.updateTranscodeStatus('remuxing', 'Remux (Auto)');
const remuxUrl = `/api/remux?url=${encodeURIComponent(streamUrl)}`;
this.currentUrl = remuxUrl;
this.video.src = remuxUrl;
@@ -794,25 +863,73 @@ class VideoPlayer {
}
}
// CHECK: Force Transcode Priority - transcoded streams bypass HLS.js
if (this.settings.forceTranscode) {
console.log('[Player] Force Transcode enabled. Routing through ffmpeg...');
const transcodeUrl = `/api/transcode?url=${encodeURIComponent(streamUrl)}`;
this.currentUrl = transcodeUrl;
// CHECK: Force Video Transcode (Full)
if (this.settings.forceVideoTranscode) {
console.log('[Player] Force Video Transcode enabled. Starting session (encode)...');
this.updateTranscodeStatus('transcoding', 'Transcoding (Video)');
const playlistUrl = await this.startTranscodeSession(streamUrl, { videoMode: 'encode' });
this.currentUrl = playlistUrl;
// Transcoded streams are fragmented MP4 - play directly with <video> element
console.log('[Player] Playing transcoded stream directly:', transcodeUrl);
this.video.src = transcodeUrl;
this.video.play().catch(e => {
if (e.name !== 'AbortError') console.log('[Player] Autoplay prevented:', e);
});
// Load HLS
this.updateNowPlaying(channel, 'Transcoding (Video)');
// ... (rest is same logic flow, simplified by just falling through to playHls call if I refactored)
// But for minimize drift, I'll copy the block logic for HLS playback init
// Actually, I can just fall through if I set looksLikeHls = true?
// No, play logic is sequential.
if (Hls.isSupported()) {
// Start HLS
// ... this repeats code. I should probably just set currentUrl and let HLS block handle?
// But HLS block is lower down.
// I will just execute the HLS init here as before.
// Actually, easiest way is to re-assign streamUrl and goto start? No.
// Copy existing forceTranscode block logic
if (this.hls) {
this.hls.destroy();
}
this.hls = new Hls();
this.hls.loadSource(playlistUrl);
this.hls.attachMedia(this.video);
this.hls.on(Hls.Events.MANIFEST_PARSED, () => {
this.video.play().catch(console.error);
});
// Handle errors
this.hls.on(Hls.Events.ERROR, (event, data) => {
if (data.fatal) {
console.log('[Player] HLS fatal error');
this.hls.destroy();
}
});
return; // Exit
}
}
// CHECK: Force Audio Transcode (Copy Video) - legacy forceTranscode setting
if (this.settings.forceTranscode) {
console.log('[Player] Force Audio Transcode enabled. Starting session (copy)...');
this.updateTranscodeStatus('transcoding', 'Transcoding (Audio)');
// Probe to get video codec for HEVC tag handling
let videoCodec = 'unknown';
try {
const probeRes = await fetch(`/api/probe?url=${encodeURIComponent(streamUrl)}`);
const info = await probeRes.json();
videoCodec = info.video;
} catch (e) { console.warn('Probe failed for force audio, assuming h264'); }
const playlistUrl = await this.startTranscodeSession(streamUrl, { videoMode: 'copy', videoCodec });
this.currentUrl = playlistUrl;
console.log('[Player] Playing transcoded HLS stream:', playlistUrl);
this.playHls(playlistUrl);
// Update UI and dispatch events
this.updateNowPlaying(channel);
this.showNowPlayingOverlay();
this.fetchEpgData(channel);
window.dispatchEvent(new CustomEvent('channelChanged', { detail: channel }));
return; // Exit early - don't use HLS.js path
return; // Exit early
}
// Proactively use proxy for:
@@ -842,6 +959,7 @@ class VideoPlayer {
if (this.settings.forceRemux && (isRawTs || isExtensionless)) {
console.log('[Player] Force Remux enabled. Routing through FFmpeg remux...');
console.log('[Player] Stream type:', isRawTs ? 'Raw TS' : 'Extension-less (assumed TS)');
this.updateTranscodeStatus('remuxing', 'Remux (Force)');
const remuxUrl = this.getRemuxUrl(streamUrl);
this.video.src = remuxUrl;
this.video.play().catch(e => {
@@ -870,6 +988,13 @@ class VideoPlayer {
// Priority 1: Use HLS.js for HLS streams on browsers that support it
if (looksLikeHls && Hls.isSupported()) {
this.updateTranscodeStatus('direct', 'Direct HLS');
// Use playHls helper logic here (or extract it)
// For now, let's just use existing logic but wrapped/modularized if possible?
// The HLS init logic is quite complex with error handling
// I'll inline the Hls init here as per original but mindful of proxy vs local
this.hls = new Hls(this.getHlsConfig());
this.hls.loadSource(finalUrl);
this.hls.attachMedia(this.video);
@@ -883,11 +1008,13 @@ class VideoPlayer {
// Re-attach error handler for the new Hls instance
this.hls.on(Hls.Events.ERROR, (event, data) => {
if (data.fatal) {
// CORS issues can manifest as NETWORK_ERROR or MEDIA_ERROR with fragParsingError
const isCorsLikely = data.type === Hls.ErrorTypes.NETWORK_ERROR ||
(data.type === Hls.ErrorTypes.MEDIA_ERROR && data.details === 'fragParsingError');
if (isCorsLikely && !this.isUsingProxy) {
// Don't proxy if it's already a local API URL
const isLocalApi = this.currentUrl.startsWith('/api/');
if (isCorsLikely && !this.isUsingProxy && !isLocalApi) {
console.log('CORS/Network error detected, retrying via proxy...', data.details);
this.isUsingProxy = true;
this.hls.loadSource(this.getProxiedUrl(this.currentUrl));
@@ -925,6 +1052,7 @@ class VideoPlayer {
} else if (this.video.canPlayType('application/vnd.apple.mpegurl') === 'probably' ||
this.video.canPlayType('application/vnd.apple.mpegurl') === 'maybe') {
// Priority 2: Native HLS support (Safari on iOS/macOS where HLS.js may not work)
this.updateTranscodeStatus('direct', 'Direct Native');
this.video.src = finalUrl;
this.video.play().catch(e => {
if (e.name === 'AbortError') return; // Ignore interruption by new load
@@ -939,6 +1067,7 @@ class VideoPlayer {
});
} else {
// Priority 3: Try direct playback for non-HLS streams
this.updateTranscodeStatus('direct', 'Direct Play');
this.video.src = finalUrl;
this.video.play().catch(e => {
if (e.name !== 'AbortError') console.log('Autoplay prevented:', e);
@@ -963,10 +1092,87 @@ class VideoPlayer {
}
}
/**
* Helper to play HLS stream (reduces duplication)
*/
playHls(url) {
if (this.hls) {
this.hls.destroy();
}
this.hls = new Hls(this.getHlsConfig());
this.hls.loadSource(url);
this.hls.attachMedia(this.video);
this.hls.on(Hls.Events.MANIFEST_PARSED, () => {
this.video.play().catch(e => {
if (e.name !== 'AbortError') console.log('Autoplay prevented:', e);
});
});
this.hls.on(Hls.Events.ERROR, (event, data) => {
if (data.fatal) {
// Simple error handling for forced HLS/transcode modes
console.error('Fatal HLS error in transcode mode:', data);
this.hls.destroy();
}
});
}
async updateTranscodeStatus(mode, text) {
const el = document.getElementById('player-transcode-status');
if (!el) return;
el.className = 'transcode-status'; // Reset classes
if (mode === 'hidden') {
el.classList.add('hidden');
return;
}
el.textContent = text || mode;
el.classList.add(mode);
// Ensure it's visible
el.classList.remove('hidden');
}
/**
* Get quality label from video height
*/
getQualityLabel(height) {
if (height >= 2160) return '4K';
if (height >= 1440) return '1440p';
if (height >= 1080) return '1080p';
if (height >= 720) return '720p';
if (height >= 480) return '480p';
if (height > 0) return `${height}p`;
return null;
}
/**
* Update quality badge display
*/
updateQualityBadge() {
const badge = document.getElementById('player-quality-badge');
if (!badge) return;
if (this.currentStreamInfo?.height > 0) {
badge.textContent = this.getQualityLabel(this.currentStreamInfo.height);
badge.classList.remove('hidden');
} else {
badge.classList.add('hidden');
}
}
/**
* Fetch EPG data for current channel
*/
async fetchEpgData(channel) {
if (!channel || (!channel.tvgId && !channel.epg_id)) {
this.updateNowPlaying(channel, null);
return;
}
try {
// First, try to use the centralized EpgGuide data (already loaded)
if (window.app && window.app.epgGuide && window.app.epgGuide.programmes) {
@@ -1088,6 +1294,9 @@ class VideoPlayer {
* Stop playback
*/
stop() {
// Stop any running transcode session first
this.stopTranscodeSession();
if (this.hls) {
this.hls.destroy();
this.hls = null;
@@ -1101,6 +1310,11 @@ class VideoPlayer {
this.controlsOverlay?.classList.add('hidden'); // Hide controls
this.loadingSpinner?.classList.remove('show');
this.nowPlaying.classList.add('hidden');
// Hide quality badge
this.currentStreamInfo = null;
const badge = document.getElementById('player-quality-badge');
if (badge) badge.classList.add('hidden');
}
/**
+1
View File
@@ -26,6 +26,7 @@ const Icons = {
check: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>`,
circle: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z"/></svg>`,
close: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>`,
fingerprint: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon"><path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm-2 16l-4-4 1.41-1.41L10 14.17l6.59-6.59L18 9l-8 8z"/></svg>`,
};
window.Icons = Icons;
+9
View File
@@ -12,6 +12,15 @@ class GuidePage {
}
async show() {
// Ensure channel data is loaded before rendering EPG
// This fixes a race condition where navigating directly to the Guide page
// before visiting Live TV would result in an empty EPG.
const channelList = this.app.channelList;
if (!channelList.channels || channelList.channels.length === 0) {
await channelList.loadSources();
await channelList.loadChannels();
}
// Only load EPG data if not already loaded
if (!this.app.epgGuide.programmes || this.app.epgGuide.programmes.length === 0) {
await this.app.epgGuide.loadEpg();
+493
View File
@@ -0,0 +1,493 @@
/**
* Home Dashboard Page
* Features "Continue Watching" and "Recently Added" content
*/
class HomePage {
constructor(app) {
this.app = app;
this.container = null; // Will be set in renderLayout
this.isLoading = false;
}
async init() {
// Initialization if needed
}
async show() {
this.renderLayout();
await this.loadDashboardData();
}
hide() {
// Cleanup if needed
if (this.container) {
this.container.innerHTML = '';
}
}
renderLayout() {
const pageHome = document.getElementById('page-home');
if (!pageHome) return;
pageHome.innerHTML = `
<div class="dashboard-content" id="home-content">
<section class="dashboard-section" id="favorite-channels-section">
<div class="section-header">
<h2>Favorite Channels</h2>
</div>
<div class="scroll-wrapper">
<button class="scroll-arrow scroll-left" aria-label="Scroll left">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/></svg>
</button>
<div class="horizontal-scroll channel-tiles" id="favorite-channels-list">
<div class="loading-state">
<div class="loading"></div>
<span>Loading favorites...</span>
</div>
</div>
<button class="scroll-arrow scroll-right" aria-label="Scroll right">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>
</button>
</div>
</section>
<section class="dashboard-section" id="continue-watching-section">
<div class="section-header">
<h2>Continue Watching</h2>
</div>
<div class="scroll-wrapper">
<button class="scroll-arrow scroll-left" aria-label="Scroll left">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/></svg>
</button>
<div class="horizontal-scroll" id="continue-watching-list">
<div class="loading-state">
<div class="loading"></div>
<span>Loading history...</span>
</div>
</div>
<button class="scroll-arrow scroll-right" aria-label="Scroll right">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>
</button>
</div>
</section>
<section class="dashboard-section">
<div class="section-header">
<h2>Recently Added Movies</h2>
</div>
<div class="scroll-wrapper">
<button class="scroll-arrow scroll-left" aria-label="Scroll left">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/></svg>
</button>
<div class="horizontal-scroll" id="recent-movies-list">
<div class="loading-state">
<div class="loading"></div>
<span>Loading recently added...</span>
</div>
</div>
<button class="scroll-arrow scroll-right" aria-label="Scroll right">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>
</button>
</div>
</section>
<section class="dashboard-section">
<div class="section-header">
<h2>Recently Added Series</h2>
</div>
<div class="scroll-wrapper">
<button class="scroll-arrow scroll-left" aria-label="Scroll left">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/></svg>
</button>
<div class="horizontal-scroll" id="recent-series-list">
<div class="loading-state">
<div class="loading"></div>
<span>Loading recently added...</span>
</div>
</div>
<button class="scroll-arrow scroll-right" aria-label="Scroll right">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>
</button>
</div>
</section>
</div>
`;
this.container = document.getElementById('home-content');
// Attach scroll arrow handlers
this.initScrollArrows();
}
initScrollArrows() {
this.container.querySelectorAll('.scroll-wrapper').forEach(wrapper => {
const scrollContainer = wrapper.querySelector('.horizontal-scroll');
const leftBtn = wrapper.querySelector('.scroll-left');
const rightBtn = wrapper.querySelector('.scroll-right');
if (!scrollContainer || !leftBtn || !rightBtn) return;
const scrollAmount = 300; // pixels to scroll per click
leftBtn.addEventListener('click', () => {
scrollContainer.scrollBy({ left: -scrollAmount, behavior: 'smooth' });
});
rightBtn.addEventListener('click', () => {
scrollContainer.scrollBy({ left: scrollAmount, behavior: 'smooth' });
});
// Update arrow visibility based on scroll position
const updateArrows = () => {
const { scrollLeft, scrollWidth, clientWidth } = scrollContainer;
leftBtn.classList.toggle('hidden', scrollLeft <= 0);
rightBtn.classList.toggle('hidden', scrollLeft + clientWidth >= scrollWidth - 5);
};
// Store reference for later updates
wrapper._updateArrows = updateArrows;
scrollContainer.addEventListener('scroll', updateArrows);
// Initial check after content loads
setTimeout(updateArrows, 100);
});
}
/**
* Re-check scroll arrow visibility for all sections
* Call this after dynamically loading content
*/
updateScrollArrows() {
this.container?.querySelectorAll('.scroll-wrapper').forEach(wrapper => {
if (wrapper._updateArrows) {
wrapper._updateArrows();
}
});
}
async loadDashboardData() {
if (this.isLoading) return;
this.isLoading = true;
try {
// 0. Load Favorite Channels (first section)
await this.renderFavoriteChannels();
// 1. Load Watch History
const history = await window.API.request('GET', '/history?limit=12');
if (history && Array.isArray(history)) {
this.renderHistory(history);
}
// 2. Load Recent Items
this.renderRecentMovies();
this.renderRecentSeries();
} catch (err) {
console.error('[Dashboard] Error loading data:', err);
} finally {
this.isLoading = false;
}
}
async renderFavoriteChannels() {
const list = document.getElementById('favorite-channels-list');
const section = document.getElementById('favorite-channels-section');
if (!list || !section) return;
try {
// Fetch favorite channels for current user
const favorites = await window.API.request('GET', '/favorites?itemType=channel');
if (!favorites || favorites.length === 0) {
list.innerHTML = '<div class="empty-state hint">Add channels to favorites from Live TV</div>';
return;
}
// Ensure channel list is loaded to resolve channel details
const channelList = this.app.channelList;
if (!channelList.channels || channelList.channels.length === 0) {
await channelList.loadSources();
await channelList.loadChannels();
}
// Match favorites to channel data
const channels = [];
for (const fav of favorites) {
// Find channel in loaded channel list
const channel = channelList.channels.find(ch =>
String(ch.sourceId) === String(fav.source_id) &&
(String(ch.id) === String(fav.item_id) || String(ch.streamId) === String(fav.item_id))
);
if (channel) {
channels.push({ ...channel, favoriteId: fav.id });
}
}
if (channels.length === 0) {
list.innerHTML = '<div class="empty-state hint">Add channels to favorites from Live TV</div>';
return;
}
// Render channel tiles
list.innerHTML = channels.map(ch => this.createChannelTile(ch)).join('');
// Attach click handlers
list.querySelectorAll('.channel-tile').forEach(tile => {
tile.addEventListener('click', () => {
const channelId = tile.dataset.channelId;
const sourceId = tile.dataset.sourceId;
this.playChannel(channelId, sourceId);
});
});
// Update scroll arrows after content renders
this.updateScrollArrows();
} catch (err) {
console.error('[Dashboard] Error loading favorite channels:', err);
list.innerHTML = '<div class="empty-state hint">Error loading favorites</div>';
}
}
createChannelTile(channel) {
const logo = channel.tvgLogo || '/img/placeholder.png';
const logoUrl = logo.startsWith('http') ? `/api/proxy/image?url=${encodeURIComponent(logo)}` : logo;
const name = channel.name || 'Unknown';
return `
<div class="channel-tile" data-channel-id="${channel.id}" data-source-id="${channel.sourceId}">
<div class="tile-logo">
<img src="${logoUrl}" alt="${name}" loading="lazy" onerror="this.onerror=null;this.src='/img/placeholder.png'">
</div>
<div class="tile-name" title="${name}">${name}</div>
</div>
`;
}
playChannel(channelId, sourceId) {
// Navigate to Live TV and select the channel
this.app.navigateTo('live');
// Small delay to ensure page is ready
setTimeout(() => {
const channelList = this.app.channelList;
if (channelList) {
// Find and select the channel
const channel = channelList.channels.find(ch =>
String(ch.id) === String(channelId) && String(ch.sourceId) === String(sourceId)
);
if (channel) {
channelList.selectChannel({
channelId: channel.id,
sourceId: channel.sourceId,
sourceType: channel.sourceType,
streamId: channel.streamId || '',
url: channel.url || ''
});
}
}
}, 100);
}
renderHistory(items) {
const list = document.getElementById('continue-watching-list');
const section = document.getElementById('continue-watching-section');
if (!list || !section) return;
if (items.length === 0) {
section.classList.add('hidden');
return;
}
section.classList.remove('hidden');
list.innerHTML = items.map(item => this.createCard(item)).join('');
// Attach click listeners
list.querySelectorAll('.dashboard-card').forEach(card => {
card.addEventListener('click', () => {
const id = card.dataset.id;
const item = items.find(i => i.item_id === id);
if (item) {
const type = item.item_type || item.type;
// IF it's a series, checking details is better than blind resume
// BUT for "Continue Watching", we ideally want to resume
// Prioritize playing directly for resume tiles
this.playItem(item, true); // true for resume
}
});
});
// Update scroll arrows after content renders
this.updateScrollArrows();
}
navigateToSeries(item) {
if (!this.app.pages.series) return;
// Prepare the series object as expected by SeriesPage.showSeriesDetails
const series = {
series_id: item.item_id,
sourceId: item.source_id,
name: item.name || (item.data ? item.data.title : 'Series'),
cover: item.stream_icon || (item.data ? item.data.poster : null),
plot: item.data ? item.data.description : '',
year: item.data ? item.data.year : ''
};
// Switch page
this.app.navigateTo('series');
// Show details (delay slightly to ensure page is visible)
setTimeout(() => {
this.app.pages.series.showSeriesDetails(series);
}, 100);
}
async renderRecentMovies() {
const list = document.getElementById('recent-movies-list');
if (!list) return;
try {
const movies = await window.API.request('GET', '/channels/recent?type=movie&limit=12');
if (!movies || movies.length === 0) {
list.innerHTML = '<div class="empty-state hint">No recently added movies found</div>';
return;
}
list.innerHTML = movies.map(item => this.createRecentCard(item)).join('');
// Attach listeners
list.querySelectorAll('.dashboard-card').forEach(card => {
card.addEventListener('click', () => {
const id = card.dataset.id;
const item = movies.find(m => m.item_id === id);
if (item) this.playItem(item);
});
});
// Update scroll arrows after content renders
this.updateScrollArrows();
} catch (err) {
console.error('[Dashboard] Error loading recent movies:', err);
}
}
async renderRecentSeries() {
const list = document.getElementById('recent-series-list');
if (!list) return;
try {
const series = await window.API.request('GET', '/channels/recent?type=series&limit=12');
if (!series || series.length === 0) {
list.innerHTML = '<div class="empty-state hint">No recently added series found</div>';
return;
}
list.innerHTML = series.map(item => this.createRecentCard(item)).join('');
// Attach listeners
list.querySelectorAll('.dashboard-card').forEach(card => {
card.addEventListener('click', () => {
const id = card.dataset.id;
const item = series.find(s => s.item_id === id);
if (item) this.navigateToSeries(item);
});
});
// Update scroll arrows after content renders
this.updateScrollArrows();
} catch (err) {
console.error('[Dashboard] Error loading recent series:', err);
}
}
createCard(item) {
const { data, progress, duration, item_id } = item;
const type = item.item_type || item.type;
const percent = Math.min(100, Math.round((progress / duration) * 100));
// Proxy the poster if it's an external URL
const poster = data.poster || '/img/poster-placeholder.jpg';
const posterUrl = poster.startsWith('http') ? `/api/proxy/image?url=${encodeURIComponent(poster)}` : poster;
return `
<div class="dashboard-card" data-id="${item_id}" data-type="${type}">
<div class="card-image">
<img src="${posterUrl}" alt="${data.title || item.name}" loading="lazy" onerror="this.onerror=null;this.src='/img/poster-placeholder.jpg'">
<div class="progress-bar-container">
<div class="progress-bar" style="width: ${percent}%"></div>
</div>
<div class="play-icon-overlay">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
</div>
</div>
<div class="card-info">
<div class="card-title" title="${item.name || data.title}">${item.name || data.title || 'Unknown Title'}</div>
<div class="card-subtitle">${data.subtitle || (type === 'movie' ? 'Movie' : 'Series')}</div>
</div>
</div>
`;
}
createRecentCard(item) {
const { data, item_id } = item;
const type = item.type || item.item_type;
const poster = item.stream_icon || data.poster || '/img/poster-placeholder.jpg';
const posterUrl = poster.startsWith('http') ? `/api/proxy/image?url=${encodeURIComponent(poster)}` : poster;
return `
<div class="dashboard-card" data-id="${item_id}" data-type="${type}">
<div class="card-image">
<img src="${posterUrl}" alt="${item.name}" loading="lazy" onerror="this.onerror=null;this.src='/img/poster-placeholder.jpg'">
<div class="play-icon-overlay">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
</div>
</div>
<div class="card-info">
<div class="card-title" title="${item.name || (data && data.title)}">${item.name || (data && data.title) || 'Unknown Title'}</div>
<div class="card-subtitle">${(data && data.subtitle) || (type === 'movie' ? 'Movie' : 'Series')}</div>
</div>
</div>
`;
}
async playItem(item, isResume = false) {
if (!this.app.pages.watch) return;
try {
const type = item.item_type || item.type;
const streamType = type === 'movie' ? 'movie' : 'series';
const sourceId = item.source_id || (item.data && item.data.sourceId);
const streamId = item.item_id;
const container = item.container_extension || (item.data && item.data.containerExtension) || 'mp4';
const result = await window.API.request('GET', `/proxy/xtream/${sourceId}/stream/${streamId}/${streamType}?container=${container}`);
if (result && result.url) {
const content = {
id: item.item_id,
type: type,
title: item.name || item.data.title,
subtitle: item.data.subtitle || (type === 'movie' ? 'Movie' : 'Series'),
poster: item.stream_icon || item.data.poster,
sourceId: sourceId,
resumeTime: isResume ? item.progress : 0,
containerExtension: container
};
// Switch to watch page
this.app.navigateTo('watch');
this.app.pages.watch.play(content, result.url);
}
} catch (err) {
console.error('[Dashboard] Playback failed:', err);
}
}
}
window.HomePage = HomePage;
@@ -2,7 +2,7 @@
* Home Page Controller
*/
class HomePage {
class LivePage {
constructor(app) {
this.app = app;
this.handleKeydown = this.handleKeydown.bind(this);
@@ -106,4 +106,4 @@ class HomePage {
}
}
window.HomePage = HomePage;
window.LivePage = LivePage;
+2 -1
View File
@@ -352,7 +352,8 @@ class MoviesPage {
year: movie.year || movie.releaseDate?.substring(0, 4),
rating: movie.rating,
sourceId: movie.sourceId,
categoryId: movie.category_id
categoryId: movie.category_id,
containerExtension: container
}, result.url);
}
}
+2 -1
View File
@@ -452,7 +452,8 @@ class SeriesPage {
seriesId: this.currentSeries?.series_id,
seriesInfo: this.currentSeriesInfo,
currentSeason: seasonNum,
currentEpisode: episodeNum
currentEpisode: episodeNum,
containerExtension: container
}, result.url);
}
}
+332 -79
View File
@@ -20,6 +20,9 @@ class SettingsPage {
// Player settings
this.initPlayerSettings();
// Transcoding settings
this.initTranscodingSettings();
// User management (admin only)
this.initUserManagement();
}
@@ -31,10 +34,6 @@ class SettingsPage {
const volumeValueDisplay = document.getElementById('volume-value');
const rememberVolumeToggle = document.getElementById('setting-remember-volume');
const autoPlayNextToggle = document.getElementById('setting-autoplay-next');
const forceProxyToggle = document.getElementById('setting-force-proxy');
const forceTranscodeToggle = document.getElementById('setting-force-transcode');
const forceRemuxToggle = document.getElementById('setting-force-remux');
const autoTranscodeToggle = document.getElementById('setting-auto-transcode');
// Load current settings
if (this.app.player?.settings) {
@@ -44,18 +43,6 @@ class SettingsPage {
volumeValueDisplay.textContent = this.app.player.settings.defaultVolume + '%';
rememberVolumeToggle.checked = this.app.player.settings.rememberVolume;
autoPlayNextToggle.checked = this.app.player.settings.autoPlayNextEpisode;
if (forceProxyToggle) {
forceProxyToggle.checked = this.app.player.settings.forceProxy || false;
}
if (forceTranscodeToggle) {
forceTranscodeToggle.checked = this.app.player.settings.forceTranscode || false;
}
if (forceRemuxToggle) {
forceRemuxToggle.checked = this.app.player.settings.forceRemux || false;
}
if (autoTranscodeToggle) {
autoTranscodeToggle.checked = this.app.player.settings.autoTranscode || false;
}
}
// Arrow keys toggle
@@ -66,56 +53,30 @@ class SettingsPage {
// Overlay duration
overlayDurationInput.addEventListener('change', () => {
const value = Math.min(30, Math.max(1, parseInt(overlayDurationInput.value) || 5));
overlayDurationInput.value = value;
this.app.player.settings.overlayDuration = value;
this.app.player.settings.overlayDuration = parseInt(overlayDurationInput.value) || 5;
this.app.player.saveSettings();
});
// Default volume slider
defaultVolumeSlider?.addEventListener('input', () => {
const value = parseInt(defaultVolumeSlider.value);
defaultVolumeSlider.addEventListener('input', () => {
const value = defaultVolumeSlider.value;
volumeValueDisplay.textContent = value + '%';
this.app.player.settings.defaultVolume = value;
this.app.player.settings.defaultVolume = parseInt(value);
this.app.player.saveSettings();
});
// Remember volume toggle
rememberVolumeToggle?.addEventListener('change', () => {
rememberVolumeToggle.addEventListener('change', () => {
this.app.player.settings.rememberVolume = rememberVolumeToggle.checked;
this.app.player.saveSettings();
});
// Auto-play next episode toggle
autoPlayNextToggle?.addEventListener('change', () => {
autoPlayNextToggle.addEventListener('change', () => {
this.app.player.settings.autoPlayNextEpisode = autoPlayNextToggle.checked;
this.app.player.saveSettings();
});
// Force proxy toggle
forceProxyToggle?.addEventListener('change', () => {
this.app.player.settings.forceProxy = forceProxyToggle.checked;
this.app.player.saveSettings();
});
// Force transcode toggle
forceTranscodeToggle?.addEventListener('change', () => {
this.app.player.settings.forceTranscode = forceTranscodeToggle.checked;
this.app.player.saveSettings();
});
// Force remux toggle
forceRemuxToggle?.addEventListener('change', () => {
this.app.player.settings.forceRemux = forceRemuxToggle.checked;
this.app.player.saveSettings();
});
// Auto transcode (smart) toggle
autoTranscodeToggle?.addEventListener('change', () => {
this.app.player.settings.autoTranscode = autoTranscodeToggle.checked;
this.app.player.saveSettings();
});
// EPG refresh interval
const epgRefreshSelect = document.getElementById('epg-refresh-interval');
if (epgRefreshSelect && this.app.player?.settings) {
@@ -126,25 +87,188 @@ class SettingsPage {
epgRefreshSelect.addEventListener('change', () => {
this.app.player.settings.epgRefreshInterval = epgRefreshSelect.value;
this.app.player.saveSettings();
// Server-side sync timer is restarted automatically via settings API
});
}
// Update last refreshed display
this.updateEpgLastRefreshed();
}
// Stream output format
const streamFormatSelect = document.getElementById('setting-stream-format');
if (streamFormatSelect && this.app.player?.settings) {
// Load saved value from player settings
streamFormatSelect.value = this.app.player.settings.streamFormat || 'm3u8';
async initTranscodingSettings() {
// Encoder settings
const hwEncoderSelect = document.getElementById('setting-hw-encoder');
const maxResolutionSelect = document.getElementById('setting-max-resolution');
const qualitySelect = document.getElementById('setting-quality');
// Save on change
streamFormatSelect.addEventListener('change', () => {
this.app.player.settings.streamFormat = streamFormatSelect.value;
// Stream processing (use -tc suffix IDs from Transcoding tab)
const forceProxyToggle = document.getElementById('setting-force-proxy-tc');
const autoTranscodeToggle = document.getElementById('setting-auto-transcode-tc');
const forceTranscodeToggle = document.getElementById('setting-force-transcode-tc');
const forceVideoTranscodeToggle = document.getElementById('setting-force-video-transcode-tc');
const forceRemuxToggle = document.getElementById('setting-force-remux-tc');
const streamFormatSelect = document.getElementById('setting-stream-format-tc');
// User-Agent (Transcoding tab versions)
const userAgentSelect = document.getElementById('setting-user-agent-tc');
const userAgentCustomInput = document.getElementById('setting-user-agent-custom-tc');
const customUaContainer = document.getElementById('custom-user-agent-container-tc');
// Fetch settings directly from API to avoid race condition with VideoPlayer
let s;
try {
s = await API.settings.get();
} catch (err) {
console.warn('[Settings] Failed to load settings from API, using player defaults:', err);
s = this.app.player?.settings || {};
}
if (hwEncoderSelect) hwEncoderSelect.value = s.hwEncoder || 'auto';
if (maxResolutionSelect) maxResolutionSelect.value = s.maxResolution || '1080p';
if (qualitySelect) qualitySelect.value = s.quality || 'medium';
if (forceProxyToggle) forceProxyToggle.checked = s.forceProxy === true;
if (autoTranscodeToggle) autoTranscodeToggle.checked = s.autoTranscode !== false;
if (forceTranscodeToggle) forceTranscodeToggle.checked = s.forceTranscode === true;
if (forceVideoTranscodeToggle) forceVideoTranscodeToggle.checked = s.forceVideoTranscode === true;
if (forceRemuxToggle) forceRemuxToggle.checked = s.forceRemux || false;
if (streamFormatSelect) streamFormatSelect.value = s.streamFormat || 'm3u8';
if (userAgentSelect) userAgentSelect.value = s.userAgentPreset || 'chrome';
if (userAgentCustomInput) userAgentCustomInput.value = s.userAgentCustom || '';
if (customUaContainer) {
customUaContainer.style.display = userAgentSelect?.value === 'custom' ? 'flex' : 'none';
}
// Event listeners for encoder settings
hwEncoderSelect?.addEventListener('change', () => {
this.app.player.settings.hwEncoder = hwEncoderSelect.value;
this.app.player.saveSettings();
});
maxResolutionSelect?.addEventListener('change', () => {
this.app.player.settings.maxResolution = maxResolutionSelect.value;
this.app.player.saveSettings();
});
qualitySelect?.addEventListener('change', () => {
this.app.player.settings.quality = qualitySelect.value;
this.app.player.saveSettings();
});
// Audio Mix Preset
const audioMixSelect = document.getElementById('setting-audio-mix');
if (audioMixSelect) {
audioMixSelect.value = s.audioMixPreset || 'auto';
audioMixSelect.addEventListener('change', () => {
this.app.player.settings.audioMixPreset = audioMixSelect.value;
this.app.player.saveSettings();
});
}
// Stream processing toggles
forceProxyToggle?.addEventListener('change', () => {
this.app.player.settings.forceProxy = forceProxyToggle.checked;
this.app.player.saveSettings();
});
autoTranscodeToggle?.addEventListener('change', () => {
this.app.player.settings.autoTranscode = autoTranscodeToggle.checked;
this.app.player.saveSettings();
});
forceTranscodeToggle?.addEventListener('change', () => {
this.app.player.settings.forceTranscode = forceTranscodeToggle.checked;
this.app.player.saveSettings();
});
forceVideoTranscodeToggle?.addEventListener('change', () => {
this.app.player.settings.forceVideoTranscode = forceVideoTranscodeToggle.checked;
this.app.player.saveSettings();
});
forceRemuxToggle?.addEventListener('change', () => {
this.app.player.settings.forceRemux = forceRemuxToggle.checked;
this.app.player.saveSettings();
});
streamFormatSelect?.addEventListener('change', () => {
this.app.player.settings.streamFormat = streamFormatSelect.value;
this.app.player.saveSettings();
});
// User-Agent handlers
const toggleCustomInput = () => {
if (customUaContainer) {
customUaContainer.style.display = userAgentSelect?.value === 'custom' ? 'flex' : 'none';
}
};
userAgentSelect?.addEventListener('change', () => {
this.app.player.settings.userAgentPreset = userAgentSelect.value;
this.app.player.saveSettings();
toggleCustomInput();
});
userAgentCustomInput?.addEventListener('change', () => {
this.app.player.settings.userAgentCustom = userAgentCustomInput.value;
this.app.player.saveSettings();
});
}
/**
* Load and display hardware info in Transcoding tab
*/
async loadHardwareInfo() {
const container = document.getElementById('hw-info-container');
if (!container) return;
try {
const response = await fetch('/api/settings/hw-info');
if (!response.ok) throw new Error('Failed to fetch hardware info');
const hwInfo = await response.json();
const detected = [];
// Only show detected hardware
if (hwInfo.nvidia?.available) {
detected.push(`<div class="hw-info-item hw-available">
<span class="hw-badge">✓ NVIDIA</span>
<span class="hw-name">${hwInfo.nvidia.name}</span>
</div>`);
}
if (hwInfo.amf?.available) {
detected.push(`<div class="hw-info-item hw-available">
<span class="hw-badge">✓ AMD</span>
<span class="hw-name">${hwInfo.amf.name || 'Available'}</span>
</div>`);
}
if (hwInfo.qsv?.available) {
detected.push(`<div class="hw-info-item hw-available">
<span class="hw-badge">✓ Intel QSV</span>
<span class="hw-name">Available</span>
</div>`);
}
if (hwInfo.vaapi?.available) {
detected.push(`<div class="hw-info-item hw-available">
<span class="hw-badge">✓ VAAPI</span>
<span class="hw-name">${hwInfo.vaapi.device || 'Available'}</span>
</div>`);
}
let html;
if (detected.length > 0) {
html = `<div class="hw-info-grid">${detected.join('')}</div>`;
html += `<p class="hint" style="margin-top: var(--space-sm);">Recommended encoder: <strong>${hwInfo.recommended}</strong></p>`;
} else {
html = `<p class="hint">No GPU acceleration detected. Using software encoding.</p>`;
}
container.innerHTML = html;
} catch (err) {
console.error('Error loading hardware info:', err);
container.innerHTML = '<p class="hint error">Failed to load hardware info</p>';
}
}
initUserManagement() {
@@ -179,53 +303,166 @@ class SettingsPage {
try {
const users = await API.users.getAll();
// Store users in memory for easy access during edit
this.users = users;
if (users.length === 0) {
userList.innerHTML = '<tr><td colspan="4" class="hint">No users found</td></tr>';
userList.innerHTML = '<tr><td colspan="5" class="hint">No users found</td></tr>';
return;
}
userList.innerHTML = users.map(user => `
userList.innerHTML = users.map(user => {
const isSSO = !!user.oidcId;
const typeBadge = isSSO
? '<span class="user-badge user-badge-sso">SSO</span>'
: '<span class="user-badge user-badge-local">Local</span>';
const roleBadge = user.role === 'admin'
? '<span class="user-badge user-badge-admin">Admin</span>'
: '<span class="user-badge user-badge-viewer">Viewer</span>';
return `
<tr>
<td>${user.username}</td>
<td><span class="badge badge-${user.role === 'admin' ? 'primary' : 'secondary'}">${user.role}</span></td>
<td>
<div style="display:flex;align-items:center;gap:8px;">
<strong>${user.username}</strong>
${typeBadge}
</div>
</td>
<td>${user.email || '<span class="hint">-</span>'}</td>
<td>${roleBadge}</td>
<td>${user.createdAt ? new Date(user.createdAt).toLocaleDateString() : 'N/A'}</td>
<td>
<button class="btn btn-sm btn-secondary" onclick="window.app.pages.settings.editUser(${user.id})">Edit</button>
<button class="btn btn-sm btn-secondary" onclick="window.app.pages.settings.openEditUserModal(${user.id})">Edit</button>
<button class="btn btn-sm btn-error" onclick="window.app.pages.settings.deleteUser(${user.id}, '${user.username}')">Delete</button>
</td>
</tr>
`).join('');
`}).join('');
} catch (err) {
console.error('Error loading users:', err);
userList.innerHTML = '<tr><td colspan="4" class="hint">Error loading users</td></tr>';
userList.innerHTML = '<tr><td colspan="5" class="hint">Error loading users</td></tr>';
}
}
async editUser(userId) {
const username = prompt('Enter new username (leave blank to keep current):');
const password = prompt('Enter new password (leave blank to keep current):');
const role = prompt('Enter role (admin or viewer, leave blank to keep current):');
openEditUserModal(userId) {
console.log('openEditUserModal called with ID:', userId, 'Type:', typeof userId);
console.log('Current users list:', this.users);
const updates = {};
if (username) updates.username = username;
if (password) updates.password = password;
if (role) updates.role = role;
const user = this.users.find(u => u.id === userId);
if (!user) {
console.error('User not found in this.users cache!');
console.log('Available IDs:', this.users.map(u => u.id));
return;
}
console.log('User found:', user);
if (Object.keys(updates).length === 0) {
alert('No changes made');
const modal = document.getElementById('edit-user-modal');
console.log('Modal element:', modal);
if (!modal) {
console.error('CRITICAL: Modal element #edit-user-modal not found in DOM!');
alert('Error: Modal not found. Please refresh the page.');
return;
}
const isSSO = !!user.oidcId;
console.log('Is SSO user:', isSSO);
// Populate form with null checks
try {
await API.users.update(userId, updates);
alert('User updated successfully!');
this.loadUsers();
const editId = document.getElementById('edit-user-id');
const editUsername = document.getElementById('edit-username');
const editEmail = document.getElementById('edit-email');
const editRole = document.getElementById('edit-role');
const editPassword = document.getElementById('edit-password');
console.log('Form elements found:', { editId, editUsername, editEmail, editRole, editPassword });
if (editId) editId.value = user.id;
if (editUsername) editUsername.value = user.username;
if (editEmail) editEmail.value = user.email || '';
if (editRole) editRole.value = user.role;
if (editPassword) editPassword.value = '';
// Handle SSO specific UI
const passwordHint = document.getElementById('edit-password-hint');
const oidcGroup = document.getElementById('oidc-info-group');
const oidcIdDisplay = document.getElementById('edit-oidc-id');
if (isSSO) {
if (editPassword) {
editPassword.disabled = true;
editPassword.placeholder = "Managed by SSO Provider";
}
if (passwordHint) passwordHint.textContent = "Password cannot be changed for SSO users.";
if (oidcGroup) oidcGroup.classList.remove('hidden');
if (oidcIdDisplay) oidcIdDisplay.textContent = user.oidcId;
} else {
if (editPassword) {
editPassword.disabled = false;
editPassword.placeholder = "Leave blank to keep current";
}
if (passwordHint) passwordHint.textContent = "Optional. Leave blank to keep unchanged.";
if (oidcGroup) oidcGroup.classList.add('hidden');
}
// Show modal
console.log('Adding active class to modal...');
modal.classList.add('active');
console.log('Modal classes after add:', modal.classList.toString());
// Setup Close/Cancel handlers (once)
this.setupModalHandlers(modal);
console.log('Modal should now be visible!');
} catch (err) {
alert('Error updating user: ' + err.message);
console.error('Error populating modal:', err);
alert('Error opening edit modal: ' + err.message);
}
}
setupModalHandlers(modal) {
if (this.modalHandlersSetup) return;
const closeBtn = document.getElementById('edit-user-close');
const cancelBtn = document.getElementById('edit-user-cancel');
const saveBtn = document.getElementById('edit-user-save');
const closeModal = () => modal.classList.remove('active');
closeBtn.onclick = closeModal;
cancelBtn.onclick = closeModal;
// Click outside to close
modal.onclick = (e) => {
if (e.target === modal) closeModal();
};
// Save Handler
saveBtn.onclick = async () => {
const userId = document.getElementById('edit-user-id').value;
const updates = {
username: document.getElementById('edit-username').value,
role: document.getElementById('edit-role').value
};
const newPassword = document.getElementById('edit-password').value;
if (newPassword && !document.getElementById('edit-password').disabled) {
updates.password = newPassword;
}
try {
await API.users.update(userId, updates);
// alert('User updated successfully!'); // Optional: Replace with toast?
closeModal();
this.loadUsers();
} catch (err) {
alert('Error updating user: ' + err.message);
}
};
this.modalHandlersSetup = true;
}
async deleteUser(userId, username) {
if (!confirm(`Are you sure you want to delete user "${username}"?`)) {
return;
@@ -233,7 +470,6 @@ class SettingsPage {
try {
await API.users.delete(userId);
alert('User deleted successfully!');
this.loadUsers();
} catch (err) {
alert('Error deleting user: ' + err.message);
@@ -253,6 +489,11 @@ class SettingsPage {
if (tabName === 'users') {
this.loadUsers();
}
// Load hardware info when switching to transcode tab
if (tabName === 'transcode') {
this.loadHardwareInfo();
}
}
async show() {
@@ -297,6 +538,18 @@ class SettingsPage {
if (autoTranscodeToggle) autoTranscodeToggle.checked = s.autoTranscode || false;
if (epgRefreshSelect) epgRefreshSelect.value = s.epgRefreshInterval || '24';
if (streamFormatSelect) streamFormatSelect.value = s.streamFormat || 'm3u8';
// User-Agent settings
const userAgentSelect = document.getElementById('setting-user-agent');
const userAgentCustomInput = document.getElementById('setting-user-agent-custom');
const customUaContainer = document.getElementById('custom-user-agent-container');
if (userAgentSelect) {
userAgentSelect.value = s.userAgentPreset || 'chrome';
if (customUaContainer) {
customUaContainer.style.display = userAgentSelect.value === 'custom' ? 'flex' : 'none';
}
}
if (userAgentCustomInput) userAgentCustomInput.value = s.userAgentCustom || '';
}
// Update EPG last refreshed display
+324 -59
View File
@@ -59,6 +59,10 @@ class WatchPage {
this.captionsMenu = document.getElementById('watch-captions-menu');
this.captionsList = document.getElementById('watch-captions-list');
// Transcode Status
this.transcodeStatusEx = document.getElementById('watch-transcode-status');
this.qualityBadgeEl = document.getElementById('watch-quality-badge');
// State
this.hls = null;
this.content = null;
@@ -79,6 +83,9 @@ class WatchPage {
this.nextEpisodeCountdown = 10;
this.nextEpisodeInterval = null;
// Watch history
this.historyInterval = null;
this.init();
}
@@ -195,8 +202,13 @@ class WatchPage {
this.seriesInfo = content.seriesInfo || null;
this.currentSeason = content.currentSeason || null;
this.currentEpisode = content.currentEpisode || null;
this.resumeTime = content.resumeTime || 0;
this.containerExtension = content.containerExtension || 'mp4';
this.returnPage = content.type === 'movie' ? 'movies' : 'series';
// Stop any Live TV playback before starting movie/series
this.app?.player?.stop?.();
// Reset state
this.cancelNextEpisode();
@@ -213,6 +225,9 @@ class WatchPage {
// Load video
await this.loadVideo(streamUrl);
// Show Now Playing indicator in navbar
this.showNowPlaying(content.title);
// Populate details section
this.renderDetails();
@@ -229,9 +244,119 @@ class WatchPage {
// Check favorite status
await this.checkFavorite();
// Show overlay initially
this.showOverlay();
// Start watch history tracking
this.startHistoryTracking();
}
/**
* Show Now Playing indicator in navbar
*/
showNowPlaying(title) {
const indicator = document.getElementById('now-playing-indicator');
const textEl = document.getElementById('now-playing-text');
if (indicator && textEl) {
textEl.textContent = title || 'Now Playing';
indicator.classList.remove('hidden');
}
}
/**
* Hide Now Playing indicator in navbar
*/
hideNowPlaying() {
const indicator = document.getElementById('now-playing-indicator');
if (indicator) {
indicator.classList.add('hidden');
}
}
/**
* Start a HLS transcode session
*/
async startTranscodeSession(url, options = {}) {
try {
console.log('[WatchPage] Starting HLS transcode session...', options);
const res = await fetch('/api/transcode/session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
url,
seekOffset: this.resumeTime, // Pass resume point to backend
...options
})
});
if (!res.ok) throw new Error('Failed to start session');
const session = await res.json();
this.currentSessionId = session.sessionId;
return session.playlistUrl;
} catch (err) {
console.error('[WatchPage] Session start failed:', err);
// Fallback to direct transcode if session fails
return `/api/transcode?url=${encodeURIComponent(url)}`;
}
}
/**
* Stop and cleanup current transcode session
*/
async stopTranscodeSession() {
if (this.currentSessionId) {
console.log('[WatchPage] Stopping transcode session:', this.currentSessionId);
try {
// Fire and forget cleanup
fetch(`/api/transcode/${this.currentSessionId}`, { method: 'DELETE' });
} catch (err) {
console.error('Failed to stop session:', err);
}
this.currentSessionId = null;
}
}
async updateTranscodeStatus(mode, text) {
if (!this.transcodeStatusEx) return;
this.transcodeStatusEx.className = 'transcode-status'; // Reset classes
if (mode === 'hidden') {
this.transcodeStatusEx.classList.add('hidden');
return;
}
this.transcodeStatusEx.textContent = text || mode;
this.transcodeStatusEx.classList.add(mode);
// Ensure it's visible
this.transcodeStatusEx.classList.remove('hidden');
}
/**
* Get quality label from video height
*/
getQualityLabel(height) {
if (height >= 2160) return '4K';
if (height >= 1440) return '1440p';
if (height >= 1080) return '1080p';
if (height >= 720) return '720p';
if (height >= 480) return '480p';
if (height > 0) return `${height}p`;
return null;
}
/**
* Update quality badge display
*/
updateQualityBadge() {
if (!this.qualityBadgeEl) return;
if (this.currentStreamInfo?.height > 0) {
this.qualityBadgeEl.textContent = this.getQualityLabel(this.currentStreamInfo.height);
this.qualityBadgeEl.classList.remove('hidden');
} else {
this.qualityBadgeEl.classList.add('hidden');
}
}
async loadVideo(url) {
@@ -261,21 +386,38 @@ class WatchPage {
if (settings.autoTranscode) {
console.log('[WatchPage] Auto Transcode enabled. Probing stream...');
try {
const probeRes = await fetch(`/api/probe?url=${encodeURIComponent(url)}`);
const ua = settings.userAgentPreset === 'custom' ? settings.userAgentCustom : settings.userAgentPreset;
const probeRes = await fetch(`/api/probe?url=${encodeURIComponent(url)}&ua=${encodeURIComponent(ua || '')}`);
const info = await probeRes.json();
console.log(`[WatchPage] Probe result: video=${info.video}, audio=${info.audio}, compatible=${info.compatible}`);
console.log(`[WatchPage] Probe result: video=${info.video}, audio=${info.audio}, ${info.width}x${info.height}, compatible=${info.compatible}`);
// Store early probe info for quality display
this.currentStreamInfo = info;
this.updateQualityBadge();
if (info.needsTranscode) {
console.log('[WatchPage] Auto: Using transcode (incompatible audio)');
const finalUrl = `/api/transcode?url=${encodeURIComponent(url)}`;
this.video.src = finalUrl;
this.video.play().catch(e => {
if (e.name !== 'AbortError') console.error('[WatchPage] Autoplay error:', e);
console.log('[WatchPage] Auto: Using HLS transcode session (incompatible audio/video)');
// Heuristic: If video is h264/compat, copy video. Usage: Audio fix.
const videoMode = (info.video && info.video.includes('h264')) ? 'copy' : 'encode';
const statusText = videoMode === 'copy' ? 'Transcoding (Audio)' : 'Transcoding (Video)';
this.updateTranscodeStatus('transcoding', statusText);
const playlistUrl = await this.startTranscodeSession(url, {
videoMode,
seekOffset: this.resumeTime, // Ensure seekOffset is passed
videoCodec: info.video,
audioCodec: info.audio,
audioChannels: info.audioChannels
});
this.playHls(playlistUrl);
this.setVolumeFromStorage();
return;
} else if (info.needsRemux) {
// Remux (container swap) currently doesn't use session logic, uses direct stream
// TODO: Move remux to session logic if seeking is needed for TS files
console.log('[WatchPage] Auto: Using remux (.ts container)');
this.updateTranscodeStatus('remuxing', 'Remux (Auto)');
const finalUrl = `/api/remux?url=${encodeURIComponent(url)}`;
this.video.src = finalUrl;
this.video.play().catch(e => {
@@ -292,14 +434,38 @@ class WatchPage {
}
}
// Priority 1: Force Transcode - route through FFmpeg
if (settings.forceTranscode) {
console.log('[WatchPage] Force Transcode enabled');
const finalUrl = `/api/transcode?url=${encodeURIComponent(url)}`;
this.video.src = finalUrl;
this.video.play().catch(e => {
if (e.name !== 'AbortError') console.error('[WatchPage] Autoplay error:', e);
// Priority 1: Force Video Transcode (Full)
if (settings.forceVideoTranscode) {
console.log('[WatchPage] Force Video Transcode enabled. Starting session (encode)...');
this.updateTranscodeStatus('transcoding', 'Transcoding (Video)');
const playlistUrl = await this.startTranscodeSession(url, {
videoMode: 'encode',
seekOffset: this.resumeTime
});
this.playHls(playlistUrl);
this.setVolumeFromStorage();
return;
}
if (settings.forceTranscode) {
console.log('[WatchPage] Force Audio Transcode enabled. Starting session (copy)...');
this.updateTranscodeStatus('transcoding', 'Transcoding (Audio)');
// Probe to get video codec for HEVC tag handling
let videoCodec = 'unknown';
try {
const ua = settings.userAgentPreset === 'custom' ? settings.userAgentCustom : settings.userAgentPreset;
const probeRes = await fetch(`/api/probe?url=${encodeURIComponent(url)}&ua=${encodeURIComponent(ua || '')}`);
const info = await probeRes.json();
videoCodec = info.video;
} catch (e) { console.warn('Probe failed for force audio, assuming h264'); }
const playlistUrl = await this.startTranscodeSession(url, {
videoMode: 'copy',
videoCodec,
seekOffset: this.resumeTime
});
this.playHls(playlistUrl);
this.setVolumeFromStorage();
return;
}
@@ -307,6 +473,7 @@ class WatchPage {
// Priority 2: Force Remux for raw TS streams
if (settings.forceRemux && isRawTs) {
console.log('[WatchPage] Force Remux enabled');
this.updateTranscodeStatus('remuxing', 'Remux (Force)');
const finalUrl = `/api/remux?url=${encodeURIComponent(url)}`;
this.video.src = finalUrl;
this.video.play().catch(e => {
@@ -325,48 +492,11 @@ class WatchPage {
// Use HLS.js for HLS streams
if (looksLikeHls && Hls.isSupported()) {
this.hls = new Hls({
maxBufferLength: 30,
maxMaxBufferLength: 60,
startLevel: -1,
enableWorker: true,
});
this.hls.loadSource(finalUrl);
this.hls.attachMedia(this.video);
// Listen for subtitle track updates
this.hls.on(Hls.Events.SUBTITLE_TRACKS_UPDATED, (event, data) => {
console.log('[WatchPage] Subtitle tracks updated:', data.subtitleTracks);
// Wait a moment for native text tracks to populate
setTimeout(() => this.updateCaptionsTracks(), 100);
});
this.hls.on(Hls.Events.SUBTITLE_TRACK_SWITCH, (event, data) => {
console.log('[WatchPage] Subtitle track switched:', data);
});
this.hls.on(Hls.Events.MANIFEST_PARSED, () => {
this.video.play().catch(e => {
if (e.name !== 'AbortError') console.error('[WatchPage] Autoplay error:', e);
});
});
this.hls.on(Hls.Events.ERROR, (event, data) => {
if (data.fatal) {
console.error('[WatchPage] HLS fatal error:', data);
// Try proxy on CORS error
if (!needsProxy && (data.type === Hls.ErrorTypes.NETWORK_ERROR)) {
console.log('[WatchPage] Retrying via proxy...');
this.hls.loadSource(`/api/proxy/stream?url=${encodeURIComponent(url)}`);
this.hls.startLoad();
} else {
this.hls.destroy();
}
}
});
this.updateTranscodeStatus('direct', 'Direct HLS');
this.playHls(finalUrl);
} else {
// Direct playback for mp4/mkv/avi
this.updateTranscodeStatus('direct', 'Direct Play');
this.video.src = finalUrl;
this.video.play().catch(e => {
if (e.name !== 'AbortError') console.error('[WatchPage] Autoplay error:', e);
@@ -376,6 +506,56 @@ class WatchPage {
this.setVolumeFromStorage();
}
/**
* Play HLS stream using Hls.js
*/
playHls(url) {
if (this.hls) {
this.hls.destroy();
}
this.hls = new Hls({
maxBufferLength: 30,
maxMaxBufferLength: 60,
startLevel: -1,
enableWorker: true,
});
this.hls.loadSource(url);
this.hls.attachMedia(this.video);
// Listen for subtitle track updates
this.hls.on(Hls.Events.SUBTITLE_TRACKS_UPDATED, (event, data) => {
console.log('[WatchPage] Subtitle tracks updated:', data.subtitleTracks);
// Wait a moment for native text tracks to populate
setTimeout(() => this.updateCaptionsTracks(), 100);
});
this.hls.on(Hls.Events.SUBTITLE_TRACK_SWITCH, (event, data) => {
console.log('[WatchPage] Subtitle track switched:', data);
});
this.hls.on(Hls.Events.MANIFEST_PARSED, () => {
this.video.play().catch(e => {
if (e.name !== 'AbortError') console.error('[WatchPage] Autoplay error:', e);
});
});
this.hls.on(Hls.Events.ERROR, (event, data) => {
if (data.fatal) {
console.error('[WatchPage] HLS fatal error:', data);
// Try proxy on CORS error (only if not already proxied/transcoded)
// Note: Transcoded streams are local, so no CORS issues usually
if (!url.startsWith('/api/') && (data.type === Hls.ErrorTypes.NETWORK_ERROR)) {
console.log('[WatchPage] Retrying via proxy...');
this.playHls(`/api/proxy/stream?url=${encodeURIComponent(this.currentUrl)}`);
} else {
this.hls.destroy();
}
}
});
}
setVolumeFromStorage() {
const savedVolume = localStorage.getItem('nodecast-volume') || '80';
this.video.volume = parseInt(savedVolume) / 100;
@@ -383,6 +563,20 @@ class WatchPage {
}
stop() {
// Stop history tracking and save final progress
this.stopHistoryTracking();
this.saveProgress();
// Cleanup transcode session if exists
this.stopTranscodeSession();
this.updateTranscodeStatus('hidden');
// Hide quality badge
this.currentStreamInfo = null;
if (this.qualityBadgeEl) {
this.qualityBadgeEl.classList.add('hidden');
}
if (this.hls) {
this.hls.destroy();
this.hls = null;
@@ -392,6 +586,8 @@ class WatchPage {
this.video.src = '';
this.video.load();
}
this.hideNowPlaying();
}
// === Playback Controls ===
@@ -498,7 +694,25 @@ class WatchPage {
}
onMetadataLoaded() {
// Duration is unreliable for transcoded streams, so we don't display it
// Detect resolution
if (this.video && this.video.videoHeight > 0) {
this.currentStreamInfo = {
width: this.video.videoWidth,
height: this.video.videoHeight
};
this.updateQualityBadge();
}
// Handle resumption
if (this.resumeTime > 0 && this.video) {
const duration = this.video.duration;
// Only resume if not near the end (95%)
if (!duration || this.resumeTime < duration * 0.95) {
console.log(`[WatchPage] Resuming at ${this.resumeTime}s`);
this.video.currentTime = this.resumeTime;
}
this.resumeTime = 0; // Reset after use
}
}
onPlay() {
@@ -719,7 +933,14 @@ class WatchPage {
renderDetails() {
if (!this.content) return;
this.posterEl.src = this.content.poster || '/img/placeholder.png';
const isChannel = this.content.type === 'channel' || !this.content.type; // Default to channel if unknown
const fallback = isChannel ? '/img/placeholder.png' : '/img/poster-placeholder.jpg';
this.posterEl.onerror = () => {
this.posterEl.onerror = null;
this.posterEl.src = fallback;
};
this.posterEl.src = this.content.poster || fallback;
this.posterEl.alt = this.content.title || '';
this.contentTitleEl.textContent = this.content.title || '';
this.yearEl.textContent = this.content.year || '';
@@ -815,7 +1036,7 @@ class WatchPage {
<div class="watch-recommended-card" data-id="${movie.stream_id}" data-source="${sourceId}">
<img src="${movie.stream_icon || movie.cover || '/img/placeholder.png'}"
alt="${movie.name}"
onerror="this.src='/img/placeholder.png'" loading="lazy">
onerror="this.onerror=null;this.src='/img/placeholder.png'" loading="lazy">
<p>${movie.name}</p>
</div>
`).join('');
@@ -1056,9 +1277,53 @@ class WatchPage {
hide() {
// Called when page becomes hidden
this.stop();
// Don't stop playback here - allow background playback
this.cancelNextEpisode();
}
// ============================================================
// Watch History Tracking
// ============================================================
startHistoryTracking() {
this.stopHistoryTracking(); // Clear existing if any
this.historyInterval = setInterval(() => this.saveProgress(), 10000); // 10s
}
stopHistoryTracking() {
if (this.historyInterval) {
clearInterval(this.historyInterval);
this.historyInterval = null;
}
}
async saveProgress() {
if (!this.content || !this.video || this.video.paused) return;
const progress = Math.floor(this.video.currentTime);
const duration = Math.floor(this.video.duration);
if (isNaN(progress) || isNaN(duration) || duration <= 0) return;
try {
const data = {
title: this.content.title || 'Unknown Title',
subtitle: this.content.subtitle || (this.content.type === 'movie' ? 'Movie' : 'Series'),
poster: this.content.poster,
sourceId: this.content.sourceId,
containerExtension: this.containerExtension
};
await window.API.request('POST', '/history', {
id: this.content.id,
type: this.content.type === 'movie' ? 'movie' : 'episode',
progress,
duration,
data
});
} catch (err) {
console.warn('[History] Failed to save progress:', err);
}
}
}
window.WatchPage = WatchPage;
+46
View File
@@ -195,10 +195,53 @@
<button type="submit" class="btn-login" id="submit-btn">Sign In</button>
</form>
<div class="sso-divider" style="text-align: center; margin: 20px 0; position: relative;">
<span
style="background: var(--color-bg-secondary); padding: 0 10px; color: var(--color-text-secondary); font-size: 14px; position: relative; z-index: 1;">OR</span>
<div
style="position: absolute; top: 50%; left: 0; right: 0; height: 1px; background: var(--color-border); z-index: 0;">
</div>
</div>
<button id="btn-sso-login" class="btn-login"
style="background: transparent; border: 1px solid var(--color-border); color: var(--color-text-primary); display: flex; align-items: center; justify-content: center; gap: 10px; transition: all 0.2s;">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"
style="width: 20px; height: 20px;">
<path
d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm-2 16l-4-4 1.41-1.41L10 14.17l6.59-6.59L18 9l-8 8z" />
</svg>
Sign in with SSO
</button>
<style>
#btn-sso-login:hover {
background: var(--color-bg-tertiary) !important;
border-color: var(--color-accent) !important;
color: var(--color-accent) !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
</style>
</div>
</div>
<script>
// Start SSO Login
document.getElementById('btn-sso-login').addEventListener('click', () => {
window.location.href = '/api/auth/oidc/login';
});
// Check for URL Error params
const urlParams = new URLSearchParams(window.location.search);
const error = urlParams.get('error');
if (error) {
const errorMessage = document.getElementById('error-message');
errorMessage.textContent = error;
errorMessage.classList.add('show');
// Clean URL
window.history.replaceState({}, document.title, window.location.pathname);
}
// Check if setup is required
let isSetupMode = false;
@@ -213,6 +256,9 @@
document.getElementById('submit-btn').textContent = 'Create Account';
document.getElementById('setup-message').classList.add('show');
document.getElementById('password').placeholder = 'Minimum 6 characters';
// Hide SSO in setup mode
document.querySelector('.sso-divider').style.display = 'none';
document.getElementById('btn-sso-login').style.display = 'none';
}
} catch (err) {
console.error('Error checking setup status:', err);
+117
View File
@@ -110,6 +110,121 @@ function configureJwtStrategy(getUserById) {
}));
}
/**
* Configure Passport session serialization
* Required for OIDC flow which uses sessions
*/
function configureSessionSerialization(getUserById) {
passport.serializeUser((user, done) => {
done(null, user.id);
});
passport.deserializeUser(async (id, done) => {
try {
const user = await getUserById(id);
done(null, user);
} catch (err) {
done(err, null);
}
});
}
/**
* Configure Passport OpenID Connect Strategy
*/
function configureOidcStrategy(findUserByOidcId, findUserByEmail, createUser) {
if (!process.env.OIDC_ISSUER_URL || !process.env.OIDC_CLIENT_ID || !process.env.OIDC_CLIENT_SECRET) {
console.warn('OIDC configuration missing - SSO disabled');
return;
}
const { Strategy: OpenIDConnectStrategy } = require('passport-openidconnect');
passport.use(new OpenIDConnectStrategy({
issuer: process.env.OIDC_ISSUER_URL || 'https://mock-issuer.com', // Dummy default for mock
authorizationURL: process.env.OIDC_AUTH_URL || `${process.env.OIDC_ISSUER_URL}/protocol/openid-connect/auth`,
tokenURL: process.env.OIDC_TOKEN_URL || `${process.env.OIDC_ISSUER_URL}/protocol/openid-connect/token`,
userInfoURL: process.env.OIDC_USERINFO_URL || `${process.env.OIDC_ISSUER_URL}/protocol/openid-connect/userinfo`,
clientID: process.env.OIDC_CLIENT_ID || 'mock-client-id',
clientSecret: process.env.OIDC_CLIENT_SECRET || 'mock-secret',
callbackURL: process.env.OIDC_CALLBACK_URL || '/api/auth/oidc/callback',
scope: ['openid', 'profile', 'email']
},
async (...args) => {
// The done callback is always the last argument
const done = args[args.length - 1];
// Map known arguments
// Standard: issuer, sub, profile, accessToken, refreshToken, done
// Some versions: issuer, sub, profile, accessToken, refreshToken, params, done
let issuer, sub, profile;
if (args.length === 3) {
// Scenario: (issuer, profile, done)
const arg0 = args[0];
const arg1 = args[1];
if (typeof arg1 === 'object' && arg1.id) {
issuer = arg0;
profile = arg1;
sub = profile.id;
} else if (typeof arg0 === 'string' && typeof arg1 === 'string') {
issuer = arg0;
sub = arg1;
profile = { id: sub, displayName: 'Unknown' };
}
} else if (args.length >= 4) {
// Assume standard: iss, sub, profile...
issuer = args[0];
sub = args[1];
profile = args[2];
}
if (!sub && profile && profile.id) sub = profile.id;
if (!sub) {
return done(new Error('Could not identify OIDC Subject (sub) from arguments'));
}
try {
// 1. Try to find by OIDC ID (sub)
let user = await findUserByOidcId(sub);
// 2. If not found, try to match by email
// Extract email - handle both profile.emails[] (Google) and profile.email (others)
const email = profile.emails?.[0]?.value || profile.email || profile._json?.email;
if (!user && email) {
user = await findUserByEmail(email);
// If found by email but no OIDC ID, link them
if (user && !user.oidcId) {
// We don't have a direct update method for specific fields without full user object in this context
// Ideally we'd update the user here. For now, we'll just log in.
// Future: Update user with oidcId
}
}
// 3. If still not found, create new user (JIT Provisioning)
if (!user) {
const username = profile.username || profile.displayName || (email ? email.split('@')[0] : `user_${sub.substring(0, 8)}`);
user = await createUser({
username: username,
role: 'viewer', // Default role for SSO users
oidcId: sub,
email: email || null
});
}
return done(null, user);
} catch (err) {
return done(err);
}
}));
}
/**
* Middleware: Require authentication using Passport JWT
*/
@@ -145,6 +260,8 @@ module.exports = {
verifyToken,
configureLocalStrategy,
configureJwtStrategy,
configureSessionSerialization,
configureOidcStrategy,
requireAuth,
requireAdmin,
requireRole
+43 -4
View File
@@ -63,14 +63,40 @@ function getDefaultSettings() {
lastVolume: 80,
autoPlayNextEpisode: false,
forceProxy: false,
forceTranscode: false,
forceTranscode: false, // Force Audio Transcode
forceVideoTranscode: false, // Force Video Transcode
forceRemux: false,
autoTranscode: true,
streamFormat: 'm3u8',
epgRefreshInterval: '24'
epgRefreshInterval: '24',
// User-Agent settings
userAgentPreset: 'chrome', // chrome | vlc | tivimate | custom
userAgentCustom: '', // Custom UA string when preset is 'custom'
// Transcoding settings
hwEncoder: 'auto', // auto | nvenc | amf | qsv | vaapi | software
maxResolution: '1080p', // 4k | 1080p | 720p | 480p
quality: 'medium', // high | medium | low
audioMixPreset: 'auto', // auto | itu | night | cinematic | passthrough
// Probe cache settings
probeCacheTTL: 300, // 5 minutes for URL probe cache
seriesProbeCacheDays: 7 // 7 days for series episode probe cache
};
}
// User-Agent presets
const USER_AGENT_PRESETS = {
chrome: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36',
vlc: 'VLC/3.0.20 LibVLC/3.0.20',
tivimate: 'TiviMate/4.7.0',
};
function getUserAgent(settings) {
if (settings.userAgentPreset === 'custom' && settings.userAgentCustom) {
return settings.userAgentCustom;
}
return USER_AGENT_PRESETS[settings.userAgentPreset] || USER_AGENT_PRESETS.chrome;
}
// Write lock to prevent concurrent writes from corrupting db.json
let writeQueue = Promise.resolve();
const tmpPath = dbPath + '.tmp';
@@ -339,6 +365,16 @@ const users = {
return db.users?.find(u => u.username === username);
},
async getByOidcId(oidcId) {
const db = await loadDb();
return db.users?.find(u => u.oidcId === oidcId);
},
async getByEmail(email) {
const db = await loadDb();
return db.users?.find(u => u.email === email);
},
async create(userData) {
const db = await loadDb();
if (!db.users) {
@@ -353,8 +389,11 @@ const users = {
const newUser = {
id: db.nextId++,
username: userData.username,
passwordHash: userData.passwordHash,
// For OIDC users, passwordHash is optional
passwordHash: userData.passwordHash || null,
role: userData.role || 'viewer',
oidcId: userData.oidcId || null,
email: userData.email || null,
createdAt: new Date().toISOString()
};
@@ -422,4 +461,4 @@ const users = {
}
};
module.exports = { loadDb, saveDb, sources, hiddenItems, favorites, settings, users, getDefaultSettings };
module.exports = { loadDb, saveDb, sources, hiddenItems, favorites, settings, users, getDefaultSettings, getUserAgent, USER_AGENT_PRESETS };
+97 -1
View File
@@ -102,10 +102,106 @@ function initSchema() {
);
`);
// User Favorites (per-user)
db.exec(`
CREATE TABLE IF NOT EXISTS favorites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
source_id INTEGER NOT NULL,
item_id TEXT NOT NULL,
item_type TEXT NOT NULL, -- 'channel', 'movie', 'series'
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, source_id, item_id, item_type)
);
CREATE INDEX IF NOT EXISTS idx_favorites_user ON favorites(user_id);
CREATE INDEX IF NOT EXISTS idx_favorites_user_type ON favorites(user_id, item_type);
`);
// Watch History (per-user)
db.exec(`
CREATE TABLE IF NOT EXISTS watch_history (
id TEXT PRIMARY KEY, -- Composite key: user_id:item_id
user_id INTEGER NOT NULL,
item_type TEXT NOT NULL, -- 'movie', 'episode'
item_id TEXT NOT NULL, -- The original item ID (stream_id or composite)
parent_id TEXT, -- For episodes (series ID)
progress INTEGER DEFAULT 0, -- Current position in seconds
duration INTEGER DEFAULT 0, -- Total duration in seconds
updated_at INTEGER NOT NULL, -- Timestamp
data JSON -- Snapshot of item data (title, poster, etc)
);
CREATE INDEX IF NOT EXISTS idx_history_user_updated ON watch_history(user_id, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_history_user_item ON watch_history(user_id, item_id);
`);
console.log('[SQLite] Schema initialized');
}
// ============================================================
// Favorites CRUD Operations
// ============================================================
const favorites = {
getAll(userId, sourceId = null, itemType = null) {
const db = getDb();
let sql = 'SELECT * FROM favorites WHERE user_id = ?';
const params = [userId];
if (sourceId) {
sql += ' AND source_id = ?';
params.push(sourceId);
}
if (itemType) {
sql += ' AND item_type = ?';
params.push(itemType);
}
sql += ' ORDER BY created_at DESC';
return db.prepare(sql).all(...params);
},
add(userId, sourceId, itemId, itemType = 'channel') {
const db = getDb();
const stmt = db.prepare(`
INSERT OR IGNORE INTO favorites (user_id, source_id, item_id, item_type)
VALUES (?, ?, ?, ?)
`);
const result = stmt.run(userId, sourceId, itemId, itemType);
return result.changes > 0;
},
remove(userId, sourceId, itemId, itemType = 'channel') {
const db = getDb();
const stmt = db.prepare(`
DELETE FROM favorites
WHERE user_id = ? AND source_id = ? AND item_id = ? AND item_type = ?
`);
const result = stmt.run(userId, sourceId, itemId, itemType);
return result.changes > 0;
},
isFavorite(userId, sourceId, itemId, itemType = 'channel') {
const db = getDb();
const row = db.prepare(`
SELECT 1 FROM favorites
WHERE user_id = ? AND source_id = ? AND item_id = ? AND item_type = ?
`).get(userId, sourceId, itemId, itemType);
return !!row;
},
// Get all favorites for a user, grouped by type (for bulk checks)
getAllAsSet(userId) {
const db = getDb();
const rows = db.prepare('SELECT source_id, item_id, item_type FROM favorites WHERE user_id = ?').all(userId);
const set = new Set();
for (const row of rows) {
set.add(`${row.source_id}:${row.item_id}:${row.item_type}`);
}
return set;
}
};
module.exports = {
getDb,
initSchema
initSchema,
favorites
};
+17
View File
@@ -1,4 +1,5 @@
const express = require('express');
require('dotenv').config();
const path = require('path');
const passport = require('passport');
const syncService = require('./services/syncService');
@@ -17,7 +18,14 @@ app.set('trust proxy', true);
app.use(express.json({ limit: '50mb' }));
// Initialize Passport
const session = require('express-session');
app.use(session({
secret: process.env.JWT_SECRET || 'keyboard cat',
resave: false,
saveUninitialized: true
}));
app.use(passport.initialize());
app.use(passport.session());
app.use(express.static(path.join(__dirname, '..', 'public')));
@@ -91,6 +99,7 @@ app.use('/api/remux', require('./routes/remux'));
app.use('/api/probe', require('./routes/probe'));
app.use('/api/subtitle', require('./routes/subtitle'));
app.use('/api/settings', require('./routes/settings'));
app.use('/api/history', require('./routes/history'));
// Version endpoint
app.get('/api/version', (req, res) => {
@@ -117,5 +126,13 @@ app.listen(PORT, async () => {
await syncService.syncAll().catch(console.error);
// Start the server-side sync timer after initial sync
await syncService.startSyncTimer().catch(console.error);
// Detect hardware acceleration capabilities
try {
const hwDetect = require('./services/hwDetect');
await hwDetect.detect();
} catch (err) {
console.warn('Hardware detection failed:', err.message);
}
}, 5000);
});
+33
View File
@@ -13,6 +13,39 @@ auth.configureJwtStrategy(
async (id) => await db.users.getById(id)
);
// Configure Passport session serialization (required for OIDC)
auth.configureSessionSerialization(
async (id) => await db.users.getById(id)
);
// Configure OIDC Strategy
auth.configureOidcStrategy(
async (oidcId) => await db.users.getByOidcId(oidcId),
async (email) => await db.users.getByEmail(email),
async (userData) => await db.users.create(userData)
);
/**
* Start OIDC Login
* GET /api/auth/oidc/login
*/
router.get('/oidc/login', auth.passport.authenticate('openidconnect'));
/**
* OIDC Callback
* GET /api/auth/oidc/callback
*/
router.get('/oidc/callback',
auth.passport.authenticate('openidconnect', { session: false, failureRedirect: '/login.html?error=SSO+Failed' }),
(req, res) => {
// Successful authentication
const token = auth.generateToken(req.user);
// Redirect to hompage with token
res.redirect(`/?token=${token}`);
}
);
/**
* Check if initial setup is required
* GET /api/auth/setup-required
+37
View File
@@ -288,5 +288,42 @@ router.post('/hide/all', async (req, res) => {
}
});
// Get recent movies or series
router.get('/recent', async (req, res) => {
try {
const { type, limit = 12 } = req.query;
if (!type || (type !== 'movie' && type !== 'series')) {
return res.status(400).json({ error: 'Valid type (movie or series) is required' });
}
const db = getDb();
const recentItems = db.prepare(`
SELECT * FROM playlist_items p
WHERE p.type = ?
AND p.is_hidden = 0
AND NOT EXISTS (
SELECT 1 FROM categories c
WHERE c.source_id = p.source_id
AND c.category_id = p.category_id
AND c.type = p.type
AND c.is_hidden = 1
)
ORDER BY p.added_at DESC
LIMIT ?
`).all(type, parseInt(limit));
// Parse JSON data for each item
const formatted = recentItems.map(item => ({
...item,
data: JSON.parse(item.data)
}));
res.json(formatted);
} catch (err) {
console.error(`Error getting recent ${req.query.type}:`, err);
res.status(500).json({ error: 'Failed to get recent items' });
}
});
module.exports = router;
+14 -9
View File
@@ -1,19 +1,23 @@
const express = require('express');
const router = express.Router();
const { favorites } = require('../db');
const { favorites } = require('../db/sqlite');
const { requireAuth } = require('../auth');
// Get all favorites
// All favorites routes require authentication
router.use(requireAuth);
// Get all favorites for current user
router.get('/', async (req, res) => {
try {
const { sourceId, itemType } = req.query;
const items = await favorites.getAll(sourceId, itemType);
const items = favorites.getAll(req.user.id, sourceId || null, itemType || null);
res.json(items);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Add favorite
// Add favorite for current user
router.post('/', async (req, res) => {
try {
const { sourceId, itemId, itemType = 'channel' } = req.body;
@@ -21,14 +25,14 @@ router.post('/', async (req, res) => {
return res.status(400).json({ error: 'Source ID and Item ID are required' });
}
await favorites.add(sourceId, itemId, itemType);
favorites.add(req.user.id, sourceId, itemId, itemType);
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Remove favorite
// Remove favorite for current user
router.delete('/', async (req, res) => {
try {
const { sourceId, itemId, itemType = 'channel' } = req.body;
@@ -36,14 +40,14 @@ router.delete('/', async (req, res) => {
return res.status(400).json({ error: 'Source ID and Item ID are required' });
}
await favorites.remove(sourceId, itemId, itemType);
favorites.remove(req.user.id, sourceId, itemId, itemType);
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Check if item is favorited
// Check if item is favorited by current user
router.get('/check', async (req, res) => {
try {
const { sourceId, itemId, itemType = 'channel' } = req.query;
@@ -51,7 +55,7 @@ router.get('/check', async (req, res) => {
return res.status(400).json({ error: 'Source ID and Item ID are required' });
}
const isFav = await favorites.isFavorite(sourceId, itemId, itemType);
const isFav = favorites.isFavorite(req.user.id, sourceId, itemId, itemType);
res.json({ isFavorite: isFav });
} catch (err) {
res.status(500).json({ error: err.message });
@@ -59,3 +63,4 @@ router.get('/check', async (req, res) => {
});
module.exports = router;
+110
View File
@@ -0,0 +1,110 @@
const express = require('express');
const router = express.Router();
const { getDb } = require('../db/sqlite');
const { requireAuth } = require('../auth');
// Middleware to ensure authentication
router.use(requireAuth);
/**
* GET /api/history
* Returns the watch history for the authenticated user
*/
router.get('/', (req, res) => {
try {
const db = getDb();
const userId = req.user.id;
const limit = parseInt(req.query.limit) || 20;
const rows = db.prepare(`
SELECT * FROM watch_history
WHERE user_id = ?
ORDER BY updated_at DESC
LIMIT ?
`).all(userId, limit);
const history = rows.map(row => ({
...row,
data: JSON.parse(row.data || '{}')
}));
res.json(history);
} catch (err) {
console.error('[History] Error fetching history:', err);
res.status(500).json({ error: 'Failed to fetch history' });
}
});
/**
* POST /api/history
* Saves/updates watch progress for an item
*/
router.post('/', (req, res) => {
try {
const db = getDb();
const userId = req.user.id;
const { id, type, parentId, progress, duration, data } = req.body;
if (!id || !type) {
return res.status(400).json({ error: 'Missing required fields (id, type)' });
}
const compositeId = `${userId}:${id}`;
const timestamp = Date.now();
const stmt = db.prepare(`
INSERT INTO watch_history (id, user_id, item_type, item_id, parent_id, progress, duration, updated_at, data)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
progress = excluded.progress,
duration = excluded.duration,
updated_at = excluded.updated_at,
data = excluded.data
`);
stmt.run(
compositeId,
userId,
type,
id.toString(),
parentId ? parentId.toString() : null,
progress || 0,
duration || 0,
timestamp,
JSON.stringify(data || {})
);
res.json({ success: true, timestamp });
} catch (err) {
console.error('[History] Error saving progress:', err);
res.status(500).json({ error: 'Failed to save progress' });
}
});
/**
* DELETE /api/history/:itemId
* Removes an item from the user's watch history
*/
router.delete('/:itemId', (req, res) => {
try {
const db = getDb();
const userId = req.user.id;
const itemId = req.params.itemId;
const compositeId = `${userId}:${itemId}`;
const stmt = db.prepare('DELETE FROM watch_history WHERE id = ? AND user_id = ?');
const result = stmt.run(compositeId, userId);
if (result.changes === 0) {
return res.status(404).json({ error: 'Item not found in history' });
}
res.json({ success: true });
} catch (err) {
console.error('[History] Error deleting history item:', err);
res.status(500).json({ error: 'Failed to delete history item' });
}
});
module.exports = router;
+19 -11
View File
@@ -28,11 +28,11 @@ const BROWSER_AUDIO_CODECS = ['aac', 'mp3', 'opus', 'vorbis'];
/**
* Probe stream with ffprobe
*/
function probeStream(url, ffprobePath, timeout = 15000) {
function probeStream(url, ffprobePath, userAgent = null, timeout = 15000) {
return new Promise((resolve, reject) => {
const args = [
'-v', 'error',
'-user_agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36',
'-user_agent', userAgent || 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36',
'-print_format', 'json',
'-show_streams',
'-show_format',
@@ -112,11 +112,15 @@ function analyzeProbeResult(probeResult, url) {
}));
// Determine what processing is needed
// 1. Incompatible audio -> Transcode (highest priority)
const needsTranscode = !audioOk;
// 4. MKV files often cause OOM/decoding issues in browser fMP4 remux,
// so we force them to "needsTranscode" which uses HLS (more robust).
// The frontend will still use "copy" mode if codecs are compatible.
const isMkv = container.includes('matroska') || container.includes('webm') || url.endsWith('.mkv');
// 2. Compatible audio/video but incompatible container -> Remux
// This catches raw TS, MKV, AVI, etc. that have H.264/AAC but wrong container
// 1. Incompatible audio/video OR MKV -> Transcode (or HLS Copy)
const needsTranscode = !audioOk || !videoOk || isMkv;
// 2. Compatible audio/video but incompatible container (non-MKV) -> Remux (fMP4 pipe)
const needsRemux = !needsTranscode && (!containerOk || isRawTs);
const compatible = !needsTranscode && !needsRemux;
@@ -124,6 +128,9 @@ function analyzeProbeResult(probeResult, url) {
return {
video: videoCodec,
audio: audioCodec,
width: videoStream?.width || 0,
height: videoStream?.height || 0,
audioChannels: audioStream?.channels || 0, // For Smart Audio Copy
container: container,
compatible: compatible,
needsRemux: needsRemux,
@@ -133,12 +140,13 @@ function analyzeProbeResult(probeResult, url) {
}
router.get('/', async (req, res) => {
const { url } = req.query;
const { url, ua } = req.query;
if (!url) {
return res.status(400).json({ error: 'URL parameter is required' });
}
const ffprobePath = req.app.locals.ffprobePath;
const cacheKey = `${url}${ua ? `|${ua}` : ''}`;
if (!ffprobePath) {
// No ffprobe available - assume needs transcoding to be safe
@@ -154,20 +162,20 @@ router.get('/', async (req, res) => {
}
// Check cache
const cached = probeCache.get(url);
const cached = probeCache.get(cacheKey);
if (cached && (Date.now() - cached.timestamp < CACHE_TTL)) {
console.log(`[Probe] Cache hit for: ${url.substring(0, 50)}...`);
return res.json(cached.result);
}
console.log(`[Probe] Probing: ${url.substring(0, 80)}...`);
console.log(`[Probe] Probing: ${url.substring(0, 80)}... ${ua ? `(UA: ${ua})` : ''}`);
try {
const probeResult = await probeStream(url, ffprobePath);
const probeResult = await probeStream(url, ffprobePath, ua);
const analysis = analyzeProbeResult(probeResult, url);
// Cache result
probeCache.set(url, { result: analysis, timestamp: Date.now() });
probeCache.set(cacheKey, { result: analysis, timestamp: Date.now() });
console.log(`[Probe] Result: video=${analysis.video}, audio=${analysis.audio}, ` +
`container=${analysis.container}, compatible=${analysis.compatible}, ` +
+17 -5
View File
@@ -1,6 +1,7 @@
const express = require('express');
const router = express.Router();
const { spawn } = require('child_process');
const db = require('../db');
/**
* Remux stream (container conversion only)
@@ -12,24 +13,31 @@ const { spawn } = require('child_process');
*
* Note: This does NOT fix Dolby/AC3 audio issues - use /api/transcode for that.
*/
router.get('/', (req, res) => {
router.get('/', async (req, res) => {
const { url } = req.query;
if (!url) {
return res.status(400).json({ error: 'URL parameter is required' });
}
const ffmpegPath = req.app.locals.ffmpegPath || 'ffmpeg';
// Get User-Agent from settings
const settings = await db.settings.get();
const userAgent = db.getUserAgent(settings);
console.log(`[Remux] Starting remux for: ${url}`);
console.log(`[Remux] Using User-Agent: ${settings.userAgentPreset}`);
// FFmpeg arguments for pure remux (no encoding)
// Very lightweight - just changes container from TS to fragmented MP4
const args = [
'-hide_banner',
'-loglevel', 'warning',
'-user_agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36',
// Low-latency startup: reduce probe/analyze time for faster first bytes
'-probesize', '32768',
'-analyzeduration', '500000', // 0.5 seconds - enough to detect audio
'-user_agent', userAgent,
'-user_agent', userAgent,
// Standard probe size to handle complex containers (MKV) correctly
'-probesize', '5000000',
'-analyzeduration', '5000000',
// Error resilience: discard corrupt packets, generate timestamps, ignore DTS, no buffering
'-fflags', '+genpts+discardcorrupt+igndts+nobuffer',
// Ignore errors in stream and continue
@@ -40,6 +48,8 @@ router.get('/', (req, res) => {
'-reconnect', '1',
'-reconnect_streamed', '1',
'-reconnect_delay_max', '5',
// Prevent Range/HEAD requests that some providers reject with 405
'-seekable', '0',
'-i', url,
// STRICT MAPPING: Only map video and audio, ignore subtitles/data/attachments
// This prevents remux failure when source container has incompatible subtitle tracks (e.g. MKV -> MP4)
@@ -49,6 +59,8 @@ router.get('/', (req, res) => {
'-sn', '-dn',
// Copy streams without re-encoding
'-c', 'copy',
// Ensure extradata is correctly extracted/converted (fixes Annex B -> AVCC issues in Firefox)
'-bsf:v', 'dump_extra',
// NOTE: We intentionally do NOT use -bsf:a aac_adtstoasc here
// That filter only works for AAC audio and breaks AC3/EAC3/MP3.
// If AAC audio from MPEG-TS fails in MP4, use /api/transcode instead.
+36
View File
@@ -71,5 +71,41 @@ router.get('/sync-status', (req, res) => {
});
});
/**
* Get hardware capabilities (GPU acceleration support)
* GET /api/settings/hw-info
*/
router.get('/hw-info', async (req, res) => {
try {
const hwDetect = require('../services/hwDetect');
let capabilities = hwDetect.getCapabilities();
// If not yet detected, run detection now
if (!capabilities) {
capabilities = await hwDetect.detect();
}
res.json(capabilities);
} catch (err) {
console.error('Error getting hardware info:', err);
res.status(500).json({ error: err.message });
}
});
/**
* Refresh hardware detection (re-probe GPUs)
* POST /api/settings/hw-info/refresh
*/
router.post('/hw-info/refresh', async (req, res) => {
try {
const hwDetect = require('../services/hwDetect');
const capabilities = await hwDetect.refresh();
res.json(capabilities);
} catch (err) {
console.error('Error refreshing hardware info:', err);
res.status(500).json({ error: err.message });
}
});
module.exports = router;
+158 -3
View File
@@ -1,22 +1,174 @@
const express = require('express');
const router = express.Router();
const { spawn } = require('child_process');
const path = require('path');
const fs = require('fs').promises;
const db = require('../db');
const transcodeSession = require('../services/transcodeSession');
/**
* Transcode stream
* Transcode Routes
*
* Direct streaming (backward compatible):
* GET /api/transcode?url=...
*
* HLS session-based (new, supports seeking):
* POST /api/transcode/session - Create new session
* GET /api/transcode/:id/stream.m3u8 - Get HLS playlist
* GET /api/transcode/:id/:segment.ts - Get segment file
* DELETE /api/transcode/:id - Stop and cleanup session
* GET /api/transcode/sessions - List all sessions (debug)
*/
// Start session cleanup interval
transcodeSession.startCleanupInterval();
/**
* Create a new transcode session
* POST /api/transcode/session
* Body: { url: string, seekOffset?: number }
*/
router.post('/session', async (req, res) => {
const { url, seekOffset, videoMode, videoCodec, audioCodec, audioChannels } = req.body;
if (!url) {
return res.status(400).json({ error: 'URL is required' });
}
const ffmpegPath = req.app.locals.ffmpegPath || 'ffmpeg';
const settings = await db.settings.get();
const userAgent = db.getUserAgent(settings);
try {
const session = await transcodeSession.createSession(url, {
ffmpegPath,
userAgent,
seekOffset: seekOffset || 0,
hwEncoder: settings.hwEncoder || 'software',
maxResolution: settings.maxResolution || '1080p',
quality: settings.quality || 'medium',
audioMixPreset: settings.audioMixPreset || 'auto', // Audio downmix preset
videoMode: videoMode, // 'copy' or 'encode'
videoCodec: videoCodec, // 'h264', 'hevc', etc.
audioCodec: audioCodec, // 'aac', 'ac3', etc.
audioChannels: audioChannels // number of channels (2=stereo)
});
await session.start();
// Wait for playlist to be ready (first segments generated)
const ready = await session.waitForPlaylist(15000);
if (!ready) {
await transcodeSession.removeSession(session.id);
return res.status(500).json({ error: 'Transcoding failed to start', reason: 'Playlist not generated in time' });
}
res.json({
sessionId: session.id,
playlistUrl: `/api/transcode/${session.id}/stream.m3u8`,
status: session.status
});
} catch (err) {
console.error('[Transcode] Session creation failed:', err);
res.status(500).json({ error: 'Failed to create session', details: err.message });
}
});
/**
* Get HLS playlist for a session
* GET /api/transcode/:sessionId/stream.m3u8
*/
router.get('/:sessionId/stream.m3u8', async (req, res) => {
const { sessionId } = req.params;
const session = transcodeSession.getSession(sessionId);
if (!session) {
return res.status(404).json({ error: 'Session not found' });
}
const playlist = await session.getPlaylist();
if (!playlist) {
return res.status(404).json({ error: 'Playlist not ready' });
}
res.setHeader('Content-Type', 'application/vnd.apple.mpegurl');
res.setHeader('Cache-Control', 'no-cache');
res.send(playlist);
});
/**
* Get a segment file for a session
* GET /api/transcode/:sessionId/:segment.ts
*/
router.get('/:sessionId/:segment', async (req, res) => {
const { sessionId, segment } = req.params;
// Only handle .ts files
if (!segment.endsWith('.ts')) {
return res.status(404).json({ error: 'Invalid segment' });
}
const session = transcodeSession.getSession(sessionId);
if (!session) {
return res.status(404).json({ error: 'Session not found' });
}
const segmentPath = await session.getSegment(segment);
if (!segmentPath) {
return res.status(404).json({ error: 'Segment not found' });
}
res.setHeader('Content-Type', 'video/MP2T');
res.setHeader('Cache-Control', 'public, max-age=31536000'); // Cache forever (immutable)
res.sendFile(segmentPath);
});
/**
* Stop and cleanup a session
* DELETE /api/transcode/:sessionId
*/
router.delete('/:sessionId', async (req, res) => {
const { sessionId } = req.params;
try {
await transcodeSession.removeSession(sessionId);
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: 'Failed to remove session', details: err.message });
}
});
/**
* List all active sessions (for debugging)
* GET /api/transcode/sessions
*/
router.get('/sessions', (req, res) => {
res.json(transcodeSession.getAllSessions());
});
/**
* Direct transcode stream (backward compatible, no seeking)
* GET /api/transcode?url=...
*
* Transcodes audio to AAC for browser compatibility while passing video through.
* This fixes playback issues with Dolby/AC3/EAC3 audio that browsers can't decode.
*/
router.get('/', (req, res) => {
router.get('/', async (req, res) => {
const { url } = req.query;
if (!url) {
return res.status(400).json({ error: 'URL parameter is required' });
}
const ffmpegPath = req.app.locals.ffmpegPath || 'ffmpeg';
// Get User-Agent from settings
const settings = await db.settings.get();
const userAgent = db.getUserAgent(settings);
console.log(`[Transcode] Starting transcoding for: ${url}`);
console.log(`[Transcode] Using User-Agent: ${settings.userAgentPreset}`);
console.log(`[Transcode] Using binary: ${ffmpegPath}`);
// FFmpeg arguments for transcoding
@@ -25,7 +177,7 @@ router.get('/', (req, res) => {
const args = [
'-hide_banner',
'-loglevel', 'warning',
'-user_agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36',
'-user_agent', userAgent,
// Faster startup - reduced probe/analyze for quicker first bytes
'-probesize', '2000000', // 2MB (reduced from 5MB)
'-analyzeduration', '3000000', // 3 seconds (reduced from 10s)
@@ -39,6 +191,8 @@ router.get('/', (req, res) => {
'-reconnect', '1',
'-reconnect_streamed', '1',
'-reconnect_delay_max', '3',
// Prevent Range/HEAD requests that some providers reject with 405
'-seekable', '0',
'-i', url,
// Map only first video and audio stream (avoid subtitle streams causing issues)
'-map', '0:v:0',
@@ -112,3 +266,4 @@ router.get('/', (req, res) => {
});
module.exports = router;
+2 -2
View File
@@ -125,7 +125,7 @@ function parse(input) {
currentObject.description = textBuffer;
break;
case 'category':
if (textBuffer) currentObject.category.push(textBuffer);
if (textBuffer && currentObject.category) currentObject.category.push(textBuffer);
break;
case 'date':
currentObject.date = textBuffer;
@@ -374,7 +374,7 @@ async function* parseStreaming(input, batchSize = 1000) {
currentObject.description = textBuffer;
break;
case 'category':
if (textBuffer) currentObject.category.push(textBuffer);
if (textBuffer && currentObject.category) currentObject.category.push(textBuffer);
break;
case 'date':
currentObject.date = textBuffer;
+281
View File
@@ -0,0 +1,281 @@
/**
* Hardware Detection Service
*
* Detects available hardware acceleration capabilities:
* - NVIDIA GPU (NVENC/NVDEC via nvidia-smi)
* - VAAPI (Linux integrated GPU acceleration)
* - QuickSync (Intel GPU acceleration)
*
* Results are cached at startup and exposed via API.
*/
const { execSync, exec } = require('child_process');
const os = require('os');
// Cache detection results
let hwCapabilities = null;
/**
* NVIDIA GPU compute capability requirements for codec support
* Source: https://developer.nvidia.com/video-encode-and-decode-gpu-support-matrix-new
*/
const NVDEC_MIN_COMPUTE = {
h264: 3.0, // Kepler+
hevc: 5.0, // Maxwell GM206+
av1: 8.0, // Ada Lovelace+
};
/**
* Detect NVIDIA GPU and its capabilities
*/
async function detectNvidia() {
try {
// Query GPU info via nvidia-smi
const result = execSync(
'nvidia-smi --query-gpu=name,compute_cap --format=csv,noheader',
{ timeout: 5000, encoding: 'utf-8', windowsHide: true }
);
const lines = result.trim().split('\n');
if (lines.length === 0 || !lines[0]) {
return { available: false };
}
// Parse "NVIDIA GeForce RTX 3080, 8.6"
const parts = lines[0].split(',');
if (parts.length < 2) {
return { available: false };
}
const gpuName = parts[0].trim();
const computeCap = parseFloat(parts[1].trim());
// Determine supported codecs based on compute capability
const supportedCodecs = [];
for (const [codec, minCap] of Object.entries(NVDEC_MIN_COMPUTE)) {
if (computeCap >= minCap) {
supportedCodecs.push(codec);
}
}
console.log(`[HwDetect] NVIDIA GPU detected: ${gpuName} (compute ${computeCap})`);
console.log(`[HwDetect] NVDEC supported codecs: ${supportedCodecs.join(', ')}`);
return {
available: true,
name: gpuName,
computeCap,
supportedCodecs,
encoder: 'h264_nvenc',
decoder: 'h264_cuvid'
};
} catch (err) {
// nvidia-smi not found or no GPU
console.log('[HwDetect] No NVIDIA GPU detected');
return { available: false };
}
}
/**
* Detect VAAPI support (Linux only)
* Checks for /dev/dri/renderD* devices
*/
async function detectVAAPI() {
if (os.platform() !== 'linux') {
return { available: false, reason: 'VAAPI is Linux-only' };
}
try {
// Check for render nodes
const result = execSync('ls /dev/dri/renderD* 2>/dev/null', {
timeout: 2000,
encoding: 'utf-8',
shell: true
});
const devices = result.trim().split('\n').filter(d => d);
if (devices.length === 0) {
return { available: false, reason: 'No render devices found' };
}
// Use first available device
const device = devices[0];
console.log(`[HwDetect] VAAPI device found: ${device}`);
return {
available: true,
device,
encoder: 'h264_vaapi',
decoder: 'vaapi'
};
} catch (err) {
console.log('[HwDetect] VAAPI not available');
return { available: false };
}
}
/**
* Detect Intel QuickSync support
* Checks if FFmpeg can use QSV
*/
async function detectQuickSync() {
try {
// Check if Intel GPU exists
let hasIntelGpu = false;
if (os.platform() === 'win32') {
// Windows: Check via WMIC
const result = execSync(
'wmic path win32_VideoController get name',
{ timeout: 5000, encoding: 'utf-8', windowsHide: true }
);
hasIntelGpu = result.toLowerCase().includes('intel');
} else if (os.platform() === 'linux') {
// Linux: Check lspci
try {
const result = execSync('lspci | grep -i "vga\\|display" | grep -i intel', {
timeout: 5000,
encoding: 'utf-8',
shell: true
});
hasIntelGpu = result.trim().length > 0;
} catch {
hasIntelGpu = false;
}
}
if (!hasIntelGpu) {
return { available: false, reason: 'No Intel GPU found' };
}
console.log('[HwDetect] Intel GPU detected, QSV may be available');
return {
available: true,
encoder: 'h264_qsv',
decoder: 'h264_qsv'
};
} catch (err) {
console.log('[HwDetect] QuickSync not available');
return { available: false };
}
}
/**
* Detect AMD AMF support (Windows only)
* Linux AMD uses VAAPI (detected separately)
*/
async function detectAMF() {
if (os.platform() !== 'win32') {
// On Linux, AMD GPUs use VAAPI which is detected separately
return { available: false, reason: 'AMF is Windows-only (Linux uses VAAPI)' };
}
try {
// Windows: Check via WMIC for AMD/Radeon
const result = execSync(
'wmic path win32_VideoController get name',
{ timeout: 5000, encoding: 'utf-8', windowsHide: true }
);
const lowerResult = result.toLowerCase();
const hasAmdGpu = lowerResult.includes('amd') || lowerResult.includes('radeon');
if (!hasAmdGpu) {
return { available: false, reason: 'No AMD GPU found' };
}
// Extract GPU name for display
const lines = result.trim().split('\n').filter(l => l.trim());
let gpuName = 'AMD GPU';
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.toLowerCase().includes('amd') || trimmed.toLowerCase().includes('radeon')) {
gpuName = trimmed;
break;
}
}
console.log(`[HwDetect] AMD GPU detected: ${gpuName}`);
return {
available: true,
name: gpuName,
encoder: 'h264_amf',
decoder: 'h264' // AMF has limited decode support, often uses software
};
} catch (err) {
console.log('[HwDetect] AMF not available');
return { available: false };
}
}
/**
* Detect all hardware capabilities
* Results are cached for performance
*/
async function detect() {
if (hwCapabilities !== null) {
return hwCapabilities;
}
console.log('[HwDetect] Probing hardware acceleration capabilities...');
const [nvidia, vaapi, qsv, amf] = await Promise.all([
detectNvidia(),
detectVAAPI(),
detectQuickSync(),
detectAMF()
]);
// Determine recommended encoder (priority: NVENC > AMF > QSV > VAAPI > Software)
let recommended = 'software';
if (nvidia.available) {
recommended = 'nvenc';
} else if (amf.available) {
recommended = 'amf';
} else if (qsv.available) {
recommended = 'qsv';
} else if (vaapi.available) {
recommended = 'vaapi';
}
hwCapabilities = {
nvidia,
amf,
vaapi,
qsv,
recommended,
platform: os.platform(),
detectedAt: new Date().toISOString()
};
console.log(`[HwDetect] Recommended encoder: ${recommended}`);
return hwCapabilities;
}
/**
* Get cached capabilities (or detect if not cached)
*/
function getCapabilities() {
return hwCapabilities;
}
/**
* Force re-detection (clears cache)
*/
async function refresh() {
hwCapabilities = null;
return detect();
}
module.exports = {
detect,
getCapabilities,
refresh,
detectNvidia,
detectAMF,
detectVAAPI,
detectQuickSync
};
+716
View File
@@ -0,0 +1,716 @@
/**
* Transcode Session Service
*
* Manages HLS transcoding sessions with segment caching for VOD seeking.
* Each session transcodes a source URL to HLS segments on disk.
*
* Key features:
* - Session-based transcoding with unique IDs
* - HLS segment output for seeking support
* - Segment caching for fast access
* - Session persistence for recovery after restart
* - Automatic cleanup of stale sessions
*/
const { spawn } = require('child_process');
const path = require('path');
const fs = require('fs').promises;
const crypto = require('crypto');
const EventEmitter = require('events');
const hwDetect = require('./hwDetect');
// Session storage
const sessions = new Map();
// Cache directory for transcoded segments
const CACHE_DIR = path.join(process.cwd(), 'transcode-cache');
// Session settings
const SESSION_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes idle timeout
const SEGMENT_DURATION = 4; // seconds per HLS segment
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // Check every 5 minutes
/**
* Generate a unique session ID
*/
function generateSessionId() {
return crypto.randomBytes(8).toString('hex');
}
/**
* Ensure cache directory exists
*/
async function ensureCacheDir() {
try {
await fs.mkdir(CACHE_DIR, { recursive: true });
} catch (err) {
if (err.code !== 'EEXIST') throw err;
}
}
/**
* TranscodeSession class
* Manages a single transcoding session from source URL to HLS segments
*/
class TranscodeSession extends EventEmitter {
constructor(url, options = {}) {
super();
this.id = generateSessionId();
this.url = url;
this.dir = path.join(CACHE_DIR, this.id);
this.playlistPath = path.join(this.dir, 'stream.m3u8');
this.process = null;
this.segments = new Map(); // segment index -> { ready: boolean, path: string }
this.status = 'pending'; // pending | starting | running | stopped | error
this.error = null;
this.startTime = Date.now();
this.lastAccess = Date.now();
this.options = {
ffmpegPath: options.ffmpegPath || 'ffmpeg',
userAgent: options.userAgent || 'Mozilla/5.0',
seekOffset: options.seekOffset || 0,
hwEncoder: options.hwEncoder || 'software',
maxResolution: options.maxResolution || '1080p',
quality: options.quality || 'medium',
...options
};
}
/**
* Start the transcoding process
*/
async start() {
if (this.status === 'running') {
return;
}
this.status = 'starting';
console.log(`[TranscodeSession ${this.id}] Starting session for: ${this.url}`);
// Create session directory
try {
await fs.mkdir(this.dir, { recursive: true });
} catch (err) {
this.status = 'error';
this.error = err.message;
throw err;
}
// Build FFmpeg arguments for HLS output
const args = this.buildFFmpegArgs();
console.log(`[TranscodeSession ${this.id}] Command: ${this.options.ffmpegPath} ${args.join(' ')}`);
try {
this.process = spawn(this.options.ffmpegPath, args, {
cwd: this.dir,
windowsHide: true
});
this.status = 'running';
// Handle stdout (should be empty for file output)
this.process.stdout.on('data', (data) => {
console.log(`[TranscodeSession ${this.id}] stdout: ${data}`);
});
// Handle stderr (FFmpeg progress/errors)
let stderrBuffer = '';
this.process.stderr.on('data', (data) => {
stderrBuffer += data.toString();
// Log periodically to avoid spam
const lines = stderrBuffer.split('\n');
if (lines.length > 1) {
lines.slice(0, -1).forEach(line => {
if (line.trim()) {
console.log(`[FFmpeg ${this.id}] ${line}`);
}
});
stderrBuffer = lines[lines.length - 1];
}
});
// Handle process exit
this.process.on('exit', (code) => {
if (code === 0 || code === null) {
console.log(`[TranscodeSession ${this.id}] FFmpeg completed successfully`);
this.status = 'stopped';
} else if (code !== 255) { // 255 is often from SIGKILL
console.error(`[TranscodeSession ${this.id}] FFmpeg exited with code ${code}`);
this.status = 'error';
this.error = `FFmpeg exited with code ${code}`;
}
this.process = null;
this.emit('exit', code);
});
// Handle spawn errors
this.process.on('error', (err) => {
console.error(`[TranscodeSession ${this.id}] FFmpeg error:`, err);
this.status = 'error';
this.error = err.message;
this.emit('error', err);
});
// Save session metadata
await this.persist();
} catch (err) {
this.status = 'error';
this.error = err.message;
throw err;
}
}
/**
* Build FFmpeg arguments for HLS output with optional GPU encoding
*/
buildFFmpegArgs() {
const segmentPattern = path.join(this.dir, 'seg%04d.m4s');
const videoMode = this.options.videoMode || 'encode';
// Resolve 'auto' encoder to detected hardware, fallback to software
let encoder = this.options.hwEncoder || 'software';
if (encoder === 'auto') {
const hwCaps = hwDetect.getCapabilities();
encoder = hwCaps?.recommended || 'software';
console.log(`[TranscodeSession ${this.id}] Auto encoder resolved to: ${encoder}`);
}
const args = [
'-hide_banner',
'-loglevel', 'warning',
'-user_agent', this.options.userAgent,
];
// Add hardware acceleration input options based on encoder (only if encoding)
if (videoMode === 'encode') {
this.addHwAccelInputArgs(args, encoder);
}
// Input options (common)
args.push(
'-probesize', '5000000',
'-analyzeduration', '5000000',
'-fflags', '+genpts+discardcorrupt',
'-err_detect', 'ignore_err',
'-reconnect', '1',
'-reconnect_streamed', '1',
'-reconnect_delay_max', '3'
);
args.push('-i', this.url);
// Add seek offset if specified (as output option to avoid Range requests)
if (this.options.seekOffset > 0) {
args.push('-ss', String(this.options.seekOffset));
}
// Map streams
args.push('-map', '0:v:0');
args.push('-map', '0:a:0?');
// Add video encoder and filters based on selected encoder OR copy
if (videoMode === 'copy') {
args.push('-c:v', 'copy');
// Critical for MKV/MP4 -> TS copy: Convert bitstream from AVCC/HVCC to Annex B
if (this.options.videoCodec === 'hevc' || this.options.videoCodec === 'h265') {
args.push('-bsf:v', 'hevc_mp4toannexb');
} else if (this.options.videoCodec === 'h264' || this.options.videoCodec === 'avc') {
args.push('-bsf:v', 'h264_mp4toannexb');
} else {
// Fallback (e.g. unknown codec), try strict extraction
args.push('-bsf:v', 'dump_extra');
}
} else {
this.addVideoEncoderArgs(args, encoder);
}
// Audio: Apply mix preset
const audioCodec = this.options.audioCodec?.toLowerCase() || 'unknown';
const audioChannels = this.options.audioChannels || 0;
const audioMixPreset = this.options.audioMixPreset || 'auto';
const isStereoAac = audioCodec.includes('aac') && audioChannels === 2;
// Define pan filter presets for 5.1 -> Stereo downmix
const AUDIO_MIX_FILTERS = {
// ITU-R BS.775 Standard: Mathematically balanced, transparent
itu: 'pan=stereo|FL=FL+0.707*FC+0.707*BL+0.5*LFE|FR=FR+0.707*FC+0.707*BR+0.5*LFE',
// Night Mode: Heavy dialogue boost, reduced bass/surrounds for quiet viewing
night: 'pan=stereo|FL=0.5*FL+1.2*FC+0.3*BL+0.1*LFE|FR=0.5*FR+1.2*FC+0.3*BR+0.1*LFE',
// Cinematic: Wide soundstage, immersive (original "dialogue boost" mix)
cinematic: 'pan=stereo|FL=FC+0.80*FL+0.60*BL+0.5*LFE|FR=FC+0.80*FR+0.60*BR+0.5*LFE'
};
if (audioMixPreset === 'passthrough') {
// Passthrough: Always copy audio, no processing
console.log(`[TranscodeSession ${this.id}] Audio: Passthrough (copy)`);
args.push('-c:a', 'copy');
} else if (audioMixPreset === 'auto' && isStereoAac) {
// Auto + Stereo AAC source: Smart copy
console.log(`[TranscodeSession ${this.id}] Audio: Auto (Smart Copy) - Source is Stereo AAC`);
args.push('-c:a', 'copy');
} else {
// Transcode to AAC with selected mix preset (default to ITU for 'auto')
const mixPreset = (audioMixPreset === 'auto') ? 'itu' : audioMixPreset;
const panFilter = AUDIO_MIX_FILTERS[mixPreset] || AUDIO_MIX_FILTERS.itu;
console.log(`[TranscodeSession ${this.id}] Audio: ${mixPreset.toUpperCase()} mix (${audioCodec} ${audioChannels}ch -> Stereo AAC)`);
args.push(
'-c:a', 'aac',
'-ar', '48000',
'-b:a', '192k',
'-af', `${panFilter},aresample=async=1`
);
}
// HLS output options
args.push(
'-f', 'hls',
'-hls_time', String(SEGMENT_DURATION),
'-hls_list_size', '0', // Keep all segments in playlist
'-hls_flags', 'independent_segments+append_list',
'-hls_segment_type', 'mpegts',
'-hls_segment_filename', path.join(this.dir, 'seg%04d.ts'),
this.playlistPath
);
return args;
}
/**
* Add hardware acceleration input arguments
*/
addHwAccelInputArgs(args, encoder) {
switch (encoder) {
case 'nvenc':
// NVIDIA CUDA/NVDEC hardware decoding
args.push(
'-hwaccel', 'cuda',
'-hwaccel_output_format', 'cuda'
);
break;
case 'vaapi':
// VAAPI hardware decoding (Linux)
args.push(
'-hwaccel', 'vaapi',
'-hwaccel_device', '/dev/dri/renderD128',
'-hwaccel_output_format', 'vaapi'
);
break;
case 'qsv':
// Intel QuickSync hardware decoding
args.push(
'-hwaccel', 'qsv',
'-hwaccel_output_format', 'qsv'
);
break;
case 'amf':
// AMD AMF (no hwaccel input, AMF is encode-only)
// Decode on CPU, encode on GPU
break;
case 'software':
case 'auto':
default:
// No hardware acceleration for input
break;
}
}
/**
* Add video encoder arguments based on selected encoder
*/
addVideoEncoderArgs(args, encoder) {
const resolution = this.getTargetHeight();
const quality = this.options.quality || 'medium';
// Quality presets mapping
const qualityPresets = {
'high': { nvenc: 18, vaapi: 18, qsv: 18, amf: 18, software: 18 },
'medium': { nvenc: 24, vaapi: 24, qsv: 24, amf: 24, software: 23 },
'low': { nvenc: 30, vaapi: 30, qsv: 30, amf: 30, software: 28 }
};
const qp = qualityPresets[quality] || qualityPresets.medium;
switch (encoder) {
case 'nvenc':
this.addNvencEncoderArgs(args, resolution, qp.nvenc);
break;
case 'amf':
this.addAmfEncoderArgs(args, resolution, qp.amf);
break;
case 'vaapi':
this.addVaapiEncoderArgs(args, resolution, qp.vaapi);
break;
case 'qsv':
this.addQsvEncoderArgs(args, resolution, qp.qsv);
break;
case 'software':
case 'auto':
default:
this.addSoftwareEncoderArgs(args, resolution, qp.software);
break;
}
}
/**
* Get target height based on maxResolution setting
*/
getTargetHeight() {
const resolutionMap = {
'4k': 2160,
'1080p': 1080,
'720p': 720,
'480p': 480
};
return resolutionMap[this.options.maxResolution] || 1080;
}
/**
* NVIDIA NVENC encoder arguments
*/
addNvencEncoderArgs(args, height, qp) {
// Video filter for scaling on GPU
args.push('-vf', `scale_cuda=-2:${height}:interp_algo=lanczos`);
// NVENC encoder with quality settings
// Using portable options that work across FFmpeg builds
args.push(
'-c:v', 'h264_nvenc',
'-preset', 'p4', // Balanced preset (p1=fastest, p7=best)
'-rc', 'constqp', // Constant QP mode
'-qp', String(qp),
'-bf', '3' // B-frames for better compression
);
}
/**
* AMD AMF encoder arguments
*/
addAmfEncoderArgs(args, height, qp) {
// CPU decoding + software scale + AMF encode
args.push('-vf', `scale=-2:${height}`);
args.push(
'-c:v', 'h264_amf',
'-quality', 'quality', // Quality preset
'-rc', 'cqp', // Constant QP
'-qp_i', String(qp),
'-qp_p', String(qp + 2),
'-qp_b', String(qp + 4)
);
}
/**
* VAAPI encoder arguments (Linux)
*/
addVaapiEncoderArgs(args, height, qp) {
// VAAPI filter chain:
// 1. scale_vaapi to resize on GPU
// 2. Ensure output format is nv12 for maximum encoder compatibility
// The format is handled automatically when using -hwaccel_output_format vaapi
args.push('-vf', `scale_vaapi=w=-2:h=${height}:format=nv12`);
// VAAPI encoder with quality setting
// Note: -global_quality is the portable way to set quality for VAAPI
args.push(
'-c:v', 'h264_vaapi',
'-profile:v', 'main', // Use main profile for compatibility
'-global_quality', String(qp),
'-bf', '3'
);
}
/**
* Intel QuickSync encoder arguments
*/
addQsvEncoderArgs(args, height, qp) {
// Scale on QSV
args.push('-vf', `scale_qsv=w=-2:h=${height}`);
args.push(
'-c:v', 'h264_qsv',
'-preset', 'medium',
'-global_quality', String(qp),
'-look_ahead', '1',
'-look_ahead_depth', '40'
);
}
/**
* Software encoder arguments (fallback)
*/
addSoftwareEncoderArgs(args, height, crf) {
// Software scaling
args.push('-vf', `scale=-2:${height}`);
args.push(
'-c:v', 'libx264',
'-preset', 'veryfast', // Fast for real-time
'-crf', String(crf),
'-profile:v', 'high',
'-level', '4.1',
'-pix_fmt', 'yuv420p' // Force 8-bit output for compatibility (fixes 10-bit input errors)
);
}
/**
* Stop the transcoding process
*/
stop() {
if (this.process) {
console.log(`[TranscodeSession ${this.id}] Stopping FFmpeg process`);
this.process.kill('SIGTERM');
// Force kill after 2 seconds if still running
setTimeout(() => {
if (this.process) {
this.process.kill('SIGKILL');
}
}, 2000);
}
this.status = 'stopped';
}
/**
* Update last access time (prevents cleanup)
*/
touch() {
this.lastAccess = Date.now();
}
/**
* Check if playlist exists and is ready
*/
async isPlaylistReady() {
try {
await fs.access(this.playlistPath);
const content = await fs.readFile(this.playlistPath, 'utf8');
// Check if playlist has at least one segment
return content.includes('.ts');
} catch {
return false;
}
}
/**
* Wait for playlist to be ready (with timeout)
*/
async waitForPlaylist(timeoutMs = 10000) {
const startTime = Date.now();
while (Date.now() - startTime < timeoutMs) {
if (await this.isPlaylistReady()) {
return true;
}
await new Promise(resolve => setTimeout(resolve, 200));
}
return false;
}
/**
* Get the HLS playlist content
*/
async getPlaylist() {
this.touch();
try {
return await fs.readFile(this.playlistPath, 'utf8');
} catch (err) {
return null;
}
}
/**
* Get a specific segment
*/
async getSegment(segmentName) {
this.touch();
const segmentPath = path.join(this.dir, segmentName);
try {
await fs.access(segmentPath);
return segmentPath;
} catch {
return null;
}
}
/**
* Save session metadata to disk for recovery
*/
async persist() {
const metadata = {
id: this.id,
url: this.url,
status: this.status,
startTime: this.startTime,
lastAccess: this.lastAccess,
options: this.options,
seekOffset: this.options.seekOffset
};
const metaPath = path.join(this.dir, 'session.json');
await fs.writeFile(metaPath, JSON.stringify(metadata, null, 2));
}
/**
* Restore a session from disk metadata
*/
static async restore(sessionDir) {
const metaPath = path.join(sessionDir, 'session.json');
try {
const data = await fs.readFile(metaPath, 'utf8');
const metadata = JSON.parse(data);
const session = new TranscodeSession(metadata.url, metadata.options);
session.id = metadata.id;
session.dir = sessionDir;
session.playlistPath = path.join(sessionDir, 'stream.m3u8');
session.startTime = metadata.startTime;
session.lastAccess = metadata.lastAccess;
session.status = 'stopped'; // Not running after restart
return session;
} catch (err) {
console.error(`Failed to restore session from ${sessionDir}:`, err.message);
return null;
}
}
/**
* Delete session directory and all segments
*/
async cleanup() {
this.stop();
try {
await fs.rm(this.dir, { recursive: true, force: true });
console.log(`[TranscodeSession ${this.id}] Cleaned up session directory`);
} catch (err) {
console.error(`[TranscodeSession ${this.id}] Failed to cleanup:`, err.message);
}
}
}
/**
* Session Manager
*/
/**
* Create a new transcode session
*/
async function createSession(url, options = {}) {
await ensureCacheDir();
const session = new TranscodeSession(url, options);
sessions.set(session.id, session);
return session;
}
/**
* Get an existing session by ID
*/
function getSession(sessionId) {
const session = sessions.get(sessionId);
if (session) {
session.touch();
}
return session;
}
/**
* Get or create a session for a URL (reuses existing if still valid)
*/
async function getOrCreateSession(url, options = {}) {
// Check for existing session with same URL
for (const session of sessions.values()) {
if (session.url === url && session.status === 'running') {
session.touch();
return session;
}
}
// Create new session
return createSession(url, options);
}
/**
* Stop and remove a session
*/
async function removeSession(sessionId) {
const session = sessions.get(sessionId);
if (session) {
await session.cleanup();
sessions.delete(sessionId);
}
}
/**
* Cleanup stale sessions (idle for too long)
*/
async function cleanupStaleSessions() {
const now = Date.now();
for (const [id, session] of sessions) {
if (now - session.lastAccess > SESSION_TIMEOUT_MS) {
console.log(`[TranscodeSession] Cleaning up stale session ${id}`);
await removeSession(id);
}
}
}
/**
* Recover sessions from disk after server restart
*/
async function recoverSessions() {
try {
await fs.access(CACHE_DIR);
const dirs = await fs.readdir(CACHE_DIR, { withFileTypes: true });
for (const dirent of dirs) {
if (dirent.isDirectory()) {
const sessionDir = path.join(CACHE_DIR, dirent.name);
const session = await TranscodeSession.restore(sessionDir);
if (session) {
sessions.set(session.id, session);
console.log(`[TranscodeSession] Recovered session ${session.id}`);
}
}
}
} catch (err) {
// Cache dir doesn't exist yet, that's fine
if (err.code !== 'ENOENT') {
console.error('[TranscodeSession] Error recovering sessions:', err.message);
}
}
}
/**
* Start cleanup interval
*/
let cleanupInterval = null;
function startCleanupInterval() {
if (!cleanupInterval) {
cleanupInterval = setInterval(cleanupStaleSessions, CLEANUP_INTERVAL_MS);
cleanupInterval.unref(); // Don't prevent process exit
}
}
/**
* Get all active sessions (for debugging/monitoring)
*/
function getAllSessions() {
return Array.from(sessions.values()).map(s => ({
id: s.id,
url: s.url,
status: s.status,
startTime: s.startTime,
lastAccess: s.lastAccess,
idleMs: Date.now() - s.lastAccess
}));
}
module.exports = {
TranscodeSession,
createSession,
getSession,
getOrCreateSession,
removeSession,
cleanupStaleSessions,
recoverSessions,
startCleanupInterval,
getAllSessions,
CACHE_DIR,
SEGMENT_DURATION
};