From 0d9f20690f2604025ff2993a4325e6e0794c907c Mon Sep 17 00:00:00 2001 From: Ivo Oskamp Date: Sun, 10 May 2026 15:13:10 +0200 Subject: [PATCH] Release v0.1.6 - Switch to shared build-and-push.sh; version read from docs/changelog.md - Add docs/changelog.md; remove version.txt, .last-branch, .gitignore - Stack: image tag via SLEEP_MEDITATION_IMAGE_TAG - Downloader: YouTube support (yt-dlp + ffmpeg), best audio to mp3 - Downloader: Content-Type validation for direct URLs - Downloader: auto-fetch YouTube title; title field optional - Downloader: progress bar with phase (downloading/converting) - Downloader: store source URL per file; show Source link in manage list Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 1 - .last-branch | 1 - README.md | 13 +- build-and-push.sh | 242 ++++++++-- .../sleep-meditation-downloader/Dockerfile | 14 + containers/sleep-meditation-downloader/api.py | 413 ++++++++++++++++++ .../sleep-meditation-downloader/site/app.js | 231 ++++++++++ .../site/index.html | 42 ++ .../site/styles.css | 198 +++++++++ containers/sleep-meditation/Dockerfile | 8 + containers/sleep-meditation/api.py | 56 +++ containers/sleep-meditation/nginx.conf | 11 + containers/sleep-meditation/site/app.js | 268 ++++++------ containers/sleep-meditation/site/index.html | 15 +- containers/sleep-meditation/site/styles.css | 136 +++++- containers/sleep-meditation/supervisord.conf | 23 + docs/TECHNICAL.md | 128 ++++-- docs/changelog-develop.md | 46 ++ docs/changelog.md | 39 ++ stack/sleep-meditation.env | 2 + stack/stack.yml | 13 +- version.txt | 1 - 22 files changed, 1666 insertions(+), 235 deletions(-) delete mode 100644 .gitignore delete mode 100644 .last-branch create mode 100644 containers/sleep-meditation-downloader/Dockerfile create mode 100644 containers/sleep-meditation-downloader/api.py create mode 100644 containers/sleep-meditation-downloader/site/app.js create mode 100644 containers/sleep-meditation-downloader/site/index.html create mode 100644 containers/sleep-meditation-downloader/site/styles.css create mode 100644 containers/sleep-meditation/api.py create mode 100644 containers/sleep-meditation/supervisord.conf create mode 100644 docs/changelog.md delete mode 100644 version.txt 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 + + + + +
+
+

Downloads

+

Manage tracks for Sleep Meditation. This page is internal only.

+
+ +
+ + + + + +

+
+ +
+ + + + +

+
+ +
+ +

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 @@ -
+ + +
-

Sleep Meditation

+
+

Sleep Meditation

+ +

Play relaxing MP3 tracks, including iPhone lock-screen playback after manual start.

