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) <noreply@anthropic.com>
This commit is contained in:
Ivo Oskamp 2026-05-10 15:13:10 +02:00
parent d9cc5b8140
commit 0d9f20690f
22 changed files with 1666 additions and 235 deletions

1
.gitignore vendored
View File

@ -1 +0,0 @@
version.txt

View File

@ -1 +0,0 @@
main

View File

@ -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) - Media Session API support (play/pause/next/previous on lock screen)
- Playlist ends at the last track (no automatic loop) - Playlist ends at the last track (no automatic loop)
- `Back to start` button resets to track 1 without autoplay - `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 - PWA manifest + service worker
- Containerized with Nginx - Containerized with Nginx + Python API (supervisord)
## Repository layout ## 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/stack.yml`: Portainer/Compose stack
- `stack/sleep-meditation.env`: environment variables - `stack/sleep-meditation.env`: environment variables
- `docs/TECHNICAL.md`: technical details and iPhone behavior - `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.

View File

@ -1,84 +1,236 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail 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 :<version>, :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/<service>/Dockerfile
# ============================================================================
DOCKER_REGISTRY="gitea.oskamp.info" DOCKER_REGISTRY="gitea.oskamp.info"
DOCKER_NAMESPACE="ivooskamp" DOCKER_NAMESPACE="ivooskamp"
VERSION_FILE="version.txt"
START_VERSION="v0.1.0" CHANGELOG_FILE="docs/changelog.md"
CONTAINERS_DIR="containers" CONTAINERS_DIR="containers"
BUMP="${1:-t}" # --- Input: prompt if missing ------------------------------------------------
if [[ "$BUMP" != "1" && "$BUMP" != "2" && "$BUMP" != "3" && "$BUMP" != "t" ]]; then MODE="${1:-}"
echo "[ERROR] Unknown bump type '$BUMP' (use 1, 2, 3, or t)." 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
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 exit 1
fi ;;
read_version() {
if [[ -f "$VERSION_FILE" ]]; then
tr -d ' \t\n\r' < "$VERSION_FILE"
else
echo "$START_VERSION"
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 esac
echo "v${MA}.${MI}.${PA}"
# --- 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
} }
if ! docker info >/dev/null 2>&1; then ensure_registry_login() {
echo "[ERROR] Docker daemon not reachable." 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 exit 1
fi 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
}
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<digits>.<digits>.<digits>"
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 if [[ ! -d "$CONTAINERS_DIR" ]]; then
echo "[ERROR] '$CONTAINERS_DIR' directory missing." echo "[ERROR] '$CONTAINERS_DIR' directory missing. Expected ./${CONTAINERS_DIR}/<service>/ with a Dockerfile."
exit 1 exit 1
fi fi
CURRENT_VERSION="$(read_version)" check_docker_ready
NEW_VERSION="$CURRENT_VERSION" ensure_registry_login
RELEASE=false validate_repo_component "$DOCKER_NAMESPACE"
if [[ "$BUMP" != "t" ]]; then # Informational: show branch and HEAD if this happens to be a git repo.
NEW_VERSION="$(bump_version "$CURRENT_VERSION" "$BUMP")" BRANCH_INFO=""
RELEASE=true HEAD_INFO=""
echo "$NEW_VERSION" > "$VERSION_FILE" if [[ -d ".git" ]]; then
git add "$VERSION_FILE" BRANCH_INFO="$(git branch --show-current 2>/dev/null || echo unknown)"
git commit -m "Release $NEW_VERSION" HEAD_INFO="$(git rev-parse --short HEAD 2>/dev/null || echo unknown)"
git tag -a "$NEW_VERSION" -m "Release $NEW_VERSION" echo "[INFO] Repo: $(pwd)"
git push --follow-tags echo "[INFO] Current branch: $BRANCH_INFO"
echo "[INFO] HEAD (sha): $HEAD_INFO"
else
echo "[INFO] Repo: $(pwd) (not a git checkout)"
fi 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 [[ -d "$svc_path" ]] || continue
svc="$(basename "$svc_path")" svc="$(basename "$svc_path")"
dockerfile="$svc_path/Dockerfile" dockerfile="$svc_path/Dockerfile"
validate_repo_component "$svc"
if [[ ! -f "$dockerfile" ]]; then if [[ ! -f "$dockerfile" ]]; then
echo "[WARNING] Skipping $svc: Dockerfile missing" echo "[WARNING] Skipping '${svc}': Dockerfile not found in ${svc_path}"
continue continue
fi fi
IMAGE_BASE="${DOCKER_REGISTRY}/${DOCKER_NAMESPACE}/${svc}" IMAGE_BASE="${DOCKER_REGISTRY}/${DOCKER_NAMESPACE}/${svc}"
if $RELEASE; then if [[ "$MODE" == "r" ]]; then
docker build -t "${IMAGE_BASE}:${NEW_VERSION}" -t "${IMAGE_BASE}:dev" "$svc_path" echo "============================================================"
docker push "${IMAGE_BASE}:${NEW_VERSION}" 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}:dev"
docker push "${IMAGE_BASE}:latest"
BUILT_IMAGES+=("${IMAGE_BASE}:${VERSION}" "${IMAGE_BASE}:dev" "${IMAGE_BASE}:latest")
else else
echo "============================================================"
echo "[INFO] Test build ${svc} -> tag: dev"
echo "============================================================"
docker build -t "${IMAGE_BASE}:dev" "$svc_path" docker build -t "${IMAGE_BASE}:dev" "$svc_path"
docker push "${IMAGE_BASE}:dev" docker push "${IMAGE_BASE}:dev"
BUILT_IMAGES+=("${IMAGE_BASE}:dev")
fi fi
done 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

View File

@ -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"]

View File

@ -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/<path:track_id>")
def download_status(track_id):
return jsonify(_downloads.get(track_id, {"status": "unknown"}))
@app.route("/api/downloads/<filename>", 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/<filename>/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/<filename>/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("/<path:filename>")
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)

View File

@ -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 = "<p class='hint'>No tracks downloaded yet.</p>";
return;
}
manageList.innerHTML = "";
tracks.forEach(t => {
const item = document.createElement("div");
item.className = "track-item track-item--col";
const sourceLink = t.source
? `<a class="track-source" href="${escapeAttr(t.source)}" target="_blank" rel="noopener noreferrer">Source</a>`
: "";
item.innerHTML = `
<div class="track-row">
<span class="track-title">${escapeHtml(t.title)}</span>
${sourceLink}
<button class="btn-danger" type="button">Delete</button>
</div>
<div class="track-skip">
<label class="skip-label">Skip intro</label>
<input type="number" class="skip-input" min="0" step="1" value="${t.skip || 0}" placeholder="0">
<span class="skip-unit">sec</span>
<button class="btn-save" type="button">Save</button>
</div>`;
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 => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
}[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();

View File

@ -0,0 +1,42 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sleep Meditation — Downloads</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<main class="app">
<header>
<h1>Downloads</h1>
<p>Manage tracks for Sleep Meditation. This page is internal only.</p>
</header>
<section class="card">
<label for="dl-url">Download track</label>
<input type="url" id="dl-url" placeholder="https://...">
<input type="text" id="dl-title" placeholder="Title (optional for YouTube)">
<button id="dl-btn" type="button">Download</button>
<progress id="dl-progress" value="0" max="100" hidden></progress>
<p id="dl-status" class="hint"></p>
</section>
<section class="card">
<label for="rename-select">Rename track</label>
<select id="rename-select" aria-label="Choose a track to rename"></select>
<input type="text" id="rename-input" placeholder="New title">
<button id="rename-btn" type="button">Save</button>
<p id="rename-status" class="hint"></p>
</section>
<section class="card">
<label>Manage downloads</label>
<div id="manage-list"><p class="hint">No tracks downloaded yet.</p></div>
</section>
</main>
<script src="/app.js" defer></script>
</body>
</html>

View File

@ -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; }

View File

@ -1,9 +1,17 @@
FROM nginx:1.27-alpine 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 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/ COPY site/ /usr/share/nginx/html/
EXPOSE 8000 EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ 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 wget -q -O /dev/null http://127.0.0.1:8000/ || exit 1
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"]

View File

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

View File

@ -13,8 +13,19 @@ server {
add_header Cache-Control "public, max-age=300"; add_header Cache-Control "public, max-age=300";
types { types {
audio/mpeg mp3; audio/mpeg mp3;
audio/mp4 m4a;
audio/aac aac;
audio/wav wav;
audio/ogg ogg;
application/json json; application/json json;
} }
autoindex on; 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;
}
} }

View File

@ -1,13 +1,8 @@
const fallbackPlaylist = [ const fallbackPlaylist = [];
{
title: "Example track",
artist: "Sleep Meditation",
src: "/mp3/example.mp3"
}
];
const supportedAudioPattern = /\.(mp3|m4a|aac|wav|ogg)$/i; const supportedAudioPattern = /\.(mp3|m4a|aac|wav|ogg)$/i;
// ── DOM refs ──────────────────────────────────────────────────────────────────
const player = document.querySelector("#player"); const player = document.querySelector("#player");
const select = document.querySelector("#playlist-select"); const select = document.querySelector("#playlist-select");
const prevBtn = document.querySelector("#prev"); const prevBtn = document.querySelector("#prev");
@ -15,23 +10,24 @@ const playBtn = document.querySelector("#play");
const nextBtn = document.querySelector("#next"); const nextBtn = document.querySelector("#next");
const restartBtn = document.querySelector("#restart"); const restartBtn = document.querySelector("#restart");
const downloadListEl = document.querySelector("#download-list");
const refreshBtn = document.querySelector("#refresh-btn");
// ── State ─────────────────────────────────────────────────────────────────────
let playlist = []; let playlist = [];
let currentIndex = 0; let currentIndex = 0;
let singleMode = false; // true while a downloaded track is playing
// ── Playlist helpers ──────────────────────────────────────────────────────────
function normalizePlaylist(list) { function normalizePlaylist(list) {
if (!Array.isArray(list)) return []; if (!Array.isArray(list)) return [];
return list return list
.filter((track) => track && typeof track.src === "string") .filter(t => t && typeof t.src === "string")
.map((track) => { .map(t => {
const normalizedSrc = track.src.startsWith("/") ? track.src : `/mp3/${track.src.replace(/^\/+/, "")}`; const src = t.src.startsWith("/") ? t.src : `/mp3/${t.src.replace(/^\/+/, "")}`;
return { return { title: t.title || toTrackTitle(src), artist: t.artist || "", src };
title: track.title || toTrackTitle(normalizedSrc),
artist: track.artist || "",
src: normalizedSrc
};
}) })
.filter((track) => supportedAudioPattern.test(track.src)); .filter(t => supportedAudioPattern.test(t.src));
} }
function toTrackTitle(pathname) { function toTrackTitle(pathname) {
@ -39,8 +35,49 @@ function toTrackTitle(pathname) {
return filename.replace(/\.[^/.]+$/, "").replace(/[_-]+/g, " ").trim() || "Track"; return filename.replace(/\.[^/.]+$/, "").replace(/[_-]+/g, " ").trim() || "Track";
} }
function clampIndex(index) { function clampIndex(i) {
return Math.min(Math.max(index, 0), playlist.length - 1); 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() { function resetToStart() {
@ -49,112 +86,36 @@ function resetToStart() {
player.currentTime = 0; 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;
}
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 setTrack(index, autoplay) { function setTrack(index, autoplay) {
if (playlist.length === 0) return; if (!playlist.length) return;
singleMode = false;
currentIndex = clampIndex(index); currentIndex = clampIndex(index);
const track = playlist[currentIndex]; const track = playlist[currentIndex];
player.src = track.src; player.src = track.src;
select.value = String(currentIndex); select.value = String(currentIndex);
setMediaSession(track.title, track.artist);
if (autoplay) player.play().catch(() => {});
}
if ("mediaSession" in navigator) { function setMediaSession(title, artist) {
if (!("mediaSession" in navigator)) return;
navigator.mediaSession.metadata = new MediaMetadata({ navigator.mediaSession.metadata = new MediaMetadata({
title: track.title || "Sleep Meditation", title: title || "Sleep Meditation",
artist: track.artist || "", artist: artist || "",
album: "Sleep Meditation" album: "Sleep Meditation",
}); });
} }
if (autoplay) { // ── Player controls ───────────────────────────────────────────────────────────
player.play().catch(() => { select.addEventListener("change", () => setTrack(Number(select.value), true));
// iOS requires a user interaction before playback can start.
});
}
}
select.addEventListener("change", () => {
setTrack(Number(select.value), true);
});
prevBtn.addEventListener("click", () => setTrack(currentIndex - 1, true)); prevBtn.addEventListener("click", () => setTrack(currentIndex - 1, true));
nextBtn.addEventListener("click", () => setTrack(currentIndex + 1, true)); nextBtn.addEventListener("click", () => setTrack(currentIndex + 1, true));
restartBtn.addEventListener("click", resetToStart); restartBtn.addEventListener("click", resetToStart);
playBtn.addEventListener("click", () => player.paused ? player.play() : player.pause());
playBtn.addEventListener("click", () => {
if (player.paused) {
player.play();
} else {
player.pause();
}
});
player.addEventListener("ended", () => { player.addEventListener("ended", () => {
if (currentIndex < playlist.length - 1) { if (singleMode) return;
setTrack(currentIndex + 1, true); if (currentIndex < playlist.length - 1) { setTrack(currentIndex + 1, true); return; }
return;
}
player.pause(); player.pause();
player.currentTime = 0; player.currentTime = 0;
}); });
@ -166,12 +127,55 @@ if ("mediaSession" in navigator) {
navigator.mediaSession.setActionHandler("nexttrack", () => setTrack(currentIndex + 1, true)); navigator.mediaSession.setActionHandler("nexttrack", () => setTrack(currentIndex + 1, true));
} }
if ("serviceWorker" in navigator) { // ── Download list (main page) ─────────────────────────────────────────────────
window.addEventListener("load", () => { async function loadDownloadList() {
navigator.serviceWorker.register("/service-worker.js").catch(() => { const tracks = await fetchDownloads();
// Service worker is optional for playback. if (!tracks.length) {
}); downloadListEl.innerHTML = "<p class='hint'>No tracks downloaded yet.</p>";
return;
}
downloadListEl.innerHTML = "";
tracks.forEach(t => {
const item = document.createElement("div");
item.className = "track-item";
item.innerHTML = `<span class="track-title">${t.title}</span>
<button type="button">&#9654; Play</button>`;
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(); loadPlaylist();
loadDownloadList();

