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
23 changes: 20 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ The Vite dev server usually listens on `http://localhost:5173`. Open the web URL

## Desktop Development

The desktop app packages the same UI through Electron and enables local folder import. It scans audio metadata and SHA-256 content hashes locally, then sends metadata, file identity, paths and cover artifacts to the API. It does not upload audio files.
The desktop app packages the same UI through Electron and enables local folder import. It scans audio metadata and SHA-256 content hashes locally, then sends metadata, file identity, paths and cover artifacts to the API. It does not upload audio files. The hosted desktop submission contract is documented in the sibling API repository at `cratebase-api/docs/imports/desktop-import-api-boundary.md`.

Run the API first, then start the desktop app in development mode:

Expand Down Expand Up @@ -88,8 +88,15 @@ Packaged builds default to `https://cratebase.example.com`, while development
builds default to `http://localhost:5094`. Set `CRATEBASE_API_BASE_URL` at
runtime when a desktop build must target another hosted origin.

Private beta users sign in with issued credentials. The first bootstrap setup
creates the first admin account and its default private collection.

The cross-repository compose and reverse proxy example lives in
`../cratebase-api/deploy`.
Private beta data handling, hosted backup ownership, and release readiness are
documented in
`../cratebase-api/docs/private-beta/data-handling-and-trust.md` and
`../cratebase-api/docs/private-beta/release-readiness.md`.

## Verification

Expand All @@ -108,17 +115,27 @@ npm run build
- Workspaces for artists, releases, tracks, owned items, labels, relations, playlists, imports, exports and settings.
- Server graph context in catalog detail panels, including credits, relations, media, collector signals and playlist backlinks.
- Persistent manual and smart playlists through `/api/playlists`.
- Desktop local folder import with streaming SHA-256 hashes and duplicate review warnings.
- Browser import review and desktop-only local folder scanning through the Electron preload bridge, with streaming SHA-256 hashes and duplicate review warnings.
- JSON and CSV export downloads in browser and desktop modes.
- JSON restore into an empty active collection.

The desktop import API boundary is documented in
`../cratebase-api/docs/imports/desktop-import-api-boundary.md`. The portable
export v1 contract is documented in
`../cratebase-api/docs/exports/portable-export-v1.md`. User-triggered JSON and
CSV exports are portability tools and personal backups; hosted service backups
are an operator-managed responsibility outside the export UI.
Private beta data handling and release readiness are tracked in
`../cratebase-api/docs/private-beta/data-handling-and-trust.md` and
`../cratebase-api/docs/private-beta/release-readiness.md`.

See [docs/acceptance-checklist.md](docs/acceptance-checklist.md) for the shared acceptance path.

## Product Boundaries

- Smart playlist editing currently exposes simple rule text and preserves server rules when editing existing smart playlists.
- Manual playlist creation can persist ordered server catalog links; free-form draft links are kept client-side until linked to catalog IDs.
- The browser app can review import sessions but cannot browse arbitrary local folders.
- The browser app can review import sessions but cannot browse arbitrary local folders or show the desktop folder picker.
- There is no streaming player, social graph, marketplace, recommendation engine or external catalog integration.
- Cratebase Web is a working catalog, search and relation-navigation interface, not a public profile system or mobile-first app.

Expand Down
37 changes: 22 additions & 15 deletions docs/acceptance-checklist.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,24 +20,29 @@ and `cratebase-web`.
- Confirm private beta desktop packages target `https://cratebase.example.com` by default, with `CRATEBASE_API_BASE_URL` available as a runtime override.
- Build the API and web Docker images, then run the example compose stack and verify `/health`, `/web-health`, web routing and authenticated `/api` calls through the reverse proxy.
- Verify staging and production do not share PostgreSQL databases, service storage, secrets, invite data or user accounts.
- Review `cratebase-api/docs/private-beta/data-handling-and-trust.md` and `cratebase-api/docs/private-beta/release-readiness.md` before private beta evidence is collected.

## Acceptance Path