@@ -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 `` bar and `Downloading 42%…` / `Converting…` status; poll interval reduced from 2000 ms to 750 ms. +- Downloader: source URL stored per file in `sources.json`; `/api/downloads` returns `source`. Manage list shows a "Source" link (new tab) next to each track for revisiting the origin. Cleanup on delete and on download failure. + +## 2026-04-09 + +- Added skip-intro offset per downloaded track, configurable in the downloader UI. +- Skip offset stored in `downloads/skip.json`; `GET /api/downloads` returns `skip` field. +- Sleep app seeks to skip offset after `loadedmetadata` when playing a downloaded track. + +## 2026-04-09 — Released as v0.1.5 + +## 2026-04-09 + +- Split download management into a separate internal container (`sleep-meditation-downloader`). +- Public `sleep-meditation` container is now read-only: no write API endpoints exposed. +- `sleep-meditation-downloader` provides a management UI and full write API (download, rename, delete). +- Both containers share the same host mp3 volume. +- Downloader port bound to all interfaces (local network only, not publicly exposed). +- Added refresh button (↺) in the app header for home screen bookmark users without a browser refresh. +- Fixed download list spacing: track rows now have a gap between them. +- Build script: added `:latest` tag on release builds; fixed `git add -f` for ignored `version.txt`. + +## 2026-04-09 — Released as v0.1.4 + +## 2026-04-09 + +- Added server-side audio download via URL (avoids browser timeout on long files). +- Downloaded tracks stored in `mp3/downloads/` subdirectory of the existing mp3 volume. +- Downloaded tracks played individually on the main page (no auto-advance after end). +- Display titles stored separately in `titles.json`; special characters (`:`, `|`, etc.) preserved. +- Added Settings page (hash routing `#settings`) with: download form, track rename, manage downloads. +- Track titles for playlist tracks editable via Settings; updates written to `playlist.json`. +- Removed example fallback track. +- Container extended with Python 3 + Flask API (`api.py`) and supervisord alongside Nginx. +- Fixed mobile layout: changed `body` and `.card` from CSS Grid to Flexbox for correct iPhone rendering. + ## 2026-03-22 - Initial repository created using the `story-grabber` layout. diff --git a/docs/changelog.md b/docs/changelog.md new file mode 100644 index 0000000..2ccf738 --- /dev/null +++ b/docs/changelog.md @@ -0,0 +1,39 @@ +# Changelog + +## v0.1.6 — 2026-05-10 + +- Replaced `build-and-push.sh` with shared version from `shared-integrations/tooling/docker-build-and-push/`. Version is now read from this changelog instead of `version.txt`; commits and tags are made manually. +- Removed obsolete `version.txt`, `.last-branch`, and `.gitignore`. +- Stack: image tag now configurable via `SLEEP_MEDITATION_IMAGE_TAG` in `sleep-meditation.env`. +- Downloader: YouTube support via `yt-dlp` + `ffmpeg`. Best-quality audio is fetched and transcoded to mp3. +- Downloader: direct (non-YouTube) URLs now require an audio `Content-Type`; HTML and other unsupported responses are rejected with a clear error instead of saving a broken mp3. +- Downloader: title field is optional. For YouTube URLs the title is fetched automatically. +- Downloader: progress bar with percentage and phase (`Downloading` / `Converting`) during downloads. +- Downloader: each download now stores its source URL; the manage list shows a "Source" link for revisiting the origin. + +## v0.1.5 — 2026-04-09 + +- Added skip-intro offset per downloaded track, configurable in the downloader UI. +- Skip offset stored in `downloads/skip.json`; player seeks to the offset on `loadedmetadata`. + +## v0.1.4 — 2026-04-09 + +- Split download management into a separate internal container (`sleep-meditation-downloader`). +- Public `sleep-meditation` container is now read-only; all write endpoints live in the downloader. +- Both containers share the same host mp3 volume; downloader is bound to the local network only. +- Added refresh button (↺) in the app header for home-screen bookmark users. +- Fixed download list spacing. + +## v0.1.3 — 2026-04-09 + +- Refinements to the download/settings flow introduced in v0.1.2. + +## v0.1.2 — 2026-04-09 + +- Added server-side audio download via URL (avoids browser timeouts on long files). +- Downloaded tracks stored in `mp3/downloads/` and played individually on the main page. +- Display titles stored separately in `titles.json`; special characters preserved. +- Added Settings page (`#settings`) with download form, track rename, and manage downloads. +- Playlist track titles editable via Settings; updates written to `playlist.json`. +- Container extended with Python 3 + Flask API and supervisord alongside Nginx. +- Fixed mobile layout: switched `body` and `.card` from CSS Grid to Flexbox for correct iPhone rendering. diff --git a/stack/sleep-meditation.env b/stack/sleep-meditation.env index 862e07d..2b9e6d7 100644 --- a/stack/sleep-meditation.env +++ b/stack/sleep-meditation.env @@ -1,3 +1,5 @@ # Portainer stack variables +SLEEP_MEDITATION_IMAGE_TAG=dev SLEEP_MEDITATION_PORT=8100 +SLEEP_MEDITATION_DOWNLOADER_PORT=8101 SLEEP_MEDITATION_MP3_PATH=/docker/appdata/sleep-meditation/mp3 diff --git a/stack/stack.yml b/stack/stack.yml index bf7583b..7fed14b 100644 --- a/stack/stack.yml +++ b/stack/stack.yml @@ -2,10 +2,21 @@ version: "3.8" services: sleep-meditation: - image: gitea.oskamp.info/ivooskamp/sleep-meditation:dev + image: gitea.oskamp.info/ivooskamp/sleep-meditation:${SLEEP_MEDITATION_IMAGE_TAG} container_name: sleep-meditation restart: unless-stopped ports: - "${SLEEP_MEDITATION_PORT}:8000" volumes: - ${SLEEP_MEDITATION_MP3_PATH}:/usr/share/nginx/html/mp3 + + sleep-meditation-downloader: + image: gitea.oskamp.info/ivooskamp/sleep-meditation-downloader:${SLEEP_MEDITATION_IMAGE_TAG} + container_name: sleep-meditation-downloader + restart: unless-stopped + ports: + - "${SLEEP_MEDITATION_DOWNLOADER_PORT}:8001" + volumes: + - ${SLEEP_MEDITATION_MP3_PATH}:/mp3 + environment: + - MP3_DIR=/mp3 diff --git a/version.txt b/version.txt deleted file mode 100644 index 027a383..0000000 --- a/version.txt +++ /dev/null @@ -1 +0,0 @@ -v0.1.5