From 9c6ed82a56fecd146337528391f3a7d27ce9bfdd Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Thu, 30 Oct 2025 22:35:02 +1100 Subject: [PATCH] add db migration --- .gitignore | 3 + AGENTS.md | 45 +- Dockerfile | 7 +- MIGRATIONS.md | 264 ++++++++++++ QUICKSTART.md | 142 ------- README.md | 57 ++- backend/MIGRATION_QUICKREF.md | 50 +++ backend/alembic.ini | 148 +++++++ backend/alembic/README | 1 + backend/alembic/env.py | 100 +++++ backend/alembic/script.py.mako | 28 ++ .../01209c730b33_initial_migration.py | 58 +++ ..._populate_file_location_and_file_format.py | 51 +++ .../c1fd223a3556_add_file_tracking_columns.py | 44 ++ backend/app/api/artist.py | 48 ++- backend/app/api/download.py | 6 +- backend/app/api/music.py | 66 ++- backend/app/api/settings.py | 132 ++++++ backend/app/core/config.py | 17 + backend/app/db/session.py | 3 + backend/app/models/models.py | 15 +- backend/app/schemas/schemas.py | 57 +++ backend/app/services/downloader.py | 10 +- backend/app/services/scanner.py | 394 ++++++++++++++++++ backend/app/services/scheduler.py | 95 +++++ backend/main.py | 54 ++- backend/migrate.sh | 79 ++++ backend/pyproject.toml | 2 + backend/requirements.txt | 2 + backend/run-migrations.sh | 12 + backend/start.sh | 14 + dev-backend.sh | 34 -- dev-frontend.sh | 29 -- dev-setup.sh | 4 +- dev-stack.sh | 148 ++++++- dev-stop.sh | 30 +- docker-compose.yml | 9 +- frontend/package-lock.json | 68 ++- frontend/package.json | 42 +- frontend/src/App.tsx | 7 +- frontend/src/api/client.ts | 11 +- frontend/src/components/MusicLibrary.tsx | 33 +- frontend/src/components/Navigation.tsx | 3 +- .../components/artist/ArtistDetailPage.tsx | 17 +- .../src/components/artist/ArtistsPage.tsx | 14 +- .../src/components/music/MusicDetailModal.tsx | 196 +++++++++ frontend/src/components/search/SearchPage.tsx | 8 +- .../src/components/settings/SettingsPage.tsx | 301 +++++++++++++ frontend/src/components/ui/card.tsx | 79 ++++ frontend/src/components/ui/dialog.tsx | 120 ++++++ frontend/src/components/ui/label.tsx | 24 ++ frontend/src/components/ui/progress.tsx | 25 ++ frontend/src/components/ui/switch.tsx | 27 ++ frontend/src/types/index.ts | 29 ++ k8s/README.md | 22 + k8s/manifest.yaml | 30 ++ 56 files changed, 3009 insertions(+), 305 deletions(-) create mode 100644 MIGRATIONS.md delete mode 100644 QUICKSTART.md create mode 100644 backend/MIGRATION_QUICKREF.md create mode 100644 backend/alembic.ini create mode 100644 backend/alembic/README create mode 100644 backend/alembic/env.py create mode 100644 backend/alembic/script.py.mako create mode 100644 backend/alembic/versions/01209c730b33_initial_migration.py create mode 100644 backend/alembic/versions/49793394b596_populate_file_location_and_file_format.py create mode 100644 backend/alembic/versions/c1fd223a3556_add_file_tracking_columns.py create mode 100644 backend/app/api/settings.py create mode 100644 backend/app/services/scanner.py create mode 100644 backend/app/services/scheduler.py create mode 100755 backend/migrate.sh create mode 100755 backend/run-migrations.sh create mode 100755 backend/start.sh delete mode 100755 dev-backend.sh delete mode 100755 dev-frontend.sh create mode 100644 frontend/src/components/music/MusicDetailModal.tsx create mode 100644 frontend/src/components/settings/SettingsPage.tsx create mode 100644 frontend/src/components/ui/card.tsx create mode 100644 frontend/src/components/ui/dialog.tsx create mode 100644 frontend/src/components/ui/label.tsx create mode 100644 frontend/src/components/ui/progress.tsx create mode 100644 frontend/src/components/ui/switch.tsx diff --git a/.gitignore b/.gitignore index 3b880ed..263889e 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,9 @@ frontend/.vite/ *.db *.sqlite +# Alembic - Keep migration files but not pycache +backend/alembic/versions/__pycache__/ + # Data directories data/ *.mp3 diff --git a/AGENTS.md b/AGENTS.md index 658c235..ab48a55 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/Dockerfile b/Dockerfile index d72d685..6be603e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/MIGRATIONS.md b/MIGRATIONS.md new file mode 100644 index 0000000..1c0401f --- /dev/null +++ b/MIGRATIONS.md @@ -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 # 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 +``` + +### 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 + +# 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 + -> 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 diff --git a/QUICKSTART.md b/QUICKSTART.md deleted file mode 100644 index 57d2f0b..0000000 --- a/QUICKSTART.md +++ /dev/null @@ -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 -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 diff --git a/README.md b/README.md index 002dfc2..9ee0cf0 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/backend/MIGRATION_QUICKREF.md b/backend/MIGRATION_QUICKREF.md new file mode 100644 index 0000000..81226e6 --- /dev/null +++ b/backend/MIGRATION_QUICKREF.md @@ -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` diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..777cd18 --- /dev/null +++ b/backend/alembic.ini @@ -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 /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 diff --git a/backend/alembic/README b/backend/alembic/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/backend/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..a2eda90 --- /dev/null +++ b/backend/alembic/env.py @@ -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() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..1101630 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -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"} diff --git a/backend/alembic/versions/01209c730b33_initial_migration.py b/backend/alembic/versions/01209c730b33_initial_migration.py new file mode 100644 index 0000000..fd294ec --- /dev/null +++ b/backend/alembic/versions/01209c730b33_initial_migration.py @@ -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 ### diff --git a/backend/alembic/versions/49793394b596_populate_file_location_and_file_format.py b/backend/alembic/versions/49793394b596_populate_file_location_and_file_format.py new file mode 100644 index 0000000..4f4d629 --- /dev/null +++ b/backend/alembic/versions/49793394b596_populate_file_location_and_file_format.py @@ -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 diff --git a/backend/alembic/versions/c1fd223a3556_add_file_tracking_columns.py b/backend/alembic/versions/c1fd223a3556_add_file_tracking_columns.py new file mode 100644 index 0000000..e7ba1f7 --- /dev/null +++ b/backend/alembic/versions/c1fd223a3556_add_file_tracking_columns.py @@ -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 ### diff --git a/backend/app/api/artist.py b/backend/app/api/artist.py index b058bc3..142c31a 100644 --- a/backend/app/api/artist.py +++ b/backend/app/api/artist.py @@ -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) diff --git a/backend/app/api/download.py b/backend/app/api/download.py index fe417c7..7abe6ec 100644 --- a/backend/app/api/download.py +++ b/backend/app/api/download.py @@ -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 diff --git a/backend/app/api/music.py b/backend/app/api/music.py index 4643973..4dbfcbc 100644 --- a/backend/app/api/music.py +++ b/backend/app/api/music.py @@ -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) + ) diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py new file mode 100644 index 0000000..f19d3c4 --- /dev/null +++ b/backend/app/api/settings.py @@ -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"] + ) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 384e359..fe0abda 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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}") diff --git a/backend/app/db/session.py b/backend/app/db/session.py index 49d4d6c..b1c302d 100644 --- a/backend/app/db/session.py +++ b/backend/app/db/session.py @@ -14,6 +14,9 @@ AsyncSessionLocal = async_sessionmaker( expire_on_commit=False, ) +# Alias for compatibility +async_session_maker = AsyncSessionLocal + Base = declarative_base() diff --git a/backend/app/models/models.py b/backend/app/models/models.py index bf86def..d4d9b93 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -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) diff --git a/backend/app/schemas/schemas.py b/backend/app/schemas/schemas.py index 0f20d6b..6231d32 100644 --- a/backend/app/schemas/schemas.py +++ b/backend/app/schemas/schemas.py @@ -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 diff --git a/backend/app/services/downloader.py b/backend/app/services/downloader.py index e8d9ff9..e976047 100644 --- a/backend/app/services/downloader.py +++ b/backend/app/services/downloader.py @@ -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) diff --git a/backend/app/services/scanner.py b/backend/app/services/scanner.py new file mode 100644 index 0000000..ba2ca62 --- /dev/null +++ b/backend/app/services/scanner.py @@ -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 diff --git a/backend/app/services/scheduler.py b/backend/app/services/scheduler.py new file mode 100644 index 0000000..2c3e8e8 --- /dev/null +++ b/backend/app/services/scheduler.py @@ -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() diff --git a/backend/main.py b/backend/main.py index bc612a4..11063ee 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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) diff --git a/backend/migrate.sh b/backend/migrate.sh new file mode 100755 index 0000000..6ee6c92 --- /dev/null +++ b/backend/migrate.sh @@ -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 [options]" + echo "" + echo "Commands:" + echo " create 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!" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index faead8d..8918078 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -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] diff --git a/backend/requirements.txt b/backend/requirements.txt index cf2e74a..308f41f 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -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 diff --git a/backend/run-migrations.sh b/backend/run-migrations.sh new file mode 100755 index 0000000..6043739 --- /dev/null +++ b/backend/run-migrations.sh @@ -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!" diff --git a/backend/start.sh b/backend/start.sh new file mode 100755 index 0000000..f05c197 --- /dev/null +++ b/backend/start.sh @@ -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 diff --git a/dev-backend.sh b/dev-backend.sh deleted file mode 100755 index 0a0f8c9..0000000 --- a/dev-backend.sh +++ /dev/null @@ -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 diff --git a/dev-frontend.sh b/dev-frontend.sh deleted file mode 100755 index d190414..0000000 --- a/dev-frontend.sh +++ /dev/null @@ -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 diff --git a/dev-setup.sh b/dev-setup.sh index a09b12d..58a451c 100755 --- a/dev-setup.sh +++ b/dev-setup.sh @@ -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}" diff --git a/dev-stack.sh b/dev-stack.sh index d62febb..bacbcae 100755 --- a/dev-stack.sh +++ b/dev-stack.sh @@ -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 .. diff --git a/dev-stop.sh b/dev-stop.sh index 24ed3c3..1a0d60e 100755 --- a/dev-stop.sh +++ b/dev-stop.sh @@ -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" + diff --git a/docker-compose.yml b/docker-compose.yml index 8ba4105..8907871 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 24fe6f3..ae6202c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 9bce694..90269e8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0f2cb1f..e0026ad 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> } /> } /> + } /> diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 6684438..d971d25 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -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'), } diff --git a/frontend/src/components/MusicLibrary.tsx b/frontend/src/components/MusicLibrary.tsx index f6661e1..1d95d48 100644 --- a/frontend/src/components/MusicLibrary.tsx +++ b/frontend/src/components/MusicLibrary.tsx @@ -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(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} >
-