1. Bootstrap a clean database and create the first admin user.
2. Create a release manually with artist credits, label metadata, tracklist rows, genres, tags and one owned item.
3. Search for the created data by artist, release title, track title, label, media, ownership status, tag and credit role.
4. Open catalog result details and verify server graph sections for credits, relations, media coverage, collector signals and workspace links.
5. Create a manual playlist with ordered release or track references and verify the order remains stable after reload.
6. Create a smart playlist with tag, genre, media, ownership status or year rules and verify results are computed from current catalog data.
7. Confirm playlists appear in search, export data, catalog links and graph backlinks.
8. Use the desktop app to scan a local audio folder and create an import review session.
9. Confirm every supported audio file includes a SHA-256 `contentHash` in the desktop scan request.
10. Re-import the same folder and verify fully duplicate drafts are no-ops against existing catalog data.
11. Rename or move duplicate files and verify same-collection content hash matching still preselects existing tracks.
12. Add a partial duplicate folder and verify existing tracks are preselected while missing catalog data can still be created.
13. Use saved search views for `remixes`, `productions`, `labels`, `physicalWithoutDigital`, `lossyWithoutLossless`, `wantedNotOwned` and `needsDigitization`.
14. Export JSON and CSV and verify core catalog data, import-created data, playlists and playlist entries are present.
15. Restore a JSON export into an empty collection and verify restored search, graph context, playlists and exports.
2. Confirm sign-in and bootstrap copy describes invited private beta access, the default private collection, and the hosted archive workflow without marketing claims.
3. Create a release manually with artist credits, label metadata, tracklist rows, genres, tags and one owned item.
4. Search for the created data by artist, release title, track title, label, media, ownership status, tag and credit role.
5. Open catalog result details and verify server graph sections for credits, relations, media coverage, collector signals and workspace links.
6. Create a manual playlist with ordered release or track references and verify the order remains stable after reload.
7. Create a smart playlist with tag, genre, media, ownership status or year rules and verify results are computed from current catalog data.
8. Confirm playlists appear in search, export data, catalog links and graph backlinks.
9. Use the browser app to review existing import sessions and confirm it does not expose local folder selection.
10. Use the desktop app to scan a local audio folder through `window.cratebaseDesktop.imports.pickAndScan()` and create an import review session.
11. Confirm every supported audio file includes a SHA-256 `contentHash` in the desktop scan request, and confirm audio bytes are not uploaded.
12. Confirm the native import confirmation prompt appears before catalog records are created.
13. Re-import the same folder and verify fully duplicate drafts are no-ops against existing catalog data.
14. Rename or move duplicate files and verify same-collection content hash matching still preselects existing tracks.
15. Add a partial duplicate folder and verify existing tracks are preselected while missing catalog data can still be created.
16. Use saved search views for `remixes`, `productions`, `labels`, `physicalWithoutDigital`, `lossyWithoutLossless`, `wantedNotOwned` and `needsDigitization`.
17. Open the export workspace and confirm it explains JSON/CSV scope, known v1 limits, no audio export, and the difference between user exports and hosted service backups.
18. Export JSON and CSV and verify core catalog data, import-created data, playlists and playlist entries are present.
19. Restore a JSON export into an empty collection and verify restored search, graph context, playlists and exports.

## Verification Commands

Expand All @@ -62,6 +67,8 @@ npm run build
## Product Boundaries

- Smart playlists are dynamic rules, not materialized snapshots.
- Browser import review is supported, but local folder scanning is desktop-only.
- Browser import review is supported, but local folder scanning is desktop-only through the Electron preload bridge. The API boundary is documented in `cratebase-api/docs/imports/desktop-import-api-boundary.md`.
- Audio files are not uploaded to the API.
- User-triggered JSON and CSV exports are portability tools and personal backups. Hosted service backups are separate operator-managed recovery work, and the export v1 contract is documented in `cratebase-api/docs/exports/portable-export-v1.md`.
- Private beta data-handling and release-readiness expectations are documented in `cratebase-api/docs/private-beta/data-handling-and-trust.md` and `cratebase-api/docs/private-beta/release-readiness.md`.
- External catalog integrations, streaming, marketplace, social, and recommendation features are outside the product boundary.
10 changes: 10 additions & 0 deletions electron/backend-config.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
const localApiBaseUrl = 'http://localhost:5094'

