diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4501174 --- /dev/null +++ b/.env.example @@ -0,0 +1,18 @@ +# Copy to .env and adjust per deployment. + +# Directory containing streamrip's config.toml (and its downloads.db). +# Usually the streamrip default: /.config/streamrip +CONFIG_DIR=/path/to/.config/streamrip + +# Where downloaded music lands. Overrides config.toml's downloads.folder +# for everything started from the web GUI. +MUSIC_DIR=/path/to/music + +# uid:gid that owns CONFIG_DIR and MUSIC_DIR on the host +PUID=1000 +PGID=1000 + +# Host port for the web GUI +PORT=5002 + +MAX_CONCURRENT_DOWNLOADS=2 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..66bca12 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.env +venv/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..6bbc8c8 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,13 @@ +## Agent skills + +### Issue tracker + +Issues live in GitHub Issues on this repo. See `docs/agents/issue-tracker.md`. + +### Triage labels + +Default label vocabulary (needs-triage, needs-info, ready-for-agent, ready-for-human, wontfix). See `docs/agents/triage-labels.md`. + +### Domain docs + +Single-context layout — one `CONTEXT.md` + `docs/adr/` at the repo root. See `docs/agents/domain.md`. diff --git a/README.md b/README.md index 9a9524b..db27e84 100644 --- a/README.md +++ b/README.md @@ -1,194 +1,208 @@ -![streamrip web interface](https://github.com/AnOddName/streamrip-web-gui/blob/main/demo/home_screen.png?raw=true) - -# Streamrip Web GUI - -A web interface for [Streamrip](https://github.com/nathom/streamrip), providing a GUI for downloading music from various streaming services. - -Streamrip is lit but CLI-only. Having to SSH into my stupid little server each time I wanted to download a track was too much effort for me. -(Mainly Quboz for me low key I don't even know if Tidal/Deezer work because I don't have accounts for them) - -Intended to be used for Docker/Docker-Compose but you can run it locally too. - -![Python](https://img.shields.io/badge/python-3.11-blue.svg) -![Docker](https://img.shields.io/badge/docker-ready-green.svg) - -## Features - -- **Multi-Service Support**: Download from Qobuz, Tidal, Deezer, SoundCloud -- **Built-in Search**: Search and download directly from the web interface -- **Download Management**: Track active downloads, view history, and browse downloaded files -- **Configuration Editor**: Edit streamrip settings directly from the web interface -- **Docker Ready**: Easy deployment with Docker - -## Screenshots - -![search](https://github.com/AnOddName/streamrip-web-gui/blob/main/demo/search.png?raw=true) -![download](https://github.com/AnOddName/streamrip-web-gui/blob/main/demo/active_dl.png?raw=true) - -## Prerequisites - -- Python 3.11+ (if running without Docker) -- Docker and Docker Compose (for containerized deployment) -- Valid streaming service credentials configured in streamrip - -## Installation - -You MUST install and configure Streamrip first. - -1. Install Streamrip: -```bash -pip install streamrip -``` - -2. Configure Streamrip: -```bash -rip config -``` -Follow the [Streamrip configuration guide](https://github.com/nathom/streamrip/wiki/Configuration) to set up your credentials. - -### Option 1: Pre-built workflow. -1: Add this to your `docker-compose.yml` - -``` - streamrip: - image: anoddname/streamrip-web-gui:latest - container_name: streamrip-web - user: "1000:1000" - environment: - - HOME=/config - - XDG_CONFIG_HOME=/config - - STREAMRIP_CONFIG=/config/streamrip/config.toml - - DOWNLOAD_DIR=/music - - MAX_CONCURRENT_DOWNLOADS=1 - volumes: - - /home/YOURUSERNAME/.config/streamrip:/config/streamrip:rw - - /home/YOURUSERNAME/media-server/data/Music:/music:rw - ports: - - "5002:5000" - restart: unless-stopped -``` - -2: run with `docker-compose up` - -3: Access the web interface at `http://localhost:5002` - -### Option 2: Docker - -1. Clone the repository: -```bash -git clone https://github.com/anoddname/streamrip-web-gui.git -cd streamrip-web -``` - -2. Create a `docker-compose.yml` file: -```yaml -services: - streamrip: - build: ./streamrip-web - container_name: streamrip - user: "1000:1000" - environment: - - HOME=/config - - XDG_CONFIG_HOME=/config - - STREAMRIP_CONFIG=/config/streamrip/config.toml - - DOWNLOAD_DIR=/music - - MAX_CONCURRENT_DOWNLOADS=2 - volumes: - - /home/YOURUSERNAME/.config/streamrip:/config/streamrip:rw - - /home/YOURUSERNAME/media-server/data/Music:/music:rw - ports: - - "5002:5000" - restart: unless-stopped -``` - -3. Build and run: -```bash -docker-compose up -d --build -``` - -4. Access the web interface at `http://localhost:5002` - -### Option 3: Manual Installation - -1. Clone this repository: -```bash -git clone https://github.com/anoddname/streamrip-web.git -cd streamrip-web -``` - -2. Install dependencies: -```bash -pip install flask gunicorn requests -``` - -3. Run the application: -```bash -python app.py -``` - -## Configuration - -### Streamrip Configuration - -Before using Streamrip Web, you need to configure streamrip with your streaming service credentials: - -1. **Qobuz**: Requires email and password (or TOKEN) -2. **Tidal**: Requires email and password -3. **Deezer**: Requires ARL -4. **SoundCloud**: Works without authentication - -Check the [Streamrip documentation](https://github.com/nathom/streamrip/wiki) for instructions. - -## Usage - -### Downloading from URL - -1. Paste a streaming service URL in the input field -2. Select quality (MP3 128/320 or FLAC 16/24-bit) -3. Click DOWNLOAD - -### Searching for Music - -1. Select a streaming service from the dropdown -2. Choose search type (Albums, Tracks, or Artists) -3. Enter your search query -4. Click on DOWNLOAD next to any result - -- Searches will use the QUALITY from the URL paste dropdown. - -## Troubleshooting - -### Common Issues - -1. **"Config file not found"**: Make sure streamrip is properly configured. Run `rip config` to create a configuration file. Also check locations. - -2. **Downloads failing/Searches timing out**: Check that your streaming service credentials are valid and properly configured in streamrip. Tidal will timeout, Deezer will throw errors. - -3. **Downloads disappearing from Active DL/History tabs**: The files were still prolly downloaded, dont worry about it I'll fix it later it was pissing me off - -4. **No images when run locally**: CORS issue - -5. Unable to open database file/Failed to parse JSON Error: This occurs when the config file inside the container has wrong paths. Fix it with: -```bash -docker exec -it streamrip /bin/bash -sed -i 's|/home/YOURUSERNAME/StreamripDownloads|/music|g' /config/streamrip/config.toml -sed -i 's|/home/YOURUSERNAME/.config/streamrip/|/config/streamrip/|g' /config/streamrip/config.toml -exit -``` - Note to replace `YOURUSERNAME` with, you guessed it, your username. - - -## Disclaimer - -This tool is for educational purposes only. Ensure you comply with the terms of service of the streaming platforms you use. Support artists by purchasing their music. - ---- - - -Fueled by spite - - - - - - +![streamrip web interface](https://github.com/AnOddName/streamrip-web-gui/blob/main/demo/home_screen.png?raw=true) + +> [!IMPORTANT] +> This repository is a fork of AnOddName's [streamrip-web-gui](https://github.com/AnOddName/streamrip-web-gui). + +# Streamrip Web GUI + +A web interface for [Streamrip](https://github.com/nathom/streamrip), providing a GUI for downloading music from various streaming services. + +Streamrip is lit but CLI-only. Having to SSH into my stupid little server each time I wanted to download a track was too much effort for me. +(Mainly Qobuz for me low key I don't even know if Tidal/Deezer work because I don't have accounts for them) + +Intended to be used with Docker/Docker Compose, but it runs locally too. + +![Python](https://img.shields.io/badge/python-3.11-blue.svg) +![Docker](https://img.shields.io/badge/docker-ready-green.svg) + +## Features + +- **Multi-Service Support**: Download from Qobuz, Tidal, Deezer, SoundCloud +- **Built-in Search**: Search and download directly from the web interface +- **Download Management**: Track active downloads, view history, and browse downloaded files +- **Duplicate Detection**: Items already in streamrip's database show up as "Already Downloaded" instead of silently re-running +- **Configuration Editor**: Edit streamrip settings directly from the web interface +- **Installable PWA**: Add it to your phone or desktop as an app +- **Mobile Friendly**: The UI adapts to phone-sized screens +- **Docker Ready**: Easy deployment with Docker Compose + +## Screenshots + +![search](https://github.com/AnOddName/streamrip-web-gui/blob/main/demo/search.png?raw=true) +![download](https://github.com/AnOddName/streamrip-web-gui/blob/main/demo/active_dl.png?raw=true) + +## How It Works + +The web app is a thin wrapper around the `rip` command line tool. When you download something, it runs `rip` for you and streams the progress to your browser. + +Two settings control everything about where files go: + +| Setting | What it does | +|---|---| +| `STREAMRIP_CONFIG` | Path to streamrip's `config.toml` (credentials, quality settings, its downloads database) | +| `DOWNLOAD_DIR` | Where downloaded music is saved. This **always overrides** the `downloads.folder` inside `config.toml` | + +Everything else (credentials, file naming, conversion) lives in your normal streamrip config. + +## Prerequisites + +- A working streamrip setup with valid streaming service credentials (see below) +- Docker and Docker Compose (for containerized deployment), **or** Python 3.11+ (for running locally) + +## Step 1: Install and Configure Streamrip + +You MUST install and configure streamrip first. The web GUI does not work without it. + +```bash +pip install streamrip +rip config open +``` + +Add your credentials following the [Streamrip configuration guide](https://github.com/nathom/streamrip/wiki/Configuration): + +- **Qobuz**: email and password (or token) +- **Tidal**: email and password +- **Deezer**: ARL +- **SoundCloud**: works without authentication + +Verify it works on its own before continuing: + +```bash +rip search qobuz track "test" +``` + +This creates streamrip's config at `~/.config/streamrip/config.toml` along with its downloads database (used to detect already-downloaded music). + +## Step 2a: Run with Docker Compose (recommended) + +1. Clone the repository: + +```bash +git clone https://github.com/anoddname/streamrip-web-gui.git +cd streamrip-web-gui +``` + +2. Create your `.env` file from the template: + +```bash +cp .env.example .env +``` + +3. Edit `.env` and set the values for your machine: + +| Variable | What to put there | Example | +|---|---|---| +| `CONFIG_DIR` | The directory containing streamrip's `config.toml` | `/home/alice/.config/streamrip` | +| `MUSIC_DIR` | Where downloaded music should land | `/home/alice/Music` | +| `PUID` / `PGID` | The user/group ID that owns those two directories (run `id -u` and `id -g`) | `1000` / `1000` | +| `PORT` | Host port for the web interface | `5002` | +| `MAX_CONCURRENT_DOWNLOADS` | How many downloads run at once | `2` | + +4. Build and start: + +```bash +docker compose up -d --build +``` + +5. Open `http://localhost:5002` (or whatever `PORT` you chose). + +**Why no paths inside `docker-compose.yml`?** The compose file mounts your two directories at the *same paths inside the container* as on the host. That way the absolute paths streamrip wrote into `config.toml` (like its database location) stay valid everywhere, and Docker, local runs, and the plain `rip` CLI all share one config and one downloads database. You never need to edit paths inside the container. + +### Using the pre-built image (not updated with this fork) + +If you don't want to build from source, replace `build: ./` with `image: anoddname/streamrip-web-gui:latest` in `docker-compose.yml`. Note the published image may lag behind the latest changes in this repo. + +## Step 2b: Run Locally (no Docker) + +1. Clone and install dependencies: + +```bash +git clone https://github.com/anoddname/streamrip-web-gui.git +cd streamrip-web-gui +pip install flask requests +``` + +2. Run it: + +```bash +python app.py +``` + +3. Open `http://localhost:5000`. + +Locally the app uses sensible defaults — no setup needed if your streamrip config is in the standard place: + +| Setting | Default | Override with | +|---|---|---| +| Streamrip config | `~/.config/streamrip/config.toml` | `STREAMRIP_CONFIG=/path/to/config.toml` | +| Download folder | `~/Music` | `DOWNLOAD_DIR=/path/to/music` | + +Example with overrides: + +```bash +DOWNLOAD_DIR=~/Downloads/Music python app.py +``` + +If the download folder isn't usable, the app tells you loudly at startup instead of failing silently later. + +## Install as an App (PWA) + +The web interface can be installed like a native app (icon, standalone window): + +- **Desktop Chrome/Edge**: click the install icon in the address bar +- **Android Chrome**: menu → "Add to Home screen" +- **iOS Safari**: share button → "Add to Home Screen" + +Browsers only offer installation over `http://localhost` or HTTPS. If you access the app from another device over plain HTTP (e.g. `http://192.168.1.x:5002`), the install option won't appear — put it behind an HTTPS reverse proxy(like [tailscale serve](https://tailscale.com/docs/features/tailscale-serve)) if you want the full app experience remotely. + +## Usage + +### Downloading from URL + +1. Paste a streaming service URL in the input field +2. Select quality (MP3 128/320 or FLAC 16/24-bit) +3. Click DOWNLOAD + +### Searching for Music + +1. Select a streaming service from the dropdown +2. Choose search type (Albums, Tracks, or Artists) +3. Enter your search query +4. Click DOWNLOAD next to any result + +Search downloads use the quality selected in the URL section's dropdown. + +### Download Statuses + +| Badge | Meaning | +|---|---| +| `DOWNLOADING` | rip is running | +| `COMPLETED` | finished successfully | +| `ALREADY DOWNLOADED` | every track was already in streamrip's database, nothing was re-downloaded | +| `FAILED` | rip exited with an error — expand the item's output, or check the server logs for the full error | + +Finished downloads move to the HISTORY tab after a few seconds. History lives in your browser session only — reloading the page clears it (the downloaded files are of course untouched). + +## Troubleshooting + +1. **"Config file not found" warning in logs**: streamrip isn't configured, or it's somewhere unexpected. Run `rip config open` to create one, or point `STREAMRIP_CONFIG` (in `.env` for Docker) at the right file. + +2. **"DOWNLOAD_DIR ... is not usable" error at startup**: the download folder doesn't exist or isn't writable by the app. Fix the path in `.env` (Docker) or set `DOWNLOAD_DIR` (local). In Docker, also check `PUID`/`PGID` match the owner of the folder (`id -u`, `id -g`). + +3. **Every download fails immediately**: check the server logs (`docker logs streamrip`) — the app logs rip's full error output on failure. Usually it's a permissions problem (see #2) or invalid credentials. + +4. **Searches time out / Tidal or Deezer errors**: check that your credentials in the streamrip config are valid. Test outside the GUI with `rip search track "something"`. + +5. **A download says completed but only got the cover image**: that should now show as `ALREADY DOWNLOADED` instead. The music is already on disk from a previous run; streamrip's database remembers it. To force a re-download, remove the entry from streamrip's database (or delete `downloads.db` to forget everything). + +6. **No install-app option in the browser**: see the PWA section — you need `localhost` or HTTPS. + +7. **No album art when running locally**: CORS issue with some services; downloads still work. + +## Disclaimer + +This tool is for educational purposes only. Ensure you comply with the terms of service of the streaming platforms you use. Support artists by purchasing their music. + +--- + +Fueled by spite diff --git a/app.py b/app.py index 3e7c391..05293be 100644 --- a/app.py +++ b/app.py @@ -20,9 +20,21 @@ app = Flask(__name__) -STREAMRIP_CONFIG = os.environ.get('STREAMRIP_CONFIG', '/config/config.toml') -DOWNLOAD_DIR = os.environ.get('DOWNLOAD_DIR', '/music') -MAX_CONCURRENT_DOWNLOADS = int(os.environ.get('MAX_CONCURRENT_DOWNLOADS', '2')) +#Defaults work for a local run; Docker overrides these via docker-compose environment +STREAMRIP_CONFIG = os.environ.get('STREAMRIP_CONFIG', os.path.expanduser('~/.config/streamrip/config.toml')) +DOWNLOAD_DIR = os.environ.get('DOWNLOAD_DIR', os.path.expanduser('~/Music')) +MAX_CONCURRENT_DOWNLOADS = int(os.environ.get('MAX_CONCURRENT_DOWNLOADS', '2')) + +#Fail loudly at startup if the download dir is unusable, instead of silently per-download +try: + os.makedirs(DOWNLOAD_DIR, exist_ok=True) + if not os.access(DOWNLOAD_DIR, os.W_OK): + raise PermissionError(f"directory is not writable") +except OSError as e: + logger.error("=" * 60) + logger.error(f"DOWNLOAD_DIR '{DOWNLOAD_DIR}' is not usable: {e}") + logger.error("All downloads WILL FAIL. Set the DOWNLOAD_DIR environment variable to a writable directory.") + logger.error("=" * 60) download_queue = queue.Queue() active_downloads = {} @@ -97,17 +109,30 @@ def run(self): }) process.wait() - + + full_output = "\n".join(output_lines) + + if process.returncode != 0: + status = 'failed' + logger.error(f"Download failed (exit code {process.returncode}): {' '.join(cmd)}") + logger.error("rip output (last 30 lines):\n%s", "\n".join(output_lines[-30:])) + elif re.search(r'Skipping track \d+', full_output) and '─ Downloading' not in full_output: + #rip skipped every track (already in its database) and only fetched cover art + status = 'skipped' + logger.info(f"Already downloaded (marked in streamrip database): {url}") + else: + status = 'completed' + broadcast_sse({ 'type': 'download_completed', 'id': task_id, - 'status': 'completed' if process.returncode == 0 else 'failed', + 'status': status, 'metadata': metadata, - 'output': "\n".join(output_lines) - + 'output': full_output }) except Exception as e: + logger.exception(f"Download worker error for {url}") broadcast_sse({ 'type': 'download_error', 'id': task_id, @@ -174,6 +199,11 @@ def generate(): def index(): return render_template('index.html') +@app.route('/sw.js') +def service_worker(): + #Service worker must be served from the root so its scope covers the whole app + return app.send_static_file('sw.js') + @app.route('/api/download', methods=['POST']) def start_download(): data = request.json diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7acb96e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,21 @@ +services: + streamrip: + build: ./ + container_name: streamrip + user: "${PUID:-1000}:${PGID:-1000}" + environment: + - HOME=/config + - STREAMRIP_CONFIG=${CONFIG_DIR}/config.toml + - DOWNLOAD_DIR=${MUSIC_DIR} + - MAX_CONCURRENT_DOWNLOADS=${MAX_CONCURRENT_DOWNLOADS:-2} + volumes: + # Self-mounts: the host dirs appear at identical paths inside the + # container, so absolute paths written in config.toml (database files, + # folders) stay valid both on the host and in the container. This also + # means local runs and the container share one config and one + # downloads database. + - ${CONFIG_DIR}:${CONFIG_DIR}:rw + - ${MUSIC_DIR}:${MUSIC_DIR}:rw + ports: + - "${PORT:-5002}:5000" + restart: unless-stopped diff --git a/docs/agents/domain.md b/docs/agents/domain.md new file mode 100644 index 0000000..8dd1695 --- /dev/null +++ b/docs/agents/domain.md @@ -0,0 +1,36 @@ +# Domain Docs + +How the engineering skills should consume this repo's domain documentation when exploring the codebase. + +## Before exploring, read these + +- **`CONTEXT.md`** at the repo root, or +- **`CONTEXT-MAP.md`** at the repo root if it exists — it points at one `CONTEXT.md` per context. Read each one relevant to the topic. +- **`docs/adr/`** — read ADRs that touch the area you're about to work in. In multi-context repos, also check `src//docs/adr/` for context-scoped decisions. + +If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest creating them upfront. The producer skill (`/grill-with-docs`) creates them lazily when terms or decisions actually get resolved. + +## File structure + +Single-context repo: + +``` +/ +├── CONTEXT.md +├── docs/adr/ +│ ├── 0001-*.md +│ └── 0002-*.md +└── src/ +``` + +## Use the glossary's vocabulary + +When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids. + +If the concept you need isn't in the glossary yet, that's a signal — either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `/grill-with-docs`). + +## Flag ADR conflicts + +If your output contradicts an existing ADR, surface it explicitly rather than silently overriding: + +> _Contradicts ADR-0007 (event-sourced orders) — but worth reopening because…_ diff --git a/docs/agents/issue-tracker.md b/docs/agents/issue-tracker.md new file mode 100644 index 0000000..cce77ec --- /dev/null +++ b/docs/agents/issue-tracker.md @@ -0,0 +1,22 @@ +# Issue tracker: GitHub + +Issues and PRDs for this repo live as GitHub issues. Use the `gh` CLI for all operations. + +## Conventions + +- **Create an issue**: `gh issue create --title "..." --body "..."`. Use a heredoc for multi-line bodies. +- **Read an issue**: `gh issue view --comments`, filtering comments by `jq` and also fetching labels. +- **List issues**: `gh issue list --state open --json number,title,body,labels,comments --jq '[.[] | {number, title, body, labels: [.labels[].name], comments: [.comments[].body]}]'` with appropriate `--label` and `--state` filters. +- **Comment on an issue**: `gh issue comment --body "..."` +- **Apply / remove labels**: `gh issue edit --add-label "..."` / `--remove-label "..."` +- **Close**: `gh issue close --comment "..."` + +Infer the repo from `git remote -v` — `gh` does this automatically when run inside a clone. + +## When a skill says "publish to the issue tracker" + +Create a GitHub issue. + +## When a skill says "fetch the relevant ticket" + +Run `gh issue view --comments`. diff --git a/docs/agents/triage-labels.md b/docs/agents/triage-labels.md new file mode 100644 index 0000000..b716855 --- /dev/null +++ b/docs/agents/triage-labels.md @@ -0,0 +1,15 @@ +# Triage Labels + +The skills speak in terms of five canonical triage roles. This file maps those roles to the actual label strings used in this repo's issue tracker. + +| Label in mattpocock/skills | Label in our tracker | Meaning | +| -------------------------- | -------------------- | ---------------------------------------- | +| `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue | +| `needs-info` | `needs-info` | Waiting on reporter for more information | +| `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent | +| `ready-for-human` | `ready-for-human` | Requires human implementation | +| `wontfix` | `wontfix` | Will not be actioned | + +When a skill mentions a role (e.g. "apply the AFK-ready triage label"), use the corresponding label string from this table. + +Edit the right-hand column to match whatever vocabulary you actually use. diff --git a/static/android-chrome-192x192.png b/static/android-chrome-192x192.png new file mode 100644 index 0000000..48d04df Binary files /dev/null and b/static/android-chrome-192x192.png differ diff --git a/static/android-chrome-512x512.png b/static/android-chrome-512x512.png new file mode 100644 index 0000000..7c66f3b Binary files /dev/null and b/static/android-chrome-512x512.png differ diff --git a/static/css/style.css b/static/css/style.css index e44fc46..be84a5e 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -18,6 +18,7 @@ --success: #00ff88; --error: #ff3333; --warning: #ffaa00; + --info: #00bbff; } body { @@ -196,6 +197,10 @@ button:disabled { border-left: 3px solid var(--error); } +.download-item.skipped { + border-left: 3px solid var(--info); +} + .download-content { display: flex; gap: 15px; @@ -373,11 +378,16 @@ button:disabled { border-color: var(--success); } -.status-badge.failed { +.status-badge.failed { color: var(--error); border-color: var(--error); } +.status-badge.skipped { + color: var(--info); + border-color: var(--info); +} + .config-editor { width: 100%; min-height: 400px; @@ -824,17 +834,167 @@ button:disabled { .error-state { padding: 15px; } - + .error-title { font-size: 16px; } - + .error-message { font-size: 13px; } - + .error-traceback pre { font-size: 10px; padding: 10px; } } + +/* ===== Responsive layout ===== */ + +/* Wide desktop: use the vertical space instead of fixed 500px panes */ +@media (min-width: 1200px) { + .search-results { + max-height: 65vh; + } + + .file-list { + max-height: 60vh; + } +} + +/* Mobile */ +@media (max-width: 768px) { + body { + padding: 12px; + /* keep content clear of notches/home bar in standalone PWA mode */ + padding-left: calc(12px + env(safe-area-inset-left)); + padding-right: calc(12px + env(safe-area-inset-right)); + padding-bottom: calc(12px + env(safe-area-inset-bottom)); + } + + .header { + padding: 20px 0; + margin-bottom: 20px; + } + + h1 { + font-size: 26px; + letter-spacing: 4px; + } + + .subtitle { + font-size: 11px; + } + + .section { + padding: 16px; + margin-bottom: 15px; + } + + /* Stack the URL bar: input, quality, button each full width */ + .input-group { + flex-direction: column; + gap: 8px; + } + + .input-group select { + min-width: 0; + width: 100%; + } + + .input-group button { + width: 100%; + padding: 14px 24px; + } + + /* Prevent iOS zoom-on-focus (triggers below 16px) */ + input[type="text"], + select, + .config-editor { + font-size: 16px; + } + + /* Stack search source above the type selector */ + .search-options { + flex-direction: column; + gap: 8px; + } + + .search-controls { + flex-direction: column; + gap: 12px; + padding: 12px; + } + + /* Tabs share the row evenly instead of overflowing */ + .tab { + flex: 1; + padding: 14px 0; + text-align: center; + font-size: 11px; + } + + /* Search results: tighter, with a full-width download button below the info */ + .search-result-item { + flex-wrap: wrap; + padding: 12px; + gap: 12px; + } + + .result-album-art { + width: 48px; + height: 48px; + } + + .result-download-btn { + flex-basis: 100%; + padding: 12px 20px; + } + + .search-results { + max-height: 60vh; + } + + /* Download items */ + .download-item { + padding: 14px; + } + + .download-album-art { + width: 48px; + height: 48px; + } + + .download-spinner { + margin-right: 0; + } + + /* File list entries stack name over meta */ + .file-item { + flex-direction: column; + gap: 6px; + padding: 12px; + } + + .file-meta { + margin-left: 0; + } + + .config-editor { + min-height: 300px; + padding: 12px; + } +} + +/* Narrow phones */ +@media (max-width: 400px) { + h1 { + font-size: 21px; + letter-spacing: 3px; + } + + .tab { + font-size: 10px; + letter-spacing: 0.5px; + } +} diff --git a/static/favicon.ico b/static/favicon.ico index e6220c6..9ff6bf7 100644 Binary files a/static/favicon.ico and b/static/favicon.ico differ diff --git a/static/js/app.js b/static/js/app.js index 18fb6ba..5fc2a5a 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -92,18 +92,23 @@ function handleDownloadError(data) { } } +function statusLabel(status) { + return status === 'skipped' ? 'already downloaded' : status; +} + function renderDownloadHistory() { const container = document.getElementById('downloadHistory'); if (!container) return; - + if (downloadHistory.length === 0) { container.innerHTML = '
NO DOWNLOAD HISTORY
'; return; } - + container.innerHTML = downloadHistory.map(item => { - const statusIcon = item.status === 'completed' ? '✓' : '✗'; - const statusClass = item.status === 'completed' ? 'success' : 'error'; + const isOk = item.status === 'completed' || item.status === 'skipped'; + const statusIcon = isOk ? '✓' : '✗'; + const statusClass = isOk ? 'success' : 'error'; return `
@@ -116,7 +121,7 @@ function renderDownloadHistory() {
${item.metadata?.title || 'Unknown'}
${item.metadata?.artist || 'Unknown Artist'}
- ${item.status} + ${statusLabel(item.status)} ${item.metadata?.service ? `${item.metadata.service.toUpperCase()}` : ''}
@@ -175,7 +180,7 @@ function updateDownloadElement(id, download) { const statusBadge = element.querySelector('.status-badge'); if (statusBadge) { - statusBadge.textContent = download.status; + statusBadge.textContent = statusLabel(download.status); statusBadge.className = `status-badge ${download.status}`; } @@ -206,7 +211,7 @@ function renderActiveDownloads() {
${item.metadata.title || 'Unknown'}
${item.metadata.artist || 'Unknown Artist'}
- ${item.status} + ${statusLabel(item.status)} ${item.metadata.service ? `${item.metadata.service.toUpperCase()}` : ''}
@@ -659,6 +664,12 @@ window.addEventListener('beforeunload', () => { } }); +if ('serviceWorker' in navigator) { + navigator.serviceWorker.register('/sw.js').catch((err) => { + console.log('Service worker registration failed:', err); + }); +} + document.addEventListener('DOMContentLoaded', () => { const urlInput = document.getElementById('urlInput'); if (urlInput) { diff --git a/static/manifest.json b/static/manifest.json new file mode 100644 index 0000000..c58f8dd --- /dev/null +++ b/static/manifest.json @@ -0,0 +1,22 @@ +{ + "name": "Streamrip Web", + "short_name": "Streamrip", + "description": "Web GUI for streamrip", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#0a0a0a", + "theme_color": "#0a0a0a", + "icons": [ + { + "src": "/static/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/static/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ] +} diff --git a/static/sw.js b/static/sw.js new file mode 100644 index 0000000..8d05e69 --- /dev/null +++ b/static/sw.js @@ -0,0 +1,54 @@ +const CACHE_NAME = 'streamrip-web-v1'; + +const PRECACHE_ASSETS = [ + '/', + '/static/css/style.css', + '/static/js/app.js', + '/static/favicon.ico', + '/static/manifest.json', + '/static/android-chrome-192x192.png', + '/static/android-chrome-512x512.png' +]; + +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE_ASSETS)) + ); + self.skipWaiting(); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then((keys) => + Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))) + ).then(() => self.clients.claim()) + ); +}); + +self.addEventListener('fetch', (event) => { + const url = new URL(event.request.url); + + //Never intercept API calls - /api/events is an SSE stream and must hit the network directly + if (event.request.method !== 'GET' || url.origin !== self.location.origin || url.pathname.startsWith('/api/')) { + return; + } + + //Network first, fall back to cache when offline + event.respondWith( + fetch(event.request) + .then((response) => { + if (response.ok) { + const copy = response.clone(); + caches.open(CACHE_NAME).then((cache) => cache.put(event.request, copy)); + } + return response; + }) + .catch(() => + caches.match(event.request).then((cached) => { + if (cached) return cached; + if (event.request.mode === 'navigate') return caches.match('/'); + return Response.error(); + }) + ) + ); +}); diff --git a/templates/index.html b/templates/index.html index 2ff4113..c1ebcad 100644 --- a/templates/index.html +++ b/templates/index.html @@ -2,13 +2,20 @@ - + Streamrip Web + + + + + + +