{music.title}

+
+

{music.title}

+ {!music.file_exists && ( + + )} +
{music.artist && music.artist !== 'Unknown' ? (
-
- {music.duration && `${Math.floor(music.duration / 60)}:${String(Math.floor(music.duration % 60)).padStart(2, '0')}`} +
+
+ {music.duration && `${Math.floor(music.duration / 60)}:${String(Math.floor(music.duration % 60)).padStart(2, '0')}`} +
+
))} + + setSelectedMusicId(null)} + /> ) } diff --git a/frontend/src/components/Navigation.tsx b/frontend/src/components/Navigation.tsx index 6fb405a..13fda8f 100644 --- a/frontend/src/components/Navigation.tsx +++ b/frontend/src/components/Navigation.tsx @@ -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 ( diff --git a/frontend/src/components/artist/ArtistDetailPage.tsx b/frontend/src/components/artist/ArtistDetailPage.tsx index 67424f3..b9e620e 100644 --- a/frontend/src/components/artist/ArtistDetailPage.tsx +++ b/frontend/src/components/artist/ArtistDetailPage.tsx @@ -56,7 +56,7 @@ export default function ArtistDetailPage({ onPlayMusic }: ArtistDetailProps) { <>
@@ -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' + }} /> - ) : ( -
- -
- )} + ) : null} +
+ +
diff --git a/frontend/src/components/artist/ArtistsPage.tsx b/frontend/src/components/artist/ArtistsPage.tsx index 214dd32..75a0a88 100644 --- a/frontend/src/components/artist/ArtistsPage.tsx +++ b/frontend/src/components/artist/ArtistsPage.tsx @@ -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' + }} /> - ) : ( -
- -
- )} + ) : null} +
+ +

