Compare commits
17 Commits
d654fe9442
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e85ed32b7b | |||
| 71d5a38f21 | |||
| e34440498c | |||
| 8d70a5133a | |||
| f873542d02 | |||
| 56760be586 | |||
| 108c374c6d | |||
| 2129dc8007 | |||
| 3f2013e9d5 | |||
| cc3ef04cbe | |||
| 7ede23ff94 | |||
| a730ab568c | |||
|
|
c30a3aff5d | ||
|
|
71d88bacf2 | ||
| 5fb8821709 | |||
| b1eaa1b6e9 | |||
| 7c2c7b0ce5 |
50
.github/workflows/docker-publish-agent-dev.yml
vendored
Normal file
50
.github/workflows/docker-publish-agent-dev.yml
vendored
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
name: Publish Metadata Agent Image (dev)
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- DEV
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: docker.io
|
||||||
|
IMAGE_NAME: ${{ secrets.DOCKERHUB_USERNAME }}/furumi-metadata-agent
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push-image:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to Docker Hub
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Determine version and tags
|
||||||
|
id: info
|
||||||
|
run: |
|
||||||
|
IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"
|
||||||
|
SHORT_SHA="$(echo '${{ github.sha }}' | cut -c1-7)"
|
||||||
|
echo "tags=${IMAGE}:dev" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "version=dev-${SHORT_SHA}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Build and push Docker image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: docker/Dockerfile.agent
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.info.outputs.tags }}
|
||||||
|
build-args: |
|
||||||
|
FURUMI_VERSION=${{ steps.info.outputs.version }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
3
.github/workflows/docker-publish-agent.yml
vendored
3
.github/workflows/docker-publish-agent.yml
vendored
@@ -4,6 +4,7 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- '**'
|
- '**'
|
||||||
|
- '!DEV'
|
||||||
tags:
|
tags:
|
||||||
- 'v*.*.*'
|
- 'v*.*.*'
|
||||||
|
|
||||||
@@ -51,7 +52,7 @@ jobs:
|
|||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: Dockerfile.agent
|
file: docker/Dockerfile.agent
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.info.outputs.tags }}
|
tags: ${{ steps.info.outputs.tags }}
|
||||||
build-args: |
|
build-args: |
|
||||||
|
|||||||
50
.github/workflows/docker-publish-player-dev.yml
vendored
Normal file
50
.github/workflows/docker-publish-player-dev.yml
vendored
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
name: Publish Web Player Image (dev)
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- DEV
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: docker.io
|
||||||
|
IMAGE_NAME: ${{ secrets.DOCKERHUB_USERNAME }}/furumi-web-player
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push-image:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to Docker Hub
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Determine version and tags
|
||||||
|
id: info
|
||||||
|
run: |
|
||||||
|
IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"
|
||||||
|
SHORT_SHA="$(echo '${{ github.sha }}' | cut -c1-7)"
|
||||||
|
echo "tags=${IMAGE}:dev" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "version=dev-${SHORT_SHA}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Build and push Docker image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: docker/Dockerfile.web-player
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.info.outputs.tags }}
|
||||||
|
build-args: |
|
||||||
|
FURUMI_VERSION=${{ steps.info.outputs.version }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
3
.github/workflows/docker-publish-player.yml
vendored
3
.github/workflows/docker-publish-player.yml
vendored
@@ -4,6 +4,7 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- '**'
|
- '**'
|
||||||
|
- '!DEV'
|
||||||
tags:
|
tags:
|
||||||
- 'v*.*.*'
|
- 'v*.*.*'
|
||||||
|
|
||||||
@@ -51,7 +52,7 @@ jobs:
|
|||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: Dockerfile.web-player
|
file: docker/Dockerfile.web-player
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.info.outputs.tags }}
|
tags: ${{ steps.info.outputs.tags }}
|
||||||
build-args: |
|
build-args: |
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ jobs:
|
|||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
|
file: docker/Dockerfile
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.info.outputs.tags }}
|
tags: ${{ steps.info.outputs.tags }}
|
||||||
build-args: |
|
build-args: |
|
||||||
12
furumi-agent/migrations/0004_release_types_hidden.sql
Normal file
12
furumi-agent/migrations/0004_release_types_hidden.sql
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
ALTER TABLE albums
|
||||||
|
ADD COLUMN IF NOT EXISTS release_type TEXT NOT NULL DEFAULT 'album',
|
||||||
|
ADD COLUMN IF NOT EXISTS hidden BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
|
||||||
|
ALTER TABLE tracks
|
||||||
|
ADD COLUMN IF NOT EXISTS hidden BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
|
||||||
|
ALTER TABLE artists
|
||||||
|
ADD COLUMN IF NOT EXISTS hidden BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
|
||||||
|
ALTER TABLE pending_tracks
|
||||||
|
ADD COLUMN IF NOT EXISTS norm_release_type TEXT;
|
||||||
@@ -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).
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
@@ -70,12 +70,33 @@ You are a music metadata normalization assistant. Your job is to take raw metada
|
|||||||
- 0.5-0.8: Some guesswork involved, human review recommended.
|
- 0.5-0.8: Some guesswork involved, human review recommended.
|
||||||
- Below 0.5: Significant uncertainty, definitely needs review.
|
- Below 0.5: Significant uncertainty, definitely needs review.
|
||||||
|
|
||||||
|
12. **Release type**: Determine the type of release based on all available evidence.
|
||||||
|
|
||||||
|
Allowed values (use exactly one, lowercase):
|
||||||
|
- `album`: Full-length release, typically 4+ tracks
|
||||||
|
- `single`: One or two tracks released as a single, OR folder/tag explicitly says "Single", "Сингл"
|
||||||
|
- `ep`: Short release, typically 3-6 tracks, OR folder/path contains "EP" or "ЕП"
|
||||||
|
- `compilation`: Best-of, greatest hits, anthology, сборник, compilation
|
||||||
|
- `live`: Live recording, concert, live album — folder or tags contain "Live", "Concert", "Концерт"
|
||||||
|
|
||||||
|
Determination rules (in priority order):
|
||||||
|
- If the folder path contains keywords like "Single", "Сингл", "single" → `single`
|
||||||
|
- If the folder path contains "EP", "ЕП", "ep" (case-insensitive) → `ep`
|
||||||
|
- If the folder path contains "Live", "Concert", "Концерт", "live" → `live`
|
||||||
|
- If the folder path contains "Compilation", "сборник", "Anthology", "Greatest Hits" → `compilation`
|
||||||
|
- If album name contains these keywords → apply same logic
|
||||||
|
- If track count in folder is 1–2 → likely `single`
|
||||||
|
- If track count in folder is 3–6 and no other evidence → likely `ep`
|
||||||
|
- If track count is 7+ → likely `album`
|
||||||
|
- When in doubt with 3–6 tracks, prefer `ep` over `album` only if EP indicators present, otherwise `album`
|
||||||
|
|
||||||
## Response format
|
## Response format
|
||||||
|
|
||||||
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:
|
||||||
|
|
||||||
{"artist": "...", "album": "...", "title": "...", "year": 2000, "track_number": 1, "genre": "...", "featured_artists": ["...", "..."], "confidence": 0.95, "notes": "brief explanation of changes made"}
|
{"artist": "...", "album": "...", "title": "...", "year": 2000, "track_number": 1, "genre": "...", "featured_artists": [], "release_type": "album", "confidence": 0.95, "notes": "brief explanation of changes made"}
|
||||||
|
|
||||||
- Use null for fields you cannot determine.
|
- Use null for fields you cannot determine.
|
||||||
- Use an empty array [] for "featured_artists" if there are no featured artists.
|
- Use an empty array [] for "featured_artists" if there are no featured artists.
|
||||||
- The "notes" field should briefly explain what you changed and why.
|
- The "notes" field should briefly explain what you changed and why.
|
||||||
|
- "release_type" must be exactly one of: "album", "single", "ep", "compilation", "live"
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ pub async fn migrate(pool: &PgPool) -> Result<(), sqlx::migrate::MigrateError> {
|
|||||||
pub struct Artist {
|
pub struct Artist {
|
||||||
pub id: i64,
|
pub id: i64,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
pub hidden: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
@@ -33,6 +34,8 @@ pub struct Album {
|
|||||||
pub artist_id: i64,
|
pub artist_id: i64,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub year: Option<i32>,
|
pub year: Option<i32>,
|
||||||
|
pub release_type: String,
|
||||||
|
pub hidden: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
@@ -64,6 +67,7 @@ pub struct PendingTrack {
|
|||||||
pub norm_track_number: Option<i32>,
|
pub norm_track_number: Option<i32>,
|
||||||
pub norm_genre: Option<String>,
|
pub norm_genre: Option<String>,
|
||||||
pub norm_featured_artists: Option<String>, // JSON array
|
pub norm_featured_artists: Option<String>, // JSON array
|
||||||
|
pub norm_release_type: Option<String>,
|
||||||
pub confidence: Option<f64>,
|
pub confidence: Option<f64>,
|
||||||
pub llm_notes: Option<String>,
|
pub llm_notes: Option<String>,
|
||||||
pub error_message: Option<String>,
|
pub error_message: Option<String>,
|
||||||
@@ -172,6 +176,7 @@ pub async fn update_pending_normalized(
|
|||||||
norm_year = $6, norm_track_number = $7, norm_genre = $8,
|
norm_year = $6, norm_track_number = $7, norm_genre = $8,
|
||||||
norm_featured_artists = $9,
|
norm_featured_artists = $9,
|
||||||
confidence = $10, llm_notes = $11, error_message = $12,
|
confidence = $10, llm_notes = $11, error_message = $12,
|
||||||
|
norm_release_type = $13,
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
WHERE id = $1"#,
|
WHERE id = $1"#,
|
||||||
)
|
)
|
||||||
@@ -187,6 +192,7 @@ pub async fn update_pending_normalized(
|
|||||||
.bind(norm.confidence)
|
.bind(norm.confidence)
|
||||||
.bind(&norm.notes)
|
.bind(&norm.notes)
|
||||||
.bind(error_message)
|
.bind(error_message)
|
||||||
|
.bind(&norm.release_type)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -254,18 +260,19 @@ pub async fn upsert_artist(pool: &PgPool, name: &str) -> Result<i64, sqlx::Error
|
|||||||
Ok(row.0)
|
Ok(row.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn upsert_album(pool: &PgPool, artist_id: i64, name: &str, year: Option<i32>) -> Result<i64, sqlx::Error> {
|
pub async fn upsert_album(pool: &PgPool, artist_id: i64, name: &str, year: Option<i32>, release_type: &str) -> Result<i64, sqlx::Error> {
|
||||||
let slug = generate_slug();
|
let slug = generate_slug();
|
||||||
let row: (i64,) = sqlx::query_as(
|
let row: (i64,) = sqlx::query_as(
|
||||||
r#"INSERT INTO albums (artist_id, name, year, slug)
|
r#"INSERT INTO albums (artist_id, name, year, slug, release_type)
|
||||||
VALUES ($1, $2, $3, $4)
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
ON CONFLICT (artist_id, name) DO UPDATE SET year = COALESCE(EXCLUDED.year, albums.year)
|
ON CONFLICT (artist_id, name) DO UPDATE SET year = COALESCE(EXCLUDED.year, albums.year), release_type = EXCLUDED.release_type
|
||||||
RETURNING id"#
|
RETURNING id"#
|
||||||
)
|
)
|
||||||
.bind(artist_id)
|
.bind(artist_id)
|
||||||
.bind(name)
|
.bind(name)
|
||||||
.bind(year)
|
.bind(year)
|
||||||
.bind(&slug)
|
.bind(&slug)
|
||||||
|
.bind(release_type)
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(row.0)
|
Ok(row.0)
|
||||||
@@ -327,23 +334,36 @@ pub async fn approve_and_finalize(
|
|||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Check if track already exists (e.g. previously approved but pending not cleaned up)
|
// Check if track already exists by file_hash (re-approval of same file)
|
||||||
let existing: Option<(i64,)> = sqlx::query_as("SELECT id FROM tracks WHERE file_hash = $1")
|
let existing: Option<(i64,)> = sqlx::query_as("SELECT id FROM tracks WHERE file_hash = $1")
|
||||||
.bind(&pt.file_hash)
|
.bind(&pt.file_hash)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if let Some((track_id,)) = existing {
|
if let Some((track_id,)) = existing {
|
||||||
// Already finalized — just mark pending as approved
|
|
||||||
update_pending_status(pool, pending_id, "approved", None).await?;
|
update_pending_status(pool, pending_id, "approved", None).await?;
|
||||||
return Ok(track_id);
|
return Ok(track_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if track already exists by storage_path (Merged: different quality file landed
|
||||||
|
// at the same destination, source was deleted — don't create a phantom duplicate)
|
||||||
|
let existing_path: Option<(i64,)> = sqlx::query_as(
|
||||||
|
"SELECT id FROM tracks WHERE storage_path = $1 AND NOT hidden"
|
||||||
|
)
|
||||||
|
.bind(storage_path)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if let Some((track_id,)) = existing_path {
|
||||||
|
update_pending_status(pool, pending_id, "merged", None).await?;
|
||||||
|
return Ok(track_id);
|
||||||
|
}
|
||||||
|
|
||||||
let artist_name = pt.norm_artist.as_deref().unwrap_or("Unknown Artist");
|
let artist_name = pt.norm_artist.as_deref().unwrap_or("Unknown Artist");
|
||||||
let artist_id = upsert_artist(pool, artist_name).await?;
|
let artist_id = upsert_artist(pool, artist_name).await?;
|
||||||
|
|
||||||
let album_id = match pt.norm_album.as_deref() {
|
let album_id = match pt.norm_album.as_deref() {
|
||||||
Some(album_name) => Some(upsert_album(pool, artist_id, album_name, pt.norm_year).await?),
|
Some(album_name) => Some(upsert_album(pool, artist_id, album_name, pt.norm_year, pt.norm_release_type.as_deref().unwrap_or("album")).await?),
|
||||||
None => None,
|
None => None,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -480,6 +500,7 @@ pub struct NormalizedFields {
|
|||||||
pub genre: Option<String>,
|
pub genre: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub featured_artists: Vec<String>,
|
pub featured_artists: Vec<String>,
|
||||||
|
pub release_type: Option<String>,
|
||||||
pub confidence: Option<f64>,
|
pub confidence: Option<f64>,
|
||||||
pub notes: Option<String>,
|
pub notes: Option<String>,
|
||||||
}
|
}
|
||||||
@@ -526,13 +547,13 @@ pub async fn delete_pending(pool: &PgPool, id: Uuid) -> Result<bool, sqlx::Error
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn list_artists_all(pool: &PgPool) -> Result<Vec<Artist>, sqlx::Error> {
|
pub async fn list_artists_all(pool: &PgPool) -> Result<Vec<Artist>, sqlx::Error> {
|
||||||
sqlx::query_as::<_, Artist>("SELECT id, name FROM artists ORDER BY name")
|
sqlx::query_as::<_, Artist>("SELECT id, name, hidden FROM artists ORDER BY name")
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn list_albums_by_artist(pool: &PgPool, artist_id: i64) -> Result<Vec<Album>, sqlx::Error> {
|
pub async fn list_albums_by_artist(pool: &PgPool, artist_id: i64) -> Result<Vec<Album>, sqlx::Error> {
|
||||||
sqlx::query_as::<_, Album>("SELECT id, artist_id, name, year FROM albums WHERE artist_id = $1 ORDER BY year, name")
|
sqlx::query_as::<_, Album>("SELECT id, artist_id, name, year, release_type, hidden FROM albums WHERE artist_id = $1 ORDER BY year, name")
|
||||||
.bind(artist_id)
|
.bind(artist_id)
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
.await
|
.await
|
||||||
@@ -603,6 +624,8 @@ pub struct AlbumRow {
|
|||||||
pub artist_name: String,
|
pub artist_name: String,
|
||||||
pub year: Option<i32>,
|
pub year: Option<i32>,
|
||||||
pub track_count: i64,
|
pub track_count: i64,
|
||||||
|
pub release_type: String,
|
||||||
|
pub hidden: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, sqlx::FromRow)]
|
#[derive(Debug, Serialize, sqlx::FromRow)]
|
||||||
@@ -610,7 +633,12 @@ pub struct ArtistRow {
|
|||||||
pub id: i64,
|
pub id: i64,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub album_count: i64,
|
pub album_count: i64,
|
||||||
|
pub single_count: i64,
|
||||||
|
pub ep_count: i64,
|
||||||
|
pub compilation_count: i64,
|
||||||
|
pub live_count: i64,
|
||||||
pub track_count: i64,
|
pub track_count: i64,
|
||||||
|
pub hidden: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn search_tracks(
|
pub async fn search_tracks(
|
||||||
@@ -657,13 +685,13 @@ pub async fn search_albums(
|
|||||||
) -> Result<Vec<AlbumRow>, sqlx::Error> {
|
) -> Result<Vec<AlbumRow>, sqlx::Error> {
|
||||||
sqlx::query_as::<_, AlbumRow>(
|
sqlx::query_as::<_, AlbumRow>(
|
||||||
r#"SELECT a.id, a.name, ar.name AS artist_name, a.year,
|
r#"SELECT a.id, a.name, ar.name AS artist_name, a.year,
|
||||||
COUNT(t.id) AS track_count
|
COUNT(t.id) AS track_count, a.release_type, a.hidden
|
||||||
FROM albums a
|
FROM albums a
|
||||||
JOIN artists ar ON ar.id = a.artist_id
|
JOIN artists ar ON ar.id = a.artist_id
|
||||||
LEFT JOIN tracks t ON t.album_id = a.id
|
LEFT JOIN tracks t ON t.album_id = a.id
|
||||||
WHERE ($1 = '' OR a.name ILIKE '%' || $1 || '%')
|
WHERE ($1 = '' OR a.name ILIKE '%' || $1 || '%')
|
||||||
AND ($2 = '' OR ar.name ILIKE '%' || $2 || '%')
|
AND ($2 = '' OR ar.name ILIKE '%' || $2 || '%')
|
||||||
GROUP BY a.id, a.name, ar.name, a.year
|
GROUP BY a.id, a.name, ar.name, a.year, a.release_type, a.hidden
|
||||||
ORDER BY ar.name, a.year NULLS LAST, a.name
|
ORDER BY ar.name, a.year NULLS LAST, a.name
|
||||||
LIMIT $3 OFFSET $4"#,
|
LIMIT $3 OFFSET $4"#,
|
||||||
)
|
)
|
||||||
@@ -690,13 +718,18 @@ pub async fn search_artists_lib(
|
|||||||
) -> Result<Vec<ArtistRow>, sqlx::Error> {
|
) -> Result<Vec<ArtistRow>, sqlx::Error> {
|
||||||
sqlx::query_as::<_, ArtistRow>(
|
sqlx::query_as::<_, ArtistRow>(
|
||||||
r#"SELECT ar.id, ar.name,
|
r#"SELECT ar.id, ar.name,
|
||||||
COUNT(DISTINCT al.id) AS album_count,
|
COUNT(DISTINCT CASE WHEN al.release_type = 'album' THEN al.id END) AS album_count,
|
||||||
COUNT(DISTINCT ta.track_id) AS track_count
|
COUNT(DISTINCT CASE WHEN al.release_type = 'single' THEN al.id END) AS single_count,
|
||||||
|
COUNT(DISTINCT CASE WHEN al.release_type = 'ep' THEN al.id END) AS ep_count,
|
||||||
|
COUNT(DISTINCT CASE WHEN al.release_type = 'compilation' THEN al.id END) AS compilation_count,
|
||||||
|
COUNT(DISTINCT CASE WHEN al.release_type = 'live' THEN al.id END) AS live_count,
|
||||||
|
COUNT(DISTINCT ta.track_id) AS track_count,
|
||||||
|
ar.hidden
|
||||||
FROM artists ar
|
FROM artists ar
|
||||||
LEFT JOIN albums al ON al.artist_id = ar.id
|
LEFT JOIN albums al ON al.artist_id = ar.id
|
||||||
LEFT JOIN track_artists ta ON ta.artist_id = ar.id AND ta.role = 'primary'
|
LEFT JOIN track_artists ta ON ta.artist_id = ar.id AND ta.role = 'primary'
|
||||||
WHERE ($1 = '' OR ar.name ILIKE '%' || $1 || '%')
|
WHERE ($1 = '' OR ar.name ILIKE '%' || $1 || '%')
|
||||||
GROUP BY ar.id, ar.name
|
GROUP BY ar.id, ar.name, ar.hidden
|
||||||
ORDER BY ar.name
|
ORDER BY ar.name
|
||||||
LIMIT $2 OFFSET $3"#,
|
LIMIT $2 OFFSET $3"#,
|
||||||
)
|
)
|
||||||
@@ -817,6 +850,12 @@ pub struct AlbumTrackRow {
|
|||||||
pub genre: Option<String>,
|
pub genre: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn set_album_tracks_genre(pool: &PgPool, album_id: i64, genre: &str) -> Result<(), sqlx::Error> {
|
||||||
|
sqlx::query("UPDATE tracks SET genre = $2 WHERE album_id = $1")
|
||||||
|
.bind(album_id).bind(genre).execute(pool).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get_album_details(pool: &PgPool, id: i64) -> Result<Option<AlbumDetails>, sqlx::Error> {
|
pub async fn get_album_details(pool: &PgPool, id: i64) -> Result<Option<AlbumDetails>, sqlx::Error> {
|
||||||
let row: Option<(i64, String, Option<i32>, i64, String)> = sqlx::query_as(
|
let row: Option<(i64, String, Option<i32>, i64, String)> = sqlx::query_as(
|
||||||
"SELECT a.id, a.name, a.year, ar.id, ar.name FROM albums a JOIN artists ar ON ar.id=a.artist_id WHERE a.id=$1"
|
"SELECT a.id, a.name, a.year, ar.id, ar.name FROM albums a JOIN artists ar ON ar.id=a.artist_id WHERE a.id=$1"
|
||||||
@@ -853,6 +892,19 @@ pub async fn get_album_cover(pool: &PgPool, album_id: i64) -> Result<Option<(Str
|
|||||||
Ok(row)
|
Ok(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the storage_path of the first track in an album (for embedded cover fallback).
|
||||||
|
pub async fn get_album_first_track_path(pool: &PgPool, album_id: i64) -> Result<Option<String>, sqlx::Error> {
|
||||||
|
let row: Option<(String,)> = sqlx::query_as(
|
||||||
|
"SELECT storage_path FROM tracks WHERE album_id=$1 ORDER BY track_number NULLS LAST, title LIMIT 1"
|
||||||
|
).bind(album_id).fetch_optional(pool).await?;
|
||||||
|
Ok(row.map(|(p,)| p))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_artist_by_id(pool: &PgPool, id: i64) -> Result<Option<Artist>, sqlx::Error> {
|
||||||
|
sqlx::query_as::<_, Artist>("SELECT id, name, hidden FROM artists WHERE id=$1")
|
||||||
|
.bind(id).fetch_optional(pool).await
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn search_albums_for_artist(pool: &PgPool, q: &str, artist_id: Option<i64>) -> Result<Vec<(i64, String)>, sqlx::Error> {
|
pub async fn search_albums_for_artist(pool: &PgPool, q: &str, artist_id: Option<i64>) -> Result<Vec<(i64, String)>, sqlx::Error> {
|
||||||
if let Some(aid) = artist_id {
|
if let Some(aid) = artist_id {
|
||||||
let rows: Vec<(i64, String)> = sqlx::query_as(
|
let rows: Vec<(i64, String)> = sqlx::query_as(
|
||||||
@@ -867,6 +919,120 @@ pub async fn search_albums_for_artist(pool: &PgPool, q: &str, artist_id: Option<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn set_track_hidden(pool: &PgPool, id: i64, hidden: bool) -> Result<(), sqlx::Error> {
|
||||||
|
sqlx::query("UPDATE tracks SET hidden=$2 WHERE id=$1").bind(id).bind(hidden).execute(pool).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_album_hidden(pool: &PgPool, id: i64, hidden: bool) -> Result<(), sqlx::Error> {
|
||||||
|
sqlx::query("UPDATE albums SET hidden=$2 WHERE id=$1").bind(id).bind(hidden).execute(pool).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_artist_hidden(pool: &PgPool, id: i64, hidden: bool) -> Result<(), sqlx::Error> {
|
||||||
|
sqlx::query("UPDATE artists SET hidden=$2 WHERE id=$1").bind(id).bind(hidden).execute(pool).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_album_release_type(pool: &PgPool, id: i64, release_type: &str) -> Result<(), sqlx::Error> {
|
||||||
|
sqlx::query("UPDATE albums SET release_type=$2 WHERE id=$1").bind(id).bind(release_type).execute(pool).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn rename_artist_name(pool: &PgPool, id: i64, name: &str) -> Result<(), sqlx::Error> {
|
||||||
|
sqlx::query("UPDATE artists SET name=$2 WHERE id=$1").bind(id).bind(name).execute(pool).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full artist data for admin form
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, sqlx::FromRow)]
|
||||||
|
pub struct ArtistAlbumRow {
|
||||||
|
pub id: i64,
|
||||||
|
pub name: String,
|
||||||
|
pub year: Option<i32>,
|
||||||
|
pub release_type: String,
|
||||||
|
pub hidden: bool,
|
||||||
|
pub track_count: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, sqlx::FromRow)]
|
||||||
|
pub struct ArtistAlbumTrack {
|
||||||
|
pub id: i64,
|
||||||
|
pub title: String,
|
||||||
|
pub track_number: Option<i32>,
|
||||||
|
pub duration_secs: Option<f64>,
|
||||||
|
pub hidden: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, sqlx::FromRow)]
|
||||||
|
pub struct AppearanceRow {
|
||||||
|
pub track_id: i64,
|
||||||
|
pub track_title: String,
|
||||||
|
pub primary_artist_id: i64,
|
||||||
|
pub primary_artist_name: String,
|
||||||
|
pub album_id: Option<i64>,
|
||||||
|
pub album_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_artist_albums(pool: &PgPool, artist_id: i64) -> Result<Vec<ArtistAlbumRow>, sqlx::Error> {
|
||||||
|
sqlx::query_as::<_, ArtistAlbumRow>(
|
||||||
|
r#"SELECT a.id, a.name, a.year, a.release_type, a.hidden,
|
||||||
|
COUNT(t.id) AS track_count
|
||||||
|
FROM albums a
|
||||||
|
LEFT JOIN tracks t ON t.album_id = a.id
|
||||||
|
WHERE a.artist_id = $1
|
||||||
|
GROUP BY a.id, a.name, a.year, a.release_type, a.hidden
|
||||||
|
ORDER BY a.year NULLS LAST, a.name"#
|
||||||
|
).bind(artist_id).fetch_all(pool).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_album_tracks_admin(pool: &PgPool, album_id: i64) -> Result<Vec<ArtistAlbumTrack>, sqlx::Error> {
|
||||||
|
sqlx::query_as::<_, ArtistAlbumTrack>(
|
||||||
|
"SELECT id, title, track_number, duration_secs, hidden FROM tracks WHERE album_id=$1 ORDER BY track_number NULLS LAST, title"
|
||||||
|
).bind(album_id).fetch_all(pool).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_artist_appearances(pool: &PgPool, artist_id: i64) -> Result<Vec<AppearanceRow>, sqlx::Error> {
|
||||||
|
sqlx::query_as::<_, AppearanceRow>(
|
||||||
|
r#"SELECT ta.track_id, t.title AS track_title,
|
||||||
|
ta_p.artist_id AS primary_artist_id, ar_p.name AS primary_artist_name,
|
||||||
|
t.album_id, al.name AS album_name
|
||||||
|
FROM track_artists ta
|
||||||
|
JOIN tracks t ON t.id = ta.track_id
|
||||||
|
JOIN track_artists ta_p ON ta_p.track_id = t.id AND ta_p.role = 'primary'
|
||||||
|
JOIN artists ar_p ON ar_p.id = ta_p.artist_id
|
||||||
|
LEFT JOIN albums al ON al.id = t.album_id
|
||||||
|
WHERE ta.artist_id = $1 AND ta.role = 'featured'
|
||||||
|
ORDER BY ar_p.name, al.name NULLS LAST, t.title"#
|
||||||
|
).bind(artist_id).fetch_all(pool).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn add_track_appearance(pool: &PgPool, track_id: i64, artist_id: i64) -> Result<(), sqlx::Error> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO track_artists (track_id, artist_id, role) VALUES ($1, $2, 'featured') ON CONFLICT DO NOTHING"
|
||||||
|
).bind(track_id).bind(artist_id).execute(pool).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn remove_track_appearance(pool: &PgPool, track_id: i64, artist_id: i64) -> Result<(), sqlx::Error> {
|
||||||
|
sqlx::query(
|
||||||
|
"DELETE FROM track_artists WHERE track_id=$1 AND artist_id=$2 AND role='featured'"
|
||||||
|
).bind(track_id).bind(artist_id).execute(pool).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn search_tracks_for_feat(pool: &PgPool, q: &str) -> Result<Vec<(i64, String, String)>, sqlx::Error> {
|
||||||
|
// Returns (track_id, track_title, primary_artist_name)
|
||||||
|
sqlx::query_as::<_, (i64, String, String)>(
|
||||||
|
r#"SELECT t.id, t.title, ar.name FROM tracks t
|
||||||
|
JOIN track_artists ta ON ta.track_id=t.id AND ta.role='primary'
|
||||||
|
JOIN artists ar ON ar.id=ta.artist_id
|
||||||
|
WHERE t.title ILIKE '%'||$1||'%' OR ar.name ILIKE '%'||$1||'%'
|
||||||
|
ORDER BY ar.name, t.title LIMIT 15"#
|
||||||
|
).bind(q).fetch_all(pool).await
|
||||||
|
}
|
||||||
|
|
||||||
// =================== Artist Merges ===================
|
// =================== Artist Merges ===================
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
@@ -951,7 +1117,7 @@ pub async fn get_pending_merges_for_processing(pool: &PgPool) -> Result<Vec<Uuid
|
|||||||
pub async fn get_artists_full_data(pool: &PgPool, ids: &[i64]) -> Result<Vec<ArtistFullData>, sqlx::Error> {
|
pub async fn get_artists_full_data(pool: &PgPool, ids: &[i64]) -> Result<Vec<ArtistFullData>, sqlx::Error> {
|
||||||
let mut result = Vec::new();
|
let mut result = Vec::new();
|
||||||
for &id in ids {
|
for &id in ids {
|
||||||
let artist: Artist = sqlx::query_as("SELECT id, name FROM artists WHERE id = $1")
|
let artist: Artist = sqlx::query_as("SELECT id, name, hidden FROM artists WHERE id = $1")
|
||||||
.bind(id).fetch_one(pool).await?;
|
.bind(id).fetch_one(pool).await?;
|
||||||
let albums: Vec<Album> = sqlx::query_as("SELECT * FROM albums WHERE artist_id = $1 ORDER BY year NULLS LAST, name")
|
let albums: Vec<Album> = sqlx::query_as("SELECT * FROM albums WHERE artist_id = $1 ORDER BY year NULLS LAST, name")
|
||||||
.bind(id).fetch_all(pool).await?;
|
.bind(id).fetch_all(pool).await?;
|
||||||
|
|||||||
@@ -132,8 +132,8 @@ async fn reprocess_pending(state: &Arc<AppState>) -> anyhow::Result<usize> {
|
|||||||
Vec::new()
|
Vec::new()
|
||||||
};
|
};
|
||||||
|
|
||||||
// LLM normalization
|
// LLM normalization (no folder context available for reprocessing from DB)
|
||||||
match normalize::normalize(state, &raw_meta, &hints, &similar_artists, &similar_albums).await {
|
match normalize::normalize(state, &raw_meta, &hints, &similar_artists, &similar_albums, None).await {
|
||||||
Ok(normalized) => {
|
Ok(normalized) => {
|
||||||
let confidence = normalized.confidence.unwrap_or(0.0);
|
let confidence = normalized.confidence.unwrap_or(0.0);
|
||||||
let status = if confidence >= state.config.confidence_threshold {
|
let status = if confidence >= state.config.confidence_threshold {
|
||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -428,9 +440,35 @@ async fn process_file(state: &Arc<AppState>, file_path: &std::path::Path) -> any
|
|||||||
tracing::info!(file = filename, matches = ?names, "Found similar albums in DB");
|
tracing::info!(file = filename, matches = ?names, "Found similar albums in DB");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build folder context for the LLM
|
||||||
|
let audio_extensions = ["flac", "mp3", "ogg", "wav", "aac", "m4a", "opus", "wma", "ape", "alac"];
|
||||||
|
let folder_ctx = {
|
||||||
|
let folder = file_path.parent().unwrap_or(file_path);
|
||||||
|
let mut folder_files: Vec<String> = std::fs::read_dir(folder)
|
||||||
|
.ok()
|
||||||
|
.map(|rd| {
|
||||||
|
rd.filter_map(|e| e.ok())
|
||||||
|
.filter_map(|e| {
|
||||||
|
let name = e.file_name().to_string_lossy().into_owned();
|
||||||
|
let ext = name.rsplit('.').next().unwrap_or("").to_lowercase();
|
||||||
|
if audio_extensions.contains(&ext.as_str()) { Some(name) } else { None }
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
folder_files.sort();
|
||||||
|
let track_count = folder_files.len();
|
||||||
|
let folder_path = folder
|
||||||
|
.strip_prefix(&state.config.inbox_dir)
|
||||||
|
.unwrap_or(folder)
|
||||||
|
.to_string_lossy()
|
||||||
|
.into_owned();
|
||||||
|
normalize::FolderContext { folder_path, folder_files, track_count }
|
||||||
|
};
|
||||||
|
|
||||||
// Call LLM for normalization
|
// Call LLM for normalization
|
||||||
tracing::info!(file = filename, model = %state.config.ollama_model, "Sending to LLM for normalization...");
|
tracing::info!(file = filename, model = %state.config.ollama_model, "Sending to LLM for normalization...");
|
||||||
match normalize::normalize(state, &raw_meta, &hints, &similar_artists, &similar_albums).await {
|
match normalize::normalize(state, &raw_meta, &hints, &similar_artists, &similar_albums, Some(&folder_ctx)).await {
|
||||||
Ok(normalized) => {
|
Ok(normalized) => {
|
||||||
let confidence = normalized.confidence.unwrap_or(0.0);
|
let confidence = normalized.confidence.unwrap_or(0.0);
|
||||||
let status = if confidence >= state.config.confidence_threshold {
|
let status = if confidence >= state.config.confidence_threshold {
|
||||||
|
|||||||
@@ -7,6 +7,13 @@ use crate::web::AppState;
|
|||||||
|
|
||||||
use super::metadata::RawMetadata;
|
use super::metadata::RawMetadata;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct FolderContext {
|
||||||
|
pub folder_path: String, // path relative to inbox_dir (e.g. "Kunteynir/Синглы/Пьюк")
|
||||||
|
pub folder_files: Vec<String>, // audio filenames in the same folder
|
||||||
|
pub track_count: usize, // number of audio files in folder
|
||||||
|
}
|
||||||
|
|
||||||
/// Build the user message with all context and call Ollama for normalization.
|
/// Build the user message with all context and call Ollama for normalization.
|
||||||
pub async fn normalize(
|
pub async fn normalize(
|
||||||
state: &Arc<AppState>,
|
state: &Arc<AppState>,
|
||||||
@@ -14,8 +21,9 @@ pub async fn normalize(
|
|||||||
hints: &crate::db::PathHints,
|
hints: &crate::db::PathHints,
|
||||||
similar_artists: &[SimilarArtist],
|
similar_artists: &[SimilarArtist],
|
||||||
similar_albums: &[SimilarAlbum],
|
similar_albums: &[SimilarAlbum],
|
||||||
|
folder_ctx: Option<&FolderContext>,
|
||||||
) -> anyhow::Result<NormalizedFields> {
|
) -> anyhow::Result<NormalizedFields> {
|
||||||
let user_message = build_user_message(raw, hints, similar_artists, similar_albums);
|
let user_message = build_user_message(raw, hints, similar_artists, similar_albums, folder_ctx);
|
||||||
|
|
||||||
let response = call_ollama(
|
let response = call_ollama(
|
||||||
&state.config.ollama_url,
|
&state.config.ollama_url,
|
||||||
@@ -34,6 +42,7 @@ fn build_user_message(
|
|||||||
hints: &crate::db::PathHints,
|
hints: &crate::db::PathHints,
|
||||||
similar_artists: &[SimilarArtist],
|
similar_artists: &[SimilarArtist],
|
||||||
similar_albums: &[SimilarAlbum],
|
similar_albums: &[SimilarAlbum],
|
||||||
|
folder_ctx: Option<&FolderContext>,
|
||||||
) -> String {
|
) -> String {
|
||||||
let mut msg = String::from("## Raw metadata from file tags\n");
|
let mut msg = String::from("## Raw metadata from file tags\n");
|
||||||
|
|
||||||
@@ -88,6 +97,18 @@ fn build_user_message(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(ctx) = folder_ctx {
|
||||||
|
msg.push_str("\n## Folder context\n");
|
||||||
|
msg.push_str(&format!("Folder path: \"{}\"\n", ctx.folder_path));
|
||||||
|
msg.push_str(&format!("Track count in folder: {}\n", ctx.track_count));
|
||||||
|
if !ctx.folder_files.is_empty() {
|
||||||
|
msg.push_str("Files in folder:\n");
|
||||||
|
for f in &ctx.folder_files {
|
||||||
|
msg.push_str(&format!(" - {}\n", f));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
msg
|
msg
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,6 +222,7 @@ fn parse_response(response: &str) -> anyhow::Result<NormalizedFields> {
|
|||||||
genre: Option<String>,
|
genre: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
featured_artists: Vec<String>,
|
featured_artists: Vec<String>,
|
||||||
|
release_type: Option<String>,
|
||||||
confidence: Option<f64>,
|
confidence: Option<f64>,
|
||||||
notes: Option<String>,
|
notes: Option<String>,
|
||||||
}
|
}
|
||||||
@@ -216,6 +238,7 @@ fn parse_response(response: &str) -> anyhow::Result<NormalizedFields> {
|
|||||||
track_number: parsed.track_number,
|
track_number: parsed.track_number,
|
||||||
genre: parsed.genre,
|
genre: parsed.genre,
|
||||||
featured_artists: parsed.featured_artists,
|
featured_artists: parsed.featured_artists,
|
||||||
|
release_type: parsed.release_type,
|
||||||
confidence: parsed.confidence,
|
confidence: parsed.confidence,
|
||||||
notes: parsed.notes,
|
notes: parsed.notes,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -147,6 +147,27 @@ async fn merge_db(
|
|||||||
proposal: &MergeProposal,
|
proposal: &MergeProposal,
|
||||||
loser_ids: &[i64],
|
loser_ids: &[i64],
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
|
// 0. Validate proposal — ensure winner and all album IDs belong to source artists
|
||||||
|
let source_ids: Vec<i64> = loser_ids.iter().copied()
|
||||||
|
.chain(std::iter::once(proposal.winner_artist_id))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Verify winner_artist_id is one of the source artists
|
||||||
|
if !source_ids.contains(&proposal.winner_artist_id) {
|
||||||
|
anyhow::bail!(
|
||||||
|
"winner_artist_id {} is not among source artists {:?}",
|
||||||
|
proposal.winner_artist_id, source_ids
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build set of valid album IDs (albums that actually belong to source artists)
|
||||||
|
let mut valid_album_ids = std::collections::HashSet::<i64>::new();
|
||||||
|
for &src_id in &source_ids {
|
||||||
|
let rows: Vec<(i64,)> = sqlx::query_as("SELECT id FROM albums WHERE artist_id = $1")
|
||||||
|
.bind(src_id).fetch_all(&mut **tx).await?;
|
||||||
|
for (id,) in rows { valid_album_ids.insert(id); }
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Rename winner artist to canonical name
|
// 1. Rename winner artist to canonical name
|
||||||
sqlx::query("UPDATE artists SET name = $2 WHERE id = $1")
|
sqlx::query("UPDATE artists SET name = $2 WHERE id = $1")
|
||||||
.bind(proposal.winner_artist_id)
|
.bind(proposal.winner_artist_id)
|
||||||
@@ -155,6 +176,15 @@ async fn merge_db(
|
|||||||
|
|
||||||
// 2. Process album mappings from the proposal
|
// 2. Process album mappings from the proposal
|
||||||
for mapping in &proposal.album_mappings {
|
for mapping in &proposal.album_mappings {
|
||||||
|
// Skip albums that don't belong to any source artist (LLM hallucinated IDs)
|
||||||
|
if !valid_album_ids.contains(&mapping.source_album_id) {
|
||||||
|
tracing::warn!(
|
||||||
|
album_id = mapping.source_album_id,
|
||||||
|
"Skipping album mapping: album does not belong to source artists"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// Skip if source was already processed (idempotent retry support)
|
// Skip if source was already processed (idempotent retry support)
|
||||||
let src_exists: (bool,) = sqlx::query_as("SELECT EXISTS(SELECT 1 FROM albums WHERE id = $1)")
|
let src_exists: (bool,) = sqlx::query_as("SELECT EXISTS(SELECT 1 FROM albums WHERE id = $1)")
|
||||||
.bind(mapping.source_album_id)
|
.bind(mapping.source_album_id)
|
||||||
|
|||||||
@@ -162,6 +162,37 @@ details.llm-expand pre { background: var(--bg-card); border: 1px solid var(--bor
|
|||||||
.pagination button:hover:not(:disabled) { border-color: var(--accent); color: var(--text); }
|
.pagination button:hover:not(:disabled) { border-color: var(--accent); color: var(--text); }
|
||||||
.pagination button.active { background: var(--accent); border-color: var(--accent); color: #fff; }
|
.pagination button.active { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||||
.pagination button:disabled { opacity: 0.3; cursor: default; }
|
.pagination button:disabled { opacity: 0.3; cursor: default; }
|
||||||
|
|
||||||
|
/* Release type badges */
|
||||||
|
.release-badge { font-size: 9px; font-weight: 700; text-transform: uppercase; padding: 1px 5px; border-radius: 3px; letter-spacing: 0.04em; }
|
||||||
|
.rb-album { background: #1e2740; color: var(--text-dim); }
|
||||||
|
.rb-single { background: #1e3a2e; color: #6ee7b7; }
|
||||||
|
.rb-ep { background: #2e1e3a; color: #c4b5fd; }
|
||||||
|
.rb-compilation{ background: #3a2e1e; color: #fcd34d; }
|
||||||
|
.rb-live { background: #3a1e1e; color: #fca5a5; }
|
||||||
|
.hidden-badge { font-size: 9px; font-weight: 700; text-transform: uppercase; padding: 1px 5px; border-radius: 3px; background: #1a1a1a; color: #555; letter-spacing: 0.04em; }
|
||||||
|
|
||||||
|
/* Artist admin form */
|
||||||
|
.artist-section { margin-top: 14px; }
|
||||||
|
.artist-section-title { font-size: 11px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 6px; display: flex; align-items: center; gap: 8px; }
|
||||||
|
.album-block { border: 1px solid var(--border); border-radius: 6px; margin-bottom: 8px; overflow: hidden; }
|
||||||
|
.album-block-header { display: flex; align-items: center; gap: 8px; padding: 7px 10px; background: var(--bg-card); cursor: pointer; }
|
||||||
|
.album-block-header img { width: 36px; height: 36px; border-radius: 3px; object-fit: cover; flex-shrink: 0; }
|
||||||
|
.album-block-header .ab-name { flex: 1; font-size: 12px; font-weight: 500; }
|
||||||
|
.album-block-header .ab-year { font-size: 10px; color: var(--text-muted); flex-shrink: 0; }
|
||||||
|
.album-block-body { display: none; padding: 0; }
|
||||||
|
.album-block-body.open { display: block; }
|
||||||
|
.album-track-row { display: flex; align-items: center; gap: 8px; padding: 4px 10px; border-top: 1px solid var(--border); font-size: 11px; background: var(--bg-base); }
|
||||||
|
.album-track-row.hidden-track { opacity: 0.45; }
|
||||||
|
.album-track-row .atr-num { color: var(--text-muted); width: 22px; text-align: right; flex-shrink: 0; }
|
||||||
|
.album-track-row .atr-title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.album-track-row .atr-dur { color: var(--text-muted); font-size: 10px; flex-shrink: 0; }
|
||||||
|
.appearance-row { display: flex; align-items: center; gap: 8px; padding: 4px 0; font-size: 12px; border-bottom: 1px solid var(--border); }
|
||||||
|
.appearance-row:last-child { border-bottom: none; }
|
||||||
|
.btn-hide { background: #1e293b; color: var(--text-muted); border: none; padding: 2px 7px; border-radius: 3px; cursor: pointer; font-size: 10px; font-family: inherit; }
|
||||||
|
.btn-hide:hover { background: #334155; }
|
||||||
|
.btn-show { background: #052e16; color: var(--success); border: none; padding: 2px 7px; border-radius: 3px; cursor: pointer; font-size: 10px; font-family: inherit; }
|
||||||
|
.btn-show:hover { background: #065f46; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -264,13 +295,14 @@ function renderFilterBar(s) {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function showTab(tab, btn) {
|
function showTab(tab, btn, noHash) {
|
||||||
currentTab = tab;
|
currentTab = tab;
|
||||||
document.querySelectorAll('nav button').forEach(b => b.classList.remove('active'));
|
document.querySelectorAll('nav button').forEach(b => b.classList.remove('active'));
|
||||||
btn.classList.add('active');
|
btn.classList.add('active');
|
||||||
clearSelection();
|
clearSelection();
|
||||||
const pag = document.getElementById('lib-pagination');
|
const pag = document.getElementById('lib-pagination');
|
||||||
if (pag) pag.style.display = 'none';
|
if (pag) pag.style.display = 'none';
|
||||||
|
if (!noHash) location.hash = tab;
|
||||||
if (tab === 'queue') { loadQueue(); loadStats(); }
|
if (tab === 'queue') { loadQueue(); loadStats(); }
|
||||||
else if (tab === 'artists') { libPage.artists = 0; renderLibSearchBar([{ label: 'Artist', key: 'q', tab: 'artists', value: libSearch.artists.q, placeholder: 'search artist…' }], ''); loadLibArtists(); }
|
else if (tab === 'artists') { libPage.artists = 0; renderLibSearchBar([{ label: 'Artist', key: 'q', tab: 'artists', value: libSearch.artists.q, placeholder: 'search artist…' }], ''); loadLibArtists(); }
|
||||||
else if (tab === 'tracks') { libPage.tracks = 0; renderLibSearchBar([{ label: 'Title', key: 'q', tab: 'tracks', value: libSearch.tracks.q, placeholder: 'search title…' }, { label: 'Artist', key: 'artist', tab: 'tracks', value: libSearch.tracks.artist, placeholder: 'search artist…' }, { label: 'Album', key: 'album', tab: 'tracks', value: libSearch.tracks.album, placeholder: 'search album…' }], ''); loadLibTracks(); }
|
else if (tab === 'tracks') { libPage.tracks = 0; renderLibSearchBar([{ label: 'Title', key: 'q', tab: 'tracks', value: libSearch.tracks.q, placeholder: 'search title…' }, { label: 'Artist', key: 'artist', tab: 'tracks', value: libSearch.tracks.artist, placeholder: 'search artist…' }, { label: 'Album', key: 'album', tab: 'tracks', value: libSearch.tracks.album, placeholder: 'search album…' }], ''); loadLibTracks(); }
|
||||||
@@ -281,7 +313,11 @@ function showTab(tab, btn) {
|
|||||||
// --- Queue ---
|
// --- Queue ---
|
||||||
async function loadQueue(status, keepSelection) {
|
async function loadQueue(status, keepSelection) {
|
||||||
currentFilter = status;
|
currentFilter = status;
|
||||||
if (!keepSelection) { clearSelection(); queueOffset = 0; }
|
if (!keepSelection) {
|
||||||
|
clearSelection();
|
||||||
|
queueOffset = 0;
|
||||||
|
location.hash = status ? 'queue/' + status : 'queue';
|
||||||
|
}
|
||||||
const qs = `?${status ? 'status='+status+'&' : ''}limit=${queuePageSize+1}&offset=${queueOffset}`;
|
const qs = `?${status ? 'status='+status+'&' : ''}limit=${queuePageSize+1}&offset=${queueOffset}`;
|
||||||
const raw = await api(`/queue${qs}`) || [];
|
const raw = await api(`/queue${qs}`) || [];
|
||||||
const hasMore = raw.length > queuePageSize;
|
const hasMore = raw.length > queuePageSize;
|
||||||
@@ -328,6 +364,9 @@ function renderQueue(hasMore) {
|
|||||||
const artist = it.norm_artist || it.raw_artist || '-';
|
const artist = it.norm_artist || it.raw_artist || '-';
|
||||||
const title = it.norm_title || it.raw_title || '-';
|
const title = it.norm_title || it.raw_title || '-';
|
||||||
const album = it.norm_album || it.raw_album || '-';
|
const album = it.norm_album || it.raw_album || '-';
|
||||||
|
const albumCoverUrl = (it.norm_album || it.raw_album) && (it.norm_artist || it.raw_artist)
|
||||||
|
? `${API}/albums/cover-by-name?artist=${encodeURIComponent(it.norm_artist||it.raw_artist||'')}&name=${encodeURIComponent(it.norm_album||it.raw_album||'')}`
|
||||||
|
: null;
|
||||||
const year = it.norm_year || it.raw_year || '';
|
const year = it.norm_year || it.raw_year || '';
|
||||||
const tnum = it.norm_track_number || it.raw_track_number || '';
|
const tnum = it.norm_track_number || it.raw_track_number || '';
|
||||||
const canApprove = it.status === 'review';
|
const canApprove = it.status === 'review';
|
||||||
@@ -337,7 +376,7 @@ function renderQueue(hasMore) {
|
|||||||
<td><span class="status status-${it.status}">${it.status}</span></td>
|
<td><span class="status status-${it.status}">${it.status}</span></td>
|
||||||
<td class="editable" ondblclick="inlineEdit(this,'${it.id}','norm_artist')" title="${esc(it.raw_artist||'')}">${esc(artist)}</td>
|
<td class="editable" ondblclick="inlineEdit(this,'${it.id}','norm_artist')" title="${esc(it.raw_artist||'')}">${esc(artist)}</td>
|
||||||
<td class="editable" ondblclick="inlineEdit(this,'${it.id}','norm_title')" title="${esc(it.raw_title||'')}">${esc(title)}</td>
|
<td class="editable" ondblclick="inlineEdit(this,'${it.id}','norm_title')" title="${esc(it.raw_title||'')}">${esc(title)}</td>
|
||||||
<td class="editable" ondblclick="inlineEdit(this,'${it.id}','norm_album')" title="${esc(it.raw_album||'')}">${esc(album)}</td>
|
<td class="editable" ondblclick="inlineEdit(this,'${it.id}','norm_album')" title="${esc(it.raw_album||'')}"><span style="display:inline-flex;align-items:center;gap:4px">${albumCoverUrl?`<img src="${albumCoverUrl}" style="width:20px;height:20px;border-radius:2px;object-fit:cover;flex-shrink:0" onerror="this.style.display='none'">`:''}${esc(album)}</span></td>
|
||||||
<td>${year}</td>
|
<td>${year}</td>
|
||||||
<td>${tnum}</td>
|
<td>${tnum}</td>
|
||||||
<td>${conf}</td>
|
<td>${conf}</td>
|
||||||
@@ -740,7 +779,7 @@ async function loadLibAlbums() {
|
|||||||
let html = `<table><tr><th>Album</th><th>Artist</th><th style="width:50px">Year</th><th style="width:60px">Tracks</th><th style="width:50px">Edit</th></tr>`;
|
let html = `<table><tr><th>Album</th><th>Artist</th><th style="width:50px">Year</th><th style="width:60px">Tracks</th><th style="width:50px">Edit</th></tr>`;
|
||||||
for (const a of data.items) {
|
for (const a of data.items) {
|
||||||
html += `<tr>
|
html += `<tr>
|
||||||
<td><span style="display:inline-flex;align-items:center;gap:5px"><img src="${API}/albums/${a.id}/cover" style="width:20px;height:20px;border-radius:2px;object-fit:cover;flex-shrink:0" onerror="this.style.display='none'"><span>${esc(a.name)}</span></span></td>
|
<td><span style="display:inline-flex;align-items:center;gap:5px"><img src="${API}/albums/${a.id}/cover" style="width:20px;height:20px;border-radius:2px;object-fit:cover;flex-shrink:0" onerror="this.style.display='none'"><span>${esc(a.name)}</span></span> ${releaseBadge(a.release_type)} ${a.hidden?'<span class="hidden-badge">Hidden</span>':''}</td>
|
||||||
<td>${esc(a.artist_name)}</td>
|
<td>${esc(a.artist_name)}</td>
|
||||||
<td>${a.year ?? ''}</td>
|
<td>${a.year ?? ''}</td>
|
||||||
<td style="color:var(--text-muted)">${a.track_count}</td>
|
<td style="color:var(--text-muted)">${a.track_count}</td>
|
||||||
@@ -768,18 +807,30 @@ async function loadLibArtists() {
|
|||||||
el.innerHTML = '<div class="empty">No artists found</div>';
|
el.innerHTML = '<div class="empty">No artists found</div>';
|
||||||
} else {
|
} else {
|
||||||
let html = `<table><tr>
|
let html = `<table><tr>
|
||||||
<th style="width:30px"></th><th style="width:50px">ID</th><th>Name</th>
|
<th style="width:30px"></th><th style="width:40px">ID</th><th>Name</th>
|
||||||
<th style="width:60px">Albums</th><th style="width:60px">Tracks</th>
|
<th style="width:46px" title="Albums">LP</th>
|
||||||
<th style="width:80px">Actions</th>
|
<th style="width:42px" title="Singles">Sng</th>
|
||||||
|
<th style="width:34px" title="EPs">EP</th>
|
||||||
|
<th style="width:40px" title="Compilations">Cmp</th>
|
||||||
|
<th style="width:36px" title="Live">Live</th>
|
||||||
|
<th style="width:46px" title="Tracks">Trk</th>
|
||||||
|
<th style="width:60px">Actions</th>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
for (const a of data.items) {
|
for (const a of data.items) {
|
||||||
html += `<tr>
|
const dim = 'style="color:var(--text-muted);text-align:center"';
|
||||||
|
html += `<tr ${a.hidden?'style="opacity:0.5"':''}>
|
||||||
<td><input type="checkbox" class="cb" ${selectedArtists.has(a.id)?'checked':''} onchange="toggleSelectArtist(${a.id},this.checked)"></td>
|
<td><input type="checkbox" class="cb" ${selectedArtists.has(a.id)?'checked':''} onchange="toggleSelectArtist(${a.id},this.checked)"></td>
|
||||||
<td style="color:var(--text-muted)">${a.id}</td>
|
<td style="color:var(--text-muted)">${a.id}</td>
|
||||||
<td class="editable" ondblclick="inlineEditArtist(this,${a.id})">${esc(a.name)}</td>
|
<td>${a.hidden?'<span class="hidden-badge" style="margin-right:4px">H</span>':''}${esc(a.name)}</td>
|
||||||
<td style="color:var(--text-muted)">${a.album_count}</td>
|
<td ${dim}>${a.album_count||''}</td>
|
||||||
<td style="color:var(--text-muted)">${a.track_count}</td>
|
<td ${dim}>${a.single_count||''}</td>
|
||||||
<td class="actions"><button class="btn btn-edit" onclick="editArtist(${a.id},'${esc(a.name)}')">Rename</button></td>
|
<td ${dim}>${a.ep_count||''}</td>
|
||||||
|
<td ${dim}>${a.compilation_count||''}</td>
|
||||||
|
<td ${dim}>${a.live_count||''}</td>
|
||||||
|
<td ${dim}>${a.track_count||''}</td>
|
||||||
|
<td class="actions">
|
||||||
|
<button class="btn btn-edit" onclick="openArtistForm(${a.id})">Edit</button>
|
||||||
|
</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
}
|
}
|
||||||
el.innerHTML = html + '</table>';
|
el.innerHTML = html + '</table>';
|
||||||
@@ -844,10 +895,11 @@ async function editArtist(id, currentName) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Track edit modal ---
|
// --- Track edit modal ---
|
||||||
async function openTrackEdit(id) {
|
async function openTrackEdit(id, returnCb) {
|
||||||
const t = await api(`/tracks/${id}`);
|
const t = await api(`/tracks/${id}`);
|
||||||
if (!t) return;
|
if (!t) return;
|
||||||
|
|
||||||
|
editReturnCallback = returnCb || null;
|
||||||
editFeatured = [...(t.featured_artists || [])];
|
editFeatured = [...(t.featured_artists || [])];
|
||||||
|
|
||||||
document.getElementById('modal').className = 'modal';
|
document.getElementById('modal').className = 'modal';
|
||||||
@@ -899,7 +951,7 @@ async function openTrackEdit(id) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button class="btn btn-cancel" onclick="closeModal()">Cancel</button>
|
<button class="btn btn-cancel" onclick="cancelTrackEdit()">Cancel</button>
|
||||||
<button class="btn btn-primary" onclick="saveTrackEdit(${t.id})">Save</button>
|
<button class="btn btn-primary" onclick="saveTrackEdit(${t.id})">Save</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -908,6 +960,7 @@ async function openTrackEdit(id) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let teArtistTimer = null, teAlbumTimer = null;
|
let teArtistTimer = null, teAlbumTimer = null;
|
||||||
|
let editReturnCallback = null;
|
||||||
|
|
||||||
function onTeArtistSearch(q) {
|
function onTeArtistSearch(q) {
|
||||||
clearTimeout(teArtistTimer);
|
clearTimeout(teArtistTimer);
|
||||||
@@ -973,12 +1026,21 @@ async function saveTrackEdit(id) {
|
|||||||
featured_artists: editFeatured,
|
featured_artists: editFeatured,
|
||||||
};
|
};
|
||||||
await api(`/tracks/${id}`, { method: 'PUT', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) });
|
await api(`/tracks/${id}`, { method: 'PUT', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) });
|
||||||
closeModal();
|
const cb = editReturnCallback;
|
||||||
if (currentTab === 'tracks') loadLibTracks();
|
editReturnCallback = null;
|
||||||
|
if (cb) { cb(); } else { closeModal(); if (currentTab === 'tracks') loadLibTracks(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelTrackEdit() {
|
||||||
|
const cb = editReturnCallback;
|
||||||
|
editReturnCallback = null;
|
||||||
|
if (cb) { cb(); } else { closeModal(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Album edit modal ---
|
// --- Album edit modal ---
|
||||||
async function openAlbumEdit(id) {
|
let albumEditReturnCallback = null;
|
||||||
|
async function openAlbumEdit(id, returnCb) {
|
||||||
|
albumEditReturnCallback = returnCb || null;
|
||||||
const d = await api(`/albums/${id}/full`);
|
const d = await api(`/albums/${id}/full`);
|
||||||
if (!d) return;
|
if (!d) return;
|
||||||
|
|
||||||
@@ -1023,7 +1085,7 @@ async function openAlbumEdit(id) {
|
|||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button class="btn btn-cancel" onclick="closeModal()">Cancel</button>
|
<button class="btn btn-cancel" onclick="cancelAlbumEdit()">Cancel</button>
|
||||||
<button class="btn btn-edit" onclick="saveAlbumReorder(${id})">Save Order</button>
|
<button class="btn btn-edit" onclick="saveAlbumReorder(${id})">Save Order</button>
|
||||||
<button class="btn btn-primary" onclick="saveAlbumEdit(${id})">Save Album</button>
|
<button class="btn btn-primary" onclick="saveAlbumEdit(${id})">Save Album</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1073,6 +1135,12 @@ function selectAeArtist(id, name) {
|
|||||||
document.getElementById('ae-artist-dropdown').classList.remove('open');
|
document.getElementById('ae-artist-dropdown').classList.remove('open');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cancelAlbumEdit() {
|
||||||
|
const cb = albumEditReturnCallback;
|
||||||
|
albumEditReturnCallback = null;
|
||||||
|
if (cb) { cb(); } else { closeModal(); }
|
||||||
|
}
|
||||||
|
|
||||||
async function saveAlbumEdit(id) {
|
async function saveAlbumEdit(id) {
|
||||||
const artistId = parseInt(document.getElementById('ae-artist-id').value);
|
const artistId = parseInt(document.getElementById('ae-artist-id').value);
|
||||||
if (!artistId) { alert('Please select an artist from the dropdown'); return; }
|
if (!artistId) { alert('Please select an artist from the dropdown'); return; }
|
||||||
@@ -1082,7 +1150,9 @@ async function saveAlbumEdit(id) {
|
|||||||
artist_id: artistId,
|
artist_id: artistId,
|
||||||
};
|
};
|
||||||
await api(`/albums/${id}/edit`, { method: 'PUT', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) });
|
await api(`/albums/${id}/edit`, { method: 'PUT', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) });
|
||||||
if (currentTab === 'albums') loadLibAlbums();
|
const cb = albumEditReturnCallback;
|
||||||
|
albumEditReturnCallback = null;
|
||||||
|
if (cb) { cb(); } else { closeModal(); if (currentTab === 'albums') loadLibAlbums(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveAlbumReorder(id) {
|
async function saveAlbumReorder(id) {
|
||||||
@@ -1094,8 +1164,42 @@ async function saveAlbumReorder(id) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function openTrackEditFromAlbum(trackId, albumId) {
|
async function openTrackEditFromAlbum(trackId, albumId) {
|
||||||
closeModal();
|
const parentCb = albumEditReturnCallback;
|
||||||
await openTrackEdit(trackId);
|
await openTrackEdit(trackId, () => openAlbumEdit(albumId, parentCb));
|
||||||
|
}
|
||||||
|
function openTrackEditForArtist(trackId, artistId) {
|
||||||
|
openTrackEdit(trackId, () => openArtistForm(artistId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Album inline meta edit (from artist form) ---
|
||||||
|
async function saveAlbumMeta(albumId, artistId) {
|
||||||
|
const name = document.getElementById(`alb-name-${albumId}`)?.value?.trim();
|
||||||
|
const yearRaw = document.getElementById(`alb-year-${albumId}`)?.value;
|
||||||
|
if (!name) return;
|
||||||
|
await api(`/albums/${albumId}/edit`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({ name, year: parseInt(yearRaw) || null, artist_id: artistId }),
|
||||||
|
});
|
||||||
|
// Update header display in place
|
||||||
|
const block = document.getElementById(`album-block-${albumId}`);
|
||||||
|
if (block) {
|
||||||
|
const nameSpan = block.querySelector('.ab-name');
|
||||||
|
if (nameSpan) nameSpan.textContent = name;
|
||||||
|
const yearSpan = block.querySelector('.ab-year');
|
||||||
|
if (yearSpan) yearSpan.textContent = yearRaw || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyAlbumGenre(albumId) {
|
||||||
|
const genre = document.getElementById(`alb-genre-${albumId}`)?.value?.trim();
|
||||||
|
if (!genre) return;
|
||||||
|
await api(`/albums/${albumId}/genre`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({ genre }),
|
||||||
|
});
|
||||||
|
document.getElementById(`alb-genre-${albumId}`).value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Helpers ---
|
// --- Helpers ---
|
||||||
@@ -1329,9 +1433,287 @@ async function retryMerge(id) {
|
|||||||
loadMerges();
|
loadMerges();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Release badge helper ---
|
||||||
|
function releaseBadge(t) {
|
||||||
|
const labels = {album:'Album',single:'Single',ep:'EP',compilation:'Comp',live:'Live'};
|
||||||
|
return `<span class="release-badge rb-${t||'album'}">${labels[t]||t||'Album'}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Artist full admin form ---
|
||||||
|
async function openArtistForm(id) {
|
||||||
|
const d = await api(`/artists/${id}/full`);
|
||||||
|
if (!d) return;
|
||||||
|
const { artist, albums, appearances } = d;
|
||||||
|
|
||||||
|
// Separate albums by type
|
||||||
|
const mainAlbums = albums.filter(a => a.release_type === 'album' || a.release_type === 'compilation' || a.release_type === 'live');
|
||||||
|
const singles = albums.filter(a => a.release_type === 'single' || a.release_type === 'ep');
|
||||||
|
|
||||||
|
function renderAlbumBlock(alb) {
|
||||||
|
const hiddenCls = alb.hidden ? ' style="opacity:0.55"' : '';
|
||||||
|
const tracks = (alb.tracks || []).map(t => `
|
||||||
|
<div class="album-track-row ${t.hidden?'hidden-track':''}" data-tid="${t.id}">
|
||||||
|
<span class="atr-num">${t.track_number??''}</span>
|
||||||
|
<span class="atr-title">${esc(t.title)}</span>
|
||||||
|
<span class="atr-dur">${fmtDuration(t.duration_secs)}</span>
|
||||||
|
<button class="btn btn-edit" style="font-size:10px;padding:2px 6px" onclick="openTrackEditForArtist(${t.id},${id})">Edit</button>
|
||||||
|
<button data-hidden="${t.hidden}" onclick="toggleTrackHidden(${t.id},this)">${t.hidden?'Show':'Hide'}</button>
|
||||||
|
</div>`).join('');
|
||||||
|
|
||||||
|
const releaseTypes = ['album','single','ep','compilation','live'];
|
||||||
|
const typeOpts = releaseTypes.map(rt => `<option value="${rt}" ${alb.release_type===rt?'selected':''}>${rt.charAt(0).toUpperCase()+rt.slice(1)}</option>`).join('');
|
||||||
|
|
||||||
|
return `<div class="album-block" id="album-block-${alb.id}"${hiddenCls}>
|
||||||
|
<div class="album-block-header" onclick="toggleAlbumBlock(${alb.id})">
|
||||||
|
<img src="${API}/albums/${alb.id}/cover" onerror="this.style.display='none'">
|
||||||
|
<span class="ab-name">${esc(alb.name)}</span>
|
||||||
|
${alb.year ? `<span class="ab-year">${alb.year}</span>` : ''}
|
||||||
|
${releaseBadge(alb.release_type)}
|
||||||
|
${alb.hidden?'<span class="hidden-badge">Hidden</span>':''}
|
||||||
|
<select onclick="event.stopPropagation()" onchange="changeReleaseType(${alb.id},this.value)" style="background:var(--bg-base);border:1px solid var(--border);border-radius:3px;color:var(--text);font-size:10px;padding:2px 4px;font-family:inherit">${typeOpts}</select>
|
||||||
|
<button data-hidden="${alb.hidden}" onclick="event.stopPropagation();toggleAlbumHidden(${alb.id},this)">${alb.hidden?'Show':'Hide'}</button>
|
||||||
|
</div>
|
||||||
|
<div class="album-block-body" id="album-body-${alb.id}"></div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const albumsHtml = mainAlbums.map(renderAlbumBlock).join('') || '<div style="color:var(--text-muted);font-size:12px;padding:4px 0">No albums</div>';
|
||||||
|
const singlesHtml = singles.map(renderAlbumBlock).join('') || '<div style="color:var(--text-muted);font-size:12px;padding:4px 0">No singles/EPs</div>';
|
||||||
|
|
||||||
|
const appHtml = appearances.map(ap => `
|
||||||
|
<div class="appearance-row">
|
||||||
|
<span style="flex:1">${esc(ap.primary_artist_name)} — <strong>${esc(ap.track_title)}</strong>${ap.album_name?` <span style="color:var(--text-muted)">(${esc(ap.album_name)})</span>`:''}</span>
|
||||||
|
<button class="btn btn-reject" style="font-size:10px;padding:2px 6px" onclick="removeAppearance(${id},${ap.track_id},this)">Remove</button>
|
||||||
|
</div>`).join('') || '<div style="color:var(--text-muted);font-size:12px;padding:4px 0">No appearances</div>';
|
||||||
|
|
||||||
|
document.getElementById('modal').className = 'modal modal-wide';
|
||||||
|
document.getElementById('modal').innerHTML = `
|
||||||
|
<div style="display:flex;align-items:center;gap:10px;margin-bottom:12px">
|
||||||
|
<h2 style="flex:1;margin:0">${esc(artist.name)}</h2>
|
||||||
|
${artist.hidden?'<span class="hidden-badge" style="font-size:11px">Hidden</span>':''}
|
||||||
|
<button data-hidden="${artist.hidden}" onclick="toggleArtistHidden(${id},this)">${artist.hidden?'Unhide Artist':'Hide Artist'}</button>
|
||||||
|
<button class="btn btn-edit" onclick="promptRenameArtist(${id})">Rename</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="artist-section">
|
||||||
|
<div class="artist-section-title">Albums & Compilations <span style="color:var(--text-dim);font-weight:400">(${mainAlbums.length})</span></div>
|
||||||
|
${albumsHtml}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="artist-section">
|
||||||
|
<div class="artist-section-title">Singles & EPs <span style="color:var(--text-dim);font-weight:400">(${singles.length})</span></div>
|
||||||
|
${singlesHtml}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="artist-section">
|
||||||
|
<div class="artist-section-title">Appearances (feat.) <span style="color:var(--text-dim);font-weight:400">(${appearances.length})</span></div>
|
||||||
|
<div id="appearances-list">${appHtml}</div>
|
||||||
|
<div style="margin-top:8px;display:flex;gap:6px;align-items:center">
|
||||||
|
<div style="position:relative;flex:1">
|
||||||
|
<input id="feat-track-search" placeholder="Search track to add appearance…" autocomplete="off"
|
||||||
|
oninput="onFeatTrackSearch(${id},this.value)" style="width:100%;background:var(--bg-card);border:1px solid var(--border);border-radius:5px;padding:6px 9px;color:var(--text);font-family:inherit;font-size:12px">
|
||||||
|
<div class="artist-dropdown" id="feat-track-dropdown"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn btn-cancel" onclick="closeModal()">Close</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Fill album body content and restore open state
|
||||||
|
for (const alb of albums) {
|
||||||
|
const body = document.getElementById(`album-body-${alb.id}`);
|
||||||
|
if (!body) continue;
|
||||||
|
const tracks = (alb.tracks || []).map(t => `
|
||||||
|
<div class="album-track-row ${t.hidden?'hidden-track':''}" data-tid="${t.id}">
|
||||||
|
<span class="atr-num">${t.track_number??''}</span>
|
||||||
|
<span class="atr-title">${esc(t.title)}</span>
|
||||||
|
<span class="atr-dur">${fmtDuration(t.duration_secs)}</span>
|
||||||
|
<button class="btn btn-edit" style="font-size:10px;padding:2px 6px" onclick="openTrackEditForArtist(${t.id},${id})">Edit</button>
|
||||||
|
<button data-hidden="${t.hidden}" onclick="toggleTrackHidden(${t.id},this)">${t.hidden?'Show':'Hide'}</button>
|
||||||
|
</div>`).join('');
|
||||||
|
const albumMeta = `<div style="padding:8px 10px;border-bottom:1px solid var(--border);background:var(--bg-panel)">
|
||||||
|
<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap">
|
||||||
|
<input id="alb-name-${alb.id}" value="${esc(alb.name)}" placeholder="Album name"
|
||||||
|
style="flex:2;min-width:140px;background:var(--bg-card);border:1px solid var(--border);border-radius:4px;padding:4px 8px;color:var(--text);font-size:11px;font-family:inherit">
|
||||||
|
<input id="alb-year-${alb.id}" type="number" value="${alb.year??''}" placeholder="Year"
|
||||||
|
style="width:70px;background:var(--bg-card);border:1px solid var(--border);border-radius:4px;padding:4px 8px;color:var(--text);font-size:11px;font-family:inherit">
|
||||||
|
<button class="btn btn-primary" style="font-size:10px;padding:3px 10px" onclick="saveAlbumMeta(${alb.id},${id})">Save</button>
|
||||||
|
<span style="color:var(--border);user-select:none">|</span>
|
||||||
|
<input id="alb-genre-${alb.id}" placeholder="Apply genre to all tracks…"
|
||||||
|
style="flex:1;min-width:130px;background:var(--bg-card);border:1px solid var(--border);border-radius:4px;padding:4px 8px;color:var(--text);font-size:11px;font-family:inherit">
|
||||||
|
<button class="btn btn-edit" style="font-size:10px;padding:3px 10px" onclick="applyAlbumGenre(${alb.id})">Apply</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
body.innerHTML = albumMeta + tracks;
|
||||||
|
if (openAlbumBlocks.has(alb.id)) body.classList.add('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
openModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
const openAlbumBlocks = new Set();
|
||||||
|
|
||||||
|
function toggleAlbumBlock(id) {
|
||||||
|
const body = document.getElementById(`album-body-${id}`);
|
||||||
|
if (!body) return;
|
||||||
|
body.classList.toggle('open');
|
||||||
|
if (body.classList.contains('open')) openAlbumBlocks.add(id);
|
||||||
|
else openAlbumBlocks.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleTrackHidden(id, btn) {
|
||||||
|
const hidden = btn.dataset.hidden !== 'true'; // toggle
|
||||||
|
await api(`/tracks/${id}/hidden`, { method:'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify({hidden}) });
|
||||||
|
btn.dataset.hidden = String(hidden);
|
||||||
|
btn.textContent = hidden ? 'Show' : 'Hide';
|
||||||
|
const row = btn.closest('.album-track-row');
|
||||||
|
if (row) row.classList.toggle('hidden-track', hidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleAlbumHidden(id, btn) {
|
||||||
|
const hidden = btn.dataset.hidden !== 'true'; // toggle
|
||||||
|
await api(`/albums/${id}/hidden`, { method:'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify({hidden}) });
|
||||||
|
btn.dataset.hidden = String(hidden);
|
||||||
|
btn.textContent = hidden ? 'Show' : 'Hide';
|
||||||
|
const block = btn.closest('.album-block');
|
||||||
|
if (block) block.style.opacity = hidden ? '0.55' : '';
|
||||||
|
const header = block?.querySelector('.album-block-header');
|
||||||
|
let badge = header?.querySelector('.hidden-badge');
|
||||||
|
if (hidden && !badge) { badge = document.createElement('span'); badge.className='hidden-badge'; badge.textContent='Hidden'; header.insertBefore(badge, btn); }
|
||||||
|
else if (!hidden && badge) badge.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleArtistHidden(id, btn) {
|
||||||
|
const hidden = btn.dataset.hidden !== 'true'; // toggle
|
||||||
|
await api(`/artists/${id}/hidden`, { method:'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify({hidden}) });
|
||||||
|
btn.dataset.hidden = String(hidden);
|
||||||
|
btn.textContent = hidden ? 'Unhide Artist' : 'Hide Artist';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changeReleaseType(id, type) {
|
||||||
|
await api(`/albums/${id}/release_type`, { method:'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify({release_type: type}) });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function promptRenameArtist(id) {
|
||||||
|
const name = prompt('New artist name:');
|
||||||
|
if (!name) return;
|
||||||
|
await api(`/artists/${id}/rename`, { method:'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify({name}) });
|
||||||
|
// refresh
|
||||||
|
closeModal();
|
||||||
|
openArtistForm(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
let featTrackTimer = null;
|
||||||
|
function onFeatTrackSearch(artistId, q) {
|
||||||
|
clearTimeout(featTrackTimer);
|
||||||
|
const dd = document.getElementById('feat-track-dropdown');
|
||||||
|
if (q.length < 2) { dd.classList.remove('open'); return; }
|
||||||
|
featTrackTimer = setTimeout(async () => {
|
||||||
|
const results = await api(`/tracks/search?q=${encodeURIComponent(q)}`);
|
||||||
|
if (!results || !results.length) { dd.classList.remove('open'); return; }
|
||||||
|
dd.innerHTML = results.map(t =>
|
||||||
|
`<div class="artist-option" onclick="addAppearance(${artistId},${t.id},'${esc(t.artist_name+' — '+t.title)}')">${esc(t.artist_name)} — ${esc(t.title)}</div>`
|
||||||
|
).join('');
|
||||||
|
dd.classList.add('open');
|
||||||
|
}, 250);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addAppearance(artistId, trackId, label) {
|
||||||
|
await api(`/artists/${artistId}/appearances`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({track_id: trackId}) });
|
||||||
|
document.getElementById('feat-track-search').value = '';
|
||||||
|
document.getElementById('feat-track-dropdown').classList.remove('open');
|
||||||
|
// Add row to appearances list
|
||||||
|
const list = document.getElementById('appearances-list');
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'appearance-row';
|
||||||
|
row.innerHTML = `<span style="flex:1">${esc(label)}</span><button class="btn btn-reject" style="font-size:10px;padding:2px 6px" onclick="removeAppearance(${artistId},${trackId},this)">Remove</button>`;
|
||||||
|
list.appendChild(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeAppearance(artistId, trackId, btn) {
|
||||||
|
await api(`/artists/${artistId}/appearances/${trackId}`, { method:'DELETE' });
|
||||||
|
btn.closest('.appearance-row').remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Cover preview ---
|
||||||
|
(function() {
|
||||||
|
const box = document.createElement('div');
|
||||||
|
box.id = 'cover-preview';
|
||||||
|
box.style.cssText = [
|
||||||
|
'display:none', 'position:fixed', 'z-index:9999', 'pointer-events:none',
|
||||||
|
'border-radius:10px', 'overflow:hidden',
|
||||||
|
'box-shadow:0 12px 40px rgba(0,0,0,0.85)',
|
||||||
|
'border:1px solid rgba(255,255,255,0.08)',
|
||||||
|
'background:#0a0c12', 'transition:opacity 0.1s',
|
||||||
|
].join(';');
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.style.cssText = 'display:block;width:280px;height:280px;object-fit:contain';
|
||||||
|
box.appendChild(img);
|
||||||
|
document.body.appendChild(box);
|
||||||
|
|
||||||
|
let showTimer = null;
|
||||||
|
|
||||||
|
function isCoverImg(el) {
|
||||||
|
return el && el.tagName === 'IMG' && el.src && el.src.includes('/cover');
|
||||||
|
}
|
||||||
|
|
||||||
|
function place(e) {
|
||||||
|
const margin = 16, pw = 280, ph = 280;
|
||||||
|
const vw = window.innerWidth, vh = window.innerHeight;
|
||||||
|
let x = e.clientX + margin, y = e.clientY + margin;
|
||||||
|
if (x + pw > vw - 8) x = e.clientX - pw - margin;
|
||||||
|
if (y + ph > vh - 8) y = e.clientY - ph - margin;
|
||||||
|
box.style.left = x + 'px';
|
||||||
|
box.style.top = y + 'px';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mouseover', e => {
|
||||||
|
if (!isCoverImg(e.target)) return;
|
||||||
|
clearTimeout(showTimer);
|
||||||
|
showTimer = setTimeout(() => {
|
||||||
|
img.src = e.target.src;
|
||||||
|
box.style.display = 'block';
|
||||||
|
place(e);
|
||||||
|
}, 120);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', e => {
|
||||||
|
if (box.style.display === 'none') return;
|
||||||
|
if (!isCoverImg(e.target)) { clearTimeout(showTimer); box.style.display = 'none'; return; }
|
||||||
|
place(e);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('mouseout', e => {
|
||||||
|
if (!isCoverImg(e.target)) return;
|
||||||
|
clearTimeout(showTimer);
|
||||||
|
box.style.display = 'none';
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
// --- Init ---
|
// --- Init ---
|
||||||
|
(function restoreFromHash() {
|
||||||
|
const hash = location.hash.slice(1); // strip #
|
||||||
|
if (!hash) return;
|
||||||
|
const [tab, filter] = hash.split('/');
|
||||||
|
const validTabs = ['queue','tracks','albums','artists','merges'];
|
||||||
|
if (!validTabs.includes(tab)) return;
|
||||||
|
const btn = Array.from(document.querySelectorAll('nav button'))
|
||||||
|
.find(b => (b.getAttribute('onclick') || '').includes(`'${tab}'`));
|
||||||
|
if (!btn) return;
|
||||||
|
// Switch tab without overwriting the hash
|
||||||
|
showTab(tab, btn, true);
|
||||||
|
// For queue, also restore the filter
|
||||||
|
if (tab === 'queue' && filter) {
|
||||||
|
currentFilter = filter;
|
||||||
|
loadQueue(filter);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
loadStats();
|
loadStats();
|
||||||
loadQueue();
|
if (currentTab === 'queue' && !location.hash.slice(1)) loadQueue();
|
||||||
setInterval(loadStats, 5000);
|
setInterval(loadStats, 5000);
|
||||||
// Auto-refresh queue when on queue tab
|
// Auto-refresh queue when on queue tab
|
||||||
setInterval(() => { if (currentTab === 'queue') loadQueue(currentFilter, true); }, 5000);
|
setInterval(() => { if (currentTab === 'queue') loadQueue(currentFilter, true); }, 5000);
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ pub async fn update_queue_item(
|
|||||||
track_number: body.norm_track_number,
|
track_number: body.norm_track_number,
|
||||||
genre: body.norm_genre,
|
genre: body.norm_genre,
|
||||||
featured_artists: body.featured_artists,
|
featured_artists: body.featured_artists,
|
||||||
|
release_type: None,
|
||||||
confidence: Some(1.0), // manual edit = full confidence
|
confidence: Some(1.0), // manual edit = full confidence
|
||||||
notes: Some("Manually edited".to_owned()),
|
notes: Some("Manually edited".to_owned()),
|
||||||
};
|
};
|
||||||
@@ -527,6 +528,20 @@ pub async fn update_album_full(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct SetGenreBody { pub genre: String }
|
||||||
|
|
||||||
|
pub async fn set_album_tracks_genre(
|
||||||
|
State(state): State<S>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
Json(body): Json<SetGenreBody>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
match db::set_album_tracks_genre(&state.pool, id, &body.genre).await {
|
||||||
|
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||||
|
Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct ReorderBody {
|
pub struct ReorderBody {
|
||||||
pub orders: Vec<(i64, i32)>,
|
pub orders: Vec<(i64, i32)>,
|
||||||
@@ -543,19 +558,82 @@ pub async fn reorder_album_tracks(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn album_cover(State(state): State<S>, Path(id): Path<i64>) -> impl IntoResponse {
|
/// Cover by artist+album name — used for queue items that may not have an album_id yet.
|
||||||
let cover = match db::get_album_cover(&state.pool, id).await {
|
#[derive(Deserialize)]
|
||||||
Ok(Some(c)) => c,
|
pub struct CoverByNameQuery {
|
||||||
Ok(None) => return StatusCode::NOT_FOUND.into_response(),
|
#[serde(default)] pub artist: String,
|
||||||
Err(e) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
#[serde(default)] pub name: String,
|
||||||
};
|
|
||||||
match tokio::fs::read(&cover.0).await {
|
|
||||||
Ok(bytes) => (
|
|
||||||
[(axum::http::header::CONTENT_TYPE, cover.1)],
|
|
||||||
bytes,
|
|
||||||
).into_response(),
|
|
||||||
Err(_) => StatusCode::NOT_FOUND.into_response(),
|
|
||||||
}
|
}
|
||||||
|
pub async fn album_cover_by_name(State(state): State<S>, Query(q): Query<CoverByNameQuery>) -> impl IntoResponse {
|
||||||
|
let album_id = match db::find_album_id(&state.pool, &q.artist, &q.name).await {
|
||||||
|
Ok(Some(id)) => id,
|
||||||
|
_ => return StatusCode::NOT_FOUND.into_response(),
|
||||||
|
};
|
||||||
|
album_cover_by_id(&state, album_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn album_cover(State(state): State<S>, Path(id): Path<i64>) -> impl IntoResponse {
|
||||||
|
album_cover_by_id(&state, id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn album_cover_by_id(state: &super::AppState, id: i64) -> axum::response::Response {
|
||||||
|
// 1. Try album_images table
|
||||||
|
if let Ok(Some((file_path, mime_type))) = db::get_album_cover(&state.pool, id).await {
|
||||||
|
if let Ok(bytes) = tokio::fs::read(&file_path).await {
|
||||||
|
return ([(axum::http::header::CONTENT_TYPE, mime_type)], bytes).into_response();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fallback: extract embedded cover from first track in album
|
||||||
|
if let Ok(Some(track_path)) = db::get_album_first_track_path(&state.pool, id).await {
|
||||||
|
let path = std::path::PathBuf::from(track_path);
|
||||||
|
if path.exists() {
|
||||||
|
let result = tokio::task::spawn_blocking(move || extract_embedded_cover(&path)).await;
|
||||||
|
if let Ok(Some((bytes, mime))) = result {
|
||||||
|
return ([(axum::http::header::CONTENT_TYPE, mime)], bytes).into_response();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
StatusCode::NOT_FOUND.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_embedded_cover(path: &std::path::Path) -> Option<(Vec<u8>, String)> {
|
||||||
|
use symphonia::core::{
|
||||||
|
formats::FormatOptions,
|
||||||
|
io::MediaSourceStream,
|
||||||
|
meta::MetadataOptions,
|
||||||
|
probe::Hint,
|
||||||
|
};
|
||||||
|
|
||||||
|
let file = std::fs::File::open(path).ok()?;
|
||||||
|
let mss = MediaSourceStream::new(Box::new(file), Default::default());
|
||||||
|
|
||||||
|
let mut hint = Hint::new();
|
||||||
|
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
|
||||||
|
hint.with_extension(ext);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut probed = symphonia::default::get_probe()
|
||||||
|
.format(
|
||||||
|
&hint,
|
||||||
|
mss,
|
||||||
|
&FormatOptions { enable_gapless: false, ..Default::default() },
|
||||||
|
&MetadataOptions::default(),
|
||||||
|
)
|
||||||
|
.ok()?;
|
||||||
|
|
||||||
|
if let Some(rev) = probed.metadata.get().as_ref().and_then(|m| m.current()) {
|
||||||
|
if let Some(v) = rev.visuals().first() {
|
||||||
|
return Some((v.data.to_vec(), v.media_type.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(rev) = probed.format.metadata().current() {
|
||||||
|
if let Some(v) = rev.visuals().first() {
|
||||||
|
return Some((v.data.to_vec(), v.media_type.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -574,6 +652,116 @@ pub async fn search_albums_for_artist(State(state): State<S>, Query(q): Query<Al
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Artist full admin form ---
|
||||||
|
|
||||||
|
pub async fn get_artist_full(State(state): State<S>, Path(id): Path<i64>) -> impl IntoResponse {
|
||||||
|
let artist = match db::get_artist_by_id(&state.pool, id).await {
|
||||||
|
Ok(Some(a)) => a,
|
||||||
|
Ok(None) => return error_response(StatusCode::NOT_FOUND, "not found"),
|
||||||
|
Err(e) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
||||||
|
};
|
||||||
|
let (albums, appearances) = tokio::join!(
|
||||||
|
db::get_artist_albums(&state.pool, id),
|
||||||
|
db::get_artist_appearances(&state.pool, id),
|
||||||
|
);
|
||||||
|
// For each album, load tracks
|
||||||
|
let albums = match albums {
|
||||||
|
Ok(a) => a,
|
||||||
|
Err(e) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
||||||
|
};
|
||||||
|
let mut albums_with_tracks = Vec::new();
|
||||||
|
for album in albums {
|
||||||
|
let tracks = db::get_album_tracks_admin(&state.pool, album.id).await.unwrap_or_default();
|
||||||
|
albums_with_tracks.push(serde_json::json!({
|
||||||
|
"id": album.id, "name": album.name, "year": album.year,
|
||||||
|
"release_type": album.release_type, "hidden": album.hidden,
|
||||||
|
"track_count": album.track_count, "tracks": tracks,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
(StatusCode::OK, Json(serde_json::json!({
|
||||||
|
"artist": artist,
|
||||||
|
"albums": albums_with_tracks,
|
||||||
|
"appearances": appearances.unwrap_or_default(),
|
||||||
|
}))).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct SetHiddenBody { pub hidden: bool }
|
||||||
|
|
||||||
|
pub async fn set_track_hidden(State(state): State<S>, Path(id): Path<i64>, Json(b): Json<SetHiddenBody>) -> impl IntoResponse {
|
||||||
|
match db::set_track_hidden(&state.pool, id, b.hidden).await {
|
||||||
|
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||||
|
Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_album_hidden(State(state): State<S>, Path(id): Path<i64>, Json(b): Json<SetHiddenBody>) -> impl IntoResponse {
|
||||||
|
match db::set_album_hidden(&state.pool, id, b.hidden).await {
|
||||||
|
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||||
|
Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_artist_hidden(State(state): State<S>, Path(id): Path<i64>, Json(b): Json<SetHiddenBody>) -> impl IntoResponse {
|
||||||
|
match db::set_artist_hidden(&state.pool, id, b.hidden).await {
|
||||||
|
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||||
|
Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct SetReleaseTypeBody { pub release_type: String }
|
||||||
|
|
||||||
|
pub async fn set_album_release_type(State(state): State<S>, Path(id): Path<i64>, Json(b): Json<SetReleaseTypeBody>) -> impl IntoResponse {
|
||||||
|
let valid = ["album","single","ep","compilation","live"];
|
||||||
|
if !valid.contains(&b.release_type.as_str()) {
|
||||||
|
return error_response(StatusCode::BAD_REQUEST, "invalid release_type");
|
||||||
|
}
|
||||||
|
match db::set_album_release_type(&state.pool, id, &b.release_type).await {
|
||||||
|
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||||
|
Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct RenameArtistBody { pub name: String }
|
||||||
|
|
||||||
|
pub async fn rename_artist_api(State(state): State<S>, Path(id): Path<i64>, Json(b): Json<RenameArtistBody>) -> impl IntoResponse {
|
||||||
|
match db::rename_artist_name(&state.pool, id, &b.name).await {
|
||||||
|
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||||
|
Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct AddAppearanceBody { pub track_id: i64 }
|
||||||
|
|
||||||
|
pub async fn add_appearance(State(state): State<S>, Path(artist_id): Path<i64>, Json(b): Json<AddAppearanceBody>) -> impl IntoResponse {
|
||||||
|
match db::add_track_appearance(&state.pool, b.track_id, artist_id).await {
|
||||||
|
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||||
|
Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn remove_appearance(State(state): State<S>, Path((artist_id, track_id)): Path<(i64, i64)>) -> impl IntoResponse {
|
||||||
|
match db::remove_track_appearance(&state.pool, track_id, artist_id).await {
|
||||||
|
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||||
|
Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct SearchTracksQuery { #[serde(default)] pub q: String }
|
||||||
|
|
||||||
|
pub async fn search_tracks_feat(State(state): State<S>, Query(q): Query<SearchTracksQuery>) -> impl IntoResponse {
|
||||||
|
match db::search_tracks_for_feat(&state.pool, &q.q).await {
|
||||||
|
Ok(rows) => (StatusCode::OK, Json(serde_json::to_value(
|
||||||
|
rows.iter().map(|(id, title, artist)| serde_json::json!({"id": id, "title": title, "artist_name": artist})).collect::<Vec<_>>()
|
||||||
|
).unwrap())).into_response(),
|
||||||
|
Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Helpers ---
|
// --- Helpers ---
|
||||||
|
|
||||||
fn error_response(status: StatusCode, message: &str) -> axum::response::Response {
|
fn error_response(status: StatusCode, message: &str) -> axum::response::Response {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ pub mod api;
|
|||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use axum::{Router, routing::{get, post, put}};
|
use axum::{Router, routing::{delete, get, post, put}};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
|
||||||
use crate::config::Args;
|
use crate::config::Args;
|
||||||
@@ -32,12 +32,23 @@ pub fn build_router(state: Arc<AppState>) -> Router {
|
|||||||
.route("/artists", get(api::list_artists))
|
.route("/artists", get(api::list_artists))
|
||||||
.route("/artists/:id", put(api::update_artist))
|
.route("/artists/:id", put(api::update_artist))
|
||||||
.route("/artists/:id/albums", get(api::list_albums))
|
.route("/artists/:id/albums", get(api::list_albums))
|
||||||
|
.route("/artists/:id/full", get(api::get_artist_full))
|
||||||
|
.route("/artists/:id/hidden", put(api::set_artist_hidden))
|
||||||
|
.route("/artists/:id/rename", put(api::rename_artist_api))
|
||||||
|
.route("/artists/:id/appearances", post(api::add_appearance))
|
||||||
|
.route("/artists/:id/appearances/:track_id", delete(api::remove_appearance))
|
||||||
|
.route("/tracks/search", get(api::search_tracks_feat))
|
||||||
.route("/tracks/:id", get(api::get_track).put(api::update_track))
|
.route("/tracks/:id", get(api::get_track).put(api::update_track))
|
||||||
|
.route("/tracks/:id/hidden", put(api::set_track_hidden))
|
||||||
.route("/albums/search", get(api::search_albums_for_artist))
|
.route("/albums/search", get(api::search_albums_for_artist))
|
||||||
|
.route("/albums/cover-by-name", get(api::album_cover_by_name))
|
||||||
.route("/albums/:id/cover", get(api::album_cover))
|
.route("/albums/:id/cover", get(api::album_cover))
|
||||||
.route("/albums/:id/full", get(api::get_album_full))
|
.route("/albums/:id/full", get(api::get_album_full))
|
||||||
.route("/albums/:id/reorder", put(api::reorder_album_tracks))
|
.route("/albums/:id/reorder", put(api::reorder_album_tracks))
|
||||||
.route("/albums/:id/edit", put(api::update_album_full))
|
.route("/albums/:id/edit", put(api::update_album_full))
|
||||||
|
.route("/albums/:id/genre", put(api::set_album_tracks_genre))
|
||||||
|
.route("/albums/:id/hidden", put(api::set_album_hidden))
|
||||||
|
.route("/albums/:id/release_type", put(api::set_album_release_type))
|
||||||
.route("/albums/:id", put(api::update_album))
|
.route("/albums/:id", put(api::update_album))
|
||||||
.route("/merges", get(api::list_merges).post(api::create_merge))
|
.route("/merges", get(api::list_merges).post(api::create_merge))
|
||||||
.route("/merges/:id", get(api::get_merge).put(api::update_merge))
|
.route("/merges/:id", get(api::get_merge).put(api::update_merge))
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { FurumiPlayer } from './FurumiPlayer'
|
||||||
import './App.css'
|
import './App.css'
|
||||||
|
|
||||||
type UserProfile = {
|
type UserProfile = {
|
||||||
@@ -60,8 +61,13 @@ 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) ? (
|
||||||
|
<FurumiPlayer apiRoot={playerApiRoot} />
|
||||||
|
) : (
|
||||||
<main className="page">
|
<main className="page">
|
||||||
<section className="card">
|
<section className="card">
|
||||||
<h1>OIDC Login</h1>
|
<h1>OIDC Login</h1>
|
||||||
@@ -128,6 +134,8 @@ function App() {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
767
furumi-node-player/client/src/FurumiPlayer.tsx
Normal file
767
furumi-node-player/client/src/FurumiPlayer.tsx
Normal file
@@ -0,0 +1,767 @@
|
|||||||
|
import { useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react'
|
||||||
|
import './furumi-player.css'
|
||||||
|
import { createFurumiApiClient } from './furumiApi'
|
||||||
|
import { SearchDropdown } from './components/SearchDropdown'
|
||||||
|
import { Breadcrumbs } from './components/Breadcrumbs'
|
||||||
|
import { LibraryList } from './components/LibraryList'
|
||||||
|
import { QueueList, type QueueItem } from './components/QueueList'
|
||||||
|
import { NowPlaying } from './components/NowPlaying'
|
||||||
|
|
||||||
|
type FurumiPlayerProps = {
|
||||||
|
apiRoot: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Crumb = { label: string; action?: () => void }
|
||||||
|
|
||||||
|
export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
|
||||||
|
const [breadcrumbs, setBreadcrumbs] = useState<Array<{ label: string; action?: () => void }>>(
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
const [libraryLoading, setLibraryLoading] = useState(false)
|
||||||
|
const [libraryError, setLibraryError] = useState<string | null>(null)
|
||||||
|
const [libraryItems, setLibraryItems] = useState<
|
||||||
|
Array<{
|
||||||
|
key: string
|
||||||
|
className: string
|
||||||
|
icon: string
|
||||||
|
name: string
|
||||||
|
detail?: string
|
||||||
|
nameClassName?: string
|
||||||
|
onClick: () => void
|
||||||
|
button?: { title: string; onClick: (ev: ReactMouseEvent<HTMLButtonElement>) => void }
|
||||||
|
}>
|
||||||
|
>([])
|
||||||
|
const [searchResults, setSearchResults] = useState<
|
||||||
|
Array<{ result_type: string; slug: string; name: string; detail?: string }>
|
||||||
|
>([])
|
||||||
|
const [searchOpen, setSearchOpen] = useState(false)
|
||||||
|
const searchSelectRef = useRef<(type: string, slug: string) => void>(() => {})
|
||||||
|
|
||||||
|
const [nowPlayingTrack, setNowPlayingTrack] = useState<QueueItem | null>(null)
|
||||||
|
const [queueItemsView, setQueueItemsView] = useState<QueueItem[]>([])
|
||||||
|
const [queueOrderView, setQueueOrderView] = useState<number[]>([])
|
||||||
|
const [queuePlayingOrigIdxView, setQueuePlayingOrigIdxView] = useState<number>(-1)
|
||||||
|
const [queueScrollSignal, setQueueScrollSignal] = useState(0)
|
||||||
|
|
||||||
|
const queueActionsRef = useRef<{
|
||||||
|
playIndex: (i: number) => void
|
||||||
|
removeFromQueue: (idx: number) => void
|
||||||
|
moveQueueItem: (fromPos: number, toPos: number) => void
|
||||||
|
} | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// --- Original player script adapted for React environment ---
|
||||||
|
const audio = document.getElementById('audioEl') as HTMLAudioElement
|
||||||
|
if (!audio) return
|
||||||
|
|
||||||
|
let queue: QueueItem[] = []
|
||||||
|
let queueIndex = -1
|
||||||
|
let shuffle = false
|
||||||
|
let repeatAll = true
|
||||||
|
let shuffleOrder: number[] = []
|
||||||
|
let searchTimer: number | null = null
|
||||||
|
let toastTimer: number | null = null
|
||||||
|
let muted = false
|
||||||
|
|
||||||
|
// Restore prefs
|
||||||
|
try {
|
||||||
|
const v = window.localStorage.getItem('furumi_vol')
|
||||||
|
const volSlider = document.getElementById('volSlider') as HTMLInputElement | null
|
||||||
|
if (v !== null && volSlider) {
|
||||||
|
audio.volume = Number(v) / 100
|
||||||
|
volSlider.value = v
|
||||||
|
}
|
||||||
|
const btnShuffle = document.getElementById('btnShuffle')
|
||||||
|
const btnRepeat = document.getElementById('btnRepeat')
|
||||||
|
shuffle = window.localStorage.getItem('furumi_shuffle') === '1'
|
||||||
|
repeatAll = window.localStorage.getItem('furumi_repeat') !== '0'
|
||||||
|
btnShuffle?.classList.toggle('active', shuffle)
|
||||||
|
btnRepeat?.classList.toggle('active', repeatAll)
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Audio events ---
|
||||||
|
audio.addEventListener('timeupdate', () => {
|
||||||
|
if (audio.duration) {
|
||||||
|
const fill = document.getElementById('progressFill')
|
||||||
|
const timeElapsed = document.getElementById('timeElapsed')
|
||||||
|
const timeDuration = document.getElementById('timeDuration')
|
||||||
|
if (fill) fill.style.width = `${(audio.currentTime / audio.duration) * 100}%`
|
||||||
|
if (timeElapsed) timeElapsed.textContent = fmt(audio.currentTime)
|
||||||
|
if (timeDuration) timeDuration.textContent = fmt(audio.duration)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
audio.addEventListener('ended', () => nextTrack())
|
||||||
|
audio.addEventListener('play', () => {
|
||||||
|
const btn = document.getElementById('btnPlayPause')
|
||||||
|
if (btn) btn.innerHTML = '⏸'
|
||||||
|
})
|
||||||
|
audio.addEventListener('pause', () => {
|
||||||
|
const btn = document.getElementById('btnPlayPause')
|
||||||
|
if (btn) btn.innerHTML = '▶'
|
||||||
|
})
|
||||||
|
audio.addEventListener('error', () => {
|
||||||
|
showToast('Playback error')
|
||||||
|
nextTrack()
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- API helper ---
|
||||||
|
const API = apiRoot
|
||||||
|
const api = createFurumiApiClient(API)
|
||||||
|
|
||||||
|
// --- Library navigation ---
|
||||||
|
async function showArtists() {
|
||||||
|
setBreadcrumb([{ label: 'Artists', action: showArtists }])
|
||||||
|
setLibraryLoading(true)
|
||||||
|
setLibraryError(null)
|
||||||
|
const artists = await api('/artists')
|
||||||
|
if (!artists) {
|
||||||
|
setLibraryLoading(false)
|
||||||
|
setLibraryError('Error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setLibraryLoading(false)
|
||||||
|
setLibraryItems(
|
||||||
|
(artists as any[]).map((a) => ({
|
||||||
|
key: `artist:${a.slug}`,
|
||||||
|
className: 'file-item dir',
|
||||||
|
icon: '👤',
|
||||||
|
name: a.name,
|
||||||
|
detail: `${a.album_count} albums`,
|
||||||
|
onClick: () => void showArtistAlbums(a.slug, a.name),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showArtistAlbums(artistSlug: string, artistName: string) {
|
||||||
|
setBreadcrumb([
|
||||||
|
{ label: 'Artists', action: showArtists },
|
||||||
|
{ label: artistName, action: () => showArtistAlbums(artistSlug, artistName) },
|
||||||
|
])
|
||||||
|
setLibraryLoading(true)
|
||||||
|
setLibraryError(null)
|
||||||
|
const albums = await api('/artists/' + artistSlug + '/albums')
|
||||||
|
if (!albums) {
|
||||||
|
setLibraryLoading(false)
|
||||||
|
setLibraryError('Error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setLibraryLoading(false)
|
||||||
|
const allTracksItem = {
|
||||||
|
key: `artist-all:${artistSlug}`,
|
||||||
|
className: 'file-item',
|
||||||
|
icon: '▶',
|
||||||
|
name: 'Play all tracks',
|
||||||
|
nameClassName: 'name',
|
||||||
|
onClick: () => void playAllArtistTracks(artistSlug),
|
||||||
|
}
|
||||||
|
const albumItems = (albums as any[]).map((a) => {
|
||||||
|
const year = a.year ? ` (${a.year})` : ''
|
||||||
|
return {
|
||||||
|
key: `album:${a.slug}`,
|
||||||
|
className: 'file-item dir',
|
||||||
|
icon: '💿',
|
||||||
|
name: `${a.name}${year}`,
|
||||||
|
detail: `${a.track_count} tracks`,
|
||||||
|
onClick: () => void showAlbumTracks(a.slug, a.name, artistSlug, artistName),
|
||||||
|
button: {
|
||||||
|
title: 'Add album to queue',
|
||||||
|
onClick: (ev: ReactMouseEvent<HTMLButtonElement>) => {
|
||||||
|
ev.stopPropagation()
|
||||||
|
void addAlbumToQueue(a.slug)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
setLibraryItems([allTracksItem, ...albumItems])
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showAlbumTracks(
|
||||||
|
albumSlug: string,
|
||||||
|
albumName: string,
|
||||||
|
artistSlug: string,
|
||||||
|
artistName: string,
|
||||||
|
) {
|
||||||
|
setBreadcrumb([
|
||||||
|
{ label: 'Artists', action: showArtists },
|
||||||
|
{ label: artistName, action: () => showArtistAlbums(artistSlug, artistName) },
|
||||||
|
{ label: albumName },
|
||||||
|
])
|
||||||
|
setLibraryLoading(true)
|
||||||
|
setLibraryError(null)
|
||||||
|
const tracks = await api('/albums/' + albumSlug)
|
||||||
|
if (!tracks) {
|
||||||
|
setLibraryLoading(false)
|
||||||
|
setLibraryError('Error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setLibraryLoading(false)
|
||||||
|
const playAlbumItem = {
|
||||||
|
key: `album-play:${albumSlug}`,
|
||||||
|
className: 'file-item',
|
||||||
|
icon: '▶',
|
||||||
|
name: 'Play album',
|
||||||
|
onClick: () => {
|
||||||
|
void addAlbumToQueue(albumSlug, true)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const trackItems = (tracks as any[]).map((t) => {
|
||||||
|
const num = t.track_number ? `${t.track_number}. ` : ''
|
||||||
|
const dur = t.duration_secs ? fmt(t.duration_secs) : ''
|
||||||
|
return {
|
||||||
|
key: `track:${t.slug}`,
|
||||||
|
className: 'file-item',
|
||||||
|
icon: '🎵',
|
||||||
|
name: `${num}${t.title}`,
|
||||||
|
detail: dur,
|
||||||
|
onClick: () => {
|
||||||
|
addTrackToQueue(
|
||||||
|
{
|
||||||
|
slug: t.slug,
|
||||||
|
title: t.title,
|
||||||
|
artist: t.artist_name,
|
||||||
|
album_slug: albumSlug,
|
||||||
|
duration: t.duration_secs,
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
setLibraryItems([playAlbumItem, ...trackItems])
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBreadcrumb(parts: Crumb[]) {
|
||||||
|
setBreadcrumbs(parts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Queue management ---
|
||||||
|
function addTrackToQueue(
|
||||||
|
track: {
|
||||||
|
slug: string
|
||||||
|
title: string
|
||||||
|
artist: string
|
||||||
|
album_slug: string | null
|
||||||
|
duration: number | null
|
||||||
|
},
|
||||||
|
playNow?: boolean,
|
||||||
|
) {
|
||||||
|
const existing = queue.findIndex((t) => t.slug === track.slug)
|
||||||
|
if (existing !== -1) {
|
||||||
|
if (playNow) playIndex(existing)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
queue.push(track)
|
||||||
|
updateQueueModel()
|
||||||
|
if (playNow || (queueIndex === -1 && queue.length === 1)) {
|
||||||
|
playIndex(queue.length - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addAlbumToQueue(albumSlug: string, playFirst?: boolean) {
|
||||||
|
const tracks = await api('/albums/' + albumSlug)
|
||||||
|
if (!tracks || !(tracks as any[]).length) return
|
||||||
|
const list = tracks as any[]
|
||||||
|
let firstIdx = queue.length
|
||||||
|
list.forEach((t) => {
|
||||||
|
if (queue.find((q) => q.slug === t.slug)) return
|
||||||
|
queue.push({
|
||||||
|
slug: t.slug,
|
||||||
|
title: t.title,
|
||||||
|
artist: t.artist_name,
|
||||||
|
album_slug: albumSlug,
|
||||||
|
duration: t.duration_secs,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
updateQueueModel()
|
||||||
|
if (playFirst || queueIndex === -1) playIndex(firstIdx)
|
||||||
|
showToast(`Added ${list.length} tracks`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function playAllArtistTracks(artistSlug: string) {
|
||||||
|
const tracks = await api('/artists/' + artistSlug + '/tracks')
|
||||||
|
if (!tracks || !(tracks as any[]).length) return
|
||||||
|
const list = tracks as any[]
|
||||||
|
clearQueue()
|
||||||
|
list.forEach((t) => {
|
||||||
|
queue.push({
|
||||||
|
slug: t.slug,
|
||||||
|
title: t.title,
|
||||||
|
artist: t.artist_name,
|
||||||
|
album_slug: t.album_slug,
|
||||||
|
duration: t.duration_secs,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
updateQueueModel()
|
||||||
|
playIndex(0)
|
||||||
|
showToast(`Added ${list.length} tracks`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function playIndex(i: number) {
|
||||||
|
if (i < 0 || i >= queue.length) return
|
||||||
|
queueIndex = i
|
||||||
|
const track = queue[i]
|
||||||
|
audio.src = `${API}/stream/${track.slug}`
|
||||||
|
void audio.play().catch(() => {})
|
||||||
|
updateNowPlaying(track)
|
||||||
|
updateQueueModel()
|
||||||
|
setQueueScrollSignal((s) => s + 1)
|
||||||
|
if (window.history && window.history.replaceState) {
|
||||||
|
const url = new URL(window.location.href)
|
||||||
|
url.searchParams.set('t', track.slug)
|
||||||
|
window.history.replaceState(null, '', url.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateNowPlaying(track: QueueItem | null) {
|
||||||
|
setNowPlayingTrack(track)
|
||||||
|
if (!track) return
|
||||||
|
|
||||||
|
document.title = `${track.title} — Furumi`
|
||||||
|
|
||||||
|
const coverUrl = `${API}/tracks/${track.slug}/cover`
|
||||||
|
if ('mediaSession' in navigator) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
navigator.mediaSession.metadata = new window.MediaMetadata({
|
||||||
|
title: track.title,
|
||||||
|
artist: track.artist || '',
|
||||||
|
album: '',
|
||||||
|
artwork: [{ src: coverUrl, sizes: '512x512' }],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentOrder() {
|
||||||
|
if (!shuffle) return [...Array(queue.length).keys()]
|
||||||
|
if (shuffleOrder.length !== queue.length) buildShuffleOrder()
|
||||||
|
return shuffleOrder
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildShuffleOrder() {
|
||||||
|
shuffleOrder = [...Array(queue.length).keys()]
|
||||||
|
for (let i = shuffleOrder.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1))
|
||||||
|
;[shuffleOrder[i], shuffleOrder[j]] = [shuffleOrder[j], shuffleOrder[i]]
|
||||||
|
}
|
||||||
|
if (queueIndex !== -1) {
|
||||||
|
const ci = shuffleOrder.indexOf(queueIndex)
|
||||||
|
if (ci > 0) {
|
||||||
|
shuffleOrder.splice(ci, 1)
|
||||||
|
shuffleOrder.unshift(queueIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateQueueModel() {
|
||||||
|
const order = currentOrder()
|
||||||
|
setQueueItemsView(queue.slice())
|
||||||
|
setQueueOrderView(order.slice())
|
||||||
|
setQueuePlayingOrigIdxView(queueIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeFromQueue(idx: number) {
|
||||||
|
if (idx === queueIndex) {
|
||||||
|
queueIndex = -1
|
||||||
|
audio.pause()
|
||||||
|
audio.src = ''
|
||||||
|
updateNowPlaying(null)
|
||||||
|
} else if (queueIndex > idx) {
|
||||||
|
queueIndex--
|
||||||
|
}
|
||||||
|
queue.splice(idx, 1)
|
||||||
|
if (shuffle) {
|
||||||
|
const si = shuffleOrder.indexOf(idx)
|
||||||
|
if (si !== -1) shuffleOrder.splice(si, 1)
|
||||||
|
for (let i = 0; i < shuffleOrder.length; i++) {
|
||||||
|
if (shuffleOrder[i] > idx) shuffleOrder[i]--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateQueueModel()
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveQueueItem(from: number, to: number) {
|
||||||
|
if (from === to) return
|
||||||
|
if (shuffle) {
|
||||||
|
const item = shuffleOrder.splice(from, 1)[0]
|
||||||
|
shuffleOrder.splice(to, 0, item)
|
||||||
|
} else {
|
||||||
|
const item = queue.splice(from, 1)[0]
|
||||||
|
queue.splice(to, 0, item)
|
||||||
|
if (queueIndex === from) queueIndex = to
|
||||||
|
else if (from < queueIndex && to >= queueIndex) queueIndex--
|
||||||
|
else if (from > queueIndex && to <= queueIndex) queueIndex++
|
||||||
|
}
|
||||||
|
updateQueueModel()
|
||||||
|
}
|
||||||
|
|
||||||
|
queueActionsRef.current = {
|
||||||
|
playIndex,
|
||||||
|
removeFromQueue,
|
||||||
|
moveQueueItem,
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearQueue() {
|
||||||
|
queue = []
|
||||||
|
queueIndex = -1
|
||||||
|
shuffleOrder = []
|
||||||
|
audio.pause()
|
||||||
|
audio.src = ''
|
||||||
|
updateNowPlaying(null)
|
||||||
|
document.title = 'Furumi Player'
|
||||||
|
updateQueueModel()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Playback controls ---
|
||||||
|
function togglePlay() {
|
||||||
|
if (!audio.src && queue.length) {
|
||||||
|
playIndex(queueIndex === -1 ? 0 : queueIndex)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (audio.paused) void audio.play()
|
||||||
|
else audio.pause()
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextTrack() {
|
||||||
|
if (!queue.length) return
|
||||||
|
const order = currentOrder()
|
||||||
|
const pos = order.indexOf(queueIndex)
|
||||||
|
if (pos < order.length - 1) playIndex(order[pos + 1])
|
||||||
|
else if (repeatAll) {
|
||||||
|
if (shuffle) buildShuffleOrder()
|
||||||
|
playIndex(currentOrder()[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function prevTrack() {
|
||||||
|
if (!queue.length) return
|
||||||
|
if (audio.currentTime > 3) {
|
||||||
|
audio.currentTime = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const order = currentOrder()
|
||||||
|
const pos = order.indexOf(queueIndex)
|
||||||
|
if (pos > 0) playIndex(order[pos - 1])
|
||||||
|
else if (repeatAll) playIndex(order[order.length - 1])
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleShuffle() {
|
||||||
|
shuffle = !shuffle
|
||||||
|
if (shuffle) buildShuffleOrder()
|
||||||
|
const btn = document.getElementById('btnShuffle')
|
||||||
|
btn?.classList.toggle('active', shuffle)
|
||||||
|
window.localStorage.setItem('furumi_shuffle', shuffle ? '1' : '0')
|
||||||
|
updateQueueModel()
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleRepeat() {
|
||||||
|
repeatAll = !repeatAll
|
||||||
|
const btn = document.getElementById('btnRepeat')
|
||||||
|
btn?.classList.toggle('active', repeatAll)
|
||||||
|
window.localStorage.setItem('furumi_repeat', repeatAll ? '1' : '0')
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Seek & Volume ---
|
||||||
|
function seekTo(e: MouseEvent) {
|
||||||
|
if (!audio.duration) return
|
||||||
|
const bar = document.getElementById('progressBar') as HTMLDivElement | null
|
||||||
|
if (!bar) return
|
||||||
|
const rect = bar.getBoundingClientRect()
|
||||||
|
const pct = (e.clientX - rect.left) / rect.width
|
||||||
|
audio.currentTime = pct * audio.duration
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleMute() {
|
||||||
|
muted = !muted
|
||||||
|
audio.muted = muted
|
||||||
|
const volIcon = document.getElementById('volIcon')
|
||||||
|
if (volIcon) volIcon.innerHTML = muted ? '🔇' : '🔊'
|
||||||
|
}
|
||||||
|
|
||||||
|
function setVolume(v: number) {
|
||||||
|
audio.volume = v / 100
|
||||||
|
const volIcon = document.getElementById('volIcon')
|
||||||
|
if (volIcon) volIcon.innerHTML = v === 0 ? '🔇' : '🔊'
|
||||||
|
window.localStorage.setItem('furumi_vol', String(v))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Search ---
|
||||||
|
function onSearch(q: string) {
|
||||||
|
if (searchTimer) {
|
||||||
|
window.clearTimeout(searchTimer)
|
||||||
|
}
|
||||||
|
if (q.length < 2) {
|
||||||
|
closeSearch()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
searchTimer = window.setTimeout(async () => {
|
||||||
|
const results = await api('/search?q=' + encodeURIComponent(q))
|
||||||
|
if (!results || !(results as any[]).length) {
|
||||||
|
closeSearch()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSearchResults(results as any[])
|
||||||
|
setSearchOpen(true)
|
||||||
|
}, 250)
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSearch() {
|
||||||
|
setSearchOpen(false)
|
||||||
|
setSearchResults([])
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSearchSelect(type: string, slug: string) {
|
||||||
|
closeSearch()
|
||||||
|
if (type === 'artist') void showArtistAlbums(slug, '')
|
||||||
|
else if (type === 'album') void addAlbumToQueue(slug, true)
|
||||||
|
else if (type === 'track') {
|
||||||
|
addTrackToQueue(
|
||||||
|
{ slug, title: '', artist: '', album_slug: null, duration: null },
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
void api('/stream/' + slug).catch(() => null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
searchSelectRef.current = onSearchSelect
|
||||||
|
|
||||||
|
// --- 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) {
|
||||||
|
const t = document.getElementById('toast')
|
||||||
|
if (!t) return
|
||||||
|
t.textContent = msg
|
||||||
|
t.classList.add('show')
|
||||||
|
if (toastTimer) window.clearTimeout(toastTimer)
|
||||||
|
toastTimer = window.setTimeout(() => t.classList.remove('show'), 2500)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSidebar() {
|
||||||
|
const sidebar = document.getElementById('sidebar')
|
||||||
|
const overlay = document.getElementById('sidebarOverlay')
|
||||||
|
sidebar?.classList.toggle('open')
|
||||||
|
overlay?.classList.toggle('show')
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- MediaSession ---
|
||||||
|
if ('mediaSession' in navigator) {
|
||||||
|
try {
|
||||||
|
navigator.mediaSession.setActionHandler('play', togglePlay)
|
||||||
|
navigator.mediaSession.setActionHandler('pause', togglePlay)
|
||||||
|
navigator.mediaSession.setActionHandler('previoustrack', prevTrack)
|
||||||
|
navigator.mediaSession.setActionHandler('nexttrack', nextTrack)
|
||||||
|
navigator.mediaSession.setActionHandler('seekto', (d: any) => {
|
||||||
|
if (typeof d.seekTime === 'number') {
|
||||||
|
audio.currentTime = d.seekTime
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Wire DOM events that were inline in HTML ---
|
||||||
|
const btnMenu = document.querySelector('.btn-menu')
|
||||||
|
btnMenu?.addEventListener('click', () => toggleSidebar())
|
||||||
|
|
||||||
|
const sidebarOverlay = document.getElementById('sidebarOverlay')
|
||||||
|
sidebarOverlay?.addEventListener('click', () => toggleSidebar())
|
||||||
|
|
||||||
|
const searchInput = document.getElementById('searchInput') as HTMLInputElement | null
|
||||||
|
if (searchInput) {
|
||||||
|
searchInput.addEventListener('input', (e) => {
|
||||||
|
onSearch((e.target as HTMLInputElement).value)
|
||||||
|
})
|
||||||
|
searchInput.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') closeSearch()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const btnShuffle = document.getElementById('btnShuffle')
|
||||||
|
btnShuffle?.addEventListener('click', () => toggleShuffle())
|
||||||
|
const btnRepeat = document.getElementById('btnRepeat')
|
||||||
|
btnRepeat?.addEventListener('click', () => toggleRepeat())
|
||||||
|
const btnClear = document.getElementById('btnClearQueue')
|
||||||
|
btnClear?.addEventListener('click', () => clearQueue())
|
||||||
|
|
||||||
|
const btnPrev = document.getElementById('btnPrev')
|
||||||
|
btnPrev?.addEventListener('click', () => prevTrack())
|
||||||
|
const btnPlay = document.getElementById('btnPlayPause')
|
||||||
|
btnPlay?.addEventListener('click', () => togglePlay())
|
||||||
|
const btnNext = document.getElementById('btnNext')
|
||||||
|
btnNext?.addEventListener('click', () => nextTrack())
|
||||||
|
|
||||||
|
const progressBar = document.getElementById('progressBar')
|
||||||
|
progressBar?.addEventListener('click', (e) => seekTo(e as MouseEvent))
|
||||||
|
|
||||||
|
const volIcon = document.getElementById('volIcon')
|
||||||
|
volIcon?.addEventListener('click', () => toggleMute())
|
||||||
|
const volSlider = document.getElementById('volSlider') as HTMLInputElement | null
|
||||||
|
if (volSlider) {
|
||||||
|
volSlider.addEventListener('input', (e) => {
|
||||||
|
const v = Number((e.target as HTMLInputElement).value)
|
||||||
|
setVolume(v)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearQueueBtn = document.getElementById('btnClearQueue')
|
||||||
|
clearQueueBtn?.addEventListener('click', () => clearQueue())
|
||||||
|
|
||||||
|
// --- Init ---
|
||||||
|
;(async () => {
|
||||||
|
const url = new URL(window.location.href)
|
||||||
|
const urlSlug = url.searchParams.get('t')
|
||||||
|
if (urlSlug) {
|
||||||
|
const info = await api('/tracks/' + urlSlug)
|
||||||
|
if (info) {
|
||||||
|
addTrackToQueue(
|
||||||
|
{
|
||||||
|
slug: (info as any).slug,
|
||||||
|
title: (info as any).title,
|
||||||
|
artist: (info as any).artist_name,
|
||||||
|
album_slug: (info as any).album_slug,
|
||||||
|
duration: (info as any).duration_secs,
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void showArtists()
|
||||||
|
})()
|
||||||
|
|
||||||
|
// Cleanup: best-effort remove listeners on unmount
|
||||||
|
return () => {
|
||||||
|
queueActionsRef.current = null
|
||||||
|
audio.pause()
|
||||||
|
}
|
||||||
|
}, [apiRoot])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="furumi-root">
|
||||||
|
<header className="header">
|
||||||
|
<div className="header-logo">
|
||||||
|
<button className="btn-menu">☰</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={(type, slug) => searchSelectRef.current(type, slug)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<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
|
||||||
|
apiRoot={apiRoot}
|
||||||
|
queue={queueItemsView}
|
||||||
|
order={queueOrderView}
|
||||||
|
playingOrigIdx={queuePlayingOrigIdxView}
|
||||||
|
scrollSignal={queueScrollSignal}
|
||||||
|
onPlay={(origIdx) => queueActionsRef.current?.playIndex(origIdx)}
|
||||||
|
onRemove={(origIdx) =>
|
||||||
|
queueActionsRef.current?.removeFromQueue(origIdx)
|
||||||
|
}
|
||||||
|
onMove={(fromPos, toPos) =>
|
||||||
|
queueActionsRef.current?.moveQueueItem(fromPos, toPos)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="player-bar">
|
||||||
|
<NowPlaying apiRoot={apiRoot} track={nowPlayingTrack} />
|
||||||
|
<div className="controls">
|
||||||
|
<div className="ctrl-btns">
|
||||||
|
<button className="ctrl-btn" id="btnPrev">
|
||||||
|
⏮
|
||||||
|
</button>
|
||||||
|
<button className="ctrl-btn ctrl-btn-main" id="btnPlayPause">
|
||||||
|
▶
|
||||||
|
</button>
|
||||||
|
<button className="ctrl-btn" id="btnNext">
|
||||||
|
⏭
|
||||||
|
</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">
|
||||||
|
🔊
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
className="volume-slider"
|
||||||
|
id="volSlider"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
defaultValue={80}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="toast" id="toast" />
|
||||||
|
<audio id="audioEl" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
30
furumi-node-player/client/src/components/Breadcrumbs.tsx
Normal file
30
furumi-node-player/client/src/components/Breadcrumbs.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
type Crumb = {
|
||||||
|
label: string
|
||||||
|
action?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type BreadcrumbsProps = {
|
||||||
|
items: Crumb[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Breadcrumbs({ items }: BreadcrumbsProps) {
|
||||||
|
if (!items.length) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="breadcrumb">
|
||||||
|
{items.map((item, index) => {
|
||||||
|
const isLast = index === items.length - 1
|
||||||
|
return (
|
||||||
|
<span key={`${item.label}-${index}`}>
|
||||||
|
{!isLast && item.action ? (
|
||||||
|
<span onClick={item.action}>{item.label}</span>
|
||||||
|
) : (
|
||||||
|
<span>{item.label}</span>
|
||||||
|
)}
|
||||||
|
{!isLast ? ' / ' : ''}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
54
furumi-node-player/client/src/components/LibraryList.tsx
Normal file
54
furumi-node-player/client/src/components/LibraryList.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import type { MouseEvent } from 'react'
|
||||||
|
|
||||||
|
type LibraryListButton = {
|
||||||
|
title: string
|
||||||
|
onClick: (ev: MouseEvent<HTMLButtonElement>) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type LibraryListItem = {
|
||||||
|
key: string
|
||||||
|
className: string
|
||||||
|
icon: string
|
||||||
|
name: string
|
||||||
|
detail?: string
|
||||||
|
nameClassName?: string
|
||||||
|
onClick: () => void
|
||||||
|
button?: LibraryListButton
|
||||||
|
}
|
||||||
|
|
||||||
|
type LibraryListProps = {
|
||||||
|
loading: boolean
|
||||||
|
error: string | null
|
||||||
|
items: LibraryListItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LibraryList({ loading, error, items }: LibraryListProps) {
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
||||||
|
<div className="spinner" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <div style={{ padding: '1rem', color: 'var(--danger)' }}>{error}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{items.map((item) => (
|
||||||
|
<div key={item.key} className={item.className} onClick={item.onClick}>
|
||||||
|
<span className="icon">{item.icon}</span>
|
||||||
|
<span className={item.nameClassName ?? 'name'}>{item.name}</span>
|
||||||
|
{item.detail ? <span className="detail">{item.detail}</span> : null}
|
||||||
|
{item.button ? (
|
||||||
|
<button className="add-btn" title={item.button.title} onClick={item.button.onClick}>
|
||||||
|
➕
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
52
furumi-node-player/client/src/components/NowPlaying.tsx
Normal file
52
furumi-node-player/client/src/components/NowPlaying.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import type { QueueItem } from './QueueList'
|
||||||
|
|
||||||
|
function Cover({ src }: { src: string }) {
|
||||||
|
const [errored, setErrored] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setErrored(false)
|
||||||
|
}, [src])
|
||||||
|
|
||||||
|
if (errored) return <>🎵</>
|
||||||
|
return <img src={src} alt="" onError={() => setErrored(true)} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NowPlaying({ apiRoot, track }: { apiRoot: string; track: QueueItem | null }) {
|
||||||
|
if (!track) {
|
||||||
|
return (
|
||||||
|
<div className="np-info">
|
||||||
|
<div className="np-cover" id="npCover">
|
||||||
|
🎵
|
||||||
|
</div>
|
||||||
|
<div className="np-text">
|
||||||
|
<div className="np-title" id="npTitle">
|
||||||
|
Nothing playing
|
||||||
|
</div>
|
||||||
|
<div className="np-artist" id="npArtist">
|
||||||
|
—
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const coverUrl = `${apiRoot}/tracks/${track.slug}/cover`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="np-info">
|
||||||
|
<div className="np-cover" id="npCover">
|
||||||
|
<Cover src={coverUrl} />
|
||||||
|
</div>
|
||||||
|
<div className="np-text">
|
||||||
|
<div className="np-title" id="npTitle">
|
||||||
|
{track.title}
|
||||||
|
</div>
|
||||||
|
<div className="np-artist" id="npArtist">
|
||||||
|
{track.artist || '—'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
144
furumi-node-player/client/src/components/QueueList.tsx
Normal file
144
furumi-node-player/client/src/components/QueueList.tsx
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
export type QueueItem = {
|
||||||
|
slug: string
|
||||||
|
title: string
|
||||||
|
artist: string
|
||||||
|
album_slug: string | null
|
||||||
|
duration: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type QueueListProps = {
|
||||||
|
apiRoot: string
|
||||||
|
queue: QueueItem[]
|
||||||
|
order: number[]
|
||||||
|
playingOrigIdx: number
|
||||||
|
scrollSignal: number
|
||||||
|
onPlay: (origIdx: number) => void
|
||||||
|
onRemove: (origIdx: number) => void
|
||||||
|
onMove: (fromPos: number, toPos: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function pad(n: number) {
|
||||||
|
return String(n).padStart(2, '0')
|
||||||
|
}
|
||||||
|
|
||||||
|
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 Cover({ src }: { src: string }) {
|
||||||
|
const [errored, setErrored] = useState(false)
|
||||||
|
useEffect(() => {
|
||||||
|
setErrored(false)
|
||||||
|
}, [src])
|
||||||
|
|
||||||
|
if (errored) return <>🎵</>
|
||||||
|
return <img src={src} alt="" onError={() => setErrored(true)} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QueueList({
|
||||||
|
apiRoot,
|
||||||
|
queue,
|
||||||
|
order,
|
||||||
|
playingOrigIdx,
|
||||||
|
scrollSignal,
|
||||||
|
onPlay,
|
||||||
|
onRemove,
|
||||||
|
onMove,
|
||||||
|
}: QueueListProps) {
|
||||||
|
const playingRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const [draggingPos, setDraggingPos] = useState<number | null>(null)
|
||||||
|
const [dragOverPos, setDragOverPos] = useState<number | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (playingRef.current) {
|
||||||
|
playingRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||||
|
}
|
||||||
|
}, [playingOrigIdx, scrollSignal])
|
||||||
|
|
||||||
|
if (!queue.length) {
|
||||||
|
return (
|
||||||
|
<div className="queue-empty">
|
||||||
|
<div className="empty-icon">🎵</div>
|
||||||
|
<div>Select an album to start</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{order.map((origIdx, pos) => {
|
||||||
|
const t = queue[origIdx]
|
||||||
|
if (!t) return null
|
||||||
|
|
||||||
|
const isPlaying = origIdx === playingOrigIdx
|
||||||
|
const coverSrc = t.album_slug ? `${apiRoot}/tracks/${t.slug}/cover` : ''
|
||||||
|
const dur = t.duration ? fmt(t.duration) : ''
|
||||||
|
const isDragging = draggingPos === pos
|
||||||
|
const isDragOver = dragOverPos === pos
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${t.slug}:${pos}`}
|
||||||
|
ref={isPlaying ? playingRef : null}
|
||||||
|
className={`queue-item${isPlaying ? ' playing' : ''}${isDragging ? ' dragging' : ''}${
|
||||||
|
isDragOver ? ' drag-over' : ''
|
||||||
|
}`}
|
||||||
|
draggable
|
||||||
|
onClick={() => onPlay(origIdx)}
|
||||||
|
onDragStart={(e) => {
|
||||||
|
setDraggingPos(pos)
|
||||||
|
e.dataTransfer?.setData('text/plain', String(pos))
|
||||||
|
}}
|
||||||
|
onDragEnd={() => {
|
||||||
|
setDraggingPos(null)
|
||||||
|
setDragOverPos(null)
|
||||||
|
}}
|
||||||
|
onDragOver={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
}}
|
||||||
|
onDragEnter={() => {
|
||||||
|
setDragOverPos(pos)
|
||||||
|
}}
|
||||||
|
onDragLeave={() => {
|
||||||
|
setDragOverPos((cur) => (cur === pos ? null : cur))
|
||||||
|
}}
|
||||||
|
onDrop={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setDragOverPos(null)
|
||||||
|
const from = parseInt(e.dataTransfer?.getData('text/plain') ?? '', 10)
|
||||||
|
if (!Number.isNaN(from)) onMove(from, pos)
|
||||||
|
setDraggingPos(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="qi-index">{isPlaying ? '' : pos + 1}</span>
|
||||||
|
<div className="qi-cover">
|
||||||
|
{coverSrc ? <Cover src={coverSrc} /> : <>🎵</>}
|
||||||
|
</div>
|
||||||
|
<div className="qi-info">
|
||||||
|
<div className="qi-title">{t.title}</div>
|
||||||
|
<div className="qi-artist">{t.artist || ''}</div>
|
||||||
|
</div>
|
||||||
|
<span className="qi-dur">{dur}</span>
|
||||||
|
<button
|
||||||
|
className="qi-remove"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onRemove(origIdx)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
30
furumi-node-player/client/src/components/SearchDropdown.tsx
Normal file
30
furumi-node-player/client/src/components/SearchDropdown.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
type SearchResultItem = {
|
||||||
|
result_type: string
|
||||||
|
slug: string
|
||||||
|
name: string
|
||||||
|
detail?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type SearchDropdownProps = {
|
||||||
|
isOpen: boolean
|
||||||
|
results: SearchResultItem[]
|
||||||
|
onSelect: (type: string, slug: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchDropdown({ isOpen, results, onSelect }: SearchDropdownProps) {
|
||||||
|
return (
|
||||||
|
<div className={`search-dropdown${isOpen ? ' open' : ''}`}>
|
||||||
|
{results.map((r) => (
|
||||||
|
<div
|
||||||
|
key={`${r.result_type}:${r.slug}`}
|
||||||
|
className="search-result"
|
||||||
|
onClick={() => onSelect(r.result_type, r.slug)}
|
||||||
|
>
|
||||||
|
<span className="sr-type">{r.result_type}</span>
|
||||||
|
{r.name}
|
||||||
|
{r.detail ? <span className="sr-detail">{r.detail}</span> : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
754
furumi-node-player/client/src/furumi-player.css
Normal file
754
furumi-node-player/client/src/furumi-player.css
Normal file
@@ -0,0 +1,754 @@
|
|||||||
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||||||
|
|
||||||
|
.furumi-root,
|
||||||
|
.furumi-root * {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.furumi-root {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
font-family: 'Inter', system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg-base: #0a0c12;
|
||||||
|
--bg-panel: #111520;
|
||||||
|
--bg-card: #161d2e;
|
||||||
|
--bg-hover: #1e2740;
|
||||||
|
--bg-active: #252f4a;
|
||||||
|
--border: #1f2c45;
|
||||||
|
--accent: #7c6af7;
|
||||||
|
--accent-dim: #5a4fcf;
|
||||||
|
--accent-glow: rgba(124, 106, 247, 0.3);
|
||||||
|
--text: #e2e8f0;
|
||||||
|
--text-muted: #64748b;
|
||||||
|
--text-dim: #94a3b8;
|
||||||
|
--success: #34d399;
|
||||||
|
--danger: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-logo svg {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-version {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-left: 0.25rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-menu {
|
||||||
|
display: none;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.1rem 0.5rem;
|
||||||
|
margin-right: 0.2rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-wrap input {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 12px 6px 30px;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 13px;
|
||||||
|
width: 220px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-wrap::before {
|
||||||
|
content: '🔍';
|
||||||
|
position: absolute;
|
||||||
|
left: 8px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0 0 6px 6px;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
z-index: 50;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-dropdown.open {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result {
|
||||||
|
padding: 8px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result .sr-type {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result .sr-detail {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
background: var(--bg-base);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-overlay {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-overlay.show {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: 280px;
|
||||||
|
min-width: 200px;
|
||||||
|
max-width: 400px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
overflow: hidden;
|
||||||
|
resize: horizontal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
padding: 0.85rem 1rem 0.6rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-muted);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb span {
|
||||||
|
color: var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb span:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.3rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 0.45rem 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
user-select: none;
|
||||||
|
transition: background 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item.dir {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item .icon {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item .name {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item .detail {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item .add-btn {
|
||||||
|
opacity: 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item:hover .add-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item .add-btn:hover {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-panel {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-header {
|
||||||
|
padding: 0.85rem 1.25rem 0.6rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-muted);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-btn {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 0.2rem 0.55rem;
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 5px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-btn:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-btn.active {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.3rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-list::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-list::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.55rem 1.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border-left: 2px solid transparent;
|
||||||
|
transition: background 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item.playing {
|
||||||
|
background: var(--bg-active);
|
||||||
|
border-left-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item.playing .qi-title {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item .qi-index {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
width: 1.5rem;
|
||||||
|
text-align: right;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item.playing .qi-index::before {
|
||||||
|
content: '▶';
|
||||||
|
font-size: 0.6rem;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item .qi-cover {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item .qi-cover img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item .qi-info {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item .qi-title {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item .qi-artist {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item .qi-dur {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qi-remove {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.3rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item:hover .qi-remove {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qi-remove:hover {
|
||||||
|
background: rgba(248, 113, 113, 0.15);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item.dragging {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item.drag-over {
|
||||||
|
border-top: 2px solid var(--accent);
|
||||||
|
margin-top: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-empty {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-empty .empty-icon {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-bar {
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding: 0.9rem 1.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 2fr 1fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.np-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.np-cover {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.np-cover img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.np-text {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.np-title {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.np-artist {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctrl-btns {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctrl-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-dim);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.35rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctrl-btn:hover {
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctrl-btn.active {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctrl-btn-main {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff !important;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
box-shadow: 0 0 14px var(--accent-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctrl-btn-main:hover {
|
||||||
|
background: var(--accent-dim) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
min-width: 2.5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
flex: 1;
|
||||||
|
height: 4px;
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-radius: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: 2px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
right: -5px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent);
|
||||||
|
box-shadow: 0 0 6px var(--accent-glow);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar:hover .progress-fill::after {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vol-icon {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-slider {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 80px;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--bg-hover);
|
||||||
|
cursor: pointer;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-slider::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
display: inline-block;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border: 2px solid var(--border);
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.7s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 90px;
|
||||||
|
right: 1.5rem;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px);
|
||||||
|
transition: all 0.25s;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.show {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.btn-menu {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: -100%;
|
||||||
|
width: 85%;
|
||||||
|
max-width: 320px;
|
||||||
|
z-index: 30;
|
||||||
|
transition: left 0.3s;
|
||||||
|
box-shadow: 4px 0 20px rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.open {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-bar {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-row {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-wrap input {
|
||||||
|
width: 140px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
12
furumi-node-player/client/src/furumiApi.ts
Normal file
12
furumi-node-player/client/src/furumiApi.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
export type FurumiApiClient = (path: string) => Promise<unknown | null>
|
||||||
|
|
||||||
|
export function createFurumiApiClient(apiRoot: string): FurumiApiClient {
|
||||||
|
const API = apiRoot
|
||||||
|
|
||||||
|
return async function api(path: string) {
|
||||||
|
const r = await fetch(API + path)
|
||||||
|
if (!r.ok) return null
|
||||||
|
return r.json()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -92,6 +92,7 @@ pub async fn list_artists(pool: &PgPool) -> Result<Vec<ArtistListItem>, sqlx::Er
|
|||||||
FROM artists ar
|
FROM artists ar
|
||||||
LEFT JOIN albums al ON al.artist_id = ar.id
|
LEFT JOIN albums al ON al.artist_id = ar.id
|
||||||
LEFT JOIN tracks t ON t.artist_id = ar.id
|
LEFT JOIN tracks t ON t.artist_id = ar.id
|
||||||
|
WHERE NOT ar.hidden
|
||||||
GROUP BY ar.id, ar.slug, ar.name
|
GROUP BY ar.id, ar.slug, ar.name
|
||||||
HAVING COUNT(DISTINCT t.id) > 0
|
HAVING COUNT(DISTINCT t.id) > 0
|
||||||
ORDER BY ar.name"#
|
ORDER BY ar.name"#
|
||||||
@@ -116,8 +117,10 @@ pub async fn list_albums_by_artist(pool: &PgPool, artist_slug: &str) -> Result<V
|
|||||||
EXISTS(SELECT 1 FROM album_images ai WHERE ai.album_id = al.id AND ai.image_type = 'cover') AS has_cover
|
EXISTS(SELECT 1 FROM album_images ai WHERE ai.album_id = al.id AND ai.image_type = 'cover') AS has_cover
|
||||||
FROM albums al
|
FROM albums al
|
||||||
JOIN artists ar ON al.artist_id = ar.id
|
JOIN artists ar ON al.artist_id = ar.id
|
||||||
LEFT JOIN tracks t ON t.album_id = al.id
|
LEFT JOIN tracks t ON t.album_id = al.id AND NOT t.hidden
|
||||||
WHERE ar.slug = $1
|
WHERE ar.slug = $1
|
||||||
|
AND NOT al.hidden
|
||||||
|
AND EXISTS (SELECT 1 FROM tracks t2 WHERE t2.album_id = al.id AND NOT t2.hidden)
|
||||||
GROUP BY al.id, al.slug, al.name, al.year
|
GROUP BY al.id, al.slug, al.name, al.year
|
||||||
ORDER BY al.year NULLS LAST, al.name"#
|
ORDER BY al.year NULLS LAST, al.name"#
|
||||||
)
|
)
|
||||||
@@ -135,6 +138,7 @@ pub async fn list_tracks_by_album(pool: &PgPool, album_slug: &str) -> Result<Vec
|
|||||||
JOIN artists ar ON t.artist_id = ar.id
|
JOIN artists ar ON t.artist_id = ar.id
|
||||||
LEFT JOIN albums al ON t.album_id = al.id
|
LEFT JOIN albums al ON t.album_id = al.id
|
||||||
WHERE al.slug = $1
|
WHERE al.slug = $1
|
||||||
|
AND NOT t.hidden
|
||||||
ORDER BY t.track_number NULLS LAST, t.title"#
|
ORDER BY t.track_number NULLS LAST, t.title"#
|
||||||
)
|
)
|
||||||
.bind(album_slug)
|
.bind(album_slug)
|
||||||
@@ -221,6 +225,7 @@ pub async fn list_all_tracks_by_artist(pool: &PgPool, artist_slug: &str) -> Resu
|
|||||||
JOIN artists ar ON t.artist_id = ar.id
|
JOIN artists ar ON t.artist_id = ar.id
|
||||||
LEFT JOIN albums al ON t.album_id = al.id
|
LEFT JOIN albums al ON t.album_id = al.id
|
||||||
WHERE ar.slug = $1
|
WHERE ar.slug = $1
|
||||||
|
AND NOT t.hidden
|
||||||
ORDER BY al.year NULLS LAST, al.name, t.track_number NULLS LAST, t.title"#
|
ORDER BY al.year NULLS LAST, al.name, t.track_number NULLS LAST, t.title"#
|
||||||
)
|
)
|
||||||
.bind(artist_slug)
|
.bind(artist_slug)
|
||||||
|
|||||||
Reference in New Issue
Block a user