Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,18 @@ webapp/dist
.git
.gitignore
.env
.env.*
.env.local
.jwt-secret
*.keypair.json
*-keypair.json
gundb-keypair.json
*.pem
*.key
.cache
.claude
.fallow
graphify-out
*.md
!README.md
# Served by GET /api/changelog and copied into the runtime image (Dockerfile:127)
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -109,3 +109,7 @@ test-config.json
# Pix Agent
.pi/
.pi-lens/

# Local cache & knowledge graph
.cache/
graphify-out/
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ All notable changes to this project will be documented in this file.

### Added

- **An upload now says how far along it is, and can be started on its own.** Saving a release fires a chain of requests — release metadata, cover art, then the audio, then the per-track edits — and the audio is the slow half, tens of megabytes a file. All the artist got was a disabled Save button: no way to tell an upload in flight from a stuck one, on the one action of the whole app that routinely takes minutes. Queued audio now sits in the tracklist with its size, the toolbar grows an **Upload N files** button that sends the audio without saving-and-closing the release (creating the release first if it does not exist yet, since a file needs somewhere to land), and a bar under the toolbar reports the whole chain: which file is going up, how far its bytes have got, and how much of the batch is left. Each queued row carries its own bar, so one stalled file is visible rather than averaged away. Audio goes up one request per file instead of one request for the batch — the browser reports bytes for a request as a whole, so a single 400 MB request can only ever say "somewhere between 0 and 100%", and one failure partway through took every file with it; per file, a failure costs only that file and the rest stay queued for a retry. Save and Publish end in a toast rather than in silence. The same bar reached the Upload Tracks and Upload Sample dialogs: the tracks dialog counted finished files, so a single large file sat at 0% until it was completely done, and now weights progress by bytes and leaves the finished bar next to the result.

