Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions app/core/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@
# Clamp bounds. Max track length is capped at 20 min (the product ceiling).
_DURATION_MIN, _DURATION_MAX = 60, 1200 # 1 min .. 20 min
_HEIGHT_MIN, _HEIGHT_MAX = 144, 2160
_PORT_MIN, _PORT_MAX = 1024, 65535
DEFAULT_PORT = 8080


def _default_allow_network() -> bool:
Expand Down Expand Up @@ -116,3 +118,21 @@ def set_video_max_height(value: int) -> int:
_ensure()["video_max_height"] = clamped
_save()
return clamped


# ── port ──
# The preferred port the server binds on launch. The desktop launcher reads this
# (default 8080) before spawning the backend; a self-hosted server's --port wins.
# Changing it needs a restart — the socket is bound at startup.
def get_port() -> int:
with _LOCK:
v = _num(_ensure().get("port"))
return max(_PORT_MIN, min(_PORT_MAX, v)) if v is not None else DEFAULT_PORT


def set_port(value: int) -> int:
with _LOCK:
clamped = max(_PORT_MIN, min(_PORT_MAX, int(value)))
_ensure()["port"] = clamped
_save()
return clamped
4 changes: 4 additions & 0 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,11 @@
from app.core.settings import (
get_allow_network,
get_max_duration_sec,
get_port,
get_video_max_height,
set_allow_network,
set_max_duration_sec,
set_port,
set_video_max_height,
)
from app.pipeline.collect import sweep_old_jobs
Expand Down Expand Up @@ -223,6 +225,7 @@ def _settings_payload() -> dict[str, object]:
"allow_network": get_allow_network(),
"max_duration_sec": get_max_duration_sec(),
"video_max_height": get_video_max_height(),
"port": get_port(),
}


