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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ dist/
*.egg
.eggs/

# Version is git-derived (hatch-vcs); these are generated artifacts, never committed.
app/_version.py
static/version.json

# Desktop dependencies and build outputs
desktop/node_modules/
desktop/src-tauri/target/
Expand Down
16 changes: 16 additions & 0 deletions .woodpecker/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ steps:
image: ghcr.io/astral-sh/uv:python3.12-bookworm-slim
environment:
UV_LINK_MODE: copy
# Version is git-derived (hatch-vcs). CI's clone is shallow/tagless, which
# makes setuptools_scm raise, so pin a placeholder for the build — CI only
# lints/tests and never publishes. #169
SETUPTOOLS_SCM_PRETEND_VERSION: "0.0.0"
commands:
- uv sync --frozen --all-extras
- uv run ruff check app/ tests/
Expand All @@ -28,6 +32,10 @@ steps:
image: ghcr.io/astral-sh/uv:python3.12-bookworm-slim
environment:
UV_LINK_MODE: copy
# Version is git-derived (hatch-vcs). CI's clone is shallow/tagless, which
# makes setuptools_scm raise, so pin a placeholder for the build — CI only
# lints/tests and never publishes. #169
SETUPTOOLS_SCM_PRETEND_VERSION: "0.0.0"
commands:
- apt-get update && apt-get install -y --no-install-recommends ffmpeg
- uv sync --frozen --all-extras
Expand All @@ -42,6 +50,10 @@ steps:
image: ghcr.io/astral-sh/uv:python3.12-bookworm-slim
environment:
UV_LINK_MODE: copy
# Version is git-derived (hatch-vcs). CI's clone is shallow/tagless, which
# makes setuptools_scm raise, so pin a placeholder for the build — CI only
# lints/tests and never publishes. #169
SETUPTOOLS_SCM_PRETEND_VERSION: "0.0.0"
commands:
- uv tool install bandit
- uv tool run bandit -r app/ -ll # fail on medium+ severity
Expand All @@ -50,6 +62,10 @@ steps:
image: ghcr.io/astral-sh/uv:python3.12-bookworm-slim
environment:
UV_LINK_MODE: copy
# Version is git-derived (hatch-vcs). CI's clone is shallow/tagless, which
# makes setuptools_scm raise, so pin a placeholder for the build — CI only
# lints/tests and never publishes. #169
SETUPTOOLS_SCM_PRETEND_VERSION: "0.0.0"
commands:
- uv tool install pip-audit
- uv pip compile pyproject.toml -o /tmp/requirements.txt
Expand Down
18 changes: 9 additions & 9 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import asyncio
import ctypes
import json
import logging
import os
import signal
Expand Down Expand Up @@ -69,17 +68,18 @@ def _process_exists(pid: int) -> bool:


def app_version() -> str:
version_file = STATIC_DIR / "version.json"
try:
data = json.loads(version_file.read_text(encoding="utf-8"))
value = str(data.get("version", "")).strip().removeprefix("v")
if value:
return value
except (OSError, json.JSONDecodeError):
pass
# Version is git-tag-derived via hatch-vcs (#169). Prefer installed package
# metadata (set at install/build from the tag); fall back to the generated
# app/_version.py for non-installed runs, then a dev placeholder.
try:
return package_version("stemdeck")
except PackageNotFoundError:
pass
try:
from app._version import __version__

return str(__version__)
except Exception:
return "0.0.0-dev"


Expand Down
15 changes: 13 additions & 2 deletions app/pipeline/download.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,17 @@ def _is_retriable(exc: Exception) -> bool:
"youtu.be",
)
)
_SOUNDCLOUD_HOSTS = frozenset(("soundcloud.com", "www.soundcloud.com", "on.soundcloud.com"))
# Note: on.soundcloud.com (the share shortener) is intentionally excluded — it
# redirects to arbitrary targets, which is an SSRF vector once handed to yt-dlp
# (#173). Users must paste the full soundcloud.com URL.
_SOUNDCLOUD_HOSTS = frozenset(("soundcloud.com", "www.soundcloud.com"))
_ALLOWED_HOSTS = _YOUTUBE_HOSTS | _SOUNDCLOUD_HOSTS

