diff --git a/.github/workflows/howto.yml b/.github/workflows/howto.yml new file mode 100644 index 00000000..5f9469ba --- /dev/null +++ b/.github/workflows/howto.yml @@ -0,0 +1,130 @@ +name: How-To docs + +# Runs on every pull request (open + each push) and on manual dispatch. +# It stands up a throwaway AudioMuse-AI stack (prebuilt image + empty Postgres + +# Redis), drives a headless browser over every page with all data mocked in the +# browser, renders the version-stamped howto.md, validates it, and uploads the +# whole docs/howto// folder as a build artifact for review. +# +# It does NOT commit anything and never touches main: you download the artifact, +# and if you like the result you commit it yourself. +# +# The version (and therefore the docs/howto/ folder, with the leading +# "v" stripped) is read from APP_VERSION in config.py — not from any git tag. + +on: + pull_request: + types: [opened, synchronize, reopened] + workflow_dispatch: + inputs: + image: + description: 'Override the app image (default ghcr.io/neptunehub/audiomuse-ai:, falls back to :latest)' + required: false + +permissions: + contents: write + packages: read + +jobs: + capture: + runs-on: ubuntu-latest + steps: + - name: Checkout (PR head branch, so refreshed docs can be pushed back) + uses: actions/checkout@v4 + with: + ref: ${{ github.head_ref || github.ref_name }} + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Resolve version from config.py + id: ver + run: | + OUT=$(python docs/howto/_tooling/_version.py) + V=$(echo "$OUT" | awk '{print $1}') + NUM=$(echo "$OUT" | awk '{print $2}') + echo "version=$V" >> "$GITHUB_OUTPUT" + echo "num=$NUM" >> "$GITHUB_OUTPUT" + echo "config.py APP_VERSION: $V -> docs/howto/$NUM" + + - name: Pick app image (version tag, else latest) + id: img + run: | + OVERRIDE="${{ github.event.inputs.image }}" + if [ -n "$OVERRIDE" ]; then + IMG="$OVERRIDE" + else + IMG="ghcr.io/neptunehub/audiomuse-ai:${{ steps.ver.outputs.version }}" + if ! docker manifest inspect "$IMG" >/dev/null 2>&1; then + echo "::warning::$IMG not found, falling back to :latest" + IMG="ghcr.io/neptunehub/audiomuse-ai:latest" + fi + fi + echo "image=$IMG" >> "$GITHUB_OUTPUT" + echo "Using image: $IMG" + + - name: Start app stack + env: + HOWTO_IMAGE: ${{ steps.img.outputs.image }} + run: docker compose -f docs/howto/_tooling/docker-compose.howto.yml up -d + + - name: Wait for /api/health + run: | + for i in $(seq 1 60); do + if curl -fsS http://localhost:8000/api/health >/dev/null 2>&1; then + echo "App is up after ${i} tries." + exit 0 + fi + sleep 5 + done + echo "App did not become healthy in time." + docker compose -f docs/howto/_tooling/docker-compose.howto.yml logs --tail=200 flask + exit 1 + + - name: Install Playwright + Chromium + run: | + pip install -r docs/howto/_tooling/requirements.txt + python -m playwright install --with-deps chromium + + - name: Capture screenshots (all data mocked in the browser) + run: | + python docs/howto/_tooling/howto_capture.py \ + --base-url http://localhost:8000 \ + --user admin --password adminpass \ + --mock-all --browser-channel "" + + - name: Render + validate howto.md + run: | + python docs/howto/_tooling/render_howto.py + python docs/howto/_tooling/validate_howto.py + + # Push the regenerated docs back to the PR branch (never main). Authenticated + # with GITHUB_TOKEN, so this push does NOT trigger another workflow run. + # Only commits when something actually changed. + - name: Commit refreshed docs to the PR branch + if: ${{ github.event_name == 'pull_request' }} + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add docs/howto/${{ steps.ver.outputs.num }} + if git diff --cached --quiet; then + echo "No documentation changes to commit." + else + git commit -m "docs(howto): refresh guide for ${{ steps.ver.outputs.version }}" + git push origin "HEAD:${{ github.head_ref }}" + fi + + - name: Tear down stack + if: ${{ always() }} + run: docker compose -f docs/howto/_tooling/docker-compose.howto.yml down -v + + - name: Upload how-to bundle for review + if: ${{ always() }} + uses: actions/upload-artifact@v4 + with: + name: howto-${{ steps.ver.outputs.num }} + path: docs/howto/${{ steps.ver.outputs.num }}/ + if-no-files-found: error diff --git a/config.py b/config.py index 8f4ff9a6..3cbaedb8 100644 --- a/config.py +++ b/config.py @@ -107,7 +107,7 @@ def _compute_headers(): # --- General Constants (Read from Environment Variables where applicable) --- -APP_VERSION = "v2.1.4" +APP_VERSION = "v2.1.5" MAX_DISTANCE = float(os.environ.get("MAX_DISTANCE", "0.5")) MAX_SONGS_PER_CLUSTER = int(os.environ.get("MAX_SONGS_PER_CLUSTER", "0")) MAX_SONGS_PER_ARTIST = int(os.getenv("MAX_SONGS_PER_ARTIST", "3")) # Max songs per artist in similarity results and clustering diff --git a/docs/howto/2.1.5/howto.md b/docs/howto/2.1.5/howto.md new file mode 100644 index 00000000..a18dcd50 --- /dev/null +++ b/docs/howto/2.1.5/howto.md @@ -0,0 +1,423 @@ +# AudioMuse-AI — How-To Guide + +*Where Music Takes Shape.* A walkthrough of every page and feature, with screenshots. + +**Application version v2.1.5** + +> **About the screenshots.** Every track title, artist and album name shown in this guide has been replaced with neutral placeholders (`Song Title 1`, `Artist Name 1`, `Album Name 1`) to avoid reproducing copyrighted metadata. Server URLs, user IDs, tokens and passwords are redacted. A few "result" screenshots use representative placeholder data so the success state can be shown without running heavy jobs. + +> **The golden rule:** AudioMuse-AI can only recommend tracks it has *analysed*. Run [Analysis and Clustering](#analysis-and-clustering) first (or schedule it under [Scheduled Tasks](#scheduled-tasks)); every other feature draws on that data. + +## Contents + +**Getting started** +- [Logging in](#logging-in) +- [Dashboard](#dashboard) + +**Build your library data** +- [Analysis and Clustering](#analysis-and-clustering) + +**Create playlists** +- [Instant Playlist](#instant-playlist) +- [Playlist from Similar Song](#playlist-from-similar-song) +- [Artist Similarity](#artist-similarity) +- [Song Path](#song-path) +- [Song Alchemy](#song-alchemy) +- [Text Search (DCLAP)](#text-search-dclap) +- [Lyrics Search](#lyrics-search) +- [Sonic Fingerprint](#sonic-fingerprint) + +**Explore** +- [Music Map](#music-map) +- [Waveform](#waveform) + +**Administration (admin only)** +- [Scheduled Tasks](#scheduled-tasks) +- [Database Cleaning](#database-cleaning) +- [Backup and Restore](#backup-and-restore) +- [Provider Migration](#provider-migration) +- [Setup Wizard](#setup-wizard) +- [Users](#users) + +--- + +## Logging in + +**Route:** `/login` + +When authentication is enabled, AudioMuse-AI presents a sign-in screen. Enter the username and password of an account created in the [Setup Wizard](#setup-wizard) or on the [Users](#users) page. A session cookie keeps you signed in for 8 hours. + +**How to use it** +1. Open the AudioMuse-AI URL in your browser. +2. Type your **username** and **password**. +3. Click **Sign In** — you land on the Dashboard. + +![Login screen](screenshots/00-login.png) +*The sign-in screen. Admins see every page; normal users see everything except the Administration menu.* + +> **Roles.** **Admins** have full access and can manage other users. **Normal users** can use all the playlist / exploration tools but cannot see the Administration menu (Analysis, Cleaning, Scheduled Tasks, Backup, Provider Migration, Setup, Users). + +--- + +## Dashboard + +**Route:** `/` + +The landing page summarises your library and the system at a glance: key counts (songs, artists, albums and how much of the library is indexed for each model), the live queue workers, the last completed batch tasks, content charts (genres, mood coverage, tempo) and the configured scheduled tasks. It refreshes automatically every 30 seconds. + +**What to look for** +1. **Total Songs / Artists / Albums** — with the percentage indexed by each model (Musicnn, CLAP, GMM). +2. **Queue Workers** — each worker container runs a high-priority and a default worker, so one node usually shows two rows. +3. **Last 10 completed batch tasks** — type, status, duration and a short note per run. +4. **Content / Library charts** — genre distribution, mood coverage and tempo profile of the analysed library. + +![Dashboard](screenshots/01-dashboard.png) +*Key numbers, queue workers, recent tasks, genre/mood/tempo charts and scheduled tasks.* + +> **Tip:** Low index percentages mean you still have library to analyse — head to [Analysis and Clustering](#analysis-and-clustering). + +--- + +## Analysis and Clustering + +**Route:** `/analysis` · **admin only** + +This is the engine room. **Analysis** scans recently-added albums on your media server and computes the acoustic features, embeddings and mood vectors that power every other feature. **Clustering** then groups the analysed library into automatically-named playlists (optionally using an AI provider to name them). Both run as background jobs whose progress you can follow live. + +**How to use it** +1. Set **Number of Recent Albums** to analyse (small for a first run, or large to catch up). +2. Click **Start Analysis** and watch *Task Status* — type, running time, progress bar and a live log. +3. Set the clustering parameters (algorithm, number of playlists, clustering runs) and, optionally, an **AI provider** for playlist names. +4. Click **Start Clustering**; when it finishes, the generated playlists appear under *Generated Playlists*. +5. Use **Fetch Playlists** at any time to list the playlists already generated. + +![Analysis run result](screenshots/02a-analysis-result.png) +*Analysis parameters and the task status after an analysis run completes.* + +![Clustering run result](screenshots/02b-clustering-result.png) +*Clustering parameters, AI naming, and the resulting auto-generated playlists.* + +> **Basic vs Advanced.** Toggle the **Advanced** view to expose the full set of tunables; **Basic** keeps just the essentials. A run can be stopped with **Cancel**. + +> **Tip:** Prefer set-and-forget? Configure recurring runs on the [Scheduled Tasks](#scheduled-tasks) page instead of starting them by hand. + +--- + +## Instant Playlist + +**Route:** `/chat/` + +Describe the vibe you want in plain language and let an AI assistant build a playlist for you. Behind the scenes it plans a chain of internal tools (similarity, search, brainstorming, lyrics) and returns up to 100 matching songs from *your* library, which you can then push to your media server as a real playlist. + +**How to use it** +1. Choose an **AI Provider** (Ollama, OpenAI-compatible, Gemini, Mistral…). The model and server fields adapt to your choice. +2. Type what you want — e.g. *"Calm rainy-day piano songs for focus"* — or click a suggestion chip (Similar Song, Multi-Song Mix, From Artist, Sound/Vibe, Lyrics Theme…). +3. Click **Get Playlist Idea** and watch the live pipeline steps. +4. Review the generated list, give it a name, and click **Create Playlist** to save it on your media server. + +![Instant Playlist form](screenshots/03-instant-playlist.png) +*Pick an AI provider, type a request (or tap a suggestion chip), then ask for a playlist.* + +![Instant Playlist AI result](screenshots/03b-instant-playlist-result.png) +*The AI returns a tool chain, a collapsible raw log, and the generated playlist.* + +> **Tip:** Keep prompts short and specific — naming 1–3 seed songs or artists plus a couple of filters (genre, year, mood) works better than a long paragraph. + +--- + +## Playlist from Similar Song + +**Route:** `/similarity` + +Pick a seed — a specific **song**, a **mood** centroid, or a saved **anchor** — and AudioMuse-AI finds the tracks that sound closest to it, ranked by acoustic distance. Turn the results into a playlist in one click. + +**How to use it** +1. Use the mode toggle to pick **Song**, **Mood** or **Anchor**. +2. In Song mode, start typing (3+ characters) and pick a track from the autocomplete. +3. Set **Number of results**; optionally enable **Radius Similarity**. +4. Click **Find Similar Tracks** to see the ranked list. +5. Give the playlist a name and click **Create Playlist on Media Server**. + +![Song autocomplete](screenshots/04b-similarity-autocomplete.png) +*Start typing to find a seed song; suggestions appear with title, artist and album.* + +![Similar tracks results](screenshots/04-similarity.png) +*Ranked similar tracks with mood/genre tags and a distance badge (smaller = more similar).* + +--- + +## Artist Similarity + +**Route:** `/artist_similarity` + +Find artists whose overall sound profile resembles a chosen artist, using Gaussian Mixture Models. Optionally see *why* they match — the shared "components" of their sound, with representative example songs from each — and build a playlist from an artist's tracks or the matching songs. + +**How to use it** +1. Type an artist name (2+ characters) and select it from the list. +2. Set how many similar artists to return; keep **Show component matches** ticked to see the reasoning. +3. Click **Find Similar Artists**. +4. Use **Show All Songs** or **Show Matches** on any row to expand its tracks. +5. Name the playlist and click **Create Playlist on Media Server**. + +![Artist search](screenshots/05b-artist-autocomplete.png) +*Type to find an artist; the suggestion shows each artist's track count.* + +![Similar artists results](screenshots/05-artist-similarity.png) +*Ranked similar artists with a similarity score and expandable song / component matches.* + +--- + +## Song Path + +**Route:** `/path` + +Build a smooth, gradually-shifting sequence of songs that travels from a **start** point to an **end** point. Each endpoint can be a song, a lyrics-seed song, a mood or an anchor. The result is charted as a feature progression and a 2-D route, and can be saved as a playlist. + +**How to use it** +1. Choose the **Start** endpoint (e.g. Song mode) and select a track from the autocomplete. +2. Choose the **End** endpoint the same way. +3. Set **Number of steps** (and optionally fix the playlist size). +4. Click **Find Path** to see the ordered journey and its charts. +5. Name it and click **Create Playlist on Media Server**. + +![Song Path results](screenshots/06-song-path.png) +*A computed path between two songs, with progression charts and the ordered track list.* + +> **Tip:** Great for parties and long drives — start mellow and end energetic (or vice-versa) for a set that evolves naturally. + +--- + +## Song Alchemy + +**Route:** `/alchemy` + +"Vector maths" for music. **Include** songs/artists/anchors/moods to add their character and **Exclude** others to subtract it; AudioMuse-AI computes the resulting centroid and returns the nearest songs, plotted on a 2-D projection. You can save the result as a playlist or store the centroid as a reusable **anchor**. + +**How to use it** +1. In each item card pick a type (song, artist, anchor, mood), search for it, and set it to **Include** or **Exclude**. +2. Add more rows as needed and tune **Number of results**, **Temperature** and **Subtract distance**. +3. Click **Run Alchemy** to compute the recommendations and the projection plot. +4. Save the results as a playlist, or click **Save Anchor** to reuse this centroid elsewhere. + +![Song Alchemy results](screenshots/07-song-alchemy.png) +*Include/Exclude item cards, the 2-D projection plot and the resulting recommended songs.* + +> **Note:** Anchors created here appear as a seed option across Similarity, Song Path and Alchemy. + +--- + +## Text Search (DCLAP) + +**Route:** `/clap_search` + +Search your library by *describing the sound* in free text. Powered by CLAP text-to-audio embeddings, it matches your description (mood, genre, instrument, energy) directly against the audio of your tracks — no tags required. + +**How to use it** +1. Type a description — e.g. *"energetic upbeat saxophone jazz"* — or tap a suggested example tag. +2. Set the result **limit** and click **Search**. +3. Review the matches (each with a similarity score), then name and create a playlist. + +![DCLAP text search results](screenshots/08-dclap-search.png) +*A natural-language audio search and its ranked, similarity-scored results.* + +> **Note:** Requires CLAP indexing to be enabled and built for your library. + +--- + +## Lyrics Search + +**Route:** `/lyrics_search` + +Find songs by what they're *about*, focusing on the lyrics rather than the groove. Three complementary modes are available as tabs. + +**By Axis — compose facets.** Pick a value for one or more lyrical "axes" (setting, social dynamic, emotional valence, narrative temporality, thematic weight) to compose a precise search. + +![Lyrics search by axis](screenshots/09a-lyrics-axis.png) +*Select one value per axis; leave an axis on "None" to ignore it.* + +**By Text — semantic free-text.** Type a free-form description of the lyrics; matching is by *meaning*, not exact words. + +![Lyrics search by text](screenshots/09b-lyrics-text.png) +*"Love and heartbreak in the city at night" surfaces songs even if those exact words never appear.* + +**By Song — lyrically and acoustically similar.** Pick a seed song and find tracks that share both its lyrical meaning (75%) and its sound (25%). + +![Lyrics search by song](screenshots/09c-lyrics-song.png) +*The SemGrove index blends lyrics and audio similarity around a seed song.* + +> **Note:** Lyrics features require lyrics analysis and the relevant indexes to be built. Any tab's results can be saved as a media-server playlist. + +--- + +## Sonic Fingerprint + +**Route:** `/sonic_fingerprint` + +Generate a personalised playlist from *your own* listening history on the media server, plus a radar "fingerprint" of your mood traits and a top-genres breakdown. + +**How to use it** +1. Enter your media-server **username/user-ID** (the default user is pre-filled). A token/password is only needed for other users. +2. Set the **Number of results**. +3. Click **Generate My Sonic Fingerprint** — the radar and recommendations appear. +4. Name and create a playlist from the results. + +![Sonic Fingerprint form](screenshots/11-sonic-fingerprint.png) +*Enter your credentials and how many songs to include.* + +![Sonic Fingerprint result](screenshots/11b-sonic-fingerprint-result.png) +*The mood radar, your top genres, and the recommended tracks.* + +> **Note:** The radar shows the share of your songs leaning toward each trait (danceable, aggressive, happy, party, relaxed, sad). + +--- + +## Music Map + +**Route:** `/map` + +An interactive 2-D scatter plot of your whole library, projected from the song embeddings and coloured by dominant genre/mood. Pan, zoom, lasso-select clusters, search for a track to highlight it, and build playlists or draw listening paths directly on the map. + +**How to use it** +1. Choose a map size (25% loads fastest; 100% shows everything). +2. Hover a point to see its track; **lasso** or click points to add them to the selection. +3. Use the search box to find a song and highlight it on the map. +4. With 2–10 songs selected, click **Song Path** to trace a route between them, or **Create playlist** from the whole selection. + +![Music Map](screenshots/10-music-map.png) +*The library as a sound-space; each dot is a track, coloured by genre.* + +![Music Map with a path drawn](screenshots/10b-music-map-path.png) +*A path drawn across the map connecting a sequence of selected songs.* + +> **Tip:** Use the legend below the map to hide/show individual genres and declutter the view. + +--- + +## Waveform + +**Route:** `/waveform` + +Visualise the amplitude waveform of any single track in your library. AudioMuse-AI downloads and analyses the file, then renders its loudness shape over time. + +**How to use it** +1. Start typing in the search box and pick a track. +2. Click **Generate Waveform** (this can take a few seconds while the file is fetched and analysed). +3. The waveform appears, captioned with the number of sample points. + +![Waveform visualization](screenshots/12-waveform.png) +*The rendered waveform for a selected track, with its title and artist above.* + +--- + +## Scheduled Tasks + +**Route:** `/cron` · **admin only** + +Automate Analysis, Clustering and Sonic Fingerprint runs with cron expressions, so your library stays up to date without manual effort. + +**How to use it** +1. Enter a cron expression for each task (e.g. `0 2 * * 0-5` = 02:00 on weekdays). +2. Tick **Enable** for the schedules you want active. +3. Click **Save Schedules**. + +![Scheduled Tasks](screenshots/14-scheduled-tasks.png) +*One cron expression and an enable toggle per task type.* + +> **Note:** Sensible defaults — Analysis nightly at 02:00 (except Saturday), Clustering Saturday at 02:00, Sonic Fingerprint Saturday at 01:00. They start disabled. + +--- + +## Database Cleaning + +**Route:** `/cleaning` · **admin only** + +Scan the media server and remove database rows for albums/tracks that no longer exist there — keeping AudioMuse-AI's data in sync with your actual library. + +**How to use it** +1. Click **Start Database Cleaning**. +2. Watch the status; a summary reports how many orphaned albums/tracks were found and deleted. + +![Database Cleaning](screenshots/13-cleaning.png) +*A single action that scans for and removes orphaned entries.* + +> **Heads up:** this permanently deletes the analysis data for tracks that are gone from your server. Run it after you've removed or moved music, not on a healthy library. + +--- + +## Backup and Restore + +**Route:** `/backup` · **admin only** + +Download a full database dump, or restore from a previous one. Analysis can be expensive to recompute, so a backup is cheap insurance. + +**How to use it** +1. **Create Backup** — runs a full dump and downloads the `.sql` file to your browser. +2. **Restore** — choose a backup file, type the confirmation phrase, and click **Restore**. + +![Backup and Restore](screenshots/15-backup-restore.png) +*Create a backup (downloads a .sql dump) or upload one to restore.* + +> **Restore replaces everything.** It wipes and recreates the database, then restarts the app and workers. Only restore a dump you trust, and ideally take a fresh backup first. + +--- + +## Provider Migration + +**Route:** `/provider-migration` · **admin only** + +Switched the media server in front of the *same* music library (e.g. replaced Navidrome with Emby)? This 6-step wizard rewrites every track's internal ID to the matching ID on the new provider, so your analysis, embeddings and local playlists keep pointing at the right songs. + +**The steps** +1. **Back up** your database and confirm you've stored it. +2. **Choose the new provider**, enter its credentials, and **Test Connection**. +3. **Automatic matching** — match every track against the new provider (re-runnable). +4. **Manual album matching** (optional) — fix any albums that didn't match automatically. +5. **Finalize** the dry run and review the counts. +6. **Execute** — apply the migration. + +![Provider Migration wizard](screenshots/16-provider-migration.png) +*The guided wizard: back up → choose provider → match → review → finalize → execute.* + +> **Destructive final step.** Executing rewrites IDs and *deletes* tracks that don't exist on the new provider as orphans. Best results come when both providers expose the library from the same file paths. Always back up first. + +--- + +## Setup Wizard + +**Route:** `/setup` · **admin only** + +The first-run and re-configuration screen. Connect your media server, configure authentication, tune advanced options, and optionally wire up lyrics-provider APIs. + +**How to use it** +1. Pick your **media server type** and fill in its URL and credentials, then **Test connection**. +2. Choose which libraries to scan (or scan all). +3. Configure **Authentication** (enable/disable, admin account, JWT secret, API token). +4. Expand **Advanced configuration** and **Lyrics API** if needed. +5. Click **Save configuration** — the app applies the settings and restarts. + +![Setup Wizard](screenshots/17-setup-wizard.png) +*Media-server connection and authentication settings (secrets redacted in this screenshot).* + +> **Note:** Once an admin account exists, manage additional accounts on the [Users](#users) page rather than here. + +--- + +## Users + +**Route:** `/users` · **admin only** + +Manage the accounts that can sign in. Admins can add normal users or other admins, change any password, and delete accounts. Normal users only see — and can only change the password of — their own account. + +**How to use it** +1. Review the user table (username, role, created date). +2. Click **Add user**, fill in the username, password and role, then **Save user**. +3. Use **Change password** or **Delete** on any row to manage existing accounts. + +![Users configuration](screenshots/18-users.png) +*The user table and the "Add user" panel (usernames anonymised in this screenshot).* + +> **Note:** Passwords are stored hashed (argon2) and never shown again. You can't delete your own account, and at least one admin must always remain. + +--- + +*AudioMuse-AI — How-To Guide · application version v2.1.5 · screenshots use placeholder metadata to avoid copyright.* diff --git a/docs/howto/2.1.5/screenshots/00-login.png b/docs/howto/2.1.5/screenshots/00-login.png new file mode 100644 index 00000000..316585dc Binary files /dev/null and b/docs/howto/2.1.5/screenshots/00-login.png differ diff --git a/docs/howto/2.1.5/screenshots/01-dashboard.png b/docs/howto/2.1.5/screenshots/01-dashboard.png new file mode 100644 index 00000000..d6083d4f Binary files /dev/null and b/docs/howto/2.1.5/screenshots/01-dashboard.png differ diff --git a/docs/howto/2.1.5/screenshots/02a-analysis-result.png b/docs/howto/2.1.5/screenshots/02a-analysis-result.png new file mode 100644 index 00000000..92ede145 Binary files /dev/null and b/docs/howto/2.1.5/screenshots/02a-analysis-result.png differ diff --git a/docs/howto/2.1.5/screenshots/02b-clustering-result.png b/docs/howto/2.1.5/screenshots/02b-clustering-result.png new file mode 100644 index 00000000..47a31c75 Binary files /dev/null and b/docs/howto/2.1.5/screenshots/02b-clustering-result.png differ diff --git a/docs/howto/2.1.5/screenshots/03-instant-playlist.png b/docs/howto/2.1.5/screenshots/03-instant-playlist.png new file mode 100644 index 00000000..f53045bb Binary files /dev/null and b/docs/howto/2.1.5/screenshots/03-instant-playlist.png differ diff --git a/docs/howto/2.1.5/screenshots/03b-instant-playlist-result.png b/docs/howto/2.1.5/screenshots/03b-instant-playlist-result.png new file mode 100644 index 00000000..a1a5f1dc Binary files /dev/null and b/docs/howto/2.1.5/screenshots/03b-instant-playlist-result.png differ diff --git a/docs/howto/2.1.5/screenshots/04-similarity.png b/docs/howto/2.1.5/screenshots/04-similarity.png new file mode 100644 index 00000000..ec9dada3 Binary files /dev/null and b/docs/howto/2.1.5/screenshots/04-similarity.png differ diff --git a/docs/howto/2.1.5/screenshots/04b-similarity-autocomplete.png b/docs/howto/2.1.5/screenshots/04b-similarity-autocomplete.png new file mode 100644 index 00000000..aaa68fe8 Binary files /dev/null and b/docs/howto/2.1.5/screenshots/04b-similarity-autocomplete.png differ diff --git a/docs/howto/2.1.5/screenshots/05-artist-similarity.png b/docs/howto/2.1.5/screenshots/05-artist-similarity.png new file mode 100644 index 00000000..de9a40ce Binary files /dev/null and b/docs/howto/2.1.5/screenshots/05-artist-similarity.png differ diff --git a/docs/howto/2.1.5/screenshots/05b-artist-autocomplete.png b/docs/howto/2.1.5/screenshots/05b-artist-autocomplete.png new file mode 100644 index 00000000..4712c697 Binary files /dev/null and b/docs/howto/2.1.5/screenshots/05b-artist-autocomplete.png differ diff --git a/docs/howto/2.1.5/screenshots/06-song-path.png b/docs/howto/2.1.5/screenshots/06-song-path.png new file mode 100644 index 00000000..88c07c6c Binary files /dev/null and b/docs/howto/2.1.5/screenshots/06-song-path.png differ diff --git a/docs/howto/2.1.5/screenshots/07-song-alchemy.png b/docs/howto/2.1.5/screenshots/07-song-alchemy.png new file mode 100644 index 00000000..ae33819c Binary files /dev/null and b/docs/howto/2.1.5/screenshots/07-song-alchemy.png differ diff --git a/docs/howto/2.1.5/screenshots/08-dclap-search.png b/docs/howto/2.1.5/screenshots/08-dclap-search.png new file mode 100644 index 00000000..97abb1bb Binary files /dev/null and b/docs/howto/2.1.5/screenshots/08-dclap-search.png differ diff --git a/docs/howto/2.1.5/screenshots/09a-lyrics-axis.png b/docs/howto/2.1.5/screenshots/09a-lyrics-axis.png new file mode 100644 index 00000000..28fa3e86 Binary files /dev/null and b/docs/howto/2.1.5/screenshots/09a-lyrics-axis.png differ diff --git a/docs/howto/2.1.5/screenshots/09b-lyrics-text.png b/docs/howto/2.1.5/screenshots/09b-lyrics-text.png new file mode 100644 index 00000000..686cc1f9 Binary files /dev/null and b/docs/howto/2.1.5/screenshots/09b-lyrics-text.png differ diff --git a/docs/howto/2.1.5/screenshots/09c-lyrics-song.png b/docs/howto/2.1.5/screenshots/09c-lyrics-song.png new file mode 100644 index 00000000..e96fa89a Binary files /dev/null and b/docs/howto/2.1.5/screenshots/09c-lyrics-song.png differ diff --git a/docs/howto/2.1.5/screenshots/10-music-map.png b/docs/howto/2.1.5/screenshots/10-music-map.png new file mode 100644 index 00000000..05a3d0cd Binary files /dev/null and b/docs/howto/2.1.5/screenshots/10-music-map.png differ diff --git a/docs/howto/2.1.5/screenshots/10b-music-map-path.png b/docs/howto/2.1.5/screenshots/10b-music-map-path.png new file mode 100644 index 00000000..9f869775 Binary files /dev/null and b/docs/howto/2.1.5/screenshots/10b-music-map-path.png differ diff --git a/docs/howto/2.1.5/screenshots/11-sonic-fingerprint.png b/docs/howto/2.1.5/screenshots/11-sonic-fingerprint.png new file mode 100644 index 00000000..108f9ebc Binary files /dev/null and b/docs/howto/2.1.5/screenshots/11-sonic-fingerprint.png differ diff --git a/docs/howto/2.1.5/screenshots/11b-sonic-fingerprint-result.png b/docs/howto/2.1.5/screenshots/11b-sonic-fingerprint-result.png new file mode 100644 index 00000000..8ae64694 Binary files /dev/null and b/docs/howto/2.1.5/screenshots/11b-sonic-fingerprint-result.png differ diff --git a/docs/howto/2.1.5/screenshots/12-waveform.png b/docs/howto/2.1.5/screenshots/12-waveform.png new file mode 100644 index 00000000..aa16ac39 Binary files /dev/null and b/docs/howto/2.1.5/screenshots/12-waveform.png differ diff --git a/docs/howto/2.1.5/screenshots/13-cleaning.png b/docs/howto/2.1.5/screenshots/13-cleaning.png new file mode 100644 index 00000000..72a7f2d8 Binary files /dev/null and b/docs/howto/2.1.5/screenshots/13-cleaning.png differ diff --git a/docs/howto/2.1.5/screenshots/14-scheduled-tasks.png b/docs/howto/2.1.5/screenshots/14-scheduled-tasks.png new file mode 100644 index 00000000..ea6cf177 Binary files /dev/null and b/docs/howto/2.1.5/screenshots/14-scheduled-tasks.png differ diff --git a/docs/howto/2.1.5/screenshots/15-backup-restore.png b/docs/howto/2.1.5/screenshots/15-backup-restore.png new file mode 100644 index 00000000..b11780ae Binary files /dev/null and b/docs/howto/2.1.5/screenshots/15-backup-restore.png differ diff --git a/docs/howto/2.1.5/screenshots/16-provider-migration.png b/docs/howto/2.1.5/screenshots/16-provider-migration.png new file mode 100644 index 00000000..e4b1b39b Binary files /dev/null and b/docs/howto/2.1.5/screenshots/16-provider-migration.png differ diff --git a/docs/howto/2.1.5/screenshots/17-setup-wizard.png b/docs/howto/2.1.5/screenshots/17-setup-wizard.png new file mode 100644 index 00000000..9d300fb7 Binary files /dev/null and b/docs/howto/2.1.5/screenshots/17-setup-wizard.png differ diff --git a/docs/howto/2.1.5/screenshots/18-users.png b/docs/howto/2.1.5/screenshots/18-users.png new file mode 100644 index 00000000..f65196fa Binary files /dev/null and b/docs/howto/2.1.5/screenshots/18-users.png differ diff --git a/docs/howto/_tooling/README.md b/docs/howto/_tooling/README.md new file mode 100644 index 00000000..2f496e4f --- /dev/null +++ b/docs/howto/_tooling/README.md @@ -0,0 +1,94 @@ +# How-To guide tooling + +Generates the per-release user guide under `docs/howto//` — a +GitHub-readable `howto.md` (table of contents + a section and screenshot per +page) plus a `screenshots/` folder. + +``` +docs/howto/ + _tooling/ <- this folder (scripts + prose template + CI stack) + howto.template.md the guide prose, with a {{VERSION}} placeholder + howto_capture.py drives a browser, screenshots every page + render_howto.py template + version -> docs/howto//howto.md (stdlib) + validate_howto.py checks a rendered folder is complete & correct (stdlib) + make_howto.py convenience: capture + render in one go + docker-compose.howto.yml throwaway app + Postgres + Redis used by CI + _version.py reads APP_VERSION from config.py + requirements.txt playwright (capture only) + 2.1.4/ <- a rendered release (howto.md + screenshots/) +``` + +## Safety (why the output is publishable) + +* **Copyright** — track **title / artist / album** never appear. When capturing + against a real instance every `/api/**` JSON response is intercepted and those + fields are rewritten to placeholders (`Song Title 1`, …) before the page + renders. When capturing in CI (`--mock-all`) the data is fabricated as + placeholders to begin with. +* **Secrets** — server URLs, user IDs, tokens and passwords are blanked on the + Setup, Sonic Fingerprint, Analysis, Instant Playlist and Users pages. +* Only **read-only** features are exercised; nothing is written to a media + server. A few "result" screenshots use representative placeholder data. + +## Two ways to produce the guide + +### A) In CI on each pull request (no real instance) — `.github/workflows/howto.yml` + +Runs on every pull request (open + each push) and on manual dispatch. The +version — and therefore the `docs/howto//` folder name, with the +leading `v` stripped — is read from `APP_VERSION` in `config.py`, **not** from a +git tag. The workflow: + +1. starts a throwaway stack from `docker-compose.howto.yml` — the prebuilt + image + an **empty** Postgres + Redis. Env vars clear the setup/auth barrier + (no media server is contacted) and seed an `admin` account on boot. +2. waits for `GET /api/health`, then runs `howto_capture.py --mock-all` — which + logs in and **fabricates every page's data in the browser**, so an empty + database still yields fully-populated screenshots. +3. renders `howto.md`, validates the folder, and **commits the refreshed + `docs/howto//` to the PR branch** (also uploaded as a build artifact). + +The push goes to the **PR branch only — never `main`** — and is authenticated +with `GITHUB_TOKEN`, so it does not trigger another run; it commits only when +something actually changed. Review the screenshots + `howto.md` in the PR diff +and **merge when you like the result**. (Fork PRs can't be pushed to — use a +same-repo branch.) The app image defaults to +`ghcr.io/neptunehub/audiomuse-ai:`, falling back to `:latest`; if that +image is private, add a `docker/login-action` step before "Start app stack". + +`--mock-all` notes: data endpoints (`/api/search_tracks`, `/api/map`, +`/api/dashboard/summary`, …) are answered from `build_mock()` in +`howto_capture.py`; config/setup/users endpoints pass through to the real app +(and are masked). Lyrics and DCLAP gate their inputs server-side on built +indexes, which an empty DB doesn't have — so in mock mode those inputs are +re-enabled and the "index not built" banner is hidden before the demo runs. + +If you add a page or change an endpoint's response shape, update `build_mock()` +to match. + +### B) Locally, against your own analysed instance + +```bash +pip install -r docs/howto/_tooling/requirements.txt +playwright install chromium # or rely on an installed Google Chrome (default channel) + +cd docs/howto/_tooling +python make_howto.py --base-url http://192.168.3.204:8000 --user root --password root +``` + +Writes `docs/howto//` (version from `config.py`; override with +`--version v2.2.0`). Real metadata is masked on the way through. Commit the +folder. URL/credentials also read from `HOWTO_BASE_URL` / `HOWTO_USER` / +`HOWTO_PASSWORD`. + +* Prose change for everyone? Edit `howto.template.md` once, then + `python render_howto.py --version vX.Y.Z` (no browser). +* Re-render only (no recapture): `make_howto.py --skip-capture`. + +## Tweaking individual scripts + +* `render_howto.py` / `validate_howto.py` are pure stdlib (no browser, no app) — + safe and fast to run anywhere, including as a release gate. +* `validate_howto.py` fails if a referenced screenshot is missing/empty, an + in-page link is broken (GitHub heading-slug rules), or the page isn't stamped + with the expected version. diff --git a/docs/howto/_tooling/_version.py b/docs/howto/_tooling/_version.py new file mode 100644 index 00000000..c5806751 --- /dev/null +++ b/docs/howto/_tooling/_version.py @@ -0,0 +1,47 @@ +"""Resolve the application version from config.py without importing it. + +config.py pulls in heavy runtime dependencies, so the version is read by +parsing the source with the ast module instead (same approach the standalone +build uses in scripts/standalone/config.py). +""" +import ast +import os +from pathlib import Path + +# docs/howto/_tooling/_version.py -> repo root is three parents up. +REPO_ROOT = Path(__file__).resolve().parents[3] + + +def read_app_version(repo_root=REPO_ROOT): + """Return APP_VERSION exactly as written in config.py, e.g. 'v2.1.4'.""" + cfg = os.path.join(str(repo_root), "config.py") + with open(cfg, "r", encoding="utf-8") as fh: + tree = ast.parse(fh.read()) + for node in tree.body: + if isinstance(node, ast.Assign): + for target in node.targets: + if isinstance(target, ast.Name) and target.id == "APP_VERSION": + if isinstance(node.value, ast.Constant): + return str(node.value.value) + raise RuntimeError("APP_VERSION not found in config.py") + + +def folder_version(version): + """Strip a leading 'v' so 'v2.1.4' -> '2.1.4' (used for the folder name).""" + return version[1:] if version[:1] in ("v", "V") else version + + +def display_version(version): + """Normalise to the 'vX.Y.Z' form shown in the document.""" + return version if version[:1] in ("v", "V") else "v" + version + + +def resolve(version=None, repo_root=REPO_ROOT): + """Return (display, folder) for an explicit tag or the value in config.py.""" + raw = version or read_app_version(repo_root) + return display_version(raw), folder_version(raw) + + +if __name__ == "__main__": + disp, folder = resolve() + print(disp, folder) diff --git a/docs/howto/_tooling/docker-compose.howto.yml b/docs/howto/_tooling/docker-compose.howto.yml new file mode 100644 index 00000000..239f5e47 --- /dev/null +++ b/docs/howto/_tooling/docker-compose.howto.yml @@ -0,0 +1,59 @@ +# Throwaway AudioMuse-AI stack for capturing how-to screenshots in CI. +# +# Boots the app from the prebuilt release image against an EMPTY Postgres + Redis. +# Env vars clear the setup/auth barrier (no real media server is ever contacted) +# and seed an admin on boot, so the capture can log in. All page DATA is mocked +# in the browser by howto_capture.py --mock-all, so no real library is needed. +# +# Image tag is parameterised: HOWTO_IMAGE=ghcr.io/neptunehub/audiomuse-ai:v2.2.0 +# (defaults to :latest). Bring up with: +# HOWTO_IMAGE=... docker compose -f docs/howto/_tooling/docker-compose.howto.yml up -d + +services: + redis: + image: redis:7-alpine + restart: unless-stopped + + postgres: + image: postgres:15-alpine + environment: + POSTGRES_USER: audiomuse + POSTGRES_PASSWORD: audiomusepassword + POSTGRES_DB: audiomusedb + healthcheck: + test: ["CMD-SHELL", "pg_isready -U audiomuse -d audiomusedb"] + interval: 5s + timeout: 5s + retries: 30 + restart: unless-stopped + + flask: + image: ${HOWTO_IMAGE:-ghcr.io/neptunehub/audiomuse-ai:latest} + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_started + environment: + SERVICE_TYPE: "flask" + TZ: "UTC" + POSTGRES_HOST: "postgres" + POSTGRES_PORT: "5432" + POSTGRES_USER: "audiomuse" + POSTGRES_PASSWORD: "audiomusepassword" + POSTGRES_DB: "audiomusedb" + REDIS_URL: "redis://redis:6379/0" + TEMP_DIR: "/app/temp_audio" + # --- clear the setup barrier (values are never contacted, just validated) --- + MEDIASERVER_TYPE: "jellyfin" + JELLYFIN_URL: "http://media.example.com" + JELLYFIN_USER_ID: "ci-user" + JELLYFIN_TOKEN: "ci-token" + # --- auth: seed an admin on boot so the capture can log in --- + AUTH_ENABLED: "true" + AUDIOMUSE_USER: "admin" + AUDIOMUSE_PASSWORD: "adminpass" + # CLAP_ENABLED / LYRICS_ENABLED default true → all nav links appear + ports: + - "8000:8000" + restart: unless-stopped diff --git a/docs/howto/_tooling/howto.template.md b/docs/howto/_tooling/howto.template.md new file mode 100644 index 00000000..6b5967d9 --- /dev/null +++ b/docs/howto/_tooling/howto.template.md @@ -0,0 +1,423 @@ +# AudioMuse-AI — How-To Guide + +*Where Music Takes Shape.* A walkthrough of every page and feature, with screenshots. + +**Application version {{VERSION}}** + +> **About the screenshots.** Every track title, artist and album name shown in this guide has been replaced with neutral placeholders (`Song Title 1`, `Artist Name 1`, `Album Name 1`) to avoid reproducing copyrighted metadata. Server URLs, user IDs, tokens and passwords are redacted. A few "result" screenshots use representative placeholder data so the success state can be shown without running heavy jobs. + +> **The golden rule:** AudioMuse-AI can only recommend tracks it has *analysed*. Run [Analysis and Clustering](#analysis-and-clustering) first (or schedule it under [Scheduled Tasks](#scheduled-tasks)); every other feature draws on that data. + +## Contents + +**Getting started** +- [Logging in](#logging-in) +- [Dashboard](#dashboard) + +**Build your library data** +- [Analysis and Clustering](#analysis-and-clustering) + +**Create playlists** +- [Instant Playlist](#instant-playlist) +- [Playlist from Similar Song](#playlist-from-similar-song) +- [Artist Similarity](#artist-similarity) +- [Song Path](#song-path) +- [Song Alchemy](#song-alchemy) +- [Text Search (DCLAP)](#text-search-dclap) +- [Lyrics Search](#lyrics-search) +- [Sonic Fingerprint](#sonic-fingerprint) + +**Explore** +- [Music Map](#music-map) +- [Waveform](#waveform) + +**Administration (admin only)** +- [Scheduled Tasks](#scheduled-tasks) +- [Database Cleaning](#database-cleaning) +- [Backup and Restore](#backup-and-restore) +- [Provider Migration](#provider-migration) +- [Setup Wizard](#setup-wizard) +- [Users](#users) + +--- + +## Logging in + +**Route:** `/login` + +When authentication is enabled, AudioMuse-AI presents a sign-in screen. Enter the username and password of an account created in the [Setup Wizard](#setup-wizard) or on the [Users](#users) page. A session cookie keeps you signed in for 8 hours. + +**How to use it** +1. Open the AudioMuse-AI URL in your browser. +2. Type your **username** and **password**. +3. Click **Sign In** — you land on the Dashboard. + +![Login screen](screenshots/00-login.png) +*The sign-in screen. Admins see every page; normal users see everything except the Administration menu.* + +> **Roles.** **Admins** have full access and can manage other users. **Normal users** can use all the playlist / exploration tools but cannot see the Administration menu (Analysis, Cleaning, Scheduled Tasks, Backup, Provider Migration, Setup, Users). + +--- + +## Dashboard + +**Route:** `/` + +The landing page summarises your library and the system at a glance: key counts (songs, artists, albums and how much of the library is indexed for each model), the live queue workers, the last completed batch tasks, content charts (genres, mood coverage, tempo) and the configured scheduled tasks. It refreshes automatically every 30 seconds. + +**What to look for** +1. **Total Songs / Artists / Albums** — with the percentage indexed by each model (Musicnn, CLAP, GMM). +2. **Queue Workers** — each worker container runs a high-priority and a default worker, so one node usually shows two rows. +3. **Last 10 completed batch tasks** — type, status, duration and a short note per run. +4. **Content / Library charts** — genre distribution, mood coverage and tempo profile of the analysed library. + +![Dashboard](screenshots/01-dashboard.png) +*Key numbers, queue workers, recent tasks, genre/mood/tempo charts and scheduled tasks.* + +> **Tip:** Low index percentages mean you still have library to analyse — head to [Analysis and Clustering](#analysis-and-clustering). + +--- + +## Analysis and Clustering + +**Route:** `/analysis` · **admin only** + +This is the engine room. **Analysis** scans recently-added albums on your media server and computes the acoustic features, embeddings and mood vectors that power every other feature. **Clustering** then groups the analysed library into automatically-named playlists (optionally using an AI provider to name them). Both run as background jobs whose progress you can follow live. + +**How to use it** +1. Set **Number of Recent Albums** to analyse (small for a first run, or large to catch up). +2. Click **Start Analysis** and watch *Task Status* — type, running time, progress bar and a live log. +3. Set the clustering parameters (algorithm, number of playlists, clustering runs) and, optionally, an **AI provider** for playlist names. +4. Click **Start Clustering**; when it finishes, the generated playlists appear under *Generated Playlists*. +5. Use **Fetch Playlists** at any time to list the playlists already generated. + +![Analysis run result](screenshots/02a-analysis-result.png) +*Analysis parameters and the task status after an analysis run completes.* + +![Clustering run result](screenshots/02b-clustering-result.png) +*Clustering parameters, AI naming, and the resulting auto-generated playlists.* + +> **Basic vs Advanced.** Toggle the **Advanced** view to expose the full set of tunables; **Basic** keeps just the essentials. A run can be stopped with **Cancel**. + +> **Tip:** Prefer set-and-forget? Configure recurring runs on the [Scheduled Tasks](#scheduled-tasks) page instead of starting them by hand. + +--- + +## Instant Playlist + +**Route:** `/chat/` + +Describe the vibe you want in plain language and let an AI assistant build a playlist for you. Behind the scenes it plans a chain of internal tools (similarity, search, brainstorming, lyrics) and returns up to 100 matching songs from *your* library, which you can then push to your media server as a real playlist. + +**How to use it** +1. Choose an **AI Provider** (Ollama, OpenAI-compatible, Gemini, Mistral…). The model and server fields adapt to your choice. +2. Type what you want — e.g. *"Calm rainy-day piano songs for focus"* — or click a suggestion chip (Similar Song, Multi-Song Mix, From Artist, Sound/Vibe, Lyrics Theme…). +3. Click **Get Playlist Idea** and watch the live pipeline steps. +4. Review the generated list, give it a name, and click **Create Playlist** to save it on your media server. + +![Instant Playlist form](screenshots/03-instant-playlist.png) +*Pick an AI provider, type a request (or tap a suggestion chip), then ask for a playlist.* + +![Instant Playlist AI result](screenshots/03b-instant-playlist-result.png) +*The AI returns a tool chain, a collapsible raw log, and the generated playlist.* + +> **Tip:** Keep prompts short and specific — naming 1–3 seed songs or artists plus a couple of filters (genre, year, mood) works better than a long paragraph. + +--- + +## Playlist from Similar Song + +**Route:** `/similarity` + +Pick a seed — a specific **song**, a **mood** centroid, or a saved **anchor** — and AudioMuse-AI finds the tracks that sound closest to it, ranked by acoustic distance. Turn the results into a playlist in one click. + +**How to use it** +1. Use the mode toggle to pick **Song**, **Mood** or **Anchor**. +2. In Song mode, start typing (3+ characters) and pick a track from the autocomplete. +3. Set **Number of results**; optionally enable **Radius Similarity**. +4. Click **Find Similar Tracks** to see the ranked list. +5. Give the playlist a name and click **Create Playlist on Media Server**. + +![Song autocomplete](screenshots/04b-similarity-autocomplete.png) +*Start typing to find a seed song; suggestions appear with title, artist and album.* + +![Similar tracks results](screenshots/04-similarity.png) +*Ranked similar tracks with mood/genre tags and a distance badge (smaller = more similar).* + +--- + +## Artist Similarity + +**Route:** `/artist_similarity` + +Find artists whose overall sound profile resembles a chosen artist, using Gaussian Mixture Models. Optionally see *why* they match — the shared "components" of their sound, with representative example songs from each — and build a playlist from an artist's tracks or the matching songs. + +**How to use it** +1. Type an artist name (2+ characters) and select it from the list. +2. Set how many similar artists to return; keep **Show component matches** ticked to see the reasoning. +3. Click **Find Similar Artists**. +4. Use **Show All Songs** or **Show Matches** on any row to expand its tracks. +5. Name the playlist and click **Create Playlist on Media Server**. + +![Artist search](screenshots/05b-artist-autocomplete.png) +*Type to find an artist; the suggestion shows each artist's track count.* + +![Similar artists results](screenshots/05-artist-similarity.png) +*Ranked similar artists with a similarity score and expandable song / component matches.* + +--- + +## Song Path + +**Route:** `/path` + +Build a smooth, gradually-shifting sequence of songs that travels from a **start** point to an **end** point. Each endpoint can be a song, a lyrics-seed song, a mood or an anchor. The result is charted as a feature progression and a 2-D route, and can be saved as a playlist. + +**How to use it** +1. Choose the **Start** endpoint (e.g. Song mode) and select a track from the autocomplete. +2. Choose the **End** endpoint the same way. +3. Set **Number of steps** (and optionally fix the playlist size). +4. Click **Find Path** to see the ordered journey and its charts. +5. Name it and click **Create Playlist on Media Server**. + +![Song Path results](screenshots/06-song-path.png) +*A computed path between two songs, with progression charts and the ordered track list.* + +> **Tip:** Great for parties and long drives — start mellow and end energetic (or vice-versa) for a set that evolves naturally. + +--- + +## Song Alchemy + +**Route:** `/alchemy` + +"Vector maths" for music. **Include** songs/artists/anchors/moods to add their character and **Exclude** others to subtract it; AudioMuse-AI computes the resulting centroid and returns the nearest songs, plotted on a 2-D projection. You can save the result as a playlist or store the centroid as a reusable **anchor**. + +**How to use it** +1. In each item card pick a type (song, artist, anchor, mood), search for it, and set it to **Include** or **Exclude**. +2. Add more rows as needed and tune **Number of results**, **Temperature** and **Subtract distance**. +3. Click **Run Alchemy** to compute the recommendations and the projection plot. +4. Save the results as a playlist, or click **Save Anchor** to reuse this centroid elsewhere. + +![Song Alchemy results](screenshots/07-song-alchemy.png) +*Include/Exclude item cards, the 2-D projection plot and the resulting recommended songs.* + +> **Note:** Anchors created here appear as a seed option across Similarity, Song Path and Alchemy. + +--- + +## Text Search (DCLAP) + +**Route:** `/clap_search` + +Search your library by *describing the sound* in free text. Powered by CLAP text-to-audio embeddings, it matches your description (mood, genre, instrument, energy) directly against the audio of your tracks — no tags required. + +**How to use it** +1. Type a description — e.g. *"energetic upbeat saxophone jazz"* — or tap a suggested example tag. +2. Set the result **limit** and click **Search**. +3. Review the matches (each with a similarity score), then name and create a playlist. + +![DCLAP text search results](screenshots/08-dclap-search.png) +*A natural-language audio search and its ranked, similarity-scored results.* + +> **Note:** Requires CLAP indexing to be enabled and built for your library. + +--- + +## Lyrics Search + +**Route:** `/lyrics_search` + +Find songs by what they're *about*, focusing on the lyrics rather than the groove. Three complementary modes are available as tabs. + +**By Axis — compose facets.** Pick a value for one or more lyrical "axes" (setting, social dynamic, emotional valence, narrative temporality, thematic weight) to compose a precise search. + +![Lyrics search by axis](screenshots/09a-lyrics-axis.png) +*Select one value per axis; leave an axis on "None" to ignore it.* + +**By Text — semantic free-text.** Type a free-form description of the lyrics; matching is by *meaning*, not exact words. + +![Lyrics search by text](screenshots/09b-lyrics-text.png) +*"Love and heartbreak in the city at night" surfaces songs even if those exact words never appear.* + +**By Song — lyrically and acoustically similar.** Pick a seed song and find tracks that share both its lyrical meaning (75%) and its sound (25%). + +![Lyrics search by song](screenshots/09c-lyrics-song.png) +*The SemGrove index blends lyrics and audio similarity around a seed song.* + +> **Note:** Lyrics features require lyrics analysis and the relevant indexes to be built. Any tab's results can be saved as a media-server playlist. + +--- + +## Sonic Fingerprint + +**Route:** `/sonic_fingerprint` + +Generate a personalised playlist from *your own* listening history on the media server, plus a radar "fingerprint" of your mood traits and a top-genres breakdown. + +**How to use it** +1. Enter your media-server **username/user-ID** (the default user is pre-filled). A token/password is only needed for other users. +2. Set the **Number of results**. +3. Click **Generate My Sonic Fingerprint** — the radar and recommendations appear. +4. Name and create a playlist from the results. + +![Sonic Fingerprint form](screenshots/11-sonic-fingerprint.png) +*Enter your credentials and how many songs to include.* + +![Sonic Fingerprint result](screenshots/11b-sonic-fingerprint-result.png) +*The mood radar, your top genres, and the recommended tracks.* + +> **Note:** The radar shows the share of your songs leaning toward each trait (danceable, aggressive, happy, party, relaxed, sad). + +--- + +## Music Map + +**Route:** `/map` + +An interactive 2-D scatter plot of your whole library, projected from the song embeddings and coloured by dominant genre/mood. Pan, zoom, lasso-select clusters, search for a track to highlight it, and build playlists or draw listening paths directly on the map. + +**How to use it** +1. Choose a map size (25% loads fastest; 100% shows everything). +2. Hover a point to see its track; **lasso** or click points to add them to the selection. +3. Use the search box to find a song and highlight it on the map. +4. With 2–10 songs selected, click **Song Path** to trace a route between them, or **Create playlist** from the whole selection. + +![Music Map](screenshots/10-music-map.png) +*The library as a sound-space; each dot is a track, coloured by genre.* + +![Music Map with a path drawn](screenshots/10b-music-map-path.png) +*A path drawn across the map connecting a sequence of selected songs.* + +> **Tip:** Use the legend below the map to hide/show individual genres and declutter the view. + +--- + +## Waveform + +**Route:** `/waveform` + +Visualise the amplitude waveform of any single track in your library. AudioMuse-AI downloads and analyses the file, then renders its loudness shape over time. + +**How to use it** +1. Start typing in the search box and pick a track. +2. Click **Generate Waveform** (this can take a few seconds while the file is fetched and analysed). +3. The waveform appears, captioned with the number of sample points. + +![Waveform visualization](screenshots/12-waveform.png) +*The rendered waveform for a selected track, with its title and artist above.* + +--- + +## Scheduled Tasks + +**Route:** `/cron` · **admin only** + +Automate Analysis, Clustering and Sonic Fingerprint runs with cron expressions, so your library stays up to date without manual effort. + +**How to use it** +1. Enter a cron expression for each task (e.g. `0 2 * * 0-5` = 02:00 on weekdays). +2. Tick **Enable** for the schedules you want active. +3. Click **Save Schedules**. + +![Scheduled Tasks](screenshots/14-scheduled-tasks.png) +*One cron expression and an enable toggle per task type.* + +> **Note:** Sensible defaults — Analysis nightly at 02:00 (except Saturday), Clustering Saturday at 02:00, Sonic Fingerprint Saturday at 01:00. They start disabled. + +--- + +## Database Cleaning + +**Route:** `/cleaning` · **admin only** + +Scan the media server and remove database rows for albums/tracks that no longer exist there — keeping AudioMuse-AI's data in sync with your actual library. + +**How to use it** +1. Click **Start Database Cleaning**. +2. Watch the status; a summary reports how many orphaned albums/tracks were found and deleted. + +![Database Cleaning](screenshots/13-cleaning.png) +*A single action that scans for and removes orphaned entries.* + +> **Heads up:** this permanently deletes the analysis data for tracks that are gone from your server. Run it after you've removed or moved music, not on a healthy library. + +--- + +## Backup and Restore + +**Route:** `/backup` · **admin only** + +Download a full database dump, or restore from a previous one. Analysis can be expensive to recompute, so a backup is cheap insurance. + +**How to use it** +1. **Create Backup** — runs a full dump and downloads the `.sql` file to your browser. +2. **Restore** — choose a backup file, type the confirmation phrase, and click **Restore**. + +![Backup and Restore](screenshots/15-backup-restore.png) +*Create a backup (downloads a .sql dump) or upload one to restore.* + +> **Restore replaces everything.** It wipes and recreates the database, then restarts the app and workers. Only restore a dump you trust, and ideally take a fresh backup first. + +--- + +## Provider Migration + +**Route:** `/provider-migration` · **admin only** + +Switched the media server in front of the *same* music library (e.g. replaced Navidrome with Emby)? This 6-step wizard rewrites every track's internal ID to the matching ID on the new provider, so your analysis, embeddings and local playlists keep pointing at the right songs. + +**The steps** +1. **Back up** your database and confirm you've stored it. +2. **Choose the new provider**, enter its credentials, and **Test Connection**. +3. **Automatic matching** — match every track against the new provider (re-runnable). +4. **Manual album matching** (optional) — fix any albums that didn't match automatically. +5. **Finalize** the dry run and review the counts. +6. **Execute** — apply the migration. + +![Provider Migration wizard](screenshots/16-provider-migration.png) +*The guided wizard: back up → choose provider → match → review → finalize → execute.* + +> **Destructive final step.** Executing rewrites IDs and *deletes* tracks that don't exist on the new provider as orphans. Best results come when both providers expose the library from the same file paths. Always back up first. + +--- + +## Setup Wizard + +**Route:** `/setup` · **admin only** + +The first-run and re-configuration screen. Connect your media server, configure authentication, tune advanced options, and optionally wire up lyrics-provider APIs. + +**How to use it** +1. Pick your **media server type** and fill in its URL and credentials, then **Test connection**. +2. Choose which libraries to scan (or scan all). +3. Configure **Authentication** (enable/disable, admin account, JWT secret, API token). +4. Expand **Advanced configuration** and **Lyrics API** if needed. +5. Click **Save configuration** — the app applies the settings and restarts. + +![Setup Wizard](screenshots/17-setup-wizard.png) +*Media-server connection and authentication settings (secrets redacted in this screenshot).* + +> **Note:** Once an admin account exists, manage additional accounts on the [Users](#users) page rather than here. + +--- + +## Users + +**Route:** `/users` · **admin only** + +Manage the accounts that can sign in. Admins can add normal users or other admins, change any password, and delete accounts. Normal users only see — and can only change the password of — their own account. + +**How to use it** +1. Review the user table (username, role, created date). +2. Click **Add user**, fill in the username, password and role, then **Save user**. +3. Use **Change password** or **Delete** on any row to manage existing accounts. + +![Users configuration](screenshots/18-users.png) +*The user table and the "Add user" panel (usernames anonymised in this screenshot).* + +> **Note:** Passwords are stored hashed (argon2) and never shown again. You can't delete your own account, and at least one admin must always remain. + +--- + +*AudioMuse-AI — How-To Guide · application version {{VERSION}} · screenshots use placeholder metadata to avoid copyright.* diff --git a/docs/howto/_tooling/howto_capture.py b/docs/howto/_tooling/howto_capture.py new file mode 100644 index 00000000..8240aa0e --- /dev/null +++ b/docs/howto/_tooling/howto_capture.py @@ -0,0 +1,819 @@ +"""Capture every AudioMuse-AI page into docs/howto//screenshots/. + +Drives a real browser (Playwright) against a running, analysed AudioMuse-AI +instance. Two safety properties make the output publishable: + + * Copyright masking — every /api/** JSON response is intercepted and the + track title / artist / album fields are rewritten to neutral placeholders + (Song Title N, Artist Name N, Album Name N) BEFORE the page renders them. + * Secret redaction — server URLs, user IDs, tokens and passwords on the + Setup, Sonic Fingerprint, Analysis, Instant Playlist and Users pages are + blanked just before each screenshot. + +A few "result" screenshots (analysis/clustering status, AI playlist, artist +results, sonic fingerprint) are produced by overriding the relevant API with +representative placeholder data, or injecting an equivalent result into the +DOM, so the success state is shown without running heavy or destructive jobs. + +Only read-only features are exercised; nothing is written to the media server. + +Usage (local, against your own instance): + python howto_capture.py --base-url http://192.168.3.204:8000 \ + --user root --password root +Credentials and URL also read from HOWTO_BASE_URL / HOWTO_USER / +HOWTO_PASSWORD. The output version defaults to APP_VERSION in config.py. +""" +import argparse +import json +import math +import os +import re +import traceback +from urllib.parse import urlparse + +from playwright.sync_api import sync_playwright + +from _version import REPO_ROOT, resolve + +# -------------------------------------------------------------------------- +# Metadata masking +# -------------------------------------------------------------------------- +KEY_CATEGORY = { + "title": "title", "track": "title", "song": "title", + "old_track": "title", "new_track": "title", + "loser_title": "title", "winner_title": "title", "target_title": "title", + "author": "artist", "artist": "artist", "artist_name": "artist", + "album_artist": "artist", + "old_artist": "artist", "new_artist": "artist", + "old_album_artist": "artist", "new_album_artist": "artist", + "loser_artist": "artist", "winner_artist": "artist", "target_artist": "artist", + "album": "album", + "old_album": "album", "new_album": "album", + "loser_album": "album", "winner_album": "album", "target_album": "album", +} +LIST_TITLE_KEYS = {"sample_titles", "representative_songs", + "artist1_representative_songs", "artist2_representative_songs"} +PATH_KEYS = {"loser_path", "winner_path", "target_path", "old_path", "new_path", "path"} +CATEGORY_LABEL = {"title": "Song Title", "artist": "Artist Name", "album": "Album Name"} + +_counters = {"title": 0, "artist": 0, "album": 0} +_cache = {} + + +def _placeholder(category, real): + key = (category, str(real)) + if key in _cache: + return _cache[key] + _counters[category] += 1 + val = "%s %d" % (CATEGORY_LABEL[category], _counters[category]) + _cache[key] = val + return val + + +_EMBED_RE = re.compile(r"(title|artist|author|album)\s*=\s*'([^']*)'", re.IGNORECASE) + + +def _scrub_string(s): + if not isinstance(s, str) or "=" not in s: + return s + + def repl(m): + field = m.group(1).lower() + cat = "title" if field == "title" else ("album" if field == "album" else "artist") + return "%s='%s'" % (m.group(1), _placeholder(cat, m.group(2))) + + return _EMBED_RE.sub(repl, s) + + +def mask(obj): + if isinstance(obj, dict): + out = {} + for k, v in obj.items(): + lk = k.lower() if isinstance(k, str) else k + if lk in KEY_CATEGORY and isinstance(v, str) and v.strip(): + out[k] = _placeholder(KEY_CATEGORY[lk], v) + elif lk in PATH_KEYS and isinstance(v, str) and v.strip(): + out[k] = "/music/Artist Name/Album Name/track.flac" + elif lk in LIST_TITLE_KEYS and isinstance(v, list): + out[k] = [_placeholder("title", x) if isinstance(x, str) + else (mask(x) if isinstance(x, (dict, list)) else x) for x in v] + elif isinstance(v, (dict, list)): + out[k] = mask(v) + elif isinstance(v, str): + out[k] = _scrub_string(v) + else: + out[k] = v + return out + if isinstance(obj, list): + return [mask(x) for x in obj] + if isinstance(obj, str): + return _scrub_string(obj) + return obj + + +def make_route_handler(mock_all=False): + def handle(route): + url = route.request.url + if "stream" in url: + return route.continue_() + if mock_all: + data = build_mock(url, route.request.method) + if data is not None: + try: + return route.fulfill(status=200, content_type="application/json", + body=json.dumps(data, ensure_ascii=False)) + except Exception: + pass + try: + resp = route.fetch() + ct = (resp.headers or {}).get("content-type", "") + if "application/json" not in ct: + return route.fulfill(response=resp) + body = json.dumps(mask(resp.json()), ensure_ascii=False) + return route.fulfill(response=resp, body=body, content_type="application/json") + except Exception: + try: + return route.continue_() + except Exception: + return + return handle + + +def fulfill_json(data): + def handler(route): + route.fulfill(status=200, content_type="application/json", + body=json.dumps(data, ensure_ascii=False)) + return handler + + +# -------------------------------------------------------------------------- +# Representative placeholder data for emulated "result" screenshots +# -------------------------------------------------------------------------- +GENRES = ["rock", "pop", "jazz", "electronic", "hip hop", "folk", "metal", "classical"] + + +def _mood(i): + h = round(0.30 + (i * 7 % 60) / 100, 2) + s = round(0.90 - h, 2) + a = round(0.25 + (i * 11 % 55) / 100, 2) + r = round(0.95 - a, 2) + party = round(0.40 + (i * 13 % 55) / 100, 2) + dance = round(0.45 + (i * 17 % 50) / 100, 2) + return "happy:%s,sad:%s,aggressive:%s,relaxed:%s,party:%s,danceable:%s" % (h, s, a, r, party, dance) + + +SONIC_DATA = [ + {"item_id": "demo-%d" % i, "title": "Song Title %d" % (200 + i), + "author": "Artist Name %d" % (1 + (i % 18)), "album": "Album Name %d" % (1 + (i % 9)), + "distance": round(0.05 + i * 0.018, 4), "top_genre": GENRES[i % len(GENRES)], + "other_features": _mood(i), "mood_vector": _mood(i)} + for i in range(1, 31) +] + +ARTIST_DATA = [] +for _i in range(1, 9): + _cm = [{"component1_index": 0, "component2_index": 1, "distance": round(0.08 + 0.03 * _i, 3), + "artist1_representative_songs": [{"item_id": "a%ds1" % _i, "title": "Song Title %d" % (_i * 10 + 1)}, + {"item_id": "a%ds2" % _i, "title": "Song Title %d" % (_i * 10 + 2)}], + "artist2_representative_songs": [{"item_id": "b%ds1" % _i, "title": "Song Title %d" % (_i * 10 + 3)}, + {"item_id": "b%ds2" % _i, "title": "Song Title %d" % (_i * 10 + 4)}]}] + if _i == 1: + _cm.append({"component1_index": 2, "component2_index": 0, "distance": 0.142, + "artist1_representative_songs": [{"item_id": "a1s3", "title": "Song Title 15"}], + "artist2_representative_songs": [{"item_id": "b1s3", "title": "Song Title 16"}]}) + ARTIST_DATA.append({"artist": "Artist Name %d" % _i, "artist_id": "art%d" % _i, + "divergence": round(0.42 + 0.13 * _i, 4), "component_matches": _cm}) + +# -------------------------------------------------------------------------- +# Mock-all data builders (used only with --mock-all, i.e. an empty CI instance) +# -------------------------------------------------------------------------- +def _songs(n, album=True, metric="distance"): + out = [] + for i in range(1, n + 1): + s = {"item_id": "trk-%d" % i, "title": "Song Title %d" % i, + "author": "Artist Name %d" % (((i - 1) % 40) + 1), + "top_genre": GENRES[i % len(GENRES)], + "other_features": _mood(i), "mood_vector": _mood(i)} + if album: + s["album"] = "Album Name %d" % (((i - 1) % 15) + 1) + s["album_artist"] = s["author"] + if metric == "distance": + s["distance"] = round(0.05 + i * 0.01, 4) + elif metric == "similarity": + s["similarity"] = round(max(0.40, 0.98 - i * 0.02), 3) + out.append(s) + return out + + +def _map_items(n=600): + items = [] + for i in range(n): + g = GENRES[i % len(GENRES)] + ang = 2 * math.pi * ((i % len(GENRES)) / len(GENRES)) + cx, cy = math.cos(ang) * 3.0, math.sin(ang) * 3.0 + jx = math.cos(i * 1.7) * ((i % 17) / 17.0) + jy = math.sin(i * 2.3) * ((i % 13) / 13.0) + items.append({"item_id": str(i), "title": "Song Title %d" % (i + 1), + "artist": "Artist Name %d" % ((i % 200) + 1), + "embedding_2d": [round(cx + jx, 3), round(cy + jy, 3)], + "mood_vector": g}) + return {"items": items, "projection": "PCA → t-SNE (demo projection)"} + + +def _dashboard(): + genres = [{"label": g, "count": 420 - 30 * i} for i, g in enumerate(GENRES)] + moods = [{"label": m, "score": round(300 - 20 * i, 2)} + for i, m in enumerate(["happy", "relaxed", "party", "danceable", "aggressive", "sad"])] + return { + "generated_at": "2026-01-01 12:00:00", "stats_updated_at": "2026-01-01 11:00:00", + "workers": [{"hostname": "audiomuse-worker-1", "queues": ["high", "default"], + "state": "idle", "current_job_id": None, + "successful_jobs": 128, "failed_jobs": 0}], + "recent_tasks": [ + {"task_id": "a1b2", "task_type": "main_analysis", "status": "SUCCESS", + "duration_seconds": 134.0, "note": "Analyzed 25 albums (412 tracks).", + "timestamp": "2026-01-01 02:02:14"}, + {"task_id": "c3d4", "task_type": "main_clustering", "status": "SUCCESS", + "duration_seconds": 408.0, "note": "Generated 200 playlists.", + "timestamp": "2025-12-31 02:06:48"}], + "content": {"total_songs": 12480, "distinct_artists": 1320, "distinct_albums": 2140, + "musicnn_indexed": 12480, "clap_indexed": 12480, "gmm_indexed": 1320, + "top_genre": genres, "moods_coverage": moods, + "tempo_profile": {"slow": 2200, "medium": 4100, "fast": 4300, + "very_fast": 1880, "avg_tempo": 118.4}}, + "cron": [{"id": 1, "name": "Nightly Analysis", "task_type": "main_analysis", + "cron_expr": "0 2 * * 0-5", "enabled": True, "last_run": "2026-01-01 02:00:00"}], + } + + +def _playlists(): + names = ["Evening Drive_automatic", "Late Night Focus_automatic", + "Sunday Morning_automatic", "Workout Energy_automatic"] + return {nm: [{"title": "Song Title %d" % (j + 1), "author": "Artist Name %d" % ((j % 20) + 1)} + for j in range(8)] for nm in names} + + +def build_mock(url, method): + """Return placeholder JSON for a data endpoint, or None to pass the request + through to the real app (e.g. /api/setup, /api/users, /api/config, health, + warmup — whose real empty-DB/config responses render fine and are masked).""" + path = urlparse(url).path + + def has(*subs): + return any(s in path for s in subs) + + if path.endswith("/api/search_tracks"): + return _songs(20, album=True) + if path.endswith("/api/search_artists"): + return [{"artist": "Artist Name %d" % i, "track_count": 400 - 13 * i} for i in range(1, 21)] + if path.endswith("/api/similar_tracks"): + return _songs(25, metric="distance") + if path.endswith("/api/similar_artists"): + return ARTIST_DATA + if path.endswith("/api/artist_tracks"): + return [{"item_id": "at%d" % i, "title": "Song Title %d" % i, "author": "Artist Name 1"} + for i in range(1, 13)] + if path.endswith("/api/find_path"): + return {"path": _songs(15, metric="distance")} + if path.endswith("/api/alchemy"): + s = _songs(20, metric="distance") + for i, x in enumerate(s): + x["embedding_2d"] = [round(math.cos(i) * 2, 3), round(math.sin(i) * 2, 3)] + return {"results": s, "filtered_out": [], "add_points": s[:2], "sub_points": []} + if path.endswith("/api/clap/search"): + return {"results": _songs(20, metric="similarity"), "query": "energetic upbeat saxophone jazz"} + if has("/api/lyrics/search/axes", "/api/lyrics/search/text"): + return {"results": _songs(20, metric="similarity")} + if has("/api/sem_grove/search"): + s = _songs(20, metric="similarity") + s[0]["is_seed"] = True + return {"results": s} + if path.endswith("/api/map"): + return _map_items() + if path.endswith("/api/waveform"): + return {"peaks": [round(abs(math.sin(i / 6.0)) * (0.4 + 0.6 * ((i % 50) / 50.0)), 3) + for i in range(500)], + "title": "Song Title 1", "author": "Artist Name 1"} + if has("/api/sonic_fingerprint/generate"): + return SONIC_DATA + if path.endswith("/api/playlists"): + return _playlists() + if path.endswith("/api/dashboard/summary"): + return _dashboard() + return None + + +# -------------------------------------------------------------------------- +# Injected JS +# -------------------------------------------------------------------------- +FORCE_UI = """ +() => { try { + localStorage.setItem('menuOpen','true'); + localStorage.setItem('theme','light'); + document.documentElement.classList.add('sidebar-open'); +} catch (e) {} } +""" + +REDACT_JS = r""" +(pageKey) => { + const ipRe = /\b\d{1,3}(?:\.\d{1,3}){3}\b/; + const setVal=(sel,v)=>document.querySelectorAll(sel).forEach(e=>{e.value=v;}); + if (pageKey === 'aiurls') { + document.querySelectorAll('input[type=text], input:not([type]), input[type=url]').forEach(e=>{ + const v=e.value||''; if(/^https?:\/\//i.test(v)||ipRe.test(v)) e.value='http://ai-server.example.com:11434/api/generate'; + }); + } + if (pageKey === 'setup') { + document.querySelectorAll('input[type=password]').forEach(e=>e.value=''); + document.querySelectorAll('input[type=text], input:not([type]), input[type=url]').forEach(e=>{ + const v=(e.value||'').trim(); + if(/^https?:\/\//i.test(v)||ipRe.test(v)){e.value='https://media.example.com';return;} + if(/^[0-9a-fA-F]{16,}$/.test(v)||/^[0-9a-fA-F]{8}-[0-9a-fA-F-]{20,}$/.test(v)){e.value='demo-user-id-0000';return;} + }); + setVal('#AUDIOMUSE_USER','demo_admin'); + } + if (pageKey === 'sonic') { + setVal('#jellyfin_user_identifier','your-username'); + setVal('#navidrome_user','your-username'); + document.querySelectorAll('input[type=password]').forEach(e=>e.value=''); + } + if (pageKey === 'users') { + document.querySelectorAll('#additional-users-tbody tr').forEach((tr,i)=>{ + const td=tr.querySelector('td'); if(td&&td.textContent.trim()&&td.textContent.trim()!=='root') td.textContent='user'+(i+1); + }); + } + if (pageKey === 'dashboard') { + document.querySelectorAll('#workers-table tbody tr').forEach((tr,i)=>{ + const td=tr.querySelector('td'); if(td&&!td.classList.contains('muted')) td.textContent='audiomuse-worker-'+(i+1); + }); + } +} +""" + +CHAT_RESULT_JS = """ +() => { + const ra=document.getElementById('responseArea'); if(!ra) return; + ra.innerHTML=''; ra.classList.remove('text-red-600'); + const ok='var(--color-success)'; + const cw=document.createElement('div'); cw.style.margin='0.25rem 0 1rem 0'; + const chain=document.createElement('div'); chain.className='tool-chain'; + ['Understand request','Brainstorm themes','Find similar tracks','Assemble playlist'].forEach((n,i)=>{ + if(i>0){const a=document.createElement('span');a.className='tool-chain-arrow';a.textContent='\\u2192';chain.appendChild(a);} + const node=document.createElement('span');node.className='tool-chain-node';node.textContent=n;chain.appendChild(node); + }); + cw.appendChild(chain); ra.appendChild(cw); + const d=document.createElement('details'); d.style.marginBottom='1rem'; + const s=document.createElement('summary'); s.style.cursor='pointer'; s.style.fontWeight='600'; s.style.padding='0.75rem'; s.style.borderRadius='0.375rem'; s.style.background='var(--bg-code,#f3f4f6)'; s.style.color='var(--text-muted)'; s.textContent='Technical details (raw log)'; + d.appendChild(s); ra.appendChild(d); + const rd=document.createElement('div'); rd.style.marginTop='1.5rem'; rd.style.padding='1.5rem'; rd.style.borderRadius='0.5rem'; rd.style.border='2px solid '+ok; rd.style.backgroundColor='var(--status-success-bg)'; + const h=document.createElement('h3'); h.style.fontWeight='700'; h.style.marginBottom='1rem'; h.style.color=ok; + const N=12; h.textContent='Generated Playlist ('+N+' songs)'; rd.appendChild(h); + const ol=document.createElement('ol'); ol.className='song-list'; ol.style.maxHeight='400px'; ol.style.overflowY='auto'; + for(let i=1;i<=N;i++){const li=document.createElement('li'); li.textContent='Song Title '+(100+i)+' by Artist Name '+i; ol.appendChild(li);} + rd.appendChild(ol); ra.appendChild(rd); + const cps=document.getElementById('createPlaylistSection'); if(cps) cps.classList.remove('hidden'); +} +""" + +ANALYSIS_STATUS_JS = """ +(s) => { + const set=(id,v)=>{const e=document.getElementById(id); if(e) e.textContent=v;}; + set('status-task-id', s.taskId); set('status-running-time', s.runtime); + set('status-task-type', s.type); set('status-status', s.status); + set('status-log', s.log); set('status-progress', '100'); + const pb=document.getElementById('progress-bar'); if(pb){ pb.style.width='100%'; pb.style.background='var(--color-success,#16A34A)'; } + const sd=document.getElementById('status-details'); if(sd){ sd.textContent=s.details; } + const ss=document.getElementById('status-status'); if(ss){ ss.className='status-success'; } +} +""" + +MAP_PATH_JS = """ +() => { + const pts=(window._plotPointsFull||[]).slice(); + if(pts.length<12) return; + let cur=pts[Math.floor(pts.length*0.4)]; + const used=new Set([cur.id]); const path=[cur]; + for(let k=0;k<11;k++){ + let best=null,bd=Infinity; + for(const p of pts){ if(used.has(p.id))continue; const d=(p.x-cur.x)*(p.x-cur.x)+(p.y-cur.y)*(p.y-cur.y); if(d({item_id:p.id,x:p.x,y:p.y,embedding_2d:[p.x,p.y],title:p.title,artist:p.artist,mood_vector:p.genre})); + try{ appendSongsToSelectionPanel(items); }catch(e){} + try{ drawPathOnMap(items,'rgba(20,20,20,0.9)'); }catch(e){} +} +""" + +AXIS_SELECT_JS = """ +() => { + const sels=[...document.querySelectorAll('#axes-container select')]; let set=0; + for(const s of sels){ if(set>=2) break; if(s.options.length>1){ s.selectedIndex=1; s.dispatchEvent(new Event('change')); set++; } } +} +""" + + +# When the backend indexes aren't built (empty CI DB), lyrics/DCLAP render their +# inputs disabled with an "index not built" banner. In --mock-all mode we enable +# them and hide the banner so the search demo can run (data comes from build_mock). +ENABLE_INPUTS_JS = """ +(scope) => { + document.querySelectorAll(scope).forEach(root => { + root.querySelectorAll('[disabled]').forEach(e => e.removeAttribute('disabled')); + }); + document.querySelectorAll('.error-message').forEach(e => { e.style.display = 'none'; }); +} +""" + + +# -------------------------------------------------------------------------- +# Playwright helpers +# -------------------------------------------------------------------------- +def launch(p, channel): + if channel: + try: + return p.chromium.launch(channel=channel, headless=True) + except Exception: + pass + return p.chromium.launch(headless=True) + + +def capture(base_url, user, password, out_dir, channel="chrome", mock_all=False): + base_url = base_url.rstrip("/") + os.makedirs(out_dir, exist_ok=True) + + def shot(page, name, full=True): + page.wait_for_timeout(300) + page.screenshot(path=os.path.join(out_dir, name), full_page=full) + print(" saved", name) + + def goto(page, path): + page.goto(base_url + path, wait_until="networkidle", timeout=40000) + page.wait_for_timeout(800) + + def redact(page, key): + try: + page.evaluate(REDACT_JS, key) + except Exception as e: + print(" redact warn:", e) + + def enable_mock(scope): + if not mock_all: + return + try: + page.evaluate(ENABLE_INPUTS_JS, scope) + except Exception as e: + print(" enable warn:", e) + + def autocomplete(page, input_sel, text, results_sel, item_sel=".autocomplete-item"): + try: + page.click(input_sel) + page.fill(input_sel, "") + page.type(input_sel, text, delay=70) + page.wait_for_selector("%s %s" % (results_sel, item_sel), timeout=8000, state="visible") + page.wait_for_timeout(400) + return True + except Exception as e: + print(" autocomplete miss (%s): %s" % (input_sel, e)) + return False + + def pick_first(page, results_sel, item_sel=".autocomplete-item"): + try: + page.locator("%s %s" % (results_sel, item_sel)).first.click(timeout=4000) + page.wait_for_timeout(500) + return True + except Exception as e: + print(" pick miss:", e) + return False + + with sync_playwright() as p: + browser = launch(p, channel) + ctx = browser.new_context(viewport={"width": 1440, "height": 900}, device_scale_factor=1) + ctx.add_init_script(FORCE_UI) + ctx.route("**/api/**", make_route_handler(mock_all)) + page = ctx.new_page() + page.set_default_timeout(15000) + + def block(label, fn): + print("==", label) + try: + fn() + except Exception: + print(" ERROR", label) + traceback.print_exc() + + # --- 00 login (unauthenticated) --- + block("login", lambda: (goto(page, "/login"), shot(page, "00-login.png", full=False))) + + page.fill("#login-user", user) + page.fill("#login-password", password) + page.click("#login-form button[type=submit]") + page.wait_for_load_state("networkidle", timeout=40000) + print("logged in ->", page.url) + + # --- 01 dashboard --- + def dashboard(): + goto(page, "/") + page.wait_for_timeout(3500) + redact(page, "dashboard") + shot(page, "01-dashboard.png") + block("dashboard", dashboard) + + # --- 02a analysis result --- + def analysis_result(): + goto(page, "/analysis") + page.wait_for_timeout(600) + redact(page, "aiurls") + page.evaluate(ANALYSIS_STATUS_JS, { + "taskId": "a1b2c3d4-e5f6-47a8-9b0c-analysis001", "runtime": "00 : 02 : 14", + "type": "main_analysis", "status": "SUCCESS", + "log": "Main analysis complete. Analyzed 25 new albums (412 tracks). Skipped 0 already-analyzed albums.", + "details": '{\n "task_type": "main_analysis",\n "status": "SUCCESS",\n "albums_analyzed": 25,\n "tracks_analyzed": 412,\n "skipped": 0\n}'}) + shot(page, "02a-analysis-result.png") + block("analysis-result", analysis_result) + + # --- 02b clustering result --- + def clustering_result(): + goto(page, "/analysis") + try: + page.click("#fetch-playlists-btn", timeout=4000) + page.wait_for_timeout(2500) + except Exception as e: + print(" fetch warn:", e) + redact(page, "aiurls") + page.evaluate(ANALYSIS_STATUS_JS, { + "taskId": "f9e8d7c6-b5a4-4321-8765-clustering01", "runtime": "00 : 06 : 48", + "type": "main_clustering", "status": "SUCCESS", + "log": "Clustering complete. Generated 200 playlists from 8 clustering runs (best score 0.8123).", + "details": '{\n "task_type": "main_clustering",\n "status": "SUCCESS",\n "playlists_created": 200,\n "best_score": 0.8123,\n "clustering_runs": 8\n}'}) + shot(page, "02b-clustering-result.png") + block("clustering-result", clustering_result) + + # --- 03 instant playlist form --- + def chat_form(): + goto(page, "/chat/") + try: + page.fill("#userInput", "Calm rainy-day piano songs for focus") + except Exception as e: + print(" fill warn:", e) + redact(page, "aiurls") + shot(page, "03-instant-playlist.png") + block("chat-form", chat_form) + + # --- 03b instant playlist AI result --- + def chat_result(): + goto(page, "/chat/") + try: + page.fill("#userInput", "Calm rainy-day piano songs for focus") + except Exception: + pass + redact(page, "aiurls") + page.evaluate(CHAT_RESULT_JS) + shot(page, "03b-instant-playlist-result.png") + block("chat-result", chat_result) + + # --- 04 similarity (autocomplete + results) --- + def similarity(): + goto(page, "/similarity") + if autocomplete(page, "#search_query", "love", "#autocomplete-results"): + shot(page, "04b-similarity-autocomplete.png", full=False) + pick_first(page, "#autocomplete-results") + try: + page.click("#similarity-form button[type=submit]", timeout=4000) + page.wait_for_selector("#results-table-wrapper .result-item", timeout=25000) + page.wait_for_timeout(800) + except Exception as e: + print(" similarity run warn:", e) + shot(page, "04-similarity.png") + block("similarity", similarity) + + # --- 05b artist autocomplete --- + def artist_autocomplete(): + goto(page, "/artist_similarity") + autocomplete(page, "#artist_search", "the", "#autocomplete-results") + page.wait_for_timeout(500) + shot(page, "05b-artist-autocomplete.png", full=False) + block("artist-autocomplete", artist_autocomplete) + + # --- 05 artist similarity results (API override) --- + def artist_result(): + page.route("**/api/similar_artists**", fulfill_json(ARTIST_DATA)) + try: + goto(page, "/artist_similarity") + page.evaluate("() => { const e=document.getElementById('artist_search'); if(e) e.value='Artist Name 1'; }") + page.click("#find-artists-btn", timeout=5000) + page.wait_for_selector("#results-table-wrapper table", timeout=15000) + page.wait_for_timeout(500) + try: + page.click('.expand-btn[data-mode="components"]', timeout=4000) + page.wait_for_timeout(700) + except Exception as e: + print(" expand warn:", e) + shot(page, "05-artist-similarity.png") + finally: + page.unroute("**/api/similar_artists**") + block("artist-result", artist_result) + + # --- 06 song path --- + def song_path(): + goto(page, "/path") + if autocomplete(page, "#start_search", "love", "#start-autocomplete-results"): + pick_first(page, "#start-autocomplete-results") + if autocomplete(page, "#end_search", "night", "#end-autocomplete-results"): + pick_first(page, "#end-autocomplete-results") + try: + page.click("#path-form button[type=submit]", timeout=4000) + page.wait_for_selector("#results-table-wrapper .result-item", timeout=30000) + page.wait_for_timeout(1200) + except Exception as e: + print(" path run warn:", e) + shot(page, "06-song-path.png") + block("song-path", song_path) + + # --- 07 song alchemy --- + def alchemy(): + goto(page, "/alchemy") + if autocomplete(page, ".song", "love", ".autocomplete-results"): + pick_first(page, ".autocomplete-results") + try: + page.click("#run-alchemy", timeout=4000) + page.wait_for_selector("#results-table-wrapper .result-item", timeout=30000) + page.wait_for_timeout(1200) + except Exception as e: + print(" alchemy run warn:", e) + shot(page, "07-song-alchemy.png") + block("alchemy", alchemy) + + # --- 08 DCLAP --- + def clap(): + goto(page, "/clap_search") + enable_mock('#search-form') + try: + page.fill("#search-query", "energetic upbeat saxophone jazz") + page.click("#search-form button[type=submit]", timeout=4000) + page.wait_for_selector("#results-list .result-item", timeout=30000) + page.wait_for_timeout(800) + except Exception as e: + print(" clap run warn:", e) + shot(page, "08-dclap-search.png") + block("clap", clap) + + # --- 09a lyrics by axis --- + def lyrics_axis(): + goto(page, "/lyrics_search") + page.click('.tab-btn[data-tab="axes"]', timeout=5000) + page.wait_for_timeout(300) + enable_mock('#axis-form') + page.evaluate(AXIS_SELECT_JS) + page.click('#axis-form button[type="submit"]', timeout=5000) + page.wait_for_selector("#results-list .result-item", timeout=30000) + page.wait_for_timeout(700) + shot(page, "09a-lyrics-axis.png") + block("lyrics-axis", lyrics_axis) + + # --- 09b lyrics by text --- + def lyrics_text(): + goto(page, "/lyrics_search") + page.click('.tab-btn[data-tab="text"]', timeout=5000) + page.wait_for_selector("#search-query", state="visible", timeout=8000) + enable_mock('#search-form') + page.fill("#search-query", "love and heartbreak in the city at night") + page.click('#search-form button[type="submit"]', timeout=5000) + page.wait_for_selector("#results-list .result-item", timeout=30000) + page.wait_for_timeout(700) + shot(page, "09b-lyrics-text.png") + block("lyrics-text", lyrics_text) + + # --- 09c lyrics by song (SemGrove) --- + def lyrics_song(): + goto(page, "/lyrics_search") + page.click('.tab-btn[data-tab="song"]', timeout=5000) + page.wait_for_selector("#sg-search-query", state="visible", timeout=8000) + enable_mock('#song-form') + if autocomplete(page, "#sg-search-query", "love", "#sg-autocomplete-results"): + pick_first(page, "#sg-autocomplete-results") + page.click('#song-form button[type="submit"]', timeout=5000) + page.wait_for_selector("#results-list .result-item", timeout=30000) + page.wait_for_timeout(700) + shot(page, "09c-lyrics-song.png") + block("lyrics-song", lyrics_song) + + # --- 10 music map --- + def music_map(): + goto(page, "/map") + try: + page.click("#btn-pct-25", timeout=4000) + except Exception as e: + print(" map pct warn:", e) + page.wait_for_selector("#plot svg, #plot canvas", timeout=40000) + page.wait_for_timeout(3500) + shot(page, "10-music-map.png", full=False) + block("map", music_map) + + # --- 10b music map with a path drawn --- + def map_path(): + goto(page, "/map") + try: + page.click("#btn-pct-25", timeout=4000) + except Exception: + pass + page.wait_for_selector("#plot svg, #plot canvas", timeout=40000) + page.wait_for_timeout(3500) + page.evaluate(MAP_PATH_JS) + page.wait_for_timeout(1500) + shot(page, "10b-music-map-path.png", full=False) + block("map-path", map_path) + + # --- 11 sonic fingerprint form --- + def sonic_form(): + goto(page, "/sonic_fingerprint") + page.wait_for_timeout(800) + redact(page, "sonic") + shot(page, "11-sonic-fingerprint.png") + block("sonic-form", sonic_form) + + # --- 11b sonic fingerprint result (API override) --- + def sonic_result(): + page.route("**/api/sonic_fingerprint/generate**", fulfill_json(SONIC_DATA)) + try: + goto(page, "/sonic_fingerprint") + page.wait_for_timeout(700) + page.evaluate("() => { const j=document.getElementById('jellyfin_user_identifier'); if(j) j.value='demo'; const n=document.getElementById('navidrome_user'); if(n) n.value='demo'; }") + page.click('#fingerprint-form button[type="submit"]', timeout=5000) + page.wait_for_selector("#results-table-wrapper .result-item", timeout=20000) + page.wait_for_timeout(1200) + shot(page, "11b-sonic-fingerprint-result.png") + finally: + page.unroute("**/api/sonic_fingerprint/generate**") + block("sonic-result", sonic_result) + + # --- 12 waveform --- + def waveform(): + goto(page, "/waveform") + if autocomplete(page, "#search_query", "love", "#autocomplete-results"): + pick_first(page, "#autocomplete-results") + try: + page.click("#generate-waveform-btn", timeout=4000) + page.wait_for_selector("#waveform-canvas", timeout=30000, state="visible") + page.wait_for_timeout(2500) + except Exception as e: + print(" waveform run warn:", e) + shot(page, "12-waveform.png") + block("waveform", waveform) + + # --- 13-16 admin (screenshot only) --- + block("cleaning", lambda: (goto(page, "/cleaning"), shot(page, "13-cleaning.png"))) + block("cron", lambda: (goto(page, "/cron"), page.wait_for_timeout(800), shot(page, "14-scheduled-tasks.png"))) + block("backup", lambda: (goto(page, "/backup"), shot(page, "15-backup-restore.png"))) + block("migration", lambda: (goto(page, "/provider-migration"), page.wait_for_timeout(800), shot(page, "16-provider-migration.png"))) + + # --- 17 setup wizard (redact secrets) --- + def setup(): + goto(page, "/setup") + page.wait_for_timeout(1500) + redact(page, "setup") + shot(page, "17-setup-wizard.png") + block("setup", setup) + + # --- 18 users (open add panel + redact) --- + def users(): + goto(page, "/users") + page.wait_for_timeout(600) + try: + page.click("#add-user-toggle", timeout=3000) + page.wait_for_timeout(500) + except Exception as e: + print(" users toggle warn:", e) + redact(page, "users") + shot(page, "18-users.png") + block("users", users) + + print("Capture complete. Placeholders used:", _counters) + ctx.close() + browser.close() + + +def main(): + ap = argparse.ArgumentParser(description="Capture AudioMuse-AI how-to screenshots.") + ap.add_argument("--base-url", default=os.environ.get("HOWTO_BASE_URL", "http://127.0.0.1:8000")) + ap.add_argument("--user", default=os.environ.get("HOWTO_USER", "root")) + ap.add_argument("--password", default=os.environ.get("HOWTO_PASSWORD", "root")) + ap.add_argument("--version", default=None, + help="Version/tag (e.g. v2.2.0). Defaults to APP_VERSION in config.py.") + ap.add_argument("--out", default=None, help="Override the screenshots output directory.") + ap.add_argument("--browser-channel", default="chrome", + help="Browser channel (chrome/msedge). Empty string uses bundled chromium.") + ap.add_argument("--mock-all", action="store_true", + help="Fulfill every data /api/** with placeholder JSON (for an empty CI instance).") + args = ap.parse_args() + + out = args.out + if not out: + _disp, folder = resolve(args.version) + out = os.path.join(str(REPO_ROOT), "docs", "howto", folder, "screenshots") + print("Output:", out) + capture(args.base_url, args.user, args.password, out, channel=args.browser_channel, + mock_all=args.mock_all) + + +if __name__ == "__main__": + main() diff --git a/docs/howto/_tooling/make_howto.py b/docs/howto/_tooling/make_howto.py new file mode 100644 index 00000000..ccf87828 --- /dev/null +++ b/docs/howto/_tooling/make_howto.py @@ -0,0 +1,46 @@ +"""One-shot local update: capture screenshots + render the how-to HTML. + +Run this against your own analysed AudioMuse-AI instance when cutting a release, +then commit the resulting docs/howto// folder. + + python make_howto.py --base-url http://192.168.3.204:8000 --user root --password root + +Version defaults to APP_VERSION in config.py; pass --version to override +(e.g. when preparing docs ahead of a tag). +""" +import argparse +import os + +from _version import REPO_ROOT, resolve +import howto_capture +import render_howto + + +def main(): + ap = argparse.ArgumentParser(description="Capture + render the how-to for a release.") + ap.add_argument("--base-url", default=os.environ.get("HOWTO_BASE_URL", "http://127.0.0.1:8000")) + ap.add_argument("--user", default=os.environ.get("HOWTO_USER", "root")) + ap.add_argument("--password", default=os.environ.get("HOWTO_PASSWORD", "root")) + ap.add_argument("--version", default=None) + ap.add_argument("--browser-channel", default="chrome") + ap.add_argument("--mock-all", action="store_true", + help="Fulfill every data /api/** with placeholder JSON (empty CI instance).") + ap.add_argument("--skip-capture", action="store_true", + help="Only re-render the Markdown from the template (no browser).") + args = ap.parse_args() + + display, folder = resolve(args.version) + out_dir = os.path.join(str(REPO_ROOT), "docs", "howto", folder, "screenshots") + + if not args.skip_capture: + print("== Capturing screenshots for %s ==" % display) + howto_capture.capture(args.base_url, args.user, args.password, out_dir, + channel=args.browser_channel, mock_all=args.mock_all) + + print("== Rendering howto.md for %s ==" % display) + out_html = render_howto.render(args.version) + print("Done. Review and commit docs/howto/%s/ (HTML: %s)" % (folder, out_html)) + + +if __name__ == "__main__": + main() diff --git a/docs/howto/_tooling/render_howto.py b/docs/howto/_tooling/render_howto.py new file mode 100644 index 00000000..eae8d64c --- /dev/null +++ b/docs/howto/_tooling/render_howto.py @@ -0,0 +1,38 @@ +"""Render the version-stamped how-to page from the Markdown prose template. + +Substitutes the {{VERSION}} placeholder in howto.template.md and writes +docs/howto//howto.md. Pure stdlib; always writes LF newlines so the +output stays CRLF-free regardless of the OS it runs on. +""" +import argparse +import os + +from _version import REPO_ROOT, resolve + +TOOLING_DIR = os.path.dirname(os.path.abspath(__file__)) +TEMPLATE = os.path.join(TOOLING_DIR, "howto.template.md") + + +def render(version=None, repo_root=REPO_ROOT, template=TEMPLATE): + display, folder = resolve(version, repo_root) + with open(template, "r", encoding="utf-8") as fh: + md = fh.read() + md = md.replace("{{VERSION}}", display) + out_dir = os.path.join(str(repo_root), "docs", "howto", folder) + os.makedirs(out_dir, exist_ok=True) + out_path = os.path.join(out_dir, "howto.md") + with open(out_path, "w", encoding="utf-8", newline="\n") as fh: + fh.write(md) + return out_path + + +def main(): + ap = argparse.ArgumentParser(description="Render the version-stamped how-to Markdown.") + ap.add_argument("--version", default=None, + help="Version/tag (e.g. v2.2.0). Defaults to APP_VERSION in config.py.") + args = ap.parse_args() + print("Rendered", render(args.version)) + + +if __name__ == "__main__": + main() diff --git a/docs/howto/_tooling/requirements.txt b/docs/howto/_tooling/requirements.txt new file mode 100644 index 00000000..9bcd9da6 --- /dev/null +++ b/docs/howto/_tooling/requirements.txt @@ -0,0 +1,3 @@ +# Only needed for the local screenshot capture (howto_capture.py / make_howto.py). +# render_howto.py and validate_howto.py are pure stdlib and need nothing here. +playwright>=1.40 diff --git a/docs/howto/_tooling/validate_howto.py b/docs/howto/_tooling/validate_howto.py new file mode 100644 index 00000000..299d9e2c --- /dev/null +++ b/docs/howto/_tooling/validate_howto.py @@ -0,0 +1,99 @@ +"""Validate a rendered how-to folder. Pure stdlib — safe to run in CI. + +Checks, for docs/howto//howto.md: + 1. howto.md exists and is stamped with the expected version (e.g. 'v2.2.0'); + 2. every screenshots/* image it references exists and is non-empty; + 3. every internal #anchor link resolves to a heading (GitHub slug rules). + +Exits non-zero (with a clear message) on any failure so a release workflow can +use it as a gate. Warns about screenshots present but never referenced. +""" +import argparse +import os +import re +import sys + +from _version import REPO_ROOT, resolve + +_PUNCT = re.compile(r"[^\w\s-]", re.UNICODE) + + +def slugify(heading, seen): + """Approximate GitHub's heading-anchor algorithm (github-slugger).""" + s = heading.strip().lower() + s = _PUNCT.sub("", s) # drop punctuation/symbols (keep word chars, space, hyphen) + s = s.replace(" ", "-") + base = s + n = seen.get(base, 0) + seen[base] = n + 1 + return base if n == 0 else "%s-%d" % (base, n) + + +def validate(version=None, repo_root=REPO_ROOT): + display, folder = resolve(version, repo_root) + base = os.path.join(str(repo_root), "docs", "howto", folder) + md_path = os.path.join(base, "howto.md") + errors, warnings = [], [] + + if not os.path.isfile(md_path): + return [f"Missing {md_path}. Run make_howto.py / render_howto.py for {display}."], [] + + with open(md_path, "r", encoding="utf-8") as fh: + md = fh.read() + + # 1. version stamp + if display not in md: + errors.append(f"howto.md is not stamped with {display} (stale version?).") + + # 2. referenced images exist and are non-empty + srcs = re.findall(r"!\[[^\]]*\]\((screenshots/[^)]+)\)", md) + if not srcs: + errors.append("No screenshots are referenced by howto.md.") + for src in srcs: + p = os.path.join(base, src) + if not os.path.isfile(p): + errors.append(f"Referenced screenshot missing: {src} " + f"(capture and commit docs/howto/{folder}/screenshots/).") + elif os.path.getsize(p) == 0: + errors.append(f"Referenced screenshot is empty: {src}") + + # 3. internal anchors resolve to heading slugs + seen = {} + slugs = set() + for line in md.splitlines(): + m = re.match(r"^#{1,6}\s+(.*?)\s*#*$", line) + if m: + slugs.add(slugify(m.group(1), seen)) + for href in re.findall(r"\]\(#([^)]+)\)", md): + if href not in slugs: + errors.append(f"Broken in-page link: #{href} has no matching heading.") + + # warn: screenshots on disk but never referenced + shots_dir = os.path.join(base, "screenshots") + if os.path.isdir(shots_dir): + referenced = {os.path.basename(s) for s in srcs} + for f in sorted(os.listdir(shots_dir)): + if f.lower().endswith(".png") and f not in referenced: + warnings.append(f"Screenshot present but not referenced: screenshots/{f}") + + return errors, warnings + + +def main(): + ap = argparse.ArgumentParser(description="Validate a rendered how-to folder.") + ap.add_argument("--version", default=None, + help="Version/tag (e.g. v2.2.0). Defaults to APP_VERSION in config.py.") + args = ap.parse_args() + errors, warnings = validate(args.version) + for w in warnings: + print("WARNING:", w) + if errors: + for e in errors: + print("ERROR:", e) + print(f"\nValidation FAILED with {len(errors)} error(s).") + sys.exit(1) + print("Validation passed.") + + +if __name__ == "__main__": + main()