10 Commits

Author SHA1 Message Date
Boris Cherepanov
8ceee6028a fix: then height 100%
All checks were successful
Publish Metadata Agent Image / build-and-push-image (push) Successful in 1m5s
Publish Web Player Image / build-and-push-image (push) Successful in 1m6s
2026-03-23 16:55:22 +03:00
Boris Cherepanov
3491c52793 feat: refactoring data fetching 2026-03-23 16:55:22 +03:00
Boris Cherepanov
f0e1bbc7f8 feat: added redux 2026-03-23 16:55:22 +03:00
Boris Cherepanov
8f38e27eb0 feat: addex type declaration 2026-03-23 16:55:22 +03:00
Boris Cherepanov
8cac2d1160 feat: refactoring 2026-03-23 16:55:22 +03:00
Boris Cherepanov
5a5dab85d0 feat: added api conversation + api review 2026-03-23 16:55:22 +03:00
Boris Cherepanov
310f0061d3 feat: added cors for web-player-backend 2026-03-23 16:55:22 +03:00
Boris Cherepanov
f26135ca25 feat: added auth by api key 2026-03-23 16:55:22 +03:00
71d5a38f21 Fix source-missing auto-merge and remove Pink Floyd examples from prompts
All checks were successful
Publish Metadata Agent Image (dev) / build-and-push-image (push) Successful in 1m10s
Publish Web Player Image (dev) / build-and-push-image (push) Successful in 1m10s
Auto-merge: when ingest pipeline detects "source file missing", now checks
if the track already exists in the library by file_hash. If so, marks the
pending entry as 'merged' instead of 'error' — avoiding stale error entries
for files that were already successfully ingested in a previous run.

Prompts: replaced Pink Floyd/The Wall/Have a Cigar examples in both
normalize.txt and merge.txt with Deep Purple examples. The LLM was using
these famous artist/album/track names as fallback output when raw metadata
was empty or ambiguous, causing hallucinated metadata like
"artist: Pink Floyd, title: Have a Cigar" for completely unrelated tracks.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 01:05:22 +00:00
8d70a5133a Disabled obsolete CI
All checks were successful
Publish Metadata Agent Image (dev) / build-and-push-image (push) Successful in 1m17s
Publish Web Player Image (dev) / build-and-push-image (push) Successful in 1m14s
2026-03-20 00:49:27 +00:00
37 changed files with 1277 additions and 300 deletions

View File

@@ -0,0 +1,32 @@
---
description: REST API запросы — создавать функции в furumiApi.ts (только furumi-node-player/client)
globs: furumi-node-player/client/**/*.{ts,tsx}
alwaysApply: false
---
# REST API в furumi-node-player
**Область действия:** правило применяется только к проекту `furumi-node-player/client/`. В остальных частях репозитория можно использовать другой подход.
Чтобы выполнить REST API запрос, нужно создать функцию, которая принимает необходимые параметры и вызывает `furumiApi`. Все такие функции должны находиться в файле `furumi-node-player/client/src/furumiApi.ts`.
## Правила
1. **Не вызывать `furumiApi` напрямую** из компонентов или других модулей.
2. **Добавлять новую функцию** в `furumiApi.ts` для каждого эндпоинта.
3. Функция принимает нужные параметры и возвращает `Promise` с данными (или `null` при ошибке).
## Пример
```typescript
// furumiApi.ts — добавлять сюда
export async function getSomething(id: string) {
const res = await furumiApi.get(`/something/${id}`).catch(() => null)
return res?.data ?? null
}
```
```typescript
// FurumiPlayer.tsx — использовать функцию, не furumiApi напрямую
const data = await getSomething(id)
```

View File

@@ -53,6 +53,7 @@ services:
FURUMI_PLAYER_DATABASE_URL: "postgres://${POSTGRES_USER:-furumi}:${POSTGRES_PASSWORD:-furumi}@db:5432/${POSTGRES_DB:-furumi}" FURUMI_PLAYER_DATABASE_URL: "postgres://${POSTGRES_USER:-furumi}:${POSTGRES_PASSWORD:-furumi}@db:5432/${POSTGRES_DB:-furumi}"
FURUMI_PLAYER_STORAGE_DIR: "/storage" FURUMI_PLAYER_STORAGE_DIR: "/storage"
FURUMI_PLAYER_BIND: "0.0.0.0:8085" FURUMI_PLAYER_BIND: "0.0.0.0:8085"
FURUMI_PLAYER_API_KEY: "node-player-api-key"
volumes: volumes:
- ./storage:/storage - ./storage:/storage
restart: always restart: always

View File

@@ -4,14 +4,14 @@ You are a music library artist merge assistant. You will receive a list of artis
You will receive a structured list like: You will receive a structured list like:
### Artist ID 42: "pink floyd" ### Artist ID 42: "deep purple"
Album ID 10: "the wall" (1979) Album ID 10: "machine head" (1972)
- 01. "In the Flesh?" [track_id=100] - 01. "Highway Star" [track_id=100]
- 02. "The Thin Ice" [track_id=101] - 02. "Maybe I'm a Leo" [track_id=101]
### Artist ID 43: "Pink Floyd" ### Artist ID 43: "Deep Purple"
Album ID 11: "Wish You Were Here" (1975) Album ID 11: "Burn" (1974)
- 01. "Shine On You Crazy Diamond (Parts I-V)" [track_id=200] - 01. "Burn" [track_id=200]
## Your task ## Your task
@@ -20,7 +20,7 @@ Determine if the artists are duplicates and produce a merge plan.
## Rules ## Rules
### 1. Canonical artist name ### 1. Canonical artist name
- Use correct capitalization and canonical spelling (e.g., "pink floyd" → "Pink Floyd", "AC DC" → "AC/DC"). - Use correct capitalization and canonical spelling (e.g., "deep purple" → "Deep Purple", "AC DC" → "AC/DC").
- If the database already contains an artist with a well-formed name, prefer that exact form. - If the database already contains an artist with a well-formed name, prefer that exact form.
- If one artist has clearly more tracks or albums, their name spelling may be more authoritative. - If one artist has clearly more tracks or albums, their name spelling may be more authoritative.
- Fix obvious typos or casing errors. - Fix obvious typos or casing errors.
@@ -54,7 +54,7 @@ Determine if the artists are duplicates and produce a merge plan.
You MUST respond with a single JSON object, no markdown fences, no extra text: You MUST respond with a single JSON object, no markdown fences, no extra text:
{"canonical_artist_name": "...", "winner_artist_id": 42, "album_mappings": [{"source_album_id": 10, "canonical_name": "The Wall", "merge_into_album_id": null}, {"source_album_id": 11, "canonical_name": "Wish You Were Here", "merge_into_album_id": null}], "notes": "..."} {"canonical_artist_name": "...", "winner_artist_id": 42, "album_mappings": [{"source_album_id": 10, "canonical_name": "Machine Head", "merge_into_album_id": null}, {"source_album_id": 11, "canonical_name": "Burn", "merge_into_album_id": null}], "notes": "..."}
- `canonical_artist_name`: the single correct name for this artist after merging. - `canonical_artist_name`: the single correct name for this artist after merging.
- `winner_artist_id`: the integer ID of the artist whose record survives (must be one of the IDs provided). - `winner_artist_id`: the integer ID of the artist whose record survives (must be one of the IDs provided).

View File