View File

@ -10,9 +10,14 @@
<link rel="stylesheet" href="/styles.css"> <link rel="stylesheet" href="/styles.css">
</head> </head>
<body> <body>
<main class="app">
<!-- Player page -->
<main class="app" id="page-player">
<header> <header>
<div class="header-top">
<h1>Sleep Meditation</h1> <h1>Sleep Meditation</h1>
<button id="refresh-btn" type="button" class="btn-refresh" title="Refresh"></button>
</div>
<p>Play relaxing MP3 tracks, including iPhone lock-screen playback after manual start.</p> <p>Play relaxing MP3 tracks, including iPhone lock-screen playback after manual start.</p>
</header> </header>
@ -35,6 +40,12 @@
iPhone tip: tap Play once first. After that, audio usually continues on the lock screen. iPhone tip: tap Play once first. After that, audio usually continues on the lock screen.
</p> </p>
</section> </section>
<section class="card">
<label>Downloads</label>
<div id="download-list"><p class="hint">No tracks downloaded yet.</p></div>
</section>
</main> </main>
<script src="/app.js" defer></script> <script src="/app.js" defer></script>

View File

@ -7,6 +7,8 @@
--muted: #cde6d0; --muted: #cde6d0;
--accent: #95d5b2; --accent: #95d5b2;
--accent-2: #74c69d; --accent-2: #74c69d;
--danger: #e63946;
--danger-2: #c1121f;
} }
* { * {
@ -19,13 +21,23 @@ body {
color: var(--text); color: var(--text);
font-family: "Avenir Next", "Segoe UI", sans-serif; 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%); background: radial-gradient(circle at top right, #2d6a4f 0%, var(--bg-2) 35%, var(--bg-1) 100%);
display: grid; display: flex;
place-items: center; flex-direction: column;
align-items: center;
padding: 20px; padding: 20px;
} }
.app { .app {
width: min(680px, 100%); width: 100%;
max-width: 680px;
display: flex;
flex-direction: column;
gap: 16px;
min-width: 0;
}
.hidden {
display: none;
} }
h1 { h1 {
@ -34,8 +46,34 @@ h1 {
letter-spacing: 0.02em; 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 { header p {
margin: 8px 0 20px; margin: 0;
color: var(--muted); color: var(--muted);
} }
@ -45,8 +83,10 @@ header p {
border: 1px solid var(--panel-border); border: 1px solid var(--panel-border);
border-radius: 18px; border-radius: 18px;
padding: 18px; padding: 18px;
display: grid; display: flex;
flex-direction: column;
gap: 12px; gap: 12px;
min-width: 0;
} }
label { label {
@ -54,12 +94,14 @@ label {
} }
select, select,
input,
button, button,
audio { audio {
width: 100%; width: 100%;
} }
select, select,
input,
button { button {
border: 1px solid rgba(241, 250, 238, 0.25); border: 1px solid rgba(241, 250, 238, 0.25);
border-radius: 10px; border-radius: 10px;
@ -67,34 +109,23 @@ button {
font: inherit; font: inherit;
} }
select { select,
input {
background: rgba(0, 0, 0, 0.2); background: rgba(0, 0, 0, 0.2);
color: var(--text); color: var(--text);
} }
input::placeholder {
color: var(--muted);
opacity: 0.7;
}
.buttons { .buttons {
display: grid; display: grid;
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(4, 1fr);
gap: 10px; 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) { @media (max-width: 700px) {
.buttons { .buttons {
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
@ -106,3 +137,62 @@ button:hover {
grid-template-columns: 1fr; 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;
}

View File

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

View File

@ -2,24 +2,70 @@
## Scope ## 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 ## Architecture
- Service: `sleep-meditation` Two containers share a host volume containing all MP3 files and metadata.
- Runtime: `nginx:alpine`
- App: static frontend (`index.html`, `styles.css`, `app.js`) ### 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` - 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://<host>:${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 ## iPhone background playback
Important on iOS:
- Autoplay without user interaction is blocked. - Autoplay without user interaction is blocked.
- The user must start playback manually at least once. - The user must start playback manually at least once.
- After playback starts, audio usually continues when the screen is locked. - After playback starts, audio usually continues when the screen is locked.
- Media Session API enables lock-screen controls for play/pause/track skip. - 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 ## Player controls
- `Previous`: go to previous track (clamped at first track) - `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) - `Next`: go to next track (clamped at last track)
- `Back to start`: select first track, reset position to `0:00`, no autoplay - `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 there is a next track, it starts automatically.
- If the last track ends, playback stops (no loop). - If the last track ends, playback stops (no loop).
## File overview When a downloaded track ends:
``` - Playback stops. No auto-advance (`singleMode` flag is set when playing a download).
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
```
## Track loading behavior ## Track loading behavior
The app loads tracks in this order: The app loads meditation tracks in this order:
1. `/mp3/playlist.json` 1. `/mp3/playlist.json`
2. Auto-discovery from `/mp3/` directory listing (Nginx `autoindex on`) 2. Auto-discovery from `/mp3/` directory listing (Nginx `autoindex on`), excluding `/mp3/downloads/`
3. Built-in fallback track (`/mp3/example.mp3`) 3. Empty list (no fallback track)
Downloaded tracks are loaded separately via `GET /api/downloads`.
## Playlist contract ## Playlist contract
@ -76,7 +110,47 @@ Rules:
- `title` optional (derived from filename if omitted) - `title` optional (derived from filename if omitted)
- `artist` optional - `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 ## Container healthcheck

View File

@ -1,5 +1,51 @@
# Changelog (develop) # 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` (0100) and `phase`. UI shows a `<progress>` 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 ## 2026-03-22
- Initial repository created using the `story-grabber` layout. - Initial repository created using the `story-grabber` layout.

39
docs/changelog.md Normal file
View File

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

View File

@ -1,3 +1,5 @@
# Portainer stack variables # Portainer stack variables
SLEEP_MEDITATION_IMAGE_TAG=dev
SLEEP_MEDITATION_PORT=8100 SLEEP_MEDITATION_PORT=8100
SLEEP_MEDITATION_DOWNLOADER_PORT=8101
SLEEP_MEDITATION_MP3_PATH=/docker/appdata/sleep-meditation/mp3 SLEEP_MEDITATION_MP3_PATH=/docker/appdata/sleep-meditation/mp3

View File

@ -2,10 +2,21 @@ version: "3.8"
services: services:
sleep-meditation: 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 container_name: sleep-meditation
restart: unless-stopped restart: unless-stopped
ports: ports:
- "${SLEEP_MEDITATION_PORT}:8000" - "${SLEEP_MEDITATION_PORT}:8000"
volumes: volumes:
- ${SLEEP_MEDITATION_MP3_PATH}:/usr/share/nginx/html/mp3 - ${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

View File

@ -1 +0,0 @@
v0.1.5