Expand All @@ -249,6 +252,7 @@ async def update_settings(request: Request) -> dict[str, object]:
for key, setter in (
("max_duration_sec", set_max_duration_sec),
("video_max_height", set_video_max_height),
("port", set_port),
):
if key in body:
try:
Expand Down
31 changes: 30 additions & 1 deletion desktop/src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -555,7 +555,7 @@ fn start_backend(
"Python runtime not found. Expected python/ or .venv/ under StemDeck.".to_string()
})?;
patch_pyvenv_cfg(&python);
let (port, port_guard) = free_port()?;
let (port, port_guard) = reserve_port(configured_port())?;
let url = format!("http://127.0.0.1:{port}");
let log_path = data_dir.join("logs").join("backend.log");
let (stdout, stderr) = prepare_backend_stdio(&log_path).unwrap_or_else(|_| {
Expand Down Expand Up @@ -1799,6 +1799,35 @@ fn free_port() -> Result<(u16, TcpListener), String> {
Ok((port, listener))
}

/// The user's preferred port (Settings -> port), read from the backend's
/// settings.json before launch. Defaults to 8080.
fn configured_port() -> u16 {
const DEFAULT_PORT: u16 = 8080;
let Ok(data_dir) = local_data_dir() else {
return DEFAULT_PORT;
};
let Ok(text) = fs::read_to_string(data_dir.join("settings.json")) else {
return DEFAULT_PORT;
};
let Ok(json) = serde_json::from_str::<serde_json::Value>(&text) else {
return DEFAULT_PORT;
};
match json.get("port").and_then(serde_json::Value::as_u64) {
Some(p) if (1024..=65535).contains(&p) => p as u16,
_ => DEFAULT_PORT,
}
}

/// Reserve the user's preferred port; fall back to any free port if it's taken,
/// so a port conflict can never block startup.
fn reserve_port(desired: u16) -> Result<(u16, TcpListener), String> {
if let Ok(listener) = TcpListener::bind(("127.0.0.1", desired)) {
let port = listener.local_addr().map_err(|e| e.to_string())?.port();
return Ok((port, listener));
}
free_port()
}

fn wait_for_health(port: u16, timeout: Duration, log_path: &Path) -> Result<(), String> {
let deadline = Instant::now() + timeout;
let mut interval = Duration::from_millis(250);
Expand Down
13 changes: 8 additions & 5 deletions static/css/daw.css
Original file line number Diff line number Diff line change
Expand Up @@ -782,11 +782,14 @@ input, textarea { font-family: inherit; }
.settings-pane.hidden { display: none; }
.settings-pane[data-pane="advanced"] .library-editor-table-wrap { flex: none; max-height: 240px; margin-bottom: 2px; }
.settings-empty { color: var(--muted); font-size: 12px; text-align: center; padding: 28px 10px; }
.settings-num { display: flex; align-items: center; gap: 7px; flex-shrink: 0; }
.settings-num input { width: 62px; background: rgba(10,17,24,0.6); border: 1px solid var(--border-strong); border-radius: 7px; color: var(--fg); font-family: var(--font-mono); font-size: 12px; padding: 6px 8px; text-align: right; }
.settings-num-unit { color: var(--muted); font-size: 11px; }
.settings-select { flex-shrink: 0; background: rgba(10,17,24,0.6); border: 1px solid var(--border-strong); border-radius: 7px; color: var(--fg); font-family: var(--font-mono); font-size: 12px; padding: 6px 9px; cursor: pointer; }
.settings-num input:focus, .settings-select:focus { outline: none; border-color: rgba(244,183,64,0.5); }
.settings-foot { display: flex; justify-content: flex-end; margin-top: 12px; padding-top: 11px; border-top: 1px solid var(--border); }
.settings-done { min-height: 32px; border-radius: 7px; border: 1px solid rgba(244,183,64,0.35); background: rgba(244,183,64,0.16); color: var(--accent); font-family: var(--font-mono); font-size: 12px; font-weight: 600; padding: 0 22px; cursor: pointer; }
.settings-done:hover { background: rgba(244,183,64,0.24); }
/* Right-aligned form controls share a fixed width so they line up down the column. */
.settings-num-input, .settings-select { flex-shrink: 0; width: 84px; background: rgba(10,17,24,0.6); border: 1px solid var(--border-strong); border-radius: 7px; color: var(--fg); font-family: var(--font-mono); font-size: 12px; padding: 6px 9px; }
.settings-num-input { text-align: right; }
.settings-select { cursor: pointer; }
.settings-num-input:focus, .settings-select:focus { outline: none; border-color: rgba(244,183,64,0.5); }

/* Settings → network access section */
.settings-section { margin-bottom: 12px; }
Expand Down
36 changes: 30 additions & 6 deletions static/js/catalog.js
Original file line number Diff line number Diff line change
Expand Up @@ -1848,13 +1848,23 @@ function networkSettingsHtml() {
async function wireGeneralSettings(overlay) {
const durInput = overlay.querySelector(".set-max-duration");
const heightSel = overlay.querySelector(".set-video-height");
if (!durInput && !heightSel) return;
const portInput = overlay.querySelector(".set-port");
if (!durInput && !heightSel && !portInput) return;

const apply = (d) => {
if (durInput && d.max_duration_sec) durInput.value = String(Math.round(d.max_duration_sec / 60));
if (heightSel && d.video_max_height) heightSel.value = String(d.video_max_height);
if (portInput && d.port) portInput.value = String(d.port);
};

// Keep the text inputs digit-only as the user types (maxlength caps the rest).
const digitsOnly = (input) => input?.addEventListener("input", () => {
const cleaned = input.value.replace(/\D/g, "");
if (cleaned !== input.value) input.value = cleaned;
});
digitsOnly(durInput);
digitsOnly(portInput);

try {
const r = await fetch("/api/settings", { cache: "no-store" });
if (r.ok) apply(await r.json());
Expand All @@ -1878,6 +1888,10 @@ async function wireGeneralSettings(overlay) {
heightSel?.addEventListener("change", () => {
post({ video_max_height: parseInt(heightSel.value, 10) });
});
portInput?.addEventListener("change", () => {
const port = Math.max(1024, Math.min(65535, parseInt(portInput.value, 10) || 8080));
post({ port });
});
}

async function wireNetworkSetting(overlay) {
Expand Down Expand Up @@ -1961,12 +1975,9 @@ function openLibraryEditor() {
<div class="settings-row">
<div class="settings-row-text">
<div class="settings-row-title">Max track length</div>
<div class="settings-row-desc">Longest track accepted for processing.</div>
</div>
<div class="settings-num">
<input type="number" class="set-max-duration" min="1" max="20" step="1" inputmode="numeric" />
<span class="settings-num-unit">min</span>
<div class="settings-row-desc">Longest track accepted for processing, in minutes (max 20).</div>
</div>
<input type="text" class="settings-num-input set-max-duration" inputmode="numeric" maxlength="2" aria-label="Max track length in minutes" />
</div>
</div>
<div class="settings-section">
Expand All @@ -1986,6 +1997,15 @@ function openLibraryEditor() {
</div>
<div class="settings-pane hidden" data-pane="advanced">
${networkSettingsHtml()}
<div class="settings-section">
<div class="settings-row">
<div class="settings-row-text">
<div class="settings-row-title">Port</div>
<div class="settings-row-desc">Port StemDeck runs on. Restart to apply.</div>
</div>
<input type="text" class="settings-num-input set-port" inputmode="numeric" maxlength="5" aria-label="Port" />
</div>
</div>
<div class="settings-subhead">Out of sync tracks</div>
<div class="library-editor-table-wrap">
<table class="library-editor-table">
Expand All @@ -1998,6 +2018,9 @@ function openLibraryEditor() {
<button class="library-editor-sync" type="button">Resync out of sync tracks</button>
</div>
</div>
<div class="settings-foot">
<button class="settings-done" type="button">Done</button>
</div>
</div>
`;

Expand All @@ -2014,6 +2037,7 @@ function openLibraryEditor() {
overlay.addEventListener("mousedown", (e) => { if (e.target === overlay) closeLibraryEditor(); });
// (status summary is filled in after the overlay is in the DOM, below)
overlay.querySelector(".library-editor-close")?.addEventListener("click", closeLibraryEditor);
overlay.querySelector(".settings-done")?.addEventListener("click", closeLibraryEditor);
overlay.querySelector(".library-editor-sync")?.addEventListener("click", () => resyncLibrary());
// Escape closes from anywhere (the overlay isn't focused, so listen on document).
libraryEditorOnKey = (e) => { if (e.code === "Escape") closeLibraryEditor(); };
Expand Down
1 change: 1 addition & 0 deletions static/js/shared/jobs.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,5 +71,6 @@ export function jobToCard(state) {
createdAt: state.created_at || 0,
initial: coverInitial(title),
gradient: coverGradient(id || title),
thumb: typeof state.thumbnail === "string" ? state.thumbnail : "",
};
}
45 changes: 40 additions & 5 deletions static/mobile/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,24 @@ function esc(s) {
return String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
}

// Cover/thumbnail art. Uses the real YouTube/SoundCloud thumbnail when present
// (layered over the gradient as a fallback if it fails to load); otherwise the
// gradient + initial. The URL is constrained to a clean https URL so it can't
// break out of the CSS url().
function safeThumb(url) {
return typeof url === "string" && /^https:\/\/[^"'()\\\s]+$/.test(url) ? url : "";
}
function artStyle(card) {
const g = (card && card.gradient) || DEFAULT_GRADIENT;
const t = card && safeThumb(card.thumb);
return t
? `background-image:url('${t}'), ${g};background-size:cover;background-position:center;`
: `background:${g};`;
}
function artLabel(card) {
return card && safeThumb(card.thumb) ? "" : esc((card && card.initial) || "♪");
}

// ─── Mixer / engine helpers ───
function lanes() {
return state.current?.detail?.lanes || [];
Expand Down Expand Up @@ -161,13 +179,29 @@ function extractAnalysis(d, laneList) {
return { stats, presence };
}

// Repaint the main waveform's played (yellow) vs. remaining bars as playback
// advances. Only touches the DOM when the played-bar count actually changes
// (≤ N times over the whole track), not every animation frame.
let _lastPlayedBar = -1;
function paintWaveProgress() {
const bars = app.querySelectorAll(".wave-bars > i");
if (!bars.length) return;
const played = Math.round(state.progress * bars.length);
if (played === _lastPlayedBar) return;
for (let i = 0; i < bars.length; i++) {
bars[i].style.background = i <= played ? "#f5b417" : "#34343c";
}
_lastPlayedBar = played;
}

function onEngineTime(t) {
const dur = curDuration() || 1;
state.progress = Math.max(0, Math.min(1, t / dur));
const head = app.querySelector(".playhead");
const cur = app.querySelector(".wave-times .cur");
if (head) head.style.left = state.progress * 100 + "%";
if (cur) cur.textContent = fmt(t);
paintWaveProgress();
}

// Load a track: fetch detail, build lanes, spin up the Web Audio engine.
Expand Down Expand Up @@ -386,7 +420,6 @@ function analysisBody() {

function mixerScreen() {
const c = state.current || { title: "No track selected", sub: "Pick one from your Library", initial: "♪", gradient: DEFAULT_GRADIENT, stemCount: 0 };
const bg = c.gradient || DEFAULT_GRADIENT;
const sourceTag = c.sub || "—";
const stemTag = c.stemCount ? `${c.stemCount} stems` : "";
const dur = curDuration();
Expand All @@ -403,7 +436,7 @@ function mixerScreen() {
<button class="icon-btn">${ICON.dots}</button>
</div>
<div class="cover-wrap">
<div class="cover" style="background:${bg}"><span>${esc(c.initial)}</span></div>
<div class="cover" style="${artStyle(c)}"><span>${artLabel(c)}</span></div>
<div class="track-title">${esc(c.title)}</div>
<div class="track-sub">${esc(c.sub)}</div>
<div class="tags"><span class="tag">${esc(sourceTag)}</span>${stemTag ? `<span class="tag">${stemTag}</span>` : ""}</div>
Expand Down Expand Up @@ -443,7 +476,7 @@ function libraryBody() {
${state.tracks.map((t) => `<div class="track-wrap${state.swipedTrackId === t.id ? " swiped" : ""}">
<button class="track-delete" data-action="delete" data-id="${esc(t.id)}">Delete</button>
<div class="track" data-action="open" data-id="${esc(t.id)}">
<div class="track-art" style="background:${t.gradient}">${esc(t.initial)}</div>
<div class="track-art" style="${artStyle(t)}">${artLabel(t)}</div>
<div class="track-info"><div class="t">${esc(t.title)}</div><div class="s">${esc(t.sub)}</div><div class="m">${esc(t.meta)}</div></div>
<div class="track-dot ${t.status}"></div>
<button class="track-load" data-action="open" data-id="${esc(t.id)}">Load</button>
Expand Down Expand Up @@ -606,9 +639,8 @@ function followExtraction(jobId) {
function miniPlayer() {
if (state.tab === "mixer" || !state.current) return "";
const c = state.current;
const bg = c.gradient || DEFAULT_GRADIENT;
return `<div class="mini" data-action="tab" data-tab="mixer">
<div class="mini-art" style="background:${bg}">${esc(c.initial)}</div>
<div class="mini-art" style="${artStyle(c)}">${artLabel(c)}</div>
<div class="mini-info"><div class="t">${esc(c.title)}</div><div class="s">${esc(c.sub)}</div></div>
<button class="mini-play" data-action="play-mini">${state.playing ? ICON.pause(17, "#1a1206") : ICON.play(18, "#1a1206")}</button>
</div>`;
Expand All @@ -624,6 +656,7 @@ function render() {
if (state.tab === "library") screen = libraryScreen();
else if (state.tab === "extract") screen = extractScreen();
app.innerHTML = screen + miniPlayer() + tabBar();
_lastPlayedBar = -1; // bars were just rebuilt; force a repaint on next tick
wireFaders();
wireSwipe();
}
Expand Down Expand Up @@ -753,6 +786,8 @@ function wireFaders() {
if (head) head.style.left = frac * 100 + "%";
const cur = app.querySelector(".wave-times .cur");
if (cur) cur.textContent = fmt(frac * curDuration());
state.progress = frac;
paintWaveProgress();
seekToFraction(frac);
};
seek(e.clientX);
Expand Down
8 changes: 8 additions & 0 deletions tests/test_network_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,14 @@ def test_runtime_settings_round_trip_and_clamp():
assert settings_mod.set_video_max_height(99999) == 2160 # ceil


def test_port_default_and_clamp():
assert settings_mod.get_port() == 8080 # default
with TestClient(app) as c:
assert c.post("/api/settings", json={"port": 9000}).json()["port"] == 9000
assert settings_mod.set_port(80) == 1024 # floor (privileged ports rejected)
assert settings_mod.set_port(70000) == 65535 # ceil


def test_settings_reject_non_integer():
with TestClient(app) as c:
assert c.post("/api/settings", json={"max_duration_sec": "abc"}).status_code == 422
Expand Down