mirror of
https://github.com/wahyd4/you-music.git
synced 2026-08-08 20:59:47 +10:00
add db migration
This commit is contained in:
@@ -27,6 +27,9 @@ frontend/.vite/
|
|||||||
*.db
|
*.db
|
||||||
*.sqlite
|
*.sqlite
|
||||||
|
|
||||||
|
# Alembic - Keep migration files but not pycache
|
||||||
|
backend/alembic/versions/__pycache__/
|
||||||
|
|
||||||
# Data directories
|
# Data directories
|
||||||
data/
|
data/
|
||||||
*.mp3
|
*.mp3
|
||||||
|
|||||||
@@ -82,6 +82,17 @@ frontend/src/
|
|||||||
|
|
||||||
## Database Schema
|
## Database Schema
|
||||||
|
|
||||||
|
**⚠️ IMPORTANT: Database Migrations**
|
||||||
|
|
||||||
|
This project uses **Alembic** for database migrations. When modifying database schema:
|
||||||
|
1. Never delete the database in production
|
||||||
|
2. Always create migrations: `cd backend && ./migrate.sh create "description"`
|
||||||
|
3. Review the generated migration in `alembic/versions/`
|
||||||
|
4. Apply with: `./migrate.sh upgrade`
|
||||||
|
5. See [MIGRATIONS.md](MIGRATIONS.md) for complete guide
|
||||||
|
|
||||||
|
Migrations run automatically on app startup, so existing deployments will auto-upgrade.
|
||||||
|
|
||||||
### Tables
|
### Tables
|
||||||
|
|
||||||
**music**
|
**music**
|
||||||
@@ -286,10 +297,37 @@ docker-compose up -d
|
|||||||
|
|
||||||
### Modifying Database Schema
|
### Modifying Database Schema
|
||||||
|
|
||||||
|
**IMPORTANT: We use Alembic for database migrations. Never delete the database in production!**
|
||||||
|
|
||||||
1. **Update model in `backend/app/models/models.py`**
|
1. **Update model in `backend/app/models/models.py`**
|
||||||
2. **Update schema in `backend/app/schemas/schemas.py`**
|
```python
|
||||||
3. **Delete database** `rm data/youmusic.db` (recreates on restart)
|
class Music(Base):
|
||||||
4. **Restart backend** to create new schema
|
# ... existing fields ...
|
||||||
|
new_field: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Create migration**
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
./migrate.sh create "Add new_field to music table"
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Review generated migration** in `backend/alembic/versions/*.py`
|
||||||
|
- Check auto-generated SQL is correct
|
||||||
|
- Edit if needed (e.g., for renaming columns, data migrations)
|
||||||
|
|
||||||
|
4. **Apply migration**
|
||||||
|
```bash
|
||||||
|
./migrate.sh upgrade
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Update schema in `backend/app/schemas/schemas.py`** if needed
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
- Migrations run automatically on app startup
|
||||||
|
- Never edit applied migrations - create new ones
|
||||||
|
- Use `./migrate.sh downgrade` to rollback if needed
|
||||||
|
- See [MIGRATIONS.md](MIGRATIONS.md) for complete guide
|
||||||
|
|
||||||
### Adding Download Source
|
### Adding Download Source
|
||||||
|
|
||||||
@@ -340,6 +378,7 @@ npm test
|
|||||||
- `uvicorn` - ASGI server
|
- `uvicorn` - ASGI server
|
||||||
- `sqlalchemy` - ORM
|
- `sqlalchemy` - ORM
|
||||||
- `aiosqlite` - Async SQLite driver
|
- `aiosqlite` - Async SQLite driver
|
||||||
|
- `alembic` - Database migrations
|
||||||
- `yt-dlp` - Universal downloader
|
- `yt-dlp` - Universal downloader
|
||||||
- `mutagen` - Audio metadata
|
- `mutagen` - Audio metadata
|
||||||
- `pydantic` - Data validation
|
- `pydantic` - Data validation
|
||||||
|
|||||||
+5
-2
@@ -31,6 +31,9 @@ RUN pip install --no-cache-dir -r requirements.txt
|
|||||||
# Copy backend code
|
# Copy backend code
|
||||||
COPY backend/ ./backend/
|
COPY backend/ ./backend/
|
||||||
|
|
||||||
|
# Make scripts executable
|
||||||
|
RUN chmod +x ./backend/run-migrations.sh ./backend/start.sh
|
||||||
|
|
||||||
# Copy frontend build from builder stage
|
# Copy frontend build from builder stage
|
||||||
COPY --from=frontend-builder /app/frontend/dist ./backend/static
|
COPY --from=frontend-builder /app/frontend/dist ./backend/static
|
||||||
|
|
||||||
@@ -61,5 +64,5 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
|||||||
# Set working directory to backend
|
# Set working directory to backend
|
||||||
WORKDIR /app/backend
|
WORKDIR /app/backend
|
||||||
|
|
||||||
# Run the application
|
# Run migrations and start the application
|
||||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
CMD ["/app/backend/start.sh"]
|
||||||
|
|||||||
+264
@@ -0,0 +1,264 @@
|
|||||||
|
# Database Migrations Guide
|
||||||
|
|
||||||
|
YouMusic uses [Alembic](https://alembic.sqlalchemy.org/) for database schema migrations. This ensures safe schema changes without data loss.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### View Migration Status
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
./migrate.sh current
|
||||||
|
```
|
||||||
|
|
||||||
|
### Create a New Migration
|
||||||
|
When you modify database models in `app/models/models.py`:
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
./migrate.sh create "Add new field description"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Apply Migrations
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
./migrate.sh upgrade
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rollback a Migration
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
./migrate.sh downgrade # Go back one migration
|
||||||
|
# or
|
||||||
|
./migrate.sh downgrade <revision_id> # Go to specific version
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migration Commands
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `./migrate.sh create "message"` | Create new migration with autogenerate |
|
||||||
|
| `./migrate.sh upgrade` | Apply all pending migrations |
|
||||||
|
| `./migrate.sh downgrade` | Rollback last migration |
|
||||||
|
| `./migrate.sh history` | View migration history |
|
||||||
|
| `./migrate.sh current` | Show current database version |
|
||||||
|
| `./migrate.sh stamp head` | Mark database as current without running migrations |
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
1. **Models** - Define your schema in `app/models/models.py`
|
||||||
|
2. **Generate Migration** - Alembic compares models to current DB and creates migration script
|
||||||
|
3. **Review Migration** - Check `alembic/versions/*.py` file before applying
|
||||||
|
4. **Apply Migration** - Run upgrade to apply changes to database
|
||||||
|
|
||||||
|
## Migration Workflow
|
||||||
|
|
||||||
|
### Adding a New Field
|
||||||
|
|
||||||
|
1. Add field to model:
|
||||||
|
```python
|
||||||
|
# app/models/models.py
|
||||||
|
class Music(Base):
|
||||||
|
# ... existing fields ...
|
||||||
|
new_field: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Create migration:
|
||||||
|
```bash
|
||||||
|
./migrate.sh create "Add new_field to music table"
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Review generated migration in `alembic/versions/`
|
||||||
|
|
||||||
|
4. Apply migration:
|
||||||
|
```bash
|
||||||
|
./migrate.sh upgrade
|
||||||
|
```
|
||||||
|
|
||||||
|
### Renaming a Field
|
||||||
|
|
||||||
|
Alembic may detect this as drop + add. Manually edit the migration:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Before (auto-generated)
|
||||||
|
def upgrade():
|
||||||
|
batch_op.drop_column('old_name')
|
||||||
|
batch_op.add_column(sa.Column('new_name', sa.String(), nullable=True))
|
||||||
|
|
||||||
|
# After (manual edit)
|
||||||
|
def upgrade():
|
||||||
|
batch_op.alter_column('old_name', new_column_name='new_name')
|
||||||
|
```
|
||||||
|
|
||||||
|
### Deleting a Field
|
||||||
|
|
||||||
|
1. Remove from model
|
||||||
|
2. Generate migration: `./migrate.sh create "Remove old_field"`
|
||||||
|
3. Review - ensure data you want is preserved
|
||||||
|
4. Apply: `./migrate.sh upgrade`
|
||||||
|
|
||||||
|
## Production Deployment
|
||||||
|
|
||||||
|
### First Deployment (New Instance)
|
||||||
|
```bash
|
||||||
|
# Migrations will run automatically on startup
|
||||||
|
# Or manually:
|
||||||
|
./migrate.sh upgrade
|
||||||
|
```
|
||||||
|
|
||||||
|
### Updating Existing Instance
|
||||||
|
```bash
|
||||||
|
# 1. Backup database
|
||||||
|
cp data/youmusic.db data/youmusic.db.backup
|
||||||
|
|
||||||
|
# 2. Pull latest code
|
||||||
|
git pull
|
||||||
|
|
||||||
|
# 3. Install dependencies
|
||||||
|
uv pip install -r requirements.txt
|
||||||
|
|
||||||
|
# 4. Apply migrations
|
||||||
|
./migrate.sh upgrade
|
||||||
|
|
||||||
|
# 5. Restart application
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker Deployment
|
||||||
|
|
||||||
|
Migrations run automatically when the container starts (via startup script).
|
||||||
|
|
||||||
|
To run manually in container:
|
||||||
|
```bash
|
||||||
|
docker exec -it youmusic /app/backend/migrate.sh current
|
||||||
|
docker exec -it youmusic /app/backend/migrate.sh upgrade
|
||||||
|
```
|
||||||
|
|
||||||
|
## Kubernetes Deployment
|
||||||
|
|
||||||
|
Migrations run as an init container before the main application starts.
|
||||||
|
|
||||||
|
Check migration status:
|
||||||
|
```bash
|
||||||
|
kubectl logs -f deployment/youmusic -c migrations
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "Target database is not up to date"
|
||||||
|
Your database is behind. Run:
|
||||||
|
```bash
|
||||||
|
./migrate.sh upgrade
|
||||||
|
```
|
||||||
|
|
||||||
|
### "Can't locate revision identified by 'xyz'"
|
||||||
|
Migration files are missing or database is inconsistent. Check:
|
||||||
|
```bash
|
||||||
|
./migrate.sh history
|
||||||
|
./migrate.sh current
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rollback Failed Migration
|
||||||
|
```bash
|
||||||
|
# Downgrade to previous version
|
||||||
|
./migrate.sh downgrade
|
||||||
|
|
||||||
|
# Or to specific version
|
||||||
|
./migrate.sh downgrade <revision_id>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reset Database (Dev Only - DATA LOSS!)
|
||||||
|
```bash
|
||||||
|
# Delete database
|
||||||
|
rm data/youmusic.db
|
||||||
|
|
||||||
|
# Recreate with latest schema
|
||||||
|
./migrate.sh upgrade
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manually Fix Database Version
|
||||||
|
If migrations got out of sync:
|
||||||
|
```bash
|
||||||
|
# Stamp to specific version (doesn't run migrations)
|
||||||
|
./migrate.sh stamp <revision_id>
|
||||||
|
|
||||||
|
# Or to latest
|
||||||
|
./migrate.sh stamp head
|
||||||
|
```
|
||||||
|
|
||||||
|
## Files and Directories
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/
|
||||||
|
├── alembic/ # Migration configuration
|
||||||
|
│ ├── versions/ # Migration scripts
|
||||||
|
│ │ └── *.py # Individual migrations
|
||||||
|
│ ├── env.py # Alembic environment config
|
||||||
|
│ └── script.py.mako # Migration template
|
||||||
|
├── alembic.ini # Alembic settings
|
||||||
|
├── migrate.sh # Helper script
|
||||||
|
└── app/
|
||||||
|
└── models/
|
||||||
|
└── models.py # SQLAlchemy models (source of truth)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Always Review** - Check auto-generated migrations before applying
|
||||||
|
2. **Test First** - Run migrations on dev/staging before production
|
||||||
|
3. **Backup Data** - Always backup database before migrations in production
|
||||||
|
4. **One Change Per Migration** - Keep migrations focused and atomic
|
||||||
|
5. **Descriptive Messages** - Use clear migration messages
|
||||||
|
6. **Version Control** - Commit migration files to git
|
||||||
|
7. **Never Edit Applied Migrations** - Create new ones instead
|
||||||
|
8. **Document Complex Changes** - Add comments in migration files
|
||||||
|
|
||||||
|
## Advanced Usage
|
||||||
|
|
||||||
|
### Manual Migration (No Autogenerate)
|
||||||
|
```bash
|
||||||
|
source .venv/bin/activate
|
||||||
|
alembic revision -m "Manual migration"
|
||||||
|
# Edit the created file manually
|
||||||
|
```
|
||||||
|
|
||||||
|
### Offline SQL Generation
|
||||||
|
```bash
|
||||||
|
source .venv/bin/activate
|
||||||
|
alembic upgrade head --sql > migration.sql
|
||||||
|
# Review migration.sql before applying
|
||||||
|
```
|
||||||
|
|
||||||
|
### Branching and Merging
|
||||||
|
```bash
|
||||||
|
# Create branch-specific migration
|
||||||
|
alembic revision -m "Feature branch change" --branch-label feature
|
||||||
|
|
||||||
|
# Merge branches
|
||||||
|
alembic merge -m "Merge branches" head1 head2
|
||||||
|
```
|
||||||
|
|
||||||
|
## SQLite Specific Notes
|
||||||
|
|
||||||
|
- Uses `batch_alter_table` for schema changes (required for SQLite)
|
||||||
|
- Some operations may require table recreation
|
||||||
|
- Foreign key constraints temporarily disabled during migrations
|
||||||
|
- `render_as_batch=True` in env.py handles this automatically
|
||||||
|
|
||||||
|
## Migration History Example
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ ./migrate.sh history
|
||||||
|
|
||||||
|
01209c730b33 -> (head), Initial migration
|
||||||
|
<base> -> 01209c730b33, Initial migration
|
||||||
|
```
|
||||||
|
|
||||||
|
## Getting Help
|
||||||
|
|
||||||
|
- Alembic Docs: https://alembic.sqlalchemy.org/
|
||||||
|
- SQLAlchemy Docs: https://docs.sqlalchemy.org/
|
||||||
|
- Project Issues: Check GitHub issues
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- `AGENTS.md` - AI/LLM context about the project
|
||||||
|
- `DEV_QUICK_START.md` - Development setup guide
|
||||||
|
- `DEPLOYMENT.md` - Deployment instructions
|
||||||
-142
@@ -1,142 +0,0 @@
|
|||||||
# Quick Start Guide
|
|
||||||
|
|
||||||
## 🚀 Fastest Way to Get Started
|
|
||||||
|
|
||||||
### Option 1: Local Development (Fastest for Development)
|
|
||||||
|
|
||||||
**For macOS/Linux:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd you-music
|
|
||||||
|
|
||||||
# One-time setup
|
|
||||||
./dev-setup.sh
|
|
||||||
|
|
||||||
# Start development
|
|
||||||
./dev.sh
|
|
||||||
|
|
||||||
# Visit http://localhost:3000
|
|
||||||
```
|
|
||||||
|
|
||||||
**Stop servers when done:**
|
|
||||||
```bash
|
|
||||||
./dev-stop.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
See [LOCAL_DEV_GUIDE.md](LOCAL_DEV_GUIDE.md) for more details.
|
|
||||||
|
|
||||||
### Option 2: Docker (Easiest for Production)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Clone the repository
|
|
||||||
git clone <your-repo-url>
|
|
||||||
cd you-music
|
|
||||||
|
|
||||||
# Start with Docker
|
|
||||||
docker-compose up -d
|
|
||||||
|
|
||||||
# Access the app
|
|
||||||
open http://localhost:8000
|
|
||||||
```
|
|
||||||
|
|
||||||
That's it! The app is now running.
|
|
||||||
|
|
||||||
### Option 3: Manual Setup
|
|
||||||
|
|
||||||
**Backend:**
|
|
||||||
```bash
|
|
||||||
cd backend
|
|
||||||
python -m venv venv
|
|
||||||
source venv/bin/activate # Windows: venv\Scripts\activate
|
|
||||||
pip install -r requirements.txt
|
|
||||||
uvicorn main:app --reload
|
|
||||||
```
|
|
||||||
|
|
||||||
**Frontend (in another terminal):**
|
|
||||||
```bash
|
|
||||||
cd frontend
|
|
||||||
npm install
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📱 First Steps
|
|
||||||
|
|
||||||
1. **Search for Music**
|
|
||||||
- Click "Search" tab
|
|
||||||
- Enter artist or song name
|
|
||||||
- Click download button to save
|
|
||||||
|
|
||||||
2. **Play Music**
|
|
||||||
- Go to "Library" tab
|
|
||||||
- Click play button on any song
|
|
||||||
- Use player controls at bottom
|
|
||||||
|
|
||||||
3. **Create Playlist**
|
|
||||||
- Go to "Playlists" tab
|
|
||||||
- Click "New Playlist"
|
|
||||||
- Add songs from your library
|
|
||||||
|
|
||||||
## 🎬 Example Usage
|
|
||||||
|
|
||||||
### Download from YouTube
|
|
||||||
```
|
|
||||||
1. Go to Search tab
|
|
||||||
2. Search for "Your favorite song"
|
|
||||||
3. Click download icon
|
|
||||||
4. Wait for download to complete
|
|
||||||
5. Find it in Library tab
|
|
||||||
```
|
|
||||||
|
|
||||||
### Share a Song
|
|
||||||
```
|
|
||||||
Copy this URL format:
|
|
||||||
http://localhost:8000/?music=1
|
|
||||||
|
|
||||||
Replace '1' with the music ID
|
|
||||||
Share with friends!
|
|
||||||
```
|
|
||||||
|
|
||||||
## ⚙️ Configuration
|
|
||||||
|
|
||||||
Copy example env files:
|
|
||||||
```bash
|
|
||||||
cp backend/.env.example backend/.env
|
|
||||||
cp frontend/.env.example frontend/.env
|
|
||||||
```
|
|
||||||
|
|
||||||
Edit as needed for your setup.
|
|
||||||
|
|
||||||
## 🐛 Common Issues
|
|
||||||
|
|
||||||
**"FFmpeg not found"**
|
|
||||||
```bash
|
|
||||||
# Ubuntu/Debian
|
|
||||||
sudo apt-get install ffmpeg
|
|
||||||
|
|
||||||
# macOS
|
|
||||||
brew install ffmpeg
|
|
||||||
|
|
||||||
# Windows
|
|
||||||
# Download from https://ffmpeg.org/download.html
|
|
||||||
```
|
|
||||||
|
|
||||||
**Port 8000 already in use**
|
|
||||||
```bash
|
|
||||||
# Use different port
|
|
||||||
docker-compose down
|
|
||||||
# Edit docker-compose.yml ports section
|
|
||||||
docker-compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
**Database issues**
|
|
||||||
```bash
|
|
||||||
# Reset database
|
|
||||||
rm data/youmusic.db
|
|
||||||
docker-compose restart
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📞 Need Help?
|
|
||||||
|
|
||||||
- Check the main [README.md](README.md)
|
|
||||||
- Visit API docs at http://localhost:8000/docs
|
|
||||||
- Open an issue on GitHub
|
|
||||||
@@ -316,14 +316,65 @@ uvicorn main:app --port 8001
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Database errors
|
### Database errors
|
||||||
Delete the database file and restart:
|
If you encounter database errors, try the following:
|
||||||
|
|
||||||
|
**With Migrations (Recommended):**
|
||||||
```bash
|
```bash
|
||||||
rm data/youmusic.db
|
# Check current migration status
|
||||||
docker-compose restart
|
cd backend
|
||||||
|
./migrate.sh current
|
||||||
|
|
||||||
|
# Apply pending migrations
|
||||||
|
./migrate.sh upgrade
|
||||||
|
|
||||||
|
# If needed, check migration history
|
||||||
|
./migrate.sh history
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Reset Database (Dev only - DATA LOSS!):**
|
||||||
|
```bash
|
||||||
|
# Delete database and restart (auto-migrates on startup)
|
||||||
|
rm data/youmusic.db
|
||||||
|
docker-compose restart
|
||||||
|
|
||||||
|
# Or for local development
|
||||||
|
./dev-stop.sh
|
||||||
|
rm backend/data/youmusic.db
|
||||||
|
./dev.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
See [MIGRATIONS.md](MIGRATIONS.md) for detailed migration documentation.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
|
### Database Migrations
|
||||||
|
|
||||||
|
YouMusic uses Alembic for database migrations. This allows safe schema changes without data loss.
|
||||||
|
|
||||||
|
**Common operations:**
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
|
||||||
|
# View current database version
|
||||||
|
./migrate.sh current
|
||||||
|
|
||||||
|
# Create a new migration after modifying models
|
||||||
|
./migrate.sh create "Add new field description"
|
||||||
|
|
||||||
|
# Apply migrations
|
||||||
|
./migrate.sh upgrade
|
||||||
|
|
||||||
|
# Rollback last migration
|
||||||
|
./migrate.sh downgrade
|
||||||
|
|
||||||
|
# View migration history
|
||||||
|
./migrate.sh history
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** Migrations run automatically on app startup, so manual migration is only needed when developing new schema changes.
|
||||||
|
|
||||||
|
For complete migration documentation, see [MIGRATIONS.md](MIGRATIONS.md).
|
||||||
|
|
||||||
### Running tests
|
### Running tests
|
||||||
```bash
|
```bash
|
||||||
# Backend
|
# Backend
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Database Migration Quick Reference
|
||||||
|
|
||||||
|
## Daily Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check current version
|
||||||
|
./migrate.sh current
|
||||||
|
|
||||||
|
# Create new migration (after modifying models)
|
||||||
|
./migrate.sh create "Add description here"
|
||||||
|
|
||||||
|
# Apply migrations
|
||||||
|
./migrate.sh upgrade
|
||||||
|
|
||||||
|
# Rollback last migration
|
||||||
|
./migrate.sh downgrade
|
||||||
|
|
||||||
|
# View history
|
||||||
|
./migrate.sh history
|
||||||
|
```
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. **Modify Model** (`app/models/models.py`)
|
||||||
|
2. **Create Migration** (`./migrate.sh create "message"`)
|
||||||
|
3. **Review Migration** (`alembic/versions/*.py`)
|
||||||
|
4. **Apply Migration** (`./migrate.sh upgrade`)
|
||||||
|
5. **Update Schema** (`app/schemas/schemas.py` if needed)
|
||||||
|
|
||||||
|
## Important Notes
|
||||||
|
|
||||||
|
- ✅ Migrations run automatically on app startup
|
||||||
|
- ✅ Safe for production - preserves data
|
||||||
|
- ✅ Can rollback if needed
|
||||||
|
- ❌ Never edit applied migrations
|
||||||
|
- ❌ Never delete database in production
|
||||||
|
- 📝 Always commit migration files to git
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `alembic/versions/*.py` - Migration scripts
|
||||||
|
- `alembic.ini` - Alembic configuration
|
||||||
|
- `alembic/env.py` - Migration environment
|
||||||
|
- `migrate.sh` - Helper script
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- Full guide: [MIGRATIONS.md](MIGRATIONS.md)
|
||||||
|
- AI context: [AGENTS.md](AGENTS.md#database-schema)
|
||||||
|
- Models: `backend/app/models/models.py`
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
# A generic, single database configuration.
|
||||||
|
|
||||||
|
[alembic]
|
||||||
|
# path to migration scripts.
|
||||||
|
# this is typically a path given in POSIX (e.g. forward slashes)
|
||||||
|
# format, relative to the token %(here)s which refers to the location of this
|
||||||
|
# ini file
|
||||||
|
script_location = %(here)s/alembic
|
||||||
|
|
||||||
|
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||||
|
# Uncomment the line below if you want the files to be prepended with date and time
|
||||||
|
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
||||||
|
# for all available tokens
|
||||||
|
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||||
|
|
||||||
|
# sys.path path, will be prepended to sys.path if present.
|
||||||
|
# defaults to the current working directory. for multiple paths, the path separator
|
||||||
|
# is defined by "path_separator" below.
|
||||||
|
prepend_sys_path = .
|
||||||
|
|
||||||
|
|
||||||
|
# timezone to use when rendering the date within the migration file
|
||||||
|
# as well as the filename.
|
||||||
|
# If specified, requires the tzdata library which can be installed by adding
|
||||||
|
# `alembic[tz]` to the pip requirements.
|
||||||
|
# string value is passed to ZoneInfo()
|
||||||
|
# leave blank for localtime
|
||||||
|
# timezone =
|
||||||
|
|
||||||
|
# max length of characters to apply to the "slug" field
|
||||||
|
# truncate_slug_length = 40
|
||||||
|
|
||||||
|
# set to 'true' to run the environment during
|
||||||
|
# the 'revision' command, regardless of autogenerate
|
||||||
|
# revision_environment = false
|
||||||
|
|
||||||
|
# set to 'true' to allow .pyc and .pyo files without
|
||||||
|
# a source .py file to be detected as revisions in the
|
||||||
|
# versions/ directory
|
||||||
|
# sourceless = false
|
||||||
|
|
||||||
|
# version location specification; This defaults
|
||||||
|
# to <script_location>/versions. When using multiple version
|
||||||
|
# directories, initial revisions must be specified with --version-path.
|
||||||
|
# The path separator used here should be the separator specified by "path_separator"
|
||||||
|
# below.
|
||||||
|
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
|
||||||
|
|
||||||
|
# path_separator; This indicates what character is used to split lists of file
|
||||||
|
# paths, including version_locations and prepend_sys_path within configparser
|
||||||
|
# files such as alembic.ini.
|
||||||
|
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
|
||||||
|
# to provide os-dependent path splitting.
|
||||||
|
#
|
||||||
|
# Note that in order to support legacy alembic.ini files, this default does NOT
|
||||||
|
# take place if path_separator is not present in alembic.ini. If this
|
||||||
|
# option is omitted entirely, fallback logic is as follows:
|
||||||
|
#
|
||||||
|
# 1. Parsing of the version_locations option falls back to using the legacy
|
||||||
|
# "version_path_separator" key, which if absent then falls back to the legacy
|
||||||
|
# behavior of splitting on spaces and/or commas.
|
||||||
|
# 2. Parsing of the prepend_sys_path option falls back to the legacy
|
||||||
|
# behavior of splitting on spaces, commas, or colons.
|
||||||
|
#
|
||||||
|
# Valid values for path_separator are:
|
||||||
|
#
|
||||||
|
# path_separator = :
|
||||||
|
# path_separator = ;
|
||||||
|
# path_separator = space
|
||||||
|
# path_separator = newline
|
||||||
|
#
|
||||||
|
# Use os.pathsep. Default configuration used for new projects.
|
||||||
|
path_separator = os
|
||||||
|
|
||||||
|
# set to 'true' to search source files recursively
|
||||||
|
# in each "version_locations" directory
|
||||||
|
# new in Alembic version 1.10
|
||||||
|
# recursive_version_locations = false
|
||||||
|
|
||||||
|
# the output encoding used when revision files
|
||||||
|
# are written from script.py.mako
|
||||||
|
# output_encoding = utf-8
|
||||||
|
|
||||||
|
# database URL. This is consumed by the user-maintained env.py script only.
|
||||||
|
# other means of configuring database URLs may be customized within the env.py
|
||||||
|
# file.
|
||||||
|
# sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||||
|
# We'll set this programmatically from config.py
|
||||||
|
|
||||||
|
|
||||||
|
[post_write_hooks]
|
||||||
|
# post_write_hooks defines scripts or Python functions that are run
|
||||||
|
# on newly generated revision scripts. See the documentation for further
|
||||||
|
# detail and examples
|
||||||
|
|
||||||
|
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||||
|
# hooks = black
|
||||||
|
# black.type = console_scripts
|
||||||
|
# black.entrypoint = black
|
||||||
|
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||||
|
|
||||||
|
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
|
||||||
|
# hooks = ruff
|
||||||
|
# ruff.type = module
|
||||||
|
# ruff.module = ruff
|
||||||
|
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||||
|
|
||||||
|
# Alternatively, use the exec runner to execute a binary found on your PATH
|
||||||
|
# hooks = ruff
|
||||||
|
# ruff.type = exec
|
||||||
|
# ruff.executable = ruff
|
||||||
|
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||||
|
|
||||||
|
# Logging configuration. This is also consumed by the user-maintained
|
||||||
|
# env.py script only.
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARNING
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARNING
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Generic single-database configuration.
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
from logging.config import fileConfig
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from sqlalchemy import pool
|
||||||
|
from sqlalchemy.engine import Connection
|
||||||
|
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
|
||||||
|
# Add parent directory to path so we can import app modules
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
# Import your models and config
|
||||||
|
from app.models.models import Base
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
# this is the Alembic Config object, which provides
|
||||||
|
# access to the values within the .ini file in use.
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
# Set the database URL from settings
|
||||||
|
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
|
||||||
|
|
||||||
|
# Interpret the config file for Python logging.
|
||||||
|
# This line sets up loggers basically.
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
# add your model's MetaData object here
|
||||||
|
# for 'autogenerate' support
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
# other values from the config, defined by the needs of env.py,
|
||||||
|
# can be acquired:
|
||||||
|
# my_important_option = config.get_main_option("my_important_option")
|
||||||
|
# ... etc.
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
"""Run migrations in 'offline' mode.
|
||||||
|
|
||||||
|
This configures the context with just a URL
|
||||||
|
and not an Engine, though an Engine is acceptable
|
||||||
|
here as well. By skipping the Engine creation
|
||||||
|
we don't even need a DBAPI to be available.
|
||||||
|
|
||||||
|
Calls to context.execute() here emit the given string to the
|
||||||
|
script output.
|
||||||
|
|
||||||
|
"""
|
||||||
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
|
context.configure(
|
||||||
|
url=url,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
render_as_batch=True, # For SQLite compatibility
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def do_run_migrations(connection: Connection) -> None:
|
||||||
|
context.configure(
|
||||||
|
connection=connection,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
render_as_batch=True, # For SQLite compatibility
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
async def run_async_migrations() -> None:
|
||||||
|
"""Run migrations in 'online' mode with async support."""
|
||||||
|
connectable = async_engine_from_config(
|
||||||
|
config.get_section(config.config_ini_section, {}),
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
|
||||||
|
async with connectable.connect() as connection:
|
||||||
|
await connection.run_sync(do_run_migrations)
|
||||||
|
|
||||||
|
await connectable.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
"""Run migrations in 'online' mode."""
|
||||||
|
asyncio.run(run_async_migrations())
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = ${repr(up_revision)}
|
||||||
|
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||||
|
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""Initial migration
|
||||||
|
|
||||||
|
Revision ID: 01209c730b33
|
||||||
|
Revises:
|
||||||
|
Create Date: 2025-10-30 21:11:30.801811
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '01209c730b33'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = None
|
||||||
|
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('app_settings',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('key', sa.String(), nullable=True),
|
||||||
|
sa.Column('value', sa.Text(), nullable=True),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
with op.batch_alter_table('app_settings', schema=None) as batch_op:
|
||||||
|
batch_op.create_index(batch_op.f('ix_app_settings_id'), ['id'], unique=False)
|
||||||
|
batch_op.create_index(batch_op.f('ix_app_settings_key'), ['key'], unique=True)
|
||||||
|
|
||||||
|
with op.batch_alter_table('music', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('file_format', sa.String(), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column('file_location', sa.String(), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column('file_exists', sa.Boolean(), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column('last_scanned_at', sa.DateTime(), nullable=True))
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('music', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('last_scanned_at')
|
||||||
|
batch_op.drop_column('file_exists')
|
||||||
|
batch_op.drop_column('file_location')
|
||||||
|
batch_op.drop_column('file_format')
|
||||||
|
|
||||||
|
with op.batch_alter_table('app_settings', schema=None) as batch_op:
|
||||||
|
batch_op.drop_index(batch_op.f('ix_app_settings_key'))
|
||||||
|
batch_op.drop_index(batch_op.f('ix_app_settings_id'))
|
||||||
|
|
||||||
|
op.drop_table('app_settings')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""Populate file_location and file_format
|
||||||
|
|
||||||
|
Revision ID: 49793394b596
|
||||||
|
Revises: c1fd223a3556
|
||||||
|
Create Date: 2025-10-30 21:56:40.131970
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '49793394b596'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = 'c1fd223a3556'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
# Populate file_location from MUSIC_DIR + file_path for existing records
|
||||||
|
# Note: This uses SQLite-specific syntax
|
||||||
|
op.execute("""
|
||||||
|
UPDATE music
|
||||||
|
SET file_location = './data/music/' || file_path
|
||||||
|
WHERE file_location IS NULL OR file_location = ''
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Populate file_format from file_path extension
|
||||||
|
op.execute("""
|
||||||
|
UPDATE music
|
||||||
|
SET file_format = LOWER(
|
||||||
|
CASE
|
||||||
|
WHEN file_path LIKE '%.mp3' THEN 'mp3'
|
||||||
|
WHEN file_path LIKE '%.m4a' THEN 'm4a'
|
||||||
|
WHEN file_path LIKE '%.flac' THEN 'flac'
|
||||||
|
WHEN file_path LIKE '%.wav' THEN 'wav'
|
||||||
|
WHEN file_path LIKE '%.ogg' THEN 'ogg'
|
||||||
|
WHEN file_path LIKE '%.opus' THEN 'opus'
|
||||||
|
ELSE SUBSTR(file_path, INSTR(file_path, '.') + 1)
|
||||||
|
END
|
||||||
|
)
|
||||||
|
WHERE file_format IS NULL OR file_format = ''
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
pass
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""Add file tracking columns
|
||||||
|
|
||||||
|
Revision ID: c1fd223a3556
|
||||||
|
Revises: 01209c730b33
|
||||||
|
Create Date: 2025-10-30 21:48:22.894324
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'c1fd223a3556'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = '01209c730b33'
|
||||||
|
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! ###
|
||||||
|
with op.batch_alter_table('music', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('file_format', sa.String(), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column('file_location', sa.String(), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column('file_exists', sa.Boolean(), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column('last_scanned_at', sa.DateTime(), nullable=True))
|
||||||
|
|
||||||
|
# Set default value for existing records
|
||||||
|
op.execute("UPDATE music SET file_exists = 1 WHERE file_exists IS NULL")
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('music', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('last_scanned_at')
|
||||||
|
batch_op.drop_column('file_exists')
|
||||||
|
batch_op.drop_column('file_location')
|
||||||
|
batch_op.drop_column('file_format')
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
+34
-14
@@ -211,34 +211,54 @@ async def get_artists(db: AsyncSession = Depends(get_db)):
|
|||||||
return artists
|
return artists
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{artist_name}/info", response_model=ArtistInfo)
|
@router.get("/info", response_model=ArtistInfo)
|
||||||
async def get_artist_info(artist_name: str):
|
async def get_artist_info(artist_name: str, db: AsyncSession = Depends(get_db)):
|
||||||
"""Get artist information from Deezer and MusicBrainz (with 30-day cache)"""
|
"""Get artist information from Deezer and MusicBrainz (with 30-day cache)
|
||||||
# Try to get from cache first
|
|
||||||
|
Artist Avatar Priority:
|
||||||
|
1. Cache (if exists and not expired)
|
||||||
|
2. Online API (Deezer/MusicBrainz)
|
||||||
|
3. Song thumbnail fallback (if artist has songs with thumbnails)
|
||||||
|
4. None (frontend shows default icon)
|
||||||
|
"""
|
||||||
|
# 1. Try to get from cache first
|
||||||
cached_info = get_cached_artist_info(artist_name)
|
cached_info = get_cached_artist_info(artist_name)
|
||||||
if cached_info:
|
if cached_info:
|
||||||
return cached_info
|
return cached_info
|
||||||
|
|
||||||
# Fetch from APIs if not cached
|
# 2. Fetch from online APIs
|
||||||
info = await get_artist_info_from_apis(artist_name)
|
info = await get_artist_info_from_apis(artist_name)
|
||||||
if not info:
|
if not info:
|
||||||
# Return minimal info if APIs fail
|
# Return minimal info if APIs fail
|
||||||
info = ArtistInfo(name=artist_name)
|
info = ArtistInfo(name=artist_name)
|
||||||
else:
|
|
||||||
# Download and cache the image if available
|
|
||||||
if info.image and info.image.startswith('http'):
|
|
||||||
cached_image_path = await download_and_cache_artist_image(artist_name, info.image)
|
|
||||||
if cached_image_path:
|
|
||||||
# Update info with local cached image path
|
|
||||||
info.image = cached_image_path
|
|
||||||
|
|
||||||
# Save to cache
|
# Download and cache the image if available from API
|
||||||
|
if info.image and info.image.startswith('http'):
|
||||||
|
cached_image_path = await download_and_cache_artist_image(artist_name, info.image)
|
||||||
|
if cached_image_path:
|
||||||
|
# Update info with local cached image path
|
||||||
|
info.image = cached_image_path
|
||||||
|
|
||||||
|
# 3. If no image found from API, try to use artist's song thumbnail as fallback
|
||||||
|
if not info.image:
|
||||||
|
result = await db.execute(
|
||||||
|
select(Music)
|
||||||
|
.where(Music.artist == artist_name)
|
||||||
|
.where(Music.thumbnail.isnot(None))
|
||||||
|
.where(Music.thumbnail != "")
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
song_with_thumbnail = result.scalar_one_or_none()
|
||||||
|
if song_with_thumbnail and song_with_thumbnail.thumbnail:
|
||||||
|
info.image = song_with_thumbnail.thumbnail
|
||||||
|
|
||||||
|
# 4. Save to cache (even if no image - will show default icon on frontend)
|
||||||
save_artist_info_to_cache(artist_name, info)
|
save_artist_info_to_cache(artist_name, info)
|
||||||
|
|
||||||
return info
|
return info
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{artist_name}/cache")
|
@router.delete("/cache", response_model=dict)
|
||||||
async def clear_artist_cache(artist_name: str):
|
async def clear_artist_cache(artist_name: str):
|
||||||
"""Clear cached artist info (forces refresh on next request)"""
|
"""Clear cached artist info (forces refresh on next request)"""
|
||||||
cache_path = get_cache_path(artist_name)
|
cache_path = get_cache_path(artist_name)
|
||||||
|
|||||||
@@ -35,8 +35,9 @@ async def process_download(
|
|||||||
source_type = "youtube" if music_downloader.is_youtube_url(url) else \
|
source_type = "youtube" if music_downloader.is_youtube_url(url) else \
|
||||||
"bilibili" if music_downloader.is_bilibili_url(url) else "other"
|
"bilibili" if music_downloader.is_bilibili_url(url) else "other"
|
||||||
|
|
||||||
# Get relative path
|
# Get relative path and file format
|
||||||
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
|
||||||
|
|
||||||
# Create database record
|
# Create database record
|
||||||
db_music = Music(
|
db_music = Music(
|
||||||
@@ -46,6 +47,9 @@ async def process_download(
|
|||||||
duration=metadata.get("duration", 0),
|
duration=metadata.get("duration", 0),
|
||||||
file_path=relative_path,
|
file_path=relative_path,
|
||||||
file_size=os.path.getsize(file_path),
|
file_size=os.path.getsize(file_path),
|
||||||
|
file_format=file_extension,
|
||||||
|
file_location=file_path,
|
||||||
|
file_exists=True,
|
||||||
source_url=url,
|
source_url=url,
|
||||||
source_type=source_type,
|
source_type=source_type,
|
||||||
thumbnail=metadata.get("thumbnail") # Add thumbnail
|
thumbnail=metadata.get("thumbnail") # Add thumbnail
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query
|
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, or_
|
from sqlalchemy import select, or_
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
@@ -8,7 +9,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
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
|
||||||
from app.schemas.schemas import Music as MusicSchema, MusicCreate, MusicUpdate
|
from app.schemas.schemas import Music as MusicSchema, MusicCreate, MusicUpdate, MusicDetailInfo
|
||||||
from app.services.downloader import music_downloader
|
from app.services.downloader import music_downloader
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
|
||||||
@@ -19,11 +20,18 @@ router = APIRouter()
|
|||||||
async def get_all_music(
|
async def get_all_music(
|
||||||
skip: int = 0,
|
skip: int = 0,
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
|
include_missing: bool = True,
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""Get all music files"""
|
"""Get all music files"""
|
||||||
|
query = select(Music)
|
||||||
|
|
||||||
|
# Filter out missing files if requested
|
||||||
|
if not include_missing:
|
||||||
|
query = query.where(Music.file_exists == True)
|
||||||
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Music).offset(skip).limit(limit)
|
query.offset(skip).limit(limit)
|
||||||
)
|
)
|
||||||
music_list = result.scalars().all()
|
music_list = result.scalars().all()
|
||||||
return music_list
|
return music_list
|
||||||
@@ -59,6 +67,18 @@ async def get_music(music_id: int, db: AsyncSession = Depends(get_db)):
|
|||||||
return music
|
return music
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{music_id}/info", response_model=MusicDetailInfo)
|
||||||
|
async def get_music_detail_info(music_id: int, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Get detailed information about a music file"""
|
||||||
|
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")
|
||||||
|
return music
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{music_id}", response_model=MusicSchema)
|
@router.put("/{music_id}", response_model=MusicSchema)
|
||||||
async def update_music(
|
async def update_music(
|
||||||
music_id: int,
|
music_id: int,
|
||||||
@@ -125,6 +145,9 @@ 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)
|
||||||
|
|
||||||
|
# Get file format
|
||||||
|
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),
|
||||||
@@ -133,6 +156,9 @@ async def upload_music(
|
|||||||
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),
|
||||||
file_size=os.path.getsize(file_path),
|
file_size=os.path.getsize(file_path),
|
||||||
|
file_format=file_extension,
|
||||||
|
file_location=file_path,
|
||||||
|
file_exists=True,
|
||||||
source_type="upload"
|
source_type="upload"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -183,6 +209,9 @@ async def scan_music_directory(db: AsyncSession = Depends(get_db)):
|
|||||||
duration=metadata.get("duration", 0),
|
duration=metadata.get("duration", 0),
|
||||||
file_path=relative_path,
|
file_path=relative_path,
|
||||||
file_size=file_path.stat().st_size,
|
file_size=file_path.stat().st_size,
|
||||||
|
file_format=file_path.suffix[1:], # Extension without dot
|
||||||
|
file_location=str(file_path),
|
||||||
|
file_exists=True,
|
||||||
source_type="local"
|
source_type="local"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -192,3 +221,36 @@ async def scan_music_directory(db: AsyncSession = Depends(get_db)):
|
|||||||
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."}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/file/{music_id}")
|
||||||
|
async def serve_music_file(
|
||||||
|
music_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Serve music file - handles both regular and local music directory files"""
|
||||||
|
# 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")
|
||||||
|
|
||||||
|
# Determine file path
|
||||||
|
if music.file_location and os.path.isabs(music.file_location):
|
||||||
|
# Use absolute path from file_location (local music dir)
|
||||||
|
file_path = music.file_location
|
||||||
|
else:
|
||||||
|
# Use relative path from MUSIC_DIR (downloaded music)
|
||||||
|
file_path = os.path.join(settings.MUSIC_DIR, music.file_path)
|
||||||
|
|
||||||
|
# Check if file exists
|
||||||
|
if not os.path.exists(file_path):
|
||||||
|
raise HTTPException(status_code=404, detail="File not found on disk")
|
||||||
|
|
||||||
|
# Return file
|
||||||
|
return FileResponse(
|
||||||
|
path=file_path,
|
||||||
|
media_type=f"audio/{music.file_format or 'mpeg'}",
|
||||||
|
filename=os.path.basename(file_path)
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
from fastapi import APIRouter, Depends, BackgroundTasks
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.schemas.schemas import SettingsResponse, SettingsUpdate, ScanStatus
|
||||||
|
from app.models.models import AppSettings
|
||||||
|
from app.core.config import settings as app_settings
|
||||||
|
from app.services.scanner import full_scan, get_scan_status
|
||||||
|
from app.services.scheduler import update_scan_interval, enable_auto_scan
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_setting(db: AsyncSession, key: str) -> Optional[str]:
|
||||||
|
"""Get a setting value from database"""
|
||||||
|
result = await db.execute(select(AppSettings).where(AppSettings.key == key))
|
||||||
|
setting = result.scalar_one_or_none()
|
||||||
|
return setting.value if setting else None
|
||||||
|
|
||||||
|
|
||||||
|
async def set_setting(db: AsyncSession, key: str, value: str):
|
||||||
|
"""Set a setting value in database"""
|
||||||
|
result = await db.execute(select(AppSettings).where(AppSettings.key == key))
|
||||||
|
setting = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if setting:
|
||||||
|
setting.value = value
|
||||||
|
setting.updated_at = datetime.utcnow()
|
||||||
|
else:
|
||||||
|
setting = AppSettings(key=key, value=value)
|
||||||
|
db.add(setting)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=SettingsResponse)
|
||||||
|
async def get_settings(db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Get current application settings"""
|
||||||
|
|
||||||
|
# Get settings from database or use defaults
|
||||||
|
local_music_dir = await get_setting(db, "local_music_dir")
|
||||||
|
scan_interval = await get_setting(db, "scan_interval")
|
||||||
|
delete_missing = await get_setting(db, "delete_missing_files")
|
||||||
|
auto_scan = await get_setting(db, "auto_scan_enabled")
|
||||||
|
last_scan = await get_setting(db, "last_scan_at")
|
||||||
|
|
||||||
|
return SettingsResponse(
|
||||||
|
local_music_dir=local_music_dir or app_settings.LOCAL_MUSIC_DIR,
|
||||||
|
scan_interval=int(scan_interval) if scan_interval else app_settings.SCAN_INTERVAL,
|
||||||
|
delete_missing_files=delete_missing.lower() == "true" if delete_missing else app_settings.DELETE_MISSING_FILES,
|
||||||
|
auto_scan_enabled=auto_scan.lower() == "true" if auto_scan else app_settings.AUTO_SCAN_ENABLED,
|
||||||
|
last_scan_at=datetime.fromisoformat(last_scan) if last_scan else None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/", response_model=SettingsResponse)
|
||||||
|
async def update_settings(
|
||||||
|
settings_update: SettingsUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Update application settings"""
|
||||||
|
|
||||||
|
# Update each setting if provided
|
||||||
|
if settings_update.local_music_dir is not None:
|
||||||
|
await set_setting(db, "local_music_dir", settings_update.local_music_dir)
|
||||||
|
app_settings.LOCAL_MUSIC_DIR = settings_update.local_music_dir
|
||||||
|
logger.info(f"Updated LOCAL_MUSIC_DIR to: {settings_update.local_music_dir}")
|
||||||
|
|
||||||
|
if settings_update.scan_interval is not None:
|
||||||
|
await set_setting(db, "scan_interval", str(settings_update.scan_interval))
|
||||||
|
app_settings.SCAN_INTERVAL = settings_update.scan_interval
|
||||||
|
update_scan_interval(settings_update.scan_interval)
|
||||||
|
logger.info(f"Updated scan_interval to: {settings_update.scan_interval}")
|
||||||
|
|
||||||
|
if settings_update.delete_missing_files is not None:
|
||||||
|
await set_setting(db, "delete_missing_files", str(settings_update.delete_missing_files))
|
||||||
|
app_settings.DELETE_MISSING_FILES = settings_update.delete_missing_files
|
||||||
|
logger.info(f"Updated delete_missing_files to: {settings_update.delete_missing_files}")
|
||||||
|
|
||||||
|
if settings_update.auto_scan_enabled is not None:
|
||||||
|
await set_setting(db, "auto_scan_enabled", str(settings_update.auto_scan_enabled))
|
||||||
|
app_settings.AUTO_SCAN_ENABLED = settings_update.auto_scan_enabled
|
||||||
|
|
||||||
|
# Update scheduler
|
||||||
|
interval = settings_update.scan_interval or app_settings.SCAN_INTERVAL
|
||||||
|
enable_auto_scan(settings_update.auto_scan_enabled, interval)
|
||||||
|
logger.info(f"Updated auto_scan_enabled to: {settings_update.auto_scan_enabled}")
|
||||||
|
|
||||||
|
# Return updated settings
|
||||||
|
return await get_settings(db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/scan")
|
||||||
|
async def trigger_manual_scan(
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Trigger a manual music library scan"""
|
||||||
|
|
||||||
|
# Get delete_missing setting
|
||||||
|
delete_missing = await get_setting(db, "delete_missing_files")
|
||||||
|
delete = delete_missing.lower() == "true" if delete_missing else app_settings.DELETE_MISSING_FILES
|
||||||
|
|
||||||
|
# Run scan in background
|
||||||
|
background_tasks.add_task(full_scan, db, delete)
|
||||||
|
|
||||||
|
logger.info("Manual scan triggered")
|
||||||
|
return {"message": "Scan started", "status": "processing"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/scan-status", response_model=ScanStatus)
|
||||||
|
async def get_scan_status_endpoint():
|
||||||
|
"""Get current scan status"""
|
||||||
|
status = get_scan_status()
|
||||||
|
|
||||||
|
return ScanStatus(
|
||||||
|
is_scanning=status["is_scanning"],
|
||||||
|
progress=status["progress"],
|
||||||
|
total=status["total"],
|
||||||
|
current_file=status["current_file"],
|
||||||
|
started_at=status["started_at"],
|
||||||
|
completed_at=status["completed_at"],
|
||||||
|
files_added=status["files_added"],
|
||||||
|
files_updated=status["files_updated"],
|
||||||
|
files_missing=status["files_missing"],
|
||||||
|
errors=status["errors"]
|
||||||
|
)
|
||||||
@@ -11,8 +11,10 @@ class Settings(BaseSettings):
|
|||||||
# Directories
|
# Directories
|
||||||
BASE_DIR: str = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
BASE_DIR: str = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||||
MUSIC_DIR: str = os.path.join(BASE_DIR, "data", "music")
|
MUSIC_DIR: str = os.path.join(BASE_DIR, "data", "music")
|
||||||
|
LOCAL_MUSIC_DIR: Optional[str] = None # User's offline music directory
|
||||||
UPLOAD_DIR: str = os.path.join(BASE_DIR, "data", "uploads")
|
UPLOAD_DIR: str = os.path.join(BASE_DIR, "data", "uploads")
|
||||||
TEMP_DIR: str = os.path.join(BASE_DIR, "data", "temp")
|
TEMP_DIR: str = os.path.join(BASE_DIR, "data", "temp")
|
||||||
|
CACHE_DIR: str = os.path.join(BASE_DIR, "data", "cache")
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
DATABASE_URL: str = "sqlite+aiosqlite:///./data/youmusic.db"
|
DATABASE_URL: str = "sqlite+aiosqlite:///./data/youmusic.db"
|
||||||
@@ -29,6 +31,11 @@ class Settings(BaseSettings):
|
|||||||
YT_DLP_AUDIO_FORMAT: str = "mp3"
|
YT_DLP_AUDIO_FORMAT: str = "mp3"
|
||||||
YT_DLP_AUDIO_QUALITY: str = "0"
|
YT_DLP_AUDIO_QUALITY: str = "0"
|
||||||
|
|
||||||
|
# Scanner Settings
|
||||||
|
SCAN_INTERVAL: int = 3600 # Default 1 hour (in seconds)
|
||||||
|
DELETE_MISSING_FILES: bool = False # Don't delete missing files by default
|
||||||
|
AUTO_SCAN_ENABLED: bool = True # Enable automatic scanning
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
case_sensitive = True
|
case_sensitive = True
|
||||||
env_file = ".env"
|
env_file = ".env"
|
||||||
@@ -40,3 +47,13 @@ settings = Settings()
|
|||||||
os.makedirs(settings.MUSIC_DIR, exist_ok=True)
|
os.makedirs(settings.MUSIC_DIR, exist_ok=True)
|
||||||
os.makedirs(settings.UPLOAD_DIR, exist_ok=True)
|
os.makedirs(settings.UPLOAD_DIR, exist_ok=True)
|
||||||
os.makedirs(settings.TEMP_DIR, exist_ok=True)
|
os.makedirs(settings.TEMP_DIR, exist_ok=True)
|
||||||
|
os.makedirs(settings.CACHE_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
# Log directory locations for debugging
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
logger.info(f"📁 MUSIC_DIR: {settings.MUSIC_DIR}")
|
||||||
|
logger.info(f"📁 LOCAL_MUSIC_DIR: {settings.LOCAL_MUSIC_DIR}")
|
||||||
|
logger.info(f"📁 UPLOAD_DIR: {settings.UPLOAD_DIR}")
|
||||||
|
logger.info(f"📁 TEMP_DIR: {settings.TEMP_DIR}")
|
||||||
|
logger.info(f"📁 CACHE_DIR: {settings.CACHE_DIR}")
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ AsyncSessionLocal = async_sessionmaker(
|
|||||||
expire_on_commit=False,
|
expire_on_commit=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Alias for compatibility
|
||||||
|
async_session_maker = AsyncSessionLocal
|
||||||
|
|
||||||
Base = declarative_base()
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey, Table, Text
|
from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey, Table, Text, Boolean
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from app.db.session import Base
|
from app.db.session import Base
|
||||||
@@ -24,12 +24,16 @@ class Music(Base):
|
|||||||
duration = Column(Float, nullable=True)
|
duration = Column(Float, nullable=True)
|
||||||
file_path = Column(String, unique=True)
|
file_path = Column(String, unique=True)
|
||||||
file_size = Column(Integer, nullable=True)
|
file_size = Column(Integer, nullable=True)
|
||||||
|
file_format = Column(String, nullable=True) # mp3, flac, m4a, wav, ogg, etc.
|
||||||
|
file_location = Column(String, nullable=True) # Full absolute path for user reference
|
||||||
|
file_exists = Column(Boolean, default=True) # Track if file still exists on disk
|
||||||
source_url = Column(String, nullable=True)
|
source_url = Column(String, nullable=True)
|
||||||
source_type = Column(String, nullable=True) # local, youtube, bilibili, etc.
|
source_type = Column(String, nullable=True) # local, youtube, bilibili, etc.
|
||||||
thumbnail = Column(String, nullable=True)
|
thumbnail = Column(String, nullable=True)
|
||||||
lyrics = Column(Text, nullable=True)
|
lyrics = Column(Text, nullable=True)
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
last_scanned_at = Column(DateTime, nullable=True) # Last time file was verified
|
||||||
|
|
||||||
playlists = relationship("Playlist", secondary=playlist_music, back_populates="music_items")
|
playlists = relationship("Playlist", secondary=playlist_music, back_populates="music_items")
|
||||||
|
|
||||||
@@ -45,3 +49,12 @@ class Playlist(Base):
|
|||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
music_items = relationship("Music", secondary=playlist_music, back_populates="playlists")
|
music_items = relationship("Music", secondary=playlist_music, back_populates="playlists")
|
||||||
|
|
||||||
|
|
||||||
|
class AppSettings(Base):
|
||||||
|
__tablename__ = "app_settings"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
key = Column(String, unique=True, index=True)
|
||||||
|
value = Column(Text)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ class Music(MusicBase):
|
|||||||
id: int
|
id: int
|
||||||
file_path: str
|
file_path: str
|
||||||
file_size: Optional[int] = None
|
file_size: Optional[int] = None
|
||||||
|
file_format: Optional[str] = None
|
||||||
|
file_location: Optional[str] = None
|
||||||
|
file_exists: bool = True
|
||||||
|
last_scanned_at: Optional[datetime] = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
@@ -83,3 +87,56 @@ class ShareLink(BaseModel):
|
|||||||
music_id: int
|
music_id: int
|
||||||
token: str
|
token: str
|
||||||
url: str
|
url: str
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsResponse(BaseModel):
|
||||||
|
local_music_dir: Optional[str] = None
|
||||||
|
scan_interval: int = 3600
|
||||||
|
delete_missing_files: bool = False
|
||||||
|
auto_scan_enabled: bool = True
|
||||||
|
last_scan_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsUpdate(BaseModel):
|
||||||
|
local_music_dir: Optional[str] = None
|
||||||
|
scan_interval: Optional[int] = None
|
||||||
|
delete_missing_files: Optional[bool] = None
|
||||||
|
auto_scan_enabled: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ScanStatus(BaseModel):
|
||||||
|
is_scanning: bool
|
||||||
|
progress: Optional[int] = None
|
||||||
|
total: Optional[int] = None
|
||||||
|
current_file: Optional[str] = None
|
||||||
|
started_at: Optional[datetime] = None
|
||||||
|
completed_at: Optional[datetime] = None
|
||||||
|
files_added: int = 0
|
||||||
|
files_updated: int = 0
|
||||||
|
files_missing: int = 0
|
||||||
|
errors: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
|
class MusicDetailInfo(BaseModel):
|
||||||
|
id: int
|
||||||
|
title: str
|
||||||
|
artist: Optional[str] = None
|
||||||
|
album: Optional[str] = None
|
||||||
|
duration: Optional[float] = None
|
||||||
|
file_path: str
|
||||||
|
file_location: Optional[str] = None
|
||||||
|
file_size: Optional[int] = None
|
||||||
|
file_format: Optional[str] = None
|
||||||
|
file_exists: bool
|
||||||
|
source_url: Optional[str] = None
|
||||||
|
source_type: Optional[str] = None
|
||||||
|
thumbnail: Optional[str] = None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
last_scanned_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|||||||
@@ -113,7 +113,8 @@ class MusicDownloader:
|
|||||||
# Find the downloaded file
|
# Find the downloaded file
|
||||||
output_file = await self._find_downloaded_file(output_name)
|
output_file = await self._find_downloaded_file(output_name)
|
||||||
if output_file:
|
if output_file:
|
||||||
# Download and embed thumbnail
|
# Download and embed thumbnail from online source
|
||||||
|
# Priority: Online thumbnail -> Embedded in metadata extraction
|
||||||
thumbnail_path = None
|
thumbnail_path = None
|
||||||
if thumbnail_url:
|
if thumbnail_url:
|
||||||
thumbnail_path = await self._download_thumbnail(thumbnail_url, output_file)
|
thumbnail_path = await self._download_thumbnail(thumbnail_url, output_file)
|
||||||
@@ -296,7 +297,12 @@ class MusicDownloader:
|
|||||||
return False, [], str(e)
|
return False, [], str(e)
|
||||||
|
|
||||||
async def get_music_metadata(self, file_path: str) -> dict:
|
async def get_music_metadata(self, file_path: str) -> dict:
|
||||||
"""Extract metadata from audio file using mutagen"""
|
"""Extract metadata from audio file using mutagen
|
||||||
|
|
||||||
|
Thumbnail Priority:
|
||||||
|
1. Embedded thumbnail in audio file (already downloaded from online during download)
|
||||||
|
2. No online fetch here - thumbnails are embedded during download phase
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
# First get basic metadata with easy=True
|
# First get basic metadata with easy=True
|
||||||
audio_easy = mutagen.File(file_path, easy=True)
|
audio_easy = mutagen.File(file_path, easy=True)
|
||||||
|
|||||||
@@ -0,0 +1,394 @@
|
|||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
from datetime import datetime
|
||||||
|
from mutagen import File as MutagenFile
|
||||||
|
from mutagen.easyid3 import EasyID3
|
||||||
|
from mutagen.mp3 import MP3
|
||||||
|
from mutagen.flac import FLAC
|
||||||
|
from mutagen.mp4 import MP4
|
||||||
|
from mutagen.oggvorbis import OggVorbis
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, update
|
||||||
|
from app.models.models import Music, AppSettings
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Global scan status
|
||||||
|
scan_status = {
|
||||||
|
"is_scanning": False,
|
||||||
|
"progress": 0,
|
||||||
|
"total": 0,
|
||||||
|
"current_file": None,
|
||||||
|
"started_at": None,
|
||||||
|
"completed_at": None,
|
||||||
|
"files_added": 0,
|
||||||
|
"files_updated": 0,
|
||||||
|
"files_missing": 0,
|
||||||
|
"errors": []
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_scan_status() -> Dict[str, Any]:
|
||||||
|
"""Get current scan status"""
|
||||||
|
return scan_status.copy()
|
||||||
|
|
||||||
|
|
||||||
|
def reset_scan_status():
|
||||||
|
"""Reset scan status"""
|
||||||
|
global scan_status
|
||||||
|
scan_status = {
|
||||||
|
"is_scanning": False,
|
||||||
|
"progress": 0,
|
||||||
|
"total": 0,
|
||||||
|
"current_file": None,
|
||||||
|
"started_at": None,
|
||||||
|
"completed_at": None,
|
||||||
|
"files_added": 0,
|
||||||
|
"files_updated": 0,
|
||||||
|
"files_missing": 0,
|
||||||
|
"errors": []
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def extract_metadata(file_path: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Extract metadata from audio file using mutagen"""
|
||||||
|
try:
|
||||||
|
audio = MutagenFile(file_path, easy=True)
|
||||||
|
if audio is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
metadata = {
|
||||||
|
"title": None,
|
||||||
|
"artist": None,
|
||||||
|
"album": None,
|
||||||
|
"duration": None,
|
||||||
|
"file_format": None,
|
||||||
|
"file_size": os.path.getsize(file_path),
|
||||||
|
"thumbnail": None
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get file format
|
||||||
|
file_ext = Path(file_path).suffix.lower().lstrip('.')
|
||||||
|
metadata["file_format"] = file_ext
|
||||||
|
|
||||||
|
# Extract duration
|
||||||
|
if hasattr(audio, 'info') and hasattr(audio.info, 'length'):
|
||||||
|
metadata["duration"] = audio.info.length
|
||||||
|
|
||||||
|
# Extract tags - try different tag formats
|
||||||
|
if isinstance(audio, MP3):
|
||||||
|
try:
|
||||||
|
tags = EasyID3(file_path)
|
||||||
|
metadata["title"] = tags.get("title", [None])[0]
|
||||||
|
metadata["artist"] = tags.get("artist", [None])[0]
|
||||||
|
metadata["album"] = tags.get("album", [None])[0]
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Extract embedded thumbnail from MP3
|
||||||
|
try:
|
||||||
|
from mutagen.id3 import ID3, APIC
|
||||||
|
audio_id3 = MP3(file_path, ID3=ID3)
|
||||||
|
if audio_id3.tags:
|
||||||
|
for tag in audio_id3.tags.values():
|
||||||
|
if isinstance(tag, APIC):
|
||||||
|
# Save thumbnail to thumbnails directory
|
||||||
|
thumbnails_dir = os.path.join(settings.MUSIC_DIR, "thumbnails")
|
||||||
|
os.makedirs(thumbnails_dir, exist_ok=True)
|
||||||
|
|
||||||
|
audio_basename = Path(file_path).stem
|
||||||
|
thumbnail_filename = f"{audio_basename}.jpg"
|
||||||
|
thumbnail_path = os.path.join(thumbnails_dir, thumbnail_filename)
|
||||||
|
|
||||||
|
# Save thumbnail
|
||||||
|
with open(thumbnail_path, 'wb') as img_file:
|
||||||
|
img_file.write(tag.data)
|
||||||
|
|
||||||
|
# Store relative path
|
||||||
|
metadata["thumbnail"] = f"thumbnails/{thumbnail_filename}"
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"No thumbnail in MP3 file: {e}")
|
||||||
|
|
||||||
|
elif audio.tags:
|
||||||
|
# Try common tag keys
|
||||||
|
title_keys = ['title', 'TITLE', 'Title', '\xa9nam']
|
||||||
|
artist_keys = ['artist', 'ARTIST', 'Artist', '\xa9ART']
|
||||||
|
album_keys = ['album', 'ALBUM', 'Album', '\xa9alb']
|
||||||
|
|
||||||
|
for key in title_keys:
|
||||||
|
if key in audio.tags:
|
||||||
|
value = audio.tags[key]
|
||||||
|
metadata["title"] = str(value[0]) if isinstance(value, list) else str(value)
|
||||||
|
break
|
||||||
|
|
||||||
|
for key in artist_keys:
|
||||||
|
if key in audio.tags:
|
||||||
|
value = audio.tags[key]
|
||||||
|
metadata["artist"] = str(value[0]) if isinstance(value, list) else str(value)
|
||||||
|
break
|
||||||
|
|
||||||
|
for key in album_keys:
|
||||||
|
if key in audio.tags:
|
||||||
|
value = audio.tags[key]
|
||||||
|
metadata["album"] = str(value[0]) if isinstance(value, list) else str(value)
|
||||||
|
break
|
||||||
|
|
||||||
|
# Extract thumbnail from other formats (FLAC, MP4, etc.)
|
||||||
|
try:
|
||||||
|
if isinstance(audio, FLAC) and audio.pictures:
|
||||||
|
picture = audio.pictures[0]
|
||||||
|
thumbnails_dir = os.path.join(settings.MUSIC_DIR, "thumbnails")
|
||||||
|
os.makedirs(thumbnails_dir, exist_ok=True)
|
||||||
|
|
||||||
|
audio_basename = Path(file_path).stem
|
||||||
|
thumbnail_filename = f"{audio_basename}.jpg"
|
||||||
|
thumbnail_path = os.path.join(thumbnails_dir, thumbnail_filename)
|
||||||
|
|
||||||
|
with open(thumbnail_path, 'wb') as img_file:
|
||||||
|
img_file.write(picture.data)
|
||||||
|
|
||||||
|
metadata["thumbnail"] = f"thumbnails/{thumbnail_filename}"
|
||||||
|
elif isinstance(audio, MP4) and 'covr' in audio.tags:
|
||||||
|
cover = audio.tags['covr'][0]
|
||||||
|
thumbnails_dir = os.path.join(settings.MUSIC_DIR, "thumbnails")
|
||||||
|
os.makedirs(thumbnails_dir, exist_ok=True)
|
||||||
|
|
||||||
|
audio_basename = Path(file_path).stem
|
||||||
|
thumbnail_filename = f"{audio_basename}.jpg"
|
||||||
|
thumbnail_path = os.path.join(thumbnails_dir, thumbnail_filename)
|
||||||
|
|
||||||
|
with open(thumbnail_path, 'wb') as img_file:
|
||||||
|
img_file.write(bytes(cover))
|
||||||
|
|
||||||
|
metadata["thumbnail"] = f"thumbnails/{thumbnail_filename}"
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"No thumbnail in audio file: {e}")
|
||||||
|
|
||||||
|
# Fallback to filename if no title
|
||||||
|
if not metadata["title"]:
|
||||||
|
metadata["title"] = Path(file_path).stem
|
||||||
|
|
||||||
|
return metadata
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to extract metadata from {file_path}: {e}")
|
||||||
|
# Return basic info even if metadata extraction fails
|
||||||
|
return {
|
||||||
|
"title": Path(file_path).stem,
|
||||||
|
"artist": None,
|
||||||
|
"album": None,
|
||||||
|
"duration": None,
|
||||||
|
"file_format": Path(file_path).suffix.lower().lstrip('.'),
|
||||||
|
"file_size": os.path.getsize(file_path) if os.path.exists(file_path) else None,
|
||||||
|
"thumbnail": None
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def create_or_update_music(db: AsyncSession, file_path: str, metadata: Dict[str, Any], source_dir: str) -> str:
|
||||||
|
"""Create or update music entry in database"""
|
||||||
|
try:
|
||||||
|
# Calculate relative path from MUSIC_DIR or use absolute path
|
||||||
|
try:
|
||||||
|
relative_path = str(Path(file_path).relative_to(settings.MUSIC_DIR))
|
||||||
|
except ValueError:
|
||||||
|
# File is not in MUSIC_DIR, use relative to LOCAL_MUSIC_DIR or absolute
|
||||||
|
relative_path = file_path
|
||||||
|
|
||||||
|
# Check if music already exists
|
||||||
|
result = await db.execute(
|
||||||
|
select(Music).where(Music.file_path == relative_path)
|
||||||
|
)
|
||||||
|
existing = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
# Update existing entry
|
||||||
|
existing.title = metadata.get("title") or existing.title
|
||||||
|
existing.artist = metadata.get("artist") or existing.artist
|
||||||
|
existing.album = metadata.get("album") or existing.album
|
||||||
|
existing.duration = metadata.get("duration") or existing.duration
|
||||||
|
existing.file_size = metadata.get("file_size") or existing.file_size
|
||||||
|
existing.file_format = metadata.get("file_format") or existing.file_format
|
||||||
|
existing.file_location = file_path
|
||||||
|
existing.file_exists = True
|
||||||
|
existing.last_scanned_at = datetime.utcnow()
|
||||||
|
existing.updated_at = datetime.utcnow()
|
||||||
|
# Update thumbnail if found in metadata
|
||||||
|
if metadata.get("thumbnail"):
|
||||||
|
existing.thumbnail = metadata.get("thumbnail")
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
logger.info(f"Updated music: {existing.title}")
|
||||||
|
return "updated"
|
||||||
|
else:
|
||||||
|
# Create new entry
|
||||||
|
new_music = Music(
|
||||||
|
title=metadata.get("title", Path(file_path).stem),
|
||||||
|
artist=metadata.get("artist"),
|
||||||
|
album=metadata.get("album"),
|
||||||
|
duration=metadata.get("duration"),
|
||||||
|
file_path=relative_path,
|
||||||
|
file_location=file_path,
|
||||||
|
file_size=metadata.get("file_size"),
|
||||||
|
file_format=metadata.get("file_format"),
|
||||||
|
file_exists=True,
|
||||||
|
source_type="local",
|
||||||
|
thumbnail=metadata.get("thumbnail"),
|
||||||
|
last_scanned_at=datetime.utcnow()
|
||||||
|
)
|
||||||
|
db.add(new_music)
|
||||||
|
await db.commit()
|
||||||
|
logger.info(f"Added new music: {new_music.title}")
|
||||||
|
return "added"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creating/updating music {file_path}: {e}")
|
||||||
|
await db.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def scan_directory(db: AsyncSession, directory: str) -> Dict[str, int]:
|
||||||
|
"""Scan a directory for music files"""
|
||||||
|
if not directory or not os.path.exists(directory):
|
||||||
|
logger.warning(f"Directory does not exist: {directory}")
|
||||||
|
return {"added": 0, "updated": 0, "errors": 0}
|
||||||
|
|
||||||
|
supported_formats = {'.mp3', '.flac', '.m4a', '.mp4', '.wav', '.ogg', '.wma', '.aac'}
|
||||||
|
stats = {"added": 0, "updated": 0, "errors": 0}
|
||||||
|
|
||||||
|
logger.info(f"Scanning directory: {directory}")
|
||||||
|
|
||||||
|
# Walk through directory
|
||||||
|
for root, _, files in os.walk(directory):
|
||||||
|
for file in files:
|
||||||
|
file_ext = Path(file).suffix.lower()
|
||||||
|
if file_ext in supported_formats:
|
||||||
|
file_path = os.path.join(root, file)
|
||||||
|
scan_status["current_file"] = file
|
||||||
|
scan_status["progress"] += 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
metadata = extract_metadata(file_path)
|
||||||
|
if metadata:
|
||||||
|
result = await create_or_update_music(db, file_path, metadata, directory)
|
||||||
|
if result == "added":
|
||||||
|
stats["added"] += 1
|
||||||
|
scan_status["files_added"] += 1
|
||||||
|
elif result == "updated":
|
||||||
|
stats["updated"] += 1
|
||||||
|
scan_status["files_updated"] += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error processing {file_path}: {e}")
|
||||||
|
stats["errors"] += 1
|
||||||
|
scan_status["errors"].append(f"{file}: {str(e)}")
|
||||||
|
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
|
async def check_existing_files(db: AsyncSession, delete_missing: bool = False) -> int:
|
||||||
|
"""Check if existing database entries still exist on disk"""
|
||||||
|
result = await db.execute(select(Music))
|
||||||
|
all_music = result.scalars().all()
|
||||||
|
|
||||||
|
missing_count = 0
|
||||||
|
|
||||||
|
for music in all_music:
|
||||||
|
# Construct full path
|
||||||
|
if music.file_location:
|
||||||
|
file_path = music.file_location
|
||||||
|
else:
|
||||||
|
file_path = os.path.join(settings.MUSIC_DIR, music.file_path)
|
||||||
|
|
||||||
|
exists = os.path.exists(file_path)
|
||||||
|
|
||||||
|
if not exists and music.file_exists:
|
||||||
|
missing_count += 1
|
||||||
|
scan_status["files_missing"] += 1
|
||||||
|
logger.warning(f"File not found: {file_path}")
|
||||||
|
|
||||||
|
if delete_missing:
|
||||||
|
await db.delete(music)
|
||||||
|
logger.info(f"Deleted missing music: {music.title}")
|
||||||
|
else:
|
||||||
|
music.file_exists = False
|
||||||
|
music.last_scanned_at = datetime.utcnow()
|
||||||
|
elif exists and not music.file_exists:
|
||||||
|
# File came back
|
||||||
|
music.file_exists = True
|
||||||
|
music.last_scanned_at = datetime.utcnow()
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
return missing_count
|
||||||
|
|
||||||
|
|
||||||
|
async def full_scan(db: AsyncSession, delete_missing: bool = False):
|
||||||
|
"""Perform a full scan of all music directories"""
|
||||||
|
global scan_status
|
||||||
|
|
||||||
|
if scan_status["is_scanning"]:
|
||||||
|
logger.warning("Scan already in progress")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info("🔍 Starting full music library scan...")
|
||||||
|
reset_scan_status()
|
||||||
|
scan_status["is_scanning"] = True
|
||||||
|
scan_status["started_at"] = datetime.utcnow()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Count total files first
|
||||||
|
total_files = 0
|
||||||
|
supported_formats = {'.mp3', '.flac', '.m4a', '.mp4', '.wav', '.ogg', '.wma', '.aac'}
|
||||||
|
|
||||||
|
for directory in [settings.MUSIC_DIR, settings.LOCAL_MUSIC_DIR]:
|
||||||
|
if directory and os.path.exists(directory):
|
||||||
|
for root, _, files in os.walk(directory):
|
||||||
|
total_files += sum(1 for f in files if Path(f).suffix.lower() in supported_formats)
|
||||||
|
|
||||||
|
scan_status["total"] = total_files
|
||||||
|
logger.info(f"Found {total_files} music files to scan")
|
||||||
|
|
||||||
|
# Scan MUSIC_DIR
|
||||||
|
if os.path.exists(settings.MUSIC_DIR):
|
||||||
|
logger.info(f"Scanning MUSIC_DIR: {settings.MUSIC_DIR}")
|
||||||
|
await scan_directory(db, settings.MUSIC_DIR)
|
||||||
|
|
||||||
|
# Scan LOCAL_MUSIC_DIR if configured
|
||||||
|
if settings.LOCAL_MUSIC_DIR and os.path.exists(settings.LOCAL_MUSIC_DIR):
|
||||||
|
logger.info(f"Scanning LOCAL_MUSIC_DIR: {settings.LOCAL_MUSIC_DIR}")
|
||||||
|
await scan_directory(db, settings.LOCAL_MUSIC_DIR)
|
||||||
|
|
||||||
|
# Check for missing files
|
||||||
|
logger.info("Checking for missing files...")
|
||||||
|
await check_existing_files(db, delete_missing)
|
||||||
|
|
||||||
|
# Update last scan time in settings
|
||||||
|
result = await db.execute(
|
||||||
|
select(AppSettings).where(AppSettings.key == "last_scan_at")
|
||||||
|
)
|
||||||
|
setting = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if setting:
|
||||||
|
setting.value = datetime.utcnow().isoformat()
|
||||||
|
setting.updated_at = datetime.utcnow()
|
||||||
|
else:
|
||||||
|
setting = AppSettings(key="last_scan_at", value=datetime.utcnow().isoformat())
|
||||||
|
db.add(setting)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
scan_status["completed_at"] = datetime.utcnow()
|
||||||
|
logger.info(
|
||||||
|
f"✅ Scan completed! Added: {scan_status['files_added']}, "
|
||||||
|
f"Updated: {scan_status['files_updated']}, "
|
||||||
|
f"Missing: {scan_status['files_missing']}, "
|
||||||
|
f"Errors: {len(scan_status['errors'])}"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error during full scan: {e}")
|
||||||
|
scan_status["errors"].append(f"Scan error: {str(e)}")
|
||||||
|
finally:
|
||||||
|
scan_status["is_scanning"] = False
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
|
from apscheduler.triggers.interval import IntervalTrigger
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.services.scanner import full_scan
|
||||||
|
from app.db.session import async_session_maker
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
scheduler = AsyncIOScheduler()
|
||||||
|
current_interval = settings.SCAN_INTERVAL
|
||||||
|
|
||||||
|
|
||||||
|
async def scheduled_scan_job():
|
||||||
|
"""Background job for scheduled scanning"""
|
||||||
|
logger.info("⏰ Running scheduled music library scan...")
|
||||||
|
|
||||||
|
async with async_session_maker() as db:
|
||||||
|
try:
|
||||||
|
# Get delete_missing setting from database
|
||||||
|
from sqlalchemy import select
|
||||||
|
from app.models.models import AppSettings
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(AppSettings).where(AppSettings.key == "delete_missing_files")
|
||||||
|
)
|
||||||
|
setting = result.scalar_one_or_none()
|
||||||
|
delete_missing = setting.value.lower() == "true" if setting else settings.DELETE_MISSING_FILES
|
||||||
|
|
||||||
|
await full_scan(db, delete_missing)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in scheduled scan: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def start_scheduler():
|
||||||
|
"""Start the background scheduler"""
|
||||||
|
if not settings.AUTO_SCAN_ENABLED:
|
||||||
|
logger.info("📅 Auto-scan is disabled, scheduler not started")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(f"📅 Starting scheduler with interval: {settings.SCAN_INTERVAL} seconds")
|
||||||
|
|
||||||
|
# Add the scan job
|
||||||
|
scheduler.add_job(
|
||||||
|
scheduled_scan_job,
|
||||||
|
trigger=IntervalTrigger(seconds=settings.SCAN_INTERVAL),
|
||||||
|
id='music_scan',
|
||||||
|
name='Music Library Scan',
|
||||||
|
replace_existing=True
|
||||||
|
)
|
||||||
|
|
||||||
|
scheduler.start()
|
||||||
|
logger.info("✅ Scheduler started successfully")
|
||||||
|
|
||||||
|
|
||||||
|
def stop_scheduler():
|
||||||
|
"""Stop the background scheduler"""
|
||||||
|
if scheduler.running:
|
||||||
|
scheduler.shutdown()
|
||||||
|
logger.info("📅 Scheduler stopped")
|
||||||
|
|
||||||
|
|
||||||
|
def update_scan_interval(new_interval: int):
|
||||||
|
"""Update the scan interval dynamically"""
|
||||||
|
global current_interval
|
||||||
|
|
||||||
|
if not settings.AUTO_SCAN_ENABLED:
|
||||||
|
logger.info("Auto-scan is disabled, not updating interval")
|
||||||
|
return
|
||||||
|
|
||||||
|
if new_interval == current_interval:
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(f"Updating scan interval from {current_interval}s to {new_interval}s")
|
||||||
|
current_interval = new_interval
|
||||||
|
|
||||||
|
# Reschedule the job with new interval
|
||||||
|
scheduler.reschedule_job(
|
||||||
|
'music_scan',
|
||||||
|
trigger=IntervalTrigger(seconds=new_interval)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def enable_auto_scan(enabled: bool, interval: int = None):
|
||||||
|
"""Enable or disable automatic scanning"""
|
||||||
|
if enabled:
|
||||||
|
if not scheduler.running:
|
||||||
|
start_scheduler()
|
||||||
|
if interval:
|
||||||
|
update_scan_interval(interval)
|
||||||
|
else:
|
||||||
|
if scheduler.running:
|
||||||
|
stop_scheduler()
|
||||||
+53
-1
@@ -5,9 +5,44 @@ from fastapi import FastAPI
|
|||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from app.api import music, playlist, download, search, stream, artist
|
from app.api import music, playlist, download, search, stream, artist, settings as settings_api
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.db.session import init_db
|
from app.db.session import init_db
|
||||||
|
from app.services.scheduler import start_scheduler, stop_scheduler
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
async def run_migrations():
|
||||||
|
"""Run database migrations on startup"""
|
||||||
|
try:
|
||||||
|
print("🔄 Running database migrations...")
|
||||||
|
# Get the backend directory
|
||||||
|
backend_dir = Path(__file__).parent
|
||||||
|
|
||||||
|
# Run alembic upgrade head
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, "-m", "alembic", "upgrade", "head"],
|
||||||
|
cwd=backend_dir,
|
||||||
|
capture_output=True,
|
||||||
|
text=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode == 0:
|
||||||
|
print("✅ Database migrations completed successfully")
|
||||||
|
if result.stdout:
|
||||||
|
for line in result.stdout.strip().split('\n'):
|
||||||
|
if line.strip():
|
||||||
|
print(f" {line}")
|
||||||
|
else:
|
||||||
|
print("⚠️ Migration warnings:")
|
||||||
|
if result.stderr:
|
||||||
|
for line in result.stderr.strip().split('\n'):
|
||||||
|
if line.strip():
|
||||||
|
print(f" {line}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"⚠️ Could not run migrations: {e}")
|
||||||
|
print(" You may need to run migrations manually: ./migrate.sh upgrade")
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -18,6 +53,7 @@ async def lifespan(app: FastAPI):
|
|||||||
print("=" * 80)
|
print("=" * 80)
|
||||||
print(f"📂 BASE_DIR: {settings.BASE_DIR}")
|
print(f"📂 BASE_DIR: {settings.BASE_DIR}")
|
||||||
print(f"📂 MUSIC_DIR: {settings.MUSIC_DIR}")
|
print(f"📂 MUSIC_DIR: {settings.MUSIC_DIR}")
|
||||||
|
print(f"📂 LOCAL_MUSIC_DIR: {settings.LOCAL_MUSIC_DIR or 'Not configured'}")
|
||||||
print(f"📂 UPLOAD_DIR: {settings.UPLOAD_DIR}")
|
print(f"📂 UPLOAD_DIR: {settings.UPLOAD_DIR}")
|
||||||
print(f"📂 TEMP_DIR: {settings.TEMP_DIR}")
|
print(f"📂 TEMP_DIR: {settings.TEMP_DIR}")
|
||||||
print(f"📂 DATABASE: {settings.DATABASE_URL}")
|
print(f"📂 DATABASE: {settings.DATABASE_URL}")
|
||||||
@@ -30,6 +66,8 @@ async def lifespan(app: FastAPI):
|
|||||||
print(f"📂 ARTIST_CACHE: {artist_cache_dir}")
|
print(f"📂 ARTIST_CACHE: {artist_cache_dir}")
|
||||||
print(f"📂 ARTIST_IMAGES: {artist_images_dir}")
|
print(f"📂 ARTIST_IMAGES: {artist_images_dir}")
|
||||||
print(f"🎬 FFMPEG: {settings.FFMPEG_LOCATION}")
|
print(f"🎬 FFMPEG: {settings.FFMPEG_LOCATION}")
|
||||||
|
print(f"⏰ AUTO_SCAN: {settings.AUTO_SCAN_ENABLED}")
|
||||||
|
print(f"⏱️ SCAN_INTERVAL: {settings.SCAN_INTERVAL}s ({settings.SCAN_INTERVAL // 3600}h)")
|
||||||
print("=" * 80)
|
print("=" * 80)
|
||||||
|
|
||||||
os.makedirs(settings.MUSIC_DIR, exist_ok=True)
|
os.makedirs(settings.MUSIC_DIR, exist_ok=True)
|
||||||
@@ -37,12 +75,25 @@ async def lifespan(app: FastAPI):
|
|||||||
os.makedirs(cache_dir, exist_ok=True)
|
os.makedirs(cache_dir, exist_ok=True)
|
||||||
os.makedirs(artist_cache_dir, exist_ok=True)
|
os.makedirs(artist_cache_dir, exist_ok=True)
|
||||||
os.makedirs(artist_images_dir, exist_ok=True)
|
os.makedirs(artist_images_dir, exist_ok=True)
|
||||||
|
|
||||||
|
if settings.LOCAL_MUSIC_DIR:
|
||||||
|
os.makedirs(settings.LOCAL_MUSIC_DIR, exist_ok=True)
|
||||||
|
print(f"✅ LOCAL_MUSIC_DIR created: {settings.LOCAL_MUSIC_DIR}")
|
||||||
|
|
||||||
|
# Run database migrations
|
||||||
|
await run_migrations()
|
||||||
|
|
||||||
await init_db()
|
await init_db()
|
||||||
|
|
||||||
|
# Start scheduler
|
||||||
|
start_scheduler()
|
||||||
|
|
||||||
print("✅ All directories created and database initialized")
|
print("✅ All directories created and database initialized")
|
||||||
print("=" * 80)
|
print("=" * 80)
|
||||||
yield
|
yield
|
||||||
# Shutdown
|
# Shutdown
|
||||||
|
stop_scheduler()
|
||||||
|
print("🛑 Scheduler stopped")
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
@@ -76,6 +127,7 @@ app.include_router(download.router, prefix="/api/download", tags=["download"])
|
|||||||
app.include_router(search.router, prefix="/api/search", tags=["search"])
|
app.include_router(search.router, prefix="/api/search", tags=["search"])
|
||||||
app.include_router(stream.router, prefix="/api", tags=["stream"])
|
app.include_router(stream.router, prefix="/api", tags=["stream"])
|
||||||
app.include_router(artist.router, prefix="/api/artists", tags=["artists"])
|
app.include_router(artist.router, prefix="/api/artists", tags=["artists"])
|
||||||
|
app.include_router(settings_api.router, prefix="/api/settings", tags=["settings"])
|
||||||
|
|
||||||
|
|
||||||
# Health check endpoint (must be before catch-all route)
|
# Health check endpoint (must be before catch-all route)
|
||||||
|
|||||||
Executable
+79
@@ -0,0 +1,79 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Database migration helper script
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
# Activate virtual environment
|
||||||
|
source .venv/bin/activate
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
"init")
|
||||||
|
echo "🔧 Initializing Alembic (already done)..."
|
||||||
|
;;
|
||||||
|
"create")
|
||||||
|
if [ -z "$2" ]; then
|
||||||
|
echo "❌ Please provide a migration message"
|
||||||
|
echo "Usage: ./migrate.sh create \"migration message\""
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "📝 Creating new migration: $2"
|
||||||
|
alembic revision --autogenerate -m "$2"
|
||||||
|
;;
|
||||||
|
"upgrade")
|
||||||
|
echo "⬆️ Upgrading database to latest version..."
|
||||||
|
alembic upgrade head
|
||||||
|
;;
|
||||||
|
"downgrade")
|
||||||
|
if [ -z "$2" ]; then
|
||||||
|
echo "⬇️ Downgrading database by 1 step..."
|
||||||
|
alembic downgrade -1
|
||||||
|
else
|
||||||
|
echo "⬇️ Downgrading database to: $2"
|
||||||
|
alembic downgrade "$2"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
"history")
|
||||||
|
echo "📜 Migration history:"
|
||||||
|
alembic history
|
||||||
|
;;
|
||||||
|
"current")
|
||||||
|
echo "📍 Current database version:"
|
||||||
|
alembic current
|
||||||
|
;;
|
||||||
|
"stamp")
|
||||||
|
if [ -z "$2" ]; then
|
||||||
|
echo "🏷️ Stamping database to head..."
|
||||||
|
alembic stamp head
|
||||||
|
else
|
||||||
|
echo "🏷️ Stamping database to: $2"
|
||||||
|
alembic stamp "$2"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "YouMusic Database Migration Tool"
|
||||||
|
echo ""
|
||||||
|
echo "Usage: ./migrate.sh <command> [options]"
|
||||||
|
echo ""
|
||||||
|
echo "Commands:"
|
||||||
|
echo " create <message> Create a new migration with autogenerate"
|
||||||
|
echo " upgrade [revision] Upgrade to latest (head) or specified revision"
|
||||||
|
echo " downgrade [revision] Downgrade one step or to specified revision"
|
||||||
|
echo " history Show migration history"
|
||||||
|
echo " current Show current database version"
|
||||||
|
echo " stamp [revision] Stamp database to head or specified revision (without running migrations)"
|
||||||
|
echo ""
|
||||||
|
echo "Examples:"
|
||||||
|
echo " ./migrate.sh create \"Add artist bio field\""
|
||||||
|
echo " ./migrate.sh upgrade"
|
||||||
|
echo " ./migrate.sh downgrade"
|
||||||
|
echo " ./migrate.sh history"
|
||||||
|
echo " ./migrate.sh current"
|
||||||
|
echo " ./migrate.sh stamp head"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo "✅ Done!"
|
||||||
@@ -20,6 +20,8 @@ dependencies = [
|
|||||||
"pydantic-settings==2.5.2",
|
"pydantic-settings==2.5.2",
|
||||||
"sqlalchemy==2.0.35",
|
"sqlalchemy==2.0.35",
|
||||||
"aiosqlite==0.20.0",
|
"aiosqlite==0.20.0",
|
||||||
|
"alembic==1.17.1",
|
||||||
|
"apscheduler==3.10.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.uv]
|
[tool.uv]
|
||||||
|
|||||||
@@ -12,3 +12,5 @@ pydantic-settings==2.5.2
|
|||||||
sqlalchemy[asyncio]==2.0.35
|
sqlalchemy[asyncio]==2.0.35
|
||||||
aiosqlite==0.20.0
|
aiosqlite==0.20.0
|
||||||
greenlet==3.1.1
|
greenlet==3.1.1
|
||||||
|
apscheduler==3.10.4
|
||||||
|
alembic==1.17.1
|
||||||
|
|||||||
Executable
+12
@@ -0,0 +1,12 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Run database migrations
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "🔄 Running database migrations..."
|
||||||
|
|
||||||
|
cd /app/backend
|
||||||
|
|
||||||
|
# Run migrations
|
||||||
|
alembic upgrade head
|
||||||
|
|
||||||
|
echo "✅ Migrations completed successfully!"
|
||||||
Executable
+14
@@ -0,0 +1,14 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Startup script that runs migrations then starts the app
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "🔄 Running database migrations..."
|
||||||
|
cd /app/backend
|
||||||
|
alembic upgrade head
|
||||||
|
|
||||||
|
echo "✅ Migrations complete!"
|
||||||
|
echo "🚀 Starting YouMusic application..."
|
||||||
|
|
||||||
|
# Start the application
|
||||||
|
exec uvicorn main:app --host 0.0.0.0 --port 8000
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Start Backend Development Server
|
|
||||||
|
|
||||||
echo "🔧 Starting Backend Development Server..."
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
cd backend
|
|
||||||
|
|
||||||
# Activate virtual environment
|
|
||||||
if [ ! -d ".venv" ]; then
|
|
||||||
echo "❌ Virtual environment not found. Run ./dev-setup.sh first"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
source .venv/bin/activate
|
|
||||||
|
|
||||||
# Check if .env exists
|
|
||||||
if [ ! -f ".env" ]; then
|
|
||||||
echo "⚠️ Creating .env from .env.example..."
|
|
||||||
cp .env.example .env
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Create data directories
|
|
||||||
mkdir -p ../data/music ../data/uploads ../data/temp
|
|
||||||
|
|
||||||
echo "✅ Backend starting on http://localhost:8000"
|
|
||||||
echo "📚 API Docs: http://localhost:8000/docs"
|
|
||||||
echo ""
|
|
||||||
echo "Press Ctrl+C to stop"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Run with auto-reload
|
|
||||||
python main.py
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Start Frontend Development Server
|
|
||||||
|
|
||||||
echo "🎨 Starting Frontend Development Server..."
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
cd frontend
|
|
||||||
|
|
||||||
# Check if node_modules exists
|
|
||||||
if [ ! -d "node_modules" ]; then
|
|
||||||
echo "❌ Dependencies not installed. Run ./dev-setup.sh first"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check if .env exists
|
|
||||||
if [ ! -f ".env" ]; then
|
|
||||||
echo "⚠️ Creating .env from .env.example..."
|
|
||||||
cp .env.example .env
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "✅ Frontend starting on http://localhost:3000"
|
|
||||||
echo "🔄 Hot reload enabled"
|
|
||||||
echo ""
|
|
||||||
echo "Press Ctrl+C to stop"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Run dev server
|
|
||||||
npm run dev
|
|
||||||
+2
-2
@@ -110,10 +110,10 @@ cd frontend
|
|||||||
|
|
||||||
if [ ! -d "node_modules" ]; then
|
if [ ! -d "node_modules" ]; then
|
||||||
echo "Installing npm dependencies (this may take a few minutes)..."
|
echo "Installing npm dependencies (this may take a few minutes)..."
|
||||||
npm install --legacy-peer-deps
|
NODE_ENV= npm install --include=dev
|
||||||
else
|
else
|
||||||
echo "Updating npm dependencies..."
|
echo "Updating npm dependencies..."
|
||||||
npm install --legacy-peer-deps
|
NODE_ENV= npm install --include=dev
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo -e "${GREEN}✅ Frontend setup complete${NC}"
|
echo -e "${GREEN}✅ Frontend setup complete${NC}"
|
||||||
|
|||||||
+139
-9
@@ -1,7 +1,7 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
# YouMusic Development Server with Foreman-like Process Management
|
# YouMusic Development Stack - Complete Setup & Start
|
||||||
# Uses overmind (better than foreman for local dev)
|
# Handles: dependency checks, installation, migrations, and server startup
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
@@ -16,11 +16,141 @@ YELLOW='\033[1;33m'
|
|||||||
BLUE='\033[0;34m'
|
BLUE='\033[0;34m'
|
||||||
NC='\033[0m' # No Color
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
# Check if setup was run
|
# ============================================================================
|
||||||
if [ ! -d "backend/.venv" ] || [ ! -d "frontend/node_modules" ]; then
|
# 1. Check System Dependencies
|
||||||
echo -e "${RED}❌ Setup not complete. Run ./dev-setup.sh first${NC}"
|
# ============================================================================
|
||||||
|
echo "🔍 Checking system dependencies..."
|
||||||
|
|
||||||
|
# Check Python 3.13
|
||||||
|
if ! command -v python3.13 &> /dev/null; then
|
||||||
|
echo -e "${RED}❌ Python 3.13 is required but not installed${NC}"
|
||||||
|
echo "Install: brew install python@3.13 (macOS) or apt-get install python3.13 (Linux)"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
echo -e "${GREEN}✅ Python 3.13 found${NC}"
|
||||||
|
|
||||||
|
# Check uv
|
||||||
|
if ! command -v uv &> /dev/null; then
|
||||||
|
echo -e "${YELLOW}⚠️ uv not found, installing...${NC}"
|
||||||
|
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||||
|
export PATH="$HOME/.cargo/bin:$PATH"
|
||||||
|
if ! command -v uv &> /dev/null; then
|
||||||
|
echo -e "${RED}❌ Failed to install uv${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
echo -e "${GREEN}✅ uv found${NC}"
|
||||||
|
|
||||||
|
# Check Node.js
|
||||||
|
if ! command -v node &> /dev/null; then
|
||||||
|
echo -e "${RED}❌ Node.js is required but not installed${NC}"
|
||||||
|
echo "Please install Node.js 18 or higher"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo -e "${GREEN}✅ Node.js found${NC}"
|
||||||
|
|
||||||
|
# Check FFmpeg
|
||||||
|
if ! command -v ffmpeg &> /dev/null; then
|
||||||
|
echo -e "${YELLOW}⚠️ FFmpeg not found${NC}"
|
||||||
|
echo "FFmpeg is required for audio processing"
|
||||||
|
echo "Install: brew install ffmpeg (macOS) or apt-get install ffmpeg (Linux)"
|
||||||
|
read -p "Continue anyway? (y/n) " -n 1 -r
|
||||||
|
echo
|
||||||
|
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo -e "${GREEN}✅ FFmpeg found${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 2. Setup Project Directories
|
||||||
|
# ============================================================================
|
||||||
|
echo "📁 Setting up project directories..."
|
||||||
|
mkdir -p data/music data/uploads data/temp logs
|
||||||
|
echo -e "${GREEN}✅ Directories ready${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 3. Backend Setup
|
||||||
|
# ============================================================================
|
||||||
|
echo "🔧 Setting up Backend..."
|
||||||
|
|
||||||
|
# Create virtual environment if needed
|
||||||
|
if [ ! -d "backend/.venv" ]; then
|
||||||
|
echo "Creating Python 3.13 virtual environment..."
|
||||||
|
cd backend
|
||||||
|
uv venv --python 3.13
|
||||||
|
cd ..
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Install/update Python dependencies
|
||||||
|
echo "Installing Python dependencies..."
|
||||||
|
cd backend
|
||||||
|
source .venv/bin/activate
|
||||||
|
uv pip install -r requirements.txt
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
# Create backend .env if needed
|
||||||
|
if [ ! -f "backend/.env" ]; then
|
||||||
|
echo "Creating backend .env file..."
|
||||||
|
cp backend/.env.example backend/.env 2>/dev/null || echo "Warning: .env.example not found"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${GREEN}✅ Backend setup complete${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 4. Frontend Setup
|
||||||
|
# ============================================================================
|
||||||
|
echo "🎨 Setting up Frontend..."
|
||||||
|
|
||||||
|
# Install/update npm dependencies
|
||||||
|
if [ ! -d "frontend/node_modules" ] || [ ! -f "frontend/node_modules/.bin/vite" ]; then
|
||||||
|
echo "Installing npm dependencies..."
|
||||||
|
cd frontend
|
||||||
|
NODE_ENV= npm install --include=dev
|
||||||
|
cd ..
|
||||||
|
else
|
||||||
|
echo "npm dependencies already installed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create frontend .env if needed
|
||||||
|
if [ ! -f "frontend/.env" ]; then
|
||||||
|
echo "Creating frontend .env file..."
|
||||||
|
cp frontend/.env.example frontend/.env 2>/dev/null || echo "Warning: .env.example not found"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${GREEN}✅ Frontend setup complete${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 5. Run Database Migrations
|
||||||
|
# ============================================================================
|
||||||
|
echo "🔄 Running database migrations..."
|
||||||
|
cd backend
|
||||||
|
source .venv/bin/activate
|
||||||
|
|
||||||
|
if [ -d "alembic" ]; then
|
||||||
|
alembic upgrade head
|
||||||
|
echo -e "${GREEN}✅ Database migrations complete${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}⚠️ No alembic directory found, skipping migrations${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd ..
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 6. Start Development Servers
|
||||||
|
# ============================================================================
|
||||||
|
echo "🚀 Starting development servers..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Clean up overmind socket if it exists
|
||||||
|
rm -f .overmind.sock
|
||||||
|
|
||||||
# Check for process managers (in order of preference)
|
# Check for process managers (in order of preference)
|
||||||
if command -v overmind &> /dev/null; then
|
if command -v overmind &> /dev/null; then
|
||||||
@@ -66,16 +196,16 @@ else
|
|||||||
|
|
||||||
trap cleanup SIGINT SIGTERM
|
trap cleanup SIGINT SIGTERM
|
||||||
|
|
||||||
# Start backend in background
|
# Start backend in background with output to console
|
||||||
cd backend
|
cd backend
|
||||||
source .venv/bin/activate
|
source .venv/bin/activate
|
||||||
uv run uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 2>&1 | sed 's/^/[backend] /' &
|
uv run uvicorn main:app --reload --host 0.0.0.0 --port 8000 2>&1 | sed 's/^/backend | /' &
|
||||||
BACKEND_PID=$!
|
BACKEND_PID=$!
|
||||||
cd ..
|
cd ..
|
||||||
|
|
||||||
# Start frontend in background
|
# Start frontend in background with output to console
|
||||||
cd frontend
|
cd frontend
|
||||||
npm run dev 2>&1 | sed 's/^/[frontend] /' &
|
npm run dev 2>&1 | sed 's/^/frontend | /' &
|
||||||
FRONTEND_PID=$!
|
FRONTEND_PID=$!
|
||||||
cd ..
|
cd ..
|
||||||
|
|
||||||
|
|||||||
+21
-9
@@ -5,29 +5,41 @@
|
|||||||
echo "🛑 Stopping Development Servers..."
|
echo "🛑 Stopping Development Servers..."
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
|
# Stop overmind if running
|
||||||
|
if command -v overmind &> /dev/null && [ -f ".overmind.sock" ]; then
|
||||||
|
echo "Stopping overmind..."
|
||||||
|
overmind quit 2>/dev/null || pkill -f overmind 2>/dev/null
|
||||||
|
rm -f .overmind.sock
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Kill processes by PID files
|
||||||
if [ -f "logs/backend.pid" ]; then
|
if [ -f "logs/backend.pid" ]; then
|
||||||
BACKEND_PID=$(cat logs/backend.pid)
|
BACKEND_PID=$(cat logs/backend.pid)
|
||||||
if ps -p $BACKEND_PID > /dev/null; then
|
if ps -p $BACKEND_PID > /dev/null 2>&1; then
|
||||||
echo "Stopping Backend (PID: $BACKEND_PID)..."
|
echo "Stopping Backend (PID: $BACKEND_PID)..."
|
||||||
kill $BACKEND_PID
|
kill -9 $BACKEND_PID 2>/dev/null
|
||||||
echo "✅ Backend stopped"
|
|
||||||
fi
|
fi
|
||||||
rm logs/backend.pid
|
rm logs/backend.pid
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -f "logs/frontend.pid" ]; then
|
if [ -f "logs/frontend.pid" ]; then
|
||||||
FRONTEND_PID=$(cat logs/frontend.pid)
|
FRONTEND_PID=$(cat logs/frontend.pid)
|
||||||
if ps -p $FRONTEND_PID > /dev/null; then
|
if ps -p $FRONTEND_PID > /dev/null 2>&1; then
|
||||||
echo "Stopping Frontend (PID: $FRONTEND_PID)..."
|
echo "Stopping Frontend (PID: $FRONTEND_PID)..."
|
||||||
kill $FRONTEND_PID
|
kill -9 $FRONTEND_PID 2>/dev/null
|
||||||
echo "✅ Frontend stopped"
|
|
||||||
fi
|
fi
|
||||||
rm logs/frontend.pid
|
rm logs/frontend.pid
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Clean up any remaining processes
|
# Kill processes by name
|
||||||
pkill -f "uvicorn main:app" 2>/dev/null
|
pkill -9 -f "uvicorn main:app" 2>/dev/null
|
||||||
pkill -f "vite" 2>/dev/null
|
pkill -9 -f "vite --host" 2>/dev/null
|
||||||
|
pkill -9 -f "node.*vite" 2>/dev/null
|
||||||
|
|
||||||
|
# Kill by port
|
||||||
|
lsof -ti:8000 | xargs kill -9 2>/dev/null || true
|
||||||
|
lsof -ti:3000 | xargs kill -9 2>/dev/null || true
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "✅ All development servers stopped"
|
echo "✅ All development servers stopped"
|
||||||
|
|
||||||
|
|||||||
+6
-3
@@ -7,16 +7,19 @@ services:
|
|||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
# Note: Do not mount ./backend in production - it overwrites the built image
|
|
||||||
# For development, use ./dev.sh instead
|
|
||||||
environment:
|
environment:
|
||||||
|
- PYTHONUNBUFFERED=1
|
||||||
|
- PYTHONPATH=/app/backend
|
||||||
- DATABASE_URL=sqlite+aiosqlite:////app/data/youmusic.db
|
- DATABASE_URL=sqlite+aiosqlite:////app/data/youmusic.db
|
||||||
- MUSIC_DIR=/app/data/music
|
- MUSIC_DIR=/app/data/music
|
||||||
|
- LOCAL_MUSIC_DIR=/app/data/local-music
|
||||||
- UPLOAD_DIR=/app/data/uploads
|
- UPLOAD_DIR=/app/data/uploads
|
||||||
- TEMP_DIR=/app/data/temp
|
- TEMP_DIR=/app/data/temp
|
||||||
|
- BASE_DIR=/app
|
||||||
|
- FFMPEG_LOCATION=ffmpeg
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
test: ["CMD", "curl", "-f", "http://localhost:8000/"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|||||||
Generated
+61
-7
@@ -10,14 +10,16 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-dialog": "^1.1.1",
|
"@radix-ui/react-dialog": "^1.1.1",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
||||||
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
"@radix-ui/react-select": "^2.1.1",
|
"@radix-ui/react-select": "^2.1.1",
|
||||||
"@radix-ui/react-slider": "^1.2.0",
|
"@radix-ui/react-slider": "^1.2.0",
|
||||||
"@radix-ui/react-slot": "^1.1.0",
|
"@radix-ui/react-slot": "^1.1.0",
|
||||||
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@radix-ui/react-tabs": "^1.1.0",
|
"@radix-ui/react-tabs": "^1.1.0",
|
||||||
"@radix-ui/react-toast": "^1.2.1",
|
"@radix-ui/react-toast": "^1.2.1",
|
||||||
"@tanstack/react-query": "^5.56.2",
|
"@tanstack/react-query": "^5.56.2",
|
||||||
"axios": "^1.7.7",
|
"axios": "^1.7.7",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.441.0",
|
"lucide-react": "^0.441.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
@@ -32,15 +34,15 @@
|
|||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
||||||
"@typescript-eslint/parser": "^7.18.0",
|
"@typescript-eslint/parser": "^7.18.0",
|
||||||
"@vitejs/plugin-react": "^4.3.1",
|
"@vitejs/plugin-react": "^4.7.0",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.21",
|
||||||
"eslint": "^8.57.0",
|
"eslint": "^8.57.0",
|
||||||
"eslint-plugin-react-hooks": "^4.6.2",
|
"eslint-plugin-react-hooks": "^4.6.2",
|
||||||
"eslint-plugin-react-refresh": "^0.4.11",
|
"eslint-plugin-react-refresh": "^0.4.11",
|
||||||
"postcss": "^8.4.47",
|
"postcss": "^8.5.6",
|
||||||
"tailwindcss": "^3.4.11",
|
"tailwindcss": "^3.4.18",
|
||||||
"typescript": "^5.6.2",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^5.4.5"
|
"vite": "^5.4.21"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@alloc/quick-lru": {
|
"node_modules/@alloc/quick-lru": {
|
||||||
@@ -1338,6 +1340,29 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-label": {
|
||||||
|
"version": "2.1.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz",
|
||||||
|
"integrity": "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-primitive": "2.1.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-menu": {
|
"node_modules/@radix-ui/react-menu": {
|
||||||
"version": "2.1.16",
|
"version": "2.1.16",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz",
|
||||||
@@ -1606,6 +1631,35 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-switch": {
|
||||||
|
"version": "1.2.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz",
|
||||||
|
"integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.3",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||||
|
"@radix-ui/react-use-previous": "1.1.1",
|
||||||
|
"@radix-ui/react-use-size": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-tabs": {
|
"node_modules/@radix-ui/react-tabs": {
|
||||||
"version": "1.1.13",
|
"version": "1.1.13",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz",
|
||||||
|
|||||||
+22
-20
@@ -10,38 +10,40 @@
|
|||||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
|
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@radix-ui/react-dialog": "^1.1.1",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
||||||
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
|
"@radix-ui/react-select": "^2.1.1",
|
||||||
|
"@radix-ui/react-slider": "^1.2.0",
|
||||||
|
"@radix-ui/react-slot": "^1.1.0",
|
||||||
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.0",
|
||||||
|
"@radix-ui/react-toast": "^1.2.1",
|
||||||
|
"@tanstack/react-query": "^5.56.2",
|
||||||
|
"axios": "^1.7.7",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.441.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^6.26.2",
|
"react-router-dom": "^6.26.2",
|
||||||
"@tanstack/react-query": "^5.56.2",
|
"sonner": "^1.5.0",
|
||||||
"axios": "^1.7.7",
|
|
||||||
"class-variance-authority": "^0.7.0",
|
|
||||||
"clsx": "^2.1.1",
|
|
||||||
"lucide-react": "^0.441.0",
|
|
||||||
"tailwind-merge": "^2.5.2",
|
"tailwind-merge": "^2.5.2",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7"
|
||||||
"@radix-ui/react-dialog": "^1.1.1",
|
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
|
||||||
"@radix-ui/react-select": "^2.1.1",
|
|
||||||
"@radix-ui/react-slider": "^1.2.0",
|
|
||||||
"@radix-ui/react-tabs": "^1.1.0",
|
|
||||||
"@radix-ui/react-toast": "^1.2.1",
|
|
||||||
"@radix-ui/react-slot": "^1.1.0",
|
|
||||||
"sonner": "^1.5.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.3.5",
|
"@types/react": "^18.3.5",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
||||||
"@typescript-eslint/parser": "^7.18.0",
|
"@typescript-eslint/parser": "^7.18.0",
|
||||||
"@vitejs/plugin-react": "^4.3.1",
|
"@vitejs/plugin-react": "^4.7.0",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.21",
|
||||||
"eslint": "^8.57.0",
|
"eslint": "^8.57.0",
|
||||||
"eslint-plugin-react-hooks": "^4.6.2",
|
"eslint-plugin-react-hooks": "^4.6.2",
|
||||||
"eslint-plugin-react-refresh": "^0.4.11",
|
"eslint-plugin-react-refresh": "^0.4.11",
|
||||||
"postcss": "^8.4.47",
|
"postcss": "^8.5.6",
|
||||||
"tailwindcss": "^3.4.11",
|
"tailwindcss": "^3.4.18",
|
||||||
"typescript": "^5.6.2",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^5.4.5"
|
"vite": "^5.4.21"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import PlaylistDetailPage from './components/playlist/PlaylistDetailPage'
|
|||||||
import DownloadCenter from './components/download/DownloadCenter'
|
import DownloadCenter from './components/download/DownloadCenter'
|
||||||
import ArtistsPage from './components/artist/ArtistsPage'
|
import ArtistsPage from './components/artist/ArtistsPage'
|
||||||
import ArtistDetailPage from './components/artist/ArtistDetailPage'
|
import ArtistDetailPage from './components/artist/ArtistDetailPage'
|
||||||
|
import SettingsPage from './components/settings/SettingsPage'
|
||||||
import Navigation from './components/Navigation'
|
import Navigation from './components/Navigation'
|
||||||
import { Toaster } from 'sonner'
|
import { Toaster } from 'sonner'
|
||||||
|
|
||||||
@@ -132,9 +133,10 @@ function App() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (audioRef.current && currentMusic) {
|
if (audioRef.current && currentMusic) {
|
||||||
// Check if it's a streaming URL or a local file
|
// Check if it's a streaming URL
|
||||||
const isStreamUrl = currentMusic.file_path.startsWith('/api/stream') || currentMusic.file_path.startsWith('http')
|
const isStreamUrl = currentMusic.file_path.startsWith('/api/stream') || currentMusic.file_path.startsWith('http')
|
||||||
audioRef.current.src = isStreamUrl ? currentMusic.file_path : `/music/${currentMusic.file_path}`
|
// Use dedicated file serving endpoint for all local files
|
||||||
|
audioRef.current.src = isStreamUrl ? currentMusic.file_path : `/api/music/file/${currentMusic.id}`
|
||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
audioRef.current.play()
|
audioRef.current.play()
|
||||||
}
|
}
|
||||||
@@ -164,6 +166,7 @@ function App() {
|
|||||||
<Route path="/playlists" element={<PlaylistsPage onPlayMusic={playMusic} />} />
|
<Route path="/playlists" element={<PlaylistsPage onPlayMusic={playMusic} />} />
|
||||||
<Route path="/playlists/:playlistId" element={<PlaylistDetailPage onPlayMusic={playMusic} />} />
|
<Route path="/playlists/:playlistId" element={<PlaylistDetailPage onPlayMusic={playMusic} />} />
|
||||||
<Route path="/downloads" element={<DownloadCenter onPlayMusic={playMusic} />} />
|
<Route path="/downloads" element={<DownloadCenter onPlayMusic={playMusic} />} />
|
||||||
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export const musicApi = {
|
|||||||
getAll: () => api.get('/music/'),
|
getAll: () => api.get('/music/'),
|
||||||
search: (query: string) => api.get('/music/search', { params: { q: query } }),
|
search: (query: string) => api.get('/music/search', { params: { q: query } }),
|
||||||
getById: (id: number) => api.get(`/music/${id}`),
|
getById: (id: number) => api.get(`/music/${id}`),
|
||||||
|
getDetailInfo: (id: number) => api.get(`/music/${id}/info`),
|
||||||
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}`),
|
||||||
upload: (file: File) => {
|
upload: (file: File) => {
|
||||||
@@ -66,5 +67,13 @@ export const searchApi = {
|
|||||||
export const artistApi = {
|
export const artistApi = {
|
||||||
getAll: () => api.get('/artists/'),
|
getAll: () => api.get('/artists/'),
|
||||||
getArtistSongs: (artistName: string) => api.get(`/artists/${encodeURIComponent(artistName)}`),
|
getArtistSongs: (artistName: string) => api.get(`/artists/${encodeURIComponent(artistName)}`),
|
||||||
getArtistInfo: (artistName: string) => api.get(`/artists/${encodeURIComponent(artistName)}/info`),
|
getArtistInfo: (artistName: string) => api.get(`/artists/info`, { params: { artist_name: artistName } }),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings API
|
||||||
|
export const settingsApi = {
|
||||||
|
get: () => api.get('/settings/'),
|
||||||
|
update: (data: any) => api.put('/settings/', data),
|
||||||
|
scan: () => api.post('/settings/scan'),
|
||||||
|
getScanStatus: () => api.get('/settings/scan-status'),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { useState } from 'react'
|
||||||
import { musicApi } from '@/api/client'
|
import { musicApi } from '@/api/client'
|
||||||
import { Music } from '@/types'
|
import { Music } from '@/types'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Play } from 'lucide-react'
|
import { Play, AlertCircle, Info } from 'lucide-react'
|
||||||
|
import MusicDetailModal from './music/MusicDetailModal'
|
||||||
|
|
||||||
interface MusicLibraryProps {
|
interface MusicLibraryProps {
|
||||||
onPlayMusic: (music: Music, playlist: Music[]) => void
|
onPlayMusic: (music: Music, playlist: Music[]) => void
|
||||||
@@ -11,6 +13,8 @@ interface MusicLibraryProps {
|
|||||||
|
|
||||||
export default function MusicLibrary({ onPlayMusic }: MusicLibraryProps) {
|
export default function MusicLibrary({ onPlayMusic }: MusicLibraryProps) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const [selectedMusicId, setSelectedMusicId] = useState<number | null>(null)
|
||||||
|
|
||||||
const { data: musicList = [], isLoading } = useQuery({
|
const { data: musicList = [], isLoading } = useQuery({
|
||||||
queryKey: ['music'],
|
queryKey: ['music'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -50,13 +54,19 @@ export default function MusicLibrary({ onPlayMusic }: MusicLibraryProps) {
|
|||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => onPlayMusic(music, musicList)}
|
onClick={() => onPlayMusic(music, musicList)}
|
||||||
|
disabled={!music.file_exists}
|
||||||
>
|
>
|
||||||
<Play className="h-5 w-5" />
|
<Play className="h-5 w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="font-medium truncate">{music.title}</h3>
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="font-medium truncate">{music.title}</h3>
|
||||||
|
{!music.file_exists && (
|
||||||
|
<AlertCircle className="h-4 w-4 text-destructive flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{music.artist && music.artist !== 'Unknown' ? (
|
{music.artist && music.artist !== 'Unknown' ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(`/artists/${encodeURIComponent(music.artist!)}`)}
|
onClick={() => navigate(`/artists/${encodeURIComponent(music.artist!)}`)}
|
||||||
@@ -71,12 +81,27 @@ export default function MusicLibrary({ onPlayMusic }: MusicLibraryProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="flex items-center gap-2">
|
||||||
{music.duration && `${Math.floor(music.duration / 60)}:${String(Math.floor(music.duration % 60)).padStart(2, '0')}`}
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{music.duration && `${Math.floor(music.duration / 60)}:${String(Math.floor(music.duration % 60)).padStart(2, '0')}`}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setSelectedMusicId(music.id)}
|
||||||
|
>
|
||||||
|
<Info className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<MusicDetailModal
|
||||||
|
musicId={selectedMusicId}
|
||||||
|
open={selectedMusicId !== null}
|
||||||
|
onClose={() => setSelectedMusicId(null)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Link, useLocation } from 'react-router-dom'
|
import { Link, useLocation } from 'react-router-dom'
|
||||||
import { Home, Search, ListMusic, Download, Music2, Sun, Moon } from 'lucide-react'
|
import { Home, Search, ListMusic, Download, Music2, Sun, Moon, Settings } from 'lucide-react'
|
||||||
import { Button } from './ui/button'
|
import { Button } from './ui/button'
|
||||||
|
|
||||||
interface NavigationProps {
|
interface NavigationProps {
|
||||||
@@ -16,6 +16,7 @@ export default function Navigation({ onToggleTheme, theme }: NavigationProps) {
|
|||||||
{ path: '/artists', label: 'Artists', icon: Music2 },
|
{ path: '/artists', label: 'Artists', icon: Music2 },
|
||||||
{ path: '/playlists', label: 'Playlists', icon: ListMusic },
|
{ path: '/playlists', label: 'Playlists', icon: ListMusic },
|
||||||
{ path: '/downloads', label: 'Downloads', icon: Download },
|
{ path: '/downloads', label: 'Downloads', icon: Download },
|
||||||
|
{ path: '/settings', label: 'Settings', icon: Settings },
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export default function ArtistDetailPage({ onPlayMusic }: ArtistDetailProps) {
|
|||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 bg-cover bg-center"
|
className="absolute inset-0 bg-cover bg-center"
|
||||||
style={{ backgroundImage: `url(${artistInfo.image})` }}
|
style={{ backgroundImage: `url(${artistInfo.image.startsWith('http') ? artistInfo.image : `/${artistInfo.image}`})` }}
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-gradient-to-b from-black/60 via-black/70 to-background" />
|
<div className="absolute inset-0 bg-gradient-to-b from-black/60 via-black/70 to-background" />
|
||||||
</>
|
</>
|
||||||
@@ -83,12 +83,17 @@ export default function ArtistDetailPage({ onPlayMusic }: ArtistDetailProps) {
|
|||||||
src={artistInfo.image.startsWith('http') ? artistInfo.image : `/${artistInfo.image}`}
|
src={artistInfo.image.startsWith('http') ? artistInfo.image : `/${artistInfo.image}`}
|
||||||
alt={decodedArtistName}
|
alt={decodedArtistName}
|
||||||
className="w-48 h-48 rounded-lg shadow-2xl object-cover"
|
className="w-48 h-48 rounded-lg shadow-2xl object-cover"
|
||||||
|
onError={(e) => {
|
||||||
|
// Hide broken image and show fallback
|
||||||
|
e.currentTarget.style.display = 'none'
|
||||||
|
const fallback = e.currentTarget.nextElementSibling
|
||||||
|
if (fallback) (fallback as HTMLElement).style.display = 'flex'
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : null}
|
||||||
<div className="w-48 h-48 rounded-lg bg-gradient-to-br from-white/20 to-white/5 backdrop-blur-sm shadow-2xl flex items-center justify-center">
|
<div className="w-48 h-48 rounded-lg bg-gradient-to-br from-white/20 to-white/5 backdrop-blur-sm shadow-2xl flex items-center justify-center" style={{ display: artistInfo?.image ? 'none' : 'flex' }}>
|
||||||
<Music2 className="h-20 w-20 text-white/80" />
|
<Music2 className="h-20 w-20 text-white/80" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 text-white">
|
<div className="flex-1 text-white">
|
||||||
|
|||||||
@@ -75,12 +75,16 @@ export default function ArtistsPage() {
|
|||||||
src={artistImage.startsWith('http') ? artistImage : `/${artistImage}`}
|
src={artistImage.startsWith('http') ? artistImage : `/${artistImage}`}
|
||||||
alt={artist.name}
|
alt={artist.name}
|
||||||
className="w-32 h-32 mx-auto rounded-full object-cover shadow-lg group-hover:shadow-xl transition-shadow"
|
className="w-32 h-32 mx-auto rounded-full object-cover shadow-lg group-hover:shadow-xl transition-shadow"
|
||||||
|
onError={(e) => {
|
||||||
|
e.currentTarget.style.display = 'none'
|
||||||
|
const fallback = e.currentTarget.nextElementSibling
|
||||||
|
if (fallback) (fallback as HTMLElement).style.display = 'flex'
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : null}
|
||||||
<div className="w-32 h-32 mx-auto rounded-full bg-gradient-to-br from-primary/20 to-primary/5 flex items-center justify-center">
|
<div className="w-32 h-32 mx-auto rounded-full bg-gradient-to-br from-primary/20 to-primary/5 flex items-center justify-center" style={{ display: artistImage ? 'none' : 'flex' }}>
|
||||||
<Music2 className="h-12 w-12 text-primary" />
|
<Music2 className="h-12 w-12 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<h3 className="font-semibold truncate mb-1">{artist.name}</h3>
|
<h3 className="font-semibold truncate mb-1">{artist.name}</h3>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { musicApi } from '@/api/client'
|
||||||
|
import { MusicDetailInfo } from '@/types'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { Loader2, FileAudio, HardDrive, Clock, CheckCircle, XCircle } from 'lucide-react'
|
||||||
|
|
||||||
|
interface MusicDetailModalProps {
|
||||||
|
musicId: number | null
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MusicDetailModal({ musicId, open, onClose }: MusicDetailModalProps) {
|
||||||
|
const { data: musicInfo, isLoading } = useQuery<MusicDetailInfo>({
|
||||||
|
queryKey: ['music-detail', musicId],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!musicId) return null
|
||||||
|
const response = await musicApi.getDetailInfo(musicId)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
enabled: !!musicId && open
|
||||||
|
})
|
||||||
|
|
||||||
|
const formatFileSize = (bytes: number | null | undefined) => {
|
||||||
|
if (!bytes) return '0 B'
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(1024))
|
||||||
|
return `${(bytes / Math.pow(1024, i)).toFixed(2)} ${sizes[i]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDuration = (seconds: number | null | undefined) => {
|
||||||
|
if (!seconds) return '0:00'
|
||||||
|
const mins = Math.floor(seconds / 60)
|
||||||
|
const secs = Math.floor(seconds % 60)
|
||||||
|
return `${mins}:${String(secs).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onClose}>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Music Details</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Detailed information about this music file
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : musicInfo ? (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Thumbnail */}
|
||||||
|
{musicInfo.thumbnail && (
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<img
|
||||||
|
src={musicInfo.thumbnail.startsWith('http') ? musicInfo.thumbnail : `/music/${musicInfo.thumbnail}`}
|
||||||
|
alt={musicInfo.title}
|
||||||
|
className="w-48 h-48 rounded-lg object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Basic Info */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-semibold text-muted-foreground">Title</label>
|
||||||
|
<p className="text-base">{musicInfo.title}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-semibold text-muted-foreground">Artist</label>
|
||||||
|
<p className="text-base">{musicInfo.artist || 'Unknown'}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{musicInfo.album && (
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-semibold text-muted-foreground">Album</label>
|
||||||
|
<p className="text-base">{musicInfo.album}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-semibold text-muted-foreground">Duration</label>
|
||||||
|
<p className="text-base">{formatDuration(musicInfo.duration)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* File Info */}
|
||||||
|
<div className="border-t pt-4">
|
||||||
|
<h3 className="text-lg font-semibold mb-3 flex items-center gap-2">
|
||||||
|
<FileAudio className="h-5 w-5" />
|
||||||
|
File Information
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-semibold text-muted-foreground">Format</label>
|
||||||
|
<p className="text-base uppercase">{musicInfo.file_format || 'Unknown'}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-semibold text-muted-foreground">Size</label>
|
||||||
|
<p className="text-base">{formatFileSize(musicInfo.file_size)}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-semibold text-muted-foreground">Source</label>
|
||||||
|
<p className="text-base capitalize">{musicInfo.source_type || 'Unknown'}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-semibold text-muted-foreground flex items-center gap-2">
|
||||||
|
File Status
|
||||||
|
</label>
|
||||||
|
<p className="text-base flex items-center gap-2">
|
||||||
|
{musicInfo.file_exists ? (
|
||||||
|
<>
|
||||||
|
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||||
|
<span>Available</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<XCircle className="h-4 w-4 text-destructive" />
|
||||||
|
<span>Missing</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className="text-sm font-semibold text-muted-foreground flex items-center gap-2">
|
||||||
|
<HardDrive className="h-4 w-4" />
|
||||||
|
File Path
|
||||||
|
</label>
|
||||||
|
<p className="text-sm font-mono bg-muted p-2 rounded mt-1 break-all">
|
||||||
|
{musicInfo.file_location || 'Not available'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timestamps */}
|
||||||
|
<div className="border-t pt-4">
|
||||||
|
<h3 className="text-lg font-semibold mb-3 flex items-center gap-2">
|
||||||
|
<Clock className="h-5 w-5" />
|
||||||
|
Timestamps
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-semibold text-muted-foreground">Created</label>
|
||||||
|
<p className="text-base">{new Date(musicInfo.created_at).toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-semibold text-muted-foreground">Updated</label>
|
||||||
|
<p className="text-base">{new Date(musicInfo.updated_at).toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{musicInfo.last_scanned_at && (
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-semibold text-muted-foreground">Last Scanned</label>
|
||||||
|
<p className="text-base">{new Date(musicInfo.last_scanned_at).toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Source URL */}
|
||||||
|
{musicInfo.source_url && (
|
||||||
|
<div className="border-t pt-4">
|
||||||
|
<label className="text-sm font-semibold text-muted-foreground">Source URL</label>
|
||||||
|
<a
|
||||||
|
href={musicInfo.source_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-sm text-primary hover:underline break-all block mt-1"
|
||||||
|
>
|
||||||
|
{musicInfo.source_url}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -54,12 +54,12 @@ export default function SearchPage({ onPlayMusic }: SearchPageProps) {
|
|||||||
// Create temporary music object for streaming playback
|
// Create temporary music object for streaming playback
|
||||||
const streamUrl = `/api/stream?url=${encodeURIComponent(url)}`
|
const streamUrl = `/api/stream?url=${encodeURIComponent(url)}`
|
||||||
const tempMusic: Music = {
|
const tempMusic: Music = {
|
||||||
id: 0, // Temporary ID
|
id: 0,
|
||||||
title,
|
title,
|
||||||
artist: artist || null,
|
artist: artist || null,
|
||||||
album: null,
|
album: null,
|
||||||
duration: null,
|
duration: null,
|
||||||
file_path: streamUrl, // Use streaming endpoint
|
file_path: streamUrl,
|
||||||
file_size: null,
|
file_size: null,
|
||||||
source_url: url,
|
source_url: url,
|
||||||
source_type: url.includes('youtube') ? 'youtube' : 'bilibili',
|
source_type: url.includes('youtube') ? 'youtube' : 'bilibili',
|
||||||
@@ -67,6 +67,10 @@ export default function SearchPage({ onPlayMusic }: SearchPageProps) {
|
|||||||
lyrics: null,
|
lyrics: null,
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
updated_at: new Date().toISOString(),
|
updated_at: new Date().toISOString(),
|
||||||
|
file_format: null,
|
||||||
|
file_location: null,
|
||||||
|
file_exists: true,
|
||||||
|
last_scanned_at: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
onPlayMusic(tempMusic)
|
onPlayMusic(tempMusic)
|
||||||
|
|||||||
@@ -0,0 +1,301 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { settingsApi } from '@/api/client'
|
||||||
|
import { Settings, ScanStatus } from '@/types'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Slider } from '@/components/ui/slider'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { Loader2, FolderOpen, RefreshCw, CheckCircle, Clock } from 'lucide-react'
|
||||||
|
import { Progress } from '@/components/ui/progress'
|
||||||
|
|
||||||
|
export default function SettingsPage() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [localSettings, setLocalSettings] = useState<Partial<Settings>>({})
|
||||||
|
const [scanIntervalHours, setScanIntervalHours] = useState(1)
|
||||||
|
|
||||||
|
const { data: settings, isLoading } = useQuery<Settings>({
|
||||||
|
queryKey: ['settings'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await settingsApi.get()
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: scanStatus, refetch: refetchScanStatus } = useQuery<ScanStatus>({
|
||||||
|
queryKey: ['scan-status'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await settingsApi.getScanStatus()
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
refetchInterval: (query) => query.state.data?.is_scanning ? 2000 : false
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (settings) {
|
||||||
|
setLocalSettings(settings)
|
||||||
|
setScanIntervalHours(settings.scan_interval / 3600)
|
||||||
|
}
|
||||||
|
}, [settings])
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: async (data: Partial<Settings>) => {
|
||||||
|
const response = await settingsApi.update(data)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['settings'] })
|
||||||
|
toast.success('Settings updated successfully')
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error('Failed to update settings')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const scanMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const response = await settingsApi.scan()
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Scan started')
|
||||||
|
refetchScanStatus()
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error('Failed to start scan')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
updateMutation.mutate({
|
||||||
|
...localSettings,
|
||||||
|
scan_interval: scanIntervalHours * 3600
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleScanNow = () => {
|
||||||
|
scanMutation.mutate()
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatInterval = (hours: number) => {
|
||||||
|
if (hours < 1) return `${Math.round(hours * 60)} minutes`
|
||||||
|
if (hours === 1) return '1 hour'
|
||||||
|
if (hours < 24) return `${hours} hours`
|
||||||
|
return `${hours / 24} day${hours / 24 > 1 ? 's' : ''}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format file size helper (keeping for potential future use)
|
||||||
|
// const formatFileSize = (bytes: number | null | undefined) => {
|
||||||
|
// if (!bytes) return '0 B'
|
||||||
|
// const sizes = ['B', 'KB', 'MB', 'GB']
|
||||||
|
// const i = Math.floor(Math.log(bytes) / Math.log(1024))
|
||||||
|
// return `${(bytes / Math.pow(1024, i)).toFixed(2)} ${sizes[i]}`
|
||||||
|
// }
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto p-6 max-w-4xl">
|
||||||
|
<h1 className="text-3xl font-bold mb-6">Settings</h1>
|
||||||
|
|
||||||
|
{/* Music Directory Settings */}
|
||||||
|
<Card className="mb-6">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Music Directories</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Configure where your music files are stored
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="local-music-dir" className="flex items-center gap-2">
|
||||||
|
<FolderOpen className="h-4 w-4" />
|
||||||
|
Local Music Directory
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="local-music-dir"
|
||||||
|
type="text"
|
||||||
|
placeholder="/path/to/your/music"
|
||||||
|
value={localSettings.local_music_dir || ''}
|
||||||
|
onChange={(e) => setLocalSettings({ ...localSettings, local_music_dir: e.target.value })}
|
||||||
|
className="mt-2"
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-muted-foreground mt-2">
|
||||||
|
Additional directory to scan for music files (optional)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Scanner Settings */}
|
||||||
|
<Card className="mb-6">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Automatic Scanning</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Configure how often to scan for music files
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="auto-scan">Enable Auto-Scan</Label>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Automatically scan for new music files
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="auto-scan"
|
||||||
|
checked={localSettings.auto_scan_enabled || false}
|
||||||
|
onCheckedChange={(checked: boolean) =>
|
||||||
|
setLocalSettings({ ...localSettings, auto_scan_enabled: checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="scan-interval">
|
||||||
|
Scan Interval: {formatInterval(scanIntervalHours)}
|
||||||
|
</Label>
|
||||||
|
<Slider
|
||||||
|
id="scan-interval"
|
||||||
|
min={0.5}
|
||||||
|
max={24}
|
||||||
|
step={0.5}
|
||||||
|
value={[scanIntervalHours]}
|
||||||
|
onValueChange={([value]) => setScanIntervalHours(value)}
|
||||||
|
className="mt-2"
|
||||||
|
disabled={!localSettings.auto_scan_enabled}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between text-xs text-muted-foreground mt-1">
|
||||||
|
<span>30 min</span>
|
||||||
|
<span>24 hours</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="delete-missing">Delete Missing Files</Label>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Remove database entries for missing files
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="delete-missing"
|
||||||
|
checked={localSettings.delete_missing_files || false}
|
||||||
|
onCheckedChange={(checked: boolean) =>
|
||||||
|
setLocalSettings({ ...localSettings, delete_missing_files: checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{settings?.last_scan_at && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Clock className="h-4 w-4" />
|
||||||
|
Last scan: {new Date(settings.last_scan_at).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Scan Status */}
|
||||||
|
{scanStatus && (
|
||||||
|
<Card className="mb-6">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Scan Status</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{scanStatus.is_scanning ? (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
<span>Scanning in progress...</span>
|
||||||
|
</div>
|
||||||
|
{scanStatus.total && scanStatus.total > 0 && (
|
||||||
|
<div>
|
||||||
|
<Progress value={(scanStatus.progress || 0) / scanStatus.total * 100} />
|
||||||
|
<p className="text-sm text-muted-foreground mt-2">
|
||||||
|
{scanStatus.progress} / {scanStatus.total} files
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{scanStatus.current_file && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Current: {scanStatus.current_file}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2 text-green-600">
|
||||||
|
<CheckCircle className="h-4 w-4" />
|
||||||
|
<span>Ready</span>
|
||||||
|
</div>
|
||||||
|
{scanStatus.completed_at && (
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Added:</span>{' '}
|
||||||
|
<span className="font-semibold">{scanStatus.files_added}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Updated:</span>{' '}
|
||||||
|
<span className="font-semibold">{scanStatus.files_updated}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Missing:</span>{' '}
|
||||||
|
<span className="font-semibold">{scanStatus.files_missing}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Errors:</span>{' '}
|
||||||
|
<span className="font-semibold">{scanStatus.errors.length}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{scanStatus.errors.length > 0 && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<p className="text-sm font-semibold text-red-600 mb-2">Errors:</p>
|
||||||
|
<div className="max-h-40 overflow-y-auto space-y-1">
|
||||||
|
{scanStatus.errors.map((error, i) => (
|
||||||
|
<p key={i} className="text-xs text-red-600">{error}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={updateMutation.isPending}
|
||||||
|
>
|
||||||
|
{updateMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
Save Settings
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleScanNow}
|
||||||
|
disabled={scanMutation.isPending || scanStatus?.is_scanning}
|
||||||
|
>
|
||||||
|
{(scanMutation.isPending || scanStatus?.is_scanning) && (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
|
Scan Now
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Card = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Card.displayName = "Card"
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardHeader.displayName = "CardHeader"
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLHeadingElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<h3
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"text-2xl font-semibold leading-none tracking-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardTitle.displayName = "CardTitle"
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardDescription.displayName = "CardDescription"
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||||
|
))
|
||||||
|
CardContent.displayName = "CardContent"
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex items-center p-6 pt-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardFooter.displayName = "CardFooter"
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||||
|
import { X } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Dialog = DialogPrimitive.Root
|
||||||
|
|
||||||
|
const DialogTrigger = DialogPrimitive.Trigger
|
||||||
|
|
||||||
|
const DialogPortal = DialogPrimitive.Portal
|
||||||
|
|
||||||
|
const DialogClose = DialogPrimitive.Close
|
||||||
|
|
||||||
|
const DialogOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||||
|
|
||||||
|
const DialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg max-h-[90vh] overflow-y-auto",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
))
|
||||||
|
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const DialogHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
DialogHeader.displayName = "DialogHeader"
|
||||||
|
|
||||||
|
const DialogFooter = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
DialogFooter.displayName = "DialogFooter"
|
||||||
|
|
||||||
|
const DialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"text-lg font-semibold leading-none tracking-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||||
|
|
||||||
|
const DialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogPortal,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogClose,
|
||||||
|
DialogTrigger,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogFooter,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const labelVariants = cva(
|
||||||
|
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
)
|
||||||
|
|
||||||
|
const Label = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||||
|
VariantProps<typeof labelVariants>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(labelVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Label.displayName = LabelPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Label }
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Progress = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement> & { value?: number }
|
||||||
|
>(({ className, value, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative h-2 w-full overflow-hidden rounded-full bg-secondary",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="h-full w-full flex-1 bg-primary transition-all"
|
||||||
|
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
Progress.displayName = "Progress"
|
||||||
|
|
||||||
|
export { Progress }
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Switch = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SwitchPrimitives.Root
|
||||||
|
className={cn(
|
||||||
|
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<SwitchPrimitives.Thumb
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SwitchPrimitives.Root>
|
||||||
|
))
|
||||||
|
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||||
|
|
||||||
|
export { Switch }
|
||||||
@@ -6,6 +6,10 @@ export interface Music {
|
|||||||
duration: number | null
|
duration: number | null
|
||||||
file_path: string
|
file_path: string
|
||||||
file_size: number | null
|
file_size: number | null
|
||||||
|
file_format: string | null
|
||||||
|
file_location: string | null
|
||||||
|
file_exists: boolean
|
||||||
|
last_scanned_at: string | null
|
||||||
source_url: string | null
|
source_url: string | null
|
||||||
source_type: string | null
|
source_type: string | null
|
||||||
thumbnail: string | null
|
thumbnail: string | null
|
||||||
@@ -14,6 +18,31 @@ export interface Music {
|
|||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MusicDetailInfo extends Music {
|
||||||
|
// Already includes all fields from Music
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Settings {
|
||||||
|
local_music_dir: string | null
|
||||||
|
scan_interval: number
|
||||||
|
delete_missing_files: boolean
|
||||||
|
auto_scan_enabled: boolean
|
||||||
|
last_scan_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScanStatus {
|
||||||
|
is_scanning: boolean
|
||||||
|
progress: number | null
|
||||||
|
total: number | null
|
||||||
|
current_file: string | null
|
||||||
|
started_at: string | null
|
||||||
|
completed_at: string | null
|
||||||
|
files_added: number
|
||||||
|
files_updated: number
|
||||||
|
files_missing: number
|
||||||
|
errors: string[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface Playlist {
|
export interface Playlist {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
|
|||||||
@@ -12,6 +12,16 @@ This guide explains how to deploy YouMusic to your k3s cluster.
|
|||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
|
### 0. Database Migrations
|
||||||
|
|
||||||
|
YouMusic now uses Alembic for database migrations. An init container will automatically run migrations before the app starts.
|
||||||
|
|
||||||
|
**How it works:**
|
||||||
|
- Init container runs `/app/backend/run-migrations.sh`
|
||||||
|
- Executes `alembic upgrade head`
|
||||||
|
- Must complete successfully before app starts
|
||||||
|
- Shares the same data volume with the app
|
||||||
|
|
||||||
### 1. Build and Push Docker Image
|
### 1. Build and Push Docker Image
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -74,6 +84,9 @@ kubectl get ingress youmusic-ingress
|
|||||||
# Check pod logs
|
# Check pod logs
|
||||||
kubectl logs -f deployment/youmusic
|
kubectl logs -f deployment/youmusic
|
||||||
|
|
||||||
|
# Check migration init container logs
|
||||||
|
kubectl logs -l app=youmusic -c youmusic-migrations
|
||||||
|
|
||||||
# Check if pod is running
|
# Check if pod is running
|
||||||
kubectl get pods
|
kubectl get pods
|
||||||
|
|
||||||
@@ -293,6 +306,9 @@ kubectl logs deployment/youmusic
|
|||||||
### Database Issues
|
### Database Issues
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Check migration init container logs
|
||||||
|
kubectl logs -l app=youmusic -c youmusic-migrations
|
||||||
|
|
||||||
# Exec into pod
|
# Exec into pod
|
||||||
kubectl exec -it deployment/youmusic -- bash
|
kubectl exec -it deployment/youmusic -- bash
|
||||||
|
|
||||||
@@ -302,6 +318,12 @@ ls -la youmusic.db
|
|||||||
|
|
||||||
# Check SQLite
|
# Check SQLite
|
||||||
sqlite3 youmusic.db "SELECT COUNT(*) FROM music;"
|
sqlite3 youmusic.db "SELECT COUNT(*) FROM music;"
|
||||||
|
|
||||||
|
# Manually run migrations if needed
|
||||||
|
cd /app/backend
|
||||||
|
./migrate.sh current
|
||||||
|
./migrate.sh history
|
||||||
|
./migrate.sh upgrade
|
||||||
```
|
```
|
||||||
|
|
||||||
### Storage Full
|
### Storage Full
|
||||||
|
|||||||
@@ -50,6 +50,32 @@ spec:
|
|||||||
- name: data
|
- name: data
|
||||||
persistentVolumeClaim:
|
persistentVolumeClaim:
|
||||||
claimName: youmusic-data-pvc
|
claimName: youmusic-data-pvc
|
||||||
|
|
||||||
|
# Init container to run database migrations
|
||||||
|
initContainers:
|
||||||
|
- name: youmusic-migrations
|
||||||
|
image: "ghcr.io/wahyd4/youmusic:latest"
|
||||||
|
imagePullPolicy: Always
|
||||||
|
command: ["/bin/bash", "/app/backend/run-migrations.sh"]
|
||||||
|
env:
|
||||||
|
- name: PYTHONUNBUFFERED
|
||||||
|
value: "1"
|
||||||
|
- name: PYTHONPATH
|
||||||
|
value: "/app/backend"
|
||||||
|
- name: DATABASE_URL
|
||||||
|
value: "sqlite+aiosqlite:///./data/youmusic.db"
|
||||||
|
- name: MUSIC_DIR
|
||||||
|
value: "/app/data/music"
|
||||||
|
- name: UPLOAD_DIR
|
||||||
|
value: "/app/data/uploads"
|
||||||
|
- name: TEMP_DIR
|
||||||
|
value: "/app/data/temp"
|
||||||
|
- name: BASE_DIR
|
||||||
|
value: "/app"
|
||||||
|
volumeMounts:
|
||||||
|
- name: data
|
||||||
|
mountPath: /app/data
|
||||||
|
|
||||||
containers:
|
containers:
|
||||||
- name: youmusic
|
- name: youmusic
|
||||||
image: "ghcr.io/wahyd4/youmusic:latest" # Update with your image
|
image: "ghcr.io/wahyd4/youmusic:latest" # Update with your image
|
||||||
@@ -60,10 +86,14 @@ spec:
|
|||||||
env:
|
env:
|
||||||
- name: PYTHONUNBUFFERED
|
- name: PYTHONUNBUFFERED
|
||||||
value: "1"
|
value: "1"
|
||||||
|
- name: PYTHONPATH
|
||||||
|
value: "/app/backend"
|
||||||
- name: DATABASE_URL
|
- name: DATABASE_URL
|
||||||
value: "sqlite+aiosqlite:///./data/youmusic.db"
|
value: "sqlite+aiosqlite:///./data/youmusic.db"
|
||||||
- name: MUSIC_DIR
|
- name: MUSIC_DIR
|
||||||
value: "/app/data/music"
|
value: "/app/data/music"
|
||||||
|
- name: LOCAL_MUSIC_DIR
|
||||||
|
value: "/app/data/local-music"
|
||||||
- name: UPLOAD_DIR
|
- name: UPLOAD_DIR
|
||||||
value: "/app/data/uploads"
|
value: "/app/data/uploads"
|
||||||
- name: TEMP_DIR
|
- name: TEMP_DIR
|
||||||
|
|||||||
Reference in New Issue
Block a user