diff --git a/.gitignore b/.gitignore
deleted file mode 100644
index 7f4dc64..0000000
--- a/.gitignore
+++ /dev/null
@@ -1 +0,0 @@
-version.txt
diff --git a/.last-branch b/.last-branch
deleted file mode 100644
index ba2906d..0000000
--- a/.last-branch
+++ /dev/null
@@ -1 +0,0 @@
-main
diff --git a/README.md b/README.md
index 8697282..b34b749 100644
--- a/README.md
+++ b/README.md
@@ -14,12 +14,17 @@ A simple website that can play MP3 audio while an iPhone screen is locked, as lo
- Media Session API support (play/pause/next/previous on lock screen)
- Playlist ends at the last track (no automatic loop)
- `Back to start` button resets to track 1 without autoplay
+- Server-side audio download via URL (avoids browser timeout on long files)
+- Downloaded tracks stored in `/mp3/downloads/`, played individually (no auto-advance)
+- Track titles editable via Settings page (titles stored separately from filenames)
+- Rename and delete downloaded tracks via Settings
+- Two-page UI: player + settings (hash routing)
- PWA manifest + service worker
-- Containerized with Nginx
+- Containerized with Nginx + Python API (supervisord)
## Repository layout
-- `containers/sleep-meditation/`: Docker image + web app files
+- `containers/sleep-meditation/`: Docker image, Nginx config, Python API, web app
- `stack/stack.yml`: Portainer/Compose stack
- `stack/sleep-meditation.env`: environment variables
- `docs/TECHNICAL.md`: technical details and iPhone behavior
@@ -44,3 +49,7 @@ A simple website that can play MP3 audio while an iPhone screen is locked, as lo
}
]
```
+
+## Download a track
+
+Open **Settings** in the app, enter a direct audio URL and a title, then press **Download**. The file is downloaded server-side and saved to `mp3/downloads/`. It appears in the Downloads section on the main page.
diff --git a/build-and-push.sh b/build-and-push.sh
index 026b428..6b4be0e 100755
--- a/build-and-push.sh
+++ b/build-and-push.sh
@@ -1,84 +1,236 @@
#!/usr/bin/env bash
set -euo pipefail
+# ============================================================================
+# build-and-push.sh
+#
+# Purpose:
+# - Build & push Docker images for each service under ./containers/*
+# - Two modes:
+# t (test) = only push :dev
+# r (release) = push :, :dev, :latest
+# version is read from the top of changelog.md
+#
+# No git operations: committing and tagging is done manually.
+#
+# Usage:
+# ./build-and-push.sh [mode]
+# - mode = t -> test build, push :dev only
+# - mode = r -> release build, version taken from changelog.md
+# - omitted -> prompt (default: t)
+#
+# Requirements:
+# - docs/changelog.md (relative to repo root), with the most recent release
+# at the top as:
+# ## vX.Y.Z — YYYY-MM-DD
+# (the version is parsed from the first such line)
+# - One Dockerfile per service under ./containers//Dockerfile
+# ============================================================================
+
DOCKER_REGISTRY="gitea.oskamp.info"
DOCKER_NAMESPACE="ivooskamp"
-VERSION_FILE="version.txt"
-START_VERSION="v0.1.0"
+
+CHANGELOG_FILE="docs/changelog.md"
CONTAINERS_DIR="containers"
-BUMP="${1:-t}"
-if [[ "$BUMP" != "1" && "$BUMP" != "2" && "$BUMP" != "3" && "$BUMP" != "t" ]]; then
- echo "[ERROR] Unknown bump type '$BUMP' (use 1, 2, 3, or t)."
- exit 1
+# --- Input: prompt if missing ------------------------------------------------
+MODE="${1:-}"
+if [[ -z "${MODE}" ]]; then
+ echo "Select build type: [t] test build (push :dev only), [r] release build (default: t)"
+ read -r MODE
+ MODE="${MODE:-t}"
fi
-read_version() {
- if [[ -f "$VERSION_FILE" ]]; then
- tr -d ' \t\n\r' < "$VERSION_FILE"
- else
- echo "$START_VERSION"
+case "$MODE" in
+ t|test) MODE="t" ;;
+ r|release) MODE="r" ;;
+ *)
+ echo "[ERROR] Unknown mode '$MODE' (use 't' for test or 'r' for release)."
+ exit 1
+ ;;
+esac
+
+# --- Helpers -----------------------------------------------------------------
+check_docker_ready() {
+ if ! docker info >/dev/null 2>&1; then
+ echo "[ERROR] Docker daemon not reachable. Is Docker running and do you have permission to use it?"
+ exit 1
fi
}
-bump_version() {
- local cur="$1"
- local kind="$2"
- local core="${cur#v}"
- IFS='.' read -r MA MI PA <<< "$core"
- case "$kind" in
- 1) PA=$((PA + 1));;
- 2) MI=$((MI + 1)); PA=0;;
- 3) MA=$((MA + 1)); MI=0; PA=0;;
- esac
- echo "v${MA}.${MI}.${PA}"
+ensure_registry_login() {
+ local cfg="${HOME}/.docker/config.json"
+ if [[ ! -f "$cfg" ]]; then
+ echo "[ERROR] Docker config not found at $cfg. Please login: docker login ${DOCKER_REGISTRY}"
+ exit 1
+ fi
+ if ! grep -q "\"${DOCKER_REGISTRY}\"" "$cfg"; then
+ echo "[ERROR] No registry auth found for ${DOCKER_REGISTRY}. Please run: docker login ${DOCKER_REGISTRY}"
+ exit 1
+ fi
}
-if ! docker info >/dev/null 2>&1; then
- echo "[ERROR] Docker daemon not reachable."
- exit 1
-fi
+validate_repo_component() {
+ local comp="$1"
+ if [[ ! "$comp" =~ ^[a-z0-9]+([._-][a-z0-9]+)*$ ]]; then
+ echo "[ERROR] Invalid repository component '$comp'."
+ echo " Must match: ^[a-z0-9]+([._-][a-z0-9]+)*$ (lowercase, digits, ., _, - as separators)."
+ return 1
+ fi
+}
+validate_tag() {
+ local tag="$1"
+ local len="${#tag}"
+ if (( len < 1 || len > 128 )); then
+ echo "[ERROR] Invalid tag length ($len). Must be between 1 and 128 characters."
+ return 1
+ fi
+ if [[ ! "$tag" =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]*$ ]]; then
+ echo "[ERROR] Invalid tag '$tag'. Allowed: [A-Za-z0-9_.-], must start with alphanumeric or underscore."
+ return 1
+ fi
+}
+
+# Parse the first "## vX.Y.Z ..." heading from changelog.md.
+# Accepts: ## v1.0.3 — 2026-04-24
+# ## v1.0.3 - 2026-04-24
+# ## v1.0.3
+read_version_from_changelog() {
+ if [[ ! -f "$CHANGELOG_FILE" ]]; then
+ echo "[ERROR] $CHANGELOG_FILE not found in $(pwd)." >&2
+ exit 1
+ fi
+ local line
+ # Match lines starting with "## v.."
+ line="$(grep -m1 -E '^##[[:space:]]+v[0-9]+\.[0-9]+\.[0-9]+' "$CHANGELOG_FILE" || true)"
+ if [[ -z "$line" ]]; then
+ echo "[ERROR] No release heading found in $CHANGELOG_FILE (expected e.g. '## v1.0.3 — 2026-04-24' near the top)." >&2
+ exit 1
+ fi
+ # Extract the vX.Y.Z token
+ local version
+ version="$(echo "$line" | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+' | head -n1)"
+ if [[ -z "$version" ]]; then
+ echo "[ERROR] Could not parse version from line: $line" >&2
+ exit 1
+ fi
+ echo "$version"
+}
+
+# --- Preflight ---------------------------------------------------------------
if [[ ! -d "$CONTAINERS_DIR" ]]; then
- echo "[ERROR] '$CONTAINERS_DIR' directory missing."
+ echo "[ERROR] '$CONTAINERS_DIR' directory missing. Expected ./${CONTAINERS_DIR}// with a Dockerfile."
exit 1
fi
-CURRENT_VERSION="$(read_version)"
-NEW_VERSION="$CURRENT_VERSION"
-RELEASE=false
+check_docker_ready
+ensure_registry_login
+validate_repo_component "$DOCKER_NAMESPACE"
-if [[ "$BUMP" != "t" ]]; then
- NEW_VERSION="$(bump_version "$CURRENT_VERSION" "$BUMP")"
- RELEASE=true
- echo "$NEW_VERSION" > "$VERSION_FILE"
- git add "$VERSION_FILE"
- git commit -m "Release $NEW_VERSION"
- git tag -a "$NEW_VERSION" -m "Release $NEW_VERSION"
- git push --follow-tags
+# Informational: show branch and HEAD if this happens to be a git repo.
+BRANCH_INFO=""
+HEAD_INFO=""
+if [[ -d ".git" ]]; then
+ BRANCH_INFO="$(git branch --show-current 2>/dev/null || echo unknown)"
+ HEAD_INFO="$(git rev-parse --short HEAD 2>/dev/null || echo unknown)"
+ echo "[INFO] Repo: $(pwd)"
+ echo "[INFO] Current branch: $BRANCH_INFO"
+ echo "[INFO] HEAD (sha): $HEAD_INFO"
+else
+ echo "[INFO] Repo: $(pwd) (not a git checkout)"
fi
-for svc_path in "$CONTAINERS_DIR"/*; do
+# --- Determine version (release only) ----------------------------------------
+VERSION=""
+if [[ "$MODE" == "r" ]]; then
+ VERSION="$(read_version_from_changelog)"
+ echo "[INFO] Release version (from $CHANGELOG_FILE): $VERSION"
+ validate_tag "$VERSION"
+ validate_tag "latest"
+
+ # Ask for confirmation so you never accidentally re-push an old version or a wrong one.
+ read -r -p "Proceed building & pushing as ${VERSION}? [y/N] " CONFIRM
+ CONFIRM="${CONFIRM:-N}"
+ if [[ ! "$CONFIRM" =~ ^[Yy]$ ]]; then
+ echo "[INFO] Aborted by user."
+ exit 0
+ fi
+else
+ echo "[INFO] Test build: only :dev will be pushed."
+fi
+validate_tag "dev"
+
+# --- Build & push per service ------------------------------------------------
+shopt -s nullglob
+services=( "$CONTAINERS_DIR"/* )
+if [[ ${#services[@]} -eq 0 ]]; then
+ echo "[ERROR] No services found under $CONTAINERS_DIR"
+ exit 1
+fi
+
+BUILT_IMAGES=()
+
+for svc_path in "${services[@]}"; do
[[ -d "$svc_path" ]] || continue
svc="$(basename "$svc_path")"
dockerfile="$svc_path/Dockerfile"
+ validate_repo_component "$svc"
+
if [[ ! -f "$dockerfile" ]]; then
- echo "[WARNING] Skipping $svc: Dockerfile missing"
+ echo "[WARNING] Skipping '${svc}': Dockerfile not found in ${svc_path}"
continue
fi
IMAGE_BASE="${DOCKER_REGISTRY}/${DOCKER_NAMESPACE}/${svc}"
- if $RELEASE; then
- docker build -t "${IMAGE_BASE}:${NEW_VERSION}" -t "${IMAGE_BASE}:dev" "$svc_path"
- docker push "${IMAGE_BASE}:${NEW_VERSION}"
+ if [[ "$MODE" == "r" ]]; then
+ echo "============================================================"
+ echo "[INFO] Building ${svc} -> tags: ${VERSION}, dev, latest"
+ echo "============================================================"
+ docker build \
+ -t "${IMAGE_BASE}:${VERSION}" \
+ -t "${IMAGE_BASE}:dev" \
+ -t "${IMAGE_BASE}:latest" \
+ "$svc_path"
+
+ docker push "${IMAGE_BASE}:${VERSION}"
docker push "${IMAGE_BASE}:dev"
+ docker push "${IMAGE_BASE}:latest"
+
+ BUILT_IMAGES+=("${IMAGE_BASE}:${VERSION}" "${IMAGE_BASE}:dev" "${IMAGE_BASE}:latest")
else
+ echo "============================================================"
+ echo "[INFO] Test build ${svc} -> tag: dev"
+ echo "============================================================"
docker build -t "${IMAGE_BASE}:dev" "$svc_path"
docker push "${IMAGE_BASE}:dev"
+ BUILT_IMAGES+=("${IMAGE_BASE}:dev")
fi
-
done
-echo "[DONE] Build/push complete"
+# --- Summary -----------------------------------------------------------------
+echo ""
+echo "============================================================"
+if [[ "$MODE" == "r" ]]; then
+ echo "[SUMMARY] Release build & push complete: $VERSION"
+else
+ echo "[SUMMARY] Test build & push complete (:dev only)"
+fi
+if [[ -n "$BRANCH_INFO" ]]; then
+ echo "[INFO] Branch: $BRANCH_INFO HEAD: $HEAD_INFO"
+fi
+echo "[INFO] Images pushed:"
+for img in "${BUILT_IMAGES[@]}"; do
+ echo " - $img"
+done
+echo "============================================================"
+echo ""
+echo "[REMINDER] No git operations were performed. If this was a release,"
+echo " commit and tag manually, e.g.:"
+if [[ "$MODE" == "r" ]]; then
+ echo " git add -A && git commit -m \"Release ${VERSION}\""
+ echo " git tag -a ${VERSION} -m \"Release ${VERSION}\""
+ echo " git push && git push --tags"
+fi
diff --git a/containers/sleep-meditation-downloader/Dockerfile b/containers/sleep-meditation-downloader/Dockerfile
new file mode 100644
index 0000000..d1a2bfc
--- /dev/null
+++ b/containers/sleep-meditation-downloader/Dockerfile
@@ -0,0 +1,14 @@
+FROM python:3.12-alpine
+
+RUN apk add --no-cache ffmpeg \
+ && pip install --no-cache-dir flask requests yt-dlp
+
+COPY api.py /app/api.py
+COPY site/ /app/site/
+
+EXPOSE 8001
+
+HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
+ CMD wget -q -O /dev/null http://127.0.0.1:8001/api/downloads || exit 1
+
+CMD ["python", "/app/api.py"]
diff --git a/containers/sleep-meditation-downloader/api.py b/containers/sleep-meditation-downloader/api.py
new file mode 100644
index 0000000..6c7074d
--- /dev/null
+++ b/containers/sleep-meditation-downloader/api.py
@@ -0,0 +1,413 @@
+import os
+import re
+import json
+import shutil
+import subprocess
+import threading
+from pathlib import Path
+from urllib.parse import urlparse
+from flask import Flask, request, jsonify, send_from_directory
+import requests as http_requests
+
+YOUTUBE_HOSTS = (
+ "youtube.com", "www.youtube.com", "m.youtube.com",
+ "music.youtube.com", "youtu.be",
+)
+AUDIO_CONTENT_TYPES = ("audio/", "application/ogg")
+
+
+def is_youtube_url(url: str) -> bool:
+ try:
+ host = (urlparse(url).hostname or "").lower()
+ except Exception:
+ return False
+ return host in YOUTUBE_HOSTS
+
+
+def fetch_youtube_title(url: str) -> str | None:
+ if not shutil.which("yt-dlp"):
+ return None
+ try:
+ proc = subprocess.run(
+ ["yt-dlp", "--get-title", "--no-playlist", "--skip-download",
+ "--no-warnings", url],
+ capture_output=True, text=True, timeout=30,
+ )
+ except Exception:
+ return None
+ if proc.returncode != 0:
+ return None
+ title = (proc.stdout or "").strip().splitlines()
+ return title[0] if title else None
+
+SITE_DIR = Path(__file__).parent / "site"
+
+app = Flask(__name__)
+
+MP3_DIR = Path(os.environ.get("MP3_DIR", "/mp3"))
+DOWNLOADS_DIR = MP3_DIR / "downloads"
+AUDIO_EXT = {".mp3", ".m4a", ".aac", ".wav", ".ogg"}
+
+DOWNLOADS_DIR.mkdir(parents=True, exist_ok=True)
+
+_downloads = {} # track_id -> {"status": ..., "error": ...}
+_TITLES_FILE = DOWNLOADS_DIR / "titles.json"
+_SKIP_FILE = DOWNLOADS_DIR / "skip.json"
+_SOURCES_FILE = DOWNLOADS_DIR / "sources.json"
+
+
+def safe_name(title: str) -> str:
+ name = re.sub(r"[^\w\s\-]", "", title).strip()
+ name = re.sub(r"\s+", " ", name)
+ return name[:100] or "track"
+
+
+def _load_titles() -> dict:
+ if _TITLES_FILE.exists():
+ try:
+ with open(_TITLES_FILE) as f:
+ return json.load(f)
+ except Exception:
+ pass
+ return {}
+
+
+def _save_titles(titles: dict) -> None:
+ with open(_TITLES_FILE, "w") as f:
+ json.dump(titles, f, indent=2, ensure_ascii=False)
+
+
+def _load_skip() -> dict:
+ if _SKIP_FILE.exists():
+ try:
+ with open(_SKIP_FILE) as f:
+ return json.load(f)
+ except Exception:
+ pass
+ return {}
+
+
+def _save_skip(skip: dict) -> None:
+ with open(_SKIP_FILE, "w") as f:
+ json.dump(skip, f, indent=2)
+
+
+def _load_sources() -> dict:
+ if _SOURCES_FILE.exists():
+ try:
+ with open(_SOURCES_FILE) as f:
+ return json.load(f)
+ except Exception:
+ pass
+ return {}
+
+
+def _save_sources(sources: dict) -> None:
+ with open(_SOURCES_FILE, "w") as f:
+ json.dump(sources, f, indent=2, ensure_ascii=False)
+
+
+def strip_mp3_prefix(src: str) -> str:
+ return src.lstrip("/").removeprefix("mp3/").lstrip("/")
+
+
+# ── Downloads ────────────────────────────────────────────────────────────────
+
+@app.route("/api/downloads")
+def list_downloads():
+ titles = _load_titles()
+ skip = _load_skip()
+ sources = _load_sources()
+ files = []
+ for f in sorted(DOWNLOADS_DIR.iterdir()):
+ if f.is_file() and f.suffix.lower() in AUDIO_EXT:
+ files.append({
+ "title": titles.get(f.name, f.stem),
+ "filename": f.name,
+ "src": f"/mp3/downloads/{f.name}",
+ "skip": skip.get(f.name, 0),
+ "source": sources.get(f.name, ""),
+ })
+ return jsonify(files)
+
+
+@app.route("/api/download", methods=["POST"])
+def start_download():
+ data = request.get_json(silent=True) or {}
+ url = (data.get("url") or "").strip()
+ title = (data.get("title") or "").strip()
+
+ if not url:
+ return jsonify({"error": "URL is required"}), 400
+ if not url.startswith(("http://", "https://")):
+ return jsonify({"error": "Only http/https URLs are supported"}), 400
+
+ if not title and is_youtube_url(url):
+ fetched = fetch_youtube_title(url)
+ if fetched:
+ title = fetched
+ if not title:
+ title = "track"
+
+ filename = safe_name(title) + ".mp3"
+ dest = DOWNLOADS_DIR / filename
+ track_id = filename
+
+ if _downloads.get(track_id, {}).get("status") == "downloading":
+ return jsonify({"status": "downloading", "track_id": track_id})
+
+ _downloads[track_id] = {"status": "downloading", "progress": 0, "phase": "starting"}
+
+ titles = _load_titles()
+ titles[filename] = title
+ _save_titles(titles)
+
+ sources = _load_sources()
+ sources[filename] = url
+ _save_sources(sources)
+
+ def fail(msg: str):
+ _downloads[track_id] = {"status": "error", "error": msg}
+ dest.unlink(missing_ok=True)
+ t = _load_titles()
+ t.pop(filename, None)
+ _save_titles(t)
+ s = _load_sources()
+ s.pop(filename, None)
+ _save_sources(s)
+
+ def download_youtube():
+ try:
+ from yt_dlp import YoutubeDL
+ except Exception:
+ fail("YouTube download not available: yt-dlp is not installed")
+ return
+
+ out_template = str(DOWNLOADS_DIR / (Path(filename).stem + ".%(ext)s"))
+
+ def hook(d):
+ st = d.get("status")
+ if st == "downloading":
+ total = d.get("total_bytes") or d.get("total_bytes_estimate")
+ done = d.get("downloaded_bytes") or 0
+ if total:
+ pct = max(0, min(99, int(done * 100 / total)))
+ _downloads[track_id]["progress"] = pct
+ _downloads[track_id]["phase"] = "downloading"
+ elif st == "finished":
+ _downloads[track_id]["progress"] = 99
+ _downloads[track_id]["phase"] = "converting"
+
+ opts = {
+ "format": "bestaudio/best",
+ "outtmpl": out_template,
+ "noplaylist": True,
+ "quiet": True,
+ "no_warnings": True,
+ "progress_hooks": [hook],
+ "postprocessors": [{
+ "key": "FFmpegExtractAudio",
+ "preferredcodec": "mp3",
+ "preferredquality": "0",
+ }],
+ }
+ try:
+ with YoutubeDL(opts) as ydl:
+ ydl.download([url])
+ except Exception as exc:
+ tail = str(exc).strip().splitlines()
+ fail(f"YouTube download failed: {tail[-1] if tail else exc}")
+ return
+ if not dest.is_file():
+ fail("YouTube download failed: output file missing")
+ return
+ _downloads[track_id]["progress"] = 100
+ _downloads[track_id]["status"] = "done"
+
+ def download_direct():
+ try:
+ resp = http_requests.get(
+ url, stream=True, timeout=600,
+ headers={"User-Agent": "Mozilla/5.0"},
+ )
+ resp.raise_for_status()
+ except Exception as exc:
+ fail(f"Download failed: {exc}")
+ return
+
+ ctype = (resp.headers.get("Content-Type") or "").split(";")[0].strip().lower()
+ if ctype and not any(ctype.startswith(p) for p in AUDIO_CONTENT_TYPES):
+ resp.close()
+ fail(
+ f"URL is not a supported audio file (Content-Type: {ctype}). "
+ "For YouTube/streaming sites, paste the page URL directly — "
+ "other sites are only supported if they link straight to an audio file."
+ )
+ return
+
+ total = 0
+ try:
+ total = int(resp.headers.get("Content-Length") or 0)
+ except ValueError:
+ total = 0
+ try:
+ done = 0
+ with open(dest, "wb") as f:
+ for chunk in resp.iter_content(chunk_size=65536):
+ f.write(chunk)
+ done += len(chunk)
+ if total:
+ pct = max(0, min(99, int(done * 100 / total)))
+ _downloads[track_id]["progress"] = pct
+ _downloads[track_id]["phase"] = "downloading"
+ except Exception as exc:
+ fail(f"Download failed: {exc}")
+ return
+ _downloads[track_id]["progress"] = 100
+ _downloads[track_id]["status"] = "done"
+
+ def do_download():
+ if is_youtube_url(url):
+ download_youtube()
+ else:
+ download_direct()
+
+ threading.Thread(target=do_download, daemon=True).start()
+ return jsonify({"status": "downloading", "track_id": track_id})
+
+
+@app.route("/api/download/status/")
+def download_status(track_id):
+ return jsonify(_downloads.get(track_id, {"status": "unknown"}))
+
+
+@app.route("/api/downloads/", methods=["DELETE"])
+def delete_download(filename):
+ if not re.match(r"^[\w\s\-\.]+$", filename) or ".." in filename:
+ return jsonify({"error": "Invalid filename"}), 400
+ dest = DOWNLOADS_DIR / filename
+ if dest.is_file() and dest.suffix.lower() in AUDIO_EXT:
+ dest.unlink()
+ titles = _load_titles()
+ titles.pop(filename, None)
+ _save_titles(titles)
+ skip = _load_skip()
+ skip.pop(filename, None)
+ _save_skip(skip)
+ sources = _load_sources()
+ sources.pop(filename, None)
+ _save_sources(sources)
+ return jsonify({"status": "deleted"})
+ return jsonify({"error": "Not found"}), 404
+
+
+@app.route("/api/downloads//skip", methods=["POST"])
+def set_skip(filename):
+ if not re.match(r"^[\w\s\-\.]+$", filename) or ".." in filename:
+ return jsonify({"error": "Invalid filename"}), 400
+ if not (DOWNLOADS_DIR / filename).is_file():
+ return jsonify({"error": "Not found"}), 404
+ data = request.get_json(silent=True) or {}
+ seconds = data.get("seconds", 0)
+ try:
+ seconds = max(0, int(seconds))
+ except (TypeError, ValueError):
+ return jsonify({"error": "seconds must be a number"}), 400
+ skip = _load_skip()
+ if seconds == 0:
+ skip.pop(filename, None)
+ else:
+ skip[filename] = seconds
+ _save_skip(skip)
+ return jsonify({"status": "saved", "skip": seconds})
+
+
+@app.route("/api/downloads//rename", methods=["POST"])
+def rename_download(filename):
+ if not re.match(r"^[\w\s\-\.]+$", filename) or ".." in filename:
+ return jsonify({"error": "Invalid filename"}), 400
+ data = request.get_json(silent=True) or {}
+ new_title = (data.get("title") or "").strip()
+ if not new_title:
+ return jsonify({"error": "Title is required"}), 400
+
+ if not (DOWNLOADS_DIR / filename).is_file():
+ return jsonify({"error": "Not found"}), 404
+
+ titles = _load_titles()
+ titles[filename] = new_title
+ _save_titles(titles)
+ return jsonify({"status": "renamed"})
+
+
+# ── Playlist track rename ─────────────────────────────────────────────────────
+
+def _load_playlist():
+ path = MP3_DIR / "playlist.json"
+ if path.exists():
+ try:
+ with open(path) as f:
+ return json.load(f)
+ except Exception:
+ pass
+ tracks = []
+ for f in sorted(MP3_DIR.iterdir()):
+ if f.is_file() and f.suffix.lower() in AUDIO_EXT:
+ title = f.stem.replace("_", " ").replace("-", " ").strip()
+ tracks.append({"src": f.name, "title": title})
+ return tracks
+
+
+def _save_playlist(tracks):
+ with open(MP3_DIR / "playlist.json", "w") as f:
+ json.dump(tracks, f, indent=2)
+
+
+@app.route("/api/tracks/rename", methods=["POST"])
+def rename_track():
+ data = request.get_json(silent=True) or {}
+ src = (data.get("src") or "").strip()
+ new_title = (data.get("title") or "").strip()
+
+ if not src or not new_title:
+ return jsonify({"error": "src and title are required"}), 400
+
+ bare = strip_mp3_prefix(src)
+ tracks = _load_playlist()
+
+ for track in tracks:
+ if strip_mp3_prefix(track.get("src", "")) == bare:
+ track["title"] = new_title
+ _save_playlist(tracks)
+ return jsonify({"status": "renamed"})
+
+ tracks.append({"src": bare, "title": new_title})
+ _save_playlist(tracks)
+ return jsonify({"status": "renamed"})
+
+
+# ── Playlist tracks (read) ────────────────────────────────────────────────────
+
+@app.route("/api/tracks")
+def list_tracks():
+ tracks = _load_playlist()
+ return jsonify([
+ {"src": t.get("src", ""), "title": t.get("title", "")}
+ for t in tracks
+ ])
+
+
+# ── UI ────────────────────────────────────────────────────────────────────────
+
+@app.route("/")
+def ui_index():
+ return send_from_directory(SITE_DIR, "index.html")
+
+
+@app.route("/")
+def ui_static(filename):
+ return send_from_directory(SITE_DIR, filename)
+
+
+if __name__ == "__main__":
+ app.run(host="0.0.0.0", port=8001, debug=False)
diff --git a/containers/sleep-meditation-downloader/site/app.js b/containers/sleep-meditation-downloader/site/app.js
new file mode 100644
index 0000000..2c59b07
--- /dev/null
+++ b/containers/sleep-meditation-downloader/site/app.js
@@ -0,0 +1,231 @@
+// ── DOM refs ──────────────────────────────────────────────────────────────────
+const dlUrl = document.querySelector("#dl-url");
+const dlTitle = document.querySelector("#dl-title");
+const dlBtn = document.querySelector("#dl-btn");
+const dlStatus = document.querySelector("#dl-status");
+const dlProgress = document.querySelector("#dl-progress");
+
+const renameSelect = document.querySelector("#rename-select");
+const renameInput = document.querySelector("#rename-input");
+const renameBtn = document.querySelector("#rename-btn");
+const renameStatus = document.querySelector("#rename-status");
+
+const manageList = document.querySelector("#manage-list");
+
+// ── Download ──────────────────────────────────────────────────────────────────
+dlBtn.addEventListener("click", async () => {
+ const url = dlUrl.value.trim();
+ const title = dlTitle.value.trim();
+ if (!url) { dlStatus.textContent = "Please enter a URL."; return; }
+
+ dlStatus.textContent = "Starting download\u2026";
+ dlBtn.disabled = true;
+ dlProgress.hidden = false;
+ dlProgress.removeAttribute("value");
+
+ try {
+ const res = await fetch("/api/download", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ url, title }),
+ });
+ const data = await res.json();
+ if (data.error) {
+ dlStatus.textContent = `Error: ${data.error}`;
+ dlBtn.disabled = false;
+ dlProgress.hidden = true;
+ return;
+ }
+ pollDownload(data.track_id);
+ } catch {
+ dlStatus.textContent = "Request failed.";
+ dlBtn.disabled = false;
+ dlProgress.hidden = true;
+ }
+});
+
+function pollDownload(trackId) {
+ const iv = setInterval(async () => {
+ try {
+ const res = await fetch(`/api/download/status/${encodeURIComponent(trackId)}`);
+ const data = await res.json();
+ if (data.status === "done") {
+ clearInterval(iv);
+ dlProgress.value = 100;
+ dlStatus.textContent = "Download complete!";
+ dlBtn.disabled = false;
+ dlUrl.value = "";
+ dlTitle.value = "";
+ setTimeout(() => { dlProgress.hidden = true; }, 800);
+ refresh();
+ } else if (data.status === "error") {
+ clearInterval(iv);
+ dlStatus.textContent = `Error: ${data.error || "unknown"}`;
+ dlBtn.disabled = false;
+ dlProgress.hidden = true;
+ } else {
+ if (typeof data.progress === "number") {
+ dlProgress.value = data.progress;
+ } else {
+ dlProgress.removeAttribute("value");
+ }
+ const phase = data.phase === "converting" ? "Converting" : "Downloading";
+ const pctTxt = typeof data.progress === "number" ? ` ${data.progress}%` : "";
+ dlStatus.textContent = `${phase}${pctTxt}\u2026`;
+ }
+ } catch {
+ clearInterval(iv);
+ dlStatus.textContent = "Status check failed.";
+ dlBtn.disabled = false;
+ dlProgress.hidden = true;
+ }
+ }, 750);
+}
+
+// ── Rename ────────────────────────────────────────────────────────────────────
+async function loadRenameDropdown() {
+ const [downloads, tracks] = await Promise.all([fetchDownloads(), fetchTracks()]);
+ renameSelect.innerHTML = "";
+
+ if (tracks.length) {
+ const grp = document.createElement("optgroup");
+ grp.label = "Playlist";
+ tracks.forEach(t => {
+ const opt = document.createElement("option");
+ opt.value = JSON.stringify({ type: "playlist", src: t.src, title: t.title });
+ opt.textContent = t.title;
+ grp.appendChild(opt);
+ });
+ renameSelect.appendChild(grp);
+ }
+
+ if (downloads.length) {
+ const grp = document.createElement("optgroup");
+ grp.label = "Downloads";
+ downloads.forEach(t => {
+ const opt = document.createElement("option");
+ opt.value = JSON.stringify({ type: "download", filename: t.filename, title: t.title });
+ opt.textContent = t.title;
+ grp.appendChild(opt);
+ });
+ renameSelect.appendChild(grp);
+ }
+
+ syncRenameInput();
+}
+
+function syncRenameInput() {
+ try { renameInput.value = JSON.parse(renameSelect.value).title; } catch {}
+}
+
+renameSelect.addEventListener("change", syncRenameInput);
+
+renameBtn.addEventListener("click", async () => {
+ const newTitle = renameInput.value.trim();
+ if (!newTitle) return;
+ let item;
+ try { item = JSON.parse(renameSelect.value); } catch { return; }
+
+ renameStatus.textContent = "Saving\u2026";
+
+ try {
+ let res;
+ if (item.type === "playlist") {
+ res = await fetch("/api/tracks/rename", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ src: item.src, title: newTitle }),
+ });
+ } else {
+ res = await fetch(`/api/downloads/${encodeURIComponent(item.filename)}/rename`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ title: newTitle }),
+ });
+ }
+ if (res.ok) {
+ renameStatus.textContent = "Saved!";
+ refresh();
+ } else {
+ renameStatus.textContent = "Save failed.";
+ }
+ } catch {
+ renameStatus.textContent = "Request failed.";
+ }
+});
+
+// ── Manage downloads ──────────────────────────────────────────────────────────
+async function loadManageList() {
+ const tracks = await fetchDownloads();
+ if (!tracks.length) {
+ manageList.innerHTML = "No tracks downloaded yet.
";
+ return;
+ }
+ manageList.innerHTML = "";
+ tracks.forEach(t => {
+ const item = document.createElement("div");
+ item.className = "track-item track-item--col";
+ const sourceLink = t.source
+ ? `Source`
+ : "";
+ item.innerHTML = `
+
+ ${escapeHtml(t.title)}
+ ${sourceLink}
+
+
+
+
+
+ sec
+
+
`;
+
+ item.querySelector(".btn-danger").addEventListener("click", async () => {
+ await fetch(`/api/downloads/${encodeURIComponent(t.filename)}`, { method: "DELETE" });
+ refresh();
+ });
+
+ const skipInput = item.querySelector(".skip-input");
+ item.querySelector(".btn-save").addEventListener("click", async () => {
+ const seconds = parseInt(skipInput.value, 10) || 0;
+ await fetch(`/api/downloads/${encodeURIComponent(t.filename)}/skip`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ seconds }),
+ });
+ });
+
+ manageList.appendChild(item);
+ });
+}
+
+// ── Helpers ───────────────────────────────────────────────────────────────────
+function escapeHtml(s) {
+ return String(s).replace(/[&<>"']/g, c => ({
+ "&": "&", "<": "<", ">": ">", '"': """, "'": "'",
+ }[c]));
+}
+function escapeAttr(s) { return escapeHtml(s); }
+
+async function fetchDownloads() {
+ try {
+ const res = await fetch("/api/downloads");
+ return res.ok ? await res.json() : [];
+ } catch { return []; }
+}
+
+async function fetchTracks() {
+ try {
+ const res = await fetch("/api/tracks");
+ return res.ok ? await res.json() : [];
+ } catch { return []; }
+}
+
+function refresh() {
+ loadRenameDropdown();
+ loadManageList();
+}
+
+// ── Init ──────────────────────────────────────────────────────────────────────
+refresh();
diff --git a/containers/sleep-meditation-downloader/site/index.html b/containers/sleep-meditation-downloader/site/index.html
new file mode 100644
index 0000000..fda1efa
--- /dev/null
+++ b/containers/sleep-meditation-downloader/site/index.html
@@ -0,0 +1,42 @@
+
+
+
+
+
+ Sleep Meditation — Downloads
+
+
+
+
+
+
+
+
+
+
+
+
+
+ No tracks downloaded yet.
+
+
+
+
+
+
diff --git a/containers/sleep-meditation-downloader/site/styles.css b/containers/sleep-meditation-downloader/site/styles.css
new file mode 100644
index 0000000..1259702
--- /dev/null
+++ b/containers/sleep-meditation-downloader/site/styles.css
@@ -0,0 +1,198 @@
+:root {
+ --bg-1: #081c15;
+ --bg-2: #1b4332;
+ --panel: rgba(216, 243, 220, 0.12);
+ --panel-border: rgba(216, 243, 220, 0.35);
+ --text: #f1faee;
+ --muted: #cde6d0;
+ --accent: #95d5b2;
+ --accent-2: #74c69d;
+ --danger: #e63946;
+ --danger-2: #c1121f;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ min-height: 100vh;
+ color: var(--text);
+ font-family: "Avenir Next", "Segoe UI", sans-serif;
+ background: radial-gradient(circle at top right, #2d6a4f 0%, var(--bg-2) 35%, var(--bg-1) 100%);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: 20px;
+}
+
+.app {
+ width: 100%;
+ max-width: 680px;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ min-width: 0;
+}
+
+h1 {
+ margin: 0;
+ font-size: clamp(2rem, 4vw, 2.8rem);
+ letter-spacing: 0.02em;
+}
+
+header {
+ display: grid;
+ gap: 4px;
+}
+
+header p {
+ margin: 0;
+ color: var(--muted);
+}
+
+.card {
+ backdrop-filter: blur(6px);
+ background: var(--panel);
+ border: 1px solid var(--panel-border);
+ border-radius: 18px;
+ padding: 18px;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ min-width: 0;
+}
+
+label {
+ font-weight: 600;
+}
+
+select,
+input,
+button {
+ width: 100%;
+ border: 1px solid rgba(241, 250, 238, 0.25);
+ border-radius: 10px;
+ padding: 10px;
+ font: inherit;
+}
+
+select,
+input {
+ background: rgba(0, 0, 0, 0.2);
+ color: var(--text);
+}
+
+input::placeholder {
+ color: var(--muted);
+ opacity: 0.7;
+}
+
+button {
+ cursor: pointer;
+ color: #052e16;
+ font-weight: 700;
+ background: linear-gradient(180deg, var(--accent), var(--accent-2));
+}
+
+button:hover {
+ filter: brightness(1.05);
+}
+
+button:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.btn-danger {
+ color: #fff;
+ background: linear-gradient(180deg, var(--danger), var(--danger-2));
+ flex-shrink: 0;
+ width: auto;
+ padding: 8px 14px;
+}
+
+.hint {
+ margin: 0;
+ color: var(--muted);
+ font-size: 0.95rem;
+}
+
+#manage-list {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.track-item {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ min-width: 0;
+}
+
+.track-item--col {
+ flex-direction: column;
+ align-items: stretch;
+ gap: 6px;
+}
+
+.track-row {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ min-width: 0;
+}
+
+.track-skip {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.skip-label {
+ font-weight: normal;
+ font-size: 0.875rem;
+ color: var(--muted);
+ white-space: nowrap;
+}
+
+.skip-input {
+ width: 70px;
+ flex-shrink: 0;
+}
+
+.skip-unit {
+ color: var(--muted);
+ font-size: 0.875rem;
+ white-space: nowrap;
+}
+
+.btn-save {
+ width: auto;
+ padding: 6px 12px;
+ font-size: 0.875rem;
+}
+
+.track-title {
+ flex: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.track-item button {
+ flex-shrink: 0;
+ width: auto;
+ padding: 8px 14px;
+}
+
+.track-source {
+ flex-shrink: 0;
+ font-size: 0.85rem;
+ color: #4a90e2;
+ text-decoration: none;
+ padding: 4px 8px;
+}
+.track-source:hover { text-decoration: underline; }
diff --git a/containers/sleep-meditation/Dockerfile b/containers/sleep-meditation/Dockerfile
index 9a16387..ddc7b39 100644
--- a/containers/sleep-meditation/Dockerfile
+++ b/containers/sleep-meditation/Dockerfile
@@ -1,9 +1,17 @@
FROM nginx:1.27-alpine
+RUN apk add --no-cache python3 supervisor && \
+ python3 -m venv /app/venv && \
+ /app/venv/bin/pip install --no-cache-dir flask
+
COPY nginx.conf /etc/nginx/conf.d/default.conf
+COPY supervisord.conf /etc/supervisord.conf
+COPY api.py /app/api.py
COPY site/ /usr/share/nginx/html/
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD wget -q -O /dev/null http://127.0.0.1:8000/ || exit 1
+
+CMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"]
diff --git a/containers/sleep-meditation/api.py b/containers/sleep-meditation/api.py
new file mode 100644
index 0000000..19b8db0
--- /dev/null
+++ b/containers/sleep-meditation/api.py
@@ -0,0 +1,56 @@
+import os
+import json
+from pathlib import Path
+from flask import Flask, jsonify
+
+app = Flask(__name__)
+
+STATIC_ROOT = Path(os.environ.get("STATIC_ROOT", "/usr/share/nginx/html"))
+MP3_DIR = STATIC_ROOT / "mp3"
+DOWNLOADS_DIR = MP3_DIR / "downloads"
+AUDIO_EXT = {".mp3", ".m4a", ".aac", ".wav", ".ogg"}
+
+_TITLES_FILE = DOWNLOADS_DIR / "titles.json"
+_SKIP_FILE = DOWNLOADS_DIR / "skip.json"
+
+
+def _load_titles() -> dict:
+ if _TITLES_FILE.exists():
+ try:
+ with open(_TITLES_FILE) as f:
+ return json.load(f)
+ except Exception:
+ pass
+ return {}
+
+
+def _load_skip() -> dict:
+ if _SKIP_FILE.exists():
+ try:
+ with open(_SKIP_FILE) as f:
+ return json.load(f)
+ except Exception:
+ pass
+ return {}
+
+
+@app.route("/api/downloads")
+def list_downloads():
+ if not DOWNLOADS_DIR.exists():
+ return jsonify([])
+ titles = _load_titles()
+ skip = _load_skip()
+ files = []
+ for f in sorted(DOWNLOADS_DIR.iterdir()):
+ if f.is_file() and f.suffix.lower() in AUDIO_EXT:
+ files.append({
+ "title": titles.get(f.name, f.stem),
+ "filename": f.name,
+ "src": f"/mp3/downloads/{f.name}",
+ "skip": skip.get(f.name, 0),
+ })
+ return jsonify(files)
+
+
+if __name__ == "__main__":
+ app.run(host="127.0.0.1", port=8001, debug=False)
diff --git a/containers/sleep-meditation/nginx.conf b/containers/sleep-meditation/nginx.conf
index 8418d37..2bf0e2c 100644
--- a/containers/sleep-meditation/nginx.conf
+++ b/containers/sleep-meditation/nginx.conf
@@ -13,8 +13,19 @@ server {
add_header Cache-Control "public, max-age=300";
types {
audio/mpeg mp3;
+ audio/mp4 m4a;
+ audio/aac aac;
+ audio/wav wav;
+ audio/ogg ogg;
application/json json;
}
autoindex on;
}
+
+ location /api/ {
+ proxy_pass http://127.0.0.1:8001;
+ proxy_set_header Host $host;
+ proxy_read_timeout 15s;
+ client_max_body_size 1m;
+ }
}
diff --git a/containers/sleep-meditation/site/app.js b/containers/sleep-meditation/site/app.js
index 773dd9e..da92ddf 100644
--- a/containers/sleep-meditation/site/app.js
+++ b/containers/sleep-meditation/site/app.js
@@ -1,37 +1,33 @@
-const fallbackPlaylist = [
- {
- title: "Example track",
- artist: "Sleep Meditation",
- src: "/mp3/example.mp3"
- }
-];
+const fallbackPlaylist = [];
const supportedAudioPattern = /\.(mp3|m4a|aac|wav|ogg)$/i;
-const player = document.querySelector("#player");
-const select = document.querySelector("#playlist-select");
-const prevBtn = document.querySelector("#prev");
-const playBtn = document.querySelector("#play");
-const nextBtn = document.querySelector("#next");
+// ── DOM refs ──────────────────────────────────────────────────────────────────
+const player = document.querySelector("#player");
+const select = document.querySelector("#playlist-select");
+const prevBtn = document.querySelector("#prev");
+const playBtn = document.querySelector("#play");
+const nextBtn = document.querySelector("#next");
const restartBtn = document.querySelector("#restart");
-let playlist = [];
-let currentIndex = 0;
+const downloadListEl = document.querySelector("#download-list");
+const refreshBtn = document.querySelector("#refresh-btn");
+// ── State ─────────────────────────────────────────────────────────────────────
+let playlist = [];
+let currentIndex = 0;
+let singleMode = false; // true while a downloaded track is playing
+
+// ── Playlist helpers ──────────────────────────────────────────────────────────
function normalizePlaylist(list) {
if (!Array.isArray(list)) return [];
-
return list
- .filter((track) => track && typeof track.src === "string")
- .map((track) => {
- const normalizedSrc = track.src.startsWith("/") ? track.src : `/mp3/${track.src.replace(/^\/+/, "")}`;
- return {
- title: track.title || toTrackTitle(normalizedSrc),
- artist: track.artist || "",
- src: normalizedSrc
- };
+ .filter(t => t && typeof t.src === "string")
+ .map(t => {
+ const src = t.src.startsWith("/") ? t.src : `/mp3/${t.src.replace(/^\/+/, "")}`;
+ return { title: t.title || toTrackTitle(src), artist: t.artist || "", src };
})
- .filter((track) => supportedAudioPattern.test(track.src));
+ .filter(t => supportedAudioPattern.test(t.src));
}
function toTrackTitle(pathname) {
@@ -39,8 +35,49 @@ function toTrackTitle(pathname) {
return filename.replace(/\.[^/.]+$/, "").replace(/[_-]+/g, " ").trim() || "Track";
}
-function clampIndex(index) {
- return Math.min(Math.max(index, 0), playlist.length - 1);
+function clampIndex(i) {
+ return Math.min(Math.max(i, 0), playlist.length - 1);
+}
+
+async function loadPlaylistJson() {
+ const res = await fetch("/mp3/playlist.json", { cache: "no-store" });
+ if (!res.ok) throw new Error("unavailable");
+ const data = await res.json();
+ const normalized = normalizePlaylist(data);
+ if (!normalized.length) throw new Error("empty");
+ return normalized;
+}
+
+async function loadPlaylistFromDirectory() {
+ const res = await fetch("/mp3/", { cache: "no-store" });
+ if (!res.ok) throw new Error("unavailable");
+ const html = await res.text();
+ const doc = new DOMParser().parseFromString(html, "text/html");
+ const base = new URL("/mp3/", window.location.origin);
+ const urls = [...new Set(
+ Array.from(doc.querySelectorAll("a[href]"))
+ .map(a => new URL(a.getAttribute("href"), base).pathname)
+ .filter(p => p.startsWith("/mp3/") && !p.startsWith("/mp3/downloads/") && supportedAudioPattern.test(p))
+ )].sort((a, b) => a.localeCompare(b));
+ if (!urls.length) throw new Error("empty");
+ return urls.map(src => ({ title: toTrackTitle(src), artist: "", src }));
+}
+
+async function loadPlaylist() {
+ try { playlist = await loadPlaylistJson(); }
+ catch { try { playlist = await loadPlaylistFromDirectory(); } catch { playlist = fallbackPlaylist; } }
+ renderOptions();
+ setTrack(0, false);
+}
+
+function renderOptions() {
+ select.innerHTML = "";
+ playlist.forEach((track, i) => {
+ const opt = document.createElement("option");
+ opt.value = String(i);
+ opt.textContent = track.artist ? `${track.title} - ${track.artist}` : track.title;
+ select.appendChild(opt);
+ });
}
function resetToStart() {
@@ -49,129 +86,96 @@ function resetToStart() {
player.currentTime = 0;
}
-async function loadPlaylistJson() {
- const response = await fetch("/mp3/playlist.json", { cache: "no-store" });
- if (!response.ok) throw new Error("playlist.json unavailable");
-
- const data = await response.json();
- const normalized = normalizePlaylist(data);
- if (normalized.length === 0) throw new Error("playlist.json empty or invalid");
-
- return normalized;
+function setTrack(index, autoplay) {
+ if (!playlist.length) return;
+ singleMode = false;
+ currentIndex = clampIndex(index);
+ const track = playlist[currentIndex];
+ player.src = track.src;
+ select.value = String(currentIndex);
+ setMediaSession(track.title, track.artist);
+ if (autoplay) player.play().catch(() => {});
}
-async function loadPlaylistFromDirectory() {
- const response = await fetch("/mp3/", { cache: "no-store" });
- if (!response.ok) throw new Error("/mp3/ directory listing unavailable");
-
- const html = await response.text();
- const doc = new DOMParser().parseFromString(html, "text/html");
- const links = Array.from(doc.querySelectorAll("a[href]"));
- const mp3Base = new URL("/mp3/", window.location.origin);
-
- const trackUrls = links
- .map((link) => new URL(link.getAttribute("href"), mp3Base).pathname)
- .filter((pathname) => pathname.startsWith("/mp3/") && supportedAudioPattern.test(pathname));
-
- const uniqueTrackUrls = [...new Set(trackUrls)].sort((a, b) => a.localeCompare(b));
- if (uniqueTrackUrls.length === 0) throw new Error("No audio files found in /mp3/");
-
- return uniqueTrackUrls.map((src) => ({
- title: toTrackTitle(src),
- artist: "",
- src
- }));
-}
-
-async function loadPlaylist() {
- try {
- playlist = await loadPlaylistJson();
- } catch {
- try {
- playlist = await loadPlaylistFromDirectory();
- } catch {
- playlist = fallbackPlaylist;
- }
- }
-
- renderOptions();
- setTrack(0, false);
-}
-
-function renderOptions() {
- select.innerHTML = "";
-
- playlist.forEach((track, index) => {
- const option = document.createElement("option");
- option.value = String(index);
- option.textContent = track.artist ? `${track.title} - ${track.artist}` : track.title;
- select.appendChild(option);
+function setMediaSession(title, artist) {
+ if (!("mediaSession" in navigator)) return;
+ navigator.mediaSession.metadata = new MediaMetadata({
+ title: title || "Sleep Meditation",
+ artist: artist || "",
+ album: "Sleep Meditation",
});
}
-function setTrack(index, autoplay) {
- if (playlist.length === 0) return;
-
- currentIndex = clampIndex(index);
- const track = playlist[currentIndex];
-
- player.src = track.src;
- select.value = String(currentIndex);
-
- if ("mediaSession" in navigator) {
- navigator.mediaSession.metadata = new MediaMetadata({
- title: track.title || "Sleep Meditation",
- artist: track.artist || "",
- album: "Sleep Meditation"
- });
- }
-
- if (autoplay) {
- player.play().catch(() => {
- // iOS requires a user interaction before playback can start.
- });
- }
-}
-
-select.addEventListener("change", () => {
- setTrack(Number(select.value), true);
-});
-
-prevBtn.addEventListener("click", () => setTrack(currentIndex - 1, true));
-nextBtn.addEventListener("click", () => setTrack(currentIndex + 1, true));
+// ── Player controls ───────────────────────────────────────────────────────────
+select.addEventListener("change", () => setTrack(Number(select.value), true));
+prevBtn.addEventListener("click", () => setTrack(currentIndex - 1, true));
+nextBtn.addEventListener("click", () => setTrack(currentIndex + 1, true));
restartBtn.addEventListener("click", resetToStart);
-
-playBtn.addEventListener("click", () => {
- if (player.paused) {
- player.play();
- } else {
- player.pause();
- }
-});
+playBtn.addEventListener("click", () => player.paused ? player.play() : player.pause());
player.addEventListener("ended", () => {
- if (currentIndex < playlist.length - 1) {
- setTrack(currentIndex + 1, true);
- return;
- }
-
+ if (singleMode) return;
+ if (currentIndex < playlist.length - 1) { setTrack(currentIndex + 1, true); return; }
player.pause();
player.currentTime = 0;
});
if ("mediaSession" in navigator) {
- navigator.mediaSession.setActionHandler("play", () => player.play());
- navigator.mediaSession.setActionHandler("pause", () => player.pause());
+ navigator.mediaSession.setActionHandler("play", () => player.play());
+ navigator.mediaSession.setActionHandler("pause", () => player.pause());
navigator.mediaSession.setActionHandler("previoustrack", () => setTrack(currentIndex - 1, true));
- navigator.mediaSession.setActionHandler("nexttrack", () => setTrack(currentIndex + 1, true));
+ navigator.mediaSession.setActionHandler("nexttrack", () => setTrack(currentIndex + 1, true));
}
-if ("serviceWorker" in navigator) {
- window.addEventListener("load", () => {
- navigator.serviceWorker.register("/service-worker.js").catch(() => {
- // Service worker is optional for playback.
- });
+// ── Download list (main page) ─────────────────────────────────────────────────
+async function loadDownloadList() {
+ const tracks = await fetchDownloads();
+ if (!tracks.length) {
+ downloadListEl.innerHTML = "No tracks downloaded yet.
";
+ return;
+ }
+ downloadListEl.innerHTML = "";
+ tracks.forEach(t => {
+ const item = document.createElement("div");
+ item.className = "track-item";
+ item.innerHTML = `${t.title}
+ `;
+ item.querySelector("button").addEventListener("click", () => playSingle(t.src, t.title, t.skip || 0));
+ downloadListEl.appendChild(item);
});
}
+function playSingle(src, title, skip) {
+ singleMode = true;
+ player.src = src;
+ player.load();
+ if (skip > 0) {
+ player.addEventListener("loadedmetadata", function onMeta() {
+ player.removeEventListener("loadedmetadata", onMeta);
+ player.currentTime = skip;
+ });
+ }
+ player.play().catch(() => {});
+ setMediaSession(title, "");
+}
+
+async function fetchDownloads() {
+ try {
+ const res = await fetch("/api/downloads");
+ return res.ok ? await res.json() : [];
+ } catch { return []; }
+}
+
+// ── Service Worker ────────────────────────────────────────────────────────────
+if ("serviceWorker" in navigator) {
+ window.addEventListener("load", () => {
+ navigator.serviceWorker.register("/service-worker.js").catch(() => {});
+ });
+}
+
+// ── Refresh ───────────────────────────────────────────────────────────────────
+refreshBtn.addEventListener("click", () => location.reload());
+
+// ── Init ──────────────────────────────────────────────────────────────────────
loadPlaylist();
+loadDownloadList();
diff --git a/containers/sleep-meditation/site/index.html b/containers/sleep-meditation/site/index.html
index 09ccf02..a6a4a87 100644
--- a/containers/sleep-meditation/site/index.html
+++ b/containers/sleep-meditation/site/index.html
@@ -10,9 +10,14 @@
-
+
+
+
@@ -35,6 +40,12 @@
iPhone tip: tap Play once first. After that, audio usually continues on the lock screen.
+
+
+
+ No tracks downloaded yet.
+
+
diff --git a/containers/sleep-meditation/site/styles.css b/containers/sleep-meditation/site/styles.css
index c710dcd..9b457bc 100644
--- a/containers/sleep-meditation/site/styles.css
+++ b/containers/sleep-meditation/site/styles.css
@@ -7,6 +7,8 @@
--muted: #cde6d0;
--accent: #95d5b2;
--accent-2: #74c69d;
+ --danger: #e63946;
+ --danger-2: #c1121f;
}
* {
@@ -19,13 +21,23 @@ body {
color: var(--text);
font-family: "Avenir Next", "Segoe UI", sans-serif;
background: radial-gradient(circle at top right, #2d6a4f 0%, var(--bg-2) 35%, var(--bg-1) 100%);
- display: grid;
- place-items: center;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
padding: 20px;
}
.app {
- width: min(680px, 100%);
+ width: 100%;
+ max-width: 680px;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ min-width: 0;
+}
+
+.hidden {
+ display: none;
}
h1 {
@@ -34,8 +46,34 @@ h1 {
letter-spacing: 0.02em;
}
+header {
+ display: grid;
+ gap: 4px;
+}
+
+.header-top {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.btn-refresh {
+ width: auto;
+ background: none;
+ border: none;
+ color: var(--accent);
+ font-size: 1.8rem;
+ line-height: 1;
+ padding: 4px 8px;
+ cursor: pointer;
+}
+
+.btn-refresh:hover {
+ filter: brightness(1.2);
+}
+
header p {
- margin: 8px 0 20px;
+ margin: 0;
color: var(--muted);
}
@@ -45,8 +83,10 @@ header p {
border: 1px solid var(--panel-border);
border-radius: 18px;
padding: 18px;
- display: grid;
+ display: flex;
+ flex-direction: column;
gap: 12px;
+ min-width: 0;
}
label {
@@ -54,12 +94,14 @@ label {
}
select,
+input,
button,
audio {
width: 100%;
}
select,
+input,
button {
border: 1px solid rgba(241, 250, 238, 0.25);
border-radius: 10px;
@@ -67,34 +109,23 @@ button {
font: inherit;
}
-select {
+select,
+input {
background: rgba(0, 0, 0, 0.2);
color: var(--text);
}
+input::placeholder {
+ color: var(--muted);
+ opacity: 0.7;
+}
+
.buttons {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
}
-button {
- cursor: pointer;
- color: #052e16;
- font-weight: 700;
- background: linear-gradient(180deg, var(--accent), var(--accent-2));
-}
-
-button:hover {
- filter: brightness(1.05);
-}
-
-.hint {
- margin: 0;
- color: var(--muted);
- font-size: 0.95rem;
-}
-
@media (max-width: 700px) {
.buttons {
grid-template-columns: repeat(2, 1fr);
@@ -106,3 +137,62 @@ button:hover {
grid-template-columns: 1fr;
}
}
+
+button {
+ cursor: pointer;
+ color: #052e16;
+ font-weight: 700;
+ background: linear-gradient(180deg, var(--accent), var(--accent-2));
+}
+
+button:hover {
+ filter: brightness(1.05);
+}
+
+button:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.btn-danger {
+ color: #fff;
+ background: linear-gradient(180deg, var(--danger), var(--danger-2));
+ flex-shrink: 0;
+ width: auto;
+ padding: 8px 14px;
+}
+
+.hint {
+ margin: 0;
+ color: var(--muted);
+ font-size: 0.95rem;
+}
+
+/* Track list rows */
+#download-list {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.track-item {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ min-width: 0;
+}
+
+.track-title {
+ flex: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.track-item button {
+ flex-shrink: 0;
+ width: auto;
+ padding: 8px 14px;
+}
+
+
diff --git a/containers/sleep-meditation/supervisord.conf b/containers/sleep-meditation/supervisord.conf
new file mode 100644
index 0000000..bd96fbe
--- /dev/null
+++ b/containers/sleep-meditation/supervisord.conf
@@ -0,0 +1,23 @@
+[supervisord]
+nodaemon=true
+logfile=/dev/null
+logfile_maxbytes=0
+pidfile=/tmp/supervisord.pid
+
+[program:nginx]
+command=nginx -g "daemon off;"
+autostart=true
+autorestart=true
+stdout_logfile=/dev/stdout
+stdout_logfile_maxbytes=0
+stderr_logfile=/dev/stderr
+stderr_logfile_maxbytes=0
+
+[program:api]
+command=/app/venv/bin/python /app/api.py
+autostart=true
+autorestart=true
+stdout_logfile=/dev/stdout
+stdout_logfile_maxbytes=0
+stderr_logfile=/dev/stderr
+stderr_logfile_maxbytes=0
diff --git a/docs/TECHNICAL.md b/docs/TECHNICAL.md
index 314234c..88807bc 100644
--- a/docs/TECHNICAL.md
+++ b/docs/TECHNICAL.md
@@ -2,24 +2,70 @@
## Scope
-This project provides a static web app in a container that plays MP3 audio on desktop and mobile, including iPhone lock-screen controls.
+This project provides a web app in a container that plays MP3 audio on desktop and mobile, including iPhone lock-screen controls. A separate internal container handles downloading and managing audio tracks.
## Architecture
-- Service: `sleep-meditation`
-- Runtime: `nginx:alpine`
-- App: static frontend (`index.html`, `styles.css`, `app.js`)
+Two containers share a host volume containing all MP3 files and metadata.
+
+### sleep-meditation (public)
+- Runtime: `nginx:1.27-alpine` + Python 3 (managed by supervisord)
+- Nginx: serves static files and proxies `/api/` to the local Python API on `localhost:8001`
+- Python API: read-only Flask app — lists downloaded tracks only (`GET /api/downloads`)
+- App: frontend (`index.html`, `styles.css`, `app.js`)
- MP3 source: mounted host volume at `/usr/share/nginx/html/mp3`
+- Externally accessible; no write operations exposed
+
+### sleep-meditation-downloader (internal)
+- Runtime: `python:3.12-alpine` + Flask
+- UI: management page at `http://:${SLEEP_MEDITATION_DOWNLOADER_PORT}/`
+- API: all write operations — download, delete, rename downloads and playlist tracks
+- MP3 volume mounted at `/mp3`
+- Accessible on `${SLEEP_MEDITATION_DOWNLOADER_PORT}` within the local network (not publicly exposed)
+- After downloading, use the refresh button in the sleep app to pick up new downloads
+
+## File overview
+
+```
+containers/sleep-meditation/
+ Dockerfile
+ nginx.conf
+ supervisord.conf
+ api.py ← read-only: GET /api/downloads only
+ site/
+ index.html
+ styles.css
+ app.js
+ manifest.webmanifest
+ service-worker.js
+ mp3/playlist.json
+containers/sleep-meditation-downloader/
+ Dockerfile
+ api.py ← all write operations + UI file serving
+ site/
+ index.html
+ styles.css
+ app.js
+stack/
+ stack.yml
+ sleep-meditation.env
+```
## iPhone background playback
-Important on iOS:
-
- Autoplay without user interaction is blocked.
- The user must start playback manually at least once.
- After playback starts, audio usually continues when the screen is locked.
- Media Session API enables lock-screen controls for play/pause/track skip.
+## UI
+
+Single page — no routing. Contains:
+
+- Playlist selector and audio player with controls
+- Downloads list (read-only, populated via `GET /api/downloads`)
+- Refresh button (↺) in the header: reloads the full page; useful when the app is saved as a home screen bookmark on iPhone (no browser refresh available)
+
## Player controls
- `Previous`: go to previous track (clamped at first track)
@@ -27,36 +73,24 @@ Important on iOS:
- `Next`: go to next track (clamped at last track)
- `Back to start`: select first track, reset position to `0:00`, no autoplay
-When a track ends:
+When a playlist track ends:
- If there is a next track, it starts automatically.
- If the last track ends, playback stops (no loop).
-## File overview
+When a downloaded track ends:
-```
-containers/sleep-meditation/
- Dockerfile
- nginx.conf
- site/
- index.html
- styles.css
- app.js
- manifest.webmanifest
- service-worker.js
- mp3/playlist.json
-stack/
- stack.yml
- sleep-meditation.env
-```
+- Playback stops. No auto-advance (`singleMode` flag is set when playing a download).
## Track loading behavior
-The app loads tracks in this order:
+The app loads meditation tracks in this order:
1. `/mp3/playlist.json`
-2. Auto-discovery from `/mp3/` directory listing (Nginx `autoindex on`)
-3. Built-in fallback track (`/mp3/example.mp3`)
+2. Auto-discovery from `/mp3/` directory listing (Nginx `autoindex on`), excluding `/mp3/downloads/`
+3. Empty list (no fallback track)
+
+Downloaded tracks are loaded separately via `GET /api/downloads`.
## Playlist contract
@@ -76,7 +110,47 @@ Rules:
- `title` optional (derived from filename if omitted)
- `artist` optional
-- `src` required; absolute path within web root (`/mp3/...`) or relative filename
+- `src` required; absolute path (`/mp3/...`) or bare filename
+
+When a track title is renamed via the downloader UI, the API writes the updated title back to `playlist.json`. If `playlist.json` does not yet exist in the volume, it is created from the current directory listing.
+
+## Downloaded tracks
+
+- Stored in `/mp3/downloads/` inside the mp3 volume.
+- Filenames are URL- and filesystem-safe (letters, digits, spaces, hyphens only).
+- Display titles (preserving special characters like `:` and `|`) are stored in `/mp3/downloads/titles.json`.
+- Renaming a downloaded track updates `titles.json` only; the filename is not changed.
+- Deleting a downloaded track removes the file and its entries from `titles.json` and `skip.json`.
+
+## Skip intro
+
+- A per-track skip offset (seconds) can be set in the downloader UI.
+- Stored in `/mp3/downloads/skip.json` as `{ "filename.mp3": seconds }`.
+- `GET /api/downloads` returns a `skip` field (default `0`) for each track.
+- The sleep app seeks to `skip` seconds after `loadedmetadata` fires when playing a downloaded track.
+- Setting skip to `0` removes the entry from `skip.json`.
+
+## API endpoints
+
+### sleep-meditation (public, read-only)
+
+| Method | Path | Description |
+|--------|------|-------------|
+| `GET` | `/api/downloads` | List downloaded tracks |
+
+### sleep-meditation-downloader (internal only)
+
+| Method | Path | Description |
+|--------|------|-------------|
+| `GET` | `/` | Management UI |
+| `GET` | `/api/downloads` | List downloaded tracks |
+| `GET` | `/api/tracks` | List playlist tracks |
+| `POST` | `/api/download` | Start server-side download `{url, title}` |
+| `GET` | `/api/download/status/{track_id}` | Poll download status |
+| `DELETE` | `/api/downloads/{filename}` | Delete a downloaded track |
+| `POST` | `/api/downloads/{filename}/rename` | Rename display title `{title}` |
+| `POST` | `/api/downloads/{filename}/skip` | Set skip-intro offset `{seconds}` |
+| `POST` | `/api/tracks/rename` | Update playlist track title `{src, title}` |
## Container healthcheck
diff --git a/docs/changelog-develop.md b/docs/changelog-develop.md
index 2c6b978..c5a2c8c 100644
--- a/docs/changelog-develop.md
+++ b/docs/changelog-develop.md
@@ -1,5 +1,51 @@
# Changelog (develop)
+## 2026-05-10 — Released as v0.1.6
+
+## 2026-05-10
+
+- Replaced `build-and-push.sh` with shared version from `shared-integrations/tooling/docker-build-and-push/`; reads release version from `docs/changelog.md` heading instead of `version.txt`. No more git operations from the script — commit and tag manually.
+- Removed obsolete `version.txt`, `.last-branch`, and `.gitignore` (only ignored `version.txt`).
+- Stack: image tag now configurable via `SLEEP_MEDITATION_IMAGE_TAG` in `sleep-meditation.env` (was hardcoded `:dev`); host ports already from env, container ports kept hardcoded since they're application-fixed.
+- Downloader: added YouTube support via `yt-dlp` + `ffmpeg` (added to Dockerfile). YouTube URLs detected by hostname (`youtube.com`, `youtu.be`, `music.youtube.com`, `m.youtube.com`); downloaded as best audio, transcoded to mp3 at highest quality.
+- Downloader: direct (non-YouTube) downloads now validate `Content-Type` (must be `audio/*` or `application/ogg`); unsupported responses (e.g. HTML pages) are rejected with a clear error instead of silently saving a broken mp3.
+- Downloader: title field now optional. For YouTube URLs without a title, the API fetches the video title via `yt-dlp --get-title` and uses it as the filename.
+- Downloader: progress tracking. yt-dlp uses `progress_hooks` to report download/convert phase; direct downloads track bytes vs `Content-Length`. `/api/download/status` returns `progress` (0–100) and `phase`. UI shows a `