add db migration

This commit is contained in:
2025-10-30 22:35:02 +11:00
parent 5dd486844f
commit 9c6ed82a56
56 changed files with 3009 additions and 305 deletions
+3
View File
@@ -27,6 +27,9 @@ frontend/.vite/
*.db
*.sqlite
# Alembic - Keep migration files but not pycache
backend/alembic/versions/__pycache__/
# Data directories
data/
*.mp3
+42 -3
View File
@@ -82,6 +82,17 @@ frontend/src/
## 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
**music**
@@ -286,10 +297,37 @@ docker-compose up -d
### 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`**
2. **Update schema in `backend/app/schemas/schemas.py`**
3. **Delete database** `rm data/youmusic.db` (recreates on restart)
4. **Restart backend** to create new schema
```python
class Music(Base):
# ... 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
@@ -340,6 +378,7 @@ npm test
- `uvicorn` - ASGI server
- `sqlalchemy` - ORM
- `aiosqlite` - Async SQLite driver
- `alembic` - Database migrations
- `yt-dlp` - Universal downloader
- `mutagen` - Audio metadata
- `pydantic` - Data validation
+5 -2
View File
@@ -31,6 +31,9 @@ RUN pip install --no-cache-dir -r requirements.txt
# Copy backend code
COPY backend/ ./backend/
# Make scripts executable
RUN chmod +x ./backend/run-migrations.sh ./backend/start.sh
# Copy frontend build from builder stage
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
WORKDIR /app/backend
# Run the application
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
# Run migrations and start the application
CMD ["/app/backend/start.sh"]
+264
View File
@@ -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
View File
@@ -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
+54 -3
View File
@@ -316,14 +316,65 @@ uvicorn main:app --port 8001
```
### Database errors
Delete the database file and restart:
If you encounter database errors, try the following:
**With Migrations (Recommended):**
```bash
rm data/youmusic.db
docker-compose restart
# Check current migration status
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
### 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
```bash
# Backend
+50
View File
@@ -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`
+148
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
Generic single-database configuration.
+100
View File
@@ -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()
+28
View File
@@ -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
View File
@@ -211,34 +211,54 @@ async def get_artists(db: AsyncSession = Depends(get_db)):
return artists
@router.get("/{artist_name}/info", response_model=ArtistInfo)
async def get_artist_info(artist_name: str):
"""Get artist information from Deezer and MusicBrainz (with 30-day cache)"""
# Try to get from cache first
@router.get("/info", response_model=ArtistInfo)
async def get_artist_info(artist_name: str, db: AsyncSession = Depends(get_db)):
"""Get artist information from Deezer and MusicBrainz (with 30-day cache)
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)
if 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)
if not info:
# Return minimal info if APIs fail
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)
return info
@router.delete("/{artist_name}/cache")
@router.delete("/cache", response_model=dict)
async def clear_artist_cache(artist_name: str):
"""Clear cached artist info (forces refresh on next request)"""
cache_path = get_cache_path(artist_name)
+5 -1
View File
@@ -35,8 +35,9 @@ async def process_download(
source_type = "youtube" if music_downloader.is_youtube_url(url) else \
"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)
file_extension = os.path.splitext(file_path)[1][1:] # Get extension without dot
# Create database record
db_music = Music(
@@ -46,6 +47,9 @@ async def process_download(
duration=metadata.get("duration", 0),
file_path=relative_path,
file_size=os.path.getsize(file_path),
file_format=file_extension,
file_location=file_path,
file_exists=True,
source_url=url,
source_type=source_type,
thumbnail=metadata.get("thumbnail") # Add thumbnail
+64 -2
View File
@@ -1,4 +1,5 @@
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, or_
from typing import List, Optional
@@ -8,7 +9,7 @@ from pathlib import Path
from app.db.session import get_db
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.core.config import settings
@@ -19,11 +20,18 @@ router = APIRouter()
async def get_all_music(
skip: int = 0,
limit: int = 100,
include_missing: bool = True,
db: AsyncSession = Depends(get_db)
):
"""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(
select(Music).offset(skip).limit(limit)
query.offset(skip).limit(limit)
)
music_list = result.scalars().all()
return music_list
@@ -59,6 +67,18 @@ async def get_music(music_id: int, db: AsyncSession = Depends(get_db)):
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)
async def update_music(
music_id: int,
@@ -125,6 +145,9 @@ async def upload_music(
# Extract metadata
metadata = await music_downloader.get_music_metadata(file_path)
# Get file format
file_extension = os.path.splitext(file.filename)[1][1:]
# Create database record
db_music = Music(
title=metadata.get("title", file.filename),
@@ -133,6 +156,9 @@ async def upload_music(
duration=metadata.get("duration", 0),
file_path=os.path.join("uploads", file.filename),
file_size=os.path.getsize(file_path),
file_format=file_extension,
file_location=file_path,
file_exists=True,
source_type="upload"
)
@@ -183,6 +209,9 @@ async def scan_music_directory(db: AsyncSession = Depends(get_db)):
duration=metadata.get("duration", 0),
file_path=relative_path,
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"
)
@@ -192,3 +221,36 @@ async def scan_music_directory(db: AsyncSession = Depends(get_db)):
await db.commit()
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)
)
+132
View File
@@ -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"]
)
+17
View File
@@ -11,8 +11,10 @@ class Settings(BaseSettings):
# Directories
BASE_DIR: str = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
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")
TEMP_DIR: str = os.path.join(BASE_DIR, "data", "temp")
CACHE_DIR: str = os.path.join(BASE_DIR, "data", "cache")
# Database
DATABASE_URL: str = "sqlite+aiosqlite:///./data/youmusic.db"
@@ -29,6 +31,11 @@ class Settings(BaseSettings):
YT_DLP_AUDIO_FORMAT: str = "mp3"
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:
case_sensitive = True
env_file = ".env"
@@ -40,3 +47,13 @@ settings = Settings()
os.makedirs(settings.MUSIC_DIR, exist_ok=True)
os.makedirs(settings.UPLOAD_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}")
+3
View File
@@ -14,6 +14,9 @@ AsyncSessionLocal = async_sessionmaker(
expire_on_commit=False,
)
# Alias for compatibility
async_session_maker = AsyncSessionLocal
Base = declarative_base()
+14 -1
View File
@@ -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 datetime import datetime
from app.db.session import Base
@@ -24,12 +24,16 @@ class Music(Base):
duration = Column(Float, nullable=True)
file_path = Column(String, unique=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_type = Column(String, nullable=True) # local, youtube, bilibili, etc.
thumbnail = Column(String, nullable=True)
lyrics = Column(Text, nullable=True)
created_at = Column(DateTime, default=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")
@@ -45,3 +49,12 @@ class Playlist(Base):
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
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)
+57
View File
@@ -29,6 +29,10 @@ class Music(MusicBase):
id: int
file_path: str
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
updated_at: datetime
@@ -83,3 +87,56 @@ class ShareLink(BaseModel):
music_id: int
token: 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
+8 -2
View File
@@ -113,7 +113,8 @@ class MusicDownloader:
# Find the downloaded file
output_file = await self._find_downloaded_file(output_name)
if output_file:
# Download and embed thumbnail
# Download and embed thumbnail from online source
# Priority: Online thumbnail -> Embedded in metadata extraction
thumbnail_path = None
if thumbnail_url:
thumbnail_path = await self._download_thumbnail(thumbnail_url, output_file)
@@ -296,7 +297,12 @@ class MusicDownloader:
return False, [], str(e)
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:
# First get basic metadata with easy=True
audio_easy = mutagen.File(file_path, easy=True)
+394
View File
@@ -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
+95
View File
@@ -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
View File
@@ -5,9 +5,44 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
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.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
@@ -18,6 +53,7 @@ async def lifespan(app: FastAPI):
print("=" * 80)
print(f"📂 BASE_DIR: {settings.BASE_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"📂 TEMP_DIR: {settings.TEMP_DIR}")
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_IMAGES: {artist_images_dir}")
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)
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(artist_cache_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()
# Start scheduler
start_scheduler()
print("✅ All directories created and database initialized")
print("=" * 80)
yield
# Shutdown
stop_scheduler()
print("🛑 Scheduler stopped")
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(stream.router, prefix="/api", tags=["stream"])
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)
+79
View File
@@ -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!"
+2
View File
@@ -20,6 +20,8 @@ dependencies = [
"pydantic-settings==2.5.2",
"sqlalchemy==2.0.35",
"aiosqlite==0.20.0",
"alembic==1.17.1",
"apscheduler==3.10.4",
]
[tool.uv]
+2
View File
@@ -12,3 +12,5 @@ pydantic-settings==2.5.2
sqlalchemy[asyncio]==2.0.35
aiosqlite==0.20.0
greenlet==3.1.1
apscheduler==3.10.4
alembic==1.17.1
+12
View File
@@ -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!"
+14
View File
@@ -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
-34
View File
@@ -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
-29
View File
@@ -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
View File
@@ -110,10 +110,10 @@ cd frontend
if [ ! -d "node_modules" ]; then
echo "Installing npm dependencies (this may take a few minutes)..."
npm install --legacy-peer-deps
NODE_ENV= npm install --include=dev
else
echo "Updating npm dependencies..."
npm install --legacy-peer-deps
NODE_ENV= npm install --include=dev
fi
echo -e "${GREEN}✅ Frontend setup complete${NC}"
+139 -9
View File
@@ -1,7 +1,7 @@
#!/bin/bash
# YouMusic Development Server with Foreman-like Process Management
# Uses overmind (better than foreman for local dev)
# YouMusic Development Stack - Complete Setup & Start
# Handles: dependency checks, installation, migrations, and server startup
set -e
@@ -16,11 +16,141 @@ YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Check if setup was run
if [ ! -d "backend/.venv" ] || [ ! -d "frontend/node_modules" ]; then
echo -e "${RED}❌ Setup not complete. Run ./dev-setup.sh first${NC}"
# ============================================================================
# 1. Check System Dependencies
# ============================================================================
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
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)
if command -v overmind &> /dev/null; then
@@ -66,16 +196,16 @@ else
trap cleanup SIGINT SIGTERM
# Start backend in background
# Start backend in background with output to console
cd backend
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=$!
cd ..
# Start frontend in background
# Start frontend in background with output to console
cd frontend
npm run dev 2>&1 | sed 's/^/[frontend] /' &
npm run dev 2>&1 | sed 's/^/frontend | /' &
FRONTEND_PID=$!
cd ..
+21 -9
View File
@@ -5,29 +5,41 @@
echo "🛑 Stopping Development Servers..."
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
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)..."
kill $BACKEND_PID
echo "✅ Backend stopped"
kill -9 $BACKEND_PID 2>/dev/null
fi
rm logs/backend.pid
fi
if [ -f "logs/frontend.pid" ]; then
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)..."
kill $FRONTEND_PID
echo "✅ Frontend stopped"
kill -9 $FRONTEND_PID 2>/dev/null
fi
rm logs/frontend.pid
fi
# Clean up any remaining processes
pkill -f "uvicorn main:app" 2>/dev/null
pkill -f "vite" 2>/dev/null
# Kill processes by name
pkill -9 -f "uvicorn main:app" 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 "✅ All development servers stopped"
+6 -3
View File
@@ -7,16 +7,19 @@ services:
- "8000:8000"
volumes:
- ./data:/app/data
# Note: Do not mount ./backend in production - it overwrites the built image
# For development, use ./dev.sh instead
environment:
- PYTHONUNBUFFERED=1
- PYTHONPATH=/app/backend
- DATABASE_URL=sqlite+aiosqlite:////app/data/youmusic.db
- MUSIC_DIR=/app/data/music
- LOCAL_MUSIC_DIR=/app/data/local-music
- UPLOAD_DIR=/app/data/uploads
- TEMP_DIR=/app/data/temp
- BASE_DIR=/app
- FFMPEG_LOCATION=ffmpeg
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
test: ["CMD", "curl", "-f", "http://localhost:8000/"]
interval: 30s
timeout: 10s
retries: 3
+61 -7
View File
@@ -10,14 +10,16 @@
"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.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.441.0",
"react": "^18.3.1",
@@ -32,15 +34,15 @@
"@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.20",
"@vitejs/plugin-react": "^4.7.0",
"autoprefixer": "^10.4.21",
"eslint": "^8.57.0",
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-react-refresh": "^0.4.11",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.11",
"typescript": "^5.6.2",
"vite": "^5.4.5"
"postcss": "^8.5.6",
"tailwindcss": "^3.4.18",
"typescript": "^5.9.3",
"vite": "^5.4.21"
}
},
"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": {
"version": "2.1.16",
"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": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz",
+22 -20
View File
@@ -10,38 +10,40 @@
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
},
"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-dom": "^18.3.1",
"react-router-dom": "^6.26.2",
"@tanstack/react-query": "^5.56.2",
"axios": "^1.7.7",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"lucide-react": "^0.441.0",
"sonner": "^1.5.0",
"tailwind-merge": "^2.5.2",
"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"
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.20",
"@vitejs/plugin-react": "^4.7.0",
"autoprefixer": "^10.4.21",
"eslint": "^8.57.0",
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-react-refresh": "^0.4.11",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.11",
"typescript": "^5.6.2",
"vite": "^5.4.5"
"postcss": "^8.5.6",
"tailwindcss": "^3.4.18",
"typescript": "^5.9.3",
"vite": "^5.4.21"
}
}
+5 -2
View File
@@ -9,6 +9,7 @@ import PlaylistDetailPage from './components/playlist/PlaylistDetailPage'
import DownloadCenter from './components/download/DownloadCenter'
import ArtistsPage from './components/artist/ArtistsPage'
import ArtistDetailPage from './components/artist/ArtistDetailPage'
import SettingsPage from './components/settings/SettingsPage'
import Navigation from './components/Navigation'
import { Toaster } from 'sonner'
@@ -132,9 +133,10 @@ function App() {
useEffect(() => {
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')
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) {
audioRef.current.play()
}
@@ -164,6 +166,7 @@ function App() {
<Route path="/playlists" element={<PlaylistsPage onPlayMusic={playMusic} />} />
<Route path="/playlists/:playlistId" element={<PlaylistDetailPage onPlayMusic={playMusic} />} />
<Route path="/downloads" element={<DownloadCenter onPlayMusic={playMusic} />} />
<Route path="/settings" element={<SettingsPage />} />
</Routes>
</main>
+10 -1
View File
@@ -14,6 +14,7 @@ export const musicApi = {
getAll: () => api.get('/music/'),
search: (query: string) => api.get('/music/search', { params: { q: query } }),
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),
delete: (id: number) => api.delete(`/music/${id}`),
upload: (file: File) => {
@@ -66,5 +67,13 @@ export const searchApi = {
export const artistApi = {
getAll: () => api.get('/artists/'),
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'),
}
+29 -4
View File
@@ -1,9 +1,11 @@
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import { useState } from 'react'
import { musicApi } from '@/api/client'
import { Music } from '@/types'
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 {
onPlayMusic: (music: Music, playlist: Music[]) => void
@@ -11,6 +13,8 @@ interface MusicLibraryProps {
export default function MusicLibrary({ onPlayMusic }: MusicLibraryProps) {
const navigate = useNavigate()
const [selectedMusicId, setSelectedMusicId] = useState<number | null>(null)
const { data: musicList = [], isLoading } = useQuery({
queryKey: ['music'],
queryFn: async () => {
@@ -50,13 +54,19 @@ export default function MusicLibrary({ onPlayMusic }: MusicLibraryProps) {
size="icon"
variant="ghost"
onClick={() => onPlayMusic(music, musicList)}
disabled={!music.file_exists}
>
<Play className="h-5 w-5" />
</Button>
</div>
<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' ? (
<button
onClick={() => navigate(`/artists/${encodeURIComponent(music.artist!)}`)}
@@ -71,12 +81,27 @@ export default function MusicLibrary({ onPlayMusic }: MusicLibraryProps) {
)}
</div>
<div className="text-sm text-muted-foreground">
{music.duration && `${Math.floor(music.duration / 60)}:${String(Math.floor(music.duration % 60)).padStart(2, '0')}`}
<div className="flex items-center gap-2">
<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>
<MusicDetailModal
musicId={selectedMusicId}
open={selectedMusicId !== null}
onClose={() => setSelectedMusicId(null)}
/>
</div>
)
}
+2 -1
View File
@@ -1,5 +1,5 @@
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'
interface NavigationProps {
@@ -16,6 +16,7 @@ export default function Navigation({ onToggleTheme, theme }: NavigationProps) {
{ path: '/artists', label: 'Artists', icon: Music2 },
{ path: '/playlists', label: 'Playlists', icon: ListMusic },
{ path: '/downloads', label: 'Downloads', icon: Download },
{ path: '/settings', label: 'Settings', icon: Settings },
]
return (
@@ -56,7 +56,7 @@ export default function ArtistDetailPage({ onPlayMusic }: ArtistDetailProps) {
<>
<div
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" />
</>
@@ -83,12 +83,17 @@ export default function ArtistDetailPage({ onPlayMusic }: ArtistDetailProps) {
src={artistInfo.image.startsWith('http') ? artistInfo.image : `/${artistInfo.image}`}
alt={decodedArtistName}
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'
}}
/>
) : (
<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">
<Music2 className="h-20 w-20 text-white/80" />
</div>
)}
) : 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" style={{ display: artistInfo?.image ? 'none' : 'flex' }}>
<Music2 className="h-20 w-20 text-white/80" />
</div>
</div>
<div className="flex-1 text-white">
@@ -75,12 +75,16 @@ export default function ArtistsPage() {
src={artistImage.startsWith('http') ? artistImage : `/${artistImage}`}
alt={artist.name}
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'
}}
/>
) : (
<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">
<Music2 className="h-12 w-12 text-primary" />
</div>
)}
) : 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" style={{ display: artistImage ? 'none' : 'flex' }}>
<Music2 className="h-12 w-12 text-primary" />
</div>
</div>
<h3 className="font-semibold truncate mb-1">{artist.name}</h3>
<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
const streamUrl = `/api/stream?url=${encodeURIComponent(url)}`
const tempMusic: Music = {
id: 0, // Temporary ID
id: 0,
title,
artist: artist || null,
album: null,
duration: null,
file_path: streamUrl, // Use streaming endpoint
file_path: streamUrl,
file_size: null,
source_url: url,
source_type: url.includes('youtube') ? 'youtube' : 'bilibili',
@@ -67,6 +67,10 @@ export default function SearchPage({ onPlayMusic }: SearchPageProps) {
lyrics: null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
file_format: null,
file_location: null,
file_exists: true,
last_scanned_at: null,
}
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>
)
}
+79
View File
@@ -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 }
+120
View File
@@ -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,
}
+24
View File
@@ -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 }
+25
View File
@@ -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 }
+27
View File
@@ -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 }
+29
View File
@@ -6,6 +6,10 @@ export interface Music {
duration: number | null
file_path: string
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_type: string | null
thumbnail: string | null
@@ -14,6 +18,31 @@ export interface Music {
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 {
id: number
name: string
+22
View File
@@ -12,6 +12,16 @@ This guide explains how to deploy YouMusic to your k3s cluster.
## 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
```bash
@@ -74,6 +84,9 @@ kubectl get ingress youmusic-ingress
# Check pod logs
kubectl logs -f deployment/youmusic
# Check migration init container logs
kubectl logs -l app=youmusic -c youmusic-migrations
# Check if pod is running
kubectl get pods
@@ -293,6 +306,9 @@ kubectl logs deployment/youmusic
### Database Issues
```bash
# Check migration init container logs
kubectl logs -l app=youmusic -c youmusic-migrations
# Exec into pod
kubectl exec -it deployment/youmusic -- bash
@@ -302,6 +318,12 @@ ls -la youmusic.db
# Check SQLite
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
+30
View File
@@ -50,6 +50,32 @@ spec:
- name: data
persistentVolumeClaim:
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:
- name: youmusic
image: "ghcr.io/wahyd4/youmusic:latest" # Update with your image
@@ -60,10 +86,14 @@ spec:
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: LOCAL_MUSIC_DIR
value: "/app/data/local-music"
- name: UPLOAD_DIR
value: "/app/data/uploads"
- name: TEMP_DIR