Compare commits
4 Commits
d9cc5b8140
...
dc5b70c790
| Author | SHA1 | Date | |
|---|---|---|---|
| dc5b70c790 | |||
| 3c3d2f6d7b | |||
| a8452f90df | |||
| 0d9f20690f |
2
.gitignore
vendored
2
.gitignore
vendored
@ -1 +1 @@
|
||||
version.txt
|
||||
.files/
|
||||
|
||||
@ -1 +0,0 @@
|
||||
main
|
||||
13
README.md
13
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.
|
||||
|
||||
@ -1,84 +1,482 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# ============================================================================
|
||||
# build-and-push.sh
|
||||
#
|
||||
# Purpose:
|
||||
# - Build & push Docker images for each service under ./containers/*
|
||||
# - Branch model: `main` is permanent and tracked by the prod stack
|
||||
# (image `:latest`). `dev` is a short-lived branch tracked by the dev
|
||||
# stack (image `:dev`); it is recreated from `main` at the start of each
|
||||
# cycle and deleted after each release. Tags `vX.Y.Z` cover rollback —
|
||||
# no `release/<version>` snapshot branches.
|
||||
# - Two modes:
|
||||
# t (test) = build & push :dev. Switches to (or creates) the `dev`
|
||||
# branch from `main` if not already on it, commits any
|
||||
# pending changes, and pushes `dev` to origin so the dev
|
||||
# stack picks up the matching compose/config alongside
|
||||
# the :dev image.
|
||||
# r (release) = MUST be run from `main`. Build & push :<version>,
|
||||
# :dev, :latest. Fast-forwards `main` against
|
||||
# `origin/main`, merges `dev` into `main` if it exists
|
||||
# (local or remote), creates a `Release vX.Y.Z` commit,
|
||||
# tags it, pushes `main` and the tag, and deletes the
|
||||
# `dev` branch locally and on origin. Version is read
|
||||
# from the top of changelog.md.
|
||||
#
|
||||
# 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
|
||||
#
|
||||
# ----------------------------------------------------------------------------
|
||||
# Self-update notice (for humans AND for Claude):
|
||||
#
|
||||
# The canonical/master copy of this script lives at:
|
||||
# /docker/develop/shared-integrations/tooling/docker-build-and-push/build-and-push.sh
|
||||
#
|
||||
# When this script is copied into a project repo, it may drift from master.
|
||||
# On startup the script compares its own SCRIPT_VERSION to the master copy and
|
||||
# warns if a newer version exists. It does NOT auto-overwrite (too risky during
|
||||
# a release run).
|
||||
#
|
||||
# CLAUDE INSTRUCTION:
|
||||
# If you (Claude) are about to invoke this script in a project repo, first
|
||||
# check whether the master copy at the path above is newer (different
|
||||
# SCRIPT_VERSION or differing file contents). If so, copy the master over
|
||||
# the local copy BEFORE running, then proceed. Mention the update to the user.
|
||||
# ============================================================================
|
||||
|
||||
SCRIPT_VERSION="1.7.0"
|
||||
SCRIPT_MASTER_PATH="/docker/develop/shared-integrations/tooling/docker-build-and-push/build-and-push.sh"
|
||||
|
||||
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)."
|
||||
# --- Self-update check -------------------------------------------------------
|
||||
# Compare this script to the canonical master copy. If it differs, offer to
|
||||
# copy master over the local copy and re-exec with the same arguments so the
|
||||
# build runs against the up-to-date script.
|
||||
#
|
||||
# Skip with: SKIP_SELF_UPDATE=1 ./build-and-push.sh ...
|
||||
self_update_check() {
|
||||
if [[ "${SKIP_SELF_UPDATE:-0}" == "1" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
local self_path="${BASH_SOURCE[0]}"
|
||||
# Resolve to absolute path so a comparison against itself is detected.
|
||||
local self_abs
|
||||
self_abs="$(cd "$(dirname "$self_path")" 2>/dev/null && pwd)/$(basename "$self_path")" || self_abs="$self_path"
|
||||
|
||||
if [[ "$self_abs" == "$SCRIPT_MASTER_PATH" ]]; then
|
||||
return 0 # We ARE the master copy.
|
||||
fi
|
||||
if [[ ! -f "$SCRIPT_MASTER_PATH" ]]; then
|
||||
return 0 # Master not reachable from this host; silently skip.
|
||||
fi
|
||||
|
||||
local master_version reason=""
|
||||
master_version="$(grep -m1 -E '^SCRIPT_VERSION=' "$SCRIPT_MASTER_PATH" | sed -E 's/.*"([^"]+)".*/\1/')"
|
||||
|
||||
if [[ -n "$master_version" && "$master_version" != "$SCRIPT_VERSION" ]]; then
|
||||
reason="version"
|
||||
elif ! cmp -s "$self_abs" "$SCRIPT_MASTER_PATH"; then
|
||||
reason="contents"
|
||||
else
|
||||
return 0 # Identical to master.
|
||||
fi
|
||||
|
||||
echo "[WARN] Local build-and-push.sh differs from master."
|
||||
if [[ "$reason" == "version" ]]; then
|
||||
echo " local : $SCRIPT_VERSION"
|
||||
echo " master : $master_version ($SCRIPT_MASTER_PATH)"
|
||||
else
|
||||
echo " Same SCRIPT_VERSION ($SCRIPT_VERSION) but file contents differ."
|
||||
echo " master : $SCRIPT_MASTER_PATH"
|
||||
fi
|
||||
|
||||
# Prompt only when stdin is a TTY; in non-interactive runs, abort safely so
|
||||
# an unattended release never silently runs against a stale script.
|
||||
if [[ ! -t 0 ]]; then
|
||||
echo "[ERROR] Non-interactive shell — refusing to auto-update."
|
||||
echo " Re-run interactively, or set SKIP_SELF_UPDATE=1 to bypass,"
|
||||
echo " or update manually: cp \"$SCRIPT_MASTER_PATH\" \"$self_abs\""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local reply
|
||||
read -r -p "Update local script from master and re-run? [Y/n] " reply
|
||||
reply="${reply:-Y}"
|
||||
if [[ ! "$reply" =~ ^[Yy]$ ]]; then
|
||||
echo "[INFO] Continuing with local version $SCRIPT_VERSION (not updated)."
|
||||
echo ""
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ! cp "$SCRIPT_MASTER_PATH" "$self_abs"; then
|
||||
echo "[ERROR] Failed to copy master to $self_abs (read-only filesystem?)."
|
||||
echo " Continuing with local version $SCRIPT_VERSION."
|
||||
echo ""
|
||||
return 0
|
||||
fi
|
||||
chmod +x "$self_abs" 2>/dev/null || true
|
||||
|
||||
echo "[INFO] Updated $self_abs from master. Re-executing..."
|
||||
echo ""
|
||||
# Re-exec with original arguments. SKIP_SELF_UPDATE=1 prevents an
|
||||
# update loop if cp somehow didn't take.
|
||||
export SKIP_SELF_UPDATE=1
|
||||
exec "$self_abs" "$@"
|
||||
}
|
||||
self_update_check "$@"
|
||||
|
||||
# --- 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<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
|
||||
echo "[ERROR] '$CONTAINERS_DIR' directory missing."
|
||||
echo "[ERROR] '$CONTAINERS_DIR' directory missing. Expected ./${CONTAINERS_DIR}/<service>/ 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
|
||||
# --- Release preflight (BEFORE any docker work) ------------------------------
|
||||
# All git-side validation for a release happens here so a wrong-branch / dirty
|
||||
# tree / stale main / conflicting dev / pre-existing tag aborts the run before
|
||||
# anything is built or pushed to the registry. dev is merged into main now so
|
||||
# the version we read from changelog.md reflects the merged state, not main's
|
||||
# pre-merge state.
|
||||
VERSION=""
|
||||
DEV_MERGED=0
|
||||
if [[ "$MODE" == "r" ]]; then
|
||||
if [[ ! -d ".git" ]]; then
|
||||
echo "[ERROR] Release mode requires a git checkout."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CURRENT_BRANCH="$(git symbolic-ref --short -q HEAD || echo)"
|
||||
if [[ "$CURRENT_BRANCH" != "main" ]]; then
|
||||
echo "[ERROR] Release build must run from 'main' branch. Current: ${CURRENT_BRANCH:-<detached>}."
|
||||
echo " Switch with: git checkout main"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! git diff --quiet HEAD -- || ! git diff --cached --quiet; then
|
||||
echo "[ERROR] Working tree has uncommitted changes. Commit or stash them on the appropriate branch before releasing."
|
||||
git status --short
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[INFO] Fetching origin..."
|
||||
git fetch origin main
|
||||
if git ls-remote --exit-code --heads origin dev >/dev/null 2>&1; then
|
||||
git fetch origin dev
|
||||
fi
|
||||
|
||||
if ! git merge --ff-only origin/main 2>/dev/null; then
|
||||
echo "[ERROR] Local main has diverged from origin/main. Resolve manually before releasing."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Merge dev into main BEFORE reading the version, so changelog.md reflects
|
||||
# the bumped state that dev brings in.
|
||||
if git show-ref --verify --quiet refs/heads/dev; then
|
||||
echo "[INFO] Merging local dev into main..."
|
||||
if ! git merge --no-ff dev -m "Release (merge dev)"; then
|
||||
echo "[ERROR] Merge of dev into main failed (conflict). Resolve manually and re-run."
|
||||
exit 1
|
||||
fi
|
||||
DEV_MERGED=1
|
||||
elif git ls-remote --exit-code --heads origin dev >/dev/null 2>&1; then
|
||||
echo "[INFO] Fetching and merging origin/dev into main..."
|
||||
git fetch origin dev:dev
|
||||
if ! git merge --no-ff dev -m "Release (merge dev)"; then
|
||||
echo "[ERROR] Merge of dev into main failed (conflict). Resolve manually and re-run."
|
||||
exit 1
|
||||
fi
|
||||
DEV_MERGED=1
|
||||
else
|
||||
echo "[INFO] No dev branch found — releasing main as-is."
|
||||
fi
|
||||
|
||||
VERSION="$(read_version_from_changelog)"
|
||||
echo "[INFO] Release version (from $CHANGELOG_FILE, post-merge): $VERSION"
|
||||
validate_tag "$VERSION"
|
||||
validate_tag "latest"
|
||||
|
||||
# Tag collision = abort. A re-release of an existing version with different
|
||||
# content would silently move what consumers think v0.X.Y points to.
|
||||
if git rev-parse -q --verify "refs/tags/${VERSION}" >/dev/null; then
|
||||
echo "[ERROR] Tag ${VERSION} already exists locally. Bump $CHANGELOG_FILE to a new version before releasing."
|
||||
exit 1
|
||||
fi
|
||||
if git ls-remote --exit-code --tags origin "refs/tags/${VERSION}" >/dev/null 2>&1; then
|
||||
echo "[ERROR] Tag ${VERSION} already exists on origin. Bump $CHANGELOG_FILE to a new version before releasing."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 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. Note: dev has been merged into local main; reset with 'git reset --hard origin/main' if you want to undo."
|
||||
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 ""
|
||||
|
||||
# --- Git: release commit + tag + push (release mode only) -------------------
|
||||
# Preflight (branch, clean tree, ff origin/main, dev merge, tag collision,
|
||||
# version parse) already ran BEFORE the build. dev is already merged into
|
||||
# local main. We only need to land the Release commit, tag, and push.
|
||||
if [[ "$MODE" == "r" ]]; then
|
||||
echo "[INFO] Finalising release: version=${VERSION}"
|
||||
|
||||
# Produce a clean Release commit at the tip. Preflight guarantees the working
|
||||
# tree was clean at start; any post-build artefacts would be unexpected, so
|
||||
# commit with --allow-empty to keep the release marker isolated.
|
||||
if git diff --quiet HEAD -- && git diff --cached --quiet; then
|
||||
git commit --allow-empty -m "Release ${VERSION}"
|
||||
else
|
||||
echo "[WARN] Working tree changed during the build — staging and including in release commit."
|
||||
git add -A
|
||||
git commit -m "Release ${VERSION}"
|
||||
fi
|
||||
|
||||
git tag -a "${VERSION}" -m "Release ${VERSION}"
|
||||
|
||||
# Push main first (triggers prod webhook), then the tag.
|
||||
git push origin main
|
||||
git push origin "refs/tags/${VERSION}"
|
||||
echo "[INFO] Pushed main and tag ${VERSION} to origin."
|
||||
|
||||
# Clean up dev branch — local and remote.
|
||||
if [[ "$DEV_MERGED" == "1" ]]; then
|
||||
if git show-ref --verify --quiet refs/heads/dev; then
|
||||
git branch -D dev
|
||||
echo "[INFO] Deleted local dev branch."
|
||||
fi
|
||||
if git ls-remote --exit-code --heads origin dev >/dev/null 2>&1; then
|
||||
git push origin --delete dev
|
||||
echo "[INFO] Deleted remote dev branch."
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Git: dev branch commit + push (test mode only) -------------------------
|
||||
if [[ "$MODE" == "t" ]]; then
|
||||
if [[ ! -d ".git" ]]; then
|
||||
echo "[WARN] Not a git checkout — skipping dev branch commit/push."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
CURRENT_BRANCH="$(git symbolic-ref --short -q HEAD || echo)"
|
||||
|
||||
# Ensure we are on the dev branch. Create it if needed.
|
||||
if [[ "$CURRENT_BRANCH" != "dev" ]]; then
|
||||
if git show-ref --verify --quiet refs/heads/dev; then
|
||||
echo "[INFO] Switching to existing local dev branch."
|
||||
git checkout dev
|
||||
elif git ls-remote --exit-code --heads origin dev >/dev/null 2>&1; then
|
||||
echo "[INFO] Checking out remote dev branch."
|
||||
git fetch origin dev
|
||||
git checkout -b dev origin/dev
|
||||
else
|
||||
echo "[INFO] Creating new dev branch from main."
|
||||
git fetch origin main
|
||||
git checkout -b dev origin/main
|
||||
fi
|
||||
fi
|
||||
|
||||
# Stage and commit if there are changes.
|
||||
git add -A
|
||||
if git diff --cached --quiet; then
|
||||
echo "[INFO] Working tree clean — pushing current HEAD to dev."
|
||||
else
|
||||
git commit -m "Dev build $(date '+%Y-%m-%d %H:%M')"
|
||||
fi
|
||||
|
||||
# Non-force push. Diverged origin/dev fails hard — resolve manually.
|
||||
git push -u origin dev
|
||||
echo "[INFO] Pushed dev to origin."
|
||||
fi
|
||||
|
||||
14
containers/sleep-meditation-downloader/Dockerfile
Normal file
14
containers/sleep-meditation-downloader/Dockerfile
Normal 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"]
|
||||
413
containers/sleep-meditation-downloader/api.py
Normal file
413
containers/sleep-meditation-downloader/api.py
Normal 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)
|
||||
231
containers/sleep-meditation-downloader/site/app.js
Normal file
231
containers/sleep-meditation-downloader/site/app.js
Normal 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 => ({
|
||||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
||||
}[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();
|
||||
42
containers/sleep-meditation-downloader/site/index.html
Normal file
42
containers/sleep-meditation-downloader/site/index.html
Normal 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>
|
||||
198
containers/sleep-meditation-downloader/site/styles.css
Normal file
198
containers/sleep-meditation-downloader/site/styles.css
Normal 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; }
|
||||
@ -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"]
|
||||
|
||||
56
containers/sleep-meditation/api.py
Normal file
56
containers/sleep-meditation/api.py
Normal 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)
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,13 +1,8 @@
|
||||
const fallbackPlaylist = [
|
||||
{
|
||||
title: "Example track",
|
||||
artist: "Sleep Meditation",
|
||||
src: "/mp3/example.mp3"
|
||||
}
|
||||
];
|
||||
const fallbackPlaylist = [];
|
||||
|
||||
const supportedAudioPattern = /\.(mp3|m4a|aac|wav|ogg)$/i;
|
||||
|
||||
// ── DOM refs ──────────────────────────────────────────────────────────────────
|
||||
const player = document.querySelector("#player");
|
||||
const select = document.querySelector("#playlist-select");
|
||||
const prevBtn = document.querySelector("#prev");
|
||||
@ -15,23 +10,24 @@ const playBtn = document.querySelector("#play");
|
||||
const nextBtn = document.querySelector("#next");
|
||||
const restartBtn = document.querySelector("#restart");
|
||||
|
||||
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,112 +86,36 @@ 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;
|
||||
}
|
||||
|
||||
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) {
|
||||
if (playlist.length === 0) return;
|
||||
|
||||
if (!playlist.length) return;
|
||||
singleMode = false;
|
||||
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.
|
||||
});
|
||||
}
|
||||
setMediaSession(track.title, track.artist);
|
||||
if (autoplay) player.play().catch(() => {});
|
||||
}
|
||||
|
||||
select.addEventListener("change", () => {
|
||||
setTrack(Number(select.value), true);
|
||||
});
|
||||
function setMediaSession(title, artist) {
|
||||
if (!("mediaSession" in navigator)) return;
|
||||
navigator.mediaSession.metadata = new MediaMetadata({
|
||||
title: title || "Sleep Meditation",
|
||||
artist: artist || "",
|
||||
album: "Sleep Meditation",
|
||||
});
|
||||
}
|
||||
|
||||
// ── 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;
|
||||
});
|
||||
@ -166,12 +127,79 @@ if ("mediaSession" in navigator) {
|
||||
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.
|
||||
// ── AirPlay / Remote Playback ─────────────────────────────────────────────────
|
||||
const airplayBtn = document.querySelector("#airplay");
|
||||
|
||||
function initAirplay() {
|
||||
if (!airplayBtn) return;
|
||||
if (window.WebKitPlaybackTargetAvailabilityEvent) {
|
||||
// Safari (iOS/macOS): native AirPlay picker
|
||||
player.addEventListener("webkitplaybacktargetavailabilitychanged", (e) => {
|
||||
airplayBtn.hidden = e.availability !== "available";
|
||||
});
|
||||
player.addEventListener("webkitcurrentplaybacktargetiswirelesschanged", () => {
|
||||
airplayBtn.classList.toggle("airplay-active", player.webkitCurrentPlaybackTargetIsWireless);
|
||||
});
|
||||
airplayBtn.addEventListener("click", () => player.webkitShowPlaybackTargetPicker());
|
||||
} else if (player.remote && player.remote.watchAvailability) {
|
||||
// Remote Playback API (Chrome/Edge)
|
||||
player.remote.watchAvailability(available => { airplayBtn.hidden = !available; }).catch(() => {});
|
||||
player.remote.addEventListener("connect", () => airplayBtn.classList.add("airplay-active"));
|
||||
player.remote.addEventListener("disconnect", () => airplayBtn.classList.remove("airplay-active"));
|
||||
airplayBtn.addEventListener("click", () => player.remote.prompt().catch(() => {}));
|
||||
}
|
||||
}
|
||||
initAirplay();
|
||||
|
||||
// ── Download list (main page) ─────────────────────────────────────────────────
|
||||
async function loadDownloadList() {
|
||||
const tracks = await fetchDownloads();
|
||||
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">▶ 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();
|
||||
loadDownloadList();
|
||||
|
||||
BIN
containers/sleep-meditation/site/apple-touch-icon.png
Normal file
BIN
containers/sleep-meditation/site/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
@ -7,12 +7,18 @@
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<title>Sleep Meditation</title>
|
||||
<link rel="manifest" href="/manifest.webmanifest">
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="app">
|
||||
|
||||
<!-- Player page -->
|
||||
<main class="app" id="page-player">
|
||||
<header>
|
||||
<div class="header-top">
|
||||
<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>
|
||||
</header>
|
||||
|
||||
@ -20,7 +26,7 @@
|
||||
<label for="playlist-select">Playlist</label>
|
||||
<select id="playlist-select" aria-label="Choose a track"></select>
|
||||
|
||||
<audio id="player" controls preload="metadata" playsinline>
|
||||
<audio id="player" controls preload="metadata" playsinline x-webkit-airplay="allow">
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
|
||||
@ -29,12 +35,25 @@
|
||||
<button id="play" type="button">Play/Pause</button>
|
||||
<button id="next" type="button">Next</button>
|
||||
<button id="restart" type="button">Back to start</button>
|
||||
<button id="airplay" type="button" hidden>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M5 17H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2h-1"/>
|
||||
<polygon points="12 15 17 21 7 21 12 15"/>
|
||||
</svg>AirPlay
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="hint">
|
||||
iPhone tip: tap Play once first. After that, audio usually continues on the lock screen.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<label>Downloads</label>
|
||||
<div id="download-list"><p class="hint">No tracks downloaded yet.</p></div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<script src="/app.js" defer></script>
|
||||
|
||||
@ -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,74 @@ 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;
|
||||
}
|
||||
|
||||
#airplay svg {
|
||||
width: 1.1em;
|
||||
height: 1.1em;
|
||||
vertical-align: -0.15em;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
#airplay.airplay-active {
|
||||
color: #fff;
|
||||
background: linear-gradient(180deg, #4ea8de, #2a6f97);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
23
containers/sleep-meditation/supervisord.conf
Normal file
23
containers/sleep-meditation/supervisord.conf
Normal 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
|
||||
@ -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://<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
|
||||
|
||||
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
|
||||
|
||||
|
||||
@ -1,5 +1,65 @@
|
||||
# Changelog (develop)
|
||||
|
||||
## 2026-06-06 — Released as v0.1.7
|
||||
|
||||
## 2026-06-06 — AirPlay button
|
||||
|
||||
### Added
|
||||
|
||||
- AirPlay button in the player button row (`index.html`, `app.js`, `styles.css`). Hidden by default; shown only when wireless playback targets are available. Safari/iOS uses the WebKit AirPlay API (`webkitplaybacktargetavailabilitychanged` + `webkitShowPlaybackTargetPicker()`); other browsers fall back to the Remote Playback API (`remote.watchAvailability()` + `remote.prompt()`). Button highlights blue while AirPlay output is active (`webkitcurrentplaybacktargetiswirelesschanged` / `connect`/`disconnect` events).
|
||||
- `x-webkit-airplay="allow"` attribute on the `<audio>` element to explicitly permit AirPlay streaming.
|
||||
|
||||
### Changed
|
||||
|
||||
- `build-and-push.sh` synced from shared master (1.2.0 → 1.7.0); test builds now also manage a dev branch from main.
|
||||
- New `.gitignore` (ignores `.files/`); `apple-touch-icon.png` (added earlier for home-screen bookmark icon) now committed.
|
||||
|
||||
## 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 `<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
|
||||
|
||||
- Initial repository created using the `story-grabber` layout.
|
||||
|
||||
45
docs/changelog.md
Normal file
45
docs/changelog.md
Normal file
@ -0,0 +1,45 @@
|
||||
# Changelog
|
||||
|
||||
## v0.1.7 — 2026-06-06
|
||||
|
||||
- Added AirPlay button to the player: opens the system speaker picker (AirPlay devices and paired Bluetooth speakers on iOS). Shown only when wireless playback targets are available; highlights while wireless output is active. Safari uses the native WebKit AirPlay API, other browsers the Remote Playback API.
|
||||
- `build-and-push.sh` synced from shared master (1.2.0 → 1.7.0).
|
||||
- Added `.gitignore` (`.files/`) and committed `apple-touch-icon.png` for home-screen bookmarks.
|
||||
|
||||
## 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.
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -1 +0,0 @@
|
||||
v0.1.5
|
||||
Loading…
Reference in New Issue
Block a user