@@ -3,10 +3,10 @@ You are a music metadata normalization assistant. Your job is to take raw metada
## Rules ## Rules
1. **Artist names** must use correct capitalization and canonical spelling. Examples: 1. **Artist names** must use correct capitalization and canonical spelling. Examples:
- "pink floyd" → "Pink Floyd" - "deep purple" → "Deep Purple"
- "AC DC" → "AC/DC" - "AC DC" → "AC/DC"
- "Guns n roses" → "Guns N' Roses" - "guns n roses" → "Guns N' Roses"
- "Led zepplin" → "Led Zeppelin" (fix common misspellings) - "led zepplin" → "Led Zeppelin" (fix common misspellings)
- "саша скул" → "Саша Скул" (fix capitalization, keep the language as-is) - "саша скул" → "Саша Скул" (fix capitalization, keep the language as-is)
- If the database already contains a matching artist (same name in any case or transliteration), always use the existing canonical name exactly. For example, if the DB has "Саша Скул" and the file says "саша скул" or "Sasha Skul", use "Саша Скул". - If the database already contains a matching artist (same name in any case or transliteration), always use the existing canonical name exactly. For example, if the DB has "Саша Скул" and the file says "саша скул" or "Sasha Skul", use "Саша Скул".
- **Compound artist fields**: When the artist field or path contains multiple artist names joined by "и", "and", "&", "/", ",", "x", or "vs", you MUST split them. The "artist" field must contain ONLY ONE primary artist. All others go into "featured_artists". If one of the names already exists in the database, prefer that one as the primary artist. - **Compound artist fields**: When the artist field or path contains multiple artist names joined by "и", "and", "&", "/", ",", "x", or "vs", you MUST split them. The "artist" field must contain ONLY ONE primary artist. All others go into "featured_artists". If one of the names already exists in the database, prefer that one as the primary artist.
@@ -43,12 +43,12 @@ You are a music metadata normalization assistant. Your job is to take raw metada
- Preserve original language for non-English albums. - Preserve original language for non-English albums.
- If the database already contains a matching album under the same artist, use the existing name exactly. - If the database already contains a matching album under the same artist, use the existing name exactly.
- Do not alter the creative content of album names (same principle as track titles). - Do not alter the creative content of album names (same principle as track titles).
- **Remastered editions**: A remastered release is a separate album entity, even if it shares the same title and tracks as the original. If the tags or path indicate a remaster (e.g., "Remastered", "Remaster", "REMASTERED" anywhere in tags, filename, or path), append " (Remastered)" to the album name if not already present, and use the year of the remaster release (not the original). Example: original album "The Wall" (1979) remastered in 2011 → album: "The Wall (Remastered)", year: 2011. - **Remastered editions**: A remastered release is a separate album entity, even if it shares the same title and tracks as the original. If the tags or path indicate a remaster (e.g., "Remastered", "Remaster", "REMASTERED" anywhere in tags, filename, or path), append " (Remastered)" to the album name if not already present, and use the year of the remaster release (not the original). Example: original album "Paranoid" (1970) remastered in 2009 → album: "Paranoid (Remastered)", year: 2009.
4. **Track titles** must use correct capitalization, but their content must be preserved exactly. 4. **Track titles** must use correct capitalization, but their content must be preserved exactly.
- Use title case for English titles. - Use title case for English titles.
- Preserve original language for non-English titles. - Preserve original language for non-English titles.
- Remove leading track numbers if present (e.g., "01 - Have a Cigar" → "Have a Cigar"). - Remove leading track numbers if present (e.g., "01 - Smoke on the Water" → "Smoke on the Water").
- **NEVER remove, add, or alter words, numbers, suffixes, punctuation marks, or special characters in titles.** Your job is to fix capitalization and encoding, not to edit the creative content. If a title contains unusual punctuation, numbers, apostrophes, or symbols — they are intentional and must be kept as-is. - **NEVER remove, add, or alter words, numbers, suffixes, punctuation marks, or special characters in titles.** Your job is to fix capitalization and encoding, not to edit the creative content. If a title contains unusual punctuation, numbers, apostrophes, or symbols — they are intentional and must be kept as-is.
- If all tracks in the same album follow a naming pattern (e.g., numbered names like "Part 1", "Part 2"), preserve that pattern consistently. Do not simplify or truncate individual track names. - If all tracks in the same album follow a naming pattern (e.g., numbered names like "Part 1", "Part 2"), preserve that pattern consistently. Do not simplify or truncate individual track names.

View File

@@ -187,9 +187,21 @@ async fn reprocess_pending(state: &Arc<AppState>) -> anyhow::Result<usize> {
continue; continue;
} }
} }
} else {
// Source file is gone — check if already in library by hash
let in_library: (bool,) = sqlx::query_as(
"SELECT EXISTS(SELECT 1 FROM tracks WHERE file_hash = $1)"
)
.bind(&pt.file_hash)
.fetch_one(&state.pool).await.unwrap_or((false,));
if in_library.0 {
tracing::info!(id = %pt.id, "Source missing but track already in library — merging");
db::update_pending_status(&state.pool, pt.id, "merged", None).await?;
} else { } else {
tracing::error!(id = %pt.id, "Source file missing: {:?}", source); tracing::error!(id = %pt.id, "Source file missing: {:?}", source);
db::update_pending_status(&state.pool, pt.id, "error", Some("Source file missing")).await?; db::update_pending_status(&state.pool, pt.id, "error", Some("Source file missing")).await?;
}
continue; continue;
}; };

View File

@@ -0,0 +1,2 @@
VITE_API_BASE_URL=http://localhost:8085
VITE_API_KEY=

View File

@@ -8,8 +8,11 @@
"name": "client", "name": "client",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@reduxjs/toolkit": "^2.11.2",
"axios": "^1.7.9",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4" "react-dom": "^19.2.4",
"react-redux": "^9.2.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.4", "@eslint/js": "^9.39.4",
@@ -586,6 +589,32 @@
"url": "https://github.com/sponsors/Boshen" "url": "https://github.com/sponsors/Boshen"
} }
}, },
"node_modules/@reduxjs/toolkit": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@rolldown/binding-android-arm64": { "node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-rc.10", "version": "1.0.0-rc.10",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.10.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.10.tgz",
@@ -848,6 +877,18 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@tybys/wasm-util": { "node_modules/@tybys/wasm-util": {
"version": "0.10.1", "version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
@@ -887,7 +928,7 @@
"version": "19.2.14", "version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
@@ -903,6 +944,12 @@
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
} }
}, },
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.57.1", "version": "8.57.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.1.tgz",
@@ -1287,6 +1334,23 @@
"dev": true, "dev": true,
"license": "Python-2.0" "license": "Python-2.0"
}, },
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/axios": {
"version": "1.13.6",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/balanced-match": { "node_modules/balanced-match": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -1352,6 +1416,19 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
} }
}, },
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/callsites": { "node_modules/callsites": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
@@ -1420,6 +1497,18 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/concat-map": { "node_modules/concat-map": {
"version": "0.0.1", "version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -1453,7 +1542,7 @@
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/debug": { "node_modules/debug": {
@@ -1481,6 +1570,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/detect-libc": { "node_modules/detect-libc": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -1491,6 +1589,20 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.321", "version": "1.5.321",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.321.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.321.tgz",
@@ -1498,6 +1610,51 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escalade": { "node_modules/escalade": {
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -1795,6 +1952,42 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/fsevents": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1810,6 +2003,15 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0" "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
} }
}, },
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/gensync": { "node_modules/gensync": {
"version": "1.0.0-beta.2", "version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
@@ -1820,6 +2022,43 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/glob-parent": { "node_modules/glob-parent": {
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -1846,6 +2085,18 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-flag": { "node_modules/has-flag": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@@ -1856,6 +2107,45 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/hermes-estree": { "node_modules/hermes-estree": {
"version": "0.25.1", "version": "0.25.1",
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
@@ -1883,6 +2173,16 @@
"node": ">= 4" "node": ">= 4"
} }
}, },
"node_modules/immer": {
"version": "11.1.4",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
"integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/import-fresh": { "node_modules/import-fresh": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -2325,6 +2625,36 @@
"yallist": "^3.0.2" "yallist": "^3.0.2"
} }
}, },
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "3.1.5", "version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
@@ -2520,6 +2850,12 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/punycode": { "node_modules/punycode": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -2551,6 +2887,50 @@
"react": "^19.2.4" "react": "^19.2.4"
} }
}, },
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT"
},
"node_modules/resolve-from": { "node_modules/resolve-from": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@@ -2814,6 +3194,15 @@
"punycode": "^2.1.0" "punycode": "^2.1.0"
} }
}, },
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/vite": { "node_modules/vite": {
"version": "8.0.1", "version": "8.0.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.1.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.1.tgz",

