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)
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""Get all songs by artist: local + online (from Deezer)"""
|
"""Get all songs by artist: local + online (from Deezer)"""
|
||||||
from sqlalchemy import asc, desc
|
from sqlalchemy import asc, desc, or_
|
||||||
|
|
||||||
# Fetch local songs
|
# Fetch local songs - search for artist name in the artist field
|
||||||
query = select(Music).where(Music.artist == artist_name)
|
# 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)
|
sort_column = getattr(Music, sort_by)
|
||||||
if sort_order == "asc":
|
if sort_order == "asc":
|
||||||
query = query.order_by(asc(sort_column))
|
query = query.order_by(asc(sort_column))
|
||||||
@@ -482,9 +490,18 @@ async def get_artist_songs(
|
|||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""Get all songs by a specific artist with sorting"""
|
"""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
|
# Apply sorting
|
||||||
sort_column = getattr(Music, sort_by)
|
sort_column = getattr(Music, sort_by)
|
||||||
@@ -496,3 +513,163 @@ async def get_artist_songs(
|
|||||||
result = await db.execute(query)
|
result = await db.execute(query)
|
||||||
songs = result.scalars().all()
|
songs = result.scalars().all()
|
||||||
return songs
|
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.downloader import music_downloader
|
||||||
from app.services.download_queue import download_queue
|
from app.services.download_queue import download_queue
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
from app.api.music import normalize_artist_name
|
||||||
import os
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
@@ -39,10 +40,14 @@ async def process_download(
|
|||||||
relative_path = os.path.relpath(file_path, settings.MUSIC_DIR)
|
relative_path = os.path.relpath(file_path, settings.MUSIC_DIR)
|
||||||
file_extension = os.path.splitext(file_path)[1][1:] # Get extension without dot
|
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
|
# Create database record
|
||||||
db_music = Music(
|
db_music = Music(
|
||||||
title=metadata.get("title", title or "Unknown"),
|
title=metadata.get("title", title or "Unknown"),
|
||||||
artist=metadata.get("artist", "Unknown"),
|
artist=normalized_artist,
|
||||||
album=metadata.get("album", ""),
|
album=metadata.get("album", ""),
|
||||||
duration=metadata.get("duration", 0),
|
duration=metadata.get("duration", 0),
|
||||||
file_path=relative_path,
|
file_path=relative_path,
|
||||||
|
|||||||
+100
-5
@@ -6,6 +6,7 @@ from typing import List, Optional
|
|||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
|
||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
from app.models.models import Music
|
from app.models.models import Music
|
||||||
@@ -16,6 +17,79 @@ from app.core.config import settings
|
|||||||
router = APIRouter()
|
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")
|
@router.get("/stats")
|
||||||
async def get_music_stats(db: AsyncSession = Depends(get_db)):
|
async def get_music_stats(db: AsyncSession = Depends(get_db)):
|
||||||
"""Get music library statistics"""
|
"""Get music library statistics"""
|
||||||
@@ -222,13 +296,17 @@ async def upload_music(
|
|||||||
# Extract metadata
|
# Extract metadata
|
||||||
metadata = await music_downloader.get_music_metadata(file_path)
|
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
|
# Get file format
|
||||||
file_extension = os.path.splitext(file.filename)[1][1:]
|
file_extension = os.path.splitext(file.filename)[1][1:]
|
||||||
|
|
||||||
# Create database record
|
# Create database record
|
||||||
db_music = Music(
|
db_music = Music(
|
||||||
title=metadata.get("title", file.filename),
|
title=metadata.get("title", file.filename),
|
||||||
artist=metadata.get("artist", "Unknown"),
|
artist=normalized_artist,
|
||||||
album=metadata.get("album", ""),
|
album=metadata.get("album", ""),
|
||||||
duration=metadata.get("duration", 0),
|
duration=metadata.get("duration", 0),
|
||||||
file_path=os.path.join("uploads", file.filename),
|
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"""
|
"""Scan music directory and add new files to database"""
|
||||||
music_dir = Path(settings.MUSIC_DIR)
|
music_dir = Path(settings.MUSIC_DIR)
|
||||||
added_count = 0
|
added_count = 0
|
||||||
|
updated_count = 0
|
||||||
|
|
||||||
for file_path in music_dir.rglob("*"):
|
for file_path in music_dir.rglob("*"):
|
||||||
if file_path.is_file() and file_path.suffix.lower() in ['.mp3', '.m4a', '.flac', '.wav']:
|
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()
|
existing = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
# 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:
|
if not existing:
|
||||||
# Add to database
|
# Add to database
|
||||||
metadata = await music_downloader.get_music_metadata(str(file_path))
|
|
||||||
|
|
||||||
db_music = Music(
|
db_music = Music(
|
||||||
title=metadata.get("title", file_path.name),
|
title=metadata.get("title", file_path.name),
|
||||||
artist=metadata.get("artist", "Unknown"),
|
artist=normalized_artist,
|
||||||
album=metadata.get("album", ""),
|
album=metadata.get("album", ""),
|
||||||
duration=metadata.get("duration", 0),
|
duration=metadata.get("duration", 0),
|
||||||
file_path=relative_path,
|
file_path=relative_path,
|
||||||
@@ -294,10 +378,21 @@ async def scan_music_directory(db: AsyncSession = Depends(get_db)):
|
|||||||
|
|
||||||
db.add(db_music)
|
db.add(db_music)
|
||||||
added_count += 1
|
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()
|
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}")
|
@router.get("/file/{music_id}")
|
||||||
|
|||||||
@@ -40,6 +40,25 @@ class Music(Base):
|
|||||||
|
|
||||||
playlists = relationship("Playlist", secondary=playlist_music, back_populates="music_items")
|
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):
|
def generate_share_token(self, expiration_days: int = 14):
|
||||||
"""Generate a secure random share token with expiration"""
|
"""Generate a secure random share token with expiration"""
|
||||||
if not self.share_token:
|
if not self.share_token:
|
||||||
@@ -120,10 +139,15 @@ class ScanHistory(Base):
|
|||||||
errors_count = Column(Integer, default=0)
|
errors_count = Column(Integer, default=0)
|
||||||
status = Column(String, default="in_progress") # in_progress, completed, failed
|
status = Column(String, default="in_progress") # in_progress, completed, failed
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
key = Column(String, unique=True, index=True)
|
|
||||||
name = Column(String)
|
|
||||||
description = Column(Text, nullable=True)
|
class ArtistAlias(Base):
|
||||||
is_active = Column(Boolean, default=True)
|
"""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)
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
expires_at = Column(DateTime, nullable=True)
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
last_used_at = Column(DateTime, nullable=True)
|
|
||||||
|
|||||||
@@ -26,4 +26,8 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[tool.uv]
|
[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`),
|
getLyrics: (id: number) => api.get(`/music/${id}/lyrics`),
|
||||||
update: (id: number, data: any) => api.put(`/music/${id}`, data),
|
update: (id: number, data: any) => api.put(`/music/${id}`, data),
|
||||||
delete: (id: number) => api.delete(`/music/${id}`),
|
delete: (id: number) => api.delete(`/music/${id}`),
|
||||||
|
rescan: (id: number) => api.post(`/music/${id}/rescan`),
|
||||||
upload: (file: File) => {
|
upload: (file: File) => {
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('file', file)
|
formData.append('file', file)
|
||||||
@@ -81,6 +82,11 @@ export const artistApi = {
|
|||||||
}),
|
}),
|
||||||
getArtistInfo: (artistName: string) => api.get(`/artists/info`, { params: { artist_name: artistName } }),
|
getArtistInfo: (artistName: string) => api.get(`/artists/info`, { params: { artist_name: artistName } }),
|
||||||
refreshArtistInfo: (artistName: string) => api.post(`/artists/refresh`, null, { 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
|
// Settings API
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ import { artistApi, autoDownloadApi } from '@/api/client'
|
|||||||
import { Music, ArtistInfo, ArtistSongsResponse, OnlineSong } from '@/types'
|
import { Music, ArtistInfo, ArtistSongsResponse, OnlineSong } from '@/types'
|
||||||
import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
|
import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
import { Button } from '@/components/ui/button'
|
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 { formatDuration } from '@/lib/utils'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import EditMetadataDialog from '../music/EditMetadataDialog'
|
import EditMetadataDialog from '../music/EditMetadataDialog'
|
||||||
|
import ManageArtistDialog from './ManageArtistDialog'
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -32,6 +33,7 @@ export default function ArtistDetailPage({ onPlayMusic }: ArtistDetailProps) {
|
|||||||
const highlightSongId = searchParams.get('highlight') ? Number(searchParams.get('highlight')) : null
|
const highlightSongId = searchParams.get('highlight') ? Number(searchParams.get('highlight')) : null
|
||||||
const songRefs = useRef<{ [key: number]: HTMLDivElement | null }>({})
|
const songRefs = useRef<{ [key: number]: HTMLDivElement | null }>({})
|
||||||
const [editingMusic, setEditingMusic] = useState<Music | null>(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({
|
const { data: artistInfo } = useQuery({
|
||||||
queryKey: ['artist-info', decodedArtistName],
|
queryKey: ['artist-info', decodedArtistName],
|
||||||
@@ -150,6 +152,20 @@ export default function ArtistDetailPage({ onPlayMusic }: ArtistDetailProps) {
|
|||||||
>
|
>
|
||||||
<RefreshCw className={`h-4 w-4 ${refreshMutation.isPending ? 'animate-spin' : ''}`} />
|
<RefreshCw className={`h-4 w-4 ${refreshMutation.isPending ? 'animate-spin' : ''}`} />
|
||||||
</Button>
|
</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>
|
||||||
|
|
||||||
<div className="flex items-end gap-6">
|
<div className="flex items-end gap-6">
|
||||||
@@ -371,6 +387,13 @@ export default function ArtistDetailPage({ onPlayMusic }: ArtistDetailProps) {
|
|||||||
open={!!editingMusic}
|
open={!!editingMusic}
|
||||||
onClose={() => setEditingMusic(null)}
|
onClose={() => setEditingMusic(null)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Manage Artist Dialog */}
|
||||||
|
<ManageArtistDialog
|
||||||
|
artist={managingArtist}
|
||||||
|
open={!!managingArtist}
|
||||||
|
onClose={() => setManagingArtist(null)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { artistApi } from '@/api/client'
|
import { artistApi } from '@/api/client'
|
||||||
import { Artist, ArtistInfo } from '@/types'
|
import { ArtistInfo } from '@/types'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { Music2, Loader2, ArrowUpDown } from 'lucide-react'
|
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 { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import {
|
import {
|
||||||
@@ -14,6 +14,12 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
|
|
||||||
|
interface ArtistWithAliases {
|
||||||
|
name: string
|
||||||
|
song_count: number
|
||||||
|
aliases: string[]
|
||||||
|
}
|
||||||
|
|
||||||
export default function ArtistsPage() {
|
export default function ArtistsPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [artistsWithImages, setArtistsWithImages] = useState<Map<string, string>>(new Map())
|
const [artistsWithImages, setArtistsWithImages] = useState<Map<string, string>>(new Map())
|
||||||
@@ -21,36 +27,47 @@ export default function ArtistsPage() {
|
|||||||
const [sortOrder, setSortOrder] = useState('desc')
|
const [sortOrder, setSortOrder] = useState('desc')
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
|
||||||
|
// Use the new API that includes aliases
|
||||||
const { data: artists, isLoading } = useQuery({
|
const { data: artists, isLoading } = useQuery({
|
||||||
queryKey: ['artists', sortBy, sortOrder],
|
queryKey: ['artists', sortBy, sortOrder],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const response = await artistApi.getAll(sortBy, sortOrder)
|
const response = await artistApi.getAllWithAliases()
|
||||||
return response.data as Artist[]
|
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
|
// Search query - use useMemo to prevent re-filtering on every render
|
||||||
const { data: searchResults } = useQuery({
|
const displayArtists = useMemo(() => {
|
||||||
queryKey: ['artists-search', searchQuery],
|
if (!artists) return artists
|
||||||
queryFn: async () => {
|
if (!searchQuery.trim()) return artists
|
||||||
if (!searchQuery.trim()) return null
|
|
||||||
const response = await artistApi.search(searchQuery)
|
const lowerQuery = searchQuery.toLowerCase()
|
||||||
return response.data as Artist[]
|
return artists.filter(artist =>
|
||||||
},
|
artist.name.toLowerCase().includes(lowerQuery) ||
|
||||||
enabled: searchQuery.trim().length > 0,
|
artist.aliases.some(alias => alias.toLowerCase().includes(lowerQuery))
|
||||||
})
|
)
|
||||||
|
}, [artists, searchQuery])
|
||||||
|
|
||||||
const displayArtists = searchQuery.trim() ? searchResults : artists
|
// Fetch artist images - only for the base artists list, not filtered results
|
||||||
|
|
||||||
// Fetch artist images
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!displayArtists) return
|
if (!artists) return
|
||||||
|
|
||||||
const fetchArtistImages = async () => {
|
const fetchArtistImages = async () => {
|
||||||
const imageMap = new Map<string, string>()
|
const imageMap = new Map<string, string>()
|
||||||
|
|
||||||
// Fetch images for all artists in parallel
|
// Fetch images for all artists in parallel
|
||||||
const promises = displayArtists.map(async (artist) => {
|
const promises = artists.map(async (artist) => {
|
||||||
try {
|
try {
|
||||||
const response = await artistApi.getArtistInfo(artist.name)
|
const response = await artistApi.getArtistInfo(artist.name)
|
||||||
const info = response.data as ArtistInfo
|
const info = response.data as ArtistInfo
|
||||||
@@ -67,7 +84,7 @@ export default function ArtistsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fetchArtistImages()
|
fetchArtistImages()
|
||||||
}, [displayArtists])
|
}, [artists])
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -146,6 +163,11 @@ export default function ArtistsPage() {
|
|||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{artist.song_count} {artist.song_count === 1 ? 'song' : 'songs'}
|
{artist.song_count} {artist.song_count === 1 ? 'song' : 'songs'}
|
||||||
</p>
|
</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>
|
</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>) => {
|
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
updateMutation.mutate({
|
updateMutation.mutate({
|
||||||
@@ -120,14 +146,25 @@ export default function EditMetadataDialog({ music, open, onClose }: EditMetadat
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter className="flex justify-between items-center">
|
||||||
<Button type="button" variant="outline" onClick={onClose}>
|
<Button
|
||||||
Cancel
|
type="button"
|
||||||
</Button>
|
variant="secondary"
|
||||||
<Button type="submit" disabled={updateMutation.isPending}>
|
onClick={handleRescan}
|
||||||
{updateMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
disabled={rescanMutation.isPending}
|
||||||
Save Changes
|
>
|
||||||
|
{rescanMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
Rescan from File
|
||||||
</Button>
|
</Button>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button type="button" variant="outline" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={updateMutation.isPending}>
|
||||||
|
{updateMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
</DialogContent>
|
</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 { useQuery } from '@tanstack/react-query'
|
||||||
import { musicApi } from '@/api/client'
|
import { musicApi } from '@/api/client'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
import ArtistLinks from './ArtistLinks'
|
||||||
|
|
||||||
interface FullScreenPlayerProps {
|
interface FullScreenPlayerProps {
|
||||||
currentMusic: Music
|
currentMusic: Music
|
||||||
@@ -20,7 +21,7 @@ interface FullScreenPlayerProps {
|
|||||||
onToggleLike: () => void
|
onToggleLike: () => void
|
||||||
onAddToPlaylist: () => void
|
onAddToPlaylist: () => void
|
||||||
onShare: () => void
|
onShare: () => void
|
||||||
onNavigateToArtist: () => void
|
onNavigateToArtist?: () => void // Optional now, not used but kept for backward compatibility
|
||||||
showLyrics?: boolean
|
showLyrics?: boolean
|
||||||
onToggleLyrics?: () => void
|
onToggleLyrics?: () => void
|
||||||
onSeek?: (value: number[]) => void
|
onSeek?: (value: number[]) => void
|
||||||
@@ -41,7 +42,7 @@ export default function FullScreenPlayer({
|
|||||||
onToggleLike,
|
onToggleLike,
|
||||||
onAddToPlaylist,
|
onAddToPlaylist,
|
||||||
onShare,
|
onShare,
|
||||||
onNavigateToArtist,
|
// onNavigateToArtist is not used anymore, using ArtistLinks component instead
|
||||||
showLyrics = false,
|
showLyrics = false,
|
||||||
onToggleLyrics,
|
onToggleLyrics,
|
||||||
onSeek,
|
onSeek,
|
||||||
@@ -128,18 +129,12 @@ export default function FullScreenPlayer({
|
|||||||
{/* Song Info */}
|
{/* Song Info */}
|
||||||
<div className={`text-center max-w-lg w-full ${showLyrics ? 'mb-3 md:mb-6' : 'mb-6'}`}>
|
<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>
|
<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' ? (
|
<div className={showLyrics ? 'text-lg md:text-xl text-muted-foreground' : 'text-xl text-muted-foreground'}>
|
||||||
<button
|
<ArtistLinks
|
||||||
onClick={onNavigateToArtist}
|
artist={currentMusic.artist}
|
||||||
className={showLyrics ? 'text-lg md:text-xl text-muted-foreground hover:underline' : 'text-xl text-muted-foreground hover:underline'}
|
musicId={currentMusic.id}
|
||||||
>
|
/>
|
||||||
{currentMusic.artist}
|
</div>
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<p className={showLyrics ? 'text-lg md:text-xl text-muted-foreground' : 'text-xl text-muted-foreground'}>
|
|
||||||
{currentMusic.artist || 'Unknown Artist'}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{currentMusic.album && (
|
{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>
|
<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 { Music } from '@/types'
|
||||||
import { Slider } from '@/components/ui/slider'
|
import { Slider } from '@/components/ui/slider'
|
||||||
import { Button } from '@/components/ui/button'
|
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 { formatDuration } from '@/lib/utils'
|
||||||
import { playlistApi, musicApi } from '@/api/client'
|
import { playlistApi, musicApi } from '@/api/client'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import PlaylistSelector from './PlaylistSelector'
|
import PlaylistSelector from './PlaylistSelector'
|
||||||
import FullScreenPlayer from './FullScreenPlayer'
|
import FullScreenPlayer from './FullScreenPlayer'
|
||||||
|
import EditMetadataDialog from '../music/EditMetadataDialog'
|
||||||
|
import ArtistLinks from './ArtistLinks'
|
||||||
|
|
||||||
interface PlayerProps {
|
interface PlayerProps {
|
||||||
currentMusic: Music | null
|
currentMusic: Music | null
|
||||||
@@ -40,6 +42,7 @@ export default function Player({
|
|||||||
const [showFullScreen, setShowFullScreen] = useState(false)
|
const [showFullScreen, setShowFullScreen] = useState(false)
|
||||||
const [showLyrics, setShowLyrics] = useState(false)
|
const [showLyrics, setShowLyrics] = useState(false)
|
||||||
const [isLiked, setIsLiked] = useState(false)
|
const [isLiked, setIsLiked] = useState(false)
|
||||||
|
const [showEditMetadata, setShowEditMetadata] = useState(false)
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
@@ -280,26 +283,17 @@ export default function Player({
|
|||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h3
|
<h3
|
||||||
className="font-semibold truncate cursor-pointer hover:underline"
|
className="font-semibold truncate cursor-pointer hover:underline"
|
||||||
onClick={() => {
|
onClick={() => setShowFullScreen(true)}
|
||||||
if (currentMusic.artist && currentMusic.artist !== 'Unknown') {
|
|
||||||
navigate(`/artists/${encodeURIComponent(currentMusic.artist)}?highlight=${currentMusic.id}`)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{currentMusic.title}
|
{currentMusic.title}
|
||||||
</h3>
|
</h3>
|
||||||
{currentMusic.artist && currentMusic.artist !== 'Unknown' ? (
|
<div className="text-sm text-muted-foreground truncate">
|
||||||
<button
|
<ArtistLinks
|
||||||
onClick={() => navigate(`/artists/${encodeURIComponent(currentMusic.artist!)}?highlight=${currentMusic.id}`)}
|
artist={currentMusic.artist}
|
||||||
className="text-sm text-muted-foreground truncate hover:underline text-left"
|
musicId={currentMusic.id}
|
||||||
>
|
maxDisplay={3}
|
||||||
{currentMusic.artist}
|
/>
|
||||||
</button>
|
</div>
|
||||||
) : (
|
|
||||||
<p className="text-sm text-muted-foreground truncate">
|
|
||||||
{currentMusic.artist || 'Unknown Artist'}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -354,6 +348,16 @@ export default function Player({
|
|||||||
>
|
>
|
||||||
<MessageSquareText className="h-5 w-5" />
|
<MessageSquareText className="h-5 w-5" />
|
||||||
</Button>
|
</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}
|
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)
|
const secs = Math.floor(seconds % 60)
|
||||||
return `${mins}:${String(secs).padStart(2, '0')}`
|
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