{artist.name}

diff --git a/frontend/src/components/music/MusicDetailModal.tsx b/frontend/src/components/music/MusicDetailModal.tsx new file mode 100644 index 0000000..8500ce3 --- /dev/null +++ b/frontend/src/components/music/MusicDetailModal.tsx @@ -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({ + 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 ( +

+ + + Music Details + + Detailed information about this music file + + + + {isLoading ? ( +
+ +
+ ) : musicInfo ? ( +
+ {/* Thumbnail */} + {musicInfo.thumbnail && ( +
+ {musicInfo.title} +
+ )} + + {/* Basic Info */} +
+
+ +

{musicInfo.title}

+
+ +
+ +

{musicInfo.artist || 'Unknown'}

+
+ + {musicInfo.album && ( +
+ +

{musicInfo.album}

+
+ )} + +
+ +

{formatDuration(musicInfo.duration)}

+
+
+ + {/* File Info */} +
+

+ + File Information +

+ +
+
+ +

{musicInfo.file_format || 'Unknown'}

+
+ +
+ +

{formatFileSize(musicInfo.file_size)}

+
+ +
+ +

{musicInfo.source_type || 'Unknown'}

+
+ +
+ +

+ {musicInfo.file_exists ? ( + <> + + Available + + ) : ( + <> + + Missing + + )} +

+
+ +
+ +

+ {musicInfo.file_location || 'Not available'} +

+
+
+
+ + {/* Timestamps */} +
+

+ + Timestamps +

+ +
+
+ +

{new Date(musicInfo.created_at).toLocaleString()}

+
+ +
+ +

{new Date(musicInfo.updated_at).toLocaleString()}

+
+ + {musicInfo.last_scanned_at && ( +
+ +

{new Date(musicInfo.last_scanned_at).toLocaleString()}

+
+ )} +
+
+ + {/* Source URL */} + {musicInfo.source_url && ( + + )} +
+ ) : null} +
+
+ ) +} diff --git a/frontend/src/components/search/SearchPage.tsx b/frontend/src/components/search/SearchPage.tsx index 07e8fe2..650bab1 100644 --- a/frontend/src/components/search/SearchPage.tsx +++ b/frontend/src/components/search/SearchPage.tsx @@ -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) diff --git a/frontend/src/components/settings/SettingsPage.tsx b/frontend/src/components/settings/SettingsPage.tsx new file mode 100644 index 0000000..63dc4c9 --- /dev/null +++ b/frontend/src/components/settings/SettingsPage.tsx @@ -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>({}) + const [scanIntervalHours, setScanIntervalHours] = useState(1) + + const { data: settings, isLoading } = useQuery({ + queryKey: ['settings'], + queryFn: async () => { + const response = await settingsApi.get() + return response.data + } + }) + + const { data: scanStatus, refetch: refetchScanStatus } = useQuery({ + 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) => { + 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 ( +
+ +
+ ) + } + + return ( +
+

Settings

+ + {/* Music Directory Settings */} + + + Music Directories + + Configure where your music files are stored + + + +
+ + setLocalSettings({ ...localSettings, local_music_dir: e.target.value })} + className="mt-2" + /> +

+ Additional directory to scan for music files (optional) +

+
+
+
+ + {/* Scanner Settings */} + + + Automatic Scanning + + Configure how often to scan for music files + + + +
+
+ +

+ Automatically scan for new music files +

+
+ + setLocalSettings({ ...localSettings, auto_scan_enabled: checked }) + } + /> +
+ +
+ + setScanIntervalHours(value)} + className="mt-2" + disabled={!localSettings.auto_scan_enabled} + /> +
+ 30 min + 24 hours +
+
+ +
+
+ +

