From f9cbf8204982a9204e5ee9651f9bc59045d16bd5 Mon Sep 17 00:00:00 2001 From: QuantumFF Date: Wed, 3 Jun 2026 01:39:32 +0800 Subject: [PATCH 01/10] docs: create .gitignore --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3a3407c --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +docker-compose.yml +venv/ From b91d150762f31d7e247464d3e7796673ae5906d0 Mon Sep 17 00:00:00 2001 From: QuantumFF Date: Wed, 3 Jun 2026 01:39:43 +0800 Subject: [PATCH 02/10] ci: removed docker publish --- .github/workflows/docker-publish.yml | 62 ---------------------------- 1 file changed, 62 deletions(-) delete mode 100644 .github/workflows/docker-publish.yml diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml deleted file mode 100644 index 6c0ca6a..0000000 --- a/.github/workflows/docker-publish.yml +++ /dev/null @@ -1,62 +0,0 @@ -name: Build and Push Docker Image - -on: - push: - branches: [ "main", "master" ] - tags: [ "v*.*.*" ] - pull_request: - branches: [ "main", "master" ] - -env: - REGISTRY_IMAGE: anoddname/streamrip-web-gui - -jobs: - build: - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - id-token: write - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Extract metadata - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ env.REGISTRY_IMAGE }} - tags: | - type=ref,event=branch - type=ref,event=pr - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=raw,value=latest,enable={{is_default_branch}} - - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Log in to Docker Hub - if: github.event_name != 'pull_request' - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Build and push Docker image - uses: docker/build-push-action@v6 - with: - context: . - platforms: linux/amd64,linux/arm64 - push: ${{ github.event_name != 'pull_request' }} - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - annotations: ${{ steps.meta.outputs.annotations }} - cache-from: type=gha - cache-to: type=gha,mode=max - provenance: true - sbom: true From c782403eec19613c0dd4490fae00d68d1dcc0d20 Mon Sep 17 00:00:00 2001 From: QuantumFF Date: Wed, 3 Jun 2026 01:39:52 +0800 Subject: [PATCH 03/10] docs: agent files --- CLAUDE.md | 13 +++++++++++++ docs/agents/domain.md | 36 ++++++++++++++++++++++++++++++++++++ docs/agents/issue-tracker.md | 22 ++++++++++++++++++++++ docs/agents/triage-labels.md | 15 +++++++++++++++ 4 files changed, 86 insertions(+) create mode 100644 CLAUDE.md create mode 100644 docs/agents/domain.md create mode 100644 docs/agents/issue-tracker.md create mode 100644 docs/agents/triage-labels.md 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/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. From 696104bdfb3c09108e58d703d0597fc0b47bba40 Mon Sep 17 00:00:00 2001 From: QuantumFF Date: Wed, 3 Jun 2026 01:55:11 +0800 Subject: [PATCH 04/10] fix: use home dir default paths for local testing --- app.py | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/app.py b/app.py index 3e7c391..bde58a2 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,7 +109,11 @@ def run(self): }) process.wait() - + + if process.returncode != 0: + 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:])) + broadcast_sse({ 'type': 'download_completed', 'id': task_id, @@ -108,6 +124,7 @@ def run(self): }) except Exception as e: + logger.exception(f"Download worker error for {url}") broadcast_sse({ 'type': 'download_error', 'id': task_id, From c7ec91dcd4daebe4d70e0e9236b877ca49851bde Mon Sep 17 00:00:00 2001 From: QuantumFF Date: Wed, 3 Jun 2026 02:07:58 +0800 Subject: [PATCH 05/10] feat: handle "already downloaded files" properly in UI --- app.py | 14 +++++++++++--- static/css/style.css | 12 +++++++++++- static/js/app.js | 19 ++++++++++++------- 3 files changed, 34 insertions(+), 11 deletions(-) diff --git a/app.py b/app.py index bde58a2..edcab1d 100644 --- a/app.py +++ b/app.py @@ -110,17 +110,25 @@ 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: diff --git a/static/css/style.css b/static/css/style.css index e44fc46..d172218 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; diff --git a/static/js/app.js b/static/js/app.js index 18fb6ba..1ba9c14 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()}` : ''}
From 85b600e487cfa34ae393a1f3d596790504048119 Mon Sep 17 00:00:00 2001 From: QuantumFF Date: Wed, 3 Jun 2026 02:17:45 +0800 Subject: [PATCH 06/10] feat: add PWA support --- app.py | 5 +++ static/android-chrome-192x192.png | Bin 0 -> 7027 bytes static/android-chrome-512x512.png | Bin 0 -> 20401 bytes static/favicon.ico | Bin 15406 -> 15406 bytes static/js/app.js | 6 ++++ static/manifest.json | 22 ++++++++++++ static/sw.js | 54 ++++++++++++++++++++++++++++++ templates/index.html | 7 ++++ 8 files changed, 94 insertions(+) create mode 100644 static/android-chrome-192x192.png create mode 100644 static/android-chrome-512x512.png create mode 100644 static/manifest.json create mode 100644 static/sw.js diff --git a/app.py b/app.py index edcab1d..05293be 100644 --- a/app.py +++ b/app.py @@ -199,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/static/android-chrome-192x192.png b/static/android-chrome-192x192.png new file mode 100644 index 0000000000000000000000000000000000000000..48d04df02448ccb1169efea323d3bbc65f1a8c3d GIT binary patch literal 7027 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 literal 0 HcmV?d00001 diff --git a/static/android-chrome-512x512.png b/static/android-chrome-512x512.png new file mode 100644 index 0000000000000000000000000000000000000000..7c66f3badd3fcd1f5d06be522ac9d10636ed43a2 GIT binary patch literal 20401 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

{U zOi=PiG;CqxozmCw-hlRSinp8z9ll^jyrD|Ox=vyyWOv1Adsx%xaHhcBG0C^RFM=ml z=VwSe*)SDdXxQ-tK2xa}W`^pMVI&0GDqF(zIP=c>@5UJ2%GcMxQsr{(*OY5~`v$}N z2MP!qKq9sHRdiEc*P?3X=80e3BYv=-gc8`pzWwhGIdYb-R^{3~!PlpHjQA6vfTyD9 z*+~EG4v$8-q^8U<)SJK$LxA{}B1t77tN=i|LrSay6dq1IYh26cav52|pV$nfdy6R# zH|0ziRf`U5+_7=t)g4eT5{Yp`Nn)S3ia6VC#BbsyNoDwvN>nPj0qK^4A2lhtDZ)$I zd+R5At;P)GUX!vZw*T7?V$^XPo*wI&km2%RvG+N*WxWijl5Z0*p8couj3p1h0Vi&= zod=HOQRr>E2%CEz<4FR}1vVpDrkv3dOM>cA~b z6Zcim!$XM11HmvmDCp@(n7TuD=i(8_$m;QZM1p_+tyn7nhLZT9f2B#LSf5bfLu1Y@&d(CDf>IyH0lH(04+XD|h?ZJ#($G{XdO560sqWEvoRy z*i>M1J*7H!TcM2p3s>K&vc?P0So~>6pd8~!gCpDS0Uyyx=`Hg{>`RbAWf<=Q%XR%C zI!O(Gg74nfVlNpT5hk-mb@Fmsb2R8sqWMPmyf;{P8y5|Z1AMTuOeKWY=9^4AAxLq)T13fo!GxC2azyAT$>EHytl&djM0==c z%I-v=NbZceg@y*gZf6`bGHdUPi!@2nV1{yA-+sN`)Fk-arg4=FrijS#cbfsN#CO$F zwQ`a@ljq=<5Xw8eq0DE($?o2F(5AC^cLU(B215I=A&Mb64`>LUfNi0$`N9?9Z4eco zmiDqafz8;Q@HJBUY1>)E86-f9`^_z2)KGIG>!A-?W`16A)s}3A{&+>w2Oqd$r?~z% z*>ubSf}Au39O0XPQNl?VXiE%?Nc_x)*JfC6=0}#b6lq9w6Wo<59CM;^pG6JdFIGI} zKh-`R5#D|5X99xbSDN3gkq%&ldFoKxV)K_OPIP%KPF!H}v1TXW&*OB8efffeIBi`D zuENdv)bv=oU)sI1Zuxvx>@UUf${yZjQjE#^#B5Y^SO@M?b)TL2?ojE)Wwjm($AZzK zOhym=U=fEN|6!fP4^KGg#YzpS##@yh(IGYi!i(~cMpTY<0V6Js4nE+qonF2C@vdDOoP7gh0PC~QTTX&oBMNQr z(=E1d7$WR5zU(}n$bQ>1K6Q0}cyj{3Exf+a6LffT=j3D7YaHIg5$9J`Shp=ox|x=OkmCVL~Mb7 zGi_(*31|W=l%MLk8FWPr$=+y!<|FB!w{>We@$*%Y??p7HMzmtT3cOiG+3bS&u z;PZJCiP3KAx@k=Wmcx`_AgLA^Hkpuq!2+m@BCXKBjn(?n5z*0!&SiOJ#@qDFR*skJ zl>1|$JPMr(&j?L`uL^ePsE4X=CZ!$?lu_TlRCNj9Otfpgi^(ff{PV{5N%VXBrFv;E zrLeZKm6HgaykSsmWQxp2@_?R_DB?QLSdJQOJ>rP~E9j-U61SgCG zZ!w(HX_37l1pGUr8b6tcrp5O-{j2&;Hi58PC&~VPMvl2)XogF}b6cs&=`m{bUynPj zJVU%jfjy!?R>)N?E!}@0YU~+gB7ELHlZr=td%LjT%l<^;8x`NgLBYjv#IKjV%8 zovxto(!#Bz5em>G4us;3ZPLN8O=C**)-fotREHS6`xGG z#+7pO;^EVRuo#a$RfcXM3N;9Zb8?5bPB>2>3kV@My}|`v8wpLrr>(~S0xN4P8m{+G-Jh{0UIG}-UWQ_@#S7)A07~((pKCu$Vn2&N40^^n=by2T zT1XVs$dbzcJImRp4`X3~VS#+3Y29So0O4m;s=qC_F+XW<;9sU>x53Ae$3)($!?-*2^xJHcb6xFG~Kue)Gqyao`s)IE*Sj zUCEK$D`(OaT?RcGyXFp$Pc!PblGHQ2yYFlYcTX+2s119MDo57?#rYFAq$WOKX}AtU zj)o-HM_n9S(RHnEI%jA@h4!{r%DWU3g0(w zxnmL&qciIJW?Uxwip{Z1#@HFk(%r$K^5bwI=y{VzCr4tM$Zq|CW2rEsLg@c(`}h9eJ&|@nzE>Q-PPAukTmbAIvuxe;q|}qK_HYES3Rj){tJk{um-f=CZ?XR` z*w=j7A!7wRO=E?8m|Bm>$;sZ^)<`<-Ke{YrHrLD9=lf(V1-m}q`TSe$$jams>mHq% zp*T0iV|vu3)%*QczyIRDQu^l?TaNm-ccw3xt#bE-YV55mz_UVL?f1PJ|7-gn;Vk_Y z;4&}ZRN9>rFWq|9Gy+Gvfr}eGHv{)Hf=)wX1C|>SAv{iw;Nyu7=mKlK?MourA;%jv z1G58<7TZF|u}Kf60V}iUOKX~eTR9aP8XSS=@T7qT$D;}&z=M&1w%!5m?=A42RSGf30C?onfdxqdToC8w0iCx)^(=5w z0CaMb1Zem*z>ott(*RPv9q7C}lhR}%=I{W`F<2qd(gbnds5c!(V-q-+G@57yM)M|c z-|L_&f*Mo4-zh#Hxm_$7T7bHC&cPCivb8cUHx3v IIVCg!0EG)hJpcdz literal 0 HcmV?d00001 diff --git a/static/favicon.ico b/static/favicon.ico index e6220c6dea2411afab8864770f416520507e6526..9ff6bf70114404310fe3707878202e9eeaefd25d 100644 GIT binary patch literal 15406 zcmeHN=T}un5WhLeA7DP@J70uH?}$=_2nYfZ(O`)fGzV)?iBYg1V2`L+uu-K*Z^o9` zP_e}ZSTM#U8cR(6ipl;~_IdB+-p6ZV$-U?7*}c0nJ2ShpZDu6tBk5yl#0ZJyF4Fu@ zBq>CaBo~)g|E5nRsgl+C`3@-YEy&wswVWB*E2;CrzlxNZQ(zT-!jx%Lb)k>rZm#X5g3@|kuI|1^Nz>9T z_7U?%Dz)ByPOW#IbDYLoe^TAmU(I~9-FrdtlhSni*w%D{!-hsi(aK$A)PDaZ|Ib=i zL?uTr^75|EbGrDAw;ppCFJC_nlafA%DlhbsyQe3`Bu}D_{(q?L?h9S~va>fizF%N4 zVGf#ZKc=+IMP~SQ@(G0{?x=H8%wtDzVSFuoT4- z_9wj$b2D7&eHwp**AMUfab!;2Jv?dko=VzK)k4$fFQFgmkJ8eO#k7!}Uqy|_>8pj= zR9N0X*ptLQ?Fcnd~!(o zdwx9cblRTY-h8gx*d0K~+tT}#)>Sm|RC}f0To3tIY(GFg{sB(mBP=?G#Q7~~BgeT5 zod_H?nwol^nCT0cL)~{NYJ8$YJcLG!rP`|x2K6myV64SGO4P~oh8w?A_~)_q@xaa{ z+=)bg)v~*XsIw+N@Y7XtGr8N$1MXPz{)0z{)0Els_&rHpFE6KOeNPy7c^%3#7A&=y zeqmXIqMzW=VZ09yFE3h@zlB=*o+{cmR=4r?)LEH|deF!HQ@x)I^z!vbis_1I!+t0qgLe6V{>N z0rzcd=?XaLjlf4Pr;j^#$nX)NSfZtF!~W>$?PE86&I4QTYxmdM%)`b* ztxmm%Bu!1Ho$Y57I-!;qbB*_G>erbL$t-kGD<3^Jnr1CtK{@P<#reCQC9JiK>|6@7 z_pJEqaBq4Z(7)~Y~dZSu#Lek=qPv>W(OEdO|l2|MDC2h4=APbGcsw<|XZD`I+J; zPaSq03JM)Viwd@|aewU}kdHgo1AGOMaS0R=GoIjQ*YmNU9yYSi~EV0e++W19iG%DCa|z;r}T;ecdJ;%xyr(D5uMSy{@_EF9k1%_o=(~ zI$lQb?MRMzm$|tGR>h52(iM4|x1YbJk1;wvnZlxDXQ$Z2wSr<9p<2zx2cCBdsY$Jin+3w!U31=;4fCp8@G z{ZnSocbX1i9ja;IU+^#H3jP-O;;Jvbjz^N?Ri5wRz7oKDvzRHwd&%47<-*bir}4kF z;g}{J%!}Y%p8NO*Qf_e>(~*Z}JdFL+x!)?_Q#17M9u-};6|nODZ7pm0nK4%I1?lyL zxPbJmAAk7K_4>B8zM`vFlZGY!V_9t9o+IZu9?of; z#aeXX-)v^Q%6Uf_F#%fe_SA_uK{XAv{^^It2$0Tg1^D3$1#@ zQ>e#>^Uvx5k9+X`lUJy<@7bU;06uTE-m7WAevCO1=Ld3U`pR&3*x5GNN4Ie8CR(+- zg57WQ_k73+ejq<|8nLd(asNY|Nbv0nSj1sM57lzo`~S+PpUdtKgX3Amd^;;gJTKOR z$&T0%OY-4tvUJ`!i~kZ9KkduT4@ig9@xIbs@d=UlOUKOwF|oV!|b;7)_}gFP3qqOb$J4W6}V zV!!hZ2;@8=E@%3@#q6E3hS?t0@Hn-27GsY+9rjLl507_-Z!KAjI>WbN=zyUEh7K4y R@TPTug$Eg*F#>jvz<({Su|og= literal 15406 zcmeI3WsDTd7JvtLSlnT;$Ks0y2_6XW14wX!1qcp5kU)@-06+MF69NQ+yGw$*yM^Ge zxV!s#{ndTducxPHrh9kT+}yjB)K*V-)v-Et>eM;4K3_6l3SU%|&uF84eNy^-)qFl* zbo5`pW3u{u3ygQMvH$!2AD{17PMC;2-+WuJ zV1cAaks^SO9zA-fu{Unqkgs3AsxkD}75o%_4;(l!U_A1o3>`XDN|!DzJ9g|aX*qJ_ zh_r3n*8F|v&K*BK(x*=^$cHjy$Pfh^e){(9tKL_sQbqOOwQHB^^YiCV$(l8*Nki(? zspZ3m59%BE;jdS(Uh?eOGg-KBq1mr|`SSAZ+c(8uh71|Z_sDY7rcG-6#*G{0%$YML zt^4=yS29Cxz`w+cq6KhRmBLWO|wz^GNLmfX8{FX(Xeu|b0d zj$^exWXhCD`uFcI%>DK2*X8ETn~L5+g9b@dR8+{Z8b%_w2D`b&#>lqdM6r%hc zc~7Fr(0gOf%*xtLl`56`jm_p6TR>%{)sG^r_65Ge>ss z-Yw6cKbI|Aw#f43%jLz37jpRUVVN;whCF=uP%d4%B<0GLGimpV&)y&V?zIdXHY{TJ zt5>fs*jBfNy`WXARuRLWCQTY)Z*W`KlXK+A5i$Jv^XFIg(k=e`_wP%gLWL5HA6;hd z(J5qVE%+Z(r%shAQ>LhW$@-1H=;z?UgVoxw>yw+`_$%yd!R8%bg#9XK&YS|jPUY9H zU$S}gX33TdO~aTmV+7j66=47P@grO} zMc?Gfll^>~U~_< z!+iPjDOqbD)DEBD=g*&)B1MV>U90ov%?pVi8-C%!1wYX61AZ;9N|h=J^KREm=AlWG zCaPbD4jlr%0~8+$TcTmvTYN|DFKQytZ7Y7p@EaW1BrWSTYu1?a%lEx|_i7Mki;r2L zK!Kq6kuiO&#R)$&q)eGo1`HUW^c4Tneg>&pwW?^}T*E^~rAn0wil4P?AA{|&^A9f8 zC-C5Z;>3voFxi)xZ!I5az?VnA?Q++@*|)DTt!#V0>-;D8AZ9kJ5q z^o)L6=Kwv&pRnt@3DkIircjl>%vHU5^}^z3-e%99?Kf{$T=98^HvA-DJMnCP$9{3_ z*s+MwaPi_r$(1XYE8LnE&g7g=?ep!0f5u`H@gFr@FD>?H=FFLX`rsBnYi-GrB|+DQ zTRNCC&e((h> zsD1nPDwg6_&)nio>i7S1*gsGf2220mLY%}1(fYa>H7QQ=kJj^e?LmfGB#rx*%K*PT zUAlCtTH|cQ=Q&U5{g!8ZWzO$fZ+OR8&MW#|Vz!(|=gyt0V&3E;h?lf!(?-Shdw^k2GkX`;>pdPD9hQswB;qkg*er{@B_Ca0xkoe(~l2hPKhj!lP? zLCKOORlX}Bv@@36AL}L@zDR$^mTcR$EurMATy5^$xf7Ls^oQJ<&LJj*iGv3Zs+hL6 zzmb-+9Uk_&MvWS3{bF033OcRxrsxMXSlsme`}Zp6+_-ULwIA5=Me=v%%$Ze=m^`_T zQ902MO~Z!|mxc`+s!Gn{>eZ`GU6mch4m^4CL?%s|6w&;G<{f=0Uc9)>pFdy8Qrm7v zf{d5&M*N$Y7JAXGTQ_U}lazyPBaSn9d8ox4;Iy}H1d1zv1qUKgO+0t>e8slVjnY zOMHA(*50aBs{(uk?Baw86Qoh2MoLfl?UfJa<@D*(YX1&3mT&m11q&ARtVh@Yr`!^@ zockio4{%!Rt=776EUe+$O;iEh0J#_Z#*Uz*K_z|(O zvC4MG8Ge5#pAP=;u{(F}tj06$+_`fh`G(&`ixzpZr{q4G1ZSC5% z)%ntCzQKze+~jC9yfS6Vn0Ep--MV!{Kk_YGwydmQzh21|x;X0=D^@Ikc5tRmn^vYz zpKjjC)3|htjceMpspD8pv$X|JF!>`3?)h1DK##@OLHAgnIyVOG>|xwz)BT)U?$W`d z(;R@4IdU53^o~0wEh&C_(C*L(KtQTcOgfBQ4*f9%+?j&lIK zQ2AXuEc6#TG!0fh?DQjZ^1kGhu#NV0h%Zf!)Xn)ePo6xE`91bJ`~6Eh&Gz5ec=XO* zH2v6Ea_-!RvgYgU{o{;10bjCk;lk>!1Ns%L;;Z8CS@#{*J-)ct^BMaiKCpcbfK4vC zXV0GM{L4DA_p$Q8dgty0G9-?LPa3T99ey=DTJqd=jhvlbzTtjj6S()L@9AoqfQdcU zex=5xzXxmS=eBAX=;E?v%RJvR(!604xOd=|MvXTTEjW?AeZSMRxoNQp#HqM9l$bn# zi#Z-QZd}-V72)_GMvO0~^(7Lr4F?0AWk2(3bAs{0S%BEUpPmOTcl?Y<+%vTDgRhTm zyLn;_g(a`&VA+bJL&w5FPE*sgt_PLcD_*8uJ1_*ly^g z^2|HFaR%W3Er^+S@7`U-#oYK7ZfJ^6AN)}Cio0g~zeuxY&D8m9tl^81<5+0imBkKn wClCLQGb8>3`=gT&8y_0}JxK@tb~<28Nm5CYK>Q@|UrMX{iU0rr diff --git a/static/js/app.js b/static/js/app.js index 1ba9c14..5fc2a5a 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -664,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..8e76f71 100644 --- a/templates/index.html +++ b/templates/index.html @@ -9,6 +9,13 @@ + + + + + + +

From 8159261906eb9b6e3e646f113880eaa7281d6a47 Mon Sep 17 00:00:00 2001 From: QuantumFF Date: Wed, 3 Jun 2026 02:24:09 +0800 Subject: [PATCH 07/10] feat: proper support for mobile viewport --- static/css/style.css | 156 ++++++++++++++++++++++++++++++++++++++++++- templates/index.html | 2 +- 2 files changed, 154 insertions(+), 4 deletions(-) diff --git a/static/css/style.css b/static/css/style.css index d172218..be84a5e 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -834,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/templates/index.html b/templates/index.html index 8e76f71..c1ebcad 100644 --- a/templates/index.html +++ b/templates/index.html @@ -2,7 +2,7 @@ - + Streamrip Web From 7f83a2bd2d1a61c2a7ee77b59f137b2d2cca3068 Mon Sep 17 00:00:00 2001 From: QuantumFF Date: Wed, 3 Jun 2026 02:45:13 +0800 Subject: [PATCH 08/10] fix: changed env values declaration method --- .env.example | 18 ++++++++++++++++++ .gitignore | 2 +- docker-compose.yml | 21 +++++++++++++++++++++ 3 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 .env.example create mode 100644 docker-compose.yml 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 index 3a3407c..66bca12 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,2 @@ -docker-compose.yml +.env venv/ 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 From f90d710006acd06e5cf9e681e241027c8127bf7b Mon Sep 17 00:00:00 2001 From: QuantumFF Date: Wed, 3 Jun 2026 02:58:18 +0800 Subject: [PATCH 09/10] docs: updated readme using new configuration method --- README.md | 402 ++++++++++++++++++++++++++++-------------------------- 1 file changed, 208 insertions(+), 194 deletions(-) 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 From b03da35dcda56986f86f9a75afd1dc2a5c541ff7 Mon Sep 17 00:00:00 2001 From: QuantumFF Date: Wed, 3 Jun 2026 03:12:25 +0800 Subject: [PATCH 10/10] Revert "ci: removed docker publish" This reverts commit b91d150762f31d7e247464d3e7796673ae5906d0. --- .github/workflows/docker-publish.yml | 62 ++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 .github/workflows/docker-publish.yml diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..6c0ca6a --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,62 @@ +name: Build and Push Docker Image + +on: + push: + branches: [ "main", "master" ] + tags: [ "v*.*.*" ] + pull_request: + branches: [ "main", "master" ] + +env: + REGISTRY_IMAGE: anoddname/streamrip-web-gui + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + id-token: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY_IMAGE }} + tags: | + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=raw,value=latest,enable={{is_default_branch}} + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + annotations: ${{ steps.meta.outputs.annotations }} + cache-from: type=gha + cache-to: type=gha,mode=max + provenance: true + sbom: true