video downloads (phase 2): unified video quality profile
One profile across slskd/torrent/usenet — video quality is resolution + source + codec (parsed from the release name/filename), not bitrate. Best-in-class settings UI on the video Downloads tab: - resolution tiers (4K/1080p/720p/480p) with per-tier toggle + arrow reordering (priority best-first), pill toggles - source preference (BluRay > WEB-DL > WEBRip > HDTV), reorderable - codec segmented control (Any / x265 / x264), Prefer-HDR toggle - max-size-per-item slider (0 = no limit) + lower-quality fallback toggle core/video/quality_profile.py: pure default/normalize/load/save (clamps size, rejects bad codec, dedupes source list, recovers from corrupt JSON) — stored as JSON in video.db's video_settings['quality_profile']. GET/POST /api/video/downloads/quality. video-settings.js renders + saves it; isolated from music (imports only json/typing). 11 tests (9 pure + 2 API). Next: video source-mode + hybrid, then the shared slskd block.
This commit is contained in:
parent
8392de9207
commit
887ee01cb2
7 changed files with 462 additions and 1 deletions
|
|
@ -44,3 +44,16 @@ def register_routes(bp):
|
|||
if key in body:
|
||||
db.set_setting(key, (str(body.get(key) or "")).strip())
|
||||
return jsonify({"status": "saved"})
|
||||
|
||||
@bp.route("/downloads/quality", methods=["GET"])
|
||||
def video_quality_profile():
|
||||
from . import get_video_db
|
||||
from core.video.quality_profile import load
|
||||
return jsonify(load(get_video_db()))
|
||||
|
||||
@bp.route("/downloads/quality", methods=["POST"])
|
||||
def video_quality_profile_save():
|
||||
from . import get_video_db
|
||||
from core.video.quality_profile import save
|
||||
body = request.get_json(silent=True) or {}
|
||||
return jsonify(save(get_video_db(), body))
|
||||
|
|
|
|||
113
core/video/quality_profile.py
Normal file
113
core/video/quality_profile.py
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
"""Video quality profile — ONE unified profile applied to every video download
|
||||
source (slskd / torrent / usenet).
|
||||
|
||||
Unlike the music side (where quality is bitrate density), video quality is
|
||||
**resolution + source + codec**, parsed from a release title or filename. So the
|
||||
profile is a small, source-agnostic ranking the (later-phase) download engine will
|
||||
use to pick the best candidate:
|
||||
|
||||
- resolution tiers (2160p / 1080p / 720p / 480p), each enabled + priority-ordered
|
||||
- a preferred source order (bluray > web-dl > webrip > hdtv)
|
||||
- a codec preference (any / x265 / x264) and an HDR preference
|
||||
- an optional max size cap per item, and a fallback toggle
|
||||
|
||||
Pure data + normalize/validate here (no DB, no network) so it's unit-tested in
|
||||
isolation. Persisted as a JSON blob in video.db's ``video_settings['quality_profile']``.
|
||||
This module is isolated — it imports nothing from the music side.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
# Ordered best→worst; the UI renders these as the default priority order.
|
||||
RESOLUTIONS = ("2160p", "1080p", "720p", "480p")
|
||||
SOURCES = ("bluray", "web-dl", "webrip", "hdtv")
|
||||
CODECS = ("any", "x265", "x264")
|
||||
MAX_SIZE_CAP_GB = 200 # slider ceiling; 0 means "no cap"
|
||||
|
||||
|
||||
def default_profile() -> dict:
|
||||
"""A sensible default: 1080p/720p on, 4K off (size), SD off."""
|
||||
return {
|
||||
"version": 1,
|
||||
"resolutions": {
|
||||
"2160p": {"enabled": False, "priority": 1},
|
||||
"1080p": {"enabled": True, "priority": 2},
|
||||
"720p": {"enabled": True, "priority": 3},
|
||||
"480p": {"enabled": False, "priority": 4},
|
||||
},
|
||||
"source_priority": list(SOURCES),
|
||||
"codec": "any",
|
||||
"prefer_hdr": False,
|
||||
"max_size_gb": 0,
|
||||
"fallback_enabled": True,
|
||||
}
|
||||
|
||||
|
||||
def _coerce_int(value: Any, default: int) -> int:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def normalize(raw: Any) -> dict:
|
||||
"""Coerce a stored/posted profile to a valid shape, filling gaps from the
|
||||
default. Unknown keys are dropped; invalid values fall back. Never raises."""
|
||||
d = default_profile()
|
||||
if not isinstance(raw, dict):
|
||||
return d
|
||||
|
||||
res = raw.get("resolutions")
|
||||
if isinstance(res, dict):
|
||||
for i, key in enumerate(RESOLUTIONS):
|
||||
r = res.get(key)
|
||||
if isinstance(r, dict):
|
||||
d["resolutions"][key] = {
|
||||
"enabled": bool(r.get("enabled", d["resolutions"][key]["enabled"])),
|
||||
"priority": _coerce_int(r.get("priority"), i + 1),
|
||||
}
|
||||
|
||||
sp = raw.get("source_priority")
|
||||
if isinstance(sp, list):
|
||||
clean = []
|
||||
for s in sp: # keep known sources, in order, no dupes/junk
|
||||
if s in SOURCES and s not in clean:
|
||||
clean.append(s)
|
||||
for s in SOURCES: # append any the caller dropped, canonical order
|
||||
if s not in clean:
|
||||
clean.append(s)
|
||||
d["source_priority"] = clean
|
||||
|
||||
if raw.get("codec") in CODECS:
|
||||
d["codec"] = raw["codec"]
|
||||
d["prefer_hdr"] = bool(raw.get("prefer_hdr", d["prefer_hdr"]))
|
||||
d["max_size_gb"] = min(MAX_SIZE_CAP_GB, max(0, _coerce_int(raw.get("max_size_gb"), 0)))
|
||||
d["fallback_enabled"] = bool(raw.get("fallback_enabled", True))
|
||||
return d
|
||||
|
||||
|
||||
def load(db) -> dict:
|
||||
"""Read + normalize the stored profile, or the default if none/garbage."""
|
||||
raw = db.get_setting("quality_profile")
|
||||
if raw:
|
||||
try:
|
||||
return normalize(json.loads(raw))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
return default_profile()
|
||||
|
||||
|
||||
def save(db, raw: Any) -> dict:
|
||||
"""Normalize + persist; returns the normalized profile that was stored."""
|
||||
prof = normalize(raw)
|
||||
db.set_setting("quality_profile", json.dumps(prof))
|
||||
return prof
|
||||
|
||||
|
||||
__all__ = [
|
||||
"RESOLUTIONS", "SOURCES", "CODECS", "MAX_SIZE_CAP_GB",
|
||||
"default_profile", "normalize", "load", "save",
|
||||
]
|
||||
|
|
@ -379,6 +379,28 @@ def test_downloads_config_save_load(tmp_path):
|
|||
videoapi._video_db = None
|
||||
|
||||
|
||||
def test_quality_profile_endpoint_roundtrips(tmp_path):
|
||||
import api.video as videoapi
|
||||
from database.video_database import VideoDatabase
|
||||
|
||||
db = VideoDatabase(database_path=str(tmp_path / "video_library.db"))
|
||||
videoapi._video_db = db
|
||||
app = Flask(__name__)
|
||||
app.register_blueprint(videoapi.create_video_blueprint(), url_prefix="/api/video")
|
||||
client = app.test_client()
|
||||
try:
|
||||
# Default profile served when unset.
|
||||
d = client.get("/api/video/downloads/quality").get_json()
|
||||
assert d["resolutions"]["1080p"]["enabled"] is True and d["codec"] == "any"
|
||||
# POST normalizes + persists; bad codec rejected.
|
||||
out = client.post("/api/video/downloads/quality",
|
||||
json={"codec": "bogus", "max_size_gb": 50, "prefer_hdr": True}).get_json()
|
||||
assert out["codec"] == "any" and out["max_size_gb"] == 50 and out["prefer_hdr"] is True
|
||||
assert client.get("/api/video/downloads/quality").get_json()["max_size_gb"] == 50
|
||||
finally:
|
||||
videoapi._video_db = None
|
||||
|
||||
|
||||
def test_video_api_imports_nothing_from_music():
|
||||
base = Path(__file__).resolve().parent.parent / "api" / "video"
|
||||
for py in base.glob("*.py"):
|
||||
|
|
|
|||
96
tests/test_video_quality_profile.py
Normal file
96
tests/test_video_quality_profile.py
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
"""Video quality profile — the pure default/normalize/load/save seam (resolution
|
||||
tiers + source/codec/HDR + size cap), isolated from the music side."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from core.video.quality_profile import (
|
||||
CODECS,
|
||||
MAX_SIZE_CAP_GB,
|
||||
RESOLUTIONS,
|
||||
SOURCES,
|
||||
default_profile,
|
||||
load,
|
||||
normalize,
|
||||
save,
|
||||
)
|
||||
|
||||
|
||||
def test_default_shape():
|
||||
d = default_profile()
|
||||
assert set(d["resolutions"]) == set(RESOLUTIONS)
|
||||
assert d["resolutions"]["1080p"]["enabled"] is True
|
||||
assert d["resolutions"]["2160p"]["enabled"] is False # 4K off by default (size)
|
||||
assert d["source_priority"] == list(SOURCES)
|
||||
assert d["codec"] == "any" and d["prefer_hdr"] is False
|
||||
assert d["max_size_gb"] == 0 and d["fallback_enabled"] is True
|
||||
|
||||
|
||||
def test_normalize_garbage_returns_default():
|
||||
assert normalize(None) == default_profile()
|
||||
assert normalize("nope") == default_profile()
|
||||
assert normalize(123) == default_profile()
|
||||
|
||||
|
||||
def test_normalize_fills_gaps_and_coerces():
|
||||
out = normalize({
|
||||
"resolutions": {"2160p": {"enabled": True, "priority": "1"}}, # str priority coerced
|
||||
"codec": "x265",
|
||||
"prefer_hdr": 1,
|
||||
"max_size_gb": "75",
|
||||
"fallback_enabled": False,
|
||||
})
|
||||
assert out["resolutions"]["2160p"] == {"enabled": True, "priority": 1}
|
||||
assert out["resolutions"]["1080p"]["enabled"] is True # untouched tier kept from default
|
||||
assert out["codec"] == "x265" and out["prefer_hdr"] is True
|
||||
assert out["max_size_gb"] == 75 and out["fallback_enabled"] is False
|
||||
|
||||
|
||||
def test_normalize_rejects_bad_codec_and_clamps_size():
|
||||
assert normalize({"codec": "vp9"})["codec"] == "any" # unknown codec rejected
|
||||
assert normalize({"max_size_gb": -5})["max_size_gb"] == 0 # negative clamped
|
||||
assert normalize({"max_size_gb": 99999})["max_size_gb"] == MAX_SIZE_CAP_GB # capped
|
||||
|
||||
|
||||
def test_normalize_source_priority_dedupes_and_completes():
|
||||
out = normalize({"source_priority": ["web-dl", "bogus", "web-dl"]})
|
||||
# known one first, rest appended in canonical order, no dupes, no junk
|
||||
assert out["source_priority"][0] == "web-dl"
|
||||
assert set(out["source_priority"]) == set(SOURCES)
|
||||
assert len(out["source_priority"]) == len(SOURCES)
|
||||
|
||||
|
||||
# ── DB round-trip via an injected fake ────────────────────────────────────────
|
||||
class _FakeDB:
|
||||
def __init__(self):
|
||||
self._kv = {}
|
||||
|
||||
def get_setting(self, key, default=None):
|
||||
return self._kv.get(key, default)
|
||||
|
||||
def set_setting(self, key, value):
|
||||
self._kv[key] = value
|
||||
|
||||
|
||||
def test_load_default_when_unset():
|
||||
assert load(_FakeDB()) == default_profile()
|
||||
|
||||
|
||||
def test_save_then_load_roundtrips_normalized():
|
||||
db = _FakeDB()
|
||||
saved = save(db, {"codec": "x264", "max_size_gb": 40,
|
||||
"resolutions": {"480p": {"enabled": True, "priority": 4}}})
|
||||
assert saved["codec"] == "x264" and saved["max_size_gb"] == 40
|
||||
assert json.loads(db.get_setting("quality_profile"))["codec"] == "x264"
|
||||
assert load(db) == saved
|
||||
|
||||
|
||||
def test_load_recovers_from_corrupt_json():
|
||||
db = _FakeDB()
|
||||
db.set_setting("quality_profile", "{not json")
|
||||
assert load(db) == default_profile()
|
||||
|
||||
|
||||
def test_codecs_constant():
|
||||
assert CODECS == ("any", "x265", "x264")
|
||||
|
|
@ -6028,6 +6028,45 @@
|
|||
<input type="text" id="video-transfer-path" placeholder="./VideoLibrary">
|
||||
</div>
|
||||
</div>
|
||||
<!-- VIDEO quality profile — one unified profile across slskd/torrent/usenet -->
|
||||
<div class="settings-group" data-stg="downloads" data-video-only>
|
||||
<h3>Video Quality Profile</h3>
|
||||
<div class="setting-help-text">
|
||||
One profile across slskd, torrent & usenet — quality is read from the release name / filename. Toggle the tiers you want and order them best-first.
|
||||
</div>
|
||||
<div class="vq-block">
|
||||
<div class="vq-label">Resolution <span class="vq-hint">order best-first with the arrows</span></div>
|
||||
<div class="vq-rows" id="vq-resolution-rows"></div>
|
||||
</div>
|
||||
<div class="vq-block">
|
||||
<div class="vq-label">Source preference</div>
|
||||
<div class="vq-rows" id="vq-source-rows"></div>
|
||||
</div>
|
||||
<div class="vq-grid">
|
||||
<div class="vq-block">
|
||||
<div class="vq-label">Codec</div>
|
||||
<div class="vq-seg" id="vq-codec">
|
||||
<button type="button" data-vq-codec="any">Any</button>
|
||||
<button type="button" data-vq-codec="x265">x265 / HEVC</button>
|
||||
<button type="button" data-vq-codec="x264">x264</button>
|
||||
</div>
|
||||
</div>
|
||||
<label class="vq-check">
|
||||
<input type="checkbox" id="vq-prefer-hdr">
|
||||
<span class="vq-check-box"></span>
|
||||
<span>Prefer HDR / Dolby Vision</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="vq-block">
|
||||
<div class="vq-label">Max size per item <span class="vq-hint" id="vq-size-label">No limit</span></div>
|
||||
<input type="range" id="vq-max-size" class="vq-slider" min="0" max="200" step="1" value="0">
|
||||
</div>
|
||||
<label class="vq-check">
|
||||
<input type="checkbox" id="vq-fallback">
|
||||
<span class="vq-check-box"></span>
|
||||
<span>Allow lower-quality fallback when nothing in your enabled tiers is found</span>
|
||||
</label>
|
||||
</div>
|
||||
<!-- Download Settings -->
|
||||
<div class="settings-group" data-stg="downloads">
|
||||
<h3>Download Settings</h3>
|
||||
|
|
|
|||
|
|
@ -16,6 +16,10 @@
|
|||
var SERVER_URL = '/api/video/server';
|
||||
var CONN_URL = '/api/video/server-config';
|
||||
var DOWNLOADS_URL = '/api/video/downloads/config';
|
||||
var QUALITY_URL = '/api/video/downloads/quality';
|
||||
var _videoQuality = null;
|
||||
var RES_LABEL = { '2160p': '4K (2160p)', '1080p': '1080p', '720p': '720p', '480p': '480p (SD)' };
|
||||
var SRC_LABEL = { 'bluray': 'BluRay', 'web-dl': 'WEB-DL', 'webrip': 'WEBRip', 'hdtv': 'HDTV' };
|
||||
|
||||
function esc(s) {
|
||||
return String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
|
|
@ -279,6 +283,123 @@
|
|||
.catch(function () { /* ignore */ });
|
||||
}
|
||||
|
||||
// ── Video quality profile (resolution tiers + source/codec/HDR/size) ──
|
||||
function loadQuality() {
|
||||
fetch(QUALITY_URL, { headers: { 'Accept': 'application/json' } })
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (d) { if (d) { _videoQuality = d; renderQuality(); } })
|
||||
.catch(function () { /* ignore */ });
|
||||
}
|
||||
|
||||
function renderQuality() {
|
||||
var p = _videoQuality;
|
||||
if (!p) return;
|
||||
var resHost = document.getElementById('vq-resolution-rows');
|
||||
if (resHost) {
|
||||
var keys = Object.keys(p.resolutions).sort(function (a, b) {
|
||||
return p.resolutions[a].priority - p.resolutions[b].priority;
|
||||
});
|
||||
resHost.innerHTML = keys.map(function (k, i) {
|
||||
var r = p.resolutions[k];
|
||||
return '<div class="vq-row' + (r.enabled ? '' : ' vq-row--off') + '">' +
|
||||
'<span class="vq-arrows">' +
|
||||
'<button type="button" class="vq-arrow" data-vq-res-move="' + k + '" data-dir="-1"' + (i === 0 ? ' disabled' : '') + '>▲</button>' +
|
||||
'<button type="button" class="vq-arrow" data-vq-res-move="' + k + '" data-dir="1"' + (i === keys.length - 1 ? ' disabled' : '') + '>▼</button>' +
|
||||
'</span>' +
|
||||
'<span class="vq-row-name">' + (RES_LABEL[k] || k) + '</span>' +
|
||||
'<span class="vq-row-prio">' + (i + 1) + '</span>' +
|
||||
'<label class="vq-toggle"><input type="checkbox" data-vq-res-toggle="' + k + '"' + (r.enabled ? ' checked' : '') + '><span class="vq-toggle-track"></span></label>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
var srcHost = document.getElementById('vq-source-rows');
|
||||
if (srcHost) {
|
||||
srcHost.innerHTML = p.source_priority.map(function (s, i) {
|
||||
return '<div class="vq-row">' +
|
||||
'<span class="vq-arrows">' +
|
||||
'<button type="button" class="vq-arrow" data-vq-src-move="' + s + '" data-dir="-1"' + (i === 0 ? ' disabled' : '') + '>▲</button>' +
|
||||
'<button type="button" class="vq-arrow" data-vq-src-move="' + s + '" data-dir="1"' + (i === p.source_priority.length - 1 ? ' disabled' : '') + '>▼</button>' +
|
||||
'</span>' +
|
||||
'<span class="vq-row-name">' + (SRC_LABEL[s] || s) + '</span>' +
|
||||
'<span class="vq-row-prio">' + (i + 1) + '</span>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
var seg = document.getElementById('vq-codec');
|
||||
if (seg) {
|
||||
Array.prototype.forEach.call(seg.querySelectorAll('[data-vq-codec]'), function (b) {
|
||||
b.classList.toggle('active', b.getAttribute('data-vq-codec') === p.codec);
|
||||
});
|
||||
}
|
||||
var hdr = document.getElementById('vq-prefer-hdr'); if (hdr) hdr.checked = !!p.prefer_hdr;
|
||||
var fb = document.getElementById('vq-fallback'); if (fb) fb.checked = p.fallback_enabled !== false;
|
||||
var sl = document.getElementById('vq-max-size'); if (sl) sl.value = p.max_size_gb || 0;
|
||||
var lab = document.getElementById('vq-size-label');
|
||||
if (lab) lab.textContent = p.max_size_gb ? (p.max_size_gb + ' GB') : 'No limit';
|
||||
}
|
||||
|
||||
function moveRes(k, dir) {
|
||||
var p = _videoQuality; if (!p) return;
|
||||
var keys = Object.keys(p.resolutions).sort(function (a, b) {
|
||||
return p.resolutions[a].priority - p.resolutions[b].priority;
|
||||
});
|
||||
var i = keys.indexOf(k), j = i + dir;
|
||||
if (j < 0 || j >= keys.length) return;
|
||||
keys[i] = keys[j]; keys[j] = k; // swap
|
||||
keys.forEach(function (key, idx) { p.resolutions[key].priority = idx + 1; });
|
||||
renderQuality(); saveQuality(true);
|
||||
}
|
||||
|
||||
function moveSrc(s, dir) {
|
||||
var p = _videoQuality; if (!p) return;
|
||||
var arr = p.source_priority, i = arr.indexOf(s), j = i + dir;
|
||||
if (j < 0 || j >= arr.length) return;
|
||||
arr[i] = arr[j]; arr[j] = s;
|
||||
renderQuality(); saveQuality(true);
|
||||
}
|
||||
|
||||
function saveQuality(silent) {
|
||||
if (!_videoQuality) return Promise.resolve();
|
||||
return fetch(QUALITY_URL, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
||||
body: JSON.stringify(_videoQuality)
|
||||
}).then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (d) { if (d) _videoQuality = d; if (!silent) toast('Quality profile saved', 'success'); })
|
||||
.catch(function () { /* ignore */ });
|
||||
}
|
||||
|
||||
// Delegated handlers for the quality profile (rows re-render, so delegate).
|
||||
function wireQuality() {
|
||||
var sec = document.getElementById('vq-resolution-rows');
|
||||
if (!sec) return;
|
||||
var card = sec.closest('.settings-group');
|
||||
if (!card || card._vqWired) return;
|
||||
card._vqWired = true;
|
||||
card.addEventListener('click', function (e) {
|
||||
var rm = e.target.closest('[data-vq-res-move]');
|
||||
if (rm) { moveRes(rm.getAttribute('data-vq-res-move'), parseInt(rm.getAttribute('data-dir'), 10)); return; }
|
||||
var sm = e.target.closest('[data-vq-src-move]');
|
||||
if (sm) { moveSrc(sm.getAttribute('data-vq-src-move'), parseInt(sm.getAttribute('data-dir'), 10)); return; }
|
||||
var cd = e.target.closest('[data-vq-codec]');
|
||||
if (cd && _videoQuality) { _videoQuality.codec = cd.getAttribute('data-vq-codec'); renderQuality(); saveQuality(true); }
|
||||
});
|
||||
card.addEventListener('change', function (e) {
|
||||
if (!_videoQuality) return;
|
||||
var rt = e.target.closest('[data-vq-res-toggle]');
|
||||
if (rt) { _videoQuality.resolutions[rt.getAttribute('data-vq-res-toggle')].enabled = rt.checked; renderQuality(); saveQuality(true); return; }
|
||||
if (e.target.id === 'vq-prefer-hdr') { _videoQuality.prefer_hdr = e.target.checked; saveQuality(true); return; }
|
||||
if (e.target.id === 'vq-fallback') { _videoQuality.fallback_enabled = e.target.checked; saveQuality(true); return; }
|
||||
if (e.target.id === 'vq-max-size') { _videoQuality.max_size_gb = parseInt(e.target.value, 10) || 0; saveQuality(true); return; }
|
||||
});
|
||||
card.addEventListener('input', function (e) {
|
||||
if (e.target.id === 'vq-max-size' && _videoQuality) {
|
||||
var v = parseInt(e.target.value, 10) || 0;
|
||||
var lab = document.getElementById('vq-size-label');
|
||||
if (lab) lab.textContent = v ? (v + ' GB') : 'No limit';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function saveKeys(silent) {
|
||||
var t = document.getElementById('tmdb-api-key');
|
||||
var v = document.getElementById('tvdb-api-key');
|
||||
|
|
@ -337,6 +458,8 @@
|
|||
load();
|
||||
loadKeys();
|
||||
loadDownloads();
|
||||
loadQuality();
|
||||
wireQuality();
|
||||
}
|
||||
|
||||
function init() {
|
||||
|
|
@ -406,7 +529,7 @@
|
|||
if (!e.target.closest('#save-settings')) return;
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
Promise.all([saveConn(true), save(true), saveKeys(true), savePrefs(true), saveDownloads(true)])
|
||||
Promise.all([saveConn(true), save(true), saveKeys(true), savePrefs(true), saveDownloads(true), saveQuality(true)])
|
||||
.then(function () { toast('Settings saved', 'success'); })
|
||||
.catch(function () { toast('Some settings could not be saved', 'error'); });
|
||||
}, true);
|
||||
|
|
|
|||
|
|
@ -2958,3 +2958,58 @@ body[data-side="video"] #soulsync-toggle { display: none; }
|
|||
.vem-yt-num { display: block; font-size: 28px; font-weight: 900; color: #fff; line-height: 1; }
|
||||
.vem-yt-lbl { display: block; margin-top: 7px; font-size: 11.5px; font-weight: 600; letter-spacing: 0.02em;
|
||||
color: rgba(255,255,255,0.45); }
|
||||
|
||||
/* =========================================================================
|
||||
Video Quality Profile (Settings → Downloads, video side)
|
||||
========================================================================= */
|
||||
.vq-block { margin: 16px 0; }
|
||||
.vq-label { font-size: 12px; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase;
|
||||
color: rgba(255,255,255,0.65); margin-bottom: 9px; display: flex; align-items: center; gap: 10px; }
|
||||
.vq-hint { font-size: 11px; font-weight: 500; text-transform: none; letter-spacing: 0;
|
||||
color: rgba(255,255,255,0.4); }
|
||||
.vq-rows { display: flex; flex-direction: column; gap: 7px; }
|
||||
.vq-row { display: flex; align-items: center; gap: 12px; padding: 9px 13px; border-radius: 11px;
|
||||
background: rgba(255,255,255,0.045); border: 1px solid rgba(255,255,255,0.07);
|
||||
transition: background 0.15s, opacity 0.15s; }
|
||||
.vq-row--off { opacity: 0.5; }
|
||||
.vq-arrows { display: inline-flex; flex-direction: column; gap: 1px; }
|
||||
.vq-arrow { width: 22px; height: 15px; display: grid; place-items: center; cursor: pointer;
|
||||
background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.1); border-radius: 4px;
|
||||
color: rgba(255,255,255,0.6); font-size: 8px; line-height: 1; padding: 0; transition: all 0.12s; }
|
||||
.vq-arrow:hover:not(:disabled) { background: rgba(var(--accent-rgb),0.25); color: #fff; }
|
||||
.vq-arrow:disabled { opacity: 0.25; cursor: default; }
|
||||
.vq-row-name { flex: 1; font-size: 14px; font-weight: 600; color: #fff; }
|
||||
.vq-row-prio { font: 700 11px/1 'JetBrains Mono', ui-monospace, monospace; color: rgba(255,255,255,0.4);
|
||||
min-width: 18px; text-align: center; }
|
||||
/* pill toggle */
|
||||
.vq-toggle { position: relative; display: inline-block; width: 40px; height: 22px; flex: 0 0 40px; cursor: pointer; }
|
||||
.vq-toggle input { position: absolute; opacity: 0; width: 0; height: 0; }
|
||||
.vq-toggle-track { position: absolute; inset: 0; border-radius: 999px; background: rgba(255,255,255,0.13);
|
||||
transition: background 0.18s; }
|
||||
.vq-toggle-track::after { content: ''; position: absolute; top: 2px; left: 2px; width: 18px; height: 18px;
|
||||
border-radius: 50%; background: #fff; transition: transform 0.18s; }
|
||||
.vq-toggle input:checked + .vq-toggle-track { background: rgb(var(--accent-rgb)); }
|
||||
.vq-toggle input:checked + .vq-toggle-track::after { transform: translateX(18px); }
|
||||
/* codec + hdr row */
|
||||
.vq-grid { display: flex; align-items: flex-end; gap: 28px; flex-wrap: wrap; margin: 16px 0; }
|
||||
.vq-seg { display: inline-flex; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.08);
|
||||
border-radius: 10px; padding: 3px; gap: 2px; }
|
||||
.vq-seg button { padding: 7px 14px; border: none; background: none; border-radius: 7px; cursor: pointer;
|
||||
font-size: 12.5px; font-weight: 600; color: rgba(255,255,255,0.6); transition: all 0.15s; }
|
||||
.vq-seg button:hover { color: #fff; }
|
||||
.vq-seg button.active { background: rgb(var(--accent-rgb)); color: #06210f; }
|
||||
/* custom checkbox (matches the rematch-modal feel) */
|
||||
.vq-check { display: inline-flex; align-items: center; gap: 10px; cursor: pointer; user-select: none;
|
||||
color: rgba(255,255,255,0.85); font-size: 13.5px; }
|
||||
.vq-check input { display: none; }
|
||||
.vq-check-box { width: 20px; height: 20px; flex: 0 0 20px; border-radius: 6px;
|
||||
border: 2px solid rgba(255,255,255,0.2); display: grid; place-items: center; transition: all 0.15s; }
|
||||
.vq-check input:checked + .vq-check-box { background: rgb(var(--accent-rgb)); border-color: transparent; }
|
||||
.vq-check input:checked + .vq-check-box::after { content: '✓'; color: #06210f; font-size: 13px; font-weight: 900; }
|
||||
/* size slider */
|
||||
.vq-slider { -webkit-appearance: none; appearance: none; width: 100%; height: 6px; border-radius: 999px;
|
||||
background: rgba(255,255,255,0.12); outline: none; cursor: pointer; }
|
||||
.vq-slider::-webkit-slider-thumb { -webkit-appearance: none; width: 18px; height: 18px; border-radius: 50%;
|
||||
background: rgb(var(--accent-rgb)); cursor: pointer; box-shadow: 0 2px 6px rgba(0,0,0,0.4); }
|
||||
.vq-slider::-moz-range-thumb { width: 18px; height: 18px; border: none; border-radius: 50%;
|
||||
background: rgb(var(--accent-rgb)); cursor: pointer; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue