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
8 changes: 8 additions & 0 deletions app/api/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
from app.core.registry import register_if_capacity as registry_register_if_capacity
from app.core.registry import remove as registry_remove
from app.core.settings import get_max_duration_sec
from app.core.stems_location import is_relocating
from app.pipeline import jobqueue
from app.pipeline.download import InvalidYouTubeURL, validate_youtube_url

Expand Down Expand Up @@ -121,6 +122,13 @@ class JobRequest(BaseModel):
async def create_job(request: Request) -> dict[str, str]:
"""Submit a YouTube URL (JSON body) or upload an audio file (multipart/form-data)
to start a stem-separation job. Returns the new job ID."""
if is_relocating():
# The stems folder just moved. This process still writes to the old one,
# so anything accepted now would be orphaned by the restart.
raise HTTPException(
status_code=409,
detail="Restart StemDeck to finish moving your stems folder before importing",
)
ct = request.headers.get("content-type", "")
if "multipart/form-data" in ct:
return await _create_local_job(request)
Expand Down
7 changes: 7 additions & 0 deletions app/api/playlist.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
from app.core.registry import persist as registry_persist
from app.core.registry import register_if_capacity as registry_register_if_capacity
from app.core.settings import get_max_duration_sec, get_playlist_max_items
from app.core.stems_location import is_relocating
from app.pipeline import jobqueue
from app.pipeline.download import InvalidPlaylistURL, expand_playlist

Expand Down Expand Up @@ -121,6 +122,12 @@ async def create_playlist_jobs(request: Request) -> dict[str, Any]:
except Exception as e:
raise HTTPException(status_code=422, detail=str(e)) from e

if is_relocating():
raise HTTPException(
status_code=409,
detail="Restart StemDeck to finish moving your stems folder before importing",
)

selected = [s for s in payload.stems if s in STEM_NAMES] if payload.stems else list(STEM_NAMES)
if not selected:
selected = list(STEM_NAMES)
Expand Down
47 changes: 46 additions & 1 deletion app/core/config.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
import os
import re
import sys
Expand Down Expand Up @@ -59,9 +60,53 @@ def detect_torch_device() -> str:
# folder.
PORTABLE_DATA_DIR_ENABLED = bool(os.environ.get("STEMDECK_DATA_DIR", "").strip())
DATA_DIR = _env_path("STEMDECK_DATA_DIR", ROOT)