# Restrict yt-dlp to the extractors we actually support. Crucially this excludes
# the "generic" extractor, so even a URL that slips past host validation cannot
# make yt-dlp fetch an arbitrary host/redirect target (#173).
_ALLOWED_EXTRACTORS = ["youtube", "soundcloud"]


class InvalidYouTubeURL(ValueError):
"""Raised at the API boundary for URLs we won't hand to yt-dlp."""
Expand Down Expand Up @@ -147,7 +155,9 @@ def download(job: Job, url: str, job_dir: Path) -> Path:

# Fetch metadata first (no download) so we can reject videos that are
# too long before wasting bandwidth and disk.
with YoutubeDL({"quiet": True, "noplaylist": True}) as ydl:
with YoutubeDL(
{"quiet": True, "noplaylist": True, "allowed_extractors": _ALLOWED_EXTRACTORS}
) as ydl:
meta = ydl.extract_info(url, download=False) or {}
duration = meta.get("duration") or 0
if duration > MAX_DURATION_SEC:
Expand Down Expand Up @@ -176,6 +186,7 @@ def hook(d: dict) -> None:
"quiet": True,
"noprogress": True,
"noplaylist": True,
"allowed_extractors": _ALLOWED_EXTRACTORS,
"progress_hooks": [hook],
}
info: dict = {}
Expand Down
6 changes: 5 additions & 1 deletion build/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,11 @@ WORKDIR /app
COPY pyproject.toml ./
COPY app/ ./app/
COPY static/ ./static/
RUN pip install . --timeout 300
# The version is git-derived (hatch-vcs), but the build context has no .git, so
# pin it explicitly: `docker build --build-arg VERSION=0.7.0-alpha.4 ...`
# (defaults to 0.0.0 for ad-hoc local builds). #169
ARG VERSION=0.0.0
RUN SETUPTOOLS_SCM_PRETEND_VERSION="${VERSION#v}" pip install . --timeout 300

