Merge feature/hw-transcoding into main
@@ -8,4 +8,5 @@ coverage/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
dist/
|
dist/
|
||||||
release/
|
release/
|
||||||
|
|
||||||
transcode-cache/
|
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
|
FROM node:20-slim
|
||||||
# Using system FFmpeg instead of ffmpeg-static for better Docker DNS compatibility
|
|
||||||
RUN apk add --no-cache python3 make g++ ffmpeg
|
# 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
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -15,8 +48,8 @@ RUN npm ci --only=production
|
|||||||
# Copy application files
|
# Copy application files
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Create data directory
|
# Create data and cache directories
|
||||||
RUN mkdir -p /app/data
|
RUN mkdir -p /app/data /app/transcode-cache && chmod 777 /app/transcode-cache
|
||||||
|
|
||||||
# Expose port
|
# Expose port
|
||||||
EXPOSE 3000
|
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.
|
- **🎬 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.
|
- **❤️ 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)).
|
- **🔐 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.
|
- **⚡ High Performance**: Optimized for large playlists (7000+ channels) using virtual scrolling and batch rendering.
|
||||||
- **⚙️ Management**:
|
- **⚙️ Management**:
|
||||||
- Support for Xtream Codes and M3U playlists.
|
- Support for Xtream Codes and M3U playlists.
|
||||||
- Manage hidden content categories.
|
- Manage hidden content categories.
|
||||||
- Playback preferences (volume memory, auto-play).
|
- Playback preferences (volume memory, auto-play).
|
||||||
- **🔊 Audio Transcoding**: Optional FFmpeg-based audio transcoding for Dolby/AC3/EAC3 compatibility.
|
- **🎛️ Hardware Transcoding**: GPU-accelerated transcoding with NVIDIA NVENC, AMD AMF, Intel QuickSync, and VAAPI support.
|
||||||
- **📦 Stream Remux**: Lightweight FFmpeg remux for raw MPEG-TS streams from IPTV middleware.
|
- **🔊 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.
|
- **🐳 Docker Ready**: Easy deployment containerization.
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="public/img/screenshots/screenshot-2.png" width="45%" alt="Screenshot 2" />
|
<img src="public/img/screenshots/screenshot-dashboard.png" width="45%" alt="Dashboard" />
|
||||||
<img src="public/img/screenshots/screenshot-1.png" width="45%" alt="Screenshot 1" />
|
<img src="public/img/screenshots/screenshot-1.png" width="45%" alt="Live TV" />
|
||||||
<img src="public/img/screenshots/screenshot-3.png" width="45%" alt="Screenshot 3" />
|
<img src="public/img/screenshots/screenshot-2.png" width="45%" alt="TV Guide" />
|
||||||
<img src="public/img/screenshots/screenshot-4.png" width="45%" alt="Screenshot 4" />
|
<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>
|
</div>
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
@@ -86,6 +90,49 @@ You can run nodecast-tv easily using Docker.
|
|||||||
|
|
||||||
The application will be available at `http://localhost:3000`.
|
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
|
### Usage
|
||||||
|
|
||||||
1. Go to **Settings** -> **Content Sources**.
|
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.
|
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 |
|
| Codec | Chrome | Firefox | Safari | Edge |
|
||||||
|-------|--------|---------|--------|------|
|
|-------|--------|---------|--------|------|
|
||||||
| **H.264 (AVC)** | ✅ | ✅ | ✅ | ✅ |
|
| **H.264 (AVC)** | ✅ | ✅ | ✅ | ✅ |
|
||||||
| **H.265 (HEVC)** | ❌¹ | ❌ | ✅ | ⚠️² |
|
| **H.265 (HEVC)** | Auto-Transcode | Auto-Transcode | ✅ | ⚠️ |
|
||||||
| **VP9** | ✅ | ✅ | ⚠️³ | ✅ |
|
| **AV1** | ✅ | ✅ | Auto-Transcode | ✅ |
|
||||||
| **AV1** | ✅ | ✅ | ❌ | ✅ |
|
|
||||||
| **AAC Audio** | ✅ | ✅ | ✅ | ✅ |
|
| **AAC Audio** | ✅ | ✅ | ✅ | ✅ |
|
||||||
| **AC3/EAC3 (Dolby)** | ❌ | ❌ | ✅ | ❌ |
|
| **AC3/EAC3 (Dolby)** | Auto-Transcode | Auto-Transcode | ✅ | Auto-Transcode |
|
||||||
| **MP3 Audio** | ✅ | ✅ | ✅ | ✅ |
|
|
||||||
|
|
||||||
**Notes:**
|
> **⚠️ 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.
|
||||||
1. Chrome may support HEVC on macOS with hardware decoder
|
> **ℹ️ Note:** Safari plays AV1 natively on supported hardware (iPhone 15 Pro, M3 Macs). On older devices, Auto-Transcode handles it.
|
||||||
2. Edge requires the paid "HEVC Video Extensions" from Microsoft Store ($0.99)
|
|
||||||
3. Safari VP9 support varies by device/version
|
|
||||||
|
|
||||||
**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
|
## 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.
|
- **⚠️ 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.
|
- **❌ 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 |
|
| 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 |
|
| **Auto Transcode (Smart)** | Probes streams and only transcodes/remuxes when needed | Recommended for most users (default ON) |
|
||||||
| **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 Audio Transcode** | Transcodes audio to AAC (video passes through) | When you have video but no audio (Dolby/AC3/EAC3) |
|
||||||
| **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 |
|
| **Force Video Transcode** | Full transcode of both audio and video | For HEVC/VP9 sources on unsupported browsers |
|
||||||
| **Stream Output Format** | Controls whether Xtream API requests use HLS (.m3u8) or TS format | Try TS if you experience buffering issues with HLS |
|
| **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
|
## Troubleshooting
|
||||||
|
|
||||||
@@ -159,23 +215,22 @@ All streaming settings are found in **Settings → Player → Streaming**.
|
|||||||
|
|
||||||
| Symptom | Likely Cause | Solution |
|
| Symptom | Likely Cause | Solution |
|
||||||
|---------|--------------|----------|
|
|---------|--------------|----------|
|
||||||
| Black screen, `Access-Control-Allow-Origin` error | CORS blocked | Enable **"Force Backend Proxy"** in Settings → Streaming |
|
| Black screen, `Access-Control-Allow-Origin` error | CORS blocked | Enable **"Force Backend Proxy"** in Settings → Transcoding |
|
||||||
| Black screen with `MEDIA_ERR_DECODE` or `fragParsingError` | Unsupported codec (likely HEVC) | Try a different browser (see Codec Support table) |
|
| Black screen with `MEDIA_ERR_DECODE` | Unsupported codec (HEVC/VP9) | Ensure **"Auto Transcode"** is enabled |
|
||||||
| Loading forever (no error) | Decoder hung on unsupported codec | Try Safari or Edge; stream likely uses HEVC |
|
| Loading forever (no error) | Browser decoder stuck | Enable **"Force Video Transcode"** (overrides Auto detection) |
|
||||||
|
|
||||||
### No Audio (Video Plays Fine)
|
### No Audio (Video Plays Fine)
|
||||||
|
|
||||||
| Symptom | Likely Cause | Solution |
|
| Symptom | Likely Cause | Solution |
|
||||||
|---------|--------------|----------|
|
|---------|--------------|----------|
|
||||||
| No audio at all | Dolby/AC3/EAC3 audio | Enable **"Force Audio Transcode"** in Settings → Streaming |
|
| No audio at all | Dolby/AC3/EAC3 audio | Enable **"Force Audio Transcode"** (overrides Auto detection) |
|
||||||
| No audio on some channels | Codec mismatch | Try Safari (best Dolby support) or enable transcoding |
|
|
||||||
| Audio out of sync | Stream encoding issue | Try changing stream format to TS in Settings |
|
| Audio out of sync | Stream encoding issue | Try changing stream format to TS in Settings |
|
||||||
|
|
||||||
### Buffering Issues
|
### Buffering Issues
|
||||||
|
|
||||||
| Symptom | Likely Cause | Solution |
|
| 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
|
### 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 |
|
| 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) |
|
| 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:**
|
**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:**
|
There is rarely a need to configure specific "Force Remux" settings manually anymore; the system detects stream types automatically.
|
||||||
|
|
||||||
| 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
|
|
||||||
|
|
||||||
### TVHeadend
|
### 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:
|
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)**
|
**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"**
|
- Enable **"Force Backend Proxy"**
|
||||||
- This routes streams through the server, bypassing browser CORS restrictions
|
- 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
|
### 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)
|
- **Recommended:** `http://proxy:6878/ace/manifest.m3u8?id=...` (HLS Playlist - Direct Play)
|
||||||
- **Do use**: `http://proxy:6878/ace/manifest.m3u8?id=...` (This wraps the stream in a browser-friendly HLS playlist)
|
- **Supported:** `http://proxy:6878/ace/getstream?id=...` (MPEG-TS - Requires Server Remuxing)
|
||||||
|
|
||||||
## Technology Stack
|
## 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
|
- **Frontend**: Vanilla JavaScript (ES6+), CSS3
|
||||||
- **Database**: SQLite (via better-sqlite3) for high-performance data storage
|
- **Database**: SQLite (via better-sqlite3) for high-performance data storage
|
||||||
- **Streaming**: HLS.js for stream playback
|
- **Streaming**: HLS.js for stream playback
|
||||||
- **Audio Transcoding**: FFmpeg (optional, via ffmpeg-static)
|
- **Transcoding**: FFmpeg (integrated for hardware/software transcoding)
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +1,25 @@
|
|||||||
{
|
{
|
||||||
"name": "nodecast-tv",
|
"name": "nodecast-tv",
|
||||||
"version": "1.5.0",
|
"version": "2.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "nodecast-tv",
|
"name": "nodecast-tv",
|
||||||
"version": "1.0.0",
|
"version": "2.0.0",
|
||||||
"license": "GPL-3.0-only",
|
"license": "GPL-3.0-only",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ffprobe-installer/ffprobe": "^2.1.2",
|
"@ffprobe-installer/ffprobe": "^2.1.2",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"better-sqlite3": "^12.5.0",
|
"better-sqlite3": "^12.5.0",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"express-session": "^1.18.2",
|
"express-session": "^1.18.2",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"passport-local": "^1.0.0",
|
"passport-local": "^1.0.0",
|
||||||
|
"passport-openidconnect": "^0.1.2",
|
||||||
"sax": "^1.4.3",
|
"sax": "^1.4.3",
|
||||||
"xml2js": "^0.6.2"
|
"xml2js": "^0.6.2"
|
||||||
},
|
},
|
||||||
@@ -525,6 +527,18 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
@@ -1196,6 +1210,12 @@
|
|||||||
"node": ">=10"
|
"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": {
|
"node_modules/object-inspect": {
|
||||||
"version": "1.13.4",
|
"version": "1.13.4",
|
||||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||||
@@ -1292,6 +1312,23 @@
|
|||||||
"node": ">= 0.4.0"
|
"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": {
|
"node_modules/passport-strategy": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "nodecast-tv",
|
"name": "nodecast-tv",
|
||||||
"version": "1.5.1",
|
"version": "2.0.0",
|
||||||
"description": "Modern IPTV Player",
|
"description": "Modern IPTV Player",
|
||||||
"license": "GPL-3.0-only",
|
"license": "GPL-3.0-only",
|
||||||
"main": "server/index.js",
|
"main": "server/index.js",
|
||||||
@@ -12,12 +12,14 @@
|
|||||||
"@ffprobe-installer/ffprobe": "^2.1.2",
|
"@ffprobe-installer/ffprobe": "^2.1.2",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"better-sqlite3": "^12.5.0",
|
"better-sqlite3": "^12.5.0",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"express-session": "^1.18.2",
|
"express-session": "^1.18.2",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"passport-local": "^1.0.0",
|
"passport-local": "^1.0.0",
|
||||||
|
"passport-openidconnect": "^0.1.2",
|
||||||
"sax": "^1.4.3",
|
"sax": "^1.4.3",
|
||||||
"xml2js": "^0.6.2"
|
"xml2js": "^0.6.2"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -980,7 +980,8 @@ video::cue {
|
|||||||
|
|
||||||
.epg-grid {
|
.epg-grid {
|
||||||
height: calc(100vh - var(--navbar-height) - 60px);
|
height: calc(100vh - var(--navbar-height) - 60px);
|
||||||
overflow: auto;
|
overflow: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
padding: var(--space-md);
|
padding: var(--space-md);
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
@@ -988,10 +989,22 @@ video::cue {
|
|||||||
|
|
||||||
.epg-time-header {
|
.epg-time-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
background: var(--color-bg-secondary);
|
background: var(--color-bg-secondary);
|
||||||
z-index: 10;
|
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 {
|
.epg-time-slot {
|
||||||
@@ -1103,7 +1116,7 @@ video::cue {
|
|||||||
bottom: 0;
|
bottom: 0;
|
||||||
width: 2px;
|
width: 2px;
|
||||||
background: var(--color-error);
|
background: var(--color-error);
|
||||||
z-index: 15;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* =====================================================
|
/* =====================================================
|
||||||
@@ -1853,6 +1866,21 @@ kbd {
|
|||||||
animation: spin 0.8s linear infinite;
|
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 {
|
@keyframes spin {
|
||||||
to {
|
to {
|
||||||
transform: rotate(360deg);
|
transform: rotate(360deg);
|
||||||
@@ -1887,6 +1915,7 @@ kbd {
|
|||||||
--sidebar-width: 320px;
|
--sidebar-width: 320px;
|
||||||
--space-lg: 16px;
|
--space-lg: 16px;
|
||||||
--space-xl: 24px;
|
--space-xl: 24px;
|
||||||
|
--epg-sidebar-width: 180px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Navigation - Hide text labels, show only icons */
|
/* Navigation - Hide text labels, show only icons */
|
||||||
@@ -1900,7 +1929,8 @@ kbd {
|
|||||||
gap: 0;
|
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;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2039,11 +2069,7 @@ kbd {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.epg-channel-info {
|
.epg-channel-info {
|
||||||
width: 180px;
|
width: var(--epg-sidebar-width);
|
||||||
}
|
|
||||||
|
|
||||||
.epg-time-slot {
|
|
||||||
min-width: 160px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Movies & Series */
|
/* Movies & Series */
|
||||||
@@ -3314,6 +3340,24 @@ kbd {
|
|||||||
gap: var(--space-md);
|
gap: var(--space-md);
|
||||||
padding: var(--space-lg);
|
padding: var(--space-lg);
|
||||||
padding-top: calc(var(--space-lg) + var(--safe-area-inset-top));
|
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 {
|
.watch-back-btn {
|
||||||
@@ -4026,3 +4070,669 @@ kbd {
|
|||||||
height: 18px;
|
height: 18px;
|
||||||
flex-shrink: 0;
|
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-capable" content="yes">
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
<meta name="mobile-web-app-capable" content="yes">
|
<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">
|
<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="icon" type="image/svg+xml" href="/favicon.svg">
|
||||||
<link rel="stylesheet" href="css/main.css">
|
<link rel="stylesheet" href="css/main.css">
|
||||||
@@ -39,7 +39,19 @@
|
|||||||
<span></span>
|
<span></span>
|
||||||
</button>
|
</button>
|
||||||
<div class="navbar-menu" id="navbar-menu">
|
<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">
|
<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"
|
<span class="nav-icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"
|
||||||
class="icon">
|
class="icon">
|
||||||
<path
|
<path
|
||||||
@@ -83,8 +95,14 @@
|
|||||||
|
|
||||||
<!-- Main Content Area -->
|
<!-- Main Content Area -->
|
||||||
<main class="main-content">
|
<main class="main-content">
|
||||||
<!-- Home Page -->
|
<!-- Home (Dashboard) Page -->
|
||||||
<div id="page-home" class="page active">
|
<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">
|
<div class="home-layout">
|
||||||
<!-- Channel Toggle Button (Mobile) -->
|
<!-- Channel Toggle Button (Mobile) -->
|
||||||
<button class="channel-toggle-btn" id="channel-toggle-btn">
|
<button class="channel-toggle-btn" id="channel-toggle-btn">
|
||||||
@@ -148,8 +166,12 @@
|
|||||||
<!-- Custom Controls Overlay -->
|
<!-- Custom Controls Overlay -->
|
||||||
<div class="watch-overlay hidden" id="player-controls-overlay">
|
<div class="watch-overlay hidden" id="player-controls-overlay">
|
||||||
<!-- Top Bar -->
|
<!-- Top Bar -->
|
||||||
<div class="watch-top-bar">
|
<div class="watch-top-bar live-header">
|
||||||
<h2 class="watch-title-overlay" id="player-channel-name"></h2>
|
<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>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
@@ -338,6 +360,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="series-content">
|
<div class="series-content">
|
||||||
<div id="series-grid" class="series-grid">
|
<div id="series-grid" class="series-grid">
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
@@ -368,6 +391,7 @@
|
|||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
<button class="tab active" data-tab="sources">Sources</button>
|
<button class="tab active" data-tab="sources">Sources</button>
|
||||||
<button class="tab" data-tab="player">Player</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="content">Manage Content</button>
|
||||||
<button class="tab" data-tab="users" id="users-tab" style="display:none;">Users</button>
|
<button class="tab" data-tab="users" id="users-tab" style="display:none;">Users</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -484,10 +508,90 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-section">
|
<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">
|
<div class="setting-item">
|
||||||
<label class="setting-toggle">
|
<label class="setting-toggle">
|
||||||
<input type="checkbox" id="setting-auto-transcode">
|
<input type="checkbox" id="setting-auto-transcode-tc">
|
||||||
<span class="toggle-slider"></span>
|
<span class="toggle-slider"></span>
|
||||||
</label>
|
</label>
|
||||||
<div class="setting-info">
|
<div class="setting-info">
|
||||||
@@ -498,18 +602,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="setting-item">
|
<div class="setting-item">
|
||||||
<label class="setting-toggle">
|
<label class="setting-toggle">
|
||||||
<input type="checkbox" id="setting-force-proxy">
|
<input type="checkbox" id="setting-force-transcode-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 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">
|
|
||||||
<span class="toggle-slider"></span>
|
<span class="toggle-slider"></span>
|
||||||
</label>
|
</label>
|
||||||
<div class="setting-info">
|
<div class="setting-info">
|
||||||
@@ -520,13 +613,25 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="setting-item">
|
<div class="setting-item">
|
||||||
<label class="setting-toggle">
|
<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>
|
<span class="toggle-slider"></span>
|
||||||
</label>
|
</label>
|
||||||
<div class="setting-info">
|
<div class="setting-info">
|
||||||
<span class="setting-label">Force Remux</span>
|
<span class="setting-label">Force Remux</span>
|
||||||
<span class="setting-hint">Remux streams to MP4 container. Enable for raw .ts streams
|
<span class="setting-hint">Remux streams to MP4 container. Enable for raw .ts streams from IPTV
|
||||||
from IPTV middleware like m3u-editor or dispatcharr.</span>
|
middleware like m3u-editor or dispatcharr.</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-item">
|
<div class="setting-item">
|
||||||
@@ -535,7 +640,7 @@
|
|||||||
<span class="setting-hint">Container format for Xtream streams. Try TS if you experience buffering
|
<span class="setting-hint">Container format for Xtream streams. Try TS if you experience buffering
|
||||||
issues.</span>
|
issues.</span>
|
||||||
</div>
|
</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="m3u8">HLS (m3u8) - Recommended</option>
|
||||||
<option value="ts">MPEG-TS (ts) - Better buffer</option>
|
<option value="ts">MPEG-TS (ts) - Better buffer</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -543,15 +648,42 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<h3>Keyboard Shortcuts</h3>
|
<h3>HTTP Settings</h3>
|
||||||
<div class="shortcuts-grid">
|
<div class="setting-item">
|
||||||
<div class="shortcut"><kbd>↑</kbd> / <kbd>↓</kbd> Channel up/down (or volume)</div>
|
<div class="setting-info" style="flex: 1;">
|
||||||
<div class="shortcut"><kbd>←</kbd> / <kbd>→</kbd> Volume down/up (when arrows = channels)</div>
|
<span class="setting-label">User-Agent</span>
|
||||||
<div class="shortcut"><kbd>Space</kbd> / <kbd>K</kbd> Play/Pause</div>
|
<span class="setting-hint">HTTP User-Agent for stream requests. Try VLC if your provider blocks
|
||||||
<div class="shortcut"><kbd>F</kbd> Fullscreen</div>
|
Chrome.</span>
|
||||||
<div class="shortcut"><kbd>M</kbd> Mute/Unmute</div>
|
</div>
|
||||||
<div class="shortcut"><kbd>I</kbd> Toggle info overlay</div>
|
<select id="setting-user-agent-tc" class="source-select" style="width: auto; min-width: 200px;">
|
||||||
<div class="shortcut"><kbd>PgUp</kbd> / <kbd>PgDn</kbd> Channel up/down</div>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -600,6 +732,7 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Username</th>
|
<th>Username</th>
|
||||||
|
<th>Email</th>
|
||||||
<th>Role</th>
|
<th>Role</th>
|
||||||
<th>Created</th>
|
<th>Created</th>
|
||||||
<th>Actions</th>
|
<th>Actions</th>
|
||||||
@@ -648,7 +781,7 @@
|
|||||||
<!-- Player Controls Overlay -->
|
<!-- Player Controls Overlay -->
|
||||||
<div class="watch-overlay" id="watch-overlay">
|
<div class="watch-overlay" id="watch-overlay">
|
||||||
<!-- Top Bar: Back + Title -->
|
<!-- 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">
|
<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">
|
<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" />
|
<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>
|
<h3 class="watch-title" id="watch-title"></h3>
|
||||||
<p class="watch-subtitle" id="watch-subtitle"></p>
|
<p class="watch-subtitle" id="watch-subtitle"></p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Center: Large Play Button -->
|
<!-- Center: Large Play Button -->
|
||||||
@@ -865,6 +1002,57 @@
|
|||||||
</svg> Hide</button>
|
</svg> Hide</button>
|
||||||
</div>
|
</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 -->
|
<!-- JavaScript -->
|
||||||
<script src="/js/icons.js"></script>
|
<script src="/js/icons.js"></script>
|
||||||
<script src="/js/api.js?v=2"></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/ChannelList.js?v=3"></script>
|
||||||
<script src="/js/components/SourceManager.js?v=2"></script>
|
<script src="/js/components/SourceManager.js?v=2"></script>
|
||||||
<script src="/js/components/EpgGuide.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/Guide.js?v=2"></script>
|
||||||
<script src="/js/pages/MoviesPage.js?v=4"></script>
|
<script src="/js/pages/MoviesPage.js?v=4"></script>
|
||||||
<script src="/js/pages/SeriesPage.js?v=1"></script>
|
<script src="/js/pages/SeriesPage.js?v=1"></script>
|
||||||
<script src="/js/pages/Settings.js?v=2"></script>
|
<script src="/js/pages/Settings.js?v=2"></script>
|
||||||
<script src="/js/pages/WatchPage.js?v=1"></script>
|
<script src="/js/pages/WatchPage.js?v=1"></script>
|
||||||
<script src="/js/app.js?v=4"></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>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
@@ -16,6 +16,7 @@ class App {
|
|||||||
|
|
||||||
// Initialize page controllers
|
// Initialize page controllers
|
||||||
this.pages.home = new HomePage(this);
|
this.pages.home = new HomePage(this);
|
||||||
|
this.pages.live = new LivePage(this);
|
||||||
this.pages.guide = new GuidePage(this);
|
this.pages.guide = new GuidePage(this);
|
||||||
this.pages.movies = new MoviesPage(this);
|
this.pages.movies = new MoviesPage(this);
|
||||||
this.pages.series = new SeriesPage(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
|
// Toggle groups button
|
||||||
document.getElementById('toggle-groups').addEventListener('click', () => {
|
document.getElementById('toggle-groups').addEventListener('click', () => {
|
||||||
this.channelList.toggleAllGroups();
|
this.channelList.toggleAllGroups();
|
||||||
@@ -137,9 +147,19 @@ class App {
|
|||||||
this.navigateTo(page, false); // false = don't add to history
|
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();
|
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');
|
console.log('NodeCast TV initialized');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -497,6 +497,7 @@ class ChannelList {
|
|||||||
|
|
||||||
const isFavorite = this.isFavorite(channel.sourceId, channel.id);
|
const isFavorite = this.isFavorite(channel.sourceId, channel.id);
|
||||||
const renderId = this.renderedChannels[renderIndex]?._renderId || '';
|
const renderId = this.renderedChannels[renderIndex]?._renderId || '';
|
||||||
|
const renderGroup = this.renderedChannels[renderIndex]?._renderGroup || groupName;
|
||||||
renderIndex++;
|
renderIndex++;
|
||||||
|
|
||||||
html += `
|
html += `
|
||||||
@@ -506,7 +507,8 @@ class ChannelList {
|
|||||||
data-source-type="${channel.sourceType}"
|
data-source-type="${channel.sourceType}"
|
||||||
data-stream-id="${channel.streamId || ''}"
|
data-stream-id="${channel.streamId || ''}"
|
||||||
data-url="${channel.url || ''}"
|
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)}"
|
<img class="channel-logo" src="${this.getProxiedImageUrl(channel.tvgLogo)}"
|
||||||
alt="" onerror="this.onerror=null;this.src='/img/placeholder.png'">
|
alt="" onerror="this.onerror=null;this.src='/img/placeholder.png'">
|
||||||
<div class="channel-info">
|
<div class="channel-info">
|
||||||
@@ -604,13 +606,22 @@ class ChannelList {
|
|||||||
const isActive = this.currentChannel?.id === channel.id;
|
const isActive = this.currentChannel?.id === channel.id;
|
||||||
const isFavorite = this.isFavorite(channel.sourceId, 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 += `
|
html += `
|
||||||
<div class="channel-item ${isActive ? 'active' : ''} ${channelHidden ? 'hidden' : ''}"
|
<div class="channel-item ${isActive ? 'active' : ''} ${channelHidden ? 'hidden' : ''}"
|
||||||
data-channel-id="${channel.id}"
|
data-channel-id="${channel.id}"
|
||||||
data-source-id="${channel.sourceId}"
|
data-source-id="${channel.sourceId}"
|
||||||
data-source-type="${channel.sourceType}"
|
data-source-type="${channel.sourceType}"
|
||||||
data-stream-id="${channel.streamId || ''}"
|
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)}"
|
<img class="channel-logo" src="${this.getProxiedImageUrl(channel.tvgLogo)}"
|
||||||
alt="" onerror="this.onerror=null;this.src='/img/placeholder.png'">
|
alt="" onerror="this.onerror=null;this.src='/img/placeholder.png'">
|
||||||
<div class="channel-info">
|
<div class="channel-info">
|
||||||
@@ -1079,6 +1090,7 @@ class ChannelList {
|
|||||||
|
|
||||||
this.currentChannel = channel;
|
this.currentChannel = channel;
|
||||||
this.currentRenderId = dataset.renderId; // Track which visual instance is active
|
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
|
// Update active state in DOM
|
||||||
this.container.querySelectorAll('.channel-item.active').forEach(el => {
|
this.container.querySelectorAll('.channel-item.active').forEach(el => {
|
||||||
@@ -1359,11 +1371,20 @@ class ChannelList {
|
|||||||
currentIndex = this.renderedChannels.findIndex(c => c._renderId === this.currentRenderId);
|
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) {
|
if (currentIndex === -1) {
|
||||||
currentIndex = this.renderedChannels.findIndex(c =>
|
// First try to find in same group (for Favorites containing duplicates)
|
||||||
c.id === this.currentChannel.id && c.sourceId === this.currentChannel.sourceId
|
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;
|
if (currentIndex === -1) return;
|
||||||
@@ -1393,10 +1414,20 @@ class ChannelList {
|
|||||||
currentIndex = this.renderedChannels.findIndex(c => c._renderId === this.currentRenderId);
|
currentIndex = this.renderedChannels.findIndex(c => c._renderId === this.currentRenderId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fallback: Find matching channel ID, prioritizing same render group
|
||||||
if (currentIndex === -1) {
|
if (currentIndex === -1) {
|
||||||
currentIndex = this.renderedChannels.findIndex(c =>
|
// First try to find in same group (for Favorites containing duplicates)
|
||||||
c.id === this.currentChannel.id && c.sourceId === this.currentChannel.sourceId
|
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;
|
if (currentIndex === -1) return;
|
||||||
|
|||||||
@@ -429,17 +429,20 @@ class EpgGuide {
|
|||||||
// Calculate total height for virtual scrolling
|
// Calculate total height for virtual scrolling
|
||||||
const totalHeight = this.filteredChannels.length * this.rowHeight;
|
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 = `
|
this.container.innerHTML = `
|
||||||
<div class="epg-container" style="position: relative;">
|
<div class="epg-container" style="position: relative;">
|
||||||
<div class="epg-time-header">
|
<div class="epg-scroll-container" style="overflow: auto; max-height: calc(100vh - 200px);">
|
||||||
${timeSlots.map(slot => `
|
<div class="epg-time-header">
|
||||||
<div class="epg-time-slot" style="width: ${30 * this.pixelsPerMinute}px;">
|
<div class="epg-header-corner"></div>
|
||||||
${slot.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
<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>
|
||||||
`).join('')}
|
</div>
|
||||||
</div>
|
|
||||||
<div class="epg-scroll-container" style="overflow-y: auto; max-height: calc(100vh - 200px);">
|
|
||||||
<div class="epg-spacer" style="height: ${totalHeight}px; position: relative;">
|
<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 class="epg-channel-rows" style="position: absolute; top: 0; left: 0; right: 0;"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -461,16 +464,46 @@ class EpgGuide {
|
|||||||
if (this._scrollHandler) {
|
if (this._scrollHandler) {
|
||||||
this.scrollContainer?.removeEventListener('scroll', 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._scrollHandler = this.debounce(() => this.updateVisibleRows(), 16); // ~60fps
|
||||||
this.scrollContainer.addEventListener('scroll', this._scrollHandler);
|
this.scrollContainer.addEventListener('scroll', this._scrollHandler);
|
||||||
|
|
||||||
|
// No need for horizontal scroll sync anymore - header is inside scroll container
|
||||||
|
|
||||||
// Initial render of visible rows
|
// Initial render of visible rows
|
||||||
this.updateVisibleRows();
|
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();
|
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() {
|
updateNowIndicator() {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const container = this.container.querySelector('.epg-container');
|
// Place indicator inside the spacer so it scrolls with content
|
||||||
if (!container) return;
|
const spacer = this.container.querySelector('.epg-spacer');
|
||||||
|
if (!spacer) return;
|
||||||
|
|
||||||
// Remove existing indicator
|
// Remove existing indicator
|
||||||
const existing = container.querySelector('.epg-now-line');
|
const existing = spacer.querySelector('.epg-now-line');
|
||||||
if (existing) existing.remove();
|
if (existing) existing.remove();
|
||||||
|
|
||||||
// Calculate position
|
// Calculate position relative to EPG start time
|
||||||
const startTime = new Date();
|
const minutesFromStart = (now - this.startTime) / 60000;
|
||||||
startTime.setHours(startTime.getHours() + this.timeOffset);
|
if (minutesFromStart < 0 || minutesFromStart > 1440) return; // Not in visible 24h range
|
||||||
startTime.setMinutes(0, 0, 0);
|
|
||||||
|
|
||||||
const minutesFromStart = (now - startTime) / 60000;
|
// Get sidebar width to offset the indicator
|
||||||
if (minutesFromStart < 0 || minutesFromStart > 240) return; // Not in visible range
|
const sidebar = this.container.querySelector('.epg-channel-info');
|
||||||
|
const sidebarWidth = sidebar ? sidebar.offsetWidth : 150;
|
||||||
|
|
||||||
// Get current sidebar width
|
// Position is sidebar + time offset
|
||||||
const sidebarWidth = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--epg-sidebar-width')) || 150;
|
|
||||||
const leftPos = sidebarWidth + (minutesFromStart * this.pixelsPerMinute);
|
const leftPos = sidebarWidth + (minutesFromStart * this.pixelsPerMinute);
|
||||||
|
|
||||||
const indicator = document.createElement('div');
|
const indicator = document.createElement('div');
|
||||||
indicator.className = 'epg-now-line';
|
indicator.className = 'epg-now-line';
|
||||||
indicator.style.left = `${leftPos}px`;
|
indicator.style.left = `${leftPos}px`;
|
||||||
container.appendChild(indicator);
|
spacer.appendChild(indicator);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -522,6 +522,17 @@ class VideoPlayer {
|
|||||||
// Setup custom video controls
|
// Setup custom video controls
|
||||||
this.initCustomControls();
|
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
|
// Initialize HLS.js if supported
|
||||||
if (Hls.isSupported()) {
|
if (Hls.isSupported()) {
|
||||||
this.hls = new Hls(this.getHlsConfig());
|
this.hls = new Hls(this.getHlsConfig());
|
||||||
@@ -707,6 +718,44 @@ class VideoPlayer {
|
|||||||
this.nowPlaying.classList.add('hidden');
|
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
|
* Play a channel
|
||||||
*/
|
*/
|
||||||
@@ -714,8 +763,12 @@ class VideoPlayer {
|
|||||||
this.currentChannel = channel;
|
this.currentChannel = channel;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Stop any WatchPage playback (movies/series) before starting Live TV
|
||||||
|
window.app?.pages?.watch?.stop?.();
|
||||||
|
|
||||||
// Stop current playback
|
// Stop current playback
|
||||||
this.stop();
|
this.stop();
|
||||||
|
this.updateTranscodeStatus('hidden');
|
||||||
|
|
||||||
// Hide "select a channel" overlay
|
// Hide "select a channel" overlay
|
||||||
this.overlay.classList.add('hidden');
|
this.overlay.classList.add('hidden');
|
||||||
@@ -733,7 +786,11 @@ class VideoPlayer {
|
|||||||
try {
|
try {
|
||||||
const probeRes = await fetch(`/api/probe?url=${encodeURIComponent(streamUrl)}`);
|
const probeRes = await fetch(`/api/probe?url=${encodeURIComponent(streamUrl)}`);
|
||||||
const info = await probeRes.json();
|
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
|
// Handle subtitles from probe result
|
||||||
// Clear existing remote tracks (from previous streams)
|
// Clear existing remote tracks (from previous streams)
|
||||||
@@ -758,14 +815,25 @@ class VideoPlayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (info.needsTranscode) {
|
if (info.needsTranscode) {
|
||||||
// Incompatible audio (AC3/EAC3/DTS) - use transcode
|
// Incompatible audio (AC3/EAC3/DTS) - use transcode session
|
||||||
console.log('[Player] Auto: Using transcode (incompatible audio)');
|
console.log('[Player] Auto: Using HLS transcode session');
|
||||||
const transcodeUrl = `/api/transcode?url=${encodeURIComponent(streamUrl)}`;
|
|
||||||
this.currentUrl = transcodeUrl;
|
// Heuristic: If video is h264, it's likely compatible, so only copy video (audio transcode only)
|
||||||
this.video.src = transcodeUrl;
|
// If video is hevc/other, we need full transcode
|
||||||
this.video.play().catch(e => {
|
const videoMode = (info.video && info.video.includes('h264')) ? 'copy' : 'encode';
|
||||||
if (e.name !== 'AbortError') console.log('[Player] Autoplay prevented:', e);
|
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.updateNowPlaying(channel);
|
||||||
this.showNowPlayingOverlay();
|
this.showNowPlayingOverlay();
|
||||||
this.fetchEpgData(channel);
|
this.fetchEpgData(channel);
|
||||||
@@ -774,6 +842,7 @@ class VideoPlayer {
|
|||||||
} else if (info.needsRemux) {
|
} else if (info.needsRemux) {
|
||||||
// Raw .ts container - use remux
|
// Raw .ts container - use remux
|
||||||
console.log('[Player] Auto: Using remux (.ts container)');
|
console.log('[Player] Auto: Using remux (.ts container)');
|
||||||
|
this.updateTranscodeStatus('remuxing', 'Remux (Auto)');
|
||||||
const remuxUrl = `/api/remux?url=${encodeURIComponent(streamUrl)}`;
|
const remuxUrl = `/api/remux?url=${encodeURIComponent(streamUrl)}`;
|
||||||
this.currentUrl = remuxUrl;
|
this.currentUrl = remuxUrl;
|
||||||
this.video.src = remuxUrl;
|
this.video.src = remuxUrl;
|
||||||
@@ -794,25 +863,73 @@ class VideoPlayer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CHECK: Force Transcode Priority - transcoded streams bypass HLS.js
|
// CHECK: Force Video Transcode (Full)
|
||||||
if (this.settings.forceTranscode) {
|
if (this.settings.forceVideoTranscode) {
|
||||||
console.log('[Player] Force Transcode enabled. Routing through ffmpeg...');
|
console.log('[Player] Force Video Transcode enabled. Starting session (encode)...');
|
||||||
const transcodeUrl = `/api/transcode?url=${encodeURIComponent(streamUrl)}`;
|
this.updateTranscodeStatus('transcoding', 'Transcoding (Video)');
|
||||||
this.currentUrl = transcodeUrl;
|
const playlistUrl = await this.startTranscodeSession(streamUrl, { videoMode: 'encode' });
|
||||||
|
this.currentUrl = playlistUrl;
|
||||||
|
|
||||||
// Transcoded streams are fragmented MP4 - play directly with <video> element
|
// Load HLS
|
||||||
console.log('[Player] Playing transcoded stream directly:', transcodeUrl);
|
this.updateNowPlaying(channel, 'Transcoding (Video)');
|
||||||
this.video.src = transcodeUrl;
|
// ... (rest is same logic flow, simplified by just falling through to playHls call if I refactored)
|
||||||
this.video.play().catch(e => {
|
// But for minimize drift, I'll copy the block logic for HLS playback init
|
||||||
if (e.name !== 'AbortError') console.log('[Player] Autoplay prevented:', e);
|
// 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
|
// Update UI and dispatch events
|
||||||
this.updateNowPlaying(channel);
|
this.updateNowPlaying(channel);
|
||||||
this.showNowPlayingOverlay();
|
this.showNowPlayingOverlay();
|
||||||
this.fetchEpgData(channel);
|
this.fetchEpgData(channel);
|
||||||
window.dispatchEvent(new CustomEvent('channelChanged', { detail: channel }));
|
window.dispatchEvent(new CustomEvent('channelChanged', { detail: channel }));
|
||||||
return; // Exit early - don't use HLS.js path
|
return; // Exit early
|
||||||
}
|
}
|
||||||
|
|
||||||
// Proactively use proxy for:
|
// Proactively use proxy for:
|
||||||
@@ -842,6 +959,7 @@ class VideoPlayer {
|
|||||||
if (this.settings.forceRemux && (isRawTs || isExtensionless)) {
|
if (this.settings.forceRemux && (isRawTs || isExtensionless)) {
|
||||||
console.log('[Player] Force Remux enabled. Routing through FFmpeg remux...');
|
console.log('[Player] Force Remux enabled. Routing through FFmpeg remux...');
|
||||||
console.log('[Player] Stream type:', isRawTs ? 'Raw TS' : 'Extension-less (assumed TS)');
|
console.log('[Player] Stream type:', isRawTs ? 'Raw TS' : 'Extension-less (assumed TS)');
|
||||||
|
this.updateTranscodeStatus('remuxing', 'Remux (Force)');
|
||||||
const remuxUrl = this.getRemuxUrl(streamUrl);
|
const remuxUrl = this.getRemuxUrl(streamUrl);
|
||||||
this.video.src = remuxUrl;
|
this.video.src = remuxUrl;
|
||||||
this.video.play().catch(e => {
|
this.video.play().catch(e => {
|
||||||
@@ -870,6 +988,13 @@ class VideoPlayer {
|
|||||||
|
|
||||||
// Priority 1: Use HLS.js for HLS streams on browsers that support it
|
// Priority 1: Use HLS.js for HLS streams on browsers that support it
|
||||||
if (looksLikeHls && Hls.isSupported()) {
|
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 = new Hls(this.getHlsConfig());
|
||||||
this.hls.loadSource(finalUrl);
|
this.hls.loadSource(finalUrl);
|
||||||
this.hls.attachMedia(this.video);
|
this.hls.attachMedia(this.video);
|
||||||
@@ -883,11 +1008,13 @@ class VideoPlayer {
|
|||||||
// Re-attach error handler for the new Hls instance
|
// Re-attach error handler for the new Hls instance
|
||||||
this.hls.on(Hls.Events.ERROR, (event, data) => {
|
this.hls.on(Hls.Events.ERROR, (event, data) => {
|
||||||
if (data.fatal) {
|
if (data.fatal) {
|
||||||
// CORS issues can manifest as NETWORK_ERROR or MEDIA_ERROR with fragParsingError
|
|
||||||
const isCorsLikely = data.type === Hls.ErrorTypes.NETWORK_ERROR ||
|
const isCorsLikely = data.type === Hls.ErrorTypes.NETWORK_ERROR ||
|
||||||
(data.type === Hls.ErrorTypes.MEDIA_ERROR && data.details === 'fragParsingError');
|
(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);
|
console.log('CORS/Network error detected, retrying via proxy...', data.details);
|
||||||
this.isUsingProxy = true;
|
this.isUsingProxy = true;
|
||||||
this.hls.loadSource(this.getProxiedUrl(this.currentUrl));
|
this.hls.loadSource(this.getProxiedUrl(this.currentUrl));
|
||||||
@@ -925,6 +1052,7 @@ class VideoPlayer {
|
|||||||
} else if (this.video.canPlayType('application/vnd.apple.mpegurl') === 'probably' ||
|
} else if (this.video.canPlayType('application/vnd.apple.mpegurl') === 'probably' ||
|
||||||
this.video.canPlayType('application/vnd.apple.mpegurl') === 'maybe') {
|
this.video.canPlayType('application/vnd.apple.mpegurl') === 'maybe') {
|
||||||
// Priority 2: Native HLS support (Safari on iOS/macOS where HLS.js may not work)
|
// 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.src = finalUrl;
|
||||||
this.video.play().catch(e => {
|
this.video.play().catch(e => {
|
||||||
if (e.name === 'AbortError') return; // Ignore interruption by new load
|
if (e.name === 'AbortError') return; // Ignore interruption by new load
|
||||||
@@ -939,6 +1067,7 @@ class VideoPlayer {
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Priority 3: Try direct playback for non-HLS streams
|
// Priority 3: Try direct playback for non-HLS streams
|
||||||
|
this.updateTranscodeStatus('direct', 'Direct Play');
|
||||||
this.video.src = finalUrl;
|
this.video.src = finalUrl;
|
||||||
this.video.play().catch(e => {
|
this.video.play().catch(e => {
|
||||||
if (e.name !== 'AbortError') console.log('Autoplay prevented:', 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
|
* Fetch EPG data for current channel
|
||||||
*/
|
*/
|
||||||
async fetchEpgData(channel) {
|
async fetchEpgData(channel) {
|
||||||
|
if (!channel || (!channel.tvgId && !channel.epg_id)) {
|
||||||
|
this.updateNowPlaying(channel, null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
// First, try to use the centralized EpgGuide data (already loaded)
|
// First, try to use the centralized EpgGuide data (already loaded)
|
||||||
if (window.app && window.app.epgGuide && window.app.epgGuide.programmes) {
|
if (window.app && window.app.epgGuide && window.app.epgGuide.programmes) {
|
||||||
@@ -1088,6 +1294,9 @@ class VideoPlayer {
|
|||||||
* Stop playback
|
* Stop playback
|
||||||
*/
|
*/
|
||||||
stop() {
|
stop() {
|
||||||
|
// Stop any running transcode session first
|
||||||
|
this.stopTranscodeSession();
|
||||||
|
|
||||||
if (this.hls) {
|
if (this.hls) {
|
||||||
this.hls.destroy();
|
this.hls.destroy();
|
||||||
this.hls = null;
|
this.hls = null;
|
||||||
@@ -1101,6 +1310,11 @@ class VideoPlayer {
|
|||||||
this.controlsOverlay?.classList.add('hidden'); // Hide controls
|
this.controlsOverlay?.classList.add('hidden'); // Hide controls
|
||||||
this.loadingSpinner?.classList.remove('show');
|
this.loadingSpinner?.classList.remove('show');
|
||||||
this.nowPlaying.classList.add('hidden');
|
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>`,
|
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>`,
|
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>`,
|
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;
|
window.Icons = Icons;
|
||||||
|
|||||||
@@ -12,6 +12,15 @@ class GuidePage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async show() {
|
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
|
// Only load EPG data if not already loaded
|
||||||
if (!this.app.epgGuide.programmes || this.app.epgGuide.programmes.length === 0) {
|
if (!this.app.epgGuide.programmes || this.app.epgGuide.programmes.length === 0) {
|
||||||
await this.app.epgGuide.loadEpg();
|
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
|
* Home Page Controller
|
||||||
*/
|
*/
|
||||||
|
|
||||||
class HomePage {
|
class LivePage {
|
||||||
constructor(app) {
|
constructor(app) {
|
||||||
this.app = app;
|
this.app = app;
|
||||||
this.handleKeydown = this.handleKeydown.bind(this);
|
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),
|
year: movie.year || movie.releaseDate?.substring(0, 4),
|
||||||
rating: movie.rating,
|
rating: movie.rating,
|
||||||
sourceId: movie.sourceId,
|
sourceId: movie.sourceId,
|
||||||
categoryId: movie.category_id
|
categoryId: movie.category_id,
|
||||||
|
containerExtension: container
|
||||||
}, result.url);
|
}, result.url);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -452,7 +452,8 @@ class SeriesPage {
|
|||||||
seriesId: this.currentSeries?.series_id,
|
seriesId: this.currentSeries?.series_id,
|
||||||
seriesInfo: this.currentSeriesInfo,
|
seriesInfo: this.currentSeriesInfo,
|
||||||
currentSeason: seasonNum,
|
currentSeason: seasonNum,
|
||||||
currentEpisode: episodeNum
|
currentEpisode: episodeNum,
|
||||||
|
containerExtension: container
|
||||||
}, result.url);
|
}, result.url);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ class SettingsPage {
|
|||||||
// Player settings
|
// Player settings
|
||||||
this.initPlayerSettings();
|
this.initPlayerSettings();
|
||||||
|
|
||||||
|
// Transcoding settings
|
||||||
|
this.initTranscodingSettings();
|
||||||
|
|
||||||
// User management (admin only)
|
// User management (admin only)
|
||||||
this.initUserManagement();
|
this.initUserManagement();
|
||||||
}
|
}
|
||||||
@@ -31,10 +34,6 @@ class SettingsPage {
|
|||||||
const volumeValueDisplay = document.getElementById('volume-value');
|
const volumeValueDisplay = document.getElementById('volume-value');
|
||||||
const rememberVolumeToggle = document.getElementById('setting-remember-volume');
|
const rememberVolumeToggle = document.getElementById('setting-remember-volume');
|
||||||
const autoPlayNextToggle = document.getElementById('setting-autoplay-next');
|
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
|
// Load current settings
|
||||||
if (this.app.player?.settings) {
|
if (this.app.player?.settings) {
|
||||||
@@ -44,18 +43,6 @@ class SettingsPage {
|
|||||||
volumeValueDisplay.textContent = this.app.player.settings.defaultVolume + '%';
|
volumeValueDisplay.textContent = this.app.player.settings.defaultVolume + '%';
|
||||||
rememberVolumeToggle.checked = this.app.player.settings.rememberVolume;
|
rememberVolumeToggle.checked = this.app.player.settings.rememberVolume;
|
||||||
autoPlayNextToggle.checked = this.app.player.settings.autoPlayNextEpisode;
|
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
|
// Arrow keys toggle
|
||||||
@@ -66,56 +53,30 @@ class SettingsPage {
|
|||||||
|
|
||||||
// Overlay duration
|
// Overlay duration
|
||||||
overlayDurationInput.addEventListener('change', () => {
|
overlayDurationInput.addEventListener('change', () => {
|
||||||
const value = Math.min(30, Math.max(1, parseInt(overlayDurationInput.value) || 5));
|
this.app.player.settings.overlayDuration = parseInt(overlayDurationInput.value) || 5;
|
||||||
overlayDurationInput.value = value;
|
|
||||||
this.app.player.settings.overlayDuration = value;
|
|
||||||
this.app.player.saveSettings();
|
this.app.player.saveSettings();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Default volume slider
|
// Default volume slider
|
||||||
defaultVolumeSlider?.addEventListener('input', () => {
|
defaultVolumeSlider.addEventListener('input', () => {
|
||||||
const value = parseInt(defaultVolumeSlider.value);
|
const value = defaultVolumeSlider.value;
|
||||||
volumeValueDisplay.textContent = value + '%';
|
volumeValueDisplay.textContent = value + '%';
|
||||||
this.app.player.settings.defaultVolume = value;
|
this.app.player.settings.defaultVolume = parseInt(value);
|
||||||
this.app.player.saveSettings();
|
this.app.player.saveSettings();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Remember volume toggle
|
// Remember volume toggle
|
||||||
rememberVolumeToggle?.addEventListener('change', () => {
|
rememberVolumeToggle.addEventListener('change', () => {
|
||||||
this.app.player.settings.rememberVolume = rememberVolumeToggle.checked;
|
this.app.player.settings.rememberVolume = rememberVolumeToggle.checked;
|
||||||
this.app.player.saveSettings();
|
this.app.player.saveSettings();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-play next episode toggle
|
// Auto-play next episode toggle
|
||||||
autoPlayNextToggle?.addEventListener('change', () => {
|
autoPlayNextToggle.addEventListener('change', () => {
|
||||||
this.app.player.settings.autoPlayNextEpisode = autoPlayNextToggle.checked;
|
this.app.player.settings.autoPlayNextEpisode = autoPlayNextToggle.checked;
|
||||||
this.app.player.saveSettings();
|
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
|
// EPG refresh interval
|
||||||
const epgRefreshSelect = document.getElementById('epg-refresh-interval');
|
const epgRefreshSelect = document.getElementById('epg-refresh-interval');
|
||||||
if (epgRefreshSelect && this.app.player?.settings) {
|
if (epgRefreshSelect && this.app.player?.settings) {
|
||||||
@@ -126,25 +87,188 @@ class SettingsPage {
|
|||||||
epgRefreshSelect.addEventListener('change', () => {
|
epgRefreshSelect.addEventListener('change', () => {
|
||||||
this.app.player.settings.epgRefreshInterval = epgRefreshSelect.value;
|
this.app.player.settings.epgRefreshInterval = epgRefreshSelect.value;
|
||||||
this.app.player.saveSettings();
|
this.app.player.saveSettings();
|
||||||
// Server-side sync timer is restarted automatically via settings API
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update last refreshed display
|
// Update last refreshed display
|
||||||
this.updateEpgLastRefreshed();
|
this.updateEpgLastRefreshed();
|
||||||
|
}
|
||||||
|
|
||||||
// Stream output format
|
async initTranscodingSettings() {
|
||||||
const streamFormatSelect = document.getElementById('setting-stream-format');
|
// Encoder settings
|
||||||
if (streamFormatSelect && this.app.player?.settings) {
|
const hwEncoderSelect = document.getElementById('setting-hw-encoder');
|
||||||
// Load saved value from player settings
|
const maxResolutionSelect = document.getElementById('setting-max-resolution');
|
||||||
streamFormatSelect.value = this.app.player.settings.streamFormat || 'm3u8';
|
const qualitySelect = document.getElementById('setting-quality');
|
||||||
|
|
||||||
// Save on change
|
// Stream processing (use -tc suffix IDs from Transcoding tab)
|
||||||
streamFormatSelect.addEventListener('change', () => {
|
const forceProxyToggle = document.getElementById('setting-force-proxy-tc');
|
||||||
this.app.player.settings.streamFormat = streamFormatSelect.value;
|
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();
|
this.app.player.saveSettings();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stream processing toggles
|
||||||
|
forceProxyToggle?.addEventListener('change', () => {
|
||||||
|
this.app.player.settings.forceProxy = forceProxyToggle.checked;
|
||||||
|
this.app.player.saveSettings();
|
||||||
|
});
|
||||||
|
|
||||||
|
autoTranscodeToggle?.addEventListener('change', () => {
|
||||||
|
this.app.player.settings.autoTranscode = autoTranscodeToggle.checked;
|
||||||
|
this.app.player.saveSettings();
|
||||||
|
});
|
||||||
|
|
||||||
|
forceTranscodeToggle?.addEventListener('change', () => {
|
||||||
|
this.app.player.settings.forceTranscode = forceTranscodeToggle.checked;
|
||||||
|
this.app.player.saveSettings();
|
||||||
|
});
|
||||||
|
|
||||||
|
forceVideoTranscodeToggle?.addEventListener('change', () => {
|
||||||
|
this.app.player.settings.forceVideoTranscode = forceVideoTranscodeToggle.checked;
|
||||||
|
this.app.player.saveSettings();
|
||||||
|
});
|
||||||
|
|
||||||
|
forceRemuxToggle?.addEventListener('change', () => {
|
||||||
|
this.app.player.settings.forceRemux = forceRemuxToggle.checked;
|
||||||
|
this.app.player.saveSettings();
|
||||||
|
});
|
||||||
|
|
||||||
|
streamFormatSelect?.addEventListener('change', () => {
|
||||||
|
this.app.player.settings.streamFormat = streamFormatSelect.value;
|
||||||
|
this.app.player.saveSettings();
|
||||||
|
});
|
||||||
|
|
||||||
|
// User-Agent handlers
|
||||||
|
const toggleCustomInput = () => {
|
||||||
|
if (customUaContainer) {
|
||||||
|
customUaContainer.style.display = userAgentSelect?.value === 'custom' ? 'flex' : 'none';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
userAgentSelect?.addEventListener('change', () => {
|
||||||
|
this.app.player.settings.userAgentPreset = userAgentSelect.value;
|
||||||
|
this.app.player.saveSettings();
|
||||||
|
toggleCustomInput();
|
||||||
|
});
|
||||||
|
|
||||||
|
userAgentCustomInput?.addEventListener('change', () => {
|
||||||
|
this.app.player.settings.userAgentCustom = userAgentCustomInput.value;
|
||||||
|
this.app.player.saveSettings();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load and display hardware info in Transcoding tab
|
||||||
|
*/
|
||||||
|
async loadHardwareInfo() {
|
||||||
|
const container = document.getElementById('hw-info-container');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/settings/hw-info');
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch hardware info');
|
||||||
|
const hwInfo = await response.json();
|
||||||
|
|
||||||
|
const detected = [];
|
||||||
|
|
||||||
|
// Only show detected hardware
|
||||||
|
if (hwInfo.nvidia?.available) {
|
||||||
|
detected.push(`<div class="hw-info-item hw-available">
|
||||||
|
<span class="hw-badge">✓ NVIDIA</span>
|
||||||
|
<span class="hw-name">${hwInfo.nvidia.name}</span>
|
||||||
|
</div>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hwInfo.amf?.available) {
|
||||||
|
detected.push(`<div class="hw-info-item hw-available">
|
||||||
|
<span class="hw-badge">✓ AMD</span>
|
||||||
|
<span class="hw-name">${hwInfo.amf.name || 'Available'}</span>
|
||||||
|
</div>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hwInfo.qsv?.available) {
|
||||||
|
detected.push(`<div class="hw-info-item hw-available">
|
||||||
|
<span class="hw-badge">✓ Intel QSV</span>
|
||||||
|
<span class="hw-name">Available</span>
|
||||||
|
</div>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hwInfo.vaapi?.available) {
|
||||||
|
detected.push(`<div class="hw-info-item hw-available">
|
||||||
|
<span class="hw-badge">✓ VAAPI</span>
|
||||||
|
<span class="hw-name">${hwInfo.vaapi.device || 'Available'}</span>
|
||||||
|
</div>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let html;
|
||||||
|
if (detected.length > 0) {
|
||||||
|
html = `<div class="hw-info-grid">${detected.join('')}</div>`;
|
||||||
|
html += `<p class="hint" style="margin-top: var(--space-sm);">Recommended encoder: <strong>${hwInfo.recommended}</strong></p>`;
|
||||||
|
} else {
|
||||||
|
html = `<p class="hint">No GPU acceleration detected. Using software encoding.</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = html;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading hardware info:', err);
|
||||||
|
container.innerHTML = '<p class="hint error">Failed to load hardware info</p>';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
initUserManagement() {
|
initUserManagement() {
|
||||||
@@ -179,53 +303,166 @@ class SettingsPage {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const users = await API.users.getAll();
|
const users = await API.users.getAll();
|
||||||
|
// Store users in memory for easy access during edit
|
||||||
|
this.users = users;
|
||||||
|
|
||||||
if (users.length === 0) {
|
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;
|
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>
|
<tr>
|
||||||
<td>${user.username}</td>
|
<td>
|
||||||
<td><span class="badge badge-${user.role === 'admin' ? 'primary' : 'secondary'}">${user.role}</span></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>${user.createdAt ? new Date(user.createdAt).toLocaleDateString() : 'N/A'}</td>
|
||||||
<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>
|
<button class="btn btn-sm btn-error" onclick="window.app.pages.settings.deleteUser(${user.id}, '${user.username}')">Delete</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
`).join('');
|
`}).join('');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error loading users:', 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) {
|
openEditUserModal(userId) {
|
||||||
const username = prompt('Enter new username (leave blank to keep current):');
|
console.log('openEditUserModal called with ID:', userId, 'Type:', typeof userId);
|
||||||
const password = prompt('Enter new password (leave blank to keep current):');
|
console.log('Current users list:', this.users);
|
||||||
const role = prompt('Enter role (admin or viewer, leave blank to keep current):');
|
|
||||||
|
|
||||||
const updates = {};
|
const user = this.users.find(u => u.id === userId);
|
||||||
if (username) updates.username = username;
|
if (!user) {
|
||||||
if (password) updates.password = password;
|
console.error('User not found in this.users cache!');
|
||||||
if (role) updates.role = role;
|
console.log('Available IDs:', this.users.map(u => u.id));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log('User found:', user);
|
||||||
|
|
||||||
if (Object.keys(updates).length === 0) {
|
const modal = document.getElementById('edit-user-modal');
|
||||||
alert('No changes made');
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isSSO = !!user.oidcId;
|
||||||
|
console.log('Is SSO user:', isSSO);
|
||||||
|
|
||||||
|
// Populate form with null checks
|
||||||
try {
|
try {
|
||||||
await API.users.update(userId, updates);
|
const editId = document.getElementById('edit-user-id');
|
||||||
alert('User updated successfully!');
|
const editUsername = document.getElementById('edit-username');
|
||||||
this.loadUsers();
|
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) {
|
} catch (err) {
|
||||||
alert('Error updating user: ' + err.message);
|
console.error('Error populating modal:', err);
|
||||||
|
alert('Error opening edit modal: ' + err.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setupModalHandlers(modal) {
|
||||||
|
if (this.modalHandlersSetup) return;
|
||||||
|
|
||||||
|
const closeBtn = document.getElementById('edit-user-close');
|
||||||
|
const cancelBtn = document.getElementById('edit-user-cancel');
|
||||||
|
const saveBtn = document.getElementById('edit-user-save');
|
||||||
|
|
||||||
|
const closeModal = () => modal.classList.remove('active');
|
||||||
|
|
||||||
|
closeBtn.onclick = closeModal;
|
||||||
|
cancelBtn.onclick = closeModal;
|
||||||
|
|
||||||
|
// Click outside to close
|
||||||
|
modal.onclick = (e) => {
|
||||||
|
if (e.target === modal) closeModal();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save Handler
|
||||||
|
saveBtn.onclick = async () => {
|
||||||
|
const userId = document.getElementById('edit-user-id').value;
|
||||||
|
const updates = {
|
||||||
|
username: document.getElementById('edit-username').value,
|
||||||
|
role: document.getElementById('edit-role').value
|
||||||
|
};
|
||||||
|
|
||||||
|
const newPassword = document.getElementById('edit-password').value;
|
||||||
|
if (newPassword && !document.getElementById('edit-password').disabled) {
|
||||||
|
updates.password = newPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await API.users.update(userId, updates);
|
||||||
|
// alert('User updated successfully!'); // Optional: Replace with toast?
|
||||||
|
closeModal();
|
||||||
|
this.loadUsers();
|
||||||
|
} catch (err) {
|
||||||
|
alert('Error updating user: ' + err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.modalHandlersSetup = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async deleteUser(userId, username) {
|
async deleteUser(userId, username) {
|
||||||
if (!confirm(`Are you sure you want to delete user "${username}"?`)) {
|
if (!confirm(`Are you sure you want to delete user "${username}"?`)) {
|
||||||
return;
|
return;
|
||||||
@@ -233,7 +470,6 @@ class SettingsPage {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await API.users.delete(userId);
|
await API.users.delete(userId);
|
||||||
alert('User deleted successfully!');
|
|
||||||
this.loadUsers();
|
this.loadUsers();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert('Error deleting user: ' + err.message);
|
alert('Error deleting user: ' + err.message);
|
||||||
@@ -253,6 +489,11 @@ class SettingsPage {
|
|||||||
if (tabName === 'users') {
|
if (tabName === 'users') {
|
||||||
this.loadUsers();
|
this.loadUsers();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load hardware info when switching to transcode tab
|
||||||
|
if (tabName === 'transcode') {
|
||||||
|
this.loadHardwareInfo();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async show() {
|
async show() {
|
||||||
@@ -297,6 +538,18 @@ class SettingsPage {
|
|||||||
if (autoTranscodeToggle) autoTranscodeToggle.checked = s.autoTranscode || false;
|
if (autoTranscodeToggle) autoTranscodeToggle.checked = s.autoTranscode || false;
|
||||||
if (epgRefreshSelect) epgRefreshSelect.value = s.epgRefreshInterval || '24';
|
if (epgRefreshSelect) epgRefreshSelect.value = s.epgRefreshInterval || '24';
|
||||||
if (streamFormatSelect) streamFormatSelect.value = s.streamFormat || 'm3u8';
|
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
|
// Update EPG last refreshed display
|
||||||
|
|||||||
@@ -59,6 +59,10 @@ class WatchPage {
|
|||||||
this.captionsMenu = document.getElementById('watch-captions-menu');
|
this.captionsMenu = document.getElementById('watch-captions-menu');
|
||||||
this.captionsList = document.getElementById('watch-captions-list');
|
this.captionsList = document.getElementById('watch-captions-list');
|
||||||
|
|
||||||
|
// Transcode Status
|
||||||
|
this.transcodeStatusEx = document.getElementById('watch-transcode-status');
|
||||||
|
this.qualityBadgeEl = document.getElementById('watch-quality-badge');
|
||||||
|
|
||||||
// State
|
// State
|
||||||
this.hls = null;
|
this.hls = null;
|
||||||
this.content = null;
|
this.content = null;
|
||||||
@@ -79,6 +83,9 @@ class WatchPage {
|
|||||||
this.nextEpisodeCountdown = 10;
|
this.nextEpisodeCountdown = 10;
|
||||||
this.nextEpisodeInterval = null;
|
this.nextEpisodeInterval = null;
|
||||||
|
|
||||||
|
// Watch history
|
||||||
|
this.historyInterval = null;
|
||||||
|
|
||||||
this.init();
|
this.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,8 +202,13 @@ class WatchPage {
|
|||||||
this.seriesInfo = content.seriesInfo || null;
|
this.seriesInfo = content.seriesInfo || null;
|
||||||
this.currentSeason = content.currentSeason || null;
|
this.currentSeason = content.currentSeason || null;
|
||||||
this.currentEpisode = content.currentEpisode || null;
|
this.currentEpisode = content.currentEpisode || null;
|
||||||
|
this.resumeTime = content.resumeTime || 0;
|
||||||
|
this.containerExtension = content.containerExtension || 'mp4';
|
||||||
this.returnPage = content.type === 'movie' ? 'movies' : 'series';
|
this.returnPage = content.type === 'movie' ? 'movies' : 'series';
|
||||||
|
|
||||||
|
// Stop any Live TV playback before starting movie/series
|
||||||
|
this.app?.player?.stop?.();
|
||||||
|
|
||||||
// Reset state
|
// Reset state
|
||||||
this.cancelNextEpisode();
|
this.cancelNextEpisode();
|
||||||
|
|
||||||
@@ -213,6 +225,9 @@ class WatchPage {
|
|||||||
// Load video
|
// Load video
|
||||||
await this.loadVideo(streamUrl);
|
await this.loadVideo(streamUrl);
|
||||||
|
|
||||||
|
// Show Now Playing indicator in navbar
|
||||||
|
this.showNowPlaying(content.title);
|
||||||
|
|
||||||
// Populate details section
|
// Populate details section
|
||||||
this.renderDetails();
|
this.renderDetails();
|
||||||
|
|
||||||
@@ -229,9 +244,119 @@ class WatchPage {
|
|||||||
|
|
||||||
// Check favorite status
|
// Check favorite status
|
||||||
await this.checkFavorite();
|
await this.checkFavorite();
|
||||||
|
|
||||||
// Show overlay initially
|
// Show overlay initially
|
||||||
this.showOverlay();
|
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) {
|
async loadVideo(url) {
|
||||||
@@ -261,21 +386,38 @@ class WatchPage {
|
|||||||
if (settings.autoTranscode) {
|
if (settings.autoTranscode) {
|
||||||
console.log('[WatchPage] Auto Transcode enabled. Probing stream...');
|
console.log('[WatchPage] Auto Transcode enabled. Probing stream...');
|
||||||
try {
|
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();
|
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) {
|
if (info.needsTranscode) {
|
||||||
console.log('[WatchPage] Auto: Using transcode (incompatible audio)');
|
console.log('[WatchPage] Auto: Using HLS transcode session (incompatible audio/video)');
|
||||||
const finalUrl = `/api/transcode?url=${encodeURIComponent(url)}`;
|
|
||||||
this.video.src = finalUrl;
|
// Heuristic: If video is h264/compat, copy video. Usage: Audio fix.
|
||||||
this.video.play().catch(e => {
|
const videoMode = (info.video && info.video.includes('h264')) ? 'copy' : 'encode';
|
||||||
if (e.name !== 'AbortError') console.error('[WatchPage] Autoplay error:', e);
|
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();
|
this.setVolumeFromStorage();
|
||||||
return;
|
return;
|
||||||
} else if (info.needsRemux) {
|
} 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)');
|
console.log('[WatchPage] Auto: Using remux (.ts container)');
|
||||||
|
this.updateTranscodeStatus('remuxing', 'Remux (Auto)');
|
||||||
const finalUrl = `/api/remux?url=${encodeURIComponent(url)}`;
|
const finalUrl = `/api/remux?url=${encodeURIComponent(url)}`;
|
||||||
this.video.src = finalUrl;
|
this.video.src = finalUrl;
|
||||||
this.video.play().catch(e => {
|
this.video.play().catch(e => {
|
||||||
@@ -292,14 +434,38 @@ class WatchPage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Priority 1: Force Transcode - route through FFmpeg
|
// Priority 1: Force Video Transcode (Full)
|
||||||
if (settings.forceTranscode) {
|
if (settings.forceVideoTranscode) {
|
||||||
console.log('[WatchPage] Force Transcode enabled');
|
console.log('[WatchPage] Force Video Transcode enabled. Starting session (encode)...');
|
||||||
const finalUrl = `/api/transcode?url=${encodeURIComponent(url)}`;
|
this.updateTranscodeStatus('transcoding', 'Transcoding (Video)');
|
||||||
this.video.src = finalUrl;
|
const playlistUrl = await this.startTranscodeSession(url, {
|
||||||
this.video.play().catch(e => {
|
videoMode: 'encode',
|
||||||
if (e.name !== 'AbortError') console.error('[WatchPage] Autoplay error:', e);
|
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();
|
this.setVolumeFromStorage();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -307,6 +473,7 @@ class WatchPage {
|
|||||||
// Priority 2: Force Remux for raw TS streams
|
// Priority 2: Force Remux for raw TS streams
|
||||||
if (settings.forceRemux && isRawTs) {
|
if (settings.forceRemux && isRawTs) {
|
||||||
console.log('[WatchPage] Force Remux enabled');
|
console.log('[WatchPage] Force Remux enabled');
|
||||||
|
this.updateTranscodeStatus('remuxing', 'Remux (Force)');
|
||||||
const finalUrl = `/api/remux?url=${encodeURIComponent(url)}`;
|
const finalUrl = `/api/remux?url=${encodeURIComponent(url)}`;
|
||||||
this.video.src = finalUrl;
|
this.video.src = finalUrl;
|
||||||
this.video.play().catch(e => {
|
this.video.play().catch(e => {
|
||||||
@@ -325,48 +492,11 @@ class WatchPage {
|
|||||||
|
|
||||||
// Use HLS.js for HLS streams
|
// Use HLS.js for HLS streams
|
||||||
if (looksLikeHls && Hls.isSupported()) {
|
if (looksLikeHls && Hls.isSupported()) {
|
||||||
this.hls = new Hls({
|
this.updateTranscodeStatus('direct', 'Direct HLS');
|
||||||
maxBufferLength: 30,
|
this.playHls(finalUrl);
|
||||||
maxMaxBufferLength: 60,
|
|
||||||
startLevel: -1,
|
|
||||||
enableWorker: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.hls.loadSource(finalUrl);
|
|
||||||
this.hls.attachMedia(this.video);
|
|
||||||
|
|
||||||
// Listen for subtitle track updates
|
|
||||||
this.hls.on(Hls.Events.SUBTITLE_TRACKS_UPDATED, (event, data) => {
|
|
||||||
console.log('[WatchPage] Subtitle tracks updated:', data.subtitleTracks);
|
|
||||||
// Wait a moment for native text tracks to populate
|
|
||||||
setTimeout(() => this.updateCaptionsTracks(), 100);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.hls.on(Hls.Events.SUBTITLE_TRACK_SWITCH, (event, data) => {
|
|
||||||
console.log('[WatchPage] Subtitle track switched:', data);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
|
||||||
this.video.play().catch(e => {
|
|
||||||
if (e.name !== 'AbortError') console.error('[WatchPage] Autoplay error:', e);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
this.hls.on(Hls.Events.ERROR, (event, data) => {
|
|
||||||
if (data.fatal) {
|
|
||||||
console.error('[WatchPage] HLS fatal error:', data);
|
|
||||||
// Try proxy on CORS error
|
|
||||||
if (!needsProxy && (data.type === Hls.ErrorTypes.NETWORK_ERROR)) {
|
|
||||||
console.log('[WatchPage] Retrying via proxy...');
|
|
||||||
this.hls.loadSource(`/api/proxy/stream?url=${encodeURIComponent(url)}`);
|
|
||||||
this.hls.startLoad();
|
|
||||||
} else {
|
|
||||||
this.hls.destroy();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
// Direct playback for mp4/mkv/avi
|
// Direct playback for mp4/mkv/avi
|
||||||
|
this.updateTranscodeStatus('direct', 'Direct Play');
|
||||||
this.video.src = finalUrl;
|
this.video.src = finalUrl;
|
||||||
this.video.play().catch(e => {
|
this.video.play().catch(e => {
|
||||||
if (e.name !== 'AbortError') console.error('[WatchPage] Autoplay error:', e);
|
if (e.name !== 'AbortError') console.error('[WatchPage] Autoplay error:', e);
|
||||||
@@ -376,6 +506,56 @@ class WatchPage {
|
|||||||
this.setVolumeFromStorage();
|
this.setVolumeFromStorage();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play HLS stream using Hls.js
|
||||||
|
*/
|
||||||
|
playHls(url) {
|
||||||
|
if (this.hls) {
|
||||||
|
this.hls.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.hls = new Hls({
|
||||||
|
maxBufferLength: 30,
|
||||||
|
maxMaxBufferLength: 60,
|
||||||
|
startLevel: -1,
|
||||||
|
enableWorker: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.hls.loadSource(url);
|
||||||
|
this.hls.attachMedia(this.video);
|
||||||
|
|
||||||
|
// Listen for subtitle track updates
|
||||||
|
this.hls.on(Hls.Events.SUBTITLE_TRACKS_UPDATED, (event, data) => {
|
||||||
|
console.log('[WatchPage] Subtitle tracks updated:', data.subtitleTracks);
|
||||||
|
// Wait a moment for native text tracks to populate
|
||||||
|
setTimeout(() => this.updateCaptionsTracks(), 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.hls.on(Hls.Events.SUBTITLE_TRACK_SWITCH, (event, data) => {
|
||||||
|
console.log('[WatchPage] Subtitle track switched:', data);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||||
|
this.video.play().catch(e => {
|
||||||
|
if (e.name !== 'AbortError') console.error('[WatchPage] Autoplay error:', e);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.hls.on(Hls.Events.ERROR, (event, data) => {
|
||||||
|
if (data.fatal) {
|
||||||
|
console.error('[WatchPage] HLS fatal error:', data);
|
||||||
|
// Try proxy on CORS error (only if not already proxied/transcoded)
|
||||||
|
// Note: Transcoded streams are local, so no CORS issues usually
|
||||||
|
if (!url.startsWith('/api/') && (data.type === Hls.ErrorTypes.NETWORK_ERROR)) {
|
||||||
|
console.log('[WatchPage] Retrying via proxy...');
|
||||||
|
this.playHls(`/api/proxy/stream?url=${encodeURIComponent(this.currentUrl)}`);
|
||||||
|
} else {
|
||||||
|
this.hls.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
setVolumeFromStorage() {
|
setVolumeFromStorage() {
|
||||||
const savedVolume = localStorage.getItem('nodecast-volume') || '80';
|
const savedVolume = localStorage.getItem('nodecast-volume') || '80';
|
||||||
this.video.volume = parseInt(savedVolume) / 100;
|
this.video.volume = parseInt(savedVolume) / 100;
|
||||||
@@ -383,6 +563,20 @@ class WatchPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
stop() {
|
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) {
|
if (this.hls) {
|
||||||
this.hls.destroy();
|
this.hls.destroy();
|
||||||
this.hls = null;
|
this.hls = null;
|
||||||
@@ -392,6 +586,8 @@ class WatchPage {
|
|||||||
this.video.src = '';
|
this.video.src = '';
|
||||||
this.video.load();
|
this.video.load();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.hideNowPlaying();
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Playback Controls ===
|
// === Playback Controls ===
|
||||||
@@ -498,7 +694,25 @@ class WatchPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMetadataLoaded() {
|
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() {
|
onPlay() {
|
||||||
@@ -719,7 +933,14 @@ class WatchPage {
|
|||||||
renderDetails() {
|
renderDetails() {
|
||||||
if (!this.content) return;
|
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.posterEl.alt = this.content.title || '';
|
||||||
this.contentTitleEl.textContent = this.content.title || '';
|
this.contentTitleEl.textContent = this.content.title || '';
|
||||||
this.yearEl.textContent = this.content.year || '';
|
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}">
|
<div class="watch-recommended-card" data-id="${movie.stream_id}" data-source="${sourceId}">
|
||||||
<img src="${movie.stream_icon || movie.cover || '/img/placeholder.png'}"
|
<img src="${movie.stream_icon || movie.cover || '/img/placeholder.png'}"
|
||||||
alt="${movie.name}"
|
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>
|
<p>${movie.name}</p>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
@@ -1056,9 +1277,53 @@ class WatchPage {
|
|||||||
|
|
||||||
hide() {
|
hide() {
|
||||||
// Called when page becomes hidden
|
// Called when page becomes hidden
|
||||||
this.stop();
|
// Don't stop playback here - allow background playback
|
||||||
this.cancelNextEpisode();
|
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;
|
window.WatchPage = WatchPage;
|
||||||
|
|||||||
@@ -195,10 +195,53 @@
|
|||||||
|
|
||||||
<button type="submit" class="btn-login" id="submit-btn">Sign In</button>
|
<button type="submit" class="btn-login" id="submit-btn">Sign In</button>
|
||||||
</form>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<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
|
// Check if setup is required
|
||||||
let isSetupMode = false;
|
let isSetupMode = false;
|
||||||
|
|
||||||
@@ -213,6 +256,9 @@
|
|||||||
document.getElementById('submit-btn').textContent = 'Create Account';
|
document.getElementById('submit-btn').textContent = 'Create Account';
|
||||||
document.getElementById('setup-message').classList.add('show');
|
document.getElementById('setup-message').classList.add('show');
|
||||||
document.getElementById('password').placeholder = 'Minimum 6 characters';
|
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) {
|
} catch (err) {
|
||||||
console.error('Error checking setup status:', 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
|
* Middleware: Require authentication using Passport JWT
|
||||||
*/
|
*/
|
||||||
@@ -145,6 +260,8 @@ module.exports = {
|
|||||||
verifyToken,
|
verifyToken,
|
||||||
configureLocalStrategy,
|
configureLocalStrategy,
|
||||||
configureJwtStrategy,
|
configureJwtStrategy,
|
||||||
|
configureSessionSerialization,
|
||||||
|
configureOidcStrategy,
|
||||||
requireAuth,
|
requireAuth,
|
||||||
requireAdmin,
|
requireAdmin,
|
||||||
requireRole
|
requireRole
|
||||||
|
|||||||
@@ -63,14 +63,40 @@ function getDefaultSettings() {
|
|||||||
lastVolume: 80,
|
lastVolume: 80,
|
||||||
autoPlayNextEpisode: false,
|
autoPlayNextEpisode: false,
|
||||||
forceProxy: false,
|
forceProxy: false,
|
||||||
forceTranscode: false,
|
forceTranscode: false, // Force Audio Transcode
|
||||||
|
forceVideoTranscode: false, // Force Video Transcode
|
||||||
forceRemux: false,
|
forceRemux: false,
|
||||||
autoTranscode: true,
|
autoTranscode: true,
|
||||||
streamFormat: 'm3u8',
|
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
|
// Write lock to prevent concurrent writes from corrupting db.json
|
||||||
let writeQueue = Promise.resolve();
|
let writeQueue = Promise.resolve();
|
||||||
const tmpPath = dbPath + '.tmp';
|
const tmpPath = dbPath + '.tmp';
|
||||||
@@ -339,6 +365,16 @@ const users = {
|
|||||||
return db.users?.find(u => u.username === username);
|
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) {
|
async create(userData) {
|
||||||
const db = await loadDb();
|
const db = await loadDb();
|
||||||
if (!db.users) {
|
if (!db.users) {
|
||||||
@@ -353,8 +389,11 @@ const users = {
|
|||||||
const newUser = {
|
const newUser = {
|
||||||
id: db.nextId++,
|
id: db.nextId++,
|
||||||
username: userData.username,
|
username: userData.username,
|
||||||
passwordHash: userData.passwordHash,
|
// For OIDC users, passwordHash is optional
|
||||||
|
passwordHash: userData.passwordHash || null,
|
||||||
role: userData.role || 'viewer',
|
role: userData.role || 'viewer',
|
||||||
|
oidcId: userData.oidcId || null,
|
||||||
|
email: userData.email || null,
|
||||||
createdAt: new Date().toISOString()
|
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');
|
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 = {
|
module.exports = {
|
||||||
getDb,
|
getDb,
|
||||||
initSchema
|
initSchema,
|
||||||
|
favorites
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
|
require('dotenv').config();
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const passport = require('passport');
|
const passport = require('passport');
|
||||||
const syncService = require('./services/syncService');
|
const syncService = require('./services/syncService');
|
||||||
@@ -17,7 +18,14 @@ app.set('trust proxy', true);
|
|||||||
app.use(express.json({ limit: '50mb' }));
|
app.use(express.json({ limit: '50mb' }));
|
||||||
|
|
||||||
// Initialize Passport
|
// 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.initialize());
|
||||||
|
app.use(passport.session());
|
||||||
|
|
||||||
app.use(express.static(path.join(__dirname, '..', 'public')));
|
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/probe', require('./routes/probe'));
|
||||||
app.use('/api/subtitle', require('./routes/subtitle'));
|
app.use('/api/subtitle', require('./routes/subtitle'));
|
||||||
app.use('/api/settings', require('./routes/settings'));
|
app.use('/api/settings', require('./routes/settings'));
|
||||||
|
app.use('/api/history', require('./routes/history'));
|
||||||
|
|
||||||
// Version endpoint
|
// Version endpoint
|
||||||
app.get('/api/version', (req, res) => {
|
app.get('/api/version', (req, res) => {
|
||||||
@@ -117,5 +126,13 @@ app.listen(PORT, async () => {
|
|||||||
await syncService.syncAll().catch(console.error);
|
await syncService.syncAll().catch(console.error);
|
||||||
// Start the server-side sync timer after initial sync
|
// Start the server-side sync timer after initial sync
|
||||||
await syncService.startSyncTimer().catch(console.error);
|
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);
|
}, 5000);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,6 +13,39 @@ auth.configureJwtStrategy(
|
|||||||
async (id) => await db.users.getById(id)
|
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
|
* Check if initial setup is required
|
||||||
* GET /api/auth/setup-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;
|
module.exports = router;
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,23 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
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) => {
|
router.get('/', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { sourceId, itemType } = req.query;
|
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);
|
res.json(items);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ error: err.message });
|
res.status(500).json({ error: err.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add favorite
|
// Add favorite for current user
|
||||||
router.post('/', async (req, res) => {
|
router.post('/', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { sourceId, itemId, itemType = 'channel' } = req.body;
|
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' });
|
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 });
|
res.json({ success: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ error: err.message });
|
res.status(500).json({ error: err.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Remove favorite
|
// Remove favorite for current user
|
||||||
router.delete('/', async (req, res) => {
|
router.delete('/', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { sourceId, itemId, itemType = 'channel' } = req.body;
|
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' });
|
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 });
|
res.json({ success: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ error: err.message });
|
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) => {
|
router.get('/check', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { sourceId, itemId, itemType = 'channel' } = req.query;
|
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' });
|
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 });
|
res.json({ isFavorite: isFav });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ error: err.message });
|
res.status(500).json({ error: err.message });
|
||||||
@@ -59,3 +63,4 @@ router.get('/check', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
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
|
* Probe stream with ffprobe
|
||||||
*/
|
*/
|
||||||
function probeStream(url, ffprobePath, timeout = 15000) {
|
function probeStream(url, ffprobePath, userAgent = null, timeout = 15000) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const args = [
|
const args = [
|
||||||
'-v', 'error',
|
'-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',
|
'-print_format', 'json',
|
||||||
'-show_streams',
|
'-show_streams',
|
||||||
'-show_format',
|
'-show_format',
|
||||||
@@ -112,11 +112,15 @@ function analyzeProbeResult(probeResult, url) {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// Determine what processing is needed
|
// Determine what processing is needed
|
||||||
// 1. Incompatible audio -> Transcode (highest priority)
|
// 4. MKV files often cause OOM/decoding issues in browser fMP4 remux,
|
||||||
const needsTranscode = !audioOk;
|
// 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
|
// 1. Incompatible audio/video OR MKV -> Transcode (or HLS Copy)
|
||||||
// This catches raw TS, MKV, AVI, etc. that have H.264/AAC but wrong container
|
const needsTranscode = !audioOk || !videoOk || isMkv;
|
||||||
|
|
||||||
|
// 2. Compatible audio/video but incompatible container (non-MKV) -> Remux (fMP4 pipe)
|
||||||
const needsRemux = !needsTranscode && (!containerOk || isRawTs);
|
const needsRemux = !needsTranscode && (!containerOk || isRawTs);
|
||||||
|
|
||||||
const compatible = !needsTranscode && !needsRemux;
|
const compatible = !needsTranscode && !needsRemux;
|
||||||
@@ -124,6 +128,9 @@ function analyzeProbeResult(probeResult, url) {
|
|||||||
return {
|
return {
|
||||||
video: videoCodec,
|
video: videoCodec,
|
||||||
audio: audioCodec,
|
audio: audioCodec,
|
||||||
|
width: videoStream?.width || 0,
|
||||||
|
height: videoStream?.height || 0,
|
||||||
|
audioChannels: audioStream?.channels || 0, // For Smart Audio Copy
|
||||||
container: container,
|
container: container,
|
||||||
compatible: compatible,
|
compatible: compatible,
|
||||||
needsRemux: needsRemux,
|
needsRemux: needsRemux,
|
||||||
@@ -133,12 +140,13 @@ function analyzeProbeResult(probeResult, url) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
router.get('/', async (req, res) => {
|
router.get('/', async (req, res) => {
|
||||||
const { url } = req.query;
|
const { url, ua } = req.query;
|
||||||
if (!url) {
|
if (!url) {
|
||||||
return res.status(400).json({ error: 'URL parameter is required' });
|
return res.status(400).json({ error: 'URL parameter is required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const ffprobePath = req.app.locals.ffprobePath;
|
const ffprobePath = req.app.locals.ffprobePath;
|
||||||
|
const cacheKey = `${url}${ua ? `|${ua}` : ''}`;
|
||||||
|
|
||||||
if (!ffprobePath) {
|
if (!ffprobePath) {
|
||||||
// No ffprobe available - assume needs transcoding to be safe
|
// No ffprobe available - assume needs transcoding to be safe
|
||||||
@@ -154,20 +162,20 @@ router.get('/', async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check cache
|
// Check cache
|
||||||
const cached = probeCache.get(url);
|
const cached = probeCache.get(cacheKey);
|
||||||
if (cached && (Date.now() - cached.timestamp < CACHE_TTL)) {
|
if (cached && (Date.now() - cached.timestamp < CACHE_TTL)) {
|
||||||
console.log(`[Probe] Cache hit for: ${url.substring(0, 50)}...`);
|
console.log(`[Probe] Cache hit for: ${url.substring(0, 50)}...`);
|
||||||
return res.json(cached.result);
|
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 {
|
try {
|
||||||
const probeResult = await probeStream(url, ffprobePath);
|
const probeResult = await probeStream(url, ffprobePath, ua);
|
||||||
const analysis = analyzeProbeResult(probeResult, url);
|
const analysis = analyzeProbeResult(probeResult, url);
|
||||||
|
|
||||||
// Cache result
|
// 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}, ` +
|
console.log(`[Probe] Result: video=${analysis.video}, audio=${analysis.audio}, ` +
|
||||||
`container=${analysis.container}, compatible=${analysis.compatible}, ` +
|
`container=${analysis.container}, compatible=${analysis.compatible}, ` +
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const { spawn } = require('child_process');
|
const { spawn } = require('child_process');
|
||||||
|
const db = require('../db');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remux stream (container conversion only)
|
* 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.
|
* 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;
|
const { url } = req.query;
|
||||||
if (!url) {
|
if (!url) {
|
||||||
return res.status(400).json({ error: 'URL parameter is required' });
|
return res.status(400).json({ error: 'URL parameter is required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const ffmpegPath = req.app.locals.ffmpegPath || 'ffmpeg';
|
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] Starting remux for: ${url}`);
|
||||||
|
console.log(`[Remux] Using User-Agent: ${settings.userAgentPreset}`);
|
||||||
|
|
||||||
// FFmpeg arguments for pure remux (no encoding)
|
// FFmpeg arguments for pure remux (no encoding)
|
||||||
// Very lightweight - just changes container from TS to fragmented MP4
|
// Very lightweight - just changes container from TS to fragmented MP4
|
||||||
const args = [
|
const args = [
|
||||||
'-hide_banner',
|
'-hide_banner',
|
||||||
'-loglevel', 'warning',
|
'-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,
|
||||||
// Low-latency startup: reduce probe/analyze time for faster first bytes
|
'-user_agent', userAgent,
|
||||||
'-probesize', '32768',
|
// Standard probe size to handle complex containers (MKV) correctly
|
||||||
'-analyzeduration', '500000', // 0.5 seconds - enough to detect audio
|
'-probesize', '5000000',
|
||||||
|
'-analyzeduration', '5000000',
|
||||||
// Error resilience: discard corrupt packets, generate timestamps, ignore DTS, no buffering
|
// Error resilience: discard corrupt packets, generate timestamps, ignore DTS, no buffering
|
||||||
'-fflags', '+genpts+discardcorrupt+igndts+nobuffer',
|
'-fflags', '+genpts+discardcorrupt+igndts+nobuffer',
|
||||||
// Ignore errors in stream and continue
|
// Ignore errors in stream and continue
|
||||||
@@ -40,6 +48,8 @@ router.get('/', (req, res) => {
|
|||||||
'-reconnect', '1',
|
'-reconnect', '1',
|
||||||
'-reconnect_streamed', '1',
|
'-reconnect_streamed', '1',
|
||||||
'-reconnect_delay_max', '5',
|
'-reconnect_delay_max', '5',
|
||||||
|
// Prevent Range/HEAD requests that some providers reject with 405
|
||||||
|
'-seekable', '0',
|
||||||
'-i', url,
|
'-i', url,
|
||||||
// STRICT MAPPING: Only map video and audio, ignore subtitles/data/attachments
|
// 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)
|
// 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',
|
'-sn', '-dn',
|
||||||
// Copy streams without re-encoding
|
// Copy streams without re-encoding
|
||||||
'-c', 'copy',
|
'-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
|
// NOTE: We intentionally do NOT use -bsf:a aac_adtstoasc here
|
||||||
// That filter only works for AAC audio and breaks AC3/EAC3/MP3.
|
// 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.
|
// 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;
|
module.exports = router;
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +1,174 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const { spawn } = require('child_process');
|
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=...
|
* GET /api/transcode?url=...
|
||||||
*
|
*
|
||||||
* Transcodes audio to AAC for browser compatibility while passing video through.
|
* 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.
|
* 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;
|
const { url } = req.query;
|
||||||
if (!url) {
|
if (!url) {
|
||||||
return res.status(400).json({ error: 'URL parameter is required' });
|
return res.status(400).json({ error: 'URL parameter is required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const ffmpegPath = req.app.locals.ffmpegPath || 'ffmpeg';
|
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] Starting transcoding for: ${url}`);
|
||||||
|
console.log(`[Transcode] Using User-Agent: ${settings.userAgentPreset}`);
|
||||||
console.log(`[Transcode] Using binary: ${ffmpegPath}`);
|
console.log(`[Transcode] Using binary: ${ffmpegPath}`);
|
||||||
|
|
||||||
// FFmpeg arguments for transcoding
|
// FFmpeg arguments for transcoding
|
||||||
@@ -25,7 +177,7 @@ router.get('/', (req, res) => {
|
|||||||
const args = [
|
const args = [
|
||||||
'-hide_banner',
|
'-hide_banner',
|
||||||
'-loglevel', 'warning',
|
'-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
|
// Faster startup - reduced probe/analyze for quicker first bytes
|
||||||
'-probesize', '2000000', // 2MB (reduced from 5MB)
|
'-probesize', '2000000', // 2MB (reduced from 5MB)
|
||||||
'-analyzeduration', '3000000', // 3 seconds (reduced from 10s)
|
'-analyzeduration', '3000000', // 3 seconds (reduced from 10s)
|
||||||
@@ -39,6 +191,8 @@ router.get('/', (req, res) => {
|
|||||||
'-reconnect', '1',
|
'-reconnect', '1',
|
||||||
'-reconnect_streamed', '1',
|
'-reconnect_streamed', '1',
|
||||||
'-reconnect_delay_max', '3',
|
'-reconnect_delay_max', '3',
|
||||||
|
// Prevent Range/HEAD requests that some providers reject with 405
|
||||||
|
'-seekable', '0',
|
||||||
'-i', url,
|
'-i', url,
|
||||||
// Map only first video and audio stream (avoid subtitle streams causing issues)
|
// Map only first video and audio stream (avoid subtitle streams causing issues)
|
||||||
'-map', '0:v:0',
|
'-map', '0:v:0',
|
||||||
@@ -112,3 +266,4 @@ router.get('/', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ function parse(input) {
|
|||||||
currentObject.description = textBuffer;
|
currentObject.description = textBuffer;
|
||||||
break;
|
break;
|
||||||
case 'category':
|
case 'category':
|
||||||
if (textBuffer) currentObject.category.push(textBuffer);
|
if (textBuffer && currentObject.category) currentObject.category.push(textBuffer);
|
||||||
break;
|
break;
|
||||||
case 'date':
|
case 'date':
|
||||||
currentObject.date = textBuffer;
|
currentObject.date = textBuffer;
|
||||||
@@ -374,7 +374,7 @@ async function* parseStreaming(input, batchSize = 1000) {
|
|||||||
currentObject.description = textBuffer;
|
currentObject.description = textBuffer;
|
||||||
break;
|
break;
|
||||||
case 'category':
|
case 'category':
|
||||||
if (textBuffer) currentObject.category.push(textBuffer);
|
if (textBuffer && currentObject.category) currentObject.category.push(textBuffer);
|
||||||
break;
|
break;
|
||||||
case 'date':
|
case 'date':
|
||||||
currentObject.date = textBuffer;
|
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
|
||||||
|
};
|
||||||