+ Remove database entries for missing files +

+
+ + setLocalSettings({ ...localSettings, delete_missing_files: checked }) + } + /> +
+ + {settings?.last_scan_at && ( +
+ + Last scan: {new Date(settings.last_scan_at).toLocaleString()} +
+ )} +
+
+ + {/* Scan Status */} + {scanStatus && ( + + + Scan Status + + + {scanStatus.is_scanning ? ( + <> +
+ + Scanning in progress... +
+ {scanStatus.total && scanStatus.total > 0 && ( +
+ +

+ {scanStatus.progress} / {scanStatus.total} files +

+
+ )} + {scanStatus.current_file && ( +

+ Current: {scanStatus.current_file} +

+ )} + + ) : ( +
+
+ + Ready +
+ {scanStatus.completed_at && ( +
+
+ Added:{' '} + {scanStatus.files_added} +
+
+ Updated:{' '} + {scanStatus.files_updated} +
+
+ Missing:{' '} + {scanStatus.files_missing} +
+
+ Errors:{' '} + {scanStatus.errors.length} +
+
+ )} + {scanStatus.errors.length > 0 && ( +
+

Errors:

+
+ {scanStatus.errors.map((error, i) => ( +

{error}

+ ))} +
+
+ )} +
+ )} +
+
+ )} + + {/* Actions */} +
+ + +
+
+ ) +} diff --git a/frontend/src/components/ui/card.tsx b/frontend/src/components/ui/card.tsx new file mode 100644 index 0000000..afa13ec --- /dev/null +++ b/frontend/src/components/ui/card.tsx @@ -0,0 +1,79 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +Card.displayName = "Card" + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardHeader.displayName = "CardHeader" + +const CardTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardTitle.displayName = "CardTitle" + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardDescription.displayName = "CardDescription" + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardContent.displayName = "CardContent" + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardFooter.displayName = "CardFooter" + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } diff --git a/frontend/src/components/ui/dialog.tsx b/frontend/src/components/ui/dialog.tsx new file mode 100644 index 0000000..196200c --- /dev/null +++ b/frontend/src/components/ui/dialog.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)) +DialogContent.displayName = DialogPrimitive.Content.displayName + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogHeader.displayName = "DialogHeader" + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogFooter.displayName = "DialogFooter" + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogTitle.displayName = DialogPrimitive.Title.displayName + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogDescription.displayName = DialogPrimitive.Description.displayName + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +} diff --git a/frontend/src/components/ui/label.tsx b/frontend/src/components/ui/label.tsx new file mode 100644 index 0000000..683faa7 --- /dev/null +++ b/frontend/src/components/ui/label.tsx @@ -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, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, ...props }, ref) => ( + +)) +Label.displayName = LabelPrimitive.Root.displayName + +export { Label } diff --git a/frontend/src/components/ui/progress.tsx b/frontend/src/components/ui/progress.tsx new file mode 100644 index 0000000..3a141cc --- /dev/null +++ b/frontend/src/components/ui/progress.tsx @@ -0,0 +1,25 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Progress = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & { value?: number } +>(({ className, value, ...props }, ref) => ( +
+
+
+)) +Progress.displayName = "Progress" + +export { Progress } diff --git a/frontend/src/components/ui/switch.tsx b/frontend/src/components/ui/switch.tsx new file mode 100644 index 0000000..aa58baa --- /dev/null +++ b/frontend/src/components/ui/switch.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +Switch.displayName = SwitchPrimitives.Root.displayName + +export { Switch } diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index a560a14..87192bd 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -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 diff --git a/k8s/README.md b/k8s/README.md index 0f0192f..4ef7da0 100644 --- a/k8s/README.md +++ b/k8s/README.md @@ -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 diff --git a/k8s/manifest.yaml b/k8s/manifest.yaml index c9e6882..946c09d 100644 --- a/k8s/manifest.yaml +++ b/k8s/manifest.yaml @@ -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