function resolveBackendBaseUrl() {
return process.env.CRATEBASE_API_BASE_URL || localApiBaseUrl
}

module.exports = {
localApiBaseUrl,
resolveBackendBaseUrl,
}
31 changes: 31 additions & 0 deletions electron/backend-config.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// @vitest-environment node

const { resolveBackendBaseUrl } = require('./backend-config.cjs')

describe('desktop backend configuration', () => {
const originalApiBaseUrl = process.env.CRATEBASE_API_BASE_URL

afterEach(() => {
if (originalApiBaseUrl === undefined) {
delete process.env.CRATEBASE_API_BASE_URL
} else {
process.env.CRATEBASE_API_BASE_URL = originalApiBaseUrl
}
})

it('uses the local API by default for packaged desktop builds', () => {
delete process.env.CRATEBASE_API_BASE_URL

expect(resolveBackendBaseUrl({ isPackaged: true })).toBe(
'http://localhost:5094',
)
})

it('allows the backend URL to be overridden for hosted deployments', () => {
process.env.CRATEBASE_API_BASE_URL = 'https://cratebase.example.test'

expect(resolveBackendBaseUrl({ isPackaged: true })).toBe(
'https://cratebase.example.test',
)
})
})
7 changes: 2 additions & 5 deletions electron/main.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,10 @@ const { app, BrowserWindow, dialog, ipcMain, shell } = require('electron')
const fsp = require('node:fs/promises')
const http = require('node:http')
const path = require('node:path')
const { resolveBackendBaseUrl } = require('./backend-config.cjs')
const { scanFolder } = require('./scanner.cjs')

const localApiBaseUrl = 'http://localhost:5094'
const hostedApiBaseUrl = 'https://cratebase.example.com'
const backendBaseUrl =
process.env.CRATEBASE_API_BASE_URL ||
(app.isPackaged ? hostedApiBaseUrl : localApiBaseUrl)
const backendBaseUrl = resolveBackendBaseUrl()
const devServerUrl = process.env.CRATEBASE_DESKTOP_DEV_SERVER
const cookieJar = new Map()
const strippedProxyResponseHeaders = new Set([
Expand Down
60 changes: 60 additions & 0 deletions electron/preload-contract.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// @vitest-environment node

const Module = require('node:module')
const path = require('node:path')

const preloadPath = path.join(__dirname, 'preload.cjs')
const originalLoad = Module._load

describe('desktop preload contract', () => {
afterEach(() => {
Module._load = originalLoad
delete require.cache[preloadPath]
})

it('exposes only the cratebaseDesktop bridge and routes imports and exports over IPC', async () => {
const exposeInMainWorld = vi.fn()
const invoke = vi
.fn()
.mockResolvedValueOnce({ cancelled: true })
.mockResolvedValueOnce({ cancelled: false, path: '/tmp/export.json' })

Module._load = function load(request, parent, isMain) {
if (request === 'electron') {
return {
contextBridge: { exposeInMainWorld },
ipcRenderer: { invoke },
}
}

return originalLoad.call(this, request, parent, isMain)
}

require(preloadPath)

expect(exposeInMainWorld).toHaveBeenCalledTimes(1)
const [bridgeName, bridge] = exposeInMainWorld.mock.calls[0]
expect(bridgeName).toBe('cratebaseDesktop')
expect(Object.keys(bridge).sort()).toEqual([
'exports',
'imports',
'isDesktop',
])
expect(Object.keys(bridge.imports)).toEqual(['pickAndScan'])
expect(Object.keys(bridge.exports)).toEqual(['download'])

await expect(bridge.imports.pickAndScan()).resolves.toEqual({
cancelled: true,
})
await expect(bridge.exports.download('json')).resolves.toEqual({
cancelled: false,
path: '/tmp/export.json',
})
expect(invoke).toHaveBeenNthCalledWith(1, 'cratebase:imports:pick-and-scan')
expect(invoke).toHaveBeenNthCalledWith(
2,
'cratebase:exports:download',
'json',
)
})
})
90 changes: 90 additions & 0 deletions electron/scanner.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// @vitest-environment node

const crypto = require('node:crypto')
const fs = require('node:fs/promises')
const os = require('node:os')
const path = require('node:path')
const { scanFolder } = require('./scanner.cjs')

const tempRoots = []

async function createTempRoot() {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'cratebase-scan-'))
tempRoots.push(root)
return root
}

