"""The ONE place muvid decides where its data lives.
Every muvid part that persists anything answers *where* from here: the footage genre's
stateful projects (``muvid/footage/workspace.py``), the visualizer's per-user render
buckets (``muvid/mcp/workspace.py``), and — new with this module — the local, single-user
project folders the part-3 pipeline and the part-4 subgenres work in.
Default root ``~/.local/share/muvid``, overridable with ``$MUVID_DATA_HOME``. Per the
app-data-lifecycle rule this is the user-data dir and **never** the app/deploy tree: a
deploy's ``rsync --delete`` treats anything it did not build as drift and erases it.
**Why this module exists at all, rather than a third copy of a four-line function.**
``data_home`` and :func:`safe_component` were duplicated verbatim in the two workspace
modules, and the part-3 surfaces had *no* copy — ``root`` is a required positional on
every part-3 CLI verb **except ``serve``**, and on
:class:`muvid.project.MusicVideoProject`, so nothing computed a default and the only
written-down answer lived in prose: ``.claude/skills/muvid/SKILL.md`` and the ``README``
both said ``~/muvid/<song-stem>``. That was followed literally, which put 36 MB of a real
project — two generated song takes, three rendered videos, the poem sources — in
``~/muvid/il-pleut``: an app-named directory under ``$HOME`` that no deploy, backup or
tool owns. A default stated in prose drifts from the code and cannot be tested; one
computed by a function can be both. So the fix is not a better sentence, it is
:func:`project_root` plus a ``muvid project-root`` verb that the skill and the README
*call* instead of a path they *state*.
**``serve`` is the exception, and it is the one that actually wrote.** ``serve(root=".")``
defaults to the cwd, which is a fine convention for "the project I am standing in" — but
the UI has no create-a-project action and used to ``mkdir -p`` its way into whatever
directory it was pointed at, so ``muvid serve`` in any folder plus one save in the browser
scaffolded ``script/`` and ``.muvid/`` there. The fix is in ``muvid/ui/app.py``, which now
refuses a root that is not a project, rather than here: the default is *right*, and it was
the absent precondition that was wrong. Do not restate the old "required on every verb"
claim — it was in this docstring, in ``facade`` and in ``CLAUDE.md``, and it was false in
exactly the place it mattered.
A person choosing to keep their own working files in ``~/Downloads`` is a different thing
and not this module's business — the rule is about where a *tool* writes by default.
"""
from __future__ import annotations
import os
from pathlib import Path
#: Env var overriding the muvid data root. ONE knob for the root, never one per kind —
#: N env vars where one belongs is the anti-pattern the app-data-lifecycle rule names.
DATA_HOME_ENV_VAR = "MUVID_DATA_HOME"
#: The ``{kind}`` subfolder local (single-user, non-connector) project folders live in.
#: Data never goes at the root itself: leaving the root open is what let the footage
#: genre add ``music_video/`` and the visualizer ``visualizer/`` without a migration.
#:
#: **Deliberately not the part-3 genre slug issue #4 will pick** (recommended there:
#: ``music-video-ai``), and deliberately kept despite reading like
#: ``music_video/projects/{email}/`` one level down. Two reasons. The connector layout is
#: per-email and per-``project_id`` — ``{root}/{slug}/projects/{email}/{id}`` — while this
#: is a local, single-user, per-*name* folder the CLI creates; they are different shapes,
#: so #4 landing does not imply these folders move, and naming this after a slug that is
#: still only *recommended* would pre-commit to it. The apparent collision is
#: disambiguated by depth: ``projects/`` at depth 1 is local, ``projects/`` under a genre
#: slug is the connector's. If #4 decides otherwise, this is a one-word change plus a
#: migration of whatever exists by then — which is why it is a named constant.
PROJECTS_KIND = "projects"
[docs]
def data_home() -> Path:
"""The muvid data root: ``$MUVID_DATA_HOME`` or ``~/.local/share/muvid``.
The override is ``expanduser``-ed and **required to be absolute**, because this path
is now printed by ``muvid project-root`` for a caller to interpolate. Without the
first, ``MUVID_DATA_HOME=~/store`` yields a *literal* ``~`` directory, so the printed
string and what :class:`muvid.project.MusicVideoProject` resolves it to are two
different places. Without the second, a relative override roots the whole data store
at the cwd — which, run from an app directory, is the very failure this module exists
to prevent, reached through its own knob. A misconfigured root is refused loudly
rather than read as something plausible.
"""
override = os.environ.get(DATA_HOME_ENV_VAR)
if not override:
return Path.home() / ".local" / "share" / "muvid"
path = Path(override).expanduser()
if not path.is_absolute():
raise ValueError(
f"{DATA_HOME_ENV_VAR} must be an absolute path, got {override!r}. "
"A relative data root resolves against the current directory, so it would "
"follow the process around and can land inside an app directory."
)
return path
[docs]
def safe_component(value: str, *, label: str) -> str:
"""A single, traversal-safe path component (no ``/``, ``\\``, ``..``, or empties).
Every caller-supplied name reaching the data root goes through this. On the footage
MCP surface the names arrive from a remote OAuth caller, so there it is a trust
boundary rather than a tidiness check; :func:`project_root` is CLI-only and not on
that surface. Behaviour is unchanged from the two copies this replaced — notably
there is no length cap and no unicode normalisation, both pre-existing.
"""
v = (value or "").strip()
if not v or v in (".", "..") or "/" in v or "\\" in v or "\x00" in v:
raise ValueError(f"invalid {label}: {value!r}")
return v
[docs]
def project_root(name: str, *, kind: str = PROJECTS_KIND) -> Path:
"""Where a local muvid project named ``name`` belongs: ``{data_home}/{kind}/{name}``.
Returns the path without creating it — ``muvid init`` (or a subgenre's own renderer)
owns creation, and a resolver that made directories could not be used to *ask* where
a project would go.
``kind`` is the seam for a surface that wants its own subtree rather than sharing
``projects/``; the connector-facing genres already use their own (``music_video/``,
``visualizer/``) through the workspace classes that own those layouts.
"""
return (
data_home()
/ safe_component(kind, label="kind")
/ safe_component(name, label="project name")
)