- **A public playlist can now be read without an account.** `GET /api/playlists/:id` is members-only, so a playlist marked public was visible to the rest of the federation by name alone: the community player could list one and never play it, and the same gap kept a listener from importing their own public playlists into a remote player. The new `GET /api/playlists/:id/public` answers anonymously for a playlist already marked public — 404 for anything else, so whether a private playlist exists is not something a stranger can probe. It is deliberately narrow: a purpose-built response rather than the internal row (no file paths, no per-user `starred`/`rating` fields), and every track still goes through `canConsumeTrack` as a guest, so a private album's track appears only where being in this public playlist is what makes it consumable. Wildcard CORS is opted into by that one path, and never for a request carrying a cookie or `Authorization` header.
- **Network DIG**: crate digging now also works over this TuneCamp network's own users, not just Bandcamp's fan-collector graph. From a release in your own library, `POST /api/dig/run` with `source: "network"` finds who else on this instance (and, opt-in, on federated peers) starred it, and what else those users starred — ranked by co-occurrence, aggregate counts only, never usernames. Correlation uses a release's `external_id` when it has one (Bandcamp/MusicBrainz match) or a normalized title+artist fallback, so a TuneCamp-only release with no external match is still included. Peers answer via the new public `POST /api/community/dig-lookup`, gated behind the `digNetworkOptIn` setting (off by default) and filtered to public/released catalog items only. See [FEDERATION.md](docs/FEDERATION.md#network-dig-cross-instance-discovery).
- **Optional public URL with no domain and no port forwarding**, for instances on a home machine behind NAT. `docker compose --profile tunnel up -d` starts an SSH reverse tunnel to [srv.us](https://docs.srv.us/) and reports an `https://<id>.srv.us` address in the tunnel container's logs; `install.sh` offers it (or `--tunnel`) when no domain is given, and skips Nginx and Certbot in that mode. The address is derived from an SSH key kept in the `tunecamp_data` volume, so it survives restarts — losing that volume changes the URL and breaks links already shared. `TUNECAMP_PUBLIC_URL` is deliberately not set from it: the per-request fallback already covers OG tags and links, and federation stays an explicit choice. srv.us is a free relay whose docs warn that bandwidth counts twice and heavy use may be throttled, so it suits a handful of listeners, not a public music site.
Expand Down
63 changes: 11 additions & 52 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -3,27 +3,14 @@
# Multi-stage build for production deployment
# ===================================================

# Top-level ARGs passed by CapRover / Docker
ARG TUNECAMP_PUBLIC_URL
# Top-level ARGs for build time
ARG TUNECAMP_RPC_URL
ARG TUNECAMP_CURRENCY_CONTRACT
ARG DISCOGS_TOKEN
ARG TUNECAMP_DOWNLOAD_DIR
ARG TELEGRAM_BOT_TOKEN
ARG TELEGRAM_MASTER_ID
ARG OPENROUTER_API_KEY
ARG OPENROUTER_MODEL
ARG TUNECAMP_GDRIVE_CLIENT_ID
ARG TUNECAMP_GDRIVE_CLIENT_SECRET
ARG STRIPE_SECRET_KEY
ARG STRIPE_WEBHOOK_SECRET
ARG STRIPE_ONRAMP_SECRET_KEY
ARG TUNECAMP_ADMIN_USER
ARG TUNECAMP_ADMIN_PASS

# CapRover passes this on deploy; using it invalidates cache per commit
ARG CAPROVER_GIT_COMMIT_SHA


# Build stage
FROM node:22-alpine AS builder

Expand Down Expand Up @@ -129,50 +116,22 @@ COPY --from=builder /app/CHANGELOG.md ./CHANGELOG.md
# Create directories for data persistence
RUN mkdir -p /music /data /radata

# Re-declare ARGs for production stage
ARG TUNECAMP_PUBLIC_URL
ARG TUNECAMP_RPC_URL
ARG TUNECAMP_CURRENCY_CONTRACT
ARG DISCOGS_TOKEN
ARG TUNECAMP_DOWNLOAD_DIR
ARG TELEGRAM_BOT_TOKEN
ARG TELEGRAM_MASTER_ID
ARG OPENROUTER_API_KEY
ARG OPENROUTER_MODEL
ARG TUNECAMP_GDRIVE_CLIENT_ID
ARG TUNECAMP_GDRIVE_CLIENT_SECRET
ARG STRIPE_SECRET_KEY
ARG STRIPE_WEBHOOK_SECRET
ARG STRIPE_ONRAMP_SECRET_KEY
ARG TUNECAMP_ADMIN_USER
ARG TUNECAMP_ADMIN_PASS

# Environment variables
# Re-declare ARG for production stage
ARG CAPROVER_GIT_COMMIT_SHA

# Environment variables (runtime defaults)
# Security Note: Sensitive credentials, API keys, and secrets (e.g. STRIPE_SECRET_KEY,
# TUNECAMP_ADMIN_PASS, OPENROUTER_API_KEY, TELEGRAM_BOT_TOKEN, etc.) MUST NOT be
# passed as Docker build ARGs to prevent leaking in image metadata/history.
# They are loaded securely at runtime via container environment variables
# (e.g. docker-compose.yml env_file, docker run -e, or CapRover App Config).
ENV NODE_ENV=production
# Deploy commit, used by Sentry as release tag (set SENTRY_DSN to enable crash reporting)
ENV TUNECAMP_GIT_SHA=$CAPROVER_GIT_COMMIT_SHA
ENV TUNECAMP_DB_PATH=/data/tunecamp.db
ENV TUNECAMP_MUSIC_DIR=/music
ENV SKIP_STARTUP_MAINTENANCE=true

# Inject build args as runtime env variables
ENV TUNECAMP_PUBLIC_URL=$TUNECAMP_PUBLIC_URL
ENV TUNECAMP_RPC_URL=$TUNECAMP_RPC_URL
ENV TUNECAMP_CURRENCY_CONTRACT=$TUNECAMP_CURRENCY_CONTRACT
ENV DISCOGS_TOKEN=$DISCOGS_TOKEN
ENV TUNECAMP_DOWNLOAD_DIR=$TUNECAMP_DOWNLOAD_DIR
ENV TELEGRAM_BOT_TOKEN=$TELEGRAM_BOT_TOKEN
ENV TELEGRAM_MASTER_ID=$TELEGRAM_MASTER_ID
ENV OPENROUTER_API_KEY=$OPENROUTER_API_KEY
ENV OPENROUTER_MODEL=$OPENROUTER_MODEL
ENV TUNECAMP_GDRIVE_CLIENT_ID=$TUNECAMP_GDRIVE_CLIENT_ID
ENV TUNECAMP_GDRIVE_CLIENT_SECRET=$TUNECAMP_GDRIVE_CLIENT_SECRET
ENV STRIPE_SECRET_KEY=$STRIPE_SECRET_KEY
ENV STRIPE_WEBHOOK_SECRET=$STRIPE_WEBHOOK_SECRET
ENV STRIPE_ONRAMP_SECRET_KEY=$STRIPE_ONRAMP_SECRET_KEY
ENV TUNECAMP_ADMIN_USER=$TUNECAMP_ADMIN_USER
ENV TUNECAMP_ADMIN_PASS=$TUNECAMP_ADMIN_PASS

# Puppeteer & Chromium configuration for Alpine
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
Expand Down
3 changes: 1 addition & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,7 @@ services:
args:
- TUNECAMP_RPC_URL=${TUNECAMP_RPC_URL:-https://mainnet.base.org}
- TUNECAMP_CURRENCY_CONTRACT=${TUNECAMP_CURRENCY_CONTRACT:-0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913}
- DISCOGS_TOKEN=${DISCOGS_TOKEN:-}
- TUNECAMP_DOWNLOAD_DIR=${TUNECAMP_DOWNLOAD_DIR:-/data/downloads}

image: tunecamp:latest
container_name: tunecamp
ports:
Expand Down
14 changes: 14 additions & 0 deletions docs/adding-music.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,20 @@ number, so a well-tagged album needs almost no typing. Anything it guesses is
editable before you save, and fields you have already filled in are never
overwritten.

### Watching an upload happen

Audio is the slow half of a release, so the editor never leaves you guessing.
Queued files are listed in the tracklist with their size, and the toolbar grows
an **Upload N files** button — press it and the audio goes up on its own,
without saving-and-closing the release: a progress bar under the toolbar names
the file being sent and how far along the whole batch is, and each queued row
carries its own bar. A file that fails stays queued, so a retry re-sends only
what did not make it.

Files also go up on **Save**, which shows the same bar across the whole chain of
work — release details, cover art, each audio file, then the track edits — so a
long save reads as progress rather than as a frozen page.

### Importing a tracklist

**Import from Bandcamp** fills the release fields and the cover from a public
Expand Down
15 changes: 15 additions & 0 deletions docs/it/adding-music.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,21 @@ un album ben taggato richiede quasi zero digitazione. Tutto ciò che viene
indovinato resta modificabile prima del salvataggio, e i campi che hai già
compilato non vengono mai sovrascritti.

### Vedere l'upload mentre avviene

L'audio è la metà lenta di una release, quindi l'editor non ti lascia mai a
indovinare. I file in coda sono elencati nella tracklist con la loro dimensione
e nella toolbar compare un pulsante **Upload N files**: premilo e l'audio parte
da solo, senza salvare-e-chiudere la release. Una barra di avanzamento sotto la
toolbar dice quale file sta partendo e a che punto è l'intero blocco, e ogni
riga in coda ha la sua barra. Un file che fallisce resta in coda, così un nuovo
tentativo rimanda solo ciò che non è arrivato.

I file partono anche con **Save**, che mostra la stessa barra su tutta la
catena — dati della release, copertina, ogni file audio, poi le modifiche alle
tracce — così un salvataggio lungo si legge come avanzamento e non come una
pagina bloccata.

### Importare una tracklist

**Import from Bandcamp** compila i campi della release e la copertina da una
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "tunecamp",
"version": "6.0.11",
"version": "6.1.0",
"description": "A decentralized music platform for artists and labels, with streaming, federation, and Subsonic API support",
"main": "dist/server/server.js",
"type": "module",
Expand Down
4 changes: 3 additions & 1 deletion src/server/routes/admin/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -522,7 +522,9 @@ export function createAdminRoutes(container: ServiceContainer): Router {
return res.status(403).json({ error: "Super Root access required" });
try {
const settings = identity.getAllSettings();
// jwtSecret is never exposed to the client — it lives only server-side.
// Sensitive cryptographic material and server secrets are never exposed in general settings
delete (settings as any).site_private_key;
delete (settings as any).jwtSecret;
res.json({
...settings,
musicDir: container.musicDir,
Expand Down
4 changes: 4 additions & 0 deletions src/server/routes/api/payments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,10 @@ export function createPaymentsRoutes(container: ServiceContainer): Router {
const verifyLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 30, message: "Too many verification attempts, try again later." });
router.use(["/verify", "/subscription/verify"], verifyLimiter);

// Checkout session creation calls Stripe; rate-limit by IP to mitigate card testing bot floods
const stripeCheckoutLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 60, message: "Too many checkout requests, please try again later." });
router.use(["/stripe/create-session", "/stripe/create-subscription-session", "/stripe/create-trackcap-session"], stripeCheckoutLimiter);

/**
* GET /api/payments/purchases
* Returns purchased items for the authenticated user.
Expand Down
2 changes: 1 addition & 1 deletion webapp/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "webapp",
"private": true,
"version": "2.16.5",
"version": "2.17.0",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
19 changes: 17 additions & 2 deletions webapp/src/components/modals/UploadSampleModal.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { useState, useRef, useEffect } from "react";
import API from "../../services/api";
import { UploadCloud, Music2 } from "lucide-react";
import { UploadProgress } from "../ui/UploadProgress";
import { formatBytes } from "../../utils/format";

const LICENSES = ["cc0", "cc-by", "cc-by-sa", "royalty-free"];

Expand All @@ -19,6 +21,8 @@ export const UploadSampleModal = ({
const [attributionName, setAttributionName] = useState("");
const [tags, setTags] = useState("");
const [uploading, setUploading] = useState(false);
/** Bytes sent, as a percentage. Samples are small but packs are not. */
const [percent, setPercent] = useState(0);
const [error, setError] = useState("");

useEffect(() => {
Expand All @@ -33,6 +37,7 @@ export const UploadSampleModal = ({
setTags("");
setError("");
setUploading(false);
setPercent(0);
dialogRef.current?.showModal();
};
document.addEventListener("open-upload-sample-modal", handleOpen);
Expand All @@ -53,11 +58,12 @@ export const UploadSampleModal = ({

setUploading(true);
setError("");
setPercent(0);
try {
if (isPack) {
await API.uploadSamplePack(files, { title, description, license, attributionName });
await API.uploadSamplePack(files, { title, description, license, attributionName }, setPercent);
} else {
await API.uploadSample(files[0], { title, description, bpm, musicalKey, license, attributionName, tags });
await API.uploadSample(files[0], { title, description, bpm, musicalKey, license, attributionName, tags }, setPercent);
}
onUploadComplete?.();
dialogRef.current?.close();
Expand Down Expand Up @@ -146,6 +152,15 @@ export const UploadSampleModal = ({

{error && <div className="text-error text-sm text-center">{error}</div>}

{uploading && (
<UploadProgress
label={isPack ? `Uploading ${files.length} files` : `Uploading ${files[0]?.name ?? "sample"}`}
percent={percent}
detail={formatBytes(files.reduce((sum, f) => sum + f.size, 0))}
color="secondary"
/>
)}

<div className="modal-action">
<button type="button" className="btn btn-ghost" onClick={() => dialogRef.current?.close()}>Close</button>
<button type="submit" className="btn btn-secondary" disabled={uploading || files.length === 0 || !title}>
Expand Down
38 changes: 37 additions & 1 deletion webapp/src/components/modals/UploadTracksModal.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { MemoryRouter } from "react-router-dom";
import { UploadTracksModal } from "./UploadTracksModal";
Expand Down Expand Up @@ -118,6 +118,42 @@ describe("UploadTracksModal", () => {
expect(mockAPI.uploadTracks).toHaveBeenCalled();
});

it("follows the bytes the browser reports, not the file count", async () => {
// Hold the upload open so the intermediate progress is observable, and
// drive it through the onProgress callback the API layer passes down.
let report: ((percent: number) => void) | undefined;
let finish: (() => void) | undefined;
mockAPI.uploadTracks.mockImplementation((_files: any, options: any) => {
report = options.onProgress;
return new Promise<void>((resolve) => {
finish = resolve;
}) as any;
});

renderModal();
const fileInput = document.querySelector('input[type="file"]')!;
fireEvent.change(fileInput, {
target: { files: [new File(["audio content"], "test.mp3", { type: "audio/mpeg" })] },
});
fireEvent.click(screen.getByRole("button", { name: /Start Upload/i }));

await waitFor(() => expect(report).toBeDefined());

act(() => report!(37));
await waitFor(() => {
expect(screen.getAllByText("37%").length).toBeGreaterThan(0);
});

await act(async () => {
finish!();
});

// The finished bar stays put next to the result, rather than vanishing.
await waitFor(() => {
expect(screen.getByText(/Finished — 1 of 1 file/i)).toBeInTheDocument();
});
});

it("shows error on upload failure", async () => {
mockAPI.uploadTracks.mockRejectedValueOnce(new Error("Upload failed"));
renderModal();
Expand Down
Loading
Loading