Merge feature/hw-transcoding into main
@@ -8,4 +8,5 @@ coverage/
|
||||
.DS_Store
|
||||
dist/
|
||||
release/
|
||||
|
||||
transcode-cache/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 259 B After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 177 KiB After Width: | Height: | Size: 514 KiB |
|
Before Width: | Height: | Size: 438 KiB After Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 799 KiB After Width: | Height: | Size: 879 KiB |
|
Before Width: | Height: | Size: 899 KiB After Width: | Height: | Size: 895 KiB |
|
After Width: | Height: | Size: 483 KiB |
|
After Width: | Height: | Size: 66 KiB |
@@ -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">×</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>
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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,12 +1371,21 @@ 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) {
|
||||
// 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,11 +1414,21 @@ class ChannelList {
|
||||
currentIndex = this.renderedChannels.findIndex(c => c._renderId === this.currentRenderId);
|
||||
}
|
||||
|
||||
// Fallback: Find matching channel ID, prioritizing same render group
|
||||
if (currentIndex === -1) {
|
||||
// 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;
|
||||
|
||||
|
||||
@@ -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-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>
|
||||
<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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -452,7 +452,8 @@ class SeriesPage {
|
||||
seriesId: this.currentSeries?.series_id,
|
||||
seriesInfo: this.currentSeriesInfo,
|
||||
currentSeason: seasonNum,
|
||||
currentEpisode: episodeNum
|
||||
currentEpisode: episodeNum,
|
||||
containerExtension: container
|
||||
}, result.url);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,24 +87,187 @@ 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', () => {
|
||||
// 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>';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
alert('No changes made');
|
||||
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);
|
||||
|
||||
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 {
|
||||
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) {
|
||||
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!');
|
||||
// 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
|
||||
|
||||
@@ -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,6 +492,28 @@ class WatchPage {
|
||||
|
||||
// Use HLS.js for HLS streams
|
||||
if (looksLikeHls && Hls.isSupported()) {
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
this.setVolumeFromStorage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Play HLS stream using Hls.js
|
||||
*/
|
||||
playHls(url) {
|
||||
if (this.hls) {
|
||||
this.hls.destroy();
|
||||
}
|
||||
|
||||
this.hls = new Hls({
|
||||
maxBufferLength: 30,
|
||||
maxMaxBufferLength: 60,
|
||||
@@ -332,7 +521,7 @@ class WatchPage {
|
||||
enableWorker: true,
|
||||
});
|
||||
|
||||
this.hls.loadSource(finalUrl);
|
||||
this.hls.loadSource(url);
|
||||
this.hls.attachMedia(this.video);
|
||||
|
||||
// Listen for subtitle track updates
|
||||
@@ -355,25 +544,16 @@ class WatchPage {
|
||||
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)) {
|
||||
// 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.hls.loadSource(`/api/proxy/stream?url=${encodeURIComponent(url)}`);
|
||||
this.hls.startLoad();
|
||||
this.playHls(`/api/proxy/stream?url=${encodeURIComponent(this.currentUrl)}`);
|
||||
} else {
|
||||
this.hls.destroy();
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Direct playback for mp4/mkv/avi
|
||||
this.video.src = finalUrl;
|
||||
this.video.play().catch(e => {
|
||||
if (e.name !== 'AbortError') console.error('[WatchPage] Autoplay error:', e);
|
||||
});
|
||||
}
|
||||
|
||||
this.setVolumeFromStorage();
|
||||
}
|
||||
|
||||
setVolumeFromStorage() {
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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}, ` +
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||