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
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
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
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
140 changes: 104 additions & 36 deletions webapp/src/components/modals/UploadTracksModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,23 @@ import { useState, useRef, useEffect } from "react";
import API from "../../services/api";
import { UploadCloud, Music, X, Trash2 } from "lucide-react";
import type { Track } from "../../types";
import { UploadProgress } from "../ui/UploadProgress";
import { formatBytes } from "../../utils/format";

/** Stable key for a queued file, so removing one doesn't shift the others' progress. */
const fileKey = (file: File) => `${file.name}:${file.size}:${file.lastModified}`;

/** Byte-level progress of one upload run. */
interface BatchProgress {
/** Files in the run. */
total: number;
/** Files finished, successfully or not. */
done: number;
/** Bytes sent across the run, as a percentage of its total size. */
percent: number;
/** Per-file percentages, keyed by `fileKey()`, for the file rows. */
files: Record<string, number>;
}

export const UploadTracksModal = ({
onUploadComplete,
Expand All @@ -15,7 +32,17 @@ export const UploadTracksModal = ({
const [releaseTitle, setReleaseTitle] = useState<string>("");
const [artistId, setArtistId] = useState<string | number>("");
const [uploading, setUploading] = useState(false);
const [progress, setProgress] = useState(0); // Mock progress for now
/**
* Progress of the current (or last) upload run, or null before the first
* one.
*
* Counting finished files was the only feedback here, so a single 80 MB
* file sat at 0% until it was completely done. This is weighted by bytes,
* so the bar moves while the bytes move, and it survives the end of the run
* so the artist sees a finished bar next to the result rather than a
* progress bar that vanishes.
*/
const [batch, setBatch] = useState<BatchProgress | null>(null);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const [existingTracks, setExistingTracks] = useState<Track[]>([]);
Expand Down Expand Up @@ -47,7 +74,7 @@ export const UploadTracksModal = ({
setError("");
setSuccess("");
setUploading(false);
setProgress(0);
setBatch(null);
dialogRef.current?.showModal();
};

Expand Down Expand Up @@ -122,41 +149,62 @@ export const UploadTracksModal = ({

setUploading(true);
setError("");
setProgress(0);

const batchFiles = [...files];
// Weighted by size, not by file count: three short interludes finishing
// first must not report an album as nearly uploaded.
const totalBytes = batchFiles.reduce((sum, f) => sum + f.size, 0);
const sentPercent: Record<string, number> = {};
setBatch({ total: batchFiles.length, done: 0, percent: 0, files: {} });

const publish = (finished = 0) =>
setBatch((prev) => {
const sentBytes = batchFiles.reduce(
(sum, f) => sum + (f.size * (sentPercent[fileKey(f)] ?? 0)) / 100,
0,
);
return {
total: batchFiles.length,
done: (prev?.done ?? 0) + finished,
percent: totalBytes > 0 ? (sentBytes / totalBytes) * 100 : 0,
files: { ...sentPercent },
};
});

let successCount = 0;
let failCount = 0;
let processedCount = 0;

const updateProgress = () => {
const percent = Math.round((processedCount / files.length) * 100);
setProgress(percent);
};

try {
// Simple concurrency control
const CONCURRENCY_LIMIT = 3;
const queue = [...files];
const queue = [...batchFiles];
const activePromises: Promise<void>[] = [];

const processNext = async () => {
const file = queue.shift();
if (!file) return;

const key = fileKey(file);
try {
await API.uploadTracks([file], {
releaseSlug,
artistId,
artist: artistName,
album: albumTitle,
onProgress: (percent) => {
sentPercent[key] = percent;
publish();
},
});
// A finished file counts as fully sent even if the browser never
// reported the last chunk.
sentPercent[key] = 100;
successCount++;
} catch (err: unknown) {
console.error(`Failed to upload ${file.name}:`, err);
failCount++;
} finally {
processedCount++;
updateProgress();
publish(1);
}

if (queue.length > 0) {
Expand All @@ -171,8 +219,6 @@ export const UploadTracksModal = ({

await Promise.all(activePromises);

setProgress(100);

if (failCount === 0) {
setSuccess(`Successfully uploaded all ${successCount} tracks.`);
setFiles([]);
Expand Down Expand Up @@ -337,23 +383,41 @@ export const UploadTracksModal = ({
</span>
</label>
<div className="bg-base-200 rounded p-2 max-h-40 overflow-y-auto space-y-1">
{files.map((file: File, i: number) => (
<div
key={i}
className="flex justify-between items-center text-xs p-1 hover:bg-base-content/5 rounded"
>
<div className="flex items-center gap-2 truncate">
<Music size={12} /> {file.name}
</div>
<button
type="button"
onClick={() => removeFile(i)}
className="btn btn-ghost btn-xs btn-square"
{files.map((file: File, i: number) => {
const percent = batch?.files[fileKey(file)] ?? 0;
return (
<div
key={i}
className="flex justify-between items-center gap-2 text-xs p-1 hover:bg-base-content/5 rounded"
>
<X size={12} />
</button>
</div>
))}
<div className="flex items-center gap-2 truncate flex-1 min-w-0">
<Music size={12} className="shrink-0" />
{uploading ? (
<UploadProgress
label={file.name}
percent={percent}
color="secondary"
/>
) : (
<>
<span className="truncate">{file.name}</span>
<span className="opacity-40 shrink-0">
{formatBytes(file.size)}
</span>
</>
)}
</div>
<button
type="button"
onClick={() => removeFile(i)}
className="btn btn-ghost btn-xs btn-square"
disabled={uploading}
>
<X size={12} />
</button>
</div>
);
})}
</div>
</div>
)}
Expand All @@ -366,13 +430,17 @@ export const UploadTracksModal = ({
)}

<div className="modal-action flex-col">
{uploading && (
{batch && (
<div className="w-full mb-2">
<progress
className="progress progress-secondary w-full"
value={progress}
max="100"
></progress>
<UploadProgress
label={
uploading
? `Uploading ${batch.total} file${batch.total === 1 ? "" : "s"} — ${batch.done} of ${batch.total} done`
: `Finished — ${batch.done} of ${batch.total} file${batch.total === 1 ? "" : "s"}`
}
percent={batch.percent}
color="secondary"
/>
</div>
)}
<div className="flex justify-end gap-2">
Expand Down
Loading
Loading