diff --git a/.gitignore b/.gitignore
index e625555..c45c938 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,5 +8,5 @@ coverage/
.DS_Store
dist/
release/
-
+.vscode
transcode-cache/
diff --git a/public/css/main.css b/public/css/main.css
index 5af3e93..3396145 100644
--- a/public/css/main.css
+++ b/public/css/main.css
@@ -4148,6 +4148,12 @@ kbd {
/* Green for Direct */
}
+.transcode-status.upscaling {
+ background: rgba(156, 39, 176, 0.8);
+ /* Purple for Upscaling */
+ box-shadow: 0 0 10px rgba(156, 39, 176, 0.4);
+}
+
.transcode-status.hidden {
opacity: 0;
pointer-events: none;
diff --git a/public/index.html b/public/index.html
index cfb728c..65246f6 100644
--- a/public/index.html
+++ b/public/index.html
@@ -587,6 +587,41 @@
+
Stream Processing
diff --git a/public/js/components/EpgGuide.js b/public/js/components/EpgGuide.js
index 18be2b6..ce4a01d 100644
--- a/public/js/components/EpgGuide.js
+++ b/public/js/components/EpgGuide.js
@@ -920,8 +920,8 @@ class EpgGuide {
);
if (channel) {
await window.app.channelList.selectChannel({ channelId: channel.id });
- // Switch to home page
- document.querySelector('[data-page="home"]').click();
+ // Switch to live TV page
+ document.querySelector('[data-page="live"]').click();
}
}
}
diff --git a/public/js/components/VideoPlayer.js b/public/js/components/VideoPlayer.js
index 751c8e2..2bc4d6f 100644
--- a/public/js/components/VideoPlayer.js
+++ b/public/js/components/VideoPlayer.js
@@ -814,16 +814,17 @@ class VideoPlayer {
}
}
- if (info.needsTranscode) {
- // Incompatible audio (AC3/EAC3/DTS) - use transcode session
- console.log('[Player] Auto: Using HLS transcode session');
+ if (info.needsTranscode || this.settings.upscaleEnabled) {
+ // Incompatible audio (AC3/EAC3/DTS) or Upscaling enabled - use transcode session
+ console.log(`[Player] Auto: Using HLS transcode session (${this.settings.upscaleEnabled ? 'Upscaling' : 'Incompatible audio/video'})`);
// Heuristic: If video is h264, it's likely compatible, so only copy video (audio transcode only)
- // If video is hevc/other, we need full transcode
- const videoMode = (info.video && info.video.includes('h264')) ? 'copy' : 'encode';
- const statusText = videoMode === 'copy' ? 'Transcoding (Audio)' : 'Transcoding (Video)';
+ // BUT: If upscaling is enabled, we MUST encode.
+ const videoMode = (info.video && info.video.includes('h264') && !this.settings.upscaleEnabled) ? 'copy' : 'encode';
+ const statusText = videoMode === 'copy' ? 'Transcoding (Audio)' : (this.settings.upscaleEnabled ? 'Upscaling' : 'Transcoding (Video)');
+ const statusMode = this.settings.upscaleEnabled ? 'upscaling' : 'transcoding';
- this.updateTranscodeStatus('transcoding', statusText);
+ this.updateTranscodeStatus(statusMode, statusText);
const playlistUrl = await this.startTranscodeSession(streamUrl, {
videoMode,
videoCodec: info.video,
@@ -863,10 +864,12 @@ class VideoPlayer {
}
}
- // CHECK: Force Video Transcode (Full)
- if (this.settings.forceVideoTranscode) {
- console.log('[Player] Force Video Transcode enabled. Starting session (encode)...');
- this.updateTranscodeStatus('transcoding', 'Transcoding (Video)');
+ // CHECK: Force Video Transcode (Full) or Upscaling
+ if (this.settings.forceVideoTranscode || this.settings.upscaleEnabled) {
+ const statusText = this.settings.upscaleEnabled ? 'Upscaling' : 'Transcoding (Video)';
+ const statusMode = this.settings.upscaleEnabled ? 'upscaling' : 'transcoding';
+ console.log(`[Player] ${statusText} enabled. Starting session (encode)...`);
+ this.updateTranscodeStatus(statusMode, statusText);
const playlistUrl = await this.startTranscodeSession(streamUrl, { videoMode: 'encode' });
this.currentUrl = playlistUrl;
diff --git a/public/js/pages/Settings.js b/public/js/pages/Settings.js
index ea6045b..a47b5d7 100644
--- a/public/js/pages/Settings.js
+++ b/public/js/pages/Settings.js
@@ -163,6 +163,44 @@ class SettingsPage {
});
}
+ // Upscaling Settings
+ const upscaleEnabledToggle = document.getElementById('setting-upscale-enabled');
+ const upscaleMethodSelect = document.getElementById('setting-upscale-method');
+ const upscaleTargetSelect = document.getElementById('setting-upscale-target');
+ const upscaleMethodContainer = document.getElementById('upscale-method-container');
+ const upscaleTargetContainer = document.getElementById('upscale-target-container');
+
+ // Helper to toggle upscale options visibility
+ const toggleUpscaleOptions = (enabled) => {
+ if (upscaleMethodContainer) upscaleMethodContainer.style.display = enabled ? 'flex' : 'none';
+ if (upscaleTargetContainer) upscaleTargetContainer.style.display = enabled ? 'flex' : 'none';
+ };
+
+ // Load upscaling settings
+ if (upscaleEnabledToggle) {
+ upscaleEnabledToggle.checked = s.upscaleEnabled || false;
+ toggleUpscaleOptions(upscaleEnabledToggle.checked);
+ }
+ if (upscaleMethodSelect) upscaleMethodSelect.value = s.upscaleMethod || 'hardware';
+ if (upscaleTargetSelect) upscaleTargetSelect.value = s.upscaleTarget || '1080p';
+
+ // Upscaling event handlers
+ upscaleEnabledToggle?.addEventListener('change', () => {
+ this.app.player.settings.upscaleEnabled = upscaleEnabledToggle.checked;
+ this.app.player.saveSettings();
+ toggleUpscaleOptions(upscaleEnabledToggle.checked);
+ });
+
+ upscaleMethodSelect?.addEventListener('change', () => {
+ this.app.player.settings.upscaleMethod = upscaleMethodSelect.value;
+ this.app.player.saveSettings();
+ });
+
+ upscaleTargetSelect?.addEventListener('change', () => {
+ this.app.player.settings.upscaleTarget = upscaleTargetSelect.value;
+ this.app.player.saveSettings();
+ });
+
// Stream processing toggles
forceProxyToggle?.addEventListener('change', () => {
this.app.player.settings.forceProxy = forceProxyToggle.checked;
diff --git a/public/js/pages/WatchPage.js b/public/js/pages/WatchPage.js
index 0218d27..6e8e25e 100644
--- a/public/js/pages/WatchPage.js
+++ b/public/js/pages/WatchPage.js
@@ -395,14 +395,16 @@ class WatchPage {
this.currentStreamInfo = info;
this.updateQualityBadge();
- if (info.needsTranscode) {
- console.log('[WatchPage] Auto: Using HLS transcode session (incompatible audio/video)');
+ if (info.needsTranscode || settings.upscaleEnabled) {
+ console.log(`[WatchPage] Auto: Using HLS transcode session (${settings.upscaleEnabled ? 'Upscaling' : 'Incompatible audio/video'})`);
- // Heuristic: If video is h264/compat, copy video. Usage: Audio fix.
- const videoMode = (info.video && info.video.includes('h264')) ? 'copy' : 'encode';
- const statusText = videoMode === 'copy' ? 'Transcoding (Audio)' : 'Transcoding (Video)';
+ // Heuristic: If video is h264/compat, copy video. Usage: Audio fix.
+ // BUT: If upscaling is enabled, we MUST encode.
+ const videoMode = (info.video && info.video.includes('h264') && !settings.upscaleEnabled) ? 'copy' : 'encode';
+ const statusText = videoMode === 'copy' ? 'Transcoding (Audio)' : (settings.upscaleEnabled ? 'Upscaling' : 'Transcoding (Video)');
+ const statusMode = settings.upscaleEnabled ? 'upscaling' : 'transcoding';
- this.updateTranscodeStatus('transcoding', statusText);
+ this.updateTranscodeStatus(statusMode, statusText);
const playlistUrl = await this.startTranscodeSession(url, {
videoMode,
seekOffset: this.resumeTime, // Ensure seekOffset is passed
@@ -434,10 +436,12 @@ class WatchPage {
}
}
- // Priority 1: Force Video Transcode (Full)
- if (settings.forceVideoTranscode) {
- console.log('[WatchPage] Force Video Transcode enabled. Starting session (encode)...');
- this.updateTranscodeStatus('transcoding', 'Transcoding (Video)');
+ // Priority 1: Force Video Transcode (Full) or Upscaling
+ if (settings.forceVideoTranscode || settings.upscaleEnabled) {
+ const statusText = settings.upscaleEnabled ? 'Upscaling' : 'Transcoding (Video)';
+ const statusMode = settings.upscaleEnabled ? 'upscaling' : 'transcoding';
+ console.log(`[WatchPage] ${statusText} enabled. Starting session (encode)...`);
+ this.updateTranscodeStatus(statusMode, statusText);
const playlistUrl = await this.startTranscodeSession(url, {
videoMode: 'encode',
seekOffset: this.resumeTime
diff --git a/server/db.js b/server/db.js
index a7e71a5..127d9b8 100644
--- a/server/db.js
+++ b/server/db.js
@@ -79,7 +79,11 @@ function getDefaultSettings() {
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
+ seriesProbeCacheDays: 7, // 7 days for series episode probe cache
+ // Upscaling settings
+ upscaleEnabled: false,
+ upscaleMethod: 'hardware', // hardware | software
+ upscaleTarget: '1080p' // 1080p | 4k | 720p
};
}
diff --git a/server/routes/transcode.js b/server/routes/transcode.js
index 08c736e..3f82ec9 100644
--- a/server/routes/transcode.js
+++ b/server/routes/transcode.js
@@ -48,6 +48,10 @@ router.post('/session', async (req, res) => {
maxResolution: settings.maxResolution || '1080p',
quality: settings.quality || 'medium',
audioMixPreset: settings.audioMixPreset || 'auto', // Audio downmix preset
+ // Upscaling options
+ upscaleEnabled: settings.upscaleEnabled || false,
+ upscaleMethod: settings.upscaleMethod || 'hardware',
+ upscaleTarget: settings.upscaleTarget || '1080p',
videoMode: videoMode, // 'copy' or 'encode'
videoCodec: videoCodec, // 'h264', 'hevc', etc.
audioCodec: audioCodec, // 'aac', 'ac3', etc.
diff --git a/server/services/transcodeSession.js b/server/services/transcodeSession.js
index 8cc953b..7cc6a01 100644
--- a/server/services/transcodeSession.js
+++ b/server/services/transcodeSession.js
@@ -72,6 +72,10 @@ class TranscodeSession extends EventEmitter {
hwEncoder: options.hwEncoder || 'software',
maxResolution: options.maxResolution || '1080p',
quality: options.quality || 'medium',
+ // Upscaling options
+ upscaleEnabled: options.upscaleEnabled || false,
+ upscaleMethod: options.upscaleMethod || 'hardware', // 'hardware' or 'software'
+ upscaleTarget: options.upscaleTarget || '1080p',
...options
};
}
@@ -355,7 +359,9 @@ class TranscodeSession extends EventEmitter {
}
/**
- * Get target height based on maxResolution setting
+ * Get target height based on maxResolution or upscaleTarget setting
+ * When upscaling is enabled, uses the upscaleTarget resolution.
+ * Otherwise, uses maxResolution to cap the output.
*/
getTargetHeight() {
const resolutionMap = {
@@ -364,15 +370,61 @@ class TranscodeSession extends EventEmitter {
'720p': 720,
'480p': 480
};
+
+ // When upscaling is enabled, use the upscale target resolution
+ if (this.options.upscaleEnabled) {
+ const target = resolutionMap[this.options.upscaleTarget] || 1080;
+ console.log(`[TranscodeSession ${this.id}] Upscale target height: ${target}p`);
+ return target;
+ }
+
+ // Otherwise, use max resolution as the cap
return resolutionMap[this.options.maxResolution] || 1080;
}
+ /**
+ * Build scale filter string based on encoder and upscaling settings
+ * @param {string} encoder - The encoder being used
+ * @param {number} height - Target height
+ */
+ buildScaleFilter(encoder, height) {
+ const useUpscale = this.options.upscaleEnabled;
+ const upscaleMethod = this.options.upscaleMethod || 'hardware';
+
+ // Log upscaling status
+ if (useUpscale) {
+ console.log(`[TranscodeSession ${this.id}] Upscaling: ${upscaleMethod} method to ${height}p`);
+ }
+
+ // Hardware scaling filters (for both upscale and downscale)
+ if (upscaleMethod === 'hardware' || !useUpscale) {
+ switch (encoder) {
+ case 'nvenc':
+ // NVIDIA CUDA scaling with Lanczos for upscaling
+ return `scale_cuda=-2:${height}:interp_algo=lanczos`;
+ case 'vaapi':
+ return `scale_vaapi=w=-2:h=${height}:format=nv12`;
+ case 'qsv':
+ return `scale_qsv=w=-2:h=${height}`;
+ case 'amf':
+ // AMF uses CPU decode, so use software scale
+ return useUpscale ? `scale=-2:${height}:flags=lanczos` : `scale=-2:${height}`;
+ case 'software':
+ default:
+ return useUpscale ? `scale=-2:${height}:flags=lanczos` : `scale=-2:${height}`;
+ }
+ }
+
+ // Software Lanczos scaling (high quality, slower)
+ return `scale=-2:${height}:flags=lanczos`;
+ }
+
/**
* NVIDIA NVENC encoder arguments
*/
addNvencEncoderArgs(args, height, qp) {
// Video filter for scaling on GPU
- args.push('-vf', `scale_cuda=-2:${height}:interp_algo=lanczos`);
+ args.push('-vf', this.buildScaleFilter('nvenc', height));
// NVENC encoder with quality settings
// Using portable options that work across FFmpeg builds
@@ -381,7 +433,8 @@ class TranscodeSession extends EventEmitter {
'-preset', 'p4', // Balanced preset (p1=fastest, p7=best)
'-rc', 'constqp', // Constant QP mode
'-qp', String(qp),
- '-bf', '3' // B-frames for better compression
+ '-bf', '3', // B-frames for better compression
+ '-pix_fmt', 'yuv420p' // Force 8-bit output for compatibility
);
}
@@ -390,7 +443,7 @@ class TranscodeSession extends EventEmitter {
*/
addAmfEncoderArgs(args, height, qp) {
// CPU decoding + software scale + AMF encode
- args.push('-vf', `scale=-2:${height}`);
+ args.push('-vf', this.buildScaleFilter('amf', height));
args.push(
'-c:v', 'h264_amf',
@@ -398,7 +451,8 @@ class TranscodeSession extends EventEmitter {
'-rc', 'cqp', // Constant QP
'-qp_i', String(qp),
'-qp_p', String(qp + 2),
- '-qp_b', String(qp + 4)
+ '-qp_b', String(qp + 4),
+ '-pix_fmt', 'yuv420p' // Force 8-bit output for compatibility
);
}
@@ -410,7 +464,7 @@ class TranscodeSession extends EventEmitter {
// 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`);
+ args.push('-vf', this.buildScaleFilter('vaapi', height));
// VAAPI encoder with quality setting
// Note: -global_quality is the portable way to set quality for VAAPI
@@ -418,7 +472,8 @@ class TranscodeSession extends EventEmitter {
'-c:v', 'h264_vaapi',
'-profile:v', 'main', // Use main profile for compatibility
'-global_quality', String(qp),
- '-bf', '3'
+ '-bf', '3',
+ '-pix_fmt', 'yuv420p' // Force 8-bit output for compatibility
);
}
@@ -427,14 +482,15 @@ class TranscodeSession extends EventEmitter {
*/
addQsvEncoderArgs(args, height, qp) {
// Scale on QSV
- args.push('-vf', `scale_qsv=w=-2:h=${height}`);
+ args.push('-vf', this.buildScaleFilter('qsv', height));
args.push(
'-c:v', 'h264_qsv',
'-preset', 'medium',
'-global_quality', String(qp),
'-look_ahead', '1',
- '-look_ahead_depth', '40'
+ '-look_ahead_depth', '40',
+ '-pix_fmt', 'yuv420p' // Force 8-bit output for compatibility
);
}
@@ -442,8 +498,8 @@ class TranscodeSession extends EventEmitter {
* Software encoder arguments (fallback)
*/
addSoftwareEncoderArgs(args, height, crf) {
- // Software scaling
- args.push('-vf', `scale=-2:${height}`);
+ // Software scaling (use Lanczos for upscaling if enabled)
+ args.push('-vf', this.buildScaleFilter('software', height));
args.push(
'-c:v', 'libx264',