# ─── Stage 2: runner ─────────────────────────────────────────────
FROM python:3.12-slim AS runner
Expand Down
2 changes: 1 addition & 1 deletion desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "stemdeck-desktop",
"version": "0.6.0-alpha.2",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
Expand Down
2 changes: 1 addition & 1 deletion desktop/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion desktop/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
[package]
name = "stemdeck"
version = "0.6.0-alpha.2"
# Placeholder — stamped from the git tag at build time (Woodpecker / make-app.sh). #169
version = "0.0.0"
description = "StemDeck desktop launcher"
authors = ["StemDeck contributors"]
edition = "2021"
Expand Down
2 changes: 1 addition & 1 deletion desktop/src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "StemDeck",
"version": "0.6.0-alpha.2",
"version": "0.0.0",
"identifier": "app.stemdeck.desktop",
"build": {
"frontendDist": "../ui",
Expand Down
22 changes: 19 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
[project]
name = "stemdeck"
version = "0.6.0-alpha.2"
# Version is derived from the git tag by hatch-vcs (see [tool.hatch.version]),
# so it is never hand-edited — `git tag vX.Y.Z` is the single source of truth.
# Builds between tags report a dev version (e.g. 0.7.0a5.dev3+g<sha>). (#169)
dynamic = ["version"]
description = "Paste a YouTube URL, get audio stems split into a DAW-style player."
requires-python = ">=3.10,<3.14"
dependencies = [
Expand Down Expand Up @@ -52,16 +55,29 @@ dev = [
]

[build-system]
requires = ["hatchling"]
requires = ["hatchling", "hatch-vcs"]
build-backend = "hatchling.build"

# Derive the version from git tags. Writes app/_version.py at build time as a
# runtime fallback for environments without installed package metadata.
[tool.hatch.version]
source = "vcs"
# Used when the tag can't be reached (e.g. CI's shallow/tagless clone) so the
# build never hard-fails. Real builds derive from the tag or the pinned
# SETUPTOOLS_SCM_PRETEND_VERSION. (#169)
fallback-version = "0.0.0"

[tool.hatch.build.hooks.vcs]
version-file = "app/_version.py"

[tool.hatch.build.targets.wheel]
packages = ["app"]

[tool.ruff]
target-version = "py310"
line-length = 100
exclude = ["jobs", ".run", "static/vendor"]
# app/_version.py is generated by hatch-vcs at build/sync time — don't lint it.
exclude = ["jobs", ".run", "static/vendor", "app/_version.py"]

[tool.ruff.lint]
select = ["E", "F", "W", "I", "UP", "B", "SIM"]
Expand Down
6 changes: 4 additions & 2 deletions scripts/macos/make-app.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@
set -euo pipefail

ARCH="${ARCH:-arm64}"
VERSION="${VERSION:-LOCAL_DEV_TEST}"
VERSION="${VERSION#v}"
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
# Default the version to the current git tag so local builds match the tag with
# no VERSION passed; falls back to a dev label outside a tagged checkout. (#169)
VERSION="${VERSION:-$(git -C "$REPO_ROOT" describe --tags --always 2>/dev/null || echo 0.0.0-dev)}"
VERSION="${VERSION#v}"
BUILD_DIR="${REPO_ROOT}/.build"

if [[ "$(uname)" != "Darwin" ]]; then
Expand Down
5 changes: 4 additions & 1 deletion scripts/macos/make-runtime-pack.sh
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,10 @@ find "$PYTHON_DIR/lib" -name "EXTERNALLY-MANAGED" -delete
echo "==> Installing packages into bundled Python"
# --system is required because $PYTHON_DIR is not a venv (it's a full Python install).
uv pip install --system --python "$PYTHON_DIR/bin/python" pip setuptools wheel
uv pip install --system --python "$PYTHON_DIR/bin/python" "$REPO_ROOT"
# The project version is git-derived (hatch-vcs). Pin it explicitly from $VERSION
# so the install doesn't depend on git tags being present in the build checkout (#169).
SETUPTOOLS_SCM_PRETEND_VERSION="${VERSION#v}" \
uv pip install --system --python "$PYTHON_DIR/bin/python" "$REPO_ROOT"

echo "==> Verifying stdlib and imports"
PYTHON_DIR="$PYTHON_DIR" PYTHONHOME="$PYTHON_DIR" "$PYTHON_DIR/bin/python" - <<'PY'
Expand Down
5 changes: 5 additions & 0 deletions scripts/windows/make-portable.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,11 @@ if (Get-Command "py" -ErrorAction SilentlyContinue) {

& $PythonExe -m pip install --upgrade pip

# The project version is git-derived (hatch-vcs). Pin it from $PackageVersion so
# the install doesn't depend on git tags in the build checkout (#169).
if ($PackageVersion) {
$env:SETUPTOOLS_SCM_PRETEND_VERSION = ($PackageVersion -replace '^v', '')
}
& $PythonExe -m pip install "$Root"

if ($CpuOnly) {
Expand Down
2 changes: 2 additions & 0 deletions static/css/daw.css
Original file line number Diff line number Diff line change
Expand Up @@ -726,6 +726,8 @@ input, textarea { font-family: inherit; }
.folder-editor-name:focus { border-color: rgba(244,183,64,0.5); box-shadow: 0 0 0 2px rgba(244,183,64,0.12); }
.folder-editor-colors { display: flex; align-items: center; gap: 9px; }
.folder-editor-colors .folder-color-dot { width: 22px; height: 22px; }
.folder-editor-msg { color: var(--danger); font-size: 11px; margin-top: 8px; }
.folder-editor-msg:empty { display: none; }
.folder-editor-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 14px; }
.folder-editor-actions button { min-height: 30px; border-radius: 7px; font-family: var(--font-mono); font-size: 11px; cursor: pointer; padding: 0 11px; }
.folder-editor-cancel { border: 1px solid var(--border); background: rgba(21,31,39,0.52); color: var(--muted); }
Expand Down
35 changes: 32 additions & 3 deletions static/js/catalog.js
Original file line number Diff line number Diff line change
Expand Up @@ -601,6 +601,13 @@ function closeFolderEditor() {
folderEditor = null;
}

// Folder names accept letters (any language), digits, spaces, and a small safe
// punctuation set — markup/symbols are rejected so names like the XSS probe or
// "±!@£$%^&*" can't be created (#170 follow-up).
const FOLDER_NAME_RE = /^[\p{L}\p{M}\p{N} '’&().,_-]+$/u;
const MAX_FOLDER_NAME_LEN = 100;
const isValidFolderName = (s) => FOLDER_NAME_RE.test(s);

function openFolderEditor(folderId) {
const folder = folders.find((f) => f.id === folderId);
if (!folder || folder.id === TRASH_ID) return;
Expand All @@ -621,14 +628,15 @@ function openFolderEditor(folderId) {
</div>
<label class="folder-editor-field">
<span>Name</span>
<input class="folder-editor-name" type="text" maxlength="48" autocomplete="off" spellcheck="false" />
<input class="folder-editor-name" type="text" maxlength="100" autocomplete="off" spellcheck="false" />
</label>
<div class="folder-editor-field">
<span>Color</span>
<div class="folder-editor-colors" role="group" aria-label="Folder color">
${folderColorButtonsHtml(selectedColor)}
</div>
</div>
<div class="folder-editor-msg" role="alert" aria-live="polite"></div>
<div class="folder-editor-actions">
<button class="folder-editor-cancel" type="button">Cancel</button>
<button class="folder-editor-save" type="submit">Save</button>
Expand Down Expand Up @@ -659,9 +667,27 @@ function openFolderEditor(folderId) {
refreshDots();
});
}
const msgEl = overlay.querySelector(".folder-editor-msg");
input.addEventListener("input", () => { msgEl.textContent = ""; });
form.addEventListener("submit", (e) => {
e.preventDefault();
folder.name = input.value.trim() || folder.name;
const name = input.value.trim();
if (!name) {
msgEl.textContent = "Enter a folder name.";
input.focus();
return;
}
if (name.length > MAX_FOLDER_NAME_LEN) {
msgEl.textContent = `Folder name is too long (max ${MAX_FOLDER_NAME_LEN}).`;
input.focus();
return;
}
if (!isValidFolderName(name)) {
msgEl.textContent = "Use letters, numbers, spaces, or - _ ' & ( ) . ,";
input.focus();
return; // don't save or close until the name is valid
}
folder.name = name;
folder.color = selectedColor;
saveState();
closeFolderEditor();
Expand Down Expand Up @@ -985,7 +1011,7 @@ function renderFolder(folder) {
</span>`}
<svg class="f-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="18 15 12 9 6 15"></polyline></svg>
${folderIcon}
<span class="f-name">${folder.name}</span>
<span class="f-name">${esc(folder.name)}</span>
<span class="f-count">${folder.items.length}</span>
${isTrash ? "" : `
<button class="f-subfolder" type="button" aria-label="New subfolder" title="New subfolder">
Expand Down Expand Up @@ -1473,6 +1499,9 @@ async function checkForUpdate() {
const data = await res.json();
const latest = normalizeVersion(data.tag_name);
if (!latest || latest === currentVersion) return;
// Dev/source builds report a git-derived version (e.g. 0.7.0a5.dev3+g…) that
// is *ahead* of the last release — don't nag them with an "update" banner.
if (/\bdev\b|\+/.test(currentVersion)) return;

let dismissed = null;
try { dismissed = localStorage.getItem(DISMISSED_UPDATE_KEY); } catch (e) { console.warn(e); }
Expand Down
1 change: 0 additions & 1 deletion static/version.json

This file was deleted.

4 changes: 3 additions & 1 deletion tests/test_url_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ def test_accepts_youtube_urls(url: str, expected: str) -> None:
("https://example.com/foo", "unsupported host"),
("https://www.youtube.com/playlist?list=PLfoo", "video ID"),
("https://evil.com/watch?v=dQw4w9WgXcQ", "unsupported host"),
# The on.soundcloud.com share shortener is rejected — it redirects to
# arbitrary targets, an SSRF vector once handed to yt-dlp (#173).
("https://on.soundcloud.com/abc123", "unsupported host"),
],
)
def test_rejects_bad_urls(url: str, reason_substring: str) -> None:
Expand All @@ -57,7 +60,6 @@ def test_rejects_bad_urls(url: str, reason_substring: str) -> None:
[
"https://soundcloud.com/artist/track",
"https://www.soundcloud.com/artist/track",
"https://on.soundcloud.com/abc123",
" https://soundcloud.com/artist/track ",
],
)
Expand Down
1 change: 0 additions & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.