def _stored_jobs_dir() -> Path | None:
"""The stems location the user picked in Settings, if any.

Read straight out of settings.json rather than through app.core.settings,
which imports this module -- and it has to happen here because JOBS_DIR is
bound at import time across the app. Any problem reading it falls through to
the default: a library that quietly moves is far worse than one that ignores
a corrupt preference.
"""
try:
raw = json.loads((DATA_DIR / "settings.json").read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
value = raw.get("jobs_dir") if isinstance(raw, dict) else None
if not isinstance(value, str) or not value.strip():
return None
path = Path(value).expanduser()
# Only honour a folder that is actually there. It existed when the user
# picked it, so a missing one means the disk holding it is not mounted --
# and ensure_runtime_dirs would otherwise happily mkdir it, which on macOS
# creates a real directory at the mount point on the boot disk and can stop
# the drive mounting under its own name later. Falling back to the default
# leaves the library findable again as soon as the disk is plugged in.
return path if path.is_dir() else None


# Where extracted stems live. Precedence, most explicit first:
# 1. STEMDECK_JOBS_DIR -- a deployment that pinned it (Docker, Unraid,
# CI, tests). Wins over everything: a mounted
# volume is not the user's to relocate.
# 2. settings.json -- what the user chose in Settings (#354).
# Desktop only; the endpoint that writes it
# refuses to run anywhere else.
# 3. STEMDECK_DEFAULT_JOBS_DIR -- the desktop shell's default
# (~/Documents/StemDeck/jobs). Passed as a
# default rather than a pin, so 2 can win.
# 4. DATA_DIR/jobs -- portable default
# 5. <repo>/jobs -- plain dev checkout
JOBS_DIR = _env_path(
"STEMDECK_JOBS_DIR",
(DATA_DIR / "jobs") if PORTABLE_DATA_DIR_ENABLED else (ROOT / "jobs"),
_stored_jobs_dir()
or _env_path(
"STEMDECK_DEFAULT_JOBS_DIR",
(DATA_DIR / "jobs") if PORTABLE_DATA_DIR_ENABLED else (ROOT / "jobs"),
),
)
CACHE_DIR = _env_path("STEMDECK_CACHE_DIR", DATA_DIR / "cache")
DOWNLOADS_DIR = _env_path("STEMDECK_DOWNLOADS_DIR", DATA_DIR / "downloads")
Expand Down
25 changes: 25 additions & 0 deletions app/core/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

- `allow_network` — whether StemDeck answers requests from other devices.
- `max_duration_sec` — longest track accepted for processing.
- `jobs_dir` — where extracted stems are written (needs a restart).
- `playlist_max_items` — how many tracks one playlist import may queue.
- `video_max_height` — max video resolution for MP4 export / YouTube pulls.
- `export_sample_rate` — sample rate for exported mixes/regions (WAV/FLAC/MP3).
Expand All @@ -21,6 +22,7 @@
import logging
import os
import threading
from pathlib import Path

from app.core.config import (
DATA_DIR,
Expand Down Expand Up @@ -125,6 +127,29 @@ def set_max_duration_sec(value: int) -> int:
return clamped


# ── jobs_dir ──
# Where extracted stems are written. Read by config.py at import time (straight
# from settings.json, to avoid importing this module from there), so a change
# only takes effect on the next start -- which is also when the move that goes
# with it has finished. Absent means "wherever the default puts it".
def get_jobs_dir() -> str | None:
with _LOCK:
value = _ensure().get("jobs_dir")
return value if isinstance(value, str) and value.strip() else None


def set_jobs_dir(value: str | None) -> str | None:
with _LOCK:
if value is None or not str(value).strip():
_ensure().pop("jobs_dir", None)
_save()
return None
resolved = str(Path(str(value)).expanduser().resolve())
_ensure()["jobs_dir"] = resolved
_save()
return resolved


# ── playlist_max_items ──
# How many tracks one playlist import may queue. A waiting link costs a registry
# record, so the ceiling is generous; the real reason to keep this adjustable is
Expand Down
179 changes: 179 additions & 0 deletions app/core/stems_location.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
"""Moving the stems library to a different folder (#354).

Documents is a sensible default -- the library is the user's work, it belongs
somewhere visible that survives a reinstall -- but on macOS and Windows it is
also the folder most people have syncing to iCloud or OneDrive, and a stem
library is tens of gigabytes nobody agreed to spend on cloud storage.

Changing the location is not just a preference write. The registry lives inside
the stems folder, so leaving the existing library behind would make the app come
back empty with the files stranded. The move is the feature; the setting is
bookkeeping.
"""

from __future__ import annotations

import logging
import os
import shutil
from dataclasses import dataclass
from pathlib import Path

logger = logging.getLogger("stemdeck.stems_location")


class StemsLocationError(ValueError):
"""Rejected before anything on disk was touched."""


# JOBS_DIR is bound at import time across the app, so a move leaves this process
# still writing to the folder the files just left. An import accepted in that
# window lands in the old folder and disappears from the library on the next
# start -- the stems are on disk, in a directory nothing looks at any more.
#
# Set before the move begins (which also closes the race against an import
# arriving mid-move) and never cleared on success: the restart is what clears
# it, and the restart is the point.
_relocating = False


def is_relocating() -> bool:
return _relocating


def begin_relocation() -> None:
global _relocating
_relocating = True


def abandon_relocation() -> None:
"""The move failed, so the library is still where this process thinks it is
and the app stays usable."""
global _relocating
_relocating = False


@dataclass(frozen=True)
class MoveResult:
source: str
target: str
moved_entries: int
bytes_moved: int
same_filesystem: bool


def directory_size(path: Path) -> int:
"""Best-effort total size. Used to tell the user what they are about to move,
so a failure to stat one file must not sink the whole answer."""
total = 0
for root, _dirs, files in os.walk(path, onerror=lambda _e: None):
for name in files:
try:
total += (Path(root) / name).stat().st_size
except OSError:
continue
return total


def validate_target(target: Path, current: Path) -> Path:
"""Check a candidate location before anything is moved into it.

Deliberately strict. This path becomes the root of a directory the app
creates and deletes inside, and a careless answer here -- the user's home
directory, or a folder already full of their own files -- turns a settings
change into data loss.
"""
if not str(target).strip():
raise StemsLocationError("Pick a folder to store stems in.")

target = Path(target).expanduser()
if not target.is_absolute():
raise StemsLocationError("The stems folder must be an absolute path.")
target = target.resolve()
current = current.expanduser().resolve()

if target == current:
raise StemsLocationError("Stems are already stored there.")
if current in target.parents:
# Moving a directory inside itself would recurse forever.
raise StemsLocationError("Pick a folder outside the current stems folder.")

if target.exists():
if not target.is_dir():
raise StemsLocationError("That path is a file, not a folder.")
# An existing folder is fine only if it is empty or already ours: merging
# a stem library into someone's Desktop is not a recoverable mistake.
entries = list(target.iterdir())
ours = {"registry.json", "failed"}
if entries and not all(e.name in ours or _looks_like_job_dir(e) for e in entries):
raise StemsLocationError("Pick an empty folder, or one StemDeck already uses.")
else:
try:
target.mkdir(parents=True, exist_ok=True)
except OSError as e:
raise StemsLocationError(f"Cannot create that folder: {e.strerror or e}") from e

if not os.access(target, os.W_OK):
raise StemsLocationError("That folder is not writable.")
return target


def _looks_like_job_dir(entry: Path) -> bool:
from app.core.config import JOB_ID_RE

return entry.is_dir() and bool(JOB_ID_RE.match(entry.name))


def move_library(current: Path, target: Path) -> MoveResult:
"""Move every stem directory from `current` into `target`.

Entry by entry rather than moving the folder itself: the folder may be one
the user picked in a native dialog and expects to keep, and on desktop the
parent (~/Documents/StemDeck) also holds the library index, which stays put.

On a failure partway, whatever has already moved stays moved and the error
names the entry that stopped it. Rolling back a half-finished multi-gigabyte
copy has its own failure modes, and the app can see a library in either
place -- it is the setting, written only on success, that decides which one
it reads.
"""
current = current.expanduser().resolve()
target = target.expanduser().resolve()
target.mkdir(parents=True, exist_ok=True)

same_fs = _same_filesystem(current, target)
moved = 0
moved_bytes = 0

if not current.exists():
return MoveResult(str(current), str(target), 0, 0, same_fs)

for entry in sorted(current.iterdir()):
destination = target / entry.name
if destination.exists():
# A previous, interrupted attempt already brought this one over.
logger.info("stems move: %s already at the target, skipping", entry.name)
continue
size = directory_size(entry) if entry.is_dir() else entry.stat().st_size
try:
shutil.move(str(entry), str(destination))
except OSError as e:
raise StemsLocationError(
f"Could not move {entry.name}: {e.strerror or e}. "
f"Anything already moved is in the new folder."
) from e
moved += 1
moved_bytes += size

return MoveResult(str(current), str(target), moved, moved_bytes, same_fs)


def _same_filesystem(a: Path, b: Path) -> bool:
"""Whether the move is a rename or a copy. Only used to set expectations in
the UI, so an unanswerable question is answered with "assume the slow one"."""
try:
probe = a if a.exists() else a.parent
other = b if b.exists() else b.parent
return probe.stat().st_dev == other.stat().st_dev
except OSError:
return False
Loading