View File

@@ -10,8 +10,11 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@reduxjs/toolkit": "^2.11.2",
"axios": "^1.7.9",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4" "react-dom": "^19.2.4",
"react-redux": "^9.2.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.4", "@eslint/js": "^9.39.4",

View File

@@ -61,12 +61,11 @@ function App() {
const loginUrl = `${apiBase}/api/login` const loginUrl = `${apiBase}/api/login`
const logoutUrl = `${apiBase}/api/logout` const logoutUrl = `${apiBase}/api/logout`
const playerApiRoot = `${apiBase}/api`
return ( return (
<> <>
{!loading && (user || runWithoutAuth) ? ( {!loading && (user || runWithoutAuth) ? (
<FurumiPlayer apiRoot={playerApiRoot} /> <FurumiPlayer />
) : ( ) : (
<main className="page"> <main className="page">
<section className="card"> <section className="card">

View File

@@ -1,24 +1,34 @@
import { useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react' import { useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react'
import './furumi-player.css' import './furumi-player.css'
import { createFurumiApiClient } from './furumiApi' import {
import { SearchDropdown } from './components/SearchDropdown' API_ROOT,
import { Breadcrumbs } from './components/Breadcrumbs' searchTracks,
import { LibraryList } from './components/LibraryList' preloadStream,
import { QueueList, type QueueItem } from './components/QueueList' } from './furumiApi'
import { NowPlaying } from './components/NowPlaying' import { useAppDispatch, useAppSelector } from './store'
import { fetchArtists } from './store/slices/artistsSlice'
import { fetchArtistAlbums } from './store/slices/albumsSlice'
import { fetchArtistTracks } from './store/slices/artistTracksSlice'
import { fetchAlbumTracks } from './store/slices/albumTracksSlice'
import { fetchTrackDetail } from './store/slices/trackDetailSlice'
import { fmt } from './utils'
import { Header } from './components/Header'
import { MainPanel, type Crumb } from './components/MainPanel'
import { PlayerBar } from './components/PlayerBar'
import type { QueueItem } from './components/QueueList'
import type { Track } from './types'
type FurumiPlayerProps = { export function FurumiPlayer() {
apiRoot: string const dispatch = useAppDispatch()
} const artistsLoading = useAppSelector((s) => s.artists.loading)
const artistsError = useAppSelector((s) => s.artists.error)
type Crumb = { label: string; action?: () => void } const albumsLoading = useAppSelector((s) => s.albums.loading)
const albumsError = useAppSelector((s) => s.albums.error)
export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) { const albumTracksLoading = useAppSelector((s) => s.albumTracks.loading)
const [breadcrumbs, setBreadcrumbs] = useState<Array<{ label: string; action?: () => void }>>( const albumTracksError = useAppSelector((s) => s.albumTracks.error)
const [breadcrumbs, setBreadcrumbs] = useState<Crumb[]>(
[], [],
) )
const [libraryLoading, setLibraryLoading] = useState(false)
const [libraryError, setLibraryError] = useState<string | null>(null)
const [libraryItems, setLibraryItems] = useState< const [libraryItems, setLibraryItems] = useState<
Array<{ Array<{
key: string key: string
@@ -42,6 +52,7 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
const [queueOrderView, setQueueOrderView] = useState<number[]>([]) const [queueOrderView, setQueueOrderView] = useState<number[]>([])
const [queuePlayingOrigIdxView, setQueuePlayingOrigIdxView] = useState<number>(-1) const [queuePlayingOrigIdxView, setQueuePlayingOrigIdxView] = useState<number>(-1)
const [queueScrollSignal, setQueueScrollSignal] = useState(0) const [queueScrollSignal, setQueueScrollSignal] = useState(0)
const [queue, setQueue] = useState<QueueItem[]>([])
const queueActionsRef = useRef<{ const queueActionsRef = useRef<{
playIndex: (i: number) => void playIndex: (i: number) => void
@@ -49,12 +60,14 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
moveQueueItem: (fromPos: number, toPos: number) => void moveQueueItem: (fromPos: number, toPos: number) => void
} | null>(null) } | null>(null)
const audioRef = useRef<HTMLAudioElement>(null)
useEffect(() => { useEffect(() => {
// --- Original player script adapted for React environment --- // --- Original player script adapted for React environment ---
const audio = document.getElementById('audioEl') as HTMLAudioElement const audioEl = audioRef.current
if (!audio) return if (!audioEl) return
const audio = audioEl
let queue: QueueItem[] = []
let queueIndex = -1 let queueIndex = -1
let shuffle = false let shuffle = false
let repeatAll = true let repeatAll = true
@@ -106,24 +119,13 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
nextTrack() nextTrack()
}) })
// --- API helper ---
const API = apiRoot
const api = createFurumiApiClient(API)
// --- Library navigation --- // --- Library navigation ---
async function showArtists() { async function showArtists() {
setBreadcrumb([{ label: 'Artists', action: showArtists }]) setBreadcrumb([{ label: 'Artists', action: showArtists }])
setLibraryLoading(true) try {
setLibraryError(null) const artists = await dispatch(fetchArtists()).unwrap()
const artists = await api('/artists')
if (!artists) {
setLibraryLoading(false)
setLibraryError('Error')
return
}
setLibraryLoading(false)
setLibraryItems( setLibraryItems(
(artists as any[]).map((a) => ({ artists.map((a) => ({
key: `artist:${a.slug}`, key: `artist:${a.slug}`,
className: 'file-item dir', className: 'file-item dir',
icon: '👤', icon: '👤',
@@ -132,6 +134,9 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
onClick: () => void showArtistAlbums(a.slug, a.name), onClick: () => void showArtistAlbums(a.slug, a.name),
})), })),
) )
} catch {
// Error is stored in artists.error
}
} }
async function showArtistAlbums(artistSlug: string, artistName: string) { async function showArtistAlbums(artistSlug: string, artistName: string) {
@@ -139,15 +144,8 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
{ label: 'Artists', action: showArtists }, { label: 'Artists', action: showArtists },
{ label: artistName, action: () => showArtistAlbums(artistSlug, artistName) }, { label: artistName, action: () => showArtistAlbums(artistSlug, artistName) },
]) ])
setLibraryLoading(true) try {
setLibraryError(null) const { albums } = await dispatch(fetchArtistAlbums(artistSlug)).unwrap()
const albums = await api('/artists/' + artistSlug + '/albums')
if (!albums) {
setLibraryLoading(false)
setLibraryError('Error')
return
}
setLibraryLoading(false)
const allTracksItem = { const allTracksItem = {
key: `artist-all:${artistSlug}`, key: `artist-all:${artistSlug}`,
className: 'file-item', className: 'file-item',
@@ -156,7 +154,7 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
nameClassName: 'name', nameClassName: 'name',
onClick: () => void playAllArtistTracks(artistSlug), onClick: () => void playAllArtistTracks(artistSlug),
} }
const albumItems = (albums as any[]).map((a) => { const albumItems = albums.map((a) => {
const year = a.year ? ` (${a.year})` : '' const year = a.year ? ` (${a.year})` : ''
return { return {
key: `album:${a.slug}`, key: `album:${a.slug}`,
@@ -175,6 +173,9 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
} }
}) })
setLibraryItems([allTracksItem, ...albumItems]) setLibraryItems([allTracksItem, ...albumItems])
} catch {
// Error is stored in albums.error
}
} }
async function showAlbumTracks( async function showAlbumTracks(
@@ -188,15 +189,9 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
{ label: artistName, action: () => showArtistAlbums(artistSlug, artistName) }, { label: artistName, action: () => showArtistAlbums(artistSlug, artistName) },
{ label: albumName }, { label: albumName },
]) ])
setLibraryLoading(true) const result = await dispatch(fetchAlbumTracks(albumSlug))
setLibraryError(null) if (result.meta.requestStatus === 'rejected') return
const tracks = await api('/albums/' + albumSlug) const { tracks } = result.payload as { albumSlug: string; tracks: Track[] }
if (!tracks) {
setLibraryLoading(false)
setLibraryError('Error')
return
}
setLibraryLoading(false)
const playAlbumItem = { const playAlbumItem = {
key: `album-play:${albumSlug}`, key: `album-play:${albumSlug}`,
className: 'file-item', className: 'file-item',
@@ -206,7 +201,7 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
void addAlbumToQueue(albumSlug, true) void addAlbumToQueue(albumSlug, true)
}, },
} }
const trackItems = (tracks as any[]).map((t) => { const trackItems = tracks.map((t) => {
const num = t.track_number ? `${t.track_number}. ` : '' const num = t.track_number ? `${t.track_number}. ` : ''
const dur = t.duration_secs ? fmt(t.duration_secs) : '' const dur = t.duration_secs ? fmt(t.duration_secs) : ''
return { return {
@@ -252,7 +247,7 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
if (playNow) playIndex(existing) if (playNow) playIndex(existing)
return return
} }
queue.push(track) setQueue((q) => [...q, track]);
updateQueueModel() updateQueueModel()
if (playNow || (queueIndex === -1 && queue.length === 1)) { if (playNow || (queueIndex === -1 && queue.length === 1)) {
playIndex(queue.length - 1) playIndex(queue.length - 1)
@@ -260,19 +255,24 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
} }
async function addAlbumToQueue(albumSlug: string, playFirst?: boolean) { async function addAlbumToQueue(albumSlug: string, playFirst?: boolean) {
const tracks = await api('/albums/' + albumSlug) const result = await dispatch(fetchAlbumTracks(albumSlug))
if (!tracks || !(tracks as any[]).length) return if (result.meta.requestStatus === 'rejected') return
const list = tracks as any[] const { tracks } = result.payload as { albumSlug: string; tracks: Track[] }
if (!tracks || !tracks.length) return
const list = tracks
let firstIdx = queue.length let firstIdx = queue.length
list.forEach((t) => { list.forEach((t) => {
if (queue.find((q) => q.slug === t.slug)) return if (queue.find((q) => q.slug === t.slug)) return
queue.push({ setQueue((q) => [
...q,
{
slug: t.slug, slug: t.slug,
title: t.title, title: t.title,
artist: t.artist_name, artist: t.artist_name,
album_slug: albumSlug, album_slug: t.album_slug,
duration: t.duration_secs, duration: t.duration_secs,
}) },
])
}) })
updateQueueModel() updateQueueModel()
if (playFirst || queueIndex === -1) playIndex(firstIdx) if (playFirst || queueIndex === -1) playIndex(firstIdx)
@@ -280,19 +280,19 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
} }
async function playAllArtistTracks(artistSlug: string) { async function playAllArtistTracks(artistSlug: string) {
const tracks = await api('/artists/' + artistSlug + '/tracks') const result = await dispatch(fetchArtistTracks(artistSlug))
if (!tracks || !(tracks as any[]).length) return if (result.meta.requestStatus === 'rejected') return
const list = tracks as any[] const { tracks } = result.payload as { artistSlug: string; tracks: Track[] }
if (!tracks || !tracks.length) return
const list = tracks
clearQueue() clearQueue()
list.forEach((t) => { setQueue(list.map((t) => ({
queue.push({
slug: t.slug, slug: t.slug,
title: t.title, title: t.title,
artist: t.artist_name, artist: t.artist_name,
album_slug: t.album_slug, album_slug: t.album_slug,
duration: t.duration_secs, duration: t.duration_secs,
}) })))
})
updateQueueModel() updateQueueModel()
playIndex(0) playIndex(0)
showToast(`Added ${list.length} tracks`) showToast(`Added ${list.length} tracks`)
@@ -302,7 +302,7 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
if (i < 0 || i >= queue.length) return if (i < 0 || i >= queue.length) return
queueIndex = i queueIndex = i
const track = queue[i] const track = queue[i]
audio.src = `${API}/stream/${track.slug}` audio.src = `${API_ROOT}/stream/${track.slug}`
void audio.play().catch(() => {}) void audio.play().catch(() => {})
updateNowPlaying(track) updateNowPlaying(track)
updateQueueModel() updateQueueModel()
@@ -320,7 +320,7 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
document.title = `${track.title} — Furumi` document.title = `${track.title} — Furumi`
const coverUrl = `${API}/tracks/${track.slug}/cover` const coverUrl = `${API_ROOT}/tracks/${track.slug}/cover`
if ('mediaSession' in navigator) { if ('mediaSession' in navigator) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
navigator.mediaSession.metadata = new window.MediaMetadata({ navigator.mediaSession.metadata = new window.MediaMetadata({
@@ -355,7 +355,7 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
function updateQueueModel() { function updateQueueModel() {
const order = currentOrder() const order = currentOrder()
setQueueItemsView(queue.slice()) setQueueItemsView(queue)
setQueueOrderView(order.slice()) setQueueOrderView(order.slice())
setQueuePlayingOrigIdxView(queueIndex) setQueuePlayingOrigIdxView(queueIndex)
} }
@@ -369,7 +369,10 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
} else if (queueIndex > idx) { } else if (queueIndex > idx) {
queueIndex-- queueIndex--
} }
queue.splice(idx, 1)
// queue.splice(idx, 1)
setQueue((q) => q.filter((_, i) => i !== idx));
if (shuffle) { if (shuffle) {
const si = shuffleOrder.indexOf(idx) const si = shuffleOrder.indexOf(idx)
if (si !== -1) shuffleOrder.splice(si, 1) if (si !== -1) shuffleOrder.splice(si, 1)
@@ -402,7 +405,7 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
} }
function clearQueue() { function clearQueue() {
queue = [] setQueue([]);
queueIndex = -1 queueIndex = -1
shuffleOrder = [] shuffleOrder = []
audio.pause() audio.pause()
@@ -495,7 +498,7 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
return return
} }
searchTimer = window.setTimeout(async () => { searchTimer = window.setTimeout(async () => {
const results = await api('/search?q=' + encodeURIComponent(q)) const results = await searchTracks(q)
if (!results || !(results as any[]).length) { if (!results || !(results as any[]).length) {
closeSearch() closeSearch()
return return
@@ -519,27 +522,12 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
{ slug, title: '', artist: '', album_slug: null, duration: null }, { slug, title: '', artist: '', album_slug: null, duration: null },
true, true,
) )
void api('/stream/' + slug).catch(() => null) void preloadStream(slug)
} }
} }
searchSelectRef.current = onSearchSelect searchSelectRef.current = onSearchSelect
// --- Helpers --- // --- Helpers ---
function fmt(secs: number) {
if (!secs || Number.isNaN(secs)) return '0:00'
const s = Math.floor(secs)
const m = Math.floor(s / 60)
const h = Math.floor(m / 60)
if (h > 0) {
return `${h}:${pad(m % 60)}:${pad(s % 60)}`
}
return `${m}:${pad(s % 60)}`
}
function pad(n: number) {
return String(n).padStart(2, '0')
}
function showToast(msg: string) { function showToast(msg: string) {
const t = document.getElementById('toast') const t = document.getElementById('toast')
if (!t) return if (!t) return
@@ -625,18 +613,20 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
const url = new URL(window.location.href) const url = new URL(window.location.href)
const urlSlug = url.searchParams.get('t') const urlSlug = url.searchParams.get('t')
if (urlSlug) { if (urlSlug) {
const info = await api('/tracks/' + urlSlug) try {
if (info) { const { detail } = await dispatch(fetchTrackDetail(urlSlug)).unwrap()
addTrackToQueue( addTrackToQueue(
{ {
slug: (info as any).slug, slug: detail.slug,
title: (info as any).title, title: detail.title,
artist: (info as any).artist_name, artist: detail.artist_name,
album_slug: (info as any).album_slug, album_slug: detail.album_slug,
duration: (info as any).duration_secs, duration: detail.duration_secs,
}, },
true, true,
) )
} catch {
// fetchTrackDetail rejected — track not found or error
} }
} }
void showArtists() void showArtists()
@@ -647,120 +637,52 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
queueActionsRef.current = null queueActionsRef.current = null
audio.pause() audio.pause()
} }
}, [apiRoot]) }, [])
const libraryLoading =
breadcrumbs.length === 1
? artistsLoading
: breadcrumbs.length === 2
? albumsLoading
: albumTracksLoading
const libraryError =
breadcrumbs.length === 1
? artistsError
: breadcrumbs.length === 2
? albumsError
: albumTracksError
return ( return (
<div className="furumi-root"> <div className="furumi-root">
<header className="header"> <Header
<div className="header-logo"> searchOpen={searchOpen}
<button className="btn-menu">&#9776;</button> searchResults={searchResults}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> onSearchSelect={(type, slug) => searchSelectRef.current(type, slug)}
<circle cx="9" cy="18" r="3" />
<circle cx="18" cy="15" r="3" />
<path d="M12 18V6l9-3v3" />
</svg>
Furumi
<span className="header-version">v</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<div className="search-wrap">
<input id="searchInput" placeholder="Search..." />
<SearchDropdown
isOpen={searchOpen}
results={searchResults}
onSelect={(type, slug) => searchSelectRef.current(type, slug)}
/> />
</div>
</div>
</header>
<div className="main"> <MainPanel
<div className="sidebar-overlay" id="sidebarOverlay" /> breadcrumbs={breadcrumbs}
<aside className="sidebar" id="sidebar"> libraryLoading={libraryLoading}
<div className="sidebar-header">Library</div> libraryError={libraryError}
<Breadcrumbs items={breadcrumbs} /> libraryItems={libraryItems}
<div className="file-list" id="fileList"> queueItemsView={queueItemsView}
<LibraryList loading={libraryLoading} error={libraryError} items={libraryItems} /> queueOrderView={queueOrderView}
</div> queuePlayingOrigIdxView={queuePlayingOrigIdxView}
</aside> queueScrollSignal={queueScrollSignal}
onQueuePlay={(origIdx) => queueActionsRef.current?.playIndex(origIdx)}
<section className="queue-panel"> onQueueRemove={(origIdx) =>
<div className="queue-header">
<span>Queue</span>
<div className="queue-actions">
<button className="queue-btn active" id="btnShuffle">
Shuffle
</button>
<button className="queue-btn active" id="btnRepeat">
Repeat
</button>
<button className="queue-btn" id="btnClearQueue">
Clear
</button>
</div>
</div>
<div className="queue-list" id="queueList">
<QueueList
apiRoot={apiRoot}
queue={queueItemsView}
order={queueOrderView}
playingOrigIdx={queuePlayingOrigIdxView}
scrollSignal={queueScrollSignal}
onPlay={(origIdx) => queueActionsRef.current?.playIndex(origIdx)}
onRemove={(origIdx) =>
queueActionsRef.current?.removeFromQueue(origIdx) queueActionsRef.current?.removeFromQueue(origIdx)
} }
onMove={(fromPos, toPos) => onQueueMove={(fromPos, toPos) =>
queueActionsRef.current?.moveQueueItem(fromPos, toPos) queueActionsRef.current?.moveQueueItem(fromPos, toPos)
} }
/> />
</div>
</section>
</div>
<div className="player-bar"> <PlayerBar track={nowPlayingTrack} />
<NowPlaying apiRoot={apiRoot} track={nowPlayingTrack} />
<div className="controls">
<div className="ctrl-btns">
<button className="ctrl-btn" id="btnPrev">
&#9198;
</button>
<button className="ctrl-btn ctrl-btn-main" id="btnPlayPause">
&#9654;
</button>
<button className="ctrl-btn" id="btnNext">
&#9197;
</button>
</div>
<div className="progress-row">
<span className="time" id="timeElapsed">
0:00
</span>
<div className="progress-bar" id="progressBar">
<div className="progress-fill" id="progressFill" style={{ width: '0%' }} />
</div>
<span className="time" id="timeDuration">
0:00
</span>
</div>
</div>
<div className="volume-row">
<span className="vol-icon" id="volIcon">
&#128266;
</span>
<input
type="range"
className="volume-slider"
id="volSlider"
min={0}
max={100}
defaultValue={80}
/>
</div>
</div>
<div className="toast" id="toast" /> <div className="toast" id="toast" />
<audio id="audioEl" /> <audio ref={audioRef} />
</div> </div>
) )
} }

View File

@@ -0,0 +1,45 @@
import { SearchDropdown } from './SearchDropdown'
type SearchResultItem = {
result_type: string
slug: string
name: string
detail?: string
}
type HeaderProps = {
searchOpen: boolean
searchResults: SearchResultItem[]
onSearchSelect: (type: string, slug: string) => void
}
export function Header({
searchOpen,
searchResults,
onSearchSelect,
}: HeaderProps) {
return (
<header className="header">
<div className="header-logo">
<button className="btn-menu">&#9776;</button>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="9" cy="18" r="3" />
<circle cx="18" cy="15" r="3" />
<path d="M12 18V6l9-3v3" />
</svg>
Furumi
<span className="header-version">v</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<div className="search-wrap">
<input id="searchInput" placeholder="Search..." />
<SearchDropdown
isOpen={searchOpen}
results={searchResults}
onSelect={onSearchSelect}
/>
</div>
</div>
</header>
)
}

View File

@@ -0,0 +1,86 @@
import type { MouseEvent as ReactMouseEvent } from 'react'
import { Breadcrumbs } from './Breadcrumbs'
import { LibraryList } from './LibraryList'
import { QueueList, type QueueItem } from './QueueList'
export type Crumb = { label: string; action?: () => void }
export type LibraryListItem = {
key: string
className: string
icon: string
name: string
detail?: string
nameClassName?: string
onClick: () => void
button?: { title: string; onClick: (ev: ReactMouseEvent<HTMLButtonElement>) => void }
}
type MainPanelProps = {
breadcrumbs: Crumb[]
libraryLoading: boolean
libraryError: string | null
libraryItems: LibraryListItem[]
queueItemsView: QueueItem[]
queueOrderView: number[]
queuePlayingOrigIdxView: number
queueScrollSignal: number
onQueuePlay: (origIdx: number) => void
onQueueRemove: (origIdx: number) => void
onQueueMove: (fromPos: number, toPos: number) => void
}
export function MainPanel({
breadcrumbs,
libraryLoading,
libraryError,
libraryItems,
queueItemsView,
queueOrderView,
queuePlayingOrigIdxView,
queueScrollSignal,
onQueuePlay,
onQueueRemove,
onQueueMove,
}: MainPanelProps) {
return (
<div className="main">
<div className="sidebar-overlay" id="sidebarOverlay" />
<aside className="sidebar" id="sidebar">
<div className="sidebar-header">Library</div>
<Breadcrumbs items={breadcrumbs} />
<div className="file-list" id="fileList">
<LibraryList loading={libraryLoading} error={libraryError} items={libraryItems} />
</div>
</aside>
<section className="queue-panel">
<div className="queue-header">
<span>Queue</span>
<div className="queue-actions">
<button className="queue-btn active" id="btnShuffle">
Shuffle
</button>
<button className="queue-btn active" id="btnRepeat">
Repeat
</button>
<button className="queue-btn" id="btnClearQueue">
Clear
</button>
</div>
</div>
<div className="queue-list" id="queueList">
<QueueList
queue={queueItemsView}
order={queueOrderView}
playingOrigIdx={queuePlayingOrigIdxView}
scrollSignal={queueScrollSignal}
onPlay={onQueuePlay}
onRemove={onQueueRemove}
onMove={onQueueMove}
/>
</div>
</section>
</div>
)
}

View File

@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { API_ROOT } from '../furumiApi'
import type { QueueItem } from './QueueList' import type { QueueItem } from './QueueList'
function Cover({ src }: { src: string }) { function Cover({ src }: { src: string }) {
@@ -12,7 +13,7 @@ function Cover({ src }: { src: string }) {
return <img src={src} alt="" onError={() => setErrored(true)} /> return <img src={src} alt="" onError={() => setErrored(true)} />
} }
export function NowPlaying({ apiRoot, track }: { apiRoot: string; track: QueueItem | null }) { export function NowPlaying({ track }: { track: QueueItem | null }) {
if (!track) { if (!track) {
return ( return (
<div className="np-info"> <div className="np-info">
@@ -31,7 +32,7 @@ export function NowPlaying({ apiRoot, track }: { apiRoot: string; track: QueueIt
) )
} }
const coverUrl = `${apiRoot}/tracks/${track.slug}/cover` const coverUrl = `${API_ROOT}/tracks/${track.slug}/cover`
return ( return (
<div className="np-info"> <div className="np-info">

View File

@@ -0,0 +1,47 @@
import { NowPlaying } from './NowPlaying'
import type { QueueItem } from './QueueList'
export function PlayerBar({ track }: { track: QueueItem | null }) {
return (
<div className="player-bar">
<NowPlaying track={track} />
<div className="controls">
<div className="ctrl-btns">
<button className="ctrl-btn" id="btnPrev">
&#9198;
</button>
<button className="ctrl-btn ctrl-btn-main" id="btnPlayPause">
&#9654;
</button>
<button className="ctrl-btn" id="btnNext">
&#9197;
</button>
</div>
<div className="progress-row">
<span className="time" id="timeElapsed">
0:00
</span>
<div className="progress-bar" id="progressBar">
<div className="progress-fill" id="progressFill" style={{ width: '0%' }} />
</div>
<span className="time" id="timeDuration">
0:00
</span>
</div>
</div>
<div className="volume-row">
<span className="vol-icon" id="volIcon">
&#128266;
</span>
<input
type="range"
className="volume-slider"
id="volSlider"
min={0}
max={100}
defaultValue={80}
/>
</div>
</div>
)
}

View File

@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { API_ROOT } from '../furumiApi'
export type QueueItem = { export type QueueItem = {
slug: string slug: string
@@ -9,7 +10,6 @@ export type QueueItem = {
} }
type QueueListProps = { type QueueListProps = {
apiRoot: string
queue: QueueItem[] queue: QueueItem[]
order: number[] order: number[]
playingOrigIdx: number playingOrigIdx: number
@@ -43,7 +43,6 @@ function Cover({ src }: { src: string }) {
} }
export function QueueList({ export function QueueList({
apiRoot,
queue, queue,
order, order,
playingOrigIdx, playingOrigIdx,
@@ -78,7 +77,7 @@ export function QueueList({
if (!t) return null if (!t) return null
const isPlaying = origIdx === playingOrigIdx const isPlaying = origIdx === playingOrigIdx
const coverSrc = t.album_slug ? `${apiRoot}/tracks/${t.slug}/cover` : '' const coverSrc = t.album_slug ? `${API_ROOT}/tracks/${t.slug}/cover` : ''
const dur = t.duration ? fmt(t.duration) : '' const dur = t.duration ? fmt(t.duration) : ''
const isDragging = draggingPos === pos const isDragging = draggingPos === pos
const isDragOver = dragOverPos === pos const isDragOver = dragOverPos === pos

View File

@@ -1,12 +1,49 @@
export type FurumiApiClient = (path: string) => Promise<unknown | null> import axios from 'axios'
import type { Album, Artist, SearchResult, Track, TrackDetail } from './types'
export function createFurumiApiClient(apiRoot: string): FurumiApiClient { const API_BASE = import.meta.env.VITE_API_BASE_URL ?? ''
const API = apiRoot export const API_ROOT = `${API_BASE}/api`
return async function api(path: string) { const API_KEY = import.meta.env.VITE_API_KEY
const r = await fetch(API + path)
if (!r.ok) return null export const furumiApi = axios.create({
return r.json() baseURL: API_ROOT,
} headers: API_KEY ? { 'x-api-key': API_KEY } : {},
})
export async function getArtists(): Promise<Artist[] | null> {
const res = await furumiApi.get<Artist[]>('/artists').catch(() => null)
return res?.data ?? null
}
export async function getArtistAlbums(artistSlug: string): Promise<Album[] | null> {
const res = await furumiApi.get<Album[]>(`/artists/${artistSlug}/albums`).catch(() => null)
return res?.data ?? null
}
export async function getAlbumTracks(albumSlug: string): Promise<Track[] | null> {
const res = await furumiApi.get<Track[]>(`/albums/${albumSlug}`).catch(() => null)
return res?.data ?? null
}
export async function getArtistTracks(artistSlug: string): Promise<Track[] | null> {
const res = await furumiApi.get<Track[]>(`/artists/${artistSlug}/tracks`).catch(() => null)
return res?.data ?? null
}
export async function searchTracks(query: string): Promise<SearchResult[] | null> {
const res = await furumiApi
.get<SearchResult[]>(`/search?q=${encodeURIComponent(query)}`)
.catch(() => null)
return res?.data ?? null
}
export async function getTrackInfo(trackSlug: string): Promise<TrackDetail | null> {
const res = await furumiApi.get<TrackDetail>(`/tracks/${trackSlug}`).catch(() => null)
return res?.data ?? null
}
export async function preloadStream(trackSlug: string) {
await furumiApi.get(`/stream/${trackSlug}`).catch(() => null)
} }

View File

@@ -1,3 +1,8 @@
html,
body {
height: 100%;
}
body { body {
margin: 0; margin: 0;
font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
@@ -5,6 +10,10 @@ body {
background-color: #f3f6fb; background-color: #f3f6fb;
} }
#root {
height: 100%;
}
* { * {
box-sizing: border-box; box-sizing: border-box;
} }

View File

@@ -1,10 +1,14 @@
import { StrictMode } from 'react' import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import { Provider } from 'react-redux'
import { store } from './store'
import './index.css' import './index.css'
import App from './App.tsx' import App from './App.tsx'
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<Provider store={store}>
<App /> <App />
</Provider>
</StrictMode>, </StrictMode>,
) )

View File

@@ -0,0 +1,23 @@
import { configureStore } from '@reduxjs/toolkit'
import { useDispatch, useSelector, type TypedUseSelectorHook } from 'react-redux'
import artistsReducer from './slices/artistsSlice'
import albumsReducer from './slices/albumsSlice'
import albumTracksReducer from './slices/albumTracksSlice'
import artistTracksReducer from './slices/artistTracksSlice'
import trackDetailReducer from './slices/trackDetailSlice'
export const store = configureStore({
reducer: {
artists: artistsReducer,
albums: albumsReducer,
albumTracks: albumTracksReducer,
artistTracks: artistTracksReducer,
trackDetail: trackDetailReducer,
},
})
export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch
export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector

View File

@@ -0,0 +1,54 @@
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
import type { Track } from '../../types'
import { getAlbumTracks } from '../../furumiApi'
export const fetchAlbumTracks = createAsyncThunk(
'albumTracks/fetch',
async (albumSlug: string, { rejectWithValue }) => {
const data = await getAlbumTracks(albumSlug)
if (data === null) return rejectWithValue('Failed to fetch album tracks')
return { albumSlug, tracks: data }
},
)
interface AlbumTracksState {
byAlbum: Record<string, Track[]>
loading: boolean
error: string | null
}
const initialState: AlbumTracksState = {
byAlbum: {},
loading: false,
error: null,
}
const albumTracksSlice = createSlice({
name: 'albumTracks',
initialState,
reducers: {
clearAlbumTracks(state) {
state.byAlbum = {}
state.error = null
},
},
extraReducers: (builder) => {
builder
.addCase(fetchAlbumTracks.pending, (state) => {
state.loading = true
state.error = null
})
.addCase(fetchAlbumTracks.fulfilled, (state, action) => {
state.loading = false
state.byAlbum[action.payload.albumSlug] = action.payload.tracks
state.error = null
})
.addCase(fetchAlbumTracks.rejected, (state, action) => {
state.loading = false
state.error = action.payload as string ?? 'Unknown error'
})
},
})
export const { clearAlbumTracks } = albumTracksSlice.actions
export default albumTracksSlice.reducer

View File

@@ -0,0 +1,54 @@
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
import type { Album } from '../../types'
import { getArtistAlbums } from '../../furumiApi'
export const fetchArtistAlbums = createAsyncThunk(
'albums/fetchByArtist',
async (artistSlug: string, { rejectWithValue }) => {
const data = await getArtistAlbums(artistSlug)
if (data === null) return rejectWithValue('Failed to fetch albums')
return { artistSlug, albums: data }
},
)
interface AlbumsState {
byArtist: Record<string, Album[]>
loading: boolean
error: string | null
}
const initialState: AlbumsState = {
byArtist: {},
loading: false,
error: null,
}
const albumsSlice = createSlice({
name: 'albums',
initialState,
reducers: {
clearAlbums(state) {
state.byArtist = {}
state.error = null
},
},
extraReducers: (builder) => {
builder
.addCase(fetchArtistAlbums.pending, (state) => {
state.loading = true
state.error = null
})
.addCase(fetchArtistAlbums.fulfilled, (state, action) => {
state.loading = false
state.byArtist[action.payload.artistSlug] = action.payload.albums
state.error = null
})
.addCase(fetchArtistAlbums.rejected, (state, action) => {
state.loading = false
state.error = action.payload as string ?? 'Unknown error'
})
},
})
export const { clearAlbums } = albumsSlice.actions
export default albumsSlice.reducer

View File

@@ -0,0 +1,54 @@
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
import type { Track } from '../../types'
import { getArtistTracks } from '../../furumiApi'
export const fetchArtistTracks = createAsyncThunk(
'artistTracks/fetch',
async (artistSlug: string, { rejectWithValue }) => {
const data = await getArtistTracks(artistSlug)
if (data === null) return rejectWithValue('Failed to fetch artist tracks')
return { artistSlug, tracks: data }
},
)
interface ArtistTracksState {
byArtist: Record<string, Track[]>
loading: boolean
error: string | null
}
const initialState: ArtistTracksState = {
byArtist: {},
loading: false,
error: null,
}
const artistTracksSlice = createSlice({
name: 'artistTracks',
initialState,
reducers: {
clearArtistTracks(state) {
state.byArtist = {}
state.error = null
},
},
extraReducers: (builder) => {
builder
.addCase(fetchArtistTracks.pending, (state) => {
state.loading = true
state.error = null
})
.addCase(fetchArtistTracks.fulfilled, (state, action) => {
state.loading = false
state.byArtist[action.payload.artistSlug] = action.payload.tracks
state.error = null
})
.addCase(fetchArtistTracks.rejected, (state, action) => {
state.loading = false
state.error = action.payload as string ?? 'Unknown error'
})
},
})
export const { clearArtistTracks } = artistTracksSlice.actions
export default artistTracksSlice.reducer

View File

@@ -0,0 +1,54 @@
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
import type { Artist } from '../../types'
import { getArtists } from '../../furumiApi'
export const fetchArtists = createAsyncThunk(
'artists/fetch',
async (_, { rejectWithValue }) => {
const data = await getArtists()
if (data === null) return rejectWithValue('Failed to fetch artists')
return data
},
)
interface ArtistsState {
items: Artist[]
loading: boolean
error: string | null
}
const initialState: ArtistsState = {
items: [],
loading: false,
error: null,
}
const artistsSlice = createSlice({
name: 'artists',
initialState,
reducers: {
clearArtists(state) {
state.items = []
state.error = null
},
},
extraReducers: (builder) => {
builder
.addCase(fetchArtists.pending, (state) => {
state.loading = true
state.error = null
})
.addCase(fetchArtists.fulfilled, (state, action) => {
state.loading = false
state.items = action.payload
state.error = null
})
.addCase(fetchArtists.rejected, (state, action) => {
state.loading = false
state.error = action.payload as string ?? 'Unknown error'
})
},
})
export const { clearArtists } = artistsSlice.actions
export default artistsSlice.reducer

View File

@@ -0,0 +1,57 @@
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
import type { TrackDetail } from '../../types'
import { getTrackInfo } from '../../furumiApi'
export const fetchTrackDetail = createAsyncThunk(
'trackDetail/fetch',
async (trackSlug: string, { rejectWithValue }) => {
const data = await getTrackInfo(trackSlug)
if (data === null) return rejectWithValue('Failed to fetch track detail')
return { trackSlug, detail: data }
},
)
interface TrackDetailState {
bySlug: Record<string, TrackDetail>
loading: boolean
error: string | null
}
const initialState: TrackDetailState = {
bySlug: {},
loading: false,
error: null,
}
const trackDetailSlice = createSlice({
name: 'trackDetail',
initialState,
reducers: {
clearTrackDetail(state) {
state.bySlug = {}
state.error = null
},
removeTrackDetail(state, action: { payload: string }) {
delete state.bySlug[action.payload]
},
},
extraReducers: (builder) => {
builder
.addCase(fetchTrackDetail.pending, (state) => {
state.loading = true
state.error = null
})
.addCase(fetchTrackDetail.fulfilled, (state, action) => {
state.loading = false
state.bySlug[action.payload.trackSlug] = action.payload.detail
state.error = null
})
.addCase(fetchTrackDetail.rejected, (state, action) => {
state.loading = false
state.error = action.payload as string ?? 'Unknown error'
})
},
})
export const { clearTrackDetail, removeTrackDetail } = trackDetailSlice.actions
export default trackDetailSlice.reducer

View File

@@ -0,0 +1,42 @@
// API entity types (see PLAYER-API.md)
export interface Artist {
slug: string
name: string
album_count: number
track_count: number
}
export interface Album {
slug: string
name: string
year: number | null
track_count: number
has_cover: boolean
}
export interface Track {
slug: string
title: string
track_number: number | null
duration_secs: number
artist_name: string
album_name: string | null
album_slug: string | null
genre: string | null
}
export interface TrackDetail extends Track {
storage_path: string
artist_slug: string
album_year: number | null
}
export type SearchResultType = 'artist' | 'album' | 'track'
export interface SearchResult {
result_type: SearchResultType
slug: string
name: string
detail: string | null
}

View File

@@ -0,0 +1,14 @@
function pad(n: number) {
return String(n).padStart(2, '0')
}
export function fmt(secs: number) {
if (!secs || Number.isNaN(secs)) return '0:00'
const s = Math.floor(secs)
const m = Math.floor(s / 60)
const h = Math.floor(m / 60)
if (h > 0) {
return `${h}:${pad(m % 60)}:${pad(s % 60)}`
}
return `${m}:${pad(s % 60)}`
}

View File

@@ -25,3 +25,4 @@ base64 = "0.22"
rand = "0.8" rand = "0.8"
urlencoding = "2.1.3" urlencoding = "2.1.3"
rustls = { version = "0.23", features = ["ring"] } rustls = { version = "0.23", features = ["ring"] }
tower-http = { version = "0.6", features = ["cors"] }

View File

@@ -39,6 +39,10 @@ struct Args {
/// OIDC Session Secret (32+ chars, for HMAC). Random if not provided. /// OIDC Session Secret (32+ chars, for HMAC). Random if not provided.
#[arg(long, env = "FURUMI_PLAYER_OIDC_SESSION_SECRET")] #[arg(long, env = "FURUMI_PLAYER_OIDC_SESSION_SECRET")]
oidc_session_secret: Option<String>, oidc_session_secret: Option<String>,
/// API key for x-api-key header auth (alternative to OIDC session)
#[arg(long, env = "FURUMI_PLAYER_API_KEY")]
api_key: Option<String>,
} }
#[tokio::main] #[tokio::main]
@@ -90,10 +94,15 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
std::process::exit(1); std::process::exit(1);
}); });
if args.api_key.is_some() {
tracing::info!("x-api-key auth: enabled");
}
let state = Arc::new(web::AppState { let state = Arc::new(web::AppState {
pool, pool,
storage_dir: Arc::new(args.storage_dir), storage_dir: Arc::new(args.storage_dir),
oidc: oidc_state, oidc: oidc_state,
api_key: args.api_key,
}); });
tracing::info!("Web player: http://{}", bind_addr); tracing::info!("Web player: http://{}", bind_addr);

View File

@@ -5,6 +5,8 @@ use axum::{
middleware::Next, middleware::Next,
response::{Html, IntoResponse, Redirect, Response}, response::{Html, IntoResponse, Redirect, Response},
}; };
const X_API_KEY: &str = "x-api-key";
use openidconnect::{ use openidconnect::{
core::{CoreClient, CoreProviderMetadata, CoreResponseType}, core::{CoreClient, CoreProviderMetadata, CoreResponseType},
reqwest::async_http_client, reqwest::async_http_client,
@@ -92,17 +94,27 @@ fn verify_sso_cookie(secret: &[u8], cookie_val: &str) -> Option<String> {
} }
} }
/// Auth middleware: requires valid SSO session cookie. /// Auth middleware: requires valid SSO session cookie or x-api-key header.
pub async fn require_auth( pub async fn require_auth(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
req: Request, req: Request,
next: Next, next: Next,
) -> Response { ) -> Response {
let oidc = match &state.oidc { // 1. Check x-api-key header (if configured)
Some(o) => o, if let Some(ref expected) = state.api_key {
None => return next.run(req).await, // No OIDC configured = no auth if let Some(val) = req
}; .headers()
.get(X_API_KEY)
.and_then(|v| v.to_str().ok())
{
if val == expected {
return next.run(req).await;
}
}
}
// 2. Check SSO session cookie (if OIDC configured)
if let Some(ref oidc) = state.oidc {
let cookies = req let cookies = req
.headers() .headers()
.get(header::COOKIE) .get(header::COOKIE)
@@ -117,12 +129,16 @@ pub async fn require_auth(
} }
} }
} }
}
let uri = req.uri().to_string(); let uri = req.uri().to_string();
if uri.starts_with("/api/") { if uri.starts_with("/api/") {
(StatusCode::UNAUTHORIZED, "Unauthorized").into_response() (StatusCode::UNAUTHORIZED, "Unauthorized").into_response()
} else { } else if state.oidc.is_some() {
Redirect::to("/login").into_response() Redirect::to("/login").into_response()
} else {
// Only API key configured — no web login available
(StatusCode::UNAUTHORIZED, "Unauthorized").into_response()
} }
} }

View File

@@ -3,9 +3,12 @@ pub mod auth;
use std::sync::Arc; use std::sync::Arc;
use std::path::PathBuf; use std::path::PathBuf;
use std::time::Duration;
use axum::{Router, routing::get, middleware}; use axum::{Router, routing::get, middleware};
use axum::http::{header, Method};
use sqlx::PgPool; use sqlx::PgPool;
use tower_http::cors::{Any, CorsLayer};
#[derive(Clone)] #[derive(Clone)]
pub struct AppState { pub struct AppState {
@@ -13,6 +16,7 @@ pub struct AppState {
#[allow(dead_code)] #[allow(dead_code)]
pub storage_dir: Arc<PathBuf>, pub storage_dir: Arc<PathBuf>,
pub oidc: Option<Arc<auth::OidcState>>, pub oidc: Option<Arc<auth::OidcState>>,
pub api_key: Option<String>,
} }
pub fn build_router(state: Arc<AppState>) -> Router { pub fn build_router(state: Arc<AppState>) -> Router {
@@ -32,21 +36,28 @@ pub fn build_router(state: Arc<AppState>) -> Router {
.route("/", get(player_html)) .route("/", get(player_html))
.nest("/api", library); .nest("/api", library);
let has_oidc = state.oidc.is_some(); let requires_auth = state.oidc.is_some();
let app = if has_oidc { let app = if requires_auth {
authed authed
.route_layer(middleware::from_fn_with_state(state.clone(), auth::require_auth)) .route_layer(middleware::from_fn_with_state(state.clone(), auth::require_auth))
} else { } else {
authed authed
}; };
let cors = CorsLayer::new()
.allow_origin(Any)
.allow_methods([Method::GET, Method::OPTIONS, Method::HEAD])
.allow_headers([header::ACCEPT, header::CONTENT_TYPE, header::HeaderName::from_static("x-api-key")])
.max_age(Duration::from_secs(600));
Router::new() Router::new()
.route("/login", get(auth::login_page)) .route("/login", get(auth::login_page))
.route("/logout", get(auth::logout)) .route("/logout", get(auth::logout))
.route("/auth/login", get(auth::oidc_login)) .route("/auth/login", get(auth::oidc_login))
.route("/auth/callback", get(auth::oidc_callback)) .route("/auth/callback", get(auth::oidc_callback))
.merge(app) .merge(app)
.layer(cors)
.with_state(state) .with_state(state)
} }