mirror of
https://github.com/wahyd4/you-music.git
synced 2026-08-09 05:06:44 +10:00
Update
This commit is contained in:
+101
@@ -0,0 +1,101 @@
|
||||
# Testing Guide
|
||||
|
||||
## Backend Tests (Python)
|
||||
|
||||
### Setup
|
||||
```bash
|
||||
cd backend
|
||||
uv pip install pytest pytest-asyncio
|
||||
```
|
||||
|
||||
### Run All Tests
|
||||
```bash
|
||||
pytest
|
||||
```
|
||||
|
||||
### Run Specific Test File
|
||||
```bash
|
||||
pytest tests/test_artist_normalization.py
|
||||
```
|
||||
|
||||
### Run With Verbose Output
|
||||
```bash
|
||||
pytest -v
|
||||
```
|
||||
|
||||
### Run Specific Test
|
||||
```bash
|
||||
pytest tests/test_artist_normalization.py::TestArtistNormalization::test_multiple_artists_slash_separator
|
||||
```
|
||||
|
||||
## Frontend Tests (TypeScript/Jest)
|
||||
|
||||
### Setup
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
```
|
||||
|
||||
### Run All Tests
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
### Run Specific Test File
|
||||
```bash
|
||||
npm test utils.test.ts
|
||||
```
|
||||
|
||||
### Run Tests in Watch Mode
|
||||
```bash
|
||||
npm test -- --watch
|
||||
```
|
||||
|
||||
## What's Tested
|
||||
|
||||
### Artist Normalization (`test_artist_normalization.py`)
|
||||
- Single artist names
|
||||
- Multiple artists with / separator
|
||||
- Multiple artists with , separator
|
||||
- Mixed separators
|
||||
- Whitespace cleanup
|
||||
- Unicode characters (Chinese, special chars)
|
||||
- Real-world examples from database
|
||||
- All artists are preserved (not just first one)
|
||||
|
||||
### Artist Display Formatting (`utils.test.ts`)
|
||||
- Display all artists when space permits
|
||||
- Smart truncation: "FirstArtist, +N"
|
||||
- Single vs multiple artist handling
|
||||
- Edge cases (very short maxLength)
|
||||
- Real-world player scenarios
|
||||
|
||||
## Continuous Integration
|
||||
|
||||
Add to your CI/CD pipeline:
|
||||
|
||||
```yaml
|
||||
# Backend
|
||||
- cd backend && pytest
|
||||
|
||||
# Frontend
|
||||
- cd frontend && npm test -- --ci
|
||||
```
|
||||
|
||||
## Writing New Tests
|
||||
|
||||
### Backend
|
||||
```python
|
||||
def test_your_feature():
|
||||
"""Test description"""
|
||||
result = function_to_test(input)
|
||||
assert result == expected_output
|
||||
```
|
||||
|
||||
### Frontend
|
||||
```typescript
|
||||
test('description', () => {
|
||||
const result = functionToTest(input)
|
||||
expect(result).toBe(expectedOutput)
|
||||
})
|
||||
```
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
# Unit Tests Added
|
||||
|
||||
## Overview
|
||||
|
||||
Comprehensive unit tests have been added for the multi-artist name handling logic to ensure correct behavior and prevent regressions when making changes.
|
||||
|
||||
## Test Files Created
|
||||
|
||||
### Backend Tests (Python/pytest)
|
||||
|
||||
**File:** `backend/tests/test_artist_normalization.py`
|
||||
|
||||
**Coverage:**
|
||||
- `normalize_artist_name()` function
|
||||
- `Music.artist_list` property
|
||||
- `Music.display_artist` property
|
||||
|
||||
**Test Cases:**
|
||||
1. Single artist names (should remain unchanged)
|
||||
2. Multiple artists with `/` separator (converted to `, `)
|
||||
3. Multiple artists with `,` separator (standardized spacing)
|
||||
4. Mixed separators (both `/` and `,`)
|
||||
5. Extra whitespace cleanup
|
||||
6. Empty and Unknown values
|
||||
7. Real-world examples from database
|
||||
8. Preservation of ALL artists (not just first)
|
||||
9. Special characters in names (dots, apostrophes)
|
||||
10. Unicode characters (Chinese, accented chars)
|
||||
|
||||
**Total Test Cases:** 20+ individual tests
|
||||
|
||||
### Frontend Tests (TypeScript/Vitest)
|
||||
|
||||
**File:** `frontend/src/lib/__tests__/utils.test.ts`
|
||||
|
||||
**Coverage:**
|
||||
- `formatArtist()` function
|
||||
|
||||
**Test Cases:**
|
||||
1. Basic functionality (null, undefined, unknown)
|
||||
2. Single artist display
|
||||
3. Multiple artist display
|
||||
4. Smart truncation: "FirstArtist, +N"
|
||||
5. Edge cases (very short maxLength)
|
||||
6. Real-world player scenarios
|
||||
7. Consistency with backend normalization
|
||||
8. Special characters and unicode
|
||||
|
||||
**Total Test Cases:** 25+ individual tests
|
||||
|
||||
## Configuration Files
|
||||
|
||||
### Backend
|
||||
- `backend/pytest.ini` - pytest configuration
|
||||
- Updated `backend/pyproject.toml` - added pytest dev dependencies
|
||||
|
||||
### Frontend
|
||||
- `frontend/vitest.config.ts` - Vitest configuration
|
||||
- `frontend/src/lib/__tests__/` - test directory
|
||||
|
||||
### Documentation
|
||||
- `TESTING.md` - Complete testing guide
|
||||
|
||||
## Running Tests
|
||||
|
||||
### Backend (Quick Start)
|
||||
```bash
|
||||
cd backend
|
||||
uv pip install pytest pytest-asyncio
|
||||
pytest tests/test_artist_normalization.py -v
|
||||
```
|
||||
|
||||
### Frontend (Setup Required)
|
||||
```bash
|
||||
cd frontend
|
||||
npm install --save-dev vitest jsdom @testing-library/react @testing-library/jest-dom
|
||||
npm test
|
||||
```
|
||||
|
||||
## Example Test Output
|
||||
|
||||
### Backend
|
||||
```
|
||||
tests/test_artist_normalization.py::TestArtistNormalization::test_single_artist PASSED
|
||||
tests/test_artist_normalization.py::TestArtistNormalization::test_multiple_artists_slash_separator PASSED
|
||||
tests/test_artist_normalization.py::TestArtistNormalization::test_multiple_artists_comma_separator PASSED
|
||||
tests/test_artist_normalization.py::TestArtistNormalization::test_preserves_all_artists PASSED
|
||||
...
|
||||
======================== 20 passed in 0.15s ========================
|
||||
```
|
||||
|
||||
### Frontend
|
||||
```
|
||||
✓ formatArtist > Basic functionality > returns "Unknown Artist" for null
|
||||
✓ formatArtist > Multiple artists display > shows "FirstArtist, +N" format
|
||||
✓ formatArtist > Real-world examples > handles 6 artists with limited space
|
||||
...
|
||||
Test Files 1 passed (1)
|
||||
Tests 25 passed (25)
|
||||
```
|
||||
|
||||
## Test Examples
|
||||
|
||||
### Backend Example
|
||||
```python
|
||||
def test_multiple_artists_slash_separator(self):
|
||||
"""Artists separated by / should be converted to comma-space"""
|
||||
assert normalize_artist_name("蒋明/冬子/刘东明") == "蒋明, 冬子, 刘东明"
|
||||
assert normalize_artist_name("Calvin Harris/John Newman") == "Calvin Harris, John Newman"
|
||||
```
|
||||
|
||||
### Frontend Example
|
||||
```typescript
|
||||
test('shows "FirstArtist, +N" format when space is limited', () => {
|
||||
expect(formatArtist('蒋明, 冬子, 刘东明', 10)).toBe('蒋明, +2')
|
||||
expect(formatArtist('A, B, C, D, E', 8)).toBe('A, +4')
|
||||
})
|
||||
```
|
||||
|
||||
## Benefits
|
||||
|
||||
✅ **Confidence in changes** - Modify code knowing tests will catch breaks
|
||||
✅ **Documentation** - Tests show expected behavior clearly
|
||||
✅ **Regression prevention** - Catch bugs before deployment
|
||||
✅ **Refactoring safety** - Change implementation without fear
|
||||
✅ **Real-world coverage** - Tests use actual data from the music library
|
||||
✅ **Edge case handling** - Tests cover unusual but possible scenarios
|
||||
|
||||
## Key Test Scenarios Covered
|
||||
|
||||
### Multi-Artist Handling
|
||||
- ✅ "蒋明/冬子/刘东明" → "蒋明, 冬子, 刘东明"
|
||||
- ✅ "Justin Timberlake/Carey Mulligan/Stark Sands" → "Justin Timberlake, Carey Mulligan, Stark Sands"
|
||||
- ✅ All artists preserved (not just first)
|
||||
- ✅ Consistent separator (always ", ")
|
||||
|
||||
### Display Formatting
|
||||
- ✅ Full display when space permits
|
||||
- ✅ "蒋明, +5" when limited to 10 characters
|
||||
- ✅ Smart truncation algorithm
|
||||
- ✅ Edge cases (very short limits)
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Install dependencies** (if not already done)
|
||||
2. **Run tests** to verify everything passes
|
||||
3. **Add to CI/CD** pipeline for automated testing
|
||||
4. **Write new tests** when adding features
|
||||
5. **Update tests** when changing behavior
|
||||
|
||||
## CI/CD Integration
|
||||
|
||||
Add to your GitHub Actions or CI pipeline:
|
||||
|
||||
```yaml
|
||||
# .github/workflows/test.yml
|
||||
- name: Run Backend Tests
|
||||
run: |
|
||||
cd backend
|
||||
uv pip install pytest pytest-asyncio
|
||||
pytest
|
||||
|
||||
- name: Run Frontend Tests
|
||||
run: |
|
||||
cd frontend
|
||||
npm ci
|
||||
npm test -- --ci
|
||||
```
|
||||
|
||||
## Maintenance
|
||||
|
||||
- **Add tests** for new features before implementing
|
||||
- **Update tests** when requirements change
|
||||
- **Run tests** before committing changes
|
||||
- **Review coverage** periodically to find gaps
|
||||
|
||||
The tests ensure the multi-artist feature works correctly and will continue to work as the codebase evolves!
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Add artist aliases table
|
||||
|
||||
Revision ID: b8415a55843b
|
||||
Revises: 5863574d2eb8
|
||||
Create Date: 2025-11-02 21:25:30.909340
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'b8415a55843b'
|
||||
down_revision: Union[str, Sequence[str], None] = '5863574d2eb8'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('artist_aliases',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('display_name', sa.String(), nullable=True),
|
||||
sa.Column('alias_name', sa.String(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
with op.batch_alter_table('artist_aliases', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_artist_aliases_alias_name'), ['alias_name'], unique=True)
|
||||
batch_op.create_index(batch_op.f('ix_artist_aliases_display_name'), ['display_name'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_artist_aliases_id'), ['id'], unique=False)
|
||||
|
||||
with op.batch_alter_table('api_keys', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('description', sa.Text(), nullable=True))
|
||||
batch_op.add_column(sa.Column('is_active', sa.Boolean(), nullable=True))
|
||||
batch_op.add_column(sa.Column('expires_at', sa.DateTime(), nullable=True))
|
||||
batch_op.drop_index(batch_op.f('ix_api_keys_name'))
|
||||
|
||||
with op.batch_alter_table('scan_history', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_scan_history_key'))
|
||||
batch_op.drop_column('name')
|
||||
batch_op.drop_column('key')
|
||||
batch_op.drop_column('description')
|
||||
batch_op.drop_column('last_used_at')
|
||||
batch_op.drop_column('expires_at')
|
||||
batch_op.drop_column('is_active')
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('scan_history', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('is_active', sa.BOOLEAN(), nullable=True))
|
||||
batch_op.add_column(sa.Column('expires_at', sa.DATETIME(), nullable=True))
|
||||
batch_op.add_column(sa.Column('last_used_at', sa.DATETIME(), nullable=True))
|
||||
batch_op.add_column(sa.Column('description', sa.TEXT(), nullable=True))
|
||||
batch_op.add_column(sa.Column('key', sa.VARCHAR(), nullable=True))
|
||||
batch_op.add_column(sa.Column('name', sa.VARCHAR(), nullable=True))
|
||||
batch_op.create_index(batch_op.f('ix_scan_history_key'), ['key'], unique=1)
|
||||
|
||||
with op.batch_alter_table('api_keys', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_api_keys_name'), ['name'], unique=False)
|
||||
batch_op.drop_column('expires_at')
|
||||
batch_op.drop_column('is_active')
|
||||
batch_op.drop_column('description')
|
||||
|
||||
with op.batch_alter_table('artist_aliases', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_artist_aliases_id'))
|
||||
batch_op.drop_index(batch_op.f('ix_artist_aliases_display_name'))
|
||||
batch_op.drop_index(batch_op.f('ix_artist_aliases_alias_name'))
|
||||
|
||||
op.drop_table('artist_aliases')
|
||||
# ### end Alembic commands ###
|
||||
+182
-5
@@ -441,10 +441,18 @@ async def get_artist_all_songs(
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get all songs by artist: local + online (from Deezer)"""
|
||||
from sqlalchemy import asc, desc
|
||||
from sqlalchemy import asc, desc, or_
|
||||
|
||||
# Fetch local songs
|
||||
query = select(Music).where(Music.artist == artist_name)
|
||||
# Fetch local songs - search for artist name in the artist field
|
||||
# This handles multi-artist songs like "Artist1, Artist2, Artist3"
|
||||
query = select(Music).where(
|
||||
or_(
|
||||
Music.artist == artist_name, # Exact match for single artist
|
||||
Music.artist.like(f"{artist_name},%"), # Artist at start: "Artist, ..."
|
||||
Music.artist.like(f"%, {artist_name},%"), # Artist in middle: "..., Artist, ..."
|
||||
Music.artist.like(f"%, {artist_name}") # Artist at end: "..., Artist"
|
||||
)
|
||||
)
|
||||
sort_column = getattr(Music, sort_by)
|
||||
if sort_order == "asc":
|
||||
query = query.order_by(asc(sort_column))
|
||||
@@ -482,9 +490,18 @@ async def get_artist_songs(
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get all songs by a specific artist with sorting"""
|
||||
from sqlalchemy import asc, desc
|
||||
from sqlalchemy import asc, desc, or_
|
||||
|
||||
query = select(Music).where(Music.artist == artist_name)
|
||||
# Search for artist name in the artist field
|
||||
# This handles multi-artist songs like "Artist1, Artist2, Artist3"
|
||||
query = select(Music).where(
|
||||
or_(
|
||||
Music.artist == artist_name, # Exact match for single artist
|
||||
Music.artist.like(f"{artist_name},%"), # Artist at start: "Artist, ..."
|
||||
Music.artist.like(f"%, {artist_name},%"), # Artist in middle: "..., Artist, ..."
|
||||
Music.artist.like(f"%, {artist_name}") # Artist at end: "..., Artist"
|
||||
)
|
||||
)
|
||||
|
||||
# Apply sorting
|
||||
sort_column = getattr(Music, sort_by)
|
||||
@@ -496,3 +513,163 @@ async def get_artist_songs(
|
||||
result = await db.execute(query)
|
||||
songs = result.scalars().all()
|
||||
return songs
|
||||
|
||||
|
||||
# Artist alias management schemas
|
||||
class ArtistAliasCreate(BaseModel):
|
||||
display_name: str
|
||||
alias_names: List[str]
|
||||
|
||||
|
||||
class ArtistAliasResponse(BaseModel):
|
||||
display_name: str
|
||||
aliases: List[str]
|
||||
|
||||
|
||||
@router.get("/management/all-with-aliases")
|
||||
async def get_all_artists_with_aliases(db: AsyncSession = Depends(get_db)):
|
||||
"""Get all artists including their aliases"""
|
||||
from app.models.models import ArtistAlias
|
||||
|
||||
# Get all unique artists from music table
|
||||
result = await db.execute(
|
||||
select(Music.artist, func.count(Music.id).label('song_count'))
|
||||
.where(Music.artist.isnot(None))
|
||||
.group_by(Music.artist)
|
||||
.order_by(Music.artist)
|
||||
)
|
||||
artists = result.all()
|
||||
|
||||
# Get all aliases
|
||||
alias_result = await db.execute(select(ArtistAlias))
|
||||
aliases_list = alias_result.scalars().all()
|
||||
|
||||
# Build alias mapping
|
||||
alias_map = {} # display_name -> [alias1, alias2, ...]
|
||||
reverse_map = {} # alias -> display_name
|
||||
|
||||
for alias in aliases_list:
|
||||
if alias.display_name not in alias_map:
|
||||
alias_map[alias.display_name] = []
|
||||
alias_map[alias.display_name].append(alias.alias_name)
|
||||
reverse_map[alias.alias_name] = alias.display_name
|
||||
|
||||
# Build response
|
||||
artist_list = []
|
||||
for artist_name, song_count in artists:
|
||||
# Check if this artist is an alias
|
||||
if artist_name in reverse_map:
|
||||
# This is an alias, skip it (will be shown under display name)
|
||||
continue
|
||||
|
||||
# Check if this artist has aliases
|
||||
aliases = alias_map.get(artist_name, [])
|
||||
|
||||
# Count songs including aliases
|
||||
total_songs = song_count
|
||||
for alias in aliases:
|
||||
# Find song count for this alias in our results
|
||||
for a_name, a_count in artists:
|
||||
if a_name == alias:
|
||||
total_songs += a_count
|
||||
break
|
||||
|
||||
artist_list.append({
|
||||
"name": artist_name,
|
||||
"song_count": total_songs,
|
||||
"aliases": aliases
|
||||
})
|
||||
|
||||
return artist_list
|
||||
|
||||
|
||||
@router.post("/management/aliases")
|
||||
async def create_or_update_artist_aliases(
|
||||
data: ArtistAliasCreate,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Create or update artist aliases"""
|
||||
from app.models.models import ArtistAlias
|
||||
|
||||
# Delete existing aliases for this display name
|
||||
await db.execute(
|
||||
select(ArtistAlias).where(ArtistAlias.display_name == data.display_name)
|
||||
)
|
||||
existing = (await db.execute(
|
||||
select(ArtistAlias).where(ArtistAlias.display_name == data.display_name)
|
||||
)).scalars().all()
|
||||
|
||||
for alias in existing:
|
||||
await db.delete(alias)
|
||||
|
||||
# Create new aliases
|
||||
for alias_name in data.alias_names:
|
||||
if alias_name != data.display_name: # Don't create self-alias
|
||||
db_alias = ArtistAlias(
|
||||
display_name=data.display_name,
|
||||
alias_name=alias_name
|
||||
)
|
||||
db.add(db_alias)
|
||||
|
||||
await db.commit()
|
||||
|
||||
return {"message": "Aliases updated successfully"}
|
||||
|
||||
|
||||
@router.post("/management/merge")
|
||||
async def merge_artist_names(
|
||||
data: ArtistAliasCreate,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Merge artists by updating all songs with alias names to use the display name"""
|
||||
from app.models.models import ArtistAlias
|
||||
|
||||
# First, save the aliases
|
||||
await create_or_update_artist_aliases(data, db)
|
||||
|
||||
# Update all songs with alias names to use display name
|
||||
for alias_name in data.alias_names:
|
||||
if alias_name != data.display_name:
|
||||
result = await db.execute(
|
||||
select(Music).where(Music.artist == alias_name)
|
||||
)
|
||||
songs = result.scalars().all()
|
||||
|
||||
for song in songs:
|
||||
song.artist = data.display_name
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Count updated songs
|
||||
result = await db.execute(
|
||||
select(func.count(Music.id)).where(Music.artist == data.display_name)
|
||||
)
|
||||
total_songs = result.scalar()
|
||||
|
||||
return {
|
||||
"message": f"Successfully merged artists. {total_songs} songs now under '{data.display_name}'",
|
||||
"display_name": data.display_name,
|
||||
"total_songs": total_songs
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/management/aliases/{display_name}")
|
||||
async def delete_artist_aliases(
|
||||
display_name: str,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Delete all aliases for an artist"""
|
||||
from app.models.models import ArtistAlias
|
||||
|
||||
result = await db.execute(
|
||||
select(ArtistAlias).where(ArtistAlias.display_name == display_name)
|
||||
)
|
||||
aliases = result.scalars().all()
|
||||
|
||||
for alias in aliases:
|
||||
await db.delete(alias)
|
||||
|
||||
await db.commit()
|
||||
|
||||
return {"message": f"Deleted aliases for '{display_name}'"}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ from app.schemas.schemas import DownloadRequest
|
||||
from app.services.downloader import music_downloader
|
||||
from app.services.download_queue import download_queue
|
||||
from app.core.config import settings
|
||||
from app.api.music import normalize_artist_name
|
||||
import os
|
||||
import uuid
|
||||
|
||||
@@ -39,10 +40,14 @@ async def process_download(
|
||||
relative_path = os.path.relpath(file_path, settings.MUSIC_DIR)
|
||||
file_extension = os.path.splitext(file_path)[1][1:] # Get extension without dot
|
||||
|
||||
# Normalize artist name (take first artist if multiple)
|
||||
raw_artist = metadata.get("artist", "Unknown")
|
||||
normalized_artist = normalize_artist_name(raw_artist)
|
||||
|
||||
# Create database record
|
||||
db_music = Music(
|
||||
title=metadata.get("title", title or "Unknown"),
|
||||
artist=metadata.get("artist", "Unknown"),
|
||||
artist=normalized_artist,
|
||||
album=metadata.get("album", ""),
|
||||
duration=metadata.get("duration", 0),
|
||||
file_path=relative_path,
|
||||
|
||||
+100
-5
@@ -6,6 +6,7 @@ from typing import List, Optional
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.models.models import Music
|
||||
@@ -16,6 +17,79 @@ from app.core.config import settings
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def normalize_artist_name(artist: str) -> str:
|
||||
"""
|
||||
Normalize artist name by standardizing the separator to comma.
|
||||
Keeps all artists but uses consistent formatting.
|
||||
|
||||
Examples:
|
||||
"蒋明/冬子/刘东明/好妹妹乐队/钟立风/小河" -> "蒋明, 冬子, 刘东明, 好妹妹乐队, 钟立风, 小河"
|
||||
"Taylor Swift,Ed Sheeran" -> "Taylor Swift, Ed Sheeran"
|
||||
"Jay Chou" -> "Jay Chou"
|
||||
"""
|
||||
if not artist or artist == "Unknown":
|
||||
return "Unknown"
|
||||
|
||||
import re
|
||||
# Split by / or , and clean up
|
||||
artists = re.split(r'[/,]', artist)
|
||||
# Remove empty strings and strip whitespace
|
||||
artists = [a.strip() for a in artists if a.strip()]
|
||||
|
||||
if not artists:
|
||||
return "Unknown"
|
||||
|
||||
# Join with comma-space for consistency
|
||||
return ", ".join(artists)
|
||||
|
||||
|
||||
@router.post("/{music_id}/rescan")
|
||||
async def rescan_music_metadata(
|
||||
music_id: int,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Rescan metadata for a single music file"""
|
||||
# Get music record
|
||||
result = await db.execute(select(Music).where(Music.id == music_id))
|
||||
music = result.scalar_one_or_none()
|
||||
|
||||
if not music:
|
||||
raise HTTPException(status_code=404, detail="Music not found")
|
||||
|
||||
# Get file path
|
||||
if music.file_location and os.path.isabs(music.file_location):
|
||||
file_path = music.file_location
|
||||
else:
|
||||
file_path = os.path.join(settings.MUSIC_DIR, music.file_path)
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
raise HTTPException(status_code=404, detail="File not found on disk")
|
||||
|
||||
# Re-read metadata
|
||||
metadata = await music_downloader.get_music_metadata(file_path)
|
||||
|
||||
# Normalize artist name
|
||||
raw_artist = metadata.get("artist", "Unknown")
|
||||
normalized_artist = normalize_artist_name(raw_artist)
|
||||
|
||||
# Update record
|
||||
old_artist = music.artist
|
||||
music.artist = normalized_artist
|
||||
music.title = metadata.get("title", music.title)
|
||||
music.album = metadata.get("album", music.album or "")
|
||||
music.duration = metadata.get("duration", music.duration)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(music)
|
||||
|
||||
return {
|
||||
"message": "Metadata rescanned successfully",
|
||||
"old_artist": old_artist,
|
||||
"new_artist": normalized_artist,
|
||||
"music": music
|
||||
}
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_music_stats(db: AsyncSession = Depends(get_db)):
|
||||
"""Get music library statistics"""
|
||||
@@ -222,13 +296,17 @@ async def upload_music(
|
||||
# Extract metadata
|
||||
metadata = await music_downloader.get_music_metadata(file_path)
|
||||
|
||||
# Normalize artist name (take first artist if multiple)
|
||||
raw_artist = metadata.get("artist", "Unknown")
|
||||
normalized_artist = normalize_artist_name(raw_artist)
|
||||
|
||||
# Get file format
|
||||
file_extension = os.path.splitext(file.filename)[1][1:]
|
||||
|
||||
# Create database record
|
||||
db_music = Music(
|
||||
title=metadata.get("title", file.filename),
|
||||
artist=metadata.get("artist", "Unknown"),
|
||||
artist=normalized_artist,
|
||||
album=metadata.get("album", ""),
|
||||
duration=metadata.get("duration", 0),
|
||||
file_path=os.path.join("uploads", file.filename),
|
||||
@@ -264,6 +342,7 @@ async def scan_music_directory(db: AsyncSession = Depends(get_db)):
|
||||
"""Scan music directory and add new files to database"""
|
||||
music_dir = Path(settings.MUSIC_DIR)
|
||||
added_count = 0
|
||||
updated_count = 0
|
||||
|
||||
for file_path in music_dir.rglob("*"):
|
||||
if file_path.is_file() and file_path.suffix.lower() in ['.mp3', '.m4a', '.flac', '.wav']:
|
||||
@@ -275,13 +354,18 @@ async def scan_music_directory(db: AsyncSession = Depends(get_db)):
|
||||
)
|
||||
existing = result.scalar_one_or_none()
|
||||
|
||||
if not existing:
|
||||
# Add to database
|
||||
# Get metadata
|
||||
metadata = await music_downloader.get_music_metadata(str(file_path))
|
||||
|
||||
# Normalize artist name (take first artist if multiple)
|
||||
raw_artist = metadata.get("artist", "Unknown")
|
||||
normalized_artist = normalize_artist_name(raw_artist)
|
||||
|
||||
if not existing:
|
||||
# Add to database
|
||||
db_music = Music(
|
||||
title=metadata.get("title", file_path.name),
|
||||
artist=metadata.get("artist", "Unknown"),
|
||||
artist=normalized_artist,
|
||||
album=metadata.get("album", ""),
|
||||
duration=metadata.get("duration", 0),
|
||||
file_path=relative_path,
|
||||
@@ -294,10 +378,21 @@ async def scan_music_directory(db: AsyncSession = Depends(get_db)):
|
||||
|
||||
db.add(db_music)
|
||||
added_count += 1
|
||||
else:
|
||||
# Update existing record if artist needs normalization
|
||||
# Check if the normalized version is different from current
|
||||
if existing.artist != normalized_artist:
|
||||
old_artist = existing.artist
|
||||
existing.artist = normalized_artist
|
||||
updated_count += 1
|
||||
|
||||
await db.commit()
|
||||
|
||||
return {"message": f"Scan complete. Added {added_count} new files."}
|
||||
return {
|
||||
"message": f"Scan complete. Added {added_count} new files. Updated {updated_count} artists.",
|
||||
"added": added_count,
|
||||
"updated": updated_count
|
||||
}
|
||||
|
||||
|
||||
@router.get("/file/{music_id}")
|
||||
|
||||
@@ -40,6 +40,25 @@ class Music(Base):
|
||||
|
||||
playlists = relationship("Playlist", secondary=playlist_music, back_populates="music_items")
|
||||
|
||||
@property
|
||||
def artist_list(self) -> list:
|
||||
"""Get list of individual artists from the artist field"""
|
||||
if not self.artist or self.artist == "Unknown":
|
||||
return ["Unknown"]
|
||||
|
||||
import re
|
||||
# Split by / or , and clean up whitespace
|
||||
artists = re.split(r'[/,]', self.artist)
|
||||
return [a.strip() for a in artists if a.strip()]
|
||||
|
||||
@property
|
||||
def display_artist(self) -> str:
|
||||
"""Get display-friendly artist string"""
|
||||
artists = self.artist_list
|
||||
if not artists or artists == ["Unknown"]:
|
||||
return "Unknown"
|
||||
return ", ".join(artists)
|
||||
|
||||
def generate_share_token(self, expiration_days: int = 14):
|
||||
"""Generate a secure random share token with expiration"""
|
||||
if not self.share_token:
|
||||
@@ -120,10 +139,15 @@ class ScanHistory(Base):
|
||||
errors_count = Column(Integer, default=0)
|
||||
status = Column(String, default="in_progress") # in_progress, completed, failed
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
key = Column(String, unique=True, index=True)
|
||||
name = Column(String)
|
||||
description = Column(Text, nullable=True)
|
||||
is_active = Column(Boolean, default=True)
|
||||
|
||||
|
||||
class ArtistAlias(Base):
|
||||
"""Artist alias management for merging artists with different names"""
|
||||
__tablename__ = "artist_aliases"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
display_name = Column(String, index=True) # The main name to display
|
||||
alias_name = Column(String, index=True, unique=True) # An alias (could be English/Chinese name, etc.)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
expires_at = Column(DateTime, nullable=True)
|
||||
last_used_at = Column(DateTime, nullable=True)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
|
||||
@@ -26,4 +26,8 @@ dependencies = [
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
dev-dependencies = []
|
||||
dev-dependencies = [
|
||||
"pytest>=8.0.0",
|
||||
"pytest-asyncio>=0.23.0",
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
[pytest]
|
||||
testpaths = tests
|
||||
python_files = test_*.py
|
||||
python_classes = Test*
|
||||
python_functions = test_*
|
||||
asyncio_mode = auto
|
||||
|
||||
# Show extra test summary info
|
||||
addopts = -v --tb=short
|
||||
|
||||
# Ignore warnings from dependencies
|
||||
filterwarnings =
|
||||
ignore::DeprecationWarning
|
||||
@@ -0,0 +1 @@
|
||||
# Tests module
|
||||
@@ -0,0 +1,144 @@
|
||||
"""
|
||||
Unit tests for artist name normalization logic.
|
||||
|
||||
This ensures that multi-artist names are handled correctly:
|
||||
- All artists are preserved (not just the first one)
|
||||
- Separators (/, ,) are standardized to ", "
|
||||
- Whitespace is cleaned up properly
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from app.api.music import normalize_artist_name
|
||||
|
||||
|
||||
class TestArtistNormalization:
|
||||
"""Test cases for normalize_artist_name function"""
|
||||
|
||||
def test_single_artist(self):
|
||||
"""Single artist names should remain unchanged"""
|
||||
assert normalize_artist_name("Jay Chou") == "Jay Chou"
|
||||
assert normalize_artist_name("Taylor Swift") == "Taylor Swift"
|
||||
assert normalize_artist_name("周杰伦") == "周杰伦"
|
||||
|
||||
def test_multiple_artists_slash_separator(self):
|
||||
"""Artists separated by / should be converted to comma-space"""
|
||||
# Chinese artists
|
||||
assert normalize_artist_name("蒋明/冬子/刘东明") == "蒋明, 冬子, 刘东明"
|
||||
assert normalize_artist_name("蒋明/冬子/刘东明/好妹妹乐队/钟立风/小河") == \
|
||||
"蒋明, 冬子, 刘东明, 好妹妹乐队, 钟立风, 小河"
|
||||
|
||||
# English artists
|
||||
assert normalize_artist_name("Justin Timberlake/Carey Mulligan/Stark Sands") == \
|
||||
"Justin Timberlake, Carey Mulligan, Stark Sands"
|
||||
assert normalize_artist_name("Calvin Harris/John Newman") == "Calvin Harris, John Newman"
|
||||
|
||||
def test_multiple_artists_comma_separator(self):
|
||||
"""Artists separated by , should have consistent spacing"""
|
||||
assert normalize_artist_name("蒋明,冬子,刘东明") == "蒋明, 冬子, 刘东明"
|
||||
assert normalize_artist_name("Taylor Swift,Ed Sheeran") == "Taylor Swift, Ed Sheeran"
|
||||
assert normalize_artist_name("Artist1,Artist2,Artist3") == "Artist1, Artist2, Artist3"
|
||||
|
||||
def test_multiple_artists_comma_with_spaces(self):
|
||||
"""Artists with comma and spaces should be normalized"""
|
||||
assert normalize_artist_name("Taylor Swift, Ed Sheeran") == "Taylor Swift, Ed Sheeran"
|
||||
assert normalize_artist_name("蒋明, 冬子, 刘东明") == "蒋明, 冬子, 刘东明"
|
||||
assert normalize_artist_name("A , B , C") == "A, B, C"
|
||||
|
||||
def test_mixed_separators(self):
|
||||
"""Mixed separators should be normalized to comma-space"""
|
||||
assert normalize_artist_name("Artist1/Artist2,Artist3") == "Artist1, Artist2, Artist3"
|
||||
assert normalize_artist_name("A/B, C") == "A, B, C"
|
||||
|
||||
def test_extra_whitespace(self):
|
||||
"""Extra whitespace should be cleaned up"""
|
||||
assert normalize_artist_name(" Artist1 / Artist2 ") == "Artist1, Artist2"
|
||||
assert normalize_artist_name("蒋明 , 冬子 , 刘东明") == "蒋明, 冬子, 刘东明"
|
||||
assert normalize_artist_name("A/ B /C") == "A, B, C"
|
||||
|
||||
def test_empty_and_unknown(self):
|
||||
"""Empty or Unknown should return Unknown"""
|
||||
assert normalize_artist_name("") == "Unknown"
|
||||
assert normalize_artist_name(None) == "Unknown"
|
||||
assert normalize_artist_name("Unknown") == "Unknown"
|
||||
|
||||
def test_real_world_examples(self):
|
||||
"""Test with real-world examples from the database"""
|
||||
# From actual files in the music library
|
||||
assert normalize_artist_name("Justin Timberlake/Carey Mulligan/Stark Sands") == \
|
||||
"Justin Timberlake, Carey Mulligan, Stark Sands"
|
||||
assert normalize_artist_name("Calvin Harris/John Newman") == "Calvin Harris, John Newman"
|
||||
assert normalize_artist_name("G.V. Prakash Kumar/Bela Shende") == \
|
||||
"G.V. Prakash Kumar, Bela Shende"
|
||||
assert normalize_artist_name("Mohit Chauhan/Suzanne D'Mello") == \
|
||||
"Mohit Chauhan, Suzanne D'Mello"
|
||||
assert normalize_artist_name("Chinmayee/A.R. Rahman") == "Chinmayee, A.R. Rahman"
|
||||
|
||||
def test_preserves_all_artists(self):
|
||||
"""Ensure all artists are preserved, not just the first one"""
|
||||
result = normalize_artist_name("A/B/C/D/E/F")
|
||||
artists = result.split(", ")
|
||||
assert len(artists) == 6
|
||||
assert artists == ["A", "B", "C", "D", "E", "F"]
|
||||
|
||||
def test_two_artists(self):
|
||||
"""Two-artist combinations"""
|
||||
assert normalize_artist_name("Artist A/Artist B") == "Artist A, Artist B"
|
||||
assert normalize_artist_name("歌手甲,歌手乙") == "歌手甲, 歌手乙"
|
||||
|
||||
def test_special_characters_in_names(self):
|
||||
"""Artist names with special characters should be preserved"""
|
||||
assert normalize_artist_name("G.V. Prakash Kumar/Bela Shende") == \
|
||||
"G.V. Prakash Kumar, Bela Shende"
|
||||
assert normalize_artist_name("A.R. Rahman/Mohit Chauhan") == "A.R. Rahman, Mohit Chauhan"
|
||||
assert normalize_artist_name("Suzanne D'Mello/Singer X") == "Suzanne D'Mello, Singer X"
|
||||
|
||||
def test_unicode_characters(self):
|
||||
"""Test with various unicode characters"""
|
||||
assert normalize_artist_name("周杰伦/王力宏") == "周杰伦, 王力宏"
|
||||
assert normalize_artist_name("Björk/Sigur Rós") == "Björk, Sigur Rós"
|
||||
assert normalize_artist_name("Café Tacvba/Molotov") == "Café Tacvba, Molotov"
|
||||
|
||||
|
||||
class TestArtistListProperty:
|
||||
"""Test cases for Music.artist_list property"""
|
||||
|
||||
def test_artist_list_parsing(self):
|
||||
"""Test that artist_list correctly parses the artist field"""
|
||||
from app.models.models import Music
|
||||
|
||||
# Create a mock music object
|
||||
music = Music(
|
||||
title="Test Song",
|
||||
artist="蒋明, 冬子, 刘东明",
|
||||
file_path="test.mp3"
|
||||
)
|
||||
|
||||
assert music.artist_list == ["蒋明", "冬子", "刘东明"]
|
||||
|
||||
def test_artist_list_single(self):
|
||||
"""Test artist_list with single artist"""
|
||||
from app.models.models import Music
|
||||
|
||||
music = Music(
|
||||
title="Test Song",
|
||||
artist="Jay Chou",
|
||||
file_path="test.mp3"
|
||||
)
|
||||
|
||||
assert music.artist_list == ["Jay Chou"]
|
||||
|
||||
def test_display_artist(self):
|
||||
"""Test display_artist property"""
|
||||
from app.models.models import Music
|
||||
|
||||
music = Music(
|
||||
title="Test Song",
|
||||
artist="蒋明, 冬子, 刘东明",
|
||||
file_path="test.mp3"
|
||||
)
|
||||
|
||||
assert music.display_artist == "蒋明, 冬子, 刘东明"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -20,6 +20,7 @@ export const musicApi = {
|
||||
getLyrics: (id: number) => api.get(`/music/${id}/lyrics`),
|
||||
update: (id: number, data: any) => api.put(`/music/${id}`, data),
|
||||
delete: (id: number) => api.delete(`/music/${id}`),
|
||||
rescan: (id: number) => api.post(`/music/${id}/rescan`),
|
||||
upload: (file: File) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
@@ -81,6 +82,11 @@ export const artistApi = {
|
||||
}),
|
||||
getArtistInfo: (artistName: string) => api.get(`/artists/info`, { params: { artist_name: artistName } }),
|
||||
refreshArtistInfo: (artistName: string) => api.post(`/artists/refresh`, null, { params: { artist_name: artistName } }),
|
||||
getAllWithAliases: () => api.get('/artists/management/all-with-aliases'),
|
||||
mergeArtists: (data: { display_name: string; alias_names: string[] }) =>
|
||||
api.post('/artists/management/merge', data),
|
||||
deleteAliases: (displayName: string) =>
|
||||
api.delete(`/artists/management/aliases/${encodeURIComponent(displayName)}`),
|
||||
}
|
||||
|
||||
// Settings API
|
||||
|
||||
@@ -3,11 +3,12 @@ import { artistApi, autoDownloadApi } from '@/api/client'
|
||||
import { Music, ArtistInfo, ArtistSongsResponse, OnlineSong } from '@/types'
|
||||
import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ArrowLeft, Play, Loader2, Music2, RefreshCw, Download, Edit } from 'lucide-react'
|
||||
import { ArrowLeft, Play, Loader2, Music2, RefreshCw, Download, Edit, Settings } from 'lucide-react'
|
||||
import { formatDuration } from '@/lib/utils'
|
||||
import { toast } from 'sonner'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import EditMetadataDialog from '../music/EditMetadataDialog'
|
||||
import ManageArtistDialog from './ManageArtistDialog'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -32,6 +33,7 @@ export default function ArtistDetailPage({ onPlayMusic }: ArtistDetailProps) {
|
||||
const highlightSongId = searchParams.get('highlight') ? Number(searchParams.get('highlight')) : null
|
||||
const songRefs = useRef<{ [key: number]: HTMLDivElement | null }>({})
|
||||
const [editingMusic, setEditingMusic] = useState<Music | null>(null)
|
||||
const [managingArtist, setManagingArtist] = useState<{ name: string; song_count: number; aliases: string[] } | null>(null)
|
||||
|
||||
const { data: artistInfo } = useQuery({
|
||||
queryKey: ['artist-info', decodedArtistName],
|
||||
@@ -150,6 +152,20 @@ export default function ArtistDetailPage({ onPlayMusic }: ArtistDetailProps) {
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${refreshMutation.isPending ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setManagingArtist({
|
||||
name: decodedArtistName,
|
||||
song_count: allSongs?.total_local || 0,
|
||||
aliases: []
|
||||
})}
|
||||
className="text-white hover:bg-white/20"
|
||||
title="Manage artist name & aliases"
|
||||
>
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
Manage
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end gap-6">
|
||||
@@ -371,6 +387,13 @@ export default function ArtistDetailPage({ onPlayMusic }: ArtistDetailProps) {
|
||||
open={!!editingMusic}
|
||||
onClose={() => setEditingMusic(null)}
|
||||
/>
|
||||
|
||||
{/* Manage Artist Dialog */}
|
||||
<ManageArtistDialog
|
||||
artist={managingArtist}
|
||||
open={!!managingArtist}
|
||||
onClose={() => setManagingArtist(null)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { artistApi } from '@/api/client'
|
||||
import { Artist, ArtistInfo } from '@/types'
|
||||
import { ArtistInfo } from '@/types'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Music2, Loader2, ArrowUpDown } from 'lucide-react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
@@ -14,6 +14,12 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
|
||||
interface ArtistWithAliases {
|
||||
name: string
|
||||
song_count: number
|
||||
aliases: string[]
|
||||
}
|
||||
|
||||
export default function ArtistsPage() {
|
||||
const navigate = useNavigate()
|
||||
const [artistsWithImages, setArtistsWithImages] = useState<Map<string, string>>(new Map())
|
||||
@@ -21,36 +27,47 @@ export default function ArtistsPage() {
|
||||
const [sortOrder, setSortOrder] = useState('desc')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
|
||||
// Use the new API that includes aliases
|
||||
const { data: artists, isLoading } = useQuery({
|
||||
queryKey: ['artists', sortBy, sortOrder],
|
||||
queryFn: async () => {
|
||||
const response = await artistApi.getAll(sortBy, sortOrder)
|
||||
return response.data as Artist[]
|
||||
const response = await artistApi.getAllWithAliases()
|
||||
const data = response.data as ArtistWithAliases[]
|
||||
|
||||
// Sort the data
|
||||
const sorted = [...data].sort((a, b) => {
|
||||
if (sortBy === 'song_count') {
|
||||
return sortOrder === 'asc' ? a.song_count - b.song_count : b.song_count - a.song_count
|
||||
} else {
|
||||
return sortOrder === 'asc' ? a.name.localeCompare(b.name) : b.name.localeCompare(a.name)
|
||||
}
|
||||
})
|
||||
|
||||
return sorted
|
||||
},
|
||||
})
|
||||
|
||||
// Search query
|
||||
const { data: searchResults } = useQuery({
|
||||
queryKey: ['artists-search', searchQuery],
|
||||
queryFn: async () => {
|
||||
if (!searchQuery.trim()) return null
|
||||
const response = await artistApi.search(searchQuery)
|
||||
return response.data as Artist[]
|
||||
},
|
||||
enabled: searchQuery.trim().length > 0,
|
||||
})
|
||||
// Search query - use useMemo to prevent re-filtering on every render
|
||||
const displayArtists = useMemo(() => {
|
||||
if (!artists) return artists
|
||||
if (!searchQuery.trim()) return artists
|
||||
|
||||
const displayArtists = searchQuery.trim() ? searchResults : artists
|
||||
const lowerQuery = searchQuery.toLowerCase()
|
||||
return artists.filter(artist =>
|
||||
artist.name.toLowerCase().includes(lowerQuery) ||
|
||||
artist.aliases.some(alias => alias.toLowerCase().includes(lowerQuery))
|
||||
)
|
||||
}, [artists, searchQuery])
|
||||
|
||||
// Fetch artist images
|
||||
// Fetch artist images - only for the base artists list, not filtered results
|
||||
useEffect(() => {
|
||||
if (!displayArtists) return
|
||||
if (!artists) return
|
||||
|
||||
const fetchArtistImages = async () => {
|
||||
const imageMap = new Map<string, string>()
|
||||
|
||||
// Fetch images for all artists in parallel
|
||||
const promises = displayArtists.map(async (artist) => {
|
||||
const promises = artists.map(async (artist) => {
|
||||
try {
|
||||
const response = await artistApi.getArtistInfo(artist.name)
|
||||
const info = response.data as ArtistInfo
|
||||
@@ -67,7 +84,7 @@ export default function ArtistsPage() {
|
||||
}
|
||||
|
||||
fetchArtistImages()
|
||||
}, [displayArtists])
|
||||
}, [artists])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -146,6 +163,11 @@ export default function ArtistsPage() {
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{artist.song_count} {artist.song_count === 1 ? 'song' : 'songs'}
|
||||
</p>
|
||||
{artist.aliases && artist.aliases.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
+{artist.aliases.length} {artist.aliases.length === 1 ? 'alias' : 'aliases'}
|
||||
</p>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { artistApi } from '@/api/client'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Loader2, X, Plus } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface ArtistWithAliases {
|
||||
name: string
|
||||
song_count: number
|
||||
aliases: string[]
|
||||
}
|
||||
|
||||
interface ManageArtistDialogProps {
|
||||
artist: ArtistWithAliases | null
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function ManageArtistDialog({ artist, open, onClose }: ManageArtistDialogProps) {
|
||||
const [displayName, setDisplayName] = useState('')
|
||||
const [aliases, setAliases] = useState<string[]>([])
|
||||
const [newAlias, setNewAlias] = useState('')
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
useEffect(() => {
|
||||
if (artist) {
|
||||
setDisplayName(artist.name)
|
||||
setAliases(artist.aliases || [])
|
||||
}
|
||||
}, [artist])
|
||||
|
||||
const mergeMutation = useMutation({
|
||||
mutationFn: async (data: { display_name: string; alias_names: string[] }) => {
|
||||
const response = await artistApi.mergeArtists(data)
|
||||
return response.data
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
toast.success(data.message || 'Artists merged successfully')
|
||||
queryClient.invalidateQueries({ queryKey: ['artists'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['artist-all-songs'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['music'] })
|
||||
onClose()
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to merge artists')
|
||||
},
|
||||
})
|
||||
|
||||
const handleAddAlias = () => {
|
||||
const trimmed = newAlias.trim()
|
||||
if (trimmed && !aliases.includes(trimmed) && trimmed !== displayName) {
|
||||
setAliases([...aliases, trimmed])
|
||||
setNewAlias('')
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveAlias = (alias: string) => {
|
||||
setAliases(aliases.filter(a => a !== alias))
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
if (!displayName.trim()) {
|
||||
toast.error('Display name is required')
|
||||
return
|
||||
}
|
||||
|
||||
mergeMutation.mutate({
|
||||
display_name: displayName.trim(),
|
||||
alias_names: aliases,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Manage Artist</DialogTitle>
|
||||
<DialogDescription>
|
||||
Set the main display name and add aliases to merge artists with different names.
|
||||
After merging, all songs will be grouped under the display name.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="displayName">Display Name *</Label>
|
||||
<Input
|
||||
id="displayName"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder="Enter main artist name"
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This will be the primary name shown everywhere
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Aliases (Alternative Names)</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={newAlias}
|
||||
onChange={(e) => setNewAlias(e.target.value)}
|
||||
placeholder="Enter an alias name"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleAddAlias()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleAddAlias}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Add all alternative names (e.g., English name, Chinese name, nicknames)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{aliases.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label>Current Aliases</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{aliases.map((alias) => (
|
||||
<Badge key={alias} variant="secondary" className="gap-1">
|
||||
{alias}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveAlias(alias)}
|
||||
className="ml-1 hover:text-destructive"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={mergeMutation.isPending}>
|
||||
{mergeMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Merge & Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -56,6 +56,32 @@ export default function EditMetadataDialog({ music, open, onClose }: EditMetadat
|
||||
},
|
||||
})
|
||||
|
||||
const rescanMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!music) return
|
||||
const response = await musicApi.rescan(music.id)
|
||||
return response.data
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
if (data) {
|
||||
setTitle(data.music.title || '')
|
||||
setArtist(data.music.artist || '')
|
||||
setAlbum(data.music.album || '')
|
||||
toast.success(`Rescanned: ${data.old_artist} → ${data.new_artist}`)
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['music'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['artist-all-songs'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['playlists'] })
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to rescan metadata')
|
||||
},
|
||||
})
|
||||
|
||||
const handleRescan = () => {
|
||||
rescanMutation.mutate()
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
updateMutation.mutate({
|
||||
@@ -120,7 +146,17 @@ export default function EditMetadataDialog({ music, open, onClose }: EditMetadat
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogFooter className="flex justify-between items-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={handleRescan}
|
||||
disabled={rescanMutation.isPending}
|
||||
>
|
||||
{rescanMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Rescan from File
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
@@ -128,6 +164,7 @@ export default function EditMetadataDialog({ music, open, onClose }: EditMetadat
|
||||
{updateMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
interface ArtistLinksProps {
|
||||
artist: string | null | undefined
|
||||
musicId?: number
|
||||
className?: string
|
||||
maxDisplay?: number
|
||||
}
|
||||
|
||||
export default function ArtistLinks({ artist, musicId, className = '', maxDisplay }: ArtistLinksProps) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
if (!artist || artist === 'Unknown') {
|
||||
return <span className={className}>Unknown Artist</span>
|
||||
}
|
||||
|
||||
// Split by comma or slash to get individual artists
|
||||
const artists = artist.split(/[,/]/).map(a => a.trim()).filter(a => a)
|
||||
|
||||
if (artists.length === 0) {
|
||||
return <span className={className}>Unknown Artist</span>
|
||||
}
|
||||
|
||||
// If only one artist, simple link
|
||||
if (artists.length === 1) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => navigate(`/artists/${encodeURIComponent(artists[0])}${musicId ? `?highlight=${musicId}` : ''}`)}
|
||||
className={className}
|
||||
>
|
||||
{artists[0]}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// Multiple artists - show as separate links
|
||||
const displayArtists = maxDisplay && artists.length > maxDisplay
|
||||
? artists.slice(0, maxDisplay)
|
||||
: artists
|
||||
const remaining = maxDisplay && artists.length > maxDisplay
|
||||
? artists.length - maxDisplay
|
||||
: 0
|
||||
|
||||
return (
|
||||
<span className={className}>
|
||||
{displayArtists.map((artistName, index) => (
|
||||
<span key={artistName}>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
navigate(`/artists/${encodeURIComponent(artistName)}${musicId ? `?highlight=${musicId}` : ''}`)
|
||||
}}
|
||||
className="hover:underline"
|
||||
>
|
||||
{artistName}
|
||||
</button>
|
||||
{index < displayArtists.length - 1 && <span>, </span>}
|
||||
</span>
|
||||
))}
|
||||
{remaining > 0 && (
|
||||
<span className="opacity-70"> +{remaining}</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { formatDuration } from '@/lib/utils'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { musicApi } from '@/api/client'
|
||||
import React from 'react'
|
||||
import ArtistLinks from './ArtistLinks'
|
||||
|
||||
interface FullScreenPlayerProps {
|
||||
currentMusic: Music
|
||||
@@ -20,7 +21,7 @@ interface FullScreenPlayerProps {
|
||||
onToggleLike: () => void
|
||||
onAddToPlaylist: () => void
|
||||
onShare: () => void
|
||||
onNavigateToArtist: () => void
|
||||
onNavigateToArtist?: () => void // Optional now, not used but kept for backward compatibility
|
||||
showLyrics?: boolean
|
||||
onToggleLyrics?: () => void
|
||||
onSeek?: (value: number[]) => void
|
||||
@@ -41,7 +42,7 @@ export default function FullScreenPlayer({
|
||||
onToggleLike,
|
||||
onAddToPlaylist,
|
||||
onShare,
|
||||
onNavigateToArtist,
|
||||
// onNavigateToArtist is not used anymore, using ArtistLinks component instead
|
||||
showLyrics = false,
|
||||
onToggleLyrics,
|
||||
onSeek,
|
||||
@@ -128,18 +129,12 @@ export default function FullScreenPlayer({
|
||||
{/* Song Info */}
|
||||
<div className={`text-center max-w-lg w-full ${showLyrics ? 'mb-3 md:mb-6' : 'mb-6'}`}>
|
||||
<h1 className={showLyrics ? 'text-2xl md:text-3xl font-bold mb-1 md:mb-2' : 'text-3xl font-bold mb-2'}>{currentMusic.title}</h1>
|
||||
{currentMusic.artist && currentMusic.artist !== 'Unknown' ? (
|
||||
<button
|
||||
onClick={onNavigateToArtist}
|
||||
className={showLyrics ? 'text-lg md:text-xl text-muted-foreground hover:underline' : 'text-xl text-muted-foreground hover:underline'}
|
||||
>
|
||||
{currentMusic.artist}
|
||||
</button>
|
||||
) : (
|
||||
<p className={showLyrics ? 'text-lg md:text-xl text-muted-foreground' : 'text-xl text-muted-foreground'}>
|
||||
{currentMusic.artist || 'Unknown Artist'}
|
||||
</p>
|
||||
)}
|
||||
<div className={showLyrics ? 'text-lg md:text-xl text-muted-foreground' : 'text-xl text-muted-foreground'}>
|
||||
<ArtistLinks
|
||||
artist={currentMusic.artist}
|
||||
musicId={currentMusic.id}
|
||||
/>
|
||||
</div>
|
||||
{currentMusic.album && (
|
||||
<p className={showLyrics ? 'text-xs md:text-sm text-muted-foreground mt-1' : 'text-sm text-muted-foreground mt-1'}>{currentMusic.album}</p>
|
||||
)}
|
||||
|
||||
@@ -4,12 +4,14 @@ import { useNavigate } from 'react-router-dom'
|
||||
import { Music } from '@/types'
|
||||
import { Slider } from '@/components/ui/slider'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Heart, ListPlus, Repeat, Repeat1, Shuffle, Share2, Maximize2, MessageSquareText } from 'lucide-react'
|
||||
import { Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Heart, ListPlus, Repeat, Repeat1, Shuffle, Share2, Maximize2, MessageSquareText, Edit } from 'lucide-react'
|
||||
import { formatDuration } from '@/lib/utils'
|
||||
import { playlistApi, musicApi } from '@/api/client'
|
||||
import { toast } from 'sonner'
|
||||
import PlaylistSelector from './PlaylistSelector'
|
||||
import FullScreenPlayer from './FullScreenPlayer'
|
||||
import EditMetadataDialog from '../music/EditMetadataDialog'
|
||||
import ArtistLinks from './ArtistLinks'
|
||||
|
||||
interface PlayerProps {
|
||||
currentMusic: Music | null
|
||||
@@ -40,6 +42,7 @@ export default function Player({
|
||||
const [showFullScreen, setShowFullScreen] = useState(false)
|
||||
const [showLyrics, setShowLyrics] = useState(false)
|
||||
const [isLiked, setIsLiked] = useState(false)
|
||||
const [showEditMetadata, setShowEditMetadata] = useState(false)
|
||||
const queryClient = useQueryClient()
|
||||
const navigate = useNavigate()
|
||||
|
||||
@@ -280,26 +283,17 @@ export default function Player({
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3
|
||||
className="font-semibold truncate cursor-pointer hover:underline"
|
||||
onClick={() => {
|
||||
if (currentMusic.artist && currentMusic.artist !== 'Unknown') {
|
||||
navigate(`/artists/${encodeURIComponent(currentMusic.artist)}?highlight=${currentMusic.id}`)
|
||||
}
|
||||
}}
|
||||
onClick={() => setShowFullScreen(true)}
|
||||
>
|
||||
{currentMusic.title}
|
||||
</h3>
|
||||
{currentMusic.artist && currentMusic.artist !== 'Unknown' ? (
|
||||
<button
|
||||
onClick={() => navigate(`/artists/${encodeURIComponent(currentMusic.artist!)}?highlight=${currentMusic.id}`)}
|
||||
className="text-sm text-muted-foreground truncate hover:underline text-left"
|
||||
>
|
||||
{currentMusic.artist}
|
||||
</button>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground truncate">
|
||||
{currentMusic.artist || 'Unknown Artist'}
|
||||
</p>
|
||||
)}
|
||||
<div className="text-sm text-muted-foreground truncate">
|
||||
<ArtistLinks
|
||||
artist={currentMusic.artist}
|
||||
musicId={currentMusic.id}
|
||||
maxDisplay={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -354,6 +348,16 @@ export default function Player({
|
||||
>
|
||||
<MessageSquareText className="h-5 w-5" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setShowEditMetadata(true)}
|
||||
className="hidden md:flex"
|
||||
title="Edit metadata"
|
||||
>
|
||||
<Edit className="h-5 w-5" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -476,6 +480,15 @@ export default function Player({
|
||||
onTogglePlayMode={onTogglePlayMode}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Edit Metadata Dialog */}
|
||||
{showEditMetadata && currentMusic && (
|
||||
<EditMetadataDialog
|
||||
music={currentMusic}
|
||||
open={showEditMetadata}
|
||||
onClose={() => setShowEditMetadata(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Unit tests for artist formatting utility.
|
||||
*
|
||||
* This ensures that multi-artist names are displayed correctly:
|
||||
* - All artists shown when space permits
|
||||
* - Smart truncation with "FirstArtist, +N" when space is limited
|
||||
* - Proper handling of single vs multiple artists
|
||||
*/
|
||||
|
||||
import { formatArtist } from '../utils'
|
||||
|
||||
describe('formatArtist', () => {
|
||||
describe('Basic functionality', () => {
|
||||
test('returns "Unknown Artist" for null or undefined', () => {
|
||||
expect(formatArtist(null)).toBe('Unknown Artist')
|
||||
expect(formatArtist(undefined)).toBe('Unknown Artist')
|
||||
expect(formatArtist('Unknown')).toBe('Unknown Artist')
|
||||
})
|
||||
|
||||
test('returns artist name as-is when no maxLength specified', () => {
|
||||
expect(formatArtist('Jay Chou')).toBe('Jay Chou')
|
||||
expect(formatArtist('Taylor Swift, Ed Sheeran')).toBe('Taylor Swift, Ed Sheeran')
|
||||
expect(formatArtist('蒋明, 冬子, 刘东明')).toBe('蒋明, 冬子, 刘东明')
|
||||
})
|
||||
|
||||
test('returns artist name as-is when within maxLength', () => {
|
||||
expect(formatArtist('Jay Chou', 50)).toBe('Jay Chou')
|
||||
expect(formatArtist('Taylor Swift', 20)).toBe('Taylor Swift')
|
||||
expect(formatArtist('蒋明', 10)).toBe('蒋明')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Single artist truncation', () => {
|
||||
test('truncates single artist with ellipsis when too long', () => {
|
||||
expect(formatArtist('Very Long Artist Name Here', 15)).toBe('Very Long Ar...')
|
||||
expect(formatArtist('周杰伦的完整艺名很长', 10)).toBe('周杰伦的完整...')
|
||||
})
|
||||
|
||||
test('truncates to exactly maxLength characters', () => {
|
||||
const result = formatArtist('This is a very long artist name', 20)
|
||||
expect(result.length).toBe(20)
|
||||
expect(result.endsWith('...')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Multiple artists display', () => {
|
||||
test('shows all artists when space permits', () => {
|
||||
expect(formatArtist('A, B, C', 50)).toBe('A, B, C')
|
||||
expect(formatArtist('蒋明, 冬子', 20)).toBe('蒋明, 冬子')
|
||||
expect(formatArtist('Taylor Swift, Ed Sheeran', 30)).toBe('Taylor Swift, Ed Sheeran')
|
||||
})
|
||||
|
||||
test('shows "FirstArtist, +N" format when space is limited', () => {
|
||||
expect(formatArtist('蒋明, 冬子, 刘东明', 10)).toBe('蒋明, +2')
|
||||
expect(formatArtist('A, B, C, D, E', 8)).toBe('A, +4')
|
||||
expect(formatArtist('Taylor Swift, Ed Sheeran, Artist3', 20)).toBe('Taylor Swift, +2')
|
||||
})
|
||||
|
||||
test('handles comma-separated artists', () => {
|
||||
const artists = '蒋明, 冬子, 刘东明, 好妹妹乐队, 钟立风, 小河'
|
||||
expect(formatArtist(artists, 15)).toBe('蒋明, +5')
|
||||
})
|
||||
|
||||
test('handles slash-separated artists', () => {
|
||||
const artists = 'Justin Timberlake/Carey Mulligan/Stark Sands'
|
||||
expect(formatArtist(artists, 25)).toBe('Justin Timberlake, +2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge cases', () => {
|
||||
test('handles very short maxLength', () => {
|
||||
expect(formatArtist('A, B, C, D, E, F', 5)).toBe('+6')
|
||||
expect(formatArtist('蒋明, 冬子, 刘东明', 3)).toBe('+3')
|
||||
})
|
||||
|
||||
test('handles two artists', () => {
|
||||
expect(formatArtist('Artist A, Artist B', 20)).toBe('Artist A, +1')
|
||||
expect(formatArtist('A, B', 50)).toBe('A, B')
|
||||
})
|
||||
|
||||
test('handles single artist that equals maxLength', () => {
|
||||
expect(formatArtist('TenChars!!', 10)).toBe('TenChars!!')
|
||||
})
|
||||
|
||||
test('handles artist with special characters', () => {
|
||||
expect(formatArtist('Björk, Sigur Rós', 20)).toBe('Björk, Sigur Rós')
|
||||
expect(formatArtist('Café Tacvba, Molotov', 15)).toBe('Café Tacvba, +1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Real-world examples', () => {
|
||||
test('formats real multi-artist songs correctly', () => {
|
||||
// From actual music library
|
||||
expect(formatArtist('Justin Timberlake, Carey Mulligan, Stark Sands', 30))
|
||||
.toBe('Justin Timberlake, +2')
|
||||
|
||||
expect(formatArtist('Calvin Harris, John Newman', 50))
|
||||
.toBe('Calvin Harris, John Newman')
|
||||
|
||||
expect(formatArtist('G.V. Prakash Kumar, Bela Shende', 25))
|
||||
.toBe('G.V. Prakash Kumar, +1')
|
||||
|
||||
expect(formatArtist('Mohit Chauhan, Suzanne D\'Mello', 40))
|
||||
.toBe('Mohit Chauhan, Suzanne D\'Mello')
|
||||
})
|
||||
|
||||
test('handles 6 artists with limited space', () => {
|
||||
const artists = '蒋明, 冬子, 刘东明, 好妹妹乐队, 钟立风, 小河'
|
||||
expect(formatArtist(artists, 20)).toBe('蒋明, +5')
|
||||
expect(formatArtist(artists, 50)).toBe(artists)
|
||||
})
|
||||
|
||||
test('formats player display (limited space)', () => {
|
||||
// Typical player shows ~30 chars
|
||||
expect(formatArtist('Taylor Swift, Ed Sheeran, Bruno Mars', 30))
|
||||
.toBe('Taylor Swift, +2')
|
||||
|
||||
expect(formatArtist('周杰伦, 王力宏, 陶喆, 林俊杰', 15))
|
||||
.toBe('周杰伦, +3')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Consistency with backend normalization', () => {
|
||||
test('handles backend normalized format (comma-space)', () => {
|
||||
// Backend normalizes to ", " separator
|
||||
expect(formatArtist('蒋明, 冬子, 刘东明', 50))
|
||||
.toBe('蒋明, 冬子, 刘东明')
|
||||
|
||||
expect(formatArtist('Artist1, Artist2, Artist3', 20))
|
||||
.toBe('Artist1, +2')
|
||||
})
|
||||
|
||||
test('handles legacy slash format', () => {
|
||||
// In case some old data still has slashes
|
||||
expect(formatArtist('A/B/C', 50)).toBe('A/B/C')
|
||||
expect(formatArtist('蒋明/冬子/刘东明', 10)).toBe('蒋明, +2')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -11,3 +11,39 @@ export function formatDuration(seconds: number): string {
|
||||
const secs = Math.floor(seconds % 60)
|
||||
return `${mins}:${String(secs).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function formatArtist(artist: string | null | undefined, maxLength?: number): string {
|
||||
if (!artist || artist === 'Unknown') return 'Unknown Artist'
|
||||
|
||||
// If no max length specified, return as is
|
||||
if (!maxLength) return artist
|
||||
|
||||
// If within length, return as is
|
||||
if (artist.length <= maxLength) return artist
|
||||
|
||||
// Split by comma or slash to get individual artists
|
||||
const artists = artist.split(/[,/]/).map(a => a.trim())
|
||||
|
||||
// If single artist, truncate with ellipsis
|
||||
if (artists.length === 1) {
|
||||
return artist.substring(0, maxLength - 3) + '...'
|
||||
}
|
||||
|
||||
// For multiple artists, show "FirstArtist, ..."
|
||||
const first = artists[0]
|
||||
const remaining = artists.length - 1
|
||||
const formatted = `${first}, +${remaining}`
|
||||
|
||||
if (formatted.length <= maxLength) {
|
||||
return formatted
|
||||
}
|
||||
|
||||
// If even that's too long, truncate the first artist
|
||||
const available = maxLength - `, +${remaining}`.length - 3
|
||||
if (available > 0) {
|
||||
return `${first.substring(0, available)}..., +${remaining}`
|
||||
}
|
||||
|
||||
// Last resort
|
||||
return `+${artists.length}`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: './src/test/setup.ts',
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user