describe('desktop folder scanner', () => {
afterEach(async () => {
await Promise.all(
tempRoots.splice(0).map((root) =>
fs.rm(root, {
force: true,
recursive: true,
}),
),
)
})

it('hashes audio files, includes metadata shape, and never attaches audio bytes', async () => {
const root = await createTempRoot()
const releaseDir = path.join(root, 'Release')
const audioPath = path.join(releaseDir, '01 Track.flac')
const coverPath = path.join(releaseDir, 'cover.jpg')
const audioBytes = Buffer.from('fake flac bytes')
const coverBytes = Buffer.from('cover bytes')
const mtime = new Date('2026-05-16T12:00:00Z')

await fs.mkdir(releaseDir, { recursive: true })
await fs.writeFile(audioPath, audioBytes)
await fs.writeFile(coverPath, coverBytes)
await fs.writeFile(path.join(releaseDir, 'notes.txt'), 'ignored')
await fs.writeFile(path.join(releaseDir, '.DS_Store'), 'ignored')
await fs.utimes(audioPath, mtime, mtime)
await fs.utimes(coverPath, mtime, mtime)

const scan = await scanFolder(root)

const audio = scan.files.find((file) => file.relativePath.endsWith('.flac'))
const cover = scan.files.find((file) => file.relativePath.endsWith('.jpg'))
expect(scan.sourceRoot).toBe(root)
expect(scan.ignoredFileCount).toBe(2)
expect(audio).toMatchObject({
filePath: audioPath,
relativePath: path.join('Release', '01 Track.flac'),
format: 'flac',
sizeBytes: audioBytes.length,
lastModifiedAt: '2026-05-16T12:00:00.000Z',
contentHash: crypto.createHash('sha256').update(audioBytes).digest('hex'),
audioMetadata: {
title: null,
artists: [],
albumTitle: null,
albumArtists: [],
catalogNumber: null,
releaseDate: null,
year: null,
durationSeconds: null,
trackNumber: null,
},
coverArtifact: null,
})
expect(audio).not.toHaveProperty('contentBase64')
expect(JSON.stringify(audio)).not.toContain(audioBytes.toString('base64'))
expect(cover).toMatchObject({
filePath: coverPath,
relativePath: path.join('Release', 'cover.jpg'),
format: null,
sizeBytes: coverBytes.length,
lastModifiedAt: '2026-05-16T12:00:00.000Z',
audioMetadata: null,
coverArtifact: {
fileName: 'cover.jpg',
extension: '.jpg',
contentType: 'image/jpeg',
sizeBytes: coverBytes.length,
contentBase64: coverBytes.toString('base64'),
},
})
})
})
10 changes: 10 additions & 0 deletions src/App.auth.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ describe('App auth', () => {
expect(
await h.screen.findByRole('form', { name: 'Sign in' }),
).toBeInTheDocument()
expect(
h.screen.getByText(
/Invited private beta users sign in with the credentials issued for their collection/i,
),
).toBeVisible()
})

it('shows bootstrap setup for first user state', async () => {
Expand All @@ -38,6 +43,11 @@ describe('App auth', () => {
expect(
await h.screen.findByRole('form', { name: 'Bootstrap setup' }),
).toBeInTheDocument()
expect(
h.screen.getByText(
/Bootstrap creates the first admin account and its default private collection/i,
),
).toBeVisible()
})

it('logs out back to sign in', async () => {
Expand Down
Loading