diff --git a/backend/main.py b/backend/main.py
index f1e67f0..1a77dea 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -551,6 +551,11 @@ def stream_video():
elif isinstance(info.get('http_headers'), dict):
upstream_headers = info['http_headers']
+ if request.method == 'HEAD' and selected_format:
+ manifest_url = selected_format.get('manifest_url')
+ if manifest_url:
+ return Response("", status=301, headers=[('Location', f"/api/stream?url={manifest_url}")])
+
referer_hint = None
if upstream_headers:
referer_hint = extract_referer(upstream_headers)
diff --git a/frontend/index.html b/frontend/index.html
index aa7e852..5735bdf 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -78,6 +78,18 @@
+
diff --git a/frontend/js/main.js b/frontend/js/main.js
index 49891de..4355d88 100644
--- a/frontend/js/main.js
+++ b/frontend/js/main.js
@@ -5,6 +5,7 @@ window.App = window.App || {};
async function initApp() {
await App.storage.ensureDefaults();
App.ui.applyTheme();
+ App.ui.applyPreferredQuality();
App.ui.renderMenu();
App.favorites.renderBar();
App.ui.bindGlobalHandlers();
diff --git a/frontend/js/state.js b/frontend/js/state.js
index 7e52eb2..606dbae 100644
--- a/frontend/js/state.js
+++ b/frontend/js/state.js
@@ -19,5 +19,6 @@ App.state = {
// Local storage keys used across modules.
App.constants = {
FAVORITES_KEY: 'favorites',
- FAVORITES_VISIBILITY_KEY: 'favoritesVisible'
+ FAVORITES_VISIBILITY_KEY: 'favoritesVisible',
+ PREFERRED_QUALITY_KEY: 'preferredQuality'
};
diff --git a/frontend/js/storage.js b/frontend/js/storage.js
index 4aa4f4d..1da8b1c 100644
--- a/frontend/js/storage.js
+++ b/frontend/js/storage.js
@@ -3,7 +3,7 @@ App.storage = App.storage || {};
App.session = App.session || {};
(function() {
- const { FAVORITES_KEY, FAVORITES_VISIBILITY_KEY } = App.constants;
+ const { FAVORITES_KEY, FAVORITES_VISIBILITY_KEY, PREFERRED_QUALITY_KEY } = App.constants;
// Basic localStorage helpers.
App.storage.getConfig = function() {
@@ -30,6 +30,14 @@ App.session = App.session || {};
localStorage.setItem('preferences', JSON.stringify(nextPreferences));
};
+ App.storage.getPreferredQuality = function() {
+ return localStorage.getItem(PREFERRED_QUALITY_KEY) || '1080';
+ };
+
+ App.storage.setPreferredQuality = function(nextQuality) {
+ localStorage.setItem(PREFERRED_QUALITY_KEY, nextQuality);
+ };
+
App.storage.getServerEntries = function() {
const config = App.storage.getConfig();
if (!config.servers || !Array.isArray(config.servers)) return [];
@@ -113,6 +121,9 @@ App.session = App.session || {};
if (!localStorage.getItem('theme')) {
localStorage.setItem('theme', 'dark');
}
+ if (!localStorage.getItem(PREFERRED_QUALITY_KEY)) {
+ localStorage.setItem(PREFERRED_QUALITY_KEY, '1080');
+ }
if (!localStorage.getItem(FAVORITES_KEY)) {
localStorage.setItem(FAVORITES_KEY, JSON.stringify([]));
}
diff --git a/frontend/js/ui.js b/frontend/js/ui.js
index 0761ce4..a0e73ce 100644
--- a/frontend/js/ui.js
+++ b/frontend/js/ui.js
@@ -11,6 +11,11 @@ App.ui = App.ui || {};
if (select) select.value = theme;
};
+ App.ui.applyPreferredQuality = function() {
+ const select = document.getElementById('quality-select');
+ if (select) select.value = App.storage.getPreferredQuality();
+ };
+
// Toast helper for playback + network errors.
App.ui.showError = function(message) {
const toast = document.getElementById('error-toast');
@@ -234,6 +239,13 @@ App.ui = App.ui || {};
};
}
+ const qualitySelect = document.getElementById('quality-select');
+ if (qualitySelect) {
+ qualitySelect.onchange = () => {
+ App.storage.setPreferredQuality(qualitySelect.value);
+ };
+ }
+
if (favoritesToggle) {
favoritesToggle.checked = App.favorites.isVisible();
favoritesToggle.onchange = () => {
diff --git a/frontend/js/videos.js b/frontend/js/videos.js
index 19a090f..be259ff 100644
--- a/frontend/js/videos.js
+++ b/frontend/js/videos.js
@@ -428,7 +428,7 @@ App.videos = App.videos || {};
return 0;
};
- App.videos.pickBestFormat = function(formats) {
+ App.videos.pickBestFormat = function(formats, preferredHeight) {
if (!Array.isArray(formats) || formats.length === 0) return null;
const candidates = formats.filter((fmt) => fmt && fmt.url);
if (!candidates.length) return null;
@@ -439,7 +439,7 @@ App.videos = App.videos || {};
if (vcodec && vcodec !== 'none') return true;
return false;
});
- const pool = videoCandidates.length ? videoCandidates : candidates;
+ let pool = videoCandidates.length ? videoCandidates : candidates;
const score = (fmt) => {
const height = App.videos.coerceNumber(fmt.height || fmt.quality);
const width = App.videos.coerceNumber(fmt.width);
@@ -448,6 +448,22 @@ App.videos = App.videos || {};
const fps = App.videos.coerceNumber(fmt.fps);
return [size, bitrate, fps];
};
+ if (preferredHeight) {
+ const atOrBelow = pool.filter((fmt) => {
+ const size = score(fmt)[0];
+ return size > 0 && size <= preferredHeight;
+ });
+ if (atOrBelow.length) {
+ pool = atOrBelow;
+ } else {
+ // Nothing at or below the preferred quality, fall back to the lowest available.
+ const lowest = pool.reduce((min, fmt) => {
+ if (!min) return fmt;
+ return score(fmt)[0] < score(min)[0] ? fmt : min;
+ }, null);
+ return lowest;
+ }
+ }
return pool.reduce((best, fmt) => {
if (!best) return fmt;
const bestScore = score(best);
@@ -460,7 +476,8 @@ App.videos = App.videos || {};
}, null);
};
- App.videos.resolveStreamSource = function(videoOrUrl) {
+ App.videos.resolveStreamSource = function(videoOrUrl, options) {
+ const applyPreferredQuality = !options || options.applyPreferredQuality !== false;
let sourceUrl = '';
let referer = '';
if (typeof videoOrUrl === 'string') {
@@ -468,7 +485,12 @@ App.videos = App.videos || {};
} else if (videoOrUrl && typeof videoOrUrl === 'object') {
const meta = videoOrUrl.meta || videoOrUrl;
sourceUrl = meta.url || videoOrUrl.url || '';
- const best = App.videos.pickBestFormat(meta.formats);
+ let preferredHeight = null;
+ if (applyPreferredQuality) {
+ const preferredQuality = App.storage.getPreferredQuality();
+ preferredHeight = preferredQuality === 'auto' ? null : App.videos.coerceNumber(preferredQuality);
+ }
+ const best = App.videos.pickBestFormat(meta.formats, preferredHeight);
if (best && best.url) {
sourceUrl = best.url;
if (best.http_headers && (best.http_headers.Referer || best.http_headers.referer)) {
@@ -490,8 +512,8 @@ App.videos = App.videos || {};
};
// Builds a proxied stream URL with an optional referer parameter.
- App.videos.buildStreamUrl = function(videoOrUrl) {
- const resolved = App.videos.resolveStreamSource(videoOrUrl);
+ App.videos.buildStreamUrl = function(videoOrUrl, options) {
+ const resolved = App.videos.resolveStreamSource(videoOrUrl, options);
if (!resolved.url) return '';
const refererParam = resolved.referer ? `&referer=${encodeURIComponent(resolved.referer)}` : '';
return `/api/stream?url=${encodeURIComponent(resolved.url)}${refererParam}`;
@@ -499,7 +521,7 @@ App.videos = App.videos || {};
App.videos.downloadVideo = function(video) {
if (!video) return;
- const streamUrl = App.videos.buildStreamUrl(video);
+ const streamUrl = App.videos.buildStreamUrl(video, { applyPreferredQuality: false });
if (!streamUrl) return;
const link = document.createElement('a');
link.href = streamUrl;